From 471270a396f2344421f8948b7c31f896c39e7bba Mon Sep 17 00:00:00 2001 From: Hadrian Burkhardt Date: Fri, 13 Feb 2026 03:13:29 +0100 Subject: [PATCH] guided scan zone --- ROADMAP.md | 11 +- .../com/clean/scanner/ui/ScannerViewModel.kt | 13 +- .../clean/scanner/ui/screens/ScannerScreen.kt | 447 ++++++++++++------ .../clean/scanner/ui/ScannerViewModelTest.kt | 27 +- 4 files changed, 347 insertions(+), 151 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index a8e188a..a8e5c88 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -21,6 +21,15 @@ - [x] Live readability feedback in camera - Show real-time hint when a code is visible vs readable. +- [x] Scan stability hardening +- Temporal box stabilization (smoothing + short persistence) and same-code holdoff to reduce repeat scans. + +- [x] Guided scan zone +- Only accept live camera scans when the readable code is inside the center aiming frame. + +- [x] Advanced gallery preview +- Open selected image in full focus mode, support pinch zoom/pan, and run live re-detection with overlay markers linked to list entries. + ## Mid-size Features (3-7 days) 1. Import history @@ -54,4 +63,4 @@ ## Suggested Next 2 1. Import history (CSV/JSON restore + merge policy) -2. Favorites / pin scans +2. Tagging (tags/folders + filter chips) diff --git a/app/src/main/java/com/clean/scanner/ui/ScannerViewModel.kt b/app/src/main/java/com/clean/scanner/ui/ScannerViewModel.kt index 3bc1d2c..ea4d435 100644 --- a/app/src/main/java/com/clean/scanner/ui/ScannerViewModel.kt +++ b/app/src/main/java/com/clean/scanner/ui/ScannerViewModel.kt @@ -30,18 +30,27 @@ class ScannerViewModel( private val saveScan: suspend (content: String, type: String) -> Unit, private val nowProvider: () -> Long = { System.currentTimeMillis() } ) : ViewModel() { + private companion object { + const val GENERAL_DEBOUNCE_MS = 800L + const val SAME_CODE_HOLDOFF_MS = 2500L + } + private val _uiState = MutableStateFlow(ScannerUiState()) val uiState: StateFlow = _uiState.asStateFlow() private val recentScanKeySet = LinkedHashSet(200) private val batchKeySet = LinkedHashSet(100) + private var lastAcceptedKey: String? = null + private var lastAcceptedTimestamp: Long = 0L fun onScan(result: ScanResult) { val now = nowProvider() val current = _uiState.value if (!current.analysisEnabled) return - if (now - current.lastScanTimestamp < 800) return val key = "${result.type}|${result.content}" + if (now - current.lastScanTimestamp < GENERAL_DEBOUNCE_MS) return + if (key == lastAcceptedKey && now - lastAcceptedTimestamp < SAME_CODE_HOLDOFF_MS) return + val isDuplicate = key in recentScanKeySet recentScanKeySet.remove(key) recentScanKeySet.add(key) @@ -49,6 +58,8 @@ class ScannerViewModel( recentScanKeySet.remove(recentScanKeySet.first()) } val updatedRecent = recentScanKeySet.toList().asReversed() + lastAcceptedKey = key + lastAcceptedTimestamp = now _uiState.value = if (current.batchMode) { val updatedBatch = if (key in batchKeySet) { diff --git a/app/src/main/java/com/clean/scanner/ui/screens/ScannerScreen.kt b/app/src/main/java/com/clean/scanner/ui/screens/ScannerScreen.kt index 4975188..7a96712 100644 --- a/app/src/main/java/com/clean/scanner/ui/screens/ScannerScreen.kt +++ b/app/src/main/java/com/clean/scanner/ui/screens/ScannerScreen.kt @@ -6,6 +6,7 @@ import android.content.Intent import android.graphics.Bitmap import android.graphics.BitmapFactory import android.graphics.ImageDecoder +import android.graphics.Paint import android.media.AudioManager import android.media.ToneGenerator import android.net.Uri @@ -17,6 +18,7 @@ import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.foundation.Canvas import androidx.compose.foundation.Image import androidx.compose.foundation.background +import androidx.compose.foundation.gestures.detectTransformGestures import androidx.compose.foundation.horizontalScroll import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box @@ -59,11 +61,17 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.geometry.CornerRadius +import androidx.compose.ui.geometry.Offset import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Path import androidx.compose.ui.graphics.asImageBitmap +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.graphics.nativeCanvas import androidx.compose.ui.graphics.drawscope.Stroke import androidx.compose.ui.hapticfeedback.HapticFeedbackType +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.layout.onSizeChanged import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalHapticFeedback @@ -71,6 +79,7 @@ import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.IntSize import androidx.compose.ui.unit.dp import androidx.core.app.ActivityCompat import androidx.core.content.ContextCompat @@ -89,8 +98,11 @@ import com.google.mlkit.vision.barcode.common.Barcode import com.google.mlkit.vision.common.InputImage import java.text.DateFormat import java.util.Date +import kotlin.coroutines.resume +import kotlin.coroutines.resumeWithException import kotlin.math.max -import kotlin.math.min +import kotlinx.coroutines.delay +import kotlinx.coroutines.suspendCancellableCoroutine private data class GalleryScanCandidate( val result: ScanResult, @@ -133,7 +145,6 @@ fun ScannerScreen( var showRiskWarning by remember { mutableStateOf(false) } var pendingOpenUrl by remember { mutableStateOf(null) } var showImageScanFailed by remember { mutableStateOf(false) } - var showNoCodeInImage by remember { mutableStateOf(false) } var imageScanCandidates by remember { mutableStateOf>(emptyList()) } var imageScanPreviewUri by remember { mutableStateOf(null) } var hasPotentialInView by remember { mutableStateOf(false) } @@ -173,6 +184,8 @@ fun ScannerScreen( showImageScanFailed = true return@rememberLauncherForActivityResult } + imageScanPreviewUri = uri + imageScanCandidates = emptyList() imageScanner.process(image) .addOnSuccessListener { barcodes -> @@ -198,14 +211,7 @@ fun ScannerScreen( box = normalizedBox ) }.distinctBy { "${it.result.type}|${it.result.content}" } - - when (candidates.size) { - 0 -> showNoCodeInImage = true - else -> { - imageScanPreviewUri = uri - imageScanCandidates = candidates - } - } + imageScanCandidates = candidates } .addOnFailureListener { showImageScanFailed = true @@ -252,6 +258,7 @@ fun ScannerScreen( val density = LocalDensity.current val viewW = with(density) { maxWidth.toPx() } val viewH = with(density) { maxHeight.toPx() } + val galleryOpen = imageScanPreviewUri != null val aimW = viewW * 0.62f val aimH = with(density) { 200.dp.toPx() } val aimLeft = (viewW - aimW) / 2f @@ -259,7 +266,7 @@ fun ScannerScreen( val aimRight = aimLeft + aimW val aimBottom = aimTop + aimH - if (cameraGranted) { + if (cameraGranted && !galleryOpen) { CameraPreview( modifier = Modifier.fillMaxSize(), analysisEnabled = analysisEnabled, @@ -443,7 +450,7 @@ fun ScannerScreen( .align(Alignment.BottomCenter) .padding(bottom = if (batchMode) 12.dp else 80.dp) ) - } else { + } else if (!galleryOpen) { PermissionContent( showSettingsHint = showSettingsHint, onRequestPermission = { permissionLauncher.launch(Manifest.permission.CAMERA) }, @@ -455,6 +462,12 @@ fun ScannerScreen( context.startActivity(intent) } ) + } else { + Box( + modifier = Modifier + .fillMaxSize() + .background(Color.Black) + ) } if (lastResult != null && !batchMode) { @@ -567,19 +580,7 @@ fun ScannerScreen( ) } - if (showNoCodeInImage) { - AlertDialog( - onDismissRequest = { showNoCodeInImage = false }, - text = { Text(stringResource(R.string.no_code_found_in_image)) }, - confirmButton = { - TextButton(onClick = { showNoCodeInImage = false }) { - Text(stringResource(R.string.confirm)) - } - } - ) - } - - if (imageScanCandidates.isNotEmpty()) { + if (imageScanPreviewUri != null) { GalleryScanPreviewDialog( imageUri = imageScanPreviewUri, candidates = imageScanCandidates, @@ -733,128 +734,6 @@ private fun buildBatchExport(results: List): String { } } -@Composable -private fun GalleryScanPreviewDialog( - imageUri: Uri?, - candidates: List, - onPick: (GalleryScanCandidate) -> Unit, - onDismiss: () -> Unit -) { - val context = LocalContext.current - val bitmap = remember(imageUri) { - imageUri?.let { loadBitmapFromUri(context, it) } - } - - AlertDialog( - onDismissRequest = onDismiss, - title = { Text(stringResource(R.string.image_scan_pick_title, candidates.size)) }, - text = { - Column( - modifier = Modifier - .fillMaxWidth() - .verticalScroll(rememberScrollState()), - verticalArrangement = Arrangement.spacedBy(8.dp) - ) { - if (bitmap != null) { - Box( - modifier = Modifier - .fillMaxWidth() - .height(260.dp) - .background(Color.Black.copy(alpha = 0.32f), RoundedCornerShape(12.dp)), - contentAlignment = Alignment.Center - ) { - Image( - bitmap = bitmap.asImageBitmap(), - contentDescription = stringResource(R.string.scan_from_image), - modifier = Modifier - .fillMaxWidth() - .height(260.dp), - contentScale = androidx.compose.ui.layout.ContentScale.Fit - ) - Canvas( - modifier = Modifier - .fillMaxWidth() - .height(260.dp) - ) { - val bitmapW = bitmap.width.toFloat() - val bitmapH = bitmap.height.toFloat() - if (bitmapW <= 0f || bitmapH <= 0f) return@Canvas - - val scale = min(size.width / bitmapW, size.height / bitmapH) - val drawW = bitmapW * scale - val drawH = bitmapH * scale - val offsetX = (size.width - drawW) / 2f - val offsetY = (size.height - drawH) / 2f - - candidates.forEach { candidate -> - val box = candidate.box ?: return@forEach - val color = Color(0xFF4AE3A3).copy(alpha = 0.96f) - val points = box.corners.map { p -> - androidx.compose.ui.geometry.Offset( - x = offsetX + (p.x * drawW), - y = offsetY + (p.y * drawH) - ) - } - if (points.size >= 4) { - val path = Path().apply { - moveTo(points.first().x, points.first().y) - points.drop(1).forEach { pt -> lineTo(pt.x, pt.y) } - close() - } - drawPath(path = path, color = color, style = Stroke(width = 4f)) - } else { - val left = offsetX + (box.left * drawW) - val top = offsetY + (box.top * drawH) - val right = offsetX + (box.right * drawW) - val bottom = offsetY + (box.bottom * drawH) - if (right > left && bottom > top) { - drawRoundRect( - color = color, - topLeft = androidx.compose.ui.geometry.Offset(left, top), - size = androidx.compose.ui.geometry.Size(right - left, bottom - top), - cornerRadius = CornerRadius(10f, 10f), - style = Stroke(width = 4f) - ) - } - } - } - } - } - } - - Text(text = stringResource(R.string.image_scan_pick_subtitle)) - candidates.forEach { candidate -> - TextButton( - onClick = { onPick(candidate) }, - modifier = Modifier.fillMaxWidth() - ) { - Column(modifier = Modifier.fillMaxWidth()) { - Text( - text = candidate.result.type, - textAlign = TextAlign.Start, - modifier = Modifier.fillMaxWidth() - ) - Text( - text = candidate.result.content, - maxLines = 2, - overflow = TextOverflow.Ellipsis, - textAlign = TextAlign.Start, - modifier = Modifier.fillMaxWidth() - ) - } - } - } - } - }, - confirmButton = {}, - dismissButton = { - TextButton(onClick = onDismiss) { - Text(stringResource(R.string.cancel)) - } - } - ) -} - private fun loadBitmapFromUri(context: android.content.Context, uri: Uri): Bitmap? { return try { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { @@ -870,6 +749,280 @@ private fun loadBitmapFromUri(context: android.content.Context, uri: Uri): Bitma } } +private suspend fun detectBarcodes( + scanner: com.google.mlkit.vision.barcode.BarcodeScanner, + image: InputImage +): List = suspendCancellableCoroutine { cont -> + scanner.process(image) + .addOnSuccessListener { barcodes -> + if (cont.isActive) cont.resume(barcodes) + } + .addOnFailureListener { error -> + if (cont.isActive) cont.resumeWithException(error) + } +} + +@Composable +private fun GalleryScanPreviewDialog( + imageUri: Uri?, + candidates: List, + onPick: (GalleryScanCandidate) -> Unit, + onDismiss: () -> Unit +) { + val context = LocalContext.current + val bitmap = remember(imageUri) { imageUri?.let { loadBitmapFromUri(context, it) } } + var liveCandidates by remember(imageUri, candidates) { mutableStateOf(candidates) } + var zoom by remember(imageUri) { mutableStateOf(1f) } + var pan by remember(imageUri) { mutableStateOf(Offset.Zero) } + var viewportSize by remember { mutableStateOf(IntSize.Zero) } + var scanTick by remember { mutableStateOf(0) } + val markerPaint = remember { + Paint().apply { + color = android.graphics.Color.WHITE + textAlign = Paint.Align.CENTER + textSize = 34f + isAntiAlias = true + isFakeBoldText = true + } + } + val scanner = remember { + BarcodeScanning.getClient( + BarcodeScannerOptions.Builder() + .setBarcodeFormats(Barcode.FORMAT_ALL_FORMATS) + .enableAllPotentialBarcodes() + .build() + ) + } + + DisposableEffect(Unit) { + onDispose { scanner.close() } + } + + LaunchedEffect(bitmap, viewportSize, zoom, pan, scanTick) { + val bmp = bitmap ?: return@LaunchedEffect + val vw = viewportSize.width.toFloat() + val vh = viewportSize.height.toFloat() + if (vw <= 0f || vh <= 0f) return@LaunchedEffect + + delay(120) + + val imgW = bmp.width.toFloat() + val imgH = bmp.height.toFloat() + if (imgW <= 1f || imgH <= 1f) return@LaunchedEffect + + val baseScale = max(vw / imgW, vh / imgH) + val effectiveScale = (baseScale * zoom).coerceAtLeast(0.01f) + val cx = vw * 0.5f + val cy = vh * 0.5f + + fun screenToImageX(screenX: Float): Float { + return ((screenX - cx - pan.x) / effectiveScale) + (imgW * 0.5f) + } + fun screenToImageY(screenY: Float): Float { + return ((screenY - cy - pan.y) / effectiveScale) + (imgH * 0.5f) + } + + val left = screenToImageX(0f).coerceIn(0f, imgW - 1f) + val right = screenToImageX(vw).coerceIn(0f, imgW - 1f) + val top = screenToImageY(0f).coerceIn(0f, imgH - 1f) + val bottom = screenToImageY(vh).coerceIn(0f, imgH - 1f) + + val cropLeft = minOf(left, right).toInt() + val cropTop = minOf(top, bottom).toInt() + val cropW = (kotlin.math.abs(right - left)).toInt().coerceAtLeast(8) + val cropH = (kotlin.math.abs(bottom - top)).toInt().coerceAtLeast(8) + val boundedW = cropW.coerceAtMost(bmp.width - cropLeft) + val boundedH = cropH.coerceAtMost(bmp.height - cropTop) + if (boundedW <= 4 || boundedH <= 4) return@LaunchedEffect + + val cropped = Bitmap.createBitmap(bmp, cropLeft, cropTop, boundedW, boundedH) + val barcodes = try { + detectBarcodes(scanner, InputImage.fromBitmap(cropped, 0)) + } catch (_: Exception) { + emptyList() + } + + val live = barcodes.mapNotNull { barcode -> + val raw = barcode.rawValue?.takeIf { it.isNotBlank() } ?: return@mapNotNull null + val normalizedBox = barcode.boundingBox?.let { bounds -> + val leftN = ((bounds.left + cropLeft) / imgW).coerceIn(0f, 1f) + val topN = ((bounds.top + cropTop) / imgH).coerceIn(0f, 1f) + val rightN = ((bounds.right + cropLeft) / imgW).coerceIn(0f, 1f) + val bottomN = ((bounds.bottom + cropTop) / imgH).coerceIn(0f, 1f) + val corners = barcode.cornerPoints?.map { p -> + com.clean.scanner.data.scanner.DetectionPoint( + x = ((p.x + cropLeft) / imgW).coerceIn(0f, 1f), + y = ((p.y + cropTop) / imgH).coerceIn(0f, 1f) + ) + } ?: emptyList() + DetectionBox(leftN, topN, rightN, bottomN, corners) + } + GalleryScanCandidate( + result = ScanResult(content = raw, type = barcode.valueType.toHumanType()), + box = normalizedBox + ) + }.distinctBy { "${it.result.type}|${it.result.content}" } + + liveCandidates = live + } + + AlertDialog( + onDismissRequest = onDismiss, + title = { Text(stringResource(R.string.image_scan_pick_title, liveCandidates.size)) }, + text = { + Column( + modifier = Modifier + .fillMaxWidth() + .verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + if (bitmap != null) { + Box( + modifier = Modifier + .fillMaxWidth() + .height(260.dp) + .onSizeChanged { + viewportSize = it + scanTick++ + } + .pointerInput(bitmap) { + detectTransformGestures { _, panChange, zoomChange, _ -> + zoom = (zoom * zoomChange).coerceIn(1f, 6f) + pan += panChange + scanTick++ + } + } + .background(Color.Black.copy(alpha = 0.32f), RoundedCornerShape(12.dp)), + contentAlignment = Alignment.Center + ) { + Image( + bitmap = bitmap.asImageBitmap(), + contentDescription = stringResource(R.string.scan_from_image), + modifier = Modifier + .fillMaxWidth() + .height(260.dp) + .graphicsLayer { + scaleX = zoom + scaleY = zoom + translationX = pan.x + translationY = pan.y + }, + contentScale = ContentScale.Crop + ) + Canvas( + modifier = Modifier + .fillMaxWidth() + .height(260.dp) + ) { + val imageW = bitmap.width.toFloat() + val imageH = bitmap.height.toFloat() + if (imageW <= 0f || imageH <= 0f) return@Canvas + + val baseScale = max(size.width / imageW, size.height / imageH) + val effectiveScale = baseScale * zoom + val cx = size.width * 0.5f + val cy = size.height * 0.5f + + fun imageToScreen(ix: Float, iy: Float): Offset { + val sx = cx + ((ix - imageW * 0.5f) * effectiveScale) + pan.x + val sy = cy + ((iy - imageH * 0.5f) * effectiveScale) + pan.y + return Offset(sx, sy) + } + + liveCandidates.forEachIndexed { index, candidate -> + val box = candidate.box ?: return@forEachIndexed + val color = Color(0xFF4AE3A3).copy(alpha = 0.96f) + val points = box.corners.map { p -> + imageToScreen(p.x * imageW, p.y * imageH) + } + if (points.size >= 4) { + val path = Path().apply { + moveTo(points.first().x, points.first().y) + points.drop(1).forEach { pt -> lineTo(pt.x, pt.y) } + close() + } + drawPath(path = path, color = color, style = Stroke(width = 4f)) + } else { + val lt = imageToScreen(box.left * imageW, box.top * imageH) + val rb = imageToScreen(box.right * imageW, box.bottom * imageH) + val left = minOf(lt.x, rb.x) + val top = minOf(lt.y, rb.y) + val right = maxOf(lt.x, rb.x) + val bottom = maxOf(lt.y, rb.y) + if (right > left && bottom > top) { + drawRoundRect( + color = color, + topLeft = androidx.compose.ui.geometry.Offset(left, top), + size = androidx.compose.ui.geometry.Size(right - left, bottom - top), + cornerRadius = CornerRadius(10f, 10f), + style = Stroke(width = 4f) + ) + } + } + + val center = if (points.isNotEmpty()) { + val sx = points.sumOf { it.x.toDouble() }.toFloat() / points.size + val sy = points.sumOf { it.y.toDouble() }.toFloat() / points.size + Offset(sx, sy) + } else { + imageToScreen( + (box.left + box.right) * 0.5f * imageW, + (box.top + box.bottom) * 0.5f * imageH + ) + } + drawCircle( + color = Color.Black.copy(alpha = 0.65f), + radius = 16f, + center = center + ) + drawContext.canvas.nativeCanvas.drawText( + "${index + 1}", + center.x, + center.y + 11f, + markerPaint + ) + } + } + } + } + + if (liveCandidates.isEmpty()) { + Text(text = stringResource(R.string.no_code_found_in_image)) + } else { + Text(text = stringResource(R.string.image_scan_pick_subtitle)) + liveCandidates.forEachIndexed { index, candidate -> + TextButton( + onClick = { onPick(candidate) }, + modifier = Modifier.fillMaxWidth() + ) { + Column(modifier = Modifier.fillMaxWidth()) { + Text( + text = "${index + 1}. ${candidate.result.type}", + textAlign = TextAlign.Start, + modifier = Modifier.fillMaxWidth() + ) + Text( + text = candidate.result.content, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + textAlign = TextAlign.Start, + modifier = Modifier.fillMaxWidth() + ) + } + } + } + } + } + }, + confirmButton = {}, + dismissButton = { + TextButton(onClick = onDismiss) { + Text(stringResource(R.string.cancel)) + } + } + ) +} + @Composable private fun PermissionContent( showSettingsHint: Boolean, diff --git a/app/src/test/java/com/clean/scanner/ui/ScannerViewModelTest.kt b/app/src/test/java/com/clean/scanner/ui/ScannerViewModelTest.kt index 5c1ea68..677d12e 100644 --- a/app/src/test/java/com/clean/scanner/ui/ScannerViewModelTest.kt +++ b/app/src/test/java/com/clean/scanner/ui/ScannerViewModelTest.kt @@ -50,7 +50,7 @@ class ScannerViewModelTest { viewModel.onScan(result) viewModel.resumeScanning() - now = 2_000L + now = 4_000L viewModel.onScan(result) advanceUntilIdle() @@ -75,7 +75,7 @@ class ScannerViewModelTest { now = 2_000L viewModel.onScan(ScanResult(content = "B", type = "Text")) - now = 3_000L + now = 3_600L viewModel.onScan(ScanResult(content = "A", type = "Text")) advanceUntilIdle() @@ -92,4 +92,27 @@ class ScannerViewModelTest { assertTrue(viewModel.uiState.value.batchResults.isEmpty()) assertEquals(3, saved.size) } + + @Test + fun sameCodeWithinHoldoff_isIgnored() = runTest { + val saved = mutableListOf>() + var now = 1_000L + val viewModel = ScannerViewModel( + saveScan = { content, type -> saved += content to type }, + nowProvider = { now } + ) + + val result = ScanResult(content = "HOLD", type = "Text") + viewModel.setBatchMode(true) + viewModel.onScan(result) + + now = 2_000L + viewModel.onScan(result) + advanceUntilIdle() + + val state = viewModel.uiState.value + assertEquals(1, state.scanFeedbackNonce) + assertEquals(1, saved.size) + assertEquals(1, state.batchResults.size) + } }