From 57485023ae4efe5b68a3626e83ea05814e98aa2b Mon Sep 17 00:00:00 2001 From: Alex Date: Mon, 9 Feb 2026 11:48:55 -0600 Subject: [PATCH] fix: free up space using small batch size to reliably work on Android (#26047) * fix: free up space delete in small batch * fix: free up space delete in small batch --- docs/docs/features/mobile-app.mdx | 2 +- mobile/lib/services/cleanup.service.dart | 20 +++-- .../test/services/cleanup.service_test.dart | 73 +++++++++++++++++++ 3 files changed, 89 insertions(+), 6 deletions(-) create mode 100644 mobile/test/services/cleanup.service_test.dart diff --git a/docs/docs/features/mobile-app.mdx b/docs/docs/features/mobile-app.mdx index 02b5d492f4..59a4844c46 100644 --- a/docs/docs/features/mobile-app.mdx +++ b/docs/docs/features/mobile-app.mdx @@ -66,7 +66,7 @@ Now make sure that the local album is selected in the backup screen (steps 1-2 a - **Keep on device:** You can choose to restrict removal to `Always keep` **All photos** or **All videos**, regardless of other settings. This setting can hamper freeing up space significantly — with 80 GB of videos and 40 GB photos, selecting `Always keep photos` retains thousands of photos on your device. 2. **Scan & Review:** Before any files are removed, you are presented with a review screen to verify which items will be deleted and how much storage is reclamable. -3. **Deletion:** Confirmed items are moved to your device's native Trash/Recycle Bin. +3. **Deletion:** Confirmed items are moved to your device's native Trash/Recycle Bin. For large queues, Immich processes deletion in batches for stability (`2000` assets per batch on Android, `10000` per batch on iOS). :::info reclaim storage To use the reclaimed space right away, you must empty the system/gallery trash manually outside of Immich. diff --git a/mobile/lib/services/cleanup.service.dart b/mobile/lib/services/cleanup.service.dart index 86ccac8067..fca5584859 100644 --- a/mobile/lib/services/cleanup.service.dart +++ b/mobile/lib/services/cleanup.service.dart @@ -1,5 +1,6 @@ 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/infrastructure/repositories/local_asset.repository.dart'; import 'package:immich_mobile/providers/infrastructure/asset.provider.dart'; import 'package:immich_mobile/repositories/asset_media.repository.dart'; @@ -9,6 +10,8 @@ final cleanupServiceProvider = Provider((ref) { }); class CleanupService { + static final int _deleteBatchSize = CurrentPlatform.isAndroid ? 2000 : 10000; + final DriftLocalAssetRepository _localAssetRepository; final AssetMediaRepository _assetMediaRepository; @@ -35,13 +38,20 @@ class CleanupService { return 0; } - final deletedIds = await _assetMediaRepository.deleteAll(localIds); - if (deletedIds.isNotEmpty) { - await _localAssetRepository.delete(deletedIds); - return deletedIds.length; + int deletedCount = 0; + + for (int index = 0; index < localIds.length; index += _deleteBatchSize) { + final end = index + _deleteBatchSize < localIds.length ? index + _deleteBatchSize : localIds.length; + final batch = localIds.sublist(index, end); + + final deletedIds = await _assetMediaRepository.deleteAll(batch); + if (deletedIds.isNotEmpty) { + await _localAssetRepository.delete(deletedIds); + deletedCount += deletedIds.length; + } } - return 0; + return deletedCount; } /// Returns album IDs that should be kept by default (e.g., messaging app albums) diff --git a/mobile/test/services/cleanup.service_test.dart b/mobile/test/services/cleanup.service_test.dart new file mode 100644 index 0000000000..2038941ecb --- /dev/null +++ b/mobile/test/services/cleanup.service_test.dart @@ -0,0 +1,73 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:immich_mobile/extensions/platform_extensions.dart'; +import 'package:immich_mobile/services/cleanup.service.dart'; +import 'package:mocktail/mocktail.dart'; + +import '../infrastructure/repository.mock.dart'; +import '../repository.mocks.dart'; + +void main() { + late CleanupService sut; + + late MockDriftLocalAssetRepository localAssetRepository; + late MockAssetMediaRepository assetMediaRepository; + + setUp(() { + localAssetRepository = MockDriftLocalAssetRepository(); + assetMediaRepository = MockAssetMediaRepository(); + sut = CleanupService(localAssetRepository, assetMediaRepository); + }); + + group('CleanupService.deleteLocalAssets', () { + test('returns 0 and does nothing for empty input', () async { + final result = await sut.deleteLocalAssets([]); + + expect(result, 0); + verifyNever(() => assetMediaRepository.deleteAll(any())); + verifyNever(() => localAssetRepository.delete(any())); + }); + + test('deletes in a single batch when under limit', () async { + final ids = List.generate(999, (i) => 'asset-$i'); + + when(() => assetMediaRepository.deleteAll(any())).thenAnswer((invocation) async { + return (invocation.positionalArguments.first as List).toList(); + }); + when(() => localAssetRepository.delete(any())).thenAnswer((_) async {}); + + final result = await sut.deleteLocalAssets(ids); + + expect(result, ids.length); + verify(() => assetMediaRepository.deleteAll(ids)).called(1); + verify(() => localAssetRepository.delete(ids)).called(1); + }); + + test('deletes in platform-specific batches when over limit', () async { + final batchSize = CurrentPlatform.isAndroid ? 2000 : 10000; + final ids = List.generate(batchSize * 2 + 501, (i) => 'asset-$i'); + final capturedBatches = >[]; + + when(() => assetMediaRepository.deleteAll(any())).thenAnswer((invocation) async { + final batch = (invocation.positionalArguments.first as List).toList(); + capturedBatches.add(batch); + return batch; + }); + when(() => localAssetRepository.delete(any())).thenAnswer((_) async {}); + + final result = await sut.deleteLocalAssets(ids); + + expect(result, ids.length); + expect(capturedBatches.length, 3); + expect(capturedBatches[0].length, batchSize); + expect(capturedBatches[1].length, batchSize); + expect(capturedBatches[2].length, 501); + expect(capturedBatches[0].first, 'asset-0'); + expect(capturedBatches[0].last, 'asset-${batchSize - 1}'); + expect(capturedBatches[1].first, 'asset-$batchSize'); + expect(capturedBatches[1].last, 'asset-${batchSize * 2 - 1}'); + expect(capturedBatches[2].first, 'asset-${batchSize * 2}'); + expect(capturedBatches[2].last, 'asset-${batchSize * 2 + 500}'); + verify(() => localAssetRepository.delete(any())).called(3); + }); + }); +}