Updated on 2026-08-14

This commit is contained in:
Tangem 2025-05-21 16:23:13 +05:00
parent 47a323859a
commit 750a696a84
17 changed files with 636 additions and 200 deletions

View file

@ -0,0 +1,64 @@
package com.tangem.data.common.currency
import com.tangem.datasource.api.tangemTech.models.UserTokensResponse
import com.tangem.domain.networks.multi.MultiNetworkStatusProducer
import com.tangem.domain.networks.multi.MultiNetworkStatusSupplier
import com.tangem.domain.notifications.toggles.NotificationsFeatureToggles
import com.tangem.domain.tokens.model.NetworkStatus
import com.tangem.domain.wallets.models.UserWalletId
import com.tangem.domain.wallets.repository.WalletsRepository
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.withContext
import javax.inject.Inject
class UserTokensResponseAddressesEnricher @Inject constructor(
private val notificationsFeatureToggles: NotificationsFeatureToggles,
private val walletsRepository: WalletsRepository,
private val dispatchers: CoroutineDispatcherProvider,
private val multiNetworkStatusSupplier: MultiNetworkStatusSupplier,
) {
suspend operator fun invoke(userWalletId: UserWalletId, response: UserTokensResponse): UserTokensResponse {
if (!notificationsFeatureToggles.isNotificationsEnabled) {
return response
}
val isNotificationsEnabled = walletsRepository.isNotificationsEnabled(userWalletId)
return withContext(dispatchers.default) {
val networksStatuses = if (isNotificationsEnabled) {
multiNetworkStatusSupplier.invoke(MultiNetworkStatusProducer.Params(userWalletId)).first()
} else {
emptySet()
}
val enrichedTokens = response.tokens.map { token ->
if (isNotificationsEnabled) {
val matchingNetwork = networksStatuses.find { status ->
status.network.backendId == token.networkId &&
status.network.derivationPath.value == token.derivationPath
} ?: return@map token
val networkAddress = when (matchingNetwork.value) {
is NetworkStatus.Verified -> (matchingNetwork.value as NetworkStatus.Verified).address
is NetworkStatus.NoAccount -> (matchingNetwork.value as NetworkStatus.NoAccount).address
else -> null
}
val addresses = networkAddress
?.availableAddresses
?.map { it.value }
?.toList()
.orEmpty()
token.copy(addresses = addresses)
} else {
token.copy(addresses = emptyList())
}
}
response.copy(tokens = enrichedTokens, notifyStatus = isNotificationsEnabled)
}
}
}

View file

@ -0,0 +1,63 @@
package com.tangem.data.common.currency
import com.tangem.data.common.api.safeApiCall
import com.tangem.data.common.tokens.UserTokensBackwardCompatibility
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.storeObject
import com.tangem.domain.wallets.models.UserWalletId
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import kotlinx.coroutines.withContext
import timber.log.Timber
class UserTokensSaver constructor(
private val tangemTechApi: TangemTechApi,
private val appPreferencesStore: AppPreferencesStore,
private val dispatchers: CoroutineDispatcherProvider,
private val userTokensResponseAddressesEnricher: UserTokensResponseAddressesEnricher,
) {
private val userTokensBackwardCompatibility = UserTokensBackwardCompatibility()
suspend fun store(userWalletId: UserWalletId, response: UserTokensResponse, useEnricher: Boolean = true) =
withContext(dispatchers.io) {
val compatibleUserTokensResponse = userTokensBackwardCompatibility.applyCompatibilityAndGetUpdated(response)
val enrichedUserTokensResponse = if (useEnricher) {
userTokensResponseAddressesEnricher(
userWalletId = userWalletId,
response = compatibleUserTokensResponse,
)
} else {
compatibleUserTokensResponse
}
appPreferencesStore.storeObject(
key = PreferencesKeys.getUserTokensKey(userWalletId = userWalletId.stringValue),
value = enrichedUserTokensResponse,
)
}
suspend fun storeAndPush(userWalletId: UserWalletId, response: UserTokensResponse) {
val enrichedUserTokensResponse = userTokensResponseAddressesEnricher(
userWalletId = userWalletId,
response = response,
)
store(userWalletId, enrichedUserTokensResponse, false)
push(userWalletId, enrichedUserTokensResponse, false)
}
suspend fun push(userWalletId: UserWalletId, response: UserTokensResponse, useEnricher: Boolean = true) =
withContext(dispatchers.io) {
val enrichedUserTokensResponse = if (useEnricher) {
userTokensResponseAddressesEnricher(
userWalletId = userWalletId,
response = response,
)
} else {
response
}
safeApiCall({ tangemTechApi.saveUserTokens(userWalletId.stringValue, enrichedUserTokensResponse).bind() }) {
Timber.e(it, "Unable to push user tokens for: ${userWalletId.stringValue}")
}
}
}

View file

@ -3,9 +3,17 @@ package com.tangem.data.common.di
import com.tangem.blockchainsdk.utils.ExcludedBlockchains
import com.tangem.data.common.currency.CardCryptoCurrencyFactory
import com.tangem.data.common.currency.DefaultCardCryptoCurrencyFactory
import com.tangem.data.common.currency.UserTokensResponseAddressesEnricher
import com.tangem.data.common.currency.UserTokensSaver
import com.tangem.datasource.api.tangemTech.TangemTechApi
import com.tangem.datasource.local.preferences.AppPreferencesStore
import com.tangem.datasource.local.token.UserTokensResponseStore
import com.tangem.datasource.local.userwallet.UserWalletsStore
import com.tangem.domain.demo.DemoConfig
import com.tangem.domain.networks.multi.MultiNetworkStatusSupplier
import com.tangem.domain.notifications.toggles.NotificationsFeatureToggles
import com.tangem.domain.wallets.repository.WalletsRepository
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
@ -30,4 +38,36 @@ internal object DataCommonModule {
userTokensResponseStore = userTokensResponseStore,
)
}
@Provides
@Singleton
fun provideUserTokensEncricher(
notificationsFeatureToggles: NotificationsFeatureToggles,
walletsRepository: WalletsRepository,
multiNetworkStatusSupplier: MultiNetworkStatusSupplier,
dispatchers: CoroutineDispatcherProvider,
): UserTokensResponseAddressesEnricher {
return UserTokensResponseAddressesEnricher(
notificationsFeatureToggles = notificationsFeatureToggles,
walletsRepository = walletsRepository,
multiNetworkStatusSupplier = multiNetworkStatusSupplier,
dispatchers = dispatchers,
)
}
@Provides
@Singleton
fun provideUserTokensSaver(
tangemTechApi: TangemTechApi,
appPreferencesStore: AppPreferencesStore,
dispatchers: CoroutineDispatcherProvider,
enricher: UserTokensResponseAddressesEnricher,
): UserTokensSaver {
return UserTokensSaver(
tangemTechApi = tangemTechApi,
appPreferencesStore = appPreferencesStore,
dispatchers = dispatchers,
userTokensResponseAddressesEnricher = enricher,
)
}
}

View file

@ -0,0 +1,37 @@
package com.tangem.data.common.tokens
import com.tangem.blockchain.common.Blockchain
import com.tangem.blockchainsdk.utils.fromNetworkId
import com.tangem.blockchainsdk.utils.toCoinId
import com.tangem.datasource.api.tangemTech.models.UserTokensResponse
/**
* Helper to apply compatibility changes for [UserTokensResponse] to support old saved tokens
* in new application with new IDs
*/
internal class UserTokensBackwardCompatibility {
fun applyCompatibilityAndGetUpdated(userTokensResponse: UserTokensResponse): UserTokensResponse {
return userTokensResponse.copy(
tokens = userTokensResponse.tokens.map { token ->
val oldSavedId = NETWORKS_TO_OLD_SAVED_IDS[token.networkId]
if (oldSavedId != null && token.id == oldSavedId) {
Blockchain.fromNetworkId(token.networkId)?.let { blockchain ->
token.copy(
id = blockchain.toCoinId(),
)
} ?: token
} else {
token
}
},
)
}
companion object {
private val NETWORKS_TO_OLD_SAVED_IDS = mapOf(
"optimistic-ethereum" to "ethereum",
"arbitrum-one" to "ethereum",
)
}
}

View file

@ -0,0 +1,219 @@
package com.tangem.data.common.currency
import com.tangem.datasource.api.tangemTech.models.UserTokensResponse
import com.tangem.domain.networks.multi.MultiNetworkStatusSupplier
import com.tangem.domain.notifications.toggles.NotificationsFeatureToggles
import com.tangem.domain.tokens.model.NetworkStatus
import com.tangem.domain.wallets.models.UserWalletId
import com.tangem.domain.wallets.repository.WalletsRepository
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import io.mockk.coEvery
import io.mockk.every
import io.mockk.mockk
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.test.runTest
import com.google.common.truth.Truth.assertThat
import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider
import com.tangem.domain.models.StatusSource
import com.tangem.domain.tokens.model.NetworkAddress
import io.mockk.clearAllMocks
import org.junit.After
import org.junit.Before
import org.junit.Test
class UserTokensResponseAddressesEnricherTest {
private lateinit var notificationsFeatureToggles: NotificationsFeatureToggles
private lateinit var walletsRepository: WalletsRepository
private val dispatchers: CoroutineDispatcherProvider = TestingCoroutineDispatcherProvider()
private lateinit var multiNetworkStatusSupplier: MultiNetworkStatusSupplier
private lateinit var enricher: UserTokensResponseAddressesEnricher
@Before
fun setup() {
notificationsFeatureToggles = mockk()
walletsRepository = mockk()
multiNetworkStatusSupplier = mockk()
enricher = UserTokensResponseAddressesEnricher(
notificationsFeatureToggles = notificationsFeatureToggles,
walletsRepository = walletsRepository,
dispatchers = dispatchers,
multiNetworkStatusSupplier = multiNetworkStatusSupplier,
)
}
@After
fun tearDown() {
clearAllMocks()
}
@Test
fun `GIVEN notifications are disabled globally WHEN invoke THEN return original response`() = runTest {
// GIVEN
val userWalletId = UserWalletId("1234567890abcdef")
val token = createToken()
val response = createUserTokensResponse(tokens = listOf(token))
every { notificationsFeatureToggles.isNotificationsEnabled } returns false
// WHEN
val result = enricher(userWalletId, response)
// THEN
assertThat(result).isEqualTo(response)
}
@Test
fun `GIVEN notifications are disabled for wallet WHEN invoke THEN return response with empty addresses`() =
runTest {
// GIVEN
val userWalletId = UserWalletId("1234567890abcdef")
val token = createToken()
val response = createUserTokensResponse(tokens = listOf(token))
every { notificationsFeatureToggles.isNotificationsEnabled } returns true
coEvery { walletsRepository.isNotificationsEnabled(userWalletId) } returns false
coEvery {
multiNetworkStatusSupplier.invoke(any())
} returns flowOf(
setOf(
NetworkStatus(
network = mockk {
every { backendId } returns "ethereum"
every { derivationPath.value } returns "m/44'/60'/0'/0/0"
},
value = NetworkStatus.Verified(
address = mockk {
every { availableAddresses } returns emptySet()
},
amounts = emptyMap(),
pendingTransactions = emptyMap(),
source = StatusSource.ACTUAL,
),
),
),
)
// WHEN
val result = enricher(userWalletId, response)
// THEN
assertThat(result.tokens).hasSize(1)
assertThat(result.tokens[0].addresses).isEmpty()
}
@Test
fun `GIVEN notifications are enabled and addresses available WHEN invoke THEN return enriched response`() =
runTest {
// GIVEN
val userWalletId = UserWalletId("1234567890abcdef")
val token = createToken()
val response = createUserTokensResponse(tokens = listOf(token))
val addresses = listOf("0x123", "0x456")
every { notificationsFeatureToggles.isNotificationsEnabled } returns true
coEvery { walletsRepository.isNotificationsEnabled(userWalletId) } returns true
coEvery {
multiNetworkStatusSupplier.invoke(any())
} returns flowOf(
setOf(
NetworkStatus(
network = mockk {
every { backendId } returns "ethereum"
every { derivationPath.value } returns "m/44'/60'/0'/0/0"
},
value = NetworkStatus.Verified(
address = mockk {
every { availableAddresses } returns addresses.map { address ->
mockk<NetworkAddress.Address> {
every { value } returns address
}
}.toSet()
},
amounts = emptyMap(),
pendingTransactions = emptyMap(),
source = StatusSource.ACTUAL,
),
),
),
)
// WHEN
val result = enricher(userWalletId, response)
// THEN
assertThat(result.tokens).hasSize(1)
assertThat(result.tokens[0].addresses).containsExactlyElementsIn(addresses)
}
@Test
fun `GIVEN notifications are enabled but no matching network WHEN invoke THEN return original token`() = runTest {
// GIVEN
val userWalletId = UserWalletId("1234567890abcdef")
val token = createToken()
val response = createUserTokensResponse(tokens = listOf(token))
every { notificationsFeatureToggles.isNotificationsEnabled } returns true
coEvery { walletsRepository.isNotificationsEnabled(userWalletId) } returns true
coEvery {
multiNetworkStatusSupplier.invoke(any())
} returns flowOf(
setOf(
NetworkStatus(
network = mockk {
every { backendId } returns "bitcoin"
every { derivationPath.value } returns "m/44'/0'/0'/0/0"
},
value = NetworkStatus.Verified(
address = mockk {
every { availableAddresses } returns emptySet()
},
amounts = emptyMap(),
pendingTransactions = emptyMap(),
source = StatusSource.ACTUAL,
),
),
),
)
// WHEN
val result = enricher(userWalletId, response)
// THEN
assertThat(result.tokens).hasSize(1)
assertThat(result.tokens[0]).isEqualTo(token)
}
private fun createToken(
networkId: String = "ethereum",
derivationPath: String = "m/44'/60'/0'/0/0",
list: List<String> = emptyList(),
name: String = "Ethereum",
symbol: String = "ETH",
decimals: Int = 18,
contractAddress: String? = null,
id: String? = null,
) = UserTokensResponse.Token(
id = id,
networkId = networkId,
derivationPath = derivationPath,
name = name,
symbol = symbol,
decimals = decimals,
contractAddress = contractAddress,
addresses = list,
)
private fun createUserTokensResponse(
tokens: List<UserTokensResponse.Token> = emptyList(),
version: Int = 0,
group: UserTokensResponse.GroupType = UserTokensResponse.GroupType.NETWORK,
sort: UserTokensResponse.SortType = UserTokensResponse.SortType.BALANCE,
notifyStatus: Boolean? = null,
) = UserTokensResponse(
version = version,
group = group,
sort = sort,
notifyStatus = notifyStatus,
tokens = tokens,
)
}

View file

@ -0,0 +1,175 @@
package com.tangem.data.common.currency
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import com.squareup.moshi.Moshi
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.UserTokensResponse
import com.tangem.datasource.local.preferences.AppPreferencesStore
import com.tangem.domain.wallets.models.UserWalletId
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider
import io.mockk.*
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.test.runTest
import org.junit.After
import org.junit.Before
import org.junit.Test
class UserTokensSaverTest {
private val tangemTechApi: TangemTechApi = mockk()
private val dataStore: DataStore<Preferences> = mockk(relaxed = true)
private val appPreferenceStore = AppPreferencesStore(
moshi = Moshi.Builder().build(),
dispatchers = TestingCoroutineDispatcherProvider(),
preferencesDataStore = dataStore,
)
private val dispatchers: CoroutineDispatcherProvider = TestingCoroutineDispatcherProvider()
private val userTokensResponseAddressesEnricher: UserTokensResponseAddressesEnricher = mockk()
private lateinit var userTokensSaver: UserTokensSaver
@Before
fun setup() {
coEvery { dataStore.data } returns flowOf(mockk(relaxed = true))
userTokensSaver = UserTokensSaver(
tangemTechApi = tangemTechApi,
appPreferencesStore = appPreferenceStore,
dispatchers = dispatchers,
userTokensResponseAddressesEnricher = userTokensResponseAddressesEnricher,
)
}
@After
fun tearDown() {
clearAllMocks()
}
@Test
fun `GIVEN user wallet id and response WHEN store THEN should store enriched response`() = runTest {
// GIVEN
val userWalletId = UserWalletId("1234567890abcdef")
val response = UserTokensResponse(
version = 0,
group = UserTokensResponse.GroupType.NETWORK,
sort = UserTokensResponse.SortType.BALANCE,
tokens = emptyList(),
)
val enrichedResponse = UserTokensResponse(
version = 0,
group = UserTokensResponse.GroupType.NETWORK,
sort = UserTokensResponse.SortType.BALANCE,
tokens = emptyList(),
)
coEvery { userTokensResponseAddressesEnricher(userWalletId, response) } returns enrichedResponse
coEvery { dataStore.updateData(any()) } returns mockk(relaxed = true)
// WHEN
userTokensSaver.store(userWalletId, response)
// THEN
coVerify {
dataStore.updateData(any())
}
coVerify(exactly = 0) {
tangemTechApi.saveUserTokens(any(), any())
}
}
@Test
fun `GIVEN user wallet id and response WHEN storeAndPush THEN should store and push enriched response`() = runTest {
// GIVEN
val userWalletId = UserWalletId("1234567890abcdef")
val response = UserTokensResponse(
version = 0,
group = UserTokensResponse.GroupType.NETWORK,
sort = UserTokensResponse.SortType.BALANCE,
tokens = emptyList(),
)
val enrichedResponse = UserTokensResponse(
version = 0,
group = UserTokensResponse.GroupType.NETWORK,
sort = UserTokensResponse.SortType.BALANCE,
tokens = emptyList(),
)
coEvery { userTokensResponseAddressesEnricher(userWalletId, response) } returns enrichedResponse
coEvery { dataStore.updateData(any()) } returns mockk(relaxed = true)
coEvery { tangemTechApi.saveUserTokens(any(), any()) } returns ApiResponse.Success(Unit)
// WHEN
userTokensSaver.storeAndPush(userWalletId, response)
// THEN
coVerify {
dataStore.updateData(any())
tangemTechApi.saveUserTokens(userWalletId.stringValue, enrichedResponse)
}
}
@Test
fun `GIVEN user wallet id and response WHEN push THEN should push enriched response`() = runTest {
// GIVEN
val userWalletId = UserWalletId("1234567890abcdef")
val response = UserTokensResponse(
version = 0,
group = UserTokensResponse.GroupType.NETWORK,
sort = UserTokensResponse.SortType.BALANCE,
tokens = emptyList(),
)
val enrichedResponse = UserTokensResponse(
version = 0,
group = UserTokensResponse.GroupType.NETWORK,
sort = UserTokensResponse.SortType.BALANCE,
tokens = emptyList(),
)
coEvery { userTokensResponseAddressesEnricher(userWalletId, response) } returns enrichedResponse
coEvery { tangemTechApi.saveUserTokens(any(), any()) } returns ApiResponse.Success(Unit)
// WHEN
userTokensSaver.push(userWalletId, response)
// THEN
coVerify {
tangemTechApi.saveUserTokens(userWalletId.stringValue, enrichedResponse)
}
coVerify(exactly = 0) {
dataStore.updateData(any())
}
}
@Test
fun `GIVEN user wallet id and response WHEN push AND api call fails THEN should log error`() = runTest {
// GIVEN
val userWalletId = UserWalletId("1234567890abcdef")
val response = UserTokensResponse(
version = 0,
group = UserTokensResponse.GroupType.NETWORK,
sort = UserTokensResponse.SortType.BALANCE,
tokens = emptyList(),
)
val enrichedResponse = UserTokensResponse(
version = 0,
group = UserTokensResponse.GroupType.NETWORK,
sort = UserTokensResponse.SortType.BALANCE,
tokens = emptyList(),
)
val error = ApiResponseError.UnknownException(Exception("API Error"))
coEvery { userTokensResponseAddressesEnricher(userWalletId, response) } returns enrichedResponse
coEvery { tangemTechApi.saveUserTokens(any(), any()) } returns
ApiResponse.Error(error) as ApiResponse<Unit>
// WHEN
userTokensSaver.push(userWalletId, response)
// THEN
coVerify {
tangemTechApi.saveUserTokens(userWalletId.stringValue, enrichedResponse)
}
coVerify(exactly = 0) {
dataStore.updateData(any())
}
}
}