Files

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
}()
}