How to get Android Documents folder storage permissions in Flutter?

Viewed 162

In my flutter app, I am trying to save a file downloaded from an API. It is sufficient to save the file to the Downloads directory, regardless of the file type.

My Android compileSDK = 32. I understand that permissions_handler 10.0.0 does not support the API 33 permissions. I downgraded to 9.2.0 and it compiles.

path_provider 2.0.11 does not support Android directories so I hard coded the path.

I am using

dio: ^4.0.6
path_provider: ^2.0.11
permission_handler: ^10.0.1

I get the following permissions error in the android studio console when I try to save the file. Request log included for completeness.

I/flutter (11956): app documents path: /data/user/0/com.example/app_flutter/fatsquid.jpg
I/flutter (11956): permission status: PermissionStatus.granted
I/flutter (11956): app external storage path: /storage/emulated/0/Android/data/com.example/files/fatsquid.jpg
I/flutter (11956): hard path string: /storage/emulated/0/Download/fatsquid.jpg
I/flutter (11956): actual path used: /storage/emulated/0/Download/fatsquid.jpg
I/flutter (11956): file save path
I/flutter (11956): /storage/emulated/0/Download/fatsquid.jpg
I/flutter (11956): *** Request ***
I/flutter (11956): uri: https://api.example.com/transcript/download/transcript/file/1
I/flutter (11956): method: GET
I/flutter (11956): responseType: ResponseType.stream
I/flutter (11956): followRedirects: true
I/flutter (11956): connectTimeout: 0
I/flutter (11956): sendTimeout: 0
I/flutter (11956): receiveTimeout: 0
I/flutter (11956): receiveDataWhenStatusError: true
I/flutter (11956): extra: {}
I/flutter (11956): headers:
I/flutter (11956):  authorization: Bearer secret
I/flutter (11956): 
I/flutter (11956): *** Response ***
I/flutter (11956): uri: https://api.example.com/transcript/download/transcript/file/1
I/flutter (11956): statusCode: 200
I/flutter (11956): headers:
I/flutter (11956):  content-type: application/octet-stream
I/flutter (11956):  date: Fri, 23 Sep 2022 06:55:43 GMT
I/flutter (11956):  vary: Origin
I/flutter (11956):  content-length: 497741
I/flutter (11956): 
E/flutter (11956): [ERROR:flutter/runtime/dart_vm_initializer.cc(41)] Unhandled Exception: FileSystemException: Cannot create file, path = '/storage/emulated/0/Download/fatsquid.jpg' (OS Error: Permission denied, errno = 13)

Here is my code:

class FileDownloadView extends StatefulWidget {
  const FileDownloadView({super.key});

  @override
  State<FileDownloadView> createState() => _FileDownloadViewState();
}

class _FileDownloadViewState extends State<FileDownloadView> {
  @override
  void initState() {
    WidgetsBinding.instance.addPostFrameCallback(
      (_) => showSnackBar(context),
    );
    super.initState();
  }

  String received = "0";

  String progress = "0";

  bool downloading = false;

  bool isDownloaded = false;

  String filename = 'file-name-not-set';

  @override
  Widget build(BuildContext context) {
    User user = Provider.of<User>(context, listen: false);

    Company company = Provider.of<Company>(context, listen: false);

    filename = user.downloadFileName;

    return Scaffold(
      backgroundColor: Theme.of(context).backgroundColor,
      resizeToAvoidBottomInset: false,
      appBar: AppBar(
        centerTitle: true,
        title: Text(
          company.companyName,
          maxLines: 1,
          overflow: TextOverflow.ellipsis,
          style: TextStyle(
              color: appTextColor,
              fontSize: user.fontsize,
              fontWeight: FontWeight.normal),
        ),
        automaticallyImplyLeading: false,
        leading: IconButton(
          icon: Icon(Icons.arrow_back),
          onPressed: () => {
            Navigator.of(context)
                .pushNamedAndRemoveUntil(RoutePaths.matter, (route) => false)
          },
        ),
      ),
      body: Container(
        padding: EdgeInsets.all(12.0),
        decoration: BoxDecoration(
          gradient: LinearGradient(
              colors: [appBackgroundColorStart, appBackgroundColorEnd],
              begin: Alignment.topCenter,
              end: Alignment.bottomCenter),
        ),
        child: SingleChildScrollView(
          child: Center(
            child: Column(
              crossAxisAlignment: CrossAxisAlignment.center,
              children: [
                SizedBox(height: 24),
                SizedBox(
                  width: double.infinity,
                  child: Text(
                    'Download $filename: $received',
                    maxLines: 4,
                    overflow: TextOverflow.ellipsis,
                    textAlign: TextAlign.center,
                    style: TextStyle(
                        color: appTextColor,
                        fontSize: user.fontsize,
                        fontWeight: FontWeight.normal),
                  ),
                ),
                SizedBox(height: 24),
                ElevatedButton.icon(
                  onPressed: () {
                    downloadMobileFile(user);
                  },
                  icon: const Icon(Icons.download),
                  label: const Text('Download'),
                ),
              ],
            ),
          ),
        ),
      ),
    );
  }

  Future<void> downloadMobileFile(User user) async {
    log('downloading with mobile function ');
    setState(
      () {
        downloading = true;
        filename = user.downloadFileName;
      },
    );

    checkWritePermission();

    String savePath = await getFileSavePath(user.downloadFileName);

    print("file save path");
    print(savePath);

    final storage = FlutterSecureStorage();

    String? token = await storage.read(key: 'jwt');

    Dio dio = Dio();

    dio.interceptors.add(LogInterceptor(responseBody: false));

    dio.download(
      user.fileUrl,
      savePath,
      options: Options(
        headers: {HttpHeaders.authorizationHeader: 'Bearer $token'},
      ),
      onReceiveProgress: (rcv, total) {
        setState(
          () {
            progress = ((rcv / total) * 100).toStringAsFixed(0);
            received =
                'received: ${rcv.toStringAsFixed(0)} out of total: ${total.toStringAsFixed(0)} $progress%';
          },
        );
        if (progress == '100') {
          setState(
            () {
              isDownloaded = true;
            },
          );
        } else if (double.parse(progress) < 100) {}
      },
      deleteOnError: true,
    ).then(
      (_) {
        print('download progress: $progress');
        print('is the file downloaded: $isDownloaded');

        setState(
          () {
            if (progress == '100') {
              isDownloaded = true;
            }
            downloading = false;
          },
        );
      },
    );
  }

  static Future<void> checkWritePermission() async {
    if (!kIsWeb) {
      if (Platform.isAndroid || Platform.isIOS) {
        var permissionStatus = await Permission.storage.status;

        print('permission status: $permissionStatus');

        switch (permissionStatus) {
          case PermissionStatus.denied:
          case PermissionStatus.permanentlyDenied:
            await Permission.storage.request();
            break;
          default:
        }
      }
    }
  }

  Future<String> getFileSavePath(String uniqueFileName) async {
    String path = '';
    Directory appDocDir = await getApplicationDocumentsDirectory();
    String appDocPath = appDocDir.path + '/' + uniqueFileName;
    print('app documents path: $appDocPath');

    final Directory? externalDir = await getExternalStorageDirectory();
    String externalPath = externalDir!.path + '/' + uniqueFileName;
    print('app external storage path: $externalPath');

    Platform.isAndroid
        ? path = '/storage/emulated/0/Download/$uniqueFileName'
        : path = '$appDocDir.path/$uniqueFileName';

    print('hard path string: $path');

    print('actual path used: $path');

    return path;
  }
}

Edit: I updated this code to incorporate suggestions below and edited my manifest to include this code below:

<manifest xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://schemas.android.com/tools"
    package="com.example">

    <uses-permission android:name="android.permission.INTERNET"/>
    <uses-permission android:name="android.permission.READ_INTERNAL_STORAGE" />
    <uses-permission android:name="android.permission.WRITE_INTERNAL_STORAGE" />

    <uses-permission
        android:name="android.permission.WRITE_EXTERNAL_STORAGE"
        android:maxSdkVersion="28"
        tools:ignore="ScopedStorage" />

   <application
        android:label="example"
        android:name="${applicationName}"
        android:requestLegacyExternalStorage="true"
        android:icon="@mipmap/ic_launcher">
        <activity
            android:name=".MainActivity"
            android:exported="true"
            android:launchMode="singleTop"
            android:theme="@style/LaunchTheme"
            android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
            android:hardwareAccelerated="true"
            android:windowSoftInputMode="adjustResize">
            <meta-data
              android:name="io.flutter.embedding.android.NormalTheme"
              android:resource="@style/NormalTheme"
              />
            <intent-filter>
                <action android:name="android.intent.action.MAIN"/>
                <category android:name="android.intent.category.LAUNCHER"/>
            </intent-filter>
        </activity>
        <!-- Dont delete the meta-data below.
             This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
        <meta-data
            android:name="flutterEmbedding"
            android:value="2" />
    </application>
</manifest>

I am able to save the file successfully to /storage/emulated/0/Android/data/com.example/files/fatsquid.jpg which is supplied by getExternalStorageDirectory() but is not a satisfactory solution as most users would have difficulty locating the file. It needs to be in the Documents folder which I understand to be the path I have hard coded.

4 Answers

This method Works for me :

static Future<void> checkPermission() async {

    if (!kIsWeb) {
      if (Platform.isAndroid || Platform.isIOS) {
        var permissionStatus = await Permission.storage.status;

        switch (permissionStatus) {
          case PermissionStatus.denied:
          case PermissionStatus.permanentlyDenied:
            await Permission.storage.request();
            break;
          default:
        }
      }
    }
  }

And in manifest you need to add the permissions :

<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />

<uses-permission
        android:name="android.permission.WRITE_EXTERNAL_STORAGE"
        android:maxSdkVersion="28"
        tools:ignore="ScopedStorage" />

And inside application :

android:requestLegacyExternalStorage="true"

Edit

<application
        android:label="my app"
        tools:replace="android:allowBackup,android:icon,android:label,android:roundIcon,android:theme"
        android:isGame="false"
        android:testOnly="false"
        android:allowBackup="false"
        android:logo="@mipmap/launcher_icon"
        android:icon="@mipmap/launcher_icon"
        android:roundIcon="@mipmap/launcher_icon"
        android:theme="@style/LaunchTheme"
        android:largeHeap="true"
        android:supportsRtl="true"
        android:usesCleartextTraffic="true"
        android:requestLegacyExternalStorage="true"
        tools:ignore="GoogleAppIndexingWarning">

For tools error , you can put the propeery tools in xml root node.

<manifest xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://schemas.android.com/tools"
    package="com.example"
    xmlns:dist="http://schemas.android.com/apk/distribution">

Since you are already using path_provider, you can just use Application Documents Directory to store the file. That way, you won't have to deal with storage permission.

import 'package:path_provider/path_provider.dart';

Directory appDocDir = await getApplicationDocumentsDirectory();
String appDocPath = appDocDir.path;

With the help of dio package which also you are already using, downloading and storing file is super easy.

import 'package:dio/dio.dart';

final Dio dio = Dio();
String ext = 'pdf';

// Download and store file
dio.download(yourFileUrl, '$appDocPath/$fileName.$ext').then((value) {
   // any you want to do after download is finished
});

To read the downloaded files from the Application Documents Directory again. You can do this

import 'dart:io';

Directory(appDocPath).exists().then((exists) {
List<FileSystemEntity> files = [];
  if (exists) {
    files = Directory(appDocPath).listSync();
  }

  return files;
});

Follow the steps, use provide_paths.xml to access to storage

<?xml version="1.0" encoding="utf-8"?>
<paths xmlns:android="http://schemas.android.com/apk/res/android">
    <external-path
        name="external_files"
        path="." />
    <root-path
        name="external_files"
        path="/storage/" />
</paths>

AndroidManifest.xml

<uses-permission android:name="android.permission.READ_INTERNAL_STORAGE" />
<uses-permission android:name="android.permission.WRITE_INTERNAL_STORAGE" />
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" tools:ignore="ScopedStorage" />
<application

 <provider
            android:name="androidx.core.content.FileProvider"
            android:authorities="${applicationId}.provider"
            android:exported="false"
            android:enabled="true"
            android:initOrder="100"
            android:permission="android.permission.MANAGE_DOCUMENTS"
            android:grantUriPermissions="true">
            <meta-data
                android:name="android.support.FILE_PROVIDER_PATHS"
                android:resource="@xml/provide_paths" />
        </provider>
</application>

For Android compileSDK 33 The solution is not to use WRITE_EXTERNAL_STORAGE but use the flutter saf package. My AndroidManifest.xml has no changes from default.

build.gradle:

android {
    compileSdkVersion 33

    defaultConfig {
        applicationId "com.example"

        minSdkVersion 21
        targetSdkVersion 33 // flutter.targetSdkVersion

pubspec.yaml:

  dio: ^4.0.6
  path_provider: ^2.0.11
  permission_handler: ^9.2.0
  saf: ^1.0.3+3

My complete code for a download page is here:

const directory = "/storage/emulated/0/Download/";

class FileDownloadView extends StatefulWidget {
  const FileDownloadView({super.key});

  @override
  State<FileDownloadView> createState() => _FileDownloadViewState();
}

class _FileDownloadViewState extends State<FileDownloadView> {
  late Saf saf;
  @override
  void initState() {
    WidgetsBinding.instance.addPostFrameCallback(
      (_) => showSnackBar(context),
    );
    Permission.storage.request();
    saf = Saf(directory);
    super.initState();
  }

  String received = "0";

  String progress = "0";

  bool downloading = false;

  bool isDownloaded = false;

  String filename = 'file-name-not-set';

  @override
  Widget build(BuildContext context) {
    User user = Provider.of<User>(context, listen: false);

    Company company = Provider.of<Company>(context, listen: false);

    filename = user.downloadFileName;

    return Scaffold(
      backgroundColor: Theme.of(context).backgroundColor,
      resizeToAvoidBottomInset: false,
      appBar: AppBar(
        centerTitle: true,
        title: Text(
          company.companyName,
          maxLines: 1,
          overflow: TextOverflow.ellipsis,
          style: TextStyle(
              color: appTextColor,
              fontSize: user.fontsize,
              fontWeight: FontWeight.normal),
        ),
        automaticallyImplyLeading: false,
        leading: IconButton(
          icon: Icon(Icons.arrow_back),
          onPressed: () => {
            Navigator.of(context)
                .pushNamedAndRemoveUntil(RoutePaths.matter, (route) => false)
          },
        ),
      ),
      body: Container(
        padding: EdgeInsets.all(12.0),
        decoration: BoxDecoration(
          gradient: LinearGradient(
              colors: [appBackgroundColorStart, appBackgroundColorEnd],
              begin: Alignment.topCenter,
              end: Alignment.bottomCenter),
        ),
        child: SingleChildScrollView(
          child: Center(
            child: Column(
              crossAxisAlignment: CrossAxisAlignment.center,
              children: [
                SizedBox(height: 24),
                SizedBox(
                  width: double.infinity,
                  child: Text(
                    'Download $filename: $received',
                    maxLines: 4,
                    overflow: TextOverflow.ellipsis,
                    textAlign: TextAlign.center,
                    style: TextStyle(
                        color: appTextColor,
                        fontSize: user.fontsize,
                        fontWeight: FontWeight.normal),
                  ),
                ),
                SizedBox(height: 24),
                ElevatedButton.icon(
                  onPressed: () {
                    downloadMobileFile(user);
                  },
                  icon: const Icon(Icons.download),
                  label: const Text('Download'),
                ),
              ],
            ),
          ),
        ),
      ),
    );
  }

  Future<void> downloadMobileFile(User user) async {
    log('downloading with mobile function ');
    setState(
      () {
        downloading = true;
        filename = user.downloadFileName;
      },
    );

    checkWritePermission();

    String savePath = await getFileSavePath(user.downloadFileName);

    //print("file save path");
    //print(savePath);

    final storage = FlutterSecureStorage();

    String? token = await storage.read(key: 'jwt');

    Dio dio = Dio();

    dio.interceptors.add(LogInterceptor(responseBody: false));

    dio.download(
      user.fileUrl,
      savePath,
      options: Options(
        headers: {HttpHeaders.authorizationHeader: 'Bearer $token'},
      ),
      onReceiveProgress: (rcv, total) {
        setState(
          () {
            progress = ((rcv / total) * 100).toStringAsFixed(0);
            received =
                'received: ${rcv.toStringAsFixed(0)} out of total: ${total.toStringAsFixed(0)} $progress%';
          },
        );
        if (progress == '100') {
          setState(
            () {
              isDownloaded = true;
            },
          );
        } else if (double.parse(progress) < 100) {}
      },
      deleteOnError: true,
    ).then(
      (_) {
        setState(
          () {
            if (progress == '100') {
              isDownloaded = true;
            }
            downloading = false;
          },
        );

        print('download progress: $progress');
        print('is the file downloaded: $isDownloaded');
      },
    );
  }

  Future<void> checkWritePermission() async {
    if (!kIsWeb) {
      if (Platform.isAndroid || Platform.isIOS) {
        var permissionStatus = await Permission.storage.status;

        switch (permissionStatus) {
          case PermissionStatus.denied:
          case PermissionStatus.permanentlyDenied:
            await Permission.storage.request();
            break;
          default:
        }

        await saf.getDirectoryPermission(isDynamic: true);

        print('permission status: $permissionStatus');
      }
    }
  }

  Future<String> getFileSavePath(String uniqueFileName) async {
    String path = '';
    Directory appDocDir = await getApplicationDocumentsDirectory();
    String appDocPath = appDocDir.path + '/' + uniqueFileName;
    print('app documents path: $appDocPath');

    final Directory? externalDir = await getExternalStorageDirectory();
    String externalStoragePath = externalDir!.path + '/' + uniqueFileName;
    print('app external storage path: $externalStoragePath');

    Platform.isAndroid
        ? path = '/storage/emulated/0/Download/$uniqueFileName'
        : path = '$appDocDir.path/$uniqueFileName';

    print('hard path string: $path');

    print('actual path used: $path');

    return path;
  }
}
Related