From 3d5a8654a5f7e40157be87715c9c7c349c8ed3b1 Mon Sep 17 00:00:00 2001 From: Tangem Date: Tue, 5 May 2026 10:31:41 +0500 Subject: [PATCH] Updated on 2026-08-14 --- app/src/main/AndroidManifest.xml | 20 ++ .../tangem/tap/routing/utils/ChildFactory.kt | 10 + .../tap/routing/utils/DeepLinkFactory.kt | 6 + .../tap/routing/utils/DeepLinkFactoryTest.kt | 28 +- .../com/tangem/common/routing/AppRoute.kt | 7 + .../tangem/common/routing/DeepLinkRoute.kt | 8 + .../common/routing/deeplink/DeeplinkConst.kt | 1 + .../routing/deeplink/DeepLinkBuilderTest.kt | 25 ++ .../domain/models/earn/PreselectedEarnType.kt | 14 + features/feed/api/build.gradle.kts | 1 + .../feed/entry/components/FeedEntryRoute.kt | 7 + .../entry/deeplink/EarnDeepLinkHandler.kt | 8 + .../entry/deeplink/YieldDeepLinkHandler.kt | 10 + features/feed/impl/build.gradle.kts | 1 + .../components/DefaultFeedEntryComponent.kt | 54 +++- .../feed/components/FeedEntryChildFactory.kt | 23 +- .../components/earn/DefaultEarnComponent.kt | 5 + .../deeplink/DefaultEarnDeepLinkHandler.kt | 38 +++ .../deeplink/DefaultYieldDeepLinkHandler.kt | 107 ++++++++ .../feed/deeplink/di/FeedDeepLinkModule.kt | 12 + .../features/feed/model/earn/EarnModel.kt | 57 +++- .../DefaultEarnDeepLinkHandlerTest.kt | 129 +++++++++ .../DefaultYieldDeepLinkHandlerTest.kt | 247 ++++++++++++++++++ 23 files changed, 783 insertions(+), 35 deletions(-) create mode 100644 domain/models/src/main/kotlin/com/tangem/domain/models/earn/PreselectedEarnType.kt create mode 100644 features/feed/api/src/main/kotlin/com/tangem/features/feed/entry/deeplink/EarnDeepLinkHandler.kt create mode 100644 features/feed/api/src/main/kotlin/com/tangem/features/feed/entry/deeplink/YieldDeepLinkHandler.kt create mode 100644 features/feed/impl/src/main/kotlin/com/tangem/features/feed/deeplink/DefaultEarnDeepLinkHandler.kt create mode 100644 features/feed/impl/src/main/kotlin/com/tangem/features/feed/deeplink/DefaultYieldDeepLinkHandler.kt create mode 100644 features/feed/impl/src/test/kotlin/com/tangem/features/feed/deeplink/DefaultEarnDeepLinkHandlerTest.kt create mode 100644 features/feed/impl/src/test/kotlin/com/tangem/features/feed/deeplink/DefaultYieldDeepLinkHandlerTest.kt diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 27f8c3c8e9..f6139be58a 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -306,6 +306,26 @@ android:host="news" android:scheme="tangem" /> + + + + + + + + + + + + + + + + diff --git a/app/src/main/java/com/tangem/tap/routing/utils/ChildFactory.kt b/app/src/main/java/com/tangem/tap/routing/utils/ChildFactory.kt index 7351bc519d..7f32d68de6 100644 --- a/app/src/main/java/com/tangem/tap/routing/utils/ChildFactory.kt +++ b/app/src/main/java/com/tangem/tap/routing/utils/ChildFactory.kt @@ -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, + ) + } } } } diff --git a/app/src/main/java/com/tangem/tap/routing/utils/DeepLinkFactory.kt b/app/src/main/java/com/tangem/tap/routing/utils/DeepLinkFactory.kt index 3e9d268dd5..23e1f1f8d7 100644 --- a/app/src/main/java/com/tangem/tap/routing/utils/DeepLinkFactory.kt +++ b/app/src/main/java/com/tangem/tap/routing/utils/DeepLinkFactory.kt @@ -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( """ diff --git a/app/src/test/kotlin/com/tangem/tap/routing/utils/DeepLinkFactoryTest.kt b/app/src/test/kotlin/com/tangem/tap/routing/utils/DeepLinkFactoryTest.kt index 7a1c5173b5..0c50d75372 100644 --- a/app/src/test/kotlin/com/tangem/tap/routing/utils/DeepLinkFactoryTest.kt +++ b/app/src/test/kotlin/com/tangem/tap/routing/utils/DeepLinkFactoryTest.kt @@ -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(relaxed = true) { + every { create(any()) } returns mockk() + } + + private val yieldDeepLinkFactory = mockk(relaxed = true) { + every { create(any(), any()) } returns mockk() + } + private val marketsTokenExchangesDeepLinkFactory = mockk(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 diff --git a/common/routing/src/main/kotlin/com/tangem/common/routing/AppRoute.kt b/common/routing/src/main/kotlin/com/tangem/common/routing/AppRoute.kt index 3a6eac7c41..42bedf6b3e 100644 --- a/common/routing/src/main/kotlin/com/tangem/common/routing/AppRoute.kt +++ b/common/routing/src/main/kotlin/com/tangem/common/routing/AppRoute.kt @@ -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") } \ No newline at end of file diff --git a/common/routing/src/main/kotlin/com/tangem/common/routing/DeepLinkRoute.kt b/common/routing/src/main/kotlin/com/tangem/common/routing/DeepLinkRoute.kt index b9caabe5d0..0bdcdc9f27 100644 --- a/common/routing/src/main/kotlin/com/tangem/common/routing/DeepLinkRoute.kt +++ b/common/routing/src/main/kotlin/com/tangem/common/routing/DeepLinkRoute.kt @@ -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) { diff --git a/common/routing/src/main/kotlin/com/tangem/common/routing/deeplink/DeeplinkConst.kt b/common/routing/src/main/kotlin/com/tangem/common/routing/deeplink/DeeplinkConst.kt index 8f745a3b32..5196edd70a 100644 --- a/common/routing/src/main/kotlin/com/tangem/common/routing/deeplink/DeeplinkConst.kt +++ b/common/routing/src/main/kotlin/com/tangem/common/routing/deeplink/DeeplinkConst.kt @@ -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" } \ No newline at end of file diff --git a/common/routing/src/test/kotlin/com/tangem/common/routing/deeplink/DeepLinkBuilderTest.kt b/common/routing/src/test/kotlin/com/tangem/common/routing/deeplink/DeepLinkBuilderTest.kt index bdcb99e48d..4e7aab6d8b 100644 --- a/common/routing/src/test/kotlin/com/tangem/common/routing/deeplink/DeepLinkBuilderTest.kt +++ b/common/routing/src/test/kotlin/com/tangem/common/routing/deeplink/DeepLinkBuilderTest.kt @@ -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 diff --git a/domain/models/src/main/kotlin/com/tangem/domain/models/earn/PreselectedEarnType.kt b/domain/models/src/main/kotlin/com/tangem/domain/models/earn/PreselectedEarnType.kt new file mode 100644 index 0000000000..51d961c48a --- /dev/null +++ b/domain/models/src/main/kotlin/com/tangem/domain/models/earn/PreselectedEarnType.kt @@ -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 } + } +} \ No newline at end of file diff --git a/features/feed/api/build.gradle.kts b/features/feed/api/build.gradle.kts index 890d5aefad..b0a3a10ffc 100644 --- a/features/feed/api/build.gradle.kts +++ b/features/feed/api/build.gradle.kts @@ -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) diff --git a/features/feed/api/src/main/kotlin/com/tangem/features/feed/entry/components/FeedEntryRoute.kt b/features/feed/api/src/main/kotlin/com/tangem/features/feed/entry/components/FeedEntryRoute.kt index 098b29f4da..38225e22f9 100644 --- a/features/feed/api/src/main/kotlin/com/tangem/features/feed/entry/components/FeedEntryRoute.kt +++ b/features/feed/api/src/main/kotlin/com/tangem/features/feed/entry/components/FeedEntryRoute.kt @@ -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 } \ No newline at end of file diff --git a/features/feed/api/src/main/kotlin/com/tangem/features/feed/entry/deeplink/EarnDeepLinkHandler.kt b/features/feed/api/src/main/kotlin/com/tangem/features/feed/entry/deeplink/EarnDeepLinkHandler.kt new file mode 100644 index 0000000000..01c17aed53 --- /dev/null +++ b/features/feed/api/src/main/kotlin/com/tangem/features/feed/entry/deeplink/EarnDeepLinkHandler.kt @@ -0,0 +1,8 @@ +package com.tangem.features.feed.entry.deeplink + +interface EarnDeepLinkHandler { + + interface Factory { + fun create(queryParams: Map): EarnDeepLinkHandler + } +} \ No newline at end of file diff --git a/features/feed/api/src/main/kotlin/com/tangem/features/feed/entry/deeplink/YieldDeepLinkHandler.kt b/features/feed/api/src/main/kotlin/com/tangem/features/feed/entry/deeplink/YieldDeepLinkHandler.kt new file mode 100644 index 0000000000..5512452cb0 --- /dev/null +++ b/features/feed/api/src/main/kotlin/com/tangem/features/feed/entry/deeplink/YieldDeepLinkHandler.kt @@ -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): YieldDeepLinkHandler + } +} \ No newline at end of file diff --git a/features/feed/impl/build.gradle.kts b/features/feed/impl/build.gradle.kts index 7d4dc84f32..a9908d1787 100644 --- a/features/feed/impl/build.gradle.kts +++ b/features/feed/impl/build.gradle.kts @@ -112,4 +112,5 @@ dependencies { testRuntimeOnly(deps.test.junit5.engine) testImplementation(deps.test.mockk) testImplementation(deps.test.truth) + testImplementation(deps.test.coroutine) } \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/DefaultFeedEntryComponent.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/DefaultFeedEntryComponent.kt index 198cdac711..94c272503e 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/DefaultFeedEntryComponent.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/DefaultFeedEntryComponent.kt @@ -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 diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/FeedEntryChildFactory.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/FeedEntryChildFactory.kt index fb8c3a7f90..4ae731f39c 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/FeedEntryChildFactory.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/FeedEntryChildFactory.kt @@ -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, ) } diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/earn/DefaultEarnComponent.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/earn/DefaultEarnComponent.kt index bdef4f7ffe..9e4094404f 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/earn/DefaultEarnComponent.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/earn/DefaultEarnComponent.kt @@ -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, ) } \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/deeplink/DefaultEarnDeepLinkHandler.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/deeplink/DefaultEarnDeepLinkHandler.kt new file mode 100644 index 0000000000..4609b29cfb --- /dev/null +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/deeplink/DefaultEarnDeepLinkHandler.kt @@ -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, + 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): DefaultEarnDeepLinkHandler + } +} \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/deeplink/DefaultYieldDeepLinkHandler.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/deeplink/DefaultYieldDeepLinkHandler.kt new file mode 100644 index 0000000000..ee24674898 --- /dev/null +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/deeplink/DefaultYieldDeepLinkHandler.kt @@ -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, + 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, + ): DefaultYieldDeepLinkHandler + } +} \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/deeplink/di/FeedDeepLinkModule.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/deeplink/di/FeedDeepLinkModule.kt index 05f42eb535..0af064279f 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/deeplink/di/FeedDeepLinkModule.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/deeplink/di/FeedDeepLinkModule.kt @@ -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 } \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/earn/EarnModel.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/earn/EarnModel.kt index d795a1289d..ae1280b2cf 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/earn/EarnModel.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/earn/EarnModel.kt @@ -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 { + 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, ), ) diff --git a/features/feed/impl/src/test/kotlin/com/tangem/features/feed/deeplink/DefaultEarnDeepLinkHandlerTest.kt b/features/feed/impl/src/test/kotlin/com/tangem/features/feed/deeplink/DefaultEarnDeepLinkHandlerTest.kt new file mode 100644 index 0000000000..bdaf14d99c --- /dev/null +++ b/features/feed/impl/src/test/kotlin/com/tangem/features/feed/deeplink/DefaultEarnDeepLinkHandlerTest.kt @@ -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(), + ) + } + } +} \ No newline at end of file diff --git a/features/feed/impl/src/test/kotlin/com/tangem/features/feed/deeplink/DefaultYieldDeepLinkHandlerTest.kt b/features/feed/impl/src/test/kotlin/com/tangem/features/feed/deeplink/DefaultYieldDeepLinkHandlerTest.kt new file mode 100644 index 0000000000..b9f6992504 --- /dev/null +++ b/features/feed/impl/src/test/kotlin/com/tangem/features/feed/deeplink/DefaultYieldDeepLinkHandlerTest.kt @@ -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 { 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 { 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 { + 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 { 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 { 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(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) { + DefaultYieldDeepLinkHandler( + scope = scope, + queryParams = queryParams, + appRouter = appRouter, + getSelectedWalletSyncUseCase = getSelectedWalletSyncUseCase, + multiWalletCryptoCurrenciesSupplier = multiWalletCryptoCurrenciesSupplier, + yieldSupplyGetAvailabilityUseCase = yieldSupplyGetAvailabilityUseCase, + ) + } +} \ No newline at end of file