Updated on 2026-08-14

This commit is contained in:
Tangem 2025-07-08 15:42:29 +05:00
parent 48180a9b38
commit 76164ac486
12 changed files with 249 additions and 50 deletions

View file

@ -348,11 +348,10 @@ class DeepLinkFactoryTest {
fun `getParams filters malicious parameters`() = runTest {
every { mockedUri.scheme } returns "tangem"
every { mockedUri.host } returns "onramp"
every { mockedUri.query } returns "safe=ok&malicious=%3Cscript%3E&quote=O%27Brien"
every { mockedUri.queryParameterNames } returns setOf("safe", "malicious", "quote")
every { mockedUri.query } returns "safe=ok&malicious=%3Cscript%3E"
every { mockedUri.queryParameterNames } returns setOf("safe", "malicious")
every { mockedUri.getQueryParameter("safe") } returns "ok"
every { mockedUri.getQueryParameter("malicious") } returns "<script>"
every { mockedUri.getQueryParameter("quote") } returns "O'Brien"
deepLinkFactory.checkRoutingReadiness(AppRoute.Wallet)
deepLinkFactory.handleDeeplink(mockedUri, testScope, isFromOnNewIntent)
@ -389,13 +388,6 @@ class DeepLinkFactoryTest {
advanceUntilIdle()
verify { onrampDeepLinkFactory.create(eq(testScope), eq(emptyMap())) }
every { mockedUri.query } returns "unsafe=O'Brien"
every { mockedUri.queryParameterNames } returns setOf("unsafe")
every { mockedUri.getQueryParameter("unsafe") } returns "O'Brien"
deepLinkFactory.handleDeeplink(mockedUri, testScope, isFromOnNewIntent)
advanceUntilIdle()
verify { onrampDeepLinkFactory.create(eq(testScope), eq(emptyMap())) }
every { mockedUri.query } returns "unsafe=test;"
every { mockedUri.queryParameterNames } returns setOf("unsafe")
every { mockedUri.getQueryParameter("unsafe") } returns "test;"

View file

@ -1,6 +1,6 @@
package com.tangem.utils.extensions
private const val DEEPLINK_VALIDATION_REGEX = "['\";<>()+\\\\]"
private const val DEEPLINK_VALIDATION_REGEX = "[\";<>()+\\\\]"
/**
* Check for malicious symbol in uri part

View file

@ -96,6 +96,7 @@ dependencies {
/** Feature Apis */
implementation(projects.features.tokendetails.api)
implementation(projects.features.wallet.api)
implementation(projects.features.staking.api)
implementation(projects.features.markets.api)
implementation(projects.features.onramp.api)

View file

@ -1,20 +1,29 @@
package com.tangem.feature.tokendetails.deeplink
import arrow.core.getOrElse
import com.tangem.common.routing.AppRoute
import com.tangem.common.routing.AppRouter
import com.tangem.core.analytics.api.AnalyticsEventHandler
import com.tangem.core.deeplink.DeeplinkConst.DERIVATION_PATH_KEY
import com.tangem.core.deeplink.DeeplinkConst.NETWORK_ID_KEY
import com.tangem.core.deeplink.DeeplinkConst.TOKEN_ID_KEY
import com.tangem.core.deeplink.DeeplinkConst.TRANSACTION_ID_KEY
import com.tangem.core.deeplink.DeeplinkConst.TYPE_KEY
import com.tangem.core.deeplink.DeeplinkConst.WALLET_ID_KEY
import com.tangem.domain.common.util.cardTypesResolver
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.network.Network
import com.tangem.domain.notifications.models.NotificationType
import com.tangem.domain.tokens.FetchCurrencyStatusUseCase
import com.tangem.domain.tokens.GetCryptoCurrenciesUseCase
import com.tangem.domain.tokens.*
import com.tangem.domain.tokens.wallet.WalletBalanceFetcher
import com.tangem.domain.wallets.models.UserWallet
import com.tangem.domain.wallets.models.UserWalletId
import com.tangem.domain.wallets.usecase.GetSelectedWalletSyncUseCase
import com.tangem.domain.wallets.models.isLocked
import com.tangem.domain.wallets.models.isMultiCurrency
import com.tangem.domain.wallets.usecase.GetUserWalletUseCase
import com.tangem.domain.wallets.usecase.SelectWalletUseCase
import com.tangem.features.pushnotifications.api.analytics.PushNotificationAnalyticEvents
import com.tangem.features.tokendetails.deeplink.TokenDetailsDeepLinkHandler
import com.tangem.features.wallet.deeplink.WalletDeepLinkActionTrigger
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
@ -28,10 +37,17 @@ internal class DefaultTokenDetailsDeepLinkHandler @AssistedInject constructor(
@Assisted private val queryParams: Map<String, String>,
@Assisted private val isFromOnNewIntent: Boolean,
private val appRouter: AppRouter,
private val getSelectedWalletSyncUseCase: GetSelectedWalletSyncUseCase,
private val selectWalletUseCase: SelectWalletUseCase,
private val getCryptoCurrenciesUseCase: GetCryptoCurrenciesUseCase,
private val getCryptoCurrencyUseCase: GetCryptoCurrencyUseCase,
private val fetchCurrencyStatusUseCase: FetchCurrencyStatusUseCase,
private val tokenDetailsDeepLinkActionTrigger: TokenDetailsDeepLinkActionTrigger,
private val walletDeepLinkActionTrigger: WalletDeepLinkActionTrigger,
private val analyticsEventHandler: AnalyticsEventHandler,
private val getUserWalletUseCase: GetUserWalletUseCase,
private val tokensFeatureToggles: TokensFeatureToggles,
private val walletBalanceFetcher: WalletBalanceFetcher,
private val fetchCardTokenListUseCase: FetchCardTokenListUseCase,
) : TokenDetailsDeepLinkHandler {
init {
@ -42,27 +58,23 @@ internal class DefaultTokenDetailsDeepLinkHandler @AssistedInject constructor(
val networkId = queryParams[NETWORK_ID_KEY]
val tokenId = queryParams[TOKEN_ID_KEY]
val type = NotificationType.getType(queryParams[TYPE_KEY])
// It is okay here, we are navigating from outside, and there is no other way to getting UserWallet
val selectedUserWalletId = getSelectedWalletSyncUseCase().getOrNull()?.walletId
val walletId = queryParams[WALLET_ID_KEY]?.let(::UserWalletId) ?: selectedUserWalletId
// If selected user wallet is different than from deeplink - ignore deeplink
// If selected user wallet is null - ignore deeplink
if (walletId != selectedUserWalletId || selectedUserWalletId == null) {
Timber.e("Error on getting user wallet")
return
}
val transactionId = queryParams[TRANSACTION_ID_KEY]
val walletId = queryParams[WALLET_ID_KEY]
scope.launch {
val cryptoCurrency = getCryptoCurrenciesUseCase(userWalletId = selectedUserWalletId).getOrElse {
Timber.e("Error on getting crypto currency list")
val userWalletId = walletId?.let(::UserWalletId)
val userWallet = userWalletId?.let { getUserWalletUseCase(userWalletId) }?.getOrNull()
// If wallet to select is null or locked, ignore deeplink
if (userWallet == null || userWallet.isLocked) {
Timber.e("Error on getting user wallet")
return@launch
}.firstOrNull {
val isNetwork = it.network.backendId.equals(networkId, ignoreCase = true)
val isCurrency = it.id.rawCurrencyId?.value?.equals(tokenId, ignoreCase = true) == true
isNetwork && isCurrency
}
if (selectWalletUseCase(userWalletId).getOrNull() == null) {
Timber.e("Error on selecting user wallet")
return@launch
}
val cryptoCurrency = getCryptoCurrency(userWallet = userWallet, networkId = networkId, tokenId = tokenId)
if (cryptoCurrency == null) {
Timber.e(
@ -77,23 +89,78 @@ internal class DefaultTokenDetailsDeepLinkHandler @AssistedInject constructor(
analyticsEventHandler.send(PushNotificationAnalyticEvents.NotificationOpened(type.name))
appRouter.push(
AppRoute.CurrencyDetails(
userWalletId = selectedUserWalletId,
currency = cryptoCurrency,
),
)
if (isFromOnNewIntent) {
fetchCurrencyStatusUseCase.invoke(
userWalletId = selectedUserWalletId,
id = cryptoCurrency.id,
refresh = true,
if (userWallet.isMultiCurrency) {
appRouter.push(
route = AppRoute.CurrencyDetails(
userWalletId = userWallet.walletId,
currency = cryptoCurrency,
),
onComplete = { walletDeepLinkActionTrigger.selectWallet(userWallet.walletId) },
)
} else {
walletDeepLinkActionTrigger.selectWallet(userWallet.walletId)
}
if (transactionId != null) {
when (type) {
NotificationType.SwapStatus,
NotificationType.OnrampStatus,
-> tokenDetailsDeepLinkActionTrigger.trigger(transactionId)
NotificationType.Promo,
NotificationType.IncomeTransactions,
NotificationType.Unknown,
-> Unit
}
}
if (isFromOnNewIntent) fetchCurrency(userWallet, cryptoCurrency)
}
}
private suspend fun fetchCurrency(userWallet: UserWallet, cryptoCurrency: CryptoCurrency) {
val isMultiCurrency = userWallet.isMultiCurrency
// single-currency wallet with token (NODL)
val isSingleWalletWithToken = userWallet is UserWallet.Cold &&
userWallet.scanResponse.cardTypesResolver.isSingleWalletWithToken()
when {
isMultiCurrency -> fetchCurrencyStatusUseCase.invoke(
userWalletId = userWallet.walletId,
id = cryptoCurrency.id,
refresh = true,
)
!isMultiCurrency && tokensFeatureToggles.isWalletBalanceFetcherEnabled ->
walletBalanceFetcher(params = WalletBalanceFetcher.Params(userWalletId = userWallet.walletId))
// remove below after delete tokensFeatureToggles.isWalletBalanceFetcherEnabled
!isMultiCurrency && userWallet is UserWallet.Cold && isSingleWalletWithToken ->
fetchCardTokenListUseCase.invoke(
userWalletId = userWallet.walletId,
refresh = true,
)
!isMultiCurrency -> fetchCurrencyStatusUseCase.invoke(
userWalletId = userWallet.walletId,
refresh = true,
)
}
}
private suspend fun getCryptoCurrency(userWallet: UserWallet, networkId: String?, tokenId: String?) =
if (userWallet.isMultiCurrency) {
val derivationPath = queryParams[DERIVATION_PATH_KEY]
getCryptoCurrenciesUseCase(userWalletId = userWallet.walletId)
.getOrNull()
?.firstOrNull {
val isNetwork = it.network.backendId.equals(networkId, ignoreCase = true)
val isCurrency = it.id.rawCurrencyId?.value?.equals(tokenId, ignoreCase = true) == true
val isDefaultDerivation = it.network.derivationPath is Network.DerivationPath.Card
val isCustomDerivation = derivationPath?.equals(it.network.derivationPath.value) == true
val isCorrectDerivation = isDefaultDerivation || isCustomDerivation
isNetwork && isCurrency && isCorrectDerivation
}
} else {
getCryptoCurrencyUseCase(userWalletId = userWallet.walletId).getOrNull()
}
@AssistedFactory
interface Factory : TokenDetailsDeepLinkHandler.Factory {
override fun create(

View file

@ -0,0 +1,27 @@
package com.tangem.feature.tokendetails.deeplink
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.SharedFlow
import javax.inject.Inject
import javax.inject.Singleton
interface TokenDetailsDeepLinkActionTrigger {
suspend fun trigger(txId: String)
}
interface TokenDetailsDeepLinkActionListener {
val tokenDetailsActionFlow: SharedFlow<String>
}
@Singleton
internal class DefaultTokenDetailsDeepLinkActionTrigger @Inject constructor() :
TokenDetailsDeepLinkActionTrigger,
TokenDetailsDeepLinkActionListener {
override val tokenDetailsActionFlow: SharedFlow<String>
field = MutableSharedFlow<String>()
override suspend fun trigger(txId: String) {
tokenDetailsActionFlow.emit(txId)
}
}

View file

@ -1,6 +1,9 @@
package com.tangem.feature.tokendetails.deeplink.di
import com.tangem.feature.tokendetails.deeplink.DefaultTokenDetailsDeepLinkActionTrigger
import com.tangem.feature.tokendetails.deeplink.DefaultTokenDetailsDeepLinkHandler
import com.tangem.feature.tokendetails.deeplink.TokenDetailsDeepLinkActionListener
import com.tangem.feature.tokendetails.deeplink.TokenDetailsDeepLinkActionTrigger
import com.tangem.features.tokendetails.deeplink.TokenDetailsDeepLinkHandler
import dagger.Binds
import dagger.Module
@ -17,4 +20,16 @@ internal interface TokenDetailsDeepLinkModule {
fun bindWalletDeepLinkHandlerFactory(
impl: DefaultTokenDetailsDeepLinkHandler.Factory,
): TokenDetailsDeepLinkHandler.Factory
@Binds
@Singleton
fun bindTokenDetailsDeepLinkActionTrigger(
impl: DefaultTokenDetailsDeepLinkActionTrigger,
): TokenDetailsDeepLinkActionTrigger
@Binds
@Singleton
fun bindTokenDetailsDeepLinkActionListener(
impl: DefaultTokenDetailsDeepLinkActionTrigger,
): TokenDetailsDeepLinkActionListener
}

View file

@ -70,6 +70,7 @@ import com.tangem.domain.wallets.models.UserWalletId
import com.tangem.domain.wallets.models.requireColdWallet
import com.tangem.domain.wallets.usecase.GetExploreUrlUseCase
import com.tangem.domain.wallets.usecase.GetUserWalletUseCase
import com.tangem.feature.tokendetails.deeplink.TokenDetailsDeepLinkActionListener
import com.tangem.feature.tokendetails.presentation.router.InnerTokenDetailsRouter
import com.tangem.feature.tokendetails.presentation.tokendetails.analytics.TokenDetailsCurrencyStatusAnalyticsSender
import com.tangem.feature.tokendetails.presentation.tokendetails.analytics.TokenDetailsNotificationsAnalyticsSender
@ -134,6 +135,7 @@ internal class TokenDetailsModel @Inject constructor(
getStakingIntegrationIdUseCase: GetStakingIntegrationIdUseCase,
private val appRouter: AppRouter,
private val router: InnerTokenDetailsRouter,
private val tokenDetailsDeepLinkActionListener: TokenDetailsDeepLinkActionListener,
) : Model(), TokenDetailsClickIntents {
private val params = paramsContainer.require<TokenDetailsComponent.Params>()
@ -153,6 +155,9 @@ internal class TokenDetailsModel @Inject constructor(
private var cryptoCurrencyStatus: CryptoCurrencyStatus? = null
private var expressTxStatusTaskScheduler = SingleTaskScheduler<PersistentList<ExpressTransactionStateUM>>()
/** Transaction id to check for status */
private val waitForFirstExpressStatusEmmit = MutableStateFlow(false)
private val stateFactory = TokenDetailsStateFactory(
currentStateProvider = Provider { uiState.value },
appCurrencyProvider = Provider(selectedAppCurrencyFlow::value),
@ -199,6 +204,7 @@ internal class TokenDetailsModel @Inject constructor(
initButtons()
updateContent()
handleBalanceHiding()
checkForActionUpdates()
}
fun onBuyCurrencyDeepLink() {
@ -335,6 +341,7 @@ internal class TokenDetailsModel @Inject constructor(
expressStatusFactory
.getExpressStatuses()
.distinctUntilChanged()
.onEach { waitForFirstExpressStatusEmmit.value = true }
.onEach { expressTxs ->
internalUiState.value = expressStatusFactory.getStateWithUpdatedExpressTxs(
expressTxs,
@ -801,7 +808,8 @@ internal class TokenDetailsModel @Inject constructor(
}
override fun onExpressTransactionClick(txId: String) {
val expressTxState = internalUiState.value.expressTxsToDisplay.first { it.info.txId == txId }
val expressTxState = internalUiState.value.expressTxsToDisplay.firstOrNull { it.info.txId == txId }
?: return
internalUiState.value = expressStatusFactory.getStateWithExpressStatusBottomSheet(expressTxState)
}
@ -1042,6 +1050,15 @@ internal class TokenDetailsModel @Inject constructor(
}
}
private fun checkForActionUpdates() {
combine(
tokenDetailsDeepLinkActionListener.tokenDetailsActionFlow,
waitForFirstExpressStatusEmmit.filter { it },
) { transactionId, _ -> transactionId }
.onEach(::onExpressTransactionClick)
.launchIn(modelScope)
}
private companion object {
const val EXPRESS_STATUS_UPDATE_DELAY = 10_000L
}

View file

@ -0,0 +1,12 @@
package com.tangem.features.wallet.deeplink
import com.tangem.domain.wallets.models.UserWalletId
import kotlinx.coroutines.flow.Flow
interface WalletDeepLinkActionTrigger {
fun selectWallet(userWalletId: UserWalletId)
}
interface WalletDeepLinkActionListener {
val selectWalletFlow: Flow<UserWalletId>
}

View file

@ -45,9 +45,11 @@ import com.tangem.feature.wallet.presentation.wallet.utils.ScreenLifecycleProvid
import com.tangem.features.biometry.AskBiometryComponent
import com.tangem.features.pushnotifications.api.utils.PUSH_PERMISSION
import com.tangem.features.pushnotifications.api.utils.getPushPermissionOrNull
import com.tangem.features.wallet.deeplink.WalletDeepLinkActionListener
import com.tangem.utils.Provider
import com.tangem.utils.coroutines.*
import kotlinx.coroutines.*
import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.flow.*
import timber.log.Timber
import javax.inject.Inject
@ -86,6 +88,7 @@ internal class WalletModel @Inject constructor(
private val appRouter: AppRouter,
private val routingFeatureToggle: RoutingFeatureToggle,
private val observeAndClearNFTCacheIfNeedUseCase: ObserveAndClearNFTCacheIfNeedUseCase,
private val walletDeepLinkActionListener: WalletDeepLinkActionListener,
val screenLifecycleProvider: ScreenLifecycleProvider,
val innerWalletRouter: InnerWalletRouter,
) : Model() {
@ -167,7 +170,16 @@ internal class WalletModel @Inject constructor(
return innerWalletRouter.isWalletLastScreen() && shouldShowSaveWalletScreenUseCase() && canUseBiometryUseCase()
}
private fun subscribeToUserWalletsUpdates() {
private fun subscribeToUserWalletsUpdates() = channelFlow<Unit> {
val firstWalletsUseCaseEmit = MutableStateFlow(false)
suspend fun waitFirstWalletsUseCaseEmit() = firstWalletsUseCaseEmit.filter { it }.first()
// deepLinkActionFlow must wait for fist getWalletsUseCase() emit to correct handle Action.InitializeWallets
walletDeepLinkActionListener.selectWalletFlow
.onEach { waitFirstWalletsUseCaseEmit() }
.onEach(::selectWalletById)
.launchIn(this)
getWalletsUseCase()
.conflate()
.distinctUntilChanged()
@ -178,10 +190,13 @@ internal class WalletModel @Inject constructor(
)
}
.onEach(::updateWallets)
.flowOn(dispatchers.default)
.launchIn(modelScope)
.saveIn(walletsUpdateJobHolder)
.onEach { firstWalletsUseCaseEmit.update { true } }
.launchIn(this)
awaitClose()
}
.flowOn(dispatchers.default)
.launchIn(modelScope)
.saveIn(walletsUpdateJobHolder)
private fun subscribeOnBalanceHiding() {
getBalanceHidingSettingsUseCase()
@ -240,6 +255,19 @@ internal class WalletModel @Inject constructor(
}
}
private fun selectWalletById(selectedWalletId: UserWalletId) {
val currentWalletId = stateHolder.getSelectedWalletId()
if (currentWalletId == selectedWalletId) return
val currentIndex = stateHolder.getWalletIndexByWalletId(userWalletId = currentWalletId) ?: return
val newIndex = stateHolder.getWalletIndexByWalletId(userWalletId = selectedWalletId) ?: return
scrollToWallet(prevIndex = currentIndex, newIndex = newIndex) {
stateHolder.update { it.copy(selectedWalletIndex = newIndex) }
}
}
private fun addReferralDeepLink(userWallet: UserWallet) {
deepLinksRegistry.register(
ReferralDeepLink(

View file

@ -0,0 +1,24 @@
package com.tangem.feature.wallet.deeplink
import com.tangem.domain.wallets.models.UserWalletId
import com.tangem.features.wallet.deeplink.WalletDeepLinkActionListener
import com.tangem.features.wallet.deeplink.WalletDeepLinkActionTrigger
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.receiveAsFlow
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
internal class DefaultWalletDeepLinkActionTrigger @Inject constructor() :
WalletDeepLinkActionTrigger,
WalletDeepLinkActionListener {
private val _selectWalletFlow = Channel<UserWalletId>()
override val selectWalletFlow: Flow<UserWalletId>
get() = _selectWalletFlow.receiveAsFlow()
override fun selectWallet(userWalletId: UserWalletId) {
_selectWalletFlow.trySend(userWalletId)
}
}

View file

@ -1,6 +1,9 @@
package com.tangem.feature.wallet.deeplink.di
import com.tangem.feature.wallet.deeplink.DefaultWalletDeepLinkActionTrigger
import com.tangem.feature.wallet.deeplink.DefaultWalletDeepLinkHandler
import com.tangem.features.wallet.deeplink.WalletDeepLinkActionListener
import com.tangem.features.wallet.deeplink.WalletDeepLinkActionTrigger
import com.tangem.features.wallet.deeplink.WalletDeepLinkHandler
import dagger.Binds
import dagger.Module
@ -15,4 +18,12 @@ internal interface WalletDeepLinkModule {
@Binds
@Singleton
fun bindWalletDeepLinkHandlerFactory(impl: DefaultWalletDeepLinkHandler.Factory): WalletDeepLinkHandler.Factory
@Binds
@Singleton
fun bindWalletDeepLinkActionTrigger(impl: DefaultWalletDeepLinkActionTrigger): WalletDeepLinkActionTrigger
@Binds
@Singleton
fun bindWalletDeepLinkActionListener(impl: DefaultWalletDeepLinkActionTrigger): WalletDeepLinkActionListener
}

View file

@ -10,6 +10,7 @@ import com.tangem.feature.wallet.presentation.wallet.state.model.WalletTopBarCon
import com.tangem.feature.wallet.presentation.wallet.state.transformers.CloseBottomSheetTransformer
import com.tangem.feature.wallet.presentation.wallet.state.transformers.OpenBottomSheetTransformer
import com.tangem.feature.wallet.presentation.wallet.state.transformers.WalletScreenStateTransformer
import com.tangem.utils.extensions.indexOfFirstOrNull
import kotlinx.collections.immutable.persistentListOf
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
@ -68,6 +69,10 @@ internal class WalletStateController @Inject constructor() {
return with(value) { wallets[selectedWalletIndex].walletCardState.id }
}
fun getWalletIndexByWalletId(userWalletId: UserWalletId): Int? {
return with(value) { wallets.indexOfFirstOrNull { it.walletCardState.id == userWalletId } }
}
fun showBottomSheet(
content: TangemBottomSheetConfigContent,
userWalletId: UserWalletId = getSelectedWalletId(),