Add network scaffolding for remote config endpoint#3435
Conversation
Adds GET /v1/subscribers/{appUserId}/config to fetch SDK configuration
(api_sources, asset_sources, manifest of asset topics) that will be
consumed by future PRs.
Includes:
- Endpoint.GetRemoteConfig with signing+nonce and fallback URL support
- @serializable RemoteConfigResponse with custom TopicsMapSerializer
that drops unknown topic names so future backend additions don't
break older SDKs
- Backend.getRemoteConfig() background-aware method modeled on
getVirtualCurrencies
- Unit tests covering parsing, unknown-field tolerance, errors, and
call deduplication
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Codecov Report❌ Patch coverage is Additional details and impacted files@@ Coverage Diff @@
## main #3435 +/- ##
==========================================
- Coverage 79.47% 79.46% -0.01%
==========================================
Files 362 363 +1
Lines 14547 14625 +78
Branches 1977 1993 +16
==========================================
+ Hits 11561 11622 +61
- Misses 2190 2196 +6
- Partials 796 807 +11 ☔ View full report in Codecov by Sentry. 🚀 New features to boost your workflow:
|
Drop config_version, api_sources, and AssetSource.{test_url, blacklist_time_seconds};
rename asset_sources to sources; rename TopicEntry.asset_blob_ref to blob_ref and drop
content_type/prefetch. Rename Kotlin AssetSource to Source now that it has no peer.
These fields can be reintroduced when consumers need them.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
39308f9 to
01f4829
Compare
Set REMOTE_CONFIG_BASE_URL in local.properties to redirect only the GET /v1/subscribers/<id>/config request to a developer-controlled host (e.g. http://localhost:8080/). Other endpoints continue to hit appConfig.baseURL. Empty by default, and only takes effect in debug builds, so production traffic is unaffected. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The remote-config server contract changed: the URL drops the user id (/v2/config) and the response root renames `sources` to `blob_sources` plus adds a new `api_sources` array describing API host candidates. - Endpoint.GetRemoteConfig becomes a parameterless object. - Backend.getRemoteConfig drops the appUserID parameter. - RemoteConfigResponse: `sources: List<Source>` -> `blobSources: List<BlobSource>`, add `apiSources: List<ApiSource>` with the new shape. - Stub appConfig.isDebugBuild in BackendGetRemoteConfigTest setUp (was missing since the override-base-URL commit, causing pre-existing MockKExceptions in non-success-path tests).
| override fun getPath(useFallback: Boolean) = pathTemplate.format(Uri.encode(userId)) | ||
| } | ||
| object GetRemoteConfig : Endpoint( | ||
| pathTemplate = "/v2/config", |
There was a problem hiding this comment.
This is not final... but we can iterate on it in future PRs.
Also, we need to decide whether this endpoint will be available in the fallback url... But again, we can leave this for once this is more advanced
| val path = endpoint.getPath() | ||
| val cacheKey = BackgroundAwareCallbackCacheKey(listOf(path), appInBackground) | ||
|
|
||
| val overrideURL = BuildConfig.REMOTE_CONFIG_BASE_URL |
There was a problem hiding this comment.
Keeping this while we test things, but the intention is for it to go away in the final version.
ajpallares
left a comment
There was a problem hiding this comment.
Looking good! I only have a couple of comments.
Also, I think the PR description is a bit stale:
- It still references the previous
GET /v1/subscribers/{appUserID}/config. - It says "concurrent calls for the same user dedupe". But the cache key doesn't really depend on the appUserID anymore, so it'd just be "concurrent calls dedupe"
- The current bullet still mentions
RemoteConfigResponse(sources, manifest)and a singleSource(id, urlFormat, priority, weight).
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes and found 1 potential issue.
❌ 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 a7f33ec. Configure here.
| } | ||
|
|
||
| private fun <K, V> entry(key: K, value: V): Map.Entry<K, V> = | ||
| java.util.AbstractMap.SimpleEntry(key, value) |
There was a problem hiding this comment.
Inline fully-qualified reference instead of import
Low Severity
The entry helper uses the fully-qualified inline reference java.util.AbstractMap.SimpleEntry instead of importing java.util.AbstractMap (or java.util.AbstractMap.SimpleEntry) at the top of the file. This violates the rule requiring FQN imports over inline fully-qualified references.
Triggered by learned rule: Use FQN imports, not inline fully-qualified references
Reviewed by Cursor Bugbot for commit a7f33ec. Configure here.
**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 -->
### Motivation Builds on RevenueCat#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.updateRemoteConfigIfNeeded` will (a) hit the backend for the latest manifest and (b) download every referenced topic blob, verify its SHA-256, and persist it under `noBackupFilesDir`. Subsequent stack PRs build on this orchestration. ### Description Adds two classes in the `remoteconfig` package: - **`TopicFetcher`** — exposes `suspend fun fetchTopicIfNeeded(topic, variant, topicEntry, source): PurchasesError?`. Given the inputs, downloads the blob from `source.urlFormat` (with `{blob_ref}` substituted), writes to a temp file, verifies SHA-256 against `topicEntry.blobRef`, and atomically renames into `noBackupFilesDir/RevenueCat/topics/<topic.key>/<blobRef>`. The blocking I/O is wrapped in `withContext(Dispatchers.IO)`. Skips the download entirely when the target file already exists. Errors surface as `PurchasesError(NetworkError, ...)` for checksum mismatch or `IOException → toPurchasesError()` for transport failures. - **`RemoteConfigManager`** — keeps a callback boundary externally (`fun updateRemoteConfigIfNeeded(appInBackground, completion)`) since its caller (`PurchasesOrchestrator`) is callback-shaped. Internally launches a `private suspend fun refresh(...)` on a private `CoroutineScope(SupervisorJob() + dispatcher)` (`dispatcher: CoroutineDispatcher = Dispatchers.IO`, overridable for tests). The refresh: - Bridges the legacy callback-based `Backend.getRemoteConfig` via `suspendCancellableCoroutine` + `safeResume`/`safeResumeWithException(PurchasesException(error))`. The backend network call stays callback-based intentionally. - Fans the topic downloads out with `coroutineScope { tasks.map { async { topicFetcher.fetchTopicIfNeeded(...) } }.awaitAll().firstNotNullOfOrNull { it } }`. The first error wins; structured concurrency makes this much smaller than a hand-rolled `CompletionTracker`. Currently only the `DEFAULT` variant of each known topic is downloaded. ### Tests `runTest` + `UnconfinedTestDispatcher(testScheduler)` for both. `coEvery` / `coVerify` for the suspend `TopicFetcher`. 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`: RevenueCat#3435 - This PR: 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) <!-- CURSOR_SUMMARY --> --- > [!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.updateRemoteConfigIfNeeded` fetches the remote-config manifest from `Backend.getRemoteConfig` (bridged into coroutines) and concurrently downloads each topic’s `default` entry via a new `TopicFetcher`, returning the first download error (if any). > > Introduces `TopicFetcher` to cache topic blobs on disk under `noBackupFilesDir/RevenueCat/topics/<topic>/<blob_ref>`, with blob-ref input validation, limited parallel downloads, temp-file writes, and SHA-256 verification. > > Refactors `FontLoader` to reuse new `UrlConnectionFactory.downloadToFile` helpers, and extends `UrlConnectionFactory` with shared download + checksum-verification utilities. Updates/expands tests to cover the new manager/fetcher behavior and changes the manifest entry key from `DEFAULT` to `default` in `RemoteConfigResponseTest`. > > <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit 8cdae44. 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
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
Endpoint.GetRemoteConfigto the sealed endpoint hierarchy andBackend.getRemoteConfig(appUserID, appInBackground, onSuccess, onError)to dispatch the call. Reuses the existingBackgroundAwareCallbackCacheKey/RemoteConfigCallbackcallback-coalescing pattern.com.revenuecat.purchases.common.remoteconfig:RemoteConfigResponse(apiSources, blobSources, manifest)ApiSource(id, url, priority, weight)BlobSource(id, urlFormat, priority, weight)Manifest(topics)Topicenum (PRODUCT_ENTITLEMENT_MAPPING) withfromKey(key)for wire-key lookupsTopicEntry(blobRef)TopicsMapSerializer— custom kotlinx-serializationKSerializerthat drops unknown topic wire keys at decode time and re-emits the enum's wire key on encode.BackendGetRemoteConfigTestfor the endpoint plumbing and error paths.REMOTE_CONFIG_BASE_URLinlocal.properties(e.g.http://localhost:8080/) redirects onlygetRemoteConfigto that host. Empty by default and gated to debug builds, so CI / release traffic is unaffected. Plumbed via a newBuildConfig.REMOTE_CONFIG_BASE_URLfield, mirroring the existingENABLE_EXTRA_REQUEST_LOGGINGpattern.All new types are
internal; no public API surface change.Stack
Add RemoteConfigManager and TopicFetcher: Add RemoteConfigManager and TopicFetcher #3437Clean up unreferenced topic files after successful remote-config refresh: Clean up unreferenced topic files after successful remote-config refresh #3439Checklist
purchases-ios/ hybrids (deferred — feature is not yet wired to any consumer)Note
Medium Risk
Adds new backend networking path (
GET /v2/config) and callback coalescing logic inBackend, 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 newBackend.getRemoteConfigcall that coalesces concurrent requests and parses a typedRemoteConfigResponse.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_URLBuildConfigfield (documented inlocal.properties.example) to optionally route only the remote-config GET request to a local host.Reviewed by Cursor Bugbot for commit a7f33ec. Bugbot is set up for automated code reviews on this repo. Configure here.