diff --git a/src/apps/mobile/ios/OpenBitFun/Features/Pairing/PairingSheet.swift b/src/apps/mobile/ios/OpenBitFun/Features/Pairing/PairingSheet.swift index e8599390eb..ada87b61ee 100644 --- a/src/apps/mobile/ios/OpenBitFun/Features/Pairing/PairingSheet.swift +++ b/src/apps/mobile/ios/OpenBitFun/Features/Pairing/PairingSheet.swift @@ -37,16 +37,19 @@ struct PairingSheet: View { } } .fullScreenCover(isPresented: $scannerOpen) { - QRCodeScannerView { code in - pairingURL = code - scannerOpen = false - if PairingLinkHintsKt.inspectPairingLink(url: code).requiresAccount { - manualOpen = true - focused = true - } else { - model.submitPairing(url: code) - } - } + QRCodeScannerView( + onCode: { code in + pairingURL = code + scannerOpen = false + if PairingLinkHintsKt.inspectPairingLink(url: code).requiresAccount { + manualOpen = true + focused = true + } else { + model.submitPairing(url: code) + } + }, + onCancel: { scannerOpen = false } + ) .ignoresSafeArea() } } diff --git a/src/apps/mobile/ios/OpenBitFun/Infrastructure/Platform/QRCodeScannerView.swift b/src/apps/mobile/ios/OpenBitFun/Infrastructure/Platform/QRCodeScannerView.swift index 42d7c7f7a0..73fa92d0c8 100644 --- a/src/apps/mobile/ios/OpenBitFun/Infrastructure/Platform/QRCodeScannerView.swift +++ b/src/apps/mobile/ios/OpenBitFun/Infrastructure/Platform/QRCodeScannerView.swift @@ -4,10 +4,12 @@ import UIKit struct QRCodeScannerView: UIViewControllerRepresentable { let onCode: (String) -> Void + let onCancel: () -> Void func makeUIViewController(context: Context) -> QRScannerController { let controller = QRScannerController() controller.onCode = onCode + controller.onCancel = onCancel return controller } @@ -15,9 +17,13 @@ struct QRCodeScannerView: UIViewControllerRepresentable { } final class QRScannerController: UIViewController, AVCaptureMetadataOutputObjectsDelegate { - private let session = AVCaptureSession() + private let sessionQueue = DispatchQueue(label: "com.openbitfun.mobile.ios.qr-session") + private lazy var session = AVCaptureSession() private var previewLayer: AVCaptureVideoPreviewLayer? + private var captureConfigured = false + private var emittedCode = false var onCode: ((String) -> Void)? + var onCancel: (() -> Void)? override func viewDidLoad() { super.viewDidLoad() @@ -27,7 +33,10 @@ final class QRScannerController: UIViewController, AVCaptureMetadataOutputObject close.tintColor = UIColor(OpenBitFunTheme.contentOnAction) close.backgroundColor = UIColor(OpenBitFunTheme.mediaControlBackground) close.layer.cornerRadius = 22 - close.addAction(UIAction { [weak self] _ in self?.dismiss(animated: true) }, for: .touchUpInside) + close.addAction(UIAction { [weak self] _ in + self?.stopCapture() + self?.onCancel?() + }, for: .touchUpInside) close.translatesAutoresizingMaskIntoConstraints = false view.addSubview(close) NSLayoutConstraint.activate([ @@ -37,44 +46,81 @@ final class QRScannerController: UIViewController, AVCaptureMetadataOutputObject close.heightAnchor.constraint(equalToConstant: 44), ]) - guard AVCaptureDevice.authorizationStatus(for: .video) != .denied else { return } - AVCaptureDevice.requestAccess(for: .video) { [weak self] granted in - guard granted else { return } - DispatchQueue.main.async { self?.configureCapture() } + switch AVCaptureDevice.authorizationStatus(for: .video) { + case .authorized: + configureCaptureAsync() + case .notDetermined: + AVCaptureDevice.requestAccess(for: .video) { [weak self] granted in + guard granted else { return } + self?.configureCaptureAsync() + } + default: + break } } + override func viewDidDisappear(_ animated: Bool) { + super.viewDidDisappear(animated) + stopCapture() + } + override func viewDidLayoutSubviews() { super.viewDidLayoutSubviews() previewLayer?.frame = view.bounds } + private func configureCaptureAsync() { + sessionQueue.async { [weak self] in + self?.configureCapture() + } + } + private func configureCapture() { + guard !captureConfigured else { return } guard let device = AVCaptureDevice.default(for: .video), let input = try? AVCaptureDeviceInput(device: device), session.canAddInput(input) else { return } let output = AVCaptureMetadataOutput() guard session.canAddOutput(output) else { return } + session.beginConfiguration() session.addInput(input) session.addOutput(output) - output.setMetadataObjectsDelegate(self, queue: .main) + output.setMetadataObjectsDelegate(self, queue: sessionQueue) output.metadataObjectTypes = [.qr] - let layer = AVCaptureVideoPreviewLayer(session: session) - layer.videoGravity = .resizeAspectFill - view.layer.insertSublayer(layer, at: 0) - previewLayer = layer + session.commitConfiguration() + captureConfigured = true + DispatchQueue.main.async { [weak self] in + guard let self, self.previewLayer == nil else { return } + let layer = AVCaptureVideoPreviewLayer(session: self.session) + layer.videoGravity = .resizeAspectFill + layer.frame = self.view.bounds + self.view.layer.insertSublayer(layer, at: 0) + self.previewLayer = layer + } session.startRunning() } + private func stopCapture() { + sessionQueue.async { [weak self] in + guard let self, self.session.isRunning else { return } + self.session.stopRunning() + } + } + func metadataOutput( _ output: AVCaptureMetadataOutput, didOutput metadataObjects: [AVMetadataObject], from connection: AVCaptureConnection, ) { guard let value = (metadataObjects.first as? AVMetadataMachineReadableCodeObject)?.stringValue, - !value.isEmpty else { return } - session.stopRunning() - onCode?(value) - dismiss(animated: true) + !value.isEmpty, + !emittedCode else { return } + emittedCode = true + if session.isRunning { + session.stopRunning() + } + DispatchQueue.main.async { [weak self] in + self?.onCode?(value) + } } }