Aug 27 - Fixed inspection lost photos recovery

This commit is contained in:
Nguyen Ngo
2026-08-27 10:42:01 -04:00
parent ca7c09f982
commit 308710538b
14 changed files with 794 additions and 114 deletions
+91
View File
@@ -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
}
}