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

@ -22,7 +22,7 @@ data class UserTokensResponse(
@Json(name = "symbol") val symbol: String,
@Json(name = "decimals") val decimals: Int,
@Json(name = "contractAddress") val contractAddress: String?,
@Json(name = "addresses") val list: List<String>? = null,
@Json(name = "addresses") val addresses: List<String>? = null,
) {
override fun equals(other: Any?): Boolean {
val otherToken = other as? Token ?: return false

View file

@ -16,6 +16,7 @@ tasks.withType<Test>().configureEach {
dependencies {
/* Core */
implementation(projects.core.datasource)
implementation(projects.core.utils)
/* Domain */
implementation(projects.domain.demo)
@ -23,6 +24,9 @@ dependencies {
implementation(projects.domain.models)
implementation(projects.domain.tokens.models)
implementation(projects.domain.wallets.models)
implementation(projects.domain.notifications.toggles)
implementation(projects.domain.networks)
implementation(projects.domain.wallets)
/* Libs - SDK */
implementation(tangemDeps.blockchain)
@ -47,4 +51,5 @@ dependencies {
testRuntimeOnly(deps.test.junit5.engine)
testImplementation(deps.test.mockk)
testImplementation(deps.test.truth)
testImplementation(deps.moshi)
}

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

@ -1,4 +1,4 @@
package com.tangem.data.tokens.utils
package com.tangem.data.common.tokens
import com.tangem.blockchain.common.Blockchain
import com.tangem.blockchainsdk.utils.fromNetworkId
@ -9,7 +9,7 @@ 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
*/
class UserTokensBackwardCompatibility {
internal class UserTokensBackwardCompatibility {
fun applyCompatibilityAndGetUpdated(userTokensResponse: UserTokensResponse): UserTokensResponse {
return userTokensResponse.copy(

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())
}
}
}

View file

@ -4,19 +4,17 @@ import com.tangem.blockchain.common.Blockchain
import com.tangem.blockchainsdk.utils.ExcludedBlockchains
import com.tangem.blockchainsdk.utils.toNetworkId
import com.tangem.crypto.hdWallet.DerivationPath
import com.tangem.data.common.api.safeApiCall
import com.tangem.data.common.currency.CryptoCurrencyFactory
import com.tangem.data.common.currency.UserTokensResponseFactory
import com.tangem.data.common.currency.UserTokensSaver
import com.tangem.data.common.currency.getNetwork
import com.tangem.data.managetokens.utils.TokenAddressesConverter
import com.tangem.data.tokens.utils.UserTokensBackwardCompatibility
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.preferences.utils.storeObject
import com.tangem.datasource.local.userwallet.UserWalletsStore
import com.tangem.domain.common.extensions.canHandleBlockchain
import com.tangem.domain.common.extensions.supportedBlockchains
@ -30,8 +28,8 @@ import com.tangem.domain.walletmanager.WalletManagersFacade
import com.tangem.domain.wallets.models.UserWalletId
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import kotlinx.coroutines.withContext
import timber.log.Timber
@Suppress("LongParameterList")
internal class DefaultCustomTokensRepository(
private val tangemTechApi: TangemTechApi,
private val userWalletsStore: UserWalletsStore,
@ -39,12 +37,12 @@ internal class DefaultCustomTokensRepository(
private val walletManagersFacade: WalletManagersFacade,
private val excludedBlockchains: ExcludedBlockchains,
private val dispatchers: CoroutineDispatcherProvider,
private val userTokensSaver: UserTokensSaver,
) : CustomTokensRepository {
private val cryptoCurrencyFactory = CryptoCurrencyFactory(excludedBlockchains)
private val userTokensResponseFactory = UserTokensResponseFactory()
private val tokenAddressConverter = TokenAddressesConverter()
private val userTokensBackwardCompatibility = UserTokensBackwardCompatibility()
override suspend fun validateContractAddress(contractAddress: String, networkId: Network.ID): Boolean =
withContext(dispatchers.io) {
@ -223,7 +221,7 @@ internal class DefaultCustomTokensRepository(
}
val token = userTokensResponseFactory.createResponseToken(cryptoCurrency)
storeAndPushTokens(
userTokensSaver.storeAndPush(
userWalletId = userWalletId,
response = storedCurrencies.copy(tokens = storedCurrencies.tokens.filterNot { it == token }),
)
@ -267,20 +265,4 @@ internal class DefaultCustomTokensRepository(
value = sdkPath.rawPath,
)
}
private suspend fun storeAndPushTokens(userWalletId: UserWalletId, response: UserTokensResponse) {
val compatibleUserTokensResponse = userTokensBackwardCompatibility.applyCompatibilityAndGetUpdated(response)
appPreferencesStore.storeObject(
key = PreferencesKeys.getUserTokensKey(userWalletId = userWalletId.stringValue),
value = compatibleUserTokensResponse,
)
pushTokens(userWalletId, response)
}
private suspend fun pushTokens(userWalletId: UserWalletId, response: UserTokensResponse) {
safeApiCall({ tangemTechApi.saveUserTokens(userWalletId.stringValue, response).bind() }) {
Timber.e(it, "Unable to push user tokens for: ${userWalletId.stringValue}")
}
}
}

View file

@ -2,6 +2,7 @@ package com.tangem.data.managetokens.di
import com.tangem.blockchainsdk.utils.ExcludedBlockchains
import com.tangem.data.common.currency.CardCryptoCurrencyFactory
import com.tangem.data.common.currency.UserTokensSaver
import com.tangem.data.managetokens.DefaultCustomTokensRepository
import com.tangem.data.managetokens.DefaultManageTokensRepository
import com.tangem.data.managetokens.utils.ManageTokensUpdateFetcher
@ -56,14 +57,16 @@ internal object ManageTokensDataModule {
walletManagersFacade: WalletManagersFacade,
dispatchers: CoroutineDispatcherProvider,
excludedBlockchains: ExcludedBlockchains,
userTokensSaver: UserTokensSaver,
): CustomTokensRepository {
return DefaultCustomTokensRepository(
tangemTechApi,
userWalletsStore,
appPreferencesStore,
walletManagersFacade,
excludedBlockchains,
dispatchers,
tangemTechApi = tangemTechApi,
userWalletsStore = userWalletsStore,
appPreferencesStore = appPreferencesStore,
walletManagersFacade = walletManagersFacade,
excludedBlockchains = excludedBlockchains,
dispatchers = dispatchers,
userTokensSaver = userTokensSaver,
)
}
}

View file

@ -3,6 +3,7 @@ package com.tangem.data.tokens.di
import com.tangem.blockchainsdk.utils.ExcludedBlockchains
import com.tangem.data.common.cache.CacheRegistry
import com.tangem.data.common.currency.CardCryptoCurrencyFactory
import com.tangem.data.common.currency.UserTokensSaver
import com.tangem.data.tokens.repository.DefaultCurrenciesRepository
import com.tangem.data.tokens.repository.DefaultCurrencyChecksRepository
import com.tangem.data.tokens.repository.DefaultPolkadotAccountHealthCheckRepository
@ -40,6 +41,7 @@ internal object TokensDataModule {
expressServiceLoader: ExpressServiceLoader,
excludedBlockchains: ExcludedBlockchains,
cardCryptoCurrencyFactory: CardCryptoCurrencyFactory,
tokensSaver: UserTokensSaver,
): CurrenciesRepository {
return DefaultCurrenciesRepository(
tangemTechApi = tangemTechApi,
@ -51,6 +53,7 @@ internal object TokensDataModule {
dispatchers = dispatchers,
excludedBlockchains = excludedBlockchains,
cardCryptoCurrencyFactory = cardCryptoCurrencyFactory,
userTokensSaver = tokensSaver,
)
}

View file

@ -7,7 +7,6 @@ import com.tangem.data.common.api.safeApiCall
import com.tangem.data.common.cache.CacheRegistry
import com.tangem.data.common.currency.*
import com.tangem.data.tokens.utils.CustomTokensMerger
import com.tangem.data.tokens.utils.UserTokensBackwardCompatibility
import com.tangem.datasource.api.common.response.ApiResponseError
import com.tangem.datasource.api.common.response.getOrThrow
import com.tangem.datasource.api.express.models.TangemExpressValues.EMPTY_CONTRACT_ADDRESS_VALUE
@ -19,7 +18,6 @@ import com.tangem.datasource.local.preferences.AppPreferencesStore
import com.tangem.datasource.local.preferences.PreferencesKeys
import com.tangem.datasource.local.preferences.utils.getObject
import com.tangem.datasource.local.preferences.utils.getObjectSyncOrNull
import com.tangem.datasource.local.preferences.utils.storeObject
import com.tangem.datasource.local.userwallet.UserWalletsStore
import com.tangem.domain.common.extensions.canHandleBlockchain
import com.tangem.domain.common.util.cardTypesResolver
@ -51,14 +49,18 @@ internal class DefaultCurrenciesRepository(
private val dispatchers: CoroutineDispatcherProvider,
private val excludedBlockchains: ExcludedBlockchains,
private val cardCryptoCurrencyFactory: CardCryptoCurrencyFactory,
private val userTokensSaver: UserTokensSaver,
) : CurrenciesRepository {
private val demoConfig = DemoConfig()
private val responseCurrenciesFactory = ResponseCryptoCurrenciesFactory(excludedBlockchains)
private val cryptoCurrencyFactory = CryptoCurrencyFactory(excludedBlockchains)
private val userTokensResponseFactory = UserTokensResponseFactory()
private val userTokensBackwardCompatibility = UserTokensBackwardCompatibility()
private val customTokensMerger = CustomTokensMerger(tangemTechApi, dispatchers)
private val customTokensMerger = CustomTokensMerger(
tangemTechApi = tangemTechApi,
dispatchers = dispatchers,
userTokensSaver = userTokensSaver,
)
override suspend fun saveTokens(
userWalletId: UserWalletId,
@ -73,8 +75,7 @@ internal class DefaultCurrenciesRepository(
isGroupedByNetwork = isGroupedByNetwork,
isSortedByBalance = isSortedByBalance,
)
storeAndPushTokens(userWalletId, response)
userTokensSaver.storeAndPush(userWalletId, response)
}
override suspend fun saveNewCurrenciesList(userWalletId: UserWalletId, currencies: List<CryptoCurrency>) {
@ -89,7 +90,7 @@ internal class DefaultCurrenciesRepository(
val updatedResponse = savedResponse.copy(
tokens = newCurrencies.map(userTokensResponseFactory::createResponseToken),
)
storeAndPushTokens(
userTokensSaver.storeAndPush(
userWalletId = userWalletId,
response = updatedResponse,
)
@ -113,7 +114,7 @@ internal class DefaultCurrenciesRepository(
val updatedResponse = savedCurrencies.copy(
tokens = savedCurrencies.tokens + currenciesToAdd.map(userTokensResponseFactory::createResponseToken),
)
storeAndPushTokens(
userTokensSaver.storeAndPush(
userWalletId = userWalletId,
response = updatedResponse,
)
@ -177,9 +178,10 @@ internal class DefaultCurrenciesRepository(
)
val token = userTokensResponseFactory.createResponseToken(currency)
storeAndPushTokens(
val updatedResponse = savedCurrencies.copy(tokens = savedCurrencies.tokens.filterNot { it == token })
userTokensSaver.storeAndPush(
userWalletId = userWalletId,
response = savedCurrencies.copy(tokens = savedCurrencies.tokens.filterNot { it == token }),
response = updatedResponse,
)
}
@ -191,11 +193,12 @@ internal class DefaultCurrenciesRepository(
)
val tokens = currencies.map(userTokensResponseFactory::createResponseToken)
storeAndPushTokens(
val updatedResponse = savedCurrencies.copy(
tokens = savedCurrencies.tokens.filterNot(tokens::contains),
)
userTokensSaver.storeAndPush(
userWalletId = userWalletId,
response = savedCurrencies.copy(
tokens = savedCurrencies.tokens.filterNot(tokens::contains),
),
response = updatedResponse,
)
}
}
@ -583,13 +586,9 @@ internal class DefaultCurrenciesRepository(
val compatibleUserTokensResponse = response
.let { it.copy(tokens = it.tokens.distinct()) }
.let { customTokensMerger.mergeIfPresented(userWalletId, response) }
.let(userTokensBackwardCompatibility::applyCompatibilityAndGetUpdated)
.let { customTokensMerger.mergeIfPresented(userWalletId, it) }
appPreferencesStore.storeObject(
key = PreferencesKeys.getUserTokensKey(userWalletId = userWallet.walletId.stringValue),
value = compatibleUserTokensResponse,
)
userTokensSaver.store(userWalletId, compatibleUserTokensResponse)
fetchExpressAssetsByNetworkIds(userWalletId, compatibleUserTokensResponse)
}
@ -600,16 +599,6 @@ internal class DefaultCurrenciesRepository(
return demoConfig.isDemoCardId(userWallet.cardId) && response == null
}
private suspend fun storeAndPushTokens(userWalletId: UserWalletId, response: UserTokensResponse) {
val compatibleUserTokensResponse = userTokensBackwardCompatibility.applyCompatibilityAndGetUpdated(response)
appPreferencesStore.storeObject(
key = PreferencesKeys.getUserTokensKey(userWalletId = userWalletId.stringValue),
value = compatibleUserTokensResponse,
)
pushTokens(userWalletId, response)
}
private suspend fun fetchExpressAssetsByNetworkIds(userWalletId: UserWalletId, userTokens: UserTokensResponse) {
val tokens = userTokens.tokens.map { token ->
LeastTokenInfo(
@ -657,7 +646,7 @@ internal class DefaultCurrenciesRepository(
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")
pushTokens(userWalletId, response)
userTokensSaver.push(userWalletId, response)
} else {
cacheRegistry.invalidate(getTokensCacheKey(userWalletId))
}
@ -665,12 +654,6 @@ 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 = cardCryptoCurrencyFactory.createDefaultCoinsForMultiCurrencyCard(userWallet.scanResponse),

View file

@ -2,6 +2,7 @@ package com.tangem.data.tokens.utils
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.UserTokensResponse
import com.tangem.domain.wallets.models.UserWalletId
@ -18,6 +19,7 @@ import timber.log.Timber
internal class CustomTokensMerger(
private val tangemTechApi: TangemTechApi,
private val dispatchers: CoroutineDispatcherProvider,
private val userTokensSaver: UserTokensSaver,
) {
/**
@ -47,7 +49,7 @@ internal class CustomTokensMerger(
// previously here was used compare response.tokens, but it's not working correctly
// because Token.equals() skip some fields
if (wasMerged.value) {
pushTokens(userWalletId, updatedResponse)
userTokensSaver.push(userWalletId, updatedResponse)
}
return updatedResponse
@ -102,10 +104,4 @@ internal class CustomTokensMerger(
},
)
}
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

@ -238,26 +238,18 @@ internal class DefaultWalletsRepository(
}
}
override suspend fun isNotificationsEnabled(userWalletId: UserWalletId, force: Boolean): Boolean =
withContext(dispatchers.io) {
if (force) {
return@withContext loadAndSaveNotificationsEnabled(userWalletId)
}
val localValue = appPreferencesStore.getObjectMap<Boolean>(PreferencesKeys.NOTIFICATIONS_ENABLED_STATES_KEY)
.map { it[userWalletId.stringValue] }
.firstOrNull()
localValue ?: loadAndSaveNotificationsEnabled(userWalletId)
}
override suspend fun isNotificationsEnabled(userWalletId: UserWalletId): Boolean =
appPreferencesStore.getObjectMap<Boolean>(PreferencesKeys.NOTIFICATIONS_ENABLED_STATES_KEY)
.map { it[userWalletId.stringValue] }
.firstOrNull() ?: false
override suspend fun setNotificationsEnabled(userWalletId: UserWalletId, isEnabled: Boolean) {
withContext(dispatchers.io) {
tangemTechApi.setNotificationsEnabled(
walletId = userWalletId.stringValue,
body = WalletBody(notifyStatus = isEnabled),
).getOrThrow()
setNotificationsEnabledLocally(userWalletId, isEnabled)
appPreferencesStore.editData {
it.setObjectMap(
key = PreferencesKeys.NOTIFICATIONS_ENABLED_STATES_KEY,
value = it.getObjectMap<Boolean>(PreferencesKeys.NOTIFICATIONS_ENABLED_STATES_KEY)
.plus(userWalletId.stringValue to isEnabled),
)
}
}
@ -283,7 +275,7 @@ internal class DefaultWalletsRepository(
value = walletInfo,
)
if (updateCache) {
setNotificationsEnabledLocally(
setNotificationsEnabled(
userWalletId = userWallet.walletId,
isEnabled = userWallet.isNotificationsEnabled,
)
@ -307,21 +299,4 @@ internal class DefaultWalletsRepository(
body = walletsBody,
).getOrThrow()
}
private suspend fun loadAndSaveNotificationsEnabled(userWalletId: UserWalletId): Boolean {
val walletResponse = tangemTechApi.getWalletById(walletId = userWalletId.stringValue).getOrThrow()
val isEnabled = walletResponse.notifyStatus
setNotificationsEnabledLocally(userWalletId, isEnabled)
return isEnabled
}
private suspend fun setNotificationsEnabledLocally(userWalletId: UserWalletId, isEnabled: Boolean) {
appPreferencesStore.editData {
it.setObjectMap(
key = PreferencesKeys.NOTIFICATIONS_ENABLED_STATES_KEY,
value = it.getObjectMap<Boolean>(PreferencesKeys.NOTIFICATIONS_ENABLED_STATES_KEY)
.plus(userWalletId.stringValue to isEnabled),
)
}
}
}

View file

@ -5,7 +5,6 @@ import androidx.datastore.preferences.core.Preferences
import com.squareup.moshi.Moshi
import com.tangem.datasource.api.common.response.ApiResponse
import com.tangem.datasource.api.tangemTech.TangemTechApi
import com.tangem.datasource.api.tangemTech.models.WalletBody
import com.tangem.datasource.api.tangemTech.models.WalletResponse
import com.tangem.datasource.local.preferences.AppPreferencesStore
import com.tangem.datasource.local.preferences.PreferencesKeys
@ -35,10 +34,6 @@ class DefaultWalletsRepositoryTest {
private lateinit var dispatchers: CoroutineDispatcherProvider
private val testWalletId = UserWalletId("1234567890abcdef")
private val testWalletResponse = WalletResponse(
id = testWalletId.stringValue,
notifyStatus = true,
)
@Before
fun setup() {
@ -54,29 +49,6 @@ class DefaultWalletsRepositoryTest {
)
}
@Test
fun `GIVEN force is true WHEN isNotificationsEnabled THEN should fetch from API and update local storage`() =
runTest {
// GIVEN
coEvery { tangemTechApi.getWalletById(testWalletId.stringValue) } returns ApiResponse.Success(
testWalletResponse,
)
coEvery { preferencesDataStore.updateData(any()) } returns mockk<Preferences>()
// WHEN
val result = repository.isNotificationsEnabled(testWalletId, force = true)
// THEN
assertThat(result).isTrue()
coVerify(exactly = 1) {
tangemTechApi.getWalletById(testWalletId.stringValue)
}
coVerify(exactly = 0) {
tangemTechApi.setNotificationsEnabled(any(), any())
}
coVerify(exactly = 1) { preferencesDataStore.updateData(any()) }
}
@Test
fun `GIVEN local storage has value WHEN isNotificationsEnabled THEN should return local value`() = runTest {
// GIVEN
@ -84,96 +56,55 @@ class DefaultWalletsRepositoryTest {
val preferences = mockk<Preferences>()
coEvery { preferencesDataStore.data } returns flowOf(preferences)
coEvery { preferences[PreferencesKeys.NOTIFICATIONS_ENABLED_STATES_KEY] } returns expectedPreferences
coEvery { tangemTechApi.getWalletById(any()) } returns ApiResponse.Success(testWalletResponse)
// WHEN
val result = repository.isNotificationsEnabled(testWalletId, force = false)
val result = repository.isNotificationsEnabled(testWalletId)
// THEN
assertThat(result).isTrue()
coVerify(inverse = true) {
tangemTechApi.getWalletById(any())
tangemTechApi.setNotificationsEnabled(any(), any())
}
}
@Test
fun `GIVEN local storage is empty WHEN isNotificationsEnabled THEN should fetch from API and update local storage`() = runTest {
fun `GIVEN local storage is empty WHEN isNotificationsEnabled THEN should return false`() = runTest {
// GIVEN
val preferences = mockk<Preferences>()
coEvery { preferencesDataStore.data } returns flowOf(preferences)
coEvery { preferences[PreferencesKeys.NOTIFICATIONS_ENABLED_STATES_KEY] } returns "{}"
coEvery { tangemTechApi.getWalletById(testWalletId.stringValue) } returns ApiResponse.Success(
testWalletResponse,
)
coEvery { preferencesDataStore.updateData(any()) } returns mockk<Preferences>()
// WHEN
val result = repository.isNotificationsEnabled(testWalletId, force = false)
val result = repository.isNotificationsEnabled(testWalletId)
// THEN
assertThat(result).isTrue()
coVerify(exactly = 1) {
tangemTechApi.getWalletById(testWalletId.stringValue)
}
coVerify(exactly = 0) {
tangemTechApi.setNotificationsEnabled(any(), any())
}
coVerify(exactly = 1) { preferencesDataStore.updateData(any()) }
assertThat(result).isFalse()
}
@Test
fun `GIVEN enabled status WHEN setNotificationsEnabled THEN should update API and local storage`() = runTest {
fun `GIVEN enabled status WHEN setNotificationsEnabled THEN should update local storage`() = runTest {
// GIVEN
coEvery {
tangemTechApi.setNotificationsEnabled(
eq(testWalletId.stringValue),
eq(WalletBody(notifyStatus = true)),
)
} returns ApiResponse.Success(Unit)
coEvery { preferencesDataStore.updateData(any()) } returns mockk<Preferences>()
val preferences = mockk<Preferences>()
coEvery { preferencesDataStore.data } returns flowOf(preferences)
coEvery { preferences[PreferencesKeys.NOTIFICATIONS_ENABLED_STATES_KEY] } returns "{}"
coEvery { preferencesDataStore.updateData(any()) } returns mockk()
// WHEN
repository.setNotificationsEnabled(testWalletId, isEnabled = true)
// THEN
coVerify(exactly = 1) {
tangemTechApi.setNotificationsEnabled(
eq(testWalletId.stringValue),
eq(WalletBody(notifyStatus = true)),
)
}
coVerify(exactly = 1) { preferencesDataStore.updateData(any()) }
}
@Test
fun `GIVEN disabled status WHEN setNotificationsEnabled THEN should update API and local storage`() = runTest {
fun `GIVEN disabled status WHEN setNotificationsEnabled THEN should update local storage`() = runTest {
// GIVEN
coEvery {
tangemTechApi.setNotificationsEnabled(
eq(testWalletId.stringValue),
eq(WalletBody(notifyStatus = false)),
)
} returns ApiResponse.Success(Unit)
coEvery { preferencesDataStore.updateData(any()) } returns mockk<Preferences>()
val preferences = mockk<Preferences>()
coEvery { preferencesDataStore.data } returns flowOf(preferences)
coEvery { preferences[PreferencesKeys.NOTIFICATIONS_ENABLED_STATES_KEY] } returns "{}"
coEvery { preferencesDataStore.updateData(any()) } returns mockk()
// WHEN
repository.setNotificationsEnabled(testWalletId, isEnabled = false)
// THEN
coVerifyOrder {
tangemTechApi.setNotificationsEnabled(
eq(testWalletId.stringValue),
eq(WalletBody(notifyStatus = false)),
)
preferencesDataStore.updateData(any())
}
coVerify(exactly = 1) {
tangemTechApi.setNotificationsEnabled(
eq(testWalletId.stringValue),
eq(WalletBody(notifyStatus = false)),
)
}
coVerify(exactly = 1) { preferencesDataStore.updateData(any()) }
}
@ -194,7 +125,7 @@ class DefaultWalletsRepositoryTest {
),
)
coEvery { tangemTechApi.getWallets(applicationId) } returns ApiResponse.Success(walletResponses)
coEvery { preferencesDataStore.updateData(any()) } returns mockk<Preferences>()
coEvery { preferencesDataStore.updateData(any()) } returns mockk()
// WHEN
val result = repository.getWalletsInfo(applicationId, updateCache = true)

View file

@ -41,10 +41,8 @@ interface WalletsRepository {
suspend fun disableNFT(userWalletId: UserWalletId)
@Throws
suspend fun isNotificationsEnabled(userWalletId: UserWalletId, force: Boolean): Boolean
suspend fun isNotificationsEnabled(userWalletId: UserWalletId): Boolean
@Throws
suspend fun setNotificationsEnabled(userWalletId: UserWalletId, isEnabled: Boolean)
@Throws

View file

@ -8,10 +8,9 @@ class GetIsNotificationsEnabledUseCase(
private val walletsRepository: WalletsRepository,
) {
suspend operator fun invoke(userWalletId: UserWalletId, force: Boolean): Either<Throwable, Boolean> = Either.catch {
suspend operator fun invoke(userWalletId: UserWalletId): Either<Throwable, Boolean> = Either.catch {
walletsRepository.isNotificationsEnabled(
userWalletId = userWalletId,
force = force,
)
}
}