Skip to content

UI Config via config endpoint#3711

Open
vegaro wants to merge 7 commits into
mainfrom
cesar/workflows-ui-config-read-path
Open

UI Config via config endpoint#3711
vegaro wants to merge 7 commits into
mainfrom
cesar/workflows-ui-config-read-path

Conversation

@vegaro

@vegaro vegaro commented Jul 3, 2026

Copy link
Copy Markdown
Member

Adds UiConfigProvider + getUiConfig/awaitGetUiConfig. Nothing consumes this yet, the WorkflowManager will use it directly


Note

Low Risk
Additive internal API behind remote config gating; behavior is covered by tests and does not change existing purchase or offerings flows until callers adopt it.

Overview
Adds a path to load UiConfig from the remote config service instead of only from legacy offerings JSON. A new UiConfigProvider reads the ui_config topic and merges four blob-backed parts (app, localizations, variable_config, custom_variables), with per-field defaults when blobs are missing and safe handling for malformed localizations.

PurchasesOrchestrator.getUiConfig / internal awaitGetUiConfig delegate to the provider when remote config is enabled; they throw ConfigurationError in UI preview mode or when remote config / UI config is unavailable. PurchasesFactory constructs UiConfigProvider alongside RemoteConfigManager and injects it into the orchestrator.

RevenueCatUI’s PurchasesType / PurchasesImpl expose awaitGetUiConfig for future workflow/paywall use; mocks are updated. Unit tests cover provider assembly, defaults, and orchestrator guardrails.

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

Base automatically changed from phase_6_read_facade_topic_body to main July 3, 2026 14:43
@vegaro vegaro force-pushed the cesar/workflows-ui-config-read-path branch from 07b4fe8 to 4a673d3 Compare July 6, 2026 08:18
@vegaro vegaro force-pushed the cesar/workflows-ui-config-read-path branch from 4a673d3 to d55e50d Compare July 6, 2026 09:30
@vegaro vegaro changed the title refactor(workflows): give ui_config its own independent read path Fetch UI config via config endpoint Jul 6, 2026
@vegaro vegaro changed the title Fetch UI config via config endpoint refactor(workflows): give ui_config its own independent read path Jul 6, 2026
@vegaro vegaro changed the title refactor(workflows): give ui_config its own independent read path UI Config via config endpoint Jul 6, 2026
@vegaro vegaro marked this pull request as ready for review July 6, 2026 10:57
@vegaro vegaro requested a review from a team as a code owner July 6, 2026 10:57
Comment thread purchases/src/defaults/kotlin/com/revenuecat/purchases/Purchases.kt Outdated

@tonidero tonidero 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.

Some questions that could potentially cleanup a few things, but nothing big. Great work!


suspend fun getUiConfig(): UiConfig {
val parts = PART_KEYS.mapNotNull { key ->
manager.blobData<JsonObject>(RemoteConfigTopic.UiConfig, key)?.let { key to it }

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.

Hmm would it work if you pass in the UiConfig type directly

Suggested change
manager.blobData<JsonObject>(RemoteConfigTopic.UiConfig, key)?.let { key to it }
manager.blobData<UiConfig>(RemoteConfigTopic.UiConfig, key)

I believe the method should perform the deserialization automatically (or that was the intention 😅 )

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

ah that's pretty obvious hahaha, missed this one when self review. Thanks, will clean it up!

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

hmm thinking about this a bit more... will that work?

the ui config topic has 4 separate blobs

 ui_config topic
    "app" → blob_ref "Ewnl…"
    "localizations"  → blob_ref "Jewn…"
    "variable_config" → blob_ref "xXyp…"
    "custom_variables"→ blob_ref "l6yC…"

Wouldn't that only work if ui_config was a single blob?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

I could do this though:

UiConfig(
    app = manager.blobData<AppConfig>(topic, "app") ?: AppConfig(),
    // `localizations` needs its property-level serializer, which skips unknown VariableLocalizationKeys.
    localizations = manager.blobData(topic, "localizations") { bytes ->
        JsonTools.json.decodeFromString(
            LocalizedVariableLocalizationKeyMapSerializer,
            bytes.decodeToString(),
        )
    } ?: emptyMap(),
    variableConfig = manager.blobData<VariableConfig>(topic, "variable_config") ?: VariableConfig(),
    customVariables = manager.blobData<Map<String, CustomVariableDefinition>>(topic, "custom_variables")
        ?: emptyMap(),
)

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.

Ohh yes you're right... if we keep these as separate blobs, indeed we will need to load them separately. Good catch!

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.

@rickvdl suggested adding a helper to the manager to handle these cases where we need to fetch multiple blobs simultaneously. Wdyt? Happy to add it and could also handle the parallelization of this process.

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.

We could also add an optional mapInto: parameter or something like that that allows constructing a container type (UIConfig in this case) from the set of fetched blobs if that helps?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

that would be great yeah, I imagine this will become a common pattern, so better to have a helper for it

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.

Alright, will work on that as well :).

cc @facumenzella for ios, no need to wait or change anything I'd say, I'll implement the new API in my PR right away.

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.

Done in Android in this PR: #3725

Comment thread purchases/src/main/kotlin/com/revenuecat/purchases/PurchasesOrchestrator.kt Outdated
Comment thread purchases/src/main/kotlin/com/revenuecat/purchases/PurchasesOrchestrator.kt 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 4914289. Configure here.

Comment thread purchases/src/main/kotlin/com/revenuecat/purchases/PurchasesOrchestrator.kt Outdated
@vegaro vegaro changed the title UI Config via config endpoint refactor(workflows): give ui_config its own independent read path Jul 6, 2026
@vegaro vegaro changed the title refactor(workflows): give ui_config its own independent read path UI Config via config endpoint Jul 6, 2026
@codecov

codecov Bot commented Jul 6, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 88.88889% with 3 lines in your changes missing coverage. Please review.
✅ Project coverage is 80.56%. Comparing base (707ad74) to head (549c7bf).
⚠️ Report is 1 commits behind head on main.

Files with missing lines Patch % Lines
.../com/revenuecat/purchases/PurchasesOrchestrator.kt 62.50% 1 Missing and 2 partials ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main    #3711      +/-   ##
==========================================
+ Coverage   80.54%   80.56%   +0.01%     
==========================================
  Files         400      401       +1     
  Lines       16595    16622      +27     
  Branches     2370     2376       +6     
==========================================
+ Hits        13367    13391      +24     
- Misses       2291     2292       +1     
- Partials      937      939       +2     

☔ View full report in Codecov by Harness.
📢 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.

vegaro and others added 6 commits July 6, 2026 16:41
Adds UiConfigProvider (reads the ui_config topic through RemoteConfigManager's
topic() facade) and a symmetric read path — PurchasesOrchestrator.getUiConfig /
Purchases.awaitGetUiConfig — mirroring getWorkflow/awaitGetWorkflow. Fully
standalone: nothing consumes it yet, WorkflowManager and PublishedWorkflow are
untouched.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
UiConfigProvider.PART_KEYS omitted custom_variables, one of UiConfig's four
fields, so every ui_config read silently dropped it in favor of the empty
default. Add it to the assembled parts, and add UiConfigProviderTest — nothing
previously exercised PART_KEYS directly (PurchasesCommonTest mocks the whole
provider), which is why this went unnoticed through implementation and review.
…data

UiConfigProvider read each part (app, localizations, variable_config,
custom_variables) from the topic item's inline metadata. Production serves
every part as its own blob-ref item instead, so metadata was always empty and
getUiConfig() silently returned an all-defaults UiConfig regardless of what
the backend actually sent — confirmed against a real /v1/config response.

Switch to RemoteConfigManager.blobData(), which resolves the referenced blob
(fetching on demand and deduping against any in-flight prefetch) instead of
reading the item index directly. Updated UiConfigProviderTest's fixtures to
match — the previous inline-metadata fixtures exercised a shape production
never sends, which is exactly how this went unnoticed.
PurchasesOrchestrator.getUiConfig wrapped a suspend call (UiConfigProvider)
in a manual callback + dedicated uiConfigScope, then Purchases.awaitGetUiConfig
re-wrapped that callback back into a suspend function via suspendCoroutine.
Both layers of wrapping were unnecessary — RemoteConfigManager.blobData already
runs on its own IO dispatcher — and the raw suspendCoroutine/continuation::resume
pattern in awaitGetUiConfig ignored caller cancellation, unlike every other
Purchases await* helper (which use suspendCancellableCoroutine + safeResume).

Make getUiConfig a suspend fun that throws PurchasesException directly; drop
uiConfigScope entirely. awaitGetUiConfig now just delegates to it with no
continuation wrapping needed at all, so there's no cancellation gap to begin
with. Updated PurchasesCommonTest's getUiConfig tests to match — the Robolectric
main-looper polling helper they needed is gone along with the callback API.
…ckage

UiConfigProvider lived in common.remoteconfig, but it's ui_config-feature-
specific, not a generic remote-config primitive — same reasoning that already
put WorkflowsConfigProvider in common.workflows rather than common.remoteconfig.
Moves UiConfigProvider + its test to common.uiconfig, no behavior change.
Decode each ui_config blob part directly into its target type and build
UiConfig via its constructor, instead of re-wrapping the parts into a
JsonObject and decoding as UiConfig once. app, variable_config, and
custom_variables use the reified blobData overload; localizations goes
through the transform overload with LocalizedVariableLocalizationKeyMapSerializer
so unknown VariableLocalizationKeys are skipped rather than throwing.

The test's stubBlob helper now runs blobData's transform against the
part's real bytes, exercising each field's real serializer.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…te instead

PurchasesOrchestrator.getUiConfig() no longer wraps provider failures in a
PurchasesException(UnknownError). The broad catch also swallowed
CancellationException, breaking structured concurrency for cancelled callers.
It now runs only the ConfigurationError precondition guard and returns the
provider's result, letting results and errors propagate to the caller — in
line with the rest of Purchases.kt, which boxes known domain errors rather
than blanket-catching.

To keep malformed-data handling consistent after dropping the wrap, the
UiConfigProvider localizations part now swallows a malformed blob to null
(defaulting to empty), matching what the reified blobData overload already
does for the app, variable_config, and custom_variables parts.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@vegaro vegaro force-pushed the cesar/workflows-ui-config-read-path branch from 5ae97b0 to 6cae726 Compare July 6, 2026 14:42
private val manager: RemoteConfigManager,
) {

suspend fun getUiConfig(): UiConfig {

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.

On iOS, this can fail 🤔
Both app and localizations are treated as required, and if one fails the whole thing fails.

What's the expectation here? We should be doing the same

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

@facumenzella facumenzella 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.

This makes sense to me

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.

4 participants