mirror of
https://github.com/immich-app/immich.git
synced 2026-05-22 23:52:32 -04:00
2d9183ab44
Detect an iOS edit, upload the unedited original, and stack the edited version on top of it. Reverting in Photos flips the stack cover back to the original and keeps the edits. Adds an optional stackParentId field to the asset upload on the server.
299 lines
8.7 KiB
Dart
299 lines
8.7 KiB
Dart
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
|
|
}
|
|
|
|
// Stop the local-sync and hash slots too. The revert reconcile runs in the hash
|
|
// task and shouldn't outlive the session.
|
|
await cancelLocal();
|
|
}
|
|
|
|
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;
|
|
});
|
|
}
|
|
|
|
/// Runs a remote sync guaranteed to observe changes up to now. [syncRemote]
|
|
/// joins an in-flight sync whose snapshot can pre-date a just-received change
|
|
/// (e.g. a stack update) and miss it, so wait for any in-flight sync to finish
|
|
/// first, then run a fresh one.
|
|
Future<void> runFreshRemoteSync() async {
|
|
final inflight = _syncTask;
|
|
if (inflight != null) {
|
|
try {
|
|
await inflight.future;
|
|
} catch (_) {
|
|
// The in-flight sync's outcome doesn't matter; we only need a fresh one after it.
|
|
}
|
|
}
|
|
await syncRemote();
|
|
}
|
|
|
|
Future<void> syncWebsocketBatchV1(List<dynamic> batchData) {
|
|
if (_syncWebsocketTask != null) {
|
|
return _syncWebsocketTask!.future;
|
|
}
|
|
_syncWebsocketTask = _handleWsAssetUploadReadyV1Batch(batchData);
|
|
return _syncWebsocketTask!.whenComplete(() {
|
|
_syncWebsocketTask = null;
|
|
});
|
|
}
|
|
|
|
Future<void> syncWebsocketBatchV2(List<dynamic> batchData) {
|
|
if (_syncWebsocketTask != null) {
|
|
return _syncWebsocketTask!.future;
|
|
}
|
|
_syncWebsocketTask = _handleWsAssetUploadReadyV2Batch(batchData);
|
|
return _syncWebsocketTask!.whenComplete(() {
|
|
_syncWebsocketTask = null;
|
|
});
|
|
}
|
|
|
|
Future<void> syncWebsocketEditV1(dynamic data) {
|
|
if (_syncWebsocketTask != null) {
|
|
return _syncWebsocketTask!.future;
|
|
}
|
|
_syncWebsocketTask = _handleWsAssetEditReadyV1(data);
|
|
return _syncWebsocketTask!.whenComplete(() {
|
|
_syncWebsocketTask = null;
|
|
});
|
|
}
|
|
|
|
Future<void> syncWebsocketEditV2(dynamic data) {
|
|
if (_syncWebsocketTask != null) {
|
|
return _syncWebsocketTask!.future;
|
|
}
|
|
_syncWebsocketTask = _handleWsAssetEditReadyV2(data);
|
|
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> _handleWsAssetUploadReadyV2Batch(List<dynamic> batchData) => runInIsolateGentle(
|
|
computation: (ref) => ref.read(syncStreamServiceProvider).handleWsAssetUploadReadyV2Batch(batchData),
|
|
debugLabel: 'websocket-batch',
|
|
);
|
|
|
|
Cancelable<void> _handleWsAssetEditReadyV1(dynamic data) => runInIsolateGentle(
|
|
computation: (ref) => ref.read(syncStreamServiceProvider).handleWsAssetEditReadyV1(data),
|
|
debugLabel: 'websocket-edit',
|
|
);
|
|
|
|
Cancelable<void> _handleWsAssetEditReadyV2(dynamic data) => runInIsolateGentle(
|
|
computation: (ref) => ref.read(syncStreamServiceProvider).handleWsAssetEditReadyV2(data),
|
|
debugLabel: 'websocket-edit',
|
|
);
|