Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 34 additions & 14 deletions Flipcash/Core/Screens/Conversation/ChatCameraSheet.swift
Original file line number Diff line number Diff line change
Expand Up @@ -102,7 +102,7 @@ struct ChatCameraSheet: View {
}

private var viewfinder: some View {
ChatCameraPreview(session: camera.session)
ChatCameraPreview(camera: camera)
// Already running when the panel warmed it; this only catches a camera granted just now.
// The warm-up stops it once the card is gone.
.task {
Expand Down Expand Up @@ -258,37 +258,57 @@ private struct ChatCameraControlLabel: View {

// MARK: - ChatCameraPreview -

/// The live feed of a capture session, filling its frame.
/// The live feed of the camera's session, filling its frame.
///
/// Its own layer rather than FlipcashUI's `CameraViewport`: that view hands out one shared
/// `_CameraPreviewView`, and mounting it here would pull the scanner's preview out of its tab.
private struct ChatCameraPreview: UIViewRepresentable {
/// Hosts the camera's one preview layer rather than building its own, because attaching a new layer
/// to a running session reconfigures it on the main thread.
struct ChatCameraPreview: UIViewRepresentable {

let session: AVCaptureSession
let camera: ChatPhotoCamera

func makeUIView(context: Context) -> PreviewView {
let view = PreviewView()
view.backgroundColor = UIColor(Color.backgroundMain)
view.previewLayer.videoGravity = .resizeAspectFill
view.previewLayer.session = session
view.host(camera.previewLayer)
return view
}

func updateUIView(_ uiView: PreviewView, context: Context) {
if uiView.previewLayer.session !== session {
uiView.previewLayer.session = session
}
uiView.host(camera.previewLayer)
}

static func dismantleUIView(_ uiView: PreviewView, coordinator: ()) {
uiView.release()
}

final class PreviewView: UIView {

override class var layerClass: AnyClass {
AVCaptureVideoPreviewLayer.self
private weak var previewLayer: AVCaptureVideoPreviewLayer?

/// Takes `layer` as this view's sublayer, from whichever view showed it last.
func host(_ layer: AVCaptureVideoPreviewLayer) {
previewLayer = layer
guard layer.superlayer !== self.layer else { return }
layer.videoGravity = .resizeAspectFill
self.layer.addSublayer(layer)
setNeedsLayout()
}

/// Gives the layer up, unless a newer mount already took it.
func release() {
guard let previewLayer, previewLayer.superlayer === layer else { return }
previewLayer.removeFromSuperlayer()
}

var previewLayer: AVCaptureVideoPreviewLayer {
// `layerClass` guarantees the type.
layer as! AVCaptureVideoPreviewLayer
override func layoutSubviews() {
super.layoutSubviews()
guard let previewLayer, previewLayer.superlayer === layer else { return }
CATransaction.begin()
CATransaction.setDisableActions(true)
previewLayer.frame = bounds
CATransaction.commit()
}
}
}
56 changes: 42 additions & 14 deletions Flipcash/Core/Screens/Conversation/ChatPhotoCamera.swift
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,11 @@ final class ChatPhotoCamera {
/// Whether the session is delivering frames: from its start until ``stop()``.
private(set) var isRunning = false

@ObservationIgnored let session = AVCaptureSession()
@ObservationIgnored let session: AVCaptureSession

/// The session's one preview layer, attached while the session is still empty so no viewfinder
/// mount has to reconfigure it on the main thread.
@ObservationIgnored let previewLayer: AVCaptureVideoPreviewLayer

@ObservationIgnored private let output = AVCapturePhotoOutput()
@ObservationIgnored private let sessionQueue = DispatchQueue(label: "com.flipcash.chat-camera.session")
Expand All @@ -57,6 +61,14 @@ final class ChatPhotoCamera {
/// Whether the session should be running. A ``stop()`` while ``prepare()`` is still configuring
/// keeps the session from starting once it finishes.
@ObservationIgnored private var wantsRunning = false
/// Whether a lens swap is still committing on the session queue.
@ObservationIgnored private var isFlipping = false

init() {
let session = AVCaptureSession()
self.session = session
previewLayer = AVCaptureVideoPreviewLayer(session: session)
}

// Same Swift 6.3 EarlyPerfInliner workaround `CameraSession` carries for @MainActor deinits.
nonisolated deinit {}
Expand Down Expand Up @@ -207,32 +219,48 @@ final class ChatPhotoCamera {
}
}

/// Swaps to the other lens, keeping the current one if the other is missing.
/// Swaps to the other lens off the main thread, keeping the current one if the other is missing.
/// A flip still in flight ignores another.
func flip() {
guard input != nil else { return }
guard let previous = input, !isFlipping else { return }
isFlipping = true
let target = Self.flipped(position)
let session = session
let output = output
sessionQueue.async { [weak self] in
let configured = Self.swap(previous, for: target, in: session, output: output)
Task { @MainActor [weak self] in
guard let self else { return }
self.isFlipping = false
if let configured {
self.adopt(configured, at: target)
}
}
}
}

/// Replaces `previous` with the `position` lens, restoring `previous` and returning `nil` if
/// that lens can't attach. Blocking; runs on the session queue.
private nonisolated static func swap(
_ previous: AVCaptureDeviceInput,
for position: AVCaptureDevice.Position,
in session: AVCaptureSession,
output: AVCapturePhotoOutput
) -> ConfiguredInput? {
session.beginConfiguration()
defer { session.commitConfiguration() }

let previous = input
if let previous {
session.removeInput(previous)
}
session.removeInput(previous)
do {
try attachInput(for: target)
return try attach(position, to: session, output: output)
} catch {
if let previous, session.canAddInput(previous) {
if session.canAddInput(previous) {
session.addInput(previous)
input = previous
}
return nil
}
}

private func attachInput(for position: AVCaptureDevice.Position) throws {
adopt(try Self.attach(position, to: session, output: output), at: position)
}

// MARK: - Capture -

/// Returns the next photo from the running session, or `nil` if the session isn't running or
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
//
// Regression_6ac9b0b2991833fd09be1dd8.swift
// FlipcashTests
//
// Copyright © 2026 Code Inc. All rights reserved.
//

import Testing
import UIKit
import SwiftUI
import AVFoundation
@testable import Flipcash

/// The attach panel hung on main in `AVCaptureVideoPreviewLayer.setSession:`, because every mount
/// of the camera card built a new preview layer and attached it to the warm session.
@MainActor
@Suite("Regression: 6ac9b0b – camera preview reattached its session on every mount", .bug("6ac9b0b2991833fd09be1dd8"))
struct Regression_6ac9b0b2991833fd09be1dd8 {

/// Mounts the viewfinder in a window and returns the preview layer it shows.
private func mountedPreviewLayer(for camera: ChatPhotoCamera) throws -> AVCaptureVideoPreviewLayer {
let host = UIHostingController(rootView: ChatCameraPreview(camera: camera).frame(width: 200, height: 300))
let scene = UIApplication.shared.connectedScenes.compactMap { $0 as? UIWindowScene }.first
let window = scene.map(UIWindow.init(windowScene:)) ?? UIWindow()
window.frame = CGRect(x: 0, y: 0, width: 200, height: 300)
window.rootViewController = host
window.makeKeyAndVisible()
defer { window.isHidden = true }
RunLoop.main.run(until: Date().addingTimeInterval(0.2))
host.view.layoutIfNeeded()
return try #require(Self.previewLayer(in: host.view.layer))
}

private static func previewLayer(in layer: CALayer) -> AVCaptureVideoPreviewLayer? {
if let preview = layer as? AVCaptureVideoPreviewLayer { return preview }
return layer.sublayers?.lazy.compactMap(previewLayer(in:)).first
}

@Test("Every mount shows the camera's own preview layer instead of building a new one")
func reusesCameraLayer() throws {
let camera = ChatPhotoCamera()
let first = try mountedPreviewLayer(for: camera)
let second = try mountedPreviewLayer(for: camera)
#expect(first === camera.previewLayer)
#expect(second === camera.previewLayer)
#expect(camera.previewLayer.session === camera.session)
}
}
Loading