diff --git a/Core/Models/BackupManifest.swift b/Core/Models/BackupManifest.swift index a43cb28..8e4b776 100644 --- a/Core/Models/BackupManifest.swift +++ b/Core/Models/BackupManifest.swift @@ -1,4 +1,54 @@ import Foundation +import Photos + +// MARK: — ManifestIndex +// Lightweight comparison structure — two Sets for O(1) lookups. +// Built from BackupManifest and then the manifest is released. +struct ManifestIndex { + let localIdentifiers: Set // PHAsset.localIdentifier (primary key) + let lowercasedFilenames: Set // filename fallback for legacy/bootstrap entries + let totalCount: Int // non-hidden file count for NAS Archive stat + + var isFilenameOnly: Bool { localIdentifiers.isEmpty } + + func matches(localIdentifier id: String) -> Bool { + !id.isEmpty && localIdentifiers.contains(id) + } + + func matches(filename: String) -> Bool { + lowercasedFilenames.contains(filename.lowercased()) + } + + // Build from decoded manifest — manifest released by caller after this returns + init(manifest: BackupManifest) { + var ids = Set(minimumCapacity: manifest.entries.count) + var names = Set(minimumCapacity: manifest.entries.count) + var count = 0 + for entry in manifest.entries { + if !entry.filename.hasPrefix(".") { count += 1 } + if !entry.localIdentifier.isEmpty { ids.insert(entry.localIdentifier) } + names.insert(entry.filename.lowercased()) + } + self.localIdentifiers = ids + self.lowercasedFilenames = names + self.totalCount = count + } + + // Build from NAS directory listing (bootstrap — no localIdentifiers known) + init(nasListing items: [NASItem]) { + var names = Set() + var count = 0 + for item in items where !item.isDirectory && item.name != BackupManifest.remoteFilename { + if !item.name.hasPrefix(".") { count += 1 } + names.insert(item.name.lowercased()) + } + self.localIdentifiers = [] + self.lowercasedFilenames = names + self.totalCount = count + } +} + +// MARK: — ManifestEntry struct ManifestEntry: Codable { let localIdentifier: String // PHAsset.localIdentifier — primary key diff --git a/Services/BackupStatusService.swift b/Services/BackupStatusService.swift index bf8e5b7..0ca0dd5 100644 --- a/Services/BackupStatusService.swift +++ b/Services/BackupStatusService.swift @@ -1,5 +1,9 @@ import Foundation import Photos +import UIKit +import os.log + +private let log = Logger(subsystem: "com.albert.nasbackup", category: "BackupStatus") @MainActor final class BackupStatusService: NSObject, ObservableObject { @@ -11,45 +15,85 @@ final class BackupStatusService: NSObject, ObservableObject { private let photoService = PhotoLibraryService() private let cacheKey = "backupStatusSnapshot_v2" + + // One active reconciliation task at a time — cancelled before starting a new one private var refreshTask: Task? + // Separate debounce task for photo library change events + private var debounceTask: Task? + private var lastFullRefresh: Date? + private static let debounceInterval: TimeInterval = 4 // seconds before photo-change refresh runs + private static let minRefreshInterval: TimeInterval = 30 // minimum between full NAS reconciliations + + // MARK: — Init private override init() { super.init() loadCachedSnapshot() PHPhotoLibrary.shared().register(self) + NotificationCenter.default.addObserver( + self, + selector: #selector(handleMemoryWarning), + name: UIApplication.didReceiveMemoryWarningNotification, + object: nil + ) } - // MARK: — Public + deinit { + PHPhotoLibrary.shared().unregisterChangeObserver(self) + NotificationCenter.default.removeObserver(self) + } - /// Trigger a full reconciliation. `force: true` bypasses the 30-second debounce. + @objc private func handleMemoryWarning() { + log.warning("Memory warning — cancelling in-flight refresh") + cancelAll() + isRefreshing = false + } + + // MARK: — Public API + + /// Start a reconciliation. `force: true` bypasses the minimum refresh interval. func refresh(force: Bool = false) { - refreshTask?.cancel() + cancelAll() refreshTask = Task { [weak self] in await self?.performRefresh(force: force) } } - /// Called by BackupEngine after a completed run. - /// Applies an optimistic update immediately, then writes the manifest and reconciles in background. + /// Called by BackupEngine after a completed run with newly uploaded entries. + /// Applies an optimistic count update, writes the manifest in background, then reconciles. func refreshAfterBackup(entries: [ManifestEntry], connection: NASConnection) { let uploaded = entries.count snapshot.alreadySafe = min(snapshot.phoneTotal, snapshot.alreadySafe + uploaded) snapshot.nasArchiveTotal += uploaded saveSnapshot() + log.info("Post-backup optimistic update: +\(uploaded) safe") + // Write manifest then force reconcile (entries captured only briefly) Task { await writeManifest(entries: entries, connection: connection) refresh(force: true) } } - // MARK: — Core refresh + // MARK: — Refresh lifecycle + + private func cancelAll() { + refreshTask?.cancel() + refreshTask = nil + debounceTask?.cancel() + debounceTask = nil + } private func performRefresh(force: Bool) async { - guard !isRefreshing else { return } + guard !isRefreshing else { + log.debug("Refresh skipped — already in progress") + return + } - // Debounce: non-forced refreshes skip the NAS round-trip if we just reconciled - if !force, let last = lastFullRefresh, Date().timeIntervalSince(last) < 30 { + // Minimum interval guard (skip NAS round-trip if recently refreshed) + if !force, let last = lastFullRefresh, + Date().timeIntervalSince(last) < Self.minRefreshInterval { + log.debug("Refresh debounced — updating phone count only") updatePhoneCountOnly() return } @@ -57,14 +101,16 @@ final class BackupStatusService: NSObject, ObservableObject { lastFullRefresh = Date() isRefreshing = true refreshError = nil + log.info("Refresh started (force=\(force))") do { try await reconcile() + log.info("Refresh completed — phone=\(self.snapshot.phoneTotal) safe=\(self.snapshot.alreadySafe)") } catch is CancellationError { - // Swallow — a newer refresh superseded this one + log.info("Refresh cancelled") } catch { + log.error("Refresh failed: \(error.localizedDescription)") refreshError = error.localizedDescription - // Keep last cached numbers; mark NAS offline snapshot.connectionState = .offline snapshot.lastCheckedAt = Date() saveSnapshot() @@ -73,98 +119,150 @@ final class BackupStatusService: NSObject, ObservableObject { isRefreshing = false } + // MARK: — Reconciliation + private func reconcile() async throws { let store = ConnectionStore.shared guard let conn = store.savedConnection else { return } - // 1. Count phone assets (fast, no network) + // ── Step 1: Phone count — metadata only, no image data, no thumbnails ── let filter = store.backupFilter - let assets = photoService.fetchAssets(filter: filter) - let phoneTotal = assets.count + let fetchResult = photoService.fetchResultForReconciliation(filter: filter) + let phoneTotal = fetchResult.count + log.info("PHFetchResult count: \(phoneTotal)") try Task.checkCancellation() - // 2. Connect to NAS + // ── Step 2: NAS connection ── let transfer: any NASTransferProtocol = conn.nasProtocol == .smb ? SMBService() : SFTPService() - do { try await transfer.connect( to: conn.host, port: conn.port, username: conn.username, password: conn.password ) } catch { - // NAS offline — update phone count only, enforce invariant on cached safe count + // NAS offline — keep cached safe count, enforce invariant snapshot.phoneTotal = phoneTotal snapshot.alreadySafe = min(snapshot.alreadySafe, phoneTotal) snapshot.connectionState = .offline snapshot.lastCheckedAt = Date() saveSnapshot() + log.warning("NAS offline — cached numbers retained") return } - defer { transfer.disconnect() } try Task.checkCancellation() - // 3. Load or bootstrap the manifest + // ── Step 3: Load ManifestIndex — Data + BackupManifest released after this call ── let manifestPath = "\(conn.remotePath)/\(BackupManifest.remoteFilename)" - let manifest: BackupManifest - - do { - let data = try await transfer.downloadData(at: manifestPath) - if let decoded = try? JSONDecoder().decode(BackupManifest.self, from: data) { - manifest = decoded - } else { - let items = (try? await transfer.listDirectory(at: conn.remotePath)) ?? [] - manifest = BackupManifest.buildFromNASListing(items) - } - } catch { - // Manifest not found — bootstrap from directory listing - let items = (try? await transfer.listDirectory(at: conn.remotePath)) ?? [] - manifest = BackupManifest.buildFromNASListing(items) - } + let index = await buildManifestIndex(transfer: transfer, path: manifestPath, conn: conn) + log.info("Manifest index built — \(index.totalCount) NAS entries, filename-only=\(index.isFilenameOnly)") try Task.checkCancellation() - // 4. Compare — localIdentifier first, filename fallback for legacy entries - var safe = 0 - for asset in assets { - if manifest.contains(localIdentifier: asset.localIdentifier) - || manifest.containsByFilename(asset.filename) { - safe += 1 - } - } + // ── Step 4: Count safe assets — PHFetchResult enumerated in batches, no [PhotoAsset] kept ── + let safe = try await countSafe(in: fetchResult, against: index) + log.info("Comparison complete — \(safe)/\(phoneTotal) already safe") - // 5. NAS archive count (excludes hidden manifest file) - let nasArchive = manifest.entries.filter { !$0.filename.hasPrefix(".") }.count - - // 6. Commit — enforce invariant + // ── Step 5: Commit — enforce invariant (alreadySafe ≤ phoneTotal) ── var updated = BackupStatusSnapshot() - updated.phoneTotal = phoneTotal - updated.alreadySafe = min(safe, phoneTotal) // invariant: never > phoneTotal - updated.nasArchiveTotal = nasArchive + updated.phoneTotal = phoneTotal + updated.alreadySafe = min(safe, phoneTotal) + updated.nasArchiveTotal = index.totalCount updated.connectionState = .connected - updated.lastCheckedAt = Date() + updated.lastCheckedAt = Date() snapshot = updated saveSnapshot() } - // Fast phone-only update used when debouncing NAS round-trips - private func updatePhoneCountOnly() { - let filter = ConnectionStore.shared.backupFilter - let assets = photoService.fetchAssets(filter: filter) - let phoneTotal = assets.count - if phoneTotal != snapshot.phoneTotal { - snapshot.phoneTotal = phoneTotal - snapshot.alreadySafe = min(snapshot.alreadySafe, phoneTotal) - saveSnapshot() + /// Builds a ManifestIndex from NAS. Data and BackupManifest are scoped inside + /// this function — they are released before the caller's comparison step begins. + private func buildManifestIndex( + transfer: any NASTransferProtocol, + path: String, + conn: NASConnection + ) async -> ManifestIndex { + do { + let data = try await transfer.downloadData(at: path) + if let manifest = try? JSONDecoder().decode(BackupManifest.self, from: data) { + // `data` and `manifest` released when this scope exits + return ManifestIndex(manifest: manifest) + } + } catch { + // File not found — fall through to directory listing bootstrap } + // Bootstrap from directory listing + let items = (try? await transfer.listDirectory(at: conn.remotePath)) ?? [] + log.info("Manifest not found — bootstrapping from \(items.count) NAS items") + return ManifestIndex(nasListing: items) } - // MARK: — Manifest write (non-blocking background operation) + /// Enumerates a PHFetchResult in batches using autoreleasepool. + /// Never builds a full [PHAsset] or [PhotoAsset] array. + private func countSafe( + in result: PHFetchResult, + against index: ManifestIndex, + batchSize: Int = 150 + ) async throws -> Int { + var safe = 0 + let total = result.count + var processed = 0 + + while processed < total { + try Task.checkCancellation() + + let batchEnd = min(processed + batchSize, total) + + // autoreleasepool releases PHAsset objects and PHAssetResource arrays + // created during this batch before moving to the next + let batchSafe: Int = autoreleasepool { + var count = 0 + for i in processed.. PHFetchResult { + 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],