Conversation
| if (delayMs > 0) delay(delayMs) | ||
| handleScan(data) | ||
| } finally { | ||
| activeScanInput = null |
There was a problem hiding this comment.
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:
it.cancel()— signals cancellation (cooperative, not immediate)activeScanInput = normalized— set for the new joblaunchScanreturns- Main dispatcher resumes the old job's cancelled continuation →
finallyruns →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:
bitkit-android/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt
Lines 1000 to 1013 in 5687321
Suggested fix: Guard the null-out in finally by checking if this coroutine is still the active scan job:
| 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) { |
There was a problem hiding this comment.
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:
bitkit-android/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt
Lines 990 to 993 in 5687321
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)| val scope = androidx.compose.runtime.rememberCoroutineScope() | ||
|
|
||
| if (drawerState != null || isPreview) { | ||
| val debouncedClick = rememberDebouncedClick { scope.launch { drawerState?.open() } } |
There was a problem hiding this comment.
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:
bitkit-android/app/src/main/java/to/bitkit/ui/scaffold/AppTopBar.kt
Lines 100 to 104 in de7f517
Fix: Hoist the rememberDebouncedClick call above the if block:
| 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 } |
There was a problem hiding this comment.
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:
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 |
There was a problem hiding this comment.
Bug: Race condition -- cancelled job's finally overwrites newly-set activeScanInput
When a second launchScan call arrives while the first job is active:
it.cancel()marks the old job as cancelling and returns immediately (non-blocking)activeScanInput = normalizedsets the new value on the calling thread- 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:
bitkit-android/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt
Lines 1003 to 1013 in de7f517
Fix: Guard the null-reset so it only clears when it still matches the current input:
| 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 { |
There was a problem hiding this comment.
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:
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) { |
There was a problem hiding this comment.
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.
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>
de7f517 to
714d493
Compare
Fixes #831
This PR:
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
ClickDebouncerutility with a 500ms window is added and exposed asrememberDebouncedClick. All interactive surfaces now route through debounced click handlers.The scan deduplication consolidates four separate
handleScan()call sites into a singlelaunchScan()method that tracks the active scan job and input, cancelling prior scans when a new one arrives and skipping exact duplicates.Preview
todoQA Notes
1. Button debouncing
2. Navigation debouncing
3. Tabbar buttons and drawer items
4. Scan deduplication - bug: #831
5. Overlay and sheet dismissal