Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,7 @@ import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.coroutines.suspendCancellableCoroutine
import java.util.concurrent.atomic.AtomicBoolean
import javax.inject.Inject
import kotlin.coroutines.resume
import kotlin.time.Clock
Expand Down Expand Up @@ -138,6 +139,7 @@ class RealSessionController @Inject constructor(

private val scannedRendezvous = mutableMapOf<String, Long>()

private val giftCardFundingInProgress = AtomicBoolean(false)
private val giftCardClaimInProgress = MutableStateFlow<String?>(null)

init {
Expand Down Expand Up @@ -463,9 +465,10 @@ class RealSessionController @Inject constructor(
)

scope.launch {
shareSheetController.onShared = { result ->
shareSheetController.onShared = onShared@{ result ->
when (result) {
is ShareResult.ActionTaken -> {
if (!giftCardFundingInProgress.compareAndSet(false, true)) return@onShared
scope.launch action@{
// immediately fund the gift card
val fundingResult = initiateGiftCardFunding(
Expand All @@ -492,7 +495,7 @@ class RealSessionController @Inject constructor(
shareable = shareable,
result = result
)
}
}.invokeOnCompletion { giftCardFundingInProgress.set(false) }
}

ShareResult.NotShared -> {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ import com.flipcash.app.core.internal.bill.BillController
import com.flipcash.app.session.internal.toast.ToastController
import com.flipcash.app.featureflags.FeatureFlagController
import com.flipcash.app.shareable.ShareSheetController
import com.flipcash.app.shareable.ShareResult
import com.flipcash.app.shareable.Shareable
import com.flipcash.app.shareable.ShareableConfirmationController
import com.flipcash.app.tokens.TokenCoordinator
import com.flipcash.app.tokens.TokenUpdater
Expand All @@ -22,16 +24,21 @@ import com.getcode.opencode.internal.manager.VerifiedState
import com.getcode.opencode.internal.transactors.ReceiveGiftTransactorError
import com.getcode.opencode.model.accounts.AccountCluster
import com.flipcash.app.core.bill.Bill
import com.flipcash.app.core.bill.BillState
import com.getcode.opencode.model.financial.LocalFiat
import com.getcode.opencode.model.financial.Token
import com.getcode.util.resources.ResourceHelper
import com.flipcash.app.billing.BillingClient
import com.flipcash.app.core.MainCoroutineRule
import com.getcode.utils.network.NetworkConnectivityListener
import com.getcode.util.vibration.Vibrator
import com.getcode.opencode.model.accounts.GiftCardAccount
import io.mockk.every
import io.mockk.mockk
import io.mockk.mockkObject
import io.mockk.slot
import io.mockk.unmockkObject
import io.mockk.verify
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.runTest
import org.junit.After
Expand Down Expand Up @@ -80,7 +87,9 @@ class SessionControllerGiftCardErrorTest {
BottomBarManager.clear()
}

private fun createController(): RealSessionController {
private fun createController(
shareSheetController: ShareSheetController = mockk(relaxed = true),
): RealSessionController {
return RealSessionController(
billController = billController,
userManager = userManager,
Expand All @@ -94,7 +103,7 @@ class SessionControllerGiftCardErrorTest {
tokenUpdater = mockk(relaxed = true),
activityFeedUpdater = mockk(relaxed = true),
profileUpdater = mockk(relaxed = true),
shareSheetController = mockk(relaxed = true),
shareSheetController = shareSheetController,
shareConfirmationController = mockk(relaxed = true),
toastController = mockk(relaxed = true),
billingClient = mockk(relaxed = true),
Expand Down Expand Up @@ -195,6 +204,70 @@ class SessionControllerGiftCardErrorTest {
assertEquals("error_title_CashReturnedToWallet", messages.first().title)
}

@Test
fun `duplicate onShared invocations only fund gift card once`() = runTest {
// Mock GiftCardAccount.create to avoid MnemonicCache (requires Android context)
mockkObject(GiftCardAccount.Companion)
every { GiftCardAccount.create(any(), any()) } returns mockk(relaxed = true)

try {
// Fake that fires onShared twice on present(), simulating the race between
// shareResultReceiver and checkForShare().
val racingShareSheet = object : ShareSheetController {
override val isCheckingForShare: Boolean = false
override var onShared: ((ShareResult) -> Unit)? = null
override fun checkForShare() {}
override suspend fun present(shareable: Shareable) {
onShared?.invoke(ShareResult.SharedToApp("com.example.app"))
onShared?.invoke(ShareResult.SharedToApp("com.example.app"))
}
override fun reset(setChecked: Boolean) {}
}

// Capture the update lambda so we can extract the SendAsLink action
val updateSlot = slot<(BillState) -> BillState>()
every { billController.update(capture(updateSlot)) } answers {}

val controller = createController(shareSheetController = racingShareSheet)

val amount = mockk<LocalFiat>(relaxed = true) {
every { nativeAmount.decimalValue } returns 5.0
}
val bill = Bill.Cash(
token = mockk(relaxed = true),
amount = amount,
didReceive = false,
kind = Bill.Kind.cash,
verifiedState = mockk(relaxed = true),
)

controller.showBill(bill)

// Extract and invoke the "Send as Link" action (calls shareGiftCard)
val updatedState = updateSlot.captured(BillState.Default)
val sendAction = updatedState.primaryAction as BillState.Action.SendAsLink
sendAction.action()

// Wait for IO-dispatched coroutines to execute
Thread.sleep(1000)

// The guard should ensure fundGiftCard is called exactly once, not twice
verify(exactly = 1) {
billController.fundGiftCard(
giftCard = any(),
amount = any(),
token = any(),
owner = any(),
verifiedState = any(),
onFunded = any(),
onError = any(),
)
}
} finally {
unmockkObject(GiftCardAccount.Companion)
}
}

// Phase 3: fund gift card error
@Test
fun `fund gift card error shows failedToCreateGiftCard error`() = runTest {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -134,13 +134,16 @@ sealed class SubmitIntentError(
get() = reasons.any { it.contains("pool balance has already been distributed") }
val isIntentAlreadyExists: Boolean
get() = reasons.any { it.contains("intent already exists") }
val isAccountAlreadyOpened: Boolean
get() = reasons.any { it.contains("account is already opened") }

val isExpected: Boolean
get() = isRaceCondition
|| isGiftCardAlreadyClaimed
|| isGiftCardExpired
|| isPoolAlreadyDistributed
|| isIntentAlreadyExists
|| isAccountAlreadyOpened

override val isNotifiable: Boolean
get() = !isExpected
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -213,6 +213,32 @@ class SubmitIntentErrorTest {
assertFalse(error.isRaceCondition)
}

@Test
fun staleStateWithAccountAlreadyOpenedIsAccountAlreadyOpened() {
val error = SubmitIntentError.typed(
buildError(
SubmitIntentResponse.Error.Code.STALE_STATE,
reasonStrings = listOf("actions[0]: account is already opened")
)
)
assertIs<SubmitIntentError.StaleState>(error)
assertTrue(error.isAccountAlreadyOpened)
assertTrue(error.isExpected)
assertFalse(error.isNotifiable)
}

@Test
fun staleStateWithOtherReasonIsNotAccountAlreadyOpened() {
val error = SubmitIntentError.typed(
buildError(
SubmitIntentResponse.Error.Code.STALE_STATE,
reasonStrings = listOf("nonce expired")
)
)
assertIs<SubmitIntentError.StaleState>(error)
assertFalse(error.isAccountAlreadyOpened)
}

@Test
fun otherWrausesCause() {
val cause = RuntimeException("root cause")
Expand Down
Loading