field_ios

In-the-field app for Radroots on iOS
git clone https://radroots.dev/git/field_ios.git
Log | Files | Refs | LICENSE

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