Updated on 2026-08-14

This commit is contained in:
Tangem 2026-06-24 17:32:48 +04:00
parent e7b7c086bb
commit ef8a13cfaa
14 changed files with 267 additions and 46 deletions

View file

@ -31,6 +31,8 @@ dependencies {
implementation(projects.domain.express.models)
implementation(projects.domain.wallets.models)
implementation(projects.domain.wallets)
implementation(projects.domain.onramp)
implementation(projects.domain.onramp.models)
implementation(projects.domain.account)
implementation(projects.domain.account.status)

View file

@ -9,6 +9,7 @@ import com.tangem.domain.express.ExpressRepository
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.onramp.repositories.OnrampRepository
import com.tangem.domain.txhistory.fetcher.AppTxHistoryFetcher
import com.tangem.domain.txhistory.fetcher.TxHistoryFetchTrigger
import com.tangem.domain.txhistory.fetcher.WalletTxHistoryFetcher
@ -22,6 +23,7 @@ import javax.inject.Inject
internal class DefaultAppTxHistoryFetcher @Inject constructor(
private val utils: TxHistoryFetcherUtils,
private val expressRepository: ExpressRepository,
private val onrampRepository: OnrampRepository,
private val getWalletsUseCase: GetWalletsUseCase,
private val selectedWalletUseCase: GetSelectedWalletUseCase,
private val walletTxHistoryFetcherFactory: DefaultWalletTxHistoryFetcher.Factory,
@ -30,9 +32,6 @@ internal class DefaultAppTxHistoryFetcher @Inject constructor(
@VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
internal val fetchers = ConcurrentHashMap<UserWalletId, WalletTxHistoryFetcher>()
/** Wallets whose express providers were already loaded — to load them at most once per wallet. */
private val providersLoadedWallets = mutableSetOf<UserWalletId>()
init {
defaultLaunchIn(buildFlow())
}
@ -53,11 +52,14 @@ internal class DefaultAppTxHistoryFetcher @Inject constructor(
.stateIn(this)
walletsFlow.value.keys.createForNewWallets()
walletsFlow.value.values.firstOrNull()?.let { wallet ->
loadExpressProviders(wallet)
loadOnrampCountries(wallet)
}
selectedWalletUseCase.selectedFlow()
.filter { wallet -> wallet.isMultiCurrency }
// todo txhistory some init trigger?
.onEach { wallet -> loadExpressProviders(wallet) }
.launchIn(this)
walletsFlow
@ -79,13 +81,17 @@ internal class DefaultAppTxHistoryFetcher @Inject constructor(
}
private fun ProducerScope<*>.loadExpressProviders(wallet: UserWallet) {
// Load once per wallet: `add` returns false if this walletId was already loaded.
if (!providersLoadedWallets.add(wallet.walletId)) return
flow { emit(expressRepository.getProviders(userWallet = wallet, filterProviderTypes = emptyList())) }
.retryThreeTimes()
.launchIn(this)
}
private fun ProducerScope<*>.loadOnrampCountries(wallet: UserWallet) {
flow { emit(onrampRepository.fetchCountries(userWallet = wallet)) }
.retryThreeTimes()
.launchIn(this)
}
private fun Flow<Set<UserWalletId>>.createForNewWallets() = onEach { ids -> ids.createForNewWallets() }
private fun Set<UserWalletId>.createForNewWallets() = this.forEach { walletId -> getOrPutFetcher(walletId) }

View file

@ -5,6 +5,7 @@ import com.tangem.data.common.converter.ExpressProviderConverter
import com.tangem.data.txhistory.repository.converter.ExpressStatusMapper
import com.tangem.data.txhistory.repository.converter.ExpressOnrampConverter
import com.tangem.data.txhistory.repository.converter.ExpressSwapConverter
import com.tangem.data.txhistory.repository.converter.OnrampCountryConverter
import com.tangem.data.txhistory.repository.factory.ExpressTransactionAssetFactory
import com.tangem.data.txhistory.repository.factory.toAssetId
import com.tangem.data.txhistory.repository.paging.TxHistoryPageBatchFetcher
@ -13,6 +14,7 @@ import com.tangem.datasource.local.txhistory.db.dao.ExpressHistoryDao
import com.tangem.datasource.local.txhistory.db.entity.express.ExpressExchangeEntity
import com.tangem.datasource.local.txhistory.db.entity.express.ExpressOnrampEntity
import com.tangem.datasource.local.txhistory.db.entity.express.ExpressProviderEntity
import com.tangem.datasource.local.txhistory.db.entity.express.OnrampCountryEntity
import com.tangem.domain.express.models.ExpressAsset
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.network.TxInfo
@ -50,6 +52,7 @@ internal class RefactoredTxHistoryRepository @Inject constructor(
private val expressProviderConverter = ExpressProviderConverter()
private val swapConverter = ExpressSwapConverter()
private val onrampConverter = ExpressOnrampConverter()
private val onrampCountryConverter = OnrampCountryConverter()
private val TxHistoryListConfig.storeKey get() = TxHistoryItemsStore.Key(userWalletId, currency)
override fun getExpressHistory(
@ -90,35 +93,46 @@ internal class RefactoredTxHistoryRepository @Inject constructor(
activeStatuses = ExpressStatusMapper.activeOnrampStatuses,
).distinctUntilChanged(),
flow4 = expressHistoryDao.getProvidersById().distinctUntilChanged(),
transform = { outgoingSwaps, incomingSwaps, onramps, providers ->
flow5 = expressHistoryDao.getCountriesByCode().distinctUntilChanged(),
transform = { outgoingSwaps, incomingSwaps, onramps, providers, countries ->
buildExpressHistory(
userWalletId = userWalletId,
outgoingSwaps = outgoingSwaps,
incomingSwaps = incomingSwaps,
onramps = onramps,
providers = providers,
sources = ExpressHistorySources(
outgoingSwaps = outgoingSwaps,
incomingSwaps = incomingSwaps,
onramps = onramps,
providers = providers,
countries = countries,
),
)
},
)
emitAll(flow)
}.flowOn(dispatchers.io)
/** The reactive express-history inputs gathered from the DB in a single [combine] tick. */
private data class ExpressHistorySources(
val outgoingSwaps: List<ExpressExchangeEntity>,
val incomingSwaps: List<ExpressExchangeEntity>,
val onramps: List<ExpressOnrampEntity>,
val providers: Map<String, ExpressProviderEntity>,
val countries: Map<String, OnrampCountryEntity>,
)
private suspend fun buildExpressHistory(
userWalletId: UserWalletId,
outgoingSwaps: List<ExpressExchangeEntity>,
incomingSwaps: List<ExpressExchangeEntity>,
onramps: List<ExpressOnrampEntity>,
providers: Map<String, ExpressProviderEntity>,
sources: ExpressHistorySources,
): List<ExpressTx> {
val currencies = expressTransactionAssetFactory.create(
userWalletId = userWalletId,
outgoingSwaps = outgoingSwaps,
incomingSwaps = incomingSwaps,
onramps = onramps,
outgoingSwaps = sources.outgoingSwaps,
incomingSwaps = sources.incomingSwaps,
onramps = sources.onramps,
)
fun String.expressProvider() = providers[this]?.let(expressProviderConverter::convert)
fun String.expressProvider() = sources.providers[this]?.let(expressProviderConverter::convert)
fun String.onrampCountry() = sources.countries[this]?.let(onrampCountryConverter::convert)
return buildList {
outgoingSwaps.forEach { entity ->
sources.outgoingSwaps.forEach { entity ->
val input = ExpressSwapConverter.Input(
entity = entity,
provider = entity.providerId.expressProvider(),
@ -128,7 +142,7 @@ internal class RefactoredTxHistoryRepository @Inject constructor(
)
add(swapConverter.convert(input))
}
incomingSwaps.forEach { entity ->
sources.incomingSwaps.forEach { entity ->
val input = ExpressSwapConverter.Input(
entity = entity,
provider = entity.providerId.expressProvider(),
@ -138,11 +152,12 @@ internal class RefactoredTxHistoryRepository @Inject constructor(
)
add(swapConverter.convert(input))
}
onramps.forEach { entity ->
sources.onramps.forEach { entity ->
val input = ExpressOnrampConverter.Input(
entity = entity,
provider = entity.providerId.expressProvider(),
toCurrency = currencies[entity.to.toAssetId()],
country = entity.countryCode.onrampCountry(),
)
add(onrampConverter.convert(input))
}

View file

@ -10,6 +10,7 @@ import com.tangem.domain.express.models.ExpressProvider
import com.tangem.domain.express.models.ExpressTransactionAsset
import com.tangem.domain.express.models.OnrampTransaction
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.onramp.model.OnrampCountry
import com.tangem.domain.tokens.model.Amount
import com.tangem.domain.tokens.model.AmountType
import com.tangem.domain.txhistory.model.ExpressTx
@ -64,6 +65,7 @@ internal class ExpressOnrampConverter : Converter<ExpressOnrampConverter.Input,
decimals = entity.to.decimals,
cryptoCurrency = value.toCurrency,
),
country = value.country,
),
txInfo = null,
)
@ -73,6 +75,7 @@ internal class ExpressOnrampConverter : Converter<ExpressOnrampConverter.Input,
val entity: ExpressOnrampEntity,
val provider: ExpressProvider?,
val toCurrency: CryptoCurrency? = null,
val country: OnrampCountry? = null,
)
}

View file

@ -0,0 +1,29 @@
package com.tangem.data.txhistory.repository.converter
import com.tangem.datasource.local.txhistory.db.entity.express.OnrampCountryEntity
import com.tangem.domain.onramp.model.OnrampCountry
import com.tangem.domain.onramp.model.OnrampCurrency
import com.tangem.utils.converter.Converter
/** Maps a persisted [OnrampCountryEntity] into the domain [OnrampCountry]. */
internal class OnrampCountryConverter : Converter<OnrampCountryEntity, OnrampCountry> {
override fun convert(value: OnrampCountryEntity): OnrampCountry {
return OnrampCountry(
id = "${value.alpha3}-${value.name}",
name = value.name,
code = value.code,
image = value.image,
alpha3 = value.alpha3,
continent = value.continent,
defaultCurrency = OnrampCurrency(
name = value.defaultCurrency.name,
code = value.defaultCurrency.code,
image = value.defaultCurrency.image,
precision = value.defaultCurrency.precision,
unit = value.defaultCurrency.unit,
),
onrampAvailable = value.isOnrampAvailable,
)
}
}

View file

@ -7,6 +7,7 @@ import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.express.ExpressRepository
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.onramp.repositories.OnrampRepository
import com.tangem.domain.txhistory.fetcher.TxHistoryFetchTrigger
import com.tangem.domain.wallets.usecase.GetSelectedWalletUseCase
import com.tangem.domain.wallets.usecase.GetWalletsUseCase
@ -14,7 +15,6 @@ import io.mockk.*
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.emptyFlow
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.job
import kotlinx.coroutines.test.*
import org.junit.jupiter.api.BeforeEach
@ -29,14 +29,16 @@ internal class DefaultAppTxHistoryFetcherTest {
private val selectedWalletUseCase: GetSelectedWalletUseCase = mockk()
private val walletFetcherFactory: DefaultWalletTxHistoryFetcher.Factory = mockk()
private val expressRepository: ExpressRepository = mockk()
private val onrampRepository: OnrampRepository = mockk()
private val currency: CryptoCurrency = MockCryptoCurrencyFactory().ethereum
@BeforeEach
fun setup() {
clearMocks(getWalletsUseCase, selectedWalletUseCase, walletFetcherFactory, expressRepository)
clearMocks(getWalletsUseCase, selectedWalletUseCase, walletFetcherFactory, expressRepository, onrampRepository)
every { selectedWalletUseCase.selectedFlow() } returns emptyFlow()
coEvery { expressRepository.getProviders(any(), any()) } returns emptyList()
coEvery { onrampRepository.fetchCountries(any()) } returns emptyList()
}
@Test
@ -147,15 +149,16 @@ internal class DefaultAppTxHistoryFetcherTest {
}
@Test
fun `loads express providers when selected wallet is multi-currency`() = runTest {
fun `loads express providers and onramp countries for the first wallet on init`() = runTest {
// Arrange
val utils = createUtils()
every { getWalletsUseCase.invokeAsMap(any(), any()) } returns MutableStateFlow(linkedMapOf())
val wallet = mockk<UserWallet.Cold>(relaxed = true) {
every { isMultiCurrency } returns true
every { walletId } returns WALLET_ID_1
}
every { selectedWalletUseCase.selectedFlow() } returns flowOf(wallet)
every { getWalletsUseCase.invokeAsMap(any(), any()) } returns
MutableStateFlow(linkedMapOf(WALLET_ID_1 to wallet))
every { walletFetcherFactory.create(WALLET_ID_1) } returns relaxedWalletFetcher()
// Act
createFetcher(utils)
@ -163,48 +166,40 @@ internal class DefaultAppTxHistoryFetcherTest {
// Assert
coVerify(exactly = 1) { expressRepository.getProviders(wallet, emptyList()) }
coVerify(exactly = 1) { onrampRepository.fetchCountries(wallet) }
}
@Test
fun `loads express providers only once per wallet`() = runTest {
fun `does not load express data when there are no wallets`() = runTest {
// Arrange
val utils = createUtils()
every { getWalletsUseCase.invokeAsMap(any(), any()) } returns MutableStateFlow(linkedMapOf())
val wallet = mockk<UserWallet.Cold>(relaxed = true) {
every { isMultiCurrency } returns true
every { walletId } returns WALLET_ID_1
}
// Same wallet selected several times.
every { selectedWalletUseCase.selectedFlow() } returns flowOf(wallet, wallet, wallet)
// Act
createFetcher(utils)
advanceUntilIdle()
// Assert
coVerify(exactly = 1) { expressRepository.getProviders(wallet, emptyList()) }
coVerify(inverse = true) { expressRepository.getProviders(any(), any()) }
coVerify(inverse = true) { onrampRepository.fetchCountries(any()) }
}
@Test
fun `provider loading failure does not break the wallet pipeline`() = runTest {
// Arrange
val utils = createUtils()
val walletsFlow = MutableStateFlow(linkedMapOf<UserWalletId, UserWallet>())
every { getWalletsUseCase.invokeAsMap(any(), any()) } returns walletsFlow
val wallet = mockk<UserWallet.Cold>(relaxed = true) {
every { isMultiCurrency } returns true
every { walletId } returns WALLET_ID_1
}
every { selectedWalletUseCase.selectedFlow() } returns flowOf(wallet)
every { getWalletsUseCase.invokeAsMap(any(), any()) } returns
MutableStateFlow(linkedMapOf(WALLET_ID_1 to wallet))
coEvery { expressRepository.getProviders(any(), any()) } throws RuntimeException("boom")
val walletFetcher1 = relaxedWalletFetcher()
every { walletFetcherFactory.create(WALLET_ID_1) } returns walletFetcher1
val fetcher = createFetcher(utils)
advanceUntilIdle()
// Act — the provider error is swallowed, so the wallet pipeline must keep working.
walletsFlow.value = linkedMapOf(WALLET_ID_1 to mockk())
val fetcher = createFetcher(utils)
advanceUntilIdle()
// Assert
@ -220,6 +215,7 @@ internal class DefaultAppTxHistoryFetcherTest {
private fun createFetcher(utils: DefaultTxHistoryFetcherUtils) = DefaultAppTxHistoryFetcher(
utils = utils,
expressRepository = expressRepository,
onrampRepository = onrampRepository,
getWalletsUseCase = getWalletsUseCase,
selectedWalletUseCase = selectedWalletUseCase,
walletTxHistoryFetcherFactory = walletFetcherFactory,