taler-ios

iOS apps for GNU Taler (wallet)
Log | Files | Refs | README | LICENSE

QRScannerView.swift (5473B)


      1 /*
      2  * This file is part of GNU Taler
      3  * (C) 2026 Bohdan, Volodymyr Potuzhnyi
      4  *
      5  * GNU Taler is free software; you can redistribute it and/or modify it under the
      6  * terms of the GNU General Public License as published by the Free Software
      7  * Foundation; either version 3, or (at your option) any later version.
      8  *
      9  * GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
     10  * WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
     11  * A PARTICULAR PURPOSE. See the GNU General Public License for more details.
     12  *
     13  * You should have received a copy of the GNU General Public License along with
     14  * GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
     15  */
     16 
     17 import SwiftUI
     18 import AVFoundation
     19 
     20 struct QRScannerView: UIViewControllerRepresentable {
     21     let onResult: (Result<String, Error>) -> Void
     22 
     23     func makeUIViewController(context: Context) -> QRScannerViewController {
     24         let vc = QRScannerViewController()
     25         vc.onResult = onResult
     26         return vc
     27     }
     28 
     29     func updateUIViewController(_ uiViewController: QRScannerViewController, context: Context) {}
     30 }
     31 
     32 final class QRScannerViewController: UIViewController, AVCaptureMetadataOutputObjectsDelegate {
     33     var onResult: ((Result<String, Error>) -> Void)?
     34     private let captureSession = AVCaptureSession()
     35     private var previewLayer: AVCaptureVideoPreviewLayer?
     36     private var hasReported = false
     37 
     38     override func viewDidLoad() {
     39         super.viewDidLoad()
     40         view.backgroundColor = .black
     41 
     42         switch AVCaptureDevice.authorizationStatus(for: .video) {
     43         case .authorized:
     44             setupCamera()
     45         case .notDetermined:
     46             AVCaptureDevice.requestAccess(for: .video) { [weak self] granted in
     47                 DispatchQueue.main.async {
     48                     if granted {
     49                         self?.setupCamera()
     50                     } else {
     51                         self?.reportError()
     52                     }
     53                 }
     54             }
     55         default:
     56             reportError()
     57         }
     58     }
     59 
     60     override func viewDidLayoutSubviews() {
     61         super.viewDidLayoutSubviews()
     62         previewLayer?.frame = view.bounds
     63     }
     64 
     65     override func viewWillAppear(_ animated: Bool) {
     66         super.viewWillAppear(animated)
     67         updatePreviewRotation()
     68         NotificationCenter.default.addObserver(
     69             self,
     70             selector: #selector(orientationDidChange),
     71             name: UIDevice.orientationDidChangeNotification,
     72             object: nil
     73         )
     74         if !captureSession.isRunning {
     75             DispatchQueue.global(qos: .userInitiated).async { [weak self] in
     76                 self?.captureSession.startRunning()
     77             }
     78         }
     79     }
     80 
     81     override func viewWillDisappear(_ animated: Bool) {
     82         super.viewWillDisappear(animated)
     83         NotificationCenter.default.removeObserver(self, name: UIDevice.orientationDidChangeNotification, object: nil)
     84         if captureSession.isRunning {
     85             captureSession.stopRunning()
     86         }
     87     }
     88 
     89     @objc private func orientationDidChange() {
     90         updatePreviewRotation()
     91     }
     92 
     93     private func setupCamera() {
     94         guard let device = AVCaptureDevice.default(for: .video),
     95               let input = try? AVCaptureDeviceInput(device: device) else {
     96             reportError()
     97             return
     98         }
     99 
    100         if captureSession.canAddInput(input) {
    101             captureSession.addInput(input)
    102         }
    103 
    104         let output = AVCaptureMetadataOutput()
    105         if captureSession.canAddOutput(output) {
    106             captureSession.addOutput(output)
    107             output.setMetadataObjectsDelegate(self, queue: .main)
    108             output.metadataObjectTypes = [.qr]
    109         }
    110 
    111         let layer = AVCaptureVideoPreviewLayer(session: captureSession)
    112         layer.videoGravity = .resizeAspectFill
    113         layer.frame = view.bounds
    114         view.layer.addSublayer(layer)
    115         previewLayer = layer
    116 
    117         DispatchQueue.global(qos: .userInitiated).async { [weak self] in
    118             self?.captureSession.startRunning()
    119             DispatchQueue.main.async {
    120                 self?.updatePreviewRotation()
    121             }
    122         }
    123     }
    124 
    125     private func updatePreviewRotation() {
    126         guard let connection = previewLayer?.connection,
    127               connection.isVideoOrientationSupported else { return }
    128         let orientation: AVCaptureVideoOrientation
    129         switch view.window?.windowScene?.interfaceOrientation {
    130         case .portrait:            orientation = .portrait
    131         case .landscapeLeft:       orientation = .landscapeLeft
    132         case .landscapeRight:      orientation = .landscapeRight
    133         case .portraitUpsideDown:  orientation = .portraitUpsideDown
    134         default:                   orientation = .landscapeRight
    135         }
    136         connection.videoOrientation = orientation
    137     }
    138 
    139     private func reportError() {
    140         guard !hasReported else { return }
    141         hasReported = true
    142         onResult?(.failure(NSError(domain: "QRScanner", code: 1)))
    143     }
    144 
    145     func metadataOutput(_ output: AVCaptureMetadataOutput, didOutput metadataObjects: [AVMetadataObject], from connection: AVCaptureConnection) {
    146         guard !hasReported,
    147               let object = metadataObjects.first as? AVMetadataMachineReadableCodeObject,
    148               let value = object.stringValue else { return }
    149         hasReported = true
    150         captureSession.stopRunning()
    151         onResult?(.success(value))
    152     }
    153 }