Updated on 2026-08-14

This commit is contained in:
Tangem 2026-01-27 16:35:45 +07:00
parent bd699c5bd9
commit ef5d0fec78
40 changed files with 600 additions and 642 deletions

View file

@ -6,12 +6,15 @@ import arrow.core.raise.ensure
import com.tangem.domain.models.TokensGroupType
import com.tangem.domain.models.TokensSortType
import com.tangem.domain.models.account.Account
import com.tangem.domain.models.account.AccountId
import com.tangem.domain.models.account.AccountName
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.utils.extensions.addOrReplace
import kotlinx.serialization.Serializable
typealias AccountCurrencyId = Pair<AccountId, CryptoCurrency.ID>
/**
* Represents a list of accounts associated with a user wallet ID.
*
@ -104,6 +107,19 @@ data class AccountList private constructor(
}
}
fun flattenMapCurrencies(): Map<AccountCurrencyId, CryptoCurrency> = buildMap {
accounts.forEach { acc ->
val account = when (acc) {
is Account.CryptoPortfolio -> acc
is Account.Payment -> TODO("[REDACTED_JIRA]")
}
account.cryptoCurrencies.forEach { currency ->
val key = account.accountId to currency.id
put(key, currency)
}
}
}
/**
* Represents possible errors that can occur when creating an `AccountList`
*/

View file

@ -1,9 +1,8 @@
package com.tangem.domain.account.status.di
import com.tangem.domain.account.status.producer.DefaultMultiAccountStatusListProducer
import com.tangem.domain.account.status.producer.DefaultSingleAccountStatusListProducer
import com.tangem.domain.account.status.producer.MultiAccountStatusListProducer
import com.tangem.domain.account.status.producer.SingleAccountStatusListProducer
import com.tangem.domain.account.status.producer.*
import com.tangem.domain.core.flow.FlowProducerScope
import com.tangem.domain.core.flow.FlowProducerTools
import dagger.Binds
import dagger.Module
import dagger.hilt.InstallIn
@ -25,4 +24,12 @@ internal interface AccountStatusListProducerFactoryModule {
fun bindMultiAccountStatusListProducerFactory(
factory: DefaultMultiAccountStatusListProducer.Factory,
): MultiAccountStatusListProducer.Factory
@Binds
@Singleton
fun bindDefaultFlowProducerTools(impl: DefaultFlowProducerTools): FlowProducerTools
@Binds
@Singleton
fun bindFlowProducerScope(impl: DefaultFlowProducerAppScope): FlowProducerScope
}

View file

@ -0,0 +1,101 @@
package com.tangem.domain.account.status.producer
import com.tangem.core.analytics.api.AnalyticsExceptionHandler
import com.tangem.core.analytics.models.ExceptionAnalyticsEvent
import com.tangem.domain.core.flow.FlowProducer
import com.tangem.domain.core.flow.FlowProducerScope
import com.tangem.domain.core.flow.FlowProducerTools
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import kotlinx.coroutines.CoroutineExceptionHandler
import kotlinx.coroutines.CoroutineName
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.*
import timber.log.Timber
import javax.inject.Inject
import kotlin.coroutines.CoroutineContext
class DefaultFlowProducerAppScope @Inject constructor(
private val dispatchers: CoroutineDispatcherProvider,
private val analyticsExceptionHandler: AnalyticsExceptionHandler,
) : FlowProducerScope {
private val tag = "FlowProducerScope"
override val coroutineContext: CoroutineContext = SupervisorJob() +
dispatchers.default +
CoroutineName(tag) +
CoroutineExceptionHandler { context, throwable ->
@Suppress("NullableToStringCall")
val coroutineName = context[CoroutineName]?.name.toString()
logError(throwable, coroutineName)
}
private fun logError(throwable: Throwable, coroutineName: String) {
Timber.tag("FlowProducerExceptionHandler").e(
throwable,
"CoroutineName $coroutineName",
)
val event = ExceptionAnalyticsEvent(
exception = throwable,
params = mapOf(
"source" to tag,
"coroutineName" to coroutineName,
),
)
analyticsExceptionHandler.sendException(event)
}
}
class DefaultFlowProducerTools @Inject constructor(
private val scope: FlowProducerScope,
private val analyticsExceptionHandler: AnalyticsExceptionHandler,
) : FlowProducerTools {
override fun <T> shareInProducer(
flow: Flow<T>,
flowProducer: FlowProducer<T>,
withRetryWhen: Boolean,
): SharedFlow<T> {
var upstream = flow
if (withRetryWhen) {
val flowProducerName = flowProducer.javaClass.simpleName
upstream = upstream
.retryWhen { cause: Throwable, attempt: Long ->
logError(cause, flowProducerName, attempt)
flowProducer.fallback.onSome { this.emit(value = it) }
delay(timeMillis = 2000)
true
}
}
return upstream
.distinctUntilChanged()
.shareIn(
scope = scope,
replay = 1,
// params control flow cleanup
started = SharingStarted.WhileSubscribed(
stopTimeoutMillis = 5_000,
replayExpirationMillis = 30_000,
),
)
}
private fun logError(cause: Throwable, flowProducerName: String, attempt: Long) {
val tag = "FlowProducerRetryWhen"
Timber.tag(tag)
.e(cause, "flowProducerName $flowProducerName attempt $attempt")
val event = ExceptionAnalyticsEvent(
exception = cause,
params = mapOf(
"source" to tag,
"flowProducerName" to flowProducerName,
),
)
analyticsExceptionHandler.sendException(event)
}
}

View file

@ -5,6 +5,7 @@ import arrow.core.some
import com.tangem.domain.account.models.AccountStatusList
import com.tangem.domain.account.repository.AccountsCRUDRepository
import com.tangem.domain.account.status.supplier.SingleAccountStatusListSupplier
import com.tangem.domain.core.flow.FlowProducerTools
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
@ -27,6 +28,7 @@ import kotlinx.coroutines.flow.flowOn
*/
internal class DefaultMultiAccountStatusListProducer @AssistedInject constructor(
@Assisted val params: Unit,
override val flowProducerTools: FlowProducerTools,
private val accountsCRUDRepository: AccountsCRUDRepository,
private val singleAccountStatusListSupplier: SingleAccountStatusListSupplier,
private val dispatchers: CoroutineDispatcherProvider,

View file

@ -2,24 +2,42 @@ package com.tangem.domain.account.status.producer
import arrow.core.Option
import arrow.core.none
import arrow.core.toOption
import com.tangem.core.analytics.api.AnalyticsExceptionHandler
import com.tangem.domain.account.models.AccountCurrencyId
import com.tangem.domain.account.models.AccountList
import com.tangem.domain.account.models.AccountStatusList
import com.tangem.domain.account.repository.AccountsCRUDRepository
import com.tangem.domain.account.status.utils.CryptoCurrencyStatusesFlowFactory
import com.tangem.domain.account.supplier.SingleAccountListSupplier
import com.tangem.domain.core.flow.FlowProducerTools
import com.tangem.domain.core.utils.lceContent
import com.tangem.domain.core.utils.lceLoading
import com.tangem.domain.models.StatusSource
import com.tangem.domain.models.TokensGroupType
import com.tangem.domain.models.TokensSortType
import com.tangem.domain.models.TotalFiatBalance
import com.tangem.domain.models.account.Account
import com.tangem.domain.models.account.AccountStatus
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.network.NetworkStatus
import com.tangem.domain.models.network.getAddress
import com.tangem.domain.models.quote.PriceChange
import com.tangem.domain.models.quote.QuoteStatus
import com.tangem.domain.models.staking.StakingBalance
import com.tangem.domain.models.staking.StakingID
import com.tangem.domain.models.tokenlist.TokenList
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.models.wallet.isMultiCurrency
import com.tangem.domain.networks.multi.MultiNetworkStatusProducer
import com.tangem.domain.networks.multi.MultiNetworkStatusSupplier
import com.tangem.domain.networks.repository.NetworksRepository
import com.tangem.domain.quotes.multi.MultiQuoteStatusSupplier
import com.tangem.domain.staking.StakingIdFactory
import com.tangem.domain.staking.multi.MultiStakingBalanceProducer
import com.tangem.domain.staking.multi.MultiStakingBalanceSupplier
import com.tangem.domain.staking.single.SingleStakingBalanceProducer.Companion.selectStakingBalance
import com.tangem.domain.tokens.operations.CryptoCurrencyStatusFactory
import com.tangem.domain.tokens.operations.PriceChangeCalculator
import com.tangem.domain.tokens.operations.TokenListFactory
import com.tangem.domain.tokens.operations.TotalFiatBalanceCalculator
@ -28,10 +46,10 @@ import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.channels.BufferOverflow
import kotlinx.coroutines.channels.ProducerScope
import kotlinx.coroutines.flow.*
import java.math.BigDecimal
import kotlin.time.Duration.Companion.milliseconds
/**
* Produces a flow of [AccountStatusList] for a single user wallet.
@ -44,117 +62,229 @@ import kotlin.time.Duration.Companion.milliseconds
*
[REDACTED_AUTHOR]
*/
@Suppress("LongParameterList")
@OptIn(ExperimentalCoroutinesApi::class)
internal class DefaultSingleAccountStatusListProducer @AssistedInject constructor(
@Assisted private val params: SingleAccountStatusListProducer.Params,
private val accountsCRUDRepository: AccountsCRUDRepository,
private val singleAccountListSupplier: SingleAccountListSupplier,
private val networksRepository: NetworksRepository,
private val cryptoCurrencyStatusesFlowFactory: CryptoCurrencyStatusesFlowFactory,
private val dispatchers: CoroutineDispatcherProvider,
override val flowProducerTools: FlowProducerTools,
private val networkStatusSupplier: MultiNetworkStatusSupplier,
private val quoteStatusSupplier: MultiQuoteStatusSupplier,
private val stakingBalanceSupplier: MultiStakingBalanceSupplier,
private val stakingIdFactory: StakingIdFactory,
private val analyticsExceptionHandler: AnalyticsExceptionHandler,
) : SingleAccountStatusListProducer {
override val fallback: Option<AccountStatusList> = none()
override fun produce(): Flow<AccountStatusList> {
return singleAccountListSupplier(userWalletId = params.userWalletId).flatMapLatest { accountList ->
val accountStatusFlows = createAccountStatusFlows(accountList)
return flattenFlow()
.flowOn(dispatchers.default)
}
combine(accountStatusFlows) { accountStatuses ->
@Suppress("LongMethod")
private fun flattenFlow(): Flow<AccountStatusList> = channelFlow {
val walletId = params.userWalletId
val userWallet = accountsCRUDRepository.getUserWallet(userWalletId = params.userWalletId)
val flattenCurrency: MutableSharedFlow<Map<AccountCurrencyId, CryptoCurrency>> = MutableSharedFlow(
replay = 1,
onBufferOverflow = BufferOverflow.DROP_OLDEST,
)
val accountListFlow: StateFlow<AccountList> = singleAccountListSupplier(walletId)
.onEach { accountList -> flattenCurrency.tryEmit(accountList.flattenMapCurrencies()) }
.stateIn(this)
val hasCachedNetworks = networksRepository.hasCachedStatuses(walletId)
if (!hasCachedNetworks) {
send(createLoadingAccountStatusList(accountListFlow.value))
}
val cryptoCurrencyStatusFlow: Flow<Map<AccountCurrencyId, CryptoCurrencyStatus>> = flattenCurrencyStatusFlow(
userWallet = userWallet,
flattenCurrency = flattenCurrency,
)
combine(
flow = accountListFlow,
flow2 = cryptoCurrencyStatusFlow,
transform = { accountList, currencyStatusMap ->
val accountStatuses: List<AccountStatus.CryptoPortfolio> = accountList.accounts.map { acc ->
val account: Account.CryptoPortfolio = when (acc) {
is Account.CryptoPortfolio -> acc
is Account.Payment -> TODO("[REDACTED_JIRA]")
}
if (account.cryptoCurrencies.isEmpty()) {
account.toEmptyAccountStatus()
} else {
val statuses: List<CryptoCurrencyStatus> = account.cryptoCurrencies.map { currency ->
val acId = account.accountId to currency.id
currencyStatusMap[acId] ?: currency.toLoadingCurrencyStatus()
}
AccountStatus.CryptoPortfolio(
account = account,
tokenList = TokenListFactory.create(
statuses = statuses,
groupType = accountList.groupType,
sortType = accountList.sortType,
),
priceChangeLce = PriceChangeCalculator.calculate(statuses = statuses),
)
}
}
val balances = accountStatuses.flattenTotalFiatBalance()
AccountStatusList(
userWalletId = accountList.userWalletId,
accountStatuses = accountStatuses.toList(),
accountStatuses = accountStatuses,
totalAccounts = accountList.totalAccounts,
totalFiatBalance = TotalFiatBalanceCalculator.calculate(balances),
totalArchivedAccounts = accountList.totalArchivedAccounts,
sortType = accountList.sortType,
groupType = accountList.groupType,
)
}
.onStartCheckCachedNetworks(accountList)
}
.distinctUntilChanged()
.flowOn(dispatchers.default)
}
private fun createAccountStatusFlows(accountList: AccountList): List<Flow<AccountStatus>> {
return accountList.accounts.map { account ->
when (account) {
is Account.CryptoPortfolio -> {
if (account.cryptoCurrencies.isEmpty()) {
createEmptyAccountStatusFlow(account)
} else {
val userWallet = accountsCRUDRepository.getUserWallet(userWalletId = params.userWalletId)
getAccountStatusFlow(
userWallet = userWallet,
account = account,
groupType = accountList.groupType,
sortType = accountList.sortType,
)
}
.distinctUntilChanged()
}
is Account.Payment -> TODO("[REDACTED_JIRA]")
}
}
}
private fun createEmptyAccountStatusFlow(account: Account.CryptoPortfolio): Flow<AccountStatus.CryptoPortfolio> {
return flowOf(
AccountStatus.CryptoPortfolio(
account = account,
tokenList = TokenList.Empty,
priceChangeLce = PriceChange(
value = BigDecimal.ZERO.movePointLeft(2),
source = StatusSource.ACTUAL,
).lceContent(),
),
},
)
.collect { accountStatusList -> channel.send(accountStatusList) }
}
private fun getAccountStatusFlow(
private fun ProducerScope<AccountStatusList>.flattenCurrencyStatusFlow(
userWallet: UserWallet,
account: Account.CryptoPortfolio,
groupType: TokensGroupType,
sortType: TokensSortType,
): Flow<AccountStatus.CryptoPortfolio> {
val statusesFlows = getCryptoCurrencyStatusesFlow(userWallet, account)
flattenCurrency: MutableSharedFlow<Map<AccountCurrencyId, CryptoCurrency>>,
): Flow<Map<AccountCurrencyId, CryptoCurrencyStatus>> {
val walletId = userWallet.walletId
val networkStatusFlow: SharedFlow<Map<Network.ID, NetworkStatus>> = networkStatusFlow(walletId, flattenCurrency)
.shareIn(this, started = SharingStarted.Eagerly, replay = 1)
val stakingBalanceFlow: SharedFlow<Map<StakingID, Set<StakingBalance>>> = stakingFlow(userWallet)
.shareIn(this, started = SharingStarted.Eagerly, replay = 1)
val quoteStatusFlow: SharedFlow<Map<CryptoCurrency.RawID, QuoteStatus>> = quoteStatusFlow()
.shareIn(this, started = SharingStarted.Eagerly, replay = 1)
return statusesFlows
.map { statusList ->
AccountStatus.CryptoPortfolio(
account = account,
tokenList = TokenListFactory.create(
statuses = statusList,
groupType = groupType,
sortType = sortType,
),
priceChangeLce = PriceChangeCalculator.calculate(statuses = statusList),
return flattenCurrency
.distinctUntilChanged()
.flatMapLatest { a ->
combine(
flow = networkStatusFlow,
flow2 = stakingBalanceFlow,
flow3 = quoteStatusFlow,
transform = { b, c, d -> Box(
flattenCurrencyMap = a,
networkStatusMap = b,
stakingBalanceMap = c,
quoteStatusMap = d,
) },
)
}
.distinctUntilChanged()
.map { box ->
val flattenCurrencyMap: Map<AccountCurrencyId, CryptoCurrency> = box.flattenCurrencyMap
val networkStatusMap: Map<Network.ID, NetworkStatus> = box.networkStatusMap
val stakingBalanceMap: Map<StakingID, Set<StakingBalance>> = box.stakingBalanceMap
val quoteStatusMap: Map<CryptoCurrency.RawID, QuoteStatus> = box.quoteStatusMap
flattenCurrencyMap.mapValues { (acId: AccountCurrencyId, currency) ->
val (_, id) = acId
val networkStatus: NetworkStatus? = networkStatusMap[currency.network.id]
val quoteStatus: QuoteStatus? = id.rawCurrencyId?.let { rawID -> quoteStatusMap[rawID] }
val stakingBalance: StakingBalance? = findStakingBalance(
networkStatus = networkStatus,
id = id,
wallet = userWallet,
stakingBalanceMap = stakingBalanceMap,
)
CryptoCurrencyStatusFactory.create(
currency = currency,
maybeNetworkStatus = networkStatus.toOption(),
maybeQuoteStatus = quoteStatus.toOption(),
maybeStakingBalance = stakingBalance.toOption(),
)
}
}
}
@OptIn(FlowPreview::class)
private fun getCryptoCurrencyStatusesFlow(
userWallet: UserWallet,
account: Account.CryptoPortfolio,
): Flow<List<CryptoCurrencyStatus>> {
val statusesFlows = account.cryptoCurrencies.map { currency ->
cryptoCurrencyStatusesFlowFactory.create(userWallet = userWallet, currency = currency)
.onStart { emit(CryptoCurrencyStatus(currency = currency, value = CryptoCurrencyStatus.Loading)) }
private fun networkStatusFlow(
walletId: UserWalletId,
flattenCurrency: MutableSharedFlow<Map<AccountCurrencyId, CryptoCurrency>>,
): Flow<Map<Network.ID, NetworkStatus>> = channelFlow {
val currencyCount = flattenCurrency
.map { map -> map.size }
.stateIn(this, SharingStarted.Eagerly, 0)
networkStatusSupplier(MultiNetworkStatusProducer.Params(walletId))
// todo accounts high frequency, investigate better debounce
.debounce {
val count = currencyCount.value
@Suppress("MagicNumber") when {
count in 10..25 -> 50L
count > 25 -> 100L
else -> 0
}
}
.mapLatest { statuses -> statuses.associateBy { status -> status.network.id } }
.distinctUntilChanged().collect { result -> channel.send(result) }
}
private fun stakingFlow(wallet: UserWallet): Flow<Map<StakingID, Set<StakingBalance>>> =
if (!wallet.isMultiCurrency) {
flowOf(emptyMap())
} else {
stakingBalanceSupplier(MultiStakingBalanceProducer.Params(wallet.walletId))
.map { balances ->
val result = mutableMapOf<StakingID, MutableSet<StakingBalance>>()
balances.forEach { balance ->
val set = result[balance.stakingId] ?: mutableSetOf()
set.add(balance)
result[balance.stakingId] = set
}
result
}
.distinctUntilChanged()
}
return combine(statusesFlows) { it.toList() }
.distinctUntilChanged()
.debounce(50.milliseconds)
private fun quoteStatusFlow(): Flow<Map<CryptoCurrency.RawID, QuoteStatus>> = quoteStatusSupplier(Unit)
.distinctUntilChanged()
private fun findStakingBalance(
networkStatus: NetworkStatus?,
id: CryptoCurrency.ID,
wallet: UserWallet,
stakingBalanceMap: Map<StakingID, Set<StakingBalance>>,
): StakingBalance? {
if (!wallet.isMultiCurrency) {
return null
}
val stakingId = stakingIdFactory.create(
currencyId = id,
defaultAddress = networkStatus.getAddress(),
).getOrNull() ?: return null
val stakingBalance = stakingBalanceMap[stakingId] ?: return null
return selectStakingBalance(
currentStakingId = stakingId,
currentBalances = stakingBalance.toList(),
analyticsExceptionHandler = analyticsExceptionHandler,
)
}
private fun Array<AccountStatus>.flattenTotalFiatBalance(): List<TotalFiatBalance> {
private fun Account.CryptoPortfolio.toEmptyAccountStatus() = AccountStatus.CryptoPortfolio(
account = this,
tokenList = TokenList.Empty,
priceChangeLce = PriceChange(
value = BigDecimal.ZERO.movePointLeft(2),
source = StatusSource.ACTUAL,
).lceContent(),
)
private fun CryptoCurrency.toLoadingCurrencyStatus() = CryptoCurrencyStatus(
currency = this,
value = CryptoCurrencyStatus.Loading,
)
private fun List<AccountStatus>.flattenTotalFiatBalance(): List<TotalFiatBalance> {
return map { accountStatus ->
when (accountStatus) {
is AccountStatus.CryptoPortfolio -> accountStatus.tokenList.totalFiatBalance
@ -162,17 +292,6 @@ internal class DefaultSingleAccountStatusListProducer @AssistedInject constructo
}
}
private fun Flow<AccountStatusList>.onStartCheckCachedNetworks(accountList: AccountList): Flow<AccountStatusList> {
return onStart {
val hasCachedNetworks = networksRepository.hasCachedStatuses(userWalletId = accountList.userWalletId)
if (hasCachedNetworks) return@onStart
val loading = createLoadingAccountStatusList(accountList)
emit(loading)
}
}
private fun createLoadingAccountStatusList(accountList: AccountList): AccountStatusList {
return AccountStatusList(
userWalletId = accountList.userWalletId,
@ -204,6 +323,13 @@ internal class DefaultSingleAccountStatusListProducer @AssistedInject constructo
)
}
private data class Box(
val flattenCurrencyMap: Map<AccountCurrencyId, CryptoCurrency>,
val networkStatusMap: Map<Network.ID, NetworkStatus>,
val stakingBalanceMap: Map<StakingID, Set<StakingBalance>>,
val quoteStatusMap: Map<CryptoCurrency.RawID, QuoteStatus>,
)
@AssistedFactory
interface Factory : SingleAccountStatusListProducer.Factory {
override fun create(params: SingleAccountStatusListProducer.Params): DefaultSingleAccountStatusListProducer

View file

@ -3,6 +3,7 @@ package com.tangem.domain.account.status.producer
import arrow.core.Option
import arrow.core.none
import com.tangem.domain.account.status.supplier.SingleAccountStatusListSupplier
import com.tangem.domain.core.flow.FlowProducerTools
import com.tangem.domain.models.account.AccountStatus
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import dagger.assisted.Assisted
@ -21,6 +22,7 @@ import kotlinx.coroutines.flow.mapNotNull
*/
internal class DefaultSingleAccountStatusProducer @AssistedInject constructor(
@Assisted val params: SingleAccountStatusProducer.Params,
override val flowProducerTools: FlowProducerTools,
private val singleAccountStatusListSupplier: SingleAccountStatusListSupplier,
private val dispatchers: CoroutineDispatcherProvider,
) : SingleAccountStatusProducer {

View file

@ -1,165 +0,0 @@
package com.tangem.domain.account.status.utils
import arrow.core.toOption
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.network.NetworkStatus
import com.tangem.domain.models.network.getAddress
import com.tangem.domain.models.quote.QuoteStatus
import com.tangem.domain.models.staking.StakingBalance
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.models.wallet.isMultiCurrency
import com.tangem.domain.networks.single.SingleNetworkStatusProducer
import com.tangem.domain.networks.single.SingleNetworkStatusSupplier
import com.tangem.domain.quotes.single.SingleQuoteStatusProducer
import com.tangem.domain.quotes.single.SingleQuoteStatusSupplier
import com.tangem.domain.staking.StakingIdFactory
import com.tangem.domain.staking.single.SingleStakingBalanceProducer
import com.tangem.domain.staking.single.SingleStakingBalanceSupplier
import com.tangem.domain.tokens.operations.CryptoCurrencyStatusFactory
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.*
import javax.inject.Inject
/**
* Factory that creates a flow of [CryptoCurrencyStatus] for a given [CryptoCurrency] in a [UserWallet].
*
* @property singleNetworkStatusSupplier Supplier for obtaining network status.
* @property singleQuoteStatusSupplier Supplier for obtaining quote status.
* @property singleStakingBalanceSupplier Supplier for obtaining staking balance.
* @property stakingIdFactory Factory for creating staking IDs.
*
[REDACTED_AUTHOR]
*/
internal class CryptoCurrencyStatusesFlowFactory @Inject constructor(
private val singleNetworkStatusSupplier: SingleNetworkStatusSupplier,
private val singleQuoteStatusSupplier: SingleQuoteStatusSupplier,
private val singleStakingBalanceSupplier: SingleStakingBalanceSupplier,
private val stakingIdFactory: StakingIdFactory,
) {
/**
* Creates a flow of [CryptoCurrencyStatus] for the specified [userWallet] and [currency].
*
* @param userWallet The user wallet containing the currency.
*/
fun create(userWallet: UserWallet, currency: CryptoCurrency): Flow<CryptoCurrencyStatus> {
return getCryptoCurrencyStatusSourcesFlow(userWallet = userWallet, currency = currency)
.map { statusSources ->
CryptoCurrencyStatusFactory.create(
currency = currency,
maybeNetworkStatus = statusSources.networkStatus.toOption(),
maybeQuoteStatus = statusSources.quoteStatus.toOption(),
maybeStakingBalance = statusSources.stakingBalance.toOption(),
)
}
.onEmpty {
emit(
CryptoCurrencyStatus(currency = currency, value = CryptoCurrencyStatus.Loading),
)
}
.distinctUntilChanged()
}
@OptIn(ExperimentalCoroutinesApi::class)
private fun getCryptoCurrencyStatusSourcesFlow(
userWallet: UserWallet,
currency: CryptoCurrency,
): Flow<CryptoCurrencyStatusSources> {
val networkStatusFlow = getNetworkStatusFlow(userWalletId = userWallet.walletId, network = currency.network)
val stakingBalanceFlow = if (userWallet.isMultiCurrency) {
networkStatusFlow.flatMapLatest { networkStatus ->
if (networkStatus != null) {
getStakingBalanceFlow(
userWalletId = userWallet.walletId,
currencyId = currency.id,
networkStatus = networkStatus,
)
} else {
flowOf(null)
}
}
} else {
null
}
val quoteStatusFlow = currency.id.rawCurrencyId?.let(::getQuoteStatusFlow)
return combine(networkStatusFlow, stakingBalanceFlow, quoteStatusFlow)
.distinctUntilChanged()
}
private fun combine(
networkStatusFlow: Flow<NetworkStatus?>,
stakingBalanceFlow: Flow<StakingBalance?>?,
quoteStatusFlow: Flow<QuoteStatus>?,
): Flow<CryptoCurrencyStatusSources> {
return when {
stakingBalanceFlow != null && quoteStatusFlow != null -> {
combine(
flow = networkStatusFlow,
flow2 = stakingBalanceFlow,
flow3 = quoteStatusFlow,
transform = ::CryptoCurrencyStatusSources,
)
}
stakingBalanceFlow != null -> {
combine(flow = networkStatusFlow, flow2 = stakingBalanceFlow, transform = ::CryptoCurrencyStatusSources)
}
quoteStatusFlow != null -> {
combine(flow = networkStatusFlow, flow2 = quoteStatusFlow) { networkStatus, quoteStatus ->
CryptoCurrencyStatusSources(networkStatus = networkStatus, quoteStatus = quoteStatus)
}
}
else -> networkStatusFlow.map(::CryptoCurrencyStatusSources)
}
}
private fun getNetworkStatusFlow(userWalletId: UserWalletId, network: Network): Flow<NetworkStatus?> {
return singleNetworkStatusSupplier(
params = SingleNetworkStatusProducer.Params(userWalletId = userWalletId, network = network),
)
.distinctUntilChanged()
.onEmpty<NetworkStatus?> {
emit(value = null)
}
}
private fun getQuoteStatusFlow(rawCurrencyId: CryptoCurrency.RawID): Flow<QuoteStatus> {
return singleQuoteStatusSupplier(
params = SingleQuoteStatusProducer.Params(rawCurrencyId = rawCurrencyId),
)
.distinctUntilChanged()
}
private fun getStakingBalanceFlow(
userWalletId: UserWalletId,
currencyId: CryptoCurrency.ID,
networkStatus: NetworkStatus,
): Flow<StakingBalance?> {
val stakingId = stakingIdFactory.create(
currencyId = currencyId,
defaultAddress = networkStatus.getAddress(),
)
.getOrNull()
return if (stakingId != null) {
singleStakingBalanceSupplier(
params = SingleStakingBalanceProducer.Params(userWalletId = userWalletId, stakingId = stakingId),
)
.distinctUntilChanged()
} else {
flowOf(null)
}
}
private data class CryptoCurrencyStatusSources(
val networkStatus: NetworkStatus? = null,
val stakingBalance: StakingBalance? = null,
val quoteStatus: QuoteStatus? = null,
)
}

View file

@ -4,6 +4,7 @@ import com.google.common.truth.Truth
import com.tangem.domain.account.models.AccountStatusList
import com.tangem.domain.account.repository.AccountsCRUDRepository
import com.tangem.domain.account.status.supplier.SingleAccountStatusListSupplier
import com.tangem.domain.core.flow.FlowProducerTools
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.test.core.getEmittedValues
@ -23,6 +24,7 @@ class DefaultMultiAccountStatusListProducerTest {
private val accountsCRUDRepository: AccountsCRUDRepository = mockk()
private val singleAccountStatusListSupplier: SingleAccountStatusListSupplier = mockk()
private val dispatchers = TestingCoroutineDispatcherProvider()
private val flowProducerTools: FlowProducerTools = mockk()
private val userWalletId1 = UserWalletId("001")
private val userWallet1 = mockk<UserWallet> {
@ -39,6 +41,7 @@ class DefaultMultiAccountStatusListProducerTest {
accountsCRUDRepository = accountsCRUDRepository,
singleAccountStatusListSupplier = singleAccountStatusListSupplier,
dispatchers = dispatchers,
flowProducerTools = flowProducerTools,
)
@AfterEach

View file

@ -6,8 +6,8 @@ import com.tangem.common.test.domain.token.MockCryptoCurrencyFactory
import com.tangem.domain.account.models.AccountList
import com.tangem.domain.account.models.AccountStatusList
import com.tangem.domain.account.repository.AccountsCRUDRepository
import com.tangem.domain.account.status.utils.CryptoCurrencyStatusesFlowFactory
import com.tangem.domain.account.supplier.SingleAccountListSupplier
import com.tangem.domain.core.flow.FlowProducerTools
import com.tangem.domain.core.utils.lceContent
import com.tangem.domain.core.utils.lceLoading
import com.tangem.domain.models.StatusSource
@ -42,20 +42,22 @@ class DefaultSingleAccountStatusListProducerTest {
private val accountsCRUDRepository: AccountsCRUDRepository = mockk()
private val singleAccountListSupplier: SingleAccountListSupplier = mockk()
private val networksRepository: NetworksRepository = mockk()
private val cryptoCurrencyStatusesFlowFactory: CryptoCurrencyStatusesFlowFactory = mockk()
private val flowProducerTools: FlowProducerTools = mockk()
private val userWalletId = UserWalletId("011")
private val userWallet = mockk<UserWallet> {
every { this@mockk.walletId } returns userWalletId
}
private val producer = DefaultSingleAccountStatusListProducer(
// todo accounts status producer tests
/*private val producer = DefaultSingleAccountStatusListProducer(
params = SingleAccountStatusListProducer.Params(userWalletId),
accountsCRUDRepository = accountsCRUDRepository,
singleAccountListSupplier = singleAccountListSupplier,
networksRepository = networksRepository,
cryptoCurrencyStatusesFlowFactory = cryptoCurrencyStatusesFlowFactory,
dispatchers = TestingCoroutineDispatcherProvider(),
flowProducerTools = flowProducerTools,
)
@AfterEach
@ -291,5 +293,5 @@ class DefaultSingleAccountStatusListProducerTest {
cryptoCurrencyStatusesFlowFactory.create(userWallet = userWallet, currency = cryptoCurrencyFactory.stellar)
networksRepository.hasCachedStatuses(userWalletId)
}
}
}*/
}

View file

@ -1,308 +0,0 @@
package com.tangem.domain.account.status.utils
import arrow.core.left
import arrow.core.right
import com.google.common.truth.Truth
import com.tangem.common.test.domain.token.MockCryptoCurrencyFactory
import com.tangem.domain.models.StatusSource
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.currency.CryptoCurrencyStatus
import com.tangem.domain.models.network.NetworkAddress
import com.tangem.domain.models.network.NetworkStatus
import com.tangem.domain.models.quote.QuoteStatus
import com.tangem.domain.models.staking.StakingID
import com.tangem.domain.models.staking.StakingBalance
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.models.wallet.isMultiCurrency
import com.tangem.domain.networks.single.SingleNetworkStatusProducer
import com.tangem.domain.networks.single.SingleNetworkStatusSupplier
import com.tangem.domain.quotes.single.SingleQuoteStatusProducer
import com.tangem.domain.quotes.single.SingleQuoteStatusSupplier
import com.tangem.domain.staking.StakingIdFactory
import com.tangem.domain.staking.single.SingleStakingBalanceProducer
import com.tangem.domain.staking.single.SingleStakingBalanceSupplier
import com.tangem.test.core.getEmittedValues
import io.mockk.*
import kotlinx.coroutines.flow.emptyFlow
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.AfterEach
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.TestInstance
import java.math.BigDecimal
/**
[REDACTED_AUTHOR]
*/
@Suppress("UnusedFlow")
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
class CryptoCurrencyStatusesFlowFactoryTest {
private val singleNetworkStatusSupplier: SingleNetworkStatusSupplier = mockk()
private val singleQuoteStatusSupplier: SingleQuoteStatusSupplier = mockk()
private val singleStakingBalanceSupplier: SingleStakingBalanceSupplier = mockk()
private val stakingIdFactory: StakingIdFactory = mockk()
private val factory = CryptoCurrencyStatusesFlowFactory(
singleNetworkStatusSupplier = singleNetworkStatusSupplier,
singleQuoteStatusSupplier = singleQuoteStatusSupplier,
singleStakingBalanceSupplier = singleStakingBalanceSupplier,
stakingIdFactory = stakingIdFactory,
)
private val userWalletId = UserWalletId("011")
private val cryptoCurrencyFactory = MockCryptoCurrencyFactory()
private val networkAddress = NetworkAddress.Single(
defaultAddress = NetworkAddress.Address(value = "0x1", type = NetworkAddress.Address.Type.Primary),
)
@AfterEach
fun tearDown() {
clearMocks(
singleNetworkStatusSupplier,
singleQuoteStatusSupplier,
singleStakingBalanceSupplier,
stakingIdFactory,
)
}
@Test
fun `if rawCurrencyId is null, there will be no subscription to the quote status`() = runTest {
// Arrange
val userWallet = mockk<UserWallet> {
every { this@mockk.walletId } returns userWalletId
every { this@mockk.isMultiCurrency } returns true
}
val currency = cryptoCurrencyFactory.ethereum.copy(
id = cryptoCurrencyFactory.ethereum.id.copy(
suffix = CryptoCurrency.ID.Suffix.ContractAddress(contractAddress = "0x12345"),
),
)
val networkStatus = NetworkStatus(
network = currency.network,
value = NetworkStatus.Unreachable(address = networkAddress),
)
val networkStatusFlow = flowOf(networkStatus)
every {
singleNetworkStatusSupplier(params = SingleNetworkStatusProducer.Params(userWalletId, currency.network))
} returns networkStatusFlow
val stakingId = StakingID(integrationId = "id", address = networkAddress.defaultAddress.value)
coEvery {
stakingIdFactory.create(currencyId = currency.id, defaultAddress = networkAddress.defaultAddress.value)
} returns stakingId.right()
val stakingBalance = StakingBalance.Empty(stakingId = stakingId, source = StatusSource.ACTUAL)
val stakingBalanceFlow = flowOf(stakingBalance)
every {
singleStakingBalanceSupplier(
params = SingleStakingBalanceProducer.Params(userWalletId = userWalletId, stakingId = stakingId),
)
} returns stakingBalanceFlow
// Act
val actual = factory.create(userWallet = userWallet, currency = currency).let(::getEmittedValues)
// Assert
val expected = CryptoCurrencyStatus(
currency = currency,
value = CryptoCurrencyStatus.Unreachable(
priceChange = null,
fiatRate = null,
networkAddress = networkAddress,
),
)
Truth.assertThat(actual).containsExactly(expected)
coVerify(ordering = Ordering.SEQUENCE) {
singleNetworkStatusSupplier(params = SingleNetworkStatusProducer.Params(userWalletId, currency.network))
stakingIdFactory.create(currencyId = currency.id, defaultAddress = networkAddress.defaultAddress.value)
singleStakingBalanceSupplier(params = SingleStakingBalanceProducer.Params(userWalletId, stakingId))
}
}
@Test
fun `if userWallet is not multi-currency, there will be no subscription to the yield balance`() = runTest {
// Arrange
val userWallet = mockk<UserWallet> {
every { this@mockk.walletId } returns userWalletId
every { this@mockk.isMultiCurrency } returns false
}
val currency = cryptoCurrencyFactory.ethereum
val networkStatus = NetworkStatus(
network = currency.network,
value = NetworkStatus.Unreachable(address = networkAddress),
)
val networkStatusFlow = flowOf(networkStatus)
every {
singleNetworkStatusSupplier(params = SingleNetworkStatusProducer.Params(userWalletId, currency.network))
} returns networkStatusFlow
val quoteStatus = QuoteStatus(
rawCurrencyId = currency.id.rawCurrencyId!!,
value = QuoteStatus.Data(
source = StatusSource.ACTUAL,
fiatRate = BigDecimal.ONE,
priceChange = BigDecimal.ONE,
),
)
val quoteStatusFlow = flowOf(quoteStatus)
every {
singleQuoteStatusSupplier(params = SingleQuoteStatusProducer.Params(currency.id.rawCurrencyId!!))
} returns quoteStatusFlow
// Act
val actual = factory.create(userWallet = userWallet, currency = currency).let(::getEmittedValues)
// Assert
val expected = CryptoCurrencyStatus(
currency = currency,
value = CryptoCurrencyStatus.Unreachable(
priceChange = BigDecimal.ONE,
fiatRate = BigDecimal.ONE,
networkAddress = networkAddress,
),
)
Truth.assertThat(actual).containsExactly(expected)
coVerify(ordering = Ordering.SEQUENCE) {
singleNetworkStatusSupplier(params = SingleNetworkStatusProducer.Params(userWalletId, currency.network))
singleQuoteStatusSupplier(params = SingleQuoteStatusProducer.Params(currency.id.rawCurrencyId!!))
}
}
@Test
fun `no subscription to the quote status and yield balance`() = runTest {
// Arrange
val userWallet = mockk<UserWallet> {
every { this@mockk.walletId } returns userWalletId
every { this@mockk.isMultiCurrency } returns false
}
val currency = cryptoCurrencyFactory.ethereum.copy(
id = cryptoCurrencyFactory.ethereum.id.copy(
suffix = CryptoCurrency.ID.Suffix.ContractAddress(contractAddress = "0x12345"),
),
)
val networkStatus = NetworkStatus(
network = currency.network,
value = NetworkStatus.Unreachable(address = networkAddress),
)
val networkStatusFlow = flowOf(networkStatus)
every {
singleNetworkStatusSupplier(params = SingleNetworkStatusProducer.Params(userWalletId, currency.network))
} returns networkStatusFlow
// Act
val actual = factory.create(userWallet = userWallet, currency = currency).let(::getEmittedValues)
// Assert
val expected = CryptoCurrencyStatus(
currency = currency,
value = CryptoCurrencyStatus.Unreachable(
priceChange = null,
fiatRate = null,
networkAddress = networkAddress,
),
)
Truth.assertThat(actual).containsExactly(expected)
coVerify(ordering = Ordering.SEQUENCE) {
singleNetworkStatusSupplier(params = SingleNetworkStatusProducer.Params(userWalletId, currency.network))
}
}
@Test
fun `if stakingId is not supported, yield balance will be null`() = runTest {
// Arrange
val userWallet = mockk<UserWallet> {
every { this@mockk.walletId } returns userWalletId
every { this@mockk.isMultiCurrency } returns true
}
val currency = cryptoCurrencyFactory.ethereum
val networkStatus = NetworkStatus(
network = currency.network,
value = NetworkStatus.Unreachable(address = networkAddress),
)
val networkStatusFlow = flowOf(networkStatus)
every {
singleNetworkStatusSupplier(params = SingleNetworkStatusProducer.Params(userWalletId, currency.network))
} returns networkStatusFlow
val quoteStatus = QuoteStatus(
rawCurrencyId = currency.id.rawCurrencyId!!,
value = QuoteStatus.Data(
source = StatusSource.ACTUAL,
fiatRate = BigDecimal.ONE,
priceChange = BigDecimal.ONE,
),
)
val quoteStatusFlow = flowOf(quoteStatus)
every {
singleQuoteStatusSupplier(params = SingleQuoteStatusProducer.Params(currency.id.rawCurrencyId!!))
} returns quoteStatusFlow
coEvery {
stakingIdFactory.create(currencyId = currency.id, defaultAddress = networkAddress.defaultAddress.value)
} returns StakingIdFactory.Error.UnsupportedCurrency.left()
// Act
val actual = factory.create(userWallet = userWallet, currency = currency).let(::getEmittedValues)
// Assert
val expected = CryptoCurrencyStatus(
currency = currency,
value = CryptoCurrencyStatus.Unreachable(
priceChange = BigDecimal.ONE,
fiatRate = BigDecimal.ONE,
networkAddress = networkAddress,
),
)
Truth.assertThat(actual).containsExactly(expected)
coVerify(ordering = Ordering.SEQUENCE) {
singleNetworkStatusSupplier(params = SingleNetworkStatusProducer.Params(userWalletId, currency.network))
singleQuoteStatusSupplier(params = SingleQuoteStatusProducer.Params(currency.id.rawCurrencyId!!))
}
}
@Test
fun `all sources are empty`() = runTest {
// Arrange
val userWallet = mockk<UserWallet> {
every { this@mockk.walletId } returns userWalletId
every { this@mockk.isMultiCurrency } returns true
}
val currency = cryptoCurrencyFactory.ethereum
every {
singleNetworkStatusSupplier(params = SingleNetworkStatusProducer.Params(userWalletId, currency.network))
} returns emptyFlow()
every {
singleQuoteStatusSupplier(params = SingleQuoteStatusProducer.Params(currency.id.rawCurrencyId!!))
} returns emptyFlow()
// Act
val actual = factory.create(userWallet = userWallet, currency = currency).let(::getEmittedValues)
// Assert
val expected = CryptoCurrencyStatus(currency = currency, value = CryptoCurrencyStatus.Loading)
Truth.assertThat(actual).containsExactly(expected)
coVerify(ordering = Ordering.SEQUENCE) {
singleNetworkStatusSupplier(params = SingleNetworkStatusProducer.Params(userWalletId, currency.network))
singleQuoteStatusSupplier(params = SingleQuoteStatusProducer.Params(currency.id.rawCurrencyId!!))
}
}
}