From a9c03ffddfe66824cfa0dd4759ddd1bbadc7aa70 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Mon, 5 Oct 2026 08:36:08 -0400 Subject: [PATCH 1/5] fix(cash-link): hold the claim guard on the collect-anyway reclaim The own-cash prompt's "Collect" called claimGiftCard directly. By then the first attempt's onError had already cleared giftCardClaimInProgress, so tapping the same voucher again during the reclaim started a second claim of the same card. claimGiftCard now takes the slot itself and returns whether the claim started. The routed deeplink event is recorded only when it did, so a dropped duplicate is not counted as routed. --- .../internal/delegates/CashLinkDelegate.kt | 14 +++++-- .../delegates/CashLinkDelegateTest.kt | 39 +++++++++++++++++++ 2 files changed, 49 insertions(+), 4 deletions(-) diff --git a/apps/flipcash/shared/session/src/main/kotlin/com/flipcash/app/session/internal/delegates/CashLinkDelegate.kt b/apps/flipcash/shared/session/src/main/kotlin/com/flipcash/app/session/internal/delegates/CashLinkDelegate.kt index 023df18a8f..1c846749ad 100644 --- a/apps/flipcash/shared/session/src/main/kotlin/com/flipcash/app/session/internal/delegates/CashLinkDelegate.kt +++ b/apps/flipcash/shared/session/src/main/kotlin/com/flipcash/app/session/internal/delegates/CashLinkDelegate.kt @@ -126,18 +126,23 @@ class CashLinkDelegate @Inject constructor( return } - if (giftCardClaimInProgress.value == null) { - giftCardClaimInProgress.value = entropy + if (claimGiftCard(owner = owner, entropy = entropy, claimIfOwned = false)) { analytics.track(DeeplinkEvents.routed(DeeplinkType.CashLink().analytics, error = null)) - claimGiftCard(owner = owner, entropy = entropy, claimIfOwned = false) } } + /** + * Starts a claim of [entropy] unless one is already running, and returns whether it started. + * Every way into a claim holds the slot, including the reclaim from the own-cash prompt, which + * runs after the first attempt has already cleared it. + */ private fun claimGiftCard( owner: AccountCluster, entropy: String, claimIfOwned: Boolean - ) { + ): Boolean { + if (!giftCardClaimInProgress.compareAndSet(null, entropy)) return false + trace( tag = "Session", message = "Claiming gift card: $entropy", @@ -225,5 +230,6 @@ class CashLinkDelegate @Inject constructor( } } ) + return true } } diff --git a/apps/flipcash/shared/session/src/test/kotlin/com/flipcash/app/session/internal/delegates/CashLinkDelegateTest.kt b/apps/flipcash/shared/session/src/test/kotlin/com/flipcash/app/session/internal/delegates/CashLinkDelegateTest.kt index 75c16ef281..6623bb3976 100644 --- a/apps/flipcash/shared/session/src/test/kotlin/com/flipcash/app/session/internal/delegates/CashLinkDelegateTest.kt +++ b/apps/flipcash/shared/session/src/test/kotlin/com/flipcash/app/session/internal/delegates/CashLinkDelegateTest.kt @@ -291,6 +291,45 @@ class CashLinkDelegateTest { assertEquals("Already Collected", BottomBarManager.messages.value.single().title) } + @Test + fun `a re-tap during the collect-anyway reclaim does not start a second claim`() = runTest { + every { resources.getString(R.string.action_collect) } returns "Collect" + val delegate = createDelegate() + + val onError = slot<(Throwable) -> Unit>() + delegate.openCashLink("ownEntropy") + verify { + billController.receiveGiftCard( + entropy = any(), + owner = any(), + claimIfOwned = false, + onReceived = any(), + onError = capture(onError), + ) + } + onError.captured.invoke(ReceiveGiftTransactorError.UsersGiftCard()) + runCurrent() + + // "Collect" on the own-cash prompt starts the reclaim, and a second tap on the same link + // while it runs must not start another. + BottomBarManager.messages.value.single().actions + .single { it.text.text == "Collect" } + .onClick() + delegate.openCashLink("ownEntropy") + + verify(exactly = 2) { + billController.receiveGiftCard( + entropy = any(), + owner = any(), + claimIfOwned = any(), + onReceived = any(), + onError = any(), + ) + } + // Only the claim that started is on record as routed; the dropped tap is not. + assertEquals(1, analytics.events.count { it == DeeplinkEvents.routed("CashLink", error = null) }) + } + @Test fun `openCashLink clears bottom bar before processing`() = runTest { // Add a message to BottomBarManager first From f64ef7caafbe4914a91b8a6c6ce36b6b134b22db Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Mon, 5 Oct 2026 08:37:14 -0400 Subject: [PATCH 2/5] feat(cash-link): expose the in-flight claim on CashLinkClaims The chat voucher only learns about a claim when it settles, so it keeps offering "Tap to claim" while the claim runs. claimInFlight publishes the delegate's existing guard slot so a surface can show that link as claiming. The messenger tests' relaxed CashLinkClaims mocks now return a real StateFlow for it, ahead of ChatViewModel collecting it. --- .../internal/ChatGroupAnalyticsTest.kt | 4 +++- .../internal/ChatGroupCashLinkTest.kt | 4 +++- .../internal/ChatSendFailureAnalyticsTest.kt | 4 +++- .../flipcash/app/session/SessionController.kt | 6 +++++ .../internal/delegates/CashLinkDelegate.kt | 3 +++ .../delegates/CashLinkDelegateTest.kt | 24 +++++++++++++++++++ 6 files changed, 42 insertions(+), 3 deletions(-) diff --git a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatGroupAnalyticsTest.kt b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatGroupAnalyticsTest.kt index f09f482c9b..7e4e0b4019 100644 --- a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatGroupAnalyticsTest.kt +++ b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatGroupAnalyticsTest.kt @@ -94,7 +94,9 @@ class ChatGroupAnalyticsTest { private val userFlags = mockk(relaxed = true) private val linkCardClassifier = mockk(relaxed = true) private val linkCardResolver = mockk(relaxed = true) - private val cashLinkClaims = mockk(relaxed = true) + private val cashLinkClaims = mockk(relaxed = true) { + every { claimInFlight } returns MutableStateFlow(null) + } private val chatDraftStore = mockk(relaxed = true) private val recentReactionsStore = mockk(relaxed = true) private val emojiCatalogLoader = mockk(relaxed = true) diff --git a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatGroupCashLinkTest.kt b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatGroupCashLinkTest.kt index 298d75c44d..a5cbcc9cfd 100644 --- a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatGroupCashLinkTest.kt +++ b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatGroupCashLinkTest.kt @@ -91,7 +91,9 @@ class ChatGroupCashLinkTest { private val userFlags = mockk(relaxed = true) private val linkCardClassifier = mockk(relaxed = true) private val linkCardResolver = mockk(relaxed = true) - private val cashLinkClaims = mockk(relaxed = true) + private val cashLinkClaims = mockk(relaxed = true) { + every { claimInFlight } returns MutableStateFlow(null) + } private val chatCashLinks = mockk(relaxed = true) private val chatDraftStore = mockk(relaxed = true) diff --git a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatSendFailureAnalyticsTest.kt b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatSendFailureAnalyticsTest.kt index 60d5a32d54..eb658de072 100644 --- a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatSendFailureAnalyticsTest.kt +++ b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatSendFailureAnalyticsTest.kt @@ -89,7 +89,9 @@ class ChatSendFailureAnalyticsTest { private val userFlags = mockk(relaxed = true) private val linkCardClassifier = mockk(relaxed = true) private val linkCardResolver = mockk(relaxed = true) - private val cashLinkClaims = mockk(relaxed = true) + private val cashLinkClaims = mockk(relaxed = true) { + every { claimInFlight } returns MutableStateFlow(null) + } private val chatDraftStore = mockk(relaxed = true) private val recentReactionsStore = mockk(relaxed = true) private val emojiCatalogLoader = mockk(relaxed = true) diff --git a/apps/flipcash/shared/session/src/main/kotlin/com/flipcash/app/session/SessionController.kt b/apps/flipcash/shared/session/src/main/kotlin/com/flipcash/app/session/SessionController.kt index 8f13be37cf..7f7eaf065b 100644 --- a/apps/flipcash/shared/session/src/main/kotlin/com/flipcash/app/session/SessionController.kt +++ b/apps/flipcash/shared/session/src/main/kotlin/com/flipcash/app/session/SessionController.kt @@ -127,6 +127,12 @@ data class SettledClaim( */ interface CashLinkClaims { val settledClaims: Flow + + /** + * The entropy of the claim running now, or null. Claims run one at a time, so a surface can + * show this link as claiming and ignore taps on it until [settledClaims] names it. + */ + val claimInFlight: StateFlow } /** One-shot signals from tip card resolution that only the UI can act on. */ diff --git a/apps/flipcash/shared/session/src/main/kotlin/com/flipcash/app/session/internal/delegates/CashLinkDelegate.kt b/apps/flipcash/shared/session/src/main/kotlin/com/flipcash/app/session/internal/delegates/CashLinkDelegate.kt index 1c846749ad..ba79bd54a0 100644 --- a/apps/flipcash/shared/session/src/main/kotlin/com/flipcash/app/session/internal/delegates/CashLinkDelegate.kt +++ b/apps/flipcash/shared/session/src/main/kotlin/com/flipcash/app/session/internal/delegates/CashLinkDelegate.kt @@ -27,7 +27,9 @@ import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.consumeAsFlow import javax.inject.Inject import javax.inject.Singleton @@ -75,6 +77,7 @@ class CashLinkDelegate @Inject constructor( override val settledClaims: Flow = _settledClaims.asSharedFlow() private val giftCardClaimInProgress = MutableStateFlow(null) + override val claimInFlight: StateFlow = giftCardClaimInProgress.asStateFlow() override fun openCashLink(cashLink: String?) { BottomBarManager.clear() diff --git a/apps/flipcash/shared/session/src/test/kotlin/com/flipcash/app/session/internal/delegates/CashLinkDelegateTest.kt b/apps/flipcash/shared/session/src/test/kotlin/com/flipcash/app/session/internal/delegates/CashLinkDelegateTest.kt index 6623bb3976..7f9a4ec475 100644 --- a/apps/flipcash/shared/session/src/test/kotlin/com/flipcash/app/session/internal/delegates/CashLinkDelegateTest.kt +++ b/apps/flipcash/shared/session/src/test/kotlin/com/flipcash/app/session/internal/delegates/CashLinkDelegateTest.kt @@ -36,6 +36,7 @@ import org.junit.Rule import org.junit.Test import kotlin.test.assertEquals import kotlin.test.assertFalse +import kotlin.test.assertNull @OptIn(ExperimentalCoroutinesApi::class) class CashLinkDelegateTest { @@ -239,6 +240,29 @@ class CashLinkDelegateTest { ) } + @Test + fun `the in-flight claim is published until it settles`() = runTest { + val delegate = createDelegate() + assertNull(delegate.claimInFlight.value) + + val onReceived = slot Unit>() + delegate.openCashLink("validEntropy123") + assertEquals("validEntropy123", delegate.claimInFlight.value) + + verify { + billController.receiveGiftCard( + entropy = any(), + owner = any(), + claimIfOwned = any(), + onReceived = capture(onReceived), + onError = any(), + ) + } + onReceived.captured.invoke(mockk(relaxed = true), localFiat) + + assertNull(delegate.claimInFlight.value) + } + @Test fun `a claim that fails settles as not collected`() = runTest { val delegate = createDelegate() From 669bb78da0139c6776458cbfb303ae25e14ec7fd Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Mon, 5 Oct 2026 08:41:01 -0400 Subject: [PATCH 3/5] feat(chat): show a claiming voucher while its claim is in flight MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A chat voucher kept reading "Tap to claim" until its claim settled, so a re-tap during a slow claim looked like the first tap did nothing. ChatViewModel forwards CashLinkClaims.claimInFlight to LinkCardResolver, which draws a resolved Claimable voucher with that entropy as the new Claim.Claiming. It is an overlay only: the stored lookup answer is untouched, so the card returns to the lookup's state when the claim settles. The voucher shows "Claiming…" in place of the pill, and a tap on it is ignored. --- .../core/src/main/res/values/strings.xml | 1 + .../app/messenger/internal/ChatViewModel.kt | 6 +++ .../internal/link/LinkCardResolver.kt | 26 ++++++++++++- .../internal/link/LinkCardResolverTest.kt | 39 +++++++++++++++++++ .../flipcash/shared/chat/models/LinkCard.kt | 7 +++- .../flipcash/shared/chat/ui/LinkCardView.kt | 3 ++ .../flipcash/shared/chat/ui/MessageBubble.kt | 5 ++- 7 files changed, 83 insertions(+), 4 deletions(-) diff --git a/apps/flipcash/core/src/main/res/values/strings.xml b/apps/flipcash/core/src/main/res/values/strings.xml index 22fed2e9a6..5ac14e95bc 100644 --- a/apps/flipcash/core/src/main/res/values/strings.xml +++ b/apps/flipcash/core/src/main/res/values/strings.xml @@ -1014,6 +1014,7 @@ Cash Tap to claim Claimed + Claiming… Expired Group Chat diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/ChatViewModel.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/ChatViewModel.kt index b9e200fb2e..b59eeaa9c4 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/ChatViewModel.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/ChatViewModel.kt @@ -1332,6 +1332,12 @@ internal class ChatViewModel @Inject constructor( } .launchIn(viewModelScope) + // The voucher being claimed reads as claiming until the claim settles, rather than offering + // the claim again for a re-tap to start a second one. + cashLinkClaims.claimInFlight + .onEach { linkCardResolver.markClaiming(it) } + .launchIn(viewModelScope) + // STARTED rather than a timer of its own, which makes one construct cover both cases worth // covering: the first pass runs on every foreground edge, so a reader who put the phone // down and came back gets a fresh answer immediately, and the loop then carries the case diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/link/LinkCardResolver.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/link/LinkCardResolver.kt index 47ac3e1241..0c7c61e948 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/link/LinkCardResolver.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/link/LinkCardResolver.kt @@ -97,15 +97,30 @@ internal class LinkCardResolver( private val _revision = MutableStateFlow(0) override val revision: StateFlow = _revision.asStateFlow() + /** The entropy of this device's claim in flight, drawn as [LinkCard.Cash.Claim.Claiming]. */ + @Volatile + private var claiming: String? = null + + /** + * Draws [entropy]'s voucher as [LinkCard.Cash.Claim.Claiming] while its claim runs, or clears + * that with null. Only an overlay: the stored answer stays what the lookup said, so nothing + * here can outlive the claim or be mistaken for the server's view of it. + */ + fun markClaiming(entropy: String?) { + if (claiming == entropy) return + claiming = entropy + _revision.update { it + 1 } + } + override fun peek(card: LinkCard): LinkCard? = when (card) { - is LinkCard.Cash -> cashAnswers[card.entropy]?.let { card.copy(state = it) } + is LinkCard.Cash -> cashAnswers[card.entropy]?.let { card.copy(state = it.overlayClaiming(card.entropy)) } is LinkCard.TokenInfo -> tokenAnswers[card.mint]?.let { card.copy(state = it) } is LinkCard.GroupInvite -> groupAnswers[card.chatId]?.let { card.copy(state = it) } is LinkCard.User -> userAnswers[card.identity]?.let { card.copy(state = it) } } override suspend fun resolve(card: LinkCard): LinkCard = when (card) { - is LinkCard.Cash -> card.copy(state = cashState(card.entropy)) + is LinkCard.Cash -> card.copy(state = cashState(card.entropy).overlayClaiming(card.entropy)) is LinkCard.TokenInfo -> card.copy(state = tokenState(card.mint)) is LinkCard.GroupInvite -> card.copy(state = groupState(card.chatId)) is LinkCard.User -> card.copy(state = userState(card.identity)) @@ -172,6 +187,13 @@ internal class LinkCardResolver( } /** Ends the queries with the screen that asked for them. */ + private fun LinkCard.Cash.State.overlayClaiming(entropy: String): LinkCard.Cash.State = + if (this is LinkCard.Cash.State.Resolved && claim == LinkCard.Cash.Claim.Claimable && entropy == claiming) { + copy(claim = LinkCard.Cash.Claim.Claiming) + } else { + this + } + fun dispose() { scope.cancel() } diff --git a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/link/LinkCardResolverTest.kt b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/link/LinkCardResolverTest.kt index d0013fbc5b..87b4ddbf94 100644 --- a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/link/LinkCardResolverTest.kt +++ b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/link/LinkCardResolverTest.kt @@ -70,6 +70,45 @@ class LinkCardResolverTest { assertEquals("$15.00", state.amount) } + @Test + fun `a claimable voucher reads as claiming while its claim is in flight`() = runTest { + val resolver = LinkCardResolver( + scope = backgroundScope, + giftCard = { Result.success(snapshot()) }, + tokenMetadata = { Result.success(mock()) }, + group = { Result.failure(IllegalStateException("unused")) }, + user = { Result.failure(IllegalStateException("unused")) }, + ) + resolver.resolve(card) + + val before = resolver.revision.value + resolver.markClaiming(card.entropy) + + assertTrue(resolver.revision.value > before) + assertEquals(LinkCard.Cash.Claim.Claiming, (resolver.peek(card) as LinkCard.Cash).claim()) + assertEquals(LinkCard.Cash.Claim.Claiming, (resolver.resolve(card) as LinkCard.Cash).claim()) + + resolver.markClaiming(null) + assertEquals(LinkCard.Cash.Claim.Claimable, (resolver.peek(card) as LinkCard.Cash).claim()) + } + + @Test + fun `a claim in flight for another link leaves this voucher claimable`() = runTest { + val resolver = LinkCardResolver( + scope = backgroundScope, + giftCard = { Result.success(snapshot()) }, + tokenMetadata = { Result.success(mock()) }, + group = { Result.failure(IllegalStateException("unused")) }, + user = { Result.failure(IllegalStateException("unused")) }, + ) + resolver.resolve(card) + resolver.markClaiming("someOtherEntropy") + + assertEquals(LinkCard.Cash.Claim.Claimable, (resolver.peek(card) as LinkCard.Cash).claim()) + } + + private fun LinkCard.Cash.claim() = (state as LinkCard.Cash.State.Resolved).claim + @Test fun `a resolved link is only looked up once`() = runTest { var calls = 0 diff --git a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/models/LinkCard.kt b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/models/LinkCard.kt index b5b5286db8..fa8ea19577 100644 --- a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/models/LinkCard.kt +++ b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/models/LinkCard.kt @@ -71,7 +71,12 @@ sealed interface LinkCard { ) : State } - enum class Claim { Claimable, Claimed, Expired } + /** + * [Claiming] is never a lookup's answer. It is drawn over a [Claimable] voucher while this + * device's claim of it is running, so the card stops offering a claim that is already + * underway, and it goes back to whatever the lookup says once the claim settles. + */ + enum class Claim { Claimable, Claiming, Claimed, Expired } } /** diff --git a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/LinkCardView.kt b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/LinkCardView.kt index 734cc3a1ac..61024c6247 100644 --- a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/LinkCardView.kt +++ b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/LinkCardView.kt @@ -267,6 +267,9 @@ private fun CashLinkCard( // Off the paper: the stub this used to sit on left with whoever claimed it. StubLabel(stringResource(R.string.label_linkCard_claimed), onPaper = false) LinkCard.Cash.Claim.Expired -> StubLabel(stringResource(R.string.label_linkCard_expired)) + // A label rather than the pill: the claim is already running, so there is nothing + // left to offer, and the tap that would offer it again is ignored. + LinkCard.Cash.Claim.Claiming -> StubLabel(stringResource(R.string.label_linkCard_claiming)) // The same voucher whoever is reading it. The transcript already says who sent the // link -- the bubble sits on the sender's side -- so a card that read differently for // the issuer would be saying it twice, and saying it in the one place both people are diff --git a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/MessageBubble.kt b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/MessageBubble.kt index bfbe7ac46d..d0fe4797ef 100644 --- a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/MessageBubble.kt +++ b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/MessageBubble.kt @@ -719,7 +719,10 @@ private fun rememberLinkCardClick(): (LinkCard) -> Unit { return { card -> when (card) { // The chat opens it, because only the chat knows whether this reader may collect it. - is LinkCard.Cash -> actionHandler(ChatAction.CashLinkOpened(card.entropy, card.url)) + // A voucher already being claimed would only start the same claim again. + is LinkCard.Cash -> if ((card.state as? LinkCard.Cash.State.Resolved)?.claim != LinkCard.Cash.Claim.Claiming) { + actionHandler(ChatAction.CashLinkOpened(card.entropy, card.url)) + } is LinkCard.TokenInfo -> actionHandler(ChatAction.ViewToken(card.mint)) // Pushed over this chat rather than through the chat deep link, which replaces the // stack: Back has to return to the message that held the invite. From 7220d5498c67d037bd47109192ee6cd06ccb8895 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Mon, 5 Oct 2026 08:47:07 -0400 Subject: [PATCH 4/5] fix(cash-link): settle a claim before freeing its slot, and keep alerts for dropped links Two ordering problems in CashLinkDelegate: - onReceived and onError cleared giftCardClaimInProgress before emitting the SettledClaim. ChatViewModel redraws on both, so the voucher could read "Tap to claim" from the cached answer for one update before the settlement invalidated it. The settlement is now emitted first. - openCashLink cleared the bottom bar before the in-flight check, so a link opened during a claim was dropped and also dismissed whatever alert was on screen. It now clears only when no claim is running. --- .../internal/delegates/CashLinkDelegate.kt | 12 +++-- .../delegates/CashLinkDelegateTest.kt | 53 +++++++++++++++++++ 2 files changed, 62 insertions(+), 3 deletions(-) diff --git a/apps/flipcash/shared/session/src/main/kotlin/com/flipcash/app/session/internal/delegates/CashLinkDelegate.kt b/apps/flipcash/shared/session/src/main/kotlin/com/flipcash/app/session/internal/delegates/CashLinkDelegate.kt index ba79bd54a0..f4d6938c1a 100644 --- a/apps/flipcash/shared/session/src/main/kotlin/com/flipcash/app/session/internal/delegates/CashLinkDelegate.kt +++ b/apps/flipcash/shared/session/src/main/kotlin/com/flipcash/app/session/internal/delegates/CashLinkDelegate.kt @@ -80,7 +80,11 @@ class CashLinkDelegate @Inject constructor( override val claimInFlight: StateFlow = giftCardClaimInProgress.asStateFlow() override fun openCashLink(cashLink: String?) { - BottomBarManager.clear() + // A link opened while a claim runs is dropped below, so it leaves whatever is on screen + // alone rather than dismissing an alert for nothing. + if (giftCardClaimInProgress.value == null) { + BottomBarManager.clear() + } val entropy = cashLink?.trim()?.replace("\n", "") if (entropy == null) { @@ -158,8 +162,10 @@ class CashLinkDelegate @Inject constructor( claimIfOwned = claimIfOwned, onReceived = { token, amount -> tokenCoordinator.add(token, amount) - giftCardClaimInProgress.value = null + // Settled before the slot clears, so a listener redrawing on both learns the claim + // landed before it would draw the link as claimable again. _settledClaims.tryEmit(SettledClaim(entropy, collected = true)) + giftCardClaimInProgress.value = null analytics.track(TransferEvents.receiveCashLink(State.SUCCESS, amount.analytics, null)) val bill = Scannable.Payable.forToken( amount = amount, @@ -171,8 +177,8 @@ class CashLinkDelegate @Inject constructor( _events.trySend(Event.RefreshFeed) }, onError = { cause -> - giftCardClaimInProgress.value = null _settledClaims.tryEmit(SettledClaim(entropy, collected = false)) + giftCardClaimInProgress.value = null if (cause !is ReceiveGiftTransactorError.UsersGiftCard) { analytics.track( TransferEvents.receiveCashLink(State.FAILURE, null, cause.analytics) diff --git a/apps/flipcash/shared/session/src/test/kotlin/com/flipcash/app/session/internal/delegates/CashLinkDelegateTest.kt b/apps/flipcash/shared/session/src/test/kotlin/com/flipcash/app/session/internal/delegates/CashLinkDelegateTest.kt index 7f9a4ec475..6b0f2dba49 100644 --- a/apps/flipcash/shared/session/src/test/kotlin/com/flipcash/app/session/internal/delegates/CashLinkDelegateTest.kt +++ b/apps/flipcash/shared/session/src/test/kotlin/com/flipcash/app/session/internal/delegates/CashLinkDelegateTest.kt @@ -28,6 +28,7 @@ import io.mockk.slot import io.mockk.verify import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.launch +import kotlinx.coroutines.test.UnconfinedTestDispatcher import kotlinx.coroutines.test.runCurrent import kotlinx.coroutines.test.runTest import org.junit.After @@ -263,6 +264,58 @@ class CashLinkDelegateTest { assertNull(delegate.claimInFlight.value) } + @Test + fun `a claim is still in flight when its settlement is announced`() = runTest { + val delegate = createDelegate() + // Unconfined, so the collector reads claimInFlight at the moment of the emission. A + // listener that redraws on both must see the settlement first, or it draws the link as + // claimable again before it learns the claim landed. + val inFlightAtSettle = mutableListOf() + backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) { + delegate.settledClaims.collect { inFlightAtSettle += delegate.claimInFlight.value } + } + + val onReceived = slot Unit>() + val onError = slot<(Throwable) -> Unit>() + delegate.openCashLink("first") + verify { + billController.receiveGiftCard( + entropy = "first", + owner = any(), + claimIfOwned = any(), + onReceived = capture(onReceived), + onError = any(), + ) + } + onReceived.captured.invoke(mockk(relaxed = true), localFiat) + + delegate.openCashLink("second") + verify { + billController.receiveGiftCard( + entropy = "second", + owner = any(), + claimIfOwned = any(), + onReceived = any(), + onError = capture(onError), + ) + } + onError.captured.invoke(IllegalStateException("offline")) + + assertEquals(listOf("first", "second"), inFlightAtSettle) + assertNull(delegate.claimInFlight.value) + } + + @Test + fun `a link opened during a claim leaves the alert on screen`() = runTest { + val delegate = createDelegate() + delegate.openCashLink("first") + BottomBarManager.showInfo(title = "existing", message = "message") + + delegate.openCashLink("second") + + assertEquals("existing", BottomBarManager.messages.value.single().title) + } + @Test fun `a claim that fails settles as not collected`() = runTest { val delegate = createDelegate() From 8d5f1f1b8a1791a075d83518f3d5e8459ee570f7 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Mon, 5 Oct 2026 08:47:07 -0400 Subject: [PATCH 5/5] test(chat): stub claimInFlight in the remaining ChatViewModel fakes --- .../flipcash/app/messenger/internal/ChatMentionPickerTest.kt | 2 +- .../flipcash/app/messenger/internal/ChatOpenTranscriptTest.kt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatMentionPickerTest.kt b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatMentionPickerTest.kt index d1a5f62372..f401275083 100644 --- a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatMentionPickerTest.kt +++ b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatMentionPickerTest.kt @@ -100,7 +100,7 @@ class ChatMentionPickerTest { userFlags = mockk(relaxed = true), linkCardClassifier = mockk(relaxed = true), linkCardResolver = mockk(relaxed = true), - cashLinkClaims = mockk(relaxed = true), + cashLinkClaims = mockk(relaxed = true) { every { claimInFlight } returns MutableStateFlow(null) }, chatCashLinks = mockk(relaxed = true), chatDraftStore = mockk(relaxed = true), recentReactionsStore = mockk(relaxed = true), diff --git a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatOpenTranscriptTest.kt b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatOpenTranscriptTest.kt index 1509cbf9f2..fc85f395b0 100644 --- a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatOpenTranscriptTest.kt +++ b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatOpenTranscriptTest.kt @@ -98,7 +98,7 @@ class ChatOpenTranscriptTest { userFlags = mockk(relaxed = true), linkCardClassifier = mockk(relaxed = true), linkCardResolver = mockk(relaxed = true), - cashLinkClaims = mockk(relaxed = true), + cashLinkClaims = mockk(relaxed = true) { every { claimInFlight } returns MutableStateFlow(null) }, chatCashLinks = mockk(relaxed = true), chatDraftStore = mockk(relaxed = true), recentReactionsStore = mockk(relaxed = true),