Updated on 2026-08-14

This commit is contained in:
Tangem 2026-07-14 14:50:24 +03:00
commit 50fe9e603d
1919 changed files with 94940 additions and 15178 deletions

View file

@ -28,6 +28,7 @@ import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
import androidx.lifecycle.flowWithLifecycle
import androidx.lifecycle.lifecycleScope
import arrow.core.getOrElse
import com.appsflyer.AppsFlyerLib
import com.tangem.common.routing.AppRouter
import com.tangem.common.routing.deeplink.DeeplinkConst.WEBLINK_KEY
import com.tangem.common.routing.deeplink.PayloadToDeeplinkConverter
@ -367,6 +368,9 @@ class MainActivity : AppCompatActivity(), ActivityResultCallbackHolder {
super.onNewIntent(intent)
TangemLogger.i("onNewIntent: data=${intent.data}, extras=${intent.extras?.keySet()}")
// Warm start: let the AppsFlyer SDK resolve a OneLink delivered while the app is already running.
AppsFlyerLib.getInstance().performOnDeepLinking(intent, this)
val isFromPush = intent.extras?.containsKey(OPENED_FROM_GCM_PUSH) == true
if (isFromPush) {
analyticsEventsHandler.send(Push.PushNotificationOpened())

View file

@ -13,17 +13,9 @@ class AppsFlyerDeepLinkListener @Inject constructor(
override fun onDeepLinking(p0: DeepLinkResult) {
when (p0.status) {
DeepLinkResult.Status.FOUND -> {
referralParamsHandler.handleDeeplink(deepLink = p0.deepLink)
}
DeepLinkResult.Status.NOT_FOUND -> {
referralParamsHandler.handleNoDeeplink()
TangemLogger.i("No deep link found")
}
DeepLinkResult.Status.ERROR -> {
referralParamsHandler.handleNoDeeplink()
TangemLogger.e("Deep link error: ${p0.error}")
}
DeepLinkResult.Status.FOUND -> referralParamsHandler.handleDeeplink(deepLink = p0.deepLink)
DeepLinkResult.Status.NOT_FOUND -> TangemLogger.i("No deep link found")
DeepLinkResult.Status.ERROR -> TangemLogger.e("Deep link error: ${p0.error}")
}
}
}

View file

@ -0,0 +1,97 @@
package com.tangem.tap.common.analytics.appsflyer
import com.tangem.common.routing.AppRoute
import com.tangem.common.routing.AppRouter
import com.tangem.core.configtoggle.FeatureToggles
import com.tangem.core.configtoggle.feature.FeatureTogglesManager
import com.tangem.datasource.local.appsflyer.AppsFlyerStore
import com.tangem.domain.appsflyer.AppsFlyerDeeplink
import com.tangem.domain.appsflyer.usecase.ClearAppsFlyerDeeplinkUseCase
import com.tangem.domain.common.wallets.UserWalletsListRepository
import com.tangem.utils.logging.TangemLogger
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import javax.inject.Inject
import javax.inject.Singleton
/**
* Reactively routes AppsFlyer deep links persisted by [AppsFlyerReferralParamsHandler].
*
* To add a deep link: add it to [AppsFlyerDeeplink] and a branch in [onDeeplinkPending] (the `when` is exhaustive).
*/
@Singleton
class AppsFlyerDeeplinkRouter @Inject constructor(
private val appsFlyerStore: AppsFlyerStore,
private val userWalletsListRepository: UserWalletsListRepository,
private val clearAppsFlyerDeeplinkUseCase: ClearAppsFlyerDeeplinkUseCase,
private val featureTogglesManager: FeatureTogglesManager,
private val appRouter: AppRouter,
) {
fun observe(scope: CoroutineScope, currentRoute: Flow<AppRoute?>) {
combine(
appsFlyerStore.observeNavigationDeeplink(),
currentRoute.distinctUntilChanged(),
) { deepLinkValue, route ->
if (deepLinkValue != null && route != null) deepLinkValue to route else null
}
.filterNotNull()
.onEach { (deepLinkValue, route) -> onDeeplinkPending(deepLinkValue, route) }
.launchIn(scope)
}
private suspend fun onDeeplinkPending(deepLinkValue: String, currentRoute: AppRoute) {
when (AppsFlyerDeeplink.from(deepLinkValue)) {
AppsFlyerDeeplink.TangemPayMobileOnboarding -> routeTangemPayOnboarding(currentRoute)
AppsFlyerDeeplink.Referral -> routeReferral(currentRoute)
null -> TangemLogger.i("Ignoring unknown AppsFlyer deep link value: $deepLinkValue")
}
}
private suspend fun routeTangemPayOnboarding(currentRoute: AppRoute) {
val isEnabled = featureTogglesManager.isFeatureEnabled(
FeatureToggles.AND_15101_TANGEM_PAY_HOT_WALLET_ONBOARDING,
)
if (!isEnabled) return
// Not on an idle entry screen yet: keep the deep link pending, re-evaluate on next route change.
if (!isIdleEntryPoint(currentRoute)) return
if (userWalletsListRepository.userWalletsSync().isNotEmpty()) {
TangemLogger.i("[TangemPay][HWO] Routing AppsFlyer deep link to Tangem Pay onboarding")
// Authorized: push onto the wallet screen so Back returns to it.
appRouter.push(AppRoute.TangemPayOnboarding(AppRoute.TangemPayOnboarding.Mode.MobileOnboardingDeeplink))
} else {
TangemLogger.i("[TangemPay][HWO] Routing AppsFlyer deep link to hot wallet onboarding")
// Not authorized: open onboarding as the root, skipping Home/stories (original cold-start flow).
appRouter.replaceAll(AppRoute.TangemPayHotWalletOnboarding)
}
// One-shot: consume the deep link once routed so the user isn't forced back here on relaunch.
clearAppsFlyerDeeplinkUseCase()
}
private suspend fun routeReferral(currentRoute: AppRoute) {
val isEnabled = featureTogglesManager.isFeatureEnabled(
FeatureToggles.TWI_1512_HIDE_STORIES_FOR_REFERRAL_ENABLED,
)
if (!isEnabled) return
// Referral targets fresh installs: from an idle entry screen, go straight to hot wallet creation
// (skips stories). The deep link is NOT cleared here — it stays as referral attribution (read by
// IsReferralInstallUseCase, cleared on wallet creation); replaceAll keeps it off the back stack.
if (!isIdleEntryPoint(currentRoute)) return
if (userWalletsListRepository.userWalletsSync().isNotEmpty()) return
TangemLogger.i("[Referral] Routing AppsFlyer referral deep link to hot wallet creation")
appRouter.replaceAll(AppRoute.CreateWalletStart(mode = AppRoute.CreateWalletStart.Mode.HotWallet))
}
}
// Route only from idle entry screens so an in-progress flow (scan, KYC, onboarding…) isn't interrupted.
internal fun isIdleEntryPoint(currentRoute: AppRoute?): Boolean =
currentRoute is AppRoute.Home || currentRoute is AppRoute.Stories || currentRoute is AppRoute.Wallet

View file

@ -1,12 +1,11 @@
package com.tangem.tap.common.analytics.appsflyer
import com.appsflyer.deeplink.DeepLink
import com.tangem.datasource.local.appsflyer.AppsFlyerDeeplinkSource
import com.tangem.datasource.local.appsflyer.AppsFlyerStore
import com.tangem.domain.appsflyer.AppsFlyerDeeplink
import com.tangem.domain.wallets.models.AppsFlyerConversionData
import com.tangem.utils.coroutines.AppCoroutineScope
import com.tangem.utils.logging.TangemLogger
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.launch
import javax.inject.Inject
import javax.inject.Singleton
@ -19,8 +18,6 @@ class AppsFlyerReferralParamsHandler @Inject constructor(
private val coroutineScope: AppCoroutineScope,
) {
private val deepLinkDeferred = CompletableDeferred<String?>()
fun handle(params: Map<String?, Any?>) {
handle(
deepLinkValue = params[DEEP_LINK_VALUE] as? String,
@ -35,40 +32,15 @@ class AppsFlyerReferralParamsHandler @Inject constructor(
deepLinkSub1 = deepLink.getStringValue(DEEP_LINK_SUB_1),
deepLinkSub2 = deepLink.getStringValue(DEEP_LINK_SUB_2),
)
deepLinkDeferred.complete(deepLink.deepLinkValue)
}
fun handleNoDeeplink() {
deepLinkDeferred.complete(null)
}
suspend fun waitForDeeplink(deeplinkSource: AppsFlyerDeeplinkSource): String? {
appsFlyerStore.getDeeplink(deeplinkSource)?.let { return it }
val expectedValue = when (deeplinkSource) {
AppsFlyerDeeplinkSource.TangemPayHotWalletOnboarding -> TANGEM_PAY_HOT_WALLET_ONBOARDING_DEEP_LINK_VALUE
AppsFlyerDeeplinkSource.Referral -> REFERRAL_DEEP_LINK_VALUE
}
val resolvedValue = deepLinkDeferred.await().takeIf { it == expectedValue }
// The deep link may have been persisted to the store while we were awaiting (e.g. from
// conversion-data handling, which stores the deep link but doesn't complete the deferred).
return resolvedValue ?: appsFlyerStore.getDeeplink(deeplinkSource)
}
private fun handle(deepLinkValue: String?, deepLinkSub1: String?, deepLinkSub2: String?) {
TangemLogger.i("AppsFlyer deeplink received: value=$deepLinkValue")
when (deepLinkValue) {
REFERRAL_DEEP_LINK_VALUE -> handleReferral(deepLinkSub1, deepLinkSub2)
TANGEM_PAY_HOT_WALLET_ONBOARDING_DEEP_LINK_VALUE -> handleTangemPayHotWalletOnboarding(deepLinkValue)
else -> TangemLogger.i("Ignoring deep link with value: ${deepLinkValue ?: "null"}")
}
}
private fun handleTangemPayHotWalletOnboarding(deepLinkValue: String) {
coroutineScope.launch {
appsFlyerStore.storeDeeplink(AppsFlyerDeeplinkSource.TangemPayHotWalletOnboarding, deepLinkValue)
TangemLogger.i("[TangemPay][HWO] Deep link stored")
when (AppsFlyerDeeplink.from(deepLinkValue)) {
AppsFlyerDeeplink.Referral -> handleReferral(deepLinkSub1, deepLinkSub2)
AppsFlyerDeeplink.TangemPayMobileOnboarding ->
storeNavigationDeeplink(AppsFlyerDeeplink.TangemPayMobileOnboarding.deepLinkValue)
null -> TangemLogger.i("Ignoring deep link with value: ${deepLinkValue ?: "null"}")
}
}
@ -76,9 +48,7 @@ class AppsFlyerReferralParamsHandler @Inject constructor(
@Suppress("NullableToStringCall")
TangemLogger.i("refcode=$deepLinkSub1\ncampaign=$deepLinkSub2")
coroutineScope.launch {
appsFlyerStore.storeDeeplink(AppsFlyerDeeplinkSource.Referral, REFERRAL_DEEP_LINK_VALUE)
}
storeNavigationDeeplink(AppsFlyerDeeplink.Referral.deepLinkValue)
if (!isValidParam(deepLinkSub1)) {
TangemLogger.e("Deeplink conversion data is invalid")
@ -97,6 +67,13 @@ class AppsFlyerReferralParamsHandler @Inject constructor(
return value != null && value.isNotBlank() && !value.equals("null", ignoreCase = true)
}
private fun storeNavigationDeeplink(deepLinkValue: String) {
coroutineScope.launch {
appsFlyerStore.storeNavigationDeeplink(deepLinkValue)
TangemLogger.i("AppsFlyer navigation deep link stored: $deepLinkValue")
}
}
private fun storeConversionData(refcode: String, campaign: String?) {
coroutineScope.launch {
appsFlyerStore.storeIfAbsent(
@ -107,9 +84,6 @@ class AppsFlyerReferralParamsHandler @Inject constructor(
private companion object {
const val REFERRAL_DEEP_LINK_VALUE = "referral"
const val TANGEM_PAY_HOT_WALLET_ONBOARDING_DEEP_LINK_VALUE = "tpay_mobileonboard"
const val DEEP_LINK_VALUE = "deep_link_value"
const val DEEP_LINK_SUB_1 = "deep_link_sub1"
const val DEEP_LINK_SUB_2 = "deep_link_sub2"

View file

@ -36,9 +36,9 @@ class AppsFlyerClient @AssistedInject constructor(
setAppId(context.packageName)
setDebugLog(true)
subscribeForDeepLink(appsFlyerDeepLinkListener)
init(apiKey, tangemAFConversionListener, context)
subscribeForDeepLink(appsFlyerDeepLinkListener)
setOneLinkCustomDomain(BRANDED_ONELINK_DOMAIN)
TangemLogger.i("Starting AppsFlyer SDK")
start(context, apiKey, InitializationListener)
@ -100,4 +100,9 @@ class AppsFlyerClient @AssistedInject constructor(
interface Factory {
fun create(apiKey: String): AppsFlyerClient
}
private companion object {
// Branded AppsFlyer OneLink domain (matches the join.tangem.com App Link filter in AndroidManifest).
const val BRANDED_ONELINK_DOMAIN = "join.tangem.com"
}
}

View file

@ -34,18 +34,11 @@ class CustomerIoAnalyticsHandler(
class Builder : AnalyticsHandlerBuilder {
override fun build(data: AnalyticsHandlerBuilder.Data): AnalyticsHandler? {
val cdpApiKey = data.config.customerIoCdpApiKey
return if (data.logConfig.isCustomerIoLogEnabled) {
CustomerIoAnalyticsHandler(client = CustomerIoLogClient())
} else if (!cdpApiKey.isNullOrBlank()) {
CustomerIoAnalyticsHandler(
client = CustomerIoClient(
application = data.application,
cdpApiKey = cdpApiKey,
),
)
} else {
null
}
if (cdpApiKey.isNullOrBlank()) return null
return CustomerIoAnalyticsHandler(
client = CustomerIoClient(application = data.application, cdpApiKey = cdpApiKey),
)
}
}
}

View file

@ -1,23 +0,0 @@
package com.tangem.tap.common.analytics.handlers.customerio
import com.tangem.utils.logging.TangemLogger
/**
* Log client for Customer.io (used in debug mode).
*
* Logs all operations to Timber instead of sending them to Customer.io.
*/
internal class CustomerIoLogClient : CustomerIoAnalyticsClient {
private var userId: String? = null
override fun setUserId(userId: String) {
this.userId = userId
TangemLogger.withTag(CustomerIoAnalyticsHandler.ID).d("identify: userId=$userId")
}
override fun clearUserId() {
TangemLogger.withTag(CustomerIoAnalyticsHandler.ID).d("clearIdentify: previous userId=$userId")
this.userId = null
}
}

View file

@ -1,13 +1,17 @@
package com.tangem.tap.data
import android.content.Context
import android.os.Build
import com.tangem.utils.info.AppInfoProvider
import com.tangem.wallet.BuildConfig
import dagger.hilt.android.qualifiers.ApplicationContext
import java.util.Locale
import java.util.TimeZone
import javax.inject.Inject
internal class DefaultAppInfoProvider @Inject constructor() : AppInfoProvider {
internal class DefaultAppInfoProvider @Inject constructor(
@ApplicationContext private val context: Context,
) : AppInfoProvider {
override val platform: String
get() = "Android"
override val device: String
@ -18,6 +22,8 @@ internal class DefaultAppInfoProvider @Inject constructor() : AppInfoProvider {
get() = Build.VERSION.SDK_INT
override val language: String
get() = Locale.getDefault().toLanguageTag()
override val deviceScale: Float
get() = context.resources.displayMetrics.density
override val timezone: String
get() = TimeZone.getDefault().id
override val appVersion: String = BuildConfig.VERSION_NAME

View file

@ -4,15 +4,16 @@ import androidx.datastore.core.DataStore
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.offramp.model.PendingOfframp
import com.tangem.domain.offramp.model.PendingOfframp.Companion.EXPIRY_MS
import com.tangem.domain.offramp.repository.OfframpRepository
import com.tangem.tap.common.apptheme.MutableAppThemeModeHolder
import com.tangem.tap.data.converter.PendingOfframpEntryConverter
import com.tangem.tap.data.model.PendingOfframpEntry
import com.tangem.tap.network.exchangeServices.SellService
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.withContext
import java.util.UUID
import java.util.concurrent.TimeUnit
/**
* Default implementation of [OfframpRepository].
@ -27,7 +28,7 @@ internal class DefaultOfframpRepository(
private val dispatchers: CoroutineDispatcherProvider,
) : OfframpRepository {
private val pendingOfframpConverter = PendingOfframpEntryConverter()
private val converter = PendingOfframpEntryConverter()
override fun getOfframpUrl(
cryptoCurrency: CryptoCurrency,
@ -71,13 +72,17 @@ internal class DefaultOfframpRepository(
entry.requestId == requestId &&
entry.userWalletId == userWalletId.stringValue &&
entry.currencyId == currencyId &&
now - entry.createdAt < EXPIRY_MS
!entry.isExpired(now)
}
// Keep the matched record so the same redirect can be followed again until it expires; only prune the
// expired ones. The record is dropped naturally once it ages past EXPIRY_MS.
stored.filterNotExpired(now)
}
matched?.let(pendingOfframpConverter::convert)
matched?.let(converter::convert)
}
override suspend fun getAllStoredOfframps(): List<PendingOfframp> = withContext(dispatchers.io) {
pendingOfframpStore.data.first().map(converter::convert)
}
// Returns the same instance when nothing is expired, so DataStore.updateData sees an unchanged value and skips
@ -85,7 +90,5 @@ internal class DefaultOfframpRepository(
private fun List<PendingOfframpEntry>.filterNotExpired(now: Long): List<PendingOfframpEntry> =
if (none { now - it.createdAt >= EXPIRY_MS }) this else filter { now - it.createdAt < EXPIRY_MS }
private companion object {
val EXPIRY_MS: Long = TimeUnit.HOURS.toMillis(1)
}
private fun PendingOfframpEntry.isExpired(now: Long): Boolean = converter.convert(this).isExpired(now)
}

View file

@ -1,288 +0,0 @@
package com.tangem.tap.data
import android.content.Context
import com.squareup.moshi.JsonAdapter
import com.squareup.moshi.Moshi
import com.squareup.moshi.Types
import com.tangem.data.pay.entity.WithdrawStoreData
import com.tangem.data.pay.util.WithdrawStateConverter
import com.tangem.data.pay.util.WithdrawStoreDataConverter
import com.tangem.datasource.di.NetworkMoshi
import com.tangem.datasource.local.preferences.AppPreferencesStore
import com.tangem.datasource.local.preferences.PreferencesKeys
import com.tangem.datasource.local.preferences.utils.getObjectMapSync
import com.tangem.datasource.local.preferences.utils.getSyncOrDefault
import com.tangem.datasource.local.preferences.utils.getSyncOrNull
import com.tangem.datasource.local.preferences.utils.store
import com.tangem.datasource.local.visa.TangemPayStorage
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.pay.TangemPayWithdrawState
import com.tangem.domain.visa.model.TangemPayAuthTokens
import com.tangem.sdk.storage.AndroidSecureStorageV2
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.flow.firstOrNull
import kotlinx.coroutines.withContext
import java.util.UUID
import javax.inject.Inject
import javax.inject.Singleton
private const val AUTH_TOKENS_DEFAULT_KEY = "tangem_pay_default_key"
private const val WITHDRAW_ORDER_ID_KEY = "tangem_pay_withdraw_order_id_key"
@Suppress("TooManyFunctions")
@Singleton
internal class DefaultTangemPayStorage @Inject constructor(
@ApplicationContext applicationContext: Context,
@NetworkMoshi moshi: Moshi,
private val dispatcherProvider: CoroutineDispatcherProvider,
private val appPreferencesStore: AppPreferencesStore,
) : TangemPayStorage {
private val secureStorage by lazy {
AndroidSecureStorageV2(
appContext = applicationContext,
useStrongBox = false,
name = "tangem_pay_storage",
)
}
private val tokensAdapter by lazy { moshi.adapter(TangemPayAuthTokens::class.java) }
private val withdrawStoreDataConverter by lazy { WithdrawStoreDataConverter() }
private val withdrawStateConverter by lazy { WithdrawStateConverter() }
private val listType by lazy {
Types.newParameterizedType(List::class.java, WithdrawStoreData::class.java)
}
private val mapType by lazy {
Types.newParameterizedType(Map::class.java, String::class.java, listType)
}
private val adapter: JsonAdapter<Map<String, List<WithdrawStoreData>>> by lazy {
appPreferencesStore.moshi.adapter(mapType)
}
override suspend fun storeCustomerWalletAddress(userWalletId: UserWalletId, customerWalletAddress: String) {
appPreferencesStore
.store(PreferencesKeys.getTangemPayCustomerWalletAddressKey(userWalletId), customerWalletAddress)
}
override suspend fun getCustomerWalletAddress(userWalletId: UserWalletId): String? {
return appPreferencesStore.getSyncOrNull(
key = PreferencesKeys.getTangemPayCustomerWalletAddressKey(userWalletId),
)
.takeIf { !it.isNullOrEmpty() }
}
override suspend fun clearCustomerWalletAddress(userWalletId: UserWalletId) {
appPreferencesStore.store(PreferencesKeys.getTangemPayCustomerWalletAddressKey(userWalletId), "")
}
override suspend fun storeAuthTokens(customerWalletAddress: String, tokens: TangemPayAuthTokens) =
withContext(dispatcherProvider.io) {
val json = tokensAdapter.toJson(tokens)
secureStorage.store(
json.encodeToByteArray(throwOnInvalidSequence = true),
createAuthTokensKey(customerWalletAddress),
)
}
override suspend fun getAuthTokens(customerWalletAddress: String): TangemPayAuthTokens? {
return withContext(dispatcherProvider.io) {
val authTokens = secureStorage.get(createAuthTokensKey(customerWalletAddress))
?.decodeToString(throwOnInvalidSequence = true)
?.let(tokensAdapter::fromJson)
authTokens?.let { tokens ->
if (tokens.idempotencyKey == null) {
val newAuthTokens = tokens.copy(idempotencyKey = UUID.randomUUID().toString())
storeAuthTokens(customerWalletAddress, newAuthTokens)
newAuthTokens
} else {
tokens
}
}
}
}
override suspend fun clearAuthTokens(customerWalletAddress: String) {
withContext(dispatcherProvider.io) {
secureStorage.delete(createAuthTokensKey(customerWalletAddress))
}
}
override suspend fun storeOrderId(customerWalletAddress: String, orderId: String) {
appPreferencesStore.store(PreferencesKeys.getTangemPayOrderIdKey(customerWalletAddress), orderId)
}
override suspend fun getOrderId(customerWalletAddress: String): String? {
return appPreferencesStore.getSyncOrNull(key = PreferencesKeys.getTangemPayOrderIdKey(customerWalletAddress))
.takeIf { !it.isNullOrEmpty() }
}
override suspend fun getAddToWalletDone(customerWalletAddress: String): Boolean {
return appPreferencesStore.getSyncOrNull(
key = PreferencesKeys.getTangemPayAddToWalletKey(customerWalletAddress),
) == true
}
override suspend fun storeAddToWalletDone(customerWalletAddress: String, isDone: Boolean) {
appPreferencesStore.store(PreferencesKeys.getTangemPayAddToWalletKey(customerWalletAddress), isDone)
}
override suspend fun clearOrderId(customerWalletAddress: String) {
appPreferencesStore.store(PreferencesKeys.getTangemPayOrderIdKey(customerWalletAddress), "")
}
override suspend fun storeCheckCustomerWalletResult(userWalletId: UserWalletId, isPaeraCustomer: Boolean) {
appPreferencesStore.store(
PreferencesKeys.getTangemPayCheckCustomerByWalletId(userWalletId),
isPaeraCustomer,
)
}
override suspend fun checkCustomerWalletResult(userWalletId: UserWalletId): Boolean? {
return appPreferencesStore.getSyncOrNull(PreferencesKeys.getTangemPayCheckCustomerByWalletId(userWalletId))
}
override suspend fun storeActiveWithdrawOrderId(userWalletId: UserWalletId, orderId: String) {
appPreferencesStore.editData { mutablePreferences ->
val orders = mutablePreferences.getObjectMap<String>(
PreferencesKeys.TANGEM_PAY_ACTIVE_WITHDRAW_ORDERS_KEY,
)
.plus(createWithdrawOrderIdKey(userWalletId) to orderId)
mutablePreferences.setObjectMap(
key = PreferencesKeys.TANGEM_PAY_ACTIVE_WITHDRAW_ORDERS_KEY,
value = orders,
)
}
}
override suspend fun storeWithdrawOrder(userWalletId: UserWalletId, data: TangemPayWithdrawState) {
appPreferencesStore.editData { prefs ->
val walletKey = createWithdrawOrderIdKey(userWalletId)
val currentMap = prefs[PreferencesKeys.TANGEM_PAY_WITHDRAW_ORDERS_KEY]
?.let(adapter::fromJson)
.orEmpty()
val newItem = withdrawStoreDataConverter.convert(data)
val currentList = currentMap[walletKey].orEmpty()
val updatedList = buildList(currentList.size + 1) {
for (item in currentList) { if (item.orderId != newItem.orderId) add(item) }
add(newItem)
}
prefs[PreferencesKeys.TANGEM_PAY_WITHDRAW_ORDERS_KEY] =
adapter.toJson(currentMap + (walletKey to updatedList))
}
}
override suspend fun getActiveWithdrawOrderId(userWalletId: UserWalletId): String? {
val orders = appPreferencesStore.getObjectMapSync<String>(
PreferencesKeys.TANGEM_PAY_ACTIVE_WITHDRAW_ORDERS_KEY,
)
return orders[createWithdrawOrderIdKey(userWalletId)]
}
override suspend fun getWithdrawOrders(userWalletId: UserWalletId): List<TangemPayWithdrawState> {
val map = appPreferencesStore.data.firstOrNull()
?.get(PreferencesKeys.TANGEM_PAY_WITHDRAW_ORDERS_KEY)?.let(adapter::fromJson).orEmpty()
return map[createWithdrawOrderIdKey(userWalletId)].orEmpty().map(withdrawStateConverter::convert)
}
override suspend fun deleteActiveWithdrawOrder(userWalletId: UserWalletId) {
appPreferencesStore.editData { mutablePreferences ->
val orders = mutablePreferences.getObjectMap<String>(
PreferencesKeys.TANGEM_PAY_ACTIVE_WITHDRAW_ORDERS_KEY,
)
.minus(createWithdrawOrderIdKey(userWalletId))
mutablePreferences.setObjectMap(
key = PreferencesKeys.TANGEM_PAY_ACTIVE_WITHDRAW_ORDERS_KEY,
value = orders,
)
}
}
override suspend fun deleteWithdrawOrder(userWalletId: UserWalletId, orderId: String) {
appPreferencesStore.editData { prefs ->
val walletKey = createWithdrawOrderIdKey(userWalletId)
val currentMap = prefs[PreferencesKeys.TANGEM_PAY_WITHDRAW_ORDERS_KEY]?.let(adapter::fromJson)
.orEmpty()
val currentList = currentMap[walletKey].orEmpty()
val updatedList = buildList(currentList.size) {
for (item in currentList) {
if (item.orderId != orderId) add(item)
}
}
val updatedMap = if (updatedList.isEmpty()) {
currentMap - walletKey
} else {
currentMap + (walletKey to updatedList)
}
prefs[PreferencesKeys.TANGEM_PAY_WITHDRAW_ORDERS_KEY] = adapter.toJson(updatedMap)
}
}
override suspend fun storeHideOnboardingBanner(userWalletId: UserWalletId, hide: Boolean) {
appPreferencesStore.store(PreferencesKeys.getTangemPayHideOnboardingKey(userWalletId), hide)
}
override suspend fun getHideMainOnboardingBanner(userWalletId: UserWalletId): Boolean {
return appPreferencesStore.getSyncOrNull(
key = PreferencesKeys.getTangemPayHideOnboardingKey(userWalletId),
) == true
}
override suspend fun storeTangemPayEligibility(eligibility: Set<String>) {
withContext(dispatcherProvider.io) {
appPreferencesStore.store(
key = PreferencesKeys.TANGEM_PAY_ELIGIBILITY_KEY,
value = eligibility,
)
}
}
override suspend fun getTangemPayEligibility(): Set<String> {
return withContext(dispatcherProvider.io) {
appPreferencesStore.getSyncOrDefault(
key = PreferencesKeys.TANGEM_PAY_ELIGIBILITY_KEY,
default = emptySet(),
)
}
}
override suspend fun storeIsTangemPayDeactivated(userWalletId: UserWalletId) {
appPreferencesStore.store(PreferencesKeys.getTangemPayDeactivatedKey(userWalletId), true)
}
override suspend fun isTangemPayDeactivated(userWalletId: UserWalletId): Boolean {
val key = PreferencesKeys.getTangemPayDeactivatedKey(userWalletId)
return appPreferencesStore.getSyncOrNull(key) == true
}
override suspend fun clearAll(userWalletId: UserWalletId, customerWalletAddress: String) {
withContext(dispatcherProvider.io) {
secureStorage.delete(createAuthTokensKey(customerWalletAddress))
}
appPreferencesStore.store(PreferencesKeys.getTangemPayCheckCustomerByWalletId(userWalletId), false)
appPreferencesStore.store(PreferencesKeys.getTangemPayCustomerWalletAddressKey(userWalletId), "")
appPreferencesStore.store(PreferencesKeys.getTangemPayOrderIdKey(customerWalletAddress), "")
appPreferencesStore.store(PreferencesKeys.getTangemPayAddToWalletKey(customerWalletAddress), false)
appPreferencesStore.store(PreferencesKeys.getTangemPayHideOnboardingKey(userWalletId), false)
// Clear the withdraw order hints together with the rest of the cache.
deleteActiveWithdrawOrder(userWalletId)
clearWithdrawOrders(userWalletId)
}
private suspend fun clearWithdrawOrders(userWalletId: UserWalletId) {
appPreferencesStore.editData { prefs ->
val walletKey = createWithdrawOrderIdKey(userWalletId)
val currentMap = prefs[PreferencesKeys.TANGEM_PAY_WITHDRAW_ORDERS_KEY]?.let(adapter::fromJson)
.orEmpty()
val updatedMap = currentMap - walletKey
prefs[PreferencesKeys.TANGEM_PAY_WITHDRAW_ORDERS_KEY] = adapter.toJson(updatedMap)
}
}
private fun createAuthTokensKey(address: String): String = "${AUTH_TOKENS_DEFAULT_KEY}_$address"
private fun createWithdrawOrderIdKey(userWalletId: UserWalletId): String = "${WITHDRAW_ORDER_ID_KEY}_$userWalletId"
}

View file

@ -10,8 +10,11 @@ import com.tangem.sdk.api.TangemSdkManager
import com.tangem.tap.domain.sdk.impl.DefaultTangemSdkManager
import com.tangem.tap.domain.sdk.impl.MockTangemSdkManager
import com.tangem.tap.domain.tasks.visa.TangemPayGenerateAddressAndSignChallengeTask
import com.tangem.tap.domain.tasks.visa.TangemPayGenerateVirtualAccountAddressTask
import com.tangem.tap.domain.tasks.visa.VisaCardActivationTask
import com.tangem.tap.domain.visa.VisaCardScanHandler
import com.tangem.tap.domain.walletregistration.WalletRegistrationLauncher
import dagger.Lazy
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
@ -31,9 +34,11 @@ internal class TangemSdkManagerModule {
visaCardScanHandler: VisaCardScanHandler,
visaCardActivationTaskFactory: VisaCardActivationTask.Factory,
tangemPayChallengeTaskFactory: TangemPayGenerateAddressAndSignChallengeTask.Factory,
tangemPayVirtualAccountTaskFactory: TangemPayGenerateVirtualAccountAddressTask.Factory,
onboardingV2FeatureToggles: OnboardingV2FeatureToggles,
analyticsErrorHandler: AnalyticsErrorHandler,
cardRepository: CardRepository,
walletRegistrationLauncher: Lazy<WalletRegistrationLauncher>,
): TangemSdkManager {
return if (BuildConfig.MOCK_DATA_SOURCE) {
MockTangemSdkManager(resources = context.resources)
@ -44,9 +49,11 @@ internal class TangemSdkManagerModule {
visaCardScanHandler = visaCardScanHandler,
visaCardActivationTaskFactory = visaCardActivationTaskFactory,
tangemPayChallengeTaskFactory = tangemPayChallengeTaskFactory,
tangemPayVirtualAccountTaskFactory = tangemPayVirtualAccountTaskFactory,
onboardingV2FeatureToggles = onboardingV2FeatureToggles,
analyticsErrorHandler = analyticsErrorHandler,
cardRepository = cardRepository,
walletRegistrationLauncher = walletRegistrationLauncher,
)
}
}

View file

@ -7,6 +7,8 @@ import com.tangem.core.navigation.deeplink.DeeplinkLauncher
import com.tangem.core.navigation.finisher.AppFinisher
import com.tangem.core.navigation.settings.SettingsManager
import com.tangem.core.navigation.share.ShareManager
import com.tangem.core.navigation.url.AppStoreOpener
import com.tangem.core.navigation.url.DefaultAppStoreOpener
import com.tangem.core.navigation.url.UrlOpener
import com.tangem.utils.coroutines.AppCoroutineScope
import com.tangem.tap.common.finisher.AndroidAppFinisher
@ -35,6 +37,10 @@ internal interface UtilsModule {
@Singleton
fun bindAppInfoProvider(impl: DefaultAppInfoProvider): AppInfoProvider
@Binds
@Singleton
fun bindAppStoreOpener(impl: DefaultAppStoreOpener): AppStoreOpener
companion object {
@Provides

View file

@ -1,12 +1,20 @@
package com.tangem.tap.di.domain
import com.tangem.domain.addressbook.crypto.AddressBookCipher
import com.tangem.domain.addressbook.interactor.GetVerifiedContactsInteractor
import com.tangem.domain.addressbook.interactor.SaveContactInteractor
import com.tangem.domain.addressbook.repository.AddressBookRepository
import com.tangem.domain.addressbook.time.DefaultIsoTimestampProvider
import com.tangem.domain.addressbook.time.IsoTimestampProvider
import com.tangem.domain.addressbook.usecase.ValidateContactAddressUseCase
import com.tangem.domain.addressbook.usecase.VerifyAddressEntriesUseCase
import com.tangem.domain.tokens.GetNetworkAddressesUseCase
import com.tangem.domain.transaction.usecase.ValidateWalletAddressUseCase
import com.tangem.domain.addressbook.usecase.CheckAddressDuplicateUseCase
import com.tangem.domain.addressbook.usecase.DeleteContactUseCase
import com.tangem.domain.addressbook.usecase.GetContactByIdUseCase
import com.tangem.domain.addressbook.usecase.GetContactsUseCase
import com.tangem.domain.addressbook.usecase.SyncAddressBooksUseCase
import com.tangem.domain.addressbook.validation.ContactNameValidator
import com.tangem.domain.addressbook.verification.ContactSignatureVerifier
import com.tangem.domain.common.wallets.UserWalletsListRepository
import com.tangem.domain.transaction.usecase.SignUseCase
import com.tangem.domain.transaction.usecase.VerifySecp256k1MessagesUseCase
import dagger.Module
import dagger.Provides
@ -20,22 +28,84 @@ object AddressBookDomainModule {
@Provides
@Singleton
fun provideValidateContactAddressUseCase(
validateWalletAddressUseCase: ValidateWalletAddressUseCase,
getNetworkAddressesUseCase: GetNetworkAddressesUseCase,
): ValidateContactAddressUseCase {
return ValidateContactAddressUseCase(
validateWalletAddressUseCase = validateWalletAddressUseCase,
getNetworkAddressesUseCase = getNetworkAddressesUseCase,
fun provideContactSignatureVerifier(
verifyMessagesUseCase: VerifySecp256k1MessagesUseCase,
userWalletsListRepository: UserWalletsListRepository,
): ContactSignatureVerifier {
return ContactSignatureVerifier(
verifyMessages = verifyMessagesUseCase,
userWalletsListRepository = userWalletsListRepository,
)
}
@Provides
@Singleton
fun provideVerifyAddressEntriesUseCase(
verifyMessagesUseCase: VerifySecp256k1MessagesUseCase,
): VerifyAddressEntriesUseCase {
return VerifyAddressEntriesUseCase(verifyMessagesUseCase = verifyMessagesUseCase)
fun provideContactNameValidator(
repository: AddressBookRepository,
contactSignatureVerifier: ContactSignatureVerifier,
): ContactNameValidator {
return ContactNameValidator(
repository = repository,
contactSignatureVerifier = contactSignatureVerifier,
)
}
@Provides
@Singleton
fun provideGetContactsUseCase(repository: AddressBookRepository): GetContactsUseCase {
return GetContactsUseCase(repository = repository)
}
@Provides
@Singleton
fun provideGetVerifiedContactsInteractor(
getContactsUseCase: GetContactsUseCase,
contactSignatureVerifier: ContactSignatureVerifier,
): GetVerifiedContactsInteractor {
return GetVerifiedContactsInteractor(
getContacts = getContactsUseCase,
contactSignatureVerifier = contactSignatureVerifier,
)
}
@Provides
@Singleton
fun provideSaveContactInteractor(
repository: AddressBookRepository,
contactNameValidator: ContactNameValidator,
signUseCase: SignUseCase,
timestampProvider: IsoTimestampProvider,
): SaveContactInteractor {
return SaveContactInteractor(
repository = repository,
validateContactName = contactNameValidator,
signUseCase = signUseCase,
timestampProvider = timestampProvider,
)
}
@Provides
@Singleton
fun provideDeleteContactUseCase(repository: AddressBookRepository): DeleteContactUseCase {
return DeleteContactUseCase(repository = repository)
}
@Provides
@Singleton
fun provideGetContactByIdUseCase(repository: AddressBookRepository): GetContactByIdUseCase {
return GetContactByIdUseCase(repository = repository)
}
@Provides
@Singleton
fun provideCheckAddressDuplicateUseCase(repository: AddressBookRepository): CheckAddressDuplicateUseCase {
return CheckAddressDuplicateUseCase(repository = repository)
}
@Provides
@Singleton
fun provideSyncAddressBooksUseCase(repository: AddressBookRepository): SyncAddressBooksUseCase {
return SyncAddressBooksUseCase(repository = repository)
}
@Provides

View file

@ -56,6 +56,14 @@ internal object ManageTokensDomainModule {
return ValidateDerivationPathUseCase(customTokensRepository)
}
@Provides
@Singleton
fun provideCheckDerivationPathSupportedUseCase(
customTokensRepository: CustomTokensRepository,
): CheckDerivationPathSupportedUseCase {
return CheckDerivationPathSupportedUseCase(customTokensRepository)
}
@Provides
@Singleton
fun provideCheckCurrencyUnsupportedUseCase(repository: ManageTokensRepository): CheckCurrencyUnsupportedUseCase {

View file

@ -0,0 +1,36 @@
package com.tangem.tap.di.domain
import com.tangem.domain.marketing.DismissMarketingBannerUseCase
import com.tangem.domain.marketing.GetMarketingBannerUseCase
import com.tangem.domain.marketing.MarketingFeatureToggles
import com.tangem.domain.marketing.MarketingRepository
import com.tangem.domain.marketing.WarmUpMarketingCampaignsUseCase
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import javax.inject.Singleton
@Module
@InstallIn(SingletonComponent::class)
internal object MarketingDomainModule {
@Provides
@Singleton
fun provideGetMarketingBannerUseCase(
repository: MarketingRepository,
featureToggles: MarketingFeatureToggles,
): GetMarketingBannerUseCase = GetMarketingBannerUseCase(repository, featureToggles)
@Provides
@Singleton
fun provideDismissMarketingBannerUseCase(repository: MarketingRepository): DismissMarketingBannerUseCase =
DismissMarketingBannerUseCase(repository)
@Provides
@Singleton
fun provideWarmUpMarketingCampaignsUseCase(
repository: MarketingRepository,
featureToggles: MarketingFeatureToggles,
): WarmUpMarketingCampaignsUseCase = WarmUpMarketingCampaignsUseCase(repository, featureToggles)
}

View file

@ -0,0 +1,27 @@
package com.tangem.tap.di.domain
import com.tangem.domain.promo.PromoRepository
import com.tangem.domain.promo.usecase.EnrollPromoCampaignUseCase
import com.tangem.domain.promo.usecase.GetPromoCampaignStateUseCase
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import javax.inject.Singleton
@Module
@InstallIn(SingletonComponent::class)
internal object PromoDomainModule {
@Provides
@Singleton
fun provideGetPromoCampaignStateUseCase(repository: PromoRepository): GetPromoCampaignStateUseCase {
return GetPromoCampaignStateUseCase(repository)
}
@Provides
@Singleton
fun provideEnrollPromoCampaignUseCase(repository: PromoRepository): EnrollPromoCampaignUseCase {
return EnrollPromoCampaignUseCase(repository)
}
}

View file

@ -1,5 +1,7 @@
package com.tangem.tap.di.domain
import com.tangem.domain.pushnotificationpreferences.IsPushNotificationFirstActivationDoneUseCase
import com.tangem.domain.pushnotificationpreferences.MarkPushNotificationFirstActivationDoneUseCase
import com.tangem.domain.pushnotificationpreferences.ObserveWalletPushNotificationPreferencesUseCase
import com.tangem.domain.pushnotificationpreferences.PreloadWalletPushNotificationPreferencesUseCase
import com.tangem.domain.pushnotificationpreferences.SetAllWalletPushNotificationPreferencesUseCase
@ -46,4 +48,20 @@ internal object PushNotificationPreferencesDomainModule {
): SetAllWalletPushNotificationPreferencesUseCase {
return SetAllWalletPushNotificationPreferencesUseCase(repository = repository)
}
@Provides
@Singleton
fun providesIsPushNotificationFirstActivationDoneUseCase(
repository: WalletPushNotificationPreferencesRepository,
): IsPushNotificationFirstActivationDoneUseCase {
return IsPushNotificationFirstActivationDoneUseCase(repository = repository)
}
@Provides
@Singleton
fun providesMarkPushNotificationFirstActivationDoneUseCase(
repository: WalletPushNotificationPreferencesRepository,
): MarkPushNotificationFirstActivationDoneUseCase {
return MarkPushNotificationFirstActivationDoneUseCase(repository = repository)
}
}

View file

@ -4,13 +4,11 @@ import com.tangem.domain.swap.SwapErrorResolver
import com.tangem.domain.swap.SwapRepositoryV2
import com.tangem.domain.swap.SwapTransactionRepository
import com.tangem.domain.swap.usecase.*
import com.tangem.feature.swap.domain.GetAvailablePairsUseCase
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import javax.inject.Singleton
import com.tangem.feature.swap.domain.api.SwapRepository as OldSwapRepository
/**
[REDACTED_AUTHOR]
@ -19,12 +17,6 @@ import com.tangem.feature.swap.domain.api.SwapRepository as OldSwapRepository
@InstallIn(SingletonComponent::class)
internal object SwapDomainModule {
@Provides
@Singleton
fun provideGetAvailablePairsUseCase(swapRepository: OldSwapRepository): GetAvailablePairsUseCase {
return GetAvailablePairsUseCase(swapRepository = swapRepository)
}
@Provides
@Singleton
fun provideGetSwapSupportedPairsUseCase(

View file

@ -6,9 +6,13 @@ import com.tangem.domain.account.supplier.SingleAccountListSupplier
import com.tangem.domain.card.repository.CardSdkConfigRepository
import com.tangem.domain.common.wallets.UserWalletsListRepository
import com.tangem.domain.demo.models.DemoConfig
import com.tangem.core.configtoggle.FeatureToggles
import com.tangem.core.configtoggle.feature.FeatureTogglesManager
import com.tangem.domain.dynamicaddresses.DynamicAddressesFeatureToggles
import com.tangem.domain.dynamicaddresses.GetDynamicReceiveAddressUseCase
import com.tangem.domain.dynamicaddresses.repository.DynamicAddressesRepository
import com.tangem.domain.transaction.GaslessYieldRepository
import com.tangem.domain.transaction.usecase.gasless.ResolveGaslessFeePlanUseCase
import com.tangem.domain.networks.single.SingleNetworkStatusFetcher
import com.tangem.domain.networks.single.SingleNetworkStatusSupplier
import com.tangem.domain.notifications.repository.PushNotificationsRepository
@ -319,30 +323,50 @@ internal object TransactionDomainModule {
gaslessTransactionRepository: GaslessTransactionRepository,
singleAccountStatusListSupplier: SingleAccountStatusListSupplier,
currencyChecksRepository: CurrencyChecksRepository,
featureTogglesManager: FeatureTogglesManager,
): GetAvailableFeeTokensUseCase {
return GetAvailableFeeTokensUseCase(
singleAccountStatusListSupplier = singleAccountStatusListSupplier,
gaslessTransactionRepository = gaslessTransactionRepository,
currencyChecksRepository = currencyChecksRepository,
isYieldWithdrawEnabled = featureTogglesManager.isFeatureEnabled(
toggle = FeatureToggles.AND_15632_GASLESS_YIELD_WITHDRAW_ENABLED,
),
)
}
@Provides
@Singleton
fun provideResolveGaslessFeePlanUseCase(
gaslessYieldRepository: GaslessYieldRepository,
): ResolveGaslessFeePlanUseCase {
return ResolveGaslessFeePlanUseCase(gaslessYieldRepository = gaslessYieldRepository)
}
@Provides
@Singleton
fun provideGetFeeForGaslessUseCase(
walletManagersFacade: WalletManagersFacade,
gaslessTransactionRepository: GaslessTransactionRepository,
gaslessYieldRepository: GaslessYieldRepository,
getFeeUseCase: GetFeeUseCase,
singleAccountStatusListSupplier: SingleAccountStatusListSupplier,
currencyChecksRepository: CurrencyChecksRepository,
resolveGaslessFeePlanUseCase: ResolveGaslessFeePlanUseCase,
featureTogglesManager: FeatureTogglesManager,
): GetFeeForGaslessUseCase {
return GetFeeForGaslessUseCase(
walletManagersFacade = walletManagersFacade,
demoConfig = DemoConfig,
gaslessTransactionRepository = gaslessTransactionRepository,
gaslessYieldRepository = gaslessYieldRepository,
singleAccountStatusListSupplier = singleAccountStatusListSupplier,
getFeeUseCase = getFeeUseCase,
currencyChecksRepository = currencyChecksRepository,
resolveGaslessFeePlanUseCase = resolveGaslessFeePlanUseCase,
isYieldWithdrawEnabled = featureTogglesManager.isFeatureEnabled(
toggle = FeatureToggles.AND_15632_GASLESS_YIELD_WITHDRAW_ENABLED,
),
)
}
@ -351,15 +375,23 @@ internal object TransactionDomainModule {
fun provideGetFeeForTokenUseCase(
walletManagersFacade: WalletManagersFacade,
gaslessTransactionRepository: GaslessTransactionRepository,
gaslessYieldRepository: GaslessYieldRepository,
singleAccountStatusListSupplier: SingleAccountStatusListSupplier,
currencyChecksRepository: CurrencyChecksRepository,
resolveGaslessFeePlanUseCase: ResolveGaslessFeePlanUseCase,
featureTogglesManager: FeatureTogglesManager,
): GetFeeForTokenUseCase {
return GetFeeForTokenUseCase(
gaslessTransactionRepository = gaslessTransactionRepository,
gaslessYieldRepository = gaslessYieldRepository,
walletManagersFacade = walletManagersFacade,
demoConfig = DemoConfig,
singleAccountStatusListSupplier = singleAccountStatusListSupplier,
currencyChecksRepository = currencyChecksRepository,
resolveGaslessFeePlanUseCase = resolveGaslessFeePlanUseCase,
isYieldWithdrawEnabled = featureTogglesManager.isFeatureEnabled(
toggle = FeatureToggles.AND_15632_GASLESS_YIELD_WITHDRAW_ENABLED,
),
)
}
@ -379,6 +411,7 @@ internal object TransactionDomainModule {
singleAccountListSupplier: SingleAccountListSupplier,
cardSdkConfigRepository: CardSdkConfigRepository,
tangemHotWalletSignerFactory: TangemHotWalletSigner.Factory,
featureTogglesManager: FeatureTogglesManager,
): CreateAndSendGaslessTransactionUseCase {
return CreateAndSendGaslessTransactionUseCase(
walletManagersFacade = walletManagersFacade,
@ -386,6 +419,9 @@ internal object TransactionDomainModule {
gaslessTransactionRepository = gaslessTransactionRepository,
cardSdkConfigRepository = cardSdkConfigRepository,
getHotWalletSigner = tangemHotWalletSignerFactory::create,
isGaslessV2Enabled = featureTogglesManager.isFeatureEnabled(
toggle = FeatureToggles.AND_15632_GASLESS_YIELD_WITHDRAW_ENABLED,
),
)
}
@ -394,15 +430,21 @@ internal object TransactionDomainModule {
fun provideEstimateFeeForTokenUseCase(
walletManagersFacade: WalletManagersFacade,
gaslessTransactionRepository: GaslessTransactionRepository,
gaslessYieldRepository: GaslessYieldRepository,
singleAccountStatusListSupplier: SingleAccountStatusListSupplier,
currencyChecksRepository: CurrencyChecksRepository,
featureTogglesManager: FeatureTogglesManager,
): EstimateFeeForTokenUseCase {
return EstimateFeeForTokenUseCase(
gaslessTransactionRepository = gaslessTransactionRepository,
gaslessYieldRepository = gaslessYieldRepository,
walletManagersFacade = walletManagersFacade,
demoConfig = DemoConfig,
singleAccountStatusListSupplier = singleAccountStatusListSupplier,
currencyChecksRepository = currencyChecksRepository,
isYieldWithdrawEnabled = featureTogglesManager.isFeatureEnabled(
toggle = FeatureToggles.AND_15632_GASLESS_YIELD_WITHDRAW_ENABLED,
),
)
}
@ -411,12 +453,14 @@ internal object TransactionDomainModule {
fun provideEstimateFeeForGaslessTxUseCase(
walletManagersFacade: WalletManagersFacade,
gaslessTransactionRepository: GaslessTransactionRepository,
gaslessYieldRepository: GaslessYieldRepository,
singleAccountStatusListSupplier: SingleAccountStatusListSupplier,
estimateFeeUseCase: EstimateFeeUseCase,
currencyChecksRepository: CurrencyChecksRepository,
): EstimateFeeForGaslessTxUseCase {
return EstimateFeeForGaslessTxUseCase(
gaslessTransactionRepository = gaslessTransactionRepository,
gaslessYieldRepository = gaslessYieldRepository,
walletManagersFacade = walletManagersFacade,
demoConfig = DemoConfig,
singleAccountStatusListSupplier = singleAccountStatusListSupplier,

View file

@ -2,6 +2,7 @@ package com.tangem.tap.di.domain
import com.tangem.core.analytics.api.AnalyticsEventHandler
import com.tangem.domain.account.repository.AccountsCRUDRepository
import com.tangem.domain.common.wallets.UserWalletDataCleaner
import com.tangem.domain.common.wallets.UserWalletSelectedHandler
import com.tangem.domain.common.wallets.UserWalletsListRepository
import com.tangem.domain.transaction.WalletAddressServiceRepository
@ -25,6 +26,7 @@ import com.tangem.feature.wallet.presentation.wallet.domain.IsWalletNFTEnabledSy
import com.tangem.feature.wallet.presentation.wallet.domain.WalletNameMigrationUseCase
import com.tangem.operations.attestation.CardArtworksProvider
import com.tangem.tap.domain.DefaultUserWalletSelectedHandler
import com.tangem.utils.coroutines.AppCoroutineScope
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import dagger.Module
import dagger.Provides
@ -188,8 +190,16 @@ internal object WalletsDomainModule {
@Provides
@Singleton
fun providesDeleteWalletUseCase(userWalletsListRepository: UserWalletsListRepository): DeleteWalletUseCase {
return DeleteWalletUseCase(userWalletsListRepository = userWalletsListRepository)
fun providesDeleteWalletUseCase(
userWalletsListRepository: UserWalletsListRepository,
userWalletDataCleaners: Set<@JvmSuppressWildcards UserWalletDataCleaner>,
appCoroutineScope: AppCoroutineScope,
): DeleteWalletUseCase {
return DeleteWalletUseCase(
userWalletsListRepository = userWalletsListRepository,
userWalletDataCleaners = userWalletDataCleaners,
appCoroutineScope = appCoroutineScope,
)
}
@Provides

View file

@ -50,6 +50,7 @@ import com.tangem.tap.common.analytics.events.TangemSdkErrorEvent
import com.tangem.tap.common.analytics.paramsInterceptor.CardContextInterceptor
import com.tangem.tap.domain.tasks.product.*
import com.tangem.tap.domain.tasks.visa.TangemPayGenerateAddressAndSignChallengeTask
import com.tangem.tap.domain.tasks.visa.TangemPayGenerateVirtualAccountAddressTask
import com.tangem.tap.domain.tasks.visa.TangemPaySignWithdrawalHashTask
import com.tangem.tap.domain.tasks.visa.VisaCardActivationTask
import com.tangem.tap.domain.tasks.visa.VisaCustomerWalletApproveTask
@ -57,8 +58,10 @@ import com.tangem.tap.domain.twins.CreateFirstTwinWalletTask
import com.tangem.tap.domain.twins.CreateSecondTwinWalletTask
import com.tangem.tap.domain.twins.FinalizeTwinTask
import com.tangem.tap.domain.visa.VisaCardScanHandler
import com.tangem.tap.domain.walletregistration.WalletRegistrationLauncher
import com.tangem.utils.logging.TangemLogger
import com.tangem.wallet.R
import dagger.Lazy
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.suspendCancellableCoroutine
@ -72,9 +75,13 @@ internal class DefaultTangemSdkManager(
private val visaCardScanHandler: VisaCardScanHandler,
private val visaCardActivationTaskFactory: VisaCardActivationTask.Factory,
private val tangemPayChallengeTaskFactory: TangemPayGenerateAddressAndSignChallengeTask.Factory,
private val tangemPayVirtualAccountTaskFactory: TangemPayGenerateVirtualAccountAddressTask.Factory,
private val onboardingV2FeatureToggles: OnboardingV2FeatureToggles,
private val analyticsErrorHandler: AnalyticsErrorHandler,
private val cardRepository: CardRepository,
// Lazy breaks a DI cycle: the launcher -> hot wallet accessor -> LegacySettingsRepository ->
// TangemSdkManager. It's only needed when a scan actually runs.
private val walletRegistrationLauncher: Lazy<WalletRegistrationLauncher>,
) : TangemSdkManager {
private val tangemSdk: TangemSdk
@ -86,7 +93,7 @@ internal class DefaultTangemSdkManager(
secureStorage = tangemSdk.secureStorage,
)
}
override val needEnrollBiometrics: Boolean
override val isEnrollBiometricsNeeded: Boolean
get() {
val isNeedEnrollBiometrics = tangemSdk.authenticationManager.needEnrollBiometrics
if (isNeedEnrollBiometrics) {
@ -102,7 +109,7 @@ internal class DefaultTangemSdkManager(
override val canUseBiometry: Boolean
get() {
val isCanUseBiometry = tangemSdk.authenticationManager.canAuthenticate || needEnrollBiometrics
val isCanUseBiometry = tangemSdk.authenticationManager.canAuthenticate || isEnrollBiometricsNeeded
if (!isCanUseBiometry) {
analyticsErrorHandler.sendErrorEvent(
AnalyticsEvent(
@ -124,7 +131,7 @@ internal class DefaultTangemSdkManager(
get() = tangemSdk.config.userCodeRequestPolicy
override suspend fun checkNeedEnrollBiometrics(awaitInitialization: Boolean): Boolean {
return needEnrollBiometrics
return isEnrollBiometricsNeeded
}
override suspend fun checkCanUseBiometry(awaitInitialization: Boolean): Boolean {
@ -145,10 +152,11 @@ internal class DefaultTangemSdkManager(
card = null,
allowsRequestAccessCodeFromRepository = allowsRequestAccessCodeFromRepository,
visaCardScanHandler = visaCardScanHandler,
visaCoroutineScope = this,
sessionCoroutineScope = this,
shouldCheckIsAlreadyActivated = shouldCheckIsAlreadyActivated,
onboardingV2FeatureToggles = onboardingV2FeatureToggles,
cardRepository = cardRepository,
walletRegistrationLauncher = walletRegistrationLauncher.get(),
),
cardId = cardId,
initialMessage = message,
@ -531,6 +539,24 @@ internal class DefaultTangemSdkManager(
}
}
override suspend fun tangemPayProduceVirtualAccountData(
preflightReadFilter: PreflightReadFilter,
): Either<Throwable, VirtualAccountActivationData> {
return coroutineScope {
val result = runTaskAsyncReturnOnMain(
runnable = tangemPayVirtualAccountTaskFactory.create(coroutineScope = this),
cardId = null,
initialMessage = Message(resources.getStringSafe(R.string.initial_message_tap_header)),
preflightReadFilter = preflightReadFilter,
)
return@coroutineScope when (result) {
is CompletionResult.Failure<*> -> result.error.left()
is CompletionResult.Success<VirtualAccountActivationData> -> result.data.right()
}
}
}
override suspend fun getWithdrawalSignature(
hash: String,
preflightReadFilter: PreflightReadFilter,

View file

@ -23,6 +23,7 @@ import com.tangem.domain.models.scan.ScanResponse
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.pay.WithdrawalSignatureResult
import com.tangem.domain.visa.model.TangemPayInitialCredentials
import com.tangem.domain.visa.model.VirtualAccountActivationData
import com.tangem.domain.visa.model.VisaActivationInput
import com.tangem.domain.visa.model.VisaDataForApprove
import com.tangem.domain.visa.model.VisaSignedDataByCustomerWallet
@ -46,7 +47,7 @@ class MockTangemSdkManager(
override val canUseBiometry: Boolean = false
override val needEnrollBiometrics: Boolean = false
override val isEnrollBiometricsNeeded: Boolean = false
override val keystoreManager = DummyKeystoreManager()
@ -57,7 +58,7 @@ class MockTangemSdkManager(
override suspend fun checkCanUseBiometry(awaitInitialization: Boolean): Boolean = canUseBiometry
override suspend fun checkNeedEnrollBiometrics(awaitInitialization: Boolean): Boolean = needEnrollBiometrics
override suspend fun checkNeedEnrollBiometrics(awaitInitialization: Boolean): Boolean = isEnrollBiometricsNeeded
override suspend fun scanProduct(
cardId: String?,
@ -241,6 +242,12 @@ class MockTangemSdkManager(
error("Not implemented")
}
override suspend fun tangemPayProduceVirtualAccountData(
preflightReadFilter: PreflightReadFilter,
): Either<Throwable, VirtualAccountActivationData> {
error("Not implemented")
}
override suspend fun getWithdrawalSignature(
hash: String,
preflightReadFilter: PreflightReadFilter,

View file

@ -33,6 +33,9 @@ object MockProvider {
MockOption("Shiba (No Backup, No Wallets)") { ShibaNoBackupNoWalletsMockContent },
MockOption("Ed25519 Curve") { EdCurveMockContent },
MockOption("Secp256k1 Curve") { Secpk1CurveMockContent },
MockOption("Wallet 2 (No ed25519_slip0010)") { Wallet2NoEd25519Slip0010MockContent },
MockOption("Wallet 1 (Legacy derivation)") { Wallet1LegacyDerivationMockContent },
MockOption("Firmware 4.51") { Firmware451MockContent },
MockOption("Backup Wallet") { BackupWalletMockContent },
MockOption("Dev Wallet") { DevWalletMockContent },
MockOption("Firmware 4.12") { Firmware412MockContent },

View file

@ -0,0 +1,15 @@
package com.tangem.tap.domain.sdk.mocks.content
import com.tangem.domain.models.scan.CardDTO
import com.tangem.domain.models.scan.ScanResponse
import com.tangem.tap.domain.sdk.mocks.MockContent
// Firmware 4.51: HD-capable (>= 4.39) but below SolanaTokensAvailable (4.52), so Solana tokens are firmware-limited.
object Firmware451MockContent : MockContent by WalletMockContent {
override val cardDto: CardDTO = WalletMockContent.cardDto.copy(
firmwareVersion = WalletMockContent.cardDto.firmwareVersion.copy(minor = 51),
)
override val scanResponse: ScanResponse = WalletMockContent.scanResponse.copy(card = cardDto)
}

View file

@ -0,0 +1,13 @@
package com.tangem.tap.domain.sdk.mocks.content
import com.tangem.domain.models.scan.CardDTO
import com.tangem.domain.models.scan.ScanResponse
import com.tangem.tap.domain.sdk.mocks.MockContent
// Wallet1 on a first-batch id (AC01) — resolves to the V1 (legacy) derivation style.
object Wallet1LegacyDerivationMockContent : MockContent by WalletMockContent {
override val cardDto: CardDTO = WalletMockContent.cardDto.copy(batchId = "AC01")
override val scanResponse: ScanResponse = WalletMockContent.scanResponse.copy(card = cardDto)
}

View file

@ -0,0 +1,18 @@
package com.tangem.tap.domain.sdk.mocks.content
import com.tangem.common.card.EllipticCurve
import com.tangem.domain.models.scan.CardDTO
import com.tangem.domain.models.scan.ScanResponse
import com.tangem.tap.domain.sdk.mocks.MockContent
// Wallet2 without its ed25519_slip0010 wallet, so adding Solana warns UnsupportedCurve (no wallet for its curve).
object Wallet2NoEd25519Slip0010MockContent : MockContent by Wallet2WithSeedPhraseMockContent {
override val cardDto: CardDTO = Wallet2WithSeedPhraseMockContent.cardDto.copy(
wallets = Wallet2WithSeedPhraseMockContent.cardDto.wallets.filterNot {
it.curve == EllipticCurve.Ed25519Slip0010
},
)
override val scanResponse: ScanResponse = Wallet2WithSeedPhraseMockContent.scanResponse.copy(card = cardDto)
}

View file

@ -160,6 +160,10 @@ object WalletMockContent : MockContent {
publicKey = byteArrayOf(2, -40, 115, -15, 80, 104, 100, -29, 80, 29, 112, -35, -54, 64, -98, 45, 115, 108, 93, 113, -71, 89, 17, 72, 98, 21, 126, 82, -50, 50, -12, -87, -62),
chainCode = byteArrayOf(-27, 56, 57, 54, 27, -40, 65, 109, 119, -54, -94, 88, -29, -115, -45, -112, -31, 57, 53, 56, 118, 87, 34, -16, -64, -45, 105, 77, 91, -106, -92, 41),
),
DerivationPath("m/44'/111111'/0'/0/0") to ExtendedPublicKey( // Kaspa
publicKey = byteArrayOf(2, -40, 115, -15, 80, 104, 100, -29, 80, 29, 112, -35, -54, 64, -98, 45, 115, 108, 93, 113, -71, 89, 17, 72, 98, 21, 126, 82, -50, 50, -12, -87, -62),
chainCode = byteArrayOf(-27, 56, 57, 54, 27, -40, 65, 109, 119, -54, -94, 88, -29, -115, -45, -112, -31, 57, 53, 56, 118, 87, 34, -16, -64, -45, 105, 77, 91, -106, -92, 41),
),
),
extendedPublicKey = ExtendedPublicKey(
publicKey = secp256k1WalletPublicKey,
@ -254,6 +258,13 @@ object WalletMockContent : MockContent {
parentFingerprint = byteArrayOf(0, 0, 0, 0),
childNumber = 0,
),
DerivationPath("m/84'/2'/0'/0/0") to ExtendedPublicKey( // ltc (reuses valid btc key)
publicKey = byteArrayOf(3, 45, 58, -110, -52, -51, -83, -4, -45, -118, 119, 37, 123, -17, 66, -83, 61, -106, 115, 47, 121, 66, 84, -122, -57, -45, 7, -79, 70, -13, 28, -125, -52),
chainCode = byteArrayOf(93, 51, 52, -66, -39, -38, 34, -84, 50, 1, -127, -20, 80, -20, -30, -72, 2, 1, -78, -81, -17, 51, -52, -25, 12, 108, 50, 89, -66, 18, 65, 70),
depth = 0,
parentFingerprint = byteArrayOf(0, 0, 0, 0),
childNumber = 0,
),
DerivationPath("m/44'/60'/0'/0/0") to ExtendedPublicKey( // eth
publicKey = byteArrayOf(2, 34, 6, 119, -106, 5, -119, 111, -22, 8, 23, -108, -72, -56, 6, 77, -17, -61, -101, -85, 16, 28, 18, 3, -3, -89, -81, -108, 48, -7, -86, -82, -67),
chainCode = byteArrayOf(-75, 55, 107, -106, -37, -81, -15, 72, -102, 94, 55, -39, 9, -112, 1, 90, -50, 103, 53, 120, -92, -36, -85, -39, -65, 1, 88, 46, 92, 104, -13, -109),
@ -261,6 +272,13 @@ object WalletMockContent : MockContent {
parentFingerprint = byteArrayOf(0, 0, 0, 0),
childNumber = 0,
),
DerivationPath("m/44'/550'/0'/0/0") to ExtendedPublicKey( // xdc (EVM, reuses valid eth key)
publicKey = byteArrayOf(2, 34, 6, 119, -106, 5, -119, 111, -22, 8, 23, -108, -72, -56, 6, 77, -17, -61, -101, -85, 16, 28, 18, 3, -3, -89, -81, -108, 48, -7, -86, -82, -67),
chainCode = byteArrayOf(-75, 55, 107, -106, -37, -81, -15, 72, -102, 94, 55, -39, 9, -112, 1, 90, -50, 103, 53, 120, -92, -36, -85, -39, -65, 1, 88, 46, 92, 104, -13, -109),
depth = 0,
parentFingerprint = byteArrayOf(0, 0, 0, 0),
childNumber = 0,
),
DerivationPath("m/44'/60'/0'/0/1") to ExtendedPublicKey( // eth (account 2)
publicKey = byteArrayOf(2, 34, 6, 119, -106, 5, -119, 111, -22, 8, 23, -108, -72, -56, 6, 77, -17, -61, -101, -85, 16, 28, 18, 3, -3, -89, -81, -108, 48, -7, -86, -82, -67),
chainCode = byteArrayOf(-75, 55, 107, -106, -37, -81, -15, 72, -102, 94, 55, -39, 9, -112, 1, 90, -50, 103, 53, 120, -92, -36, -85, -39, -65, 1, 88, 46, 92, 104, -13, -109),
@ -486,6 +504,13 @@ object WalletMockContent : MockContent {
parentFingerprint = byteArrayOf(0, 0, 0, 0),
childNumber = 0,
),
DerivationPath("m/44'/3030'/0'/0'/0'") to ExtendedPublicKey( // Hedera (address resolves via network)
publicKey = byteArrayOf(-65, -53, -62, -12, -57, -32, -38, -9, -128, -52, -83, -61, 73, 39, 41, 15, -74, -97, 38, 52, -101, 63, 74, -56, -20, 15, 57, -127, 114, -93, -17, -109),
chainCode = byteArrayOf(-81, 15, 125, 28, -115, -22, 87, 81, 87, -123, -25, -74, 86, 2, 1, 110, -115, 65, -110, 63, -64, 83, -93, -97, -104, 123, 12, -26, 94, 27, 84, -6),
depth = 0,
parentFingerprint = byteArrayOf(0, 0, 0, 0),
childNumber = 0,
),
),
),

View file

@ -34,7 +34,10 @@ import com.tangem.operations.files.ReadFilesTask
import com.tangem.operations.issuerAndUserData.ReadIssuerDataCommand
import com.tangem.tap.domain.TapSdkError
import com.tangem.tap.domain.visa.VisaCardScanHandler
import com.tangem.tap.domain.walletregistration.WalletRegistrationLauncher
import com.tangem.tap.mainScope
import com.tangem.utils.coroutines.runSuspendCatching
import com.tangem.utils.logging.TangemLogger
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
@ -42,10 +45,11 @@ import kotlinx.coroutines.launch
internal class ScanProductTask(
private val card: Card?,
private val visaCardScanHandler: VisaCardScanHandler?,
private val visaCoroutineScope: CoroutineScope?,
private val sessionCoroutineScope: CoroutineScope?,
private val onboardingV2FeatureToggles: OnboardingV2FeatureToggles?,
private val shouldCheckIsAlreadyActivated: Boolean,
private val cardRepository: CardRepository,
private val walletRegistrationLauncher: WalletRegistrationLauncher? = null,
override val allowsRequestAccessCodeFromRepository: Boolean = false,
) : CardSessionRunnable<ScanResponse> {
@ -97,7 +101,7 @@ internal class ScanProductTask(
val processorScanResponseWithNewCard = processorResult.data.copy(
card = CardDTO(scanTaskResult.data),
)
callback(CompletionResult.Success(processorScanResponseWithNewCard))
registerColdWalletThenComplete(session, processorScanResponseWithNewCard, callback)
}
is CompletionResult.Failure -> callback(CompletionResult.Failure(scanTaskResult.error))
}
@ -107,6 +111,38 @@ internal class ScanProductTask(
}
}
/**
* Best-effort COLD wallet registration with the Auth Service while the session is still open
* (the card is tapped for `AttestWalletKeyTask`); the network POST is deferred by the launcher.
* The in-session part (nonce request + attestation) runs before the scan completes, so it can
* extend the scan slightly but it never *fails* the scan: on any error the scan still
* completes successfully.
*/
private fun registerColdWalletThenComplete(
session: CardSession,
scanResponse: ScanResponse,
callback: (result: CompletionResult<ScanResponse>) -> Unit,
) {
val scope = sessionCoroutineScope
val launcher = walletRegistrationLauncher
if (scope == null || launcher == null) {
TangemLogger.i("Skipping cold wallet registration: coroutine scope or launcher unavailable")
callback(CompletionResult.Success(scanResponse))
return
}
scope.launch {
try {
runSuspendCatching { launcher.registerColdInSession(session, scanResponse) }
.onFailure { TangemLogger.e("Cold wallet registration failed", it) }
} finally {
// Always complete the scan, even if the registration coroutine is cancelled
// (runSuspendCatching rethrows CancellationException) — the scan never depends on
// the registration outcome.
callback(CompletionResult.Success(scanResponse))
}
}
}
override fun preflightReadMode(): PreflightReadMode {
return if (shouldCheckIsAlreadyActivated) {
PreflightReadMode.FullCardReadWithAccessCodeCheck
@ -138,12 +174,12 @@ internal class ScanProductTask(
return
}
visaCoroutineScope ?: run {
sessionCoroutineScope ?: run {
callback(CompletionResult.Failure(TangemSdkError.InsNotSupported()))
return
}
visaCoroutineScope.launch {
sessionCoroutineScope.launch {
when (val result = visaCardScanHandler.handleVisaCardScan(session = session)) {
is CompletionResult.Success -> {
scanWalletProcessor.proceed(

View file

@ -0,0 +1,80 @@
package com.tangem.tap.domain.tasks.visa
import com.tangem.common.CompletionResult
import com.tangem.common.card.CardWallet
import com.tangem.common.core.CardSession
import com.tangem.common.core.CardSessionRunnable
import com.tangem.common.core.CompletionCallback
import com.tangem.common.core.TangemSdkError
import com.tangem.common.extensions.toMapKey
import com.tangem.core.error.ext.tangemError
import com.tangem.crypto.hdWallet.bip32.ExtendedPublicKey
import com.tangem.domain.card.common.visa.VisaUtilities
import com.tangem.domain.visa.error.VisaActivationError
import com.tangem.domain.visa.model.VirtualAccountActivationData
import com.tangem.operations.derivation.DeriveWalletPublicKeyTask
import com.tangem.operations.derivation.ExtendedPublicKeysMap
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
/**
* Derives the Virtual Account key ([VisaUtilities.virtualAccountDerivationPath]) on the card and
* generates its deposit address. The derived key is returned (keyed by the seed wallet public key)
* so the caller can persist it via `DerivationsRepository.storeDerivedKeys` no second tap needed.
*/
class TangemPayGenerateVirtualAccountAddressTask @AssistedInject constructor(
@Assisted private val coroutineScope: CoroutineScope,
) : CardSessionRunnable<VirtualAccountActivationData> {
override fun run(session: CardSession, callback: CompletionCallback<VirtualAccountActivationData>) {
coroutineScope.launch {
callback(runSuspend(session = session))
}
}
private suspend fun runSuspend(session: CardSession): CompletionResult<VirtualAccountActivationData> {
val card = session.environment.card ?: return CompletionResult.Failure(TangemSdkError.MissingPreflightRead())
val wallet = card.wallets.firstOrNull { it.curve == VisaUtilities.curve }
?: return CompletionResult.Failure(VisaActivationError.MissingWallet.tangemError)
val extendedPublicKey = when (val derivationResult = runDerivationTask(session, wallet)) {
is CompletionResult.Failure<*> -> return CompletionResult.Failure(derivationResult.error)
is CompletionResult.Success<ExtendedPublicKey> -> derivationResult.data
}
val address = VisaUtilities.generateAddressFromExtendedKey(extendedPublicKey = extendedPublicKey)
val derivedKeys = mapOf(
wallet.publicKey.toMapKey() to ExtendedPublicKeysMap(
mapOf(VisaUtilities.virtualAccountDerivationPath to extendedPublicKey),
),
)
return CompletionResult.Success(
data = VirtualAccountActivationData(address = address, derivedKeys = derivedKeys),
)
}
private suspend fun runDerivationTask(
session: CardSession,
wallet: CardWallet,
): CompletionResult<ExtendedPublicKey> {
val deferred = CompletableDeferred<CompletionResult<ExtendedPublicKey>>()
val derivationTask = DeriveWalletPublicKeyTask(
walletPublicKey = wallet.publicKey,
derivationPath = VisaUtilities.virtualAccountDerivationPath,
)
derivationTask.run(session = session, callback = deferred::complete)
return deferred.await()
}
@AssistedFactory
interface Factory {
fun create(coroutineScope: CoroutineScope): TangemPayGenerateVirtualAccountAddressTask
}
}

View file

@ -31,7 +31,7 @@ class FinalizeTwinTask(
ScanProductTask(
card = readResult.data,
visaCardScanHandler = null,
visaCoroutineScope = null,
sessionCoroutineScope = null,
shouldCheckIsAlreadyActivated = false,
onboardingV2FeatureToggles = null,
cardRepository = cardRepository,

View file

@ -0,0 +1,15 @@
package com.tangem.tap.domain.userWalletList.di
import com.tangem.domain.common.wallets.UserWalletDataCleaner
import dagger.Module
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import dagger.multibindings.Multibinds
@Module
@InstallIn(SingletonComponent::class)
internal interface UserWalletDataCleanerModule {
@Multibinds
fun userWalletDataCleaners(): Set<UserWalletDataCleaner>
}

View file

@ -14,7 +14,6 @@ import com.tangem.core.analytics.utils.TrackingContextProxy
import com.tangem.datasource.local.preferences.AppPreferencesStore
import com.tangem.datasource.local.preferences.PreferencesKeys
import com.tangem.datasource.local.preferences.utils.getSyncOrDefault
import com.tangem.domain.appsflyer.AppsFlyerDeeplinkSource
import com.tangem.domain.appsflyer.usecase.ClearAppsFlyerDeeplinkUseCase
import com.tangem.domain.common.wallets.UserWalletSelectedHandler
import com.tangem.domain.common.wallets.UserWalletTransformAction
@ -622,11 +621,12 @@ internal class DefaultUserWalletsListRepository(
private suspend fun onFirstWalletCreated() {
// reset the referral attribution (set from AF deeplink) after creating a new wallet
clearAppsFlyerDeeplinkUseCase(AppsFlyerDeeplinkSource.Referral)
clearAppsFlyerDeeplinkUseCase()
}
private suspend fun onAllWalletsDeleted() {
// reset the referral attribution (set from AF deeplink) after removing the last wallet
clearAppsFlyerDeeplinkUseCase(AppsFlyerDeeplinkSource.Referral)
clearAppsFlyerDeeplinkUseCase()
appPreferencesStore.editData { it.remove(PreferencesKeys.USEDESK_CLIENT_ID_KEY) }
}
}

View file

@ -0,0 +1,109 @@
package com.tangem.tap.domain.walletregistration
import com.tangem.blockchain.common.UnmarshalHelper
import com.tangem.common.CompletionResult
import com.tangem.common.card.EllipticCurve
import com.tangem.common.core.CardSession
import com.tangem.common.extensions.calculateSha256
import com.tangem.common.extensions.toDecompressedPublicKey
import com.tangem.domain.models.scan.ScanResponse
import com.tangem.lib.auth.session.WalletSignatureBundle
import com.tangem.lib.auth.session.WalletSigner
import com.tangem.operations.attestation.AttestWalletKeyResponse
import com.tangem.operations.attestation.AttestWalletKeyTask
import kotlinx.coroutines.suspendCancellableCoroutine
import javax.inject.Inject
import kotlin.coroutines.resume
/**
* Builds a [WalletSigner] for a COLD (card-backed) wallet. Runs `AttestWalletKeyTask` in Dynamic
* mode **inside an already-open [CardSession]** (no extra tap): the card produces the wallet
* signature and, on COS 2.01+, the card signature over the wallet nonce. On older cards without a
* card signature the wallet is registered with the wallet signature only (backend treats it as hot).
*/
internal class ColdWalletRegistrationSigner @Inject constructor() {
fun signerFor(session: CardSession, scanResponse: ScanResponse): WalletSigner = WalletSigner { nonceBytes ->
val card = scanResponse.card
val walletPublicKey = card.wallets.firstOrNull { it.curve == EllipticCurve.Secp256k1 }?.publicKey
?: error("No secp256k1 wallet on card ${card.cardId}")
buildBundle(
response = attest(session, walletPublicKey, nonceBytes),
walletPublicKey = walletPublicKey,
cardPublicKey = card.cardPublicKey,
nonceBytes = nonceBytes,
)
}
/**
* Pure mapping of an [AttestWalletKeyResponse] to a [WalletSignatureBundle] (no card session)
* unit-testable in isolation.
*/
fun buildBundle(
response: AttestWalletKeyResponse,
walletPublicKey: ByteArray,
cardPublicKey: ByteArray,
nonceBytes: ByteArray,
): WalletSignatureBundle {
val salt = response.salt
val walletSignatureRsv = UnmarshalHelper.unmarshalSignatureExtended(
signature = response.walletSignature,
hash = (nonceBytes + salt).calculateSha256(),
publicKey = walletPublicKey.toDecompressedPublicKey(),
).asRSVLegacyEVM()
val cardSignature = response.cardSignature
val publicKeySalt = response.publicKeySalt
// The dynamic card signature (proving the card owns the wallet) requires COS 2.01+. On older
// cards it is absent — we then register with the wallet signature only, and the backend
// treats such a wallet as hot (no card-ownership proof).
if (cardSignature == null || publicKeySalt == null) {
return WalletSignatureBundle(
walletSignature = walletSignatureRsv,
walletSignatureSalt = salt,
cardSignature = null,
cardSignatureSalt = null,
walletStatusByte = null,
)
}
val walletStatusByte = response.walletStatus?.code?.toByte()
// Card-signature preimage: walletPublicKey | challenge | publicKeySalt [| walletStatus].
// The walletStatus byte is appended only when the card reports it (COS 6+).
val cardMessage = walletPublicKey + nonceBytes + publicKeySalt +
(walletStatusByte?.let { byteArrayOf(it) } ?: ByteArray(size = 0))
val cardSignatureRsv = UnmarshalHelper.unmarshalSignatureExtended(
signature = cardSignature,
hash = cardMessage.calculateSha256(),
publicKey = cardPublicKey.toDecompressedPublicKey(),
).asRSVLegacyEVM()
return WalletSignatureBundle(
walletSignature = walletSignatureRsv,
walletSignatureSalt = salt,
cardSignature = cardSignatureRsv,
cardSignatureSalt = publicKeySalt,
walletStatusByte = walletStatusByte,
)
}
private suspend fun attest(
session: CardSession,
walletPublicKey: ByteArray,
nonceBytes: ByteArray,
): AttestWalletKeyResponse {
val result = suspendCancellableCoroutine { continuation ->
AttestWalletKeyTask(publicKey = walletPublicKey, challenge = nonceBytes)
.run(session) { if (continuation.isActive) continuation.resume(it) }
}
return when (result) {
is CompletionResult.Success -> result.data
is CompletionResult.Failure -> throw ColdWalletAttestationException(result.error.customMessage)
}
}
}
/** `AttestWalletKeyTask` failed (NFC error, verification failure, user cancelled). */
internal class ColdWalletAttestationException(message: String) :
Exception("Cold wallet attestation failed: $message")

View file

@ -0,0 +1,14 @@
package com.tangem.tap.domain.walletregistration
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.wallets.registration.WalletRegistrationTrigger
import javax.inject.Inject
internal class DefaultWalletRegistrationTrigger @Inject constructor(
private val launcher: WalletRegistrationLauncher,
) : WalletRegistrationTrigger {
override suspend fun onMobileWalletCreated(userWallet: UserWallet.Hot) {
launcher.registerMobile(userWallet)
}
}

View file

@ -0,0 +1,56 @@
package com.tangem.tap.domain.walletregistration
import com.tangem.blockchain.common.UnmarshalHelper
import com.tangem.common.card.EllipticCurve
import com.tangem.common.extensions.calculateSha256
import com.tangem.common.extensions.toDecompressedPublicKey
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.wallets.hot.HotWalletAccessor
import com.tangem.hot.sdk.model.DataToSign
import com.tangem.lib.auth.session.WalletSignatureBundle
import com.tangem.lib.auth.session.WalletSigner
import java.security.SecureRandom
import javax.inject.Inject
/**
* Builds a [WalletSigner] for a MOBILE (hot/software) wallet. The wallet's secp256k1 key signs
* `sha256(nonceBytes || walletSignatureSalt)` via the hot wallet SDK; there is no card, so the
* card-signature fields stay null.
*/
internal class MobileWalletRegistrationSigner @Inject constructor(
private val hotWalletAccessor: HotWalletAccessor,
) {
fun signerFor(userWallet: UserWallet.Hot): WalletSigner = WalletSigner { nonceBytes ->
val wallet = userWallet.wallets
?.firstOrNull { it.curve == EllipticCurve.Secp256k1 }
?: error("No secp256k1 wallet available for hot wallet ${userWallet.walletId}")
val salt = ByteArray(SALT_SIZE).also(secureRandom::nextBytes)
val hash = (nonceBytes + salt).calculateSha256()
val signature = hotWalletAccessor.signHashes(
hotWalletId = userWallet.hotWalletId,
dataToSign = listOf(DataToSign(curve = EllipticCurve.Secp256k1, hashes = listOf(hash))),
).first().signatures.first()
val rsvSignature = UnmarshalHelper.unmarshalSignatureExtended(
signature = signature,
hash = hash,
publicKey = wallet.publicKey.toDecompressedPublicKey(),
).asRSVLegacyEVM()
WalletSignatureBundle(
walletSignature = rsvSignature,
walletSignatureSalt = salt,
cardSignature = null,
cardSignatureSalt = null,
walletStatusByte = null,
)
}
private companion object {
const val SALT_SIZE = 16
val secureRandom = SecureRandom()
}
}

View file

@ -0,0 +1,82 @@
package com.tangem.tap.domain.walletregistration
import android.util.Base64
import arrow.core.getOrElse
import com.tangem.common.core.CardSession
import com.tangem.domain.models.scan.ScanResponse
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.wallets.builder.UserWalletIdBuilder
import com.tangem.lib.auth.AuthFeatureToggles
import com.tangem.lib.auth.session.WalletRegistrar
import com.tangem.utils.coroutines.AppCoroutineScope
import com.tangem.utils.coroutines.runSuspendCatching
import com.tangem.utils.logging.TangemLogger
import kotlinx.coroutines.launch
import javax.inject.Inject
/**
* Single entry point that registers wallets with the Tangem Auth Service. Gated by the backend-auth
* feature toggle; all failures are log-only (registration is retried on the next launch / card scan,
* never blocks the user). MOBILE wallets register without UI; COLD wallets attest inside a live
* card session (no extra tap) and POST after the session closes.
*/
internal class WalletRegistrationLauncher @Inject constructor(
private val walletRegistrar: WalletRegistrar,
private val mobileSigner: MobileWalletRegistrationSigner,
private val coldSigner: ColdWalletRegistrationSigner,
private val authFeatureToggles: AuthFeatureToggles,
private val appCoroutineScope: AppCoroutineScope,
) {
/**
* Never throws (beyond cooperative cancellation) any unexpected failure is caught and logged,
* so callers relying on the fire-and-forget contract stay safe.
*/
suspend fun registerMobile(userWallet: UserWallet.Hot) {
if (!authFeatureToggles.isBackendAuthenticationEnabled) return
runSuspendCatching {
walletRegistrar.register(
walletId = userWallet.walletId.toBase64(),
signer = mobileSigner.signerFor(userWallet),
).onLeft { TangemLogger.e("Mobile wallet registration deferred: $it") }
}.onFailure { TangemLogger.e("Mobile wallet registration failed", it) }
}
/**
* COLD registration. Phase 1 ([WalletRegistrar.prepare]) runs inside the still-open [session]
* (the card is tapped here); phase 2 (the network POST) is dispatched on [appCoroutineScope]
* after this returns, so the user doesn't hold the card during the request. Call this BEFORE
* the scan completes its session callback.
*/
suspend fun registerColdInSession(session: CardSession, scanResponse: ScanResponse) {
if (!authFeatureToggles.isBackendAuthenticationEnabled) return
val walletId = UserWalletIdBuilder.scanResponse(scanResponse).build()?.toBase64() ?: return
val prepared = walletRegistrar.prepare(walletId, coldSigner.signerFor(session, scanResponse))
.getOrElse { error ->
TangemLogger.e("Cold wallet registration prepare deferred: $error")
return
}
if (prepared == null) return // already registered
appCoroutineScope.launch {
runSuspendCatching {
walletRegistrar.submit(prepared)
.onLeft { TangemLogger.e("Cold wallet registration submit deferred: $it") }
}.onFailure { TangemLogger.e("Cold wallet registration submit failed", it) }
}
}
/** Launch-time safety net: registers any not-yet-registered MOBILE wallets (no UI). */
suspend fun retryMobileRegistrations(userWallets: List<UserWallet>) {
if (!authFeatureToggles.isBackendAuthenticationEnabled) return
userWallets.filterIsInstance<UserWallet.Hot>().forEach { registerMobile(it) }
}
private fun UserWalletId.toBase64(): String = Base64.encodeToString(value, Base64.NO_WRAP)
}

View file

@ -0,0 +1,15 @@
package com.tangem.tap.domain.walletregistration
import com.tangem.domain.wallets.registration.WalletRegistrationTrigger
import dagger.Binds
import dagger.Module
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
@Module
@InstallIn(SingletonComponent::class)
internal interface WalletRegistrationModule {
@Binds
fun bindWalletRegistrationTrigger(impl: DefaultWalletRegistrationTrigger): WalletRegistrationTrigger
}

View file

@ -31,6 +31,7 @@ internal fun AppSettingsScreen(state: AppSettingsScreenState, onBackClick: () ->
modifier = modifier,
titleRes = R.string.app_settings_title,
addBottomInsets = false,
backButtonTestTag = AppSettingsScreenTestTags.BACK_BUTTON,
content = {
when (state) {
is AppSettingsScreenState.Content -> AppSettings(state = state)

View file

@ -115,7 +115,7 @@ internal class AppSettingsModel @Inject constructor(
private fun observeBiometricsStatusChanges() {
flow {
do {
val isEnrollBiometricsNeeded = runCatching(tangemSdkManager::needEnrollBiometrics).getOrNull()
val isEnrollBiometricsNeeded = runCatching(tangemSdkManager::isEnrollBiometricsNeeded).getOrNull()
if (isEnrollBiometricsNeeded != null) {
emit(isEnrollBiometricsNeeded)
}
@ -366,7 +366,7 @@ internal class AppSettingsModel @Inject constructor(
localState.update { state ->
state.copy(
hasSecuredWallets = userWalletsListRepository.hasSecuredWallets(),
isEnrollBiometricsNeeded = runCatching(tangemSdkManager::needEnrollBiometrics).getOrNull() == true,
isEnrollBiometricsNeeded = runCatching(tangemSdkManager::isEnrollBiometricsNeeded).getOrNull() == true,
isBiometricAuthenticationUsed = walletsRepository.useBiometricAuthentication(),
isAccessCodeRequired = walletsRepository.requireAccessCode(),
)

View file

@ -8,6 +8,7 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.unit.dp
import com.tangem.core.ui.components.PrimaryButtonIconEnd
@ -23,6 +24,7 @@ internal fun SettingsScreensScaffold(
@StringRes titleRes: Int? = null,
addBottomInsets: Boolean = true,
snackbarHostState: SnackbarHostState = remember { SnackbarHostState() },
backButtonTestTag: String? = null,
content: @Composable () -> Unit,
fab: @Composable () -> Unit = {},
) {
@ -35,6 +37,7 @@ internal fun SettingsScreensScaffold(
modifier = Modifier.statusBarsPadding(),
onBackClick = onBackClick,
backgroundColor = backgroundColor,
backButtonTestTag = backButtonTestTag,
)
},
modifier = modifier,
@ -91,13 +94,17 @@ internal fun EmptyTopBarWithNavigation(
onBackClick: () -> Unit,
modifier: Modifier = Modifier,
backgroundColor: Color = TangemTheme.colors.background.primary,
backButtonTestTag: String? = null,
) {
TopAppBar(
modifier = modifier,
title = { },
navigationIcon =
{
IconButton(onClick = onBackClick) {
IconButton(
onClick = onBackClick,
modifier = if (backButtonTestTag != null) Modifier.testTag(backButtonTestTag) else Modifier,
) {
Icon(
painter = painterResource(id = R.drawable.ic_back_24),
contentDescription = null,

View file

@ -0,0 +1,19 @@
package com.tangem.tap.features.root
import kotlinx.coroutines.channels.Channel
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
internal class DefaultRootWarningContinuation @Inject constructor() : RootWarningContinuation {
private val dismissals = Channel<Unit>(capacity = Channel.CONFLATED)
override suspend fun awaitDismiss() {
dismissals.receive()
}
override fun dismiss() {
dismissals.trySend(Unit)
}
}

View file

@ -1,68 +1,40 @@
package com.tangem.tap.features.root
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.arkivanov.essenty.instancekeeper.getOrCreateSimple
import com.tangem.core.decompose.context.AppComponentContext
import com.tangem.core.decompose.factory.ComponentFactory
import com.tangem.core.ui.components.DialogFullScreen
import com.tangem.core.ui.decompose.ComposableContentComponent
import com.tangem.domain.settings.repositories.SettingsRepository
import com.tangem.security.DeviceSecurityInfoProvider
import com.tangem.security.isSecurityExposed
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
/**
* Full-screen root-detected security warning. Presentational only its visibility is controlled by the
* startup gate (via a childSlot); "Continue" resolves [RootWarningContinuation]. Whether it should be shown
* at all (and marking it as shown) is decided by the gate.
*/
@Suppress("UnusedPrivateProperty")
class RootDetectedWarningComponent @AssistedInject constructor(
internal class RootDetectedWarningComponent @AssistedInject constructor(
@Assisted appComponentContext: AppComponentContext,
@Assisted params: Unit,
private val securityInfoProvider: DeviceSecurityInfoProvider,
private val settingsRepository: SettingsRepository,
private val rootWarningContinuation: RootWarningContinuation,
) : AppComponentContext by appComponentContext, ComposableContentComponent {
private val isShown = instanceKeeper.getOrCreateSimple { MutableStateFlow(false) }
suspend fun shouldShowWarning(): Boolean {
return settingsRepository.isRootDetectedWarningShown().not() && securityInfoProvider.isSecurityExposed()
}
suspend fun tryToShowWarningAndWaitContinuation() {
if (isShown.value) return
if (shouldShowWarning()) {
isShown.value = true
}
isShown.first { it == false } // Wait until the warning is dismissed
}
@Composable
override fun Content(modifier: Modifier) {
val isShownState by isShown.collectAsStateWithLifecycle()
if (isShownState) {
DialogFullScreen(onDismissRequest = {}) {
RootDetectedWarningContent(
modifier = modifier,
onContinueClick = remember(this) { ::onContinueClick },
)
}
DialogFullScreen(onDismissRequest = {}) {
RootDetectedWarningContent(
modifier = modifier,
onContinueClick = remember(this) { ::onContinueClick },
)
}
}
private fun onContinueClick() {
componentScope.launch {
settingsRepository.setRootDetectedWarningShown(true)
isShown.value = false
}
rootWarningContinuation.dismiss()
}
@AssistedFactory

View file

@ -0,0 +1,13 @@
package com.tangem.tap.features.root
/**
* Resumes the startup gate after the root-detected security warning is dismissed.
*
* The gate awaits [awaitDismiss] while the warning is shown, and the screen calls [dismiss] on "Continue".
*/
interface RootWarningContinuation {
suspend fun awaitDismiss()
fun dismiss()
}

View file

@ -0,0 +1,18 @@
package com.tangem.tap.features.root.di
import com.tangem.tap.features.root.DefaultRootWarningContinuation
import com.tangem.tap.features.root.RootWarningContinuation
import dagger.Binds
import dagger.Module
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import javax.inject.Singleton
@Module
@InstallIn(SingletonComponent::class)
internal interface RootModule {
@Binds
@Singleton
fun bindRootWarningContinuation(impl: DefaultRootWarningContinuation): RootWarningContinuation
}

View file

@ -42,8 +42,9 @@ internal fun RootContent(
onBack: () -> Unit,
modifier: Modifier = Modifier,
wcContent: @Composable (modifier: Modifier) -> Unit,
promoContent: @Composable (modifier: Modifier) -> Unit,
hotAccessCodeContent: @Composable (modifier: Modifier) -> Unit,
rootDetectedWarningContent: @Composable (modifier: Modifier) -> Unit,
startupGateContent: @Composable (modifier: Modifier) -> Unit,
scanFailsContent: @Composable (modifier: Modifier) -> Unit,
) {
val context = LocalContext.current
@ -80,9 +81,11 @@ internal fun RootContent(
wcContent(Modifier.fillMaxSize())
promoContent(Modifier.fillMaxSize())
hotAccessCodeContent(Modifier.fillMaxSize())
rootDetectedWarningContent(Modifier.fillMaxSize())
startupGateContent(Modifier.fillMaxSize())
scanFailsContent(Modifier.fillMaxSize())

View file

@ -19,8 +19,6 @@ import com.tangem.core.analytics.models.Basic
import com.tangem.core.analytics.models.ExceptionAnalyticsEvent
import com.tangem.core.analytics.models.event.OnboardingAnalyticsEvent
import com.tangem.core.analytics.utils.TrackingContextProxy
import com.tangem.core.configtoggle.FeatureToggles
import com.tangem.core.configtoggle.feature.FeatureTogglesManager
import com.tangem.core.decompose.context.AppComponentContext
import com.tangem.core.decompose.context.child
import com.tangem.core.decompose.context.childByContext
@ -31,7 +29,6 @@ import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.message.DialogMessage
import com.tangem.core.ui.message.EventMessageAction
import com.tangem.core.ui.message.SnackbarMessage
import com.tangem.datasource.local.appsflyer.AppsFlyerDeeplinkSource
import com.tangem.domain.card.repository.CardRepository
import com.tangem.domain.common.wallets.UserWalletsListRepository
import com.tangem.domain.models.scan.ScanResponse
@ -47,20 +44,21 @@ import com.tangem.features.hotwallet.HotAccessCodeRequestComponent
import com.tangem.features.hotwallet.accesscoderequest.proxy.HotWalletPasswordRequesterProxy
import com.tangem.features.onboarding.v2.common.analytics.OnboardingEvent
import com.tangem.features.pushnotifications.api.utils.PUSH_PERMISSION
import com.tangem.features.promobanners.api.swapcashback.CampaignsComponent
import com.tangem.features.walletconnect.components.WcRoutingComponent
import com.tangem.hot.sdk.TangemHotSdk
import com.tangem.hot.sdk.android.create
import com.tangem.sdk.api.BackupServiceHolder
import com.tangem.tap.common.SnackbarHandler
import com.tangem.tap.common.analytics.appsflyer.AppsFlyerReferralParamsHandler
import com.tangem.tap.common.analytics.appsflyer.AppsFlyerDeeplinkRouter
import com.tangem.tap.features.hot.TangemHotSDKProxy
import com.tangem.tap.features.root.RootDetectedWarningComponent
import com.tangem.tap.features.scanfails.ScanFailsComponent
import com.tangem.tap.features.scanfails.ScanFailsRequesterProxy
import com.tangem.tap.routing.RootContent
import com.tangem.tap.routing.component.RoutingComponent
import com.tangem.tap.routing.component.RoutingComponent.Child
import com.tangem.tap.routing.configurator.AppRouterConfig
import com.tangem.tap.routing.startup.AppStartupGateComponent
import com.tangem.tap.routing.utils.ChildFactory
import com.tangem.tap.routing.utils.DeepLinkFactory
import com.tangem.utils.logging.TangemLogger
@ -68,11 +66,8 @@ import com.tangem.wallet.R
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import kotlinx.coroutines.async
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.withTimeoutOrNull
import kotlin.time.Duration.Companion.seconds
@Suppress("LongParameterList", "LargeClass")
internal class DefaultRoutingComponent @AssistedInject constructor(
@ -83,15 +78,16 @@ internal class DefaultRoutingComponent @AssistedInject constructor(
private val appRouterConfig: AppRouterConfig,
private val uiDependencies: UiDependencies,
private val wcRoutingComponentFactory: WcRoutingComponent.Factory,
private val campaignsComponentFactory: CampaignsComponent.Factory,
private val deeplinkFactory: DeepLinkFactory,
private val tangemHotSDKProxy: TangemHotSDKProxy,
private val hotAccessCodeRequestComponentFactory: HotAccessCodeRequestComponent.Factory,
private val hotAccessCodeRequesterProxy: HotWalletPasswordRequesterProxy,
private val rootDetectedWarningComponentFactory: RootDetectedWarningComponent.Factory,
private val appStartupGateComponentFactory: AppStartupGateComponent.Factory,
private val userWalletsListRepository: UserWalletsListRepository,
private val cardRepository: CardRepository,
private val onboardingRepository: OnboardingRepository,
private val appsFlyerReferralParamsHandler: AppsFlyerReferralParamsHandler,
private val appsFlyerDeeplinkRouter: AppsFlyerDeeplinkRouter,
private val trackingContextProxy: TrackingContextProxy,
private val scanFailsComponentFactory: ScanFailsComponent.Factory,
private val scanFailsRequesterProxy: ScanFailsRequesterProxy,
@ -102,7 +98,6 @@ internal class DefaultRoutingComponent @AssistedInject constructor(
private val neverToInitiallyAskPermissionUseCase: NeverToInitiallyAskPermissionUseCase,
private val shouldInitiallyAskPermissionUseCase: ShouldInitiallyAskPermissionUseCase,
private val neverRequestPermissionUseCase: NeverRequestPermissionUseCase,
private val featureTogglesManager: FeatureTogglesManager,
) : RoutingComponent,
AppComponentContext by context,
SnackbarHandler {
@ -112,14 +107,18 @@ internal class DefaultRoutingComponent @AssistedInject constructor(
.create(child("wcRoutingComponent"), params = Unit)
}
private val campaignsComponent: CampaignsComponent by lazy {
campaignsComponentFactory
.create(child("swapCashbackCampaign"), params = Unit)
}
private val hotAccessCodeRequestComponent: HotAccessCodeRequestComponent by lazy {
hotAccessCodeRequestComponentFactory
.create(child("hotAccessCodeRequestComponent"), Unit)
}
private val rootDetectedWarningComponent: RootDetectedWarningComponent by lazy {
rootDetectedWarningComponentFactory
.create(child("rootDetectedWarningComponent"), Unit)
private val appStartupGateComponent: AppStartupGateComponent by lazy {
appStartupGateComponentFactory.create(child("appStartupGate"))
}
private val scanFailsComponent: ScanFailsComponent by lazy {
@ -147,6 +146,8 @@ internal class DefaultRoutingComponent @AssistedInject constructor(
},
)
private val currentRoute = MutableStateFlow<AppRoute?>(null)
init {
appRouterConfig.routerScope = componentScope
appRouterConfig.componentRouter = router
@ -161,6 +162,7 @@ internal class DefaultRoutingComponent @AssistedInject constructor(
val stackItems = stack.items.map { it.configuration }
currentRoute.value = stack.active.configuration
wcRoutingComponent.onAppRouteChange(stack.active.configuration)
deeplinkFactory.checkRoutingReadiness(stack.active.configuration)
@ -171,47 +173,46 @@ internal class DefaultRoutingComponent @AssistedInject constructor(
configureProxies()
initializeInitialNavigation()
appsFlyerDeeplinkRouter.observe(scope = componentScope, currentRoute = currentRoute)
}
private fun initializeInitialNavigation() {
if (initialStack.isNullOrEmpty()) {
componentScope.launch {
val initialRoute = resolveInitialRoute()
if (rootDetectedWarningComponent.shouldShowWarning()) {
launch(dispatchers.main) {
rootDetectedWarningComponent.tryToShowWarningAndWaitContinuation()
router.replaceAll(initialRoute)
}
} else {
router.replaceAll(initialRoute)
}
}
componentScope.launch { resolveAndNavigate() }
}
}
private suspend fun resolveInitialRoute(): AppRoute {
private suspend fun resolveAndNavigate() {
appStartupGateComponent.await()
navigateToStartRoute()
}
private suspend fun navigateToStartRoute() {
val initialRoute = resolveStartRoute()
onInitialRouteResolved(initialRoute)
router.replaceAll(initialRoute)
}
private suspend fun resolveStartRoute(): AppRoute {
val userWallets = userWalletsListRepository.userWalletsSync()
return when {
userWallets.isEmpty() -> navigateForEmptyWallets()
userWallets.any { it.isLocked } -> {
AppRoute.Welcome(
launchMode = launchMode,
)
}
else -> {
trackSignInEvent()
AppRoute.Wallet
}
}.also {
appRouterConfig.initializedState.value = true
checkForUnfinishedBackup()
userWallets.any { it.isLocked } -> AppRoute.Welcome(launchMode = launchMode)
else -> AppRoute.Wallet
}
}
private suspend fun onInitialRouteResolved(route: AppRoute) {
appRouterConfig.initializedState.value = true
if (route is AppRoute.Wallet) trackSignInEvent()
checkForUnfinishedBackup()
}
private suspend fun navigateForEmptyWallets(): AppRoute {
val afterEmptyRoute = resolveAppsFlyerOnboardingRoute()
?: AppRoute.Home(launchMode = launchMode)
// AppsFlyer deep links (referral, tpay_mobileonboard) are routed reactively by AppsFlyerDeeplinkRouter
// once we settle on an idle screen — not resolved here.
val afterEmptyRoute = AppRoute.Home(launchMode = launchMode)
val shouldAskPushPermission = shouldInitiallyAskPermissionUseCase(PUSH_PERMISSION).getOrNull()
?: return afterEmptyRoute
@ -231,44 +232,6 @@ internal class DefaultRoutingComponent @AssistedInject constructor(
}
}
private suspend fun resolveAppsFlyerOnboardingRoute(): AppRoute? = coroutineScope {
val tangemPayRoute = async { resolveTangemPayHotWalletOnboardingRoute() }
val referralRoute = async { resolveReferralRoute() }
tangemPayRoute.await() ?: referralRoute.await()
}
private suspend fun resolveTangemPayHotWalletOnboardingRoute(): AppRoute? {
val isEnabled = featureTogglesManager.isFeatureEnabled(
FeatureToggles.AND_15101_TANGEM_PAY_HOT_WALLET_ONBOARDING,
)
TangemLogger.i("[TangemPay][HWO] Feature toggle enabled=$isEnabled")
if (!isEnabled) return null
val deepLink = awaitAppsFlyerDeeplink(AppsFlyerDeeplinkSource.TangemPayHotWalletOnboarding)
TangemLogger.i("[TangemPay][HWO] Deep link present=${deepLink != null}")
return if (deepLink != null) AppRoute.TangemPayHotWalletOnboarding else null
}
private suspend fun resolveReferralRoute(): AppRoute? {
val isEnabled = featureTogglesManager.isFeatureEnabled(
FeatureToggles.TWI_1512_HIDE_STORIES_FOR_REFERRAL_ENABLED,
)
if (!isEnabled) return null
val referralDeepLink = awaitAppsFlyerDeeplink(AppsFlyerDeeplinkSource.Referral)
return if (referralDeepLink != null) {
AppRoute.CreateWalletStart(mode = AppRoute.CreateWalletStart.Mode.HotWallet)
} else {
null
}
}
private suspend fun awaitAppsFlyerDeeplink(source: AppsFlyerDeeplinkSource): String? {
return withTimeoutOrNull(2.seconds) {
appsFlyerReferralParamsHandler.waitForDeeplink(source)
}
}
@Composable
override fun Content(modifier: Modifier) {
RootContent(
@ -278,8 +241,9 @@ internal class DefaultRoutingComponent @AssistedInject constructor(
onBack = router::pop,
modifier = modifier,
wcContent = { wcRoutingComponent.Content(it) },
promoContent = { campaignsComponent.Content(it) },
hotAccessCodeContent = { hotAccessCodeRequestComponent.Content(it) },
rootDetectedWarningContent = { rootDetectedWarningComponent.Content(it) },
startupGateContent = { appStartupGateComponent.Content(it) },
scanFailsContent = { scanFailsComponent.Content(it) },
)
}

View file

@ -0,0 +1,141 @@
package com.tangem.tap.routing.startup
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import com.arkivanov.decompose.extensions.compose.subscribeAsState
import com.arkivanov.decompose.router.slot.ChildSlot
import com.arkivanov.decompose.router.slot.SlotNavigation
import com.arkivanov.decompose.router.slot.activate
import com.arkivanov.decompose.router.slot.childSlot
import com.arkivanov.decompose.router.slot.dismiss
import com.arkivanov.decompose.value.Value
import com.tangem.core.decompose.context.AppComponentContext
import com.tangem.core.decompose.context.childByContext
import com.tangem.core.ui.decompose.ComposableContentComponent
import com.tangem.domain.appupdate.model.AppUpdateState
import com.tangem.domain.appupdate.usecase.GetAppUpdateStateUseCase
import com.tangem.domain.settings.repositories.SettingsRepository
import com.tangem.features.forceupdate.ForceUpdateComponent
import com.tangem.features.forceupdate.ForceUpdateContinuation
import com.tangem.features.forceupdate.ForceUpdateFeatureToggles
import com.tangem.security.DeviceSecurityInfoProvider
import com.tangem.security.isSecurityExposed
import com.tangem.tap.features.root.RootDetectedWarningComponent
import com.tangem.tap.features.root.RootWarningContinuation
import com.tangem.tap.routing.configurator.AppRouterConfig
import com.tangem.utils.coroutines.runSuspendCatching
import com.tangem.utils.logging.TangemLogger
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import kotlinx.coroutines.launch
/**
* Owns the pre-start gates shown before the regular startup navigation the force-update screen and the
* root-detected security warning as interchangeable full-screen overlays in a single [childSlot].
* [await] runs them in order and returns when the app may proceed, so the routing component stays agnostic.
*/
@Suppress("LongParameterList")
internal class AppStartupGateComponent @AssistedInject constructor(
@Assisted context: AppComponentContext,
private val getAppUpdateStateUseCase: GetAppUpdateStateUseCase,
private val forceUpdateFeatureToggles: ForceUpdateFeatureToggles,
private val forceUpdateContinuation: ForceUpdateContinuation,
private val forceUpdateComponentFactory: ForceUpdateComponent.Factory,
private val rootDetectedWarningComponentFactory: RootDetectedWarningComponent.Factory,
private val rootWarningContinuation: RootWarningContinuation,
private val settingsRepository: SettingsRepository,
private val securityInfoProvider: DeviceSecurityInfoProvider,
private val appRouterConfig: AppRouterConfig,
) : AppComponentContext by context, ComposableContentComponent {
private val slotNavigation = SlotNavigation<GateConfig>()
private val slot: Value<ChildSlot<GateConfig, ComposableContentComponent>> = childSlot(
source = slotNavigation,
serializer = null,
handleBackButton = false,
childFactory = { config, childContext ->
when (config) {
is GateConfig.ForceUpdate -> forceUpdateComponentFactory.create(
context = childByContext(childContext),
params = ForceUpdateComponent.Params(mode = config.mode),
)
GateConfig.RootWarning -> rootDetectedWarningComponentFactory.create(
context = childByContext(childContext),
params = Unit,
)
}
},
)
/** Runs the pre-start gates in order; returns when the app may proceed to normal startup. */
suspend fun await() {
awaitForceUpdate()
awaitRootWarning()
}
private suspend fun awaitForceUpdate() {
val mode = runSuspendCatching { resolveForceUpdateMode() }
.onFailure { error -> TangemLogger.e("App update check failed, proceeding with normal startup", error) }
.getOrNull()
?: return
showGate(GateConfig.ForceUpdate(mode))
forceUpdateContinuation.awaitDismiss()
slotNavigation.dismiss()
}
private suspend fun awaitRootWarning() {
if (settingsRepository.isRootDetectedWarningShown() || !securityInfoProvider.isSecurityExposed()) return
showGate(GateConfig.RootWarning)
rootWarningContinuation.awaitDismiss()
settingsRepository.setRootDetectedWarningShown(true)
slotNavigation.dismiss()
}
private fun showGate(config: GateConfig) {
// The gate overlay is drawn on top of the splash, so mark navigation initialized to dismiss the splash.
appRouterConfig.initializedState.value = true
slotNavigation.activate(config)
}
private suspend fun resolveForceUpdateMode(): ForceUpdateComponent.Mode? {
if (!forceUpdateFeatureToggles.isForceUpdateEnabled) return null
val mode = getAppUpdateStateUseCase.getCached().toForceUpdateModeOrNull()
// The force-update screen re-checks on open, so a one-shot refresh is only needed when no screen is shown.
if (mode == null) {
componentScope.launch { getAppUpdateStateUseCase.refresh() }
}
return mode
}
@Composable
override fun Content(modifier: Modifier) {
val child by slot.subscribeAsState()
child.child?.instance?.Content(modifier)
}
private fun AppUpdateState.toForceUpdateModeOrNull(): ForceUpdateComponent.Mode? = when (this) {
AppUpdateState.ForceUpdate -> ForceUpdateComponent.Mode.Force
AppUpdateState.Brick -> ForceUpdateComponent.Mode.Brick
AppUpdateState.OsTooOld -> ForceUpdateComponent.Mode.OsTooOld
AppUpdateState.OptionalUpdate -> ForceUpdateComponent.Mode.Optional
AppUpdateState.NoUpdate -> null
}
private sealed interface GateConfig {
data class ForceUpdate(val mode: ForceUpdateComponent.Mode) : GateConfig
data object RootWarning : GateConfig
}
@AssistedFactory
interface Factory {
fun create(context: AppComponentContext): AppStartupGateComponent
}
}

View file

@ -43,6 +43,7 @@ import com.tangem.features.tangempay.components.TangemPayHotWalletOnboardingComp
import com.tangem.features.tangempay.components.TangemPayOnboardingComponent
import com.tangem.features.tangempay.components.TangemPayOnboardingComponent.Params.*
import com.tangem.features.tokendetails.TokenDetailsComponent
import com.tangem.features.virtualaccount.onboarding.component.VirtualAccountOnboardingComponent
import com.tangem.features.wallet.WalletEntryComponent
import com.tangem.features.walletconnect.components.WalletConnectEntryComponent
import com.tangem.features.yield.supply.api.YieldSupplyEntryComponent
@ -71,7 +72,6 @@ internal class ChildFactory @Inject constructor(
private val onrampSuccessComponentFactory: OnrampSuccessComponent.Factory,
private val buyCryptoComponentFactory: BuyCryptoComponent.Factory,
private val sellCryptoComponentFactory: SellCryptoComponent.Factory,
private val swapSelectTokensComponentFactory: SwapSelectTokensComponent.Factory,
private val onboardingEntryComponentFactory: OnboardingEntryComponent.Factory,
private val newWelcomeComponentFactory: NewWelcomeComponent.Factory,
private val storiesComponentFactory: StoriesComponent.Factory,
@ -114,6 +114,7 @@ internal class ChildFactory @Inject constructor(
private val tangemPayDetailsContainerComponentFactory: TangemPayDetailsContainerComponent.Factory,
private val tangemPayOnboardingComponentFactory: TangemPayOnboardingComponent.Factory,
private val tangemPayWalletOnboardingComponentFactory: TangemPayHotWalletOnboardingComponent.Factory,
private val virtualAccountOnboardingComponentFactory: VirtualAccountOnboardingComponent.Factory,
private val kycComponentFactory: KycComponent.Factory,
private val surveyComponentFactory: SurveyComponent.Factory,
private val yieldSupplyEntryComponentFactory: YieldSupplyEntryComponent.Factory,
@ -253,13 +254,6 @@ internal class ChildFactory @Inject constructor(
componentFactory = sellCryptoComponentFactory,
)
}
is AppRoute.SwapCrypto -> {
createComponentChild(
context = context,
params = SwapSelectTokensComponent.Params(userWalletId = route.userWalletId),
componentFactory = swapSelectTokensComponentFactory,
)
}
is AppRoute.Onboarding -> {
createComponentChild(
context = context,
@ -381,6 +375,7 @@ internal class ChildFactory @Inject constructor(
is AppRoute.QrScanning.Source.Send -> SourceType.SEND
is AppRoute.QrScanning.Source.WalletConnect -> SourceType.WALLET_CONNECT
is AppRoute.QrScanning.Source.MainScreen -> SourceType.MAIN_SCREEN
is AppRoute.QrScanning.Source.AddressBook -> SourceType.ADDRESS_BOOK
}
createComponentChild(
context = context,
@ -496,10 +491,14 @@ internal class ChildFactory @Inject constructor(
componentFactory = feedEntryComponentFactory,
)
}
is AppRoute.Usedesk -> { // TODO [REDACTED_TASK_KEY] pass params
is AppRoute.Usedesk -> {
createComponentChild(
context = context,
params = UsedeskComponent.Params(),
params = UsedeskComponent.Params(
userWalletId = route.walletMetaInfo.userWalletId?.stringValue,
source = route.source,
prefilledMessage = route.prefilledMessage,
),
componentFactory = usedeskComponentFactory,
)
}
@ -692,6 +691,7 @@ internal class ChildFactory @Inject constructor(
)
is AppRoute.TangemPayOnboarding.Mode.FromBannerInSettings -> FromBannerInSettings
is AppRoute.TangemPayOnboarding.Mode.FromBannerOnMain -> FromBannerOnMain
is AppRoute.TangemPayOnboarding.Mode.MobileOnboardingDeeplink -> MobileOnboardingDeeplink
},
componentFactory = tangemPayOnboardingComponentFactory,
)
@ -703,6 +703,23 @@ internal class ChildFactory @Inject constructor(
componentFactory = tangemPayWalletOnboardingComponentFactory,
)
}
is AppRoute.VirtualAccountOnboarding -> {
createComponentChild(
context = context,
params = when (val mode = route.mode) {
is AppRoute.VirtualAccountOnboarding.Mode.Deeplink ->
VirtualAccountOnboardingComponent.Params.Deeplink(
userWalletId = mode.userWalletId,
deeplink = mode.deeplink,
)
is AppRoute.VirtualAccountOnboarding.Mode.FromMain ->
VirtualAccountOnboardingComponent.Params.FromMain(userWalletId = mode.userWalletId)
is AppRoute.VirtualAccountOnboarding.Mode.FromDetailsScreen ->
VirtualAccountOnboardingComponent.Params.FromDetailsScreen(userWalletId = mode.userWalletId)
},
componentFactory = virtualAccountOnboardingComponentFactory,
)
}
is AppRoute.Kyc -> {
createComponentChild(
context = context,
@ -760,7 +777,7 @@ internal class ChildFactory @Inject constructor(
is AppRoute.AddressBook -> {
createComponentChild(
context = context,
params = AddressBookComponent.Params(route.predefinedAddress),
params = AddressBookComponent.Params(addressBookOpenMode = route.addressBookOpenMode),
componentFactory = addressBookComponentFactory,
)
}

View file

@ -17,10 +17,12 @@ import com.tangem.features.onramp.deeplink.BuyDeepLinkHandler
import com.tangem.features.onramp.deeplink.OnrampDeepLinkHandler
import com.tangem.features.onramp.deeplink.SellDeepLinkHandler
import com.tangem.features.onramp.deeplink.SwapDeepLinkHandler
import com.tangem.features.promobanners.api.deeplink.CampaignsDeepLinkHandler
import com.tangem.features.send.api.deeplink.SellRedirectDeepLinkHandler
import com.tangem.features.staking.api.deeplink.StakingDeepLinkHandler
import com.tangem.features.survey.deeplink.SurveyDeepLinkHandler
import com.tangem.features.tangempay.deeplink.OnboardVisaDeepLinkHandler
import com.tangem.features.virtualaccount.onboarding.deeplink.OnboardVirtualAccountsDeepLinkHandler
import com.tangem.features.tangempay.deeplink.TangemPayMainDeepLinkHandler
import com.tangem.features.tokendetails.deeplink.TokenDetailsDeepLinkHandler
import com.tangem.features.wallet.deeplink.PromoDeeplinkHandler
@ -57,6 +59,7 @@ internal class DeepLinkFactory @Inject constructor(
private val swapDeepLink: SwapDeepLinkHandler.Factory,
private val promoDeepLink: PromoDeeplinkHandler.Factory,
private val onboardVisaDeepLink: OnboardVisaDeepLinkHandler.Factory,
private val onboardVirtualAccountsDeepLink: OnboardVirtualAccountsDeepLinkHandler.Factory,
private val marketsTokenExchangesDeepLink: MarketsTokenExchangesDeepLinkHandler.Factory,
private val tangemPayMainDeepLink: TangemPayMainDeepLinkHandler.Factory,
private val newsDetailsDeepLink: NewsDetailsDeepLinkHandler.Factory,
@ -64,6 +67,7 @@ internal class DeepLinkFactory @Inject constructor(
private val earnDeepLink: EarnDeepLinkHandler.Factory,
private val yieldDeepLink: YieldDeepLinkHandler.Factory,
private val surveyDeepLink: SurveyDeepLinkHandler.Factory,
private val promoCampaignsDeepLink: CampaignsDeepLinkHandler.Factory,
) {
private val permittedAppRoute = MutableStateFlow(false)
@ -173,11 +177,13 @@ internal class DeepLinkFactory @Inject constructor(
DeepLinkRoute.WalletConnect.host -> walletConnectDeepLink.create(deeplinkUri)
DeepLinkRoute.Promo.host -> promoDeepLink.create(coroutineScope, queryParams)
DeepLinkRoute.OnboardVisa.host -> onboardVisaDeepLink.create(deeplinkUri)
DeepLinkRoute.OnboardVirtualAccounts.host -> onboardVirtualAccountsDeepLink.create(deeplinkUri)
DeepLinkRoute.News.host -> newsDeepLink.create(queryParams)
DeepLinkRoute.Earn.host -> earnDeepLink.create(queryParams)
DeepLinkRoute.Yield.host -> yieldDeepLink.create(coroutineScope, queryParams)
DeepLinkRoute.PayAppMain.host -> tangemPayMainDeepLink.create(coroutineScope, queryParams)
DeepLinkRoute.Survey.host -> surveyDeepLink.create(queryParams)
DeepLinkRoute.Campaigns.host -> promoCampaignsDeepLink.create(queryParams)
else -> {
TangemLogger.i(
"""