Skip to content

Commit 2d63bd8

Browse files
committed
fix(remote-config): resolve ui_config parts as blobs, not inline metadata
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.
1 parent 059c6ac commit 2d63bd8

2 files changed

Lines changed: 52 additions & 51 deletions

File tree

purchases/src/main/kotlin/com/revenuecat/purchases/common/remoteconfig/UiConfigProvider.kt

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -9,18 +9,20 @@ import kotlinx.serialization.json.decodeFromJsonElement
99
/**
1010
* The topic-specific front door for `ui_config`: four independently-updated parts — `app`, `localizations`,
1111
* `variable_config`, `custom_variables` — that together deserialize as one [UiConfig], the same shape the legacy
12-
* offerings response sends pre-assembled in a single JSON object. Each part is inline item metadata (no blob), so
13-
* it's read straight off the topic's item index. A part with no body (topic absent or item absent) is simply
14-
* omitted, so [UiConfig]'s own field defaults fill the gap.
12+
* offerings response sends pre-assembled in a single JSON object. Each part is its own blob-ref item under the
13+
* topic (not inline metadata), resolved through [RemoteConfigManager.blobData]. A part with no resolvable blob
14+
* (topic absent, item absent, or blob unresolvable) is simply omitted, so [UiConfig]'s own field defaults fill
15+
* the gap.
1516
*/
1617
@OptIn(InternalRevenueCatAPI::class)
1718
internal class UiConfigProvider(
1819
private val manager: RemoteConfigManager,
1920
) {
2021

2122
suspend fun getUiConfig(): UiConfig {
22-
val topic = manager.topic(RemoteConfigTopic.UiConfig)
23-
val parts = PART_KEYS.mapNotNull { key -> topic?.get(key)?.let { key to it.metadata } }.toMap()
23+
val parts = PART_KEYS.mapNotNull { key ->
24+
manager.blobData<JsonObject>(RemoteConfigTopic.UiConfig, key)?.let { key to it }
25+
}.toMap()
2426
return JsonTools.json.decodeFromJsonElement(JsonObject(parts))
2527
}
2628

purchases/src/test/java/com/revenuecat/purchases/common/remoteconfig/UiConfigProviderTest.kt

Lines changed: 45 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,6 @@ import com.revenuecat.purchases.paywalls.components.common.VariableLocalizationK
66
import io.mockk.coEvery
77
import io.mockk.mockk
88
import kotlinx.coroutines.test.runTest
9-
import kotlinx.serialization.json.JsonObject
109
import kotlinx.serialization.json.buildJsonObject
1110
import kotlinx.serialization.json.put
1211
import org.assertj.core.api.Assertions.assertThat
@@ -19,42 +18,30 @@ internal class UiConfigProviderTest {
1918
private val provider = UiConfigProvider(manager)
2019

2120
@Test
22-
fun `getUiConfig assembles all four topic parts into one UiConfig`() = runTest {
23-
val topic = ConfigTopic(
24-
mapOf(
25-
"app" to RemoteConfiguration.ConfigItem(
26-
metadata = buildJsonObject {
27-
put("colors", buildJsonObject {})
28-
put("fonts", buildJsonObject {})
29-
},
30-
),
31-
"localizations" to RemoteConfiguration.ConfigItem(
32-
metadata = buildJsonObject {
33-
put("en_US", buildJsonObject { put("day", "Day") })
34-
},
35-
),
36-
"variable_config" to RemoteConfiguration.ConfigItem(
37-
metadata = buildJsonObject {
38-
put("variable_compatibility_map", buildJsonObject { put("old_var", "new_var") })
39-
put("function_compatibility_map", buildJsonObject {})
40-
},
41-
),
42-
// The part this test exists to guard: dropped from PART_KEYS in an earlier revision, which
43-
// silently discarded custom_variables from every ui_config read.
44-
"custom_variables" to RemoteConfiguration.ConfigItem(
45-
metadata = buildJsonObject {
46-
put(
47-
"user_name",
48-
buildJsonObject {
49-
put("type", "string")
50-
put("default_value", "Friend")
51-
},
52-
)
53-
},
54-
),
55-
),
56-
)
57-
coEvery { manager.topic(RemoteConfigTopic.UiConfig) } returns topic
21+
fun `getUiConfig assembles all four blob-ref parts into one UiConfig`() = runTest {
22+
// Production serves every ui_config part (app, localizations, variable_config, custom_variables) as
23+
// its own blob-ref item under the topic — never as inline item metadata. An earlier revision read
24+
// item.metadata directly, which silently produced an all-defaults UiConfig against real backend data.
25+
stubBlob("app") {
26+
put("colors", buildJsonObject {})
27+
put("fonts", buildJsonObject {})
28+
}
29+
stubBlob("localizations") {
30+
put("en_US", buildJsonObject { put("day", "Day") })
31+
}
32+
stubBlob("variable_config") {
33+
put("variable_compatibility_map", buildJsonObject { put("old_var", "new_var") })
34+
put("function_compatibility_map", buildJsonObject {})
35+
}
36+
stubBlob("custom_variables") {
37+
put(
38+
"user_name",
39+
buildJsonObject {
40+
put("type", "string")
41+
put("default_value", "Friend")
42+
},
43+
)
44+
}
5845

5946
val uiConfig = provider.getUiConfig()
6047

@@ -67,25 +54,37 @@ internal class UiConfigProviderTest {
6754
}
6855

6956
@Test
70-
fun `getUiConfig defaults every field when the ui_config topic is absent`() = runTest {
71-
coEvery { manager.topic(RemoteConfigTopic.UiConfig) } returns null
57+
fun `getUiConfig defaults every field when no part's blob resolves`() = runTest {
58+
coEvery {
59+
manager.blobData<Any>(RemoteConfigTopic.UiConfig, any(), any())
60+
} returns null
7261

7362
val uiConfig = provider.getUiConfig()
7463

7564
assertThat(uiConfig).isEqualTo(com.revenuecat.purchases.UiConfig())
7665
}
7766

7867
@Test
79-
fun `getUiConfig defaults custom_variables to empty when its topic item is absent`() = runTest {
80-
val topic = ConfigTopic(
81-
mapOf(
82-
"app" to RemoteConfiguration.ConfigItem(metadata = JsonObject(emptyMap())),
83-
),
84-
)
85-
coEvery { manager.topic(RemoteConfigTopic.UiConfig) } returns topic
68+
fun `getUiConfig defaults custom_variables to empty when only its blob is unresolved`() = runTest {
69+
stubBlob("app") {}
70+
coEvery {
71+
manager.blobData<Any>(RemoteConfigTopic.UiConfig, "custom_variables", any())
72+
} returns null
73+
coEvery {
74+
manager.blobData<Any>(RemoteConfigTopic.UiConfig, "localizations", any())
75+
} returns null
76+
coEvery {
77+
manager.blobData<Any>(RemoteConfigTopic.UiConfig, "variable_config", any())
78+
} returns null
8679

8780
val uiConfig = provider.getUiConfig()
8881

8982
assertThat(uiConfig.customVariables).isEmpty()
9083
}
84+
85+
private fun stubBlob(key: String, content: kotlinx.serialization.json.JsonObjectBuilder.() -> Unit) {
86+
coEvery {
87+
manager.blobData<Any>(RemoteConfigTopic.UiConfig, key, any())
88+
} returns buildJsonObject(content)
89+
}
9190
}

0 commit comments

Comments
 (0)