diff --git a/domain/tokens/models/src/main/java/com/tangem/domain/tokens/model/analytics/TokenScreenAnalyticsEvent.kt b/domain/tokens/models/src/main/java/com/tangem/domain/tokens/model/analytics/TokenScreenAnalyticsEvent.kt index c2c7e8aa7c..32709d5676 100644 --- a/domain/tokens/models/src/main/java/com/tangem/domain/tokens/model/analytics/TokenScreenAnalyticsEvent.kt +++ b/domain/tokens/models/src/main/java/com/tangem/domain/tokens/model/analytics/TokenScreenAnalyticsEvent.kt @@ -22,14 +22,18 @@ sealed class TokenScreenAnalyticsEvent( blockchain: String, token: String, tokenBalance: TokenBalance, + isDynamicAddress: Boolean? = null, ) : AnalyticsEvent( category = "Details Screen", event = "Details Screen Opened", - params = mapOf( - BLOCKCHAIN to blockchain, - TOKEN_PARAM to token, - BALANCE to tokenBalance.name, - ), + params = buildMap { + put(BLOCKCHAIN, blockchain) + put(TOKEN_PARAM, token) + put(BALANCE, tokenBalance.name) + isDynamicAddress?.let { + put("Dynamic Address", if (it) "True" else "False") + } + }, ) { sealed class TokenBalance(val name: String) { data object Full : TokenBalance("Full") diff --git a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/analytics/TokenDetailsAnalyticsEvent.kt b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/analytics/TokenDetailsAnalyticsEvent.kt index dcbe0119f9..56ed840731 100644 --- a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/analytics/TokenDetailsAnalyticsEvent.kt +++ b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/analytics/TokenDetailsAnalyticsEvent.kt @@ -9,6 +9,31 @@ internal open class TokenDetailsAnalyticsEvent( params: Map = mapOf(), ) : AnalyticsEvent(category = "Token", event, params) { + class DynamicAddressesScreenOpened(currency: CryptoCurrency) : TokenDetailsAnalyticsEvent( + event = "Dynamic Addresses Screen Opened", + params = currency.toAnalyticsParams(), + ) + + class ButtonEnableDynamicAddresses(currency: CryptoCurrency) : TokenDetailsAnalyticsEvent( + event = "Button - Enable Dynamic Addresses", + params = currency.toAnalyticsParams(), + ) + + class DynamicAddressesEnabled(currency: CryptoCurrency) : TokenDetailsAnalyticsEvent( + event = "Dynamic Addresses Enabled", + params = currency.toAnalyticsParams(), + ) + + class ButtonDisableDynamicAddresses(currency: CryptoCurrency) : TokenDetailsAnalyticsEvent( + event = "Button - Disable Dynamic Addresses", + params = currency.toAnalyticsParams(), + ) + + class DynamicAddressesDisabled(currency: CryptoCurrency) : TokenDetailsAnalyticsEvent( + event = "Dynamic Addresses Disabled", + params = currency.toAnalyticsParams(), + ) + open class Notice( event: String, params: Map = mapOf(), @@ -19,14 +44,40 @@ internal open class TokenDetailsAnalyticsEvent( params = currency.toAnalyticsParams(), ) - class NotEnoughFee(currency: CryptoCurrency) : Notice( + class NotEnoughFee(currency: CryptoCurrency, source: Source) : Notice( event = "Not Enough Fee", - params = currency.toAnalyticsParams(), - ) + params = currency.toAnalyticsParams() + ("Source" to source.value), + ) { + enum class Source(val value: String) { + DetailedScreen("Detailed Screen"), + DynamicAddresses("Dynamic Addresses"), + } + } class Reveal(currency: CryptoCurrency) : Notice( event = "Reveal Transaction", params = currency.toAnalyticsParams(), ) + + class DynamicAddressesUnavailable(currency: CryptoCurrency) : Notice( + event = "Dynamic Addresses Unavailable", + params = currency.toAnalyticsParams(), + ) + + class AdditionalAddressesFound(currency: CryptoCurrency) : Notice( + event = "Additional Addresses Found", + params = currency.toAnalyticsParams(), + ) + } + + open class Error( + event: String, + params: Map = emptyMap(), + ) : TokenDetailsAnalyticsEvent(event = "Error - $event", params) { + + class DynamicAddressesUnavailable(currency: CryptoCurrency) : Error( + event = "Dynamic Addresses Unavailable", + params = currency.toAnalyticsParams(), + ) } } \ No newline at end of file diff --git a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/analytics/TokenDetailsNotificationsAnalyticsSender.kt b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/analytics/TokenDetailsNotificationsAnalyticsSender.kt index ba37caff40..7e71e96ddf 100644 --- a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/analytics/TokenDetailsNotificationsAnalyticsSender.kt +++ b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/analytics/TokenDetailsNotificationsAnalyticsSender.kt @@ -37,6 +37,7 @@ internal class TokenDetailsNotificationsAnalyticsSender( is TokenDetailsNotification.NetworkFeeWithBuyButton, -> TokenDetailsAnalyticsEvent.Notice.NotEnoughFee( currency = cryptoCurrency, + source = TokenDetailsAnalyticsEvent.Notice.NotEnoughFee.Source.DetailedScreen, ) is TokenDetailsNotification.SwapPromo -> PromoAnalyticsEvent.NoticePromotionBanner( program = PromoAnalyticsEvent.Program.Empty, // Use it on new promo action @@ -49,6 +50,10 @@ internal class TokenDetailsNotificationsAnalyticsSender( token = cryptoCurrency.symbol, blockchain = cryptoCurrency.network.name, ) + is TokenDetailsNotification.DynamicAddressesFundsFound -> + TokenDetailsAnalyticsEvent.Notice.AdditionalAddressesFound( + currency = cryptoCurrency, + ) is TokenDetailsNotification.NetworksUnreachable, is TokenDetailsNotification.ExistentialDeposit, is TokenDetailsNotification.NetworksNoAccount, diff --git a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/model/DynamicAddressesDelegate.kt b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/model/DynamicAddressesDelegate.kt index 63f7200f14..b2c42270c5 100644 --- a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/model/DynamicAddressesDelegate.kt +++ b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/model/DynamicAddressesDelegate.kt @@ -1,6 +1,8 @@ package com.tangem.feature.tokendetails.presentation.tokendetails.model import com.tangem.common.core.TangemSdkError +import com.tangem.core.analytics.api.AnalyticsEventHandler +import com.tangem.core.analytics.api.ResettableOneTimeEventSender import com.tangem.core.decompose.di.GlobalUiMessageSender import com.tangem.core.decompose.ui.UiMessageSender import com.tangem.core.res.R @@ -23,6 +25,7 @@ import com.tangem.domain.transaction.error.SendTransactionError import com.tangem.domain.transaction.usecase.GetFeeUseCase import com.tangem.domain.transaction.usecase.SendTransactionUseCase import com.tangem.domain.wallets.usecase.GetExtendedPublicKeyForCurrencyUseCase +import com.tangem.feature.tokendetails.presentation.tokendetails.analytics.TokenDetailsAnalyticsEvent import com.tangem.feature.tokendetails.presentation.tokendetails.ui.components.dynamicaddresses.DynamicAddressesBottomSheetConfig import com.tangem.utils.coroutines.CoroutineDispatcherProvider import com.tangem.utils.coroutines.runSuspendCatching @@ -37,7 +40,7 @@ import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch -@Suppress("LongParameterList") +@Suppress("LongParameterList", "LargeClass") internal class DynamicAddressesDelegate @AssistedInject constructor( private val enableDynamicAddressesUseCase: EnableDynamicAddressesUseCase, private val disableDynamicAddressesUseCase: DisableDynamicAddressesUseCase, @@ -47,6 +50,7 @@ internal class DynamicAddressesDelegate @AssistedInject constructor( private val getDerivedXpubUseCase: GetDerivedXpubUseCase, private val dynamicAddressesRepository: DynamicAddressesRepository, private val getExtendedPublicKeyUseCase: GetExtendedPublicKeyForCurrencyUseCase, + private val analyticsEventHandler: AnalyticsEventHandler, @GlobalUiMessageSender private val uiMessageSender: UiMessageSender, private val dispatchers: CoroutineDispatcherProvider, @Assisted private val userWallet: UserWallet, @@ -68,17 +72,20 @@ internal class DynamicAddressesDelegate @AssistedInject constructor( ) val bottomSheetConfig: StateFlow = _bottomSheetConfig.asStateFlow() + private val resettableOneTimeEventSender = ResettableOneTimeEventSender(analyticsEventHandler) + // region Entry point fun onDynamicAddressesClick() { - val network = cryptoCurrencyStatusProvider()?.currency?.network ?: return + val currency = cryptoCurrencyStatusProvider()?.currency ?: return + analyticsEventHandler.send(TokenDetailsAnalyticsEvent.DynamicAddressesScreenOpened(currency)) coroutineScope.launch(dispatchers.main) { - val status = dynamicAddressesRepository.getStatus(userWalletId, network).first() + val status = dynamicAddressesRepository.getStatus(userWalletId, currency.network).first() when (status) { DynamicAddressesStatus.ENABLED, DynamicAddressesStatus.ENABLED_REQUIRES_SETUP, - -> onDisableFlow(network) - DynamicAddressesStatus.DISABLED -> onEnableFlow(network) + -> onDisableFlow(currency.network) + DynamicAddressesStatus.DISABLED -> onEnableFlow(currency.network) } } } @@ -90,6 +97,9 @@ internal class DynamicAddressesDelegate @AssistedInject constructor( private suspend fun onEnableFlow(network: Network) { val hasConflicts = dynamicAddressesRepository.hasConflictingCustomTokens(userWalletId, network) if (hasConflicts) { + cryptoCurrencyStatusProvider()?.currency?.let { + analyticsEventHandler.send(TokenDetailsAnalyticsEvent.Notice.DynamicAddressesUnavailable(it)) + } _bottomSheetConfig.value = DynamicAddressesBottomSheetConfig.ConflictingCustomTokens( onDismissClick = dismissBottomSheet, ) @@ -106,7 +116,9 @@ internal class DynamicAddressesDelegate @AssistedInject constructor( } private fun onEnableClick() { - val network = cryptoCurrencyStatusProvider()?.currency?.network ?: return + val currency = cryptoCurrencyStatusProvider()?.currency ?: return + val network = currency.network + analyticsEventHandler.send(TokenDetailsAnalyticsEvent.ButtonEnableDynamicAddresses(currency)) coroutineScope.launch(dispatchers.main) { _bottomSheetConfig.value = DynamicAddressesBottomSheetConfig.Enable( isCardScanRequired = false, @@ -120,6 +132,9 @@ internal class DynamicAddressesDelegate @AssistedInject constructor( dismissBottomSheet() } else { TangemLogger.e("Failed to get XPUB: ${error.message}") + analyticsEventHandler.send( + TokenDetailsAnalyticsEvent.Error.DynamicAddressesUnavailable(currency), + ) _bottomSheetConfig.value = DynamicAddressesBottomSheetConfig.ServiceUnavailable( onDismissClick = dismissBottomSheet, ) @@ -131,21 +146,24 @@ internal class DynamicAddressesDelegate @AssistedInject constructor( enableDynamicAddressesUseCase(userWalletId, network, xpub).fold( ifLeft = { error -> - when (error) { - is EnableDynamicAddressesError.ConflictingCustomTokens -> { - _bottomSheetConfig.value = DynamicAddressesBottomSheetConfig.ConflictingCustomTokens( + analyticsEventHandler.send( + TokenDetailsAnalyticsEvent.Error.DynamicAddressesUnavailable(currency), + ) + _bottomSheetConfig.value = when (error) { + is EnableDynamicAddressesError.ConflictingCustomTokens -> + DynamicAddressesBottomSheetConfig.ConflictingCustomTokens( onDismissClick = dismissBottomSheet, ) - } is EnableDynamicAddressesError.ServiceError -> { TangemLogger.e("Failed to enable dynamic addresses: ${error.cause.message}") - _bottomSheetConfig.value = DynamicAddressesBottomSheetConfig.ServiceUnavailable( + DynamicAddressesBottomSheetConfig.ServiceUnavailable( onDismissClick = dismissBottomSheet, ) } } }, ifRight = { + analyticsEventHandler.send(TokenDetailsAnalyticsEvent.DynamicAddressesEnabled(currency)) dismissBottomSheet() onDynamicAddressesStateChanged() uiMessageSender.send( @@ -190,10 +208,13 @@ internal class DynamicAddressesDelegate @AssistedInject constructor( } private fun onSimpleDisableClick() { - val network = cryptoCurrencyStatusProvider()?.currency?.network ?: return + val currency = cryptoCurrencyStatusProvider()?.currency ?: return + val network = currency.network + analyticsEventHandler.send(TokenDetailsAnalyticsEvent.ButtonDisableDynamicAddresses(currency)) coroutineScope.launch(dispatchers.main) { runSuspendCatching { dynamicAddressesRepository.disable(userWalletId, network) } .onSuccess { + analyticsEventHandler.send(TokenDetailsAnalyticsEvent.DynamicAddressesDisabled(currency)) dismissBottomSheet() onDynamicAddressesStateChanged() uiMessageSender.send( @@ -210,6 +231,7 @@ internal class DynamicAddressesDelegate @AssistedInject constructor( } private fun showDisableSheetAndLoadFee() { + resettableOneTimeEventSender.reset(NOT_ENOUGH_FEE_EVENT_KEY) _bottomSheetConfig.value = DynamicAddressesBottomSheetConfig.DisableWithConsolidation( feeState = DynamicAddressesBottomSheetConfig.DisableFeeState.Loading, onDisableClick = ::onDisableClick, @@ -245,6 +267,13 @@ internal class DynamicAddressesDelegate @AssistedInject constructor( cryptoCurrency = currency, ).fold( ifLeft = { + resettableOneTimeEventSender.sendEventOnce( + key = NOT_ENOUGH_FEE_EVENT_KEY, + event = TokenDetailsAnalyticsEvent.Notice.NotEnoughFee( + currency = currency, + source = TokenDetailsAnalyticsEvent.Notice.NotEnoughFee.Source.DynamicAddresses, + ), + ) _bottomSheetConfig.value = disableWithConsolidationConfig().copy( feeState = DynamicAddressesBottomSheetConfig.DisableFeeState.Error, ) @@ -282,7 +311,9 @@ internal class DynamicAddressesDelegate @AssistedInject constructor( } private fun onDisableClick() { - val network = cryptoCurrencyStatusProvider()?.currency?.network ?: return + val currency = cryptoCurrencyStatusProvider()?.currency ?: return + val network = currency.network + analyticsEventHandler.send(TokenDetailsAnalyticsEvent.ButtonDisableDynamicAddresses(currency)) coroutineScope.launch(dispatchers.main) { _bottomSheetConfig.value = disableWithConsolidationConfig().copy( isSending = true, @@ -320,6 +351,7 @@ internal class DynamicAddressesDelegate @AssistedInject constructor( } catch (e: Exception) { TangemLogger.e("Failed to disable dynamic addresses after consolidation: ${e.message}") } + analyticsEventHandler.send(TokenDetailsAnalyticsEvent.DynamicAddressesDisabled(currency)) dismissBottomSheet() onDynamicAddressesStateChanged() uiMessageSender.send( @@ -359,4 +391,8 @@ internal class DynamicAddressesDelegate @AssistedInject constructor( @Assisted("onDynamicAddressesStateChanged") onDynamicAddressesStateChanged: () -> Unit, ): DynamicAddressesDelegate } + + private companion object { + const val NOT_ENOUGH_FEE_EVENT_KEY = "DynamicAddressesNotEnoughFee" + } } \ No newline at end of file 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 0c00b2b411..f6ea084bff 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 @@ -13,6 +13,7 @@ import com.tangem.domain.dynamicaddresses.DynamicAddressesDerivationChecker import com.tangem.domain.dynamicaddresses.DynamicAddressesFeatureToggles import com.tangem.domain.dynamicaddresses.DynamicAddressesSupportedBlockchains import com.tangem.domain.dynamicaddresses.IsXpubSupportedUseCase +import com.tangem.domain.dynamicaddresses.repository.DynamicAddressesRepository import com.tangem.common.TangemBlogUrlBuilder import com.tangem.common.routing.AppRoute import com.tangem.common.routing.AppRouter @@ -176,6 +177,7 @@ internal class TokenDetailsModel @Inject constructor( private val signCloreMessageUseCase: SignCloreMessageUseCase, private val isXpubSupportedUseCase: IsXpubSupportedUseCase, private val dynamicAddressesFeatureToggles: DynamicAddressesFeatureToggles, + private val dynamicAddressesRepository: DynamicAddressesRepository, private val dynamicAddressesDelegateFactory: DynamicAddressesDelegate.Factory, private val dialogFactory: TokenDetailsDialogFactory, private val userWalletsListRepository: UserWalletsListRepository, @@ -1258,7 +1260,7 @@ internal class TokenDetailsModel @Inject constructor( return TokenDetailsBottomSheetConfig.Receive(receiveConfig) } - private fun sendOneTimeBalanceLoadedAnalyticsEvent(cryptoCurrencyStatus: CryptoCurrencyStatus?) { + private suspend fun sendOneTimeBalanceLoadedAnalyticsEvent(cryptoCurrencyStatus: CryptoCurrencyStatus?) { if (isBalanceLoadedEventSent || cryptoCurrencyStatus == null) return val tokenBalance = when (val value = cryptoCurrencyStatus.value) { @@ -1290,11 +1292,19 @@ internal class TokenDetailsModel @Inject constructor( blockchain = cryptoCurrency.network.name, token = cryptoCurrency.symbol, tokenBalance = tokenBalance, + isDynamicAddress = getIsDynamicAddressParam(), ), ) isBalanceLoadedEventSent = true } + private suspend fun getIsDynamicAddressParam(): Boolean? { + if (!DynamicAddressesSupportedBlockchains.isSupportedByNetworkId(cryptoCurrency.network.rawId)) return null + return dynamicAddressesRepository + .isDynamicAddressesEnabledForNetwork(userWalletId, cryptoCurrency.network.id) + .first() + } + private suspend fun needShowYieldSupplyWarning(): Boolean { return needShowYieldSupplyDepositedWarningUseCase(cryptoCurrencyStatus) } diff --git a/features/tokendetails/impl/src/test/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/analytics/TokenDetailsNotificationsAnalyticsSenderTest.kt b/features/tokendetails/impl/src/test/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/analytics/TokenDetailsNotificationsAnalyticsSenderTest.kt new file mode 100644 index 0000000000..89f9583fd0 --- /dev/null +++ b/features/tokendetails/impl/src/test/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/analytics/TokenDetailsNotificationsAnalyticsSenderTest.kt @@ -0,0 +1,137 @@ +package com.tangem.feature.tokendetails.presentation.tokendetails.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.models.currency.CryptoCurrency +import com.tangem.domain.models.network.Network +import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenDetailsState +import com.tangem.feature.tokendetails.presentation.tokendetails.state.components.TokenDetailsNotification +import io.mockk.every +import io.mockk.mockk +import io.mockk.slot +import io.mockk.verify +import kotlinx.collections.immutable.toPersistentList +import org.junit.jupiter.api.Test + +internal class TokenDetailsNotificationsAnalyticsSenderTest { + + private val analyticsEventHandler: AnalyticsEventHandler = mockk(relaxed = true) + + private val network: Network = mockk(relaxed = true) { + every { name } returns "Ethereum" + } + private val cryptoCurrency: CryptoCurrency = mockk(relaxed = true) { + every { symbol } returns "ETH" + every { this@mockk.network } returns this@TokenDetailsNotificationsAnalyticsSenderTest.network + } + + private val sender = TokenDetailsNotificationsAnalyticsSender( + cryptoCurrency = cryptoCurrency, + analyticsEventHandler = analyticsEventHandler, + ) + + @Test + fun `GIVEN NetworkFee notification WHEN send THEN NotEnoughFee event with DetailedScreen source is sent`() { + // GIVEN + val notification = mockk() + val displayedState = createState(notifications = emptyList(), isRefreshing = false) + val eventSlot = slot() + every { analyticsEventHandler.send(capture(eventSlot)) } returns Unit + + // WHEN + sender.send(displayedUiState = displayedState, newNotifications = listOf(notification)) + + // THEN + val event = eventSlot.captured as TokenDetailsAnalyticsEvent.Notice.NotEnoughFee + assertThat(event.category).isEqualTo("Token") + assertThat(event.event).isEqualTo("Notice - Not Enough Fee") + assertThat(event.params).containsEntry("Token", "ETH") + assertThat(event.params).containsEntry("Blockchain", "Ethereum") + assertThat(event.params).containsEntry("Source", "Detailed Screen") + } + + @Test + fun `GIVEN NetworkFeeWithBuyButton notification WHEN send THEN NotEnoughFee event with DetailedScreen source is sent`() { + // GIVEN + val notification = mockk() + val displayedState = createState(notifications = emptyList(), isRefreshing = false) + val eventSlot = slot() + every { analyticsEventHandler.send(capture(eventSlot)) } returns Unit + + // WHEN + sender.send(displayedUiState = displayedState, newNotifications = listOf(notification)) + + // THEN + val event = eventSlot.captured as TokenDetailsAnalyticsEvent.Notice.NotEnoughFee + assertThat(event.params).containsEntry("Source", "Detailed Screen") + } + + @Test + fun `GIVEN DynamicAddressesFundsFound notification WHEN send THEN AdditionalAddressesFound event is sent`() { + // GIVEN + val notification = TokenDetailsNotification.DynamicAddressesFundsFound(onLearnMoreClick = {}) + val displayedState = createState(notifications = emptyList(), isRefreshing = false) + val eventSlot = slot() + every { analyticsEventHandler.send(capture(eventSlot)) } returns Unit + + // WHEN + sender.send(displayedUiState = displayedState, newNotifications = listOf(notification)) + + // THEN + verify(exactly = 1) { analyticsEventHandler.send(any()) } + val event = eventSlot.captured as TokenDetailsAnalyticsEvent.Notice.AdditionalAddressesFound + assertThat(event.category).isEqualTo("Token") + assertThat(event.event).isEqualTo("Notice - Additional Addresses Found") + assertThat(event.params).containsEntry("Token", "ETH") + assertThat(event.params).containsEntry("Blockchain", "Ethereum") + } + + @Test + fun `GIVEN empty new notifications WHEN send THEN no event is sent`() { + // GIVEN + val displayedState = createState(notifications = emptyList(), isRefreshing = false) + + // WHEN + sender.send(displayedUiState = displayedState, newNotifications = emptyList()) + + // THEN + verify(exactly = 0) { analyticsEventHandler.send(any()) } + } + + @Test + fun `GIVEN pullToRefresh is refreshing WHEN send THEN no event is sent`() { + // GIVEN + val notification = TokenDetailsNotification.DynamicAddressesFundsFound(onLearnMoreClick = {}) + val displayedState = createState(notifications = emptyList(), isRefreshing = true) + + // WHEN + sender.send(displayedUiState = displayedState, newNotifications = listOf(notification)) + + // THEN + verify(exactly = 0) { analyticsEventHandler.send(any()) } + } + + @Test + fun `GIVEN notification without matching event WHEN send THEN no event is sent`() { + // GIVEN: NetworksUnreachable is an unmapped notification (returns null in getEvent) + val notification = TokenDetailsNotification.NetworksUnreachable + val displayedState = createState(notifications = emptyList(), isRefreshing = false) + + // WHEN + sender.send(displayedUiState = displayedState, newNotifications = listOf(notification)) + + // THEN + verify(exactly = 0) { analyticsEventHandler.send(any()) } + } + + private fun createState( + notifications: List, + isRefreshing: Boolean, + ): TokenDetailsState { + return mockk(relaxed = true) { + every { this@mockk.notifications } returns notifications.toPersistentList() + every { pullToRefreshConfig.isRefreshing } returns isRefreshing + } + } +} \ No newline at end of file diff --git a/features/tokendetails/impl/src/test/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/model/DynamicAddressesDelegateTest.kt b/features/tokendetails/impl/src/test/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/model/DynamicAddressesDelegateTest.kt new file mode 100644 index 0000000000..5c47799232 --- /dev/null +++ b/features/tokendetails/impl/src/test/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/model/DynamicAddressesDelegateTest.kt @@ -0,0 +1,441 @@ +package com.tangem.feature.tokendetails.presentation.tokendetails.model + +import arrow.core.left +import arrow.core.right +import com.google.common.truth.Truth.assertThat +import com.tangem.blockchain.common.TransactionData +import com.tangem.common.core.TangemSdkError +import com.tangem.core.analytics.api.AnalyticsEventHandler +import com.tangem.core.analytics.models.AnalyticsEvent +import com.tangem.core.decompose.ui.UiMessageSender +import com.tangem.domain.appcurrency.model.AppCurrency +import com.tangem.domain.dynamicaddresses.CreateConsolidationTransactionUseCase +import com.tangem.domain.dynamicaddresses.DisableDynamicAddressesUseCase +import com.tangem.domain.dynamicaddresses.EnableDynamicAddressesError +import com.tangem.domain.dynamicaddresses.EnableDynamicAddressesUseCase +import com.tangem.domain.dynamicaddresses.GetDerivedXpubUseCase +import com.tangem.domain.dynamicaddresses.model.DynamicAddressesStatus +import com.tangem.domain.dynamicaddresses.repository.DynamicAddressesRepository +import com.tangem.domain.models.currency.CryptoCurrency +import com.tangem.domain.models.currency.CryptoCurrencyStatus +import com.tangem.domain.models.network.Network +import com.tangem.domain.models.network.NetworkAddress +import com.tangem.domain.models.wallet.UserWallet +import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.domain.transaction.error.GetFeeError +import com.tangem.domain.transaction.error.SendTransactionError +import com.tangem.domain.transaction.usecase.GetFeeUseCase +import com.tangem.domain.transaction.usecase.SendTransactionUseCase +import com.tangem.domain.wallets.usecase.GetExtendedPublicKeyForCurrencyUseCase +import com.tangem.feature.tokendetails.presentation.tokendetails.analytics.TokenDetailsAnalyticsEvent +import com.tangem.feature.tokendetails.presentation.tokendetails.ui.components.dynamicaddresses.DynamicAddressesBottomSheetConfig +import com.tangem.utils.Provider +import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.every +import io.mockk.mockk +import io.mockk.slot +import io.mockk.verify +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.Test +import java.math.BigDecimal + +private const val TEST_XPUB = "xpub-test-value" +private const val TOKEN_SYMBOL = "ETH" +private const val BLOCKCHAIN_NAME = "Ethereum" +private const val TEST_ADDRESS = "0xTestAddress" + +@OptIn(ExperimentalCoroutinesApi::class) +internal class DynamicAddressesDelegateTest { + + private val analyticsEventHandler: AnalyticsEventHandler = mockk(relaxed = true) + + private val enableDynamicAddressesUseCase: EnableDynamicAddressesUseCase = mockk() + private val disableDynamicAddressesUseCase: DisableDynamicAddressesUseCase = mockk() + private val createConsolidationTransactionUseCase: CreateConsolidationTransactionUseCase = mockk() + private val getFeeUseCase: GetFeeUseCase = mockk(relaxed = true) + private val sendTransactionUseCase: SendTransactionUseCase = mockk(relaxed = true) + private val getDerivedXpubUseCase: GetDerivedXpubUseCase = mockk() + private val dynamicAddressesRepository: DynamicAddressesRepository = mockk(relaxed = true) + private val getExtendedPublicKeyUseCase: GetExtendedPublicKeyForCurrencyUseCase = mockk() + private val uiMessageSender: UiMessageSender = mockk(relaxed = true) + + private val network: Network = mockk(relaxed = true) { + every { name } returns BLOCKCHAIN_NAME + } + private val cryptoCurrency: CryptoCurrency = mockk(relaxed = true) { + every { symbol } returns TOKEN_SYMBOL + every { this@mockk.network } returns this@DynamicAddressesDelegateTest.network + } + private val cryptoCurrencyStatus: CryptoCurrencyStatus = mockk(relaxed = true) { + every { currency } returns cryptoCurrency + } + + private val userWalletId = UserWalletId("1234567890ABCDEF") + private val userWallet: UserWallet = mockk(relaxed = true) { + every { walletId } returns userWalletId + } + + private val showBottomSheet: () -> Unit = mockk(relaxed = true) + private val dismissBottomSheet: () -> Unit = mockk(relaxed = true) + private val onDynamicAddressesStateChanged: () -> Unit = mockk(relaxed = true) + + @Test + fun `GIVEN currency is available WHEN onDynamicAddressesClick THEN DynamicAddressesScreenOpened event is sent`() = + runTest { + // GIVEN + every { dynamicAddressesRepository.getStatus(userWalletId, network) } returns + flowOf(DynamicAddressesStatus.DISABLED) + coEvery { dynamicAddressesRepository.hasConflictingCustomTokens(userWalletId, network) } returns false + coEvery { getDerivedXpubUseCase(userWalletId, network) } returns TEST_XPUB + val delegate = createDelegate(cryptoCurrencyStatus = cryptoCurrencyStatus) + val eventSlot = slot() + every { analyticsEventHandler.send(capture(eventSlot)) } returns Unit + + // WHEN + delegate.onDynamicAddressesClick() + + // THEN + val event = eventSlot.captured as TokenDetailsAnalyticsEvent.DynamicAddressesScreenOpened + assertThat(event.category).isEqualTo("Token") + assertThat(event.event).isEqualTo("Dynamic Addresses Screen Opened") + assertThat(event.params).containsEntry("Token", TOKEN_SYMBOL) + assertThat(event.params).containsEntry("Blockchain", BLOCKCHAIN_NAME) + } + + @Test + fun `GIVEN no currency WHEN onDynamicAddressesClick THEN no event is sent`() = runTest { + // GIVEN + val delegate = createDelegate(cryptoCurrencyStatus = null) + + // WHEN + delegate.onDynamicAddressesClick() + + // THEN + verify(exactly = 0) { analyticsEventHandler.send(any()) } + } + + @Test + fun `GIVEN DISABLED status AND conflicts WHEN onDynamicAddressesClick THEN Notice DynamicAddressesUnavailable is sent`() = + runTest { + // GIVEN + every { dynamicAddressesRepository.getStatus(userWalletId, network) } returns + flowOf(DynamicAddressesStatus.DISABLED) + coEvery { dynamicAddressesRepository.hasConflictingCustomTokens(userWalletId, network) } returns true + val delegate = createDelegate(cryptoCurrencyStatus = cryptoCurrencyStatus) + + // WHEN + delegate.onDynamicAddressesClick() + + // THEN + verify { + analyticsEventHandler.send( + match { + it.event == "Notice - Dynamic Addresses Unavailable" && + it.params["Token"] == TOKEN_SYMBOL && + it.params["Blockchain"] == BLOCKCHAIN_NAME + }, + ) + } + } + + @Test + fun `GIVEN DISABLED status WHEN enable button clicked AND enable succeeds THEN ButtonEnable and DynamicAddressesEnabled are sent`() = + runTest { + // GIVEN + every { dynamicAddressesRepository.getStatus(userWalletId, network) } returns + flowOf(DynamicAddressesStatus.DISABLED) + coEvery { dynamicAddressesRepository.hasConflictingCustomTokens(userWalletId, network) } returns false + coEvery { getDerivedXpubUseCase(userWalletId, network) } returns TEST_XPUB + coEvery { getExtendedPublicKeyUseCase(userWalletId, network) } returns TEST_XPUB.right() + coEvery { enableDynamicAddressesUseCase(userWalletId, network, TEST_XPUB) } returns Unit.right() + + val delegate = createDelegate(cryptoCurrencyStatus = cryptoCurrencyStatus) + delegate.onDynamicAddressesClick() + + // WHEN + (delegate.bottomSheetConfig.value as DynamicAddressesBottomSheetConfig.Enable).onEnableClick() + + // THEN + verify { + analyticsEventHandler.send( + match { + it.event == "Button - Enable Dynamic Addresses" && + it.params["Token"] == TOKEN_SYMBOL + }, + ) + analyticsEventHandler.send( + match { + it.event == "Dynamic Addresses Enabled" && + it.params["Token"] == TOKEN_SYMBOL + }, + ) + } + coVerify { enableDynamicAddressesUseCase(userWalletId, network, TEST_XPUB) } + } + + @Test + fun `GIVEN DISABLED status WHEN enable button clicked AND xpub retrieval fails THEN Error DynamicAddressesUnavailable is sent`() = + runTest { + // GIVEN + every { dynamicAddressesRepository.getStatus(userWalletId, network) } returns + flowOf(DynamicAddressesStatus.DISABLED) + coEvery { dynamicAddressesRepository.hasConflictingCustomTokens(userWalletId, network) } returns false + coEvery { getDerivedXpubUseCase(userWalletId, network) } returns TEST_XPUB + coEvery { getExtendedPublicKeyUseCase(userWalletId, network) } returns + IllegalStateException("xpub fail").left() + + val delegate = createDelegate(cryptoCurrencyStatus = cryptoCurrencyStatus) + delegate.onDynamicAddressesClick() + + // WHEN + (delegate.bottomSheetConfig.value as DynamicAddressesBottomSheetConfig.Enable).onEnableClick() + + // THEN + verify { + analyticsEventHandler.send( + match { + it.event == "Error - Dynamic Addresses Unavailable" && + it.params["Token"] == TOKEN_SYMBOL + }, + ) + } + } + + @Test + fun `GIVEN DISABLED status WHEN enable button clicked AND user cancels xpub derivation THEN Error event is NOT sent`() = + runTest { + // GIVEN + every { dynamicAddressesRepository.getStatus(userWalletId, network) } returns + flowOf(DynamicAddressesStatus.DISABLED) + coEvery { dynamicAddressesRepository.hasConflictingCustomTokens(userWalletId, network) } returns false + coEvery { getDerivedXpubUseCase(userWalletId, network) } returns TEST_XPUB + coEvery { getExtendedPublicKeyUseCase(userWalletId, network) } returns + TangemSdkError.UserCancelled().left() + + val delegate = createDelegate(cryptoCurrencyStatus = cryptoCurrencyStatus) + delegate.onDynamicAddressesClick() + + // WHEN + (delegate.bottomSheetConfig.value as DynamicAddressesBottomSheetConfig.Enable).onEnableClick() + + // THEN + verify(exactly = 0) { + analyticsEventHandler.send(ofType()) + analyticsEventHandler.send(ofType()) + } + } + + @Test + fun `GIVEN DISABLED status WHEN enable useCase fails THEN Error DynamicAddressesUnavailable is sent`() = runTest { + // GIVEN + every { dynamicAddressesRepository.getStatus(userWalletId, network) } returns + flowOf(DynamicAddressesStatus.DISABLED) + coEvery { dynamicAddressesRepository.hasConflictingCustomTokens(userWalletId, network) } returns false + coEvery { getDerivedXpubUseCase(userWalletId, network) } returns TEST_XPUB + coEvery { getExtendedPublicKeyUseCase(userWalletId, network) } returns TEST_XPUB.right() + coEvery { enableDynamicAddressesUseCase(userWalletId, network, TEST_XPUB) } returns + EnableDynamicAddressesError.ServiceError(RuntimeException("boom")).left() + + val delegate = createDelegate(cryptoCurrencyStatus = cryptoCurrencyStatus) + delegate.onDynamicAddressesClick() + + // WHEN + (delegate.bottomSheetConfig.value as DynamicAddressesBottomSheetConfig.Enable).onEnableClick() + + // THEN + verify { + analyticsEventHandler.send(ofType()) + } + verify(exactly = 0) { + analyticsEventHandler.send(ofType()) + } + } + + @Test + fun `GIVEN ENABLED status AND no consolidation WHEN simple disable clicked THEN ButtonDisable and DynamicAddressesDisabled are sent`() = + runTest { + // GIVEN + every { dynamicAddressesRepository.getStatus(userWalletId, network) } returns + flowOf(DynamicAddressesStatus.ENABLED) + coEvery { disableDynamicAddressesUseCase(userWalletId, network) } returns false.right() + coEvery { dynamicAddressesRepository.disable(userWalletId, network) } returns Unit + + val delegate = createDelegate(cryptoCurrencyStatus = cryptoCurrencyStatus) + delegate.onDynamicAddressesClick() + + // WHEN + (delegate.bottomSheetConfig.value as DynamicAddressesBottomSheetConfig.DisableWithoutConsolidation) + .onDisableClick() + + // THEN + verify { + analyticsEventHandler.send( + match { + it.event == "Button - Disable Dynamic Addresses" + }, + ) + analyticsEventHandler.send( + match { + it.event == "Dynamic Addresses Disabled" + }, + ) + } + } + + @Test + fun `GIVEN consolidation required AND fee fails WHEN load fee THEN NotEnoughFee with DynamicAddresses source is sent`() = + runTest { + // GIVEN: consolidation required, status provides balance and address, fee load fails + setupConsolidationFlow() + coEvery { + getFeeUseCase(any(), any(), userWallet, cryptoCurrency) + } returns mockk(relaxed = true).left() + val events = mutableListOf() + every { analyticsEventHandler.send(capture(events)) } returns Unit + + // WHEN + val delegate = createDelegate(cryptoCurrencyStatus = cryptoCurrencyStatus) + delegate.onDynamicAddressesClick() + + // THEN + val notEnoughFee = events + .filterIsInstance() + .single() + assertThat(notEnoughFee.event).isEqualTo("Notice - Not Enough Fee") + assertThat(notEnoughFee.params).containsEntry("Source", "Dynamic Addresses") + assertThat(notEnoughFee.params).containsEntry("Token", TOKEN_SYMBOL) + assertThat(notEnoughFee.params).containsEntry("Blockchain", BLOCKCHAIN_NAME) + } + + @Test + fun `GIVEN consolidation required AND fee fails WHEN load fee retried THEN NotEnoughFee is sent only once`() = + runTest { + // GIVEN + setupConsolidationFlow() + coEvery { + getFeeUseCase(any(), any(), userWallet, cryptoCurrency) + } returns mockk(relaxed = true).left() + val delegate = createDelegate(cryptoCurrencyStatus = cryptoCurrencyStatus) + + // WHEN: initial load + refresh + delegate.onDynamicAddressesClick() + (delegate.bottomSheetConfig.value as DynamicAddressesBottomSheetConfig.DisableWithConsolidation) + .onRefreshFee() + + // THEN: one-time event sender collapses repeated errors + verify(exactly = 1) { + analyticsEventHandler.send(ofType()) + } + } + + @Test + fun `GIVEN consolidation flow WHEN disable clicked AND tx succeeds THEN ButtonDisable and DynamicAddressesDisabled are sent`() = + runTest { + // GIVEN + setupConsolidationFlow() + coEvery { + getFeeUseCase(any(), any(), userWallet, cryptoCurrency) + } returns mockk(relaxed = true).left() + val txData = mockk(relaxed = true) + coEvery { createConsolidationTransactionUseCase(userWalletId, network) } returns txData.right() + coEvery { sendTransactionUseCase(txData, userWallet, network) } returns "tx-hash".right() + coEvery { dynamicAddressesRepository.disable(userWalletId, network) } returns Unit + + val delegate = createDelegate(cryptoCurrencyStatus = cryptoCurrencyStatus) + delegate.onDynamicAddressesClick() + + // WHEN + (delegate.bottomSheetConfig.value as DynamicAddressesBottomSheetConfig.DisableWithConsolidation) + .onDisableClick() + + // THEN + verify { + analyticsEventHandler.send(ofType()) + analyticsEventHandler.send(ofType()) + } + coVerify { sendTransactionUseCase(txData, userWallet, network) } + } + + @Test + fun `GIVEN consolidation flow WHEN disable clicked AND tx cancelled by user THEN DynamicAddressesDisabled is NOT sent`() = + runTest { + // GIVEN + setupConsolidationFlow() + coEvery { + getFeeUseCase(any(), any(), userWallet, cryptoCurrency) + } returns mockk(relaxed = true).left() + val txData = mockk(relaxed = true) + coEvery { createConsolidationTransactionUseCase(userWalletId, network) } returns txData.right() + coEvery { sendTransactionUseCase(txData, userWallet, network) } returns + SendTransactionError.UserCancelledError.left() + + val delegate = createDelegate(cryptoCurrencyStatus = cryptoCurrencyStatus) + delegate.onDynamicAddressesClick() + + // WHEN + (delegate.bottomSheetConfig.value as DynamicAddressesBottomSheetConfig.DisableWithConsolidation) + .onDisableClick() + + // THEN: ButtonDisable is sent on click, but success event is not + verify { analyticsEventHandler.send(ofType()) } + verify(exactly = 0) { + analyticsEventHandler.send(ofType()) + } + } + + @Test + fun `GIVEN Notice category event WHEN sent THEN id starts with Token category`() { + // GIVEN + val notice = TokenDetailsAnalyticsEvent.Notice.DynamicAddressesUnavailable(cryptoCurrency) + val error = TokenDetailsAnalyticsEvent.Error.DynamicAddressesUnavailable(cryptoCurrency) + + // THEN + assertThat(notice.category).isEqualTo("Token") + assertThat(notice.event).isEqualTo("Notice - Dynamic Addresses Unavailable") + assertThat(error.category).isEqualTo("Token") + assertThat(error.event).isEqualTo("Error - Dynamic Addresses Unavailable") + } + + private fun setupConsolidationFlow() { + every { dynamicAddressesRepository.getStatus(userWalletId, network) } returns + flowOf(DynamicAddressesStatus.ENABLED) + coEvery { disableDynamicAddressesUseCase(userWalletId, network) } returns true.right() + val address = NetworkAddress.Address(value = TEST_ADDRESS, type = NetworkAddress.Address.Type.Primary) + every { cryptoCurrencyStatus.value } returns mockk(relaxed = true) { + every { amount } returns BigDecimal.ONE + every { fiatRate } returns BigDecimal.ONE + every { networkAddress } returns NetworkAddress.Single(defaultAddress = address) + } + } + + private fun createDelegate(cryptoCurrencyStatus: CryptoCurrencyStatus?): DynamicAddressesDelegate { + val scope = CoroutineScope(Dispatchers.Unconfined + SupervisorJob()) + return DynamicAddressesDelegate( + enableDynamicAddressesUseCase = enableDynamicAddressesUseCase, + disableDynamicAddressesUseCase = disableDynamicAddressesUseCase, + createConsolidationTransactionUseCase = createConsolidationTransactionUseCase, + getFeeUseCase = getFeeUseCase, + sendTransactionUseCase = sendTransactionUseCase, + getDerivedXpubUseCase = getDerivedXpubUseCase, + dynamicAddressesRepository = dynamicAddressesRepository, + getExtendedPublicKeyUseCase = getExtendedPublicKeyUseCase, + analyticsEventHandler = analyticsEventHandler, + uiMessageSender = uiMessageSender, + dispatchers = TestingCoroutineDispatcherProvider(), + userWallet = userWallet, + cryptoCurrencyStatusProvider = Provider { cryptoCurrencyStatus }, + appCurrencyProvider = Provider { mockk(relaxed = true) }, + coroutineScope = scope, + showBottomSheet = showBottomSheet, + dismissBottomSheet = dismissBottomSheet, + onDynamicAddressesStateChanged = onDynamicAddressesStateChanged, + ) + } +} \ No newline at end of file