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
|
|
|
|
|
|
2026-05-18 10:01:09 +03:00
|
|
|
private var lastTriggerWasLAN = false
|
2026-05-18 16:07:41 +03:00
|
|
|
private var lastAutoCheckAt: Date = .distantPast
|
|
|
|
|
private var recheckTimer: AnyCancellable?
|
2026-05-18 10:01:09 +03:00
|
|
|
|
2026-05-18 18:55:27 +03:00
|
|
|
// 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
|
|
|
|
|
|
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()
|
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
|
2026-05-18 18:55:27 +03:00
|
|
|
self?.backupTriggerArmed = false
|
2026-05-18 16:07:41 +03:00
|
|
|
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.
|
2026-05-18 16:07:41 +03:00
|
|
|
/// Safe to call repeatedly — debounce prevents redundant reconciliation within 55s.
|
2026-05-18 10:01:09 +03:00
|
|
|
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 }
|
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()
|
2026-05-18 18:55:27 +03:00
|
|
|
backupTriggerArmed = 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
|
|
|
phase = .checking
|
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
|
2026-05-18 18:55:27 +03:00
|
|
|
backupTriggerArmed = 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
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-18 18:55:27 +03:00
|
|
|
// 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
|
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 snapshot.needBackup > 0 else {
|
|
|
|
|
phase = .idle
|
|
|
|
|
pendingAtDisconnect = 0
|
2026-05-18 18:55:27 +03:00
|
|
|
backupTriggerArmed = 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
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// NAS offline — queue pending count and wait for reachability
|
|
|
|
|
if snapshot.connectionState == .offline {
|
|
|
|
|
pendingAtDisconnect = snapshot.needBackup
|
|
|
|
|
phase = .disconnected
|
2026-05-18 18:55:27 +03:00
|
|
|
backupTriggerArmed = 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
|
|
|
log.info("NAS offline — \(snapshot.needBackup) items queued")
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-18 18:55:27 +03:00
|
|
|
// 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
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-18 10:01:09 +03:00
|
|
|
// Gate auto-backup on trigger source:
|
2026-05-18 18:55:27 +03:00
|
|
|
// LAN join → autoBackupEnabled
|
|
|
|
|
// App open → autoBackupOnOpen (off by default)
|
2026-05-18 10:01:09 +03:00
|
|
|
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
|
2026-05-18 18:55:27 +03:00
|
|
|
backupTriggerArmed = false
|
2026-05-18 10:01:09 +03:00
|
|
|
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
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-18 18:55:27 +03:00
|
|
|
backupTriggerArmed = 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
|
|
|
triggerAutoBackupIfNeeded(conn: conn)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private func handleNASReachable() {
|
|
|
|
|
guard case .disconnected = phase else { return }
|
2026-05-18 10:01:09 +03:00
|
|
|
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
|
|
|
|
|
}
|
|
|
|
|
}
|