From 309aa9d17a72135b992b6c29bdfec93a5c7aabef Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Mon, 21 Sep 2026 16:30:34 -0400 Subject: [PATCH] fix: stop the QR scanner pumping the zoom in and out AutoZoomSweep started hunting after 1.2s without a decode and then ran a full zoom-in/zoom-out triangle every 3s, indefinitely, until something decoded or the user pinched. 1.2s is barely longer than it takes a person to aim, so in normal use the camera simply pumped: reported from the tablet as "this strange zooming in and out effect". The intent -- a code too small in frame is the commonest reason a scan fails -- does not survive the cost. It made the viewfinder look broken during the seconds the reader was still aiming, and by moving the field of view every 100ms it also corrupted the overlay geometry: the analysis pipeline runs a frame or two behind the preview, so the highlight was computed from a frame captured at one zoom level and drawn over a preview showing another. The mismatch was worst at the moment of the first detection, when zoomRatio snaps back to 1f while the camera physically takes time to follow. Zoom is now only ever written by a pinch or by Reset, so the viewfinder does nothing the reader did not ask for. The zoom chip's condition loses its autoZoomEnabled half and simply shows whenever the reader has zoomed in. Camera scanning re-verified end to end on the emulator after the removal. Co-Authored-By: Claude Opus 5 (1M context) --- .../qrcode/scanner/QrScannerDialog.kt | 45 ------------------- .../qrcode/scanner/QrScannerOverlay.kt | 2 +- .../loggedIn/qrcode/scanner/QrScannerState.kt | 32 ------------- 3 files changed, 1 insertion(+), 78 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/qrcode/scanner/QrScannerDialog.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/qrcode/scanner/QrScannerDialog.kt index 88a373990e..0b133935ef 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/qrcode/scanner/QrScannerDialog.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/qrcode/scanner/QrScannerDialog.kt @@ -106,11 +106,9 @@ import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.quartz.utils.Log import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.delay -import kotlinx.coroutines.isActive import kotlinx.coroutines.launch import java.util.concurrent.Executors import java.util.concurrent.TimeUnit -import kotlin.math.min /** What the caller did with a decoded payload — and therefore what the scanner does next. */ enum class ScanOutcome { @@ -350,8 +348,6 @@ private fun QrCameraScanner( snapshotFlow { state.zoomRatio }.collect { runCatching { control.setZoomRatio(it) } } } - AutoZoomSweep(state = state, enabled = camera != null) - LaunchedEffect(state.notice) { if (state.notice != null) { delay(NOTICE_DURATION_MS) @@ -394,7 +390,6 @@ private fun QrCameraScanner( .pointerInput(Unit) { detectTransformGestures { _, _, zoom, _ -> if (zoom != 1f) { - state.onPinch() state.zoomRatio = (state.zoomRatio * zoom).coerceIn(1f, maxOf(1f, state.maxZoomRatio)) } } @@ -503,44 +498,6 @@ private fun ScanOverlayCanvas( } } -/** - * Sweeps the zoom while nothing is decoding. - * - * A code too small in frame to resolve is the single most common reason a scan fails, and no - * amount of decoder tuning fixes it — there are not enough pixels per module to read. Rather than - * leave the user to work that out and walk closer, the camera pushes in and back out on its own. - * It stops for good once the user pinches: they have taken over. - */ -@Composable -private fun AutoZoomSweep( - state: QrScannerState, - enabled: Boolean, -) { - LaunchedEffect(enabled, state.autoZoomEnabled) { - if (!enabled || !state.autoZoomEnabled) return@LaunchedEffect - - var sweep = 0f - while (isActive) { - delay(AUTO_ZOOM_TICK_MS) - - if (state.msSinceLastDetection < QrScannerState.AUTO_ZOOM_AFTER_MS) { - if (sweep != 0f) { - sweep = 0f - state.zoomRatio = 1f - } - continue - } - - val ceiling = min(state.maxZoomRatio, QrScannerState.AUTO_ZOOM_MAX) - if (ceiling <= 1.01f) continue - - sweep = (sweep + AUTO_ZOOM_TICK_MS.toFloat() / AUTO_ZOOM_PERIOD_MS) % 1f - val triangle = if (sweep < 0.5f) sweep * 2f else (1f - sweep) * 2f - state.zoomRatio = 1f + triangle * (ceiling - 1f) - } - } -} - /** The visible code nearest the tap, or null when the tap was not on one. */ private fun pickCandidateAt( tap: Offset, @@ -620,8 +577,6 @@ private val ANALYSIS_RESOLUTION = ResolutionStrategy(Size(1280, 720), ResolutionStrategy.FALLBACK_RULE_CLOSEST_HIGHER_THEN_LOWER), ).build() -private const val AUTO_ZOOM_TICK_MS = 100L -private const val AUTO_ZOOM_PERIOD_MS = 3_000f private const val NOTICE_DURATION_MS = 3_000L private const val FOCUS_RING_DURATION_MS = 800L private const val FOCUS_AUTO_CANCEL_SECONDS = 4L diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/qrcode/scanner/QrScannerOverlay.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/qrcode/scanner/QrScannerOverlay.kt index d420d78867..33a127f1df 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/qrcode/scanner/QrScannerOverlay.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/qrcode/scanner/QrScannerOverlay.kt @@ -187,7 +187,7 @@ fun QrScannerControls( ) } - if (!state.autoZoomEnabled && state.zoomRatio > 1.05f) { + if (state.zoomRatio > 1.05f) { ZoomChip( zoomRatio = state.zoomRatio, onReset = { state.resetZoom() }, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/qrcode/scanner/QrScannerState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/qrcode/scanner/QrScannerState.kt index 10c56702cd..1767e27e01 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/qrcode/scanner/QrScannerState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/qrcode/scanner/QrScannerState.kt @@ -23,7 +23,6 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.qrcode.scanner import androidx.compose.runtime.Stable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableFloatStateOf -import androidx.compose.runtime.mutableLongStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue @@ -63,13 +62,6 @@ class QrScannerState { var zoomRatio by mutableFloatStateOf(1f) var maxZoomRatio by mutableFloatStateOf(1f) - /** - * Auto-zoom stops for good the first time the user pinches. Someone who has framed the shot - * themselves does not want the camera arguing about it. - */ - var autoZoomEnabled by mutableStateOf(true) - private set - /** A payload we decoded but the caller could not use; drives the explain-what-happened sheet. */ var rejected by mutableStateOf(null) @@ -89,16 +81,6 @@ class QrScannerState { private var lastSubmittedAt = 0L private var darkSinceMs = 0L - /** Milliseconds since anything at all was decoded — what auto-zoom watches. */ - var msSinceLastDetection by mutableLongStateOf(0L) - private set - - private var lastDetectionMs = 0L - - fun onPinch() { - autoZoomEnabled = false - } - fun resetZoom() { zoomRatio = 1f } @@ -121,11 +103,6 @@ class QrScannerState { // whether or not the camera is busy reading something else. if (sequence.dropIfStale(nowMs)) sequenceProgress = null - // Seed the clock on the first frame. Left at zero, the very first empty frame would read - // as "nothing decoded since the epoch" and send auto-zoom hunting before the user has had - // a chance to aim. - if (lastDetectionMs == 0L) lastDetectionMs = nowMs - // Nothing is decided while the "we can't open this" sheet is up: the user is reading it, // and the offending code is very probably still sitting in front of the lens. if (rejected != null) { @@ -136,12 +113,9 @@ class QrScannerState { val found = scan.results.distinctBy { it.text } if (found.isEmpty()) { candidates = emptyList() - msSinceLastDetection = nowMs - lastDetectionMs return null } - lastDetectionMs = nowMs - msSinceLastDetection = 0 candidates = found // A multi-part code is never complete on its first part, so it can't be a single answer. @@ -232,11 +206,5 @@ class QrScannerState { /** Don't offer the torch for a thumb over the lens or a moment of shadow. */ const val DARK_DWELL_MS = 1_000L - - /** How long with nothing decoded before auto-zoom starts hunting. */ - const val AUTO_ZOOM_AFTER_MS = 1_200L - - /** Ceiling for the auto-zoom sweep — past this, focus and shake beat the extra reach. */ - const val AUTO_ZOOM_MAX = 2.5f } }