diff --git a/app/src/main/java/one/mixin/android/ui/qr/QRCodeProcessor.kt b/app/src/main/java/one/mixin/android/ui/qr/QRCodeProcessor.kt index 3f85b752f2..8b8161eb3c 100644 --- a/app/src/main/java/one/mixin/android/ui/qr/QRCodeProcessor.kt +++ b/app/src/main/java/one/mixin/android/ui/qr/QRCodeProcessor.kt @@ -1,6 +1,10 @@ package one.mixin.android.ui.qr import android.graphics.Bitmap +import android.graphics.Canvas +import android.graphics.ColorMatrix +import android.graphics.ColorMatrixColorFilter +import android.graphics.Paint import com.google.mlkit.vision.barcode.BarcodeScanner import com.google.mlkit.vision.barcode.BarcodeScannerOptions import com.google.mlkit.vision.barcode.BarcodeScanning @@ -12,6 +16,9 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import one.mixin.android.extension.closeSilently import one.mixin.android.extension.decodeQR +import kotlin.coroutines.resume +import kotlin.coroutines.resumeWithException +import kotlin.coroutines.suspendCoroutine class QRCodeProcessor { private val scanner: BarcodeScanner = @@ -27,49 +34,136 @@ class QRCodeProcessor { onSuccess: (String) -> Unit, onFailure: (Exception?) -> Unit, onComplete: (() -> Unit)? = null, + ) = detectAll( + coroutineScope, + bitmap, + onSuccess = { results -> onSuccess(results.first()) }, + onFailure = onFailure, + onComplete = onComplete, + ) + + fun detectAll( + coroutineScope: CoroutineScope, + bitmap: Bitmap, + onSuccess: (List) -> Unit, + onFailure: (Exception?) -> Unit, + onComplete: (() -> Unit)? = null, ) = coroutineScope.launch { + var failure: Exception? = null + val createdBitmaps = mutableListOf() + val sourceBitmap = + if (bitmap.config == Bitmap.Config.HARDWARE) { + bitmap.copy(Bitmap.Config.ARGB_8888, false).also { createdBitmaps.add(it) } + } else { + bitmap + } try { - var url: String? = null - val inputImage = InputImage.fromBitmap(bitmap, 0) - scanner.process(inputImage) - .addOnSuccessListener { barcodes -> - url = barcodes.firstOrNull()?.rawValue - url?.let { onSuccess(it) } + val candidates = + withContext(Dispatchers.IO) { + val inverted = invert(sourceBitmap).also { createdBitmaps.add(it) } + val monochrome = monochrome(inverted, 90).also { createdBitmaps.add(it) } + listOf(sourceBitmap, inverted, monochrome) } - .addOnFailureListener { - onFailure(it) - } - .addOnCompleteListener { - if (url == null) { - decodeWithZxing(coroutineScope, bitmap, onSuccess, onFailure, onComplete) - } else { + for (candidate in candidates) { + try { + val results = detectWithMlKit(candidate) + if (results.isNotEmpty()) { + onSuccess(results) onComplete?.invoke() + return@launch } + } catch (e: Exception) { + failure = e + } + } + val url = + withContext(Dispatchers.IO) { + sourceBitmap.decodeQR() } + if (url != null) { + onSuccess(listOf(url)) + onComplete?.invoke() + } else { + onComplete?.invoke() + onFailure(failure) + } } catch (e: Exception) { - decodeWithZxing(coroutineScope, bitmap, onSuccess, onFailure, onComplete) + val url = + withContext(Dispatchers.IO) { + sourceBitmap.decodeQR() + } + if (url != null) { + onSuccess(listOf(url)) + onComplete?.invoke() + } else { + onComplete?.invoke() + onFailure(e) + } + } finally { + createdBitmaps.forEach { candidate -> + if (candidate !== bitmap && !candidate.isRecycled) { + candidate.recycle() + } + } } } - private fun decodeWithZxing( - coroutineScope: CoroutineScope, + private suspend fun detectWithMlKit(bitmap: Bitmap): List = + suspendCoroutine { continuation -> + val inputImage = InputImage.fromBitmap(bitmap, 0) + scanner.process(inputImage) + .addOnSuccessListener { barcodes -> + continuation.resume( + barcodes.mapNotNull { barcode -> + (barcode.rawValue ?: barcode.displayValue)?.takeIf { it.isNotBlank() } + }, + ) + } + .addOnFailureListener { continuation.resumeWithException(it) } + } + + private fun invert(bitmap: Bitmap): Bitmap { + val newBitmap = Bitmap.createBitmap(bitmap.width, bitmap.height, Bitmap.Config.ARGB_8888) + val paint = Paint() + val matrixGrayscale = ColorMatrix().apply { setSaturation(0f) } + val matrixInvert = + ColorMatrix( + floatArrayOf( + -1.0f, 0.0f, 0.0f, 0.0f, 255.0f, + 0.0f, -1.0f, 0.0f, 0.0f, 255.0f, + 0.0f, 0.0f, -1.0f, 0.0f, 255.0f, + 0.0f, 0.0f, 0.0f, 1.0f, 0.0f, + ), + ) + matrixInvert.preConcat(matrixGrayscale) + paint.colorFilter = ColorMatrixColorFilter(matrixInvert) + Canvas(newBitmap).drawBitmap(bitmap, 0f, 0f, paint) + return newBitmap + } + + private fun monochrome( bitmap: Bitmap, - onSuccess: (String) -> Unit, - onFailure: (Exception?) -> Unit, - onComplete: (() -> Unit)? = null, - ) = coroutineScope.launch { - val url = - withContext(Dispatchers.IO) { - bitmap.decodeQR() + threshold: Int, + ): Bitmap { + val newBitmap = Bitmap.createBitmap(bitmap.width, bitmap.height, Bitmap.Config.ARGB_8888) + val paint = + Paint().apply { + colorFilter = ColorMatrixColorFilter(createThresholdMatrix(threshold)) } - onComplete?.invoke() - if (url != null) { - onSuccess(url) - } else { - onFailure(null) - } + Canvas(newBitmap).drawBitmap(bitmap, 0f, 0f, paint) + return newBitmap } + private fun createThresholdMatrix(threshold: Int) = + ColorMatrix( + floatArrayOf( + 85f, 85f, 85f, 0f, -255f * threshold, + 85f, 85f, 85f, 0f, -255f * threshold, + 85f, 85f, 85f, 0f, -255f * threshold, + 0f, 0f, 0f, 1f, 0f, + ), + ) + fun close() { scanner.closeSilently() } diff --git a/app/src/main/java/one/mixin/android/ui/qr/ScanFragment.kt b/app/src/main/java/one/mixin/android/ui/qr/ScanFragment.kt index 4754219a15..0e13779ec3 100644 --- a/app/src/main/java/one/mixin/android/ui/qr/ScanFragment.kt +++ b/app/src/main/java/one/mixin/android/ui/qr/ScanFragment.kt @@ -36,13 +36,15 @@ import one.mixin.android.ui.web.WebActivity import one.mixin.android.util.mlkit.scan.BaseCameraScanFragment import one.mixin.android.util.mlkit.scan.analyze.AnalyzeResult import one.mixin.android.util.mlkit.scan.analyze.Analyzer +import one.mixin.android.util.mlkit.scan.analyze.BarcodeScanItem import one.mixin.android.util.mlkit.scan.analyze.BarcodeResult import one.mixin.android.util.mlkit.scan.analyze.BarcodeScanningAnalyzer +import one.mixin.android.util.mlkit.scan.analyze.ScanCoordinateMapper +import one.mixin.android.util.mlkit.scan.analyze.ScanDecision +import one.mixin.android.util.mlkit.scan.analyze.ScanResultSelector import one.mixin.android.util.mlkit.scan.camera.config.AspectRatioCameraConfig -import one.mixin.android.util.mlkit.scan.utils.PointUtils import one.mixin.android.util.viewBinding import one.mixin.android.widget.ViewfinderView -import timber.log.Timber @AndroidEntryPoint class ScanFragment : BaseCameraScanFragment() { @@ -61,6 +63,7 @@ class ScanFragment : BaseCameraScanFragment() { private val forScanResult by lazy { requireArguments().getBoolean(ARGS_FOR_SCAN_RESULT) } private val fromShortcut by lazy { requireArguments().getBoolean(ARGS_SHORTCUT) } private lateinit var getMediaResult: ActivityResultLauncher + private val scanResultSelector = ScanResultSelector() override fun getLayoutId() = R.layout.fragment_scan @@ -81,6 +84,7 @@ class ScanFragment : BaseCameraScanFragment() { .setImageBitmap(null) binding.viewfinderView .showScanner() + scanResultSelector.reset() cameraScan.setAnalyzeImage(true) return } else { @@ -126,49 +130,78 @@ class ScanFragment : BaseCameraScanFragment() { super.initCameraScan() cameraScan.setPlayBeep(false) .setCameraConfig(AspectRatioCameraConfig(requireContext())) - .setVibrate(true) + .setVibrate(false) } override fun onScanResultCallback(result: AnalyzeResult) { - val width = result.bitmap?.width ?: return - val height = result.bitmap?.height ?: return - if (result.result?.content != null) { - cameraScan.setAnalyzeImage(false) - handleAnalysis(result.result?.content!!) - } else if (result.result?.barcodes != null) { - cameraScan.setAnalyzeImage(false) - Timber.e("$width - $height ${binding.viewfinderView.width} ${binding.viewfinderView.height}") - result.result?.barcodes?.let { results -> - binding.ivResult.setImageBitmap(previewView.bitmap) - val points = mutableListOf() - for (barcode in results) { - barcode.boundingBox?.let { box -> - val point = - PointUtils.transform( - box.centerX(), - box.centerY(), - width, - height, - binding.viewfinderView.width, - binding.viewfinderView.height, - ) - points.add(point) - } - } - Timber.e("$width - $height $points") - binding.viewfinderView.showResultPoints(points) - binding.viewfinderView.setOnItemClickListener( - object : ViewfinderView.OnItemClickListener { - override fun onItemClick(position: Int) { - handleAnalysis(results[position].displayValue!!) - } - }, - ) - if (points.size == 1) { - handleAnalysis(results[0].displayValue!!) - } + val items = result.result?.items.orEmpty() + when (val decision = scanResultSelector.select(items)) { + ScanDecision.Continue -> binding.viewfinderView.clearTrackedBounds() + is ScanDecision.Track -> trackBarcode(decision.item) + is ScanDecision.AutoHandle -> { + cameraScan.setAnalyzeImage(false) + binding.viewfinderView.clearTrackedBounds() + handleAnalysis(decision.text) } + is ScanDecision.ShowChoices -> showBarcodeChoices(decision.items) + } + } + + override fun onScanResultFailure() { + if (!binding.viewfinderView.isShowPoints) { + scanResultSelector.reset() + binding.viewfinderView.clearTrackedBounds() + } + } + + private fun trackBarcode(item: BarcodeScanItem) { + val box = item.boundingBox + if (box == null) { + binding.viewfinderView.clearTrackedBounds() + return + } + binding.viewfinderView.trackResultBounds( + ScanCoordinateMapper.transform( + box, + item.sourceWidth, + item.sourceHeight, + binding.viewfinderView.width, + binding.viewfinderView.height, + ), + ) + } + + private fun showBarcodeChoices(items: List) { + cameraScan.setAnalyzeImage(false) + binding.viewfinderView.clearTrackedBounds() + binding.ivResult.setImageBitmap(previewView.bitmap) + val choiceItems = mutableListOf() + val points = mutableListOf() + for (item in items) { + val box = item.boundingBox ?: continue + points.add( + ScanCoordinateMapper.transformCenter( + box, + item.sourceWidth, + item.sourceHeight, + binding.viewfinderView.width, + binding.viewfinderView.height, + ), + ) + choiceItems.add(item) } + if (points.isEmpty()) { + handleAnalysis(items.first().text) + return + } + binding.viewfinderView.showResultPoints(points) + binding.viewfinderView.setOnItemClickListener( + object : ViewfinderView.OnItemClickListener { + override fun onItemClick(position: Int) { + handleAnalysis(choiceItems[position].text) + } + }, + ) } private fun onMediaPicked(uri: Uri?) { diff --git a/app/src/main/java/one/mixin/android/util/mlkit/scan/analyze/BarcodeResult.kt b/app/src/main/java/one/mixin/android/util/mlkit/scan/analyze/BarcodeResult.kt index 167fed7baf..6722258c48 100644 --- a/app/src/main/java/one/mixin/android/util/mlkit/scan/analyze/BarcodeResult.kt +++ b/app/src/main/java/one/mixin/android/util/mlkit/scan/analyze/BarcodeResult.kt @@ -1,5 +1,25 @@ package one.mixin.android.util.mlkit.scan.analyze +import android.graphics.Point +import android.graphics.Rect import com.google.mlkit.vision.barcode.common.Barcode -class BarcodeResult(val barcodes: List?, val content: String?) +data class BarcodeScanItem( + val text: String, + val boundingBox: Rect?, + val cornerPoints: List, + val sourceWidth: Int, + val sourceHeight: Int, +) + +class BarcodeResult( + val items: List, + val content: String? = null, + val barcodes: List? = null, +) { + constructor(barcodes: List?, content: String?) : this( + items = emptyList(), + content = content, + barcodes = barcodes, + ) +} diff --git a/app/src/main/java/one/mixin/android/util/mlkit/scan/analyze/BarcodeScanningAnalyzer.kt b/app/src/main/java/one/mixin/android/util/mlkit/scan/analyze/BarcodeScanningAnalyzer.kt index 00272e2126..2d87fcc9bd 100644 --- a/app/src/main/java/one/mixin/android/util/mlkit/scan/analyze/BarcodeScanningAnalyzer.kt +++ b/app/src/main/java/one/mixin/android/util/mlkit/scan/analyze/BarcodeScanningAnalyzer.kt @@ -46,17 +46,46 @@ class BarcodeScanningAnalyzer : Analyzer { val inputImage = InputImage.fromBitmap(bitmap!!, 0) mDetector?.process(inputImage) ?.addOnSuccessListener { result: List? -> - if (result.isNullOrEmpty()) { + val items = + result.orEmpty().mapNotNull { barcode -> + val text = barcode.rawValue ?: barcode.displayValue ?: return@mapNotNull null + if (text.isBlank()) return@mapNotNull null + BarcodeScanItem( + text = text, + boundingBox = barcode.boundingBox, + cornerPoints = barcode.cornerPoints?.toList().orEmpty(), + sourceWidth = bitmap.width, + sourceHeight = bitmap.height, + ) + } + if (items.isEmpty()) { listener.onFailure() } else { - listener.onSuccess(AnalyzeResult(bitmap, BarcodeResult(result, null))) + listener.onSuccess(AnalyzeResult(bitmap, BarcodeResult(items, barcodes = result))) } }?.addOnFailureListener { e: Exception? -> listener.onFailure() } } catch (e: Exception) { val bitmap = BitmapUtils.getBitmap(imageProxy) val result = bitmap?.decodeQR() if (result != null) { - listener.onSuccess(AnalyzeResult(bitmap, BarcodeResult(null, result))) + listener.onSuccess( + AnalyzeResult( + bitmap, + BarcodeResult( + items = + listOf( + BarcodeScanItem( + text = result, + boundingBox = null, + cornerPoints = emptyList(), + sourceWidth = bitmap.width, + sourceHeight = bitmap.height, + ), + ), + content = result, + ), + ), + ) } else { listener.onFailure() } diff --git a/app/src/main/java/one/mixin/android/util/mlkit/scan/analyze/ScanCoordinateMapper.kt b/app/src/main/java/one/mixin/android/util/mlkit/scan/analyze/ScanCoordinateMapper.kt new file mode 100644 index 0000000000..623b2436fb --- /dev/null +++ b/app/src/main/java/one/mixin/android/util/mlkit/scan/analyze/ScanCoordinateMapper.kt @@ -0,0 +1,36 @@ +package one.mixin.android.util.mlkit.scan.analyze + +import android.graphics.Point +import android.graphics.Rect +import android.graphics.RectF + +object ScanCoordinateMapper { + fun transform( + rect: Rect, + sourceWidth: Int, + sourceHeight: Int, + destWidth: Int, + destHeight: Int, + ): RectF { + val ratio = maxOf(destWidth.toFloat() / sourceWidth, destHeight.toFloat() / sourceHeight) + val leftOffset = (sourceWidth * ratio - destWidth) / 2f + val topOffset = (sourceHeight * ratio - destHeight) / 2f + return RectF( + rect.left * ratio - leftOffset, + rect.top * ratio - topOffset, + rect.right * ratio - leftOffset, + rect.bottom * ratio - topOffset, + ) + } + + fun transformCenter( + rect: Rect, + sourceWidth: Int, + sourceHeight: Int, + destWidth: Int, + destHeight: Int, + ): Point { + val mapped = transform(rect, sourceWidth, sourceHeight, destWidth, destHeight) + return Point(mapped.centerX().toInt(), mapped.centerY().toInt()) + } +} diff --git a/app/src/main/java/one/mixin/android/util/mlkit/scan/analyze/ScanResultSelector.kt b/app/src/main/java/one/mixin/android/util/mlkit/scan/analyze/ScanResultSelector.kt new file mode 100644 index 0000000000..fe14530827 --- /dev/null +++ b/app/src/main/java/one/mixin/android/util/mlkit/scan/analyze/ScanResultSelector.kt @@ -0,0 +1,52 @@ +package one.mixin.android.util.mlkit.scan.analyze + +sealed class ScanDecision { + data object Continue : ScanDecision() + + data class Track(val item: BarcodeScanItem) : ScanDecision() + + data class AutoHandle(val text: String) : ScanDecision() + + data class ShowChoices(val items: List) : ScanDecision() +} + +class ScanResultSelector( + private val stableFrames: Int = 2, +) { + private var lastText: String? = null + private var stableCount = 0 + + fun select(items: List): ScanDecision { + return when (items.size) { + 0 -> { + reset() + ScanDecision.Continue + } + 1 -> selectSingle(items[0]) + else -> { + reset() + ScanDecision.ShowChoices(items) + } + } + } + + fun reset() { + lastText = null + stableCount = 0 + } + + private fun selectSingle(item: BarcodeScanItem): ScanDecision { + stableCount = + if (item.text == lastText) { + stableCount + 1 + } else { + 1 + } + lastText = item.text + return if (stableCount >= stableFrames) { + ScanDecision.AutoHandle(item.text) + } else { + ScanDecision.Track(item) + } + } +} diff --git a/app/src/main/java/one/mixin/android/widget/ViewfinderView.kt b/app/src/main/java/one/mixin/android/widget/ViewfinderView.kt index efb39f9d1a..4c92b5259d 100644 --- a/app/src/main/java/one/mixin/android/widget/ViewfinderView.kt +++ b/app/src/main/java/one/mixin/android/widget/ViewfinderView.kt @@ -14,6 +14,7 @@ import android.graphics.Rect import android.graphics.RectF import android.graphics.Shader.TileMode import android.graphics.drawable.Drawable +import android.os.SystemClock import android.text.Layout import android.text.StaticLayout import android.text.TextPaint @@ -33,6 +34,7 @@ import androidx.core.content.ContextCompat import androidx.core.graphics.toRectF import one.mixin.android.R import one.mixin.android.extension.dp +import kotlin.math.roundToInt class ViewfinderView @JvmOverloads @@ -94,6 +96,14 @@ class ViewfinderView private set private var onItemClickListener: OnItemClickListener? = null private var gestureDetector: GestureDetector? = null + private val trackedFromBounds = RectF() + private val trackedBounds = RectF() + private val trackedDrawBounds = RectF() + private val trackedFrame = Rect() + private var hasTrackedBounds = false + private var lastTrackedBoundsUpdate = 0L + private val trackedBoundsUpdateDuration = 75L + private val currentScannerFrame = Rect() @IntDef(ViewfinderStyle.CLASSIC, ViewfinderStyle.POPULAR) @Retention(AnnotationRetention.SOURCE) @@ -421,17 +431,7 @@ class ViewfinderView topOffsets.toInt() + frameHeight, ) if (laserStyle == LaserStyle.RADAR) { - this.radarPath.reset() - this.radarPath.addRoundRect( - frame.left.toFloat(), - frame.top.toFloat(), - frame.right.toFloat(), - frame.bottom.toFloat(), - cornerRadius, - cornerRadius, - Path.Direction.CW, - ) - this.radarPath.close() + updateRadarPath(frame) } } @@ -444,33 +444,56 @@ class ViewfinderView } return } - if (scannerStart == 0 || scannerEnd == 0) { - scannerStart = frame.top - scannerEnd = frame.bottom - scannerLineHeight + val drawingFrame = getTrackedFrame() ?: frame + prepareScannerFrame(drawingFrame) + if (laserStyle == LaserStyle.RADAR) { + updateRadarPath(drawingFrame) } when (viewfinderStyle) { ViewfinderStyle.CLASSIC -> { - drawExterior(canvas, frame, width, height) - drawLaserScanner(canvas, frame) - drawFrame(canvas, frame) - drawCorner(canvas, frame) - drawTextInfo(canvas, frame) + drawExterior(canvas, drawingFrame, width, height) + drawLaserScanner(canvas, drawingFrame) + drawFrame(canvas, drawingFrame) + drawCorner(canvas, drawingFrame) + drawTextInfo(canvas, drawingFrame) postInvalidateDelayed( scannerAnimationDelay.toLong(), - frame.left, - frame.top, - frame.right, - frame.bottom, + drawingFrame.left, + drawingFrame.top, + drawingFrame.right, + drawingFrame.bottom, ) } ViewfinderStyle.POPULAR -> { - drawLaserScanner(canvas, frame) + drawLaserScanner(canvas, drawingFrame) postInvalidateDelayed(scannerAnimationDelay.toLong()) } } } + private fun prepareScannerFrame(frame: Rect) { + if (scannerStart == 0 || scannerEnd == 0 || currentScannerFrame != frame) { + currentScannerFrame.set(frame) + scannerStart = frame.top + scannerEnd = frame.bottom - scannerLineHeight + } + } + + private fun updateRadarPath(frame: Rect) { + radarPath.reset() + radarPath.addRoundRect( + frame.left.toFloat(), + frame.top.toFloat(), + frame.right.toFloat(), + frame.bottom.toFloat(), + cornerRadius, + cornerRadius, + Path.Direction.CW, + ) + radarPath.close() + } + private fun drawTextInfo( canvas: Canvas, frame: Rect, @@ -604,7 +627,12 @@ class ViewfinderView canvas.clipPath(radarPath) paint.shader = null radarGrid.run { - canvas.drawBitmap(this, rFrame.left, scannerStart.toFloat() - height, paint) + canvas.drawBitmap( + this, + null, + RectF(rFrame.left, scannerStart - rFrame.height(), rFrame.right, scannerStart.toFloat()), + paint, + ) } paint.shader = LinearGradient( @@ -623,7 +651,7 @@ class ViewfinderView scannerStart = frame.top } radarFrame.run { - canvas.drawBitmap(this, rFrame.left, rFrame.top, paint) + canvas.drawBitmap(this, null, rFrame, paint) } } @@ -913,18 +941,88 @@ class ViewfinderView fun showScanner() { isShowPoints = false + clearTrackedBounds() invalidate() } fun showResultPoints(points: List?) { pointList = points isShowPoints = true + hasTrackedBounds = false zoomCount = 0 lastZoomRatio = 0f currentZoomRatio = 1f invalidate() } + fun trackResultBounds(bounds: RectF) { + if (width <= 0 || height <= 0) return + val paddedBounds = + RectF(bounds).apply { + inset(-24.dp.toFloat(), -16.dp.toFloat()) + left = left.coerceIn(0f, width.toFloat()) + top = top.coerceIn(0f, height.toFloat()) + right = right.coerceIn(left, width.toFloat()) + bottom = bottom.coerceIn(top, height.toFloat()) + } + val now = SystemClock.elapsedRealtime() + if (!hasTrackedBounds) { + trackedFromBounds.set(paddedBounds) + trackedBounds.set(paddedBounds) + lastTrackedBoundsUpdate = now - trackedBoundsUpdateDuration + } else { + getTrackedBounds(now) + trackedFromBounds.set(trackedDrawBounds) + trackedBounds.set(paddedBounds) + lastTrackedBoundsUpdate = now + } + hasTrackedBounds = true + isShowPoints = false + invalidate() + } + + fun clearTrackedBounds() { + if (!hasTrackedBounds) return + hasTrackedBounds = false + scannerStart = 0 + scannerEnd = 0 + invalidate() + } + + private fun getTrackedFrame(): Rect? { + if (!hasTrackedBounds) return null + val bounds = getTrackedBounds(SystemClock.elapsedRealtime()) + trackedFrame.set( + bounds.left.roundToInt(), + bounds.top.roundToInt(), + bounds.right.roundToInt(), + bounds.bottom.roundToInt(), + ) + return trackedFrame + } + + private fun getTrackedBounds(now: Long): RectF { + val t = + ((now - lastTrackedBoundsUpdate).toFloat() / trackedBoundsUpdateDuration) + .coerceIn(0f, 1f) + trackedDrawBounds.set( + lerp(trackedFromBounds.left, trackedBounds.left, t), + lerp(trackedFromBounds.top, trackedBounds.top, t), + lerp(trackedFromBounds.right, trackedBounds.right, t), + lerp(trackedFromBounds.bottom, trackedBounds.bottom, t), + ) + if (t < 1f) { + invalidate() + } + return trackedDrawBounds + } + + private fun lerp( + from: Float, + to: Float, + t: Float, + ) = from + (to - from) * t + fun setOnItemClickListener(listener: OnItemClickListener?) { onItemClickListener = listener } diff --git a/app/src/test/java/one/mixin/android/util/mlkit/scan/analyze/ScanResultSelectorTest.kt b/app/src/test/java/one/mixin/android/util/mlkit/scan/analyze/ScanResultSelectorTest.kt new file mode 100644 index 0000000000..29aeba176f --- /dev/null +++ b/app/src/test/java/one/mixin/android/util/mlkit/scan/analyze/ScanResultSelectorTest.kt @@ -0,0 +1,72 @@ +package one.mixin.android.util.mlkit.scan.analyze + +import android.graphics.Rect +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import kotlin.test.assertEquals +import kotlin.test.assertIs + +@RunWith(RobolectricTestRunner::class) +class ScanResultSelectorTest { + @Test + fun `single result tracks before stable auto handle`() { + val selector = ScanResultSelector(stableFrames = 2) + val item = scanItem("mixin://one") + + assertIs(selector.select(listOf(item))) + val decision = selector.select(listOf(item)) + + assertIs(decision) + assertEquals("mixin://one", decision.text) + } + + @Test + fun `different single result resets stability`() { + val selector = ScanResultSelector(stableFrames = 2) + + selector.select(listOf(scanItem("mixin://one"))) + val decision = selector.select(listOf(scanItem("mixin://two"))) + + assertIs(decision) + assertEquals("mixin://two", decision.item.text) + } + + @Test + fun `multiple results ask user to choose`() { + val selector = ScanResultSelector(stableFrames = 2) + val first = scanItem("mixin://one") + val second = scanItem("mixin://two") + + val decision = selector.select(listOf(first, second)) + + assertIs(decision) + assertEquals(listOf(first, second), decision.items) + } + + @Test + fun `center crop mapper transforms source rect to preview rect`() { + val rect = + ScanCoordinateMapper.transform( + Rect(100, 50, 300, 250), + sourceWidth = 400, + sourceHeight = 300, + destWidth = 800, + destHeight = 800, + ) + + assertEquals(133.33333f, rect.left, absoluteTolerance = 0.01f) + assertEquals(133.33333f, rect.top, absoluteTolerance = 0.01f) + assertEquals(666.6667f, rect.right, absoluteTolerance = 0.01f) + assertEquals(666.6667f, rect.bottom, absoluteTolerance = 0.01f) + } + + private fun scanItem(text: String) = + BarcodeScanItem( + text = text, + boundingBox = Rect(10, 20, 110, 120), + cornerPoints = emptyList(), + sourceWidth = 200, + sourceHeight = 200, + ) +}