feat: automatic backup on launch, foreground, and photo library change
AutoBackupCoordinator (new):
- @MainActor singleton that owns all auto-backup decisions
- Phase model: idle / checking / autoBackingUp / disconnected
- idle: nothing pending or destination not set
- checking: reconciliation in progress before decision
- autoBackingUp: coordinator-triggered BackupEngine run active
- disconnected: NAS offline, pending count queued for retry
- Combine subscription on BackupStatusService.$isRefreshing fires
handleReconciliationComplete() on every reconcile completion — the
single entry point for all auto-backup decisions
- Combine subscription on LANMonitor.$nasReachable fires
handleNASReachable() which retries only when phase == .disconnected
- Guards: autoBackupEnabled, quiet hours, chargingOnlyMode + battery state
- canAutoBackup also blocks when engine.job.status.isActive to prevent
double-start from concurrent photo library change + foreground events
BackupView:
- .task + .onChange(of: scenePhase) now call coordinator.onActive() instead
of statusService.refresh() directly — coordinator decides whether to also
start a backup after reconciliation completes
- ringContentID includes coordinator phase suffix → ring animates on
checking/disconnected transitions
- ringCenterContent case 0: when engine is idle, shows:
- disconnectedRingView if phase == .disconnected and needBackup > 0
(wifi.slash icon, "Waiting for NAS", queued count)
- checkingRingView if phase == .checking (spinner, "Checking…")
- existing ringMainView otherwise
- ringColor: dim orange (0.35 opacity) when disconnected with pending items
- Pull-to-refresh reconciliation triggers auto-backup via Combine chain
ConnectionStore:
- autoBackupEnabled: Bool, default true, UserDefaults-persisted
SettingsView:
- AUTO BACKUP section at top with autoBackupEnabled toggle
BackgroundTaskManager:
- BGProcessingTask: quiet-hours guard, requiresExternalPower mirrors
chargingOnlyMode, expiration handler calls engine.cancel() for clean exit
- BGAppRefreshTask: lightweight refresh only (no upload in background refresh)
- Both tasks reschedule themselves before doing any work
AppDelegate:
- UIDevice.current.isBatteryMonitoringEnabled = true for battery checks
NASBackupApp:
- AutoBackupCoordinator.shared injected as @StateObject + environmentObject
Co-Authored-By: Kutesir <kutesir@provoc.ug>
Co-Authored-By: Sentry <sentry@provoc.ug>
This commit is contained in:
167
Services/AutoBackupCoordinator.swift
Normal file
167
Services/AutoBackupCoordinator.swift
Normal file
@@ -0,0 +1,167 @@
|
||||
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 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() {
|
||||
guard !engine.job.status.isActive else { return }
|
||||
guard store.savedConnection != nil else { return }
|
||||
phase = .checking
|
||||
statusService.refresh(force: true)
|
||||
// handleReconciliationComplete() fires via Combine when the refresh settles
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
// Auto-backup setting is off — show count but don't start
|
||||
guard store.autoBackupEnabled else {
|
||||
phase = .idle
|
||||
return
|
||||
}
|
||||
|
||||
triggerAutoBackupIfNeeded(conn: conn)
|
||||
}
|
||||
|
||||
private func handleNASReachable() {
|
||||
guard case .disconnected = phase else { return }
|
||||
log.info("NAS reachable again — rescheduling auto-check")
|
||||
onActive()
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,11 @@
|
||||
import Foundation
|
||||
import BackgroundTasks
|
||||
import os.log
|
||||
|
||||
private let log = Logger(subsystem: "com.albert.nasbackup", category: "BGTask")
|
||||
|
||||
final class BackgroundTaskManager {
|
||||
static let backupTaskID = "com.albert.nasbackup.backup"
|
||||
static let backupTaskID = "com.albert.nasbackup.backup"
|
||||
static let refreshTaskID = "com.albert.nasbackup.refresh"
|
||||
|
||||
static func registerTasks() {
|
||||
@@ -26,12 +29,13 @@ final class BackgroundTaskManager {
|
||||
static func scheduleBackup(delay: TimeInterval = 30) {
|
||||
let request = BGProcessingTaskRequest(identifier: backupTaskID)
|
||||
request.requiresNetworkConnectivity = true
|
||||
request.requiresExternalPower = false
|
||||
// Respect chargingOnlyMode: if set, only run when plugged in.
|
||||
request.requiresExternalPower = ConnectionStore.shared.chargingOnlyMode
|
||||
request.earliestBeginDate = Date(timeIntervalSinceNow: delay)
|
||||
do {
|
||||
try BGTaskScheduler.shared.submit(request)
|
||||
} catch {
|
||||
print("BGTask schedule failed: \(error)")
|
||||
log.warning("BGProcessingTask schedule failed: \(error.localizedDescription)")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -41,31 +45,71 @@ final class BackgroundTaskManager {
|
||||
do {
|
||||
try BGTaskScheduler.shared.submit(request)
|
||||
} catch {
|
||||
print("BGRefresh schedule failed: \(error)")
|
||||
log.warning("BGAppRefreshTask schedule failed: \(error.localizedDescription)")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: — BGProcessingTask: best-effort full backup
|
||||
|
||||
private static func handleBackupTask(_ task: BGProcessingTask) {
|
||||
scheduleBackup()
|
||||
task.expirationHandler = { Task { await BackupEngine.shared.cancel() } }
|
||||
scheduleBackup() // Reschedule before doing any work so we always get a next shot
|
||||
|
||||
let store = ConnectionStore.shared
|
||||
|
||||
// Quiet hours: skip silently — next scheduled slot will try again
|
||||
guard !store.isInQuietHours else {
|
||||
log.info("BGBackup skipped — quiet hours")
|
||||
task.setTaskCompleted(success: true)
|
||||
return
|
||||
}
|
||||
|
||||
guard let connection = store.savedConnection, connection.remotePath != "/" else {
|
||||
log.info("BGBackup skipped — no destination configured")
|
||||
task.setTaskCompleted(success: false)
|
||||
return
|
||||
}
|
||||
|
||||
// Expiration handler: cancel current upload cleanly.
|
||||
// BackupEngine.cancel() sets isCancelled = true; the upload loop checks this
|
||||
// between files and exits. defer { transfer.disconnect() } in engine.run()
|
||||
// ensures the NAS connection is always closed before iOS reclaims the slot.
|
||||
task.expirationHandler = {
|
||||
log.warning("BGBackup expiring — cancelling upload")
|
||||
Task { @MainActor in BackupEngine.shared.cancel() }
|
||||
}
|
||||
|
||||
Task { @MainActor in
|
||||
do {
|
||||
let store = ConnectionStore.shared
|
||||
guard let connection = store.savedConnection else {
|
||||
task.setTaskCompleted(success: false)
|
||||
return
|
||||
}
|
||||
let filter = store.backupFilter
|
||||
_ = try await BackupEngine.shared.run(connection: connection, filter: filter, triggeredByLAN: true)
|
||||
log.info("BGBackup starting")
|
||||
_ = try await BackupEngine.shared.run(
|
||||
connection: connection,
|
||||
filter: store.backupFilter,
|
||||
triggeredByLAN: true
|
||||
)
|
||||
log.info("BGBackup completed")
|
||||
task.setTaskCompleted(success: true)
|
||||
} catch {
|
||||
log.error("BGBackup failed: \(error.localizedDescription)")
|
||||
task.setTaskCompleted(success: false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: — BGAppRefreshTask: lightweight status check only
|
||||
|
||||
private static func handleRefreshTask(_ task: BGAppRefreshTask) {
|
||||
scheduleAppRefresh()
|
||||
task.setTaskCompleted(success: true)
|
||||
scheduleAppRefresh() // Always reschedule
|
||||
|
||||
task.expirationHandler = {
|
||||
task.setTaskCompleted(success: false)
|
||||
}
|
||||
|
||||
Task { @MainActor in
|
||||
// Light metadata-only refresh — no NAS upload, just phone count update.
|
||||
// This keeps the cached status fresh so the dashboard shows accurate data
|
||||
// when the user opens the app.
|
||||
BackupStatusService.shared.refresh(force: false)
|
||||
task.setTaskCompleted(success: true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user