Files
Kisani/Services/AutoBackupCoordinator.swift
Robin Kutesa 1afcbaff96 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

171 lines
5.9 KiB
Swift

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 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() {
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)
}
// 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
}
// Auto-backup setting is off show count but don't start
guard store.autoBackupEnabled else {
phase = .idle
return
}
triggerAutoBackupIfNeeded(conn: conn)
}
private func handleNASReachable() {
guard case .disconnected = phase else { return }
log.info("NAS reachable again — rescheduling auto-check")
onActive()
}
// 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
}
}