From 4f5257e537ef385136de39c38e268337a8e38ae8 Mon Sep 17 00:00:00 2001 From: Tangem Date: Wed, 12 Feb 2025 23:04:32 +0400 Subject: [PATCH] Updated on 2026-08-14 --- .../di/NetworksStatusesStoreModule.kt | 22 ++++- .../network/DefaultNetworksStatusesStore.kt | 87 +++++++++++++++++-- .../local/network/NetworksStatusesStore.kt | 3 + .../local/network/entity/NetworkStatusDM.kt | 27 ++++++ .../network/utils/NetworkStatusMapper.kt | 80 +++++++++++++++++ .../utils/NetworkStatusesSerializer.kt | 36 ++++++++ .../repository/DefaultNetworksRepository.kt | 27 +++--- .../domain/tokens/model/CryptoCurrency.kt | 52 ++++++++++- 8 files changed, 307 insertions(+), 27 deletions(-) create mode 100644 core/datasource/src/main/java/com/tangem/datasource/local/network/entity/NetworkStatusDM.kt create mode 100644 core/datasource/src/main/java/com/tangem/datasource/local/network/utils/NetworkStatusMapper.kt create mode 100644 core/datasource/src/main/java/com/tangem/datasource/local/network/utils/NetworkStatusesSerializer.kt diff --git a/core/datasource/src/main/java/com/tangem/datasource/di/NetworksStatusesStoreModule.kt b/core/datasource/src/main/java/com/tangem/datasource/di/NetworksStatusesStoreModule.kt index d9cb5b5946..6aa2a9f344 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/di/NetworksStatusesStoreModule.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/di/NetworksStatusesStoreModule.kt @@ -1,21 +1,39 @@ package com.tangem.datasource.di +import android.content.Context +import androidx.datastore.core.DataStoreFactory +import androidx.datastore.dataStoreFile +import com.squareup.moshi.Moshi import com.tangem.datasource.local.datastore.RuntimeDataStore import com.tangem.datasource.local.network.DefaultNetworksStatusesStore import com.tangem.datasource.local.network.NetworksStatusesStore +import com.tangem.datasource.local.network.utils.NetworkStatusesSerializer +import com.tangem.utils.coroutines.CoroutineDispatcherProvider import dagger.Module import dagger.Provides import dagger.hilt.InstallIn +import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.components.SingletonComponent +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.SupervisorJob @Module @InstallIn(SingletonComponent::class) internal object NetworksStatusesStoreModule { @Provides - fun provideNetworksStatusesStore(): NetworksStatusesStore { + fun provideNetworksStatusesStore( + @NetworkMoshi moshi: Moshi, + @ApplicationContext context: Context, + dispatchers: CoroutineDispatcherProvider, + ): NetworksStatusesStore { return DefaultNetworksStatusesStore( - dataStore = RuntimeDataStore(), + runtimeDataStore = RuntimeDataStore(), + persistneceDataStore = DataStoreFactory.create( + serializer = NetworkStatusesSerializer(moshi), + produceFile = { context.dataStoreFile(fileName = "networks_statuses") }, + scope = CoroutineScope(context = dispatchers.io + SupervisorJob()), + ), ) } } \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/local/network/DefaultNetworksStatusesStore.kt b/core/datasource/src/main/java/com/tangem/datasource/local/network/DefaultNetworksStatusesStore.kt index e168276a5b..975f51a140 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/local/network/DefaultNetworksStatusesStore.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/local/network/DefaultNetworksStatusesStore.kt @@ -1,22 +1,70 @@ package com.tangem.datasource.local.network -import com.tangem.datasource.local.datastore.core.StringKeyDataStore -import com.tangem.datasource.local.datastore.core.StringKeyDataStoreDecorator +import androidx.datastore.core.DataStore +import com.tangem.datasource.local.datastore.RuntimeDataStore +import com.tangem.datasource.local.network.entity.NetworkStatusesDM +import com.tangem.datasource.local.network.utils.toDataModel +import com.tangem.datasource.local.network.utils.toDomainModel +import com.tangem.domain.tokens.model.Network import com.tangem.domain.tokens.model.NetworkStatus import com.tangem.domain.wallets.models.UserWalletId import com.tangem.utils.extensions.addOrReplace import com.tangem.utils.extensions.replaceBy +import kotlinx.coroutines.flow.* import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock internal class DefaultNetworksStatusesStore( - dataStore: StringKeyDataStore>, -) : NetworksStatusesStore, StringKeyDataStoreDecorator>(dataStore) { + private val runtimeDataStore: RuntimeDataStore>, + private val persistneceDataStore: DataStore, +) : NetworksStatusesStore { private val mutex = Mutex() - override fun provideStringKey(key: UserWalletId): String { - return key.stringValue + override fun get(key: UserWalletId): Flow> { + return runtimeDataStore.get(provideStringKey(key)) + } + + override fun get(key: UserWalletId, networks: Set): Flow> = channelFlow { + val cachedStatuses = persistneceDataStore.data.firstOrNull() + ?.get(key.stringValue) + ?.mapNotNullTo(mutableSetOf()) { status -> + val network = networks + .firstOrNull { it.id == status.networkId } + ?: return@mapNotNullTo null + + status.toDomainModel(network) + } + .orEmpty() + + if (cachedStatuses.isNotEmpty()) { + send(cachedStatuses) + } + + runtimeDataStore.get(provideStringKey(key)) + .onEach { runtimeStatuses -> + if (cachedStatuses.isEmpty()) { + send(runtimeStatuses) + } else { + val mergedStatuses = cachedStatuses.toMutableList() + + runtimeStatuses.forEach { runtimeStatus -> + val index = mergedStatuses.indexOfFirst { it.network == runtimeStatus.network } + if (index != -1) { + mergedStatuses[index] = runtimeStatus + } else { + mergedStatuses.add(runtimeStatus) + } + } + + send(mergedStatuses.toSet()) + } + } + .launchIn(scope = this) + } + + override suspend fun getSyncOrNull(key: UserWalletId): Set? { + return runtimeDataStore.getSyncOrNull(provideStringKey(key)) } override suspend fun store(key: UserWalletId, value: NetworkStatus) { @@ -25,7 +73,8 @@ internal class DefaultNetworksStatusesStore( ?.addOrReplace(value) { it.network == value.network } ?: setOf(value) - store(key, newValues) + runtimeDataStore.store(provideStringKey(key), newValues) + storeNetworkStatusInPersistence(key, value) } } @@ -44,7 +93,29 @@ internal class DefaultNetworksStatusesStore( } } - store(key, updatedValues) + runtimeDataStore.store(provideStringKey(key), updatedValues) } } + + private suspend fun storeNetworkStatusInPersistence(userWalletId: UserWalletId, networkStatus: NetworkStatus) { + val status = networkStatus.value + val network = networkStatus.network + + if (status !is NetworkStatus.Verified) return + + persistneceDataStore.updateData { storedStatuses -> + val userWalletStatuses = storedStatuses[userWalletId.stringValue] ?: emptySet() + val updatedStatuses = userWalletStatuses.addOrReplace(status.toDataModel(network)) { + it.networkId == network.id + } + + storedStatuses.toMutableMap().apply { + this[userWalletId.stringValue] = updatedStatuses + } + } + } + + private fun provideStringKey(key: UserWalletId): String { + return "network_statuses_${key.stringValue}" + } } \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/local/network/NetworksStatusesStore.kt b/core/datasource/src/main/java/com/tangem/datasource/local/network/NetworksStatusesStore.kt index 3ab684275f..6b191e150d 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/local/network/NetworksStatusesStore.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/local/network/NetworksStatusesStore.kt @@ -1,5 +1,6 @@ package com.tangem.datasource.local.network +import com.tangem.domain.tokens.model.Network import com.tangem.domain.tokens.model.NetworkStatus import com.tangem.domain.wallets.models.UserWalletId import kotlinx.coroutines.flow.Flow @@ -8,6 +9,8 @@ interface NetworksStatusesStore { fun get(key: UserWalletId): Flow> + fun get(key: UserWalletId, networks: Set): Flow> + suspend fun getSyncOrNull(key: UserWalletId): Set? suspend fun store(key: UserWalletId, value: NetworkStatus) diff --git a/core/datasource/src/main/java/com/tangem/datasource/local/network/entity/NetworkStatusDM.kt b/core/datasource/src/main/java/com/tangem/datasource/local/network/entity/NetworkStatusDM.kt new file mode 100644 index 0000000000..6c681b5cf8 --- /dev/null +++ b/core/datasource/src/main/java/com/tangem/datasource/local/network/entity/NetworkStatusDM.kt @@ -0,0 +1,27 @@ +package com.tangem.datasource.local.network.entity + +import com.squareup.moshi.JsonClass +import com.tangem.domain.tokens.model.Network +import java.math.BigDecimal + +internal typealias NetworkStatusesDM = Map> + +@JsonClass(generateAdapter = true) +internal data class NetworkStatusDM( + val networkId: Network.ID, + val selectedAddress: String, + val availableAddresses: Set
, + val amounts: Map, +) { + + @JsonClass(generateAdapter = true) + data class Address( + val value: String, + val type: Type, + ) { + + enum class Type { + Primary, Secondary, + } + } +} \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/local/network/utils/NetworkStatusMapper.kt b/core/datasource/src/main/java/com/tangem/datasource/local/network/utils/NetworkStatusMapper.kt new file mode 100644 index 0000000000..cd0aa4071c --- /dev/null +++ b/core/datasource/src/main/java/com/tangem/datasource/local/network/utils/NetworkStatusMapper.kt @@ -0,0 +1,80 @@ +package com.tangem.datasource.local.network.utils + +import com.tangem.common.extensions.mapNotNullValues +import com.tangem.datasource.local.network.entity.NetworkStatusDM +import com.tangem.domain.tokens.model.* +import timber.log.Timber +import java.math.BigDecimal + +internal fun NetworkStatus.Verified.toDataModel(network: Network): NetworkStatusDM { + return NetworkStatusDM( + networkId = network.id, + selectedAddress = address.defaultAddress.value, + availableAddresses = address.availableAddresses + .map { address -> + NetworkStatusDM.Address( + value = address.value, + type = when (address.type) { + NetworkAddress.Address.Type.Primary -> NetworkStatusDM.Address.Type.Primary + NetworkAddress.Address.Type.Secondary -> NetworkStatusDM.Address.Type.Secondary + }, + ) + } + .toSet(), + amounts = amounts + .mapKeys { (id, _) -> id.value } + .mapNotNullValues { (_, amount) -> + when (amount) { + is CryptoCurrencyAmountStatus.Loaded -> amount.value + is CryptoCurrencyAmountStatus.NotFound -> null + } + }, + ) +} + +internal fun NetworkStatusDM.toDomainModel(network: Network): NetworkStatus { + return NetworkStatus( + network = network, + value = NetworkStatus.Verified( + address = mapToDomainAddress(selectedAddress, availableAddresses), + amounts = mapToDomainAmounts(amounts), + pendingTransactions = mapOf(), + ), + ) +} + +private fun mapToDomainAmounts(amounts: Map): Map { + return amounts + .mapKeys { CryptoCurrency.ID.fromValue(it.key) } + .mapValues { (_, amount) -> CryptoCurrencyAmountStatus.Loaded(amount) } +} + +private fun mapToDomainAddress( + selectedAddress: String, + availableAddresses: Set, +): NetworkAddress { + val defaultAddress = availableAddresses + .firstOrNull { it.value == selectedAddress } + ?.let(::mapToDomainAddress) + + requireNotNull(defaultAddress) { "Selected address must not be null" } + + return if (availableAddresses.size != 1) { + NetworkAddress.Selectable(defaultAddress, availableAddresses.mapTo(hashSetOf(), ::mapToDomainAddress)) + } else { + NetworkAddress.Single(defaultAddress) + } +} + +private fun mapToDomainAddress(address: NetworkStatusDM.Address): NetworkAddress.Address { + val type = when (address.type) { + NetworkStatusDM.Address.Type.Primary -> NetworkAddress.Address.Type.Primary + NetworkStatusDM.Address.Type.Secondary -> NetworkAddress.Address.Type.Secondary + } + + if (address.value.isBlank()) { + Timber.w("Address value is blank") + } + + return NetworkAddress.Address(address.value, type) +} \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/local/network/utils/NetworkStatusesSerializer.kt b/core/datasource/src/main/java/com/tangem/datasource/local/network/utils/NetworkStatusesSerializer.kt new file mode 100644 index 0000000000..487cd016b3 --- /dev/null +++ b/core/datasource/src/main/java/com/tangem/datasource/local/network/utils/NetworkStatusesSerializer.kt @@ -0,0 +1,36 @@ +package com.tangem.datasource.local.network.utils + +import androidx.datastore.core.Serializer +import com.squareup.moshi.Moshi +import com.squareup.moshi.Types +import com.tangem.datasource.local.network.entity.NetworkStatusDM +import com.tangem.datasource.local.network.entity.NetworkStatusesDM +import java.io.InputStream +import java.io.OutputStream + +internal class NetworkStatusesSerializer(moshi: Moshi) : Serializer { + + private val adapter by lazy { + val type = Types.newParameterizedType( + Map::class.java, + String::class.java, + Types.newParameterizedType(Set::class.java, NetworkStatusDM::class.java), + ) + + moshi.adapter(type) + } + + override val defaultValue: NetworkStatusesDM = emptyMap() + + override suspend fun readFrom(input: InputStream): NetworkStatusesDM { + return input.bufferedReader().use { reader -> + adapter.fromJson(reader.readText()) ?: defaultValue + } + } + + override suspend fun writeTo(t: NetworkStatusesDM, output: OutputStream) { + output.bufferedWriter().use { writer -> + writer.write(adapter.toJson(t)) + } + } +} \ No newline at end of file diff --git a/data/tokens/src/main/kotlin/com/tangem/data/tokens/repository/DefaultNetworksRepository.kt b/data/tokens/src/main/kotlin/com/tangem/data/tokens/repository/DefaultNetworksRepository.kt index 664baf087d..7d219967a7 100644 --- a/data/tokens/src/main/kotlin/com/tangem/data/tokens/repository/DefaultNetworksRepository.kt +++ b/data/tokens/src/main/kotlin/com/tangem/data/tokens/repository/DefaultNetworksRepository.kt @@ -26,10 +26,7 @@ import com.tangem.domain.walletmanager.model.UpdateWalletManagerResult import com.tangem.domain.wallets.models.UserWalletId import com.tangem.utils.coroutines.CoroutineDispatcherProvider import kotlinx.coroutines.* -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.channelFlow -import kotlinx.coroutines.flow.launchIn -import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.* import timber.log.Timber @Suppress("LongParameterList") @@ -48,15 +45,18 @@ internal class DefaultNetworksRepository( private val responseCurrenciesFactory = ResponseCryptoCurrenciesFactory(excludedBlockchains) private val networkStatusFactory = NetworkStatusFactory() - override suspend fun fetchNetworkStatuses(userWalletId: UserWalletId, networks: Set, refresh: Boolean) { - TODO("Will be implemented in [REDACTED_TASK_KEY]") - } - override fun getNetworkStatusesUpdates( userWalletId: UserWalletId, networks: Set, ): Flow> { - TODO("Will be implemented in [REDACTED_TASK_KEY]") + return networksStatusesStore.get(userWalletId, networks) + .flowOn(dispatchers.io) + } + + override suspend fun fetchNetworkStatuses(userWalletId: UserWalletId, networks: Set, refresh: Boolean) { + withContext(dispatchers.io) { + fetchNetworksStatusesIfCacheExpired(userWalletId, networks, refresh) + } } override fun getNetworkStatusesUpdatesLegacy( @@ -167,12 +167,13 @@ internal class DefaultNetworksRepository( network: Network, currencies: Sequence, ) { + val networkCurrencies = currencies.filter { it.network == network } + val result = walletManagersFacade.update( userWalletId = userWalletId, network = network, - extraTokens = currencies + extraTokens = networkCurrencies .filterIsInstance() - .filter { it.network == network } .toSet(), ) @@ -183,7 +184,7 @@ internal class DefaultNetworksRepository( val networkStatus = networkStatusFactory.createNetworkStatus( network = network, result = result, - currencies = currencies.toSet(), + currencies = networkCurrencies.toSet(), ) networksStatusesStore.store(userWalletId, networkStatus) @@ -214,7 +215,7 @@ internal class DefaultNetworksRepository( private suspend fun getCurrencies(userWalletId: UserWalletId, networks: Set): Sequence { val currencies = getCurrencies(userWalletId) - return currencies.filter { networks.contains(it.network) } + return currencies.filter { it.network in networks } } private suspend fun getCurrencies(userWalletId: UserWalletId): Sequence { diff --git a/domain/tokens/models/src/main/java/com/tangem/domain/tokens/model/CryptoCurrency.kt b/domain/tokens/models/src/main/java/com/tangem/domain/tokens/model/CryptoCurrency.kt index 017bc65de0..b882eb2bf6 100644 --- a/domain/tokens/models/src/main/java/com/tangem/domain/tokens/model/CryptoCurrency.kt +++ b/domain/tokens/models/src/main/java/com/tangem/domain/tokens/model/CryptoCurrency.kt @@ -171,7 +171,7 @@ sealed class CryptoCurrency { get() = buildString { append(rawId) if (contractAddress != null) { - append(SUFFIX_DELIMITER) + append(CONTRACT_ADDRESS_DELIMITER) append(contractAddress) } } @@ -188,11 +188,55 @@ sealed class CryptoCurrency { return "ID(value='$value')" } + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as ID + + return value == other.value + } + + override fun hashCode(): Int { + return value.hashCode() + } + companion object { // should use delimiters that could be used in URL not like path or query delimiters - private const val PREFIX_DELIMITER = '_' - private const val SUFFIX_DELIMITER = ';' - private const val DERIVATION_PATH_DELIMITER = 'd' + private const val PREFIX_DELIMITER = '\u27E8' // ⟨ + private const val SUFFIX_DELIMITER = '\u27E9' // ⟩ + private const val CONTRACT_ADDRESS_DELIMITER = '\u2693' // ⚓ + private const val DERIVATION_PATH_DELIMITER = '\u2192' // → + + private const val ID_PARTS_COUNT = 3 + + /** + * Creates an [ID] from a string [value]. + * */ + fun fromValue(value: String): ID { + val parts = value.split(PREFIX_DELIMITER, SUFFIX_DELIMITER) + + require(value = parts.size == ID_PARTS_COUNT) { "Invalid ID format: $value" } + + val prefix = Prefix.entries.firstOrNull { it.value == parts[0] } + requireNotNull(prefix) { "Invalid ID prefix: ${parts[0]}" } + + val bodyParts = parts[1].split(DERIVATION_PATH_DELIMITER) + val body = when (bodyParts.size) { + 1 -> Body.NetworkId(bodyParts[0]) + 2 -> Body.NetworkIdWithDerivationPath(bodyParts[0], bodyParts[1]) + else -> error("Invalid ID body: ${parts[1]}") + } + + val suffixParts = parts[2].split(CONTRACT_ADDRESS_DELIMITER) + val suffix = when (suffixParts.size) { + 1 -> Suffix.ContractAddress(suffixParts[0]) + 2 -> Suffix.RawID(suffixParts[0], suffixParts[1]) + else -> error("Invalid ID suffix: ${parts[2]}") + } + + return ID(prefix, body, suffix) + } } }