Skip to content

Use product_entitlement_mapping topic blob for offline entitlements when remote config enabled#3455

Draft
tonidero wants to merge 1 commit into
toniricodiez/wire-remote-config-manager-on-configure-and-loginfrom
toniricodiez/use-topic-file-for-offline-entitlements
Draft

Use product_entitlement_mapping topic blob for offline entitlements when remote config enabled#3455
tonidero wants to merge 1 commit into
toniricodiez/wire-remote-config-manager-on-configure-and-loginfrom
toniricodiez/use-topic-file-for-offline-entitlements

Conversation

@tonidero
Copy link
Copy Markdown
Contributor

@tonidero tonidero commented May 7, 2026

Motivation

When BuildConfig.ENABLE_REMOTE_CONFIG is on, the remote-config pipeline (#3435/#3437/#3439/#3450) downloads the product_entitlement_mapping topic blob to disk. The SDK should consume that file as the source-of-truth for offline entitlements instead of issuing the legacy GET /v1/product_entitlement_mapping call. When the flag is off, behavior must be unchanged.

Description

Behavior gated on BuildConfig.ENABLE_REMOTE_CONFIG, surfaced into the SDK via a single useRemoteConfigForProductEntitlementMapping value computed once in PurchasesFactory:

  • Stop the direct backend fetch. OfflineEntitlementsManager.updateProductEntitlementMappingCacheIfStale becomes a no-op (completion?.invoke(null) and return) when the flag is on, so Backend.getProductEntitlementMapping is never issued.
  • Read the topic file at consumer-call time, off-main, with in-memory caching. New ProductEntitlementMappingTopicReader:
    • suspend fun read(): ProductEntitlementMapping? — reads the single non-rc_topic_ file in noBackupFilesDir/RevenueCat/topics/product_entitlement_mapping/ and parses it via ProductEntitlementMapping.fromJson(JSONObject(...), loadedFromCache = true).
    • Coalesces concurrent callers via a shared Deferred<ProductEntitlementMapping?> held in inFlight, guarded by synchronized(lock). Started with CoroutineStart.LAZY so inFlight is assigned before the body runs (otherwise an eager dispatcher would let the body's inFlight = null execute and .also { inFlight = it } would overwrite it back with a completed deferred, leaking it into the next read).
    • Owns its own CoroutineScope(SupervisorJob() + dispatcher) (dispatcher: CoroutineDispatcher = Dispatchers.IO, overridable for tests) so cancellation of one caller doesn't cancel the load others are awaiting.
    • fun invalidate() is non-suspend (it's called from a synchronous topic-update listener); just clears cached under the lock.
  • Fall back to the existing SharedPreferences cache if the topic file is absent (first launch, download in-flight). New ProductEntitlementMappingSource interface (kept callback-shaped because its consumer PurchasedProductsFetcher.queryActiveProducts is pre-existing callback API):
    • DeviceCacheProductEntitlementMappingSource — synchronous, used when the flag is off.
    • RemoteConfigProductEntitlementMappingSource — owns a small CoroutineScope, launches reader.read() (suspend) and forwards topicMapping ?: deviceCache.getProductEntitlementMapping() to the callback.
  • Invalidate the in-memory cache when a fresh blob is downloaded. TopicFetcher gains a topicUpdatedListener: ((Topic) -> Unit)? = null parameter, invoked after a successful downloadVerifyAndStore. PurchasesFactory registers a listener that calls productEntitlementMappingTopicReader.invalidate() when the PRODUCT_ENTITLEMENT_MAPPING topic is rewritten, so a new manifest takes effect within the same session.
  • Wiring (PurchasesFactory). Hoists TopicFetcher out of RemoteConfigManager's inline construction so the same instance is shared between the manager and the reader's invalidation hook. The flag funnels in at one place, matching the existing PurchasesOrchestrator.refreshRemoteConfigIfEnabled gating style.

Tests

runTest for everything; UnconfinedTestDispatcher for the simple paths and StandardTestDispatcher for the coalescing test (so two callers can interleave before the body runs):

  • ProductEntitlementMappingTopicReaderTest (new) — missing dir, empty dir, valid blob parsed, second read returns same instance with no extra disk access (counted via noBackupFilesDir mock answer), ignores rc_topic_ prefix files, corrupt JSON returns null, invalidate() clears the cache, concurrent reads coalesce into a single disk access.
  • OfflineEntitlementsManagerTest — adds a "skips backend fetch when useRemoteConfigForProductEntitlementMapping = true" case and confirms the existing 25h staleness logic still runs when the flag is off.
  • PurchasedProductsFetcherTest — already callback-shaped via ProductEntitlementMappingSource; reused unchanged.
  • TopicFetcherTest — adds three listener cases (fires after successful download, doesn't fire on cache hit, doesn't fire on download error).

Trade-offs

  • The topic blob is read into a ByteArray and decoded as UTF-8 JSON. Realistic mapping size is well under 1 MB; revisit if it grows materially.
  • We do not bridge a topic-file mapping into SharedPreferences. The two stores stay independent — SharedPreferences exists only as a fallback. With the flag on and the topic system healthy, the SharedPreferences cache will go stale; that's intentional.
  • Backend.getProductEntitlementMapping stays callback-based — it's the legacy network path.

All changes are internal; no public API surface change.

Stack

Checklist

  • Unit tests
  • Follow-up issues for purchases-ios / hybrids (deferred — feature is not yet wired to any consumer)

@codecov
Copy link
Copy Markdown

codecov Bot commented May 8, 2026

Codecov Report

❌ Patch coverage is 84.14634% with 13 lines in your changes missing coverage. Please review.
✅ Project coverage is 79.98%. Comparing base (bf72a8a) to head (f23483c).

Files with missing lines Patch % Lines
...ineentitlements/ProductEntitlementMappingSource.kt 18.18% 9 Missing ⚠️
...otlin/com/revenuecat/purchases/PurchasesFactory.kt 85.71% 2 Missing ⚠️
.../offlineentitlements/OfflineEntitlementsManager.kt 66.66% 0 Missing and 1 partial ⚠️
...titlements/ProductEntitlementMappingTopicReader.kt 97.22% 0 Missing and 1 partial ⚠️
Additional details and impacted files
@@                                        Coverage Diff                                         @@
##           toniricodiez/wire-remote-config-manager-on-configure-and-login    #3455      +/-   ##
==================================================================================================
- Coverage                                                           79.98%   79.98%   -0.01%     
==================================================================================================
  Files                                                                 369      371       +2     
  Lines                                                               14951    15015      +64     
  Branches                                                             2069     2083      +14     
==================================================================================================
+ Hits                                                                11959    12010      +51     
- Misses                                                               2153     2164      +11     
- Partials                                                              839      841       +2     

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@tonidero tonidero force-pushed the toniricodiez/use-topic-file-for-offline-entitlements branch from 3b891ef to f25993a Compare May 8, 2026 10:37
@tonidero tonidero force-pushed the toniricodiez/wire-remote-config-manager-on-configure-and-login branch from a00cdc7 to 8feda35 Compare May 8, 2026 10:37
@tonidero tonidero force-pushed the toniricodiez/wire-remote-config-manager-on-configure-and-login branch from 8feda35 to 63a76b2 Compare May 8, 2026 11:27
@tonidero tonidero force-pushed the toniricodiez/use-topic-file-for-offline-entitlements branch from f25993a to dd05dba Compare May 8, 2026 11:27
@emerge-tools
Copy link
Copy Markdown

emerge-tools Bot commented May 8, 2026

📸 Snapshot Test

591 unchanged

Name Added Removed Modified Renamed Unchanged Errored Approval
TestPurchasesUIAndroidCompatibility Paparazzi
com.revenuecat.testpurchasesuiandroidcompatibility.paparazzi
0 0 0 0 257 0 N/A
TestPurchasesUIAndroidCompatibility
com.revenuecat.testpurchasesuiandroidcompatibility
0 0 0 0 334 0 N/A

🛸 Powered by Emerge Tools

@tonidero tonidero force-pushed the toniricodiez/wire-remote-config-manager-on-configure-and-login branch from 63a76b2 to dbef916 Compare May 8, 2026 12:09
@tonidero tonidero force-pushed the toniricodiez/use-topic-file-for-offline-entitlements branch from dd05dba to bafed02 Compare May 8, 2026 12:09
@tonidero tonidero force-pushed the toniricodiez/wire-remote-config-manager-on-configure-and-login branch from dbef916 to b206de3 Compare May 8, 2026 13:48
@tonidero tonidero force-pushed the toniricodiez/use-topic-file-for-offline-entitlements branch from bafed02 to 475500e Compare May 8, 2026 13:48
@tonidero tonidero force-pushed the toniricodiez/wire-remote-config-manager-on-configure-and-login branch from b206de3 to ed2adf1 Compare May 11, 2026 08:40
@tonidero tonidero force-pushed the toniricodiez/use-topic-file-for-offline-entitlements branch 2 times, most recently from 5d17b34 to 03c6f6f Compare May 11, 2026 11:37
@tonidero tonidero force-pushed the toniricodiez/wire-remote-config-manager-on-configure-and-login branch from ed2adf1 to 49cac31 Compare May 11, 2026 11:37
@tonidero tonidero force-pushed the toniricodiez/use-topic-file-for-offline-entitlements branch from 03c6f6f to d128c04 Compare May 11, 2026 13:16
@tonidero tonidero force-pushed the toniricodiez/wire-remote-config-manager-on-configure-and-login branch from 49cac31 to 87d6e93 Compare May 11, 2026 13:16
@tonidero tonidero force-pushed the toniricodiez/use-topic-file-for-offline-entitlements branch from d128c04 to f057f59 Compare May 11, 2026 13:23
@tonidero tonidero force-pushed the toniricodiez/wire-remote-config-manager-on-configure-and-login branch 2 times, most recently from e90d04d to cd26018 Compare May 11, 2026 13:30
@tonidero tonidero force-pushed the toniricodiez/use-topic-file-for-offline-entitlements branch from f057f59 to 08f5042 Compare May 11, 2026 13:30
@tonidero tonidero force-pushed the toniricodiez/use-topic-file-for-offline-entitlements branch 2 times, most recently from e4ffa02 to 7f3963c Compare May 11, 2026 14:05
@tonidero tonidero force-pushed the toniricodiez/wire-remote-config-manager-on-configure-and-login branch 2 times, most recently from 61eade2 to a822583 Compare May 12, 2026 09:47
@tonidero tonidero force-pushed the toniricodiez/use-topic-file-for-offline-entitlements branch 2 times, most recently from 3b1b781 to 9be534c Compare May 12, 2026 11:56
@tonidero tonidero force-pushed the toniricodiez/wire-remote-config-manager-on-configure-and-login branch from a822583 to 0ac6c71 Compare May 12, 2026 11:56
@tonidero tonidero force-pushed the toniricodiez/use-topic-file-for-offline-entitlements branch from 3b1b781 to 9be534c Compare May 12, 2026 11:56
@tonidero tonidero force-pushed the toniricodiez/wire-remote-config-manager-on-configure-and-login branch from a822583 to 0ac6c71 Compare May 12, 2026 11:56
@tonidero tonidero force-pushed the toniricodiez/use-topic-file-for-offline-entitlements branch from 9be534c to e3fd14c Compare May 12, 2026 12:52
@tonidero tonidero force-pushed the toniricodiez/wire-remote-config-manager-on-configure-and-login branch 2 times, most recently from 8b87d1a to dab20f7 Compare May 12, 2026 14:01
@tonidero tonidero force-pushed the toniricodiez/use-topic-file-for-offline-entitlements branch from e3fd14c to 8107411 Compare May 12, 2026 14:01
@tonidero tonidero force-pushed the toniricodiez/wire-remote-config-manager-on-configure-and-login branch from dab20f7 to 87f1fd5 Compare May 13, 2026 08:49
@tonidero tonidero force-pushed the toniricodiez/use-topic-file-for-offline-entitlements branch from 8107411 to 3944eb0 Compare May 13, 2026 08:49
@tonidero tonidero force-pushed the toniricodiez/wire-remote-config-manager-on-configure-and-login branch from 87f1fd5 to 488944c Compare May 13, 2026 09:40
@tonidero tonidero force-pushed the toniricodiez/use-topic-file-for-offline-entitlements branch 2 times, most recently from 9c184f6 to b03be9e Compare May 13, 2026 10:22
@tonidero tonidero force-pushed the toniricodiez/wire-remote-config-manager-on-configure-and-login branch 2 times, most recently from 6e1a280 to c6390a4 Compare May 13, 2026 11:09
@tonidero tonidero force-pushed the toniricodiez/use-topic-file-for-offline-entitlements branch 2 times, most recently from 30c82b7 to 6fb4f33 Compare May 13, 2026 11:52
@tonidero tonidero force-pushed the toniricodiez/wire-remote-config-manager-on-configure-and-login branch from c6390a4 to 98f0ddd Compare May 13, 2026 11:52
@tonidero tonidero force-pushed the toniricodiez/use-topic-file-for-offline-entitlements branch 2 times, most recently from f7fa6f8 to bae0fd9 Compare May 13, 2026 15:19
@tonidero tonidero force-pushed the toniricodiez/wire-remote-config-manager-on-configure-and-login branch from 4446628 to d08fa32 Compare May 13, 2026 15:19
…hen remote config enabled

When ENABLE_REMOTE_CONFIG is on, route the offline-entitlements read path
through the topic file at noBackupFilesDir/RevenueCat/topics/product_entitlement_mapping/
instead of the /v1/product_entitlement_mapping backend endpoint. The legacy
SharedPreferences cache remains as a fallback (e.g. before the first
remote-config refresh has landed).

- New ProductEntitlementMappingTopicReader reads the blob off the main
  thread and caches the parsed mapping in memory; concurrent reads coalesce
  into a single dispatch.
- TopicFetcher now invokes a topicUpdatedListener after a successful
  download so the reader can invalidate its cache mid-session when a fresh
  blob arrives.
- ProductEntitlementMappingSource abstracts read access; PurchasesFactory
  picks DeviceCacheProductEntitlementMappingSource (flag off) or
  RemoteConfigProductEntitlementMappingSource (flag on).
- OfflineEntitlementsManager.updateProductEntitlementMappingCacheIfStale
  is a no-op when the flag is on, so Backend.getProductEntitlementMapping
  is never issued.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@tonidero tonidero force-pushed the toniricodiez/use-topic-file-for-offline-entitlements branch from bae0fd9 to f23483c Compare May 13, 2026 16:12
@tonidero tonidero force-pushed the toniricodiez/wire-remote-config-manager-on-configure-and-login branch from d08fa32 to bf72a8a Compare May 13, 2026 16:12
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant