fix(mobile): cancel share download when dialog is dismissed (#25466)

* fix(mobile): cancel share download when dialog is dismissed

* refactor: centralize temporary file cleanup logic

* refactor: replace `CancellationToken` with `Completer<void>` for asset sharing cancellation

---------

Co-authored-by: cmdpromptcritical <cmdpromptcritical@github.com>
This commit is contained in:
cmdPromptCritical 2026-02-05 14:08:35 -05:00 committed by GitHub
parent 9d8efe2685
commit ad9f3cfa05
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 53 additions and 20 deletions

View file

@ -1,3 +1,4 @@
import 'dart:async';
import 'dart:io'; import 'dart:io';
import 'package:easy_localization/easy_localization.dart'; import 'package:easy_localization/easy_localization.dart';
@ -41,16 +42,20 @@ class ShareActionButton extends ConsumerWidget {
return; return;
} }
final cancelCompleter = Completer<void>();
const preparingDialog = _SharePreparingDialog();
await showDialog( await showDialog(
context: context, context: context,
builder: (BuildContext buildContext) { builder: (BuildContext buildContext) {
ref.read(actionProvider.notifier).shareAssets(source, context).then((ActionResult result) { ref.read(actionProvider.notifier).shareAssets(source, context, cancelCompleter: cancelCompleter).then((
ref.read(multiSelectProvider.notifier).reset(); ActionResult result,
) {
if (!context.mounted) { if (cancelCompleter.isCompleted || !context.mounted) {
return; return;
} }
ref.read(multiSelectProvider.notifier).reset();
if (!result.success) { if (!result.success) {
ImmichToast.show( ImmichToast.show(
context: context, context: context,
@ -64,11 +69,15 @@ class ShareActionButton extends ConsumerWidget {
}); });
// show a loading spinner with a "Preparing" message // show a loading spinner with a "Preparing" message
return const _SharePreparingDialog(); return preparingDialog;
}, },
barrierDismissible: false, barrierDismissible: false,
useRootNavigator: false, useRootNavigator: false,
); ).then((_) {
if (!cancelCompleter.isCompleted) {
cancelCompleter.complete();
}
});
} }
@override @override

View file

@ -405,11 +405,15 @@ class ActionNotifier extends Notifier<void> {
} }
} }
Future<ActionResult> shareAssets(ActionSource source, BuildContext context) async { Future<ActionResult> shareAssets(
ActionSource source,
BuildContext context, {
Completer<void>? cancelCompleter,
}) async {
final ids = _getAssets(source).toList(growable: false); final ids = _getAssets(source).toList(growable: false);
try { try {
await _service.shareAssets(ids, context); await _service.shareAssets(ids, context, cancelCompleter: cancelCompleter);
return ActionResult(count: ids.length, success: true); return ActionResult(count: ids.length, success: true);
} catch (error, stack) { } catch (error, stack) {
_logger.severe('Failed to share assets', error, stack); _logger.severe('Failed to share assets', error, stack);

View file

@ -23,7 +23,6 @@ final assetMediaRepositoryProvider = Provider((ref) => AssetMediaRepository(ref.
class AssetMediaRepository { class AssetMediaRepository {
final AssetApiRepository _assetApiRepository; final AssetApiRepository _assetApiRepository;
static final Logger _log = Logger("AssetMediaRepository"); static final Logger _log = Logger("AssetMediaRepository");
const AssetMediaRepository(this._assetApiRepository); const AssetMediaRepository(this._assetApiRepository);
@ -58,6 +57,7 @@ class AssetMediaRepository {
static asset_entity.Asset? toAsset(AssetEntity? local) { static asset_entity.Asset? toAsset(AssetEntity? local) {
if (local == null) return null; if (local == null) return null;
final asset_entity.Asset asset = asset_entity.Asset( final asset_entity.Asset asset = asset_entity.Asset(
checksum: "", checksum: "",
localId: local.id, localId: local.id,
@ -72,19 +72,21 @@ class AssetMediaRepository {
height: local.height, height: local.height,
isFavorite: local.isFavorite, isFavorite: local.isFavorite,
); );
if (asset.fileCreatedAt.year == 1970) { if (asset.fileCreatedAt.year == 1970) {
asset.fileCreatedAt = asset.fileModifiedAt; asset.fileCreatedAt = asset.fileModifiedAt;
} }
if (local.latitude != null) { if (local.latitude != null) {
asset.exifInfo = ExifInfo(latitude: local.latitude, longitude: local.longitude); asset.exifInfo = ExifInfo(latitude: local.latitude, longitude: local.longitude);
} }
asset.local = local; asset.local = local;
return asset; return asset;
} }
Future<String?> getOriginalFilename(String id) async { Future<String?> getOriginalFilename(String id) async {
final entity = await AssetEntity.fromId(id); final entity = await AssetEntity.fromId(id);
if (entity == null) { if (entity == null) {
return null; return null;
} }
@ -101,12 +103,31 @@ class AssetMediaRepository {
} }
} }
/// Deletes temporary files in parallel
Future<void> _cleanupTempFiles(List<File> tempFiles) async {
await Future.wait(
tempFiles.map((file) async {
try {
await file.delete();
} catch (e) {
_log.warning("Failed to delete temporary file: ${file.path}", e);
}
}),
);
}
// TODO: make this more efficient // TODO: make this more efficient
Future<int> shareAssets(List<BaseAsset> assets, BuildContext context) async { Future<int> shareAssets(List<BaseAsset> assets, BuildContext context, {Completer<void>? cancelCompleter}) async {
final downloadedXFiles = <XFile>[]; final downloadedXFiles = <XFile>[];
final tempFiles = <File>[]; final tempFiles = <File>[];
for (var asset in assets) { for (var asset in assets) {
if (cancelCompleter != null && cancelCompleter.isCompleted) {
// if cancelled, delete any temp files created so far
await _cleanupTempFiles(tempFiles);
return 0;
}
final localId = (asset is LocalAsset) final localId = (asset is LocalAsset)
? asset.id ? asset.id
: asset is RemoteAsset : asset is RemoteAsset
@ -146,6 +167,11 @@ class AssetMediaRepository {
return 0; return 0;
} }
if (cancelCompleter != null && cancelCompleter.isCompleted) {
await _cleanupTempFiles(tempFiles);
return 0;
}
// we dont want to await the share result since the // we dont want to await the share result since the
// "preparing" dialog will not disappear until // "preparing" dialog will not disappear until
final size = context.sizeData; final size = context.sizeData;
@ -154,13 +180,7 @@ class AssetMediaRepository {
downloadedXFiles, downloadedXFiles,
sharePositionOrigin: Rect.fromPoints(Offset.zero, Offset(size.width / 3, size.height)), sharePositionOrigin: Rect.fromPoints(Offset.zero, Offset(size.width / 3, size.height)),
).then((result) async { ).then((result) async {
for (var file in tempFiles) { await _cleanupTempFiles(tempFiles);
try {
await file.delete();
} catch (e) {
_log.warning("Failed to delete temporary file: ${file.path}", e);
}
}
}), }),
); );

View file

@ -232,8 +232,8 @@ class ActionService {
await _assetApiRepository.unStack(stackIds); await _assetApiRepository.unStack(stackIds);
} }
Future<int> shareAssets(List<BaseAsset> assets, BuildContext context) { Future<int> shareAssets(List<BaseAsset> assets, BuildContext context, {Completer<void>? cancelCompleter}) {
return _assetMediaRepository.shareAssets(assets, context); return _assetMediaRepository.shareAssets(assets, context, cancelCompleter: cancelCompleter);
} }
Future<List<bool>> downloadAll(List<RemoteAsset> assets) { Future<List<bool>> downloadAll(List<RemoteAsset> assets) {