From b4635e82b89ad9fc407f190c7efcb6a7e81284e0 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Sat, 10 Oct 2026 22:35:27 -0400 Subject: [PATCH 1/2] fix(chat): stop the attach camera preview from reconfiguring on main Bugsnag 6ac9b0b2991833fd09be1dd8 is an App Hang in ChatCameraPreview.makeUIView. Setting a new preview layer's session on the main thread makes AVCaptureSession commit a configuration, and the attach surface mounts the hidden camera card each time it appears, so toggling the panel repeated that rebuild on main. ChatPhotoCamera now creates one AVCaptureVideoPreviewLayer in init, while its session is still empty. The input added later on the session queue connects to it there. ChatCameraPreview hosts that layer instead of making its own, sets its frame in layoutSubviews, and removes it on dismantle only if it is still the view's sublayer, so a remount that already moved it keeps it. The regression test mounts the preview twice and expects the camera's layer both times. --- .../Conversation/ChatCameraSheet.swift | 48 +++++++++++++------ .../Conversation/ChatPhotoCamera.swift | 12 ++++- .../Regression_6ac9b0b2991833fd09be1dd8.swift | 48 +++++++++++++++++++ 3 files changed, 93 insertions(+), 15 deletions(-) create mode 100644 FlipcashTests/Regressions/Regression_6ac9b0b2991833fd09be1dd8.swift 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..e02cfd585 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") @@ -58,6 +62,12 @@ final class ChatPhotoCamera { /// keeps the session from starting once it finishes. @ObservationIgnored private var wantsRunning = 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 {} 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) + } +} From a60d14feed4f8c62d6f37637a182a00a4edced0f Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Sat, 10 Oct 2026 22:35:28 -0400 Subject: [PATCH 2/2] fix(chat): swap camera lenses off the main thread flip() ran its beginConfiguration/commitConfiguration on main, the same kind of session rebuild behind the attach panel hang. The swap now runs on the session queue and hands the new input back to main. Taps while a swap is in flight are ignored, and a lens that fails to attach restores the previous input. --- .../Conversation/ChatPhotoCamera.swift | 44 +++++++++++++------ 1 file changed, 31 insertions(+), 13 deletions(-) diff --git a/Flipcash/Core/Screens/Conversation/ChatPhotoCamera.swift b/Flipcash/Core/Screens/Conversation/ChatPhotoCamera.swift index e02cfd585..999cccb85 100644 --- a/Flipcash/Core/Screens/Conversation/ChatPhotoCamera.swift +++ b/Flipcash/Core/Screens/Conversation/ChatPhotoCamera.swift @@ -61,6 +61,8 @@ 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() @@ -217,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