UI Config via config endpoint#3711
Conversation
07b4fe8 to
4a673d3
Compare
4a673d3 to
d55e50d
Compare
tonidero
left a comment
There was a problem hiding this comment.
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 } |
There was a problem hiding this comment.
Hmm would it work if you pass in the UiConfig type directly
| 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 😅 )
There was a problem hiding this comment.
ah that's pretty obvious hahaha, missed this one when self review. Thanks, will clean it up!
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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(),
)There was a problem hiding this comment.
Ohh yes you're right... if we keep these as separate blobs, indeed we will need to load them separately. Good catch!
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
that would be great yeah, I imagine this will become a common pattern, so better to have a helper for it
There was a problem hiding this comment.
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.
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 4914289. Configure here.
Codecov Report❌ Patch coverage is
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. 🚀 New features to boost your workflow:
|
549c7bf to
5ae97b0
Compare
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>
5ae97b0 to
6cae726
Compare
| private val manager: RemoteConfigManager, | ||
| ) { | ||
|
|
||
| suspend fun getUiConfig(): UiConfig { |
There was a problem hiding this comment.
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
There was a problem hiding this comment.
that's changing when I start using this helper https://github.com/RevenueCat/purchases-android/pull/3725/files#r3530180787

Adds
UiConfigProvider+getUiConfig/awaitGetUiConfig. Nothing consumes this yet, the WorkflowManager will use it directlyNote
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
UiConfigProviderreads theui_configtopic and merges four blob-backed parts (app,localizations,variable_config,custom_variables), with per-field defaults when blobs are missing and safe handling for malformedlocalizations.PurchasesOrchestrator.getUiConfig/ internalawaitGetUiConfigdelegate to the provider when remote config is enabled; they throwConfigurationErrorin UI preview mode or when remote config / UI config is unavailable.PurchasesFactoryconstructsUiConfigProvideralongsideRemoteConfigManagerand injects it into the orchestrator.RevenueCatUI’s
PurchasesType/PurchasesImplexposeawaitGetUiConfigfor 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.