fix(mobile): permanently delete local copies when moving to the locked folder (#29730)
Some checks failed
CLI Build / CLI Publish (push) Has been cancelled
CodeQL / Analyze (push) Has been cancelled
CodeQL / Analyze-1 (push) Has been cancelled
Docker / pre-job (push) Has been cancelled
Docs build / pre-job (push) Has been cancelled
Zizmor / Zizmor (push) Has been cancelled
Static Code Analysis / pre-job (push) Has been cancelled
Test / pre-job (push) Has been cancelled
Test / ShellCheck (push) Has been cancelled
Test / OpenAPI Clients (push) Has been cancelled
Test / SQL Schema Checks (push) Has been cancelled
CLI Build / Docker (push) Has been cancelled
Docker / Re-Tag ML (push) Has been cancelled
Docker / Re-Tag ML-1 (push) Has been cancelled
Docker / Re-Tag ML-2 (push) Has been cancelled
Docker / Re-Tag ML-3 (push) Has been cancelled
Docker / Re-Tag ML-4 (push) Has been cancelled
Docker / Re-Tag ML-5 (push) Has been cancelled
Docker / Re-Tag Server (push) Has been cancelled
Docker / Build and Push ML (push) Has been cancelled
Docker / Build and Push ML-1 (push) Has been cancelled
Docker / Build and Push ML-2 (push) Has been cancelled
Docker / Build and Push ML-3 (push) Has been cancelled
Docker / Build and Push ML-4 (push) Has been cancelled
Docker / Build and Push ML-5 (push) Has been cancelled
Docker / Build and Push Server (push) Has been cancelled
Docker / Mirror to Docker Hub (push) Has been cancelled
Docker / Mirror to Docker Hub-1 (push) Has been cancelled
Docker / Mirror to Docker Hub-2 (push) Has been cancelled
Docker / Mirror to Docker Hub-3 (push) Has been cancelled
Docker / Mirror to Docker Hub-4 (push) Has been cancelled
Docker / Mirror to Docker Hub-5 (push) Has been cancelled
Docker / Mirror to Docker Hub-6 (push) Has been cancelled
Docker / Docker Build & Push Server Success (push) Has been cancelled
Docker / Docker Build & Push ML Success (push) Has been cancelled
Docs build / Docs Build (push) Has been cancelled
Static Code Analysis / Run Dart Code Analysis (push) Has been cancelled
Test / Scripts unit tests (push) Has been cancelled
Test / Test & Lint Server (push) Has been cancelled
Test / Unit Test CLI (push) Has been cancelled
Test / Unit Test CLI (Windows) (push) Has been cancelled
Test / Lint Web (push) Has been cancelled
Test / Test Web (push) Has been cancelled
Test / Test i18n (push) Has been cancelled
Test / End-to-End Lint (push) Has been cancelled
Test / Medium Tests (Server) (push) Has been cancelled
Test / End-to-End Tests (Server & CLI) (push) Has been cancelled
Test / End-to-End Tests (Server & CLI)-1 (push) Has been cancelled
Test / End-to-End Tests (Web) (push) Has been cancelled
Test / End-to-End Tests (Web)-1 (push) Has been cancelled
Test / End-to-End Tests Success (push) Has been cancelled
Test / Unit Test Mobile (push) Has been cancelled
Test / Unit Test ML (push) Has been cancelled
Test / .github Files Formatting (push) Has been cancelled

* permanently delete local copies when moving to the locked folder

* simplify the delete path and move the stubs into the mock defaults

* drop the kept count from the partial message and log the mismatch instead
This commit is contained in:
Santo Shakil 2026-08-22 08:12:11 +06:00 committed by GitHub
parent 11cdfa9c5e
commit 2237b28813
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 130 additions and 19 deletions

View file

@ -849,6 +849,7 @@
"delete_api_key_prompt": "Are you sure you want to delete this API key?",
"delete_dialog_alert": "These items will be permanently deleted from Immich and from your device",
"delete_dialog_alert_local": "These items will be permanently removed from your device but still be available on the Immich server",
"delete_dialog_alert_local_ios": "These items will be deleted from Photos, but will still be available on the Immich server. They will be in Recently Deleted for 30 days.",
"delete_dialog_alert_local_non_backed_up": "Some of the items aren't backed up to Immich and will be permanently removed from your device",
"delete_dialog_title": "Delete Permanently",
"delete_duplicates_confirmation": "Are you sure you want to permanently delete these duplicates?",
@ -1482,6 +1483,7 @@
"move_to": "Move to",
"move_to_device_trash": "Move to device trash",
"move_to_lock_folder_action_prompt": "{count} added to the locked folder",
"move_to_lock_folder_partial_prompt": "{count} added to the locked folder, some local copies were kept",
"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",
"moved_to_trash": "Moved to trash",

View file

@ -176,17 +176,17 @@ class AssetService {
}
}
Future<int> deleteLocal(List<String> localIds) async {
Future<int> deleteLocal(List<String> localIds, {bool trash = true}) async {
if (localIds.isEmpty) {
return 0;
}
final deletedIds = await _mediaRepository.deleteAll(localIds);
final deletedIds = await _mediaRepository.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 _trashedLocalRepository.applyTrashedAssets(deletedIds);
} else {
await _localRepository.deleteAssets(deletedIds);

View file

@ -1,12 +1,15 @@
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/enums.dart';
import 'package:immich_mobile/extensions/platform_extensions.dart';
import 'package:immich_mobile/generated/translations.g.dart';
import 'package:immich_mobile/presentation/actions/action.dart';
import 'package:immich_mobile/providers/infrastructure/asset.provider.dart';
import 'package:immich_mobile/providers/infrastructure/toast.provider.dart';
import 'package:immich_mobile/services/toast.service.dart';
import 'package:immich_mobile/utils/error_handler.dart';
import 'package:immich_mobile/widgets/common/confirm_dialog.dart';
import 'package:logging/logging.dart';
typedef _State = ({bool shouldLock, List<String> assetIds, List<String> localIds});
@ -29,6 +32,8 @@ final _stateProvider = Provider.family.autoDispose<_State?, ActionSource>((ref,
class LockAction extends AssetActionBuilder {
const LockAction({required super.source});
static final Logger _log = Logger('LockAction');
@override
ActionItem? create(BuildContext context, WidgetRef ref) {
final shouldLock = ref.watch(_stateProvider(source).select((state) => state?.shouldLock));
@ -50,19 +55,44 @@ class LockAction extends AssetActionBuilder {
}
final (:shouldLock, :assetIds, :localIds) = state;
final message = shouldLock
? context.t.move_to_lock_folder_action_prompt(count: assetIds.length)
: context.t.remove_from_lock_folder_action_prompt(count: assetIds.length);
if (shouldLock && localIds.isNotEmpty) {
final confirmed = await showDialog<bool>(
context: context,
builder: (_) => ConfirmDialog(
title: context.t.move_to_locked_folder,
content: CurrentPlatform.isAndroid
? context.t.delete_dialog_alert_local
: context.t.delete_dialog_alert_local_ios,
ok: context.t.confirm,
),
);
if (confirmed != true || !context.mounted) {
return;
}
}
final assetService = ref.read(assetServiceProvider);
final toastService = ref.read(toastServiceProvider);
final clearSelection = ref.read(clearSelectionProvider(source));
try {
await assetService.update(assetIds, visibility: .some(shouldLock ? .locked : .timeline));
var keptCount = 0;
if (localIds.isNotEmpty) {
// A locked asset still sits in the device gallery, so offer to remove the local copy.
await assetService.deleteLocal(localIds);
keptCount = localIds.length - await assetService.deleteLocal(localIds, trash: false);
if (keptCount != 0) {
_log.warning('Only deleted ${localIds.length - keptCount} of ${localIds.length} local copies');
}
}
if (!context.mounted) {
return;
}
final message = shouldLock
? keptCount > 0
? context.t.move_to_lock_folder_partial_prompt(count: assetIds.length)
: context.t.move_to_lock_folder_action_prompt(count: assetIds.length)
: context.t.remove_from_lock_folder_action_prompt(count: assetIds.length);
// Unlocking is a sensitive action and requires an elevated session
final toast = shouldLock
? null

View file

@ -45,15 +45,11 @@ class AssetMediaRepository {
return false;
}
Future<List<String>> deleteAll(List<String> ids) async {
if (CurrentPlatform.isAndroid) {
if (await _androidSupportsTrash()) {
return PhotoManager.editor.android.moveToTrash(
ids.map((e) => AssetEntity(id: e, width: 1, height: 1, typeInt: 0)).toList(),
);
} else {
return PhotoManager.editor.deleteWithIds(ids);
}
Future<List<String>> deleteAll(List<String> ids, {bool trash = true}) async {
if (trash && CurrentPlatform.isAndroid && await _androidSupportsTrash()) {
return PhotoManager.editor.android.moveToTrash(
ids.map((e) => AssetEntity(id: e, width: 1, height: 1, typeInt: 0)).toList(),
);
}
return PhotoManager.editor.deleteWithIds(ids);
}

View file

@ -65,6 +65,7 @@ class RepositoryMocks {
_stubAssetApiRepository();
_stubAssetMediaRepository();
_stubDownloadRepository();
_stubTrashedAssetRepository();
_stubPermissionRepository();
}
@ -86,6 +87,7 @@ class RepositoryMocks {
void _stubLocalAssetRepository() {
when(localAsset.reconcileHashesFromCloudId).thenAnswer((_) async => {});
when(localAsset.updateHashes).thenAnswer((_) async => {});
when(localAsset.deleteAssets).thenAnswer((_) async => {});
}
void _stubNativeSyncApi() {
@ -97,6 +99,7 @@ class RepositoryMocks {
}
void _stubAssetMediaRepository() {
when(assetMedia.deleteAll).thenAnswer((inv) async => inv.positionalArguments.first as List<String>);
when(assetMedia.shareAssets).thenAnswer((_) async => 1);
when(assetMedia.getOriginalFilename).thenAnswer((_) async => null);
}
@ -105,6 +108,10 @@ class RepositoryMocks {
when(download.downloadAllAssets).thenAnswer((_) async => const []);
}
void _stubTrashedAssetRepository() {
when(() => trashedAsset.applyTrashedAssets(any())).thenAnswer((_) async => {});
}
void _stubPermissionRepository() {
when(permission.getStatus).thenAnswer((_) async => DevicePermissionStatus.denied);
when(permission.request).thenAnswer((_) async => DevicePermissionStatus.denied);
@ -254,6 +261,9 @@ extension type const LocalAssetRepositoryStub(MockLocalAssetRepository repo)
Future<void> Function() get updateHashes =>
() => repo.updateHashes(any());
Future<void> Function() get deleteAssets =>
() => repo.deleteAssets(any());
}
extension type const RemoteAssetRepositoryStub(MockRemoteAssetRepository repo)
@ -357,7 +367,7 @@ extension type const AssetServiceStub(MockAssetService service) implements Stub<
() => service.applyEdits(any(), any());
Future<int> Function() get deleteLocal =>
() => service.deleteLocal(any());
() => service.deleteLocal(any(), trash: any(named: 'trash'));
}
extension type const RemoteAlbumServiceStub(MockRemoteAlbumService service) implements Stub<MockRemoteAlbumService> {
@ -393,6 +403,9 @@ extension type const AssetApiRepositoryStub(MockAssetApiRepository api) implemen
}
extension type const AssetMediaRepositoryStub(MockAssetMediaRepository api) implements Stub<MockAssetMediaRepository> {
Future<List<String>> Function() get deleteAll =>
() => api.deleteAll(any(), trash: any(named: 'trash'));
Future<int> Function() get shareAssets =>
() => api.shareAssets(
any(),

View file

@ -1,8 +1,10 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/generated/translations.g.dart';
import 'package:immich_mobile/presentation/actions/action.widget.dart';
import 'package:immich_mobile/presentation/actions/lock.action.dart';
import 'package:immich_mobile/widgets/common/confirm_dialog.dart';
import 'package:immich_ui/immich_ui.dart';
import 'package:mocktail/mocktail.dart';
@ -29,6 +31,13 @@ void main() {
Future<void> pumpLock(WidgetTester tester, Set<BaseAsset> selection) =>
tester.pumpTestAction(context, const LockAction(source: .timeline), overrides: context.selected(selection));
Future<void> respondToDialog(WidgetTester tester, {required bool confirm}) async {
await tester.pumpUntilFound(find.byType(ConfirmDialog));
expect(find.text(StaticTranslations.instance.move_to_locked_folder), findsOneWidget);
await tester.tap(find.text(confirm ? StaticTranslations.instance.confirm : StaticTranslations.instance.cancel));
await tester.pumpAndSettle();
}
group('LockAction', () {
testWidgets('locks the eligible owned assets', (tester) async {
final asset = owned();
@ -79,8 +88,28 @@ void main() {
final remoteOnly = owned();
await pumpLock(tester, {merged, remoteOnly});
await respondToDialog(tester, confirm: true);
verify(() => assetService.deleteLocal(['local-1'])).called(1);
verify(() => assetService.deleteLocal(['local-1'], trash: false)).called(1);
});
testWidgets('does nothing when the warning is cancelled', (tester) async {
final merged = RemoteAssetFactory.create(ownerId: context.currentUser.id, localId: 'local-1');
await pumpLock(tester, {merged});
await respondToDialog(tester, confirm: false);
verifyNever(() => assetService.update(any(), visibility: any(named: 'visibility')));
verifyNever(() => assetService.deleteLocal(any(), trash: any(named: 'trash')));
});
testWidgets('locks remote-only assets without a warning', (tester) async {
final remoteOnly = owned();
await pumpLock(tester, {remoteOnly});
expect(find.byType(ConfirmDialog), findsNothing);
verify(() => assetService.update([remoteOnly.id], visibility: const .some(.locked))).called(1);
});
testWidgets('leaves the local copies alone when unlocking', (tester) async {

View file

@ -1,5 +1,13 @@
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/domain/models/store.model.dart';
import 'package:immich_mobile/domain/services/asset.service.dart';
import 'package:immich_mobile/domain/services/store.service.dart';
import 'package:immich_mobile/entities/store.entity.dart';
import 'package:immich_mobile/infrastructure/repositories/db.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/store.repository.dart';
import 'package:mocktail/mocktail.dart';
import '../../infrastructure/repository.mock.dart';
@ -12,6 +20,20 @@ void main() {
late MockAssetApiRepository apiRepository;
late MockRemoteAssetRepository remoteRepository;
late MockRemoteExifRepository exifRepository;
late Drift db;
setUpAll(() async {
TestWidgetsFlutterBinding.ensureInitialized();
debugDefaultTargetPlatformOverride = TargetPlatform.android;
db = Drift(drift.DatabaseConnection(NativeDatabase.memory(), closeStreamsSynchronously: true));
await StoreService.init(storeRepository: StoreRepository(db));
});
tearDownAll(() async {
debugDefaultTargetPlatformOverride = null;
await Store.clear();
await db.close();
});
setUp(() {
mocks = RepositoryMocks();
@ -22,13 +44,17 @@ void main() {
sut = AssetService(
remoteRepository: remoteRepository,
exifRepository: exifRepository,
localRepository: MockLocalAssetRepository(),
localRepository: mocks.localAsset.repo,
apiRepository: apiRepository,
mediaRepository: mocks.assetMedia.api,
trashedLocalRepository: mocks.trashedAsset,
);
});
tearDown(() async {
await Store.delete(StoreKey.manageLocalMediaAndroid);
});
group('AssetService.updateDateTime', () {
const ids = ['asset_id_1'];
@ -78,4 +104,19 @@ void main() {
verifyZeroInteractions(remoteRepository);
});
});
group('AssetService.deleteLocal', () {
const ids = ['l1', 'l2'];
test('permanently deletes local copies without trashing, even when Android trash handling is on', () async {
await Store.put(StoreKey.manageLocalMediaAndroid, true);
final result = await sut.deleteLocal(ids, trash: false);
expect(result, ids.length);
verify(() => mocks.assetMedia.api.deleteAll(ids, trash: false)).called(1);
verify(() => mocks.localAsset.repo.deleteAssets(ids)).called(1);
verifyNever(() => mocks.trashedAsset.applyTrashedAssets(any()));
});
});
}