Updated on 2026-08-14

This commit is contained in:
Tangem 2026-04-16 14:41:37 +04:00
parent 179ee8f01b
commit ffc2e8a6db
6 changed files with 250 additions and 6 deletions

View file

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

View file

@ -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<SwapQuoteUM>, selectedQuote: SwapQuoteUM?) {
val errorMessage = resolveErrorMessage(quotes, selectedQuote)
if (errorMessage == lastSentErrorMessage) return
lastSentErrorMessage = errorMessage
if (errorMessage != null) {
analyticsEventHandler.send(
SendWithSwapAnalyticEvents.SendWithSwapError(
errorScreen = SendWithSwapAnalyticEvents.ErrorScreen.Amount,
message = errorMessage,
),
)
}
}
private fun resolveErrorMessage(quotes: List<SwapQuoteUM>, selectedQuote: SwapQuoteUM?): String? {
if (quotes.isEmpty()) return SendWithSwapAnalyticsErrorMessages.EXPRESS_QUOTE_NO_PROVIDERS
val error = (selectedQuote as? SwapQuoteUM.Error)?.expressError ?: return null
return when (error) {
is ExpressError.AmountError.TooSmallError -> SendWithSwapAnalyticsErrorMessages.MIN_AMOUNT
is ExpressError.AmountError.TooBigError -> SendWithSwapAnalyticsErrorMessages.MAX_AMOUNT
else -> "${SendWithSwapAnalyticsErrorMessages.EXPRESS_QUOTE}: code=${error.code}"
}
}
}

View file

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

View file

@ -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<String> = emptySet()
val uiState: StateFlow<ImmutableList<NotificationUM>>
field = MutableStateFlow<ImmutableList<NotificationUM>>(persistentListOf())
@ -110,6 +112,8 @@ internal class SwapNotificationsModel @Inject constructor(
)
}
}
sendErrorAnalyticsIfNeeded(notifications)
}
private suspend fun MutableList<NotificationUM>.addDestinationTagRequiredNotification() {
@ -189,4 +193,34 @@ internal class SwapNotificationsModel @Inject constructor(
add(notification)
}
private fun sendErrorAnalyticsIfNeeded(notifications: List<NotificationUM>) {
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,
),
)
}
}
}

View file

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

View file

@ -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<AnalyticsEventHandler>(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<AnalyticsEvent>()
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<AnalyticsEvent>()
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<AnalyticsEvent>()
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<AnalyticsEvent>()
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<SwapQuoteUM.Content>()
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<SwapQuoteUM.Content>()
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()) }
}
}