Merge pull request #4158 from vitorpamplona/claude/scanner-and-cache-log-fixes

fix: stop the QR scanner pumping the zoom in and out
This commit is contained in:
Vitor Pamplona
2026-09-21 16:54:12 -04:00
committed by GitHub
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
}
}