Making dependency composable AVFoundation

Hi! Currently trying to incorporate an interaction with the camera in my TCA app using AVFoundation. So I've wrapped parts of it in a client that exposes some effects. I'm noticing some unreliable behaviour however. I notice that sometimes the AVCapturePhotoCaptureDelegate doesn't seem to get called and therefore the app freezing. I'm guessing it's due to the way I've implemented it using TCA anyone with experience in AVFoundation and TCA who can spot my error maybe? Really hoping to find some pointers since I'm a bit stuck. I've based my implementation on what I found here Custom Camera and then tried to translate that to something TCA compatible :).
Short recap: camera output delegate method doesn't always get fired, not sure why, and this behaviour currently freezes my application.

Code:

I've wrapped all the AVFoundation stuff in a dependency called CameraClient

public struct CameraClient {
    public var onAppear: Effect<DelegateEvent, Never>
    public var onDisappear: Effect<Never, Never>
    public var takePicture: Effect<DelegateEvent, Never>
    public var startSession: Effect<Never, Never>
    public var stopSession: Effect<Never, Never>
    
    public enum DelegateEvent: Equatable {
        case pictureTaken(Data)
        case providePreviewLayer(CALayer)
    }
}

which has the following live implementation:

extension CameraClient {
    private static var session: AVCaptureSession?
    private static var output: AVCapturePhotoOutput?
    private static var delegate: Delegate?
    
    private static func setUp() -> CALayer {
        do {
            let session = AVCaptureSession()
            Self.session = session
            session.beginConfiguration()

            let device = AVCaptureDevice.default(.builtInDualCamera, for: .video, position: .back)
            
            let input = try AVCaptureDeviceInput(device: device!)
            
            if session.canAddInput(input) {
                session.addInput(input)
            }
            
            let output = AVCapturePhotoOutput()
            Self.output = output
            
            if session.canAddOutput(output) {
                session.addOutput(output)
            }
            
            session.commitConfiguration()
            
            let previewLayer = AVCaptureVideoPreviewLayer(session: session)
            previewLayer.videoGravity = .resizeAspectFill
            
            return previewLayer
        } catch {
            #warning("Better error handling")
            fatalError(error.localizedDescription)
        }
    }
    
    public static let live = Self(
        onAppear: .run { subscriber in
            switch AVCaptureDevice.authorizationStatus(for: .video) {
            case .authorized:
                let previewLayer = setUp()
                subscriber.send(.providePreviewLayer(previewLayer))
            case .notDetermined:
                AVCaptureDevice.requestAccess(for: .video) { status in
                    if status {
                        let previewLayer = setUp()
                        subscriber.send(.providePreviewLayer(previewLayer))
                    } else {
                        //alert?
                    }
                }
            case .restricted:
                // alert?
                print("restricted access")
            case .denied:
                // alert
                print("access denied")
            @unknown default:
                // ?
                print("unknown value")
            }
            
            subscriber.send(completion: .finished)
            return AnyCancellable { }
        },
        onDisappear: .run { subscriber in
            Self.delegate = nil
            Self.output = nil
            Self.session = nil
            subscriber.send(completion: .finished)
            return AnyCancellable { }
        },
        takePicture: .run { subscriber in
            let delegate = Delegate(subscriber: subscriber)
            Self.delegate = delegate
            Self.output?.capturePhoto(
                with: AVCapturePhotoSettings(),
                delegate: delegate
            )
            return AnyCancellable { }
        },
        startSession: .run { subscriber in
            session?.startRunning()
            subscriber.send(completion: .finished)
            return AnyCancellable { }
        },
        stopSession: .run { subscriber in
            session?.stopRunning()
            subscriber.send(completion: .finished)
            return AnyCancellable { }
        }
    )
    
    private class Delegate: NSObject, AVCapturePhotoCaptureDelegate {
        let subscriber: Effect<DelegateEvent, Never>.Subscriber

        init(subscriber: Effect<DelegateEvent, Never>.Subscriber) {
            self.subscriber = subscriber
        }
        
        func photoOutput(
            _ output: AVCapturePhotoOutput,
            didFinishProcessingPhoto photo: AVCapturePhoto,
            error: Error?
        ) {
            if let error = error {
                print(error)
                return
            }
            print("Pic taken")
            
            guard let imageData = photo.fileDataRepresentation() else {
                return
            }
            
            subscriber.send(.pictureTaken(imageData))
            subscriber.send(completion: .finished)
        }
    }
}

And this gets implemented in a camera view which contains the following state, actions, environment and reducer:

public struct CameraState: Equatable {
    public var isTaken = false
    public var picData = Data(count: 0)
    public var image: UIImage?
    public var previewLayer: CALayer?
    
    public init() { }
}

public enum CameraAction: Equatable {
    case onAppear
    case onDisappear
    case cameraClient(CameraClient.DelegateEvent)
    case takePictureButtonTapped
    case retakePictureButtonTapped
    case saveButtonTapped
}

public struct CameraEnvironment {
    public var mainQueue: AnySchedulerOf<DispatchQueue>
    public var backgroundQueue: AnySchedulerOf<DispatchQueue>
    public var cameraClient: CameraClient
    
    public init(
        mainQueue: AnySchedulerOf<DispatchQueue>,
        backgroundQueue: AnySchedulerOf<DispatchQueue>,
        cameraClient: CameraClient
    ) {
        self.mainQueue = mainQueue
        self.backgroundQueue = backgroundQueue
        self.cameraClient = cameraClient
    }
}

public let cameraReducer = Reducer<CameraState, CameraAction, CameraEnvironment> { state, action, environment in
    switch action {
    case .onAppear:
        return Effect.concatenate(
            environment.cameraClient.onAppear,
            environment.cameraClient.startSession
                .subscribe(on: environment.backgroundQueue)
                .receive(on: environment.mainQueue)
                .fireAndForget()
        ).map { .cameraClient($0) }
        
    case .onDisappear:
        return environment.cameraClient.onDisappear
            .fireAndForget()
    
    case let .cameraClient(.providePreviewLayer(layer)):
        state.previewLayer = layer
        return .none
    
    case let .cameraClient(.pictureTaken(data)):
        state.picData = data
        state.isTaken.toggle()
        return .none
        
    case .takePictureButtonTapped:
        return Effect.concatenate(
            environment.cameraClient.takePicture,
            environment.cameraClient.stopSession
                .subscribe(on: environment.backgroundQueue)
                .receive(on: environment.mainQueue)
                .fireAndForget()
        )
        .map { .cameraClient($0) }
        
    case .retakePictureButtonTapped:
        state.isTaken.toggle()
        return environment.cameraClient.startSession
            .subscribe(on: environment.backgroundQueue)
            .receive(on: environment.mainQueue)
            .fireAndForget()
        
    case .saveButtonTapped:
        print("moving to next thing!")
        return .none
    }
}.debug()

And the CameraView itself:

public struct CameraView: View {
    public let store: Store<CameraState, CameraAction>
    
    public init(store: Store<CameraState, CameraAction>) {
        self.store = store
    }
    
    public var body: some View {
        WithViewStore(self.store) { viewStore in
            ZStack {
                IfLetStore(
                    self.store.scope(
                        state: { $0.previewLayer }
                    ),
                    then: { store in
                        CameraPreview(store: store)
                            .ignoresSafeArea(.all, edges: .all)
                    },
                    else: Color.red
                        .ignoresSafeArea(.all, edges: .all)
                )
                
                VStack {
                    Spacer()
                    
                    HStack {
                        if viewStore.isTaken {
                            Button(action: { viewStore.send(.saveButtonTapped) }) {
                                Text("Save")
                                    .foregroundColor(.black)
                                    .fontWeight(.semibold)
                                    .padding(.vertical, 10)
                                    .padding(.horizontal, 20)
                                    .background(Color.white)
                                    .clipShape(Capsule())
                            }
                            .padding(.leading)
                            
                            Spacer()
                            
                            Button(action: { viewStore.send(.retakePictureButtonTapped) }) {
                                Image(systemName: "arrow.triangle.2.circlepath.camera")
                                    .foregroundColor(.black)
                                    .padding()
                                    .background(Color.white)
                                    .clipShape(Circle())
                            }
                            .padding(.trailing, 10)
                        } else {
                            Button(action: { viewStore.send(.takePictureButtonTapped) }) {
                                ShutterButton()
                            }
                        }
                    }.frame(height: 75)
                }
            
            }
            .onAppear { viewStore.send(.onAppear) }
            .onDisappear { viewStore.send(.onDisappear) }
        }
    }
}

struct CameraPreview: UIViewRepresentable {
    private let store: Store<CALayer, CameraAction>
    private let viewStore: ViewStore<CALayer, CameraAction>
    
    public init(store: Store<CALayer, CameraAction>) {
        self.store = store
        self.viewStore = ViewStore(self.store)
    }
    
    public func makeUIView(context: Context) -> UIView {
        let view = UIView(frame: UIScreen.main.bounds)
        let previewLayer = viewStore.state
        previewLayer.frame = view.frame
        view.layer.addSublayer(previewLayer)
        return view
    }
    
    public func updateUIView(_ uiView: UIView, context: Context) {
        
    }
}

struct ShutterButton: View {
    var body: some View {
        ZStack {
            Circle()
                .fill(Color.white)
                .frame(width: 60, height: 60)
        
            Circle()
                .stroke(Color.white, lineWidth: 5)
                .frame(width: 75, height: 75)
        }
    }
}

Edit:
I've also made the following test to test some of my reducer logic.

func testTakingPicture() throws {
    let caLayer = CALayer()
    let mainQueue = DispatchQueue.testScheduler
    let backgroundQueue = DispatchQueue.testScheduler
    
    let store = TestStore(
        initialState: CameraState(),
        reducer: cameraReducer,
        environment: CameraEnvironment(
            mainQueue: mainQueue.eraseToAnyScheduler(),
            backgroundQueue: backgroundQueue.eraseToAnyScheduler(),
            cameraClient: CameraClient(
                onAppear: .run { subscriber in
                    subscriber.send(.providePreviewLayer(caLayer))
                    subscriber.send(completion: .finished)
                    return AnyCancellable { }
                },
                onDisappear: .run { subscriber in
                    subscriber.send(completion: .finished)
                    return AnyCancellable { }
                },
                takePicture: .run { subscriber in
                    subscriber.send(.pictureTaken(Data()))
                    subscriber.send(completion: .finished)
                    return AnyCancellable { }
                },
                startSession: .run { subscriber in
                    subscriber.send(completion: .finished)
                    return AnyCancellable { }
                },
                stopSession: .run { subscriber in
                    subscriber.send(completion: .finished)
                    return AnyCancellable { }
                }
            )
        )
    )
    
    store.assert(
        .send(.onAppear),
        .receive(
            .cameraClient(.providePreviewLayer(caLayer)),
            { $0.previewLayer = caLayer }
        ),            
        .send(.takePictureButtonTapped),
        .receive(
            .cameraClient(.pictureTaken(Data())),
            {
                $0.picData = Data()
                $0.isTaken = true
            }
        ),
        .send(
            .retakePictureButtonTapped,
            { $0.isTaken = false }
        ),
        .do { backgroundQueue.advance() },
        .do { mainQueue.advance() }
    )
}

Edit2: Updated code with what seems to be a solution! :)

1 Like

Seems I've found the issue! I was running capturePhoto on a background thread, while only session.stopRunning() should be run in the background. I've updated the code above so others might use it :). I've also restructured it a bit. Not entirely happy with the separation between the cameraClient and the view since a user would still need some knowledge about the cameraclient's inner workings to use it. Especially in the reducer at the .takePictureButtonTapped and .onAppear action, with the concatenation of effects and some effects needing the .subscribe(on: environment.backgroundQueue).receive(on: environment.mainQueue) and others don't.
So any tips for abstracting that api would be very welcome!

1 Like
Terms of Service

Privacy Policy

Cookie Policy