iOS pairing scanner: delayed bounded retry for reason-1 interruption

On device the camera still reported reason 1 (videoDeviceNotAvailable-
InBackground) even when started from viewDidAppear with the app
foreground-active: the camera assertion isn't granted until the sheet's
presentation transition fully settles (a few hundred ms), and the
back-to-back retries all fired inside that unsettled window.

Add a short delayed retry (0.5s, bounded to 12 attempts) driven by the
interruption notification, plus a proactive +0.6s retry after the view
appears, resetting the counter once the session actually starts. Also log
the window/app/scene activation state to confirm the foreground signals.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
This commit is contained in:
Nucleic
2026-06-21 17:09:14 -07:00
co-authored by Claude Opus 4.8
parent 07b6769410
commit 765183d25b
@@ -121,6 +121,7 @@ final class QRScannerController: UIViewController, AVCaptureMetadataOutputObject
/// 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 configured = false
private var startAttempts = 0
private var didFind = false private var didFind = false
private var previewView: CameraPreviewView { view as! CameraPreviewView } private var previewView: CameraPreviewView { view as! CameraPreviewView }
@@ -209,17 +210,24 @@ final class QRScannerController: UIViewController, AVCaptureMetadataOutputObject
override func viewDidAppear(_ animated: Bool) { override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated) super.viewDidAppear(animated)
startSessionIfReady() startSessionIfReady()
// The presentation transition can leave the session transiently "backgrounded" even
// though the app is foreground; a short delayed retry catches the settled state if the
// immediate start was interrupted.
DispatchQueue.main.asyncAfter(deadline: .now() + 0.6) { [weak self] in self?.startSessionIfReady() }
} }
/// Start the camera only once it can actually run — configured, on screen, and /// 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 /// foreground-active. Starting during the sheet's presentation made iOS interrupt the
/// session (`videoDeviceNotAvailableInBackground`, reason 1) and leave it stopped → black. /// session (`videoDeviceNotAvailableInBackground`, reason 1) and leave it stopped → black.
/// Every start trigger (config finished, view appeared, interruption ended, app foregrounded) /// Every start trigger (config finished, view appeared, delayed retry, interruption ended,
/// funnels here; the session-queue hop no-ops if already running, so it's idempotent. /// app foregrounded) funnels here; the session-queue hop no-ops if already running.
@objc private func startSessionIfReady() { @objc private func startSessionIfReady() {
DispatchQueue.main.async { [weak self] in DispatchQueue.main.async { [weak self] in
guard let self, self.isViewLoaded, self.view.window != nil, guard let self, self.isViewLoaded else { return }
UIApplication.shared.applicationState == .active else { return } let appState = UIApplication.shared.applicationState
let sceneState = self.view.window?.windowScene?.activationState
scannerLog.notice("📷 tryStart window=\(self.view.window != nil, privacy: .public) app=\(appState.rawValue, privacy: .public) scene=\(sceneState?.rawValue ?? -1, privacy: .public)")
guard self.view.window != nil, appState == .active else { return }
self.sessionQueue.async { self.sessionQueue.async {
guard self.configured, !self.session.isRunning else { return } guard self.configured, !self.session.isRunning else { return }
self.session.startRunning() self.session.startRunning()
@@ -231,6 +239,7 @@ final class QRScannerController: UIViewController, AVCaptureMetadataOutputObject
@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.startAttempts = 0
self.report(.scanning) 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) viewBounds=\(self.view.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)")
@@ -246,6 +255,15 @@ final class QRScannerController: UIViewController, AVCaptureMetadataOutputObject
@objc private func sessionInterrupted(_ note: Notification) { @objc private func sessionInterrupted(_ note: Notification) {
let reason = (note.userInfo?[AVCaptureSessionInterruptionReasonKey] as? Int) ?? -1 let reason = (note.userInfo?[AVCaptureSessionInterruptionReasonKey] as? Int) ?? -1
scannerLog.error("📷 sessionInterrupted reason=\(reason, privacy: .public)") scannerLog.error("📷 sessionInterrupted reason=\(reason, privacy: .public)")
// Reason 1 fires transiently while the sheet's presentation settles even though the app
// is foreground; back off briefly and retry until it sticks (bounded).
DispatchQueue.main.async { [weak self] in
guard let self, self.startAttempts < 12 else {
scannerLog.error("📷 gave up starting after retries"); return
}
self.startAttempts += 1
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { [weak self] in self?.startSessionIfReady() }
}
} }
override func viewDidDisappear(_ animated: Bool) { override func viewDidDisappear(_ animated: Bool) {