field_ios

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

commit 90e3de137b5eff6a35dafc61b98d3576a0b8f589
parent 974e7375deb4b519dc7fc931aa7d731a179adff5
Author: triesap <tyson@radroots.org>
Date:   Sun, 14 Jun 2026 13:12:28 -0700

capture: add field intake runtime

- add app-owned durable capture record storage
- wire AppleKit media and document scanner services
- add camera privacy metadata for invoked capture
- include RadrootsKitTesting for deterministic UI probes

Diffstat:
MRadroots.xcodeproj/project.pbxproj | 38+++++++++++++++++++++++++++-----------
MRadroots/App/AppState.swift | 66++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
MRadroots/Info.plist | 2++
ARadroots/Runtime/FieldCaptureIntake.swift | 440+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mproject.yml | 2++
5 files changed, 537 insertions(+), 11 deletions(-)

diff --git a/Radroots.xcodeproj/project.pbxproj b/Radroots.xcodeproj/project.pbxproj @@ -13,6 +13,7 @@ 1C6EA551530A46CA77BD9E1C /* FieldLocationCheckIn.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2F3541389124A31F5D701A45 /* FieldLocationCheckIn.swift */; }; 1E5B41A3E1F9A7D68F63B079 /* HomeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0A0274A0260D1C04F40C71AF /* HomeView.swift */; }; 275D4D574BF3B3C1DD746CE7 /* ProfileView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C71A93F98C7B93188748B99B /* ProfileView.swift */; }; + 299A6111507F657670856F36 /* FieldCaptureIntake.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7CC1D11E2E296B4B54E7E8A9 /* FieldCaptureIntake.swift */; }; 2B3886FD26434A54F3726591 /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = 8D19AA8D515FC8F5D2407378 /* Localizable.strings */; }; 2B6ACA26689B355CECBFFB57 /* SetupView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 16A7641E5C643B4B36CFEDA8 /* SetupView.swift */; }; 33A800AA701C354099623B24 /* MarketView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 08C0B870E44C7B152A7FABE0 /* MarketView.swift */; }; @@ -26,6 +27,7 @@ 505A5731ACDBBB0296134340 /* TradeListingCreateView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 947ADA6D32E42ED2B40A5351 /* TradeListingCreateView.swift */; }; 5AECD474FB2F91855BDD79C0 /* PostFeedViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = F21554DA87EEC1E5C5F38365 /* PostFeedViewModel.swift */; }; 657BEA5AAFF129E10177FE63 /* Nostr.swift in Sources */ = {isa = PBXBuildFile; fileRef = 63189EB90A86A9929BECD9ED /* Nostr.swift */; }; + 6E11DAC51E648F1E4EDFBE68 /* RadrootsKitTesting in Frameworks */ = {isa = PBXBuildFile; productRef = F2A002A084F2E04B096C93C7 /* RadrootsKitTesting */; }; 6E15D30653861F26AC45B501 /* FieldLocalState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9EB0A2CFCBBFA9D204C6992B /* FieldLocalState.swift */; }; 7C8DD424F3E3E0AB1B133863 /* RadrootsKitBindings.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC881872F750120A184F45E6 /* RadrootsKitBindings.swift */; }; 7E650F6DA30931E310F842E2 /* FieldFileAccessUITestProbe.swift in Sources */ = {isa = PBXBuildFile; fileRef = 491D57E540BAB04F24619737 /* FieldFileAccessUITestProbe.swift */; }; @@ -75,8 +77,10 @@ 63189EB90A86A9929BECD9ED /* Nostr.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Nostr.swift; sourceTree = "<group>"; }; 676B89EB116B60AE8C2B4313 /* Base.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Base.xcconfig; sourceTree = "<group>"; }; 6FBB081610305940C7849C7C /* RelaySettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RelaySettings.swift; sourceTree = "<group>"; }; + 7515B8FD2A65990C3E3E93CE /* apple_kit */ = {isa = PBXFileReference; lastKnownFileType = folder; name = apple_kit; path = ../../../../domains/radroots/apple_kit; sourceTree = SOURCE_ROOT; }; 7BCA99336E305EC789152DDE /* radroots.local.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = radroots.local.xcconfig; sourceTree = "<group>"; }; 7C294E8EF50F5E1E73F5C135 /* Common.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Common.xcconfig; sourceTree = "<group>"; }; + 7CC1D11E2E296B4B54E7E8A9 /* FieldCaptureIntake.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FieldCaptureIntake.swift; sourceTree = "<group>"; }; 7D69D200DB1F5FA7AA561CD7 /* TradeListing.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TradeListing.swift; sourceTree = "<group>"; }; 8246B707FA9D218414EC4038 /* FieldSecureIdentityStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FieldSecureIdentityStore.swift; sourceTree = "<group>"; }; 8F0F21496E7A8490EB14AC5B /* Radroots.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Radroots.swift; sourceTree = "<group>"; }; @@ -118,6 +122,7 @@ files = ( 04AA409CFECBA11BFC175C5C /* RadrootsFFI.xcframework in Frameworks */, F3E40E5A76B4EA19AC7603D2 /* RadrootsKit in Frameworks */, + 6E11DAC51E648F1E4EDFBE68 /* RadrootsKitTesting in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -183,6 +188,7 @@ isa = PBXGroup; children = ( A71EFADBC7D54AF5B9314773 /* BuildConfig.swift */, + 7CC1D11E2E296B4B54E7E8A9 /* FieldCaptureIntake.swift */, E4F2BDC19EF9435162BFF5EC /* FieldDocumentInterchange.swift */, EF05B944D348DAA4EF28B463 /* FieldDocumentInterchangeUITestProbe.swift */, 491D57E540BAB04F24619737 /* FieldFileAccessUITestProbe.swift */, @@ -221,6 +227,14 @@ path = Shared; sourceTree = "<group>"; }; + 9458C318B571871852A3FD1B /* Packages */ = { + isa = PBXGroup; + children = ( + 7515B8FD2A65990C3E3E93CE /* apple_kit */, + ); + name = Packages; + sourceTree = "<group>"; + }; 97FA23F0FD7E25C1AF2585FB /* Frameworks */ = { isa = PBXGroup; children = ( @@ -261,6 +275,7 @@ C4F02317699AB4FA59315D05 = { isa = PBXGroup; children = ( + 9458C318B571871852A3FD1B /* Packages */, 5FD6379AE27C57D02E8C7EE1 /* Radroots */, 97FA23F0FD7E25C1AF2585FB /* Frameworks */, 6240123423927396E47D6B3E /* Products */, @@ -330,6 +345,7 @@ name = Radroots; packageProductDependencies = ( 2DAD90EBF8EB00ACDD7611CD /* RadrootsKit */, + F2A002A084F2E04B096C93C7 /* RadrootsKitTesting */, ); productName = Radroots; productReference = 93AA285819DD1269C3EAD80A /* Radroots.app */; @@ -356,7 +372,7 @@ mainGroup = C4F02317699AB4FA59315D05; minimizedProjectReferenceProxies = 1; packageReferences = ( - 6BC345C2400A6F7ED798D79A /* XCRemoteSwiftPackageReference "apple_kit" */, + D71BF9693060631950EFC310 /* XCLocalSwiftPackageReference "../../../../domains/radroots/apple_kit" */, ); preferredProjectObjectVersion = 77; productRefGroup = 6240123423927396E47D6B3E /* Products */; @@ -415,6 +431,7 @@ 9121BD4A3E7C6EF2B21F540F /* CopyButton.swift in Sources */, D3834AF9A4E1327B7DA557F3 /* CopyRow.swift in Sources */, 4025E63F6603011431B8A0E1 /* DebugDump.swift in Sources */, + 299A6111507F657670856F36 /* FieldCaptureIntake.swift in Sources */, 3FC570AC038C3DC575E5A3E7 /* FieldDocumentInterchange.swift in Sources */, E432FD39ECC8F03764EEED81 /* FieldDocumentInterchangeUITestProbe.swift in Sources */, 7E650F6DA30931E310F842E2 /* FieldFileAccessUITestProbe.swift in Sources */, @@ -652,23 +669,22 @@ }; /* End XCConfigurationList section */ -/* Begin XCRemoteSwiftPackageReference section */ - 6BC345C2400A6F7ED798D79A /* XCRemoteSwiftPackageReference "apple_kit" */ = { - isa = XCRemoteSwiftPackageReference; - repositoryURL = "git@github.com:radrootslabs/apple_kit.git"; - requirement = { - branch = master; - kind = branch; - }; +/* Begin XCLocalSwiftPackageReference section */ + D71BF9693060631950EFC310 /* XCLocalSwiftPackageReference "../../../../domains/radroots/apple_kit" */ = { + isa = XCLocalSwiftPackageReference; + relativePath = ../../../../domains/radroots/apple_kit; }; -/* End XCRemoteSwiftPackageReference section */ +/* End XCLocalSwiftPackageReference section */ /* Begin XCSwiftPackageProductDependency section */ 2DAD90EBF8EB00ACDD7611CD /* RadrootsKit */ = { isa = XCSwiftPackageProductDependency; - package = 6BC345C2400A6F7ED798D79A /* XCRemoteSwiftPackageReference "apple_kit" */; productName = RadrootsKit; }; + F2A002A084F2E04B096C93C7 /* RadrootsKitTesting */ = { + isa = XCSwiftPackageProductDependency; + productName = RadrootsKitTesting; + }; /* End XCSwiftPackageProductDependency section */ }; rootObject = 572C41532B5066EC7641561C /* Project object */; diff --git a/Radroots/App/AppState.swift b/Radroots/App/AppState.swift @@ -46,6 +46,7 @@ public final class AppState: ObservableObject { @Published public private(set) var locationCheckInState: FieldLocationCheckInState = .idle( RadrootsLocationServicesAvailability(locationServicesEnabled: false, authorization: .unavailable) ) + @Published public private(set) var captureIntakeState: FieldCaptureIntakeState = .idle public var canShowAppContent: Bool { bootstrapPhase == .ready && runtimeIdentityReady && !isLocked @@ -76,6 +77,7 @@ public final class AppState: ObservableObject { private var statusTask: Task<Void, Never>? private var secureIdentityStore: FieldSecureIdentityStore? private var identityMetadataStore: FieldIdentityPublicMetadataStore? + private var captureIntake: FieldCaptureIntake? private let locationCheckIn = FieldLocationCheckIn.configured() public init(radroots: Radroots = Radroots()) { @@ -116,6 +118,8 @@ public final class AppState: ObservableObject { } else { loadStoredIdentityMetadata(metadataStore) } + let captureIntake = try FieldCaptureIntake.configured(bundleIdentifier: appBundleIdentifier) + self.captureIntake = captureIntake await refreshRuntimeState(using: service) if runtimeIdentityReady && !isLocked { try await connect(using: service) @@ -128,6 +132,7 @@ public final class AppState: ObservableObject { ) try refreshDocumentInterchangeProbe(bundleIdentifier: appBundleIdentifier) await refreshLocationCheckInStatus() + await refreshCaptureIntakeState(using: captureIntake) bootstrapPhase = .ready } catch { statusTask?.cancel() @@ -237,6 +242,32 @@ public final class AppState: ObservableObject { locationCheckInState = await locationCheckIn.checkIn() } + public func refreshCaptureIntakeState() async { + guard let captureIntake else { + captureIntakeState.lastError = FieldCaptureIntakeError.serviceNotReady.localizedDescription + return + } + await refreshCaptureIntakeState(using: captureIntake) + } + + public func importPhotoEvidence() async { + await performCaptureIntakeOperation(.importingPhoto) { captureIntake, records in + try await captureIntake.importPhoto(records: records) + } + } + + public func capturePhotoEvidence() async { + await performCaptureIntakeOperation(.capturingPhoto) { captureIntake, records in + try await captureIntake.capturePhoto(records: records) + } + } + + public func scanDocumentEvidence() async { + await performCaptureIntakeOperation(.scanningDocument) { captureIntake, records in + try await captureIntake.scanDocument(records: records) + } + } + func prepareDiagnosticsDocumentExport() throws -> RadrootsPreparedExportDocument { try documentInterchange().prepareDiagnosticsExport( infoJSONString: infoJSONString, @@ -271,6 +302,41 @@ public final class AppState: ObservableObject { try FieldDocumentInterchange(bundleIdentifier: bundleIdentifier()) } + private func refreshCaptureIntakeState(using captureIntake: FieldCaptureIntake) async { + captureIntakeState.operation = .refreshing + captureIntakeState.lastError = nil + do { + captureIntakeState.records = try captureIntake.loadRecords() + captureIntakeState.support = try await captureIntake.support() + captureIntakeState.operation = .idle + } catch { + captureIntakeState.support = .unavailable + captureIntakeState.operation = .idle + captureIntakeState.lastError = error.localizedDescription + } + } + + private func performCaptureIntakeOperation( + _ operation: FieldCaptureIntakeOperation, + action: (FieldCaptureIntake, [FieldCaptureRecord]) async throws -> [FieldCaptureRecord] + ) async { + guard let captureIntake else { + captureIntakeState.lastError = FieldCaptureIntakeError.serviceNotReady.localizedDescription + return + } + captureIntakeState.operation = operation + captureIntakeState.lastError = nil + do { + let updatedRecords = try await action(captureIntake, captureIntakeState.records) + captureIntakeState.records = updatedRecords + captureIntakeState.support = try await captureIntake.support() + captureIntakeState.operation = .idle + } catch { + captureIntakeState.operation = .idle + captureIntakeState.lastError = error.localizedDescription + } + } + private var isFailed: Bool { if case .failed = bootstrapPhase { return true diff --git a/Radroots/Info.plist b/Radroots/Info.plist @@ -22,6 +22,8 @@ <string>1</string> <key>NSLocationWhenInUseUsageDescription</key> <string>Radroots uses your location only when you tap Location Check-in.</string> + <key>NSCameraUsageDescription</key> + <string>Radroots uses the camera only when you capture photo evidence or scan a document.</string> <key>GIT_SHA</key> <string>$(GIT_SHA)</string> diff --git a/Radroots/Runtime/FieldCaptureIntake.swift b/Radroots/Runtime/FieldCaptureIntake.swift @@ -0,0 +1,440 @@ +import Foundation +import RadrootsKit +import RadrootsKitTesting + +enum FieldCaptureIntakeError: LocalizedError { + case serviceNotReady + case missingFileScope(String) + + var errorDescription: String? { + switch self { + case .serviceNotReady: + "Capture intake is not ready. Please retry." + case .missingFileScope(let value): + "Unsupported capture file scope: \(value)." + } + } +} + +public enum FieldCaptureRecordSource: String, Codable, Equatable, Sendable { + case libraryImport + case cameraCapture + case documentScan + + var displayName: String { + switch self { + case .libraryImport: + "Imported photo" + case .cameraCapture: + "Camera photo" + case .documentScan: + "Scanned document" + } + } +} + +public enum FieldCaptureRecordKind: String, Codable, Equatable, Sendable { + case image + case pdf +} + +public enum FieldCaptureFileScope: String, Codable, Equatable, Sendable { + case data + case cache + case temporary + case logs + + init(_ scope: RadrootsFileScope) { + switch scope { + case .data: + self = .data + case .cache: + self = .cache + case .temporary: + self = .temporary + case .logs: + self = .logs + } + } + + var fileScope: RadrootsFileScope { + switch self { + case .data: + .data + case .cache: + .cache + case .temporary: + .temporary + case .logs: + .logs + } + } +} + +public struct FieldCaptureRecord: Identifiable, Codable, Equatable, Sendable { + public let id: UUID + let source: FieldCaptureRecordSource + let kind: FieldCaptureRecordKind + let fileScope: FieldCaptureFileScope + let fileRelativePath: String + let mediaType: String + let suggestedFilename: String + let sizeBytes: UInt64 + let pixelWidth: UInt32? + let pixelHeight: UInt32? + let pageCount: UInt16? + let capturedAt: Date + + var file: RadrootsFileReference { + RadrootsFileReference(scope: fileScope.fileScope, relativePath: fileRelativePath) + } + + var summary: String { + if let pageCount { + return "\(source.displayName) - \(pageCount) pages - \(suggestedFilename)" + } + if let pixelWidth, let pixelHeight { + return "\(source.displayName) - \(pixelWidth)x\(pixelHeight) - \(suggestedFilename)" + } + return "\(source.displayName) - \(suggestedFilename)" + } +} + +public struct FieldCaptureSupportState: Equatable, Sendable { + var photoImportAvailable: Bool + var cameraPhotoAvailable: Bool + var documentScannerAvailable: Bool + + static let unavailable = FieldCaptureSupportState( + photoImportAvailable: false, + cameraPhotoAvailable: false, + documentScannerAvailable: false + ) +} + +public enum FieldCaptureIntakeOperation: Equatable, Sendable { + case idle + case refreshing + case importingPhoto + case capturingPhoto + case scanningDocument +} + +public struct FieldCaptureIntakeState: Equatable, Sendable { + var support: FieldCaptureSupportState + var records: [FieldCaptureRecord] + var operation: FieldCaptureIntakeOperation + var lastError: String? + + static let idle = FieldCaptureIntakeState( + support: .unavailable, + records: [], + operation: .idle, + lastError: nil + ) + + var latestRecord: FieldCaptureRecord? { + records.sorted { left, right in + left.capturedAt > right.capturedAt + }.first + } +} + +final class FieldCaptureIntake: @unchecked Sendable { + private let mediaPicker: any RadrootsMediaPicker + private let documentScanner: any RadrootsDocumentScanner + private let fileAccess: RadrootsAppleFileAccess + private let recordsFile = RadrootsFileReference( + scope: .data, + relativePath: "capture_intake/records.json" + ) + private let encoder: JSONEncoder + private let decoder: JSONDecoder + + init( + fileAccess: RadrootsAppleFileAccess, + mediaPicker: any RadrootsMediaPicker, + documentScanner: any RadrootsDocumentScanner + ) { + self.fileAccess = fileAccess + self.mediaPicker = mediaPicker + self.documentScanner = documentScanner + self.encoder = JSONEncoder() + self.encoder.dateEncodingStrategy = .iso8601 + self.encoder.outputFormatting = [.prettyPrinted, .sortedKeys] + self.decoder = JSONDecoder() + self.decoder.dateDecodingStrategy = .iso8601 + } + + static func configured(bundleIdentifier: String) throws -> FieldCaptureIntake { + let fileAccess = try FieldLocalState.fileAccess(bundleIdentifier: bundleIdentifier) + if uiTestWasRequested { + return try uiTestConfigured(fileAccess: fileAccess) + } + return FieldCaptureIntake( + fileAccess: fileAccess, + mediaPicker: RadrootsAppleMediaPicker(fileAccess: fileAccess), + documentScanner: RadrootsAppleDocumentScanner(fileAccess: fileAccess) + ) + } + + func loadRecords() throws -> [FieldCaptureRecord] { + do { + let result = try fileAccess.read(recordsFile, mode: .inline) + guard case .inline(let data) = result else { + return [] + } + return try decoder.decode([FieldCaptureRecord].self, from: data) + } catch let error as RadrootsAppleFileError { + if case .notFound = error { + return [] + } + throw error + } + } + + func support() async throws -> FieldCaptureSupportState { + let mediaSupport = try await mediaPicker.currentSupport() + let scannerSupport = try await documentScanner.currentSupport() + return FieldCaptureSupportState( + photoImportAvailable: mediaSupport.importAvailable && mediaSupport.supportedImportKinds.contains(.image), + cameraPhotoAvailable: mediaSupport.cameraCaptureAvailable && mediaSupport.supportedCaptureKinds.contains(.image), + documentScannerAvailable: scannerSupport.interactiveScanAvailable && scannerSupport.supportedOutputKinds.contains(.pdf) + ) + } + + func importPhoto(records: [FieldCaptureRecord]) async throws -> [FieldCaptureRecord] { + let result = try await mediaPicker.importMedia( + try RadrootsMediaImportRequest( + allowedMediaKinds: [.image], + selectionLimit: 1, + destinationScope: .data + ) + ) + return try append(result.items.map(record(from:)), to: records) + } + + func capturePhoto(records: [FieldCaptureRecord]) async throws -> [FieldCaptureRecord] { + let result = try await mediaPicker.captureMedia( + try RadrootsMediaCaptureRequest(mediaKind: .image, destinationScope: .data) + ) + return try append([record(from: result.item)], to: records) + } + + func scanDocument(records: [FieldCaptureRecord]) async throws -> [FieldCaptureRecord] { + let result = try await documentScanner.scanDocument( + RadrootsDocumentScanRequest(outputKind: .pdf, destinationScope: .data) + ) + return try append([record(from: result)], to: records) + } + + private func append(_ newRecords: [FieldCaptureRecord], to records: [FieldCaptureRecord]) throws -> [FieldCaptureRecord] { + let updated = records + newRecords + try save(updated) + return updated + } + + private func save(_ records: [FieldCaptureRecord]) throws { + try fileAccess.write(.inline(encoder.encode(records)), to: recordsFile) + } + + private func record(from asset: RadrootsMediaAsset) -> FieldCaptureRecord { + FieldCaptureRecord( + id: UUID(), + source: asset.source == .cameraCapture ? .cameraCapture : .libraryImport, + kind: .image, + fileScope: FieldCaptureFileScope(asset.file.scope), + fileRelativePath: asset.file.relativePath, + mediaType: asset.mediaType, + suggestedFilename: asset.suggestedFilename, + sizeBytes: asset.sizeBytes, + pixelWidth: asset.pixelWidth, + pixelHeight: asset.pixelHeight, + pageCount: nil, + capturedAt: asset.capturedAt + ) + } + + private func record(from document: RadrootsScannedDocument) -> FieldCaptureRecord { + FieldCaptureRecord( + id: UUID(), + source: .documentScan, + kind: .pdf, + fileScope: FieldCaptureFileScope(document.file.scope), + fileRelativePath: document.file.relativePath, + mediaType: document.mediaType, + suggestedFilename: document.suggestedFilename, + sizeBytes: document.sizeBytes, + pixelWidth: nil, + pixelHeight: nil, + pageCount: document.pageCount, + capturedAt: document.capturedAt + ) + } + + private static var uiTestWasRequested: Bool { + let environment = ProcessInfo.processInfo.environment + return environment["RADROOTS_FIELD_IOS_UI_TEST"] == "true" || + ProcessInfo.processInfo.arguments.contains("--radroots-field-ios-ui-test") + } + + private static func uiTestConfigured(fileAccess: RadrootsAppleFileAccess) throws -> FieldCaptureIntake { + let importedAsset = try uiTestMediaAsset( + fileAccess: fileAccess, + source: .libraryImport, + relativePath: "capture_intake/ui_tests/imported_photo.jpg", + filename: "imported-field-photo.jpg", + bytes: "radroots imported field photo".data(using: .utf8) ?? Data(), + capturedAt: Date(timeIntervalSinceReferenceDate: 1_000) + ) + let capturedAsset = try uiTestMediaAsset( + fileAccess: fileAccess, + source: .cameraCapture, + relativePath: "capture_intake/ui_tests/camera_photo.jpg", + filename: "camera-field-photo.jpg", + bytes: "radroots camera field photo".data(using: .utf8) ?? Data(), + capturedAt: Date(timeIntervalSinceReferenceDate: 2_000) + ) + let scannedDocument = try uiTestScannedDocument(fileAccess: fileAccess) + let importOutcome = try uiTestMediaImportOutcome(success: RadrootsMediaImportResult(items: [importedAsset])) + let captureOutcome = uiTestMediaCaptureOutcome(success: RadrootsMediaCaptureResult(item: capturedAsset)) + let scannerOutcome = uiTestDocumentScannerOutcome(success: scannedDocument) + let mediaSupport = try RadrootsMediaPickerSupport( + importAvailable: !uiTestOutcome("RADROOTS_FIELD_IOS_UI_TEST_CAPTURE_IMPORT_OUTCOME").isUnavailable, + cameraCaptureAvailable: !uiTestOutcome("RADROOTS_FIELD_IOS_UI_TEST_CAPTURE_CAMERA_OUTCOME").isUnavailable, + supportedImportKinds: [.image], + supportedCaptureKinds: [.image], + multipleSelectionSupported: false + ) + let scannerSupport = try RadrootsDocumentScannerSupport( + interactiveScanAvailable: !uiTestOutcome("RADROOTS_FIELD_IOS_UI_TEST_CAPTURE_SCANNER_OUTCOME").isUnavailable, + multiPageSupported: true, + supportedOutputKinds: [.pdf] + ) + return FieldCaptureIntake( + fileAccess: fileAccess, + mediaPicker: RadrootsFakeMediaPicker( + support: mediaSupport, + importOutcome: importOutcome, + captureOutcome: captureOutcome + ), + documentScanner: RadrootsFakeDocumentScanner( + support: scannerSupport, + scanOutcome: scannerOutcome + ) + ) + } + + private static func uiTestMediaAsset( + fileAccess: RadrootsAppleFileAccess, + source: RadrootsMediaSource, + relativePath: String, + filename: String, + bytes: Data, + capturedAt: Date + ) throws -> RadrootsMediaAsset { + let file = RadrootsFileReference(scope: .data, relativePath: relativePath) + try fileAccess.write(.inline(bytes), to: file) + return try RadrootsMediaAsset( + source: source, + kind: .image, + file: file, + mediaType: "image/jpeg", + suggestedFilename: filename, + sizeBytes: UInt64(bytes.count), + pixelWidth: 1200, + pixelHeight: 900, + capturedAt: capturedAt + ) + } + + private static func uiTestScannedDocument(fileAccess: RadrootsAppleFileAccess) throws -> RadrootsScannedDocument { + let bytes = Data("%PDF-1.7\n% radroots field scan\n".utf8) + let file = RadrootsFileReference( + scope: .data, + relativePath: "capture_intake/ui_tests/scanned_document.pdf" + ) + try fileAccess.write(.inline(bytes), to: file) + return try RadrootsScannedDocument( + file: file, + outputKind: .pdf, + suggestedFilename: "field-scan.pdf", + mediaType: "application/pdf", + pageCount: 2, + sizeBytes: UInt64(bytes.count), + capturedAt: Date(timeIntervalSinceReferenceDate: 3_000) + ) + } + + private static func uiTestMediaImportOutcome( + success: RadrootsMediaImportResult + ) throws -> Result<RadrootsMediaImportResult, RadrootsCaptureIntakeError> { + try uiTestResult( + key: "RADROOTS_FIELD_IOS_UI_TEST_CAPTURE_IMPORT_OUTCOME", + success: success + ) + } + + private static func uiTestMediaCaptureOutcome( + success: RadrootsMediaCaptureResult + ) -> Result<RadrootsMediaCaptureResult, RadrootsCaptureIntakeError> { + do { + return try uiTestResult( + key: "RADROOTS_FIELD_IOS_UI_TEST_CAPTURE_CAMERA_OUTCOME", + success: success + ) + } catch { + return .failure(.permanentFailure(error.localizedDescription)) + } + } + + private static func uiTestDocumentScannerOutcome( + success: RadrootsScannedDocument + ) -> Result<RadrootsScannedDocument, RadrootsCaptureIntakeError> { + do { + return try uiTestResult( + key: "RADROOTS_FIELD_IOS_UI_TEST_CAPTURE_SCANNER_OUTCOME", + success: success + ) + } catch { + return .failure(.permanentFailure(error.localizedDescription)) + } + } + + private static func uiTestResult<T>( + key: String, + success: T + ) throws -> Result<T, RadrootsCaptureIntakeError> { + let outcome = uiTestOutcome(key) + switch outcome { + case .success: + return .success(success) + case .cancelled: + return .failure(.userCancelled("Capture was cancelled.")) + case .denied: + return .failure(.permissionDenied("Capture permission is denied.")) + case .unavailable: + return .failure(.unavailable("Capture is unavailable.")) + case .transientFailure: + return .failure(.transientFailure("Capture failed. Please retry.")) + } + } + + private static func uiTestOutcome(_ key: String) -> FieldCaptureUITestOutcome { + FieldCaptureUITestOutcome( + rawValue: ProcessInfo.processInfo.environment[key]?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + ) ?? .success + } +} + +private enum FieldCaptureUITestOutcome: String { + case success + case cancelled + case denied + case unavailable + case transientFailure = "transient_failure" + + var isUnavailable: Bool { + self == .unavailable + } +} diff --git a/project.yml b/project.yml @@ -34,6 +34,8 @@ targets: - framework: Radroots/Frameworks/RadrootsFFI.xcframework embed: false - package: RadrootsKit + - package: RadrootsKit + product: RadrootsKitTesting preBuildScripts: - name: Generate git SHA xcconfig basedOnDependencyAnalysis: false