package app.tauri.barcodescanner import android.Manifest import android.annotation.SuppressLint import android.app.Activity import android.content.Context import android.content.Context.MODE_PRIVATE import android.content.Intent import android.content.SharedPreferences import android.content.pm.PackageManager import android.net.Uri import android.os.Build import android.os.VibrationEffect import android.os.Vibrator import android.provider.Settings import android.util.Size import android.view.ViewGroup import android.webkit.WebView import android.widget.FrameLayout import androidx.activity.result.ActivityResult import androidx.camera.core.Camera import androidx.camera.core.CameraSelector import androidx.camera.core.ImageAnalysis import androidx.camera.core.ImageProxy import androidx.camera.core.Preview import androidx.camera.lifecycle.ProcessCameraProvider import androidx.camera.view.PreviewView import androidx.core.content.ContextCompat import androidx.lifecycle.LifecycleOwner import app.tauri.Logger import app.tauri.PermissionState import app.tauri.annotation.ActivityCallback import app.tauri.annotation.Command import app.tauri.annotation.Permission import app.tauri.annotation.PermissionCallback import app.tauri.annotation.TauriPlugin import app.tauri.plugin.Invoke import app.tauri.plugin.JSArray import app.tauri.plugin.JSObject import app.tauri.plugin.Plugin import com.google.common.util.concurrent.ListenableFuture import com.google.mlkit.vision.barcode.BarcodeScannerOptions import com.google.mlkit.vision.barcode.BarcodeScanning import com.google.mlkit.vision.barcode.common.Barcode import com.google.mlkit.vision.common.InputImage import org.json.JSONException import java.util.Collections import java.util.concurrent.ExecutionException private const val PERMISSION_ALIAS_CAMERA = "camera" private const val PERMISSION_NAME = Manifest.permission.CAMERA private const val PREFS_PERMISSION_FIRST_TIME_ASKING = "PREFS_PERMISSION_FIRST_TIME_ASKING" @TauriPlugin( permissions = [ Permission(strings = [Manifest.permission.CAMERA], alias = "camera") ] ) class BarcodeScannerPlugin(private val activity: Activity) : Plugin(activity), ImageAnalysis.Analyzer { private lateinit var webView: WebView private var previewView: PreviewView? = null private var cameraProviderFuture: ListenableFuture? = null private var cameraProvider: ProcessCameraProvider? = null private var graphicOverlay: GraphicOverlay? = null private var camera: Camera? = null private var vibrator: Vibrator? = null private var scannerOptions: BarcodeScannerOptions? = null private var scanner: com.google.mlkit.vision.barcode.BarcodeScanner? = null private var requestPermissionResponse: JSObject? = null private var cameraReady = false // declare a map constant for allowed barcode formats private val supportedFormats = supportedFormats() private var savedInvoke: Invoke? = null override fun load(webView: WebView) { super.load(webView) this.webView = webView } private fun supportedFormats(): Map { val map: MutableMap = HashMap() map["UPC_A"] = Barcode.FORMAT_UPC_A map["UPC_E"] = Barcode.FORMAT_UPC_E map["EAN_8"] = Barcode.FORMAT_EAN_8 map["EAN_13"] = Barcode.FORMAT_EAN_13 map["CODE_39"] = Barcode.FORMAT_CODE_39 map["CODE_93"] = Barcode.FORMAT_CODE_93 map["CODE_128"] = Barcode.FORMAT_CODE_128 map["CODABAR"] = Barcode.FORMAT_CODABAR map["ITF"] = Barcode.FORMAT_ITF map["AZTEC"] = Barcode.FORMAT_AZTEC map["DATA_MATRIX"] = Barcode.FORMAT_DATA_MATRIX map["PDF_417"] = Barcode.FORMAT_PDF417 map["QR_CODE"] = Barcode.FORMAT_QR_CODE return Collections.unmodifiableMap(map) } private fun hasCamera(): Boolean { return activity.packageManager .hasSystemFeature(PackageManager.FEATURE_CAMERA_ANY) } private fun setupCamera(cameraDirection: String) { activity .runOnUiThread { val previewView = PreviewView(activity) previewView.layoutParams = FrameLayout.LayoutParams( ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT ) this.previewView = previewView val graphicOverlay = GraphicOverlay(activity) graphicOverlay.layoutParams = FrameLayout.LayoutParams( ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT ) this.graphicOverlay = graphicOverlay val parent = webView.parent as ViewGroup parent.addView(previewView) parent.addView(graphicOverlay) val cameraProviderFuture = ProcessCameraProvider.getInstance(activity) cameraProviderFuture.addListener( { try { val cameraProvider = cameraProviderFuture.get() bindPreview( cameraProvider, if (cameraDirection == "front") CameraSelector.LENS_FACING_FRONT else CameraSelector.LENS_FACING_BACK ) this.cameraProvider = cameraProvider } catch (e: InterruptedException) { // ignored } catch (_: ExecutionException) { // ignored } }, ContextCompat.getMainExecutor(activity) ) this.cameraProviderFuture = cameraProviderFuture } } private fun bindPreview(cameraProvider: ProcessCameraProvider, cameraDirection: Int) { activity .runOnUiThread { val preview = Preview.Builder().build() val cameraSelector = CameraSelector.Builder().requireLensFacing(cameraDirection).build() preview.setSurfaceProvider(previewView?.surfaceProvider) val imageAnalysis = ImageAnalysis.Builder() .setBackpressureStrategy(ImageAnalysis.STRATEGY_KEEP_ONLY_LATEST) .setTargetResolution(Size(1280, 720)) .build() imageAnalysis.setAnalyzer( ContextCompat.getMainExecutor(activity), this ) camera = cameraProvider.bindToLifecycle( activity as LifecycleOwner, cameraSelector, preview, imageAnalysis ) } } private fun dismantleCamera() { // opposite of setupCamera activity .runOnUiThread { if (cameraProvider != null) { cameraProvider?.unbindAll() val parent = webView.parent as ViewGroup parent.removeView(previewView) parent.removeView(graphicOverlay) camera = null previewView = null graphicOverlay = null } } } private fun getFormats(invoke: Invoke): List { val jsFormats = invoke.getArray("formats", JSArray()) val formats = ArrayList() for (i in 0 until jsFormats.length()) { try { val targetedFormat: String = jsFormats.getString(i) val targetedBarcodeFormat = supportedFormats[targetedFormat] if (targetedBarcodeFormat != null) { formats.add(targetedBarcodeFormat) } } catch (e: JSONException) { e.printStackTrace() } } return formats } private fun prepareInternal(direction: String) { dismantleCamera() setupCamera(direction) } private fun destroy() { dismantleCamera() savedInvoke = null } @Suppress("DEPRECATION") private fun configureCamera(formats: List) { activity .runOnUiThread { val vibrator = activity.getSystemService(Context.VIBRATOR_SERVICE) as Vibrator this.vibrator = vibrator if (previewView == null) { throw Exception("Something went wrong configuring the BarcodeScanner") } if (formats.isNotEmpty()) { val mappedFormats = mapFormats(formats) val options = BarcodeScannerOptions.Builder() .setBarcodeFormats(Barcode.FORMAT_QR_CODE, *mappedFormats).build() scannerOptions = options scanner = BarcodeScanning.getClient(options) } else { val options = BarcodeScannerOptions.Builder() .setBarcodeFormats(Barcode.FORMAT_ALL_FORMATS).build() scannerOptions = options scanner = BarcodeScanning.getClient(options) } } } private fun mapFormats(integers: List): IntArray { val ret = IntArray(integers.size) for (i in ret.indices) { if (integers[i] != Barcode.FORMAT_QR_CODE) ret[i] = integers[i] } return ret } override fun analyze(image: ImageProxy) { @SuppressLint("UnsafeOptInUsageError") val mediaImage = image.image if (mediaImage != null) { val inputImage = InputImage.fromMediaImage(mediaImage, image.imageInfo.rotationDegrees) scanner ?.process(inputImage) ?.addOnSuccessListener { barcodes -> for (barcode in barcodes) { val bounds = barcode.boundingBox val rawValue = barcode.rawValue ?: "" // add vibration logic here val s = bounds?.flattenToString() val jsObject = JSObject() jsObject.put("content", rawValue) jsObject.put("bounds", s) savedInvoke?.resolve(jsObject) destroy() } } ?.addOnFailureListener { e -> Logger.error(e.message ?: e.toString()) } ?.addOnCompleteListener { image.close() mediaImage.close() } } } @Command fun vibrate(invoke: Invoke) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { vibrator!!.vibrate( VibrationEffect.createOneShot( 50, VibrationEffect.DEFAULT_AMPLITUDE ) ) } invoke.resolve() } @Command fun prepare(invoke: Invoke) { prepareInternal(invoke.getString("cameraDirection", "back")) cameraReady = true invoke.resolve() } @Command fun cancel(invoke: Invoke) { savedInvoke?.reject("cancelled") destroy() invoke.resolve() } @Command fun scan(invoke: Invoke) { savedInvoke = invoke if (hasCamera()) { if (getPermissionState("camera") != PermissionState.GRANTED) { throw Exception("No permission to use camera. Did you request it yet?") } else { if (!cameraReady) { prepareInternal(invoke.getString("cameraDirection", "back")) } cameraReady = false configureCamera(getFormats(invoke)) } } } private fun markFirstPermissionRequest() { val sharedPreference: SharedPreferences = activity.getSharedPreferences(PREFS_PERMISSION_FIRST_TIME_ASKING, MODE_PRIVATE) sharedPreference.edit().putBoolean(PERMISSION_NAME, false).apply() } private fun firstPermissionRequest(): Boolean { return activity.getSharedPreferences(PREFS_PERMISSION_FIRST_TIME_ASKING, MODE_PRIVATE) .getBoolean(PERMISSION_NAME, true) } @SuppressLint("ObsoleteSdkInt") @PermissionCallback fun cameraPermissionCallback(invoke: Invoke) { if (requestPermissionResponse == null) { return } val requestPermissionResponse = requestPermissionResponse!! val granted = getPermissionState(PERMISSION_ALIAS_CAMERA) === PermissionState.GRANTED if (granted) { requestPermissionResponse.put(PERMISSION_ALIAS_CAMERA, PermissionState.GRANTED) } else { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { if (!activity.shouldShowRequestPermissionRationale(PERMISSION_NAME)) { requestPermissionResponse.put(PERMISSION_ALIAS_CAMERA, PermissionState.DENIED) } } else { requestPermissionResponse.put(PERMISSION_ALIAS_CAMERA, PermissionState.GRANTED) } } invoke.resolve(requestPermissionResponse) this.requestPermissionResponse = null } @SuppressLint("ObsoleteSdkInt") @Command override fun requestPermissions(invoke: Invoke) { val requestPermissionResponse = JSObject() this.requestPermissionResponse = requestPermissionResponse if (getPermissionState(PERMISSION_ALIAS_CAMERA) === PermissionState.GRANTED) { requestPermissionResponse.put(PERMISSION_ALIAS_CAMERA, PermissionState.GRANTED) } else { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { if (firstPermissionRequest() || activity.shouldShowRequestPermissionRationale( PERMISSION_NAME ) ) { markFirstPermissionRequest() requestPermissionForAlias( PERMISSION_ALIAS_CAMERA, invoke, "cameraPermissionCallback" ) return } else { requestPermissionResponse.put(PERMISSION_ALIAS_CAMERA, PermissionState.DENIED) } } else { requestPermissionResponse.put(PERMISSION_ALIAS_CAMERA, PermissionState.GRANTED) } } invoke.resolve(requestPermissionResponse) } @Command fun openAppSettings(invoke: Invoke) { val intent = Intent( Settings.ACTION_APPLICATION_DETAILS_SETTINGS, Uri.fromParts("package", activity.packageName, null) ) intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) startActivityForResult(invoke, intent, "openSettingsResult") } @ActivityCallback private fun openSettingsResult(invoke: Invoke, result: ActivityResult) { invoke.resolve() } }