Add RemoteConfigManager and TopicFetcher#3437
Conversation
Codecov Report❌ Patch coverage is Additional details and impacted files@@ Coverage Diff @@
## main #3437 +/- ##
==========================================
+ Coverage 79.45% 79.52% +0.06%
==========================================
Files 364 366 +2
Lines 14647 14754 +107
Branches 1999 2013 +14
==========================================
+ Hits 11638 11733 +95
- Misses 2200 2208 +8
- Partials 809 813 +4 ☔ View full report in Codecov by Sentry. 🚀 New features to boost your workflow:
|
1bd9380 to
1a10e10
Compare
39308f9 to
01f4829
Compare
9d39923 to
07ee1a7
Compare
07ee1a7 to
3a747f6
Compare
3a747f6 to
85a2bc8
Compare
| ) { | ||
| val source = response.blobSources.firstOrNull() | ||
| val tasks = response.manifest.topics.mapNotNull { (topic, variants) -> | ||
| val entry = variants[DEFAULT_VARIANT] ?: return@mapNotNull null |
There was a problem hiding this comment.
This is still todo, right now only product entitlement mapping is fetched, which only has a a single entry in the topic, which is the default.
a4228d0 to
8e9c782
Compare
8e9c782 to
29cea95
Compare
RemoteConfigManager calls Backend.getRemoteConfig, picks the first
asset source and the DEFAULT variant for each known topic, and
delegates per-topic downloads to TopicFetcher.
TopicFetcher downloads each topic asset into noBackupFilesDir/RevenueCat/topics/{topic_key}/{blob_ref},
verifies the bytes against the assetBlobRef SHA-256, and uses a temp
file plus atomic rename to avoid partial writes. Existing files are
trusted by name (filename = SHA-256 hash) and skip download.
Both classes are wired up but not yet invoked from PurchasesOrchestrator;
that lands in a follow-up stacked PR.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Update RemoteConfigManager and TopicFetcher to reference the renamed types (AssetSource -> Source, assetSources -> sources, asset_blob_ref -> blob_ref). No behavior change — purely a rename to match the trimmed wire surface. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…tcher Follows the schema and URL change in the parent branch: the manager now reads `response.blobSources` instead of `response.sources`, the fetcher takes a `BlobSource` (the renamed data class), and `updateRemoteConfigIfNeeded` no longer accepts an `appUserID` since /v2/config is not user-scoped.
29cea95 to
ee20e11
Compare
ajpallares
left a comment
There was a problem hiding this comment.
Some initial comments. But this is looking good!
ee20e11 to
ba8adae
Compare
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes and found 1 potential issue.
There are 2 total unresolved issues (including 1 from previous review).
❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, have a team admin enable autofix in the Cursor dashboard.
Reviewed by Cursor Bugbot for commit ba8adae. Configure here.
…ceeds + Update tests to use lowercase defaults
ajpallares
left a comment
There was a problem hiding this comment.
I think it looks good! Just one minor comment, not a blocker though
| const val TOPICS_ROOT = "RevenueCat/topics" | ||
| const val BLOB_REF_PLACEHOLDER = "{blob_ref}" | ||
| const val MAX_PARALLEL_TOPIC_DOWNLOADS = 4 | ||
| val BLOB_REF_PATTERN = Regex("^[a-zA-Z0-9]+$") |
There was a problem hiding this comment.
Should we tighten BLOB_REF_PATTERN to exactly ^[a-fA-F0-9]{64}$? Right now any alphanumeric string of any length (including non-hex chars like G–Z) passes validation and we only catch obviously-bad values after downloading and recomputing SHA-256. Since blobRef is the SHA-256 hex digest itself, enforcing the exact shape feels like cheap insurance and would make the "accepts mixed-case 64-char hex blobRef" test case actually exercise the real constraint.
There was a problem hiding this comment.
Indeed, I started with that, and then thought to futureproof it by relaxing the constraints... Though it's true that this just means it will fail later when verifying the checksum... So yeah, can change it back 👍
There was a problem hiding this comment.
Will do this in the follow-up PR, so we can merge this and avoid the extra iterations. Thanks again!
**This is an automatic release.** ## RevenueCat SDK ### 📦 Dependency Updates * [RENOVATE] Update dependency gradle to v8.14.5 (#3459) via RevenueCat Git Bot (@RCGitBot) ## RevenueCatUI SDK ### ✨ New Features * Pre-warm image cache for workflow step states (#3447) via Cesar de la Vega (@vegaro) ### Paywallv2 #### ✨ New Features * Add `close_workflow` button action (#3453) via Cesar de la Vega (@vegaro) #### 🐞 Bugfixes * Fix preload VideoComponent fallback override images (#3449) via Cesar de la Vega (@vegaro) ### 🔄 Other Changes * Select blob source by priority and weighted random (#3458) via Toni Rico (@tonidero) * [AUTOMATIC] Update golden test files for backend integration tests (#3473) via RevenueCat Git Bot (@RCGitBot) * Clean up unreferenced topic files after successful remote-config refresh (#3439) via Toni Rico (@tonidero) * Cache remote config response in memory with TTL and persist to disk (#3457) via Toni Rico (@tonidero) * build(deps): bump fastlane from 2.233.1 to 2.234.0 (#3463) via dependabot[bot] (@dependabot[bot]) * Update codelabs links (#3460) via Jaewoong Eum (@skydoves) * Add RemoteConfigManager and TopicFetcher (#3437) via Toni Rico (@tonidero) * Add exit offers support to workflows (#3452) via Cesar de la Vega (@vegaro) * Update baseline profiles (#3461) via RevenueCat Git Bot (@RCGitBot) * Add network scaffolding for remote config endpoint (#3435) via Toni Rico (@tonidero) * test: cover singleStepFallbackId == initialStepId edge case (#3445) via Facundo Menzella (@facumenzella) <!-- CURSOR_SUMMARY --> --- > [!NOTE] > **Low Risk** > Low risk: this is a release/versioning update (SNAPSHOT -> final) plus docs deployment path changes, with no functional code changes beyond version constants. > > **Overview** > Finalizes the `10.6.0` release by switching all version references from `10.6.0-SNAPSHOT` to `10.6.0` (root `.version`, `gradle.properties`, `Config.frameworkVersion`, and sample/test app `libs.versions.toml` files). > > Updates documentation publishing to point at the `10.6.0` docs path (CircleCI S3 sync target and `docs/index.html` redirect), and prepends the `10.6.0` section to `CHANGELOG.md`/`CHANGELOG.latest.md`. > > <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit 4da1697. Bugbot is set up for automated code reviews on this repo. Configure [here](https://www.cursor.com/dashboard/bugbot).</sup> <!-- /CURSOR_SUMMARY -->
Replace the manual AtomicInteger completion counter with a coroutineScope that launches each prefetch concurrently and waits for all of them before firing the completion callbacks. A failed prefetch is logged and resumes normally so it does not cancel its siblings. Mirrors the async fan-out RemoteConfigManager uses in #3437. Also reword the getWorkflowsList and FetchDecision docs around the completion gate, dropping the confusing "prefetch-wait" framing. No behavior change. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
### Motivation First step in landing remote-config support. The backend exposes `GET /v2/config`, which returns a manifest describing remote-controlled topics (e.g. product-entitlement mapping) plus the asset sources for downloading them. Wiring up the endpoint and its response models is a prerequisite for the orchestration and on-disk cache work in the rest of the stack. ### Description - Adds `Endpoint.GetRemoteConfig` to the sealed endpoint hierarchy and `Backend.getRemoteConfig(appUserID, appInBackground, onSuccess, onError)` to dispatch the call. Reuses the existing `BackgroundAwareCallbackCacheKey` / `RemoteConfigCallback` callback-coalescing pattern. - Adds the response models in a new feature package `com.revenuecat.purchases.common.remoteconfig`: - `RemoteConfigResponse(apiSources, blobSources, manifest)` - `ApiSource(id, url, priority, weight)` - `BlobSource(id, urlFormat, priority, weight)` - `Manifest(topics)` - `Topic` enum (`PRODUCT_ENTITLEMENT_MAPPING`) with `fromKey(key)` for wire-key lookups - `TopicEntry(blobRef)` - `TopicsMapSerializer` — custom kotlinx-serialization `KSerializer` that drops unknown topic wire keys at decode time and re-emits the enum's wire key on encode. - Test coverage for the serializer (unknown-key filtering, default decoding, round-trip), plus `BackendGetRemoteConfigTest` for the endpoint plumbing and error paths. - Adds a developer-only build-time override: setting `REMOTE_CONFIG_BASE_URL` in `local.properties` (e.g. `http://localhost:8080/`) redirects only `getRemoteConfig` to that host. Empty by default and gated to debug builds, so CI / release traffic is unaffected. Plumbed via a new `BuildConfig.REMOTE_CONFIG_BASE_URL` field, mirroring the existing `ENABLE_EXTRA_REQUEST_LOGGING` pattern. All new types are `internal`; no public API surface change. ### Stack - This PR (base): RevenueCat#3435 - ⬆ `Add RemoteConfigManager and TopicFetcher`: RevenueCat#3437 - ⬆ `Clean up unreferenced topic files after successful remote-config refresh`: RevenueCat#3439 ### Checklist - [x] Unit tests - [ ] Follow-up issues for `purchases-ios` / hybrids (deferred — feature is not yet wired to any consumer) <!-- CURSOR_SUMMARY --> --- > [!NOTE] > **Medium Risk** > Adds new backend networking path (`GET /v2/config`) and callback coalescing logic in `Backend`, plus a debug-only base URL override; issues here could affect request routing or parsing for this new call. > > **Overview** > Adds initial remote-config networking support by introducing `Endpoint.GetRemoteConfig` (`/v2/config`) and a new `Backend.getRemoteConfig` call that coalesces concurrent requests and parses a typed `RemoteConfigResponse`. > > Introduces internal remote-config response models (including a custom serializer that drops unknown topic keys) and adds unit tests covering endpoint properties, parsing/unknown-field behavior, error propagation, and request de-duping. > > Adds a debug-only `REMOTE_CONFIG_BASE_URL` `BuildConfig` field (documented in `local.properties.example`) to optionally route only the remote-config GET request to a local host. > > <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit a7f33ec. Bugbot is set up for automated code reviews on this repo. Configure [here](https://www.cursor.com/dashboard/bugbot).</sup> <!-- /CURSOR_SUMMARY --> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…esh (RevenueCat#3439) ### Motivation `TopicFetcher` writes a new file every time the manifest's `blob_ref` rotates, but never deletes the previous one. Without cleanup, the on-disk cache at `noBackupFilesDir/RevenueCat/topics/...` grows unboundedly across backend rotations. This PR adds a one-shot disk-hygiene pass that runs at the end of each fully successful remote-config refresh. ### Description - **`suspend fun TopicFetcher.cleanupUnreferencedTopics(referenced: Map<Topic, Set<String>>)`** — wraps the disk walk in `withContext(Dispatchers.IO)`, iterates `Topic.values()`, lists each topic's directory, and deletes files whose name isn't in the keep-set for that topic. Skips files prefixed with `rc_topic_` (the `createTempFile` prefix, now extracted as a `TEMP_PREFIX` constant) so a concurrent in-flight download isn't clobbered. - **`RemoteConfigManager.refresh`** — builds the reference set as the union of every blob ref in the new manifest (across all variants, not just `DEFAULT`) and triggers cleanup at the right moment, all within the existing suspend coroutine flow: - When `sources` is empty or no DEFAULT-variant tasks remain → cleanup runs immediately (`fetchError = null` short-circuit). - When downloads dispatch → after the `async`/`awaitAll` fan-out, cleanup is awaited only if every fetch returned `null` (no error). Partial-failure refreshes leave the cache untouched. Confirmed semantics: - Reference set spans **all** variants in the new manifest (forward-compatible if non-DEFAULT variants ever start being cached). - Topics in the SDK's `Topic` enum but absent from the new manifest get **all** their cached files wiped (reference-set = ∅). - Cleanup runs **only** when every download in the refresh succeeded. ### Tests `runTest` for both: - 5 new `TopicFetcherTest` cases: delete unreferenced / wipe topic absent from set / no-op when root missing / preserve `rc_topic_*.tmp` / mixed keep+delete. - 5 new `RemoteConfigManagerTest` cases (with `coEvery { topicFetcher.cleanupUnreferencedTopics(any()) } returns Unit` + `coVerify`): cleanup with all-variant refs / cleanup on empty sources & empty topics / no cleanup on fetcher error / no cleanup on backend error. All changes are `internal`; no public API surface change. ### Stack - ⬇ `Add network scaffolding for remote config endpoint`: RevenueCat#3435 - ⬇ `Add RemoteConfigManager and TopicFetcher`: RevenueCat#3437 - This PR: RevenueCat#3439 ### Checklist - [x] Unit tests - [ ] Follow-up issues for `purchases-ios` / hybrids (deferred) <!-- CURSOR_SUMMARY --> --- > [!NOTE] > **Medium Risk** > Adds automatic deletion of on-disk topic files after successful remote-config refresh; mistakes in reference calculation or deletion logic could remove still-needed cached data, though it is guarded to run only on fully successful refreshes and has test coverage. > > **Overview** > After a fully successful remote-config refresh, the SDK now performs a one-shot cleanup pass to delete **unreferenced cached topic blob files** under `noBackupFilesDir/RevenueCat/topics`, preventing unbounded growth when `blob_ref`s rotate. > > `RemoteConfigManager` builds a per-topic keep-set from *all* manifest entryIds’ `blob_ref`s and calls `TopicFetcher.cleanupUnreferencedTopics()` only when refresh completes with no errors (and caching conditions are met). `TopicFetcher` implements the disk walk/deletion, skips in-flight temp files via a shared `rc_topic_` prefix constant, and adds unit tests covering the new cleanup behavior and its failure/no-op cases. > > <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit 9503c36. Bugbot is set up for automated code reviews on this repo. Configure [here](https://www.cursor.com/dashboard/bugbot).</sup> <!-- /CURSOR_SUMMARY --> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>


Motivation
Builds on #3435 by adding the orchestration layer that turns a remote-config response into a populated on-disk cache of topic blobs. With this PR, calling
RemoteConfigManager.updateRemoteConfigIfNeededwill (a) hit the backend for the latest manifest and (b) download every referenced topic blob, verify its SHA-256, and persist it undernoBackupFilesDir. Subsequent stack PRs build on this orchestration.Description
Adds two classes in the
remoteconfigpackage:TopicFetcher— exposessuspend fun fetchTopicIfNeeded(topic, variant, topicEntry, source): PurchasesError?. Given the inputs, downloads the blob fromsource.urlFormat(with{blob_ref}substituted), writes to a temp file, verifies SHA-256 againsttopicEntry.blobRef, and atomically renames intonoBackupFilesDir/RevenueCat/topics/<topic.key>/<blobRef>. The blocking I/O is wrapped inwithContext(Dispatchers.IO). Skips the download entirely when the target file already exists. Errors surface asPurchasesError(NetworkError, ...)for checksum mismatch orIOException → toPurchasesError()for transport failures.RemoteConfigManager— keeps a callback boundary externally (fun updateRemoteConfigIfNeeded(appInBackground, completion)) since its caller (PurchasesOrchestrator) is callback-shaped. Internally launches aprivate suspend fun refresh(...)on a privateCoroutineScope(SupervisorJob() + dispatcher)(dispatcher: CoroutineDispatcher = Dispatchers.IO, overridable for tests). The refresh:Backend.getRemoteConfigviasuspendCancellableCoroutine+safeResume/safeResumeWithException(PurchasesException(error)). The backend network call stays callback-based intentionally.coroutineScope { tasks.map { async { topicFetcher.fetchTopicIfNeeded(...) } }.awaitAll().firstNotNullOfOrNull { it } }. The first error wins; structured concurrency makes this much smaller than a hand-rolledCompletionTracker.Currently only the
DEFAULTvariant of each known topic is downloaded.Tests
runTest+UnconfinedTestDispatcher(testScheduler)for both.coEvery/coVerifyfor the suspendTopicFetcher. Coverage:TopicFetcherTest— cache hit, successful download + verify + persist, URL-format substitution, HTTP failure, IO failure, SHA mismatch, multi-variant filename isolation.RemoteConfigManagerTest— empty-source / empty-topics / no-DEFAULT skip paths, first-error-wins fan-out, backend-error short-circuit, background flag forwarded, null completion.All new types are
internal. No public API surface change.Stack
Add network scaffolding for remote config endpoint: Add network scaffolding for remote config endpoint #3435Clean up unreferenced topic files after successful remote-config refresh: Clean up unreferenced topic files after successful remote-config refresh #3439Checklist
purchases-ios/ hybrids (deferred)Note
Medium Risk
Adds new remote-config orchestration that performs concurrent network downloads and filesystem writes under
noBackupFilesDir, including checksum verification and new error-handling paths. While internal-only, bugs here could impact startup/network behavior and caching correctness.Overview
Adds a new remote-config orchestration layer:
RemoteConfigManager.updateRemoteConfigIfNeededfetches the remote-config manifest fromBackend.getRemoteConfig(bridged into coroutines) and concurrently downloads each topic’sdefaultentry via a newTopicFetcher, returning the first download error (if any).Introduces
TopicFetcherto cache topic blobs on disk undernoBackupFilesDir/RevenueCat/topics/<topic>/<blob_ref>, with blob-ref input validation, limited parallel downloads, temp-file writes, and SHA-256 verification.Refactors
FontLoaderto reuse newUrlConnectionFactory.downloadToFilehelpers, and extendsUrlConnectionFactorywith shared download + checksum-verification utilities. Updates/expands tests to cover the new manager/fetcher behavior and changes the manifest entry key fromDEFAULTtodefaultinRemoteConfigResponseTest.Reviewed by Cursor Bugbot for commit 8cdae44. Bugbot is set up for automated code reviews on this repo. Configure here.