Source Code added

This commit is contained in:
Fr4nz D13trich 2026-02-02 15:06:40 +01:00
parent 800376eafd
commit 9efa9bc6dd
3912 changed files with 754770 additions and 2 deletions

View file

@ -0,0 +1,248 @@
import 'dart:async';
import 'package:immich_mobile/domain/utils/migrate_cloud_ids.dart' as m;
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';
import 'package:worker_manager/worker_manager.dart';
typedef SyncCallback = void Function();
typedef SyncCallbackWithResult<T> = void Function(T result);
typedef SyncErrorCallback = void Function(String error);
class BackgroundSyncManager {
final SyncCallback? onRemoteSyncStart;
final SyncCallbackWithResult<bool?>? onRemoteSyncComplete;
final SyncErrorCallback? onRemoteSyncError;
final SyncCallback? onLocalSyncStart;
final SyncCallback? onLocalSyncComplete;
final SyncErrorCallback? onLocalSyncError;
final SyncCallback? onHashingStart;
final SyncCallback? onHashingComplete;
final SyncErrorCallback? onHashingError;
final SyncCallback? onCloudIdSyncStart;
final SyncCallback? onCloudIdSyncComplete;
final SyncErrorCallback? onCloudIdSyncError;
Cancelable<bool?>? _syncTask;
Cancelable<void>? _syncWebsocketTask;
Cancelable<void>? _cloudIdSyncTask;
Cancelable<void>? _deviceAlbumSyncTask;
Cancelable<void>? _linkedAlbumSyncTask;
Cancelable<void>? _hashTask;
BackgroundSyncManager({
this.onRemoteSyncStart,
this.onRemoteSyncComplete,
this.onRemoteSyncError,
this.onLocalSyncStart,
this.onLocalSyncComplete,
this.onLocalSyncError,
this.onHashingStart,
this.onHashingComplete,
this.onHashingError,
this.onCloudIdSyncStart,
this.onCloudIdSyncComplete,
this.onCloudIdSyncError,
});
Future<void> cancel() async {
final futures = <Future>[];
if (_syncTask != null) {
futures.add(_syncTask!.future);
}
_syncTask?.cancel();
_syncTask = null;
if (_syncWebsocketTask != null) {
futures.add(_syncWebsocketTask!.future);
}
_syncWebsocketTask?.cancel();
_syncWebsocketTask = null;
if (_cloudIdSyncTask != null) {
futures.add(_cloudIdSyncTask!.future);
}
_cloudIdSyncTask?.cancel();
_cloudIdSyncTask = null;
if (_linkedAlbumSyncTask != null) {
futures.add(_linkedAlbumSyncTask!.future);
}
_linkedAlbumSyncTask?.cancel();
_linkedAlbumSyncTask = null;
try {
await Future.wait(futures);
} on CanceledError {
// Ignore cancellation errors
}
}
Future<void> cancelLocal() async {
final futures = <Future>[];
if (_hashTask != null) {
futures.add(_hashTask!.future);
}
_hashTask?.cancel();
_hashTask = null;
if (_deviceAlbumSyncTask != null) {
futures.add(_deviceAlbumSyncTask!.future);
}
_deviceAlbumSyncTask?.cancel();
_deviceAlbumSyncTask = null;
try {
await Future.wait(futures);
} on CanceledError {
// Ignore cancellation errors
}
}
// No need to cancel the task, as it can also be run when the user logs out
Future<void> syncLocal({bool full = false}) {
if (_deviceAlbumSyncTask != null) {
return _deviceAlbumSyncTask!.future;
}
onLocalSyncStart?.call();
// We use a ternary operator to avoid [_deviceAlbumSyncTask] from being
// captured by the closure passed to [runInIsolateGentle].
_deviceAlbumSyncTask = full
? runInIsolateGentle(
computation: (ref) => ref.read(localSyncServiceProvider).sync(full: true),
debugLabel: 'local-sync-full-true',
)
: runInIsolateGentle(
computation: (ref) => ref.read(localSyncServiceProvider).sync(full: false),
debugLabel: 'local-sync-full-false',
);
return _deviceAlbumSyncTask!
.whenComplete(() {
_deviceAlbumSyncTask = null;
onLocalSyncComplete?.call();
})
.catchError((error) {
onLocalSyncError?.call(error.toString());
_deviceAlbumSyncTask = null;
});
}
Future<void> hashAssets() {
if (_hashTask != null) {
return _hashTask!.future;
}
onHashingStart?.call();
_hashTask = runInIsolateGentle(
computation: (ref) => ref.read(hashServiceProvider).hashAssets(),
debugLabel: 'hash-assets',
);
return _hashTask!
.whenComplete(() {
onHashingComplete?.call();
_hashTask = null;
})
.catchError((error) {
onHashingError?.call(error.toString());
_hashTask = null;
});
}
Future<bool> syncRemote() {
if (_syncTask != null) {
return _syncTask!.future.then((result) => result ?? false).catchError((_) => false);
}
onRemoteSyncStart?.call();
_syncTask = runInIsolateGentle(
computation: (ref) => ref.read(syncStreamServiceProvider).sync(),
debugLabel: 'remote-sync',
);
return _syncTask!
.then((result) {
final success = result ?? false;
onRemoteSyncComplete?.call(success);
return success;
})
.catchError((error) {
onRemoteSyncError?.call(error.toString());
_syncTask = null;
return false;
})
.whenComplete(() {
_syncTask = null;
});
}
Future<void> syncWebsocketBatch(List<dynamic> batchData) {
if (_syncWebsocketTask != null) {
return _syncWebsocketTask!.future;
}
_syncWebsocketTask = _handleWsAssetUploadReadyV1Batch(batchData);
return _syncWebsocketTask!.whenComplete(() {
_syncWebsocketTask = null;
});
}
Future<void> syncWebsocketEditBatch(List<dynamic> batchData) {
if (_syncWebsocketTask != null) {
return _syncWebsocketTask!.future;
}
_syncWebsocketTask = _handleWsAssetEditReadyV1Batch(batchData);
return _syncWebsocketTask!.whenComplete(() {
_syncWebsocketTask = null;
});
}
Future<void> syncLinkedAlbum() {
if (_linkedAlbumSyncTask != null) {
return _linkedAlbumSyncTask!.future;
}
_linkedAlbumSyncTask = runInIsolateGentle(computation: syncLinkedAlbumsIsolated, debugLabel: 'linked-album-sync');
return _linkedAlbumSyncTask!.whenComplete(() {
_linkedAlbumSyncTask = null;
});
}
Future<void> syncCloudIds() {
if (_cloudIdSyncTask != null) {
return _cloudIdSyncTask!.future;
}
onCloudIdSyncStart?.call();
_cloudIdSyncTask = runInIsolateGentle(computation: m.syncCloudIds);
return _cloudIdSyncTask!
.whenComplete(() {
onCloudIdSyncComplete?.call();
_cloudIdSyncTask = null;
})
.catchError((error) {
onCloudIdSyncError?.call(error.toString());
_cloudIdSyncTask = null;
});
}
}
Cancelable<void> _handleWsAssetUploadReadyV1Batch(List<dynamic> batchData) => runInIsolateGentle(
computation: (ref) => ref.read(syncStreamServiceProvider).handleWsAssetUploadReadyV1Batch(batchData),
debugLabel: 'websocket-batch',
);
Cancelable<void> _handleWsAssetEditReadyV1Batch(List<dynamic> batchData) => runInIsolateGentle(
computation: (ref) => ref.read(syncStreamServiceProvider).handleWsAssetEditReadyV1Batch(batchData),
debugLabel: 'websocket-edit',
);

View file

@ -0,0 +1,38 @@
import 'dart:async';
class Event {
const Event();
}
class EventStream {
EventStream._();
static final EventStream shared = EventStream._();
final StreamController<Event> _controller = StreamController<Event>.broadcast();
void emit(Event event) {
_controller.add(event);
}
Stream<T> where<T extends Event>() {
if (T == Event) {
return _controller.stream as Stream<T>;
}
return _controller.stream.where((event) => event is T).cast<T>();
}
StreamSubscription<T> listen<T extends Event>(
void Function(T event)? onData, {
Function? onError,
void Function()? onDone,
bool? cancelOnError,
}) {
return where<T>().listen(onData, onError: onError, onDone: onDone, cancelOnError: cancelOnError);
}
/// Closes the stream controller
void dispose() {
_controller.close();
}
}

View file

@ -0,0 +1,191 @@
import 'package:drift/drift.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/constants.dart';
import 'package:immich_mobile/domain/models/asset/asset_metadata.model.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/extensions/platform_extensions.dart';
import 'package:immich_mobile/infrastructure/entities/local_asset.entity.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';
import 'package:immich_mobile/providers/server_info.provider.dart';
import 'package:immich_mobile/providers/user.provider.dart';
import 'package:logging/logging.dart';
// ignore: import_rule_openapi
import 'package:openapi/api.dart' hide AssetVisibility;
Future<void> syncCloudIds(ProviderContainer ref) async {
if (!CurrentPlatform.isIOS) {
return;
}
final logger = Logger('migrateCloudIds');
final db = ref.read(driftProvider);
// Populate cloud IDs for local assets that don't have one yet
await _populateCloudIds(db);
final serverInfo = await ref.read(serverInfoProvider.notifier).getServerInfo();
final canUpdateMetadata = serverInfo.serverVersion.isAtLeast(major: 2, minor: 4);
if (!canUpdateMetadata) {
logger.fine('Server version does not support asset metadata updates. Skipping cloudId migration.');
return;
}
final canBulkUpdateMetadata = serverInfo.serverVersion.isAtLeast(major: 2, minor: 5);
// Wait for remote sync to complete, so we have up-to-date asset metadata entries
try {
await ref.read(syncStreamServiceProvider).sync();
} catch (e, s) {
logger.fine('Failed to complete remote sync before cloudId migration.', e, s);
return;
}
// Fetch the mapping for backed up assets that have a cloud ID locally but do not have a cloud ID on the server
final currentUser = ref.read(currentUserProvider);
if (currentUser == null) {
logger.warning('Current user is null. Aborting cloudId migration.');
return;
}
final assetApi = ref.read(apiServiceProvider).assetsApi;
// Process cloud IDs in paginated batches
await _processCloudIdMappingsInBatches(db, currentUser.id, assetApi, canBulkUpdateMetadata, logger);
}
Future<void> _processCloudIdMappingsInBatches(
Drift drift,
String userId,
AssetsApi assetsApi,
bool canBulkUpdate,
Logger logger,
) async {
const pageSize = 20000;
String? lastLocalId;
final seenRemoteAssetIds = <String>{};
while (true) {
final mappings = await _fetchCloudIdMappings(drift, userId, pageSize, lastLocalId);
if (mappings.isEmpty) {
break;
}
final items = <AssetMetadataBulkUpsertItemDto>[];
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<void> _sequentialUpdateCloudIds(AssetsApi assetsApi, List<AssetMetadataBulkUpsertItemDto> items) async {
for (final item in items) {
final upsertItem = AssetMetadataUpsertItemDto(key: item.key, value: item.value);
try {
await assetsApi.updateAssetMetadata(item.assetId, AssetMetadataUpsertDto(items: [upsertItem]));
} catch (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 {
final query = drift.localAssetEntity.selectOnly()
..addColumns([drift.localAssetEntity.id])
..where(drift.localAssetEntity.iCloudId.isNull());
final ids = await query.map((row) => row.read(drift.localAssetEntity.id)!).get();
final cloudMapping = <String, String>{};
final cloudIds = await NativeSyncApi().getCloudIdForAssetIds(ids);
for (int i = 0; i < cloudIds.length; i++) {
final cloudIdResult = cloudIds[i];
if (cloudIdResult.cloudId != null) {
cloudMapping[cloudIdResult.assetId] = cloudIdResult.cloudId!;
} else {
Logger('migrateCloudIds').fine(
"Cannot fetch cloudId for asset with id: ${cloudIdResult.assetId}. Error: ${cloudIdResult.error ?? "unknown"}",
);
}
}
await DriftLocalAlbumRepository(drift).updateCloudMapping(cloudMapping);
}
typedef _CloudIdMapping = ({String remoteAssetId, LocalAsset localAsset});
Future<List<_CloudIdMapping>> _fetchCloudIdMappings(Drift drift, String userId, int limit, String? lastLocalId) async {
final query =
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)!,
localAsset: row.readTable(drift.localAssetEntity).toDto(),
);
}).get();
}

View file

@ -0,0 +1,14 @@
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/store.model.dart';
import 'package:immich_mobile/domain/services/sync_linked_album.service.dart';
import 'package:immich_mobile/entities/store.entity.dart';
import 'package:logging/logging.dart';
Future<void> syncLinkedAlbumsIsolated(ProviderContainer ref) {
final user = Store.tryGet(StoreKey.currentUser);
if (user == null) {
Logger("SyncLinkedAlbum").warning("No user logged in, skipping linked album sync");
return Future.value();
}
return ref.read(syncLinkedAlbumServiceProvider).syncLinkedAlbums(user.id);
}