Updated on 2026-08-14

This commit is contained in:
Tangem 2025-04-10 11:51:40 +04:00
parent eef5e02b3e
commit 8d5c9e0e58
11 changed files with 448 additions and 31 deletions

View file

@ -1,6 +1,7 @@
package com.tangem.datasource.di
import android.content.Context
import androidx.datastore.core.DataStore
import androidx.datastore.core.DataStoreFactory
import androidx.datastore.dataStoreFile
import com.squareup.moshi.Moshi
@ -26,21 +27,27 @@ internal object QuotesStoreModule {
@Provides
@Singleton
fun provideQuotesStore(
fun providePersistenceQuotesStore(
@NetworkMoshi moshi: Moshi,
@ApplicationContext context: Context,
dispatchers: CoroutineDispatcherProvider,
): QuotesStore {
return DefaultQuotesStore(
persistenceStore = DataStoreFactory.create(
serializer = MoshiDataStoreSerializer(
moshi = moshi,
types = mapWithStringKeyTypes<QuotesResponse.Quote>(),
defaultValue = emptyMap(),
),
produceFile = { context.dataStoreFile(fileName = "quotes") },
scope = CoroutineScope(context = dispatchers.io + SupervisorJob()),
): DataStore<Map<String, QuotesResponse.Quote>> {
return DataStoreFactory.create(
serializer = MoshiDataStoreSerializer(
moshi = moshi,
types = mapWithStringKeyTypes<QuotesResponse.Quote>(),
defaultValue = emptyMap(),
),
produceFile = { context.dataStoreFile(fileName = "quotes") },
scope = CoroutineScope(context = dispatchers.io + SupervisorJob()),
)
}
@Provides
@Singleton
fun provideQuotesStore(persistenceStore: DataStore<Map<String, QuotesResponse.Quote>>): QuotesStore {
return DefaultQuotesStore(
persistenceStore = persistenceStore,
runtimeStore = RuntimeSharedStore(),
)
}

View file

@ -24,7 +24,10 @@ class JobHolder {
/** Cancel current [job] */
fun cancel() {
job?.cancel()
job = null
}
fun isEmpty() = job == null
}
fun Job.saveIn(jobHolder: JobHolder): Job = jobHolder.update(job = this)

View file

@ -6,9 +6,13 @@ import com.tangem.data.quotes.store.QuotesStoreV2
import com.tangem.data.tokens.utils.QuotesUnsupportedCurrenciesIdAdapter
import com.tangem.datasource.api.tangemTech.TangemTechApi
import com.tangem.datasource.appcurrency.AppCurrencyResponseStore
import com.tangem.domain.core.utils.catchOn
import com.tangem.domain.quotes.multi.MultiQuoteFetcher
import com.tangem.domain.tokens.model.CryptoCurrency
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import timber.log.Timber
import javax.inject.Inject
import javax.inject.Singleton
/**
* Default implementation of [MultiQuoteFetcher]
@ -16,18 +20,26 @@ import timber.log.Timber
* @property tangemTechApi tangemTech api
* @property appCurrencyResponseStore app currency response store
* @property quotesStore quotes store
* @property dispatchers dispatchers
*
[REDACTED_AUTHOR]
*/
internal class DefaultMultiQuoteFetcher(
@Singleton
internal class DefaultMultiQuoteFetcher @Inject constructor(
private val tangemTechApi: TangemTechApi,
private val appCurrencyResponseStore: AppCurrencyResponseStore,
private val quotesStore: QuotesStoreV2,
private val dispatchers: CoroutineDispatcherProvider,
) : MultiQuoteFetcher {
private val quotesUnsupportedCurrenciesAdapter = QuotesUnsupportedCurrenciesIdAdapter()
override suspend fun invoke(params: MultiQuoteFetcher.Params): Either<Throwable, Unit> = Either.catch {
override suspend fun invoke(params: MultiQuoteFetcher.Params) = Either.catchOn(dispatchers.default) {
if (params.currenciesIds.isEmpty()) {
Timber.d("No currencies to fetch quotes for")
return@catchOn
}
quotesStore.refresh(currenciesIds = params.currenciesIds)
val replacementIdsResult = quotesUnsupportedCurrenciesAdapter.replaceUnsupportedCurrencies(
@ -37,26 +49,37 @@ internal class DefaultMultiQuoteFetcher(
),
)
val appCurrency = appCurrencyResponseStore.getSyncOrNull()
?: error(message = "Unable to get AppCurrency for updating quotes")
val appCurrencyId = getAppCurrencyId(params = params)
val coinIds = replacementIdsResult.idsForRequest.joinToString(separator = ",")
safeApiCallWithTimeout(
call = {
val coinIds = replacementIdsResult.idsForRequest.joinToString(separator = ",")
val response = tangemTechApi.getQuotes(currencyId = appCurrency.id, coinIds = coinIds).bind()
val updatedResponse = quotesUnsupportedCurrenciesAdapter.getResponseWithUnsupportedCurrencies(
response = response,
filteredIds = replacementIdsResult.idsFiltered,
)
quotesStore.storeActual(values = updatedResponse.quotes)
},
val response = safeApiCallWithTimeout(
call = { tangemTechApi.getQuotes(currencyId = appCurrencyId, coinIds = coinIds).bind() },
onError = { error -> throw error },
)
val updatedResponse = quotesUnsupportedCurrenciesAdapter.getResponseWithUnsupportedCurrencies(
response = response,
filteredIds = replacementIdsResult.idsFiltered,
)
quotesStore.storeActual(values = updatedResponse.quotes)
}
.onLeft {
Timber.e(it)
quotesStore.storeError(currenciesIds = params.currenciesIds)
}
private suspend fun getAppCurrencyId(params: MultiQuoteFetcher.Params): String {
val appCurrencyId = params.appCurrencyId
?: appCurrencyResponseStore.getSyncOrNull()?.id
if (appCurrencyId.isNullOrBlank()) {
val exception = IllegalStateException("Unable to get AppCurrency for updating quotes")
Timber.e(exception)
throw exception
}
return appCurrencyId
}
}

View file

@ -0,0 +1,86 @@
package com.tangem.data.quotes.multi
import androidx.annotation.VisibleForTesting
import arrow.core.left
import com.tangem.data.quotes.store.QuotesStoreV2
import com.tangem.datasource.appcurrency.AppCurrencyResponseStore
import com.tangem.domain.core.utils.EitherFlow
import com.tangem.domain.quotes.multi.MultiQuoteFetcher
import com.tangem.domain.quotes.multi.MultiQuoteUpdater
import com.tangem.domain.tokens.model.Quote
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import com.tangem.utils.coroutines.JobHolder
import com.tangem.utils.coroutines.saveIn
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.*
import timber.log.Timber
import javax.inject.Inject
import javax.inject.Singleton
/**
* Default implementation of [MultiQuoteUpdater] which updates quotes when the app currency changes
*
* @property appCurrencyResponseStore app currency response store
* @property quotesStore quotes store
* @property multiQuoteFetcher multi quote fetcher
* @param dispatchers dispatchers
*
[REDACTED_AUTHOR]
*/
@Singleton
internal class DefaultMultiQuoteUpdater @Inject constructor(
private val appCurrencyResponseStore: AppCurrencyResponseStore,
private val quotesStore: QuotesStoreV2,
private val multiQuoteFetcher: MultiQuoteFetcher,
dispatchers: CoroutineDispatcherProvider,
) : MultiQuoteUpdater {
private val coroutineScope = CoroutineScope(SupervisorJob() + dispatchers.default)
private val updaterHolder = JobHolder()
override fun subscribe() {
Timber.d("Subscribe on quotes updates")
getMultiQuoteUpdates()
.launchIn(coroutineScope)
.saveIn(updaterHolder)
}
override fun unsubscribe() {
Timber.e("Unsubscribe from quotes updates")
updaterHolder.cancel()
}
@OptIn(ExperimentalCoroutinesApi::class)
private fun getMultiQuoteUpdates(): EitherFlow<Throwable, Unit> {
return appCurrencyResponseStore.get()
.drop(count = 1) // skip initial value
.distinctUntilChanged()
.filterNotNull()
.mapLatest { appCurrency ->
val currenciesIds = quotesStore.getAllSyncOrNull().orEmpty()
.mapTo(destination = hashSetOf(), transform = Quote::rawCurrencyId)
multiQuoteFetcher(
params = MultiQuoteFetcher.Params(currenciesIds = currenciesIds, appCurrencyId = appCurrency.id),
)
.onLeft(Timber::e)
}
.retryWhen { cause, _ ->
Timber.e("Retry updating quotes: $cause")
emit(cause.left())
delay(timeMillis = 2000)
true
}
}
@VisibleForTesting(VisibleForTesting.NONE)
fun getMultiQuoteUpdatesFlow(): EitherFlow<Throwable, Unit> = getMultiQuoteUpdates()
@VisibleForTesting(VisibleForTesting.NONE)
fun getUpdaterJobHolder(): JobHolder = updaterHolder
}

View file

@ -47,6 +47,8 @@ internal class DefaultQuotesStoreV2(
override fun get(): Flow<Set<Quote>> = runtimeStore.get()
override suspend fun getAllSyncOrNull(): Set<Quote>? = runtimeStore.getSyncOrNull()
override suspend fun refresh(currenciesIds: Set<CryptoCurrency.RawID>) {
updateStatusSourceInRuntime(currenciesIds = currenciesIds, source = StatusSource.CACHE)
}

View file

@ -11,6 +11,9 @@ internal interface QuotesStoreV2 {
/** Get flow of quotes */
fun get(): Flow<Set<Quote>>
/** Get all quotes synchronously or null */
suspend fun getAllSyncOrNull(): Set<Quote>?
/** Refresh status of [currenciesIds] */
suspend fun refresh(currenciesIds: Set<CryptoCurrency.RawID>)

View file

@ -11,6 +11,7 @@ import com.tangem.datasource.api.tangemTech.models.QuotesResponse
import com.tangem.datasource.appcurrency.AppCurrencyResponseStore
import com.tangem.domain.quotes.multi.MultiQuoteFetcher
import com.tangem.domain.tokens.model.CryptoCurrency
import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider
import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.coVerifyOrder
@ -32,11 +33,12 @@ internal class DefaultMultiQuoteFetcherTest {
tangemTechApi = tangemTechApi,
appCurrencyResponseStore = appCurrencyResponseStore,
quotesStore = quotesStore,
dispatchers = TestingCoroutineDispatcherProvider(),
)
@Test
fun `fetch quotes successfully`() = runTest {
val params = MultiQuoteFetcher.Params(currenciesIds = currenciesIds)
val params = MultiQuoteFetcher.Params(currenciesIds = currenciesIds, appCurrencyId = null)
coEvery { appCurrencyResponseStore.getSyncOrNull() } returns usdAppCurrency
@ -64,9 +66,78 @@ internal class DefaultMultiQuoteFetcherTest {
Truth.assertThat(actual.isRight()).isTrue()
}
@Test
fun `fetch quotes successfully if currenciesIds from params is empty`() = runTest {
val params = MultiQuoteFetcher.Params(currenciesIds = emptySet(), appCurrencyId = null)
val actual = fetcher(params)
coVerify(inverse = true) {
quotesStore.refresh(currenciesIds = any())
appCurrencyResponseStore.getSyncOrNull()
tangemTechApi.getQuotes(currencyId = any(), coinIds = any())
quotesStore.storeActual(values = any())
quotesStore.storeError(currenciesIds = any())
}
Truth.assertThat(actual.isRight()).isTrue()
}
@Test
fun `fetch quotes successfully if appCurrencyId from params is not null`() = runTest {
val appCurrencyId = "usd"
val params = MultiQuoteFetcher.Params(currenciesIds = currenciesIds, appCurrencyId = appCurrencyId)
val coinIds = "BTC,ETH"
coEvery {
tangemTechApi.getQuotes(currencyId = appCurrencyId, coinIds = coinIds)
} returns ApiResponse.Success(successResponse)
val actual = fetcher(params)
coVerifyOrder {
quotesStore.refresh(currenciesIds = params.currenciesIds)
tangemTechApi.getQuotes(currencyId = "usd", coinIds = coinIds)
quotesStore.storeActual(values = successResponse.quotes)
}
coVerify(inverse = true) {
appCurrencyResponseStore.getSyncOrNull()
quotesStore.storeError(currenciesIds = any())
}
Truth.assertThat(actual.isRight()).isTrue()
}
@Test
fun `fetch quotes failure because appCurrencyId from params is blank`() = runTest {
val appCurrencyId = ""
val params = MultiQuoteFetcher.Params(currenciesIds = currenciesIds, appCurrencyId = appCurrencyId)
val actual = fetcher(params)
coVerifyOrder {
quotesStore.refresh(currenciesIds = params.currenciesIds)
quotesStore.storeError(currenciesIds = params.currenciesIds)
}
coVerify(inverse = true) {
appCurrencyResponseStore.getSyncOrNull()
tangemTechApi.getQuotes(currencyId = any(), coinIds = any())
quotesStore.storeActual(values = any())
}
Truth.assertThat(actual.isLeft()).isTrue()
Truth.assertThat(actual.leftOrNull()).isInstanceOf(IllegalStateException::class.java)
Truth.assertThat(actual.leftOrNull()).hasMessageThat()
.isEqualTo("Unable to get AppCurrency for updating quotes")
}
@Test
fun `fetch quotes failure because api request failed`() = runTest {
val params = MultiQuoteFetcher.Params(currenciesIds = currenciesIds)
val params = MultiQuoteFetcher.Params(currenciesIds = currenciesIds, appCurrencyId = null)
coEvery { appCurrencyResponseStore.getSyncOrNull() } returns usdAppCurrency
@ -97,7 +168,7 @@ internal class DefaultMultiQuoteFetcherTest {
@Test
fun `fetch quotes failure because app currency not found`() = runTest {
val params = MultiQuoteFetcher.Params(currenciesIds = currenciesIds)
val params = MultiQuoteFetcher.Params(currenciesIds = currenciesIds, appCurrencyId = null)
coEvery { appCurrencyResponseStore.getSyncOrNull() } returns null

View file

@ -0,0 +1,169 @@
package com.tangem.data.quotes.multi
import arrow.core.right
import com.google.common.truth.Truth
import com.tangem.common.test.utils.getEmittedValues
import com.tangem.data.quotes.store.QuotesStoreV2
import com.tangem.datasource.api.tangemTech.models.CurrenciesResponse
import com.tangem.datasource.appcurrency.AppCurrencyResponseStore
import com.tangem.domain.quotes.multi.MultiQuoteFetcher
import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider
import io.mockk.*
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.test.runTest
import org.junit.Test
/**
[REDACTED_AUTHOR]
*/
internal class DefaultMultiQuoteUpdaterTest {
private val appCurrencyResponseStore: AppCurrencyResponseStore = mockk()
private val quotesStore: QuotesStoreV2 = mockk()
private val multiQuoteFetcher: MultiQuoteFetcher = mockk()
private val multiQuoteUpdater = DefaultMultiQuoteUpdater(
appCurrencyResponseStore = appCurrencyResponseStore,
quotesStore = quotesStore,
multiQuoteFetcher = multiQuoteFetcher,
dispatchers = TestingCoroutineDispatcherProvider(),
)
@Test
fun `test that initial app currency is skipped`() = runTest {
val appCurrencyFlow = flowOf(null, usdAppCurrency)
every { appCurrencyResponseStore.get() } returns appCurrencyFlow
coEvery { quotesStore.getAllSyncOrNull() } returns emptySet()
val params = MultiQuoteFetcher.Params(currenciesIds = emptySet(), appCurrencyId = usdAppCurrency.id)
coEvery { multiQuoteFetcher(params) } returns Unit.right()
val actual = multiQuoteUpdater.getMultiQuoteUpdatesFlow()
coVerify(exactly = 1) { appCurrencyResponseStore.get() }
val values = getEmittedValues(actual)
Truth.assertThat(values).isEqualTo(listOf(Unit.right()))
coVerifyOrder {
quotesStore.getAllSyncOrNull()
multiQuoteFetcher(params)
}
}
@Test
fun `test that flow is filtered the same status`() = runTest {
val appCurrencyFlow = flowOf(usdAppCurrency, usdAppCurrency)
every { appCurrencyResponseStore.get() } returns appCurrencyFlow
coEvery { quotesStore.getAllSyncOrNull() } returns emptySet()
val params = MultiQuoteFetcher.Params(currenciesIds = emptySet(), appCurrencyId = usdAppCurrency.id)
coEvery { multiQuoteFetcher(params) } returns Unit.right()
val actual = multiQuoteUpdater.getMultiQuoteUpdatesFlow()
coVerify(exactly = 1) { appCurrencyResponseStore.get() }
val values = getEmittedValues(actual)
Truth.assertThat(values).isEqualTo(listOf(Unit.right()))
coVerifyOrder {
quotesStore.getAllSyncOrNull()
multiQuoteFetcher(params)
}
}
@Test
fun `test that flow is filtered the null`() = runTest {
val appCurrencyFlow = flowOf(null, null)
every { appCurrencyResponseStore.get() } returns appCurrencyFlow
val actual = multiQuoteUpdater.getMultiQuoteUpdatesFlow()
coVerify(exactly = 1) { appCurrencyResponseStore.get() }
val values = getEmittedValues(actual)
Truth.assertThat(values.size).isEqualTo(0)
coVerify(inverse = true) {
quotesStore.getAllSyncOrNull()
multiQuoteFetcher(any())
}
}
@Test
fun `test if flow throws exception`() = runTest {
val exception = IllegalStateException()
val innerFlow = MutableStateFlow(value = false)
val appCurrencyFlow = flow {
if (innerFlow.value) {
emitAll(flowOf(null, usdAppCurrency))
} else {
throw exception
}
}
.buffer(capacity = 5)
every { appCurrencyResponseStore.get() } returns appCurrencyFlow
coEvery { quotesStore.getAllSyncOrNull() } returns emptySet()
val params = MultiQuoteFetcher.Params(currenciesIds = emptySet(), appCurrencyId = usdAppCurrency.id)
coEvery { multiQuoteFetcher(params) } returns Unit.right()
val actual = multiQuoteUpdater.getMultiQuoteUpdatesFlow()
coVerify(exactly = 1) { appCurrencyResponseStore.get() }
val values1 = getEmittedValues(actual)
Truth.assertThat(values1.size).isEqualTo(1)
Truth.assertThat(values1.first().isLeft()).isTrue()
Truth.assertThat(values1.first().leftOrNull()).isInstanceOf(exception::class.java)
Truth.assertThat(values1.first().leftOrNull()).hasMessageThat().isEqualTo(exception.message)
coVerify(inverse = true) {
quotesStore.getAllSyncOrNull()
multiQuoteFetcher(any())
}
innerFlow.emit(value = true)
val values2 = getEmittedValues(flow = actual)
Truth.assertThat(values2).isEqualTo(listOf(Unit.right()))
coVerifyOrder {
quotesStore.getAllSyncOrNull()
multiQuoteFetcher(params)
}
}
@Test
fun `subscribe and unsubscribe successfully`() {
every { appCurrencyResponseStore.get() } returns emptyFlow()
val updaterJobHolder = multiQuoteUpdater.getUpdaterJobHolder()
Truth.assertThat(updaterJobHolder.isEmpty()).isTrue()
multiQuoteUpdater.subscribe()
Truth.assertThat(updaterJobHolder.isEmpty()).isFalse()
multiQuoteUpdater.unsubscribe()
Truth.assertThat(updaterJobHolder.isEmpty()).isTrue()
}
private companion object {
val usdAppCurrency = CurrenciesResponse.Currency(
id = "USD".lowercase(),
code = "USD",
name = "US Dollar",
unit = "$",
type = "fiat",
rateBTC = "",
)
}
}

View file

@ -60,4 +60,33 @@ internal class QuotesStoreGetMethodTest {
Truth.assertThat(values.size).isEqualTo(1)
Truth.assertThat(values).isEqualTo(listOf(setOf(btcQuote.toDomain(), ethQuote.toDomain())))
}
@Test
fun `test getAllSyncOrNull if runtime store is empty`() = runTest {
val actual = store.getAllSyncOrNull()
Truth.assertThat(actual).isEqualTo(null)
}
@Test
fun `test getAllSyncOrNull if runtime store contains empty set`() = runTest {
runtimeStore.store(value = emptySet())
val actual = store.getAllSyncOrNull()
Truth.assertThat(actual).isEqualTo(emptySet<Quote>())
}
@Test
fun `test getAllSyncOrNull if runtime store is not empty`() = runTest {
val btcQuote = "BTC" to MockQuoteResponseFactory.createSinglePrice(BigDecimal.ZERO)
val ethQuote = "ETH" to MockQuoteResponseFactory.createSinglePrice(BigDecimal.ONE)
runtimeStore.store(value = setOf(btcQuote.toDomain(), ethQuote.toDomain()))
val actual = store.getAllSyncOrNull()
val expected = setOf(btcQuote.toDomain(), ethQuote.toDomain())
Truth.assertThat(actual).isEqualTo(expected)
}
}

View file

@ -10,5 +10,14 @@ import com.tangem.domain.tokens.model.CryptoCurrency
*/
interface MultiQuoteFetcher : FlowFetcher<MultiQuoteFetcher.Params> {
data class Params(val currenciesIds: Set<CryptoCurrency.RawID>)
/**
* Params
*
* @property currenciesIds identifiers of currencies
* @property appCurrencyId app currency id, if null then selected app currency will be used
*/
data class Params(
val currenciesIds: Set<CryptoCurrency.RawID>,
val appCurrencyId: String?,
)
}

View file

@ -0,0 +1,15 @@
package com.tangem.domain.quotes.multi
/**
* Updater of quotes
*
[REDACTED_AUTHOR]
*/
interface MultiQuoteUpdater {
/** Subscribe */
fun subscribe()
/** Unsubscribe */
fun unsubscribe()
}