Jul 21 - Update to support photos' timestamp/location

This commit is contained in:
2026-07-21 16:22:13 -04:00
parent 2a0b264a5c
commit 1921f95faf
18 changed files with 261 additions and 5172 deletions
+55 -6
View File
@@ -134,7 +134,20 @@ actor APIClient {
// Photo Upload
func uploadPhoto(localPath: String, entityType: String, retrying: Bool = false) async throws -> String {
/// Upload a photo and return its server path.
///
/// `capturedAt` / `latitude` / `longitude` drive the timestamp + GPS overlay
/// the server burns into the image. They are optional on the wire, but this
/// app must send them: photos are re-encoded on save (jpegData), which
/// strips EXIF, so the server has no other way to learn the true capture
/// moment it would fall back to upload time, which is wrong for anything
/// captured offline. See Utils/PhotoCapture.swift.
func uploadPhoto(localPath: String,
entityType: String,
capturedAt: Date? = nil,
latitude: Double? = nil,
longitude: Double? = nil,
retrying: Bool = false) async throws -> String {
let url = try buildURL("/api/v1/photos/upload")
guard let imageData = FileManager.default.contents(atPath: localPath) else {
@@ -148,6 +161,9 @@ actor APIClient {
var body = Data()
body.append("--\(boundary)\r\nContent-Disposition: form-data; name=\"entity_type\"\r\n\r\n\(entityType)\r\n".data(using: .utf8)!)
body.append(Self.formField("captured_at", capturedAt.map { PhotoCaptureFormat.iso8601.string(from: $0) }, boundary))
body.append(Self.formField("latitude", latitude.map { String(format: "%.6f", $0) }, boundary))
body.append(Self.formField("longitude", longitude.map { String(format: "%.6f", $0) }, boundary))
body.append("--\(boundary)\r\nContent-Disposition: form-data; name=\"file\"; filename=\"\(filename)\"\r\nContent-Type: \(mime)\r\n\r\n".data(using: .utf8)!)
body.append(imageData)
body.append("\r\n--\(boundary)--\r\n".data(using: .utf8)!)
@@ -162,16 +178,38 @@ actor APIClient {
if shouldRefresh(response, retrying: retrying) {
let refreshed = await refreshAccessToken()
if refreshed { return try await uploadPhoto(localPath: localPath, entityType: entityType, retrying: true) }
if refreshed {
return try await uploadPhoto(localPath: localPath, entityType: entityType,
capturedAt: capturedAt, latitude: latitude,
longitude: longitude, retrying: true)
}
throw APIError.notAuthenticated
}
struct PhotoResult: Decodable, Sendable { let serverPath: String }
// `stamped` / `capturedAt` / `captureSource` are also returned; decoded
// as optionals so older servers (which omit them) still parse.
struct PhotoResult: Decodable, Sendable {
let serverPath: String
let stamped: Bool?
let captureSource: String?
}
if let env = try? decoder.decode(_Envelope<PhotoResult>.self, from: data),
env.ok, let r = env.data { return r.serverPath }
env.ok, let r = env.data {
if r.stamped == false {
print("[JQC] Photo stored unstamped (source=\(r.captureSource ?? "?")): \(r.serverPath)")
}
return r.serverPath
}
throw APIError.serverError("Photo upload failed")
}
/// Build one multipart text field, or empty Data when the value is nil.
private static func formField(_ name: String, _ value: String?, _ boundary: String) -> Data {
guard let value else { return Data() }
return "--\(boundary)\r\nContent-Disposition: form-data; name=\"\(name)\"\r\n\r\n\(value)\r\n"
.data(using: .utf8) ?? Data()
}
// Submit Inspection
func submitInspection(_ inspection: LocalInspection) async throws -> Int {
@@ -262,7 +300,11 @@ actor APIClient {
// Upload a resolution photo (entity_type = issue_result)
// Saves to issue_result_photos subfolder on the server same bucket
// as photos uploaded via the web update form.
func uploadResultPhoto(localPath: String, retrying: Bool = false) async throws -> String {
func uploadResultPhoto(localPath: String,
capturedAt: Date? = nil,
latitude: Double? = nil,
longitude: Double? = nil,
retrying: Bool = false) async throws -> String {
let url = try buildURL("/api/v1/photos/upload")
guard let imageData = FileManager.default.contents(atPath: localPath) else {
@@ -276,6 +318,9 @@ actor APIClient {
var body = Data()
body.append("--\(boundary)\r\nContent-Disposition: form-data; name=\"entity_type\"\r\n\r\nissue_result\r\n".data(using: .utf8)!)
body.append(Self.formField("captured_at", capturedAt.map { PhotoCaptureFormat.iso8601.string(from: $0) }, boundary))
body.append(Self.formField("latitude", latitude.map { String(format: "%.6f", $0) }, boundary))
body.append(Self.formField("longitude", longitude.map { String(format: "%.6f", $0) }, boundary))
body.append("--\(boundary)\r\nContent-Disposition: form-data; name=\"file\"; filename=\"\(filename)\"\r\nContent-Type: \(mime)\r\n\r\n".data(using: .utf8)!)
body.append(imageData)
body.append("\r\n--\(boundary)--\r\n".data(using: .utf8)!)
@@ -290,7 +335,11 @@ actor APIClient {
if shouldRefresh(response, retrying: retrying) {
let refreshed = await refreshAccessToken()
if refreshed { return try await uploadResultPhoto(localPath: localPath, retrying: true) }
if refreshed {
return try await uploadResultPhoto(localPath: localPath, capturedAt: capturedAt,
latitude: latitude, longitude: longitude,
retrying: true)
}
throw APIError.notAuthenticated
}