From db08399066a8950f9a98071521919568b66848c3 Mon Sep 17 00:00:00 2001 From: Tangem Date: Mon, 25 Aug 2025 14:16:56 +0700 Subject: [PATCH 01/19] Updated on 2026-08-14 --- .../connections/components/WcPairComponent.kt | 26 ++++++++-------- .../connections/model/WcPairModel.kt | 18 +++++------ .../connections/routes/WcAppInfoRoutes.kt | 31 ++++++++++--------- 3 files changed, 39 insertions(+), 36 deletions(-) diff --git a/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/connections/components/WcPairComponent.kt b/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/connections/components/WcPairComponent.kt index afa23ea711..80430447de 100644 --- a/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/connections/components/WcPairComponent.kt +++ b/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/connections/components/WcPairComponent.kt @@ -57,9 +57,9 @@ internal class WcPairComponent( private fun onChildBack() { when (val config = contentStack.value.active.configuration) { is WcAppInfoRoutes.AppInfo -> dismiss() - is Alert -> when (config.alertType) { - is Alert.Type.UnsupportedDApp, - is Alert.Type.UnsupportedNetwork, + is Alert -> when (config) { + is Alert.UnsupportedDApp, + is Alert.UnsupportedNetwork, -> dismiss() else -> model.stackNavigation.pop() } @@ -85,7 +85,7 @@ internal class WcPairComponent( ) is Alert -> AlertsComponentV2( appComponentContext = appComponentContext, - messageUM = createBottomSheetMessageUM(config.alertType), + messageUM = createBottomSheetMessageUM(config), ) is WcAppInfoRoutes.SelectNetworks -> WcSelectNetworksComponent( appComponentContext = appComponentContext, @@ -110,18 +110,18 @@ internal class WcPairComponent( } } - private fun createBottomSheetMessageUM(alertType: Alert.Type): MessageBottomSheetUMV2 { + private fun createBottomSheetMessageUM(alertType: Alert): MessageBottomSheetUMV2 { return when (alertType) { - is Alert.Type.Verified -> WcAlertsFactory.createVerifiedDomainAlert(alertType.appName) - is Alert.Type.UnknownDomain -> WcAlertsFactory.createUnknownDomainAlert(model::connectFromAlert) - is Alert.Type.InvalidDomain -> WcAlertsFactory.createInvalidDomainAlert(model::errorAlertOnDismiss) - is Alert.Type.UnsafeDomain -> WcAlertsFactory.createUnsafeDomainAlert(model::connectFromAlert) - is Alert.Type.UnsupportedDApp -> + is Alert.Verified -> WcAlertsFactory.createVerifiedDomainAlert(alertType.appName) + is Alert.UnknownDomain -> WcAlertsFactory.createUnknownDomainAlert(model::connectFromAlert) + is Alert.InvalidDomain -> WcAlertsFactory.createInvalidDomainAlert(model::errorAlertOnDismiss) + is Alert.UnsafeDomain -> WcAlertsFactory.createUnsafeDomainAlert(model::connectFromAlert) + is Alert.UnsupportedDApp -> WcAlertsFactory.createUnsupportedDomainAlert(alertType.appName, model::errorAlertOnDismiss) - is Alert.Type.UnsupportedNetwork -> + is Alert.UnsupportedNetwork -> WcAlertsFactory.createUnsupportedChainAlert(alertType.appName, model::errorAlertOnDismiss) - is Alert.Type.UriAlreadyUsed -> WcAlertsFactory.createUriAlreadyUsedAlert(model::errorAlertOnDismiss) - is Alert.Type.TimeoutException -> WcAlertsFactory.createTimeoutExceptionAlert(model::errorAlertOnDismiss) + is Alert.UriAlreadyUsed -> WcAlertsFactory.createUriAlreadyUsedAlert(model::errorAlertOnDismiss) + is Alert.TimeoutException -> WcAlertsFactory.createTimeoutExceptionAlert(model::errorAlertOnDismiss) } } diff --git a/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/connections/model/WcPairModel.kt b/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/connections/model/WcPairModel.kt index fe64931b39..7449212ae2 100644 --- a/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/connections/model/WcPairModel.kt +++ b/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/connections/model/WcPairModel.kt @@ -186,7 +186,7 @@ internal class WcPairModel @Inject constructor( source = WcAnalyticEvents.NoticeSecurityAlert.Source.Domain, ) analytics.send(event) - stackNavigation.pushNew(WcAppInfoRoutes.Alert(WcAppInfoRoutes.Alert.Type.UnknownDomain)) + stackNavigation.pushNew(WcAppInfoRoutes.Alert.UnknownDomain) } private fun showSecurityRiskAlert() { @@ -196,11 +196,11 @@ internal class WcPairModel @Inject constructor( source = WcAnalyticEvents.NoticeSecurityAlert.Source.Domain, ) analytics.send(event) - stackNavigation.pushNew(WcAppInfoRoutes.Alert(WcAppInfoRoutes.Alert.Type.UnsafeDomain)) + stackNavigation.pushNew(WcAppInfoRoutes.Alert.UnsafeDomain) } private fun showVerifiedAlert(appName: String) { - stackNavigation.pushNew(WcAppInfoRoutes.Alert(WcAppInfoRoutes.Alert.Type.Verified(appName))) + stackNavigation.pushNew(WcAppInfoRoutes.Alert.Verified(appName)) } private fun processSuccessfullyConnected(session: WcSession) { @@ -216,18 +216,18 @@ internal class WcPairModel @Inject constructor( private fun processError(error: WcPairError) { val alert = when (error) { - is WcPairError.InvalidDomainURL -> WcAppInfoRoutes.Alert.Type.InvalidDomain - is WcPairError.UnsupportedDApp -> WcAppInfoRoutes.Alert.Type.UnsupportedDApp(error.appName) - is WcPairError.UnsupportedBlockchains -> WcAppInfoRoutes.Alert.Type.UnsupportedNetwork(error.appName) - is WcPairError.UriAlreadyUsed -> WcAppInfoRoutes.Alert.Type.UriAlreadyUsed - is WcPairError.TimeoutException -> WcAppInfoRoutes.Alert.Type.TimeoutException + is WcPairError.InvalidDomainURL -> WcAppInfoRoutes.Alert.InvalidDomain + is WcPairError.UnsupportedDApp -> WcAppInfoRoutes.Alert.UnsupportedDApp(error.appName) + is WcPairError.UnsupportedBlockchains -> WcAppInfoRoutes.Alert.UnsupportedNetwork(error.appName) + is WcPairError.UriAlreadyUsed -> WcAppInfoRoutes.Alert.UriAlreadyUsed + is WcPairError.TimeoutException -> WcAppInfoRoutes.Alert.TimeoutException else -> { messageSender.send(ToastMessage(message = stringReference(error.message))) router.pop() null } } - alert?.let { stackNavigation.pushNew(WcAppInfoRoutes.Alert(it)) } + alert?.let { stackNavigation.pushNew(it) } } override fun onWalletSelected(userWalletId: UserWalletId) { diff --git a/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/connections/routes/WcAppInfoRoutes.kt b/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/connections/routes/WcAppInfoRoutes.kt index 784e0457ed..c83d3cd1a1 100644 --- a/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/connections/routes/WcAppInfoRoutes.kt +++ b/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/connections/routes/WcAppInfoRoutes.kt @@ -2,14 +2,13 @@ package com.tangem.features.walletconnect.connections.routes import androidx.compose.runtime.Immutable import com.tangem.core.decompose.navigation.Route -import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfigContent import com.tangem.domain.models.network.Network import com.tangem.domain.models.wallet.UserWalletId import kotlinx.serialization.Serializable @Serializable @Immutable -internal sealed class WcAppInfoRoutes : TangemBottomSheetConfigContent, Route { +internal sealed class WcAppInfoRoutes : Route { @Serializable data object AppInfo : WcAppInfoRoutes() @@ -26,17 +25,21 @@ internal sealed class WcAppInfoRoutes : TangemBottomSheetConfigContent, Route { ) : WcAppInfoRoutes() @Serializable - data class Alert(val alertType: Type) : WcAppInfoRoutes() { - @Serializable - sealed class Type { - data class Verified(val appName: String) : Type() - data object UnknownDomain : Type() - data object UnsafeDomain : Type() - data object InvalidDomain : Type() - data class UnsupportedDApp(val appName: String) : Type() - data class UnsupportedNetwork(val appName: String) : Type() - data object UriAlreadyUsed : Type() - data object TimeoutException : Type() - } + sealed class Alert : WcAppInfoRoutes() { + @Serializable data class Verified(val appName: String) : Alert() + + @Serializable data object UnknownDomain : Alert() + + @Serializable data object UnsafeDomain : Alert() + + @Serializable data object InvalidDomain : Alert() + + @Serializable data class UnsupportedDApp(val appName: String) : Alert() + + @Serializable data class UnsupportedNetwork(val appName: String) : Alert() + + @Serializable data object UriAlreadyUsed : Alert() + + @Serializable data object TimeoutException : Alert() } } \ No newline at end of file From ae809ece30ac3aa761e5bc042eea518f222a07d1 Mon Sep 17 00:00:00 2001 From: Tangem Date: Sun, 24 Aug 2025 14:16:45 +0500 Subject: [PATCH 02/19] Updated on 2026-08-14 --- app/src/main/AndroidManifest.xml | 11 + .../tap/routing/utils/DeepLinkFactory.kt | 3 + .../tap/routing/utils/DeepLinkFactoryTest.kt | 33 +- .../tangem/common/routing/DeepLinkRoute.kt | 4 + .../common/routing/deeplink/DeeplinkConst.kt | 1 + .../api/tangemTech/TangemTechApi.kt | 5 + .../models/PromocodeActivationBody.kt | 10 + .../models/PromocodeActivationResponse.kt | 9 + core/res/src/main/res/values-es/strings.xml | 8 +- core/res/src/main/res/values-ja/strings.xml | 12 +- core/res/src/main/res/values-ru/strings.xml | 10 + .../src/main/res/values-uk-rUA/strings.xml | 10 + core/res/src/main/res/values/strings.xml | 40 +++ .../com/tangem/core/ui/extensions/String.kt | 25 ++ .../tangem/core/ui/message/EventMessage.kt | 2 + .../core/ui/message/EventMessageEffect.kt | 47 +++ .../core/ui/extensions/StringMaskTest.kt | 91 +++++ .../data/wallets/DefaultWalletsRepository.kt | 30 ++ .../wallets/DefaultWalletsRepositoryTest.kt | 64 ++++ .../wallets/PromoCodeActivationResult.kt | 9 + .../models/errors/ActivatePromoCodeError.kt | 12 + .../wallets/repository/WalletsRepository.kt | 4 + .../ActivateBitcoinPromocodeUseCase.kt | 14 + .../wallet/deeplink/PromoDeeplinkHandler.kt | 10 + features/wallet/impl/build.gradle.kts | 6 +- .../deeplink/DefaultPromoDeeplinkHandler.kt | 142 ++++++++ .../deeplink/di/WalletDeepLinkModule.kt | 6 + .../DefaultPromoDeeplinkHandlerTest.kt | 331 ++++++++++++++++++ 28 files changed, 939 insertions(+), 10 deletions(-) create mode 100644 core/datasource/src/main/java/com/tangem/datasource/api/tangemTech/models/PromocodeActivationBody.kt create mode 100644 core/datasource/src/main/java/com/tangem/datasource/api/tangemTech/models/PromocodeActivationResponse.kt create mode 100644 core/ui/src/test/java/com/tangem/core/ui/extensions/StringMaskTest.kt create mode 100644 domain/wallets/models/src/main/java/com/tangem/domain/wallets/PromoCodeActivationResult.kt create mode 100644 domain/wallets/models/src/main/java/com/tangem/domain/wallets/models/errors/ActivatePromoCodeError.kt create mode 100644 domain/wallets/src/main/java/com/tangem/domain/wallets/usecase/ActivateBitcoinPromocodeUseCase.kt create mode 100644 features/wallet/api/src/main/kotlin/com/tangem/features/wallet/deeplink/PromoDeeplinkHandler.kt create mode 100644 features/wallet/impl/src/main/java/com/tangem/feature/wallet/deeplink/DefaultPromoDeeplinkHandler.kt create mode 100644 features/wallet/impl/src/test/kotlin/com/tangem/feature/wallet/presentation/wallet/deeplink/DefaultPromoDeeplinkHandlerTest.kt 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 From ab72c61f5f184730b6f017e90f10c501a1bee391 Mon Sep 17 00:00:00 2001 From: Tangem Date: Mon, 25 Aug 2025 15:30:37 +0500 Subject: [PATCH 03/19] Updated on 2026-08-14 --- .../deeplink/DefaultPromoDeeplinkHandler.kt | 6 ++++ .../analytics/PromoActivationAnalytics.kt | 28 +++++++++++++++++++ 2 files changed, 34 insertions(+) create mode 100644 features/wallet/impl/src/main/java/com/tangem/feature/wallet/deeplink/analytics/PromoActivationAnalytics.kt 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 index bb524e4d59..c927aa7000 100644 --- 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 @@ -2,6 +2,7 @@ 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 @@ -15,6 +16,7 @@ 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 @@ -25,6 +27,7 @@ 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, @@ -32,9 +35,11 @@ internal class DefaultPromoDeeplinkHandler @AssistedInject constructor( 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) @@ -103,6 +108,7 @@ internal class DefaultPromoDeeplinkHandler @AssistedInject constructor( } 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) diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/deeplink/analytics/PromoActivationAnalytics.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/deeplink/analytics/PromoActivationAnalytics.kt new file mode 100644 index 0000000000..9bcd32f457 --- /dev/null +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/deeplink/analytics/PromoActivationAnalytics.kt @@ -0,0 +1,28 @@ +package com.tangem.feature.wallet.deeplink.analytics + +import com.tangem.core.analytics.models.AnalyticsEvent +import com.tangem.domain.wallets.PromoCodeActivationResult + +sealed class PromoActivationAnalytics( + event: String, + params: Map = mapOf(), +) : AnalyticsEvent(category = "Promotion", event = event, params = params) { + + data object PromoDeepLinkActivationStart : PromoActivationAnalytics( + event = "Bitcoin Promo Deep Link Activation", + params = emptyMap(), + ) + + data class PromoActivation(val result: PromoCodeActivationResult) : PromoActivationAnalytics( + event = "Bitcoin Promo Activation", + params = mapOf( + "State" to when (result) { + PromoCodeActivationResult.Failed -> "Error" + PromoCodeActivationResult.InvalidPromoCode -> "Invalid" + PromoCodeActivationResult.NoBitcoinAddress -> "No Address " + PromoCodeActivationResult.PromoCodeAlreadyUsed -> "Already Use" + PromoCodeActivationResult.Activated -> "Activated" + }, + ), + ) +} \ No newline at end of file From b152c36c19e7ace4fdf33454ab5d6e5919bda09a Mon Sep 17 00:00:00 2001 From: Tangem Date: Mon, 25 Aug 2025 16:05:23 +0500 Subject: [PATCH 04/19] Updated on 2026-08-14 --- .../DefaultPromoDeeplinkHandlerTest.kt | 77 +++++++++++++++++++ 1 file changed, 77 insertions(+) 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 index 4733150631..e355a21829 100644 --- 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 @@ -4,6 +4,7 @@ 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 @@ -22,6 +23,7 @@ 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.CapturingSlot import io.mockk.MockKAnnotations @@ -32,6 +34,7 @@ import io.mockk.just import io.mockk.mockk import io.mockk.runs import io.mockk.slot +import io.mockk.verify import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.cancel @@ -47,6 +50,7 @@ import org.junit.Before import org.junit.Test import timber.log.Timber import java.math.BigDecimal +import com.tangem.domain.wallets.PromoCodeActivationResult class DefaultPromoDeeplinkHandlerTest { @@ -62,6 +66,9 @@ class DefaultPromoDeeplinkHandlerTest { @MockK private lateinit var getSelectedWalletSyncUseCase: GetSelectedWalletSyncUseCase + @MockK + private lateinit var analyticsEventHandler: AnalyticsEventHandler + private lateinit var messageSlot: CapturingSlot private lateinit var testDispatcher: TestDispatcher private lateinit var testScope: TestScope @@ -75,6 +82,7 @@ class DefaultPromoDeeplinkHandlerTest { Dispatchers.setMain(testDispatcher) MockKAnnotations.init(this) + every { analyticsEventHandler.send(any()) } returns Unit messageSlot = slot() every { uiMessageSender.send(capture(messageSlot)) } just runs @@ -99,11 +107,19 @@ class DefaultPromoDeeplinkHandlerTest { 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 @@ -118,11 +134,19 @@ class DefaultPromoDeeplinkHandlerTest { 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 @@ -141,6 +165,7 @@ class DefaultPromoDeeplinkHandlerTest { getMultiCryptoCurrencyStatusUseCase = getMultiCryptoCurrencyStatusUseCase, activateBitcoinPromocodeUseCase = activateBitcoinPromocodeUseCase, getSelectedWalletSyncUseCase = getSelectedWalletSyncUseCase, + analyticsEventsHandler = analyticsEventHandler, ) advanceUntilIdle() @@ -148,6 +173,13 @@ class DefaultPromoDeeplinkHandlerTest { 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 @@ -168,6 +200,7 @@ class DefaultPromoDeeplinkHandlerTest { getMultiCryptoCurrencyStatusUseCase = getMultiCryptoCurrencyStatusUseCase, activateBitcoinPromocodeUseCase = activateBitcoinPromocodeUseCase, getSelectedWalletSyncUseCase = getSelectedWalletSyncUseCase, + analyticsEventsHandler = analyticsEventHandler, ) advanceUntilIdle() @@ -175,6 +208,13 @@ class DefaultPromoDeeplinkHandlerTest { 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 @@ -196,6 +236,7 @@ class DefaultPromoDeeplinkHandlerTest { getMultiCryptoCurrencyStatusUseCase = getMultiCryptoCurrencyStatusUseCase, activateBitcoinPromocodeUseCase = activateBitcoinPromocodeUseCase, getSelectedWalletSyncUseCase = getSelectedWalletSyncUseCase, + analyticsEventsHandler = analyticsEventHandler, ) advanceUntilIdle() @@ -203,6 +244,13 @@ class DefaultPromoDeeplinkHandlerTest { 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 @@ -212,6 +260,13 @@ class DefaultPromoDeeplinkHandlerTest { 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 @@ -221,6 +276,13 @@ class DefaultPromoDeeplinkHandlerTest { 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 @@ -230,6 +292,13 @@ class DefaultPromoDeeplinkHandlerTest { 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 @@ -239,6 +308,13 @@ class DefaultPromoDeeplinkHandlerTest { 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( @@ -263,6 +339,7 @@ class DefaultPromoDeeplinkHandlerTest { getMultiCryptoCurrencyStatusUseCase = getMultiCryptoCurrencyStatusUseCase, activateBitcoinPromocodeUseCase = activateBitcoinPromocodeUseCase, getSelectedWalletSyncUseCase = getSelectedWalletSyncUseCase, + analyticsEventsHandler = analyticsEventHandler, ) advanceUntilIdle() From b7bea8357df1e7524619f48a922d0fe88ad7a8fd Mon Sep 17 00:00:00 2001 From: Tangem Date: Tue, 26 Aug 2025 10:16:07 +0500 Subject: [PATCH 05/19] Updated on 2026-08-14 --- core/res/src/main/res/values-fr/strings.xml | 10 +++++ core/res/src/main/res/values-ja/strings.xml | 43 ++++++++++++++++++--- core/res/src/main/res/values/strings.xml | 4 +- 3 files changed, 50 insertions(+), 7 deletions(-) diff --git a/core/res/src/main/res/values-fr/strings.xml b/core/res/src/main/res/values-fr/strings.xml index e907d8df01..97e1096155 100644 --- a/core/res/src/main/res/values-fr/strings.xml +++ b/core/res/src/main/res/values-fr/strings.xml @@ -60,6 +60,16 @@ Veuillez réessayer dans 30 secondes ou scannez la carte/bague Trop de tentatives 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. + Une erreur s\'est produite lors du traitement de votre code promo. Veuillez réessayer plus tard. + Erreur d\'activation + Votre code promo a été activé avec succès. Un bonus de 10 USDT en Bitcoin sera crédité dans 14 jours. + Code promo activé + Ce code promo a déjà été utilisé et ne peut pas être activé à nouveau. + Code indisponible + Ce code promo n\'est pas valide et ne peut pas être activé. + Code invalide + Une adresse Bitcoin est nécessaire pour recevoir le bonus. Veuillez en ajouter une à votre portefeuille et réessayer l\'activation. + Adresse Bitcoin requise Démarrer le processus de sauvegarde Utilisez une carte bancaire ou d\'autres moyens de paiement diff --git a/core/res/src/main/res/values-ja/strings.xml b/core/res/src/main/res/values-ja/strings.xml index 4c5d34d274..0b8555b8da 100644 --- a/core/res/src/main/res/values-ja/strings.xml +++ b/core/res/src/main/res/values-ja/strings.xml @@ -12,8 +12,12 @@ ウォレットのロックを解除するには、 %s桁のアクセスコードを設定します。 アクセスコードの作成 アクセスコード + %1$s個を超えるアカウントは作成できません。新しいアカウントを追加するには、1つをアーカイブしてください。 + 新しいアカウントを追加できません アーカイブされたアカウント 回復する + 「 %1$s 」を回復しようとしています。 + アカウントを回復する アーカイブ済み アカウントをアーカイブする アーカイブ @@ -27,6 +31,7 @@ 新しいアカウント アカウントを追加 アカウントを編集 + %1$s ( %2$s内) アカウントを長押しして並べ替える 編集を続ける 破棄 @@ -73,8 +78,10 @@ 設定に移動して、Tangemアプリで生体認証を有効にします。 生体認証を有効にする %1$sが無効になると、アプリのロックを解除してウォレットを操作するために、パスコードを入力する必要があります。 + 後でウォレットのアクセスコードを入力してもらいます。それを安全に保存し、今後の利用に備えるためです。 これにより、保存されているウォレットアクセスコードがすべて削除されます。ウォレットでの今後の操作には、アクセスコードの送信が必要になります。 保存したデバイスを削除すると、保存されているすべてのウォレットとそのアクセスコードがアプリから削除されます。 + これにより、保存されているウォレットアクセスコードがすべて削除されます。今後ウォレットを操作するには、アクセスコードの送信が必要になります。 アクセスコードを要求する このオプションを選択すると、機密性の高い操作における生体認証が無効になります。取引の署名時などには、毎回アクセスコードの入力が必要になります。 アクセスコードを保存 @@ -117,11 +124,15 @@ 試行回数が多すぎます お使いの携帯電話で生体認証が無効になっているため、アプリにウォレットを保存できません。ウォレットを保存するには、携帯電話の設定で生体認証機能を有効にしてください。 プロモーションコードの処理中にエラーが発生しました。しばらくしてからもう一度お試しください。 + アクティベーションエラー プロモーションコードが正常に有効化されました。14日以内に10 USDT相当のビットコインボーナスが付与されます。 プロモーションコードが有効になりました このプロモーションコードは既に使用されているため、再度使うことはできません。 + コードが利用できません このプロモーションコードは無効であり、有効化できません。 + 無効なコード ボーナスを受け取るにはビットコインアドレスが必要です。ウォレットにビットコインアドレスを追加し、再度アクティベーションをお試しください。 + ビットコインアドレスが必要です バックアップ処理を開始する 銀行カードまたはその他の支払い方法を使用する @@ -241,6 +252,9 @@ ネットワーク手数料 送金額は、選択された手数料レベルをカバーするため、%1$s (%2$s) 減額されます。 + + %dネットワーク + NFT いいえ @@ -367,8 +381,8 @@ 他のネットワークで資産を送金すると、永久に失われます。 %sネットワーク 下記のみを使用して資金を送金する - サポートチームの皆様、コード %s のエラーが発生しました。 - WalletConnect エラー + こんにちは、サポートチームの皆さん、コード %s のエラーが発生しました。 + WalletConnectエラー 別のウォレットのカードまたはリングを使用しました。このウォレットにリンクしているカードまたはリングをタップしてください。 取引に必要な資金が不足しています。アカウントに入金してください。 マイトークン @@ -485,6 +499,8 @@ 実行すると、最初からやり直す必要があります。 Googleドライブのバックアップに保存されている既存のウォレットを復元する Googleドライブのバックアップ + Tangemの業界最高水準のハードウェアウォレットで、今すぐセキュリティをアップグレードしましょう。 + ハードウェアウォレット バックアップへ移動 アクセスコードを使用してウォレットを保護するには、まずバックアップを完了してください。 まずバックアップを完了する @@ -496,7 +512,21 @@ 最新の機能とニュースをお届けします シードフレーズのバックアップ モバイルウォレットを作成する + このリカバリーフレーズはすでにインポートされています。 モバイルウォレット + 手続き中も資金は安全に保管され、完全にアクセス可能です + 資金へのアクセス + すべてのプライベートウォレットデータはモバイルアプリから削除され、Tangemデバイスにのみ安全に保存されます。 + セキュリティ全般 + 秘密鍵は、アプリからTangemカード・リングに移動します + 鍵の移行 + デバイスをスキャン + アップグレードを開始 + ウォレットをTangemウォレットにアップグレードします。これにより、コールドストレージで資産を安全に保管できます。 + Tangemウォレット + ハードウェアウォレットにアップグレード + Tangemの業界最高水準のハードウェアウォレットで、暗号資産を安全に保管しましょう。 + ハードウェアバックアップでウォレットをアップグレード この情報はAIで生成されました。 \nエラーが見つかった場合は、ここをタップしてください。 アクセスコードを変更するには、上図のようにカードまたはリングをタップし、操作が終了するまで取り外さないでください。 パスコードを変更するには、上記のようにカードをタップし、操作が終了するまで取り外さないでください。 @@ -696,6 +726,9 @@ 今すぐ参加 コードを共有すると、販売ごとに5 USDTを獲得できます。お友達は10%割引になります。 友達への紹介で報酬を獲得しよう! + 暗号資産を買い付ける + SEPA送金で暗号資産を買い付けると、手数料はかかりません。 + SEPAで暗号資産を買い付ける すべてのデバイスを保護するには、単一のアクセスコードを設定してください。 保護する 後で各カードおよびリングに個別のアクセスコードを設定できます。 @@ -1287,10 +1320,10 @@ Dapp%1$s 、BNB取引の署名を要求しています\n\n%2$s %1$sの取引注文\n価格: %2$s\n受取金額: %3$s\n支払金額: %4$s 取引の詳細:\n送信元: %1$s\n受取先: %2$s\n量: %3$s - クリップボードには WalletConnect コードが含まれています。コピーした値を使用するか、QRコードをスキャンしてください。 + クリップボードには WalletConnectコードが含まれています。コピーした値を使用するか、QRコードをスキャンしてください。 %1$sの取引を作成するリクエスト\n%2$s\n\n金額: %3$s\n手数料: %4$s\n合計: %5$s\n残高: %6$s 取引を送信できません。資金が足りません。 - WalletConnect セッションを確立できませんでした。しばらくしてからもう一度お試しください。 + WalletConnectセッションを確立できませんでした。しばらくしてからもう一度お試しください。 すべてのトークンがリストには追加されませんでした。まず追加してから、もう一度お試しください。不足しているトークン: \n メッセージの署名に失敗しました。\nもう一度お試しください。 WalletConnectセッションの確立に失敗しました:タイムアウトエラー。しばらくしてもう一度お試しください。 @@ -1469,7 +1502,7 @@ サポートされていないdApp エラーコード: 8 005。問題が解決しない場合は、お気軽にサポートまでお問い合わせください。 不明なエラーが発生しました - このネットワーク %s はTangem Walletでサポートされておらず、接続できません。 + このネットワーク%sはTangemウォレットでサポートされておらず、接続できません。 サポートされていないネットワーク Tangemは現在%sで必要なネットワークをサポートしていません。 未対応のネットワーク diff --git a/core/res/src/main/res/values/strings.xml b/core/res/src/main/res/values/strings.xml index 8387e25d6a..c69bcd90ed 100644 --- a/core/res/src/main/res/values/strings.xml +++ b/core/res/src/main/res/values/strings.xml @@ -126,8 +126,8 @@ 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 + Your promo code was successfully activated. A bonus of 10 USDT in Bitcoin will be credited to your account within 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. From a378cb57478f40fc013a2b529ad8f60ccd5a0df6 Mon Sep 17 00:00:00 2001 From: Tangem Date: Mon, 25 Aug 2025 15:26:54 +0500 Subject: [PATCH 06/19] Updated on 2026-08-14 --- .../send/WcSendTransactionComponent.kt | 48 ++++++++++++------- 1 file changed, 30 insertions(+), 18 deletions(-) diff --git a/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/transaction/components/send/WcSendTransactionComponent.kt b/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/transaction/components/send/WcSendTransactionComponent.kt index 62f838c9b7..5e78c040a8 100644 --- a/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/transaction/components/send/WcSendTransactionComponent.kt +++ b/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/transaction/components/send/WcSendTransactionComponent.kt @@ -19,27 +19,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, - ), - 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) } } } @@ -54,8 +40,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, @@ -67,4 +57,26 @@ 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, + ), + onResult = model::updateFee, + ) + feeSelectorBlockComponent = component + component + } + } } \ No newline at end of file From 8c926b56262056724bb7ed870c484215b9ba63ce Mon Sep 17 00:00:00 2001 From: Tangem Date: Tue, 26 Aug 2025 21:50:18 +0700 Subject: [PATCH 07/19] Updated on 2026-08-14 --- .../domain/walletconnect/model/WcEthMethod.kt | 2 +- .../model/WcEthSignTypedDataParams.kt | 58 +------------------ 2 files changed, 3 insertions(+), 57 deletions(-) diff --git a/domain/wallet-connect/models/src/main/java/com/tangem/domain/walletconnect/model/WcEthMethod.kt b/domain/wallet-connect/models/src/main/java/com/tangem/domain/walletconnect/model/WcEthMethod.kt index 34b43e11bb..0157a3dc6c 100644 --- a/domain/wallet-connect/models/src/main/java/com/tangem/domain/walletconnect/model/WcEthMethod.kt +++ b/domain/wallet-connect/models/src/main/java/com/tangem/domain/walletconnect/model/WcEthMethod.kt @@ -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( diff --git a/domain/wallet-connect/models/src/main/java/com/tangem/domain/walletconnect/model/WcEthSignTypedDataParams.kt b/domain/wallet-connect/models/src/main/java/com/tangem/domain/walletconnect/model/WcEthSignTypedDataParams.kt index b14e6af6df..60ff88d729 100644 --- a/domain/wallet-connect/models/src/main/java/com/tangem/domain/walletconnect/model/WcEthSignTypedDataParams.kt +++ b/domain/wallet-connect/models/src/main/java/com/tangem/domain/walletconnect/model/WcEthSignTypedDataParams.kt @@ -5,60 +5,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>, -) { - @JsonClass(generateAdapter = true) - data class Domain( - @Json(name = "chainId") - val chainId: Int?, - @Json(name = "name") - val name: String?, - @Json(name = "verifyingContract") - val verifyingContract: String?, - @Json(name = "version") - val version: String?, - ) - - @JsonClass(generateAdapter = true) - data class Message( - @Json(name = "contents") - val contents: String?, - @Json(name = "from") - val from: Address?, - @Json(name = "to") - val to: Address?, - ) { - @JsonClass(generateAdapter = true) - data class Address( - @Json(name = "name") - val name: String, - @Json(name = "wallet") - val wallet: String, - ) - } - - @JsonClass(generateAdapter = true) - data class Types( - @Json(name = "EIP712Domain") - val eIP712Domain: List = listOf(), - @Json(name = "Mail") - val mail: List = listOf(), - @Json(name = "Person") - val person: List = listOf(), - ) { - @JsonClass(generateAdapter = true) - data class Type( - @Json(name = "name") - val name: String, - @Json(name = "type") - val type: String, - ) - } -} \ No newline at end of file + val message: String?, +) \ No newline at end of file From f2e2bfe1875e4fc9a7aaed2744839ab96c83133c Mon Sep 17 00:00:00 2001 From: Tangem Date: Tue, 26 Aug 2025 20:40:42 +0300 Subject: [PATCH 08/19] Updated on 2026-08-14 --- app/src/main/assets/tangem-app-config | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/main/assets/tangem-app-config b/app/src/main/assets/tangem-app-config index 3ac868e93f..d3ba082fad 160000 --- a/app/src/main/assets/tangem-app-config +++ b/app/src/main/assets/tangem-app-config @@ -1 +1 @@ -Subproject commit 3ac868e93f88498258867d457f8b8c4577b40f98 +Subproject commit d3ba082fadbdb583a45cc1c714c57b382cbbe6ef From f1085eea2f65963a0d85a693cc94aeb429aa3610 Mon Sep 17 00:00:00 2001 From: Tangem Date: Tue, 26 Aug 2025 20:42:33 +0300 Subject: [PATCH 09/19] Updated on 2026-08-14 --- app/src/main/assets/tangem-app-config | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/main/assets/tangem-app-config b/app/src/main/assets/tangem-app-config index d3ba082fad..7d225a195e 160000 --- a/app/src/main/assets/tangem-app-config +++ b/app/src/main/assets/tangem-app-config @@ -1 +1 @@ -Subproject commit d3ba082fadbdb583a45cc1c714c57b382cbbe6ef +Subproject commit 7d225a195eb001f9f4ce88aa4a6fa2d965b9159c From 68255809a6b12bf8b02d1a9903a582ee863e4a53 Mon Sep 17 00:00:00 2001 From: Tangem Date: Thu, 28 Aug 2025 17:17:14 +0700 Subject: [PATCH 10/19] Updated on 2026-08-14 --- .../walletconnect/pair/DefaultWcPairService.kt | 14 ++++++++++++-- .../domain/walletconnect/model/WcEthMethod.kt | 2 +- .../model/WcEthSignTypedDataParams.kt | 10 ++++++++-- 3 files changed, 21 insertions(+), 5 deletions(-) diff --git a/data/wallet-connect/src/main/kotlin/com/tangem/data/walletconnect/pair/DefaultWcPairService.kt b/data/wallet-connect/src/main/kotlin/com/tangem/data/walletconnect/pair/DefaultWcPairService.kt index d0749f0362..8a49571b5c 100644 --- a/data/wallet-connect/src/main/kotlin/com/tangem/data/walletconnect/pair/DefaultWcPairService.kt +++ b/data/wallet-connect/src/main/kotlin/com/tangem/data/walletconnect/pair/DefaultWcPairService.kt @@ -1,6 +1,5 @@ package com.tangem.data.walletconnect.pair -import androidx.core.net.toUri import com.tangem.domain.walletconnect.WcPairService import com.tangem.domain.walletconnect.model.WcPairRequest import com.tangem.domain.walletconnect.repository.WcSessionsManager @@ -38,8 +37,19 @@ class DefaultWcPairService @Inject constructor( } private suspend fun existSessionTopic(uri: String) = runCatching { - val sessionTopic = uri.toUri().getQueryParameter("sessionTopic") ?: return@runCatching false + val deeplinkRegex = Regex(WC_PARAM_REGEX) + val matched = deeplinkRegex.findAll(uri) + val sessionTopic = matched + .firstOrNull { it.value.contains(WC_TOPIC_QUERY_NAME) } + ?.groupValues + ?.lastOrNull() + ?: return@runCatching false val isExistSession = sessionsManager.findSessionByTopic(sessionTopic) != null return@runCatching isExistSession } + + private companion object { + const val WC_TOPIC_QUERY_NAME = "sessionTopic" + const val WC_PARAM_REGEX = "([a-zA-Z\\d-]+)=([a-zA-Z\\d]+)" + } } \ No newline at end of file diff --git a/domain/wallet-connect/models/src/main/java/com/tangem/domain/walletconnect/model/WcEthMethod.kt b/domain/wallet-connect/models/src/main/java/com/tangem/domain/walletconnect/model/WcEthMethod.kt index 0157a3dc6c..34b43e11bb 100644 --- a/domain/wallet-connect/models/src/main/java/com/tangem/domain/walletconnect/model/WcEthMethod.kt +++ b/domain/wallet-connect/models/src/main/java/com/tangem/domain/walletconnect/model/WcEthMethod.kt @@ -13,7 +13,7 @@ sealed interface WcEthMethod : WcMethod { val account: String, val dataForSign: String, ) : WcEthMethod { - val humanMsg: String = params.message.orEmpty() + val humanMsg: String = params.message?.contents.orEmpty() } data class SendTransaction( diff --git a/domain/wallet-connect/models/src/main/java/com/tangem/domain/walletconnect/model/WcEthSignTypedDataParams.kt b/domain/wallet-connect/models/src/main/java/com/tangem/domain/walletconnect/model/WcEthSignTypedDataParams.kt index 60ff88d729..193a13c172 100644 --- a/domain/wallet-connect/models/src/main/java/com/tangem/domain/walletconnect/model/WcEthSignTypedDataParams.kt +++ b/domain/wallet-connect/models/src/main/java/com/tangem/domain/walletconnect/model/WcEthSignTypedDataParams.kt @@ -6,5 +6,11 @@ import com.squareup.moshi.JsonClass @JsonClass(generateAdapter = true) data class WcEthSignTypedDataParams( @Json(name = "message") - val message: String?, -) \ No newline at end of file + val message: Message?, +) { + @JsonClass(generateAdapter = true) + data class Message( + @Json(name = "contents") + val contents: String?, + ) +} \ No newline at end of file From 1edd1086529b08b5dccc3c8c1b826e34345c33bd Mon Sep 17 00:00:00 2001 From: Tangem Date: Wed, 27 Aug 2025 21:44:24 +0500 Subject: [PATCH 11/19] Updated on 2026-08-14 --- .../tap/routing/utils/DeepLinkFactory.kt | 1 + .../deeplink/DefaultPromoDeeplinkHandler.kt | 50 +++- .../DefaultPromoDeeplinkHandlerTest.kt | 240 +++++++++--------- 3 files changed, 163 insertions(+), 128 deletions(-) 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 e5c704d74a..82ab8a2925 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 @@ -89,6 +89,7 @@ internal class DeepLinkFactory @Inject constructor( AppRoute.Initial, AppRoute.Home, is AppRoute.Welcome, + is AppRoute.PushNotification, is AppRoute.Disclaimer, is AppRoute.Stories, is AppRoute.Onboarding, 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 index c927aa7000..084ff6b3e5 100644 --- 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 @@ -9,8 +9,10 @@ import com.tangem.core.ui.extensions.mask import com.tangem.core.ui.extensions.resourceReference import com.tangem.core.ui.message.DialogMessage import com.tangem.core.ui.message.GlobalLoadingMessage +import com.tangem.domain.models.network.NetworkStatus import com.tangem.domain.models.wallet.UserWallet -import com.tangem.domain.tokens.GetMultiCryptoCurrencyStatusUseCase +import com.tangem.domain.networks.multi.MultiNetworkStatusProducer +import com.tangem.domain.networks.multi.MultiNetworkStatusSupplier import com.tangem.domain.wallets.PromoCodeActivationResult import com.tangem.domain.wallets.PromoCodeActivationResult.* import com.tangem.domain.wallets.models.errors.ActivatePromoCodeError @@ -19,23 +21,28 @@ import com.tangem.domain.wallets.usecase.GetSelectedWalletSyncUseCase import com.tangem.feature.wallet.deeplink.analytics.PromoActivationAnalytics import com.tangem.feature.wallet.impl.R import com.tangem.features.wallet.deeplink.PromoDeeplinkHandler +import com.tangem.utils.coroutines.CoroutineDispatcherProvider import dagger.assisted.Assisted import dagger.assisted.AssistedFactory import dagger.assisted.AssistedInject import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch +import kotlinx.coroutines.withTimeoutOrNull import timber.log.Timber +import kotlin.time.Duration.Companion.seconds @Suppress("LongParameterList") internal class DefaultPromoDeeplinkHandler @AssistedInject constructor( @Assisted private val scope: CoroutineScope, @Assisted private val queryParams: Map, @GlobalUiMessageSender private val uiMessageSender: UiMessageSender, - private val getMultiCryptoCurrencyStatusUseCase: GetMultiCryptoCurrencyStatusUseCase, + private val multiNetworkStatusSupplier: MultiNetworkStatusSupplier, private val activateBitcoinPromocodeUseCase: ActivateBitcoinPromocodeUseCase, private val getSelectedWalletSyncUseCase: GetSelectedWalletSyncUseCase, private val analyticsEventsHandler: AnalyticsEventHandler, + private val dispatchers: CoroutineDispatcherProvider, ) : PromoDeeplinkHandler { init { @@ -62,13 +69,30 @@ internal class DefaultPromoDeeplinkHandler @AssistedInject constructor( } 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 + scope.launch(context = dispatchers.default) { + val bitcoinStatus = withTimeoutOrNull( + FETCH_TIMEOUT_SECONDS.seconds, + { + multiNetworkStatusSupplier + .invoke(MultiNetworkStatusProducer.Params(userWallet.walletId)) + .first { statuses -> + statuses.any { status -> status.network.rawId == Blockchain.Bitcoin.id } + }.first { status -> status.network.rawId == Blockchain.Bitcoin.id } + }, + ) + + if (bitcoinStatus == null) { + Timber.tag(LOG_TAG).d("No bitcoin, bitcoin network status == null") + showAlert(NoBitcoinAddress) + } else { + val networkAddress = when (bitcoinStatus.value) { + is NetworkStatus.Verified -> (bitcoinStatus.value as NetworkStatus.Verified).address + is NetworkStatus.NoAccount -> (bitcoinStatus.value as NetworkStatus.NoAccount).address + else -> null + } + + val bitcoinAddress = networkAddress + ?.defaultAddress?.value if (bitcoinAddress != null) { Timber.tag(LOG_TAG).d( @@ -76,12 +100,11 @@ internal class DefaultPromoDeeplinkHandler @AssistedInject constructor( ) activatePromoCode(bitcoinAddress = bitcoinAddress, promoCode = promoCode) } else { - Timber.tag(LOG_TAG).d("no Bitcoin address") + uiMessageSender.send(GlobalLoadingMessage(false)) + delay(DEFAULT_MESSAGE_SENDER_DELAY) + Timber.tag(LOG_TAG).d("No Bitcoin address $bitcoinStatus.value") showAlert(NoBitcoinAddress) } - }.onLeft { - Timber.tag(LOG_TAG).e("Error on getting userWallet currencies: $it") - showAlert(Failed) } } } @@ -144,5 +167,6 @@ internal class DefaultPromoDeeplinkHandler @AssistedInject constructor( companion object { private const val LOG_TAG = "PromoCodeActivation" private const val DEFAULT_MESSAGE_SENDER_DELAY = 500L + private const val FETCH_TIMEOUT_SECONDS = 5 } } \ No newline at end of file diff --git a/features/wallet/impl/src/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 index e355a21829..024a9415a4 100644 --- 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 @@ -10,14 +10,13 @@ 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.StatusSource import com.tangem.domain.models.network.Network import com.tangem.domain.models.network.NetworkAddress +import com.tangem.domain.models.network.NetworkStatus import com.tangem.domain.models.wallet.UserWallet import com.tangem.domain.models.wallet.UserWalletId -import com.tangem.domain.tokens.GetMultiCryptoCurrencyStatusUseCase -import com.tangem.domain.tokens.error.TokenListError -import com.tangem.domain.tokens.model.CryptoCurrencyStatus +import com.tangem.domain.networks.multi.MultiNetworkStatusSupplier import com.tangem.domain.wallets.models.GetUserWalletError import com.tangem.domain.wallets.models.errors.ActivatePromoCodeError import com.tangem.domain.wallets.usecase.ActivateBitcoinPromocodeUseCase @@ -25,7 +24,6 @@ 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.CapturingSlot import io.mockk.MockKAnnotations import io.mockk.coEvery import io.mockk.every @@ -33,32 +31,31 @@ import io.mockk.impl.annotations.MockK import io.mockk.just import io.mockk.mockk import io.mockk.runs -import io.mockk.slot import io.mockk.verify -import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.cancel +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.test.StandardTestDispatcher -import kotlinx.coroutines.test.TestDispatcher -import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.TestCoroutineScheduler +import kotlinx.coroutines.test.advanceTimeBy 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 import com.tangem.domain.wallets.PromoCodeActivationResult +import com.tangem.utils.coroutines.CoroutineDispatcherProvider +import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider +@OptIn(ExperimentalCoroutinesApi::class) class DefaultPromoDeeplinkHandlerTest { @MockK(relaxed = true) private lateinit var uiMessageSender: UiMessageSender @MockK - private lateinit var getMultiCryptoCurrencyStatusUseCase: GetMultiCryptoCurrencyStatusUseCase + private lateinit var multiNetworkStatusSupplier: MultiNetworkStatusSupplier @MockK private lateinit var activateBitcoinPromocodeUseCase: ActivateBitcoinPromocodeUseCase @@ -69,31 +66,60 @@ class DefaultPromoDeeplinkHandlerTest { @MockK private lateinit var analyticsEventHandler: AnalyticsEventHandler - private lateinit var messageSlot: CapturingSlot - private lateinit var testDispatcher: TestDispatcher - private lateinit var testScope: TestScope + private lateinit var messages: MutableList - @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 + messages = mutableListOf() + every { uiMessageSender.send(capture(messages)) } just runs - Timber.uprootAll() // Disable Timber logging for tests + Timber.uprootAll() } - @OptIn(ExperimentalCoroutinesApi::class) - @After - fun tearDown() { - Dispatchers.resetMain() - testScope.cancel() + @Test + fun `GIVEN empty and non-BTC then BTC WHEN supplier emits THEN activated dialog is shown`() = runTest { + val promoCode = "PROMO123" + val queryParams = mapOf(DeeplinkConst.PROMO_CODE_KEY to promoCode) + val userWallet = mockUserWallet("ABCDEF") + every { getSelectedWalletSyncUseCase.invoke() } returns Either.Right(userWallet) + + val ethStatus = buildNetworkStatus(rawNetworkId = "ethereum", address = "0x123") + val btcStatus = buildNetworkStatus(rawNetworkId = Blockchain.Bitcoin.id, address = "bc1qxyz") + coEvery { multiNetworkStatusSupplier.invoke(any()) } returns flow { + emit(emptySet()) + emit(setOf(ethStatus)) + emit(setOf(btcStatus)) + } + coEvery { activateBitcoinPromocodeUseCase.invoke("bc1qxyz", promoCode) } returns Either.Right("ok") + val dispatcherProvider = testDispatcherProvider(testScheduler) + + DefaultPromoDeeplinkHandler( + scope = this, + queryParams = queryParams, + uiMessageSender = uiMessageSender, + multiNetworkStatusSupplier = multiNetworkStatusSupplier, + activateBitcoinPromocodeUseCase = activateBitcoinPromocodeUseCase, + getSelectedWalletSyncUseCase = getSelectedWalletSyncUseCase, + analyticsEventsHandler = analyticsEventHandler, + dispatchers = dispatcherProvider, + ) + + advanceUntilIdle() + advanceTimeBy(500) + advanceUntilIdle() + + val sent = messages.last { it is DialogMessage } as DialogMessage + Truth.assertThat(sent.title).isEqualTo(resourceReference(R.string.bitcoin_promo_activation_success_title)) + Truth.assertThat(sent.message).isEqualTo(resourceReference(R.string.bitcoin_promo_activation_success)) + + verify(exactly = 1) { analyticsEventHandler.send(PromoActivationAnalytics.PromoDeepLinkActivationStart) } + verify(exactly = 1) { + analyticsEventHandler.send( + PromoActivationAnalytics.PromoActivation(PromoCodeActivationResult.Activated), + ) + } } @Test @@ -104,13 +130,14 @@ class DefaultPromoDeeplinkHandlerTest { scope = this, queryParams = queryParams, uiMessageSender = uiMessageSender, - getMultiCryptoCurrencyStatusUseCase = getMultiCryptoCurrencyStatusUseCase, + multiNetworkStatusSupplier = multiNetworkStatusSupplier, activateBitcoinPromocodeUseCase = activateBitcoinPromocodeUseCase, getSelectedWalletSyncUseCase = getSelectedWalletSyncUseCase, analyticsEventsHandler = analyticsEventHandler, + dispatchers = TestingCoroutineDispatcherProvider(), ) - val sent = messageSlot.captured as DialogMessage + val sent = messages.last { it is DialogMessage } as DialogMessage Truth.assertThat(sent.title).isEqualTo(resourceReference(R.string.bitcoin_promo_invalid_code_title)) Truth.assertThat(sent.message).isEqualTo(resourceReference(R.string.bitcoin_promo_invalid_code)) @@ -131,46 +158,14 @@ class DefaultPromoDeeplinkHandlerTest { scope = this, queryParams = queryParams, uiMessageSender = uiMessageSender, - getMultiCryptoCurrencyStatusUseCase = getMultiCryptoCurrencyStatusUseCase, + multiNetworkStatusSupplier = multiNetworkStatusSupplier, activateBitcoinPromocodeUseCase = activateBitcoinPromocodeUseCase, getSelectedWalletSyncUseCase = getSelectedWalletSyncUseCase, analyticsEventsHandler = analyticsEventHandler, + dispatchers = TestingCoroutineDispatcherProvider(), ) - 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 + val sent = messages.last { it is DialogMessage } as DialogMessage Truth.assertThat(sent.title).isEqualTo(resourceReference(R.string.bitcoin_promo_activation_error_title)) Truth.assertThat(sent.message).isEqualTo(resourceReference(R.string.bitcoin_promo_activation_error)) @@ -187,25 +182,26 @@ class DefaultPromoDeeplinkHandlerTest { 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), - ) + val btcUnreachable = buildUnreachableNetworkStatus(rawNetworkId = Blockchain.Bitcoin.id) + coEvery { multiNetworkStatusSupplier.invoke(any()) } returns flowOf(setOf(btcUnreachable)) + val dispatcherProvider = testDispatcherProvider(testScheduler) DefaultPromoDeeplinkHandler( scope = this, queryParams = queryParams, uiMessageSender = uiMessageSender, - getMultiCryptoCurrencyStatusUseCase = getMultiCryptoCurrencyStatusUseCase, + multiNetworkStatusSupplier = multiNetworkStatusSupplier, activateBitcoinPromocodeUseCase = activateBitcoinPromocodeUseCase, getSelectedWalletSyncUseCase = getSelectedWalletSyncUseCase, analyticsEventsHandler = analyticsEventHandler, + dispatchers = dispatcherProvider, ) + advanceUntilIdle() + advanceTimeBy(500) advanceUntilIdle() - val sent = messageSlot.captured as DialogMessage + val sent = messages.last { it is DialogMessage } as DialogMessage Truth.assertThat(sent.title).isEqualTo(resourceReference(R.string.bitcoin_promo_no_address_title)) Truth.assertThat(sent.message).isEqualTo(resourceReference(R.string.bitcoin_promo_no_address)) @@ -223,25 +219,27 @@ class DefaultPromoDeeplinkHandlerTest { 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), - ) + val btcStatus = buildNetworkStatus(rawNetworkId = Blockchain.Bitcoin.id, address = "bc1qxyz") + coEvery { multiNetworkStatusSupplier.invoke(any()) } returns flowOf(setOf(btcStatus)) coEvery { activateBitcoinPromocodeUseCase.invoke("bc1qxyz", promoCode) } returns Either.Right("ok") + val dispatcherProvider = testDispatcherProvider(testScheduler) DefaultPromoDeeplinkHandler( scope = this, queryParams = queryParams, uiMessageSender = uiMessageSender, - getMultiCryptoCurrencyStatusUseCase = getMultiCryptoCurrencyStatusUseCase, + multiNetworkStatusSupplier = multiNetworkStatusSupplier, activateBitcoinPromocodeUseCase = activateBitcoinPromocodeUseCase, getSelectedWalletSyncUseCase = getSelectedWalletSyncUseCase, analyticsEventsHandler = analyticsEventHandler, + dispatchers = dispatcherProvider, ) + advanceUntilIdle() + advanceTimeBy(500) advanceUntilIdle() - val sent = messageSlot.captured as DialogMessage + val sent = messages.last { it is DialogMessage } as DialogMessage Truth.assertThat(sent.title).isEqualTo(resourceReference(R.string.bitcoin_promo_activation_success_title)) Truth.assertThat(sent.message).isEqualTo(resourceReference(R.string.bitcoin_promo_activation_success)) @@ -326,25 +324,27 @@ class DefaultPromoDeeplinkHandlerTest { 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), - ) + val btcStatus = buildNetworkStatus(rawNetworkId = Blockchain.Bitcoin.id, address = "bc1qxyz") + coEvery { multiNetworkStatusSupplier.invoke(any()) } returns flowOf(setOf(btcStatus)) coEvery { activateBitcoinPromocodeUseCase.invoke("bc1qxyz", promoCode) } returns Either.Left(error) + val dispatcherProvider = testDispatcherProvider(testScheduler) DefaultPromoDeeplinkHandler( scope = this, queryParams = queryParams, uiMessageSender = uiMessageSender, - getMultiCryptoCurrencyStatusUseCase = getMultiCryptoCurrencyStatusUseCase, + multiNetworkStatusSupplier = multiNetworkStatusSupplier, activateBitcoinPromocodeUseCase = activateBitcoinPromocodeUseCase, getSelectedWalletSyncUseCase = getSelectedWalletSyncUseCase, analyticsEventsHandler = analyticsEventHandler, + dispatchers = dispatcherProvider, ) + advanceUntilIdle() + advanceTimeBy(500) advanceUntilIdle() - val sent = messageSlot.captured as DialogMessage + val sent = messages.last { it is DialogMessage } as DialogMessage Truth.assertThat(sent.title).isEqualTo(expectedTitle) Truth.assertThat(sent.message).isEqualTo(expectedMessage) } @@ -355,7 +355,7 @@ class DefaultPromoDeeplinkHandlerTest { return userWallet } - private fun buildStatusForNetwork(rawNetworkId: String, address: String): CryptoCurrencyStatus { + private fun buildNetworkStatus(rawNetworkId: String, address: String): NetworkStatus { val networkId = Network.ID(Network.RawID(rawNetworkId), Network.DerivationPath.None) val network = Network( id = networkId, @@ -370,39 +370,49 @@ class DefaultPromoDeeplinkHandlerTest { 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( + val value = NetworkStatus.Verified( + address = NetworkAddress.Single( defaultAddress = NetworkAddress.Address( value = address, type = NetworkAddress.Address.Type.Primary, ), ), - sources = CryptoCurrencyStatus.Sources(), + amounts = emptyMap(), + pendingTransactions = emptyMap(), + source = StatusSource.ACTUAL, ) - return CryptoCurrencyStatus( - currency = coin, - value = value, + return NetworkStatus(network = network, value = value) + } + + private fun buildUnreachableNetworkStatus(rawNetworkId: String): NetworkStatus { + val networkId = Network.ID(Network.RawID(rawNetworkId), Network.DerivationPath.None) + val network = Network( + id = networkId, + backendId = rawNetworkId, + name = rawNetworkId, + currencySymbol = rawNetworkId.take(3).uppercase(), + derivationPath = Network.DerivationPath.None, + isTestnet = false, + standardType = Network.StandardType.Unspecified("UNSPECIFIED"), + hasFiatFeeRate = false, + canHandleTokens = false, + transactionExtrasType = Network.TransactionExtrasType.NONE, ) + + val value = NetworkStatus.Unreachable(address = null) + + return NetworkStatus(network = network, value = value) + } + + private fun testDispatcherProvider(scheduler: TestCoroutineScheduler): CoroutineDispatcherProvider { + val dispatcher: CoroutineDispatcher = StandardTestDispatcher(scheduler) + return object : CoroutineDispatcherProvider { + override val main: CoroutineDispatcher = dispatcher + override val mainImmediate: CoroutineDispatcher = dispatcher + override val io: CoroutineDispatcher = dispatcher + override val default: CoroutineDispatcher = dispatcher + override val single: CoroutineDispatcher = dispatcher + } } } \ No newline at end of file From 2a73e5654e4e1f7f9bbf1770fe9d13fe3fc2c69a Mon Sep 17 00:00:00 2001 From: Tangem Date: Thu, 28 Aug 2025 15:43:27 +0500 Subject: [PATCH 12/19] Updated on 2026-08-14 --- app/src/main/AndroidManifest.xml | 2 +- .../wallet/deeplink/analytics/PromoActivationAnalytics.kt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 9ba22faa95..91aa75f425 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -62,7 +62,7 @@ android:name="com.tangem.tap.MainActivity" android:configChanges="uiMode" android:exported="true" - android:launchMode="singleTop" + android:launchMode="singleInstance" android:screenOrientation="portrait" android:theme="@style/SplashTheme" android:windowSoftInputMode="adjustResize"> diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/deeplink/analytics/PromoActivationAnalytics.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/deeplink/analytics/PromoActivationAnalytics.kt index 9bcd32f457..8af8c92161 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/deeplink/analytics/PromoActivationAnalytics.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/deeplink/analytics/PromoActivationAnalytics.kt @@ -19,7 +19,7 @@ sealed class PromoActivationAnalytics( "State" to when (result) { PromoCodeActivationResult.Failed -> "Error" PromoCodeActivationResult.InvalidPromoCode -> "Invalid" - PromoCodeActivationResult.NoBitcoinAddress -> "No Address " + PromoCodeActivationResult.NoBitcoinAddress -> "No Address" PromoCodeActivationResult.PromoCodeAlreadyUsed -> "Already Use" PromoCodeActivationResult.Activated -> "Activated" }, From 420529c45fd20143e7b1291ac3336b452f3d3f61 Mon Sep 17 00:00:00 2001 From: Tangem Date: Thu, 28 Aug 2025 16:45:59 +0500 Subject: [PATCH 13/19] Updated on 2026-08-14 --- app/src/main/AndroidManifest.xml | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 91aa75f425..baf6ae4211 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -51,13 +51,18 @@ property to make the "android:screenOrientation" work on API <37 https://developer.android.com/about/versions/16/behavior-changes-16 --> - + Date: Thu, 28 Aug 2025 15:29:27 +0300 Subject: [PATCH 14/19] Updated on 2026-08-14 --- app/src/main/assets/tangem-app-config | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/main/assets/tangem-app-config b/app/src/main/assets/tangem-app-config index 7d225a195e..d3ba082fad 160000 --- a/app/src/main/assets/tangem-app-config +++ b/app/src/main/assets/tangem-app-config @@ -1 +1 @@ -Subproject commit 7d225a195eb001f9f4ce88aa4a6fa2d965b9159c +Subproject commit d3ba082fadbdb583a45cc1c714c57b382cbbe6ef From f548450cf4ac0b8b2dff85ab25348285ccea0b51 Mon Sep 17 00:00:00 2001 From: Tangem Date: Thu, 28 Aug 2025 21:02:09 +0500 Subject: [PATCH 15/19] Updated on 2026-08-14 --- core/res/src/main/res/values-de/strings.xml | 154 ++++++++++++++++++-- core/res/src/main/res/values-ja/strings.xml | 14 +- core/res/src/main/res/values/strings.xml | 11 +- 3 files changed, 166 insertions(+), 13 deletions(-) diff --git a/core/res/src/main/res/values-de/strings.xml b/core/res/src/main/res/values-de/strings.xml index ddcad5ed92..300a8bea78 100644 --- a/core/res/src/main/res/values-de/strings.xml +++ b/core/res/src/main/res/values-de/strings.xml @@ -4,11 +4,44 @@ Trotzdem überspringen Zugangscode nicht festgelegt Zugangscode eingeben + Falscher Zugangscode. Deine Hot-Wallet wird nach %s weiteren Fehlversuchen gelöscht. + Falscher Zugangscode. App wird bei %s weiteren Eingabefehlern gesperrt + Falscher Zugangscode.\nBitte warte %s Sekunden und versuche es erneut. Bestätige Deinen zuvor eingegebenen Code, um fortzufahren Zugangscode erneut eingeben Lege einen %s -stelligen Zugangscode fest, um Deine Wallet zu entsperren. Zugangscode erstellen Zugangscode + Du kannst nicht mehr erstellen als %1$s Konto/Konten. Archiviere eines, um es neu hinzuzufügen. + Neues Konto kann nicht hinzugefügt werden + Konto archiviert + Archivierte Konten + Wiederherstellen + Du bist dabei, dich zu erholen \"%1$s”. + Konto wiederherstellen + Archiviert + Konto erstellt + Archivkonto + Archiv + Du archivierst dieses Konto, kannst es aber jederzeit entarchivieren. + Konto + Konto gespeichert + Kontonummer %s – wird zur Adressableitung verwendet. + Konto hinzufügen + Speichern + Kontoname + Konto + Neues Konto + Konto hinzufügen + Konto bearbeiten + %1$s in %2$s + Konto wiederhergestellt + Tippe lange auf ein Konto, um die Konten neu anzuordnen + Weiter bearbeiten + Verwerfen + Bist Du sicher, dass Du das neue Konto verwerfen willst? + Bist Du sicher, dass Du die Bearbeitungen verwerfen willst? + Nicht gespeicherte Änderungen Du kannst Deinen Token nicht finden? Gehe zum Bereich „Markt“ auf der Hauptseite und fügen diesen zum kaufen im Portfolio hinzu. Du kannst Deinen Token nicht finden? Gehe zum Bereich „Markt“ auf der Hauptseite und fügen diesen zum verkaufen im Portfolio hinzu. Verkaufen @@ -43,12 +76,18 @@ Tokens im %1$s -Netzwerk werden von dieser Karte oder Ring aufgrund einer Firmware-Einschränkung nicht unterstützt. Hast du Probleme beim Scannen deiner Karte oder Ring? Diese Karte oder Ring ist für die Zusammenarbeit mit Tangem nicht geeignet + Verwenden %1$s, um Deine Wallet schnell und sicher zu entsperren und alle sensiblen Aktionen zu autorisieren, wie z. B. das Unterschreiben von Transaktionen. Für Hardware-Wallets benötigst Du weiterhin eine Karte zum signieren. Standardgebühr Aktiviere die Option Standardgebühr, um die Transaktionsgebühren automatisch festzulegen und die Gebührenseite beim Senden von Geldern zu überspringen. Du kannst bei Bedarf jederzeit zu dieser Seite zurückkehren. Gehe zu den Einstellungen, um die biometrische Authentifizierung in der Tangem-App zu aktivieren Biometrische Authentifizierung aktivieren + Wenn Du %1$s deaktivierst, musst Du Deinen Passcode eingeben, um die App zu entsperren und um mit Deiner Wallet zu interagieren. + Du wirst später nach dem Zugangscode Deiner Wallet gefragt, damit wir sie für die zukünftige Verwendung sicher aufbewahren können Dadurch werden alle gespeicherten Zugangscodes für die Wallet gelöscht. Jede weitere Operation mit der Wallet erfordert die Übermittlung des Zugangscodes. Durch das Entfernen der gespeicherten Geräte werden alle gespeicherten Wallets und deren Zugangscodes aus der App gelöscht. + Dadurch werden alle gespeicherten Wallet-Zugangscodes gelöscht. Für jede weitere Interaktion mit dem Wallet ist die Eingabe des Zugangscodes erforderlich. + Zugangscode erforderlich + Mit dieser Option wird die biometrische Authentifizierung für vertrauliche Aktionen deaktiviert. Du musst Deinen Zugangscode jedes Mal eingeben, z. B. wenn Du eine Transaktion unterzeichnest. Zugangscode speichern Bei Interaktionen mit deiner Karte oder Ring wird anstelle des Zugangscodes eine biometrische Authentifizierung abgefragt. Behalte die Wallet in der App @@ -58,6 +97,14 @@ Systemstandard Thema App Einstellungen + Wallet hinzufügen + Wähle eine Wallet zum Einloggen + Willkommen zurück! + + %s Karte + %s Karten + + Handy-Wallet Du hast Deine Wallet erfolgreich gesichert. Diese Wörter können bei Verlust nicht wiederhergestellt werden. Bewahre diese an einem sicheren Ort auf. Sicherung abgeschlossen @@ -81,6 +128,16 @@ Bitte versuche es in 30 Sekunden erneut oder scanne die Karte oder Ring Zu viele Versuche Du hast die biometrische Authentifizierung auf deinem Telefon deaktiviert und kannst keine Wallets in der App speichern. Um Wallets zu speichern, aktiviere bitte die biometrische Authentifizierung in deinen Telefoneinstellungen. + Bei der Bearbeitung Deines Aktionscodes ist ein Fehler aufgetreten. Bitte versuche es später noch einmal. + Fehler bei der Aktivierung + Dein Promo-Code wurde erfolgreich aktiviert. Ein Bonus von 10 USDT in Bitcoin wird Deinem Konto innerhalb von 14 Tagen gutgeschrieben. + Promo-Code aktiviert + Dieser Aktionscode wurde bereits verwendet und kann nicht erneut aktiviert werden. + Code nicht verfügbar + Dieser Aktionscode ist ungültig und kann nicht aktiviert werden. + Ungültiger Code + Für den Erhalt des Bonus ist eine Bitcoin-Adresse erforderlich. Bitte füge eine zu Deiner Wallet hinzu und starte die Aktivierung erneut. + Bitcoin-Adresse erforderlich Backup-Prozess starten Verwende eine Bankkarte oder eine andere Zahlungsmethode @@ -116,6 +173,7 @@ Nicht genug ADA Akzeptieren Zugang verweigert + Konten Hinzufügen Zum Portfolio hinzufügen Token hinzufügen @@ -168,6 +226,7 @@ Tage Entfernen + Deaktivieren Deaktiviert Trennen Erledigt @@ -197,16 +256,23 @@ In Arbeit Später %1$s übrig + Legacy Bitcoin Gesperrt Hauptnetz monat Netzgebühr Der überwiesene Betrag wird um %1$s (%2$s) gekürzt, um die gewählte Gebührenhöhe zu decken. + + %1$s Netzwerk + %1$s Netzwerke + + Neue Adresse Weiter NFT Nein Keine Adresse Nicht hinzugefügt + Nicht jetzt Jetzt OK Im Browser öffnen @@ -222,6 +288,7 @@ Ablehnen Neu laden Umbenennen + Erforderlich Speichern Änderungen speichern Suchen @@ -249,6 +316,7 @@ Unterstützung Unterstützte Netzwerke Tauschen + Tangem Allgemeine Geschäftsbedingungen Nutzungsbedingungen Heute @@ -260,6 +328,7 @@ Transaktionsstatus Transaktionen Überweisung + Die Daten konnten nicht geladen werden… Ich verstehe Es ist ein Fehler aufgetreten. Bitte versuche es erneut. Nicht erreichbar @@ -322,6 +391,11 @@ Details Überprüfe deine Internetverbindung oder wechseln zu einem anderen Netzwerk Nutzungsbedingungen + Standardadresse + Empfangen von Vermögenswerten + Das Senden von Vermögenswerten in anderen Netzwerken führt zu dauerhaftem Verlust. + %s Netzwerk + Sende Geld nur mit Hallo Support-Team, ich habe einen Fehler mit dem Code %s festgestellt. WalletConnect-Fehler Du hast eine Karte oder Ring aus einer anderen Wallet verwendet. Tippe auf die Karte oder Ring, die dieser Wallet zugeordnet ist. @@ -348,8 +422,8 @@ Der Betrag wurde in %1$s (%2$s Netzwerk) zurückerstattet. Besuche die Website des Anbieters zur Überprüfung KYC-Überprüfung durch den Anbieter erforderlich - Gekauft - Einkaufen + Kauf abgeschlossen + Kauf steht aus Warten auf Kauf... Transaktion abgebrochen Anzahlung bestätigt @@ -381,6 +455,7 @@ Anbieter Bester Preis Warnliste der FCA + Anbieter in FCA-Warnliste Verfügbar bis zu %s Erhältlich bei %s Für dieses Paar nicht verfügbar @@ -428,10 +503,19 @@ Karte oder Ring scannen An %s Im %s Netzwerk + Willst Du den Vorgang zur Erstellung des Zugangscodes wirklich beenden? Jetzt sichern Um die Einrichtung abzuschließen, sicher Deine Wallet und sicher den App-Zugriff mit einem Access Code. Jetzt beenden Wallet-Aktivierung abschließen + Um das Setup abzuschließen, sicher den App-Zugriff mit Access Code. + Wenn dies der Fall ist, musst Du von vorne beginnen. + Bist Du sicher, dass Du den Aktivierungsprozess beenden willst? + Wenn dies der Fall ist, musst Du von vorne beginnen. + Wiederherstellung einer bestehenden Wallet, die in Ihrem Google Drive-Backup gespeichert ist + Google Drive-Backup + Verbesser Deine Sicherheit sofort mit einer erstklassigen Hardware-Wallet von Tangem. + Hardware-Wallet Gehe zum Backup Um Deine Wallet mit einem Zugangscode zu sichern, führe zuerst die Sicherung durch. Sicherung zuerst beenden @@ -443,6 +527,21 @@ Bleibe über die neuesten Funktionen und Neuigkeiten auf dem Laufenden Sicherung der Seed-Phrase Mobile Wallet erstellen + Diese Wiederherstellungsphrase wurde bereits importiert + Mobile Wallet + Dein Geld bleibt während des gesamten Prozesses sicher und vollständig zugänglich + Zugang zu Geldern + Alle privaten Wallet-Daten werden aus der mobilen App entfernt und nur sicher auf Deinem Tangem-Gerät gespeichert + Allgemeine Sicherheit + Private Schlüssel werden von der App auf Deine Tangem-Karte oder Deinen Tangem-Ring verschoben + Schlüsselmigration + Gerät scannen + Upgrade starten + Du bist dabei, Deine Wallet auf Tangem Wallet zu aktualisieren. Dies wird Dein Vermögen mit cold-storage sicher verwahren. + Tangem Wallet + Upgrade auf Hardware Wallet + Bewahren Deine Kryptowährungen sicher auf - mit der erstklassigen Hardware-Wallet von Tangem. + Wallet mit einem Hardware-Backup aufrüsten Diese Informationen wurden mithilfe von KI generiert.\nTippe hier, wenn Du Fehler finden. Um den Zugangscode zu ändern, halte die Karte oder Ring wie oben gezeigt an das Gerät und entferne sie erst am Ende des Vorgangs. Um den Passcode zu ändern, halte die Karte oder Ring wie oben gezeigt an das Gerät und entferne sie erst am Ende des Vorgangs. @@ -647,6 +746,9 @@ Jetzt beitreten Teile Deinen Code – verdiene 5 USDT pro Verkauf. Deine Freunde erhalten 10 % Rabatt. Erhalte BELOHNUNGEN für jeden Freund! + Krypto kaufen + Profitiere von null Gebühren beim Kauf von Kryptowährungen per SEPA-Überweisung. + Krypto mit SEPA kaufen Du musst einen einzigen Zugangscode einrichten, um alle deine Geräte zu schützen Schützen Du kannst später auf jeder Karte oder Ring einen individuellen Zugangscode einrichten @@ -727,7 +829,7 @@ Keine Sicherungsgeräte Benachrichtigungen Eine Backupgerät hinzugefügt - Bereite deine Karte oder Ring vor + Bereite Deine Karte oder Deinen Ring vor Zweites Backupgerät hinzugefügt Um zu beginnen, lade einfach die wallet mit einem beliebigen Betrag auf Um zu beginnen, lade die Wallet einfach mit mehr als %1$s %2$s auf. @@ -769,6 +871,7 @@ Du kannst diesen Bildschirm schließen und den Transaktionsstatus auf dem Bildschirm mit den Token-Details überprüfen. Du kannst diesen Bildschirm schließen und den Transaktionsstatus auf dem Bildschirm mit den Token-Details überprüfen. Über + Du zahlst Gruppe erstellen Nach Guthaben Token organisieren @@ -866,6 +969,7 @@ %1$s, %2$s Ziel-Tag Adresse eingeben + ENS-Name oder Adresse eingeben Die Adresse stimmt mit der Adresse Ihrer Brieftasche überein Minimaler Betrag ist %s Minimaler Wechselgeld ist %s @@ -947,14 +1051,20 @@ Ungültiger Betrag Die Gebühr geht über die Bilanz hinaus Der Gesamtbetrag geht über die Bilanz hinaus + Bist Du sicher, dass Du den Empfangstoken ändern möchtest? Dadurch werden Dein zuvor eingegebenen Daten zurückgesetzt. + Token ändern Tauschen und senden Mit der Konvertierung fortfahren? Dadurch werden Deine vorherigen Daten gelöscht. + Konvertierung bestätigen Das Senden einer anderen Währung führt zu deren unwiderruflichem Verlust. Wähle das richtige Empfängernetzwerk Sende uns ein Token, und wir konvertieren es unterwegs. Dein Empfänger erhält genau das, was er braucht – nahtlos. - Wird an den Empfänger gesendet + Der Empfänger erhält + An den Empfänger Zu erhaltender Betrag + Empfänger erhält %s Möchtest Du die Konvertierung wirklich abbrechen? Deine bisherigen Daten werden gelöscht. + Konvertierung entfernen Senden mit Swap Transaktion gesendet Bereite das Scannen der Karte oder Ring vor, die du einrichten möchtest. @@ -1121,6 +1231,7 @@ Fehler bei der Gebührenschätzung. Bitte sende dein Feedback an den Support. Du wechselst Der Tausch dieser Menge ausgewählter Token hat erhebliche Auswirkungen auf den Preis und verringert dein Ergebnis. + Hoher Einfluss auf den Preis Unzureichende Mittel Erlaubnis erteilen Tauschen @@ -1207,6 +1318,7 @@ Wallet umbenennen Alle freischalten Alle mit %s freischalten + Grenzen Verfügbar für %d Tag Verfügbar für %d Tage @@ -1224,6 +1336,7 @@ Schon kann es losgehen! Du kannst Deine Verbindung \n auf der Website des Drittanbieters \n herstellen und zurück zur Tangem-App gelangen Zur Website gehen + Wallet wählen Fahren wir mit der Einrichtung Deines Kontos fort. Willkommen zurück! Folge den Schritten, um Dein Konto einzurichten. @@ -1300,8 +1413,11 @@ Entdecke die Tangem Wallet Geheimcode zum Schutz dieser Wallet. Wird für Anmeldung und Signaturen verwendet. Zugangscode festlegen/ändern + Zugangscode ändern Bleib stets über eingehende Wallet-Transaktionen und Tangem-Updates auf dem Laufenden. + Push-Benachrichtigungen funktionieren derzeit möglicherweise nicht auf Huawei-Geräten. Wir arbeiten aktiv an einer Lösung und werden in einem kommenden Update einen Fix veröffentlichen. Vielen Dank für Ihr Verständnis! Transaktionsbenachrichtigungen + Zugangscode festlegen Wallet-Einstellungen Tangem Verwende %s oder scanne eine Karte oder Ring, um den Zugriff auf deine Wallet freizuschalten. @@ -1329,8 +1445,8 @@ Die Genehmigung des Swaps ist im Gange und wird in Kürze abgeschlossen sein. Genehmigung in Arbeit Der Mindestbetrag für den Tausch beträgt %1$s. Bitte stelle sicher, dass der Restsaldo nach dem Swap nicht unter %2$s liegt. - Sie haben keine Token in Ihrem Portfolio, gegen die %s getauscht werden kann. Bitte fügen Sie einen anderen Token hinzu, um den Tausch durchzuführen. - Keine Token zum Tauschen verfügbar + Du hast keine Token in Deinem Portfolio, gegen die %s getauscht werden kann. Bitte füge einen anderen Token hinzu, um den Austausch zu ermöglichen. + Keine kompatiblen Token hinzugefügt Um eine Transaktion durchzuführen, du etwas etwas einzahlen %1$s %2$s Die Gebühr %s kann nicht gedeckt werden Der zu erhaltende Betrag muss mindestens %s betragen @@ -1389,7 +1505,7 @@ Wischen Sie nach unten, um zu aktualisieren, oder versuchen Sie es später erneut. Einige Netzwerke sind nicht erreichbar Einige Token-Guthaben konnten nicht aktualisiert werden - Nicht genug %s. Lade Dein XLM-Konto auf, um dieses Token zu verknüpfen + Nicht genug %s. Lade Dein XLM-Konto auf, um Trustline zu öffnen. Dies ist eine Testnet-Karte. Sie kann keine Transaktionen verarbeiten und sollte nur zu Test- und Entwicklungszwecken verwendet werden. Nur für Testzwecke Der Kontostand ist möglicherweise veraltet. Aktualisiere bittre die Seite. @@ -1416,6 +1532,7 @@ Wenn das Problem weiterhin besteht, wende Dich bitte an unseren Support. Wir haben einen unbekannten Fehler festgestellt. Tangem Wallet unterstützt derzeit nicht %s + Nicht unterstützte dApp Fehlercode: 8 005. Wenn das Problem weiterhin besteht, wende Dich bitte an unseren Support. Wir haben einen unbekannten Fehler festgestellt. Dieses Netzwerk %s wird von Tangem Wallet nicht unterstützt und kann nicht verbunden werden. @@ -1426,6 +1543,7 @@ Verifizierte Domain Falsche Karte oder falscher Ring in der App ausgewählt Wir haben eine Art Problem + Alle dApps getrennt Erlaubnis auszugeben Vertragsadresse Verbinden @@ -1434,7 +1552,9 @@ Netzwerke Unbegrenzt Wallet + Verbundene App Verbundene Netzwerke + Verbunden mit %1$s Anzeige des Kontostands und der Aktivitäten in Deiner Wallet Signiere die Transaktionen ohne eine Vorankündigung Genehmigung für Transaktionen anfordern @@ -1445,8 +1565,9 @@ Inhalt Daten kopieren Benutzerdefinierter Freibetrag + dApp getrennt Alle trennen - Alle dApp-Sitzungen werden getrennt. Ihre Wallet wird nicht mehr mit dApps verbunden sein. + Alle dApp-Sitzungen werden getrennt. Deien Wallet ist nicht mehr mit dApps verknüpft. Alle dApps trennen Versuchen Sie erneut, mit einer neuen URI zu koppeln Ungültige dApp-Domain @@ -1456,21 +1577,34 @@ Verbindungsvorschlag abgelaufen Geschätzte Wallet-Änderungen Die Transaktion konnte nicht simuliert werden. Bitte sei vorsichtig. + Die Schätzung wird nicht unterstützt für %s + Vorgeschlagen von %s + Lade Dein Guthaben auf, um die Netzwerkgebühr zu decken + Unzureichend %1$s Böswillige/ gefährliche Transaktion - Füge Deinem Profil für dieses Wallet das Netzwerk %s hinzu + Füge das Netzwerk %s zu Deinem Portfolio für diese Wallet hinzu Die Wallet verfügt über keine erforderlichen Netzwerke Neue Verbindung - Verbinde Deine Wallet mit einer anderen dApp + Verbinde Deine Wallet mit verschiedenen dApps Keine Sitzungen + Keine Wallet-Änderungen erkannt Es wurden potenzielle Risiken oder bösartiges Verhalten erkannt. Das Verbinden oder Signieren von Transaktionen kann zum Verlust von Geldern führen. Bekanntes Sicherheitsrisiko + Öffne die Web3-App und wähle die Option WalletConnect Anfrage von + Trotzdem signieren Art der Signatur + Für die dApp-Verbindung ist mindestens ein Netzwerk erforderlich + Ausgewählte Netzwerke angeben + Erfolgreich signiert An Transaktionsanfrage Transaktionsanfrage Unbegrenzte Menge + Stelle sicher, dass bei jedem Kopplungsversuch ein frischer und eindeutiger URI verwendet wird. + URI bereits verwendet WalletConnect + Verdächtige Transaktion Verwerfen Du hast eine unterbrochene Sicherung. Möchtest du diese fortsetzen? Ja, fortsetzen diff --git a/core/res/src/main/res/values-ja/strings.xml b/core/res/src/main/res/values-ja/strings.xml index 0b8555b8da..2a0890b5cb 100644 --- a/core/res/src/main/res/values-ja/strings.xml +++ b/core/res/src/main/res/values-ja/strings.xml @@ -14,15 +14,18 @@ アクセスコード %1$s個を超えるアカウントは作成できません。新しいアカウントを追加するには、1つをアーカイブしてください。 新しいアカウントを追加できません + アカウントはアーカイブされました アーカイブされたアカウント 回復する 「 %1$s 」を回復しようとしています。 アカウントを回復する アーカイブ済み + アカウントを作成しました アカウントをアーカイブする アーカイブ このアカウントをアーカイブしますが、いつでも復元できます。 アカウント + アカウントが保存されました アカウント番号%s — アドレス導出に使用されます。 アカウントを追加 保存 @@ -32,6 +35,7 @@ アカウントを追加 アカウントを編集 %1$s ( %2$s内) + アカウントが回復しました アカウントを長押しして並べ替える 編集を続ける 破棄 @@ -125,7 +129,7 @@ お使いの携帯電話で生体認証が無効になっているため、アプリにウォレットを保存できません。ウォレットを保存するには、携帯電話の設定で生体認証機能を有効にしてください。 プロモーションコードの処理中にエラーが発生しました。しばらくしてからもう一度お試しください。 アクティベーションエラー - プロモーションコードが正常に有効化されました。14日以内に10 USDT相当のビットコインボーナスが付与されます。 + プロモーションコードが正常に有効化されました。14日以内に10 USDT相当のビットコインがアカウントに入金されます。 プロモーションコードが有効になりました このプロモーションコードは既に使用されているため、再度使うことはできません。 コードが利用できません @@ -247,6 +251,7 @@ 進行中 後で 残り%1$s + レガシービットコイン ロックされています メインネットワーク @@ -255,6 +260,7 @@ %dネットワーク + 新しいアドレス NFT いいえ @@ -304,6 +310,7 @@ サポート 対応ネットワーク スワップ + Tangem 利用規約 利用規約 今日 @@ -377,6 +384,7 @@ 詳細 インターネット接続を確認するか、別のネットワークに切り替えてください。 利用規約 + デフォルトアドレス 資金を受け取る 他のネットワークで資産を送金すると、永久に失われます。 %sネットワーク @@ -526,7 +534,7 @@ Tangemウォレット ハードウェアウォレットにアップグレード Tangemの業界最高水準のハードウェアウォレットで、暗号資産を安全に保管しましょう。 - ハードウェアバックアップでウォレットをアップグレード + ハードウェアバックアップで、ウォレットをアップグレード この情報はAIで生成されました。 \nエラーが見つかった場合は、ここをタップしてください。 アクセスコードを変更するには、上図のようにカードまたはリングをタップし、操作が終了するまで取り外さないでください。 パスコードを変更するには、上記のようにカードをタップし、操作が終了するまで取り外さないでください。 @@ -849,6 +857,7 @@ この画面を閉じて、トークンの詳細画面で取引状況を確認できます。 この画面を閉じて、トークンの詳細画面で取引状況を確認できます。 経由 + 支払い グループ 残高順 トークンを整理する @@ -1302,6 +1311,7 @@ 長くはかかりません。アカウントを設定しています。 長くはかかりません。アクティベーションを完了しています。 準備完了です! + 4桁のコードを設定します。 \nお支払いの際に使用されます。 PINの認証に失敗しました。もう一度お試しいただくか、別のコードを使用してください。 無効な暗証番号:連続や繰り返しを避けてください ウェブサイトに移動 diff --git a/core/res/src/main/res/values/strings.xml b/core/res/src/main/res/values/strings.xml index c69bcd90ed..d29deb2f04 100644 --- a/core/res/src/main/res/values/strings.xml +++ b/core/res/src/main/res/values/strings.xml @@ -14,15 +14,18 @@ Access code You cannot create more than %1$s accounts. Archive one to add new. Can’t add new account + Account archived Archived accounts Recover You’re about to recover “%1$s”. Recover account Archived + Account created Archive account Archive You are archiving this account, but you can always get it back. Account + Account saved Account #%s — used for address derivation. Add account Save @@ -32,6 +35,7 @@ Add account Edit account %1$s in %2$s + Account recovered Long tap on an account to reorder accounts Keep Editing Discard @@ -252,6 +256,7 @@ In progress Later %1$s left + Legacy Bitcoin Locked Main network month @@ -261,6 +266,7 @@ %d network %d networks + New address Next NFT No @@ -310,6 +316,7 @@ Support Supported networks Swap + Tangem Tangem Wallet terms and conditions Terms of Use @@ -385,6 +392,7 @@ Details Check your internet connection or switch to a different network Terms of service + Default Address Receive assets Sending assets in other networks will result in permanent loss. %s network @@ -534,7 +542,7 @@ Tangem Wallet Upgrade to Hardware Wallet Keep your crypto safe with Tangem’s best-in-class hardware wallet. - Upgrade wallet with a hardware
backup + 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 @@ -864,6 +872,7 @@ You can close this screen and check the transaction status on the token details screen. You can close this screen and check the transaction status on the token details screen. Via + You will pay Group By balance Organize tokens From 89e0a3ca45811a1cdec0ab93d227563aa7195566 Mon Sep 17 00:00:00 2001 From: Tangem Date: Thu, 28 Aug 2025 22:33:00 +0500 Subject: [PATCH 16/19] Updated on 2026-08-14 --- .../wallet/deeplink/analytics/PromoActivationAnalytics.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/deeplink/analytics/PromoActivationAnalytics.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/deeplink/analytics/PromoActivationAnalytics.kt index 8af8c92161..bd1bbc7dcc 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/deeplink/analytics/PromoActivationAnalytics.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/deeplink/analytics/PromoActivationAnalytics.kt @@ -20,7 +20,7 @@ sealed class PromoActivationAnalytics( PromoCodeActivationResult.Failed -> "Error" PromoCodeActivationResult.InvalidPromoCode -> "Invalid" PromoCodeActivationResult.NoBitcoinAddress -> "No Address" - PromoCodeActivationResult.PromoCodeAlreadyUsed -> "Already Use" + PromoCodeActivationResult.PromoCodeAlreadyUsed -> "Already Used" PromoCodeActivationResult.Activated -> "Activated" }, ), From fb597aa0150df7ea982dd2719d17264603eeec3f Mon Sep 17 00:00:00 2001 From: Tangem Date: Fri, 29 Aug 2025 16:18:23 +0700 Subject: [PATCH 17/19] Updated on 2026-08-14 --- .../com/tangem/data/walletconnect/network/solana/Model.kt | 6 ++++++ .../data/walletconnect/network/solana/WcSolanaNetwork.kt | 4 ++-- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/data/wallet-connect/src/main/kotlin/com/tangem/data/walletconnect/network/solana/Model.kt b/data/wallet-connect/src/main/kotlin/com/tangem/data/walletconnect/network/solana/Model.kt index 3f739b3f64..fcd037a22a 100644 --- a/data/wallet-connect/src/main/kotlin/com/tangem/data/walletconnect/network/solana/Model.kt +++ b/data/wallet-connect/src/main/kotlin/com/tangem/data/walletconnect/network/solana/Model.kt @@ -19,4 +19,10 @@ internal data class WcSolanaSignTransactionRequest( @Json(name = "feePayer") val feePayer: String?, +) + +@JsonClass(generateAdapter = true) +internal data class WcSolanaSignAllTransactionRequest( + @Json(name = "transactions") + val transactions: List, ) \ No newline at end of file diff --git a/data/wallet-connect/src/main/kotlin/com/tangem/data/walletconnect/network/solana/WcSolanaNetwork.kt b/data/wallet-connect/src/main/kotlin/com/tangem/data/walletconnect/network/solana/WcSolanaNetwork.kt index 0b47155291..ebaf668ba0 100644 --- a/data/wallet-connect/src/main/kotlin/com/tangem/data/walletconnect/network/solana/WcSolanaNetwork.kt +++ b/data/wallet-connect/src/main/kotlin/com/tangem/data/walletconnect/network/solana/WcSolanaNetwork.kt @@ -112,9 +112,9 @@ internal class WcSolanaNetwork( WcSolanaMethodName.SignTransaction -> moshi.fromJson(rawParams) .getOrElse { return it.left() } ?.let { request -> WcSolanaMethod.SignTransaction(request.transaction, request.feePayer) } - WcSolanaMethodName.SendAllTransaction -> moshi.fromJson>(rawParams) + WcSolanaMethodName.SendAllTransaction -> moshi.fromJson(rawParams) .getOrElse { return it.left() } - ?.let { list -> WcSolanaMethod.SignAllTransaction(list) } + ?.let { request -> WcSolanaMethod.SignAllTransaction(request.transactions) } }.right() } From e557bc44e60d5766591c4e91c15075d238789f10 Mon Sep 17 00:00:00 2001 From: Tangem Date: Fri, 29 Aug 2025 14:00:38 +0300 Subject: [PATCH 18/19] Updated on 2026-08-14 --- core/res/src/main/res/values-es/strings.xml | 10 + core/res/src/main/res/values-ru/strings.xml | 2 +- .../src/main/res/values-uk-rUA/strings.xml | 1 + core/res/src/main/res/values/strings.xml | 5 +- .../deeplink/DefaultPromoDeeplinkHandler.kt | 30 +- .../DefaultPromoDeeplinkHandlerTest.kt | 468 +++++++++++++++++- 6 files changed, 499 insertions(+), 17 deletions(-) diff --git a/core/res/src/main/res/values-es/strings.xml b/core/res/src/main/res/values-es/strings.xml index 37b3938536..7abdfe3162 100644 --- a/core/res/src/main/res/values-es/strings.xml +++ b/core/res/src/main/res/values-es/strings.xml @@ -78,6 +78,16 @@ Por favor, inténtelo de nuevo en 30 segundos o escanee la tarjeta/anillo Demasiados intentos Ha desactivado la autenticación biométrica en su teléfono y no podrá guardar billeteras en la aplicación. Para guardar billeteras, active la función de autenticación biométrica en los ajustes de su teléfono. + Se produjo un error al procesar tu código promocional. Inténtalo de nuevo más tarde. + Error de activación + Su código promocional se ha activado correctamente. Un bono de 10 USDT en Bitcoin se abonará en su cuenta en un plazo de 14 días. + Código promocional activado + Este código promocional ya ha sido utilizado y no puede activarse de nuevo. + Código no disponible + Este código promocional no es válido y no puede activarse. + Código inválido + Se requiere una dirección de Bitcoin para recibir el bono. Añade una a tu billetera y vuelve a intentar la activación. + Se requiere una dirección de Bitcoin Iniciar proceso de backup Utilice una tarjeta bancaria u otros métodos de pago diff --git a/core/res/src/main/res/values-ru/strings.xml b/core/res/src/main/res/values-ru/strings.xml index 899b661b01..b94dfccc7f 100644 --- a/core/res/src/main/res/values-ru/strings.xml +++ b/core/res/src/main/res/values-ru/strings.xml @@ -922,7 +922,7 @@ Обмен и отправка Продолжить с конвертацией? Это действие удалит предыдущие данные Подтвердить конвертацию - Отправьте любой токен, и мы конвертируем его по пути. Адресат получит именно то, что нужно — без лишних действий. + Выберите любой токен к получению. Ваш адресат получит ровно то, что вы выбрали — без лишних сложностей. Будет получено Получателю Сумма к получению 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 21880f895f..1175b3d951 100644 --- a/core/res/src/main/res/values-uk-rUA/strings.xml +++ b/core/res/src/main/res/values-uk-rUA/strings.xml @@ -916,6 +916,7 @@ Недопустима сума Комісія перевищує залишок Сума, що відправляється, перевищує залишок + Виберіть будь-який токен. Той, кому ви його надішлете, отримає саме те, що ви обрали — без жодних ускладнень. Трансакцію надіслано Підготуйтеся до сканування кільця або картки, яку ви хочете налаштувати. Забути гаманець diff --git a/core/res/src/main/res/values/strings.xml b/core/res/src/main/res/values/strings.xml index d29deb2f04..e12369b185 100644 --- a/core/res/src/main/res/values/strings.xml +++ b/core/res/src/main/res/values/strings.xml @@ -530,6 +530,9 @@ Create Mobile Wallet This recovery phrase has already been imported Mobile Wallet + This device can’t be used for upgrade, it already contains another wallet. + Choose another device, this one can’t be used for upgrade. + An error occurred during the operation. 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 @@ -1059,7 +1062,7 @@ Confirm Conversion Sending any other currency will result in its irreversible loss. Select the correct recipient network - Send any token, and we’ll convert it on the way. Your recipient gets exactly what they need—seamlessly. + Choose any token to receive. Your recipient gets exactly what you selected—seamlessly. Recipient will receive To recipient Amount to receive 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 index 084ff6b3e5..e7f0ce0a1f 100644 --- 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 @@ -13,6 +13,8 @@ import com.tangem.domain.models.network.NetworkStatus import com.tangem.domain.models.wallet.UserWallet import com.tangem.domain.networks.multi.MultiNetworkStatusProducer import com.tangem.domain.networks.multi.MultiNetworkStatusSupplier +import com.tangem.domain.tokens.MultiWalletCryptoCurrenciesProducer +import com.tangem.domain.tokens.MultiWalletCryptoCurrenciesSupplier import com.tangem.domain.wallets.PromoCodeActivationResult import com.tangem.domain.wallets.PromoCodeActivationResult.* import com.tangem.domain.wallets.models.errors.ActivatePromoCodeError @@ -39,6 +41,7 @@ internal class DefaultPromoDeeplinkHandler @AssistedInject constructor( @Assisted private val queryParams: Map, @GlobalUiMessageSender private val uiMessageSender: UiMessageSender, private val multiNetworkStatusSupplier: MultiNetworkStatusSupplier, + private val multiWalletCryptoCurrenciesSupplier: MultiWalletCryptoCurrenciesSupplier, private val activateBitcoinPromocodeUseCase: ActivateBitcoinPromocodeUseCase, private val getSelectedWalletSyncUseCase: GetSelectedWalletSyncUseCase, private val analyticsEventsHandler: AnalyticsEventHandler, @@ -70,17 +73,38 @@ internal class DefaultPromoDeeplinkHandler @AssistedInject constructor( private fun findBitcoinAddress(userWallet: UserWallet, promoCode: String) { scope.launch(context = dispatchers.default) { - val bitcoinStatus = withTimeoutOrNull( + val networkStatuses = withTimeoutOrNull( FETCH_TIMEOUT_SECONDS.seconds, { multiNetworkStatusSupplier .invoke(MultiNetworkStatusProducer.Params(userWallet.walletId)) .first { statuses -> statuses.any { status -> status.network.rawId == Blockchain.Bitcoin.id } - }.first { status -> status.network.rawId == Blockchain.Bitcoin.id } + } }, ) + Timber.tag(LOG_TAG).d("All user network statuses ${networkStatuses?.size}") + + val cryptoCurrencies = multiWalletCryptoCurrenciesSupplier + .getSyncOrNull( + MultiWalletCryptoCurrenciesProducer.Params( + userWallet + .walletId, + ), + ) + + Timber.tag(LOG_TAG).d("All user cryptoCurrencies on main ${cryptoCurrencies?.size}") + + val bitcoinCurrency = cryptoCurrencies?.firstOrNull { it.id.rawNetworkId == Blockchain.Bitcoin.id } + Timber.tag(LOG_TAG).d("BitcoinCurrency $bitcoinCurrency") + + val bitcoinStatus = networkStatuses?.firstOrNull { status -> + status.network.id == bitcoinCurrency + ?.network?.id + } + Timber.tag(LOG_TAG).d("BitcoinStatus $bitcoinStatus") + if (bitcoinStatus == null) { Timber.tag(LOG_TAG).d("No bitcoin, bitcoin network status == null") showAlert(NoBitcoinAddress) @@ -112,11 +136,13 @@ internal class DefaultPromoDeeplinkHandler @AssistedInject constructor( private suspend fun activatePromoCode(bitcoinAddress: String, promoCode: String) { uiMessageSender.send(GlobalLoadingMessage(true)) activateBitcoinPromocodeUseCase(bitcoinAddress, promoCode).onRight { + delay(DEFAULT_MESSAGE_SENDER_DELAY) uiMessageSender.send(GlobalLoadingMessage(false)) delay(DEFAULT_MESSAGE_SENDER_DELAY) Timber.tag(LOG_TAG).d("${promoCode.mask()} activation success on address ${bitcoinAddress.mask()}") showAlert(Activated) }.onLeft { error -> + delay(DEFAULT_MESSAGE_SENDER_DELAY) uiMessageSender.send(GlobalLoadingMessage(false)) delay(DEFAULT_MESSAGE_SENDER_DELAY) Timber.tag(LOG_TAG).d("${promoCode.mask()} activation failed $error") 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 index 024a9415a4..b37d43ee93 100644 --- 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 @@ -10,6 +10,7 @@ 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.StatusSource import com.tangem.domain.models.network.Network import com.tangem.domain.models.network.NetworkAddress @@ -17,6 +18,7 @@ import com.tangem.domain.models.network.NetworkStatus import com.tangem.domain.models.wallet.UserWallet import com.tangem.domain.models.wallet.UserWalletId import com.tangem.domain.networks.multi.MultiNetworkStatusSupplier +import com.tangem.domain.tokens.MultiWalletCryptoCurrenciesSupplier import com.tangem.domain.wallets.models.GetUserWalletError import com.tangem.domain.wallets.models.errors.ActivatePromoCodeError import com.tangem.domain.wallets.usecase.ActivateBitcoinPromocodeUseCase @@ -26,6 +28,7 @@ import com.tangem.feature.wallet.deeplink.analytics.PromoActivationAnalytics import com.tangem.feature.wallet.impl.R import io.mockk.MockKAnnotations import io.mockk.coEvery +import io.mockk.coVerify import io.mockk.every import io.mockk.impl.annotations.MockK import io.mockk.just @@ -38,7 +41,6 @@ import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.test.StandardTestDispatcher import kotlinx.coroutines.test.TestCoroutineScheduler -import kotlinx.coroutines.test.advanceTimeBy import kotlinx.coroutines.test.advanceUntilIdle import kotlinx.coroutines.test.runTest import org.junit.Before @@ -57,6 +59,9 @@ class DefaultPromoDeeplinkHandlerTest { @MockK private lateinit var multiNetworkStatusSupplier: MultiNetworkStatusSupplier + @MockK + private lateinit var multiWalletCryptoCurrenciesSupplier: MultiWalletCryptoCurrenciesSupplier + @MockK private lateinit var activateBitcoinPromocodeUseCase: ActivateBitcoinPromocodeUseCase @@ -92,6 +97,8 @@ class DefaultPromoDeeplinkHandlerTest { emit(setOf(ethStatus)) emit(setOf(btcStatus)) } + val btcCoin = buildCryptoCurrency(rawNetworkId = Blockchain.Bitcoin.id) + coEvery { multiWalletCryptoCurrenciesSupplier.getSyncOrNull(any()) } returns setOf(btcCoin) coEvery { activateBitcoinPromocodeUseCase.invoke("bc1qxyz", promoCode) } returns Either.Right("ok") val dispatcherProvider = testDispatcherProvider(testScheduler) @@ -100,14 +107,13 @@ class DefaultPromoDeeplinkHandlerTest { queryParams = queryParams, uiMessageSender = uiMessageSender, multiNetworkStatusSupplier = multiNetworkStatusSupplier, + multiWalletCryptoCurrenciesSupplier = multiWalletCryptoCurrenciesSupplier, activateBitcoinPromocodeUseCase = activateBitcoinPromocodeUseCase, getSelectedWalletSyncUseCase = getSelectedWalletSyncUseCase, analyticsEventsHandler = analyticsEventHandler, dispatchers = dispatcherProvider, ) - advanceUntilIdle() - advanceTimeBy(500) advanceUntilIdle() val sent = messages.last { it is DialogMessage } as DialogMessage @@ -131,6 +137,7 @@ class DefaultPromoDeeplinkHandlerTest { queryParams = queryParams, uiMessageSender = uiMessageSender, multiNetworkStatusSupplier = multiNetworkStatusSupplier, + multiWalletCryptoCurrenciesSupplier = multiWalletCryptoCurrenciesSupplier, activateBitcoinPromocodeUseCase = activateBitcoinPromocodeUseCase, getSelectedWalletSyncUseCase = getSelectedWalletSyncUseCase, analyticsEventsHandler = analyticsEventHandler, @@ -159,6 +166,7 @@ class DefaultPromoDeeplinkHandlerTest { queryParams = queryParams, uiMessageSender = uiMessageSender, multiNetworkStatusSupplier = multiNetworkStatusSupplier, + multiWalletCryptoCurrenciesSupplier = multiWalletCryptoCurrenciesSupplier, activateBitcoinPromocodeUseCase = activateBitcoinPromocodeUseCase, getSelectedWalletSyncUseCase = getSelectedWalletSyncUseCase, analyticsEventsHandler = analyticsEventHandler, @@ -178,12 +186,14 @@ class DefaultPromoDeeplinkHandlerTest { } @Test - fun `GIVEN no bitcoin address WHEN getWalletCurrenciesScreen THEN no bitcoin address dialog is shown`() = runTest { + fun `GIVEN no bitcoin address WHEN findBitcoinAddress THEN no bitcoin address dialog is shown`() = runTest { val queryParams = mapOf(DeeplinkConst.PROMO_CODE_KEY to "PROMO123") val userWallet = mockUserWallet("ABCDEF") every { getSelectedWalletSyncUseCase.invoke() } returns Either.Right(userWallet) val btcUnreachable = buildUnreachableNetworkStatus(rawNetworkId = Blockchain.Bitcoin.id) coEvery { multiNetworkStatusSupplier.invoke(any()) } returns flowOf(setOf(btcUnreachable)) + val btcCoin = buildCryptoCurrency(rawNetworkId = Blockchain.Bitcoin.id) + coEvery { multiWalletCryptoCurrenciesSupplier.getSyncOrNull(any()) } returns setOf(btcCoin) val dispatcherProvider = testDispatcherProvider(testScheduler) DefaultPromoDeeplinkHandler( @@ -191,14 +201,13 @@ class DefaultPromoDeeplinkHandlerTest { queryParams = queryParams, uiMessageSender = uiMessageSender, multiNetworkStatusSupplier = multiNetworkStatusSupplier, + multiWalletCryptoCurrenciesSupplier = multiWalletCryptoCurrenciesSupplier, activateBitcoinPromocodeUseCase = activateBitcoinPromocodeUseCase, getSelectedWalletSyncUseCase = getSelectedWalletSyncUseCase, analyticsEventsHandler = analyticsEventHandler, dispatchers = dispatcherProvider, ) - advanceUntilIdle() - advanceTimeBy(500) advanceUntilIdle() val sent = messages.last { it is DialogMessage } as DialogMessage @@ -221,6 +230,8 @@ class DefaultPromoDeeplinkHandlerTest { every { getSelectedWalletSyncUseCase.invoke() } returns Either.Right(userWallet) val btcStatus = buildNetworkStatus(rawNetworkId = Blockchain.Bitcoin.id, address = "bc1qxyz") coEvery { multiNetworkStatusSupplier.invoke(any()) } returns flowOf(setOf(btcStatus)) + val btcCoin = buildCryptoCurrency(rawNetworkId = Blockchain.Bitcoin.id) + coEvery { multiWalletCryptoCurrenciesSupplier.getSyncOrNull(any()) } returns setOf(btcCoin) coEvery { activateBitcoinPromocodeUseCase.invoke("bc1qxyz", promoCode) } returns Either.Right("ok") val dispatcherProvider = testDispatcherProvider(testScheduler) @@ -229,14 +240,13 @@ class DefaultPromoDeeplinkHandlerTest { queryParams = queryParams, uiMessageSender = uiMessageSender, multiNetworkStatusSupplier = multiNetworkStatusSupplier, + multiWalletCryptoCurrenciesSupplier = multiWalletCryptoCurrenciesSupplier, activateBitcoinPromocodeUseCase = activateBitcoinPromocodeUseCase, getSelectedWalletSyncUseCase = getSelectedWalletSyncUseCase, analyticsEventsHandler = analyticsEventHandler, dispatchers = dispatcherProvider, ) - advanceUntilIdle() - advanceTimeBy(500) advanceUntilIdle() val sent = messages.last { it is DialogMessage } as DialogMessage @@ -326,6 +336,8 @@ class DefaultPromoDeeplinkHandlerTest { every { getSelectedWalletSyncUseCase.invoke() } returns Either.Right(userWallet) val btcStatus = buildNetworkStatus(rawNetworkId = Blockchain.Bitcoin.id, address = "bc1qxyz") coEvery { multiNetworkStatusSupplier.invoke(any()) } returns flowOf(setOf(btcStatus)) + val btcCurrency = buildCryptoCurrency(rawNetworkId = Blockchain.Bitcoin.id) + coEvery { multiWalletCryptoCurrenciesSupplier.getSyncOrNull(any()) } returns setOf(btcCurrency) coEvery { activateBitcoinPromocodeUseCase.invoke("bc1qxyz", promoCode) } returns Either.Left(error) val dispatcherProvider = testDispatcherProvider(testScheduler) @@ -334,14 +346,13 @@ class DefaultPromoDeeplinkHandlerTest { queryParams = queryParams, uiMessageSender = uiMessageSender, multiNetworkStatusSupplier = multiNetworkStatusSupplier, + multiWalletCryptoCurrenciesSupplier = multiWalletCryptoCurrenciesSupplier, activateBitcoinPromocodeUseCase = activateBitcoinPromocodeUseCase, getSelectedWalletSyncUseCase = getSelectedWalletSyncUseCase, analyticsEventsHandler = analyticsEventHandler, dispatchers = dispatcherProvider, ) - advanceUntilIdle() - advanceTimeBy(500) advanceUntilIdle() val sent = messages.last { it is DialogMessage } as DialogMessage @@ -349,20 +360,418 @@ class DefaultPromoDeeplinkHandlerTest { Truth.assertThat(sent.message).isEqualTo(expectedMessage) } + @Test + fun `GIVEN BTC status but currencies without BTC WHEN findBitcoinAddress THEN no bitcoin address dialog is shown`() = runTest { + val queryParams = mapOf(DeeplinkConst.PROMO_CODE_KEY to "PROMO123") + val userWallet = mockUserWallet("ABCDEF") + every { getSelectedWalletSyncUseCase.invoke() } returns Either.Right(userWallet) + val btcStatus = buildNetworkStatus(rawNetworkId = Blockchain.Bitcoin.id, address = "bc1qxyz") + coEvery { multiNetworkStatusSupplier.invoke(any()) } returns flowOf(setOf(btcStatus)) + val ethOnly = buildCryptoCurrency(rawNetworkId = "ethereum") + coEvery { multiWalletCryptoCurrenciesSupplier.getSyncOrNull(any()) } returns setOf(ethOnly) + val dispatcherProvider = testDispatcherProvider(testScheduler) + + DefaultPromoDeeplinkHandler( + scope = this, + queryParams = queryParams, + uiMessageSender = uiMessageSender, + multiNetworkStatusSupplier = multiNetworkStatusSupplier, + multiWalletCryptoCurrenciesSupplier = multiWalletCryptoCurrenciesSupplier, + activateBitcoinPromocodeUseCase = activateBitcoinPromocodeUseCase, + getSelectedWalletSyncUseCase = getSelectedWalletSyncUseCase, + analyticsEventsHandler = analyticsEventHandler, + dispatchers = dispatcherProvider, + ) + + advanceUntilIdle() + + val sent = messages.last { it is DialogMessage } as DialogMessage + Truth.assertThat(sent.title).isEqualTo(resourceReference(R.string.bitcoin_promo_no_address_title)) + Truth.assertThat(sent.message).isEqualTo(resourceReference(R.string.bitcoin_promo_no_address)) + } + + @Test + fun `GIVEN multiple statuses emissions and currencies without BTC WHEN findBitcoinAddress THEN no bitcoin address dialog is shown`() = runTest { + val promoCode = "PROMO123" + val queryParams = mapOf(DeeplinkConst.PROMO_CODE_KEY to promoCode) + val userWallet = mockUserWallet("ABCDEF") + every { getSelectedWalletSyncUseCase.invoke() } returns Either.Right(userWallet) + + val ethStatus = buildNetworkStatus(rawNetworkId = "ethereum", address = "0x123") + val btcStatus = buildNetworkStatus(rawNetworkId = Blockchain.Bitcoin.id, address = "bc1qxyz") + coEvery { multiNetworkStatusSupplier.invoke(any()) } returns flow { + emit(emptySet()) + emit(setOf(ethStatus)) + emit(setOf(ethStatus, btcStatus)) + } + + val ethOnly = buildCryptoCurrency(rawNetworkId = "ethereum") + coEvery { multiWalletCryptoCurrenciesSupplier.getSyncOrNull(any()) } returns setOf(ethOnly) + + val dispatcherProvider = testDispatcherProvider(testScheduler) + + DefaultPromoDeeplinkHandler( + scope = this, + queryParams = queryParams, + uiMessageSender = uiMessageSender, + multiNetworkStatusSupplier = multiNetworkStatusSupplier, + multiWalletCryptoCurrenciesSupplier = multiWalletCryptoCurrenciesSupplier, + activateBitcoinPromocodeUseCase = activateBitcoinPromocodeUseCase, + getSelectedWalletSyncUseCase = getSelectedWalletSyncUseCase, + analyticsEventsHandler = analyticsEventHandler, + dispatchers = dispatcherProvider, + ) + + advanceUntilIdle() + + val sent = messages.last { it is DialogMessage } as DialogMessage + Truth.assertThat(sent.title).isEqualTo(resourceReference(R.string.bitcoin_promo_no_address_title)) + Truth.assertThat(sent.message).isEqualTo(resourceReference(R.string.bitcoin_promo_no_address)) + + verify(exactly = 1) { analyticsEventHandler.send(PromoActivationAnalytics.PromoDeepLinkActivationStart) } + verify(exactly = 1) { + analyticsEventHandler.send( + PromoActivationAnalytics.PromoActivation(PromoCodeActivationResult.NoBitcoinAddress), + ) + } + } + + @Test + fun `GIVEN two BTC statuses with different derivation AND two BTC currencies WHEN activate THEN activated`() = + runTest { + val promoCode = "PROMO123" + val queryParams = mapOf(DeeplinkConst.PROMO_CODE_KEY to promoCode) + val userWallet = mockUserWallet("ABCDEF") + every { getSelectedWalletSyncUseCase.invoke() } returns Either.Right(userWallet) + + val dpCard = Network.DerivationPath.Card("m/44'/0'/0'") + val dpCustom = Network.DerivationPath.Custom("m/84'/0'/0'") + + val btcStatusCard = buildNetworkStatus( + rawNetworkId = Blockchain.Bitcoin.id, + address = "bc1qcard", + derivationPath = dpCard, + ) + val btcStatusCustom = buildNetworkStatus( + rawNetworkId = Blockchain.Bitcoin.id, + address = "bc1qcustom", + derivationPath = dpCustom, + ) + coEvery { multiNetworkStatusSupplier.invoke(any()) } returns flowOf(setOf(btcStatusCard, btcStatusCustom)) + + val btcCoinCustom = buildCryptoCurrency(rawNetworkId = Blockchain.Bitcoin.id, derivationPath = dpCustom) + val btcCoinCard = buildCryptoCurrency(rawNetworkId = Blockchain.Bitcoin.id, derivationPath = dpCard) + coEvery { multiWalletCryptoCurrenciesSupplier.getSyncOrNull(any()) } returns setOf( + btcCoinCustom, + btcCoinCard, + ) + coEvery { activateBitcoinPromocodeUseCase.invoke("bc1qcustom", promoCode) } returns Either.Right("ok") + + val dispatcherProvider = testDispatcherProvider(testScheduler) + + DefaultPromoDeeplinkHandler( + scope = this, + queryParams = queryParams, + uiMessageSender = uiMessageSender, + multiNetworkStatusSupplier = multiNetworkStatusSupplier, + multiWalletCryptoCurrenciesSupplier = multiWalletCryptoCurrenciesSupplier, + activateBitcoinPromocodeUseCase = activateBitcoinPromocodeUseCase, + getSelectedWalletSyncUseCase = getSelectedWalletSyncUseCase, + analyticsEventsHandler = analyticsEventHandler, + dispatchers = dispatcherProvider, + ) + + advanceUntilIdle() + + val sent = messages.last { it is DialogMessage } as DialogMessage + Truth.assertThat(sent.title).isEqualTo(resourceReference(R.string.bitcoin_promo_activation_success_title)) + Truth.assertThat(sent.message).isEqualTo(resourceReference(R.string.bitcoin_promo_activation_success)) + + coVerify(exactly = 1) { activateBitcoinPromocodeUseCase.invoke("bc1qcustom", promoCode) } + coVerify(exactly = 0) { activateBitcoinPromocodeUseCase.invoke("bc1qcard", promoCode) } + } + + @Test + fun `GIVEN two BTC statuses with different derivation AND one matching BTC currency WHEN activate THEN activated`() = runTest { + val promoCode = "PROMO123" + val queryParams = mapOf(DeeplinkConst.PROMO_CODE_KEY to promoCode) + val userWallet = mockUserWallet("ABCDEF") + every { getSelectedWalletSyncUseCase.invoke() } returns Either.Right(userWallet) + + val dpCard = Network.DerivationPath.Card("m/44'/0'/0'") + val dpCustom = Network.DerivationPath.Custom("m/84'/0'/0'") + + val btcStatusCard = buildNetworkStatus( + rawNetworkId = Blockchain.Bitcoin.id, + address = "bc1qcard", + derivationPath = dpCard, + ) + val btcStatusCustom = buildNetworkStatus( + rawNetworkId = Blockchain.Bitcoin.id, + address = "bc1qcustom", + derivationPath = dpCustom, + ) + coEvery { multiNetworkStatusSupplier.invoke(any()) } returns flowOf(setOf(btcStatusCard, btcStatusCustom)) + + val btcCoinCard = buildCryptoCurrency(rawNetworkId = Blockchain.Bitcoin.id, derivationPath = dpCard) + coEvery { multiWalletCryptoCurrenciesSupplier.getSyncOrNull(any()) } returns setOf(btcCoinCard) + + coEvery { activateBitcoinPromocodeUseCase.invoke("bc1qcard", promoCode) } returns Either.Right("ok") + + val dispatcherProvider = testDispatcherProvider(testScheduler) + + DefaultPromoDeeplinkHandler( + scope = this, + queryParams = queryParams, + uiMessageSender = uiMessageSender, + multiNetworkStatusSupplier = multiNetworkStatusSupplier, + multiWalletCryptoCurrenciesSupplier = multiWalletCryptoCurrenciesSupplier, + activateBitcoinPromocodeUseCase = activateBitcoinPromocodeUseCase, + getSelectedWalletSyncUseCase = getSelectedWalletSyncUseCase, + analyticsEventsHandler = analyticsEventHandler, + dispatchers = dispatcherProvider, + ) + + advanceUntilIdle() + + val sent = messages.last { it is DialogMessage } as DialogMessage + Truth.assertThat(sent.title).isEqualTo(resourceReference(R.string.bitcoin_promo_activation_success_title)) + Truth.assertThat(sent.message).isEqualTo(resourceReference(R.string.bitcoin_promo_activation_success)) + + coVerify(exactly = 1) { activateBitcoinPromocodeUseCase.invoke("bc1qcard", promoCode) } + coVerify(exactly = 0) { activateBitcoinPromocodeUseCase.invoke("bc1qcustom", promoCode) } + + verify(exactly = 1) { analyticsEventHandler.send(PromoActivationAnalytics.PromoDeepLinkActivationStart) } + verify(exactly = 1) { + analyticsEventHandler.send( + PromoActivationAnalytics.PromoActivation(PromoCodeActivationResult.Activated), + ) + } + } + + @Test + fun `GIVEN two BTC statuses with different derivation AND no BTC currencies WHEN findBitcoinAddress THEN no bitcoin address dialog is shown`() = runTest { + val promoCode = "PROMO123" + val queryParams = mapOf(DeeplinkConst.PROMO_CODE_KEY to promoCode) + val userWallet = mockUserWallet("ABCDEF") + every { getSelectedWalletSyncUseCase.invoke() } returns Either.Right(userWallet) + + val dpCard = Network.DerivationPath.Card("m/44'/0'/0'") + val dpCustom = Network.DerivationPath.Custom("m/84'/0'/0'") + + val btcStatusCard = buildNetworkStatus( + rawNetworkId = Blockchain.Bitcoin.id, + address = "bc1qcard", + derivationPath = dpCard, + ) + val btcStatusCustom = buildNetworkStatus( + rawNetworkId = Blockchain.Bitcoin.id, + address = "bc1qcustom", + derivationPath = dpCustom, + ) + coEvery { multiNetworkStatusSupplier.invoke(any()) } returns flowOf(setOf(btcStatusCard, btcStatusCustom)) + + coEvery { multiWalletCryptoCurrenciesSupplier.getSyncOrNull(any()) } returns emptySet() + + val dispatcherProvider = testDispatcherProvider(testScheduler) + + DefaultPromoDeeplinkHandler( + scope = this, + queryParams = queryParams, + uiMessageSender = uiMessageSender, + multiNetworkStatusSupplier = multiNetworkStatusSupplier, + multiWalletCryptoCurrenciesSupplier = multiWalletCryptoCurrenciesSupplier, + activateBitcoinPromocodeUseCase = activateBitcoinPromocodeUseCase, + getSelectedWalletSyncUseCase = getSelectedWalletSyncUseCase, + analyticsEventsHandler = analyticsEventHandler, + dispatchers = dispatcherProvider, + ) + + advanceUntilIdle() + + val sent = messages.last { it is DialogMessage } as DialogMessage + Truth.assertThat(sent.title).isEqualTo(resourceReference(R.string.bitcoin_promo_no_address_title)) + Truth.assertThat(sent.message).isEqualTo(resourceReference(R.string.bitcoin_promo_no_address)) + + coVerify(exactly = 0) { activateBitcoinPromocodeUseCase.invoke(any(), any()) } + + verify(exactly = 1) { analyticsEventHandler.send(PromoActivationAnalytics.PromoDeepLinkActivationStart) } + verify(exactly = 1) { + analyticsEventHandler.send( + PromoActivationAnalytics.PromoActivation(PromoCodeActivationResult.NoBitcoinAddress), + ) + } + } + + @Test + fun `GIVEN two BTC currencies first derivation mismatched AND one matching status WHEN findBitcoinAddress THEN no bitcoin address dialog is shown`() = runTest { + val promoCode = "PROMO123" + val queryParams = mapOf(DeeplinkConst.PROMO_CODE_KEY to promoCode) + val userWallet = mockUserWallet("ABCDEF") + every { getSelectedWalletSyncUseCase.invoke() } returns Either.Right(userWallet) + + val dpCard = Network.DerivationPath.Card("m/44'/0'/0'") + val dpCustom = Network.DerivationPath.Custom("m/84'/0'/0'") + + val btcStatusCard = buildNetworkStatus( + rawNetworkId = Blockchain.Bitcoin.id, + address = "bc1qcard", + derivationPath = dpCard, + ) + coEvery { multiNetworkStatusSupplier.invoke(any()) } returns flowOf(setOf(btcStatusCard)) + + val btcCoinCustom = buildCryptoCurrency(rawNetworkId = Blockchain.Bitcoin.id, derivationPath = dpCustom) + val btcCoinCard = buildCryptoCurrency(rawNetworkId = Blockchain.Bitcoin.id, derivationPath = dpCard) + coEvery { multiWalletCryptoCurrenciesSupplier.getSyncOrNull(any()) } returns setOf(btcCoinCustom, btcCoinCard) + + val dispatcherProvider = testDispatcherProvider(testScheduler) + + DefaultPromoDeeplinkHandler( + scope = this, + queryParams = queryParams, + uiMessageSender = uiMessageSender, + multiNetworkStatusSupplier = multiNetworkStatusSupplier, + multiWalletCryptoCurrenciesSupplier = multiWalletCryptoCurrenciesSupplier, + activateBitcoinPromocodeUseCase = activateBitcoinPromocodeUseCase, + getSelectedWalletSyncUseCase = getSelectedWalletSyncUseCase, + analyticsEventsHandler = analyticsEventHandler, + dispatchers = dispatcherProvider, + ) + + advanceUntilIdle() + + val sent = messages.last { it is DialogMessage } as DialogMessage + Truth.assertThat(sent.title).isEqualTo(resourceReference(R.string.bitcoin_promo_no_address_title)) + Truth.assertThat(sent.message).isEqualTo(resourceReference(R.string.bitcoin_promo_no_address)) + + coVerify(exactly = 0) { activateBitcoinPromocodeUseCase.invoke(any(), any()) } + + verify(exactly = 1) { analyticsEventHandler.send(PromoActivationAnalytics.PromoDeepLinkActivationStart) } + verify(exactly = 1) { + analyticsEventHandler.send( + PromoActivationAnalytics.PromoActivation(PromoCodeActivationResult.NoBitcoinAddress), + ) + } + } + + @Test + fun `GIVEN only BTC status with CUSTOM derivation AND only BTC currency with CARD derivation WHEN findBitcoinAddress THEN no bitcoin address dialog is shown`() = runTest { + val promoCode = "PROMO123" + val queryParams = mapOf(DeeplinkConst.PROMO_CODE_KEY to promoCode) + val userWallet = mockUserWallet("ABCDEF") + every { getSelectedWalletSyncUseCase.invoke() } returns Either.Right(userWallet) + + val dpCard = Network.DerivationPath.Card("m/44'/0'/0'") + val dpCustom = Network.DerivationPath.Custom("m/84'/0'/0'") + + val btcStatusCustom = buildNetworkStatus( + rawNetworkId = Blockchain.Bitcoin.id, + address = "bc1qcustom", + derivationPath = dpCustom, + ) + coEvery { multiNetworkStatusSupplier.invoke(any()) } returns flowOf(setOf(btcStatusCustom)) + + val btcCoinCard = buildCryptoCurrency(rawNetworkId = Blockchain.Bitcoin.id, derivationPath = dpCard) + coEvery { multiWalletCryptoCurrenciesSupplier.getSyncOrNull(any()) } returns setOf(btcCoinCard) + + val dispatcherProvider = testDispatcherProvider(testScheduler) + + DefaultPromoDeeplinkHandler( + scope = this, + queryParams = queryParams, + uiMessageSender = uiMessageSender, + multiNetworkStatusSupplier = multiNetworkStatusSupplier, + multiWalletCryptoCurrenciesSupplier = multiWalletCryptoCurrenciesSupplier, + activateBitcoinPromocodeUseCase = activateBitcoinPromocodeUseCase, + getSelectedWalletSyncUseCase = getSelectedWalletSyncUseCase, + analyticsEventsHandler = analyticsEventHandler, + dispatchers = dispatcherProvider, + ) + + advanceUntilIdle() + + val sent = messages.last { it is DialogMessage } as DialogMessage + Truth.assertThat(sent.title).isEqualTo(resourceReference(R.string.bitcoin_promo_no_address_title)) + Truth.assertThat(sent.message).isEqualTo(resourceReference(R.string.bitcoin_promo_no_address)) + + coVerify(exactly = 0) { activateBitcoinPromocodeUseCase.invoke(any(), any()) } + + verify(exactly = 1) { analyticsEventHandler.send(PromoActivationAnalytics.PromoDeepLinkActivationStart) } + verify(exactly = 1) { + analyticsEventHandler.send( + PromoActivationAnalytics.PromoActivation(PromoCodeActivationResult.NoBitcoinAddress), + ) + } + } + + @Test + fun `GIVEN only BTC status with CARD derivation AND only BTC currency with CUSTOM derivation WHEN findBitcoinAddress THEN no bitcoin address dialog is shown`() = runTest { + val promoCode = "PROMO123" + val queryParams = mapOf(DeeplinkConst.PROMO_CODE_KEY to promoCode) + val userWallet = mockUserWallet("ABCDEF") + every { getSelectedWalletSyncUseCase.invoke() } returns Either.Right(userWallet) + + val dpCard = Network.DerivationPath.Card("m/44'/0'/0'") + val dpCustom = Network.DerivationPath.Custom("m/84'/0'/0'") + + val btcStatusCard = buildNetworkStatus( + rawNetworkId = Blockchain.Bitcoin.id, + address = "bc1qcard", + derivationPath = dpCard, + ) + coEvery { multiNetworkStatusSupplier.invoke(any()) } returns flowOf(setOf(btcStatusCard)) + + val btcCoinCustom = buildCryptoCurrency(rawNetworkId = Blockchain.Bitcoin.id, derivationPath = dpCustom) + coEvery { multiWalletCryptoCurrenciesSupplier.getSyncOrNull(any()) } returns setOf(btcCoinCustom) + + val dispatcherProvider = testDispatcherProvider(testScheduler) + + DefaultPromoDeeplinkHandler( + scope = this, + queryParams = queryParams, + uiMessageSender = uiMessageSender, + multiNetworkStatusSupplier = multiNetworkStatusSupplier, + multiWalletCryptoCurrenciesSupplier = multiWalletCryptoCurrenciesSupplier, + activateBitcoinPromocodeUseCase = activateBitcoinPromocodeUseCase, + getSelectedWalletSyncUseCase = getSelectedWalletSyncUseCase, + analyticsEventsHandler = analyticsEventHandler, + dispatchers = dispatcherProvider, + ) + + advanceUntilIdle() + + val sent = messages.last { it is DialogMessage } as DialogMessage + Truth.assertThat(sent.title).isEqualTo(resourceReference(R.string.bitcoin_promo_no_address_title)) + Truth.assertThat(sent.message).isEqualTo(resourceReference(R.string.bitcoin_promo_no_address)) + + coVerify(exactly = 0) { activateBitcoinPromocodeUseCase.invoke(any(), any()) } + + verify(exactly = 1) { analyticsEventHandler.send(PromoActivationAnalytics.PromoDeepLinkActivationStart) } + verify(exactly = 1) { + analyticsEventHandler.send( + PromoActivationAnalytics.PromoActivation(PromoCodeActivationResult.NoBitcoinAddress), + ) + } + } + private fun mockUserWallet(id: String): UserWallet { val userWallet = mockk(relaxed = true) every { userWallet.walletId } returns UserWalletId(id) return userWallet } - private fun buildNetworkStatus(rawNetworkId: String, address: String): NetworkStatus { - val networkId = Network.ID(Network.RawID(rawNetworkId), Network.DerivationPath.None) + private fun buildNetworkStatus( + rawNetworkId: String, + address: String, + derivationPath: Network.DerivationPath = Network.DerivationPath.None, + ): NetworkStatus { + val networkId = Network.ID(Network.RawID(rawNetworkId), derivationPath) val network = Network( id = networkId, backendId = rawNetworkId, name = rawNetworkId, currencySymbol = rawNetworkId.take(3).uppercase(), - derivationPath = Network.DerivationPath.None, + derivationPath = derivationPath, isTestnet = false, standardType = Network.StandardType.Unspecified("UNSPECIFIED"), hasFiatFeeRate = false, @@ -405,6 +814,39 @@ class DefaultPromoDeeplinkHandlerTest { return NetworkStatus(network = network, value = value) } + private fun buildCryptoCurrency( + rawNetworkId: String, + derivationPath: Network.DerivationPath = Network.DerivationPath.None, + ): CryptoCurrency.Coin { + val networkId = Network.ID(Network.RawID(rawNetworkId), derivationPath) + val network = Network( + id = networkId, + backendId = rawNetworkId, + name = rawNetworkId, + currencySymbol = rawNetworkId.take(3).uppercase(), + derivationPath = derivationPath, + isTestnet = false, + standardType = Network.StandardType.Unspecified("UNSPECIFIED"), + hasFiatFeeRate = false, + canHandleTokens = false, + transactionExtrasType = Network.TransactionExtrasType.NONE, + ) + + return CryptoCurrency.Coin( + id = CryptoCurrency.ID( + prefix = CryptoCurrency.ID.Prefix.COIN_PREFIX, + body = CryptoCurrency.ID.Body.NetworkId(rawNetworkId), + suffix = CryptoCurrency.ID.Suffix.RawID(rawNetworkId), + ), + network = network, + name = rawNetworkId, + symbol = rawNetworkId.take(3).uppercase(), + decimals = 8, + iconUrl = null, + isCustom = false, + ) + } + private fun testDispatcherProvider(scheduler: TestCoroutineScheduler): CoroutineDispatcherProvider { val dispatcher: CoroutineDispatcher = StandardTestDispatcher(scheduler) return object : CoroutineDispatcherProvider { From 3dc0dc3686948a7d04dea3620c27f3051d3af078 Mon Sep 17 00:00:00 2001 From: Tangem Date: Mon, 1 Sep 2025 07:38:00 +0000 Subject: [PATCH 19/19] Updated on 2026-08-14 --- gradle/tangem_dependencies.toml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/gradle/tangem_dependencies.toml b/gradle/tangem_dependencies.toml index 57e50cf4ac..05f6e8b112 100644 --- a/gradle/tangem_dependencies.toml +++ b/gradle/tangem_dependencies.toml @@ -5,13 +5,13 @@ # https://github.com/tangem/tangem-sdk-android/ # https://github.com/tangem/vico -tangemBlockchainSdk = "releases-5.27.0-1148" +tangemBlockchainSdk = "releases-5.28-1211" #tangemBlockchainSdk = "0.0.1" # Keep it! - used for local builds -tangemCardSdk = "releases-5.27.0-519" +tangemCardSdk = "releases-5.28-559" #tangemCardSdk = "0.0.1" # Keep it! - used for local builds ^ tangemVico = "2.0.0-alpha.25-tangem12" #tangemVico = "0.0.1" # Keep it! - used for local builds ^ -tangemHotSdk = "develop-454" +tangemHotSdk = "develop-461" #tangemHotSdk = "0.0.1" # Keep it! - used for local builds ^