fix(mobile): ask before deleting local only photos on android 10 and below (#31146)

* ask before deleting local only photos on android 10 and below

* move the delete consent prompt into _cleanupLocalAssets

* ask on android 11 as well before deleting local photos

* ask before deleting only where android shows no prompt

* split the local delete prompt into its own helper

* pick the local delete dialog in one flat branch and rename notBackedUp to isLocalOnly
This commit is contained in:
Santo Shakil
2026-09-25 20:19:01 +06:00
committed by GitHub
parent 520a70891b
commit fbff5f41be
3 changed files with 132 additions and 31 deletions
@@ -10,12 +10,13 @@ import 'package:immich_mobile/providers/infrastructure/store.provider.dart';
import 'package:immich_mobile/providers/infrastructure/toast.provider.dart';
import 'package:immich_mobile/providers/server_info.provider.dart';
import 'package:immich_mobile/providers/user.provider.dart';
import 'package:immich_mobile/repositories/permission.repository.dart';
import 'package:immich_mobile/services/cleanup.service.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';
typedef _State = ({List<String> localIds, List<String> remoteIds, bool trash});
typedef _State = ({List<String> localIds, List<String> remoteIds, bool trash, bool isLocalOnly});
final _stateProvider = Provider.family.autoDispose<_State?, ActionSource>((ref, source) {
final assets = ref.watch(assetsActionProvider(source));
@@ -40,8 +41,10 @@ final _stateProvider = Provider.family.autoDispose<_State?, ActionSource>((ref,
// Assets already in the trash or in the locked folder are deleted outright, irrespective of the server setting.
final trash =
ownedRemote.isEmpty || (trashEnabled && !ownedRemote.every((asset) => asset.isTrashed || asset.isLocked));
final remoteIds = ownedRemote.map((asset) => asset.id).toList(growable: false);
final isLocalOnly = assets.any((asset) => asset.isLocalOnly);
return (localIds: localIds, remoteIds: ownedRemote.map((asset) => asset.id).toList(growable: false), trash: trash);
return (localIds: localIds, remoteIds: remoteIds, trash: trash, isLocalOnly: isLocalOnly);
}, dependencies: [assetsActionProvider]);
class DeleteAction extends AssetActionBuilder {
@@ -67,7 +70,7 @@ class DeleteAction extends AssetActionBuilder {
return;
}
final (:localIds, :remoteIds, :trash) = state;
final (:localIds, :remoteIds, :trash, :isLocalOnly) = state;
final assetService = ref.read(assetServiceProvider);
final toastService = ref.read(toastServiceProvider);
final clearSelection = ref.read(clearSelectionProvider(source));
@@ -77,9 +80,9 @@ class DeleteAction extends AssetActionBuilder {
// Only trashing is reversible; a permanent delete and a device cleanup are not.
ToastOption? undo;
if (remoteIds.isEmpty) {
message = await _removeLocalAssets(context, ref, localIds);
message = await _removeLocalAssets(context, ref, localIds, isLocalOnly: isLocalOnly);
} else if (trash) {
message = await _moveToTrash(context, ref, remoteIds, localIds);
message = await _moveToTrash(context, ref, remoteIds, localIds, isLocalOnly: isLocalOnly);
undo = .new(onUndo: () => assetService.restoreTrash(remoteIds));
} else {
message = await _deletePermanently(context, ref, remoteIds, localIds);
@@ -96,8 +99,13 @@ class DeleteAction extends AssetActionBuilder {
}
}
Future<String?> _removeLocalAssets(BuildContext context, WidgetRef ref, List<String> localIds) async {
final count = await _cleanupLocalAssets(context, ref, localIds);
Future<String?> _removeLocalAssets(
BuildContext context,
WidgetRef ref,
List<String> localIds, {
required bool isLocalOnly,
}) async {
final count = await _cleanupLocalAssets(context, ref, localIds, isLocalOnly: isLocalOnly);
if (count <= 0 || !context.mounted) {
return null;
}
@@ -109,11 +117,12 @@ class DeleteAction extends AssetActionBuilder {
BuildContext context,
WidgetRef ref,
List<String> remoteIds,
List<String> localIds,
) async {
List<String> localIds, {
required bool isLocalOnly,
}) async {
final assetService = ref.read(assetServiceProvider);
if (localIds.isNotEmpty) {
await _cleanupLocalAssets(context, ref, localIds);
await _cleanupLocalAssets(context, ref, localIds, isLocalOnly: isLocalOnly);
if (!context.mounted) {
return null;
}
@@ -201,38 +210,64 @@ class CleanupLocalAction extends AssetActionBuilder {
}
/// Removes the device copies of [assetIds], returning how many were deleted.
///
/// iOS and Android without MANAGE_MEDIA prompt the user
/// with MANAGE_MEDIA, we do it ourselves unless [requestCustomPrompt] is false.
Future<int> _cleanupLocalAssets(
BuildContext context,
WidgetRef ref,
List<String> assetIds, {
bool requestCustomPrompt = true,
bool isLocalOnly = false,
}) async {
if (assetIds.isEmpty) {
return 0;
}
final cleanupService = ref.read(cleanupServiceProvider);
final requiresPrompt =
requestCustomPrompt &&
CurrentPlatform.isAndroid &&
ref.read(storeServiceProvider).get(.manageLocalMediaAndroid, false);
if (requiresPrompt) {
final confirmed = await showDialog<bool>(
context: context,
builder: (_) => ConfirmDialog(
title: context.t.move_to_device_trash,
content: context.t.free_up_space_description,
ok: context.t.ok,
),
);
if (confirmed != true) {
if (!await _allowsDeletion(context, ref, requestCustomPrompt: requestCustomPrompt, isLocalOnly: isLocalOnly)) {
return 0;
}
}
return cleanupService.deleteLocalAssets(assetIds);
}
/// iOS always shows a prompt and adds to on device trash
/// Android varies by API version:
/// < 30: Does not prompt by default. Immediately deletes. We provide a prompt to notify the user
/// 30: Prompts by default. Immediately deletes
/// > 30: When `MANAGE_MEDIA` is present, does not prompt by default. When `MANAGE_MEDIA` is not present, does prompt
/// by default. Either way adds to trash. We provide a prompt to notify the user in the `MANAGE_MEDIA` case
Future<bool> _allowsDeletion(
BuildContext context,
WidgetRef ref, {
required bool requestCustomPrompt,
required bool isLocalOnly,
}) async {
if (!requestCustomPrompt || !CurrentPlatform.isAndroid) {
return true;
}
final manageMedia = ref.read(storeServiceProvider).get(.manageLocalMediaAndroid, false);
final versionRequiresPrompt = await ref.read(permissionRepositoryProvider).getAndroidSdkVersion() < 30;
if (!context.mounted) {
return false;
}
final ConfirmDialog dialog;
if (versionRequiresPrompt) {
dialog = ConfirmDialog(
title: context.t.delete_dialog_title,
content: isLocalOnly ? context.t.delete_dialog_alert_local_non_backed_up : context.t.delete_dialog_alert_local,
ok: isLocalOnly ? context.t.delete_local_dialog_ok_force : context.t.delete_permanently,
);
} else if (manageMedia) {
dialog = ConfirmDialog(
title: context.t.move_to_device_trash,
content: context.t.free_up_space_description,
ok: context.t.ok,
);
} else {
return true;
}
final confirmed = await showDialog<bool>(context: context, builder: (_) => dialog);
return confirmed == true;
}
@@ -205,6 +205,8 @@ void main() {
group('prompt handling', () {
testWidgets('permanent delete shows a single app dialog', (tester) async {
debugDefaultTargetPlatformOverride = TargetPlatform.android;
when(context.repository.permission.getAndroidSdkVersion).thenAnswer((_) async => 28);
final asset = owned(localId: 'local');
await pumpDelete(tester, {asset}, trashEnabled: false);
@@ -214,9 +216,10 @@ void main() {
await tester.tap(find.byType(TextButton).at(1));
await tester.pumpAndSettle();
expect(find.text(StaticTranslations.instance.move_to_device_trash), findsNothing);
expect(find.byType(ConfirmDialog), findsNothing);
verify(() => assetService.delete([asset.id])).called(1);
verify(() => cleanupService.deleteLocalAssets(['local'])).called(1);
debugDefaultTargetPlatformOverride = null;
});
testWidgets('local only delete on Android with MANAGE_MEDIA shows the prompt', (tester) async {
@@ -247,6 +250,49 @@ void main() {
verifyNever(() => cleanupService.deleteLocalAssets(any()));
debugDefaultTargetPlatformOverride = null;
});
testWidgets('local only delete on Android 30 shows no Flutter prompt', (tester) async {
debugDefaultTargetPlatformOverride = TargetPlatform.android;
when(context.repository.permission.getAndroidSdkVersion).thenAnswer((_) async => 30);
final asset = LocalAssetFactory.create();
await pumpDelete(tester, {asset});
await tester.pumpAndSettle();
expect(find.byType(ConfirmDialog), findsNothing);
verify(() => cleanupService.deleteLocalAssets([asset.id])).called(1);
debugDefaultTargetPlatformOverride = null;
});
testWidgets('backed up device copy below API 30 shows the delete prompt, not the backup warning', (tester) async {
debugDefaultTargetPlatformOverride = TargetPlatform.android;
when(context.repository.permission.getAndroidSdkVersion).thenAnswer((_) async => 28);
final asset = LocalAssetFactory.create(remoteId: 'remote');
await pumpDelete(tester, {asset});
await tester.pump(const Duration(milliseconds: 300));
expect(find.text(StaticTranslations.instance.delete_dialog_alert_local), findsOneWidget);
await respondToDialog(tester, confirm: true);
verify(() => cleanupService.deleteLocalAssets([asset.id])).called(1);
debugDefaultTargetPlatformOverride = null;
});
testWidgets('mixed selection below API 30 warns about the local only asset', (tester) async {
debugDefaultTargetPlatformOverride = TargetPlatform.android;
when(context.repository.permission.getAndroidSdkVersion).thenAnswer((_) async => 28);
final backedUp = owned(localId: 'local');
final localOnly = LocalAssetFactory.create();
await pumpDelete(tester, {backedUp, localOnly});
await tester.pump(const Duration(milliseconds: 300));
expect(find.text(StaticTranslations.instance.delete_dialog_alert_local_non_backed_up), findsOneWidget);
await respondToDialog(tester, confirm: true);
verify(() => assetService.trash([backedUp.id])).called(1);
verify(() => cleanupService.deleteLocalAssets(['local', localOnly.id])).called(1);
debugDefaultTargetPlatformOverride = null;
});
});
testWidgets('is hidden when nothing can be deleted', (tester) async {
@@ -284,5 +330,23 @@ void main() {
expect(find.byType(ImmichIconButton), findsNothing);
});
testWidgets('asks before deleting on Android below API 30', (tester) async {
debugDefaultTargetPlatformOverride = TargetPlatform.android;
when(context.repository.permission.getAndroidSdkVersion).thenAnswer((_) async => 28);
final asset = LocalAssetFactory.create(remoteId: 'remote');
await tester.pumpTestAction(
context,
const CleanupLocalAction(source: .timeline),
overrides: context.selected({asset}),
);
await tester.pump(const Duration(milliseconds: 300));
expect(find.text(StaticTranslations.instance.delete_dialog_alert_local), findsOneWidget);
await respondToDialog(tester, confirm: true);
verify(() => cleanupService.deleteLocalAssets([asset.id])).called(1);
debugDefaultTargetPlatformOverride = null;
});
});
}
@@ -25,6 +25,7 @@ import 'package:immich_mobile/providers/routes.provider.dart';
import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
import 'package:immich_mobile/providers/user.provider.dart';
import 'package:immich_mobile/repositories/asset_media.repository.dart';
import 'package:immich_mobile/repositories/permission.repository.dart';
import 'package:immich_mobile/services/cleanup.service.dart';
import 'package:immich_mobile/services/gcast.service.dart';
import 'package:immich_mobile/services/server_info.service.dart';
@@ -64,6 +65,7 @@ class PresentationContext {
serverInfoServiceProvider.overrideWithValue(service.serverInfo),
inLockedViewProvider.overrideWithValue(false),
assetMediaRepositoryProvider.overrideWithValue(repository.assetMedia.api),
permissionRepositoryProvider.overrideWithValue(repository.permission.repo),
tagApiRepositoryProvider.overrideWithValue(service.tag.repo),
];