Fix backup loop, phone count invariant, and auto-start behaviour

- LocalPhotoIndex: add count(filter:) so fast-path reconcile uses the
  filtered asset count as phoneTotal instead of the unfiltered totalCount;
  fixes the broken alreadySafe + needBackup == phoneTotal invariant

- BackupStatusService: fast-path uses count(filter:) instead of totalCount;
  writeManifest falls back to NASManifestCache when NAS download fails so
  accumulated history is never silently discarded on a flaky connection

- ConnectionStore: autoBackupEnabled defaults false; new autoBackupOnOpen
  (default false) separates LAN-join auto-backup from app-open auto-backup

- AutoBackupCoordinator: onActive(lanTriggered:) tracks trigger source;
  handleReconciliationComplete gates on autoBackupEnabled for LAN joins and
  autoBackupOnOpen for app-open — prevents backup starting on every launch

- SettingsView: split auto-backup into two toggles with accurate descriptions

Co-Authored-By: Kutesir <kutesir@provoc.ug>
Co-Authored-By: Sentry <sentry@provoc.ug>
This commit is contained in:
Robin Kutesa
2026-05-18 10:01:09 +03:00
parent b18a2aad14
commit 3beea88fab
5 changed files with 61 additions and 22 deletions

View File

@@ -29,6 +29,8 @@ final class AutoBackupCoordinator: ObservableObject {
@Published private(set) var phase: Phase = .idle
@Published private(set) var pendingAtDisconnect: Int = 0
private var lastTriggerWasLAN = false
private let engine = BackupEngine.shared
private let statusService = BackupStatusService.shared
private let store = ConnectionStore.shared
@@ -68,14 +70,12 @@ final class AutoBackupCoordinator: ObservableObject {
/// Call when the app becomes active: on first launch, foreground resume, or session unlock.
/// Safe to call repeatedly guards prevent redundant reconciliation.
func onActive() {
func onActive(lanTriggered: Bool = false) {
lastTriggerWasLAN = lanTriggered
guard !engine.job.status.isActive else { return }
guard store.savedConnection != nil else { return }
guard phase == .idle else { return } // Don't interrupt an in-progress check
phase = .checking
// force: false BackupStatusService's 30-second debounce prevents expensive
// rescans on rapid foreground/background cycles. The cached snapshot is shown
// immediately; reconciliation only runs when truly stale.
statusService.refresh(force: false)
}
@@ -105,9 +105,16 @@ final class AutoBackupCoordinator: ObservableObject {
return
}
// Auto-backup setting is off show count but don't start
guard store.autoBackupEnabled else {
// 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 {
phase = .idle
log.info("Auto-backup gated — trigger=\(self.lastTriggerWasLAN ? "LAN" : "appOpen") enabled=\(autoAllowed)")
return
}
@@ -116,8 +123,8 @@ final class AutoBackupCoordinator: ObservableObject {
private func handleNASReachable() {
guard case .disconnected = phase else { return }
log.info("NAS reachable again — rescheduling auto-check")
onActive()
log.info("NAS reachable again — rescheduling auto-check (LAN trigger)")
onActive(lanTriggered: true)
}
// MARK: Auto-backup trigger