iOS pairing scanner: start camera only when foreground (fix black preview)

Root cause (from on-device logs): the session was started from
viewDidLoad while the pairing sheet was still presenting, so iOS
interrupted it with reason 1 (videoDeviceNotAvailableInBackground) and it
never ran (isRunning=false) — a black preview. The CMVideoFormatDescription
-12710 errors were unrelated noise.

Defer startRunning until the camera can actually run: configure the graph
up front, then start only once the view is on screen and the app is
foreground-active (viewDidAppear + a guarded startSessionIfReady). Recover
on AVCaptureSessionInterruptionEnded / didBecomeActive / runtimeError. All
start triggers funnel through the session queue and no-op if already
running, so it's idempotent.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
This commit is contained in:
Nucleic
2026-06-21 17:00:42 -07:00
co-authored by Claude Opus 4.8
parent 783aac7683
commit 07b6769410
@@ -120,6 +120,7 @@ final class QRScannerController: UIViewController, AVCaptureMetadataOutputObject
/// AVCaptureSession isn't thread-safe — all configuration + start/stop happen here; only the
/// preview layer and delegate callbacks touch the main thread.
private let sessionQueue = DispatchQueue(label: "nucleic.qrscanner.session")
private var configured = false
private var didFind = false
private var previewView: CameraPreviewView { view as! CameraPreviewView }
@@ -139,6 +140,13 @@ final class QRScannerController: UIViewController, AVCaptureMetadataOutputObject
name: .AVCaptureSessionRuntimeError, object: session)
nc.addObserver(self, selector: #selector(sessionInterrupted(_:)),
name: .AVCaptureSessionWasInterrupted, object: session)
// Recover the session once it's allowed to run again (interruption cleared, or the app
// returned to the foreground) — the reason-1 interruption at presentation time left it
// stopped with no retry.
nc.addObserver(self, selector: #selector(startSessionIfReady),
name: .AVCaptureSessionInterruptionEnded, object: session)
nc.addObserver(self, selector: #selector(startSessionIfReady),
name: UIApplication.didBecomeActiveNotification, object: nil)
// Gate on camera authorization first — without this the prompt may never appear and the
// feed silently fails (and on a denied device it would just stay black).
@@ -192,23 +200,47 @@ final class QRScannerController: UIViewController, AVCaptureMetadataOutputObject
output.metadataObjectTypes = output.availableMetadataObjectTypes.contains(.qr)
? [.qr] : output.availableMetadataObjectTypes
self.session.startRunning()
scannerLog.notice("📷 startRunning -> isRunning=\(self.session.isRunning, privacy: .public) inputs=\(self.session.inputs.count, privacy: .public) outputs=\(self.session.outputs.count, privacy: .public)")
self.report(.scanning)
self.configured = true
scannerLog.notice("📷 configured inputs=\(self.session.inputs.count, privacy: .public) outputs=\(self.session.outputs.count, privacy: .public)")
self.startSessionIfReady()
}
}
override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
startSessionIfReady()
}
/// Start the camera only once it can actually run — configured, on screen, and
/// foreground-active. Starting during the sheet's presentation made iOS interrupt the
/// session (`videoDeviceNotAvailableInBackground`, reason 1) and leave it stopped → black.
/// Every start trigger (config finished, view appeared, interruption ended, app foregrounded)
/// funnels here; the session-queue hop no-ops if already running, so it's idempotent.
@objc private func startSessionIfReady() {
DispatchQueue.main.async { [weak self] in
guard let self, self.isViewLoaded, self.view.window != nil,
UIApplication.shared.applicationState == .active else { return }
self.sessionQueue.async {
guard self.configured, !self.session.isRunning else { return }
self.session.startRunning()
scannerLog.notice("📷 startRunning -> isRunning=\(self.session.isRunning, privacy: .public)")
}
}
}
@objc private func sessionDidStart() {
DispatchQueue.main.async { [weak self] in
guard let self else { return }
self.report(.scanning)
let connection = self.previewView.previewLayer.connection
scannerLog.notice("📷 didStartRunning: previewConnection=\(connection != nil, privacy: .public) active=\(connection?.isActive ?? false, privacy: .public) enabled=\(connection?.isEnabled ?? false, privacy: .public) viewBounds=\(self.view.bounds.debugDescription, privacy: .public) layerBounds=\(self.previewView.previewLayer.bounds.debugDescription, privacy: .public)")
scannerLog.notice("📷 didStartRunning: previewConnection=\(connection != nil, privacy: .public) active=\(connection?.isActive ?? false, privacy: .public) viewBounds=\(self.view.bounds.debugDescription, privacy: .public)")
}
}
@objc private func sessionRuntimeError(_ note: Notification) {
let error = note.userInfo?[AVCaptureSessionErrorKey] as? NSError
scannerLog.error("📷 runtimeError=\(error?.description ?? "nil", privacy: .public)")
startSessionIfReady() // e.g. media-services reset — try to bring it back
}
@objc private func sessionInterrupted(_ note: Notification) {