diff --git a/NucleicRemote/NucleicRemote/Views/PairingScannerView.swift b/NucleicRemote/NucleicRemote/Views/PairingScannerView.swift index 55f6183..3a0cf81 100644 --- a/NucleicRemote/NucleicRemote/Views/PairingScannerView.swift +++ b/NucleicRemote/NucleicRemote/Views/PairingScannerView.swift @@ -121,6 +121,7 @@ final class QRScannerController: UIViewController, AVCaptureMetadataOutputObject /// preview layer and delegate callbacks touch the main thread. private let sessionQueue = DispatchQueue(label: "nucleic.qrscanner.session") private var configured = false + private var startAttempts = 0 private var didFind = false private var previewView: CameraPreviewView { view as! CameraPreviewView } @@ -209,17 +210,24 @@ final class QRScannerController: UIViewController, AVCaptureMetadataOutputObject override func viewDidAppear(_ animated: Bool) { super.viewDidAppear(animated) 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 /// 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. + /// Every start trigger (config finished, view appeared, delayed retry, interruption ended, + /// app foregrounded) funnels here; the session-queue hop no-ops if already running. @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 } + guard let self, self.isViewLoaded 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 { guard self.configured, !self.session.isRunning else { return } self.session.startRunning() @@ -231,6 +239,7 @@ final class QRScannerController: UIViewController, AVCaptureMetadataOutputObject @objc private func sessionDidStart() { DispatchQueue.main.async { [weak self] in guard let self else { return } + self.startAttempts = 0 self.report(.scanning) 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)") @@ -246,6 +255,15 @@ final class QRScannerController: UIViewController, AVCaptureMetadataOutputObject @objc private func sessionInterrupted(_ note: Notification) { let reason = (note.userInfo?[AVCaptureSessionInterruptionReasonKey] as? Int) ?? -1 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) {