From 457cd3149e5dec0e4b101b9b6eab002519910d9d Mon Sep 17 00:00:00 2001 From: Tien Do Nam <38380847+Tienisto@users.noreply.github.com> Date: Mon, 26 Feb 2024 03:17:37 +0100 Subject: [PATCH] fix(android): save files to SD card on Android 10 or lower (#1162) --- app/assets/CHANGELOG.md | 1 + .../state/server/receive_session_state.dart | 2 + .../server/receive_session_state.mapper.dart | 8 ++ app/lib/pages/receive_history_page.dart | 2 +- .../server/controller/receive_controller.dart | 34 ++---- ...nation_directory.dart => directories.dart} | 4 + app/lib/util/native/file_saver.dart | 104 +++++++++++++++++- app/pubspec.lock | 8 ++ app/pubspec.yaml | 1 + 9 files changed, 135 insertions(+), 29 deletions(-) rename app/lib/util/native/{get_destination_directory.dart => directories.dart} (93%) diff --git a/app/assets/CHANGELOG.md b/app/assets/CHANGELOG.md index 01dcbdf6c71..afc86628537 100644 --- a/app/assets/CHANGELOG.md +++ b/app/assets/CHANGELOG.md @@ -4,6 +4,7 @@ - feat: use fix button width for all buttons in the selection row (only noticeable in Russian) (@Tienisto) - fix: picking many files should not freeze the UI (@Tienisto) - fix: do not create a new session for the same IP when sharing via link (@MisterChangRay) +- fix(android): save files to SD card on Android 10 or older (@Tienisto) - i18n: add Danish (@Limfjorden) ## 1.13.1 (2023-12-08) diff --git a/app/lib/model/state/server/receive_session_state.dart b/app/lib/model/state/server/receive_session_state.dart index 4f1e77cfd31..d5883c0e309 100644 --- a/app/lib/model/state/server/receive_session_state.dart +++ b/app/lib/model/state/server/receive_session_state.dart @@ -19,6 +19,7 @@ class ReceiveSessionState with ReceiveSessionStateMappable { final int? startTime; final int? endTime; final String destinationDirectory; + final String cacheDirectory; final bool saveToGallery; final StreamController?>? responseHandler; @@ -31,6 +32,7 @@ class ReceiveSessionState with ReceiveSessionStateMappable { required this.startTime, required this.endTime, required this.destinationDirectory, + required this.cacheDirectory, required this.saveToGallery, required this.responseHandler, }); diff --git a/app/lib/model/state/server/receive_session_state.mapper.dart b/app/lib/model/state/server/receive_session_state.mapper.dart index 07d68cb38a8..05b410e9e1d 100644 --- a/app/lib/model/state/server/receive_session_state.mapper.dart +++ b/app/lib/model/state/server/receive_session_state.mapper.dart @@ -38,6 +38,8 @@ class ReceiveSessionStateMapper extends ClassMapperBase { static const Field _f$endTime = Field('endTime', _$endTime); static String _$destinationDirectory(ReceiveSessionState v) => v.destinationDirectory; static const Field _f$destinationDirectory = Field('destinationDirectory', _$destinationDirectory); + static String _$cacheDirectory(ReceiveSessionState v) => v.cacheDirectory; + static const Field _f$cacheDirectory = Field('cacheDirectory', _$cacheDirectory); static bool _$saveToGallery(ReceiveSessionState v) => v.saveToGallery; static const Field _f$saveToGallery = Field('saveToGallery', _$saveToGallery); static StreamController?>? _$responseHandler(ReceiveSessionState v) => v.responseHandler; @@ -53,6 +55,7 @@ class ReceiveSessionStateMapper extends ClassMapperBase { #startTime: _f$startTime, #endTime: _f$endTime, #destinationDirectory: _f$destinationDirectory, + #cacheDirectory: _f$cacheDirectory, #saveToGallery: _f$saveToGallery, #responseHandler: _f$responseHandler, }; @@ -67,6 +70,7 @@ class ReceiveSessionStateMapper extends ClassMapperBase { startTime: data.dec(_f$startTime), endTime: data.dec(_f$endTime), destinationDirectory: data.dec(_f$destinationDirectory), + cacheDirectory: data.dec(_f$cacheDirectory), saveToGallery: data.dec(_f$saveToGallery), responseHandler: data.dec(_f$responseHandler)); } @@ -128,6 +132,7 @@ abstract class ReceiveSessionStateCopyWith<$R, $In extends ReceiveSessionState, int? startTime, int? endTime, String? destinationDirectory, + String? cacheDirectory, bool? saveToGallery, StreamController?>? responseHandler}); ReceiveSessionStateCopyWith<$R2, $In, $Out2> $chain<$R2, $Out2>(Then<$Out2, $R2> t); @@ -154,6 +159,7 @@ class _ReceiveSessionStateCopyWithImpl<$R, $Out> extends ClassCopyWithBase<$R, R Object? startTime = $none, Object? endTime = $none, String? destinationDirectory, + String? cacheDirectory, bool? saveToGallery, Object? responseHandler = $none}) => $apply(FieldCopyWithData({ @@ -165,6 +171,7 @@ class _ReceiveSessionStateCopyWithImpl<$R, $Out> extends ClassCopyWithBase<$R, R if (startTime != $none) #startTime: startTime, if (endTime != $none) #endTime: endTime, if (destinationDirectory != null) #destinationDirectory: destinationDirectory, + if (cacheDirectory != null) #cacheDirectory: cacheDirectory, if (saveToGallery != null) #saveToGallery: saveToGallery, if (responseHandler != $none) #responseHandler: responseHandler })); @@ -178,6 +185,7 @@ class _ReceiveSessionStateCopyWithImpl<$R, $Out> extends ClassCopyWithBase<$R, R startTime: data.get(#startTime, or: $value.startTime), endTime: data.get(#endTime, or: $value.endTime), destinationDirectory: data.get(#destinationDirectory, or: $value.destinationDirectory), + cacheDirectory: data.get(#cacheDirectory, or: $value.cacheDirectory), saveToGallery: data.get(#saveToGallery, or: $value.saveToGallery), responseHandler: data.get(#responseHandler, or: $value.responseHandler)); diff --git a/app/lib/pages/receive_history_page.dart b/app/lib/pages/receive_history_page.dart index f813aa1b08f..7c3ba7b0b1f 100644 --- a/app/lib/pages/receive_history_page.dart +++ b/app/lib/pages/receive_history_page.dart @@ -5,7 +5,7 @@ import 'package:localsend_app/provider/receive_history_provider.dart'; import 'package:localsend_app/provider/settings_provider.dart'; import 'package:localsend_app/theme.dart'; import 'package:localsend_app/util/file_size_helper.dart'; -import 'package:localsend_app/util/native/get_destination_directory.dart'; +import 'package:localsend_app/util/native/directories.dart'; import 'package:localsend_app/util/native/open_file.dart'; import 'package:localsend_app/util/native/open_folder.dart'; import 'package:localsend_app/util/native/platform_check.dart'; diff --git a/app/lib/provider/network/server/controller/receive_controller.dart b/app/lib/provider/network/server/controller/receive_controller.dart index a1f321c6831..d116c5649a8 100644 --- a/app/lib/provider/network/server/controller/receive_controller.dart +++ b/app/lib/provider/network/server/controller/receive_controller.dart @@ -1,6 +1,5 @@ import 'dart:async'; import 'dart:convert'; -import 'dart:io'; import 'package:collection/collection.dart'; import 'package:common/common.dart'; @@ -22,13 +21,11 @@ import 'package:localsend_app/provider/progress_provider.dart'; import 'package:localsend_app/provider/receive_history_provider.dart'; import 'package:localsend_app/provider/settings_provider.dart'; import 'package:localsend_app/util/api_route_builder.dart'; -import 'package:localsend_app/util/file_path_helper.dart'; +import 'package:localsend_app/util/native/directories.dart'; import 'package:localsend_app/util/native/file_saver.dart'; -import 'package:localsend_app/util/native/get_destination_directory.dart'; import 'package:localsend_app/util/native/platform_check.dart'; import 'package:localsend_app/util/native/tray_helper.dart'; import 'package:logging/logging.dart'; -import 'package:path/path.dart' as p; import 'package:permission_handler/permission_handler.dart'; import 'package:routerino/routerino.dart'; import 'package:shelf/shelf.dart'; @@ -194,6 +191,7 @@ class ReceiveController { final settings = server.ref.read(settingsProvider); final destinationDir = settings.destination ?? await getDefaultDestinationDirectory(); + final cacheDir = await getCacheDirectory(); final sessionId = _uuid.v4(); _logger.info('Session Id: $sessionId'); @@ -222,6 +220,7 @@ class ReceiveController { startTime: null, endTime: null, destinationDirectory: destinationDir, + cacheDirectory: cacheDir, saveToGallery: checkPlatformWithGallery() && settings.saveToGallery && dto.files.values.every((f) => !f.fileName.contains('/')), responseHandler: streamController, ), @@ -391,21 +390,23 @@ class ReceiveController { ); try { - final destinationPath = await _digestFilePathAndPrepareDirectory( - parentDirectory: receiveState.destinationDirectory, + final fileType = receivingFile.file.fileType; + final saveToGallery = receiveState.saveToGallery && (fileType == FileType.image || fileType == FileType.video); + + final destinationPath = await digestFilePathAndPrepareDirectory( + parentDirectory: saveToGallery ? receiveState.cacheDirectory : receiveState.destinationDirectory, fileName: receivingFile.desiredName!, ); _logger.info('Saving ${receivingFile.file.fileName} to $destinationPath'); - final fileType = receivingFile.file.fileType; - final saveToGallery = receiveState.saveToGallery && (fileType == FileType.image || fileType == FileType.video); await saveFile( destinationPath: destinationPath, name: receivingFile.desiredName!, saveToGallery: saveToGallery, isImage: fileType == FileType.image, stream: request.read(), + androidSdkInt: server.ref.read(deviceInfoProvider).androidSdkInt, onProgress: (savedBytes) { if (receivingFile.file.size != 0) { server.ref.notifier(progressProvider).setProgress( @@ -688,23 +689,6 @@ void _cancelBySender(ServerUtils server) { )); } -/// If there is a file with the same name, then it appends a number to its file name -Future _digestFilePathAndPrepareDirectory({required String parentDirectory, required String fileName}) async { - final actualFileName = p.basename(fileName); - final fileNameParts = p.split(fileName); - final dir = p.joinAll([parentDirectory, ...fileNameParts.take(fileNameParts.length - 1)]); - - Directory(dir).createSync(recursive: true); - - String destinationPath; - int counter = 1; - do { - destinationPath = counter == 1 ? p.join(dir, actualFileName) : p.join(dir, actualFileName.withCount(counter)); - counter++; - } while (await File(destinationPath).exists()); - return destinationPath; -} - extension on ReceiveSessionState { ReceiveSessionState fileFinished({ required String fileId, diff --git a/app/lib/util/native/get_destination_directory.dart b/app/lib/util/native/directories.dart similarity index 93% rename from app/lib/util/native/get_destination_directory.dart rename to app/lib/util/native/directories.dart index 783c11639c3..2aefd5223fa 100644 --- a/app/lib/util/native/get_destination_directory.dart +++ b/app/lib/util/native/directories.dart @@ -33,3 +33,7 @@ Future getDefaultDestinationDirectory() async { return downloadDir.path.replaceAll('\\', '/'); } } + +Future getCacheDirectory() async { + return (await path.getTemporaryDirectory()).path; +} diff --git a/app/lib/util/native/file_saver.dart b/app/lib/util/native/file_saver.dart index 46e5c47fddb..d5f5e519864 100644 --- a/app/lib/util/native/file_saver.dart +++ b/app/lib/util/native/file_saver.dart @@ -1,10 +1,16 @@ import 'dart:io'; +import 'dart:typed_data'; import 'package:gal/gal.dart'; +import 'package:localsend_app/util/file_path_helper.dart'; import 'package:logging/logging.dart'; +import 'package:path/path.dart' as p; +import 'package:saf_stream/saf_stream.dart'; final _logger = Logger('FileSaver'); +final _saf = SafStream(); + /// Saves the data [stream] to the [destinationPath]. /// [onProgress] will be called on every 100 ms. Future saveFile({ @@ -13,14 +19,69 @@ Future saveFile({ required bool saveToGallery, required bool isImage, required Stream> stream, + required int? androidSdkInt, required void Function(int savedBytes) onProgress, }) async { + if (!saveToGallery && androidSdkInt != null && androidSdkInt <= 29) { + final sdCardPath = getSdCardPath(destinationPath); + if (sdCardPath != null) { + // Use Android SAF to save the file to the SD card + final info = await _saf.startWriteStream( + Uri.parse('content://com.android.externalstorage.documents/tree/${sdCardPath.sdCardId}:${sdCardPath.path}'), + name, + isImage ? 'image/*' : '*/*', + ); + final sessionID = info.session; + await _saveFile( + destinationPath: destinationPath, + saveToGallery: saveToGallery, + isImage: isImage, + stream: stream, + onProgress: onProgress, + write: null, + writeAsync: (data) async { + await _saf.writeChunk(sessionID, Uint8List.fromList(data)); + }, + close: () async { + await _saf.endWriteStream(sessionID); + }, + ); + return; + } + } + final sink = File(destinationPath).openWrite(); + await _saveFile( + destinationPath: destinationPath, + saveToGallery: saveToGallery, + isImage: isImage, + stream: stream, + onProgress: onProgress, + write: sink.add, + writeAsync: null, + close: sink.close, + ); +} + +Future _saveFile({ + required String destinationPath, + required bool saveToGallery, + required bool isImage, + required Stream> stream, + required void Function(int savedBytes) onProgress, + required void Function(List data)? write, + required Future Function(List data)? writeAsync, + required Future Function() close, +}) async { try { int savedBytes = 0; final stopwatch = Stopwatch()..start(); await for (final event in stream) { - sink.add(event); + if (writeAsync != null) { + await writeAsync(event); + } else { + write!(event); + } savedBytes += event.length; if (stopwatch.elapsedMilliseconds >= 100) { @@ -29,7 +90,7 @@ Future saveFile({ } } - await sink.close(); + await close(); if (saveToGallery) { isImage ? await Gal.putImage(destinationPath) : await Gal.putVideo(destinationPath); @@ -39,7 +100,7 @@ Future saveFile({ onProgress(savedBytes); // always emit final event } catch (_) { try { - await sink.close(); + await close(); await File(destinationPath).delete(); } catch (e) { _logger.warning('Could not delete file', e); @@ -47,3 +108,40 @@ Future saveFile({ rethrow; } } + +/// If there is a file with the same name, then it appends a number to its file name +Future digestFilePathAndPrepareDirectory({required String parentDirectory, required String fileName}) async { + final actualFileName = p.basename(fileName); + final fileNameParts = p.split(fileName); + final dir = p.joinAll([parentDirectory, ...fileNameParts.take(fileNameParts.length - 1)]); + + Directory(dir).createSync(recursive: true); + + String destinationPath; + int counter = 1; + do { + destinationPath = counter == 1 ? p.join(dir, actualFileName) : p.join(dir, actualFileName.withCount(counter)); + counter++; + } while (await File(destinationPath).exists()); + return destinationPath; +} + +final _sdCardPathRegex = RegExp(r'^/storage/([A-Fa-f0-9]{4}-[A-Fa-f0-9]{4})/(.*)$'); + +class SdCardPath { + final String sdCardId; + final String path; + + SdCardPath(this.sdCardId, this.path); +} + +/// Checks if the [path] is on the SD card and returns the SD card path. +/// Returns `null` if the [path] is not on the SD card. +/// Only works on Android. +SdCardPath? getSdCardPath(String path) { + final match = _sdCardPathRegex.firstMatch(path); + if (match == null) { + return null; + } + return SdCardPath(match.group(1)!, match.group(2)!); +} diff --git a/app/pubspec.lock b/app/pubspec.lock index 97926943f85..6133c0f44ef 100644 --- a/app/pubspec.lock +++ b/app/pubspec.lock @@ -1181,6 +1181,14 @@ packages: url: "https://pub.dev" source: hosted version: "0.8.0" + saf_stream: + dependency: "direct main" + description: + name: saf_stream + sha256: "1db21cfa5914a5cf9a7c962b5d57fc8c07011561e365e7ff7f8013019cc3c0f3" + url: "https://pub.dev" + source: hosted + version: "0.4.0" screen_retriever: dependency: "direct main" description: diff --git a/app/pubspec.yaml b/app/pubspec.yaml index 4c794c7ab88..73e249b8de4 100644 --- a/app/pubspec.yaml +++ b/app/pubspec.yaml @@ -48,6 +48,7 @@ dependencies: refena_flutter: 1.6.0 refena_inspector_client: 1.2.0 routerino: 0.8.0 + saf_stream: 0.4.0 # Saving files to SD card for Android <11 screen_retriever: 0.1.9 share_handler: 0.0.19 share_handler_ios: 0.0.12 # Keep iOS <14 support