Updated on 2026-08-14

This commit is contained in:
Tangem 2025-07-21 18:57:13 +03:00
commit 86d0d58706
31 changed files with 446 additions and 235 deletions

18
LICENSE Normal file
View file

@ -0,0 +1,18 @@
Tangem Proprietary and Confidential
Copyright (c) 2025 Tangem AG. All rights reserved.
================================================================================
NOTICE: THIS IS NOT AN OPEN SOURCE LICENSE.
This software, including all source code, documentation, and accompanying files (the "Software") stored in this repository, is the proprietary and confidential information of Tangem AG ("Tangem"). The publication of this Software on a public platform does not grant any license, express or implied.
1. Definitions.
"Software" refers to all contents of this repository.
"Protocol" refers to the proprietary communication protocol, including the sequence of, data structures, and cryptographic methods used to interact with Tangem hardware.
"SDKs" refer to the official Software Development Kits (e.g., for Android, iOS, React Native) provided by Tangem in separate repositories and under their own distinct licenses.
2. Strict Prohibition of Use. Any use of the Software, in whole or in part, is strictly prohibited without the express prior written consent of Tangem. This prohibition includes, but is not limited to, the following actions for any purpose, whether commercial or non-commercial:
- Copying, modifying, or merging the Software.
- Publishing, distributing, or sublicensing the Software.
- Selling copies of the Software.
- Creating derivative works based on the Software.
3. Protocol Usage. Reverse-engineering, analyzing, decompiling, or attempting to recreate the Protocol is strictly prohibited. The only authorized method for third-party applications to interact with Tangem hardware is by using the official Tangem SDKs. The SDKs are governed by their own license terms (e.g., MIT License), which permit their use in third-party applications. This Software is provided for transparency and reference purposes only.
4. No Implied Rights. The act of Tangem publishing the Software to a public repository does not grant users any implied rights or licenses to use, modify, or distribute the Software. All rights not expressly granted by Tangem in a separate written agreement are reserved.
5. Legal Action. Unauthorized use, reproduction, or distribution of the Software or the Protocol may result in severe civil and criminal penalties, and will be prosecuted to the maximum extent possible under applicable law.
For inquiries about licensing or permissions to use the Software or the Protocol, please contact Tangem AG at: legal@tangem.com.

View file

@ -7,8 +7,6 @@ import com.tangem.domain.managetokens.repository.ManageTokensRepository
import com.tangem.domain.networks.multi.MultiNetworkStatusFetcher
import com.tangem.domain.quotes.multi.MultiQuoteStatusFetcher
import com.tangem.domain.staking.multi.MultiYieldBalanceFetcher
import com.tangem.domain.tokens.MultiWalletCryptoCurrenciesSupplier
import com.tangem.domain.tokens.TokensFeatureToggles
import com.tangem.domain.tokens.repository.CurrenciesRepository
import com.tangem.domain.walletmanager.WalletManagersFacade
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
@ -74,8 +72,6 @@ internal object ManageTokensDomainModule {
multiNetworkStatusFetcher: MultiNetworkStatusFetcher,
multiQuoteStatusFetcher: MultiQuoteStatusFetcher,
multiYieldBalanceFetcher: MultiYieldBalanceFetcher,
multiWalletCryptoCurrenciesSupplier: MultiWalletCryptoCurrenciesSupplier,
tokensFeatureToggles: TokensFeatureToggles,
): SaveManagedTokensUseCase {
return SaveManagedTokensUseCase(
customTokensRepository = customTokensRepository,
@ -85,8 +81,6 @@ internal object ManageTokensDomainModule {
multiNetworkStatusFetcher = multiNetworkStatusFetcher,
multiQuoteStatusFetcher = multiQuoteStatusFetcher,
multiYieldBalanceFetcher = multiYieldBalanceFetcher,
multiWalletCryptoCurrenciesSupplier = multiWalletCryptoCurrenciesSupplier,
tokensFeatureToggles = tokensFeatureToggles,
)
}

View file

@ -214,8 +214,8 @@ internal object WalletsDomainModule {
@Singleton
fun providesGetSavedWalletChangesIdUseCase(
userWalletsListManager: UserWalletsListManager,
): GetSavedWalletChangesUseCase {
return GetSavedWalletChangesUseCase(
): GetSavedWalletsCountUseCase {
return GetSavedWalletsCountUseCase(
userWalletsListManager = userWalletsListManager,
)
}

View file

@ -36,6 +36,11 @@ internal class BiometricUserWalletsListManager(
.mapLatest { it.userWallets }
.distinctUntilChanged()
override val savedWalletsCount: Flow<Int>
get() = state
.mapLatest { walletsCount }
.distinctUntilChanged()
override val userWalletsSync: List<UserWallet>
get() = state.value.userWallets

View file

@ -60,6 +60,14 @@ internal class GeneralUserWalletsListManager(
// As a result subscription occurs on empty flow, than will not change if user wallets are available
.filter { requireImplementation.hasUserWallets }
override val savedWalletsCount: Flow<Int>
get() = implementation
.transformLatest { impl ->
if (impl != null) {
emitAll(impl.savedWalletsCount)
}
}
override val userWalletsSync: List<UserWallet>
get() = requireImplementation.userWalletsSync

View file

@ -20,6 +20,11 @@ internal class RuntimeUserWalletsListManager : UserWalletsListManager {
.mapLatest { listOfNotNull(it.userWallet) }
.distinctUntilChanged()
override val savedWalletsCount: Flow<Int>
get() = state
.mapLatest { walletsCount }
.distinctUntilChanged()
override val selectedUserWallet: Flow<UserWallet>
get() = state
.mapLatest { it.userWallet }

View file

@ -41,7 +41,7 @@ import com.tangem.domain.staking.FetchStakingTokensUseCase
import com.tangem.domain.wallets.legacy.UserWalletsListManager
import com.tangem.domain.wallets.models.requireColdWallet
import com.tangem.domain.wallets.usecase.AssociateWalletsWithApplicationIdUseCase
import com.tangem.domain.wallets.usecase.GetSavedWalletChangesUseCase
import com.tangem.domain.wallets.usecase.GetSavedWalletsCountUseCase
import com.tangem.domain.wallets.usecase.UpdateRemoteWalletsInfoUseCase
import com.tangem.feature.swap.analytics.StoriesEvents
import com.tangem.tap.common.extensions.setContext
@ -84,7 +84,7 @@ internal class MainViewModel @Inject constructor(
private val onboardingRepository: OnboardingRepository,
private val notificationsToggles: NotificationsFeatureToggles,
private val getApplicationIdUseCase: GetApplicationIdUseCase,
private val subscribeOnWalletsUseCase: GetSavedWalletChangesUseCase,
private val subscribeOnWalletsUseCase: GetSavedWalletsCountUseCase,
private val associateWalletsWithApplicationIdUseCase: AssociateWalletsWithApplicationIdUseCase,
private val updateRemoteWalletsInfoUseCase: UpdateRemoteWalletsInfoUseCase,
private val sendPushTokenUseCase: SendPushTokenUseCase,

View file

@ -1,7 +1,6 @@
package com.tangem.datasource.info
import android.os.Build
import com.tangem.utils.SupportedLanguages
import com.tangem.utils.info.AppInfoProvider
import com.tangem.utils.version.AppVersionProvider
import java.util.*
@ -17,7 +16,7 @@ internal class AndroidAppInfoProvider @Inject constructor(
override val osVersion: String
get() = Build.VERSION.RELEASE
override val language: String
get() = SupportedLanguages.getCurrentSupportedLanguageCode()
get() = Locale.getDefault().language
override val timezone: String
get() = TimeZone.getDefault().id
override val appVersion: String

View file

@ -68,6 +68,10 @@ internal class DefaultNotificationsRepository @Inject constructor(
appId.value,
NotificationApplicationCreateBody(
pushToken = pushToken,
systemVersion = appInfoProvider.osVersion,
language = appInfoProvider.language,
timezone = appInfoProvider.timezone,
version = appInfoProvider.appVersion,
),
).getOrThrow()
}

View file

@ -111,16 +111,20 @@ class DefaultNotificationsRepositoryTest {
// GIVEN
val appId = ApplicationId("test-app-id")
val pushToken = "test-push-token"
coEvery { appInfoProvider.device } returns "test-device"
coEvery { appInfoProvider.osVersion } returns "11"
coEvery { appInfoProvider.language } returns "en"
coEvery { appInfoProvider.appVersion } returns "5.21.1"
coEvery { appInfoProvider.timezone } returns "UTC"
coEvery {
tangemTechApi.updatePushTokenForApplicationId(
appId.value,
NotificationApplicationCreateBody(
pushToken = pushToken,
platform = null,
device = null,
systemVersion = null,
language = null,
timezone = null,
systemVersion = "11",
language = "en",
timezone = "UTC",
version = "5.21.1",
),
)
} returns ApiResponse.Success(Unit)
@ -134,11 +138,10 @@ class DefaultNotificationsRepositoryTest {
appId.value,
NotificationApplicationCreateBody(
pushToken = pushToken,
platform = null,
device = null,
systemVersion = null,
language = null,
timezone = null,
systemVersion = "11",
language = "en",
timezone = "UTC",
version = "5.21.1",
),
)
}

View file

@ -129,6 +129,61 @@ internal class DefaultCurrenciesRepository(
currenciesToAdd
}
override suspend fun saveNewCurrenciesListCache(userWalletId: UserWalletId, currencies: List<CryptoCurrency>) {
withContext(dispatchers.io) {
val savedResponse = requireNotNull(
value = getSavedUserTokensResponseSync(key = userWalletId),
lazyMessage = { "Saved tokens empty. Can not perform add currencies action." },
)
val newCurrencies = populateCurrenciesWithMissedCoins(currencies)
val updatedResponse = savedResponse.copy(
tokens = newCurrencies.map(userTokensResponseFactory::createResponseToken),
)
userTokensSaver.store(
userWalletId = userWalletId,
response = updatedResponse,
)
fetchExpressAssetsByNetworkIds(
userWallet = userWalletsStore.getSyncStrict(key = userWalletId),
userTokens = updatedResponse,
)
}
}
override suspend fun addCurrenciesCache(
userWalletId: UserWalletId,
currencies: List<CryptoCurrency>,
): List<CryptoCurrency> = withContext(dispatchers.io) {
val savedCurrencies = requireNotNull(
value = getSavedUserTokensResponseSync(key = userWalletId),
lazyMessage = { "Saved tokens empty. Can not perform add currencies action" },
)
val currenciesToAdd = filterAlreadyAddedCurrencies(
savedCurrencies = savedCurrencies.tokens,
currenciesToAdd = populateCurrenciesWithMissedCoins(currencies = currencies),
)
val updatedResponse = savedCurrencies.copy(
tokens = savedCurrencies.tokens + currenciesToAdd.map(userTokensResponseFactory::createResponseToken),
)
userTokensSaver.store(
userWalletId = userWalletId,
response = updatedResponse,
)
fetchExpressAssetsByNetworkIds(
userWallet = userWalletsStore.getSyncStrict(key = userWalletId),
userTokens = updatedResponse,
)
currenciesToAdd
}
private fun filterAlreadyAddedCurrencies(
savedCurrencies: List<UserTokensResponse.Token>,
currenciesToAdd: List<CryptoCurrency>,
@ -605,17 +660,16 @@ internal class DefaultCurrenciesRepository(
}
override suspend fun syncTokens(userWalletId: UserWalletId) {
val savedCurrencies = requireNotNull(
value = getSavedUserTokensResponseSync(key = userWalletId),
lazyMessage = { "Saved tokens empty. Can not perform add currencies action" },
)
userTokensSaver.push(
userWalletId = userWalletId,
response = savedCurrencies,
onFailSend = {
throw IllegalStateException("Unable to push tokens")
},
)
runCatching {
val savedCurrencies = requireNotNull(
value = getSavedUserTokensResponseSync(key = userWalletId),
lazyMessage = { "Saved tokens empty. Can not perform add currencies action" },
)
userTokensSaver.storeAndPush(
userWalletId = userWalletId,
response = savedCurrencies,
)
}
}
override fun getCardTypesResolver(userWalletId: UserWalletId): CardTypesResolver? {

View file

@ -10,9 +10,6 @@ import com.tangem.domain.models.network.Network
import com.tangem.domain.networks.multi.MultiNetworkStatusFetcher
import com.tangem.domain.quotes.multi.MultiQuoteStatusFetcher
import com.tangem.domain.staking.multi.MultiYieldBalanceFetcher
import com.tangem.domain.tokens.MultiWalletCryptoCurrenciesProducer
import com.tangem.domain.tokens.MultiWalletCryptoCurrenciesSupplier
import com.tangem.domain.tokens.TokensFeatureToggles
import com.tangem.domain.tokens.repository.CurrenciesRepository
import com.tangem.domain.walletmanager.WalletManagersFacade
import com.tangem.domain.wallets.models.UserWalletId
@ -26,8 +23,6 @@ class SaveManagedTokensUseCase(
private val multiNetworkStatusFetcher: MultiNetworkStatusFetcher,
private val multiQuoteStatusFetcher: MultiQuoteStatusFetcher,
private val multiYieldBalanceFetcher: MultiYieldBalanceFetcher,
private val multiWalletCryptoCurrenciesSupplier: MultiWalletCryptoCurrenciesSupplier,
private val tokensFeatureToggles: TokensFeatureToggles,
) {
suspend operator fun invoke(
@ -35,43 +30,33 @@ class SaveManagedTokensUseCase(
currenciesToAdd: Map<ManagedCryptoCurrency.Token, Set<Network>>,
currenciesToRemove: Map<ManagedCryptoCurrency.Token, Set<Network>>,
): Either<Throwable, Unit> = Either.catch {
val removingCurrencies = currenciesToRemove.mapToCryptoCurrencies(userWalletId)
val addingCurrencies = currenciesToAdd.mapToCryptoCurrencies(userWalletId)
if (currenciesToRemove.isNotEmpty()) {
val removingCurrencies = currenciesToRemove.mapToCryptoCurrencies(userWalletId)
derivationsRepository.derivePublicKeysByNetworks(
userWalletId = userWalletId,
networks = currenciesToAdd.values.flatten(),
)
currenciesRepository.removeCurrencies(userWalletId = userWalletId, currencies = removingCurrencies)
val existingCurrencies = if (tokensFeatureToggles.isWalletBalanceFetcherEnabled) {
multiWalletCryptoCurrenciesSupplier
.getSyncOrNull(params = MultiWalletCryptoCurrenciesProducer.Params(userWalletId))
.orEmpty()
.toList()
} else {
currenciesRepository.getMultiCurrencyWalletCurrenciesSync(userWalletId)
removeCurrenciesFromWalletManager(userWalletId = userWalletId, currencies = removingCurrencies)
}
val newCurrenciesList = existingCurrencies
.filterNot(removingCurrencies::contains)
.toMutableList()
.also { it.addAll(addingCurrencies) }
if (currenciesToAdd.isNotEmpty()) {
derivationsRepository.derivePublicKeysByNetworks(
userWalletId = userWalletId,
networks = currenciesToAdd.values.flatten(),
)
currenciesRepository.saveNewCurrenciesList(userWalletId, newCurrenciesList)
val addingCurrencies = currenciesToAdd.mapToCryptoCurrencies(userWalletId)
removeCurrenciesFromWalletManager(
userWalletId = userWalletId,
currencies = removingCurrencies.filterNot(existingCurrencies::contains),
)
refreshUpdatedNetworks(
userWalletId = userWalletId,
existingCurrencies = existingCurrencies,
currenciesToAdd = addingCurrencies,
)
val savedCurrencies = currenciesRepository.addCurrenciesCache(
userWalletId = userWalletId,
currencies = addingCurrencies,
)
refreshUpdatedYieldBalances(userWalletId, existingCurrencies)
refreshUpdatedNetworks(userWalletId = userWalletId, addedCurrencies = savedCurrencies)
refreshUpdatedQuotes(addingCurrencies)
refreshUpdatedYieldBalances(userWalletId = userWalletId, addedCurrencies = savedCurrencies)
refreshUpdatedQuotes(addedCurrencies = savedCurrencies)
}
}
private suspend fun removeCurrenciesFromWalletManager(
@ -91,36 +76,25 @@ class SaveManagedTokensUseCase(
)
}
private suspend fun refreshUpdatedNetworks(
userWalletId: UserWalletId,
currenciesToAdd: List<CryptoCurrency>,
existingCurrencies: List<CryptoCurrency>,
) {
val networksToUpdate = currenciesToAdd
.asSequence()
.filterIsInstance<CryptoCurrency.Token>()
.map(CryptoCurrency.Token::network)
.filterTo(hashSetOf()) { hasCoinForNetwork(existingCurrencies, it) }
val networkToUpdate = currenciesToAdd.map { it.network }
.subtract(existingCurrencies.map { it.network }.toSet())
private suspend fun refreshUpdatedNetworks(userWalletId: UserWalletId, addedCurrencies: List<CryptoCurrency>) {
multiNetworkStatusFetcher(
MultiNetworkStatusFetcher.Params(
userWalletId = userWalletId,
networks = networksToUpdate + networkToUpdate,
networks = addedCurrencies.map(CryptoCurrency::network).toSet(),
),
)
currenciesRepository.syncTokens(userWalletId)
}
private suspend fun refreshUpdatedYieldBalances(
userWalletId: UserWalletId,
existingCurrencies: List<CryptoCurrency>,
addedCurrencies: List<CryptoCurrency>,
) {
multiYieldBalanceFetcher(
params = MultiYieldBalanceFetcher.Params(
userWalletId = userWalletId,
currencyIdWithNetworkMap = existingCurrencies.associateTo(hashMapOf()) { it.id to it.network },
currencyIdWithNetworkMap = addedCurrencies.associateTo(hashMapOf()) { it.id to it.network },
),
)
}
@ -134,16 +108,6 @@ class SaveManagedTokensUseCase(
)
}
/**
* Determines if the [existingCurrencies] list contains a coin that corresponds
* to the given [network].
*/
private fun hasCoinForNetwork(existingCurrencies: List<CryptoCurrency>, network: Network): Boolean {
return existingCurrencies.any { currency ->
currency is CryptoCurrency.Coin && currency.network == network
}
}
private suspend fun Map<ManagedCryptoCurrency.Token, Set<Network>>.mapToCryptoCurrencies(
userWalletId: UserWalletId,
): List<CryptoCurrency> {

View file

@ -62,7 +62,7 @@ class SaveMarketTokensUseCase(
)
}
val savedCurrencies = currenciesRepository.addCurrencies(
val savedCurrencies = currenciesRepository.addCurrenciesCache(
userWalletId = userWalletId,
currencies = addedCurrencies,
)
@ -82,6 +82,7 @@ class SaveMarketTokensUseCase(
networks = addedCurrencies.map(CryptoCurrency::network).toSet(),
),
)
currenciesRepository.syncTokens(userWalletId)
}
private suspend fun refreshUpdatedYieldBalances(

View file

@ -129,7 +129,7 @@ class AddCryptoCurrenciesUseCase(
* Refreshes the network statuses for tokens that have corresponding coins in the
* [existingCurrencies] list.
*/
private suspend fun Raise<Throwable>.refreshUpdatedNetworks(
private suspend fun refreshUpdatedNetworks(
userWalletId: UserWalletId,
currencyToAdd: CryptoCurrency,
existingCurrencies: List<CryptoCurrency>,
@ -150,7 +150,8 @@ class AddCryptoCurrenciesUseCase(
networks = setOfNotNull(networksToUpdate, networkToUpdate),
),
)
.bind()
currenciesRepository.syncTokens(userWalletId)
}
private suspend fun refreshUpdatedYieldBalances(userWalletId: UserWalletId, addedCurrency: CryptoCurrency) {
@ -193,7 +194,7 @@ class AddCryptoCurrenciesUseCase(
private suspend fun Raise<Throwable>.addCurrencies(userWalletId: UserWalletId, currency: CryptoCurrency) {
catch(
{ currenciesRepository.addCurrencies(userWalletId, listOf(currency)) },
{ currenciesRepository.addCurrenciesCache(userWalletId, listOf(currency)) },
) {
raise(it)
}

View file

@ -52,6 +52,26 @@ interface CurrenciesRepository {
*/
suspend fun addCurrencies(userWalletId: UserWalletId, currencies: List<CryptoCurrency>): List<CryptoCurrency>
/**
* Saves the given list of cryptocurrencies for a specific multi-currency user wallet.
*
* @param userWalletId The unique identifier of the user wallet.
* @param currencies The list of cryptocurrencies to be saved.
*/
@Deprecated("Tech debt")
suspend fun saveNewCurrenciesListCache(userWalletId: UserWalletId, currencies: List<CryptoCurrency>)
/**
* Add currencies to a specific user wallet.
*
* @param userWalletId The unique identifier of the user wallet.
* @param currencies The currencies which must be added.
* @throws DataError.UserWalletError.WrongUserWallet If single-currency user wallet
* ID provided.
*/
@Deprecated("Tech debt")
suspend fun addCurrenciesCache(userWalletId: UserWalletId, currencies: List<CryptoCurrency>): List<CryptoCurrency>
/**
* Removes currency from a specific user wallet.
*

View file

@ -53,6 +53,13 @@ internal class MockCurrenciesRepository(
currencies: List<CryptoCurrency>,
): List<CryptoCurrency> = emptyList()
override suspend fun saveNewCurrenciesListCache(userWalletId: UserWalletId, currencies: List<CryptoCurrency>) = Unit
override suspend fun addCurrenciesCache(
userWalletId: UserWalletId,
currencies: List<CryptoCurrency>,
): List<CryptoCurrency> = emptyList()
override suspend fun removeCurrency(userWalletId: UserWalletId, currency: CryptoCurrency) {
removeCurrencyResult.onLeft { throw it }
}

View file

@ -15,6 +15,9 @@ interface UserWalletsListManager {
/** [Flow] with all saved [UserWallet]s updates */
val userWallets: Flow<List<UserWallet>>
/** Count saved wallets updates */
val savedWalletsCount: Flow<Int>
/** [Flow] with selected [UserWallet] updates */
@Deprecated("You should provide the selected wallet via routing parameters due to the scalability of the features")
val selectedUserWallet: Flow<UserWallet>

View file

@ -4,23 +4,21 @@ import com.tangem.domain.wallets.legacy.UserWalletsListManager
import com.tangem.domain.wallets.legacy.asLockable
import com.tangem.domain.wallets.legacy.isLockedSync
import com.tangem.domain.wallets.models.UserWallet
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.*
class GetSavedWalletChangesUseCase(
class GetSavedWalletsCountUseCase(
private val userWalletsListManager: UserWalletsListManager,
) {
operator fun invoke(): Flow<List<UserWallet>> {
return userWalletsListManager.userWallets
.filter {
return userWalletsListManager.savedWalletsCount
.filter { count ->
if (count == 0) return@filter true
userWalletsListManager.asLockable() ?: return@filter false
return@filter if (userWalletsListManager.isLockedSync.not()) {
it.isNotEmpty()
} else {
false
}
return@filter userWalletsListManager.isLockedSync.not()
}
.map {
userWalletsListManager.userWalletsSync
}
.distinctUntilChanged()
}

View file

@ -1,76 +0,0 @@
package com.tangem.domain.wallets.usecase
import com.google.common.truth.Truth.assertThat
import com.tangem.domain.wallets.legacy.UserWalletsListManager
import com.tangem.domain.wallets.legacy.asLockable
import com.tangem.domain.wallets.legacy.isLockedSync
import com.tangem.domain.wallets.models.UserWallet
import io.mockk.every
import io.mockk.mockk
import io.mockk.mockkStatic
import kotlinx.coroutines.flow.firstOrNull
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.test.runTest
import org.junit.Before
import org.junit.Test
class GetSavedWalletChangesUseCaseTest {
private lateinit var useCase: GetSavedWalletChangesUseCase
private lateinit var userWalletsListManager: UserWalletsListManager
@Before
fun setup() {
userWalletsListManager = mockk()
useCase = GetSavedWalletChangesUseCase(userWalletsListManager)
mockkStatic("com.tangem.domain.wallets.legacy.UserWalletsListManagerExtensionsKt")
}
@Test
fun `GIVEN manager is not lockable WHEN invoke THEN return empty list`() = runTest {
// GIVEN
every { userWalletsListManager.isLockable } returns false
every { userWalletsListManager.userWallets } returns flowOf(emptyList())
every { userWalletsListManager.isLockedSync } returns false
every { userWalletsListManager.asLockable() } returns null
// WHEN
val result = useCase().firstOrNull()
// THEN
assertThat(result).isNull()
}
@Test
fun `GIVEN manager is locked WHEN invoke THEN return empty list`() = runTest {
// GIVEN
val mockLockable = mockk<UserWalletsListManager.Lockable>()
every { userWalletsListManager.isLockable } returns true
every { userWalletsListManager.userWallets } returns flowOf(emptyList())
every { userWalletsListManager.isLockedSync } returns true
every { userWalletsListManager.asLockable() } returns mockLockable
// WHEN
val result = useCase().firstOrNull()
// THEN
assertThat(result).isNull()
}
@Test
fun `GIVEN manager is not locked and has wallets WHEN invoke THEN return wallets list`() = runTest {
// GIVEN
val mockLockable = mockk<UserWalletsListManager.Lockable>()
val wallets = listOf(mockk<UserWallet>(), mockk<UserWallet>())
every { userWalletsListManager.isLockable } returns true
every { userWalletsListManager.userWallets } returns flowOf(wallets)
every { userWalletsListManager.isLockedSync } returns false
every { userWalletsListManager.asLockable() } returns mockLockable
// WHEN
val result = useCase().firstOrNull()
// THEN
assertThat(result).isEqualTo(wallets)
}
}

View file

@ -0,0 +1,137 @@
package com.tangem.domain.wallets.usecase
import com.google.common.truth.Truth.assertThat
import com.tangem.domain.wallets.legacy.UserWalletsListManager
import com.tangem.domain.wallets.legacy.asLockable
import com.tangem.domain.wallets.legacy.isLockedSync
import com.tangem.domain.wallets.models.UserWallet
import io.mockk.every
import io.mockk.mockk
import io.mockk.mockkStatic
import kotlinx.coroutines.flow.firstOrNull
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.test.runTest
import org.junit.Before
import org.junit.Test
class GetSavedWalletsCountUseCaseTest {
private lateinit var useCase: GetSavedWalletsCountUseCase
private lateinit var userWalletsListManager: UserWalletsListManager
@Before
fun setup() {
userWalletsListManager = mockk()
useCase = GetSavedWalletsCountUseCase(userWalletsListManager)
mockkStatic("com.tangem.domain.wallets.legacy.UserWalletsListManagerExtensionsKt")
}
@Test
fun `GIVEN manager is not lockable WHEN invoke THEN return empty list`() = runTest {
// GIVEN
every { userWalletsListManager.isLockable } returns false
every { userWalletsListManager.savedWalletsCount } returns flowOf(0)
every { userWalletsListManager.userWallets } returns flowOf(emptyList())
every { userWalletsListManager.userWalletsSync } returns emptyList()
every { userWalletsListManager.isLockedSync } returns false
every { userWalletsListManager.asLockable() } returns null
// WHEN
val result = useCase().firstOrNull()
// THEN
assertThat(result).isEmpty()
}
@Test
fun `GIVEN manager is locked WHEN invoke THEN return empty list`() = runTest {
// GIVEN
val mockLockable = mockk<UserWalletsListManager.Lockable>()
every { userWalletsListManager.isLockable } returns true
every { userWalletsListManager.savedWalletsCount } returns flowOf(0)
every { userWalletsListManager.userWallets } returns flowOf(emptyList())
every { userWalletsListManager.userWalletsSync } returns emptyList()
every { userWalletsListManager.isLockedSync } returns true
every { userWalletsListManager.asLockable() } returns mockLockable
// WHEN
val result = useCase().firstOrNull()
// THEN
assertThat(result).isEmpty()
}
@Test
fun `GIVEN manager is not locked and has wallets WHEN invoke THEN return wallets list`() = runTest {
// GIVEN
val mockLockable = mockk<UserWalletsListManager.Lockable>()
val wallets = listOf(mockk<UserWallet>(), mockk<UserWallet>())
every { userWalletsListManager.isLockable } returns true
every { userWalletsListManager.savedWalletsCount } returns flowOf(wallets.size)
every { userWalletsListManager.userWallets } returns flowOf(wallets)
every { userWalletsListManager.userWalletsSync } returns wallets
every { userWalletsListManager.isLockedSync } returns false
every { userWalletsListManager.asLockable() } returns mockLockable
// WHEN
val result = useCase().firstOrNull()
// THEN
assertThat(result).isEqualTo(wallets)
}
@Test
fun `GIVEN manager is not lockable and savedWalletsCount is zero WHEN invoke THEN return empty list`() = runTest {
// GIVEN
every { userWalletsListManager.isLockable } returns false
every { userWalletsListManager.savedWalletsCount } returns flowOf(0)
every { userWalletsListManager.userWallets } returns flowOf(emptyList())
every { userWalletsListManager.userWalletsSync } returns emptyList()
every { userWalletsListManager.isLockedSync } returns false
every { userWalletsListManager.asLockable() } returns null
// WHEN
val result = useCase().firstOrNull()
// THEN
assertThat(result).isEmpty()
}
@Test
fun `GIVEN manager is lockable and locked and savedWalletsCount is zero WHEN invoke THEN return empty list`() =
runTest {
// GIVEN
val mockLockable = mockk<UserWalletsListManager.Lockable>()
every { userWalletsListManager.isLockable } returns true
every { userWalletsListManager.savedWalletsCount } returns flowOf(0)
every { userWalletsListManager.userWallets } returns flowOf(emptyList())
every { userWalletsListManager.userWalletsSync } returns emptyList()
every { userWalletsListManager.isLockedSync } returns true
every { userWalletsListManager.asLockable() } returns mockLockable
// WHEN
val result = useCase().firstOrNull()
// THEN
assertThat(result).isEmpty()
}
@Test
fun `GIVEN manager is lockable and unlocked and savedWalletsCount is zero WHEN invoke THEN return empty list`() =
runTest {
// GIVEN
val mockLockable = mockk<UserWalletsListManager.Lockable>()
every { userWalletsListManager.isLockable } returns true
every { userWalletsListManager.savedWalletsCount } returns flowOf(0)
every { userWalletsListManager.userWallets } returns flowOf(emptyList())
every { userWalletsListManager.userWalletsSync } returns emptyList()
every { userWalletsListManager.isLockedSync } returns false
every { userWalletsListManager.asLockable() } returns mockLockable
// WHEN
val result = useCase().firstOrNull()
// THEN
assertThat(result).isEmpty()
}
}

View file

@ -82,6 +82,10 @@ internal class SendAmountModel @Inject constructor(
configAmountNavigation()
initAppCurrency()
subscribeOnCryptoCurrencyStatusFlow()
subscribeOnAmountReduceByTriggerUpdates()
subscribeOnAmountReduceToTriggerUpdates()
subscribeOnAmountIgnoreReduceTriggerUpdates()
subscribeOnAmountUpdateTriggerUpdates()
}
private fun initAppCurrency() {
@ -104,12 +108,6 @@ internal class SendAmountModel @Inject constructor(
.onEach { newCryptoCurrencyStatus ->
cryptoCurrencyStatus = newCryptoCurrencyStatus
maxAmountBoundary = MaxEnterAmountConverter().convert(cryptoCurrencyStatus)
if (uiState.value is AmountState.Empty) {
subscribeOnAmountReduceByTriggerUpdates()
subscribeOnAmountReduceToTriggerUpdates()
subscribeOnAmountIgnoreReduceTriggerUpdates()
subscribeOnAmountUpdateTriggerUpdates()
}
initMinBoundary()
}
.launchIn(modelScope)

View file

@ -29,6 +29,7 @@ internal fun SendCustomFee(
hasNotifications: Boolean,
onValueChange: (Int, String) -> Unit,
onNonceChange: (String) -> Unit,
displayNonceInput: Boolean,
nonce: String?,
modifier: Modifier = Modifier,
) {
@ -85,10 +86,12 @@ internal fun SendCustomFee(
}
}
}
Nonce(
onNonceChange = onNonceChange,
nonce = nonce,
)
if (displayNonceInput) {
Nonce(
onNonceChange = onNonceChange,
nonce = nonce,
)
}
}
}
}

View file

@ -41,6 +41,7 @@ internal fun SendFeeContent(state: FeeUM, clickIntents: SendFeeClickIntents) {
feeSelectorUM = feeSelectorUM,
onValueChange = clickIntents::onCustomFeeValueChange,
onNonceChange = clickIntents::onNonceChange,
displayNonceInput = state.displayNonceInput,
nonce = state.nonce?.toString().orEmpty(),
hasNotifications = hasNotifications,
)
@ -59,11 +60,13 @@ private fun LazyListScope.feeSelector(state: FeeUM.Content, clickIntents: SendFe
}
}
@Suppress("LongParameterList")
internal fun LazyListScope.customFee(
feeSelectorUM: FeeSelectorUM.Content,
hasNotifications: Boolean,
onValueChange: (Int, String) -> Unit,
onNonceChange: (String) -> Unit,
displayNonceInput: Boolean,
nonce: String?,
modifier: Modifier = Modifier,
) {
@ -74,6 +77,7 @@ internal fun LazyListScope.customFee(
hasNotifications = hasNotifications,
onValueChange = onValueChange,
onNonceChange = onNonceChange,
displayNonceInput = displayNonceInput,
nonce = nonce,
modifier = modifier
.fillMaxWidth()

View file

@ -304,7 +304,7 @@ internal class NotificationsModel @Inject constructor(
) {
val validationError = validateTransactionUseCase(
userWalletId = userWalletId,
amount = enteredAmount.convertToSdkAmount(currency),
amount = sendingAmount.convertToSdkAmount(currency),
fee = fee,
memo = memo,
destination = destinationAddress,

View file

@ -240,6 +240,7 @@ private fun StakingRewardBlock(
title = resourceReference(R.string.staking_rewards),
text = text,
iconRes = R.drawable.ic_chevron_right_24.takeIf { isShowIcon },
onIconClick = onRewardsClick,
textColor = textColor,
modifier = Modifier
.clip(TangemTheme.shapes.roundedCornersXMedium)

View file

@ -21,6 +21,7 @@ import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.network.Network
import com.tangem.domain.models.quote.QuoteStatus
import com.tangem.domain.quotes.QuotesRepository
import com.tangem.domain.quotes.multi.MultiQuoteStatusFetcher
import com.tangem.domain.tokens.*
import com.tangem.domain.tokens.model.CryptoCurrencyStatus
import com.tangem.domain.tokens.model.FeePaidCurrency
@ -64,6 +65,7 @@ internal class SwapInteractorImpl @AssistedInject constructor(
private val createApprovalTransactionUseCase: CreateApprovalTransactionUseCase,
private val isDemoCardUseCase: IsDemoCardUseCase,
private val quotesRepository: QuotesRepository,
private val multiQuoteStatusFetcher: MultiQuoteStatusFetcher,
private val swapTransactionRepository: SwapTransactionRepository,
private val currencyChecksRepository: CurrencyChecksRepository,
private val appCurrencyRepository: AppCurrencyRepository,
@ -1279,7 +1281,7 @@ internal class SwapInteractorImpl @AssistedInject constructor(
network = network,
userWallet = userWallet,
).getOrNull() ?: error("unable to calculate fee")
} catch (e: IllegalStateException) {
} catch (_: IllegalStateException) {
getEthSpecificFeeUseCase(
userWallet = userWallet,
cryptoCurrency = fromToken,
@ -1805,20 +1807,41 @@ internal class SwapInteractorImpl @AssistedInject constructor(
}
private suspend fun getQuotes(vararg ids: CryptoCurrency.ID): Map<CryptoCurrency.ID, BigDecimal> {
val set = ids.mapNotNull { it.rawCurrencyId }
.toSet()
val set = ids.mapNotNullTo(destination = hashSetOf(), transform = CryptoCurrency.ID::rawCurrencyId)
.getQuotesOrEmpty()
return ids
.mapNotNull { id ->
set.find { it.rawCurrencyId == id.rawCurrencyId && it.value is QuoteStatus.Data }
?.let { id to (it.value as QuoteStatus.Data).fiatRate }
val found = set.find { it.rawCurrencyId == id.rawCurrencyId && it.value is QuoteStatus.Data }
?: return@mapNotNull null
id to (found.value as QuoteStatus.Data).fiatRate
}
.toMap()
}
private suspend fun Set<CryptoCurrency.RawID>.getQuotesOrEmpty(): Set<QuoteStatus> {
return runCatching { quotesRepository.getMultiQuoteSyncOrNull(currenciesIds = this) }
return runCatching {
val cachedQuotes = quotesRepository.getMultiQuoteSyncOrNull(currenciesIds = this)
val allQuotesFound = cachedQuotes?.all { it.value !is QuoteStatus.Empty } == true
if (allQuotesFound) return@runCatching cachedQuotes
val currenciesIds = if (cachedQuotes.isNullOrEmpty()) {
this
} else {
cachedQuotes.mapNotNullTo(hashSetOf()) {
if (it.value is QuoteStatus.Empty) it.rawCurrencyId else null
}
}
multiQuoteStatusFetcher(
params = MultiQuoteStatusFetcher.Params(currenciesIds = currenciesIds, appCurrencyId = null),
)
quotesRepository.getMultiQuoteSyncOrNull(currenciesIds = this)
}
.getOrNull()
.orEmpty()
}

View file

@ -16,10 +16,12 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.ColorFilter
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
import com.tangem.core.ui.components.SpacerH12
import com.tangem.core.ui.components.SpacerW2
import com.tangem.core.ui.components.appbar.ExpandableSearchView
import com.tangem.core.ui.components.atoms.text.EllipsisText
import com.tangem.core.ui.components.currency.icon.CurrencyIcon
import com.tangem.core.ui.components.currency.icon.CurrencyIconState
import com.tangem.core.ui.decorations.roundedShapeItemDecoration
@ -222,10 +224,11 @@ private fun TokenItem(
Column(
modifier = Modifier
.weight(1f)
.align(Alignment.CenterVertically)
.padding(start = TangemTheme.dimens.spacing12),
) {
Text(
EllipsisText(
text = token.name,
style = TangemTheme.typography.subtitle1,
color = if (token.available) {
@ -235,20 +238,19 @@ private fun TokenItem(
},
)
SpacerW2()
Text(
EllipsisText(
text = token.symbol,
style = TangemTheme.typography.caption2,
color = TangemTheme.colors.text.tertiary,
)
}
Spacer(modifier = Modifier.weight(1f))
if (token.addedTokenBalanceData != null) {
Column(
horizontalAlignment = Alignment.End,
verticalArrangement = Arrangement.Center,
modifier = Modifier.padding(start = TangemTheme.dimens.spacing8),
modifier = Modifier
.padding(start = TangemTheme.dimens.spacing8),
) {
Text(
text = token.addedTokenBalanceData.amountEquivalent.orEmpty().orMaskWithStars(
@ -256,6 +258,9 @@ private fun TokenItem(
!token.addedTokenBalanceData.amountEquivalent.isNullOrEmpty(),
),
style = TangemTheme.typography.subtitle1,
maxLines = 1,
softWrap = false,
overflow = TextOverflow.Visible,
color = if (token.available) {
TangemTheme.colors.text.primary1
} else {
@ -268,6 +273,7 @@ private fun TokenItem(
maskWithStars = token.addedTokenBalanceData.isBalanceHidden &&
!token.addedTokenBalanceData.amount.isNullOrEmpty(),
),
maxLines = 1,
style = TangemTheme.typography.caption2,
color = TangemTheme.colors.text.tertiary,
)
@ -284,12 +290,11 @@ private val token = TokenToSelectState.TokenToSelect(
showCustomBadge = false,
),
id = "",
name = "USDC",
name = "Optimistic Ethereum (ETH)",
symbol = "USDC",
addedTokenBalanceData = TokenBalanceData(
amount = "15 000 $",
amountEquivalent = "15 000 " +
"USDT",
amountEquivalent = "15 000 USDT",
isBalanceHidden = false,
),
)

View file

@ -26,7 +26,6 @@ import com.tangem.domain.models.scan.ScanResponse
import com.tangem.domain.nft.DisableWalletNFTUseCase
import com.tangem.domain.nft.EnableWalletNFTUseCase
import com.tangem.domain.nft.GetWalletNFTEnabledUseCase
import com.tangem.domain.notifications.GetApplicationIdUseCase
import com.tangem.domain.notifications.toggles.NotificationsFeatureToggles
import com.tangem.domain.settings.repositories.PermissionRepository
import com.tangem.domain.wallets.models.UserWallet
@ -46,7 +45,6 @@ import com.tangem.features.pushnotifications.api.analytics.PushNotificationAnaly
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import kotlinx.collections.immutable.PersistentList
import kotlinx.collections.immutable.persistentListOf
import kotlinx.coroutines.NonCancellable
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.launch
import timber.log.Timber
@ -74,8 +72,6 @@ internal class WalletSettingsModel @Inject constructor(
private val setNotificationsEnabledUseCase: SetNotificationsEnabledUseCase,
private val settingsManager: SettingsManager,
private val permissionsRepository: PermissionRepository,
private val getApplicationIdUseCase: GetApplicationIdUseCase,
private val associateWalletsWithApplicationIdUseCase: AssociateWalletsWithApplicationIdUseCase,
) : Model() {
val params: WalletSettingsComponent.Params = paramsContainer.require()
@ -196,20 +192,10 @@ internal class WalletSettingsModel @Inject constructor(
if (hasUserWallets) {
router.pop()
} else {
clearWalletsAssociatedWithApplicationId()
router.replaceAll(AppRoute.Home)
}
}
private fun clearWalletsAssociatedWithApplicationId() = modelScope.launch(NonCancellable) {
getApplicationIdUseCase().onRight { applicationId ->
associateWalletsWithApplicationIdUseCase(applicationId, emptyList())
.onLeft {
Timber.e("Unable to associate empty wallets with application ID: $it")
}
}
}
private fun onLinkMoreCardsClick(scanResponse: ScanResponse) {
analyticsEventHandler.send(Settings.ButtonCreateBackup)
analyticsContextProxy.addContext(scanResponse)

View file

@ -18,12 +18,15 @@ import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.networks.multi.MultiNetworkStatusFetcher
import com.tangem.domain.promo.ShouldShowPromoWalletUseCase
import com.tangem.domain.promo.models.PromoId
import com.tangem.domain.quotes.multi.MultiQuoteStatusFetcher
import com.tangem.domain.settings.NeverToSuggestRateAppUseCase
import com.tangem.domain.settings.RemindToRateAppLaterUseCase
import com.tangem.domain.staking.multi.MultiYieldBalanceFetcher
import com.tangem.domain.tokens.model.analytics.TokenSwapPromoAnalyticsEvent
import com.tangem.domain.wallets.legacy.UserWalletsListManager.Lockable.UnlockType
import com.tangem.domain.wallets.models.UnlockWalletsError
import com.tangem.domain.wallets.models.UserWallet
import com.tangem.domain.wallets.models.UserWalletId
import com.tangem.domain.wallets.models.requireColdWallet
import com.tangem.domain.wallets.usecase.GetUserWalletUseCase
import com.tangem.domain.wallets.usecase.SeedPhraseNotificationUseCase
@ -40,6 +43,9 @@ import com.tangem.feature.wallet.presentation.wallet.state.model.WalletEvent
import com.tangem.feature.wallet.presentation.wallet.state.transformers.CloseBottomSheetTransformer
import com.tangem.feature.wallet.presentation.wallet.state.utils.WalletEventSender
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.launch
import timber.log.Timber
import javax.inject.Inject
@ -105,6 +111,8 @@ internal class WalletWarningsClickIntentsImplementor @Inject constructor(
private val seedPhraseNotificationUseCase: SeedPhraseNotificationUseCase,
private val urlOpener: UrlOpener,
private val multiNetworkStatusFetcher: MultiNetworkStatusFetcher,
private val multiQuoteStatusFetcher: MultiQuoteStatusFetcher,
private val multiYieldBalanceFetcher: MultiYieldBalanceFetcher,
private val appRouter: AppRouter,
) : BaseWalletClickIntents(), WalletWarningsClickIntents {
@ -148,13 +156,7 @@ internal class WalletWarningsClickIntentsImplementor @Inject constructor(
).fold(
ifLeft = { Timber.e(it, "Failed to derive public keys") },
ifRight = {
multiNetworkStatusFetcher(
params = MultiNetworkStatusFetcher.Params(
userWalletId = userWallet.walletId,
networks = missedAddressCurrencies.map(CryptoCurrency::network).toSet(),
),
)
.onLeft { Timber.e("Unable to refresh token list: $it") }
fetchCryptoCurrencies(userWalletId = userWallet.walletId, currencies = missedAddressCurrencies)
},
)
}
@ -375,6 +377,41 @@ internal class WalletWarningsClickIntentsImplementor @Inject constructor(
// TODO implement wallet activation process
}
private suspend fun fetchCryptoCurrencies(userWalletId: UserWalletId, currencies: List<CryptoCurrency>) {
coroutineScope {
listOf(
async {
multiNetworkStatusFetcher(
params = MultiNetworkStatusFetcher.Params(
userWalletId = userWalletId,
networks = currencies.map(CryptoCurrency::network).toSet(),
),
)
.onLeft { Timber.e("Unable to fetch networks: $it") }
},
async {
multiQuoteStatusFetcher(
params = MultiQuoteStatusFetcher.Params(
currenciesIds = currencies.mapNotNull { it.id.rawCurrencyId }.toSet(),
appCurrencyId = null,
),
)
.onLeft { Timber.e("Unable to fetch quotes: $it") }
},
async {
multiYieldBalanceFetcher(
params = MultiYieldBalanceFetcher.Params(
userWalletId = userWalletId,
currencyIdWithNetworkMap = currencies.associate { it.id to it.network },
),
)
.onLeft { Timber.e("Unable to fetch yield balances: $it") }
},
)
.awaitAll()
}
}
private fun getSelectedUserWallet(): UserWallet? {
val userWalletId = stateHolder.getSelectedWalletId()
return getUserWalletUseCase(userWalletId).getOrElse {

View file

@ -5,9 +5,9 @@
# https://github.com/tangem/tangem-sdk-android/
# https://github.com/tangem/vico
tangemBlockchainSdk = "develop-1119"
tangemBlockchainSdk = "develop-1121"
#tangemBlockchainSdk = "0.0.1" # Keep it! - used for local builds
tangemCardSdk = "develop-489"
tangemCardSdk = "develop-501"
#tangemCardSdk = "0.0.1" # Keep it! - used for local builds ^
tangemVico = "2.0.0-alpha.25-tangem12"
#tangemVico = "0.0.1" # Keep it! - used for local builds ^

View file

@ -1,11 +1,14 @@
package com.tangem.sdk.api.di
import android.content.Context
import com.tangem.domain.card.repository.CardSdkConfigRepository
import com.tangem.operations.attestation.CardArtworksProvider
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent
import java.io.File
import javax.inject.Singleton
@Module
@ -14,10 +17,16 @@ internal object CardSdkModule {
@Provides
@Singleton
fun provideCardArtworksProvider(sdkRepository: CardSdkConfigRepository): CardArtworksProvider {
fun provideCardArtworksProvider(
sdkRepository: CardSdkConfigRepository,
@ApplicationContext context: Context,
): CardArtworksProvider {
return CardArtworksProvider(
tangemApiBaseUrlProvider = { sdkRepository.sdk.config.tangemApiBaseUrl },
secureStorage = sdkRepository.sdk.secureStorage,
artworksDirectory = File(
context.getExternalFilesDir(null) ?: context.filesDir,
"card_artworks",
).also { it.mkdirs() },
)
}
}