diff --git a/core/datasource/src/main/java/com/tangem/datasource/di/QuotesStoreModule.kt b/core/datasource/src/main/java/com/tangem/datasource/di/QuotesStoreModule.kt index 7d45e11efc..dbcf34e870 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/di/QuotesStoreModule.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/di/QuotesStoreModule.kt @@ -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(), + defaultValue = emptyMap(), + ), produceFile = { context.dataStoreFile(fileName = "quotes") }, scope = CoroutineScope(context = dispatchers.io + SupervisorJob()), ), + runtimeStore = RuntimeSharedStore(), ) } } \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/local/datastore/RuntimeSharedStore.kt b/core/datasource/src/main/java/com/tangem/datasource/local/datastore/RuntimeSharedStore.kt new file mode 100644 index 0000000000..1636a37cd0 --- /dev/null +++ b/core/datasource/src/main/java/com/tangem/datasource/local/datastore/RuntimeSharedStore.kt @@ -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 { + + /** Get flow of elements [T] */ + fun get(): Flow + + /** 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 invoke(): RuntimeSharedStore = object : RuntimeSharedStore { + + private val flow = MutableSharedFlow(replay = 1) + + init { + flow.tryEmit(value = null) + } + + override fun get(): Flow = 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) + } + } + } +} \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/local/quote/DefaultQuotesStore.kt b/core/datasource/src/main/java/com/tangem/datasource/local/quote/DefaultQuotesStore.kt index 3bad4c835a..150f67eb66 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/local/quote/DefaultQuotesStore.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/local/quote/DefaultQuotesStore.kt @@ -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 + +/** + * Default implementation of [QuotesStore] + * + * @property persistenceStore persistence quotes store + * @property runtimeStore runtime quotes store + */ internal class DefaultQuotesStore( - private val dataStore: DataStore, + private val persistenceStore: DataStore, + private val runtimeStore: RuntimeSharedStore>, ) : QuotesStore { - override fun get(currenciesIds: Set): Flow> { - return dataStore.data - .map { quotes -> createQuotes(currenciesIds, quotes) } + override fun get(currenciesIds: Set): Flow> = 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): Set { - 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, quoteEntities: Set): Set = - 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): Set { + 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, runtimeQuotes: Set): Set { + 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 } + } } \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/local/quote/QuotesStore.kt b/core/datasource/src/main/java/com/tangem/datasource/local/quote/QuotesStore.kt index 2d2fada694..feb0872d92 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/local/quote/QuotesStore.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/local/quote/QuotesStore.kt @@ -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): Flow> + /** Get quotes for [currenciesIds] synchronously */ suspend fun getSync(currenciesIds: Set): Set + /** Store [response] from remote */ suspend fun store(response: QuotesResponse) } \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/local/quote/converter/QuoteConverter.kt b/core/datasource/src/main/java/com/tangem/datasource/local/quote/converter/QuoteConverter.kt new file mode 100644 index 0000000000..314d2ee920 --- /dev/null +++ b/core/datasource/src/main/java/com/tangem/datasource/local/quote/converter/QuoteConverter.kt @@ -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, Quote.Value> { + + override fun convert(value: Map.Entry): 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, + ) + } +} \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/local/quote/model/QuoteDM.kt b/core/datasource/src/main/java/com/tangem/datasource/local/quote/model/QuoteDM.kt deleted file mode 100644 index 1465465631..0000000000 --- a/core/datasource/src/main/java/com/tangem/datasource/local/quote/model/QuoteDM.kt +++ /dev/null @@ -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 - -@JsonClass(generateAdapter = true) -internal data class QuoteDM( - val rawCurrencyId: CryptoCurrency.RawID, - val fiatRate: BigDecimal, - val priceChange: BigDecimal, -) \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/local/quote/utils/QuotesSerializer.kt b/core/datasource/src/main/java/com/tangem/datasource/local/quote/utils/QuotesSerializer.kt deleted file mode 100644 index 524683ce7d..0000000000 --- a/core/datasource/src/main/java/com/tangem/datasource/local/quote/utils/QuotesSerializer.kt +++ /dev/null @@ -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 { - - private val adapter by lazy { - val types = Types.newParameterizedType(Set::class.java, QuoteDM::class.java) - - moshi.adapter(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)) - } - } -} \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/utils/MoshiDataStoreSerializer.kt b/core/datasource/src/main/java/com/tangem/datasource/utils/MoshiDataStoreSerializer.kt new file mode 100644 index 0000000000..c76773424f --- /dev/null +++ b/core/datasource/src/main/java/com/tangem/datasource/utils/MoshiDataStoreSerializer.kt @@ -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( + override val defaultValue: T, + private val adapter: JsonAdapter, +) : Serializer { + + /** + * 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(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)) + } + } +} \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/utils/MoshiTypesExt.kt b/core/datasource/src/main/java/com/tangem/datasource/utils/MoshiTypesExt.kt new file mode 100644 index 0000000000..2330648ff4 --- /dev/null +++ b/core/datasource/src/main/java/com/tangem/datasource/utils/MoshiTypesExt.kt @@ -0,0 +1,16 @@ +package com.tangem.datasource.utils + +import com.squareup.moshi.Types +import java.lang.reflect.ParameterizedType + +inline fun mapWithStringKeyTypes(): ParameterizedType { + return Types.newParameterizedType(Map::class.java, String::class.java, T::class.java) +} + +inline fun listTypes(): ParameterizedType { + return Types.newParameterizedType(List::class.java, T::class.java) +} + +inline fun setTypes(): ParameterizedType { + return Types.newParameterizedType(Set::class.java, T::class.java) +} \ No newline at end of file diff --git a/data/onramp/src/main/java/com/tangem/data/onramp/converters/HotCryptoCurrencyConverter.kt b/data/onramp/src/main/java/com/tangem/data/onramp/converters/HotCryptoCurrencyConverter.kt index a810cc5105..4d16a5b159 100644 --- a/data/onramp/src/main/java/com/tangem/data/onramp/converters/HotCryptoCurrencyConverter.kt +++ b/data/onramp/src/main/java/com/tangem/data/onramp/converters/HotCryptoCurrencyConverter.kt @@ -94,6 +94,7 @@ internal class HotCryptoCurrencyConverter( rawCurrencyId = rawCurrencyId, fiatRate = fiatRate, priceChange = priceChange.movePointLeft(2), + isCached = false, // It doesn't matter ) } else { Quote.Empty(rawCurrencyId) diff --git a/domain/tokens/models/src/main/java/com/tangem/domain/tokens/model/Quote.kt b/domain/tokens/models/src/main/java/com/tangem/domain/tokens/model/Quote.kt index c330160b97..d4d7fb06c7 100644 --- a/domain/tokens/models/src/main/java/com/tangem/domain/tokens/model/Quote.kt +++ b/domain/tokens/models/src/main/java/com/tangem/domain/tokens/model/Quote.kt @@ -19,10 +19,12 @@ sealed interface Quote { * @property rawCurrencyId The unique identifier of the cryptocurrency for which the financial information is provided. * @property fiatRate The current fiat exchange rate for the cryptocurrency. * @property priceChange The price change for the cryptocurrency. + * @property isCached flag that determines whether the quote is a cache */ data class Value( override val rawCurrencyId: CryptoCurrency.RawID, val fiatRate: BigDecimal, val priceChange: BigDecimal, + val isCached: Boolean, ) : Quote } \ No newline at end of file diff --git a/domain/tokens/src/test/kotlin/com/tangem/domain/tokens/mock/MockQuotes.kt b/domain/tokens/src/test/kotlin/com/tangem/domain/tokens/mock/MockQuotes.kt index 5dde109c87..a3b1ee5456 100644 --- a/domain/tokens/src/test/kotlin/com/tangem/domain/tokens/mock/MockQuotes.kt +++ b/domain/tokens/src/test/kotlin/com/tangem/domain/tokens/mock/MockQuotes.kt @@ -12,60 +12,70 @@ internal object MockQuotes { rawCurrencyId = MockTokens.token1.id.rawCurrencyId!!, fiatRate = BigDecimal("1.23"), priceChange = BigDecimal("0.01"), + isCached = false, ) val quote2 = Quote.Value( rawCurrencyId = MockTokens.token2.id.rawCurrencyId!!, fiatRate = BigDecimal("2.34"), priceChange = BigDecimal("-0.02"), + isCached = false, ) val quote3 = Quote.Value( rawCurrencyId = MockTokens.token3.id.rawCurrencyId!!, fiatRate = BigDecimal("3.45"), priceChange = BigDecimal("0.03"), + isCached = false, ) val quote4 = Quote.Value( rawCurrencyId = MockTokens.token4.id.rawCurrencyId!!, fiatRate = BigDecimal("4.56"), priceChange = BigDecimal("-0.04"), + isCached = false, ) val quote5 = Quote.Value( rawCurrencyId = MockTokens.token5.id.rawCurrencyId!!, fiatRate = BigDecimal("5.67"), priceChange = BigDecimal("0.05"), + isCached = false, ) val quote6 = Quote.Value( rawCurrencyId = MockTokens.token6.id.rawCurrencyId!!, fiatRate = BigDecimal("6.78"), priceChange = BigDecimal("-0.06"), + isCached = false, ) val quote7 = Quote.Value( rawCurrencyId = MockTokens.token7.id.rawCurrencyId!!, fiatRate = BigDecimal("7.89"), priceChange = BigDecimal("0.07"), + isCached = false, ) val quote8 = Quote.Value( rawCurrencyId = MockTokens.token8.id.rawCurrencyId!!, fiatRate = BigDecimal("8.90"), priceChange = BigDecimal("-0.08"), + isCached = false, ) val quote9 = Quote.Value( rawCurrencyId = MockTokens.token9.id.rawCurrencyId!!, fiatRate = BigDecimal("9.01"), priceChange = BigDecimal("0.09"), + isCached = false, ) val quote10 = Quote.Value( rawCurrencyId = MockTokens.token10.id.rawCurrencyId!!, fiatRate = BigDecimal("10.12"), priceChange = BigDecimal("-0.10"), + isCached = false, ) val quote11 = Quote.Empty(CryptoCurrency.RawID("null"))