Replace full [PhotoAsset] array enumeration with PHFetchResult batch enumeration (autoreleasepool + Task.yield per batch) to keep memory bounded during reconciliation. Introduce ManifestIndex with two O(1) Set<String> lookups (localIdentifier + lowercased filename) instead of O(n×m) linear scans. Scope manifest Data+struct inside buildManifestIndex so they are released by ARC before the comparison loop begins. Add UIApplication.didReceiveMemoryWarningNotification handler to cancel in-flight tasks under memory pressure. Separate debounceTask from refreshTask to avoid cancelling long-running reconciles on every photo library event. fetchResultForReconciliation returns PHFetchResult directly — no [PhotoAsset] array is ever allocated during reconciliation. Co-Authored-By: Kutesir <kutesir@provoc.ug> Co-Authored-By: Sentry <sentry@provoc.ug>
150 lines
5.9 KiB
Swift
150 lines
5.9 KiB
Swift
import Foundation
|
|
import Photos
|
|
|
|
final class PhotoLibraryService: PhotoLibraryProtocol {
|
|
var authorizationStatus: PHAuthorizationStatus {
|
|
PHPhotoLibrary.authorizationStatus(for: .readWrite)
|
|
}
|
|
|
|
func requestAuthorization() async -> PHAuthorizationStatus {
|
|
await PHPhotoLibrary.requestAuthorization(for: .readWrite)
|
|
}
|
|
|
|
func fetchAssets(filter: BackupFilter) -> [PhotoAsset] {
|
|
let options = PHFetchOptions()
|
|
options.sortDescriptors = [NSSortDescriptor(key: "creationDate", ascending: true)]
|
|
|
|
var subpredicates: [NSPredicate] = []
|
|
|
|
var mediaTypes: [PHAssetMediaType] = []
|
|
if filter.includePhotos { mediaTypes.append(.image) }
|
|
if filter.includeVideos { mediaTypes.append(.video) }
|
|
|
|
if !mediaTypes.isEmpty {
|
|
let typePredicate = NSPredicate(
|
|
format: "mediaType IN %@",
|
|
mediaTypes.map(\.rawValue)
|
|
)
|
|
subpredicates.append(typePredicate)
|
|
}
|
|
|
|
if !filter.includeScreenshots {
|
|
subpredicates.append(NSPredicate(
|
|
format: "NOT ((mediaSubtype & %d) != 0)",
|
|
PHAssetMediaSubtype.photoScreenshot.rawValue
|
|
))
|
|
}
|
|
|
|
if subpredicates.count > 1 {
|
|
options.predicate = NSCompoundPredicate(andPredicateWithSubpredicates: subpredicates)
|
|
} else {
|
|
options.predicate = subpredicates.first
|
|
}
|
|
|
|
let result = PHAsset.fetchAssets(with: options)
|
|
var assets: [PhotoAsset] = []
|
|
result.enumerateObjects { asset, _, _ in
|
|
autoreleasepool {
|
|
let isScreenshot = asset.mediaSubtypes.contains(.photoScreenshot)
|
|
let resources = PHAssetResource.assetResources(for: asset)
|
|
let isRAW = resources.contains { $0.type == .alternatePhoto }
|
|
if isRAW && !filter.includeRAW { return }
|
|
|
|
let filename = resources.first?.originalFilename ?? "\(asset.localIdentifier).jpg"
|
|
|
|
assets.append(PhotoAsset(
|
|
localIdentifier: asset.localIdentifier,
|
|
filename: filename,
|
|
creationDate: asset.creationDate,
|
|
mediaType: asset.mediaType,
|
|
pixelWidth: asset.pixelWidth,
|
|
pixelHeight: asset.pixelHeight,
|
|
duration: asset.duration,
|
|
isScreenshot: isScreenshot,
|
|
isRAW: isRAW
|
|
))
|
|
}
|
|
}
|
|
return assets
|
|
}
|
|
|
|
/// Returns a raw PHFetchResult for memory-safe reconciliation.
|
|
/// No PhotoAsset array is created — caller enumerates in batches.
|
|
func fetchResultForReconciliation(filter: BackupFilter) -> PHFetchResult<PHAsset> {
|
|
let options = PHFetchOptions()
|
|
options.sortDescriptors = [NSSortDescriptor(key: "creationDate", ascending: true)]
|
|
|
|
var subpredicates: [NSPredicate] = []
|
|
var mediaTypes: [PHAssetMediaType] = []
|
|
if filter.includePhotos { mediaTypes.append(.image) }
|
|
if filter.includeVideos { mediaTypes.append(.video) }
|
|
|
|
if !mediaTypes.isEmpty {
|
|
subpredicates.append(NSPredicate(
|
|
format: "mediaType IN %@",
|
|
mediaTypes.map(\.rawValue)
|
|
))
|
|
}
|
|
if !filter.includeScreenshots {
|
|
subpredicates.append(NSPredicate(
|
|
format: "NOT ((mediaSubtype & %d) != 0)",
|
|
PHAssetMediaSubtype.photoScreenshot.rawValue
|
|
))
|
|
}
|
|
|
|
if subpredicates.count > 1 {
|
|
options.predicate = NSCompoundPredicate(andPredicateWithSubpredicates: subpredicates)
|
|
} else {
|
|
options.predicate = subpredicates.first
|
|
}
|
|
|
|
return PHAsset.fetchAssets(with: options)
|
|
}
|
|
|
|
func exportAsset(_ asset: PhotoAsset) async throws -> URL {
|
|
let fetchResult = PHAsset.fetchAssets(
|
|
withLocalIdentifiers: [asset.localIdentifier],
|
|
options: nil
|
|
)
|
|
guard let phAsset = fetchResult.firstObject else {
|
|
throw BackupError.uploadFailed(asset.filename, underlying: NSError(domain: "PhotoExport", code: -1))
|
|
}
|
|
|
|
let tmpURL = FileManager.default.temporaryDirectory
|
|
.appendingPathComponent(UUID().uuidString)
|
|
.appendingPathExtension((asset.filename as NSString).pathExtension)
|
|
|
|
return try await withCheckedThrowingContinuation { continuation in
|
|
if phAsset.mediaType == .video {
|
|
let options = PHVideoRequestOptions()
|
|
options.isNetworkAccessAllowed = true
|
|
PHImageManager.default().requestAVAsset(forVideo: phAsset, options: options) { avAsset, _, _ in
|
|
guard let urlAsset = avAsset as? AVURLAsset else {
|
|
continuation.resume(throwing: BackupError.uploadFailed(asset.filename, underlying: NSError(domain: "AVExport", code: -1)))
|
|
return
|
|
}
|
|
continuation.resume(returning: urlAsset.url)
|
|
}
|
|
} else {
|
|
let options = PHImageRequestOptions()
|
|
options.isNetworkAccessAllowed = true
|
|
options.version = .current
|
|
options.deliveryMode = .highQualityFormat
|
|
|
|
PHImageManager.default().requestImageDataAndOrientation(for: phAsset, options: options) { data, _, _, _ in
|
|
guard let data else {
|
|
continuation.resume(throwing: BackupError.uploadFailed(asset.filename, underlying: NSError(domain: "PhotoExport", code: -2)))
|
|
return
|
|
}
|
|
do {
|
|
try data.write(to: tmpURL)
|
|
continuation.resume(returning: tmpURL)
|
|
} catch {
|
|
continuation.resume(throwing: BackupError.uploadFailed(asset.filename, underlying: error))
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|