Skip to content

fix: debounce clicks and deduplicate scans#832

Open
ovitrif wants to merge 10 commits intomasterfrom
fix/accidental-db-clicks
Open

fix: debounce clicks and deduplicate scans#832
ovitrif wants to merge 10 commits intomasterfrom
fix/accidental-db-clicks

Conversation

@ovitrif
Copy link
Collaborator

@ovitrif ovitrif commented Mar 9, 2026

Fixes #831

This PR:

  1. Adds click debouncing across all interactive UI elements to prevent accidental double-taps
  2. Ensures only one scan job runs at a time, deduplicating concurrent scan inputs from paste, scanner, deep links, and address entry

Description

Rapid taps on clickable elements (buttons, navigation icons, tabbar buttons, drawer items, overlays, etc) could trigger duplicate actions (double navigations, duplicate payments, etc.). A ClickDebouncer utility with a 500ms window is added and exposed as rememberDebouncedClick. All interactive surfaces now route through debounced click handlers.

The scan deduplication consolidates four separate handleScan() call sites into a single launchScan() method that tracks the active scan job and input, cancelling prior scans when a new one arrives and skipping exact duplicates.

Preview

todo

QA Notes

1. Button debouncing

  1. Open the app and navigate to any screen with buttons (e.g. Send, Receive, Settings)
  2. Rapidly multi-tap on any button
  3. Verify only one action is triggered (no duplicate actions)

2. Navigation debouncing

  1. Tap the back arrow or any navigation components rapidly (ie. Settings clickables)
  2. Verify no double navigation occurs

3. Tabbar buttons and drawer items

  1. Rapidly tap between bottom tabbar buttons
  2. Open the drawer and fast multi tap menu items
  3. Verify smooth single-action behavior

4. Scan deduplication - bug: #831

  1. Enable QuickPay & copy a LN invoice with amount below treshold
  2. Rapidly multi tap Paste buttons on scanner or Send sheet
  3. Verify only one scan processes (check logs for "Skipping duplicate scan")
  4. Verify scan and quickpay proceeds without issues

5. Overlay and sheet dismissal

  1. Tap the scrim/overlay area of a bottom sheet rapidly
  2. Verify it dismisses once without flicker

if (delayMs > 0) delay(delayMs)
handleScan(data)
} finally {
activeScanInput = null
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sequencing bug: finally of cancelled job nulls activeScanInput for the new job

When a prior job is cancelled and a new scan starts, the sequence on the main thread is:

  1. it.cancel() — signals cancellation (cooperative, not immediate)
  2. activeScanInput = normalized — set for the new job
  3. launchScan returns
  4. Main dispatcher resumes the old job's cancelled continuation → finally runs → activeScanInput = null

After step 4, activeScanInput is null instead of normalized, so the very next call to launchScan with the same input will not be recognized as a duplicate and will launch a redundant job — defeating the deduplication this PR introduces.

Reference:

activeScanJob?.let {
Logger.info("Cancelling prior scan for new '${source.label}': '$scanId'", context = TAG)
it.cancel()
}
activeScanInput = normalized
Logger.debug("Scan from '${source.label}': '$scanId'", context = TAG)
activeScanJob = viewModelScope.launch {
try {
if (delayMs > 0) delay(delayMs)
handleScan(data)
} finally {
activeScanInput = null
}

Suggested fix: Guard the null-out in finally by checking if this coroutine is still the active scan job:

Suggested change
activeScanInput = null
} finally {
if (activeScanJob == coroutineContext[Job]) activeScanInput = null
}

private fun onAddressContinue(data: String) {
viewModelScope.launch {
handleScan(data)
private fun launchScan(source: ScanSource, data: String, delayMs: Long = 0) {
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

CLAUDE.md: prefer kotlin.time.Duration for delays

The delayMs: Long parameter uses raw milliseconds. Per the project's CLAUDE.md: "ALWAYS prefer Kotlin Duration for timeouts and delays".

Reference:

private fun launchScan(source: ScanSource, data: String, delayMs: Long = 0) {
val normalized = data.removeLightningSchemes()
val scanId = if (data.length > 24) "${data.take(11)}${data.takeLast(11)}" else data

Consider changing the signature and call sites to use Duration:

private fun launchScan(source: ScanSource, data: String, delay: Duration = Duration.ZERO) {
    ...
    if (delay > Duration.ZERO) delay(delay)

And updating the call site:

launchScan(source = ScanSource.DEEPLINK, data = uri.toString(), delay = SCREEN_TRANSITION_DELAY_MS.milliseconds)

@ovitrif ovitrif self-assigned this Mar 9, 2026
@ovitrif ovitrif added this to the 2.2.0 milestone Mar 9, 2026
Copy link

@claude claude bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code review: Found 5 issues

Copy link

@claude claude bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code review

Found 5 issues: 2 Compose rules violations (conditional @Composable calls that can cause runtime crashes), 1 race condition in scan deduplication, and 2 CLAUDE.md violations (Duration preferred over raw Long).

val scope = androidx.compose.runtime.rememberCoroutineScope()

if (drawerState != null || isPreview) {
val debouncedClick = rememberDebouncedClick { scope.launch { drawerState?.open() } }
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug: @Composable called inside a conditional block

rememberDebouncedClick is annotated @Composable and internally calls remember { }. Invoking a composable inside an if block violates Compose's fundamental rule that composables must be called unconditionally and in the same order on every recomposition. If drawerState transitions between null and non-null across recompositions, the composition slot table becomes misaligned and will throw a runtime IllegalStateException.

Relevant code:

if (drawerState != null || isPreview) {
val debouncedClick = rememberDebouncedClick { scope.launch { drawerState?.open() } }
IconButton(
onClick = debouncedClick,

Fix: Hoist the rememberDebouncedClick call above the if block:

Suggested change
val debouncedClick = rememberDebouncedClick { scope.launch { drawerState?.open() } }
val debouncedDrawerClick = rememberDebouncedClick { scope.launch { drawerState?.open() } }
if (drawerState != null || isPreview) {
IconButton(
onClick = debouncedDrawerClick,

modifier = Modifier.testTag("FundTransfer")
)
if (balances.channelFundableBalance == 0uL) {
val debouncedClick = rememberDebouncedClick { showNoFundsAlert = true }
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug: @Composable called inside a conditional block

Same issue as in AppTopBar.kt: rememberDebouncedClick is a @Composable function (it calls remember { } internally) being invoked inside an if block. The condition balances.channelFundableBalance == 0uL can change across recompositions (e.g. as the wallet receives funds), which will misalign Compose's slot table and crash at runtime with IllegalStateException.

Relevant code:

)
if (balances.channelFundableBalance == 0uL) {
val debouncedClick = rememberDebouncedClick { showNoFundsAlert = true }
Box(
modifier = Modifier

Fix: Hoist the rememberDebouncedClick call above the if block and use the resulting lambda inside it.

if (delayMs > 0) delay(delayMs)
handleScan(data)
} finally {
activeScanInput = null
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug: Race condition -- cancelled job's finally overwrites newly-set activeScanInput

When a second launchScan call arrives while the first job is active:

  1. it.cancel() marks the old job as cancelling and returns immediately (non-blocking)
  2. activeScanInput = normalized sets the new value on the calling thread
  3. New viewModelScope.launch { } is queued

The old job's finally { activeScanInput = null } runs after step 2, on the coroutine dispatcher -- clearing the guard that was just set. A subsequent duplicate scan of the same new input then finds activeScanInput == null, bypasses the deduplication check, and launches a redundant job -- defeating the purpose of the whole mechanism.

Relevant code:

activeScanInput = normalized
Logger.debug("Scan from '${source.label}': '$scanId'", context = TAG)
activeScanJob = viewModelScope.launch {
try {
if (delayMs > 0) delay(delayMs)
handleScan(data)
} finally {
activeScanInput = null
}
}
}

Fix: Guard the null-reset so it only clears when it still matches the current input:

Suggested change
activeScanInput = null
activeScanJob = viewModelScope.launch {
try {
if (delayMs > 0) delay(delayMs)
handleScan(data)
} finally {
if (activeScanInput == normalized) activeScanInput = null
}
}

}

@Composable
fun rememberDebouncedClick(debounceMs: Long = CLICK_DEBOUNCE_MS, onClick: () -> Unit): () -> Unit {
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

CLAUDE.md: prefer Duration over raw Long for timeouts and delays

The debounce interval is expressed as a raw Long throughout ClickableAlpha.kt (CLICK_DEBOUNCE_MS = 500L, ClickDebouncer(debounceMs: Long), rememberDebouncedClick(debounceMs: Long = ...)).

CLAUDE.md rule: "ALWAYS prefer Kotlin Duration for timeouts and delays"

Relevant code:

private const val CLICK_DEBOUNCE_MS = 500L
private class ClickDebouncer(private val debounceMs: Long = CLICK_DEBOUNCE_MS) {
private var lastClickTime = 0L
fun tryClick(onClick: () -> Unit) {
val now = SystemClock.uptimeMillis()
if (now - lastClickTime >= debounceMs) {
lastClickTime = now
onClick()
}
}
}
@Composable
fun rememberDebouncedClick(debounceMs: Long = CLICK_DEBOUNCE_MS, onClick: () -> Unit): () -> Unit {
val debouncer = remember { ClickDebouncer(debounceMs) }
return { debouncer.tryClick(onClick) }

Fix: Use kotlin.time.Duration (e.g. 500.milliseconds) and Duration.inWholeMilliseconds in the SystemClock comparison.

private fun onAddressContinue(data: String) {
viewModelScope.launch {
handleScan(data)
private fun launchScan(source: ScanSource, data: String, delayMs: Long = 0) {
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

CLAUDE.md: prefer Duration over raw Long for delays

The new launchScan method passes delay values as a raw Long milliseconds parameter (delayMs: Long = 0), and the body calls delay(delayMs) directly.

CLAUDE.md rule: "ALWAYS prefer Kotlin Duration for timeouts and delays"

kotlinx.coroutines.delay has a Duration-accepting overload, so the fix is straightforward -- use e.g. SCREEN_TRANSITION_DELAY_MS.milliseconds at call sites.

ovitrif and others added 10 commits March 10, 2026 19:44
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@ovitrif ovitrif force-pushed the fix/accidental-db-clicks branch from de7f517 to 714d493 Compare March 10, 2026 18:45
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Bug]: Double tap on Paste invoice triggers duplicate QuickPay payment

1 participant