diff --git a/app/src/main/java/com/tangem/tap/features/customtoken/impl/data/DefaultCustomTokenRepository.kt b/app/src/main/java/com/tangem/tap/features/customtoken/impl/data/DefaultCustomTokenRepository.kt index b949793a7d..96f2eea824 100644 --- a/app/src/main/java/com/tangem/tap/features/customtoken/impl/data/DefaultCustomTokenRepository.kt +++ b/app/src/main/java/com/tangem/tap/features/customtoken/impl/data/DefaultCustomTokenRepository.kt @@ -1,6 +1,7 @@ package com.tangem.tap.features.customtoken.impl.data import com.tangem.blockchain.common.Blockchain +import com.tangem.datasource.api.common.response.getOrThrow import com.tangem.datasource.api.tangemTech.TangemTechApi import com.tangem.domain.common.extensions.supportedBlockchains import com.tangem.domain.common.extensions.toNetworkId @@ -39,6 +40,7 @@ class DefaultCustomTokenRepository( contractAddress = address, networkIds = networkId ?: supportedTokenNetworkIds.joinToString(separator = ","), ) + .getOrThrow() .coins.firstNotNullOfOrNull { coin -> val networksWithTheSameAddress = coin.networks.filter { network -> (network.contractAddress != null || network.decimalCount != null) && diff --git a/app/src/main/java/com/tangem/tap/features/customtoken/impl/domain/CustomTokenRepository.kt b/app/src/main/java/com/tangem/tap/features/customtoken/impl/domain/CustomTokenRepository.kt index 13b5d1c043..cb1959d157 100644 --- a/app/src/main/java/com/tangem/tap/features/customtoken/impl/domain/CustomTokenRepository.kt +++ b/app/src/main/java/com/tangem/tap/features/customtoken/impl/domain/CustomTokenRepository.kt @@ -9,6 +9,10 @@ import com.tangem.tap.features.customtoken.impl.domain.models.FoundToken */ interface CustomTokenRepository { - /** Find token by [address] and [networkId] */ + /** + * Find token by [address] and [networkId] + * + * @throws com.tangem.datasource.api.common.response.ApiResponseError + * */ suspend fun findToken(address: String, networkId: String?): FoundToken } \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/features/tokens/impl/data/TangemApiTokensPagingSource.kt b/app/src/main/java/com/tangem/tap/features/tokens/impl/data/TangemApiTokensPagingSource.kt index 5a65f98b6b..4fc0e646b3 100644 --- a/app/src/main/java/com/tangem/tap/features/tokens/impl/data/TangemApiTokensPagingSource.kt +++ b/app/src/main/java/com/tangem/tap/features/tokens/impl/data/TangemApiTokensPagingSource.kt @@ -3,6 +3,7 @@ package com.tangem.tap.features.tokens.impl.data import androidx.paging.PagingSource import androidx.paging.PagingState import com.tangem.blockchain.common.Blockchain +import com.tangem.datasource.api.common.response.getOrThrow import com.tangem.datasource.api.tangemTech.TangemTechApi import com.tangem.domain.common.extensions.supportedBlockchains import com.tangem.domain.common.extensions.toNetworkId @@ -50,7 +51,7 @@ internal class TangemApiTokensPagingSource( searchText = searchText, offset = page * params.loadSize, limit = params.loadSize, - ) + ).getOrThrow() }.fold( onSuccess = { response -> LoadResult.Page( diff --git a/app/src/main/java/com/tangem/tap/features/tokens/impl/domain/TokensListRepository.kt b/app/src/main/java/com/tangem/tap/features/tokens/impl/domain/TokensListRepository.kt index 11fea0d2fc..8b2f16a644 100644 --- a/app/src/main/java/com/tangem/tap/features/tokens/impl/domain/TokensListRepository.kt +++ b/app/src/main/java/com/tangem/tap/features/tokens/impl/domain/TokensListRepository.kt @@ -15,6 +15,8 @@ internal interface TokensListRepository { * Get available tokens list * * @param searchText search text + * + * @throws com.tangem.datasource.api.common.response.ApiResponseError */ fun getAvailableTokens(searchText: String?): Flow> } \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/tangemTech/TangemTechApi.kt b/core/datasource/src/main/java/com/tangem/datasource/api/tangemTech/TangemTechApi.kt index be16a55885..9992d45c5f 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/api/tangemTech/TangemTechApi.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/api/tangemTech/TangemTechApi.kt @@ -20,7 +20,7 @@ interface TangemTechApi { @Query("searchText") searchText: String? = null, @Query("offset") offset: Int? = null, @Query("limit") limit: Int? = null, - ): CoinsResponse + ): ApiResponse @GET("rates") suspend fun getRates(@Query("currencyId") currencyId: String, @Query("coinIds") coinIds: String): RatesResponse @@ -35,7 +35,10 @@ interface TangemTechApi { suspend fun getUserTokens(@Path(value = "user-id") userId: String): ApiResponse @PUT("user-tokens/{user-id}") - suspend fun saveUserTokens(@Path(value = "user-id") userId: String, @Body userTokens: UserTokensResponse) + suspend fun saveUserTokens( + @Path(value = "user-id") userId: String, + @Body userTokens: UserTokensResponse, + ): ApiResponse /** Returns referral status by [walletId] */ @GET("referral/{walletId}") diff --git a/data/common/src/main/kotlin/com/tangem/data/common/api/ApiResponseRaise.kt b/data/common/src/main/kotlin/com/tangem/data/common/api/ApiResponseRaise.kt index 2d29e30093..b481d8e846 100644 --- a/data/common/src/main/kotlin/com/tangem/data/common/api/ApiResponseRaise.kt +++ b/data/common/src/main/kotlin/com/tangem/data/common/api/ApiResponseRaise.kt @@ -31,12 +31,16 @@ value class ApiResponseRaise( /** * Attempts to execute an API call safely, providing error handling. * + * @param T The return type of the API call and the function. * @param call The API call block to execute. * @param onError A function to handle errors and return a fallback value of type [T]. * * @return The result of the API call or the fallback value provided by [onError] if an error occurs. */ -inline fun safeApiCall(call: ApiResponseRaise.() -> T, onError: (ApiResponseError) -> T): T { +suspend inline fun safeApiCall( + crossinline call: suspend ApiResponseRaise.() -> T, + crossinline onError: suspend (ApiResponseError) -> T, +): T { return recover( block = { call(ApiResponseRaise(raise = this)) }, recover = { diff --git a/data/tokens/src/main/kotlin/com/tangem/data/tokens/paging/CoinsPagingSource.kt b/data/tokens/src/main/kotlin/com/tangem/data/tokens/paging/CoinsPagingSource.kt index beb9f10b36..e5c4c36e1a 100644 --- a/data/tokens/src/main/kotlin/com/tangem/data/tokens/paging/CoinsPagingSource.kt +++ b/data/tokens/src/main/kotlin/com/tangem/data/tokens/paging/CoinsPagingSource.kt @@ -2,6 +2,7 @@ package com.tangem.data.tokens.paging import androidx.paging.PagingSource import androidx.paging.PagingState +import com.tangem.datasource.api.common.response.getOrThrow import com.tangem.datasource.api.tangemTech.TangemTechApi import com.tangem.datasource.api.tangemTech.models.CoinsResponse import com.tangem.domain.tokens.model.CryptoCurrency @@ -41,7 +42,7 @@ internal class CoinsPagingSource( searchText = searchText, offset = page * params.loadSize, limit = params.loadSize, - ) + ).getOrThrow() }.fold( onSuccess = { response -> val coinsIds = response.coins.map { coin -> diff --git a/data/tokens/src/main/kotlin/com/tangem/data/tokens/repository/DefaultCurrenciesRepository.kt b/data/tokens/src/main/kotlin/com/tangem/data/tokens/repository/DefaultCurrenciesRepository.kt index d927d3e04e..be19a43d03 100644 --- a/data/tokens/src/main/kotlin/com/tangem/data/tokens/repository/DefaultCurrenciesRepository.kt +++ b/data/tokens/src/main/kotlin/com/tangem/data/tokens/repository/DefaultCurrenciesRepository.kt @@ -49,6 +49,7 @@ internal class DefaultCurrenciesRepository( private val cardCurrenciesFactory = CardCryptoCurrenciesFactory(demoConfig) private val userTokensResponseFactory = UserTokensResponseFactory() private val userTokensBackwardCompatibility = UserTokensBackwardCompatibility() + private val customTokensMerger = CustomTokensMerger(tangemTechApi, dispatchers) override suspend fun saveTokens( userWalletId: UserWalletId, @@ -313,6 +314,7 @@ internal class DefaultCurrenciesRepository( ): Boolean { val blockchain = Blockchain.fromId(cryptoCurrencyStatus.currency.network.id.value) val isBitcoinBlockchain = blockchain == Blockchain.Bitcoin || blockchain == Blockchain.BitcoinTestnet + return if (cryptoCurrencyStatus.currency is CryptoCurrency.Coin && isBitcoinBlockchain) { val outgoingTransactions = cryptoCurrencyStatus.value.pendingTransactions.filter { it.isOutgoing } outgoingTransactions.isNotEmpty() @@ -341,40 +343,32 @@ internal class DefaultCurrenciesRepository( private suspend fun fetchTokens(userWallet: UserWallet) { val userWalletId = userWallet.walletId - if (demoConfig.isDemoCardId(userWallet.cardId) && userTokensStore.getSyncOrNull(key = userWalletId) == null) { - userTokensStore.store( - key = userWalletId, - value = userTokensResponseFactory.createUserTokensResponse( - currencies = cardCurrenciesFactory.createDefaultCoinsForMultiCurrencyCard(userWallet.scanResponse), - isGroupedByNetwork = false, - isSortedByBalance = false, - ), - ) - return + val response = if (checkIsEmptyDemoWallet(userWallet)) { + createDefaultUserTokensResponse(userWallet) + } else { + safeApiCall({ tangemTechApi.getUserTokens(userWalletId.stringValue).bind() }) { + handleFetchTokensError(userWallet, it) + } } - val response = safeApiCall( - call = { - tangemTechApi.getUserTokens(userWalletId.stringValue).bind().let { - it.copy(tokens = it.tokens.distinct()) - } - }, - onError = { handleFetchTokensError(userWallet, it) }, - ) + val compatibleUserTokensResponse = response + .let { it.copy(tokens = it.tokens.distinct()) } + .let { customTokensMerger.mergeIfPresented(userWalletId, response) } + .let(userTokensBackwardCompatibility::applyCompatibilityAndGetUpdated) - val compatibleUserTokensResponse = userTokensBackwardCompatibility.applyCompatibilityAndGetUpdated(response) userTokensStore.store(userWallet.walletId, compatibleUserTokensResponse) fetchExchangeableUserMarketCoinsByIds(userWalletId, compatibleUserTokensResponse) } + private suspend fun checkIsEmptyDemoWallet(userWallet: UserWallet): Boolean { + return demoConfig.isDemoCardId(userWallet.cardId) && userTokensStore.getSyncOrNull(userWallet.walletId) == null + } + private suspend fun storeAndPushTokens(userWalletId: UserWalletId, response: UserTokensResponse) { val compatibleUserTokensResponse = userTokensBackwardCompatibility.applyCompatibilityAndGetUpdated(response) userTokensStore.store(userWalletId, compatibleUserTokensResponse) - try { - tangemTechApi.saveUserTokens(userWalletId.stringValue, response) - } catch (e: Throwable) { - Timber.e("Unable to save user tokens for: ${userWalletId.stringValue}") - } + + pushTokens(userWalletId, response) } private suspend fun fetchExchangeableUserMarketCoinsByIds( @@ -407,16 +401,12 @@ internal class DefaultCurrenciesRepository( private suspend fun handleFetchTokensError(userWallet: UserWallet, e: ApiResponseError): UserTokensResponse { val userWalletId = userWallet.walletId val response = userTokensStore.getSyncOrNull(userWalletId) - ?: userTokensResponseFactory.createUserTokensResponse( - currencies = cardCurrenciesFactory.createDefaultCoinsForMultiCurrencyCard(userWallet.scanResponse), - isGroupedByNetwork = false, - isSortedByBalance = false, - ) + ?: createDefaultUserTokensResponse(userWallet) if (e is ApiResponseError.HttpException && e.code == ApiResponseError.HttpException.Code.NOT_FOUND) { Timber.w(e, "Requested currencies could not be found in the remote store for: $userWalletId") - tangemTechApi.saveUserTokens(userWalletId.stringValue, response) + pushTokens(userWalletId, response) } else { cacheRegistry.invalidate(getTokensCacheKey(userWalletId)) } @@ -424,6 +414,19 @@ internal class DefaultCurrenciesRepository( return response } + private suspend fun pushTokens(userWalletId: UserWalletId, response: UserTokensResponse) { + safeApiCall({ tangemTechApi.saveUserTokens(userWalletId.stringValue, response).bind() }) { + Timber.e(it, "Unable to save user tokens for: ${userWalletId.stringValue}") + } + } + + private fun createDefaultUserTokensResponse(userWallet: UserWallet) = + userTokensResponseFactory.createUserTokensResponse( + currencies = cardCurrenciesFactory.createDefaultCoinsForMultiCurrencyCard(userWallet.scanResponse), + isGroupedByNetwork = false, + isSortedByBalance = false, + ) + private suspend fun getUserWallet(userWalletId: UserWalletId): UserWallet { return requireNotNull(userWalletsStore.getSyncOrNull(userWalletId)) { "Unable to find a user wallet with provided ID: $userWalletId" diff --git a/data/tokens/src/main/kotlin/com/tangem/data/tokens/utils/CustomTokensMerger.kt b/data/tokens/src/main/kotlin/com/tangem/data/tokens/utils/CustomTokensMerger.kt new file mode 100644 index 0000000000..aae3c27916 --- /dev/null +++ b/data/tokens/src/main/kotlin/com/tangem/data/tokens/utils/CustomTokensMerger.kt @@ -0,0 +1,95 @@ +package com.tangem.data.tokens.utils + +import com.tangem.data.common.api.safeApiCall +import com.tangem.datasource.api.tangemTech.TangemTechApi +import com.tangem.datasource.api.tangemTech.models.UserTokensResponse +import com.tangem.domain.wallets.models.UserWalletId +import com.tangem.utils.coroutines.CoroutineDispatcherProvider +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.withContext +import timber.log.Timber + +/** + * Responsible for merging custom tokens into a user's token response. + * It handles the logic to update tokens with additional details if necessary. + */ +internal class CustomTokensMerger( + private val tangemTechApi: TangemTechApi, + private val dispatchers: CoroutineDispatcherProvider, +) { + + /** + * Merges custom tokens into the user's token response if needed. + * + * This function processes each token in the response asynchronously, checking if an update + * is needed, and if so, updating the token from [TangemTechApi.getCoins] response. It then pushes to the backend + * and returns an updated UserTokensResponse. + * + * @param userWalletId The identifier for the user's wallet, used when pushing updates. + * @param response The original user tokens response that may need to be updated. + * @return A potentially updated UserTokensResponse, with custom tokens merged if necessary. + */ + suspend fun mergeIfPresented(userWalletId: UserWalletId, response: UserTokensResponse): UserTokensResponse { + val mergedTokens = withContext(dispatchers.default) { + response.tokens + .map { token -> + async { mergeIfPresented(token) } + } + .awaitAll() + } + val updatedResponse = response.copy(tokens = mergedTokens) + + if (response.tokens != updatedResponse.tokens) { + pushTokens(userWalletId, updatedResponse) + } + + return updatedResponse + } + + private suspend fun mergeIfPresented(token: UserTokensResponse.Token): UserTokensResponse.Token { + if (isCoinOrNonCustomToken(token)) return token + + return merge(token) + } + + private suspend fun merge(customToken: UserTokensResponse.Token): UserTokensResponse.Token { + val foundToken = fetchToken(customToken) + + return foundToken ?: customToken + } + + private fun isCoinOrNonCustomToken(token: UserTokensResponse.Token): Boolean { + return token.contractAddress.isNullOrEmpty() || token.id != null + } + + private suspend fun fetchToken(token: UserTokensResponse.Token): UserTokensResponse.Token? { + val response = withContext(dispatchers.io) { + safeApiCall( + call = { + tangemTechApi.getCoins( + contractAddress = token.contractAddress, + networkIds = token.networkId, + ).bind() + }, + onError = { + Timber.w(it, "Unable to fetch token") + null + }, + ) + } + val foundToken = response?.coins?.firstOrNull() ?: return null + + return token.copy( + id = foundToken.id, + name = foundToken.name, + symbol = foundToken.symbol, + ) + } + + private suspend fun pushTokens(userWalletId: UserWalletId, response: UserTokensResponse) { + safeApiCall({ tangemTechApi.saveUserTokens(userWalletId.stringValue, response).bind() }) { + Timber.e(it, "Unable to save user tokens for: $userWalletId") + } + } +} \ No newline at end of file diff --git a/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/repository/TokensListRepository.kt b/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/repository/TokensListRepository.kt index 96db81b6af..992b9b8fcc 100644 --- a/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/repository/TokensListRepository.kt +++ b/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/repository/TokensListRepository.kt @@ -15,6 +15,7 @@ interface TokensListRepository { * * @param searchText The search text used to filter tokens. * @return A [Flow] emitting [PagingData] containing the tokens with quotes matching the search criteria. + * @throws com.tangem.datasource.api.common.response.ApiResponseError */ fun getTokens(searchText: String?): Flow> } \ No newline at end of file diff --git a/features/swap/data/src/main/java/com/tangem/feature/swap/SwapRepositoryImpl.kt b/features/swap/data/src/main/java/com/tangem/feature/swap/SwapRepositoryImpl.kt index 90358d75df..3b4c45e77e 100644 --- a/features/swap/data/src/main/java/com/tangem/feature/swap/SwapRepositoryImpl.kt +++ b/features/swap/data/src/main/java/com/tangem/feature/swap/SwapRepositoryImpl.kt @@ -156,7 +156,7 @@ internal class SwapRepositoryImpl @Inject constructor( exchangeable = true, active = true, networkIds = networkId, - ).coins, + ).getOrThrow().coins, ) } } diff --git a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/SwapRepository.kt b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/SwapRepository.kt index 9f90d4dc3d..417267a7bb 100644 --- a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/SwapRepository.kt +++ b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/SwapRepository.kt @@ -13,6 +13,9 @@ interface SwapRepository { suspend fun getRates(currencyId: String, tokenIds: List): Map + /** + * @throws com.tangem.datasource.api.common.response.ApiResponseError + * */ suspend fun getExchangeableTokens(networkId: String): List suspend fun getExchangeStatus(txId: String): Either