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:
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