Updated on 2026-08-14

This commit is contained in:
Tangem 2026-07-13 10:02:00 +02:00
parent 8b49dc2f7f
commit 6fdbff6fa8
11 changed files with 379 additions and 44 deletions

View file

@ -4,6 +4,7 @@ import com.tangem.domain.marketing.DismissMarketingBannerUseCase
import com.tangem.domain.marketing.GetMarketingBannerUseCase
import com.tangem.domain.marketing.MarketingFeatureToggles
import com.tangem.domain.marketing.MarketingRepository
import com.tangem.domain.marketing.WarmUpMarketingCampaignsUseCase
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
@ -25,4 +26,11 @@ internal object MarketingDomainModule {
@Singleton
fun provideDismissMarketingBannerUseCase(repository: MarketingRepository): DismissMarketingBannerUseCase =
DismissMarketingBannerUseCase(repository)
@Provides
@Singleton
fun provideWarmUpMarketingCampaignsUseCase(
repository: MarketingRepository,
featureToggles: MarketingFeatureToggles,
): WarmUpMarketingCampaignsUseCase = WarmUpMarketingCampaignsUseCase(repository, featureToggles)
}

View file

@ -12,9 +12,15 @@ import com.tangem.datasource.api.tangemTech.TangemTechApi
import com.tangem.domain.marketing.MarketingRepository
import com.tangem.domain.marketing.models.MarketingCampaign
import com.tangem.domain.marketing.models.MarketingScreen
import com.tangem.domain.marketing.models.MarketingScreenType
import com.tangem.utils.SupportedLanguages
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import com.tangem.utils.logging.TangemLogger
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
internal class DefaultMarketingRepository(
@ -25,25 +31,34 @@ internal class DefaultMarketingRepository(
private val dispatchers: CoroutineDispatcherProvider,
) : MarketingRepository {
// In-memory per-session cache for background (cacheable) types. Serves repeated reads within a session
// without hitting the network; DataStore ETag cache remains the cross-session layer inside fetchAndCacheByType.
private val sessionCache = MutableStateFlow<Map<MarketingScreenType, List<MarketingCampaign>>>(emptyMap())
private val cacheMutex = Mutex()
override suspend fun getCampaigns(screen: MarketingScreen): Either<Throwable, List<MarketingCampaign>> =
withContext(dispatchers.io) {
Either.catch {
val isCacheable = screen.type.isCacheable
val cached = if (isCacheable) cacheStore.get(screen.type.value) else null
if (screen.type.isCacheable) {
loadCacheableByType(screen.type)
} else {
// swap/onramp — always fresh, never cached
when (val response = requestCampaigns(screen, eTag = null)) {
is ApiResponse.Success -> convert(response.data)
is ApiResponse.Error -> emptyList()
}
}
}
}
when (val response = requestCampaigns(screen, eTag = cached?.eTag)) {
is ApiResponse.Success -> {
if (isCacheable) {
// eTag may be null if the server omits it; we still cache the body for the 5xx
// fallback path. A null eTag simply means the next request sends no If-None-Match
// (Retrofit omits null headers) and receives a fresh 200.
val eTag = response.headers[ETAG_HEADER]?.firstOrNull()
cacheStore.store(screen.type.value, MarketingCampaignsCacheEntry(eTag, response.data))
}
convert(response.data)
}
is ApiResponse.Error -> handleError(cached)
}
override suspend fun prefetchBackgroundCampaigns(type: MarketingScreenType) {
if (!type.isCacheable) return
try {
loadCacheableByType(type)
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
// Fire-and-forget warm-up: failures are non-fatal, the next getCampaigns() call will retry.
}
}
@ -51,6 +66,47 @@ internal class DefaultMarketingRepository(
override suspend fun dismissBanner(campaignId: Int) = dismissStore.dismiss(campaignId)
private suspend fun loadCacheableByType(type: MarketingScreenType): List<MarketingCampaign> {
sessionCache.value[type]?.let { return it }
return cacheMutex.withLock {
sessionCache.value[type]?.let { return@withLock it } // double-check under lock
val result = fetchAndCacheByType(type)
// Only cache authoritative results. A pure error fallback (error + no DataStore cache -> null)
// must NOT poison the session cache, so a later screen open still retries the network.
if (result != null) {
sessionCache.update { it + (type to result) }
}
result.orEmpty()
}
}
private suspend fun fetchAndCacheByType(type: MarketingScreenType): List<MarketingCampaign>? {
val cached = cacheStore.get(type.value)
return when (val response = requestByType(type, eTag = cached?.eTag)) {
is ApiResponse.Success -> {
// eTag may be null if the server omits it; we still cache the body for the 5xx
// fallback path. A null eTag simply means the next request sends no If-None-Match
// (Retrofit omits null headers) and receives a fresh 200.
val eTag = response.headers[ETAG_HEADER]?.firstOrNull()
cacheStore.store(type.value, MarketingCampaignsCacheEntry(eTag, response.data))
convert(response.data) // authoritative (may be empty = real "no banners")
}
// Cached fallback is authoritative-ish; null when there is nothing cached (do not session-cache).
is ApiResponse.Error -> cached?.response?.let(::convert)
}
}
private suspend fun requestByType(
type: MarketingScreenType,
eTag: String?,
): ApiResponse<MarketingCampaignsResponse> {
return tangemTechApi.getMarketingCampaigns(
type = type.value,
language = SupportedLanguages.getCurrentSupportedLanguageCode(),
eTag = eTag,
)
}
private suspend fun requestCampaigns(
screen: MarketingScreen,
eTag: String?,
@ -77,19 +133,10 @@ internal class DefaultMarketingRepository(
is MarketingScreen.TokenMarkets,
is MarketingScreen.Staking,
is MarketingScreen.Yield,
-> tangemTechApi.getMarketingCampaigns(type = screen.type.value, language = language, eTag = eTag)
-> requestByType(screen.type, eTag)
}
}
/**
* All error cases (304 not-modified, 5xx, network failure) degrade gracefully to the cached
* response. Returning an empty list when there is no cache is intentional "no banner" is a
* normal state, not an error the caller needs to handle.
*/
private fun handleError(cached: MarketingCampaignsCacheEntry?): List<MarketingCampaign> {
return cached?.response?.let(::convert).orEmpty()
}
private fun convert(response: MarketingCampaignsResponse): List<MarketingCampaign> =
converter.convertListIgnoreErrors(response.campaigns) { throwable ->
TangemLogger.w("Skipped invalid marketing campaign: ${throwable.message}")

View file

@ -14,12 +14,16 @@ import com.tangem.datasource.api.marketing.models.MarketingCampaignsCacheEntry
import com.tangem.datasource.api.marketing.models.MarketingCampaignsResponse
import com.tangem.datasource.api.tangemTech.TangemTechApi
import com.tangem.domain.marketing.models.MarketingScreen
import com.tangem.domain.marketing.models.MarketingScreenType
import com.tangem.utils.SupportedLanguages
import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider
import io.mockk.clearMocks
import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.mockk
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.async
import kotlinx.coroutines.test.runCurrent
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
@ -34,17 +38,20 @@ internal class DefaultMarketingRepositoryTest {
private val language = SupportedLanguages.getCurrentSupportedLanguageCode()
private val repository = DefaultMarketingRepository(
// Recreated per test (not a val): DefaultMarketingRepository now holds mutable in-memory session-cache
// state, which would otherwise leak between tests sharing this PER_CLASS instance.
private lateinit var repository: DefaultMarketingRepository
@BeforeEach
fun reset() {
clearMocks(tangemTechApi, cacheStore, dismissStore)
repository = DefaultMarketingRepository(
tangemTechApi = tangemTechApi,
cacheStore = cacheStore,
dismissStore = dismissStore,
converter = MarketingCampaignConverter(),
dispatchers = TestingCoroutineDispatcherProvider(),
)
@BeforeEach
fun reset() {
clearMocks(tangemTechApi, cacheStore, dismissStore)
}
private fun response(id: Int) = MarketingCampaignsResponse(
@ -118,6 +125,24 @@ internal class DefaultMarketingRepositoryTest {
assertThat(result.getOrNull()).isEmpty()
}
@Test
fun `GIVEN 5xx without cache WHEN getCampaigns twice THEN not session-cached and retried`() = runTest {
// Arrange
coEvery { cacheStore.get("staking") } returns null
coEvery { tangemTechApi.getMarketingCampaigns(type = "staking", language = language, eTag = null) } returns
httpError(Code.SERVICE_UNAVAILABLE)
val screen = MarketingScreen.Staking(networkId = "ethereum", contractAddress = "0x")
// Act
val first = repository.getCampaigns(screen)
val second = repository.getCampaigns(screen)
// Assert
assertThat(first.getOrNull()).isEmpty()
assertThat(second.getOrNull()).isEmpty()
coVerify(exactly = 2) { tangemTechApi.getMarketingCampaigns(type = "staking", language = language, eTag = null) }
}
@Test
fun `GIVEN swap screen WHEN getCampaigns THEN sends pair params and does not touch cache`() = runTest {
// Arrange
@ -143,6 +168,89 @@ internal class DefaultMarketingRepositoryTest {
coVerify(exactly = 0) { cacheStore.store(any(), any()) }
}
@Test
fun `GIVEN cached in session WHEN getCampaigns twice THEN api called once`() = runTest {
// Arrange
coEvery { cacheStore.get("token_details") } returns null
coEvery { tangemTechApi.getMarketingCampaigns(type = "token_details", language = language, eTag = null) } returns
ApiResponse.Success(data = response(id = 7))
// Act
val screen = MarketingScreen.TokenDetails(networkId = "ethereum", contractAddress = "0x")
val first = repository.getCampaigns(screen)
val second = repository.getCampaigns(screen)
// Assert
assertThat(first.getOrNull()!!.map { it.id }).containsExactly(7)
assertThat(second.getOrNull()!!.map { it.id }).containsExactly(7)
coVerify(exactly = 1) { tangemTechApi.getMarketingCampaigns(type = "token_details", language = language, eTag = null) }
}
@Test
fun `GIVEN two concurrent getCampaigns for same type WHEN both in flight THEN api called once`() = runTest {
// Arrange
val gate = CompletableDeferred<Unit>()
coEvery { cacheStore.get("token_details") } returns null
coEvery { tangemTechApi.getMarketingCampaigns(type = "token_details", language = language, eTag = null) } coAnswers {
gate.await() // first caller suspends inside the lock, second blocks on the mutex
ApiResponse.Success(data = response(id = 7))
}
val screen = MarketingScreen.TokenDetails(networkId = "ethereum", contractAddress = "0x")
// Act — launch both before either completes, then release the API
val a = async { repository.getCampaigns(screen) }
val b = async { repository.getCampaigns(screen) }
runCurrent()
gate.complete(Unit)
val first = a.await()
val second = b.await()
// Assert
assertThat(first.getOrNull()!!.map { it.id }).containsExactly(7)
assertThat(second.getOrNull()!!.map { it.id }).containsExactly(7)
coVerify(exactly = 1) { tangemTechApi.getMarketingCampaigns(type = "token_details", language = language, eTag = null) }
}
@Test
fun `GIVEN prefetch WHEN getCampaigns THEN served from session cache without extra api call`() = runTest {
// Arrange
coEvery { cacheStore.get("markets_token") } returns null
coEvery { tangemTechApi.getMarketingCampaigns(type = "markets_token", language = language, eTag = null) } returns
ApiResponse.Success(data = response(id = 3))
// Act
repository.prefetchBackgroundCampaigns(MarketingScreenType.TOKEN_MARKETS)
val result = repository.getCampaigns(MarketingScreen.TokenMarkets(coingeckoId = "id"))
// Assert
assertThat(result.getOrNull()!!.map { it.id }).containsExactly(3)
coVerify(exactly = 1) { tangemTechApi.getMarketingCampaigns(type = "markets_token", language = language, eTag = null) }
}
@Test
fun `GIVEN swap WHEN getCampaigns twice THEN never session-cached (api called each time)`() = runTest {
// Arrange
val swap = MarketingScreen.Swap("eth", "0xF", "btc", "0xT")
coEvery {
tangemTechApi.getMarketingCampaigns(
type = "swap", language = language,
fromNetwork = "eth", fromContractAddress = "0xF", toNetwork = "btc", toContractAddress = "0xT",
)
} returns ApiResponse.Success(data = response(id = 1))
// Act
repository.getCampaigns(swap)
repository.getCampaigns(swap)
// Assert
coVerify(exactly = 2) {
tangemTechApi.getMarketingCampaigns(
type = "swap", language = language,
fromNetwork = "eth", fromContractAddress = "0xF", toNetwork = "btc", toContractAddress = "0xT",
)
}
}
@Test
fun `GIVEN dismiss WHEN dismissBanner THEN delegates to dismiss store`() = runTest {
// Act

View file

@ -0,0 +1,16 @@
package com.tangem.domain.marketing.models
import java.math.BigDecimal
/**
* USD min/max eligibility gate. Applies only to swap/onramp campaigns and only when [amountUsd] is known;
* otherwise the campaign passes (non-amount screens and the "amount unknown" case are not gated).
*/
fun MarketingCampaign.matchesUsdAmount(amountUsd: BigDecimal?): Boolean {
val isAmountScreen = type == MarketingScreenType.SWAP || type == MarketingScreenType.ONRAMP
if (!isAmountScreen || amountUsd == null) return true
if (minAmount != null && amountUsd < minAmount) return false
if (maxAmount != null && amountUsd > maxAmount) return false
return true
}

View file

@ -0,0 +1,64 @@
package com.tangem.domain.marketing.models
import com.google.common.truth.Truth.assertThat
import org.junit.jupiter.api.Test
import java.math.BigDecimal
internal class MarketingCampaignAmountTest {
private fun campaign(
type: MarketingScreenType,
minAmount: BigDecimal? = null,
maxAmount: BigDecimal? = null,
) = MarketingCampaign(
id = 1, type = type, priority = 1, startAt = null, endAt = null,
minAmount = minAmount, maxAmount = maxAmount, providerIds = null,
banner = MarketingBanner(
uiType = MarketingBanner.UiType.STANDALONE, text = "t", iconUrl = null,
iconAlign = null, bgColor = null, deeplink = null, isDismissible = false,
),
targets = emptyList(),
)
@Test
fun `GIVEN non swap-onramp type WHEN matchesUsdAmount THEN always true`() {
val c = campaign(MarketingScreenType.TOKEN_DETAILS, minAmount = BigDecimal(50), maxAmount = BigDecimal(300))
assertThat(c.matchesUsdAmount(BigDecimal(10))).isTrue()
assertThat(c.matchesUsdAmount(null)).isTrue()
}
@Test
fun `GIVEN swap with null amount WHEN matchesUsdAmount THEN true`() {
val c = campaign(MarketingScreenType.SWAP, minAmount = BigDecimal(50))
assertThat(c.matchesUsdAmount(null)).isTrue()
}
@Test
fun `GIVEN swap amount below min WHEN matchesUsdAmount THEN false`() {
val c = campaign(MarketingScreenType.SWAP, minAmount = BigDecimal(50), maxAmount = BigDecimal(300))
assertThat(c.matchesUsdAmount(BigDecimal(49))).isFalse()
}
@Test
fun `GIVEN swap amount above max WHEN matchesUsdAmount THEN false`() {
val c = campaign(MarketingScreenType.ONRAMP, minAmount = BigDecimal(50), maxAmount = BigDecimal(300))
assertThat(c.matchesUsdAmount(BigDecimal(301))).isFalse()
}
@Test
fun `GIVEN amount on boundaries WHEN matchesUsdAmount THEN true`() {
val c = campaign(MarketingScreenType.SWAP, minAmount = BigDecimal(50), maxAmount = BigDecimal(300))
assertThat(c.matchesUsdAmount(BigDecimal(50))).isTrue()
assertThat(c.matchesUsdAmount(BigDecimal(300))).isTrue()
}
@Test
fun `GIVEN nullable bounds WHEN matchesUsdAmount THEN only present bound applies`() {
val onlyMin = campaign(MarketingScreenType.SWAP, minAmount = BigDecimal(50), maxAmount = null)
assertThat(onlyMin.matchesUsdAmount(BigDecimal(10_000))).isTrue()
assertThat(onlyMin.matchesUsdAmount(BigDecimal(10))).isFalse()
val onlyMax = campaign(MarketingScreenType.SWAP, minAmount = null, maxAmount = BigDecimal(300))
assertThat(onlyMax.matchesUsdAmount(BigDecimal(1))).isTrue()
assertThat(onlyMax.matchesUsdAmount(BigDecimal(301))).isFalse()
}
}

View file

@ -4,7 +4,7 @@ import arrow.core.Either
import com.tangem.domain.marketing.models.MarketingCampaign
import com.tangem.domain.marketing.models.MarketingCampaignTarget
import com.tangem.domain.marketing.models.MarketingScreen
import com.tangem.domain.marketing.models.MarketingScreenType
import com.tangem.domain.marketing.models.matchesUsdAmount
import java.math.BigDecimal
class GetMarketingBannerUseCase(
@ -28,7 +28,7 @@ class GetMarketingBannerUseCase(
campaigns.asSequence()
.filterNot { it.id in dismissed }
.filter { matchesTarget(it, screen) }
.filter { matchesAmount(it, amountUsd) }
.filter { it.matchesUsdAmount(amountUsd) }
.sortedBy { it.priority }
.toList()
}
@ -71,15 +71,4 @@ class GetMarketingBannerUseCase(
else -> false
}
}
private fun matchesAmount(campaign: MarketingCampaign, amountUsd: BigDecimal?): Boolean {
val isAmountScreen = campaign.type == MarketingScreenType.SWAP || campaign.type == MarketingScreenType.ONRAMP
if (!isAmountScreen || amountUsd == null) return true
val min = campaign.minAmount
val max = campaign.maxAmount
if (min != null && amountUsd < min) return false
if (max != null && amountUsd > max) return false
return true
}
}

View file

@ -3,12 +3,16 @@ package com.tangem.domain.marketing
import arrow.core.Either
import com.tangem.domain.marketing.models.MarketingCampaign
import com.tangem.domain.marketing.models.MarketingScreen
import com.tangem.domain.marketing.models.MarketingScreenType
interface MarketingRepository {
/** Fetches campaigns for [screen]. Returns Right(emptyList()) when there is nothing to show (incl. 5xx without cache). */
suspend fun getCampaigns(screen: MarketingScreen): Either<Throwable, List<MarketingCampaign>>
/** Loads and caches campaigns for a background [type] into the in-memory session cache (fire-and-forget warm-up). */
suspend fun prefetchBackgroundCampaigns(type: MarketingScreenType)
/** Ids of campaigns whose banner the user has dismissed (stored client-side). */
suspend fun getDismissedBannerIds(): Set<Int>

View file

@ -0,0 +1,31 @@
package com.tangem.domain.marketing
import com.tangem.domain.marketing.models.MarketingScreenType
import kotlinx.coroutines.CancellationException
/**
* Warms the session cache for background campaign types shown outside a dedicated screen entry
* (token details & markets). Toggle-gated; failures are swallowed (fire-and-forget from the main screen).
*/
class WarmUpMarketingCampaignsUseCase(
private val repository: MarketingRepository,
private val featureToggles: MarketingFeatureToggles,
) {
suspend operator fun invoke() {
if (!featureToggles.isMarketingBannersEnabled) return
WARMED_TYPES.forEach { type ->
try {
repository.prefetchBackgroundCampaigns(type)
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
// fire-and-forget warm-up: ignore, next screen open retries
}
}
}
private companion object {
val WARMED_TYPES = listOf(MarketingScreenType.TOKEN_DETAILS, MarketingScreenType.TOKEN_MARKETS)
}
}

View file

@ -0,0 +1,58 @@
package com.tangem.domain.marketing
import com.tangem.domain.marketing.models.MarketingScreenType
import io.mockk.clearMocks
import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.every
import io.mockk.mockk
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.TestInstance
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
internal class WarmUpMarketingCampaignsUseCaseTest {
private val repository: MarketingRepository = mockk(relaxed = true)
private val featureToggles: MarketingFeatureToggles = mockk()
private val useCase = WarmUpMarketingCampaignsUseCase(repository, featureToggles)
@BeforeEach
fun reset() = clearMocks(repository, featureToggles)
@Test
fun `GIVEN toggle off WHEN invoke THEN no prefetch`() = runTest {
// Arrange
every { featureToggles.isMarketingBannersEnabled } returns false
// Act
useCase()
// Assert
coVerify(exactly = 0) { repository.prefetchBackgroundCampaigns(any()) }
}
@Test
fun `GIVEN toggle on WHEN invoke THEN prefetch token_details and markets`() = runTest {
// Arrange
every { featureToggles.isMarketingBannersEnabled } returns true
// Act
useCase()
// Assert
coVerify(exactly = 1) { repository.prefetchBackgroundCampaigns(MarketingScreenType.TOKEN_DETAILS) }
coVerify(exactly = 1) { repository.prefetchBackgroundCampaigns(MarketingScreenType.TOKEN_MARKETS) }
}
@Test
fun `GIVEN prefetch throws WHEN invoke THEN swallowed`() = runTest {
// Arrange
every { featureToggles.isMarketingBannersEnabled } returns true
coEvery { repository.prefetchBackgroundCampaigns(any()) } throws RuntimeException("boom")
// Act + Assert (does not throw)
useCase()
}
}

View file

@ -116,6 +116,7 @@ dependencies {
implementation(projects.domain.core)
implementation(projects.domain.demo.models)
implementation(projects.domain.feedback.models)
implementation(projects.domain.marketing)
implementation(projects.domain.markets.models)
implementation(projects.domain.nft.models)
implementation(projects.domain.onramp.models)

View file

@ -24,6 +24,7 @@ import com.tangem.domain.apptheme.model.AppThemeMode
import com.tangem.domain.assetsdiscovery.usecase.StartAssetsDiscoveryUseCase
import com.tangem.domain.balancehiding.GetBalanceHidingSettingsUseCase
import com.tangem.domain.common.wallets.UserWalletsListRepository
import com.tangem.domain.marketing.WarmUpMarketingCampaignsUseCase
import com.tangem.domain.models.wallet.*
import com.tangem.domain.notifications.GetIsHuaweiDeviceWithoutGoogleServicesUseCase
import com.tangem.domain.notifications.repository.NotificationsRepository
@ -131,6 +132,7 @@ internal class WalletModel @Inject constructor(
private val addressBookFeatureToggles: AddressBookFeatureToggles,
private val startAssetsDiscoveryUseCase: StartAssetsDiscoveryUseCase,
private val syncAddressBooksUseCase: SyncAddressBooksUseCase,
private val warmUpMarketingCampaignsUseCase: WarmUpMarketingCampaignsUseCase,
val screenLifecycleProvider: ScreenLifecycleProvider,
val innerWalletRouter: InnerWalletRouter,
) : Model() {
@ -155,6 +157,7 @@ internal class WalletModel @Inject constructor(
maybeMigrateNames()
maybeSetWalletFirstTimeUsage()
preloadPushNotificationPreferences()
warmUpMarketingCampaigns()
updateYieldSupplyApy()
subscribeToUserWalletsUpdates()
subscribeOnBalanceHiding()
@ -203,6 +206,12 @@ internal class WalletModel @Inject constructor(
}
}
private fun warmUpMarketingCampaigns() {
modelScope.launch(dispatchers.io) {
warmUpMarketingCampaignsUseCase()
}
}
private fun preloadPushNotificationPreferences() {
if (!pushNotificationSettingsFeatureToggles.isPushNotificationSettingsEnabled) return
getWalletsUseCase()