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:
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
|
/// AVCaptureSession isn't thread-safe — all configuration + start/stop happen here; only the
|
||||||
/// preview layer and delegate callbacks touch the main thread.
|
/// preview layer and delegate callbacks touch the main thread.
|
||||||
private let sessionQueue = DispatchQueue(label: "nucleic.qrscanner.session")
|
private let sessionQueue = DispatchQueue(label: "nucleic.qrscanner.session")
|
||||||
|
private var configured = false
|
||||||
private var didFind = false
|
private var didFind = false
|
||||||
|
|
||||||
private var previewView: CameraPreviewView { view as! CameraPreviewView }
|
private var previewView: CameraPreviewView { view as! CameraPreviewView }
|
||||||
@@ -139,6 +140,13 @@ final class QRScannerController: UIViewController, AVCaptureMetadataOutputObject
|
|||||||
name: .AVCaptureSessionRuntimeError, object: session)
|
name: .AVCaptureSessionRuntimeError, object: session)
|
||||||
nc.addObserver(self, selector: #selector(sessionInterrupted(_:)),
|
nc.addObserver(self, selector: #selector(sessionInterrupted(_:)),
|
||||||
name: .AVCaptureSessionWasInterrupted, object: session)
|
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
|
// 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).
|
// 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)
|
output.metadataObjectTypes = output.availableMetadataObjectTypes.contains(.qr)
|
||||||
? [.qr] : output.availableMetadataObjectTypes
|
? [.qr] : output.availableMetadataObjectTypes
|
||||||
|
|
||||||
self.session.startRunning()
|
self.configured = true
|
||||||
scannerLog.notice("📷 startRunning -> isRunning=\(self.session.isRunning, privacy: .public) inputs=\(self.session.inputs.count, privacy: .public) outputs=\(self.session.outputs.count, privacy: .public)")
|
scannerLog.notice("📷 configured inputs=\(self.session.inputs.count, privacy: .public) outputs=\(self.session.outputs.count, privacy: .public)")
|
||||||
self.report(.scanning)
|
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() {
|
@objc private func sessionDidStart() {
|
||||||
DispatchQueue.main.async { [weak self] in
|
DispatchQueue.main.async { [weak self] in
|
||||||
guard let self else { return }
|
guard let self else { return }
|
||||||
|
self.report(.scanning)
|
||||||
let connection = self.previewView.previewLayer.connection
|
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) {
|
@objc private func sessionRuntimeError(_ note: Notification) {
|
||||||
let error = note.userInfo?[AVCaptureSessionErrorKey] as? NSError
|
let error = note.userInfo?[AVCaptureSessionErrorKey] as? NSError
|
||||||
scannerLog.error("📷 runtimeError=\(error?.description ?? "nil", privacy: .public)")
|
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) {
|
@objc private func sessionInterrupted(_ note: Notification) {
|
||||||
|
|||||||
Reference in New Issue
Block a user