import AVFoundation import Combine import OSLog import UIKit struct CameraSessionState { private(set) var configurationDepth = 0 var canStartSession: Bool { configurationDepth == 0 } mutating func beginConfiguration() { configurationDepth += 1 } mutating func commitConfiguration() { configurationDepth = max(0, configurationDepth - 1) } } enum CameraServiceError: LocalizedError { case unauthorized case unavailable case captureFailed var errorDescription: String? { switch self { case .unauthorized: return "Camera access is required to capture a card photo." case .unavailable: return "The device camera is unavailable." case .captureFailed: return "The photo could not be captured." } } } @MainActor final class CameraService: NSObject, ObservableObject { private static let logger = Logger(subsystem: "dev.matthiasmeister.StackDex", category: "CameraService") @Published private(set) var authorizationStatus: AVAuthorizationStatus = AVCaptureDevice.authorizationStatus(for: .video) @Published private(set) var isSessionRunning = false @Published private(set) var isConfigured = false let session = AVCaptureSession() private let photoOutput = AVCapturePhotoOutput() private var captureContinuation: CheckedContinuation? private var sessionState = CameraSessionState() func prepareIfAuthorized() async { authorizationStatus = AVCaptureDevice.authorizationStatus(for: .video) guard authorizationStatus == .authorized else { return } await configureAndStartSessionIfNeeded() } func requestAccessAndStart() async { authorizationStatus = AVCaptureDevice.authorizationStatus(for: .video) if authorizationStatus == .notDetermined { authorizationStatus = await AVCaptureDevice.requestAccess(for: .video) ? .authorized : .denied } guard authorizationStatus == .authorized else { return } await configureAndStartSessionIfNeeded() } func stopSession() { if session.isRunning { session.stopRunning() isSessionRunning = false } } func capturePhoto() async throws -> UIImage { guard authorizationStatus == .authorized else { throw CameraServiceError.unauthorized } if !isSessionRunning { await configureAndStartSessionIfNeeded() } return try await withCheckedThrowingContinuation { continuation in captureContinuation = continuation photoOutput.capturePhoto(with: AVCapturePhotoSettings(), delegate: self) } } private func configureAndStartSessionIfNeeded() async { if isConfigured { startSessionIfNeeded() return } var shouldStartSession = false sessionState.beginConfiguration() Self.logger.debug("Camera session beginConfiguration depth=\(self.sessionState.configurationDepth)") session.beginConfiguration() do { session.sessionPreset = .photo guard let device = AVCaptureDevice.default(.builtInWideAngleCamera, for: .video, position: .back) else { throw CameraServiceError.unavailable } let input = try AVCaptureDeviceInput(device: device) if !session.inputs.contains(where: { ($0 as? AVCaptureDeviceInput)?.device == device }) && session.canAddInput(input) { session.addInput(input) } if !session.outputs.contains(photoOutput) && session.canAddOutput(photoOutput) { session.addOutput(photoOutput) } isConfigured = true shouldStartSession = true } catch { isConfigured = false Self.logger.error("Camera session configuration failed: \(String(describing: error), privacy: .public)") } session.commitConfiguration() sessionState.commitConfiguration() Self.logger.debug("Camera session commitConfiguration depth=\(self.sessionState.configurationDepth)") if shouldStartSession { startSessionIfNeeded() } } private func startSessionIfNeeded() { guard sessionState.canStartSession else { let message = "Attempted to start AVCaptureSession before commitConfiguration completed." Self.logger.fault("\(message, privacy: .public)") assertionFailure(message) return } guard !session.isRunning else { return } Self.logger.debug("Starting camera session") session.startRunning() isSessionRunning = true } } extension CameraService: AVCapturePhotoCaptureDelegate { nonisolated func photoOutput(_ output: AVCapturePhotoOutput, didFinishProcessingPhoto photo: AVCapturePhoto, error: Error?) { Task { @MainActor in if let error { captureContinuation?.resume(throwing: error) captureContinuation = nil return } guard let data = photo.fileDataRepresentation(), let image = UIImage(data: data) else { captureContinuation?.resume(throwing: CameraServiceError.captureFailed) captureContinuation = nil return } captureContinuation?.resume(returning: image) captureContinuation = nil } } }