Updated on 2026-08-14

This commit is contained in:
Tangem 2023-12-27 10:43:46 +04:00
parent 8da4dc8205
commit f70d183814
12 changed files with 156 additions and 37 deletions

View file

@ -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) &&

View file

@ -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
}

View file

@ -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(

View file

@ -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<PagingData<Token>>
}

View file

@ -20,7 +20,7 @@ interface TangemTechApi {
@Query("searchText") searchText: String? = null,
@Query("offset") offset: Int? = null,
@Query("limit") limit: Int? = null,
): CoinsResponse
): ApiResponse<CoinsResponse>
@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<UserTokensResponse>
@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<Unit>
/** Returns referral status by [walletId] */
@GET("referral/{walletId}")

View file

@ -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 <T> safeApiCall(call: ApiResponseRaise.() -> T, onError: (ApiResponseError) -> T): T {
suspend inline fun <T> safeApiCall(
crossinline call: suspend ApiResponseRaise.() -> T,
crossinline onError: suspend (ApiResponseError) -> T,
): T {
return recover(
block = { call(ApiResponseRaise(raise = this)) },
recover = {

View file

@ -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 ->

View file

@ -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"

View file

@ -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")
}
}
}

View file

@ -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<PagingData<Token>>
}

View file

@ -156,7 +156,7 @@ internal class SwapRepositoryImpl @Inject constructor(
exchangeable = true,
active = true,
networkIds = networkId,
).coins,
).getOrThrow().coins,
)
}
}

View file

@ -13,6 +13,9 @@ interface SwapRepository {
suspend fun getRates(currencyId: String, tokenIds: List<String>): Map<String, Double>
/**
* @throws com.tangem.datasource.api.common.response.ApiResponseError
* */
suspend fun getExchangeableTokens(networkId: String): List<Currency>
suspend fun getExchangeStatus(txId: String): Either<UnknownError, ExchangeStatusModel>