diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index c176d2d..bf0b209 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -26,7 +26,7 @@ okhttp = "5.3.2" workRuntime = "2.11.2" hiltWork = "1.3.0" cameraX = "1.6.1" -mlkitBarcode = "17.3.0" +zxing = "3.5.3" javaOtp = "0.4.0" appcompat = "1.7.1" datastore = "1.2.1" @@ -90,7 +90,7 @@ androidx-camera-core = { group = "androidx.camera", name = "camera-core", versio androidx-camera-camera2 = { group = "androidx.camera", name = "camera-camera2", version.ref = "cameraX" } androidx-camera-lifecycle = { group = "androidx.camera", name = "camera-lifecycle", version.ref = "cameraX" } androidx-camera-view = { group = "androidx.camera", name = "camera-view", version.ref = "cameraX" } -mlkit-barcode-scanning = { group = "com.google.mlkit", name = "barcode-scanning", version.ref = "mlkitBarcode" } +zxing-core = { group = "com.google.zxing", name = "core", version.ref = "zxing" } java-otp = { group = "com.eatthepath", name = "java-otp", version.ref = "javaOtp" } androidx-ui = { group = "androidx.compose.ui", name = "ui" } diff --git a/ui/build.gradle.kts b/ui/build.gradle.kts index ed978bf..68951f8 100644 --- a/ui/build.gradle.kts +++ b/ui/build.gradle.kts @@ -80,7 +80,7 @@ dependencies { implementation(libs.androidx.camera.camera2) implementation(libs.androidx.camera.lifecycle) implementation(libs.androidx.camera.view) - implementation(libs.mlkit.barcode.scanning) + implementation(libs.zxing.core) testImplementation(libs.junit) testImplementation(libs.kotlinx.coroutines.test) diff --git a/ui/src/main/java/com/github/nullptroma/wallenc/ui/elements/QrImageDecoder.kt b/ui/src/main/java/com/github/nullptroma/wallenc/ui/elements/QrImageDecoder.kt new file mode 100644 index 0000000..f860077 --- /dev/null +++ b/ui/src/main/java/com/github/nullptroma/wallenc/ui/elements/QrImageDecoder.kt @@ -0,0 +1,106 @@ +package com.github.nullptroma.wallenc.ui.elements + +import androidx.camera.core.ImageProxy +import com.google.zxing.BarcodeFormat +import com.google.zxing.BinaryBitmap +import com.google.zxing.ChecksumException +import com.google.zxing.DecodeHintType +import com.google.zxing.FormatException +import com.google.zxing.NotFoundException +import com.google.zxing.PlanarYUVLuminanceSource +import com.google.zxing.common.HybridBinarizer +import com.google.zxing.qrcode.QRCodeReader + +internal object QrImageDecoder { + private val hints = mapOf( + DecodeHintType.POSSIBLE_FORMATS to listOf(BarcodeFormat.QR_CODE), + DecodeHintType.CHARACTER_SET to "UTF-8", + ) + private val reader = QRCodeReader() + + fun decode(imageProxy: ImageProxy): String? { + val yPlane = imageProxy.planes[0] + val yBuffer = yPlane.buffer + yBuffer.rewind() + val yRowStride = yPlane.rowStride + val yPixelStride = yPlane.pixelStride + val width = imageProxy.width + val height = imageProxy.height + val yBytes = ByteArray(width * height) + var outputOffset = 0 + if (yPixelStride == 1 && yRowStride == width) { + yBuffer.get(yBytes, 0, yBytes.size.coerceAtMost(yBuffer.remaining())) + } else { + for (row in 0 until height) { + var inputOffset = row * yRowStride + for (col in 0 until width) { + yBytes[outputOffset++] = yBuffer.get(inputOffset) + inputOffset += yPixelStride + } + } + } + val (luminance, decodeWidth, decodeHeight) = orientLuminance( + yBytes, + width, + height, + imageProxy.imageInfo.rotationDegrees, + ) + val source = PlanarYUVLuminanceSource( + luminance, + decodeWidth, + decodeHeight, + 0, + 0, + decodeWidth, + decodeHeight, + false, + ) + return try { + reader.decode(BinaryBitmap(HybridBinarizer(source)), hints).text + } catch (_: NotFoundException) { + null + } catch (_: ChecksumException) { + null + } catch (_: FormatException) { + null + } + } + + private fun orientLuminance( + yBytes: ByteArray, + width: Int, + height: Int, + rotationDegrees: Int, + ): Triple = when (rotationDegrees) { + 90 -> Triple(rotateY90(yBytes, width, height, clockwise = true), height, width) + 180 -> Triple(rotateY180(yBytes, width, height), width, height) + 270 -> Triple(rotateY90(yBytes, width, height, clockwise = false), height, width) + else -> Triple(yBytes, width, height) + } + + private fun rotateY90(data: ByteArray, width: Int, height: Int, clockwise: Boolean): ByteArray { + val output = ByteArray(data.size) + for (y in 0 until height) { + for (x in 0 until width) { + val srcIndex = y * width + x + val dstIndex = if (clockwise) { + x * height + (height - 1 - y) + } else { + (width - 1 - x) * height + y + } + output[dstIndex] = data[srcIndex] + } + } + return output + } + + private fun rotateY180(data: ByteArray, width: Int, height: Int): ByteArray { + val output = ByteArray(data.size) + for (y in 0 until height) { + for (x in 0 until width) { + output[(height - 1 - y) * width + (width - 1 - x)] = data[y * width + x] + } + } + return output + } +} diff --git a/ui/src/main/java/com/github/nullptroma/wallenc/ui/elements/QrScannerDialog.kt b/ui/src/main/java/com/github/nullptroma/wallenc/ui/elements/QrScannerDialog.kt index effa13e..1bbf7e6 100644 --- a/ui/src/main/java/com/github/nullptroma/wallenc/ui/elements/QrScannerDialog.kt +++ b/ui/src/main/java/com/github/nullptroma/wallenc/ui/elements/QrScannerDialog.kt @@ -31,10 +31,6 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import androidx.compose.ui.viewinterop.AndroidView import com.github.nullptroma.wallenc.ui.R -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 java.util.concurrent.Executors import java.util.concurrent.atomic.AtomicBoolean @@ -48,20 +44,12 @@ fun QrScannerDialog( val lifecycleOwner = androidx.lifecycle.compose.LocalLifecycleOwner.current val cameraProviderFuture = remember { ProcessCameraProvider.getInstance(context) } val cameraExecutor = remember { Executors.newSingleThreadExecutor() } - val scanner = remember { - BarcodeScanning.getClient( - BarcodeScannerOptions.Builder() - .setBarcodeFormats(Barcode.FORMAT_QR_CODE) - .build(), - ) - } val consumed = remember { AtomicBoolean(false) } var previewViewRef by remember { mutableStateOf(null) } DisposableEffect(Unit) { onDispose { runCatching { cameraProviderFuture.get().unbindAll() } - runCatching { scanner.close() } cameraExecutor.shutdown() } } @@ -76,24 +64,17 @@ fun QrScannerDialog( .setBackpressureStrategy(ImageAnalysis.STRATEGY_KEEP_ONLY_LATEST) .build() .also { imageAnalysis -> + imageAnalysis.targetRotation = previewView.display.rotation imageAnalysis.setAnalyzer(cameraExecutor) { imageProxy -> - val mediaImage = imageProxy.image - if (mediaImage == null) { + try { + if (consumed.get()) return@setAnalyzer + val raw = QrImageDecoder.decode(imageProxy)?.trim().orEmpty() + if (raw.isNotBlank() && consumed.compareAndSet(false, true)) { + onScanned(raw) + } + } finally { imageProxy.close() - return@setAnalyzer } - val input = InputImage.fromMediaImage(mediaImage, imageProxy.imageInfo.rotationDegrees) - scanner.process(input) - .addOnSuccessListener { barcodes -> - if (consumed.get()) return@addOnSuccessListener - val raw = barcodes.firstOrNull()?.rawValue?.trim().orEmpty() - if (raw.isNotBlank() && consumed.compareAndSet(false, true)) { - onScanned(raw) - } - } - .addOnCompleteListener { - imageProxy.close() - } } } cameraProvider.unbindAll()