Updated on 2026-08-14

This commit is contained in:
Tangem 2026-05-18 18:18:29 +04:00
parent 097e9c1bce
commit 2437fa39d8
9 changed files with 120 additions and 22 deletions

View file

@ -11,9 +11,11 @@ import com.tangem.domain.common.wallets.getSyncStrict
import com.tangem.domain.core.flow.FlowProducerTools import com.tangem.domain.core.flow.FlowProducerTools
import com.tangem.domain.models.account.Account import com.tangem.domain.models.account.Account
import com.tangem.domain.models.wallet.UserWallet import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.features.tangempay.TangemPayFeatureToggles import com.tangem.features.tangempay.TangemPayFeatureToggles
import com.tangem.hot.sdk.model.HotWalletId import com.tangem.hot.sdk.model.HotWalletId
import com.tangem.utils.coroutines.CoroutineDispatcherProvider import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import com.tangem.utils.logging.TangemLogger
import dagger.assisted.Assisted import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject import dagger.assisted.AssistedInject
@ -21,6 +23,7 @@ import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onEach
/** /**
* Default implementation of [SingleAccountListProducer]. * Default implementation of [SingleAccountListProducer].
@ -43,23 +46,45 @@ internal class DefaultSingleAccountListProducer @AssistedInject constructor(
override val fallback: Option<AccountList> = none() override val fallback: Option<AccountList> = none()
private val logger = TangemLogger.withTag(TAG)
@OptIn(ExperimentalCoroutinesApi::class) @OptIn(ExperimentalCoroutinesApi::class)
override fun produce(): Flow<AccountList> { override fun produce(): Flow<AccountList> {
val walletId = params.userWalletId
logger.i("produce() called for $walletId")
val accountListFlow: Flow<AccountList> = if (tangemPayFeatureToggles.isTangemPayAccountsRefactorEnabled) { val accountListFlow: Flow<AccountList> = if (tangemPayFeatureToggles.isTangemPayAccountsRefactorEnabled) {
combineWithPaymentAccount() combineWithPaymentAccount(walletId)
} else { } else {
walletAccountListFlowFactory.create(userWalletId = params.userWalletId) walletAccountListFlowFactory.create(userWalletId = walletId)
} }
return accountListFlow.flowOn(dispatchers.default) return accountListFlow
.onEach { accountList ->
logger.i(
"produce()[$walletId] emit: accounts=${accountList.accounts.size}, " +
"currencies=${accountList.flattenMapCurrencies().size}",
)
}
.flowOn(dispatchers.default)
} }
private fun combineWithPaymentAccount(): Flow<AccountList> { private fun combineWithPaymentAccount(walletId: UserWalletId): Flow<AccountList> {
return walletAccountListFlowFactory.create(params.userWalletId) return walletAccountListFlowFactory.create(walletId)
.onEach { accountList ->
logger.i(
"produce()[$walletId]: accountList received accounts=${accountList.accounts.size}, " +
"currencies=${accountList.flattenMapCurrencies().size}",
)
}
.map { accountList -> .map { accountList ->
val userWallet = userWalletsListRepository.getSyncStrict(id = params.userWalletId) val userWallet = userWalletsListRepository.getSyncStrict(id = walletId)
if (userWallet.isPaymentAccountSupported()) { val isPaymentSupported = userWallet.isPaymentAccountSupported()
accountList.plus(Account.Payment(params.userWalletId)).getOrElse { throwable -> logger.i(
"produce()[$walletId]: userWallet resolved (type=${userWallet::class.simpleName}), " +
"isPaymentAccountSupported=$isPaymentSupported",
)
if (isPaymentSupported) {
accountList.plus(Account.Payment(walletId)).getOrElse { throwable ->
error("Can not combine account list and payment account status: $throwable") error("Can not combine account list and payment account status: $throwable")
} }
} else { } else {
@ -77,4 +102,8 @@ internal class DefaultSingleAccountListProducer @AssistedInject constructor(
interface Factory : SingleAccountListProducer.Factory { interface Factory : SingleAccountListProducer.Factory {
override fun create(params: SingleAccountListProducer.Params): DefaultSingleAccountListProducer override fun create(params: SingleAccountListProducer.Params): DefaultSingleAccountListProducer
} }
private companion object {
const val TAG = "SingleAccountListProducer"
}
} }

View file

@ -12,6 +12,7 @@ import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.models.wallet.UserWalletId import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.models.wallet.isMultiCurrency import com.tangem.domain.models.wallet.isMultiCurrency
import com.tangem.domain.models.wallet.requireColdWallet import com.tangem.domain.models.wallet.requireColdWallet
import com.tangem.utils.logging.TangemLogger
import kotlinx.coroutines.flow.* import kotlinx.coroutines.flow.*
import javax.inject.Inject import javax.inject.Inject
@ -47,7 +48,15 @@ internal class WalletAccountListFlowFactory @Inject constructor(
return accountsResponseStoreFactory.create(userWallet.walletId).data return accountsResponseStoreFactory.create(userWallet.walletId).data
.filterNotNull() .filterNotNull()
.filter { it.accounts.isNotEmpty() } .filter { response ->
val hasAccounts = response.accounts.isNotEmpty()
if (!hasAccounts) {
TangemLogger.e("Account list is empty for ${userWallet.walletId}")
}
hasAccounts
}
.distinctUntilChanged() .distinctUntilChanged()
.map { converter.convert(it) } .map { converter.convert(it) }
} }

View file

@ -135,10 +135,9 @@ internal class DefaultCardCryptoCurrencyFactory(
userWallet: UserWallet, userWallet: UserWallet,
networks: Set<Network>, networks: Set<Network>,
): Map<Network, List<CryptoCurrency>> { ): Map<Network, List<CryptoCurrency>> {
val response = walletAccountsFetcher.getSaved(userWallet.walletId) val response = walletAccountsFetcher.getSaved(userWallet.walletId) ?: return emptyMap()
?: return emptyMap()
val existingNetworkWithCurrencies = response.accounts.flatMapTo(hashSetOf()) { accountDTO -> val currenciesByNetworkId = response.accounts.flatMapTo(hashSetOf()) { accountDTO ->
val accountIndex = DerivationIndex(accountDTO.derivationIndex).getOrNull() val accountIndex = DerivationIndex(accountDTO.derivationIndex).getOrNull()
?: return@flatMapTo emptySet() ?: return@flatMapTo emptySet()
@ -152,9 +151,11 @@ internal class DefaultCardCryptoCurrencyFactory(
accountIndex = accountIndex, accountIndex = accountIndex,
) )
} }
.groupBy(CryptoCurrency::network) .groupBy { it.network.id }
return networks.associateWith { emptyList<CryptoCurrency>() } + existingNetworkWithCurrencies return networks.associateWith { network ->
currenciesByNetworkId[network.id].orEmpty()
}
} }
private suspend fun getMultiWalletCurrenciesByRawId( private suspend fun getMultiWalletCurrenciesByRawId(

View file

@ -1,6 +1,7 @@
package com.tangem.data.networks.fetcher package com.tangem.data.networks.fetcher
import arrow.core.Either import arrow.core.Either
import arrow.core.right
import com.tangem.data.networks.store.NetworksStatusesStore import com.tangem.data.networks.store.NetworksStatusesStore
import com.tangem.data.networks.store.setSourceAsOnlyCache import com.tangem.data.networks.store.setSourceAsOnlyCache
import com.tangem.data.networks.store.storeStatus import com.tangem.data.networks.store.storeStatus
@ -44,6 +45,13 @@ internal class CommonNetworkStatusFetcher @Inject constructor(
network: Network, network: Network,
networkCurrencies: Set<CryptoCurrency>, networkCurrencies: Set<CryptoCurrency>,
): Either<Throwable, Unit> { ): Either<Throwable, Unit> {
// Guard: empty networkCurrencies would result in NetworkStatus.Verified(amounts=emptyMap()),
// which overwrites any valid cached status and leaves all currencies in this network as Loading.
if (networkCurrencies.isEmpty()) {
TangemLogger.w("Skipping fetch for $userWalletId [${network.rawId}]: networkCurrencies is empty")
return Unit.right()
}
return Either.catchOn(dispatchers.default) { return Either.catchOn(dispatchers.default) {
val result = withContext(dispatchers.io) { val result = withContext(dispatchers.io) {
walletManagersFacade.update( walletManagersFacade.update(

View file

@ -73,6 +73,32 @@ internal class CommonNetworkStatusFetcherTest {
} }
} }
@Test
fun `fetch skips wallet manager update when networkCurrencies is empty`() = runTest {
// Arrange
val userWalletId = UserWalletId("011")
val network = cryptoCurrencyFactory.ethereum.network
// Act
val actual = fetcher.fetch(
userWalletId = userWalletId,
network = network,
networkCurrencies = emptySet(),
)
// Assert: returns success without touching the wallet manager facade — guard prevents storing
// a NetworkStatus.Verified with empty `amounts` over an existing valid cached status.
Truth.assertThat(actual).isEqualTo(Either.Right(Unit))
coVerify(inverse = true) {
walletManagersFacade.update(
userWalletId = any(),
network = any(),
extraTokens = any(),
)
}
}
@ParameterizedTest @ParameterizedTest
@ProvideTestModels @ProvideTestModels
fun `fetch successfully for any result of walletManagersFacade`(model: SuccessTestModel) = runTest { fun `fetch successfully for any result of walletManagersFacade`(model: SuccessTestModel) = runTest {

View file

@ -68,7 +68,7 @@ internal class WalletsUpdateActionResolver @Inject constructor(
isAnyWalletUnlocked(state, wallets) -> { isAnyWalletUnlocked(state, wallets) -> {
Action.UnlockWallet( Action.UnlockWallet(
selectedWallet = selectedWallet, selectedWallet = selectedWallet,
unlockedWallets = wallets.filterNot(UserWallet::isLocked), unlockedWallets = getJustUnlockedWallets(state = state, wallets = wallets),
) )
} }
isAnyWalletNameChanged(state, wallets) -> { isAnyWalletNameChanged(state, wallets) -> {
@ -232,11 +232,28 @@ internal class WalletsUpdateActionResolver @Inject constructor(
private fun isAnyWalletUnlocked(state: WalletScreenState, wallets: List<UserWallet>): Boolean { private fun isAnyWalletUnlocked(state: WalletScreenState, wallets: List<UserWallet>): Boolean {
return state.wallets.any { walletState -> return state.wallets.any { walletState ->
val wallet = wallets.firstOrNull { it.walletId == walletState.walletCardState.id } ?: return@any false val wallet = wallets.firstOrNull { it.walletId == walletState.walletCardState.id } ?: return@any false
!wallet.isLocked && wallet.isJustUnlockedFrom(walletState)
(walletState is WalletState.MultiCurrency.Locked || walletState is WalletState.SingleCurrency.Locked)
} }
} }
/**
* Wallets that transitioned from a locked UI state to unlocked data state in this update cycle.
* Does not include wallets that were already unlocked before otherwise downstream handlers
* (e.g. [com.tangem.feature.wallet.child.wallet.model.WalletModel.unlockWallet]) would re-fetch
* already-loaded wallets and emit redundant transformer state changes.
*/
private fun getJustUnlockedWallets(state: WalletScreenState, wallets: List<UserWallet>): List<UserWallet> {
return state.wallets.mapNotNull { walletState ->
val wallet = wallets.firstOrNull { it.walletId == walletState.walletCardState.id }
wallet?.takeIf { it.isJustUnlockedFrom(walletState) }
}
}
private fun UserWallet.isJustUnlockedFrom(walletState: WalletState): Boolean {
return !isLocked &&
(walletState is WalletState.MultiCurrency.Locked || walletState is WalletState.SingleCurrency.Locked)
}
private fun isSelectedWalletCardsCountChanged(state: WalletScreenState, selectedWallet: UserWallet): Boolean { private fun isSelectedWalletCardsCountChanged(state: WalletScreenState, selectedWallet: UserWallet): Boolean {
if (selectedWallet !is UserWallet.Cold) return false if (selectedWallet !is UserWallet.Cold) return false
val prevSelectedWallet = state.getPrevSelectedWallet() val prevSelectedWallet = state.getPrevSelectedWallet()

View file

@ -8,12 +8,13 @@ import com.tangem.feature.wallet.presentation.wallet.state.model.*
import com.tangem.feature.wallet.presentation.wallet.state.transformers.CloseBottomSheetTransformer import com.tangem.feature.wallet.presentation.wallet.state.transformers.CloseBottomSheetTransformer
import com.tangem.feature.wallet.presentation.wallet.state.transformers.OpenBottomSheetTransformer import com.tangem.feature.wallet.presentation.wallet.state.transformers.OpenBottomSheetTransformer
import com.tangem.feature.wallet.presentation.wallet.state.transformers.WalletScreenStateTransformer import com.tangem.feature.wallet.presentation.wallet.state.transformers.WalletScreenStateTransformer
import com.tangem.feature.wallet.presentation.wallet.state.transformers.WalletStateTransformer
import com.tangem.utils.extensions.indexOfFirstOrNull import com.tangem.utils.extensions.indexOfFirstOrNull
import com.tangem.utils.logging.TangemLogger
import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.persistentListOf
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
import com.tangem.utils.logging.TangemLogger
import javax.inject.Inject import javax.inject.Inject
import javax.inject.Singleton import javax.inject.Singleton
@ -41,7 +42,14 @@ internal class WalletStateController @Inject constructor(
} }
fun update(transformer: WalletScreenStateTransformer) { fun update(transformer: WalletScreenStateTransformer) {
TangemLogger.d("Applying: ${transformer::class.simpleName}") val walletStateTransformer = transformer as? WalletStateTransformer
val maybeWalletId = if (walletStateTransformer != null) {
" for ${walletStateTransformer.userWalletId}"
} else {
""
}
TangemLogger.d("Applying: ${transformer::class.simpleName}$maybeWalletId")
mutableUiState.update(function = transformer::transform) mutableUiState.update(function = transformer::transform)
} }

View file

@ -67,7 +67,7 @@ internal class UnlockWalletTransformer(
is WalletState.MultiCurrency.Content, is WalletState.MultiCurrency.Content,
is WalletState.SingleCurrency.Content, is WalletState.SingleCurrency.Content,
-> { -> {
TangemLogger.e("Impossible to unlock wallet with not locked state") TangemLogger.e("createLoadingState: Impossible to unlock wallet with not locked state")
prevState prevState
} }
} }
@ -79,7 +79,7 @@ internal class UnlockWalletTransformer(
userWallet = unlockedWallet, userWallet = unlockedWallet,
) )
is WalletUM.Content -> { is WalletUM.Content -> {
TangemLogger.e("Impossible to unlock wallet with not locked state") TangemLogger.e("createLoadingState2: Impossible to unlock wallet with not locked state")
walletUM walletUM
} }
} }

View file

@ -7,7 +7,7 @@ import com.tangem.feature.wallet.presentation.wallet.state.model.WalletUM
import kotlinx.collections.immutable.toImmutableList import kotlinx.collections.immutable.toImmutableList
internal abstract class WalletStateTransformer( internal abstract class WalletStateTransformer(
protected val userWalletId: UserWalletId, internal val userWalletId: UserWalletId,
) : WalletScreenStateTransformer { ) : WalletScreenStateTransformer {
abstract fun transform(prevState: WalletState): WalletState abstract fun transform(prevState: WalletState): WalletState