- LocalPhotoIndex: add count(filter:) so fast-path reconcile uses the filtered asset count as phoneTotal instead of the unfiltered totalCount; fixes the broken alreadySafe + needBackup == phoneTotal invariant - BackupStatusService: fast-path uses count(filter:) instead of totalCount; writeManifest falls back to NASManifestCache when NAS download fails so accumulated history is never silently discarded on a flaky connection - ConnectionStore: autoBackupEnabled defaults false; new autoBackupOnOpen (default false) separates LAN-join auto-backup from app-open auto-backup - AutoBackupCoordinator: onActive(lanTriggered:) tracks trigger source; handleReconciliationComplete gates on autoBackupEnabled for LAN joins and autoBackupOnOpen for app-open — prevents backup starting on every launch - SettingsView: split auto-backup into two toggles with accurate descriptions Co-Authored-By: Kutesir <kutesir@provoc.ug> Co-Authored-By: Sentry <sentry@provoc.ug>
178 lines
6.2 KiB
Swift
178 lines
6.2 KiB
Swift
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 let engine = BackupEngine.shared
|
|
private let statusService = BackupStatusService.shared
|
|
private let store = ConnectionStore.shared
|
|
private let lanMonitor = LANMonitor.shared
|
|
|
|
private var cancellables = Set<AnyCancellable>()
|
|
|
|
// MARK: — Init
|
|
|
|
private init() {
|
|
setupObservation()
|
|
}
|
|
|
|
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 — guards prevent redundant reconciliation.
|
|
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 } // Don't interrupt an in-progress check
|
|
phase = .checking
|
|
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
|
|
return
|
|
}
|
|
|
|
// Nothing to back up — clear any stale state
|
|
guard snapshot.needBackup > 0 else {
|
|
phase = .idle
|
|
pendingAtDisconnect = 0
|
|
return
|
|
}
|
|
|
|
// NAS offline — queue pending count and wait for reachability
|
|
if snapshot.connectionState == .offline {
|
|
pendingAtDisconnect = snapshot.needBackup
|
|
phase = .disconnected
|
|
log.info("NAS offline — \(snapshot.needBackup) items queued")
|
|
return
|
|
}
|
|
|
|
// Gate auto-backup on trigger source:
|
|
// LAN join → autoBackupEnabled (lanTriggerEnabled guards the SSID check in LANMonitor)
|
|
// App open → autoBackupOnOpen (off by default; requires explicit opt-in)
|
|
let autoAllowed = lastTriggerWasLAN
|
|
? store.autoBackupEnabled
|
|
: store.autoBackupOnOpen
|
|
|
|
guard autoAllowed else {
|
|
phase = .idle
|
|
log.info("Auto-backup gated — trigger=\(self.lastTriggerWasLAN ? "LAN" : "appOpen") enabled=\(autoAllowed)")
|
|
return
|
|
}
|
|
|
|
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
|
|
}
|
|
}
|