diff --git a/apps/ios/Sources/Onboarding/QRScannerView.swift b/apps/ios/Sources/Onboarding/QRScannerView.swift index 024532908a9..2528397e14e 100644 --- a/apps/ios/Sources/Onboarding/QRScannerView.swift +++ b/apps/ios/Sources/Onboarding/QRScannerView.swift @@ -120,23 +120,14 @@ struct QRScannerView: UIViewControllerRepresentable { context.coordinator.reportError("Camera scanning is currently unavailable.") return UIViewController() } - let scanner = DataScannerViewController( - recognizedDataTypes: [.barcode(symbologies: [.qr])], - isHighlightingEnabled: true) - scanner.delegate = context.coordinator - do { - try scanner.startScanning() - } catch { - context.coordinator.reportError("Could not start QR scanner.") - } - return scanner + return QRScannerContainerViewController(coordinator: context.coordinator) } func updateUIViewController(_: UIViewController, context _: Context) {} static func dismantleUIViewController(_ uiViewController: UIViewController, coordinator: Coordinator) { - if let scanner = uiViewController as? DataScannerViewController { - scanner.stopScanning() + if let scanner = uiViewController as? QRScannerContainerViewController { + scanner.stopScannerCapture() } coordinator.parent.onDismiss() } @@ -145,6 +136,68 @@ struct QRScannerView: UIViewControllerRepresentable { Coordinator(parent: self) } + final class QRScannerContainerViewController: UIViewController { + private let coordinator: Coordinator + private let scanner: DataScannerViewController + private var didStartScanning = false + + init(coordinator: Coordinator) { + self.coordinator = coordinator + self.scanner = DataScannerViewController( + recognizedDataTypes: [.barcode(symbologies: [.qr])], + isHighlightingEnabled: true) + super.init(nibName: nil, bundle: nil) + self.scanner.delegate = coordinator + } + + @available(*, unavailable) + required init?(coder _: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func viewDidLoad() { + super.viewDidLoad() + self.view.backgroundColor = .systemBackground + self.addChild(self.scanner) + self.scanner.view.translatesAutoresizingMaskIntoConstraints = false + self.view.addSubview(self.scanner.view) + NSLayoutConstraint.activate([ + self.scanner.view.leadingAnchor.constraint(equalTo: self.view.leadingAnchor), + self.scanner.view.trailingAnchor.constraint(equalTo: self.view.trailingAnchor), + self.scanner.view.topAnchor.constraint(equalTo: self.view.topAnchor), + self.scanner.view.bottomAnchor.constraint(equalTo: self.view.bottomAnchor), + ]) + self.scanner.didMove(toParent: self) + } + + override func viewDidAppear(_ animated: Bool) { + super.viewDidAppear(animated) + // VisionKit owns the camera session; start it only after UIKit has + // presented the scanner so sheet teardown cannot race construction. + self.startScanningIfNeeded() + } + + override func viewWillDisappear(_ animated: Bool) { + self.stopScannerCapture() + super.viewWillDisappear(animated) + } + + func stopScannerCapture() { + self.scanner.stopScanning() + self.didStartScanning = false + } + + private func startScanningIfNeeded() { + guard !self.didStartScanning else { return } + do { + try self.scanner.startScanning() + self.didStartScanning = true + } catch { + self.coordinator.reportError("Could not start QR scanner.") + } + } + } + final class Coordinator: NSObject, DataScannerViewControllerDelegate { let parent: QRScannerView private var handled = false diff --git a/apps/ios/Tests/RootTabsSourceGuardTests.swift b/apps/ios/Tests/RootTabsSourceGuardTests.swift index 9319dfb0dd0..bec57c203f2 100644 --- a/apps/ios/Tests/RootTabsSourceGuardTests.swift +++ b/apps/ios/Tests/RootTabsSourceGuardTests.swift @@ -792,6 +792,14 @@ struct RootTabsSourceGuardTests { settingsPendingSetupHandler.range(of: "self.scannerResultHandoff.cancel()")) let settingsSetupStaging = try #require( settingsPendingSetupHandler.range(of: "self.stagedGatewaySetupLink = link")) + let scannerMake = try Self.extract( + scannerSource, + from: "func makeUIViewController", + to: "func updateUIViewController") + let scannerLifecycle = try Self.extract( + scannerSource, + from: "final class QRScannerContainerViewController", + to: "final class Coordinator") let scannerDelivery = try Self.extract( scannerSource, from: "private func deliver(_ result: QRScannerResult", @@ -799,6 +807,12 @@ struct RootTabsSourceGuardTests { let stopScanning = try #require(scannerDelivery.range(of: "scanner.stopScanning()")) let deliverResult = try #require(scannerDelivery.range(of: "self.parent.onResult(result)")) #expect(scannerSource.contains("static let defaultSettlingNanoseconds: UInt64 = 1_200_000_000")) + #expect(scannerSource.contains("QRScannerContainerViewController(coordinator: context.coordinator)")) + #expect(!scannerMake.contains("startScanning()")) + #expect(scannerLifecycle.contains("override func viewDidAppear")) + #expect(scannerLifecycle.contains("try self.scanner.startScanning()")) + #expect(scannerLifecycle.contains("override func viewWillDisappear")) + #expect(scannerLifecycle.contains("self.stopScannerCapture()")) let activeProblemToast = try Self.extract( rootSource, from: "private var activeGatewayProblemToast: GatewayConnectionProblem?",