From 836d22570f8bb5facebf7d66846247ee5b93578f Mon Sep 17 00:00:00 2001 From: shenlong <139912620+shenlong-tanwen@users.noreply.github.com> Date: Mon, 26 Jan 2026 20:42:00 +0530 Subject: [PATCH] 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> --- .../drift_schemas/main/drift_schema_v18.json | Bin 0 -> 43891 bytes mobile/lib/domain/services/hash.service.dart | 11 +- .../lib/domain/utils/migrate_cloud_ids.dart | 172 ++++--- .../remote_asset_cloud_id.entity.dart | 1 + .../remote_asset_cloud_id.entity.drift.dart | Bin 28634 -> 28817 bytes .../repositories/db.repository.dart | 5 +- .../repositories/db.repository.drift.dart | Bin 14174 -> 14205 bytes .../repositories/db.repository.steps.dart | Bin 218059 -> 230516 bytes .../repositories/local_asset.repository.dart | 47 +- .../lib/pages/common/splash_screen.page.dart | 3 +- .../providers/app_life_cycle.provider.dart | 3 +- .../domain/services/hash_service_test.dart | 3 +- mobile/test/drift/main/generated/schema.dart | Bin 2015 -> 2109 bytes .../test/drift/main/generated/schema_v18.dart | Bin 0 -> 271927 bytes .../local_asset_repository_test.dart | 461 +++++++++++++++--- 15 files changed, 518 insertions(+), 188 deletions(-) create mode 100644 mobile/drift_schemas/main/drift_schema_v18.json create mode 100644 mobile/test/drift/main/generated/schema_v18.dart 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 0000000000000000000000000000000000000000..8d9efd3db66629b62005002a6d9f07ef82d0ed70 GIT binary patch literal 43891 zcmeGlYj4{|@~;s5QU?%%*nQm9SLEP0Dk3y-+Q{zX>VTj`%3@8C3LlQ$ApiYlm%HTb zgAz%R6rBplhj?eToL$b&dxoD(Yvm@kdHTt8+;|>&>%NSg^}Sz-XeKwj!ZTWRy=-s^7NVc zd1Qw488LZgoP?2UIdK9de!-VEPyhU6dX9PeY-C36 z!j0Tu?!rQUz8{&%!`g+XB=*dA;kmFT-Bn<($Z!g%TXdNR0DgoAIPToHBiFIKz*>c2 zWS%Z;KXymvJoM95Kvw-^vR}g^N9K*2B(Mk`BEPfPgX1Lff*Y6@q`ps)$QMjuo2&CM zfGKd02`o+K0FLg$PW=S-AHiP5vAOR7x*{m{t%YkRX_W2x^C(AE*^DzX0UtGC9GAd1njAN~TBSQX zzk2s>d~s=APTq{CmuGK&-eITq9dM(_0?q<_My93=c+OyWd%!EY*jep&mN8dAKLBnH zJdjEY;8~UnlmKLv5yv=4SChbT?}7TVc|V-debcQyKSF;z&GND0EX5rZWXfpFwG6Cxl(?$^FI_Y!M;3#^?; zk@ueo2PF#}OXB3&R(TS+s}O{i9mg&l1Ce}951 zfOuZmcVR>{Oj$~J@s9-vW8AWijBsc}J1$+WAH6vI7=WFrp;8K%5L*BDP{Z!1x3qG~b#((X4Q>NC zEYR=@f$fKL+b>)V3awK3%fg_*N369odXosrGF{Cqume@eMM1AJ2u4^#6TPA9xV~GW zJ}^)<^`SR}eece-*2^#n!O(ErPfSR24C?jz-Yl}C2OY8uG$?dI zcDfGe0veBLJmvsQitVcc+4Eac1aylj0;<%BRNK|klf&brw!>M zqnx^Xc;6G#{90m~Osy#oI%X!v!AD#KNgtKodGHaJ2b>Q+Vz~A)HTP!n<<4Ny+{=ft zDBl~B-2Z#<5qFXCfn32N5g-62gME60kUv#%Q5;;vSq@OW)Cq96gDFNsFv^LFFiZn7 zE2yWIl`8c0joiX3>sfO@Od$zNV;`OqNEP+6H@<5-e}VC{B8gcfT}?Uq0?x8h@ab!9 zs@_odfmo8%QE^590oyRm66uIGo~5((hie$zlpu>B`^6TNe_(4}C#HNOnHIKu&YHqb#3N*;Q6e$O^}TBln#7leigU{&EC=I=(b6u3o+WycVPQrcz z@SCl=AoD;UR6$xBw6k7Sn>?Gn5xnmji0mHwZ4@YSf8r z*xE+gO*G`Z@GW~r8^o+zY3JkWE0q16q)B`|`nHxXv13d-`?==MQ9zY@R?)~Vy=ZD^ zUcP6$COTJt!p>Bqy<}95==|yw(Bm0z;4>|qafK&zbH)coR@AM_xdOmXx^a*r=8@BQ zQOfx*nX?sq%_GIBAYH&~M_U7sJ_nG~LmkNw%y;fBd}mP{73!x+@$|d#85BTFE?$m* zHzq$C7jG|(@$ZxA<R{n<&G7VcYF0quBnpE#@6QDUR_LnxzbbVmX)r98oRy@etQMgLdH>%o^DL0)UWvD zKi9+pYt6gVwE%EzwsPIC1vLYJcRgStDl*ixn23p z2#*qIy6lw77@YRUr8kAIUiE&|POh}mWND#7HF(*Fwl2gx>$~?1Z{Q-PA@Nb{rP!L`m8UwVy!ecbV_e(!sL-dkVR`bMh-)btZfPK37vG7}jRvlr_~K#4p*hZ>IZpN7 zRayU=o}1&;^=N>pkw%ms_Ol&KL25cm$cJ`1!BQW{?(K%C?G6`O=37D_# z7N6^cr)0ZIV`#OtWm{Kd&ZF7a8s5=7)TG=2;5-}0Z$={>tGPNGSHE>;qJw9zuwIXq zB(}mN-_)&-^d%&wdh;AMsvT;)jAXnI2N=>$+x9M2SXxmC>he6TTk+Oa%gnPeOv}4* znYxHJXU`igs);5a}7C#a2 zp0kE;#Azn=X?tU@f0)vqmm}lt>@SG?{`Yy8nGgiA%$-fh4Y3zFD`I=|wl(a9v{=c! zI&k`R4M!HLooTD`GvJGd1Bcn#!c@y)*x`~Mf4cBR2L278hXZbQz@V0p>IH~ztJMHr zKq?m)NZ?cLb~Qyfh-Pf9q{KX|Te_Sg1Y6W#LF{xB;gHyibRiJylf$^k8CarOF(;mT z?=70MKNNuxZU5fFw}LN{JLlVNR8`{<3X#=SV6=P?dX=iG78}GjvwypxFi4>*h^Flu z9~}yo^DqsPNOPF^_)st<1qE3rbg1IH@dD_1E8F+H=HP8H0b|d>f==179a+$WSkodh z2N8yG-X{g;HPa%p`7p*)wrYAiR^Fgg#F38<0!3)A9LCU@USAl=p_;PDeZKsK1wE^0 znpCvK1s}(U>+G>6lD4BKo{iWjl5L)YU!mD2qO^X6 zJZV-rW%3UDaH&^ArR>Py-5H5Pq>0Pk}T;q)0Z}b3p+eETJ0L+y4 z>M}&mHVIh-*)O);Yoi70K9LKYwkx13D6NMsAytFKR1ekA_A$u!7>etPd8~@L-oXYsa|rhOr!EfN<{$Z zVCZ%6wYx|0A%TTG2fd3r>@^~ZK+g!Uid5=Ntp&U5pZjI_x`GmasvHgu^l8V{^(uMA z0o|Ic+4Ds^vtel#-;B7}&YA^2uzBc*YI|}%icd=8i0JJ;%)AIn8i^cLTqYFsGJ{}r zwL-UJggevIfN>$MOm6seJUTgnM^g{msaX>3%{U-shF`97R?6lnpbCqxsFs!bb87^M zYcU1%)s7mE*qK!zBcxEKn2xWJrWvHlIaj-hrN=Co8FEU^&Y$V1wt6>*3*QfhW-h!y zNsNzD*l(Ugt3xoe>)Dfze1#dhdCivw-&d}8vm~z1VIVw%%0&l~t*u#jZ`~iO@`S-( zg!jJ_=m4>P*ogHX1I7COWaC*A=n&6(Bi4Tm2kSUYBQSKdv9}CH0$9=^YjnDb0fEZxODcOE#5^(iTkvo~?A8M0UJ{Z*-{5v*G2|?bh=s96VZ<#|HN(Q5vTk zP>w@md}|4(|pDtkk=U2bOod@3inq5rM;(TEeJVEy|o`+)i~v=6}t&1=ViIxFo; z`(w06>a4f;s>~*bPyever>a|&D|=7ShuUI7^pv_+jEg=9vhoo-r#Y*+{9IZOOm fRkte_0JgAQdI74wRo!0b0^2squi3VZzW@Axh!(h6 literal 0 HcmV?d00001 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 8b15fb8f475eb905d9db6c2f964d69823937ced5..f86528ee6410cff780b3118c8d41d7052c8f3b51 100644 GIT binary patch delta 155 zcmcb0pK;8p$AI<1 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 72dc1e804617ba33e58123cc7fc3f1593d46d094..c561eef0c6d01b5bb6c22e296598e0f3da402d37 100644 GIT binary patch delta 41 xcmcbY_cw3DNf8M{6TQroilEfo{E}41;^Nd2=bZe~6wj2&FGS=vKM<)?0swcH5jg+= delta 12 UcmeyHcQ0?lNs-MjM5ZbM05M7is{jB1 diff --git a/mobile/lib/infrastructure/repositories/db.repository.steps.dart b/mobile/lib/infrastructure/repositories/db.repository.steps.dart index fe7d1d4f0d5dc0edcdde4407f3efe5642715adef..72601f249f06747804c73d97697c74335c1c0d3d 100644 GIT binary patch delta 282 zcmX@TocBuyUqcIH3)2?nch=LTHZqDZT1`3^GmFm8Wm&HRLAy0a~_nW&+KLUD3N zYHp&YLRwLNuAzBIzM;kT3l7Y8x!IBvi&GU0rx&;~D=->PpWw=z$Oe(0zR{6c8R+Eg iOMRGaS@9Y4AdvYfqmlxW*;Ytw9WDh0O&u;xYc2quOj(!! delta 46 zcmey;!FPH&Z$k@X3)2?nch=k0vYDT-Y!5GBzRkV;Z4I+2>-PL6=Esa|nmSyX)?5I< CxDd_& 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 89eef9c831f7a4a6c92784cbf76f6a850b4376bd..1fe0fff6ae6883acd41941164d755e77e2c609a6 100644 GIT binary patch delta 104 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 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); + }); + }); }