Compare commits

...

5 Commits

Author SHA1 Message Date
Santo Shakil
65c17b14b8 Merge remote-tracking branch 'origin/main' into fix/android-locked-folder-delete
# Conflicts:
#	mobile/lib/services/action.service.dart
2026-07-23 01:57:24 +06:00
Santo Shakil
df79e74084 fix(mobile): scope locked folder local deletes to owned assets and report cancelled deletions 2026-07-23 01:50:58 +06:00
Santo Shakil
f7e6ba2cda Merge remote-tracking branch 'origin/main' into fix/android-locked-folder-delete 2026-07-22 21:51:33 +06:00
Santo Shakil
364003e506 Merge remote-tracking branch 'origin/main' into fix/android-locked-folder-delete
# Conflicts:
#	mobile/lib/providers/infrastructure/action.provider.dart
2026-07-22 13:48:53 +06:00
Santo Shakil
49714a3b30 fix(mobile): permanently delete local copies when moving to the locked folder 2026-07-08 14:18:20 +06:00
7 changed files with 182 additions and 29 deletions

View File

@@ -1461,6 +1461,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 deleted from Photos, but will still be available on the Immich server. They will be in Recently Deleted for 30 days.",
"moved_to_trash": "Moved to trash",
"mute_memories": "Mute Memories",
"my_albums": "My albums",

View File

@@ -16,11 +16,15 @@ Future<void> 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(

View File

@@ -9,6 +9,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/providers/asset_viewer/asset_viewer.provider.dart';
import 'package:immich_mobile/providers/backup/asset_upload_progress.provider.dart';
import 'package:immich_mobile/providers/infrastructure/album.provider.dart';
@@ -24,6 +25,7 @@ import 'package:immich_mobile/services/action.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';
@@ -68,14 +70,11 @@ class ActionNotifier extends Notifier<void> {
return _getAssets(source).whereType<RemoteAsset>().toIds().toList(growable: false);
}
List<String> _getLocalIdsForSource(ActionSource source, {bool ignoreLocalOnly = false}) {
List<String> _getLocalIdsForSource(ActionSource source) {
final Set<BaseAsset> assets = _getAssets(source);
final List<String> localIds = [];
for (final asset in assets) {
if (ignoreLocalOnly && asset.storage != AssetState.merged) {
continue;
}
if (asset is LocalAsset) {
localIds.add(asset.id);
} else if (asset is RemoteAsset && asset.localId != null) {
@@ -181,12 +180,28 @@ class ActionNotifier extends Notifier<void> {
}
}
Future<ActionResult> moveToLockFolder(ActionSource source) async {
final ids = _getOwnedRemoteIdsForSource(source);
final localIds = _getLocalIdsForSource(source, ignoreLocalOnly: true);
Future<ActionResult?> moveToLockFolder(ActionSource source, BuildContext context) async {
final assets = _getOwnedRemoteAssetsForSource(source);
final ids = assets.toIds().toList(growable: false);
final localIds = assets.map((asset) => asset.localId).nonNulls.toList(growable: false);
if (localIds.isNotEmpty) {
final confirmed = await showDialog<bool>(
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);
final deletedCount = await _service.moveToLockFolder(ids, localIds);
return ActionResult(count: ids.length, success: deletedCount == localIds.length);
} catch (error, stack) {
_logger.severe('Failed to move assets to lock folder', error, stack);
return ActionResult(count: ids.length, success: false, error: error.toString());

View File

@@ -45,9 +45,9 @@ class AssetMediaRepository {
return false;
}
Future<List<String>> deleteAll(List<String> ids) async {
Future<List<String>> deleteAll(List<String> 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(),
);

View File

@@ -88,14 +88,15 @@ class ActionService {
await _remoteAssetRepository.updateVisibility(remoteIds, AssetVisibility.timeline);
}
Future<void> moveToLockFolder(List<String> remoteIds, List<String> localIds) async {
Future<int> moveToLockFolder(List<String> remoteIds, List<String> localIds) async {
await _assetApiRepository.updateVisibility(remoteIds, .locked);
await _remoteAssetRepository.updateVisibility(remoteIds, AssetVisibility.locked);
// Ask user if they want to delete local copies
if (localIds.isNotEmpty) {
await _deleteLocalAssets(localIds);
// Locked assets stay on the server, so permanently delete the local copies instead of trashing them
if (localIds.isEmpty) {
return 0;
}
return _deleteLocalAssets(localIds, trash: false);
}
Future<void> removeFromLockFolder(List<String> remoteIds) async {
@@ -313,12 +314,12 @@ class ActionService {
}
}
Future<int> _deleteLocalAssets(List<String> localIds) async {
final deletedIds = await _assetMediaRepository.deleteAll(localIds);
Future<int> _deleteLocalAssets(List<String> 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);

View File

@@ -1,4 +1,4 @@
import 'package:flutter/widgets.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/enums.dart';
@@ -10,11 +10,14 @@ import 'package:immich_mobile/providers/asset_viewer/asset_viewer.provider.dart'
import 'package:immich_mobile/providers/infrastructure/action.provider.dart';
import 'package:immich_mobile/providers/infrastructure/asset.provider.dart';
import 'package:immich_mobile/providers/infrastructure/asset_viewer/asset.provider.dart';
import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
import 'package:immich_mobile/providers/user.provider.dart';
import 'package:immich_mobile/services/action.service.dart';
import 'package:immich_mobile/services/foreground_upload.service.dart';
import 'package:mocktail/mocktail.dart';
import '../../widget_tester_extensions.dart';
class MockActionService extends Mock implements ActionService {}
class MockAssetService extends Mock implements AssetService {}
@@ -77,7 +80,9 @@ void main() {
container.listen(assetExifProvider(_asset), (_, __) {});
await container.read(assetExifProvider(_asset).future);
final result = await container.read(actionProvider.notifier).editDateTime(ActionSource.viewer, FakeBuildContext());
final result = await container
.read(actionProvider.notifier)
.editDateTime(ActionSource.viewer, FakeBuildContext());
expect(result?.success, isTrue);
await container.read(assetExifProvider(_asset).future);
@@ -89,7 +94,9 @@ void main() {
container.listen(assetExifProvider(_asset), (_, __) {});
await container.read(assetExifProvider(_asset).future);
final result = await container.read(actionProvider.notifier).editDateTime(ActionSource.timeline, FakeBuildContext());
final result = await container
.read(actionProvider.notifier)
.editDateTime(ActionSource.timeline, FakeBuildContext());
expect(result?.success, isTrue);
await container.read(assetExifProvider(_asset).future);
@@ -102,11 +109,76 @@ void main() {
container.listen(assetExifProvider(_asset), (_, __) {});
await container.read(assetExifProvider(_asset).future);
final result = await container.read(actionProvider.notifier).editDateTime(ActionSource.viewer, FakeBuildContext());
final result = await container
.read(actionProvider.notifier)
.editDateTime(ActionSource.viewer, FakeBuildContext());
expect(result, isNull);
await container.read(assetExifProvider(_asset).future);
verify(() => assetService.getExif(_asset)).called(1);
});
});
group('moveToLockFolder', () {
Future<ActionResult?> runAction(WidgetTester tester) async {
late BuildContext context;
await tester.pumpConsumerWidget(
Builder(
builder: (value) {
context = value;
return const SizedBox.shrink();
},
),
);
final result = container.read(actionProvider.notifier).moveToLockFolder(ActionSource.timeline, context);
await tester.pumpAndSettle();
await tester.tap(find.byType(TextButton).last);
await tester.pumpAndSettle();
return result;
}
testWidgets('reports failure when local deletion is cancelled', (tester) async {
final asset = _asset.copyWith(localId: 'local-owned');
container.read(multiSelectProvider.notifier).selectAsset(asset);
when(() => actionService.moveToLockFolder(any(), any())).thenAnswer((_) async => 0);
final result = await runAction(tester);
expect(result?.success, isFalse);
verify(() => actionService.moveToLockFolder(['asset-1'], ['local-owned'])).called(1);
});
testWidgets('reports failure when only some local copies are deleted', (tester) async {
final first = _asset.copyWith(localId: 'local-1');
final second = _asset.copyWith(id: 'asset-2', localId: 'local-2', checksum: 'checksum-2');
container.read(multiSelectProvider.notifier)
..selectAsset(first)
..selectAsset(second);
when(() => actionService.moveToLockFolder(any(), any())).thenAnswer((_) async => 1);
final result = await runAction(tester);
expect(result?.success, isFalse);
});
testWidgets('deletes only owned local copies from a mixed selection', (tester) async {
final owned = _asset.copyWith(localId: 'local-owned');
final partner = _asset.copyWith(
id: 'asset-2',
localId: 'local-partner',
ownerId: 'partner-1',
checksum: 'checksum-2',
);
container.read(multiSelectProvider.notifier)
..selectAsset(owned)
..selectAsset(partner);
when(() => actionService.moveToLockFolder(any(), any())).thenAnswer((_) async => 1);
final result = await runAction(tester);
expect(result?.success, isTrue);
verify(() => actionService.moveToLockFolder(['asset-1'], ['local-owned'])).called(1);
});
});
}

View File

@@ -2,6 +2,7 @@ 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/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 +148,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 +163,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 +178,73 @@ void main() {
await Store.put(StoreKey.manageLocalMediaAndroid, true);
const ids = ['x'];
when(() => assetMediaRepository.deleteAll(ids)).thenAnswer((_) async => <String>[]);
when(() => assetMediaRepository.deleteAll(ids, trash: true)).thenAnswer((_) async => <String>[]);
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, AssetVisibility.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 {});
final result = await sut.moveToLockFolder(remoteIds, localIds);
expect(result, localIds.length);
verify(() => assetApiRepository.updateVisibility(remoteIds, AssetVisibility.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, AssetVisibility.locked)).thenAnswer((_) async {});
when(() => remoteAssetRepository.updateVisibility(remoteIds, AssetVisibility.locked)).thenAnswer((_) async {});
final result = await sut.moveToLockFolder(remoteIds, const []);
expect(result, 0);
verify(() => assetApiRepository.updateVisibility(remoteIds, AssetVisibility.locked)).called(1);
verifyNever(() => assetMediaRepository.deleteAll(any(), trash: any(named: 'trash')));
});
test('returns zero when local deletion is cancelled', () async {
when(() => assetApiRepository.updateVisibility(remoteIds, AssetVisibility.locked)).thenAnswer((_) async {});
when(() => remoteAssetRepository.updateVisibility(remoteIds, AssetVisibility.locked)).thenAnswer((_) async {});
when(() => assetMediaRepository.deleteAll(localIds, trash: false)).thenAnswer((_) async => <String>[]);
final result = await sut.moveToLockFolder(remoteIds, localIds);
expect(result, 0);
verify(() => assetMediaRepository.deleteAll(localIds, trash: false)).called(1);
verifyNever(() => localAssetRepository.delete(any()));
});
test('returns the number of local copies deleted from a partial result', () async {
const deletedIds = ['l1'];
when(() => assetApiRepository.updateVisibility(remoteIds, AssetVisibility.locked)).thenAnswer((_) async {});
when(() => remoteAssetRepository.updateVisibility(remoteIds, AssetVisibility.locked)).thenAnswer((_) async {});
when(() => assetMediaRepository.deleteAll(localIds, trash: false)).thenAnswer((_) async => deletedIds);
when(() => localAssetRepository.delete(deletedIds)).thenAnswer((_) async {});
final result = await sut.moveToLockFolder(remoteIds, localIds);
expect(result, deletedIds.length);
verify(() => localAssetRepository.delete(deletedIds)).called(1);
});
});
}