From 9fa8de7baa612fde6ff9d550bb4100ee8eb3e844 Mon Sep 17 00:00:00 2001 From: shenlong <139912620+shenlong-tanwen@users.noreply.github.com> Date: Thu, 15 Jan 2026 00:04:11 +0530 Subject: [PATCH] feat: add cloud id during native sync (#20418) * use adjustment time in iOS for hash reset # Conflicts: # mobile/lib/infrastructure/repositories/local_album.repository.dart # mobile/lib/presentation/pages/drift_asset_troubleshoot.page.dart * migration * feat: sync cloudId and eTag on sync * fixes fixes * more fixes * re-sync updated eTags * add server version check & auto sync cloud ids on compatible servers * fix test * remove button from sync status page * chore: modify for testing * more changes * chore: add commas in toString * use cached provider in splash screen * read upload service provider to prevent reset * log errors from fetching cloud id mapping * WIP: migrate cloud id - debug log * ignore locked asset update * bulk update metadata * change log text --------- Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com> Co-authored-by: Alex --- i18n/en.json | 1 + .../app/alextran/immich/sync/Messages.g.kt | 61 +++++ .../alextran/immich/sync/MessagesImplBase.kt | 10 +- .../drift_schemas/main/drift_schema_v16.json | Bin 0 -> 43426 bytes mobile/ios/Runner/Sync/Messages.g.swift | 56 ++++ mobile/ios/Runner/Sync/MessagesImpl.swift | 148 +++++----- mobile/lib/constants/constants.dart | 2 + .../models/asset/asset_metadata.model.dart | 62 +++++ .../models/asset/local_asset.model.dart | 17 +- mobile/lib/domain/services/hash.service.dart | 12 + .../domain/services/local_sync.service.dart | 54 +++- .../domain/services/sync_stream.service.dart | 4 + mobile/lib/domain/utils/background_sync.dart | 35 ++- .../lib/domain/utils/migrate_cloud_ids.dart | 166 ++++++++++++ .../entities/local_asset.entity.dart | 4 + .../entities/local_asset.entity.drift.dart | Bin 40771 -> 43295 bytes .../entities/merged_asset.drift | 12 +- .../entities/merged_asset.drift.dart | Bin 7835 -> 8420 bytes .../remote_asset_cloud_id.entity.dart | 20 ++ .../remote_asset_cloud_id.entity.drift.dart | Bin 0 -> 28707 bytes .../repositories/db.repository.dart | 10 +- .../repositories/db.repository.drift.dart | Bin 13383 -> 14174 bytes .../repositories/db.repository.steps.dart | Bin 187936 -> 203344 bytes .../repositories/local_album.repository.dart | 19 ++ .../repositories/local_asset.repository.dart | 38 +++ .../repositories/sync_api.repository.dart | 3 + .../repositories/sync_stream.repository.dart | 47 ++++ .../repositories/timeline.repository.dart | 4 + .../server_info/server_version.model.dart | 4 + .../lib/pages/common/splash_screen.page.dart | 4 +- mobile/lib/platform/native_sync_api.g.dart | Bin 20806 -> 23157 bytes .../pages/drift_asset_troubleshoot.page.dart | 1 + .../providers/app_life_cycle.provider.dart | 1 + mobile/lib/providers/auth.provider.dart | 10 +- .../providers/background_sync.provider.dart | 3 + .../infrastructure/sync.provider.dart | 1 + .../lib/providers/server_info.provider.dart | 3 +- .../lib/providers/sync_status.provider.dart | 21 +- mobile/lib/services/upload.service.dart | 32 +++ .../sync_status_and_actions.dart | 2 +- mobile/pigeon/native_sync_api.dart | 11 + .../domain/services/hash_service_test.dart | 1 + .../services/local_sync_service_test.dart | 4 + mobile/test/drift/main/generated/schema.dart | Bin 1827 -> 1921 bytes .../test/drift/main/generated/schema_v16.dart | Bin 0 -> 270443 bytes mobile/test/services/upload.service_test.dart | 255 +++++++++++++++++- 46 files changed, 1048 insertions(+), 90 deletions(-) create mode 100644 mobile/drift_schemas/main/drift_schema_v16.json create mode 100644 mobile/lib/domain/models/asset/asset_metadata.model.dart create mode 100644 mobile/lib/domain/utils/migrate_cloud_ids.dart create mode 100644 mobile/lib/infrastructure/entities/remote_asset_cloud_id.entity.dart create mode 100644 mobile/lib/infrastructure/entities/remote_asset_cloud_id.entity.drift.dart create mode 100644 mobile/test/drift/main/generated/schema_v16.dart diff --git a/i18n/en.json b/i18n/en.json index c66d1d344..7adbba3c8 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -2124,6 +2124,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_status": "Sync Status", diff --git a/mobile/android/app/src/main/kotlin/app/alextran/immich/sync/Messages.g.kt b/mobile/android/app/src/main/kotlin/app/alextran/immich/sync/Messages.g.kt index d3282f4df..b59f47a1d 100644 --- a/mobile/android/app/src/main/kotlin/app/alextran/immich/sync/Messages.g.kt +++ b/mobile/android/app/src/main/kotlin/app/alextran/immich/sync/Messages.g.kt @@ -252,6 +252,40 @@ data class HashResult ( override fun hashCode(): Int = toList().hashCode() } + +/** Generated class from Pigeon that represents data sent in messages. */ +data class CloudIdResult ( + val assetId: String, + val error: String? = null, + val cloudId: String? = null +) + { + companion object { + fun fromList(pigeonVar_list: List): CloudIdResult { + val assetId = pigeonVar_list[0] as String + val error = pigeonVar_list[1] as String? + val cloudId = pigeonVar_list[2] as String? + return CloudIdResult(assetId, error, cloudId) + } + } + fun toList(): List { + return listOf( + assetId, + error, + cloudId, + ) + } + override fun equals(other: Any?): Boolean { + if (other !is CloudIdResult) { + return false + } + if (this === other) { + return true + } + return MessagesPigeonUtils.deepEquals(toList(), other.toList()) } + + override fun hashCode(): Int = toList().hashCode() +} private open class MessagesPigeonCodec : StandardMessageCodec() { override fun readValueOfType(type: Byte, buffer: ByteBuffer): Any? { return when (type) { @@ -275,6 +309,11 @@ private open class MessagesPigeonCodec : StandardMessageCodec() { HashResult.fromList(it) } } + 133.toByte() -> { + return (readValue(buffer) as? List)?.let { + CloudIdResult.fromList(it) + } + } else -> super.readValueOfType(type, buffer) } } @@ -296,6 +335,10 @@ private open class MessagesPigeonCodec : StandardMessageCodec() { stream.write(132) writeValue(stream, value.toList()) } + is CloudIdResult -> { + stream.write(133) + writeValue(stream, value.toList()) + } else -> super.writeValue(stream, value) } } @@ -315,6 +358,7 @@ interface NativeSyncApi { fun hashAssets(assetIds: List, allowNetworkAccess: Boolean, callback: (Result>) -> Unit) fun cancelHashing() fun getTrashedAssets(): Map> + fun getCloudIdForAssetIds(assetIds: List): List companion object { /** The codec used by NativeSyncApi. */ @@ -508,6 +552,23 @@ interface NativeSyncApi { channel.setMessageHandler(null) } } + run { + val channel = BasicMessageChannel(binaryMessenger, "dev.flutter.pigeon.immich_mobile.NativeSyncApi.getCloudIdForAssetIds$separatedMessageChannelSuffix", codec, taskQueue) + if (api != null) { + channel.setMessageHandler { message, reply -> + val args = message as List + val assetIdsArg = args[0] as List + val wrapped: List = try { + listOf(api.getCloudIdForAssetIds(assetIdsArg)) + } catch (exception: Throwable) { + MessagesPigeonUtils.wrapError(exception) + } + reply.reply(wrapped) + } + } else { + channel.setMessageHandler(null) + } + } } } } diff --git a/mobile/android/app/src/main/kotlin/app/alextran/immich/sync/MessagesImplBase.kt b/mobile/android/app/src/main/kotlin/app/alextran/immich/sync/MessagesImplBase.kt index b374ef50f..1b04fa50e 100644 --- a/mobile/android/app/src/main/kotlin/app/alextran/immich/sync/MessagesImplBase.kt +++ b/mobile/android/app/src/main/kotlin/app/alextran/immich/sync/MessagesImplBase.kt @@ -14,6 +14,7 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.async import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.currentCoroutineContext import kotlinx.coroutines.ensureActive import kotlinx.coroutines.launch import kotlinx.coroutines.sync.Semaphore @@ -21,7 +22,6 @@ import kotlinx.coroutines.sync.withPermit import java.io.File import java.security.MessageDigest import kotlin.coroutines.cancellation.CancellationException -import kotlin.coroutines.coroutineContext sealed class AssetResult { data class ValidAsset(val asset: PlatformAsset, val albumId: String) : AssetResult() @@ -298,7 +298,7 @@ open class NativeSyncApiImplBase(context: Context) : ImmichPlugin() { var bytesRead: Int val buffer = ByteArray(HASH_BUFFER_SIZE) while (inputStream.read(buffer).also { bytesRead = it } > 0) { - coroutineContext.ensureActive() + currentCoroutineContext().ensureActive() digest.update(buffer, 0, bytesRead) } } ?: return HashResult(assetId, "Cannot open input stream for asset", null) @@ -316,4 +316,10 @@ open class NativeSyncApiImplBase(context: Context) : ImmichPlugin() { hashTask?.cancel() hashTask = null } + + // This method is only implemented on iOS; on Android, we do not have a concept of cloud IDs + @Suppress("unused", "UNUSED_PARAMETER") + fun getCloudIdForAssetIds(assetIds: List): List { + return emptyList() + } } diff --git a/mobile/drift_schemas/main/drift_schema_v16.json b/mobile/drift_schemas/main/drift_schema_v16.json new file mode 100644 index 0000000000000000000000000000000000000000..8e716ada0d0e7750fa79c606a7c50fd2db8eff3a GIT binary patch literal 43426 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 SjrYnH*tSu@gWERx@$vt6cN>-f literal 0 HcmV?d00001 diff --git a/mobile/ios/Runner/Sync/Messages.g.swift b/mobile/ios/Runner/Sync/Messages.g.swift index c1cc98014..e18af39e0 100644 --- a/mobile/ios/Runner/Sync/Messages.g.swift +++ b/mobile/ios/Runner/Sync/Messages.g.swift @@ -312,6 +312,39 @@ struct HashResult: Hashable { } } +/// Generated class from Pigeon that represents data sent in messages. +struct CloudIdResult: Hashable { + var assetId: String + var error: String? = nil + var cloudId: String? = nil + + + // swift-format-ignore: AlwaysUseLowerCamelCase + static func fromList(_ pigeonVar_list: [Any?]) -> CloudIdResult? { + let assetId = pigeonVar_list[0] as! String + let error: String? = nilOrValue(pigeonVar_list[1]) + let cloudId: String? = nilOrValue(pigeonVar_list[2]) + + return CloudIdResult( + assetId: assetId, + error: error, + cloudId: cloudId + ) + } + func toList() -> [Any?] { + return [ + assetId, + error, + cloudId, + ] + } + static func == (lhs: CloudIdResult, rhs: CloudIdResult) -> Bool { + return deepEqualsMessages(lhs.toList(), rhs.toList()) } + func hash(into hasher: inout Hasher) { + deepHashMessages(value: toList(), hasher: &hasher) + } +} + private class MessagesPigeonCodecReader: FlutterStandardReader { override func readValue(ofType type: UInt8) -> Any? { switch type { @@ -323,6 +356,8 @@ private class MessagesPigeonCodecReader: FlutterStandardReader { return SyncDelta.fromList(self.readValue() as! [Any?]) case 132: return HashResult.fromList(self.readValue() as! [Any?]) + case 133: + return CloudIdResult.fromList(self.readValue() as! [Any?]) default: return super.readValue(ofType: type) } @@ -343,6 +378,9 @@ private class MessagesPigeonCodecWriter: FlutterStandardWriter { } else if let value = value as? HashResult { super.writeByte(132) super.writeValue(value.toList()) + } else if let value = value as? CloudIdResult { + super.writeByte(133) + super.writeValue(value.toList()) } else { super.writeValue(value) } @@ -377,6 +415,7 @@ protocol NativeSyncApi { func hashAssets(assetIds: [String], allowNetworkAccess: Bool, completion: @escaping (Result<[HashResult], Error>) -> Void) func cancelHashing() throws func getTrashedAssets() throws -> [String: [PlatformAsset]] + func getCloudIdForAssetIds(assetIds: [String]) throws -> [CloudIdResult] } /// Generated setup class from Pigeon to handle messages through the `binaryMessenger`. @@ -560,5 +599,22 @@ class NativeSyncApiSetup { } else { getTrashedAssetsChannel.setMessageHandler(nil) } + let getCloudIdForAssetIdsChannel = taskQueue == nil + ? FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.NativeSyncApi.getCloudIdForAssetIds\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec) + : FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.NativeSyncApi.getCloudIdForAssetIds\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec, taskQueue: taskQueue) + if let api = api { + getCloudIdForAssetIdsChannel.setMessageHandler { message, reply in + let args = message as! [Any?] + let assetIdsArg = args[0] as! [String] + do { + let result = try api.getCloudIdForAssetIds(assetIds: assetIdsArg) + reply(wrapResult(result)) + } catch { + reply(wrapError(error)) + } + } + } else { + getCloudIdForAssetIdsChannel.setMessageHandler(nil) + } } } diff --git a/mobile/ios/Runner/Sync/MessagesImpl.swift b/mobile/ios/Runner/Sync/MessagesImpl.swift index 03493f57c..0650b4787 100644 --- a/mobile/ios/Runner/Sync/MessagesImpl.swift +++ b/mobile/ios/Runner/Sync/MessagesImpl.swift @@ -19,31 +19,31 @@ struct AssetWrapper: Hashable, Equatable { class NativeSyncApiImpl: ImmichPlugin, NativeSyncApi, FlutterPlugin { static let name = "NativeSyncApi" - + static func register(with registrar: any FlutterPluginRegistrar) { let instance = NativeSyncApiImpl() NativeSyncApiSetup.setUp(binaryMessenger: registrar.messenger(), api: instance) registrar.publish(instance) } - + func detachFromEngine(for registrar: any FlutterPluginRegistrar) { super.detachFromEngine() } - + private let defaults: UserDefaults private let changeTokenKey = "immich:changeToken" private let albumTypes: [PHAssetCollectionType] = [.album, .smartAlbum] private let recoveredAlbumSubType = 1000000219 - + private var hashTask: Task? private static let hashCancelledCode = "HASH_CANCELLED" private static let hashCancelled = Result<[HashResult], Error>.failure(PigeonError(code: hashCancelledCode, message: "Hashing cancelled", details: nil)) - - + + init(with defaults: UserDefaults = .standard) { self.defaults = defaults } - + @available(iOS 16, *) private func getChangeToken() -> PHPersistentChangeToken? { guard let data = defaults.data(forKey: changeTokenKey) else { @@ -51,7 +51,7 @@ class NativeSyncApiImpl: ImmichPlugin, NativeSyncApi, FlutterPlugin { } return try? NSKeyedUnarchiver.unarchivedObject(ofClass: PHPersistentChangeToken.self, from: data) } - + @available(iOS 16, *) private func saveChangeToken(token: PHPersistentChangeToken) -> Void { guard let data = try? NSKeyedArchiver.archivedData(withRootObject: token, requiringSecureCoding: true) else { @@ -59,18 +59,18 @@ class NativeSyncApiImpl: ImmichPlugin, NativeSyncApi, FlutterPlugin { } defaults.set(data, forKey: changeTokenKey) } - + func clearSyncCheckpoint() -> Void { defaults.removeObject(forKey: changeTokenKey) } - + func checkpointSync() { guard #available(iOS 16, *) else { return } saveChangeToken(token: PHPhotoLibrary.shared().currentChangeToken) } - + func shouldFullSync() -> Bool { guard #available(iOS 16, *), PHPhotoLibrary.authorizationStatus(for: .readWrite) == .authorized, @@ -78,36 +78,36 @@ class NativeSyncApiImpl: ImmichPlugin, NativeSyncApi, FlutterPlugin { // When we do not have access to photo library, older iOS version or No token available, fallback to full sync return true } - + guard let _ = try? PHPhotoLibrary.shared().fetchPersistentChanges(since: storedToken) else { // Cannot fetch persistent changes return true } - + return false } - + func getAlbums() throws -> [PlatformAlbum] { var albums: [PlatformAlbum] = [] - + albumTypes.forEach { type in let collections = PHAssetCollection.fetchAssetCollections(with: type, subtype: .any, options: nil) for i in 0.. SyncDelta { guard #available(iOS 16, *) else { throw PigeonError(code: "UNSUPPORTED_OS", message: "This feature requires iOS 16 or later.", details: nil) } - + guard PHPhotoLibrary.authorizationStatus(for: .readWrite) == .authorized else { throw PigeonError(code: "NO_AUTH", message: "No photo library access", details: nil) } - + guard let storedToken = getChangeToken() else { // No token exists, definitely need a full sync print("MediaManager::getMediaChanges: No token found") throw PigeonError(code: "NO_TOKEN", message: "No stored change token", details: nil) } - + let currentToken = PHPhotoLibrary.shared().currentChangeToken if storedToken == currentToken { return SyncDelta(hasChanges: false, updates: [], deletes: [], assetAlbums: [:]) } - + do { let changes = try PHPhotoLibrary.shared().fetchPersistentChanges(since: storedToken) - + var updatedAssets: Set = [] var deletedAssets: Set = [] - + for change in changes { guard let details = try? change.changeDetails(for: PHObjectType.asset) else { continue } - + let updated = details.updatedLocalIdentifiers.union(details.insertedLocalIdentifiers) deletedAssets.formUnion(details.deletedLocalIdentifiers) - + if (updated.isEmpty) { continue } - + let options = PHFetchOptions() options.includeHiddenAssets = false let result = PHAsset.fetchAssets(withLocalIdentifiers: Array(updated), options: options) for i in 0..) -> [String: [String]] { guard !assets.isEmpty else { return [:] } - + var albumAssets: [String: [String]] = [:] - + for type in albumTypes { let collections = PHAssetCollection.fetchAssetCollections(with: type, subtype: .any, options: nil) collections.enumerateObjects { (album, _, _) in @@ -211,13 +211,13 @@ class NativeSyncApiImpl: ImmichPlugin, NativeSyncApi, FlutterPlugin { } return albumAssets } - + func getAssetIdsForAlbum(albumId: String) throws -> [String] { let collections = PHAssetCollection.fetchAssetCollections(withLocalIdentifiers: [albumId], options: nil) guard let album = collections.firstObject else { return [] } - + var ids: [String] = [] let options = PHFetchOptions() options.includeHiddenAssets = false @@ -227,13 +227,13 @@ class NativeSyncApiImpl: ImmichPlugin, NativeSyncApi, FlutterPlugin { } return ids } - + func getAssetsCountSince(albumId: String, timestamp: Int64) throws -> Int64 { let collections = PHAssetCollection.fetchAssetCollections(withLocalIdentifiers: [albumId], options: nil) guard let album = collections.firstObject else { return 0 } - + let date = NSDate(timeIntervalSince1970: TimeInterval(timestamp)) let options = PHFetchOptions() options.predicate = NSPredicate(format: "creationDate > %@ OR modificationDate > %@", date, date) @@ -241,32 +241,32 @@ class NativeSyncApiImpl: ImmichPlugin, NativeSyncApi, FlutterPlugin { let assets = getAssetsFromAlbum(in: album, options: options) return Int64(assets.count) } - + func getAssetsForAlbum(albumId: String, updatedTimeCond: Int64?) throws -> [PlatformAsset] { let collections = PHAssetCollection.fetchAssetCollections(withLocalIdentifiers: [albumId], options: nil) guard let album = collections.firstObject else { return [] } - + let options = PHFetchOptions() options.includeHiddenAssets = false if(updatedTimeCond != nil) { let date = NSDate(timeIntervalSince1970: TimeInterval(updatedTimeCond!)) options.predicate = NSPredicate(format: "creationDate > %@ OR modificationDate > %@", date, date) } - + let result = getAssetsFromAlbum(in: album, options: options) if(result.count == 0) { return [] } - + var assets: [PlatformAsset] = [] result.enumerateObjects { (asset, _, _) in assets.append(asset.toPlatformAsset()) } return assets } - + func hashAssets(assetIds: [String], allowNetworkAccess: Bool, completion: @escaping (Result<[HashResult], Error>) -> Void) { if let prevTask = hashTask { prevTask.cancel() @@ -284,11 +284,11 @@ class NativeSyncApiImpl: ImmichPlugin, NativeSyncApi, FlutterPlugin { missingAssetIds.remove(asset.localIdentifier) assets.append(asset) } - + if Task.isCancelled { return self?.completeWhenActive(for: completion, with: Self.hashCancelled) } - + await withTaskGroup(of: HashResult?.self) { taskGroup in var results = [HashResult]() results.reserveCapacity(assets.count) @@ -301,28 +301,28 @@ class NativeSyncApiImpl: ImmichPlugin, NativeSyncApi, FlutterPlugin { return await self.hashAsset(asset, allowNetworkAccess: allowNetworkAccess) } } - + for await result in taskGroup { guard let result = result else { return self?.completeWhenActive(for: completion, with: Self.hashCancelled) } results.append(result) } - + for missing in missingAssetIds { results.append(HashResult(assetId: missing, error: "Asset not found in library", hash: nil)) } - + return self?.completeWhenActive(for: completion, with: .success(results)) } } } - + func cancelHashing() { hashTask?.cancel() hashTask = nil } - + private func hashAsset(_ asset: PHAsset, allowNetworkAccess: Bool) async -> HashResult? { class RequestRef { var id: PHAssetResourceDataRequestID? @@ -332,21 +332,21 @@ class NativeSyncApiImpl: ImmichPlugin, NativeSyncApi, FlutterPlugin { if Task.isCancelled { return nil } - + guard let resource = asset.getResource() else { return HashResult(assetId: asset.localIdentifier, error: "Cannot get asset resource", hash: nil) } - + if Task.isCancelled { return nil } - + let options = PHAssetResourceRequestOptions() options.isNetworkAccessAllowed = allowNetworkAccess - + return await withCheckedContinuation { continuation in var hasher = Insecure.SHA1() - + requestRef.id = PHAssetResourceManager.default().requestData( for: resource, options: options, @@ -377,11 +377,11 @@ class NativeSyncApiImpl: ImmichPlugin, NativeSyncApi, FlutterPlugin { PHAssetResourceManager.default().cancelDataRequest(requestId) }) } - + func getTrashedAssets() throws -> [String: [PlatformAsset]] { throw PigeonError(code: "UNSUPPORTED_OS", message: "This feature not supported on iOS.", details: nil) } - + private func getAssetsFromAlbum(in album: PHAssetCollection, options: PHFetchOptions) -> PHFetchResult { // Ensure to actually getting all assets for the Recents album if (album.assetCollectionSubtype == .smartAlbumUserLibrary) { @@ -390,4 +390,28 @@ class NativeSyncApiImpl: ImmichPlugin, NativeSyncApi, FlutterPlugin { return PHAsset.fetchAssets(in: album, options: options) } } + + func getCloudIdForAssetIds(assetIds: [String]) throws -> [CloudIdResult] { + guard #available(iOS 16, *) else { + return assetIds.map { CloudIdResult(assetId: $0) } + } + + var mappings: [CloudIdResult] = [] + let result = PHPhotoLibrary.shared().cloudIdentifierMappings(forLocalIdentifiers: assetIds) + for (key, value) in result { + switch value { + case .success(let cloudIdentifier): + let cloudId = cloudIdentifier.stringValue + // Ignores invalid cloud ids of the format "GUID:ID:". Valid Ids are of the form "GUID:ID:HASH" + if !cloudId.hasSuffix(":") { + mappings.append(CloudIdResult(assetId: key, cloudId: cloudId)) + } else { + mappings.append(CloudIdResult(assetId: key, error: "Incomplete Cloud Id: \(cloudId)")) + } + case .failure(let error): + mappings.append(CloudIdResult(assetId: key, error: "Error getting Cloud Id: \(error.localizedDescription)")) + } + } + return mappings; + } } diff --git a/mobile/lib/constants/constants.dart b/mobile/lib/constants/constants.dart index cc408548d..9d28941b8 100644 --- a/mobile/lib/constants/constants.dart +++ b/mobile/lib/constants/constants.dart @@ -4,6 +4,8 @@ const int noDbId = -9223372036854775808; // from Isar const double downloadCompleted = -1; const double downloadFailed = -2; +const String kMobileMetadataKey = "mobile-app"; + // Number of log entries to retain on app start const int kLogTruncateLimit = 2000; diff --git a/mobile/lib/domain/models/asset/asset_metadata.model.dart b/mobile/lib/domain/models/asset/asset_metadata.model.dart new file mode 100644 index 000000000..fc29da3db --- /dev/null +++ b/mobile/lib/domain/models/asset/asset_metadata.model.dart @@ -0,0 +1,62 @@ +enum RemoteAssetMetadataKey { + mobileApp("mobile-app"); + + final String key; + + const RemoteAssetMetadataKey(this.key); +} + +abstract class RemoteAssetMetadataValue { + const RemoteAssetMetadataValue(); + + Map toJson(); +} + +class RemoteAssetMetadataItem { + final RemoteAssetMetadataKey key; + final RemoteAssetMetadataValue value; + + const RemoteAssetMetadataItem({required this.key, required this.value}); + + Map toJson() { + return {'key': key.key, 'value': value}; + } +} + +class RemoteAssetMobileAppMetadata extends RemoteAssetMetadataValue { + final String? cloudId; + final String? createdAt; + final String? adjustmentTime; + final String? latitude; + final String? longitude; + + const RemoteAssetMobileAppMetadata({ + this.cloudId, + this.createdAt, + this.adjustmentTime, + this.latitude, + this.longitude, + }); + + @override + Map toJson() { + final map = {}; + if (cloudId != null) { + map["iCloudId"] = cloudId; + } + if (createdAt != null) { + map["createdAt"] = createdAt; + } + if (adjustmentTime != null) { + map["adjustmentTime"] = adjustmentTime; + } + if (latitude != null) { + map["latitude"] = latitude; + } + if (longitude != null) { + map["longitude"] = longitude; + } + + return map; + } +} diff --git a/mobile/lib/domain/models/asset/local_asset.model.dart b/mobile/lib/domain/models/asset/local_asset.model.dart index ba64cc40b..b7ef635f2 100644 --- a/mobile/lib/domain/models/asset/local_asset.model.dart +++ b/mobile/lib/domain/models/asset/local_asset.model.dart @@ -3,6 +3,7 @@ part of 'base_asset.model.dart'; class LocalAsset extends BaseAsset { final String id; final String? remoteAssetId; + final String? cloudId; final int orientation; final DateTime? adjustmentTime; @@ -12,6 +13,7 @@ class LocalAsset extends BaseAsset { const LocalAsset({ required this.id, String? remoteId, + this.cloudId, required super.name, super.checksum, required super.type, @@ -53,12 +55,14 @@ class LocalAsset extends BaseAsset { width: ${width ?? ""}, height: ${height ?? ""}, durationInSeconds: ${durationInSeconds ?? ""}, - remoteId: ${remoteId ?? ""} + remoteId: ${remoteId ?? ""}, + cloudId: ${cloudId ?? ""}, + checksum: ${checksum ?? ""}, isFavorite: $isFavorite, - orientation: $orientation, - adjustmentTime: $adjustmentTime, - latitude: ${latitude ?? ""}, - longitude: ${longitude ?? ""}, + orientation: $orientation, + adjustmentTime: $adjustmentTime, + latitude: ${latitude ?? ""}, + longitude: ${longitude ?? ""}, }'''; } @@ -69,6 +73,7 @@ class LocalAsset extends BaseAsset { if (identical(this, other)) return true; return super == other && id == other.id && + cloudId == other.cloudId && orientation == other.orientation && adjustmentTime == other.adjustmentTime && latitude == other.latitude && @@ -88,6 +93,7 @@ class LocalAsset extends BaseAsset { LocalAsset copyWith({ String? id, String? remoteId, + String? cloudId, String? name, String? checksum, AssetType? type, @@ -105,6 +111,7 @@ class LocalAsset extends BaseAsset { return LocalAsset( id: id ?? this.id, remoteId: remoteId ?? this.remoteId, + cloudId: cloudId ?? this.cloudId, name: name ?? this.name, checksum: checksum ?? this.checksum, type: type ?? this.type, diff --git a/mobile/lib/domain/services/hash.service.dart b/mobile/lib/domain/services/hash.service.dart index 5e81643fc..ee2e96dd8 100644 --- a/mobile/lib/domain/services/hash.service.dart +++ b/mobile/lib/domain/services/hash.service.dart @@ -40,6 +40,9 @@ class HashService { _log.info("Starting hashing of assets"); 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(); + // Sorted by backupSelection followed by isCloud final localAlbums = await _localAlbumRepository.getBackupAlbums(); @@ -75,6 +78,15 @@ 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/services/local_sync.service.dart b/mobile/lib/domain/services/local_sync.service.dart index 1194331a6..8b324cf6c 100644 --- a/mobile/lib/domain/services/local_sync.service.dart +++ b/mobile/lib/domain/services/local_sync.service.dart @@ -8,6 +8,7 @@ import 'package:immich_mobile/domain/models/store.model.dart'; import 'package:immich_mobile/entities/store.entity.dart'; import 'package:immich_mobile/extensions/platform_extensions.dart'; import 'package:immich_mobile/infrastructure/repositories/local_album.repository.dart'; +import 'package:immich_mobile/infrastructure/repositories/local_asset.repository.dart'; import 'package:immich_mobile/infrastructure/repositories/storage.repository.dart'; import 'package:immich_mobile/infrastructure/repositories/trashed_local_asset.repository.dart'; import 'package:immich_mobile/platform/native_sync_api.g.dart'; @@ -18,6 +19,7 @@ import 'package:logging/logging.dart'; class LocalSyncService { final DriftLocalAlbumRepository _localAlbumRepository; + final DriftLocalAssetRepository _localAssetRepository; final NativeSyncApi _nativeSyncApi; final DriftTrashedLocalAssetRepository _trashedLocalAssetRepository; final LocalFilesManagerRepository _localFilesManager; @@ -26,11 +28,13 @@ class LocalSyncService { LocalSyncService({ required DriftLocalAlbumRepository localAlbumRepository, + required DriftLocalAssetRepository localAssetRepository, required DriftTrashedLocalAssetRepository trashedLocalAssetRepository, required LocalFilesManagerRepository localFilesManager, required StorageRepository storageRepository, required NativeSyncApi nativeSyncApi, }) : _localAlbumRepository = localAlbumRepository, + _localAssetRepository = localAssetRepository, _trashedLocalAssetRepository = trashedLocalAssetRepository, _localFilesManager = localFilesManager, _storageRepository = storageRepository, @@ -47,6 +51,12 @@ class LocalSyncService { _log.warning("syncTrashedAssets cannot proceed because MANAGE_MEDIA permission is missing"); } } + + if (CurrentPlatform.isIOS) { + final assets = await _localAssetRepository.getEmptyCloudIdAssets(); + await _mapIosCloudIds(assets); + } + if (full || await _nativeSyncApi.shouldFullSync()) { _log.fine("Full sync request from ${full ? "user" : "native"}"); return await fullSync(); @@ -63,8 +73,9 @@ class LocalSyncService { final deviceAlbums = await _nativeSyncApi.getAlbums(); await _localAlbumRepository.updateAll(deviceAlbums.toLocalAlbums()); + final newAssets = delta.updates.toLocalAssets(); await _localAlbumRepository.processDelta( - updates: delta.updates.toLocalAssets(), + updates: newAssets, deletes: delta.deletes, assetAlbums: delta.assetAlbums, ); @@ -92,6 +103,8 @@ class LocalSyncService { } await updateAlbum(dbAlbum, album); } + + await _mapIosCloudIds(newAssets); } await _nativeSyncApi.checkpointSync(); } catch (e, s) { @@ -130,9 +143,12 @@ class LocalSyncService { try { _log.fine("Adding device album ${album.name}"); - final assets = album.assetCount > 0 ? await _nativeSyncApi.getAssetsForAlbum(album.id) : []; + final assets = album.assetCount > 0 + ? await _nativeSyncApi.getAssetsForAlbum(album.id).then((a) => a.toLocalAssets()) + : []; - await _localAlbumRepository.upsert(album, toUpsert: assets.toLocalAssets()); + await _localAlbumRepository.upsert(album, toUpsert: assets); + await _mapIosCloudIds(assets); _log.fine("Successfully added device album ${album.name}"); } catch (e, s) { _log.warning("Error while adding device album", e, s); @@ -202,13 +218,16 @@ class LocalSyncService { return false; } - final newAssets = await _nativeSyncApi.getAssetsForAlbum(deviceAlbum.id, updatedTimeCond: updatedTime); + final newAssets = await _nativeSyncApi + .getAssetsForAlbum(deviceAlbum.id, updatedTimeCond: updatedTime) + .then((a) => a.toLocalAssets()); await _localAlbumRepository.upsert( deviceAlbum.copyWith(backupSelection: dbAlbum.backupSelection), - toUpsert: newAssets.toLocalAssets(), + toUpsert: newAssets, ); + await _mapIosCloudIds(newAssets); return true; } catch (e, s) { _log.warning("Error on fast syncing local album: ${dbAlbum.name}", e, s); @@ -240,6 +259,7 @@ class LocalSyncService { if (dbAlbum.assetCount == 0) { _log.fine("Device album ${deviceAlbum.name} is empty. Adding assets to DB."); await _localAlbumRepository.upsert(updatedDeviceAlbum, toUpsert: assetsInDevice); + await _mapIosCloudIds(assetsInDevice); return true; } @@ -277,6 +297,7 @@ class LocalSyncService { } await _localAlbumRepository.upsert(updatedDeviceAlbum, toUpsert: assetsToUpsert, toDelete: assetsToDelete); + await _mapIosCloudIds(assetsToUpsert); return true; } catch (e, s) { @@ -285,6 +306,29 @@ class LocalSyncService { return true; } + Future _mapIosCloudIds(List assets) async { + if (!CurrentPlatform.isIOS || assets.isEmpty) { + return; + } + + final assetIds = assets.map((a) => a.id).toList(); + final cloudMapping = {}; + final cloudIds = await _nativeSyncApi.getCloudIdForAssetIds(assetIds); + for (int i = 0; i < cloudIds.length; i++) { + final cloudIdResult = cloudIds[i]; + if (cloudIdResult.cloudId != null) { + cloudMapping[cloudIdResult.assetId] = cloudIdResult.cloudId!; + } else { + final asset = assets.firstWhereOrNull((a) => a.id == cloudIdResult.assetId); + _log.fine( + "Cannot fetch cloudId for asset with id: ${cloudIdResult.assetId}, name: ${asset?.name}, createdAt: ${asset?.createdAt}. Error: ${cloudIdResult.error ?? "unknown"}", + ); + } + } + + await _localAlbumRepository.updateCloudMapping(cloudMapping); + } + bool _assetsEqual(LocalAsset a, LocalAsset b) { if (CurrentPlatform.isAndroid) { return a.updatedAt.isAtSameMomentAs(b.updatedAt) && diff --git a/mobile/lib/domain/services/sync_stream.service.dart b/mobile/lib/domain/services/sync_stream.service.dart index 2ff0f18fc..e14321a78 100644 --- a/mobile/lib/domain/services/sync_stream.service.dart +++ b/mobile/lib/domain/services/sync_stream.service.dart @@ -118,6 +118,10 @@ class SyncStreamService { return _syncStreamRepository.deleteAssetsV1(data.cast()); case SyncEntityType.assetExifV1: return _syncStreamRepository.updateAssetsExifV1(data.cast()); + case SyncEntityType.assetMetadataV1: + return _syncStreamRepository.updateAssetsMetadataV1(data.cast()); + case SyncEntityType.assetMetadataDeleteV1: + return _syncStreamRepository.deleteAssetsMetadataV1(data.cast()); case SyncEntityType.partnerAssetV1: return _syncStreamRepository.updateAssetsV1(data.cast(), debugLabel: 'partner'); case SyncEntityType.partnerAssetBackfillV1: diff --git a/mobile/lib/domain/utils/background_sync.dart b/mobile/lib/domain/utils/background_sync.dart index 38e249b9f..637ae20cb 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' 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'; @@ -22,8 +23,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; @@ -38,6 +44,9 @@ class BackgroundSyncManager { this.onHashingStart, this.onHashingComplete, this.onHashingError, + this.onCloudIdSyncStart, + this.onCloudIdSyncComplete, + this.onCloudIdSyncError, }); Future cancel() async { @@ -55,6 +64,12 @@ class BackgroundSyncManager { _syncWebsocketTask?.cancel(); _syncWebsocketTask = null; + if (_cloudIdSyncTask != null) { + futures.add(_cloudIdSyncTask!.future); + } + _cloudIdSyncTask?.cancel(); + _cloudIdSyncTask = null; + if (_linkedAlbumSyncTask != null) { futures.add(_linkedAlbumSyncTask!.future); } @@ -121,7 +136,6 @@ class BackgroundSyncManager { }); } - // No need to cancel the task, as it can also be run when the user logs out Future hashAssets() { if (_hashTask != null) { return _hashTask!.future; @@ -192,6 +206,25 @@ class BackgroundSyncManager { _linkedAlbumSyncTask = null; }); } + + Future 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 _handleWsAssetUploadReadyV1Batch(List batchData) => runInIsolateGentle( 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 000000000..0ed184a02 --- /dev/null +++ b/mobile/lib/domain/utils/migrate_cloud_ids.dart @@ -0,0 +1,166 @@ +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 syncCloudIds(ProviderContainer ref) async { + if (!CurrentPlatform.isIOS) { + return; + } + + 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( + 'migrateCloudIds', + ).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('migrateCloudIds').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('migrateCloudIds').warning('Current user is null. Aborting cloudId migration.'); + return; + } + + final mappingsToUpdate = await _fetchCloudIdMappings(db, currentUser.id); + final assetApi = ref.read(apiServiceProvider).assetsApi; + + if (canBulkUpdateMetadata) { + await _bulkUpdateCloudIds(assetApi, mappingsToUpdate); + return; + } + await _sequentialUpdateCloudIds(assetApi, mappingsToUpdate); +} + +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 _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(), + ), + ), + ); + } + 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]) + ..where(drift.localAssetEntity.iCloudId.isNull()); + final ids = await query.map((row) => row.read(drift.localAssetEntity.id)!).get(); + final cloudMapping = {}; + 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> _fetchCloudIdMappings(Drift drift, String userId) 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)))), + ); + return query.map((row) { + return ( + remoteAssetId: row.read(drift.remoteAssetEntity.id)!, + localAsset: row.readTable(drift.localAssetEntity).toDto(), + ); + }).get(); +} diff --git a/mobile/lib/infrastructure/entities/local_asset.entity.dart b/mobile/lib/infrastructure/entities/local_asset.entity.dart index d2455b744..6591f922a 100644 --- a/mobile/lib/infrastructure/entities/local_asset.entity.dart +++ b/mobile/lib/infrastructure/entities/local_asset.entity.dart @@ -5,6 +5,7 @@ import 'package:immich_mobile/infrastructure/utils/asset.mixin.dart'; import 'package:immich_mobile/infrastructure/utils/drift_default.mixin.dart'; @TableIndex.sql('CREATE INDEX IF NOT EXISTS idx_local_asset_checksum ON local_asset_entity (checksum)') +@TableIndex.sql('CREATE INDEX IF NOT EXISTS idx_local_asset_cloud_id ON local_asset_entity (i_cloud_id)') class LocalAssetEntity extends Table with DriftDefaultsMixin, AssetEntityMixin { const LocalAssetEntity(); @@ -16,6 +17,8 @@ class LocalAssetEntity extends Table with DriftDefaultsMixin, AssetEntityMixin { IntColumn get orientation => integer().withDefault(const Constant(0))(); + TextColumn get iCloudId => text().nullable()(); + DateTimeColumn get adjustmentTime => dateTime().nullable()(); RealColumn get latitude => real().nullable()(); @@ -43,5 +46,6 @@ extension LocalAssetEntityDataDomainExtension on LocalAssetEntityData { adjustmentTime: adjustmentTime, latitude: latitude, longitude: longitude, + cloudId: iCloudId, ); } diff --git a/mobile/lib/infrastructure/entities/local_asset.entity.drift.dart b/mobile/lib/infrastructure/entities/local_asset.entity.drift.dart index 22219b1e6e5d74397e7f8b928f1e624b6c7e773f..088cfac97da8be974d73f1473a7d821d7395c4f0 100644 GIT binary patch delta 1361 zcmZ`(OH30{6iug;wo^*^m9|>QD?)}sTMJ4^ilqTD#>mISPof3J(l#)r>32%RK%@x> z2!)y(Vhm}Fi5sH}@x?8lxR#GQV`AJIUEsovZY;HLrX?_|H}}4C-nr-A_daY;&p%Ob zlTrxClnU-*AQbmq@xi8S0{@nNkrYI6CaQoRDz%(!^Qf9d%yvaRtk6yHS5XRSGy;2C zW%fU;QNvji4f7f^Z0MU0V>qlCRlo!7iA;C14BzxU1c^8F`*KLts*C@h4e45&A}7NF zE&6H*8Lc>t^mKt_h?v`8PiDY7O%bYsl;=QXg{{`9Bc}^-z!A}Ryr6ZA!cAZp#{6622$O$GA9%{AyQM7sY0%4q*3N$IM#dUI18KIr-1H> z;#ZdwMIvJ=(S~pLwkm0|kMlu!e?u)frO(fMELo;QHWG#2{u;fAV1`T<#0p8%H9KCs zIg{6H$*>dtbeF)^kWrftBg|%4Uo~LF&J~qi=c8P3)Fm8+)81u?pcfA%tj!PN>o9gwG4hv zmgB1X?~5*xj05>RyhqrW$5sa<=;IL03i`Q3uGC{?QX9hXBN%@1ezox_5wZw@I1DbwKP0`?LHx}n!zLEAi tdG<SbY-VOSegZX;`R06P#zLDr=zpjl-vR&t delta 323 zcmbP#iRth@rVYK!o7XctGfiI4oUyoujbpMsC*Njy{)wEE{e}4^%kyVX-Y2ZL`Gce& z$0RurKWTD;65He$McK&{lzBJHE4Oh1bxKa|tyY+PKwETj0vp%l1J&wKw$S7c#y*?x z>ds)C{NJ>7vz);*kUcn6Z~kEXnsM`g(>#&Q3%nayCO1tJnf$jydUITW5$9&7s20x6 z0Vy+BHZx_mFi!q8fp79WNw&>vvqV`o>*b5GZf-6CvMy9)0j-$DvAL%DE7Rspbx&9} zKW+|Z-kjQA#t1TR^WTn1Oq=&~7cgxO=&NPg{A)rT6VR;A&3aQ*m?m>g<=)&hZ4=XE p$t8S~ZSuJ`H_X(A7cLS}q&PJU@hd}azlEGMxfv!pa76-gvNFC9%NF(s?C zxFk0F5Pz{~DRQ4MHyd%p; delta 48 zcmV-00MGyAL7P3Wg$9$`6d{wj6WX)U2OI&jp%exJvo#B41(TK-60@EW3k text().references(RemoteAssetEntity, #id, onDelete: KeyAction.cascade)(); + + TextColumn get cloudId => text().unique().nullable()(); + + DateTimeColumn get createdAt => dateTime().nullable()(); + + DateTimeColumn get adjustmentTime => dateTime().nullable()(); + + RealColumn get latitude => real().nullable()(); + + RealColumn get longitude => real().nullable()(); + + @override + Set get primaryKey => {assetId}; +} 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 new file mode 100644 index 0000000000000000000000000000000000000000..f3c416ad3abc3e7e25d69eef63c23528f9ad0799 GIT binary patch literal 28707 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 15; + int get schemaVersion => 16; @override MigrationStrategy get migration => MigrationStrategy( @@ -193,6 +195,12 @@ class Drift extends $Drift implements IDatabaseRepository { from14To15: (m, v15) async { await m.addColumn(v15.trashedLocalAssetEntity, v15.trashedLocalAssetEntity.source); }, + from15To16: (m, v16) async { + // Add i_cloud_id to local and remote asset tables + await m.addColumn(v16.localAssetEntity, v16.localAssetEntity.iCloudId); + await m.createIndex(v16.idxLocalAssetCloudId); + await m.createTable(v16.remoteAssetCloudIdEntity); + }, ), ); diff --git a/mobile/lib/infrastructure/repositories/db.repository.drift.dart b/mobile/lib/infrastructure/repositories/db.repository.drift.dart index bd72da949ccc6cdab7b3c7ac09f86c0a93bff331..72dc1e804617ba33e58123cc7fc3f1593d46d094 100644 GIT binary patch delta 728 zcmaJ;PiqrF9A#2Bn}qIelQm5t)@;)aO-)#x-L%P45-lnsDx|j1W2ntqSJEsb+oGuW z1q6k6@*;W?#DlU>@uFVDn*~9<)P4akDR}eZ?52f6s&n|wo0;FAdGCF>wcCEP$wA^G zLbq$Vjn$U3(QMdFEF2rhQ|>J8bL05KnfS&{V>xW16rRL8wm%6^A;FvIg|oQF_AC5p zeCLm&&gHSiD}>?)Un06U9C^(xU$@pB*P8byoc9M@ZeDD=wtIJJzN5^^l=2&c#_YT_6&Vv3=%I7`ZCi@9i7Q|nfz>$HRX3Od`bHjnYI=@!vFQv5tujvtRnXI8u&K-(c|b1(F&EtZren+>L9Yg}pSPjQ sLlsTMCozfVdhrk*W;F`)D{jmMF%$IIG~&L5KaB!<1rG0xZ2x%sFMl8A^8f$< delta 426 zcmYjMu}cDR80A-;r;^^8rbC)_r)U}KFYZn)D8gEzAj+oJPIE}%JPN6yf54&M8t7CF z4#7cVLk(>~{Q*%+Ta-&n5dAEQmiNBzeZ22`-$UhQ>wJg96DQ-0n?>FxqtBK@lW~#p zhb@LH#v8l@iO=DJPhp>*Lf9_hnfIb#mk70X?QITCx9m`ok!5|mYSi)Rl5p&H;N2Y~ zDuxA(Xjm7b4EKa_lEI0PV0bSmBnw`Y879OyM{~(Y0e6}o6>+qO=5Q!dlE$?-K`7ot z8b~LXb)%$j&edwVId7DU=1jainYOZ?Q-Z2jwEDd+f{gE|Ywq|4@#qs+X?1+Jg7p$D zxiF+mIa$o#oRp5@K=Jmf$p~KE0j#7vxKu)aeJkZ>b6fEH;6lYLoAt42=g3HzYHZ1h zZq%w3bkZR#ssZOeiBbs-b%6cYQuE&^qn&cXqxl_K=3FzYhR%#ZENOv%N;gkK^K|34 MwACLU(J?Lon|p;?=d#+aXi?J0sH z5kzq+gSR9-3swtzkz$TTC3{j2iu5G*lw3Tjo2JH|Jbm-c&-2bZ3Y zR3CO5K;oGLa0sN=wZ<~A^(|Vd_N!N^R?uhQZz<@Ynkg7Gw#KKEabE4mQ-42{wH`Dk zr)OI=YW_T-gleF6IXojmPEKbEayXxt3p>J*TZLp{Q8+7HNyKi;9&UbzbEfS{jY>y) ztKjFb*m5__clFrED;2!2*y@lnai|G)#l4Pqbeh@hc?WtVB|C*L_t1p9PK}_M%gY$& z@@MGMZ_Cl<#s1D>!{R7~zIH1;&u|>5yl6xJX%a64I}OdHaXP-R4?MgXfc-o(g*LW( zK&HU8d41cQ#@mT@Xmle|_(udkfp?hHIlHcXi=~qB*yN??<;e(zorg7f3;iycG;g#z zsPzTSR4kck%6lBnnaiX_-_?vLDd`V5XEDsi x^0FXuMMVKbuBjL^wCgN>L`2!Rl-GBf9 delta 44 zcmcbxg=fJY?uHh|EljE>w?AQK_F~yy&B2_?wSBfQ^K6#w%@WM38QC;-xHPS~0C)!u A7XSbN diff --git a/mobile/lib/infrastructure/repositories/local_album.repository.dart b/mobile/lib/infrastructure/repositories/local_album.repository.dart index 9d4c9bc49..a59e20092 100644 --- a/mobile/lib/infrastructure/repositories/local_album.repository.dart +++ b/mobile/lib/infrastructure/repositories/local_album.repository.dart @@ -246,6 +246,25 @@ class DriftLocalAlbumRepository extends DriftDatabaseRepository { return query.map((row) => row.readTable(_db.localAssetEntity).toDto()).get(); } + Future updateCloudMapping(Map cloudMapping) { + if (cloudMapping.isEmpty) { + return Future.value(); + } + + return _db.batch((batch) { + for (final entry in cloudMapping.entries) { + final assetId = entry.key; + final cloudId = entry.value; + + batch.update( + _db.localAssetEntity, + LocalAssetEntityCompanion(iCloudId: Value(cloudId)), + where: (f) => f.id.equals(assetId), + ); + } + }); + } + Future Function(Iterable) get _upsertAssets => CurrentPlatform.isIOS ? _upsertAssetsDarwin : _upsertAssetsAndroid; diff --git a/mobile/lib/infrastructure/repositories/local_asset.repository.dart b/mobile/lib/infrastructure/repositories/local_asset.repository.dart index 8cbce084c..6a9181e60 100644 --- a/mobile/lib/infrastructure/repositories/local_asset.repository.dart +++ b/mobile/lib/infrastructure/repositories/local_asset.repository.dart @@ -1,3 +1,5 @@ +import 'dart:async'; + import 'package:collection/collection.dart'; import 'package:drift/drift.dart'; import 'package:immich_mobile/constants/constants.dart'; @@ -172,4 +174,40 @@ class DriftLocalAssetRepository extends DriftDatabaseRepository { final rows = await query.get(); return rows.map((row) => row.readTable(_db.localAssetEntity).toDto()).toList(); } + + Future> getEmptyCloudIdAssets() { + final query = _db.localAssetEntity.select()..where((row) => row.iCloudId.isNull()); + 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}; + } } diff --git a/mobile/lib/infrastructure/repositories/sync_api.repository.dart b/mobile/lib/infrastructure/repositories/sync_api.repository.dart index 8bf2e8057..7b5980389 100644 --- a/mobile/lib/infrastructure/repositories/sync_api.repository.dart +++ b/mobile/lib/infrastructure/repositories/sync_api.repository.dart @@ -45,6 +45,7 @@ class SyncApiRepository { SyncRequestType.usersV1, SyncRequestType.assetsV1, SyncRequestType.assetExifsV1, + SyncRequestType.assetMetadataV1, SyncRequestType.partnersV1, SyncRequestType.partnerAssetsV1, SyncRequestType.partnerAssetExifsV1, @@ -148,6 +149,8 @@ const _kResponseMap = { SyncEntityType.assetV1: SyncAssetV1.fromJson, SyncEntityType.assetDeleteV1: SyncAssetDeleteV1.fromJson, SyncEntityType.assetExifV1: SyncAssetExifV1.fromJson, + SyncEntityType.assetMetadataV1: SyncAssetMetadataV1.fromJson, + SyncEntityType.assetMetadataDeleteV1: SyncAssetMetadataDeleteV1.fromJson, SyncEntityType.partnerAssetV1: SyncAssetV1.fromJson, SyncEntityType.partnerAssetBackfillV1: SyncAssetV1.fromJson, SyncEntityType.partnerAssetDeleteV1: SyncAssetDeleteV1.fromJson, diff --git a/mobile/lib/infrastructure/repositories/sync_stream.repository.dart b/mobile/lib/infrastructure/repositories/sync_stream.repository.dart index b6dc7a286..95239a469 100644 --- a/mobile/lib/infrastructure/repositories/sync_stream.repository.dart +++ b/mobile/lib/infrastructure/repositories/sync_stream.repository.dart @@ -2,6 +2,7 @@ import 'dart:convert'; import 'package:collection/collection.dart'; import 'package:drift/drift.dart'; +import 'package:immich_mobile/constants/constants.dart'; import 'package:immich_mobile/domain/models/album/album.model.dart'; import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; import 'package:immich_mobile/domain/models/memory.model.dart'; @@ -18,6 +19,7 @@ import 'package:immich_mobile/infrastructure/entities/remote_album.entity.drift. import 'package:immich_mobile/infrastructure/entities/remote_album_asset.entity.drift.dart'; import 'package:immich_mobile/infrastructure/entities/remote_album_user.entity.drift.dart'; import 'package:immich_mobile/infrastructure/entities/remote_asset.entity.drift.dart'; +import 'package:immich_mobile/infrastructure/entities/remote_asset_cloud_id.entity.drift.dart'; import 'package:immich_mobile/infrastructure/entities/stack.entity.drift.dart'; import 'package:immich_mobile/infrastructure/entities/user.entity.drift.dart'; import 'package:immich_mobile/infrastructure/entities/user_metadata.entity.drift.dart'; @@ -55,6 +57,7 @@ class SyncStreamRepository extends DriftDatabaseRepository { await _db.authUserEntity.deleteAll(); await _db.userEntity.deleteAll(); await _db.userMetadataEntity.deleteAll(); + await _db.remoteAssetCloudIdEntity.deleteAll(); }); await _db.customStatement('PRAGMA foreign_keys = ON'); }); @@ -272,6 +275,50 @@ class SyncStreamRepository extends DriftDatabaseRepository { } } + Future deleteAssetsMetadataV1(Iterable data) async { + try { + await _db.batch((batch) { + for (final metadata in data) { + if (metadata.key == kMobileMetadataKey) { + batch.deleteWhere(_db.remoteAssetCloudIdEntity, (row) => row.assetId.equals(metadata.assetId)); + } + } + }); + } catch (error, stack) { + _logger.severe('Error: deleteAssetsMetadataV1', error, stack); + rethrow; + } + } + + Future updateAssetsMetadataV1(Iterable data) async { + try { + await _db.batch((batch) { + for (final metadata in data) { + if (metadata.key == kMobileMetadataKey) { + final map = metadata.value as Map; + final companion = RemoteAssetCloudIdEntityCompanion( + cloudId: Value(map['iCloudId']?.toString()), + createdAt: Value(map['createdAt'] != null ? DateTime.parse(map['createdAt'] as String) : null), + adjustmentTime: Value( + map['adjustmentTime'] != null ? DateTime.parse(map['adjustmentTime'] as String) : null, + ), + latitude: Value(map['latitude'] != null ? (double.tryParse(map['latitude'] as String)) : null), + longitude: Value(map['longitude'] != null ? (double.tryParse(map['longitude'] as String)) : null), + ); + batch.insert( + _db.remoteAssetCloudIdEntity, + companion.copyWith(assetId: Value(metadata.assetId)), + onConflict: DoUpdate((_) => companion), + ); + } + } + }); + } catch (error, stack) { + _logger.severe('Error: updateAssetsMetadataV1', error, stack); + rethrow; + } + } + Future deleteAlbumsV1(Iterable data) async { try { await _db.batch((batch) { diff --git a/mobile/lib/infrastructure/repositories/timeline.repository.dart b/mobile/lib/infrastructure/repositories/timeline.repository.dart index 66ae47a0b..e625b57c1 100644 --- a/mobile/lib/infrastructure/repositories/timeline.repository.dart +++ b/mobile/lib/infrastructure/repositories/timeline.repository.dart @@ -84,6 +84,10 @@ class DriftTimelineRepository extends DriftDatabaseRepository { isFavorite: row.isFavorite, durationInSeconds: row.durationInSeconds, orientation: row.orientation, + cloudId: row.iCloudId, + latitude: row.latitude, + longitude: row.longitude, + adjustmentTime: row.adjustmentTime, ), ) .get(); diff --git a/mobile/lib/models/server_info/server_version.model.dart b/mobile/lib/models/server_info/server_version.model.dart index 3aea98a80..c8bf73db8 100644 --- a/mobile/lib/models/server_info/server_version.model.dart +++ b/mobile/lib/models/server_info/server_version.model.dart @@ -10,4 +10,8 @@ class ServerVersion extends SemVer { } ServerVersion.fromDto(ServerVersionResponseDto dto) : super(major: dto.major, minor: dto.minor, patch: dto.patch_); + + bool isAtLeast({int major = 0, int minor = 0, int patch = 0}) { + return this >= SemVer(major: major, minor: minor, patch: patch); + } } diff --git a/mobile/lib/pages/common/splash_screen.page.dart b/mobile/lib/pages/common/splash_screen.page.dart index 79db33104..6c024600c 100644 --- a/mobile/lib/pages/common/splash_screen.page.dart +++ b/mobile/lib/pages/common/splash_screen.page.dart @@ -10,7 +10,6 @@ import 'package:immich_mobile/providers/background_sync.provider.dart'; import 'package:immich_mobile/providers/backup/backup.provider.dart'; import 'package:immich_mobile/providers/backup/drift_backup.provider.dart'; import 'package:immich_mobile/providers/gallery_permission.provider.dart'; -import 'package:immich_mobile/providers/server_info.provider.dart'; import 'package:immich_mobile/providers/websocket.provider.dart'; import 'package:immich_mobile/routing/router.dart'; import 'package:logging/logging.dart'; @@ -50,7 +49,6 @@ class SplashScreenPageState extends ConsumerState { final accessToken = Store.tryGet(StoreKey.accessToken); if (accessToken != null && serverUrl != null && endpoint != null) { - final infoProvider = ref.read(serverInfoProvider.notifier); final wsProvider = ref.read(websocketProvider.notifier); final backgroundManager = ref.read(backgroundSyncProvider); final backupProvider = ref.read(driftBackupProvider.notifier); @@ -60,7 +58,6 @@ class SplashScreenPageState extends ConsumerState { (_) async { try { wsProvider.connect(); - unawaited(infoProvider.getServerInfo()); if (Store.isBetaTimelineEnabled) { bool syncSuccess = false; @@ -75,6 +72,7 @@ class SplashScreenPageState extends ConsumerState { _resumeBackup(backupProvider); }), _resumeBackup(backupProvider), + backgroundManager.syncCloudIds(), ]); } else { await backgroundManager.hashAssets(); diff --git a/mobile/lib/platform/native_sync_api.g.dart b/mobile/lib/platform/native_sync_api.g.dart index 1c3b4b083e194cfc889d81987f353c5fefc8b92a..61bed5241144b37f42141807dd60b328f2004b6e 100644 GIT binary patch delta 334 zcmX@Mi1F(d#tj0JlFm8#r74~%L8-;1IVB3!TnY+Mk;!XC4cL<*vXdW*njqMMCX;=| z1mOHcF$W%GQzlR3aAJoU@t8nv<5q<7D(1| H)p7v<>}7Gw delta 31 pcmV+)0O0@iv;oGV0k9Amv!NW90+YT>2(zyrr~$K}A?-5)eG1*%4B`L) diff --git a/mobile/lib/presentation/pages/drift_asset_troubleshoot.page.dart b/mobile/lib/presentation/pages/drift_asset_troubleshoot.page.dart index 2b7034770..579b4c1d5 100644 --- a/mobile/lib/presentation/pages/drift_asset_troubleshoot.page.dart +++ b/mobile/lib/presentation/pages/drift_asset_troubleshoot.page.dart @@ -131,6 +131,7 @@ class _AssetPropertiesSectionState extends ConsumerState<_AssetPropertiesSection final albums = await ref.read(assetServiceProvider).getSourceAlbums(asset.id); properties.add(_PropertyItem(label: 'Album', value: albums.map((a) => a.name).join(', '))); if (CurrentPlatform.isIOS) { + properties.add(_PropertyItem(label: 'Cloud ID', value: asset.cloudId)); properties.add(_PropertyItem(label: 'Adjustment Time', value: asset.adjustmentTime?.toString())); } properties.add( diff --git a/mobile/lib/providers/app_life_cycle.provider.dart b/mobile/lib/providers/app_life_cycle.provider.dart index 4b1bf3e80..20ae8d20a 100644 --- a/mobile/lib/providers/app_life_cycle.provider.dart +++ b/mobile/lib/providers/app_life_cycle.provider.dart @@ -160,6 +160,7 @@ class AppLifeCycleNotifier extends StateNotifier { _resumeBackup(); }), _resumeBackup(), + backgroundManager.syncCloudIds(), ]); } else { await _safeRun(backgroundManager.hashAssets(), "hashAssets"); diff --git a/mobile/lib/providers/auth.provider.dart b/mobile/lib/providers/auth.provider.dart index 9a1559899..91f245afc 100644 --- a/mobile/lib/providers/auth.provider.dart +++ b/mobile/lib/providers/auth.provider.dart @@ -14,19 +14,19 @@ import 'package:immich_mobile/services/auth.service.dart'; import 'package:immich_mobile/services/secure_storage.service.dart'; import 'package:immich_mobile/services/upload.service.dart'; import 'package:immich_mobile/services/widget.service.dart'; +import 'package:immich_mobile/utils/debug_print.dart'; import 'package:immich_mobile/utils/hash.dart'; import 'package:logging/logging.dart'; import 'package:openapi/api.dart'; -import 'package:immich_mobile/utils/debug_print.dart'; final authProvider = StateNotifierProvider((ref) { return AuthNotifier( ref.watch(authServiceProvider), ref.watch(apiServiceProvider), ref.watch(userServiceProvider), - ref.watch(uploadServiceProvider), ref.watch(secureStorageServiceProvider), ref.watch(widgetServiceProvider), + ref, ); }); @@ -34,9 +34,9 @@ class AuthNotifier extends StateNotifier { final AuthService _authService; final ApiService _apiService; final UserService _userService; - final UploadService _uploadService; final SecureStorageService _secureStorageService; final WidgetService _widgetService; + final Ref _ref; final _log = Logger("AuthenticationNotifier"); static const Duration _timeoutDuration = Duration(seconds: 7); @@ -45,9 +45,9 @@ class AuthNotifier extends StateNotifier { this._authService, this._apiService, this._userService, - this._uploadService, this._secureStorageService, this._widgetService, + this._ref, ) : super( const AuthState( deviceId: "", @@ -87,7 +87,7 @@ class AuthNotifier extends StateNotifier { await _widgetService.clearCredentials(); await _authService.logout(); - await _uploadService.cancelBackup(); + await _ref.read(uploadServiceProvider).cancelBackup(); } finally { await _cleanUp(); } diff --git a/mobile/lib/providers/background_sync.provider.dart b/mobile/lib/providers/background_sync.provider.dart index 5d6a2f0f4..37b3145eb 100644 --- a/mobile/lib/providers/background_sync.provider.dart +++ b/mobile/lib/providers/background_sync.provider.dart @@ -28,6 +28,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/infrastructure/sync.provider.dart b/mobile/lib/providers/infrastructure/sync.provider.dart index 6ba9c4bb7..29dee6f72 100644 --- a/mobile/lib/providers/infrastructure/sync.provider.dart +++ b/mobile/lib/providers/infrastructure/sync.provider.dart @@ -32,6 +32,7 @@ final syncStreamRepositoryProvider = Provider((ref) => SyncStreamRepository(ref. final localSyncServiceProvider = Provider( (ref) => LocalSyncService( localAlbumRepository: ref.watch(localAlbumRepository), + localAssetRepository: ref.watch(localAssetRepository), trashedLocalAssetRepository: ref.watch(trashedLocalAssetRepository), localFilesManager: ref.watch(localFilesManagerRepositoryProvider), storageRepository: ref.watch(storageRepositoryProvider), diff --git a/mobile/lib/providers/server_info.provider.dart b/mobile/lib/providers/server_info.provider.dart index 9619ba86a..bb201a607 100644 --- a/mobile/lib/providers/server_info.provider.dart +++ b/mobile/lib/providers/server_info.provider.dart @@ -32,10 +32,11 @@ class ServerInfoNotifier extends StateNotifier { final ServerInfoService _serverInfoService; final _log = Logger("ServerInfoNotifier"); - Future getServerInfo() async { + Future getServerInfo() async { await getServerVersion(); await getServerFeatures(); await getServerConfig(); + return state; } Future getServerVersion() async { diff --git a/mobile/lib/providers/sync_status.provider.dart b/mobile/lib/providers/sync_status.provider.dart index 8e24bbf4d..203184fc8 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/services/upload.service.dart b/mobile/lib/services/upload.service.dart index 1ce0cf032..f4ee73ad4 100644 --- a/mobile/lib/services/upload.service.dart +++ b/mobile/lib/services/upload.service.dart @@ -7,6 +7,7 @@ import 'package:cancellation_token_http/http.dart'; import 'package:flutter/foundation.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/domain/models/store.model.dart'; import 'package:immich_mobile/entities/store.entity.dart'; @@ -14,10 +15,12 @@ import 'package:immich_mobile/extensions/platform_extensions.dart'; import 'package:immich_mobile/infrastructure/repositories/backup.repository.dart'; import 'package:immich_mobile/infrastructure/repositories/local_asset.repository.dart'; import 'package:immich_mobile/infrastructure/repositories/storage.repository.dart'; +import 'package:immich_mobile/models/server_info/server_info.model.dart'; import 'package:immich_mobile/providers/app_settings.provider.dart'; import 'package:immich_mobile/providers/backup/drift_backup.provider.dart'; import 'package:immich_mobile/providers/infrastructure/asset.provider.dart'; import 'package:immich_mobile/providers/infrastructure/storage.provider.dart'; +import 'package:immich_mobile/providers/server_info.provider.dart'; import 'package:immich_mobile/repositories/asset_media.repository.dart'; import 'package:immich_mobile/repositories/upload.repository.dart'; import 'package:immich_mobile/services/api.service.dart'; @@ -34,6 +37,7 @@ final uploadServiceProvider = Provider((ref) { ref.watch(localAssetRepository), ref.watch(appSettingsServiceProvider), ref.watch(assetMediaRepositoryProvider), + ref.watch(serverInfoProvider), ); ref.onDispose(service.dispose); @@ -48,6 +52,7 @@ class UploadService { this._localAssetRepository, this._appSettingsService, this._assetMediaRepository, + this._serverInfo, ) { _uploadRepository.onUploadStatus = _onUploadCallback; _uploadRepository.onTaskProgress = _onTaskProgressCallback; @@ -59,6 +64,7 @@ class UploadService { final DriftLocalAssetRepository _localAssetRepository; final AppSettingsService _appSettingsService; final AssetMediaRepository _assetMediaRepository; + final ServerInfo _serverInfo; final Logger _logger = Logger('UploadService'); final StreamController _taskStatusController = StreamController.broadcast(); @@ -352,6 +358,10 @@ class UploadService { priority: priority, isFavorite: asset.isFavorite, requiresWiFi: requiresWiFi, + cloudId: asset.cloudId, + adjustmentTime: asset.adjustmentTime?.toIso8601String(), + latitude: asset.latitude?.toString(), + longitude: asset.longitude?.toString(), ); } @@ -383,6 +393,10 @@ class UploadService { priority: 0, // Highest priority to get upload immediately isFavorite: asset.isFavorite, requiresWiFi: requiresWiFi, + cloudId: asset.cloudId, + adjustmentTime: asset.adjustmentTime?.toIso8601String(), + latitude: asset.latitude?.toString(), + longitude: asset.longitude?.toString(), ); } @@ -410,6 +424,10 @@ class UploadService { int? priority, bool? isFavorite, bool requiresWiFi = true, + String? cloudId, + String? adjustmentTime, + String? latitude, + String? longitude, }) async { final serverEndpoint = Store.get(StoreKey.serverEndpoint); final url = Uri.parse('$serverEndpoint/assets').toString(); @@ -425,6 +443,20 @@ class UploadService { 'isFavorite': isFavorite?.toString() ?? 'false', 'duration': '0', if (fields != null) ...fields, + // Include cloudId and eTag in metadata if available and server version supports it + if (CurrentPlatform.isIOS && cloudId != null && _serverInfo.serverVersion.isAtLeast(major: 2, minor: 4)) + 'metadata': jsonEncode([ + RemoteAssetMetadataItem( + key: RemoteAssetMetadataKey.mobileApp, + value: RemoteAssetMobileAppMetadata( + cloudId: cloudId, + createdAt: createdAt.toIso8601String(), + adjustmentTime: adjustmentTime, + latitude: latitude, + longitude: longitude, + ), + ), + ]), }; return UploadTask( diff --git a/mobile/lib/widgets/settings/beta_sync_settings/sync_status_and_actions.dart b/mobile/lib/widgets/settings/beta_sync_settings/sync_status_and_actions.dart index d4730951c..1f3fa14c6 100644 --- a/mobile/lib/widgets/settings/beta_sync_settings/sync_status_and_actions.dart +++ b/mobile/lib/widgets/settings/beta_sync_settings/sync_status_and_actions.dart @@ -194,7 +194,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), diff --git a/mobile/pigeon/native_sync_api.dart b/mobile/pigeon/native_sync_api.dart index ec28afb00..ae82018b0 100644 --- a/mobile/pigeon/native_sync_api.dart +++ b/mobile/pigeon/native_sync_api.dart @@ -90,6 +90,14 @@ class HashResult { const HashResult({required this.assetId, this.error, this.hash}); } +class CloudIdResult { + final String assetId; + final String? error; + final String? cloudId; + + const CloudIdResult({required this.assetId, this.error, this.cloudId}); +} + @HostApi() abstract class NativeSyncApi { bool shouldFullSync(); @@ -121,4 +129,7 @@ abstract class NativeSyncApi { @TaskQueue(type: TaskQueueType.serialBackgroundThread) Map> getTrashedAssets(); + + @TaskQueue(type: TaskQueueType.serialBackgroundThread) + List getCloudIdForAssetIds(List assetIds); } diff --git a/mobile/test/domain/services/hash_service_test.dart b/mobile/test/domain/services/hash_service_test.dart index 3529ecca3..d71dd63da 100644 --- a/mobile/test/domain/services/hash_service_test.dart +++ b/mobile/test/domain/services/hash_service_test.dart @@ -33,6 +33,7 @@ void main() { registerFallbackValue(LocalAssetStub.image1); registerFallbackValue({}); + when(() => mockAssetRepo.getHashMappingFromCloudId()).thenAnswer((_) async => {}); when(() => mockAssetRepo.updateHashes(any())).thenAnswer((_) async => {}); }); diff --git a/mobile/test/domain/services/local_sync_service_test.dart b/mobile/test/domain/services/local_sync_service_test.dart index 45088305e..17d02581d 100644 --- a/mobile/test/domain/services/local_sync_service_test.dart +++ b/mobile/test/domain/services/local_sync_service_test.dart @@ -9,6 +9,7 @@ import 'package:immich_mobile/domain/services/store.service.dart'; import 'package:immich_mobile/entities/store.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/infrastructure/repositories/local_asset.repository.dart'; import 'package:immich_mobile/infrastructure/repositories/storage.repository.dart'; import 'package:immich_mobile/infrastructure/repositories/store.repository.dart'; import 'package:immich_mobile/infrastructure/repositories/trashed_local_asset.repository.dart'; @@ -25,6 +26,7 @@ import '../../repository.mocks.dart'; void main() { late LocalSyncService sut; late DriftLocalAlbumRepository mockLocalAlbumRepository; + late DriftLocalAssetRepository mockLocalAssetRepository; late DriftTrashedLocalAssetRepository mockTrashedLocalAssetRepository; late LocalFilesManagerRepository mockLocalFilesManager; late StorageRepository mockStorageRepository; @@ -47,6 +49,7 @@ void main() { setUp(() async { mockLocalAlbumRepository = MockLocalAlbumRepository(); + mockLocalAssetRepository = MockLocalAssetRepository(); mockTrashedLocalAssetRepository = MockTrashedLocalAssetRepository(); mockLocalFilesManager = MockLocalFilesManagerRepository(); mockStorageRepository = MockStorageRepository(); @@ -66,6 +69,7 @@ void main() { sut = LocalSyncService( localAlbumRepository: mockLocalAlbumRepository, + localAssetRepository: mockLocalAssetRepository, trashedLocalAssetRepository: mockTrashedLocalAssetRepository, localFilesManager: mockLocalFilesManager, storageRepository: mockStorageRepository, diff --git a/mobile/test/drift/main/generated/schema.dart b/mobile/test/drift/main/generated/schema.dart index 9edeed5ddff6f4b74bc4400613dccea3ab7c12c9..571d5c028470f88cc56fa3b2248c671e7276333f 100644 GIT binary patch delta 90 zcmZ3?*T}!2pGiJ5w;;c$L_xhcIU_YUF}}>uOfMy|s6<^Mu~-4f+1$&tk(oU?u{c%1 p&}?!ct1!Qro=aj$ViHiqu_O#AX2kY}R{51pq`}8)5(e delta 22 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 mockAssetMediaRepository.getOriginalFilename(asset.id)).called(1); }); }); + + group('Server Info - cloudId and eTag metadata', () { + test('should include cloudId and eTag metadata on iOS when server version is 2.4+', () async { + debugDefaultTargetPlatformOverride = TargetPlatform.iOS; + addTearDown(() => debugDefaultTargetPlatformOverride = null); + + final sutWithV24 = UploadService( + mockUploadRepository, + mockBackupRepository, + mockStorageRepository, + mockLocalAssetRepository, + mockAppSettingsService, + mockAssetMediaRepository, + _serverInfo, + ); + addTearDown(() => sutWithV24.dispose()); + + final assetWithCloudId = LocalAsset( + id: 'test-asset-id', + name: 'test.jpg', + type: AssetType.image, + createdAt: DateTime(2025, 1, 1), + updatedAt: DateTime(2025, 1, 2), + cloudId: 'cloud-id-123', + latitude: 37.7749, + longitude: -122.4194, + adjustmentTime: DateTime(2026, 1, 2), + ); + + final mockEntity = MockAssetEntity(); + final mockFile = File('/path/to/test.jpg'); + + when(() => mockEntity.isLivePhoto).thenReturn(false); + when(() => mockStorageRepository.getAssetEntityForAsset(assetWithCloudId)).thenAnswer((_) async => mockEntity); + when(() => mockStorageRepository.getFileForAsset(assetWithCloudId.id)).thenAnswer((_) async => mockFile); + when(() => mockAssetMediaRepository.getOriginalFilename(assetWithCloudId.id)).thenAnswer((_) async => 'test.jpg'); + + final task = await sutWithV24.getUploadTask(assetWithCloudId); + + expect(task, isNotNull); + expect(task!.fields.containsKey('metadata'), isTrue); + + final metadata = jsonDecode(task.fields['metadata']!) as List; + expect(metadata, hasLength(1)); + expect(metadata[0]['key'], equals('mobile-app')); + expect(metadata[0]['value']['iCloudId'], equals('cloud-id-123')); + expect(metadata[0]['value']['createdAt'], isNotNull); + expect(metadata[0]['value']['adjustmentTime'], isNotNull); + expect(metadata[0]['value']['latitude'], isNotNull); + expect(metadata[0]['value']['longitude'], isNotNull); + }); + + test('should NOT include metadata on iOS when server version is below 2.4', () async { + debugDefaultTargetPlatformOverride = TargetPlatform.iOS; + addTearDown(() => debugDefaultTargetPlatformOverride = null); + + final sutWithV23 = UploadService( + mockUploadRepository, + mockBackupRepository, + mockStorageRepository, + mockLocalAssetRepository, + mockAppSettingsService, + mockAssetMediaRepository, + _serverInfo.copyWith( + serverVersion: const ServerVersion(major: 2, minor: 3, patch: 0), + latestVersion: const ServerVersion(major: 2, minor: 3, patch: 0), + ), + ); + addTearDown(() => sutWithV23.dispose()); + + final assetWithCloudId = LocalAsset( + id: 'test-asset-id', + name: 'test.jpg', + type: AssetType.image, + createdAt: DateTime(2025, 1, 1), + updatedAt: DateTime(2025, 1, 2), + cloudId: 'cloud-id-123', + latitude: 37.7749, + longitude: -122.4194, + ); + + final mockEntity = MockAssetEntity(); + final mockFile = File('/path/to/test.jpg'); + + when(() => mockEntity.isLivePhoto).thenReturn(false); + when(() => mockStorageRepository.getAssetEntityForAsset(assetWithCloudId)).thenAnswer((_) async => mockEntity); + when(() => mockStorageRepository.getFileForAsset(assetWithCloudId.id)).thenAnswer((_) async => mockFile); + when(() => mockAssetMediaRepository.getOriginalFilename(assetWithCloudId.id)).thenAnswer((_) async => 'test.jpg'); + + final task = await sutWithV23.getUploadTask(assetWithCloudId); + + expect(task, isNotNull); + expect(task!.fields.containsKey('metadata'), isFalse); + }); + + test('should NOT include metadata on Android regardless of server version', () async { + debugDefaultTargetPlatformOverride = TargetPlatform.android; + addTearDown(() => debugDefaultTargetPlatformOverride = null); + + final sutAndroid = UploadService( + mockUploadRepository, + mockBackupRepository, + mockStorageRepository, + mockLocalAssetRepository, + mockAppSettingsService, + mockAssetMediaRepository, + _serverInfo, + ); + addTearDown(() => sutAndroid.dispose()); + + final assetWithCloudId = LocalAsset( + id: 'test-asset-id', + name: 'test.jpg', + type: AssetType.image, + createdAt: DateTime(2025, 1, 1), + updatedAt: DateTime(2025, 1, 2), + cloudId: 'cloud-id-123', + latitude: 37.7749, + longitude: -122.4194, + ); + + final mockEntity = MockAssetEntity(); + final mockFile = File('/path/to/test.jpg'); + + when(() => mockEntity.isLivePhoto).thenReturn(false); + when(() => mockStorageRepository.getAssetEntityForAsset(assetWithCloudId)).thenAnswer((_) async => mockEntity); + when(() => mockStorageRepository.getFileForAsset(assetWithCloudId.id)).thenAnswer((_) async => mockFile); + when(() => mockAssetMediaRepository.getOriginalFilename(assetWithCloudId.id)).thenAnswer((_) async => 'test.jpg'); + + final task = await sutAndroid.getUploadTask(assetWithCloudId); + + expect(task, isNotNull); + expect(task!.fields.containsKey('metadata'), isFalse); + }); + + test('should NOT include metadata when cloudId is null even on iOS with server 2.4+', () async { + debugDefaultTargetPlatformOverride = TargetPlatform.iOS; + addTearDown(() => debugDefaultTargetPlatformOverride = null); + + final sutWithV24 = UploadService( + mockUploadRepository, + mockBackupRepository, + mockStorageRepository, + mockLocalAssetRepository, + mockAppSettingsService, + mockAssetMediaRepository, + _serverInfo, + ); + addTearDown(() => sutWithV24.dispose()); + + final assetWithoutCloudId = LocalAsset( + id: 'test-asset-id', + name: 'test.jpg', + type: AssetType.image, + createdAt: DateTime(2025, 1, 1), + updatedAt: DateTime(2025, 1, 2), + cloudId: null, // No cloudId + ); + + final mockEntity = MockAssetEntity(); + final mockFile = File('/path/to/test.jpg'); + + when(() => mockEntity.isLivePhoto).thenReturn(false); + when(() => mockStorageRepository.getAssetEntityForAsset(assetWithoutCloudId)).thenAnswer((_) async => mockEntity); + when(() => mockStorageRepository.getFileForAsset(assetWithoutCloudId.id)).thenAnswer((_) async => mockFile); + when( + () => mockAssetMediaRepository.getOriginalFilename(assetWithoutCloudId.id), + ).thenAnswer((_) async => 'test.jpg'); + + final task = await sutWithV24.getUploadTask(assetWithoutCloudId); + + expect(task, isNotNull); + expect(task!.fields.containsKey('metadata'), isFalse); + }); + + test('should include metadata for live photos with cloudId on iOS 2.4+', () async { + debugDefaultTargetPlatformOverride = TargetPlatform.iOS; + addTearDown(() => debugDefaultTargetPlatformOverride = null); + + final sutWithV24 = UploadService( + mockUploadRepository, + mockBackupRepository, + mockStorageRepository, + mockLocalAssetRepository, + mockAppSettingsService, + mockAssetMediaRepository, + _serverInfo, + ); + addTearDown(() => sutWithV24.dispose()); + + final assetWithCloudId = LocalAsset( + id: 'test-livephoto-id', + name: 'livephoto.heic', + type: AssetType.image, + createdAt: DateTime(2025, 1, 1), + updatedAt: DateTime(2025, 1, 2), + cloudId: 'cloud-id-livephoto', + latitude: 37.7749, + longitude: -122.4194, + ); + + final mockEntity = MockAssetEntity(); + final mockFile = File('/path/to/livephoto.heic'); + + when(() => mockEntity.isLivePhoto).thenReturn(true); + when(() => mockStorageRepository.getAssetEntityForAsset(assetWithCloudId)).thenAnswer((_) async => mockEntity); + when(() => mockStorageRepository.getFileForAsset(assetWithCloudId.id)).thenAnswer((_) async => mockFile); + when( + () => mockAssetMediaRepository.getOriginalFilename(assetWithCloudId.id), + ).thenAnswer((_) async => 'livephoto.heic'); + + final task = await sutWithV24.getLivePhotoUploadTask(assetWithCloudId, 'video-123'); + + expect(task, isNotNull); + expect(task!.fields.containsKey('metadata'), isTrue); + expect(task.fields['livePhotoVideoId'], equals('video-123')); + + final metadata = jsonDecode(task.fields['metadata']!) as List; + expect(metadata, hasLength(1)); + expect(metadata[0]['key'], equals('mobile-app')); + expect(metadata[0]['value']['iCloudId'], equals('cloud-id-livephoto')); + }); + }); }