import Foundation import Photos import UIKit import os.log private let log = Logger(subsystem: "com.albert.nasbackup", category: "BackupStatus") private let signposter = OSSignposter(subsystem: "com.albert.nasbackup", category: "BackupStatus") @MainActor final class BackupStatusService: NSObject, ObservableObject { static let shared = BackupStatusService() @Published private(set) var snapshot: BackupStatusSnapshot = .empty @Published private(set) var isRefreshing: Bool = false @Published private(set) var refreshError: String? @Published private(set) var lastManifest: BackupManifest? 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? private var debounceTask: Task? private var lastFullRefresh: Date? private static let debounceInterval: TimeInterval = 4 private static let minRefreshInterval: TimeInterval = 30 // MARK: — Init private override init() { super.init() loadCachedSnapshot() PHPhotoLibrary.shared().register(self) NotificationCenter.default.addObserver( self, selector: #selector(handleMemoryWarning), name: UIApplication.didReceiveMemoryWarningNotification, object: nil ) } deinit { PHPhotoLibrary.shared().unregisterChangeObserver(self) NotificationCenter.default.removeObserver(self) } @objc private func handleMemoryWarning() { log.warning("Memory warning — cancelling in-flight refresh") cancelAll() isRefreshing = false } // MARK: — Public API func refresh(force: Bool = false) { cancelAll() refreshTask = Task { [weak self] in await self?.performRefresh(force: force) } } func refreshAndWait(force: Bool = true) async { cancelAll() await performRefresh(force: force) } func refreshAfterBackup(entries: [ManifestEntry], connection: NASConnection) { let uploaded = entries.count snapshot.alreadySafe = min(snapshot.phoneTotal, snapshot.alreadySafe + uploaded) // nasArchiveTotal is NOT updated optimistically — writeManifest will update // NASManifestCache with the full merged count, and refresh(force: true) reads it. saveSnapshot() log.info("Post-backup optimistic update: +\(uploaded) safe") Task { await writeManifest(entries: entries, connection: connection) refresh(force: true) } } // MARK: — Refresh lifecycle private func cancelAll() { refreshTask?.cancel() refreshTask = nil debounceTask?.cancel() debounceTask = nil } private func performRefresh(force: Bool) async { guard !isRefreshing else { log.debug("Refresh skipped — already in progress") return } if !force, let last = lastFullRefresh, Date().timeIntervalSince(last) < Self.minRefreshInterval { log.debug("Refresh debounced — updating phone count only") updatePhoneCountOnly() return } 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 { log.info("Refresh cancelled") } catch { log.error("Refresh failed: \(error.localizedDescription)") refreshError = error.localizedDescription snapshot.connectionState = .offline snapshot.lastCheckedAt = Date() saveSnapshot() } isRefreshing = false } // MARK: — Reconciliation private func reconcile() async throws { let spReconcile = signposter.beginInterval("Reconcile") defer { signposter.endInterval("Reconcile", spReconcile) } let store = ConnectionStore.shared guard let conn = store.savedConnection else { return } let filter = store.backupFilter log.info(""" [reconcile] host=\(conn.host, privacy: .public) remotePath=\(conn.remotePath, privacy: .public) \ manifestPath=\(conn.remotePath, privacy: .public)/\(BackupManifest.remoteFilename, privacy: .public) \ filter=photos:\(filter.includePhotos) videos:\(filter.includeVideos) \ screenshots:\(filter.includeScreenshots) raw:\(filter.includeRAW) \ autoBackupEnabled=\(store.autoBackupEnabled) autoBackupOnOpen=\(store.autoBackupOnOpen) """) // ── Fast path: use local caches when both are warm ──────────────── // Skips NAS connection + PHFetchResult batch enumeration entirely. // Triggered when: manifest cache < 5 min old AND LocalPhotoIndex has data. let manifestCacheIsStale = await NASManifestCache.shared.isStale let localIndexCount = await LocalPhotoIndex.shared.totalCount if !manifestCacheIsStale, let cached = await NASManifestCache.shared.manifest, localIndexCount > 0 { let index = await Task.detached(priority: .utility) { ManifestIndex(manifest: cached) }.value let safe = await LocalPhotoIndex.shared.countSafe(against: index, filter: filter) let phoneTotal = await LocalPhotoIndex.shared.count(filter: filter) var updated = BackupStatusSnapshot() updated.phoneTotal = phoneTotal updated.alreadySafe = min(safe, phoneTotal) updated.nasArchiveTotal = index.totalCount updated.connectionState = .connected updated.lastCheckedAt = Date() snapshot = updated saveSnapshot() log.info("Reconcile (cached): phone=\(phoneTotal) safe=\(safe) NAS=\(index.totalCount) needBackup=\(phoneTotal - min(safe, phoneTotal))") return } // ── Full path: connect to NAS and download fresh manifest ───────── // Step 1: Phone count (O(1) PHFetchResult.count) let fetchResult = photoService.fetchResultForReconciliation(filter: filter) let phoneTotal = fetchResult.count log.info("PHFetchResult count: \(phoneTotal)") try Task.checkCancellation() // ── 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 { let reason = (error as? BackupError).flatMap { $0.errorDescription } ?? error.localizedDescription log.warning("NAS connect failed: \(reason, privacy: .public) — retaining cached counts") refreshError = reason // Restore last-known NAS archive count from manifest cache so the dashboard // doesn't show 0 when the NAS is temporarily offline. if let cached = await NASManifestCache.shared.manifest { let cachedCount = ManifestIndex(manifest: cached).totalCount snapshot.nasArchiveTotal = max(snapshot.nasArchiveTotal, cachedCount) log.info("NAS offline — restored nasArchiveTotal=\(self.snapshot.nasArchiveTotal) from cache") } snapshot.phoneTotal = phoneTotal snapshot.alreadySafe = min(snapshot.alreadySafe, phoneTotal) snapshot.connectionState = .offline snapshot.lastCheckedAt = Date() saveSnapshot() return } defer { transfer.disconnect() } try Task.checkCancellation() // ── Step 3: Load ManifestIndex ──────────────────────────────────── let spManifest = signposter.beginInterval("ManifestLoad") let manifestPath = "\(conn.remotePath)/\(BackupManifest.remoteFilename)" let index = await buildManifestIndex(transfer: transfer, path: manifestPath, conn: conn) signposter.endInterval("ManifestLoad", spManifest, "\(index.totalCount) entries, filename-only=\(index.isFilenameOnly)") log.info("Manifest index built — \(index.totalCount) NAS entries") try Task.checkCancellation() // ── Step 4: Count safe assets ───────────────────────────────────── // Prefer LocalPhotoIndex (pure in-memory, no CoreData) if populated; // otherwise fall back to batch PHFetchResult enumeration. let spCount = signposter.beginInterval("CountSafe") let localCount = await LocalPhotoIndex.shared.totalCount let safe: Int if localCount > 0 { safe = await LocalPhotoIndex.shared.countSafe(against: index, filter: filter) } else { safe = try await Task.detached(priority: .userInitiated) { [weak self, fetchResult, index] in guard let self else { throw CancellationError() } return try await self.countSafe(in: fetchResult, against: index) }.value } signposter.endInterval("CountSafe", spCount, "\(safe)/\(phoneTotal) safe") log.info("Comparison complete — \(safe)/\(phoneTotal) already safe") // ── Step 5: Commit ──────────────────────────────────────────────── var updated = BackupStatusSnapshot() updated.phoneTotal = phoneTotal updated.alreadySafe = min(safe, phoneTotal) updated.nasArchiveTotal = index.totalCount updated.connectionState = .connected updated.lastCheckedAt = Date() snapshot = updated saveSnapshot() // Invariant check — alreadySafe + needBackup must always equal phoneTotal let inv = updated.alreadySafe + updated.needBackup if inv != updated.phoneTotal { log.error("Invariant broken: alreadySafe(\(updated.alreadySafe)) + needBackup(\(updated.needBackup)) = \(inv) ≠ phoneTotal(\(updated.phoneTotal))") } } /// Builds a ManifestIndex from NAS. /// - The download suspends the main actor. /// - JSON decode and Set construction run in a background task. private func buildManifestIndex( transfer: any NASTransferProtocol, path: String, conn: NASConnection ) async -> ManifestIndex { log.info("buildManifestIndex: remotePath=\(conn.remotePath, privacy: .public) manifestFile=\(path, privacy: .public)") do { let data = try await transfer.downloadData(at: path) log.info("buildManifestIndex: manifest downloaded — \(data.count) bytes") // Decode and build index off main actor — JSONDecoder is not cheap for large manifests. let decoded: (ManifestIndex, BackupManifest)? = await Task.detached(priority: .utility) { guard let manifest = try? JSONDecoder.kisani.decode(BackupManifest.self, from: data) else { return nil } return (ManifestIndex(manifest: manifest), manifest) }.value if let (index, manifest) = decoded { log.info("buildManifestIndex: decoded \(index.totalCount) entries (isFilenameOnly=\(index.isFilenameOnly))") lastManifest = manifest Task { await NASManifestCache.shared.update(manifest) } // Validity check: if the manifest has ≤ 1 entries it may be corrupt or newly // created. Scan the directory to get the real file count for the NAS Archive // display — but do NOT add directory files as orphan manifest entries. if index.totalCount <= 1 { let items = (try? await transfer.listDirectory(at: conn.remotePath)) ?? [] let dirCount = items.filter { !$0.isDirectory && !$0.name.hasPrefix(".") && $0.name != BackupManifest.remoteFilename }.count log.info("buildManifestIndex: validity — manifest=\(index.totalCount) dir=\(dirCount) — using max") if dirCount > index.totalCount { return ManifestIndex(manifest: manifest, overrideTotalCount: dirCount) } } return index } log.warning("buildManifestIndex: manifest downloaded but failed to decode (\(data.count) bytes)") } catch { log.info("buildManifestIndex: manifest not found at \(path, privacy: .public) — \(error.localizedDescription, privacy: .public) — falling back to directory listing") } // Bootstrap: list NAS directory and build filename-only index in background. let items = (try? await transfer.listDirectory(at: conn.remotePath)) ?? [] log.info("buildManifestIndex: bootstrap from \(items.count) items in \(conn.remotePath, privacy: .public)") return await Task.detached(priority: .utility) { ManifestIndex(nasListing: items) }.value } /// Enumerates a PHFetchResult in batches using autoreleasepool. /// Marked nonisolated so it can be called from a background Task without /// requiring main-actor scheduling — it accesses no @MainActor state. private nonisolated 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..