Updated on 2026-08-14
This commit is contained in:
commit
b654c6ea6e
35 changed files with 1279 additions and 165 deletions
|
|
@ -1581,6 +1581,7 @@ internal class SwapInteractorImpl @Inject constructor(
|
|||
feeValue = nativeFee,
|
||||
selectedFeeToken = fee.selectedFeeToken,
|
||||
provider = state.swapProvider,
|
||||
txType = state.txType,
|
||||
)
|
||||
val currencyCheck = manageWarnings(
|
||||
fromSwapCurrencyStatus = fromSwapCurrencyStatus,
|
||||
|
|
@ -1621,6 +1622,7 @@ internal class SwapInteractorImpl @Inject constructor(
|
|||
* from-currencies but "fee > native balance" for Token from-currencies is resolved here
|
||||
* by consulting `isBalanceEnough` (amount-alone check) directly.
|
||||
*/
|
||||
@Suppress("LongParameterList")
|
||||
private suspend fun computeBalanceStatus(
|
||||
fromSwapCurrencyStatus: SwapCurrencyStatus,
|
||||
amount: SwapAmount,
|
||||
|
|
@ -1628,9 +1630,10 @@ internal class SwapInteractorImpl @Inject constructor(
|
|||
feeValue: BigDecimal,
|
||||
selectedFeeToken: CryptoCurrencyStatus?,
|
||||
provider: SwapProvider,
|
||||
txType: ExpressTxType?,
|
||||
): SwapBalanceStatus {
|
||||
when (provider.type) {
|
||||
ExchangeProviderType.CEX -> {
|
||||
when (resolveQuoteFlow(provider, txType)) {
|
||||
ResolvedFlow.CexLike -> {
|
||||
val includeStatus = getIncludeFeeInAmountInternal(
|
||||
fromSwapCurrencyStatus = fromSwapCurrencyStatus,
|
||||
amount = amount,
|
||||
|
|
@ -1642,9 +1645,7 @@ internal class SwapInteractorImpl @Inject constructor(
|
|||
return SwapBalanceStatus.FeeAdjustedAmount(adjustedAmount = includeStatus.amountSubtractFee)
|
||||
}
|
||||
}
|
||||
ExchangeProviderType.DEX,
|
||||
ExchangeProviderType.DEX_BRIDGE,
|
||||
-> Unit
|
||||
ResolvedFlow.DexLike -> Unit
|
||||
}
|
||||
|
||||
val isAmountAlone = isBalanceEnough(fromSwapCurrencyStatus, amount, fee = feeValue)
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ import com.tangem.domain.tokens.model.warnings.CryptoCurrencyCheck
|
|||
import com.tangem.feature.swap.domain.fee.TransactionFeeResult
|
||||
import com.tangem.feature.swap.domain.models.SwapAmount
|
||||
import com.tangem.feature.swap.domain.models.domain.ExchangeProviderType
|
||||
import com.tangem.feature.swap.domain.models.domain.ExpressTxType
|
||||
import com.tangem.feature.swap.domain.models.domain.PreparedSwapConfigState
|
||||
import com.tangem.feature.swap.domain.models.domain.SwapBalanceStatus
|
||||
import com.tangem.feature.swap.domain.models.ui.*
|
||||
|
|
@ -214,6 +215,76 @@ internal class SwapInteractorImplApplySwapFeeMatrixTest : SwapInteractorImplTest
|
|||
}
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Section A2: DEX provider re-routed to the CEX-like flow via txType=SEND ([REDACTED_TASK_KEY])
|
||||
// =========================================================================
|
||||
|
||||
@Nested
|
||||
inner class `DEX provider with SEND txType follows CEX semantics` {
|
||||
|
||||
/**
|
||||
* [REDACTED_TASK_KEY]: a DEX-typed provider (e.g. Moonpay trade) whose quote returned txType=SEND
|
||||
* executes as a plain transfer built by the app, so the fee must be folded into the amount
|
||||
* exactly like for a CEX provider.
|
||||
*
|
||||
* GIVEN ExchangeProviderType.DEX, txType = SEND
|
||||
* fromToken is Coin, amount = full native balance (max amount), fee = 0.01
|
||||
* WHEN applySwapFee runs
|
||||
* THEN balanceStatus == FeeAdjustedAmount with adjustedAmount = balance - fee
|
||||
* (NOT InsufficientAmount — the pre-fix behavior that showed "Insufficient funds")
|
||||
*/
|
||||
@Test
|
||||
fun `applySwapFee DEX with SEND txType — max amount returns FeeAdjustedAmount like CEX`() = runTest {
|
||||
coEvery { currenciesRepository.getFeePaidCurrency(any(), any()) } returns FeePaidCurrency.Coin
|
||||
coEvery {
|
||||
walletManagersFacade.getNativeTokenBalance(any(), any(), any())
|
||||
} returns BigDecimal("1.0")
|
||||
|
||||
val state = buildQuotesLoadedState(
|
||||
providerType = ExchangeProviderType.DEX,
|
||||
fromAmount = SwapAmount(BigDecimal("1.0"), 18),
|
||||
isCoin = true,
|
||||
fromBalance = BigDecimal("1.0"),
|
||||
txType = ExpressTxType.SEND,
|
||||
)
|
||||
val fee = buildSwapFeeWithCoinToken(feeValue = BigDecimal("0.01"))
|
||||
|
||||
val result = sut.applySwapFee(state, fee, lastReducedBalanceBy)
|
||||
|
||||
val balanceStatus = result.preparedSwapConfigState.balanceStatus
|
||||
assertThat(balanceStatus).isInstanceOf(SwapBalanceStatus.FeeAdjustedAmount::class.java)
|
||||
assertThat((balanceStatus as SwapBalanceStatus.FeeAdjustedAmount).adjustedAmount.value)
|
||||
.isEqualTo(BigDecimal("0.99"))
|
||||
}
|
||||
|
||||
/**
|
||||
* Twin guard: the same max-amount scenario with txType = SWAP keeps the DEX invariant —
|
||||
* the fee is never deducted from the amount, and the amount alone exceeding
|
||||
* balance-with-fee yields InsufficientAmount.
|
||||
*/
|
||||
@Test
|
||||
fun `applySwapFee DEX with SWAP txType — max amount keeps DEX semantics without fee deduction`() = runTest {
|
||||
coEvery { currenciesRepository.getFeePaidCurrency(any(), any()) } returns FeePaidCurrency.Coin
|
||||
coEvery {
|
||||
walletManagersFacade.getNativeTokenBalance(any(), any(), any())
|
||||
} returns BigDecimal("1.0")
|
||||
|
||||
val state = buildQuotesLoadedState(
|
||||
providerType = ExchangeProviderType.DEX,
|
||||
fromAmount = SwapAmount(BigDecimal("1.0"), 18),
|
||||
isCoin = true,
|
||||
fromBalance = BigDecimal("1.0"),
|
||||
txType = ExpressTxType.SWAP,
|
||||
)
|
||||
val fee = buildSwapFeeWithCoinToken(feeValue = BigDecimal("0.01"))
|
||||
|
||||
val result = sut.applySwapFee(state, fee, lastReducedBalanceBy)
|
||||
|
||||
assertThat(result.preparedSwapConfigState.balanceStatus)
|
||||
.isInstanceOf(SwapBalanceStatus.InsufficientAmount::class.java)
|
||||
}
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Section B: FeePaidCurrency.Token (gasless-token) paths
|
||||
// =========================================================================
|
||||
|
|
@ -749,6 +820,7 @@ internal class SwapInteractorImplApplySwapFeeMatrixTest : SwapInteractorImplTest
|
|||
fromAmount: SwapAmount,
|
||||
isCoin: Boolean,
|
||||
fromBalance: BigDecimal,
|
||||
txType: ExpressTxType? = null,
|
||||
): SwapState.QuotesLoadedState {
|
||||
val from = buildSwapCurrencyStatus(
|
||||
networkRawId = ethNetwork,
|
||||
|
|
@ -778,6 +850,7 @@ internal class SwapInteractorImplApplySwapFeeMatrixTest : SwapInteractorImplTest
|
|||
validationResult = null,
|
||||
minAdaValue = null,
|
||||
swapProvider = buildSwapProvider(providerType),
|
||||
txType = txType,
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -25,6 +25,7 @@ import com.tangem.domain.transaction.usecase.gasless.IsGaslessFeeSupportedForNet
|
|||
import com.tangem.feature.swap.domain.models.ExpressDataError
|
||||
import com.tangem.feature.swap.domain.models.SwapAmount
|
||||
import com.tangem.feature.swap.domain.models.domain.ExchangeProviderType
|
||||
import com.tangem.feature.swap.domain.models.domain.ExpressTxType
|
||||
import com.tangem.feature.swap.domain.models.domain.SwapBalanceStatus
|
||||
import com.tangem.feature.swap.domain.models.ui.PermissionDataState
|
||||
import com.tangem.feature.swap.domain.models.ui.PriceImpact
|
||||
|
|
@ -327,11 +328,16 @@ internal class SwapNotificationsFactory(
|
|||
val shouldShowCoverWarning = quoteModel.permissionState !is PermissionDataState.PermissionLoading &&
|
||||
feeCryptoCurrencyStatus.currency != fromCurrency
|
||||
|
||||
val isCEXProvider = quoteModel.swapProvider.type == ExchangeProviderType.CEX
|
||||
// A DEX-typed provider whose quote returned txType=SEND executes as a CEX-style transfer,
|
||||
// so it must follow the same gasless suppression rule as a real CEX provider.
|
||||
val isCexLikeFlow = quoteModel.swapProvider.type == ExchangeProviderType.CEX ||
|
||||
quoteModel.txType == ExpressTxType.SEND
|
||||
|
||||
val isNotEnoughFee = insufficientFee != null && !isCEXProvider
|
||||
val isNotEnoughFee = insufficientFee != null
|
||||
|
||||
val isGaslessAvailable = isGaslessFeeSupportedForNetwork(fromCurrency.network) && isCEXProvider
|
||||
// Suppress only when the user can actually switch the fee to a token via the gasless
|
||||
// selector; on networks without gasless support the warning must show for CEX too.
|
||||
val isGaslessAvailable = isGaslessFeeSupportedForNetwork(fromCurrency.network) && isCexLikeFlow
|
||||
if (shouldShowCoverWarning && !isGaslessAvailable && isNotEnoughFee) {
|
||||
add(
|
||||
if (fromCurrency.id == feeCryptoCurrencyStatus.currency.id) {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,276 @@
|
|||
package com.tangem.feature.swap.model
|
||||
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import com.tangem.common.routing.AppRouter
|
||||
import com.tangem.domain.appcurrency.model.AppCurrency
|
||||
import com.tangem.domain.models.account.Account
|
||||
import com.tangem.domain.models.currency.CryptoCurrency
|
||||
import com.tangem.domain.models.currency.CryptoCurrencyStatus
|
||||
import com.tangem.domain.models.network.Network
|
||||
import com.tangem.domain.models.wallet.UserWallet
|
||||
import com.tangem.domain.models.wallet.UserWalletId
|
||||
import com.tangem.domain.swap.models.SwapCurrencyStatus
|
||||
import com.tangem.domain.transaction.usecase.gasless.IsGaslessFeeSupportedForNetwork
|
||||
import com.tangem.feature.swap.domain.models.SwapAmount
|
||||
import com.tangem.feature.swap.domain.models.domain.ExchangeProviderType
|
||||
import com.tangem.feature.swap.domain.models.domain.ExpressTxType
|
||||
import com.tangem.feature.swap.domain.models.domain.PreparedSwapConfigState
|
||||
import com.tangem.feature.swap.domain.models.domain.RateType
|
||||
import com.tangem.feature.swap.domain.models.domain.SwapBalanceStatus
|
||||
import com.tangem.feature.swap.domain.models.domain.SwapProvider
|
||||
import com.tangem.feature.swap.domain.models.ui.PermissionDataState
|
||||
import com.tangem.feature.swap.domain.models.ui.PriceImpact
|
||||
import com.tangem.feature.swap.domain.models.ui.SwapState
|
||||
import com.tangem.feature.swap.domain.models.ui.TokenSwapInfo
|
||||
import com.tangem.feature.swap.models.UiActions
|
||||
import com.tangem.feature.swap.models.states.SwapNotificationUM
|
||||
import com.tangem.utils.Provider
|
||||
import io.mockk.clearMocks
|
||||
import io.mockk.every
|
||||
import io.mockk.mockk
|
||||
import org.junit.jupiter.api.BeforeEach
|
||||
import org.junit.jupiter.api.Test
|
||||
import java.math.BigDecimal
|
||||
|
||||
/**
|
||||
* Tests for [SwapNotificationsFactory.getConfirmationStateNotifications], focused on the
|
||||
* `UnableToCoverFeeWarning` gating ([REDACTED_TASK_KEY]):
|
||||
*
|
||||
* | flow | gasless network | expected for InsufficientFee |
|
||||
* |-------------------------------|-----------------|--------------------------------|
|
||||
* | CEX | no | warning shown (the bug fix) |
|
||||
* | CEX | yes | suppressed (fee → token) |
|
||||
* | DEX (txType=null) | yes | warning shown (DEX unchanged) |
|
||||
* | DEX + txType=SEND (CEX-like) | yes | suppressed like a real CEX |
|
||||
* | DEX + txType=SEND (CEX-like) | no | warning shown |
|
||||
*/
|
||||
internal class SwapNotificationsFactoryTest {
|
||||
|
||||
private val actions: UiActions = mockk(relaxed = true)
|
||||
private val isGaslessFeeSupportedForNetwork: IsGaslessFeeSupportedForNetwork = mockk()
|
||||
private val appCurrencyProvider: Provider<AppCurrency> = Provider { AppCurrency.Default }
|
||||
private val appRouter: AppRouter = mockk()
|
||||
|
||||
private val factory = SwapNotificationsFactory(
|
||||
actions = actions,
|
||||
isGaslessFeeSupportedForNetwork = isGaslessFeeSupportedForNetwork,
|
||||
appCurrencyProvider = appCurrencyProvider,
|
||||
)
|
||||
|
||||
private val userWalletId = UserWalletId(stringValue = "deadbeef")
|
||||
private val userWallet: UserWallet = mockk(relaxed = true) {
|
||||
every { walletId } returns userWalletId
|
||||
}
|
||||
|
||||
@BeforeEach
|
||||
fun resetMocks() {
|
||||
clearMocks(isGaslessFeeSupportedForNetwork, appRouter)
|
||||
every { appRouter.stack } returns emptyList()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN CEX and no gasless support WHEN insufficient fee THEN cover fee warning shown`() {
|
||||
// Arrange
|
||||
every { isGaslessFeeSupportedForNetwork(any()) } returns false
|
||||
val quoteModel = buildQuotesLoadedState(providerType = ExchangeProviderType.CEX)
|
||||
|
||||
// Act
|
||||
val notifications = factory.getConfirmationStateNotifications(
|
||||
quoteModel = quoteModel,
|
||||
feeCryptoCurrencyStatus = buildCoinFeeStatus(),
|
||||
swapFee = null,
|
||||
feeError = null,
|
||||
appRouter = appRouter,
|
||||
)
|
||||
|
||||
// Assert
|
||||
val warning = notifications.filterIsInstance<SwapNotificationUM.Error.UnableToCoverFeeWarning>().single()
|
||||
assertThat(warning.currencyName).isEqualTo("Ethereum")
|
||||
assertThat(warning.currencySymbol).isEqualTo("ETH")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN CEX and gasless support WHEN insufficient fee THEN cover fee warning suppressed`() {
|
||||
// Arrange
|
||||
every { isGaslessFeeSupportedForNetwork(any()) } returns true
|
||||
val quoteModel = buildQuotesLoadedState(providerType = ExchangeProviderType.CEX)
|
||||
|
||||
// Act
|
||||
val notifications = factory.getConfirmationStateNotifications(
|
||||
quoteModel = quoteModel,
|
||||
feeCryptoCurrencyStatus = buildCoinFeeStatus(),
|
||||
swapFee = null,
|
||||
feeError = null,
|
||||
appRouter = appRouter,
|
||||
)
|
||||
|
||||
// Assert
|
||||
assertThat(notifications.filterIsInstance<SwapNotificationUM.Error.UnableToCoverFeeWarning>()).isEmpty()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN DEX and gasless support WHEN insufficient fee THEN cover fee warning shown`() {
|
||||
// Arrange
|
||||
every { isGaslessFeeSupportedForNetwork(any()) } returns true
|
||||
val quoteModel = buildQuotesLoadedState(providerType = ExchangeProviderType.DEX)
|
||||
|
||||
// Act
|
||||
val notifications = factory.getConfirmationStateNotifications(
|
||||
quoteModel = quoteModel,
|
||||
feeCryptoCurrencyStatus = buildCoinFeeStatus(),
|
||||
swapFee = null,
|
||||
feeError = null,
|
||||
appRouter = appRouter,
|
||||
)
|
||||
|
||||
// Assert
|
||||
assertThat(notifications.filterIsInstance<SwapNotificationUM.Error.UnableToCoverFeeWarning>()).hasSize(1)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN DEX with SEND txType and gasless support WHEN insufficient fee THEN warning suppressed like CEX`() {
|
||||
// Arrange
|
||||
every { isGaslessFeeSupportedForNetwork(any()) } returns true
|
||||
val quoteModel = buildQuotesLoadedState(
|
||||
providerType = ExchangeProviderType.DEX,
|
||||
txType = ExpressTxType.SEND,
|
||||
)
|
||||
|
||||
// Act
|
||||
val notifications = factory.getConfirmationStateNotifications(
|
||||
quoteModel = quoteModel,
|
||||
feeCryptoCurrencyStatus = buildCoinFeeStatus(),
|
||||
swapFee = null,
|
||||
feeError = null,
|
||||
appRouter = appRouter,
|
||||
)
|
||||
|
||||
// Assert
|
||||
assertThat(notifications.filterIsInstance<SwapNotificationUM.Error.UnableToCoverFeeWarning>()).isEmpty()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN DEX with SEND txType and no gasless support WHEN insufficient fee THEN warning shown`() {
|
||||
// Arrange
|
||||
every { isGaslessFeeSupportedForNetwork(any()) } returns false
|
||||
val quoteModel = buildQuotesLoadedState(
|
||||
providerType = ExchangeProviderType.DEX,
|
||||
txType = ExpressTxType.SEND,
|
||||
)
|
||||
|
||||
// Act
|
||||
val notifications = factory.getConfirmationStateNotifications(
|
||||
quoteModel = quoteModel,
|
||||
feeCryptoCurrencyStatus = buildCoinFeeStatus(),
|
||||
swapFee = null,
|
||||
feeError = null,
|
||||
appRouter = appRouter,
|
||||
)
|
||||
|
||||
// Assert
|
||||
assertThat(notifications.filterIsInstance<SwapNotificationUM.Error.UnableToCoverFeeWarning>()).hasSize(1)
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
private fun buildEthNetwork(): Network = mockk(relaxed = true) {
|
||||
every { rawId } returns "ethereum"
|
||||
every { name } returns "Ethereum"
|
||||
every { currencySymbol } returns "ETH"
|
||||
}
|
||||
|
||||
/** Token from-currency, so the fee is paid in a different (native coin) currency. */
|
||||
private fun buildTokenFromStatus(): SwapCurrencyStatus {
|
||||
val network = buildEthNetwork()
|
||||
val currency = mockk<CryptoCurrency.Token>(relaxed = true) {
|
||||
every { this@mockk.network } returns network
|
||||
every { symbol } returns "USDT"
|
||||
every { name } returns "Tether"
|
||||
every { decimals } returns 6
|
||||
}
|
||||
val statusValue = mockk<CryptoCurrencyStatus.Loaded>(relaxed = true) {
|
||||
every { amount } returns BigDecimal("100")
|
||||
every { pendingTransactions } returns emptySet()
|
||||
}
|
||||
return SwapCurrencyStatus(
|
||||
userWallet = userWallet,
|
||||
status = CryptoCurrencyStatus(currency = currency, value = statusValue),
|
||||
account = Account.CryptoPortfolio.createMainAccount(userWalletId),
|
||||
)
|
||||
}
|
||||
|
||||
private fun buildCoinFeeStatus(): CryptoCurrencyStatus {
|
||||
val network = buildEthNetwork()
|
||||
val currency = mockk<CryptoCurrency.Coin>(relaxed = true) {
|
||||
every { this@mockk.network } returns network
|
||||
every { symbol } returns "ETH"
|
||||
every { name } returns "Ethereum"
|
||||
every { decimals } returns 18
|
||||
}
|
||||
val statusValue = mockk<CryptoCurrencyStatus.Loaded>(relaxed = true) {
|
||||
every { amount } returns BigDecimal.ZERO
|
||||
}
|
||||
return CryptoCurrencyStatus(currency = currency, value = statusValue)
|
||||
}
|
||||
|
||||
private fun buildQuotesLoadedState(
|
||||
providerType: ExchangeProviderType,
|
||||
txType: ExpressTxType? = null,
|
||||
): SwapState.QuotesLoadedState {
|
||||
val toStatusValue = mockk<CryptoCurrencyStatus.Loaded>(relaxed = true) {
|
||||
every { amount } returns BigDecimal("1")
|
||||
}
|
||||
val toCurrency = mockk<CryptoCurrency.Coin>(relaxed = true) {
|
||||
every { network } returns buildEthNetwork()
|
||||
every { symbol } returns "BTC"
|
||||
every { decimals } returns 8
|
||||
}
|
||||
val toSwapCurrencyStatus = SwapCurrencyStatus(
|
||||
userWallet = userWallet,
|
||||
status = CryptoCurrencyStatus(currency = toCurrency, value = toStatusValue),
|
||||
account = Account.CryptoPortfolio.createMainAccount(userWalletId),
|
||||
)
|
||||
return SwapState.QuotesLoadedState(
|
||||
fromTokenInfo = TokenSwapInfo(
|
||||
tokenAmount = SwapAmount(BigDecimal("50"), 6),
|
||||
swapCurrencyStatus = buildTokenFromStatus(),
|
||||
amountFiat = BigDecimal.ZERO,
|
||||
),
|
||||
toTokenInfo = TokenSwapInfo(
|
||||
tokenAmount = SwapAmount(BigDecimal("0.5"), 8),
|
||||
swapCurrencyStatus = toSwapCurrencyStatus,
|
||||
amountFiat = BigDecimal.ZERO,
|
||||
),
|
||||
priceImpact = PriceImpact.Empty,
|
||||
preparedSwapConfigState = PreparedSwapConfigState(
|
||||
balanceStatus = SwapBalanceStatus.InsufficientFee(
|
||||
feeCurrencyName = "Ethereum",
|
||||
feeCurrencySymbol = "ETH",
|
||||
),
|
||||
hasOutgoingTransaction = false,
|
||||
),
|
||||
permissionState = PermissionDataState.Empty,
|
||||
swapDataModel = null,
|
||||
currencyCheck = null,
|
||||
validationResult = null,
|
||||
minAdaValue = null,
|
||||
swapProvider = buildProvider(providerType),
|
||||
txType = txType,
|
||||
)
|
||||
}
|
||||
|
||||
private fun buildProvider(type: ExchangeProviderType): SwapProvider = SwapProvider(
|
||||
providerId = "p",
|
||||
rateTypes = listOf(RateType.FLOAT),
|
||||
name = "Provider",
|
||||
type = type,
|
||||
imageLarge = "",
|
||||
termsOfUse = null,
|
||||
privacyPolicy = null,
|
||||
isRecommended = false,
|
||||
slippage = null,
|
||||
isExtraIdSupported = false,
|
||||
)
|
||||
}
|
||||
|
|
@ -29,7 +29,6 @@ import com.tangem.domain.models.account.PaymentAccountStatusValue
|
|||
import com.tangem.domain.models.account.TangemPayCustomerTariffPlan
|
||||
import com.tangem.domain.models.account.VirtualAccountOnramp
|
||||
import com.tangem.domain.models.currency.CryptoCurrency
|
||||
import com.tangem.domain.models.pay.TangemPayCardFrozenState
|
||||
import com.tangem.domain.pay.flow.PaymentAccountStatusFetcher
|
||||
import com.tangem.domain.pay.flow.PaymentAccountStatusSupplier
|
||||
import com.tangem.domain.pay.model.TangemPayTopUpData
|
||||
|
|
@ -129,7 +128,6 @@ internal class TangemPayDetailsModel @Inject constructor(
|
|||
|
||||
private val refreshStateJobHolder = JobHolder()
|
||||
private val addToWalletBannerJobHolder = JobHolder()
|
||||
private val frozenStateJobHolder = JobHolder()
|
||||
|
||||
val bottomSheetNavigation: SlotNavigation<TangemPayDetailsNavigation> = SlotNavigation()
|
||||
|
||||
|
|
@ -162,9 +160,6 @@ internal class TangemPayDetailsModel @Inject constructor(
|
|||
isMuted = !state.isFresh,
|
||||
)
|
||||
uiState.update { balanceTransformer.transform(stateFactory.getLoadedState(state)) }
|
||||
state.cards.firstOrNull()?.let { card ->
|
||||
subscribeToCardFrozenState(card.id)
|
||||
}
|
||||
}
|
||||
else -> uiState.update { stateFactory.getLoadingState() }
|
||||
}
|
||||
|
|
@ -195,33 +190,11 @@ internal class TangemPayDetailsModel @Inject constructor(
|
|||
|
||||
fun isRedesignEnabled(): Boolean = tangemPayFeatureToggles.isRedesignEnabled
|
||||
|
||||
private fun subscribeToCardFrozenState(cardId: String) {
|
||||
frozenStateJobHolder.cancel()
|
||||
cardDetailsRepository
|
||||
.cardFrozenState(cardId)
|
||||
.onEach { frozenState ->
|
||||
// Mirror getLoadedState gating so a live freeze update can't re-enable actions on stale data.
|
||||
val isFresh = currentStatus.value.ifLoadedOrNull { it.isFresh } == true
|
||||
val isUnfrozen = frozenState == TangemPayCardFrozenState.Unfrozen
|
||||
val areActionButtonsEnabled = isFresh && isUnfrozen
|
||||
val hasWithdrawableBalance = currentStatus.value.balanceOrNull()?.hasWithdrawableAmount == true
|
||||
uiState.update(
|
||||
TangemPayActionButtonsTransformer(
|
||||
stateFactory.getActionButtonsConfig(
|
||||
isAddFundsEnabled = areActionButtonsEnabled,
|
||||
isWithdrawEnabled = areActionButtonsEnabled && hasWithdrawableBalance,
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
.launchIn(modelScope)
|
||||
.saveIn(frozenStateJobHolder)
|
||||
}
|
||||
|
||||
override fun onClickAddFunds() {
|
||||
analytics.send(TangemPayAnalyticsEvents.AddFundsClicked())
|
||||
val balance = currentStatus.value.balanceOrNull()
|
||||
if (balance == null) {
|
||||
val address = currentStatus.value.ifLoadedOrNull { it.depositAddress }
|
||||
if (balance == null || address.isNullOrEmpty()) {
|
||||
showBottomSheetError(TangemPayDetailsErrorType.Receive)
|
||||
} else {
|
||||
bottomSheetNavigation.activate(
|
||||
|
|
|
|||
|
|
@ -178,7 +178,11 @@ private fun FeeInfoRow(titleRes: Int, value: String, showDivider: Boolean = fals
|
|||
},
|
||||
valueSlot = {
|
||||
if (value.isEmpty()) {
|
||||
TangemShimmer(style = TangemTheme.typography3.body.medium)
|
||||
TangemShimmer(
|
||||
modifier = Modifier.width(80.dp),
|
||||
style = TangemTheme.typography3.body.medium,
|
||||
textAlign = TextAlign.End,
|
||||
)
|
||||
} else {
|
||||
TangemRowText(
|
||||
text = value,
|
||||
|
|
@ -299,7 +303,7 @@ private fun ReissueCardSheetPreview(state: TangemPayReissueCardUM) {
|
|||
|
||||
private class TangemPayReissueCardUMPreviewProvider : CollectionPreviewParameterProvider<TangemPayReissueCardUM>(
|
||||
collection = listOf(
|
||||
TangemPayReissueCardUM.stub(error = null),
|
||||
TangemPayReissueCardUM.stub(error = null, feeAmount = ""),
|
||||
TangemPayReissueCardUM.stub(
|
||||
error = TangemPayReissueCardError.InsufficientFunds,
|
||||
cardBalance = "$0.05",
|
||||
|
|
|
|||
|
|
@ -1,11 +1,8 @@
|
|||
package com.tangem.features.tangempay.utils
|
||||
|
||||
import com.tangem.domain.models.StatusSource
|
||||
import com.tangem.domain.models.account.AccountStatus
|
||||
import com.tangem.domain.models.account.PaymentAccountStatusValue
|
||||
import com.tangem.domain.models.account.findCardWithId
|
||||
import com.tangem.domain.models.currency.CryptoCurrency
|
||||
import com.tangem.domain.models.pay.TangemPayCard
|
||||
import com.tangem.domain.models.wallet.UserWalletId
|
||||
|
||||
internal val AccountStatus.Payment.userWalletId: UserWalletId
|
||||
|
|
@ -22,14 +19,12 @@ internal val AccountStatus.Payment.isDeactivated: Boolean
|
|||
get() = value is PaymentAccountStatusValue.Deactivated
|
||||
|
||||
internal val PaymentAccountStatusValue.Loaded.isFresh: Boolean
|
||||
get() = source == StatusSource.ACTUAL && error == null
|
||||
get() = source.isActual() && error == null
|
||||
|
||||
internal fun AccountStatus.Payment.requireLoaded(): PaymentAccountStatusValue.Loaded =
|
||||
value as? PaymentAccountStatusValue.Loaded
|
||||
?: error("Card-detail subflow requires Loaded status, got ${value::class.simpleName}")
|
||||
|
||||
internal fun AccountStatus.Payment.firstCard(): TangemPayCard = requireLoaded().cards.first()
|
||||
|
||||
internal inline fun <T> AccountStatus.Payment.ifLoadedOrNull(call: (PaymentAccountStatusValue.Loaded) -> T): T? {
|
||||
val value = value
|
||||
return if (value is PaymentAccountStatusValue.Loaded) {
|
||||
|
|
@ -46,20 +41,4 @@ internal fun AccountStatus.Payment.balanceOrNull(): PaymentAccountStatusValue.Ba
|
|||
}
|
||||
|
||||
internal val PaymentAccountStatusValue.Balance.hasWithdrawableAmount: Boolean
|
||||
get() = availableForWithdrawal.signum() > 0
|
||||
|
||||
internal fun AccountStatus.Payment.findCard(
|
||||
initialCardId: String,
|
||||
initialStatus: AccountStatus.Payment,
|
||||
): TangemPayCard? {
|
||||
val value = value
|
||||
|
||||
if (value !is PaymentAccountStatusValue.Loaded || value.source != StatusSource.ACTUAL) return null
|
||||
|
||||
val initialCard = value.findCardWithId(initialCardId)
|
||||
val newCards = initialStatus.ifLoadedOrNull { status ->
|
||||
val initialCardIds = status.cards.mapTo(mutableSetOf()) { it.id }
|
||||
value.cards.filterNot { it.id in initialCardIds }
|
||||
}
|
||||
return initialCard ?: newCards?.firstOrNull()
|
||||
}
|
||||
get() = availableForWithdrawal.signum() > 0
|
||||
|
|
@ -37,27 +37,6 @@ internal class TangemPayDetailsModelTest {
|
|||
private val paymentAccountStatusSupplier: PaymentAccountStatusSupplier = mockk()
|
||||
private val cardDetailsRepository: TangemPayCardDetailsRepository = mockk(relaxed = true)
|
||||
|
||||
@ParameterizedTest
|
||||
@MethodSource("provideFreezeCases")
|
||||
fun `GIVEN frozen state and balance WHEN status loaded THEN action buttons gated accordingly`(
|
||||
case: FreezeCase,
|
||||
) = runTest {
|
||||
// Arrange + Act
|
||||
val model = createModel(
|
||||
testScope = this,
|
||||
statusSource = case.statusSource,
|
||||
frozenState = case.frozenState,
|
||||
availableForWithdrawal = case.availableForWithdrawal,
|
||||
)
|
||||
advanceUntilIdle()
|
||||
|
||||
// Assert
|
||||
val state = model.uiState.value
|
||||
assertThat(state.addFundsButton.isEnabled).isEqualTo(case.expectedAddFundsEnabled)
|
||||
assertThat(state.withdrawButton.isEnabled).isEqualTo(case.expectedWithdrawEnabled)
|
||||
model.onDestroy()
|
||||
}
|
||||
|
||||
@ParameterizedTest
|
||||
@MethodSource("provideMutedCases")
|
||||
fun `GIVEN status source WHEN status loaded THEN balance is muted only when cached`(case: MutedCase) = runTest {
|
||||
|
|
|
|||
|
|
@ -31,7 +31,10 @@ import dagger.assisted.Assisted
|
|||
import dagger.assisted.AssistedFactory
|
||||
import dagger.assisted.AssistedInject
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.flow.firstOrNull
|
||||
import kotlinx.coroutines.flow.mapNotNull
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withTimeoutOrNull
|
||||
|
||||
@Suppress("LongParameterList")
|
||||
internal class DefaultTokenDetailsDeepLinkHandler @AssistedInject constructor(
|
||||
|
|
@ -83,10 +86,7 @@ internal class DefaultTokenDetailsDeepLinkHandler @AssistedInject constructor(
|
|||
}
|
||||
}
|
||||
|
||||
// Refresh the portfolio before searching so a token just added on the backend is present locally.
|
||||
refreshAccountsIfNeeded(userWallet)
|
||||
|
||||
val cryptoCurrency = findCryptoCurrency(userWallet = userWallet, networkId = networkId, tokenId = tokenId)
|
||||
val cryptoCurrency = resolveCryptoCurrency(userWallet, networkId, tokenId)
|
||||
|
||||
if (cryptoCurrency == null) {
|
||||
TangemLogger.e(
|
||||
|
|
@ -130,6 +130,28 @@ internal class DefaultTokenDetailsDeepLinkHandler @AssistedInject constructor(
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves the target currency for the deeplink: refreshes the portfolio when needed and searches for the token.
|
||||
*
|
||||
* A multi-currency link needs both [networkId] and [tokenId] to match a token; a malformed link can never match,
|
||||
* so we skip the refresh/await entirely to avoid wasted backend work and return immediately for the redirect.
|
||||
*/
|
||||
private suspend fun resolveCryptoCurrency(
|
||||
userWallet: UserWallet,
|
||||
networkId: String?,
|
||||
tokenId: String?,
|
||||
): CryptoCurrency? {
|
||||
if (userWallet.isMultiCurrency && (networkId.isNullOrBlank() || tokenId.isNullOrBlank())) return null
|
||||
|
||||
val wasRefreshed = refreshAccountsIfNeeded(userWallet)
|
||||
return findCryptoCurrency(
|
||||
userWallet = userWallet,
|
||||
networkId = networkId,
|
||||
tokenId = tokenId,
|
||||
awaitOnMiss = wasRefreshed,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Refreshes wallet accounts so a token just added on the backend appears in the local portfolio.
|
||||
*
|
||||
|
|
@ -137,12 +159,17 @@ internal class DefaultTokenDetailsDeepLinkHandler @AssistedInject constructor(
|
|||
* on cold start the fresh list is already loaded by the regular auth flow, and single-currency
|
||||
* wallets have a fixed token. The fetch is best-effort — on failure we fall through and try the
|
||||
* current cache, so existing tokens (e.g. swap/onramp pushes) still open without regression.
|
||||
*
|
||||
* @return `true` only when a refresh was actually performed and succeeded. Waiting for the refreshed
|
||||
* list (see [awaitCryptoCurrency]) makes sense only in that case; otherwise there is nothing to wait for.
|
||||
*/
|
||||
private suspend fun refreshAccountsIfNeeded(userWallet: UserWallet) {
|
||||
private suspend fun refreshAccountsIfNeeded(userWallet: UserWallet): Boolean {
|
||||
if (isFromOnNewIntent && userWallet.isMultiCurrency) {
|
||||
singleAccountListFetcher(SingleAccountListFetcher.Params(userWalletId = userWallet.walletId))
|
||||
return singleAccountListFetcher(SingleAccountListFetcher.Params(userWalletId = userWallet.walletId))
|
||||
.onLeft { TangemLogger.e("Error on refreshing wallet accounts", it) }
|
||||
.isRight()
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
private suspend fun fetchCurrency(userWallet: UserWallet, cryptoCurrency: CryptoCurrency) {
|
||||
|
|
@ -158,26 +185,42 @@ internal class DefaultTokenDetailsDeepLinkHandler @AssistedInject constructor(
|
|||
}
|
||||
}
|
||||
|
||||
private suspend fun findCryptoCurrency(userWallet: UserWallet, networkId: String?, tokenId: String?) =
|
||||
if (userWallet.isMultiCurrency) {
|
||||
val derivationPath = queryParams[DERIVATION_PATH_KEY]
|
||||
|
||||
getCryptoCurrencies(userWalletId = userWallet.walletId)?.firstOrNull { currency ->
|
||||
val isNetwork = currency.network.rawId.equals(networkId, ignoreCase = true)
|
||||
val isCurrency = currency.id.rawCurrencyId?.value?.equals(tokenId, ignoreCase = true) == true
|
||||
|
||||
val isDefaultDerivation = currency.network.derivationPath is Network.DerivationPath.Card
|
||||
val isCustomDerivation = derivationPath?.equals(currency.network.derivationPath.value) == true
|
||||
val isCorrectDerivation = isDefaultDerivation || isCustomDerivation
|
||||
isNetwork && isCurrency && isCorrectDerivation
|
||||
}
|
||||
} else {
|
||||
singleAccountListSupplier.getSyncOrNull(userWalletId = userWallet.walletId)
|
||||
private suspend fun findCryptoCurrency(
|
||||
userWallet: UserWallet,
|
||||
networkId: String?,
|
||||
tokenId: String?,
|
||||
awaitOnMiss: Boolean,
|
||||
): CryptoCurrency? {
|
||||
if (!userWallet.isMultiCurrency) {
|
||||
return singleAccountListSupplier.getSyncOrNull(userWalletId = userWallet.walletId)
|
||||
?.mainAccount?.cryptoCurrencies?.first()
|
||||
}
|
||||
|
||||
private suspend fun getCryptoCurrencies(userWalletId: UserWalletId): List<CryptoCurrency>? {
|
||||
return singleAccountListSupplier.getSyncOrNull(userWalletId)?.flattenCurrencies()
|
||||
val derivationPath = queryParams[DERIVATION_PATH_KEY]
|
||||
val matches = { currency: CryptoCurrency -> currency.matches(networkId, tokenId, derivationPath) }
|
||||
|
||||
return singleAccountListSupplier.getSyncOrNull(userWallet.walletId)?.flattenCurrencies()?.firstOrNull(matches)
|
||||
// getSyncOrNull returns the stale SharedFlow replay just after a fetch; wait for the refreshed list.
|
||||
// Only when a refresh actually ran and succeeded — otherwise a missing token would block for the full
|
||||
// timeout before the fall-through redirect.
|
||||
?: if (awaitOnMiss) awaitCryptoCurrency(userWallet.walletId, matches) else null
|
||||
}
|
||||
|
||||
private suspend fun awaitCryptoCurrency(
|
||||
userWalletId: UserWalletId,
|
||||
matches: (CryptoCurrency) -> Boolean,
|
||||
): CryptoCurrency? = withTimeoutOrNull(TOKEN_APPEARANCE_TIMEOUT_MILLIS) {
|
||||
singleAccountListSupplier(userWalletId)
|
||||
.mapNotNull { accountList -> accountList.flattenCurrencies().firstOrNull(matches) }
|
||||
.firstOrNull()
|
||||
}
|
||||
|
||||
private fun CryptoCurrency.matches(networkId: String?, tokenId: String?, derivationPath: String?): Boolean {
|
||||
val isNetwork = network.rawId.equals(networkId, ignoreCase = true)
|
||||
val isCurrency = id.rawCurrencyId?.value?.equals(tokenId, ignoreCase = true) == true
|
||||
val isDefaultDerivation = network.derivationPath is Network.DerivationPath.Card
|
||||
val isCustomDerivation = derivationPath?.equals(network.derivationPath.value) == true
|
||||
return isNetwork && isCurrency && (isDefaultDerivation || isCustomDerivation)
|
||||
}
|
||||
|
||||
@AssistedFactory
|
||||
|
|
@ -188,4 +231,8 @@ internal class DefaultTokenDetailsDeepLinkHandler @AssistedInject constructor(
|
|||
isFromOnNewIntent: Boolean,
|
||||
): DefaultTokenDetailsDeepLinkHandler
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val TOKEN_APPEARANCE_TIMEOUT_MILLIS = 3_000L
|
||||
}
|
||||
}
|
||||
|
|
@ -1,12 +1,7 @@
|
|||
package com.tangem.feature.tokendetails.presentation.tokendetails.ui
|
||||
|
||||
import android.content.res.Configuration
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
|
|
@ -103,7 +98,6 @@ private fun QuickTopUpBlock_Preview() {
|
|||
),
|
||||
),
|
||||
),
|
||||
modifier = Modifier.padding(TangemTheme.dimens2.x3),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -226,7 +226,7 @@ private fun TokenDetailsBody(
|
|||
item(key = "quick_top_up_block") {
|
||||
QuickTopUpBlock(
|
||||
state = quickTopUpBlock,
|
||||
modifier = itemModifier.padding(vertical = TangemTheme.dimens2.x0),
|
||||
modifier = itemModifier.padding(top = 8.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,10 +1,6 @@
|
|||
package com.tangem.feature.tokendetails.presentation.tokendetails.ui.bottomsheet
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
|
|
@ -87,7 +83,7 @@ private fun SwapAndSendActionRow(state: TransferUM) {
|
|||
if (state is TransferUM.Content && row == null) return
|
||||
ActionRow(
|
||||
iconRes = CoreR.drawable.ic_exchange_mini_24,
|
||||
title = resourceReference(CoreR.string.common_send_with_swap),
|
||||
title = resourceReference(CoreR.string.send_with_swap_confirm_title),
|
||||
description = resourceReference(CoreR.string.quick_action_send_and_swap_description),
|
||||
row = row,
|
||||
isLoading = state is TransferUM.Loading,
|
||||
|
|
|
|||
|
|
@ -31,6 +31,7 @@ import com.tangem.utils.logging.TangemLogger
|
|||
import io.mockk.*
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.test.advanceUntilIdle
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.jupiter.api.BeforeEach
|
||||
|
|
@ -532,6 +533,9 @@ class DefaultTokenDetailsDeepLinkHandlerTest {
|
|||
mockMultiCurrencyWallet(userWalletId)
|
||||
mockSelectWallet(userWalletId)
|
||||
coEvery { singleAccountListSupplier.getSyncOrNull(userWalletId) } returns null
|
||||
every { singleAccountListSupplier.invoke(userWalletId) } returns MutableStateFlow(
|
||||
AccountList.empty(userWalletId = userWalletId, cryptoCurrencies = emptyList()),
|
||||
)
|
||||
|
||||
createHandler(scope = this, defaultQueryParams(), isFromOnNewIntent = true)
|
||||
advanceUntilIdle()
|
||||
|
|
@ -565,6 +569,108 @@ class DefaultTokenDetailsDeepLinkHandlerTest {
|
|||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN token appears only after refresh WHEN handle deeplink THEN push new route`() = runTest {
|
||||
val userWalletId = UserWalletId("011")
|
||||
val cryptoCurrency = mockCryptoCurrency()
|
||||
mockMultiCurrencyWallet(userWalletId)
|
||||
mockSelectWallet(userWalletId)
|
||||
|
||||
val staleList = AccountList.empty(userWalletId = userWalletId, cryptoCurrencies = emptyList())
|
||||
val freshList = AccountList.empty(userWalletId = userWalletId, cryptoCurrencies = listOf(cryptoCurrency))
|
||||
val accountListFlow = MutableStateFlow(staleList)
|
||||
|
||||
coEvery { singleAccountListSupplier.getSyncOrNull(userWalletId) } returns staleList
|
||||
every { singleAccountListSupplier.invoke(userWalletId) } returns accountListFlow
|
||||
coEvery { singleAccountListFetcher.invoke(SingleAccountListFetcher.Params(userWalletId)) } answers {
|
||||
accountListFlow.value = freshList
|
||||
Either.Right(Unit)
|
||||
}
|
||||
every {
|
||||
cryptoCurrencyBalanceFetcher.invoke(userWalletId = userWalletId, currency = cryptoCurrency)
|
||||
} just Runs
|
||||
val expectedRoute = AppRoute.CurrencyDetails(userWalletId = userWalletId, currency = cryptoCurrency)
|
||||
|
||||
createHandler(scope = this, defaultQueryParams(), isFromOnNewIntent = true)
|
||||
advanceUntilIdle()
|
||||
|
||||
verify { appRouter.push(route = expectedRoute, onComplete = any()) }
|
||||
verify(exactly = 0) { appRouter.popTo(route = AppRoute.Wallet, onComplete = any()) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN cold start AND token missing WHEN handle deeplink THEN redirect to main without awaiting`() = runTest {
|
||||
// Arrange
|
||||
val userWalletId = UserWalletId("011")
|
||||
mockMultiCurrencyWallet(userWalletId)
|
||||
mockSelectWallet(userWalletId)
|
||||
coEvery { singleAccountListSupplier.getSyncOrNull(userWalletId) } returns AccountList.empty(
|
||||
userWalletId = userWalletId,
|
||||
cryptoCurrencies = emptyList(),
|
||||
)
|
||||
|
||||
// Act
|
||||
createHandler(scope = this, defaultQueryParams(), isFromOnNewIntent = false)
|
||||
advanceUntilIdle()
|
||||
|
||||
// Assert
|
||||
verify { appRouter.popTo(route = AppRoute.Wallet, onComplete = any()) }
|
||||
coVerify(exactly = 0) { singleAccountListFetcher.invoke(any()) }
|
||||
verify(exactly = 0) { singleAccountListSupplier.invoke(any<UserWalletId>()) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN refresh failed AND token missing WHEN handle deeplink THEN redirect to main without awaiting`() =
|
||||
runTest {
|
||||
// Arrange
|
||||
val userWalletId = UserWalletId("011")
|
||||
mockMultiCurrencyWallet(userWalletId)
|
||||
mockSelectWallet(userWalletId)
|
||||
coEvery {
|
||||
singleAccountListFetcher.invoke(SingleAccountListFetcher.Params(userWalletId))
|
||||
} returns Either.Left(IllegalStateException("service unavailable"))
|
||||
coEvery { singleAccountListSupplier.getSyncOrNull(userWalletId) } returns AccountList.empty(
|
||||
userWalletId = userWalletId,
|
||||
cryptoCurrencies = emptyList(),
|
||||
)
|
||||
|
||||
// Act
|
||||
createHandler(scope = this, defaultQueryParams(), isFromOnNewIntent = true)
|
||||
advanceUntilIdle()
|
||||
|
||||
// Assert
|
||||
verify { appRouter.popTo(route = AppRoute.Wallet, onComplete = any()) }
|
||||
verify(exactly = 0) { singleAccountListSupplier.invoke(any<UserWalletId>()) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN malformed deeplink AND refresh succeeded WHEN handle deeplink THEN redirect to main without awaiting`() =
|
||||
runTest {
|
||||
// Arrange
|
||||
val userWalletId = UserWalletId("011")
|
||||
mockMultiCurrencyWallet(userWalletId)
|
||||
mockSelectWallet(userWalletId)
|
||||
coEvery { singleAccountListSupplier.getSyncOrNull(userWalletId) } returns AccountList.empty(
|
||||
userWalletId = userWalletId,
|
||||
cryptoCurrencies = emptyList(),
|
||||
)
|
||||
val queryParams = mapOf(
|
||||
WALLET_ID_KEY to "011",
|
||||
NETWORK_ID_KEY to "123",
|
||||
DERIVATION_PATH_KEY to "777",
|
||||
// TOKEN_ID_KEY is missing
|
||||
)
|
||||
|
||||
// Act
|
||||
createHandler(scope = this, queryParams, isFromOnNewIntent = true)
|
||||
advanceUntilIdle()
|
||||
|
||||
// Assert
|
||||
verify { appRouter.popTo(route = AppRoute.Wallet, onComplete = any()) }
|
||||
verify(exactly = 0) { singleAccountListSupplier.invoke(any<UserWalletId>()) }
|
||||
coVerify(exactly = 0) { singleAccountListFetcher.invoke(any()) }
|
||||
}
|
||||
|
||||
private fun defaultQueryParams() = mapOf(
|
||||
WALLET_ID_KEY to "011",
|
||||
NETWORK_ID_KEY to "123",
|
||||
|
|
|
|||
|
|
@ -129,8 +129,8 @@ internal sealed class WalletNotificationUM(val messageUM: TangemMessageUM, val t
|
|||
data class BackupError(val onClick: () -> Unit) : WalletNotificationUM(
|
||||
messageUM = TangemMessageUM(
|
||||
id = "BackupErrorNotification",
|
||||
title = resourceReference(id = R.string.warning_backup_errors_title),
|
||||
subtitle = resourceReference(id = R.string.warning_backup_errors_message),
|
||||
title = resourceReference(id = R.string.warning_incomplete_backup_notification_title),
|
||||
subtitle = resourceReference(id = R.string.warning_incomplete_backup_notification_message),
|
||||
messageEffect = TangemMessageEffect.Warning,
|
||||
iconUM = TangemIconUM.Icon(
|
||||
iconRes = R.drawable.ic_attention_default_24,
|
||||
|
|
|
|||
|
|
@ -12,9 +12,9 @@ import com.tangem.domain.models.currency.yieldSupplyKey
|
|||
import com.tangem.domain.models.staking.StakingBalance
|
||||
import com.tangem.domain.staking.model.StakingAvailability
|
||||
import com.tangem.domain.staking.model.StakingOption
|
||||
import com.tangem.domain.staking.model.optionOrNull
|
||||
import com.tangem.domain.staking.model.common.RewardInfo
|
||||
import com.tangem.domain.staking.model.common.RewardType
|
||||
import com.tangem.domain.staking.model.optionOrNull
|
||||
import com.tangem.lib.crypto.BlockchainUtils
|
||||
import com.tangem.utils.converter.Converter
|
||||
import java.math.BigDecimal
|
||||
|
|
@ -34,7 +34,7 @@ internal class EarnApyConverter(
|
|||
)
|
||||
}?.value
|
||||
if (yieldSupplyApy != null) {
|
||||
val isActive = value.value.yieldSupplyStatus?.isActive == false
|
||||
val isActive = value.value.yieldSupplyStatus?.isActive == true
|
||||
return EarnApyInfo(
|
||||
text = resourceReference(
|
||||
R.string.yield_module_earn_badge,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue