fix: slow hash reconcilation (#25503)

* fix: slow hash reconcilation

* tests for reconcileHashesFromCloudId

* paginate cloud id fetch in migrate cloud id

* pr review

* skip cloudId sync

---------

Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com>
This commit is contained in:
shenlong 2026-01-26 20:42:00 +05:30 committed by GitHub
parent 3b0be896e6
commit 836d22570f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
15 changed files with 518 additions and 188 deletions

Binary file not shown.

View file

@ -41,7 +41,7 @@ class HashService {
final Stopwatch stopwatch = Stopwatch()..start(); final Stopwatch stopwatch = Stopwatch()..start();
try { try {
// Migrate hashes from cloud ID to local ID so we don't have to re-hash them // 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 // Sorted by backupSelection followed by isCloud
final localAlbums = await _localAlbumRepository.getBackupAlbums(); final localAlbums = await _localAlbumRepository.getBackupAlbums();
@ -78,15 +78,6 @@ class HashService {
_log.info("Hashing took - ${stopwatch.elapsedMilliseconds}ms"); _log.info("Hashing took - ${stopwatch.elapsedMilliseconds}ms");
} }
Future<void> _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 /// 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 /// 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. /// [LocalAssetHashEntity] by local id. Only missing entries are newly hashed and added to the DB.

View file

@ -50,75 +50,84 @@ Future<void> syncCloudIds(ProviderContainer ref) async {
return; return;
} }
final mappingsToUpdate = await _fetchCloudIdMappings(db, currentUser.id);
// Deduplicate mappings as a single remote asset ID can match multiple local assets
final seenRemoteAssetIds = <String>{};
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; final assetApi = ref.read(apiServiceProvider).assetsApi;
if (canBulkUpdateMetadata) { // Process cloud IDs in paginated batches
await _bulkUpdateCloudIds(assetApi, uniqueMapping); await _processCloudIdMappingsInBatches(db, currentUser.id, assetApi, canBulkUpdateMetadata, logger);
return;
}
await _sequentialUpdateCloudIds(assetApi, uniqueMapping);
} }
Future<void> _sequentialUpdateCloudIds(AssetsApi assetsApi, List<_CloudIdMapping> mappings) async { Future<void> _processCloudIdMappingsInBatches(
for (final mapping in mappings) { Drift drift,
final item = AssetMetadataUpsertItemDto( String userId,
key: kMobileMetadataKey, AssetsApi assetsApi,
value: RemoteAssetMobileAppMetadata( bool canBulkUpdate,
cloudId: mapping.localAsset.cloudId, Logger logger,
createdAt: mapping.localAsset.createdAt.toIso8601String(), ) async {
adjustmentTime: mapping.localAsset.adjustmentTime?.toIso8601String(), const pageSize = 20000;
latitude: mapping.localAsset.latitude?.toString(), String? lastLocalId;
longitude: mapping.localAsset.longitude?.toString(), final seenRemoteAssetIds = <String>{};
),
); while (true) {
try { final mappings = await _fetchCloudIdMappings(drift, userId, pageSize, lastLocalId);
await assetsApi.updateAssetMetadata(mapping.remoteAssetId, AssetMetadataUpsertDto(items: [item])); if (mappings.isEmpty) {
} catch (error, stack) { break;
Logger('migrateCloudIds').warning('Failed to update metadata for asset ${mapping.remoteAssetId}', error, stack);
} }
}
}
Future<void> _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 = <AssetMetadataBulkUpsertItemDto>[]; final items = <AssetMetadataBulkUpsertItemDto>[];
for (final mapping in batch) { for (final mapping in mappings) {
items.add( if (seenRemoteAssetIds.add(mapping.remoteAssetId)) {
AssetMetadataBulkUpsertItemDto( items.add(
assetId: mapping.remoteAssetId, AssetMetadataBulkUpsertItemDto(
key: kMobileMetadataKey, assetId: mapping.remoteAssetId,
value: RemoteAssetMobileAppMetadata( key: kMobileMetadataKey,
cloudId: mapping.localAsset.cloudId, value: RemoteAssetMobileAppMetadata(
createdAt: mapping.localAsset.createdAt.toIso8601String(), cloudId: mapping.localAsset.cloudId,
adjustmentTime: mapping.localAsset.adjustmentTime?.toIso8601String(), createdAt: mapping.localAsset.createdAt.toIso8601String(),
latitude: mapping.localAsset.latitude?.toString(), adjustmentTime: mapping.localAsset.adjustmentTime?.toIso8601String(),
longitude: mapping.localAsset.longitude?.toString(), 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<void> _sequentialUpdateCloudIds(AssetsApi assetsApi, List<AssetMetadataBulkUpsertItemDto> items) async {
for (final item in items) {
final upsertItem = AssetMetadataUpsertItemDto(key: item.key, value: item.value);
try { try {
await assetsApi.updateBulkAssetMetadata(AssetMetadataBulkUpsertDto(items: items)); await assetsApi.updateAssetMetadata(item.assetId, AssetMetadataUpsertDto(items: [upsertItem]));
} catch (error, stack) { } 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<void> _bulkUpdateCloudIds(AssetsApi assetsApi, List<AssetMetadataBulkUpsertItemDto> items) async {
try {
await assetsApi.updateBulkAssetMetadata(AssetMetadataBulkUpsertDto(items: items));
} catch (error, stack) {
Logger('migrateCloudIds').warning('Failed to bulk update metadata', error, stack);
}
}
Future<void> _populateCloudIds(Drift drift) async { Future<void> _populateCloudIds(Drift drift) async {
final query = drift.localAssetEntity.selectOnly() final query = drift.localAssetEntity.selectOnly()
..addColumns([drift.localAssetEntity.id]) ..addColumns([drift.localAssetEntity.id])
@ -141,31 +150,38 @@ Future<void> _populateCloudIds(Drift drift) async {
typedef _CloudIdMapping = ({String remoteAssetId, LocalAsset localAsset}); typedef _CloudIdMapping = ({String remoteAssetId, LocalAsset localAsset});
Future<List<_CloudIdMapping>> _fetchCloudIdMappings(Drift drift, String userId) async { Future<List<_CloudIdMapping>> _fetchCloudIdMappings(Drift drift, String userId, int limit, String? lastLocalId) async {
final query = final query =
drift.remoteAssetEntity.select().join([ drift.localAssetEntity.select().join([
leftOuterJoin( innerJoin(
drift.localAssetEntity, drift.remoteAssetEntity,
drift.localAssetEntity.checksum.equalsExp(drift.remoteAssetEntity.checksum), drift.localAssetEntity.checksum.equalsExp(drift.remoteAssetEntity.checksum),
), ),
leftOuterJoin( leftOuterJoin(
drift.remoteAssetCloudIdEntity, drift.remoteAssetCloudIdEntity,
drift.remoteAssetEntity.id.equalsExp(drift.remoteAssetCloudIdEntity.assetId), drift.remoteAssetEntity.id.equalsExp(drift.remoteAssetCloudIdEntity.assetId),
useColumns: false, useColumns: false,
), ),
])..where( ])
// Only select assets that have a local cloud ID but either no remote cloud ID or a mismatched eTag ..where(
drift.localAssetEntity.id.isNotNull() & // Only select assets that have a local cloud ID but either no remote cloud ID or a mismatched eTag
drift.localAssetEntity.iCloudId.isNotNull() & drift.localAssetEntity.iCloudId.isNotNull() &
drift.remoteAssetEntity.ownerId.equals(userId) & drift.remoteAssetEntity.ownerId.equals(userId) &
// Skip locked assets as we cannot update them without unlocking first // Skip locked assets as we cannot update them without unlocking first
drift.remoteAssetEntity.visibility.isNotValue(AssetVisibility.locked.index) & drift.remoteAssetEntity.visibility.isNotValue(AssetVisibility.locked.index) &
(drift.remoteAssetCloudIdEntity.cloudId.isNull() | (drift.remoteAssetCloudIdEntity.cloudId.isNull() |
drift.remoteAssetCloudIdEntity.adjustmentTime.isNotExp(drift.localAssetEntity.adjustmentTime) | drift.remoteAssetCloudIdEntity.adjustmentTime.isNotExp(drift.localAssetEntity.adjustmentTime) |
drift.remoteAssetCloudIdEntity.latitude.isNotExp(drift.localAssetEntity.latitude) | drift.remoteAssetCloudIdEntity.latitude.isNotExp(drift.localAssetEntity.latitude) |
drift.remoteAssetCloudIdEntity.longitude.isNotExp(drift.localAssetEntity.longitude) | drift.remoteAssetCloudIdEntity.longitude.isNotExp(drift.localAssetEntity.longitude) |
drift.remoteAssetCloudIdEntity.createdAt.isNotExp(drift.localAssetEntity.createdAt)), 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 query.map((row) {
return ( return (
remoteAssetId: row.read(drift.remoteAssetEntity.id)!, remoteAssetId: row.read(drift.remoteAssetEntity.id)!,

View file

@ -2,6 +2,7 @@ import 'package:drift/drift.dart';
import 'package:immich_mobile/infrastructure/entities/remote_asset.entity.dart'; import 'package:immich_mobile/infrastructure/entities/remote_asset.entity.dart';
import 'package:immich_mobile/infrastructure/utils/drift_default.mixin.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 { class RemoteAssetCloudIdEntity extends Table with DriftDefaultsMixin {
TextColumn get assetId => text().references(RemoteAssetEntity, #id, onDelete: KeyAction.cascade)(); TextColumn get assetId => text().references(RemoteAssetEntity, #id, onDelete: KeyAction.cascade)();

View file

@ -97,7 +97,7 @@ class Drift extends $Drift implements IDatabaseRepository {
} }
@override @override
int get schemaVersion => 17; int get schemaVersion => 18;
@override @override
MigrationStrategy get migration => MigrationStrategy( MigrationStrategy get migration => MigrationStrategy(
@ -204,6 +204,9 @@ class Drift extends $Drift implements IDatabaseRepository {
from16To17: (m, v17) async { from16To17: (m, v17) async {
await m.addColumn(v17.remoteAssetEntity, v17.remoteAssetEntity.isEdited); await m.addColumn(v17.remoteAssetEntity, v17.remoteAssetEntity.isEdited);
}, },
from17To18: (m, v18) async {
await m.createIndex(v18.idxRemoteAssetCloudId);
},
), ),
); );

View file

@ -204,34 +204,23 @@ class DriftLocalAssetRepository extends DriftDatabaseRepository {
return query.map((row) => row.toDto()).get(); return query.map((row) => row.toDto()).get();
} }
Future<Map<String, String>> getHashMappingFromCloudId() async { Future<void> reconcileHashesFromCloudId() async {
final query = await _db.customUpdate(
_db.localAssetEntity.selectOnly().join([ '''
leftOuterJoin( UPDATE local_asset_entity
_db.remoteAssetCloudIdEntity, SET checksum = remote_asset_entity.checksum
_db.localAssetEntity.iCloudId.equalsExp(_db.remoteAssetCloudIdEntity.cloudId), FROM remote_asset_cloud_id_entity
useColumns: false, INNER JOIN remote_asset_entity
), ON remote_asset_cloud_id_entity.asset_id = remote_asset_entity.id
leftOuterJoin( WHERE local_asset_entity.i_cloud_id = remote_asset_cloud_id_entity.cloud_id
_db.remoteAssetEntity, AND local_asset_entity.checksum IS NULL
_db.remoteAssetCloudIdEntity.assetId.equalsExp(_db.remoteAssetEntity.id), AND remote_asset_cloud_id_entity.adjustment_time IS local_asset_entity.adjustment_time
useColumns: false, 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
..addColumns([_db.localAssetEntity.id, _db.remoteAssetEntity.checksum]) ''',
..where( updates: {_db.localAssetEntity},
_db.remoteAssetCloudIdEntity.cloudId.isNotNull() & updateKind: UpdateKind.update,
_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};
} }
} }

View file

@ -75,7 +75,8 @@ class SplashScreenPageState extends ConsumerState<SplashScreenPage> {
_resumeBackup(backupProvider); _resumeBackup(backupProvider);
}), }),
_resumeBackup(backupProvider), _resumeBackup(backupProvider),
backgroundManager.syncCloudIds(), // TODO: Bring back when the soft freeze issue is addressed
// backgroundManager.syncCloudIds(),
]); ]);
} else { } else {
await backgroundManager.hashAssets(); await backgroundManager.hashAssets();

View file

@ -160,7 +160,8 @@ class AppLifeCycleNotifier extends StateNotifier<AppLifeCycleEnum> {
_resumeBackup(); _resumeBackup();
}), }),
_resumeBackup(), _resumeBackup(),
_safeRun(backgroundManager.syncCloudIds(), "syncCloudIds"), // TODO: Bring back when the soft freeze issue is addressed
// _safeRun(backgroundManager.syncCloudIds(), "syncCloudIds"),
]); ]);
} else { } else {
await _safeRun(backgroundManager.hashAssets(), "hashAssets"); await _safeRun(backgroundManager.hashAssets(), "hashAssets");

View file

@ -33,7 +33,7 @@ void main() {
registerFallbackValue(LocalAssetStub.image1); registerFallbackValue(LocalAssetStub.image1);
registerFallbackValue(<String, String>{}); registerFallbackValue(<String, String>{});
when(() => mockAssetRepo.getHashMappingFromCloudId()).thenAnswer((_) async => {}); when(() => mockAssetRepo.reconcileHashesFromCloudId()).thenAnswer((_) async => {});
when(() => mockAssetRepo.updateHashes(any())).thenAnswer((_) async => {}); when(() => mockAssetRepo.updateHashes(any())).thenAnswer((_) async => {});
}); });
@ -191,5 +191,4 @@ void main() {
verify(() => mockNativeApi.hashAssets([asset2.id], allowNetworkAccess: false)).called(1); verify(() => mockNativeApi.hashAssets([asset2.id], allowNetworkAccess: false)).called(1);
}); });
}); });
} }

Binary file not shown.

Binary file not shown.

View file

@ -1,4 +1,4 @@
import 'package:drift/drift.dart'; import 'package:drift/drift.dart' hide isNull;
import 'package:drift/native.dart'; import 'package:drift/native.dart';
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
import 'package:immich_mobile/constants/enums.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_album_asset.entity.drift.dart';
import 'package:immich_mobile/infrastructure/entities/local_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.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/entities/user.entity.drift.dart';
import 'package:immich_mobile/infrastructure/repositories/db.repository.dart'; import 'package:immich_mobile/infrastructure/repositories/db.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/local_asset.repository.dart'; import 'package:immich_mobile/infrastructure/repositories/local_asset.repository.dart';
void main() { void main() {
final now = DateTime(2024, 1, 15);
late Drift db; late Drift db;
late DriftLocalAssetRepository repository; late DriftLocalAssetRepository repository;
@ -25,68 +27,98 @@ void main() {
await db.close(); await db.close();
}); });
Future<void> 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<void> 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<void> 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<void> insertUser(String id, String email) async {
await db.into(db.userEntity).insert(UserEntityCompanion.insert(id: id, email: email, name: email));
}
group('getRemovalCandidates', () { group('getRemovalCandidates', () {
final userId = 'user-123'; final userId = 'user-123';
final otherUserId = 'user-456'; final otherUserId = 'user-456';
final now = DateTime(2024, 1, 15);
final cutoffDate = DateTime(2024, 1, 10); final cutoffDate = DateTime(2024, 1, 10);
final beforeCutoff = DateTime(2024, 1, 5); final beforeCutoff = DateTime(2024, 1, 5);
final afterCutoff = DateTime(2024, 1, 12); final afterCutoff = DateTime(2024, 1, 12);
Future<void> insertUser(String id, String email) async {
await db.into(db.userEntity).insert(UserEntityCompanion.insert(id: id, email: email, name: email));
}
setUp(() async { setUp(() async {
await insertUser(userId, 'user@test.com'); await insertUser(userId, 'user@test.com');
await insertUser(otherUserId, 'other@test.com'); await insertUser(otherUserId, 'other@test.com');
}); });
Future<void> 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<void> 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<void> insertLocalAlbum({required String id, required String name, required bool isIosSharedAlbum}) async { Future<void> insertLocalAlbum({required String id, required String name, required bool isIosSharedAlbum}) async {
await db await db
.into(db.localAlbumEntity) .into(db.localAlbumEntity)
@ -211,11 +243,7 @@ void main() {
); );
await insertRemoteAsset(id: 'remote-video', checksum: 'checksum-video', ownerId: userId); await insertRemoteAsset(id: 'remote-video', checksum: 'checksum-video', ownerId: userId);
final result = await repository.getRemovalCandidates( final result = await repository.getRemovalCandidates(userId, cutoffDate, keepMediaType: AssetKeepType.photosOnly);
userId,
cutoffDate,
keepMediaType: AssetKeepType.photosOnly,
);
expect(result.assets.length, 1); expect(result.assets.length, 1);
expect(result.assets[0].id, 'local-video'); expect(result.assets[0].id, 'local-video');
@ -243,11 +271,7 @@ void main() {
); );
await insertRemoteAsset(id: 'remote-video', checksum: 'checksum-video', ownerId: userId); await insertRemoteAsset(id: 'remote-video', checksum: 'checksum-video', ownerId: userId);
final result = await repository.getRemovalCandidates( final result = await repository.getRemovalCandidates(userId, cutoffDate, keepMediaType: AssetKeepType.videosOnly);
userId,
cutoffDate,
keepMediaType: AssetKeepType.videosOnly,
);
expect(result.assets.length, 1); expect(result.assets.length, 1);
expect(result.assets[0].id, 'local-photo'); expect(result.assets[0].id, 'local-photo');
@ -507,11 +531,7 @@ void main() {
await insertRemoteAsset(id: 'remote-3', checksum: 'checksum-3', ownerId: userId); await insertRemoteAsset(id: 'remote-3', checksum: 'checksum-3', ownerId: userId);
await insertLocalAlbumAsset(albumId: 'album-3', assetId: 'local-3'); await insertLocalAlbumAsset(albumId: 'album-3', assetId: 'local-3');
final result = await repository.getRemovalCandidates( final result = await repository.getRemovalCandidates(userId, cutoffDate, keepAlbumIds: {'album-1', 'album-2'});
userId,
cutoffDate,
keepAlbumIds: {'album-1', 'album-2'},
);
expect(result.assets.length, 1); expect(result.assets.length, 1);
expect(result.assets[0].id, 'local-3'); expect(result.assets[0].id, 'local-3');
@ -644,4 +664,313 @@ void main() {
expect(result.assets[0].id, 'local-video'); 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);
});
});
} }