Updated on 2026-08-14

This commit is contained in:
Tangem 2025-04-08 20:09:32 +04:00
parent 60ff94372b
commit 13a9dba8ab
14 changed files with 206 additions and 24 deletions

View file

@ -3,6 +3,7 @@ package com.tangem.data.networks.multi
import arrow.core.Either
import arrow.core.raise.either
import arrow.core.raise.ensure
import com.tangem.data.networks.store.NetworksStatusesStoreV2
import com.tangem.domain.networks.multi.MultiNetworkStatusFetcher
import com.tangem.domain.networks.single.SingleNetworkStatusFetcher
import kotlinx.coroutines.async
@ -14,14 +15,21 @@ import javax.inject.Inject
* Default implementation of [MultiNetworkStatusFetcher]
*
* @property singleNetworkStatusFetcher single network status fetcher
* @property networksStatusesStore networks statuses store
*
[REDACTED_AUTHOR]
*/
internal class DefaultMultiNetworkStatusFetcher @Inject constructor(
private val singleNetworkStatusFetcher: SingleNetworkStatusFetcher,
private val networksStatusesStore: NetworksStatusesStoreV2,
) : MultiNetworkStatusFetcher {
override suspend fun invoke(params: MultiNetworkStatusFetcher.Params): Either<Throwable, Unit> = either {
// Optimization!
// Every singleNetworkStatusFetcher with applyRefresh as true will refresh every network in the store.
// So if we update all networks at once, it will be more efficient.
networksStatusesStore.refresh(userWalletId = params.userWalletId, networks = params.networks)
val result = coroutineScope {
params.networks
.map {
@ -30,6 +38,7 @@ internal class DefaultMultiNetworkStatusFetcher @Inject constructor(
params = SingleNetworkStatusFetcher.Params(
userWalletId = params.userWalletId,
network = it,
applyRefresh = false,
),
)
}

View file

@ -6,13 +6,11 @@ import com.tangem.data.networks.store.NetworksStatusesStoreV2
import com.tangem.datasource.local.userwallet.UserWalletsStore
import com.tangem.domain.networks.multi.MultiNetworkStatusProducer
import com.tangem.domain.tokens.model.NetworkStatus
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.mapNotNull
import kotlinx.coroutines.flow.onEmpty
import kotlinx.coroutines.flow.*
import timber.log.Timber
/**
@ -22,12 +20,14 @@ import timber.log.Timber
* @property networksStatusesStore networks statuses store
* @property userWalletsStore user wallets store
* @property excludedBlockchains excluded blockchains
* @property dispatchers dispatchers
*/
internal class DefaultMultiNetworkStatusProducer @AssistedInject constructor(
@Assisted val params: MultiNetworkStatusProducer.Params,
private val networksStatusesStore: NetworksStatusesStoreV2,
private val userWalletsStore: UserWalletsStore,
private val excludedBlockchains: ExcludedBlockchains,
private val dispatchers: CoroutineDispatcherProvider,
) : MultiNetworkStatusProducer {
override val fallback: Set<NetworkStatus>
@ -57,6 +57,7 @@ internal class DefaultMultiNetworkStatusProducer @AssistedInject constructor(
}
.distinctUntilChanged()
.onEmpty { emit(value = hashSetOf()) }
.flowOn(dispatchers.default)
}
@AssistedFactory

View file

@ -14,6 +14,7 @@ import com.tangem.datasource.local.preferences.PreferencesKeys
import com.tangem.datasource.local.preferences.utils.getObjectSyncOrNull
import com.tangem.datasource.local.userwallet.UserWalletsStore
import com.tangem.domain.common.util.cardTypesResolver
import com.tangem.domain.core.utils.catchOn
import com.tangem.domain.demo.DemoConfig
import com.tangem.domain.networks.single.SingleNetworkStatusFetcher
import com.tangem.domain.tokens.model.CryptoCurrency
@ -51,8 +52,10 @@ internal class DefaultSingleNetworkStatusFetcher @Inject constructor(
private val responseCurrenciesFactory = ResponseCryptoCurrenciesFactory(excludedBlockchains)
private val networkStatusFactory = NetworkStatusFactory()
override suspend fun invoke(params: SingleNetworkStatusFetcher.Params): Either<Throwable, Unit> = Either.catch {
networksStatusesStore.refresh(userWalletId = params.userWalletId, network = params.network)
override suspend fun invoke(params: SingleNetworkStatusFetcher.Params) = Either.catchOn(dispatchers.default) {
if (params.applyRefresh) {
networksStatusesStore.refresh(userWalletId = params.userWalletId, network = params.network)
}
val userWallet = userWalletsStore.getSyncStrict(key = params.userWalletId)
val networkCurrencies = createCurrencies(userWallet = userWallet, network = params.network)

View file

@ -4,11 +4,13 @@ import com.tangem.domain.networks.multi.MultiNetworkStatusProducer
import com.tangem.domain.networks.multi.MultiNetworkStatusSupplier
import com.tangem.domain.networks.single.SingleNetworkStatusProducer
import com.tangem.domain.tokens.model.NetworkStatus
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.mapNotNull
/**
@ -16,12 +18,14 @@ import kotlinx.coroutines.flow.mapNotNull
*
* @property params params
* @property multiNetworkStatusSupplier multi network status supplier
* @property dispatchers dispatchers
*
[REDACTED_AUTHOR]
*/
internal class DefaultSingleNetworkStatusProducer @AssistedInject constructor(
@Assisted val params: SingleNetworkStatusProducer.Params,
private val multiNetworkStatusSupplier: MultiNetworkStatusSupplier,
private val dispatchers: CoroutineDispatcherProvider,
) : SingleNetworkStatusProducer {
override val fallback: NetworkStatus
@ -35,6 +39,7 @@ internal class DefaultSingleNetworkStatusProducer @AssistedInject constructor(
statuses.firstOrNull { it.network == params.network }
}
.distinctUntilChanged()
.flowOn(dispatchers.default)
}
@AssistedFactory

View file

@ -58,6 +58,29 @@ internal class DefaultNetworksStatusesStoreV2(
updateStatusSourceInRuntime(userWalletId = userWalletId, network = network, source = StatusSource.CACHE)
}
override suspend fun refresh(userWalletId: UserWalletId, networks: Set<Network>) {
val simpleStatusIds = networks.map(SimpleNetworkStatus::Id)
runtimeStore.update(default = emptyMap()) { stored ->
stored.toMutableMap().apply {
val storedStatuses = this[userWalletId.stringValue].orEmpty()
val statuses = simpleStatusIds.mapTo(hashSetOf()) { simpleStatusId ->
val status = storedStatuses.firstOrNull { it.id == simpleStatusId }
?: createDefaultStatus(id = simpleStatusId)
status.copy(
value = status.value.copySealed(source = StatusSource.CACHE),
)
}
val updatedStatuses = storedStatuses.addOrReplace(statuses) { old, new -> old.id == new.id }
put(key = userWalletId.stringValue, value = updatedStatuses)
}
}
}
override suspend fun storeActual(userWalletId: UserWalletId, value: NetworkStatus) {
if (value.value.source != StatusSource.ACTUAL) {
error("Method storeActual can be called only with StatusSource.ACTUAL")
@ -84,10 +107,7 @@ internal class DefaultNetworksStatusesStoreV2(
stored.toMutableMap().apply {
val status = this[userWalletId.stringValue].orEmpty()
.firstOrNull { it.id == simpleStatusId }
?: SimpleNetworkStatus(
id = simpleStatusId,
value = NetworkStatus.Unreachable(address = null),
)
?: createDefaultStatus(id = simpleStatusId)
val updatedStatus = status.copy(
value = status.value.copySealed(source = source),
@ -130,4 +150,8 @@ internal class DefaultNetworksStatusesStoreV2(
}
}
}
private fun createDefaultStatus(id: SimpleNetworkStatus.Id): SimpleNetworkStatus {
return SimpleNetworkStatus(id = id, value = NetworkStatus.Unreachable(address = null))
}
}

View file

@ -15,6 +15,9 @@ internal interface NetworksStatusesStoreV2 {
/** Refresh status of [network] by [userWalletId] */
suspend fun refresh(userWalletId: UserWalletId, network: Network)
/** Refresh statuses of [networks] by [userWalletId] */
suspend fun refresh(userWalletId: UserWalletId, networks: Set<Network>)
/** Store actual [NetworkStatus] by [userWalletId] */
suspend fun storeActual(userWalletId: UserWalletId, value: NetworkStatus)

View file

@ -4,6 +4,7 @@ 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.data.networks.store.NetworksStatusesStoreV2
import com.tangem.domain.networks.multi.MultiNetworkStatusFetcher
import com.tangem.domain.networks.single.SingleNetworkStatusFetcher
import com.tangem.domain.wallets.models.UserWalletId
@ -19,8 +20,12 @@ import org.junit.Test
internal class DefaultMultiNetworkStatusFetcherTest {
private val singleNetworkStatusFetcher: SingleNetworkStatusFetcher = mockk()
private val networksStatusesStore: NetworksStatusesStoreV2 = mockk(relaxed = true)
private val fetcher = DefaultMultiNetworkStatusFetcher(singleNetworkStatusFetcher = singleNetworkStatusFetcher)
private val fetcher = DefaultMultiNetworkStatusFetcher(
singleNetworkStatusFetcher = singleNetworkStatusFetcher,
networksStatusesStore = networksStatusesStore,
)
@Test
fun `fetch networks statuses successfully`() = runTest {
@ -29,11 +34,13 @@ internal class DefaultMultiNetworkStatusFetcherTest {
val ethParams = SingleNetworkStatusFetcher.Params(
userWalletId = userWalletId,
network = ethereumAndStellar.first(),
applyRefresh = false,
)
val stellarParams = SingleNetworkStatusFetcher.Params(
userWalletId = userWalletId,
network = ethereumAndStellar.last(),
applyRefresh = false,
)
coEvery { singleNetworkStatusFetcher(ethParams) } returns Unit.right()
@ -42,6 +49,7 @@ internal class DefaultMultiNetworkStatusFetcherTest {
val actual = fetcher(params)
coVerify {
networksStatusesStore.refresh(userWalletId = userWalletId, networks = ethereumAndStellar)
singleNetworkStatusFetcher(ethParams)
singleNetworkStatusFetcher(stellarParams)
}
@ -56,11 +64,13 @@ internal class DefaultMultiNetworkStatusFetcherTest {
val ethParams = SingleNetworkStatusFetcher.Params(
userWalletId = userWalletId,
network = ethereumAndStellar.first(),
applyRefresh = false,
)
val stellarParams = SingleNetworkStatusFetcher.Params(
userWalletId = userWalletId,
network = ethereumAndStellar.last(),
applyRefresh = false,
)
val ethException = IllegalStateException("eth")
@ -70,6 +80,7 @@ internal class DefaultMultiNetworkStatusFetcherTest {
val actual = fetcher(params)
coVerify {
networksStatusesStore.refresh(userWalletId = userWalletId, networks = ethereumAndStellar)
singleNetworkStatusFetcher(ethParams)
singleNetworkStatusFetcher(stellarParams)
}
@ -88,11 +99,13 @@ internal class DefaultMultiNetworkStatusFetcherTest {
val ethParams = SingleNetworkStatusFetcher.Params(
userWalletId = userWalletId,
network = ethereumAndStellar.first(),
applyRefresh = false,
)
val stellarParams = SingleNetworkStatusFetcher.Params(
userWalletId = userWalletId,
network = ethereumAndStellar.last(),
applyRefresh = false,
)
coEvery { singleNetworkStatusFetcher(ethParams) } returns IllegalStateException("eth").left()
@ -101,6 +114,7 @@ internal class DefaultMultiNetworkStatusFetcherTest {
val actual = fetcher(params)
coVerify {
networksStatusesStore.refresh(userWalletId = userWalletId, networks = ethereumAndStellar)
singleNetworkStatusFetcher(ethParams)
singleNetworkStatusFetcher(stellarParams)
}

View file

@ -14,6 +14,7 @@ import com.tangem.datasource.local.userwallet.UserWalletsStore
import com.tangem.domain.common.configs.GenericCardConfig
import com.tangem.domain.networks.multi.MultiNetworkStatusProducer
import com.tangem.domain.tokens.model.NetworkStatus
import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider
import io.mockk.every
import io.mockk.mockk
import io.mockk.verify
@ -32,12 +33,14 @@ internal class DefaultMultiNetworkStatusProducerTest {
private val networksStatusesStore = mockk<NetworksStatusesStoreV2>()
private val userWalletsStore = mockk<UserWalletsStore>()
private val excludedBlockchains = mockk<ExcludedBlockchains>()
private val dispatchers = TestingCoroutineDispatcherProvider()
private val producer = DefaultMultiNetworkStatusProducer(
params = params,
networksStatusesStore = networksStatusesStore,
userWalletsStore = userWalletsStore,
excludedBlockchains = excludedBlockchains,
dispatchers = dispatchers,
)
@Before

View file

@ -10,9 +10,9 @@ import com.tangem.domain.walletmanager.WalletManagersFacade
import com.tangem.domain.walletmanager.model.UpdateWalletManagerResult
import com.tangem.domain.wallets.models.UserWalletId
import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider
import io.mockk.Ordering
import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.coVerifyOrder
import io.mockk.mockk
import kotlinx.coroutines.test.runTest
import org.junit.Test
@ -37,14 +37,18 @@ internal class DefaultSingleNetworkStatusFetcherTest {
@Test
fun `fetch network status successfully`() = runTest {
val params = SingleNetworkStatusFetcher.Params(userWalletId = userWalletId, network = network)
val params = SingleNetworkStatusFetcher.Params(
userWalletId = userWalletId,
network = network,
applyRefresh = true,
)
val result = UpdateWalletManagerResult.MissedDerivation
coEvery { walletManagersFacade.update(userWalletId, network, emptySet()) } returns result
val actual = fetcher(params)
coVerify(ordering = Ordering.SEQUENCE) {
coVerifyOrder {
networksStatusesStore.refresh(userWalletId = userWalletId, network = network)
userWalletsStore.getSyncStrict(key = userWalletId)
walletManagersFacade.update(userWalletId, network, emptySet())
@ -59,14 +63,18 @@ internal class DefaultSingleNetworkStatusFetcherTest {
@Test
fun `fetch network status failure`() = runTest {
val params = SingleNetworkStatusFetcher.Params(userWalletId = userWalletId, network = network)
val params = SingleNetworkStatusFetcher.Params(
userWalletId = userWalletId,
network = network,
applyRefresh = true,
)
val exception = IllegalStateException()
coEvery { userWalletsStore.getSyncStrict(key = userWalletId) } throws exception
val actual = fetcher(params)
coVerify(ordering = Ordering.SEQUENCE) {
coVerifyOrder {
networksStatusesStore.refresh(userWalletId = userWalletId, network = network)
userWalletsStore.getSyncStrict(key = userWalletId)
networksStatusesStore.storeError(userWalletId = userWalletId, network = network)
@ -81,6 +89,35 @@ internal class DefaultSingleNetworkStatusFetcherTest {
Truth.assertThat(actual.leftOrNull()).isEqualTo(exception)
}
@Test
fun `fetch network status if applyRefresh is false`() = runTest {
val params = SingleNetworkStatusFetcher.Params(
userWalletId = userWalletId,
network = network,
applyRefresh = false,
)
val result = UpdateWalletManagerResult.MissedDerivation
coEvery { walletManagersFacade.update(userWalletId, network, emptySet()) } returns result
val actual = fetcher(params)
coVerifyOrder {
userWalletsStore.getSyncStrict(key = userWalletId)
walletManagersFacade.update(userWalletId, network, emptySet())
networksStatusesStore.storeActual(
userWalletId = userWalletId,
value = NetworkStatus(network, NetworkStatus.MissedDerivation),
)
}
coVerify(inverse = true) {
networksStatusesStore.refresh(userWalletId = any(), network = any())
}
Truth.assertThat(actual.isRight()).isTrue()
}
private companion object {
val userWalletId = UserWalletId("011")
val network = MockCryptoCurrencyFactory().ethereum.network

View file

@ -9,6 +9,7 @@ import com.tangem.domain.networks.multi.MultiNetworkStatusSupplier
import com.tangem.domain.networks.single.SingleNetworkStatusProducer
import com.tangem.domain.tokens.model.NetworkStatus
import com.tangem.domain.wallets.models.UserWalletId
import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider
import io.mockk.every
import io.mockk.mockk
import io.mockk.verify
@ -27,10 +28,12 @@ internal class DefaultSingleNetworkStatusProducerTest {
)
private val multiNetworkStatusSupplier = mockk<MultiNetworkStatusSupplier>()
private val dispatchers = TestingCoroutineDispatcherProvider()
private val producer = DefaultSingleNetworkStatusProducer(
params = params,
multiNetworkStatusSupplier = multiNetworkStatusSupplier,
dispatchers = dispatchers,
)
@Test

View file

@ -32,7 +32,7 @@ internal class NetworkStatusesStoreUpdateMethodsTest {
)
@Test
fun `refresh if runtime store is empty`() = runTest {
fun `refresh the single network if runtime store is empty`() = runTest {
store.refresh(userWalletId = userWalletId, network = network)
val runtimeExpected = mapOf(
@ -49,7 +49,7 @@ internal class NetworkStatusesStoreUpdateMethodsTest {
}
@Test
fun `refresh if runtime store contains status with this network`() = runTest {
fun `refresh the single network if runtime store contains status with this network`() = runTest {
val status = MockNetworkStatusFactory.createVerified().toSimple()
runtimeStore.store(
@ -68,6 +68,45 @@ internal class NetworkStatusesStoreUpdateMethodsTest {
Truth.assertThat(persistenceStore.data.firstOrNull()).isEqualTo(emptyMap<String, Set<NetworkStatusDM>>())
}
@Test
fun `refresh the multi networks if runtime store is empty`() = runTest {
store.refresh(userWalletId = userWalletId, networks = networks)
val runtimeExpected = mapOf(
userWalletId.stringValue to networks.mapTo(hashSetOf()) {
SimpleNetworkStatus(
id = SimpleNetworkStatus.Id(it),
value = NetworkStatus.Unreachable(address = null),
)
},
)
Truth.assertThat(runtimeStore.getSyncOrNull()).isEqualTo(runtimeExpected)
Truth.assertThat(persistenceStore.data.firstOrNull()).isEqualTo(emptyMap<String, Set<NetworkStatusDM>>())
}
@Test
fun `refresh the multi networks if runtime store contains status with this network`() = runTest {
val firstStatus = MockNetworkStatusFactory.createVerified(network = networks.first()).toSimple()
val secondStatus = MockNetworkStatusFactory.createVerified(network = networks.last()).toSimple()
runtimeStore.store(
value = mapOf(userWalletId.stringValue to setOf(firstStatus, secondStatus)),
)
store.refresh(userWalletId = userWalletId, networks = networks)
val runtimeExpected = mapOf(
userWalletId.stringValue to setOf(
firstStatus.copy(value = firstStatus.value.copySealed(source = StatusSource.CACHE)),
secondStatus.copy(value = secondStatus.value.copySealed(source = StatusSource.CACHE)),
),
)
Truth.assertThat(runtimeStore.getSyncOrNull()).isEqualTo(runtimeExpected)
Truth.assertThat(persistenceStore.data.firstOrNull()).isEqualTo(emptyMap<String, Set<NetworkStatusDM>>())
}
@Test
fun `store actual with any status sources`() = runTest {
val expectedErrorMessage = "Method storeActual can be called only with StatusSource.ACTUAL"
@ -210,5 +249,7 @@ internal class NetworkStatusesStoreUpdateMethodsTest {
val userWalletId = UserWalletId(stringValue = "011")
val network = MockCryptoCurrencyFactory().ethereum.network
val networks = MockCryptoCurrencyFactory().ethereumAndStellar.mapTo(hashSetOf()) { it.network }
}
}

View file

@ -2,7 +2,9 @@ package com.tangem.domain.core.utils
import arrow.core.Either
import com.tangem.domain.core.lce.Lce
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.withContext
/**
* [Flow] of [Either]
@ -34,4 +36,15 @@ inline fun <reified E : Any, reified T : Any> Either<E, T>.toLce(isStillLoading:
}
}
}
}
@JvmName("tryCatchWithDispatch")
suspend inline fun <R> Either.Companion.catchOn(
dispatcher: CoroutineDispatcher,
crossinline function: suspend () -> R,
): Either<Throwable, R> {
return withContext(
context = dispatcher,
block = { catch { function() } },
)
}

View file

@ -11,5 +11,16 @@ import com.tangem.domain.wallets.models.UserWalletId
*/
interface SingleNetworkStatusFetcher : FlowFetcher<SingleNetworkStatusFetcher.Params> {
data class Params(val userWalletId: UserWalletId, val network: Network)
/**
* Params
*
* @property userWalletId wallet id
* @property network network
* @property applyRefresh flag that determines whether to apply refresh (see DefaultMultiNetworkStatusFetcher)
*/
data class Params(
val userWalletId: UserWalletId,
val network: Network,
val applyRefresh: Boolean = true,
)
}

View file

@ -68,13 +68,12 @@ class CachedCurrenciesStatusesOperations(
val prevStatuses = MutableStateFlow(value = emptyList<CryptoCurrencyStatus>())
val isUpdating = MutableStateFlow(value = true)
val isFetchingStarted = MutableStateFlow(value = false)
val nonEmptyCurrencies = currenciesFlow.mapNotNull { it.getOrNull() }.firstOrNull()?.toNonEmptyListOrNull()
if (nonEmptyCurrencies != null) {
if (!isFetchingStarted(userWalletId) && nonEmptyCurrencies != null) {
launch {
isFetchingStarted.value = true
setFetchStarted(userWalletId)
val (networks, currenciesIds) = getIds(nonEmptyCurrencies)
fetchComponents(userWalletId, networks, currenciesIds, nonEmptyCurrencies)
@ -132,9 +131,9 @@ class CachedCurrenciesStatusesOperations(
)
}
if (!isFetchingStarted.value) {
if (!isFetchingStarted(userWalletId)) {
launch {
isFetchingStarted.value = true
setFetchStarted(userWalletId)
fetchComponents(userWalletId, networks, currenciesIds, currencies)
}
@ -331,6 +330,7 @@ class CachedCurrenciesStatusesOperations(
}
// temporary code because token list is built using networks list
@OptIn(FlowPreview::class)
private fun getNetworkStatusesUpdates(
userWalletId: UserWalletId,
networks: NonEmptySet<Network>,
@ -356,11 +356,26 @@ class CachedCurrenciesStatusesOperations(
.onEach(::send)
.launchIn(scope = this)
}
.debounce(timeoutMillis = 500)
.map<Set<NetworkStatus>, Either<TokenListError, Set<NetworkStatus>>> { it.right() }
.distinctUntilChanged()
}
private fun isFetchingStarted(userWalletId: UserWalletId): Boolean {
return fetchingState.value[userWalletId] ?: false
}
private fun setFetchStarted(userWalletId: UserWalletId) {
fetchingState.update {
it.toMutableMap().apply {
put(key = userWalletId, value = true)
}
}
}
companion object {
internal const val RETRY_DELAY = 2000L
private val fetchingState = MutableStateFlow(value = emptyMap<UserWalletId, Boolean>())
}
}