mirror of
https://github.com/immich-app/immich.git
synced 2026-05-18 03:10:24 +03:00
local sync poc
This commit is contained in:
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user