Updated on 2026-08-14

This commit is contained in:
Tangem 2026-05-05 10:31:41 +05:00
parent 7f2f051415
commit 3d5a8654a5
23 changed files with 783 additions and 35 deletions

View file

@ -306,6 +306,26 @@
android:host="news"
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="earn"
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="yield"
android:scheme="tangem" />
</intent-filter>
<intent-filter android:autoVerify="true">
<action android:name="android.intent.action.VIEW" />

View file

@ -709,6 +709,16 @@ internal class ChildFactory @Inject constructor(
componentFactory = feedEntryComponentFactory,
)
}
is AppRoute.Earn -> {
createComponentChild(
context = context,
params = FeedEntryRoute.Earn(
preselectedEarnType = route.preselectedEarnType,
preselectedNetworkId = route.preselectedNetworkId,
),
componentFactory = feedEntryComponentFactory,
)
}
}
}
}

View file

@ -6,11 +6,13 @@ import com.tangem.common.routing.DeepLinkRoute
import com.tangem.common.routing.DeepLinkScheme
import com.tangem.data.card.sdk.CardSdkProvider
import com.tangem.feature.referral.api.deeplink.ReferralDeepLinkHandler
import com.tangem.features.feed.entry.deeplink.EarnDeepLinkHandler
import com.tangem.features.feed.entry.deeplink.MarketsDeepLinkHandler
import com.tangem.features.feed.entry.deeplink.MarketsTokenDetailDeepLinkHandler
import com.tangem.features.feed.entry.deeplink.MarketsTokenExchangesDeepLinkHandler
import com.tangem.features.feed.entry.deeplink.NewsDeepLinkHandler
import com.tangem.features.feed.entry.deeplink.NewsDetailsDeepLinkHandler
import com.tangem.features.feed.entry.deeplink.YieldDeepLinkHandler
import com.tangem.features.onramp.deeplink.BuyDeepLinkHandler
import com.tangem.features.onramp.deeplink.OnrampDeepLinkHandler
import com.tangem.features.onramp.deeplink.SellDeepLinkHandler
@ -56,6 +58,8 @@ internal class DeepLinkFactory @Inject constructor(
private val marketsTokenExchangesDeepLink: MarketsTokenExchangesDeepLinkHandler.Factory,
private val newsDetailsDeepLink: NewsDetailsDeepLinkHandler.Factory,
private val newsDeepLink: NewsDeepLinkHandler.Factory,
private val earnDeepLink: EarnDeepLinkHandler.Factory,
private val yieldDeepLink: YieldDeepLinkHandler.Factory,
) {
private val permittedAppRoute = MutableStateFlow(false)
@ -162,6 +166,8 @@ internal class DeepLinkFactory @Inject constructor(
DeepLinkRoute.Promo.host -> promoDeepLink.create(coroutineScope, queryParams)
DeepLinkRoute.OnboardVisa.host -> onboardVisaDeepLink.create(deeplinkUri)
DeepLinkRoute.News.host -> newsDeepLink.create(queryParams)
DeepLinkRoute.Earn.host -> earnDeepLink.create(queryParams)
DeepLinkRoute.Yield.host -> yieldDeepLink.create(coroutineScope, queryParams)
else -> {
TangemLogger.i(
"""

View file

@ -4,11 +4,13 @@ import android.net.Uri
import com.tangem.common.routing.AppRoute
import com.tangem.data.card.sdk.CardSdkProvider
import com.tangem.feature.referral.api.deeplink.ReferralDeepLinkHandler
import com.tangem.features.feed.entry.deeplink.EarnDeepLinkHandler
import com.tangem.features.feed.entry.deeplink.MarketsDeepLinkHandler
import com.tangem.features.feed.entry.deeplink.MarketsTokenDetailDeepLinkHandler
import com.tangem.features.feed.entry.deeplink.MarketsTokenExchangesDeepLinkHandler
import com.tangem.features.feed.entry.deeplink.NewsDeepLinkHandler
import com.tangem.features.feed.entry.deeplink.NewsDetailsDeepLinkHandler
import com.tangem.features.feed.entry.deeplink.YieldDeepLinkHandler
import com.tangem.features.onramp.deeplink.BuyDeepLinkHandler
import com.tangem.features.onramp.deeplink.OnrampDeepLinkHandler
import com.tangem.features.onramp.deeplink.SellDeepLinkHandler
@ -92,6 +94,14 @@ class DeepLinkFactoryTest {
every { create(any()) } returns mockk()
}
private val earnDeepLinkFactory = mockk<EarnDeepLinkHandler.Factory>(relaxed = true) {
every { create(any()) } returns mockk()
}
private val yieldDeepLinkFactory = mockk<YieldDeepLinkHandler.Factory>(relaxed = true) {
every { create(any(), any()) } returns mockk()
}
private val marketsTokenExchangesDeepLinkFactory =
mockk<MarketsTokenExchangesDeepLinkHandler.Factory>(relaxed = true) {
every { create(any(), any()) } returns mockk()
@ -122,6 +132,8 @@ class DeepLinkFactoryTest {
onboardVisaDeepLink = onboardVisaDeepLink,
newsDetailsDeepLink = newsDeeplink,
newsDeepLink = newsDeepLinkFactory,
earnDeepLink = earnDeepLinkFactory,
yieldDeepLink = yieldDeepLinkFactory,
)
@OptIn(ExperimentalCoroutinesApi::class)
@ -439,18 +451,28 @@ class DeepLinkFactoryTest {
}
@Test
fun `handleTangemDeepLinks routes news host to dedicated handler`() = runTest {
fun `handleTangemDeepLinks routes news, earn and yield hosts to dedicated handlers`() = runTest {
every { mockedUri.scheme } returns "tangem"
every { mockedUri.host } returns "news"
every { mockedUri.query } returns null
every { mockedUri.queryParameterNames } returns emptySet()
every { mockedUri.getQueryParameter(any()) } returns null
deepLinkFactory.checkRoutingReadiness(AppRoute.Wallet)
every { mockedUri.host } returns "news"
deepLinkFactory.handleDeeplink(mockedUri, testScope, isFromOnNewIntent)
advanceUntilIdle()
verify { newsDeepLinkFactory.create(eq(emptyMap())) }
every { mockedUri.host } returns "earn"
deepLinkFactory.handleDeeplink(mockedUri, testScope, isFromOnNewIntent)
advanceUntilIdle()
verify { earnDeepLinkFactory.create(eq(emptyMap())) }
every { mockedUri.host } returns "yield"
deepLinkFactory.handleDeeplink(mockedUri, testScope, isFromOnNewIntent)
advanceUntilIdle()
verify { yieldDeepLinkFactory.create(eq(testScope), eq(emptyMap())) }
}
@Test

View file

@ -17,6 +17,7 @@ import com.tangem.domain.markets.TokenMarketParams
import com.tangem.domain.models.account.Account
import com.tangem.domain.models.account.AccountId
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.earn.PreselectedEarnType
import com.tangem.domain.models.scan.ScanResponse
import com.tangem.domain.models.serialization.SerializedBigDecimal
import com.tangem.domain.models.wallet.UserWalletId
@ -494,4 +495,10 @@ sealed class AppRoute(val path: String) : Route {
data class News(
val categoryId: Int? = null,
) : AppRoute(path = "/news")
@Serializable
data class Earn(
val preselectedEarnType: PreselectedEarnType? = null,
val preselectedNetworkId: String? = null,
) : AppRoute(path = "/earn")
}

View file

@ -75,6 +75,14 @@ sealed class DeepLinkRoute {
data object News : DeepLinkRoute() {
override val host: String = "news"
}
data object Earn : DeepLinkRoute() {
override val host: String = "earn"
}
data object Yield : DeepLinkRoute() {
override val host: String = "yield"
}
}
enum class DeepLinkScheme(val scheme: String) {

View file

@ -20,4 +20,5 @@ object DeeplinkConst {
const val SECTION_KEY = "section"
const val CATEGORY_ID_KEY = "category_id"
const val NEWS_ID_KEY = "news_id"
const val EARN_TYPE_KEY = "earn_type"
}

View file

@ -108,6 +108,31 @@ internal class DeepLinkBuilderTest {
assertThat(result).isEqualTo("${DeeplinkConst.TANGEM_SCHEME}://news?${DeeplinkConst.NEWS_ID_KEY}=20533")
}
@Test
fun `earn host with filters produces expected uri`() {
val result = deepLinkBuilder
.setAction("earn")
.addQueryParam(DeeplinkConst.EARN_TYPE_KEY, "yield")
.addQueryParam(DeeplinkConst.NETWORK_ID_KEY, "base")
.build()
assertThat(result).isEqualTo(
"${DeeplinkConst.TANGEM_SCHEME}://earn?${DeeplinkConst.EARN_TYPE_KEY}=yield" +
"&${DeeplinkConst.NETWORK_ID_KEY}=base",
)
}
@Test
fun `yield host with token and network produces expected uri`() {
val result = deepLinkBuilder
.setAction("yield")
.addQueryParam(DeeplinkConst.TOKEN_ID_KEY, "usd-coin")
.addQueryParam(DeeplinkConst.NETWORK_ID_KEY, "base")
.build()
assertThat(result).isEqualTo("${DeeplinkConst.TANGEM_SCHEME}://yield?token_id=usd-coin&network_id=base")
}
@Test
fun `GIVEN complex deep link WHEN build THEN should construct correct URI`() {
// GIVEN

View file

@ -0,0 +1,14 @@
package com.tangem.domain.models.earn
import kotlinx.serialization.Serializable
@Serializable
enum class PreselectedEarnType(val value: String) {
Staking("staking"),
Yield("yield"),
;
companion object {
fun parse(value: String?): PreselectedEarnType? = entries.firstOrNull { it.value == value }
}
}

View file

@ -22,6 +22,7 @@ dependencies {
/* Project - Domain */
implementation(projects.domain.core)
implementation(projects.domain.models)
implementation(projects.domain.tokens.models)
implementation(projects.domain.appCurrency.models)
implementation(projects.domain.markets.models)

View file

@ -5,6 +5,7 @@ import com.tangem.domain.markets.PreselectedMarketsInterval
import com.tangem.domain.markets.PreselectedMarketsOrder
import com.tangem.domain.markets.PreselectedTokenDetailsSection
import com.tangem.domain.markets.TokenMarketParams
import com.tangem.domain.models.earn.PreselectedEarnType
import kotlinx.serialization.Serializable
@Serializable
@ -39,4 +40,10 @@ sealed interface FeedEntryRoute {
@Serializable
data class NewsList(val preselectedCategoryId: Int? = null) : FeedEntryRoute
@Serializable
data class Earn(
val preselectedEarnType: PreselectedEarnType? = null,
val preselectedNetworkId: String? = null,
) : FeedEntryRoute
}

View file

@ -0,0 +1,8 @@
package com.tangem.features.feed.entry.deeplink
interface EarnDeepLinkHandler {
interface Factory {
fun create(queryParams: Map<String, String>): EarnDeepLinkHandler
}
}

View file

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

View file

@ -112,4 +112,5 @@ dependencies {
testRuntimeOnly(deps.test.junit5.engine)
testImplementation(deps.test.mockk)
testImplementation(deps.test.truth)
testImplementation(deps.test.coroutine)
}

View file

@ -21,10 +21,13 @@ import com.tangem.core.ui.res.LocalMainBottomSheetColor
import com.tangem.core.ui.res.TangemTheme
import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.domain.markets.TokenMarketParams
import com.tangem.domain.models.earn.PreselectedEarnType
import com.tangem.domain.news.model.NewsListConfig
import com.tangem.features.feed.components.earn.DefaultEarnComponent
import com.tangem.features.feed.components.market.details.DefaultMarketsTokenDetailsComponent
import com.tangem.features.feed.components.market.list.DefaultMarketsTokenListComponent
import com.tangem.features.feed.components.news.details.DefaultNewsDetailsComponent
import com.tangem.features.feed.components.news.list.DefaultNewsListComponent
import com.tangem.features.feed.entry.components.FeedEntryComponent
import com.tangem.features.feed.entry.components.FeedEntryRoute
import com.tangem.features.feed.model.FeedEntryModel
@ -125,11 +128,19 @@ internal class DefaultFeedEntryComponent @AssistedInject constructor(
}
override fun onOpenAllNews() {
innerRouter.push(FeedEntryChildFactory.Child.NewsList())
innerRouter.push(
FeedEntryChildFactory.Child.NewsList(
params = buildNewsListParams(onBack = { onChildBack() }),
),
)
}
override fun onOpenEarnPage() {
innerRouter.push(FeedEntryChildFactory.Child.Earn)
innerRouter.push(
FeedEntryChildFactory.Child.Earn(
params = buildEarnParams(onBack = { onChildBack() }),
),
)
}
override fun openSearch(source: String) {
@ -264,12 +275,49 @@ internal class DefaultFeedEntryComponent @AssistedInject constructor(
),
)
is FeedEntryRoute.NewsList -> FeedEntryChildFactory.Child.NewsList(
preselectedCategoryId = entryRoute.preselectedCategoryId,
params = buildNewsListParams(
onBack = { router.pop() },
preselectedCategoryId = entryRoute.preselectedCategoryId,
),
)
is FeedEntryRoute.Earn -> FeedEntryChildFactory.Child.Earn(
params = buildEarnParams(
onBack = { router.pop() },
preselectedEarnType = entryRoute.preselectedEarnType,
preselectedNetworkId = entryRoute.preselectedNetworkId,
),
)
null -> FeedEntryChildFactory.Child.Feed
}
}
private fun buildNewsListParams(
onBack: () -> Unit,
preselectedCategoryId: Int? = null,
): DefaultNewsListComponent.Params = DefaultNewsListComponent.Params(
onArticleClicked = { currentArticle, prefetchedArticles, paginationConfig ->
clickIntents.onArticleClick(
articleId = currentArticle,
preselectedArticlesId = prefetchedArticles,
screenSource = AnalyticsParam.ScreensSources.NewsList,
paginationConfig = paginationConfig,
)
},
onBackClick = onBack,
preselectedCategoryId = preselectedCategoryId,
)
private fun buildEarnParams(
onBack: () -> Unit,
preselectedEarnType: PreselectedEarnType? = null,
preselectedNetworkId: String? = null,
): DefaultEarnComponent.Params = DefaultEarnComponent.Params(
onBackClick = onBack,
onSearchClicked = clickIntents::openSearch,
preselectedEarnType = preselectedEarnType,
preselectedNetworkId = preselectedNetworkId,
)
@AssistedFactory
interface Factory : FeedEntryComponent.Factory {
override fun create(context: AppComponentContext, entryRoute: FeedEntryRoute?): DefaultFeedEntryComponent

View file

@ -17,7 +17,6 @@ import com.tangem.features.feed.components.market.details.portfolioblock.Portfol
import com.tangem.features.feed.components.market.list.DefaultMarketsTokenListComponent
import com.tangem.features.feed.components.news.details.DefaultNewsDetailsComponent
import com.tangem.features.feed.components.news.list.DefaultNewsListComponent
import com.tangem.features.feed.components.news.list.DefaultNewsListComponent.Params
import com.tangem.features.feed.components.search.DefaultSearchComponent
import com.tangem.features.promobanners.api.NewPromoBannersFeatureToggles
import com.tangem.features.promobanners.api.PromoBannersBlockComponent
@ -53,7 +52,7 @@ internal class FeedEntryChildFactory @Inject constructor(
@Serializable
@Immutable
data class NewsList(val preselectedCategoryId: Int? = null) : Child
data class NewsList(val params: DefaultNewsListComponent.Params) : Child
@Serializable
@Immutable
@ -61,7 +60,7 @@ internal class FeedEntryChildFactory @Inject constructor(
@Serializable
@Immutable
data object Earn : Child
data class Earn(val params: DefaultEarnComponent.Params) : Child
@Serializable
@Immutable
@ -113,18 +112,7 @@ internal class FeedEntryChildFactory @Inject constructor(
is Child.NewsList -> {
DefaultNewsListComponent(
appComponentContext = appComponentContext,
params = Params(
onArticleClicked = { currentArticle, prefetchedArticles, paginationConfig ->
feedEntryClickIntents.onArticleClick(
articleId = currentArticle,
preselectedArticlesId = prefetchedArticles,
screenSource = AnalyticsParam.ScreensSources.NewsList,
paginationConfig = paginationConfig,
)
},
onBackClick = onBackClicked,
preselectedCategoryId = child.preselectedCategoryId,
),
params = child.params,
)
}
Child.Feed -> {
@ -139,10 +127,7 @@ internal class FeedEntryChildFactory @Inject constructor(
is Child.Earn -> {
DefaultEarnComponent(
appComponentContext = appComponentContext,
params = DefaultEarnComponent.Params(
onBackClick = onBackClicked,
onSearchClicked = feedEntryClickIntents::openSearch,
),
params = child.params,
addToPortfolioComponentFactory = addToPortfolioComponentFactory,
)
}

View file

@ -34,7 +34,9 @@ import com.tangem.core.ui.res.LocalMainBottomSheetColor
import com.tangem.core.ui.res.LocalRedesignEnabled
import com.tangem.core.ui.res.TangemTheme
import com.tangem.features.commonfeatures.api.addtoportfolio.AddToPortfolioComponent
import com.tangem.domain.models.earn.PreselectedEarnType
import com.tangem.features.feed.components.feed.FeedBottomSheetRoute
import kotlinx.serialization.Serializable
import com.tangem.features.feed.model.earn.EarnModel
import com.tangem.features.feed.model.earn.analytics.EarnSource
import com.tangem.features.feed.ui.components.FeedSearchBar
@ -151,8 +153,11 @@ internal class DefaultEarnComponent(
)
}
@Serializable
data class Params(
val onBackClick: () -> Unit,
val onSearchClicked: (source: String) -> Unit,
val preselectedEarnType: PreselectedEarnType? = null,
val preselectedNetworkId: String? = null,
)
}

View file

@ -0,0 +1,38 @@
package com.tangem.features.feed.deeplink
import com.tangem.common.routing.AppRoute
import com.tangem.common.routing.AppRouter
import com.tangem.common.routing.deeplink.DeeplinkConst.EARN_TYPE_KEY
import com.tangem.common.routing.deeplink.DeeplinkConst.NETWORK_ID_KEY
import com.tangem.domain.models.earn.PreselectedEarnType
import com.tangem.features.feed.entry.deeplink.EarnDeepLinkHandler
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
internal class DefaultEarnDeepLinkHandler @AssistedInject constructor(
@Assisted private val queryParams: Map<String, String>,
private val appRouter: AppRouter,
) : EarnDeepLinkHandler {
init {
handleDeepLink()
}
private fun handleDeepLink() {
val earnType = PreselectedEarnType.parse(queryParams[EARN_TYPE_KEY])
val networkId = queryParams[NETWORK_ID_KEY]?.takeIf { it.isNotBlank() }
appRouter.push(
AppRoute.Earn(
preselectedEarnType = earnType,
preselectedNetworkId = networkId,
),
)
}
@AssistedFactory
interface Factory : EarnDeepLinkHandler.Factory {
override fun create(queryParams: Map<String, String>): DefaultEarnDeepLinkHandler
}
}

View file

@ -0,0 +1,107 @@
package com.tangem.features.feed.deeplink
import com.tangem.common.routing.AppRoute
import com.tangem.common.routing.AppRouter
import com.tangem.common.routing.deeplink.DeeplinkConst.NETWORK_ID_KEY
import com.tangem.common.routing.deeplink.DeeplinkConst.TOKEN_ID_KEY
import com.tangem.domain.models.earn.PreselectedEarnType
import com.tangem.domain.tokens.MultiWalletCryptoCurrenciesProducer
import com.tangem.domain.tokens.MultiWalletCryptoCurrenciesSupplier
import com.tangem.domain.wallets.usecase.GetSelectedWalletSyncUseCase
import com.tangem.domain.yield.supply.models.YieldSupplyAvailability
import com.tangem.domain.yield.supply.usecase.YieldSupplyGetAvailabilityUseCase
import com.tangem.features.feed.entry.deeplink.YieldDeepLinkHandler
import com.tangem.utils.logging.TangemLogger
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
internal class DefaultYieldDeepLinkHandler @AssistedInject constructor(
@Assisted private val scope: CoroutineScope,
@Assisted private val queryParams: Map<String, String>,
private val appRouter: AppRouter,
private val getSelectedWalletSyncUseCase: GetSelectedWalletSyncUseCase,
private val multiWalletCryptoCurrenciesSupplier: MultiWalletCryptoCurrenciesSupplier,
private val yieldSupplyGetAvailabilityUseCase: YieldSupplyGetAvailabilityUseCase,
) : YieldDeepLinkHandler {
init {
handleDeepLink()
}
private fun handleDeepLink() {
val tokenId = queryParams[TOKEN_ID_KEY]?.takeIf { it.isNotBlank() }
val networkId = queryParams[NETWORK_ID_KEY]?.takeIf { it.isNotBlank() }
if (tokenId == null || networkId == null) {
TangemLogger.i("Yield deeplink: missing token_id or network_id; falling back to earn yield list")
pushFallback(networkId)
return
}
scope.launch {
val userWallet = getSelectedWalletSyncUseCase().getOrNull()
if (userWallet == null) {
TangemLogger.e("Yield deeplink: no selected user wallet")
pushFallback(networkId)
return@launch
}
val cryptoCurrency = multiWalletCryptoCurrenciesSupplier.getSyncOrNull(
params = MultiWalletCryptoCurrenciesProducer.Params(userWallet.walletId),
)
.orEmpty()
.firstOrNull { currency ->
currency.network.rawId.equals(networkId, ignoreCase = true) &&
currency.id.rawCurrencyId?.value?.equals(tokenId, ignoreCase = true) == true
}
if (cryptoCurrency == null) {
TangemLogger.i(
"""
Yield deeplink: token not in selected wallet
|- $TOKEN_ID_KEY: $tokenId
|- $NETWORK_ID_KEY: $networkId
""".trimIndent(),
)
pushFallback(networkId)
return@launch
}
val availability = yieldSupplyGetAvailabilityUseCase(cryptoCurrency).getOrNull()
val available = availability as? YieldSupplyAvailability.Available
if (available == null) {
TangemLogger.i("Yield deeplink: yield not available for ${cryptoCurrency.name}")
pushFallback(networkId)
return@launch
}
appRouter.push(
AppRoute.YieldSupplyEntry(
userWalletId = userWallet.walletId,
cryptoCurrency = cryptoCurrency,
apy = available.apy,
),
)
}
}
private fun pushFallback(networkId: String?) {
appRouter.push(
AppRoute.Earn(
preselectedEarnType = PreselectedEarnType.Yield,
preselectedNetworkId = networkId,
),
)
}
@AssistedFactory
interface Factory : YieldDeepLinkHandler.Factory {
override fun create(
coroutineScope: CoroutineScope,
queryParams: Map<String, String>,
): DefaultYieldDeepLinkHandler
}
}

View file

@ -1,9 +1,13 @@
package com.tangem.features.feed.deeplink.di
import com.tangem.features.feed.deeplink.DefaultEarnDeepLinkHandler
import com.tangem.features.feed.deeplink.DefaultNewsDeepLinkHandler
import com.tangem.features.feed.deeplink.DefaultNewsDetailsDeepLinkHandler
import com.tangem.features.feed.deeplink.DefaultYieldDeepLinkHandler
import com.tangem.features.feed.entry.deeplink.EarnDeepLinkHandler
import com.tangem.features.feed.entry.deeplink.NewsDeepLinkHandler
import com.tangem.features.feed.entry.deeplink.NewsDetailsDeepLinkHandler
import com.tangem.features.feed.entry.deeplink.YieldDeepLinkHandler
import dagger.Binds
import dagger.Module
import dagger.hilt.InstallIn
@ -23,4 +27,12 @@ internal interface FeedDeepLinkModule {
@Binds
@Singleton
fun bindNewsDeepLinkHandlerFactory(impl: DefaultNewsDeepLinkHandler.Factory): NewsDeepLinkHandler.Factory
@Binds
@Singleton
fun bindEarnDeepLinkHandlerFactory(impl: DefaultEarnDeepLinkHandler.Factory): EarnDeepLinkHandler.Factory
@Binds
@Singleton
fun bindYieldDeepLinkHandlerFactory(impl: DefaultYieldDeepLinkHandler.Factory): YieldDeepLinkHandler.Factory
}

View file

@ -23,6 +23,7 @@ import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.earn.EarnNetworks
import com.tangem.domain.models.earn.EarnTokenWithCurrency
import com.tangem.features.commonfeatures.api.addtoportfolio.AddToPortfolioManager
import com.tangem.domain.models.earn.PreselectedEarnType
import com.tangem.features.feed.components.earn.DefaultEarnComponent
import com.tangem.features.feed.components.earn.EarnNetworkFilterComponent
import com.tangem.features.feed.components.earn.EarnTypeFilterComponent
@ -108,7 +109,8 @@ internal class EarnModel @Inject constructor(
init {
updateInitialState()
fetchEarnNetworks()
subscribeOnStoredFilters()
fetchTopEarnTokens()
subscribeOnActiveFilters()
subscribeOnNetworks()
subscribeOnBatchFlow()
subscribeToMostlyUsed()
@ -147,6 +149,45 @@ internal class EarnModel @Inject constructor(
)
}
private fun activeFilters(): Flow<EarnFilter> {
val deeplink = buildDeeplinkFilter() ?: return getEarnFilterUseCase()
return getEarnFilterUseCase().drop(1).onStart { emit(deeplink) }
}
private fun buildDeeplinkFilter(): EarnFilter? {
val type = params.preselectedEarnType?.toEarnFilterType()
val networkId = params.preselectedNetworkId?.takeIf { it.isNotBlank() }
if (type == null && networkId == null) return null
return EarnFilter(
earnFilterType = type ?: EarnFilterType.ALL,
earnFilterNetwork = networkId
?.let { EarnFilterNetwork.Specific(id = it, symbol = "", fullName = it, isSelected = true) }
?: EarnFilterNetwork.AllNetworks(isSelected = true),
)
}
private fun EarnFilter.resolveAgainst(networks: EarnNetworks): EarnFilter {
val specific = earnFilterNetwork as? EarnFilterNetwork.Specific ?: return this
if (specific.symbol.isNotEmpty()) return this
val loaded = networks.getOrNull()?.takeIf { it.isNotEmpty() } ?: return this
val match = loaded.firstOrNull { it.networkId.equals(specific.id, ignoreCase = true) }
return copy(
earnFilterNetwork = match?.let { earnNetwork ->
EarnFilterNetwork.Specific(
isSelected = true,
id = earnNetwork.networkId,
symbol = earnNetwork.symbol,
fullName = earnNetwork.fullName,
)
} ?: EarnFilterNetwork.AllNetworks(isSelected = true),
)
}
private fun PreselectedEarnType.toEarnFilterType(): EarnFilterType = when (this) {
PreselectedEarnType.Staking -> EarnFilterType.STAKING
PreselectedEarnType.Yield -> EarnFilterType.YIELD
}
private fun subscribeOnBatchFlow() {
combine(
batchFlowManager.uiItems,
@ -193,18 +234,14 @@ internal class EarnModel @Inject constructor(
}
}
private fun subscribeOnStoredFilters() {
private fun subscribeOnActiveFilters() {
modelScope.launch(dispatchers.default) {
combine(
getEarnFilterUseCase(),
earnNetworks,
) { filter, networks ->
val typeFilterUM = EarnFilterTypeConverter().convert(filter.earnFilterType)
val networkFilterUM = EarnFilterNetworkConverter().convert(filter.earnFilterNetwork)
combine(activeFilters(), earnNetworks) { filter, networks ->
val resolved = filter.resolveAgainst(networks)
stateController.update(
EarnFilterSelectedStateTransformer(
filterType = typeFilterUM,
filterNetwork = networkFilterUM,
filterType = EarnFilterTypeConverter().convert(resolved.earnFilterType),
filterNetwork = EarnFilterNetworkConverter().convert(resolved.earnFilterNetwork),
earnNetworks = networks,
),
)

View file

@ -0,0 +1,129 @@
package com.tangem.features.feed.deeplink
import com.tangem.common.routing.AppRoute
import com.tangem.common.routing.AppRouter
import com.tangem.common.routing.deeplink.DeeplinkConst.EARN_TYPE_KEY
import com.tangem.common.routing.deeplink.DeeplinkConst.NETWORK_ID_KEY
import com.tangem.domain.models.earn.PreselectedEarnType
import io.mockk.Runs
import io.mockk.every
import io.mockk.just
import io.mockk.mockk
import io.mockk.verify
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
class DefaultEarnDeepLinkHandlerTest {
private val appRouter: AppRouter = mockk()
@BeforeEach
fun setUp() {
every { appRouter.push(any(), any()) } just Runs
}
@Test
fun `no params opens earn list with no filters`() {
DefaultEarnDeepLinkHandler(queryParams = emptyMap(), appRouter = appRouter)
verify {
appRouter.push(
AppRoute.Earn(preselectedEarnType = null, preselectedNetworkId = null),
any(),
)
}
}
@Test
fun `staking earnType is parsed`() {
DefaultEarnDeepLinkHandler(
queryParams = mapOf(EARN_TYPE_KEY to "staking"),
appRouter = appRouter,
)
verify {
appRouter.push(
AppRoute.Earn(preselectedEarnType = PreselectedEarnType.Staking, preselectedNetworkId = null),
any(),
)
}
}
@Test
fun `yield earnType is parsed`() {
DefaultEarnDeepLinkHandler(
queryParams = mapOf(EARN_TYPE_KEY to "yield"),
appRouter = appRouter,
)
verify {
appRouter.push(
AppRoute.Earn(preselectedEarnType = PreselectedEarnType.Yield, preselectedNetworkId = null),
any(),
)
}
}
@Test
fun `invalid earnType is silently dropped`() {
DefaultEarnDeepLinkHandler(
queryParams = mapOf(EARN_TYPE_KEY to "bogus"),
appRouter = appRouter,
)
verify {
appRouter.push(
AppRoute.Earn(preselectedEarnType = null, preselectedNetworkId = null),
any(),
)
}
}
@Test
fun `networkId is passed through`() {
DefaultEarnDeepLinkHandler(
queryParams = mapOf(NETWORK_ID_KEY to "base"),
appRouter = appRouter,
)
verify {
appRouter.push(
AppRoute.Earn(preselectedEarnType = null, preselectedNetworkId = "base"),
any(),
)
}
}
@Test
fun `blank networkId is dropped`() {
DefaultEarnDeepLinkHandler(
queryParams = mapOf(NETWORK_ID_KEY to " "),
appRouter = appRouter,
)
verify {
appRouter.push(
AppRoute.Earn(preselectedEarnType = null, preselectedNetworkId = null),
any(),
)
}
}
@Test
fun `earnType and networkId together`() {
DefaultEarnDeepLinkHandler(
queryParams = mapOf(EARN_TYPE_KEY to "yield", NETWORK_ID_KEY to "base"),
appRouter = appRouter,
)
verify {
appRouter.push(
AppRoute.Earn(
preselectedEarnType = PreselectedEarnType.Yield,
preselectedNetworkId = "base",
),
any(),
)
}
}
}

View file

@ -0,0 +1,247 @@
package com.tangem.features.feed.deeplink
import arrow.core.Either
import com.tangem.common.routing.AppRoute
import com.tangem.common.routing.AppRouter
import com.tangem.common.routing.deeplink.DeeplinkConst.NETWORK_ID_KEY
import com.tangem.common.routing.deeplink.DeeplinkConst.TOKEN_ID_KEY
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.earn.PreselectedEarnType
import com.tangem.domain.models.network.Network
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.tokens.MultiWalletCryptoCurrenciesProducer
import com.tangem.domain.tokens.MultiWalletCryptoCurrenciesSupplier
import com.tangem.domain.wallets.usecase.GetSelectedWalletSyncUseCase
import com.tangem.domain.yield.supply.models.YieldSupplyAvailability
import com.tangem.domain.yield.supply.usecase.YieldSupplyGetAvailabilityUseCase
import com.tangem.utils.logging.TangemLogger
import io.mockk.Runs
import io.mockk.coEvery
import io.mockk.every
import io.mockk.just
import io.mockk.mockk
import io.mockk.mockkObject
import io.mockk.verify
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.UnconfinedTestDispatcher
import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
@OptIn(ExperimentalCoroutinesApi::class)
class DefaultYieldDeepLinkHandlerTest {
private val appRouter: AppRouter = mockk()
private val getSelectedWalletSyncUseCase: GetSelectedWalletSyncUseCase = mockk()
private val multiWalletCryptoCurrenciesSupplier: MultiWalletCryptoCurrenciesSupplier = mockk()
private val yieldSupplyGetAvailabilityUseCase: YieldSupplyGetAvailabilityUseCase = mockk()
private val tokenId = "usd-coin"
private val networkId = "base"
@BeforeEach
fun setUp() {
mockkObject(TangemLogger)
every { appRouter.push(any(), any()) } just Runs
}
@Test
fun `missing token_id falls back to earn yield`() = runTest {
handle(
scope = CoroutineScope(UnconfinedTestDispatcher(testScheduler)),
queryParams = mapOf(NETWORK_ID_KEY to networkId),
)
verify {
appRouter.push(
AppRoute.Earn(
preselectedEarnType = PreselectedEarnType.Yield,
preselectedNetworkId = networkId,
),
any(),
)
}
}
@Test
fun `missing network_id falls back to earn yield`() = runTest {
handle(
scope = CoroutineScope(UnconfinedTestDispatcher(testScheduler)),
queryParams = mapOf(TOKEN_ID_KEY to tokenId),
)
verify {
appRouter.push(
AppRoute.Earn(
preselectedEarnType = PreselectedEarnType.Yield,
preselectedNetworkId = null,
),
any(),
)
}
}
@Test
fun `no selected wallet falls back to earn yield`() = runTest {
every { getSelectedWalletSyncUseCase() } returns Either.Left(mockk())
handle(
scope = CoroutineScope(UnconfinedTestDispatcher(testScheduler)),
queryParams = mapOf(TOKEN_ID_KEY to tokenId, NETWORK_ID_KEY to networkId),
)
advanceUntilIdle()
verify {
appRouter.push(
AppRoute.Earn(
preselectedEarnType = PreselectedEarnType.Yield,
preselectedNetworkId = networkId,
),
any(),
)
}
}
@Test
fun `token not in wallet falls back to earn yield`() = runTest {
val walletId = UserWalletId("011")
val wallet = mockk<UserWallet> { every { this@mockk.walletId } returns walletId }
every { getSelectedWalletSyncUseCase() } returns Either.Right(wallet)
coEvery {
multiWalletCryptoCurrenciesSupplier.getSyncOrNull(
MultiWalletCryptoCurrenciesProducer.Params(walletId),
)
} returns emptySet()
handle(
scope = CoroutineScope(UnconfinedTestDispatcher(testScheduler)),
queryParams = mapOf(TOKEN_ID_KEY to tokenId, NETWORK_ID_KEY to networkId),
)
advanceUntilIdle()
verify {
appRouter.push(
AppRoute.Earn(
preselectedEarnType = PreselectedEarnType.Yield,
preselectedNetworkId = networkId,
),
any(),
)
}
}
@Test
fun `eligible token with yield available pushes YieldSupplyEntry`() = runTest {
val walletId = UserWalletId("011")
val wallet = mockk<UserWallet> { every { this@mockk.walletId } returns walletId }
val currency = mockEligibleCurrency()
every { getSelectedWalletSyncUseCase() } returns Either.Right(wallet)
coEvery {
multiWalletCryptoCurrenciesSupplier.getSyncOrNull(
MultiWalletCryptoCurrenciesProducer.Params(walletId),
)
} returns setOf(currency)
coEvery { yieldSupplyGetAvailabilityUseCase(currency) } returns
Either.Right(YieldSupplyAvailability.Available(apy = "2.66"))
handle(
scope = CoroutineScope(UnconfinedTestDispatcher(testScheduler)),
queryParams = mapOf(TOKEN_ID_KEY to tokenId, NETWORK_ID_KEY to networkId),
)
advanceUntilIdle()
verify {
appRouter.push(
match<AppRoute.YieldSupplyEntry> {
it.userWalletId == walletId && it.cryptoCurrency === currency && it.apy == "2.66"
},
any(),
)
}
}
@Test
fun `eligible token with yield unavailable falls back to earn yield`() = runTest {
val walletId = UserWalletId("011")
val wallet = mockk<UserWallet> { every { this@mockk.walletId } returns walletId }
val currency = mockEligibleCurrency()
every { getSelectedWalletSyncUseCase() } returns Either.Right(wallet)
coEvery {
multiWalletCryptoCurrenciesSupplier.getSyncOrNull(
MultiWalletCryptoCurrenciesProducer.Params(walletId),
)
} returns setOf(currency)
coEvery { yieldSupplyGetAvailabilityUseCase(currency) } returns
Either.Right(YieldSupplyAvailability.Unavailable)
handle(
scope = CoroutineScope(UnconfinedTestDispatcher(testScheduler)),
queryParams = mapOf(TOKEN_ID_KEY to tokenId, NETWORK_ID_KEY to networkId),
)
advanceUntilIdle()
verify {
appRouter.push(
AppRoute.Earn(
preselectedEarnType = PreselectedEarnType.Yield,
preselectedNetworkId = networkId,
),
any(),
)
}
}
@Test
fun `eligible token with availability lookup error falls back to earn yield`() = runTest {
val walletId = UserWalletId("011")
val wallet = mockk<UserWallet> { every { this@mockk.walletId } returns walletId }
val currency = mockEligibleCurrency()
every { getSelectedWalletSyncUseCase() } returns Either.Right(wallet)
coEvery {
multiWalletCryptoCurrenciesSupplier.getSyncOrNull(
MultiWalletCryptoCurrenciesProducer.Params(walletId),
)
} returns setOf(currency)
coEvery { yieldSupplyGetAvailabilityUseCase(currency) } returns
Either.Left(IllegalStateException("api 500"))
handle(
scope = CoroutineScope(UnconfinedTestDispatcher(testScheduler)),
queryParams = mapOf(TOKEN_ID_KEY to tokenId, NETWORK_ID_KEY to networkId),
)
advanceUntilIdle()
verify {
appRouter.push(
AppRoute.Earn(
preselectedEarnType = PreselectedEarnType.Yield,
preselectedNetworkId = networkId,
),
any(),
)
}
}
/** Builds a [CryptoCurrency] mock whose `network.rawId` and `id.rawCurrencyId` match the test's
* [tokenId] / [networkId] i.e. the supplier-lookup predicate inside the handler returns it. */
private fun mockEligibleCurrency(): CryptoCurrency = mockk(relaxed = true) {
every { network } returns mockk<Network>(relaxed = true) { every { rawId } returns networkId }
every { id } returns mockk(relaxed = true) {
every { rawCurrencyId } returns CryptoCurrency.RawID(tokenId)
}
}
private fun handle(scope: CoroutineScope, queryParams: Map<String, String>) {
DefaultYieldDeepLinkHandler(
scope = scope,
queryParams = queryParams,
appRouter = appRouter,
getSelectedWalletSyncUseCase = getSelectedWalletSyncUseCase,
multiWalletCryptoCurrenciesSupplier = multiWalletCryptoCurrenciesSupplier,
yieldSupplyGetAvailabilityUseCase = yieldSupplyGetAvailabilityUseCase,
)
}
}