local sync poc

This commit is contained in:
mertalev
2025-11-21 00:50:24 -05:00
parent c70146c4a8
commit 8fe932d891
7 changed files with 626 additions and 898 deletions
+512 -325
View File
@@ -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
}