From 82c93cf325f8d172683fc58419c955bcbf71faff Mon Sep 17 00:00:00 2001 From: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com> Date: Thu, 4 Sep 2025 01:27:34 +0530 Subject: [PATCH] handle cloud id migration --- i18n/en.json | 1 + mobile/lib/domain/utils/background_sync.dart | 32 +++++++++ .../lib/domain/utils/migrate_cloud_ids.dart | 70 +++++++++++++++++++ .../providers/background_sync.provider.dart | 3 + .../lib/providers/sync_status.provider.dart | 21 +++++- mobile/lib/utils/migration.dart | 26 +------ .../beta_sync_settings.dart | 14 +++- 7 files changed, 139 insertions(+), 28 deletions(-) create mode 100644 mobile/lib/domain/utils/migrate_cloud_ids.dart diff --git a/i18n/en.json b/i18n/en.json index f8a51eb5f6..d375e15177 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -1921,6 +1921,7 @@ "sync": "Sync", "sync_albums": "Sync albums", "sync_albums_manual_subtitle": "Sync all uploaded videos and photos to the selected backup albums", + "sync_cloud_ids": "Sync Cloud IDs", "sync_local": "Sync Local", "sync_remote": "Sync Remote", "sync_upload_album_setting_subtitle": "Create and upload your photos and videos to the selected albums on Immich", diff --git a/mobile/lib/domain/utils/background_sync.dart b/mobile/lib/domain/utils/background_sync.dart index 4160a5f7bc..b33ea2a386 100644 --- a/mobile/lib/domain/utils/background_sync.dart +++ b/mobile/lib/domain/utils/background_sync.dart @@ -1,5 +1,6 @@ import 'dart:async'; +import 'package:immich_mobile/domain/utils/migrate_cloud_ids.dart'; import 'package:immich_mobile/domain/utils/sync_linked_album.dart'; import 'package:immich_mobile/providers/infrastructure/sync.provider.dart'; import 'package:immich_mobile/utils/isolate.dart'; @@ -21,8 +22,13 @@ class BackgroundSyncManager { final SyncCallback? onHashingComplete; final SyncErrorCallback? onHashingError; + final SyncCallback? onCloudIdSyncStart; + final SyncCallback? onCloudIdSyncComplete; + final SyncErrorCallback? onCloudIdSyncError; + Cancelable? _syncTask; Cancelable? _syncWebsocketTask; + Cancelable? _cloudIdSyncTask; Cancelable? _deviceAlbumSyncTask; Cancelable? _linkedAlbumSyncTask; Cancelable? _hashTask; @@ -37,6 +43,9 @@ class BackgroundSyncManager { this.onHashingStart, this.onHashingComplete, this.onHashingError, + this.onCloudIdSyncStart, + this.onCloudIdSyncComplete, + this.onCloudIdSyncError, }); Future cancel() async { @@ -54,6 +63,11 @@ class BackgroundSyncManager { _syncWebsocketTask?.cancel(); _syncWebsocketTask = null; + if (_cloudIdSyncTask != null) { + futures.add(_cloudIdSyncTask!.future); + } + _cloudIdSyncTask?.cancel(); + if (_linkedAlbumSyncTask != null) { futures.add(_linkedAlbumSyncTask!.future); } @@ -173,6 +187,24 @@ class BackgroundSyncManager { return _linkedAlbumSyncTask!.whenComplete(() { _linkedAlbumSyncTask = null; }); + + Future syncCloudIds() { + if (_cloudIdSyncTask != null) { + return _cloudIdSyncTask!.future; + } + + onCloudIdSyncStart?.call(); + + _cloudIdSyncTask = runInIsolateGentle(computation: migrateCloudIds); + return _cloudIdSyncTask! + .whenComplete(() { + onCloudIdSyncComplete?.call(); + _cloudIdSyncTask = null; + }) + .catchError((error) { + onCloudIdSyncError?.call(error.toString()); + _cloudIdSyncTask = null; + }); } } diff --git a/mobile/lib/domain/utils/migrate_cloud_ids.dart b/mobile/lib/domain/utils/migrate_cloud_ids.dart new file mode 100644 index 0000000000..a85f17232e --- /dev/null +++ b/mobile/lib/domain/utils/migrate_cloud_ids.dart @@ -0,0 +1,70 @@ +import 'package:drift/drift.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/domain/models/asset/asset_metadata.model.dart'; +import 'package:immich_mobile/infrastructure/repositories/db.repository.dart'; +import 'package:immich_mobile/infrastructure/repositories/local_album.repository.dart'; +import 'package:immich_mobile/platform/native_sync_api.g.dart'; +import 'package:immich_mobile/providers/api.provider.dart'; +import 'package:immich_mobile/providers/infrastructure/db.provider.dart'; +import 'package:immich_mobile/providers/infrastructure/sync.provider.dart'; +// ignore: import_rule_openapi +import 'package:openapi/api.dart'; + +Future migrateCloudIds(ProviderContainer ref) async { + final db = ref.read(driftProvider); + // Populate cloud IDs for local assets that don't have one yet + await _populateCloudIds(db); + + // Wait for remote sync to complete, so we have up-to-date asset metadata entries + await ref.read(syncStreamServiceProvider).sync(); + + // Fetch the mapping for backed up assets that have a cloud ID locally but do not have a cloud ID on the server + final mappingsToUpdate = await _fetchCloudIdMappings(db); + final assetApi = ref.read(apiServiceProvider).assetsApi; + for (final mapping in mappingsToUpdate) { + final mobileMeta = AssetMetadataUpsertItemDto( + key: AssetMetadataKey.mobileApp, + value: {"iCloudId": mapping.cloudId}, + ); + await assetApi.updateAssetMetadata(mapping.assetId, AssetMetadataUpsertDto(items: [mobileMeta])); + } +} + +Future _populateCloudIds(Drift drift) async { + final query = drift.localAssetEntity.selectOnly() + ..addColumns([drift.localAssetEntity.id]) + ..where(drift.localAssetEntity.cloudId.isNull()); + final ids = await query.map((row) => row.read(drift.localAssetEntity.id)!).get(); + final cloudMapping = await NativeSyncApi().getCloudIdForAssetIds(ids); + await DriftLocalAlbumRepository(drift).updateCloudMapping(cloudMapping); +} + +typedef _CloudIdMapping = ({String assetId, String cloudId}); + +Future> _fetchCloudIdMappings(Drift drift) async { + final query = + drift.remoteAssetEntity.selectOnly().join([ + leftOuterJoin( + drift.localAssetEntity, + drift.localAssetEntity.checksum.equalsExp(drift.remoteAssetEntity.checksum), + useColumns: false, + ), + leftOuterJoin( + drift.remoteAssetMetadataEntity, + drift.remoteAssetMetadataEntity.assetId.equalsExp(drift.remoteAssetEntity.id) & + drift.remoteAssetMetadataEntity.key.equalsValue(RemoteAssetMetadataKey.mobileApp), + useColumns: false, + ), + ]) + ..addColumns([drift.remoteAssetEntity.id, drift.localAssetEntity.cloudId]) + ..where( + drift.localAssetEntity.id.isNotNull() & + drift.localAssetEntity.cloudId.isNotNull() & + drift.remoteAssetMetadataEntity.cloudId.isNull(), + ); + return query + .map( + (row) => (assetId: row.read(drift.remoteAssetEntity.id)!, cloudId: row.read(drift.localAssetEntity.cloudId)!), + ) + .get(); +} diff --git a/mobile/lib/providers/background_sync.provider.dart b/mobile/lib/providers/background_sync.provider.dart index 1981c45fb1..f9f845fccb 100644 --- a/mobile/lib/providers/background_sync.provider.dart +++ b/mobile/lib/providers/background_sync.provider.dart @@ -15,6 +15,9 @@ final backgroundSyncProvider = Provider((ref) { onHashingStart: syncStatusNotifier.startHashJob, onHashingComplete: syncStatusNotifier.completeHashJob, onHashingError: syncStatusNotifier.errorHashJob, + onCloudIdSyncStart: syncStatusNotifier.startCloudIdSync, + onCloudIdSyncComplete: syncStatusNotifier.completeCloudIdSync, + onCloudIdSyncError: syncStatusNotifier.errorCloudIdSync, ); ref.onDispose(manager.cancel); return manager; diff --git a/mobile/lib/providers/sync_status.provider.dart b/mobile/lib/providers/sync_status.provider.dart index 8e24bbf4d0..203184fc87 100644 --- a/mobile/lib/providers/sync_status.provider.dart +++ b/mobile/lib/providers/sync_status.provider.dart @@ -21,6 +21,7 @@ class SyncStatusState { final SyncStatus remoteSyncStatus; final SyncStatus localSyncStatus; final SyncStatus hashJobStatus; + final SyncStatus cloudIdSyncStatus; final String? errorMessage; @@ -28,6 +29,7 @@ class SyncStatusState { this.remoteSyncStatus = SyncStatus.idle, this.localSyncStatus = SyncStatus.idle, this.hashJobStatus = SyncStatus.idle, + this.cloudIdSyncStatus = SyncStatus.idle, this.errorMessage, }); @@ -35,12 +37,14 @@ class SyncStatusState { SyncStatus? remoteSyncStatus, SyncStatus? localSyncStatus, SyncStatus? hashJobStatus, + SyncStatus? cloudIdSyncStatus, String? errorMessage, }) { return SyncStatusState( remoteSyncStatus: remoteSyncStatus ?? this.remoteSyncStatus, localSyncStatus: localSyncStatus ?? this.localSyncStatus, hashJobStatus: hashJobStatus ?? this.hashJobStatus, + cloudIdSyncStatus: cloudIdSyncStatus ?? this.cloudIdSyncStatus, errorMessage: errorMessage ?? this.errorMessage, ); } @@ -48,6 +52,7 @@ class SyncStatusState { bool get isRemoteSyncing => remoteSyncStatus == SyncStatus.syncing; bool get isLocalSyncing => localSyncStatus == SyncStatus.syncing; bool get isHashing => hashJobStatus == SyncStatus.syncing; + bool get isCloudIdSyncing => cloudIdSyncStatus == SyncStatus.syncing; @override bool operator ==(Object other) { @@ -56,11 +61,12 @@ class SyncStatusState { other.remoteSyncStatus == remoteSyncStatus && other.localSyncStatus == localSyncStatus && other.hashJobStatus == hashJobStatus && + other.cloudIdSyncStatus == cloudIdSyncStatus && other.errorMessage == errorMessage; } @override - int get hashCode => Object.hash(remoteSyncStatus, localSyncStatus, hashJobStatus, errorMessage); + int get hashCode => Object.hash(remoteSyncStatus, localSyncStatus, hashJobStatus, cloudIdSyncStatus, errorMessage); } class SyncStatusNotifier extends Notifier { @@ -71,6 +77,7 @@ class SyncStatusNotifier extends Notifier { remoteSyncStatus: SyncStatus.idle, localSyncStatus: SyncStatus.idle, hashJobStatus: SyncStatus.idle, + cloudIdSyncStatus: SyncStatus.idle, ); } @@ -109,6 +116,18 @@ class SyncStatusNotifier extends Notifier { void startHashJob() => setHashJobStatus(SyncStatus.syncing); void completeHashJob() => setHashJobStatus(SyncStatus.success); void errorHashJob(String error) => setHashJobStatus(SyncStatus.error, error); + + /// + /// Cloud ID Sync Job + /// + + void setCloudIdSyncStatus(SyncStatus status, [String? errorMessage]) { + state = state.copyWith(cloudIdSyncStatus: status, errorMessage: status == SyncStatus.error ? errorMessage : null); + } + + void startCloudIdSync() => setCloudIdSyncStatus(SyncStatus.syncing); + void completeCloudIdSync() => setCloudIdSyncStatus(SyncStatus.success); + void errorCloudIdSync(String error) => setCloudIdSyncStatus(SyncStatus.error, error); } final syncStatusProvider = NotifierProvider(SyncStatusNotifier.new); diff --git a/mobile/lib/utils/migration.dart b/mobile/lib/utils/migration.dart index d0b7f63929..cdead7c949 100644 --- a/mobile/lib/utils/migration.dart +++ b/mobile/lib/utils/migration.dart @@ -35,7 +35,7 @@ import 'package:logging/logging.dart'; // ignore: import_rule_photo_manager import 'package:photo_manager/photo_manager.dart'; -const int targetVersion = 15; +const int targetVersion = 14; Future migrateDatabaseIfNeeded(Isar db, Drift drift) async { final hasVersion = Store.tryGet(StoreKey.version) != null; @@ -70,16 +70,6 @@ Future migrateDatabaseIfNeeded(Isar db, Drift drift) async { await Store.populateCache(); } - if (version < 15) { - try { - await updateCloudId(drift); - } catch (error) { - Logger("Migration").warning("Error occurred while updating cloud ID: $error"); - // do not update version when error occurs so this is retried the next time - return; - } - } - if (targetVersion >= 12) { await Store.put(StoreKey.version, targetVersion); return; @@ -189,20 +179,6 @@ Future migrateDeviceAssetToSqlite(Isar db, Drift drift) async { } } -Future updateCloudId(Drift drift) async { - // Android do not have a concept of cloud IDs - if (Platform.isAndroid) { - return; - } - - final query = drift.localAssetEntity.selectOnly() - ..addColumns([drift.localAssetEntity.id]) - ..where(drift.localAssetEntity.cloudId.isNull()); - final ids = await query.map((row) => row.read(drift.localAssetEntity.id)!).get(); - final cloudMapping = await NativeSyncApi().getCloudIdForAssetIds(ids); - await DriftLocalAlbumRepository(drift).updateCloudMapping(cloudMapping); -} - Future migrateBackupAlbumsToSqlite(Isar db, Drift drift) async { try { final isarBackupAlbums = await db.backupAlbums.where().findAll(); diff --git a/mobile/lib/widgets/settings/beta_sync_settings/beta_sync_settings.dart b/mobile/lib/widgets/settings/beta_sync_settings/beta_sync_settings.dart index e5c65a9c67..9d225f7eee 100644 --- a/mobile/lib/widgets/settings/beta_sync_settings/beta_sync_settings.dart +++ b/mobile/lib/widgets/settings/beta_sync_settings/beta_sync_settings.dart @@ -6,8 +6,8 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/extensions/translate_extensions.dart'; import 'package:immich_mobile/providers/background_sync.provider.dart'; -import 'package:immich_mobile/providers/infrastructure/asset.provider.dart'; import 'package:immich_mobile/providers/infrastructure/album.provider.dart'; +import 'package:immich_mobile/providers/infrastructure/asset.provider.dart'; import 'package:immich_mobile/providers/infrastructure/db.provider.dart'; import 'package:immich_mobile/providers/infrastructure/memory.provider.dart'; import 'package:immich_mobile/providers/infrastructure/storage.provider.dart'; @@ -301,6 +301,16 @@ class BetaSyncSettings extends HookConsumerWidget { ref.read(backgroundSyncProvider).hashAssets(); }, ), + ListTile( + title: Text( + "sync_cloud_ids".t(context: context), + style: const TextStyle(fontWeight: FontWeight.w500), + ), + leading: const Icon(Icons.keyboard_command_key_rounded), + subtitle: Text("tap_to_run_job".t(context: context)), + trailing: _SyncStatusIcon(status: ref.watch(syncStatusProvider).cloudIdSyncStatus), + onTap: ref.read(backgroundSyncProvider).syncCloudIds, + ), const Divider(height: 1, indent: 16, endIndent: 16), const SizedBox(height: 24), _SectionHeaderText(text: "actions".t(context: context)), @@ -347,7 +357,7 @@ class _SyncStatusIcon extends StatelessWidget { @override Widget build(BuildContext context) { return switch (status) { - SyncStatus.idle => const Icon(Icons.pause_circle_outline_rounded), + SyncStatus.idle => const SizedBox.shrink(), SyncStatus.syncing => const SizedBox(height: 24, width: 24, child: CircularProgressIndicator(strokeWidth: 2)), SyncStatus.success => const Icon(Icons.check_circle_outline, color: Colors.green), SyncStatus.error => Icon(Icons.error_outline, color: context.colorScheme.error),