Skip to content
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ import com.revenuecat.purchases.common.offerings.OfferingsFactory
import com.revenuecat.purchases.common.offerings.OfferingsManager
import com.revenuecat.purchases.common.offlineentitlements.OfflineCustomerInfoCalculator
import com.revenuecat.purchases.common.offlineentitlements.OfflineEntitlementsManager
import com.revenuecat.purchases.common.offlineentitlements.ProductEntitlementMappingTopicProvider
import com.revenuecat.purchases.common.offlineentitlements.PurchasedProductsFetcher
import com.revenuecat.purchases.common.remoteconfig.DefaultRemoteConfigSourceProvider
import com.revenuecat.purchases.common.remoteconfig.RemoteConfigBlobStore
Expand Down Expand Up @@ -267,20 +268,6 @@ internal class PurchasesFactory(
automaticDeviceIdentifierCollectionEnabled,
)

val offlineCustomerInfoCalculator = OfflineCustomerInfoCalculator(
PurchasedProductsFetcher(cache, billing),
appConfig,
diagnosticsTracker,
)

val offlineEntitlementsManager = OfflineEntitlementsManager(
backend,
offlineCustomerInfoCalculator,
cache,
appConfig,
diagnosticsTracker,
)

val offeringsCache = OfferingsCache(
deviceCache = cache,
localeProvider = localeProvider,
Expand All @@ -300,6 +287,25 @@ internal class PurchasesFactory(
} else {
null
}
val productEntitlementMappingTopicProvider = createProductEntitlementMappingTopicProvider(
remoteConfigEnabled,
remoteConfigManager,
)

val offlineCustomerInfoCalculator = OfflineCustomerInfoCalculator(
PurchasedProductsFetcher(cache, billing),
appConfig,
diagnosticsTracker,
)

val offlineEntitlementsManager = OfflineEntitlementsManager(
backend,
offlineCustomerInfoCalculator,
cache,
appConfig,
diagnosticsTracker,
productEntitlementMappingTopicProvider,
)

val fontLoader = FontLoader(
context = contextForStorage,
Expand Down Expand Up @@ -630,6 +636,16 @@ internal class PurchasesFactory(
}

companion object {
@VisibleForTesting
internal fun createProductEntitlementMappingTopicProvider(
remoteConfigEnabled: Boolean,
remoteConfigManager: RemoteConfigManager?,
): ProductEntitlementMappingTopicProvider? = if (remoteConfigEnabled) {
remoteConfigManager?.let(::ProductEntitlementMappingTopicProvider)
} else {
null
}

@VisibleForTesting
internal fun shouldInitializeDiagnostics(
diagnosticsEnabled: Boolean,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,14 +13,24 @@ import com.revenuecat.purchases.common.diagnostics.DiagnosticsTracker
import com.revenuecat.purchases.common.errorLog
import com.revenuecat.purchases.common.warnLog
import com.revenuecat.purchases.strings.OfflineEntitlementsStrings
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.CoroutineStart
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.launch

@OptIn(InternalRevenueCatAPI::class)
@Suppress("LongParameterList")
internal class OfflineEntitlementsManager(
private val backend: Backend,
private val offlineCustomerInfoCalculator: OfflineCustomerInfoCalculator,
private val deviceCache: DeviceCache,
private val appConfig: AppConfig,
private val diagnosticsTracker: DiagnosticsTracker?,
private val productEntitlementMappingTopicProvider: ProductEntitlementMappingTopicProvider? = null,
private val scope: CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.IO),
) {
// We cache the offline customer info in memory, so it's not persisted.
val offlineCustomerInfo: CustomerInfo?
Expand All @@ -31,6 +41,9 @@ internal class OfflineEntitlementsManager(

private val offlineCustomerInfoCallbackCache = mutableMapOf<String, List<OfflineCustomerInfoCallback>>()

private val productEntitlementMappingUpdateLock = Any()
private var productEntitlementMappingUpdateJob: Job? = null

@Synchronized
fun resetOfflineCustomerInfoCache() {
if (_offlineCustomerInfo != null) {
Expand Down Expand Up @@ -99,25 +112,96 @@ internal class OfflineEntitlementsManager(
}

fun updateProductEntitlementMappingCacheIfStale(completion: ((PurchasesError?) -> Unit)? = null) {
if (isOfflineEntitlementsEnabled() && deviceCache.isProductEntitlementMappingCacheStale()) {
debugLog { OfflineEntitlementsStrings.UPDATING_PRODUCT_ENTITLEMENT_MAPPING }
backend.getProductEntitlementMapping(
onSuccessHandler = { productEntitlementMapping ->
deviceCache.cacheProductEntitlementMapping(productEntitlementMapping)
debugLog { OfflineEntitlementsStrings.SUCCESSFULLY_UPDATED_PRODUCT_ENTITLEMENTS }
completion?.invoke(null)
},
onErrorHandler = { e ->
errorLog { OfflineEntitlementsStrings.ERROR_UPDATING_PRODUCT_ENTITLEMENTS.format(e) }
completion?.invoke(e)
},
)
} else {
if (!isOfflineEntitlementsEnabled() || !deviceCache.isProductEntitlementMappingCacheStale()) {
completion?.invoke(null)
return
}

debugLog { OfflineEntitlementsStrings.UPDATING_PRODUCT_ENTITLEMENT_MAPPING }
val topicProvider = productEntitlementMappingTopicProvider
?: return fetchLegacyProductEntitlementMapping(completion)

startProductEntitlementMappingUpdate(topicProvider, completion)
}

fun close() {
synchronized(productEntitlementMappingUpdateLock) {
productEntitlementMappingUpdateJob = null
}
scope.cancel()
}

private fun startProductEntitlementMappingUpdate(
topicProvider: ProductEntitlementMappingTopicProvider,
completion: ((PurchasesError?) -> Unit)?,
) {
fun finishUpdate(updateJob: Job): Boolean =
synchronized(productEntitlementMappingUpdateLock) {
if (productEntitlementMappingUpdateJob !== updateJob) {
false
} else {
productEntitlementMappingUpdateJob = null
true
}
}

val completeUpdate: (Job, PurchasesError?) -> Unit = { updateJob, error ->
if (finishUpdate(updateJob)) {
completion?.invoke(error)
}
}
val updateJob = synchronized(productEntitlementMappingUpdateLock) {
if (productEntitlementMappingUpdateJob != null) {
return
}

scope.launch(start = CoroutineStart.LAZY) {
val currentJob = coroutineContext[Job] ?: return@launch
var waitingForLegacyResult = false
try {
val mapping = topicProvider.getProductEntitlementMapping()
if (mapping != null) {
deviceCache.cacheProductEntitlementMapping(mapping)
debugLog { OfflineEntitlementsStrings.SUCCESSFULLY_UPDATED_PRODUCT_ENTITLEMENTS }
completeUpdate(currentJob, null)
} else {
waitingForLegacyResult = true
fetchLegacyProductEntitlementMapping { completeUpdate(currentJob, it) }
}
} finally {
if (!waitingForLegacyResult) {
finishUpdate(currentJob)
}
}
}.also {
productEntitlementMappingUpdateJob = it
}
}
updateJob.start()
}

private fun fetchLegacyProductEntitlementMapping(completion: ((PurchasesError?) -> Unit)?) {
backend.getProductEntitlementMapping(
onSuccessHandler = { productEntitlementMapping ->
cacheProductEntitlementMapping(productEntitlementMapping, completion)
},
onErrorHandler = { e ->
errorLog { OfflineEntitlementsStrings.ERROR_UPDATING_PRODUCT_ENTITLEMENTS.format(e) }
completion?.invoke(e)
},
)
}

private fun cacheProductEntitlementMapping(
productEntitlementMapping: ProductEntitlementMapping,
completion: ((PurchasesError?) -> Unit)?,
) {
deviceCache.cacheProductEntitlementMapping(productEntitlementMapping)
debugLog { OfflineEntitlementsStrings.SUCCESSFULLY_UPDATED_PRODUCT_ENTITLEMENTS }
completion?.invoke(null)
}

// We disable offline entitlements in observer mode (finishTransactions = true) since it doesn't
// We disable offline entitlements in observer mode (finishTransactions = false) since it doesn't
// provide any value and simplifies operations in that mode. Also on test store, since we don't have a store
// to store purchases in the client.
private fun isOfflineEntitlementsEnabled() = appConfig.finishTransactions &&
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
package com.revenuecat.purchases.common.offlineentitlements

import com.revenuecat.purchases.InternalRevenueCatAPI
import com.revenuecat.purchases.common.errorLog
import com.revenuecat.purchases.common.remoteconfig.RemoteConfigManager
import com.revenuecat.purchases.common.remoteconfig.RemoteConfigTopic
import org.json.JSONException
import org.json.JSONObject

/** Decodes the `product_entitlement_mapping.default` remote-config blob. */
@OptIn(InternalRevenueCatAPI::class)
internal class ProductEntitlementMappingTopicProvider(
private val manager: RemoteConfigManager,
) {
suspend fun getProductEntitlementMapping(): ProductEntitlementMapping? =
manager.blobData(RemoteConfigTopic.ProductEntitlementMapping, ITEM_KEY) { bytes ->
try {
ProductEntitlementMapping.fromJson(JSONObject(bytes.decodeToString()))
} catch (e: JSONException) {
errorLog(e) { "Failed to parse product entitlement mapping from remote config." }
null
}
}

private companion object {
private const val ITEM_KEY = "default"
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -390,13 +390,16 @@ internal class RemoteConfigManager(
// A 4xx: disable the endpoint for the rest of the session. This is an endpoint-level fact, so set it
// regardless of epoch ownership (a late response for an old identity is still a valid signal that the
// endpoint refuses this app's requests). Reads now return null, so drop any in-memory caches too.
disabled = true
val invalidatedGeneration = generation.incrementAndGet()
listeners.forEach { it.onConfigInvalidated(invalidatedGeneration) }
// Distinct one-shot signal (this branch runs once, guarded by !disabled): lets consumers refetch
// offerings so paywall components — skipped while the endpoint was live — get decoded for the
// fallback render path.
listeners.forEach { it.onRemoteConfigDisabled(invalidatedGeneration) }
synchronized(cacheLock) {
if (!disabled) {
disabled = true
val invalidatedGeneration = generation.incrementAndGet()
listeners.forEach { it.onConfigInvalidated(invalidatedGeneration) }
// Distinct one-shot signal: lets consumers refetch offerings so paywall components — skipped
// while the endpoint was live — get decoded for the fallback render path.
listeners.forEach { it.onRemoteConfigDisabled(invalidatedGeneration) }
}
}
}
if (releaseGuardIfOwned(requestEpoch)) {
errorLog(error)
Expand Down Expand Up @@ -746,11 +749,15 @@ internal class RemoteConfigManager(
}
verboseLog { "Reading remote config blob (topic='${topic.wireName}', item='$itemKey')." }
val ref = committedItem(topic, itemKey)?.blobRef
return when {
ref == null -> {
return ref?.let { resolveBlobBytes(it, itemKey) }.also {
if (ref == null) {
verboseLog { "Remote config item '$itemKey' is missing or has no blob ref; returning null." }
null
}
}
}

private suspend fun resolveBlobBytes(ref: String, itemKey: String): ByteArray? {
return when {
blobFetcher.ensureDownloaded(ref) -> {
blobStore.read(ref).also { bytes ->
if (bytes != null) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,4 +12,5 @@ internal enum class RemoteConfigTopic(val wireName: String) {
Workflows("workflows"),
UiConfig("ui_config"),
Sources("sources"),
ProductEntitlementMapping("product_entitlement_mapping"),
}
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,9 @@ internal open class BasePurchasesTest {
every {
mockOfflineEntitlementsManager.updateProductEntitlementMappingCacheIfStale()
} just Runs
every {
mockOfflineEntitlementsManager.close()
} just Runs
every {
mockEventsManager.flushEvents(any())
} just Runs
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3046,6 +3046,7 @@ internal class PurchasesCommonTest: BasePurchasesTest() {

private fun verifyClose() {
verify {
mockOfflineEntitlementsManager.close()
mockBackend.close()
mockRemoteConfigManager.close()
mockBillingAbstract.close()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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<RemoteConfigManager>(),
)

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<RemoteConfigManager>(),
)

assertThat(provider).isNull()
}

// endregion

private fun createConfiguration(
Expand Down
Loading