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

174 lines
6.8 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import Foundation
import Photos
import os.log
private let log = Logger(subsystem: "com.albert.nasbackup", category: "LocalPhotoIndex")
/// On-device index of phone asset metadata eliminates full PHFetchResult
/// enumeration for status counts after the initial build.
///
/// Build cost: O(N) on first launch, then zero on every subsequent open.
/// Update cost: O(changed) via PHPhotoLibraryChangeObserver.
/// Memory: ~100 bytes × asset count (1 MB for 10 k photos).
actor LocalPhotoIndex {
static let shared = LocalPhotoIndex()
struct Record: Codable, Sendable {
let localIdentifier: String
var filename: String
var creationDate: Date?
var mediaType: Int // PHAssetMediaType.rawValue
var isScreenshot: Bool
var isRAW: Bool
}
private var records: [String: Record] = [:]
private let diskURL: URL
private var needsSave = false
private init() {
let caches = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask)[0]
diskURL = caches.appendingPathComponent("kisani_local_index.json")
}
// MARK: Lifecycle
/// Load from disk if available; otherwise build from PHFetchResult in background.
/// Safe to call multiple times subsequent calls are no-ops if records are loaded.
func loadOrBuild() async {
guard records.isEmpty else { return }
if let loaded = await Self.loadFromDisk(url: diskURL) {
records = loaded
log.info("LocalPhotoIndex: loaded \(self.records.count) records from disk")
return
}
log.info("LocalPhotoIndex: building from scratch (first launch)")
await buildFromLibrary()
}
// MARK: Queries
var totalCount: Int { records.count }
func count(filter: BackupFilter) -> Int {
records.values.filter { passes($0, filter: filter) }.count
}
func allRecords() -> [Record] { Array(records.values) }
func countSafe(against index: ManifestIndex, filter: BackupFilter) -> Int {
var safe = 0
for record in records.values {
guard passes(record, filter: filter) else { continue }
if index.matches(localIdentifier: record.localIdentifier) {
safe += 1
} else if index.isFilenameOnly && index.matches(filename: record.filename) {
// Pure bootstrap mode: every entry is filename-only, match by name.
safe += 1
} else if index.matchesUnclaimed(filename: record.filename) {
// Mixed mode: this specific entry was uploaded without a localIdentifier
// (interrupted session / pre-ID backup). Filename match prevents re-upload.
safe += 1
}
}
return safe
}
/// Returns localIdentifiers for assets not yet in the manifest (pending upload).
func pendingIDs(against index: ManifestIndex, filter: BackupFilter) -> [String] {
records.values.compactMap { record in
guard passes(record, filter: filter) else { return nil }
let alreadyBacked =
index.matches(localIdentifier: record.localIdentifier) ||
(index.isFilenameOnly && index.matches(filename: record.filename)) ||
index.matchesUnclaimed(filename: record.filename)
return alreadyBacked ? nil : record.localIdentifier
}
}
// MARK: Incremental updates (from PHPhotoLibraryChangeObserver)
func apply(inserted: [PHAsset], removed: [PHAsset], changed: [PHAsset]) {
for asset in removed { records.removeValue(forKey: asset.localIdentifier) }
for asset in inserted + changed {
records[asset.localIdentifier] = Self.makeRecord(asset)
}
needsSave = true
log.debug("LocalPhotoIndex: +\(inserted.count) -\(removed.count) ~\(changed.count)")
}
func flush() async {
guard needsSave else { return }
needsSave = false
let snapshot = records
let url = diskURL
await Task.detached(priority: .utility) {
guard let data = try? JSONEncoder.kisani.encode(snapshot) else { return }
try? data.write(to: url, options: .atomic)
}.value
log.debug("LocalPhotoIndex: flushed \(snapshot.count) records")
}
// MARK: Private helpers
private func passes(_ record: Record, filter: BackupFilter) -> Bool {
let img = PHAssetMediaType.image.rawValue
let vid = PHAssetMediaType.video.rawValue
if !filter.includePhotos && record.mediaType == img { return false }
if !filter.includeVideos && record.mediaType == vid { return false }
if !filter.includeScreenshots && record.isScreenshot { return false }
if !filter.includeRAW && record.isRAW { return false }
return true
}
private func buildFromLibrary() async {
let opts = PHFetchOptions()
opts.includeAssetSourceTypes = [.typeUserLibrary, .typeCloudShared, .typeiTunesSynced]
let built: [String: Record] = await Task.detached(priority: .userInitiated) {
let result = PHAsset.fetchAssets(with: opts)
var map = [String: Record](minimumCapacity: result.count)
let batchSize = 200
var i = 0
while i < result.count {
autoreleasepool {
let end = min(i + batchSize, result.count)
for j in i..<end {
let asset = result.object(at: j)
let rec = Self.makeRecord(asset)
map[rec.localIdentifier] = rec
}
i = end
}
}
return map
}.value
records = built
needsSave = true
await flush()
log.info("LocalPhotoIndex: built \(self.records.count) records")
}
private nonisolated static func loadFromDisk(url: URL) async -> [String: Record]? {
await Task.detached(priority: .utility) {
guard let data = try? Data(contentsOf: url) else { return nil }
return try? JSONDecoder.kisani.decode([String: Record].self, from: data)
}.value
}
nonisolated static func makeRecord(_ asset: PHAsset) -> Record {
let resources = PHAssetResource.assetResources(for: asset)
let filename = resources.first?.originalFilename
?? "\(asset.localIdentifier.prefix(8)).jpg"
let isRAW = resources.contains { $0.type == .alternatePhoto }
let isScreenshot = asset.mediaSubtypes.contains(.photoScreenshot)
return Record(
localIdentifier: asset.localIdentifier,
filename: filename,
creationDate: asset.creationDate,
mediaType: asset.mediaType.rawValue,
isScreenshot: isScreenshot,
isRAW: isRAW
)
}
}