diff --git a/Flipcash/Core/Screens/Conversation/ChatCameraSheet.swift b/Flipcash/Core/Screens/Conversation/ChatCameraSheet.swift index 617515449..d13c994b7 100644 --- a/Flipcash/Core/Screens/Conversation/ChatCameraSheet.swift +++ b/Flipcash/Core/Screens/Conversation/ChatCameraSheet.swift @@ -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 { @@ -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() } } } diff --git a/Flipcash/Core/Screens/Conversation/ChatPhotoCamera.swift b/Flipcash/Core/Screens/Conversation/ChatPhotoCamera.swift index 2b48a3ce6..999cccb85 100644 --- a/Flipcash/Core/Screens/Conversation/ChatPhotoCamera.swift +++ b/Flipcash/Core/Screens/Conversation/ChatPhotoCamera.swift @@ -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") @@ -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 {} @@ -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 diff --git a/FlipcashTests/Regressions/Regression_6ac9b0b2991833fd09be1dd8.swift b/FlipcashTests/Regressions/Regression_6ac9b0b2991833fd09be1dd8.swift new file mode 100644 index 000000000..2fa28a607 --- /dev/null +++ b/FlipcashTests/Regressions/Regression_6ac9b0b2991833fd09be1dd8.swift @@ -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) + } +}