Updated on 2026-08-14

This commit is contained in:
Tangem 2025-07-15 17:35:00 +03:00
commit b73c5486d3
29 changed files with 944 additions and 501 deletions

View file

@ -30,9 +30,9 @@ import com.tangem.common.routing.entity.SerializableIntent
import com.tangem.core.analytics.api.AnalyticsEventHandler
import com.tangem.core.decompose.context.AppComponentContext
import com.tangem.core.decompose.di.RootAppComponentContext
import com.tangem.core.deeplink.DEEPLINK_KEY
import com.tangem.core.deeplink.DeepLinksRegistry
import com.tangem.core.deeplink.WEBLINK_KEY
import com.tangem.core.deeplink.converter.PayloadToDeeplinkConverter
import com.tangem.core.navigation.url.UrlOpener
import com.tangem.data.balancehiding.DefaultDeviceFlipDetector
import com.tangem.data.card.sdk.CardSdkOwner
@ -476,7 +476,7 @@ class MainActivity : AppCompatActivity(), ActivityResultCallbackHolder {
}
private fun handleDeepLink(intent: Intent, isFromOnNewIntent: Boolean) {
val deepLinkExtras = intent.getStringExtra(DEEPLINK_KEY)?.toUri()
val deepLinkExtras = PayloadToDeeplinkConverter.convertBundle(intent.extras)?.toUri()
val webLink = intent.getStringExtra(WEBLINK_KEY)
val receivedDeepLink = intent.data ?: deepLinkExtras

View file

@ -15,9 +15,6 @@ import coil.executeBlocking
import coil.request.ImageRequest
import com.google.firebase.messaging.FirebaseMessagingService
import com.google.firebase.messaging.RemoteMessage
import com.tangem.core.deeplink.DEEPLINK_KEY
import com.tangem.core.deeplink.WEBLINK_KEY
import com.tangem.core.deeplink.converter.PayloadToDeeplinkConverter
import com.tangem.domain.common.LogConfig
import com.tangem.tap.MainActivity
import com.tangem.tap.common.images.createCoilImageLoader
@ -38,11 +35,11 @@ internal class TangemPushNotificationService : FirebaseMessagingService() {
val notification = message.notification ?: return
val channelId = notification.channelId ?: TANGEM_CHANNEL_ID
val deeplink = PayloadToDeeplinkConverter.convert(message.data)
val intent = Intent(applicationContext, MainActivity::class.java).apply {
putExtra(DEEPLINK_KEY, deeplink)
putExtra(WEBLINK_KEY, message.data[WEBLINK_KEY])
message.data.forEach {
putExtra(it.key, it.value)
}
putExtra(OnPushClickedIntentHandler.OPENED_FROM_GCM_PUSH, true)
addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP)
}

View file

@ -5,7 +5,6 @@ import com.tangem.datasource.exchangeservice.swap.ExpressServiceLoader
import com.tangem.domain.card.ScanCardUseCase
import com.tangem.domain.card.repository.CardSdkConfigRepository
import com.tangem.domain.exchange.RampStateManager
import com.tangem.domain.tokens.GetNetworkCoinStatusUseCase
import com.tangem.domain.tokens.GetPolkadotCheckHasImmortalUseCase
import com.tangem.domain.tokens.GetPolkadotCheckHasResetUseCase
import com.tangem.domain.tokens.repository.CurrenciesRepository
@ -49,7 +48,6 @@ internal object ActivityModule {
appStateHolder: AppStateHolder,
expressServiceLoader: ExpressServiceLoader,
currenciesRepository: CurrenciesRepository,
getNetworkCoinStatusUseCase: GetNetworkCoinStatusUseCase,
excludedBlockchains: ExcludedBlockchains,
dispatchers: CoroutineDispatcherProvider,
): RampStateManager {
@ -57,7 +55,6 @@ internal object ActivityModule {
sellService = Provider { requireNotNull(appStateHolder.sellService) },
expressServiceLoader = expressServiceLoader,
currenciesRepository = currenciesRepository,
getNetworkCoinStatusUseCase = getNetworkCoinStatusUseCase,
dispatchers = dispatchers,
excludedBlockchains = excludedBlockchains,
)

View file

@ -256,20 +256,16 @@ internal object TokensDomainModule {
fun provideGetCryptoCurrencyActionsUseCase(
rampStateManager: RampStateManager,
walletManagersFacade: WalletManagersFacade,
currenciesRepository: CurrenciesRepository,
stakingRepository: StakingRepository,
promoRepository: PromoRepository,
dispatchers: CoroutineDispatcherProvider,
currencyStatusOperations: BaseCurrencyStatusOperations,
): GetCryptoCurrencyActionsUseCase {
return GetCryptoCurrencyActionsUseCase(
rampManager = rampStateManager,
walletManagersFacade = walletManagersFacade,
currenciesRepository = currenciesRepository,
stakingRepository = stakingRepository,
promoRepository = promoRepository,
dispatchers = dispatchers,
currencyStatusOperations = currencyStatusOperations,
)
}

View file

@ -14,11 +14,9 @@ import com.tangem.domain.exchange.ExpressAvailabilityState
import com.tangem.domain.exchange.RampStateManager
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.scan.ScanResponse
import com.tangem.domain.tokens.GetNetworkCoinStatusUseCase
import com.tangem.domain.tokens.model.CryptoCurrencyStatus
import com.tangem.domain.tokens.model.ScenarioUnavailabilityReason
import com.tangem.domain.tokens.repository.CurrenciesRepository
import com.tangem.domain.wallets.models.UserWallet
import com.tangem.domain.wallets.models.UserWalletId
import com.tangem.utils.Provider
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
@ -32,7 +30,6 @@ internal class DefaultRampManager(
private val sellService: Provider<ExchangeService>,
private val expressServiceLoader: ExpressServiceLoader,
private val currenciesRepository: CurrenciesRepository,
private val getNetworkCoinStatusUseCase: GetNetworkCoinStatusUseCase,
private val dispatchers: CoroutineDispatcherProvider,
excludedBlockchains: ExcludedBlockchains,
) : RampStateManager {
@ -52,8 +49,9 @@ internal class DefaultRampManager(
}
override suspend fun availableForSell(
userWallet: UserWallet,
userWalletId: UserWalletId,
status: CryptoCurrencyStatus,
sendUnavailabilityReason: ScenarioUnavailabilityReason?,
): Either<ScenarioUnavailabilityReason, Unit> {
return either {
val sellSupportedByService = catch(
@ -65,7 +63,8 @@ internal class DefaultRampManager(
catch = { raise(ScenarioUnavailabilityReason.NotSupportedBySellService(status.currency.name)) },
)
val reason = getSendUnavailabilityReason(userWallet = userWallet, cryptoCurrencyStatus = status)
val reason = sendUnavailabilityReason
?: getSendUnavailabilityReason(userWalletId = userWalletId, cryptoCurrencyStatus = status)
ensure(condition = reason is ScenarioUnavailabilityReason.None) {
when (reason) {
@ -111,6 +110,27 @@ internal class DefaultRampManager(
return expressServiceLoader.getInitializationStatus(userWalletId)
}
override suspend fun getSendUnavailabilityReason(
userWalletId: UserWalletId,
cryptoCurrencyStatus: CryptoCurrencyStatus,
): ScenarioUnavailabilityReason {
return when {
cryptoCurrencyStatus.value.amount.isNullOrZero() -> {
ScenarioUnavailabilityReason.EmptyBalance(ScenarioUnavailabilityReason.WithdrawalScenario.SEND)
}
currenciesRepository.isSendBlockedByPendingTransactions(
userWalletId = userWalletId,
cryptoCurrencyStatus = cryptoCurrencyStatus,
) -> {
ScenarioUnavailabilityReason.PendingTransaction(
withdrawalScenario = ScenarioUnavailabilityReason.WithdrawalScenario.SEND,
networkName = cryptoCurrencyStatus.currency.network.name,
)
}
else -> ScenarioUnavailabilityReason.None
}
}
private suspend fun getExchangeableState(
userWalletId: UserWalletId,
cryptoCurrency: CryptoCurrency,
@ -179,33 +199,4 @@ internal class DefaultRampManager(
val contractAddress = (this as? CryptoCurrency.Token)?.contractAddress ?: EMPTY_CONTRACT_ADDRESS_VALUE
return asset.network == network.backendId && asset.contractAddress.equals(contractAddress, ignoreCase = true)
}
private suspend fun getSendUnavailabilityReason(
userWallet: UserWallet,
cryptoCurrencyStatus: CryptoCurrencyStatus,
): ScenarioUnavailabilityReason {
val coinStatus = getNetworkCoinStatusUseCase.invokeSync(
userWallet = userWallet,
networkId = cryptoCurrencyStatus.currency.network.id,
derivationPath = cryptoCurrencyStatus.currency.network.derivationPath,
).getOrNull()
return when {
cryptoCurrencyStatus.value.amount.isNullOrZero() -> {
ScenarioUnavailabilityReason.EmptyBalance(ScenarioUnavailabilityReason.WithdrawalScenario.SEND)
}
currenciesRepository.isSendBlockedByPendingTransactions(
cryptoCurrencyStatus = cryptoCurrencyStatus,
coinStatus = coinStatus,
) -> {
ScenarioUnavailabilityReason.PendingTransaction(
withdrawalScenario = ScenarioUnavailabilityReason.WithdrawalScenario.SEND,
networkName = coinStatus?.currency?.network?.name.orEmpty(),
)
}
else -> {
ScenarioUnavailabilityReason.None
}
}
}
}

View file

@ -1,5 +1,6 @@
package com.tangem.core.deeplink.converter
import android.os.Bundle
import com.tangem.common.routing.DeepLinkRoute
import com.tangem.common.routing.DeepLinkScheme
import com.tangem.core.deeplink.DEEPLINK_KEY
@ -22,13 +23,25 @@ object PayloadToDeeplinkConverter : Converter<Map<String, String>, String?> {
}
}
fun convertBundle(bundle: Bundle?): String? {
if (bundle == null) return null
val bundleDataMap = mutableMapOf<String, String>()
for (key in bundle.keySet()) {
val value = bundle.getString(key)
if (value != null) {
bundleDataMap[key] = value
}
}
return convert(bundleDataMap)
}
@Suppress("ReturnCount")
private fun buildNotificationDeeplink(payload: Map<String, String>): String? {
val type = payload[TYPE_KEY] ?: return null
val networkId = payload[NETWORK_ID_KEY] ?: return null
val tokenId = payload[TOKEN_ID_KEY] ?: return null
val walletId = payload[WALLET_ID_KEY] ?: return null
val derivationPath = payload[DERIVATION_PATH_KEY] ?: return null
val derivationPath = payload[DERIVATION_PATH_KEY].orEmpty()
val transactionId = payload[TRANSACTION_ID_KEY]
val name = payload[NAME_KEY]
@ -38,7 +51,9 @@ object PayloadToDeeplinkConverter : Converter<Map<String, String>, String?> {
addQueryParam(TOKEN_ID_KEY, tokenId)
addQueryParam(TYPE_KEY, type)
addQueryParam(WALLET_ID_KEY, walletId)
addQueryParam(DERIVATION_PATH_KEY, derivationPath)
if (derivationPath.isNotBlank()) {
addQueryParam(DERIVATION_PATH_KEY, derivationPath)
}
transactionId?.let { addQueryParam(TRANSACTION_ID_KEY, it) }
name?.let { addQueryParam(NAME_KEY, it) }
@ -49,7 +64,6 @@ object PayloadToDeeplinkConverter : Converter<Map<String, String>, String?> {
return payload.containsKey(TYPE_KEY) &&
payload.containsKey(NETWORK_ID_KEY) &&
payload.containsKey(TOKEN_ID_KEY) &&
payload.containsKey(WALLET_ID_KEY) &&
payload.containsKey(DERIVATION_PATH_KEY)
payload.containsKey(WALLET_ID_KEY)
}
}

View file

@ -48,6 +48,25 @@ internal class PayloadToDeeplinkConverterTest {
)
}
@Test
fun `GIVEN push notification payload without derivationPath WHEN convert THEN should return correct deeplink without derivation_path`() {
// GIVEN
val payload = mapOf(
TYPE_KEY to "token",
NETWORK_ID_KEY to "ethereum",
TOKEN_ID_KEY to "0x123",
WALLET_ID_KEY to "wallet123",
)
// WHEN
val result = PayloadToDeeplinkConverter.convert(payload)
// THEN
assertThat(result).isEqualTo(
"tangem://token?network_id=ethereum&token_id=0x123&type=token&user_wallet_id=wallet123",
)
}
@Test
fun `GIVEN push notification payload with missing type WHEN convert THEN should return null`() {
// GIVEN

View file

@ -235,7 +235,7 @@ internal class DefaultStakingRepository(
)
when {
prefetchedYield != null && isSupportedInMobileApp -> {
send(StakingAvailability.Available(prefetchedYield.id))
send(StakingAvailability.Available(prefetchedYield))
}
prefetchedYield == null && isSupportedInMobileApp -> {
send(StakingAvailability.TemporaryUnavailable)
@ -279,7 +279,7 @@ internal class DefaultStakingRepository(
return when {
prefetchedYield != null && isSupportedInMobileApp -> {
StakingAvailability.Available(prefetchedYield.id)
StakingAvailability.Available(prefetchedYield)
}
prefetchedYield == null && isSupportedInMobileApp -> {
StakingAvailability.TemporaryUnavailable

View file

@ -52,6 +52,8 @@ internal class DefaultMultiYieldBalanceFetcher @Inject constructor(
) : MultiYieldBalanceFetcher {
override suspend fun invoke(params: MultiYieldBalanceFetcher.Params): Either<Throwable, Unit> {
Timber.i("Start fetching yield balances for params:\n$params")
checkIsSupportedByWalletOrElse(userWalletId = params.userWalletId) {
return it.left()
}
@ -60,6 +62,8 @@ internal class DefaultMultiYieldBalanceFetcher @Inject constructor(
return it.left()
}
Timber.i("Staking IDs to fetch:\n${stakingIds.joinToString("\n")}")
return Either.catchOn(dispatchers.default) {
yieldsBalancesStore.refresh(userWalletId = params.userWalletId, stakingIds = stakingIds)
@ -126,6 +130,13 @@ internal class DefaultMultiYieldBalanceFetcher @Inject constructor(
val availableStakingIds = groupedStakingIds[true].orEmpty()
val unavailableStakingIds = groupedStakingIds[false].orEmpty()
Timber.i(
"""
Available staking IDs: ${availableStakingIds.joinToString()}
Unavailable staking IDs: ${unavailableStakingIds.joinToString()}
""".trimIndent(),
)
if (unavailableStakingIds.isNotEmpty()) {
yieldsBalancesStore.storeError(userWalletId = userWalletId, stakingIds = unavailableStakingIds.toSet())
}
@ -138,7 +149,7 @@ internal class DefaultMultiYieldBalanceFetcher @Inject constructor(
stakingIds: ${stakingIds.joinToString()}
""".trimIndent(),
)
Timber.d(exception)
Timber.i(exception)
throw exception
}
}
@ -174,6 +185,7 @@ internal class DefaultMultiYieldBalanceFetcher @Inject constructor(
.toSet()
}
Timber.i("Successfully fetched yield balances for $userWalletId:\n${yieldBalances.joinToString("\n")}")
yieldsBalancesStore.storeActual(userWalletId = userWalletId, values = yieldBalances)
if (!allResponsesReceived(requests, yieldBalances)) {

View file

@ -47,11 +47,18 @@ internal class DefaultSingleYieldBalanceProducer @AssistedInject constructor(
private var stakingId: StakingID? = null
override fun produce(): Flow<YieldBalance> {
Timber.i("Producing yield balance for params:\n$params")
return multiYieldBalanceSupplier(
params = MultiYieldBalanceProducer.Params(userWalletId = params.userWalletId),
)
.mapNotNull { balances ->
val currentStakingId = getStakingId() ?: return@mapNotNull YieldBalance.Unsupported
val currentStakingId = getStakingId()
if (currentStakingId == null) {
Timber.i("Staking ID is null for params: $params")
return@mapNotNull YieldBalance.Unsupported
}
val currentBalances = balances.filter { it.getStakingId() == currentStakingId }
@ -66,7 +73,7 @@ internal class DefaultSingleYieldBalanceProducer @AssistedInject constructor(
),
)
Timber.w(
Timber.e(
"Multiple balances found for staking ID $currentStakingId:\n%s",
currentBalances.joinToString("\n"),
)
@ -79,7 +86,15 @@ internal class DefaultSingleYieldBalanceProducer @AssistedInject constructor(
currentBalances.first()
}
} else {
currentBalances.firstOrNull() ?: YieldBalance.Unsupported
val balance = currentBalances.firstOrNull()
if (balance != null) {
Timber.i("Yield balance found for $currentStakingId:\n$balance")
balance
} else {
Timber.i("No yield balance found for $currentStakingId:\n${YieldBalance.Unsupported}")
YieldBalance.Unsupported
}
}
}
.distinctUntilChanged()

View file

@ -1,6 +1,7 @@
package com.tangem.data.tokens.repository
import com.tangem.blockchain.common.Blockchain
import com.tangem.blockchain.common.TransactionStatus
import com.tangem.blockchainsdk.compatibility.getL2CompatibilityTokenComparison
import com.tangem.blockchainsdk.utils.*
import com.tangem.data.common.api.safeApiCall
@ -432,9 +433,9 @@ internal class DefaultCurrenciesRepository(
}
}
override fun isSendBlockedByPendingTransactions(
override suspend fun isSendBlockedByPendingTransactions(
userWalletId: UserWalletId,
cryptoCurrencyStatus: CryptoCurrencyStatus,
coinStatus: CryptoCurrencyStatus?,
): Boolean {
val blockchain = cryptoCurrencyStatus.currency.network.toBlockchain()
val isBitcoinBlockchain = blockchain == Blockchain.Bitcoin || blockchain == Blockchain.BitcoinTestnet
@ -445,7 +446,14 @@ internal class DefaultCurrenciesRepository(
}
blockchain.isEvm() -> false
blockchain == Blockchain.Tron || blockchain == Blockchain.TronTestnet -> false
else -> coinStatus?.value?.hasCurrentNetworkTransactions == true
else -> {
val walletManager = walletManagersFacade.getOrCreateWalletManager(
userWalletId = userWalletId,
network = cryptoCurrencyStatus.currency.network,
) ?: return false
walletManager.wallet.recentTransactions.any { it.status == TransactionStatus.Unconfirmed }
}
}
}

View file

@ -6,7 +6,6 @@ import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.scan.ScanResponse
import com.tangem.domain.tokens.model.CryptoCurrencyStatus
import com.tangem.domain.tokens.model.ScenarioUnavailabilityReason
import com.tangem.domain.wallets.models.UserWallet
import com.tangem.domain.wallets.models.UserWalletId
import kotlinx.coroutines.flow.Flow
@ -24,12 +23,14 @@ interface RampStateManager {
/**
* Check if [CryptoCurrency] is available for sell
*
* @param userWallet user wallet
* @param status crypto currency status
* @param userWalletId the ID of the user's wallet
* @param status crypto currency status
* @param sendUnavailabilityReason the reason why sending is unavailable or null
*/
suspend fun availableForSell(
userWallet: UserWallet,
userWalletId: UserWalletId,
status: CryptoCurrencyStatus,
sendUnavailabilityReason: ScenarioUnavailabilityReason?,
): Either<ScenarioUnavailabilityReason, Unit>
suspend fun availableForSwap(
@ -42,4 +43,15 @@ interface RampStateManager {
fun getSellInitializationStatus(): Flow<Lce<Throwable, Any>>
fun getExpressInitializationStatus(userWalletId: UserWalletId): Flow<Lce<Throwable, Any>>
/**
* Returns the reason why sending is unavailable for the given user wallet and cryptocurrency status
*
* @param userWalletId the ID of the user's wallet
* @param cryptoCurrencyStatus the status of the cryptocurrency
*/
suspend fun getSendUnavailabilityReason(
userWalletId: UserWalletId,
cryptoCurrencyStatus: CryptoCurrencyStatus,
): ScenarioUnavailabilityReason
}

View file

@ -1,8 +1,10 @@
package com.tangem.domain.staking.model
import com.tangem.domain.staking.model.stakekit.Yield
sealed class StakingAvailability {
data class Available(val integrationId: String) : StakingAvailability()
data class Available(val yield: Yield) : StakingAvailability()
data object Unavailable : StakingAvailability()

View file

@ -21,5 +21,19 @@ interface MultiYieldBalanceFetcher : FlowFetcher<MultiYieldBalanceFetcher.Params
data class Params(
val userWalletId: UserWalletId,
val currencyIdWithNetworkMap: Map<CryptoCurrency.ID, Network>,
)
) {
override fun toString(): String {
val currencyIdWithNetworkMap = currencyIdWithNetworkMap.entries.joinToString {
"${it.key.value} - ${it.value}"
}
return """
MultiYieldBalanceFetcher.Params(
userWalletId = $userWalletId,
currencyIdWithNetworkMap: $currencyIdWithNetworkMap
)
""".trimIndent()
}
}
}

View file

@ -17,7 +17,18 @@ interface SingleYieldBalanceProducer : FlowProducer<YieldBalance> {
val userWalletId: UserWalletId,
val currencyId: CryptoCurrency.ID,
val network: Network,
)
) {
override fun toString(): String {
return """
SingleYieldBalanceProducer.Params(
userWalletId = $userWalletId,
currencyId = $currencyId,
network = $network
)
""".trimIndent()
}
}
interface Factory : FlowProducer.Factory<Params, SingleYieldBalanceProducer>
}

View file

@ -1,463 +1,137 @@
package com.tangem.domain.tokens
import com.tangem.domain.common.util.cardTypesResolver
import com.tangem.domain.exchange.RampStateManager
import com.tangem.domain.models.StatusSource
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.network.NetworkAddress
import com.tangem.domain.promo.PromoRepository
import com.tangem.domain.promo.models.StoryContent
import com.tangem.domain.promo.models.StoryContentIds
import com.tangem.domain.staking.model.StakingAvailability
import com.tangem.domain.staking.repositories.StakingRepository
import com.tangem.domain.tokens.actions.CommonActionsFactory
import com.tangem.domain.tokens.actions.MissedDerivationsActionsFactory
import com.tangem.domain.tokens.actions.OutdatedDataActionsFactory
import com.tangem.domain.tokens.actions.UnreachableActionsFactory
import com.tangem.domain.tokens.model.CryptoCurrencyStatus
import com.tangem.domain.tokens.model.ScenarioUnavailabilityReason
import com.tangem.domain.tokens.model.TokenActionsState
import com.tangem.domain.tokens.operations.BaseCurrencyStatusOperations
import com.tangem.domain.tokens.repository.CurrenciesRepository
import com.tangem.domain.transaction.models.AssetRequirementsCondition
import com.tangem.domain.walletmanager.WalletManagersFacade
import com.tangem.domain.wallets.models.UserWallet
import com.tangem.domain.wallets.models.requireColdWallet
import com.tangem.domain.wallets.models.UserWalletId
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import com.tangem.utils.isNullOrZero
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.withTimeoutOrNull
/**
* Use case to determine which TokenActions are available for a [CryptoCurrency]
* Use case for retrieving actions available for a specific cryptocurrency in a user's wallet.
*
* @property rampManager Ramp manager to check ramp availability
* @param rampManager the manager for handling ramp state operations
* @param walletManagersFacade the facade for managing wallet operations
* @property stakingRepository the repository for staking-related data
* @property promoRepository the repository for promotional content
* @property dispatchers the coroutine dispatcher provider for managing concurrency
*/
@Suppress("LongParameterList", "LargeClass")
class GetCryptoCurrencyActionsUseCase(
private val rampManager: RampStateManager,
private val walletManagersFacade: WalletManagersFacade,
private val currenciesRepository: CurrenciesRepository,
rampManager: RampStateManager,
walletManagersFacade: WalletManagersFacade,
private val stakingRepository: StakingRepository,
private val promoRepository: PromoRepository,
private val dispatchers: CoroutineDispatcherProvider,
private val currencyStatusOperations: BaseCurrencyStatusOperations,
) {
suspend operator fun invoke(
userWallet: UserWallet,
cryptoCurrencyStatus: CryptoCurrencyStatus,
): Flow<TokenActionsState> {
private val unreachableActionsFactory = UnreachableActionsFactory(
walletManagersFacade = walletManagersFacade,
rampStateManager = rampManager,
)
private val outdatedDataActionsFactory = OutdatedDataActionsFactory(
walletManagersFacade = walletManagersFacade,
rampStateManager = rampManager,
)
private val commonActionsFactory = CommonActionsFactory(
walletManagersFacade = walletManagersFacade,
rampStateManager = rampManager,
)
operator fun invoke(userWallet: UserWallet, cryptoCurrencyStatus: CryptoCurrencyStatus): Flow<TokenActionsState> {
return when (userWallet) {
is UserWallet.Cold -> {
coldFlow(userWallet, cryptoCurrencyStatus)
}
is UserWallet.Hot -> {
TODO("[REDACTED_TASK_KEY]")
}
is UserWallet.Cold -> coldFlow(userWallet, cryptoCurrencyStatus)
is UserWallet.Hot -> TODO("[REDACTED_TASK_KEY]")
}
}
private suspend fun coldFlow(
@OptIn(ExperimentalCoroutinesApi::class)
private fun coldFlow(
userWallet: UserWallet.Cold,
cryptoCurrencyStatus: CryptoCurrencyStatus,
): Flow<TokenActionsState> {
val networkId = cryptoCurrencyStatus.currency.network.id
val requirements = withTimeoutOrNull(REQUEST_EXCHANGE_DATA_TIMEOUT) {
walletManagersFacade.getAssetRequirements(userWallet.walletId, cryptoCurrencyStatus.currency)
}
return flow {
val networkFlow = if (userWallet.scanResponse.cardTypesResolver.isSingleWalletWithToken()) {
currencyStatusOperations.getNetworkCoinForSingleWalletWithTokenFlow(userWallet.walletId, networkId)
} else if (!userWallet.isMultiCurrency) {
currencyStatusOperations.getPrimaryCurrencyStatusFlow(userWallet.walletId, includeQuotes = false)
} else {
currencyStatusOperations.getNetworkCoinFlow(
userWalletId = userWallet.walletId,
networkId = networkId,
derivationPath = cryptoCurrencyStatus.currency.network.derivationPath,
includeQuotes = false,
)
}
val flow = combine(
flow = networkFlow,
flow2 = promoRepository.getStoryById(StoryContentIds.STORY_FIRST_TIME_SWAP.id).conflate(),
flow3 = stakingRepository.getStakingAvailability(
userWalletId = userWallet.walletId,
cryptoCurrency = cryptoCurrencyStatus.currency,
).onStart { emit(StakingAvailability.Unavailable) },
) { maybeCoinStatus, maybeSwapStories, stakingAvailability ->
createTokenActionsState(
userWallet = userWallet,
coinStatus = maybeCoinStatus.getOrNull(),
cryptoCurrencyStatus = cryptoCurrencyStatus,
requirements = requirements,
shouldShowSwapStories = maybeSwapStories != null,
isStakingAvailable = stakingAvailability is StakingAvailability.Available,
)
}
emitAll(flow)
}.flowOn(dispatchers.io)
}
private suspend fun createTokenActionsState(
userWallet: UserWallet,
coinStatus: CryptoCurrencyStatus?,
cryptoCurrencyStatus: CryptoCurrencyStatus,
requirements: AssetRequirementsCondition?,
shouldShowSwapStories: Boolean,
isStakingAvailable: Boolean,
): TokenActionsState {
return TokenActionsState(
walletId = userWallet.walletId,
cryptoCurrencyStatus = cryptoCurrencyStatus,
states = createListOfActions(
userWallet = userWallet,
coinStatus = coinStatus,
cryptoCurrencyStatus = cryptoCurrencyStatus,
requirements = requirements,
shouldShowSwapStories = shouldShowSwapStories,
isStakingAvailable = isStakingAvailable,
),
)
}
/**
* Creates list of action for expected order
* Actions priority: [Receive Send Swap Buy Sell]
*/
@Suppress("CyclomaticComplexMethod", "LongMethod")
private suspend fun createListOfActions(
userWallet: UserWallet,
coinStatus: CryptoCurrencyStatus?,
cryptoCurrencyStatus: CryptoCurrencyStatus,
requirements: AssetRequirementsCondition?,
shouldShowSwapStories: Boolean,
isStakingAvailable: Boolean,
): List<TokenActionsState.ActionState> {
val cryptoCurrency = cryptoCurrencyStatus.currency
if (cryptoCurrencyStatus.value is CryptoCurrencyStatus.MissedDerivation) {
return listOf(TokenActionsState.ActionState.HideToken(ScenarioUnavailabilityReason.None))
}
if (cryptoCurrencyStatus.value is CryptoCurrencyStatus.Unreachable) {
return getActionsForUnreachableCurrency(userWallet, cryptoCurrencyStatus, requirements)
}
if (cryptoCurrencyStatus.value.sources.total != StatusSource.ACTUAL) {
return getActionsForOutdatedData(userWallet, cryptoCurrencyStatus, requirements, isStakingAvailable)
}
val activeList = mutableListOf<TokenActionsState.ActionState>()
val disabledList = mutableListOf<TokenActionsState.ActionState>()
// markets
// not a custom token
if (cryptoCurrencyStatus.currency.id.rawCurrencyId != null) {
activeList.add(TokenActionsState.ActionState.Analytics(ScenarioUnavailabilityReason.None))
}
// copy address
if (isAddressAvailable(cryptoCurrencyStatus.value.networkAddress)) {
activeList.add(TokenActionsState.ActionState.CopyAddress(ScenarioUnavailabilityReason.None))
}
// receive
if (isAddressAvailable(cryptoCurrencyStatus.value.networkAddress)) {
val scenario = getReceiveScenario(requirements)
activeList.add(TokenActionsState.ActionState.Receive(scenario))
}
// staking
addStakingActions(cryptoCurrency, isStakingAvailable, activeList, disabledList)
// send
val sendUnavailabilityReason = getSendUnavailabilityReason(
cryptoCurrencyStatus = cryptoCurrencyStatus,
coinStatus = coinStatus,
)
if (sendUnavailabilityReason == ScenarioUnavailabilityReason.None) {
activeList.add(TokenActionsState.ActionState.Send(sendUnavailabilityReason))
} else {
disabledList.add(TokenActionsState.ActionState.Send(sendUnavailabilityReason))
}
// swap
val swapActionState = getSwapUnavailabilityReason(userWallet, cryptoCurrencyStatus, shouldShowSwapStories)
if (swapActionState.unavailabilityReason == ScenarioUnavailabilityReason.None) {
activeList.add(swapActionState)
} else {
disabledList.add(swapActionState)
}
// buy
val onrampActionState = getOnrampUnavailabilityReason(userWallet, cryptoCurrencyStatus)
if (onrampActionState.unavailabilityReason == ScenarioUnavailabilityReason.None) {
activeList.add(onrampActionState)
} else {
disabledList.add(onrampActionState)
}
// region sell
rampManager.availableForSell(userWallet = userWallet, status = cryptoCurrencyStatus)
.onRight {
activeList.add(TokenActionsState.ActionState.Sell(ScenarioUnavailabilityReason.None))
}
.onLeft { reason ->
disabledList.add(TokenActionsState.ActionState.Sell(reason))
}
// endregion
// hide
activeList.add(TokenActionsState.ActionState.HideToken(ScenarioUnavailabilityReason.None))
return activeList + disabledList
}
private suspend fun addStakingActions(
cryptoCurrency: CryptoCurrency,
isStakingAvailable: Boolean,
activeList: MutableList<TokenActionsState.ActionState>,
disabledList: MutableList<TokenActionsState.ActionState>,
) {
if (isStakingAvailable) {
val yield = kotlin.runCatching {
stakingRepository.getYield(
cryptoCurrencyId = cryptoCurrency.id,
symbol = cryptoCurrency.symbol,
)
}.getOrNull()
activeList.add(
TokenActionsState.ActionState.Stake(
unavailabilityReason = ScenarioUnavailabilityReason.None,
yield = yield,
),
)
} else {
disabledList.add(
TokenActionsState.ActionState.Stake(
unavailabilityReason = ScenarioUnavailabilityReason.StakingUnavailable(cryptoCurrency.name),
yield = null,
),
)
}
}
private suspend fun getActionsForUnreachableCurrency(
userWallet: UserWallet,
cryptoCurrencyStatus: CryptoCurrencyStatus,
requirements: AssetRequirementsCondition?,
): List<TokenActionsState.ActionState> {
val activeList = mutableListOf<TokenActionsState.ActionState>()
val disabledList = mutableListOf<TokenActionsState.ActionState>()
if (isAddressAvailable(cryptoCurrencyStatus.value.networkAddress)) {
activeList.add(TokenActionsState.ActionState.CopyAddress(ScenarioUnavailabilityReason.None))
}
// buy (is not depend on cache)
val onrampActionState = getOnrampUnavailabilityReason(userWallet, cryptoCurrencyStatus)
if (onrampActionState.unavailabilityReason == ScenarioUnavailabilityReason.None) {
activeList.add(onrampActionState)
} else {
disabledList.add(onrampActionState)
}
disabledList.add(TokenActionsState.ActionState.Send(ScenarioUnavailabilityReason.Unreachable))
disabledList.add(
TokenActionsState.ActionState.Swap(
unavailabilityReason = ScenarioUnavailabilityReason.Unreachable,
showBadge = false,
),
)
disabledList.add(TokenActionsState.ActionState.Sell(ScenarioUnavailabilityReason.Unreachable))
if (isAddressAvailable(cryptoCurrencyStatus.value.networkAddress)) {
val scenario = getReceiveScenario(requirements)
activeList.add(TokenActionsState.ActionState.Receive(scenario))
}
disabledList.add(TokenActionsState.ActionState.Stake(ScenarioUnavailabilityReason.Unreachable, null))
activeList.add(TokenActionsState.ActionState.HideToken(ScenarioUnavailabilityReason.None))
return activeList + disabledList
}
@Suppress("LongMethod")
private suspend fun getActionsForOutdatedData(
userWallet: UserWallet,
cryptoCurrencyStatus: CryptoCurrencyStatus,
requirements: AssetRequirementsCondition?,
isStakingAvailable: Boolean,
): List<TokenActionsState.ActionState> {
val activeList = mutableListOf<TokenActionsState.ActionState>()
val disabledList = mutableListOf<TokenActionsState.ActionState>()
val cryptoCurrency = cryptoCurrencyStatus.currency
// copy address
if (isAddressAvailable(cryptoCurrencyStatus.value.networkAddress)) {
activeList.add(TokenActionsState.ActionState.CopyAddress(ScenarioUnavailabilityReason.None))
}
// receive
if (isAddressAvailable(cryptoCurrencyStatus.value.networkAddress)) {
val scenario = getReceiveScenario(requirements)
val action = TokenActionsState.ActionState.Receive(scenario)
if (scenario == ScenarioUnavailabilityReason.None) {
activeList.add(action)
} else {
disabledList.add(action)
}
}
// swap
val sources = cryptoCurrencyStatus.value.sources
val isSwapAvailable = with(sources) {
quoteSource.isActual() && networkSource.isActual()
}
val swapAction = TokenActionsState.ActionState.Swap(
unavailabilityReason = if (isSwapAvailable) {
ScenarioUnavailabilityReason.None
} else if (sources.networkSource == StatusSource.ONLY_CACHE) {
ScenarioUnavailabilityReason.UsedOutdatedData
} else {
// CACHE source always when loading
ScenarioUnavailabilityReason.DataLoading
},
showBadge = false,
)
if (swapAction.unavailabilityReason == ScenarioUnavailabilityReason.None) {
activeList.add(swapAction)
} else {
disabledList.add(swapAction)
}
// buy (is not depend on cache)
val onrampActionState = getOnrampUnavailabilityReason(userWallet, cryptoCurrencyStatus)
if (onrampActionState.unavailabilityReason == ScenarioUnavailabilityReason.None) {
activeList.add(onrampActionState)
} else {
disabledList.add(onrampActionState)
}
// staking
if (cryptoCurrencyStatus.value.sources.networkSource.isActual()) {
addStakingActions(cryptoCurrency, isStakingAvailable, activeList, disabledList)
} else {
disabledList.add(TokenActionsState.ActionState.Stake(ScenarioUnavailabilityReason.UsedOutdatedData, null))
}
// send
val isSendAvailable = cryptoCurrencyStatus.value.sources.networkSource.isActual()
val sendAction = TokenActionsState.ActionState.Send(
unavailabilityReason = if (isSendAvailable) {
ScenarioUnavailabilityReason.None
} else {
ScenarioUnavailabilityReason.UsedOutdatedData
},
)
if (sendAction.unavailabilityReason == ScenarioUnavailabilityReason.None) {
activeList.add(sendAction)
} else {
disabledList.add(sendAction)
}
// region sell
if (isSendAvailable) {
activeList.add(TokenActionsState.ActionState.Sell(ScenarioUnavailabilityReason.None))
} else {
disabledList.add(TokenActionsState.ActionState.Sell(ScenarioUnavailabilityReason.UsedOutdatedData))
}
// endregion
// hide
activeList.add(TokenActionsState.ActionState.HideToken(ScenarioUnavailabilityReason.None))
return activeList + disabledList
}
private fun getReceiveScenario(requirements: AssetRequirementsCondition?): ScenarioUnavailabilityReason {
return when (requirements) {
AssetRequirementsCondition.PaidTransaction,
is AssetRequirementsCondition.PaidTransactionWithFee,
-> ScenarioUnavailabilityReason.UnassociatedAsset
is AssetRequirementsCondition.IncompleteTransaction,
null,
-> ScenarioUnavailabilityReason.None
is AssetRequirementsCondition.RequiredTrustline -> ScenarioUnavailabilityReason.TrustlineRequired
}
}
private fun getSendUnavailabilityReason(
cryptoCurrencyStatus: CryptoCurrencyStatus,
coinStatus: CryptoCurrencyStatus?,
): ScenarioUnavailabilityReason {
return when {
cryptoCurrencyStatus.value.amount.isNullOrZero() -> {
ScenarioUnavailabilityReason.EmptyBalance(ScenarioUnavailabilityReason.WithdrawalScenario.SEND)
cryptoCurrencyStatus.value is CryptoCurrencyStatus.MissedDerivation -> {
flowOf(value = MissedDerivationsActionsFactory.create())
}
currenciesRepository.isSendBlockedByPendingTransactions(
cryptoCurrencyStatus = cryptoCurrencyStatus,
coinStatus = coinStatus,
) -> {
ScenarioUnavailabilityReason.PendingTransaction(
withdrawalScenario = ScenarioUnavailabilityReason.WithdrawalScenario.SEND,
networkName = coinStatus?.currency?.network?.name.orEmpty(),
cryptoCurrencyStatus.value is CryptoCurrencyStatus.Unreachable -> {
flow {
val actions = unreachableActionsFactory.create(
userWallet = userWallet,
cryptoCurrencyStatus = cryptoCurrencyStatus,
)
emit(actions)
}
}
cryptoCurrencyStatus.value.sources.total != StatusSource.ACTUAL -> {
getStakingAvailabilityFlow(
userWalletId = userWallet.walletId,
currency = cryptoCurrencyStatus.currency,
)
.mapLatest {
outdatedDataActionsFactory.create(
userWallet = userWallet,
cryptoCurrencyStatus = cryptoCurrencyStatus,
stakingAvailability = it,
)
}
}
else -> {
ScenarioUnavailabilityReason.None
combine(
flow = getStakingAvailabilityFlow(
userWalletId = userWallet.walletId,
currency = cryptoCurrencyStatus.currency,
),
flow2 = getSwapStoryContent(),
) { stakingAvailability, swapStoryContent ->
commonActionsFactory.create(
userWallet = userWallet,
cryptoCurrencyStatus = cryptoCurrencyStatus,
stakingAvailability = stakingAvailability,
shouldShowSwapStories = swapStoryContent != null,
)
}
}
}
}
private suspend fun getSwapUnavailabilityReason(
userWallet: UserWallet,
cryptoCurrencyStatus: CryptoCurrencyStatus,
shouldShowSwapStories: Boolean,
): TokenActionsState.ActionState {
val cryptoCurrency = cryptoCurrencyStatus.currency
val isMultiCurrency =
userWallet is UserWallet.Hot || userWallet is UserWallet.Cold && userWallet.isMultiCurrency
return if (isMultiCurrency) {
if (cryptoCurrency.isCustom) {
return TokenActionsState.ActionState.Swap(
unavailabilityReason = ScenarioUnavailabilityReason.CustomToken(cryptoCurrency.name),
showBadge = false,
.map {
TokenActionsState(
walletId = userWallet.walletId,
cryptoCurrencyStatus = cryptoCurrencyStatus,
states = it.toList(),
)
}
if (cryptoCurrencyStatus.value is CryptoCurrencyStatus.NoQuote) {
return TokenActionsState.ActionState.Swap(
unavailabilityReason = ScenarioUnavailabilityReason.TokenNoQuotes(cryptoCurrency.name),
showBadge = false,
)
}
val reason = rampManager.availableForSwap(userWallet.walletId, cryptoCurrency)
val isShowBadge = reason == ScenarioUnavailabilityReason.None && shouldShowSwapStories
TokenActionsState.ActionState.Swap(
unavailabilityReason = reason,
showBadge = isShowBadge,
)
} else {
TokenActionsState.ActionState.Swap(
unavailabilityReason = ScenarioUnavailabilityReason.SingleWallet,
showBadge = false,
)
}
.flowOn(dispatchers.default)
}
private suspend fun getOnrampUnavailabilityReason(
userWallet: UserWallet,
cryptoCurrencyStatus: CryptoCurrencyStatus,
): TokenActionsState.ActionState {
userWallet.requireColdWallet() // TODO [REDACTED_TASK_KEY]
val cryptoCurrency = cryptoCurrencyStatus.currency
val reason = rampManager.availableForBuy(userWallet.scanResponse, userWallet.walletId, cryptoCurrency)
return TokenActionsState.ActionState.Buy(unavailabilityReason = reason)
private fun getStakingAvailabilityFlow(
userWalletId: UserWalletId,
currency: CryptoCurrency,
): Flow<StakingAvailability> {
return stakingRepository.getStakingAvailability(userWalletId = userWalletId, cryptoCurrency = currency)
.onStart { emit(StakingAvailability.Unavailable) }
.conflate()
.distinctUntilChanged()
}
private fun isAddressAvailable(networkAddress: NetworkAddress?): Boolean {
return networkAddress != null && networkAddress.defaultAddress.value.isNotEmpty()
}
private companion object {
const val REQUEST_EXCHANGE_DATA_TIMEOUT = 1000L
private fun getSwapStoryContent(): Flow<StoryContent?> {
return promoRepository.getStoryById(StoryContentIds.STORY_FIRST_TIME_SWAP.id)
.conflate()
.distinctUntilChanged()
}
}

View file

@ -0,0 +1,62 @@
package com.tangem.domain.tokens.actions
import com.tangem.domain.tokens.model.ScenarioUnavailabilityReason
import com.tangem.domain.tokens.model.TokenActionsState
/**
* Builder for creating a set of [TokenActionsState.ActionState] based on their availability
*
[REDACTED_AUTHOR]
*/
internal class ActionAvailabilityBuilder {
private val activeList = mutableSetOf<TokenActionsState.ActionState>()
private val disabledList = mutableSetOf<TokenActionsState.ActionState>()
/** Marks the current [TokenActionsState.ActionState] as active */
fun TokenActionsState.ActionState.active() {
activeList.add(this)
}
/** Marks the current [TokenActionsState.ActionState] as disabled */
fun TokenActionsState.ActionState.disabled() {
disabledList.add(this)
}
/** Marks a list of [TokenActionsState.ActionState] as disabled */
fun List<TokenActionsState.ActionState>.disabled() {
disabledList.addAll(this)
}
/**
* Adds the current [TokenActionsState.ActionState] to the appropriate list based on its [ScenarioUnavailabilityReason].
*
* If the [ScenarioUnavailabilityReason] is [ScenarioUnavailabilityReason.None], the action is added to the active list.
* Otherwise, it is added to the disabled list.
*/
fun TokenActionsState.ActionState.addByReason() {
if (unavailabilityReason == ScenarioUnavailabilityReason.None) {
activeList.add(this)
} else {
disabledList.add(this)
}
}
fun build(): Set<TokenActionsState.ActionState> {
return activeList + disabledList
}
}
/**
* This function initializes an [ActionAvailabilityBuilder], applies the given
* [block] to it, and returns the resulting set of [TokenActionsState.ActionState]
*/
internal suspend fun actionAvailabilityBuilder(
block: suspend ActionAvailabilityBuilder.() -> Unit,
): Set<TokenActionsState.ActionState> {
val builder = ActionAvailabilityBuilder()
builder.block()
return builder.build()
}

View file

@ -0,0 +1,188 @@
package com.tangem.domain.tokens.actions
import com.tangem.domain.exchange.RampStateManager
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.network.NetworkAddress
import com.tangem.domain.staking.model.StakingAvailability
import com.tangem.domain.tokens.model.CryptoCurrencyStatus
import com.tangem.domain.tokens.model.ScenarioUnavailabilityReason
import com.tangem.domain.tokens.model.TokenActionsState.ActionState
import com.tangem.domain.transaction.models.AssetRequirementsCondition
import com.tangem.domain.walletmanager.WalletManagersFacade
import com.tangem.domain.wallets.models.UserWallet
import com.tangem.domain.wallets.models.UserWalletId
import kotlinx.coroutines.Deferred
import kotlinx.coroutines.withTimeoutOrNull
/**
* Base factory class for creating token actions.
*
* This class provides utility methods to determine the availability of actions and to create specific token actions
* based on the provided conditions.
*
* @param walletManagersFacade the facade for managing wallet operations
* @param rampStateManager the manager for handling ramp state operations
*
[REDACTED_AUTHOR]
*/
internal open class BaseActionsFactory(
private val walletManagersFacade: WalletManagersFacade,
private val rampStateManager: RampStateManager,
) {
/** Checks if the provided network address [networkAddress] is available */
protected fun isAddressAvailable(networkAddress: NetworkAddress?): Boolean {
return networkAddress != null && networkAddress.defaultAddress.value.isNotEmpty()
}
/**
* Retrieves the asset requirements for a specific user wallet and cryptocurrency.
*
* @param userWalletId The ID of the user wallet.
* @param currency The cryptocurrency to check.
* @return The asset requirements condition, or `null` if the operation times out.
*/
protected suspend fun getAssetRequirements(
userWalletId: UserWalletId,
currency: CryptoCurrency,
): AssetRequirementsCondition? {
return withTimeoutOrNull(timeMillis = 1000L) {
walletManagersFacade.getAssetRequirements(userWalletId = userWalletId, currency = currency)
}
}
/**
* Determines the unavailability reason for the BUY action
*
* @param userWallet the user's cold wallet
* @param currency the cryptocurrency to check
*/
protected suspend fun getOnrampUnavailabilityReason(
userWallet: UserWallet.Cold,
currency: CryptoCurrency,
): ScenarioUnavailabilityReason {
return rampStateManager.availableForBuy(
userWalletId = userWallet.walletId,
scanResponse = userWallet.scanResponse,
cryptoCurrency = currency,
)
}
/**
* Determines the unavailability reason for the SEND action
*
* @param userWalletId the ID of the user's wallet
* @param cryptoCurrencyStatus the status of the cryptocurrency
*/
protected suspend fun getSendUnavailabilityReason(
userWalletId: UserWalletId,
cryptoCurrencyStatus: CryptoCurrencyStatus,
): ScenarioUnavailabilityReason {
return rampStateManager.getSendUnavailabilityReason(
userWalletId = userWalletId,
cryptoCurrencyStatus = cryptoCurrencyStatus,
)
}
/**
* Determines the unavailability reason for the SELL action
*
* @param userWalletId the ID of the user's wallet
* @param status the status of the cryptocurrency
* @param sendUnavailabilityReason the reason for unavailability of the send action
*/
protected suspend fun getSellUnavailabilityReason(
userWalletId: UserWalletId,
status: CryptoCurrencyStatus,
sendUnavailabilityReason: ScenarioUnavailabilityReason,
): ScenarioUnavailabilityReason {
return rampStateManager.availableForSell(
userWalletId = userWalletId,
status = status,
sendUnavailabilityReason = sendUnavailabilityReason,
).fold(
ifLeft = { it },
ifRight = { ScenarioUnavailabilityReason.None },
)
}
/** Adds a "Copy Address" action to the builder if the address is available [isAddressAvailable] */
protected fun ActionAvailabilityBuilder.addCopyAction(isAddressAvailable: Boolean) {
if (isAddressAvailable) {
ActionState.CopyAddress(unavailabilityReason = ScenarioUnavailabilityReason.None).active()
}
}
/**
* Adds a "Receive" action to the builder based on the address availability and asset requirements
*
* @param isAddressAvailable indicates whether the address is available
* @param requirementsDeferred a deferred object containing the asset requirements condition
*/
protected suspend fun ActionAvailabilityBuilder.addReceiveAction(
isAddressAvailable: Boolean,
requirementsDeferred: Deferred<AssetRequirementsCondition?>?,
) {
if (isAddressAvailable && requirementsDeferred != null) {
val scenario = getReceiveScenario(requirements = requirementsDeferred.await())
val action = ActionState.Receive(scenario)
if (scenario == ScenarioUnavailabilityReason.None) {
action.active()
} else {
action.disabled()
}
}
}
/** Adds a "Buy" action to the builder based on the unavailability [reason] */
protected fun ActionAvailabilityBuilder.addBuyAction(reason: ScenarioUnavailabilityReason) {
val action = ActionState.Buy(unavailabilityReason = reason)
if (reason == ScenarioUnavailabilityReason.None) {
action.active()
} else {
action.disabled()
}
}
/** Adds a "Hide Token" action to the builder */
protected fun ActionAvailabilityBuilder.addHideTokenAction() {
ActionState.HideToken(unavailabilityReason = ScenarioUnavailabilityReason.None).active()
}
/**
* Creates a staking action based on the staking availability
*
* @param currency the cryptocurrency for staking
* @param stakingAvailability the staking availability status
*/
protected fun createStakingAction(
currency: CryptoCurrency,
stakingAvailability: StakingAvailability,
): ActionState.Stake {
return if (stakingAvailability is StakingAvailability.Available) {
ActionState.Stake(
unavailabilityReason = ScenarioUnavailabilityReason.None,
yield = stakingAvailability.yield,
)
} else {
ActionState.Stake(
unavailabilityReason = ScenarioUnavailabilityReason.StakingUnavailable(currency.name),
yield = null,
)
}
}
private fun getReceiveScenario(requirements: AssetRequirementsCondition?): ScenarioUnavailabilityReason {
return when (requirements) {
AssetRequirementsCondition.PaidTransaction,
is AssetRequirementsCondition.PaidTransactionWithFee,
-> ScenarioUnavailabilityReason.UnassociatedAsset
is AssetRequirementsCondition.IncompleteTransaction,
null,
-> ScenarioUnavailabilityReason.None
is AssetRequirementsCondition.RequiredTrustline -> ScenarioUnavailabilityReason.TrustlineRequired
}
}
}

View file

@ -0,0 +1,176 @@
package com.tangem.domain.tokens.actions
import com.tangem.domain.exchange.RampStateManager
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.staking.model.StakingAvailability
import com.tangem.domain.tokens.model.CryptoCurrencyStatus
import com.tangem.domain.tokens.model.ScenarioUnavailabilityReason
import com.tangem.domain.tokens.model.TokenActionsState.ActionState
import com.tangem.domain.walletmanager.WalletManagersFacade
import com.tangem.domain.wallets.models.UserWallet
import com.tangem.domain.wallets.models.UserWalletId
import kotlinx.coroutines.Deferred
import kotlinx.coroutines.async
import kotlinx.coroutines.coroutineScope
/**
* Factory class for creating common token actions
*
* @param walletManagersFacade the facade for managing wallet operations
* @param rampStateManager the manager for handling ramp state operations
*
[REDACTED_AUTHOR]
*/
internal class CommonActionsFactory(
walletManagersFacade: WalletManagersFacade,
private val rampStateManager: RampStateManager,
) : BaseActionsFactory(walletManagersFacade, rampStateManager) {
/**
* Creates a set of token actions based on the provided parameters
*
* @param userWallet the user's cold wallet
* @param cryptoCurrencyStatus the status of the cryptocurrency
* @param stakingAvailability the staking availability for the cryptocurrency
* @param shouldShowSwapStories a flag indicating whether to show swap stories
*/
suspend fun create(
userWallet: UserWallet.Cold,
cryptoCurrencyStatus: CryptoCurrencyStatus,
stakingAvailability: StakingAvailability,
shouldShowSwapStories: Boolean,
): Set<ActionState> = coroutineScope {
val isAddressAvailable = isAddressAvailable(cryptoCurrencyStatus.value.networkAddress)
val requirementsDeferred = if (isAddressAvailable) {
async {
getAssetRequirements(userWalletId = userWallet.walletId, currency = cryptoCurrencyStatus.currency)
}
} else {
null
}
val onrampUnavailabilityReasonDeferred = async {
getOnrampUnavailabilityReason(userWallet = userWallet, currency = cryptoCurrencyStatus.currency)
}
val sendUnavailabilityReasonDeferred = async {
getSendUnavailabilityReason(userWalletId = userWallet.walletId, cryptoCurrencyStatus = cryptoCurrencyStatus)
}
val swapUnavailabilityReason = if (!cryptoCurrencyStatus.currency.isCustom &&
cryptoCurrencyStatus.value !is CryptoCurrencyStatus.NoQuote
) {
async {
getSwapUnavailabilityReason(
userWalletId = userWallet.walletId,
currency = cryptoCurrencyStatus.currency,
)
}
} else {
null
}
actionAvailabilityBuilder {
// region Analytics
if (cryptoCurrencyStatus.currency.id.rawCurrencyId != null) {
ActionState.Analytics(unavailabilityReason = ScenarioUnavailabilityReason.None).active()
}
// endregion
// region Copy
addCopyAction(isAddressAvailable = isAddressAvailable)
// endregion
// region Receive
addReceiveAction(isAddressAvailable = isAddressAvailable, requirementsDeferred = requirementsDeferred)
// endregion
// region Stake
createStakingAction(currency = cryptoCurrencyStatus.currency, stakingAvailability = stakingAvailability)
.addByReason()
// endregion
val sendUnavailabilityReason = sendUnavailabilityReasonDeferred.await()
// region Send
ActionState.Send(unavailabilityReason = sendUnavailabilityReason).addByReason()
// endregion
// region Swap
createSwapAction(
userWallet = userWallet,
cryptoCurrencyStatus = cryptoCurrencyStatus,
swapUnavailableReasonDeferred = swapUnavailabilityReason,
shouldShowSwapStories = shouldShowSwapStories,
).addByReason()
// endregion
// region Buy
addBuyAction(reason = onrampUnavailabilityReasonDeferred.await())
// endregion
// region Sell
val sellUnavailabilityReason = getSellUnavailabilityReason(
userWalletId = userWallet.walletId,
status = cryptoCurrencyStatus,
sendUnavailabilityReason = sendUnavailabilityReason,
)
ActionState.Sell(unavailabilityReason = sellUnavailabilityReason).addByReason()
// endregion
// region HideToken
addHideTokenAction()
// endregion
}
}
private suspend fun createSwapAction(
userWallet: UserWallet,
cryptoCurrencyStatus: CryptoCurrencyStatus,
swapUnavailableReasonDeferred: Deferred<ScenarioUnavailabilityReason>?,
shouldShowSwapStories: Boolean,
): ActionState {
val cryptoCurrency = cryptoCurrencyStatus.currency
val isMultiCurrency = userWallet is UserWallet.Cold && userWallet.isMultiCurrency ||
userWallet is UserWallet.Hot
if (!isMultiCurrency) {
return ActionState.Swap(
unavailabilityReason = ScenarioUnavailabilityReason.SingleWallet,
showBadge = false,
)
}
return when {
cryptoCurrency.isCustom -> {
ActionState.Swap(
unavailabilityReason = ScenarioUnavailabilityReason.CustomToken(cryptoCurrency.name),
showBadge = false,
)
}
cryptoCurrencyStatus.value is CryptoCurrencyStatus.NoQuote -> {
ActionState.Swap(
unavailabilityReason = ScenarioUnavailabilityReason.TokenNoQuotes(cryptoCurrency.name),
showBadge = false,
)
}
else -> {
val reason = swapUnavailableReasonDeferred!!.await()
return ActionState.Swap(
unavailabilityReason = reason,
showBadge = reason == ScenarioUnavailabilityReason.None && shouldShowSwapStories,
)
}
}
}
private suspend fun getSwapUnavailabilityReason(
userWalletId: UserWalletId,
currency: CryptoCurrency,
): ScenarioUnavailabilityReason {
return rampStateManager.availableForSwap(userWalletId = userWalletId, cryptoCurrency = currency)
}
}

View file

@ -0,0 +1,19 @@
package com.tangem.domain.tokens.actions
import com.tangem.domain.tokens.model.ScenarioUnavailabilityReason
import com.tangem.domain.tokens.model.TokenActionsState.ActionState
/**
* Factory for creating a set of token action states for missed derivations
*
[REDACTED_AUTHOR]
*/
internal object MissedDerivationsActionsFactory {
/** Creates a set of token actions */
fun create(): Set<ActionState> {
val action = ActionState.HideToken(unavailabilityReason = ScenarioUnavailabilityReason.None)
return setOf(action)
}
}

View file

@ -0,0 +1,154 @@
package com.tangem.domain.tokens.actions
import com.tangem.domain.exchange.RampStateManager
import com.tangem.domain.models.StatusSource
import com.tangem.domain.staking.model.StakingAvailability
import com.tangem.domain.tokens.model.CryptoCurrencyStatus
import com.tangem.domain.tokens.model.ScenarioUnavailabilityReason
import com.tangem.domain.tokens.model.TokenActionsState.ActionState
import com.tangem.domain.walletmanager.WalletManagersFacade
import com.tangem.domain.wallets.models.UserWallet
import kotlinx.coroutines.Deferred
import kotlinx.coroutines.async
import kotlinx.coroutines.coroutineScope
/**
* Factory for creating a set of token action states when data is outdated
*
* @param walletManagersFacade the facade for managing wallet operations
* @param rampStateManager the manager for handling ramp state operations
*
[REDACTED_AUTHOR]
*/
internal class OutdatedDataActionsFactory(
walletManagersFacade: WalletManagersFacade,
rampStateManager: RampStateManager,
) : BaseActionsFactory(walletManagersFacade, rampStateManager) {
/**
* Creates a set of token actions based on the provided parameters
*
* @param userWallet the user's cold wallet
* @param cryptoCurrencyStatus the status of the cryptocurrency
* @param stakingAvailability the staking availability for the cryptocurrency
*/
suspend fun create(
userWallet: UserWallet.Cold,
cryptoCurrencyStatus: CryptoCurrencyStatus,
stakingAvailability: StakingAvailability,
): Set<ActionState> = coroutineScope {
val sources = cryptoCurrencyStatus.value.sources
val isAddressAvailable = isAddressAvailable(cryptoCurrencyStatus.value.networkAddress)
val requirementsDeferred = if (isAddressAvailable) {
async {
getAssetRequirements(userWalletId = userWallet.walletId, currency = cryptoCurrencyStatus.currency)
}
} else {
null
}
val onrampUnavailabilityReasonDeferred = async {
getOnrampUnavailabilityReason(userWallet = userWallet, currency = cryptoCurrencyStatus.currency)
}
val sendUnavailabilityReasonDeferred = if (sources.networkSource == StatusSource.ACTUAL) {
async {
getSendUnavailabilityReason(
userWalletId = userWallet.walletId,
cryptoCurrencyStatus = cryptoCurrencyStatus,
)
}
} else {
null
}
actionAvailabilityBuilder {
// region Copy
addCopyAction(isAddressAvailable = isAddressAvailable)
// endregion
// region Receive
addReceiveAction(isAddressAvailable = isAddressAvailable, requirementsDeferred = requirementsDeferred)
// endregion
// region Swap
createSwapAction(sources = sources).addByReason()
// endregion
// region Buy
addBuyAction(reason = onrampUnavailabilityReasonDeferred.await())
// endregion
// region Stake
if (sources.networkSource.isActual()) {
val stakingAction = createStakingAction(
currency = cryptoCurrencyStatus.currency,
stakingAvailability = stakingAvailability,
)
stakingAction.addByReason()
} else {
val stakingAction = ActionState.Stake(
unavailabilityReason = ScenarioUnavailabilityReason.UsedOutdatedData,
yield = null,
)
stakingAction.disabled()
}
// endregion
val sendUnavailabilityReason = getSendUnavailabilityReason(
sources = sources,
reasonDeferred = sendUnavailabilityReasonDeferred,
)
// region Send
ActionState.Send(sendUnavailabilityReason).addByReason()
// endregion
// region Sell
if (sendUnavailabilityReason == ScenarioUnavailabilityReason.None) {
val sellUnavailabilityReason = getSellUnavailabilityReason(
userWalletId = userWallet.walletId,
status = cryptoCurrencyStatus,
sendUnavailabilityReason = sendUnavailabilityReason,
)
ActionState.Sell(sellUnavailabilityReason).addByReason()
} else {
ActionState.Sell(sendUnavailabilityReason).disabled()
}
// endregion
// region HideToken
addHideTokenAction()
// endregion
}
}
private fun createSwapAction(sources: CryptoCurrencyStatus.Sources): ActionState {
val isSwapAvailable = with(sources) { quoteSource.isActual() && networkSource.isActual() }
return ActionState.Swap(
unavailabilityReason = when {
isSwapAvailable -> ScenarioUnavailabilityReason.None
sources.networkSource == StatusSource.ONLY_CACHE -> ScenarioUnavailabilityReason.UsedOutdatedData
else -> ScenarioUnavailabilityReason.DataLoading // CACHE source always when loading
},
showBadge = false,
)
}
private suspend fun getSendUnavailabilityReason(
sources: CryptoCurrencyStatus.Sources,
reasonDeferred: Deferred<ScenarioUnavailabilityReason>?,
): ScenarioUnavailabilityReason {
if (sources.networkSource != StatusSource.ACTUAL || reasonDeferred == null) {
return ScenarioUnavailabilityReason.UsedOutdatedData
}
return reasonDeferred.await()
}
}

View file

@ -0,0 +1,73 @@
package com.tangem.domain.tokens.actions
import com.tangem.domain.exchange.RampStateManager
import com.tangem.domain.tokens.model.CryptoCurrencyStatus
import com.tangem.domain.tokens.model.ScenarioUnavailabilityReason
import com.tangem.domain.tokens.model.TokenActionsState.ActionState
import com.tangem.domain.walletmanager.WalletManagersFacade
import com.tangem.domain.wallets.models.UserWallet
import kotlinx.coroutines.async
import kotlinx.coroutines.coroutineScope
/**
* Factory for creating a set of unreachable token action states
*
* @param walletManagersFacade the facade for managing wallet operations
* @param rampStateManager the manager for handling ramp state operations
*
[REDACTED_AUTHOR]
*/
internal class UnreachableActionsFactory(
walletManagersFacade: WalletManagersFacade,
rampStateManager: RampStateManager,
) : BaseActionsFactory(walletManagersFacade, rampStateManager) {
suspend fun create(userWallet: UserWallet.Cold, cryptoCurrencyStatus: CryptoCurrencyStatus): Set<ActionState> =
coroutineScope {
val isAddressAvailable = isAddressAvailable(cryptoCurrencyStatus.value.networkAddress)
// region Deferred
val requirementsDeferred = if (isAddressAvailable) {
async {
getAssetRequirements(userWalletId = userWallet.walletId, currency = cryptoCurrencyStatus.currency)
}
} else {
null
}
val onrampUnavailabilityReasonDeferred = async {
getOnrampUnavailabilityReason(userWallet = userWallet, currency = cryptoCurrencyStatus.currency)
}
// endregion
actionAvailabilityBuilder {
// region Copy
addCopyAction(isAddressAvailable = isAddressAvailable)
// endregion
// region Buy
addBuyAction(reason = onrampUnavailabilityReasonDeferred.await())
// endregion
// region Receive
addReceiveAction(isAddressAvailable = isAddressAvailable, requirementsDeferred = requirementsDeferred)
// endregion
// region Send, Swap, Sell, Stake
listOf(
ActionState.Send(unavailabilityReason = ScenarioUnavailabilityReason.Unreachable),
ActionState.Swap(
unavailabilityReason = ScenarioUnavailabilityReason.Unreachable,
showBadge = false,
),
ActionState.Sell(unavailabilityReason = ScenarioUnavailabilityReason.Unreachable),
ActionState.Stake(unavailabilityReason = ScenarioUnavailabilityReason.Unreachable, yield = null),
).disabled()
// endregion
// region HideToken
addHideTokenAction()
// endregion
}
}
}

View file

@ -223,12 +223,12 @@ interface CurrenciesRepository {
/**
* Determines whether the currency sending is blocked by network pending transaction
*
* @param userWalletId the unique identifier of the user wallet
* @param cryptoCurrencyStatus currency status
* @param coinStatus main currency status in [cryptoCurrencyStatus] network
*/
fun isSendBlockedByPendingTransactions(
suspend fun isSendBlockedByPendingTransactions(
userWalletId: UserWalletId,
cryptoCurrencyStatus: CryptoCurrencyStatus,
coinStatus: CryptoCurrencyStatus?,
): Boolean
/**

View file

@ -134,9 +134,9 @@ internal class MockCurrenciesRepository(
return isSortedByBalance.map { it.getOrElse { e -> throw e } }
}
override fun isSendBlockedByPendingTransactions(
override suspend fun isSendBlockedByPendingTransactions(
userWalletId: UserWalletId,
cryptoCurrencyStatus: CryptoCurrencyStatus,
coinStatus: CryptoCurrencyStatus?,
): Boolean {
return false
}

View file

@ -147,7 +147,7 @@ internal class OnrampTokenListModel @Inject constructor(
private fun Lce<TokenListError, TokenList>.isInsufficientBalanceForSell(): Boolean {
return if (params.filterOperation == OnrampOperation.SELL) {
isContent {
(it.totalFiatBalance as? TotalFiatBalance.Loaded)?.amount?.isZero() ?: false
(it.totalFiatBalance as? TotalFiatBalance.Loaded)?.amount?.isZero() == true
}
} else {
false
@ -235,7 +235,11 @@ internal class OnrampTokenListModel @Inject constructor(
).isAvailable()
}
OnrampOperation.SELL -> {
rampStateManager.availableForSell(userWallet = userWallet, status = status).isRight()
rampStateManager.availableForSell(
userWalletId = userWallet.walletId,
status = status,
sendUnavailabilityReason = null,
).isRight()
}
OnrampOperation.SWAP -> {
val isAvailable = rampStateManager.availableForSwap(

View file

@ -256,7 +256,7 @@ internal class TokenDetailsModel @Inject constructor(
.launchIn(modelScope)
}
private suspend fun updateButtons(currencyStatus: CryptoCurrencyStatus) {
private fun updateButtons(currencyStatus: CryptoCurrencyStatus) {
getCryptoCurrencyActionsUseCase(
userWallet = userWallet,
cryptoCurrencyStatus = currencyStatus,

View file

@ -24,6 +24,7 @@ import com.tangem.lib.crypto.BlockchainUtils.isStakingRewardUnavailable
import com.tangem.utils.Provider
import com.tangem.utils.converter.Converter
import com.tangem.utils.isNullOrZero
import timber.log.Timber
import java.math.BigDecimal
internal class TokenDetailsStakingInfoConverter(
@ -45,6 +46,7 @@ internal class TokenDetailsStakingInfoConverter(
state: TokenDetailsState,
stakingAvailability: StakingAvailability,
): StakingBlockUM? {
Timber.i("Define staking block for [${status.currency.id.value}] with availability:\n$stakingAvailability")
return when (stakingAvailability) {
StakingAvailability.TemporaryUnavailable -> StakingBlockUM.TemporaryUnavailable
StakingAvailability.Unavailable -> null
@ -60,6 +62,15 @@ internal class TokenDetailsStakingInfoConverter(
val iconState = state.tokenInfoBlockState.iconState
Timber.i(
"""
getStakingInfoBlock:
yieldBalance: $yieldBalance
stakingCryptoAmount: $stakingCryptoAmount
stakingEntryInfo: $stakingEntryInfo
""".trimIndent(),
)
return when {
stakingCryptoAmount.isNullOrZero() && stakingEntryInfo != null -> {
if (pendingBalances.isEmpty()) {

View file

@ -11,17 +11,11 @@ import com.tangem.domain.txhistory.usecase.GetTxHistoryItemsCountUseCase
import com.tangem.domain.txhistory.usecase.GetTxHistoryItemsUseCase
import com.tangem.domain.wallets.models.UserWallet
import com.tangem.domain.wallets.usecase.ShouldSaveUserWalletsUseCase
import com.tangem.feature.wallet.presentation.wallet.state.WalletStateController
import com.tangem.feature.wallet.child.wallet.model.intents.WalletClickIntents
import com.tangem.feature.wallet.presentation.wallet.analytics.utils.WalletWarningsAnalyticsSender
import com.tangem.feature.wallet.presentation.wallet.domain.GetSingleWalletWarningsFactory
import com.tangem.feature.wallet.presentation.wallet.state.WalletStateController
import com.tangem.feature.wallet.presentation.wallet.subscribers.*
import com.tangem.feature.wallet.presentation.wallet.subscribers.PrimaryCurrencySubscriber
import com.tangem.feature.wallet.presentation.wallet.subscribers.SingleWalletButtonsSubscriber
import com.tangem.feature.wallet.presentation.wallet.subscribers.SingleWalletNotificationsSubscriber
import com.tangem.feature.wallet.presentation.wallet.subscribers.TxHistorySubscriber
import com.tangem.feature.wallet.presentation.wallet.subscribers.WalletDropDownItemsSubscriber
import com.tangem.feature.wallet.presentation.wallet.subscribers.WalletSubscriber
@Suppress("LongParameterList")
internal class SingleWalletContentLoader(

View file

@ -12,10 +12,10 @@ import com.tangem.domain.txhistory.usecase.GetTxHistoryItemsCountUseCase
import com.tangem.domain.txhistory.usecase.GetTxHistoryItemsUseCase
import com.tangem.domain.wallets.models.UserWallet
import com.tangem.domain.wallets.usecase.ShouldSaveUserWalletsUseCase
import com.tangem.feature.wallet.child.wallet.model.intents.WalletClickIntents
import com.tangem.feature.wallet.presentation.wallet.analytics.utils.WalletWarningsAnalyticsSender
import com.tangem.feature.wallet.presentation.wallet.domain.GetSingleWalletWarningsFactory
import com.tangem.feature.wallet.presentation.wallet.state.WalletStateController
import com.tangem.feature.wallet.child.wallet.model.intents.WalletClickIntents
import javax.inject.Inject
@ModelScoped