From 8fe932d891c96eaf67f3bc01d75381da51b42dc0 Mon Sep 17 00:00:00 2001 From: mertalev <101130780+mertalev@users.noreply.github.com> Date: Fri, 21 Nov 2025 00:50:24 -0500 Subject: [PATCH] local sync poc --- mobile/ios/Runner.xcodeproj/project.pbxproj | 16 + mobile/ios/Runner/AppDelegate.swift | 5 - mobile/ios/Runner/Schemas/Tables.swift | 12 +- mobile/ios/Runner/Sync/Messages.g.swift | 552 ------------ mobile/ios/Runner/Sync/MessagesImpl.swift | 837 +++++++++++------- .../ios/Runner/Sync/PHAssetExtensions.swift | 49 +- mobile/ios/Runner/Sync/PhotoLibrary.swift | 53 ++ 7 files changed, 626 insertions(+), 898 deletions(-) delete mode 100644 mobile/ios/Runner/Sync/Messages.g.swift create mode 100644 mobile/ios/Runner/Sync/PhotoLibrary.swift diff --git a/mobile/ios/Runner.xcodeproj/project.pbxproj b/mobile/ios/Runner.xcodeproj/project.pbxproj index 876732f8fc..83f3c337b9 100644 --- a/mobile/ios/Runner.xcodeproj/project.pbxproj +++ b/mobile/ios/Runner.xcodeproj/project.pbxproj @@ -29,6 +29,7 @@ FAC6F89B2D287C890078CB2F /* ShareExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = FAC6F8902D287C890078CB2F /* ShareExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; FAC6F8B72D287F120078CB2F /* ShareViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = FAC6F8B52D287F120078CB2F /* ShareViewController.swift */; }; FAC6F8B92D287F120078CB2F /* MainInterface.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = FAC6F8B32D287F120078CB2F /* MainInterface.storyboard */; }; + FE30A0D02ECF97B8007AFDD7 /* Algorithms in Frameworks */ = {isa = PBXBuildFile; productRef = FE30A0CF2ECF97B8007AFDD7 /* Algorithms */; }; FEAFA8732E4D42F4001E47FE /* Thumbhash.swift in Sources */ = {isa = PBXBuildFile; fileRef = FEAFA8722E4D42F4001E47FE /* Thumbhash.swift */; }; FED3B1962E253E9B0030FD97 /* ThumbnailsImpl.swift in Sources */ = {isa = PBXBuildFile; fileRef = FED3B1942E253E9B0030FD97 /* ThumbnailsImpl.swift */; }; FED3B1972E253E9B0030FD97 /* Thumbnails.g.swift in Sources */ = {isa = PBXBuildFile; fileRef = FED3B1932E253E9B0030FD97 /* Thumbnails.g.swift */; }; @@ -187,6 +188,7 @@ FEE084F82EC172460045228E /* SQLiteData in Frameworks */, FEE084FB2EC1725A0045228E /* RawStructuredFieldValues in Frameworks */, FEE084FD2EC1725A0045228E /* StructuredFieldValues in Frameworks */, + FE30A0D02ECF97B8007AFDD7 /* Algorithms in Frameworks */, D218389C4A4C4693F141F7D1 /* Pods_Runner.framework in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; @@ -456,6 +458,7 @@ packageReferences = ( FEE084F62EC172460045228E /* XCRemoteSwiftPackageReference "sqlite-data" */, FEE084F92EC1725A0045228E /* XCRemoteSwiftPackageReference "swift-http-structured-headers" */, + FE30A0CE2ECF97B8007AFDD7 /* XCRemoteSwiftPackageReference "swift-algorithms" */, ); preferredProjectObjectVersion = 77; productRefGroup = 97C146EF1CF9000F007C117D /* Products */; @@ -1252,6 +1255,14 @@ /* End XCConfigurationList section */ /* Begin XCRemoteSwiftPackageReference section */ + FE30A0CE2ECF97B8007AFDD7 /* XCRemoteSwiftPackageReference "swift-algorithms" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/apple/swift-algorithms.git"; + requirement = { + kind = upToNextMajorVersion; + minimumVersion = 1.2.1; + }; + }; FEE084F62EC172460045228E /* XCRemoteSwiftPackageReference "sqlite-data" */ = { isa = XCRemoteSwiftPackageReference; repositoryURL = "https://github.com/pointfreeco/sqlite-data"; @@ -1271,6 +1282,11 @@ /* End XCRemoteSwiftPackageReference section */ /* Begin XCSwiftPackageProductDependency section */ + FE30A0CF2ECF97B8007AFDD7 /* Algorithms */ = { + isa = XCSwiftPackageProductDependency; + package = FE30A0CE2ECF97B8007AFDD7 /* XCRemoteSwiftPackageReference "swift-algorithms" */; + productName = Algorithms; + }; FEE084F72EC172460045228E /* SQLiteData */ = { isa = XCSwiftPackageProductDependency; package = FEE084F62EC172460045228E /* XCRemoteSwiftPackageReference "sqlite-data" */; diff --git a/mobile/ios/Runner/AppDelegate.swift b/mobile/ios/Runner/AppDelegate.swift index fea19ac1c7..692656abf1 100644 --- a/mobile/ios/Runner/AppDelegate.swift +++ b/mobile/ios/Runner/AppDelegate.swift @@ -54,7 +54,6 @@ import shared_preferences_foundation } public static func registerPlugins(with engine: FlutterEngine) { - NativeSyncApiImpl.register(with: engine.registrar(forPlugin: NativeSyncApiImpl.name)!) ThumbnailApiSetup.setUp(binaryMessenger: engine.binaryMessenger, api: ThumbnailApiImpl()) BackgroundWorkerFgHostApiSetup.setUp(binaryMessenger: engine.binaryMessenger, api: BackgroundWorkerApiImpl()) @@ -67,8 +66,4 @@ import shared_preferences_foundation api: UploadApiImpl(statusListener: statusListener, progressListener: progressListener) ) } - - public static func cancelPlugins(with engine: FlutterEngine) { - (engine.valuePublished(byPlugin: NativeSyncApiImpl.name) as? NativeSyncApiImpl)?.detachFromEngine() - } } diff --git a/mobile/ios/Runner/Schemas/Tables.swift b/mobile/ios/Runner/Schemas/Tables.swift index b9f18417f8..615a3eebaf 100644 --- a/mobile/ios/Runner/Schemas/Tables.swift +++ b/mobile/ios/Runner/Schemas/Tables.swift @@ -100,6 +100,16 @@ extension LocalAlbumAsset { static let excluded = Self.where { $0.id.assetId.eq(LocalAsset.columns.id) && $0.id.albumId.in(LocalAlbum.excluded.select(\.id)) } + + /// Get all asset ids that are only in this album and not in other albums. + /// This is useful in cases where the album is a smart album or a user-created album, especially on iOS + static func uniqueAssetIds(albumId: String) -> Select { + return Self.select(\.id.assetId) + .where { laa in + laa.id.albumId.eq(albumId) + && !LocalAlbumAsset.where { $0.id.assetId.eq(laa.id.assetId) && $0.id.albumId.neq(albumId) }.exists() + } + } } @Table("local_asset_entity") @@ -109,7 +119,7 @@ struct LocalAsset: Identifiable { @Column("created_at") let createdAt: String @Column("duration_in_seconds") - let durationInSeconds: Int? + let durationInSeconds: Int64? let height: Int? @Column("is_favorite") let isFavorite: Bool diff --git a/mobile/ios/Runner/Sync/Messages.g.swift b/mobile/ios/Runner/Sync/Messages.g.swift deleted file mode 100644 index bbe18e7375..0000000000 --- a/mobile/ios/Runner/Sync/Messages.g.swift +++ /dev/null @@ -1,552 +0,0 @@ -// Autogenerated from Pigeon (v26.0.2), do not edit directly. -// See also: https://pub.dev/packages/pigeon - -import Foundation - -#if os(iOS) - import Flutter -#elseif os(macOS) - import FlutterMacOS -#else - #error("Unsupported platform.") -#endif - -/// Error class for passing custom error details to Dart side. -final class PigeonError: Error { - let code: String - let message: String? - let details: Sendable? - - init(code: String, message: String?, details: Sendable?) { - self.code = code - self.message = message - self.details = details - } - - var localizedDescription: String { - return - "PigeonError(code: \(code), message: \(message ?? ""), details: \(details ?? "")" - } -} - -private func wrapResult(_ result: Any?) -> [Any?] { - return [result] -} - -private func wrapError(_ error: Any) -> [Any?] { - if let pigeonError = error as? PigeonError { - return [ - pigeonError.code, - pigeonError.message, - pigeonError.details, - ] - } - if let flutterError = error as? FlutterError { - return [ - flutterError.code, - flutterError.message, - flutterError.details, - ] - } - return [ - "\(error)", - "\(type(of: error))", - "Stacktrace: \(Thread.callStackSymbols)", - ] -} - -private func isNullish(_ value: Any?) -> Bool { - return value is NSNull || value == nil -} - -private func nilOrValue(_ value: Any?) -> T? { - if value is NSNull { return nil } - return value as! T? -} - -func deepEqualsMessages(_ lhs: Any?, _ rhs: Any?) -> Bool { - let cleanLhs = nilOrValue(lhs) as Any? - let cleanRhs = nilOrValue(rhs) as Any? - switch (cleanLhs, cleanRhs) { - case (nil, nil): - return true - - case (nil, _), (_, nil): - return false - - case is (Void, Void): - return true - - case let (cleanLhsHashable, cleanRhsHashable) as (AnyHashable, AnyHashable): - return cleanLhsHashable == cleanRhsHashable - - case let (cleanLhsArray, cleanRhsArray) as ([Any?], [Any?]): - guard cleanLhsArray.count == cleanRhsArray.count else { return false } - for (index, element) in cleanLhsArray.enumerated() { - if !deepEqualsMessages(element, cleanRhsArray[index]) { - return false - } - } - return true - - case let (cleanLhsDictionary, cleanRhsDictionary) as ([AnyHashable: Any?], [AnyHashable: Any?]): - guard cleanLhsDictionary.count == cleanRhsDictionary.count else { return false } - for (key, cleanLhsValue) in cleanLhsDictionary { - guard cleanRhsDictionary.index(forKey: key) != nil else { return false } - if !deepEqualsMessages(cleanLhsValue, cleanRhsDictionary[key]!) { - return false - } - } - return true - - default: - // Any other type shouldn't be able to be used with pigeon. File an issue if you find this to be untrue. - return false - } -} - -func deepHashMessages(value: Any?, hasher: inout Hasher) { - if let valueList = value as? [AnyHashable] { - for item in valueList { deepHashMessages(value: item, hasher: &hasher) } - return - } - - if let valueDict = value as? [AnyHashable: AnyHashable] { - for key in valueDict.keys { - hasher.combine(key) - deepHashMessages(value: valueDict[key]!, hasher: &hasher) - } - return - } - - if let hashableValue = value as? AnyHashable { - hasher.combine(hashableValue.hashValue) - } - - return hasher.combine(String(describing: value)) -} - - - -/// Generated class from Pigeon that represents data sent in messages. -struct PlatformAsset: Hashable { - var id: String - var name: String - var type: Int64 - var createdAt: Int64? = nil - var updatedAt: Int64? = nil - var width: Int64? = nil - var height: Int64? = nil - var durationInSeconds: Int64 - var orientation: Int64 - var isFavorite: Bool - - - // swift-format-ignore: AlwaysUseLowerCamelCase - static func fromList(_ pigeonVar_list: [Any?]) -> PlatformAsset? { - let id = pigeonVar_list[0] as! String - let name = pigeonVar_list[1] as! String - let type = pigeonVar_list[2] as! Int64 - let createdAt: Int64? = nilOrValue(pigeonVar_list[3]) - let updatedAt: Int64? = nilOrValue(pigeonVar_list[4]) - let width: Int64? = nilOrValue(pigeonVar_list[5]) - let height: Int64? = nilOrValue(pigeonVar_list[6]) - let durationInSeconds = pigeonVar_list[7] as! Int64 - let orientation = pigeonVar_list[8] as! Int64 - let isFavorite = pigeonVar_list[9] as! Bool - - return PlatformAsset( - id: id, - name: name, - type: type, - createdAt: createdAt, - updatedAt: updatedAt, - width: width, - height: height, - durationInSeconds: durationInSeconds, - orientation: orientation, - isFavorite: isFavorite - ) - } - func toList() -> [Any?] { - return [ - id, - name, - type, - createdAt, - updatedAt, - width, - height, - durationInSeconds, - orientation, - isFavorite, - ] - } - static func == (lhs: PlatformAsset, rhs: PlatformAsset) -> Bool { - return deepEqualsMessages(lhs.toList(), rhs.toList()) } - func hash(into hasher: inout Hasher) { - deepHashMessages(value: toList(), hasher: &hasher) - } -} - -/// Generated class from Pigeon that represents data sent in messages. -struct PlatformAlbum: Hashable { - var id: String - var name: String - var updatedAt: Int64? = nil - var isCloud: Bool - var assetCount: Int64 - - - // swift-format-ignore: AlwaysUseLowerCamelCase - static func fromList(_ pigeonVar_list: [Any?]) -> PlatformAlbum? { - let id = pigeonVar_list[0] as! String - let name = pigeonVar_list[1] as! String - let updatedAt: Int64? = nilOrValue(pigeonVar_list[2]) - let isCloud = pigeonVar_list[3] as! Bool - let assetCount = pigeonVar_list[4] as! Int64 - - return PlatformAlbum( - id: id, - name: name, - updatedAt: updatedAt, - isCloud: isCloud, - assetCount: assetCount - ) - } - func toList() -> [Any?] { - return [ - id, - name, - updatedAt, - isCloud, - assetCount, - ] - } - static func == (lhs: PlatformAlbum, rhs: PlatformAlbum) -> Bool { - return deepEqualsMessages(lhs.toList(), rhs.toList()) } - func hash(into hasher: inout Hasher) { - deepHashMessages(value: toList(), hasher: &hasher) - } -} - -/// Generated class from Pigeon that represents data sent in messages. -struct SyncDelta: Hashable { - var hasChanges: Bool - var updates: [PlatformAsset] - var deletes: [String] - var assetAlbums: [String: [String]] - - - // swift-format-ignore: AlwaysUseLowerCamelCase - static func fromList(_ pigeonVar_list: [Any?]) -> SyncDelta? { - let hasChanges = pigeonVar_list[0] as! Bool - let updates = pigeonVar_list[1] as! [PlatformAsset] - let deletes = pigeonVar_list[2] as! [String] - let assetAlbums = pigeonVar_list[3] as! [String: [String]] - - return SyncDelta( - hasChanges: hasChanges, - updates: updates, - deletes: deletes, - assetAlbums: assetAlbums - ) - } - func toList() -> [Any?] { - return [ - hasChanges, - updates, - deletes, - assetAlbums, - ] - } - static func == (lhs: SyncDelta, rhs: SyncDelta) -> Bool { - return deepEqualsMessages(lhs.toList(), rhs.toList()) } - func hash(into hasher: inout Hasher) { - deepHashMessages(value: toList(), hasher: &hasher) - } -} - -/// Generated class from Pigeon that represents data sent in messages. -struct HashResult: Hashable { - var assetId: String - var error: String? = nil - var hash: String? = nil - - - // swift-format-ignore: AlwaysUseLowerCamelCase - static func fromList(_ pigeonVar_list: [Any?]) -> HashResult? { - let assetId = pigeonVar_list[0] as! String - let error: String? = nilOrValue(pigeonVar_list[1]) - let hash: String? = nilOrValue(pigeonVar_list[2]) - - return HashResult( - assetId: assetId, - error: error, - hash: hash - ) - } - func toList() -> [Any?] { - return [ - assetId, - error, - hash, - ] - } - static func == (lhs: HashResult, rhs: HashResult) -> 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 { - case 129: - return PlatformAsset.fromList(self.readValue() as! [Any?]) - case 130: - return PlatformAlbum.fromList(self.readValue() as! [Any?]) - case 131: - return SyncDelta.fromList(self.readValue() as! [Any?]) - case 132: - return HashResult.fromList(self.readValue() as! [Any?]) - default: - return super.readValue(ofType: type) - } - } -} - -private class MessagesPigeonCodecWriter: FlutterStandardWriter { - override func writeValue(_ value: Any) { - if let value = value as? PlatformAsset { - super.writeByte(129) - super.writeValue(value.toList()) - } else if let value = value as? PlatformAlbum { - super.writeByte(130) - super.writeValue(value.toList()) - } else if let value = value as? SyncDelta { - super.writeByte(131) - super.writeValue(value.toList()) - } else if let value = value as? HashResult { - super.writeByte(132) - super.writeValue(value.toList()) - } else { - super.writeValue(value) - } - } -} - -private class MessagesPigeonCodecReaderWriter: FlutterStandardReaderWriter { - override func reader(with data: Data) -> FlutterStandardReader { - return MessagesPigeonCodecReader(data: data) - } - - override func writer(with data: NSMutableData) -> FlutterStandardWriter { - return MessagesPigeonCodecWriter(data: data) - } -} - -class MessagesPigeonCodec: FlutterStandardMessageCodec, @unchecked Sendable { - static let shared = MessagesPigeonCodec(readerWriter: MessagesPigeonCodecReaderWriter()) -} - - -/// Generated protocol from Pigeon that represents a handler of messages from Flutter. -protocol NativeSyncApi { - func shouldFullSync() throws -> Bool - func getMediaChanges() throws -> SyncDelta - func checkpointSync() throws - func clearSyncCheckpoint() throws - func getAssetIdsForAlbum(albumId: String) throws -> [String] - func getAlbums() throws -> [PlatformAlbum] - func getAssetsCountSince(albumId: String, timestamp: Int64) throws -> Int64 - func getAssetsForAlbum(albumId: String, updatedTimeCond: Int64?) throws -> [PlatformAsset] - func hashAssets(assetIds: [String], allowNetworkAccess: Bool, completion: @escaping (Result<[HashResult], Error>) -> Void) - func cancelHashing() throws - func getTrashedAssets() throws -> [String: [PlatformAsset]] -} - -/// Generated setup class from Pigeon to handle messages through the `binaryMessenger`. -class NativeSyncApiSetup { - static var codec: FlutterStandardMessageCodec { MessagesPigeonCodec.shared } - /// Sets up an instance of `NativeSyncApi` to handle messages through the `binaryMessenger`. - static func setUp(binaryMessenger: FlutterBinaryMessenger, api: NativeSyncApi?, messageChannelSuffix: String = "") { - let channelSuffix = messageChannelSuffix.count > 0 ? ".\(messageChannelSuffix)" : "" - #if os(iOS) - let taskQueue = binaryMessenger.makeBackgroundTaskQueue?() - #else - let taskQueue: FlutterTaskQueue? = nil - #endif - let shouldFullSyncChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.NativeSyncApi.shouldFullSync\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec) - if let api = api { - shouldFullSyncChannel.setMessageHandler { _, reply in - do { - let result = try api.shouldFullSync() - reply(wrapResult(result)) - } catch { - reply(wrapError(error)) - } - } - } else { - shouldFullSyncChannel.setMessageHandler(nil) - } - let getMediaChangesChannel = taskQueue == nil - ? FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.NativeSyncApi.getMediaChanges\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec) - : FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.NativeSyncApi.getMediaChanges\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec, taskQueue: taskQueue) - if let api = api { - getMediaChangesChannel.setMessageHandler { _, reply in - do { - let result = try api.getMediaChanges() - reply(wrapResult(result)) - } catch { - reply(wrapError(error)) - } - } - } else { - getMediaChangesChannel.setMessageHandler(nil) - } - let checkpointSyncChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.NativeSyncApi.checkpointSync\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec) - if let api = api { - checkpointSyncChannel.setMessageHandler { _, reply in - do { - try api.checkpointSync() - reply(wrapResult(nil)) - } catch { - reply(wrapError(error)) - } - } - } else { - checkpointSyncChannel.setMessageHandler(nil) - } - let clearSyncCheckpointChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.NativeSyncApi.clearSyncCheckpoint\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec) - if let api = api { - clearSyncCheckpointChannel.setMessageHandler { _, reply in - do { - try api.clearSyncCheckpoint() - reply(wrapResult(nil)) - } catch { - reply(wrapError(error)) - } - } - } else { - clearSyncCheckpointChannel.setMessageHandler(nil) - } - let getAssetIdsForAlbumChannel = taskQueue == nil - ? FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.NativeSyncApi.getAssetIdsForAlbum\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec) - : FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.NativeSyncApi.getAssetIdsForAlbum\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec, taskQueue: taskQueue) - if let api = api { - getAssetIdsForAlbumChannel.setMessageHandler { message, reply in - let args = message as! [Any?] - let albumIdArg = args[0] as! String - do { - let result = try api.getAssetIdsForAlbum(albumId: albumIdArg) - reply(wrapResult(result)) - } catch { - reply(wrapError(error)) - } - } - } else { - getAssetIdsForAlbumChannel.setMessageHandler(nil) - } - let getAlbumsChannel = taskQueue == nil - ? FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.NativeSyncApi.getAlbums\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec) - : FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.NativeSyncApi.getAlbums\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec, taskQueue: taskQueue) - if let api = api { - getAlbumsChannel.setMessageHandler { _, reply in - do { - let result = try api.getAlbums() - reply(wrapResult(result)) - } catch { - reply(wrapError(error)) - } - } - } else { - getAlbumsChannel.setMessageHandler(nil) - } - let getAssetsCountSinceChannel = taskQueue == nil - ? FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.NativeSyncApi.getAssetsCountSince\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec) - : FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.NativeSyncApi.getAssetsCountSince\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec, taskQueue: taskQueue) - if let api = api { - getAssetsCountSinceChannel.setMessageHandler { message, reply in - let args = message as! [Any?] - let albumIdArg = args[0] as! String - let timestampArg = args[1] as! Int64 - do { - let result = try api.getAssetsCountSince(albumId: albumIdArg, timestamp: timestampArg) - reply(wrapResult(result)) - } catch { - reply(wrapError(error)) - } - } - } else { - getAssetsCountSinceChannel.setMessageHandler(nil) - } - let getAssetsForAlbumChannel = taskQueue == nil - ? FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.NativeSyncApi.getAssetsForAlbum\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec) - : FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.NativeSyncApi.getAssetsForAlbum\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec, taskQueue: taskQueue) - if let api = api { - getAssetsForAlbumChannel.setMessageHandler { message, reply in - let args = message as! [Any?] - let albumIdArg = args[0] as! String - let updatedTimeCondArg: Int64? = nilOrValue(args[1]) - do { - let result = try api.getAssetsForAlbum(albumId: albumIdArg, updatedTimeCond: updatedTimeCondArg) - reply(wrapResult(result)) - } catch { - reply(wrapError(error)) - } - } - } else { - getAssetsForAlbumChannel.setMessageHandler(nil) - } - let hashAssetsChannel = taskQueue == nil - ? FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.NativeSyncApi.hashAssets\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec) - : FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.NativeSyncApi.hashAssets\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec, taskQueue: taskQueue) - if let api = api { - hashAssetsChannel.setMessageHandler { message, reply in - let args = message as! [Any?] - let assetIdsArg = args[0] as! [String] - let allowNetworkAccessArg = args[1] as! Bool - api.hashAssets(assetIds: assetIdsArg, allowNetworkAccess: allowNetworkAccessArg) { result in - switch result { - case .success(let res): - reply(wrapResult(res)) - case .failure(let error): - reply(wrapError(error)) - } - } - } - } else { - hashAssetsChannel.setMessageHandler(nil) - } - let cancelHashingChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.NativeSyncApi.cancelHashing\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec) - if let api = api { - cancelHashingChannel.setMessageHandler { _, reply in - do { - try api.cancelHashing() - reply(wrapResult(nil)) - } catch { - reply(wrapError(error)) - } - } - } else { - cancelHashingChannel.setMessageHandler(nil) - } - let getTrashedAssetsChannel = taskQueue == nil - ? FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.NativeSyncApi.getTrashedAssets\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec) - : FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.NativeSyncApi.getTrashedAssets\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec, taskQueue: taskQueue) - if let api = api { - getTrashedAssetsChannel.setMessageHandler { _, reply in - do { - let result = try api.getTrashedAssets() - reply(wrapResult(result)) - } catch { - reply(wrapError(error)) - } - } - } else { - getTrashedAssetsChannel.setMessageHandler(nil) - } - } -} diff --git a/mobile/ios/Runner/Sync/MessagesImpl.swift b/mobile/ios/Runner/Sync/MessagesImpl.swift index 03493f57ca..459837f211 100644 --- a/mobile/ios/Runner/Sync/MessagesImpl.swift +++ b/mobile/ios/Runner/Sync/MessagesImpl.swift @@ -1,393 +1,580 @@ -import Photos +import Algorithms import CryptoKit +import Foundation +import Photos +import SQLiteData +import os.log -struct AssetWrapper: Hashable, Equatable { - let asset: PlatformAsset - - init(with asset: PlatformAsset) { - self.asset = asset - } - - func hash(into hasher: inout Hasher) { - hasher.combine(self.asset.id) - } - - static func == (lhs: AssetWrapper, rhs: AssetWrapper) -> Bool { - return lhs.asset.id == rhs.asset.id - } +extension Notification.Name { + static let localSyncDidComplete = Notification.Name("localSyncDidComplete") } -class NativeSyncApiImpl: ImmichPlugin, NativeSyncApi, FlutterPlugin { - static let name = "NativeSyncApi" +enum LocalSyncError: Error { + case photoAccessDenied, assetUpsertFailed, noChangeToken, unsupportedOS + case unsupportedAssetType(Int) +} - static func register(with registrar: any FlutterPluginRegistrar) { - let instance = NativeSyncApiImpl() - NativeSyncApiSetup.setUp(binaryMessenger: registrar.messenger(), api: instance) - registrar.publish(instance) - } +enum SyncConfig { + static let albumTypes: [PHAssetCollectionType] = [.album, .smartAlbum] + static let batchSize: Int = 5000 + static let changeTokenKey = "immich:changeToken" + static let recoveredAlbumSubType = 1_000_000_219 + static let sortDescriptors = [NSSortDescriptor(key: "localIdentifier", ascending: true)] +} - func detachFromEngine(for registrar: any FlutterPluginRegistrar) { - super.detachFromEngine() - } +class LocalSyncService { + private static let dateFormatter = ISO8601DateFormatter() private let defaults: UserDefaults - private let changeTokenKey = "immich:changeToken" - private let albumTypes: [PHAssetCollectionType] = [.album, .smartAlbum] - private let recoveredAlbumSubType = 1000000219 + private let db: DatabasePool + private let photoLibrary: PhotoLibraryProvider + private let logger = Logger(subsystem: "com.immich.mobile", category: "LocalSync") - 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) { + init(db: DatabasePool, photoLibrary: PhotoLibraryProvider, with defaults: UserDefaults = .standard) { self.defaults = defaults + self.db = db + self.photoLibrary = photoLibrary } @available(iOS 16, *) private func getChangeToken() -> PHPersistentChangeToken? { - guard let data = defaults.data(forKey: changeTokenKey) else { - return nil - } - return try? NSKeyedUnarchiver.unarchivedObject(ofClass: PHPersistentChangeToken.self, from: data) + defaults.data(forKey: SyncConfig.changeTokenKey) + .flatMap { try? NSKeyedUnarchiver.unarchivedObject(ofClass: PHPersistentChangeToken.self, from: $0) } } @available(iOS 16, *) - private func saveChangeToken(token: PHPersistentChangeToken) -> Void { + private func saveChangeToken(token: PHPersistentChangeToken) { guard let data = try? NSKeyedArchiver.archivedData(withRootObject: token, requiringSecureCoding: true) else { return } - defaults.set(data, forKey: changeTokenKey) + defaults.set(data, forKey: SyncConfig.changeTokenKey) } - func clearSyncCheckpoint() -> Void { - defaults.removeObject(forKey: changeTokenKey) + func clearSyncCheckpoint() { + defaults.removeObject(forKey: SyncConfig.changeTokenKey) } func checkpointSync() { - guard #available(iOS 16, *) else { - return - } - saveChangeToken(token: PHPhotoLibrary.shared().currentChangeToken) + guard #available(iOS 16, *) else { return } + saveChangeToken(token: photoLibrary.currentChangeToken) } - func shouldFullSync() -> Bool { - guard #available(iOS 16, *), - PHPhotoLibrary.authorizationStatus(for: .readWrite) == .authorized, - let storedToken = getChangeToken() else { - // When we do not have access to photo library, older iOS version or No token available, fallback to full sync - return true + func sync(full: Bool = false) async throws { + let start = Date() + defer { logger.info("Sync completed in \(Int(Date().timeIntervalSince(start) * 1000))ms") } + + guard !full, !shouldFullSync(), let delta = try? getMediaChanges(), delta.hasChanges + else { + logger.debug("Full sync: \(full ? "user requested" : "required")") + return try await fullSync() } - guard let _ = try? PHPhotoLibrary.shared().fetchPersistentChanges(since: storedToken) else { - // Cannot fetch persistent changes - return true + logger.debug("Delta sync: +\(delta.updates.count) -\(delta.deletes.count)") + + let albumFetchOptions = PHFetchOptions() + albumFetchOptions.predicate = NSPredicate(format: "assetCollectionSubtype != %d", SyncConfig.recoveredAlbumSubType) + + try await db.write { conn in + try #sql("pragma temp_store = 2").execute(conn) + try #sql("create temp table current_albums(id text primary key) without rowid").execute(conn) + + var cloudAlbums = [PHAssetCollection]() + for type in SyncConfig.albumTypes { + photoLibrary.fetchAlbums(with: type, subtype: .any, options: albumFetchOptions) + .enumerateObjects { album, _, _ in + try? CurrentAlbum.insert { CurrentAlbum(id: album.localIdentifier) }.execute(conn) + try? upsertAlbum(album, conn: conn) + if album.isCloud { + cloudAlbums.append(album) + } + } + } + + try LocalAlbum.delete().where { localAlbum in + localAlbum.backupSelection.eq(BackupSelection.none) && !CurrentAlbum.where { $0.id == localAlbum.id }.exists() + }.execute(conn) + + for asset in delta.updates { + try upsertAsset(asset, conn: conn) + } + + if !delta.deletes.isEmpty { + try LocalAsset.delete().where { $0.id.in(delta.deletes) }.execute(conn) + } + + try self.updateAssetAlbumLinks(delta.assetAlbums, conn: conn) } + // On iOS, we need to full sync albums that are marked as cloud as the delta sync + // does not include changes for cloud albums. If ignoreIcloudAssets is enabled, + // remove the albums from the local database from the previous sync + if !cloudAlbums.isEmpty { + try await syncCloudAlbums(cloudAlbums) + } + + checkpointSync() + } + + private func fullSync() async throws { + let start = Date() + defer { logger.info("Full sync completed in \(Int(Date().timeIntervalSince(start) * 1000))ms") } + + let dbAlbumIds = try await db.read { conn in + try LocalAlbum.all.select(\.id).order { $0.id }.fetchAll(conn) + } + + let albumFetchOptions = PHFetchOptions() + albumFetchOptions.predicate = NSPredicate(format: "assetCollectionSubtype != %d", SyncConfig.recoveredAlbumSubType) + albumFetchOptions.sortDescriptors = SyncConfig.sortDescriptors + + let albums = photoLibrary.fetchAlbums(with: .album, subtype: .any, options: albumFetchOptions) + let smartAlbums = photoLibrary.fetchAlbums(with: .smartAlbum, subtype: .any, options: albumFetchOptions) + + try await withThrowingTaskGroup(of: Void.self) { group in + var dbIndex = 0 + var albumIndex = 0 + var smartAlbumIndex = 0 + + // Three-pointer merge: dbAlbumIds, albums, smartAlbums + while albumIndex < albums.count || smartAlbumIndex < smartAlbums.count { + let currentAlbum = albumIndex < albums.count ? albums.object(at: albumIndex) : nil + let currentSmartAlbum = smartAlbumIndex < smartAlbums.count ? smartAlbums.object(at: smartAlbumIndex) : nil + + let useRegular = + currentSmartAlbum == nil + || (currentAlbum != nil && currentAlbum!.localIdentifier < currentSmartAlbum!.localIdentifier) + + let nextAlbum = useRegular ? currentAlbum! : currentSmartAlbum! + let deviceId = nextAlbum.localIdentifier + + while dbIndex < dbAlbumIds.count && dbAlbumIds[dbIndex] < deviceId { + let albumToRemove = dbAlbumIds[dbIndex] + group.addTask { try await self.removeAlbum(albumId: albumToRemove) } + dbIndex += 1 + } + + if dbIndex < dbAlbumIds.count && dbAlbumIds[dbIndex] == deviceId { + group.addTask { try await self.syncAlbum(albumId: deviceId, deviceAlbum: nextAlbum) } + dbIndex += 1 + } else { + group.addTask { try await self.addAlbum(nextAlbum) } + } + + if useRegular { + albumIndex += 1 + } else { + smartAlbumIndex += 1 + } + } + + // Remove any remaining DB albums + while dbIndex < dbAlbumIds.count { + let albumToRemove = dbAlbumIds[dbIndex] + group.addTask { try await self.removeAlbum(albumId: albumToRemove) } + dbIndex += 1 + } + + try await group.waitForAll() + } + + checkpointSync() + } + + private func shouldFullSync() -> Bool { + guard #available(iOS 16, *), photoLibrary.isAuthorized, let token = getChangeToken(), + (try? photoLibrary.fetchPersistentChanges(since: token)) != nil + else { + return true + } return false } - func getAlbums() throws -> [PlatformAlbum] { - var albums: [PlatformAlbum] = [] + private func addAlbum(_ album: PHAssetCollection) async throws { + let options = PHFetchOptions() + options.includeHiddenAssets = false - albumTypes.forEach { type in - let collections = PHAssetCollection.fetchAssetCollections(with: type, subtype: .any, options: nil) - for i in 0.. %@ OR modificationDate > %@", date, date) + } + + let result = photoLibrary.fetchAssets(in: album, options: options) + try await self.db.write { conn in + try upsertStreamedAssets(result: result, albumId: album.localIdentifier, conn: conn) } - return albums.sorted { $0.id < $1.id } } - func getMediaChanges() throws -> SyncDelta { - guard #available(iOS 16, *) else { - throw PigeonError(code: "UNSUPPORTED_OS", message: "This feature requires iOS 16 or later.", details: nil) + private func upsertStreamedAssets(result: PHFetchResult, albumId: String, conn: Database) throws { + result.enumerateObjects { asset, _, stop in + do { + try self.upsertAsset(asset, conn: conn) + try self.linkAsset(asset.localIdentifier, toAlbum: albumId, conn: conn) + } catch { + stop.pointee = true + } + } + if let error = conn.lastErrorMessage { + throw LocalSyncError.assetUpsertFailed + } + } + + /// Remove all assets that are only in this particular album. + /// We cannot remove all assets in the album because they might be in other albums in iOS. + private func removeAlbum(albumId: String) async throws { + try await db.write { conn in + try LocalAsset.delete().where { $0.id.in(LocalAlbumAsset.uniqueAssetIds(albumId: albumId)) }.execute(conn) + try LocalAlbum.delete() + .where { $0.id.eq(albumId) && $0.backupSelection.eq(BackupSelection.none) } + .execute(conn) + } + } + + private func syncAlbum(albumId: String, deviceAlbum: PHAssetCollection) async throws { + let dbAlbum = try await db.read { conn in + try LocalAlbum.all.where { $0.id.eq(albumId) }.fetchOne(conn) + } + guard let dbAlbum else { return try await addAlbum(deviceAlbum) } + + // Check if unchanged + guard dbAlbum.name != deviceAlbum.localizedTitle || dbAlbum.updatedAt != deviceAlbum.updatedAt + else { return } + + try await fullDiffAlbum(dbAlbum: dbAlbum, deviceAlbum: deviceAlbum) + } + + private func fullDiffAlbum(dbAlbum: LocalAlbum, deviceAlbum: PHAssetCollection) async throws { + let options = PHFetchOptions() + options.includeHiddenAssets = false + let date = dbAlbum.updatedAt as NSDate + options.predicate = NSPredicate(format: "creationDate > %@ OR modificationDate > %@", date, date) + options.sortDescriptors = SyncConfig.sortDescriptors + + var deviceAssetIds: [String] = [] + let result = photoLibrary.fetchAssets(in: deviceAlbum, options: options) + result.enumerateObjects { asset, _, _ in + deviceAssetIds.append(asset.localIdentifier) } - guard PHPhotoLibrary.authorizationStatus(for: .readWrite) == .authorized else { - throw PigeonError(code: "NO_AUTH", message: "No photo library access", details: nil) + let dbAssetIds = try await db.read { conn in + try LocalAlbumAsset.all + .where { $0.id.albumId.eq(dbAlbum.id) } + .select(\.id.assetId) + .order { $0.id.assetId } + .fetchAll(conn) + } + + let (toFetch, toDelete) = diffSortedArrays(dbAssetIds, deviceAssetIds) + guard !toFetch.isEmpty || !toDelete.isEmpty else { return } + + logger.debug("Syncing \(deviceAlbum.localizedTitle ?? "album"): +\(toFetch.count) -\(toDelete.count)") + + try await db.write { conn in + try self.updateAlbum(deviceAlbum, conn: conn) + } + + for batch in toFetch.chunks(ofCount: SyncConfig.batchSize) { + let options = PHFetchOptions() + options.includeHiddenAssets = false + let result = photoLibrary.fetchAssets(withLocalIdentifiers: Array(batch), options: options) + + try await db.write { conn in + try upsertStreamedAssets(result: result, albumId: deviceAlbum.localIdentifier, conn: conn) + } + } + + guard !toDelete.isEmpty else { return } + + let uniqueAssetIds = try await db.read { conn in + return try LocalAlbumAsset.uniqueAssetIds(albumId: deviceAlbum.localIdentifier).fetchAll(conn) + } + + // Delete unique assets and unlink others + var toDeleteSet = Set(toDelete) + let uniqueIds = toDeleteSet.intersection(uniqueAssetIds) + toDeleteSet.subtract(uniqueIds) + let toUnlink = toDeleteSet + guard !toDeleteSet.isEmpty || !toUnlink.isEmpty else { return } + try await db.write { conn in + if !uniqueIds.isEmpty { + try LocalAsset.delete().where { $0.id.in(Array(uniqueIds)) }.execute(conn) + } + + if !toUnlink.isEmpty { + try LocalAlbumAsset.delete() + .where { $0.id.assetId.in(Array(toUnlink)) && $0.id.albumId.eq(deviceAlbum.localIdentifier) } + .execute(conn) + } + } + } + + private func syncCloudAlbums(_ albums: [PHAssetCollection]) async throws { + try await withThrowingTaskGroup(of: Void.self) { group in + for album in albums { + group.addTask { + let dbAlbum = try await self.db.read { conn in + try LocalAlbum.all.where { $0.id.eq(album.localIdentifier) }.fetchOne(conn) + } + + guard let dbAlbum else { return } + + let deviceIds = try self.getAssetIdsForAlbum(albumId: album.localIdentifier) + let dbIds = try await self.db.read { conn in + try LocalAlbumAsset.all + .where { $0.id.albumId.eq(album.localIdentifier) } + .select(\.id.assetId) + .order { $0.id.assetId } + .fetchAll(conn) + } + + guard deviceIds != dbIds else { return } + try await self.fullDiffAlbum(dbAlbum: dbAlbum, deviceAlbum: album) + } + } + + try await group.waitForAll() + } + } + + private func upsertAlbum(_ album: PHAssetCollection, conn: Database) throws { + try LocalAlbum.insert { + LocalAlbum( + id: album.localIdentifier, + backupSelection: .none, + linkedRemoteAlbumId: nil, + marker_: nil, + name: album.localizedTitle ?? "", + isIosSharedAlbum: album.isCloud, + updatedAt: album.updatedAt ?? Date() + ) + } onConflict: { + $0.id + } doUpdate: { old, new in + old.name = new.name + old.updatedAt = new.updatedAt + old.isIosSharedAlbum = new.isIosSharedAlbum + old.marker_ = new.marker_ + }.execute(conn) + } + + private func updateAlbum(_ album: PHAssetCollection, conn: Database) throws { + try LocalAlbum.update { row in + row.name = album.localizedTitle ?? "" + row.updatedAt = album.updatedAt ?? Date() + row.isIosSharedAlbum = album.isCloud + }.where { $0.id.eq(album.localIdentifier) }.execute(conn) + } + + private func upsertAsset(_ asset: PHAsset, conn: Database) throws { + guard let assetType = AssetType(rawValue: asset.mediaType.rawValue) else { + throw LocalSyncError.unsupportedAssetType(asset.mediaType.rawValue) + } + let dateStr = Self.dateFormatter.string(from: asset.creationDate ?? Date()) + + try LocalAsset.insert { + LocalAsset( + id: asset.localIdentifier, + checksum: nil, + createdAt: dateStr, + durationInSeconds: Int64(asset.duration), + height: asset.pixelHeight, + isFavorite: asset.isFavorite, + name: asset.title, + orientation: "0", + type: assetType, + updatedAt: dateStr, + width: asset.pixelWidth + ) + } onConflict: { + $0.id + } doUpdate: { old, new in + old.name = new.name + old.type = new.type + old.updatedAt = new.updatedAt + old.width = new.width + old.height = new.height + old.durationInSeconds = new.durationInSeconds + old.isFavorite = new.isFavorite + old.orientation = new.orientation + }.execute(conn) + } + + private func linkAsset(_ assetId: String, toAlbum albumId: String, conn: Database) throws { + try LocalAlbumAsset.insert { + LocalAlbumAsset(id: LocalAlbumAsset.ID(assetId: assetId, albumId: albumId), marker_: nil) + } onConflict: { + ($0.id.assetId, $0.id.albumId) + }.execute(conn) + } + + private func updateAssetAlbumLinks(_ assetAlbums: [String: [String]], conn: Database) throws { + for (assetId, albumIds) in assetAlbums { + // Delete old links not in the new set + try LocalAlbumAsset.delete() + .where { $0.id.assetId.eq(assetId) && !$0.id.albumId.in(albumIds) } + .execute(conn) + + // Insert new links + for albumId in albumIds { + try LocalAlbumAsset.insert { + LocalAlbumAsset(id: LocalAlbumAsset.ID(assetId: assetId, albumId: albumId), marker_: nil) + } onConflict: { + ($0.id.assetId, $0.id.albumId) + }.execute(conn) + } + } + } + + private func fetchAssetsByIds(_ ids: [String]) throws -> [PHAsset] { + let options = PHFetchOptions() + options.includeHiddenAssets = false + let result = photoLibrary.fetchAssets(withLocalIdentifiers: ids, options: options) + + var assets: [PHAsset] = [] + assets.reserveCapacity(ids.count) + result.enumerateObjects { asset, _, _ in assets.append(asset) } + + return assets + } + + private func getMediaChanges() throws -> NativeSyncDelta { + guard #available(iOS 16, *) else { + throw LocalSyncError.unsupportedOS + } + + guard photoLibrary.isAuthorized else { + throw LocalSyncError.photoAccessDenied } 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) + throw LocalSyncError.noChangeToken } - let currentToken = PHPhotoLibrary.shared().currentChangeToken - if storedToken == currentToken { - return SyncDelta(hasChanges: false, updates: [], deletes: [], assetAlbums: [:]) + let currentToken = photoLibrary.currentChangeToken + guard storedToken != currentToken else { + return NativeSyncDelta(hasChanges: false, updates: [], deletes: [], assetAlbums: [:]) } - do { - let changes = try PHPhotoLibrary.shared().fetchPersistentChanges(since: storedToken) + let changes = try photoLibrary.fetchPersistentChanges(since: storedToken) + var updatedIds = Set() + var deletedIds = Set() - 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 [:] + for change in changes { + guard let details = try? change.changeDetails(for: PHObjectType.asset) else { continue } + updatedIds.formUnion(details.updatedLocalIdentifiers.union(details.insertedLocalIdentifiers)) + deletedIds.formUnion(details.deletedLocalIdentifiers) } - var albumAssets: [String: [String]] = [:] - - for type in albumTypes { - let collections = PHAssetCollection.fetchAssetCollections(with: type, subtype: .any, options: nil) - collections.enumerateObjects { (album, _, _) in - let options = PHFetchOptions() - options.predicate = NSPredicate(format: "localIdentifier IN %@", assets.map(\.id)) - options.includeHiddenAssets = false - let result = self.getAssetsFromAlbum(in: album, options: options) - result.enumerateObjects { (asset, _, _) in - albumAssets[asset.localIdentifier, default: []].append(album.localIdentifier) - } - } - } - return albumAssets - } - - func getAssetIdsForAlbum(albumId: String) throws -> [String] { - let collections = PHAssetCollection.fetchAssetCollections(withLocalIdentifiers: [albumId], options: nil) - guard let album = collections.firstObject else { - return [] + guard !updatedIds.isEmpty || !deletedIds.isEmpty else { + return NativeSyncDelta(hasChanges: false, updates: [], deletes: [], assetAlbums: [:]) } - var ids: [String] = [] + let updatedIdArray = Array(updatedIds) let options = PHFetchOptions() options.includeHiddenAssets = false - let assets = getAssetsFromAlbum(in: album, options: options) - assets.enumerateObjects { (asset, _, _) in + let result = photoLibrary.fetchAssets(withLocalIdentifiers: updatedIdArray, options: options) + + var updates: [PHAsset] = [] + result.enumerateObjects { asset, _, _ in updates.append(asset) } + + return NativeSyncDelta( + hasChanges: true, + updates: updates, + deletes: Array(deletedIds), + assetAlbums: buildAssetAlbumsMap(assetIds: updatedIdArray) + ) + } + + private func buildAssetAlbumsMap(assetIds: [String]) -> [String: [String]] { + guard !assetIds.isEmpty else { return [:] } + + var result: [String: [String]] = [:] + let options = PHFetchOptions() + options.predicate = NSPredicate(format: "localIdentifier IN %@", assetIds) + options.includeHiddenAssets = false + + for type in SyncConfig.albumTypes { + photoLibrary.fetchAssetCollections(with: type, subtype: .any, options: nil) + .enumerateObjects { album, _, _ in + photoLibrary.fetchAssets(in: album, options: options) + .enumerateObjects { asset, _, _ in + result[asset.localIdentifier, default: []].append(album.localIdentifier) + } + } + } + + return result + } + + private func getAssetIdsForAlbum(albumId: String) throws -> [String] { + guard let album = photoLibrary.fetchAssetCollection(albumId: albumId, options: nil) else { return [] } + + let options = PHFetchOptions() + options.includeHiddenAssets = false + options.sortDescriptors = [NSSortDescriptor(key: "localIdentifier", ascending: true)] + + var ids: [String] = [] + photoLibrary.fetchAssets(in: album, options: options).enumerateObjects { asset, _, _ in ids.append(asset.localIdentifier) } 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) - options.includeHiddenAssets = false - 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 [] - } + private func getAssetsForAlbum(albumId: String, updatedTimeCond: Int64?) throws -> [PHAsset] { + guard let album = photoLibrary.fetchAssetCollection(albumId: albumId, options: nil) 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) + if let timestamp = updatedTimeCond { + let date = Date(timeIntervalSince1970: TimeInterval(timestamp)) + options.predicate = NSPredicate( + format: "creationDate > %@ OR modificationDate > %@", + date as NSDate, + date as NSDate + ) } - let result = getAssetsFromAlbum(in: album, options: options) - if(result.count == 0) { - return [] - } + let result = photoLibrary.fetchAssets(in: album, options: options) + var assets: [PHAsset] = [] + result.enumerateObjects { asset, _, _ in assets.append(asset) } - 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() - hashTask = nil - } - hashTask = Task { [weak self] in - var missingAssetIds = Set(assetIds) - var assets = [PHAsset]() - assets.reserveCapacity(assetIds.count) - PHAsset.fetchAssets(withLocalIdentifiers: assetIds, options: nil).enumerateObjects { (asset, _, stop) in - if Task.isCancelled { - stop.pointee = true - return - } - 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) - for asset in assets { - if Task.isCancelled { - return self?.completeWhenActive(for: completion, with: Self.hashCancelled) - } - taskGroup.addTask { - guard let self = self else { return nil } - 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? - } - let requestRef = RequestRef() - return await withTaskCancellationHandler(operation: { - 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, - dataReceivedHandler: { data in - hasher.update(data: data) - }, - completionHandler: { error in - let result: HashResult? = switch (error) { - case let e as PHPhotosError where e.code == .userCancelled: nil - case let .some(e): HashResult( - assetId: asset.localIdentifier, - error: "Failed to hash asset: \(e.localizedDescription)", - hash: nil - ) - case .none: - HashResult( - assetId: asset.localIdentifier, - error: nil, - hash: Data(hasher.finalize()).base64EncodedString() - ) - } - continuation.resume(returning: result) - } - ) - } - }, onCancel: { - guard let requestId = requestRef.id else { return } - 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) { - return PHAsset.fetchAssets(with: options) - } else { - return PHAsset.fetchAssets(in: album, options: options) - } - } +} + +func diffSortedArrays(_ a: [T], _ b: [T]) -> (toAdd: [T], toRemove: [T]) { + var toAdd: [T] = [] + var toRemove: [T] = [] + var i = 0 + var j = 0 + + while i < a.count && j < b.count { + if a[i] < b[j] { + toRemove.append(a[i]) + i += 1 + } else if b[j] < a[i] { + toAdd.append(b[j]) + j += 1 + } else { + i += 1 + j += 1 + } + } + + toRemove.append(contentsOf: a[i...]) + toAdd.append(contentsOf: b[j...]) + + return (toAdd, toRemove) +} + +private struct NativeSyncDelta: Hashable { + var hasChanges: Bool + var updates: [PHAsset] + var deletes: [String] + var assetAlbums: [String: [String]] +} + +/// Temp table to avoid parameter limit for album changes. +@Table("current_albums") +private struct CurrentAlbum { + let id: String } diff --git a/mobile/ios/Runner/Sync/PHAssetExtensions.swift b/mobile/ios/Runner/Sync/PHAssetExtensions.swift index e33881a693..2831f93cf5 100644 --- a/mobile/ios/Runner/Sync/PHAssetExtensions.swift +++ b/mobile/ios/Runner/Sync/PHAssetExtensions.swift @@ -1,21 +1,6 @@ import Photos extension PHAsset { - func toPlatformAsset() -> PlatformAsset { - return PlatformAsset( - id: localIdentifier, - name: title, - type: Int64(mediaType.rawValue), - createdAt: creationDate.map { Int64($0.timeIntervalSince1970) }, - updatedAt: modificationDate.map { Int64($0.timeIntervalSince1970) }, - width: Int64(pixelWidth), - height: Int64(pixelHeight), - durationInSeconds: Int64(duration), - orientation: 0, - isFavorite: isFavorite - ) - } - var title: String { return filename ?? originalFilename ?? "" } @@ -92,3 +77,37 @@ extension PHAsset { } } } + +extension PHAssetCollection { + private static let latestAssetOptions: PHFetchOptions = { + let options = PHFetchOptions() + options.includeHiddenAssets = false + options.sortDescriptors = [NSSortDescriptor(key: "modificationDate", ascending: false)] + options.fetchLimit = 1 + return options + }() + + var isCloud: Bool { assetCollectionSubtype == .albumCloudShared || assetCollectionSubtype == .albumMyPhotoStream } + + var updatedAt: Date? { + let result: PHFetchResult + if assetCollectionSubtype == .smartAlbumUserLibrary { + result = PHAsset.fetchAssets(with: Self.latestAssetOptions) + } else { + result = PHAsset.fetchAssets(in: self, options: Self.latestAssetOptions) + } + + return result.firstObject?.modificationDate + } + + static func fetchAssetCollection(albumId: String, options: PHFetchOptions? = nil) -> PHAssetCollection? { + let albums = PHAssetCollection.fetchAssetCollections(withLocalIdentifiers: [albumId], options: options) + return albums.firstObject + } + + static func fetchAssets(in album: PHAssetCollection, options: PHFetchOptions) -> PHFetchResult { + album.assetCollectionSubtype == .smartAlbumUserLibrary + ? PHAsset.fetchAssets(with: options) + : PHAsset.fetchAssets(in: album, options: options) + } +} diff --git a/mobile/ios/Runner/Sync/PhotoLibrary.swift b/mobile/ios/Runner/Sync/PhotoLibrary.swift new file mode 100644 index 0000000000..9c7dd3f6a8 --- /dev/null +++ b/mobile/ios/Runner/Sync/PhotoLibrary.swift @@ -0,0 +1,53 @@ +import Photos + +protocol PhotoLibraryProvider { + var isAuthorized: Bool { get } + @available(iOS 16, *) + var currentChangeToken: PHPersistentChangeToken { get } + + func fetchAlbums(sorted: Bool) -> [PHAssetCollection] + func fetchAlbums(with type: PHAssetCollectionType, subtype: PHAssetCollectionSubtype, options: PHFetchOptions?) -> PHFetchResult + func fetchAssets(in album: PHAssetCollection, options: PHFetchOptions?) -> PHFetchResult + func fetchAssets(withIdentifiers ids: [String], options: PHFetchOptions?) -> PHFetchResult + @available(iOS 16, *) + func fetchPersistentChanges(since token: PHPersistentChangeToken) throws -> PHPersistentChangeFetchResult +} + +struct PhotoLibrary: PhotoLibraryProvider { + static let shared: PhotoLibrary = .init() + + private init() {} + + func fetchAlbums(with type: PHAssetCollectionType, subtype: PHAssetCollectionSubtype, options: PHFetchOptions?) -> PHFetchResult { + PHAssetCollection.fetchAssetCollections(with: type, subtype: subtype, options: options) + } + + func fetchAssetCollection(albumId: String, options: PHFetchOptions? = nil) -> PHAssetCollection? { + let albums = PHAssetCollection.fetchAssetCollections(withLocalIdentifiers: [albumId], options: options) + return albums.firstObject + } + + func fetchAssets(in album: PHAssetCollection, options: PHFetchOptions?) -> PHFetchResult { + album.assetCollectionSubtype == .smartAlbumUserLibrary + ? PHAsset.fetchAssets(with: options) + : PHAsset.fetchAssets(in: album, options: options) + } + + func fetchAssets(withIdentifiers ids: [String], options: PHFetchOptions?) -> PHFetchResult { + PHAsset.fetchAssets(withLocalIdentifiers: ids, options: options) + } + + @available(iOS 16, *) + func fetchPersistentChanges(since token: PHPersistentChangeToken) throws -> PHPersistentChangeFetchResult { + try PHPhotoLibrary.shared().fetchPersistentChanges(since: token) + } + + @available(iOS 16, *) + var currentChangeToken: PHPersistentChangeToken { + PHPhotoLibrary.shared().currentChangeToken + } + + var isAuthorized: Bool { + PHPhotoLibrary.authorizationStatus(for: .readWrite) == .authorized + } +}