diff --git a/mobile/drift_schemas/main/drift_schema_v15.json b/mobile/drift_schemas/main/drift_schema_v15.json new file mode 100644 index 000000000..8c56e7fa4 Binary files /dev/null and b/mobile/drift_schemas/main/drift_schema_v15.json differ diff --git a/mobile/lib/infrastructure/entities/trashed_local_asset.entity.dart b/mobile/lib/infrastructure/entities/trashed_local_asset.entity.dart index 308130b9e..2eaff5d5f 100644 --- a/mobile/lib/infrastructure/entities/trashed_local_asset.entity.dart +++ b/mobile/lib/infrastructure/entities/trashed_local_asset.entity.dart @@ -4,6 +4,13 @@ import 'package:immich_mobile/infrastructure/entities/trashed_local_asset.entity import 'package:immich_mobile/infrastructure/utils/asset.mixin.dart'; import 'package:immich_mobile/infrastructure/utils/drift_default.mixin.dart'; +enum TrashOrigin { + // do not change this order! + localSync, + remoteSync, + localUser, +} + @TableIndex.sql('CREATE INDEX IF NOT EXISTS idx_trashed_local_asset_checksum ON trashed_local_asset_entity (checksum)') @TableIndex.sql('CREATE INDEX IF NOT EXISTS idx_trashed_local_asset_album ON trashed_local_asset_entity (album_id)') class TrashedLocalAssetEntity extends Table with DriftDefaultsMixin, AssetEntityMixin { @@ -19,6 +26,8 @@ class TrashedLocalAssetEntity extends Table with DriftDefaultsMixin, AssetEntity IntColumn get orientation => integer().withDefault(const Constant(0))(); + IntColumn get source => intEnum()(); + @override Set get primaryKey => {id, albumId}; } diff --git a/mobile/lib/infrastructure/entities/trashed_local_asset.entity.drift.dart b/mobile/lib/infrastructure/entities/trashed_local_asset.entity.drift.dart index aab226c3a..eeec2b301 100644 Binary files a/mobile/lib/infrastructure/entities/trashed_local_asset.entity.drift.dart and b/mobile/lib/infrastructure/entities/trashed_local_asset.entity.drift.dart differ diff --git a/mobile/lib/infrastructure/repositories/db.repository.dart b/mobile/lib/infrastructure/repositories/db.repository.dart index b42aa3155..9ea0ba52e 100644 --- a/mobile/lib/infrastructure/repositories/db.repository.dart +++ b/mobile/lib/infrastructure/repositories/db.repository.dart @@ -95,7 +95,7 @@ class Drift extends $Drift implements IDatabaseRepository { } @override - int get schemaVersion => 14; + int get schemaVersion => 15; @override MigrationStrategy get migration => MigrationStrategy( @@ -190,6 +190,9 @@ class Drift extends $Drift implements IDatabaseRepository { await m.addColumn(v14.localAssetEntity, v14.localAssetEntity.latitude); await m.addColumn(v14.localAssetEntity, v14.localAssetEntity.longitude); }, + from14To15: (m, v15) async { + await m.addColumn(v15.trashedLocalAssetEntity, v15.trashedLocalAssetEntity.source); + }, ), ); diff --git a/mobile/lib/infrastructure/repositories/db.repository.steps.dart b/mobile/lib/infrastructure/repositories/db.repository.steps.dart index 21a3db527..38e0cec63 100644 Binary files a/mobile/lib/infrastructure/repositories/db.repository.steps.dart and b/mobile/lib/infrastructure/repositories/db.repository.steps.dart differ diff --git a/mobile/lib/infrastructure/repositories/trashed_local_asset.repository.dart b/mobile/lib/infrastructure/repositories/trashed_local_asset.repository.dart index 498e4227b..7e93713c4 100644 --- a/mobile/lib/infrastructure/repositories/trashed_local_asset.repository.dart +++ b/mobile/lib/infrastructure/repositories/trashed_local_asset.repository.dart @@ -48,7 +48,8 @@ class DriftTrashedLocalAssetRepository extends DriftDatabaseRepository { _db.remoteAssetEntity.checksum.equalsExp(_db.trashedLocalAssetEntity.checksum), ), ])..where( - _db.trashedLocalAssetEntity.albumId.isInQuery(selectedAlbumIds) & + _db.trashedLocalAssetEntity.source.equalsValue(TrashOrigin.remoteSync) & + _db.trashedLocalAssetEntity.albumId.isInQuery(selectedAlbumIds) & _db.remoteAssetEntity.deletedAt.isNull(), )) .get(); @@ -84,6 +85,7 @@ class DriftTrashedLocalAssetRepository extends DriftDatabaseRepository { durationInSeconds: Value(item.asset.durationInSeconds), isFavorite: Value(item.asset.isFavorite), orientation: Value(item.asset.orientation), + source: TrashOrigin.localSync, ); batch.insert<$TrashedLocalAssetEntityTable, TrashedLocalAssetEntityData>( @@ -124,7 +126,7 @@ class DriftTrashedLocalAssetRepository extends DriftDatabaseRepository { Future trashLocalAsset(Map> assetsByAlbums) async { if (assetsByAlbums.isEmpty) { - return; + return Future.value(); } final companions = []; @@ -147,6 +149,7 @@ class DriftTrashedLocalAssetRepository extends DriftDatabaseRepository { orientation: Value(asset.orientation), createdAt: Value(asset.createdAt), updatedAt: Value(asset.updatedAt), + source: const Value(TrashOrigin.remoteSync), ), ); } @@ -165,7 +168,7 @@ class DriftTrashedLocalAssetRepository extends DriftDatabaseRepository { Future applyRestoredAssets(List idList) async { if (idList.isEmpty) { - return; + return Future.value(); } final trashedAssets = []; @@ -205,6 +208,58 @@ class DriftTrashedLocalAssetRepository extends DriftDatabaseRepository { }); } + Future applyTrashedAssets(List idList) async { + if (idList.isEmpty) { + return Future.value(); + } + + final trashedAssets = <({LocalAssetEntityData asset, String albumId})>[]; + + for (final slice in idList.slices(kDriftMaxChunk)) { + final rows = await (_db.select(_db.localAlbumAssetEntity).join([ + innerJoin(_db.localAssetEntity, _db.localAlbumAssetEntity.assetId.equalsExp(_db.localAssetEntity.id)), + ])..where(_db.localAlbumAssetEntity.assetId.isIn(slice))).get(); + + final assetsWithAlbum = rows.map( + (row) => + (albumId: row.readTable(_db.localAlbumAssetEntity).albumId, asset: row.readTable(_db.localAssetEntity)), + ); + + trashedAssets.addAll(assetsWithAlbum); + } + + if (trashedAssets.isEmpty) { + return; + } + + final companions = trashedAssets.map((e) { + return TrashedLocalAssetEntityCompanion.insert( + id: e.asset.id, + name: e.asset.name, + type: e.asset.type, + createdAt: Value(e.asset.createdAt), + updatedAt: Value(e.asset.updatedAt), + width: Value(e.asset.width), + height: Value(e.asset.height), + durationInSeconds: Value(e.asset.durationInSeconds), + checksum: Value(e.asset.checksum), + isFavorite: Value(e.asset.isFavorite), + orientation: Value(e.asset.orientation), + source: TrashOrigin.localUser, + albumId: e.albumId, + ); + }); + + await _db.transaction(() async { + for (final companion in companions) { + await _db.into(_db.trashedLocalAssetEntity).insertOnConflictUpdate(companion); + } + for (final slice in idList.slices(kDriftMaxChunk)) { + await (_db.delete(_db.localAssetEntity)..where((t) => t.id.isIn(slice))).go(); + } + }); + } + Future>> getToTrash() async { final result = >{}; diff --git a/mobile/lib/repositories/local_files_manager.repository.dart b/mobile/lib/repositories/local_files_manager.repository.dart index 765c9a6f0..6a6200b2e 100644 --- a/mobile/lib/repositories/local_files_manager.repository.dart +++ b/mobile/lib/repositories/local_files_manager.repository.dart @@ -10,7 +10,7 @@ final localFilesManagerRepositoryProvider = Provider( class LocalFilesManagerRepository { LocalFilesManagerRepository(this._service); - final Logger _logger = Logger('SyncStreamService'); + final Logger _logger = Logger('LocalFilesManagerRepo'); final LocalFilesManagerService _service; Future moveToTrash(List mediaUrls) async { @@ -38,8 +38,10 @@ class LocalFilesManagerRepository { for (final asset in assets) { _logger.info("Restoring from trash, localId: ${asset.id}, remoteId: ${asset.checksum}"); try { - await _service.restoreFromTrashById(asset.id, asset.type.index); - restoredIds.add(asset.id); + final result = await _service.restoreFromTrashById(asset.id, asset.type.index); + if (result) { + restoredIds.add(asset.id); + } } catch (e) { _logger.warning("Restoring failure: $e"); } diff --git a/mobile/lib/services/action.service.dart b/mobile/lib/services/action.service.dart index 4261613a1..4d6e9611d 100644 --- a/mobile/lib/services/action.service.dart +++ b/mobile/lib/services/action.service.dart @@ -5,9 +5,13 @@ import 'package:flutter/material.dart'; import 'package:hooks_riverpod/hooks_riverpod.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/entities/store.entity.dart'; +import 'package:immich_mobile/extensions/platform_extensions.dart'; import 'package:immich_mobile/infrastructure/repositories/local_asset.repository.dart'; import 'package:immich_mobile/infrastructure/repositories/remote_album.repository.dart'; import 'package:immich_mobile/infrastructure/repositories/remote_asset.repository.dart'; +import 'package:immich_mobile/infrastructure/repositories/trashed_local_asset.repository.dart'; import 'package:immich_mobile/providers/infrastructure/album.provider.dart'; import 'package:immich_mobile/providers/infrastructure/asset.provider.dart'; import 'package:immich_mobile/repositories/asset_api.repository.dart'; @@ -28,6 +32,7 @@ final actionServiceProvider = Provider( ref.watch(localAssetRepository), ref.watch(driftAlbumApiRepositoryProvider), ref.watch(remoteAlbumRepository), + ref.watch(trashedLocalAssetRepository), ref.watch(assetMediaRepositoryProvider), ref.watch(downloadRepositoryProvider), ), @@ -39,6 +44,7 @@ class ActionService { final DriftLocalAssetRepository _localAssetRepository; final DriftAlbumApiRepository _albumApiRepository; final DriftRemoteAlbumRepository _remoteAlbumRepository; + final DriftTrashedLocalAssetRepository _trashedLocalAssetRepository; final AssetMediaRepository _assetMediaRepository; final DownloadRepository _downloadRepository; @@ -48,6 +54,7 @@ class ActionService { this._localAssetRepository, this._albumApiRepository, this._remoteAlbumRepository, + this._trashedLocalAssetRepository, this._assetMediaRepository, this._downloadRepository, ); @@ -82,11 +89,7 @@ class ActionService { // Ask user if they want to delete local copies if (localIds.isNotEmpty) { - final deletedIds = await _assetMediaRepository.deleteAll(localIds); - - if (deletedIds.isNotEmpty) { - await _localAssetRepository.delete(deletedIds); - } + await _deleteLocalAssets(localIds); } } @@ -110,11 +113,7 @@ class ActionService { await _remoteAssetRepository.trash(remoteIds); if (localIds.isNotEmpty) { - final deletedIds = await _assetMediaRepository.deleteAll(localIds); - - if (deletedIds.isNotEmpty) { - await _localAssetRepository.delete(deletedIds); - } + await _deleteLocalAssets(localIds); } } @@ -123,22 +122,12 @@ class ActionService { await _remoteAssetRepository.delete(remoteIds); if (localIds.isNotEmpty) { - final deletedIds = await _assetMediaRepository.deleteAll(localIds); - - if (deletedIds.isNotEmpty) { - await _localAssetRepository.delete(deletedIds); - } + await _deleteLocalAssets(localIds); } } Future deleteLocal(List localIds) async { - final deletedIds = await _assetMediaRepository.deleteAll(localIds); - if (deletedIds.isNotEmpty) { - await _localAssetRepository.delete(deletedIds); - return deletedIds.length; - } - - return 0; + return await _deleteLocalAssets(localIds); } Future editLocation(List remoteIds, BuildContext context) async { @@ -242,4 +231,17 @@ class ActionService { Future> downloadAll(List assets) { return _downloadRepository.downloadAllAssets(assets); } + + Future _deleteLocalAssets(List localIds) async { + final deletedIds = await _assetMediaRepository.deleteAll(localIds); + if (deletedIds.isEmpty) { + return 0; + } + if (CurrentPlatform.isAndroid && Store.get(StoreKey.manageLocalMediaAndroid, false)) { + await _trashedLocalAssetRepository.applyTrashedAssets(deletedIds); + } else { + await _localAssetRepository.delete(deletedIds); + } + return deletedIds.length; + } } diff --git a/mobile/test/domain/services/local_sync_service_test.dart b/mobile/test/domain/services/local_sync_service_test.dart index 92ab01c7e..45088305e 100644 --- a/mobile/test/domain/services/local_sync_service_test.dart +++ b/mobile/test/domain/services/local_sync_service_test.dart @@ -153,7 +153,14 @@ void main() { 'album-a': [platformAsset], }); - verify(() => mockTrashedLocalAssetRepository.processTrashSnapshot(any())).called(1); + final trashedSnapshot = + verify(() => mockTrashedLocalAssetRepository.processTrashSnapshot(captureAny())).captured.single + as Iterable; + expect(trashedSnapshot.length, 1); + final trashedEntry = trashedSnapshot.single; + expect(trashedEntry.albumId, 'album-a'); + expect(trashedEntry.asset.id, platformAsset.id); + expect(trashedEntry.asset.name, platformAsset.name); verify(() => mockTrashedLocalAssetRepository.getToTrash()).called(1); verify(() => mockLocalFilesManager.restoreAssetsFromTrash(any())).called(1); @@ -174,6 +181,10 @@ void main() { await sut.processTrashedAssets({}); + final trashedSnapshot = + verify(() => mockTrashedLocalAssetRepository.processTrashSnapshot(captureAny())).captured.single + as Iterable; + expect(trashedSnapshot, isEmpty); verifyNever(() => mockLocalFilesManager.restoreAssetsFromTrash(any())); verifyNever(() => mockTrashedLocalAssetRepository.applyRestoredAssets(any())); }); diff --git a/mobile/test/drift/main/generated/schema.dart b/mobile/test/drift/main/generated/schema.dart index 5e1961057..9edeed5dd 100644 Binary files a/mobile/test/drift/main/generated/schema.dart and b/mobile/test/drift/main/generated/schema.dart differ diff --git a/mobile/test/drift/main/generated/schema_v15.dart b/mobile/test/drift/main/generated/schema_v15.dart new file mode 100644 index 000000000..fa419d739 Binary files /dev/null and b/mobile/test/drift/main/generated/schema_v15.dart differ diff --git a/mobile/test/services/action.service_test.dart b/mobile/test/services/action.service_test.dart new file mode 100644 index 000000000..87263c9ae --- /dev/null +++ b/mobile/test/services/action.service_test.dart @@ -0,0 +1,118 @@ +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/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:immich_mobile/repositories/download.repository.dart'; +import 'package:immich_mobile/services/action.service.dart'; +import 'package:mocktail/mocktail.dart'; + +import '../infrastructure/repository.mock.dart'; +import '../repository.mocks.dart'; + +class MockDownloadRepository extends Mock implements DownloadRepository {} + +void main() { + late ActionService sut; + + late MockAssetApiRepository assetApiRepository; + late MockRemoteAssetRepository remoteAssetRepository; + late MockDriftLocalAssetRepository localAssetRepository; + late MockDriftAlbumApiRepository albumApiRepository; + late MockRemoteAlbumRepository remoteAlbumRepository; + late MockTrashedLocalAssetRepository trashedLocalAssetRepository; + late MockAssetMediaRepository assetMediaRepository; + late MockDownloadRepository downloadRepository; + + late Drift db; + + setUpAll(() async { + TestWidgetsFlutterBinding.ensureInitialized(); + debugDefaultTargetPlatformOverride = TargetPlatform.android; + + db = Drift(drift.DatabaseConnection(NativeDatabase.memory(), closeStreamsSynchronously: true)); + await StoreService.init(storeRepository: DriftStoreRepository(db)); + }); + + tearDownAll(() async { + debugDefaultTargetPlatformOverride = null; + await Store.clear(); + await db.close(); + }); + + setUp(() { + assetApiRepository = MockAssetApiRepository(); + remoteAssetRepository = MockRemoteAssetRepository(); + localAssetRepository = MockDriftLocalAssetRepository(); + albumApiRepository = MockDriftAlbumApiRepository(); + remoteAlbumRepository = MockRemoteAlbumRepository(); + trashedLocalAssetRepository = MockTrashedLocalAssetRepository(); + assetMediaRepository = MockAssetMediaRepository(); + downloadRepository = MockDownloadRepository(); + + sut = ActionService( + assetApiRepository, + remoteAssetRepository, + localAssetRepository, + albumApiRepository, + remoteAlbumRepository, + trashedLocalAssetRepository, + assetMediaRepository, + downloadRepository, + ); + }); + + tearDown(() async { + await Store.clear(); + }); + + group('ActionService.deleteLocal', () { + test('routes deleted ids to trashed repository when Android trash handling is enabled', () async { + await Store.put(StoreKey.manageLocalMediaAndroid, true); + const ids = ['a', 'b']; + + when(() => assetMediaRepository.deleteAll(ids)).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(() => trashedLocalAssetRepository.applyTrashedAssets(ids)).called(1); + verifyNever(() => localAssetRepository.delete(any())); + }); + + test('deletes locally when Android trash handling is disabled', () async { + await Store.put(StoreKey.manageLocalMediaAndroid, false); + const ids = ['c']; + + when(() => assetMediaRepository.deleteAll(ids)).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(() => localAssetRepository.delete(ids)).called(1); + verifyNever(() => trashedLocalAssetRepository.applyTrashedAssets(any())); + }); + + test('short-circuits when nothing was deleted', () async { + await Store.put(StoreKey.manageLocalMediaAndroid, true); + const ids = ['x']; + + when(() => assetMediaRepository.deleteAll(ids)).thenAnswer((_) async => []); + + final result = await sut.deleteLocal(ids); + + expect(result, 0); + verify(() => assetMediaRepository.deleteAll(ids)).called(1); + verifyNever(() => trashedLocalAssetRepository.applyTrashedAssets(any())); + verifyNever(() => localAssetRepository.delete(any())); + }); + }); +}