Updated on 2026-08-14

This commit is contained in:
Tangem 2025-09-01 12:44:34 +03:00
commit a24fd1a391
33 changed files with 1588 additions and 115 deletions

View file

@ -51,18 +51,23 @@
property to make the "android:screenOrientation" work on API <37
https://developer.android.com/about/versions/16/behavior-changes-16
-->
<property android:name="android.window.PROPERTY_COMPAT_ALLOW_RESTRICTED_RESIZABILITY"
<property
android:name="android.window.PROPERTY_COMPAT_ALLOW_RESTRICTED_RESIZABILITY"
android:value="true" />
<meta-data
android:name="com.google.android.gms.wallet.api.enabled"
android:value="true" />
<!--
Use android:launchMode="singleInstance" to ensure a single MainActivity instance in its own task;
Prevents duplicate launches from deep links/external intents
-->
<activity
android:name="com.tangem.tap.MainActivity"
android:configChanges="uiMode"
android:exported="true"
android:launchMode="singleTop"
android:launchMode="singleInstance"
android:screenOrientation="portrait"
android:theme="@style/SplashTheme"
android:windowSoftInputMode="adjustResize">
@ -248,6 +253,17 @@
android:host="swap"
android:scheme="tangem" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data
android:host="promo"
android:scheme="tangem" />
</intent-filter>
</activity>
<!-- Disable android.startup completely. Used for Worker according doc -->

View file

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

View file

@ -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<PromoDeeplinkHandler.Factory>(relaxed = true) {
every { create(any(), any()) } returns mockk()
}
private val cardSdkProvider = mockk<CardSdkProvider>(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")))
}
}
}

View file

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

View file

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

View file

@ -141,6 +141,11 @@ interface TangemTechApi {
suspend fun getWallets(@Path("app_id") appId: String): ApiResponse<List<WalletResponse>>
// endregion
// promo
@POST("promo/v1/promo-codes/activate")
suspend fun activatePromoCode(@Body body: PromocodeActivationBody): ApiResponse<PromocodeActivationResponse>
// endregion
// region account
@GET("/v1/wallets/{walletId}/accounts")
suspend fun getWalletAccounts(@Path("walletId") walletId: String): ApiResponse<GetWalletAccountsResponse>

View file

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

View file

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

View file

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

View file

@ -147,6 +147,8 @@ data class DialogMessage(
}
}
data class GlobalLoadingMessage(val isShow: Boolean) : EventMessage
/**
* Shows a bottom sheet.
*

View file

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

View file

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

View file

@ -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<String>,
)

View file

@ -112,9 +112,9 @@ internal class WcSolanaNetwork(
WcSolanaMethodName.SignTransaction -> moshi.fromJson<WcSolanaSignTransactionRequest>(rawParams)
.getOrElse { return it.left() }
?.let { request -> WcSolanaMethod.SignTransaction(request.transaction, request.feePayer) }
WcSolanaMethodName.SendAllTransaction -> moshi.fromJson<List<String>>(rawParams)
WcSolanaMethodName.SendAllTransaction -> moshi.fromJson<WcSolanaSignAllTransactionRequest>(rawParams)
.getOrElse { return it.left() }
?.let { list -> WcSolanaMethod.SignAllTransaction(list) }
?.let { request -> WcSolanaMethod.SignAllTransaction(request.transactions) }
}.right()
}

View file

@ -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]+)"
}
}

View file

@ -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<UserWalletId, SeedPhraseNotificationsStatus>
@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<ActivatePromoCodeError, String> = 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()
},)
}
}

View file

@ -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<PromocodeActivationResponse>
// 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<PromocodeActivationResponse>
// WHEN
val result = repository.activatePromoCode(promoCode = "PROMO", bitcoinAddress = "addr")
// THEN
var error: ActivatePromoCodeError? = null
result.fold({ error = it }, { })
assertThat(error).isEqualTo(ActivatePromoCodeError.PromocodeAlreadyUsed)
}
}

View file

@ -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<String, List<Types.Type>>,
) {
@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<Type> = listOf(),
@Json(name = "Mail")
val mail: List<Type> = listOf(),
@Json(name = "Person")
val person: List<Type> = listOf(),
) {
@JsonClass(generateAdapter = true)
data class Type(
@Json(name = "name")
val name: String,
@Json(name = "type")
val type: String,
)
}
)
}

View file

@ -0,0 +1,9 @@
package com.tangem.domain.wallets
enum class PromoCodeActivationResult {
Failed,
InvalidPromoCode,
NoBitcoinAddress,
PromoCodeAlreadyUsed,
Activated,
}

View file

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

View file

@ -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<UserWallet>)
suspend fun activatePromoCode(promoCode: String, bitcoinAddress: String): Either<ActivatePromoCodeError, String>
}

View file

@ -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<ActivatePromoCodeError, String> =
walletsRepository.activatePromoCode(promoCode = promoCode, bitcoinAddress = address)
}

View file

@ -0,0 +1,10 @@
package com.tangem.features.wallet.deeplink
import kotlinx.coroutines.CoroutineScope
interface PromoDeeplinkHandler {
interface Factory {
fun create(coroutineScope: CoroutineScope, queryParams: Map<String, String>): PromoDeeplinkHandler
}
}

View file

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

View file

@ -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<String, String>,
@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<String, String>,
): DefaultPromoDeeplinkHandler
}
companion object {
private const val LOG_TAG = "PromoCodeActivation"
private const val DEFAULT_MESSAGE_SENDER_DELAY = 500L
private const val FETCH_TIMEOUT_SECONDS = 5
}
}

View file

@ -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<String, String> = 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"
},
),
)
}

View file

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

View file

@ -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<UiMessage>
@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<String, String>()
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<UserWallet>(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
}
}
}

View file

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

View file

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

View file

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

View file

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

@ -1 +1 @@
Subproject commit 428b83bb378b615209e23afa05c88c454d06a9f1
Subproject commit 794a8187e6d248ca3c21661df199a34ffeb0037a