diff --git a/features/swap-v2/impl/build.gradle.kts b/features/swap-v2/impl/build.gradle.kts index 612a39dcc8..76f57cbad9 100644 --- a/features/swap-v2/impl/build.gradle.kts +++ b/features/swap-v2/impl/build.gradle.kts @@ -92,4 +92,9 @@ dependencies { /** DI */ implementation(deps.hilt.android) kapt(deps.hilt.kapt) + + /** Test */ + testImplementation(deps.test.junit) + testImplementation(deps.test.truth) + testImplementation(deps.test.mockk) } \ No newline at end of file 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 new file mode 100644 index 0000000000..d6b20adde2 --- /dev/null +++ b/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/amount/analytics/SwapAmountAnalyticsSender.kt @@ -0,0 +1,38 @@ +package com.tangem.features.swap.v2.impl.amount.analytics + +import com.tangem.core.analytics.api.AnalyticsEventHandler +import com.tangem.domain.express.models.ExpressError +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 + + 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, + ), + ) + } + } + + 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 dcf53ad59d..b033d4f8cf 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 @@ -46,6 +46,7 @@ import com.tangem.features.swap.v2.impl.amount.SwapAmountComponentParams import com.tangem.features.swap.v2.impl.amount.SwapAmountReduceListener import com.tangem.features.swap.v2.impl.amount.SwapAmountUpdateListener import com.tangem.features.swap.v2.impl.amount.analytics.SwapAmountAnalyticEvents +import com.tangem.features.swap.v2.impl.amount.analytics.SwapAmountAnalyticsSender 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.amount.model.converter.SwapQuoteUMConverter @@ -56,6 +57,7 @@ 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 @@ -120,7 +122,8 @@ internal class SwapAmountModel @Inject constructor( val rateInfoNavigation: SlotNavigation = SlotNavigation() private var isShowBestRateAnimation: Boolean = false - private var lastAmountScreenOpenedCurrencyId: CryptoCurrency.ID? = null + private var isAmountScreenOpenedSent: Boolean = false + private val amountAnalyticsSender = SwapAmountAnalyticsSender(analyticsEventHandler) private var autoUpdateSubscriberJob: Job? = null @@ -608,7 +611,7 @@ internal class SwapAmountModel @Inject constructor( @Suppress("NullableToStringCall") TangemLogger.e( """ - Invalid cryptocurrencies status: + Invalid cryptocurrencies status: | Primary -> $primaryStatus | Secondary -> $secondaryStatus """.trimIndent(), @@ -616,7 +619,8 @@ internal class SwapAmountModel @Inject constructor( analyticsEventHandler.send( SendWithSwapAnalyticEvents.SendWithSwapError( errorScreen = SendWithSwapAnalyticEvents.ErrorScreen.Amount, - message = "Invalid cryptocurrencies status: primary=$primaryStatus, secondary=$secondaryStatus", + message = "${SendWithSwapAnalyticsErrorMessages.INVALID_CRYPTOCURRENCIES_STATUS}: " + + "primary=$primaryStatus, secondary=$secondaryStatus", ), ) showErrorAlert(errorMessage = null) @@ -625,9 +629,9 @@ internal class SwapAmountModel @Inject constructor( } private fun sendAmountScreenOpenedIfNeeded(secondaryStatus: CryptoCurrencyStatus) { - val currencyId = secondaryStatus.currency.id - if (lastAmountScreenOpenedCurrencyId == currencyId) return - lastAmountScreenOpenedCurrencyId = currencyId + if (params !is SwapAmountComponentParams.AmountParams) return + if (isAmountScreenOpenedSent) return + isAmountScreenOpenedSent = true val content = uiState.value as? SwapAmountUM.Content ?: return @@ -778,6 +782,10 @@ internal class SwapAmountModel @Inject constructor( secondaryFiatRateUSD = secondaryFiatRateUSD, ), ) + if (params is SwapAmountComponentParams.AmountParams) { + val selectedQuote = (uiState.value as? SwapAmountUM.Content)?.selectedQuote + amountAnalyticsSender.sendErrorIfNeeded(quotes, selectedQuote) + } feeSelectorReloadTrigger.triggerUpdate() } } diff --git a/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/notifications/model/SwapNotificationsModel.kt b/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/notifications/model/SwapNotificationsModel.kt index bbfb56b4ad..0031fd2efd 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 @@ -17,6 +17,7 @@ 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 @@ -44,6 +45,7 @@ internal class SwapNotificationsModel @Inject constructor( private val params: SwapNotificationsComponent.Params = paramsContainer.require() private var notificationData = params.swapNotificationData + private var lastSentErrorMessages: Set = emptySet() val uiState: StateFlow> field = MutableStateFlow>(persistentListOf()) @@ -110,6 +112,8 @@ internal class SwapNotificationsModel @Inject constructor( ) } } + + sendErrorAnalyticsIfNeeded(notifications) } private suspend fun MutableList.addDestinationTagRequiredNotification() { @@ -189,4 +193,34 @@ internal class SwapNotificationsModel @Inject constructor( add(notification) } + + private fun sendErrorAnalyticsIfNeeded(notifications: List) { + val currentErrors = notifications.mapNotNull { notification -> + when (notification) { + is SwapNotificationUM.Error.InsufficientFunds -> + SendWithSwapAnalyticsErrorMessages.INSUFFICIENT_BALANCE + is SwapNotificationUM.Error.MinimalAmountError -> + SendWithSwapAnalyticsErrorMessages.MIN_AMOUNT + 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 + else -> null + } + }.toSet() + + val newErrors = currentErrors - lastSentErrorMessages + lastSentErrorMessages = currentErrors + + newErrors.forEach { errorMessage -> + analyticsEventHandler.send( + SendWithSwapAnalyticEvents.SendWithSwapError( + errorScreen = SendWithSwapAnalyticEvents.ErrorScreen.Confirm, + message = errorMessage, + ), + ) + } + } } \ No newline at end of file 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 new file mode 100644 index 0000000000..610a9c1159 --- /dev/null +++ b/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/sendviaswap/analytics/SendWithSwapAnalyticsErrorMessages.kt @@ -0,0 +1,11 @@ +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/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 new file mode 100644 index 0000000000..26f07c6275 --- /dev/null +++ b/features/swap-v2/impl/src/test/java/com/tangem/features/swap/v2/impl/amount/analytics/SwapAmountAnalyticsSenderTest.kt @@ -0,0 +1,148 @@ +package com.tangem.features.swap.v2.impl.amount.analytics + +import com.google.common.truth.Truth.assertThat +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.express.models.ExpressProvider +import com.tangem.domain.express.models.ExpressProviderType +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 +import io.mockk.verify +import org.junit.Test +import java.math.BigDecimal + +class SwapAmountAnalyticsSenderTest { + + private val analyticsEventHandler = mockk(relaxed = true) + private val sender = SwapAmountAnalyticsSender(analyticsEventHandler) + + private val testProvider = ExpressProvider( + providerId = "test", + name = "Test Provider", + type = ExpressProviderType.CEX, + imageLarge = "", + termsOfUse = null, + privacyPolicy = null, + slippage = null, + ) + + @Test + fun `GIVEN empty quotes WHEN sendErrorIfNeeded THEN send no providers error`() { + val eventSlot = slot() + every { analyticsEventHandler.send(capture(eventSlot)) } returns Unit + + 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) + } + + @Test + fun `GIVEN too small error quote WHEN sendErrorIfNeeded THEN send min amount error`() { + 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 + + sender.sendErrorIfNeeded(quotes = listOf(errorQuote), selectedQuote = errorQuote) + + verify(exactly = 1) { analyticsEventHandler.send(any()) } + val event = eventSlot.captured as SendWithSwapAnalyticEvents.SendWithSwapError + assertThat(event.message).isEqualTo(SendWithSwapAnalyticsErrorMessages.MIN_AMOUNT) + } + + @Test + fun `GIVEN too big error quote WHEN sendErrorIfNeeded THEN send max amount error`() { + val errorQuote = SwapQuoteUM.Error( + provider = testProvider, + expressError = ExpressError.AmountError.TooBigError(code = 1002, amount = BigDecimal("1000")), + ) + val eventSlot = slot() + every { analyticsEventHandler.send(capture(eventSlot)) } returns Unit + + sender.sendErrorIfNeeded(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) + } + + @Test + fun `GIVEN unknown express error WHEN sendErrorIfNeeded THEN send express quote error with code`() { + val errorQuote = SwapQuoteUM.Error( + provider = testProvider, + expressError = ExpressError.InternalError(code = 500), + ) + val eventSlot = slot() + every { analyticsEventHandler.send(capture(eventSlot)) } returns Unit + + sender.sendErrorIfNeeded(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") + } + + @Test + fun `GIVEN content quote WHEN sendErrorIfNeeded THEN do not send analytics`() { + val contentQuote = mockk() + + sender.sendErrorIfNeeded(quotes = listOf(contentQuote), selectedQuote = contentQuote) + + verify(exactly = 0) { analyticsEventHandler.send(any()) } + } + + @Test + fun `GIVEN same error twice WHEN sendErrorIfNeeded THEN send analytics only once`() { + val errorQuote = SwapQuoteUM.Error( + provider = testProvider, + expressError = ExpressError.AmountError.TooSmallError(code = 1001, amount = BigDecimal("0.01")), + ) + + sender.sendErrorIfNeeded(quotes = listOf(errorQuote), selectedQuote = errorQuote) + sender.sendErrorIfNeeded(quotes = listOf(errorQuote), selectedQuote = errorQuote) + + verify(exactly = 1) { analyticsEventHandler.send(any()) } + } + + @Test + fun `GIVEN error then different error WHEN sendErrorIfNeeded THEN send analytics twice`() { + val smallError = SwapQuoteUM.Error( + provider = testProvider, + expressError = ExpressError.AmountError.TooSmallError(code = 1001, amount = BigDecimal("0.01")), + ) + val bigError = SwapQuoteUM.Error( + provider = testProvider, + expressError = ExpressError.AmountError.TooBigError(code = 1002, amount = BigDecimal("1000")), + ) + + sender.sendErrorIfNeeded(quotes = listOf(smallError), selectedQuote = smallError) + sender.sendErrorIfNeeded(quotes = listOf(bigError), selectedQuote = bigError) + + verify(exactly = 2) { analyticsEventHandler.send(any()) } + } + + @Test + fun `GIVEN error then success then same error WHEN sendErrorIfNeeded THEN send analytics twice`() { + val errorQuote = SwapQuoteUM.Error( + provider = testProvider, + expressError = ExpressError.AmountError.TooSmallError(code = 1001, amount = BigDecimal("0.01")), + ) + val contentQuote = mockk() + + sender.sendErrorIfNeeded(quotes = listOf(errorQuote), selectedQuote = errorQuote) + sender.sendErrorIfNeeded(quotes = listOf(contentQuote), selectedQuote = contentQuote) + sender.sendErrorIfNeeded(quotes = listOf(errorQuote), selectedQuote = errorQuote) + + verify(exactly = 2) { analyticsEventHandler.send(any()) } + } +} \ No newline at end of file