Updated on 2026-08-14
This commit is contained in:
commit
a24fd1a391
33 changed files with 1588 additions and 115 deletions
|
|
@ -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 -->
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
"""
|
||||
|
|
|
|||
|
|
@ -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")))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
|
@ -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,
|
||||
)
|
||||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -147,6 +147,8 @@ data class DialogMessage(
|
|||
}
|
||||
}
|
||||
|
||||
data class GlobalLoadingMessage(val isShow: Boolean) : EventMessage
|
||||
|
||||
/**
|
||||
* Shows a bottom sheet.
|
||||
*
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
|
|
@ -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>,
|
||||
)
|
||||
|
|
@ -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()
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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]+)"
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
},)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
package com.tangem.domain.wallets
|
||||
|
||||
enum class PromoCodeActivationResult {
|
||||
Failed,
|
||||
InvalidPromoCode,
|
||||
NoBitcoinAddress,
|
||||
PromoCodeAlreadyUsed,
|
||||
Activated,
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
|
|
@ -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>
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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"
|
||||
},
|
||||
),
|
||||
)
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
Loading…
Add table
Add a link
Reference in a new issue