From 0056701aeebab1d8036e9e3c619c522a9b9b3bf8 Mon Sep 17 00:00:00 2001 From: "William K. Santiago" Date: Fri, 17 Apr 2026 20:58:26 -0400 Subject: [PATCH 1/5] Replace Google ML Kit barcode scanner with ZXing for F-Droid eligibility (#243) --- app/build.gradle.kts | 1 - .../io/privkey/keep/ImportShareScreen.kt | 153 ++++++++++++++---- 2 files changed, 124 insertions(+), 30 deletions(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index d1f857e3..4bc5f3d1 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -122,7 +122,6 @@ dependencies { implementation("net.zetetic:sqlcipher-android:4.14.1") implementation("androidx.biometric:biometric:1.1.0") implementation("androidx.security:security-crypto:1.1.0") - implementation("com.google.mlkit:barcode-scanning:17.3.0") implementation("androidx.camera:camera-camera2:1.6.0") implementation("androidx.camera:camera-lifecycle:1.6.0") implementation("androidx.camera:camera-view:1.6.0") diff --git a/app/src/main/kotlin/io/privkey/keep/ImportShareScreen.kt b/app/src/main/kotlin/io/privkey/keep/ImportShareScreen.kt index d9213585..773a89bc 100644 --- a/app/src/main/kotlin/io/privkey/keep/ImportShareScreen.kt +++ b/app/src/main/kotlin/io/privkey/keep/ImportShareScreen.kt @@ -31,9 +31,13 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.viewinterop.AndroidView import androidx.core.content.ContextCompat import androidx.lifecycle.compose.LocalLifecycleOwner -import com.google.mlkit.vision.barcode.BarcodeScanning -import com.google.mlkit.vision.barcode.common.Barcode -import com.google.mlkit.vision.common.InputImage +import com.google.zxing.BarcodeFormat +import com.google.zxing.BinaryBitmap +import com.google.zxing.DecodeHintType +import com.google.zxing.MultiFormatReader +import com.google.zxing.NotFoundException +import com.google.zxing.PlanarYUVLuminanceSource +import com.google.zxing.common.HybridBinarizer import org.json.JSONObject import java.util.Arrays import java.util.concurrent.Executors @@ -44,6 +48,100 @@ private const val MAX_SHARE_LENGTH = 8192 private const val MAX_ANIMATED_FRAMES = 100 private const val MAX_FRAME_LENGTH = 4096 +private fun decodeQrFromImageProxy( + imageProxy: androidx.camera.core.ImageProxy, + reader: MultiFormatReader +): String? { + val plane = imageProxy.planes.firstOrNull() ?: return null + val buffer = plane.buffer + val rowStride = plane.rowStride + val width = imageProxy.width + val height = imageProxy.height + + val data = ByteArray(rowStride * height) + buffer.rewind() + buffer.get(data, 0, minOf(data.size, buffer.remaining())) + + val rotation = imageProxy.imageInfo.rotationDegrees + val (rotated, rWidth, rHeight) = rotateLuminance(data, rowStride, width, height, rotation) + + return try { + decodeLuminance(reader, rotated, rWidth, rHeight, rWidth) + } catch (_: NotFoundException) { + null + } catch (_: Exception) { + null + } +} + +private fun decodeLuminance( + reader: MultiFormatReader, + data: ByteArray, + width: Int, + height: Int, + rowStride: Int +): String? { + if (width <= 0 || height <= 0) return null + val source = PlanarYUVLuminanceSource(data, rowStride, height, 0, 0, width, height, false) + val bitmap = BinaryBitmap(HybridBinarizer(source)) + return try { + reader.decodeWithState(bitmap).text + } finally { + reader.reset() + } +} + +private data class RotatedLuminance(val data: ByteArray, val width: Int, val height: Int) + +private fun rotateLuminance( + source: ByteArray, + rowStride: Int, + width: Int, + height: Int, + rotationDegrees: Int +): RotatedLuminance { + return when (rotationDegrees) { + 90 -> { + val out = ByteArray(width * height) + for (y in 0 until height) { + for (x in 0 until width) { + out[x * height + (height - 1 - y)] = source[y * rowStride + x] + } + } + RotatedLuminance(out, height, width) + } + 180 -> { + val out = ByteArray(width * height) + for (y in 0 until height) { + for (x in 0 until width) { + out[(height - 1 - y) * width + (width - 1 - x)] = source[y * rowStride + x] + } + } + RotatedLuminance(out, width, height) + } + 270 -> { + val out = ByteArray(width * height) + for (y in 0 until height) { + for (x in 0 until width) { + out[(width - 1 - x) * height + y] = source[y * rowStride + x] + } + } + RotatedLuminance(out, height, width) + } + else -> { + if (rowStride == width) { + RotatedLuminance(source, width, height) + } else { + val out = ByteArray(width * height) + for (y in 0 until height) { + System.arraycopy(source, y * rowStride, out, y * width, width) + } + RotatedLuminance(out, width, height) + } + } + } +} + private fun isValidBech32Payload(prefix: String, data: String): Boolean { if (data.length > MAX_SHARE_LENGTH) return false if (!data.startsWith(prefix)) return false @@ -477,14 +575,18 @@ private fun CameraPreview( Box(modifier = Modifier.fillMaxSize()) { var cameraProvider by remember { mutableStateOf(null) } val previewView = remember { PreviewView(context) } - val scanner = remember { BarcodeScanning.getClient() } + val reader = remember { + MultiFormatReader().apply { + setHints(mapOf(DecodeHintType.POSSIBLE_FORMATS to listOf(BarcodeFormat.QR_CODE))) + } + } val executor = remember { Executors.newSingleThreadExecutor() } val scanned = remember { AtomicBoolean(false) } val closed = remember { AtomicBoolean(false) } fun cleanupResources() { if (closed.compareAndSet(false, true)) { - runCatching { scanner.close() } + runCatching { reader.reset() } runCatching { executor.shutdownNow() } } } @@ -517,33 +619,26 @@ private fun CameraPreview( .build() analysis.setAnalyzer(executor) { imageProxy -> - val mediaImage = imageProxy.image - if (mediaImage == null) { - imageProxy.close() - return@setAnalyzer - } - val image = InputImage.fromMediaImage(mediaImage, imageProxy.imageInfo.rotationDegrees) - scanner.process(image) - .addOnSuccessListener { barcodes -> - if (scanned.get()) return@addOnSuccessListener - val rawValue = barcodes.firstOrNull { it.valueType == Barcode.TYPE_TEXT }?.rawValue - ?: return@addOnSuccessListener - - val result = frameCollector.processQrContent(rawValue) - - frameProgress = if (frameCollector.isCollecting) { - frameCollector.framesCollected to frameCollector.total - } else { - null - } + try { + if (scanned.get()) return@setAnalyzer + val rawValue = decodeQrFromImageProxy(imageProxy, reader) ?: return@setAnalyzer - if (result != null && validator(result)) { - if (scanned.compareAndSet(false, true)) { - onCodeScanned(result) - } + val result = frameCollector.processQrContent(rawValue) + + frameProgress = if (frameCollector.isCollecting) { + frameCollector.framesCollected to frameCollector.total + } else { + null + } + + if (result != null && validator(result)) { + if (scanned.compareAndSet(false, true)) { + onCodeScanned(result) } } - .addOnCompleteListener { imageProxy.close() } + } finally { + imageProxy.close() + } } try { From d69b8b86b98c675e7deee77507f8be68151985e0 Mon Sep 17 00:00:00 2001 From: "William K. Santiago" Date: Fri, 17 Apr 2026 21:05:01 -0400 Subject: [PATCH 2/5] harden QR scanner thread safety and buffer bounds --- .../kotlin/io/privkey/keep/ImportShareScreen.kt | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/app/src/main/kotlin/io/privkey/keep/ImportShareScreen.kt b/app/src/main/kotlin/io/privkey/keep/ImportShareScreen.kt index 773a89bc..eaa76a67 100644 --- a/app/src/main/kotlin/io/privkey/keep/ImportShareScreen.kt +++ b/app/src/main/kotlin/io/privkey/keep/ImportShareScreen.kt @@ -41,6 +41,7 @@ import com.google.zxing.common.HybridBinarizer import org.json.JSONObject import java.util.Arrays import java.util.concurrent.Executors +import java.util.concurrent.TimeUnit import java.util.concurrent.atomic.AtomicBoolean import javax.crypto.Cipher @@ -58,9 +59,11 @@ private fun decodeQrFromImageProxy( val width = imageProxy.width val height = imageProxy.height - val data = ByteArray(rowStride * height) + val expected = rowStride * height buffer.rewind() - buffer.get(data, 0, minOf(data.size, buffer.remaining())) + if (buffer.remaining() < expected) return null + val data = ByteArray(expected) + buffer.get(data, 0, expected) val rotation = imageProxy.imageInfo.rotationDegrees val (rotated, rWidth, rHeight) = rotateLuminance(data, rowStride, width, height, rotation) @@ -69,7 +72,8 @@ private fun decodeQrFromImageProxy( decodeLuminance(reader, rotated, rWidth, rHeight, rWidth) } catch (_: NotFoundException) { null - } catch (_: Exception) { + } catch (e: Exception) { + if (BuildConfig.DEBUG) Log.d("ImportShare", "QR decode failed: ${e::class.simpleName}") null } } @@ -586,8 +590,11 @@ private fun CameraPreview( fun cleanupResources() { if (closed.compareAndSet(false, true)) { - runCatching { reader.reset() } - runCatching { executor.shutdownNow() } + runCatching { + executor.shutdownNow() + executor.awaitTermination(100, TimeUnit.MILLISECONDS) + reader.reset() + } } } From fbe5ae902477b70a87c1503eb621a43b687f7a0a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kyle=20=F0=9F=90=86?= Date: Sat, 18 Apr 2026 18:00:37 -0400 Subject: [PATCH 3/5] fix: address QR scanner review feedback --- .../io/privkey/keep/ImportShareScreen.kt | 102 ++++++++++++------ 1 file changed, 68 insertions(+), 34 deletions(-) diff --git a/app/src/main/kotlin/io/privkey/keep/ImportShareScreen.kt b/app/src/main/kotlin/io/privkey/keep/ImportShareScreen.kt index eaa76a67..f59bfdea 100644 --- a/app/src/main/kotlin/io/privkey/keep/ImportShareScreen.kt +++ b/app/src/main/kotlin/io/privkey/keep/ImportShareScreen.kt @@ -35,8 +35,8 @@ import com.google.zxing.BarcodeFormat import com.google.zxing.BinaryBitmap import com.google.zxing.DecodeHintType import com.google.zxing.MultiFormatReader -import com.google.zxing.NotFoundException import com.google.zxing.PlanarYUVLuminanceSource +import com.google.zxing.ReaderException import com.google.zxing.common.HybridBinarizer import org.json.JSONObject import java.util.Arrays @@ -49,9 +49,25 @@ private const val MAX_SHARE_LENGTH = 8192 private const val MAX_ANIMATED_FRAMES = 100 private const val MAX_FRAME_LENGTH = 4096 +private class LuminanceBuffers { + var raw: ByteArray = ByteArray(0) + var rotated: ByteArray = ByteArray(0) + + fun rawBuffer(size: Int): ByteArray { + if (raw.size < size) raw = ByteArray(size) + return raw + } + + fun rotatedBuffer(size: Int): ByteArray { + if (rotated.size < size) rotated = ByteArray(size) + return rotated + } +} + private fun decodeQrFromImageProxy( imageProxy: androidx.camera.core.ImageProxy, - reader: MultiFormatReader + reader: MultiFormatReader, + buffers: LuminanceBuffers ): String? { val plane = imageProxy.planes.firstOrNull() ?: return null val buffer = plane.buffer @@ -59,18 +75,22 @@ private fun decodeQrFromImageProxy( val width = imageProxy.width val height = imageProxy.height - val expected = rowStride * height + if (width <= 0 || height <= 0 || rowStride < width) return null + + val expected = rowStride * (height - 1) + width buffer.rewind() - if (buffer.remaining() < expected) return null - val data = ByteArray(expected) - buffer.get(data, 0, expected) + val available = buffer.remaining() + if (available < expected) return null + val copyLen = minOf(available, rowStride * height) + val data = buffers.rawBuffer(copyLen) + buffer.get(data, 0, copyLen) val rotation = imageProxy.imageInfo.rotationDegrees - val (rotated, rWidth, rHeight) = rotateLuminance(data, rowStride, width, height, rotation) + val rotated = rotateLuminance(data, rowStride, width, height, rotation, buffers) ?: return null return try { - decodeLuminance(reader, rotated, rWidth, rHeight, rWidth) - } catch (_: NotFoundException) { + decodeLuminance(reader, rotated.data, rotated.width, rotated.height, rotated.width) + } catch (e: ReaderException) { null } catch (e: Exception) { if (BuildConfig.DEBUG) Log.d("ImportShare", "QR decode failed: ${e::class.simpleName}") @@ -88,11 +108,7 @@ private fun decodeLuminance( if (width <= 0 || height <= 0) return null val source = PlanarYUVLuminanceSource(data, rowStride, height, 0, 0, width, height, false) val bitmap = BinaryBitmap(HybridBinarizer(source)) - return try { - reader.decodeWithState(bitmap).text - } finally { - reader.reset() - } + return reader.decodeWithState(bitmap).text } private data class RotatedLuminance(val data: ByteArray, val width: Int, val height: Int) @@ -102,32 +118,42 @@ private fun rotateLuminance( rowStride: Int, width: Int, height: Int, - rotationDegrees: Int -): RotatedLuminance { + rotationDegrees: Int, + buffers: LuminanceBuffers +): RotatedLuminance? { + val needed = width * height + val lastRowStart = (height - 1) * rowStride + val requiredSource = lastRowStart + width + if (source.size < requiredSource) return null return when (rotationDegrees) { 90 -> { - val out = ByteArray(width * height) + val out = buffers.rotatedBuffer(needed) for (y in 0 until height) { + val rowBase = y * rowStride + val outBase = height - 1 - y for (x in 0 until width) { - out[x * height + (height - 1 - y)] = source[y * rowStride + x] + out[x * height + outBase] = source[rowBase + x] } } RotatedLuminance(out, height, width) } 180 -> { - val out = ByteArray(width * height) + val out = buffers.rotatedBuffer(needed) for (y in 0 until height) { + val rowBase = y * rowStride + val outRowBase = (height - 1 - y) * width for (x in 0 until width) { - out[(height - 1 - y) * width + (width - 1 - x)] = source[y * rowStride + x] + out[outRowBase + (width - 1 - x)] = source[rowBase + x] } } RotatedLuminance(out, width, height) } 270 -> { - val out = ByteArray(width * height) + val out = buffers.rotatedBuffer(needed) for (y in 0 until height) { + val rowBase = y * rowStride for (x in 0 until width) { - out[(width - 1 - x) * height + y] = source[y * rowStride + x] + out[(width - 1 - x) * height + y] = source[rowBase + x] } } RotatedLuminance(out, height, width) @@ -136,7 +162,7 @@ private fun rotateLuminance( if (rowStride == width) { RotatedLuminance(source, width, height) } else { - val out = ByteArray(width * height) + val out = buffers.rotatedBuffer(needed) for (y in 0 until height) { System.arraycopy(source, y * rowStride, out, y * width, width) } @@ -584,16 +610,20 @@ private fun CameraPreview( setHints(mapOf(DecodeHintType.POSSIBLE_FORMATS to listOf(BarcodeFormat.QR_CODE))) } } + val luminanceBuffers = remember { LuminanceBuffers() } val executor = remember { Executors.newSingleThreadExecutor() } + val mainExecutor = remember(context) { ContextCompat.getMainExecutor(context) } val scanned = remember { AtomicBoolean(false) } val closed = remember { AtomicBoolean(false) } + val currentAnalysis = remember { mutableStateOf(null) } fun cleanupResources() { if (closed.compareAndSet(false, true)) { runCatching { + currentAnalysis.value?.clearAnalyzer() + currentAnalysis.value = null executor.shutdownNow() executor.awaitTermination(100, TimeUnit.MILLISECONDS) - reader.reset() } } } @@ -624,22 +654,24 @@ private fun CameraPreview( .setResolutionSelector(resolutionSelector) .setBackpressureStrategy(ImageAnalysis.STRATEGY_KEEP_ONLY_LATEST) .build() + currentAnalysis.value = analysis analysis.setAnalyzer(executor) { imageProxy -> try { if (scanned.get()) return@setAnalyzer - val rawValue = decodeQrFromImageProxy(imageProxy, reader) ?: return@setAnalyzer + val rawValue = decodeQrFromImageProxy(imageProxy, reader, luminanceBuffers) + ?: return@setAnalyzer val result = frameCollector.processQrContent(rawValue) - - frameProgress = if (frameCollector.isCollecting) { - frameCollector.framesCollected to frameCollector.total - } else { - null - } - - if (result != null && validator(result)) { - if (scanned.compareAndSet(false, true)) { + val collecting = frameCollector.isCollecting + val framesCollected = frameCollector.framesCollected + val total = frameCollector.total + val accepted = result != null && validator(result) && + scanned.compareAndSet(false, true) + + mainExecutor.execute { + frameProgress = if (collecting) framesCollected to total else null + if (accepted && result != null) { onCodeScanned(result) } } @@ -657,6 +689,8 @@ private fun CameraPreview( } onDispose { + analysis.clearAnalyzer() + if (currentAnalysis.value === analysis) currentAnalysis.value = null provider.unbindAll() cleanupResources() } From f7e53f4c8156b0168b4a198a452c55922975813e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kyle=20=F0=9F=90=86?= Date: Sat, 18 Apr 2026 18:17:18 -0400 Subject: [PATCH 4/5] simplify QR scanner: inline single-use decode helper, tighten LuminanceBuffers --- .../io/privkey/keep/ImportShareScreen.kt | 30 ++++++------------- 1 file changed, 9 insertions(+), 21 deletions(-) diff --git a/app/src/main/kotlin/io/privkey/keep/ImportShareScreen.kt b/app/src/main/kotlin/io/privkey/keep/ImportShareScreen.kt index f59bfdea..198b52cd 100644 --- a/app/src/main/kotlin/io/privkey/keep/ImportShareScreen.kt +++ b/app/src/main/kotlin/io/privkey/keep/ImportShareScreen.kt @@ -50,8 +50,8 @@ private const val MAX_ANIMATED_FRAMES = 100 private const val MAX_FRAME_LENGTH = 4096 private class LuminanceBuffers { - var raw: ByteArray = ByteArray(0) - var rotated: ByteArray = ByteArray(0) + private var raw: ByteArray = ByteArray(0) + private var rotated: ByteArray = ByteArray(0) fun rawBuffer(size: Int): ByteArray { if (raw.size < size) raw = ByteArray(size) @@ -89,7 +89,11 @@ private fun decodeQrFromImageProxy( val rotated = rotateLuminance(data, rowStride, width, height, rotation, buffers) ?: return null return try { - decodeLuminance(reader, rotated.data, rotated.width, rotated.height, rotated.width) + val source = PlanarYUVLuminanceSource( + rotated.data, rotated.width, rotated.height, + 0, 0, rotated.width, rotated.height, false + ) + reader.decodeWithState(BinaryBitmap(HybridBinarizer(source))).text } catch (e: ReaderException) { null } catch (e: Exception) { @@ -98,19 +102,6 @@ private fun decodeQrFromImageProxy( } } -private fun decodeLuminance( - reader: MultiFormatReader, - data: ByteArray, - width: Int, - height: Int, - rowStride: Int -): String? { - if (width <= 0 || height <= 0) return null - val source = PlanarYUVLuminanceSource(data, rowStride, height, 0, 0, width, height, false) - val bitmap = BinaryBitmap(HybridBinarizer(source)) - return reader.decodeWithState(bitmap).text -} - private data class RotatedLuminance(val data: ByteArray, val width: Int, val height: Int) private fun rotateLuminance( @@ -122,9 +113,7 @@ private fun rotateLuminance( buffers: LuminanceBuffers ): RotatedLuminance? { val needed = width * height - val lastRowStart = (height - 1) * rowStride - val requiredSource = lastRowStart + width - if (source.size < requiredSource) return null + if (source.size < (height - 1) * rowStride + width) return null return when (rotationDegrees) { 90 -> { val out = buffers.rotatedBuffer(needed) @@ -670,6 +659,7 @@ private fun CameraPreview( scanned.compareAndSet(false, true) mainExecutor.execute { + if (closed.get()) return@execute frameProgress = if (collecting) framesCollected to total else null if (accepted && result != null) { onCodeScanned(result) @@ -689,8 +679,6 @@ private fun CameraPreview( } onDispose { - analysis.clearAnalyzer() - if (currentAnalysis.value === analysis) currentAnalysis.value = null provider.unbindAll() cleanupResources() } From ed6cb4fc01b608eb41df95a739cab1489ce870e6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kyle=20=F0=9F=90=86?= Date: Sat, 18 Apr 2026 22:41:53 -0400 Subject: [PATCH 5/5] drop redundant null check flagged by kotlin -Werror --- app/src/main/kotlin/io/privkey/keep/ImportShareScreen.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/main/kotlin/io/privkey/keep/ImportShareScreen.kt b/app/src/main/kotlin/io/privkey/keep/ImportShareScreen.kt index 198b52cd..e6b07dbc 100644 --- a/app/src/main/kotlin/io/privkey/keep/ImportShareScreen.kt +++ b/app/src/main/kotlin/io/privkey/keep/ImportShareScreen.kt @@ -661,7 +661,7 @@ private fun CameraPreview( mainExecutor.execute { if (closed.get()) return@execute frameProgress = if (collecting) framesCollected to total else null - if (accepted && result != null) { + if (accepted) { onCodeScanned(result) } }