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 'package:easy_localization/easy_localization.dart';
@ -41,16 +42,20 @@ class ShareActionButton extends ConsumerWidget {
return;
}
final cancelCompleter = Completer<void>();
const preparingDialog = _SharePreparingDialog();
await showDialog(
context: context,
builder: (BuildContext buildContext) {
ref.read(actionProvider.notifier).shareAssets(source, context).then((ActionResult result) {
ref.read(multiSelectProvider.notifier).reset();
if (!context.mounted) {
ref.read(actionProvider.notifier).shareAssets(source, context, cancelCompleter: cancelCompleter).then((
ActionResult result,
) {
if (cancelCompleter.isCompleted || !context.mounted) {
return;
}
ref.read(multiSelectProvider.notifier).reset();
if (!result.success) {
ImmichToast.show(
context: context,
@ -64,11 +69,15 @@ class ShareActionButton extends ConsumerWidget {
});
// show a loading spinner with a "Preparing" message
return const _SharePreparingDialog();
return preparingDialog;
},
barrierDismissible: false,
useRootNavigator: false,
);
).then((_) {
if (!cancelCompleter.isCompleted) {
cancelCompleter.complete();
}
});
}
@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);
try {
await _service.shareAssets(ids, context);
await _service.shareAssets(ids, context, cancelCompleter: cancelCompleter);
return ActionResult(count: ids.length, success: true);
} catch (error, stack) {
_logger.severe('Failed to share assets', error, stack);

View file

@ -23,7 +23,6 @@ final assetMediaRepositoryProvider = Provider((ref) => AssetMediaRepository(ref.
class AssetMediaRepository {
final AssetApiRepository _assetApiRepository;
static final Logger _log = Logger("AssetMediaRepository");
const AssetMediaRepository(this._assetApiRepository);
@ -58,6 +57,7 @@ class AssetMediaRepository {
static asset_entity.Asset? toAsset(AssetEntity? local) {
if (local == null) return null;
final asset_entity.Asset asset = asset_entity.Asset(
checksum: "",
localId: local.id,
@ -72,19 +72,21 @@ class AssetMediaRepository {
height: local.height,
isFavorite: local.isFavorite,
);
if (asset.fileCreatedAt.year == 1970) {
asset.fileCreatedAt = asset.fileModifiedAt;
}
if (local.latitude != null) {
asset.exifInfo = ExifInfo(latitude: local.latitude, longitude: local.longitude);
}
asset.local = local;
return asset;
}
Future<String?> getOriginalFilename(String id) async {
final entity = await AssetEntity.fromId(id);
if (entity == 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
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 tempFiles = <File>[];
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)
? asset.id
: asset is RemoteAsset
@ -146,6 +167,11 @@ class AssetMediaRepository {
return 0;
}
if (cancelCompleter != null && cancelCompleter.isCompleted) {
await _cleanupTempFiles(tempFiles);
return 0;
}
// we dont want to await the share result since the
// "preparing" dialog will not disappear until
final size = context.sizeData;
@ -154,13 +180,7 @@ class AssetMediaRepository {
downloadedXFiles,
sharePositionOrigin: Rect.fromPoints(Offset.zero, Offset(size.width / 3, size.height)),
).then((result) async {
for (var file in tempFiles) {
try {
await file.delete();
} catch (e) {
_log.warning("Failed to delete temporary file: ${file.path}", e);
}
}
await _cleanupTempFiles(tempFiles);
}),
);

View file

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