From 1e25b19264718aa187047a4484157e16484ecc85 Mon Sep 17 00:00:00 2001 From: Tangem Date: Mon, 13 Jul 2026 13:02:00 +0500 Subject: [PATCH] Updated on 2026-08-14 --- .../tap/di/domain/MarketingDomainModule.kt | 8 ++ .../marketing/DefaultMarketingRepository.kt | 95 ++++++++++---- .../DefaultMarketingRepositoryTest.kt | 122 +++++++++++++++++- .../models/MarketingCampaignAmount.kt | 16 +++ .../models/MarketingCampaignAmountTest.kt | 64 +++++++++ .../marketing/GetMarketingBannerUseCase.kt | 15 +-- .../domain/marketing/MarketingRepository.kt | 4 + .../WarmUpMarketingCampaignsUseCase.kt | 31 +++++ .../WarmUpMarketingCampaignsUseCaseTest.kt | 58 +++++++++ features/wallet/impl/build.gradle.kts | 1 + .../wallet/child/wallet/model/WalletModel.kt | 9 ++ 11 files changed, 379 insertions(+), 44 deletions(-) create mode 100644 domain/marketing/models/src/main/kotlin/com/tangem/domain/marketing/models/MarketingCampaignAmount.kt create mode 100644 domain/marketing/models/src/test/kotlin/com/tangem/domain/marketing/models/MarketingCampaignAmountTest.kt create mode 100644 domain/marketing/src/main/kotlin/com/tangem/domain/marketing/WarmUpMarketingCampaignsUseCase.kt create mode 100644 domain/marketing/src/test/kotlin/com/tangem/domain/marketing/WarmUpMarketingCampaignsUseCaseTest.kt diff --git a/app/src/main/java/com/tangem/tap/di/domain/MarketingDomainModule.kt b/app/src/main/java/com/tangem/tap/di/domain/MarketingDomainModule.kt index 7e927f9d61..16b6359490 100644 --- a/app/src/main/java/com/tangem/tap/di/domain/MarketingDomainModule.kt +++ b/app/src/main/java/com/tangem/tap/di/domain/MarketingDomainModule.kt @@ -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) } \ No newline at end of file diff --git a/data/marketing/src/main/kotlin/com/tangem/data/marketing/DefaultMarketingRepository.kt b/data/marketing/src/main/kotlin/com/tangem/data/marketing/DefaultMarketingRepository.kt index 1c27200732..547f9bbc60 100644 --- a/data/marketing/src/main/kotlin/com/tangem/data/marketing/DefaultMarketingRepository.kt +++ b/data/marketing/src/main/kotlin/com/tangem/data/marketing/DefaultMarketingRepository.kt @@ -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,32 +31,82 @@ 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>>(emptyMap()) + private val cacheMutex = Mutex() + override suspend fun getCampaigns(screen: MarketingScreen): Either> = withContext(dispatchers.io) { Either.catch { - val isCacheable = screen.type.isCacheable - val cached = if (isCacheable) cacheStore.get(screen.type.value) else null - - 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) + 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() } - 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. + } + } + override suspend fun getDismissedBannerIds(): Set = dismissStore.getDismissedIds() override suspend fun dismissBanner(campaignId: Int) = dismissStore.dismiss(campaignId) + private suspend fun loadCacheableByType(type: MarketingScreenType): List { + 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? { + 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 { + 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 { - return cached?.response?.let(::convert).orEmpty() - } - private fun convert(response: MarketingCampaignsResponse): List = converter.convertListIgnoreErrors(response.campaigns) { throwable -> TangemLogger.w("Skipped invalid marketing campaign: ${throwable.message}") diff --git a/data/marketing/src/test/kotlin/com/tangem/data/marketing/DefaultMarketingRepositoryTest.kt b/data/marketing/src/test/kotlin/com/tangem/data/marketing/DefaultMarketingRepositoryTest.kt index e752efa9e5..40895fc9a5 100644 --- a/data/marketing/src/test/kotlin/com/tangem/data/marketing/DefaultMarketingRepositoryTest.kt +++ b/data/marketing/src/test/kotlin/com/tangem/data/marketing/DefaultMarketingRepositoryTest.kt @@ -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( - tangemTechApi = tangemTechApi, - cacheStore = cacheStore, - dismissStore = dismissStore, - converter = MarketingCampaignConverter(), - dispatchers = TestingCoroutineDispatcherProvider(), - ) + // 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(), + ) } 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() + 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 diff --git a/domain/marketing/models/src/main/kotlin/com/tangem/domain/marketing/models/MarketingCampaignAmount.kt b/domain/marketing/models/src/main/kotlin/com/tangem/domain/marketing/models/MarketingCampaignAmount.kt new file mode 100644 index 0000000000..5fffde6c7d --- /dev/null +++ b/domain/marketing/models/src/main/kotlin/com/tangem/domain/marketing/models/MarketingCampaignAmount.kt @@ -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 +} \ No newline at end of file diff --git a/domain/marketing/models/src/test/kotlin/com/tangem/domain/marketing/models/MarketingCampaignAmountTest.kt b/domain/marketing/models/src/test/kotlin/com/tangem/domain/marketing/models/MarketingCampaignAmountTest.kt new file mode 100644 index 0000000000..2e8fa7ebef --- /dev/null +++ b/domain/marketing/models/src/test/kotlin/com/tangem/domain/marketing/models/MarketingCampaignAmountTest.kt @@ -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() + } +} \ No newline at end of file diff --git a/domain/marketing/src/main/kotlin/com/tangem/domain/marketing/GetMarketingBannerUseCase.kt b/domain/marketing/src/main/kotlin/com/tangem/domain/marketing/GetMarketingBannerUseCase.kt index 29107a7c3f..387553c39a 100644 --- a/domain/marketing/src/main/kotlin/com/tangem/domain/marketing/GetMarketingBannerUseCase.kt +++ b/domain/marketing/src/main/kotlin/com/tangem/domain/marketing/GetMarketingBannerUseCase.kt @@ -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 - } } \ No newline at end of file diff --git a/domain/marketing/src/main/kotlin/com/tangem/domain/marketing/MarketingRepository.kt b/domain/marketing/src/main/kotlin/com/tangem/domain/marketing/MarketingRepository.kt index 77476650b3..15c9f0cf35 100644 --- a/domain/marketing/src/main/kotlin/com/tangem/domain/marketing/MarketingRepository.kt +++ b/domain/marketing/src/main/kotlin/com/tangem/domain/marketing/MarketingRepository.kt @@ -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> + /** 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 diff --git a/domain/marketing/src/main/kotlin/com/tangem/domain/marketing/WarmUpMarketingCampaignsUseCase.kt b/domain/marketing/src/main/kotlin/com/tangem/domain/marketing/WarmUpMarketingCampaignsUseCase.kt new file mode 100644 index 0000000000..7e4c061bf3 --- /dev/null +++ b/domain/marketing/src/main/kotlin/com/tangem/domain/marketing/WarmUpMarketingCampaignsUseCase.kt @@ -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) + } +} \ No newline at end of file diff --git a/domain/marketing/src/test/kotlin/com/tangem/domain/marketing/WarmUpMarketingCampaignsUseCaseTest.kt b/domain/marketing/src/test/kotlin/com/tangem/domain/marketing/WarmUpMarketingCampaignsUseCaseTest.kt new file mode 100644 index 0000000000..5bcfb32997 --- /dev/null +++ b/domain/marketing/src/test/kotlin/com/tangem/domain/marketing/WarmUpMarketingCampaignsUseCaseTest.kt @@ -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() + } +} \ No newline at end of file diff --git a/features/wallet/impl/build.gradle.kts b/features/wallet/impl/build.gradle.kts index ede24c9be3..579d588463 100644 --- a/features/wallet/impl/build.gradle.kts +++ b/features/wallet/impl/build.gradle.kts @@ -93,6 +93,7 @@ dependencies { implementation(projects.domain.feedback) implementation(projects.domain.feedback.models) implementation(projects.domain.legacy) + implementation(projects.domain.marketing) implementation(projects.domain.markets.models) implementation(projects.domain.models) implementation(projects.domain.networks) diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/wallet/model/WalletModel.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/wallet/model/WalletModel.kt index 8a0506515f..15549d3933 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/wallet/model/WalletModel.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/wallet/model/WalletModel.kt @@ -23,6 +23,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 @@ -127,6 +128,7 @@ internal class WalletModel @Inject constructor( private val walletFeatureToggles: WalletFeatureToggles, private val pushNotificationSettingsFeatureToggles: PushNotificationSettingsFeatureToggles, private val startAssetsDiscoveryUseCase: StartAssetsDiscoveryUseCase, + private val warmUpMarketingCampaignsUseCase: WarmUpMarketingCampaignsUseCase, val screenLifecycleProvider: ScreenLifecycleProvider, val innerWalletRouter: InnerWalletRouter, ) : Model() { @@ -151,6 +153,7 @@ internal class WalletModel @Inject constructor( maybeMigrateNames() maybeSetWalletFirstTimeUsage() preloadPushNotificationPreferences() + warmUpMarketingCampaigns() updateYieldSupplyApy() subscribeToUserWalletsUpdates() subscribeOnBalanceHiding() @@ -198,6 +201,12 @@ internal class WalletModel @Inject constructor( } } + private fun warmUpMarketingCampaigns() { + modelScope.launch(dispatchers.io) { + warmUpMarketingCampaignsUseCase() + } + } + private fun preloadPushNotificationPreferences() { if (!pushNotificationSettingsFeatureToggles.isPushNotificationSettingsEnabled) return getWalletsUseCase()