Jul 21 - Update to support photos' timestamp/location
This commit is contained in:
@@ -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
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user