Files
Kisani/Services/AutoBackupCoordinator.swift

178 lines
6.2 KiB
Swift
Raw Normal View History

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>
2026-05-17 17:28:11 +03:00
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
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>
2026-05-17 17:28:11 +03:00
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
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>
2026-05-17 17:28:11 +03:00
guard !engine.job.status.isActive else { return }
guard store.savedConnection != nil else { return }
perf: eliminate full rescans with local caches; add queue tracking, notifications, SyncView redesign Cached indexes: - NASManifestCache: actor-based disk cache for the NAS manifest (kisani_manifest_cache.json) Avoids NAS network round-trip when cache < 5 min old. Updated after every manifest download. - LocalPhotoIndex: actor-based disk cache of phone asset metadata (kisani_local_index.json) Built once on first launch, then updated incrementally via PHPhotoLibraryChangeObserver. O(1) countSafe via in-memory dict (vs PHFetchResult batch enumeration + CoreData calls). Fast path reconcile: When both caches are warm, reconcile() skips NAS connection + PHFetchResult enumeration entirely and returns in microseconds. Falls through to full NAS path only when stale. AutoBackupCoordinator.onActive(): Changed force: true → force: false so the 30-second debounce prevents expensive rescans on rapid app foreground/background cycles. Dashboard appears instantly from cached snapshot. BackupQueueItem + queue tracking: BackupEngine publishes queueItems: [BackupQueueItem] with per-file status, progress, speed, error details. Queue is persisted to disk and reloaded on next launch so SyncView always has data to show even after restart. NotificationService: Centralised local notification sender replacing the inline UNMutableNotificationContent blocks. Sends for: backup started, completed, failed, paused, NAS offline. Uses category identifiers so same-type notifications replace each other. SyncView redesign: - Removed pill/chip around NAS IP — replaced with plain "Online/Offline/Checking" dot+text - No live NAS directory listing (was expensive NAS connection on every tab open) - Shows engine.queueItems grouped into Active / Failed / Completed sections - Per-file rows with inline progress bar + bytes/speed for uploading items, error text for failed - Idle state shows archive count from statusService snapshot GalleryViewModel: NAS tab and All tab now use NASManifestCache as fallback when statusService.lastManifest is nil (offline launch). Gallery shows NAS items even when NAS is unreachable. AppDelegate: Bootstraps LocalPhotoIndex.loadOrBuild() in background on launch (non-blocking). Co-Authored-By: Kutesir <kutesir@provoc.ug> Co-Authored-By: Sentry <sentry@provoc.ug>
2026-05-17 23:28:29 +03:00
guard phase == .idle else { return } // Don't interrupt an in-progress check
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>
2026-05-17 17:28:11 +03:00
phase = .checking
perf: eliminate full rescans with local caches; add queue tracking, notifications, SyncView redesign Cached indexes: - NASManifestCache: actor-based disk cache for the NAS manifest (kisani_manifest_cache.json) Avoids NAS network round-trip when cache < 5 min old. Updated after every manifest download. - LocalPhotoIndex: actor-based disk cache of phone asset metadata (kisani_local_index.json) Built once on first launch, then updated incrementally via PHPhotoLibraryChangeObserver. O(1) countSafe via in-memory dict (vs PHFetchResult batch enumeration + CoreData calls). Fast path reconcile: When both caches are warm, reconcile() skips NAS connection + PHFetchResult enumeration entirely and returns in microseconds. Falls through to full NAS path only when stale. AutoBackupCoordinator.onActive(): Changed force: true → force: false so the 30-second debounce prevents expensive rescans on rapid app foreground/background cycles. Dashboard appears instantly from cached snapshot. BackupQueueItem + queue tracking: BackupEngine publishes queueItems: [BackupQueueItem] with per-file status, progress, speed, error details. Queue is persisted to disk and reloaded on next launch so SyncView always has data to show even after restart. NotificationService: Centralised local notification sender replacing the inline UNMutableNotificationContent blocks. Sends for: backup started, completed, failed, paused, NAS offline. Uses category identifiers so same-type notifications replace each other. SyncView redesign: - Removed pill/chip around NAS IP — replaced with plain "Online/Offline/Checking" dot+text - No live NAS directory listing (was expensive NAS connection on every tab open) - Shows engine.queueItems grouped into Active / Failed / Completed sections - Per-file rows with inline progress bar + bytes/speed for uploading items, error text for failed - Idle state shows archive count from statusService snapshot GalleryViewModel: NAS tab and All tab now use NASManifestCache as fallback when statusService.lastManifest is nil (offline launch). Gallery shows NAS items even when NAS is unreachable. AppDelegate: Bootstraps LocalPhotoIndex.loadOrBuild() in background on launch (non-blocking). Co-Authored-By: Kutesir <kutesir@provoc.ug> Co-Authored-By: Sentry <sentry@provoc.ug>
2026-05-17 23:28:29 +03:00
statusService.refresh(force: false)
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>
2026-05-17 17:28:11 +03:00
}
// 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 {
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>
2026-05-17 17:28:11 +03:00
phase = .idle
log.info("Auto-backup gated — trigger=\(self.lastTriggerWasLAN ? "LAN" : "appOpen") enabled=\(autoAllowed)")
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>
2026-05-17 17:28:11 +03:00
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)
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>
2026-05-17 17:28:11 +03:00
}
// 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
}
}