Skip to content

Add network scaffolding for remote config endpoint#3435

Merged
tonidero merged 7 commits into
mainfrom
toniricodiez/add-remote-config-network-scaffolding
May 8, 2026
Merged

Add network scaffolding for remote config endpoint#3435
tonidero merged 7 commits into
mainfrom
toniricodiez/add-remote-config-network-scaffolding

Conversation

@tonidero

@tonidero tonidero commented May 5, 2026

Copy link
Copy Markdown
Contributor

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

Checklist

  • Unit tests
  • Follow-up issues for 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 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.

Reviewed by Cursor Bugbot for commit a7f33ec. Bugbot is set up for automated code reviews on this repo. Configure here.

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

codecov Bot commented May 5, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 76.92308% with 18 lines in your changes missing coverage. Please review.
✅ Project coverage is 79.46%. Comparing base (aa0f786) to head (a7f33ec).
⚠️ Report is 15 commits behind head on main.

Files with missing lines Patch % Lines
.../kotlin/com/revenuecat/purchases/common/Backend.kt 71.11% 7 Missing and 6 partials ⚠️
...chases/common/remoteconfig/RemoteConfigResponse.kt 81.48% 0 Missing and 5 partials ⚠️
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.
📢 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.

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>
@tonidero tonidero force-pushed the toniricodiez/add-remote-config-network-scaffolding branch from 39308f9 to 01f4829 Compare May 6, 2026 13:33
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",

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Keeping this while we test things, but the intention is for it to go away in the final version.

@tonidero tonidero marked this pull request as ready for review May 8, 2026 07:55
@tonidero tonidero requested a review from a team as a code owner May 8, 2026 07:55

@ajpallares ajpallares left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 single Source(id, urlFormat, priority, weight).

Comment thread local.properties.example Outdated

@cursor cursor Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 1 potential issue.

Fix All in Cursor

❌ 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)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Fix in Cursor Fix in Web

Triggered by learned rule: Use FQN imports, not inline fully-qualified references

Reviewed by Cursor Bugbot for commit a7f33ec. Configure here.

@tonidero tonidero added this pull request to the merge queue May 8, 2026
Merged via the queue into main with commit 315b770 May 8, 2026
43 checks passed
@tonidero tonidero deleted the toniricodiez/add-remote-config-network-scaffolding branch May 8, 2026 11:42
github-merge-queue Bot pushed a commit that referenced this pull request May 13, 2026
**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 -->
matteinn pushed a commit to matteinn/purchases-android that referenced this pull request Jun 5, 2026
### 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>
matteinn pushed a commit to matteinn/purchases-android that referenced this pull request Jun 5, 2026
…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>
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.

2 participants