diff --git a/core/datasource/src/main/java/com/tangem/datasource/di/MoshiModule.kt b/core/datasource/src/main/java/com/tangem/datasource/di/MoshiModule.kt index d4a7ccf033..e36fe50437 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/di/MoshiModule.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/di/MoshiModule.kt @@ -48,6 +48,7 @@ class MoshiModule { ) .add( NamePolymorphicAdapterFactory.of(PaymentAccountStatusValueDM::class.java) + .withSubtype(PaymentAccountStatusValueDM.Empty::class.java, "empty") .withSubtype(PaymentAccountStatusValueDM.NotCreated::class.java, "not_created") .withSubtype(PaymentAccountStatusValueDM.UnderReview::class.java, "kyc_status") .withSubtype(PaymentAccountStatusValueDM.IssuingCard::class.java, "issuing_card") diff --git a/core/datasource/src/main/java/com/tangem/datasource/local/visa/entity/PaymentAccountStatusValueDM.kt b/core/datasource/src/main/java/com/tangem/datasource/local/visa/entity/PaymentAccountStatusValueDM.kt index 31f6c44adf..6efc96bd8c 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/local/visa/entity/PaymentAccountStatusValueDM.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/local/visa/entity/PaymentAccountStatusValueDM.kt @@ -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, diff --git a/core/utils/src/main/java/com/tangem/utils/coroutines/PeriodicTask.kt b/core/utils/src/main/java/com/tangem/utils/coroutines/PeriodicTask.kt index 3beae6ac03..ccfb61e52a 100644 --- a/core/utils/src/main/java/com/tangem/utils/coroutines/PeriodicTask.kt +++ b/core/utils/src/main/java/com/tangem/utils/coroutines/PeriodicTask.kt @@ -10,15 +10,15 @@ class PeriodicTask( private val task: suspend () -> Result, private val onSuccess: (T) -> Unit, private val onError: (Throwable) -> Unit, - private val isDelayFirst: Boolean = false, + private val initialDelay: Long = 0L, ) { private var isActive: AtomicBoolean = AtomicBoolean(false) suspend fun runTaskWithDelay() { isActive.set(true) - if (isDelayFirst) { - delay(delay) + if (initialDelay > 0L) { + delay(initialDelay) } while (isActive.get()) { task.invoke() diff --git a/core/utils/src/test/kotlin/com/tangem/utils/coroutines/PeriodicTaskTest.kt b/core/utils/src/test/kotlin/com/tangem/utils/coroutines/PeriodicTaskTest.kt new file mode 100644 index 0000000000..1f77fe3cce --- /dev/null +++ b/core/utils/src/test/kotlin/com/tangem/utils/coroutines/PeriodicTaskTest.kt @@ -0,0 +1,208 @@ +package com.tangem.utils.coroutines + +import com.google.common.truth.Truth.assertThat +import io.mockk.mockk +import io.mockk.verify +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.advanceTimeBy +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.Test +import java.util.concurrent.atomic.AtomicInteger + +@OptIn(ExperimentalCoroutinesApi::class) +class PeriodicTaskTest { + + @Test + fun `GIVEN initialDelay 0 and delay 1000 WHEN runTaskWithDelay THEN task is invoked immediately`() = runTest { + val callCount = AtomicInteger(0) + val onSuccess = mockk<(Int) -> Unit>(relaxed = true) + val onError = mockk<(Throwable) -> Unit>(relaxed = true) + val periodicTask = PeriodicTask( + delay = PERIOD, + task = { callCount.incrementAndGet(); Result.success(VALUE) }, + onSuccess = onSuccess, + onError = onError, + initialDelay = 0L, + ) + + launch { periodicTask.runTaskWithDelay() } + runCurrent() + + assertThat(callCount.get()).isEqualTo(1) + periodicTask.cancel() + } + + @Test + fun `GIVEN initialDelay 1000 WHEN runTaskWithDelay THEN task is not invoked before initialDelay elapses`() = + runTest { + val callCount = AtomicInteger(0) + val periodicTask = PeriodicTask( + delay = PERIOD, + task = { callCount.incrementAndGet(); Result.success(VALUE) }, + onSuccess = mockk(relaxed = true), + onError = mockk(relaxed = true), + initialDelay = INITIAL_DELAY, + ) + + launch { periodicTask.runTaskWithDelay() } + advanceTimeBy(INITIAL_DELAY - 1) + + assertThat(callCount.get()).isEqualTo(0) + periodicTask.cancel() + } + + @Test + fun `GIVEN initialDelay 1000 WHEN runTaskWithDelay THEN task is invoked once initialDelay elapses`() = runTest { + val callCount = AtomicInteger(0) + val periodicTask = PeriodicTask( + delay = PERIOD, + task = { callCount.incrementAndGet(); Result.success(VALUE) }, + onSuccess = mockk(relaxed = true), + onError = mockk(relaxed = true), + initialDelay = INITIAL_DELAY, + ) + + launch { periodicTask.runTaskWithDelay() } + advanceTimeBy(INITIAL_DELAY) + runCurrent() + + assertThat(callCount.get()).isEqualTo(1) + periodicTask.cancel() + } + + @Test + fun `GIVEN periodic task WHEN runTaskWithDelay THEN task is invoked repeatedly every delay ms`() = runTest { + val callCount = AtomicInteger(0) + val periodicTask = PeriodicTask( + delay = PERIOD, + task = { callCount.incrementAndGet(); Result.success(VALUE) }, + onSuccess = mockk(relaxed = true), + onError = mockk(relaxed = true), + initialDelay = 0L, + ) + + launch { periodicTask.runTaskWithDelay() } + runCurrent() + advanceTimeBy(PERIOD * 3) + runCurrent() + + assertThat(callCount.get()).isEqualTo(4) + periodicTask.cancel() + } + + @Test + fun `GIVEN successful task WHEN runTaskWithDelay THEN onSuccess is called with the result value`() = runTest { + val received = AtomicInteger(-1) + val errors = AtomicInteger(0) + val periodicTask = PeriodicTask( + delay = PERIOD, + task = { Result.success(VALUE) }, + onSuccess = { received.set(it) }, + onError = { errors.incrementAndGet() }, + initialDelay = 0L, + ) + + launch { periodicTask.runTaskWithDelay() } + runCurrent() + + assertThat(received.get()).isEqualTo(VALUE) + assertThat(errors.get()).isEqualTo(0) + periodicTask.cancel() + } + + @Test + fun `GIVEN failing task WHEN runTaskWithDelay THEN onError is called with the thrown exception`() = runTest { + val boom = IllegalStateException("boom") + val captured = arrayOfNulls(1) + val successes = AtomicInteger(0) + val periodicTask = PeriodicTask( + delay = PERIOD, + task = { Result.failure(boom) }, + onSuccess = { successes.incrementAndGet() }, + onError = { captured[0] = it }, + initialDelay = 0L, + ) + + launch { periodicTask.runTaskWithDelay() } + runCurrent() + + assertThat(captured[0]).isSameInstanceAs(boom) + assertThat(successes.get()).isEqualTo(0) + periodicTask.cancel() + } + + @Test + fun `GIVEN task running WHEN cancel THEN no further invocations happen`() = runTest { + val callCount = AtomicInteger(0) + val periodicTask = PeriodicTask( + delay = PERIOD, + task = { callCount.incrementAndGet(); Result.success(VALUE) }, + onSuccess = mockk(relaxed = true), + onError = mockk(relaxed = true), + initialDelay = 0L, + ) + + launch { periodicTask.runTaskWithDelay() } + runCurrent() + assertThat(callCount.get()).isEqualTo(1) + + periodicTask.cancel() + advanceUntilIdle() + + assertThat(callCount.get()).isEqualTo(1) + } + + @Test + fun `GIVEN initialDelay 1000 and cancel before it elapses WHEN runTaskWithDelay THEN task is never invoked`() = + runTest { + val callCount = AtomicInteger(0) + val periodicTask = PeriodicTask( + delay = PERIOD, + task = { callCount.incrementAndGet(); Result.success(VALUE) }, + onSuccess = mockk(relaxed = true), + onError = mockk(relaxed = true), + initialDelay = INITIAL_DELAY, + ) + + launch { periodicTask.runTaskWithDelay() } + advanceTimeBy(INITIAL_DELAY / 2) + periodicTask.cancel() + advanceUntilIdle() + + assertThat(callCount.get()).isEqualTo(0) + } + + @Test + fun `GIVEN task cancelled during invocation WHEN runTaskWithDelay THEN onSuccess is not called for the pending result`() = + runTest { + val onSuccess = mockk<(Int) -> Unit>(relaxed = true) + val periodicTask = PeriodicTask( + delay = PERIOD, + // Simulates a slow task that completes after the scheduler was cancelled. + task = { + delay(PERIOD) + Result.success(VALUE) + }, + onSuccess = onSuccess, + onError = mockk(relaxed = true), + initialDelay = 0L, + ) + + launch { periodicTask.runTaskWithDelay() } + runCurrent() + periodicTask.cancel() + advanceUntilIdle() + + verify(exactly = 0) { onSuccess.invoke(any()) } + } + + private companion object { + const val PERIOD = 10_000L + const val INITIAL_DELAY = 1_000L + const val VALUE = 42 + } +} \ No newline at end of file diff --git a/data/visa/src/main/kotlin/com/tangem/data/pay/DefaultTangemPayEligibilityManager.kt b/data/visa/src/main/kotlin/com/tangem/data/pay/DefaultTangemPayEligibilityManager.kt index 029791dec1..8ca4cbc613 100644 --- a/data/visa/src/main/kotlin/com/tangem/data/pay/DefaultTangemPayEligibilityManager.kt +++ b/data/visa/src/main/kotlin/com/tangem/data/pay/DefaultTangemPayEligibilityManager.kt @@ -48,9 +48,7 @@ internal class DefaultTangemPayEligibilityManager @Inject constructor( } override suspend fun getTangemPayAvailability(entryPoint: TangemPayEntryPoint): Boolean { - val eligibility = onboardingRepository.getCustomerEligibility().ifEmpty { - onboardingRepository.checkCustomerEligibility() - } + val eligibility = onboardingRepository.checkCustomerEligibility() val type = entryPoint.toEligibilityType() return eligibility.any { it == type } .also { isEligible -> if (!isEligible) reset() } diff --git a/data/visa/src/main/kotlin/com/tangem/data/pay/converter/PaymentAccountStatusValueDMConverter.kt b/data/visa/src/main/kotlin/com/tangem/data/pay/converter/PaymentAccountStatusValueDMConverter.kt index 8fbc87cb6f..0eaa957399 100644 --- a/data/visa/src/main/kotlin/com/tangem/data/pay/converter/PaymentAccountStatusValueDMConverter.kt +++ b/data/visa/src/main/kotlin/com/tangem/data/pay/converter/PaymentAccountStatusValueDMConverter.kt @@ -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, diff --git a/data/visa/src/main/kotlin/com/tangem/data/pay/flow/DefaultPaymentAccountStatusFetcher.kt b/data/visa/src/main/kotlin/com/tangem/data/pay/flow/DefaultPaymentAccountStatusFetcher.kt index 220bdc93a1..b9bfca6812 100644 --- a/data/visa/src/main/kotlin/com/tangem/data/pay/flow/DefaultPaymentAccountStatusFetcher.kt +++ b/data/visa/src/main/kotlin/com/tangem/data/pay/flow/DefaultPaymentAccountStatusFetcher.kt @@ -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 + } } \ No newline at end of file diff --git a/data/visa/src/main/kotlin/com/tangem/data/pay/repository/DefaultOnboardingRepository.kt b/data/visa/src/main/kotlin/com/tangem/data/pay/repository/DefaultOnboardingRepository.kt index 9b3188f36b..05189f3d42 100644 --- a/data/visa/src/main/kotlin/com/tangem/data/pay/repository/DefaultOnboardingRepository.kt +++ b/data/visa/src/main/kotlin/com/tangem/data/pay/repository/DefaultOnboardingRepository.kt @@ -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 { diff --git a/domain/account/status/src/main/java/com/tangem/domain/account/status/usecase/IsAccountsModeEnabledUseCase.kt b/domain/account/status/src/main/java/com/tangem/domain/account/status/usecase/IsAccountsModeEnabledUseCase.kt index 1ae6967739..6667cb8e2a 100644 --- a/domain/account/status/src/main/java/com/tangem/domain/account/status/usecase/IsAccountsModeEnabledUseCase.kt +++ b/domain/account/status/src/main/java/com/tangem/domain/account/status/usecase/IsAccountsModeEnabledUseCase.kt @@ -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 + } } } \ No newline at end of file diff --git a/domain/account/status/src/test/kotlin/com/tangem/domain/account/status/usecase/IsAccountsModeEnabledUseCaseTest.kt b/domain/account/status/src/test/kotlin/com/tangem/domain/account/status/usecase/IsAccountsModeEnabledUseCaseTest.kt index df60423785..009627834e 100644 --- a/domain/account/status/src/test/kotlin/com/tangem/domain/account/status/usecase/IsAccountsModeEnabledUseCaseTest.kt +++ b/domain/account/status/src/test/kotlin/com/tangem/domain/account/status/usecase/IsAccountsModeEnabledUseCaseTest.kt @@ -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( diff --git a/domain/models/src/main/kotlin/com/tangem/domain/models/account/PaymentAccountStatusValue.kt b/domain/models/src/main/kotlin/com/tangem/domain/models/account/PaymentAccountStatusValue.kt index 15029d713b..aaa2a634e3 100644 --- a/domain/models/src/main/kotlin/com/tangem/domain/models/account/PaymentAccountStatusValue.kt +++ b/domain/models/src/main/kotlin/com/tangem/domain/models/account/PaymentAccountStatusValue.kt @@ -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() { diff --git a/features/approval/impl/build.gradle.kts b/features/approval/impl/build.gradle.kts index 2e43f87aee..1a43aaf4a5 100644 --- a/features/approval/impl/build.gradle.kts +++ b/features/approval/impl/build.gradle.kts @@ -10,6 +10,10 @@ android { namespace = "com.tangem.features.approval.impl" } +tasks.withType().configureEach { + useJUnitPlatform() +} + dependencies { /** Feature */ @@ -56,4 +60,12 @@ dependencies { /** DI */ implementation(deps.hilt.android) kapt(deps.hilt.kapt) + + // region Tests + testImplementation(deps.test.coroutine) + testImplementation(deps.test.junit5) + testRuntimeOnly(deps.test.junit5.engine) + testImplementation(deps.test.mockk) + testImplementation(deps.test.truth) + // endregion } \ No newline at end of file diff --git a/features/approval/impl/src/main/java/com/tangem/features/approval/impl/model/GiveApprovalModel.kt b/features/approval/impl/src/main/java/com/tangem/features/approval/impl/model/GiveApprovalModel.kt index 1cecfc51ea..108efdb33c 100644 --- a/features/approval/impl/src/main/java/com/tangem/features/approval/impl/model/GiveApprovalModel.kt +++ b/features/approval/impl/src/main/java/com/tangem/features/approval/impl/model/GiveApprovalModel.kt @@ -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() { diff --git a/features/approval/impl/src/test/kotlin/com/tangem/features/approval/impl/model/GiveApprovalModelTest.kt b/features/approval/impl/src/test/kotlin/com/tangem/features/approval/impl/model/GiveApprovalModelTest.kt new file mode 100644 index 0000000000..cf09525c4c --- /dev/null +++ b/features/approval/impl/src/test/kotlin/com/tangem/features/approval/impl/model/GiveApprovalModelTest.kt @@ -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(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() } + } +} \ No newline at end of file diff --git a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/success/model/OnrampSuccessComponentModel.kt b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/success/model/OnrampSuccessComponentModel.kt index dbae3fb93d..05354188cf 100644 --- a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/success/model/OnrampSuccessComponentModel.kt +++ b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/success/model/OnrampSuccessComponentModel.kt @@ -120,7 +120,6 @@ internal class OnrampSuccessComponentModel @Inject constructor( expressTxStatusTaskScheduler.scheduleTask( modelScope, PeriodicTask( - isDelayFirst = false, delay = EXPRESS_STATUS_UPDATE_DELAY, task = { runSuspendCatching { diff --git a/features/promo-banners/api/src/main/kotlin/com/tangem/features/promobanners/api/PromoBannersBlockComponent.kt b/features/promo-banners/api/src/main/kotlin/com/tangem/features/promobanners/api/PromoBannersBlockComponent.kt index e97e39f047..57c0e3844a 100644 --- a/features/promo-banners/api/src/main/kotlin/com/tangem/features/promobanners/api/PromoBannersBlockComponent.kt +++ b/features/promo-banners/api/src/main/kotlin/com/tangem/features/promobanners/api/PromoBannersBlockComponent.kt @@ -12,9 +12,9 @@ interface PromoBannersBlockComponent : ComposableContentComponent { val isInitiallyVisibleOnScreen: Boolean = true, ) - enum class Placeholder { - MAIN, - FEED, + enum class Placeholder(val value: String) { + MAIN("main"), + FEED("shtorka"), } interface Factory : ComponentFactory diff --git a/features/promo-banners/impl/src/main/kotlin/com/tangem/features/promobanners/impl/model/PromoBannersBlockModel.kt b/features/promo-banners/impl/src/main/kotlin/com/tangem/features/promobanners/impl/model/PromoBannersBlockModel.kt index d4a633984f..7abc5a48bd 100644 --- a/features/promo-banners/impl/src/main/kotlin/com/tangem/features/promobanners/impl/model/PromoBannersBlockModel.kt +++ b/features/promo-banners/impl/src/main/kotlin/com/tangem/features/promobanners/impl/model/PromoBannersBlockModel.kt @@ -21,6 +21,8 @@ import java.util.Locale import java.util.concurrent.ConcurrentHashMap import javax.inject.Inject +private typealias ShownBannerKey = Pair + @ModelScoped internal class PromoBannersBlockModel @Inject constructor( override val dispatchers: CoroutineDispatcherProvider, @@ -34,8 +36,8 @@ internal class PromoBannersBlockModel @Inject constructor( private val params = paramsContainer.require() private val converter = PromoBannerDisplayToNotificationConverter() - private val placeholder: String = params.placeholder.name.lowercase() - private val shownBannerIds: MutableSet = ConcurrentHashMap.newKeySet() + private val placeholderName: String = params.placeholder.value + private val shownBannerIds: MutableSet = ConcurrentHashMap.newKeySet() private var isVisibleOnScreen: Boolean = params.isInitiallyVisibleOnScreen private var wasCarouselScrolled = false private val savedDisplayIdByWalletId: MutableMap = mutableMapOf() @@ -92,7 +94,7 @@ internal class PromoBannersBlockModel @Inject constructor( banners = bannerUMs, isVisibleOnScreen = isVisibleOnScreen, placeholder = params.placeholder, - onBannerShown = ::onBannerShown, + onBannerShown = { displayId -> onBannerShown(walletId, displayId) }, onCarouselScrolled = ::onCarouselScrolled, onPageChanged = { displayId -> savedDisplayIdByWalletId[walletId] = displayId }, ) @@ -101,21 +103,21 @@ internal class PromoBannersBlockModel @Inject constructor( } } - private fun onBannerShown(displayId: Int) { - if (shownBannerIds.add(displayId)) { - analyticsEventHandler.send(PromoBannerAnalyticsEvent.Shown(displayId, placeholder)) + private fun onBannerShown(walletId: String, displayId: Int) { + if (shownBannerIds.add(walletId to displayId)) { + analyticsEventHandler.send(PromoBannerAnalyticsEvent.Shown(displayId, placeholderName)) } } private fun onCarouselScrolled(displayId: Int) { if (!wasCarouselScrolled) { wasCarouselScrolled = true - analyticsEventHandler.send(PromoBannerAnalyticsEvent.CarouselScrolled(displayId, placeholder)) + analyticsEventHandler.send(PromoBannerAnalyticsEvent.CarouselScrolled(displayId, placeholderName)) } } private fun onButtonClick(displayId: Int, deeplink: String?) { - analyticsEventHandler.send(PromoBannerAnalyticsEvent.Clicked(displayId, placeholder)) + analyticsEventHandler.send(PromoBannerAnalyticsEvent.Clicked(displayId, placeholderName)) deeplink?.let { deeplinkLauncher.launch(it) } } @@ -131,7 +133,7 @@ internal class PromoBannersBlockModel @Inject constructor( ) private fun onBannerDismiss(walletId: String, displayId: Int) { - analyticsEventHandler.send(PromoBannerAnalyticsEvent.Dismissed(displayId, placeholder)) + analyticsEventHandler.send(PromoBannerAnalyticsEvent.Dismissed(displayId, placeholderName)) uiState.update { state -> state.copy( banners = state.banners diff --git a/features/promo-banners/impl/src/main/kotlin/com/tangem/features/promobanners/impl/repository/DefaultPromoBannersRepository.kt b/features/promo-banners/impl/src/main/kotlin/com/tangem/features/promobanners/impl/repository/DefaultPromoBannersRepository.kt index 37c5cf33ef..0ab8bf3ee9 100644 --- a/features/promo-banners/impl/src/main/kotlin/com/tangem/features/promobanners/impl/repository/DefaultPromoBannersRepository.kt +++ b/features/promo-banners/impl/src/main/kotlin/com/tangem/features/promobanners/impl/repository/DefaultPromoBannersRepository.kt @@ -37,7 +37,7 @@ internal class DefaultPromoBannersRepository( val banners = withContext(dispatchers.io) { tangemTechApi.getPromoBannerDisplays( walletId = walletId, - placeholder = placeholder.toApiValue(), + placeholder = placeholder.value, languageISOCode = languageISOCode, ).getOrThrow() .items @@ -71,9 +71,4 @@ internal class DefaultPromoBannersRepository( tangemTechApi.dismissPromoBannerDisplay(displayId, request).getOrThrow() } } - - private fun Placeholder.toApiValue(): String = when (this) { - Placeholder.MAIN -> "main" - Placeholder.FEED -> "shtorka" - } } \ No newline at end of file diff --git a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/component/extended/FeeExtendedSelectorComponent.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/component/extended/FeeExtendedSelectorComponent.kt index 76f9baab92..42f403fe05 100644 --- a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/component/extended/FeeExtendedSelectorComponent.kt +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/component/extended/FeeExtendedSelectorComponent.kt @@ -24,10 +24,11 @@ internal class FeeExtendedSelectorComponent @AssistedInject constructor( @Composable override fun Content(modifier: Modifier) { val state by model.uiState.collectAsStateWithLifecycle() + val current = state ?: return FeeExtendedSelectorContent( modifier = modifier, - state = state, + state = current, ) } diff --git a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/component/extended/model/FeeExtendedSelectorModel.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/component/extended/model/FeeExtendedSelectorModel.kt index 0a68ace88e..6c56801a3b 100644 --- a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/component/extended/model/FeeExtendedSelectorModel.kt +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/component/extended/model/FeeExtendedSelectorModel.kt @@ -34,8 +34,8 @@ class FeeExtendedSelectorModel @Inject constructor( initAppCurrency() } - val uiState: StateFlow - field = MutableStateFlow(getInitialState()) + val uiState: StateFlow + field = MutableStateFlow(getInitialState()) init { params.state @@ -44,8 +44,8 @@ class FeeExtendedSelectorModel @Inject constructor( .launchIn(modelScope) } - private fun getInitialState(): FeeExtendedSelectorUM { - val parentContentState = params.state.value as FeeSelectorUM.Content + private fun getInitialState(): FeeExtendedSelectorUM? { + val parentContentState = params.state.value as? FeeSelectorUM.Content ?: return null return convertState(parentContentState) } diff --git a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/component/token/FeeTokenSelectorComponent.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/component/token/FeeTokenSelectorComponent.kt index 0fc8cce54a..410e436d41 100644 --- a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/component/token/FeeTokenSelectorComponent.kt +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/component/token/FeeTokenSelectorComponent.kt @@ -24,9 +24,10 @@ internal class FeeTokenSelectorComponent @AssistedInject constructor( @Composable override fun Content(modifier: Modifier) { val state by model.uiState.collectAsStateWithLifecycle() + val current = state ?: return FeeTokenSelectorContent( - state = state, + state = current, intents = model, modifier = modifier, ) diff --git a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/component/token/model/FeeTokenSelectorModel.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/component/token/model/FeeTokenSelectorModel.kt index eb7e57e5b0..2385981d76 100644 --- a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/component/token/model/FeeTokenSelectorModel.kt +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/component/token/model/FeeTokenSelectorModel.kt @@ -41,7 +41,7 @@ internal class FeeTokenSelectorModel @Inject constructor( private val params = paramsContainer.require() private var appCurrency = AppCurrency.Default - val uiState: StateFlow + val uiState: StateFlow field = MutableStateFlow(getInitialState()) init { @@ -59,8 +59,8 @@ internal class FeeTokenSelectorModel @Inject constructor( } } - private fun getInitialState(): FeeTokenSelectorUM { - val parentState = params.state.value as FeeSelectorUM.Content + private fun getInitialState(): FeeTokenSelectorUM? { + val parentState = params.state.value as? FeeSelectorUM.Content ?: return null return stateFromParent(parentState) } diff --git a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/ui/StakingConfirmationContent.kt b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/ui/StakingConfirmationContent.kt index 7f3853dbbd..a34f98fccb 100644 --- a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/ui/StakingConfirmationContent.kt +++ b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/ui/StakingConfirmationContent.kt @@ -68,7 +68,7 @@ internal fun StakingConfirmationContent( ) ValidatorBlock( validatorState = validatorState, - isClickable = !isTransactionInProgress, + isClickable = !isTransactionSent && !isTransactionInProgress, onClick = clickIntents::openValidators, ) StakingFeeBlock(feeState = state.feeState) diff --git a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/ui/StakingSuccessContent.kt b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/ui/StakingSuccessContent.kt index d9297f768d..5ae45606f9 100644 --- a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/ui/StakingSuccessContent.kt +++ b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/ui/StakingSuccessContent.kt @@ -60,7 +60,7 @@ internal fun StakingSuccessContent( ) ValidatorBlock( validatorState = validatorState, - isClickable = !isTransactionInProgress, + isClickable = !isTransactionSent && !isTransactionInProgress, onClick = clickIntents::openValidators, ) StakingFeeBlock(feeState = state.feeState) diff --git a/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/amount/analytics/SwapAmountAnalyticsSender.kt b/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/amount/analytics/SwapAmountAnalyticsSender.kt index d6b20adde2..a349372423 100644 --- a/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/amount/analytics/SwapAmountAnalyticsSender.kt +++ b/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/amount/analytics/SwapAmountAnalyticsSender.kt @@ -1,38 +1,61 @@ package com.tangem.features.swap.v2.impl.amount.analytics import com.tangem.core.analytics.api.AnalyticsEventHandler +import com.tangem.core.analytics.models.AnalyticsEvent import com.tangem.domain.express.models.ExpressError +import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.features.swap.v2.impl.common.entity.SwapQuoteUM import com.tangem.features.swap.v2.impl.sendviaswap.analytics.SendWithSwapAnalyticEvents -import com.tangem.features.swap.v2.impl.sendviaswap.analytics.SendWithSwapAnalyticsErrorMessages internal class SwapAmountAnalyticsSender( private val analyticsEventHandler: AnalyticsEventHandler, ) { - private var lastSentErrorMessage: String? = null + private var lastSentEvent: AnalyticsEvent? = null - fun sendErrorIfNeeded(quotes: List, selectedQuote: SwapQuoteUM?) { - val errorMessage = resolveErrorMessage(quotes, selectedQuote) - if (errorMessage == lastSentErrorMessage) return - lastSentErrorMessage = errorMessage - if (errorMessage != null) { - analyticsEventHandler.send( - SendWithSwapAnalyticEvents.SendWithSwapError( - errorScreen = SendWithSwapAnalyticEvents.ErrorScreen.Amount, - message = errorMessage, - ), + fun sendErrorIfNeeded( + quotes: List, + selectedQuote: SwapQuoteUM?, + fromToken: CryptoCurrency, + toToken: CryptoCurrency, + hasInsufficientBalance: Boolean, + ) { + val event = resolveEvent( + quotes = quotes, + selectedQuote = selectedQuote, + fromToken = fromToken, + toToken = toToken, + hasInsufficientBalance = hasInsufficientBalance, + ) + if (event?.event == lastSentEvent?.event && event?.params == lastSentEvent?.params) return + lastSentEvent = event + if (event != null) { + analyticsEventHandler.send(event) + } + } + + private fun resolveEvent( + quotes: List, + selectedQuote: SwapQuoteUM?, + fromToken: CryptoCurrency, + toToken: CryptoCurrency, + hasInsufficientBalance: Boolean, + ): AnalyticsEvent? { + if (hasInsufficientBalance) { + return SendWithSwapAnalyticEvents.ErrorInsufficientBalance(fromToken = fromToken) + } + if (quotes.isEmpty()) return null + val error = (selectedQuote as? SwapQuoteUM.Error)?.expressError ?: return null + return when (error) { + is ExpressError.AmountError.TooSmallError -> + SendWithSwapAnalyticEvents.ErrorMinAmount(fromToken = fromToken) + is ExpressError.AmountError.TooBigError -> + SendWithSwapAnalyticEvents.ErrorMaxAmount(fromToken = fromToken) + else -> SendWithSwapAnalyticEvents.ErrorExpressQuote( + fromToken = fromToken, + toToken = toToken, + errorDescription = "code=${error.code}", ) } } - - private fun resolveErrorMessage(quotes: List, selectedQuote: SwapQuoteUM?): String? { - if (quotes.isEmpty()) return SendWithSwapAnalyticsErrorMessages.EXPRESS_QUOTE_NO_PROVIDERS - val error = (selectedQuote as? SwapQuoteUM.Error)?.expressError ?: return null - return when (error) { - is ExpressError.AmountError.TooSmallError -> SendWithSwapAnalyticsErrorMessages.MIN_AMOUNT - is ExpressError.AmountError.TooBigError -> SendWithSwapAnalyticsErrorMessages.MAX_AMOUNT - else -> "${SendWithSwapAnalyticsErrorMessages.EXPRESS_QUOTE}: code=${error.code}" - } - } } \ No newline at end of file diff --git a/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/amount/model/SwapAmountModel.kt b/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/amount/model/SwapAmountModel.kt index 9df178e74b..eb17fb5103 100644 --- a/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/amount/model/SwapAmountModel.kt +++ b/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/amount/model/SwapAmountModel.kt @@ -57,7 +57,6 @@ import com.tangem.features.swap.v2.impl.common.entity.SwapQuoteUM import com.tangem.features.swap.v2.impl.sendviaswap.SendWithSwapRoute import com.tangem.features.swap.v2.impl.sendviaswap.analytics.SendWithSwapAnalyticEvents import com.tangem.features.swap.v2.impl.sendviaswap.analytics.SendWithSwapAnalyticEvents.NoticeFixedRate.toAnalyticsRateType -import com.tangem.features.swap.v2.impl.sendviaswap.analytics.SendWithSwapAnalyticsErrorMessages import com.tangem.utils.coroutines.CoroutineDispatcherProvider import com.tangem.utils.coroutines.Debouncer import com.tangem.utils.coroutines.PeriodicTask @@ -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) { + 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 { + private fun loadQuotesTask(initialDelay: Long = QUOTES_UPDATE_DELAY): PeriodicTask { 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 } } \ No newline at end of file diff --git a/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/amount/model/transformers/SwapAmountSelectQuoteTransformer.kt b/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/amount/model/transformers/SwapAmountSelectQuoteTransformer.kt index 7588c150a5..9131977708 100644 --- a/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/amount/model/transformers/SwapAmountSelectQuoteTransformer.kt +++ b/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/amount/model/transformers/SwapAmountSelectQuoteTransformer.kt @@ -61,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?, diff --git a/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/notifications/SwapNotificationsComponent.kt b/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/notifications/SwapNotificationsComponent.kt index f68c3e608d..c7e62ed4f3 100644 --- a/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/notifications/SwapNotificationsComponent.kt +++ b/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/notifications/SwapNotificationsComponent.kt @@ -7,6 +7,7 @@ import com.tangem.core.decompose.context.AppComponentContext import com.tangem.core.decompose.model.getOrCreateModel import com.tangem.domain.express.models.ExpressError import com.tangem.domain.express.models.ExpressProvider +import com.tangem.domain.express.models.ExpressRateType import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.models.currency.CryptoCurrencyStatus import com.tangem.domain.models.wallet.UserWalletId @@ -53,6 +54,7 @@ internal class SwapNotificationsComponent( val fromCryptoCurrencyStatus: CryptoCurrencyStatus? = null, val priceImpact: PriceImpact? = null, val provider: ExpressProvider? = null, + val rateType: ExpressRateType? = null, val shouldIncludeFeeInBalanceCheck: Boolean = false, val feeValue: BigDecimal? = null, ) diff --git a/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/notifications/model/SwapNotificationsModel.kt b/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/notifications/model/SwapNotificationsModel.kt index 0031fd2efd..d82b8ace85 100644 --- a/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/notifications/model/SwapNotificationsModel.kt +++ b/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/notifications/model/SwapNotificationsModel.kt @@ -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 = emptySet() + private var lastSentErrorKeys: Set>> = emptySet() val uiState: StateFlow> field = MutableStateFlow>(persistentListOf()) @@ -150,21 +150,28 @@ internal class SwapNotificationsModel @Inject constructor( fun MutableList.addExpressErrorNotification() { val expressError = notificationData.expressError ?: return val fromCryptoCurrency = notificationData.fromCryptoCurrency ?: return + val toCryptoCurrency = notificationData.toCryptoCurrencyStatus?.currency ?: return + + val amountErrorCurrency = if (notificationData.rateType == ExpressRateType.Fixed) { + toCryptoCurrency + } else { + fromCryptoCurrency + } val errorNotification = when (expressError) { is ExpressError.AmountError.TooSmallError -> SwapNotificationUM.Error.MinimalAmountError( expressError.amount.format { crypto( - symbol = fromCryptoCurrency.symbol, - decimals = fromCryptoCurrency.decimals, + symbol = amountErrorCurrency.symbol, + decimals = amountErrorCurrency.decimals, ) }, ) is ExpressError.AmountError.TooBigError -> SwapNotificationUM.Error.MaximumAmountError( expressError.amount.format { crypto( - symbol = fromCryptoCurrency.symbol, - decimals = fromCryptoCurrency.decimals, + symbol = amountErrorCurrency.symbol, + decimals = amountErrorCurrency.decimals, ) }, ) @@ -195,32 +202,32 @@ internal class SwapNotificationsModel @Inject constructor( } private fun sendErrorAnalyticsIfNeeded(notifications: List) { - val currentErrors = notifications.mapNotNull { notification -> + val fromToken = notificationData.fromCryptoCurrency ?: return + val toToken = notificationData.toCryptoCurrencyStatus?.currency + + val events = notifications.mapNotNull { notification -> when (notification) { is SwapNotificationUM.Error.InsufficientFunds -> - SendWithSwapAnalyticsErrorMessages.INSUFFICIENT_BALANCE + SendWithSwapAnalyticEvents.ErrorInsufficientBalance(fromToken = fromToken) is SwapNotificationUM.Error.MinimalAmountError -> - SendWithSwapAnalyticsErrorMessages.MIN_AMOUNT + SendWithSwapAnalyticEvents.ErrorMinAmount(fromToken = fromToken) is SwapNotificationUM.Error.MaximumAmountError -> - SendWithSwapAnalyticsErrorMessages.MAX_AMOUNT - is SwapNotificationUM.Warning.ExpressGeneralError -> - "${SendWithSwapAnalyticsErrorMessages.EXPRESS_QUOTE}: code=${notification.expressError.code}" - is NotificationUM.Error.DestinationTagRequired -> - SendWithSwapAnalyticsErrorMessages.DESTINATION_TAG_REQUIRED + SendWithSwapAnalyticEvents.ErrorMaxAmount(fromToken = fromToken) + is SwapNotificationUM.Warning.ExpressGeneralError -> toToken?.let { receiveToken -> + SendWithSwapAnalyticEvents.ErrorExpressQuote( + fromToken = fromToken, + toToken = receiveToken, + errorDescription = "code=${notification.expressError.code}", + ) + } else -> null } - }.toSet() - - val newErrors = currentErrors - lastSentErrorMessages - lastSentErrorMessages = currentErrors - - newErrors.forEach { errorMessage -> - analyticsEventHandler.send( - SendWithSwapAnalyticEvents.SendWithSwapError( - errorScreen = SendWithSwapAnalyticEvents.ErrorScreen.Confirm, - message = errorMessage, - ), - ) } + + val currentKeys = events.map { it.event to it.params }.toSet() + val newEvents = events.filter { it.event to it.params !in lastSentErrorKeys } + lastSentErrorKeys = currentKeys + + newEvents.forEach(analyticsEventHandler::send) } } \ No newline at end of file diff --git a/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/sendviaswap/analytics/SendWithSwapAnalyticEvents.kt b/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/sendviaswap/analytics/SendWithSwapAnalyticEvents.kt index f753ef6f26..5a68284603 100644 --- a/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/sendviaswap/analytics/SendWithSwapAnalyticEvents.kt +++ b/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/sendviaswap/analytics/SendWithSwapAnalyticEvents.kt @@ -4,7 +4,7 @@ import com.tangem.core.analytics.models.AnalyticsEvent import com.tangem.core.analytics.models.AnalyticsParam import com.tangem.core.analytics.models.AnalyticsParam.Key.ACCOUNT_DERIVATION_FROM import com.tangem.core.analytics.models.AnalyticsParam.Key.ACCOUNT_DERIVATION_TO -import com.tangem.core.analytics.models.AnalyticsParam.Key.ERROR_MESSAGE +import com.tangem.core.analytics.models.AnalyticsParam.Key.ERROR_DESCRIPTION import com.tangem.core.analytics.models.AnalyticsParam.Key.FEE_TYPE import com.tangem.core.analytics.models.AnalyticsParam.Key.PROVIDER import com.tangem.core.analytics.models.AnalyticsParam.Key.RATE_TYPE @@ -120,19 +120,51 @@ internal sealed class SendWithSwapAnalyticEvents( params = emptyMap(), ) - data class SendWithSwapError( - val errorScreen: ErrorScreen, - val message: String, + data class ErrorInsufficientBalance( + val fromToken: CryptoCurrency, ) : SendWithSwapAnalyticEvents( - event = when (errorScreen) { - ErrorScreen.Amount -> "Send With Swap Amount Screen Error" - ErrorScreen.Confirm -> "Send With Swap Confirm Screen Error" - }, + event = "Error - Insufficient balance", params = mapOf( - ERROR_MESSAGE to message, + SEND_TOKEN to fromToken.symbol, + SEND_BLOCKCHAIN to fromToken.network.name, ), ) + data class ErrorMinAmount( + val fromToken: CryptoCurrency, + ) : SendWithSwapAnalyticEvents( + event = "Error - Min amount", + params = mapOf( + SEND_TOKEN to fromToken.symbol, + SEND_BLOCKCHAIN to fromToken.network.name, + ), + ) + + data class ErrorMaxAmount( + val fromToken: CryptoCurrency, + ) : SendWithSwapAnalyticEvents( + event = "Error - Max amount", + params = mapOf( + SEND_TOKEN to fromToken.symbol, + SEND_BLOCKCHAIN to fromToken.network.name, + ), + ) + + data class ErrorExpressQuote( + val fromToken: CryptoCurrency, + val toToken: CryptoCurrency, + val errorDescription: String? = null, + ) : SendWithSwapAnalyticEvents( + event = "Error - Express quote", + params = buildMap { + put(SEND_TOKEN, fromToken.symbol) + put(SEND_BLOCKCHAIN, fromToken.network.name) + put(RECEIVE_TOKEN, toToken.symbol) + put(RECEIVE_BLOCKCHAIN, toToken.network.name) + if (errorDescription != null) put(ERROR_DESCRIPTION, errorDescription) + }, + ) + class HighPriceImpact( val sendToken: String, val receiveToken: String, @@ -168,11 +200,6 @@ internal sealed class SendWithSwapAnalyticEvents( ), ) - enum class ErrorScreen { - Amount, - Confirm, - } - enum class RateType { Float, Fixed, diff --git a/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/sendviaswap/analytics/SendWithSwapAnalyticsErrorMessages.kt b/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/sendviaswap/analytics/SendWithSwapAnalyticsErrorMessages.kt deleted file mode 100644 index 610a9c1159..0000000000 --- a/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/sendviaswap/analytics/SendWithSwapAnalyticsErrorMessages.kt +++ /dev/null @@ -1,11 +0,0 @@ -package com.tangem.features.swap.v2.impl.sendviaswap.analytics - -internal object SendWithSwapAnalyticsErrorMessages { - const val INSUFFICIENT_BALANCE = "Error - Insufficient balance" - const val MIN_AMOUNT = "Error - Min amount" - const val MAX_AMOUNT = "Error - Max amount" - const val EXPRESS_QUOTE_NO_PROVIDERS = "Error - Express quote no providers found" - const val EXPRESS_QUOTE = "Error - Express quote" - const val DESTINATION_TAG_REQUIRED = "Error - Destination tag required" - const val INVALID_CRYPTOCURRENCIES_STATUS = "Error - Invalid cryptocurrencies status" -} \ No newline at end of file diff --git a/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/sendviaswap/confirm/SendWithSwapConfirmComponent.kt b/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/sendviaswap/confirm/SendWithSwapConfirmComponent.kt index 8e24591f1f..387432f1fd 100644 --- a/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/sendviaswap/confirm/SendWithSwapConfirmComponent.kt +++ b/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/sendviaswap/confirm/SendWithSwapConfirmComponent.kt @@ -141,6 +141,7 @@ internal class SendWithSwapConfirmComponent @AssistedInject constructor( enteredFromAmount = model.confirmData.enteredFromAmount, fromCryptoCurrencyStatus = model.confirmData.fromCryptoCurrencyStatus, priceImpact = model.confirmData.priceImpact, + rateType = model.confirmData.rateType, ), ), ) diff --git a/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/sendviaswap/confirm/model/SendWithSwapConfirmModel.kt b/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/sendviaswap/confirm/model/SendWithSwapConfirmModel.kt index 7b506a8eda..5785695c59 100644 --- a/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/sendviaswap/confirm/model/SendWithSwapConfirmModel.kt +++ b/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/sendviaswap/confirm/model/SendWithSwapConfirmModel.kt @@ -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, ), diff --git a/features/swap-v2/impl/src/test/java/com/tangem/features/swap/v2/impl/amount/analytics/SwapAmountAnalyticsSenderTest.kt b/features/swap-v2/impl/src/test/java/com/tangem/features/swap/v2/impl/amount/analytics/SwapAmountAnalyticsSenderTest.kt index 26f07c6275..3f81cf380b 100644 --- a/features/swap-v2/impl/src/test/java/com/tangem/features/swap/v2/impl/amount/analytics/SwapAmountAnalyticsSenderTest.kt +++ b/features/swap-v2/impl/src/test/java/com/tangem/features/swap/v2/impl/amount/analytics/SwapAmountAnalyticsSenderTest.kt @@ -6,9 +6,10 @@ import com.tangem.core.analytics.models.AnalyticsEvent import com.tangem.domain.express.models.ExpressError import com.tangem.domain.express.models.ExpressProvider import com.tangem.domain.express.models.ExpressProviderType +import com.tangem.domain.models.currency.CryptoCurrency +import com.tangem.domain.models.network.Network import com.tangem.features.swap.v2.impl.common.entity.SwapQuoteUM import com.tangem.features.swap.v2.impl.sendviaswap.analytics.SendWithSwapAnalyticEvents -import com.tangem.features.swap.v2.impl.sendviaswap.analytics.SendWithSwapAnalyticsErrorMessages import io.mockk.every import io.mockk.mockk import io.mockk.slot @@ -21,6 +22,21 @@ class SwapAmountAnalyticsSenderTest { private val analyticsEventHandler = mockk(relaxed = true) private val sender = SwapAmountAnalyticsSender(analyticsEventHandler) + private val fromNetwork = mockk(relaxed = true).also { + every { it.name } returns "Ethereum" + } + private val toNetwork = mockk(relaxed = true).also { + every { it.name } returns "Bitcoin" + } + private val fromToken = mockk(relaxed = true).also { + every { it.symbol } returns "ETH" + every { it.network } returns fromNetwork + } + private val toToken = mockk(relaxed = true).also { + every { it.symbol } returns "BTC" + every { it.network } returns toNetwork + } + private val testProvider = ExpressProvider( providerId = "test", name = "Test Provider", @@ -31,21 +47,41 @@ class SwapAmountAnalyticsSenderTest { slippage = null, ) + private fun send( + quotes: List = emptyList(), + selectedQuote: SwapQuoteUM? = null, + hasInsufficientBalance: Boolean = false, + ) = sender.sendErrorIfNeeded( + quotes = quotes, + selectedQuote = selectedQuote, + fromToken = fromToken, + toToken = toToken, + hasInsufficientBalance = hasInsufficientBalance, + ) + @Test - fun `GIVEN empty quotes WHEN sendErrorIfNeeded THEN send no providers error`() { - val eventSlot = slot() - every { analyticsEventHandler.send(capture(eventSlot)) } returns Unit + fun `GIVEN empty quotes WHEN sendErrorIfNeeded THEN do not send analytics`() { + send(quotes = emptyList(), selectedQuote = null) - sender.sendErrorIfNeeded(quotes = emptyList(), selectedQuote = null) - - verify(exactly = 1) { analyticsEventHandler.send(any()) } - val event = eventSlot.captured as SendWithSwapAnalyticEvents.SendWithSwapError - assertThat(event.errorScreen).isEqualTo(SendWithSwapAnalyticEvents.ErrorScreen.Amount) - assertThat(event.message).isEqualTo(SendWithSwapAnalyticsErrorMessages.EXPRESS_QUOTE_NO_PROVIDERS) + verify(exactly = 0) { analyticsEventHandler.send(any()) } } @Test - fun `GIVEN too small error quote WHEN sendErrorIfNeeded THEN send min amount error`() { + fun `GIVEN insufficient balance WHEN sendErrorIfNeeded THEN send ErrorInsufficientBalance with from token params`() { + val eventSlot = slot() + every { analyticsEventHandler.send(capture(eventSlot)) } returns Unit + + send(hasInsufficientBalance = true) + + verify(exactly = 1) { analyticsEventHandler.send(any()) } + val event = eventSlot.captured + assertThat(event).isInstanceOf(SendWithSwapAnalyticEvents.ErrorInsufficientBalance::class.java) + assertThat(event.event).isEqualTo("Error - Insufficient balance") + assertThat(event.params).containsExactly("Send Token", "ETH", "Send Blockchain", "Ethereum") + } + + @Test + fun `GIVEN insufficient balance and express error WHEN sendErrorIfNeeded THEN insufficient balance takes priority`() { val errorQuote = SwapQuoteUM.Error( provider = testProvider, expressError = ExpressError.AmountError.TooSmallError(code = 1001, amount = BigDecimal("0.01")), @@ -53,15 +89,32 @@ class SwapAmountAnalyticsSenderTest { val eventSlot = slot() every { analyticsEventHandler.send(capture(eventSlot)) } returns Unit - sender.sendErrorIfNeeded(quotes = listOf(errorQuote), selectedQuote = errorQuote) + send(quotes = listOf(errorQuote), selectedQuote = errorQuote, hasInsufficientBalance = true) verify(exactly = 1) { analyticsEventHandler.send(any()) } - val event = eventSlot.captured as SendWithSwapAnalyticEvents.SendWithSwapError - assertThat(event.message).isEqualTo(SendWithSwapAnalyticsErrorMessages.MIN_AMOUNT) + assertThat(eventSlot.captured).isInstanceOf(SendWithSwapAnalyticEvents.ErrorInsufficientBalance::class.java) } @Test - fun `GIVEN too big error quote WHEN sendErrorIfNeeded THEN send max amount error`() { + fun `GIVEN too small error quote WHEN sendErrorIfNeeded THEN send ErrorMinAmount with from token params`() { + val errorQuote = SwapQuoteUM.Error( + provider = testProvider, + expressError = ExpressError.AmountError.TooSmallError(code = 1001, amount = BigDecimal("0.01")), + ) + val eventSlot = slot() + every { analyticsEventHandler.send(capture(eventSlot)) } returns Unit + + send(quotes = listOf(errorQuote), selectedQuote = errorQuote) + + verify(exactly = 1) { analyticsEventHandler.send(any()) } + val event = eventSlot.captured + assertThat(event).isInstanceOf(SendWithSwapAnalyticEvents.ErrorMinAmount::class.java) + assertThat(event.event).isEqualTo("Error - Min amount") + assertThat(event.params).containsExactly("Send Token", "ETH", "Send Blockchain", "Ethereum") + } + + @Test + fun `GIVEN too big error quote WHEN sendErrorIfNeeded THEN send ErrorMaxAmount with from token params`() { val errorQuote = SwapQuoteUM.Error( provider = testProvider, expressError = ExpressError.AmountError.TooBigError(code = 1002, amount = BigDecimal("1000")), @@ -69,15 +122,17 @@ class SwapAmountAnalyticsSenderTest { val eventSlot = slot() every { analyticsEventHandler.send(capture(eventSlot)) } returns Unit - sender.sendErrorIfNeeded(quotes = listOf(errorQuote), selectedQuote = errorQuote) + send(quotes = listOf(errorQuote), selectedQuote = errorQuote) verify(exactly = 1) { analyticsEventHandler.send(any()) } - val event = eventSlot.captured as SendWithSwapAnalyticEvents.SendWithSwapError - assertThat(event.message).isEqualTo(SendWithSwapAnalyticsErrorMessages.MAX_AMOUNT) + val event = eventSlot.captured + assertThat(event).isInstanceOf(SendWithSwapAnalyticEvents.ErrorMaxAmount::class.java) + assertThat(event.event).isEqualTo("Error - Max amount") + assertThat(event.params).containsExactly("Send Token", "ETH", "Send Blockchain", "Ethereum") } @Test - fun `GIVEN unknown express error WHEN sendErrorIfNeeded THEN send express quote error with code`() { + fun `GIVEN unknown express error WHEN sendErrorIfNeeded THEN send ErrorExpressQuote with both tokens and code`() { val errorQuote = SwapQuoteUM.Error( provider = testProvider, expressError = ExpressError.InternalError(code = 500), @@ -85,18 +140,26 @@ class SwapAmountAnalyticsSenderTest { val eventSlot = slot() every { analyticsEventHandler.send(capture(eventSlot)) } returns Unit - sender.sendErrorIfNeeded(quotes = listOf(errorQuote), selectedQuote = errorQuote) + send(quotes = listOf(errorQuote), selectedQuote = errorQuote) verify(exactly = 1) { analyticsEventHandler.send(any()) } - val event = eventSlot.captured as SendWithSwapAnalyticEvents.SendWithSwapError - assertThat(event.message).isEqualTo("${SendWithSwapAnalyticsErrorMessages.EXPRESS_QUOTE}: code=500") + val event = eventSlot.captured + assertThat(event).isInstanceOf(SendWithSwapAnalyticEvents.ErrorExpressQuote::class.java) + assertThat(event.event).isEqualTo("Error - Express quote") + assertThat(event.params).containsExactly( + "Send Token", "ETH", + "Send Blockchain", "Ethereum", + "Receive Token", "BTC", + "Receive Blockchain", "Bitcoin", + "Error Description", "code=500", + ) } @Test fun `GIVEN content quote WHEN sendErrorIfNeeded THEN do not send analytics`() { val contentQuote = mockk() - sender.sendErrorIfNeeded(quotes = listOf(contentQuote), selectedQuote = contentQuote) + send(quotes = listOf(contentQuote), selectedQuote = contentQuote) verify(exactly = 0) { analyticsEventHandler.send(any()) } } @@ -108,8 +171,16 @@ class SwapAmountAnalyticsSenderTest { expressError = ExpressError.AmountError.TooSmallError(code = 1001, amount = BigDecimal("0.01")), ) - sender.sendErrorIfNeeded(quotes = listOf(errorQuote), selectedQuote = errorQuote) - sender.sendErrorIfNeeded(quotes = listOf(errorQuote), selectedQuote = errorQuote) + send(quotes = listOf(errorQuote), selectedQuote = errorQuote) + send(quotes = listOf(errorQuote), selectedQuote = errorQuote) + + verify(exactly = 1) { analyticsEventHandler.send(any()) } + } + + @Test + fun `GIVEN insufficient balance twice WHEN sendErrorIfNeeded THEN send analytics only once`() { + send(hasInsufficientBalance = true) + send(hasInsufficientBalance = true) verify(exactly = 1) { analyticsEventHandler.send(any()) } } @@ -125,8 +196,21 @@ class SwapAmountAnalyticsSenderTest { expressError = ExpressError.AmountError.TooBigError(code = 1002, amount = BigDecimal("1000")), ) - sender.sendErrorIfNeeded(quotes = listOf(smallError), selectedQuote = smallError) - sender.sendErrorIfNeeded(quotes = listOf(bigError), selectedQuote = bigError) + send(quotes = listOf(smallError), selectedQuote = smallError) + send(quotes = listOf(bigError), selectedQuote = bigError) + + verify(exactly = 2) { analyticsEventHandler.send(any()) } + } + + @Test + fun `GIVEN express error then insufficient balance WHEN sendErrorIfNeeded THEN send analytics twice`() { + val smallError = SwapQuoteUM.Error( + provider = testProvider, + expressError = ExpressError.AmountError.TooSmallError(code = 1001, amount = BigDecimal("0.01")), + ) + + send(quotes = listOf(smallError), selectedQuote = smallError) + send(quotes = listOf(smallError), selectedQuote = smallError, hasInsufficientBalance = true) verify(exactly = 2) { analyticsEventHandler.send(any()) } } @@ -139,9 +223,9 @@ class SwapAmountAnalyticsSenderTest { ) val contentQuote = mockk() - sender.sendErrorIfNeeded(quotes = listOf(errorQuote), selectedQuote = errorQuote) - sender.sendErrorIfNeeded(quotes = listOf(contentQuote), selectedQuote = contentQuote) - sender.sendErrorIfNeeded(quotes = listOf(errorQuote), selectedQuote = errorQuote) + send(quotes = listOf(errorQuote), selectedQuote = errorQuote) + send(quotes = listOf(contentQuote), selectedQuote = contentQuote) + send(quotes = listOf(errorQuote), selectedQuote = errorQuote) verify(exactly = 2) { analyticsEventHandler.send(any()) } } diff --git a/features/swap-v2/impl/src/test/java/com/tangem/features/swap/v2/impl/amount/model/transformers/SwapAmountSelectQuoteTransformerTest.kt b/features/swap-v2/impl/src/test/java/com/tangem/features/swap/v2/impl/amount/model/transformers/SwapAmountSelectQuoteTransformerTest.kt new file mode 100644 index 0000000000..b1776a7f6c --- /dev/null +++ b/features/swap-v2/impl/src/test/java/com/tangem/features/swap/v2/impl/amount/model/transformers/SwapAmountSelectQuoteTransformerTest.kt @@ -0,0 +1,185 @@ +package com.tangem.features.swap.v2.impl.amount.model.transformers + +import com.google.common.truth.Truth.assertThat +import com.tangem.core.ui.extensions.TextReference +import com.tangem.domain.express.models.ExpressError +import com.tangem.domain.express.models.ExpressProvider +import com.tangem.domain.express.models.ExpressProviderType +import com.tangem.domain.express.models.ExpressRateType +import com.tangem.domain.models.currency.CryptoCurrencyStatus +import com.tangem.domain.swap.models.SwapAmountType +import com.tangem.domain.swap.models.SwapCurrencies +import com.tangem.domain.swap.models.SwapDirection +import com.tangem.domain.swap.models.SwapRateMode +import com.tangem.features.swap.v2.impl.amount.entity.SwapAmountFieldUM +import com.tangem.features.swap.v2.impl.amount.entity.SwapAmountUM +import com.tangem.features.swap.v2.impl.common.entity.SwapQuoteUM +import io.mockk.every +import io.mockk.mockk +import kotlinx.collections.immutable.persistentListOf +import org.junit.Test +import java.math.BigDecimal + +internal class SwapAmountSelectQuoteTransformerTest { + + private val provider = ExpressProvider( + providerId = "test-provider", + name = "Test Provider", + type = ExpressProviderType.CEX, + imageLarge = "", + termsOfUse = null, + privacyPolicy = null, + slippage = null, + ) + + @Test + fun `GIVEN fixed mode quote with fromAmount exceeding primary balance WHEN transform THEN isPrimaryButtonEnabled is false`() { + // GIVEN + val prevState = buildContentState( + selectedAmountType = SwapAmountType.To, + primaryBalance = BigDecimal("10"), + ) + val quote = buildContentQuote(fromAmount = BigDecimal("20"), toAmount = BigDecimal("1")) + val transformer = buildTransformer(quoteUM = quote) + + // WHEN + val result = transformer.transform(prevState) + + // THEN + val content = result as SwapAmountUM.Content + assertThat(content.isPrimaryButtonEnabled).isFalse() + assertThat(content.selectedQuote).isEqualTo(quote) + } + + @Test + fun `GIVEN fixed mode quote with fromAmount within primary balance WHEN transform THEN isPrimaryButtonEnabled is true`() { + // GIVEN + val prevState = buildContentState( + selectedAmountType = SwapAmountType.To, + primaryBalance = BigDecimal("100"), + ) + val quote = buildContentQuote(fromAmount = BigDecimal("20"), toAmount = BigDecimal("1")) + val transformer = buildTransformer(quoteUM = quote) + + // WHEN + val result = transformer.transform(prevState) + + // THEN + val content = result as SwapAmountUM.Content + assertThat(content.isPrimaryButtonEnabled).isTrue() + } + + @Test + fun `GIVEN float mode with selectedAmountType From WHEN transform THEN isPrimaryButtonEnabled is true regardless of fromAmount`() { + // GIVEN + val prevState = buildContentState( + selectedAmountType = SwapAmountType.From, + primaryBalance = BigDecimal("10"), + ) + // fromAmount > balance, but we're in From-mode so the check must not fire + val quote = buildContentQuote(fromAmount = BigDecimal("20"), toAmount = BigDecimal("1")) + val transformer = buildTransformer(quoteUM = quote) + + // WHEN + val result = transformer.transform(prevState) + + // THEN + val content = result as SwapAmountUM.Content + assertThat(content.isPrimaryButtonEnabled).isTrue() + } + + @Test + fun `GIVEN quote is SwapQuoteUM Error WHEN transform THEN isPrimaryButtonEnabled is false`() { + // GIVEN + val prevState = buildContentState( + selectedAmountType = SwapAmountType.To, + primaryBalance = BigDecimal("100"), + ) + val errorQuote = SwapQuoteUM.Error( + provider = provider, + expressError = ExpressError.InternalError(code = 500), + ) + val transformer = buildTransformer(quoteUM = errorQuote) + + // WHEN + val result = transformer.transform(prevState) + + // THEN + val content = result as SwapAmountUM.Content + assertThat(content.isPrimaryButtonEnabled).isFalse() + assertThat(content.selectedQuote).isEqualTo(errorQuote) + } + + @Test + fun `GIVEN prevState is SwapAmountUM Empty WHEN transform THEN returns the same state`() { + // GIVEN + val prevState = SwapAmountUM.Empty(swapDirection = SwapDirection.Direct) + val quote = buildContentQuote(fromAmount = BigDecimal("20"), toAmount = BigDecimal("1")) + val transformer = buildTransformer(quoteUM = quote) + + // WHEN + val result = transformer.transform(prevState) + + // THEN + assertThat(result).isEqualTo(prevState) + } + + private fun buildTransformer(quoteUM: SwapQuoteUM): SwapAmountSelectQuoteTransformer { + return SwapAmountSelectQuoteTransformer( + quoteUM = quoteUM, + secondaryMaximumAmountBoundary = null, + secondaryMinimumAmountBoundary = null, + isNeedApplyFCARestrictions = false, + isBalanceHidden = false, + primaryMaximumAmountBoundary = null, + primaryMinimumAmountBoundary = null, + primaryFiatRateUSD = null, + secondaryFiatRateUSD = null, + ) + } + + private fun buildContentQuote(fromAmount: BigDecimal, toAmount: BigDecimal): SwapQuoteUM.Content { + return SwapQuoteUM.Content( + provider = provider, + toAmount = toAmount, + fromAmount = fromAmount, + toAmountValue = TextReference.EMPTY, + fromAmountValue = TextReference.EMPTY, + diffPercent = SwapQuoteUM.Content.DifferencePercent.Empty, + isSingleProvider = true, + rate = TextReference.EMPTY, + quoteId = null, + ) + } + + private fun buildContentState( + selectedAmountType: SwapAmountType, + primaryBalance: BigDecimal?, + ): SwapAmountUM.Content { + val primaryStatus = mockk(relaxed = true).also { status -> + every { status.value.amount } returns primaryBalance + every { status.value.fiatRate } returns null + every { status.value.fiatAmount } returns null + every { status.currency.symbol } returns "BTC" + every { status.currency.decimals } returns 8 + } + return SwapAmountUM.Content( + isPrimaryButtonEnabled = false, + swapDirection = SwapDirection.Direct, + selectedAmountType = selectedAmountType, + primaryAmount = SwapAmountFieldUM.Empty(SwapAmountType.From), + secondaryAmount = SwapAmountFieldUM.Empty(SwapAmountType.To), + primaryCryptoCurrencyStatus = primaryStatus, + secondaryCryptoCurrencyStatus = null, + swapRateType = ExpressRateType.Fixed, + swapRateMode = SwapRateMode.FIXED_ONLY, + priceImpact = null, + swapCurrencies = SwapCurrencies.EMPTY, + swapQuotes = persistentListOf(), + selectedQuote = SwapQuoteUM.Empty, + isShowFCAWarning = false, + appCurrency = null, + isShowBestRateAnimation = false, + ) + } +} \ No newline at end of file diff --git a/features/swap/impl/src/main/java/com/tangem/feature/swap/choosetoken/impl/converter/ChooseTokenListItemConverter.kt b/features/swap/impl/src/main/java/com/tangem/feature/swap/choosetoken/impl/converter/ChooseTokenListItemConverter.kt index 3ad65aa9dd..277772d1df 100644 --- a/features/swap/impl/src/main/java/com/tangem/feature/swap/choosetoken/impl/converter/ChooseTokenListItemConverter.kt +++ b/features/swap/impl/src/main/java/com/tangem/feature/swap/choosetoken/impl/converter/ChooseTokenListItemConverter.kt @@ -186,6 +186,7 @@ internal class ChooseTokenListItemConverter( PaymentAccountStatusValue.NotCreated, is PaymentAccountStatusValue.UnderReview, PaymentAccountStatusValue.Loading, + PaymentAccountStatusValue.Empty, -> return null is PaymentAccountStatusValue.Loaded -> status.cryptoCurrencyStatus } diff --git a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/model/ExpressTransactionsModel.kt b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/model/ExpressTransactionsModel.kt index e69efad8e5..f5455c0b58 100644 --- a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/model/ExpressTransactionsModel.kt +++ b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/model/ExpressTransactionsModel.kt @@ -202,7 +202,6 @@ internal class ExpressTransactionsModel @Inject constructor( expressTxStatusTaskScheduler.scheduleTask( scope = modelScope, task = PeriodicTask( - isDelayFirst = false, delay = EXPRESS_STATUS_UPDATE_DELAY, task = { try { diff --git a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/model/TokenDetailsModel.kt b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/model/TokenDetailsModel.kt index 8423c533ab..0c00b2b411 100644 --- a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/model/TokenDetailsModel.kt +++ b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/model/TokenDetailsModel.kt @@ -437,7 +437,6 @@ internal class TokenDetailsModel @Inject constructor( expressTxStatusTaskScheduler.scheduleTask( scope = modelScope, task = PeriodicTask( - isDelayFirst = false, delay = EXPRESS_STATUS_UPDATE_DELAY, task = { runSuspendCatching { diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/wallet/model/WalletModel.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/wallet/model/WalletModel.kt index bb44739865..da57b17d4e 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/wallet/model/WalletModel.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/wallet/model/WalletModel.kt @@ -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 { diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/analytics/WalletScreenAnalyticsEvent.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/analytics/WalletScreenAnalyticsEvent.kt index 5040a3728d..4462ad2539 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/analytics/WalletScreenAnalyticsEvent.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/analytics/WalletScreenAnalyticsEvent.kt @@ -54,6 +54,7 @@ sealed class WalletScreenAnalyticsEvent { data class ScreenOpened( private val hasMobileWallet: Boolean, private val accountsCount: Int?, + private val isBackedUp: Boolean, val theme: String, val isImported: Boolean, val referralId: String?, @@ -71,6 +72,7 @@ sealed class WalletScreenAnalyticsEvent { } put("Wallet Type", seedPhrase) put("App Currency", appCurrency) + put("Backuped", if (isBackedUp) "Yes" else "No") putAll(getReferralParams(referralId)) }, ), AppsFlyerIncludedEvent diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/domain/GetMultiWalletWarningsFactory.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/domain/GetMultiWalletWarningsFactory.kt index 36bc62a6cc..193dfee657 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/domain/GetMultiWalletWarningsFactory.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/domain/GetMultiWalletWarningsFactory.kt @@ -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) diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/domain/GetWalletNotificationsFactory.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/domain/GetWalletNotificationsFactory.kt index 203004c0fc..4f66d4e800 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/domain/GetWalletNotificationsFactory.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/domain/GetWalletNotificationsFactory.kt @@ -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) diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/converter/TangemPayMainBlockConverter.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/converter/TangemPayMainBlockConverter.kt index 77a4f8c17b..a005fd4832 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/converter/TangemPayMainBlockConverter.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/converter/TangemPayMainBlockConverter.kt @@ -53,6 +53,7 @@ internal class TangemPayMainBlockConverter( } }, ) + is PaymentAccountStatusValue.Empty -> TangemPayMainUM.Empty is PaymentAccountStatusValue.NotCreated -> TangemPayMainUM.Empty is PaymentAccountStatusValue.Loading -> TangemPayMainUM.Loading is PaymentAccountStatusValue.Loaded -> {