Aug 27 - Fixed inspection lost photos recovery
This commit is contained in:
@@ -0,0 +1,91 @@
|
||||
// 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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user