From bac57ca199c30f1e7de999aec3f1648c5577f7e6 Mon Sep 17 00:00:00 2001 From: Tangem Date: Mon, 20 Apr 2026 13:52:27 +0400 Subject: [PATCH 01/13] Updated on 2026-08-14 --- .../notifications/SwapNotificationsComponent.kt | 2 ++ .../model/SwapNotificationsModel.kt | 16 ++++++++++++---- .../confirm/SendWithSwapConfirmComponent.kt | 1 + .../confirm/model/SendWithSwapConfirmModel.kt | 1 + 4 files changed, 16 insertions(+), 4 deletions(-) diff --git a/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/notifications/SwapNotificationsComponent.kt b/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/notifications/SwapNotificationsComponent.kt index f68c3e608d..c7e62ed4f3 100644 --- a/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/notifications/SwapNotificationsComponent.kt +++ b/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/notifications/SwapNotificationsComponent.kt @@ -7,6 +7,7 @@ import com.tangem.core.decompose.context.AppComponentContext import com.tangem.core.decompose.model.getOrCreateModel import com.tangem.domain.express.models.ExpressError import com.tangem.domain.express.models.ExpressProvider +import com.tangem.domain.express.models.ExpressRateType import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.models.currency.CryptoCurrencyStatus import com.tangem.domain.models.wallet.UserWalletId @@ -53,6 +54,7 @@ internal class SwapNotificationsComponent( val fromCryptoCurrencyStatus: CryptoCurrencyStatus? = null, val priceImpact: PriceImpact? = null, val provider: ExpressProvider? = null, + val rateType: ExpressRateType? = null, val shouldIncludeFeeInBalanceCheck: Boolean = false, val feeValue: BigDecimal? = null, ) diff --git a/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/notifications/model/SwapNotificationsModel.kt b/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/notifications/model/SwapNotificationsModel.kt index 0031fd2efd..ca86d23dee 100644 --- a/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/notifications/model/SwapNotificationsModel.kt +++ b/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/notifications/model/SwapNotificationsModel.kt @@ -8,6 +8,7 @@ import com.tangem.core.decompose.model.ParamsContainer import com.tangem.core.ui.format.bigdecimal.crypto import com.tangem.core.ui.format.bigdecimal.format import com.tangem.domain.express.models.ExpressError +import com.tangem.domain.express.models.ExpressRateType import com.tangem.domain.transaction.usecase.IsMemoRequiredUseCase import com.tangem.features.swap.v2.api.subcomponents.SwapAmountUpdateTrigger import com.tangem.features.swap.v2.impl.amount.entity.PriceImpact @@ -150,21 +151,28 @@ internal class SwapNotificationsModel @Inject constructor( fun MutableList.addExpressErrorNotification() { val expressError = notificationData.expressError ?: return val fromCryptoCurrency = notificationData.fromCryptoCurrency ?: return + val toCryptoCurrency = notificationData.toCryptoCurrencyStatus?.currency ?: return + + val amountErrorCurrency = if (notificationData.rateType == ExpressRateType.Fixed) { + toCryptoCurrency + } else { + fromCryptoCurrency + } val errorNotification = when (expressError) { is ExpressError.AmountError.TooSmallError -> SwapNotificationUM.Error.MinimalAmountError( expressError.amount.format { crypto( - symbol = fromCryptoCurrency.symbol, - decimals = fromCryptoCurrency.decimals, + symbol = amountErrorCurrency.symbol, + decimals = amountErrorCurrency.decimals, ) }, ) is ExpressError.AmountError.TooBigError -> SwapNotificationUM.Error.MaximumAmountError( expressError.amount.format { crypto( - symbol = fromCryptoCurrency.symbol, - decimals = fromCryptoCurrency.decimals, + symbol = amountErrorCurrency.symbol, + decimals = amountErrorCurrency.decimals, ) }, ) diff --git a/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/sendviaswap/confirm/SendWithSwapConfirmComponent.kt b/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/sendviaswap/confirm/SendWithSwapConfirmComponent.kt index 8e24591f1f..387432f1fd 100644 --- a/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/sendviaswap/confirm/SendWithSwapConfirmComponent.kt +++ b/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/sendviaswap/confirm/SendWithSwapConfirmComponent.kt @@ -141,6 +141,7 @@ internal class SendWithSwapConfirmComponent @AssistedInject constructor( enteredFromAmount = model.confirmData.enteredFromAmount, fromCryptoCurrencyStatus = model.confirmData.fromCryptoCurrencyStatus, priceImpact = model.confirmData.priceImpact, + rateType = model.confirmData.rateType, ), ), ) diff --git a/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/sendviaswap/confirm/model/SendWithSwapConfirmModel.kt b/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/sendviaswap/confirm/model/SendWithSwapConfirmModel.kt index 35c8b24d02..809ef27a7f 100644 --- a/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/sendviaswap/confirm/model/SendWithSwapConfirmModel.kt +++ b/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/sendviaswap/confirm/model/SendWithSwapConfirmModel.kt @@ -463,6 +463,7 @@ internal class SendWithSwapConfirmModel @Inject constructor( fromCryptoCurrencyStatus = confirmData.fromCryptoCurrencyStatus, priceImpact = confirmData.priceImpact, provider = confirmData.quote?.provider, + rateType = confirmData.rateType, shouldIncludeFeeInBalanceCheck = isFixedRate && isAmountSubtractAvailable, feeValue = confirmData.fee?.amount?.value, ), From 1c17a8389aeebaee304391c359e76d39f2960015 Mon Sep 17 00:00:00 2001 From: Tangem Date: Mon, 20 Apr 2026 19:04:32 +0300 Subject: [PATCH 02/13] Updated on 2026-08-14 --- .../api/PromoBannersBlockComponent.kt | 6 +++--- .../impl/model/PromoBannersBlockModel.kt | 20 ++++++++++--------- .../DefaultPromoBannersRepository.kt | 7 +------ 3 files changed, 15 insertions(+), 18 deletions(-) diff --git a/features/promo-banners/api/src/main/kotlin/com/tangem/features/promobanners/api/PromoBannersBlockComponent.kt b/features/promo-banners/api/src/main/kotlin/com/tangem/features/promobanners/api/PromoBannersBlockComponent.kt index e97e39f047..57c0e3844a 100644 --- a/features/promo-banners/api/src/main/kotlin/com/tangem/features/promobanners/api/PromoBannersBlockComponent.kt +++ b/features/promo-banners/api/src/main/kotlin/com/tangem/features/promobanners/api/PromoBannersBlockComponent.kt @@ -12,9 +12,9 @@ interface PromoBannersBlockComponent : ComposableContentComponent { val isInitiallyVisibleOnScreen: Boolean = true, ) - enum class Placeholder { - MAIN, - FEED, + enum class Placeholder(val value: String) { + MAIN("main"), + FEED("shtorka"), } interface Factory : ComponentFactory diff --git a/features/promo-banners/impl/src/main/kotlin/com/tangem/features/promobanners/impl/model/PromoBannersBlockModel.kt b/features/promo-banners/impl/src/main/kotlin/com/tangem/features/promobanners/impl/model/PromoBannersBlockModel.kt index d4a633984f..7abc5a48bd 100644 --- a/features/promo-banners/impl/src/main/kotlin/com/tangem/features/promobanners/impl/model/PromoBannersBlockModel.kt +++ b/features/promo-banners/impl/src/main/kotlin/com/tangem/features/promobanners/impl/model/PromoBannersBlockModel.kt @@ -21,6 +21,8 @@ import java.util.Locale import java.util.concurrent.ConcurrentHashMap import javax.inject.Inject +private typealias ShownBannerKey = Pair + @ModelScoped internal class PromoBannersBlockModel @Inject constructor( override val dispatchers: CoroutineDispatcherProvider, @@ -34,8 +36,8 @@ internal class PromoBannersBlockModel @Inject constructor( private val params = paramsContainer.require() private val converter = PromoBannerDisplayToNotificationConverter() - private val placeholder: String = params.placeholder.name.lowercase() - private val shownBannerIds: MutableSet = ConcurrentHashMap.newKeySet() + private val placeholderName: String = params.placeholder.value + private val shownBannerIds: MutableSet = ConcurrentHashMap.newKeySet() private var isVisibleOnScreen: Boolean = params.isInitiallyVisibleOnScreen private var wasCarouselScrolled = false private val savedDisplayIdByWalletId: MutableMap = mutableMapOf() @@ -92,7 +94,7 @@ internal class PromoBannersBlockModel @Inject constructor( banners = bannerUMs, isVisibleOnScreen = isVisibleOnScreen, placeholder = params.placeholder, - onBannerShown = ::onBannerShown, + onBannerShown = { displayId -> onBannerShown(walletId, displayId) }, onCarouselScrolled = ::onCarouselScrolled, onPageChanged = { displayId -> savedDisplayIdByWalletId[walletId] = displayId }, ) @@ -101,21 +103,21 @@ internal class PromoBannersBlockModel @Inject constructor( } } - private fun onBannerShown(displayId: Int) { - if (shownBannerIds.add(displayId)) { - analyticsEventHandler.send(PromoBannerAnalyticsEvent.Shown(displayId, placeholder)) + private fun onBannerShown(walletId: String, displayId: Int) { + if (shownBannerIds.add(walletId to displayId)) { + analyticsEventHandler.send(PromoBannerAnalyticsEvent.Shown(displayId, placeholderName)) } } private fun onCarouselScrolled(displayId: Int) { if (!wasCarouselScrolled) { wasCarouselScrolled = true - analyticsEventHandler.send(PromoBannerAnalyticsEvent.CarouselScrolled(displayId, placeholder)) + analyticsEventHandler.send(PromoBannerAnalyticsEvent.CarouselScrolled(displayId, placeholderName)) } } private fun onButtonClick(displayId: Int, deeplink: String?) { - analyticsEventHandler.send(PromoBannerAnalyticsEvent.Clicked(displayId, placeholder)) + analyticsEventHandler.send(PromoBannerAnalyticsEvent.Clicked(displayId, placeholderName)) deeplink?.let { deeplinkLauncher.launch(it) } } @@ -131,7 +133,7 @@ internal class PromoBannersBlockModel @Inject constructor( ) private fun onBannerDismiss(walletId: String, displayId: Int) { - analyticsEventHandler.send(PromoBannerAnalyticsEvent.Dismissed(displayId, placeholder)) + analyticsEventHandler.send(PromoBannerAnalyticsEvent.Dismissed(displayId, placeholderName)) uiState.update { state -> state.copy( banners = state.banners diff --git a/features/promo-banners/impl/src/main/kotlin/com/tangem/features/promobanners/impl/repository/DefaultPromoBannersRepository.kt b/features/promo-banners/impl/src/main/kotlin/com/tangem/features/promobanners/impl/repository/DefaultPromoBannersRepository.kt index 37c5cf33ef..0ab8bf3ee9 100644 --- a/features/promo-banners/impl/src/main/kotlin/com/tangem/features/promobanners/impl/repository/DefaultPromoBannersRepository.kt +++ b/features/promo-banners/impl/src/main/kotlin/com/tangem/features/promobanners/impl/repository/DefaultPromoBannersRepository.kt @@ -37,7 +37,7 @@ internal class DefaultPromoBannersRepository( val banners = withContext(dispatchers.io) { tangemTechApi.getPromoBannerDisplays( walletId = walletId, - placeholder = placeholder.toApiValue(), + placeholder = placeholder.value, languageISOCode = languageISOCode, ).getOrThrow() .items @@ -71,9 +71,4 @@ internal class DefaultPromoBannersRepository( tangemTechApi.dismissPromoBannerDisplay(displayId, request).getOrThrow() } } - - private fun Placeholder.toApiValue(): String = when (this) { - Placeholder.MAIN -> "main" - Placeholder.FEED -> "shtorka" - } } \ No newline at end of file From 83900d8417030ffbe67301a7d31e8757d34c9691 Mon Sep 17 00:00:00 2001 From: Tangem Date: Tue, 21 Apr 2026 01:31:26 -0700 Subject: [PATCH 03/13] Updated on 2026-08-14 --- .../com/tangem/data/pay/DefaultTangemPayEligibilityManager.kt | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/data/visa/src/main/kotlin/com/tangem/data/pay/DefaultTangemPayEligibilityManager.kt b/data/visa/src/main/kotlin/com/tangem/data/pay/DefaultTangemPayEligibilityManager.kt index fb9bd81fda..8f71e7360f 100644 --- a/data/visa/src/main/kotlin/com/tangem/data/pay/DefaultTangemPayEligibilityManager.kt +++ b/data/visa/src/main/kotlin/com/tangem/data/pay/DefaultTangemPayEligibilityManager.kt @@ -48,9 +48,7 @@ internal class DefaultTangemPayEligibilityManager @Inject constructor( } override suspend fun getTangemPayAvailability(entryPoint: TangemPayEntryPoint): Boolean { - val eligibility = onboardingRepository.getCustomerEligibility().ifEmpty { - onboardingRepository.checkCustomerEligibility() - } + val eligibility = onboardingRepository.checkCustomerEligibility() val type = entryPoint.toEligibilityType() return eligibility.any { it == type } .also { isEligible -> if (!isEligible) reset() } From 7b46bfda11ff988e59c6552ba37e4f31c7b3e737 Mon Sep 17 00:00:00 2001 From: Tangem Date: Tue, 21 Apr 2026 15:35:50 +0400 Subject: [PATCH 04/13] Updated on 2026-08-14 --- .../tangem/feature/wallet/child/wallet/model/WalletModel.kt | 5 +++++ .../wallet/analytics/WalletScreenAnalyticsEvent.kt | 2 ++ 2 files changed, 7 insertions(+) diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/wallet/model/WalletModel.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/wallet/model/WalletModel.kt index 5a2a395cfc..8597389093 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/wallet/model/WalletModel.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/wallet/model/WalletModel.kt @@ -249,6 +249,10 @@ internal class WalletModel @Inject constructor( } else { null } + val isBackedUp = when (selectedWallet) { + is UserWallet.Cold -> selectedWallet.scanResponse.card.backupStatus?.isActive == true + is UserWallet.Hot -> selectedWallet.backedUp + } val result = getAppThemeModeUseCase().firstOrNull() val theme = result?.getOrElse { AppThemeMode.FOLLOW_SYSTEM } ?: AppThemeMode.FOLLOW_SYSTEM val appCurrency = getSelectedAppCurrencyUseCase.invokeSync().getOrElse { AppCurrency.Default }.code @@ -256,6 +260,7 @@ internal class WalletModel @Inject constructor( WalletScreenAnalyticsEvent.MainScreen.ScreenOpened( hasMobileWallet = hasMobileWallet, accountsCount = accountsCount, + isBackedUp = isBackedUp, theme = theme.value, isImported = selectedWallet.isImported(), referralId = appsFlyerStore.get()?.refcode, diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/analytics/WalletScreenAnalyticsEvent.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/analytics/WalletScreenAnalyticsEvent.kt index 5040a3728d..4462ad2539 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/analytics/WalletScreenAnalyticsEvent.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/analytics/WalletScreenAnalyticsEvent.kt @@ -54,6 +54,7 @@ sealed class WalletScreenAnalyticsEvent { data class ScreenOpened( private val hasMobileWallet: Boolean, private val accountsCount: Int?, + private val isBackedUp: Boolean, val theme: String, val isImported: Boolean, val referralId: String?, @@ -71,6 +72,7 @@ sealed class WalletScreenAnalyticsEvent { } put("Wallet Type", seedPhrase) put("App Currency", appCurrency) + put("Backuped", if (isBackedUp) "Yes" else "No") putAll(getReferralParams(referralId)) }, ), AppsFlyerIncludedEvent From 6d686a0820ab4445275ade5ad7359de0c3e1e602 Mon Sep 17 00:00:00 2001 From: Tangem Date: Wed, 22 Apr 2026 19:30:22 +0500 Subject: [PATCH 05/13] Updated on 2026-08-14 --- .../com/tangem/datasource/di/MoshiModule.kt | 1 + .../entity/PaymentAccountStatusValueDM.kt | 5 ++ .../PaymentAccountStatusValueDMConverter.kt | 2 + .../DefaultPaymentAccountStatusFetcher.kt | 53 ++++++++++++------- .../repository/DefaultOnboardingRepository.kt | 11 ++++ .../usecase/IsAccountsModeEnabledUseCase.kt | 14 ++++- .../IsAccountsModeEnabledUseCaseTest.kt | 24 +++++++++ .../account/PaymentAccountStatusValue.kt | 8 +++ .../domain/GetMultiWalletWarningsFactory.kt | 9 ++-- .../domain/GetWalletNotificationsFactory.kt | 1 + .../converter/TangemPayMainBlockConverter.kt | 1 + 11 files changed, 103 insertions(+), 26 deletions(-) diff --git a/core/datasource/src/main/java/com/tangem/datasource/di/MoshiModule.kt b/core/datasource/src/main/java/com/tangem/datasource/di/MoshiModule.kt index effeecade9..53e728d17c 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/di/MoshiModule.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/di/MoshiModule.kt @@ -48,6 +48,7 @@ class MoshiModule { ) .add( NamePolymorphicAdapterFactory.of(PaymentAccountStatusValueDM::class.java) + .withSubtype(PaymentAccountStatusValueDM.Empty::class.java, "empty") .withSubtype(PaymentAccountStatusValueDM.NotCreated::class.java, "not_created") .withSubtype(PaymentAccountStatusValueDM.UnderReview::class.java, "kyc_status") .withSubtype(PaymentAccountStatusValueDM.IssuingCard::class.java, "issuing_card") diff --git a/core/datasource/src/main/java/com/tangem/datasource/local/visa/entity/PaymentAccountStatusValueDM.kt b/core/datasource/src/main/java/com/tangem/datasource/local/visa/entity/PaymentAccountStatusValueDM.kt index 7b1ca88f44..fb91718338 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/local/visa/entity/PaymentAccountStatusValueDM.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/local/visa/entity/PaymentAccountStatusValueDM.kt @@ -16,6 +16,11 @@ import java.math.BigDecimal @JsonClass(generateAdapter = true, generator = PolymorphicAdapterType.NAME_POLYMORPHIC_ADAPTER) sealed interface PaymentAccountStatusValueDM { + @NameLabel("empty") + data class Empty( + @Json(name = "empty") val marker: Boolean = true, + ) : PaymentAccountStatusValueDM + @NameLabel("not_created") data class NotCreated( @Json(name = "not_created") val marker: Boolean = true, diff --git a/data/visa/src/main/kotlin/com/tangem/data/pay/converter/PaymentAccountStatusValueDMConverter.kt b/data/visa/src/main/kotlin/com/tangem/data/pay/converter/PaymentAccountStatusValueDMConverter.kt index a42852436a..94006aaf9c 100644 --- a/data/visa/src/main/kotlin/com/tangem/data/pay/converter/PaymentAccountStatusValueDMConverter.kt +++ b/data/visa/src/main/kotlin/com/tangem/data/pay/converter/PaymentAccountStatusValueDMConverter.kt @@ -51,6 +51,7 @@ internal object PaymentAccountStatusValueDMConverter : is PaymentAccountStatusValue.Error.CardIssueFailed -> PaymentAccountStatusValueDM.CardIssueFailed( customerId = value.customerId, ) + is PaymentAccountStatusValue.Empty -> PaymentAccountStatusValueDM.Empty() // Transient statuses are not persisted is PaymentAccountStatusValue.Loading, is PaymentAccountStatusValue.Error.ExposedDevice, @@ -62,6 +63,7 @@ internal object PaymentAccountStatusValueDMConverter : override fun convertBack(value: PaymentAccountStatusValueDM?): PaymentAccountStatusValue { return when (value) { + is PaymentAccountStatusValueDM.Empty -> PaymentAccountStatusValue.Empty is PaymentAccountStatusValueDM.NotCreated -> PaymentAccountStatusValue.NotCreated is PaymentAccountStatusValueDM.CardIssueFailed -> PaymentAccountStatusValue.Error.CardIssueFailed( customerId = value.customerId, diff --git a/data/visa/src/main/kotlin/com/tangem/data/pay/flow/DefaultPaymentAccountStatusFetcher.kt b/data/visa/src/main/kotlin/com/tangem/data/pay/flow/DefaultPaymentAccountStatusFetcher.kt index fa38de8fbd..30b61c32ef 100644 --- a/data/visa/src/main/kotlin/com/tangem/data/pay/flow/DefaultPaymentAccountStatusFetcher.kt +++ b/data/visa/src/main/kotlin/com/tangem/data/pay/flow/DefaultPaymentAccountStatusFetcher.kt @@ -8,10 +8,13 @@ import com.tangem.domain.models.account.Account import com.tangem.domain.models.account.AccountStatus import com.tangem.domain.models.account.PaymentAccountStatusValue import com.tangem.domain.models.kyc.KycStatus +import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.domain.pay.TangemPayEligibilityManager import com.tangem.domain.pay.flow.PaymentAccountStatusFetcher import com.tangem.domain.pay.model.CustomerInfo import com.tangem.domain.pay.model.OrderData import com.tangem.domain.pay.model.OrderStatus +import com.tangem.domain.pay.model.TangemPayEntryPoint import com.tangem.domain.pay.repository.CustomerOrderRepository import com.tangem.domain.pay.repository.OnboardingRepository import com.tangem.domain.visa.error.VisaApiError @@ -28,12 +31,14 @@ import kotlin.time.Duration.Companion.minutes private const val TAG = "PaymentAccountStatusFetcher" +@Suppress("LongParameterList") internal class DefaultPaymentAccountStatusFetcher @Inject constructor( private val paymentAccountStatusesStore: PaymentAccountStatusesStore, private val onboardingRepository: OnboardingRepository, private val customerOrderRepository: CustomerOrderRepository, private val deviceSecurity: DeviceSecurityInfoProvider, private val dispatchers: CoroutineDispatcherProvider, + private val eligibilityManager: TangemPayEligibilityManager, ) : PaymentAccountStatusFetcher { private val logger = TangemLogger.withTag(TAG) @@ -43,6 +48,16 @@ internal class DefaultPaymentAccountStatusFetcher @Inject constructor( val account = Account.Payment(userWalletId = params.userWalletId) logger.i("fetch: ${params.userWalletId.stringValue}") + if (onboardingRepository.isTangemPayDeactivated(params.userWalletId)) { + return@catchOn paymentAccountStatusesStore.store( + userWalletId = params.userWalletId, + status = AccountStatus.Payment( + account = account, + value = PaymentAccountStatusValue.Empty, + ), + ) + } + if (deviceSecurity.isSecurityExposed()) { logger.i("fetch security info: rooted: ${deviceSecurity.isRooted}") logger.i("fetch security info: xposed: ${deviceSecurity.isXposed}") @@ -57,22 +72,12 @@ internal class DefaultPaymentAccountStatusFetcher @Inject constructor( ) } - if (onboardingRepository.isTangemPayDeactivated(params.userWalletId)) { - return@catchOn paymentAccountStatusesStore.store( - userWalletId = params.userWalletId, - status = AccountStatus.Payment( - account = account, - value = PaymentAccountStatusValue.NotCreated, - ), - ) - } - val status = onboardingRepository.hasTangemPayInWallet(userWalletId = params.userWalletId) .fold( ifLeft = { error -> logger.e("Failed check wallet ${params.userWalletId}: ${error.javaClass.simpleName}") when (error) { - is VisaApiError.NotPaeraCustomer -> PaymentAccountStatusValue.NotCreated + is VisaApiError.NotPaeraCustomer -> constructNotCreatedOrEmptyStatus(params.userWalletId) else -> PaymentAccountStatusValue.Error.Unavailable } }, @@ -100,7 +105,7 @@ internal class DefaultPaymentAccountStatusFetcher @Inject constructor( return if (hasTangemPay) { fetchTangemPayAccountStatus(account) } else { - PaymentAccountStatusValue.NotCreated + constructNotCreatedOrEmptyStatus(account.userWalletId) } } @@ -133,7 +138,7 @@ internal class DefaultPaymentAccountStatusFetcher @Inject constructor( return onboardingRepository.getCustomerInfo(account.userWalletId).fold( ifLeft = { error -> logger.e("proceedWithoutOrder ${account.userWalletId} error: $error") - error.mapToPaymentAccountStatus() + error.mapToPaymentAccountStatus(account.userWalletId) }, ifRight = { customerInfo -> logger.i("proceedWithoutOrder data customerInfo ${account.userWalletId}") @@ -153,7 +158,7 @@ internal class DefaultPaymentAccountStatusFetcher @Inject constructor( val customerInfo = onboardingRepository.getCustomerInfo(account.userWalletId).fold( ifLeft = { error -> logger.e("proceedWithOrderId KYC check ${account.userWalletId} error: $error") - return error.mapToPaymentAccountStatus() + return error.mapToPaymentAccountStatus(account.userWalletId) }, ifRight = { it }, ) @@ -172,7 +177,7 @@ internal class DefaultPaymentAccountStatusFetcher @Inject constructor( return customerOrderRepository.getOrderData(userWalletId = account.userWalletId, orderId = orderId).fold( ifLeft = { error -> logger.e("proceedWithOrderId ${account.userWalletId} orderId: $orderId error: $error") - error.mapToPaymentAccountStatus() + error.mapToPaymentAccountStatus(account.userWalletId) }, ifRight = { orderData -> logger.i("proceedWithOrderId ${account.userWalletId}: $orderId status: ${orderData.status}") @@ -241,7 +246,7 @@ internal class DefaultPaymentAccountStatusFetcher @Inject constructor( onboardingRepository.clearOrderId(account.userWalletId) return onboardingRepository.getCustomerInfo(userWalletId = account.userWalletId) .fold( - ifLeft = { it.mapToPaymentAccountStatus() }, + ifLeft = { it.mapToPaymentAccountStatus(account.userWalletId) }, ifRight = { customerInfo -> customerInfo.mapToPaymentAccountStatus() }, ) } @@ -297,12 +302,22 @@ internal class DefaultPaymentAccountStatusFetcher @Inject constructor( } } - private fun VisaApiError.mapToPaymentAccountStatus(): PaymentAccountStatusValue { + private suspend fun VisaApiError.mapToPaymentAccountStatus(userWalletId: UserWalletId): PaymentAccountStatusValue { return when (this) { is VisaApiError.RefreshTokenExpired -> PaymentAccountStatusValue.Error.NotSynced - is VisaApiError.NotPaeraCustomer -> PaymentAccountStatusValue.NotCreated - is VisaApiError.Deactivated -> PaymentAccountStatusValue.NotCreated + is VisaApiError.NotPaeraCustomer -> constructNotCreatedOrEmptyStatus(userWalletId) + is VisaApiError.Deactivated -> constructNotCreatedOrEmptyStatus(userWalletId) else -> PaymentAccountStatusValue.Error.Unavailable } } + + private suspend fun constructNotCreatedOrEmptyStatus(userWalletId: UserWalletId): PaymentAccountStatusValue { + val entryPoint = TangemPayEntryPoint.BANNER + val shouldShowBanner = !eligibilityManager.isPaeraCustomerForAnyWallet(entryPoint) && + eligibilityManager.getEligibleWallets(shouldExcludePaeraCustomers = false, entryPoint = entryPoint) + .any { it.walletId == userWalletId } && + !onboardingRepository.getHideMainOnboardingBanner(userWalletId) + + return if (shouldShowBanner) PaymentAccountStatusValue.NotCreated else PaymentAccountStatusValue.Empty + } } \ No newline at end of file diff --git a/data/visa/src/main/kotlin/com/tangem/data/pay/repository/DefaultOnboardingRepository.kt b/data/visa/src/main/kotlin/com/tangem/data/pay/repository/DefaultOnboardingRepository.kt index 8253961561..e1f741be61 100644 --- a/data/visa/src/main/kotlin/com/tangem/data/pay/repository/DefaultOnboardingRepository.kt +++ b/data/visa/src/main/kotlin/com/tangem/data/pay/repository/DefaultOnboardingRepository.kt @@ -5,6 +5,7 @@ import arrow.core.flatMap import arrow.core.left import arrow.core.right import com.tangem.core.analytics.api.AnalyticsEventHandler +import com.tangem.data.pay.store.PaymentAccountStatusesStore import com.tangem.datasource.api.pay.TangemPayApi import com.tangem.datasource.api.pay.models.request.DeeplinkValidityRequest import com.tangem.datasource.api.pay.models.request.OrderRequest @@ -15,6 +16,8 @@ import com.tangem.datasource.local.visa.TangemPayCardFrozenStateStore import com.tangem.datasource.local.visa.TangemPayStorage import com.tangem.domain.common.wallets.UserWalletsListRepository import com.tangem.domain.models.TangemPayEligibilityType +import com.tangem.domain.models.account.Account +import com.tangem.domain.models.account.AccountStatus import com.tangem.domain.models.account.PaymentAccountStatusValue import com.tangem.domain.models.kyc.KycStatus import com.tangem.domain.models.wallet.UserWallet @@ -44,6 +47,7 @@ internal class DefaultOnboardingRepository @Inject constructor( private val authDataSource: TangemPayAuthDataSource, private val cardFrozenStateStore: TangemPayCardFrozenStateStore, private val userWalletsListRepository: UserWalletsListRepository, + private val paymentAccountStatusStore: PaymentAccountStatusesStore, ) : OnboardingRepository { // Save data for a session @@ -265,6 +269,13 @@ internal class DefaultOnboardingRepository @Inject constructor( override suspend fun setHideMainOnboardingBanner(userWalletId: UserWalletId) { tangemPayStorage.storeHideOnboardingBanner(userWalletId, hide = true) + paymentAccountStatusStore.store( + userWalletId = userWalletId, + status = AccountStatus.Payment( + account = Account.Payment(userWalletId), + value = PaymentAccountStatusValue.Empty, + ), + ) } override suspend fun disableTangemPay(userWalletId: UserWalletId): Either { diff --git a/domain/account/status/src/main/java/com/tangem/domain/account/status/usecase/IsAccountsModeEnabledUseCase.kt b/domain/account/status/src/main/java/com/tangem/domain/account/status/usecase/IsAccountsModeEnabledUseCase.kt index 1ae6967739..ae529e245c 100644 --- a/domain/account/status/src/main/java/com/tangem/domain/account/status/usecase/IsAccountsModeEnabledUseCase.kt +++ b/domain/account/status/src/main/java/com/tangem/domain/account/status/usecase/IsAccountsModeEnabledUseCase.kt @@ -12,6 +12,7 @@ import kotlinx.coroutines.flow.map * Accounts mode is considered enabled if any [com.tangem.domain.account.models.AccountStatusList] produced for the * user's wallets has more than one [AccountStatus.CryptoPortfolio], or has a [AccountStatus.Payment] with any + * [PaymentAccountStatusValue.Empty]. * * @property multiAccountStatusListSupplier supplier that provides a list of * [com.tangem.domain.account.models.AccountStatusList]s for all user wallets @@ -45,6 +46,17 @@ class IsAccountsModeEnabledUseCase( } private fun PaymentAccountStatusValue.isActivePayment(): Boolean { - return this !is PaymentAccountStatusValue.NotCreated + return when (this) { + is PaymentAccountStatusValue.Empty, + is PaymentAccountStatusValue.NotCreated, + -> false + is PaymentAccountStatusValue.Error, + is PaymentAccountStatusValue.IssuingCard, + is PaymentAccountStatusValue.Loaded, + is PaymentAccountStatusValue.Loading, + is PaymentAccountStatusValue.Locked, + is PaymentAccountStatusValue.UnderReview, + -> true + } } } \ No newline at end of file diff --git a/domain/account/status/src/test/kotlin/com/tangem/domain/account/status/usecase/IsAccountsModeEnabledUseCaseTest.kt b/domain/account/status/src/test/kotlin/com/tangem/domain/account/status/usecase/IsAccountsModeEnabledUseCaseTest.kt index 49a4a0bd50..02aa942198 100644 --- a/domain/account/status/src/test/kotlin/com/tangem/domain/account/status/usecase/IsAccountsModeEnabledUseCaseTest.kt +++ b/domain/account/status/src/test/kotlin/com/tangem/domain/account/status/usecase/IsAccountsModeEnabledUseCaseTest.kt @@ -102,6 +102,18 @@ class IsAccountsModeEnabledUseCaseTest { Truth.assertThat(actual).isFalse() } + @Test + fun `returns false when payment account is Empty`() = runTest { + val statusList = createAccountStatusList( + statuses = listOf(mockCryptoPortfolio(), mockPayment(PaymentAccountStatusValue.Empty)), + ) + every { multiAccountStatusListSupplier.invoke() } returns flowOf(listOf(statusList)) + + val actual = useCase.invoke().first() + + Truth.assertThat(actual).isFalse() + } + @Test fun `returns true when payment account is UnderReview`() = runTest { val statusList = createAccountStatusList( @@ -234,6 +246,18 @@ class IsAccountsModeEnabledUseCaseTest { Truth.assertThat(actual).isFalse() } + @Test + fun `returns false when payment account is Empty`() = runTest { + val statusList = createAccountStatusList( + statuses = listOf(mockCryptoPortfolio(), mockPayment(PaymentAccountStatusValue.Empty)), + ) + coEvery { multiAccountStatusListSupplier.getSyncOrNull(Unit, any()) } returns listOf(statusList) + + val actual = useCase.invokeSync() + + Truth.assertThat(actual).isFalse() + } + @Test fun `returns true when payment account is UnderReview`() = runTest { val statusList = createAccountStatusList( diff --git a/domain/models/src/main/kotlin/com/tangem/domain/models/account/PaymentAccountStatusValue.kt b/domain/models/src/main/kotlin/com/tangem/domain/models/account/PaymentAccountStatusValue.kt index fc44ccc105..542733a235 100644 --- a/domain/models/src/main/kotlin/com/tangem/domain/models/account/PaymentAccountStatusValue.kt +++ b/domain/models/src/main/kotlin/com/tangem/domain/models/account/PaymentAccountStatusValue.kt @@ -20,6 +20,7 @@ sealed class PaymentAccountStatusValue { get() = when (this) { is Error, is IssuingCard, + is Empty, is NotCreated, is UnderReview, -> TotalFiatBalance.Loaded(amount = SerializedBigDecimal.ZERO, source = source) @@ -40,12 +41,19 @@ sealed class PaymentAccountStatusValue { is Locked -> copy(source = source) is UnderReview -> copy(source = source) is Loading, + is Empty, is NotCreated, is Error, -> this } } + /** Represents an empty payment account status when no specific state is available. */ + @Serializable + data object Empty : PaymentAccountStatusValue() { + override val source: StatusSource = StatusSource.ACTUAL + } + /** Represents the Loading state of a payment account, typically while fetching its details. */ @Serializable data object Loading : PaymentAccountStatusValue() { diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/domain/GetMultiWalletWarningsFactory.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/domain/GetMultiWalletWarningsFactory.kt index f0d03bf32a..32e10b3532 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/domain/GetMultiWalletWarningsFactory.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/domain/GetMultiWalletWarningsFactory.kt @@ -29,13 +29,13 @@ import com.tangem.domain.promo.models.PromoId import com.tangem.domain.settings.IsReadyToShowRateAppUseCase import com.tangem.domain.tokensync.model.TokenSyncProgress import com.tangem.domain.tokensync.usecase.ObserveTokenSyncUseCase -import com.tangem.features.hotwallet.HotWalletFeatureToggles import com.tangem.domain.wallets.usecase.IsNeedToBackupUseCase import com.tangem.feature.wallet.child.wallet.model.WalletActivationBannerType import com.tangem.feature.wallet.child.wallet.model.intents.WalletClickIntents import com.tangem.feature.wallet.impl.R import com.tangem.feature.wallet.presentation.account.AccountDependencies import com.tangem.feature.wallet.presentation.wallet.state.model.WalletNotification +import com.tangem.features.hotwallet.HotWalletFeatureToggles import com.tangem.hot.sdk.model.HotWalletId import com.tangem.lib.crypto.BlockchainUtils import com.tangem.utils.extensions.addIf @@ -43,11 +43,7 @@ import com.tangem.utils.extensions.isPositive import com.tangem.utils.extensions.orZero import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.toImmutableList -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.combine -import kotlinx.coroutines.flow.distinctUntilChanged -import kotlinx.coroutines.flow.flowOf -import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.* import javax.inject.Inject @Deprecated("Remove with main toggle [DesignFeatureToggles.isRedesignEnabled]") @@ -214,6 +210,7 @@ internal class GetMultiWalletWarningsFactory @Inject constructor( is PaymentAccountStatusValue.Loading, is PaymentAccountStatusValue.Locked, is PaymentAccountStatusValue.UnderReview, + is PaymentAccountStatusValue.Empty, -> null } notification?.let(::add) diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/domain/GetWalletNotificationsFactory.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/domain/GetWalletNotificationsFactory.kt index 8ce6b1a9c0..c119fc6251 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/domain/GetWalletNotificationsFactory.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/domain/GetWalletNotificationsFactory.kt @@ -239,6 +239,7 @@ internal class GetWalletNotificationsFactory @Inject constructor( is PaymentAccountStatusValue.Loading, is PaymentAccountStatusValue.Locked, is PaymentAccountStatusValue.UnderReview, + is PaymentAccountStatusValue.Empty, -> null } notification?.let(::add) diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/converter/TangemPayMainBlockConverter.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/converter/TangemPayMainBlockConverter.kt index 1ce25e26ac..fef7fb08bb 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/converter/TangemPayMainBlockConverter.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/converter/TangemPayMainBlockConverter.kt @@ -53,6 +53,7 @@ internal class TangemPayMainBlockConverter( } }, ) + is PaymentAccountStatusValue.Empty -> TangemPayMainUM.Empty is PaymentAccountStatusValue.NotCreated -> TangemPayMainUM.Empty is PaymentAccountStatusValue.Loading -> TangemPayMainUM.Loading is PaymentAccountStatusValue.Locked -> TangemPayMainUM.Content( From efabb01299408a6680fc99d53221c8d1470defd3 Mon Sep 17 00:00:00 2001 From: Tangem Date: Wed, 22 Apr 2026 17:40:58 +0300 Subject: [PATCH 06/13] Updated on 2026-08-14 --- .../staking/impl/presentation/ui/StakingConfirmationContent.kt | 2 +- .../staking/impl/presentation/ui/StakingSuccessContent.kt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/ui/StakingConfirmationContent.kt b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/ui/StakingConfirmationContent.kt index 7f3853dbbd..a34f98fccb 100644 --- a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/ui/StakingConfirmationContent.kt +++ b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/ui/StakingConfirmationContent.kt @@ -68,7 +68,7 @@ internal fun StakingConfirmationContent( ) ValidatorBlock( validatorState = validatorState, - isClickable = !isTransactionInProgress, + isClickable = !isTransactionSent && !isTransactionInProgress, onClick = clickIntents::openValidators, ) StakingFeeBlock(feeState = state.feeState) diff --git a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/ui/StakingSuccessContent.kt b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/ui/StakingSuccessContent.kt index d9297f768d..5ae45606f9 100644 --- a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/ui/StakingSuccessContent.kt +++ b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/ui/StakingSuccessContent.kt @@ -60,7 +60,7 @@ internal fun StakingSuccessContent( ) ValidatorBlock( validatorState = validatorState, - isClickable = !isTransactionInProgress, + isClickable = !isTransactionSent && !isTransactionInProgress, onClick = clickIntents::openValidators, ) StakingFeeBlock(feeState = state.feeState) From ffa50554bd6ce9cb7080c7c539cc0341b72d96d8 Mon Sep 17 00:00:00 2001 From: Tangem Date: Fri, 24 Apr 2026 12:01:13 +0400 Subject: [PATCH 07/13] Updated on 2026-08-14 --- .../component/extended/FeeExtendedSelectorComponent.kt | 3 ++- .../component/extended/model/FeeExtendedSelectorModel.kt | 8 ++++---- .../component/token/FeeTokenSelectorComponent.kt | 3 ++- .../component/token/model/FeeTokenSelectorModel.kt | 6 +++--- 4 files changed, 11 insertions(+), 9 deletions(-) diff --git a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/component/extended/FeeExtendedSelectorComponent.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/component/extended/FeeExtendedSelectorComponent.kt index 76f9baab92..42f403fe05 100644 --- a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/component/extended/FeeExtendedSelectorComponent.kt +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/component/extended/FeeExtendedSelectorComponent.kt @@ -24,10 +24,11 @@ internal class FeeExtendedSelectorComponent @AssistedInject constructor( @Composable override fun Content(modifier: Modifier) { val state by model.uiState.collectAsStateWithLifecycle() + val current = state ?: return FeeExtendedSelectorContent( modifier = modifier, - state = state, + state = current, ) } diff --git a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/component/extended/model/FeeExtendedSelectorModel.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/component/extended/model/FeeExtendedSelectorModel.kt index 0a68ace88e..6c56801a3b 100644 --- a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/component/extended/model/FeeExtendedSelectorModel.kt +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/component/extended/model/FeeExtendedSelectorModel.kt @@ -34,8 +34,8 @@ class FeeExtendedSelectorModel @Inject constructor( initAppCurrency() } - val uiState: StateFlow - field = MutableStateFlow(getInitialState()) + val uiState: StateFlow + field = MutableStateFlow(getInitialState()) init { params.state @@ -44,8 +44,8 @@ class FeeExtendedSelectorModel @Inject constructor( .launchIn(modelScope) } - private fun getInitialState(): FeeExtendedSelectorUM { - val parentContentState = params.state.value as FeeSelectorUM.Content + private fun getInitialState(): FeeExtendedSelectorUM? { + val parentContentState = params.state.value as? FeeSelectorUM.Content ?: return null return convertState(parentContentState) } diff --git a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/component/token/FeeTokenSelectorComponent.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/component/token/FeeTokenSelectorComponent.kt index 0fc8cce54a..410e436d41 100644 --- a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/component/token/FeeTokenSelectorComponent.kt +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/component/token/FeeTokenSelectorComponent.kt @@ -24,9 +24,10 @@ internal class FeeTokenSelectorComponent @AssistedInject constructor( @Composable override fun Content(modifier: Modifier) { val state by model.uiState.collectAsStateWithLifecycle() + val current = state ?: return FeeTokenSelectorContent( - state = state, + state = current, intents = model, modifier = modifier, ) diff --git a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/component/token/model/FeeTokenSelectorModel.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/component/token/model/FeeTokenSelectorModel.kt index eb7e57e5b0..2385981d76 100644 --- a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/component/token/model/FeeTokenSelectorModel.kt +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/component/token/model/FeeTokenSelectorModel.kt @@ -41,7 +41,7 @@ internal class FeeTokenSelectorModel @Inject constructor( private val params = paramsContainer.require() private var appCurrency = AppCurrency.Default - val uiState: StateFlow + val uiState: StateFlow field = MutableStateFlow(getInitialState()) init { @@ -59,8 +59,8 @@ internal class FeeTokenSelectorModel @Inject constructor( } } - private fun getInitialState(): FeeTokenSelectorUM { - val parentState = params.state.value as FeeSelectorUM.Content + private fun getInitialState(): FeeTokenSelectorUM? { + val parentState = params.state.value as? FeeSelectorUM.Content ?: return null return stateFromParent(parentState) } From ad17ee6d6d950821f3fbb890c2471358f130292b Mon Sep 17 00:00:00 2001 From: Tangem Date: Fri, 24 Apr 2026 20:29:29 +0400 Subject: [PATCH 08/13] Updated on 2026-08-14 --- .../SwapAmountSelectQuoteTransformer.kt | 14 +- .../SwapAmountSelectQuoteTransformerTest.kt | 185 ++++++++++++++++++ 2 files changed, 198 insertions(+), 1 deletion(-) create mode 100644 features/swap-v2/impl/src/test/java/com/tangem/features/swap/v2/impl/amount/model/transformers/SwapAmountSelectQuoteTransformerTest.kt diff --git a/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/amount/model/transformers/SwapAmountSelectQuoteTransformer.kt b/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/amount/model/transformers/SwapAmountSelectQuoteTransformer.kt index 19a5e22946..271054c6a7 100644 --- a/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/amount/model/transformers/SwapAmountSelectQuoteTransformer.kt +++ b/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/amount/model/transformers/SwapAmountSelectQuoteTransformer.kt @@ -61,8 +61,10 @@ internal class SwapAmountSelectQuoteTransformer( secondaryCryptoCurrencyStatus = prevState.secondaryCryptoCurrencyStatus, ) + val hasInsufficientFundsForFixed = hasInsufficientFundsForFixed(prevState, quoteContent) + return prevState.copy( - isPrimaryButtonEnabled = quoteUM is SwapQuoteUM.Content, + isPrimaryButtonEnabled = quoteUM is SwapQuoteUM.Content && !hasInsufficientFundsForFixed, selectedQuote = quoteUM, isShowFCAWarning = isNeedApplyFCARestrictions && quoteUM.provider?.isRestrictedByFCA() == true, primaryAmount = newPrimaryAmount, @@ -71,6 +73,16 @@ internal class SwapAmountSelectQuoteTransformer( ) } + private fun hasInsufficientFundsForFixed( + prevState: SwapAmountUM.Content, + quoteContent: SwapQuoteUM.Content?, + ): Boolean { + if (prevState.selectedAmountType != SwapAmountType.To) return false + val fromAmount = quoteContent?.fromAmount ?: return false + val primaryBalance = prevState.primaryCryptoCurrencyStatus.value.amount ?: return false + return fromAmount > primaryBalance + } + private fun getPrimaryAmount( prevState: SwapAmountUM.Content, quoteContent: SwapQuoteUM.Content?, diff --git a/features/swap-v2/impl/src/test/java/com/tangem/features/swap/v2/impl/amount/model/transformers/SwapAmountSelectQuoteTransformerTest.kt b/features/swap-v2/impl/src/test/java/com/tangem/features/swap/v2/impl/amount/model/transformers/SwapAmountSelectQuoteTransformerTest.kt new file mode 100644 index 0000000000..b1776a7f6c --- /dev/null +++ b/features/swap-v2/impl/src/test/java/com/tangem/features/swap/v2/impl/amount/model/transformers/SwapAmountSelectQuoteTransformerTest.kt @@ -0,0 +1,185 @@ +package com.tangem.features.swap.v2.impl.amount.model.transformers + +import com.google.common.truth.Truth.assertThat +import com.tangem.core.ui.extensions.TextReference +import com.tangem.domain.express.models.ExpressError +import com.tangem.domain.express.models.ExpressProvider +import com.tangem.domain.express.models.ExpressProviderType +import com.tangem.domain.express.models.ExpressRateType +import com.tangem.domain.models.currency.CryptoCurrencyStatus +import com.tangem.domain.swap.models.SwapAmountType +import com.tangem.domain.swap.models.SwapCurrencies +import com.tangem.domain.swap.models.SwapDirection +import com.tangem.domain.swap.models.SwapRateMode +import com.tangem.features.swap.v2.impl.amount.entity.SwapAmountFieldUM +import com.tangem.features.swap.v2.impl.amount.entity.SwapAmountUM +import com.tangem.features.swap.v2.impl.common.entity.SwapQuoteUM +import io.mockk.every +import io.mockk.mockk +import kotlinx.collections.immutable.persistentListOf +import org.junit.Test +import java.math.BigDecimal + +internal class SwapAmountSelectQuoteTransformerTest { + + private val provider = ExpressProvider( + providerId = "test-provider", + name = "Test Provider", + type = ExpressProviderType.CEX, + imageLarge = "", + termsOfUse = null, + privacyPolicy = null, + slippage = null, + ) + + @Test + fun `GIVEN fixed mode quote with fromAmount exceeding primary balance WHEN transform THEN isPrimaryButtonEnabled is false`() { + // GIVEN + val prevState = buildContentState( + selectedAmountType = SwapAmountType.To, + primaryBalance = BigDecimal("10"), + ) + val quote = buildContentQuote(fromAmount = BigDecimal("20"), toAmount = BigDecimal("1")) + val transformer = buildTransformer(quoteUM = quote) + + // WHEN + val result = transformer.transform(prevState) + + // THEN + val content = result as SwapAmountUM.Content + assertThat(content.isPrimaryButtonEnabled).isFalse() + assertThat(content.selectedQuote).isEqualTo(quote) + } + + @Test + fun `GIVEN fixed mode quote with fromAmount within primary balance WHEN transform THEN isPrimaryButtonEnabled is true`() { + // GIVEN + val prevState = buildContentState( + selectedAmountType = SwapAmountType.To, + primaryBalance = BigDecimal("100"), + ) + val quote = buildContentQuote(fromAmount = BigDecimal("20"), toAmount = BigDecimal("1")) + val transformer = buildTransformer(quoteUM = quote) + + // WHEN + val result = transformer.transform(prevState) + + // THEN + val content = result as SwapAmountUM.Content + assertThat(content.isPrimaryButtonEnabled).isTrue() + } + + @Test + fun `GIVEN float mode with selectedAmountType From WHEN transform THEN isPrimaryButtonEnabled is true regardless of fromAmount`() { + // GIVEN + val prevState = buildContentState( + selectedAmountType = SwapAmountType.From, + primaryBalance = BigDecimal("10"), + ) + // fromAmount > balance, but we're in From-mode so the check must not fire + val quote = buildContentQuote(fromAmount = BigDecimal("20"), toAmount = BigDecimal("1")) + val transformer = buildTransformer(quoteUM = quote) + + // WHEN + val result = transformer.transform(prevState) + + // THEN + val content = result as SwapAmountUM.Content + assertThat(content.isPrimaryButtonEnabled).isTrue() + } + + @Test + fun `GIVEN quote is SwapQuoteUM Error WHEN transform THEN isPrimaryButtonEnabled is false`() { + // GIVEN + val prevState = buildContentState( + selectedAmountType = SwapAmountType.To, + primaryBalance = BigDecimal("100"), + ) + val errorQuote = SwapQuoteUM.Error( + provider = provider, + expressError = ExpressError.InternalError(code = 500), + ) + val transformer = buildTransformer(quoteUM = errorQuote) + + // WHEN + val result = transformer.transform(prevState) + + // THEN + val content = result as SwapAmountUM.Content + assertThat(content.isPrimaryButtonEnabled).isFalse() + assertThat(content.selectedQuote).isEqualTo(errorQuote) + } + + @Test + fun `GIVEN prevState is SwapAmountUM Empty WHEN transform THEN returns the same state`() { + // GIVEN + val prevState = SwapAmountUM.Empty(swapDirection = SwapDirection.Direct) + val quote = buildContentQuote(fromAmount = BigDecimal("20"), toAmount = BigDecimal("1")) + val transformer = buildTransformer(quoteUM = quote) + + // WHEN + val result = transformer.transform(prevState) + + // THEN + assertThat(result).isEqualTo(prevState) + } + + private fun buildTransformer(quoteUM: SwapQuoteUM): SwapAmountSelectQuoteTransformer { + return SwapAmountSelectQuoteTransformer( + quoteUM = quoteUM, + secondaryMaximumAmountBoundary = null, + secondaryMinimumAmountBoundary = null, + isNeedApplyFCARestrictions = false, + isBalanceHidden = false, + primaryMaximumAmountBoundary = null, + primaryMinimumAmountBoundary = null, + primaryFiatRateUSD = null, + secondaryFiatRateUSD = null, + ) + } + + private fun buildContentQuote(fromAmount: BigDecimal, toAmount: BigDecimal): SwapQuoteUM.Content { + return SwapQuoteUM.Content( + provider = provider, + toAmount = toAmount, + fromAmount = fromAmount, + toAmountValue = TextReference.EMPTY, + fromAmountValue = TextReference.EMPTY, + diffPercent = SwapQuoteUM.Content.DifferencePercent.Empty, + isSingleProvider = true, + rate = TextReference.EMPTY, + quoteId = null, + ) + } + + private fun buildContentState( + selectedAmountType: SwapAmountType, + primaryBalance: BigDecimal?, + ): SwapAmountUM.Content { + val primaryStatus = mockk(relaxed = true).also { status -> + every { status.value.amount } returns primaryBalance + every { status.value.fiatRate } returns null + every { status.value.fiatAmount } returns null + every { status.currency.symbol } returns "BTC" + every { status.currency.decimals } returns 8 + } + return SwapAmountUM.Content( + isPrimaryButtonEnabled = false, + swapDirection = SwapDirection.Direct, + selectedAmountType = selectedAmountType, + primaryAmount = SwapAmountFieldUM.Empty(SwapAmountType.From), + secondaryAmount = SwapAmountFieldUM.Empty(SwapAmountType.To), + primaryCryptoCurrencyStatus = primaryStatus, + secondaryCryptoCurrencyStatus = null, + swapRateType = ExpressRateType.Fixed, + swapRateMode = SwapRateMode.FIXED_ONLY, + priceImpact = null, + swapCurrencies = SwapCurrencies.EMPTY, + swapQuotes = persistentListOf(), + selectedQuote = SwapQuoteUM.Empty, + isShowFCAWarning = false, + appCurrency = null, + isShowBestRateAnimation = false, + ) + } +} \ No newline at end of file From d14835df833714ef59408778505096aba26734c1 Mon Sep 17 00:00:00 2001 From: Tangem Date: Mon, 27 Apr 2026 11:37:25 +0400 Subject: [PATCH 09/13] Updated on 2026-08-14 --- features/approval/impl/build.gradle.kts | 12 ++ .../approval/impl/model/GiveApprovalModel.kt | 9 +- .../impl/model/GiveApprovalModelTest.kt | 114 ++++++++++++++++++ 3 files changed, 134 insertions(+), 1 deletion(-) create mode 100644 features/approval/impl/src/test/kotlin/com/tangem/features/approval/impl/model/GiveApprovalModelTest.kt diff --git a/features/approval/impl/build.gradle.kts b/features/approval/impl/build.gradle.kts index 2e43f87aee..1a43aaf4a5 100644 --- a/features/approval/impl/build.gradle.kts +++ b/features/approval/impl/build.gradle.kts @@ -10,6 +10,10 @@ android { namespace = "com.tangem.features.approval.impl" } +tasks.withType().configureEach { + useJUnitPlatform() +} + dependencies { /** Feature */ @@ -56,4 +60,12 @@ dependencies { /** DI */ implementation(deps.hilt.android) kapt(deps.hilt.kapt) + + // region Tests + testImplementation(deps.test.coroutine) + testImplementation(deps.test.junit5) + testRuntimeOnly(deps.test.junit5.engine) + testImplementation(deps.test.mockk) + testImplementation(deps.test.truth) + // endregion } \ No newline at end of file diff --git a/features/approval/impl/src/main/java/com/tangem/features/approval/impl/model/GiveApprovalModel.kt b/features/approval/impl/src/main/java/com/tangem/features/approval/impl/model/GiveApprovalModel.kt index 727c03292f..2161f8c8a1 100644 --- a/features/approval/impl/src/main/java/com/tangem/features/approval/impl/model/GiveApprovalModel.kt +++ b/features/approval/impl/src/main/java/com/tangem/features/approval/impl/model/GiveApprovalModel.kt @@ -37,6 +37,7 @@ import com.tangem.features.approval.api.GiveApprovalComponent import com.tangem.features.send.v2.api.callbacks.FeeSelectorModelCallback import com.tangem.features.send.v2.api.entity.FeeItem import com.tangem.features.send.v2.api.entity.FeeSelectorUM +import com.tangem.features.send.v2.api.subcomponents.feeSelector.FeeSelectorReloadTrigger import com.tangem.utils.TangemBlogUrlBuilder.RESOURCE_TO_LEARN_ABOUT_APPROVING_IN_SWAP import com.tangem.utils.coroutines.CoroutineDispatcherProvider import com.tangem.utils.logging.TangemLogger @@ -65,6 +66,7 @@ internal class GiveApprovalModel @Inject constructor( private val urlOpener: UrlOpener, private val getUserWalletUseCase: GetUserWalletUseCase, private val analyticsEventHandler: AnalyticsEventHandler, + private val feeSelectorReloadTrigger: FeeSelectorReloadTrigger, ) : Model(), FeeSelectorModelCallback { private val params: GiveApprovalComponent.Params = paramsContainer.require() @@ -115,7 +117,12 @@ internal class GiveApprovalModel @Inject constructor( } fun onChangeApproveType(approveType: ApproveType) { - uiState.update { it.copy(approveType = approveType) } + if (uiState.value.approveType == approveType) return + uiState.update { it.copy(approveType = approveType, isApproveButtonEnabled = false) } + modelScope.launch { + feeSelectorReloadTrigger.triggerLoadingState() + feeSelectorReloadTrigger.triggerUpdate() + } } fun onOpenLearnMoreAboutApproveClick() { diff --git a/features/approval/impl/src/test/kotlin/com/tangem/features/approval/impl/model/GiveApprovalModelTest.kt b/features/approval/impl/src/test/kotlin/com/tangem/features/approval/impl/model/GiveApprovalModelTest.kt new file mode 100644 index 0000000000..fc097acb8c --- /dev/null +++ b/features/approval/impl/src/test/kotlin/com/tangem/features/approval/impl/model/GiveApprovalModelTest.kt @@ -0,0 +1,114 @@ +package com.tangem.features.approval.impl.model + +import arrow.core.right +import com.google.common.truth.Truth.assertThat +import com.tangem.common.ui.bottomsheet.permission.state.ApproveType +import com.tangem.core.analytics.api.AnalyticsEventHandler +import com.tangem.core.decompose.model.MutableParamsContainer +import com.tangem.core.decompose.ui.UiMessageSender +import com.tangem.core.navigation.url.UrlOpener +import com.tangem.core.ui.extensions.TextReference +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.models.wallet.UserWalletId +import com.tangem.domain.transaction.usecase.CreateApprovalTransactionUseCase +import com.tangem.domain.transaction.usecase.GetAllowanceInfoUseCase +import com.tangem.domain.transaction.usecase.GetFeeUseCase +import com.tangem.domain.transaction.usecase.SendTransactionUseCase +import com.tangem.domain.transaction.usecase.gasless.CreateAndSendGaslessTransactionUseCase +import com.tangem.domain.transaction.usecase.gasless.GetFeeForGaslessUseCase +import com.tangem.domain.transaction.usecase.gasless.GetFeeForTokenUseCase +import com.tangem.domain.wallets.usecase.GetUserWalletUseCase +import com.tangem.features.approval.api.GiveApprovalComponent +import com.tangem.features.send.v2.api.subcomponents.feeSelector.FeeSelectorReloadTrigger +import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider +import io.mockk.coVerify +import io.mockk.every +import io.mockk.mockk +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test + +@OptIn(ExperimentalCoroutinesApi::class) +class GiveApprovalModelTest { + + private val createApprovalTransactionUseCase: CreateApprovalTransactionUseCase = mockk(relaxed = true) + private val getAllowanceInfoUseCase: GetAllowanceInfoUseCase = mockk(relaxed = true) + private val sendTransactionUseCase: SendTransactionUseCase = mockk(relaxed = true) + private val getFeeUseCase: GetFeeUseCase = mockk(relaxed = true) + private val getFeeForGaslessUseCase: GetFeeForGaslessUseCase = mockk(relaxed = true) + private val getFeeForTokenUseCase: GetFeeForTokenUseCase = mockk(relaxed = true) + private val createAndSendGaslessTransactionUseCase: CreateAndSendGaslessTransactionUseCase = mockk(relaxed = true) + private val uiMessageSender: UiMessageSender = mockk(relaxed = true) + private val urlOpener: UrlOpener = mockk(relaxed = true) + private val analyticsEventHandler: AnalyticsEventHandler = mockk(relaxed = true) + private val feeSelectorReloadTrigger: FeeSelectorReloadTrigger = mockk(relaxed = true) + + private val userWalletId = UserWalletId(stringValue = "0123456789ABCDEF") + private val userWallet: UserWallet.Hot = mockk(relaxed = true) + + private val cryptoCurrencyStatus: CryptoCurrencyStatus = mockk { + every { currency } returns mockk(relaxed = true) + } + + private val params = GiveApprovalComponent.Params( + userWalletId = userWalletId, + cryptoCurrencyStatus = cryptoCurrencyStatus, + feeCryptoCurrencyStatus = cryptoCurrencyStatus, + amount = "10", + spenderAddress = "0xSpender", + amountFooter = TextReference.EMPTY, + feeFooter = TextReference.EMPTY, + callback = mockk(relaxed = true), + ) + + private val getUserWalletUseCase: GetUserWalletUseCase = mockk(relaxed = true) + + @BeforeEach + fun setUp() { + every { getUserWalletUseCase.invoke(userWalletId) } returns userWallet.right() + } + + private fun createModel(): GiveApprovalModel = GiveApprovalModel( + dispatchers = TestingCoroutineDispatcherProvider(), + paramsContainer = MutableParamsContainer(params), + createApprovalTransactionUseCase = createApprovalTransactionUseCase, + getAllowanceInfoUseCase = getAllowanceInfoUseCase, + sendTransactionUseCase = sendTransactionUseCase, + getFeeUseCase = getFeeUseCase, + getFeeForGaslessUseCase = getFeeForGaslessUseCase, + getFeeForTokenUseCase = getFeeForTokenUseCase, + createAndSendGaslessTransactionUseCase = createAndSendGaslessTransactionUseCase, + uiMessageSender = uiMessageSender, + urlOpener = urlOpener, + getUserWalletUseCase = getUserWalletUseCase, + analyticsEventHandler = analyticsEventHandler, + feeSelectorReloadTrigger = feeSelectorReloadTrigger, + ) + + @Test + fun `GIVEN approveType LIMITED WHEN onChangeApproveType THEN triggers fee reload and updates state`() = runTest { + val model = createModel() + + model.onChangeApproveType(ApproveType.UNLIMITED) + + val state = model.uiState.value + assertThat(state.approveType).isEqualTo(ApproveType.UNLIMITED) + assertThat(state.isApproveButtonEnabled).isFalse() + coVerify(exactly = 1) { feeSelectorReloadTrigger.triggerLoadingState() } + coVerify(exactly = 1) { feeSelectorReloadTrigger.triggerUpdate() } + } + + @Test + fun `GIVEN same approveType WHEN onChangeApproveType THEN does not trigger fee reload`() = runTest { + val model = createModel() + + model.onChangeApproveType(ApproveType.LIMITED) + + assertThat(model.uiState.value.approveType).isEqualTo(ApproveType.LIMITED) + coVerify(exactly = 0) { feeSelectorReloadTrigger.triggerLoadingState() } + coVerify(exactly = 0) { feeSelectorReloadTrigger.triggerUpdate() } + } +} \ No newline at end of file From 44fc5b9610f2a25114ef09ed145368296adc4219 Mon Sep 17 00:00:00 2001 From: Tangem Date: Mon, 27 Apr 2026 12:40:31 +0400 Subject: [PATCH 10/13] Updated on 2026-08-14 --- .../analytics/SwapAmountAnalyticsSender.kt | 67 +++++--- .../v2/impl/amount/model/SwapAmountModel.kt | 39 +++-- .../model/SwapNotificationsModel.kt | 43 +++--- .../analytics/SendWithSwapAnalyticEvents.kt | 55 +++++-- .../SendWithSwapAnalyticsErrorMessages.kt | 11 -- .../confirm/model/SendWithSwapConfirmModel.kt | 23 ++- .../SwapAmountAnalyticsSenderTest.kt | 144 ++++++++++++++---- 7 files changed, 260 insertions(+), 122 deletions(-) delete mode 100644 features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/sendviaswap/analytics/SendWithSwapAnalyticsErrorMessages.kt diff --git a/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/amount/analytics/SwapAmountAnalyticsSender.kt b/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/amount/analytics/SwapAmountAnalyticsSender.kt index d6b20adde2..a349372423 100644 --- a/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/amount/analytics/SwapAmountAnalyticsSender.kt +++ b/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/amount/analytics/SwapAmountAnalyticsSender.kt @@ -1,38 +1,61 @@ package com.tangem.features.swap.v2.impl.amount.analytics import com.tangem.core.analytics.api.AnalyticsEventHandler +import com.tangem.core.analytics.models.AnalyticsEvent import com.tangem.domain.express.models.ExpressError +import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.features.swap.v2.impl.common.entity.SwapQuoteUM import com.tangem.features.swap.v2.impl.sendviaswap.analytics.SendWithSwapAnalyticEvents -import com.tangem.features.swap.v2.impl.sendviaswap.analytics.SendWithSwapAnalyticsErrorMessages internal class SwapAmountAnalyticsSender( private val analyticsEventHandler: AnalyticsEventHandler, ) { - private var lastSentErrorMessage: String? = null + private var lastSentEvent: AnalyticsEvent? = null - fun sendErrorIfNeeded(quotes: List, selectedQuote: SwapQuoteUM?) { - val errorMessage = resolveErrorMessage(quotes, selectedQuote) - if (errorMessage == lastSentErrorMessage) return - lastSentErrorMessage = errorMessage - if (errorMessage != null) { - analyticsEventHandler.send( - SendWithSwapAnalyticEvents.SendWithSwapError( - errorScreen = SendWithSwapAnalyticEvents.ErrorScreen.Amount, - message = errorMessage, - ), + fun sendErrorIfNeeded( + quotes: List, + selectedQuote: SwapQuoteUM?, + fromToken: CryptoCurrency, + toToken: CryptoCurrency, + hasInsufficientBalance: Boolean, + ) { + val event = resolveEvent( + quotes = quotes, + selectedQuote = selectedQuote, + fromToken = fromToken, + toToken = toToken, + hasInsufficientBalance = hasInsufficientBalance, + ) + if (event?.event == lastSentEvent?.event && event?.params == lastSentEvent?.params) return + lastSentEvent = event + if (event != null) { + analyticsEventHandler.send(event) + } + } + + private fun resolveEvent( + quotes: List, + selectedQuote: SwapQuoteUM?, + fromToken: CryptoCurrency, + toToken: CryptoCurrency, + hasInsufficientBalance: Boolean, + ): AnalyticsEvent? { + if (hasInsufficientBalance) { + return SendWithSwapAnalyticEvents.ErrorInsufficientBalance(fromToken = fromToken) + } + if (quotes.isEmpty()) return null + val error = (selectedQuote as? SwapQuoteUM.Error)?.expressError ?: return null + return when (error) { + is ExpressError.AmountError.TooSmallError -> + SendWithSwapAnalyticEvents.ErrorMinAmount(fromToken = fromToken) + is ExpressError.AmountError.TooBigError -> + SendWithSwapAnalyticEvents.ErrorMaxAmount(fromToken = fromToken) + else -> SendWithSwapAnalyticEvents.ErrorExpressQuote( + fromToken = fromToken, + toToken = toToken, + errorDescription = "code=${error.code}", ) } } - - private fun resolveErrorMessage(quotes: List, selectedQuote: SwapQuoteUM?): String? { - if (quotes.isEmpty()) return SendWithSwapAnalyticsErrorMessages.EXPRESS_QUOTE_NO_PROVIDERS - val error = (selectedQuote as? SwapQuoteUM.Error)?.expressError ?: return null - return when (error) { - is ExpressError.AmountError.TooSmallError -> SendWithSwapAnalyticsErrorMessages.MIN_AMOUNT - is ExpressError.AmountError.TooBigError -> SendWithSwapAnalyticsErrorMessages.MAX_AMOUNT - else -> "${SendWithSwapAnalyticsErrorMessages.EXPRESS_QUOTE}: code=${error.code}" - } - } } \ No newline at end of file diff --git a/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/amount/model/SwapAmountModel.kt b/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/amount/model/SwapAmountModel.kt index b033d4f8cf..3aedd8f612 100644 --- a/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/amount/model/SwapAmountModel.kt +++ b/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/amount/model/SwapAmountModel.kt @@ -57,7 +57,6 @@ import com.tangem.features.swap.v2.impl.common.entity.SwapQuoteUM import com.tangem.features.swap.v2.impl.sendviaswap.SendWithSwapRoute import com.tangem.features.swap.v2.impl.sendviaswap.analytics.SendWithSwapAnalyticEvents import com.tangem.features.swap.v2.impl.sendviaswap.analytics.SendWithSwapAnalyticEvents.NoticeFixedRate.toAnalyticsRateType -import com.tangem.features.swap.v2.impl.sendviaswap.analytics.SendWithSwapAnalyticsErrorMessages import com.tangem.utils.coroutines.CoroutineDispatcherProvider import com.tangem.utils.coroutines.Debouncer import com.tangem.utils.coroutines.PeriodicTask @@ -616,18 +615,35 @@ internal class SwapAmountModel @Inject constructor( | Secondary -> $secondaryStatus """.trimIndent(), ) - analyticsEventHandler.send( - SendWithSwapAnalyticEvents.SendWithSwapError( - errorScreen = SendWithSwapAnalyticEvents.ErrorScreen.Amount, - message = "${SendWithSwapAnalyticsErrorMessages.INVALID_CRYPTOCURRENCIES_STATUS}: " + - "primary=$primaryStatus, secondary=$secondaryStatus", - ), - ) showErrorAlert(errorMessage = null) } } } + private fun sendAmountErrorAnalyticsIfNeeded(quotes: List) { + val content = uiState.value as? SwapAmountUM.Content ?: return + val toCurrency = content.secondaryCryptoCurrencyStatus?.currency ?: return + amountAnalyticsSender.sendErrorIfNeeded( + quotes = quotes, + selectedQuote = content.selectedQuote, + fromToken = content.primaryCryptoCurrencyStatus.currency, + toToken = toCurrency, + hasInsufficientBalance = hasInsufficientBalance(content), + ) + } + + private fun hasInsufficientBalance(content: SwapAmountUM.Content): Boolean { + val primaryBalance = content.primaryCryptoCurrencyStatus.value.amount ?: return false + val fromAmount = when (content.selectedAmountType) { + SwapAmountType.To -> (content.selectedQuote as? SwapQuoteUM.Content)?.fromAmount + SwapAmountType.From -> { + val field = content.primaryAmount as? SwapAmountFieldUM.Content + (field?.amountField as? AmountState.Data)?.amountTextField?.cryptoAmount?.value + } + } ?: return false + return fromAmount > primaryBalance + } + private fun sendAmountScreenOpenedIfNeeded(secondaryStatus: CryptoCurrencyStatus) { if (params !is SwapAmountComponentParams.AmountParams) return if (isAmountScreenOpenedSent) return @@ -711,7 +727,9 @@ internal class SwapAmountModel @Inject constructor( val isAmountScreen = params is SwapAmountComponentParams.AmountParams val isAmountError = amountField?.amountTextField?.isError == true || amountValue.isNullOrZero() if (isAmountScreen && isAmountError) { - uiState.transformerUpdate(SwapQuoteEmptyStateTransformer); return + uiState.transformerUpdate(SwapQuoteEmptyStateTransformer) + sendAmountErrorAnalyticsIfNeeded(quotes = emptyList()) + return } val rateType = when (state.selectedAmountType) { @@ -783,8 +801,7 @@ internal class SwapAmountModel @Inject constructor( ), ) if (params is SwapAmountComponentParams.AmountParams) { - val selectedQuote = (uiState.value as? SwapAmountUM.Content)?.selectedQuote - amountAnalyticsSender.sendErrorIfNeeded(quotes, selectedQuote) + sendAmountErrorAnalyticsIfNeeded(quotes) } feeSelectorReloadTrigger.triggerUpdate() } diff --git a/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/notifications/model/SwapNotificationsModel.kt b/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/notifications/model/SwapNotificationsModel.kt index ca86d23dee..d82b8ace85 100644 --- a/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/notifications/model/SwapNotificationsModel.kt +++ b/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/notifications/model/SwapNotificationsModel.kt @@ -18,7 +18,6 @@ import com.tangem.features.swap.v2.impl.notifications.SwapNotificationsComponent import com.tangem.features.swap.v2.impl.notifications.SwapNotificationsUpdateListener import com.tangem.features.swap.v2.impl.notifications.entity.SwapNotificationUM import com.tangem.features.swap.v2.impl.sendviaswap.analytics.SendWithSwapAnalyticEvents -import com.tangem.features.swap.v2.impl.sendviaswap.analytics.SendWithSwapAnalyticsErrorMessages import com.tangem.utils.coroutines.CoroutineDispatcherProvider import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf @@ -46,7 +45,7 @@ internal class SwapNotificationsModel @Inject constructor( private val params: SwapNotificationsComponent.Params = paramsContainer.require() private var notificationData = params.swapNotificationData - private var lastSentErrorMessages: Set = emptySet() + private var lastSentErrorKeys: Set>> = emptySet() val uiState: StateFlow> field = MutableStateFlow>(persistentListOf()) @@ -203,32 +202,32 @@ internal class SwapNotificationsModel @Inject constructor( } private fun sendErrorAnalyticsIfNeeded(notifications: List) { - val currentErrors = notifications.mapNotNull { notification -> + val fromToken = notificationData.fromCryptoCurrency ?: return + val toToken = notificationData.toCryptoCurrencyStatus?.currency + + val events = notifications.mapNotNull { notification -> when (notification) { is SwapNotificationUM.Error.InsufficientFunds -> - SendWithSwapAnalyticsErrorMessages.INSUFFICIENT_BALANCE + SendWithSwapAnalyticEvents.ErrorInsufficientBalance(fromToken = fromToken) is SwapNotificationUM.Error.MinimalAmountError -> - SendWithSwapAnalyticsErrorMessages.MIN_AMOUNT + SendWithSwapAnalyticEvents.ErrorMinAmount(fromToken = fromToken) is SwapNotificationUM.Error.MaximumAmountError -> - SendWithSwapAnalyticsErrorMessages.MAX_AMOUNT - is SwapNotificationUM.Warning.ExpressGeneralError -> - "${SendWithSwapAnalyticsErrorMessages.EXPRESS_QUOTE}: code=${notification.expressError.code}" - is NotificationUM.Error.DestinationTagRequired -> - SendWithSwapAnalyticsErrorMessages.DESTINATION_TAG_REQUIRED + SendWithSwapAnalyticEvents.ErrorMaxAmount(fromToken = fromToken) + is SwapNotificationUM.Warning.ExpressGeneralError -> toToken?.let { receiveToken -> + SendWithSwapAnalyticEvents.ErrorExpressQuote( + fromToken = fromToken, + toToken = receiveToken, + errorDescription = "code=${notification.expressError.code}", + ) + } else -> null } - }.toSet() - - val newErrors = currentErrors - lastSentErrorMessages - lastSentErrorMessages = currentErrors - - newErrors.forEach { errorMessage -> - analyticsEventHandler.send( - SendWithSwapAnalyticEvents.SendWithSwapError( - errorScreen = SendWithSwapAnalyticEvents.ErrorScreen.Confirm, - message = errorMessage, - ), - ) } + + val currentKeys = events.map { it.event to it.params }.toSet() + val newEvents = events.filter { it.event to it.params !in lastSentErrorKeys } + lastSentErrorKeys = currentKeys + + newEvents.forEach(analyticsEventHandler::send) } } \ No newline at end of file diff --git a/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/sendviaswap/analytics/SendWithSwapAnalyticEvents.kt b/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/sendviaswap/analytics/SendWithSwapAnalyticEvents.kt index f753ef6f26..5a68284603 100644 --- a/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/sendviaswap/analytics/SendWithSwapAnalyticEvents.kt +++ b/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/sendviaswap/analytics/SendWithSwapAnalyticEvents.kt @@ -4,7 +4,7 @@ import com.tangem.core.analytics.models.AnalyticsEvent import com.tangem.core.analytics.models.AnalyticsParam import com.tangem.core.analytics.models.AnalyticsParam.Key.ACCOUNT_DERIVATION_FROM import com.tangem.core.analytics.models.AnalyticsParam.Key.ACCOUNT_DERIVATION_TO -import com.tangem.core.analytics.models.AnalyticsParam.Key.ERROR_MESSAGE +import com.tangem.core.analytics.models.AnalyticsParam.Key.ERROR_DESCRIPTION import com.tangem.core.analytics.models.AnalyticsParam.Key.FEE_TYPE import com.tangem.core.analytics.models.AnalyticsParam.Key.PROVIDER import com.tangem.core.analytics.models.AnalyticsParam.Key.RATE_TYPE @@ -120,19 +120,51 @@ internal sealed class SendWithSwapAnalyticEvents( params = emptyMap(), ) - data class SendWithSwapError( - val errorScreen: ErrorScreen, - val message: String, + data class ErrorInsufficientBalance( + val fromToken: CryptoCurrency, ) : SendWithSwapAnalyticEvents( - event = when (errorScreen) { - ErrorScreen.Amount -> "Send With Swap Amount Screen Error" - ErrorScreen.Confirm -> "Send With Swap Confirm Screen Error" - }, + event = "Error - Insufficient balance", params = mapOf( - ERROR_MESSAGE to message, + SEND_TOKEN to fromToken.symbol, + SEND_BLOCKCHAIN to fromToken.network.name, ), ) + data class ErrorMinAmount( + val fromToken: CryptoCurrency, + ) : SendWithSwapAnalyticEvents( + event = "Error - Min amount", + params = mapOf( + SEND_TOKEN to fromToken.symbol, + SEND_BLOCKCHAIN to fromToken.network.name, + ), + ) + + data class ErrorMaxAmount( + val fromToken: CryptoCurrency, + ) : SendWithSwapAnalyticEvents( + event = "Error - Max amount", + params = mapOf( + SEND_TOKEN to fromToken.symbol, + SEND_BLOCKCHAIN to fromToken.network.name, + ), + ) + + data class ErrorExpressQuote( + val fromToken: CryptoCurrency, + val toToken: CryptoCurrency, + val errorDescription: String? = null, + ) : SendWithSwapAnalyticEvents( + event = "Error - Express quote", + params = buildMap { + put(SEND_TOKEN, fromToken.symbol) + put(SEND_BLOCKCHAIN, fromToken.network.name) + put(RECEIVE_TOKEN, toToken.symbol) + put(RECEIVE_BLOCKCHAIN, toToken.network.name) + if (errorDescription != null) put(ERROR_DESCRIPTION, errorDescription) + }, + ) + class HighPriceImpact( val sendToken: String, val receiveToken: String, @@ -168,11 +200,6 @@ internal sealed class SendWithSwapAnalyticEvents( ), ) - enum class ErrorScreen { - Amount, - Confirm, - } - enum class RateType { Float, Fixed, diff --git a/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/sendviaswap/analytics/SendWithSwapAnalyticsErrorMessages.kt b/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/sendviaswap/analytics/SendWithSwapAnalyticsErrorMessages.kt deleted file mode 100644 index 610a9c1159..0000000000 --- a/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/sendviaswap/analytics/SendWithSwapAnalyticsErrorMessages.kt +++ /dev/null @@ -1,11 +0,0 @@ -package com.tangem.features.swap.v2.impl.sendviaswap.analytics - -internal object SendWithSwapAnalyticsErrorMessages { - const val INSUFFICIENT_BALANCE = "Error - Insufficient balance" - const val MIN_AMOUNT = "Error - Min amount" - const val MAX_AMOUNT = "Error - Max amount" - const val EXPRESS_QUOTE_NO_PROVIDERS = "Error - Express quote no providers found" - const val EXPRESS_QUOTE = "Error - Express quote" - const val DESTINATION_TAG_REQUIRED = "Error - Destination tag required" - const val INVALID_CRYPTOCURRENCIES_STATUS = "Error - Invalid cryptocurrencies status" -} \ No newline at end of file diff --git a/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/sendviaswap/confirm/model/SendWithSwapConfirmModel.kt b/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/sendviaswap/confirm/model/SendWithSwapConfirmModel.kt index 809ef27a7f..082d7d8ffa 100644 --- a/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/sendviaswap/confirm/model/SendWithSwapConfirmModel.kt +++ b/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/sendviaswap/confirm/model/SendWithSwapConfirmModel.kt @@ -322,12 +322,17 @@ internal class SendWithSwapConfirmModel @Inject constructor( isAmountSubtractAvailable = isAmountSubtractAvailable, onExpressError = { expressError -> uiState.transformerUpdate(SendWithSwapConfirmSendingStateTransformer(false)) - analyticsEventHandler.send( - SendWithSwapAnalyticEvents.SendWithSwapError( - errorScreen = SendWithSwapAnalyticEvents.ErrorScreen.Confirm, - message = "Express error: $expressError", - ), - ) + val fromCurrency = confirmData.fromCryptoCurrencyStatus?.currency + val toCurrency = confirmData.toCryptoCurrencyStatus?.currency + if (fromCurrency != null && toCurrency != null) { + analyticsEventHandler.send( + SendWithSwapAnalyticEvents.ErrorExpressQuote( + fromToken = fromCurrency, + toToken = toCurrency, + errorDescription = "code=${expressError.code}", + ), + ) + } swapAlertFactory.getGenericErrorState( expressError = expressError, onFailedTxEmailClick = { @@ -345,12 +350,6 @@ internal class SendWithSwapConfirmModel @Inject constructor( }, onSendError = { error -> uiState.transformerUpdate(SendWithSwapConfirmSendingStateTransformer(false)) - analyticsEventHandler.send( - SendWithSwapAnalyticEvents.SendWithSwapError( - errorScreen = SendWithSwapAnalyticEvents.ErrorScreen.Confirm, - message = "Send error: ${error?.toString().orEmpty()}", - ), - ) swapAlertFactory.getSendTransactionErrorState( error = error, onFailedTxEmailClick = { _ -> diff --git a/features/swap-v2/impl/src/test/java/com/tangem/features/swap/v2/impl/amount/analytics/SwapAmountAnalyticsSenderTest.kt b/features/swap-v2/impl/src/test/java/com/tangem/features/swap/v2/impl/amount/analytics/SwapAmountAnalyticsSenderTest.kt index 26f07c6275..3f81cf380b 100644 --- a/features/swap-v2/impl/src/test/java/com/tangem/features/swap/v2/impl/amount/analytics/SwapAmountAnalyticsSenderTest.kt +++ b/features/swap-v2/impl/src/test/java/com/tangem/features/swap/v2/impl/amount/analytics/SwapAmountAnalyticsSenderTest.kt @@ -6,9 +6,10 @@ import com.tangem.core.analytics.models.AnalyticsEvent import com.tangem.domain.express.models.ExpressError import com.tangem.domain.express.models.ExpressProvider import com.tangem.domain.express.models.ExpressProviderType +import com.tangem.domain.models.currency.CryptoCurrency +import com.tangem.domain.models.network.Network import com.tangem.features.swap.v2.impl.common.entity.SwapQuoteUM import com.tangem.features.swap.v2.impl.sendviaswap.analytics.SendWithSwapAnalyticEvents -import com.tangem.features.swap.v2.impl.sendviaswap.analytics.SendWithSwapAnalyticsErrorMessages import io.mockk.every import io.mockk.mockk import io.mockk.slot @@ -21,6 +22,21 @@ class SwapAmountAnalyticsSenderTest { private val analyticsEventHandler = mockk(relaxed = true) private val sender = SwapAmountAnalyticsSender(analyticsEventHandler) + private val fromNetwork = mockk(relaxed = true).also { + every { it.name } returns "Ethereum" + } + private val toNetwork = mockk(relaxed = true).also { + every { it.name } returns "Bitcoin" + } + private val fromToken = mockk(relaxed = true).also { + every { it.symbol } returns "ETH" + every { it.network } returns fromNetwork + } + private val toToken = mockk(relaxed = true).also { + every { it.symbol } returns "BTC" + every { it.network } returns toNetwork + } + private val testProvider = ExpressProvider( providerId = "test", name = "Test Provider", @@ -31,21 +47,41 @@ class SwapAmountAnalyticsSenderTest { slippage = null, ) + private fun send( + quotes: List = emptyList(), + selectedQuote: SwapQuoteUM? = null, + hasInsufficientBalance: Boolean = false, + ) = sender.sendErrorIfNeeded( + quotes = quotes, + selectedQuote = selectedQuote, + fromToken = fromToken, + toToken = toToken, + hasInsufficientBalance = hasInsufficientBalance, + ) + @Test - fun `GIVEN empty quotes WHEN sendErrorIfNeeded THEN send no providers error`() { - val eventSlot = slot() - every { analyticsEventHandler.send(capture(eventSlot)) } returns Unit + fun `GIVEN empty quotes WHEN sendErrorIfNeeded THEN do not send analytics`() { + send(quotes = emptyList(), selectedQuote = null) - sender.sendErrorIfNeeded(quotes = emptyList(), selectedQuote = null) - - verify(exactly = 1) { analyticsEventHandler.send(any()) } - val event = eventSlot.captured as SendWithSwapAnalyticEvents.SendWithSwapError - assertThat(event.errorScreen).isEqualTo(SendWithSwapAnalyticEvents.ErrorScreen.Amount) - assertThat(event.message).isEqualTo(SendWithSwapAnalyticsErrorMessages.EXPRESS_QUOTE_NO_PROVIDERS) + verify(exactly = 0) { analyticsEventHandler.send(any()) } } @Test - fun `GIVEN too small error quote WHEN sendErrorIfNeeded THEN send min amount error`() { + fun `GIVEN insufficient balance WHEN sendErrorIfNeeded THEN send ErrorInsufficientBalance with from token params`() { + val eventSlot = slot() + every { analyticsEventHandler.send(capture(eventSlot)) } returns Unit + + send(hasInsufficientBalance = true) + + verify(exactly = 1) { analyticsEventHandler.send(any()) } + val event = eventSlot.captured + assertThat(event).isInstanceOf(SendWithSwapAnalyticEvents.ErrorInsufficientBalance::class.java) + assertThat(event.event).isEqualTo("Error - Insufficient balance") + assertThat(event.params).containsExactly("Send Token", "ETH", "Send Blockchain", "Ethereum") + } + + @Test + fun `GIVEN insufficient balance and express error WHEN sendErrorIfNeeded THEN insufficient balance takes priority`() { val errorQuote = SwapQuoteUM.Error( provider = testProvider, expressError = ExpressError.AmountError.TooSmallError(code = 1001, amount = BigDecimal("0.01")), @@ -53,15 +89,32 @@ class SwapAmountAnalyticsSenderTest { val eventSlot = slot() every { analyticsEventHandler.send(capture(eventSlot)) } returns Unit - sender.sendErrorIfNeeded(quotes = listOf(errorQuote), selectedQuote = errorQuote) + send(quotes = listOf(errorQuote), selectedQuote = errorQuote, hasInsufficientBalance = true) verify(exactly = 1) { analyticsEventHandler.send(any()) } - val event = eventSlot.captured as SendWithSwapAnalyticEvents.SendWithSwapError - assertThat(event.message).isEqualTo(SendWithSwapAnalyticsErrorMessages.MIN_AMOUNT) + assertThat(eventSlot.captured).isInstanceOf(SendWithSwapAnalyticEvents.ErrorInsufficientBalance::class.java) } @Test - fun `GIVEN too big error quote WHEN sendErrorIfNeeded THEN send max amount error`() { + fun `GIVEN too small error quote WHEN sendErrorIfNeeded THEN send ErrorMinAmount with from token params`() { + val errorQuote = SwapQuoteUM.Error( + provider = testProvider, + expressError = ExpressError.AmountError.TooSmallError(code = 1001, amount = BigDecimal("0.01")), + ) + val eventSlot = slot() + every { analyticsEventHandler.send(capture(eventSlot)) } returns Unit + + send(quotes = listOf(errorQuote), selectedQuote = errorQuote) + + verify(exactly = 1) { analyticsEventHandler.send(any()) } + val event = eventSlot.captured + assertThat(event).isInstanceOf(SendWithSwapAnalyticEvents.ErrorMinAmount::class.java) + assertThat(event.event).isEqualTo("Error - Min amount") + assertThat(event.params).containsExactly("Send Token", "ETH", "Send Blockchain", "Ethereum") + } + + @Test + fun `GIVEN too big error quote WHEN sendErrorIfNeeded THEN send ErrorMaxAmount with from token params`() { val errorQuote = SwapQuoteUM.Error( provider = testProvider, expressError = ExpressError.AmountError.TooBigError(code = 1002, amount = BigDecimal("1000")), @@ -69,15 +122,17 @@ class SwapAmountAnalyticsSenderTest { val eventSlot = slot() every { analyticsEventHandler.send(capture(eventSlot)) } returns Unit - sender.sendErrorIfNeeded(quotes = listOf(errorQuote), selectedQuote = errorQuote) + send(quotes = listOf(errorQuote), selectedQuote = errorQuote) verify(exactly = 1) { analyticsEventHandler.send(any()) } - val event = eventSlot.captured as SendWithSwapAnalyticEvents.SendWithSwapError - assertThat(event.message).isEqualTo(SendWithSwapAnalyticsErrorMessages.MAX_AMOUNT) + val event = eventSlot.captured + assertThat(event).isInstanceOf(SendWithSwapAnalyticEvents.ErrorMaxAmount::class.java) + assertThat(event.event).isEqualTo("Error - Max amount") + assertThat(event.params).containsExactly("Send Token", "ETH", "Send Blockchain", "Ethereum") } @Test - fun `GIVEN unknown express error WHEN sendErrorIfNeeded THEN send express quote error with code`() { + fun `GIVEN unknown express error WHEN sendErrorIfNeeded THEN send ErrorExpressQuote with both tokens and code`() { val errorQuote = SwapQuoteUM.Error( provider = testProvider, expressError = ExpressError.InternalError(code = 500), @@ -85,18 +140,26 @@ class SwapAmountAnalyticsSenderTest { val eventSlot = slot() every { analyticsEventHandler.send(capture(eventSlot)) } returns Unit - sender.sendErrorIfNeeded(quotes = listOf(errorQuote), selectedQuote = errorQuote) + send(quotes = listOf(errorQuote), selectedQuote = errorQuote) verify(exactly = 1) { analyticsEventHandler.send(any()) } - val event = eventSlot.captured as SendWithSwapAnalyticEvents.SendWithSwapError - assertThat(event.message).isEqualTo("${SendWithSwapAnalyticsErrorMessages.EXPRESS_QUOTE}: code=500") + val event = eventSlot.captured + assertThat(event).isInstanceOf(SendWithSwapAnalyticEvents.ErrorExpressQuote::class.java) + assertThat(event.event).isEqualTo("Error - Express quote") + assertThat(event.params).containsExactly( + "Send Token", "ETH", + "Send Blockchain", "Ethereum", + "Receive Token", "BTC", + "Receive Blockchain", "Bitcoin", + "Error Description", "code=500", + ) } @Test fun `GIVEN content quote WHEN sendErrorIfNeeded THEN do not send analytics`() { val contentQuote = mockk() - sender.sendErrorIfNeeded(quotes = listOf(contentQuote), selectedQuote = contentQuote) + send(quotes = listOf(contentQuote), selectedQuote = contentQuote) verify(exactly = 0) { analyticsEventHandler.send(any()) } } @@ -108,8 +171,16 @@ class SwapAmountAnalyticsSenderTest { expressError = ExpressError.AmountError.TooSmallError(code = 1001, amount = BigDecimal("0.01")), ) - sender.sendErrorIfNeeded(quotes = listOf(errorQuote), selectedQuote = errorQuote) - sender.sendErrorIfNeeded(quotes = listOf(errorQuote), selectedQuote = errorQuote) + send(quotes = listOf(errorQuote), selectedQuote = errorQuote) + send(quotes = listOf(errorQuote), selectedQuote = errorQuote) + + verify(exactly = 1) { analyticsEventHandler.send(any()) } + } + + @Test + fun `GIVEN insufficient balance twice WHEN sendErrorIfNeeded THEN send analytics only once`() { + send(hasInsufficientBalance = true) + send(hasInsufficientBalance = true) verify(exactly = 1) { analyticsEventHandler.send(any()) } } @@ -125,8 +196,21 @@ class SwapAmountAnalyticsSenderTest { expressError = ExpressError.AmountError.TooBigError(code = 1002, amount = BigDecimal("1000")), ) - sender.sendErrorIfNeeded(quotes = listOf(smallError), selectedQuote = smallError) - sender.sendErrorIfNeeded(quotes = listOf(bigError), selectedQuote = bigError) + send(quotes = listOf(smallError), selectedQuote = smallError) + send(quotes = listOf(bigError), selectedQuote = bigError) + + verify(exactly = 2) { analyticsEventHandler.send(any()) } + } + + @Test + fun `GIVEN express error then insufficient balance WHEN sendErrorIfNeeded THEN send analytics twice`() { + val smallError = SwapQuoteUM.Error( + provider = testProvider, + expressError = ExpressError.AmountError.TooSmallError(code = 1001, amount = BigDecimal("0.01")), + ) + + send(quotes = listOf(smallError), selectedQuote = smallError) + send(quotes = listOf(smallError), selectedQuote = smallError, hasInsufficientBalance = true) verify(exactly = 2) { analyticsEventHandler.send(any()) } } @@ -139,9 +223,9 @@ class SwapAmountAnalyticsSenderTest { ) val contentQuote = mockk() - sender.sendErrorIfNeeded(quotes = listOf(errorQuote), selectedQuote = errorQuote) - sender.sendErrorIfNeeded(quotes = listOf(contentQuote), selectedQuote = contentQuote) - sender.sendErrorIfNeeded(quotes = listOf(errorQuote), selectedQuote = errorQuote) + send(quotes = listOf(errorQuote), selectedQuote = errorQuote) + send(quotes = listOf(contentQuote), selectedQuote = contentQuote) + send(quotes = listOf(errorQuote), selectedQuote = errorQuote) verify(exactly = 2) { analyticsEventHandler.send(any()) } } From fc7b758aff8abf6dea290eebc04120d27248d487 Mon Sep 17 00:00:00 2001 From: Tangem Date: Tue, 28 Apr 2026 13:03:59 +0400 Subject: [PATCH 11/13] Updated on 2026-08-14 --- .../tangem/utils/coroutines/PeriodicTask.kt | 6 +- .../utils/coroutines/PeriodicTaskTest.kt | 208 ++++++++++++++++++ .../model/OnrampSuccessComponentModel.kt | 1 - .../v2/impl/amount/model/SwapAmountModel.kt | 13 +- .../model/ExpressTransactionsModel.kt | 1 - .../tokendetails/model/TokenDetailsModel.kt | 1 - .../wallet/child/wallet/model/WalletModel.kt | 1 - 7 files changed, 220 insertions(+), 11 deletions(-) create mode 100644 core/utils/src/test/kotlin/com/tangem/utils/coroutines/PeriodicTaskTest.kt diff --git a/core/utils/src/main/java/com/tangem/utils/coroutines/PeriodicTask.kt b/core/utils/src/main/java/com/tangem/utils/coroutines/PeriodicTask.kt index 3beae6ac03..ccfb61e52a 100644 --- a/core/utils/src/main/java/com/tangem/utils/coroutines/PeriodicTask.kt +++ b/core/utils/src/main/java/com/tangem/utils/coroutines/PeriodicTask.kt @@ -10,15 +10,15 @@ class PeriodicTask( private val task: suspend () -> Result, private val onSuccess: (T) -> Unit, private val onError: (Throwable) -> Unit, - private val isDelayFirst: Boolean = false, + private val initialDelay: Long = 0L, ) { private var isActive: AtomicBoolean = AtomicBoolean(false) suspend fun runTaskWithDelay() { isActive.set(true) - if (isDelayFirst) { - delay(delay) + if (initialDelay > 0L) { + delay(initialDelay) } while (isActive.get()) { task.invoke() diff --git a/core/utils/src/test/kotlin/com/tangem/utils/coroutines/PeriodicTaskTest.kt b/core/utils/src/test/kotlin/com/tangem/utils/coroutines/PeriodicTaskTest.kt new file mode 100644 index 0000000000..1f77fe3cce --- /dev/null +++ b/core/utils/src/test/kotlin/com/tangem/utils/coroutines/PeriodicTaskTest.kt @@ -0,0 +1,208 @@ +package com.tangem.utils.coroutines + +import com.google.common.truth.Truth.assertThat +import io.mockk.mockk +import io.mockk.verify +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.advanceTimeBy +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.Test +import java.util.concurrent.atomic.AtomicInteger + +@OptIn(ExperimentalCoroutinesApi::class) +class PeriodicTaskTest { + + @Test + fun `GIVEN initialDelay 0 and delay 1000 WHEN runTaskWithDelay THEN task is invoked immediately`() = runTest { + val callCount = AtomicInteger(0) + val onSuccess = mockk<(Int) -> Unit>(relaxed = true) + val onError = mockk<(Throwable) -> Unit>(relaxed = true) + val periodicTask = PeriodicTask( + delay = PERIOD, + task = { callCount.incrementAndGet(); Result.success(VALUE) }, + onSuccess = onSuccess, + onError = onError, + initialDelay = 0L, + ) + + launch { periodicTask.runTaskWithDelay() } + runCurrent() + + assertThat(callCount.get()).isEqualTo(1) + periodicTask.cancel() + } + + @Test + fun `GIVEN initialDelay 1000 WHEN runTaskWithDelay THEN task is not invoked before initialDelay elapses`() = + runTest { + val callCount = AtomicInteger(0) + val periodicTask = PeriodicTask( + delay = PERIOD, + task = { callCount.incrementAndGet(); Result.success(VALUE) }, + onSuccess = mockk(relaxed = true), + onError = mockk(relaxed = true), + initialDelay = INITIAL_DELAY, + ) + + launch { periodicTask.runTaskWithDelay() } + advanceTimeBy(INITIAL_DELAY - 1) + + assertThat(callCount.get()).isEqualTo(0) + periodicTask.cancel() + } + + @Test + fun `GIVEN initialDelay 1000 WHEN runTaskWithDelay THEN task is invoked once initialDelay elapses`() = runTest { + val callCount = AtomicInteger(0) + val periodicTask = PeriodicTask( + delay = PERIOD, + task = { callCount.incrementAndGet(); Result.success(VALUE) }, + onSuccess = mockk(relaxed = true), + onError = mockk(relaxed = true), + initialDelay = INITIAL_DELAY, + ) + + launch { periodicTask.runTaskWithDelay() } + advanceTimeBy(INITIAL_DELAY) + runCurrent() + + assertThat(callCount.get()).isEqualTo(1) + periodicTask.cancel() + } + + @Test + fun `GIVEN periodic task WHEN runTaskWithDelay THEN task is invoked repeatedly every delay ms`() = runTest { + val callCount = AtomicInteger(0) + val periodicTask = PeriodicTask( + delay = PERIOD, + task = { callCount.incrementAndGet(); Result.success(VALUE) }, + onSuccess = mockk(relaxed = true), + onError = mockk(relaxed = true), + initialDelay = 0L, + ) + + launch { periodicTask.runTaskWithDelay() } + runCurrent() + advanceTimeBy(PERIOD * 3) + runCurrent() + + assertThat(callCount.get()).isEqualTo(4) + periodicTask.cancel() + } + + @Test + fun `GIVEN successful task WHEN runTaskWithDelay THEN onSuccess is called with the result value`() = runTest { + val received = AtomicInteger(-1) + val errors = AtomicInteger(0) + val periodicTask = PeriodicTask( + delay = PERIOD, + task = { Result.success(VALUE) }, + onSuccess = { received.set(it) }, + onError = { errors.incrementAndGet() }, + initialDelay = 0L, + ) + + launch { periodicTask.runTaskWithDelay() } + runCurrent() + + assertThat(received.get()).isEqualTo(VALUE) + assertThat(errors.get()).isEqualTo(0) + periodicTask.cancel() + } + + @Test + fun `GIVEN failing task WHEN runTaskWithDelay THEN onError is called with the thrown exception`() = runTest { + val boom = IllegalStateException("boom") + val captured = arrayOfNulls(1) + val successes = AtomicInteger(0) + val periodicTask = PeriodicTask( + delay = PERIOD, + task = { Result.failure(boom) }, + onSuccess = { successes.incrementAndGet() }, + onError = { captured[0] = it }, + initialDelay = 0L, + ) + + launch { periodicTask.runTaskWithDelay() } + runCurrent() + + assertThat(captured[0]).isSameInstanceAs(boom) + assertThat(successes.get()).isEqualTo(0) + periodicTask.cancel() + } + + @Test + fun `GIVEN task running WHEN cancel THEN no further invocations happen`() = runTest { + val callCount = AtomicInteger(0) + val periodicTask = PeriodicTask( + delay = PERIOD, + task = { callCount.incrementAndGet(); Result.success(VALUE) }, + onSuccess = mockk(relaxed = true), + onError = mockk(relaxed = true), + initialDelay = 0L, + ) + + launch { periodicTask.runTaskWithDelay() } + runCurrent() + assertThat(callCount.get()).isEqualTo(1) + + periodicTask.cancel() + advanceUntilIdle() + + assertThat(callCount.get()).isEqualTo(1) + } + + @Test + fun `GIVEN initialDelay 1000 and cancel before it elapses WHEN runTaskWithDelay THEN task is never invoked`() = + runTest { + val callCount = AtomicInteger(0) + val periodicTask = PeriodicTask( + delay = PERIOD, + task = { callCount.incrementAndGet(); Result.success(VALUE) }, + onSuccess = mockk(relaxed = true), + onError = mockk(relaxed = true), + initialDelay = INITIAL_DELAY, + ) + + launch { periodicTask.runTaskWithDelay() } + advanceTimeBy(INITIAL_DELAY / 2) + periodicTask.cancel() + advanceUntilIdle() + + assertThat(callCount.get()).isEqualTo(0) + } + + @Test + fun `GIVEN task cancelled during invocation WHEN runTaskWithDelay THEN onSuccess is not called for the pending result`() = + runTest { + val onSuccess = mockk<(Int) -> Unit>(relaxed = true) + val periodicTask = PeriodicTask( + delay = PERIOD, + // Simulates a slow task that completes after the scheduler was cancelled. + task = { + delay(PERIOD) + Result.success(VALUE) + }, + onSuccess = onSuccess, + onError = mockk(relaxed = true), + initialDelay = 0L, + ) + + launch { periodicTask.runTaskWithDelay() } + runCurrent() + periodicTask.cancel() + advanceUntilIdle() + + verify(exactly = 0) { onSuccess.invoke(any()) } + } + + private companion object { + const val PERIOD = 10_000L + const val INITIAL_DELAY = 1_000L + const val VALUE = 42 + } +} \ No newline at end of file diff --git a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/success/model/OnrampSuccessComponentModel.kt b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/success/model/OnrampSuccessComponentModel.kt index dbae3fb93d..05354188cf 100644 --- a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/success/model/OnrampSuccessComponentModel.kt +++ b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/success/model/OnrampSuccessComponentModel.kt @@ -120,7 +120,6 @@ internal class OnrampSuccessComponentModel @Inject constructor( expressTxStatusTaskScheduler.scheduleTask( modelScope, PeriodicTask( - isDelayFirst = false, delay = EXPRESS_STATUS_UPDATE_DELAY, task = { runSuspendCatching { diff --git a/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/amount/model/SwapAmountModel.kt b/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/amount/model/SwapAmountModel.kt index 3aedd8f612..77a4272f1a 100644 --- a/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/amount/model/SwapAmountModel.kt +++ b/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/amount/model/SwapAmountModel.kt @@ -151,10 +151,14 @@ internal class SwapAmountModel @Inject constructor( } fun onStart() { - val isDelayFirst = params !is SwapAmountComponentParams.AmountBlockParams + val initialDelay = if (params is SwapAmountComponentParams.AmountBlockParams) { + BLOCK_INITIAL_QUOTE_DELAY + } else { + QUOTES_UPDATE_DELAY + } quoteTaskScheduler.scheduleTask( scope = modelScope, - task = loadQuotesTask(isDelayFirst = isDelayFirst), + task = loadQuotesTask(initialDelay = initialDelay), ) subscribeOnAutoupdateEnabling() } @@ -850,10 +854,10 @@ internal class SwapAmountModel @Inject constructor( ) } - private fun loadQuotesTask(isDelayFirst: Boolean = true): PeriodicTask { + private fun loadQuotesTask(initialDelay: Long = QUOTES_UPDATE_DELAY): PeriodicTask { return PeriodicTask( delay = QUOTES_UPDATE_DELAY, - isDelayFirst = isDelayFirst, + initialDelay = initialDelay, task = { runCatching { loadQuotes(isSilentReload = true) } }, @@ -933,5 +937,6 @@ internal class SwapAmountModel @Inject constructor( private companion object { const val DEBOUNCE_AMOUNT_DELAY = 500L const val QUOTES_UPDATE_DELAY = 10000L + const val BLOCK_INITIAL_QUOTE_DELAY = 1000L } } \ No newline at end of file diff --git a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/model/ExpressTransactionsModel.kt b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/model/ExpressTransactionsModel.kt index bdeb591c42..55ce756ae3 100644 --- a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/model/ExpressTransactionsModel.kt +++ b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/model/ExpressTransactionsModel.kt @@ -195,7 +195,6 @@ internal class ExpressTransactionsModel @Inject constructor( expressTxStatusTaskScheduler.scheduleTask( scope = modelScope, task = PeriodicTask( - isDelayFirst = false, delay = EXPRESS_STATUS_UPDATE_DELAY, task = { try { diff --git a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/model/TokenDetailsModel.kt b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/model/TokenDetailsModel.kt index 4cd0017182..8214740b7b 100644 --- a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/model/TokenDetailsModel.kt +++ b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/model/TokenDetailsModel.kt @@ -390,7 +390,6 @@ internal class TokenDetailsModel @Inject constructor( expressTxStatusTaskScheduler.scheduleTask( scope = modelScope, task = PeriodicTask( - isDelayFirst = false, delay = EXPRESS_STATUS_UPDATE_DELAY, task = { runSuspendCatching { diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/wallet/model/WalletModel.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/wallet/model/WalletModel.kt index 8597389093..89446f2de8 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/wallet/model/WalletModel.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/wallet/model/WalletModel.kt @@ -398,7 +398,6 @@ internal class WalletModel @Inject constructor( expressTxStatusTaskScheduler.scheduleTask( modelScope, PeriodicTask( - isDelayFirst = false, delay = EXPRESS_STATUS_UPDATE_DELAY, task = { runCatching { From 1bd07c862651e2989fd43d8e635d91040a4fb313 Mon Sep 17 00:00:00 2001 From: Tangem Date: Tue, 28 Apr 2026 09:04:22 +0000 Subject: [PATCH 12/13] Updated on 2026-08-14 --- gradle/tangem_dependencies.toml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/gradle/tangem_dependencies.toml b/gradle/tangem_dependencies.toml index 94aa5668ec..83bb5cdb69 100644 --- a/gradle/tangem_dependencies.toml +++ b/gradle/tangem_dependencies.toml @@ -5,9 +5,9 @@ # https://github.com/tangem/tangem-sdk-android/ # https://github.com/tangem/vico -tangemBlockchainSdk = "releases-5.37-1494" +tangemBlockchainSdk = "develop-1498" #tangemBlockchainSdk = "0.0.1" # Keep it! - used for local builds -tangemCardSdk = "releases-5.37-603" +tangemCardSdk = "develop-611" #tangemCardSdk = "0.0.1" # Keep it! - used for local builds ^ tangemVico = "tangem-master-21" #tangemVico = "0.0.1" # Keep it! - used for local builds ^ From 6587ba5e1ee37017cafa0767adfb99d312b126d4 Mon Sep 17 00:00:00 2001 From: Tangem Date: Tue, 28 Apr 2026 14:23:37 +0300 Subject: [PATCH 13/13] Updated on 2026-08-14 --- .../account/status/usecase/IsAccountsModeEnabledUseCase.kt | 1 - .../features/approval/impl/model/GiveApprovalModelTest.kt | 2 -- .../choosetoken/impl/converter/ChooseTokenListItemConverter.kt | 1 + .../presentation/wallet/domain/GetMultiWalletWarningsFactory.kt | 2 -- 4 files changed, 1 insertion(+), 5 deletions(-) diff --git a/domain/account/status/src/main/java/com/tangem/domain/account/status/usecase/IsAccountsModeEnabledUseCase.kt b/domain/account/status/src/main/java/com/tangem/domain/account/status/usecase/IsAccountsModeEnabledUseCase.kt index ae529e245c..6667cb8e2a 100644 --- a/domain/account/status/src/main/java/com/tangem/domain/account/status/usecase/IsAccountsModeEnabledUseCase.kt +++ b/domain/account/status/src/main/java/com/tangem/domain/account/status/usecase/IsAccountsModeEnabledUseCase.kt @@ -54,7 +54,6 @@ class IsAccountsModeEnabledUseCase( is PaymentAccountStatusValue.IssuingCard, is PaymentAccountStatusValue.Loaded, is PaymentAccountStatusValue.Loading, - is PaymentAccountStatusValue.Locked, is PaymentAccountStatusValue.UnderReview, -> true } diff --git a/features/approval/impl/src/test/kotlin/com/tangem/features/approval/impl/model/GiveApprovalModelTest.kt b/features/approval/impl/src/test/kotlin/com/tangem/features/approval/impl/model/GiveApprovalModelTest.kt index fc097acb8c..cf09525c4c 100644 --- a/features/approval/impl/src/test/kotlin/com/tangem/features/approval/impl/model/GiveApprovalModelTest.kt +++ b/features/approval/impl/src/test/kotlin/com/tangem/features/approval/impl/model/GiveApprovalModelTest.kt @@ -41,7 +41,6 @@ class GiveApprovalModelTest { private val getFeeForGaslessUseCase: GetFeeForGaslessUseCase = mockk(relaxed = true) private val getFeeForTokenUseCase: GetFeeForTokenUseCase = mockk(relaxed = true) private val createAndSendGaslessTransactionUseCase: CreateAndSendGaslessTransactionUseCase = mockk(relaxed = true) - private val uiMessageSender: UiMessageSender = mockk(relaxed = true) private val urlOpener: UrlOpener = mockk(relaxed = true) private val analyticsEventHandler: AnalyticsEventHandler = mockk(relaxed = true) private val feeSelectorReloadTrigger: FeeSelectorReloadTrigger = mockk(relaxed = true) @@ -81,7 +80,6 @@ class GiveApprovalModelTest { getFeeForGaslessUseCase = getFeeForGaslessUseCase, getFeeForTokenUseCase = getFeeForTokenUseCase, createAndSendGaslessTransactionUseCase = createAndSendGaslessTransactionUseCase, - uiMessageSender = uiMessageSender, urlOpener = urlOpener, getUserWalletUseCase = getUserWalletUseCase, analyticsEventHandler = analyticsEventHandler, diff --git a/features/swap/impl/src/main/java/com/tangem/feature/swap/choosetoken/impl/converter/ChooseTokenListItemConverter.kt b/features/swap/impl/src/main/java/com/tangem/feature/swap/choosetoken/impl/converter/ChooseTokenListItemConverter.kt index 3ad65aa9dd..277772d1df 100644 --- a/features/swap/impl/src/main/java/com/tangem/feature/swap/choosetoken/impl/converter/ChooseTokenListItemConverter.kt +++ b/features/swap/impl/src/main/java/com/tangem/feature/swap/choosetoken/impl/converter/ChooseTokenListItemConverter.kt @@ -186,6 +186,7 @@ internal class ChooseTokenListItemConverter( PaymentAccountStatusValue.NotCreated, is PaymentAccountStatusValue.UnderReview, PaymentAccountStatusValue.Loading, + PaymentAccountStatusValue.Empty, -> return null is PaymentAccountStatusValue.Loaded -> status.cryptoCurrencyStatus } diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/domain/GetMultiWalletWarningsFactory.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/domain/GetMultiWalletWarningsFactory.kt index 5f27a893f7..193dfee657 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/domain/GetMultiWalletWarningsFactory.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/domain/GetMultiWalletWarningsFactory.kt @@ -27,8 +27,6 @@ import com.tangem.domain.notifications.repository.NotificationsRepository import com.tangem.domain.promo.ShouldShowPromoWalletUseCase import com.tangem.domain.promo.models.PromoId import com.tangem.domain.settings.IsReadyToShowRateAppUseCase -import com.tangem.domain.tokensync.model.TokenSyncProgress -import com.tangem.domain.tokensync.usecase.ObserveTokenSyncUseCase import com.tangem.domain.assetsdiscovery.model.AssetsDiscoveryProgress import com.tangem.domain.assetsdiscovery.usecase.ObserveAssetsDiscoveryUseCase import com.tangem.domain.wallets.usecase.IsNeedToBackupUseCase