В моем приложении флаттера я пытаюсь сохранить файл, загруженный из 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(), но это не является удовлетворительным решением, поскольку у большинства пользователей возникнут трудности с поиском файла. Он должен быть в папке «Документы», которая, как я понимаю, является жестко запрограммированным путем.
Этот метод работает для меня:
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 версия?
compileSdkVersion и цель 32
почему вы включаете android:maxSdkVersion = "28"?
Это разрешение устарело для Android 10 и выше... Итак, с этой строкой Android 10 и выше не читайте это разрешение
Какой файл и позицию мне включить android:requestLegacyExternalStorage = "true" ?
В манифесте внутри приложения. Я отредактировал свой ответ, чтобы привести пример
Я отредактировал свой вопрос, чтобы показать результат.
Вы можете поместить инструменты xml в манифест. Проверьте редактирование
Я получил ошибку после того, как применил ваши изменения, пожалуйста, проверьте мой код.
Видишь мое последнее редактирование?
Без изменений, я получаю ту же ошибку разрешений, что и выше
После обновления кода ваше приложение снова требует разрешения?
Да, он снова запросил разрешение, которое я одобрил.
Извините за задержку @markhorrocks. Какое значение разрешенияStatus в этой строке: var PermissionStatus = await Permission.storage.status;
Статус разрешения: PermissionStatus.granted
Поскольку вы уже используете 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, который успешно загружается, но я не могу найти этот путь в своем проводнике.
Вы не найдете его в проводнике, потому что он есть в приложении. Проводник может читать только с внешнего хранилища. Если вам нужно отобразить загруженные изображения в вашем приложении, вам нужно снова прочитать их из каталога приложения. Я могу предоставить код для вас. Но если вы хотите, чтобы пользователи вашего приложения видели их из приложения «Диспетчер файлов», вы должны иметь дело с разрешением на хранение, и мой код больше не будет действителен. Что ты хочешь?
Мне нужно разрешить права доступа к хранилищу. Пользователи должны иметь возможность найти загруженный файл в ожидаемом месте, в папке «Загрузки». Или хотя бы папку «Документы». Я не хочу, чтобы мое приложение открывало файл.
Да, если вы не показываете их в своем приложении. Это действительно ожидаемые места. Я все равно добавил код для чтения загруженных файлов.
Следуйте шагам,
используйте 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?
Добавьте его в папку res XML.
Эти изменения ничего не изменили. Я все еще получаю ошибку прав доступа 13, когда пытаюсь сохранить в /storage/emulated/0/Download/fatsquid.jpg.
использовать последнюю версию permission_handler: ^10.0.1,
Я обновился до permission_handler ^10.0.1, но это ничего не изменило.
<uses-permission android:name = "android.permission.MANAGE_EXTERNAL_STORAGE" /> добавить это, дал принудительное разрешение в настройках приложения
Для 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;
}
}
Permission.storage предназначен только для Scoped Storage (внутренние папки приложения). Я бы порекомендовал начать с документации Android MediaStore, которая позволит вам сохранять файлы в общую папку «Загрузки»: developer.android.com/training/data-storage/shared/media, а затем найти пакет Flutter, который включает эти функции. вам нужно во Flutter, или создайте для них свои собственные каналы платформы. Альтернативный способ получить прямой доступ к этим путям — использовать специальный Permission.manageExternalStorage, который требует от вас заполнения формы декларации разрешений при отправке приложения.