Files
stackdex_neu/StackDex/Services/CameraService.swift
Matthias a60a76b797 Add scan flow MVP and local Axiom skill workspace
This snapshot establishes the camera-to-result recognition flow and related tests while checking in the project skill/docs assets required for the configured local tooling.
2026-04-19 21:11:32 +02:00

173 lines
5.4 KiB
Swift

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<UIImage, Error>?
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
}
}
}