Updated on 2026-08-14

This commit is contained in:
Tangem 2025-11-18 08:47:52 +01:00
parent 9105ffbd75
commit dcb8b474cf
20 changed files with 550 additions and 8 deletions

View file

@ -54,5 +54,9 @@
{
"name": "ACCOUNTS_FEATURE_ENABLED",
"version": "undefined"
},
{
"name": "FEED_ENABLED",
"version": "undefined"
}
]

View file

@ -29,6 +29,7 @@ sealed class ApiConfig {
BlockAid,
YieldSupply,
MoonPay,
News,
}
private fun initializeId(): ID {
@ -41,6 +42,7 @@ sealed class ApiConfig {
is BlockAid -> ID.BlockAid
is YieldSupply -> ID.YieldSupply
is MoonPay -> ID.MoonPay
is News -> ID.News
}
}

View file

@ -0,0 +1,62 @@
package com.tangem.datasource.api.common.config
import com.tangem.datasource.BuildConfig
import com.tangem.datasource.api.common.AuthProvider
import com.tangem.datasource.utils.RequestHeader
import com.tangem.utils.Provider
/**
* News [ApiConfig]
[REDACTED_AUTHOR]
*/
internal class News(
private val authProvider: AuthProvider,
) : ApiConfig() {
override val defaultEnvironment: ApiEnvironment = getInitialEnvironment()
override val environmentConfigs: List<ApiEnvironmentConfig> = listOf(
createProdEnvironment(),
createDevEnvironment(),
)
private fun getInitialEnvironment(): ApiEnvironment {
return when (BuildConfig.BUILD_TYPE) {
MOCKED_BUILD_TYPE,
DEBUG_BUILD_TYPE,
-> ApiEnvironment.DEV
INTERNAL_BUILD_TYPE,
EXTERNAL_BUILD_TYPE,
RELEASE_BUILD_TYPE,
-> ApiEnvironment.PROD
else -> error("Unknown build type [${BuildConfig.BUILD_TYPE}]")
}
}
private fun createProdEnvironment(): ApiEnvironmentConfig = ApiEnvironmentConfig(
environment = ApiEnvironment.PROD,
baseUrl = PROD_BASE_URL,
headers = createHeaders(ApiEnvironment.PROD),
)
private fun createDevEnvironment(): ApiEnvironmentConfig = ApiEnvironmentConfig(
environment = ApiEnvironment.DEV,
baseUrl = DEV_BASE_URL,
headers = createHeaders(ApiEnvironment.DEV),
)
private fun createHeaders(environment: ApiEnvironment) = buildMap {
putAll(
RequestHeader.TangemApiKeyHeader(
authProvider = authProvider,
apiEnvironment = Provider { environment },
).values,
)
}
private companion object {
private const val PROD_BASE_URL = "https://api.tangem.org/"
private const val DEV_BASE_URL = "[REDACTED_ENV_URL]"
}
}

View file

@ -0,0 +1,43 @@
package com.tangem.datasource.api.news
import com.tangem.datasource.api.common.response.ApiResponse
import com.tangem.datasource.api.news.models.response.NewsCategoriesResponse
import com.tangem.datasource.api.news.models.response.NewsDetailsResponse
import com.tangem.datasource.api.news.models.response.NewsListResponse
import com.tangem.datasource.api.news.models.response.NewsTrendingResponse
import retrofit2.http.GET
import retrofit2.http.Path
import retrofit2.http.Query
interface NewsApi {
@GET(NEWS_PATH)
suspend fun getNews(
@Query("page") page: Int? = null,
@Query("limit") limit: Int? = null,
@Query("lang") language: String? = null,
@Query("asOf") snapshot: String? = null,
@Query("tokenIds") tokenIds: List<String>? = null,
@Query("categoryIds") categoryIds: List<Int>? = null,
): ApiResponse<NewsListResponse>
@GET("$NEWS_PATH/{newsId}")
suspend fun getNewsDetails(
@Path("newsId") newsId: Int,
@Query("lang") language: String? = null,
): ApiResponse<NewsDetailsResponse>
@GET("$NEWS_PATH/trending")
suspend fun getTrendingNews(
@Query("limit") limit: Int? = null,
@Query("lang") language: String? = null,
): ApiResponse<NewsTrendingResponse>
@GET("$NEWS_PATH/categories")
suspend fun getCategories(): ApiResponse<NewsCategoriesResponse>
private companion object {
private const val NEWS_PATH = "api/v1/news"
}
}

View file

@ -0,0 +1,41 @@
package com.tangem.datasource.api.news.models.response
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
@JsonClass(generateAdapter = true)
data class NewsArticleDto(
@Json(name = "id") val id: Int,
@Json(name = "createdAt") val createdAt: String,
@Json(name = "score") val score: Double,
@Json(name = "language") val language: String,
@Json(name = "isTrending") val isTrending: Boolean,
@Json(name = "categories") val categories: List<NewsCategoryDto>,
@Json(name = "relatedTokens") val relatedTokens: List<NewsRelatedTokenDto>,
@Json(name = "title") val title: String,
@Json(name = "newsUrl") val newsUrl: String,
)
@JsonClass(generateAdapter = true)
data class NewsCategoryDto(
@Json(name = "id") val id: Int,
@Json(name = "name") val name: String,
)
@JsonClass(generateAdapter = true)
data class NewsRelatedTokenDto(
@Json(name = "id") val id: String,
@Json(name = "symbol") val symbol: String,
@Json(name = "name") val name: String,
)
@JsonClass(generateAdapter = true)
data class NewsOriginalArticleDto(
@Json(name = "id") val id: Int,
@Json(name = "title") val title: String,
@Json(name = "sourceName") val sourceName: String,
@Json(name = "language") val language: String,
@Json(name = "publishedAt") val publishedAt: String,
@Json(name = "url") val url: String,
@Json(name = "imageUrl") val imageUrl: String? = null,
)

View file

@ -0,0 +1,9 @@
package com.tangem.datasource.api.news.models.response
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
@JsonClass(generateAdapter = true)
data class NewsCategoriesResponse(
@Json(name = "items") val items: List<NewsCategoryDto>,
)

View file

@ -0,0 +1,20 @@
package com.tangem.datasource.api.news.models.response
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
@JsonClass(generateAdapter = true)
data class NewsDetailsResponse(
@Json(name = "id") val id: Int,
@Json(name = "createdAt") val createdAt: String,
@Json(name = "score") val score: Double,
@Json(name = "language") val language: String,
@Json(name = "isTrending") val isTrending: Boolean,
@Json(name = "categories") val categories: List<NewsCategoryDto>,
@Json(name = "relatedTokens") val relatedTokens: List<NewsRelatedTokenDto>,
@Json(name = "title") val title: String,
@Json(name = "newsUrl") val newsUrl: String,
@Json(name = "shortContent") val shortContent: String,
@Json(name = "content") val content: String,
@Json(name = "originalArticles") val originalArticles: List<NewsOriginalArticleDto>,
)

View file

@ -0,0 +1,19 @@
package com.tangem.datasource.api.news.models.response
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
@JsonClass(generateAdapter = true)
data class NewsListResponse(
@Json(name = "meta") val meta: NewsListMetaDto,
@Json(name = "items") val items: List<NewsArticleDto>,
)
@JsonClass(generateAdapter = true)
data class NewsListMetaDto(
@Json(name = "page") val page: Int,
@Json(name = "limit") val limit: Int,
@Json(name = "total") val total: Long,
@Json(name = "hasNext") val hasNext: Boolean,
@Json(name = "asOf") val asOf: String,
)

View file

@ -0,0 +1,15 @@
package com.tangem.datasource.api.news.models.response
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
@JsonClass(generateAdapter = true)
data class NewsTrendingResponse(
@Json(name = "meta") val meta: NewsTrendingMetaDto,
@Json(name = "items") val items: List<NewsArticleDto>,
)
@JsonClass(generateAdapter = true)
data class NewsTrendingMetaDto(
@Json(name = "limit") val limit: Int,
)

View file

@ -58,6 +58,10 @@ internal object ApiConfigsModule {
appInfoProvider = appInfoProvider,
)
@Provides
@IntoSet
fun provideNewsConfig(authProvider: AuthProvider): ApiConfig = News(authProvider = authProvider)
@Provides
@IntoSet
fun provideYieldSupplyConfig(

View file

@ -13,6 +13,7 @@ import com.tangem.datasource.api.common.config.managers.ProdApiConfigsManager
import com.tangem.datasource.api.express.TangemExpressApi
import com.tangem.datasource.api.markets.TangemTechMarketsApi
import com.tangem.datasource.api.moonpay.MoonPayApi
import com.tangem.datasource.api.news.NewsApi
import com.tangem.datasource.api.onramp.OnrampApi
import com.tangem.datasource.api.ethpool.P2PEthPoolApi
import com.tangem.datasource.api.pay.TangemPayApi
@ -151,4 +152,13 @@ internal object NetworkModule {
applyTimeoutAnnotations = false,
)
}
@Provides
@Singleton
fun provideNewsApi(retrofitApiBuilder: RetrofitApiBuilder): NewsApi {
return retrofitApiBuilder.build(
apiConfigId = ApiConfig.ID.News,
applyTimeoutAnnotations = false,
)
}
}

View file

@ -73,6 +73,7 @@ class ApiConfigTest {
ApiConfig.ID.BlockAid -> BlockAid(configStorage = mockk())
ApiConfig.ID.MoonPay -> MoonPay()
ApiConfig.ID.P2PEthPool -> P2PEthPool(p2pAuthProvider = mockk())
ApiConfig.ID.News -> News(authProvider = appAuthProvider)
}
}
}

View file

@ -114,6 +114,7 @@ internal class ProdApiConfigsManagerTest {
ApiConfig.ID.BlockAid -> BlockAid(configStorage = environmentConfigStorage)
ApiConfig.ID.MoonPay -> MoonPay()
ApiConfig.ID.P2PEthPool -> P2PEthPool(p2pAuthProvider = p2pEthPoolAuthProvider)
ApiConfig.ID.News -> News(authProvider = appAuthProvider)
}
}
}
@ -128,6 +129,7 @@ internal class ProdApiConfigsManagerTest {
ApiConfig.ID.BlockAid -> createBlockAidSdkModel()
ApiConfig.ID.MoonPay -> createMoonPayModel()
ApiConfig.ID.P2PEthPool -> createP2PModel()
ApiConfig.ID.News -> createNewsModel()
}
}
@ -295,6 +297,29 @@ internal class ProdApiConfigsManagerTest {
)
}
private fun createNewsModel(): TestModel {
val (environment, baseUrl) = when (BuildConfig.BUILD_TYPE) {
MOCKED_BUILD_TYPE,
DEBUG_BUILD_TYPE,
-> ApiEnvironment.DEV to "[REDACTED_ENV_URL]"
INTERNAL_BUILD_TYPE,
EXTERNAL_BUILD_TYPE,
RELEASE_BUILD_TYPE,
-> ApiEnvironment.PROD to "https://tangem.com/"
else -> error("Unknown build type [${BuildConfig.BUILD_TYPE}]")
}
return TestModel(
id = ApiConfig.ID.News,
expected = ApiEnvironmentConfig(
environment = environment,
baseUrl = baseUrl,
headers = mapOf(
"api-key" to ProviderSuspend { TANGEM_API_KEY },
),
),
)
}
private fun String.checkHeaderValueOrEmpty(): String {
for (i in this.indices) {
val c = this[i]

View file

@ -1,7 +1,9 @@
package com.tangem.data.news.di
import com.tangem.data.news.repository.DefaultNewsRepository
import com.tangem.datasource.api.news.NewsApi
import com.tangem.domain.news.repository.NewsRepository
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
@ -14,7 +16,10 @@ internal object NewsDataModule {
@Singleton
@Provides
fun providesQuotesRepository(): NewsRepository {
return DefaultNewsRepository()
fun provideNewsRepository(newsApi: NewsApi, dispatchers: CoroutineDispatcherProvider): NewsRepository {
return DefaultNewsRepository(
newsApi = newsApi,
dispatchers = dispatchers,
)
}
}

View file

@ -1,10 +1,170 @@
package com.tangem.data.news.repository
import com.tangem.datasource.api.common.response.getOrThrow
import com.tangem.datasource.api.news.NewsApi
import com.tangem.domain.news.model.NewsListBatchFlow
import com.tangem.domain.news.model.NewsListBatchingContext
import com.tangem.domain.news.model.NewsListConfig
import com.tangem.domain.news.models.ArticleCategory
import com.tangem.domain.news.models.DetailedArticle
import com.tangem.domain.news.models.ShortArticle
import com.tangem.domain.news.repository.NewsRepository
import com.tangem.pagination.BatchFetchResult
import com.tangem.pagination.BatchListSource
import com.tangem.pagination.exception.EndOfPaginationException
import com.tangem.pagination.fetcher.BatchFetcher
import com.tangem.pagination.toBatchFlow
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import javax.inject.Inject
/**
* Default implementation of [NewsRepository]
*
* Implementation of [NewsRepository].
[REDACTED_AUTHOR]
*/
internal class DefaultNewsRepository : NewsRepository
internal class DefaultNewsRepository @Inject constructor(
private val newsApi: NewsApi,
private val dispatchers: CoroutineDispatcherProvider,
) : NewsRepository {
override fun getNewsListBatchFlow(context: NewsListBatchingContext, batchSize: Int): NewsListBatchFlow {
return BatchListSource(
fetchDispatcher = dispatchers.io,
context = context,
generateNewKey = { keys -> keys.lastOrNull()?.inc() ?: INITIAL_BATCH_KEY },
batchFetcher = createBatchFetcher(batchSize),
).toBatchFlow()
}
override suspend fun getDetailedArticle(newsId: Int, language: String?): DetailedArticle {
val response = newsApi.getNewsDetails(newsId = newsId, language = language).getOrThrow()
return response.toDomainDetailedArticle()
}
override suspend fun getTrendingNews(limit: Int, language: String?): List<ShortArticle> {
val response = newsApi.getTrendingNews(limit = limit, language = language).getOrThrow()
return response.items.map { it.toDomainShortArticle() }
}
override suspend fun getCategories(): List<ArticleCategory> {
return newsApi.getCategories().getOrThrow().items.map { dto ->
ArticleCategory(
id = dto.id,
name = dto.name,
)
}
}
private fun createBatchFetcher(batchSize: Int): BatchFetcher<NewsListConfig, List<ShortArticle>> {
return NewsBatchFetcher(
newsApi = newsApi,
batchSize = batchSize,
)
}
private class NewsBatchFetcher(
private val newsApi: NewsApi,
private val batchSize: Int,
) : BatchFetcher<NewsListConfig, List<ShortArticle>> {
private var state: NewsPaginationState? = null
override suspend fun fetchFirst(requestParams: NewsListConfig): BatchFetchResult<List<ShortArticle>> {
return runCatching {
loadPage(
page = FIRST_PAGE,
params = requestParams,
limit = batchSize,
snapshotOverride = requestParams.snapshot,
)
}.fold(
onSuccess = { result ->
state = result.state
result.batchResult
},
onFailure = { throwable -> BatchFetchResult.Error(throwable) },
)
}
override suspend fun fetchNext(
overrideRequestParams: NewsListConfig?,
lastResult: BatchFetchResult<List<ShortArticle>>,
): BatchFetchResult<List<ShortArticle>> {
val currentState = state ?: return BatchFetchResult.Error(
IllegalStateException("fetchFirst must be called"),
)
if (lastResult is BatchFetchResult.Success && lastResult.last && overrideRequestParams == null) {
return BatchFetchResult.Error(EndOfPaginationException())
}
val params = overrideRequestParams ?: currentState.params
val shouldReset = overrideRequestParams != null && overrideRequestParams != currentState.params
val pageToLoad = if (shouldReset) FIRST_PAGE else currentState.nextPage
val snapshot = if (shouldReset) overrideRequestParams.snapshot else currentState.snapshot
return runCatching {
loadPage(
page = pageToLoad,
params = params,
limit = batchSize,
snapshotOverride = snapshot,
)
}.fold(
onSuccess = { result ->
state = result.state
result.batchResult
},
onFailure = { throwable -> BatchFetchResult.Error(throwable) },
)
}
private suspend fun loadPage(
page: Int,
params: NewsListConfig,
limit: Int,
snapshotOverride: String?,
): PageLoadResult {
val response = newsApi.getNews(
page = page,
limit = limit,
language = params.language,
snapshot = snapshotOverride,
tokenIds = params.tokenIds.takeIf { it.isNotEmpty() },
categoryIds = params.categoryIds.takeIf { it.isNotEmpty() },
).getOrThrow()
val items = response.items.map { it.toDomainShortArticle() }
val batchResult = BatchFetchResult.Success(
data = items,
empty = items.isEmpty(),
last = response.meta.hasNext.not(),
)
return PageLoadResult(
batchResult = batchResult,
state = NewsPaginationState(
nextPage = response.meta.page + 1,
snapshot = response.meta.asOf,
params = params.copy(snapshot = response.meta.asOf),
),
)
}
}
private data class PageLoadResult(
val batchResult: BatchFetchResult.Success<List<ShortArticle>>,
val state: NewsPaginationState,
)
private data class NewsPaginationState(
val nextPage: Int,
val snapshot: String?,
val params: NewsListConfig,
)
private companion object {
private const val INITIAL_BATCH_KEY = 0
private const val FIRST_PAGE = 1
}
}

View file

@ -0,0 +1,58 @@
package com.tangem.data.news.repository
import com.tangem.datasource.api.news.models.response.NewsArticleDto
import com.tangem.datasource.api.news.models.response.NewsDetailsResponse
import com.tangem.datasource.api.news.models.response.NewsOriginalArticleDto
import com.tangem.datasource.api.news.models.response.NewsRelatedTokenDto
import com.tangem.domain.news.models.*
internal fun NewsDetailsResponse.toDomainDetailedArticle(): DetailedArticle {
return DetailedArticle(
id = id,
createdAt = createdAt,
score = score.toFloat(),
locale = language,
isTrending = isTrending,
categories = categories.map { ArticleCategory(id = it.id, name = it.name) },
relatedTokens = relatedTokens.map { it.toDomainRelatedToken() },
title = title,
newsUrl = newsUrl,
shortContent = shortContent,
content = content,
originalArticles = originalArticles.map { it.toDomainOriginalArticle() },
)
}
internal fun NewsArticleDto.toDomainShortArticle(): ShortArticle {
return ShortArticle(
id = id,
createdAt = createdAt,
score = score.toFloat(),
locale = language,
categories = categories.map { ArticleCategory(id = it.id, name = it.name) },
relatedTokens = relatedTokens.map { it.toDomainRelatedToken() },
isTrending = isTrending,
title = title,
newsUrl = newsUrl,
)
}
internal fun NewsRelatedTokenDto.toDomainRelatedToken(): RelatedToken {
return RelatedToken(
id = id,
symbol = symbol,
name = name,
)
}
internal fun NewsOriginalArticleDto.toDomainOriginalArticle(): OriginalArticle {
return OriginalArticle(
id = id,
title = title,
sourceName = sourceName,
locale = language,
publishedAt = publishedAt,
url = url,
imageUrl = imageUrl,
)
}

View file

@ -7,5 +7,6 @@ plugins {
dependencies {
api(projects.domain.core)
api(projects.domain.models)
api(projects.core.pagination)
implementation(deps.kotlin.serialization)
}

View file

@ -0,0 +1,21 @@
package com.tangem.domain.news.model
import kotlinx.serialization.Serializable
/**
* Represents config for request of news list.
*
[REDACTED_AUTHOR]
*
* @param language device locale (ex: en, ru).
* @param snapshot id snapshot (`meta.asOf`) to stabilize responses.
* @param tokenIds filter by tokens.
* @param categoryIds filter by category.
*/
@Serializable
data class NewsListConfig(
val language: String,
val snapshot: String,
val tokenIds: List<String> = emptyList(),
val categoryIds: List<Int> = emptyList(),
)

View file

@ -0,0 +1,9 @@
package com.tangem.domain.news.model
import com.tangem.domain.news.models.ShortArticle
import com.tangem.pagination.BatchFlow
import com.tangem.pagination.BatchingContext
typealias NewsListBatchingContext = BatchingContext<Int, NewsListConfig, Nothing>
typealias NewsListBatchFlow = BatchFlow<Int, List<ShortArticle>, Nothing>

View file

@ -1,8 +1,41 @@
package com.tangem.domain.news.repository
import com.tangem.domain.news.model.NewsListBatchFlow
import com.tangem.domain.news.model.NewsListBatchingContext
import com.tangem.domain.news.models.ArticleCategory
import com.tangem.domain.news.models.DetailedArticle
import com.tangem.domain.news.models.ShortArticle
/**
* News repository
*
[REDACTED_AUTHOR]
*/
interface NewsRepository
interface NewsRepository {
/**
* Returns flow of batch with pagination handling.
*
* @param context pagination context
* @param batchSize page capacity
*/
fun getNewsListBatchFlow(context: NewsListBatchingContext, batchSize: Int): NewsListBatchFlow
/**
* Returns detailed article by id with locale configuration.
* @param newsId news identification
* @param language current locale
*/
suspend fun getDetailedArticle(newsId: Int, language: String?): DetailedArticle
/**
* Returns list of trending news by limit and with correct locale.
*
* @param limit
* @param language current device locale
*/
suspend fun getTrendingNews(limit: Int, language: String?): List<ShortArticle>
/**
* Returns available categories.
*/
suspend fun getCategories(): List<ArticleCategory>
}