import Foundation import Combine import UIKit import os.log private let log = Logger(subsystem: "com.albert.nasbackup", category: "AutoBackup") /// Decides when to start backup automatically and tracks the high-level app phase. /// /// Phase model: /// idle — no pending work, or destination not configured /// checking — reconciliation running, awaiting the pending-queue size /// autoBackingUp — coordinator-triggered backup running /// disconnected — NAS offline while items are queued; will retry when reachable /// /// Active-backup states (running/paused/completed/failed) are owned by BackupEngine.job /// and are read directly by BackupView — the coordinator does not duplicate them. @MainActor final class AutoBackupCoordinator: ObservableObject { static let shared = AutoBackupCoordinator() enum Phase: Equatable { case idle case checking case autoBackingUp case disconnected } @Published private(set) var phase: Phase = .idle @Published private(set) var pendingAtDisconnect: Int = 0 private var lastTriggerWasLAN = false private var lastAutoCheckAt: Date = .distantPast private var recheckTimer: AnyCancellable? // One-shot flag armed by onActive()/handleNASReachable() and consumed by // handleReconciliationComplete(). Prevents the post-backup refresh cycle from // re-triggering auto-backup — only an explicit app-open or LAN-join should do that. private var backupTriggerArmed = false private let engine = BackupEngine.shared private let statusService = BackupStatusService.shared private let store = ConnectionStore.shared private let lanMonitor = LANMonitor.shared private var cancellables = Set() // MARK: — Init private init() { setupObservation() setupAppLifecycle() } private func setupAppLifecycle() { NotificationCenter.default.addObserver( self, selector: #selector(appWillResignActive), name: UIApplication.willResignActiveNotification, object: nil ) } // Stop the timer and reset the debounce window on every background transition. // This ensures the next foreground session always gets an immediate status check. @objc private nonisolated func appWillResignActive() { Task { @MainActor [weak self] in self?.recheckTimer = nil self?.lastAutoCheckAt = .distantPast self?.backupTriggerArmed = false log.debug("App resigned — recheck timer stopped, debounce reset") } } private func startPeriodicRecheckIfNeeded() { guard recheckTimer == nil else { return } recheckTimer = Timer.publish(every: 60, on: .main, in: .common) .autoconnect() .sink { [weak self] _ in log.debug("Periodic 60s recheck fired") self?.onActive() } } private func setupObservation() { // Fires when reconciliation transitions from running → done. // dropFirst() skips the initial subscription value so we don't fire on // startup before the first real reconciliation has run. statusService.$isRefreshing .dropFirst() .filter { !$0 } .receive(on: RunLoop.main) .sink { [weak self] _ in self?.handleReconciliationComplete() } .store(in: &cancellables) // Fires when NAS transitions to reachable — only acts when in .disconnected phase // so we don't duplicate the trigger on initial startup or during active backup. lanMonitor.$nasReachable .dropFirst() .compactMap { $0 } .filter { $0 } .receive(on: RunLoop.main) .sink { [weak self] _ in self?.handleNASReachable() } .store(in: &cancellables) } // MARK: — External triggers /// Call when the app becomes active: on first launch, foreground resume, or session unlock. /// Safe to call repeatedly — debounce prevents redundant reconciliation within 55s. func onActive(lanTriggered: Bool = false) { lastTriggerWasLAN = lanTriggered guard !engine.job.status.isActive else { return } guard store.savedConnection != nil else { return } guard phase == .idle else { return } // Non-LAN triggers are debounced: .task {} fires on every navigation appear, // and scenePhase fires on every foreground. Only allow one check per 55 seconds // so the user can background+foreground without restarting a new backup job. if !lanTriggered { let elapsed = Date().timeIntervalSince(lastAutoCheckAt) guard elapsed > 55 else { log.debug("onActive debounced — \(Int(elapsed))s since last check (need >55s)") return } } lastAutoCheckAt = Date() backupTriggerArmed = true phase = .checking startPeriodicRecheckIfNeeded() log.info("onActive: trigger=\(lanTriggered ? "LAN" : "appOpen") — status check starting") statusService.refresh(force: false) } // MARK: — Combine handlers private func handleReconciliationComplete() { let snapshot = statusService.snapshot // No destination configured yet guard let conn = store.savedConnection, conn.remotePath != "/" else { phase = .idle backupTriggerArmed = false return } // Auto-resolve: engine reported failure but reconcile shows all assets are safe. // This fires after refreshAfterBackup's writeManifest completes and updates the manifest, // at which point needBackup correctly reflects what's still missing. if case .failed = engine.job.status { if snapshot.needBackup == 0 { engine.resolveSuccess() log.info("Auto-resolved .failed → .completed (needBackup == 0 after reconcile)") } } // Nothing to back up guard snapshot.needBackup > 0 else { phase = .idle pendingAtDisconnect = 0 backupTriggerArmed = false return } // NAS offline — queue pending count and wait for reachability if snapshot.connectionState == .offline { pendingAtDisconnect = snapshot.needBackup phase = .disconnected backupTriggerArmed = false log.info("NAS offline — \(snapshot.needBackup) items queued") return } // Only fire auto-backup when explicitly armed by onActive() or handleNASReachable(). // Post-backup reconciles (from refreshAfterBackup) must not re-trigger — that's the // source of the restart loop when alreadySafe is temporarily wrong. guard backupTriggerArmed else { phase = .idle log.info("handleReconciliationComplete: trigger not armed — skipping auto-backup") return } // Gate auto-backup on trigger source: // LAN join → autoBackupEnabled // App open → autoBackupOnOpen (off by default) let autoAllowed = lastTriggerWasLAN ? store.autoBackupEnabled : store.autoBackupOnOpen guard autoAllowed else { phase = .idle backupTriggerArmed = false log.info("Auto-backup gated — trigger=\(self.lastTriggerWasLAN ? "LAN" : "appOpen") enabled=\(autoAllowed)") return } backupTriggerArmed = false triggerAutoBackupIfNeeded(conn: conn) } private func handleNASReachable() { guard case .disconnected = phase else { return } log.info("NAS reachable again — rescheduling auto-check (LAN trigger)") onActive(lanTriggered: true) } // MARK: — Auto-backup trigger private func triggerAutoBackupIfNeeded(conn: NASConnection) { guard canAutoBackup else { return } let pending = statusService.snapshot.needBackup log.info("Auto-backup triggered: \(pending) pending items → \(conn.remotePath)") phase = .autoBackingUp pendingAtDisconnect = 0 Task { do { _ = try await engine.run(connection: conn, filter: store.backupFilter) } catch { log.error("Auto-backup error: \(error.localizedDescription)") } // engine.run() calls refreshAfterBackup() which triggers reconciliation. // handleReconciliationComplete() will fire again and reset phase. if case .autoBackingUp = phase { phase = .idle } } } // MARK: — Guards private var canAutoBackup: Bool { // Already running guard !engine.job.status.isActive else { return false } // Quiet hours guard !store.isInQuietHours else { log.info("Auto-backup skipped — quiet hours active") return false } // Charging-only mode guard isBatteryOK else { log.info("Auto-backup skipped — not charging (chargingOnlyMode enabled)") return false } return true } private var isBatteryOK: Bool { guard store.chargingOnlyMode else { return true } let state = UIDevice.current.batteryState // .unknown covers simulator and unavailable reads — allow in those cases return state == .charging || state == .full || state == .unknown } }