diff --git a/qr/lib/src/main/java/org/signal/qr/ImageProxyLuminanceSource.kt b/qr/lib/src/main/java/org/signal/qr/ImageProxyLuminanceSource.kt new file mode 100644 index 000000000..f9e721ff1 --- /dev/null +++ b/qr/lib/src/main/java/org/signal/qr/ImageProxyLuminanceSource.kt @@ -0,0 +1,68 @@ +package org.signal.qr + +import android.graphics.ImageFormat +import androidx.camera.core.ImageProxy +import com.google.zxing.LuminanceSource +import java.nio.ByteBuffer + +/** + * Luminance source that gets data via an [ImageProxy]. The main reason for this is because + * the Y-Plane provided by the camera framework can have a row stride (number of bytes that make up a row) + * that is different than the image width. + * + * An image width can be reported as 1080 but the row stride may be 1088. Thus when representing a row-major + * 2D array as a 1D array, the math can go sideways if width is used instead of row stride. + */ +class ImageProxyLuminanceSource(image: ImageProxy) : LuminanceSource(image.width, image.height) { + + val yData: ByteArray + + init { + require(image.format == ImageFormat.YUV_420_888) { "Invalid image format" } + + yData = ByteArray(image.width * image.height) + + val yBuffer: ByteBuffer = image.planes[0].buffer + yBuffer.position(0) + + val yRowStride: Int = image.planes[0].rowStride + + for (y in 0 until image.height) { + val yIndex: Int = y * yRowStride + yBuffer.position(yIndex) + yBuffer.get(yData, y * image.width, image.width) + } + } + + override fun getRow(y: Int, row: ByteArray?): ByteArray { + require(y in 0 until height) { "Requested row is outside the image: $y" } + + val toReturn: ByteArray = if (row == null || row.size < width) { + ByteArray(width) + } else { + row + } + + val yIndex: Int = y * width + + yData.copyInto(toReturn, 0, yIndex, yIndex + width) + + return toReturn + } + + override fun getMatrix(): ByteArray { + return yData + } + + fun render(): IntArray { + val argbArray = IntArray(width * height) + + var yValue: Int + yData.forEachIndexed { i, byte -> + yValue = (byte.toInt() and 0xff).coerceIn(0..255) + argbArray[i] = 255 shl 24 or (yValue and 255 shl 16) or (yValue and 255 shl 8) or (yValue and 255) + } + + return argbArray + } +} diff --git a/qr/lib/src/main/java/org/signal/qr/QrProcessor.kt b/qr/lib/src/main/java/org/signal/qr/QrProcessor.kt index 579845a49..8462d81ca 100644 --- a/qr/lib/src/main/java/org/signal/qr/QrProcessor.kt +++ b/qr/lib/src/main/java/org/signal/qr/QrProcessor.kt @@ -1,9 +1,11 @@ package org.signal.qr +import androidx.camera.core.ImageProxy import com.google.zxing.BinaryBitmap import com.google.zxing.ChecksumException import com.google.zxing.DecodeHintType import com.google.zxing.FormatException +import com.google.zxing.LuminanceSource import com.google.zxing.NotFoundException import com.google.zxing.PlanarYUVLuminanceSource import com.google.zxing.Result @@ -21,19 +23,25 @@ class QrProcessor { private var previousHeight = 0 private var previousWidth = 0 + fun getScannedData(proxy: ImageProxy): String? { + return getScannedData(ImageProxyLuminanceSource(proxy)) + } + fun getScannedData( data: ByteArray, width: Int, height: Int ): String? { - try { - if (width != previousWidth || height != previousHeight) { - Log.i(TAG, "Processing $width x $height image, data: ${data.size}") - previousWidth = width - previousHeight = height - } + return getScannedData(PlanarYUVLuminanceSource(data, width, height, 0, 0, width, height, false)) + } - val source = PlanarYUVLuminanceSource(data, width, height, 0, 0, width, height, false) + private fun getScannedData(source: LuminanceSource): String? { + try { + if (source.width != previousWidth || source.height != previousHeight) { + Log.i(TAG, "Processing ${source.width} x ${source.height} image") + previousWidth = source.width + previousHeight = source.height + } val bitmap = BinaryBitmap(HybridBinarizer(source)) val result: Result? = reader.decode(bitmap, mapOf(DecodeHintType.TRY_HARDER to true, DecodeHintType.CHARACTER_SET to "ISO-8859-1")) diff --git a/qr/lib/src/main/java/org/signal/qr/ScannerView21.kt b/qr/lib/src/main/java/org/signal/qr/ScannerView21.kt index 8b563120f..93486486a 100644 --- a/qr/lib/src/main/java/org/signal/qr/ScannerView21.kt +++ b/qr/lib/src/main/java/org/signal/qr/ScannerView21.kt @@ -2,22 +2,30 @@ package org.signal.qr import android.annotation.SuppressLint import android.content.Context +import android.graphics.Bitmap +import android.graphics.ImageFormat +import android.util.Size import android.widget.FrameLayout import androidx.annotation.RequiresApi import androidx.camera.core.AspectRatio 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.core.math.MathUtils.clamp import androidx.lifecycle.DefaultLifecycleObserver import androidx.lifecycle.LifecycleOwner +import io.reactivex.rxjava3.subjects.PublishSubject import org.signal.core.util.logging.Log import org.signal.qr.kitkat.ScanListener +import java.nio.ByteBuffer import java.util.concurrent.Executors + /** * API21+ version of QR scanning view. Uses camerax APIs. */ @@ -74,21 +82,17 @@ internal class ScannerView21 constructor( val preview = Preview.Builder().build() val imageAnalysis = ImageAnalysis.Builder() - .setTargetAspectRatio(AspectRatio.RATIO_4_3) + .setTargetResolution(Size(1920, 1080)) .setBackpressureStrategy(ImageAnalysis.STRATEGY_KEEP_ONLY_LATEST) .build() imageAnalysis.setAnalyzer(analyzerExecutor) { proxy -> - val buffer = proxy.planes[0].buffer.apply { rewind() } - val bytes = ByteArray(buffer.capacity()) - buffer.get(bytes) - - val data: String? = qrProcessor.getScannedData(bytes, proxy.width, proxy.height) - if (data != null) { - listener.onQrDataFound(data) + proxy.use { + val data: String? = qrProcessor.getScannedData(it) + if (data != null) { + listener.onQrDataFound(data) + } } - - proxy.close() } cameraProvider.unbindAll()