// Utils/PhotoStore.swift // ---------------------- // Resolves a stored photo path against the CURRENT app container. // // ── The bug this exists to fix ─────────────────────────────────────────────── // Every photo path in the database is ABSOLUTE and embeds the app-container // UUID: // // /var/mobile/Containers/Data/Application//Documents/JQC/Photos/.jpg // // iOS assigns a NEW container UUID on every app update, reinstall and restore. // The Documents directory survives — the files are all still there — but every // stored path is instantly dead. // // Nothing accounted for that. `uploadPhoto` does // `FileManager.default.contents(atPath:)`, which returns nil, so the upload // throws "Could not read photo" and can NEVER succeed no matter how often it is // retried: the path names a container that no longer exists. Any photo still // awaiting upload when the app updates is therefore stranded permanently, and // its inspection is submitted with the field blank. // // That is what happened to inspection #887 (9 photos, Aug 2026): the diagnostic // reported all nine as `upload: pending` with 0 failed rows and // "Stored path stale (container changed) — file found by name, recoverable". // // ── Why basename lookup is safe ────────────────────────────────────────────── // Filenames are `UUID().uuidString + ".jpg"`, generated per save at every call // site, so a basename identifies a file unambiguously. This is the same // resolution PhotoDiagnosticView already performs to report recoverability — // it just was not wired into the code paths that actually read the files. import Foundation nonisolated enum PhotoStore { /// Sub-directories of Documents/ the app has ever written photos to. /// `JQCPhotos` is not written by any current code path but is checked so a /// file left by an older build is still found. private static let subdirectories = ["JQC/Photos", "JQC/ResultPhotos", "JQCPhotos"] /// Documents/ in the CURRENT container. Recomputed per call — caching it /// across an app update would reintroduce the very staleness this fixes. private static var documentsDirectory: URL? { FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first } /// Absolute URL for a photo directory in the current container. static func directory(_ subdirectory: String = "JQC/Photos") -> URL? { documentsDirectory?.appendingPathComponent(subdirectory, isDirectory: true) } /// The live absolute path for a stored photo path, or nil if the file is /// genuinely gone. /// /// Returns `storedPath` unchanged when it still resolves (the common case, /// costing one `fileExists` check). Otherwise re-resolves by filename under /// the current container. static func resolve(_ storedPath: String) -> String? { guard !storedPath.isEmpty else { return nil } let fm = FileManager.default if fm.fileExists(atPath: storedPath) { return storedPath } let name = URL(fileURLWithPath: storedPath).lastPathComponent guard !name.isEmpty else { return nil } for sub in subdirectories { guard let candidate = directory(sub)?.appendingPathComponent(name) else { continue } if fm.fileExists(atPath: candidate.path) { return candidate.path } } return nil } /// Read a photo's bytes, healing a stale container path first. static func contents(at storedPath: String) -> Data? { guard let live = resolve(storedPath) else { return nil } return FileManager.default.contents(atPath: live) } /// Delete a photo, whichever container its path was written in. @discardableResult static func remove(at storedPath: String) -> Bool { guard let live = resolve(storedPath) else { return false } return (try? FileManager.default.removeItem(atPath: live)) != nil } /// Filename component, which is the only stable part of a stored path. /// Use this — never the full path — to compare a database reference against /// a file on disk (see `SyncManager.cleanupOrphanedPhotos`). static func filename(of storedPath: String) -> String { URL(fileURLWithPath: storedPath).lastPathComponent } }