Updated on 2026-08-14

This commit is contained in:
Tangem 2025-08-27 16:38:31 +03:00
commit de9478cc0b
35 changed files with 1082 additions and 128 deletions

View file

@ -248,6 +248,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)
@ -131,6 +133,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

@ -202,7 +202,7 @@
<string name="common_nft">NFT</string>
<string name="common_no">No</string>
<string name="common_no_address">Ninguna dirección</string>
<string name="common_not_added">No aregada</string>
<string name="common_not_added">No agregada</string>
<string name="common_not_now">Ahora no</string>
<string name="common_now">Ahora</string>
<string name="common_ok">OK</string>
@ -250,8 +250,8 @@
<string name="common_terms_of_use">Condiciones de uso</string>
<string name="common_today">Hoy</string>
<plurals name="common_tokens_count">
<item quantity="one">%d ficha</item>
<item quantity="other">%d fichas</item>
<item quantity="one">%d token</item>
<item quantity="other">%d tokens</item>
</plurals>
<string name="common_transaction_failed">Transacción fallida</string>
<string name="common_transaction_status">Estado de la transacción</string>
@ -1348,7 +1348,7 @@
<string name="wc_alert_network_not_connected_description">Para continuar, vuelva a conectar su sesión de dApp con la red requerida %s.</string>
<string name="wc_alert_network_not_connected_title">Red no conectada</string>
<string name="wc_alert_request_timeout_description">Verifique su conexión de red</string>
<string name="wc_alert_request_timeout_title">Tiempo de espera de la solicitud agotado</string>
<string name="wc_alert_request_timeout_title">Tiempo de espera agotado</string>
<string name="wc_alert_session_disconnected_description">Vuelva a su navegador y vuelva a conectarse a través de WalletConnect.</string>
<string name="wc_alert_session_disconnected_title">La sesión de Wallet Connect se desconectó</string>
<string name="wc_alert_sign_anyway">Firmar de todos modos</string>

View file

@ -60,6 +60,16 @@
<string name="biometric_lockout_warning_description">Veuillez réessayer dans 30 secondes ou scannez la carte/bague</string>
<string name="biometric_lockout_warning_title">Trop de tentatives</string>
<string name="biometric_unavailable_warning">Vous avez désactivé l\'authentification biométrique sur votre téléphone et ne pourrez pas enregistrer de portefeuilles dans l\'application. Pour enregistrer des portefeuilles, veuillez activer la fonction d\'authentification biométrique dans les paramètres de votre téléphone.</string>
<string name="bitcoin_promo_activation_error">Une erreur s\'est produite lors du traitement de votre code promo. Veuillez réessayer plus tard.</string>
<string name="bitcoin_promo_activation_error_title">Erreur d\'activation</string>
<string name="bitcoin_promo_activation_success">Votre code promo a été activé avec succès. Un bonus de 10 USDT en Bitcoin sera crédité dans 14 jours.</string>
<string name="bitcoin_promo_activation_success_title">Code promo activé</string>
<string name="bitcoin_promo_already_activated">Ce code promo a déjà été utilisé et ne peut pas être activé à nouveau.</string>
<string name="bitcoin_promo_already_activated_title">Code indisponible</string>
<string name="bitcoin_promo_invalid_code">Ce code promo n\'est pas valide et ne peut pas être activé.</string>
<string name="bitcoin_promo_invalid_code_title">Code invalide</string>
<string name="bitcoin_promo_no_address">Une adresse Bitcoin est nécessaire pour recevoir le bonus. Veuillez en ajouter une à votre portefeuille et réessayer l\'activation.</string>
<string name="bitcoin_promo_no_address_title">Adresse Bitcoin requise</string>
<string name="button_start_backup_process">Démarrer le processus de sauvegarde</string>
<string name="buy_token_description">Utilisez une carte bancaire ou d\'autres moyens de paiement</string>
<plurals name="card_label_card_count">

View file

@ -12,8 +12,12 @@
<string name="access_code_create_description">ウォレットのロックを解除するには、 %s桁のアクセスコードを設定します。</string>
<string name="access_code_create_title">アクセスコードの作成</string>
<string name="access_code_navtitle">アクセスコード</string>
<string name="account_add_limit_dialog_description">%1$s個を超えるアカウントは作成できません。新しいアカウントを追加するには、1つをアーカイブしてください。</string>
<string name="account_add_limit_dialog_title">新しいアカウントを追加できません</string>
<string name="account_archived_accounts">アーカイブされたアカウント</string>
<string name="account_archived_recover">回復する</string>
<string name="account_archived_recover_dialog_description">「 %1$s 」を回復しようとしています。</string>
<string name="account_archived_recover_dialog_title">アカウントを回復する</string>
<string name="account_archived_title">アーカイブ済み</string>
<string name="account_details_archive">アカウントをアーカイブする</string>
<string name="account_details_archive_action">アーカイブ</string>
@ -27,6 +31,7 @@
<string name="account_form_placeholder_new_account">新しいアカウント</string>
<string name="account_form_title_create">アカウントを追加</string>
<string name="account_form_title_edit">アカウントを編集</string>
<string name="account_label_tokens_info">%1$s ( %2$s内)</string>
<string name="account_reorder_description">アカウントを長押しして並べ替える</string>
<string name="account_unsaved_dialog_action_first">編集を続ける</string>
<string name="account_unsaved_dialog_action_second">破棄</string>
@ -119,11 +124,15 @@
<string name="biometric_lockout_warning_title">試行回数が多すぎます</string>
<string name="biometric_unavailable_warning">お使いの携帯電話で生体認証が無効になっているため、アプリにウォレットを保存できません。ウォレットを保存するには、携帯電話の設定で生体認証機能を有効にしてください。</string>
<string name="bitcoin_promo_activation_error">プロモーションコードの処理中にエラーが発生しました。しばらくしてからもう一度お試しください。</string>
<string name="bitcoin_promo_activation_error_title">アクティベーションエラー</string>
<string name="bitcoin_promo_activation_success">プロモーションコードが正常に有効化されました。14日以内に10 USDT相当のビットコインボーナスが付与されます。</string>
<string name="bitcoin_promo_activation_success_title">プロモーションコードが有効になりました</string>
<string name="bitcoin_promo_already_activated">このプロモーションコードは既に使用されているため、再度使うことはできません。</string>
<string name="bitcoin_promo_already_activated_title">コードが利用できません</string>
<string name="bitcoin_promo_invalid_code">このプロモーションコードは無効であり、有効化できません。</string>
<string name="bitcoin_promo_invalid_code_title">無効なコード</string>
<string name="bitcoin_promo_no_address">ボーナスを受け取るにはビットコインアドレスが必要です。ウォレットにビットコインアドレスを追加し、再度アクティベーションをお試しください。</string>
<string name="bitcoin_promo_no_address_title">ビットコインアドレスが必要です</string>
<string name="button_start_backup_process">バックアップ処理を開始する</string>
<string name="buy_token_description">銀行カードまたはその他の支払い方法を使用する</string>
<plurals name="card_label_card_count">
@ -243,6 +252,9 @@
<string name="common_month"></string>
<string name="common_network_fee_title">ネットワーク手数料</string>
<string name="common_network_fee_warning_content">送金額は、選択された手数料レベルをカバーするため、%1$s (%2$s) 減額されます。</string>
<plurals name="common_networks_count">
<item quantity="other">%dネットワーク</item>
</plurals>
<string name="common_next"></string>
<string name="common_nft">NFT</string>
<string name="common_no">いいえ</string>
@ -369,8 +381,8 @@
<string name="domain_receive_assets_onboarding_description">他のネットワークで資産を送金すると、永久に失われます。</string>
<string name="domain_receive_assets_onboarding_network_name">%sネットワーク</string>
<string name="domain_receive_assets_onboarding_title">下記のみを使用して資金を送金する</string>
<string name="email_preface_wc_error">サポートチームの皆様、コード %s のエラーが発生しました。</string>
<string name="email_subject_wc_error">WalletConnect エラー</string>
<string name="email_preface_wc_error">こんにちは、サポートチームの皆さん、コード %s のエラーが発生しました。</string>
<string name="email_subject_wc_error">WalletConnectエラー</string>
<string name="error_wrong_wallet_tapped">別のウォレットのカードまたはリングを使用しました。このウォレットにリンクしているカードまたはリングをタップしてください。</string>
<string name="eth_gas_required_exceeds_allowance">取引に必要な資金が不足しています。アカウントに入金してください。</string>
<string name="exchange_tokens_available_tokens_header">マイトークン</string>
@ -714,6 +726,9 @@
<string name="notification_referral_promo_button">今すぐ参加</string>
<string name="notification_referral_promo_text">コードを共有すると、販売ごとに5 USDTを獲得できます。お友達は10%割引になります。</string>
<string name="notification_referral_promo_title">友達への紹介で報酬を獲得しよう!</string>
<string name="notification_sepa_button">暗号資産を買い付ける</string>
<string name="notification_sepa_text">SEPA送金で暗号資産を買い付けると、手数料はかかりません。</string>
<string name="notification_sepa_title">SEPAで暗号資産を買い付ける</string>
<string name="onboarding_access_code_feature_1_description">すべてのデバイスを保護するには、単一のアクセスコードを設定してください。</string>
<string name="onboarding_access_code_feature_1_title">保護する</string>
<string name="onboarding_access_code_feature_2_description">後で各カードおよびリングに個別のアクセスコードを設定できます。</string>
@ -1305,10 +1320,10 @@
<string name="wallet_connect_bnb_sign_message">Dapp%1$s 、BNB取引の署名を要求しています\n\n%2$s</string>
<string name="wallet_connect_bnb_trade_order_message">%1$sの取引注文\n価格: %2$s\n受取金額: %3$s\n支払金額: %4$s</string>
<string name="wallet_connect_bnb_transaction_message">取引の詳細:\n送信元: %1$s\n受取先: %2$s\n量: %3$s</string>
<string name="wallet_connect_clipboard_alert">クリップボードには WalletConnect コードが含まれています。コピーした値を使用するか、QRコードをスキャンしてください。</string>
<string name="wallet_connect_clipboard_alert">クリップボードには WalletConnectコードが含まれています。コピーした値を使用するか、QRコードをスキャンしてください。</string>
<string name="wallet_connect_create_tx_message">%1$sの取引を作成するリクエスト\n%2$s\n\n金額: %3$s\n手数料: %4$s\n合計: %5$s\n残高: %6$s</string>
<string name="wallet_connect_create_tx_not_enough_funds">取引を送信できません。資金が足りません。</string>
<string name="wallet_connect_error_failed_to_connect">WalletConnect セッションを確立できませんでした。しばらくしてからもう一度お試しください。</string>
<string name="wallet_connect_error_failed_to_connect">WalletConnectセッションを確立できませんでした。しばらくしてからもう一度お試しください。</string>
<string name="wallet_connect_error_missing_blockchains">すべてのトークンがリストには追加されませんでした。まず追加してから、もう一度お試しください。不足しているトークン: \n</string>
<string name="wallet_connect_error_sing_failed">メッセージの署名に失敗しました。\nもう一度お試しください。</string>
<string name="wallet_connect_error_timeout">WalletConnectセッションの確立に失敗しましたタイムアウトエラー。しばらくしてもう一度お試しください。</string>
@ -1487,7 +1502,7 @@
<string name="wc_alert_unsupported_dapps_title">サポートされていないdApp</string>
<string name="wc_alert_unsupported_method_description">エラーコード: 8 005。問題が解決しない場合は、お気軽にサポートまでお問い合わせください。</string>
<string name="wc_alert_unsupported_method_title">不明なエラーが発生しました</string>
<string name="wc_alert_unsupported_network_description">このネットワーク %s はTangem Walletでサポートされておらず、接続できません。</string>
<string name="wc_alert_unsupported_network_description">このネットワーク%sはTangemウォレットでサポートされておらず、接続できません。</string>
<string name="wc_alert_unsupported_network_title">サポートされていないネットワーク</string>
<string name="wc_alert_unsupported_networks_description">Tangemは現在%sで必要なネットワークをサポートしていません。</string>
<string name="wc_alert_unsupported_networks_title">未対応のネットワーク</string>

View file

@ -125,11 +125,15 @@
<string name="biometric_lockout_warning_title">Too many attempts</string>
<string name="biometric_unavailable_warning">You have disabled biometric authentication on your phone and will not be able to save wallets in the app. To save wallets, please enable the biometric authentication function in your phone settings.</string>
<string name="bitcoin_promo_activation_error">An error occurred while processing your promo code. Please try again later.</string>
<string name="bitcoin_promo_activation_success">Your promo code has been successfully activated. A bonus of 10 USDT in Bitcoin will be credited in 14 days.</string>
<string name="bitcoin_promo_activation_success_title">Promo code activated</string>
<string name="bitcoin_promo_activation_error_title">Activation error</string>
<string name="bitcoin_promo_activation_success">Your promo code was successfully activated. A bonus of 10 USDT in Bitcoin will be credited to your account within 14 days.</string>
<string name="bitcoin_promo_activation_success_title">Promo Code Activated</string>
<string name="bitcoin_promo_already_activated">This promo code has already been used and cannot be activated again.</string>
<string name="bitcoin_promo_already_activated_title">Code unavailable</string>
<string name="bitcoin_promo_invalid_code">This promo code is not valid and cannot be activated.</string>
<string name="bitcoin_promo_invalid_code_title">Invalid code</string>
<string name="bitcoin_promo_no_address">A Bitcoin address is required to receive the bonus. Please add one to your wallet and retry the activation.</string>
<string name="bitcoin_promo_no_address_title">Bitcoin address required</string>
<string name="button_start_backup_process">Start backup process</string>
<string name="buy_token_description">Use a bank card or other payment methods</string>
<plurals name="card_label_card_count">
@ -735,6 +739,9 @@
<string name="notification_referral_promo_button">Join Now</string>
<string name="notification_referral_promo_text">Share your code - earn 5 USDT per sale. Your friend gets 10% OFF.</string>
<string name="notification_referral_promo_title">Get REWARDS for every friend!</string>
<string name="notification_sepa_button">Buy crypto</string>
<string name="notification_sepa_text">Enjoy zero fees when purchasing crypto via SEPA transfer.</string>
<string name="notification_sepa_title">Buy Crypto with SEPA</string>
<string name="onboarding_access_code_feature_1_description">Set up a single access code to protect all your devices.</string>
<string name="onboarding_access_code_feature_1_title">Protect</string>
<string name="onboarding_access_code_feature_2_description">Set an individual access code for each card or ring later.</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

@ -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
@ -35,7 +41,7 @@ import kotlin.collections.mutableSetOf
typealias SeedPhraseNotificationsStatuses = Map<UserWalletId, SeedPhraseNotificationsStatus>
@Suppress("TooManyFunctions")
@Suppress("TooManyFunctions", "LargeClass")
internal class DefaultWalletsRepository(
private val appPreferencesStore: AppPreferencesStore,
private val tangemTechApi: TangemTechApi,
@ -387,4 +393,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

@ -13,7 +13,7 @@ sealed interface WcEthMethod : WcMethod {
val account: String,
val dataForSign: String,
) : WcEthMethod {
val humanMsg: String = params.message?.contents.orEmpty()
val humanMsg: String = params.message.orEmpty()
}
data class SendTransaction(

View file

@ -5,60 +5,6 @@ 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,
)
}
}
val message: 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")
@ -72,4 +74,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,148 @@
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.wallet.UserWallet
import com.tangem.domain.tokens.GetMultiCryptoCurrencyStatusUseCase
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 dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import timber.log.Timber
@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 getMultiCryptoCurrencyStatusUseCase: GetMultiCryptoCurrencyStatusUseCase,
private val activateBitcoinPromocodeUseCase: ActivateBitcoinPromocodeUseCase,
private val getSelectedWalletSyncUseCase: GetSelectedWalletSyncUseCase,
private val analyticsEventsHandler: AnalyticsEventHandler,
) : 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 {
getMultiCryptoCurrencyStatusUseCase.invokeMultiWalletSync(userWallet.walletId).onRight { currencies ->
val bitcoinAddress = currencies.find { it.currency.id.rawNetworkId == Blockchain.Bitcoin.id }
?.value
?.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 {
Timber.tag(LOG_TAG).d("no Bitcoin address")
showAlert(NoBitcoinAddress)
}
}.onLeft {
Timber.tag(LOG_TAG).e("Error on getting userWallet currencies: $it")
showAlert(Failed)
}
}
}
private suspend fun activatePromoCode(bitcoinAddress: String, promoCode: String) {
uiMessageSender.send(GlobalLoadingMessage(true))
activateBitcoinPromocodeUseCase(bitcoinAddress, promoCode).onRight {
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 ->
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
}
}

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 Use"
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,395 @@
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.currency.CryptoCurrency
import com.tangem.domain.models.currency.CryptoCurrencyStatus
import com.tangem.domain.models.network.Network
import com.tangem.domain.models.network.NetworkAddress
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.tokens.GetMultiCryptoCurrencyStatusUseCase
import com.tangem.domain.tokens.error.TokenListError
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 io.mockk.*
import io.mockk.impl.annotations.MockK
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.cancel
import kotlinx.coroutines.test.*
import org.junit.After
import org.junit.Before
import org.junit.Test
import timber.log.Timber
import java.math.BigDecimal
class DefaultPromoDeeplinkHandlerTest {
@MockK(relaxed = true)
private lateinit var uiMessageSender: UiMessageSender
@MockK
private lateinit var getMultiCryptoCurrencyStatusUseCase: GetMultiCryptoCurrencyStatusUseCase
@MockK
private lateinit var activateBitcoinPromocodeUseCase: ActivateBitcoinPromocodeUseCase
@MockK
private lateinit var getSelectedWalletSyncUseCase: GetSelectedWalletSyncUseCase
@MockK
private lateinit var analyticsEventHandler: AnalyticsEventHandler
private lateinit var messageSlot: CapturingSlot<UiMessage>
private lateinit var testDispatcher: TestDispatcher
private lateinit var testScope: TestScope
@OptIn(ExperimentalCoroutinesApi::class)
@Before
fun setUp() {
testDispatcher = StandardTestDispatcher()
testScope = TestScope(testDispatcher)
Dispatchers.setMain(testDispatcher)
MockKAnnotations.init(this)
every { analyticsEventHandler.send(any()) } returns Unit
messageSlot = slot()
every { uiMessageSender.send(capture(messageSlot)) } just runs
Timber.uprootAll() // Disable Timber logging for tests
}
@OptIn(ExperimentalCoroutinesApi::class)
@After
fun tearDown() {
Dispatchers.resetMain()
testScope.cancel()
}
@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,
getMultiCryptoCurrencyStatusUseCase = getMultiCryptoCurrencyStatusUseCase,
activateBitcoinPromocodeUseCase = activateBitcoinPromocodeUseCase,
getSelectedWalletSyncUseCase = getSelectedWalletSyncUseCase,
analyticsEventsHandler = analyticsEventHandler,
)
val sent = messageSlot.captured 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,
getMultiCryptoCurrencyStatusUseCase = getMultiCryptoCurrencyStatusUseCase,
activateBitcoinPromocodeUseCase = activateBitcoinPromocodeUseCase,
getSelectedWalletSyncUseCase = getSelectedWalletSyncUseCase,
analyticsEventsHandler = analyticsEventHandler,
)
val sent = messageSlot.captured 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 token status error WHEN getWalletCurrenciesScreen THEN failed dialog is shown`() = runTest {
val queryParams = mapOf(DeeplinkConst.PROMO_CODE_KEY to "PROMO123")
val userWallet = mockUserWallet("ABCDEF")
every { getSelectedWalletSyncUseCase.invoke() } returns Either.Right(userWallet)
coEvery { getMultiCryptoCurrencyStatusUseCase.invokeMultiWalletSync(userWallet.walletId) } returns Either.Left(
TokenListError.DataError(Exception("boom")),
)
DefaultPromoDeeplinkHandler(
scope = this,
queryParams = queryParams,
uiMessageSender = uiMessageSender,
getMultiCryptoCurrencyStatusUseCase = getMultiCryptoCurrencyStatusUseCase,
activateBitcoinPromocodeUseCase = activateBitcoinPromocodeUseCase,
getSelectedWalletSyncUseCase = getSelectedWalletSyncUseCase,
analyticsEventsHandler = analyticsEventHandler,
)
advanceUntilIdle()
val sent = messageSlot.captured 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 getWalletCurrenciesScreen 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)
// Return only ETH status so BTC is not found
val ethStatus = buildStatusForNetwork(rawNetworkId = "ethereum", address = "0x123")
coEvery { getMultiCryptoCurrencyStatusUseCase.invokeMultiWalletSync(userWallet.walletId) } returns Either.Right(
listOf(ethStatus),
)
DefaultPromoDeeplinkHandler(
scope = this,
queryParams = queryParams,
uiMessageSender = uiMessageSender,
getMultiCryptoCurrencyStatusUseCase = getMultiCryptoCurrencyStatusUseCase,
activateBitcoinPromocodeUseCase = activateBitcoinPromocodeUseCase,
getSelectedWalletSyncUseCase = getSelectedWalletSyncUseCase,
analyticsEventsHandler = analyticsEventHandler,
)
advanceUntilIdle()
val sent = messageSlot.captured 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 = buildStatusForNetwork(rawNetworkId = Blockchain.Bitcoin.id, address = "bc1qxyz")
coEvery { getMultiCryptoCurrencyStatusUseCase.invokeMultiWalletSync(userWallet.walletId) } returns Either.Right(
listOf(btcStatus),
)
coEvery { activateBitcoinPromocodeUseCase.invoke("bc1qxyz", promoCode) } returns Either.Right("ok")
DefaultPromoDeeplinkHandler(
scope = this,
queryParams = queryParams,
uiMessageSender = uiMessageSender,
getMultiCryptoCurrencyStatusUseCase = getMultiCryptoCurrencyStatusUseCase,
activateBitcoinPromocodeUseCase = activateBitcoinPromocodeUseCase,
getSelectedWalletSyncUseCase = getSelectedWalletSyncUseCase,
analyticsEventsHandler = analyticsEventHandler,
)
advanceUntilIdle()
val sent = messageSlot.captured 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 = buildStatusForNetwork(rawNetworkId = Blockchain.Bitcoin.id, address = "bc1qxyz")
coEvery { getMultiCryptoCurrencyStatusUseCase.invokeMultiWalletSync(userWallet.walletId) } returns Either.Right(
listOf(btcStatus),
)
coEvery { activateBitcoinPromocodeUseCase.invoke("bc1qxyz", promoCode) } returns Either.Left(error)
DefaultPromoDeeplinkHandler(
scope = this,
queryParams = queryParams,
uiMessageSender = uiMessageSender,
getMultiCryptoCurrencyStatusUseCase = getMultiCryptoCurrencyStatusUseCase,
activateBitcoinPromocodeUseCase = activateBitcoinPromocodeUseCase,
getSelectedWalletSyncUseCase = getSelectedWalletSyncUseCase,
analyticsEventsHandler = analyticsEventHandler,
)
advanceUntilIdle()
val sent = messageSlot.captured as DialogMessage
Truth.assertThat(sent.title).isEqualTo(expectedTitle)
Truth.assertThat(sent.message).isEqualTo(expectedMessage)
}
private fun mockUserWallet(id: String): UserWallet {
val userWallet = mockk<UserWallet>(relaxed = true)
every { userWallet.walletId } returns UserWalletId(id)
return userWallet
}
private fun buildStatusForNetwork(rawNetworkId: String, address: String): CryptoCurrencyStatus {
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 currencyId = CryptoCurrency.ID(
prefix = CryptoCurrency.ID.Prefix.TOKEN_PREFIX,
body = CryptoCurrency.ID.Body.NetworkId(rawNetworkId),
suffix = CryptoCurrency.ID.Suffix.RawID(rawNetworkId),
)
val coin = CryptoCurrency.Coin(
id = currencyId,
network = network,
name = rawNetworkId,
symbol = network.currencySymbol,
decimals = 8,
iconUrl = null,
isCustom = false,
)
val value = CryptoCurrencyStatus.NoQuote(
amount = BigDecimal.ZERO,
yieldBalance = null,
hasCurrentNetworkTransactions = false,
pendingTransactions = emptySet(),
networkAddress = NetworkAddress.Single(
defaultAddress = NetworkAddress.Address(
value = address,
type = NetworkAddress.Address.Type.Primary,
),
),
sources = CryptoCurrencyStatus.Sources(),
)
return CryptoCurrencyStatus(
currency = coin,
value = value,
)
}
}

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