Updated on 2026-08-14
This commit is contained in:
parent
5db12ad1e6
commit
2f160f54d9
6 changed files with 234 additions and 57 deletions
|
|
@ -16,6 +16,7 @@ import com.tangem.datasource.api.tangemTech.models.UserTokensResponse
|
|||
import com.tangem.datasource.exchangeservice.swap.ExpressServiceLoader
|
||||
import com.tangem.datasource.local.token.UserTokensResponseStore
|
||||
import com.tangem.datasource.local.userwallet.UserWalletsStore
|
||||
import com.tangem.domain.common.CardTypesResolver
|
||||
import com.tangem.domain.common.util.cardTypesResolver
|
||||
import com.tangem.domain.core.error.DataError
|
||||
import com.tangem.domain.demo.DemoConfig
|
||||
|
|
@ -573,6 +574,10 @@ internal class DefaultCurrenciesRepository(
|
|||
)
|
||||
}
|
||||
|
||||
override fun getCardTypesResolver(userWalletId: UserWalletId): CardTypesResolver {
|
||||
return userWalletsStore.getSyncStrict(userWalletId).requireColdWallet().cardTypesResolver
|
||||
}
|
||||
|
||||
private fun getMultiCurrencyWalletCurrencies(userWallet: UserWallet): Flow<List<CryptoCurrency>> {
|
||||
return getSavedUserTokensResponse(userWallet.walletId).map { storedTokens ->
|
||||
responseCryptoCurrenciesFactory.createCurrencies(
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import arrow.atomic.AtomicBoolean
|
|||
import com.tangem.data.common.api.safeApiCall
|
||||
import com.tangem.data.common.currency.UserTokensSaver
|
||||
import com.tangem.datasource.api.tangemTech.TangemTechApi
|
||||
import com.tangem.datasource.api.tangemTech.models.CoinsResponse
|
||||
import com.tangem.datasource.api.tangemTech.models.UserTokensResponse
|
||||
import com.tangem.domain.wallets.models.UserWalletId
|
||||
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
|
||||
|
|
@ -15,11 +16,15 @@ 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.
|
||||
*
|
||||
* @property tangemTechApi Tangem Tech API
|
||||
* @property userTokensSaver user tokens saver
|
||||
* @property dispatchers dispatchers
|
||||
*/
|
||||
internal class CustomTokensMerger(
|
||||
private val tangemTechApi: TangemTechApi,
|
||||
private val dispatchers: CoroutineDispatcherProvider,
|
||||
private val userTokensSaver: UserTokensSaver,
|
||||
private val dispatchers: CoroutineDispatcherProvider,
|
||||
) {
|
||||
|
||||
/**
|
||||
|
|
@ -29,21 +34,17 @@ internal class CustomTokensMerger(
|
|||
* 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.
|
||||
* @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 {
|
||||
// use flag to check: we can't compare two token list after merge because Token equals don't include some fields
|
||||
val wasMerged = AtomicBoolean(false)
|
||||
|
||||
val mergedTokens = withContext(dispatchers.default) {
|
||||
response.tokens
|
||||
.map { token ->
|
||||
async { mergeIfPresented(token, wasMerged) }
|
||||
}
|
||||
.awaitAll()
|
||||
}
|
||||
val mergedTokens = mergeTokens(response = response, wasMerged = wasMerged)
|
||||
|
||||
val updatedResponse = response.copy(tokens = mergedTokens)
|
||||
|
||||
// previously here was used compare response.tokens, but it's not working correctly
|
||||
|
|
@ -55,33 +56,48 @@ internal class CustomTokensMerger(
|
|||
return updatedResponse
|
||||
}
|
||||
|
||||
private suspend fun mergeIfPresented(
|
||||
token: UserTokensResponse.Token,
|
||||
private suspend fun mergeTokens(
|
||||
response: UserTokensResponse,
|
||||
wasMerged: AtomicBoolean,
|
||||
): UserTokensResponse.Token {
|
||||
if (isCoinOrNonCustomToken(token)) return token
|
||||
): List<UserTokensResponse.Token> {
|
||||
return withContext(dispatchers.default) {
|
||||
response.tokens
|
||||
.map { token ->
|
||||
async {
|
||||
if (isCoinOrNonCustomToken(token)) return@async token
|
||||
|
||||
return merge(token, wasMerged)
|
||||
}
|
||||
|
||||
private suspend fun merge(
|
||||
customToken: UserTokensResponse.Token,
|
||||
wasMerged: AtomicBoolean,
|
||||
): UserTokensResponse.Token {
|
||||
val foundToken = fetchToken(customToken)
|
||||
if (foundToken != null) {
|
||||
wasMerged.set(true)
|
||||
mergeToken(customToken = token, wasMerged = wasMerged)
|
||||
}
|
||||
}
|
||||
.awaitAll()
|
||||
}
|
||||
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(
|
||||
private suspend fun mergeToken(
|
||||
customToken: UserTokensResponse.Token,
|
||||
wasMerged: AtomicBoolean,
|
||||
): UserTokensResponse.Token {
|
||||
val foundToken = findToken(token = customToken)
|
||||
|
||||
return if (foundToken != null) {
|
||||
wasMerged.set(true)
|
||||
customToken.mergeWith(foundToken)
|
||||
} else {
|
||||
customToken
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Find a [token] among the cryptocurrencies available for application.
|
||||
* If result is not null, then the token is available and should not be custom.
|
||||
*/
|
||||
private suspend fun findToken(token: UserTokensResponse.Token): CoinsResponse.Coin? {
|
||||
return withContext(dispatchers.io) {
|
||||
val response = safeApiCall(
|
||||
call = {
|
||||
tangemTechApi.getCoins(
|
||||
contractAddress = token.contractAddress,
|
||||
|
|
@ -89,19 +105,20 @@ internal class CustomTokensMerger(
|
|||
).bind()
|
||||
},
|
||||
onError = {
|
||||
Timber.w(it, "Unable to fetch token")
|
||||
Timber.e(it, "Unable to fetch token:\n$token")
|
||||
null
|
||||
},
|
||||
)
|
||||
}
|
||||
val foundToken = response?.coins?.firstOrNull() ?: return null
|
||||
|
||||
return token.copy(
|
||||
id = foundToken.id,
|
||||
name = foundToken.name,
|
||||
symbol = foundToken.symbol.ifEmpty {
|
||||
token.symbol
|
||||
},
|
||||
response?.coins?.firstOrNull()
|
||||
}
|
||||
}
|
||||
|
||||
private fun UserTokensResponse.Token.mergeWith(coin: CoinsResponse.Coin): UserTokensResponse.Token {
|
||||
return copy(
|
||||
id = coin.id,
|
||||
name = coin.name,
|
||||
symbol = coin.symbol.ifEmpty { symbol },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -1,18 +0,0 @@
|
|||
package com.tangem.data.tokens.utils
|
||||
|
||||
import com.tangem.datasource.api.tangemTech.models.CoinsResponse
|
||||
import com.tangem.domain.tokens.model.FoundToken
|
||||
import com.tangem.utils.converter.Converter
|
||||
|
||||
internal object FoundTokenConverter : Converter<CoinsResponse.Coin, FoundToken> {
|
||||
|
||||
override fun convert(value: CoinsResponse.Coin): FoundToken {
|
||||
return FoundToken(
|
||||
id = value.id,
|
||||
name = value.name,
|
||||
symbol = value.symbol,
|
||||
contractAddress = requireNotNull(value.networks.first().contractAddress),
|
||||
decimals = requireNotNull(value.networks.first().decimalCount).intValueExact(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,165 @@
|
|||
package com.tangem.data.tokens.utils
|
||||
|
||||
import com.google.common.truth.Truth
|
||||
import com.tangem.common.test.domain.token.MockCryptoCurrencyFactory
|
||||
import com.tangem.data.common.currency.UserTokensResponseFactory
|
||||
import com.tangem.data.common.currency.UserTokensSaver
|
||||
import com.tangem.datasource.api.common.response.ApiResponse
|
||||
import com.tangem.datasource.api.common.response.ApiResponseError
|
||||
import com.tangem.datasource.api.tangemTech.TangemTechApi
|
||||
import com.tangem.datasource.api.tangemTech.models.CoinsResponse
|
||||
import com.tangem.datasource.api.tangemTech.models.UserTokensResponse
|
||||
import com.tangem.domain.wallets.models.UserWalletId
|
||||
import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider
|
||||
import io.mockk.*
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.jupiter.api.BeforeEach
|
||||
import org.junit.jupiter.api.Test
|
||||
import org.junit.jupiter.api.TestInstance
|
||||
|
||||
/**
|
||||
[REDACTED_AUTHOR]
|
||||
*/
|
||||
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
|
||||
internal class CustomTokensMergerTest {
|
||||
|
||||
private val tangemTechApi: TangemTechApi = mockk()
|
||||
private val userTokensSaver: UserTokensSaver = mockk(relaxUnitFun = true)
|
||||
private val merger = CustomTokensMerger(
|
||||
tangemTechApi = tangemTechApi,
|
||||
userTokensSaver = userTokensSaver,
|
||||
dispatchers = TestingCoroutineDispatcherProvider(),
|
||||
)
|
||||
|
||||
@BeforeEach
|
||||
fun resetMocks() {
|
||||
clearMocks(tangemTechApi, userTokensSaver)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `mergeIfPresented successfully if UserTokensResponse DOESN'T CONTAIN custom tokens`() = runTest {
|
||||
// Act
|
||||
val actual = merger.mergeIfPresented(userWalletId = userWalletId, response = defaultUserTokensResponse)
|
||||
|
||||
// Assert
|
||||
val expected = defaultUserTokensResponse
|
||||
Truth.assertThat(actual).isEqualTo(expected)
|
||||
|
||||
coVerify(inverse = true) {
|
||||
tangemTechApi.getCoins(contractAddress = any(), networkIds = any())
|
||||
userTokensSaver.push(any(), any())
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `mergeIfPresented successfully if UserTokensResponse CONTAINS custom tokens`() = runTest {
|
||||
// Arrange
|
||||
val customToken = UserTokensResponse.Token(
|
||||
id = null,
|
||||
networkId = "cum",
|
||||
name = "Matilda Rich",
|
||||
symbol = "consectetuer",
|
||||
decimals = 9582,
|
||||
contractAddress = "contractAddress",
|
||||
)
|
||||
|
||||
val userTokensResponse = defaultUserTokensResponse.copy(
|
||||
tokens = defaultUserTokensResponse.tokens + customToken,
|
||||
)
|
||||
|
||||
coEvery {
|
||||
tangemTechApi.getCoins(contractAddress = customToken.contractAddress, networkIds = customToken.networkId)
|
||||
} returns ApiResponse.Success(data = createCoinResponse(token = customToken))
|
||||
|
||||
// Act
|
||||
val actual = merger.mergeIfPresented(userWalletId = userWalletId, response = userTokensResponse)
|
||||
|
||||
// Assert
|
||||
val updatedCustomToken = customToken.copy(
|
||||
id = "NEW_${customToken.id}",
|
||||
name = "NEW_${customToken.name}",
|
||||
symbol = "NEW_${customToken.symbol}",
|
||||
)
|
||||
|
||||
val expected = defaultUserTokensResponse.copy(
|
||||
tokens = defaultUserTokensResponse.tokens + updatedCustomToken,
|
||||
)
|
||||
|
||||
Truth.assertThat(actual).isEqualTo(expected)
|
||||
|
||||
coVerifyOrder {
|
||||
tangemTechApi.getCoins(
|
||||
contractAddress = customToken.contractAddress,
|
||||
networkIds = customToken.networkId,
|
||||
)
|
||||
|
||||
userTokensSaver.push(userWalletId = userWalletId, response = expected)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `mergeIfPresented successfully if API returns error`() = runTest {
|
||||
// Arrange
|
||||
val customToken = UserTokensResponse.Token(
|
||||
id = null,
|
||||
networkId = "cum",
|
||||
name = "Matilda Rich",
|
||||
symbol = "consectetuer",
|
||||
decimals = 9582,
|
||||
contractAddress = "contractAddress",
|
||||
)
|
||||
|
||||
val userTokensResponse = defaultUserTokensResponse.copy(
|
||||
tokens = defaultUserTokensResponse.tokens + customToken,
|
||||
)
|
||||
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
val apiResponse = ApiResponse.Error(ApiResponseError.TimeoutException) as ApiResponse<CoinsResponse>
|
||||
|
||||
coEvery {
|
||||
tangemTechApi.getCoins(contractAddress = customToken.contractAddress, networkIds = customToken.networkId)
|
||||
} returns apiResponse
|
||||
|
||||
// Act
|
||||
val actual = merger.mergeIfPresented(userWalletId = userWalletId, response = userTokensResponse)
|
||||
|
||||
// Assert
|
||||
val expected = userTokensResponse
|
||||
Truth.assertThat(actual).isEqualTo(expected)
|
||||
|
||||
coVerify {
|
||||
tangemTechApi.getCoins(
|
||||
contractAddress = customToken.contractAddress,
|
||||
networkIds = customToken.networkId,
|
||||
)
|
||||
}
|
||||
|
||||
coVerify(inverse = true) { userTokensSaver.push(userWalletId = any(), response = any()) }
|
||||
}
|
||||
|
||||
private companion object {
|
||||
|
||||
val userWalletId = UserWalletId("011")
|
||||
|
||||
val defaultUserTokensResponse = UserTokensResponseFactory().createUserTokensResponse(
|
||||
currencies = MockCryptoCurrencyFactory().ethereumAndStellar,
|
||||
isGroupedByNetwork = false,
|
||||
isSortedByBalance = false,
|
||||
)
|
||||
|
||||
fun createCoinResponse(token: UserTokensResponse.Token): CoinsResponse {
|
||||
return CoinsResponse(
|
||||
imageHost = null,
|
||||
coins = listOf(
|
||||
CoinsResponse.Coin(
|
||||
id = "NEW_${token.id}",
|
||||
name = "NEW_${token.name}",
|
||||
symbol = "NEW_${token.symbol}",
|
||||
active = true,
|
||||
),
|
||||
),
|
||||
total = 1,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,5 +1,6 @@
|
|||
package com.tangem.domain.tokens.repository
|
||||
|
||||
import com.tangem.domain.common.CardTypesResolver
|
||||
import com.tangem.domain.core.error.DataError
|
||||
import com.tangem.domain.models.currency.CryptoCurrency
|
||||
import com.tangem.domain.models.network.Network
|
||||
|
|
@ -8,7 +9,6 @@ import com.tangem.domain.tokens.model.FeePaidCurrency
|
|||
import com.tangem.domain.wallets.models.UserWallet
|
||||
import com.tangem.domain.wallets.models.UserWalletId
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlin.jvm.Throws
|
||||
|
||||
/**
|
||||
* Repository for everything related to the tokens of user wallet
|
||||
|
|
@ -263,4 +263,7 @@ interface CurrenciesRepository {
|
|||
*/
|
||||
@Throws
|
||||
suspend fun syncTokens(userWalletId: UserWalletId)
|
||||
|
||||
@Throws
|
||||
fun getCardTypesResolver(userWalletId: UserWalletId): CardTypesResolver
|
||||
}
|
||||
|
|
@ -2,6 +2,7 @@ package com.tangem.domain.tokens.repository
|
|||
|
||||
import arrow.core.Either
|
||||
import arrow.core.getOrElse
|
||||
import com.tangem.domain.common.CardTypesResolver
|
||||
import com.tangem.domain.core.error.DataError
|
||||
import com.tangem.domain.models.currency.CryptoCurrency
|
||||
import com.tangem.domain.models.network.Network
|
||||
|
|
@ -166,4 +167,8 @@ internal class MockCurrenciesRepository(
|
|||
override suspend fun syncTokens(userWalletId: UserWalletId) {
|
||||
return Unit
|
||||
}
|
||||
|
||||
override fun getCardTypesResolver(userWalletId: UserWalletId): CardTypesResolver {
|
||||
error("No-op")
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue