tauri-plugin-barcode-scanner-continuous 0.1.0

Fork of tauri-plugin-barcode-scanner with on-device fixes enabling a stable continuous scan loop on iOS and Android.
// Copyright 2019-2023 Tauri Programme within The Commons Conservancy
// SPDX-License-Identifier: Apache-2.0
// SPDX-License-Identifier: MIT

import AVFoundation
import Tauri
import UIKit
import WebKit

struct ScanOptions: Decodable {
  var formats: [SupportedFormat]?
  var windowed: Bool?
  var cameraDirection: String?
  /**
   * Optional bottom inset in points for non-windowed mode.
   * Lets apps reserve lower UI (e.g. cart) while camera stays above webview.
   */
  var overlayInsetBottom: Double?
}

enum SupportedFormat: String, CaseIterable, Decodable {
  // UPC_A not supported
  case UPC_E
  case EAN_8
  case EAN_13
  case CODE_39
  case CODE_93
  case CODE_128
  // CODABAR not supported
  case ITF
  case AZTEC
  case DATA_MATRIX
  case PDF_417
  case QR_CODE
  case GS1_DATA_BAR
  case GS1_DATA_BAR_LIMITED
  case GS1_DATA_BAR_EXPANDED

  var value: AVMetadataObject.ObjectType? {
    switch self {
    case .UPC_E: return AVMetadataObject.ObjectType.upce
    case .EAN_8: return AVMetadataObject.ObjectType.ean8
    case .EAN_13: return AVMetadataObject.ObjectType.ean13
    case .CODE_39: return AVMetadataObject.ObjectType.code39
    case .CODE_93: return AVMetadataObject.ObjectType.code93
    case .CODE_128: return AVMetadataObject.ObjectType.code128
    case .ITF: return AVMetadataObject.ObjectType.interleaved2of5
    case .AZTEC: return AVMetadataObject.ObjectType.aztec
    case .DATA_MATRIX: return AVMetadataObject.ObjectType.dataMatrix
    case .PDF_417: return AVMetadataObject.ObjectType.pdf417
    case .QR_CODE: return AVMetadataObject.ObjectType.qr
    case .GS1_DATA_BAR:
      if #available(iOS 15.4, *) {
        return AVMetadataObject.ObjectType.gs1DataBar
      } else {
        return nil
      }
    case .GS1_DATA_BAR_LIMITED:
      if #available(iOS 15.4, *) {
        return AVMetadataObject.ObjectType.gs1DataBarLimited
      } else {
        return nil
      }
    case .GS1_DATA_BAR_EXPANDED:
      if #available(iOS 15.4, *) {
        return AVMetadataObject.ObjectType.gs1DataBarExpanded
      } else {
        return nil
      }
    }
  }
}

enum CaptureError: Error {
  case backCameraUnavailable
  case frontCameraUnavailable
  case couldNotCaptureInput(error: NSError)
}

class BarcodeScannerPlugin: Plugin, AVCaptureMetadataOutputObjectsDelegate {
  var webView: WKWebView!
  var cameraView: CameraView!
  var captureSession: AVCaptureSession?
  var captureVideoPreviewLayer: AVCaptureVideoPreviewLayer?
  var metaOutput: AVCaptureMetadataOutput?

  var currentCamera = 0
  var frontCamera: AVCaptureDevice?
  var backCamera: AVCaptureDevice?

  var isScanning = false

  var windowed = false
  var previousBackgroundColor: UIColor? = UIColor.white

  var invoke: Invoke? = nil

  var scanFormats = [AVMetadataObject.ObjectType]()

  public override func load(webview: WKWebView) {
    self.webView = webview
    loadCamera()
  }

  private func loadCamera(overlayInsetBottom: CGFloat = 0) {
    let clampedInset = max(0, overlayInsetBottom)
    let fullBounds = UIScreen.main.bounds
    let cameraHeight = max(0, fullBounds.height - clampedInset)
    cameraView = CameraView(
      frame: CGRect(
        x: 0,
        y: 0,
        width: fullBounds.width,
        height: cameraHeight
      )
    )
    cameraView.autoresizingMask = [.flexibleWidth, .flexibleHeight]
  }

  public func metadataOutput(
    _ captureOutput: AVCaptureMetadataOutput, didOutput metadataObjects: [AVMetadataObject],
    from connection: AVCaptureConnection
  ) {
    if metadataObjects.count == 0 || !self.isScanning {
      // while nothing is detected, or if scanning is false, do nothing.
      return
    }

    let found = metadataObjects[0] as! AVMetadataMachineReadableCodeObject
    if scanFormats.contains(found.type) {
      var jsObject: JsonObject = [:]

      jsObject["format"] = formatStringFromMetadata(found.type)
      if found.stringValue != nil {
        jsObject["content"] = found.stringValue
      }

      invoke?.resolve(jsObject)
      destroy()

    }
  }

  private func setupCamera(direction: String, windowed: Bool) {
    do {
      var cameraDirection = direction
      cameraView.backgroundColor = UIColor.clear
      if windowed {
        webView.superview?.insertSubview(cameraView, belowSubview: webView)
      } else {
        webView.superview?.insertSubview(cameraView, aboveSubview: webView)
      }

      let availableVideoDevices = discoverCaptureDevices()
      for device in availableVideoDevices {
        if device.position == AVCaptureDevice.Position.back {
          backCamera = device
        } else if device.position == AVCaptureDevice.Position.front {
          frontCamera = device
        }
      }

      // older iPods have no back camera
      if cameraDirection == "back" {
        if backCamera == nil {
          cameraDirection = "front"
        }
      } else {
        if frontCamera == nil {
          cameraDirection = "back"
        }
      }

      let input: AVCaptureDeviceInput
      input = try createCaptureDeviceInput(
        cameraDirection: cameraDirection, backCamera: backCamera, frontCamera: frontCamera)
      captureSession = AVCaptureSession()
      captureSession!.addInput(input)
      metaOutput = AVCaptureMetadataOutput()
      captureSession!.addOutput(metaOutput!)
      metaOutput!.setMetadataObjectsDelegate(self, queue: DispatchQueue.main)
      captureVideoPreviewLayer = AVCaptureVideoPreviewLayer(session: captureSession!)
      cameraView.addPreviewLayer(captureVideoPreviewLayer)

      self.windowed = windowed
      if windowed {
        self.previousBackgroundColor = self.webView.backgroundColor
        self.webView.isOpaque = false
        self.webView.backgroundColor = UIColor.clear
        self.webView.scrollView.backgroundColor = UIColor.clear
      }
    } catch CaptureError.backCameraUnavailable {
      //
    } catch CaptureError.frontCameraUnavailable {
      //
    } catch CaptureError.couldNotCaptureInput {
      //
    } catch {
      //
    }
  }

  private func dismantleCamera() {
    if self.captureSession != nil {
      self.captureSession!.stopRunning()
      self.cameraView.removePreviewLayer()
      self.cameraView.removeFromSuperview()
      self.captureVideoPreviewLayer = nil
      self.metaOutput = nil
      self.captureSession = nil
      self.frontCamera = nil
      self.backCamera = nil
    }

    self.isScanning = false
  }

  private func destroy() {
    dismantleCamera()
    invoke = nil
    if windowed {
      let backgroundColor = previousBackgroundColor ?? UIColor.white
      webView.isOpaque = true
      webView.backgroundColor = backgroundColor
      webView.scrollView.backgroundColor = backgroundColor
    }
  }

  private func getPermissionState() -> String {
    var permissionState: String

    switch AVCaptureDevice.authorizationStatus(for: .video) {
    case .authorized:
      permissionState = "granted"
    case .denied:
      permissionState = "denied"
    default:
      permissionState = "prompt"
    }

    return permissionState
  }

  @objc override func checkPermissions(_ invoke: Invoke) {
    let permissionState = getPermissionState()
    invoke.resolve(["camera": permissionState])
  }

  @objc override func requestPermissions(_ invoke: Invoke) {
    let state = getPermissionState()
    if state == "prompt" {
      AVCaptureDevice.requestAccess(for: .video) { (authorized) in
        invoke.resolve(["camera": authorized ? "granted" : "denied"])
      }
    } else {
      invoke.resolve(["camera": state])
    }
  }

  @objc func openAppSettings(_ invoke: Invoke) {
    guard let settingsUrl = URL(string: UIApplication.openSettingsURLString) else {
      return
    }

    DispatchQueue.main.async {
      if UIApplication.shared.canOpenURL(settingsUrl) {
        UIApplication.shared.open(
          settingsUrl,
          completionHandler: { (success) in
            invoke.resolve()
          })
      }
    }
  }

  private func runScanner(_ invoke: Invoke, args: ScanOptions) {
    if getPermissionState() != "granted" {
      invoke.reject("Camera permission denied or not yet requested")
      return
    }

    scanFormats = [AVMetadataObject.ObjectType]()

    (args.formats ?? []).forEach { format in
      if let formatValue = format.value {
        scanFormats.append(formatValue)
      } else {
        invoke.reject("Unsupported barcode format on this iOS version: \(format)")
        return
      }
    }

    if scanFormats.isEmpty {
      for supportedFormat in SupportedFormat.allCases {
        if let formatValue = supportedFormat.value {
          scanFormats.append(formatValue)
        }
      }
    }

    self.metaOutput!.metadataObjectTypes = self.scanFormats
    DispatchQueue.main.async {
      self.captureSession!.startRunning()
    }

    self.isScanning = true
  }

  @objc private func scan(_ invoke: Invoke) throws {
    let args = try invoke.parseArgs(ScanOptions.self)

    self.invoke = invoke

    let entry = Bundle.main.infoDictionary?["NSCameraUsageDescription"] as? String

    if entry == nil || entry?.count == 0 {
      invoke.reject("NSCameraUsageDescription is not in the app Info.plist")
      return
    }

    // Check if camera is available on this platform (iOS simulator doesn't have cameras)
    let availableVideoDevices = discoverCaptureDevices()
    if availableVideoDevices.isEmpty {
      invoke.reject("No camera available on this device (e.g., iOS Simulator)")
      return
    }

    var iOS14min: Bool = false
    if #available(iOS 14.0, *) { iOS14min = true }
    if !iOS14min && self.getPermissionState() != "granted" {
      var authorized = false
      AVCaptureDevice.requestAccess(for: .video) { (isAuthorized) in
        authorized = isAuthorized
      }
      if !authorized {
        invoke.reject("denied by the user")
        return
      }
    }

    let overlayInsetBottom = CGFloat(max(0, args.overlayInsetBottom ?? 0))

    DispatchQueue.main.async { [self] in
      self.loadCamera(overlayInsetBottom: overlayInsetBottom)
      self.dismantleCamera()
      self.setupCamera(
        direction: args.cameraDirection ?? "back",
        windowed: args.windowed ?? false
      )
      self.runScanner(invoke, args: args)
    }
  }

  @objc private func cancel(_ invoke: Invoke) {
    DispatchQueue.main.async { [self] in
      self.invoke?.reject("cancelled")
      destroy()
      invoke.resolve()
    }
  }
}

@_cdecl("init_plugin_barcode_scanner_continuous")
func initPlugin() -> Plugin {
  return BarcodeScannerPlugin()
}