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