diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index a2c1f4e5fe..baf6ae4211 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -51,18 +51,23 @@ property to make the "android:screenOrientation" work on API <37 https://developer.android.com/about/versions/16/behavior-changes-16 --> - + @@ -248,6 +253,17 @@ android:host="swap" android:scheme="tangem" /> + + + + + + + + + diff --git a/app/src/main/java/com/tangem/tap/routing/utils/DeepLinkFactory.kt b/app/src/main/java/com/tangem/tap/routing/utils/DeepLinkFactory.kt index 0f9059d367..3412cac097 100644 --- a/app/src/main/java/com/tangem/tap/routing/utils/DeepLinkFactory.kt +++ b/app/src/main/java/com/tangem/tap/routing/utils/DeepLinkFactory.kt @@ -15,6 +15,7 @@ import com.tangem.features.onramp.deeplink.SwapDeepLinkHandler import com.tangem.features.send.v2.api.deeplink.SellRedirectDeepLinkHandler import com.tangem.features.staking.api.deeplink.StakingDeepLinkHandler import com.tangem.features.tokendetails.deeplink.TokenDetailsDeepLinkHandler +import com.tangem.features.wallet.deeplink.PromoDeeplinkHandler import com.tangem.features.wallet.deeplink.WalletDeepLinkHandler import com.tangem.features.walletconnect.components.deeplink.WalletConnectDeepLinkHandler import com.tangem.utils.coroutines.JobHolder @@ -46,6 +47,7 @@ internal class DeepLinkFactory @Inject constructor( private val buyDeepLink: BuyDeepLinkHandler.Factory, private val sellDeepLink: SellDeepLinkHandler.Factory, private val swapDeepLink: SwapDeepLinkHandler.Factory, + private val promoDeepLink: PromoDeeplinkHandler.Factory, ) { private val permittedAppRoute = MutableStateFlow(false) @@ -87,6 +89,7 @@ internal class DeepLinkFactory @Inject constructor( AppRoute.Initial, is AppRoute.Home, is AppRoute.Welcome, + is AppRoute.PushNotification, is AppRoute.Disclaimer, is AppRoute.Stories, is AppRoute.Onboarding, @@ -131,6 +134,7 @@ internal class DeepLinkFactory @Inject constructor( DeepLinkRoute.Sell.host -> sellDeepLink.create() DeepLinkRoute.Swap.host -> swapDeepLink.create() DeepLinkRoute.WalletConnect.host -> walletConnectDeepLink.create(deeplinkUri) + DeepLinkRoute.Promo.host -> promoDeepLink.create(coroutineScope, queryParams) else -> { Timber.i( """ diff --git a/app/src/test/kotlin/com/tangem/tap/routing/utils/DeepLinkFactoryTest.kt b/app/src/test/kotlin/com/tangem/tap/routing/utils/DeepLinkFactoryTest.kt index 34c9ae969c..dde88e9295 100644 --- a/app/src/test/kotlin/com/tangem/tap/routing/utils/DeepLinkFactoryTest.kt +++ b/app/src/test/kotlin/com/tangem/tap/routing/utils/DeepLinkFactoryTest.kt @@ -13,6 +13,7 @@ import com.tangem.features.onramp.deeplink.SwapDeepLinkHandler import com.tangem.features.send.v2.api.deeplink.SellRedirectDeepLinkHandler import com.tangem.features.staking.api.deeplink.StakingDeepLinkHandler import com.tangem.features.tokendetails.deeplink.TokenDetailsDeepLinkHandler +import com.tangem.features.wallet.deeplink.PromoDeeplinkHandler import com.tangem.features.wallet.deeplink.WalletDeepLinkHandler import com.tangem.features.walletconnect.components.deeplink.WalletConnectDeepLinkHandler import io.mockk.every @@ -68,6 +69,10 @@ class DeepLinkFactoryTest { every { create() } returns mockk() } + private val promoDeepLinkFactory = mockk(relaxed = true) { + every { create(any(), any()) } returns mockk() + } + private val cardSdkProvider = mockk(relaxed = true) { every { sdk.uiVisibility() } returns MutableStateFlow(false) } @@ -91,6 +96,7 @@ class DeepLinkFactoryTest { buyDeepLink = buyDeepLinkFactory, sellDeepLink = sellDeepLinkFactory, swapDeepLink = swapDeepLinkFactory, + promoDeepLink = promoDeepLinkFactory, ) @OptIn(ExperimentalCoroutinesApi::class) @@ -303,13 +309,19 @@ class DeepLinkFactoryTest { every { mockedUri.host } returns "swap" deepLinkFactory.handleDeeplink(mockedUri, testScope, isFromOnNewIntent) advanceUntilIdle() - verify { sellDeepLinkFactory.create() } + verify { swapDeepLinkFactory.create() } // Test Buy every { mockedUri.host } returns "buy" deepLinkFactory.handleDeeplink(mockedUri, testScope, isFromOnNewIntent) advanceUntilIdle() verify { buyDeepLinkFactory.create() } + + // Test Promo + every { mockedUri.host } returns "promo" + deepLinkFactory.handleDeeplink(mockedUri, testScope, isFromOnNewIntent) + advanceUntilIdle() + verify { promoDeepLinkFactory.create(eq(testScope), eq(emptyMap())) } } @Test @@ -332,6 +344,7 @@ class DeepLinkFactoryTest { buyDeepLinkFactory.create() sellDeepLinkFactory.create() swapDeepLinkFactory.create() + promoDeepLinkFactory.create(any(), any()) } } @@ -400,4 +413,22 @@ class DeepLinkFactoryTest { advanceUntilIdle() verify { onrampDeepLinkFactory.create(eq(testScope), eq(emptyMap())) } } + + @Test + fun `handleTangemDeepLinks routes to promo handler`() = runTest { + every { mockedUri.scheme } returns "tangem" + every { mockedUri.host } returns "promo" + every { mockedUri.query } returns "promo_code=PROMO123" + every { mockedUri.queryParameterNames } returns setOf("promo_code") + every { mockedUri.getQueryParameter("promo_code") } returns "PROMO123" + + deepLinkFactory.checkRoutingReadiness(AppRoute.Wallet) + deepLinkFactory.handleDeeplink(mockedUri, testScope, isFromOnNewIntent) + + advanceUntilIdle() + + verify { + promoDeepLinkFactory.create(eq(testScope), eq(mapOf("promo_code" to "PROMO123"))) + } + } } \ No newline at end of file diff --git a/common/routing/src/main/kotlin/com/tangem/common/routing/DeepLinkRoute.kt b/common/routing/src/main/kotlin/com/tangem/common/routing/DeepLinkRoute.kt index 78a4ad85ea..1fbffcb8a8 100644 --- a/common/routing/src/main/kotlin/com/tangem/common/routing/DeepLinkRoute.kt +++ b/common/routing/src/main/kotlin/com/tangem/common/routing/DeepLinkRoute.kt @@ -55,6 +55,10 @@ sealed class DeepLinkRoute { data object WalletConnect : DeepLinkRoute() { override val host: String = "wc" } + + data object Promo : DeepLinkRoute() { + override val host: String = "promo" + } } enum class DeepLinkScheme(val scheme: String) { diff --git a/common/routing/src/main/kotlin/com/tangem/common/routing/deeplink/DeeplinkConst.kt b/common/routing/src/main/kotlin/com/tangem/common/routing/deeplink/DeeplinkConst.kt index a435e90549..575be52e80 100644 --- a/common/routing/src/main/kotlin/com/tangem/common/routing/deeplink/DeeplinkConst.kt +++ b/common/routing/src/main/kotlin/com/tangem/common/routing/deeplink/DeeplinkConst.kt @@ -11,5 +11,6 @@ object DeeplinkConst { const val TOKEN_ID_KEY = "token_id" const val DERIVATION_PATH_KEY = "derivation_path" const val TRANSACTION_ID_KEY = "transaction_id" + const val PROMO_CODE_KEY = "promo_code" const val NAME_KEY = "name" } \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/tangemTech/TangemTechApi.kt b/core/datasource/src/main/java/com/tangem/datasource/api/tangemTech/TangemTechApi.kt index 51fd1ce19a..be6fe202e7 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/api/tangemTech/TangemTechApi.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/api/tangemTech/TangemTechApi.kt @@ -141,6 +141,11 @@ interface TangemTechApi { suspend fun getWallets(@Path("app_id") appId: String): ApiResponse> // endregion + // promo + @POST("promo/v1/promo-codes/activate") + suspend fun activatePromoCode(@Body body: PromocodeActivationBody): ApiResponse + // endregion + // region account @GET("/v1/wallets/{walletId}/accounts") suspend fun getWalletAccounts(@Path("walletId") walletId: String): ApiResponse diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/tangemTech/models/PromocodeActivationBody.kt b/core/datasource/src/main/java/com/tangem/datasource/api/tangemTech/models/PromocodeActivationBody.kt new file mode 100644 index 0000000000..9d28f527e7 --- /dev/null +++ b/core/datasource/src/main/java/com/tangem/datasource/api/tangemTech/models/PromocodeActivationBody.kt @@ -0,0 +1,10 @@ +package com.tangem.datasource.api.tangemTech.models + +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass + +@JsonClass(generateAdapter = true) +data class PromocodeActivationBody( + @Json(name = "promoCode") val promoCode: String, + @Json(name = "address") val address: String, +) \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/tangemTech/models/PromocodeActivationResponse.kt b/core/datasource/src/main/java/com/tangem/datasource/api/tangemTech/models/PromocodeActivationResponse.kt new file mode 100644 index 0000000000..427a4f78d9 --- /dev/null +++ b/core/datasource/src/main/java/com/tangem/datasource/api/tangemTech/models/PromocodeActivationResponse.kt @@ -0,0 +1,9 @@ +package com.tangem.datasource.api.tangemTech.models + +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass + +@JsonClass(generateAdapter = true) +data class PromocodeActivationResponse( + @Json(name = "status") val status: String, +) \ No newline at end of file diff --git a/core/ui/src/main/java/com/tangem/core/ui/extensions/String.kt b/core/ui/src/main/java/com/tangem/core/ui/extensions/String.kt index f60730bd69..3d5ce5fc09 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/extensions/String.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/extensions/String.kt @@ -80,4 +80,29 @@ fun String.capitalize(): String = replaceFirstChar { if (it.isLowerCase()) it.ti fun String.orMaskWithStars(maskWithStars: Boolean): String { return if (maskWithStars) THREE_STARS else this +} + +/** + * Returns a masked representation of the string for safe display. + * + * Rules: + * - empty string: returned unchanged + * - length <= 2: all characters are replaced with '*' + * - length <= 4: keep the first and last characters, mask the middle + * - length > 4: keep the first two and last two characters, mask the middle with "**" + * + * @return masked string according to the rules above + */ +@Suppress("MagicNumber") +fun String.mask(): String { + return when { + this.isEmpty() -> this + this.length <= 2 -> "*".repeat(this.length) // mask all if too short + this.length <= 4 -> this.first() + "*".repeat(this.length - 2) + this.last() + else -> { + val prefix = this.take(2) + val suffix = this.takeLast(2) + "$prefix**$suffix" + } + } } \ No newline at end of file diff --git a/core/ui/src/main/java/com/tangem/core/ui/message/EventMessage.kt b/core/ui/src/main/java/com/tangem/core/ui/message/EventMessage.kt index 216e6c755c..d2988d0b0c 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/message/EventMessage.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/message/EventMessage.kt @@ -147,6 +147,8 @@ data class DialogMessage( } } +data class GlobalLoadingMessage(val isShow: Boolean) : EventMessage + /** * Shows a bottom sheet. * diff --git a/core/ui/src/main/java/com/tangem/core/ui/message/EventMessageEffect.kt b/core/ui/src/main/java/com/tangem/core/ui/message/EventMessageEffect.kt index 25984f4a44..8d23185045 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/message/EventMessageEffect.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/message/EventMessageEffect.kt @@ -2,13 +2,23 @@ package com.tangem.core.ui.message import android.content.Context import android.widget.Toast +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.SnackbarDuration import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.SnackbarResult import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Dialog +import androidx.compose.ui.window.DialogProperties import com.tangem.core.ui.components.BasicDialog import com.tangem.core.ui.components.DialogButtonUM +import com.tangem.core.ui.components.SpacerHMax import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig import com.tangem.core.ui.components.bottomsheets.message.MessageBottomSheet import com.tangem.core.ui.components.bottomsheets.message.MessageBottomSheetUM @@ -17,6 +27,7 @@ import com.tangem.core.ui.event.EventEffect import com.tangem.core.ui.extensions.resolveReference import com.tangem.core.ui.res.LocalEventMessageHandler import com.tangem.core.ui.res.LocalSnackbarHostState +import com.tangem.core.ui.res.TangemTheme @Composable fun EventMessageEffect( @@ -33,6 +44,7 @@ fun EventMessageEffect( var dialogMessage: DialogMessage? by remember { mutableStateOf(value = null) } var bottomSheetMessage: BottomSheetMessage? by remember { mutableStateOf(value = null) } var bottomSheetMessageV2: BottomSheetMessageV2? by remember { mutableStateOf(value = null) } + var loadingMessage: GlobalLoadingMessage? by remember { mutableStateOf(value = null) } EventEffect(event = messageEvent) { message -> when (message) { @@ -51,6 +63,13 @@ fun EventMessageEffect( is ToastMessage -> { onShowToast(message, context) } + is GlobalLoadingMessage -> { + loadingMessage = if (message.isShow) { + message + } else { + null + } + } } } @@ -80,6 +99,34 @@ fun EventMessageEffect( onDismissRequest = { bottomSheetMessageV2 = null }, ) } + + loadingMessage?.let { + LoadingDialog() + } +} + +@Composable +private fun LoadingDialog() { + Dialog( + onDismissRequest = {}, + properties = DialogProperties( + dismissOnBackPress = false, + dismissOnClickOutside = false, + ), + ) { + Column( + modifier = Modifier + .fillMaxSize(), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + SpacerHMax() + CircularProgressIndicator( + modifier = Modifier.align(Alignment.CenterHorizontally), + color = TangemTheme.colors.icon.primary1, + ) + SpacerHMax() + } + } } @Composable diff --git a/core/ui/src/test/java/com/tangem/core/ui/extensions/StringMaskTest.kt b/core/ui/src/test/java/com/tangem/core/ui/extensions/StringMaskTest.kt new file mode 100644 index 0000000000..8717d32a2c --- /dev/null +++ b/core/ui/src/test/java/com/tangem/core/ui/extensions/StringMaskTest.kt @@ -0,0 +1,91 @@ +package com.tangem.core.ui.extensions + +import com.google.common.truth.Truth.assertThat +import org.junit.Test + +class StringMaskTest { + + @Test + fun GIVEN_empty_string_WHEN_mask_THEN_returns_empty_string() { + // GIVEN + val input = "" + + // WHEN + val actual = input.mask() + + // THEN + assertThat(actual).isEqualTo("") + } + + @Test + fun GIVEN_one_char_WHEN_mask_THEN_returns_asterisk() { + // GIVEN + val input = "a" + + // WHEN + val actual = input.mask() + + // THEN + assertThat(actual).isEqualTo("*") + } + + @Test + fun GIVEN_two_chars_WHEN_mask_THEN_returns_two_asterisks() { + // GIVEN + val input = "ab" + + // WHEN + val actual = input.mask() + + // THEN + assertThat(actual).isEqualTo("**") + } + + @Test + fun GIVEN_three_chars_WHEN_mask_THEN_masks_middle_only() { + // GIVEN + val input = "abc" + + // WHEN + val actual = input.mask() + + // THEN + assertThat(actual).isEqualTo("a*c") + } + + @Test + fun GIVEN_four_chars_WHEN_mask_THEN_masks_middle_two() { + // GIVEN + val input = "abcd" + + // WHEN + val actual = input.mask() + + // THEN + assertThat(actual).isEqualTo("a**d") + } + + @Test + fun GIVEN_five_chars_WHEN_mask_THEN_keeps_edges_and_two_stars_in_middle() { + // GIVEN + val input = "abcde" + + // WHEN + val actual = input.mask() + + // THEN + assertThat(actual).isEqualTo("ab**de") + } + + @Test + fun GIVEN_seven_chars_WHEN_mask_THEN_keeps_two_on_each_side_and_two_stars() { + // GIVEN + val input = "abcdefg" + + // WHEN + val actual = input.mask() + + // THEN + assertThat(actual).isEqualTo("ab**fg") + } +} \ No newline at end of file diff --git a/data/wallet-connect/src/main/kotlin/com/tangem/data/walletconnect/network/solana/Model.kt b/data/wallet-connect/src/main/kotlin/com/tangem/data/walletconnect/network/solana/Model.kt index 3f739b3f64..fcd037a22a 100644 --- a/data/wallet-connect/src/main/kotlin/com/tangem/data/walletconnect/network/solana/Model.kt +++ b/data/wallet-connect/src/main/kotlin/com/tangem/data/walletconnect/network/solana/Model.kt @@ -19,4 +19,10 @@ internal data class WcSolanaSignTransactionRequest( @Json(name = "feePayer") val feePayer: String?, +) + +@JsonClass(generateAdapter = true) +internal data class WcSolanaSignAllTransactionRequest( + @Json(name = "transactions") + val transactions: List, ) \ No newline at end of file diff --git a/data/wallet-connect/src/main/kotlin/com/tangem/data/walletconnect/network/solana/WcSolanaNetwork.kt b/data/wallet-connect/src/main/kotlin/com/tangem/data/walletconnect/network/solana/WcSolanaNetwork.kt index 0b47155291..ebaf668ba0 100644 --- a/data/wallet-connect/src/main/kotlin/com/tangem/data/walletconnect/network/solana/WcSolanaNetwork.kt +++ b/data/wallet-connect/src/main/kotlin/com/tangem/data/walletconnect/network/solana/WcSolanaNetwork.kt @@ -112,9 +112,9 @@ internal class WcSolanaNetwork( WcSolanaMethodName.SignTransaction -> moshi.fromJson(rawParams) .getOrElse { return it.left() } ?.let { request -> WcSolanaMethod.SignTransaction(request.transaction, request.feePayer) } - WcSolanaMethodName.SendAllTransaction -> moshi.fromJson>(rawParams) + WcSolanaMethodName.SendAllTransaction -> moshi.fromJson(rawParams) .getOrElse { return it.left() } - ?.let { list -> WcSolanaMethod.SignAllTransaction(list) } + ?.let { request -> WcSolanaMethod.SignAllTransaction(request.transactions) } }.right() } diff --git a/data/wallet-connect/src/main/kotlin/com/tangem/data/walletconnect/pair/DefaultWcPairService.kt b/data/wallet-connect/src/main/kotlin/com/tangem/data/walletconnect/pair/DefaultWcPairService.kt index d0749f0362..8a49571b5c 100644 --- a/data/wallet-connect/src/main/kotlin/com/tangem/data/walletconnect/pair/DefaultWcPairService.kt +++ b/data/wallet-connect/src/main/kotlin/com/tangem/data/walletconnect/pair/DefaultWcPairService.kt @@ -1,6 +1,5 @@ package com.tangem.data.walletconnect.pair -import androidx.core.net.toUri import com.tangem.domain.walletconnect.WcPairService import com.tangem.domain.walletconnect.model.WcPairRequest import com.tangem.domain.walletconnect.repository.WcSessionsManager @@ -38,8 +37,19 @@ class DefaultWcPairService @Inject constructor( } private suspend fun existSessionTopic(uri: String) = runCatching { - val sessionTopic = uri.toUri().getQueryParameter("sessionTopic") ?: return@runCatching false + val deeplinkRegex = Regex(WC_PARAM_REGEX) + val matched = deeplinkRegex.findAll(uri) + val sessionTopic = matched + .firstOrNull { it.value.contains(WC_TOPIC_QUERY_NAME) } + ?.groupValues + ?.lastOrNull() + ?: return@runCatching false val isExistSession = sessionsManager.findSessionByTopic(sessionTopic) != null return@runCatching isExistSession } + + private companion object { + const val WC_TOPIC_QUERY_NAME = "sessionTopic" + const val WC_PARAM_REGEX = "([a-zA-Z\\d-]+)=([a-zA-Z\\d]+)" + } } \ No newline at end of file diff --git a/data/wallets/src/main/java/com/tangem/data/wallets/DefaultWalletsRepository.kt b/data/wallets/src/main/java/com/tangem/data/wallets/DefaultWalletsRepository.kt index 2127d9b80b..e6c82e427a 100644 --- a/data/wallets/src/main/java/com/tangem/data/wallets/DefaultWalletsRepository.kt +++ b/data/wallets/src/main/java/com/tangem/data/wallets/DefaultWalletsRepository.kt @@ -1,12 +1,17 @@ package com.tangem.data.wallets +import arrow.core.Either +import arrow.core.left +import arrow.core.right import com.tangem.data.wallets.converters.UserWalletRemoteInfoConverter import com.tangem.data.wallets.converters.WalletIdBodyConverter import com.tangem.datasource.api.common.AuthProvider import com.tangem.datasource.api.common.response.ApiResponseError.HttpException +import com.tangem.datasource.api.common.response.fold import com.tangem.datasource.api.common.response.getOrThrow import com.tangem.datasource.api.tangemTech.TangemTechApi import com.tangem.datasource.api.tangemTech.models.MarkUserWalletWasCreatedBody +import com.tangem.datasource.api.tangemTech.models.PromocodeActivationBody import com.tangem.datasource.api.tangemTech.models.SeedPhraseNotificationDTO import com.tangem.datasource.api.tangemTech.models.SeedPhraseNotificationDTO.Status import com.tangem.datasource.api.tangemTech.models.WalletBody @@ -24,6 +29,7 @@ import com.tangem.domain.models.wallet.UserWallet import com.tangem.domain.models.wallet.UserWalletId import com.tangem.domain.wallets.models.SeedPhraseNotificationsStatus import com.tangem.domain.wallets.models.UserWalletRemoteInfo +import com.tangem.domain.wallets.models.errors.ActivatePromoCodeError import com.tangem.domain.wallets.repository.WalletsRepository import com.tangem.utils.WEEK_MILLIS import com.tangem.utils.coroutines.CoroutineDispatcherProvider @@ -34,7 +40,7 @@ import kotlinx.coroutines.withContext typealias SeedPhraseNotificationsStatuses = Map -@Suppress("TooManyFunctions") +@Suppress("TooManyFunctions", "LargeClass") internal class DefaultWalletsRepository( private val appPreferencesStore: AppPreferencesStore, private val tangemTechApi: TangemTechApi, @@ -373,4 +379,28 @@ internal class DefaultWalletsRepository( body = walletsBody, ).getOrThrow() } + + override suspend fun activatePromoCode( + promoCode: String, + bitcoinAddress: String, + ): Either = withContext(dispatchers.io) { + tangemTechApi.activatePromoCode( + body = PromocodeActivationBody( + promoCode = promoCode, + address = bitcoinAddress, + ), + ).fold({ + return@fold it.status.right() + }, { error -> + val error = when (error) { + is HttpException -> when (error.code) { + HttpException.Code.NOT_FOUND -> ActivatePromoCodeError.InvalidPromoCode + HttpException.Code.CONFLICT -> ActivatePromoCodeError.PromocodeAlreadyUsed + else -> ActivatePromoCodeError.ActivationFailed + } + else -> ActivatePromoCodeError.ActivationFailed + } + return@fold error.left() + },) + } } \ No newline at end of file diff --git a/data/wallets/src/test/java/com/tangem/data/wallets/DefaultWalletsRepositoryTest.kt b/data/wallets/src/test/java/com/tangem/data/wallets/DefaultWalletsRepositoryTest.kt index 447c2effdf..f305beb722 100644 --- a/data/wallets/src/test/java/com/tangem/data/wallets/DefaultWalletsRepositoryTest.kt +++ b/data/wallets/src/test/java/com/tangem/data/wallets/DefaultWalletsRepositoryTest.kt @@ -6,12 +6,16 @@ import com.google.common.truth.Truth.assertThat import com.squareup.moshi.Moshi import com.tangem.datasource.api.common.AuthProvider import com.tangem.datasource.api.common.response.ApiResponse +import com.tangem.datasource.api.common.response.ApiResponseError.HttpException import com.tangem.datasource.api.tangemTech.TangemTechApi import com.tangem.datasource.api.tangemTech.models.WalletResponse +import com.tangem.datasource.api.tangemTech.models.PromocodeActivationBody +import com.tangem.datasource.api.tangemTech.models.PromocodeActivationResponse import com.tangem.datasource.local.preferences.AppPreferencesStore import com.tangem.datasource.local.preferences.PreferencesKeys import com.tangem.domain.models.wallet.UserWallet import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.domain.wallets.models.errors.ActivatePromoCodeError import com.tangem.utils.coroutines.CoroutineDispatcherProvider import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider import io.mockk.coEvery @@ -235,4 +239,64 @@ class DefaultWalletsRepositoryTest { ) } } + + @Test + fun `GIVEN valid data WHEN activatePromoCode THEN returns Right with status and calls API`() = runTest { + // GIVEN + val promoCode = "PROMO123" + val address = "bc1qexampleaddress" + coEvery { tangemTechApi.activatePromoCode(any()) } returns ApiResponse.Success( + PromocodeActivationResponse(status = "activated"), + ) + + // WHEN + val result = repository.activatePromoCode(promoCode = promoCode, bitcoinAddress = address) + + // THEN + var right: String? = null + var left: ActivatePromoCodeError? = null + result.fold({ left = it }, { right = it }) + assertThat(left).isNull() + assertThat(right).isEqualTo("activated") + + coVerify(exactly = 1) { + tangemTechApi.activatePromoCode( + match { it is PromocodeActivationBody && it.promoCode == promoCode && it.address == address }, + ) + } + } + + @Test + fun `GIVEN NOT_FOUND error WHEN activatePromoCode THEN returns Left InvalidPromoCode`() = runTest { + // GIVEN + coEvery { tangemTechApi.activatePromoCode(any()) } returns + ApiResponse.Error( + HttpException(code = HttpException.Code.NOT_FOUND, message = null, errorBody = null), + ) as ApiResponse + + // WHEN + val result = repository.activatePromoCode(promoCode = "PROMO", bitcoinAddress = "addr") + + // THEN + var error: ActivatePromoCodeError? = null + result.fold({ error = it }, { }) + assertThat(error).isEqualTo(ActivatePromoCodeError.InvalidPromoCode) + } + + @Test + fun `GIVEN CONFLICT error WHEN activatePromoCode THEN returns Left PromocodeAlreadyUsed`() = runTest { + // GIVEN + coEvery { tangemTechApi.activatePromoCode(any()) } returns + ApiResponse.Error( + HttpException(code = HttpException.Code.CONFLICT, message = null, errorBody = null), + ) as ApiResponse + + // WHEN + val result = repository.activatePromoCode(promoCode = "PROMO", bitcoinAddress = "addr") + + // THEN + var error: ActivatePromoCodeError? = null + result.fold({ error = it }, { }) + assertThat(error).isEqualTo(ActivatePromoCodeError.PromocodeAlreadyUsed) + } } \ No newline at end of file diff --git a/domain/wallet-connect/models/src/main/java/com/tangem/domain/walletconnect/model/WcEthSignTypedDataParams.kt b/domain/wallet-connect/models/src/main/java/com/tangem/domain/walletconnect/model/WcEthSignTypedDataParams.kt index b14e6af6df..193a13c172 100644 --- a/domain/wallet-connect/models/src/main/java/com/tangem/domain/walletconnect/model/WcEthSignTypedDataParams.kt +++ b/domain/wallet-connect/models/src/main/java/com/tangem/domain/walletconnect/model/WcEthSignTypedDataParams.kt @@ -5,60 +5,12 @@ import com.squareup.moshi.JsonClass @JsonClass(generateAdapter = true) data class WcEthSignTypedDataParams( - @Json(name = "domain") - val domain: Domain?, @Json(name = "message") val message: Message?, - @Json(name = "primaryType") - val primaryType: String?, - @Json(name = "types") - val types: Map>, ) { - @JsonClass(generateAdapter = true) - data class Domain( - @Json(name = "chainId") - val chainId: Int?, - @Json(name = "name") - val name: String?, - @Json(name = "verifyingContract") - val verifyingContract: String?, - @Json(name = "version") - val version: String?, - ) - @JsonClass(generateAdapter = true) data class Message( @Json(name = "contents") val contents: String?, - @Json(name = "from") - val from: Address?, - @Json(name = "to") - val to: Address?, - ) { - @JsonClass(generateAdapter = true) - data class Address( - @Json(name = "name") - val name: String, - @Json(name = "wallet") - val wallet: String, - ) - } - - @JsonClass(generateAdapter = true) - data class Types( - @Json(name = "EIP712Domain") - val eIP712Domain: List = listOf(), - @Json(name = "Mail") - val mail: List = listOf(), - @Json(name = "Person") - val person: List = listOf(), - ) { - @JsonClass(generateAdapter = true) - data class Type( - @Json(name = "name") - val name: String, - @Json(name = "type") - val type: String, - ) - } + ) } \ No newline at end of file diff --git a/domain/wallets/models/src/main/java/com/tangem/domain/wallets/PromoCodeActivationResult.kt b/domain/wallets/models/src/main/java/com/tangem/domain/wallets/PromoCodeActivationResult.kt new file mode 100644 index 0000000000..0ca02411b2 --- /dev/null +++ b/domain/wallets/models/src/main/java/com/tangem/domain/wallets/PromoCodeActivationResult.kt @@ -0,0 +1,9 @@ +package com.tangem.domain.wallets + +enum class PromoCodeActivationResult { + Failed, + InvalidPromoCode, + NoBitcoinAddress, + PromoCodeAlreadyUsed, + Activated, +} \ No newline at end of file diff --git a/domain/wallets/models/src/main/java/com/tangem/domain/wallets/models/errors/ActivatePromoCodeError.kt b/domain/wallets/models/src/main/java/com/tangem/domain/wallets/models/errors/ActivatePromoCodeError.kt new file mode 100644 index 0000000000..cd1accae97 --- /dev/null +++ b/domain/wallets/models/src/main/java/com/tangem/domain/wallets/models/errors/ActivatePromoCodeError.kt @@ -0,0 +1,12 @@ +package com.tangem.domain.wallets.models.errors + +sealed class ActivatePromoCodeError { + + data object InvalidPromoCode : ActivatePromoCodeError() + + data object ActivationFailed : ActivatePromoCodeError() + + data object PromocodeAlreadyUsed : ActivatePromoCodeError() + + data object NoBitcoinAddress : ActivatePromoCodeError() +} \ No newline at end of file diff --git a/domain/wallets/src/main/java/com/tangem/domain/wallets/repository/WalletsRepository.kt b/domain/wallets/src/main/java/com/tangem/domain/wallets/repository/WalletsRepository.kt index a1eb1f0cd8..6e58c523ae 100644 --- a/domain/wallets/src/main/java/com/tangem/domain/wallets/repository/WalletsRepository.kt +++ b/domain/wallets/src/main/java/com/tangem/domain/wallets/repository/WalletsRepository.kt @@ -1,9 +1,11 @@ package com.tangem.domain.wallets.repository +import arrow.core.Either import com.tangem.domain.wallets.models.SeedPhraseNotificationsStatus import com.tangem.domain.models.wallet.UserWallet import com.tangem.domain.models.wallet.UserWalletId import com.tangem.domain.wallets.models.UserWalletRemoteInfo +import com.tangem.domain.wallets.models.errors.ActivatePromoCodeError import kotlinx.coroutines.flow.Flow @Suppress("TooManyFunctions") @@ -68,4 +70,6 @@ interface WalletsRepository { @Throws suspend fun associateWallets(applicationId: String, wallets: List) + + suspend fun activatePromoCode(promoCode: String, bitcoinAddress: String): Either } \ No newline at end of file diff --git a/domain/wallets/src/main/java/com/tangem/domain/wallets/usecase/ActivateBitcoinPromocodeUseCase.kt b/domain/wallets/src/main/java/com/tangem/domain/wallets/usecase/ActivateBitcoinPromocodeUseCase.kt new file mode 100644 index 0000000000..0acd401fcb --- /dev/null +++ b/domain/wallets/src/main/java/com/tangem/domain/wallets/usecase/ActivateBitcoinPromocodeUseCase.kt @@ -0,0 +1,14 @@ +package com.tangem.domain.wallets.usecase + +import arrow.core.Either +import com.tangem.domain.wallets.models.errors.ActivatePromoCodeError +import com.tangem.domain.wallets.repository.WalletsRepository +import javax.inject.Inject + +class ActivateBitcoinPromocodeUseCase @Inject constructor( + private val walletsRepository: WalletsRepository, +) { + + suspend operator fun invoke(address: String, promoCode: String): Either = + walletsRepository.activatePromoCode(promoCode = promoCode, bitcoinAddress = address) +} \ No newline at end of file diff --git a/features/wallet/api/src/main/kotlin/com/tangem/features/wallet/deeplink/PromoDeeplinkHandler.kt b/features/wallet/api/src/main/kotlin/com/tangem/features/wallet/deeplink/PromoDeeplinkHandler.kt new file mode 100644 index 0000000000..75939e8c55 --- /dev/null +++ b/features/wallet/api/src/main/kotlin/com/tangem/features/wallet/deeplink/PromoDeeplinkHandler.kt @@ -0,0 +1,10 @@ +package com.tangem.features.wallet.deeplink + +import kotlinx.coroutines.CoroutineScope + +interface PromoDeeplinkHandler { + + interface Factory { + fun create(coroutineScope: CoroutineScope, queryParams: Map): PromoDeeplinkHandler + } +} \ No newline at end of file diff --git a/features/wallet/impl/build.gradle.kts b/features/wallet/impl/build.gradle.kts index a8fffc2ea0..1df9de0f95 100644 --- a/features/wallet/impl/build.gradle.kts +++ b/features/wallet/impl/build.gradle.kts @@ -123,6 +123,8 @@ dependencies { implementation(projects.common.ui) /** Test libraries */ - implementation(deps.test.junit) - implementation(deps.test.truth) + testImplementation(deps.test.junit) + testImplementation(deps.test.coroutine) + testImplementation(deps.test.truth) + testImplementation(deps.test.mockk) } \ No newline at end of file diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/deeplink/DefaultPromoDeeplinkHandler.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/deeplink/DefaultPromoDeeplinkHandler.kt new file mode 100644 index 0000000000..e7f0ce0a1f --- /dev/null +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/deeplink/DefaultPromoDeeplinkHandler.kt @@ -0,0 +1,198 @@ +package com.tangem.feature.wallet.deeplink + +import com.tangem.blockchain.common.Blockchain +import com.tangem.common.routing.deeplink.DeeplinkConst.PROMO_CODE_KEY +import com.tangem.core.analytics.api.AnalyticsEventHandler +import com.tangem.core.decompose.di.GlobalUiMessageSender +import com.tangem.core.decompose.ui.UiMessageSender +import com.tangem.core.ui.extensions.mask +import com.tangem.core.ui.extensions.resourceReference +import com.tangem.core.ui.message.DialogMessage +import com.tangem.core.ui.message.GlobalLoadingMessage +import com.tangem.domain.models.network.NetworkStatus +import com.tangem.domain.models.wallet.UserWallet +import com.tangem.domain.networks.multi.MultiNetworkStatusProducer +import com.tangem.domain.networks.multi.MultiNetworkStatusSupplier +import com.tangem.domain.tokens.MultiWalletCryptoCurrenciesProducer +import com.tangem.domain.tokens.MultiWalletCryptoCurrenciesSupplier +import com.tangem.domain.wallets.PromoCodeActivationResult +import com.tangem.domain.wallets.PromoCodeActivationResult.* +import com.tangem.domain.wallets.models.errors.ActivatePromoCodeError +import com.tangem.domain.wallets.usecase.ActivateBitcoinPromocodeUseCase +import com.tangem.domain.wallets.usecase.GetSelectedWalletSyncUseCase +import com.tangem.feature.wallet.deeplink.analytics.PromoActivationAnalytics +import com.tangem.feature.wallet.impl.R +import com.tangem.features.wallet.deeplink.PromoDeeplinkHandler +import com.tangem.utils.coroutines.CoroutineDispatcherProvider +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch +import kotlinx.coroutines.withTimeoutOrNull +import timber.log.Timber +import kotlin.time.Duration.Companion.seconds + +@Suppress("LongParameterList") +internal class DefaultPromoDeeplinkHandler @AssistedInject constructor( + @Assisted private val scope: CoroutineScope, + @Assisted private val queryParams: Map, + @GlobalUiMessageSender private val uiMessageSender: UiMessageSender, + private val multiNetworkStatusSupplier: MultiNetworkStatusSupplier, + private val multiWalletCryptoCurrenciesSupplier: MultiWalletCryptoCurrenciesSupplier, + private val activateBitcoinPromocodeUseCase: ActivateBitcoinPromocodeUseCase, + private val getSelectedWalletSyncUseCase: GetSelectedWalletSyncUseCase, + private val analyticsEventsHandler: AnalyticsEventHandler, + private val dispatchers: CoroutineDispatcherProvider, +) : PromoDeeplinkHandler { + + init { + analyticsEventsHandler.send(PromoActivationAnalytics.PromoDeepLinkActivationStart) + val promoCode = queryParams[PROMO_CODE_KEY].orEmpty() + if (promoCode.isEmpty()) { + showAlert(InvalidPromoCode) + } else { + findSelectedWallet(promoCode) + } + } + + private fun findSelectedWallet(promoCode: String) { + getSelectedWalletSyncUseCase().fold( + ifLeft = { + Timber.tag(LOG_TAG).e("Error on getting user wallet: $it") + showAlert(Failed) + }, + ifRight = { userWallet -> + Timber.tag(LOG_TAG).d("SelectedUserWallet ${userWallet.walletId.stringValue.mask()}") + findBitcoinAddress(userWallet = userWallet, promoCode = promoCode) + }, + ) + } + + private fun findBitcoinAddress(userWallet: UserWallet, promoCode: String) { + scope.launch(context = dispatchers.default) { + val networkStatuses = withTimeoutOrNull( + FETCH_TIMEOUT_SECONDS.seconds, + { + multiNetworkStatusSupplier + .invoke(MultiNetworkStatusProducer.Params(userWallet.walletId)) + .first { statuses -> + statuses.any { status -> status.network.rawId == Blockchain.Bitcoin.id } + } + }, + ) + + Timber.tag(LOG_TAG).d("All user network statuses ${networkStatuses?.size}") + + val cryptoCurrencies = multiWalletCryptoCurrenciesSupplier + .getSyncOrNull( + MultiWalletCryptoCurrenciesProducer.Params( + userWallet + .walletId, + ), + ) + + Timber.tag(LOG_TAG).d("All user cryptoCurrencies on main ${cryptoCurrencies?.size}") + + val bitcoinCurrency = cryptoCurrencies?.firstOrNull { it.id.rawNetworkId == Blockchain.Bitcoin.id } + Timber.tag(LOG_TAG).d("BitcoinCurrency $bitcoinCurrency") + + val bitcoinStatus = networkStatuses?.firstOrNull { status -> + status.network.id == bitcoinCurrency + ?.network?.id + } + Timber.tag(LOG_TAG).d("BitcoinStatus $bitcoinStatus") + + if (bitcoinStatus == null) { + Timber.tag(LOG_TAG).d("No bitcoin, bitcoin network status == null") + showAlert(NoBitcoinAddress) + } else { + val networkAddress = when (bitcoinStatus.value) { + is NetworkStatus.Verified -> (bitcoinStatus.value as NetworkStatus.Verified).address + is NetworkStatus.NoAccount -> (bitcoinStatus.value as NetworkStatus.NoAccount).address + else -> null + } + + val bitcoinAddress = networkAddress + ?.defaultAddress?.value + + if (bitcoinAddress != null) { + Timber.tag(LOG_TAG).d( + "Start activation promoCode ${promoCode.mask()} address ${bitcoinAddress.mask()}", + ) + activatePromoCode(bitcoinAddress = bitcoinAddress, promoCode = promoCode) + } else { + uiMessageSender.send(GlobalLoadingMessage(false)) + delay(DEFAULT_MESSAGE_SENDER_DELAY) + Timber.tag(LOG_TAG).d("No Bitcoin address $bitcoinStatus.value") + showAlert(NoBitcoinAddress) + } + } + } + } + + private suspend fun activatePromoCode(bitcoinAddress: String, promoCode: String) { + uiMessageSender.send(GlobalLoadingMessage(true)) + activateBitcoinPromocodeUseCase(bitcoinAddress, promoCode).onRight { + delay(DEFAULT_MESSAGE_SENDER_DELAY) + uiMessageSender.send(GlobalLoadingMessage(false)) + delay(DEFAULT_MESSAGE_SENDER_DELAY) + Timber.tag(LOG_TAG).d("${promoCode.mask()} activation success on address ${bitcoinAddress.mask()}") + showAlert(Activated) + }.onLeft { error -> + delay(DEFAULT_MESSAGE_SENDER_DELAY) + uiMessageSender.send(GlobalLoadingMessage(false)) + delay(DEFAULT_MESSAGE_SENDER_DELAY) + Timber.tag(LOG_TAG).d("${promoCode.mask()} activation failed $error") + val alertType = when (error) { + ActivatePromoCodeError.ActivationFailed -> Failed + ActivatePromoCodeError.InvalidPromoCode -> InvalidPromoCode + ActivatePromoCodeError.NoBitcoinAddress -> NoBitcoinAddress + ActivatePromoCodeError.PromocodeAlreadyUsed -> PromoCodeAlreadyUsed + } + showAlert(alertType) + } + } + + private fun showAlert(type: PromoCodeActivationResult) { + analyticsEventsHandler.send(PromoActivationAnalytics.PromoActivation(type)) + val (title, message) = when (type) { + Failed -> resourceReference(R.string.bitcoin_promo_activation_error_title) to + resourceReference(R.string.bitcoin_promo_activation_error) + InvalidPromoCode -> resourceReference(R.string.bitcoin_promo_invalid_code_title) to + resourceReference(R.string.bitcoin_promo_invalid_code) + NoBitcoinAddress -> resourceReference(R.string.bitcoin_promo_no_address_title) to + resourceReference(R.string.bitcoin_promo_no_address) + PromoCodeAlreadyUsed -> resourceReference(R.string.bitcoin_promo_already_activated_title) to + resourceReference(R.string.bitcoin_promo_already_activated) + Activated -> resourceReference(R.string.bitcoin_promo_activation_success_title) to + resourceReference(R.string.bitcoin_promo_activation_success) + } + uiMessageSender.send( + DialogMessage( + title = title, + message = message, + dismissOnFirstAction = true, + firstActionBuilder = { + okAction { } + }, + ), + ) + } + + @AssistedFactory + interface Factory : PromoDeeplinkHandler.Factory { + override fun create( + coroutineScope: CoroutineScope, + queryParams: Map, + ): DefaultPromoDeeplinkHandler + } + + companion object { + private const val LOG_TAG = "PromoCodeActivation" + private const val DEFAULT_MESSAGE_SENDER_DELAY = 500L + private const val FETCH_TIMEOUT_SECONDS = 5 + } +} \ No newline at end of file diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/deeplink/analytics/PromoActivationAnalytics.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/deeplink/analytics/PromoActivationAnalytics.kt new file mode 100644 index 0000000000..bd1bbc7dcc --- /dev/null +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/deeplink/analytics/PromoActivationAnalytics.kt @@ -0,0 +1,28 @@ +package com.tangem.feature.wallet.deeplink.analytics + +import com.tangem.core.analytics.models.AnalyticsEvent +import com.tangem.domain.wallets.PromoCodeActivationResult + +sealed class PromoActivationAnalytics( + event: String, + params: Map = mapOf(), +) : AnalyticsEvent(category = "Promotion", event = event, params = params) { + + data object PromoDeepLinkActivationStart : PromoActivationAnalytics( + event = "Bitcoin Promo Deep Link Activation", + params = emptyMap(), + ) + + data class PromoActivation(val result: PromoCodeActivationResult) : PromoActivationAnalytics( + event = "Bitcoin Promo Activation", + params = mapOf( + "State" to when (result) { + PromoCodeActivationResult.Failed -> "Error" + PromoCodeActivationResult.InvalidPromoCode -> "Invalid" + PromoCodeActivationResult.NoBitcoinAddress -> "No Address" + PromoCodeActivationResult.PromoCodeAlreadyUsed -> "Already Used" + PromoCodeActivationResult.Activated -> "Activated" + }, + ), + ) +} \ No newline at end of file diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/deeplink/di/WalletDeepLinkModule.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/deeplink/di/WalletDeepLinkModule.kt index fca5019c10..f8f1e46718 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/deeplink/di/WalletDeepLinkModule.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/deeplink/di/WalletDeepLinkModule.kt @@ -1,7 +1,9 @@ package com.tangem.feature.wallet.deeplink.di +import com.tangem.feature.wallet.deeplink.DefaultPromoDeeplinkHandler import com.tangem.feature.wallet.deeplink.DefaultWalletDeepLinkActionTrigger import com.tangem.feature.wallet.deeplink.DefaultWalletDeepLinkHandler +import com.tangem.features.wallet.deeplink.PromoDeeplinkHandler import com.tangem.features.wallet.deeplink.WalletDeepLinkActionListener import com.tangem.features.wallet.deeplink.WalletDeepLinkActionTrigger import com.tangem.features.wallet.deeplink.WalletDeepLinkHandler @@ -19,6 +21,10 @@ internal interface WalletDeepLinkModule { @Singleton fun bindWalletDeepLinkHandlerFactory(impl: DefaultWalletDeepLinkHandler.Factory): WalletDeepLinkHandler.Factory + @Binds + @Singleton + fun bindPromoDeepLinkHandlerFactory(impl: DefaultPromoDeeplinkHandler.Factory): PromoDeeplinkHandler.Factory + @Binds @Singleton fun bindWalletDeepLinkActionTrigger(impl: DefaultWalletDeepLinkActionTrigger): WalletDeepLinkActionTrigger diff --git a/features/wallet/impl/src/test/kotlin/com/tangem/feature/wallet/presentation/wallet/deeplink/DefaultPromoDeeplinkHandlerTest.kt b/features/wallet/impl/src/test/kotlin/com/tangem/feature/wallet/presentation/wallet/deeplink/DefaultPromoDeeplinkHandlerTest.kt new file mode 100644 index 0000000000..29a42feb84 --- /dev/null +++ b/features/wallet/impl/src/test/kotlin/com/tangem/feature/wallet/presentation/wallet/deeplink/DefaultPromoDeeplinkHandlerTest.kt @@ -0,0 +1,868 @@ +@file:Suppress("FunctionSignature") + +package com.tangem.feature.wallet.presentation.wallet.deeplink + +import arrow.core.Either +import com.google.common.truth.Truth +import com.tangem.blockchain.common.Blockchain +import com.tangem.common.routing.deeplink.DeeplinkConst +import com.tangem.core.analytics.api.AnalyticsEventHandler +import com.tangem.core.decompose.ui.UiMessage +import com.tangem.core.decompose.ui.UiMessageSender +import com.tangem.core.ui.extensions.TextReference +import com.tangem.core.ui.extensions.resourceReference +import com.tangem.core.ui.message.DialogMessage +import com.tangem.domain.models.StatusSource +import com.tangem.domain.models.currency.CryptoCurrency +import com.tangem.domain.models.network.Network +import com.tangem.domain.models.network.NetworkAddress +import com.tangem.domain.models.network.NetworkStatus +import com.tangem.domain.models.wallet.UserWallet +import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.domain.networks.multi.MultiNetworkStatusSupplier +import com.tangem.domain.tokens.MultiWalletCryptoCurrenciesSupplier +import com.tangem.domain.wallets.PromoCodeActivationResult +import com.tangem.domain.wallets.models.GetUserWalletError +import com.tangem.domain.wallets.models.errors.ActivatePromoCodeError +import com.tangem.domain.wallets.usecase.ActivateBitcoinPromocodeUseCase +import com.tangem.domain.wallets.usecase.GetSelectedWalletSyncUseCase +import com.tangem.feature.wallet.deeplink.DefaultPromoDeeplinkHandler +import com.tangem.feature.wallet.deeplink.analytics.PromoActivationAnalytics +import com.tangem.feature.wallet.impl.R +import com.tangem.utils.coroutines.CoroutineDispatcherProvider +import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider +import io.mockk.* +import io.mockk.impl.annotations.MockK +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.TestCoroutineScheduler +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runTest +import org.junit.Before +import org.junit.Test +import timber.log.Timber + +@OptIn(ExperimentalCoroutinesApi::class) +class DefaultPromoDeeplinkHandlerTest { + + @MockK(relaxed = true) + private lateinit var uiMessageSender: UiMessageSender + + @MockK + private lateinit var multiNetworkStatusSupplier: MultiNetworkStatusSupplier + + @MockK + private lateinit var multiWalletCryptoCurrenciesSupplier: MultiWalletCryptoCurrenciesSupplier + + @MockK + private lateinit var activateBitcoinPromocodeUseCase: ActivateBitcoinPromocodeUseCase + + @MockK + private lateinit var getSelectedWalletSyncUseCase: GetSelectedWalletSyncUseCase + + @MockK + private lateinit var analyticsEventHandler: AnalyticsEventHandler + + private lateinit var messages: MutableList + + @Before + fun setUp() { + MockKAnnotations.init(this) + every { analyticsEventHandler.send(any()) } returns Unit + messages = mutableListOf() + every { uiMessageSender.send(capture(messages)) } just runs + + Timber.uprootAll() + } + + @Test + fun `GIVEN empty and non-BTC then BTC WHEN supplier emits THEN activated dialog is shown`() = runTest { + val promoCode = "PROMO123" + val queryParams = mapOf(DeeplinkConst.PROMO_CODE_KEY to promoCode) + val userWallet = mockUserWallet("ABCDEF") + every { getSelectedWalletSyncUseCase.invoke() } returns Either.Right(userWallet) + + val ethStatus = buildNetworkStatus(rawNetworkId = "ethereum", address = "0x123") + val btcStatus = buildNetworkStatus(rawNetworkId = Blockchain.Bitcoin.id, address = "bc1qxyz") + coEvery { multiNetworkStatusSupplier.invoke(any()) } returns flow { + emit(emptySet()) + emit(setOf(ethStatus)) + emit(setOf(btcStatus)) + } + val btcCoin = buildCryptoCurrency(rawNetworkId = Blockchain.Bitcoin.id) + coEvery { multiWalletCryptoCurrenciesSupplier.getSyncOrNull(any()) } returns setOf(btcCoin) + coEvery { activateBitcoinPromocodeUseCase.invoke("bc1qxyz", promoCode) } returns Either.Right("ok") + val dispatcherProvider = testDispatcherProvider(testScheduler) + + DefaultPromoDeeplinkHandler( + scope = this, + queryParams = queryParams, + uiMessageSender = uiMessageSender, + multiNetworkStatusSupplier = multiNetworkStatusSupplier, + multiWalletCryptoCurrenciesSupplier = multiWalletCryptoCurrenciesSupplier, + activateBitcoinPromocodeUseCase = activateBitcoinPromocodeUseCase, + getSelectedWalletSyncUseCase = getSelectedWalletSyncUseCase, + analyticsEventsHandler = analyticsEventHandler, + dispatchers = dispatcherProvider, + ) + + advanceUntilIdle() + + val sent = messages.last { it is DialogMessage } as DialogMessage + Truth.assertThat(sent.title).isEqualTo(resourceReference(R.string.bitcoin_promo_activation_success_title)) + Truth.assertThat(sent.message).isEqualTo(resourceReference(R.string.bitcoin_promo_activation_success)) + + verify(exactly = 1) { analyticsEventHandler.send(PromoActivationAnalytics.PromoDeepLinkActivationStart) } + verify(exactly = 1) { + analyticsEventHandler.send( + PromoActivationAnalytics.PromoActivation(PromoCodeActivationResult.Activated), + ) + } + } + + @Test + fun `GIVEN empty promo code WHEN init THEN invalid promo code dialog is shown`() = runTest { + val queryParams = emptyMap() + + DefaultPromoDeeplinkHandler( + scope = this, + queryParams = queryParams, + uiMessageSender = uiMessageSender, + multiNetworkStatusSupplier = multiNetworkStatusSupplier, + multiWalletCryptoCurrenciesSupplier = multiWalletCryptoCurrenciesSupplier, + activateBitcoinPromocodeUseCase = activateBitcoinPromocodeUseCase, + getSelectedWalletSyncUseCase = getSelectedWalletSyncUseCase, + analyticsEventsHandler = analyticsEventHandler, + dispatchers = TestingCoroutineDispatcherProvider(), + ) + + val sent = messages.last { it is DialogMessage } as DialogMessage + Truth.assertThat(sent.title).isEqualTo(resourceReference(R.string.bitcoin_promo_invalid_code_title)) + Truth.assertThat(sent.message).isEqualTo(resourceReference(R.string.bitcoin_promo_invalid_code)) + + verify(exactly = 1) { analyticsEventHandler.send(PromoActivationAnalytics.PromoDeepLinkActivationStart) } + verify(exactly = 1) { + analyticsEventHandler.send( + PromoActivationAnalytics.PromoActivation(PromoCodeActivationResult.InvalidPromoCode), + ) + } + } + + @Test + fun `GIVEN wallet fetch error WHEN getSelectedWalletUseCase THEN failed dialog is shown`() = runTest { + val queryParams = mapOf(DeeplinkConst.PROMO_CODE_KEY to "PROMO123") + every { getSelectedWalletSyncUseCase.invoke() } returns Either.Left(GetUserWalletError.UserWalletNotFound) + + DefaultPromoDeeplinkHandler( + scope = this, + queryParams = queryParams, + uiMessageSender = uiMessageSender, + multiNetworkStatusSupplier = multiNetworkStatusSupplier, + multiWalletCryptoCurrenciesSupplier = multiWalletCryptoCurrenciesSupplier, + activateBitcoinPromocodeUseCase = activateBitcoinPromocodeUseCase, + getSelectedWalletSyncUseCase = getSelectedWalletSyncUseCase, + analyticsEventsHandler = analyticsEventHandler, + dispatchers = TestingCoroutineDispatcherProvider(), + ) + + val sent = messages.last { it is DialogMessage } as DialogMessage + Truth.assertThat(sent.title).isEqualTo(resourceReference(R.string.bitcoin_promo_activation_error_title)) + Truth.assertThat(sent.message).isEqualTo(resourceReference(R.string.bitcoin_promo_activation_error)) + + verify(exactly = 1) { analyticsEventHandler.send(PromoActivationAnalytics.PromoDeepLinkActivationStart) } + verify(exactly = 1) { + analyticsEventHandler.send( + PromoActivationAnalytics.PromoActivation(PromoCodeActivationResult.Failed), + ) + } + } + + @Test + fun `GIVEN no bitcoin address WHEN findBitcoinAddress THEN no bitcoin address dialog is shown`() = runTest { + val queryParams = mapOf(DeeplinkConst.PROMO_CODE_KEY to "PROMO123") + val userWallet = mockUserWallet("ABCDEF") + every { getSelectedWalletSyncUseCase.invoke() } returns Either.Right(userWallet) + val btcUnreachable = buildUnreachableNetworkStatus(rawNetworkId = Blockchain.Bitcoin.id) + coEvery { multiNetworkStatusSupplier.invoke(any()) } returns flowOf(setOf(btcUnreachable)) + val btcCoin = buildCryptoCurrency(rawNetworkId = Blockchain.Bitcoin.id) + coEvery { multiWalletCryptoCurrenciesSupplier.getSyncOrNull(any()) } returns setOf(btcCoin) + val dispatcherProvider = testDispatcherProvider(testScheduler) + + DefaultPromoDeeplinkHandler( + scope = this, + queryParams = queryParams, + uiMessageSender = uiMessageSender, + multiNetworkStatusSupplier = multiNetworkStatusSupplier, + multiWalletCryptoCurrenciesSupplier = multiWalletCryptoCurrenciesSupplier, + activateBitcoinPromocodeUseCase = activateBitcoinPromocodeUseCase, + getSelectedWalletSyncUseCase = getSelectedWalletSyncUseCase, + analyticsEventsHandler = analyticsEventHandler, + dispatchers = dispatcherProvider, + ) + + advanceUntilIdle() + + val sent = messages.last { it is DialogMessage } as DialogMessage + Truth.assertThat(sent.title).isEqualTo(resourceReference(R.string.bitcoin_promo_no_address_title)) + Truth.assertThat(sent.message).isEqualTo(resourceReference(R.string.bitcoin_promo_no_address)) + + verify(exactly = 1) { analyticsEventHandler.send(PromoActivationAnalytics.PromoDeepLinkActivationStart) } + verify(exactly = 1) { + analyticsEventHandler.send( + PromoActivationAnalytics.PromoActivation(PromoCodeActivationResult.NoBitcoinAddress), + ) + } + } + + @Test + fun `GIVEN activation success WHEN activatePromoCode THEN activated dialog is shown`() = runTest { + val promoCode = "PROMO123" + val queryParams = mapOf(DeeplinkConst.PROMO_CODE_KEY to promoCode) + val userWallet = mockUserWallet("ABCDEF") + every { getSelectedWalletSyncUseCase.invoke() } returns Either.Right(userWallet) + val btcStatus = buildNetworkStatus(rawNetworkId = Blockchain.Bitcoin.id, address = "bc1qxyz") + coEvery { multiNetworkStatusSupplier.invoke(any()) } returns flowOf(setOf(btcStatus)) + val btcCoin = buildCryptoCurrency(rawNetworkId = Blockchain.Bitcoin.id) + coEvery { multiWalletCryptoCurrenciesSupplier.getSyncOrNull(any()) } returns setOf(btcCoin) + coEvery { activateBitcoinPromocodeUseCase.invoke("bc1qxyz", promoCode) } returns Either.Right("ok") + val dispatcherProvider = testDispatcherProvider(testScheduler) + + DefaultPromoDeeplinkHandler( + scope = this, + queryParams = queryParams, + uiMessageSender = uiMessageSender, + multiNetworkStatusSupplier = multiNetworkStatusSupplier, + multiWalletCryptoCurrenciesSupplier = multiWalletCryptoCurrenciesSupplier, + activateBitcoinPromocodeUseCase = activateBitcoinPromocodeUseCase, + getSelectedWalletSyncUseCase = getSelectedWalletSyncUseCase, + analyticsEventsHandler = analyticsEventHandler, + dispatchers = dispatcherProvider, + ) + + advanceUntilIdle() + + val sent = messages.last { it is DialogMessage } as DialogMessage + Truth.assertThat(sent.title).isEqualTo(resourceReference(R.string.bitcoin_promo_activation_success_title)) + Truth.assertThat(sent.message).isEqualTo(resourceReference(R.string.bitcoin_promo_activation_success)) + + verify(exactly = 1) { analyticsEventHandler.send(PromoActivationAnalytics.PromoDeepLinkActivationStart) } + verify(exactly = 1) { + analyticsEventHandler.send( + PromoActivationAnalytics.PromoActivation(PromoCodeActivationResult.Activated), + ) + } + } + + @Test + fun `GIVEN activation invalid code WHEN activatePromoCode THEN invalid promo code dialog is shown`() = runTest { + runActivationErrorCase( + error = ActivatePromoCodeError.InvalidPromoCode, + expectedTitle = resourceReference(R.string.bitcoin_promo_invalid_code_title), + expectedMessage = resourceReference(R.string.bitcoin_promo_invalid_code), + ) + + verify(exactly = 1) { analyticsEventHandler.send(PromoActivationAnalytics.PromoDeepLinkActivationStart) } + verify(exactly = 1) { + analyticsEventHandler.send( + PromoActivationAnalytics.PromoActivation(PromoCodeActivationResult.InvalidPromoCode), + ) + } + } + + @Test + fun `GIVEN activation failed WHEN activatePromoCode THEN failed dialog is shown`() = runTest { + runActivationErrorCase( + error = ActivatePromoCodeError.ActivationFailed, + expectedTitle = resourceReference(R.string.bitcoin_promo_activation_error_title), + expectedMessage = resourceReference(R.string.bitcoin_promo_activation_error), + ) + + verify(exactly = 1) { analyticsEventHandler.send(PromoActivationAnalytics.PromoDeepLinkActivationStart) } + verify(exactly = 1) { + analyticsEventHandler.send( + PromoActivationAnalytics.PromoActivation(PromoCodeActivationResult.Failed), + ) + } + } + + @Test + fun `GIVEN activation no address WHEN activatePromoCode THEN no bitcoin address dialog is shown`() = runTest { + runActivationErrorCase( + error = ActivatePromoCodeError.NoBitcoinAddress, + expectedTitle = resourceReference(R.string.bitcoin_promo_no_address_title), + expectedMessage = resourceReference(R.string.bitcoin_promo_no_address), + ) + + verify(exactly = 1) { analyticsEventHandler.send(PromoActivationAnalytics.PromoDeepLinkActivationStart) } + verify(exactly = 1) { + analyticsEventHandler.send( + PromoActivationAnalytics.PromoActivation(PromoCodeActivationResult.NoBitcoinAddress), + ) + } + } + + @Test + fun `GIVEN activation already used WHEN activatePromoCode THEN already activated dialog is shown`() = runTest { + runActivationErrorCase( + error = ActivatePromoCodeError.PromocodeAlreadyUsed, + expectedTitle = resourceReference(R.string.bitcoin_promo_already_activated_title), + expectedMessage = resourceReference(R.string.bitcoin_promo_already_activated), + ) + + verify(exactly = 1) { analyticsEventHandler.send(PromoActivationAnalytics.PromoDeepLinkActivationStart) } + verify(exactly = 1) { + analyticsEventHandler.send( + PromoActivationAnalytics.PromoActivation(PromoCodeActivationResult.PromoCodeAlreadyUsed), + ) + } + } + + private fun runActivationErrorCase( + error: ActivatePromoCodeError, + expectedTitle: TextReference, + expectedMessage: TextReference, + ) = runTest { + val promoCode = "PROMO123" + val queryParams = mapOf(DeeplinkConst.PROMO_CODE_KEY to promoCode) + val userWallet = mockUserWallet("ABCDEF") + every { getSelectedWalletSyncUseCase.invoke() } returns Either.Right(userWallet) + val btcStatus = buildNetworkStatus(rawNetworkId = Blockchain.Bitcoin.id, address = "bc1qxyz") + coEvery { multiNetworkStatusSupplier.invoke(any()) } returns flowOf(setOf(btcStatus)) + val btcCurrency = buildCryptoCurrency(rawNetworkId = Blockchain.Bitcoin.id) + coEvery { multiWalletCryptoCurrenciesSupplier.getSyncOrNull(any()) } returns setOf(btcCurrency) + coEvery { activateBitcoinPromocodeUseCase.invoke("bc1qxyz", promoCode) } returns Either.Left(error) + val dispatcherProvider = testDispatcherProvider(testScheduler) + + DefaultPromoDeeplinkHandler( + scope = this, + queryParams = queryParams, + uiMessageSender = uiMessageSender, + multiNetworkStatusSupplier = multiNetworkStatusSupplier, + multiWalletCryptoCurrenciesSupplier = multiWalletCryptoCurrenciesSupplier, + activateBitcoinPromocodeUseCase = activateBitcoinPromocodeUseCase, + getSelectedWalletSyncUseCase = getSelectedWalletSyncUseCase, + analyticsEventsHandler = analyticsEventHandler, + dispatchers = dispatcherProvider, + ) + + advanceUntilIdle() + + val sent = messages.last { it is DialogMessage } as DialogMessage + Truth.assertThat(sent.title).isEqualTo(expectedTitle) + Truth.assertThat(sent.message).isEqualTo(expectedMessage) + } + + @Test + fun `GIVEN BTC status but currencies without BTC WHEN findBitcoinAddress THEN no bitcoin address dialog is shown`() = + runTest { + val queryParams = mapOf(DeeplinkConst.PROMO_CODE_KEY to "PROMO123") + val userWallet = mockUserWallet("ABCDEF") + every { getSelectedWalletSyncUseCase.invoke() } returns Either.Right(userWallet) + val btcStatus = buildNetworkStatus(rawNetworkId = Blockchain.Bitcoin.id, address = "bc1qxyz") + coEvery { multiNetworkStatusSupplier.invoke(any()) } returns flowOf(setOf(btcStatus)) + val ethOnly = buildCryptoCurrency(rawNetworkId = "ethereum") + coEvery { multiWalletCryptoCurrenciesSupplier.getSyncOrNull(any()) } returns setOf(ethOnly) + val dispatcherProvider = testDispatcherProvider(testScheduler) + + DefaultPromoDeeplinkHandler( + scope = this, + queryParams = queryParams, + uiMessageSender = uiMessageSender, + multiNetworkStatusSupplier = multiNetworkStatusSupplier, + multiWalletCryptoCurrenciesSupplier = multiWalletCryptoCurrenciesSupplier, + activateBitcoinPromocodeUseCase = activateBitcoinPromocodeUseCase, + getSelectedWalletSyncUseCase = getSelectedWalletSyncUseCase, + analyticsEventsHandler = analyticsEventHandler, + dispatchers = dispatcherProvider, + ) + + advanceUntilIdle() + + val sent = messages.last { it is DialogMessage } as DialogMessage + Truth.assertThat(sent.title).isEqualTo(resourceReference(R.string.bitcoin_promo_no_address_title)) + Truth.assertThat(sent.message).isEqualTo(resourceReference(R.string.bitcoin_promo_no_address)) + } + + @Test + fun `GIVEN multiple statuses emissions and currencies without BTC WHEN findBitcoinAddress THEN no bitcoin address dialog is shown`() = + runTest { + val promoCode = "PROMO123" + val queryParams = mapOf(DeeplinkConst.PROMO_CODE_KEY to promoCode) + val userWallet = mockUserWallet("ABCDEF") + every { getSelectedWalletSyncUseCase.invoke() } returns Either.Right(userWallet) + + val ethStatus = buildNetworkStatus(rawNetworkId = "ethereum", address = "0x123") + val btcStatus = buildNetworkStatus(rawNetworkId = Blockchain.Bitcoin.id, address = "bc1qxyz") + coEvery { multiNetworkStatusSupplier.invoke(any()) } returns flow { + emit(emptySet()) + emit(setOf(ethStatus)) + emit(setOf(ethStatus, btcStatus)) + } + + val ethOnly = buildCryptoCurrency(rawNetworkId = "ethereum") + coEvery { multiWalletCryptoCurrenciesSupplier.getSyncOrNull(any()) } returns setOf(ethOnly) + + val dispatcherProvider = testDispatcherProvider(testScheduler) + + DefaultPromoDeeplinkHandler( + scope = this, + queryParams = queryParams, + uiMessageSender = uiMessageSender, + multiNetworkStatusSupplier = multiNetworkStatusSupplier, + multiWalletCryptoCurrenciesSupplier = multiWalletCryptoCurrenciesSupplier, + activateBitcoinPromocodeUseCase = activateBitcoinPromocodeUseCase, + getSelectedWalletSyncUseCase = getSelectedWalletSyncUseCase, + analyticsEventsHandler = analyticsEventHandler, + dispatchers = dispatcherProvider, + ) + + advanceUntilIdle() + + val sent = messages.last { it is DialogMessage } as DialogMessage + Truth.assertThat(sent.title).isEqualTo(resourceReference(R.string.bitcoin_promo_no_address_title)) + Truth.assertThat(sent.message).isEqualTo(resourceReference(R.string.bitcoin_promo_no_address)) + + verify(exactly = 1) { analyticsEventHandler.send(PromoActivationAnalytics.PromoDeepLinkActivationStart) } + verify(exactly = 1) { + analyticsEventHandler.send( + PromoActivationAnalytics.PromoActivation(PromoCodeActivationResult.NoBitcoinAddress), + ) + } + } + + @Test + fun `GIVEN two BTC statuses with different derivation AND two BTC currencies WHEN activate THEN activated`() = + runTest { + val promoCode = "PROMO123" + val queryParams = mapOf(DeeplinkConst.PROMO_CODE_KEY to promoCode) + val userWallet = mockUserWallet("ABCDEF") + every { getSelectedWalletSyncUseCase.invoke() } returns Either.Right(userWallet) + + val dpCard = Network.DerivationPath.Card("m/44'/0'/0'") + val dpCustom = Network.DerivationPath.Custom("m/84'/0'/0'") + + val btcStatusCard = buildNetworkStatus( + rawNetworkId = Blockchain.Bitcoin.id, + address = "bc1qcard", + derivationPath = dpCard, + ) + val btcStatusCustom = buildNetworkStatus( + rawNetworkId = Blockchain.Bitcoin.id, + address = "bc1qcustom", + derivationPath = dpCustom, + ) + coEvery { multiNetworkStatusSupplier.invoke(any()) } returns flowOf(setOf(btcStatusCard, btcStatusCustom)) + + val btcCoinCustom = buildCryptoCurrency(rawNetworkId = Blockchain.Bitcoin.id, derivationPath = dpCustom) + val btcCoinCard = buildCryptoCurrency(rawNetworkId = Blockchain.Bitcoin.id, derivationPath = dpCard) + coEvery { multiWalletCryptoCurrenciesSupplier.getSyncOrNull(any()) } returns setOf( + btcCoinCustom, + btcCoinCard, + ) + coEvery { activateBitcoinPromocodeUseCase.invoke("bc1qcustom", promoCode) } returns Either.Right("ok") + + val dispatcherProvider = testDispatcherProvider(testScheduler) + + DefaultPromoDeeplinkHandler( + scope = this, + queryParams = queryParams, + uiMessageSender = uiMessageSender, + multiNetworkStatusSupplier = multiNetworkStatusSupplier, + multiWalletCryptoCurrenciesSupplier = multiWalletCryptoCurrenciesSupplier, + activateBitcoinPromocodeUseCase = activateBitcoinPromocodeUseCase, + getSelectedWalletSyncUseCase = getSelectedWalletSyncUseCase, + analyticsEventsHandler = analyticsEventHandler, + dispatchers = dispatcherProvider, + ) + + advanceUntilIdle() + + val sent = messages.last { it is DialogMessage } as DialogMessage + Truth.assertThat(sent.title).isEqualTo(resourceReference(R.string.bitcoin_promo_activation_success_title)) + Truth.assertThat(sent.message).isEqualTo(resourceReference(R.string.bitcoin_promo_activation_success)) + + coVerify(exactly = 1) { activateBitcoinPromocodeUseCase.invoke("bc1qcustom", promoCode) } + coVerify(exactly = 0) { activateBitcoinPromocodeUseCase.invoke("bc1qcard", promoCode) } + } + + @Test + fun `GIVEN two BTC statuses with different derivation AND one matching BTC currency WHEN activate THEN activated`() = + runTest { + val promoCode = "PROMO123" + val queryParams = mapOf(DeeplinkConst.PROMO_CODE_KEY to promoCode) + val userWallet = mockUserWallet("ABCDEF") + every { getSelectedWalletSyncUseCase.invoke() } returns Either.Right(userWallet) + + val dpCard = Network.DerivationPath.Card("m/44'/0'/0'") + val dpCustom = Network.DerivationPath.Custom("m/84'/0'/0'") + + val btcStatusCard = buildNetworkStatus( + rawNetworkId = Blockchain.Bitcoin.id, + address = "bc1qcard", + derivationPath = dpCard, + ) + val btcStatusCustom = buildNetworkStatus( + rawNetworkId = Blockchain.Bitcoin.id, + address = "bc1qcustom", + derivationPath = dpCustom, + ) + coEvery { multiNetworkStatusSupplier.invoke(any()) } returns flowOf(setOf(btcStatusCard, btcStatusCustom)) + + val btcCoinCard = buildCryptoCurrency(rawNetworkId = Blockchain.Bitcoin.id, derivationPath = dpCard) + coEvery { multiWalletCryptoCurrenciesSupplier.getSyncOrNull(any()) } returns setOf(btcCoinCard) + + coEvery { activateBitcoinPromocodeUseCase.invoke("bc1qcard", promoCode) } returns Either.Right("ok") + + val dispatcherProvider = testDispatcherProvider(testScheduler) + + DefaultPromoDeeplinkHandler( + scope = this, + queryParams = queryParams, + uiMessageSender = uiMessageSender, + multiNetworkStatusSupplier = multiNetworkStatusSupplier, + multiWalletCryptoCurrenciesSupplier = multiWalletCryptoCurrenciesSupplier, + activateBitcoinPromocodeUseCase = activateBitcoinPromocodeUseCase, + getSelectedWalletSyncUseCase = getSelectedWalletSyncUseCase, + analyticsEventsHandler = analyticsEventHandler, + dispatchers = dispatcherProvider, + ) + + advanceUntilIdle() + + val sent = messages.last { it is DialogMessage } as DialogMessage + Truth.assertThat(sent.title).isEqualTo(resourceReference(R.string.bitcoin_promo_activation_success_title)) + Truth.assertThat(sent.message).isEqualTo(resourceReference(R.string.bitcoin_promo_activation_success)) + + coVerify(exactly = 1) { activateBitcoinPromocodeUseCase.invoke("bc1qcard", promoCode) } + coVerify(exactly = 0) { activateBitcoinPromocodeUseCase.invoke("bc1qcustom", promoCode) } + + verify(exactly = 1) { analyticsEventHandler.send(PromoActivationAnalytics.PromoDeepLinkActivationStart) } + verify(exactly = 1) { + analyticsEventHandler.send( + PromoActivationAnalytics.PromoActivation(PromoCodeActivationResult.Activated), + ) + } + } + + @Test + fun `GIVEN two BTC statuses with different derivation AND no BTC currencies WHEN findBitcoinAddress THEN no bitcoin address dialog is shown`() = + runTest { + val promoCode = "PROMO123" + val queryParams = mapOf(DeeplinkConst.PROMO_CODE_KEY to promoCode) + val userWallet = mockUserWallet("ABCDEF") + every { getSelectedWalletSyncUseCase.invoke() } returns Either.Right(userWallet) + + val dpCard = Network.DerivationPath.Card("m/44'/0'/0'") + val dpCustom = Network.DerivationPath.Custom("m/84'/0'/0'") + + val btcStatusCard = buildNetworkStatus( + rawNetworkId = Blockchain.Bitcoin.id, + address = "bc1qcard", + derivationPath = dpCard, + ) + val btcStatusCustom = buildNetworkStatus( + rawNetworkId = Blockchain.Bitcoin.id, + address = "bc1qcustom", + derivationPath = dpCustom, + ) + coEvery { multiNetworkStatusSupplier.invoke(any()) } returns flowOf(setOf(btcStatusCard, btcStatusCustom)) + + coEvery { multiWalletCryptoCurrenciesSupplier.getSyncOrNull(any()) } returns emptySet() + + val dispatcherProvider = testDispatcherProvider(testScheduler) + + DefaultPromoDeeplinkHandler( + scope = this, + queryParams = queryParams, + uiMessageSender = uiMessageSender, + multiNetworkStatusSupplier = multiNetworkStatusSupplier, + multiWalletCryptoCurrenciesSupplier = multiWalletCryptoCurrenciesSupplier, + activateBitcoinPromocodeUseCase = activateBitcoinPromocodeUseCase, + getSelectedWalletSyncUseCase = getSelectedWalletSyncUseCase, + analyticsEventsHandler = analyticsEventHandler, + dispatchers = dispatcherProvider, + ) + + advanceUntilIdle() + + val sent = messages.last { it is DialogMessage } as DialogMessage + Truth.assertThat(sent.title).isEqualTo(resourceReference(R.string.bitcoin_promo_no_address_title)) + Truth.assertThat(sent.message).isEqualTo(resourceReference(R.string.bitcoin_promo_no_address)) + + coVerify(exactly = 0) { activateBitcoinPromocodeUseCase.invoke(any(), any()) } + + verify(exactly = 1) { analyticsEventHandler.send(PromoActivationAnalytics.PromoDeepLinkActivationStart) } + verify(exactly = 1) { + analyticsEventHandler.send( + PromoActivationAnalytics.PromoActivation(PromoCodeActivationResult.NoBitcoinAddress), + ) + } + } + + @Test + fun `GIVEN two BTC currencies first derivation mismatched AND one matching status WHEN findBitcoinAddress THEN no bitcoin address dialog is shown`() = + runTest { + val promoCode = "PROMO123" + val queryParams = mapOf(DeeplinkConst.PROMO_CODE_KEY to promoCode) + val userWallet = mockUserWallet("ABCDEF") + every { getSelectedWalletSyncUseCase.invoke() } returns Either.Right(userWallet) + + val dpCard = Network.DerivationPath.Card("m/44'/0'/0'") + val dpCustom = Network.DerivationPath.Custom("m/84'/0'/0'") + + val btcStatusCard = buildNetworkStatus( + rawNetworkId = Blockchain.Bitcoin.id, + address = "bc1qcard", + derivationPath = dpCard, + ) + coEvery { multiNetworkStatusSupplier.invoke(any()) } returns flowOf(setOf(btcStatusCard)) + + val btcCoinCustom = buildCryptoCurrency(rawNetworkId = Blockchain.Bitcoin.id, derivationPath = dpCustom) + val btcCoinCard = buildCryptoCurrency(rawNetworkId = Blockchain.Bitcoin.id, derivationPath = dpCard) + coEvery { multiWalletCryptoCurrenciesSupplier.getSyncOrNull(any()) } returns setOf( + btcCoinCustom, + btcCoinCard, + ) + + val dispatcherProvider = testDispatcherProvider(testScheduler) + + DefaultPromoDeeplinkHandler( + scope = this, + queryParams = queryParams, + uiMessageSender = uiMessageSender, + multiNetworkStatusSupplier = multiNetworkStatusSupplier, + multiWalletCryptoCurrenciesSupplier = multiWalletCryptoCurrenciesSupplier, + activateBitcoinPromocodeUseCase = activateBitcoinPromocodeUseCase, + getSelectedWalletSyncUseCase = getSelectedWalletSyncUseCase, + analyticsEventsHandler = analyticsEventHandler, + dispatchers = dispatcherProvider, + ) + + advanceUntilIdle() + + val sent = messages.last { it is DialogMessage } as DialogMessage + Truth.assertThat(sent.title).isEqualTo(resourceReference(R.string.bitcoin_promo_no_address_title)) + Truth.assertThat(sent.message).isEqualTo(resourceReference(R.string.bitcoin_promo_no_address)) + + coVerify(exactly = 0) { activateBitcoinPromocodeUseCase.invoke(any(), any()) } + + verify(exactly = 1) { analyticsEventHandler.send(PromoActivationAnalytics.PromoDeepLinkActivationStart) } + verify(exactly = 1) { + analyticsEventHandler.send( + PromoActivationAnalytics.PromoActivation(PromoCodeActivationResult.NoBitcoinAddress), + ) + } + } + + @Test + fun `GIVEN only BTC status with CUSTOM derivation AND only BTC currency with CARD derivation WHEN findBitcoinAddress THEN no bitcoin address dialog is shown`() = + runTest { + val promoCode = "PROMO123" + val queryParams = mapOf(DeeplinkConst.PROMO_CODE_KEY to promoCode) + val userWallet = mockUserWallet("ABCDEF") + every { getSelectedWalletSyncUseCase.invoke() } returns Either.Right(userWallet) + + val dpCard = Network.DerivationPath.Card("m/44'/0'/0'") + val dpCustom = Network.DerivationPath.Custom("m/84'/0'/0'") + + val btcStatusCustom = buildNetworkStatus( + rawNetworkId = Blockchain.Bitcoin.id, + address = "bc1qcustom", + derivationPath = dpCustom, + ) + coEvery { multiNetworkStatusSupplier.invoke(any()) } returns flowOf(setOf(btcStatusCustom)) + + val btcCoinCard = buildCryptoCurrency(rawNetworkId = Blockchain.Bitcoin.id, derivationPath = dpCard) + coEvery { multiWalletCryptoCurrenciesSupplier.getSyncOrNull(any()) } returns setOf(btcCoinCard) + + val dispatcherProvider = testDispatcherProvider(testScheduler) + + DefaultPromoDeeplinkHandler( + scope = this, + queryParams = queryParams, + uiMessageSender = uiMessageSender, + multiNetworkStatusSupplier = multiNetworkStatusSupplier, + multiWalletCryptoCurrenciesSupplier = multiWalletCryptoCurrenciesSupplier, + activateBitcoinPromocodeUseCase = activateBitcoinPromocodeUseCase, + getSelectedWalletSyncUseCase = getSelectedWalletSyncUseCase, + analyticsEventsHandler = analyticsEventHandler, + dispatchers = dispatcherProvider, + ) + + advanceUntilIdle() + + val sent = messages.last { it is DialogMessage } as DialogMessage + Truth.assertThat(sent.title).isEqualTo(resourceReference(R.string.bitcoin_promo_no_address_title)) + Truth.assertThat(sent.message).isEqualTo(resourceReference(R.string.bitcoin_promo_no_address)) + + coVerify(exactly = 0) { activateBitcoinPromocodeUseCase.invoke(any(), any()) } + + verify(exactly = 1) { analyticsEventHandler.send(PromoActivationAnalytics.PromoDeepLinkActivationStart) } + verify(exactly = 1) { + analyticsEventHandler.send( + PromoActivationAnalytics.PromoActivation(PromoCodeActivationResult.NoBitcoinAddress), + ) + } + } + + @Test + fun `GIVEN only BTC status with CARD derivation AND only BTC currency with CUSTOM derivation WHEN findBitcoinAddress THEN no bitcoin address dialog is shown`() = + runTest { + val promoCode = "PROMO123" + val queryParams = mapOf(DeeplinkConst.PROMO_CODE_KEY to promoCode) + val userWallet = mockUserWallet("ABCDEF") + every { getSelectedWalletSyncUseCase.invoke() } returns Either.Right(userWallet) + + val dpCard = Network.DerivationPath.Card("m/44'/0'/0'") + val dpCustom = Network.DerivationPath.Custom("m/84'/0'/0'") + + val btcStatusCard = buildNetworkStatus( + rawNetworkId = Blockchain.Bitcoin.id, + address = "bc1qcard", + derivationPath = dpCard, + ) + coEvery { multiNetworkStatusSupplier.invoke(any()) } returns flowOf(setOf(btcStatusCard)) + + val btcCoinCustom = buildCryptoCurrency(rawNetworkId = Blockchain.Bitcoin.id, derivationPath = dpCustom) + coEvery { multiWalletCryptoCurrenciesSupplier.getSyncOrNull(any()) } returns setOf(btcCoinCustom) + + val dispatcherProvider = testDispatcherProvider(testScheduler) + + DefaultPromoDeeplinkHandler( + scope = this, + queryParams = queryParams, + uiMessageSender = uiMessageSender, + multiNetworkStatusSupplier = multiNetworkStatusSupplier, + multiWalletCryptoCurrenciesSupplier = multiWalletCryptoCurrenciesSupplier, + activateBitcoinPromocodeUseCase = activateBitcoinPromocodeUseCase, + getSelectedWalletSyncUseCase = getSelectedWalletSyncUseCase, + analyticsEventsHandler = analyticsEventHandler, + dispatchers = dispatcherProvider, + ) + + advanceUntilIdle() + + val sent = messages.last { it is DialogMessage } as DialogMessage + Truth.assertThat(sent.title).isEqualTo(resourceReference(R.string.bitcoin_promo_no_address_title)) + Truth.assertThat(sent.message).isEqualTo(resourceReference(R.string.bitcoin_promo_no_address)) + + coVerify(exactly = 0) { activateBitcoinPromocodeUseCase.invoke(any(), any()) } + + verify(exactly = 1) { analyticsEventHandler.send(PromoActivationAnalytics.PromoDeepLinkActivationStart) } + verify(exactly = 1) { + analyticsEventHandler.send( + PromoActivationAnalytics.PromoActivation(PromoCodeActivationResult.NoBitcoinAddress), + ) + } + } + + private fun mockUserWallet(id: String): UserWallet { + val userWallet = mockk(relaxed = true) + every { userWallet.walletId } returns UserWalletId(id) + return userWallet + } + + private fun buildNetworkStatus( + rawNetworkId: String, + address: String, + derivationPath: Network.DerivationPath = Network.DerivationPath.None, + ): NetworkStatus { + val networkId = Network.ID(Network.RawID(rawNetworkId), derivationPath) + val network = Network( + id = networkId, + backendId = rawNetworkId, + name = rawNetworkId, + currencySymbol = rawNetworkId.take(3).uppercase(), + derivationPath = derivationPath, + isTestnet = false, + standardType = Network.StandardType.Unspecified("UNSPECIFIED"), + hasFiatFeeRate = false, + canHandleTokens = false, + transactionExtrasType = Network.TransactionExtrasType.NONE, + nameResolvingType = Network.NameResolvingType.NONE, + ) + + val value = NetworkStatus.Verified( + address = NetworkAddress.Single( + defaultAddress = NetworkAddress.Address( + value = address, + type = NetworkAddress.Address.Type.Primary, + ), + ), + amounts = emptyMap(), + pendingTransactions = emptyMap(), + source = StatusSource.ACTUAL, + ) + + return NetworkStatus(network = network, value = value) + } + + private fun buildUnreachableNetworkStatus(rawNetworkId: String): NetworkStatus { + val networkId = Network.ID(Network.RawID(rawNetworkId), Network.DerivationPath.None) + val network = Network( + id = networkId, + backendId = rawNetworkId, + name = rawNetworkId, + currencySymbol = rawNetworkId.take(3).uppercase(), + derivationPath = Network.DerivationPath.None, + isTestnet = false, + standardType = Network.StandardType.Unspecified("UNSPECIFIED"), + hasFiatFeeRate = false, + canHandleTokens = false, + transactionExtrasType = Network.TransactionExtrasType.NONE, + nameResolvingType = Network.NameResolvingType.NONE, + ) + + val value = NetworkStatus.Unreachable(address = null) + + return NetworkStatus(network = network, value = value) + } + + private fun buildCryptoCurrency( + rawNetworkId: String, + derivationPath: Network.DerivationPath = Network.DerivationPath.None, + ): CryptoCurrency.Coin { + val networkId = Network.ID(Network.RawID(rawNetworkId), derivationPath) + val network = Network( + id = networkId, + backendId = rawNetworkId, + name = rawNetworkId, + currencySymbol = rawNetworkId.take(3).uppercase(), + derivationPath = derivationPath, + isTestnet = false, + standardType = Network.StandardType.Unspecified("UNSPECIFIED"), + hasFiatFeeRate = false, + canHandleTokens = false, + transactionExtrasType = Network.TransactionExtrasType.NONE, + nameResolvingType = Network.NameResolvingType.NONE, + ) + + return CryptoCurrency.Coin( + id = CryptoCurrency.ID( + prefix = CryptoCurrency.ID.Prefix.COIN_PREFIX, + body = CryptoCurrency.ID.Body.NetworkId(rawNetworkId), + suffix = CryptoCurrency.ID.Suffix.RawID(rawNetworkId), + ), + network = network, + name = rawNetworkId, + symbol = rawNetworkId.take(3).uppercase(), + decimals = 8, + iconUrl = null, + isCustom = false, + ) + } + + private fun testDispatcherProvider(scheduler: TestCoroutineScheduler): CoroutineDispatcherProvider { + val dispatcher: CoroutineDispatcher = StandardTestDispatcher(scheduler) + return object : CoroutineDispatcherProvider { + override val main: CoroutineDispatcher = dispatcher + override val mainImmediate: CoroutineDispatcher = dispatcher + override val io: CoroutineDispatcher = dispatcher + override val default: CoroutineDispatcher = dispatcher + override val single: CoroutineDispatcher = dispatcher + } + } +} \ No newline at end of file diff --git a/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/connections/components/WcPairComponent.kt b/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/connections/components/WcPairComponent.kt index afa23ea711..80430447de 100644 --- a/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/connections/components/WcPairComponent.kt +++ b/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/connections/components/WcPairComponent.kt @@ -57,9 +57,9 @@ internal class WcPairComponent( private fun onChildBack() { when (val config = contentStack.value.active.configuration) { is WcAppInfoRoutes.AppInfo -> dismiss() - is Alert -> when (config.alertType) { - is Alert.Type.UnsupportedDApp, - is Alert.Type.UnsupportedNetwork, + is Alert -> when (config) { + is Alert.UnsupportedDApp, + is Alert.UnsupportedNetwork, -> dismiss() else -> model.stackNavigation.pop() } @@ -85,7 +85,7 @@ internal class WcPairComponent( ) is Alert -> AlertsComponentV2( appComponentContext = appComponentContext, - messageUM = createBottomSheetMessageUM(config.alertType), + messageUM = createBottomSheetMessageUM(config), ) is WcAppInfoRoutes.SelectNetworks -> WcSelectNetworksComponent( appComponentContext = appComponentContext, @@ -110,18 +110,18 @@ internal class WcPairComponent( } } - private fun createBottomSheetMessageUM(alertType: Alert.Type): MessageBottomSheetUMV2 { + private fun createBottomSheetMessageUM(alertType: Alert): MessageBottomSheetUMV2 { return when (alertType) { - is Alert.Type.Verified -> WcAlertsFactory.createVerifiedDomainAlert(alertType.appName) - is Alert.Type.UnknownDomain -> WcAlertsFactory.createUnknownDomainAlert(model::connectFromAlert) - is Alert.Type.InvalidDomain -> WcAlertsFactory.createInvalidDomainAlert(model::errorAlertOnDismiss) - is Alert.Type.UnsafeDomain -> WcAlertsFactory.createUnsafeDomainAlert(model::connectFromAlert) - is Alert.Type.UnsupportedDApp -> + is Alert.Verified -> WcAlertsFactory.createVerifiedDomainAlert(alertType.appName) + is Alert.UnknownDomain -> WcAlertsFactory.createUnknownDomainAlert(model::connectFromAlert) + is Alert.InvalidDomain -> WcAlertsFactory.createInvalidDomainAlert(model::errorAlertOnDismiss) + is Alert.UnsafeDomain -> WcAlertsFactory.createUnsafeDomainAlert(model::connectFromAlert) + is Alert.UnsupportedDApp -> WcAlertsFactory.createUnsupportedDomainAlert(alertType.appName, model::errorAlertOnDismiss) - is Alert.Type.UnsupportedNetwork -> + is Alert.UnsupportedNetwork -> WcAlertsFactory.createUnsupportedChainAlert(alertType.appName, model::errorAlertOnDismiss) - is Alert.Type.UriAlreadyUsed -> WcAlertsFactory.createUriAlreadyUsedAlert(model::errorAlertOnDismiss) - is Alert.Type.TimeoutException -> WcAlertsFactory.createTimeoutExceptionAlert(model::errorAlertOnDismiss) + is Alert.UriAlreadyUsed -> WcAlertsFactory.createUriAlreadyUsedAlert(model::errorAlertOnDismiss) + is Alert.TimeoutException -> WcAlertsFactory.createTimeoutExceptionAlert(model::errorAlertOnDismiss) } } diff --git a/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/connections/model/WcPairModel.kt b/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/connections/model/WcPairModel.kt index fe64931b39..7449212ae2 100644 --- a/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/connections/model/WcPairModel.kt +++ b/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/connections/model/WcPairModel.kt @@ -186,7 +186,7 @@ internal class WcPairModel @Inject constructor( source = WcAnalyticEvents.NoticeSecurityAlert.Source.Domain, ) analytics.send(event) - stackNavigation.pushNew(WcAppInfoRoutes.Alert(WcAppInfoRoutes.Alert.Type.UnknownDomain)) + stackNavigation.pushNew(WcAppInfoRoutes.Alert.UnknownDomain) } private fun showSecurityRiskAlert() { @@ -196,11 +196,11 @@ internal class WcPairModel @Inject constructor( source = WcAnalyticEvents.NoticeSecurityAlert.Source.Domain, ) analytics.send(event) - stackNavigation.pushNew(WcAppInfoRoutes.Alert(WcAppInfoRoutes.Alert.Type.UnsafeDomain)) + stackNavigation.pushNew(WcAppInfoRoutes.Alert.UnsafeDomain) } private fun showVerifiedAlert(appName: String) { - stackNavigation.pushNew(WcAppInfoRoutes.Alert(WcAppInfoRoutes.Alert.Type.Verified(appName))) + stackNavigation.pushNew(WcAppInfoRoutes.Alert.Verified(appName)) } private fun processSuccessfullyConnected(session: WcSession) { @@ -216,18 +216,18 @@ internal class WcPairModel @Inject constructor( private fun processError(error: WcPairError) { val alert = when (error) { - is WcPairError.InvalidDomainURL -> WcAppInfoRoutes.Alert.Type.InvalidDomain - is WcPairError.UnsupportedDApp -> WcAppInfoRoutes.Alert.Type.UnsupportedDApp(error.appName) - is WcPairError.UnsupportedBlockchains -> WcAppInfoRoutes.Alert.Type.UnsupportedNetwork(error.appName) - is WcPairError.UriAlreadyUsed -> WcAppInfoRoutes.Alert.Type.UriAlreadyUsed - is WcPairError.TimeoutException -> WcAppInfoRoutes.Alert.Type.TimeoutException + is WcPairError.InvalidDomainURL -> WcAppInfoRoutes.Alert.InvalidDomain + is WcPairError.UnsupportedDApp -> WcAppInfoRoutes.Alert.UnsupportedDApp(error.appName) + is WcPairError.UnsupportedBlockchains -> WcAppInfoRoutes.Alert.UnsupportedNetwork(error.appName) + is WcPairError.UriAlreadyUsed -> WcAppInfoRoutes.Alert.UriAlreadyUsed + is WcPairError.TimeoutException -> WcAppInfoRoutes.Alert.TimeoutException else -> { messageSender.send(ToastMessage(message = stringReference(error.message))) router.pop() null } } - alert?.let { stackNavigation.pushNew(WcAppInfoRoutes.Alert(it)) } + alert?.let { stackNavigation.pushNew(it) } } override fun onWalletSelected(userWalletId: UserWalletId) { diff --git a/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/connections/routes/WcAppInfoRoutes.kt b/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/connections/routes/WcAppInfoRoutes.kt index 784e0457ed..c83d3cd1a1 100644 --- a/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/connections/routes/WcAppInfoRoutes.kt +++ b/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/connections/routes/WcAppInfoRoutes.kt @@ -2,14 +2,13 @@ package com.tangem.features.walletconnect.connections.routes import androidx.compose.runtime.Immutable import com.tangem.core.decompose.navigation.Route -import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfigContent import com.tangem.domain.models.network.Network import com.tangem.domain.models.wallet.UserWalletId import kotlinx.serialization.Serializable @Serializable @Immutable -internal sealed class WcAppInfoRoutes : TangemBottomSheetConfigContent, Route { +internal sealed class WcAppInfoRoutes : Route { @Serializable data object AppInfo : WcAppInfoRoutes() @@ -26,17 +25,21 @@ internal sealed class WcAppInfoRoutes : TangemBottomSheetConfigContent, Route { ) : WcAppInfoRoutes() @Serializable - data class Alert(val alertType: Type) : WcAppInfoRoutes() { - @Serializable - sealed class Type { - data class Verified(val appName: String) : Type() - data object UnknownDomain : Type() - data object UnsafeDomain : Type() - data object InvalidDomain : Type() - data class UnsupportedDApp(val appName: String) : Type() - data class UnsupportedNetwork(val appName: String) : Type() - data object UriAlreadyUsed : Type() - data object TimeoutException : Type() - } + sealed class Alert : WcAppInfoRoutes() { + @Serializable data class Verified(val appName: String) : Alert() + + @Serializable data object UnknownDomain : Alert() + + @Serializable data object UnsafeDomain : Alert() + + @Serializable data object InvalidDomain : Alert() + + @Serializable data class UnsupportedDApp(val appName: String) : Alert() + + @Serializable data class UnsupportedNetwork(val appName: String) : Alert() + + @Serializable data object UriAlreadyUsed : Alert() + + @Serializable data object TimeoutException : Alert() } } \ No newline at end of file diff --git a/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/transaction/components/send/WcSendTransactionComponent.kt b/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/transaction/components/send/WcSendTransactionComponent.kt index a974a6874f..c5b943b9b4 100644 --- a/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/transaction/components/send/WcSendTransactionComponent.kt +++ b/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/transaction/components/send/WcSendTransactionComponent.kt @@ -20,28 +20,13 @@ internal class WcSendTransactionComponent( private val feeSelectorBlockComponentFactory: FeeSelectorBlockComponent.Factory, ) : AppComponentContext by appComponentContext, ComposableBottomSheetComponent { - private val feeSelectorBlockComponent by lazy { - val state = requireNotNull(model.uiState.value) { "in this step state should be not null" } - feeSelectorBlockComponentFactory.create( - context = appComponentContext, - params = FeeSelectorParams.FeeSelectorBlockParams( - state = state.feeSelectorUM, - onLoadFee = model::loadFee, - cryptoCurrencyStatus = model.cryptoCurrencyStatus, - feeCryptoCurrencyStatus = model.cryptoCurrencyStatus, - feeStateConfiguration = model.feeStateConfiguration, - feeDisplaySource = FeeSelectorParams.FeeDisplaySource.BottomSheet, - analyticsCategoryName = WcAnalyticEvents.WC_CATEGORY_NAME, - ), - onResult = model::updateFee, - ) - } + private var feeSelectorBlockComponent: FeeSelectorBlockComponent? = null init { lifecycle.doOnResume { val state = model.uiState.value if (state?.transaction?.feeState is WcTransactionFeeState.Success) { - feeSelectorBlockComponent.updateState(state.feeSelectorUM) + feeSelectorBlockComponent?.updateState(state.feeSelectorUM) } } } @@ -56,8 +41,12 @@ internal class WcSendTransactionComponent( val state = content?.transaction if (state != null) { - val feeSelectorBlock = - if (state.feeState !is WcTransactionFeeState.None) feeSelectorBlockComponent else null + val feeSelectorUM = content?.feeSelectorUM + val feeSelectorBlock = if (state.feeState !is WcTransactionFeeState.None && feeSelectorUM != null) { + getFeeSelectorBlockComponent(feeSelectorUM) + } else { + null + } WcSendTransactionModalBottomSheet( state = state, feeSelectorBlockComponent = feeSelectorBlock, @@ -69,4 +58,27 @@ internal class WcSendTransactionComponent( ) } } + + private fun getFeeSelectorBlockComponent(feeSelectorUM: FeeSelectorUM): FeeSelectorBlockComponent { + val local = feeSelectorBlockComponent + return if (local != null) { + local + } else { + val component = feeSelectorBlockComponentFactory.create( + context = appComponentContext, + params = FeeSelectorParams.FeeSelectorBlockParams( + state = feeSelectorUM, + onLoadFee = model::loadFee, + cryptoCurrencyStatus = model.cryptoCurrencyStatus, + feeCryptoCurrencyStatus = model.cryptoCurrencyStatus, + feeStateConfiguration = model.feeStateConfiguration, + feeDisplaySource = FeeSelectorParams.FeeDisplaySource.BottomSheet, + analyticsCategoryName = WcAnalyticEvents.WC_CATEGORY_NAME, + ), + onResult = model::updateFee, + ) + feeSelectorBlockComponent = component + component + } + } } \ No newline at end of file diff --git a/tangem-android-tools b/tangem-android-tools index 428b83bb37..794a8187e6 160000 --- a/tangem-android-tools +++ b/tangem-android-tools @@ -1 +1 @@ -Subproject commit 428b83bb378b615209e23afa05c88c454d06a9f1 +Subproject commit 794a8187e6d248ca3c21661df199a34ffeb0037a