Updated on 2026-08-14

This commit is contained in:
Tangem 2025-02-17 12:43:00 +04:00
parent 46c19f2a3c
commit 850ecba894
12 changed files with 251 additions and 85 deletions

View file

@ -4,9 +4,12 @@ import android.content.Context
import androidx.datastore.core.DataStoreFactory
import androidx.datastore.dataStoreFile
import com.squareup.moshi.Moshi
import com.tangem.datasource.api.tangemTech.models.QuotesResponse
import com.tangem.datasource.local.datastore.RuntimeSharedStore
import com.tangem.datasource.local.quote.DefaultQuotesStore
import com.tangem.datasource.local.quote.QuotesStore
import com.tangem.datasource.local.quote.utils.QuotesSerializer
import com.tangem.datasource.utils.MoshiDataStoreSerializer
import com.tangem.datasource.utils.mapWithStringKeyTypes
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import dagger.Module
import dagger.Provides
@ -29,11 +32,16 @@ internal object QuotesStoreModule {
dispatchers: CoroutineDispatcherProvider,
): QuotesStore {
return DefaultQuotesStore(
dataStore = DataStoreFactory.create(
serializer = QuotesSerializer(moshi),
persistenceStore = DataStoreFactory.create(
serializer = MoshiDataStoreSerializer(
moshi = moshi,
types = mapWithStringKeyTypes<QuotesResponse.Quote>(),
defaultValue = emptyMap(),
),
produceFile = { context.dataStoreFile(fileName = "quotes") },
scope = CoroutineScope(context = dispatchers.io + SupervisorJob()),
),
runtimeStore = RuntimeSharedStore(),
)
}
}

View file

@ -0,0 +1,68 @@
package com.tangem.datasource.local.datastore
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.firstOrNull
import kotlinx.coroutines.flow.mapNotNull
/**
* Runtime shared store
*
[REDACTED_AUTHOR]
*/
interface RuntimeSharedStore<T> {
/** Get flow of elements [T] */
fun get(): Flow<T>
/** Get element [T] synchronously or null */
suspend fun getSyncOrNull(): T?
/** Get element [T] synchronously or default value */
suspend fun getSyncOrDefault(default: T): T
/** Store [value] */
suspend fun store(value: T)
/**
* Update
*
* @param default default value if store is empty
* @param function update function
*/
suspend fun update(default: T, function: (T) -> T)
companion object {
/**
* Create [RuntimeSharedStore]
*
* @param T type of stored value
*/
operator fun <T> invoke(): RuntimeSharedStore<T> = object : RuntimeSharedStore<T> {
private val flow = MutableSharedFlow<T?>(replay = 1)
init {
flow.tryEmit(value = null)
}
override fun get(): Flow<T> = flow.mapNotNull { it ?: return@mapNotNull null }
override suspend fun getSyncOrNull(): T? = flow.firstOrNull()
override suspend fun getSyncOrDefault(default: T): T = getSyncOrNull() ?: default
override suspend fun store(value: T) {
flow.emit(value = value)
}
override suspend fun update(default: T, function: (T) -> T) {
val storedData = flow.firstOrNull() ?: default
val updatedData = function(storedData)
flow.emit(value = updatedData)
}
}
}
}

View file

@ -2,54 +2,82 @@ package com.tangem.datasource.local.quote
import androidx.datastore.core.DataStore
import com.tangem.datasource.api.tangemTech.models.QuotesResponse
import com.tangem.datasource.local.quote.model.QuoteDM
import com.tangem.datasource.local.quote.model.QuotesDM
import com.tangem.datasource.local.datastore.RuntimeSharedStore
import com.tangem.datasource.local.quote.converter.QuoteConverter
import com.tangem.domain.tokens.model.CryptoCurrency
import com.tangem.domain.tokens.model.Quote
import com.tangem.utils.extensions.orZero
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.firstOrNull
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.launch
internal typealias QuotesByCurrencyId = Map<String, QuotesResponse.Quote>
/**
* Default implementation of [QuotesStore]
*
* @property persistenceStore persistence quotes store
* @property runtimeStore runtime quotes store
*/
internal class DefaultQuotesStore(
private val dataStore: DataStore<QuotesDM>,
private val persistenceStore: DataStore<QuotesByCurrencyId>,
private val runtimeStore: RuntimeSharedStore<Set<Quote>>,
) : QuotesStore {
override fun get(currenciesIds: Set<CryptoCurrency.RawID>): Flow<Set<Quote>> {
return dataStore.data
.map { quotes -> createQuotes(currenciesIds, quotes) }
override fun get(currenciesIds: Set<CryptoCurrency.RawID>): Flow<Set<Quote>> = channelFlow {
val cachedQuotes = getCachedQuotes(currenciesIds = currenciesIds)
if (cachedQuotes.isNotEmpty()) {
send(cachedQuotes)
}
runtimeStore.get()
.onEach {
val mergedQuotes = mergeQuotes(cachedQuotes = cachedQuotes, runtimeQuotes = it)
send(element = mergedQuotes)
}
.launchIn(scope = this)
}
override suspend fun getSync(currenciesIds: Set<CryptoCurrency.RawID>): Set<Quote> {
val quotes = dataStore.data.firstOrNull().orEmpty()
return createQuotes(currenciesIds, quotes)
return runtimeStore.getSyncOrDefault(default = emptySet())
.filter { it.rawCurrencyId in currenciesIds }
.toSet()
}
private fun createQuotes(currenciesIds: Set<CryptoCurrency.RawID>, quoteEntities: Set<QuoteDM>): Set<Quote> =
currenciesIds.mapTo(mutableSetOf()) { id ->
quoteEntities.firstOrNull { it.rawCurrencyId == id }
?.let { quote ->
Quote.Value(
rawCurrencyId = id,
fiatRate = quote.fiatRate,
priceChange = quote.priceChange,
)
}
?: Quote.Empty(id)
}
override suspend fun store(response: QuotesResponse) {
val newQuotes = response.quotes.mapTo(mutableSetOf()) { (currencyId, quote) ->
QuoteDM(
rawCurrencyId = CryptoCurrency.RawID(currencyId),
fiatRate = quote.price.orZero(),
priceChange = quote.priceChange24h.orZero().movePointLeft(2),
)
}
dataStore.updateData { storedQuotes ->
(newQuotes + storedQuotes).distinctBy { it.rawCurrencyId }.toSet()
coroutineScope {
launch { storeInRuntimeStore(response = response) }
launch { storeInPersistenceStore(response = response) }
}
}
private suspend fun getCachedQuotes(currenciesIds: Set<CryptoCurrency.RawID>): Set<Quote.Value> {
val ids = currenciesIds.map(CryptoCurrency.RawID::value).toSet()
val cachedQuotes = persistenceStore.data.firstOrNull().orEmpty().filterKeys { it in ids }
return QuoteConverter(isCached = true).convertSet(input = cachedQuotes.entries)
}
private fun mergeQuotes(cachedQuotes: Set<Quote.Value>, runtimeQuotes: Set<Quote>): Set<Quote> {
return runtimeQuotes.map { runtimeQuote ->
if (runtimeQuote is Quote.Empty) {
cachedQuotes.firstOrNull { runtimeQuote.rawCurrencyId == it.rawCurrencyId } ?: runtimeQuote
} else {
runtimeQuote
}
}
.toSet()
}
private suspend fun storeInRuntimeStore(response: QuotesResponse) {
val new = QuoteConverter(isCached = false).convertSet(input = response.quotes.entries)
runtimeStore.update(default = emptySet()) { saved ->
(saved + new).distinctBy { it.rawCurrencyId }.toSet()
}
}
private suspend fun storeInPersistenceStore(response: QuotesResponse) {
persistenceStore.updateData { storedQuotes -> storedQuotes + response.quotes }
}
}

View file

@ -5,11 +5,15 @@ import com.tangem.domain.tokens.model.CryptoCurrency
import com.tangem.domain.tokens.model.Quote
import kotlinx.coroutines.flow.Flow
/** Quotes store */
interface QuotesStore {
/** Get flow of quotes for [currenciesIds] */
fun get(currenciesIds: Set<CryptoCurrency.RawID>): Flow<Set<Quote>>
/** Get quotes for [currenciesIds] synchronously */
suspend fun getSync(currenciesIds: Set<CryptoCurrency.RawID>): Set<Quote>
/** Store [response] from remote */
suspend fun store(response: QuotesResponse)
}

View file

@ -0,0 +1,29 @@
package com.tangem.datasource.local.quote.converter
import com.tangem.datasource.api.tangemTech.models.QuotesResponse
import com.tangem.domain.tokens.model.CryptoCurrency
import com.tangem.domain.tokens.model.Quote
import com.tangem.utils.converter.Converter
import com.tangem.utils.extensions.orZero
/**
* Converter from [QuotesResponse.Quote] to [Quote.Value]
*
* @property isCached flag that determines whether the quote is a cache
*
[REDACTED_AUTHOR]
*/
internal class QuoteConverter(private val isCached: Boolean) :
Converter<Map.Entry<String, QuotesResponse.Quote>, Quote.Value> {
override fun convert(value: Map.Entry<String, QuotesResponse.Quote>): Quote.Value {
val (currencyId, quote) = value
return Quote.Value(
rawCurrencyId = CryptoCurrency.RawID(currencyId),
fiatRate = quote.price.orZero(),
priceChange = quote.priceChange24h.orZero().movePointLeft(2),
isCached = isCached,
)
}
}

View file

@ -1,14 +0,0 @@
package com.tangem.datasource.local.quote.model
import com.squareup.moshi.JsonClass
import com.tangem.domain.tokens.model.CryptoCurrency
import java.math.BigDecimal
internal typealias QuotesDM = Set<QuoteDM>
@JsonClass(generateAdapter = true)
internal data class QuoteDM(
val rawCurrencyId: CryptoCurrency.RawID,
val fiatRate: BigDecimal,
val priceChange: BigDecimal,
)

View file

@ -1,32 +0,0 @@
package com.tangem.datasource.local.quote.utils
import androidx.datastore.core.Serializer
import com.squareup.moshi.Moshi
import com.squareup.moshi.Types
import com.tangem.datasource.local.quote.model.QuoteDM
import com.tangem.datasource.local.quote.model.QuotesDM
import java.io.InputStream
import java.io.OutputStream
internal class QuotesSerializer(moshi: Moshi) : Serializer<QuotesDM> {
private val adapter by lazy {
val types = Types.newParameterizedType(Set::class.java, QuoteDM::class.java)
moshi.adapter<QuotesDM>(types)
}
override val defaultValue: QuotesDM = emptySet()
override suspend fun readFrom(input: InputStream): QuotesDM {
return input.bufferedReader().use { reader ->
adapter.fromJson(reader.readText()) ?: defaultValue
}
}
override suspend fun writeTo(t: QuotesDM, output: OutputStream) {
output.bufferedWriter().use { write ->
write.write(adapter.toJson(t))
}
}
}

View file

@ -0,0 +1,46 @@
package com.tangem.datasource.utils
import androidx.datastore.core.Serializer
import com.squareup.moshi.JsonAdapter
import com.squareup.moshi.Moshi
import java.io.InputStream
import java.io.OutputStream
import java.lang.reflect.ParameterizedType
/**
* Moshi serializer [JsonAdapter] for [androidx.datastore.core.DataStore]
*
* @property defaultValue default value
* @property adapter moshi adapter
*
[REDACTED_AUTHOR]
*/
class MoshiDataStoreSerializer<T>(
override val defaultValue: T,
private val adapter: JsonAdapter<T>,
) : Serializer<T> {
/**
* Constructor
*
* @param moshi moshi for creating adapter
* @param types types of data
* @param defaultValue default value
*/
constructor(moshi: Moshi, types: ParameterizedType, defaultValue: T) : this(
defaultValue = defaultValue,
adapter = moshi.adapter<T>(types),
)
override suspend fun readFrom(input: InputStream): T {
return input.bufferedReader().use { reader ->
adapter.fromJson(reader.readText()) ?: defaultValue
}
}
override suspend fun writeTo(t: T, output: OutputStream) {
output.bufferedWriter().use { write ->
write.write(adapter.toJson(t))
}
}
}

View file

@ -0,0 +1,16 @@
package com.tangem.datasource.utils
import com.squareup.moshi.Types
import java.lang.reflect.ParameterizedType
inline fun <reified T> mapWithStringKeyTypes(): ParameterizedType {
return Types.newParameterizedType(Map::class.java, String::class.java, T::class.java)
}
inline fun <reified T> listTypes(): ParameterizedType {
return Types.newParameterizedType(List::class.java, T::class.java)
}
inline fun <reified T> setTypes(): ParameterizedType {
return Types.newParameterizedType(Set::class.java, T::class.java)
}