Updated on 2026-08-14
This commit is contained in:
parent
46c19f2a3c
commit
850ecba894
12 changed files with 251 additions and 85 deletions
|
|
@ -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(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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 }
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
)
|
||||
|
|
@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue