From f7ac40363f8d612e1df2209d8f6c96c3ad9c7e44 Mon Sep 17 00:00:00 2001 From: Tangem Date: Thu, 9 Jul 2026 17:42:50 +0500 Subject: [PATCH 01/15] Updated on 2026-08-14 --- core/res/src/main/res/values-fr/strings.xml | 2 ++ .../presentation/wallet/state/model/WalletNotificationUM.kt | 4 ++-- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/core/res/src/main/res/values-fr/strings.xml b/core/res/src/main/res/values-fr/strings.xml index a10ceb497d..600ea449c2 100644 --- a/core/res/src/main/res/values-fr/strings.xml +++ b/core/res/src/main/res/values-fr/strings.xml @@ -2138,6 +2138,8 @@ Ce jeton doit être associé à votre compte Hedera avant que vous puissiez le recevoir Associez votre jeton %s insuffisant. Renflouez votre compte Hedera pour associer ce jeton + Nous avons constaté que le processus d\'activation de la carte n\'a pas été effectué correctement en raison de problèmes avec le module NFC de votre appareil ou d\'une méthode incorrecte pour maintenir les cartes sur votre téléphone. Veuillez contacter notre équipe d\'assistance pour plus de détails. + Action requise. N\'utilisez pas votre portefeuille! Êtes-vous sûr(e) de vouloir annuler la transaction ? Vous ne pourrez plus réessayer. Votre transaction d\'un montant de %1$s %2$s n\'a pas été finalisée. Vous pouvez réessayer plus tard pour la finaliser. Vous avez une transaction inachevée diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/model/WalletNotificationUM.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/model/WalletNotificationUM.kt index b3be6cc73a..542935b215 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/model/WalletNotificationUM.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/model/WalletNotificationUM.kt @@ -129,8 +129,8 @@ internal sealed class WalletNotificationUM(val messageUM: TangemMessageUM, val t data class BackupError(val onClick: () -> Unit) : WalletNotificationUM( messageUM = TangemMessageUM( id = "BackupErrorNotification", - title = resourceReference(id = R.string.warning_backup_errors_title), - subtitle = resourceReference(id = R.string.warning_backup_errors_message), + title = resourceReference(id = R.string.warning_incomplete_backup_notification_title), + subtitle = resourceReference(id = R.string.warning_incomplete_backup_notification_message), messageEffect = TangemMessageEffect.Warning, iconUM = TangemIconUM.Icon( iconRes = R.drawable.ic_attention_default_24, From 7832fae9f3cc6139186db16487bdef4c5ebede23 Mon Sep 17 00:00:00 2001 From: Tangem Date: Thu, 9 Jul 2026 18:00:10 +0500 Subject: [PATCH 02/15] Updated on 2026-08-14 --- .../DefaultTokenDetailsDeepLinkHandler.kt | 93 +++++++++++---- .../DefaultTokenDetailsDeepLinkHandlerTest.kt | 106 ++++++++++++++++++ 2 files changed, 176 insertions(+), 23 deletions(-) diff --git a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/deeplink/DefaultTokenDetailsDeepLinkHandler.kt b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/deeplink/DefaultTokenDetailsDeepLinkHandler.kt index 42605a81a8..3d6772d33a 100644 --- a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/deeplink/DefaultTokenDetailsDeepLinkHandler.kt +++ b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/deeplink/DefaultTokenDetailsDeepLinkHandler.kt @@ -31,7 +31,10 @@ import dagger.assisted.Assisted import dagger.assisted.AssistedFactory import dagger.assisted.AssistedInject import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.firstOrNull +import kotlinx.coroutines.flow.mapNotNull import kotlinx.coroutines.launch +import kotlinx.coroutines.withTimeoutOrNull @Suppress("LongParameterList") internal class DefaultTokenDetailsDeepLinkHandler @AssistedInject constructor( @@ -83,10 +86,7 @@ internal class DefaultTokenDetailsDeepLinkHandler @AssistedInject constructor( } } - // Refresh the portfolio before searching so a token just added on the backend is present locally. - refreshAccountsIfNeeded(userWallet) - - val cryptoCurrency = findCryptoCurrency(userWallet = userWallet, networkId = networkId, tokenId = tokenId) + val cryptoCurrency = resolveCryptoCurrency(userWallet, networkId, tokenId) if (cryptoCurrency == null) { TangemLogger.e( @@ -130,6 +130,28 @@ internal class DefaultTokenDetailsDeepLinkHandler @AssistedInject constructor( } } + /** + * Resolves the target currency for the deeplink: refreshes the portfolio when needed and searches for the token. + * + * A multi-currency link needs both [networkId] and [tokenId] to match a token; a malformed link can never match, + * so we skip the refresh/await entirely to avoid wasted backend work and return immediately for the redirect. + */ + private suspend fun resolveCryptoCurrency( + userWallet: UserWallet, + networkId: String?, + tokenId: String?, + ): CryptoCurrency? { + if (userWallet.isMultiCurrency && (networkId.isNullOrBlank() || tokenId.isNullOrBlank())) return null + + val wasRefreshed = refreshAccountsIfNeeded(userWallet) + return findCryptoCurrency( + userWallet = userWallet, + networkId = networkId, + tokenId = tokenId, + awaitOnMiss = wasRefreshed, + ) + } + /** * Refreshes wallet accounts so a token just added on the backend appears in the local portfolio. * @@ -137,12 +159,17 @@ internal class DefaultTokenDetailsDeepLinkHandler @AssistedInject constructor( * on cold start the fresh list is already loaded by the regular auth flow, and single-currency * wallets have a fixed token. The fetch is best-effort — on failure we fall through and try the * current cache, so existing tokens (e.g. swap/onramp pushes) still open without regression. + * + * @return `true` only when a refresh was actually performed and succeeded. Waiting for the refreshed + * list (see [awaitCryptoCurrency]) makes sense only in that case; otherwise there is nothing to wait for. */ - private suspend fun refreshAccountsIfNeeded(userWallet: UserWallet) { + private suspend fun refreshAccountsIfNeeded(userWallet: UserWallet): Boolean { if (isFromOnNewIntent && userWallet.isMultiCurrency) { - singleAccountListFetcher(SingleAccountListFetcher.Params(userWalletId = userWallet.walletId)) + return singleAccountListFetcher(SingleAccountListFetcher.Params(userWalletId = userWallet.walletId)) .onLeft { TangemLogger.e("Error on refreshing wallet accounts", it) } + .isRight() } + return false } private suspend fun fetchCurrency(userWallet: UserWallet, cryptoCurrency: CryptoCurrency) { @@ -158,26 +185,42 @@ internal class DefaultTokenDetailsDeepLinkHandler @AssistedInject constructor( } } - private suspend fun findCryptoCurrency(userWallet: UserWallet, networkId: String?, tokenId: String?) = - if (userWallet.isMultiCurrency) { - val derivationPath = queryParams[DERIVATION_PATH_KEY] - - getCryptoCurrencies(userWalletId = userWallet.walletId)?.firstOrNull { currency -> - val isNetwork = currency.network.rawId.equals(networkId, ignoreCase = true) - val isCurrency = currency.id.rawCurrencyId?.value?.equals(tokenId, ignoreCase = true) == true - - val isDefaultDerivation = currency.network.derivationPath is Network.DerivationPath.Card - val isCustomDerivation = derivationPath?.equals(currency.network.derivationPath.value) == true - val isCorrectDerivation = isDefaultDerivation || isCustomDerivation - isNetwork && isCurrency && isCorrectDerivation - } - } else { - singleAccountListSupplier.getSyncOrNull(userWalletId = userWallet.walletId) + private suspend fun findCryptoCurrency( + userWallet: UserWallet, + networkId: String?, + tokenId: String?, + awaitOnMiss: Boolean, + ): CryptoCurrency? { + if (!userWallet.isMultiCurrency) { + return singleAccountListSupplier.getSyncOrNull(userWalletId = userWallet.walletId) ?.mainAccount?.cryptoCurrencies?.first() } - private suspend fun getCryptoCurrencies(userWalletId: UserWalletId): List? { - return singleAccountListSupplier.getSyncOrNull(userWalletId)?.flattenCurrencies() + val derivationPath = queryParams[DERIVATION_PATH_KEY] + val matches = { currency: CryptoCurrency -> currency.matches(networkId, tokenId, derivationPath) } + + return singleAccountListSupplier.getSyncOrNull(userWallet.walletId)?.flattenCurrencies()?.firstOrNull(matches) + // getSyncOrNull returns the stale SharedFlow replay just after a fetch; wait for the refreshed list. + // Only when a refresh actually ran and succeeded — otherwise a missing token would block for the full + // timeout before the fall-through redirect. + ?: if (awaitOnMiss) awaitCryptoCurrency(userWallet.walletId, matches) else null + } + + private suspend fun awaitCryptoCurrency( + userWalletId: UserWalletId, + matches: (CryptoCurrency) -> Boolean, + ): CryptoCurrency? = withTimeoutOrNull(TOKEN_APPEARANCE_TIMEOUT_MILLIS) { + singleAccountListSupplier(userWalletId) + .mapNotNull { accountList -> accountList.flattenCurrencies().firstOrNull(matches) } + .firstOrNull() + } + + private fun CryptoCurrency.matches(networkId: String?, tokenId: String?, derivationPath: String?): Boolean { + val isNetwork = network.rawId.equals(networkId, ignoreCase = true) + val isCurrency = id.rawCurrencyId?.value?.equals(tokenId, ignoreCase = true) == true + val isDefaultDerivation = network.derivationPath is Network.DerivationPath.Card + val isCustomDerivation = derivationPath?.equals(network.derivationPath.value) == true + return isNetwork && isCurrency && (isDefaultDerivation || isCustomDerivation) } @AssistedFactory @@ -188,4 +231,8 @@ internal class DefaultTokenDetailsDeepLinkHandler @AssistedInject constructor( isFromOnNewIntent: Boolean, ): DefaultTokenDetailsDeepLinkHandler } + + private companion object { + const val TOKEN_APPEARANCE_TIMEOUT_MILLIS = 3_000L + } } \ No newline at end of file diff --git a/features/tokendetails/impl/src/test/kotlin/com/tangem/feature/tokendetails/deeplink/DefaultTokenDetailsDeepLinkHandlerTest.kt b/features/tokendetails/impl/src/test/kotlin/com/tangem/feature/tokendetails/deeplink/DefaultTokenDetailsDeepLinkHandlerTest.kt index deefc4ebdf..803f252096 100644 --- a/features/tokendetails/impl/src/test/kotlin/com/tangem/feature/tokendetails/deeplink/DefaultTokenDetailsDeepLinkHandlerTest.kt +++ b/features/tokendetails/impl/src/test/kotlin/com/tangem/feature/tokendetails/deeplink/DefaultTokenDetailsDeepLinkHandlerTest.kt @@ -31,6 +31,7 @@ import com.tangem.utils.logging.TangemLogger import io.mockk.* import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.test.advanceUntilIdle import kotlinx.coroutines.test.runTest import org.junit.jupiter.api.BeforeEach @@ -532,6 +533,9 @@ class DefaultTokenDetailsDeepLinkHandlerTest { mockMultiCurrencyWallet(userWalletId) mockSelectWallet(userWalletId) coEvery { singleAccountListSupplier.getSyncOrNull(userWalletId) } returns null + every { singleAccountListSupplier.invoke(userWalletId) } returns MutableStateFlow( + AccountList.empty(userWalletId = userWalletId, cryptoCurrencies = emptyList()), + ) createHandler(scope = this, defaultQueryParams(), isFromOnNewIntent = true) advanceUntilIdle() @@ -565,6 +569,108 @@ class DefaultTokenDetailsDeepLinkHandlerTest { } } + @Test + fun `GIVEN token appears only after refresh WHEN handle deeplink THEN push new route`() = runTest { + val userWalletId = UserWalletId("011") + val cryptoCurrency = mockCryptoCurrency() + mockMultiCurrencyWallet(userWalletId) + mockSelectWallet(userWalletId) + + val staleList = AccountList.empty(userWalletId = userWalletId, cryptoCurrencies = emptyList()) + val freshList = AccountList.empty(userWalletId = userWalletId, cryptoCurrencies = listOf(cryptoCurrency)) + val accountListFlow = MutableStateFlow(staleList) + + coEvery { singleAccountListSupplier.getSyncOrNull(userWalletId) } returns staleList + every { singleAccountListSupplier.invoke(userWalletId) } returns accountListFlow + coEvery { singleAccountListFetcher.invoke(SingleAccountListFetcher.Params(userWalletId)) } answers { + accountListFlow.value = freshList + Either.Right(Unit) + } + every { + cryptoCurrencyBalanceFetcher.invoke(userWalletId = userWalletId, currency = cryptoCurrency) + } just Runs + val expectedRoute = AppRoute.CurrencyDetails(userWalletId = userWalletId, currency = cryptoCurrency) + + createHandler(scope = this, defaultQueryParams(), isFromOnNewIntent = true) + advanceUntilIdle() + + verify { appRouter.push(route = expectedRoute, onComplete = any()) } + verify(exactly = 0) { appRouter.popTo(route = AppRoute.Wallet, onComplete = any()) } + } + + @Test + fun `GIVEN cold start AND token missing WHEN handle deeplink THEN redirect to main without awaiting`() = runTest { + // Arrange + val userWalletId = UserWalletId("011") + mockMultiCurrencyWallet(userWalletId) + mockSelectWallet(userWalletId) + coEvery { singleAccountListSupplier.getSyncOrNull(userWalletId) } returns AccountList.empty( + userWalletId = userWalletId, + cryptoCurrencies = emptyList(), + ) + + // Act + createHandler(scope = this, defaultQueryParams(), isFromOnNewIntent = false) + advanceUntilIdle() + + // Assert + verify { appRouter.popTo(route = AppRoute.Wallet, onComplete = any()) } + coVerify(exactly = 0) { singleAccountListFetcher.invoke(any()) } + verify(exactly = 0) { singleAccountListSupplier.invoke(any()) } + } + + @Test + fun `GIVEN refresh failed AND token missing WHEN handle deeplink THEN redirect to main without awaiting`() = + runTest { + // Arrange + val userWalletId = UserWalletId("011") + mockMultiCurrencyWallet(userWalletId) + mockSelectWallet(userWalletId) + coEvery { + singleAccountListFetcher.invoke(SingleAccountListFetcher.Params(userWalletId)) + } returns Either.Left(IllegalStateException("service unavailable")) + coEvery { singleAccountListSupplier.getSyncOrNull(userWalletId) } returns AccountList.empty( + userWalletId = userWalletId, + cryptoCurrencies = emptyList(), + ) + + // Act + createHandler(scope = this, defaultQueryParams(), isFromOnNewIntent = true) + advanceUntilIdle() + + // Assert + verify { appRouter.popTo(route = AppRoute.Wallet, onComplete = any()) } + verify(exactly = 0) { singleAccountListSupplier.invoke(any()) } + } + + @Test + fun `GIVEN malformed deeplink AND refresh succeeded WHEN handle deeplink THEN redirect to main without awaiting`() = + runTest { + // Arrange + val userWalletId = UserWalletId("011") + mockMultiCurrencyWallet(userWalletId) + mockSelectWallet(userWalletId) + coEvery { singleAccountListSupplier.getSyncOrNull(userWalletId) } returns AccountList.empty( + userWalletId = userWalletId, + cryptoCurrencies = emptyList(), + ) + val queryParams = mapOf( + WALLET_ID_KEY to "011", + NETWORK_ID_KEY to "123", + DERIVATION_PATH_KEY to "777", + // TOKEN_ID_KEY is missing + ) + + // Act + createHandler(scope = this, queryParams, isFromOnNewIntent = true) + advanceUntilIdle() + + // Assert + verify { appRouter.popTo(route = AppRoute.Wallet, onComplete = any()) } + verify(exactly = 0) { singleAccountListSupplier.invoke(any()) } + coVerify(exactly = 0) { singleAccountListFetcher.invoke(any()) } + } + private fun defaultQueryParams() = mapOf( WALLET_ID_KEY to "011", NETWORK_ID_KEY to "123", From e2026ec41002bdf5c24c0679c061adadd57b476b Mon Sep 17 00:00:00 2001 From: Tangem Date: Thu, 9 Jul 2026 18:02:52 +0500 Subject: [PATCH 03/15] Updated on 2026-08-14 --- .../local/nft/DefaultNFTRuntimeStore.kt | 4 +- .../local/nft/NFTPersistenceStoreFactory.kt | 2 + .../local/nft/DefaultNFTRuntimeStoreTest.kt | 136 +++++++++ .../tangem/data/nft/DefaultNFTRepository.kt | 127 ++++++-- .../data/nft/DefaultNFTRepositoryTest.kt | 288 ++++++++++++++++++ 5 files changed, 527 insertions(+), 30 deletions(-) create mode 100644 core/datasource/src/test/kotlin/com/tangem/datasource/local/nft/DefaultNFTRuntimeStoreTest.kt create mode 100644 data/nft/src/test/kotlin/com/tangem/data/nft/DefaultNFTRepositoryTest.kt diff --git a/core/datasource/src/main/java/com/tangem/datasource/local/nft/DefaultNFTRuntimeStore.kt b/core/datasource/src/main/java/com/tangem/datasource/local/nft/DefaultNFTRuntimeStore.kt index 1efb779b34..6e10964419 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/local/nft/DefaultNFTRuntimeStore.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/local/nft/DefaultNFTRuntimeStore.kt @@ -121,8 +121,8 @@ internal class DefaultNFTRuntimeStore( }, ) } - val assetsCount = when (assets) { - is NFTCollection.Assets.Value -> assets.items.size + val assetsCount = when { + assets is NFTCollection.Assets.Value && assets.items.isNotEmpty() -> assets.items.size else -> data.count } data.copy( diff --git a/core/datasource/src/main/java/com/tangem/datasource/local/nft/NFTPersistenceStoreFactory.kt b/core/datasource/src/main/java/com/tangem/datasource/local/nft/NFTPersistenceStoreFactory.kt index 6b3694be92..24effa1faa 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/local/nft/NFTPersistenceStoreFactory.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/local/nft/NFTPersistenceStoreFactory.kt @@ -3,6 +3,7 @@ package com.tangem.datasource.local.nft import android.content.Context import androidx.datastore.core.DataStore import androidx.datastore.core.DataStoreFactory +import androidx.datastore.core.handlers.ReplaceFileCorruptionHandler import androidx.datastore.dataStoreFile import com.squareup.moshi.Moshi import com.tangem.blockchain.nft.models.NFTCollection @@ -58,6 +59,7 @@ class NFTPersistenceStoreFactory @Inject constructor( types = types, defaultValue = defaultValue, ), + corruptionHandler = ReplaceFileCorruptionHandler { defaultValue }, produceFile = { context.dataStoreFile(fileName = fileName) }, scope = appScope, ) diff --git a/core/datasource/src/test/kotlin/com/tangem/datasource/local/nft/DefaultNFTRuntimeStoreTest.kt b/core/datasource/src/test/kotlin/com/tangem/datasource/local/nft/DefaultNFTRuntimeStoreTest.kt new file mode 100644 index 0000000000..f09b40c825 --- /dev/null +++ b/core/datasource/src/test/kotlin/com/tangem/datasource/local/nft/DefaultNFTRuntimeStoreTest.kt @@ -0,0 +1,136 @@ +package com.tangem.datasource.local.nft + +import com.google.common.truth.Truth.assertThat +import com.tangem.datasource.local.datastore.RuntimeSharedStore +import com.tangem.domain.models.StatusSource +import com.tangem.domain.models.network.Network +import com.tangem.domain.nft.models.NFTAsset +import com.tangem.domain.nft.models.NFTCollection +import com.tangem.domain.nft.models.NFTCollections +import com.tangem.domain.nft.models.NFTSalePrice +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.Test +import java.math.BigInteger + +class DefaultNFTRuntimeStoreTest { + + private val network = createNetwork() + + private val store = DefaultNFTRuntimeStore( + network = network, + collectionsRuntimeStore = RuntimeSharedStore(), + pricesRuntimeStore = RuntimeSharedStore(), + ) + + @Test + fun `GIVEN collection with empty loaded assets WHEN getCollections THEN collection is kept`() = runTest { + // Arrange + val collection = createCollection( + count = 1, + assets = NFTCollection.Assets.Value(items = emptyList(), source = StatusSource.ACTUAL), + ) + store.initialize(collections = createCollections(collection), prices = emptyMap()) + + // Act + val content = store.getCollections().first().content + + // Assert + assertThat(content).isInstanceOf(NFTCollections.Content.Collections::class.java) + content as NFTCollections.Content.Collections + val actual = content.collections.orEmpty().single() + assertThat(actual.count).isEqualTo(1) + assertThat(actual.assets).isInstanceOf(NFTCollection.Assets.Value::class.java) + } + + @Test + fun `GIVEN collection with loaded assets WHEN getCollections THEN count recalculated from assets`() = runTest { + // Arrange + val collection = createCollection( + count = 5, + assets = NFTCollection.Assets.Value(items = listOf(createAsset()), source = StatusSource.ACTUAL), + ) + store.initialize(collections = createCollections(collection), prices = emptyMap()) + + // Act + val content = store.getCollections().first().content + + // Assert + content as NFTCollections.Content.Collections + assertThat(content.collections.orEmpty().single().count).isEqualTo(1) + } + + @Test + fun `GIVEN collection with zero count and not loaded assets WHEN getCollections THEN collection filtered out`() = + runTest { + // Arrange + val collection = createCollection(count = 0, assets = NFTCollection.Assets.Empty) + store.initialize(collections = createCollections(collection), prices = emptyMap()) + + // Act + val content = store.getCollections().first().content + + // Assert + content as NFTCollections.Content.Collections + assertThat(content.collections.orEmpty()).isEmpty() + } + + private fun createCollections(vararg collections: NFTCollection) = NFTCollections( + network = network, + content = NFTCollections.Content.Collections( + collections = collections.toList(), + source = StatusSource.ACTUAL, + ), + ) + + private fun createCollection(count: Int, assets: NFTCollection.Assets) = NFTCollection( + id = NFTCollection.Identifier.EVM(tokenAddress = TOKEN_ADDRESS), + network = network, + name = "Test collection", + description = null, + logoUrl = null, + count = count, + assets = assets, + ) + + private fun createAsset(): NFTAsset { + val assetId = NFTAsset.Identifier.EVM( + tokenAddress = TOKEN_ADDRESS, + tokenId = BigInteger.ONE, + contractType = NFTAsset.Identifier.EVM.ContractType.ERC721, + ) + return NFTAsset( + id = assetId, + collectionId = NFTCollection.Identifier.EVM(tokenAddress = TOKEN_ADDRESS), + network = network, + contractType = "ERC721", + owner = null, + name = "Test asset", + description = null, + amount = null, + decimals = 0, + salePrice = NFTSalePrice.Empty(assetId), + rarity = null, + media = null, + traits = emptyList(), + source = StatusSource.ACTUAL, + ) + } + + private fun createNetwork() = Network( + id = Network.ID(rawId = Network.RawID("ethereum"), derivationPath = Network.DerivationPath.None), + name = "Ethereum", + currencySymbol = "ETH", + derivationPath = Network.DerivationPath.None, + isTestnet = false, + standardType = Network.StandardType.ERC20, + hasFiatFeeRate = true, + canHandleTokens = true, + transactionExtrasType = Network.TransactionExtrasType.NONE, + nameResolvingType = Network.NameResolvingType.NONE, + ) + + private companion object { + const val TOKEN_ADDRESS = "0x0000000000000000000000000000000000000001" + } +} \ No newline at end of file diff --git a/data/nft/src/main/kotlin/com/tangem/data/nft/DefaultNFTRepository.kt b/data/nft/src/main/kotlin/com/tangem/data/nft/DefaultNFTRepository.kt index d5e0ee4f95..dcca338ef7 100644 --- a/data/nft/src/main/kotlin/com/tangem/data/nft/DefaultNFTRepository.kt +++ b/data/nft/src/main/kotlin/com/tangem/data/nft/DefaultNFTRepository.kt @@ -12,6 +12,7 @@ import com.tangem.datasource.local.nft.NFTPersistenceStore import com.tangem.datasource.local.nft.NFTPersistenceStoreFactory import com.tangem.datasource.local.nft.NFTRuntimeStore import com.tangem.datasource.local.nft.NFTRuntimeStoreFactory +import com.tangem.datasource.local.nft.converter.NFTSdkAssetConverter import com.tangem.datasource.local.nft.converter.NFTSdkAssetIdentifierConverter import com.tangem.datasource.local.nft.converter.NFTSdkAssetSalePriceConverter import com.tangem.datasource.local.nft.converter.NFTSdkCollectionConverter @@ -181,29 +182,27 @@ internal class DefaultNFTRepository @Inject constructor( } } - getNFTPersistenceStore(userWalletId, network) - .getCollectionsSync() - ?.map { collection -> - if (collection.identifier == sdkCollectionId) { - collection.copy(assets = assets) - } else { - collection - } - } - ?.let { collections -> - saveCollectionsInRuntime( - userWalletId = userWalletId, - network = network, - collections = collections, - ) - saveCollectionsInPersistence( - userWalletId = userWalletId, - network = network, - collections = collections, - ) - } + saveAssetsInRuntime( + userWalletId = userWalletId, + network = network, + collectionId = collectionId, + assets = assets, + ) + + // local cache failures must not affect the runtime state which is already up to date + runSuspendCatching { + updateAssetsInPersistence( + userWalletId = userWalletId, + network = network, + sdkCollectionId = sdkCollectionId, + assets = assets, + ) + }.onFailure { error -> + TangemLogger.e("Failed to persist NFT assets for $network", error) + } }.onLeft { throwable -> if (throwable !is UnsupportedOperationException) { + TangemLogger.e("Failed to refresh NFT assets for $network", throwable) saveFailedStateInRuntime( userWalletId = userWalletId, network = network, @@ -269,18 +268,29 @@ internal class DefaultNFTRepository @Inject constructor( expireCollections(userWalletId, network) val collections = walletManagersFacade.getNFTCollections(userWalletId, network) - val mergedCollections = collections.mergeWithStoredAssets(userWalletId, network) + + // local cache failures must not affect successfully fetched collections + val mergedCollections = runSuspendCatching { + collections.mergeWithStoredAssets(userWalletId, network) + }.getOrElse { error -> + TangemLogger.e("Failed to merge NFT collections with stored assets for $network", error) + collections + } saveCollectionsInRuntime( userWalletId = userWalletId, network = network, collections = mergedCollections, ) - saveCollectionsInPersistence( - userWalletId = userWalletId, - network = network, - collections = mergedCollections, - ) + runSuspendCatching { + saveCollectionsInPersistence( + userWalletId = userWalletId, + network = network, + collections = mergedCollections, + ) + }.onFailure { error -> + TangemLogger.e("Failed to persist NFT collections for $network", error) + } if (refreshAssets) { mergedCollections.forEach { collection -> @@ -292,6 +302,7 @@ internal class DefaultNFTRepository @Inject constructor( } } }.onLeft { throwable -> + TangemLogger.e("Failed to refresh NFT collections for $network", throwable) saveFailedStateInRuntime( userWalletId = userWalletId, network = network, @@ -391,12 +402,72 @@ internal class DefaultNFTRepository @Inject constructor( } } + private suspend fun saveAssetsInRuntime( + userWalletId: UserWalletId, + network: Network, + collectionId: NFTCollection.Identifier, + assets: List, + ) { + val store = getNFTRuntimeStore(userWalletId, network) + val storedCollections = store.getCollectionsSync() + val content = storedCollections.content as? NFTCollections.Content.Collections ?: return + + val convertedAssets = assets + .map { asset -> NFTSdkAssetConverter.convert(network to asset) } + .filter { it.id !is NFTAsset.Identifier.Unknown } + + val updatedCollections = content.collections + ?.map { collection -> + if (collection.id == collectionId) { + collection.copy( + assets = NFTCollection.Assets.Value( + items = convertedAssets, + source = StatusSource.ACTUAL, + ), + ) + } else { + collection + } + } + + store.saveCollections( + storedCollections.copy(content = content.copy(collections = updatedCollections)), + ) + } + + private suspend fun updateAssetsInPersistence( + userWalletId: UserWalletId, + network: Network, + sdkCollectionId: SdkNFTCollection.Identifier, + assets: List, + ) { + val storedCollections = getNFTPersistenceStore(userWalletId, network).getCollectionsSync() ?: return + val updatedCollections = storedCollections.map { collection -> + if (collection.identifier == sdkCollectionId) { + collection.copy(assets = assets) + } else { + collection + } + } + saveCollectionsInPersistence(userWalletId, network, updatedCollections) + } + private suspend fun saveCollectionsInPersistence( userWalletId: UserWalletId, network: Network, collections: List, ) { - getNFTPersistenceStore(userWalletId, network).saveCollections(collections) + val serializableCollections = collections + .filter { it.identifier !is SdkNFTCollection.Identifier.Unknown } + .map { collection -> + collection.copy( + assets = collection.assets.filter { asset -> + asset.identifier !is SdkNFTAsset.Identifier.Unknown && + asset.collectionIdentifier !is SdkNFTCollection.Identifier.Unknown + }, + ) + } + getNFTPersistenceStore(userWalletId, network).saveCollections(serializableCollections) } private suspend fun saveSalePriceInRuntime(userWalletId: UserWalletId, network: Network, salePrice: NFTSalePrice) { diff --git a/data/nft/src/test/kotlin/com/tangem/data/nft/DefaultNFTRepositoryTest.kt b/data/nft/src/test/kotlin/com/tangem/data/nft/DefaultNFTRepositoryTest.kt new file mode 100644 index 0000000000..250cf17d20 --- /dev/null +++ b/data/nft/src/test/kotlin/com/tangem/data/nft/DefaultNFTRepositoryTest.kt @@ -0,0 +1,288 @@ +package com.tangem.data.nft + +import android.content.Context +import com.google.common.truth.Truth.assertThat +import com.tangem.blockchain.common.Blockchain +import com.tangem.blockchainsdk.utils.ExcludedBlockchains +import com.tangem.common.test.domain.token.MockCryptoCurrencyFactory +import com.tangem.datasource.local.nft.NFTPersistenceStore +import com.tangem.datasource.local.nft.NFTPersistenceStoreFactory +import com.tangem.datasource.local.nft.NFTRuntimeStore +import com.tangem.datasource.local.nft.NFTRuntimeStoreFactory +import com.tangem.datasource.local.nft.converter.NFTSdkCollectionIdentifierConverter +import com.tangem.domain.common.wallets.UserWalletsListRepository +import com.tangem.domain.models.StatusSource +import com.tangem.domain.models.network.Network +import com.tangem.domain.models.wallet.UserWallet +import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.domain.nft.models.NFTAsset +import com.tangem.domain.nft.models.NFTCollection +import com.tangem.domain.nft.models.NFTCollections +import com.tangem.domain.nft.models.NFTSalePrice +import com.tangem.domain.walletmanager.WalletManagersFacade +import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider +import io.mockk.clearMocks +import io.mockk.coEvery +import io.mockk.every +import io.mockk.mockk +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.TestInstance +import java.io.IOException +import java.math.BigInteger +import com.tangem.blockchain.nft.models.NFTAsset as SdkNFTAsset +import com.tangem.blockchain.nft.models.NFTCollection as SdkNFTCollection + +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +class DefaultNFTRepositoryTest { + + private val nftPersistenceStoreFactory: NFTPersistenceStoreFactory = mockk() + private val nftRuntimeStoreFactory: NFTRuntimeStoreFactory = mockk() + private val walletManagersFacade: WalletManagersFacade = mockk() + private val userWalletsListRepository: UserWalletsListRepository = mockk() + private val context: Context = mockk() + + private val userWalletId = UserWalletId("011") + private val userWallet = mockk { + every { walletId } returns userWalletId + } + + private val network: Network = MockCryptoCurrencyFactory().ethereum.network + + @BeforeEach + fun resetMocks() { + clearMocks(nftPersistenceStoreFactory, nftRuntimeStoreFactory, walletManagersFacade, userWalletsListRepository) + every { userWalletsListRepository.userWallets } returns MutableStateFlow(listOf(userWallet)) + every { context.resources } returns mockk() + } + + private fun createRepository() = DefaultNFTRepository( + nftPersistenceStoreFactory = nftPersistenceStoreFactory, + nftRuntimeStoreFactory = nftRuntimeStoreFactory, + walletManagersFacade = walletManagersFacade, + dispatchers = TestingCoroutineDispatcherProvider(), + userWalletsListRepository = userWalletsListRepository, + networkFactory = mockk(), + excludedBlockchains = ExcludedBlockchains(), + context = context, + ) + + @Test + fun `GIVEN collections fetched WHEN persistence write fails THEN runtime keeps actual data`() = runTest { + // Arrange + val runtimeStore = FakeNFTRuntimeStore(network) + val persistenceStore = mockk { + coEvery { getCollectionsSync() } returns null + coEvery { getSalePricesSync() } returns null + coEvery { saveCollections(any()) } throws IOException("Failed to write to disk") + } + every { nftPersistenceStoreFactory.provide(userWalletId, network) } returns persistenceStore + every { nftRuntimeStoreFactory.provide(network) } returns runtimeStore + coEvery { walletManagersFacade.getNFTCollections(userWalletId, network) } returns listOf(createSdkCollection()) + + // Act + createRepository().refreshCollections(userWalletId, listOf(network)) + + // Assert + val content = runtimeStore.getCollectionsSync().content + assertThat(content).isInstanceOf(NFTCollections.Content.Collections::class.java) + content as NFTCollections.Content.Collections + assertThat(content.source).isEqualTo(StatusSource.ACTUAL) + assertThat(content.collections).hasSize(1) + } + + @Test + fun `GIVEN runtime has collection missing in persistence WHEN refreshAssets THEN assets saved to runtime`() = + runTest { + // Arrange + val sdkCollection = createSdkCollection() + val collectionId = NFTSdkCollectionIdentifierConverter.convert(sdkCollection.identifier) + val runtimeStore = FakeNFTRuntimeStore(network) + val persistenceStore = mockk { + coEvery { getCollectionsSync() } returns null + coEvery { getSalePricesSync() } returns null + coEvery { saveCollections(any()) } returns Unit + } + every { nftPersistenceStoreFactory.provide(userWalletId, network) } returns persistenceStore + every { nftRuntimeStoreFactory.provide(network) } returns runtimeStore + coEvery { + walletManagersFacade.getNFTCollections(userWalletId, network) + } returns listOf(sdkCollection) + coEvery { + walletManagersFacade.getNFTAssets(userWalletId, network, sdkCollection.identifier) + } returns listOf(createSdkAsset()) + coEvery { walletManagersFacade.getNFTSalePrice(userWalletId, network, any(), any()) } returns null + + val repository = createRepository() + // seed runtime store with the fetched collection, persistence stays empty + repository.refreshCollections(userWalletId, listOf(network)) + + // Act + repository.refreshAssets(userWalletId, network, collectionId) + + // Assert + val content = runtimeStore.getCollectionsSync().content + assertThat(content).isInstanceOf(NFTCollections.Content.Collections::class.java) + content as NFTCollections.Content.Collections + val assets = content.collections.orEmpty().single().assets + assertThat(assets).isInstanceOf(NFTCollection.Assets.Value::class.java) + assets as NFTCollection.Assets.Value + assertThat(assets.items).hasSize(1) + } + + @Test + fun `GIVEN fetch returns no assets WHEN refreshAssets THEN empty loaded value saved to runtime`() = runTest { + // Arrange + val sdkCollection = createSdkCollection() + val collectionId = NFTSdkCollectionIdentifierConverter.convert(sdkCollection.identifier) + val runtimeStore = FakeNFTRuntimeStore(network) + val persistenceStore = mockk { + coEvery { getCollectionsSync() } returns null + coEvery { getSalePricesSync() } returns null + coEvery { saveCollections(any()) } returns Unit + } + every { nftPersistenceStoreFactory.provide(userWalletId, network) } returns persistenceStore + every { nftRuntimeStoreFactory.provide(network) } returns runtimeStore + coEvery { + walletManagersFacade.getNFTCollections(userWalletId, network) + } returns listOf(sdkCollection) + coEvery { + walletManagersFacade.getNFTAssets(userWalletId, network, sdkCollection.identifier) + } returns emptyList() + + val repository = createRepository() + repository.refreshCollections(userWalletId, listOf(network)) + + // Act + repository.refreshAssets(userWalletId, network, collectionId) + + // Assert + val content = runtimeStore.getCollectionsSync().content + assertThat(content).isInstanceOf(NFTCollections.Content.Collections::class.java) + content as NFTCollections.Content.Collections + val assets = content.collections.orEmpty().single().assets + assertThat(assets).isInstanceOf(NFTCollection.Assets.Value::class.java) + assets as NFTCollection.Assets.Value + assertThat(assets.items).isEmpty() + } + + @Test + fun `GIVEN no cached collections WHEN fetch fails THEN error state saved to runtime`() = runTest { + // Arrange + val runtimeStore = FakeNFTRuntimeStore(network) + val persistenceStore = mockk { + coEvery { getCollectionsSync() } returns null + coEvery { getSalePricesSync() } returns null + } + every { nftPersistenceStoreFactory.provide(userWalletId, network) } returns persistenceStore + every { nftRuntimeStoreFactory.provide(network) } returns runtimeStore + coEvery { walletManagersFacade.getNFTCollections(userWalletId, network) } throws IOException("HTTP 500") + + // Act + createRepository().refreshCollections(userWalletId, listOf(network)) + + // Assert + val content = runtimeStore.getCollectionsSync().content + assertThat(content).isInstanceOf(NFTCollections.Content.Error::class.java) + } + + @Test + fun `GIVEN cached collections WHEN fetch fails THEN cache marked as only cache`() = runTest { + // Arrange + val runtimeStore = FakeNFTRuntimeStore(network) + val persistenceStore = mockk { + coEvery { getCollectionsSync() } returns listOf(createSdkCollection()) + coEvery { getSalePricesSync() } returns null + } + every { nftPersistenceStoreFactory.provide(userWalletId, network) } returns persistenceStore + every { nftRuntimeStoreFactory.provide(network) } returns runtimeStore + coEvery { walletManagersFacade.getNFTCollections(userWalletId, network) } throws IOException("HTTP 500") + + // Act + createRepository().refreshCollections(userWalletId, listOf(network)) + + // Assert + val content = runtimeStore.getCollectionsSync().content + assertThat(content).isInstanceOf(NFTCollections.Content.Collections::class.java) + content as NFTCollections.Content.Collections + assertThat(content.source).isEqualTo(StatusSource.ONLY_CACHE) + assertThat(content.collections).hasSize(1) + } + + private fun createSdkCollection(assets: List = emptyList()) = SdkNFTCollection( + identifier = SdkNFTCollection.Identifier.EVM(tokenAddress = TOKEN_ADDRESS), + blockchainId = Blockchain.Ethereum.id, + name = "Test collection", + description = null, + logoUrl = null, + count = 1, + assets = assets, + ) + + private fun createSdkAsset() = SdkNFTAsset( + identifier = SdkNFTAsset.Identifier.EVM( + tokenId = BigInteger.ONE, + tokenAddress = TOKEN_ADDRESS, + contractType = SdkNFTAsset.Identifier.EVM.ContractType.ERC721, + ), + collectionIdentifier = SdkNFTCollection.Identifier.EVM(tokenAddress = TOKEN_ADDRESS), + blockchainId = Blockchain.Ethereum.id, + contractType = "ERC721", + owner = null, + name = "Test asset", + description = null, + amount = BigInteger.ONE, + decimals = 0, + salePrice = null, + rarity = null, + media = null, + traits = emptyList(), + ) + + private class FakeNFTRuntimeStore(private val network: Network) : NFTRuntimeStore { + + private var collections: NFTCollections = NFTCollections.empty(network) + private var prices: Map = emptyMap() + + override suspend fun initialize(collections: NFTCollections, prices: Map) { + this.collections = collections + this.prices = prices + } + + override fun getCollections(): Flow = flowOf(collections) + + override suspend fun getCollectionsSync(): NFTCollections = collections + + override fun getAsset( + collectionId: NFTCollection.Identifier, + assetId: NFTAsset.Identifier, + ): Flow = flowOf(null) + + override fun getSalePrice(assetId: NFTAsset.Identifier): Flow = + flowOf(prices[assetId] ?: NFTSalePrice.Empty(assetId)) + + override suspend fun getSalePriceSync(assetId: NFTAsset.Identifier): NFTSalePrice = + prices[assetId] ?: NFTSalePrice.Empty(assetId) + + override suspend fun saveCollections(collections: NFTCollections) { + this.collections = collections + } + + override suspend fun saveSalePrice(salePrice: NFTSalePrice) { + prices = prices + (salePrice.assetId to salePrice) + } + + override suspend fun clear() { + collections = NFTCollections.empty(network) + prices = emptyMap() + } + } + + private companion object { + const val TOKEN_ADDRESS = "0x0000000000000000000000000000000000000001" + } +} \ No newline at end of file From 4d170f0e62586c413a40352e2f28b55cb1632c64 Mon Sep 17 00:00:00 2001 From: Tangem Date: Thu, 9 Jul 2026 17:51:33 +0500 Subject: [PATCH 04/15] Updated on 2026-08-14 --- core/res/src/main/res/values-de/strings.xml | 26 +++++++- core/res/src/main/res/values-es/strings.xml | 6 +- core/res/src/main/res/values-fr/strings.xml | 27 +++++++-- core/res/src/main/res/values-ja/strings.xml | 22 ++++++- .../src/main/res/values-pt-rBR/strings.xml | 59 ++++++++++++++++--- core/res/src/main/res/values-ru/strings.xml | 38 ++++++++---- .../src/main/res/values-uk-rUA/strings.xml | 36 ++++++++--- .../src/main/res/values-zh-rCN/strings.xml | 9 ++- core/res/src/main/res/values/strings.xml | 30 ++++++++-- 9 files changed, 207 insertions(+), 46 deletions(-) diff --git a/core/res/src/main/res/values-de/strings.xml b/core/res/src/main/res/values-de/strings.xml index a038bc4d36..2642b05908 100644 --- a/core/res/src/main/res/values-de/strings.xml +++ b/core/res/src/main/res/values-de/strings.xml @@ -105,6 +105,7 @@ Adressen Adresse auswählen + Alles löschen Kontakt Name der Kontaktperson Adresse kopieren @@ -134,6 +135,7 @@ In Wallet speichern Dieser Kontakt wird mit dem Adressbuch dieser Wallet verknüpft. Es wurden keine Ergebnisse gefunden.\nVersuchen Sie es mit einem anderen Namen + Alle auswählen Netzwerk auswählen Adressbuch Nicht gespeicherte Änderungen @@ -1298,6 +1300,18 @@ Nach Guthaben Token organisieren Gruppe löschen + Berechtigte Cashback-Zahlungen werden wie folgt ausgezahlt: + Sie sind bereits eingeschrieben in %1$s + Zulässige Token + Anmelden + Sie sind erfolgreich eingeschrieben in %1$s + Diese Aktion existiert nicht mehr oder ist abgelaufen. + Kampagne nicht aktiv + Cashback-Konto auswählen + Melden Sie sich an %1$s + Ich stimme zu, dass %1$s + %1$s Bedingungen + Erhalten Sie bis Ende Juli bei jedem Swap ab 10.000 $ Cashback.\n\nDie Sätze steigen mit dem Volumen: 0,10% ab 10.000 $, 0,20% ab 20.000 $, 0,50% ab 100.000 $.\n\nMaximale Auszahlung: 500 $ pro Swap und 10.000 $ pro Wallet und Swap-Richtung, solange die Aktion läuft. Swaps von Stablecoins in andere Stablecoins sind ausgeschlossen.\n\nDie Auszahlung erfolgt wöchentlich an eine USDT- oder USDC-Adresse Ihrer Wahl. %s Unterstützung Push-Benachrichtigungen sind aktiviert, funktionieren aber erst, nachdem du Benachrichtigungen in den Geräteeinstellungen zugelassen hast. Benachrichtigungen zulassen @@ -1629,9 +1643,9 @@ Um mit dem Staking zu beginnen, musst Du zuerst Dein TON-Konto aktivieren. Kontoaktivierung Um mit dem Staking in TON zu beginnen, sende zunächst eine kleine Transaktion an Deine eigene Adresse – dadurch wird Deine Wallet aktiviert. - Für den Abschluss der Transaktion können zusätzlich zur Netzwerkgebühr bis zu 0,2 TON erforderlich sein. Nicht genutzte Beträge werden zurückerstattet. - Für diesen Vorgang sind zusätzlich zur Netzwerkgebühr 0,2 TON erforderlich. Bitte lade Dein Guthaben auf. - TON-Reserve erforderlich + Für den Abschluss der Transaktion können zusätzlich zur Netzwerkgebühr bis zu 0,2 GRAM erforderlich sein. Nicht genutzte Beträge werden zurückerstattet. + Für diesen Vorgang sind zusätzlich zur Netzwerkgebühr 0,2 GRAM erforderlich. Bitte lade Dein Guthaben auf. + GRAM-Reserve erforderlich Durch diese Aktion werden andere Positionen geschlossen oder gemäß den Netzwerkregeln in den Auszahlungsstatus versetzt. Positionsstatus Entsperre dein Geld, um es aus dem Staking-Prozess abzuheben. Das Freischalten nimmt %s. @@ -1900,6 +1914,7 @@ PIN-Code ändern Kehren Sie zur App zurück, falls Sie ihn vergessen. Karte + Fehler beim Laden Tarif wechseln Kartenbezogen Planbezogen @@ -2037,6 +2052,7 @@ Laden Sie Ihr Konto mit einem beliebigen Token aus Ihrer Wallet auf Aus Ihrer Tangem Wallet USDC im Polygon + Visavorteile Gelder aus erstatteten Käufen werden nicht auf Ihr On-Chain-Guthaben zurückerstattet und stehen nicht für Abhebungen zur Verfügung, bleiben aber auf Ihrem Kartenguthaben für Einkäufe verfügbar Bitte beachten Sie Ihr PIN-Code @@ -2080,6 +2096,10 @@ QR-Code anzeigen Die Daten des Tokens konnten nicht geladen werden. Gehe zum Tauschen + Letzte Aktualisierung: %1$s + Negativer Ausblick + Neutraler Ausblick + Positiver Ausblick Token-Zusammenfassung Tausche diesen Token gegen einen anderen zu %1$s Servicegebühren von Februar %2$s-%3$s. Tausche mit Changelly, %s Gebühren diff --git a/core/res/src/main/res/values-es/strings.xml b/core/res/src/main/res/values-es/strings.xml index d3a635d982..c139dd7025 100644 --- a/core/res/src/main/res/values-es/strings.xml +++ b/core/res/src/main/res/values-es/strings.xml @@ -1580,9 +1580,9 @@ Para empezar a hacer staking, primero debes activar tu cuenta de TON. Activación de cuenta Para empezar a hacer staking en TON, realice primero una transacción de salida de cualquier importe - esto activará su billetera. - Es posible que se requieran hasta 0.2 TON además de la tarifa de red para completar la transacción. Cualquier cantidad no utilizada será reembolsada. - Se requieren 0.2 TON para realizar esta operación, además de la tarifa de red. Por favor, recargue su saldo. - Se requiere reserva de TON + Es posible que se requieran hasta 0.2 GRAM además de la tarifa de red para completar la transacción. Cualquier cantidad no utilizada será reembolsada. + Se requieren 0.2 GRAM para realizar esta operación, además de la tarifa de red. Por favor, recargue su saldo. + Se requiere reserva de GRAM Esta acción cerrará otras posiciones o las cambiará al estado de retiro, de acuerdo con las reglas de la red. Estado de las posiciones Desbloquee su dinero para retirarlo del proceso de staking. El desbloqueo demora %s minuto. diff --git a/core/res/src/main/res/values-fr/strings.xml b/core/res/src/main/res/values-fr/strings.xml index 600ea449c2..bebe09f137 100644 --- a/core/res/src/main/res/values-fr/strings.xml +++ b/core/res/src/main/res/values-fr/strings.xml @@ -94,26 +94,44 @@ Ajouter une adresse Ajouter une adresse et sélectionner un réseau Ajouter le contact + Adresse copiée + Cette adresse est déjà enregistrée sous le nom %1$s adresse adresses + Choisir une adresse + Tout supprimer Contact Nom du contact Copier l\'adresse + Contact sauvegardé Nous n\'avons pas pu créer le contact. Veuillez réessayer plus tard. + Supprimer le contact Ce contact sera supprimé de tous vos carnets d\'adresses + \"%1$s\" n\'a qu\'une seule adresse. La supprimer va aussi supprimer le contact. Continuer? Nous n\'avons pas pu supprimer le contact. Veuillez réessayer plus tard. Gérer les contacts & adresses Oui, annuler Modifier l\'adresse Entrer l\'adresse + Adresse invalide Non, continuer + Vous ne pouvez pas créer plus de 20 adresses. Supprimez-en une pour en ajouter une nouvelle. + Impossible d\'ajouter une nouvelle adresse + Le nom du contact est requis + Le nom du contact contient des caractères invalides + Le nom du contact ne doit pas dépasser 50 caractères + Ce nom est déjà utilisé dans ce portefeuille Nouveau contact Aucun contact pour le moment Les contacts que vous ajouterez vont apparaître ici Supprimer l\'adresse + Enregistrer le contact + Enregistrer dans le portefeuille Ce contact va être lié au carnet d\'adresses de ce portefeuille. + Aucun résultat.\nEssayez un autre nom + Tout sélectionner Sélectionner un réseau Carnet d\'adresses Modifications non enregistrées @@ -1182,6 +1200,7 @@ Aucun jeton pris en charge n\'a été trouvé Ce code QR contient des paramètres non reconnus : %s. Si vous continuez, certaines informations de paiement risquent d\'être perdues. Paramètres inconnus + Envoyer en échangeant vers un autre token Recharge rapide Aucun mémo requis %1$s (%2$s) sur le réseau %3$s @@ -1355,7 +1374,7 @@ Le total dépasse le solde Êtes-vous sûr de vouloir modifier le token de réception ? Cela réinitialisera les données que vous avez saisies précédemment. Changement de token - Échanger et envoyer + Échanger et Envoyer Poursuivre l\'échange ? Cela effacera vos données précédentes. Confirmer la conversion L\'envoi de toute autre crypto entraînera sa perte irréversible. @@ -1459,9 +1478,9 @@ Pour commencer le staking, vous devez d’abord activer votre compte TON Activation du compte Pour commencer à staker TON, effectuez d\'abord une transaction sortante de n\'importe quel montant — cela activera votre portefeuille. - Jusqu\'à 0,2 TON peuvent être requis en plus des frais de réseau pour terminer la transaction. Tout montant non utilisé sera remboursé. - 0,2 TON requis pour effectuer cette opération, en plus des frais de réseau. Veuillez recharger votre solde. - Réserve de TON requise + Jusqu\'à 0,2 GRAM peuvent être requis en plus des frais de réseau pour terminer la transaction. Tout montant non utilisé sera remboursé. + 0,2 GRAM requis pour effectuer cette opération, en plus des frais de réseau. Veuillez recharger votre solde. + Réserve de GRAM requise Cette action fermera d\'autres positions ou les fera passer au statut de retrait, conformément aux règles du réseau. Statut des positions Débloquez votre argent pour le retirer du processus de staking. Le déverrouillage prend %s. diff --git a/core/res/src/main/res/values-ja/strings.xml b/core/res/src/main/res/values-ja/strings.xml index 0cb5e2e92d..2c2737a84a 100644 --- a/core/res/src/main/res/values-ja/strings.xml +++ b/core/res/src/main/res/values-ja/strings.xml @@ -98,14 +98,20 @@ アドレスを追加 アドレスを追加し、ネットワークを選択してください。 連絡先を追加 + アドレスをコピーしました + このアドレスはすでに%1$sとして保存されています %d件のアドレス + アドレスを選択 連絡先 連絡先名 アドレスをコピー + 連絡先を保存しました 連絡先を作成できませんでした。しばらくしてからもう一度お試しください。 + 連絡先を削除 この連絡先は、すべてのアドレス帳から削除されます。 + 「%1$s」には1つのアドレスしかありません。削除すると、連絡先も削除されます。続行しますか? 連絡先を削除できませんでした。しばらくしてからもう一度お試しください。 連絡先とアドレスを管理 破棄 @@ -113,11 +119,20 @@ アドレスを入力 無効なアドレス 編集を続ける + 20件を超えるアドレスは作成できません。新しく追加するには、既存のアドレスを1件削除してください。 + 新しいアドレスを追加できません + 連絡先名を入力してください + 連絡先名に使用できない文字が含まれています + 連絡先名は50文字以内で入力してください + その名前はこのウォレットですでに使用されています 新しい連絡先 連絡先はまだありません 追加した連絡先はここに表示されます。 アドレスを削除 + 連絡先を保存 + ウォレットに保存 この連絡先は、このウォレットのアドレス帳に紐付けられます。 + 結果が見つかりませんでした。\n別の名前を試してください。 ネットワークを選択 連絡先 保存されていない変更 @@ -1269,6 +1284,7 @@ クレジットカードまたは銀行口座 アドレスまたはQRコードを共有してください 暗号資産を安全に売却 + 別のトークンにスワップして送る 別のウォレットに送信 ポートフォリオ間で その他 @@ -1558,9 +1574,9 @@ ステーキングを開始するには、まずTONアカウントを有効化してください。 アカウントの有効化 TONのステーキングを開始するには、まず自分のアドレスに少額の取引を送信します。これによりウォレットが有効になります。 - 取引を完了するには、ネットワーク手数料に加えて最大0.2TONが必要になる場合があります。未使用分は返金されます。 - この操作を続行するには、ネットワーク手数料に加えて0.2 TONが必要です。残高を補充してください。 - TONの準備金が必要です + 取引を完了するには、ネットワーク手数料に加えて最大0.2GRAMが必要になる場合があります。未使用分は返金されます。 + この操作を続行するには、ネットワーク手数料に加えて0.2 GRAMが必要です。残高を補充してください。 + GRAMの準備金が必要です このアクションは、ネットワークのルールに従って、他のポジションをクローズするか、引き出しステータスに切り替えます。 ポジション状況 資金をステーキングから引き出すには、ロックを解除してください。ロック解除には%sかかります。 diff --git a/core/res/src/main/res/values-pt-rBR/strings.xml b/core/res/src/main/res/values-pt-rBR/strings.xml index 0b5e395924..0d25eceb4f 100644 --- a/core/res/src/main/res/values-pt-rBR/strings.xml +++ b/core/res/src/main/res/values-pt-rBR/strings.xml @@ -105,6 +105,7 @@ %d endereços Escolha o endereço + Limpar tudo Contato Nome do contato Copiar endereço @@ -134,6 +135,7 @@ Salvar na carteira Este contato será vinculado à agenda de endereços desta carteira. Nenhum resultado encontrado.\nTente outro nome + Selecionar tudo Selecione a rede Agenda de endereços Alterações não salvas @@ -1174,13 +1176,13 @@ Biometria Leia mais sobre a frase-semente. - Escreva esta 1palavra na ordem indicada abaixo e guarde-a em um local seguro e secreto. - Escreva estas %dpalavras na ordem indicada abaixo e guarde-as em um local seguro e secreto. + Escreva esta %d palavra na ordem indicada abaixo e guarde-a em um local seguro e secreto. + Escreva estas %d palavras na ordem indicada abaixo e guarde-as em um local seguro e secreto. Sua frase-semente - %dpalavra - %dpalavras + %d palavra + %d palavras Para importar sua carteira, insira sua frase mnemônica no campo abaixo. Gerar frase-semente @@ -1284,6 +1286,8 @@ Disponível em Disponível até Você recebe + Este token não é compatível. Escolha outro token para comprar. + %s não é suportado O serviço é fornecido por um provedor externo. A Tangem não se responsabiliza por ele. Você pode fechar esta tela e verificar o status da transação na tela de detalhes do token. Até @@ -1296,6 +1300,18 @@ Por equilíbrio Organizar tokens Desagrupar + O cashback elegível será distribuído para: + Você já está matriculado(a) em %1$s + Tokens elegíveis + Inscreva-se + Você se inscreveu com sucesso em %1$s + Esta campanha não existe mais ou expirou. + Campanha inativa + Selecione a conta de cashback + Inscreva-se em %1$s + Concordo com %1$s + %1$s Termos + Ganhe cashback em todas as suas transações a partir de US$ 10.000 até o final de julho.\n\nAs taxas aumentam conforme o tamanho da transação: 0,10% a partir de US$ 10 mil, 0,20% a partir de US$ 20 mil, 0,50% A partir de US$ 100 mil.\n\nPagamento máximo: US$ 500 por troca e US$ 10.000 por carteira por direção de troca, enquanto durar a campanha. Trocas de stablecoin por stablecoin estão excluídas.\n\nO pagamento é feito semanalmente em USDT ou USDC, no endereço de sua escolha. %s suporte As notificações push estão ativadas, mas só funcionarão depois que você as permitir nas configurações do seu dispositivo. Permitir notificações @@ -1627,9 +1643,9 @@ Para começar a fazer staking, você precisa ativar sua conta TON primeiro. Ativação de conta Para começar a fazer staking de TON, primeiro envie uma pequena transação para o seu próprio endereço — isso ativará sua carteira. - Poderá ser necessário um valor adicional de até 0,2 TON, além da taxa de rede, para concluir a transação. Qualquer valor não utilizado será reembolsado. - Para prosseguir com esta operação, é necessário um saldo de 0,2 TON, além da taxa de rede. Por favor, recarregue seu saldo. - Reserva TON necessária + Poderá ser necessário um valor adicional de até 0,2 GRAM, além da taxa de rede, para concluir a transação. Qualquer valor não utilizado será reembolsado. + Para prosseguir com esta operação, é necessário um saldo de 0,2 GRAM, além da taxa de rede. Por favor, recarregue seu saldo. + Reserva GRAM necessária Essa ação encerrará outras posições ou as converterá para o status de saque, de acordo com as regras da rede. Status das posições Desbloqueie seu dinheiro para retirá-lo do processo de staking. O desbloqueio leva... %s. @@ -1819,6 +1835,16 @@ Sua conta foi encerrada Não é possível usar em dispositivos com root. Saldo disponível + ACH + FedWire + Taxa de acesso à rampa + Os USD recebidos serão convertidos para USDC na proporção de 1:1. + A transferência bancária pode levar de 1 a 2 dias úteis + Ao utilizar o serviço, você concorda com o provedor. %1$s e %2$s + Mostrar detalhes + Isso pode levar um pouco de tempo. + Preparando seus dados bancários + Depósito somente via ACH ou FedWire. Transferências SWIFT serão devolvidas. Ocultar KYC da tela principal Cartão Tangem Pay 1 Adicionar fundos @@ -1888,6 +1914,7 @@ Alterar código PIN Volte ao aplicativo se você se esquecer. Cartão + Erro ao carregar Alterar plano relacionado a cartões Plano relacionado @@ -2018,11 +2045,14 @@ Use USDC para pagamentos do dia a dia. O serviço Tangem Pay está temporariamente inacessível. Tangem Pay + Receba USD fiduciário via ACH/FedWire + Transferência bancária Envie USDC Polygon para o endereço da sua conta De outra carteira ou exchange Recarregue sua conta com qualquer token da carteira Da sua Tangem Wallet USDC na rede Polygon + Benefícios do visto Os fundos de compras reembolsadas não serão devolvidos ao seu saldo na blockchain nem estarão disponíveis para saque, mas permanecerão no saldo do seu cartão para compras futuras Observe Seu código PIN @@ -2064,6 +2094,13 @@ Não foi possível ocultar %s N/A Mostrar código QR + Não foi possível carregar os dados do token. + Ir para o swap + Última atualização: %1$s + Perspectiva negativa + Perspectiva neutra + Perspectiva positiva + Resumo do token Troque este token por outro em %1$s taxas de serviço a partir de fevereiro %2$s-%3$s. Trocar com Changelly, %s tarifas Troque agora @@ -2144,6 +2181,12 @@ Renomear carteira Desbloquear tudo Desbloqueie tudo com %s + Número de conta + Endereço do banco + Nome do banco + Endereço do beneficiário + Nome do beneficiário + Número de roteamento Conta virtual Ainda não há transações. Comece a gastar e veja o histórico aqui. Verificado AML @@ -2656,6 +2699,8 @@ %1$s retirado de Aave Modo de rendimento inicializado Modo de rendimento reativado + Devolvido + Fornecido Fornecimento para Aave %1$s fornecido à Aave Retirar do Aave diff --git a/core/res/src/main/res/values-ru/strings.xml b/core/res/src/main/res/values-ru/strings.xml index 0354f68c22..d111e36499 100644 --- a/core/res/src/main/res/values-ru/strings.xml +++ b/core/res/src/main/res/values-ru/strings.xml @@ -81,7 +81,7 @@ Выберите токен для получения Выберите токен для обмена Пополнить - Обмен + Обменять Перевести Добавить в портфель Добавить токены @@ -98,17 +98,24 @@ Добавить адрес Добавить адрес и выбрать сеть Добавить контакт + Адрес скопирован + Этот адрес уже сохранен как %1$s - адрес - адреса - адресов - адресов + %d адрес + %d адреса + %d адресов + %d адресов + Выберите адрес + Очистить все Контакт Имя контакта Копировать адрес + Контакт сохранен Не удалось создать контакт. Пожалуйста, попробуйте позже. + Удалить контакт Этот контакт будет удален из всех ваших адресных книг + У \"%1$s\" только один адрес. Его удаление также приведет к удалению контакта. Продолжить? Не удалось удалить контакт. Пожалуйста, попробуйте позже. Управление контактами и адресами Отменить @@ -116,11 +123,21 @@ Ввести адрес Неверный адрес Продолжить + Вы не можете создать более 20 адресов. Удалите один, чтобы добавить новый. + Невозможно добавить новый адрес + Имя контакта обязательно + Имя контакта содержит недопустимые символы + Имя контакта должно содержать не более 50 символов + Это имя уже используется в этом кошельке Новый контакт Нет добавленных контактов Здесь отобразятся добавленные вами контакты. Удалить адрес + Сохранить контакт + Сохранить в кошелек Этот контакт будет привязан к этому кошельку в адресной книге. + Ничего не найдено.\nПопробуйте другое имя + Выбрать все Выбрать сеть Адресная книга Несохраненные изменения @@ -1335,6 +1352,7 @@ Банковская карта или банковский счет Поделитесь своим адресом или QR-кодом Продавайте криптовалюту безопасно + Отправить с обменом на другой токен Отправить на другой кошелек Между вашими портфелями Другие @@ -1526,7 +1544,7 @@ Отправляемая сумма превышает остаток Вы уверены, что хотите изменить токен для получения? Это действие сбросит ранее введённые данные. Изменение токена - Обмен и отправка + Обменять и отправить Продолжить с обменом? Это действие удалит предыдущие данные Подтвердить конвертацию Отправка любой другой валюты приведёт к её безвозвратной потере. @@ -1631,9 +1649,9 @@ Чтобы начать стейкинг, сначала активируйте свой TON-аккаунт. Активация аккаунта Чтобы начать стейкинг в TON, сначала отправьте небольшую транзакцию на свой же адрес — это активирует ваш кошелёк. - До 0.2 TON может потребоваться сверх сетевой комиссии для завершения транзакции. Неиспользованная часть будет возвращена. - Для выполнения операции требуется дополнительно 0.2 TON, помимо сетевой комиссии. Пожалуйста, пополните баланс. - Требуется резерв TON + До 0.2 GRAM может потребоваться сверх сетевой комиссии для завершения транзакции. Неиспользованная часть будет возвращена. + Для выполнения операции требуется дополнительно 0.2 GRAM, помимо сетевой комиссии. Пожалуйста, пополните баланс. + Требуется резерв GRAM Это действие закроет другие позиции или переведёт их в статус вывода средств в соответствии с правилами сети. Статус позиций Разблокируйте свои средства, чтобы вывести их из стейкинга. Разблокировка займёт %s. @@ -2047,7 +2065,7 @@ Токен в сети %%image%% %1$s %s сеть %1$s в сети %2$s - %1$sв %%image%% %2$s + %1$s в %%image%% %2$s %1$s в %2$s %%image%% Токен %1$s (%2$s) является основной валютой в сети %3$s и не может быть скрыт до тех пор, пока у вас в списке есть другие токены этой сети Невозможно скрыть %s diff --git a/core/res/src/main/res/values-uk-rUA/strings.xml b/core/res/src/main/res/values-uk-rUA/strings.xml index 1176df0144..87fbf2490b 100644 --- a/core/res/src/main/res/values-uk-rUA/strings.xml +++ b/core/res/src/main/res/values-uk-rUA/strings.xml @@ -81,7 +81,7 @@ Оберіть токен для отримання Оберіть токен для обміну Поповнити - Обмін + Обміняти Переказ Додати до портфеля Додати токени @@ -98,17 +98,24 @@ Додати адресу Додати адресу та вибрати мережу Додати контакт + Адресу скопійовано + Цю адресу вже збережено як %1$s - адреса - адреси - адрес - адрес + %d адреса + %d адреси + %d адрес + %d адрес + Оберіть адресу + Очистити все Контакт Ім\'я контакту Скопіювати адресу + Контакт збережено Не вдалося створити контакт. Будь ласка, спробуйте пізніше. + Видалити контакт Цей контакт буде видалено з усіх ваших адресних книг + У \"%1$s\" лише одна адреса. Її видалення також призведе до видалення контакту. Продовжити? Не вдалося видалити контакт. Будь ласка, спробуйте пізніше. Керування контактами та адресами Скасувати @@ -116,11 +123,21 @@ Ввести адресу Недійсна адреса Продовжити + Ви не можете створити більше 20 адрес. Видаліть одну, щоб додати нову. + Неможливо додати нову адресу + Ім\'я контакту обов\'язкове + Ім\'я контакту містить недопустимі символи + Ім\'я контакту повинно містити не більше 50 символів + Це ім\'я вже використовується в цьому гаманці Новий контакт Немає доданих контактів Тут відображатимуться додані вами контакти. Видалити адресу + Зберегти контакт + Зберегти в гаманець Цей контакт буде прив\'язано до цього гаманця в адресній книзі. + Нічого не знайдено.\nСпробуйте інше ім\'я + Вибрати все Вибрати мережу Адресна книга Незбережені зміни @@ -1335,6 +1352,7 @@ Банківська картка або банківський рахунок Поділіться своєю адресою або QR-кодом Безпечно продавайте криптовалюту + Надіслати з обміном на інший токен Надіслати на інший гаманець Між вашими портфелями Інші @@ -1526,7 +1544,7 @@ Сума, що відправляється, перевищує залишок Ви впевнені, що хочете змінити токен отримання? Це призведе до скидання раніше введених даних. Зміна токену - Обмін та надсилання + Обміняти та надіслати Продовжити обмін? Це очистить ваші попередні дані. Підтвердити конвертацію Надсилання будь-якої іншої валюти призведе до її незворотної втрати. @@ -1631,9 +1649,9 @@ Щоб розпочати стейкінг, спочатку активуйте свій TON-акаунт. Активація акаунту Щоб почати стейкінг в TON, спочатку здійсніть вихідну транзакцію на будь-яку суму — це активує ваш гаманець. - До 0.2 TON може знадобитися додатково мережевої комісії для завершення транзакції. Невикористана частина буде повернута. - Для виконання операції потребується додатково 0.2 TON, крім мережевої комісії. Будь ласка, поповніть баланс. - Потрібен резерв TON + До 0.2 GRAM може знадобитися додатково мережевої комісії для завершення транзакції. Невикористана частина буде повернута. + Для виконання операції потребується додатково 0.2 GRAM, крім мережевої комісії. Будь ласка, поповніть баланс. + Потрібен резерв GRAM Ця дія закриє інші позиції або переведе їх у статус виведення, відповідно до правил мережі. Статус позицій Розблокуйте свої кошти, щоб вивести їх зі стейкінгу. Розблокування займе %s. diff --git a/core/res/src/main/res/values-zh-rCN/strings.xml b/core/res/src/main/res/values-zh-rCN/strings.xml index 3a428f777a..ef6f3f2e6c 100644 --- a/core/res/src/main/res/values-zh-rCN/strings.xml +++ b/core/res/src/main/res/values-zh-rCN/strings.xml @@ -104,6 +104,7 @@ %d地址\n%d地址 选择地址 + 全部清除 联系人 联系人姓名 复制地址 @@ -133,6 +134,7 @@ 保存到钱包 该联系人将与该钱包的通讯录关联。 未找到结果。\n请尝试其他名称 + 全选 选择网络 地址簿 未保存的更改 @@ -1278,6 +1280,7 @@ 信用卡或银行账户 分享您的地址或二维码 安全出售加密货币 + 发送并兑换成另一种代币 发送到另一个钱包 在您的投资组合之间 其他 @@ -1565,9 +1568,9 @@ 要开始质押,您需要先激活您的 TON 账户。 激活账户 要开始在 TON 上进行质押,首先向您自己的地址发送一笔小额交易——这将激活您的钱包。 - 除网络费用外,完成交易可能还需要额外支付最多 0.2 TON 的费用。任何未使用的金额将予以退还。 - 除网络费用外,本次操作还需要 0.2 TON。请充值。 - 需要TON储备 + 除网络费用外,完成交易可能还需要额外支付最多 0.2 GRAM 的费用。任何未使用的金额将予以退还。 + 除网络费用外,本次操作还需要 0.2 GRAM。请充值。 + 需要GRAM储备 根据网络规则,此操作将关闭其他仓位或将其切换为提现状态。 仓位状态 解锁您的资金即可从质押过程中提款。解锁需要一定时间。 %s。 diff --git a/core/res/src/main/res/values/strings.xml b/core/res/src/main/res/values/strings.xml index 7540b870fa..175671a742 100644 --- a/core/res/src/main/res/values/strings.xml +++ b/core/res/src/main/res/values/strings.xml @@ -105,6 +105,7 @@ %d addresses Choose address + Clear All Contact Contact name Copy address @@ -134,6 +135,7 @@ Save to Wallet This contact will be linked to this wallet’s address book. No results found.\nTry another name + Select All Select network Address book Unsaved changes @@ -636,6 +638,8 @@ Long transaction time The transaction amount was refunded in %1$s to your wallet due to OKX or bridge rules. %2$s The amount was refunded in %1$s (%2$s network) + Your funds have been refunded in %1$s to your wallet on the %2$s network, in accordance with OKX exchange rules. + Refunded in %s Visit provider’s website for verification KYC verification required by provider Purchase completed @@ -1299,6 +1303,18 @@ By balance Organize tokens Ungroup + Eligible cashback will be distributed to: + You\'re already enrolled in %1$s + Eligible tokens + Enroll + You\'re successfully enrolled in %1$s + This campaign no longer exists or has expired + Campaign not active + Select cashback account + Enroll in %1$s + I agree with %1$s + %1$s Terms + Earn cashback on every swap from $10K until the end of July.\n\nRates step up with size: 0.10% from $10K, 0.20% from $20K, 0.50% from $100K.\n\nMax payout: $500 per swap, and $10,000 per wallet per swap direction until campaign lasts. Stable coin into stablecoin swaps are excluded.\n\nPayout arrives weekly in USDT or USDC address of your choice. %s support Push Notifications are enabled but won\'t work until you allow them Allow notifications @@ -1522,7 +1538,7 @@ Total amount exceeds balance Are you sure you want to change the receiving token? This will reset your previously entered data. Changing token - Swap and send + Swap & Send Proceed with swap? This will clear your previous data. Confirm Conversion Sending any other currency will result in its irreversible loss. @@ -1630,9 +1646,9 @@ To begin staking, you need to activate your TON account first. Account activation To start staking in TON, first send a small transaction to your own address — this will activate your wallet. - Up to 0.2 TON may be required in addition to the network fee to complete the transaction. Any unused amount will be refunded. - 0.2 TON is required to proceed with this operation, in addition to the network fee. Please top up your balance. - TON reserve required + Up to 0.2 GRAM may be required in addition to the network fee to complete the transaction. Any unused amount will be refunded. + 0.2 GRAM is required to proceed with this operation, in addition to the network fee. Please top up your balance. + GRAM reserve required This action will close other positions or switch them to withdrawal status, according to network rules. Positions status Unlock your money to withdraw it from staking process. Unlocking takes %s. @@ -1901,6 +1917,7 @@ Change PIN-code Come back to the app if you forget it. Card + Error loading Change plan Card related Plan related @@ -2038,6 +2055,7 @@ Use crypto from your wallet to top up your payment account From your Tangem Wallet USDC on Polygon network + Visa Benefits Funds from refunded purchases won’t be returned your on-chain Polygon balance or be available for withdrawal, but will stay on your card balance for purchases Please note Your PIN code @@ -2081,6 +2099,10 @@ Show QR code Can’t load data of the token Go to swap + Last update: %1$s + Negative outlook + Neutral outlook + Positive outlook Token summary Exchange this token for another at %1$s service fees from February %2$s-%3$s. Swap with Changelly, %s fees From fa478206b58c84e2e48b2e6915075f2cf0caf8ab Mon Sep 17 00:00:00 2001 From: Tangem Date: Thu, 9 Jul 2026 17:52:25 +0500 Subject: [PATCH 05/15] Updated on 2026-08-14 --- .../swap/model/SwapNotificationsFactory.kt | 12 +- .../model/SwapNotificationsFactoryTest.kt | 276 ++++++++++++++++++ 2 files changed, 285 insertions(+), 3 deletions(-) create mode 100644 features/swap/impl/src/test/java/com/tangem/feature/swap/model/SwapNotificationsFactoryTest.kt diff --git a/features/swap/impl/src/main/java/com/tangem/feature/swap/model/SwapNotificationsFactory.kt b/features/swap/impl/src/main/java/com/tangem/feature/swap/model/SwapNotificationsFactory.kt index 67b1f7056c..4373d09847 100644 --- a/features/swap/impl/src/main/java/com/tangem/feature/swap/model/SwapNotificationsFactory.kt +++ b/features/swap/impl/src/main/java/com/tangem/feature/swap/model/SwapNotificationsFactory.kt @@ -25,6 +25,7 @@ import com.tangem.domain.transaction.usecase.gasless.IsGaslessFeeSupportedForNet import com.tangem.feature.swap.domain.models.ExpressDataError import com.tangem.feature.swap.domain.models.SwapAmount import com.tangem.feature.swap.domain.models.domain.ExchangeProviderType +import com.tangem.feature.swap.domain.models.domain.ExpressTxType import com.tangem.feature.swap.domain.models.domain.SwapBalanceStatus import com.tangem.feature.swap.domain.models.ui.PermissionDataState import com.tangem.feature.swap.domain.models.ui.PriceImpact @@ -318,11 +319,16 @@ internal class SwapNotificationsFactory( val shouldShowCoverWarning = quoteModel.permissionState !is PermissionDataState.PermissionLoading && feeCryptoCurrencyStatus.currency != fromCurrency - val isCEXProvider = quoteModel.swapProvider.type == ExchangeProviderType.CEX + // A DEX-typed provider whose quote returned txType=SEND executes as a CEX-style transfer, + // so it must follow the same gasless suppression rule as a real CEX provider. + val isCexLikeFlow = quoteModel.swapProvider.type == ExchangeProviderType.CEX || + quoteModel.txType == ExpressTxType.SEND - val isNotEnoughFee = insufficientFee != null && !isCEXProvider + val isNotEnoughFee = insufficientFee != null - val isGaslessAvailable = isGaslessFeeSupportedForNetwork(fromCurrency.network) && isCEXProvider + // Suppress only when the user can actually switch the fee to a token via the gasless + // selector; on networks without gasless support the warning must show for CEX too. + val isGaslessAvailable = isGaslessFeeSupportedForNetwork(fromCurrency.network) && isCexLikeFlow if (shouldShowCoverWarning && !isGaslessAvailable && isNotEnoughFee) { add( if (fromCurrency.id == feeCryptoCurrencyStatus.currency.id) { diff --git a/features/swap/impl/src/test/java/com/tangem/feature/swap/model/SwapNotificationsFactoryTest.kt b/features/swap/impl/src/test/java/com/tangem/feature/swap/model/SwapNotificationsFactoryTest.kt new file mode 100644 index 0000000000..52c26f5da8 --- /dev/null +++ b/features/swap/impl/src/test/java/com/tangem/feature/swap/model/SwapNotificationsFactoryTest.kt @@ -0,0 +1,276 @@ +package com.tangem.feature.swap.model + +import com.google.common.truth.Truth.assertThat +import com.tangem.common.routing.AppRouter +import com.tangem.domain.appcurrency.model.AppCurrency +import com.tangem.domain.models.account.Account +import com.tangem.domain.models.currency.CryptoCurrency +import com.tangem.domain.models.currency.CryptoCurrencyStatus +import com.tangem.domain.models.network.Network +import com.tangem.domain.models.wallet.UserWallet +import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.domain.swap.models.SwapCurrencyStatus +import com.tangem.domain.transaction.usecase.gasless.IsGaslessFeeSupportedForNetwork +import com.tangem.feature.swap.domain.models.SwapAmount +import com.tangem.feature.swap.domain.models.domain.ExchangeProviderType +import com.tangem.feature.swap.domain.models.domain.ExpressTxType +import com.tangem.feature.swap.domain.models.domain.PreparedSwapConfigState +import com.tangem.feature.swap.domain.models.domain.RateType +import com.tangem.feature.swap.domain.models.domain.SwapBalanceStatus +import com.tangem.feature.swap.domain.models.domain.SwapProvider +import com.tangem.feature.swap.domain.models.ui.PermissionDataState +import com.tangem.feature.swap.domain.models.ui.PriceImpact +import com.tangem.feature.swap.domain.models.ui.SwapState +import com.tangem.feature.swap.domain.models.ui.TokenSwapInfo +import com.tangem.feature.swap.models.UiActions +import com.tangem.feature.swap.models.states.SwapNotificationUM +import com.tangem.utils.Provider +import io.mockk.clearMocks +import io.mockk.every +import io.mockk.mockk +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import java.math.BigDecimal + +/** + * Tests for [SwapNotificationsFactory.getConfirmationStateNotifications], focused on the + * `UnableToCoverFeeWarning` gating ([REDACTED_TASK_KEY]): + * + * | flow | gasless network | expected for InsufficientFee | + * |-------------------------------|-----------------|--------------------------------| + * | CEX | no | warning shown (the bug fix) | + * | CEX | yes | suppressed (fee → token) | + * | DEX (txType=null) | yes | warning shown (DEX unchanged) | + * | DEX + txType=SEND (CEX-like) | yes | suppressed like a real CEX | + * | DEX + txType=SEND (CEX-like) | no | warning shown | + */ +internal class SwapNotificationsFactoryTest { + + private val actions: UiActions = mockk(relaxed = true) + private val isGaslessFeeSupportedForNetwork: IsGaslessFeeSupportedForNetwork = mockk() + private val appCurrencyProvider: Provider = Provider { AppCurrency.Default } + private val appRouter: AppRouter = mockk() + + private val factory = SwapNotificationsFactory( + actions = actions, + isGaslessFeeSupportedForNetwork = isGaslessFeeSupportedForNetwork, + appCurrencyProvider = appCurrencyProvider, + ) + + private val userWalletId = UserWalletId(stringValue = "deadbeef") + private val userWallet: UserWallet = mockk(relaxed = true) { + every { walletId } returns userWalletId + } + + @BeforeEach + fun resetMocks() { + clearMocks(isGaslessFeeSupportedForNetwork, appRouter) + every { appRouter.stack } returns emptyList() + } + + @Test + fun `GIVEN CEX and no gasless support WHEN insufficient fee THEN cover fee warning shown`() { + // Arrange + every { isGaslessFeeSupportedForNetwork(any()) } returns false + val quoteModel = buildQuotesLoadedState(providerType = ExchangeProviderType.CEX) + + // Act + val notifications = factory.getConfirmationStateNotifications( + quoteModel = quoteModel, + feeCryptoCurrencyStatus = buildCoinFeeStatus(), + swapFee = null, + feeError = null, + appRouter = appRouter, + ) + + // Assert + val warning = notifications.filterIsInstance().single() + assertThat(warning.currencyName).isEqualTo("Ethereum") + assertThat(warning.currencySymbol).isEqualTo("ETH") + } + + @Test + fun `GIVEN CEX and gasless support WHEN insufficient fee THEN cover fee warning suppressed`() { + // Arrange + every { isGaslessFeeSupportedForNetwork(any()) } returns true + val quoteModel = buildQuotesLoadedState(providerType = ExchangeProviderType.CEX) + + // Act + val notifications = factory.getConfirmationStateNotifications( + quoteModel = quoteModel, + feeCryptoCurrencyStatus = buildCoinFeeStatus(), + swapFee = null, + feeError = null, + appRouter = appRouter, + ) + + // Assert + assertThat(notifications.filterIsInstance()).isEmpty() + } + + @Test + fun `GIVEN DEX and gasless support WHEN insufficient fee THEN cover fee warning shown`() { + // Arrange + every { isGaslessFeeSupportedForNetwork(any()) } returns true + val quoteModel = buildQuotesLoadedState(providerType = ExchangeProviderType.DEX) + + // Act + val notifications = factory.getConfirmationStateNotifications( + quoteModel = quoteModel, + feeCryptoCurrencyStatus = buildCoinFeeStatus(), + swapFee = null, + feeError = null, + appRouter = appRouter, + ) + + // Assert + assertThat(notifications.filterIsInstance()).hasSize(1) + } + + @Test + fun `GIVEN DEX with SEND txType and gasless support WHEN insufficient fee THEN warning suppressed like CEX`() { + // Arrange + every { isGaslessFeeSupportedForNetwork(any()) } returns true + val quoteModel = buildQuotesLoadedState( + providerType = ExchangeProviderType.DEX, + txType = ExpressTxType.SEND, + ) + + // Act + val notifications = factory.getConfirmationStateNotifications( + quoteModel = quoteModel, + feeCryptoCurrencyStatus = buildCoinFeeStatus(), + swapFee = null, + feeError = null, + appRouter = appRouter, + ) + + // Assert + assertThat(notifications.filterIsInstance()).isEmpty() + } + + @Test + fun `GIVEN DEX with SEND txType and no gasless support WHEN insufficient fee THEN warning shown`() { + // Arrange + every { isGaslessFeeSupportedForNetwork(any()) } returns false + val quoteModel = buildQuotesLoadedState( + providerType = ExchangeProviderType.DEX, + txType = ExpressTxType.SEND, + ) + + // Act + val notifications = factory.getConfirmationStateNotifications( + quoteModel = quoteModel, + feeCryptoCurrencyStatus = buildCoinFeeStatus(), + swapFee = null, + feeError = null, + appRouter = appRouter, + ) + + // Assert + assertThat(notifications.filterIsInstance()).hasSize(1) + } + + // ------------------------------------------------------------------------- + // Helpers + // ------------------------------------------------------------------------- + + private fun buildEthNetwork(): Network = mockk(relaxed = true) { + every { rawId } returns "ethereum" + every { name } returns "Ethereum" + every { currencySymbol } returns "ETH" + } + + /** Token from-currency, so the fee is paid in a different (native coin) currency. */ + private fun buildTokenFromStatus(): SwapCurrencyStatus { + val network = buildEthNetwork() + val currency = mockk(relaxed = true) { + every { this@mockk.network } returns network + every { symbol } returns "USDT" + every { name } returns "Tether" + every { decimals } returns 6 + } + val statusValue = mockk(relaxed = true) { + every { amount } returns BigDecimal("100") + every { pendingTransactions } returns emptySet() + } + return SwapCurrencyStatus( + userWallet = userWallet, + status = CryptoCurrencyStatus(currency = currency, value = statusValue), + account = Account.CryptoPortfolio.createMainAccount(userWalletId), + ) + } + + private fun buildCoinFeeStatus(): CryptoCurrencyStatus { + val network = buildEthNetwork() + val currency = mockk(relaxed = true) { + every { this@mockk.network } returns network + every { symbol } returns "ETH" + every { name } returns "Ethereum" + every { decimals } returns 18 + } + val statusValue = mockk(relaxed = true) { + every { amount } returns BigDecimal.ZERO + } + return CryptoCurrencyStatus(currency = currency, value = statusValue) + } + + private fun buildQuotesLoadedState( + providerType: ExchangeProviderType, + txType: ExpressTxType? = null, + ): SwapState.QuotesLoadedState { + val toStatusValue = mockk(relaxed = true) { + every { amount } returns BigDecimal("1") + } + val toCurrency = mockk(relaxed = true) { + every { network } returns buildEthNetwork() + every { symbol } returns "BTC" + every { decimals } returns 8 + } + val toSwapCurrencyStatus = SwapCurrencyStatus( + userWallet = userWallet, + status = CryptoCurrencyStatus(currency = toCurrency, value = toStatusValue), + account = Account.CryptoPortfolio.createMainAccount(userWalletId), + ) + return SwapState.QuotesLoadedState( + fromTokenInfo = TokenSwapInfo( + tokenAmount = SwapAmount(BigDecimal("50"), 6), + swapCurrencyStatus = buildTokenFromStatus(), + amountFiat = BigDecimal.ZERO, + ), + toTokenInfo = TokenSwapInfo( + tokenAmount = SwapAmount(BigDecimal("0.5"), 8), + swapCurrencyStatus = toSwapCurrencyStatus, + amountFiat = BigDecimal.ZERO, + ), + priceImpact = PriceImpact.Empty, + preparedSwapConfigState = PreparedSwapConfigState( + balanceStatus = SwapBalanceStatus.InsufficientFee( + feeCurrencyName = "Ethereum", + feeCurrencySymbol = "ETH", + ), + hasOutgoingTransaction = false, + ), + permissionState = PermissionDataState.Empty, + swapDataModel = null, + currencyCheck = null, + validationResult = null, + minAdaValue = null, + swapProvider = buildProvider(providerType), + txType = txType, + ) + } + + private fun buildProvider(type: ExchangeProviderType): SwapProvider = SwapProvider( + providerId = "p", + rateTypes = listOf(RateType.FLOAT), + name = "Provider", + type = type, + imageLarge = "", + termsOfUse = null, + privacyPolicy = null, + isRecommended = false, + slippage = null, + isExtraIdSupported = false, + ) +} \ No newline at end of file From a0cc4794f85bdd1853c0176c51ad628d01053b17 Mon Sep 17 00:00:00 2001 From: Tangem Date: Thu, 9 Jul 2026 17:52:46 +0500 Subject: [PATCH 06/15] Updated on 2026-08-14 --- .../feature/swap/domain/SwapInteractorImpl.kt | 11 +-- ...wapInteractorImplApplySwapFeeMatrixTest.kt | 73 +++++++++++++++++++ 2 files changed, 79 insertions(+), 5 deletions(-) diff --git a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/SwapInteractorImpl.kt b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/SwapInteractorImpl.kt index fa252262e5..2e424aeb67 100644 --- a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/SwapInteractorImpl.kt +++ b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/SwapInteractorImpl.kt @@ -1581,6 +1581,7 @@ internal class SwapInteractorImpl @Inject constructor( feeValue = nativeFee, selectedFeeToken = fee.selectedFeeToken, provider = state.swapProvider, + txType = state.txType, ) val currencyCheck = manageWarnings( fromSwapCurrencyStatus = fromSwapCurrencyStatus, @@ -1621,6 +1622,7 @@ internal class SwapInteractorImpl @Inject constructor( * from-currencies but "fee > native balance" for Token from-currencies is resolved here * by consulting `isBalanceEnough` (amount-alone check) directly. */ + @Suppress("LongParameterList") private suspend fun computeBalanceStatus( fromSwapCurrencyStatus: SwapCurrencyStatus, amount: SwapAmount, @@ -1628,9 +1630,10 @@ internal class SwapInteractorImpl @Inject constructor( feeValue: BigDecimal, selectedFeeToken: CryptoCurrencyStatus?, provider: SwapProvider, + txType: ExpressTxType?, ): SwapBalanceStatus { - when (provider.type) { - ExchangeProviderType.CEX -> { + when (resolveQuoteFlow(provider, txType)) { + ResolvedFlow.CexLike -> { val includeStatus = getIncludeFeeInAmountInternal( fromSwapCurrencyStatus = fromSwapCurrencyStatus, amount = amount, @@ -1642,9 +1645,7 @@ internal class SwapInteractorImpl @Inject constructor( return SwapBalanceStatus.FeeAdjustedAmount(adjustedAmount = includeStatus.amountSubtractFee) } } - ExchangeProviderType.DEX, - ExchangeProviderType.DEX_BRIDGE, - -> Unit + ResolvedFlow.DexLike -> Unit } val isAmountAlone = isBalanceEnough(fromSwapCurrencyStatus, amount, fee = feeValue) diff --git a/features/swap/domain/src/test/kotlin/com/tangem/feature/swap/domain/SwapInteractorImplApplySwapFeeMatrixTest.kt b/features/swap/domain/src/test/kotlin/com/tangem/feature/swap/domain/SwapInteractorImplApplySwapFeeMatrixTest.kt index 9f7b14673e..67c30c7fdd 100644 --- a/features/swap/domain/src/test/kotlin/com/tangem/feature/swap/domain/SwapInteractorImplApplySwapFeeMatrixTest.kt +++ b/features/swap/domain/src/test/kotlin/com/tangem/feature/swap/domain/SwapInteractorImplApplySwapFeeMatrixTest.kt @@ -14,6 +14,7 @@ import com.tangem.domain.tokens.model.warnings.CryptoCurrencyCheck import com.tangem.feature.swap.domain.fee.TransactionFeeResult import com.tangem.feature.swap.domain.models.SwapAmount import com.tangem.feature.swap.domain.models.domain.ExchangeProviderType +import com.tangem.feature.swap.domain.models.domain.ExpressTxType import com.tangem.feature.swap.domain.models.domain.PreparedSwapConfigState import com.tangem.feature.swap.domain.models.domain.SwapBalanceStatus import com.tangem.feature.swap.domain.models.ui.* @@ -214,6 +215,76 @@ internal class SwapInteractorImplApplySwapFeeMatrixTest : SwapInteractorImplTest } } + // ========================================================================= + // Section A2: DEX provider re-routed to the CEX-like flow via txType=SEND ([REDACTED_TASK_KEY]) + // ========================================================================= + + @Nested + inner class `DEX provider with SEND txType follows CEX semantics` { + + /** + * [REDACTED_TASK_KEY]: a DEX-typed provider (e.g. Moonpay trade) whose quote returned txType=SEND + * executes as a plain transfer built by the app, so the fee must be folded into the amount + * exactly like for a CEX provider. + * + * GIVEN ExchangeProviderType.DEX, txType = SEND + * fromToken is Coin, amount = full native balance (max amount), fee = 0.01 + * WHEN applySwapFee runs + * THEN balanceStatus == FeeAdjustedAmount with adjustedAmount = balance - fee + * (NOT InsufficientAmount — the pre-fix behavior that showed "Insufficient funds") + */ + @Test + fun `applySwapFee DEX with SEND txType — max amount returns FeeAdjustedAmount like CEX`() = runTest { + coEvery { currenciesRepository.getFeePaidCurrency(any(), any()) } returns FeePaidCurrency.Coin + coEvery { + walletManagersFacade.getNativeTokenBalance(any(), any(), any()) + } returns BigDecimal("1.0") + + val state = buildQuotesLoadedState( + providerType = ExchangeProviderType.DEX, + fromAmount = SwapAmount(BigDecimal("1.0"), 18), + isCoin = true, + fromBalance = BigDecimal("1.0"), + txType = ExpressTxType.SEND, + ) + val fee = buildSwapFeeWithCoinToken(feeValue = BigDecimal("0.01")) + + val result = sut.applySwapFee(state, fee, lastReducedBalanceBy) + + val balanceStatus = result.preparedSwapConfigState.balanceStatus + assertThat(balanceStatus).isInstanceOf(SwapBalanceStatus.FeeAdjustedAmount::class.java) + assertThat((balanceStatus as SwapBalanceStatus.FeeAdjustedAmount).adjustedAmount.value) + .isEqualTo(BigDecimal("0.99")) + } + + /** + * Twin guard: the same max-amount scenario with txType = SWAP keeps the DEX invariant — + * the fee is never deducted from the amount, and the amount alone exceeding + * balance-with-fee yields InsufficientAmount. + */ + @Test + fun `applySwapFee DEX with SWAP txType — max amount keeps DEX semantics without fee deduction`() = runTest { + coEvery { currenciesRepository.getFeePaidCurrency(any(), any()) } returns FeePaidCurrency.Coin + coEvery { + walletManagersFacade.getNativeTokenBalance(any(), any(), any()) + } returns BigDecimal("1.0") + + val state = buildQuotesLoadedState( + providerType = ExchangeProviderType.DEX, + fromAmount = SwapAmount(BigDecimal("1.0"), 18), + isCoin = true, + fromBalance = BigDecimal("1.0"), + txType = ExpressTxType.SWAP, + ) + val fee = buildSwapFeeWithCoinToken(feeValue = BigDecimal("0.01")) + + val result = sut.applySwapFee(state, fee, lastReducedBalanceBy) + + assertThat(result.preparedSwapConfigState.balanceStatus) + .isInstanceOf(SwapBalanceStatus.InsufficientAmount::class.java) + } + } + // ========================================================================= // Section B: FeePaidCurrency.Token (gasless-token) paths // ========================================================================= @@ -749,6 +820,7 @@ internal class SwapInteractorImplApplySwapFeeMatrixTest : SwapInteractorImplTest fromAmount: SwapAmount, isCoin: Boolean, fromBalance: BigDecimal, + txType: ExpressTxType? = null, ): SwapState.QuotesLoadedState { val from = buildSwapCurrencyStatus( networkRawId = ethNetwork, @@ -778,6 +850,7 @@ internal class SwapInteractorImplApplySwapFeeMatrixTest : SwapInteractorImplTest validationResult = null, minAdaValue = null, swapProvider = buildSwapProvider(providerType), + txType = txType, ) } From 3f56f32dd6e2905a40c60268310ee4a6e1f444bf Mon Sep 17 00:00:00 2001 From: Tangem Date: Thu, 9 Jul 2026 18:39:43 +0500 Subject: [PATCH 07/15] Updated on 2026-08-14 --- .../tokens/actions/BaseActionsFactory.kt | 17 +++ .../tokens/actions/CommonActionsFactory.kt | 7 +- .../actions/OutdatedDataActionsFactory.kt | 7 +- .../actions/UnreachableActionsFactory.kt | 7 +- .../actions/CommonActionsFactoryTest.kt | 108 ++++++++++++++++++ 5 files changed, 143 insertions(+), 3 deletions(-) create mode 100644 domain/tokens/src/test/kotlin/com/tangem/domain/tokens/actions/CommonActionsFactoryTest.kt diff --git a/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/actions/BaseActionsFactory.kt b/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/actions/BaseActionsFactory.kt index 4cc9fc0ed5..0e1e27ace2 100644 --- a/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/actions/BaseActionsFactory.kt +++ b/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/actions/BaseActionsFactory.kt @@ -133,6 +133,23 @@ internal open class BaseActionsFactory( } } + /** + * Determines the unavailability reason for the BUY action + * + * @param userWallet the user's wallet + * @param currency the cryptocurrency to check + */ + protected fun getBuyUnavailabilityReason( + userWallet: UserWallet, + currency: CryptoCurrency, + ): ScenarioUnavailabilityReason { + return if (userWallet is UserWallet.Cold && userWallet.cardTypesResolver.isStart2Coin()) { + ScenarioUnavailabilityReason.BuyUnavailable(currency.name) + } else { + ScenarioUnavailabilityReason.None + } + } + /** Adds a "Buy" action to the builder based on the unavailability [reason] */ protected fun ActionAvailabilityBuilder.addBuyAction(reason: ScenarioUnavailabilityReason) { val action = ActionState.Buy(unavailabilityReason = reason) diff --git a/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/actions/CommonActionsFactory.kt b/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/actions/CommonActionsFactory.kt index f244724fd4..7d10d0735c 100644 --- a/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/actions/CommonActionsFactory.kt +++ b/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/actions/CommonActionsFactory.kt @@ -91,7 +91,12 @@ internal class CommonActionsFactory( // endregion // region Buy - addBuyAction(reason = ScenarioUnavailabilityReason.None) + addBuyAction( + reason = getBuyUnavailabilityReason( + userWallet = userWallet, + currency = cryptoCurrencyStatus.currency, + ), + ) // endregion // region Sell diff --git a/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/actions/OutdatedDataActionsFactory.kt b/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/actions/OutdatedDataActionsFactory.kt index 111e4685a5..7973e82ecf 100644 --- a/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/actions/OutdatedDataActionsFactory.kt +++ b/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/actions/OutdatedDataActionsFactory.kt @@ -79,7 +79,12 @@ internal class OutdatedDataActionsFactory( // endregion // region Buy - addBuyAction(reason = ScenarioUnavailabilityReason.None) + addBuyAction( + reason = getBuyUnavailabilityReason( + userWallet = userWallet, + currency = cryptoCurrencyStatus.currency, + ), + ) // endregion // region Stake diff --git a/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/actions/UnreachableActionsFactory.kt b/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/actions/UnreachableActionsFactory.kt index 23e188e3dd..6ecb09d774 100644 --- a/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/actions/UnreachableActionsFactory.kt +++ b/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/actions/UnreachableActionsFactory.kt @@ -44,7 +44,12 @@ internal class UnreachableActionsFactory( // endregion // region Buy - addBuyAction(reason = ScenarioUnavailabilityReason.None) + addBuyAction( + reason = getBuyUnavailabilityReason( + userWallet = userWallet, + currency = cryptoCurrencyStatus.currency, + ), + ) // endregion // region Receive diff --git a/domain/tokens/src/test/kotlin/com/tangem/domain/tokens/actions/CommonActionsFactoryTest.kt b/domain/tokens/src/test/kotlin/com/tangem/domain/tokens/actions/CommonActionsFactoryTest.kt new file mode 100644 index 0000000000..ba34737ad5 --- /dev/null +++ b/domain/tokens/src/test/kotlin/com/tangem/domain/tokens/actions/CommonActionsFactoryTest.kt @@ -0,0 +1,108 @@ +package com.tangem.domain.tokens.actions + +import arrow.core.right +import com.google.common.truth.Truth.assertThat +import com.tangem.common.test.domain.token.MockCryptoCurrencyFactory +import com.tangem.domain.card.CardTypesResolver +import com.tangem.domain.card.common.util.cardTypesResolver +import com.tangem.domain.exchange.RampStateManager +import com.tangem.domain.models.currency.CryptoCurrency +import com.tangem.domain.models.currency.CryptoCurrencyStatus +import com.tangem.domain.models.wallet.UserWallet +import com.tangem.domain.staking.model.StakingAvailability +import com.tangem.domain.tokens.model.ScenarioUnavailabilityReason +import com.tangem.domain.tokens.model.TokenActionsState.ActionState +import com.tangem.domain.walletmanager.WalletManagersFacade +import com.tangem.domain.yield.supply.models.YieldSupplyAvailability +import io.mockk.clearMocks +import io.mockk.coEvery +import io.mockk.every +import io.mockk.mockk +import io.mockk.mockkStatic +import io.mockk.unmockkStatic +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.AfterAll +import org.junit.jupiter.api.BeforeAll +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.TestInstance + +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +internal class CommonActionsFactoryTest { + + private val walletManagersFacade: WalletManagersFacade = mockk(relaxed = true) + private val rampStateManager: RampStateManager = mockk(relaxed = true) + + private val factory = CommonActionsFactory( + walletManagersFacade = walletManagersFacade, + rampStateManager = rampStateManager, + ) + + private val currency: CryptoCurrency = MockCryptoCurrencyFactory().ethereum + + private val cardTypesResolver: CardTypesResolver = mockk() + private val userWallet: UserWallet.Cold = mockk(relaxed = true) + private val cryptoCurrencyStatus: CryptoCurrencyStatus = mockk() + + @BeforeAll + fun setupStatic() { + // cardTypesResolver is a UserWallet.Cold extension property, so it is stubbed via its file class. + mockkStatic("com.tangem.domain.card.common.util.ScanResponseExtKt") + } + + @AfterAll + fun tearDownStatic() { + unmockkStatic("com.tangem.domain.card.common.util.ScanResponseExtKt") + } + + @BeforeEach + fun setup() { + clearMocks(walletManagersFacade, rampStateManager, cardTypesResolver, userWallet, cryptoCurrencyStatus) + + val value = mockk(relaxed = true) + every { cryptoCurrencyStatus.value } returns value + every { cryptoCurrencyStatus.currency } returns currency + + every { userWallet.cardTypesResolver } returns cardTypesResolver + every { userWallet.isMultiCurrency } returns false + + coEvery { rampStateManager.getSendUnavailabilityReason(any(), any()) } returns ScenarioUnavailabilityReason.None + coEvery { rampStateManager.availableForSell(any(), any(), any()) } returns Unit.right() + } + + @Test + fun `GIVEN Start2Coin cold wallet WHEN create THEN buy action is unavailable`() = runTest { + // Arrange + every { cardTypesResolver.isStart2Coin() } returns true + + // Act + val buyAction = createBuyAction() + + // Assert + assertThat(buyAction.unavailabilityReason) + .isEqualTo(ScenarioUnavailabilityReason.BuyUnavailable(currency.name)) + } + + @Test + fun `GIVEN non-Start2Coin cold wallet WHEN create THEN buy action is available`() = runTest { + // Arrange + every { cardTypesResolver.isStart2Coin() } returns false + + // Act + val buyAction = createBuyAction() + + // Assert + assertThat(buyAction.unavailabilityReason).isEqualTo(ScenarioUnavailabilityReason.None) + } + + private suspend fun createBuyAction(): ActionState.Buy { + val actions = factory.create( + userWallet = userWallet, + cryptoCurrencyStatus = cryptoCurrencyStatus, + stakingAvailability = StakingAvailability.Unavailable, + yieldSupplyAvailability = YieldSupplyAvailability.Unavailable, + shouldShowSwapStories = false, + ) + return actions.filterIsInstance().single() + } +} \ No newline at end of file From 60911ceb8ca8c9c2b0fd980b59b2e3c4b706167c Mon Sep 17 00:00:00 2001 From: Tangem Date: Fri, 10 Jul 2026 13:11:37 +0500 Subject: [PATCH 08/15] Updated on 2026-08-14 --- .../tangempay/model/TangemPayDetailsModel.kt | 20 ---- ...TangemPayFreezeUnfreezeStateTransformer.kt | 26 ----- .../utils/PaymentAccountStatusExt.kt | 4 +- .../TangemPayDetailsStateFactoryTest.kt | 17 +++ ...emPayFreezeUnfreezeStateTransformerTest.kt | 108 ------------------ 5 files changed, 18 insertions(+), 157 deletions(-) delete mode 100644 features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/transformers/TangemPayFreezeUnfreezeStateTransformer.kt delete mode 100644 features/tangempay/details/impl/src/test/kotlin/com/tangem/features/tangempay/model/transformers/TangemPayFreezeUnfreezeStateTransformerTest.kt diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayDetailsModel.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayDetailsModel.kt index 0b89ff84dd..0ad2010e54 100644 --- a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayDetailsModel.kt +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayDetailsModel.kt @@ -120,7 +120,6 @@ internal class TangemPayDetailsModel @Inject constructor( private val refreshStateJobHolder = JobHolder() private val addToWalletBannerJobHolder = JobHolder() - private val frozenStateJobHolder = JobHolder() val bottomSheetNavigation: SlotNavigation = SlotNavigation() @@ -141,9 +140,6 @@ internal class TangemPayDetailsModel @Inject constructor( fetchAddToWalletBanner() uiState.update { stateFactory.getLoadedState(state) } uiState.update(DetailsBalanceTransformer(state.balance.fiatBalance)) - state.cards.firstOrNull()?.let { card -> - subscribeToCardFrozenState(card.id) - } } else -> uiState.update { stateFactory.getLoadingState() } } @@ -165,22 +161,6 @@ internal class TangemPayDetailsModel @Inject constructor( fun isRedesignEnabled(): Boolean = tangemPayFeatureToggles.isRedesignEnabled - private fun subscribeToCardFrozenState(cardId: String) { - frozenStateJobHolder.cancel() - cardDetailsRepository - .cardFrozenState(cardId) - .onEach { frozenState -> - uiState.update( - TangemPayFreezeUnfreezeStateTransformer( - cardFrozenState = frozenState, - isDataFresh = currentStatus.value.ifLoadedOrNull { it.isFresh } == true, - ), - ) - } - .launchIn(modelScope) - .saveIn(frozenStateJobHolder) - } - override fun onClickAddFunds() { analytics.send(TangemPayAnalyticsEvents.AddFundsClicked()) val balance = currentStatus.value.balanceOrNull() diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/transformers/TangemPayFreezeUnfreezeStateTransformer.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/transformers/TangemPayFreezeUnfreezeStateTransformer.kt deleted file mode 100644 index 2644465ec6..0000000000 --- a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/transformers/TangemPayFreezeUnfreezeStateTransformer.kt +++ /dev/null @@ -1,26 +0,0 @@ -package com.tangem.features.tangempay.model.transformers - -import com.tangem.domain.models.pay.TangemPayCardFrozenState -import com.tangem.features.tangempay.entity.TangemPayDetailsBalanceBlockState -import com.tangem.features.tangempay.entity.TangemPayDetailsUM -import com.tangem.utils.transformer.Transformer -import kotlinx.collections.immutable.toPersistentList - -internal class TangemPayFreezeUnfreezeStateTransformer( - private val cardFrozenState: TangemPayCardFrozenState, - private val isDataFresh: Boolean, -) : Transformer { - - override fun transform(prevState: TangemPayDetailsUM): TangemPayDetailsUM { - val balanceBlockState = if (prevState.balanceBlockState is TangemPayDetailsBalanceBlockState.Content) { - val isEnabled = isDataFresh && cardFrozenState == TangemPayCardFrozenState.Unfrozen - val actionButtons = prevState.balanceBlockState.actionButtons.map { - it.copy(isEnabled = isEnabled) - } - prevState.balanceBlockState.copy(actionButtons = actionButtons.toPersistentList()) - } else { - prevState.balanceBlockState - } - return prevState.copy(balanceBlockState = balanceBlockState) - } -} \ No newline at end of file diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/utils/PaymentAccountStatusExt.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/utils/PaymentAccountStatusExt.kt index 0398035dfc..afd9d0f07a 100644 --- a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/utils/PaymentAccountStatusExt.kt +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/utils/PaymentAccountStatusExt.kt @@ -22,14 +22,12 @@ internal val AccountStatus.Payment.isDeactivated: Boolean get() = value is PaymentAccountStatusValue.Deactivated internal val PaymentAccountStatusValue.Loaded.isFresh: Boolean - get() = source == StatusSource.ACTUAL && error == null + get() = source.isActual() && error == null internal fun AccountStatus.Payment.requireLoaded(): PaymentAccountStatusValue.Loaded = value as? PaymentAccountStatusValue.Loaded ?: error("Card-detail subflow requires Loaded status, got ${value::class.simpleName}") -internal fun AccountStatus.Payment.firstCard(): TangemPayCard = requireLoaded().cards.first() - internal inline fun AccountStatus.Payment.ifLoadedOrNull(call: (PaymentAccountStatusValue.Loaded) -> T): T? { val value = value return if (value is PaymentAccountStatusValue.Loaded) { diff --git a/features/tangempay/details/impl/src/test/kotlin/com/tangem/features/tangempay/entity/TangemPayDetailsStateFactoryTest.kt b/features/tangempay/details/impl/src/test/kotlin/com/tangem/features/tangempay/entity/TangemPayDetailsStateFactoryTest.kt index c13bd904e7..e180072110 100644 --- a/features/tangempay/details/impl/src/test/kotlin/com/tangem/features/tangempay/entity/TangemPayDetailsStateFactoryTest.kt +++ b/features/tangempay/details/impl/src/test/kotlin/com/tangem/features/tangempay/entity/TangemPayDetailsStateFactoryTest.kt @@ -96,6 +96,23 @@ internal class TangemPayDetailsStateFactoryTest { assertThat(state.balanceBlockState.cardsBlockState?.isAddCardEnabled).isTrue() } + @Test + fun `GIVEN actual status with frozen first card but unfrozen second card WHEN getLoadedState THEN action buttons enabled`() { + val factory = createFactory() + val frozenFirstCard = activeUnfrozenCard.copy(id = "card_frozen", frozenState = TangemPayCardFrozenState.Frozen) + val status = loadedStatus( + statusSource = StatusSource.ACTUAL, + statusError = null, + statusCards = listOf(frozenFirstCard, activeUnfrozenCard), + ) + + // Act + val state = factory.getLoadedState(status) + + // Assert + assertThat(state.balanceBlockState.actionButtons.map { it.isEnabled }).containsExactly(true, true) + } + @Test fun `GIVEN actual status with issuing card WHEN getLoadedState THEN add card disabled`() { // Arrange diff --git a/features/tangempay/details/impl/src/test/kotlin/com/tangem/features/tangempay/model/transformers/TangemPayFreezeUnfreezeStateTransformerTest.kt b/features/tangempay/details/impl/src/test/kotlin/com/tangem/features/tangempay/model/transformers/TangemPayFreezeUnfreezeStateTransformerTest.kt deleted file mode 100644 index 44e221858c..0000000000 --- a/features/tangempay/details/impl/src/test/kotlin/com/tangem/features/tangempay/model/transformers/TangemPayFreezeUnfreezeStateTransformerTest.kt +++ /dev/null @@ -1,108 +0,0 @@ -package com.tangem.features.tangempay.model.transformers - -import com.google.common.truth.Truth.assertThat -import com.tangem.core.ui.components.buttons.actions.ActionButtonConfig -import com.tangem.core.ui.components.containers.pullToRefresh.PullToRefreshConfig -import com.tangem.core.ui.extensions.TextReference -import com.tangem.domain.models.pay.TangemPayCardFrozenState -import com.tangem.features.tangempay.entity.TangemPayDetailsBalanceBlockState -import com.tangem.features.tangempay.entity.TangemPayDetailsTopBarConfig -import com.tangem.features.tangempay.entity.TangemPayDetailsUM -import kotlinx.collections.immutable.persistentListOf -import org.junit.jupiter.api.Test -import org.junit.jupiter.params.ParameterizedTest -import org.junit.jupiter.params.provider.MethodSource - -internal class TangemPayFreezeUnfreezeStateTransformerTest { - - @ParameterizedTest - @MethodSource("provideCases") - fun `GIVEN content state WHEN transform THEN buttons enabled only when fresh and unfrozen`(case: Case) { - // Arrange - val state = contentState(actionButtonsEnabled = true) - - // Act - val result = TangemPayFreezeUnfreezeStateTransformer( - cardFrozenState = case.frozenState, - isDataFresh = case.isDataFresh, - ).transform(state) - - // Assert - val enabled = result.balanceBlockState.actionButtons.map { it.isEnabled } - assertThat(enabled).containsExactly(case.expectedEnabled, case.expectedEnabled) - } - - @Test - fun `GIVEN non-content state WHEN transform THEN state is unchanged`() { - // Arrange - val state = loadingState() - - // Act - val result = TangemPayFreezeUnfreezeStateTransformer( - cardFrozenState = TangemPayCardFrozenState.Unfrozen, - isDataFresh = true, - ).transform(state) - - // Assert - assertThat(result).isEqualTo(state) - } - - private fun contentState(actionButtonsEnabled: Boolean): TangemPayDetailsUM = baseState( - balanceBlockState = TangemPayDetailsBalanceBlockState.Content( - actionButtons = persistentListOf( - actionButton(isEnabled = actionButtonsEnabled), - actionButton(isEnabled = actionButtonsEnabled), - ), - cardsBlockState = null, - fiatBalance = TextReference.EMPTY, - isBalanceFlickering = false, - ), - ) - - private fun loadingState(): TangemPayDetailsUM = baseState( - balanceBlockState = TangemPayDetailsBalanceBlockState.Loading( - actionButtons = persistentListOf(actionButton(isEnabled = true)), - cardsBlockState = null, - ), - ) - - private fun baseState(balanceBlockState: TangemPayDetailsBalanceBlockState) = TangemPayDetailsUM( - topBarConfig = TangemPayDetailsTopBarConfig( - onBackClick = {}, - onOpenMenu = {}, - items = persistentListOf(), - itemsV2 = persistentListOf(), - ), - pullToRefreshConfig = PullToRefreshConfig(isRefreshing = false, onRefresh = {}), - balanceBlockState = balanceBlockState, - addToWalletBlockState = null, - isBalanceHidden = false, - errorNotificationConfig = null, - accountDeactivatedNotificationConfig = null, - ) - - private fun actionButton(isEnabled: Boolean) = ActionButtonConfig( - text = TextReference.EMPTY, - iconResId = 0, - onClick = {}, - isEnabled = isEnabled, - ) - - internal data class Case( - val frozenState: TangemPayCardFrozenState, - val isDataFresh: Boolean, - val expectedEnabled: Boolean, - ) - - private companion object { - @JvmStatic - fun provideCases() = listOf( - Case(frozenState = TangemPayCardFrozenState.Unfrozen, isDataFresh = true, expectedEnabled = true), - Case(frozenState = TangemPayCardFrozenState.Unfrozen, isDataFresh = false, expectedEnabled = false), - Case(frozenState = TangemPayCardFrozenState.Frozen, isDataFresh = true, expectedEnabled = false), - Case(frozenState = TangemPayCardFrozenState.Pending, isDataFresh = true, expectedEnabled = false), - Case(frozenState = TangemPayCardFrozenState.Frozen, isDataFresh = false, expectedEnabled = false), - Case(frozenState = TangemPayCardFrozenState.Pending, isDataFresh = false, expectedEnabled = false), - ) - } -} \ No newline at end of file From d183b010a921e93bafe8d17bfff7e1a8caf038d4 Mon Sep 17 00:00:00 2001 From: Tangem Date: Fri, 10 Jul 2026 13:13:12 +0500 Subject: [PATCH 09/15] Updated on 2026-08-14 --- .../tangempay/ui/TangemPayReissueCardContentV2.kt | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TangemPayReissueCardContentV2.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TangemPayReissueCardContentV2.kt index e567d483d2..349abba8d0 100644 --- a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TangemPayReissueCardContentV2.kt +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TangemPayReissueCardContentV2.kt @@ -173,7 +173,11 @@ private fun FeeInfoRow(titleRes: Int, value: String, showDivider: Boolean = fals }, valueSlot = { if (value.isEmpty()) { - TangemShimmer(style = TangemTheme.typography3.body.medium) + TangemShimmer( + modifier = Modifier.width(80.dp), + style = TangemTheme.typography3.body.medium, + textAlign = TextAlign.End, + ) } else { TangemRowText( text = value, @@ -305,7 +309,7 @@ private fun ReissueCardSheetPreview(state: TangemPayReissueCardUM) { private class TangemPayReissueCardUMPreviewProvider : CollectionPreviewParameterProvider( collection = listOf( - TangemPayReissueCardUM.stub(error = null), + TangemPayReissueCardUM.stub(error = null, feeAmount = ""), TangemPayReissueCardUM.stub( error = TangemPayReissueCardError.InsufficientFunds, cardBalance = "$0.05", From fcf967c8b8fec761c8ff6f9e35cb3dfb516f2d05 Mon Sep 17 00:00:00 2001 From: Tangem Date: Fri, 10 Jul 2026 13:36:38 +0500 Subject: [PATCH 10/15] Updated on 2026-08-14 --- .../tangempay/model/TangemPayDetailsModel.kt | 3 ++- .../utils/PaymentAccountStatusExt.kt | 19 ------------------- 2 files changed, 2 insertions(+), 20 deletions(-) diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayDetailsModel.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayDetailsModel.kt index 0ad2010e54..076230a318 100644 --- a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayDetailsModel.kt +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayDetailsModel.kt @@ -164,7 +164,8 @@ internal class TangemPayDetailsModel @Inject constructor( override fun onClickAddFunds() { analytics.send(TangemPayAnalyticsEvents.AddFundsClicked()) val balance = currentStatus.value.balanceOrNull() - if (balance == null) { + val address = currentStatus.value.ifLoadedOrNull { it.depositAddress } + if (balance == null || address.isNullOrEmpty()) { showBottomSheetError(TangemPayDetailsErrorType.Receive) } else { bottomSheetNavigation.activate( diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/utils/PaymentAccountStatusExt.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/utils/PaymentAccountStatusExt.kt index afd9d0f07a..9c7503bc7d 100644 --- a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/utils/PaymentAccountStatusExt.kt +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/utils/PaymentAccountStatusExt.kt @@ -1,11 +1,8 @@ package com.tangem.features.tangempay.utils -import com.tangem.domain.models.StatusSource import com.tangem.domain.models.account.AccountStatus import com.tangem.domain.models.account.PaymentAccountStatusValue -import com.tangem.domain.models.account.findCardWithId import com.tangem.domain.models.currency.CryptoCurrency -import com.tangem.domain.models.pay.TangemPayCard import com.tangem.domain.models.wallet.UserWalletId internal val AccountStatus.Payment.userWalletId: UserWalletId @@ -41,20 +38,4 @@ internal fun AccountStatus.Payment.balanceOrNull(): PaymentAccountStatusValue.Ba is PaymentAccountStatusValue.Loaded -> v.balance is PaymentAccountStatusValue.Deactivated -> v.balance else -> null -} - -internal fun AccountStatus.Payment.findCard( - initialCardId: String, - initialStatus: AccountStatus.Payment, -): TangemPayCard? { - val value = value - - if (value !is PaymentAccountStatusValue.Loaded || value.source != StatusSource.ACTUAL) return null - - val initialCard = value.findCardWithId(initialCardId) - val newCards = initialStatus.ifLoadedOrNull { status -> - val initialCardIds = status.cards.mapTo(mutableSetOf()) { it.id } - value.cards.filterNot { it.id in initialCardIds } - } - return initialCard ?: newCards?.firstOrNull() } \ No newline at end of file From dc7a104ac2fc0d8c87653004bae5e58d9b1301a8 Mon Sep 17 00:00:00 2001 From: Tangem Date: Fri, 10 Jul 2026 11:50:23 +0200 Subject: [PATCH 11/15] Updated on 2026-08-14 --- .../wallet/state/transformers/converter/EarnApyConverter.kt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/converter/EarnApyConverter.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/converter/EarnApyConverter.kt index c7826b72c9..22e8abd72f 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/converter/EarnApyConverter.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/converter/EarnApyConverter.kt @@ -12,9 +12,9 @@ import com.tangem.domain.models.currency.yieldSupplyKey import com.tangem.domain.models.staking.StakingBalance import com.tangem.domain.staking.model.StakingAvailability import com.tangem.domain.staking.model.StakingOption -import com.tangem.domain.staking.model.optionOrNull import com.tangem.domain.staking.model.common.RewardInfo import com.tangem.domain.staking.model.common.RewardType +import com.tangem.domain.staking.model.optionOrNull import com.tangem.lib.crypto.BlockchainUtils import com.tangem.utils.converter.Converter import java.math.BigDecimal @@ -34,7 +34,7 @@ internal class EarnApyConverter( ) }?.value if (yieldSupplyApy != null) { - val isActive = value.value.yieldSupplyStatus?.isActive == false + val isActive = value.value.yieldSupplyStatus?.isActive == true return EarnApyInfo( text = resourceReference( R.string.yield_module_earn_badge, From 69487b12e3f477625ed56ddf99c6a7e1ff2debe7 Mon Sep 17 00:00:00 2001 From: Tangem Date: Fri, 10 Jul 2026 11:50:42 +0200 Subject: [PATCH 12/15] Updated on 2026-08-14 --- .../presentation/tokendetails/ui/QuickTopUpBlock.kt | 8 +------- .../presentation/tokendetails/ui/TokenDetailsScreen.kt | 2 +- 2 files changed, 2 insertions(+), 8 deletions(-) diff --git a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/ui/QuickTopUpBlock.kt b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/ui/QuickTopUpBlock.kt index 18eac82d3c..f826bee2ad 100644 --- a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/ui/QuickTopUpBlock.kt +++ b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/ui/QuickTopUpBlock.kt @@ -1,12 +1,7 @@ package com.tangem.feature.tokendetails.presentation.tokendetails.ui import android.content.res.Configuration -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.* import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier @@ -103,7 +98,6 @@ private fun QuickTopUpBlock_Preview() { ), ), ), - modifier = Modifier.padding(TangemTheme.dimens2.x3), ) } } \ No newline at end of file diff --git a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/ui/TokenDetailsScreen.kt b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/ui/TokenDetailsScreen.kt index 0760de9790..5fb601334f 100644 --- a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/ui/TokenDetailsScreen.kt +++ b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/ui/TokenDetailsScreen.kt @@ -226,7 +226,7 @@ private fun TokenDetailsBody( item(key = "quick_top_up_block") { QuickTopUpBlock( state = quickTopUpBlock, - modifier = itemModifier.padding(vertical = TangemTheme.dimens2.x0), + modifier = itemModifier.padding(top = 8.dp), ) } } From 3900f1004b947af68063801e1852bec2d5a7e4e2 Mon Sep 17 00:00:00 2001 From: Tangem Date: Fri, 10 Jul 2026 11:50:58 +0200 Subject: [PATCH 13/15] Updated on 2026-08-14 --- .../sheet/TangemBottomSheetDraggableHeaderLegacy.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/core/ui/src/main/java/com/tangem/core/ui/components/bottomsheets/sheet/TangemBottomSheetDraggableHeaderLegacy.kt b/core/ui/src/main/java/com/tangem/core/ui/components/bottomsheets/sheet/TangemBottomSheetDraggableHeaderLegacy.kt index 18b34425eb..adcce89a2a 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/components/bottomsheets/sheet/TangemBottomSheetDraggableHeaderLegacy.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/components/bottomsheets/sheet/TangemBottomSheetDraggableHeaderLegacy.kt @@ -47,7 +47,7 @@ fun TangemBottomSheetDraggableHeader() { height = TangemTheme.dimens2.x1, ) .background( - color = TangemTheme.colors2.graphic.neutral.primaryInverted, + color = TangemTheme.colors3.icon.tertiary, shape = RoundedCornerShape(TangemTheme.dimens2.x0_5), ), ) From 68b2bb4d1e13dd322f87aa8b10bc0819da7a8c8b Mon Sep 17 00:00:00 2001 From: Tangem Date: Fri, 10 Jul 2026 13:13:04 +0200 Subject: [PATCH 14/15] Updated on 2026-08-14 --- .../common/ui/markets/action/QuickActionUM.kt | 2 +- .../ui/markets/action/TokenActionsBSContentUM.kt | 2 +- core/res/src/main/res/values-de/strings.xml | 6 ++++++ core/res/src/main/res/values-es/strings.xml | 1 + core/res/src/main/res/values-ja/strings.xml | 15 +++++++++++++++ core/res/src/main/res/values-pt-rBR/strings.xml | 1 + core/res/src/main/res/values-ru/strings.xml | 1 + core/res/src/main/res/values-zh-rCN/strings.xml | 16 ++++++++++++++++ core/res/src/main/res/values/strings.xml | 4 ++++ .../ui/bottomsheet/TransferBottomSheetContent.kt | 8 ++------ 10 files changed, 48 insertions(+), 8 deletions(-) diff --git a/common/ui-markets/src/main/kotlin/com/tangem/common/ui/markets/action/QuickActionUM.kt b/common/ui-markets/src/main/kotlin/com/tangem/common/ui/markets/action/QuickActionUM.kt index bc41c68a0f..d244afa082 100644 --- a/common/ui-markets/src/main/kotlin/com/tangem/common/ui/markets/action/QuickActionUM.kt +++ b/common/ui-markets/src/main/kotlin/com/tangem/common/ui/markets/action/QuickActionUM.kt @@ -139,7 +139,7 @@ sealed class QuickActionUM( ) data object SwapAndSend : V2( - title = resourceReference(R.string.common_send_with_swap), + title = resourceReference(R.string.send_with_swap_confirm_title), description = resourceReference(R.string.quick_action_send_and_swap_description), icon = R.drawable.ic_exchange_mini_24, ) diff --git a/common/ui-markets/src/main/kotlin/com/tangem/common/ui/markets/action/TokenActionsBSContentUM.kt b/common/ui-markets/src/main/kotlin/com/tangem/common/ui/markets/action/TokenActionsBSContentUM.kt index bcbc87b589..3bea816930 100644 --- a/common/ui-markets/src/main/kotlin/com/tangem/common/ui/markets/action/TokenActionsBSContentUM.kt +++ b/common/ui-markets/src/main/kotlin/com/tangem/common/ui/markets/action/TokenActionsBSContentUM.kt @@ -44,7 +44,7 @@ data class TokenActionsBSContentUM( iconRes = R.drawable.ic_exchange_horizontal_24, ), SendWithSwap( - text = resourceReference(R.string.common_send_with_swap), + text = resourceReference(R.string.send_with_swap_confirm_title), iconRes = R.drawable.ic_exchange_horizontal_24, ), Stake( diff --git a/core/res/src/main/res/values-de/strings.xml b/core/res/src/main/res/values-de/strings.xml index 2642b05908..92c99b8dfa 100644 --- a/core/res/src/main/res/values-de/strings.xml +++ b/core/res/src/main/res/values-de/strings.xml @@ -131,6 +131,7 @@ Noch keine Kontakte Die von Ihnen hinzugefügten Kontakte werden hier angezeigt Adresse entfernen + Adresse speichern Kontakt speichern In Wallet speichern Dieser Kontakt wird mit dem Adressbuch dieser Wallet verknüpft. @@ -638,6 +639,8 @@ Lange Transaktionszeit Der Transaktionsbetrag wurde aufgrund von OKX- oder Bridge-Regeln in %1$s auf deine Wallet zurückerstattet. %2$s Der Betrag wurde in %1$s (%2$s Netzwerk) zurückerstattet. + Ihr Guthaben wurde gemäß den Regeln der OKX-Börse in %1$s an Ihre Wallet im %2$s -Netzwerk zurückerstattet. + Rückerstattung erfolgt in %s Besuche die Website des Anbieters zur Überprüfung KYC-Überprüfung durch den Anbieter erforderlich Kauf abgeschlossen @@ -1307,9 +1310,12 @@ Sie sind erfolgreich eingeschrieben in %1$s Diese Aktion existiert nicht mehr oder ist abgelaufen. Kampagne nicht aktiv + Erhalten Sie bei jedem Swap über 500 $ ein Cashback von 0,5% , und zwar für alle Währungspaare außer „Stablecoin zu Stablecoin“. Die maximale Auszahlung beträgt 50 $ pro Swap.\n\nFühren Sie fünf qualifizierende Swaps durch und sichern Sie sich einen zusätzlichen Bonus von 10 $.\n\nDie Prämien werden wöchentlich in USDT oder USDC an die ausgewählte Adresse ausgezahlt. Cashback-Konto auswählen + Token auswählen Melden Sie sich an %1$s Ich stimme zu, dass %1$s + Ich stimme zu, dass %1$s Bedingungen Erhalten Sie bis Ende Juli bei jedem Swap ab 10.000 $ Cashback.\n\nDie Sätze steigen mit dem Volumen: 0,10% ab 10.000 $, 0,20% ab 20.000 $, 0,50% ab 100.000 $.\n\nMaximale Auszahlung: 500 $ pro Swap und 10.000 $ pro Wallet und Swap-Richtung, solange die Aktion läuft. Swaps von Stablecoins in andere Stablecoins sind ausgeschlossen.\n\nDie Auszahlung erfolgt wöchentlich an eine USDT- oder USDC-Adresse Ihrer Wahl. %s Unterstützung diff --git a/core/res/src/main/res/values-es/strings.xml b/core/res/src/main/res/values-es/strings.xml index c139dd7025..60ae725cbf 100644 --- a/core/res/src/main/res/values-es/strings.xml +++ b/core/res/src/main/res/values-es/strings.xml @@ -1288,6 +1288,7 @@ Tarjeta de crédito o cuenta bancaria Comparta su dirección o código QR Venda criptomonedas de forma segura + Enviar con intercambio a otro token Enviar a otra billetera Entre sus portafolios Otro diff --git a/core/res/src/main/res/values-ja/strings.xml b/core/res/src/main/res/values-ja/strings.xml index 2c2737a84a..5038b93bf1 100644 --- a/core/res/src/main/res/values-ja/strings.xml +++ b/core/res/src/main/res/values-ja/strings.xml @@ -355,6 +355,7 @@ 送信元 %sから アドレスを同期する + 入手 はじめる トークンを取得 プロバイダーへ移動 @@ -1249,6 +1250,20 @@ 残高順 トークンを整理する グループ解除 + 対象のキャッシュバックは次のアドレスに配布されます: + すでに%1$sに参加しています + 対象トークン + 参加する + %1$sへの参加が完了しました + このキャンペーンは終了したか、無効です + キャンペーンは実施されていません + 500ドルを超えるスワップごとに0.5%のキャッシュバックを獲得できます。ステーブルコイン同士を除く、すべてのペアが対象です。キャッシュバックは1回のスワップあたり最大50ドルです。\n\n対象のスワップを5回完了すると、さらに10ドルのボーナスを獲得できます。\n\n報酬は毎週、ご指定のUSDTまたはUSDCアドレスにお支払いします。 + キャッシュバックの受取口座を選択 + トークンを選択 + %1$sに参加 + %1$sに同意します + %1$s 利用規約 + 7月末まで、累計1万ドル以上のスワップでキャッシュバックを獲得できます。\n\n累計スワップ額に応じて還元率がアップします。1万ドル以上で0.10%、2万ドル以上で0.20%、10万ドル以上で0.50%。\n\nキャッシュバックは、1回のスワップあたり最大500ドル、ウォレットごと・スワップ方向ごとにキャンペーン期間中、合計最大1万ドルです。ステーブルコイン同士のスワップは対象外です。\n\nキャッシュバックは毎週、ご指定のUSDTまたはUSDCアドレスにお支払いします。 %sサポート プッシュ通知は有効ですが、許可するまで動作しません 通知を許可する diff --git a/core/res/src/main/res/values-pt-rBR/strings.xml b/core/res/src/main/res/values-pt-rBR/strings.xml index 0d25eceb4f..7e2292db62 100644 --- a/core/res/src/main/res/values-pt-rBR/strings.xml +++ b/core/res/src/main/res/values-pt-rBR/strings.xml @@ -1310,6 +1310,7 @@ Selecione a conta de cashback Inscreva-se em %1$s Concordo com %1$s + Concordo com %1$s Termos Ganhe cashback em todas as suas transações a partir de US$ 10.000 até o final de julho.\n\nAs taxas aumentam conforme o tamanho da transação: 0,10% a partir de US$ 10 mil, 0,20% a partir de US$ 20 mil, 0,50% A partir de US$ 100 mil.\n\nPagamento máximo: US$ 500 por troca e US$ 10.000 por carteira por direção de troca, enquanto durar a campanha. Trocas de stablecoin por stablecoin estão excluídas.\n\nO pagamento é feito semanalmente em USDT ou USDC, no endereço de sua escolha. %s suporte diff --git a/core/res/src/main/res/values-ru/strings.xml b/core/res/src/main/res/values-ru/strings.xml index d111e36499..dc4cc7ef15 100644 --- a/core/res/src/main/res/values-ru/strings.xml +++ b/core/res/src/main/res/values-ru/strings.xml @@ -377,6 +377,7 @@ Из Из %s Синхронизировать адреса + Приобрести Начать Получить токен К провайдеру diff --git a/core/res/src/main/res/values-zh-rCN/strings.xml b/core/res/src/main/res/values-zh-rCN/strings.xml index ef6f3f2e6c..b2e532969f 100644 --- a/core/res/src/main/res/values-zh-rCN/strings.xml +++ b/core/res/src/main/res/values-zh-rCN/strings.xml @@ -356,6 +356,7 @@ 从 %s 同步地址 + 得到 开始 获取代币 前往服务提供商 @@ -1245,6 +1246,21 @@ 按余额 整理代币 取消分组 + 符合条件的返现将发放给: + 您已经注册了 %1$s + 符合条件的代币 + 注册 + 您已成功注册 %1$s + 此活动已结束或已过期。 + 活动未启动 + 每次兑换金额超过 500 美元,即可获得 0.5% 的返现,适用于除稳定币与稳定币之间的兑换以外的所有交易对。每次兑换的最高返现金额为 50 美元。\n\n完成五笔符合条件的兑换,即可解锁额外 10 美元的奖励。\n\n奖励每周以 USDT 或 USDC 形式支付至您选择的地址。 + 选择返现账户 + 选择代币 + 报名参加 %1$s + 我同意 %1$s + 我同意 + %1$s 条款 + 7月底前,每次1万美元及以上的兑换均可获得返现。\n\n返现率随交易金额递增:1万美元起为0.10% ,2万美元起为0.20% ,10万美元起为0.50% 。n\n最高奖励:每笔最少兑换500美元,且在活动期间内,每个钱包单向兑换方向的奖励上限为10,000美元。稳定币与稳定币之间的兑换不计入奖励范围。\n\n奖励每周发放至您指定的USDT或USDC地址。 %s 支持 推送通知已启用,但需要您在设备设置中允许通知才能正常工作。 允许通知 diff --git a/core/res/src/main/res/values/strings.xml b/core/res/src/main/res/values/strings.xml index 175671a742..cea7f845ca 100644 --- a/core/res/src/main/res/values/strings.xml +++ b/core/res/src/main/res/values/strings.xml @@ -131,6 +131,7 @@ No contacts yet Contacts added will appear here Remove address + Save address Save contact Save to Wallet This contact will be linked to this wallet’s address book. @@ -1310,9 +1311,12 @@ You\'re successfully enrolled in %1$s This campaign no longer exists or has expired Campaign not active + Earn 0.5% cashback on every swap over $500, on any pair except stable to stable. Max payout $50 per swap.\n\nComplete five qualifying swaps and unlock an extra $10 bonus.\n\nRewards are paid weekly in USDT or USDC on the address selected. Select cashback account + Select token Enroll in %1$s I agree with %1$s + I agree with %1$s Terms Earn cashback on every swap from $10K until the end of July.\n\nRates step up with size: 0.10% from $10K, 0.20% from $20K, 0.50% from $100K.\n\nMax payout: $500 per swap, and $10,000 per wallet per swap direction until campaign lasts. Stable coin into stablecoin swaps are excluded.\n\nPayout arrives weekly in USDT or USDC address of your choice. %s support diff --git a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/ui/bottomsheet/TransferBottomSheetContent.kt b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/ui/bottomsheet/TransferBottomSheetContent.kt index 99a2cc8b34..907b472f7c 100644 --- a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/ui/bottomsheet/TransferBottomSheetContent.kt +++ b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/ui/bottomsheet/TransferBottomSheetContent.kt @@ -1,10 +1,6 @@ package com.tangem.feature.tokendetails.presentation.tokendetails.ui.bottomsheet -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.* import androidx.compose.material3.CircularProgressIndicator import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider @@ -87,7 +83,7 @@ private fun SwapAndSendActionRow(state: TransferUM) { if (state is TransferUM.Content && row == null) return ActionRow( iconRes = CoreR.drawable.ic_exchange_mini_24, - title = resourceReference(CoreR.string.common_send_with_swap), + title = resourceReference(CoreR.string.send_with_swap_confirm_title), description = resourceReference(CoreR.string.quick_action_send_and_swap_description), row = row, isLoading = state is TransferUM.Loading, From e873fe124e813717990f8b857cc1028d94499ef2 Mon Sep 17 00:00:00 2001 From: Tangem Date: Fri, 10 Jul 2026 11:13:29 +0000 Subject: [PATCH 15/15] Updated on 2026-08-14 --- gradle/tangem_dependencies.toml | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/gradle/tangem_dependencies.toml b/gradle/tangem_dependencies.toml index e752ccee30..8493666708 100644 --- a/gradle/tangem_dependencies.toml +++ b/gradle/tangem_dependencies.toml @@ -5,16 +5,16 @@ # https://github.com/tangem/tangem-sdk-android/ # https://github.com/tangem/vico -tangemBlockchainSdk = "releases-6.0-1603" +tangemBlockchainSdk = "develop-1607" #tangemBlockchainSdk = "0.0.1" # Keep it! - used for local builds -tangemCardSdk = "releases-6.0-626" +tangemCardSdk = "develop-630" #tangemCardSdk = "0.0.1" # Keep it! - used for local builds ^ tangemVico = "tangem-master-21" #tangemVico = "0.0.1" # Keep it! - used for local builds ^ tangemHotSdk = "develop-550" #tangemHotSdk = "0.0.1" # Keep it! - used for local builds ^ - - +tangemUsedeskSdk = "main-12" +#tangemUsedeskSdk = "0.0.1" # Keep it! - used for local builds ^ [libraries] blockchain = { module = "com.tangem:blockchain", version.ref = "tangemBlockchainSdk" } @@ -23,6 +23,9 @@ card-core = { module = "com.tangem.tangem-sdk-kotlin:core", version.ref = "tange hot-core = { module = "com.tangem.tangem-hot-sdk-kotlin:core", version.ref = "tangemHotSdk" } hot-android = { module = "com.tangem.tangem-hot-sdk-kotlin:android", version.ref = "tangemHotSdk" } +usedesk-chat-sdk = { module = "com.tangem.usedesk:chat-sdk", version.ref = "tangemUsedeskSdk" } +usedesk-chat-gui = { module = "com.tangem.usedesk:chat-gui", version.ref = "tangemUsedeskSdk" } + vico-compose = { group = "com.tangem.vico", name = "compose", version.ref = "tangemVico" } vico-compose-m3 = { group = "com.tangem.vico", name = "compose-m3", version.ref = "tangemVico" } vico-core = { group = "com.tangem.vico", name = "core", version.ref = "tangemVico" }