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:
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) {
|
||||||
|
|||||||
Reference in New Issue
Block a user