Updated on 2026-08-14

This commit is contained in:
Tangem 2026-05-19 12:54:46 +04:00
commit 6371434106
10 changed files with 121 additions and 23 deletions

@ -1 +1 @@
Subproject commit 498b0bcd0d871ed60c43b5d44f646548a4f11d37
Subproject commit 158fbd8808d2db92ef82d3f9ed92c81340c707c5

View file

@ -11,9 +11,11 @@ import com.tangem.domain.common.wallets.getSyncStrict
import com.tangem.domain.core.flow.FlowProducerTools
import com.tangem.domain.models.account.Account
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.features.tangempay.TangemPayFeatureToggles
import com.tangem.hot.sdk.model.HotWalletId
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import com.tangem.utils.logging.TangemLogger
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
@ -21,6 +23,7 @@ import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onEach
/**
* Default implementation of [SingleAccountListProducer].
@ -43,23 +46,45 @@ internal class DefaultSingleAccountListProducer @AssistedInject constructor(
override val fallback: Option<AccountList> = none()
private val logger = TangemLogger.withTag(TAG)
@OptIn(ExperimentalCoroutinesApi::class)
override fun produce(): Flow<AccountList> {
val walletId = params.userWalletId
logger.i("produce() called for $walletId")
val accountListFlow: Flow<AccountList> = if (tangemPayFeatureToggles.isTangemPayAccountsRefactorEnabled) {
combineWithPaymentAccount()
combineWithPaymentAccount(walletId)
} 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> {
return walletAccountListFlowFactory.create(params.userWalletId)
private fun combineWithPaymentAccount(walletId: UserWalletId): Flow<AccountList> {
return walletAccountListFlowFactory.create(walletId)
.onEach { accountList ->
logger.i(
"produce()[$walletId]: accountList received accounts=${accountList.accounts.size}, " +
"currencies=${accountList.flattenMapCurrencies().size}",
)
}
.map { accountList ->
val userWallet = userWalletsListRepository.getSyncStrict(id = params.userWalletId)
if (userWallet.isPaymentAccountSupported()) {
accountList.plus(Account.Payment(params.userWalletId)).getOrElse { throwable ->
val userWallet = userWalletsListRepository.getSyncStrict(id = walletId)
val isPaymentSupported = userWallet.isPaymentAccountSupported()
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")
}
} else {
@ -77,4 +102,8 @@ internal class DefaultSingleAccountListProducer @AssistedInject constructor(
interface Factory : SingleAccountListProducer.Factory {
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.isMultiCurrency
import com.tangem.domain.models.wallet.requireColdWallet
import com.tangem.utils.logging.TangemLogger
import kotlinx.coroutines.flow.*
import javax.inject.Inject
@ -47,7 +48,15 @@ internal class WalletAccountListFlowFactory @Inject constructor(
return accountsResponseStoreFactory.create(userWallet.walletId).data
.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()
.map { converter.convert(it) }
}

View file

@ -135,10 +135,9 @@ internal class DefaultCardCryptoCurrencyFactory(
userWallet: UserWallet,
networks: Set<Network>,
): Map<Network, List<CryptoCurrency>> {
val response = walletAccountsFetcher.getSaved(userWallet.walletId)
?: return emptyMap()
val response = walletAccountsFetcher.getSaved(userWallet.walletId) ?: return emptyMap()
val existingNetworkWithCurrencies = response.accounts.flatMapTo(hashSetOf()) { accountDTO ->
val currenciesByNetworkId = response.accounts.flatMapTo(hashSetOf()) { accountDTO ->
val accountIndex = DerivationIndex(accountDTO.derivationIndex).getOrNull()
?: return@flatMapTo emptySet()
@ -152,9 +151,11 @@ internal class DefaultCardCryptoCurrencyFactory(
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(

View file

@ -1,6 +1,7 @@
package com.tangem.data.networks.fetcher
import arrow.core.Either
import arrow.core.right
import com.tangem.data.networks.store.NetworksStatusesStore
import com.tangem.data.networks.store.setSourceAsOnlyCache
import com.tangem.data.networks.store.storeStatus
@ -44,6 +45,13 @@ internal class CommonNetworkStatusFetcher @Inject constructor(
network: Network,
networkCurrencies: Set<CryptoCurrency>,
): 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) {
val result = withContext(dispatchers.io) {
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
@ProvideTestModels
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) -> {
Action.UnlockWallet(
selectedWallet = selectedWallet,
unlockedWallets = wallets.filterNot(UserWallet::isLocked),
unlockedWallets = getJustUnlockedWallets(state = state, wallets = wallets),
)
}
isAnyWalletNameChanged(state, wallets) -> {
@ -232,11 +232,28 @@ internal class WalletsUpdateActionResolver @Inject constructor(
private fun isAnyWalletUnlocked(state: WalletScreenState, wallets: List<UserWallet>): Boolean {
return state.wallets.any { walletState ->
val wallet = wallets.firstOrNull { it.walletId == walletState.walletCardState.id } ?: return@any false
!wallet.isLocked &&
(walletState is WalletState.MultiCurrency.Locked || walletState is WalletState.SingleCurrency.Locked)
wallet.isJustUnlockedFrom(walletState)
}
}
/**
* 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 {
if (selectedWallet !is UserWallet.Cold) return false
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.OpenBottomSheetTransformer
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.logging.TangemLogger
import kotlinx.collections.immutable.persistentListOf
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.update
import com.tangem.utils.logging.TangemLogger
import javax.inject.Inject
import javax.inject.Singleton
@ -41,7 +42,14 @@ internal class WalletStateController @Inject constructor(
}
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)
}

View file

@ -67,7 +67,7 @@ internal class UnlockWalletTransformer(
is WalletState.MultiCurrency.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
}
}
@ -79,7 +79,7 @@ internal class UnlockWalletTransformer(
userWallet = unlockedWallet,
)
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
}
}

View file

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