QrCameraAnalyzer.kt
/*
* This file is part of GNU Taler
* (C) 2026 Taler Systems S.A.
*
* GNU Taler is free software; you can redistribute it and/or modify it under the
* terms of the GNU General Public License as published by the Free Software
* Foundation; either version 3, or (at your option) any later version.
*
* GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
* WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
* A PARTICULAR PURPOSE. See the GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License along with
* GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
*/
package net.taler.lib.android.qr
import android.content.Context
import android.graphics.Bitmap
import android.media.Image
import androidx.camera.core.Camera
import androidx.camera.core.CameraSelector
import androidx.camera.core.ExperimentalGetImage
import androidx.camera.core.FocusMeteringAction
import androidx.camera.core.ImageAnalysis
import androidx.camera.core.Preview
import androidx.camera.core.SurfaceOrientedMeteringPointFactory
import androidx.camera.lifecycle.ProcessCameraProvider
import androidx.camera.view.PreviewView
import androidx.core.content.ContextCompat
import androidx.lifecycle.LifecycleOwner
import com.google.zxing.BinaryBitmap
import com.google.zxing.DecodeHintType
import com.google.zxing.MultiFormatReader
import com.google.zxing.NotFoundException
import com.google.zxing.RGBLuminanceSource
import com.google.zxing.BarcodeFormat
import com.google.zxing.common.HybridBinarizer
import com.google.zxing.PlanarYUVLuminanceSource
import java.util.concurrent.Executor
@androidx.annotation.OptIn(ExperimentalGetImage::class)
class QrCameraAnalyzer(
private val context: Context,
private val lifecycleOwner: LifecycleOwner,
private val previewView: PreviewView,
private val onQrDetected: (String) -> Unit,
private val onCameraReady: ((initialLinearZoom: Float) -> Unit)? = null,
) {
private val mainExecutor: Executor = ContextCompat.getMainExecutor(context)
private var camera: Camera? = null
private val qrReader = MultiFormatReader().apply {
setHints(
mapOf(
DecodeHintType.POSSIBLE_FORMATS to listOf(BarcodeFormat.QR_CODE),
DecodeHintType.CHARACTER_SET to "UTF-8",
),
)
}
fun startCamera() {
val providerFuture = ProcessCameraProvider.getInstance(context)
providerFuture.addListener({
val provider = providerFuture.get()
val preview = Preview.Builder().build().also {
it.setSurfaceProvider(previewView.surfaceProvider)
}
val analysis = ImageAnalysis.Builder()
.setBackpressureStrategy(ImageAnalysis.STRATEGY_KEEP_ONLY_LATEST)
.build().also { useCase ->
useCase.setAnalyzer(mainExecutor) { proxy ->
val mediaImage = proxy.image
if (mediaImage != null) {
val nv21 = yuv420888ToNv21(mediaImage)
val width = mediaImage.width
val height = mediaImage.height
val rotation = proxy.imageInfo.rotationDegrees
val (rotatedNv21, rotatedWidth, rotatedHeight) = when (rotation) {
90 -> rotateNV21(nv21, width, height, 90)
180 -> rotateNV21(nv21, width, height, 180)
270 -> rotateNV21(nv21, width, height, 270)
else -> Triple(nv21, width, height)
}
val source = PlanarYUVLuminanceSource(
rotatedNv21, rotatedWidth, rotatedHeight, 0, 0, rotatedWidth, rotatedHeight, false,
)
val bitmap = BinaryBitmap(HybridBinarizer(source))
try {
val result = qrReader.decodeWithState(bitmap)
onQrDetected(result.text)
} catch (_: NotFoundException) {
} finally {
proxy.close()
}
} else {
proxy.close()
}
}
}
provider.unbindAll()
camera = provider.bindToLifecycle(
lifecycleOwner,
CameraSelector.DEFAULT_BACK_CAMERA,
preview,
analysis,
)
camera?.cameraInfo?.zoomState?.value?.let { zoomState ->
onCameraReady?.invoke(zoomState.linearZoom)
}
}, mainExecutor)
}
fun stopCamera() {
try {
ProcessCameraProvider.getInstance(context).get().unbindAll()
camera = null
} catch (_: Exception) {
}
}
fun enableTorch(enabled: Boolean) {
camera?.cameraControl?.enableTorch(enabled)
}
fun setLinearZoom(zoom: Float) {
camera?.cameraControl?.setLinearZoom(zoom.coerceIn(0f, 1f))
}
fun focusAtPoint(x: Float, y: Float) {
val cam = camera ?: return
val factory = SurfaceOrientedMeteringPointFactory(
previewView.width.toFloat(),
previewView.height.toFloat(),
)
val point = factory.createPoint(x, y)
val action = FocusMeteringAction.Builder(point)
.setAutoCancelDuration(3, java.util.concurrent.TimeUnit.SECONDS)
.build()
cam.cameraControl.startFocusAndMetering(action)
}
private fun yuv420888ToNv21(image: Image): ByteArray {
val yPlane = image.planes[0].buffer
val uPlane = image.planes[1].buffer
val vPlane = image.planes[2].buffer
val ySize = yPlane.remaining()
val uSize = uPlane.remaining()
val vSize = vPlane.remaining()
val nv21 = ByteArray(ySize + uSize + vSize)
yPlane.get(nv21, 0, ySize)
vPlane.get(nv21, ySize, vSize)
uPlane.get(nv21, ySize + vSize, uSize)
return nv21
}
private fun rotateNV21(data: ByteArray, width: Int, height: Int, rotation: Int): Triple<ByteArray, Int, Int> {
if (rotation == 0) return Triple(data, width, height)
val ySize = width * height
val uvSize = ySize / 2
val rotated = ByteArray(ySize + uvSize)
when (rotation) {
90 -> {
var i = 0
for (x in 0 until width) {
for (y in height - 1 downTo 0) {
rotated[i++] = data[y * width + x]
}
}
var uvIndex = ySize
for (x in 0 until width step 2) {
for (y in height - 1 downTo 0 step 2) {
val uvPos = ySize + (y * width + x)
if (uvPos + 1 < data.size) {
rotated[uvIndex++] = data[uvPos]
rotated[uvIndex++] = data[uvPos + 1]
}
}
}
return Triple(rotated, height, width)
}
180 -> {
var i = ySize - 1
for (j in 0 until ySize) {
rotated[j] = data[i--]
}
i = data.size - 1
for (j in ySize until data.size step 2) {
rotated[j] = data[i - 1]
rotated[j + 1] = data[i]
i -= 2
}
return Triple(rotated, width, height)
}
270 -> {
var i = 0
for (x in width - 1 downTo 0) {
for (y in 0 until height) {
rotated[i++] = data[y * width + x]
}
}
var uvIndex = ySize
for (x in width - 1 downTo 0 step 2) {
for (y in 0 until height step 2) {
val uvPos = ySize + (y * width + x - 1)
if (uvPos + 1 < data.size) {
rotated[uvIndex++] = data[uvPos]
rotated[uvIndex++] = data[uvPos + 1]
}
}
}
return Triple(rotated, height, width)
}
}
return Triple(data, width, height)
}
companion object {
fun decodeQrFromBitmap(bitmap: Bitmap): String? {
val width = bitmap.width
val height = bitmap.height
val pixels = IntArray(width * height)
bitmap.getPixels(pixels, 0, width, 0, 0, width, height)
val source = RGBLuminanceSource(width, height, pixels)
val binaryBitmap = BinaryBitmap(HybridBinarizer(source))
val reader = MultiFormatReader().apply {
setHints(
mapOf(
DecodeHintType.POSSIBLE_FORMATS to listOf(BarcodeFormat.QR_CODE),
DecodeHintType.CHARACTER_SET to "UTF-8",
),
)
}
return try {
reader.decodeWithState(binaryBitmap).text
} catch (_: NotFoundException) {
null
}
}
}
}