handle cloud id migration

This commit is contained in:
shenlong-tanwen 2025-09-04 01:27:34 +05:30
parent d02d3b5472
commit 82c93cf325
7 changed files with 139 additions and 28 deletions

View File

@ -1921,6 +1921,7 @@
"sync": "Sync", "sync": "Sync",
"sync_albums": "Sync albums", "sync_albums": "Sync albums",
"sync_albums_manual_subtitle": "Sync all uploaded videos and photos to the selected backup 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_local": "Sync Local",
"sync_remote": "Sync Remote", "sync_remote": "Sync Remote",
"sync_upload_album_setting_subtitle": "Create and upload your photos and videos to the selected albums on Immich", "sync_upload_album_setting_subtitle": "Create and upload your photos and videos to the selected albums on Immich",

View File

@ -1,5 +1,6 @@
import 'dart:async'; 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/domain/utils/sync_linked_album.dart';
import 'package:immich_mobile/providers/infrastructure/sync.provider.dart'; import 'package:immich_mobile/providers/infrastructure/sync.provider.dart';
import 'package:immich_mobile/utils/isolate.dart'; import 'package:immich_mobile/utils/isolate.dart';
@ -21,8 +22,13 @@ class BackgroundSyncManager {
final SyncCallback? onHashingComplete; final SyncCallback? onHashingComplete;
final SyncErrorCallback? onHashingError; final SyncErrorCallback? onHashingError;
final SyncCallback? onCloudIdSyncStart;
final SyncCallback? onCloudIdSyncComplete;
final SyncErrorCallback? onCloudIdSyncError;
Cancelable<void>? _syncTask; Cancelable<void>? _syncTask;
Cancelable<void>? _syncWebsocketTask; Cancelable<void>? _syncWebsocketTask;
Cancelable<void>? _cloudIdSyncTask;
Cancelable<void>? _deviceAlbumSyncTask; Cancelable<void>? _deviceAlbumSyncTask;
Cancelable<void>? _linkedAlbumSyncTask; Cancelable<void>? _linkedAlbumSyncTask;
Cancelable<void>? _hashTask; Cancelable<void>? _hashTask;
@ -37,6 +43,9 @@ class BackgroundSyncManager {
this.onHashingStart, this.onHashingStart,
this.onHashingComplete, this.onHashingComplete,
this.onHashingError, this.onHashingError,
this.onCloudIdSyncStart,
this.onCloudIdSyncComplete,
this.onCloudIdSyncError,
}); });
Future<void> cancel() async { Future<void> cancel() async {
@ -54,6 +63,11 @@ class BackgroundSyncManager {
_syncWebsocketTask?.cancel(); _syncWebsocketTask?.cancel();
_syncWebsocketTask = null; _syncWebsocketTask = null;
if (_cloudIdSyncTask != null) {
futures.add(_cloudIdSyncTask!.future);
}
_cloudIdSyncTask?.cancel();
if (_linkedAlbumSyncTask != null) { if (_linkedAlbumSyncTask != null) {
futures.add(_linkedAlbumSyncTask!.future); futures.add(_linkedAlbumSyncTask!.future);
} }
@ -173,6 +187,24 @@ class BackgroundSyncManager {
return _linkedAlbumSyncTask!.whenComplete(() { return _linkedAlbumSyncTask!.whenComplete(() {
_linkedAlbumSyncTask = null; _linkedAlbumSyncTask = null;
}); });
Future<void> 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;
});
} }
} }

View File

@ -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<void> 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<void> _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<List<_CloudIdMapping>> _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();
}

View File

@ -15,6 +15,9 @@ final backgroundSyncProvider = Provider<BackgroundSyncManager>((ref) {
onHashingStart: syncStatusNotifier.startHashJob, onHashingStart: syncStatusNotifier.startHashJob,
onHashingComplete: syncStatusNotifier.completeHashJob, onHashingComplete: syncStatusNotifier.completeHashJob,
onHashingError: syncStatusNotifier.errorHashJob, onHashingError: syncStatusNotifier.errorHashJob,
onCloudIdSyncStart: syncStatusNotifier.startCloudIdSync,
onCloudIdSyncComplete: syncStatusNotifier.completeCloudIdSync,
onCloudIdSyncError: syncStatusNotifier.errorCloudIdSync,
); );
ref.onDispose(manager.cancel); ref.onDispose(manager.cancel);
return manager; return manager;

View File

@ -21,6 +21,7 @@ class SyncStatusState {
final SyncStatus remoteSyncStatus; final SyncStatus remoteSyncStatus;
final SyncStatus localSyncStatus; final SyncStatus localSyncStatus;
final SyncStatus hashJobStatus; final SyncStatus hashJobStatus;
final SyncStatus cloudIdSyncStatus;
final String? errorMessage; final String? errorMessage;
@ -28,6 +29,7 @@ class SyncStatusState {
this.remoteSyncStatus = SyncStatus.idle, this.remoteSyncStatus = SyncStatus.idle,
this.localSyncStatus = SyncStatus.idle, this.localSyncStatus = SyncStatus.idle,
this.hashJobStatus = SyncStatus.idle, this.hashJobStatus = SyncStatus.idle,
this.cloudIdSyncStatus = SyncStatus.idle,
this.errorMessage, this.errorMessage,
}); });
@ -35,12 +37,14 @@ class SyncStatusState {
SyncStatus? remoteSyncStatus, SyncStatus? remoteSyncStatus,
SyncStatus? localSyncStatus, SyncStatus? localSyncStatus,
SyncStatus? hashJobStatus, SyncStatus? hashJobStatus,
SyncStatus? cloudIdSyncStatus,
String? errorMessage, String? errorMessage,
}) { }) {
return SyncStatusState( return SyncStatusState(
remoteSyncStatus: remoteSyncStatus ?? this.remoteSyncStatus, remoteSyncStatus: remoteSyncStatus ?? this.remoteSyncStatus,
localSyncStatus: localSyncStatus ?? this.localSyncStatus, localSyncStatus: localSyncStatus ?? this.localSyncStatus,
hashJobStatus: hashJobStatus ?? this.hashJobStatus, hashJobStatus: hashJobStatus ?? this.hashJobStatus,
cloudIdSyncStatus: cloudIdSyncStatus ?? this.cloudIdSyncStatus,
errorMessage: errorMessage ?? this.errorMessage, errorMessage: errorMessage ?? this.errorMessage,
); );
} }
@ -48,6 +52,7 @@ class SyncStatusState {
bool get isRemoteSyncing => remoteSyncStatus == SyncStatus.syncing; bool get isRemoteSyncing => remoteSyncStatus == SyncStatus.syncing;
bool get isLocalSyncing => localSyncStatus == SyncStatus.syncing; bool get isLocalSyncing => localSyncStatus == SyncStatus.syncing;
bool get isHashing => hashJobStatus == SyncStatus.syncing; bool get isHashing => hashJobStatus == SyncStatus.syncing;
bool get isCloudIdSyncing => cloudIdSyncStatus == SyncStatus.syncing;
@override @override
bool operator ==(Object other) { bool operator ==(Object other) {
@ -56,11 +61,12 @@ class SyncStatusState {
other.remoteSyncStatus == remoteSyncStatus && other.remoteSyncStatus == remoteSyncStatus &&
other.localSyncStatus == localSyncStatus && other.localSyncStatus == localSyncStatus &&
other.hashJobStatus == hashJobStatus && other.hashJobStatus == hashJobStatus &&
other.cloudIdSyncStatus == cloudIdSyncStatus &&
other.errorMessage == errorMessage; other.errorMessage == errorMessage;
} }
@override @override
int get hashCode => Object.hash(remoteSyncStatus, localSyncStatus, hashJobStatus, errorMessage); int get hashCode => Object.hash(remoteSyncStatus, localSyncStatus, hashJobStatus, cloudIdSyncStatus, errorMessage);
} }
class SyncStatusNotifier extends Notifier<SyncStatusState> { class SyncStatusNotifier extends Notifier<SyncStatusState> {
@ -71,6 +77,7 @@ class SyncStatusNotifier extends Notifier<SyncStatusState> {
remoteSyncStatus: SyncStatus.idle, remoteSyncStatus: SyncStatus.idle,
localSyncStatus: SyncStatus.idle, localSyncStatus: SyncStatus.idle,
hashJobStatus: SyncStatus.idle, hashJobStatus: SyncStatus.idle,
cloudIdSyncStatus: SyncStatus.idle,
); );
} }
@ -109,6 +116,18 @@ class SyncStatusNotifier extends Notifier<SyncStatusState> {
void startHashJob() => setHashJobStatus(SyncStatus.syncing); void startHashJob() => setHashJobStatus(SyncStatus.syncing);
void completeHashJob() => setHashJobStatus(SyncStatus.success); void completeHashJob() => setHashJobStatus(SyncStatus.success);
void errorHashJob(String error) => setHashJobStatus(SyncStatus.error, error); 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, SyncStatusState>(SyncStatusNotifier.new); final syncStatusProvider = NotifierProvider<SyncStatusNotifier, SyncStatusState>(SyncStatusNotifier.new);

View File

@ -35,7 +35,7 @@ import 'package:logging/logging.dart';
// ignore: import_rule_photo_manager // ignore: import_rule_photo_manager
import 'package:photo_manager/photo_manager.dart'; import 'package:photo_manager/photo_manager.dart';
const int targetVersion = 15; const int targetVersion = 14;
Future<void> migrateDatabaseIfNeeded(Isar db, Drift drift) async { Future<void> migrateDatabaseIfNeeded(Isar db, Drift drift) async {
final hasVersion = Store.tryGet(StoreKey.version) != null; final hasVersion = Store.tryGet(StoreKey.version) != null;
@ -70,16 +70,6 @@ Future<void> migrateDatabaseIfNeeded(Isar db, Drift drift) async {
await Store.populateCache(); 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) { if (targetVersion >= 12) {
await Store.put(StoreKey.version, targetVersion); await Store.put(StoreKey.version, targetVersion);
return; return;
@ -189,20 +179,6 @@ Future<void> migrateDeviceAssetToSqlite(Isar db, Drift drift) async {
} }
} }
Future<void> 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<void> migrateBackupAlbumsToSqlite(Isar db, Drift drift) async { Future<void> migrateBackupAlbumsToSqlite(Isar db, Drift drift) async {
try { try {
final isarBackupAlbums = await db.backupAlbums.where().findAll(); final isarBackupAlbums = await db.backupAlbums.where().findAll();

View File

@ -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/build_context_extensions.dart';
import 'package:immich_mobile/extensions/translate_extensions.dart'; import 'package:immich_mobile/extensions/translate_extensions.dart';
import 'package:immich_mobile/providers/background_sync.provider.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/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/db.provider.dart';
import 'package:immich_mobile/providers/infrastructure/memory.provider.dart'; import 'package:immich_mobile/providers/infrastructure/memory.provider.dart';
import 'package:immich_mobile/providers/infrastructure/storage.provider.dart'; import 'package:immich_mobile/providers/infrastructure/storage.provider.dart';
@ -301,6 +301,16 @@ class BetaSyncSettings extends HookConsumerWidget {
ref.read(backgroundSyncProvider).hashAssets(); 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 Divider(height: 1, indent: 16, endIndent: 16),
const SizedBox(height: 24), const SizedBox(height: 24),
_SectionHeaderText(text: "actions".t(context: context)), _SectionHeaderText(text: "actions".t(context: context)),
@ -347,7 +357,7 @@ class _SyncStatusIcon extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return switch (status) { 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.syncing => const SizedBox(height: 24, width: 24, child: CircularProgressIndicator(strokeWidth: 2)),
SyncStatus.success => const Icon(Icons.check_circle_outline, color: Colors.green), SyncStatus.success => const Icon(Icons.check_circle_outline, color: Colors.green),
SyncStatus.error => Icon(Icons.error_outline, color: context.colorScheme.error), SyncStatus.error => Icon(Icons.error_outline, color: context.colorScheme.error),