diff --git a/NucleicRemote/NucleicRemote/Views/PairingScannerView.swift b/NucleicRemote/NucleicRemote/Views/PairingScannerView.swift index 2d68a78..55f6183 100644 --- a/NucleicRemote/NucleicRemote/Views/PairingScannerView.swift +++ b/NucleicRemote/NucleicRemote/Views/PairingScannerView.swift @@ -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) {