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 000000000..8e716ada0 Binary files /dev/null and b/mobile/drift_schemas/main/drift_schema_v16.json differ 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 22219b1e6..088cfac97 100644 Binary files a/mobile/lib/infrastructure/entities/local_asset.entity.drift.dart and b/mobile/lib/infrastructure/entities/local_asset.entity.drift.dart differ diff --git a/mobile/lib/infrastructure/entities/merged_asset.drift b/mobile/lib/infrastructure/entities/merged_asset.drift index d1377f668..93d7f0c90 100644 --- a/mobile/lib/infrastructure/entities/merged_asset.drift +++ b/mobile/lib/infrastructure/entities/merged_asset.drift @@ -21,7 +21,11 @@ SELECT rae.owner_id, rae.live_photo_video_id, 0 as orientation, - rae.stack_id + rae.stack_id, + NULL as i_cloud_id, + NULL as latitude, + NULL as longitude, + NULL as adjustmentTime FROM remote_asset_entity rae LEFT JOIN @@ -53,7 +57,11 @@ SELECT NULL as owner_id, NULL as live_photo_video_id, lae.orientation, - NULL as stack_id + NULL as stack_id, + lae.i_cloud_id, + lae.latitude, + lae.longitude, + lae.adjustment_time FROM local_asset_entity lae WHERE NOT EXISTS ( diff --git a/mobile/lib/infrastructure/entities/merged_asset.drift.dart b/mobile/lib/infrastructure/entities/merged_asset.drift.dart index 5a091c349..169004b45 100644 Binary files a/mobile/lib/infrastructure/entities/merged_asset.drift.dart and b/mobile/lib/infrastructure/entities/merged_asset.drift.dart differ diff --git a/mobile/lib/infrastructure/entities/remote_asset_cloud_id.entity.dart b/mobile/lib/infrastructure/entities/remote_asset_cloud_id.entity.dart new file mode 100644 index 000000000..ccebbf635 --- /dev/null +++ b/mobile/lib/infrastructure/entities/remote_asset_cloud_id.entity.dart @@ -0,0 +1,20 @@ +import 'package:drift/drift.dart'; +import 'package:immich_mobile/infrastructure/entities/remote_asset.entity.dart'; +import 'package:immich_mobile/infrastructure/utils/drift_default.mixin.dart'; + +class RemoteAssetCloudIdEntity extends Table with DriftDefaultsMixin { + TextColumn get assetId => 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 000000000..f3c416ad3 Binary files /dev/null and b/mobile/lib/infrastructure/entities/remote_asset_cloud_id.entity.drift.dart differ diff --git a/mobile/lib/infrastructure/repositories/db.repository.dart b/mobile/lib/infrastructure/repositories/db.repository.dart index 9ea0ba52e..482f7f04b 100644 --- a/mobile/lib/infrastructure/repositories/db.repository.dart +++ b/mobile/lib/infrastructure/repositories/db.repository.dart @@ -18,6 +18,7 @@ import 'package:immich_mobile/infrastructure/entities/remote_album.entity.dart'; import 'package:immich_mobile/infrastructure/entities/remote_album_asset.entity.dart'; import 'package:immich_mobile/infrastructure/entities/remote_album_user.entity.dart'; import 'package:immich_mobile/infrastructure/entities/remote_asset.entity.dart'; +import 'package:immich_mobile/infrastructure/entities/remote_asset_cloud_id.entity.dart'; import 'package:immich_mobile/infrastructure/entities/stack.entity.dart'; import 'package:immich_mobile/infrastructure/entities/store.entity.dart'; import 'package:immich_mobile/infrastructure/entities/trashed_local_asset.entity.dart'; @@ -57,6 +58,7 @@ class IsarDatabaseRepository implements IDatabaseRepository { RemoteAlbumEntity, RemoteAlbumAssetEntity, RemoteAlbumUserEntity, + RemoteAssetCloudIdEntity, MemoryEntity, MemoryAssetEntity, StackEntity, @@ -95,7 +97,7 @@ class Drift extends $Drift implements IDatabaseRepository { } @override - int get schemaVersion => 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 bd72da949..72dc1e804 100644 Binary files a/mobile/lib/infrastructure/repositories/db.repository.drift.dart and b/mobile/lib/infrastructure/repositories/db.repository.drift.dart differ diff --git a/mobile/lib/infrastructure/repositories/db.repository.steps.dart b/mobile/lib/infrastructure/repositories/db.repository.steps.dart index 38e0cec63..6e46e3e13 100644 Binary files a/mobile/lib/infrastructure/repositories/db.repository.steps.dart and b/mobile/lib/infrastructure/repositories/db.repository.steps.dart differ 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 1c3b4b083..61bed5241 100644 Binary files a/mobile/lib/platform/native_sync_api.g.dart and b/mobile/lib/platform/native_sync_api.g.dart differ 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 9edeed5dd..571d5c028 100644 Binary files a/mobile/test/drift/main/generated/schema.dart and b/mobile/test/drift/main/generated/schema.dart differ diff --git a/mobile/test/drift/main/generated/schema_v16.dart b/mobile/test/drift/main/generated/schema_v16.dart new file mode 100644 index 000000000..ce0284500 Binary files /dev/null and b/mobile/test/drift/main/generated/schema_v16.dart differ diff --git a/mobile/test/services/upload.service_test.dart b/mobile/test/services/upload.service_test.dart index d33126782..86acf104e 100644 --- a/mobile/test/services/upload.service_test.dart +++ b/mobile/test/services/upload.service_test.dart @@ -1,14 +1,22 @@ +import 'dart:convert'; import 'dart:io'; import 'package:drift/drift.dart' hide isNull, isNotNull; import 'package:drift/native.dart'; +import 'package:flutter/foundation.dart'; import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.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/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/store.repository.dart'; +import 'package:immich_mobile/models/server_info/server_config.model.dart'; +import 'package:immich_mobile/models/server_info/server_disk_info.model.dart'; +import 'package:immich_mobile/models/server_info/server_features.model.dart'; +import 'package:immich_mobile/models/server_info/server_info.model.dart'; +import 'package:immich_mobile/models/server_info/server_version.model.dart'; import 'package:immich_mobile/services/app_settings.service.dart'; import 'package:immich_mobile/services/upload.service.dart'; import 'package:mocktail/mocktail.dart'; @@ -16,8 +24,29 @@ import 'package:mocktail/mocktail.dart'; import '../domain/service.mock.dart'; import '../fixtures/asset.stub.dart'; import '../infrastructure/repository.mock.dart'; -import '../repository.mocks.dart'; import '../mocks/asset_entity.mock.dart'; +import '../repository.mocks.dart'; + +// Test ServerInfo stub +const _serverInfo = ServerInfo( + serverVersion: ServerVersion(major: 2, minor: 4, patch: 0), + latestVersion: ServerVersion(major: 2, minor: 4, patch: 0), + serverFeatures: ServerFeatures(trash: true, map: true, oauthEnabled: false, passwordLogin: true, ocr: false), + serverConfig: ServerConfig( + trashDays: 30, + oauthButtonText: 'Login with OAuth', + externalDomain: '', + mapDarkStyleUrl: '', + mapLightStyleUrl: '', + ), + serverDiskInfo: ServerDiskInfo( + diskAvailable: '100GB', + diskSize: '500GB', + diskUse: '400GB', + diskUsagePercentage: 80.0, + ), + versionStatus: VersionStatus.upToDate, +); void main() { late UploadService sut; @@ -62,6 +91,7 @@ void main() { mockLocalAssetRepository, mockAppSettingsService, mockAssetMediaRepository, + _serverInfo, ); mockUploadRepository.onUploadStatus = (_) {}; @@ -165,4 +195,227 @@ void main() { verify(() => 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')); + }); + }); }