From 49714a3b30012a6ff04fada8a1eadfd83387bf7a Mon Sep 17 00:00:00 2001 From: Santo Shakil Date: Wed, 8 Jul 2026 14:18:20 +0600 Subject: [PATCH] fix(mobile): permanently delete local copies when moving to the locked folder --- i18n/en.json | 1 + ...e_to_lock_folder_action_button.widget.dart | 6 ++- .../infrastructure/action.provider.dart | 19 +++++++- .../repositories/asset_media.repository.dart | 4 +- mobile/lib/services/action.service.dart | 10 ++-- mobile/test/services/action.service_test.dart | 46 ++++++++++++++++--- 6 files changed, 71 insertions(+), 15 deletions(-) diff --git a/i18n/en.json b/i18n/en.json index 5f8509b4c7..c449d0e80d 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -1462,6 +1462,7 @@ "move_to_lock_folder_action_prompt": "{count} added to the locked folder", "move_to_locked_folder": "Move to locked folder", "move_to_locked_folder_confirmation": "These photos and video will be removed from all albums, and only viewable from the locked folder", + "move_to_locked_folder_local_ios": "These items will be moved to Recently Deleted but still be available on the Immich server", "moved_to_trash": "Moved to trash", "mute_memories": "Mute Memories", "my_albums": "My albums", diff --git a/mobile/lib/presentation/widgets/action_buttons/move_to_lock_folder_action_button.widget.dart b/mobile/lib/presentation/widgets/action_buttons/move_to_lock_folder_action_button.widget.dart index 56191e9055..4bc78a958d 100644 --- a/mobile/lib/presentation/widgets/action_buttons/move_to_lock_folder_action_button.widget.dart +++ b/mobile/lib/presentation/widgets/action_buttons/move_to_lock_folder_action_button.widget.dart @@ -16,11 +16,15 @@ Future performMoveToLockFolderAction(BuildContext context, WidgetRef ref, return; } + final result = await ref.read(actionProvider.notifier).moveToLockFolder(source, context); + if (result == null) { + return; + } + if (source == ActionSource.viewer) { EventStream.shared.emit(const ViewerReloadAssetEvent()); } - final result = await ref.read(actionProvider.notifier).moveToLockFolder(source); ref.read(multiSelectProvider.notifier).reset(); final successMessage = 'move_to_lock_folder_action_prompt'.t( diff --git a/mobile/lib/providers/infrastructure/action.provider.dart b/mobile/lib/providers/infrastructure/action.provider.dart index ed62b9a0e8..7e888840e0 100644 --- a/mobile/lib/providers/infrastructure/action.provider.dart +++ b/mobile/lib/providers/infrastructure/action.provider.dart @@ -10,6 +10,7 @@ import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; import 'package:immich_mobile/domain/models/asset_edit.model.dart'; import 'package:immich_mobile/domain/services/asset.service.dart'; import 'package:immich_mobile/domain/services/remote_album.service.dart'; +import 'package:immich_mobile/extensions/platform_extensions.dart'; import 'package:immich_mobile/models/download/livephotos_medatada.model.dart'; import 'package:immich_mobile/providers/asset_viewer/asset_viewer.provider.dart'; import 'package:immich_mobile/providers/backup/asset_upload_progress.provider.dart'; @@ -27,6 +28,7 @@ import 'package:immich_mobile/services/download.service.dart'; import 'package:immich_mobile/services/foreground_upload.service.dart'; import 'package:immich_mobile/utils/semver.dart'; import 'package:immich_mobile/widgets/asset_grid/delete_dialog.dart'; +import 'package:immich_mobile/widgets/common/confirm_dialog.dart'; import 'package:logging/logging.dart'; import 'package:openapi/api.dart'; @@ -200,9 +202,24 @@ class ActionNotifier extends Notifier { } } - Future moveToLockFolder(ActionSource source) async { + Future moveToLockFolder(ActionSource source, BuildContext context) async { final ids = _getOwnedRemoteIdsForSource(source); final localIds = _getLocalIdsForSource(source, ignoreLocalOnly: true); + + if (localIds.isNotEmpty) { + final confirmed = await showDialog( + context: context, + builder: (_) => ConfirmDialog( + title: "move_to_locked_folder", + content: CurrentPlatform.isAndroid ? "delete_dialog_alert_local" : "move_to_locked_folder_local_ios", + ok: "confirm", + ), + ); + if (confirmed != true) { + return null; + } + } + try { await _service.moveToLockFolder(ids, localIds); return ActionResult(count: ids.length, success: true); diff --git a/mobile/lib/repositories/asset_media.repository.dart b/mobile/lib/repositories/asset_media.repository.dart index 4c9b6f6009..1dda347420 100644 --- a/mobile/lib/repositories/asset_media.repository.dart +++ b/mobile/lib/repositories/asset_media.repository.dart @@ -45,9 +45,9 @@ class AssetMediaRepository { return false; } - Future> deleteAll(List ids) async { + Future> deleteAll(List ids, {bool trash = true}) async { if (CurrentPlatform.isAndroid) { - if (await _androidSupportsTrash()) { + if (trash && await _androidSupportsTrash()) { return PhotoManager.editor.android.moveToTrash( ids.map((e) => AssetEntity(id: e, width: 1, height: 1, typeInt: 0)).toList(), ); diff --git a/mobile/lib/services/action.service.dart b/mobile/lib/services/action.service.dart index 8e01777c5d..be48618de8 100644 --- a/mobile/lib/services/action.service.dart +++ b/mobile/lib/services/action.service.dart @@ -92,9 +92,9 @@ class ActionService { await _assetApiRepository.updateVisibility(remoteIds, AssetVisibilityEnum.locked); await _remoteAssetRepository.updateVisibility(remoteIds, AssetVisibility.locked); - // Ask user if they want to delete local copies + // Locked assets stay on the server, so permanently delete the local copies instead of trashing them if (localIds.isNotEmpty) { - await _deleteLocalAssets(localIds); + await _deleteLocalAssets(localIds, trash: false); } } @@ -313,12 +313,12 @@ class ActionService { } } - Future _deleteLocalAssets(List localIds) async { - final deletedIds = await _assetMediaRepository.deleteAll(localIds); + Future _deleteLocalAssets(List localIds, {bool trash = true}) async { + final deletedIds = await _assetMediaRepository.deleteAll(localIds, trash: trash); if (deletedIds.isEmpty) { return 0; } - if (CurrentPlatform.isAndroid && Store.get(StoreKey.manageLocalMediaAndroid, false)) { + if (trash && CurrentPlatform.isAndroid && Store.get(StoreKey.manageLocalMediaAndroid, false)) { await _trashedLocalAssetRepository.applyTrashedAssets(deletedIds); } else { await _localAssetRepository.delete(deletedIds); diff --git a/mobile/test/services/action.service_test.dart b/mobile/test/services/action.service_test.dart index ef5ece38e9..f3c215c7cb 100644 --- a/mobile/test/services/action.service_test.dart +++ b/mobile/test/services/action.service_test.dart @@ -2,6 +2,8 @@ import 'package:drift/drift.dart' as drift; import 'package:drift/native.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:immich_mobile/constants/enums.dart'; +import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; import 'package:immich_mobile/domain/models/store.model.dart'; import 'package:immich_mobile/domain/services/store.service.dart'; import 'package:immich_mobile/entities/store.entity.dart'; @@ -147,13 +149,13 @@ void main() { await Store.put(StoreKey.manageLocalMediaAndroid, true); const ids = ['a', 'b']; - when(() => assetMediaRepository.deleteAll(ids)).thenAnswer((_) async => ids); + when(() => assetMediaRepository.deleteAll(ids, trash: true)).thenAnswer((_) async => ids); when(() => trashedLocalAssetRepository.applyTrashedAssets(ids)).thenAnswer((_) async {}); final result = await sut.deleteLocal(ids); expect(result, ids.length); - verify(() => assetMediaRepository.deleteAll(ids)).called(1); + verify(() => assetMediaRepository.deleteAll(ids, trash: true)).called(1); verify(() => trashedLocalAssetRepository.applyTrashedAssets(ids)).called(1); verifyNever(() => localAssetRepository.delete(any())); }); @@ -162,13 +164,13 @@ void main() { await Store.put(StoreKey.manageLocalMediaAndroid, false); const ids = ['c']; - when(() => assetMediaRepository.deleteAll(ids)).thenAnswer((_) async => ids); + when(() => assetMediaRepository.deleteAll(ids, trash: true)).thenAnswer((_) async => ids); when(() => localAssetRepository.delete(ids)).thenAnswer((_) async {}); final result = await sut.deleteLocal(ids); expect(result, ids.length); - verify(() => assetMediaRepository.deleteAll(ids)).called(1); + verify(() => assetMediaRepository.deleteAll(ids, trash: true)).called(1); verify(() => localAssetRepository.delete(ids)).called(1); verifyNever(() => trashedLocalAssetRepository.applyTrashedAssets(any())); }); @@ -177,14 +179,46 @@ void main() { await Store.put(StoreKey.manageLocalMediaAndroid, true); const ids = ['x']; - when(() => assetMediaRepository.deleteAll(ids)).thenAnswer((_) async => []); + when(() => assetMediaRepository.deleteAll(ids, trash: true)).thenAnswer((_) async => []); final result = await sut.deleteLocal(ids); expect(result, 0); - verify(() => assetMediaRepository.deleteAll(ids)).called(1); + verify(() => assetMediaRepository.deleteAll(ids, trash: true)).called(1); verifyNever(() => trashedLocalAssetRepository.applyTrashedAssets(any())); verifyNever(() => localAssetRepository.delete(any())); }); }); + + group('ActionService.moveToLockFolder', () { + const remoteIds = ['r1', 'r2']; + const localIds = ['l1', 'l2']; + + test('permanently deletes local copies without trashing, even when Android trash handling is on', () async { + await Store.put(StoreKey.manageLocalMediaAndroid, true); + + when(() => assetApiRepository.updateVisibility(remoteIds, AssetVisibilityEnum.locked)).thenAnswer((_) async {}); + when(() => remoteAssetRepository.updateVisibility(remoteIds, AssetVisibility.locked)).thenAnswer((_) async {}); + when(() => assetMediaRepository.deleteAll(localIds, trash: false)).thenAnswer((_) async => localIds); + when(() => localAssetRepository.delete(localIds)).thenAnswer((_) async {}); + + await sut.moveToLockFolder(remoteIds, localIds); + + verify(() => assetApiRepository.updateVisibility(remoteIds, AssetVisibilityEnum.locked)).called(1); + verify(() => remoteAssetRepository.updateVisibility(remoteIds, AssetVisibility.locked)).called(1); + verify(() => assetMediaRepository.deleteAll(localIds, trash: false)).called(1); + verify(() => localAssetRepository.delete(localIds)).called(1); + verifyNever(() => trashedLocalAssetRepository.applyTrashedAssets(any())); + }); + + test('locks remote assets without touching local media when there are no local copies', () async { + when(() => assetApiRepository.updateVisibility(remoteIds, AssetVisibilityEnum.locked)).thenAnswer((_) async {}); + when(() => remoteAssetRepository.updateVisibility(remoteIds, AssetVisibility.locked)).thenAnswer((_) async {}); + + await sut.moveToLockFolder(remoteIds, const []); + + verify(() => assetApiRepository.updateVisibility(remoteIds, AssetVisibilityEnum.locked)).called(1); + verifyNever(() => assetMediaRepository.deleteAll(any(), trash: any(named: 'trash'))); + }); + }); }