diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index a2c1f4e5fe..9ba22faa95 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -248,6 +248,17 @@
android:host="swap"
android:scheme="tangem" />
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/java/com/tangem/tap/routing/utils/DeepLinkFactory.kt b/app/src/main/java/com/tangem/tap/routing/utils/DeepLinkFactory.kt
index 436d1a2ef3..e5c704d74a 100644
--- a/app/src/main/java/com/tangem/tap/routing/utils/DeepLinkFactory.kt
+++ b/app/src/main/java/com/tangem/tap/routing/utils/DeepLinkFactory.kt
@@ -15,6 +15,7 @@ import com.tangem.features.onramp.deeplink.SwapDeepLinkHandler
import com.tangem.features.send.v2.api.deeplink.SellRedirectDeepLinkHandler
import com.tangem.features.staking.api.deeplink.StakingDeepLinkHandler
import com.tangem.features.tokendetails.deeplink.TokenDetailsDeepLinkHandler
+import com.tangem.features.wallet.deeplink.PromoDeeplinkHandler
import com.tangem.features.wallet.deeplink.WalletDeepLinkHandler
import com.tangem.features.walletconnect.components.deeplink.WalletConnectDeepLinkHandler
import com.tangem.utils.coroutines.JobHolder
@@ -46,6 +47,7 @@ internal class DeepLinkFactory @Inject constructor(
private val buyDeepLink: BuyDeepLinkHandler.Factory,
private val sellDeepLink: SellDeepLinkHandler.Factory,
private val swapDeepLink: SwapDeepLinkHandler.Factory,
+ private val promoDeepLink: PromoDeeplinkHandler.Factory,
) {
private val permittedAppRoute = MutableStateFlow(false)
@@ -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(
"""
diff --git a/app/src/test/kotlin/com/tangem/tap/routing/utils/DeepLinkFactoryTest.kt b/app/src/test/kotlin/com/tangem/tap/routing/utils/DeepLinkFactoryTest.kt
index 34c9ae969c..dde88e9295 100644
--- a/app/src/test/kotlin/com/tangem/tap/routing/utils/DeepLinkFactoryTest.kt
+++ b/app/src/test/kotlin/com/tangem/tap/routing/utils/DeepLinkFactoryTest.kt
@@ -13,6 +13,7 @@ import com.tangem.features.onramp.deeplink.SwapDeepLinkHandler
import com.tangem.features.send.v2.api.deeplink.SellRedirectDeepLinkHandler
import com.tangem.features.staking.api.deeplink.StakingDeepLinkHandler
import com.tangem.features.tokendetails.deeplink.TokenDetailsDeepLinkHandler
+import com.tangem.features.wallet.deeplink.PromoDeeplinkHandler
import com.tangem.features.wallet.deeplink.WalletDeepLinkHandler
import com.tangem.features.walletconnect.components.deeplink.WalletConnectDeepLinkHandler
import io.mockk.every
@@ -68,6 +69,10 @@ class DeepLinkFactoryTest {
every { create() } returns mockk()
}
+ private val promoDeepLinkFactory = mockk(relaxed = true) {
+ every { create(any(), any()) } returns mockk()
+ }
+
private val cardSdkProvider = mockk(relaxed = true) {
every { sdk.uiVisibility() } returns MutableStateFlow(false)
}
@@ -91,6 +96,7 @@ class DeepLinkFactoryTest {
buyDeepLink = buyDeepLinkFactory,
sellDeepLink = sellDeepLinkFactory,
swapDeepLink = swapDeepLinkFactory,
+ promoDeepLink = promoDeepLinkFactory,
)
@OptIn(ExperimentalCoroutinesApi::class)
@@ -303,13 +309,19 @@ class DeepLinkFactoryTest {
every { mockedUri.host } returns "swap"
deepLinkFactory.handleDeeplink(mockedUri, testScope, isFromOnNewIntent)
advanceUntilIdle()
- verify { sellDeepLinkFactory.create() }
+ verify { swapDeepLinkFactory.create() }
// Test Buy
every { mockedUri.host } returns "buy"
deepLinkFactory.handleDeeplink(mockedUri, testScope, isFromOnNewIntent)
advanceUntilIdle()
verify { buyDeepLinkFactory.create() }
+
+ // Test Promo
+ every { mockedUri.host } returns "promo"
+ deepLinkFactory.handleDeeplink(mockedUri, testScope, isFromOnNewIntent)
+ advanceUntilIdle()
+ verify { promoDeepLinkFactory.create(eq(testScope), eq(emptyMap())) }
}
@Test
@@ -332,6 +344,7 @@ class DeepLinkFactoryTest {
buyDeepLinkFactory.create()
sellDeepLinkFactory.create()
swapDeepLinkFactory.create()
+ promoDeepLinkFactory.create(any(), any())
}
}
@@ -400,4 +413,22 @@ class DeepLinkFactoryTest {
advanceUntilIdle()
verify { onrampDeepLinkFactory.create(eq(testScope), eq(emptyMap())) }
}
+
+ @Test
+ fun `handleTangemDeepLinks routes to promo handler`() = runTest {
+ every { mockedUri.scheme } returns "tangem"
+ every { mockedUri.host } returns "promo"
+ every { mockedUri.query } returns "promo_code=PROMO123"
+ every { mockedUri.queryParameterNames } returns setOf("promo_code")
+ every { mockedUri.getQueryParameter("promo_code") } returns "PROMO123"
+
+ deepLinkFactory.checkRoutingReadiness(AppRoute.Wallet)
+ deepLinkFactory.handleDeeplink(mockedUri, testScope, isFromOnNewIntent)
+
+ advanceUntilIdle()
+
+ verify {
+ promoDeepLinkFactory.create(eq(testScope), eq(mapOf("promo_code" to "PROMO123")))
+ }
+ }
}
\ No newline at end of file
diff --git a/common/routing/src/main/kotlin/com/tangem/common/routing/DeepLinkRoute.kt b/common/routing/src/main/kotlin/com/tangem/common/routing/DeepLinkRoute.kt
index 78a4ad85ea..1fbffcb8a8 100644
--- a/common/routing/src/main/kotlin/com/tangem/common/routing/DeepLinkRoute.kt
+++ b/common/routing/src/main/kotlin/com/tangem/common/routing/DeepLinkRoute.kt
@@ -55,6 +55,10 @@ sealed class DeepLinkRoute {
data object WalletConnect : DeepLinkRoute() {
override val host: String = "wc"
}
+
+ data object Promo : DeepLinkRoute() {
+ override val host: String = "promo"
+ }
}
enum class DeepLinkScheme(val scheme: String) {
diff --git a/common/routing/src/main/kotlin/com/tangem/common/routing/deeplink/DeeplinkConst.kt b/common/routing/src/main/kotlin/com/tangem/common/routing/deeplink/DeeplinkConst.kt
index a435e90549..575be52e80 100644
--- a/common/routing/src/main/kotlin/com/tangem/common/routing/deeplink/DeeplinkConst.kt
+++ b/common/routing/src/main/kotlin/com/tangem/common/routing/deeplink/DeeplinkConst.kt
@@ -11,5 +11,6 @@ object DeeplinkConst {
const val TOKEN_ID_KEY = "token_id"
const val DERIVATION_PATH_KEY = "derivation_path"
const val TRANSACTION_ID_KEY = "transaction_id"
+ const val PROMO_CODE_KEY = "promo_code"
const val NAME_KEY = "name"
}
\ No newline at end of file
diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/tangemTech/TangemTechApi.kt b/core/datasource/src/main/java/com/tangem/datasource/api/tangemTech/TangemTechApi.kt
index 14ec9f906f..2d6d08aff3 100644
--- a/core/datasource/src/main/java/com/tangem/datasource/api/tangemTech/TangemTechApi.kt
+++ b/core/datasource/src/main/java/com/tangem/datasource/api/tangemTech/TangemTechApi.kt
@@ -176,4 +176,9 @@ interface TangemTechApi {
@GET("v1/user-wallets/wallets/by-app/{app_id}")
suspend fun getWallets(@Path("app_id") appId: String): ApiResponse>
// endregion
+
+ // promo
+ @POST("promo/v1/promo-codes/activate")
+ suspend fun activatePromoCode(@Body body: PromocodeActivationBody): ApiResponse
+ // endregion
}
\ No newline at end of file
diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/tangemTech/models/PromocodeActivationBody.kt b/core/datasource/src/main/java/com/tangem/datasource/api/tangemTech/models/PromocodeActivationBody.kt
new file mode 100644
index 0000000000..9d28f527e7
--- /dev/null
+++ b/core/datasource/src/main/java/com/tangem/datasource/api/tangemTech/models/PromocodeActivationBody.kt
@@ -0,0 +1,10 @@
+package com.tangem.datasource.api.tangemTech.models
+
+import com.squareup.moshi.Json
+import com.squareup.moshi.JsonClass
+
+@JsonClass(generateAdapter = true)
+data class PromocodeActivationBody(
+ @Json(name = "promoCode") val promoCode: String,
+ @Json(name = "address") val address: String,
+)
\ No newline at end of file
diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/tangemTech/models/PromocodeActivationResponse.kt b/core/datasource/src/main/java/com/tangem/datasource/api/tangemTech/models/PromocodeActivationResponse.kt
new file mode 100644
index 0000000000..427a4f78d9
--- /dev/null
+++ b/core/datasource/src/main/java/com/tangem/datasource/api/tangemTech/models/PromocodeActivationResponse.kt
@@ -0,0 +1,9 @@
+package com.tangem.datasource.api.tangemTech.models
+
+import com.squareup.moshi.Json
+import com.squareup.moshi.JsonClass
+
+@JsonClass(generateAdapter = true)
+data class PromocodeActivationResponse(
+ @Json(name = "status") val status: String,
+)
\ No newline at end of file
diff --git a/core/res/src/main/res/values-es/strings.xml b/core/res/src/main/res/values-es/strings.xml
index fa2411cdf5..37b3938536 100644
--- a/core/res/src/main/res/values-es/strings.xml
+++ b/core/res/src/main/res/values-es/strings.xml
@@ -202,7 +202,7 @@
NFT
No
Ninguna dirección
- No aregada
+ No agregada
Ahora no
Ahora
OK
@@ -250,8 +250,8 @@
Condiciones de uso
Hoy
- - %d ficha
- - %d fichas
+ - %d token
+ - %d tokens
Transacción fallida
Estado de la transacción
@@ -1348,7 +1348,7 @@
Para continuar, vuelva a conectar su sesión de dApp con la red requerida %s.
Red no conectada
Verifique su conexión de red
- Tiempo de espera de la solicitud agotado
+ Tiempo de espera agotado
Vuelva a su navegador y vuelva a conectarse a través de WalletConnect.
La sesión de Wallet Connect se desconectó
Firmar de todos modos
diff --git a/core/res/src/main/res/values-ja/strings.xml b/core/res/src/main/res/values-ja/strings.xml
index 5ffb365ef3..4c5d34d274 100644
--- a/core/res/src/main/res/values-ja/strings.xml
+++ b/core/res/src/main/res/values-ja/strings.xml
@@ -12,6 +12,7 @@
ウォレットのロックを解除するには、 %s桁のアクセスコードを設定します。
アクセスコードの作成
アクセスコード
+ アーカイブされたアカウント
回復する
アーカイブ済み
アカウントをアーカイブする
@@ -26,6 +27,7 @@
新しいアカウント
アカウントを追加
アカウントを編集
+ アカウントを長押しして並べ替える
編集を続ける
破棄
新しいアカウントを破棄してもよろしいですか?
@@ -71,10 +73,8 @@
設定に移動して、Tangemアプリで生体認証を有効にします。
生体認証を有効にする
%1$sが無効になると、アプリのロックを解除してウォレットを操作するために、パスコードを入力する必要があります。
- 後でウォレットのアクセスコードを入力してもらいます。それを安全に保存し、今後の利用に備えるためです。
これにより、保存されているウォレットアクセスコードがすべて削除されます。ウォレットでの今後の操作には、アクセスコードの送信が必要になります。
保存したデバイスを削除すると、保存されているすべてのウォレットとそのアクセスコードがアプリから削除されます。
- これにより、保存されているウォレットアクセスコードがすべて削除されます。今後ウォレットを操作するには、アクセスコードの送信が必要になります。
アクセスコードを要求する
このオプションを選択すると、機密性の高い操作における生体認証が無効になります。取引の署名時などには、毎回アクセスコードの入力が必要になります。
アクセスコードを保存
@@ -116,6 +116,12 @@
30秒後に再試行するか、カードまたはリングをスキャンしてください
試行回数が多すぎます
お使いの携帯電話で生体認証が無効になっているため、アプリにウォレットを保存できません。ウォレットを保存するには、携帯電話の設定で生体認証機能を有効にしてください。
+ プロモーションコードの処理中にエラーが発生しました。しばらくしてからもう一度お試しください。
+ プロモーションコードが正常に有効化されました。14日以内に10 USDT相当のビットコインボーナスが付与されます。
+ プロモーションコードが有効になりました
+ このプロモーションコードは既に使用されているため、再度使うことはできません。
+ このプロモーションコードは無効であり、有効化できません。
+ ボーナスを受け取るにはビットコインアドレスが必要です。ウォレットにビットコインアドレスを追加し、再度アクティベーションをお試しください。
バックアップ処理を開始する
銀行カードまたはその他の支払い方法を使用する
@@ -149,6 +155,7 @@
ADAが不足しています。
受け入れる
アクセスが拒否されました
+ アカウント
追加
ポートフォリオに追加
トークンを追加
@@ -489,7 +496,6 @@
最新の機能とニュースをお届けします
シードフレーズのバックアップ
モバイルウォレットを作成する
- このリカバリーフレーズはすでにインポートされています。
モバイルウォレット
この情報はAIで生成されました。 \nエラーが見つかった場合は、ここをタップしてください。
アクセスコードを変更するには、上図のようにカードまたはリングをタップし、操作が終了するまで取り外さないでください。
diff --git a/core/res/src/main/res/values-ru/strings.xml b/core/res/src/main/res/values-ru/strings.xml
index befe0fd2d7..899b661b01 100644
--- a/core/res/src/main/res/values-ru/strings.xml
+++ b/core/res/src/main/res/values-ru/strings.xml
@@ -59,6 +59,16 @@
Пожалуйста, попробуйте снова через 30 секунд или отсканируйте карту или кольцо
Слишком много попыток
Вы отключили биометрическую аутентификацию на вашем телефоне и не сможете сохранять кошельки в приложении. Для сохранения кошельков, пожалуйста, включите функцию биометрической аутентификации в настройках телефона.
+ При обработке промокода произошла ошибка. Пожалуйста, попробуйте позже.
+ Ошибка активации
+ Ваш промокод успешно активирован. Бонус 10 USDT в Bitcoin будет зачислен через 14 дней.
+ Промокод активирован
+ Этот промокод уже был использован и не может быть активирован повторно.
+ Код недоступен
+ Этот промокод недействителен и не может быть активирован.
+ Неверный код
+ Для зачисления бонуса нужен Bitcoin-адрес. Добавьте его в портфель и повторите активацию.
+ Требуется Bitcoin-адрес
Начать резервное копирование
Используйте банковскую карту или другие методы оплаты
diff --git a/core/res/src/main/res/values-uk-rUA/strings.xml b/core/res/src/main/res/values-uk-rUA/strings.xml
index 5c7d9f4ef4..21880f895f 100644
--- a/core/res/src/main/res/values-uk-rUA/strings.xml
+++ b/core/res/src/main/res/values-uk-rUA/strings.xml
@@ -59,6 +59,16 @@
Будь ласка, спробуйте знову через 30 секунд або відскануйте картку або кільце
Забагато спроб
Ви вимкнули біометричну автентифікацію на своєму телефоні і не зможете зберігати гаманці в додатку. Щоб зберегти гаманці, будь ласка, увімкніть функцію біометричної автентифікації в налаштуваннях телефону.
+ Під час обробки промокоду сталася помилка. Будь ласка, спробуйте пізніше.
+ Помилка активації
+ Ваш промокод успішно активовано. Бонус 10 USDT у Bitcoin буде зараховано через 14 днів.
+ Промокод активовано
+ Цей промокод уже був використаний і не може бути активований повторно.
+ Код недоступний
+ Цей промокод недійсний і не може бути активований.
+ Невірний код
+ Для зарахування бонусу потрібна Bitcoin-адреса. Додайте її до портфеля та повторіть активацію.
+ Потрібна Bitcoin-адреса
Почніть процес резервного копіювання
Використовуйте банківську картку або інші способи оплати
diff --git a/core/res/src/main/res/values/strings.xml b/core/res/src/main/res/values/strings.xml
index 8bda8073a5..8387e25d6a 100644
--- a/core/res/src/main/res/values/strings.xml
+++ b/core/res/src/main/res/values/strings.xml
@@ -12,7 +12,12 @@
Set a %s-digit Access Code to unlock your wallet.
Create Access Code
Access code
+ You cannot create more than %1$s accounts. Archive one to add new.
+ Can’t add new account
+ Archived accounts
Recover
+ You’re about to recover “%1$s”.
+ Recover account
Archived
Archive account
Archive
@@ -26,6 +31,8 @@
New account
Add account
Edit account
+ %1$s in %2$s
+ Long tap on an account to reorder accounts
Keep Editing
Discard
Are you sure you want to discard new account?
@@ -117,6 +124,16 @@
Please try again in 30 seconds or scan the card or ring
Too many attempts
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.
+ An error occurred while processing your promo code. Please try again later.
+ Activation error
+ Your promo code has been successfully activated. A bonus of 10 USDT in Bitcoin will be credited in 14 days.
+ Promo code activated
+ This promo code has already been used and cannot be activated again.
+ Code unavailable
+ This promo code is not valid and cannot be activated.
+ Invalid code
+ A Bitcoin address is required to receive the bonus. Please add one to your wallet and retry the activation.
+ Bitcoin address required
Start backup process
Use a bank card or other payment methods
@@ -152,6 +169,7 @@
Not enough ADA
Accept
Access denied
+ Accounts
Add
Add to portfolio
Add token
@@ -239,6 +257,10 @@
month
Network fee
Amount sent will be reduced by %1$s (%2$s) to cover the selected fee level
+
+ - %d network
+ - %d networks
+
Next
NFT
No
@@ -485,6 +507,8 @@
If you do, you\'ll need to start over.
Recover an existing wallet stored in your Google Drive backup
Google Drive Backup
+ Upgrade your security right away with a best in class hardware wallet from Tangem.
+ Hardware Wallet
Go to backup
To secure your wallet with a Access Code, complete the backup first.
Finish Backup First
@@ -498,6 +522,19 @@
Create Mobile Wallet
This recovery phrase has already been imported
Mobile Wallet
+ Your funds stay safe and fully accessible during the process
+ Funds access
+ All private wallet data will be removed from the mobile app and stored securely on your Tangem device only
+ General Security
+ Private keys will be moved from the app to your Tangem card or ring
+ Key Migration
+ Scan device
+ Start upgrade
+ You’re about to upgrade your wallet to Tangem Wallet. This will keep your assets safe with cold storage.
+ Tangem Wallet
+ Upgrade to Hardware Wallet
+ Keep your crypto safe with Tangem’s best-in-class hardware wallet.
+ Upgrade wallet with a hardware
backup
This information was generated with AI.\nTap here, if you find any errors.
To change the access code tap the card or ring as shown above and do not remove until the end of the operation
To change the passcode tap the card as shown above and do not remove until the end of the operation
@@ -702,6 +739,9 @@
Join Now
Share your code - earn 5 USDT per sale. Your friend gets 10% OFF.
Get REWARDS for every friend!
+ Buy crypto
+ Enjoy zero fees when purchasing crypto via SEPA transfer.
+ Buy Crypto with SEPA
Set up a single access code to protect all your devices.
Protect
Set an individual access code for each card or ring later.
diff --git a/core/ui/src/main/java/com/tangem/core/ui/extensions/String.kt b/core/ui/src/main/java/com/tangem/core/ui/extensions/String.kt
index f60730bd69..3d5ce5fc09 100644
--- a/core/ui/src/main/java/com/tangem/core/ui/extensions/String.kt
+++ b/core/ui/src/main/java/com/tangem/core/ui/extensions/String.kt
@@ -80,4 +80,29 @@ fun String.capitalize(): String = replaceFirstChar { if (it.isLowerCase()) it.ti
fun String.orMaskWithStars(maskWithStars: Boolean): String {
return if (maskWithStars) THREE_STARS else this
+}
+
+/**
+ * Returns a masked representation of the string for safe display.
+ *
+ * Rules:
+ * - empty string: returned unchanged
+ * - length <= 2: all characters are replaced with '*'
+ * - length <= 4: keep the first and last characters, mask the middle
+ * - length > 4: keep the first two and last two characters, mask the middle with "**"
+ *
+ * @return masked string according to the rules above
+ */
+@Suppress("MagicNumber")
+fun String.mask(): String {
+ return when {
+ this.isEmpty() -> this
+ this.length <= 2 -> "*".repeat(this.length) // mask all if too short
+ this.length <= 4 -> this.first() + "*".repeat(this.length - 2) + this.last()
+ else -> {
+ val prefix = this.take(2)
+ val suffix = this.takeLast(2)
+ "$prefix**$suffix"
+ }
+ }
}
\ No newline at end of file
diff --git a/core/ui/src/main/java/com/tangem/core/ui/message/EventMessage.kt b/core/ui/src/main/java/com/tangem/core/ui/message/EventMessage.kt
index 216e6c755c..d2988d0b0c 100644
--- a/core/ui/src/main/java/com/tangem/core/ui/message/EventMessage.kt
+++ b/core/ui/src/main/java/com/tangem/core/ui/message/EventMessage.kt
@@ -147,6 +147,8 @@ data class DialogMessage(
}
}
+data class GlobalLoadingMessage(val isShow: Boolean) : EventMessage
+
/**
* Shows a bottom sheet.
*
diff --git a/core/ui/src/main/java/com/tangem/core/ui/message/EventMessageEffect.kt b/core/ui/src/main/java/com/tangem/core/ui/message/EventMessageEffect.kt
index 25984f4a44..8d23185045 100644
--- a/core/ui/src/main/java/com/tangem/core/ui/message/EventMessageEffect.kt
+++ b/core/ui/src/main/java/com/tangem/core/ui/message/EventMessageEffect.kt
@@ -2,13 +2,23 @@ package com.tangem.core.ui.message
import android.content.Context
import android.widget.Toast
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.SnackbarDuration
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.SnackbarResult
import androidx.compose.runtime.*
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.window.Dialog
+import androidx.compose.ui.window.DialogProperties
import com.tangem.core.ui.components.BasicDialog
import com.tangem.core.ui.components.DialogButtonUM
+import com.tangem.core.ui.components.SpacerHMax
import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig
import com.tangem.core.ui.components.bottomsheets.message.MessageBottomSheet
import com.tangem.core.ui.components.bottomsheets.message.MessageBottomSheetUM
@@ -17,6 +27,7 @@ import com.tangem.core.ui.event.EventEffect
import com.tangem.core.ui.extensions.resolveReference
import com.tangem.core.ui.res.LocalEventMessageHandler
import com.tangem.core.ui.res.LocalSnackbarHostState
+import com.tangem.core.ui.res.TangemTheme
@Composable
fun EventMessageEffect(
@@ -33,6 +44,7 @@ fun EventMessageEffect(
var dialogMessage: DialogMessage? by remember { mutableStateOf(value = null) }
var bottomSheetMessage: BottomSheetMessage? by remember { mutableStateOf(value = null) }
var bottomSheetMessageV2: BottomSheetMessageV2? by remember { mutableStateOf(value = null) }
+ var loadingMessage: GlobalLoadingMessage? by remember { mutableStateOf(value = null) }
EventEffect(event = messageEvent) { message ->
when (message) {
@@ -51,6 +63,13 @@ fun EventMessageEffect(
is ToastMessage -> {
onShowToast(message, context)
}
+ is GlobalLoadingMessage -> {
+ loadingMessage = if (message.isShow) {
+ message
+ } else {
+ null
+ }
+ }
}
}
@@ -80,6 +99,34 @@ fun EventMessageEffect(
onDismissRequest = { bottomSheetMessageV2 = null },
)
}
+
+ loadingMessage?.let {
+ LoadingDialog()
+ }
+}
+
+@Composable
+private fun LoadingDialog() {
+ Dialog(
+ onDismissRequest = {},
+ properties = DialogProperties(
+ dismissOnBackPress = false,
+ dismissOnClickOutside = false,
+ ),
+ ) {
+ Column(
+ modifier = Modifier
+ .fillMaxSize(),
+ verticalArrangement = Arrangement.spacedBy(16.dp),
+ ) {
+ SpacerHMax()
+ CircularProgressIndicator(
+ modifier = Modifier.align(Alignment.CenterHorizontally),
+ color = TangemTheme.colors.icon.primary1,
+ )
+ SpacerHMax()
+ }
+ }
}
@Composable
diff --git a/core/ui/src/test/java/com/tangem/core/ui/extensions/StringMaskTest.kt b/core/ui/src/test/java/com/tangem/core/ui/extensions/StringMaskTest.kt
new file mode 100644
index 0000000000..8717d32a2c
--- /dev/null
+++ b/core/ui/src/test/java/com/tangem/core/ui/extensions/StringMaskTest.kt
@@ -0,0 +1,91 @@
+package com.tangem.core.ui.extensions
+
+import com.google.common.truth.Truth.assertThat
+import org.junit.Test
+
+class StringMaskTest {
+
+ @Test
+ fun GIVEN_empty_string_WHEN_mask_THEN_returns_empty_string() {
+ // GIVEN
+ val input = ""
+
+ // WHEN
+ val actual = input.mask()
+
+ // THEN
+ assertThat(actual).isEqualTo("")
+ }
+
+ @Test
+ fun GIVEN_one_char_WHEN_mask_THEN_returns_asterisk() {
+ // GIVEN
+ val input = "a"
+
+ // WHEN
+ val actual = input.mask()
+
+ // THEN
+ assertThat(actual).isEqualTo("*")
+ }
+
+ @Test
+ fun GIVEN_two_chars_WHEN_mask_THEN_returns_two_asterisks() {
+ // GIVEN
+ val input = "ab"
+
+ // WHEN
+ val actual = input.mask()
+
+ // THEN
+ assertThat(actual).isEqualTo("**")
+ }
+
+ @Test
+ fun GIVEN_three_chars_WHEN_mask_THEN_masks_middle_only() {
+ // GIVEN
+ val input = "abc"
+
+ // WHEN
+ val actual = input.mask()
+
+ // THEN
+ assertThat(actual).isEqualTo("a*c")
+ }
+
+ @Test
+ fun GIVEN_four_chars_WHEN_mask_THEN_masks_middle_two() {
+ // GIVEN
+ val input = "abcd"
+
+ // WHEN
+ val actual = input.mask()
+
+ // THEN
+ assertThat(actual).isEqualTo("a**d")
+ }
+
+ @Test
+ fun GIVEN_five_chars_WHEN_mask_THEN_keeps_edges_and_two_stars_in_middle() {
+ // GIVEN
+ val input = "abcde"
+
+ // WHEN
+ val actual = input.mask()
+
+ // THEN
+ assertThat(actual).isEqualTo("ab**de")
+ }
+
+ @Test
+ fun GIVEN_seven_chars_WHEN_mask_THEN_keeps_two_on_each_side_and_two_stars() {
+ // GIVEN
+ val input = "abcdefg"
+
+ // WHEN
+ val actual = input.mask()
+
+ // THEN
+ assertThat(actual).isEqualTo("ab**fg")
+ }
+}
\ No newline at end of file
diff --git a/data/wallets/src/main/java/com/tangem/data/wallets/DefaultWalletsRepository.kt b/data/wallets/src/main/java/com/tangem/data/wallets/DefaultWalletsRepository.kt
index 8808bd336a..da3b78db9c 100644
--- a/data/wallets/src/main/java/com/tangem/data/wallets/DefaultWalletsRepository.kt
+++ b/data/wallets/src/main/java/com/tangem/data/wallets/DefaultWalletsRepository.kt
@@ -1,12 +1,17 @@
package com.tangem.data.wallets
+import arrow.core.Either
+import arrow.core.left
+import arrow.core.right
import com.tangem.data.wallets.converters.UserWalletRemoteInfoConverter
import com.tangem.data.wallets.converters.WalletIdBodyConverter
import com.tangem.datasource.api.common.AuthProvider
import com.tangem.datasource.api.common.response.ApiResponseError.HttpException
+import com.tangem.datasource.api.common.response.fold
import com.tangem.datasource.api.common.response.getOrThrow
import com.tangem.datasource.api.tangemTech.TangemTechApi
import com.tangem.datasource.api.tangemTech.models.MarkUserWalletWasCreatedBody
+import com.tangem.datasource.api.tangemTech.models.PromocodeActivationBody
import com.tangem.datasource.api.tangemTech.models.SeedPhraseNotificationDTO
import com.tangem.datasource.api.tangemTech.models.SeedPhraseNotificationDTO.Status
import com.tangem.datasource.api.tangemTech.models.WalletBody
@@ -23,6 +28,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
@@ -308,4 +314,28 @@ internal class DefaultWalletsRepository(
body = walletsBody,
).getOrThrow()
}
+
+ override suspend fun activatePromoCode(
+ promoCode: String,
+ bitcoinAddress: String,
+ ): Either = withContext(dispatchers.io) {
+ tangemTechApi.activatePromoCode(
+ body = PromocodeActivationBody(
+ promoCode = promoCode,
+ address = bitcoinAddress,
+ ),
+ ).fold({
+ return@fold it.status.right()
+ }, { error ->
+ val error = when (error) {
+ is HttpException -> when (error.code) {
+ HttpException.Code.NOT_FOUND -> ActivatePromoCodeError.InvalidPromoCode
+ HttpException.Code.CONFLICT -> ActivatePromoCodeError.PromocodeAlreadyUsed
+ else -> ActivatePromoCodeError.ActivationFailed
+ }
+ else -> ActivatePromoCodeError.ActivationFailed
+ }
+ return@fold error.left()
+ },)
+ }
}
\ No newline at end of file
diff --git a/data/wallets/src/test/java/com/tangem/data/wallets/DefaultWalletsRepositoryTest.kt b/data/wallets/src/test/java/com/tangem/data/wallets/DefaultWalletsRepositoryTest.kt
index 2911dd5968..1a64234232 100644
--- a/data/wallets/src/test/java/com/tangem/data/wallets/DefaultWalletsRepositoryTest.kt
+++ b/data/wallets/src/test/java/com/tangem/data/wallets/DefaultWalletsRepositoryTest.kt
@@ -6,12 +6,16 @@ import com.google.common.truth.Truth.assertThat
import com.squareup.moshi.Moshi
import com.tangem.datasource.api.common.AuthProvider
import com.tangem.datasource.api.common.response.ApiResponse
+import com.tangem.datasource.api.common.response.ApiResponseError.HttpException
import com.tangem.datasource.api.tangemTech.TangemTechApi
import com.tangem.datasource.api.tangemTech.models.WalletResponse
+import com.tangem.datasource.api.tangemTech.models.PromocodeActivationBody
+import com.tangem.datasource.api.tangemTech.models.PromocodeActivationResponse
import com.tangem.datasource.local.preferences.AppPreferencesStore
import com.tangem.datasource.local.preferences.PreferencesKeys
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.models.wallet.UserWalletId
+import com.tangem.domain.wallets.models.errors.ActivatePromoCodeError
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider
import io.mockk.coEvery
@@ -235,4 +239,64 @@ class DefaultWalletsRepositoryTest {
)
}
}
+
+ @Test
+ fun `GIVEN valid data WHEN activatePromoCode THEN returns Right with status and calls API`() = runTest {
+ // GIVEN
+ val promoCode = "PROMO123"
+ val address = "bc1qexampleaddress"
+ coEvery { tangemTechApi.activatePromoCode(any()) } returns ApiResponse.Success(
+ PromocodeActivationResponse(status = "activated"),
+ )
+
+ // WHEN
+ val result = repository.activatePromoCode(promoCode = promoCode, bitcoinAddress = address)
+
+ // THEN
+ var right: String? = null
+ var left: ActivatePromoCodeError? = null
+ result.fold({ left = it }, { right = it })
+ assertThat(left).isNull()
+ assertThat(right).isEqualTo("activated")
+
+ coVerify(exactly = 1) {
+ tangemTechApi.activatePromoCode(
+ match { it is PromocodeActivationBody && it.promoCode == promoCode && it.address == address },
+ )
+ }
+ }
+
+ @Test
+ fun `GIVEN NOT_FOUND error WHEN activatePromoCode THEN returns Left InvalidPromoCode`() = runTest {
+ // GIVEN
+ coEvery { tangemTechApi.activatePromoCode(any()) } returns
+ ApiResponse.Error(
+ HttpException(code = HttpException.Code.NOT_FOUND, message = null, errorBody = null),
+ ) as ApiResponse
+
+ // WHEN
+ val result = repository.activatePromoCode(promoCode = "PROMO", bitcoinAddress = "addr")
+
+ // THEN
+ var error: ActivatePromoCodeError? = null
+ result.fold({ error = it }, { })
+ assertThat(error).isEqualTo(ActivatePromoCodeError.InvalidPromoCode)
+ }
+
+ @Test
+ fun `GIVEN CONFLICT error WHEN activatePromoCode THEN returns Left PromocodeAlreadyUsed`() = runTest {
+ // GIVEN
+ coEvery { tangemTechApi.activatePromoCode(any()) } returns
+ ApiResponse.Error(
+ HttpException(code = HttpException.Code.CONFLICT, message = null, errorBody = null),
+ ) as ApiResponse
+
+ // WHEN
+ val result = repository.activatePromoCode(promoCode = "PROMO", bitcoinAddress = "addr")
+
+ // THEN
+ var error: ActivatePromoCodeError? = null
+ result.fold({ error = it }, { })
+ assertThat(error).isEqualTo(ActivatePromoCodeError.PromocodeAlreadyUsed)
+ }
}
\ No newline at end of file
diff --git a/domain/wallets/models/src/main/java/com/tangem/domain/wallets/PromoCodeActivationResult.kt b/domain/wallets/models/src/main/java/com/tangem/domain/wallets/PromoCodeActivationResult.kt
new file mode 100644
index 0000000000..0ca02411b2
--- /dev/null
+++ b/domain/wallets/models/src/main/java/com/tangem/domain/wallets/PromoCodeActivationResult.kt
@@ -0,0 +1,9 @@
+package com.tangem.domain.wallets
+
+enum class PromoCodeActivationResult {
+ Failed,
+ InvalidPromoCode,
+ NoBitcoinAddress,
+ PromoCodeAlreadyUsed,
+ Activated,
+}
\ No newline at end of file
diff --git a/domain/wallets/models/src/main/java/com/tangem/domain/wallets/models/errors/ActivatePromoCodeError.kt b/domain/wallets/models/src/main/java/com/tangem/domain/wallets/models/errors/ActivatePromoCodeError.kt
new file mode 100644
index 0000000000..cd1accae97
--- /dev/null
+++ b/domain/wallets/models/src/main/java/com/tangem/domain/wallets/models/errors/ActivatePromoCodeError.kt
@@ -0,0 +1,12 @@
+package com.tangem.domain.wallets.models.errors
+
+sealed class ActivatePromoCodeError {
+
+ data object InvalidPromoCode : ActivatePromoCodeError()
+
+ data object ActivationFailed : ActivatePromoCodeError()
+
+ data object PromocodeAlreadyUsed : ActivatePromoCodeError()
+
+ data object NoBitcoinAddress : ActivatePromoCodeError()
+}
\ No newline at end of file
diff --git a/domain/wallets/src/main/java/com/tangem/domain/wallets/repository/WalletsRepository.kt b/domain/wallets/src/main/java/com/tangem/domain/wallets/repository/WalletsRepository.kt
index 5a6c051057..2a2f93d29c 100644
--- a/domain/wallets/src/main/java/com/tangem/domain/wallets/repository/WalletsRepository.kt
+++ b/domain/wallets/src/main/java/com/tangem/domain/wallets/repository/WalletsRepository.kt
@@ -1,9 +1,11 @@
package com.tangem.domain.wallets.repository
+import arrow.core.Either
import com.tangem.domain.wallets.models.SeedPhraseNotificationsStatus
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.wallets.models.UserWalletRemoteInfo
+import com.tangem.domain.wallets.models.errors.ActivatePromoCodeError
import kotlinx.coroutines.flow.Flow
@Suppress("TooManyFunctions")
@@ -58,4 +60,6 @@ interface WalletsRepository {
@Throws
suspend fun associateWallets(applicationId: String, wallets: List)
+
+ suspend fun activatePromoCode(promoCode: String, bitcoinAddress: String): Either
}
\ No newline at end of file
diff --git a/domain/wallets/src/main/java/com/tangem/domain/wallets/usecase/ActivateBitcoinPromocodeUseCase.kt b/domain/wallets/src/main/java/com/tangem/domain/wallets/usecase/ActivateBitcoinPromocodeUseCase.kt
new file mode 100644
index 0000000000..0acd401fcb
--- /dev/null
+++ b/domain/wallets/src/main/java/com/tangem/domain/wallets/usecase/ActivateBitcoinPromocodeUseCase.kt
@@ -0,0 +1,14 @@
+package com.tangem.domain.wallets.usecase
+
+import arrow.core.Either
+import com.tangem.domain.wallets.models.errors.ActivatePromoCodeError
+import com.tangem.domain.wallets.repository.WalletsRepository
+import javax.inject.Inject
+
+class ActivateBitcoinPromocodeUseCase @Inject constructor(
+ private val walletsRepository: WalletsRepository,
+) {
+
+ suspend operator fun invoke(address: String, promoCode: String): Either =
+ walletsRepository.activatePromoCode(promoCode = promoCode, bitcoinAddress = address)
+}
\ No newline at end of file
diff --git a/features/wallet/api/src/main/kotlin/com/tangem/features/wallet/deeplink/PromoDeeplinkHandler.kt b/features/wallet/api/src/main/kotlin/com/tangem/features/wallet/deeplink/PromoDeeplinkHandler.kt
new file mode 100644
index 0000000000..75939e8c55
--- /dev/null
+++ b/features/wallet/api/src/main/kotlin/com/tangem/features/wallet/deeplink/PromoDeeplinkHandler.kt
@@ -0,0 +1,10 @@
+package com.tangem.features.wallet.deeplink
+
+import kotlinx.coroutines.CoroutineScope
+
+interface PromoDeeplinkHandler {
+
+ interface Factory {
+ fun create(coroutineScope: CoroutineScope, queryParams: Map): PromoDeeplinkHandler
+ }
+}
\ No newline at end of file
diff --git a/features/wallet/impl/build.gradle.kts b/features/wallet/impl/build.gradle.kts
index 77dd37708d..646bb012f2 100644
--- a/features/wallet/impl/build.gradle.kts
+++ b/features/wallet/impl/build.gradle.kts
@@ -121,6 +121,8 @@ dependencies {
implementation(projects.common.ui)
/** Test libraries */
- implementation(deps.test.junit)
- implementation(deps.test.truth)
+ testImplementation(deps.test.junit)
+ testImplementation(deps.test.coroutine)
+ testImplementation(deps.test.truth)
+ testImplementation(deps.test.mockk)
}
\ No newline at end of file
diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/deeplink/DefaultPromoDeeplinkHandler.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/deeplink/DefaultPromoDeeplinkHandler.kt
new file mode 100644
index 0000000000..bb524e4d59
--- /dev/null
+++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/deeplink/DefaultPromoDeeplinkHandler.kt
@@ -0,0 +1,142 @@
+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.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.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
+
+internal class DefaultPromoDeeplinkHandler @AssistedInject constructor(
+ @Assisted private val scope: CoroutineScope,
+ @Assisted private val queryParams: Map,
+ @GlobalUiMessageSender private val uiMessageSender: UiMessageSender,
+ private val getMultiCryptoCurrencyStatusUseCase: GetMultiCryptoCurrencyStatusUseCase,
+ private val activateBitcoinPromocodeUseCase: ActivateBitcoinPromocodeUseCase,
+ private val getSelectedWalletSyncUseCase: GetSelectedWalletSyncUseCase,
+) : PromoDeeplinkHandler {
+
+ init {
+ 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) {
+ val (title, message) = when (type) {
+ Failed -> resourceReference(R.string.bitcoin_promo_activation_error_title) to
+ resourceReference(R.string.bitcoin_promo_activation_error)
+ InvalidPromoCode -> resourceReference(R.string.bitcoin_promo_invalid_code_title) to
+ resourceReference(R.string.bitcoin_promo_invalid_code)
+ NoBitcoinAddress -> resourceReference(R.string.bitcoin_promo_no_address_title) to
+ resourceReference(R.string.bitcoin_promo_no_address)
+ PromoCodeAlreadyUsed -> resourceReference(R.string.bitcoin_promo_already_activated_title) to
+ resourceReference(R.string.bitcoin_promo_already_activated)
+ Activated -> resourceReference(R.string.bitcoin_promo_activation_success_title) to
+ resourceReference(R.string.bitcoin_promo_activation_success)
+ }
+ uiMessageSender.send(
+ DialogMessage(
+ title = title,
+ message = message,
+ dismissOnFirstAction = true,
+ firstActionBuilder = {
+ okAction { }
+ },
+ ),
+ )
+ }
+
+ @AssistedFactory
+ interface Factory : PromoDeeplinkHandler.Factory {
+ override fun create(
+ coroutineScope: CoroutineScope,
+ queryParams: Map,
+ ): DefaultPromoDeeplinkHandler
+ }
+
+ companion object {
+ private const val LOG_TAG = "PromoCodeActivation"
+ private const val DEFAULT_MESSAGE_SENDER_DELAY = 500L
+ }
+}
\ No newline at end of file
diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/deeplink/di/WalletDeepLinkModule.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/deeplink/di/WalletDeepLinkModule.kt
index fca5019c10..f8f1e46718 100644
--- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/deeplink/di/WalletDeepLinkModule.kt
+++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/deeplink/di/WalletDeepLinkModule.kt
@@ -1,7 +1,9 @@
package com.tangem.feature.wallet.deeplink.di
+import com.tangem.feature.wallet.deeplink.DefaultPromoDeeplinkHandler
import com.tangem.feature.wallet.deeplink.DefaultWalletDeepLinkActionTrigger
import com.tangem.feature.wallet.deeplink.DefaultWalletDeepLinkHandler
+import com.tangem.features.wallet.deeplink.PromoDeeplinkHandler
import com.tangem.features.wallet.deeplink.WalletDeepLinkActionListener
import com.tangem.features.wallet.deeplink.WalletDeepLinkActionTrigger
import com.tangem.features.wallet.deeplink.WalletDeepLinkHandler
@@ -19,6 +21,10 @@ internal interface WalletDeepLinkModule {
@Singleton
fun bindWalletDeepLinkHandlerFactory(impl: DefaultWalletDeepLinkHandler.Factory): WalletDeepLinkHandler.Factory
+ @Binds
+ @Singleton
+ fun bindPromoDeepLinkHandlerFactory(impl: DefaultPromoDeeplinkHandler.Factory): PromoDeeplinkHandler.Factory
+
@Binds
@Singleton
fun bindWalletDeepLinkActionTrigger(impl: DefaultWalletDeepLinkActionTrigger): WalletDeepLinkActionTrigger
diff --git a/features/wallet/impl/src/test/kotlin/com/tangem/feature/wallet/presentation/wallet/deeplink/DefaultPromoDeeplinkHandlerTest.kt b/features/wallet/impl/src/test/kotlin/com/tangem/feature/wallet/presentation/wallet/deeplink/DefaultPromoDeeplinkHandlerTest.kt
new file mode 100644
index 0000000000..4733150631
--- /dev/null
+++ b/features/wallet/impl/src/test/kotlin/com/tangem/feature/wallet/presentation/wallet/deeplink/DefaultPromoDeeplinkHandlerTest.kt
@@ -0,0 +1,331 @@
+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.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.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.tokens.model.CryptoCurrencyStatus
+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.impl.R
+import io.mockk.CapturingSlot
+import io.mockk.MockKAnnotations
+import io.mockk.coEvery
+import io.mockk.every
+import io.mockk.impl.annotations.MockK
+import io.mockk.just
+import io.mockk.mockk
+import io.mockk.runs
+import io.mockk.slot
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.cancel
+import kotlinx.coroutines.test.StandardTestDispatcher
+import kotlinx.coroutines.test.TestDispatcher
+import kotlinx.coroutines.test.TestScope
+import kotlinx.coroutines.test.advanceUntilIdle
+import kotlinx.coroutines.test.resetMain
+import kotlinx.coroutines.test.runTest
+import kotlinx.coroutines.test.setMain
+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
+
+ private lateinit var messageSlot: CapturingSlot
+ 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)
+ 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()
+
+ DefaultPromoDeeplinkHandler(
+ scope = this,
+ queryParams = queryParams,
+ uiMessageSender = uiMessageSender,
+ getMultiCryptoCurrencyStatusUseCase = getMultiCryptoCurrencyStatusUseCase,
+ activateBitcoinPromocodeUseCase = activateBitcoinPromocodeUseCase,
+ getSelectedWalletSyncUseCase = getSelectedWalletSyncUseCase,
+ )
+
+ 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))
+ }
+
+ @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,
+ )
+
+ 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))
+ }
+
+ @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,
+ )
+
+ 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))
+ }
+
+ @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,
+ )
+
+ 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))
+ }
+
+ @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,
+ )
+
+ 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))
+ }
+
+ @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),
+ )
+ }
+
+ @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),
+ )
+ }
+
+ @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),
+ )
+ }
+
+ @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),
+ )
+ }
+
+ 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,
+ )
+
+ 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(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,
+ )
+
+ 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,
+ )
+ }
+}
\ No newline at end of file