Updated on 2026-08-14

This commit is contained in:
Tangem 2025-05-21 16:24:36 +05:00
commit fdfb07ee33
17 changed files with 263 additions and 20 deletions

View file

@ -154,6 +154,28 @@
android:scheme="tangem" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data
android:host="main"
android:scheme="tangem" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data
android:host="token"
android:scheme="tangem" />
</intent-filter>
</activity>
<!-- Disable android.startup completely. Used for Worker according doc -->

View file

@ -8,6 +8,8 @@ import com.tangem.feature.referral.api.deeplink.ReferralDeepLinkHandler
import com.tangem.features.onramp.deeplink.BuyDeepLinkHandler
import com.tangem.features.onramp.deeplink.OnrampDeepLinkHandler
import com.tangem.features.send.v2.api.deeplink.SellDeepLinkHandler
import com.tangem.features.tokendetails.deeplink.TokenDetailsDeepLinkHandler
import com.tangem.features.wallet.deeplink.WalletDeepLinkHandler
import com.tangem.features.walletconnect.components.deeplink.WalletConnectDeepLinkHandler
import com.tangem.utils.coroutines.JobHolder
import com.tangem.utils.coroutines.saveIn
@ -20,6 +22,7 @@ import kotlinx.coroutines.flow.transformLatest
import timber.log.Timber
import javax.inject.Inject
@Suppress("LongParameterList")
@ActivityScoped
internal class DeepLinkFactory @Inject constructor(
private val onrampDeepLink: OnrampDeepLinkHandler.Factory,
@ -27,6 +30,8 @@ internal class DeepLinkFactory @Inject constructor(
private val buyDeepLink: BuyDeepLinkHandler.Factory,
private val referralDeepLink: ReferralDeepLinkHandler.Factory,
private val walletConnectDeepLink: WalletConnectDeepLinkHandler.Factory,
private val walletDeepLink: WalletDeepLinkHandler.Factory,
private val tokenDetailsDeepLink: TokenDetailsDeepLinkHandler.Factory,
) {
private val permittedAppRoute = MutableStateFlow(false)
@ -88,25 +93,27 @@ internal class DeepLinkFactory @Inject constructor(
}
private fun handleTangemDeepLinks(deeplinkUri: Uri, coroutineScope: CoroutineScope) {
val params = getParams(deeplinkUri)
val queryParams = getQueryParams(deeplinkUri)
when (deeplinkUri.host) {
DeepLinkRoute.Onramp.host -> onrampDeepLink.create(coroutineScope, params)
DeepLinkRoute.Sell.host -> sellDeepLink.create(coroutineScope, params)
DeepLinkRoute.Onramp.host -> onrampDeepLink.create(coroutineScope, queryParams)
DeepLinkRoute.Sell.host -> sellDeepLink.create(coroutineScope, queryParams)
DeepLinkRoute.Buy.host -> buyDeepLink.create(coroutineScope)
DeepLinkRoute.Referral.host -> referralDeepLink.create()
DeepLinkRoute.Wallet.host -> walletDeepLink.create()
DeepLinkRoute.TokenDetails.host -> tokenDetailsDeepLink.create(coroutineScope, queryParams)
else -> {
Timber.i(
"""
No match found for deep link
|- Received URI: $deeplinkUri
|- With params: $params
|- With params: $queryParams
""".trimIndent(),
)
}
}
}
private fun getParams(uri: Uri): Map<String, String> {
private fun getQueryParams(uri: Uri): Map<String, String> {
val params = mutableMapOf<String, String>()
uri.queryParameterNames.forEach { paramName ->

View file

@ -6,6 +6,8 @@ import com.tangem.feature.referral.api.deeplink.ReferralDeepLinkHandler
import com.tangem.features.onramp.deeplink.BuyDeepLinkHandler
import com.tangem.features.onramp.deeplink.OnrampDeepLinkHandler
import com.tangem.features.send.v2.api.deeplink.SellDeepLinkHandler
import com.tangem.features.tokendetails.deeplink.TokenDetailsDeepLinkHandler
import com.tangem.features.wallet.deeplink.WalletDeepLinkHandler
import com.tangem.features.walletconnect.components.deeplink.WalletConnectDeepLinkHandler
import io.mockk.every
import io.mockk.mockk
@ -37,6 +39,12 @@ class DeepLinkFactoryTest {
private val walletConnectDeepLinkFactory = mockk<WalletConnectDeepLinkHandler.Factory>(relaxed = true) {
every { create(any()) } returns mockk()
}
private val walletDeepLinkFactory = mockk<WalletDeepLinkHandler.Factory>(relaxed = true) {
every { create() } returns mockk()
}
private val tokenDetailsDeepLinkFactory = mockk<TokenDetailsDeepLinkHandler.Factory>(relaxed = true) {
every { create(any(), any()) } returns mockk()
}
private val mockedUri = mockk<Uri>(relaxed = true)
@ -49,6 +57,8 @@ class DeepLinkFactoryTest {
buyDeepLinkFactory,
referralDeepLinkFactory,
walletConnectDeepLinkFactory,
walletDeepLinkFactory,
tokenDetailsDeepLinkFactory,
)
@OptIn(ExperimentalCoroutinesApi::class)
@ -162,6 +172,8 @@ class DeepLinkFactoryTest {
buyDeepLinkFactory.create(any())
referralDeepLinkFactory.create()
walletConnectDeepLinkFactory.create(any())
walletDeepLinkFactory.create()
tokenDetailsDeepLinkFactory.create(any(), any())
}
}
@ -186,6 +198,12 @@ class DeepLinkFactoryTest {
advanceUntilIdle()
verify { sellDeepLinkFactory.create(eq(testScope), eq(mapOf("param" to "value"))) }
// Test Token Details
every { mockedUri.host } returns "token"
deepLinkFactory.handleDeeplink(mockedUri, testScope)
advanceUntilIdle()
verify { tokenDetailsDeepLinkFactory.create(eq(testScope), eq(mapOf("param" to "value"))) }
// Reset params
every { mockedUri.queryParameterNames } returns emptySet()
every { mockedUri.getQueryParameter(any()) } returns ""
@ -201,6 +219,12 @@ class DeepLinkFactoryTest {
deepLinkFactory.handleDeeplink(mockedUri, testScope)
advanceUntilIdle()
verify { referralDeepLinkFactory.create() }
// Test Wallet
every { mockedUri.host } returns "main"
deepLinkFactory.handleDeeplink(mockedUri, testScope)
advanceUntilIdle()
verify { walletDeepLinkFactory.create() }
}
@Test
@ -219,6 +243,8 @@ class DeepLinkFactoryTest {
buyDeepLinkFactory.create(any())
referralDeepLinkFactory.create()
walletConnectDeepLinkFactory.create(any())
walletDeepLinkFactory.create()
tokenDetailsDeepLinkFactory.create(any(), any())
}
}

View file

@ -19,6 +19,14 @@ sealed class DeepLinkRoute {
data object Referral : DeepLinkRoute() {
override val host: String = "referral"
}
data object Wallet : DeepLinkRoute() {
override val host: String = "main"
}
data object TokenDetails : DeepLinkRoute() {
override val host: String = "token"
}
}
enum class DeepLinkScheme(val scheme: String) {

View file

@ -0,0 +1,13 @@
package com.tangem.domain.notifications.models
enum class NotificationType(val type: String) {
Promo("promo"),
Unknown("unknown"),
;
companion object {
fun getType(type: String?): NotificationType {
return entries.firstOrNull { it.type == type } ?: Unknown
}
}
}

View file

@ -15,6 +15,6 @@ abstract class OnrampDeepLink : DeepLink() {
interface OnrampDeepLinkHandler {
interface Factory {
fun create(coroutineScope: CoroutineScope, params: Map<String, String>): OnrampDeepLinkHandler
fun create(coroutineScope: CoroutineScope, queryParams: Map<String, String>): OnrampDeepLinkHandler
}
}

View file

@ -12,14 +12,14 @@ import timber.log.Timber
internal class DefaultOnrampDeepLinkHandler @AssistedInject constructor(
@Assisted scope: CoroutineScope,
@Assisted params: Map<String, String>,
@Assisted queryParams: Map<String, String>,
appRouter: AppRouter,
onrampSuccessScreenTrigger: OnrampSuccessScreenTrigger,
) : OnrampDeepLinkHandler {
init {
val txId = params[TX_ID_KEY]
val result = OnrampRedirectResult.getResult(params[RESULT_KEY])
val txId = queryParams[TX_ID_KEY]
val result = OnrampRedirectResult.getResult(queryParams[RESULT_KEY])
when {
!txId.isNullOrEmpty() -> {
@ -39,7 +39,7 @@ internal class DefaultOnrampDeepLinkHandler @AssistedInject constructor(
Timber.e(
"""
Invalid parameters for ONRAMP deeplink
|- Params: $params
|- Params: $queryParams
""".trimIndent(),
)
}
@ -48,7 +48,10 @@ internal class DefaultOnrampDeepLinkHandler @AssistedInject constructor(
@AssistedFactory
interface Factory : OnrampDeepLinkHandler.Factory {
override fun create(coroutineScope: CoroutineScope, params: Map<String, String>): DefaultOnrampDeepLinkHandler
override fun create(
coroutineScope: CoroutineScope,
queryParams: Map<String, String>,
): DefaultOnrampDeepLinkHandler
}
private companion object {

View file

@ -5,6 +5,6 @@ import kotlinx.coroutines.CoroutineScope
interface SellDeepLinkHandler {
interface Factory {
fun create(coroutineScope: CoroutineScope, params: Map<String, String>): SellDeepLinkHandler
fun create(coroutineScope: CoroutineScope, queryParams: Map<String, String>): SellDeepLinkHandler
}
}

View file

@ -16,18 +16,18 @@ import timber.log.Timber
@Suppress("ComplexCondition")
internal class DefaultSellDeepLinkHandler @AssistedInject constructor(
@Assisted scope: CoroutineScope,
@Assisted params: Map<String, String>,
@Assisted queryParams: Map<String, String>,
appRouter: AppRouter,
getSelectedWalletSyncUseCase: GetSelectedWalletSyncUseCase,
getCryptoCurrencyUseCase: GetCryptoCurrencyUseCase,
) : SellDeepLinkHandler {
init {
val currencyId = params[CURRENCY_ID_KEY]
val transactionId = params[TRANSACTION_ID_KEY]
val amount = params[AMOUNT_KEY]
val destinationAddress = params[DESTINATION_ADDRESS_KEY]
val memo = params[MEMO_KEY]
val currencyId = queryParams[CURRENCY_ID_KEY]
val transactionId = queryParams[TRANSACTION_ID_KEY]
val amount = queryParams[AMOUNT_KEY]
val destinationAddress = queryParams[DESTINATION_ADDRESS_KEY]
val memo = queryParams[MEMO_KEY]
// It is okay here, we are navigating from outside, and there is no other way to getting UserWallet
getSelectedWalletSyncUseCase()
@ -42,7 +42,7 @@ internal class DefaultSellDeepLinkHandler @AssistedInject constructor(
Timber.e(
"""
Invalid parameters for SELL deeplink
|- Params: $params
|- Params: $queryParams
""".trimIndent(),
)
return@fold
@ -71,7 +71,10 @@ internal class DefaultSellDeepLinkHandler @AssistedInject constructor(
@AssistedFactory
interface Factory : SellDeepLinkHandler.Factory {
override fun create(coroutineScope: CoroutineScope, params: Map<String, String>): DefaultSellDeepLinkHandler
override fun create(
coroutineScope: CoroutineScope,
queryParams: Map<String, String>,
): DefaultSellDeepLinkHandler
}
private companion object {

View file

@ -18,4 +18,7 @@ dependencies {
api(projects.domain.models)
implementation(projects.domain.tokens.models)
implementation(projects.domain.wallets.models)
/** Compose */
implementation(deps.compose.runtime)
}

View file

@ -0,0 +1,10 @@
package com.tangem.features.tokendetails.deeplink
import kotlinx.coroutines.CoroutineScope
interface TokenDetailsDeepLinkHandler {
interface Factory {
fun create(coroutineScope: CoroutineScope, queryParams: Map<String, String>): TokenDetailsDeepLinkHandler
}
}

View file

@ -87,6 +87,7 @@ dependencies {
implementation(projects.domain.promo)
implementation(projects.domain.promo.models)
implementation(projects.domain.quotes)
implementation(projects.domain.notifications.models)
/** Temp dependency to swap domain */
implementation(projects.features.swap.domain)

View file

@ -0,0 +1,88 @@
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.domain.notifications.models.NotificationType
import com.tangem.domain.tokens.GetCryptoCurrenciesUseCase
import com.tangem.domain.wallets.models.UserWalletId
import com.tangem.domain.wallets.usecase.GetSelectedWalletSyncUseCase
import com.tangem.features.tokendetails.deeplink.TokenDetailsDeepLinkHandler
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
import timber.log.Timber
internal class DefaultTokenDetailsDeepLinkHandler @AssistedInject constructor(
@Assisted private val scope: CoroutineScope,
@Assisted private val queryParams: Map<String, String>,
private val appRouter: AppRouter,
private val getSelectedWalletSyncUseCase: GetSelectedWalletSyncUseCase,
private val getCryptoCurrenciesUseCase: GetCryptoCurrenciesUseCase,
) : TokenDetailsDeepLinkHandler {
init {
handleDeepLink()
}
private fun handleDeepLink() {
// It is okay here, we are navigating from outside, and there is no other way to getting UserWallet
val userWalletId = queryParams[WALLET_ID_KEY]?.let(::UserWalletId)
?: getSelectedWalletSyncUseCase().getOrNull()?.walletId
if (userWalletId == null) {
Timber.e("Error on getting user wallet")
return
}
val networkId = queryParams[NETWORK_ID_KEY]
val tokenId = queryParams[TOKEN_ID_KEY]
val type = NotificationType.getType(queryParams[TYPE_KEY])
if (type == NotificationType.Promo) {
scope.launch {
val cryptoCurrency = getCryptoCurrenciesUseCase(userWalletId = userWalletId).getOrElse {
Timber.e("Error on getting crypto currency list")
return@launch
}.firstOrNull {
it.id.rawNetworkId == networkId && it.id.rawCurrencyId?.value == tokenId
}
if (cryptoCurrency == null) {
Timber.e(
"""
Could not get crypto currency for
|- $NETWORK_ID_KEY: $networkId
|- $TOKEN_ID_KEY: $tokenId
""".trimIndent(),
)
return@launch
}
appRouter.push(
AppRoute.CurrencyDetails(
userWalletId = userWalletId,
currency = cryptoCurrency,
),
)
}
}
}
@AssistedFactory
interface Factory : TokenDetailsDeepLinkHandler.Factory {
override fun create(
coroutineScope: CoroutineScope,
queryParams: Map<String, String>,
): DefaultTokenDetailsDeepLinkHandler
}
private companion object {
const val WALLET_ID_KEY = "walletId"
const val NETWORK_ID_KEY = "network_id"
const val TYPE_KEY = "type"
const val TOKEN_ID_KEY = "token_id"
}
}

View file

@ -0,0 +1,20 @@
package com.tangem.feature.tokendetails.deeplink.di
import com.tangem.feature.tokendetails.deeplink.DefaultTokenDetailsDeepLinkHandler
import com.tangem.features.tokendetails.deeplink.TokenDetailsDeepLinkHandler
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 TokenDetailsDeepLinkModule {
@Binds
@Singleton
fun bindWalletDeepLinkHandlerFactory(
impl: DefaultTokenDetailsDeepLinkHandler.Factory,
): TokenDetailsDeepLinkHandler.Factory
}

View file

@ -0,0 +1,8 @@
package com.tangem.features.wallet.deeplink
interface WalletDeepLinkHandler {
interface Factory {
fun create(): WalletDeepLinkHandler
}
}

View file

@ -0,0 +1,13 @@
package com.tangem.feature.wallet.deeplink
import com.tangem.features.wallet.deeplink.WalletDeepLinkHandler
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
internal class DefaultWalletDeepLinkHandler @AssistedInject constructor() : WalletDeepLinkHandler {
@AssistedFactory
interface Factory : WalletDeepLinkHandler.Factory {
override fun create(): DefaultWalletDeepLinkHandler
}
}

View file

@ -0,0 +1,18 @@
package com.tangem.feature.wallet.deeplink.di
import com.tangem.feature.wallet.deeplink.DefaultWalletDeepLinkHandler
import com.tangem.features.wallet.deeplink.WalletDeepLinkHandler
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 WalletDeepLinkModule {
@Binds
@Singleton
fun bindWalletDeepLinkHandlerFactory(impl: DefaultWalletDeepLinkHandler.Factory): WalletDeepLinkHandler.Factory
}