FieldCaptureIntake.swift (15375B)
1 import Foundation 2 import RadrootsKit 3 4 enum FieldCaptureIntakeError: LocalizedError { 5 case serviceNotReady 6 case missingFileScope(String) 7 8 var errorDescription: String? { 9 switch self { 10 case .serviceNotReady: 11 "Capture intake is not ready. Please retry." 12 case .missingFileScope(let value): 13 "Unsupported capture file scope: \(value)." 14 } 15 } 16 } 17 18 public enum FieldCaptureRecordSource: String, Codable, Equatable, Sendable { 19 case libraryImport 20 case cameraCapture 21 case documentScan 22 23 var displayName: String { 24 switch self { 25 case .libraryImport: 26 "Imported photo" 27 case .cameraCapture: 28 "Camera photo" 29 case .documentScan: 30 "Scanned document" 31 } 32 } 33 } 34 35 public enum FieldCaptureRecordKind: String, Codable, Equatable, Sendable { 36 case image 37 case pdf 38 } 39 40 public enum FieldCaptureFileScope: String, Codable, Equatable, Sendable { 41 case data 42 case cache 43 case temporary 44 case logs 45 46 init(_ scope: RadrootsFileScope) { 47 switch scope { 48 case .data: 49 self = .data 50 case .cache: 51 self = .cache 52 case .temporary: 53 self = .temporary 54 case .logs: 55 self = .logs 56 } 57 } 58 59 var fileScope: RadrootsFileScope { 60 switch self { 61 case .data: 62 .data 63 case .cache: 64 .cache 65 case .temporary: 66 .temporary 67 case .logs: 68 .logs 69 } 70 } 71 } 72 73 public struct FieldCaptureRecord: Identifiable, Codable, Equatable, Sendable { 74 public let id: UUID 75 let source: FieldCaptureRecordSource 76 let kind: FieldCaptureRecordKind 77 let fileScope: FieldCaptureFileScope 78 let fileRelativePath: String 79 let mediaType: String 80 let suggestedFilename: String 81 let sizeBytes: UInt64 82 let pixelWidth: UInt32? 83 let pixelHeight: UInt32? 84 let pageCount: UInt16? 85 let capturedAt: Date 86 87 var file: RadrootsFileReference { 88 RadrootsFileReference(scope: fileScope.fileScope, relativePath: fileRelativePath) 89 } 90 91 var summary: String { 92 if let pageCount { 93 return "\(source.displayName) - \(pageCount) pages - \(suggestedFilename)" 94 } 95 if let pixelWidth, let pixelHeight { 96 return "\(source.displayName) - \(pixelWidth)x\(pixelHeight) - \(suggestedFilename)" 97 } 98 return "\(source.displayName) - \(suggestedFilename)" 99 } 100 } 101 102 public struct FieldCaptureSupportState: Equatable, Sendable { 103 var photoImportAvailable: Bool 104 var cameraPhotoAvailable: Bool 105 var documentScannerAvailable: Bool 106 107 static let unavailable = FieldCaptureSupportState( 108 photoImportAvailable: false, 109 cameraPhotoAvailable: false, 110 documentScannerAvailable: false 111 ) 112 } 113 114 public enum FieldCaptureIntakeOperation: Equatable, Sendable { 115 case idle 116 case refreshing 117 case importingPhoto 118 case capturingPhoto 119 case scanningDocument 120 } 121 122 public struct FieldCaptureIntakeState: Equatable, Sendable { 123 var support: FieldCaptureSupportState 124 var records: [FieldCaptureRecord] 125 var operation: FieldCaptureIntakeOperation 126 var lastError: String? 127 var recoveryAction: FieldExternalActionRecovery? 128 129 static let idle = FieldCaptureIntakeState( 130 support: .unavailable, 131 records: [], 132 operation: .idle, 133 lastError: nil, 134 recoveryAction: nil 135 ) 136 137 var latestRecord: FieldCaptureRecord? { 138 records.sorted { left, right in 139 left.capturedAt > right.capturedAt 140 }.first 141 } 142 } 143 144 final class FieldCaptureIntake: @unchecked Sendable { 145 private let mediaPicker: any RadrootsMediaPicker 146 private let documentScanner: any RadrootsDocumentScanner 147 private let fileAccess: RadrootsAppleFileAccess 148 private let recordsFile = RadrootsFileReference( 149 scope: .data, 150 relativePath: "capture_intake/records.json" 151 ) 152 private let encoder: JSONEncoder 153 private let decoder: JSONDecoder 154 155 init( 156 fileAccess: RadrootsAppleFileAccess, 157 mediaPicker: any RadrootsMediaPicker, 158 documentScanner: any RadrootsDocumentScanner 159 ) { 160 self.fileAccess = fileAccess 161 self.mediaPicker = mediaPicker 162 self.documentScanner = documentScanner 163 self.encoder = JSONEncoder() 164 self.encoder.dateEncodingStrategy = .iso8601 165 self.encoder.outputFormatting = [.prettyPrinted, .sortedKeys] 166 self.decoder = JSONDecoder() 167 self.decoder.dateDecodingStrategy = .iso8601 168 } 169 170 static func configured(bundleIdentifier: String) throws -> FieldCaptureIntake { 171 let fileAccess = try FieldLocalState.fileAccess(bundleIdentifier: bundleIdentifier) 172 #if DEBUG 173 if FieldUITestHarness.isRequested { 174 return try uiTestConfigured(fileAccess: fileAccess) 175 } 176 #endif 177 return FieldCaptureIntake( 178 fileAccess: fileAccess, 179 mediaPicker: RadrootsAppleMediaPicker(fileAccess: fileAccess), 180 documentScanner: RadrootsAppleDocumentScanner(fileAccess: fileAccess) 181 ) 182 } 183 184 func loadRecords() throws -> [FieldCaptureRecord] { 185 do { 186 let result = try fileAccess.read(recordsFile, mode: .inline) 187 guard case .inline(let data) = result else { 188 return [] 189 } 190 return try decoder.decode([FieldCaptureRecord].self, from: data) 191 } catch let error as RadrootsAppleFileError { 192 if case .notFound = error { 193 return [] 194 } 195 throw error 196 } 197 } 198 199 func support() async throws -> FieldCaptureSupportState { 200 let mediaSupport = try await mediaPicker.currentSupport() 201 let scannerSupport = try await documentScanner.currentSupport() 202 return FieldCaptureSupportState( 203 photoImportAvailable: mediaSupport.importAvailable && mediaSupport.supportedImportKinds.contains(.image), 204 cameraPhotoAvailable: mediaSupport.cameraCaptureAvailable && mediaSupport.supportedCaptureKinds.contains(.image), 205 documentScannerAvailable: scannerSupport.interactiveScanAvailable && scannerSupport.supportedOutputKinds.contains(.pdf) 206 ) 207 } 208 209 func importPhoto(records: [FieldCaptureRecord]) async throws -> [FieldCaptureRecord] { 210 let result = try await mediaPicker.importMedia( 211 try RadrootsMediaImportRequest( 212 allowedMediaKinds: [.image], 213 selectionLimit: 1, 214 destinationScope: .data 215 ) 216 ) 217 return try append(result.items.map(record(from:)), to: records) 218 } 219 220 func capturePhoto(records: [FieldCaptureRecord]) async throws -> [FieldCaptureRecord] { 221 let result = try await mediaPicker.captureMedia( 222 try RadrootsMediaCaptureRequest(mediaKind: .image, destinationScope: .data) 223 ) 224 return try append([record(from: result.item)], to: records) 225 } 226 227 func scanDocument(records: [FieldCaptureRecord]) async throws -> [FieldCaptureRecord] { 228 let result = try await documentScanner.scanDocument( 229 RadrootsDocumentScanRequest(outputKind: .pdf, destinationScope: .data) 230 ) 231 return try append([record(from: result)], to: records) 232 } 233 234 private func append(_ newRecords: [FieldCaptureRecord], to records: [FieldCaptureRecord]) throws -> [FieldCaptureRecord] { 235 let updated = records + newRecords 236 try save(updated) 237 return updated 238 } 239 240 private func save(_ records: [FieldCaptureRecord]) throws { 241 try fileAccess.write(.inline(encoder.encode(records)), to: recordsFile) 242 } 243 244 private func record(from asset: RadrootsMediaAsset) -> FieldCaptureRecord { 245 FieldCaptureRecord( 246 id: UUID(), 247 source: asset.source == .cameraCapture ? .cameraCapture : .libraryImport, 248 kind: .image, 249 fileScope: FieldCaptureFileScope(asset.file.scope), 250 fileRelativePath: asset.file.relativePath, 251 mediaType: asset.mediaType, 252 suggestedFilename: asset.suggestedFilename, 253 sizeBytes: asset.sizeBytes, 254 pixelWidth: asset.pixelWidth, 255 pixelHeight: asset.pixelHeight, 256 pageCount: nil, 257 capturedAt: asset.capturedAt 258 ) 259 } 260 261 private func record(from document: RadrootsScannedDocument) -> FieldCaptureRecord { 262 FieldCaptureRecord( 263 id: UUID(), 264 source: .documentScan, 265 kind: .pdf, 266 fileScope: FieldCaptureFileScope(document.file.scope), 267 fileRelativePath: document.file.relativePath, 268 mediaType: document.mediaType, 269 suggestedFilename: document.suggestedFilename, 270 sizeBytes: document.sizeBytes, 271 pixelWidth: nil, 272 pixelHeight: nil, 273 pageCount: document.pageCount, 274 capturedAt: document.capturedAt 275 ) 276 } 277 278 #if DEBUG 279 private static func uiTestConfigured(fileAccess: RadrootsAppleFileAccess) throws -> FieldCaptureIntake { 280 let importedAsset = try uiTestMediaAsset( 281 fileAccess: fileAccess, 282 source: .libraryImport, 283 relativePath: "capture_intake/ui_tests/imported_photo.jpg", 284 filename: "imported-field-photo.jpg", 285 bytes: "radroots imported field photo".data(using: .utf8) ?? Data(), 286 capturedAt: Date(timeIntervalSinceReferenceDate: 1_000) 287 ) 288 let capturedAsset = try uiTestMediaAsset( 289 fileAccess: fileAccess, 290 source: .cameraCapture, 291 relativePath: "capture_intake/ui_tests/camera_photo.jpg", 292 filename: "camera-field-photo.jpg", 293 bytes: "radroots camera field photo".data(using: .utf8) ?? Data(), 294 capturedAt: Date(timeIntervalSinceReferenceDate: 2_000) 295 ) 296 let scannedDocument = try uiTestScannedDocument(fileAccess: fileAccess) 297 let importOutcome = try uiTestMediaImportOutcome(success: RadrootsMediaImportResult(items: [importedAsset])) 298 let captureOutcome = uiTestMediaCaptureOutcome(success: RadrootsMediaCaptureResult(item: capturedAsset)) 299 let scannerOutcome = uiTestDocumentScannerOutcome(success: scannedDocument) 300 let mediaSupport = try RadrootsMediaPickerSupport( 301 importAvailable: !uiTestOutcome("RADROOTS_FIELD_IOS_UI_TEST_CAPTURE_IMPORT_OUTCOME").isUnavailable, 302 cameraCaptureAvailable: !uiTestOutcome("RADROOTS_FIELD_IOS_UI_TEST_CAPTURE_CAMERA_OUTCOME").isUnavailable, 303 supportedImportKinds: [.image], 304 supportedCaptureKinds: [.image], 305 multipleSelectionSupported: false 306 ) 307 let scannerSupport = try RadrootsDocumentScannerSupport( 308 interactiveScanAvailable: !uiTestOutcome("RADROOTS_FIELD_IOS_UI_TEST_CAPTURE_SCANNER_OUTCOME").isUnavailable, 309 multiPageSupported: true, 310 supportedOutputKinds: [.pdf] 311 ) 312 return FieldCaptureIntake( 313 fileAccess: fileAccess, 314 mediaPicker: FieldUITestMediaPicker( 315 support: mediaSupport, 316 importOutcome: importOutcome, 317 captureOutcome: captureOutcome 318 ), 319 documentScanner: FieldUITestDocumentScanner( 320 support: scannerSupport, 321 scanOutcome: scannerOutcome 322 ) 323 ) 324 } 325 326 private static func uiTestMediaAsset( 327 fileAccess: RadrootsAppleFileAccess, 328 source: RadrootsMediaSource, 329 relativePath: String, 330 filename: String, 331 bytes: Data, 332 capturedAt: Date 333 ) throws -> RadrootsMediaAsset { 334 let file = RadrootsFileReference(scope: .data, relativePath: relativePath) 335 try fileAccess.write(.inline(bytes), to: file) 336 return try RadrootsMediaAsset( 337 source: source, 338 kind: .image, 339 file: file, 340 mediaType: "image/jpeg", 341 suggestedFilename: filename, 342 sizeBytes: UInt64(bytes.count), 343 pixelWidth: 1200, 344 pixelHeight: 900, 345 capturedAt: capturedAt 346 ) 347 } 348 349 private static func uiTestScannedDocument(fileAccess: RadrootsAppleFileAccess) throws -> RadrootsScannedDocument { 350 let bytes = Data("%PDF-1.7\n% radroots field scan\n".utf8) 351 let file = RadrootsFileReference( 352 scope: .data, 353 relativePath: "capture_intake/ui_tests/scanned_document.pdf" 354 ) 355 try fileAccess.write(.inline(bytes), to: file) 356 return try RadrootsScannedDocument( 357 file: file, 358 outputKind: .pdf, 359 suggestedFilename: "field-scan.pdf", 360 mediaType: "application/pdf", 361 pageCount: 2, 362 sizeBytes: UInt64(bytes.count), 363 capturedAt: Date(timeIntervalSinceReferenceDate: 3_000) 364 ) 365 } 366 367 private static func uiTestMediaImportOutcome( 368 success: RadrootsMediaImportResult 369 ) throws -> Result<RadrootsMediaImportResult, RadrootsCaptureIntakeError> { 370 try uiTestResult( 371 key: "RADROOTS_FIELD_IOS_UI_TEST_CAPTURE_IMPORT_OUTCOME", 372 success: success 373 ) 374 } 375 376 private static func uiTestMediaCaptureOutcome( 377 success: RadrootsMediaCaptureResult 378 ) -> Result<RadrootsMediaCaptureResult, RadrootsCaptureIntakeError> { 379 do { 380 return try uiTestResult( 381 key: "RADROOTS_FIELD_IOS_UI_TEST_CAPTURE_CAMERA_OUTCOME", 382 success: success 383 ) 384 } catch { 385 return .failure(.permanentFailure(error.fieldRuntimeMessage)) 386 } 387 } 388 389 private static func uiTestDocumentScannerOutcome( 390 success: RadrootsScannedDocument 391 ) -> Result<RadrootsScannedDocument, RadrootsCaptureIntakeError> { 392 do { 393 return try uiTestResult( 394 key: "RADROOTS_FIELD_IOS_UI_TEST_CAPTURE_SCANNER_OUTCOME", 395 success: success 396 ) 397 } catch { 398 return .failure(.permanentFailure(error.fieldRuntimeMessage)) 399 } 400 } 401 402 private static func uiTestResult<T>( 403 key: String, 404 success: T 405 ) throws -> Result<T, RadrootsCaptureIntakeError> { 406 let outcome = uiTestOutcome(key) 407 switch outcome { 408 case .success: 409 return .success(success) 410 case .cancelled: 411 return .failure(.userCancelled("Capture was cancelled.")) 412 case .denied: 413 return .failure(.permissionDenied("Capture permission is denied.")) 414 case .unavailable: 415 return .failure(.unavailable("Capture is unavailable.")) 416 case .transientFailure: 417 return .failure(.transientFailure("Capture failed. Please retry.")) 418 } 419 } 420 421 private static func uiTestOutcome(_ key: String) -> FieldCaptureUITestOutcome { 422 FieldCaptureUITestOutcome( 423 rawValue: FieldUITestHarness.string(key) ?? "" 424 ) ?? .success 425 } 426 #endif 427 } 428 429 #if DEBUG 430 private enum FieldCaptureUITestOutcome: String { 431 case success 432 case cancelled 433 case denied 434 case unavailable 435 case transientFailure = "transient_failure" 436 437 var isUnavailable: Bool { 438 self == .unavailable 439 } 440 } 441 #endif