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/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/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), 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/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. 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 023df18a8f..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 @@ -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,9 +77,14 @@ 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() + // 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) { @@ -126,18 +133,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", @@ -150,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, @@ -163,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) @@ -225,5 +239,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..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 @@ -36,6 +37,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 +241,81 @@ 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 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() @@ -291,6 +368,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