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