92 lines
4.4 KiB
Swift
92 lines
4.4 KiB
Swift
// 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/<CONTAINER-UUID>/Documents/JQC/Photos/<file>.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
|
|
}
|
|
}
|