Как получить разрешения на хранение папки Android Documents во Flutter?

В моем приложении флаттера я пытаюсь сохранить файл, загруженный из API. Достаточно сохранить файл в каталог «Загрузки», независимо от типа файла.

Мой Андроид compileSDK = 32. Я понимаю, что permissions_handler 10.0.0 не поддерживает разрешения API 33. Я понизился до 9.2.0, и он компилируется.

path_provider 2.0.11 не поддерживает каталоги Android, поэтому я жестко закодировал путь.

Я использую

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

Я получаю следующую ошибку разрешений в консоли Android Studio, когда пытаюсь сохранить файл. Журнал запросов включен для полноты.

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)

Вот мой код:

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;
  }
}

Обновлено: я обновил этот код, чтобы включить приведенные ниже предложения, и отредактировал свой манифест, включив этот код ниже:

<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>

Я могу успешно сохранить файл в /storage/emulated/0/Android/data/com.example/files/fatsquid.jpg, который предоставляется getExternalStorageDirectory(), но это не является удовлетворительным решением, поскольку у большинства пользователей возникнут трудности с поиском файла. Он должен быть в папке «Документы», которая, как я понимаю, является жестко запрограммированным путем.

Permission.storage предназначен только для Scoped Storage (внутренние папки приложения). Я бы порекомендовал начать с документации Android MediaStore, которая позволит вам сохранять файлы в общую папку «Загрузки»: developer.android.com/training/data-storage/shared/media, а затем найти пакет Flutter, который включает эти функции. вам нужно во Flutter, или создайте для них свои собственные каналы платформы. Альтернативный способ получить прямой доступ к этим путям — использовать специальный Permission.manageExternalStorage, который требует от вас заполнения формы декларации разрешений при отправке приложения.

Ovidiu 23.09.2022 13:52
0
1
338
4
Перейти к ответу Данный вопрос помечен как решенный

Ответы 4

Этот метод работает для меня:

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:
        }
      }
    }
  }

И в манифесте вам нужно добавить разрешения:

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

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

И внутри приложения:

android:requestLegacyExternalStorage = "true"

Редактировать

<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">

Для ошибки инструментов вы можете поместить собственные инструменты в корневой узел xml.

<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">

Какая у вас compileSDK версия?

markhorrocks 17.09.2022 13:55

compileSdkVersion и цель 32

Sergio Clemente 17.09.2022 13:58

почему вы включаете android:maxSdkVersion = "28"?

markhorrocks 17.09.2022 13:59

Это разрешение устарело для Android 10 и выше... Итак, с этой строкой Android 10 и выше не читайте это разрешение

Sergio Clemente 17.09.2022 14:08

Какой файл и позицию мне включить android:requestLegacyExternalStorage = "true" ?

markhorrocks 17.09.2022 14:30

В манифесте внутри приложения. Я отредактировал свой ответ, чтобы привести пример

Sergio Clemente 17.09.2022 14:37

Я отредактировал свой вопрос, чтобы показать результат.

markhorrocks 17.09.2022 15:24

Вы можете поместить инструменты xml в манифест. Проверьте редактирование

Sergio Clemente 17.09.2022 15:34

Я получил ошибку после того, как применил ваши изменения, пожалуйста, проверьте мой код.

markhorrocks 17.09.2022 15:39

Видишь мое последнее редактирование?

Sergio Clemente 17.09.2022 15:57

Без изменений, я получаю ту же ошибку разрешений, что и выше

markhorrocks 17.09.2022 16:20

После обновления кода ваше приложение снова требует разрешения?

Sergio Clemente 17.09.2022 18:25

Да, он снова запросил разрешение, которое я одобрил.

markhorrocks 17.09.2022 18:45

Извините за задержку @markhorrocks. Какое значение разрешенияStatus в этой строке: var PermissionStatus = await Permission.storage.status;

Sergio Clemente 22.09.2022 19:43

Статус разрешения: PermissionStatus.granted

markhorrocks 23.09.2022 05:43

Поскольку вы уже используете path_provider, вы можете просто использовать Application Documents Directory для хранения файла. Таким образом, вам не придется иметь дело с разрешением на хранение.

import 'package:path_provider/path_provider.dart';

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

С помощью пакета dio, который вы уже используете, загрузка и сохранение файла становится очень простой задачей.

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
});

Для повторного чтения загруженных файлов из каталога документов приложений. Вы можете сделать это

import 'dart:io';

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

  return files;
});
getApplicationDocumentsDirectory() дает мне окончательный путь /data/user/0/com.example/app_flutter/fatsquid.jpg, который успешно загружается, но я не могу найти этот путь в своем проводнике.
markhorrocks 23.09.2022 09:49

Вы не найдете его в проводнике, потому что он есть в приложении. Проводник может читать только с внешнего хранилища. Если вам нужно отобразить загруженные изображения в вашем приложении, вам нужно снова прочитать их из каталога приложения. Я могу предоставить код для вас. Но если вы хотите, чтобы пользователи вашего приложения видели их из приложения «Диспетчер файлов», вы должны иметь дело с разрешением на хранение, и мой код больше не будет действителен. Что ты хочешь?

Myo Win 23.09.2022 10:57

Мне нужно разрешить права доступа к хранилищу. Пользователи должны иметь возможность найти загруженный файл в ожидаемом месте, в папке «Загрузки». Или хотя бы папку «Документы». Я не хочу, чтобы мое приложение открывало файл.

markhorrocks 23.09.2022 11:03

Да, если вы не показываете их в своем приложении. Это действительно ожидаемые места. Я все равно добавил код для чтения загруженных файлов.

Myo Win 23.09.2022 11:08

Следуйте шагам, используйте provide_paths.xml для доступа к хранилищу

<?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>

Входит ли файл Provide_paths.xml в android/app/src/main?

markhorrocks 23.09.2022 05:57

Добавьте его в папку res XML.

Mahesh 23.09.2022 06:28

Эти изменения ничего не изменили. Я все еще получаю ошибку прав доступа 13, когда пытаюсь сохранить в /storage/emulated/0/Download/fatsquid.jpg.

markhorrocks 23.09.2022 08:48

использовать последнюю версию permission_handler: ^10.0.1,

Mahesh 23.09.2022 09:14

Я обновился до permission_handler ^10.0.1, но это ничего не изменило.

markhorrocks 23.09.2022 09:24

<uses-permission android:name = "android.permission.MANAGE_EXTERNAL_STORAGE" /> добавить это, дал принудительное разрешение в настройках приложения

Mahesh 23.09.2022 09:28
cdn.ourcodeworld.com/public-media/gallery/… нравится это
Mahesh 23.09.2022 09:33
Ответ принят как подходящий

Для Android compileSDK 33 Решение не использовать WRITE_EXTERNAL_STORAGE, а использовать пакет flutter saf. Мой AndroidManifest.xml не отличается от значения по умолчанию.

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

Мой полный код для страницы загрузки находится здесь:

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;
  }
}

Другие вопросы по теме