Updated on 2026-08-14

This commit is contained in:
Tangem 2025-02-22 13:32:18 +04:00
parent 85b000a850
commit a0ef13e0cf
12 changed files with 278 additions and 60 deletions

View file

@ -93,13 +93,15 @@ internal object TokensDomainModule {
networksRepository: NetworksRepository,
stakingRepository: StakingRepository,
dispatchers: CoroutineDispatcherProvider,
tokensFeatureToggles: TokensFeatureToggles,
): GetCurrencyStatusUpdatesUseCase {
return GetCurrencyStatusUpdatesUseCase(
currenciesRepository,
quotesRepository,
networksRepository,
stakingRepository,
dispatchers,
currenciesRepository = currenciesRepository,
quotesRepository = quotesRepository,
networksRepository = networksRepository,
stakingRepository = stakingRepository,
dispatchers = dispatchers,
tokensFeatureToggles = tokensFeatureToggles,
)
}
@ -131,6 +133,7 @@ internal object TokensDomainModule {
currencyChecksRepository: CurrencyChecksRepository,
stakingRepository: StakingRepository,
dispatchers: CoroutineDispatcherProvider,
tokensFeatureToggles: TokensFeatureToggles,
): GetCurrencyWarningsUseCase {
return GetCurrencyWarningsUseCase(
walletManagersFacade = walletManagersFacade,
@ -140,6 +143,7 @@ internal object TokensDomainModule {
currencyChecksRepository = currencyChecksRepository,
stakingRepository = stakingRepository,
dispatchers = dispatchers,
tokensFeatureToggles = tokensFeatureToggles,
)
}
@ -363,12 +367,14 @@ internal object TokensDomainModule {
quotesRepository: QuotesRepository,
networksRepository: NetworksRepository,
stakingRepository: StakingRepository,
tokensFeatureToggles: TokensFeatureToggles,
): GetWalletTotalBalanceUseCase {
return GetWalletTotalBalanceUseCase(
currenciesRepository = currenciesRepository,
quotesRepository = quotesRepository,
networksRepository = networksRepository,
stakingRepository = stakingRepository,
tokensFeatureToggles = tokensFeatureToggles,
)
}

View file

@ -46,14 +46,6 @@ internal class DefaultNetworksStatusesStore(
if (cachedStatuses.isNotEmpty()) {
send(cachedStatuses)
/**
* Required for storing cache data.
* This will help to recognize networks that are still uploaded.
*
* @see mergeStatuses
*/
storeAll(key = key, values = cachedStatuses)
}
runtimeDataStore.get(provideStringKey(key))
@ -118,8 +110,18 @@ internal class DefaultNetworksStatusesStore(
return networks.mapNotNullTo(hashSetOf()) { network ->
val runtimeStatus = runtimeStatuses.firstOrNull { it.network == network }
if (runtimeStatus == null || runtimeStatus.value is NetworkStatus.Unreachable) {
getCachedStatusIfPossible(cachedStatuses = cachedStatuses, network = network)
if (runtimeStatus == null) {
getCachedStatusIfPossible(
cachedStatuses = cachedStatuses,
network = network,
source = StatusSource.CACHE,
)
} else if (runtimeStatus.value is NetworkStatus.Unreachable) {
getCachedStatusIfPossible(
cachedStatuses = cachedStatuses,
network = network,
source = StatusSource.ONLY_CACHE,
)
?: runtimeStatus
} else {
runtimeStatus
@ -127,12 +129,16 @@ internal class DefaultNetworksStatusesStore(
}
}
private fun getCachedStatusIfPossible(cachedStatuses: Set<NetworkStatus>, network: Network): NetworkStatus? {
private fun getCachedStatusIfPossible(
cachedStatuses: Set<NetworkStatus>,
network: Network,
source: StatusSource,
): NetworkStatus? {
val cached = cachedStatuses.firstOrNull { it.network == network } ?: return null
val updatedCachedStatus = when (val status = cached.value) {
is NetworkStatus.NoAccount -> status.copy(source = StatusSource.ONLY_CACHE)
is NetworkStatus.Verified -> status.copy(source = StatusSource.ONLY_CACHE)
is NetworkStatus.NoAccount -> status.copy(source = source)
is NetworkStatus.Verified -> status.copy(source = source)
is NetworkStatus.Unreachable,
is NetworkStatus.MissedDerivation,
-> null

View file

@ -59,4 +59,6 @@ sealed class CryptoCurrencyWarning {
) : CryptoCurrencyWarning()
data object TokensInBetaWarning : CryptoCurrencyWarning()
data object UsedOutdatedDataWarning : CryptoCurrencyWarning()
}

View file

@ -6,6 +6,7 @@ import com.tangem.domain.tokens.error.CurrencyStatusError
import com.tangem.domain.tokens.error.mapper.mapToCurrencyError
import com.tangem.domain.tokens.model.CryptoCurrency
import com.tangem.domain.tokens.model.CryptoCurrencyStatus
import com.tangem.domain.tokens.operations.CurrenciesStatusesCachedOperations
import com.tangem.domain.tokens.operations.CurrenciesStatusesOperations
import com.tangem.domain.tokens.repository.CurrenciesRepository
import com.tangem.domain.tokens.repository.NetworksRepository
@ -27,6 +28,7 @@ class GetCurrencyStatusUpdatesUseCase(
private val networksRepository: NetworksRepository,
private val stakingRepository: StakingRepository,
private val dispatchers: CoroutineDispatcherProvider,
private val tokensFeatureToggles: TokensFeatureToggles,
) {
/**
@ -58,18 +60,27 @@ class GetCurrencyStatusUpdatesUseCase(
currencyId: CryptoCurrency.ID,
isSingleWalletWithTokens: Boolean,
): Flow<Either<CurrencyStatusError, CryptoCurrencyStatus>> {
val operations = CurrenciesStatusesOperations(
currenciesRepository = currenciesRepository,
quotesRepository = quotesRepository,
networksRepository = networksRepository,
stakingRepository = stakingRepository,
userWalletId = userWalletId,
)
val currencyFlow = if (isSingleWalletWithTokens) {
operations.getCurrencyStatusSingleWalletWithTokensFlow(currencyId)
val currencyFlow = if (tokensFeatureToggles.isBalancesCachingEnabled) {
CurrenciesStatusesCachedOperations(
currenciesRepository = currenciesRepository,
quotesRepository = quotesRepository,
networksRepository = networksRepository,
stakingRepository = stakingRepository,
).getCurrencyStatusFlow(userWalletId, currencyId)
} else {
operations.getCurrencyStatusFlow(currencyId)
val operations = CurrenciesStatusesOperations(
currenciesRepository = currenciesRepository,
quotesRepository = quotesRepository,
networksRepository = networksRepository,
stakingRepository = stakingRepository,
userWalletId = userWalletId,
)
if (isSingleWalletWithTokens) {
operations.getCurrencyStatusSingleWalletWithTokensFlow(currencyId)
} else {
operations.getCurrencyStatusFlow(currencyId)
}
}
return currencyFlow.map { maybeCurrency ->
maybeCurrency.mapLeft(CurrenciesStatusesOperations.Error::mapToCurrencyError)

View file

@ -1,10 +1,12 @@
package com.tangem.domain.tokens
import com.tangem.domain.models.StatusSource
import com.tangem.domain.staking.repositories.StakingRepository
import com.tangem.domain.tokens.model.*
import com.tangem.domain.tokens.model.warnings.CryptoCurrencyWarning
import com.tangem.domain.tokens.model.warnings.HederaWarnings
import com.tangem.domain.tokens.model.warnings.KaspaWarnings
import com.tangem.domain.tokens.operations.CurrenciesStatusesCachedOperations
import com.tangem.domain.tokens.operations.CurrenciesStatusesOperations
import com.tangem.domain.tokens.repository.CurrenciesRepository
import com.tangem.domain.tokens.repository.CurrencyChecksRepository
@ -27,6 +29,7 @@ class GetCurrencyWarningsUseCase(
private val stakingRepository: StakingRepository,
private val dispatchers: CoroutineDispatcherProvider,
private val currencyChecksRepository: CurrencyChecksRepository,
private val tokensFeatureToggles: TokensFeatureToggles,
) {
suspend operator fun invoke(
@ -36,18 +39,11 @@ class GetCurrencyWarningsUseCase(
isSingleWalletWithTokens: Boolean,
): Flow<Set<CryptoCurrencyWarning>> {
val currency = currencyStatus.currency
val operations = CurrenciesStatusesOperations(
currenciesRepository = currenciesRepository,
quotesRepository = quotesRepository,
networksRepository = networksRepository,
stakingRepository = stakingRepository,
userWalletId = userWalletId,
)
// don't add here notifications that require async requests
return combine(
getCoinRelatedWarnings(
userWalletId = userWalletId,
operations = operations,
networkId = currency.network.id,
currencyId = currency.id,
derivationPath = derivationPath,
@ -74,22 +70,50 @@ class GetCurrencyWarningsUseCase(
@Suppress("LongParameterList")
private suspend fun getCoinRelatedWarnings(
userWalletId: UserWalletId,
operations: CurrenciesStatusesOperations,
networkId: Network.ID,
currencyId: CryptoCurrency.ID,
derivationPath: Network.DerivationPath,
isSingleWalletWithTokens: Boolean,
): Flow<List<CryptoCurrencyWarning>> {
val currencyFlow = if (isSingleWalletWithTokens) {
operations.getCurrencyStatusSingleWalletWithTokensFlow(currencyId)
} else {
operations.getCurrencyStatusFlow(currencyId)
val cachedOperations by lazy {
CurrenciesStatusesCachedOperations(
currenciesRepository = currenciesRepository,
quotesRepository = quotesRepository,
networksRepository = networksRepository,
stakingRepository = stakingRepository,
)
}
val networkFlow = if (isSingleWalletWithTokens) {
operations.getNetworkCoinForSingleWalletWithTokenFlow(networkId)
} else {
operations.getNetworkCoinFlow(networkId, derivationPath)
val lceOperations by lazy {
CurrenciesStatusesOperations(
currenciesRepository = currenciesRepository,
quotesRepository = quotesRepository,
networksRepository = networksRepository,
stakingRepository = stakingRepository,
userWalletId = userWalletId,
)
}
val currencyFlow = if (tokensFeatureToggles.isBalancesCachingEnabled) {
cachedOperations.getCurrencyStatusFlow(userWalletId, currencyId)
} else {
if (isSingleWalletWithTokens) {
lceOperations.getCurrencyStatusSingleWalletWithTokensFlow(currencyId)
} else {
lceOperations.getCurrencyStatusFlow(currencyId)
}
}
val networkFlow = if (tokensFeatureToggles.isBalancesCachingEnabled) {
cachedOperations.getNetworkCoinFlow(userWalletId, networkId, derivationPath)
} else {
if (isSingleWalletWithTokens) {
lceOperations.getNetworkCoinForSingleWalletWithTokenFlow(networkId)
} else {
lceOperations.getNetworkCoinFlow(networkId, derivationPath)
}
}
return combine(
currencyFlow.map { it.getOrNull() },
networkFlow.map { it.getOrNull() },
@ -97,6 +121,7 @@ class GetCurrencyWarningsUseCase(
when {
tokenStatus != null && coinStatus != null -> {
buildList {
getUsedOutdatedDataWarning(tokenStatus)?.let(::add)
getIsBetaTokensWarning(tokenStatus.currency)?.let(::add)
getFeeWarning(
userWalletId = userWalletId,
@ -144,6 +169,10 @@ class GetCurrencyWarningsUseCase(
}
}
private fun getUsedOutdatedDataWarning(status: CryptoCurrencyStatus): CryptoCurrencyWarning? {
return CryptoCurrencyWarning.UsedOutdatedDataWarning.takeIf { status.value.source == StatusSource.ONLY_CACHE }
}
private fun getIsBetaTokensWarning(currency: CryptoCurrency): CryptoCurrencyWarning? {
val isTokenBetaFunctionality = BlockchainUtils.isTokenBetaFunctionality(currency.network.id.value)
return if (currency is CryptoCurrency.Token && isTokenBetaFunctionality) {

View file

@ -11,6 +11,7 @@ import com.tangem.domain.staking.repositories.StakingRepository
import com.tangem.domain.tokens.error.TokenListError
import com.tangem.domain.tokens.model.CryptoCurrencyStatus
import com.tangem.domain.tokens.model.TotalFiatBalance
import com.tangem.domain.tokens.operations.CurrenciesStatusesCachedOperations
import com.tangem.domain.tokens.operations.CurrenciesStatusesLceOperations
import com.tangem.domain.tokens.operations.TokenListFiatBalanceOperations
import com.tangem.domain.tokens.repository.CurrenciesRepository
@ -28,6 +29,7 @@ class GetWalletTotalBalanceUseCase(
private val quotesRepository: QuotesRepository,
private val networksRepository: NetworksRepository,
private val stakingRepository: StakingRepository,
private val tokensFeatureToggles: TokensFeatureToggles,
) {
operator fun invoke(
@ -86,13 +88,20 @@ class GetWalletTotalBalanceUseCase(
}
private fun getStatuses(userWalletId: UserWalletId): LceFlow<TokenListError, List<CryptoCurrencyStatus>> {
val operations = CurrenciesStatusesLceOperations(
currenciesRepository = currenciesRepository,
quotesRepository = quotesRepository,
networksRepository = networksRepository,
stakingRepository = stakingRepository,
)
return operations.getCurrenciesStatuses(userWalletId)
return if (tokensFeatureToggles.isBalancesCachingEnabled) {
CurrenciesStatusesCachedOperations(
currenciesRepository = currenciesRepository,
quotesRepository = quotesRepository,
networksRepository = networksRepository,
stakingRepository = stakingRepository,
).getCurrenciesStatuses(userWalletId)
} else {
CurrenciesStatusesLceOperations(
currenciesRepository = currenciesRepository,
quotesRepository = quotesRepository,
networksRepository = networksRepository,
stakingRepository = stakingRepository,
).getCurrenciesStatuses(userWalletId)
}
}
}

View file

@ -1,10 +1,7 @@
package com.tangem.domain.tokens.operations
import arrow.core.*
import arrow.core.raise.Raise
import arrow.core.raise.catch
import arrow.core.raise.ensureNotNull
import arrow.core.raise.recover
import arrow.core.raise.*
import com.tangem.domain.core.lce.Lce
import com.tangem.domain.core.lce.LceFlow
import com.tangem.domain.core.lce.lce
@ -15,6 +12,7 @@ import com.tangem.domain.staking.model.stakekit.YieldBalanceList
import com.tangem.domain.staking.repositories.StakingRepository
import com.tangem.domain.tokens.error.TokenListError
import com.tangem.domain.tokens.model.*
import com.tangem.domain.tokens.operations.CurrenciesStatusesOperations.Error
import com.tangem.domain.tokens.repository.CurrenciesRepository
import com.tangem.domain.tokens.repository.NetworksRepository
import com.tangem.domain.tokens.repository.QuotesRepository
@ -22,6 +20,7 @@ import com.tangem.domain.wallets.models.UserWalletId
import kotlinx.coroutines.*
import kotlinx.coroutines.flow.*
@Suppress("LargeClass")
internal class CurrenciesStatusesCachedOperations(
private val currenciesRepository: CurrenciesRepository,
private val quotesRepository: QuotesRepository,
@ -36,6 +35,89 @@ internal class CurrenciesStatusesCachedOperations(
)
}
suspend fun getCurrencyStatusFlow(
userWalletId: UserWalletId,
currencyId: CryptoCurrency.ID,
includeQuotes: Boolean = true,
): Flow<Either<Error, CryptoCurrencyStatus>> {
val currency = recover(
block = { getMultiCurrencyWalletCurrency(userWalletId = userWalletId, currencyId = currencyId) },
recover = { return flowOf(it.left()) },
)
return getCurrencyStatusFlow(userWalletId = userWalletId, currency = currency, includeQuotes = includeQuotes)
}
suspend fun getNetworkCoinFlow(
userWalletId: UserWalletId,
networkId: Network.ID,
derivationPath: Network.DerivationPath,
includeQuotes: Boolean = true,
): Flow<Either<Error, CryptoCurrencyStatus>> {
val operations = CurrenciesStatusesOperations(
currenciesRepository = currenciesRepository,
quotesRepository = quotesRepository,
networksRepository = networksRepository,
stakingRepository = stakingRepository,
userWalletId = userWalletId,
)
val currency = recover(
block = {
with(operations) { getNetworkCoin(networkId, derivationPath) }
},
recover = { return flowOf(it.left()) },
)
return getCurrencyStatusFlow(userWalletId, currency, includeQuotes)
}
private fun getCurrencyStatusFlow(
userWalletId: UserWalletId,
currency: CryptoCurrency,
includeQuotes: Boolean = true,
): Flow<Either<Error, CryptoCurrencyStatus>> {
val rawCurrencyId = currency.id.rawCurrencyId
val quoteFlow = if (includeQuotes && rawCurrencyId != null) {
getQuotes(rawCurrencyId)
.map { maybeQuotes ->
maybeQuotes.flatMap { quotes ->
quotes.singleOrNull { it.rawCurrencyId == currency.id.rawCurrencyId }?.right()
?: Error.EmptyQuotes.left()
}
}
} else {
// don't use emptyFlow()
flow { emit(Error.EmptyQuotes.left()) }
}
val statusFlow = getNetworksStatuses(userWalletId = userWalletId, network = currency.network)
.map { maybeStatuses ->
maybeStatuses.flatMap { statuses ->
statuses.singleOrNull { it.network == currency.network }?.right()
?: Error.EmptyNetworksStatuses.left()
}
}
val yieldBalanceFlow = getYieldBalance(userWalletId = userWalletId, cryptoCurrency = currency)
return combine(quoteFlow, statusFlow, yieldBalanceFlow) { maybeQuote, maybeNetworkStatus, maybeYieldBalance ->
createCurrencyStatus(currency, maybeQuote, maybeNetworkStatus, maybeYieldBalance)
}
}
private suspend fun Raise<Error>.getMultiCurrencyWalletCurrency(
userWalletId: UserWalletId,
currencyId: CryptoCurrency.ID,
): CryptoCurrency {
return Either.catch {
currenciesRepository.getMultiCurrencyWalletCurrency(userWalletId = userWalletId, id = currencyId)
}
.mapLeft { Error.DataError(it) }
.bind()
}
private fun transformToCurrenciesStatuses(
userWalletId: UserWalletId,
currenciesFlow: EitherFlow<TokenListError, List<CryptoCurrency>>,
@ -143,6 +225,30 @@ internal class CurrenciesStatusesCachedOperations(
}
}
private fun createCurrencyStatus(
currency: CryptoCurrency,
maybeQuote: Either<Error, Quote?>,
maybeNetworkStatus: Either<Error, NetworkStatus?>,
maybeYieldBalance: Either<Error, YieldBalance>?,
): Either<Error, CryptoCurrencyStatus> = either {
var quoteRetrievingFailed = false
val networkStatus = maybeNetworkStatus.bind()
val quote = recover({ maybeQuote.bind() }) {
quoteRetrievingFailed = true
null
}
val yieldBalance = maybeYieldBalance?.getOrNull()
createCurrencyStatus(
currency = currency,
quote = quote,
networkStatus = networkStatus,
ignoreQuote = quoteRetrievingFailed,
yieldBalance = yieldBalance,
)
}
private fun findYieldBalanceOrNull(
yieldBalances: YieldBalanceList?,
currency: CryptoCurrency,
@ -197,6 +303,18 @@ internal class CurrenciesStatusesCachedOperations(
.distinctUntilChanged()
}
private fun getQuotes(id: CryptoCurrency.RawID): Flow<Either<Error, Set<Quote>>> {
return quotesRepository.getQuotesUpdates(setOf(id))
.map<Set<Quote>, Either<Error, Set<Quote>>> { it.right() }
.retryWhen { cause, _ ->
emit(Error.DataError(cause).left())
// adding delay before retry to avoid spam when flow restarted
delay(RETRY_DELAY)
true
}
.distinctUntilChanged()
}
private fun getNetworksStatuses(
userWalletId: UserWalletId,
networks: NonEmptySet<Network>,
@ -212,6 +330,21 @@ internal class CurrenciesStatusesCachedOperations(
.distinctUntilChanged()
}
private fun getNetworksStatuses(
userWalletId: UserWalletId,
network: Network,
): EitherFlow<Error, Set<NetworkStatus>> {
return networksRepository.getNetworkStatusesUpdates(userWalletId, setOf(network))
.map<Set<NetworkStatus>, Either<Error, Set<NetworkStatus>>> { it.right() }
.retryWhen { cause, _ ->
emit(Error.DataError(cause).left())
// adding delay before retry to avoid spam when flow restarted
delay(RETRY_DELAY)
true
}
.distinctUntilChanged()
}
private fun getYieldBalances(
userWalletId: UserWalletId,
cryptoCurrencies: List<CryptoCurrency>,
@ -227,6 +360,18 @@ internal class CurrenciesStatusesCachedOperations(
.distinctUntilChanged()
}
private fun getYieldBalance(
userWalletId: UserWalletId,
cryptoCurrency: CryptoCurrency,
): EitherFlow<Error, YieldBalance> {
return stakingRepository.getSingleYieldBalanceFlow(
userWalletId = userWalletId,
cryptoCurrency = cryptoCurrency,
).map<YieldBalance, Either<Error, YieldBalance>> { it.right() }
.catch { emit(Error.DataError(it).left()) }
.onEmpty { emit(Error.EmptyYieldBalances.left()) }
}
private fun getIds(currencies: List<CryptoCurrency>): Pair<NonEmptySet<Network>, NonEmptySet<CryptoCurrency.ID>> {
val currencyIdToNetworkId = currencies.associate { currency ->
currency.id to currency.network

View file

@ -321,7 +321,7 @@ internal class CurrenciesStatusesOperations(
.bind()
}
private suspend fun Raise<Error>.getNetworkCoin(
suspend fun Raise<Error>.getNetworkCoin(
networkId: Network.ID,
derivationPath: Network.DerivationPath,
): CryptoCurrency {

View file

@ -54,6 +54,7 @@ internal class TokenDetailsNotificationsAnalyticsSender(
is TokenDetailsNotification.KoinosMana,
is TokenDetailsNotification.MigrationMaticToPol,
is TokenDetailsNotification.TokensInBeta,
is TokenDetailsNotification.UsedOutdatedData,
-> null
}
}

View file

@ -234,4 +234,11 @@ internal sealed class TokenDetailsNotification(val config: NotificationConfig) {
title = resourceReference(id = R.string.beta_mode_warning_title),
subtitle = resourceReference(id = R.string.beta_mode_warning_message),
)
data object UsedOutdatedData : TokenDetailsNotification(
config = NotificationConfig(
subtitle = resourceReference(R.string.warning_some_token_balances_not_updated),
iconResId = R.drawable.ic_error_sync_24,
),
)
}

View file

@ -136,6 +136,7 @@ internal class TokenDetailsNotificationConverter(
)
is CryptoCurrencyWarning.MigrationMaticToPol -> MigrationMaticToPol
is CryptoCurrencyWarning.TokensInBetaWarning -> TokensInBeta
is CryptoCurrencyWarning.UsedOutdatedDataWarning -> UsedOutdatedData
}
}

View file

@ -20,12 +20,12 @@ import com.tangem.core.ui.components.bottomsheets.chooseaddress.ChooseAddressBot
import com.tangem.core.ui.components.bottomsheets.chooseaddress.ChooseAddressBottomSheetConfig
import com.tangem.core.ui.components.bottomsheets.tokenreceive.TokenReceiveBottomSheet
import com.tangem.core.ui.components.bottomsheets.tokenreceive.TokenReceiveBottomSheetConfig
import com.tangem.core.ui.components.containers.pullToRefresh.TangemPullToRefreshContainer
import com.tangem.core.ui.components.marketprice.MarketPriceBlock
import com.tangem.core.ui.components.marketprice.MarketPriceBlockState
import com.tangem.core.ui.components.notifications.Notification
import com.tangem.core.ui.components.transactions.state.TxHistoryState
import com.tangem.core.ui.components.transactions.txHistoryItems
import com.tangem.core.ui.components.containers.pullToRefresh.TangemPullToRefreshContainer
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreview
import com.tangem.feature.tokendetails.presentation.tokendetails.TokenDetailsPreviewData
@ -96,6 +96,7 @@ internal fun TokenDetailsScreen(state: TokenDetailsState, tokenMarketBlockCompon
config = it.config,
iconTint = when (it) {
is TokenDetailsNotification.Informational -> TangemTheme.colors.icon.accent
is TokenDetailsNotification.UsedOutdatedData -> TangemTheme.colors.text.attention
else -> null
},
)