Files
Kisani/Services/AutoBackupCoordinator.swift
Robin Kutesa 8d8b6c4418 fix: resolve duplicate-file failures, alreadySafe reset, and backup restart loop
BackupEngine: after all retries fail, call fileExists() before recording a
failure. If the file is on NAS (uploaded earlier but fileExists silently
threw on the pre-check), count it as skipped and add it to manifestEntries —
this ensures writeManifest includes every safe asset and prevents alreadySafe
from dropping to 1 after the post-backup reconcile.

BackupView: remove the immediate refreshAndWait from onChange(.failed). That
call raced with refreshAfterBackup's writeManifest, cancelled the manifest-
correct refresh, then reconciled against a stale 1-entry NAS manifest.
Auto-resolve (.failed → .completed when needBackup == 0) is now owned by the
coordinator so it runs after writeManifest finishes.

AutoBackupCoordinator: add backupTriggerArmed flag (set by onActive/
handleNASReachable, cleared by handleReconciliationComplete). Post-backup
reconciles from refreshAfterBackup are no longer able to re-trigger auto-
backup — only an explicit app-open or LAN-join can arm the trigger. Also
added auto-resolve: calls engine.resolveSuccess() when job is .failed but
needBackup == 0, which fires the .completed green-flash path in BackupView.

Co-Authored-By: Kutesir <kutesir@provoc.ug>
Co-Authored-By: Sentry <sentry@provoc.ug>
2026-05-18 18:55:27 +03:00

256 lines
9.4 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 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<AnyCancellable>()
// 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
}
}