apple_kit

Apple-native services for Radroots iOS and macOS apps
git clone https://radroots.dev/git/apple_kit.git
Log | Files | Refs | README

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 }