132 lines
5.0 KiB
Swift
132 lines
5.0 KiB
Swift
// Utils/PhotoCapture.swift
|
|
// ------------------------
|
|
// Capture-time metadata for evidence photos.
|
|
//
|
|
// The server burns a timestamp + GPS overlay into every photo uploaded through
|
|
// POST /api/v1/photos/upload (see photo_stamp.py in the Flask app). It resolves
|
|
// that metadata from the client form fields first, then the image's own EXIF,
|
|
// then — last resort — server receipt time.
|
|
//
|
|
// EXIF is NOT a usable fallback for this app: savePhotoToDisk() re-encodes each
|
|
// UIImage via jpegData(compressionQuality:), which strips every EXIF tag. So
|
|
// these client fields are the ONLY source of true capture time and location.
|
|
// Without them, a photo taken offline at 09:14 and synced at 16:00 is stamped
|
|
// 16:00 — the wrong time, on evidence.
|
|
//
|
|
// Hence the flow: record the moment + fix AT CAPTURE, carry them through
|
|
// CapturedPhoto -> PendingPhoto -> the upload request.
|
|
|
|
import Foundation
|
|
import UIKit
|
|
import CoreLocation
|
|
|
|
// MARK: - CapturedPhoto
|
|
|
|
/// A photo the user just took or picked, together with the metadata recorded
|
|
/// at that instant. Property names match the tuple this replaced
|
|
/// (`image`, `path`), so existing call sites keep compiling.
|
|
struct CapturedPhoto {
|
|
|
|
let image: UIImage
|
|
let path: String
|
|
let capturedAt: Date
|
|
let latitude: Double?
|
|
let longitude: Double?
|
|
|
|
/// Stamps "now" plus the freshest GPS fix available at the moment of capture.
|
|
init(image: UIImage, path: String) {
|
|
let fix = PhotoLocationProvider.shared.lastLocation
|
|
self.image = image
|
|
self.path = path
|
|
self.capturedAt = Date()
|
|
self.latitude = fix?.coordinate.latitude
|
|
self.longitude = fix?.coordinate.longitude
|
|
}
|
|
}
|
|
|
|
// MARK: - PhotoLocationProvider
|
|
|
|
/// Shared, always-warm location source for photo capture.
|
|
///
|
|
/// One long-lived CLLocationManager: views call `start()` in `onAppear` so a
|
|
/// fix already exists the instant the shutter fires. Kept separate from
|
|
/// ExecuteInspectionView's `InspectionLocationManager` (phase 25 submit GPS),
|
|
/// which is per-view and would start cold on every photo screen.
|
|
final class PhotoLocationProvider: NSObject, CLLocationManagerDelegate {
|
|
|
|
static let shared = PhotoLocationProvider()
|
|
|
|
private let manager = CLLocationManager()
|
|
|
|
/// Most recent fix, or nil if unavailable/denied.
|
|
private(set) var lastLocation: CLLocation?
|
|
|
|
private override init() {
|
|
super.init()
|
|
manager.delegate = self
|
|
manager.desiredAccuracy = kCLLocationAccuracyNearestTenMeters
|
|
manager.distanceFilter = 10
|
|
}
|
|
|
|
/// Request permission if needed and begin updating. Safe to call repeatedly.
|
|
/// Requires NSLocationWhenInUseUsageDescription in the target's Info settings.
|
|
func start() {
|
|
switch manager.authorizationStatus {
|
|
case .notDetermined:
|
|
manager.requestWhenInUseAuthorization()
|
|
// locationManagerDidChangeAuthorization starts updates once granted.
|
|
case .authorizedWhenInUse, .authorizedAlways:
|
|
manager.startUpdatingLocation()
|
|
default:
|
|
break // denied/restricted — lat/lng stay nil; the timestamp is still stamped
|
|
}
|
|
}
|
|
|
|
func stop() {
|
|
manager.stopUpdatingLocation()
|
|
}
|
|
|
|
// CLLocationManagerDelegate
|
|
|
|
func locationManager(_ m: CLLocationManager, didUpdateLocations locations: [CLLocation]) {
|
|
if let loc = locations.last { lastLocation = loc }
|
|
}
|
|
|
|
func locationManager(_ m: CLLocationManager, didFailWithError error: Error) {
|
|
// Non-fatal — photos still upload, just without coordinates.
|
|
print("[JQC] Photo location fix failed: \(error.localizedDescription)")
|
|
}
|
|
|
|
func locationManagerDidChangeAuthorization(_ m: CLLocationManager) {
|
|
if m.authorizationStatus == .authorizedWhenInUse ||
|
|
m.authorizationStatus == .authorizedAlways {
|
|
manager.startUpdatingLocation()
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Wire format
|
|
|
|
// Explicitly not @MainActor. SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor makes
|
|
// every type MainActor-isolated by default, but this formatter is read from
|
|
// `actor APIClient` while building the photo-upload multipart body — a
|
|
// nonisolated context. Without this the reference warns ("Main actor-isolated
|
|
// static property 'iso8601' can not be referenced from a nonisolated context")
|
|
// and becomes a hard error under the Swift 6 language mode. Same treatment as
|
|
// `Constants` / `ServerConfig` and `SyncManager.isoFormatter` (rule 35).
|
|
nonisolated enum PhotoCaptureFormat {
|
|
|
|
/// ISO-8601 with an explicit offset — the format the server's
|
|
/// `_parse_client_datetime()` expects. A naive string with no offset would
|
|
/// be read as Eastern wall time, so the offset must always be present.
|
|
///
|
|
/// Created once and reused: formatter init is expensive, and this runs per
|
|
/// photo upload. Foundation formatters are thread-safe for formatting, so
|
|
/// sharing one across isolation domains is safe.
|
|
static let iso8601: ISO8601DateFormatter = {
|
|
let f = ISO8601DateFormatter()
|
|
f.formatOptions = [.withInternetDateTime]
|
|
return f
|
|
}()
|
|
}
|