Updated on 2026-08-14

This commit is contained in:
Tangem 2026-04-30 18:02:28 +05:00
parent d718cf557d
commit 970312afd3
19 changed files with 260 additions and 10 deletions

View file

@ -296,6 +296,16 @@
android:host="onboard-visa"
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="news"
android:scheme="tangem" />
</intent-filter>
<intent-filter android:autoVerify="true">
<action android:name="android.intent.action.VIEW" />

View file

@ -700,6 +700,15 @@ internal class ChildFactory @Inject constructor(
componentFactory = feedEntryComponentFactory,
)
}
is AppRoute.News -> {
createComponentChild(
context = context,
params = FeedEntryRoute.NewsList(
preselectedCategoryId = route.categoryId,
),
componentFactory = feedEntryComponentFactory,
)
}
}
}
}

View file

@ -9,6 +9,7 @@ import com.tangem.feature.referral.api.deeplink.ReferralDeepLinkHandler
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.onramp.deeplink.BuyDeepLinkHandler
import com.tangem.features.onramp.deeplink.OnrampDeepLinkHandler
@ -54,6 +55,7 @@ internal class DeepLinkFactory @Inject constructor(
private val onboardVisaDeepLink: OnboardVisaDeepLinkHandler.Factory,
private val marketsTokenExchangesDeepLink: MarketsTokenExchangesDeepLinkHandler.Factory,
private val newsDetailsDeepLink: NewsDetailsDeepLinkHandler.Factory,
private val newsDeepLink: NewsDeepLinkHandler.Factory,
) {
private val permittedAppRoute = MutableStateFlow(false)
@ -159,6 +161,7 @@ internal class DeepLinkFactory @Inject constructor(
DeepLinkRoute.WalletConnect.host -> walletConnectDeepLink.create(deeplinkUri)
DeepLinkRoute.Promo.host -> promoDeepLink.create(coroutineScope, queryParams)
DeepLinkRoute.OnboardVisa.host -> onboardVisaDeepLink.create(deeplinkUri)
DeepLinkRoute.News.host -> newsDeepLink.create(queryParams)
else -> {
TangemLogger.i(
"""

View file

@ -7,6 +7,7 @@ import com.tangem.feature.referral.api.deeplink.ReferralDeepLinkHandler
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.onramp.deeplink.BuyDeepLinkHandler
import com.tangem.features.onramp.deeplink.OnrampDeepLinkHandler
@ -87,6 +88,10 @@ class DeepLinkFactoryTest {
every { create(any(), any()) } returns mockk()
}
private val newsDeepLinkFactory = mockk<NewsDeepLinkHandler.Factory>(relaxed = true) {
every { create(any()) } returns mockk()
}
private val marketsTokenExchangesDeepLinkFactory =
mockk<MarketsTokenExchangesDeepLinkHandler.Factory>(relaxed = true) {
every { create(any(), any()) } returns mockk()
@ -116,6 +121,7 @@ class DeepLinkFactoryTest {
promoDeepLink = promoDeepLinkFactory,
onboardVisaDeepLink = onboardVisaDeepLink,
newsDetailsDeepLink = newsDeeplink,
newsDeepLink = newsDeepLinkFactory,
)
@OptIn(ExperimentalCoroutinesApi::class)
@ -432,6 +438,21 @@ class DeepLinkFactoryTest {
verify { onrampDeepLinkFactory.create(eq(testScope), eq(emptyMap())) }
}
@Test
fun `handleTangemDeepLinks routes news host to dedicated handler`() = 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)
deepLinkFactory.handleDeeplink(mockedUri, testScope, isFromOnNewIntent)
advanceUntilIdle()
verify { newsDeepLinkFactory.create(eq(emptyMap())) }
}
@Test
fun `handleTangemDeepLinks routes to promo handler`() = runTest {
every { mockedUri.scheme } returns "tangem"

View file

@ -489,4 +489,9 @@ sealed class AppRoute(val path: String) : Route {
@Serializable
data class NewsDetails(val newsId: Int) : AppRoute(path = "/news_details/$newsId")
@Serializable
data class News(
val categoryId: Int? = null,
) : AppRoute(path = "/news")
}

View file

@ -71,6 +71,10 @@ sealed class DeepLinkRoute {
data object PayApp : DeepLinkRoute() {
override val host: String = "tangem.com"
}
data object News : DeepLinkRoute() {
override val host: String = "news"
}
}
enum class DeepLinkScheme(val scheme: String) {

View file

@ -18,4 +18,6 @@ object DeeplinkConst {
const val ORDER_KEY = "order"
const val INTERVAL_KEY = "interval"
const val SECTION_KEY = "section"
const val CATEGORY_ID_KEY = "category_id"
const val NEWS_ID_KEY = "news_id"
}

View file

@ -88,6 +88,26 @@ internal class DeepLinkBuilderTest {
assertThat(result).isEqualTo("${DeeplinkConst.TANGEM_SCHEME}://$action?$key1=$value1&$key2=$value2")
}
@Test
fun `news host with categoryId produces expected uri`() {
val result = deepLinkBuilder
.setAction("news")
.addQueryParam(DeeplinkConst.CATEGORY_ID_KEY, "5")
.build()
assertThat(result).isEqualTo("${DeeplinkConst.TANGEM_SCHEME}://news?${DeeplinkConst.CATEGORY_ID_KEY}=5")
}
@Test
fun `news host with newsId produces expected uri`() {
val result = deepLinkBuilder
.setAction("news")
.addQueryParam(DeeplinkConst.NEWS_ID_KEY, "20533")
.build()
assertThat(result).isEqualTo("${DeeplinkConst.TANGEM_SCHEME}://news?${DeeplinkConst.NEWS_ID_KEY}=20533")
}
@Test
fun `GIVEN complex deep link WHEN build THEN should construct correct URI`() {
// GIVEN

View file

@ -36,4 +36,7 @@ sealed interface FeedEntryRoute {
@Serializable
data class NewsDetail(val articleId: Int, val preselectedArticlesId: List<Int>) : FeedEntryRoute
@Serializable
data class NewsList(val preselectedCategoryId: Int? = null) : FeedEntryRoute
}

View file

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

View file

@ -125,7 +125,7 @@ internal class DefaultFeedEntryComponent @AssistedInject constructor(
}
override fun onOpenAllNews() {
innerRouter.push(FeedEntryChildFactory.Child.NewsList)
innerRouter.push(FeedEntryChildFactory.Child.NewsList())
}
override fun onOpenEarnPage() {
@ -263,6 +263,9 @@ internal class DefaultFeedEntryComponent @AssistedInject constructor(
},
),
)
is FeedEntryRoute.NewsList -> FeedEntryChildFactory.Child.NewsList(
preselectedCategoryId = entryRoute.preselectedCategoryId,
)
null -> FeedEntryChildFactory.Child.Feed
}
}

View file

@ -53,7 +53,7 @@ internal class FeedEntryChildFactory @Inject constructor(
@Serializable
@Immutable
data object NewsList : Child
data class NewsList(val preselectedCategoryId: Int? = null) : Child
@Serializable
@Immutable
@ -110,7 +110,7 @@ internal class FeedEntryChildFactory @Inject constructor(
params = child.params,
)
}
Child.NewsList -> {
is Child.NewsList -> {
DefaultNewsListComponent(
appComponentContext = appComponentContext,
params = Params(
@ -123,6 +123,7 @@ internal class FeedEntryChildFactory @Inject constructor(
)
},
onBackClick = onBackClicked,
preselectedCategoryId = child.preselectedCategoryId,
),
)
}

View file

@ -112,5 +112,6 @@ internal class DefaultNewsListComponent(
paginationConfig: NewsListConfig?,
) -> Unit,
val onBackClick: () -> Unit,
val preselectedCategoryId: Int? = null,
)
}

View file

@ -0,0 +1,35 @@
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.CATEGORY_ID_KEY
import com.tangem.common.routing.deeplink.DeeplinkConst.NEWS_ID_KEY
import com.tangem.features.feed.entry.deeplink.NewsDeepLinkHandler
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
internal class DefaultNewsDeepLinkHandler @AssistedInject constructor(
@Assisted private val queryParams: Map<String, String>,
private val appRouter: AppRouter,
) : NewsDeepLinkHandler {
init {
handleDeepLink()
}
private fun handleDeepLink() {
val newsId = queryParams[NEWS_ID_KEY]?.toIntOrNull()
if (newsId != null) {
appRouter.push(AppRoute.NewsDetails(newsId = newsId))
return
}
appRouter.push(AppRoute.News(categoryId = queryParams[CATEGORY_ID_KEY]?.toIntOrNull()))
}
@AssistedFactory
interface Factory : NewsDeepLinkHandler.Factory {
override fun create(queryParams: Map<String, String>): DefaultNewsDeepLinkHandler
}
}

View file

@ -1,6 +1,8 @@
package com.tangem.features.feed.deeplink.di
import com.tangem.features.feed.deeplink.DefaultNewsDeepLinkHandler
import com.tangem.features.feed.deeplink.DefaultNewsDetailsDeepLinkHandler
import com.tangem.features.feed.entry.deeplink.NewsDeepLinkHandler
import com.tangem.features.feed.entry.deeplink.NewsDetailsDeepLinkHandler
import dagger.Binds
import dagger.Module
@ -17,4 +19,8 @@ internal interface FeedDeepLinkModule {
fun bindNewsDetailsDeepLinkHandlerFactory(
impl: DefaultNewsDetailsDeepLinkHandler.Factory,
): NewsDetailsDeepLinkHandler.Factory
@Binds
@Singleton
fun bindNewsDeepLinkHandlerFactory(impl: DefaultNewsDeepLinkHandler.Factory): NewsDeepLinkHandler.Factory
}

View file

@ -6,6 +6,8 @@ import com.tangem.core.decompose.model.Model
import com.tangem.core.decompose.model.ParamsContainer
import com.tangem.core.ui.DesignFeatureToggles
import com.tangem.core.ui.components.chip.entity.ChipUM
import com.tangem.core.ui.event.consumedEvent
import com.tangem.core.ui.event.triggeredEvent
import com.tangem.domain.news.model.NewsListConfig
import com.tangem.domain.news.usecase.GetNewsCategoriesUseCase
import com.tangem.domain.news.usecase.GetNewsListBatchFlowUseCase
@ -39,7 +41,7 @@ internal class NewsListModel @Inject constructor(
) : Model() {
private val params = paramsContainer.require<DefaultNewsListComponent.Params>()
private val selectedCategoryId = MutableStateFlow<Int?>(null)
private val selectedCategoryId = MutableStateFlow(params.preselectedCategoryId)
private val categoriesLoader by lazy {
NewsCategoriesLoader(
@ -67,7 +69,7 @@ internal class NewsListModel @Inject constructor(
private val _state = MutableStateFlow(
NewsListUM(
selectedCategoryId = DEFAULT_ALL_NEWS_CATEGORIES_ID,
selectedCategoryId = params.preselectedCategoryId ?: DEFAULT_ALL_NEWS_CATEGORIES_ID,
filters = persistentListOf(),
newsListState = NewsListState.Loading,
listOfArticles = persistentListOf(),
@ -84,20 +86,38 @@ internal class NewsListModel @Inject constructor(
val state = _state.asStateFlow()
init {
loadCategories()
observeNewsList()
batchFlowManager.reload()
loadCategories()
}
private fun loadCategories() {
modelScope.launch(dispatchers.default) {
val filterChips = categoriesLoader.load()
val validIds = filterChips.mapTo(mutableSetOf()) { it.id }
selectedCategoryId.value = selectedCategoryId.value?.takeIf { it in validIds }
val effectiveId = selectedCategoryId.value ?: DEFAULT_ALL_NEWS_CATEGORIES_ID
val scrollIndex = filterChips.indexOfFirst { it.id == effectiveId }.takeIf { it > 0 }
_state.update { currentState ->
currentState.copy(filters = filterChips)
currentState.copy(
selectedCategoryId = effectiveId,
filters = filterChips.map { chip ->
chip.copy(isSelected = chip.id == effectiveId)
}.toImmutableList(),
scrollToCategoryEvent = if (scrollIndex != null) {
triggeredEvent(data = scrollIndex, onConsume = ::onScrollToCategoryConsumed)
} else {
currentState.scrollToCategoryEvent
},
)
}
batchFlowManager.reload()
}
}
private fun onScrollToCategoryConsumed() {
_state.update { it.copy(scrollToCategoryEvent = consumedEvent()) }
}
private fun observeNewsList() {
modelScope.launch(dispatchers.default) {
combine(
@ -111,7 +131,6 @@ internal class NewsListModel @Inject constructor(
paginationStatus = paginationStatus,
onRetryClick = {
loadCategories()
batchFlowManager.reload()
},
onLoadMore = { batchFlowManager.loadMore() },
)

View file

@ -3,6 +3,7 @@ package com.tangem.features.feed.ui.news.list
import androidx.compose.animation.core.EaseOut
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
@ -20,6 +21,7 @@ import com.tangem.core.ui.components.haze.hazeEffectTangem
import com.tangem.core.ui.components.haze.hazeSourceTangem
import com.tangem.core.ui.components.label.entity.LabelUM
import com.tangem.core.ui.ds.tabs.TangemTab
import com.tangem.core.ui.event.EventEffect
import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.res.*
import com.tangem.features.feed.ui.feed.components.articles.ArticleConfigUM
@ -50,6 +52,9 @@ internal fun NewsListContent(contentPadding: PaddingValues, state: NewsListUM, m
internal fun NewsListContentV1(contentPadding: PaddingValues, state: NewsListUM, modifier: Modifier = Modifier) {
val background = LocalMainBottomSheetColor.current.value
val lazyListState = rememberLazyListState()
val chipsListState = rememberLazyListState()
ScrollChipsToSelected(state = state, chipsListState = chipsListState)
Column(
modifier = modifier
@ -58,6 +63,7 @@ internal fun NewsListContentV1(contentPadding: PaddingValues, state: NewsListUM,
) {
SpacerH(contentPadding.calculateTopPadding())
LazyRow(
state = chipsListState,
contentPadding = PaddingValues(horizontal = 16.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
@ -84,9 +90,12 @@ internal fun NewsListContentV1(contentPadding: PaddingValues, state: NewsListUM,
internal fun NewsListContentV2(contentPadding: PaddingValues, state: NewsListUM) {
val background = LocalMainBottomSheetColor.current.value
val lazyListState = rememberLazyListState()
val chipsListState = rememberLazyListState()
var chipsHeight by remember { mutableStateOf(0.dp) }
val density = LocalDensity.current
ScrollChipsToSelected(state = state, chipsListState = chipsListState)
Box(
modifier = Modifier
.fillMaxSize()
@ -103,6 +112,7 @@ internal fun NewsListContentV2(contentPadding: PaddingValues, state: NewsListUM)
onArticleClick = state.onArticleClick,
)
LazyRow(
state = chipsListState,
modifier = Modifier
.align(Alignment.TopStart)
.padding(top = contentPadding.calculateTopPadding(), bottom = TangemTheme.dimens2.x4)
@ -141,6 +151,13 @@ internal fun NewsListContentV2(contentPadding: PaddingValues, state: NewsListUM)
}
}
@Composable
private fun ScrollChipsToSelected(state: NewsListUM, chipsListState: LazyListState) {
EventEffect(event = state.scrollToCategoryEvent) { index ->
chipsListState.animateScrollToItem(index)
}
}
@Suppress("LongMethod")
@Preview(showBackground = true)
@Composable

View file

@ -1,8 +1,10 @@
package com.tangem.features.feed.ui.news.list.state
import androidx.compose.runtime.Immutable
import com.tangem.features.feed.ui.feed.components.articles.ArticleConfigUM
import com.tangem.core.ui.components.chip.entity.ChipUM
import com.tangem.core.ui.event.StateEvent
import com.tangem.core.ui.event.consumedEvent
import com.tangem.features.feed.ui.feed.components.articles.ArticleConfigUM
import kotlinx.collections.immutable.ImmutableList
@Immutable
@ -13,6 +15,7 @@ data class NewsListUM(
val newsListState: NewsListState,
val onArticleClick: (Int) -> Unit,
val onBackClick: () -> Unit,
val scrollToCategoryEvent: StateEvent<Int> = consumedEvent(),
)
@Immutable

View file

@ -0,0 +1,80 @@
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.CATEGORY_ID_KEY
import com.tangem.common.routing.deeplink.DeeplinkConst.NEWS_ID_KEY
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 DefaultNewsDeepLinkHandlerTest {
private val appRouter: AppRouter = mockk()
@BeforeEach
fun setUp() {
every { appRouter.push(any(), any()) } just Runs
}
@Test
fun `no params opens news list with null category`() {
DefaultNewsDeepLinkHandler(queryParams = emptyMap(), appRouter = appRouter)
verify { appRouter.push(AppRoute.News(categoryId = null), any()) }
}
@Test
fun `valid categoryId opens news list with that category`() {
DefaultNewsDeepLinkHandler(
queryParams = mapOf(CATEGORY_ID_KEY to "5"),
appRouter = appRouter,
)
verify { appRouter.push(AppRoute.News(categoryId = 5), any()) }
}
@Test
fun `unknown categoryId is forwarded — sanitized in NewsListModel against loaded chips`() {
DefaultNewsDeepLinkHandler(
queryParams = mapOf(CATEGORY_ID_KEY to "42"),
appRouter = appRouter,
)
verify { appRouter.push(AppRoute.News(categoryId = 42), any()) }
}
@Test
fun `non-integer categoryId is silently dropped`() {
DefaultNewsDeepLinkHandler(
queryParams = mapOf(CATEGORY_ID_KEY to "not-a-number"),
appRouter = appRouter,
)
verify { appRouter.push(AppRoute.News(categoryId = null), any()) }
}
@Test
fun `newsId opens news details`() {
DefaultNewsDeepLinkHandler(
queryParams = mapOf(NEWS_ID_KEY to "20533"),
appRouter = appRouter,
)
verify { appRouter.push(AppRoute.NewsDetails(newsId = 20533), any()) }
}
@Test
fun `newsId takes priority over categoryId`() {
DefaultNewsDeepLinkHandler(
queryParams = mapOf(NEWS_ID_KEY to "20533", CATEGORY_ID_KEY to "5"),
appRouter = appRouter,
)
verify { appRouter.push(AppRoute.NewsDetails(newsId = 20533), any()) }
}
}