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) <noreply@anthropic.com>
This commit is contained in:
Vitor Pamplona
2026-09-21 16:30:34 -04:00
co-authored by Claude Opus 5
parent 7545e0e52f
commit 309aa9d17a
3 changed files with 1 additions and 78 deletions
@@ -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
@@ -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() },
@@ -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<ScannedPayload?>(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
}
}