diff --git a/purchases/src/main/kotlin/com/revenuecat/purchases/PurchasesFactory.kt b/purchases/src/main/kotlin/com/revenuecat/purchases/PurchasesFactory.kt index 3ed0a4894a..bb20320535 100644 --- a/purchases/src/main/kotlin/com/revenuecat/purchases/PurchasesFactory.kt +++ b/purchases/src/main/kotlin/com/revenuecat/purchases/PurchasesFactory.kt @@ -35,6 +35,7 @@ import com.revenuecat.purchases.common.offerings.OfferingsFactory import com.revenuecat.purchases.common.offerings.OfferingsManager import com.revenuecat.purchases.common.offlineentitlements.OfflineCustomerInfoCalculator import com.revenuecat.purchases.common.offlineentitlements.OfflineEntitlementsManager +import com.revenuecat.purchases.common.offlineentitlements.ProductEntitlementMappingTopicProvider import com.revenuecat.purchases.common.offlineentitlements.PurchasedProductsFetcher import com.revenuecat.purchases.common.remoteconfig.DefaultRemoteConfigSourceProvider import com.revenuecat.purchases.common.remoteconfig.RemoteConfigBlobStore @@ -267,20 +268,6 @@ internal class PurchasesFactory( automaticDeviceIdentifierCollectionEnabled, ) - val offlineCustomerInfoCalculator = OfflineCustomerInfoCalculator( - PurchasedProductsFetcher(cache, billing), - appConfig, - diagnosticsTracker, - ) - - val offlineEntitlementsManager = OfflineEntitlementsManager( - backend, - offlineCustomerInfoCalculator, - cache, - appConfig, - diagnosticsTracker, - ) - val offeringsCache = OfferingsCache( deviceCache = cache, localeProvider = localeProvider, @@ -300,6 +287,25 @@ internal class PurchasesFactory( } else { null } + val productEntitlementMappingTopicProvider = createProductEntitlementMappingTopicProvider( + remoteConfigEnabled, + remoteConfigManager, + ) + + val offlineCustomerInfoCalculator = OfflineCustomerInfoCalculator( + PurchasedProductsFetcher(cache, billing), + appConfig, + diagnosticsTracker, + ) + + val offlineEntitlementsManager = OfflineEntitlementsManager( + backend, + offlineCustomerInfoCalculator, + cache, + appConfig, + diagnosticsTracker, + productEntitlementMappingTopicProvider, + ) val fontLoader = FontLoader( context = contextForStorage, @@ -630,6 +636,16 @@ internal class PurchasesFactory( } companion object { + @VisibleForTesting + internal fun createProductEntitlementMappingTopicProvider( + remoteConfigEnabled: Boolean, + remoteConfigManager: RemoteConfigManager?, + ): ProductEntitlementMappingTopicProvider? = if (remoteConfigEnabled) { + remoteConfigManager?.let(::ProductEntitlementMappingTopicProvider) + } else { + null + } + @VisibleForTesting internal fun shouldInitializeDiagnostics( diagnosticsEnabled: Boolean, diff --git a/purchases/src/main/kotlin/com/revenuecat/purchases/PurchasesOrchestrator.kt b/purchases/src/main/kotlin/com/revenuecat/purchases/PurchasesOrchestrator.kt index 1aa485cdab..02506e1a0f 100644 --- a/purchases/src/main/kotlin/com/revenuecat/purchases/PurchasesOrchestrator.kt +++ b/purchases/src/main/kotlin/com/revenuecat/purchases/PurchasesOrchestrator.kt @@ -885,6 +885,7 @@ internal class PurchasesOrchestrator( synchronized(this@PurchasesOrchestrator) { state = state.copy(purchaseCallbacksByProductId = Collections.emptyMap()) } + this.offlineEntitlementsManager.close() this.backend.close() this.remoteConfigManager?.close() this.workflowManager?.close() diff --git a/purchases/src/main/kotlin/com/revenuecat/purchases/common/offlineentitlements/OfflineEntitlementsManager.kt b/purchases/src/main/kotlin/com/revenuecat/purchases/common/offlineentitlements/OfflineEntitlementsManager.kt index 017327bbe1..73ed7d09f8 100644 --- a/purchases/src/main/kotlin/com/revenuecat/purchases/common/offlineentitlements/OfflineEntitlementsManager.kt +++ b/purchases/src/main/kotlin/com/revenuecat/purchases/common/offlineentitlements/OfflineEntitlementsManager.kt @@ -13,14 +13,24 @@ import com.revenuecat.purchases.common.diagnostics.DiagnosticsTracker import com.revenuecat.purchases.common.errorLog import com.revenuecat.purchases.common.warnLog import com.revenuecat.purchases.strings.OfflineEntitlementsStrings +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.CoroutineStart +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.launch @OptIn(InternalRevenueCatAPI::class) +@Suppress("LongParameterList") internal class OfflineEntitlementsManager( private val backend: Backend, private val offlineCustomerInfoCalculator: OfflineCustomerInfoCalculator, private val deviceCache: DeviceCache, private val appConfig: AppConfig, private val diagnosticsTracker: DiagnosticsTracker?, + private val productEntitlementMappingTopicProvider: ProductEntitlementMappingTopicProvider? = null, + private val scope: CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.IO), ) { // We cache the offline customer info in memory, so it's not persisted. val offlineCustomerInfo: CustomerInfo? @@ -31,6 +41,9 @@ internal class OfflineEntitlementsManager( private val offlineCustomerInfoCallbackCache = mutableMapOf>() + private val productEntitlementMappingUpdateLock = Any() + private var productEntitlementMappingUpdateJob: Job? = null + @Synchronized fun resetOfflineCustomerInfoCache() { if (_offlineCustomerInfo != null) { @@ -99,25 +112,96 @@ internal class OfflineEntitlementsManager( } fun updateProductEntitlementMappingCacheIfStale(completion: ((PurchasesError?) -> Unit)? = null) { - if (isOfflineEntitlementsEnabled() && deviceCache.isProductEntitlementMappingCacheStale()) { - debugLog { OfflineEntitlementsStrings.UPDATING_PRODUCT_ENTITLEMENT_MAPPING } - backend.getProductEntitlementMapping( - onSuccessHandler = { productEntitlementMapping -> - deviceCache.cacheProductEntitlementMapping(productEntitlementMapping) - debugLog { OfflineEntitlementsStrings.SUCCESSFULLY_UPDATED_PRODUCT_ENTITLEMENTS } - completion?.invoke(null) - }, - onErrorHandler = { e -> - errorLog { OfflineEntitlementsStrings.ERROR_UPDATING_PRODUCT_ENTITLEMENTS.format(e) } - completion?.invoke(e) - }, - ) - } else { + if (!isOfflineEntitlementsEnabled() || !deviceCache.isProductEntitlementMappingCacheStale()) { completion?.invoke(null) + return + } + + debugLog { OfflineEntitlementsStrings.UPDATING_PRODUCT_ENTITLEMENT_MAPPING } + val topicProvider = productEntitlementMappingTopicProvider + ?: return fetchLegacyProductEntitlementMapping(completion) + + startProductEntitlementMappingUpdate(topicProvider, completion) + } + + fun close() { + synchronized(productEntitlementMappingUpdateLock) { + productEntitlementMappingUpdateJob = null + } + scope.cancel() + } + + private fun startProductEntitlementMappingUpdate( + topicProvider: ProductEntitlementMappingTopicProvider, + completion: ((PurchasesError?) -> Unit)?, + ) { + fun finishUpdate(updateJob: Job): Boolean = + synchronized(productEntitlementMappingUpdateLock) { + if (productEntitlementMappingUpdateJob !== updateJob) { + false + } else { + productEntitlementMappingUpdateJob = null + true + } + } + + val completeUpdate: (Job, PurchasesError?) -> Unit = { updateJob, error -> + if (finishUpdate(updateJob)) { + completion?.invoke(error) + } } + val updateJob = synchronized(productEntitlementMappingUpdateLock) { + if (productEntitlementMappingUpdateJob != null) { + return + } + + scope.launch(start = CoroutineStart.LAZY) { + val currentJob = coroutineContext[Job] ?: return@launch + var waitingForLegacyResult = false + try { + val mapping = topicProvider.getProductEntitlementMapping() + if (mapping != null) { + deviceCache.cacheProductEntitlementMapping(mapping) + debugLog { OfflineEntitlementsStrings.SUCCESSFULLY_UPDATED_PRODUCT_ENTITLEMENTS } + completeUpdate(currentJob, null) + } else { + waitingForLegacyResult = true + fetchLegacyProductEntitlementMapping { completeUpdate(currentJob, it) } + } + } finally { + if (!waitingForLegacyResult) { + finishUpdate(currentJob) + } + } + }.also { + productEntitlementMappingUpdateJob = it + } + } + updateJob.start() + } + + private fun fetchLegacyProductEntitlementMapping(completion: ((PurchasesError?) -> Unit)?) { + backend.getProductEntitlementMapping( + onSuccessHandler = { productEntitlementMapping -> + cacheProductEntitlementMapping(productEntitlementMapping, completion) + }, + onErrorHandler = { e -> + errorLog { OfflineEntitlementsStrings.ERROR_UPDATING_PRODUCT_ENTITLEMENTS.format(e) } + completion?.invoke(e) + }, + ) + } + + private fun cacheProductEntitlementMapping( + productEntitlementMapping: ProductEntitlementMapping, + completion: ((PurchasesError?) -> Unit)?, + ) { + deviceCache.cacheProductEntitlementMapping(productEntitlementMapping) + debugLog { OfflineEntitlementsStrings.SUCCESSFULLY_UPDATED_PRODUCT_ENTITLEMENTS } + completion?.invoke(null) } - // We disable offline entitlements in observer mode (finishTransactions = true) since it doesn't + // We disable offline entitlements in observer mode (finishTransactions = false) since it doesn't // provide any value and simplifies operations in that mode. Also on test store, since we don't have a store // to store purchases in the client. private fun isOfflineEntitlementsEnabled() = appConfig.finishTransactions && diff --git a/purchases/src/main/kotlin/com/revenuecat/purchases/common/offlineentitlements/ProductEntitlementMappingTopicProvider.kt b/purchases/src/main/kotlin/com/revenuecat/purchases/common/offlineentitlements/ProductEntitlementMappingTopicProvider.kt new file mode 100644 index 0000000000..16c2167240 --- /dev/null +++ b/purchases/src/main/kotlin/com/revenuecat/purchases/common/offlineentitlements/ProductEntitlementMappingTopicProvider.kt @@ -0,0 +1,28 @@ +package com.revenuecat.purchases.common.offlineentitlements + +import com.revenuecat.purchases.InternalRevenueCatAPI +import com.revenuecat.purchases.common.errorLog +import com.revenuecat.purchases.common.remoteconfig.RemoteConfigManager +import com.revenuecat.purchases.common.remoteconfig.RemoteConfigTopic +import org.json.JSONException +import org.json.JSONObject + +/** Decodes the `product_entitlement_mapping.default` remote-config blob. */ +@OptIn(InternalRevenueCatAPI::class) +internal class ProductEntitlementMappingTopicProvider( + private val manager: RemoteConfigManager, +) { + suspend fun getProductEntitlementMapping(): ProductEntitlementMapping? = + manager.blobData(RemoteConfigTopic.ProductEntitlementMapping, ITEM_KEY) { bytes -> + try { + ProductEntitlementMapping.fromJson(JSONObject(bytes.decodeToString())) + } catch (e: JSONException) { + errorLog(e) { "Failed to parse product entitlement mapping from remote config." } + null + } + } + + private companion object { + private const val ITEM_KEY = "default" + } +} diff --git a/purchases/src/main/kotlin/com/revenuecat/purchases/common/remoteconfig/RemoteConfigManager.kt b/purchases/src/main/kotlin/com/revenuecat/purchases/common/remoteconfig/RemoteConfigManager.kt index 30295bbf52..173bd402a9 100644 --- a/purchases/src/main/kotlin/com/revenuecat/purchases/common/remoteconfig/RemoteConfigManager.kt +++ b/purchases/src/main/kotlin/com/revenuecat/purchases/common/remoteconfig/RemoteConfigManager.kt @@ -390,13 +390,16 @@ internal class RemoteConfigManager( // A 4xx: disable the endpoint for the rest of the session. This is an endpoint-level fact, so set it // regardless of epoch ownership (a late response for an old identity is still a valid signal that the // endpoint refuses this app's requests). Reads now return null, so drop any in-memory caches too. - disabled = true - val invalidatedGeneration = generation.incrementAndGet() - listeners.forEach { it.onConfigInvalidated(invalidatedGeneration) } - // Distinct one-shot signal (this branch runs once, guarded by !disabled): lets consumers refetch - // offerings so paywall components — skipped while the endpoint was live — get decoded for the - // fallback render path. - listeners.forEach { it.onRemoteConfigDisabled(invalidatedGeneration) } + synchronized(cacheLock) { + if (!disabled) { + disabled = true + val invalidatedGeneration = generation.incrementAndGet() + listeners.forEach { it.onConfigInvalidated(invalidatedGeneration) } + // Distinct one-shot signal: lets consumers refetch offerings so paywall components — skipped + // while the endpoint was live — get decoded for the fallback render path. + listeners.forEach { it.onRemoteConfigDisabled(invalidatedGeneration) } + } + } } if (releaseGuardIfOwned(requestEpoch)) { errorLog(error) @@ -746,11 +749,15 @@ internal class RemoteConfigManager( } verboseLog { "Reading remote config blob (topic='${topic.wireName}', item='$itemKey')." } val ref = committedItem(topic, itemKey)?.blobRef - return when { - ref == null -> { + return ref?.let { resolveBlobBytes(it, itemKey) }.also { + if (ref == null) { verboseLog { "Remote config item '$itemKey' is missing or has no blob ref; returning null." } - null } + } + } + + private suspend fun resolveBlobBytes(ref: String, itemKey: String): ByteArray? { + return when { blobFetcher.ensureDownloaded(ref) -> { blobStore.read(ref).also { bytes -> if (bytes != null) { diff --git a/purchases/src/main/kotlin/com/revenuecat/purchases/common/remoteconfig/RemoteConfigTopic.kt b/purchases/src/main/kotlin/com/revenuecat/purchases/common/remoteconfig/RemoteConfigTopic.kt index 3b0129ce58..d88c1919bb 100644 --- a/purchases/src/main/kotlin/com/revenuecat/purchases/common/remoteconfig/RemoteConfigTopic.kt +++ b/purchases/src/main/kotlin/com/revenuecat/purchases/common/remoteconfig/RemoteConfigTopic.kt @@ -12,4 +12,5 @@ internal enum class RemoteConfigTopic(val wireName: String) { Workflows("workflows"), UiConfig("ui_config"), Sources("sources"), + ProductEntitlementMapping("product_entitlement_mapping"), } diff --git a/purchases/src/test/java/com/revenuecat/purchases/BasePurchasesTest.kt b/purchases/src/test/java/com/revenuecat/purchases/BasePurchasesTest.kt index 3c536a34a3..63880be1ef 100644 --- a/purchases/src/test/java/com/revenuecat/purchases/BasePurchasesTest.kt +++ b/purchases/src/test/java/com/revenuecat/purchases/BasePurchasesTest.kt @@ -147,6 +147,9 @@ internal open class BasePurchasesTest { every { mockOfflineEntitlementsManager.updateProductEntitlementMappingCacheIfStale() } just Runs + every { + mockOfflineEntitlementsManager.close() + } just Runs every { mockEventsManager.flushEvents(any()) } just Runs diff --git a/purchases/src/test/java/com/revenuecat/purchases/PurchasesCommonTest.kt b/purchases/src/test/java/com/revenuecat/purchases/PurchasesCommonTest.kt index 1cd3368f8a..29490c89d6 100644 --- a/purchases/src/test/java/com/revenuecat/purchases/PurchasesCommonTest.kt +++ b/purchases/src/test/java/com/revenuecat/purchases/PurchasesCommonTest.kt @@ -3046,6 +3046,7 @@ internal class PurchasesCommonTest: BasePurchasesTest() { private fun verifyClose() { verify { + mockOfflineEntitlementsManager.close() mockBackend.close() mockRemoteConfigManager.close() mockBillingAbstract.close() diff --git a/purchases/src/test/java/com/revenuecat/purchases/PurchasesFactoryTest.kt b/purchases/src/test/java/com/revenuecat/purchases/PurchasesFactoryTest.kt index fba75ad0e5..0b79bcfb52 100644 --- a/purchases/src/test/java/com/revenuecat/purchases/PurchasesFactoryTest.kt +++ b/purchases/src/test/java/com/revenuecat/purchases/PurchasesFactoryTest.kt @@ -6,6 +6,8 @@ import android.content.Context import android.content.Intent import android.content.pm.PackageManager import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.revenuecat.purchases.common.offlineentitlements.ProductEntitlementMappingTopicProvider +import com.revenuecat.purchases.common.remoteconfig.RemoteConfigManager import io.mockk.clearAllMocks import io.mockk.every import io.mockk.mockk @@ -191,6 +193,26 @@ class PurchasesFactoryTest { .isFalse } + @Test + fun `remote config enabled creates product entitlement mapping topic provider`() { + val provider = PurchasesFactory.createProductEntitlementMappingTopicProvider( + remoteConfigEnabled = true, + remoteConfigManager = mockk(), + ) + + assertThat(provider).isInstanceOf(ProductEntitlementMappingTopicProvider::class.java) + } + + @Test + fun `remote config disabled does not create product entitlement mapping topic provider`() { + val provider = PurchasesFactory.createProductEntitlementMappingTopicProvider( + remoteConfigEnabled = false, + remoteConfigManager = mockk(), + ) + + assertThat(provider).isNull() + } + // endregion private fun createConfiguration( diff --git a/purchases/src/test/java/com/revenuecat/purchases/common/offlineentitlements/OfflineEntitlementsManagerTest.kt b/purchases/src/test/java/com/revenuecat/purchases/common/offlineentitlements/OfflineEntitlementsManagerTest.kt index f7a14ac0fb..60a207f211 100644 --- a/purchases/src/test/java/com/revenuecat/purchases/common/offlineentitlements/OfflineEntitlementsManagerTest.kt +++ b/purchases/src/test/java/com/revenuecat/purchases/common/offlineentitlements/OfflineEntitlementsManagerTest.kt @@ -11,12 +11,21 @@ import com.revenuecat.purchases.common.caching.DeviceCache import com.revenuecat.purchases.common.diagnostics.DiagnosticsTracker import io.mockk.CapturingSlot import io.mockk.Runs +import io.mockk.coEvery +import io.mockk.coVerify import io.mockk.every import io.mockk.invoke import io.mockk.just import io.mockk.mockk import io.mockk.slot import io.mockk.verify +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.runTest import org.assertj.core.api.Assertions.assertThat import org.assertj.core.api.Assertions.fail import org.junit.Before @@ -26,6 +35,7 @@ import org.robolectric.annotation.Config @RunWith(AndroidJUnit4::class) @Config(manifest = Config.NONE) +@OptIn(ExperimentalCoroutinesApi::class) class OfflineEntitlementsManagerTest { private val appUserID = "test-app-user-id" @@ -438,6 +448,122 @@ class OfflineEntitlementsManagerTest { verify(exactly = 0) { backend.getProductEntitlementMapping(any(), any()) } } + @Test + fun `updateProductEntitlementMappingCacheIfStale does no remote config or legacy work if cache not stale`() { + val topicProvider = mockk() + offlineEntitlementsManager = managerWithTopicProvider(topicProvider) + every { deviceCache.isProductEntitlementMappingCacheStale() } returns false + + offlineEntitlementsManager.updateProductEntitlementMappingCacheIfStale() + + coVerify(exactly = 0) { topicProvider.getProductEntitlementMapping() } + verify(exactly = 0) { backend.getProductEntitlementMapping(any(), any()) } + } + + @Test + fun `updateProductEntitlementMappingCacheIfStale caches remote config mapping and skips legacy endpoint`() { + val topicProvider = mockk() + val mapping = createProductEntitlementMapping() + offlineEntitlementsManager = managerWithTopicProvider(topicProvider) + every { deviceCache.isProductEntitlementMappingCacheStale() } returns true + every { deviceCache.cacheProductEntitlementMapping(mapping) } just Runs + coEvery { topicProvider.getProductEntitlementMapping() } returns mapping + var completionCallCount = 0 + + offlineEntitlementsManager.updateProductEntitlementMappingCacheIfStale { + assertThat(it).isNull() + completionCallCount++ + } + + assertThat(completionCallCount).isEqualTo(1) + coVerify(exactly = 1) { topicProvider.getProductEntitlementMapping() } + verify(exactly = 1) { deviceCache.cacheProductEntitlementMapping(mapping) } + verify(exactly = 0) { backend.getProductEntitlementMapping(any(), any()) } + } + + @Test + fun `mapping update does nothing when a remote config job is already pending`() = runTest { + val topicProvider = mockk() + val result = CompletableDeferred() + val mapping = createProductEntitlementMapping() + val managerScope = CoroutineScope(SupervisorJob() + StandardTestDispatcher(testScheduler)) + offlineEntitlementsManager = managerWithTopicProvider(topicProvider, managerScope) + every { deviceCache.isProductEntitlementMappingCacheStale() } returns true + every { deviceCache.cacheProductEntitlementMapping(mapping) } just Runs + coEvery { topicProvider.getProductEntitlementMapping() } coAnswers { result.await() } + var firstCompletionCallCount = 0 + var secondCompletionCallCount = 0 + + offlineEntitlementsManager.updateProductEntitlementMappingCacheIfStale { firstCompletionCallCount++ } + offlineEntitlementsManager.updateProductEntitlementMappingCacheIfStale { secondCompletionCallCount++ } + testScheduler.runCurrent() + result.complete(mapping) + testScheduler.advanceUntilIdle() + + coVerify(exactly = 1) { topicProvider.getProductEntitlementMapping() } + verify(exactly = 1) { deviceCache.cacheProductEntitlementMapping(mapping) } + verify(exactly = 0) { backend.getProductEntitlementMapping(any(), any()) } + assertThat(firstCompletionCallCount).isEqualTo(1) + assertThat(secondCompletionCallCount).isZero() + } + + @Test + fun `updateProductEntitlementMappingCacheIfStale falls back when remote config mapping is unavailable`() { + val topicProvider = mockk() + val mapping = createProductEntitlementMapping() + offlineEntitlementsManager = managerWithTopicProvider(topicProvider) + every { deviceCache.isProductEntitlementMappingCacheStale() } returns true + every { deviceCache.cacheProductEntitlementMapping(mapping) } just Runs + coEvery { topicProvider.getProductEntitlementMapping() } returns null + var completionCallCount = 0 + + offlineEntitlementsManager.updateProductEntitlementMappingCacheIfStale { + assertThat(it).isNull() + completionCallCount++ + } + + coVerify(exactly = 1) { topicProvider.getProductEntitlementMapping() } + verify(exactly = 1) { backend.getProductEntitlementMapping(any(), any()) } + backendSuccessSlot.captured(mapping) + assertThat(completionCallCount).isEqualTo(1) + verify(exactly = 1) { deviceCache.cacheProductEntitlementMapping(mapping) } + } + + @Test + fun `updateProductEntitlementMappingCacheIfStale reports legacy error after remote config is unavailable`() { + val topicProvider = mockk() + val error = PurchasesError(PurchasesErrorCode.UnknownBackendError, "legacy failed") + offlineEntitlementsManager = managerWithTopicProvider(topicProvider) + every { deviceCache.isProductEntitlementMappingCacheStale() } returns true + coEvery { topicProvider.getProductEntitlementMapping() } returns null + var reportedError: PurchasesError? = null + + offlineEntitlementsManager.updateProductEntitlementMappingCacheIfStale { reportedError = it } + backendErrorSlot.captured(error) + + assertThat(reportedError).isEqualTo(error) + verify(exactly = 0) { deviceCache.cacheProductEntitlementMapping(any()) } + } + + @Test + fun `close cancels an in-flight remote config mapping read without starting legacy fallback`() = runTest { + val topicProvider = mockk() + val result = CompletableDeferred() + val managerScope = CoroutineScope(SupervisorJob() + StandardTestDispatcher(testScheduler)) + offlineEntitlementsManager = managerWithTopicProvider(topicProvider, managerScope) + every { deviceCache.isProductEntitlementMappingCacheStale() } returns true + coEvery { topicProvider.getProductEntitlementMapping() } coAnswers { result.await() } + + offlineEntitlementsManager.updateProductEntitlementMappingCacheIfStale() + testScheduler.runCurrent() + offlineEntitlementsManager.close() + result.complete(createProductEntitlementMapping()) + testScheduler.advanceUntilIdle() + + verify(exactly = 0) { deviceCache.cacheProductEntitlementMapping(any()) } + verify(exactly = 0) { backend.getProductEntitlementMapping(any(), any()) } + } + @Test fun `updateProductEntitlementMappingCacheIfStale does nothing if not finishing transactions`() { every { deviceCache.isProductEntitlementMappingCacheStale() } returns true @@ -469,7 +595,7 @@ class OfflineEntitlementsManagerTest { } @Test - fun `updateProductEntitlementMappingCacheIfStale caches mapping from backend if request successful`() { + fun `updateProductEntitlementMappingCacheIfStale uses backend when remote config is disabled`() { every { deviceCache.isProductEntitlementMappingCacheStale() } returns true every { deviceCache.cacheProductEntitlementMapping(any()) } just Runs var completionCallCount = 0 @@ -495,6 +621,19 @@ class OfflineEntitlementsManagerTest { // region helpers + private fun managerWithTopicProvider( + topicProvider: ProductEntitlementMappingTopicProvider, + managerScope: CoroutineScope = CoroutineScope(UnconfinedTestDispatcher()), + ): OfflineEntitlementsManager = OfflineEntitlementsManager( + backend, + offlineEntitlementsCalculator, + deviceCache, + appConfig, + diagnosticsTracker, + topicProvider, + managerScope, + ) + private fun mockCalculateOfflineEntitlements( successCustomerInfo: CustomerInfo? = null, error: PurchasesError? = null diff --git a/purchases/src/test/java/com/revenuecat/purchases/common/offlineentitlements/ProductEntitlementMappingTopicProviderTest.kt b/purchases/src/test/java/com/revenuecat/purchases/common/offlineentitlements/ProductEntitlementMappingTopicProviderTest.kt new file mode 100644 index 0000000000..d0f7490145 --- /dev/null +++ b/purchases/src/test/java/com/revenuecat/purchases/common/offlineentitlements/ProductEntitlementMappingTopicProviderTest.kt @@ -0,0 +1,94 @@ +package com.revenuecat.purchases.common.offlineentitlements + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.revenuecat.purchases.common.remoteconfig.RemoteConfigManager +import com.revenuecat.purchases.common.remoteconfig.RemoteConfigTopic +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.mockk +import kotlinx.coroutines.test.runTest +import org.assertj.core.api.Assertions.assertThat +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.annotation.Config + +@RunWith(AndroidJUnit4::class) +@Config(manifest = Config.NONE) +internal class ProductEntitlementMappingTopicProviderTest { + + private lateinit var manager: RemoteConfigManager + private lateinit var provider: ProductEntitlementMappingTopicProvider + + @Before + fun setUp() { + manager = mockk() + provider = ProductEntitlementMappingTopicProvider(manager) + } + + @Test + fun `getProductEntitlementMapping decodes the default blob`() = runTest { + val blob = """ + { + "product_entitlement_mapping": { + "monthly": { + "product_identifier": "monthly", + "base_plan_id": "monthly-base-plan", + "entitlements": ["pro"] + } + } + } + """.trimIndent().toByteArray() + stubBlobRead(blob) + + val result = provider.getProductEntitlementMapping() + + assertThat(result?.mappings).containsOnlyKeys("monthly") + assertThat(result?.mappings?.get("monthly")).isEqualTo( + ProductEntitlementMapping.Mapping( + productIdentifier = "monthly", + basePlanId = "monthly-base-plan", + entitlements = listOf("pro"), + ), + ) + coVerify(exactly = 1) { + manager.blobData( + RemoteConfigTopic.ProductEntitlementMapping, + "default", + any<(ByteArray) -> ProductEntitlementMapping?>(), + ) + } + } + + @Test + fun `getProductEntitlementMapping returns null when the default blob is unavailable`() = runTest { + coEvery { + manager.blobData( + RemoteConfigTopic.ProductEntitlementMapping, + "default", + any<(ByteArray) -> ProductEntitlementMapping?>(), + ) + } returns null + + assertThat(provider.getProductEntitlementMapping()).isNull() + } + + @Test + fun `getProductEntitlementMapping returns null when the default blob is malformed`() = runTest { + stubBlobRead("{ malformed".toByteArray()) + + assertThat(provider.getProductEntitlementMapping()).isNull() + } + + private fun stubBlobRead(blob: ByteArray) { + coEvery { + manager.blobData( + RemoteConfigTopic.ProductEntitlementMapping, + "default", + any<(ByteArray) -> ProductEntitlementMapping?>(), + ) + } answers { + thirdArg<(ByteArray) -> ProductEntitlementMapping?>().invoke(blob) + } + } +} diff --git a/purchases/src/test/java/com/revenuecat/purchases/common/remoteconfig/RemoteConfigManagerIntegrationTest.kt b/purchases/src/test/java/com/revenuecat/purchases/common/remoteconfig/RemoteConfigManagerIntegrationTest.kt index ca52c9e801..19e4e78edb 100644 --- a/purchases/src/test/java/com/revenuecat/purchases/common/remoteconfig/RemoteConfigManagerIntegrationTest.kt +++ b/purchases/src/test/java/com/revenuecat/purchases/common/remoteconfig/RemoteConfigManagerIntegrationTest.kt @@ -9,6 +9,8 @@ import com.revenuecat.purchases.common.DateProvider import com.revenuecat.purchases.common.networking.RCContainer import com.revenuecat.purchases.common.networking.RCContainerTestData import com.revenuecat.purchases.common.networking.RCContentEncoding +import com.revenuecat.purchases.common.offlineentitlements.ProductEntitlementMappingTopicProvider +import io.mockk.coEvery import io.mockk.every import io.mockk.mockk import kotlinx.coroutines.CoroutineScope @@ -45,6 +47,7 @@ class RemoteConfigManagerIntegrationTest { private lateinit var backend: Backend private lateinit var diskCache: RemoteConfigDiskCache private lateinit var blobStore: RemoteConfigBlobStore + private lateinit var blobFetcher: RemoteConfigBlobFetcher private lateinit var manager: RemoteConfigManager // Unconfined so the launched 200-path coroutine runs eagerly: settle(onSuccess) then assert still works. @@ -71,6 +74,7 @@ class RemoteConfigManagerIntegrationTest { // This suite exercises the real disk/blob-store path; network prefetch is unit-tested separately, so the // fetcher is mocked here to keep the test hermetic (no real CDN calls from the background worker pool). val topicStore = RemoteConfigTopicStore { diskCache.read()?.topics?.get(it.wireName) } + blobFetcher = mockk(relaxed = true) manager = RemoteConfigManager( backend, diskCache, @@ -79,7 +83,8 @@ class RemoteConfigManagerIntegrationTest { scope = testScope, topicStore = topicStore, sourceProvider = DefaultRemoteConfigSourceProvider(topicStore), - blobFetcher = mockk(relaxed = true), + blobFetcher = blobFetcher, + appUserIDProvider = { "integration-test-user" }, ) every { @@ -289,6 +294,74 @@ class RemoteConfigManagerIntegrationTest { assertThat(body).isNull() } + @Test + fun `a synced product entitlement mapping blob decodes through its topic provider`() { + val blob = """ + { + "product_entitlement_mapping": { + "monthly": { + "product_identifier": "monthly", + "base_plan_id": "monthly-base-plan", + "entitlements": ["pro"] + } + } + } + """.trimIndent().toByteArray() + val ref = RCContainerTestData.refOf(blob) + coEvery { blobFetcher.ensureDownloaded(ref) } returns true + + sync(container(productEntitlementMappingConfig(ref), blob)) + + val mapping = runBlocking { + ProductEntitlementMappingTopicProvider(manager).getProductEntitlementMapping() + } + assertThat(mapping?.mappings).containsOnlyKeys("monthly") + assertThat(mapping?.mappings?.get("monthly")?.entitlements).containsExactly("pro") + assertThat(blobStore.read(ref)).isEqualTo(blob) + } + + @Test + fun `product entitlement mapping provider returns null when its blob download fails`() { + val blob = """{"product_entitlement_mapping":{}}""".toByteArray() + val ref = RCContainerTestData.refOf(blob) + coEvery { blobFetcher.ensureDownloaded(ref) } returns false + sync(container(productEntitlementMappingConfig(ref))) + + val mapping = runBlocking { + ProductEntitlementMappingTopicProvider(manager).getProductEntitlementMapping() + } + + assertThat(mapping).isNull() + } + + @Test + fun `a cold product entitlement mapping read returns the config fetched on demand`() { + val blob = """ + { + "product_entitlement_mapping": { + "monthly": { + "product_identifier": "monthly", + "entitlements": ["pro"] + } + } + } + """.trimIndent().toByteArray() + val ref = RCContainerTestData.refOf(blob) + val response = container(productEntitlementMappingConfig(ref), blob) + coEvery { blobFetcher.ensureDownloaded(ref) } returns true + every { + backend.getRemoteConfig(any(), any(), any(), any(), any(), any(), any(), any()) + } answers { + arg<(RCContainer?, VerificationResult) -> Unit>(6).invoke(response, VerificationResult.VERIFIED) + } + + val mapping = runBlocking { + ProductEntitlementMappingTopicProvider(manager).getProductEntitlementMapping() + } + + assertThat(mapping?.mappings).containsOnlyKeys("monthly") + } + /** Builds a workflows-topic config. Each item maps an item key to its `blob_ref`, or `null` for inline-only. */ private fun workflowsConfig( manifest: String = "v1.1.workflows:etag1", @@ -315,6 +388,21 @@ class RemoteConfigManagerIntegrationTest { """.trimIndent() } + private fun productEntitlementMappingConfig(ref: String): String = + """ + { + "domain": "app", + "manifest": "v1.1.product_entitlement_mapping:etag1", + "active_topics": ["product_entitlement_mapping"], + "prefetch_blobs": [], + "topics": { + "product_entitlement_mapping": { + "default": { "blob_ref": "$ref" } + } + } + } + """.trimIndent() + private fun container( configJson: String, vararg blobs: ByteArray, diff --git a/purchases/src/test/java/com/revenuecat/purchases/common/remoteconfig/RemoteConfigManagerTest.kt b/purchases/src/test/java/com/revenuecat/purchases/common/remoteconfig/RemoteConfigManagerTest.kt index 1daad06a3e..74e3c3b2ee 100644 --- a/purchases/src/test/java/com/revenuecat/purchases/common/remoteconfig/RemoteConfigManagerTest.kt +++ b/purchases/src/test/java/com/revenuecat/purchases/common/remoteconfig/RemoteConfigManagerTest.kt @@ -1738,6 +1738,59 @@ class RemoteConfigManagerTest { coVerify(exactly = 1) { blobFetcher.ensureDownloaded(REF_VALID) } } + @Test + fun `blobData retains legacy behavior when identity changes during blob resolution`() = runTest { + every { diskCache.read() } returns persisted( + manifest = "m", + activeTopics = listOf("workflows"), + topics = mapOf( + "workflows" to ConfigTopic(mapOf("wf1" to RemoteConfiguration.ConfigItem(blobRef = REF_VALID))), + ), + ) + coEvery { blobFetcher.ensureDownloaded(REF_VALID) } answers { + manager.clearCache("new-user") + true + } + every { blobStore.read(REF_VALID) } returns byteArrayOf(4, 2) + + val result = manager.blobData(RemoteConfigTopic.Workflows, "wf1") { it } + + assertThat(result).isEqualTo(byteArrayOf(4, 2)) + } + + @Test + fun `blobData retains legacy behavior when the item ref changes during blob resolution`() = runTest { + every { diskCache.read() } returns persisted( + manifest = "m", + activeTopics = listOf("workflows"), + topics = mapOf( + "workflows" to ConfigTopic(mapOf("wf1" to RemoteConfiguration.ConfigItem(blobRef = REF_VALID))), + ), + ) + every { blobStore.read(REF_VALID) } returns byteArrayOf(4, 2) + manager.refreshRemoteConfig( + appInBackground = false, + appUserID = TEST_APP_USER_ID, + fetchContext = DEFAULT_FETCH_CONTEXT, + ) + coEvery { blobFetcher.ensureDownloaded(REF_VALID) } answers { + val response = """ + { + "domain": "app", + "manifest": "v2", + "active_topics": ["workflows"], + "topics": { "workflows": { "wf1": { "blob_ref": "$REF_TAMPERED" } } } + } + """.trimIndent() + onSuccess.invoke(containerWithConfig(response), VerificationResult.VERIFIED) + true + } + + val result = manager.blobData(RemoteConfigTopic.Workflows, "wf1") { it } + + assertThat(result).isEqualTo(byteArrayOf(4, 2)) + } + @Test fun `reified blobData fetches the blob on demand then deserializes it, like the transform overload`() = runTest { every { diskCache.read() } returns persisted(