Updated on 2026-08-14

This commit is contained in:
Tangem 2024-08-15 13:25:20 +04:00
parent 292d895c4a
commit 8dc8119970
8 changed files with 461 additions and 0 deletions

View file

@ -104,6 +104,7 @@ dependencies {
implementation(projects.data.staking)
implementation(projects.data.walletConnect)
implementation(projects.data.markets)
implementation(projects.data.manageTokens)
/** Features */
implementation(projects.features.onboarding)

1
data/manage-tokens/.gitignore vendored Normal file
View file

@ -0,0 +1 @@
/build

View file

@ -0,0 +1,44 @@
plugins {
alias(deps.plugins.android.library)
alias(deps.plugins.kotlin.android)
alias(deps.plugins.kotlin.kapt)
id("configuration")
}
android {
namespace = "com.example.data.managetokens"
}
dependencies {
/** Project - Domain */
implementation(projects.domain.models)
implementation(projects.domain.manageTokens)
implementation(projects.domain.tokens.models)
implementation(projects.domain.wallets.models)
/** Project - Data */
implementation(projects.core.datasource)
implementation(projects.data.common)
/** Project - Utils */
implementation(projects.core.utils)
implementation(projects.core.pagination)
implementation(projects.domain.legacy)
implementation(projects.libs.blockchainSdk)
/** Tangem SDKs */
implementation(deps.tangem.blockchain)
implementation(deps.tangem.card.core)
/** AndroidX */
implementation(deps.androidx.datastore)
/** DI */
implementation(deps.hilt.core)
kapt(deps.hilt.kapt)
/** Other */
implementation(deps.moshi.kotlin)
implementation(deps.timber)
}

View file

@ -0,0 +1,138 @@
package com.tangem.data.managetokens
import com.tangem.blockchain.common.Blockchain
import com.tangem.blockchainsdk.utils.isSupportedInApp
import com.tangem.blockchainsdk.utils.toNetworkId
import com.tangem.data.common.utils.retryOnError
import com.tangem.data.managetokens.utils.ManageTokensUpdateFetcher
import com.tangem.data.managetokens.utils.ManagedCryptoCurrencyFactory
import com.tangem.datasource.api.common.response.getOrThrow
import com.tangem.datasource.api.tangemTech.TangemTechApi
import com.tangem.datasource.api.tangemTech.models.UserTokensResponse
import com.tangem.datasource.local.preferences.AppPreferencesStore
import com.tangem.datasource.local.preferences.PreferencesKeys
import com.tangem.datasource.local.preferences.utils.getObjectSyncOrNull
import com.tangem.datasource.local.userwallet.UserWalletsStore
import com.tangem.domain.common.extensions.supportedBlockchains
import com.tangem.domain.common.util.cardTypesResolver
import com.tangem.domain.common.util.derivationStyleProvider
import com.tangem.domain.managetokens.model.ManageTokensListBatchFlow
import com.tangem.domain.managetokens.model.ManageTokensListBatchingContext
import com.tangem.domain.managetokens.model.ManageTokensListConfig
import com.tangem.domain.managetokens.model.ManagedCryptoCurrency
import com.tangem.domain.managetokens.repository.ManageTokensRepository
import com.tangem.domain.wallets.models.UserWallet
import com.tangem.domain.wallets.models.UserWalletId
import com.tangem.pagination.BatchFetchResult
import com.tangem.pagination.BatchListSource
import com.tangem.pagination.fetcher.LimitOffsetBatchFetcher
import com.tangem.pagination.toBatchFlow
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
internal class DefaultManageTokensRepository(
private val tangemTechApi: TangemTechApi,
private val userWalletsStore: UserWalletsStore,
private val managedCryptoCurrencyFactory: ManagedCryptoCurrencyFactory,
private val manageTokensUpdateFetcher: ManageTokensUpdateFetcher,
private val appPreferencesStore: AppPreferencesStore,
private val dispatchers: CoroutineDispatcherProvider,
) : ManageTokensRepository {
override fun getTokenListBatchFlow(
context: ManageTokensListBatchingContext,
batchSize: Int,
): ManageTokensListBatchFlow {
return BatchListSource(
fetchDispatcher = dispatchers.io,
context = context,
generateNewKey = { it.size.inc() },
batchFetcher = createFetcher(batchSize),
updateFetcher = manageTokensUpdateFetcher,
).toBatchFlow()
}
@Suppress("ComplexCondition")
private fun createFetcher(
batchSize: Int,
): LimitOffsetBatchFetcher<ManageTokensListConfig, List<ManagedCryptoCurrency>> = LimitOffsetBatchFetcher(
prefetchDistance = batchSize,
batchSize = batchSize,
subFetcher = { request, _, isFirstBatchFetching ->
val userWallet = getUserWallet(request.params.userWalletId)
val supportedBlockchains = getSupportedBlockchains(userWallet)
val searchText = request.params.searchText?.takeIf { it.isNotBlank() }
val call = suspend {
tangemTechApi.getCoins(
networkIds = supportedBlockchains.joinToString(
separator = ",",
transform = Blockchain::toNetworkId,
),
active = true,
searchText = searchText,
offset = request.offset * request.limit,
limit = request.limit,
).getOrThrow()
}
val coinsResponse = if (isFirstBatchFetching) {
call()
} else {
retryOnError(call = call)
}
val tokensResponse = getStoredUserTokens(request.params.userWalletId)
val items = if (isFirstBatchFetching &&
tokensResponse != null &&
userWallet != null &&
request.params.searchText.isNullOrBlank()
) {
managedCryptoCurrencyFactory.createWithCustomTokens(
coinsResponse = coinsResponse,
tokensResponse = tokensResponse,
derivationStyleProvider = userWallet.scanResponse.derivationStyleProvider,
)
} else {
managedCryptoCurrencyFactory.create(
coinsResponse = coinsResponse,
tokensResponse = tokensResponse,
derivationStyleProvider = userWallet?.scanResponse?.derivationStyleProvider,
)
}
BatchFetchResult.Success(
data = items,
empty = items.isEmpty(),
last = items.size < request.limit,
)
},
)
private suspend fun getStoredUserTokens(userWalletId: UserWalletId?): UserTokensResponse? {
return if (userWalletId != null) {
appPreferencesStore.getObjectSyncOrNull(
key = PreferencesKeys.getUserTokensKey(userWalletId.stringValue),
)
} else {
null
}
}
private suspend fun getUserWallet(userWalletId: UserWalletId?): UserWallet? {
if (userWalletId == null) {
return null
}
return requireNotNull(userWalletsStore.getSyncOrNull(userWalletId)) {
"User wallet not found"
}
}
private fun getSupportedBlockchains(userWallet: UserWallet?): List<Blockchain> {
return userWallet?.scanResponse?.let {
it.card.supportedBlockchains(it.cardTypesResolver)
} ?: Blockchain.entries.filter {
!it.isTestnet() && it.isSupportedInApp()
}
}
}

View file

@ -0,0 +1,40 @@
package com.tangem.data.managetokens.di
import com.tangem.data.managetokens.DefaultManageTokensRepository
import com.tangem.data.managetokens.utils.ManageTokensUpdateFetcher
import com.tangem.data.managetokens.utils.ManagedCryptoCurrencyFactory
import com.tangem.datasource.api.tangemTech.TangemTechApi
import com.tangem.datasource.local.preferences.AppPreferencesStore
import com.tangem.datasource.local.userwallet.UserWalletsStore
import com.tangem.domain.managetokens.repository.ManageTokensRepository
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import javax.inject.Singleton
@Module
@InstallIn(SingletonComponent::class)
internal object ManageTokensDataModule {
@Provides
@Singleton
fun provideManageTokensRepository(
tangemTechApi: TangemTechApi,
userWalletsStore: UserWalletsStore,
managedCryptoCurrencyFactory: ManagedCryptoCurrencyFactory,
manageTokensUpdateFetcher: ManageTokensUpdateFetcher,
appPreferencesStore: AppPreferencesStore,
dispatchers: CoroutineDispatcherProvider,
): ManageTokensRepository {
return DefaultManageTokensRepository(
tangemTechApi,
userWalletsStore,
managedCryptoCurrencyFactory,
manageTokensUpdateFetcher,
appPreferencesStore,
dispatchers,
)
}
}

View file

@ -0,0 +1,68 @@
package com.tangem.data.managetokens.utils
import com.tangem.domain.managetokens.model.ManageTokensUpdateAction
import com.tangem.domain.managetokens.model.ManagedCryptoCurrency
import com.tangem.pagination.Batch
import com.tangem.pagination.BatchUpdateFetcher
import com.tangem.pagination.BatchUpdateResult
import kotlinx.coroutines.async
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.launch
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
internal class ManageTokensUpdateFetcher @Inject constructor() :
BatchUpdateFetcher<Int, List<ManagedCryptoCurrency>, ManageTokensUpdateAction> {
override suspend fun BatchUpdateFetcher.UpdateContext<Int, List<ManagedCryptoCurrency>>.fetchUpdateAsync(
toUpdate: List<Batch<Int, List<ManagedCryptoCurrency>>>,
updateRequest: ManageTokensUpdateAction,
) {
when (updateRequest) {
is ManageTokensUpdateAction.AddCurrency -> coroutineScope {
val tasks = toUpdate.map { (key, data) ->
async {
val currencyIndex = data.indexOfFirst { it.id == updateRequest.currencyId }
.takeIf { it != -1 }
?: error("Currency '${updateRequest.currencyId}' not found in batch #$key")
val updatedCurrency = when (val currency = data[currencyIndex]) {
is ManagedCryptoCurrency.Custom -> error("Can't add custom currency '${currency.id}'")
is ManagedCryptoCurrency.Token -> {
currency.copy(
addedIn = if (updateRequest.isSelected) {
currency.addedIn + updateRequest.networkId
} else {
currency.addedIn - updateRequest.networkId
},
)
}
}
data.toMutableList().apply {
set(currencyIndex, updatedCurrency)
}
}
}
tasks.forEachIndexed { index, task ->
launch {
val updatedItems = task.await()
update {
BatchUpdateResult.Success(
data = mapNotNull {
if (it.key == toUpdate[index].key) {
Batch(it.key, updatedItems)
} else {
null
}
},
)
}
}
}
}
}
}
}

View file

@ -0,0 +1,168 @@
package com.tangem.data.managetokens.utils
import com.tangem.blockchain.common.Blockchain
import com.tangem.blockchainsdk.utils.fromNetworkId
import com.tangem.blockchainsdk.utils.isSupportedInApp
import com.tangem.blockchainsdk.utils.toCoinId
import com.tangem.data.common.currency.getCoinId
import com.tangem.data.common.currency.getNetwork
import com.tangem.data.common.currency.getTokenId
import com.tangem.datasource.api.tangemTech.models.CoinsResponse
import com.tangem.datasource.api.tangemTech.models.UserTokensResponse
import com.tangem.domain.common.DerivationStyleProvider
import com.tangem.domain.managetokens.model.ManagedCryptoCurrency
import com.tangem.domain.managetokens.model.ManagedCryptoCurrency.SourceNetwork
import com.tangem.domain.tokens.model.Network
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
internal class ManagedCryptoCurrencyFactory @Inject constructor() {
fun create(
coinsResponse: CoinsResponse,
tokensResponse: UserTokensResponse?,
derivationStyleProvider: DerivationStyleProvider?,
): List<ManagedCryptoCurrency> {
return coinsResponse.coins.mapNotNull { coin ->
createToken(coin, tokensResponse, coinsResponse.imageHost, derivationStyleProvider)
}
}
fun createWithCustomTokens(
coinsResponse: CoinsResponse,
tokensResponse: UserTokensResponse,
derivationStyleProvider: DerivationStyleProvider,
): List<ManagedCryptoCurrency> {
val customTokens = tokensResponse.tokens
.mapNotNull { token ->
maybeCreateCustomToken(token, coinsResponse.imageHost, derivationStyleProvider)
}
val tokens = create(coinsResponse, tokensResponse, derivationStyleProvider)
return customTokens + tokens
}
private fun maybeCreateCustomToken(
token: UserTokensResponse.Token,
imageHost: String?,
derivationStyleProvider: DerivationStyleProvider,
): ManagedCryptoCurrency? {
val blockchain = Blockchain.fromNetworkId(token.networkId)
?.takeIf { it.isSupportedInApp() }
?: return null
if (!checkIsCustomToken(token, blockchain, derivationStyleProvider)) {
return null
}
val network = getNetwork(
blockchain = blockchain,
extraDerivationPath = token.derivationPath,
derivationStyleProvider = derivationStyleProvider,
) ?: return null
val contractAddress = token.contractAddress
return if (contractAddress.isNullOrBlank()) {
ManagedCryptoCurrency.Custom.Coin(
currencyId = getCoinId(network, blockchain.toCoinId()),
name = token.name,
symbol = token.symbol,
iconUrl = getIconUrl(blockchain.id, imageHost),
network = network,
)
} else {
ManagedCryptoCurrency.Custom.Token(
currencyId = getTokenId(network, token.id, contractAddress),
name = token.name,
symbol = token.symbol,
iconUrl = token.id?.let { getIconUrl(it, imageHost) },
contractAddress = contractAddress,
network = network,
)
}
}
private fun createToken(
coinResponse: CoinsResponse.Coin,
tokensResponse: UserTokensResponse?,
imageHost: String?,
derivationStyleProvider: DerivationStyleProvider?,
): ManagedCryptoCurrency? {
if (coinResponse.networks.isEmpty() || !coinResponse.active) return null
return ManagedCryptoCurrency.Token(
id = ManagedCryptoCurrency.ID(coinResponse.id),
name = coinResponse.name,
symbol = coinResponse.symbol,
iconUrl = getIconUrl(coinResponse.id, imageHost),
availableNetworks = coinResponse.networks.mapNotNull { network ->
createSource(network, derivationStyleProvider)
},
addedIn = findAddedInNetworksIds(coinResponse.id, tokensResponse),
)
}
private fun createSource(
networkResponse: CoinsResponse.Coin.Network,
derivationStyleProvider: DerivationStyleProvider?,
extraDerivationPath: String? = null,
): SourceNetwork? {
val blockchain = Blockchain.fromNetworkId(networkResponse.networkId)
?.takeIf { it.isSupportedInApp() }
?: return null
val network = getNetwork(blockchain, extraDerivationPath, derivationStyleProvider) ?: return null
val contractAddress = networkResponse.contractAddress
return if (contractAddress.isNullOrBlank()) {
SourceNetwork.Main(
network = network,
)
} else {
SourceNetwork.Default(
network = network,
contractAddress = contractAddress,
)
}
}
private fun findAddedInNetworksIds(currencyId: String, tokensResponse: UserTokensResponse?): Set<Network.ID> {
if (tokensResponse == null) return emptySet()
return tokensResponse.tokens
.filter { it.id == currencyId }
.map { it.networkId }
.mapNotNullTo(mutableSetOf()) { networkId ->
val blockchain = Blockchain.fromNetworkId(networkId)
if (blockchain != null && blockchain.isSupportedInApp()) {
Network.ID(blockchain.id)
} else {
null
}
}
}
private fun getIconUrl(id: String, imageHost: String?): String {
return "${imageHost ?: DEFAULT_IMAGE_HOST}large/$id.png"
}
private fun checkIsCustomToken(
token: UserTokensResponse.Token,
blockchain: Blockchain,
derivationStyleProvider: DerivationStyleProvider,
): Boolean = token.id.isNullOrBlank() ||
checkIsCustomDerivationPath(token.derivationPath, blockchain, derivationStyleProvider)
private fun checkIsCustomDerivationPath(
derivationPath: String?,
blockchain: Blockchain,
derivationStyleProvider: DerivationStyleProvider,
): Boolean = derivationPath != blockchain.derivationPath(derivationStyleProvider.getDerivationStyle())?.rawPath
private companion object {
const val DEFAULT_IMAGE_HOST = "https://s3.eu-central-1.amazonaws.com/tangem.api/coins/"
}
}

View file

@ -250,4 +250,5 @@ include(":data:qr-scanning")
include(":data:staking")
include(":data:wallet-connect")
include(":data:markets")
include(":data:manage-tokens")
// endregion Data modules