Files
Kisani/Services/BackupStatusService.swift
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

455 lines
19 KiB
Swift

import Foundation
import Photos
import UIKit
import os.log
private let log = Logger(subsystem: "com.albert.nasbackup", category: "BackupStatus")
private let signposter = OSSignposter(subsystem: "com.albert.nasbackup", category: "BackupStatus")
@MainActor
final class BackupStatusService: NSObject, ObservableObject {
static let shared = BackupStatusService()
@Published private(set) var snapshot: BackupStatusSnapshot = .empty
@Published private(set) var isRefreshing: Bool = false
@Published private(set) var refreshError: String?
@Published private(set) var lastManifest: BackupManifest?
private let photoService = PhotoLibraryService()
private let cacheKey = "backupStatusSnapshot_v2"
// One active reconciliation task at a time cancelled before starting a new one.
private var refreshTask: Task<Void, Never>?
private var debounceTask: Task<Void, Never>?
private var lastFullRefresh: Date?
private static let debounceInterval: TimeInterval = 4
private static let minRefreshInterval: TimeInterval = 30
// MARK: Init
private override init() {
super.init()
loadCachedSnapshot()
PHPhotoLibrary.shared().register(self)
NotificationCenter.default.addObserver(
self,
selector: #selector(handleMemoryWarning),
name: UIApplication.didReceiveMemoryWarningNotification,
object: nil
)
}
deinit {
PHPhotoLibrary.shared().unregisterChangeObserver(self)
NotificationCenter.default.removeObserver(self)
}
@objc private func handleMemoryWarning() {
log.warning("Memory warning — cancelling in-flight refresh")
cancelAll()
isRefreshing = false
}
// MARK: Public API
func refresh(force: Bool = false) {
cancelAll()
refreshTask = Task { [weak self] in
await self?.performRefresh(force: force)
}
}
func refreshAndWait(force: Bool = true) async {
cancelAll()
await performRefresh(force: force)
}
func refreshAfterBackup(entries: [ManifestEntry], connection: NASConnection) {
let uploaded = entries.count
snapshot.alreadySafe = min(snapshot.phoneTotal, snapshot.alreadySafe + uploaded)
// nasArchiveTotal is NOT updated optimistically writeManifest will update
// NASManifestCache with the full merged count, and refresh(force: true) reads it.
saveSnapshot()
log.info("Post-backup optimistic update: +\(uploaded) safe")
Task {
await writeManifest(entries: entries, connection: connection)
refresh(force: true)
}
}
// MARK: Refresh lifecycle
private func cancelAll() {
refreshTask?.cancel()
refreshTask = nil
debounceTask?.cancel()
debounceTask = nil
}
private func performRefresh(force: Bool) async {
guard !isRefreshing else {
log.debug("Refresh skipped — already in progress")
return
}
if !force, let last = lastFullRefresh,
Date().timeIntervalSince(last) < Self.minRefreshInterval {
log.debug("Refresh debounced — updating phone count only")
updatePhoneCountOnly()
return
}
lastFullRefresh = Date()
isRefreshing = true
refreshError = nil
log.info("Refresh started (force=\(force))")
do {
try await reconcile()
log.info("Refresh completed — phone=\(self.snapshot.phoneTotal) safe=\(self.snapshot.alreadySafe)")
} catch is CancellationError {
log.info("Refresh cancelled")
} catch {
log.error("Refresh failed: \(error.localizedDescription)")
refreshError = error.localizedDescription
snapshot.connectionState = .offline
snapshot.lastCheckedAt = Date()
saveSnapshot()
}
isRefreshing = false
}
// MARK: Reconciliation
private func reconcile() async throws {
let spReconcile = signposter.beginInterval("Reconcile")
defer { signposter.endInterval("Reconcile", spReconcile) }
let store = ConnectionStore.shared
guard let conn = store.savedConnection else { return }
let filter = store.backupFilter
log.info("""
[reconcile] host=\(conn.host, privacy: .public) remotePath=\(conn.remotePath, privacy: .public) \
manifestPath=\(conn.remotePath, privacy: .public)/\(BackupManifest.remoteFilename, privacy: .public) \
filter=photos:\(filter.includePhotos) videos:\(filter.includeVideos) \
screenshots:\(filter.includeScreenshots) raw:\(filter.includeRAW) \
autoBackupEnabled=\(store.autoBackupEnabled) autoBackupOnOpen=\(store.autoBackupOnOpen)
""")
// Fast path: use local caches when both are warm
// Skips NAS connection + PHFetchResult batch enumeration entirely.
// Triggered when: manifest cache < 5 min old AND LocalPhotoIndex has data.
let manifestCacheIsStale = await NASManifestCache.shared.isStale
let localIndexCount = await LocalPhotoIndex.shared.totalCount
if !manifestCacheIsStale, let cached = await NASManifestCache.shared.manifest, localIndexCount > 0 {
let index = await Task.detached(priority: .utility) {
ManifestIndex(manifest: cached)
}.value
let safe = await LocalPhotoIndex.shared.countSafe(against: index, filter: filter)
let phoneTotal = await LocalPhotoIndex.shared.count(filter: filter)
var updated = BackupStatusSnapshot()
updated.phoneTotal = phoneTotal
updated.alreadySafe = min(safe, phoneTotal)
updated.nasArchiveTotal = index.totalCount
updated.connectionState = .connected
updated.lastCheckedAt = Date()
snapshot = updated
saveSnapshot()
log.info("Reconcile (cached): phone=\(phoneTotal) safe=\(safe) NAS=\(index.totalCount) needBackup=\(phoneTotal - min(safe, phoneTotal))")
return
}
// Full path: connect to NAS and download fresh manifest
// Step 1: Phone count (O(1) PHFetchResult.count)
let fetchResult = photoService.fetchResultForReconciliation(filter: filter)
let phoneTotal = fetchResult.count
log.info("PHFetchResult count: \(phoneTotal)")
try Task.checkCancellation()
// Step 2: NAS connection
let transfer: any NASTransferProtocol = conn.nasProtocol == .smb ? SMBService() : SFTPService()
do {
try await transfer.connect(
to: conn.host, port: conn.port,
username: conn.username, password: conn.password
)
} catch {
let reason = (error as? BackupError).flatMap { $0.errorDescription } ?? error.localizedDescription
log.warning("NAS connect failed: \(reason, privacy: .public) — retaining cached counts")
refreshError = reason
// Restore last-known NAS archive count from manifest cache so the dashboard
// doesn't show 0 when the NAS is temporarily offline.
if let cached = await NASManifestCache.shared.manifest {
let cachedCount = ManifestIndex(manifest: cached).totalCount
snapshot.nasArchiveTotal = max(snapshot.nasArchiveTotal, cachedCount)
log.info("NAS offline — restored nasArchiveTotal=\(self.snapshot.nasArchiveTotal) from cache")
}
snapshot.phoneTotal = phoneTotal
snapshot.alreadySafe = min(snapshot.alreadySafe, phoneTotal)
snapshot.connectionState = .offline
snapshot.lastCheckedAt = Date()
saveSnapshot()
return
}
defer { transfer.disconnect() }
try Task.checkCancellation()
// Step 3: Load ManifestIndex
let spManifest = signposter.beginInterval("ManifestLoad")
let manifestPath = "\(conn.remotePath)/\(BackupManifest.remoteFilename)"
let index = await buildManifestIndex(transfer: transfer, path: manifestPath, conn: conn)
signposter.endInterval("ManifestLoad", spManifest,
"\(index.totalCount) entries, filename-only=\(index.isFilenameOnly)")
log.info("Manifest index built — \(index.totalCount) NAS entries")
try Task.checkCancellation()
// Step 4: Count safe assets
// Prefer LocalPhotoIndex (pure in-memory, no CoreData) if populated;
// otherwise fall back to batch PHFetchResult enumeration.
let spCount = signposter.beginInterval("CountSafe")
let localCount = await LocalPhotoIndex.shared.totalCount
let safe: Int
if localCount > 0 {
safe = await LocalPhotoIndex.shared.countSafe(against: index, filter: filter)
} else {
safe = try await Task.detached(priority: .userInitiated) { [weak self, fetchResult, index] in
guard let self else { throw CancellationError() }
return try await self.countSafe(in: fetchResult, against: index)
}.value
}
signposter.endInterval("CountSafe", spCount, "\(safe)/\(phoneTotal) safe")
log.info("Comparison complete — \(safe)/\(phoneTotal) already safe")
// Step 5: Commit
var updated = BackupStatusSnapshot()
updated.phoneTotal = phoneTotal
updated.alreadySafe = min(safe, phoneTotal)
updated.nasArchiveTotal = index.totalCount
updated.connectionState = .connected
updated.lastCheckedAt = Date()
snapshot = updated
saveSnapshot()
}
/// Builds a ManifestIndex from NAS.
/// - The download suspends the main actor.
/// - JSON decode and Set construction run in a background task.
private func buildManifestIndex(
transfer: any NASTransferProtocol,
path: String,
conn: NASConnection
) async -> ManifestIndex {
do {
let data = try await transfer.downloadData(at: path)
// Decode and build index off main actor JSONDecoder is not cheap for large manifests.
let decoded: (ManifestIndex, BackupManifest)? = await Task.detached(priority: .utility) {
guard let manifest = try? JSONDecoder.kisani.decode(BackupManifest.self, from: data)
else { return nil }
return (ManifestIndex(manifest: manifest), manifest)
}.value
if let (index, manifest) = decoded {
lastManifest = manifest
Task { await NASManifestCache.shared.update(manifest) }
// Validity check: if the manifest has 1 entries it may be corrupt or newly
// created. Scan the directory to get the real file count for the NAS Archive
// display but do NOT add directory files as orphan manifest entries.
if index.totalCount <= 1 {
let items = (try? await transfer.listDirectory(at: conn.remotePath)) ?? []
let dirCount = items.filter {
!$0.isDirectory && !$0.name.hasPrefix(".") && $0.name != BackupManifest.remoteFilename
}.count
log.info("buildManifestIndex: manifest=\(index.totalCount) dir=\(dirCount) — using max for nasArchiveTotal")
if dirCount > index.totalCount {
return ManifestIndex(manifest: manifest, overrideTotalCount: dirCount)
}
}
return index
}
} catch {
// File not found fall through to directory listing bootstrap
}
// Bootstrap: list NAS directory and build filename-only index in background.
let items = (try? await transfer.listDirectory(at: conn.remotePath)) ?? []
log.info("Manifest not found — bootstrapping from \(items.count) NAS items")
return await Task.detached(priority: .utility) {
ManifestIndex(nasListing: items)
}.value
}
/// Enumerates a PHFetchResult in batches using autoreleasepool.
/// Marked nonisolated so it can be called from a background Task without
/// requiring main-actor scheduling it accesses no @MainActor state.
private nonisolated func countSafe(
in result: PHFetchResult<PHAsset>,
against index: ManifestIndex,
batchSize: Int = 150
) async throws -> Int {
var safe = 0
let total = result.count
var processed = 0
while processed < total {
try Task.checkCancellation()
let batchEnd = min(processed + batchSize, total)
// autoreleasepool releases PHAsset objects and PHAssetResource arrays
// created during this batch before moving to the next.
let batchSafe: Int = autoreleasepool {
var count = 0
for i in processed..<batchEnd {
let asset = result.object(at: i)
if index.matches(localIdentifier: asset.localIdentifier) {
count += 1
} else {
let resources = PHAssetResource.assetResources(for: asset)
if let name = resources.first?.originalFilename {
if index.isFilenameOnly && index.matches(filename: name) {
count += 1
} else if index.matchesUnclaimed(filename: name) {
count += 1
}
}
}
}
return count
}
safe += batchSafe
processed = batchEnd
await Task.yield()
}
return safe
}
// MARK: Phone-only lightweight update (no NAS connection)
private func updatePhoneCountOnly() {
let result = photoService.fetchResultForReconciliation(
filter: ConnectionStore.shared.backupFilter
)
let phoneTotal = result.count
guard phoneTotal != snapshot.phoneTotal else { return }
snapshot.phoneTotal = phoneTotal
snapshot.alreadySafe = min(snapshot.alreadySafe, phoneTotal)
saveSnapshot()
log.debug("Phone-only update: \(phoneTotal) assets")
}
// MARK: Manifest write (non-blocking, background only)
func writeManifest(entries: [ManifestEntry], connection: NASConnection) async {
guard !entries.isEmpty else { return }
// Capture cache before connecting used as fallback if NAS download fails.
// This prevents discarding accumulated history when the connection is flaky.
let cacheBase = await NASManifestCache.shared.manifest
let transfer: any NASTransferProtocol = connection.nasProtocol == .smb ? SMBService() : SFTPService()
do {
try await transfer.connect(
to: connection.host, port: connection.port,
username: connection.username, password: connection.password
)
defer { transfer.disconnect() }
let manifestPath = "\(connection.remotePath)/\(BackupManifest.remoteFilename)"
log.info("writeManifest: path=\(manifestPath, privacy: .public) entries=\(entries.count)")
// Try NAS download authoritative if successful.
let existingData = try? await transfer.downloadData(at: manifestPath)
// Merge + JSON encode off main actor can be slow for large manifests.
let result: (data: Data, manifest: BackupManifest)? = await Task.detached(priority: .utility) {
var manifest: BackupManifest
if let data = existingData,
let existing = try? JSONDecoder.kisani.decode(BackupManifest.self, from: data) {
manifest = existing // NAS is authoritative when readable
} else if let base = cacheBase {
manifest = base // NAS download failed preserve history from cache
} else {
manifest = BackupManifest()
}
manifest.merge(entries: entries)
guard let encoded = try? JSONEncoder.kisani.encode(manifest) else { return nil }
return (encoded, manifest)
}.value
guard let (encoded, mergedManifest) = result else { return }
try await transfer.writeData(encoded, to: manifestPath)
// Update local cache so fast-path reconcile reads fresh data.
await NASManifestCache.shared.update(mergedManifest)
lastManifest = mergedManifest
log.info("writeManifest: done — \(entries.count) new, total=\(mergedManifest.entries.count)")
} catch {
log.error("writeManifest failed (non-fatal): \(error.localizedDescription)")
}
}
// MARK: Cache
private func loadCachedSnapshot() {
guard let data = UserDefaults.standard.data(forKey: cacheKey),
let cached = try? JSONDecoder().decode(BackupStatusSnapshot.self, from: data)
else { return }
snapshot = cached
}
private func saveSnapshot() {
let encoder = JSONEncoder()
encoder.dateEncodingStrategy = .iso8601
if let data = try? encoder.encode(snapshot) {
UserDefaults.standard.set(data, forKey: cacheKey)
}
}
}
// MARK: PHPhotoLibraryChangeObserver
extension BackupStatusService: PHPhotoLibraryChangeObserver {
nonisolated func photoLibraryDidChange(_ changeInstance: PHChange) {
// Update LocalPhotoIndex incrementally avoids full rebuild on every change.
// We need the fetch result for change details; rebuild from scratch if unavailable.
Task(priority: .utility) {
let opts = PHFetchOptions()
let allAssets = PHAsset.fetchAssets(with: opts)
if let details = changeInstance.changeDetails(for: allAssets) {
await LocalPhotoIndex.shared.apply(
inserted: details.insertedObjects,
removed: details.removedObjects,
changed: details.changedObjects
)
await LocalPhotoIndex.shared.flush()
}
}
Task { @MainActor [weak self] in
guard let self else { return }
self.debounceTask?.cancel()
self.debounceTask = Task {
do {
try await Task.sleep(nanoseconds: UInt64(BackupStatusService.debounceInterval * 1_000_000_000))
self.refresh()
} catch {
// Cancelled a newer change event took over
}
}
}
}
}