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:
Robin Kutesa
2026-05-17 17:28:11 +03:00
parent 0f62268af1
commit 5b68fde62f
8 changed files with 337 additions and 30 deletions

View File

@@ -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)
}
}
}