From a075d9737bbc7c480eaec48771ba192b58141fe3 Mon Sep 17 00:00:00 2001 From: Tangem Date: Thu, 7 May 2026 01:10:09 -0700 Subject: [PATCH 01/10] Updated on 2026-08-14 --- .../domain/scanCard/chains/AnalyticsChain.kt | 3 +- .../sdk/impl/DefaultTangemSdkManager.kt | 3 +- .../core/analytics/models/AnalyticsEvent.kt | 27 +++++++++- .../com/tangem/core/analytics/Analytics.kt | 16 +++--- .../tangempay/TangemPayAnalyticsEvents.kt | 22 ++++++-- .../setup/TangemPayCardLimitSetupModel.kt | 6 +++ .../tangempay/model/TangemPayCardPageModel.kt | 7 ++- .../setup/TangemPayCardLimitSetupModelTest.kt | 54 ++++++++++++++----- 8 files changed, 107 insertions(+), 31 deletions(-) diff --git a/app/src/main/java/com/tangem/tap/domain/scanCard/chains/AnalyticsChain.kt b/app/src/main/java/com/tangem/tap/domain/scanCard/chains/AnalyticsChain.kt index c7c56864ec..06314f32d7 100644 --- a/app/src/main/java/com/tangem/tap/domain/scanCard/chains/AnalyticsChain.kt +++ b/app/src/main/java/com/tangem/tap/domain/scanCard/chains/AnalyticsChain.kt @@ -25,9 +25,8 @@ class AnalyticsChain( val interceptor = CardContextInterceptor(previousChainResult) val params = event.params.toMutableMap() interceptor.intercept(params) - event.params = params.toMap() - Analytics.send(event) + Analytics.send(event.withParams(params.toMap())) return previousChainResult.right() } diff --git a/app/src/main/java/com/tangem/tap/domain/sdk/impl/DefaultTangemSdkManager.kt b/app/src/main/java/com/tangem/tap/domain/sdk/impl/DefaultTangemSdkManager.kt index c4a6e936fc..797c3ce6da 100644 --- a/app/src/main/java/com/tangem/tap/domain/sdk/impl/DefaultTangemSdkManager.kt +++ b/app/src/main/java/com/tangem/tap/domain/sdk/impl/DefaultTangemSdkManager.kt @@ -238,9 +238,8 @@ internal class DefaultTangemSdkManager( val interceptor = CardContextInterceptor(scanResponse) val params = analyticsEvent.params.toMutableMap() interceptor.intercept(params) - analyticsEvent.params = params.toMap() - Analytics.send(event = analyticsEvent) + Analytics.send(event = analyticsEvent.withParams(params.toMap())) } .doOnFailure { tangemError -> (tangemError as? TangemSdkError)?.let { error -> diff --git a/core/analytics/models/src/main/java/com/tangem/core/analytics/models/AnalyticsEvent.kt b/core/analytics/models/src/main/java/com/tangem/core/analytics/models/AnalyticsEvent.kt index 8c12f046df..d259b2ba00 100644 --- a/core/analytics/models/src/main/java/com/tangem/core/analytics/models/AnalyticsEvent.kt +++ b/core/analytics/models/src/main/java/com/tangem/core/analytics/models/AnalyticsEvent.kt @@ -6,8 +6,31 @@ package com.tangem.core.analytics.models open class AnalyticsEvent( val category: String, val event: String, - var params: Map = mapOf(), + val params: Map = mapOf(), ) { - val id: String = "[$category] $event" + + fun withParams(newParams: Map) = AnalyticsEvent(category, event, newParams) + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as AnalyticsEvent + + if (category != other.category) return false + if (event != other.event) return false + if (params != other.params) return false + if (id != other.id) return false + + return true + } + + override fun hashCode(): Int { + var result = category.hashCode() + result = 31 * result + event.hashCode() + result = 31 * result + params.hashCode() + result = 31 * result + id.hashCode() + return result + } } \ No newline at end of file diff --git a/core/analytics/src/main/java/com/tangem/core/analytics/Analytics.kt b/core/analytics/src/main/java/com/tangem/core/analytics/Analytics.kt index d6571cb78c..5b93790744 100644 --- a/core/analytics/src/main/java/com/tangem/core/analytics/Analytics.kt +++ b/core/analytics/src/main/java/com/tangem/core/analytics/Analytics.kt @@ -91,16 +91,16 @@ object Analytics : GlobalAnalyticsEventHandler { if (event is OneTimePerSessionEvent && !shouldSendThrottledEvent(event)) { return@launch } - event.params = applyParamsInterceptors(event) - val eventFilter = analyticsFilters.firstOrNull { it.canBeAppliedTo(event) } + val eventWithParams = event.withParams(applyParamsInterceptors(event)) + val eventFilter = analyticsFilters.firstOrNull { it.canBeAppliedTo(eventWithParams) } analyticsMutex.withLock { when { - eventFilter == null -> analyticsHandlers.forEach { handler -> handler.send(event) } - eventFilter.canBeSent(event) -> { + eventFilter == null -> analyticsHandlers.forEach { handler -> handler.send(eventWithParams) } + eventFilter.canBeSent(eventWithParams) -> { analyticsHandlers - .filter { handler -> eventFilter.canBeConsumedByHandler(handler, event) } - .forEach { handler -> handler.send(event) } + .filter { handler -> eventFilter.canBeConsumedByHandler(handler, eventWithParams) } + .forEach { handler -> handler.send(eventWithParams) } } } } @@ -109,10 +109,10 @@ object Analytics : GlobalAnalyticsEventHandler { override fun sendErrorEvent(event: AnalyticsEvent) { analyticsScope.launch { - event.params = applyParamsInterceptors(event) + val eventWithParams = event.withParams(applyParamsInterceptors(event)) analyticsMutex.withLock { analyticsHandlers.filterIsInstance() - .forEach { handler -> handler.sendErrorEvent(event) } + .forEach { handler -> handler.sendErrorEvent(eventWithParams) } } } } diff --git a/domain/visa/src/main/kotlin/com/tangem/domain/tangempay/TangemPayAnalyticsEvents.kt b/domain/visa/src/main/kotlin/com/tangem/domain/tangempay/TangemPayAnalyticsEvents.kt index a8229e803a..5b3bc6f428 100644 --- a/domain/visa/src/main/kotlin/com/tangem/domain/tangempay/TangemPayAnalyticsEvents.kt +++ b/domain/visa/src/main/kotlin/com/tangem/domain/tangempay/TangemPayAnalyticsEvents.kt @@ -194,20 +194,36 @@ sealed class TangemPayAnalyticsEvents( ) class ReplaceCardClicked : TangemPayAnalyticsEvents( - categoryName = "Visa Screen", + categoryName = "Visa Card Management", event = "Visa Replace Card Clicked", ) class ReplaceCardConfirmationPopupOpened : TangemPayAnalyticsEvents( - categoryName = "Visa Screen", + categoryName = "Visa Card Management", event = "Visa Replace Card Confirmation Popup Opened", ) class ReplaceCardConfirmed : TangemPayAnalyticsEvents( - categoryName = "Visa Screen", + categoryName = "Visa Card Management", event = "Visa Replace Card Confirmed", ) + class LimitChangeClicked : TangemPayAnalyticsEvents( + categoryName = "Visa Card Management", + event = "Visa Daily Limit Change Clicked", + ) + + class LimitManagementOpened : TangemPayAnalyticsEvents( + categoryName = "Visa Card Management", + event = "Visa Limit Management Screen Opened", + ) + + data class LimitChangeConfirmed(val amount: String) : TangemPayAnalyticsEvents( + categoryName = "Visa Card Management", + event = "Visa Set Limits Confirmed", + params = mapOf("amount" to amount), + ) + class MainVisaPermanentBannerClicked : TangemPayAnalyticsEvents( categoryName = "Visa Onboarding", event = "Visa Permanent Banner Clicked", diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/limit/setup/TangemPayCardLimitSetupModel.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/limit/setup/TangemPayCardLimitSetupModel.kt index 487e568445..ff13c3a2b7 100644 --- a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/limit/setup/TangemPayCardLimitSetupModel.kt +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/limit/setup/TangemPayCardLimitSetupModel.kt @@ -1,6 +1,7 @@ package com.tangem.features.tangempay.limit.setup import androidx.compose.runtime.Stable +import com.tangem.core.analytics.api.AnalyticsEventHandler import com.tangem.core.decompose.di.ModelScoped import com.tangem.core.decompose.model.Model import com.tangem.core.decompose.model.ParamsContainer @@ -20,6 +21,7 @@ import com.tangem.domain.models.account.requireCardWithId import com.tangem.domain.models.pay.TangemPayCardLimitPeriod import com.tangem.domain.pay.flow.PaymentAccountStatusSupplier import com.tangem.domain.pay.usecase.SetTangemPayCardLimitUseCase +import com.tangem.domain.tangempay.TangemPayAnalyticsEvents import com.tangem.features.tangempay.components.TangemPayDetailsContainerComponent import com.tangem.features.tangempay.details.impl.R import com.tangem.features.tangempay.navigation.TangemPayCardDetailsInnerRoute @@ -32,6 +34,7 @@ import java.math.BigDecimal import java.util.Currency import javax.inject.Inject +@Suppress("LongParameterList") @Stable @ModelScoped internal class TangemPayCardLimitSetupModel @Inject constructor( @@ -41,6 +44,7 @@ internal class TangemPayCardLimitSetupModel @Inject constructor( private val paymentAccountStatusSupplier: PaymentAccountStatusSupplier, private val setTangemPayCardLimitUseCase: SetTangemPayCardLimitUseCase, private val uiMessageSender: UiMessageSender, + private val analytics: AnalyticsEventHandler, ) : Model() { private val params: TangemPayDetailsContainerComponent.Params = paramsContainer.require() @@ -66,6 +70,7 @@ internal class TangemPayCardLimitSetupModel @Inject constructor( ) init { + analytics.send(TangemPayAnalyticsEvents.LimitManagementOpened()) observeCardState() } @@ -130,6 +135,7 @@ internal class TangemPayCardLimitSetupModel @Inject constructor( val amount = uiState.value.amountFieldModel.value.toBigDecimalOrNull() ?: return modelScope.launch { uiState.update { it.copy(isSubmitButtonLoading = true) } + analytics.send(TangemPayAnalyticsEvents.LimitChangeConfirmed(amount.toPlainString())) setTangemPayCardLimitUseCase( cardId = params.config.cardId, userWalletId = params.userWalletId, diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayCardPageModel.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayCardPageModel.kt index 735d0b9398..14d8765c16 100644 --- a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayCardPageModel.kt +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayCardPageModel.kt @@ -100,7 +100,7 @@ internal class TangemPayCardPageModel @Inject constructor( val symbol = getJavaCurrencyByCode(status.currencyCode).symbol fiat(status.currencyCode, symbol) }, - onChangeClick = { router.push(TangemPayCardDetailsInnerRoute.LimitSetup) }, + onChangeClick = ::onClickLimitChange, ) } else { TangemPayDailyLimitBlockState.Error @@ -132,6 +132,11 @@ internal class TangemPayCardPageModel @Inject constructor( ) } + private fun onClickLimitChange() { + analytics.send(TangemPayAnalyticsEvents.LimitChangeClicked()) + router.push(TangemPayCardDetailsInnerRoute.LimitSetup) + } + private fun onClickChangePIN(isPinSet: Boolean) { if (!isPinSet) { router.push(TangemPayCardDetailsInnerRoute.ChangePIN) diff --git a/features/tangempay/details/impl/src/test/kotlin/com/tangem/features/tangempay/limit/setup/TangemPayCardLimitSetupModelTest.kt b/features/tangempay/details/impl/src/test/kotlin/com/tangem/features/tangempay/limit/setup/TangemPayCardLimitSetupModelTest.kt index f9a1defa96..6de5f4d546 100644 --- a/features/tangempay/details/impl/src/test/kotlin/com/tangem/features/tangempay/limit/setup/TangemPayCardLimitSetupModelTest.kt +++ b/features/tangempay/details/impl/src/test/kotlin/com/tangem/features/tangempay/limit/setup/TangemPayCardLimitSetupModelTest.kt @@ -1,6 +1,7 @@ package com.tangem.features.tangempay.limit.setup import com.google.common.truth.Truth.assertThat +import com.tangem.core.analytics.api.AnalyticsEventHandler import com.tangem.core.decompose.model.MutableParamsContainer import com.tangem.core.decompose.navigation.Router import com.tangem.core.decompose.ui.UiMessageSender @@ -15,21 +16,21 @@ import com.tangem.domain.models.wallet.UserWalletId import com.tangem.domain.pay.TangemPayDetailsConfig import com.tangem.domain.pay.flow.PaymentAccountStatusSupplier import com.tangem.domain.pay.usecase.SetTangemPayCardLimitUseCase +import com.tangem.domain.tangempay.TangemPayAnalyticsEvents import com.tangem.domain.visa.model.TangemPayCardFrozenState import com.tangem.features.tangempay.components.TangemPayDetailsContainerComponent import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider import io.mockk.every import io.mockk.mockk +import io.mockk.verify import kotlinx.coroutines.flow.flowOf import org.junit.jupiter.api.Nested import org.junit.jupiter.api.Test -import org.junit.jupiter.api.TestInstance import org.junit.jupiter.params.ParameterizedTest import org.junit.jupiter.params.provider.Arguments import org.junit.jupiter.params.provider.MethodSource import java.math.BigDecimal -@TestInstance(TestInstance.Lifecycle.PER_CLASS) internal class TangemPayCardLimitSetupModelTest { private val cardId = "test_card_id" @@ -39,6 +40,7 @@ internal class TangemPayCardLimitSetupModelTest { private val uiMessageSender: UiMessageSender = mockk(relaxed = true) private val setLimitUseCase: SetTangemPayCardLimitUseCase = mockk(relaxed = true) private val paymentAccountStatusSupplier: PaymentAccountStatusSupplier = mockk() + private val analytics: AnalyticsEventHandler = mockk(relaxed = true) private val params = TangemPayDetailsContainerComponent.Params( userWalletId = userWalletId, @@ -90,6 +92,7 @@ internal class TangemPayCardLimitSetupModelTest { paymentAccountStatusSupplier = paymentAccountStatusSupplier, setTangemPayCardLimitUseCase = setLimitUseCase, uiMessageSender = uiMessageSender, + analytics = analytics, ) } @@ -119,7 +122,29 @@ internal class TangemPayCardLimitSetupModelTest { } @Nested - @TestInstance(TestInstance.Lifecycle.PER_CLASS) + inner class Analytics { + @Test + fun `GIVEN model WHEN init THEN LimitManagementOpened is sent`() { + val model = createModel() + + verify(exactly = 1) { analytics.send(TangemPayAnalyticsEvents.LimitManagementOpened()) } + + model.onDestroy() + } + + @Test + fun `GIVEN model WHEN onSubmitClick THEN LimitChangeConfirmed is sent`() { + val model = createModel() + + model.uiState.value.amountFieldModel.onValueChange("100") + model.uiState.value.onSubmitClick() + verify(exactly = 1) { analytics.send(TangemPayAnalyticsEvents.LimitChangeConfirmed("100")) } + + model.onDestroy() + } + } + + @Nested inner class Presets { @Test @@ -143,14 +168,17 @@ internal class TangemPayCardLimitSetupModelTest { } } - private fun provideTestCases() = listOf( - Arguments.of("0", false), - Arguments.of("0.99", false), - Arguments.of("1", true), - Arguments.of("100", true), - Arguments.of("-1", false), - Arguments.of("", false), - Arguments.of("abc", true), - Arguments.of("1001", false), - ) + private companion object { + @JvmStatic + fun provideTestCases() = listOf( + Arguments.of("0", false), + Arguments.of("0.99", false), + Arguments.of("1", true), + Arguments.of("100", true), + Arguments.of("-1", false), + Arguments.of("", false), + Arguments.of("abc", true), + Arguments.of("1001", false), + ) + } } \ No newline at end of file From 0a34e912f68a51d4d7473f5b84fad211a44dba92 Mon Sep 17 00:00:00 2001 From: Tangem Date: Fri, 8 May 2026 11:52:13 +0500 Subject: [PATCH 02/10] Updated on 2026-08-14 --- .../java/com/tangem/feature/swap/ui/TransactionCard.kt | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/TransactionCard.kt b/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/TransactionCard.kt index 65aad222b7..c4c9307346 100644 --- a/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/TransactionCard.kt +++ b/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/TransactionCard.kt @@ -382,8 +382,12 @@ private fun Content( onFocusChange = type.onFocusChanged, ) - LaunchedEffect(Unit) { - focusRequester.requestFocus() + LaunchedEffect(type.isEnabled) { + if (type.isEnabled) { + focusRequester.requestFocus() + } else { + focusRequester.freeFocus() + } } } } From a4e8e059a7312e85acb110db84f29502a686f61a Mon Sep 17 00:00:00 2001 From: Tangem Date: Fri, 8 May 2026 17:43:42 +0500 Subject: [PATCH 03/10] Updated on 2026-08-14 --- .../common/ui/notifications/NotificationUM.kt | 8 +++++++ .../ui/notifications/NotificationsFactory.kt | 24 +++++++++++++------ 2 files changed, 25 insertions(+), 7 deletions(-) diff --git a/common/ui/src/main/java/com/tangem/common/ui/notifications/NotificationUM.kt b/common/ui/src/main/java/com/tangem/common/ui/notifications/NotificationUM.kt index fe9115cfec..893ca50431 100644 --- a/common/ui/src/main/java/com/tangem/common/ui/notifications/NotificationUM.kt +++ b/common/ui/src/main/java/com/tangem/common/ui/notifications/NotificationUM.kt @@ -170,6 +170,14 @@ sealed class NotificationUM(val config: NotificationConfig) { subtitle = resourceReference(id = R.string.send_notification_invalid_reserve_amount_text), ) + data class NetworkAccountNotFunded(val coinName: String) : Error( + title = resourceReference(R.string.alert_failed_to_send_transaction_title), + subtitle = resourceReference( + id = R.string.no_account_generic, + formatArgs = wrappedList(coinName), + ), + ) + data object DestinationTagRequired : Error( title = resourceReference(id = R.string.send_validation_destination_tag_required_title), subtitle = resourceReference(id = R.string.send_validation_destination_tag_required_description), diff --git a/common/ui/src/main/java/com/tangem/common/ui/notifications/NotificationsFactory.kt b/common/ui/src/main/java/com/tangem/common/ui/notifications/NotificationsFactory.kt index 4e0e7d90e5..b52325095e 100644 --- a/common/ui/src/main/java/com/tangem/common/ui/notifications/NotificationsFactory.kt +++ b/common/ui/src/main/java/com/tangem/common/ui/notifications/NotificationsFactory.kt @@ -136,14 +136,24 @@ object NotificationsFactory { // No need to show reserve amount warning if fee currency is unknown for token transfer return } else if (!isAccountFunded && reserveAmount != null && reserveAmount > sendingCoinAmount) { - add( - NotificationUM.Error.ReserveAmount( - reserveAmount.format { - crypto(feeCryptoCurrency ?: cryptoCurrency) - }, - ), - ) + // account not funded, sending coin amount < reserve (send coin with less amount OR send any token) + + if (cryptoCurrency is CryptoCurrency.Coin) { + // Try to send coin amount less than reserve amount (e.g. less than 1 XLM in Stellar) + add( + NotificationUM.Error.ReserveAmount( + reserveAmount.format { + crypto(feeCryptoCurrency ?: cryptoCurrency) + }, + ), + ) + } else { + checkNotNull(feeCryptoCurrency) + // Try to send any token (e.g. USDC in Stellar, but account not funded -> user must send XLM at first) + add(NotificationUM.Error.NetworkAccountNotFunded(coinName = feeCryptoCurrency.name)) + } } + // TODO: check the RECEIVER account trustline before sending } fun MutableList.addMinimumAmountErrorNotification( From 0d8970b386f5e78f0c2b993a2a45af1007f6473a Mon Sep 17 00:00:00 2001 From: Tangem Date: Fri, 8 May 2026 05:45:30 -0700 Subject: [PATCH 04/10] Updated on 2026-08-14 --- .../format/bigdecimal/BigDecimalFiatFormat.kt | 27 ++++++++++++++ .../bigdecimal/BigDecimalFiatFormatTest.kt | 36 +++++++++++++++++++ .../setup/TangemPayCardLimitSetupModel.kt | 10 +++--- .../tangempay/model/TangemPayCardPageModel.kt | 3 +- 4 files changed, 70 insertions(+), 6 deletions(-) diff --git a/core/ui/src/main/java/com/tangem/core/ui/format/bigdecimal/BigDecimalFiatFormat.kt b/core/ui/src/main/java/com/tangem/core/ui/format/bigdecimal/BigDecimalFiatFormat.kt index 7629ed6986..af950e8bfe 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/format/bigdecimal/BigDecimalFiatFormat.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/format/bigdecimal/BigDecimalFiatFormat.kt @@ -242,6 +242,33 @@ fun BigDecimalFiatFormat.anyDecimals(decimals: Int): BigDecimalFormat = BigDecim .replace(formatterCurrency.getSymbol(locale), fiatCurrencySymbol) } +/** + * Formats fiat amount following the pattern: + * + * 123 -> $123 + * + * 123.1 -> $123.10 + * + * 123.10 -> $123.10 + * + * 123.456 -> $123.46 + */ +fun BigDecimalFiatFormat.optionalDecimals(): BigDecimalFormat = BigDecimalFormat { value -> + val formatterCurrency = getJavaCurrencyByCode(fiatCurrencyCode) + + val hasFraction = value.stripTrailingZeros().scale() > 0 + val defaultDigits = formatterCurrency.defaultFractionDigits + val formatter = NumberFormat.getCurrencyInstance(locale).apply { + currency = formatterCurrency + minimumFractionDigits = if (hasFraction) defaultDigits else 0 + maximumFractionDigits = defaultDigits + roundingMode = RoundingMode.HALF_UP + } + + formatter.format(value) + .replace(formatterCurrency.getSymbol(locale), fiatCurrencySymbol) +} + // == Helpers == private fun BigDecimal.isLessThanThreshold() = this > BigDecimal.ZERO && this < FIAT_FORMAT_THRESHOLD diff --git a/core/ui/src/test/kotlin/com/tangem/core/ui/format/bigdecimal/BigDecimalFiatFormatTest.kt b/core/ui/src/test/kotlin/com/tangem/core/ui/format/bigdecimal/BigDecimalFiatFormatTest.kt index fcc2eca898..a5133d16d0 100644 --- a/core/ui/src/test/kotlin/com/tangem/core/ui/format/bigdecimal/BigDecimalFiatFormatTest.kt +++ b/core/ui/src/test/kotlin/com/tangem/core/ui/format/bigdecimal/BigDecimalFiatFormatTest.kt @@ -2,6 +2,9 @@ package com.tangem.core.ui.format.bigdecimal import com.google.common.truth.Truth import org.junit.Test +import org.junit.jupiter.params.ParameterizedTest +import org.junit.jupiter.params.provider.Arguments +import org.junit.jupiter.params.provider.MethodSource import java.math.BigDecimal import java.util.Locale @@ -342,4 +345,37 @@ internal class BigDecimalFiatFormatTest { Truth.assertThat(formatted) .isEqualTo("-" + "0.50".addUsdSymbolLeft()) } + + @ParameterizedTest + @MethodSource("provideTestCasesForOptionalDecimals") + fun `GIVEN amount WHEN format with optionalDecimals THEN correct answer`( + amount: String, + answer: String, + ) { + val testValue = BigDecimal(amount) + + val formatted = testValue.format { + fiat( + fiatCurrencyCode = usdCurrencyCode, + fiatCurrencySymbol = usdSymbol, + locale = testLocale, + ).optionalDecimals() + } + + Truth.assertThat(formatted).isEqualTo(answer) + } + + private companion object { + @JvmStatic + fun provideTestCasesForOptionalDecimals() = listOf( + Arguments.of("123", "$123"), + Arguments.of("123.1", "$123.10"), + Arguments.of("123.10", "$123.10"), + Arguments.of("123.456", "$123.46"), + Arguments.of("0", "$0"), + Arguments.of("0.1", "$0.10"), + Arguments.of("0.12", "$0.12"), + Arguments.of("0.127", "$0.13"), + ) + } } \ No newline at end of file diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/limit/setup/TangemPayCardLimitSetupModel.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/limit/setup/TangemPayCardLimitSetupModel.kt index ff13c3a2b7..cd273833c5 100644 --- a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/limit/setup/TangemPayCardLimitSetupModel.kt +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/limit/setup/TangemPayCardLimitSetupModel.kt @@ -9,10 +9,10 @@ import com.tangem.core.decompose.navigation.Router import com.tangem.core.decompose.ui.UiMessageSender import com.tangem.core.ui.extensions.TextReference import com.tangem.core.ui.extensions.WrappedList -import com.tangem.core.ui.format.bigdecimal.anyDecimals import com.tangem.core.ui.format.bigdecimal.fiat import com.tangem.core.ui.format.bigdecimal.format import com.tangem.core.ui.format.bigdecimal.getJavaCurrencyByCode +import com.tangem.core.ui.format.bigdecimal.optionalDecimals import com.tangem.core.ui.message.DialogMessage import com.tangem.domain.models.StatusSource import com.tangem.domain.models.account.PaymentAccountStatusValue @@ -170,7 +170,7 @@ internal class TangemPayCardLimitSetupModel @Inject constructor( id = R.string.tangempay_card_limit_setup_amount_subtitle, formatArgs = WrappedList( listOf( - MIN_LIMIT.format { fiat(currency.currencyCode, currency.symbol).anyDecimals(0) }, + MIN_LIMIT.format { fiat(currency.currencyCode, currency.symbol).optionalDecimals() }, ), ), ) @@ -179,8 +179,8 @@ internal class TangemPayCardLimitSetupModel @Inject constructor( id = R.string.tangempay_daily_limit_hint, formatArgs = WrappedList( listOf( - MIN_LIMIT.format { fiat(currency.currencyCode, currency.symbol).anyDecimals(0) }, - maxLimit.format { fiat(currency.currencyCode, currency.symbol).anyDecimals(0) }, + MIN_LIMIT.format { fiat(currency.currencyCode, currency.symbol).optionalDecimals() }, + maxLimit.format { fiat(currency.currencyCode, currency.symbol).optionalDecimals() }, ), ), ) @@ -193,7 +193,7 @@ internal class TangemPayCardLimitSetupModel @Inject constructor( BigDecimal("10000"), BigDecimal("25000"), ).map { preset -> - val label = preset.format { fiat(currency.currencyCode, currency.symbol).anyDecimals(0) } + val label = preset.format { fiat(currency.currencyCode, currency.symbol).optionalDecimals() } TangemPayCardLimitSetupUM.LimitPresetUM( label = label, onClick = { onPresetClick(preset) }, diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayCardPageModel.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayCardPageModel.kt index 14d8765c16..f5c7e0a5e5 100644 --- a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayCardPageModel.kt +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayCardPageModel.kt @@ -17,6 +17,7 @@ import com.tangem.core.ui.extensions.resourceReference import com.tangem.core.ui.format.bigdecimal.fiat import com.tangem.core.ui.format.bigdecimal.format import com.tangem.core.ui.format.bigdecimal.getJavaCurrencyByCode +import com.tangem.core.ui.format.bigdecimal.optionalDecimals import com.tangem.core.ui.message.SnackbarMessage import com.tangem.domain.models.StatusSource import com.tangem.domain.models.TokenReceiveConfig @@ -98,7 +99,7 @@ internal class TangemPayCardPageModel @Inject constructor( TangemPayDailyLimitBlockState.Content( limit = limit.amount.format { val symbol = getJavaCurrencyByCode(status.currencyCode).symbol - fiat(status.currencyCode, symbol) + fiat(status.currencyCode, symbol).optionalDecimals() }, onChangeClick = ::onClickLimitChange, ) From 06fd799843b266b4801f3a1086e9e7e1c9c692c4 Mon Sep 17 00:00:00 2001 From: Tangem Date: Fri, 8 May 2026 15:45:57 +0300 Subject: [PATCH 05/10] Updated on 2026-08-14 --- .../kotlin/com/tangem/scenarios/MarketsScenarios.kt | 3 +-- .../kotlin/com/tangem/tests/balance/TotalBalanceUpdateTest.kt | 2 +- .../kotlin/com/tangem/tests/markets/MarketsExchangesTest.kt | 3 ++- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/app/src/androidTest/kotlin/com/tangem/scenarios/MarketsScenarios.kt b/app/src/androidTest/kotlin/com/tangem/scenarios/MarketsScenarios.kt index 9c3b33b013..cda67fda49 100644 --- a/app/src/androidTest/kotlin/com/tangem/scenarios/MarketsScenarios.kt +++ b/app/src/androidTest/kotlin/com/tangem/scenarios/MarketsScenarios.kt @@ -3,7 +3,6 @@ package com.tangem.scenarios import com.tangem.common.BaseTestCase import com.tangem.common.extensions.SwipeDirection import com.tangem.common.extensions.clickWithAssertion -import com.tangem.common.extensions.swipeMarketsBlock import com.tangem.common.extensions.swipeVertical import com.tangem.screens.onMainScreen import com.tangem.screens.onMarketsExchangesScreen @@ -55,7 +54,7 @@ fun BaseTestCase.openMarketsScreen() { synchronizeAddresses() } step("Open 'Markets' screen") { - swipeMarketsBlock(SwipeDirection.UP) + onMainScreen { searchThroughMarketPlaceholder.performClick() } waitForIdle() } } diff --git a/app/src/androidTest/kotlin/com/tangem/tests/balance/TotalBalanceUpdateTest.kt b/app/src/androidTest/kotlin/com/tangem/tests/balance/TotalBalanceUpdateTest.kt index 45000fa4ad..b8a7826147 100644 --- a/app/src/androidTest/kotlin/com/tangem/tests/balance/TotalBalanceUpdateTest.kt +++ b/app/src/androidTest/kotlin/com/tangem/tests/balance/TotalBalanceUpdateTest.kt @@ -73,7 +73,7 @@ class TotalBalanceUpdateTest : BaseTestCase() { onMainScreen { totalBalanceText.assertTextContains(TOTAL_BALANCE) } } step("Open 'Markets screen'") { - swipeMarketsBlock(SwipeDirection.UP) + onMainScreen { searchThroughMarketPlaceholder.performClick() } waitForIdle() } step("Click on $tokenTitle token") { diff --git a/app/src/androidTest/kotlin/com/tangem/tests/markets/MarketsExchangesTest.kt b/app/src/androidTest/kotlin/com/tangem/tests/markets/MarketsExchangesTest.kt index fd470cb89d..fc17e54653 100644 --- a/app/src/androidTest/kotlin/com/tangem/tests/markets/MarketsExchangesTest.kt +++ b/app/src/androidTest/kotlin/com/tangem/tests/markets/MarketsExchangesTest.kt @@ -12,6 +12,7 @@ import com.tangem.scenarios.assertMarketsExchangesScreen import com.tangem.scenarios.openMainScreen import com.tangem.scenarios.openMarketsExchangesScreen import com.tangem.scenarios.synchronizeAddresses +import com.tangem.screens.onMainScreen import com.tangem.screens.onMarketsExchangesScreen import com.tangem.screens.onMarketsScreen import dagger.hilt.android.testing.HiltAndroidTest @@ -52,7 +53,7 @@ class MarketsExchangesTest : BaseTestCase() { synchronizeAddresses() } step("Open 'Markets' screen") { - swipeMarketsBlock(SwipeDirection.UP) + onMainScreen { searchThroughMarketPlaceholder.performClick() } waitForIdle() } step("Click on '$tokenName' token") { From f397a279b1569793033765ab3d281a5d6409d980 Mon Sep 17 00:00:00 2001 From: Tangem Date: Sat, 9 May 2026 01:01:51 +0500 Subject: [PATCH 06/10] Updated on 2026-08-14 --- .../feature/swap/domain/SwapInteractorImpl.kt | 34 ++++++++++--------- .../swap/model/SwapNotificationsFactory.kt | 16 +++++---- 2 files changed, 27 insertions(+), 23 deletions(-) diff --git a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/SwapInteractorImpl.kt b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/SwapInteractorImpl.kt index 9fc28e116c..09b5c11731 100644 --- a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/SwapInteractorImpl.kt +++ b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/SwapInteractorImpl.kt @@ -1223,6 +1223,23 @@ internal class SwapInteractorImpl @Inject constructor( }, ) + val fee = when (txFeeSealedState) { + is TxFeeSealedState.Component -> txFeeSealedState.txFee.fee.amount.value + is TxFeeSealedState.Legacy -> { + when (val txFee = txFeeSealedState.txFeeState) { + TxFeeState.Empty -> BigDecimal.ZERO + is TxFeeState.MultipleFeeState -> txFee.priorityFee.fee.amount.value + is TxFeeState.SingleFeeState -> txFee.fee.fee.amount.value + } + } + } + + val feeState = getFeeState( + fromSwapCurrencyStatus = fromSwapCurrencyStatus, + fee = fee, + spendAmount = amount, + ) + when (provider.type) { ExchangeProviderType.DEX, ExchangeProviderType.DEX_BRIDGE -> { val state = updatePermissionState( @@ -1236,26 +1253,11 @@ internal class SwapInteractorImpl @Inject constructor( state.copy( preparedSwapConfigState = state.preparedSwapConfigState.copy( isBalanceEnough = isBalanceWithoutFeeEnough, + feeState = feeState, ), ) } ExchangeProviderType.CEX -> { - val fee = when (txFeeSealedState) { - is TxFeeSealedState.Component -> txFeeSealedState.txFee.fee.amount.value - is TxFeeSealedState.Legacy -> { - when (val txFee = txFeeSealedState.txFeeState) { - TxFeeState.Empty -> BigDecimal.ZERO - is TxFeeState.MultipleFeeState -> txFee.priorityFee.fee.amount.value - is TxFeeState.SingleFeeState -> txFee.fee.fee.amount.value - } - } - } - - val feeState = getFeeState( - fromSwapCurrencyStatus = fromSwapCurrencyStatus, - fee = fee, - spendAmount = amount, - ) swapState.copy( permissionState = PermissionDataState.Empty, preparedSwapConfigState = PreparedSwapConfigState( diff --git a/features/swap/impl/src/main/java/com/tangem/feature/swap/model/SwapNotificationsFactory.kt b/features/swap/impl/src/main/java/com/tangem/feature/swap/model/SwapNotificationsFactory.kt index 2bff6bb705..6f203e2e72 100644 --- a/features/swap/impl/src/main/java/com/tangem/feature/swap/model/SwapNotificationsFactory.kt +++ b/features/swap/impl/src/main/java/com/tangem/feature/swap/model/SwapNotificationsFactory.kt @@ -306,23 +306,25 @@ internal class SwapNotificationsFactory( ) { if (hideFee) return val fromCurrency = quoteModel.fromTokenInfo.swapCurrencyStatus.currency - val feeEnoughState = quoteModel.preparedSwapConfigState.feeState as? SwapFeeState.NotEnough ?: return - val shouldShowCoverWarning = quoteModel.preparedSwapConfigState.isBalanceEnough && + val feeEnoughState = quoteModel.preparedSwapConfigState.feeState as? SwapFeeState.NotEnough + val shouldShowCoverWarning = !quoteModel.preparedSwapConfigState.isBalanceEnough && quoteModel.permissionState !is PermissionDataState.PermissionLoading && feeCryptoCurrencyStatus?.currency != fromCurrency - val isNotEnoughFee = + val isCEXProvider = quoteModel.swapProvider.type == ExchangeProviderType.CEX + + val isNotEnoughFee = feeEnoughState is SwapFeeState.NotEnough && !isCEXProvider || quoteModel.preparedSwapConfigState.includeFeeInAmount is IncludeFeeInAmount.BalanceNotEnough - val isGaslessAvailable = iGaslessFeeSupportedForNetwork(fromCurrency.network) && - quoteModel.swapProvider.type == ExchangeProviderType.CEX + val isGaslessAvailable = iGaslessFeeSupportedForNetwork(fromCurrency.network) && isCEXProvider + if (shouldShowCoverWarning && !isGaslessAvailable || isNotEnoughFee) { add( SwapNotificationUM.Error.UnableToCoverFeeWarning( fromToken = fromCurrency, feeCurrency = feeCryptoCurrencyStatus?.currency, - currencyName = feeEnoughState.currencyName ?: fromCurrency.network.name, - currencySymbol = feeEnoughState.currencySymbol ?: fromCurrency.network.currencySymbol, + currencyName = feeEnoughState?.currencyName ?: fromCurrency.network.name, + currencySymbol = feeEnoughState?.currencySymbol ?: fromCurrency.network.currencySymbol, onConfirmClick = actions.onBuyClick, ), ) From ae6ec0e02a7d87539aea7b49f8dd8dedb0b44f54 Mon Sep 17 00:00:00 2001 From: Tangem Date: Tue, 12 May 2026 10:16:33 +0100 Subject: [PATCH 07/10] Updated on 2026-08-14 --- .../src/main/res/values-pt-rBR/strings.xml | 13 + core/res/src/main/res/values-ru/strings.xml | 3 + core/res/src/main/res/values/strings.xml | 4 + .../analytics/WalletSettingsAnalyticEvents.kt | 28 +- features/hot-wallet/impl/build.gradle.kts | 11 + ...letBackupUM.kt => WalletBackupContract.kt} | 2 +- .../walletbackup/model/WalletBackupModel.kt | 33 ++- .../walletbackup/ui/WalletBackupContent.kt | 8 +- .../model/WalletBackupModelTest.kt | 278 ++++++++++++++++++ 9 files changed, 356 insertions(+), 24 deletions(-) rename features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/walletbackup/entity/{WalletBackupUM.kt => WalletBackupContract.kt} (95%) create mode 100644 features/hot-wallet/impl/src/test/kotlin/com/tangem/features/hotwallet/walletbackup/model/WalletBackupModelTest.kt diff --git a/core/res/src/main/res/values-pt-rBR/strings.xml b/core/res/src/main/res/values-pt-rBR/strings.xml index 45dbb43d03..4cd264c962 100644 --- a/core/res/src/main/res/values-pt-rBR/strings.xml +++ b/core/res/src/main/res/values-pt-rBR/strings.xml @@ -517,6 +517,7 @@ Foram encontrados fundos em endereços adicionais. Ative os Endereços Dinâmicos para acessá-los. Fundos encontrados em endereços adicionais Endereço dinâmico + O gerenciamento de endereços dinâmicos estará disponível assim que as transações pendentes estiverem na rede. %@ está completo Melhores oportunidades Limpar filtro A lista está temporariamente vazia, pois está sendo atualizada. Volte daqui a pouco. @@ -595,6 +596,7 @@ Disponível em %s Indisponível para este par Permissão necessária + É necessária permissão. Recomendado Comprado %s Comprando %s @@ -641,6 +643,7 @@ A função Aprovar é necessária para conceder permissão a outro endereço para usar uma quantidade específica de seus tokens. Por definição, os contratos inteligentes não podem acessar seus tokens a menos que você aprove. Ao \"desbloquear\" seus tokens, você autoriza o contrato inteligente StakeKit a usá-los. Os mineradores da rede recebem uma taxa de gás (paga por você) para registrar essa ação no blockchain. Você pode fazer staking de seus tokens após conceder a aprovação. Para continuar, você precisa permitir que o contrato inteligente da Polygon use seus dados. %s Para continuar, conceda %1s permissão de contratos inteligentes para usar seu %2s + As corretoras descentralizadas exigem permissão para interagir com sua carteira. %1s Conceder permissão Ilimitado Os endereços são gerados diretamente na sua carteira de hardware Tangem — prontos para usar e totalmente protegidos. @@ -1115,6 +1118,14 @@ Esta transação já foi processada. Nenhuma ação adicional é necessária. Obtendo as melhores taxas... Instantâneo + A verificação é gratuita e geralmente leva de 1 a 2 minutos. + A Tangem não terá acesso às suas informações de identidade; você compartilha os dados diretamente com o provedor regulamentado. + A verificação desbloqueia o acesso total a transações futuras com este fornecedor. + Escolha outro método + Para cumprir os requisitos regulamentares locais %@ Requer verificação de identidade. + Verificação de identidade exigida pelo provedor de pagamento + Verificar + O que é importante Ao utilizar a funcionalidade de acesso prioritário, você concorda com os termos do provedor. %1$s e %2$s O serviço é fornecido por um provedor externo. A Tangem não se responsabiliza por ele. O valor da compra não deve ser superior a %s @@ -1181,6 +1192,8 @@ Cartão de crédito ou conta bancária Compartilhe seu endereço ou código QR. Entre seus portfólios + Outro + Recarga rápida Não é necessário memorando %1$s (%2$s) sobre %3$s rede %1$s sobre %2$s rede diff --git a/core/res/src/main/res/values-ru/strings.xml b/core/res/src/main/res/values-ru/strings.xml index 0a6290a4fb..b8e2e9e0e4 100644 --- a/core/res/src/main/res/values-ru/strings.xml +++ b/core/res/src/main/res/values-ru/strings.xml @@ -293,11 +293,13 @@ Удалить Отключить Отключено + Выключение Отключить Готово Изменить Включить Включено + Включение Ошибка Комиссия сети Обменять @@ -347,6 +349,7 @@ %dмин назад месяц + Еще Комиссия сети Сумма отправки будет уменьшена на %1$s (%2$s) для покрытия выбранного уровня комиссии diff --git a/core/res/src/main/res/values/strings.xml b/core/res/src/main/res/values/strings.xml index e20ccf50f8..f8c7c79e89 100644 --- a/core/res/src/main/res/values/strings.xml +++ b/core/res/src/main/res/values/strings.xml @@ -596,6 +596,7 @@ Available from %s Unavailable for this pair Permission Required + Permission needed Recommended Bought %s Buying %s @@ -643,6 +644,7 @@ The Approve function is needed to grant permission to another address to use a specific amount of your tokens. By design, smart contracts can\'t access your tokens unless you approve. By \"unlocking\" your tokens, you authorize the StakeKit smart contract to use them. The network\'s miners receive a gas fee (paid by you) to record this action on the blockchain. You can stake your token after giving approval. To continue you need to allow Polygon smart contract to use your %s To continue, grant %1s smart contracts permission to use your %2s + Decentralized exchanges require permission to interact with your wallet. %1s Give Permission Unlimited Addresses are generated directly on your Tangem hardware wallet — ready to use and fully protected. @@ -670,6 +672,8 @@ Keeps your crypto safe and offline. Slim as a credit card, safer than a bank vault. If you do, you\'ll need to start over. Recover existing wallet via Google Drive backup + We\'re working on Google Drive backup to make wallet recovery even easier. + Google Drive backup is coming soon Google Drive backup Create a secure wallet and transfer your funds for extra protection. Create new wallet diff --git a/domain/wallets/src/main/java/com/tangem/domain/wallets/analytics/WalletSettingsAnalyticEvents.kt b/domain/wallets/src/main/java/com/tangem/domain/wallets/analytics/WalletSettingsAnalyticEvents.kt index 850782d36a..b8c7e1c1f4 100644 --- a/domain/wallets/src/main/java/com/tangem/domain/wallets/analytics/WalletSettingsAnalyticEvents.kt +++ b/domain/wallets/src/main/java/com/tangem/domain/wallets/analytics/WalletSettingsAnalyticEvents.kt @@ -64,14 +64,18 @@ sealed class WalletSettingsAnalyticEvents( event = "Button - Recovery phrase", ) + class ButtonGoogleDriveBackup : WalletSettingsAnalyticEvents( + event = "Button - Cloud Backup", + ) + data class NoticeBackupFirst( val source: String, val action: Action, ) : WalletSettingsAnalyticEvents( event = "Notice - Backup First", params = mapOf( - AnalyticsParam.Key.SOURCE to source, - AnalyticsParam.Key.ACTION to action.value, + AnalyticsParam.SOURCE to source, + ACTION to action.value, ), ) { enum class Action(val value: String) { @@ -111,8 +115,8 @@ sealed class WalletSettingsAnalyticEvents( ) : WalletSettingsAnalyticEvents( event = "Recovery Phrase Screen Info", params = mapOf( - AnalyticsParam.Key.SOURCE to source, - AnalyticsParam.Key.ACTION to action, + AnalyticsParam.SOURCE to source, + ACTION to action, ), ) @@ -122,8 +126,8 @@ sealed class WalletSettingsAnalyticEvents( ) : WalletSettingsAnalyticEvents( event = "Recovery Phrase Screen", params = mapOf( - AnalyticsParam.Key.SOURCE to source, - AnalyticsParam.Key.ACTION to action, + AnalyticsParam.SOURCE to source, + ACTION to action, ), ) @@ -133,8 +137,8 @@ sealed class WalletSettingsAnalyticEvents( ) : WalletSettingsAnalyticEvents( event = "Recovery Phrase Check", params = mapOf( - AnalyticsParam.Key.SOURCE to source, - AnalyticsParam.Key.ACTION to action, + AnalyticsParam.SOURCE to source, + ACTION to action, ), ) @@ -144,8 +148,8 @@ sealed class WalletSettingsAnalyticEvents( ) : WalletSettingsAnalyticEvents( event = "Backup Complete Screen", params = mapOf( - AnalyticsParam.Key.SOURCE to source, - AnalyticsParam.Key.ACTION to action, + AnalyticsParam.SOURCE to source, + ACTION to action, ), ) @@ -153,14 +157,14 @@ sealed class WalletSettingsAnalyticEvents( val source: String, ) : WalletSettingsAnalyticEvents( event = "Access Code Screen Opened", - params = mapOf(AnalyticsParam.Key.SOURCE to source), + params = mapOf(AnalyticsParam.SOURCE to source), ) data class ReEnterAccessCodeScreen( val source: String, ) : WalletSettingsAnalyticEvents( event = "Re-enter Access Code Screen", - params = mapOf(AnalyticsParam.Key.SOURCE to source), + params = mapOf(AnalyticsParam.SOURCE to source), ) class ButtonStartUpgrade : WalletSettingsAnalyticEvents( diff --git a/features/hot-wallet/impl/build.gradle.kts b/features/hot-wallet/impl/build.gradle.kts index 9d2eadef6e..5fb36dc68f 100644 --- a/features/hot-wallet/impl/build.gradle.kts +++ b/features/hot-wallet/impl/build.gradle.kts @@ -11,6 +11,10 @@ android { namespace = "com.tangem.features.hotwallet.impl" } +tasks.withType().configureEach { + useJUnitPlatform() +} + dependencies { /** Api */ implementation(projects.features.hotWallet.api) @@ -78,4 +82,11 @@ dependencies { /** DI */ implementation(deps.hilt.android) kapt(deps.hilt.kapt) + + /** Test */ + testImplementation(deps.test.junit5) + testRuntimeOnly(deps.test.junit5.engine) + testImplementation(deps.test.mockk) + testImplementation(deps.test.truth) + testImplementation(deps.test.coroutine) } \ No newline at end of file diff --git a/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/walletbackup/entity/WalletBackupUM.kt b/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/walletbackup/entity/WalletBackupContract.kt similarity index 95% rename from features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/walletbackup/entity/WalletBackupUM.kt rename to features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/walletbackup/entity/WalletBackupContract.kt index a1219ed46a..a7a4115f71 100644 --- a/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/walletbackup/entity/WalletBackupUM.kt +++ b/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/walletbackup/entity/WalletBackupContract.kt @@ -11,7 +11,7 @@ internal data class WalletBackupUM( val onRecoveryPhraseClick: () -> Unit, val onGoogleDriveClick: () -> Unit, val onHardwareWalletClick: () -> Unit, - val backedUp: Boolean, + val isBackedUp: Boolean, ) internal sealed class BackupStatus { diff --git a/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/walletbackup/model/WalletBackupModel.kt b/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/walletbackup/model/WalletBackupModel.kt index 32986a6549..6eb1b82ac3 100644 --- a/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/walletbackup/model/WalletBackupModel.kt +++ b/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/walletbackup/model/WalletBackupModel.kt @@ -7,10 +7,13 @@ import com.tangem.core.decompose.di.ModelScoped import com.tangem.core.decompose.model.Model import com.tangem.core.decompose.model.ParamsContainer import com.tangem.core.decompose.navigation.Router +import com.tangem.core.decompose.ui.UiMessageSender import com.tangem.core.ui.R import com.tangem.core.ui.components.label.entity.LabelStyle import com.tangem.core.ui.components.label.entity.LabelUM import com.tangem.core.ui.extensions.resourceReference +import com.tangem.core.ui.message.DialogMessage +import com.tangem.core.ui.message.EventMessageAction import com.tangem.domain.models.wallet.UserWallet import com.tangem.domain.wallets.analytics.WalletSettingsAnalyticEvents import com.tangem.domain.wallets.usecase.GetUserWalletUseCase @@ -19,9 +22,9 @@ import com.tangem.features.hotwallet.WalletBackupComponent import com.tangem.features.hotwallet.walletbackup.entity.BackupStatus import com.tangem.features.hotwallet.walletbackup.entity.WalletBackupUM import com.tangem.utils.coroutines.CoroutineDispatcherProvider +import com.tangem.utils.logging.TangemLogger import kotlinx.coroutines.flow.* import kotlinx.coroutines.launch -import com.tangem.utils.logging.TangemLogger import java.util.concurrent.atomic.AtomicBoolean import javax.inject.Inject @@ -35,6 +38,7 @@ internal class WalletBackupModel @Inject constructor( private val router: Router, private val trackingContextProxy: TrackingContextProxy, private val analyticsEventHandler: AnalyticsEventHandler, + private val uiMessageSender: UiMessageSender, ) : Model() { private val params: WalletBackupComponent.Params = paramsContainer.require() @@ -59,9 +63,9 @@ internal class WalletBackupModel @Inject constructor( ), googleDriveStatus = BackupStatus.ComingSoon, onRecoveryPhraseClick = ::onRecoveryPhraseClick, - onGoogleDriveClick = { }, + onGoogleDriveClick = ::onGoogleDriveBackupClick, onHardwareWalletClick = ::onHardwareWalletClick, - backedUp = false, + isBackedUp = false, ), ) @@ -116,15 +120,16 @@ internal class WalletBackupModel @Inject constructor( ) }, googleDriveOption = LabelUM( - text = resourceReference(R.string.common_coming_soon), - style = LabelStyle.REGULAR, + text = resourceReference(R.string.hw_backup_no_backup), + style = LabelStyle.WARNING, ), - backedUp = userWallet.backedUp, + isBackedUp = userWallet.backedUp, + googleDriveStatus = BackupStatus.NoBackup, ) private fun onRecoveryPhraseClick() { analyticsEventHandler.send(WalletSettingsAnalyticEvents.ButtonRecoveryPhrase()) - if (uiState.value.backedUp) { + if (uiState.value.isBackedUp) { getUserWalletUseCase.invoke(params.userWalletId) .fold( ifLeft = { @@ -150,6 +155,20 @@ internal class WalletBackupModel @Inject constructor( } } + private fun onGoogleDriveBackupClick() { + analyticsEventHandler.send(WalletSettingsAnalyticEvents.ButtonGoogleDriveBackup()) + uiMessageSender.send( + DialogMessage( + title = resourceReference(id = R.string.hw_backup_google_drive_dialog_title), + message = resourceReference(id = R.string.hw_backup_google_drive_dialog_message), + firstAction = EventMessageAction( + title = resourceReference(id = R.string.common_ok), + onClick = {}, + ), + ), + ) + } + private fun showSeedPhrase(hotWallet: UserWallet.Hot) { modelScope.launch { unlockHotWalletContextualUseCase.invoke(hotWallet.hotWalletId) diff --git a/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/walletbackup/ui/WalletBackupContent.kt b/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/walletbackup/ui/WalletBackupContent.kt index dc2787f9c6..59fb6abcdd 100644 --- a/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/walletbackup/ui/WalletBackupContent.kt +++ b/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/walletbackup/ui/WalletBackupContent.kt @@ -132,7 +132,7 @@ private class WalletBackupUMProvider : CollectionPreviewParameterProvider() } returns params + every { getUserWalletUseCase.invokeFlow(walletId) } returns flowOf(hotWalletNotBackedUp.right()) + } + + @Test + fun `GIVEN hot wallet WHEN model is created THEN context added AND BackupScreenOpened sent AND state updated`() = + runTest { + every { getUserWalletUseCase.invokeFlow(walletId) } returns flowOf(hotWalletNotBackedUp.right()) + + val model = createModel(this) + advanceUntilIdle() + + verify { trackingContextProxy.addHotWalletContext() } + verify { + analyticsEventHandler.send(WalletSettingsAnalyticEvents.BackupScreenOpened(isBackedUp = false)) + } + val state = model.uiState.value + Assertions.assertEquals(false, state.isBackedUp) + Assertions.assertEquals(BackupStatus.NoBackup, state.googleDriveStatus) + } + + @Test + fun `GIVEN cold wallet WHEN model is created THEN context added AND BackupScreenOpened not sent AND state untouched`() = + runTest { + every { getUserWalletUseCase.invokeFlow(walletId) } returns flowOf(coldWallet.right()) + + val model = createModel(this) + advanceUntilIdle() + + verify { trackingContextProxy.addHotWalletContext() } + verify(exactly = 0) { + analyticsEventHandler.send(match { true }) + } + val state = model.uiState.value + Assertions.assertEquals(false, state.isBackedUp) + Assertions.assertEquals(BackupStatus.ComingSoon, state.googleDriveStatus) + } + + @Test + fun `GIVEN error WHEN model is created THEN context added AND BackupScreenOpened not sent`() = runTest { + every { getUserWalletUseCase.invokeFlow(walletId) } returns flowOf(GetUserWalletError.UserWalletNotFound.left()) + + createModel(this) + advanceUntilIdle() + + verify { trackingContextProxy.addHotWalletContext() } + verify(exactly = 0) { + analyticsEventHandler.send(match { true }) + } + } + + @Test + fun `WHEN onDestroy THEN trackingContextProxy removeContext is called`() = runTest { + val model = createModel(this) + advanceUntilIdle() + + model.onDestroy() + + verify { trackingContextProxy.removeContext() } + } + + @Test + fun `GIVEN backed up hot wallet AND unlock success WHEN onRecoveryPhraseClick THEN ViewPhrase pushed`() = runTest { + every { getUserWalletUseCase.invokeFlow(walletId) } returns flowOf(hotWalletBackedUp.right()) + every { getUserWalletUseCase.invoke(walletId) } returns hotWalletBackedUp.right() + coEvery { unlockHotWalletContextualUseCase.invoke(hotWalletId) } returns mockk().right() + + val model = createModel(this) + advanceUntilIdle() + + model.uiState.value.onRecoveryPhraseClick() + advanceUntilIdle() + + verify { analyticsEventHandler.send(match { true }) } + coVerify { unlockHotWalletContextualUseCase.invoke(hotWalletId) } + verify { router.push(route = AppRoute.ViewPhrase(userWalletId = walletId), onComplete = any()) } + } + + @Test + fun `GIVEN backed up hot wallet AND unlock failure WHEN onRecoveryPhraseClick THEN ViewPhrase not pushed`() = + runTest { + every { getUserWalletUseCase.invokeFlow(walletId) } returns flowOf(hotWalletBackedUp.right()) + every { getUserWalletUseCase.invoke(walletId) } returns hotWalletBackedUp.right() + coEvery { unlockHotWalletContextualUseCase.invoke(hotWalletId) } returns Throwable("error").left() + + val model = createModel(this) + advanceUntilIdle() + + model.uiState.value.onRecoveryPhraseClick() + advanceUntilIdle() + + verify { analyticsEventHandler.send(match { true }) } + coVerify { unlockHotWalletContextualUseCase.invoke(hotWalletId) } + verify(exactly = 0) { + router.push(route = AppRoute.ViewPhrase(userWalletId = walletId), onComplete = any()) + } + } + + @Test + fun `GIVEN backed up cold wallet WHEN onRecoveryPhraseClick THEN no navigation AND no unlock`() = runTest { + every { getUserWalletUseCase.invokeFlow(walletId) } returns flowOf(hotWalletBackedUp.right()) + every { getUserWalletUseCase.invoke(walletId) } returns coldWallet.right() + + val model = createModel(this) + advanceUntilIdle() + + model.uiState.value.onRecoveryPhraseClick() + advanceUntilIdle() + + verify { analyticsEventHandler.send(match { true }) } + coVerify(exactly = 0) { unlockHotWalletContextualUseCase.invoke(any()) } + verify(exactly = 0) { router.push(route = any(), onComplete = any()) } + } + + @Test + fun `GIVEN not backed up wallet WHEN onRecoveryPhraseClick THEN WalletActivation pushed`() = runTest { + val model = createModel(this) + advanceUntilIdle() + + model.uiState.value.onRecoveryPhraseClick() + advanceUntilIdle() + + verify { analyticsEventHandler.send(match { true }) } + verify(exactly = 0) { getUserWalletUseCase.invoke(walletId) } + coVerify(exactly = 0) { unlockHotWalletContextualUseCase.invoke(any()) } + verify { + router.push( + route = AppRoute.WalletActivation(userWalletId = walletId, isBackupExists = false), + onComplete = any(), + ) + } + } + + @Test + fun `WHEN onHardwareWalletClick THEN ButtonHardwareUpdate sent AND WalletHardwareBackup pushed`() = runTest { + val model = createModel(this) + advanceUntilIdle() + + model.uiState.value.onHardwareWalletClick() + + verify { analyticsEventHandler.send(match { true }) } + verify { + router.push( + route = AppRoute.WalletHardwareBackup(userWalletId = walletId), + onComplete = any(), + ) + } + } + + @Test + fun `WHEN onGoogleDriveClick THEN DialogMessage sent AND analytics sent`() = runTest { + val model = createModel(this) + advanceUntilIdle() + + model.uiState.value.onGoogleDriveClick() + + verify { + analyticsEventHandler.send( + event = match { true } + ) + } + verify { + uiMessageSender.send( + match { + val isTitleCorrect = it.title == resourceReference( + id = R.string.hw_backup_google_drive_dialog_title + ) + val isMessageCorrect = it.message == resourceReference( + id = R.string.hw_backup_google_drive_dialog_message + ) + isTitleCorrect && isMessageCorrect + } + ) + } + } + + @Test + fun `WHEN onBackClick THEN router pop is called`() = runTest { + val model = createModel(this) + advanceUntilIdle() + + model.uiState.value.onBackClick() + + verify { router.pop(onComplete = any()) } + } + + private fun createModel(testScope: TestScope): WalletBackupModel { + return WalletBackupModel( + paramsContainer = paramsContainer, + dispatchers = testScope.createTestingCoroutineDispatcherProvider(), + getUserWalletUseCase = getUserWalletUseCase, + unlockHotWalletContextualUseCase = unlockHotWalletContextualUseCase, + router = router, + trackingContextProxy = trackingContextProxy, + analyticsEventHandler = analyticsEventHandler, + uiMessageSender = uiMessageSender, + ) + } + + private fun TestScope.createTestingCoroutineDispatcherProvider(): TestingCoroutineDispatcherProvider { + val testDispatcher = StandardTestDispatcher(testScheduler) + return TestingCoroutineDispatcherProvider( + main = testDispatcher, + mainImmediate = testDispatcher, + io = testDispatcher, + default = testDispatcher, + single = testDispatcher, + ) + } +} \ No newline at end of file From 2008b259e2b67c09827a9f72c383e6e7db3ff6dc Mon Sep 17 00:00:00 2001 From: Tangem Date: Tue, 12 May 2026 14:46:45 +0300 Subject: [PATCH 08/10] Updated on 2026-08-14 --- .../wallet/presentation/wallet/domain/Wallet2CobrandImage.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/domain/Wallet2CobrandImage.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/domain/Wallet2CobrandImage.kt index c844afcbdf..d802da3a1a 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/domain/Wallet2CobrandImage.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/domain/Wallet2CobrandImage.kt @@ -354,7 +354,7 @@ internal enum class Wallet2CobrandImage( WinterSakura( cards2ResId = R.drawable.ill_winter_sakura_card2_120_106, cards3ResId = R.drawable.ill_winter_sakura_card3_120_106, - batchIds = setOf("AF990053", "AF990054", "AF990055"), + batchIds = setOf("AF990053", "AF990054", "AF990055", "AF990074", "AF990075", "AF990076"), ), LockedMoney( From 183ab2eab217211ac7d2d1ec578c852bbeff5381 Mon Sep 17 00:00:00 2001 From: Tangem Date: Tue, 12 May 2026 17:55:07 +0300 Subject: [PATCH 09/10] Updated on 2026-08-14 --- gradle/tangem_dependencies.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle/tangem_dependencies.toml b/gradle/tangem_dependencies.toml index 7179ef2644..76d48569f9 100644 --- a/gradle/tangem_dependencies.toml +++ b/gradle/tangem_dependencies.toml @@ -5,7 +5,7 @@ # https://github.com/tangem/tangem-sdk-android/ # https://github.com/tangem/vico -tangemBlockchainSdk = "releases-5.38-1503" +tangemBlockchainSdk = "releases-5.38-1512" #tangemBlockchainSdk = "0.0.1" # Keep it! - used for local builds tangemCardSdk = "releases-5.38-615" #tangemCardSdk = "0.0.1" # Keep it! - used for local builds ^ From 334bc45dabd96900f266e05fec45d5b8822d3292 Mon Sep 17 00:00:00 2001 From: Tangem Date: Wed, 13 May 2026 07:38:05 +0000 Subject: [PATCH 10/10] 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 76d48569f9..8fbd09509c 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.38-1512" +tangemBlockchainSdk = "develop-1506" #tangemBlockchainSdk = "0.0.1" # Keep it! - used for local builds -tangemCardSdk = "releases-5.38-615" +tangemCardSdk = "develop-614" #tangemCardSdk = "0.0.1" # Keep it! - used for local builds ^ tangemVico = "tangem-master-21" #tangemVico = "0.0.1" # Keep it! - used for local builds ^