Commit Graph

10 Commits

Author SHA1 Message Date
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
2b2d869ae7 fix: backup loops because isFilenameOnly gate disables filename fallback for mixed manifests
Root cause: ManifestIndex.isFilenameOnly is false as soon as any single entry has a
localIdentifier. All filename-only entries (orphans uploaded in prior sessions without
a saved localIdentifier) are never matched, so their assets appear pending on every
reconcile and the backup re-starts from scratch on every app open.

Fix: add unclaimedFilenames set — filenames of entries with localIdentifier: "".
Use this as a targeted fallback in countSafe / pendingIDs / BackupEngine filter:
an asset not matched by localIdentifier is still considered safe if its filename
matches an unclaimed entry. This is safe because unclaimed entries are specifically
files we know are on NAS but whose identity we can only track by name.

Co-Authored-By: Kutesir <kutesir@provoc.ug>
Co-Authored-By: Sentry <sentry@provoc.ug>
2026-05-18 00:17:16 +03:00
Robin Kutesa
c7555a2001 fix: manifest cache not updated after backup causes NAS Archive = 1 and backup loop
- writeManifest now updates NASManifestCache after every successful NAS write;
  previously the fast-path reconcile would read the pre-backup cache entry
  (e.g. 1 entry) and overwrite nasArchiveTotal / alreadySafe with stale values
- BackupEngine seeds NASManifestCache when it loads the manifest so the cache
  is always at least as fresh as what the engine sees at backup-start time
- Remove snapshot.nasArchiveTotal += uploaded from refreshAfterBackup; the
  correct total comes from the merged manifest written to NAS, not an additive
  delta from a potentially-wrong baseline
- Fix JSONDecoder() → JSONDecoder.kisani throughout manifest load paths so
  ISO8601 dates decode correctly instead of silently failing and returning nil
- Add retry with exponential backoff (max 3 attempts, 2 s / 4 s intervals)
  to the upload loop; permanently-failed items are now marked .failed only
  after all retries are exhausted

Co-Authored-By: Kutesir <kutesir@provoc.ug>
Co-Authored-By: Sentry <sentry@provoc.ug>
2026-05-18 00:03:10 +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
025326f2b2 feat: redesign Gallery as photo timeline with sync state, deduplication, and detail view
- GalleryItem: unified model with phone/nas/synced source, sync state enum
- GalleryViewModel: off-actor PHFetchResult + manifest merge, Phone/NAS/All tab filtering, date section grouping, Combine-driven reactivity
- GalleryView: sticky date section headers, 3-col grid, shimmer placeholders, sync dots (green/orange/blue), video duration badge, search bar, context menu (Share, Save to Photos, Delete from Device)
- GalleryDetailView: full-screen swipeable photo viewer (TabView page mode), pinch-to-zoom, double-tap zoom toggle, bottom metadata sheet with file info and actions
- BackupStatusService: expose lastManifest for gallery deduplication without extra NAS connection
- ThumbnailCache: add decodeOffThread static helper for off-actor JPEG decode
- BackupManifest: Sendable conformance for safe Task.detached capture
- ThumbnailCache: synced items load from PHImageManager (instant) instead of NAS

Co-Authored-By: Kutesir <kutesir@provoc.ug>
Co-Authored-By: Sentry <sentry@provoc.ug>
2026-05-17 21:30:31 +03:00
Robin Kutesa
0f62268af1 perf: move heavy work off main actor, throttle progress updates, add signposts
BackupEngine:
- fetchAssets() runs in Task.detached — prevents 100-500ms UI freeze during
  PHFetchResult enumeration on large libraries
- Manifest JSON decode + ManifestIndex Set construction run in Task.detached
- pendingAssets filter runs in Task.detached — O(N) array filter off main thread
- Progress updates throttled to 8 Hz via ProgressThrottle — eliminates the
  continuous SwiftUI full-tree redraws caused by per-byte upload callbacks
- bytesSoFar captured by value in upload closure — avoids mutable var capture
- reserveCapacity on manifestEntries array
- os_signpost intervals on FetchAssets, NASConnect, ManifestLoad,
  BuildPendingQueue, UploadLoop, UploadFile for Instruments profiling

BackupStatusService:
- countSafe() marked nonisolated — runs in Task.detached off main actor,
  keeping touch/animation responsive during PHFetchResult batch enumeration
- ManifestIndex JSON decode + Set construction run in Task.detached
- Bootstrap ManifestIndex(nasListing:) construction off main actor
- writeManifest JSON decode/encode/merge off main actor
- os_signpost intervals on Reconcile, ManifestLoad, CountSafe

Model types:
- ManifestIndex, ManifestEntry, PhotoAsset, BackupFilter marked Sendable —
  safe to pass across task boundaries without warnings

Co-Authored-By: Kutesir <kutesir@provoc.ug>
Co-Authored-By: Sentry <sentry@provoc.ug>
2026-05-17 16:42:48 +03:00
Robin Kutesa
f40cf9ee37 Fix backup progress state machine: pending queue, resolvedStatus, failure resolution
- BackupEngine: pre-filter assets against ManifestIndex before job.start() so
  totalFiles reflects only pending items (not full gallery). Short-circuit with
  allAlreadySafe() when queue is empty.
- BackupJob: add resolveSuccess() (upgrades stale .failed → .completed after
  reconciliation proves needBackup==0) and allAlreadySafe() for empty-queue case.
- BackupStatusService: expose refreshAndWait(force:) async for pull-to-refresh
  and post-failure reconciliation.
- BackupView: wire resolvedStatus throughout ringContentID, ringMainView,
  ringColor, and actionButton; fix progress label to show pending-queue
  denominator; handle .failed in onChange by reconciling and auto-resolving
  to .completed when counts confirm all photos are safe.

Co-Authored-By: Kutesir <kutesir@provoc.ug>
Co-Authored-By: Sentry <sentry@provoc.ug>
2026-05-17 15:58:54 +03:00
Robin Kutesa
4a7ba9b332 Make backup status reconciliation memory-safe
Replace full [PhotoAsset] array enumeration with PHFetchResult batch
enumeration (autoreleasepool + Task.yield per batch) to keep memory
bounded during reconciliation. Introduce ManifestIndex with two O(1)
Set<String> lookups (localIdentifier + lowercased filename) instead of
O(n×m) linear scans. Scope manifest Data+struct inside buildManifestIndex
so they are released by ARC before the comparison loop begins. Add
UIApplication.didReceiveMemoryWarningNotification handler to cancel
in-flight tasks under memory pressure. Separate debounceTask from
refreshTask to avoid cancelling long-running reconciles on every photo
library event. fetchResultForReconciliation returns PHFetchResult
directly — no [PhotoAsset] array is ever allocated during reconciliation.

Co-Authored-By: Kutesir <kutesir@provoc.ug>
Co-Authored-By: Sentry <sentry@provoc.ug>
2026-05-17 13:06:44 +03:00
Robin Kutesa
2225629dd8 Fix invalid await inside ?? autoclosure in BackupStatusService
Swift does not allow async expressions inside ?? closures.
Replaced with explicit if-let branch so await is at statement level.

Co-Authored-By: Kutesir <kutesir@provoc.ug>
Co-Authored-By: Sentry <sentry@provoc.ug>
2026-05-17 12:57:05 +03:00
Robin Kutesa
712e110f57 Add live backup status reconciliation with NAS manifest
BackupStatusService (new singleton, ObservableObject, PHPhotoLibraryChangeObserver):
- Loads cached BackupStatusSnapshot instantly from UserDefaults on init
- Full reconcile: fetch phone assets → connect NAS → load/build manifest → compare
  → enforce invariant (alreadySafe ≤ phoneTotal), persist result
- Debounced (30s) for photo library changes; force=true bypasses debounce
- If NAS unreachable: keeps last cached numbers, marks connectionState = .offline
- PHPhotoLibraryChangeObserver triggers refresh on any library change

BackupManifest (new):
- Stored at {remotePath}/.kisani.json on NAS
- Indexed by localIdentifier (PHAsset stable ID); filename fallback for legacy entries
- Built from directory listing if manifest missing (bootstrap for existing backups)
- merged/updated after each backup run via NASTransferProtocol.writeData

BackupStatusSnapshot (new):
- Single source of truth: phoneTotal, alreadySafe, needBackup (derived), nasArchiveTotal,
  lastCheckedAt, connectionState
- Invariant enforced in service: alreadySafe = min(safe, phoneTotal)

Protocol / services:
- NASTransferProtocol: adds writeData(_ data: Data, to remotePath: String)
- SMBService: implements writeData via temp file + SMBClient.upload
- SFTPService: implements writeData via SFTP ByteBuffer write

BackupEngine:
- Tracks ManifestEntry per successful upload during backup loop
- After backup: calls BackupStatusService.refreshAfterBackup(entries:connection:)
  which applies optimistic UI update then writes manifest + triggers reconcile

BackupView:
- Reads all stats from BackupStatusService.snapshot (not vm/nasFileCount)
- Stats labels: "NAS Archive" / "On iPhone" / "Need Backup" / "Already Safe"
- Live refresh triggers: .task (force), scenePhase.active (force),
  nasReachable change (force), remotePath change (force), backup completed (+2s)
- Subtle status row below stats: "Checking…" spinner or "Updated X ago" with
  wifi-slash icon when NAS offline; tap refresh button for forced reconcile
- AppMenuView sheet now correctly passes engine EnvironmentObject

BackupViewModel: stripped to auth-only (photosAuthStatus + requestPhotosAccess)

Co-Authored-By: Kutesir <kutesir@provoc.ug>
Co-Authored-By: Sentry <sentry@provoc.ug>
2026-05-17 12:51:03 +03:00