From 3021b29eb86bb37855b528e746f8791289963f56 Mon Sep 17 00:00:00 2001 From: Rick van der Linden Date: Wed, 22 Jul 2026 12:11:00 +0200 Subject: [PATCH 1/9] Guard remote config blob reads against invalidation --- .../remoteconfig/RemoteConfigManager.kt | 97 ++++++++++++++++--- .../remoteconfig/RemoteConfigManagerTest.kt | 53 ++++++++++ 2 files changed, 139 insertions(+), 11 deletions(-) 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..c740fa77c1 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 @@ -87,6 +87,9 @@ internal class RemoteConfigManager( ) { private val isRefreshing = AtomicBoolean(false) + @Volatile + private var isClosed = false + // Bumped by clearCache() on every identity change. A request captures the epoch when it starts; once it // changes, the in-flight request's callbacks drop their result (the /v1/config request itself cannot be // socket-cancelled), so an old user's response can never persist over the wiped cache. @@ -390,13 +393,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) @@ -435,6 +441,8 @@ internal class RemoteConfigManager( fun close() { scope.cancel() synchronized(cacheLock) { + isClosed = true + generation.incrementAndGet() isRefreshing.set(false) completeRefresh() } @@ -644,9 +652,59 @@ internal class RemoteConfigManager( itemKey: String, transform: (ByteArray) -> T?, ): T? = withContext(ioDispatcher) { - resolveBlobBytes(topic, itemKey)?.let(transform) + blobDataSnapshot(topic, itemKey, transform)?.value } + /** Resolves a blob and retains the committed-config identity that produced it. */ + suspend fun blobDataSnapshot( + topic: RemoteConfigTopic, + itemKey: String, + transform: (ByteArray) -> T?, + ): RemoteConfigBlobData? = withContext(ioDispatcher) { + val item = committedItem(topic, itemKey) ?: return@withContext null + val readState = synchronized(cacheLock) { + val currentRef = topicStore.topic(topic)?.get(itemKey)?.blobRef + if (disabled || isClosed) { + null + } else if (currentRef == null || currentRef != item.blobRef) { + null + } else { + RemoteConfigReadState(epoch.get(), generation.get(), currentRef) + } + } ?: return@withContext null + + val bytes = resolveBlobBytes(readState.blobRef, itemKey) ?: return@withContext null + val value = transform(bytes) ?: return@withContext null + synchronized(cacheLock) { + if (!isCurrent(topic, itemKey, readState)) { + null + } else { + RemoteConfigBlobData(value, topic, itemKey, readState) + } + } + } + + /** Runs [action] only while [blobData] still belongs to the current committed config. */ + fun useIfCurrent(blobData: RemoteConfigBlobData, action: (T) -> Unit): Boolean = + synchronized(cacheLock) { + if (!isCurrent(blobData.topic, blobData.itemKey, blobData.readState)) { + false + } else { + action(blobData.value) + true + } + } + + private fun isCurrent( + topic: RemoteConfigTopic, + itemKey: String, + readState: RemoteConfigReadState, + ): Boolean = !disabled && + !isClosed && + epoch.get() == readState.epoch && + generation.get() == readState.generation && + topicStore.topic(topic)?.get(itemKey)?.blobRef == readState.blobRef + /** * Resolves the blobs for every key in [itemKeys] within [topic] **concurrently**, builds a single JSON * object mapping **each item key to that item's parsed blob JSON**, and decodes it into a single [T]. Use @@ -746,11 +804,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) { @@ -882,6 +944,19 @@ internal class RemoteConfigManager( } } +internal data class RemoteConfigReadState( + val epoch: Int, + val generation: Int, + val blobRef: String, +) + +internal class RemoteConfigBlobData internal constructor( + val value: T, + internal val topic: RemoteConfigTopic, + internal val itemKey: String, + internal val readState: RemoteConfigReadState, +) + /** The blob refs each topic's items reference, keyed by topic name (empty list for inline-only topics). */ internal fun Map.toTopicBlobRefs(): Map> = mapValues { (_, topic) -> topic.values.mapNotNull { it.blobRef } } 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..11071926ce 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 discards bytes 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).isNull() + } + + @Test + fun `blobData discards bytes 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).isNull() + } + @Test fun `reified blobData fetches the blob on demand then deserializes it, like the transform overload`() = runTest { every { diskCache.read() } returns persisted( From a64c87c88b25e257a711fe9bcf3c0edfdca4c2a5 Mon Sep 17 00:00:00 2001 From: Rick van der Linden Date: Wed, 22 Jul 2026 12:11:15 +0200 Subject: [PATCH 2/9] Fetch offline entitlement mappings through Remote Config --- .../revenuecat/purchases/PurchasesFactory.kt | 45 +++++++++++----- .../purchases/PurchasesOrchestrator.kt | 1 + .../OfflineEntitlementsManager.kt | 54 +++++++++++++++---- .../ProductEntitlementMappingTopicProvider.kt | 45 ++++++++++++++++ .../common/remoteconfig/RemoteConfigTopic.kt | 1 + 5 files changed, 123 insertions(+), 23 deletions(-) create mode 100644 purchases/src/main/kotlin/com/revenuecat/purchases/common/offlineentitlements/ProductEntitlementMappingTopicProvider.kt diff --git a/purchases/src/main/kotlin/com/revenuecat/purchases/PurchasesFactory.kt b/purchases/src/main/kotlin/com/revenuecat/purchases/PurchasesFactory.kt index 3ed0a4894a..5d43314b4e 100644 --- a/purchases/src/main/kotlin/com/revenuecat/purchases/PurchasesFactory.kt +++ b/purchases/src/main/kotlin/com/revenuecat/purchases/PurchasesFactory.kt @@ -33,8 +33,10 @@ import com.revenuecat.purchases.common.networking.ETagManager import com.revenuecat.purchases.common.offerings.OfferingsCache import com.revenuecat.purchases.common.offerings.OfferingsFactory import com.revenuecat.purchases.common.offerings.OfferingsManager +import com.revenuecat.purchases.common.offlineentitlements.EntitlementMappingTopicProvider 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 +269,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 +288,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 +637,16 @@ internal class PurchasesFactory( } companion object { + @VisibleForTesting + internal fun createProductEntitlementMappingTopicProvider( + remoteConfigEnabled: Boolean, + remoteConfigManager: RemoteConfigManager?, + ): EntitlementMappingTopicProvider? = 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..064208ce8c 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,22 @@ 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.Dispatchers +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: EntitlementMappingTopicProvider? = 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? @@ -101,22 +109,50 @@ 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) + val topicProvider = productEntitlementMappingTopicProvider + if (topicProvider == null) { + fetchLegacyProductEntitlementMapping(completion) + return + } + scope.launch { + val result = topicProvider.getProductEntitlementMapping() + if (result != null && result.cacheIfCurrent(deviceCache::cacheProductEntitlementMapping)) { debugLog { OfflineEntitlementsStrings.SUCCESSFULLY_UPDATED_PRODUCT_ENTITLEMENTS } completion?.invoke(null) - }, - onErrorHandler = { e -> - errorLog { OfflineEntitlementsStrings.ERROR_UPDATING_PRODUCT_ENTITLEMENTS.format(e) } - completion?.invoke(e) - }, - ) + } else { + fetchLegacyProductEntitlementMapping(completion) + } + } } else { completion?.invoke(null) } } + fun close() { + scope.cancel() + } + + 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 // 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. 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..611d31ddfd --- /dev/null +++ b/purchases/src/main/kotlin/com/revenuecat/purchases/common/offlineentitlements/ProductEntitlementMappingTopicProvider.kt @@ -0,0 +1,45 @@ +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, +) : EntitlementMappingTopicProvider { + override suspend fun getProductEntitlementMapping(): ProductEntitlementMappingResult? { + val blobData = manager.blobDataSnapshot(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 + } + } ?: return null + return ProductEntitlementMappingResult(blobData.value) { action -> + manager.useIfCurrent(blobData, action) + } + } + + private companion object { + private const val ITEM_KEY = "default" + } +} + +@OptIn(InternalRevenueCatAPI::class) +internal interface EntitlementMappingTopicProvider { + suspend fun getProductEntitlementMapping(): ProductEntitlementMappingResult? +} + +@OptIn(InternalRevenueCatAPI::class) +internal class ProductEntitlementMappingResult( + val mapping: ProductEntitlementMapping, + private val useIfCurrent: ((ProductEntitlementMapping) -> Unit) -> Boolean, +) { + fun cacheIfCurrent(action: (ProductEntitlementMapping) -> Unit): Boolean = useIfCurrent.invoke(action) +} 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"), } From 62678daa58da35866744f9af9837a9e5f80d87f2 Mon Sep 17 00:00:00 2001 From: Rick van der Linden Date: Wed, 22 Jul 2026 12:11:38 +0200 Subject: [PATCH 3/9] Test offline entitlement Remote Config fallback behavior --- .../revenuecat/purchases/BasePurchasesTest.kt | 3 + .../purchases/PurchasesCommonTest.kt | 1 + .../purchases/PurchasesFactoryTest.kt | 22 +++ .../OfflineEntitlementsManagerTest.kt | 136 +++++++++++++++++- ...ductEntitlementMappingTopicProviderTest.kt | 127 ++++++++++++++++ .../RemoteConfigManagerIntegrationTest.kt | 90 +++++++++++- 6 files changed, 377 insertions(+), 2 deletions(-) create mode 100644 purchases/src/test/java/com/revenuecat/purchases/common/offlineentitlements/ProductEntitlementMappingTopicProviderTest.kt 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..73dcf3ba2f 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,111 @@ 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 currentResult(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 `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 `updateProductEntitlementMappingCacheIfStale does not cache mapping invalidated after provider read`() { + val topicProvider = mockk() + val mapping = mockk() + coEvery { topicProvider.getProductEntitlementMapping() } returns + ProductEntitlementMappingResult(mapping) { false } + offlineEntitlementsManager = managerWithTopicProvider(topicProvider) + every { deviceCache.isProductEntitlementMappingCacheStale() } returns true + + offlineEntitlementsManager.updateProductEntitlementMappingCacheIfStale() + + verify(exactly = 0) { deviceCache.cacheProductEntitlementMapping(any()) } + verify(exactly = 1) { backend.getProductEntitlementMapping(any(), 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(currentResult(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 +584,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 +610,25 @@ class OfflineEntitlementsManagerTest { // region helpers + private fun managerWithTopicProvider( + topicProvider: EntitlementMappingTopicProvider, + managerScope: CoroutineScope = CoroutineScope(UnconfinedTestDispatcher()), + ): OfflineEntitlementsManager = OfflineEntitlementsManager( + backend, + offlineEntitlementsCalculator, + deviceCache, + appConfig, + diagnosticsTracker, + topicProvider, + managerScope, + ) + + private fun currentResult(mapping: ProductEntitlementMapping) = + ProductEntitlementMappingResult(mapping) { action -> + action(mapping) + true + } + 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..76a7430e3a --- /dev/null +++ b/purchases/src/test/java/com/revenuecat/purchases/common/offlineentitlements/ProductEntitlementMappingTopicProviderTest.kt @@ -0,0 +1,127 @@ +package com.revenuecat.purchases.common.offlineentitlements + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.revenuecat.purchases.common.remoteconfig.RemoteConfigBlobData +import com.revenuecat.purchases.common.remoteconfig.RemoteConfigManager +import com.revenuecat.purchases.common.remoteconfig.RemoteConfigReadState +import com.revenuecat.purchases.common.remoteconfig.RemoteConfigTopic +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.every +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?.mapping?.mappings).containsOnlyKeys("monthly") + assertThat(result?.mapping?.mappings?.get("monthly")).isEqualTo( + ProductEntitlementMapping.Mapping( + productIdentifier = "monthly", + basePlanId = "monthly-base-plan", + entitlements = listOf("pro"), + ), + ) + coVerify(exactly = 1) { + manager.blobDataSnapshot( + RemoteConfigTopic.ProductEntitlementMapping, + "default", + any<(ByteArray) -> ProductEntitlementMapping?>(), + ) + } + } + + @Test + fun `getProductEntitlementMapping returns null when the default blob is unavailable`() = runTest { + coEvery { + manager.blobDataSnapshot( + 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() + } + + @Test + fun `getProductEntitlementMapping result is rejected when config changes before use`() = runTest { + stubBlobRead( + """ + { + "product_entitlement_mapping": { + "monthly": { + "product_identifier": "monthly", + "entitlements": ["pro"] + } + } + } + """.trimIndent().toByteArray(), + ) + + every { manager.useIfCurrent(any>(), any()) } returns false + + val result = provider.getProductEntitlementMapping() + + assertThat(result?.cacheIfCurrent {}).isFalse() + } + + private fun stubBlobRead(blob: ByteArray) { + coEvery { + manager.blobDataSnapshot( + RemoteConfigTopic.ProductEntitlementMapping, + "default", + any<(ByteArray) -> ProductEntitlementMapping?>(), + ) + } answers { + val value = thirdArg<(ByteArray) -> ProductEntitlementMapping?>().invoke(blob) + value?.let { + RemoteConfigBlobData( + it, + RemoteConfigTopic.ProductEntitlementMapping, + "default", + RemoteConfigReadState(0, 0, "ref"), + ) + } + } + } +} 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..2a303c3ad4 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?.mapping?.mappings).containsOnlyKeys("monthly") + assertThat(mapping?.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?.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, From d955d4ff92f73c4e8970a462337f190ac66d3fff Mon Sep 17 00:00:00 2001 From: Rick van der Linden Date: Wed, 22 Jul 2026 12:11:50 +0200 Subject: [PATCH 4/9] Clarify offline entitlement observer mode documentation --- .../common/offlineentitlements/OfflineEntitlementsManager.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 064208ce8c..fca127fa9e 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 @@ -153,7 +153,7 @@ internal class OfflineEntitlementsManager( 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 && From 11de00436efdd49efcb0d6b4e4d00c31bc748e8f Mon Sep 17 00:00:00 2001 From: Rick van der Linden Date: Thu, 23 Jul 2026 07:43:09 +0200 Subject: [PATCH 5/9] Simplify generation-guarded blob reads --- .../remoteconfig/RemoteConfigManager.kt | 34 ++---- ...ductEntitlementMappingTopicProviderTest.kt | 8 +- .../remoteconfig/RemoteConfigManagerTest.kt | 108 ++++++++++++++++++ 3 files changed, 117 insertions(+), 33 deletions(-) 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 c740fa77c1..4959b26adc 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 @@ -655,31 +655,31 @@ internal class RemoteConfigManager( blobDataSnapshot(topic, itemKey, transform)?.value } - /** Resolves a blob and retains the committed-config identity that produced it. */ + /** Resolves a blob and retains the committed-config generation that produced it. */ suspend fun blobDataSnapshot( topic: RemoteConfigTopic, itemKey: String, transform: (ByteArray) -> T?, ): RemoteConfigBlobData? = withContext(ioDispatcher) { val item = committedItem(topic, itemKey) ?: return@withContext null - val readState = synchronized(cacheLock) { + val (readGeneration, blobRef) = synchronized(cacheLock) { val currentRef = topicStore.topic(topic)?.get(itemKey)?.blobRef if (disabled || isClosed) { null } else if (currentRef == null || currentRef != item.blobRef) { null } else { - RemoteConfigReadState(epoch.get(), generation.get(), currentRef) + generation.get() to currentRef } } ?: return@withContext null - val bytes = resolveBlobBytes(readState.blobRef, itemKey) ?: return@withContext null + val bytes = resolveBlobBytes(blobRef, itemKey) ?: return@withContext null val value = transform(bytes) ?: return@withContext null synchronized(cacheLock) { - if (!isCurrent(topic, itemKey, readState)) { + if (disabled || isClosed || generation.get() != readGeneration) { null } else { - RemoteConfigBlobData(value, topic, itemKey, readState) + RemoteConfigBlobData(value, readGeneration) } } } @@ -687,7 +687,7 @@ internal class RemoteConfigManager( /** Runs [action] only while [blobData] still belongs to the current committed config. */ fun useIfCurrent(blobData: RemoteConfigBlobData, action: (T) -> Unit): Boolean = synchronized(cacheLock) { - if (!isCurrent(blobData.topic, blobData.itemKey, blobData.readState)) { + if (disabled || isClosed || generation.get() != blobData.generation) { false } else { action(blobData.value) @@ -695,16 +695,6 @@ internal class RemoteConfigManager( } } - private fun isCurrent( - topic: RemoteConfigTopic, - itemKey: String, - readState: RemoteConfigReadState, - ): Boolean = !disabled && - !isClosed && - epoch.get() == readState.epoch && - generation.get() == readState.generation && - topicStore.topic(topic)?.get(itemKey)?.blobRef == readState.blobRef - /** * Resolves the blobs for every key in [itemKeys] within [topic] **concurrently**, builds a single JSON * object mapping **each item key to that item's parsed blob JSON**, and decodes it into a single [T]. Use @@ -944,17 +934,9 @@ internal class RemoteConfigManager( } } -internal data class RemoteConfigReadState( - val epoch: Int, - val generation: Int, - val blobRef: String, -) - internal class RemoteConfigBlobData internal constructor( val value: T, - internal val topic: RemoteConfigTopic, - internal val itemKey: String, - internal val readState: RemoteConfigReadState, + internal val generation: Int, ) /** The blob refs each topic's items reference, keyed by topic name (empty list for inline-only topics). */ 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 index 76a7430e3a..5754a3b260 100644 --- a/purchases/src/test/java/com/revenuecat/purchases/common/offlineentitlements/ProductEntitlementMappingTopicProviderTest.kt +++ b/purchases/src/test/java/com/revenuecat/purchases/common/offlineentitlements/ProductEntitlementMappingTopicProviderTest.kt @@ -3,7 +3,6 @@ package com.revenuecat.purchases.common.offlineentitlements import androidx.test.ext.junit.runners.AndroidJUnit4 import com.revenuecat.purchases.common.remoteconfig.RemoteConfigBlobData import com.revenuecat.purchases.common.remoteconfig.RemoteConfigManager -import com.revenuecat.purchases.common.remoteconfig.RemoteConfigReadState import com.revenuecat.purchases.common.remoteconfig.RemoteConfigTopic import io.mockk.coEvery import io.mockk.coVerify @@ -115,12 +114,7 @@ internal class ProductEntitlementMappingTopicProviderTest { } answers { val value = thirdArg<(ByteArray) -> ProductEntitlementMapping?>().invoke(blob) value?.let { - RemoteConfigBlobData( - it, - RemoteConfigTopic.ProductEntitlementMapping, - "default", - RemoteConfigReadState(0, 0, "ref"), - ) + RemoteConfigBlobData(it, 0) } } } 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 11071926ce..dea1a5d220 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,114 @@ class RemoteConfigManagerTest { coVerify(exactly = 1) { blobFetcher.ensureDownloaded(REF_VALID) } } + @Test + fun `useIfCurrent executes exactly once for a current blob snapshot`() = 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) } returns true + every { blobStore.read(REF_VALID) } returns byteArrayOf(4, 2) + val manager = readManager() + val snapshot = manager.blobDataSnapshot(RemoteConfigTopic.Workflows, "wf1") { it } + var invocationCount = 0 + + val used = manager.useIfCurrent(snapshot!!) { + invocationCount++ + assertThat(it).isEqualTo(byteArrayOf(4, 2)) + } + + assertThat(used).isTrue + assertThat(invocationCount).isEqualTo(1) + } + + @Test + fun `useIfCurrent rejects a blob snapshot after identity invalidation`() = 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) } returns true + every { blobStore.read(REF_VALID) } returns byteArrayOf(4, 2) + val manager = readManager() + val snapshot = manager.blobDataSnapshot(RemoteConfigTopic.Workflows, "wf1") { it } + manager.clearCache("new-user") + var invoked = false + + assertThat(manager.useIfCurrent(snapshot!!) { invoked = true }).isFalse() + assertThat(invoked).isFalse() + } + + @Test + fun `useIfCurrent rejects a blob snapshot after a newer config commit`() = 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) } returns true + every { blobStore.read(REF_VALID) } returns byteArrayOf(4, 2) + val manager = readManager() + val snapshot = manager.blobDataSnapshot(RemoteConfigTopic.Workflows, "wf1") { it } + manager.refreshRemoteConfig( + appInBackground = false, + appUserID = TEST_APP_USER_ID, + fetchContext = DEFAULT_FETCH_CONTEXT, + ) + val response = """ + { + "domain": "app", + "manifest": "v2", + "active_topics": ["workflows"], + "topics": { "workflows": { "wf1": { "blob_ref": "$REF_TAMPERED" } } } + } + """.trimIndent() + onSuccess.invoke(containerWithConfig(response), VerificationResult.VERIFIED) + var invoked = false + + assertThat(manager.useIfCurrent(snapshot!!) { invoked = true }).isFalse() + assertThat(invoked).isFalse() + } + + @Test + fun `useIfCurrent rejects a blob snapshot after disabling or closing the manager`() = 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) } returns true + every { blobStore.read(REF_VALID) } returns byteArrayOf(4, 2) + + val disabledManager = readManager() + val disabledSnapshot = disabledManager.blobDataSnapshot(RemoteConfigTopic.Workflows, "wf1") { it } + disabledManager.refreshRemoteConfig( + appInBackground = false, + appUserID = TEST_APP_USER_ID, + fetchContext = DEFAULT_FETCH_CONTEXT, + ) + onError.invoke( + PurchasesError(PurchasesErrorCode.InvalidCredentialsError, "bad request"), + GetRemoteConfigErrorHandlingBehavior.SHOULD_DISABLE, + ) + assertThat(disabledManager.useIfCurrent(disabledSnapshot!!) {}).isFalse() + + val closedManager = readManager() + val closedSnapshot = closedManager.blobDataSnapshot(RemoteConfigTopic.Workflows, "wf1") { it } + closedManager.close() + assertThat(closedManager.useIfCurrent(closedSnapshot!!) {}).isFalse() + } + @Test fun `blobData discards bytes when identity changes during blob resolution`() = runTest { every { diskCache.read() } returns persisted( From 9e3f75dec31d5c449d4ceb6899f9d5e9bd5441aa Mon Sep 17 00:00:00 2001 From: Rick van der Linden Date: Thu, 23 Jul 2026 08:59:51 +0200 Subject: [PATCH 6/9] Keep PEM reads isolated from existing blob consumers --- .../OfflineEntitlementsManager.kt | 67 +++++++++++++++++-- .../remoteconfig/RemoteConfigManager.kt | 2 +- .../OfflineEntitlementsManagerTest.kt | 24 +++++++ .../remoteconfig/RemoteConfigManagerTest.kt | 39 ++++++++++- 4 files changed, 122 insertions(+), 10 deletions(-) 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 fca127fa9e..8759f306bd 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 @@ -14,7 +14,9 @@ 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 @@ -39,6 +41,10 @@ internal class OfflineEntitlementsManager( private val offlineCustomerInfoCallbackCache = mutableMapOf>() + private val productEntitlementMappingUpdateLock = Any() + private var productEntitlementMappingUpdateJob: Job? = null + private val productEntitlementMappingUpdateCompletions = mutableListOf<(PurchasesError?) -> Unit>() + @Synchronized fun resetOfflineCustomerInfoCache() { if (_offlineCustomerInfo != null) { @@ -114,24 +120,73 @@ internal class OfflineEntitlementsManager( fetchLegacyProductEntitlementMapping(completion) return } - scope.launch { - val result = topicProvider.getProductEntitlementMapping() - if (result != null && result.cacheIfCurrent(deviceCache::cacheProductEntitlementMapping)) { - debugLog { OfflineEntitlementsStrings.SUCCESSFULLY_UPDATED_PRODUCT_ENTITLEMENTS } - completion?.invoke(null) + + val updateJob = synchronized(productEntitlementMappingUpdateLock) { + completion?.let(productEntitlementMappingUpdateCompletions::add) + if (productEntitlementMappingUpdateJob != null) { + null } else { - fetchLegacyProductEntitlementMapping(completion) + lateinit var newUpdateJob: Job + newUpdateJob = scope.launch(start = CoroutineStart.LAZY) { + var waitingForLegacyResult = false + try { + val result = topicProvider.getProductEntitlementMapping() + if (result != null && + result.cacheIfCurrent(deviceCache::cacheProductEntitlementMapping) + ) { + debugLog { OfflineEntitlementsStrings.SUCCESSFULLY_UPDATED_PRODUCT_ENTITLEMENTS } + finishProductEntitlementMappingUpdate(newUpdateJob, null) + } else { + waitingForLegacyResult = true + fetchLegacyProductEntitlementMapping { + finishProductEntitlementMappingUpdate(newUpdateJob, it) + } + } + } finally { + if (!waitingForLegacyResult) { + finishProductEntitlementMappingUpdate(newUpdateJob, null, notifyCompletions = false) + } + } + } + productEntitlementMappingUpdateJob = newUpdateJob + newUpdateJob } } + updateJob?.start() } else { completion?.invoke(null) } } fun close() { + synchronized(productEntitlementMappingUpdateLock) { + productEntitlementMappingUpdateJob = null + productEntitlementMappingUpdateCompletions.clear() + } scope.cancel() } + private fun finishProductEntitlementMappingUpdate( + updateJob: Job, + error: PurchasesError?, + notifyCompletions: Boolean = true, + ) { + val completions = synchronized(productEntitlementMappingUpdateLock) { + if (productEntitlementMappingUpdateJob !== updateJob) { + return@synchronized emptyList() + } + productEntitlementMappingUpdateJob = null + productEntitlementMappingUpdateCompletions + .takeIf { notifyCompletions } + .orEmpty() + .toList() + .also { + productEntitlementMappingUpdateCompletions.clear() + } + } + completions.forEach { it(error) } + } + private fun fetchLegacyProductEntitlementMapping(completion: ((PurchasesError?) -> Unit)?) { backend.getProductEntitlementMapping( onSuccessHandler = { productEntitlementMapping -> 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 4959b26adc..db45416c5e 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 @@ -652,7 +652,7 @@ internal class RemoteConfigManager( itemKey: String, transform: (ByteArray) -> T?, ): T? = withContext(ioDispatcher) { - blobDataSnapshot(topic, itemKey, transform)?.value + resolveBlobBytes(topic, itemKey)?.let(transform) } /** Resolves a blob and retains the committed-config generation that produced it. */ 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 73dcf3ba2f..8f060563e3 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 @@ -481,6 +481,30 @@ class OfflineEntitlementsManagerTest { verify(exactly = 0) { backend.getProductEntitlementMapping(any(), any()) } } + @Test + fun `concurrent mapping updates share one remote config job and complete every caller`() = 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 completionCallCount = 0 + + offlineEntitlementsManager.updateProductEntitlementMappingCacheIfStale { completionCallCount++ } + offlineEntitlementsManager.updateProductEntitlementMappingCacheIfStale { completionCallCount++ } + testScheduler.runCurrent() + result.complete(currentResult(mapping)) + testScheduler.advanceUntilIdle() + + coVerify(exactly = 1) { topicProvider.getProductEntitlementMapping() } + verify(exactly = 1) { deviceCache.cacheProductEntitlementMapping(mapping) } + verify(exactly = 0) { backend.getProductEntitlementMapping(any(), any()) } + assertThat(completionCallCount).isEqualTo(2) + } + @Test fun `updateProductEntitlementMappingCacheIfStale falls back when remote config mapping is unavailable`() { val topicProvider = mockk() 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 dea1a5d220..0ea1334161 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 @@ -1847,7 +1847,7 @@ class RemoteConfigManagerTest { } @Test - fun `blobData discards bytes when identity changes during blob resolution`() = runTest { + fun `blobData retains legacy behavior when identity changes during blob resolution`() = runTest { every { diskCache.read() } returns persisted( manifest = "m", activeTopics = listOf("workflows"), @@ -1863,11 +1863,11 @@ class RemoteConfigManagerTest { val result = manager.blobData(RemoteConfigTopic.Workflows, "wf1") { it } - assertThat(result).isNull() + assertThat(result).isEqualTo(byteArrayOf(4, 2)) } @Test - fun `blobData discards bytes when the item ref changes during blob resolution`() = runTest { + fun `blobData retains legacy behavior when the item ref changes during blob resolution`() = runTest { every { diskCache.read() } returns persisted( manifest = "m", activeTopics = listOf("workflows"), @@ -1896,6 +1896,39 @@ class RemoteConfigManagerTest { val result = manager.blobData(RemoteConfigTopic.Workflows, "wf1") { it } + assertThat(result).isEqualTo(byteArrayOf(4, 2)) + } + + @Test + fun `blobDataSnapshot discards bytes 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.blobDataSnapshot(RemoteConfigTopic.Workflows, "wf1") { it } + assertThat(result).isNull() } From 0ffade1688f84d16fe48a142e279cdddbec78730 Mon Sep 17 00:00:00 2001 From: Rick van der Linden Date: Thu, 23 Jul 2026 09:10:10 +0200 Subject: [PATCH 7/9] Avoid duplicate offline entitlement mapping updates --- .../OfflineEntitlementsManager.kt | 36 +++++++------------ .../OfflineEntitlementsManagerTest.kt | 12 ++++--- 2 files changed, 20 insertions(+), 28 deletions(-) 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 8759f306bd..f4483c28e6 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 @@ -43,7 +43,6 @@ internal class OfflineEntitlementsManager( private val productEntitlementMappingUpdateLock = Any() private var productEntitlementMappingUpdateJob: Job? = null - private val productEntitlementMappingUpdateCompletions = mutableListOf<(PurchasesError?) -> Unit>() @Synchronized fun resetOfflineCustomerInfoCache() { @@ -122,7 +121,6 @@ internal class OfflineEntitlementsManager( } val updateJob = synchronized(productEntitlementMappingUpdateLock) { - completion?.let(productEntitlementMappingUpdateCompletions::add) if (productEntitlementMappingUpdateJob != null) { null } else { @@ -135,16 +133,20 @@ internal class OfflineEntitlementsManager( result.cacheIfCurrent(deviceCache::cacheProductEntitlementMapping) ) { debugLog { OfflineEntitlementsStrings.SUCCESSFULLY_UPDATED_PRODUCT_ENTITLEMENTS } - finishProductEntitlementMappingUpdate(newUpdateJob, null) + if (finishProductEntitlementMappingUpdate(newUpdateJob)) { + completion?.invoke(null) + } } else { waitingForLegacyResult = true fetchLegacyProductEntitlementMapping { - finishProductEntitlementMappingUpdate(newUpdateJob, it) + if (finishProductEntitlementMappingUpdate(newUpdateJob)) { + completion?.invoke(it) + } } } } finally { if (!waitingForLegacyResult) { - finishProductEntitlementMappingUpdate(newUpdateJob, null, notifyCompletions = false) + finishProductEntitlementMappingUpdate(newUpdateJob) } } } @@ -161,31 +163,19 @@ internal class OfflineEntitlementsManager( fun close() { synchronized(productEntitlementMappingUpdateLock) { productEntitlementMappingUpdateJob = null - productEntitlementMappingUpdateCompletions.clear() } scope.cancel() } - private fun finishProductEntitlementMappingUpdate( - updateJob: Job, - error: PurchasesError?, - notifyCompletions: Boolean = true, - ) { - val completions = synchronized(productEntitlementMappingUpdateLock) { + private fun finishProductEntitlementMappingUpdate(updateJob: Job): Boolean = + synchronized(productEntitlementMappingUpdateLock) { if (productEntitlementMappingUpdateJob !== updateJob) { - return@synchronized emptyList() + false + } else { + productEntitlementMappingUpdateJob = null + true } - productEntitlementMappingUpdateJob = null - productEntitlementMappingUpdateCompletions - .takeIf { notifyCompletions } - .orEmpty() - .toList() - .also { - productEntitlementMappingUpdateCompletions.clear() - } } - completions.forEach { it(error) } - } private fun fetchLegacyProductEntitlementMapping(completion: ((PurchasesError?) -> Unit)?) { backend.getProductEntitlementMapping( 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 8f060563e3..2472b94f55 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 @@ -482,7 +482,7 @@ class OfflineEntitlementsManagerTest { } @Test - fun `concurrent mapping updates share one remote config job and complete every caller`() = runTest { + fun `mapping update does nothing when a remote config job is already pending`() = runTest { val topicProvider = mockk() val result = CompletableDeferred() val mapping = createProductEntitlementMapping() @@ -491,10 +491,11 @@ class OfflineEntitlementsManagerTest { every { deviceCache.isProductEntitlementMappingCacheStale() } returns true every { deviceCache.cacheProductEntitlementMapping(mapping) } just Runs coEvery { topicProvider.getProductEntitlementMapping() } coAnswers { result.await() } - var completionCallCount = 0 + var firstCompletionCallCount = 0 + var secondCompletionCallCount = 0 - offlineEntitlementsManager.updateProductEntitlementMappingCacheIfStale { completionCallCount++ } - offlineEntitlementsManager.updateProductEntitlementMappingCacheIfStale { completionCallCount++ } + offlineEntitlementsManager.updateProductEntitlementMappingCacheIfStale { firstCompletionCallCount++ } + offlineEntitlementsManager.updateProductEntitlementMappingCacheIfStale { secondCompletionCallCount++ } testScheduler.runCurrent() result.complete(currentResult(mapping)) testScheduler.advanceUntilIdle() @@ -502,7 +503,8 @@ class OfflineEntitlementsManagerTest { coVerify(exactly = 1) { topicProvider.getProductEntitlementMapping() } verify(exactly = 1) { deviceCache.cacheProductEntitlementMapping(mapping) } verify(exactly = 0) { backend.getProductEntitlementMapping(any(), any()) } - assertThat(completionCallCount).isEqualTo(2) + assertThat(firstCompletionCallCount).isEqualTo(1) + assertThat(secondCompletionCallCount).isZero() } @Test From 110edbed4d5555089222a6ad1ffe54653ca143f9 Mon Sep 17 00:00:00 2001 From: Rick van der Linden Date: Thu, 23 Jul 2026 09:19:39 +0200 Subject: [PATCH 8/9] Simplify offline entitlement mapping updates --- .../OfflineEntitlementsManager.kt | 104 +++++++++--------- 1 file changed, 53 insertions(+), 51 deletions(-) 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 f4483c28e6..dfc113efad 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 @@ -112,52 +112,16 @@ internal class OfflineEntitlementsManager( } fun updateProductEntitlementMappingCacheIfStale(completion: ((PurchasesError?) -> Unit)? = null) { - if (isOfflineEntitlementsEnabled() && deviceCache.isProductEntitlementMappingCacheStale()) { - debugLog { OfflineEntitlementsStrings.UPDATING_PRODUCT_ENTITLEMENT_MAPPING } - val topicProvider = productEntitlementMappingTopicProvider - if (topicProvider == null) { - fetchLegacyProductEntitlementMapping(completion) - return - } - - val updateJob = synchronized(productEntitlementMappingUpdateLock) { - if (productEntitlementMappingUpdateJob != null) { - null - } else { - lateinit var newUpdateJob: Job - newUpdateJob = scope.launch(start = CoroutineStart.LAZY) { - var waitingForLegacyResult = false - try { - val result = topicProvider.getProductEntitlementMapping() - if (result != null && - result.cacheIfCurrent(deviceCache::cacheProductEntitlementMapping) - ) { - debugLog { OfflineEntitlementsStrings.SUCCESSFULLY_UPDATED_PRODUCT_ENTITLEMENTS } - if (finishProductEntitlementMappingUpdate(newUpdateJob)) { - completion?.invoke(null) - } - } else { - waitingForLegacyResult = true - fetchLegacyProductEntitlementMapping { - if (finishProductEntitlementMappingUpdate(newUpdateJob)) { - completion?.invoke(it) - } - } - } - } finally { - if (!waitingForLegacyResult) { - finishProductEntitlementMappingUpdate(newUpdateJob) - } - } - } - productEntitlementMappingUpdateJob = newUpdateJob - newUpdateJob - } - } - updateJob?.start() - } 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() { @@ -167,15 +131,53 @@ internal class OfflineEntitlementsManager( scope.cancel() } - private fun finishProductEntitlementMappingUpdate(updateJob: Job): Boolean = - synchronized(productEntitlementMappingUpdateLock) { - if (productEntitlementMappingUpdateJob !== updateJob) { - false - } else { - productEntitlementMappingUpdateJob = null - true + private fun startProductEntitlementMappingUpdate( + topicProvider: EntitlementMappingTopicProvider, + 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 result = topicProvider.getProductEntitlementMapping() + if (result?.cacheIfCurrent(deviceCache::cacheProductEntitlementMapping) == true) { + 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( From 40b147f79e7ad90ee7423ef9aa90532ce36c9d91 Mon Sep 17 00:00:00 2001 From: Rick van der Linden Date: Thu, 23 Jul 2026 15:05:32 +0200 Subject: [PATCH 9/9] Simplify remote config PEM reads --- .../revenuecat/purchases/PurchasesFactory.kt | 3 +- .../OfflineEntitlementsManager.kt | 9 +- .../ProductEntitlementMappingTopicProvider.kt | 23 +-- .../remoteconfig/RemoteConfigManager.kt | 50 ------- .../OfflineEntitlementsManagerTest.kt | 45 ++---- ...ductEntitlementMappingTopicProviderTest.kt | 39 +---- .../RemoteConfigManagerIntegrationTest.kt | 6 +- .../remoteconfig/RemoteConfigManagerTest.kt | 141 ------------------ 8 files changed, 30 insertions(+), 286 deletions(-) diff --git a/purchases/src/main/kotlin/com/revenuecat/purchases/PurchasesFactory.kt b/purchases/src/main/kotlin/com/revenuecat/purchases/PurchasesFactory.kt index 5d43314b4e..bb20320535 100644 --- a/purchases/src/main/kotlin/com/revenuecat/purchases/PurchasesFactory.kt +++ b/purchases/src/main/kotlin/com/revenuecat/purchases/PurchasesFactory.kt @@ -33,7 +33,6 @@ import com.revenuecat.purchases.common.networking.ETagManager import com.revenuecat.purchases.common.offerings.OfferingsCache import com.revenuecat.purchases.common.offerings.OfferingsFactory import com.revenuecat.purchases.common.offerings.OfferingsManager -import com.revenuecat.purchases.common.offlineentitlements.EntitlementMappingTopicProvider import com.revenuecat.purchases.common.offlineentitlements.OfflineCustomerInfoCalculator import com.revenuecat.purchases.common.offlineentitlements.OfflineEntitlementsManager import com.revenuecat.purchases.common.offlineentitlements.ProductEntitlementMappingTopicProvider @@ -641,7 +640,7 @@ internal class PurchasesFactory( internal fun createProductEntitlementMappingTopicProvider( remoteConfigEnabled: Boolean, remoteConfigManager: RemoteConfigManager?, - ): EntitlementMappingTopicProvider? = if (remoteConfigEnabled) { + ): ProductEntitlementMappingTopicProvider? = if (remoteConfigEnabled) { remoteConfigManager?.let(::ProductEntitlementMappingTopicProvider) } else { null 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 dfc113efad..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 @@ -29,7 +29,7 @@ internal class OfflineEntitlementsManager( private val deviceCache: DeviceCache, private val appConfig: AppConfig, private val diagnosticsTracker: DiagnosticsTracker?, - private val productEntitlementMappingTopicProvider: EntitlementMappingTopicProvider? = null, + 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. @@ -132,7 +132,7 @@ internal class OfflineEntitlementsManager( } private fun startProductEntitlementMappingUpdate( - topicProvider: EntitlementMappingTopicProvider, + topicProvider: ProductEntitlementMappingTopicProvider, completion: ((PurchasesError?) -> Unit)?, ) { fun finishUpdate(updateJob: Job): Boolean = @@ -159,8 +159,9 @@ internal class OfflineEntitlementsManager( val currentJob = coroutineContext[Job] ?: return@launch var waitingForLegacyResult = false try { - val result = topicProvider.getProductEntitlementMapping() - if (result?.cacheIfCurrent(deviceCache::cacheProductEntitlementMapping) == true) { + val mapping = topicProvider.getProductEntitlementMapping() + if (mapping != null) { + deviceCache.cacheProductEntitlementMapping(mapping) debugLog { OfflineEntitlementsStrings.SUCCESSFULLY_UPDATED_PRODUCT_ENTITLEMENTS } completeUpdate(currentJob, null) } else { 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 index 611d31ddfd..16c2167240 100644 --- a/purchases/src/main/kotlin/com/revenuecat/purchases/common/offlineentitlements/ProductEntitlementMappingTopicProvider.kt +++ b/purchases/src/main/kotlin/com/revenuecat/purchases/common/offlineentitlements/ProductEntitlementMappingTopicProvider.kt @@ -11,35 +11,18 @@ import org.json.JSONObject @OptIn(InternalRevenueCatAPI::class) internal class ProductEntitlementMappingTopicProvider( private val manager: RemoteConfigManager, -) : EntitlementMappingTopicProvider { - override suspend fun getProductEntitlementMapping(): ProductEntitlementMappingResult? { - val blobData = manager.blobDataSnapshot(RemoteConfigTopic.ProductEntitlementMapping, ITEM_KEY) { bytes -> +) { + 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 } - } ?: return null - return ProductEntitlementMappingResult(blobData.value) { action -> - manager.useIfCurrent(blobData, action) } - } private companion object { private const val ITEM_KEY = "default" } } - -@OptIn(InternalRevenueCatAPI::class) -internal interface EntitlementMappingTopicProvider { - suspend fun getProductEntitlementMapping(): ProductEntitlementMappingResult? -} - -@OptIn(InternalRevenueCatAPI::class) -internal class ProductEntitlementMappingResult( - val mapping: ProductEntitlementMapping, - private val useIfCurrent: ((ProductEntitlementMapping) -> Unit) -> Boolean, -) { - fun cacheIfCurrent(action: (ProductEntitlementMapping) -> Unit): Boolean = useIfCurrent.invoke(action) -} 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 db45416c5e..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 @@ -87,9 +87,6 @@ internal class RemoteConfigManager( ) { private val isRefreshing = AtomicBoolean(false) - @Volatile - private var isClosed = false - // Bumped by clearCache() on every identity change. A request captures the epoch when it starts; once it // changes, the in-flight request's callbacks drop their result (the /v1/config request itself cannot be // socket-cancelled), so an old user's response can never persist over the wiped cache. @@ -441,8 +438,6 @@ internal class RemoteConfigManager( fun close() { scope.cancel() synchronized(cacheLock) { - isClosed = true - generation.incrementAndGet() isRefreshing.set(false) completeRefresh() } @@ -655,46 +650,6 @@ internal class RemoteConfigManager( resolveBlobBytes(topic, itemKey)?.let(transform) } - /** Resolves a blob and retains the committed-config generation that produced it. */ - suspend fun blobDataSnapshot( - topic: RemoteConfigTopic, - itemKey: String, - transform: (ByteArray) -> T?, - ): RemoteConfigBlobData? = withContext(ioDispatcher) { - val item = committedItem(topic, itemKey) ?: return@withContext null - val (readGeneration, blobRef) = synchronized(cacheLock) { - val currentRef = topicStore.topic(topic)?.get(itemKey)?.blobRef - if (disabled || isClosed) { - null - } else if (currentRef == null || currentRef != item.blobRef) { - null - } else { - generation.get() to currentRef - } - } ?: return@withContext null - - val bytes = resolveBlobBytes(blobRef, itemKey) ?: return@withContext null - val value = transform(bytes) ?: return@withContext null - synchronized(cacheLock) { - if (disabled || isClosed || generation.get() != readGeneration) { - null - } else { - RemoteConfigBlobData(value, readGeneration) - } - } - } - - /** Runs [action] only while [blobData] still belongs to the current committed config. */ - fun useIfCurrent(blobData: RemoteConfigBlobData, action: (T) -> Unit): Boolean = - synchronized(cacheLock) { - if (disabled || isClosed || generation.get() != blobData.generation) { - false - } else { - action(blobData.value) - true - } - } - /** * Resolves the blobs for every key in [itemKeys] within [topic] **concurrently**, builds a single JSON * object mapping **each item key to that item's parsed blob JSON**, and decodes it into a single [T]. Use @@ -934,11 +889,6 @@ internal class RemoteConfigManager( } } -internal class RemoteConfigBlobData internal constructor( - val value: T, - internal val generation: Int, -) - /** The blob refs each topic's items reference, keyed by topic name (empty list for inline-only topics). */ internal fun Map.toTopicBlobRefs(): Map> = mapValues { (_, topic) -> topic.values.mapNotNull { it.blobRef } } 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 2472b94f55..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 @@ -450,7 +450,7 @@ class OfflineEntitlementsManagerTest { @Test fun `updateProductEntitlementMappingCacheIfStale does no remote config or legacy work if cache not stale`() { - val topicProvider = mockk() + val topicProvider = mockk() offlineEntitlementsManager = managerWithTopicProvider(topicProvider) every { deviceCache.isProductEntitlementMappingCacheStale() } returns false @@ -462,12 +462,12 @@ class OfflineEntitlementsManagerTest { @Test fun `updateProductEntitlementMappingCacheIfStale caches remote config mapping and skips legacy endpoint`() { - val topicProvider = mockk() + val topicProvider = mockk() val mapping = createProductEntitlementMapping() offlineEntitlementsManager = managerWithTopicProvider(topicProvider) every { deviceCache.isProductEntitlementMappingCacheStale() } returns true every { deviceCache.cacheProductEntitlementMapping(mapping) } just Runs - coEvery { topicProvider.getProductEntitlementMapping() } returns currentResult(mapping) + coEvery { topicProvider.getProductEntitlementMapping() } returns mapping var completionCallCount = 0 offlineEntitlementsManager.updateProductEntitlementMappingCacheIfStale { @@ -483,8 +483,8 @@ class OfflineEntitlementsManagerTest { @Test fun `mapping update does nothing when a remote config job is already pending`() = runTest { - val topicProvider = mockk() - val result = CompletableDeferred() + val topicProvider = mockk() + val result = CompletableDeferred() val mapping = createProductEntitlementMapping() val managerScope = CoroutineScope(SupervisorJob() + StandardTestDispatcher(testScheduler)) offlineEntitlementsManager = managerWithTopicProvider(topicProvider, managerScope) @@ -497,7 +497,7 @@ class OfflineEntitlementsManagerTest { offlineEntitlementsManager.updateProductEntitlementMappingCacheIfStale { firstCompletionCallCount++ } offlineEntitlementsManager.updateProductEntitlementMappingCacheIfStale { secondCompletionCallCount++ } testScheduler.runCurrent() - result.complete(currentResult(mapping)) + result.complete(mapping) testScheduler.advanceUntilIdle() coVerify(exactly = 1) { topicProvider.getProductEntitlementMapping() } @@ -509,7 +509,7 @@ class OfflineEntitlementsManagerTest { @Test fun `updateProductEntitlementMappingCacheIfStale falls back when remote config mapping is unavailable`() { - val topicProvider = mockk() + val topicProvider = mockk() val mapping = createProductEntitlementMapping() offlineEntitlementsManager = managerWithTopicProvider(topicProvider) every { deviceCache.isProductEntitlementMappingCacheStale() } returns true @@ -531,7 +531,7 @@ class OfflineEntitlementsManagerTest { @Test fun `updateProductEntitlementMappingCacheIfStale reports legacy error after remote config is unavailable`() { - val topicProvider = mockk() + val topicProvider = mockk() val error = PurchasesError(PurchasesErrorCode.UnknownBackendError, "legacy failed") offlineEntitlementsManager = managerWithTopicProvider(topicProvider) every { deviceCache.isProductEntitlementMappingCacheStale() } returns true @@ -545,25 +545,10 @@ class OfflineEntitlementsManagerTest { verify(exactly = 0) { deviceCache.cacheProductEntitlementMapping(any()) } } - @Test - fun `updateProductEntitlementMappingCacheIfStale does not cache mapping invalidated after provider read`() { - val topicProvider = mockk() - val mapping = mockk() - coEvery { topicProvider.getProductEntitlementMapping() } returns - ProductEntitlementMappingResult(mapping) { false } - offlineEntitlementsManager = managerWithTopicProvider(topicProvider) - every { deviceCache.isProductEntitlementMappingCacheStale() } returns true - - offlineEntitlementsManager.updateProductEntitlementMappingCacheIfStale() - - verify(exactly = 0) { deviceCache.cacheProductEntitlementMapping(any()) } - verify(exactly = 1) { backend.getProductEntitlementMapping(any(), any()) } - } - @Test fun `close cancels an in-flight remote config mapping read without starting legacy fallback`() = runTest { - val topicProvider = mockk() - val result = CompletableDeferred() + val topicProvider = mockk() + val result = CompletableDeferred() val managerScope = CoroutineScope(SupervisorJob() + StandardTestDispatcher(testScheduler)) offlineEntitlementsManager = managerWithTopicProvider(topicProvider, managerScope) every { deviceCache.isProductEntitlementMappingCacheStale() } returns true @@ -572,7 +557,7 @@ class OfflineEntitlementsManagerTest { offlineEntitlementsManager.updateProductEntitlementMappingCacheIfStale() testScheduler.runCurrent() offlineEntitlementsManager.close() - result.complete(currentResult(createProductEntitlementMapping())) + result.complete(createProductEntitlementMapping()) testScheduler.advanceUntilIdle() verify(exactly = 0) { deviceCache.cacheProductEntitlementMapping(any()) } @@ -637,7 +622,7 @@ class OfflineEntitlementsManagerTest { // region helpers private fun managerWithTopicProvider( - topicProvider: EntitlementMappingTopicProvider, + topicProvider: ProductEntitlementMappingTopicProvider, managerScope: CoroutineScope = CoroutineScope(UnconfinedTestDispatcher()), ): OfflineEntitlementsManager = OfflineEntitlementsManager( backend, @@ -649,12 +634,6 @@ class OfflineEntitlementsManagerTest { managerScope, ) - private fun currentResult(mapping: ProductEntitlementMapping) = - ProductEntitlementMappingResult(mapping) { action -> - action(mapping) - true - } - 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 index 5754a3b260..d0f7490145 100644 --- a/purchases/src/test/java/com/revenuecat/purchases/common/offlineentitlements/ProductEntitlementMappingTopicProviderTest.kt +++ b/purchases/src/test/java/com/revenuecat/purchases/common/offlineentitlements/ProductEntitlementMappingTopicProviderTest.kt @@ -1,12 +1,10 @@ package com.revenuecat.purchases.common.offlineentitlements import androidx.test.ext.junit.runners.AndroidJUnit4 -import com.revenuecat.purchases.common.remoteconfig.RemoteConfigBlobData 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.every import io.mockk.mockk import kotlinx.coroutines.test.runTest import org.assertj.core.api.Assertions.assertThat @@ -45,8 +43,8 @@ internal class ProductEntitlementMappingTopicProviderTest { val result = provider.getProductEntitlementMapping() - assertThat(result?.mapping?.mappings).containsOnlyKeys("monthly") - assertThat(result?.mapping?.mappings?.get("monthly")).isEqualTo( + assertThat(result?.mappings).containsOnlyKeys("monthly") + assertThat(result?.mappings?.get("monthly")).isEqualTo( ProductEntitlementMapping.Mapping( productIdentifier = "monthly", basePlanId = "monthly-base-plan", @@ -54,7 +52,7 @@ internal class ProductEntitlementMappingTopicProviderTest { ), ) coVerify(exactly = 1) { - manager.blobDataSnapshot( + manager.blobData( RemoteConfigTopic.ProductEntitlementMapping, "default", any<(ByteArray) -> ProductEntitlementMapping?>(), @@ -65,7 +63,7 @@ internal class ProductEntitlementMappingTopicProviderTest { @Test fun `getProductEntitlementMapping returns null when the default blob is unavailable`() = runTest { coEvery { - manager.blobDataSnapshot( + manager.blobData( RemoteConfigTopic.ProductEntitlementMapping, "default", any<(ByteArray) -> ProductEntitlementMapping?>(), @@ -82,40 +80,15 @@ internal class ProductEntitlementMappingTopicProviderTest { assertThat(provider.getProductEntitlementMapping()).isNull() } - @Test - fun `getProductEntitlementMapping result is rejected when config changes before use`() = runTest { - stubBlobRead( - """ - { - "product_entitlement_mapping": { - "monthly": { - "product_identifier": "monthly", - "entitlements": ["pro"] - } - } - } - """.trimIndent().toByteArray(), - ) - - every { manager.useIfCurrent(any>(), any()) } returns false - - val result = provider.getProductEntitlementMapping() - - assertThat(result?.cacheIfCurrent {}).isFalse() - } - private fun stubBlobRead(blob: ByteArray) { coEvery { - manager.blobDataSnapshot( + manager.blobData( RemoteConfigTopic.ProductEntitlementMapping, "default", any<(ByteArray) -> ProductEntitlementMapping?>(), ) } answers { - val value = thirdArg<(ByteArray) -> ProductEntitlementMapping?>().invoke(blob) - value?.let { - RemoteConfigBlobData(it, 0) - } + 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 2a303c3ad4..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 @@ -315,8 +315,8 @@ class RemoteConfigManagerIntegrationTest { val mapping = runBlocking { ProductEntitlementMappingTopicProvider(manager).getProductEntitlementMapping() } - assertThat(mapping?.mapping?.mappings).containsOnlyKeys("monthly") - assertThat(mapping?.mapping?.mappings?.get("monthly")?.entitlements).containsExactly("pro") + assertThat(mapping?.mappings).containsOnlyKeys("monthly") + assertThat(mapping?.mappings?.get("monthly")?.entitlements).containsExactly("pro") assertThat(blobStore.read(ref)).isEqualTo(blob) } @@ -359,7 +359,7 @@ class RemoteConfigManagerIntegrationTest { ProductEntitlementMappingTopicProvider(manager).getProductEntitlementMapping() } - assertThat(mapping?.mapping?.mappings).containsOnlyKeys("monthly") + 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. */ 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 0ea1334161..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,114 +1738,6 @@ class RemoteConfigManagerTest { coVerify(exactly = 1) { blobFetcher.ensureDownloaded(REF_VALID) } } - @Test - fun `useIfCurrent executes exactly once for a current blob snapshot`() = 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) } returns true - every { blobStore.read(REF_VALID) } returns byteArrayOf(4, 2) - val manager = readManager() - val snapshot = manager.blobDataSnapshot(RemoteConfigTopic.Workflows, "wf1") { it } - var invocationCount = 0 - - val used = manager.useIfCurrent(snapshot!!) { - invocationCount++ - assertThat(it).isEqualTo(byteArrayOf(4, 2)) - } - - assertThat(used).isTrue - assertThat(invocationCount).isEqualTo(1) - } - - @Test - fun `useIfCurrent rejects a blob snapshot after identity invalidation`() = 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) } returns true - every { blobStore.read(REF_VALID) } returns byteArrayOf(4, 2) - val manager = readManager() - val snapshot = manager.blobDataSnapshot(RemoteConfigTopic.Workflows, "wf1") { it } - manager.clearCache("new-user") - var invoked = false - - assertThat(manager.useIfCurrent(snapshot!!) { invoked = true }).isFalse() - assertThat(invoked).isFalse() - } - - @Test - fun `useIfCurrent rejects a blob snapshot after a newer config commit`() = 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) } returns true - every { blobStore.read(REF_VALID) } returns byteArrayOf(4, 2) - val manager = readManager() - val snapshot = manager.blobDataSnapshot(RemoteConfigTopic.Workflows, "wf1") { it } - manager.refreshRemoteConfig( - appInBackground = false, - appUserID = TEST_APP_USER_ID, - fetchContext = DEFAULT_FETCH_CONTEXT, - ) - val response = """ - { - "domain": "app", - "manifest": "v2", - "active_topics": ["workflows"], - "topics": { "workflows": { "wf1": { "blob_ref": "$REF_TAMPERED" } } } - } - """.trimIndent() - onSuccess.invoke(containerWithConfig(response), VerificationResult.VERIFIED) - var invoked = false - - assertThat(manager.useIfCurrent(snapshot!!) { invoked = true }).isFalse() - assertThat(invoked).isFalse() - } - - @Test - fun `useIfCurrent rejects a blob snapshot after disabling or closing the manager`() = 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) } returns true - every { blobStore.read(REF_VALID) } returns byteArrayOf(4, 2) - - val disabledManager = readManager() - val disabledSnapshot = disabledManager.blobDataSnapshot(RemoteConfigTopic.Workflows, "wf1") { it } - disabledManager.refreshRemoteConfig( - appInBackground = false, - appUserID = TEST_APP_USER_ID, - fetchContext = DEFAULT_FETCH_CONTEXT, - ) - onError.invoke( - PurchasesError(PurchasesErrorCode.InvalidCredentialsError, "bad request"), - GetRemoteConfigErrorHandlingBehavior.SHOULD_DISABLE, - ) - assertThat(disabledManager.useIfCurrent(disabledSnapshot!!) {}).isFalse() - - val closedManager = readManager() - val closedSnapshot = closedManager.blobDataSnapshot(RemoteConfigTopic.Workflows, "wf1") { it } - closedManager.close() - assertThat(closedManager.useIfCurrent(closedSnapshot!!) {}).isFalse() - } - @Test fun `blobData retains legacy behavior when identity changes during blob resolution`() = runTest { every { diskCache.read() } returns persisted( @@ -1899,39 +1791,6 @@ class RemoteConfigManagerTest { assertThat(result).isEqualTo(byteArrayOf(4, 2)) } - @Test - fun `blobDataSnapshot discards bytes 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.blobDataSnapshot(RemoteConfigTopic.Workflows, "wf1") { it } - - assertThat(result).isNull() - } - @Test fun `reified blobData fetches the blob on demand then deserializes it, like the transform overload`() = runTest { every { diskCache.read() } returns persisted(