5 Commits

Author SHA1 Message Date
Robin Kutesa
8d8b6c4418 fix: resolve duplicate-file failures, alreadySafe reset, and backup restart loop
BackupEngine: after all retries fail, call fileExists() before recording a
failure. If the file is on NAS (uploaded earlier but fileExists silently
threw on the pre-check), count it as skipped and add it to manifestEntries —
this ensures writeManifest includes every safe asset and prevents alreadySafe
from dropping to 1 after the post-backup reconcile.

BackupView: remove the immediate refreshAndWait from onChange(.failed). That
call raced with refreshAfterBackup's writeManifest, cancelled the manifest-
correct refresh, then reconciled against a stale 1-entry NAS manifest.
Auto-resolve (.failed → .completed when needBackup == 0) is now owned by the
coordinator so it runs after writeManifest finishes.

AutoBackupCoordinator: add backupTriggerArmed flag (set by onActive/
handleNASReachable, cleared by handleReconciliationComplete). Post-backup
reconciles from refreshAfterBackup are no longer able to re-trigger auto-
backup — only an explicit app-open or LAN-join can arm the trigger. Also
added auto-resolve: calls engine.resolveSuccess() when job is .failed but
needBackup == 0, which fires the .completed green-flash path in BackupView.

Co-Authored-By: Kutesir <kutesir@provoc.ug>
Co-Authored-By: Sentry <sentry@provoc.ug>
2026-05-18 18:55:27 +03:00
Robin Kutesa
d2190bce8a 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
Robin Kutesa
3beea88fab 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>
2026-05-18 10:01:09 +03:00
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
Robin Kutesa
5b68fde62f 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