Skip to content

Commit

Permalink
fix(android): save files to SD card on Android 10 or lower (localsend…
Browse files Browse the repository at this point in the history
  • Loading branch information
Tienisto authored Feb 26, 2024
1 parent 13730f1 commit 457cd31
Show file tree
Hide file tree
Showing 9 changed files with 135 additions and 29 deletions.
1 change: 1 addition & 0 deletions app/assets/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
2 changes: 2 additions & 0 deletions app/lib/model/state/server/receive_session_state.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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<Map<String, String>?>? responseHandler;

Expand All @@ -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,
});
Expand Down
8 changes: 8 additions & 0 deletions app/lib/model/state/server/receive_session_state.mapper.dart
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,8 @@ class ReceiveSessionStateMapper extends ClassMapperBase<ReceiveSessionState> {
static const Field<ReceiveSessionState, int> _f$endTime = Field('endTime', _$endTime);
static String _$destinationDirectory(ReceiveSessionState v) => v.destinationDirectory;
static const Field<ReceiveSessionState, String> _f$destinationDirectory = Field('destinationDirectory', _$destinationDirectory);
static String _$cacheDirectory(ReceiveSessionState v) => v.cacheDirectory;
static const Field<ReceiveSessionState, String> _f$cacheDirectory = Field('cacheDirectory', _$cacheDirectory);
static bool _$saveToGallery(ReceiveSessionState v) => v.saveToGallery;
static const Field<ReceiveSessionState, bool> _f$saveToGallery = Field('saveToGallery', _$saveToGallery);
static StreamController<Map<String, String>?>? _$responseHandler(ReceiveSessionState v) => v.responseHandler;
Expand All @@ -53,6 +55,7 @@ class ReceiveSessionStateMapper extends ClassMapperBase<ReceiveSessionState> {
#startTime: _f$startTime,
#endTime: _f$endTime,
#destinationDirectory: _f$destinationDirectory,
#cacheDirectory: _f$cacheDirectory,
#saveToGallery: _f$saveToGallery,
#responseHandler: _f$responseHandler,
};
Expand All @@ -67,6 +70,7 @@ class ReceiveSessionStateMapper extends ClassMapperBase<ReceiveSessionState> {
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));
}
Expand Down Expand Up @@ -128,6 +132,7 @@ abstract class ReceiveSessionStateCopyWith<$R, $In extends ReceiveSessionState,
int? startTime,
int? endTime,
String? destinationDirectory,
String? cacheDirectory,
bool? saveToGallery,
StreamController<Map<String, String>?>? responseHandler});
ReceiveSessionStateCopyWith<$R2, $In, $Out2> $chain<$R2, $Out2>(Then<$Out2, $R2> t);
Expand All @@ -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({
Expand All @@ -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
}));
Expand All @@ -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));

Expand Down
2 changes: 1 addition & 1 deletion app/lib/pages/receive_history_page.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
34 changes: 9 additions & 25 deletions app/lib/provider/network/server/controller/receive_controller.dart
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import 'dart:async';
import 'dart:convert';
import 'dart:io';

import 'package:collection/collection.dart';
import 'package:common/common.dart';
Expand All @@ -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';
Expand Down Expand Up @@ -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');
Expand Down Expand Up @@ -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,
),
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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<String> _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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,3 +33,7 @@ Future<String> getDefaultDestinationDirectory() async {
return downloadDir.path.replaceAll('\\', '/');
}
}

Future<String> getCacheDirectory() async {
return (await path.getTemporaryDirectory()).path;
}
104 changes: 101 additions & 3 deletions app/lib/util/native/file_saver.dart
Original file line number Diff line number Diff line change
@@ -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<void> saveFile({
Expand All @@ -13,14 +19,69 @@ Future<void> saveFile({
required bool saveToGallery,
required bool isImage,
required Stream<List<int>> 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<void> _saveFile({
required String destinationPath,
required bool saveToGallery,
required bool isImage,
required Stream<List<int>> stream,
required void Function(int savedBytes) onProgress,
required void Function(List<int> data)? write,
required Future<void> Function(List<int> data)? writeAsync,
required Future<void> 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) {
Expand All @@ -29,7 +90,7 @@ Future<void> saveFile({
}
}

await sink.close();
await close();

if (saveToGallery) {
isImage ? await Gal.putImage(destinationPath) : await Gal.putVideo(destinationPath);
Expand All @@ -39,11 +100,48 @@ Future<void> 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);
}
rethrow;
}
}

/// If there is a file with the same name, then it appends a number to its file name
Future<String> 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)!);
}
8 changes: 8 additions & 0 deletions app/pubspec.lock
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
1 change: 1 addition & 0 deletions app/pubspec.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down

0 comments on commit 457cd31

Please sign in to comment.