diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 968acfe32e..01c88fedd1 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -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) diff --git a/data/manage-tokens/.gitignore b/data/manage-tokens/.gitignore new file mode 100644 index 0000000000..42afabfd2a --- /dev/null +++ b/data/manage-tokens/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/data/manage-tokens/build.gradle.kts b/data/manage-tokens/build.gradle.kts new file mode 100644 index 0000000000..137a893c81 --- /dev/null +++ b/data/manage-tokens/build.gradle.kts @@ -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) +} \ No newline at end of file diff --git a/data/manage-tokens/src/main/kotlin/com/tangem/data/managetokens/DefaultManageTokensRepository.kt b/data/manage-tokens/src/main/kotlin/com/tangem/data/managetokens/DefaultManageTokensRepository.kt new file mode 100644 index 0000000000..bb45239024 --- /dev/null +++ b/data/manage-tokens/src/main/kotlin/com/tangem/data/managetokens/DefaultManageTokensRepository.kt @@ -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> = 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 { + return userWallet?.scanResponse?.let { + it.card.supportedBlockchains(it.cardTypesResolver) + } ?: Blockchain.entries.filter { + !it.isTestnet() && it.isSupportedInApp() + } + } +} \ No newline at end of file diff --git a/data/manage-tokens/src/main/kotlin/com/tangem/data/managetokens/di/ManageTokensDataModule.kt b/data/manage-tokens/src/main/kotlin/com/tangem/data/managetokens/di/ManageTokensDataModule.kt new file mode 100644 index 0000000000..5cb10e4936 --- /dev/null +++ b/data/manage-tokens/src/main/kotlin/com/tangem/data/managetokens/di/ManageTokensDataModule.kt @@ -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, + ) + } +} \ No newline at end of file diff --git a/data/manage-tokens/src/main/kotlin/com/tangem/data/managetokens/utils/ManageTokensUpdateFetcher.kt b/data/manage-tokens/src/main/kotlin/com/tangem/data/managetokens/utils/ManageTokensUpdateFetcher.kt new file mode 100644 index 0000000000..6c23f37b0b --- /dev/null +++ b/data/manage-tokens/src/main/kotlin/com/tangem/data/managetokens/utils/ManageTokensUpdateFetcher.kt @@ -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, ManageTokensUpdateAction> { + + override suspend fun BatchUpdateFetcher.UpdateContext>.fetchUpdateAsync( + toUpdate: List>>, + 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 + } + }, + ) + } + } + } + } + } + } +} \ No newline at end of file diff --git a/data/manage-tokens/src/main/kotlin/com/tangem/data/managetokens/utils/ManagedCryptoCurrencyFactory.kt b/data/manage-tokens/src/main/kotlin/com/tangem/data/managetokens/utils/ManagedCryptoCurrencyFactory.kt new file mode 100644 index 0000000000..de239a1f09 --- /dev/null +++ b/data/manage-tokens/src/main/kotlin/com/tangem/data/managetokens/utils/ManagedCryptoCurrencyFactory.kt @@ -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 { + return coinsResponse.coins.mapNotNull { coin -> + createToken(coin, tokensResponse, coinsResponse.imageHost, derivationStyleProvider) + } + } + + fun createWithCustomTokens( + coinsResponse: CoinsResponse, + tokensResponse: UserTokensResponse, + derivationStyleProvider: DerivationStyleProvider, + ): List { + 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 { + 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/" + } +} \ No newline at end of file diff --git a/settings.gradle.kts b/settings.gradle.kts index a4720852bf..4af0388ca6 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -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 \ No newline at end of file