Updated on 2026-08-14

This commit is contained in:
Tangem 2026-04-28 16:27:18 +04:00
commit ace91fb278
43 changed files with 965 additions and 197 deletions

View file

@ -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")

View file

@ -17,6 +17,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,

View file

@ -10,15 +10,15 @@ class PeriodicTask<T>(
private val task: suspend () -> Result<T>,
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()

View file

@ -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<Throwable>(1)
val successes = AtomicInteger(0)
val periodicTask = PeriodicTask<Int>(
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
}
}

View file

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

View file

@ -56,6 +56,7 @@ internal class PaymentAccountStatusValueDMConverter @Inject constructor(
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,
@ -67,6 +68,7 @@ internal class PaymentAccountStatusValueDMConverter @Inject constructor(
fun convertBack(userWalletId: UserWalletId, 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,

View file

@ -11,11 +11,13 @@ import com.tangem.domain.models.account.PaymentAccountStatusValue
import com.tangem.domain.models.kyc.KycStatus
import com.tangem.domain.models.pay.TangemPayCard
import com.tangem.domain.models.pay.TangemPayCardLimitData
import com.tangem.domain.pay.TangemPayEligibilityManager
import com.tangem.domain.models.wallet.UserWalletId
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
@ -32,6 +34,7 @@ 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,
@ -39,6 +42,7 @@ internal class DefaultPaymentAccountStatusFetcher @Inject constructor(
private val deviceSecurity: DeviceSecurityInfoProvider,
private val dispatchers: CoroutineDispatcherProvider,
private val tangemPayCurrencyFactory: TangemPayCurrencyFactory,
private val eligibilityManager: TangemPayEligibilityManager,
) : PaymentAccountStatusFetcher {
private val logger = TangemLogger.withTag(TAG)
@ -48,6 +52,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}")
@ -62,22 +76,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
}
},
@ -105,7 +109,7 @@ internal class DefaultPaymentAccountStatusFetcher @Inject constructor(
return if (hasTangemPay) {
fetchTangemPayAccountStatus(account)
} else {
PaymentAccountStatusValue.NotCreated
constructNotCreatedOrEmptyStatus(account.userWalletId)
}
}
@ -138,7 +142,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}")
@ -158,7 +162,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 },
)
@ -177,7 +181,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}")
@ -246,7 +250,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(account.userWalletId) },
)
}
@ -303,12 +307,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
}
}

View file

@ -6,6 +6,7 @@ import arrow.core.getOrElse
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
@ -17,6 +18,8 @@ import com.tangem.datasource.local.visa.TangemPayStorage
import com.tangem.domain.common.wallets.UserWalletsListRepository
import com.tangem.domain.models.pay.TangemPayEligibilityType
import com.tangem.domain.models.account.CardDisplayName
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
@ -48,6 +51,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
@ -285,6 +289,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<VisaApiError, Unit> {

View file

@ -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,16 @@ 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.UnderReview,
-> true
}
}
}

View file

@ -90,6 +90,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(
@ -210,6 +222,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(

View file

@ -26,6 +26,7 @@ sealed class PaymentAccountStatusValue {
get() = when (this) {
is Error,
is IssuingCard,
is Empty,
is NotCreated,
is UnderReview,
-> TotalFiatBalance.Loaded(amount = SerializedBigDecimal.ZERO, source = source)
@ -44,12 +45,19 @@ sealed class PaymentAccountStatusValue {
is Loaded -> 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() {

View file

@ -10,6 +10,10 @@ android {
namespace = "com.tangem.features.approval.impl"
}
tasks.withType<Test>().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
}

View file

@ -35,6 +35,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.coroutines.CoroutineDispatcherProvider
import com.tangem.utils.logging.TangemLogger
import kotlinx.coroutines.flow.MutableStateFlow
@ -61,6 +62,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()
@ -111,7 +113,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() {

View file

@ -0,0 +1,112 @@
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 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<CryptoCurrency.Token>(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,
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() }
}
}

View file

@ -120,7 +120,6 @@ internal class OnrampSuccessComponentModel @Inject constructor(
expressTxStatusTaskScheduler.scheduleTask(
modelScope,
PeriodicTask(
isDelayFirst = false,
delay = EXPRESS_STATUS_UPDATE_DELAY,
task = {
runSuspendCatching {

View file

@ -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<Params, PromoBannersBlockComponent>

View file

@ -21,6 +21,8 @@ import java.util.Locale
import java.util.concurrent.ConcurrentHashMap
import javax.inject.Inject
private typealias ShownBannerKey = Pair<String, Int>
@ModelScoped
internal class PromoBannersBlockModel @Inject constructor(
override val dispatchers: CoroutineDispatcherProvider,
@ -34,8 +36,8 @@ internal class PromoBannersBlockModel @Inject constructor(
private val params = paramsContainer.require<PromoBannersBlockComponent.Params>()
private val converter = PromoBannerDisplayToNotificationConverter()
private val placeholder: String = params.placeholder.name.lowercase()
private val shownBannerIds: MutableSet<Int> = ConcurrentHashMap.newKeySet()
private val placeholderName: String = params.placeholder.value
private val shownBannerIds: MutableSet<ShownBannerKey> = ConcurrentHashMap.newKeySet()
private var isVisibleOnScreen: Boolean = params.isInitiallyVisibleOnScreen
private var wasCarouselScrolled = false
private val savedDisplayIdByWalletId: MutableMap<String, Int> = 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

View file

@ -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"
}
}

View file

@ -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,
)
}

View file

@ -34,8 +34,8 @@ class FeeExtendedSelectorModel @Inject constructor(
initAppCurrency()
}
val uiState: StateFlow<FeeExtendedSelectorUM>
field = MutableStateFlow<FeeExtendedSelectorUM>(getInitialState())
val uiState: StateFlow<FeeExtendedSelectorUM?>
field = MutableStateFlow<FeeExtendedSelectorUM?>(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)
}

View file

@ -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,
)

View file

@ -41,7 +41,7 @@ internal class FeeTokenSelectorModel @Inject constructor(
private val params = paramsContainer.require<FeeSelectorComponentParams>()
private var appCurrency = AppCurrency.Default
val uiState: StateFlow<FeeTokenSelectorUM>
val uiState: StateFlow<FeeTokenSelectorUM?>
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)
}

View file

@ -68,7 +68,7 @@ internal fun StakingConfirmationContent(
)
ValidatorBlock(
validatorState = validatorState,
isClickable = !isTransactionInProgress,
isClickable = !isTransactionSent && !isTransactionInProgress,
onClick = clickIntents::openValidators,
)
StakingFeeBlock(feeState = state.feeState)

View file

@ -60,7 +60,7 @@ internal fun StakingSuccessContent(
)
ValidatorBlock(
validatorState = validatorState,
isClickable = !isTransactionInProgress,
isClickable = !isTransactionSent && !isTransactionInProgress,
onClick = clickIntents::openValidators,
)
StakingFeeBlock(feeState = state.feeState)

View file

@ -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<SwapQuoteUM>, 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<SwapQuoteUM>,
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<SwapQuoteUM>,
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<SwapQuoteUM>, 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}"
}
}
}

View file

@ -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
@ -152,11 +151,15 @@ 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
}
configAmountNavigation()
quoteTaskScheduler.scheduleTask(
scope = modelScope,
task = loadQuotesTask(isDelayFirst = isDelayFirst),
task = loadQuotesTask(initialDelay = initialDelay),
)
subscribeOnAutoupdateEnabling()
}
@ -618,18 +621,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<SwapQuoteUM>) {
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
@ -713,7 +733,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) {
@ -785,8 +807,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()
}
@ -835,10 +856,10 @@ internal class SwapAmountModel @Inject constructor(
)
}
private fun loadQuotesTask(isDelayFirst: Boolean = true): PeriodicTask<Unit> {
private fun loadQuotesTask(initialDelay: Long = QUOTES_UPDATE_DELAY): PeriodicTask<Unit> {
return PeriodicTask(
delay = QUOTES_UPDATE_DELAY,
isDelayFirst = isDelayFirst,
initialDelay = initialDelay,
task = {
runCatching { loadQuotes(isSilentReload = true) }
},
@ -919,5 +940,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
}
}

View file

@ -61,9 +61,12 @@ internal class SwapAmountSelectQuoteTransformer(
secondaryCryptoCurrencyStatus = prevState.secondaryCryptoCurrencyStatus,
)
val hasInsufficientFundsForFixed = hasInsufficientFundsForFixed(prevState, quoteContent)
return prevState.copy(
isPrimaryButtonEnabled = quoteUM is SwapQuoteUM.Content &&
!hasInsufficientBalance(prevState, quoteContent, newPrimaryAmount),
!hasInsufficientBalance(prevState, quoteContent, newPrimaryAmount) &&
!hasInsufficientFundsForFixed,
selectedQuote = quoteUM,
isShowFCAWarning = isNeedApplyFCARestrictions && quoteUM.provider?.isRestrictedByFCA() == true,
primaryAmount = newPrimaryAmount,
@ -72,6 +75,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?,

View file

@ -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,
)

View file

@ -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
@ -17,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
@ -45,7 +45,7 @@ internal class SwapNotificationsModel @Inject constructor(
private val params: SwapNotificationsComponent.Params = paramsContainer.require()
private var notificationData = params.swapNotificationData
private var lastSentErrorMessages: Set<String> = emptySet()
private var lastSentErrorKeys: Set<Pair<String, Map<String, String>>> = emptySet()
val uiState: StateFlow<ImmutableList<NotificationUM>>
field = MutableStateFlow<ImmutableList<NotificationUM>>(persistentListOf())
@ -150,21 +150,28 @@ internal class SwapNotificationsModel @Inject constructor(
fun MutableList<NotificationUM>.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,
)
},
)
@ -195,32 +202,32 @@ internal class SwapNotificationsModel @Inject constructor(
}
private fun sendErrorAnalyticsIfNeeded(notifications: List<NotificationUM>) {
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)
}
}

View file

@ -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,

View file

@ -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"
}

View file

@ -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,
),
),
)

View file

@ -320,12 +320,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 = {
@ -343,12 +348,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 = { _ ->
@ -461,6 +460,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,
),

View file

@ -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<AnalyticsEventHandler>(relaxed = true)
private val sender = SwapAmountAnalyticsSender(analyticsEventHandler)
private val fromNetwork = mockk<Network>(relaxed = true).also {
every { it.name } returns "Ethereum"
}
private val toNetwork = mockk<Network>(relaxed = true).also {
every { it.name } returns "Bitcoin"
}
private val fromToken = mockk<CryptoCurrency>(relaxed = true).also {
every { it.symbol } returns "ETH"
every { it.network } returns fromNetwork
}
private val toToken = mockk<CryptoCurrency>(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<SwapQuoteUM> = 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<AnalyticsEvent>()
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<AnalyticsEvent>()
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<AnalyticsEvent>()
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<AnalyticsEvent>()
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<AnalyticsEvent>()
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<AnalyticsEvent>()
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<SwapQuoteUM.Content>()
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<SwapQuoteUM.Content>()
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()) }
}

View file

@ -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<CryptoCurrencyStatus>(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,
)
}
}

View file

@ -186,6 +186,7 @@ internal class ChooseTokenListItemConverter(
PaymentAccountStatusValue.NotCreated,
is PaymentAccountStatusValue.UnderReview,
PaymentAccountStatusValue.Loading,
PaymentAccountStatusValue.Empty,
-> return null
is PaymentAccountStatusValue.Loaded -> status.cryptoCurrencyStatus
}

View file

@ -202,7 +202,6 @@ internal class ExpressTransactionsModel @Inject constructor(
expressTxStatusTaskScheduler.scheduleTask(
scope = modelScope,
task = PeriodicTask(
isDelayFirst = false,
delay = EXPRESS_STATUS_UPDATE_DELAY,
task = {
try {

View file

@ -437,7 +437,6 @@ internal class TokenDetailsModel @Inject constructor(
expressTxStatusTaskScheduler.scheduleTask(
scope = modelScope,
task = PeriodicTask(
isDelayFirst = false,
delay = EXPRESS_STATUS_UPDATE_DELAY,
task = {
runSuspendCatching {

View file

@ -247,6 +247,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
@ -254,6 +258,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,
@ -391,7 +396,6 @@ internal class WalletModel @Inject constructor(
expressTxStatusTaskScheduler.scheduleTask(
modelScope,
PeriodicTask(
isDelayFirst = false,
delay = EXPRESS_STATUS_UPDATE_DELAY,
task = {
runCatching {

View file

@ -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

View file

@ -29,13 +29,13 @@ import com.tangem.domain.promo.models.PromoId
import com.tangem.domain.settings.IsReadyToShowRateAppUseCase
import com.tangem.domain.assetsdiscovery.model.AssetsDiscoveryProgress
import com.tangem.domain.assetsdiscovery.usecase.ObserveAssetsDiscoveryUseCase
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.Loaded,
is PaymentAccountStatusValue.Loading,
is PaymentAccountStatusValue.UnderReview,
is PaymentAccountStatusValue.Empty,
-> null
}
notification?.let(::add)

View file

@ -238,6 +238,7 @@ internal class GetWalletNotificationsFactory @Inject constructor(
is PaymentAccountStatusValue.Loaded,
is PaymentAccountStatusValue.Loading,
is PaymentAccountStatusValue.UnderReview,
is PaymentAccountStatusValue.Empty,
-> null
}
notification?.let(::add)

View file

@ -53,6 +53,7 @@ internal class TangemPayMainBlockConverter(
}
},
)
is PaymentAccountStatusValue.Empty -> TangemPayMainUM.Empty
is PaymentAccountStatusValue.NotCreated -> TangemPayMainUM.Empty
is PaymentAccountStatusValue.Loading -> TangemPayMainUM.Loading
is PaymentAccountStatusValue.Loaded -> {