diff --git a/mobile/drift_schemas/main/drift_schema_v18.json b/mobile/drift_schemas/main/drift_schema_v18.json new file mode 100644 index 000000000..8d9efd3db Binary files /dev/null and b/mobile/drift_schemas/main/drift_schema_v18.json differ diff --git a/mobile/lib/domain/services/hash.service.dart b/mobile/lib/domain/services/hash.service.dart index ee2e96dd8..8be3c2f22 100644 --- a/mobile/lib/domain/services/hash.service.dart +++ b/mobile/lib/domain/services/hash.service.dart @@ -41,7 +41,7 @@ class HashService { final Stopwatch stopwatch = Stopwatch()..start(); try { // Migrate hashes from cloud ID to local ID so we don't have to re-hash them - await _migrateHashes(); + await _localAssetRepository.reconcileHashesFromCloudId(); // Sorted by backupSelection followed by isCloud final localAlbums = await _localAlbumRepository.getBackupAlbums(); @@ -78,15 +78,6 @@ class HashService { _log.info("Hashing took - ${stopwatch.elapsedMilliseconds}ms"); } - Future _migrateHashes() async { - final hashMappings = await _localAssetRepository.getHashMappingFromCloudId(); - if (hashMappings.isEmpty) { - return; - } - - await _localAssetRepository.updateHashes(hashMappings); - } - /// Processes a list of [LocalAsset]s, storing their hash and updating the assets in the DB /// with hash for those that were successfully hashed. Hashes are looked up in a table /// [LocalAssetHashEntity] by local id. Only missing entries are newly hashed and added to the DB. diff --git a/mobile/lib/domain/utils/migrate_cloud_ids.dart b/mobile/lib/domain/utils/migrate_cloud_ids.dart index 6ff78823c..33a8eca94 100644 --- a/mobile/lib/domain/utils/migrate_cloud_ids.dart +++ b/mobile/lib/domain/utils/migrate_cloud_ids.dart @@ -50,75 +50,84 @@ Future syncCloudIds(ProviderContainer ref) async { return; } - final mappingsToUpdate = await _fetchCloudIdMappings(db, currentUser.id); - // Deduplicate mappings as a single remote asset ID can match multiple local assets - final seenRemoteAssetIds = {}; - final uniqueMapping = mappingsToUpdate.where((mapping) { - if (!seenRemoteAssetIds.add(mapping.remoteAssetId)) { - logger.fine('Duplicate remote asset ID found: ${mapping.remoteAssetId}. Skipping duplicate entry.'); - return false; - } - return true; - }).toList(); - final assetApi = ref.read(apiServiceProvider).assetsApi; - if (canBulkUpdateMetadata) { - await _bulkUpdateCloudIds(assetApi, uniqueMapping); - return; - } - await _sequentialUpdateCloudIds(assetApi, uniqueMapping); + // Process cloud IDs in paginated batches + await _processCloudIdMappingsInBatches(db, currentUser.id, assetApi, canBulkUpdateMetadata, logger); } -Future _sequentialUpdateCloudIds(AssetsApi assetsApi, List<_CloudIdMapping> mappings) async { - for (final mapping in mappings) { - final item = AssetMetadataUpsertItemDto( - key: kMobileMetadataKey, - value: RemoteAssetMobileAppMetadata( - cloudId: mapping.localAsset.cloudId, - createdAt: mapping.localAsset.createdAt.toIso8601String(), - adjustmentTime: mapping.localAsset.adjustmentTime?.toIso8601String(), - latitude: mapping.localAsset.latitude?.toString(), - longitude: mapping.localAsset.longitude?.toString(), - ), - ); - try { - await assetsApi.updateAssetMetadata(mapping.remoteAssetId, AssetMetadataUpsertDto(items: [item])); - } catch (error, stack) { - Logger('migrateCloudIds').warning('Failed to update metadata for asset ${mapping.remoteAssetId}', error, stack); +Future _processCloudIdMappingsInBatches( + Drift drift, + String userId, + AssetsApi assetsApi, + bool canBulkUpdate, + Logger logger, +) async { + const pageSize = 20000; + String? lastLocalId; + final seenRemoteAssetIds = {}; + + while (true) { + final mappings = await _fetchCloudIdMappings(drift, userId, pageSize, lastLocalId); + if (mappings.isEmpty) { + break; } - } -} -Future _bulkUpdateCloudIds(AssetsApi assetsApi, List<_CloudIdMapping> mappings) async { - const batchSize = 10000; - for (int i = 0; i < mappings.length; i += batchSize) { - final endIndex = (i + batchSize > mappings.length) ? mappings.length : i + batchSize; - final batch = mappings.sublist(i, endIndex); final items = []; - for (final mapping in batch) { - items.add( - AssetMetadataBulkUpsertItemDto( - assetId: mapping.remoteAssetId, - key: kMobileMetadataKey, - value: RemoteAssetMobileAppMetadata( - cloudId: mapping.localAsset.cloudId, - createdAt: mapping.localAsset.createdAt.toIso8601String(), - adjustmentTime: mapping.localAsset.adjustmentTime?.toIso8601String(), - latitude: mapping.localAsset.latitude?.toString(), - longitude: mapping.localAsset.longitude?.toString(), + for (final mapping in mappings) { + if (seenRemoteAssetIds.add(mapping.remoteAssetId)) { + items.add( + AssetMetadataBulkUpsertItemDto( + assetId: mapping.remoteAssetId, + key: kMobileMetadataKey, + value: RemoteAssetMobileAppMetadata( + cloudId: mapping.localAsset.cloudId, + createdAt: mapping.localAsset.createdAt.toIso8601String(), + adjustmentTime: mapping.localAsset.adjustmentTime?.toIso8601String(), + latitude: mapping.localAsset.latitude?.toString(), + longitude: mapping.localAsset.longitude?.toString(), + ), ), - ), - ); + ); + } else { + logger.fine('Duplicate remote asset ID found: ${mapping.remoteAssetId}. Skipping duplicate entry.'); + } } + + if (items.isNotEmpty) { + if (canBulkUpdate) { + await _bulkUpdateCloudIds(assetsApi, items); + } else { + await _sequentialUpdateCloudIds(assetsApi, items); + } + } + + lastLocalId = mappings.last.localAsset.id; + if (mappings.length < pageSize) { + break; + } + } +} + +Future _sequentialUpdateCloudIds(AssetsApi assetsApi, List items) async { + for (final item in items) { + final upsertItem = AssetMetadataUpsertItemDto(key: item.key, value: item.value); try { - await assetsApi.updateBulkAssetMetadata(AssetMetadataBulkUpsertDto(items: items)); + await assetsApi.updateAssetMetadata(item.assetId, AssetMetadataUpsertDto(items: [upsertItem])); } catch (error, stack) { - Logger('migrateCloudIds').warning('Failed to bulk update metadata', error, stack); + Logger('migrateCloudIds').warning('Failed to update metadata for asset ${item.assetId}', error, stack); } } } +Future _bulkUpdateCloudIds(AssetsApi assetsApi, List items) async { + try { + await assetsApi.updateBulkAssetMetadata(AssetMetadataBulkUpsertDto(items: items)); + } catch (error, stack) { + Logger('migrateCloudIds').warning('Failed to bulk update metadata', error, stack); + } +} + Future _populateCloudIds(Drift drift) async { final query = drift.localAssetEntity.selectOnly() ..addColumns([drift.localAssetEntity.id]) @@ -141,31 +150,38 @@ Future _populateCloudIds(Drift drift) async { typedef _CloudIdMapping = ({String remoteAssetId, LocalAsset localAsset}); -Future> _fetchCloudIdMappings(Drift drift, String userId) async { +Future> _fetchCloudIdMappings(Drift drift, String userId, int limit, String? lastLocalId) async { final query = - drift.remoteAssetEntity.select().join([ - leftOuterJoin( - drift.localAssetEntity, - drift.localAssetEntity.checksum.equalsExp(drift.remoteAssetEntity.checksum), - ), - leftOuterJoin( - drift.remoteAssetCloudIdEntity, - drift.remoteAssetEntity.id.equalsExp(drift.remoteAssetCloudIdEntity.assetId), - useColumns: false, - ), - ])..where( - // Only select assets that have a local cloud ID but either no remote cloud ID or a mismatched eTag - drift.localAssetEntity.id.isNotNull() & - drift.localAssetEntity.iCloudId.isNotNull() & - drift.remoteAssetEntity.ownerId.equals(userId) & - // Skip locked assets as we cannot update them without unlocking first - drift.remoteAssetEntity.visibility.isNotValue(AssetVisibility.locked.index) & - (drift.remoteAssetCloudIdEntity.cloudId.isNull() | - drift.remoteAssetCloudIdEntity.adjustmentTime.isNotExp(drift.localAssetEntity.adjustmentTime) | - drift.remoteAssetCloudIdEntity.latitude.isNotExp(drift.localAssetEntity.latitude) | - drift.remoteAssetCloudIdEntity.longitude.isNotExp(drift.localAssetEntity.longitude) | - drift.remoteAssetCloudIdEntity.createdAt.isNotExp(drift.localAssetEntity.createdAt)), - ); + drift.localAssetEntity.select().join([ + innerJoin( + drift.remoteAssetEntity, + drift.localAssetEntity.checksum.equalsExp(drift.remoteAssetEntity.checksum), + ), + leftOuterJoin( + drift.remoteAssetCloudIdEntity, + drift.remoteAssetEntity.id.equalsExp(drift.remoteAssetCloudIdEntity.assetId), + useColumns: false, + ), + ]) + ..where( + // Only select assets that have a local cloud ID but either no remote cloud ID or a mismatched eTag + drift.localAssetEntity.iCloudId.isNotNull() & + drift.remoteAssetEntity.ownerId.equals(userId) & + // Skip locked assets as we cannot update them without unlocking first + drift.remoteAssetEntity.visibility.isNotValue(AssetVisibility.locked.index) & + (drift.remoteAssetCloudIdEntity.cloudId.isNull() | + drift.remoteAssetCloudIdEntity.adjustmentTime.isNotExp(drift.localAssetEntity.adjustmentTime) | + drift.remoteAssetCloudIdEntity.latitude.isNotExp(drift.localAssetEntity.latitude) | + drift.remoteAssetCloudIdEntity.longitude.isNotExp(drift.localAssetEntity.longitude) | + drift.remoteAssetCloudIdEntity.createdAt.isNotExp(drift.localAssetEntity.createdAt)), + ) + ..orderBy([OrderingTerm.asc(drift.localAssetEntity.id)]) + ..limit(limit); + + if (lastLocalId != null) { + query.where(drift.localAssetEntity.id.isBiggerThanValue(lastLocalId)); + } + return query.map((row) { return ( remoteAssetId: row.read(drift.remoteAssetEntity.id)!, diff --git a/mobile/lib/infrastructure/entities/remote_asset_cloud_id.entity.dart b/mobile/lib/infrastructure/entities/remote_asset_cloud_id.entity.dart index 332a38a69..593931f98 100644 --- a/mobile/lib/infrastructure/entities/remote_asset_cloud_id.entity.dart +++ b/mobile/lib/infrastructure/entities/remote_asset_cloud_id.entity.dart @@ -2,6 +2,7 @@ import 'package:drift/drift.dart'; import 'package:immich_mobile/infrastructure/entities/remote_asset.entity.dart'; import 'package:immich_mobile/infrastructure/utils/drift_default.mixin.dart'; +@TableIndex.sql('CREATE INDEX IF NOT EXISTS idx_remote_asset_cloud_id ON remote_asset_cloud_id_entity (cloud_id)') class RemoteAssetCloudIdEntity extends Table with DriftDefaultsMixin { TextColumn get assetId => text().references(RemoteAssetEntity, #id, onDelete: KeyAction.cascade)(); diff --git a/mobile/lib/infrastructure/entities/remote_asset_cloud_id.entity.drift.dart b/mobile/lib/infrastructure/entities/remote_asset_cloud_id.entity.drift.dart index 8b15fb8f4..f86528ee6 100644 Binary files a/mobile/lib/infrastructure/entities/remote_asset_cloud_id.entity.drift.dart and b/mobile/lib/infrastructure/entities/remote_asset_cloud_id.entity.drift.dart differ diff --git a/mobile/lib/infrastructure/repositories/db.repository.dart b/mobile/lib/infrastructure/repositories/db.repository.dart index ad15401be..e0e29e829 100644 --- a/mobile/lib/infrastructure/repositories/db.repository.dart +++ b/mobile/lib/infrastructure/repositories/db.repository.dart @@ -97,7 +97,7 @@ class Drift extends $Drift implements IDatabaseRepository { } @override - int get schemaVersion => 17; + int get schemaVersion => 18; @override MigrationStrategy get migration => MigrationStrategy( @@ -204,6 +204,9 @@ class Drift extends $Drift implements IDatabaseRepository { from16To17: (m, v17) async { await m.addColumn(v17.remoteAssetEntity, v17.remoteAssetEntity.isEdited); }, + from17To18: (m, v18) async { + await m.createIndex(v18.idxRemoteAssetCloudId); + }, ), ); diff --git a/mobile/lib/infrastructure/repositories/db.repository.drift.dart b/mobile/lib/infrastructure/repositories/db.repository.drift.dart index 72dc1e804..c561eef0c 100644 Binary files a/mobile/lib/infrastructure/repositories/db.repository.drift.dart and b/mobile/lib/infrastructure/repositories/db.repository.drift.dart differ diff --git a/mobile/lib/infrastructure/repositories/db.repository.steps.dart b/mobile/lib/infrastructure/repositories/db.repository.steps.dart index fe7d1d4f0..72601f249 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/local_asset.repository.dart b/mobile/lib/infrastructure/repositories/local_asset.repository.dart index 7b2be63b3..9d7cbd831 100644 --- a/mobile/lib/infrastructure/repositories/local_asset.repository.dart +++ b/mobile/lib/infrastructure/repositories/local_asset.repository.dart @@ -204,34 +204,23 @@ class DriftLocalAssetRepository extends DriftDatabaseRepository { return query.map((row) => row.toDto()).get(); } - Future> getHashMappingFromCloudId() async { - final query = - _db.localAssetEntity.selectOnly().join([ - leftOuterJoin( - _db.remoteAssetCloudIdEntity, - _db.localAssetEntity.iCloudId.equalsExp(_db.remoteAssetCloudIdEntity.cloudId), - useColumns: false, - ), - leftOuterJoin( - _db.remoteAssetEntity, - _db.remoteAssetCloudIdEntity.assetId.equalsExp(_db.remoteAssetEntity.id), - useColumns: false, - ), - ]) - ..addColumns([_db.localAssetEntity.id, _db.remoteAssetEntity.checksum]) - ..where( - _db.remoteAssetCloudIdEntity.cloudId.isNotNull() & - _db.localAssetEntity.checksum.isNull() & - ((_db.remoteAssetCloudIdEntity.adjustmentTime.isExp(_db.localAssetEntity.adjustmentTime)) & - (_db.remoteAssetCloudIdEntity.latitude.isExp(_db.localAssetEntity.latitude)) & - (_db.remoteAssetCloudIdEntity.longitude.isExp(_db.localAssetEntity.longitude)) & - (_db.remoteAssetCloudIdEntity.createdAt.isExp(_db.localAssetEntity.createdAt))), - ); - final mapping = await query - .map( - (row) => (assetId: row.read(_db.localAssetEntity.id)!, checksum: row.read(_db.remoteAssetEntity.checksum)!), - ) - .get(); - return {for (final entry in mapping) entry.assetId: entry.checksum}; + Future reconcileHashesFromCloudId() async { + await _db.customUpdate( + ''' + UPDATE local_asset_entity + SET checksum = remote_asset_entity.checksum + FROM remote_asset_cloud_id_entity + INNER JOIN remote_asset_entity + ON remote_asset_cloud_id_entity.asset_id = remote_asset_entity.id + WHERE local_asset_entity.i_cloud_id = remote_asset_cloud_id_entity.cloud_id + AND local_asset_entity.checksum IS NULL + AND remote_asset_cloud_id_entity.adjustment_time IS local_asset_entity.adjustment_time + AND remote_asset_cloud_id_entity.latitude IS local_asset_entity.latitude + AND remote_asset_cloud_id_entity.longitude IS local_asset_entity.longitude + AND remote_asset_cloud_id_entity.created_at IS local_asset_entity.created_at + ''', + updates: {_db.localAssetEntity}, + updateKind: UpdateKind.update, + ); } } diff --git a/mobile/lib/pages/common/splash_screen.page.dart b/mobile/lib/pages/common/splash_screen.page.dart index bc407f5b7..c7d786626 100644 --- a/mobile/lib/pages/common/splash_screen.page.dart +++ b/mobile/lib/pages/common/splash_screen.page.dart @@ -75,7 +75,8 @@ class SplashScreenPageState extends ConsumerState { _resumeBackup(backupProvider); }), _resumeBackup(backupProvider), - backgroundManager.syncCloudIds(), + // TODO: Bring back when the soft freeze issue is addressed + // backgroundManager.syncCloudIds(), ]); } else { await backgroundManager.hashAssets(); diff --git a/mobile/lib/providers/app_life_cycle.provider.dart b/mobile/lib/providers/app_life_cycle.provider.dart index a6e8503d9..883c4f483 100644 --- a/mobile/lib/providers/app_life_cycle.provider.dart +++ b/mobile/lib/providers/app_life_cycle.provider.dart @@ -160,7 +160,8 @@ class AppLifeCycleNotifier extends StateNotifier { _resumeBackup(); }), _resumeBackup(), - _safeRun(backgroundManager.syncCloudIds(), "syncCloudIds"), + // TODO: Bring back when the soft freeze issue is addressed + // _safeRun(backgroundManager.syncCloudIds(), "syncCloudIds"), ]); } else { await _safeRun(backgroundManager.hashAssets(), "hashAssets"); diff --git a/mobile/test/domain/services/hash_service_test.dart b/mobile/test/domain/services/hash_service_test.dart index d71dd63da..9f36a5635 100644 --- a/mobile/test/domain/services/hash_service_test.dart +++ b/mobile/test/domain/services/hash_service_test.dart @@ -33,7 +33,7 @@ void main() { registerFallbackValue(LocalAssetStub.image1); registerFallbackValue({}); - when(() => mockAssetRepo.getHashMappingFromCloudId()).thenAnswer((_) async => {}); + when(() => mockAssetRepo.reconcileHashesFromCloudId()).thenAnswer((_) async => {}); when(() => mockAssetRepo.updateHashes(any())).thenAnswer((_) async => {}); }); @@ -191,5 +191,4 @@ void main() { verify(() => mockNativeApi.hashAssets([asset2.id], allowNetworkAccess: false)).called(1); }); }); - } diff --git a/mobile/test/drift/main/generated/schema.dart b/mobile/test/drift/main/generated/schema.dart index 89eef9c83..1fe0fff6a 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_v18.dart b/mobile/test/drift/main/generated/schema_v18.dart new file mode 100644 index 000000000..c0b1e6889 Binary files /dev/null and b/mobile/test/drift/main/generated/schema_v18.dart differ diff --git a/mobile/test/infrastructure/repositories/local_asset_repository_test.dart b/mobile/test/infrastructure/repositories/local_asset_repository_test.dart index 598905817..245cc86a9 100644 --- a/mobile/test/infrastructure/repositories/local_asset_repository_test.dart +++ b/mobile/test/infrastructure/repositories/local_asset_repository_test.dart @@ -1,4 +1,4 @@ -import 'package:drift/drift.dart'; +import 'package:drift/drift.dart' hide isNull; import 'package:drift/native.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:immich_mobile/constants/enums.dart'; @@ -8,11 +8,13 @@ import 'package:immich_mobile/infrastructure/entities/local_album.entity.drift.d import 'package:immich_mobile/infrastructure/entities/local_album_asset.entity.drift.dart'; import 'package:immich_mobile/infrastructure/entities/local_asset.entity.drift.dart'; import 'package:immich_mobile/infrastructure/entities/remote_asset.entity.drift.dart'; +import 'package:immich_mobile/infrastructure/entities/remote_asset_cloud_id.entity.drift.dart'; import 'package:immich_mobile/infrastructure/entities/user.entity.drift.dart'; import 'package:immich_mobile/infrastructure/repositories/db.repository.dart'; import 'package:immich_mobile/infrastructure/repositories/local_asset.repository.dart'; void main() { + final now = DateTime(2024, 1, 15); late Drift db; late DriftLocalAssetRepository repository; @@ -25,68 +27,98 @@ void main() { await db.close(); }); + Future insertLocalAsset({ + required String id, + String? checksum, + DateTime? createdAt, + AssetType type = AssetType.image, + bool isFavorite = false, + String? iCloudId, + DateTime? adjustmentTime, + double? latitude, + double? longitude, + }) async { + final created = createdAt ?? now; + await db + .into(db.localAssetEntity) + .insert( + LocalAssetEntityCompanion.insert( + id: id, + name: 'asset_$id.jpg', + checksum: Value(checksum), + type: type, + createdAt: Value(created), + updatedAt: Value(created), + isFavorite: Value(isFavorite), + iCloudId: Value(iCloudId), + adjustmentTime: Value(adjustmentTime), + latitude: Value(latitude), + longitude: Value(longitude), + ), + ); + } + + Future insertRemoteAsset({ + required String id, + required String checksum, + required String ownerId, + DateTime? deletedAt, + }) async { + await db + .into(db.remoteAssetEntity) + .insert( + RemoteAssetEntityCompanion.insert( + id: id, + name: 'remote_$id.jpg', + checksum: checksum, + type: AssetType.image, + createdAt: Value(now), + updatedAt: Value(now), + ownerId: ownerId, + visibility: AssetVisibility.timeline, + deletedAt: Value(deletedAt), + ), + ); + } + + Future insertRemoteAssetCloudId({ + required String assetId, + required String? cloudId, + DateTime? createdAt, + DateTime? adjustmentTime, + double? latitude, + double? longitude, + }) async { + await db + .into(db.remoteAssetCloudIdEntity) + .insert( + RemoteAssetCloudIdEntityCompanion.insert( + assetId: assetId, + cloudId: Value(cloudId), + createdAt: Value(createdAt), + adjustmentTime: Value(adjustmentTime), + latitude: Value(latitude), + longitude: Value(longitude), + ), + ); + } + + Future insertUser(String id, String email) async { + await db.into(db.userEntity).insert(UserEntityCompanion.insert(id: id, email: email, name: email)); + } + group('getRemovalCandidates', () { final userId = 'user-123'; final otherUserId = 'user-456'; - final now = DateTime(2024, 1, 15); final cutoffDate = DateTime(2024, 1, 10); final beforeCutoff = DateTime(2024, 1, 5); final afterCutoff = DateTime(2024, 1, 12); - Future insertUser(String id, String email) async { - await db.into(db.userEntity).insert(UserEntityCompanion.insert(id: id, email: email, name: email)); - } - setUp(() async { await insertUser(userId, 'user@test.com'); await insertUser(otherUserId, 'other@test.com'); }); - Future insertLocalAsset({ - required String id, - required String checksum, - required DateTime createdAt, - required AssetType type, - required bool isFavorite, - }) async { - await db - .into(db.localAssetEntity) - .insert( - LocalAssetEntityCompanion.insert( - id: id, - name: 'asset_$id.jpg', - checksum: Value(checksum), - type: type, - createdAt: Value(createdAt), - updatedAt: Value(createdAt), - isFavorite: Value(isFavorite), - ), - ); - } - - Future insertRemoteAsset({ - required String id, - required String checksum, - required String ownerId, - DateTime? deletedAt, - }) async { - await db - .into(db.remoteAssetEntity) - .insert( - RemoteAssetEntityCompanion.insert( - id: id, - name: 'remote_$id.jpg', - checksum: checksum, - type: AssetType.image, - createdAt: Value(now), - updatedAt: Value(now), - ownerId: ownerId, - visibility: AssetVisibility.timeline, - deletedAt: Value(deletedAt), - ), - ); - } - Future insertLocalAlbum({required String id, required String name, required bool isIosSharedAlbum}) async { await db .into(db.localAlbumEntity) @@ -211,11 +243,7 @@ void main() { ); await insertRemoteAsset(id: 'remote-video', checksum: 'checksum-video', ownerId: userId); - final result = await repository.getRemovalCandidates( - userId, - cutoffDate, - keepMediaType: AssetKeepType.photosOnly, - ); + final result = await repository.getRemovalCandidates(userId, cutoffDate, keepMediaType: AssetKeepType.photosOnly); expect(result.assets.length, 1); expect(result.assets[0].id, 'local-video'); @@ -243,11 +271,7 @@ void main() { ); await insertRemoteAsset(id: 'remote-video', checksum: 'checksum-video', ownerId: userId); - final result = await repository.getRemovalCandidates( - userId, - cutoffDate, - keepMediaType: AssetKeepType.videosOnly, - ); + final result = await repository.getRemovalCandidates(userId, cutoffDate, keepMediaType: AssetKeepType.videosOnly); expect(result.assets.length, 1); expect(result.assets[0].id, 'local-photo'); @@ -507,11 +531,7 @@ void main() { await insertRemoteAsset(id: 'remote-3', checksum: 'checksum-3', ownerId: userId); await insertLocalAlbumAsset(albumId: 'album-3', assetId: 'local-3'); - final result = await repository.getRemovalCandidates( - userId, - cutoffDate, - keepAlbumIds: {'album-1', 'album-2'}, - ); + final result = await repository.getRemovalCandidates(userId, cutoffDate, keepAlbumIds: {'album-1', 'album-2'}); expect(result.assets.length, 1); expect(result.assets[0].id, 'local-3'); @@ -644,4 +664,313 @@ void main() { expect(result.assets[0].id, 'local-video'); }); }); + + group('reconcileHashesFromCloudId', () { + final userId = 'user-123'; + final createdAt = DateTime(2024, 1, 10); + final adjustmentTime = DateTime(2024, 1, 11); + const latitude = 37.7749; + const longitude = -122.4194; + + setUp(() async { + await insertUser(userId, 'user@test.com'); + }); + + test('updates local asset checksum when all metadata matches', () async { + await insertLocalAsset( + id: 'local-1', + checksum: null, + iCloudId: 'cloud-123', + createdAt: createdAt, + adjustmentTime: adjustmentTime, + latitude: latitude, + longitude: longitude, + ); + + await insertRemoteAsset(id: 'remote-1', checksum: 'hash-abc123', ownerId: userId); + + await insertRemoteAssetCloudId( + assetId: 'remote-1', + cloudId: 'cloud-123', + createdAt: createdAt, + adjustmentTime: adjustmentTime, + latitude: latitude, + longitude: longitude, + ); + + await repository.reconcileHashesFromCloudId(); + + final updated = await repository.getById('local-1'); + expect(updated?.checksum, 'hash-abc123'); + }); + + test('does not update when local asset already has checksum', () async { + await insertLocalAsset( + id: 'local-1', + checksum: 'existing-checksum', + iCloudId: 'cloud-123', + createdAt: createdAt, + adjustmentTime: adjustmentTime, + latitude: latitude, + longitude: longitude, + ); + + await insertRemoteAsset(id: 'remote-1', checksum: 'hash-abc123', ownerId: userId); + + await insertRemoteAssetCloudId( + assetId: 'remote-1', + cloudId: 'cloud-123', + createdAt: createdAt, + adjustmentTime: adjustmentTime, + latitude: latitude, + longitude: longitude, + ); + + await repository.reconcileHashesFromCloudId(); + + final updated = await repository.getById('local-1'); + expect(updated?.checksum, 'existing-checksum'); + }); + + test('does not update when adjustment_time does not match', () async { + await insertLocalAsset( + id: 'local-1', + checksum: null, + iCloudId: 'cloud-123', + createdAt: createdAt, + adjustmentTime: adjustmentTime, + latitude: latitude, + longitude: longitude, + ); + + await insertRemoteAsset(id: 'remote-1', checksum: 'hash-abc123', ownerId: userId); + + await insertRemoteAssetCloudId( + assetId: 'remote-1', + cloudId: 'cloud-123', + createdAt: createdAt, + adjustmentTime: DateTime(2024, 1, 12), + latitude: latitude, + longitude: longitude, + ); + + await repository.reconcileHashesFromCloudId(); + + final updated = await repository.getById('local-1'); + expect(updated?.checksum, isNull); + }); + + test('does not update when latitude does not match', () async { + await insertLocalAsset( + id: 'local-1', + checksum: null, + iCloudId: 'cloud-123', + createdAt: createdAt, + adjustmentTime: adjustmentTime, + latitude: latitude, + longitude: longitude, + ); + + await insertRemoteAsset(id: 'remote-1', checksum: 'hash-abc123', ownerId: userId); + + await insertRemoteAssetCloudId( + assetId: 'remote-1', + cloudId: 'cloud-123', + createdAt: createdAt, + adjustmentTime: adjustmentTime, + latitude: 40.7128, + longitude: longitude, + ); + + await repository.reconcileHashesFromCloudId(); + + final updated = await repository.getById('local-1'); + expect(updated?.checksum, isNull); + }); + + test('does not update when longitude does not match', () async { + await insertLocalAsset( + id: 'local-1', + checksum: null, + iCloudId: 'cloud-123', + createdAt: createdAt, + adjustmentTime: adjustmentTime, + latitude: latitude, + longitude: longitude, + ); + + await insertRemoteAsset(id: 'remote-1', checksum: 'hash-abc123', ownerId: userId); + + await insertRemoteAssetCloudId( + assetId: 'remote-1', + cloudId: 'cloud-123', + createdAt: createdAt, + adjustmentTime: adjustmentTime, + latitude: latitude, + longitude: -74.0060, + ); + + await repository.reconcileHashesFromCloudId(); + + final updated = await repository.getById('local-1'); + expect(updated?.checksum, isNull); + }); + + test('does not update when createdAt does not match', () async { + await insertLocalAsset( + id: 'local-1', + checksum: null, + iCloudId: 'cloud-123', + createdAt: createdAt, + adjustmentTime: adjustmentTime, + latitude: latitude, + longitude: longitude, + ); + + await insertRemoteAsset(id: 'remote-1', checksum: 'hash-abc123', ownerId: userId); + + await insertRemoteAssetCloudId( + assetId: 'remote-1', + cloudId: 'cloud-123', + createdAt: DateTime(2024, 1, 5), + adjustmentTime: adjustmentTime, + latitude: latitude, + longitude: longitude, + ); + + await repository.reconcileHashesFromCloudId(); + + final updated = await repository.getById('local-1'); + expect(updated?.checksum, isNull); + }); + + test('does not update when iCloudId is null', () async { + await insertLocalAsset( + id: 'local-1', + checksum: null, + iCloudId: null, + createdAt: createdAt, + adjustmentTime: adjustmentTime, + latitude: latitude, + longitude: longitude, + ); + + await insertRemoteAsset(id: 'remote-1', checksum: 'hash-abc123', ownerId: userId); + + await insertRemoteAssetCloudId( + assetId: 'remote-1', + cloudId: 'cloud-123', + createdAt: createdAt, + adjustmentTime: adjustmentTime, + latitude: latitude, + longitude: longitude, + ); + + await repository.reconcileHashesFromCloudId(); + + final updated = await repository.getById('local-1'); + expect(updated?.checksum, isNull); + }); + + test('does not update when cloudId does not match iCloudId', () async { + await insertLocalAsset( + id: 'local-1', + checksum: null, + iCloudId: 'cloud-123', + createdAt: createdAt, + adjustmentTime: adjustmentTime, + latitude: latitude, + longitude: longitude, + ); + + await insertRemoteAsset(id: 'remote-1', checksum: 'hash-abc123', ownerId: userId); + + await insertRemoteAssetCloudId( + assetId: 'remote-1', + cloudId: 'cloud-456', + createdAt: createdAt, + adjustmentTime: adjustmentTime, + latitude: latitude, + longitude: longitude, + ); + + await repository.reconcileHashesFromCloudId(); + + final updated = await repository.getById('local-1'); + expect(updated?.checksum, isNull); + }); + + test('handles partial null metadata fields matching correctly', () async { + await insertLocalAsset( + id: 'local-1', + checksum: null, + iCloudId: 'cloud-123', + createdAt: createdAt, + adjustmentTime: null, + latitude: latitude, + longitude: longitude, + ); + + await insertRemoteAsset(id: 'remote-1', checksum: 'hash-abc123', ownerId: userId); + + await insertRemoteAssetCloudId( + assetId: 'remote-1', + cloudId: 'cloud-123', + createdAt: createdAt, + adjustmentTime: null, + latitude: latitude, + longitude: longitude, + ); + + await repository.reconcileHashesFromCloudId(); + + final updated = await repository.getById('local-1'); + expect(updated?.checksum, 'hash-abc123'); + }); + + test('does not update when one has null and other has value', () async { + await insertLocalAsset( + id: 'local-1', + checksum: null, + iCloudId: 'cloud-123', + createdAt: createdAt, + adjustmentTime: adjustmentTime, + latitude: null, + longitude: longitude, + ); + + await insertRemoteAsset(id: 'remote-1', checksum: 'hash-abc123', ownerId: userId); + + await insertRemoteAssetCloudId( + assetId: 'remote-1', + cloudId: 'cloud-123', + createdAt: createdAt, + adjustmentTime: adjustmentTime, + latitude: latitude, + longitude: longitude, + ); + + await repository.reconcileHashesFromCloudId(); + + final updated = await repository.getById('local-1'); + expect(updated?.checksum, isNull); + }); + + test('handles no matching assets gracefully', () async { + await insertLocalAsset( + id: 'local-1', + checksum: null, + iCloudId: 'cloud-999', + createdAt: createdAt, + adjustmentTime: adjustmentTime, + latitude: latitude, + longitude: longitude, + ); + + await repository.reconcileHashesFromCloudId(); + + final updated = await repository.getById('local-1'); + expect(updated?.checksum, isNull); + }); + }); }