apple_kit

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

commit d4a4fbb498a44885c6dea5f8f58a173d87674a6a
parent a74b8041cd092bc54f5cb59d6482cc4932dd8beb
Author: triesap <tyson@radroots.org>
Date:   Sun, 14 Jun 2026 13:02:54 -0700

capture: add Apple media picker

- add AppleKit-owned still-image import and camera capture service
- persist media results through RadrootsAppleFileAccess
- normalize media metadata and capture errors behind RadrootsKit
- cover unavailable platform behavior with Swift tests

Diffstat:
ASources/RadrootsKit/RadrootsAppleMediaPicker.swift | 676+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
ATests/RadrootsKitTests/RadrootsAppleMediaPickerTests.swift | 34++++++++++++++++++++++++++++++++++
2 files changed, 710 insertions(+), 0 deletions(-)

diff --git a/Sources/RadrootsKit/RadrootsAppleMediaPicker.swift b/Sources/RadrootsKit/RadrootsAppleMediaPicker.swift @@ -0,0 +1,676 @@ +import Foundation + +#if canImport(AVFoundation) +@preconcurrency import AVFoundation +#endif + +#if canImport(ImageIO) +import ImageIO +#endif + +#if canImport(PhotosUI) +@preconcurrency import PhotosUI +#endif + +#if canImport(UIKit) +@preconcurrency import UIKit +#endif + +#if canImport(UniformTypeIdentifiers) +import UniformTypeIdentifiers +#endif + +#if canImport(UIKit) +public typealias RadrootsAppleViewControllerProvider = @MainActor @Sendable () throws -> UIViewController +#endif + +public final class RadrootsAppleMediaPicker: RadrootsMediaPicker, @unchecked Sendable { + private let fileAccess: RadrootsAppleFileAccess + private let fileManager: FileManager + private let callbackTimeout: TimeInterval + + #if canImport(UIKit) + private let viewControllerProvider: RadrootsAppleViewControllerProvider + #endif + + #if canImport(UIKit) + public init( + fileAccess: RadrootsAppleFileAccess, + fileManager: FileManager = .default, + callbackTimeout: TimeInterval = 120, + viewControllerProvider: @escaping RadrootsAppleViewControllerProvider = { + try RadrootsAppleUIKitPresentation.activeViewController(service: "media picker") + } + ) { + self.fileAccess = fileAccess + self.fileManager = fileManager + self.callbackTimeout = callbackTimeout + self.viewControllerProvider = viewControllerProvider + } + #else + public init( + fileAccess: RadrootsAppleFileAccess, + fileManager: FileManager = .default, + callbackTimeout: TimeInterval = 120 + ) { + self.fileAccess = fileAccess + self.fileManager = fileManager + self.callbackTimeout = callbackTimeout + } + #endif + + public func currentSupport() async throws -> RadrootsMediaPickerSupport { + #if canImport(UIKit) && canImport(PhotosUI) + try await MainActor.run { + try Self.liveSupport() + } + #else + try Self.unavailableSupport() + #endif + } + + public func importMedia(_ request: RadrootsMediaImportRequest) async throws -> RadrootsMediaImportResult { + #if canImport(UIKit) && canImport(PhotosUI) + let support = try await currentSupport() + guard support.importAvailable else { + throw RadrootsCaptureIntakeError.unavailable("media import is unavailable") + } + let writer = RadrootsAppleMediaAssetWriter(fileAccess: fileAccess, fileManager: fileManager) + let presenter = try await MainActor.run { + try viewControllerProvider() + } + let coordinatorID = UUID() + return try await RadrootsAppleCaptureAsyncSupport.awaitMainActorCallback( + timeout: callbackTimeout, + timeoutMessage: "timed out while presenting media import" + ) { completion in + var configuration = PHPickerConfiguration(photoLibrary: .shared()) + configuration.selectionLimit = request.selectionLimit + configuration.filter = .images + let picker = PHPickerViewController(configuration: configuration) + let coordinator = RadrootsApplePhotoPickerCoordinator( + writer: writer, + request: request, + coordinatorID: coordinatorID + ) + coordinator.completion = completion + picker.delegate = coordinator + RadrootsApplePresentationRetainer.shared.store(coordinator, id: coordinatorID) + presenter.present(picker, animated: true) + } + #else + throw RadrootsCaptureIntakeError.unavailable("media import is unavailable") + #endif + } + + public func captureMedia(_ request: RadrootsMediaCaptureRequest) async throws -> RadrootsMediaCaptureResult { + #if canImport(UIKit) + let support = try await currentSupport() + guard support.cameraCaptureAvailable else { + throw RadrootsCaptureIntakeError.unavailable("camera photo capture is unavailable") + } + try await Self.requestCameraAccessIfNeeded() + let writer = RadrootsAppleMediaAssetWriter(fileAccess: fileAccess, fileManager: fileManager) + let presenter = try await MainActor.run { + try viewControllerProvider() + } + let coordinatorID = UUID() + return try await RadrootsAppleCaptureAsyncSupport.awaitMainActorCallback( + timeout: callbackTimeout, + timeoutMessage: "timed out while presenting camera photo capture" + ) { completion in + let picker = UIImagePickerController() + picker.sourceType = .camera + picker.mediaTypes = [Self.imageTypeIdentifier()] + picker.cameraCaptureMode = .photo + let coordinator = RadrootsAppleCameraCaptureCoordinator( + writer: writer, + request: request, + coordinatorID: coordinatorID + ) + coordinator.completion = completion + picker.delegate = coordinator + RadrootsApplePresentationRetainer.shared.store(coordinator, id: coordinatorID) + presenter.present(picker, animated: true) + } + #else + throw RadrootsCaptureIntakeError.unavailable("camera photo capture is unavailable") + #endif + } + + static func unavailableSupport() throws -> RadrootsMediaPickerSupport { + try RadrootsMediaPickerSupport( + importAvailable: false, + cameraCaptureAvailable: false, + supportedImportKinds: [], + supportedCaptureKinds: [], + multipleSelectionSupported: false + ) + } + + static func adapt(error: Error) -> RadrootsCaptureIntakeError { + if let captureError = error as? RadrootsCaptureIntakeError { + return captureError + } + if let fileError = error as? RadrootsAppleFileError { + return adapt(fileError: fileError) + } + return RadrootsCaptureIntakeError.transientFailure((error as NSError).localizedDescription) + } + + static func adapt(fileError: RadrootsAppleFileError) -> RadrootsCaptureIntakeError { + switch fileError { + case .invalidRequest(let message): + return .invalidRequest(message) + case .notFound(let message): + return .transientFailure(message) + case .permissionDenied(let message): + return .permissionDenied(message) + case .transientFailure(let message): + return .transientFailure(message) + case .permanentFailure(let message): + return .permanentFailure(message) + } + } +} + +#if canImport(UIKit) +private extension RadrootsAppleMediaPicker { + @MainActor + static func liveSupport() throws -> RadrootsMediaPickerSupport { + let cameraAvailable = UIImagePickerController.isSourceTypeAvailable(.camera) && + UIImagePickerController.availableMediaTypes(for: .camera)?.contains(imageTypeIdentifier()) == true + return try RadrootsMediaPickerSupport( + importAvailable: true, + cameraCaptureAvailable: cameraAvailable, + supportedImportKinds: [.image], + supportedCaptureKinds: cameraAvailable ? [.image] : [], + multipleSelectionSupported: true + ) + } + + static func imageTypeIdentifier() -> String { + #if canImport(UniformTypeIdentifiers) + UTType.image.identifier + #else + "public.image" + #endif + } + + static func requestCameraAccessIfNeeded() async throws { + #if canImport(AVFoundation) + switch AVCaptureDevice.authorizationStatus(for: .video) { + case .authorized: + return + case .notDetermined: + let granted = await AVCaptureDevice.requestAccess(for: .video) + guard granted else { + throw RadrootsCaptureIntakeError.permissionDenied("camera access was not granted") + } + case .denied: + throw RadrootsCaptureIntakeError.permissionDenied("camera access is denied") + case .restricted: + throw RadrootsCaptureIntakeError.permissionDenied("camera access is restricted") + @unknown default: + throw RadrootsCaptureIntakeError.unavailable("camera authorization is unavailable") + } + #else + throw RadrootsCaptureIntakeError.unavailable("camera authorization is unavailable") + #endif + } +} + +@MainActor +private enum RadrootsAppleUIKitPresentation { + static func activeViewController(service: String) throws -> UIViewController { + let scenes = UIApplication.shared.connectedScenes + .compactMap { $0 as? UIWindowScene } + .filter { scene in + scene.activationState == .foregroundActive || scene.activationState == .foregroundInactive + } + let windows = scenes.flatMap(\.windows) + guard let window = windows.first(where: \.isKeyWindow) ?? windows.first(where: { !$0.isHidden }) else { + throw RadrootsCaptureIntakeError.unavailable("\(service) requires an active foreground window") + } + guard let rootViewController = window.rootViewController else { + throw RadrootsCaptureIntakeError.unavailable("\(service) requires an active foreground view controller") + } + return topViewController(rootViewController) + } + + private static func topViewController(_ viewController: UIViewController) -> UIViewController { + if let presentedViewController = viewController.presentedViewController { + return topViewController(presentedViewController) + } + if let navigationController = viewController as? UINavigationController, + let visibleViewController = navigationController.visibleViewController { + return topViewController(visibleViewController) + } + if let tabBarController = viewController as? UITabBarController, + let selectedViewController = tabBarController.selectedViewController { + return topViewController(selectedViewController) + } + return viewController + } +} + +@MainActor +private final class RadrootsApplePhotoPickerCoordinator: NSObject, PHPickerViewControllerDelegate { + var completion: (@Sendable (Result<RadrootsMediaImportResult, RadrootsCaptureIntakeError>) -> Void)? + + private let writer: RadrootsAppleMediaAssetWriter + private let request: RadrootsMediaImportRequest + private let coordinatorID: UUID + private var selectedResults: [PHPickerResult] + private var didResolve: Bool + + init( + writer: RadrootsAppleMediaAssetWriter, + request: RadrootsMediaImportRequest, + coordinatorID: UUID + ) { + self.writer = writer + self.request = request + self.coordinatorID = coordinatorID + self.selectedResults = [] + self.didResolve = false + } + + func picker(_ picker: PHPickerViewController, didFinishPicking results: [PHPickerResult]) { + picker.dismiss(animated: true) + selectedResults = Array(results.prefix(request.selectionLimit)) + guard !selectedResults.isEmpty else { + finish(.failure(.userCancelled("media import was cancelled"))) + return + } + loadResult(at: 0, collected: []) + } + + private func loadResult(at index: Int, collected: [RadrootsMediaAsset]) { + guard index < selectedResults.count else { + do { + finish(.success(try RadrootsMediaImportResult(items: collected))) + } catch { + finish(.failure(RadrootsAppleMediaPicker.adapt(error: error))) + } + return + } + let provider = selectedResults[index].itemProvider + let suggestedName = provider.suggestedName ?? "photo" + guard provider.hasItemConformingToTypeIdentifier(RadrootsAppleMediaPicker.imageTypeIdentifier()) else { + finish(.failure(.transientFailure("media import could not resolve an image file representation"))) + return + } + provider.loadFileRepresentation(forTypeIdentifier: RadrootsAppleMediaPicker.imageTypeIdentifier()) { url, error in + if let error { + Task { @MainActor in + self.finish(.failure(RadrootsAppleMediaPicker.adapt(error: error))) + } + return + } + guard let url else { + Task { @MainActor in + self.finish(.failure(.transientFailure("media import finished without an image file representation"))) + } + return + } + let result: Result<RadrootsMediaAsset, RadrootsCaptureIntakeError> + do { + result = .success( + try self.writer.persistExternalImage( + sourceURL: url, + source: .libraryImport, + destinationScope: self.request.destinationScope, + suggestedFilename: suggestedName, + mediaTypeHint: self.mediaTypeHint(from: provider) + ) + ) + } catch { + result = .failure(RadrootsAppleMediaPicker.adapt(error: error)) + } + Task { @MainActor in + switch result { + case .success(let asset): + var nextCollected = collected + nextCollected.append(asset) + self.loadResult(at: index + 1, collected: nextCollected) + case .failure(let error): + self.finish(.failure(error)) + } + } + } + } + + private func mediaTypeHint(from provider: NSItemProvider) -> String? { + #if canImport(UniformTypeIdentifiers) + provider.registeredTypeIdentifiers + .compactMap(UTType.init) + .first(where: { $0.conforms(to: .image) })? + .preferredMIMEType + #else + nil + #endif + } + + private func finish(_ result: Result<RadrootsMediaImportResult, RadrootsCaptureIntakeError>) { + guard !didResolve else { return } + didResolve = true + let completion = completion + self.completion = nil + RadrootsApplePresentationRetainer.shared.release(id: coordinatorID) + completion?(result) + } +} + +@MainActor +private final class RadrootsAppleCameraCaptureCoordinator: NSObject, UIImagePickerControllerDelegate, UINavigationControllerDelegate { + var completion: (@Sendable (Result<RadrootsMediaCaptureResult, RadrootsCaptureIntakeError>) -> Void)? + + private let writer: RadrootsAppleMediaAssetWriter + private let request: RadrootsMediaCaptureRequest + private let coordinatorID: UUID + private var didResolve: Bool + + init( + writer: RadrootsAppleMediaAssetWriter, + request: RadrootsMediaCaptureRequest, + coordinatorID: UUID + ) { + self.writer = writer + self.request = request + self.coordinatorID = coordinatorID + self.didResolve = false + } + + func imagePickerControllerDidCancel(_ picker: UIImagePickerController) { + picker.dismiss(animated: true) + finish(.failure(.userCancelled("camera photo capture was cancelled"))) + } + + func imagePickerController( + _ picker: UIImagePickerController, + didFinishPickingMediaWithInfo info: [UIImagePickerController.InfoKey: Any] + ) { + picker.dismiss(animated: true) + do { + finish(.success(try RadrootsMediaCaptureResult(item: buildAsset(info: info)))) + } catch { + finish(.failure(RadrootsAppleMediaPicker.adapt(error: error))) + } + } + + private func buildAsset(info: [UIImagePickerController.InfoKey: Any]) throws -> RadrootsMediaAsset { + if let imageURL = info[.imageURL] as? URL { + return try writer.persistExternalImage( + sourceURL: imageURL, + source: .cameraCapture, + destinationScope: request.destinationScope, + suggestedFilename: imageURL.lastPathComponent, + mediaTypeHint: nil + ) + } + guard let image = (info[.editedImage] as? UIImage) ?? (info[.originalImage] as? UIImage), + let jpegData = image.jpegData(compressionQuality: 0.92) else { + throw RadrootsCaptureIntakeError.transientFailure("camera photo capture finished without a usable image") + } + return try writer.persistCapturedJPEG( + data: jpegData, + image: image, + destinationScope: request.destinationScope + ) + } + + private func finish(_ result: Result<RadrootsMediaCaptureResult, RadrootsCaptureIntakeError>) { + guard !didResolve else { return } + didResolve = true + let completion = completion + self.completion = nil + RadrootsApplePresentationRetainer.shared.release(id: coordinatorID) + completion?(result) + } +} +#endif + +@MainActor +private final class RadrootsApplePresentationRetainer { + static let shared = RadrootsApplePresentationRetainer() + private var retainers: [UUID: AnyObject] + + private init() { + self.retainers = [:] + } + + func store(_ retainer: AnyObject, id: UUID) { + retainers[id] = retainer + } + + func release(id: UUID) { + retainers.removeValue(forKey: id) + } +} + +private final class RadrootsAppleMediaAssetWriter: @unchecked Sendable { + private let fileAccess: RadrootsAppleFileAccess + private let fileManager: FileManager + + init(fileAccess: RadrootsAppleFileAccess, fileManager: FileManager) { + self.fileAccess = fileAccess + self.fileManager = fileManager + } + + func persistExternalImage( + sourceURL: URL, + source: RadrootsMediaSource, + destinationScope: RadrootsFileScope, + suggestedFilename: String, + mediaTypeHint: String? + ) throws -> RadrootsMediaAsset { + let filename = try sanitizedFilename( + suggestedFilename, + fallbackBasename: "photo", + fallbackExtension: fallbackExtension(mediaType: mediaTypeHint) + ) + let file = try destinationFile(source: source, scope: destinationScope, filename: filename) + let mediaType = try normalizedImageMediaType(mediaTypeHint, filename: filename) + let imported = try fileAccess.copyExternalFile( + sourceURL, + to: file, + mediaType: mediaType, + suggestedFilename: filename + ) + let destinationURL = try fileAccess.roots.resolvedURL(for: imported.file) + let dimensions = imageDimensions(fileURL: destinationURL) + return try RadrootsMediaAsset( + source: source, + kind: .image, + file: imported.file, + mediaType: imported.mediaType ?? mediaType, + suggestedFilename: imported.suggestedFilename, + sizeBytes: imported.sizeBytes, + pixelWidth: dimensions?.width, + pixelHeight: dimensions?.height, + capturedAt: Date() + ) + } + + #if canImport(UIKit) + func persistCapturedJPEG( + data: Data, + image: UIImage, + destinationScope: RadrootsFileScope + ) throws -> RadrootsMediaAsset { + let filename = try sanitizedFilename( + "captured_photo.jpg", + fallbackBasename: "captured_photo", + fallbackExtension: "jpg" + ) + let file = try destinationFile(source: .cameraCapture, scope: destinationScope, filename: filename) + try fileAccess.write(.inline(data), to: file) + return try RadrootsMediaAsset( + source: .cameraCapture, + kind: .image, + file: file, + mediaType: "image/jpeg", + suggestedFilename: filename, + sizeBytes: UInt64(data.count), + pixelWidth: image.cgImage.map { UInt32($0.width) } ?? positiveRoundedUInt32(image.size.width), + pixelHeight: image.cgImage.map { UInt32($0.height) } ?? positiveRoundedUInt32(image.size.height), + capturedAt: Date() + ) + } + #endif + + private func destinationFile( + source: RadrootsMediaSource, + scope: RadrootsFileScope, + filename: String + ) throws -> RadrootsFileReference { + let namespace: String + switch source { + case .libraryImport: + namespace = "library_import" + case .cameraCapture: + namespace = "camera_capture" + } + let validatedFilename = try RadrootsCaptureIntakeValidation.normalizedFilename(filename) + return RadrootsFileReference( + scope: scope, + relativePath: "capture_intake/media/\(namespace)/\(UUID().uuidString.lowercased())/\(validatedFilename)" + ) + } + + private func sanitizedFilename( + _ value: String, + fallbackBasename: String, + fallbackExtension: String + ) throws -> String { + let fallback = "\(fallbackBasename).\(fallbackExtension)" + let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) + let lastComponent = URL(fileURLWithPath: trimmed).lastPathComponent + let raw = lastComponent.isEmpty || lastComponent == "/" ? fallback : lastComponent + let sanitizedScalars = raw.unicodeScalars.map { scalar -> Character in + if CharacterSet.controlCharacters.contains(scalar) || + scalar == "/" || + scalar == "\\" || + scalar == "\0" || + scalar == ":" { + return "_" + } + return Character(scalar) + } + var sanitized = String(sanitizedScalars).trimmingCharacters(in: .whitespacesAndNewlines) + if sanitized.isEmpty || sanitized == "." || sanitized == ".." { + sanitized = fallback + } + if URL(fileURLWithPath: sanitized).pathExtension.isEmpty { + sanitized = "\(sanitized).\(fallbackExtension)" + } + return try RadrootsCaptureIntakeValidation.normalizedFilename(sanitized) + } + + private func normalizedImageMediaType(_ mediaType: String?, filename: String) throws -> String { + if let mediaType { + return try RadrootsCaptureIntakeValidation.normalizedMediaType(mediaType) + } + #if canImport(UniformTypeIdentifiers) + if let type = UTType(filenameExtension: URL(fileURLWithPath: filename).pathExtension), + let preferredMIMEType = type.preferredMIMEType { + return try RadrootsCaptureIntakeValidation.normalizedMediaType(preferredMIMEType) + } + #endif + return "image/jpeg" + } + + private func fallbackExtension(mediaType: String?) -> String { + switch mediaType?.lowercased() { + case "image/png": + "png" + case "image/heic": + "heic" + case "image/heif": + "heif" + default: + "jpg" + } + } + + private func imageDimensions(fileURL: URL) -> (width: UInt32, height: UInt32)? { + #if canImport(ImageIO) + guard let imageSource = CGImageSourceCreateWithURL(fileURL as CFURL, nil), + let properties = CGImageSourceCopyPropertiesAtIndex(imageSource, 0, nil) as? [CFString: Any], + let width = properties[kCGImagePropertyPixelWidth] as? NSNumber, + let height = properties[kCGImagePropertyPixelHeight] as? NSNumber, + width.uint32Value > 0, + height.uint32Value > 0 else { + return nil + } + return (width.uint32Value, height.uint32Value) + #else + return nil + #endif + } + + private func positiveRoundedUInt32(_ value: Double) -> UInt32? { + guard value.isFinite, value > 0 else { + return nil + } + return UInt32(value.rounded()) + } +} + +private enum RadrootsAppleCaptureAsyncSupport { + static func awaitMainActorCallback<Value: Sendable>( + timeout: TimeInterval, + timeoutMessage: String, + operation: @escaping @MainActor (@escaping @Sendable (Result<Value, RadrootsCaptureIntakeError>) -> Void) -> Void + ) async throws -> Value { + try await withCheckedThrowingContinuation { continuation in + let state = RadrootsAppleCaptureAsyncCallbackState(continuation: continuation) + let timeoutTask = Task { + let nanoseconds = UInt64(max(timeout, 0) * 1_000_000_000) + do { + try await Task.sleep(nanoseconds: nanoseconds) + } catch { + return + } + state.resolve(.failure(.transientFailure(timeoutMessage))) + } + Task { @MainActor in + operation { result in + timeoutTask.cancel() + state.resolve(result) + } + } + } + } +} + +private final class RadrootsAppleCaptureAsyncCallbackState<Value: Sendable>: @unchecked Sendable { + private let lock: NSLock + private var continuation: CheckedContinuation<Value, any Error>? + private var didResolve: Bool + + init(continuation: CheckedContinuation<Value, any Error>) { + self.lock = NSLock() + self.continuation = continuation + self.didResolve = false + } + + func resolve(_ result: Result<Value, RadrootsCaptureIntakeError>) { + lock.lock() + guard !didResolve, let continuation else { + lock.unlock() + return + } + didResolve = true + self.continuation = nil + lock.unlock() + + switch result { + case .success(let value): + continuation.resume(returning: value) + case .failure(let error): + continuation.resume(throwing: error) + } + } +} diff --git a/Tests/RadrootsKitTests/RadrootsAppleMediaPickerTests.swift b/Tests/RadrootsKitTests/RadrootsAppleMediaPickerTests.swift @@ -0,0 +1,34 @@ +import Foundation +import Testing +@testable import RadrootsKit + +#if !canImport(UIKit) +@Test func appleMediaPickerReportsUnavailableWithoutUIKit() async throws { + let picker = try RadrootsAppleMediaPicker(fileAccess: mediaPickerTestFileAccess()) + let support = try await picker.currentSupport() + + #expect(!support.importAvailable) + #expect(!support.cameraCaptureAvailable) + #expect(support.supportedImportKinds.isEmpty) + #expect(support.supportedCaptureKinds.isEmpty) + + await #expect(throws: RadrootsCaptureIntakeError.unavailable("media import is unavailable")) { + _ = try await picker.importMedia(try RadrootsMediaImportRequest()) + } + await #expect(throws: RadrootsCaptureIntakeError.unavailable("camera photo capture is unavailable")) { + _ = try await picker.captureMedia(try RadrootsMediaCaptureRequest()) + } +} +#endif + +private func mediaPickerTestFileAccess() throws -> RadrootsAppleFileAccess { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("radroots-media-picker-\(UUID().uuidString)", isDirectory: true) + let roots = try RadrootsAppleFileRoots( + appIdentifier: "org.radroots.media-picker-test", + dataRoot: root.appendingPathComponent("data", isDirectory: true), + cacheRoot: root.appendingPathComponent("cache", isDirectory: true), + temporaryRoot: root.appendingPathComponent("temporary", isDirectory: true) + ) + return RadrootsAppleFileAccess(roots: roots) +}