apple_kit

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

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 }