local sync poc
parent
c70146c4a8
commit
8fe932d891
|
|
@ -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" */;
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<String, Self, ()> {
|
||||
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
|
||||
|
|
|
|||
|
|
@ -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 ?? "<nil>"), details: \(details ?? "<nil>")"
|
||||
}
|
||||
}
|
||||
|
||||
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<T>(_ 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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<Void?, Error>?
|
||||
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..<collections.count {
|
||||
let album = collections.object(at: i)
|
||||
|
||||
// Ignore recovered album
|
||||
if(album.assetCollectionSubtype.rawValue == self.recoveredAlbumSubType) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let options = PHFetchOptions()
|
||||
options.sortDescriptors = [NSSortDescriptor(key: "modificationDate", ascending: false)]
|
||||
options.includeHiddenAssets = false
|
||||
|
||||
let assets = getAssetsFromAlbum(in: album, options: options)
|
||||
|
||||
let isCloud = album.assetCollectionSubtype == .albumCloudShared || album.assetCollectionSubtype == .albumMyPhotoStream
|
||||
|
||||
var domainAlbum = PlatformAlbum(
|
||||
id: album.localIdentifier,
|
||||
name: album.localizedTitle!,
|
||||
updatedAt: nil,
|
||||
isCloud: isCloud,
|
||||
assetCount: Int64(assets.count)
|
||||
)
|
||||
|
||||
if let firstAsset = assets.firstObject {
|
||||
domainAlbum.updatedAt = firstAsset.modificationDate.map { Int64($0.timeIntervalSince1970) }
|
||||
}
|
||||
|
||||
albums.append(domainAlbum)
|
||||
}
|
||||
if let timestamp = album.updatedAt {
|
||||
let date = timestamp as NSDate
|
||||
options.predicate = NSPredicate(format: "creationDate > %@ 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<PHAsset>, 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<String>()
|
||||
var deletedIds = Set<String>()
|
||||
|
||||
var updatedAssets: Set<AssetWrapper> = []
|
||||
var deletedAssets: Set<String> = []
|
||||
|
||||
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..<result.count {
|
||||
let asset = result.object(at: i)
|
||||
|
||||
// Asset wrapper only uses the id for comparison. Multiple change can contain the same asset, skip duplicate changes
|
||||
let predicate = PlatformAsset(
|
||||
id: asset.localIdentifier,
|
||||
name: "",
|
||||
type: 0,
|
||||
durationInSeconds: 0,
|
||||
orientation: 0,
|
||||
isFavorite: false
|
||||
)
|
||||
if (updatedAssets.contains(AssetWrapper(with: predicate))) {
|
||||
continue
|
||||
}
|
||||
|
||||
let domainAsset = AssetWrapper(with: asset.toPlatformAsset())
|
||||
updatedAssets.insert(domainAsset)
|
||||
}
|
||||
}
|
||||
|
||||
let updates = Array(updatedAssets.map { $0.asset })
|
||||
return SyncDelta(hasChanges: true, updates: updates, deletes: Array(deletedAssets), assetAlbums: buildAssetAlbumsMap(assets: updates))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private func buildAssetAlbumsMap(assets: Array<PlatformAsset>) -> [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<PHAsset> {
|
||||
// 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<T: Comparable & Hashable>(_ 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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 ?? "<unknown>"
|
||||
}
|
||||
|
|
@ -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<PHAsset>
|
||||
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<PHAsset> {
|
||||
album.assetCollectionSubtype == .smartAlbumUserLibrary
|
||||
? PHAsset.fetchAssets(with: options)
|
||||
: PHAsset.fetchAssets(in: album, options: options)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<PHAssetCollection>
|
||||
func fetchAssets(in album: PHAssetCollection, options: PHFetchOptions?) -> PHFetchResult<PHAsset>
|
||||
func fetchAssets(withIdentifiers ids: [String], options: PHFetchOptions?) -> PHFetchResult<PHAsset>
|
||||
@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> {
|
||||
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<PHAsset> {
|
||||
album.assetCollectionSubtype == .smartAlbumUserLibrary
|
||||
? PHAsset.fetchAssets(with: options)
|
||||
: PHAsset.fetchAssets(in: album, options: options)
|
||||
}
|
||||
|
||||
func fetchAssets(withIdentifiers ids: [String], options: PHFetchOptions?) -> PHFetchResult<PHAsset> {
|
||||
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
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue