RadrootsCaptureIntake.swift (13001B)
1 import Foundation 2 3 public enum RadrootsCaptureIntakeError: Error, Equatable, Sendable { 4 case invalidRequest(String) 5 case unavailable(String) 6 case permissionDenied(String) 7 case userCancelled(String) 8 case transientFailure(String) 9 case permanentFailure(String) 10 } 11 12 extension RadrootsCaptureIntakeError: LocalizedError { 13 public var errorDescription: String? { 14 switch self { 15 case .invalidRequest(let message): 16 message 17 case .unavailable(let message): 18 message 19 case .permissionDenied(let message): 20 message 21 case .userCancelled(let message): 22 message 23 case .transientFailure(let message): 24 message 25 case .permanentFailure(let message): 26 message 27 } 28 } 29 } 30 31 public enum RadrootsMediaKind: String, Sendable, Equatable, Hashable, CaseIterable { 32 case image 33 } 34 35 public enum RadrootsMediaSource: String, Sendable, Equatable, Hashable { 36 case libraryImport 37 case cameraCapture 38 } 39 40 public struct RadrootsMediaImportRequest: Sendable, Equatable, Hashable { 41 public let allowedMediaKinds: [RadrootsMediaKind] 42 public let selectionLimit: Int 43 public let destinationScope: RadrootsFileScope 44 45 public init( 46 allowedMediaKinds: [RadrootsMediaKind] = [.image], 47 selectionLimit: Int = 1, 48 destinationScope: RadrootsFileScope = .temporary 49 ) throws { 50 self.allowedMediaKinds = try RadrootsCaptureIntakeValidation.normalizedMediaKinds( 51 allowedMediaKinds, 52 field: "media import" 53 ) 54 self.selectionLimit = try RadrootsCaptureIntakeValidation.normalizedSelectionLimit(selectionLimit) 55 self.destinationScope = destinationScope 56 } 57 } 58 59 public struct RadrootsMediaCaptureRequest: Sendable, Equatable, Hashable { 60 public let mediaKind: RadrootsMediaKind 61 public let destinationScope: RadrootsFileScope 62 63 public init( 64 mediaKind: RadrootsMediaKind = .image, 65 destinationScope: RadrootsFileScope = .temporary 66 ) throws { 67 self.mediaKind = mediaKind 68 self.destinationScope = destinationScope 69 } 70 } 71 72 public struct RadrootsMediaPickerSupport: Sendable, Equatable, Hashable { 73 public let importAvailable: Bool 74 public let cameraCaptureAvailable: Bool 75 public let supportedImportKinds: [RadrootsMediaKind] 76 public let supportedCaptureKinds: [RadrootsMediaKind] 77 public let multipleSelectionSupported: Bool 78 79 public init( 80 importAvailable: Bool, 81 cameraCaptureAvailable: Bool, 82 supportedImportKinds: [RadrootsMediaKind], 83 supportedCaptureKinds: [RadrootsMediaKind], 84 multipleSelectionSupported: Bool 85 ) throws { 86 self.importAvailable = importAvailable 87 self.cameraCaptureAvailable = cameraCaptureAvailable 88 self.supportedImportKinds = importAvailable 89 ? try RadrootsCaptureIntakeValidation.normalizedMediaKinds(supportedImportKinds, field: "media import support") 90 : [] 91 self.supportedCaptureKinds = cameraCaptureAvailable 92 ? try RadrootsCaptureIntakeValidation.normalizedMediaKinds(supportedCaptureKinds, field: "camera capture support") 93 : [] 94 self.multipleSelectionSupported = multipleSelectionSupported 95 } 96 } 97 98 public struct RadrootsMediaAsset: Sendable, Equatable, Hashable { 99 public let source: RadrootsMediaSource 100 public let kind: RadrootsMediaKind 101 public let file: RadrootsFileReference 102 public let mediaType: String 103 public let suggestedFilename: String 104 public let sizeBytes: UInt64 105 public let pixelWidth: UInt32? 106 public let pixelHeight: UInt32? 107 public let capturedAt: Date 108 109 public init( 110 source: RadrootsMediaSource, 111 kind: RadrootsMediaKind, 112 file: RadrootsFileReference, 113 mediaType: String, 114 suggestedFilename: String, 115 sizeBytes: UInt64, 116 pixelWidth: UInt32? = nil, 117 pixelHeight: UInt32? = nil, 118 capturedAt: Date 119 ) throws { 120 self.source = source 121 self.kind = kind 122 self.file = file 123 self.mediaType = try RadrootsCaptureIntakeValidation.normalizedMediaType(mediaType) 124 self.suggestedFilename = try RadrootsCaptureIntakeValidation.normalizedFilename(suggestedFilename) 125 self.sizeBytes = sizeBytes 126 self.pixelWidth = try RadrootsCaptureIntakeValidation.normalizedDimension(pixelWidth, field: "pixel width") 127 self.pixelHeight = try RadrootsCaptureIntakeValidation.normalizedDimension(pixelHeight, field: "pixel height") 128 if self.pixelWidth == nil || self.pixelHeight == nil { 129 guard self.pixelWidth == nil && self.pixelHeight == nil else { 130 throw RadrootsCaptureIntakeError.invalidRequest("image dimensions must include width and height together") 131 } 132 } 133 self.capturedAt = try RadrootsCaptureIntakeValidation.normalizedDate(capturedAt, field: "captured timestamp") 134 } 135 } 136 137 public struct RadrootsMediaImportResult: Sendable, Equatable, Hashable { 138 public let items: [RadrootsMediaAsset] 139 140 public init(items: [RadrootsMediaAsset]) throws { 141 guard !items.isEmpty else { 142 throw RadrootsCaptureIntakeError.invalidRequest("media import result cannot be empty") 143 } 144 self.items = items 145 } 146 } 147 148 public struct RadrootsMediaCaptureResult: Sendable, Equatable, Hashable { 149 public let item: RadrootsMediaAsset 150 151 public init(item: RadrootsMediaAsset) { 152 self.item = item 153 } 154 } 155 156 public protocol RadrootsMediaPicker: Sendable { 157 func currentSupport() async throws -> RadrootsMediaPickerSupport 158 func importMedia(_ request: RadrootsMediaImportRequest) async throws -> RadrootsMediaImportResult 159 func captureMedia(_ request: RadrootsMediaCaptureRequest) async throws -> RadrootsMediaCaptureResult 160 } 161 162 public enum RadrootsDocumentScannerOutputKind: String, Sendable, Equatable, Hashable, CaseIterable { 163 case pdf 164 } 165 166 public struct RadrootsDocumentScannerSupport: Sendable, Equatable, Hashable { 167 public let interactiveScanAvailable: Bool 168 public let multiPageSupported: Bool 169 public let supportedOutputKinds: [RadrootsDocumentScannerOutputKind] 170 171 public init( 172 interactiveScanAvailable: Bool, 173 multiPageSupported: Bool, 174 supportedOutputKinds: [RadrootsDocumentScannerOutputKind] 175 ) throws { 176 self.interactiveScanAvailable = interactiveScanAvailable 177 self.multiPageSupported = multiPageSupported && interactiveScanAvailable 178 self.supportedOutputKinds = interactiveScanAvailable 179 ? try RadrootsCaptureIntakeValidation.normalizedScannerOutputKinds(supportedOutputKinds) 180 : [] 181 } 182 } 183 184 public struct RadrootsDocumentScanRequest: Sendable, Equatable, Hashable { 185 public let outputKind: RadrootsDocumentScannerOutputKind 186 public let destinationScope: RadrootsFileScope 187 188 public init( 189 outputKind: RadrootsDocumentScannerOutputKind = .pdf, 190 destinationScope: RadrootsFileScope = .temporary 191 ) { 192 self.outputKind = outputKind 193 self.destinationScope = destinationScope 194 } 195 } 196 197 public struct RadrootsScannedDocument: Sendable, Equatable, Hashable { 198 public let file: RadrootsFileReference 199 public let outputKind: RadrootsDocumentScannerOutputKind 200 public let suggestedFilename: String 201 public let mediaType: String 202 public let pageCount: UInt16 203 public let sizeBytes: UInt64 204 public let capturedAt: Date 205 206 public init( 207 file: RadrootsFileReference, 208 outputKind: RadrootsDocumentScannerOutputKind, 209 suggestedFilename: String, 210 mediaType: String, 211 pageCount: UInt16, 212 sizeBytes: UInt64, 213 capturedAt: Date 214 ) throws { 215 self.file = file 216 self.outputKind = outputKind 217 self.suggestedFilename = try RadrootsCaptureIntakeValidation.normalizedFilename(suggestedFilename) 218 self.mediaType = try RadrootsCaptureIntakeValidation.normalizedMediaType(mediaType) 219 guard pageCount > 0 else { 220 throw RadrootsCaptureIntakeError.invalidRequest("scanned document page count must be positive") 221 } 222 self.pageCount = pageCount 223 self.sizeBytes = sizeBytes 224 self.capturedAt = try RadrootsCaptureIntakeValidation.normalizedDate(capturedAt, field: "scanned document timestamp") 225 } 226 } 227 228 public protocol RadrootsDocumentScanner: Sendable { 229 func currentSupport() async throws -> RadrootsDocumentScannerSupport 230 func scanDocument(_ request: RadrootsDocumentScanRequest) async throws -> RadrootsScannedDocument 231 } 232 233 public enum RadrootsCaptureIntakeValidation { 234 public static func normalizedMediaKinds(_ kinds: [RadrootsMediaKind], field: String) throws -> [RadrootsMediaKind] { 235 var seen = Set<RadrootsMediaKind>() 236 let normalized = kinds.filter { kind in 237 if seen.contains(kind) { 238 return false 239 } 240 seen.insert(kind) 241 return true 242 } 243 guard !normalized.isEmpty else { 244 throw RadrootsCaptureIntakeError.invalidRequest("\(field) must allow at least one media kind") 245 } 246 return normalized 247 } 248 249 public static func normalizedSelectionLimit(_ selectionLimit: Int) throws -> Int { 250 guard selectionLimit > 0 else { 251 throw RadrootsCaptureIntakeError.invalidRequest("media import selection limit must be positive") 252 } 253 guard selectionLimit <= 100 else { 254 throw RadrootsCaptureIntakeError.invalidRequest("media import selection limit cannot exceed 100") 255 } 256 return selectionLimit 257 } 258 259 public static func normalizedScannerOutputKinds( 260 _ outputKinds: [RadrootsDocumentScannerOutputKind] 261 ) throws -> [RadrootsDocumentScannerOutputKind] { 262 var seen = Set<RadrootsDocumentScannerOutputKind>() 263 let normalized = outputKinds.filter { outputKind in 264 if seen.contains(outputKind) { 265 return false 266 } 267 seen.insert(outputKind) 268 return true 269 } 270 guard !normalized.isEmpty else { 271 throw RadrootsCaptureIntakeError.invalidRequest("document scanner must support at least one output kind") 272 } 273 return normalized 274 } 275 276 public static func normalizedFilename(_ filename: String) throws -> String { 277 let trimmed = filename.trimmingCharacters(in: .whitespacesAndNewlines) 278 guard !trimmed.isEmpty else { 279 throw RadrootsCaptureIntakeError.invalidRequest("capture filename cannot be empty") 280 } 281 guard trimmed != "." && trimmed != ".." else { 282 throw RadrootsCaptureIntakeError.invalidRequest("capture filename cannot be a path segment") 283 } 284 guard !NSString(string: trimmed).isAbsolutePath else { 285 throw RadrootsCaptureIntakeError.invalidRequest("capture filename cannot be absolute") 286 } 287 guard !trimmed.contains("/") && !trimmed.contains("\\") && !trimmed.contains("\0") else { 288 throw RadrootsCaptureIntakeError.invalidRequest("capture filename cannot contain path separators") 289 } 290 guard trimmed.rangeOfCharacter(from: .controlCharacters) == nil else { 291 throw RadrootsCaptureIntakeError.invalidRequest("capture filename cannot contain control characters") 292 } 293 guard trimmed.utf8.count <= 255 else { 294 throw RadrootsCaptureIntakeError.invalidRequest("capture filename is too long") 295 } 296 return trimmed 297 } 298 299 public static func normalizedMediaType(_ mediaType: String) throws -> String { 300 let trimmed = mediaType.trimmingCharacters(in: .whitespacesAndNewlines) 301 guard !trimmed.isEmpty else { 302 throw RadrootsCaptureIntakeError.invalidRequest("capture media type cannot be empty") 303 } 304 guard trimmed.rangeOfCharacter(from: .whitespacesAndNewlines.union(.controlCharacters)) == nil else { 305 throw RadrootsCaptureIntakeError.invalidRequest("capture media type cannot contain whitespace") 306 } 307 let parts = trimmed.split(separator: "/", omittingEmptySubsequences: false) 308 guard parts.count == 2, parts.allSatisfy({ !$0.isEmpty }) else { 309 throw RadrootsCaptureIntakeError.invalidRequest("capture media type must be type/subtype") 310 } 311 return trimmed.lowercased() 312 } 313 314 public static func normalizedDimension(_ dimension: UInt32?, field: String) throws -> UInt32? { 315 guard let dimension else { 316 return nil 317 } 318 guard dimension > 0 else { 319 throw RadrootsCaptureIntakeError.invalidRequest("\(field) must be positive") 320 } 321 return dimension 322 } 323 324 public static func normalizedDate(_ date: Date, field: String) throws -> Date { 325 guard date.timeIntervalSinceReferenceDate.isFinite else { 326 throw RadrootsCaptureIntakeError.invalidRequest("\(field) must be finite") 327 } 328 return date 329 } 330 }