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, ); }; };
|
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 */; };
|
FAC6F8B72D287F120078CB2F /* ShareViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = FAC6F8B52D287F120078CB2F /* ShareViewController.swift */; };
|
||||||
FAC6F8B92D287F120078CB2F /* MainInterface.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = FAC6F8B32D287F120078CB2F /* MainInterface.storyboard */; };
|
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 */; };
|
FEAFA8732E4D42F4001E47FE /* Thumbhash.swift in Sources */ = {isa = PBXBuildFile; fileRef = FEAFA8722E4D42F4001E47FE /* Thumbhash.swift */; };
|
||||||
FED3B1962E253E9B0030FD97 /* ThumbnailsImpl.swift in Sources */ = {isa = PBXBuildFile; fileRef = FED3B1942E253E9B0030FD97 /* ThumbnailsImpl.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 */; };
|
FED3B1972E253E9B0030FD97 /* Thumbnails.g.swift in Sources */ = {isa = PBXBuildFile; fileRef = FED3B1932E253E9B0030FD97 /* Thumbnails.g.swift */; };
|
||||||
|
|
@ -187,6 +188,7 @@
|
||||||
FEE084F82EC172460045228E /* SQLiteData in Frameworks */,
|
FEE084F82EC172460045228E /* SQLiteData in Frameworks */,
|
||||||
FEE084FB2EC1725A0045228E /* RawStructuredFieldValues in Frameworks */,
|
FEE084FB2EC1725A0045228E /* RawStructuredFieldValues in Frameworks */,
|
||||||
FEE084FD2EC1725A0045228E /* StructuredFieldValues in Frameworks */,
|
FEE084FD2EC1725A0045228E /* StructuredFieldValues in Frameworks */,
|
||||||
|
FE30A0D02ECF97B8007AFDD7 /* Algorithms in Frameworks */,
|
||||||
D218389C4A4C4693F141F7D1 /* Pods_Runner.framework in Frameworks */,
|
D218389C4A4C4693F141F7D1 /* Pods_Runner.framework in Frameworks */,
|
||||||
);
|
);
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
|
|
@ -456,6 +458,7 @@
|
||||||
packageReferences = (
|
packageReferences = (
|
||||||
FEE084F62EC172460045228E /* XCRemoteSwiftPackageReference "sqlite-data" */,
|
FEE084F62EC172460045228E /* XCRemoteSwiftPackageReference "sqlite-data" */,
|
||||||
FEE084F92EC1725A0045228E /* XCRemoteSwiftPackageReference "swift-http-structured-headers" */,
|
FEE084F92EC1725A0045228E /* XCRemoteSwiftPackageReference "swift-http-structured-headers" */,
|
||||||
|
FE30A0CE2ECF97B8007AFDD7 /* XCRemoteSwiftPackageReference "swift-algorithms" */,
|
||||||
);
|
);
|
||||||
preferredProjectObjectVersion = 77;
|
preferredProjectObjectVersion = 77;
|
||||||
productRefGroup = 97C146EF1CF9000F007C117D /* Products */;
|
productRefGroup = 97C146EF1CF9000F007C117D /* Products */;
|
||||||
|
|
@ -1252,6 +1255,14 @@
|
||||||
/* End XCConfigurationList section */
|
/* End XCConfigurationList section */
|
||||||
|
|
||||||
/* Begin XCRemoteSwiftPackageReference 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" */ = {
|
FEE084F62EC172460045228E /* XCRemoteSwiftPackageReference "sqlite-data" */ = {
|
||||||
isa = XCRemoteSwiftPackageReference;
|
isa = XCRemoteSwiftPackageReference;
|
||||||
repositoryURL = "https://github.com/pointfreeco/sqlite-data";
|
repositoryURL = "https://github.com/pointfreeco/sqlite-data";
|
||||||
|
|
@ -1271,6 +1282,11 @@
|
||||||
/* End XCRemoteSwiftPackageReference section */
|
/* End XCRemoteSwiftPackageReference section */
|
||||||
|
|
||||||
/* Begin XCSwiftPackageProductDependency section */
|
/* Begin XCSwiftPackageProductDependency section */
|
||||||
|
FE30A0CF2ECF97B8007AFDD7 /* Algorithms */ = {
|
||||||
|
isa = XCSwiftPackageProductDependency;
|
||||||
|
package = FE30A0CE2ECF97B8007AFDD7 /* XCRemoteSwiftPackageReference "swift-algorithms" */;
|
||||||
|
productName = Algorithms;
|
||||||
|
};
|
||||||
FEE084F72EC172460045228E /* SQLiteData */ = {
|
FEE084F72EC172460045228E /* SQLiteData */ = {
|
||||||
isa = XCSwiftPackageProductDependency;
|
isa = XCSwiftPackageProductDependency;
|
||||||
package = FEE084F62EC172460045228E /* XCRemoteSwiftPackageReference "sqlite-data" */;
|
package = FEE084F62EC172460045228E /* XCRemoteSwiftPackageReference "sqlite-data" */;
|
||||||
|
|
|
||||||
|
|
@ -54,7 +54,6 @@ import shared_preferences_foundation
|
||||||
}
|
}
|
||||||
|
|
||||||
public static func registerPlugins(with engine: FlutterEngine) {
|
public static func registerPlugins(with engine: FlutterEngine) {
|
||||||
NativeSyncApiImpl.register(with: engine.registrar(forPlugin: NativeSyncApiImpl.name)!)
|
|
||||||
ThumbnailApiSetup.setUp(binaryMessenger: engine.binaryMessenger, api: ThumbnailApiImpl())
|
ThumbnailApiSetup.setUp(binaryMessenger: engine.binaryMessenger, api: ThumbnailApiImpl())
|
||||||
BackgroundWorkerFgHostApiSetup.setUp(binaryMessenger: engine.binaryMessenger, api: BackgroundWorkerApiImpl())
|
BackgroundWorkerFgHostApiSetup.setUp(binaryMessenger: engine.binaryMessenger, api: BackgroundWorkerApiImpl())
|
||||||
|
|
||||||
|
|
@ -67,8 +66,4 @@ import shared_preferences_foundation
|
||||||
api: UploadApiImpl(statusListener: statusListener, progressListener: progressListener)
|
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 {
|
static let excluded = Self.where {
|
||||||
$0.id.assetId.eq(LocalAsset.columns.id) && $0.id.albumId.in(LocalAlbum.excluded.select(\.id))
|
$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")
|
@Table("local_asset_entity")
|
||||||
|
|
@ -109,7 +119,7 @@ struct LocalAsset: Identifiable {
|
||||||
@Column("created_at")
|
@Column("created_at")
|
||||||
let createdAt: String
|
let createdAt: String
|
||||||
@Column("duration_in_seconds")
|
@Column("duration_in_seconds")
|
||||||
let durationInSeconds: Int?
|
let durationInSeconds: Int64?
|
||||||
let height: Int?
|
let height: Int?
|
||||||
@Column("is_favorite")
|
@Column("is_favorite")
|
||||||
let isFavorite: Bool
|
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 CryptoKit
|
||||||
|
import Foundation
|
||||||
|
import Photos
|
||||||
|
import SQLiteData
|
||||||
|
import os.log
|
||||||
|
|
||||||
struct AssetWrapper: Hashable, Equatable {
|
extension Notification.Name {
|
||||||
let asset: PlatformAsset
|
static let localSyncDidComplete = Notification.Name("localSyncDidComplete")
|
||||||
|
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
class NativeSyncApiImpl: ImmichPlugin, NativeSyncApi, FlutterPlugin {
|
enum LocalSyncError: Error {
|
||||||
static let name = "NativeSyncApi"
|
case photoAccessDenied, assetUpsertFailed, noChangeToken, unsupportedOS
|
||||||
|
case unsupportedAssetType(Int)
|
||||||
|
}
|
||||||
|
|
||||||
static func register(with registrar: any FlutterPluginRegistrar) {
|
enum SyncConfig {
|
||||||
let instance = NativeSyncApiImpl()
|
static let albumTypes: [PHAssetCollectionType] = [.album, .smartAlbum]
|
||||||
NativeSyncApiSetup.setUp(binaryMessenger: registrar.messenger(), api: instance)
|
static let batchSize: Int = 5000
|
||||||
registrar.publish(instance)
|
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) {
|
class LocalSyncService {
|
||||||
super.detachFromEngine()
|
private static let dateFormatter = ISO8601DateFormatter()
|
||||||
}
|
|
||||||
|
|
||||||
private let defaults: UserDefaults
|
private let defaults: UserDefaults
|
||||||
private let changeTokenKey = "immich:changeToken"
|
private let db: DatabasePool
|
||||||
private let albumTypes: [PHAssetCollectionType] = [.album, .smartAlbum]
|
private let photoLibrary: PhotoLibraryProvider
|
||||||
private let recoveredAlbumSubType = 1000000219
|
private let logger = Logger(subsystem: "com.immich.mobile", category: "LocalSync")
|
||||||
|
|
||||||
private var hashTask: Task<Void?, Error>?
|
init(db: DatabasePool, photoLibrary: PhotoLibraryProvider, with defaults: UserDefaults = .standard) {
|
||||||
private static let hashCancelledCode = "HASH_CANCELLED"
|
|
||||||
private static let hashCancelled = Result<[HashResult], Error>.failure(PigeonError(code: hashCancelledCode, message: "Hashing cancelled", details: nil))
|
|
||||||
|
|
||||||
|
|
||||||
init(with defaults: UserDefaults = .standard) {
|
|
||||||
self.defaults = defaults
|
self.defaults = defaults
|
||||||
|
self.db = db
|
||||||
|
self.photoLibrary = photoLibrary
|
||||||
}
|
}
|
||||||
|
|
||||||
@available(iOS 16, *)
|
@available(iOS 16, *)
|
||||||
private func getChangeToken() -> PHPersistentChangeToken? {
|
private func getChangeToken() -> PHPersistentChangeToken? {
|
||||||
guard let data = defaults.data(forKey: changeTokenKey) else {
|
defaults.data(forKey: SyncConfig.changeTokenKey)
|
||||||
return nil
|
.flatMap { try? NSKeyedUnarchiver.unarchivedObject(ofClass: PHPersistentChangeToken.self, from: $0) }
|
||||||
}
|
|
||||||
return try? NSKeyedUnarchiver.unarchivedObject(ofClass: PHPersistentChangeToken.self, from: data)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@available(iOS 16, *)
|
@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 {
|
guard let data = try? NSKeyedArchiver.archivedData(withRootObject: token, requiringSecureCoding: true) else {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
defaults.set(data, forKey: changeTokenKey)
|
defaults.set(data, forKey: SyncConfig.changeTokenKey)
|
||||||
}
|
}
|
||||||
|
|
||||||
func clearSyncCheckpoint() -> Void {
|
func clearSyncCheckpoint() {
|
||||||
defaults.removeObject(forKey: changeTokenKey)
|
defaults.removeObject(forKey: SyncConfig.changeTokenKey)
|
||||||
}
|
}
|
||||||
|
|
||||||
func checkpointSync() {
|
func checkpointSync() {
|
||||||
guard #available(iOS 16, *) else {
|
guard #available(iOS 16, *) else { return }
|
||||||
return
|
saveChangeToken(token: photoLibrary.currentChangeToken)
|
||||||
}
|
|
||||||
saveChangeToken(token: PHPhotoLibrary.shared().currentChangeToken)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func shouldFullSync() -> Bool {
|
func sync(full: Bool = false) async throws {
|
||||||
guard #available(iOS 16, *),
|
let start = Date()
|
||||||
PHPhotoLibrary.authorizationStatus(for: .readWrite) == .authorized,
|
defer { logger.info("Sync completed in \(Int(Date().timeIntervalSince(start) * 1000))ms") }
|
||||||
let storedToken = getChangeToken() else {
|
|
||||||
// When we do not have access to photo library, older iOS version or No token available, fallback to full sync
|
guard !full, !shouldFullSync(), let delta = try? getMediaChanges(), delta.hasChanges
|
||||||
return true
|
else {
|
||||||
|
logger.debug("Full sync: \(full ? "user requested" : "required")")
|
||||||
|
return try await fullSync()
|
||||||
}
|
}
|
||||||
|
|
||||||
guard let _ = try? PHPhotoLibrary.shared().fetchPersistentChanges(since: storedToken) else {
|
logger.debug("Delta sync: +\(delta.updates.count) -\(delta.deletes.count)")
|
||||||
// Cannot fetch persistent changes
|
|
||||||
return true
|
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
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
func getAlbums() throws -> [PlatformAlbum] {
|
private func addAlbum(_ album: PHAssetCollection) async throws {
|
||||||
var albums: [PlatformAlbum] = []
|
let options = PHFetchOptions()
|
||||||
|
options.includeHiddenAssets = false
|
||||||
|
|
||||||
albumTypes.forEach { type in
|
if let timestamp = album.updatedAt {
|
||||||
let collections = PHAssetCollection.fetchAssetCollections(with: type, subtype: .any, options: nil)
|
let date = timestamp as NSDate
|
||||||
for i in 0..<collections.count {
|
options.predicate = NSPredicate(format: "creationDate > %@ OR modificationDate > %@", date, date)
|
||||||
let album = collections.object(at: i)
|
}
|
||||||
|
|
||||||
// Ignore recovered album
|
let result = photoLibrary.fetchAssets(in: album, options: options)
|
||||||
if(album.assetCollectionSubtype.rawValue == self.recoveredAlbumSubType) {
|
try await self.db.write { conn in
|
||||||
continue;
|
try upsertStreamedAssets(result: result, albumId: album.localIdentifier, conn: conn)
|
||||||
}
|
|
||||||
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return albums.sorted { $0.id < $1.id }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func getMediaChanges() throws -> SyncDelta {
|
private func upsertStreamedAssets(result: PHFetchResult<PHAsset>, albumId: String, conn: Database) throws {
|
||||||
guard #available(iOS 16, *) else {
|
result.enumerateObjects { asset, _, stop in
|
||||||
throw PigeonError(code: "UNSUPPORTED_OS", message: "This feature requires iOS 16 or later.", details: nil)
|
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 {
|
let dbAssetIds = try await db.read { conn in
|
||||||
throw PigeonError(code: "NO_AUTH", message: "No photo library access", details: nil)
|
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 {
|
guard let storedToken = getChangeToken() else {
|
||||||
// No token exists, definitely need a full sync
|
throw LocalSyncError.noChangeToken
|
||||||
print("MediaManager::getMediaChanges: No token found")
|
|
||||||
throw PigeonError(code: "NO_TOKEN", message: "No stored change token", details: nil)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let currentToken = PHPhotoLibrary.shared().currentChangeToken
|
let currentToken = photoLibrary.currentChangeToken
|
||||||
if storedToken == currentToken {
|
guard storedToken != currentToken else {
|
||||||
return SyncDelta(hasChanges: false, updates: [], deletes: [], assetAlbums: [:])
|
return NativeSyncDelta(hasChanges: false, updates: [], deletes: [], assetAlbums: [:])
|
||||||
}
|
}
|
||||||
|
|
||||||
do {
|
let changes = try photoLibrary.fetchPersistentChanges(since: storedToken)
|
||||||
let changes = try PHPhotoLibrary.shared().fetchPersistentChanges(since: storedToken)
|
var updatedIds = Set<String>()
|
||||||
|
var deletedIds = Set<String>()
|
||||||
|
|
||||||
var updatedAssets: Set<AssetWrapper> = []
|
for change in changes {
|
||||||
var deletedAssets: Set<String> = []
|
guard let details = try? change.changeDetails(for: PHObjectType.asset) else { continue }
|
||||||
|
updatedIds.formUnion(details.updatedLocalIdentifiers.union(details.insertedLocalIdentifiers))
|
||||||
for change in changes {
|
deletedIds.formUnion(details.deletedLocalIdentifiers)
|
||||||
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 [:]
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var albumAssets: [String: [String]] = [:]
|
guard !updatedIds.isEmpty || !deletedIds.isEmpty else {
|
||||||
|
return NativeSyncDelta(hasChanges: false, updates: [], deletes: [], assetAlbums: [:])
|
||||||
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 []
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var ids: [String] = []
|
let updatedIdArray = Array(updatedIds)
|
||||||
let options = PHFetchOptions()
|
let options = PHFetchOptions()
|
||||||
options.includeHiddenAssets = false
|
options.includeHiddenAssets = false
|
||||||
let assets = getAssetsFromAlbum(in: album, options: options)
|
let result = photoLibrary.fetchAssets(withLocalIdentifiers: updatedIdArray, options: options)
|
||||||
assets.enumerateObjects { (asset, _, _) in
|
|
||||||
|
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)
|
ids.append(asset.localIdentifier)
|
||||||
}
|
}
|
||||||
return ids
|
return ids
|
||||||
}
|
}
|
||||||
|
|
||||||
func getAssetsCountSince(albumId: String, timestamp: Int64) throws -> Int64 {
|
private func getAssetsForAlbum(albumId: String, updatedTimeCond: Int64?) throws -> [PHAsset] {
|
||||||
let collections = PHAssetCollection.fetchAssetCollections(withLocalIdentifiers: [albumId], options: nil)
|
guard let album = photoLibrary.fetchAssetCollection(albumId: albumId, options: nil) else { return [] }
|
||||||
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 []
|
|
||||||
}
|
|
||||||
|
|
||||||
let options = PHFetchOptions()
|
let options = PHFetchOptions()
|
||||||
options.includeHiddenAssets = false
|
options.includeHiddenAssets = false
|
||||||
if(updatedTimeCond != nil) {
|
if let timestamp = updatedTimeCond {
|
||||||
let date = NSDate(timeIntervalSince1970: TimeInterval(updatedTimeCond!))
|
let date = Date(timeIntervalSince1970: TimeInterval(timestamp))
|
||||||
options.predicate = NSPredicate(format: "creationDate > %@ OR modificationDate > %@", date, date)
|
options.predicate = NSPredicate(
|
||||||
|
format: "creationDate > %@ OR modificationDate > %@",
|
||||||
|
date as NSDate,
|
||||||
|
date as NSDate
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
let result = getAssetsFromAlbum(in: album, options: options)
|
let result = photoLibrary.fetchAssets(in: album, options: options)
|
||||||
if(result.count == 0) {
|
var assets: [PHAsset] = []
|
||||||
return []
|
result.enumerateObjects { asset, _, _ in assets.append(asset) }
|
||||||
}
|
|
||||||
|
|
||||||
var assets: [PlatformAsset] = []
|
|
||||||
result.enumerateObjects { (asset, _, _) in
|
|
||||||
assets.append(asset.toPlatformAsset())
|
|
||||||
}
|
|
||||||
return assets
|
return assets
|
||||||
}
|
}
|
||||||
|
}
|
||||||
func hashAssets(assetIds: [String], allowNetworkAccess: Bool, completion: @escaping (Result<[HashResult], Error>) -> Void) {
|
|
||||||
if let prevTask = hashTask {
|
func diffSortedArrays<T: Comparable & Hashable>(_ a: [T], _ b: [T]) -> (toAdd: [T], toRemove: [T]) {
|
||||||
prevTask.cancel()
|
var toAdd: [T] = []
|
||||||
hashTask = nil
|
var toRemove: [T] = []
|
||||||
}
|
var i = 0
|
||||||
hashTask = Task { [weak self] in
|
var j = 0
|
||||||
var missingAssetIds = Set(assetIds)
|
|
||||||
var assets = [PHAsset]()
|
while i < a.count && j < b.count {
|
||||||
assets.reserveCapacity(assetIds.count)
|
if a[i] < b[j] {
|
||||||
PHAsset.fetchAssets(withLocalIdentifiers: assetIds, options: nil).enumerateObjects { (asset, _, stop) in
|
toRemove.append(a[i])
|
||||||
if Task.isCancelled {
|
i += 1
|
||||||
stop.pointee = true
|
} else if b[j] < a[i] {
|
||||||
return
|
toAdd.append(b[j])
|
||||||
}
|
j += 1
|
||||||
missingAssetIds.remove(asset.localIdentifier)
|
} else {
|
||||||
assets.append(asset)
|
i += 1
|
||||||
}
|
j += 1
|
||||||
|
}
|
||||||
if Task.isCancelled {
|
}
|
||||||
return self?.completeWhenActive(for: completion, with: Self.hashCancelled)
|
|
||||||
}
|
toRemove.append(contentsOf: a[i...])
|
||||||
|
toAdd.append(contentsOf: b[j...])
|
||||||
await withTaskGroup(of: HashResult?.self) { taskGroup in
|
|
||||||
var results = [HashResult]()
|
return (toAdd, toRemove)
|
||||||
results.reserveCapacity(assets.count)
|
}
|
||||||
for asset in assets {
|
|
||||||
if Task.isCancelled {
|
private struct NativeSyncDelta: Hashable {
|
||||||
return self?.completeWhenActive(for: completion, with: Self.hashCancelled)
|
var hasChanges: Bool
|
||||||
}
|
var updates: [PHAsset]
|
||||||
taskGroup.addTask {
|
var deletes: [String]
|
||||||
guard let self = self else { return nil }
|
var assetAlbums: [String: [String]]
|
||||||
return await self.hashAsset(asset, allowNetworkAccess: allowNetworkAccess)
|
}
|
||||||
}
|
|
||||||
}
|
/// Temp table to avoid parameter limit for album changes.
|
||||||
|
@Table("current_albums")
|
||||||
for await result in taskGroup {
|
private struct CurrentAlbum {
|
||||||
guard let result = result else {
|
let id: String
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,21 +1,6 @@
|
||||||
import Photos
|
import Photos
|
||||||
|
|
||||||
extension PHAsset {
|
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 {
|
var title: String {
|
||||||
return filename ?? originalFilename ?? "<unknown>"
|
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