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.
173 lines
5.4 KiB
Swift
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
|
|
}
|
|
}
|
|
}
|