Files
Kisani/Services/AutoBackupCoordinator.swift

225 lines
8.0 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
Fix backup loop, NAS Archive regression, and manifest validity check - AutoBackupCoordinator: add 55s debounce to onActive() so repeated .task and scenePhase triggers (navigation appear, rapid foreground cycles) are no-ops; start a 60s periodic recheck timer on first active call; stop timer and reset debounce on willResignActiveNotification so each new foreground session always gets an immediate fresh status check - BackupView: remove the redundant lanMonitor.nasReachable onChange that was calling statusService.refresh() directly — coordinator already handles this via its Combine $nasReachable subscriber, avoiding a double reconcile that could bypass the autoBackupOnOpen gate with the wrong lastTriggerWasLAN value - BackupStatusService: when NAS connect fails, restore nasArchiveTotal from NASManifestCache before writing the offline snapshot — prevents the count from showing 0 when the NAS is temporarily unreachable but the cache is warm - BackupStatusService: manifest validity check — if the decoded manifest has ≤ 1 entries (corrupt or newly created), scan the NAS directory and use the real file count as the display floor for nasArchiveTotal without adding orphan entries to the manifest (that was the 7421 regression) - BackupManifest: add ManifestIndex.init(manifest:overrideTotalCount:) for the validity check path — keeps localIdentifier matching intact while correcting the displayed archive count - SMBService: add os.log around connect/auth/listDirectory with actual error reason instead of always surfacing authenticationFailed; distinguish network errors (timeout, host unreachable) from auth errors in thrown BackupError Co-Authored-By: Kutesir <kutesir@provoc.ug> Co-Authored-By: Sentry <sentry@provoc.ug>
2026-05-18 16:07:41 +03:00
private var lastAutoCheckAt: Date = .distantPast
private var recheckTimer: AnyCancellable?
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()
Fix backup loop, NAS Archive regression, and manifest validity check - AutoBackupCoordinator: add 55s debounce to onActive() so repeated .task and scenePhase triggers (navigation appear, rapid foreground cycles) are no-ops; start a 60s periodic recheck timer on first active call; stop timer and reset debounce on willResignActiveNotification so each new foreground session always gets an immediate fresh status check - BackupView: remove the redundant lanMonitor.nasReachable onChange that was calling statusService.refresh() directly — coordinator already handles this via its Combine $nasReachable subscriber, avoiding a double reconcile that could bypass the autoBackupOnOpen gate with the wrong lastTriggerWasLAN value - BackupStatusService: when NAS connect fails, restore nasArchiveTotal from NASManifestCache before writing the offline snapshot — prevents the count from showing 0 when the NAS is temporarily unreachable but the cache is warm - BackupStatusService: manifest validity check — if the decoded manifest has ≤ 1 entries (corrupt or newly created), scan the NAS directory and use the real file count as the display floor for nasArchiveTotal without adding orphan entries to the manifest (that was the 7421 regression) - BackupManifest: add ManifestIndex.init(manifest:overrideTotalCount:) for the validity check path — keeps localIdentifier matching intact while correcting the displayed archive count - SMBService: add os.log around connect/auth/listDirectory with actual error reason instead of always surfacing authenticationFailed; distinguish network errors (timeout, host unreachable) from auth errors in thrown BackupError Co-Authored-By: Kutesir <kutesir@provoc.ug> Co-Authored-By: Sentry <sentry@provoc.ug>
2026-05-18 16:07:41 +03:00
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
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()
}
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 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.
Fix backup loop, NAS Archive regression, and manifest validity check - AutoBackupCoordinator: add 55s debounce to onActive() so repeated .task and scenePhase triggers (navigation appear, rapid foreground cycles) are no-ops; start a 60s periodic recheck timer on first active call; stop timer and reset debounce on willResignActiveNotification so each new foreground session always gets an immediate fresh status check - BackupView: remove the redundant lanMonitor.nasReachable onChange that was calling statusService.refresh() directly — coordinator already handles this via its Combine $nasReachable subscriber, avoiding a double reconcile that could bypass the autoBackupOnOpen gate with the wrong lastTriggerWasLAN value - BackupStatusService: when NAS connect fails, restore nasArchiveTotal from NASManifestCache before writing the offline snapshot — prevents the count from showing 0 when the NAS is temporarily unreachable but the cache is warm - BackupStatusService: manifest validity check — if the decoded manifest has ≤ 1 entries (corrupt or newly created), scan the NAS directory and use the real file count as the display floor for nasArchiveTotal without adding orphan entries to the manifest (that was the 7421 regression) - BackupManifest: add ManifestIndex.init(manifest:overrideTotalCount:) for the validity check path — keeps localIdentifier matching intact while correcting the displayed archive count - SMBService: add os.log around connect/auth/listDirectory with actual error reason instead of always surfacing authenticationFailed; distinguish network errors (timeout, host unreachable) from auth errors in thrown BackupError Co-Authored-By: Kutesir <kutesir@provoc.ug> Co-Authored-By: Sentry <sentry@provoc.ug>
2026-05-18 16:07:41 +03:00
/// Safe to call repeatedly debounce prevents redundant reconciliation within 55s.
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 }
Fix backup loop, NAS Archive regression, and manifest validity check - AutoBackupCoordinator: add 55s debounce to onActive() so repeated .task and scenePhase triggers (navigation appear, rapid foreground cycles) are no-ops; start a 60s periodic recheck timer on first active call; stop timer and reset debounce on willResignActiveNotification so each new foreground session always gets an immediate fresh status check - BackupView: remove the redundant lanMonitor.nasReachable onChange that was calling statusService.refresh() directly — coordinator already handles this via its Combine $nasReachable subscriber, avoiding a double reconcile that could bypass the autoBackupOnOpen gate with the wrong lastTriggerWasLAN value - BackupStatusService: when NAS connect fails, restore nasArchiveTotal from NASManifestCache before writing the offline snapshot — prevents the count from showing 0 when the NAS is temporarily unreachable but the cache is warm - BackupStatusService: manifest validity check — if the decoded manifest has ≤ 1 entries (corrupt or newly created), scan the NAS directory and use the real file count as the display floor for nasArchiveTotal without adding orphan entries to the manifest (that was the 7421 regression) - BackupManifest: add ManifestIndex.init(manifest:overrideTotalCount:) for the validity check path — keeps localIdentifier matching intact while correcting the displayed archive count - SMBService: add os.log around connect/auth/listDirectory with actual error reason instead of always surfacing authenticationFailed; distinguish network errors (timeout, host unreachable) from auth errors in thrown BackupError Co-Authored-By: Kutesir <kutesir@provoc.ug> Co-Authored-By: Sentry <sentry@provoc.ug>
2026-05-18 16:07:41 +03:00
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()
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
Fix backup loop, NAS Archive regression, and manifest validity check - AutoBackupCoordinator: add 55s debounce to onActive() so repeated .task and scenePhase triggers (navigation appear, rapid foreground cycles) are no-ops; start a 60s periodic recheck timer on first active call; stop timer and reset debounce on willResignActiveNotification so each new foreground session always gets an immediate fresh status check - BackupView: remove the redundant lanMonitor.nasReachable onChange that was calling statusService.refresh() directly — coordinator already handles this via its Combine $nasReachable subscriber, avoiding a double reconcile that could bypass the autoBackupOnOpen gate with the wrong lastTriggerWasLAN value - BackupStatusService: when NAS connect fails, restore nasArchiveTotal from NASManifestCache before writing the offline snapshot — prevents the count from showing 0 when the NAS is temporarily unreachable but the cache is warm - BackupStatusService: manifest validity check — if the decoded manifest has ≤ 1 entries (corrupt or newly created), scan the NAS directory and use the real file count as the display floor for nasArchiveTotal without adding orphan entries to the manifest (that was the 7421 regression) - BackupManifest: add ManifestIndex.init(manifest:overrideTotalCount:) for the validity check path — keeps localIdentifier matching intact while correcting the displayed archive count - SMBService: add os.log around connect/auth/listDirectory with actual error reason instead of always surfacing authenticationFailed; distinguish network errors (timeout, host unreachable) from auth errors in thrown BackupError Co-Authored-By: Kutesir <kutesir@provoc.ug> Co-Authored-By: Sentry <sentry@provoc.ug>
2026-05-18 16:07:41 +03:00
startPeriodicRecheckIfNeeded()
log.info("onActive: trigger=\(lanTriggered ? "LAN" : "appOpen") — status check starting")
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
}
}