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