Skip to content
Original file line number Diff line number Diff line change
Expand Up @@ -425,6 +425,11 @@ public class Purchases internal constructor(
)
}

@InternalRevenueCatAPI
@JvmSynthetic
@Throws(PurchasesException::class)
public suspend fun awaitGetUiConfig(): UiConfig = purchasesOrchestrator.getUiConfig()

@InternalRevenueCatAPI
public fun workflowIdForOfferingId(offeringId: String): String? =
purchasesOrchestrator.workflowIdForOfferingId(offeringId)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ import com.revenuecat.purchases.common.offlineentitlements.PurchasedProductsFetc
import com.revenuecat.purchases.common.remoteconfig.RemoteConfigBlobStore
import com.revenuecat.purchases.common.remoteconfig.RemoteConfigDiskCache
import com.revenuecat.purchases.common.remoteconfig.RemoteConfigManager
import com.revenuecat.purchases.common.uiconfig.UiConfigProvider
import com.revenuecat.purchases.common.verification.SignatureVerificationMode
import com.revenuecat.purchases.common.verification.SigningManager
import com.revenuecat.purchases.common.warnLog
Expand Down Expand Up @@ -286,6 +287,8 @@ internal class PurchasesFactory(
null
}

val uiConfigProvider = remoteConfigManager?.let { UiConfigProvider(it) }

val identityManager = IdentityManager(
cache,
subscriberAttributesCache,
Expand Down Expand Up @@ -492,6 +495,7 @@ internal class PurchasesFactory(
workflowManager = workflowManager,
fileRepository = fileRepository,
remoteConfigManager = remoteConfigManager,
uiConfigProvider = uiConfigProvider,
)

return Purchases(purchasesOrchestrator)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ import com.revenuecat.purchases.common.offlineentitlements.OfflineEntitlementsMa
import com.revenuecat.purchases.common.remoteconfig.RemoteConfigManager
import com.revenuecat.purchases.common.sha1
import com.revenuecat.purchases.common.subscriberattributes.SubscriberAttributeKey
import com.revenuecat.purchases.common.uiconfig.UiConfigProvider
import com.revenuecat.purchases.common.verboseLog
import com.revenuecat.purchases.common.warnLog
import com.revenuecat.purchases.common.workflows.WorkflowDataResult
Expand Down Expand Up @@ -162,6 +163,7 @@ internal class PurchasesOrchestrator(
private val backupManager: BackupManager = BackupManager(application),
val fileRepository: FileRepository = DefaultFileRepository(application),
private val remoteConfigManager: RemoteConfigManager? = null,
private val uiConfigProvider: UiConfigProvider? = null,
@OptIn(ExperimentalPreviewRevenueCatPurchasesAPI::class)
val adTracker: AdTracker = AdTracker(adEventsManager),
) : LifecycleDelegate, CustomActivityLifecycleHandler {
Expand Down Expand Up @@ -619,6 +621,19 @@ internal class PurchasesOrchestrator(
fun workflowIdForOfferingId(offeringId: String): String? =
workflowManager?.workflowIdForOfferingId(offeringId)

suspend fun getUiConfig(): UiConfig {
val provider = uiConfigProvider
if (appConfig.uiPreviewMode || provider == null) {
val message = if (appConfig.uiPreviewMode) {
"UI config cannot be fetched in UI preview mode."
} else {
"UI config is not enabled."
}
throw PurchasesException(PurchasesError(PurchasesErrorCode.ConfigurationError, message))
}
return provider.getUiConfig()
}

fun getProducts(
productIds: List<String>,
type: ProductType? = null,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
package com.revenuecat.purchases.common.uiconfig

import com.revenuecat.purchases.InternalRevenueCatAPI
import com.revenuecat.purchases.UiConfig
import com.revenuecat.purchases.common.remoteconfig.RemoteConfigManager
import com.revenuecat.purchases.common.remoteconfig.RemoteConfigTopic

/**
* The topic-specific front door for `ui_config`: four independently-updated parts — `app`, `localizations`,
* `variable_config`, `custom_variables` — that together make up one [UiConfig], the same shape the legacy
* offerings response sends pre-assembled in a single JSON object. Each part is its own blob-ref item under the
* topic (not inline metadata). The parts are resolved concurrently and merged into a single keyed object via
* [RemoteConfigManager.mergeItemsBlobData], whose item-key-to-blob shape matches [UiConfig]'s wire format
* exactly, so the merged object decodes straight into [UiConfig] — including the property-level localizations
* serializer that skips unknown variable localization keys.
*
* The merge is all-or-nothing: if any part is missing, unresolvable, or the merged object doesn't decode, the
* whole config falls back to a default [UiConfig] rather than a partially-populated one.
*/
@OptIn(InternalRevenueCatAPI::class)
internal class UiConfigProvider(
private val manager: RemoteConfigManager,
) {

suspend fun getUiConfig(): UiConfig =
manager.mergeItemsBlobData<UiConfig>(
RemoteConfigTopic.UiConfig,
listOf("app", "localizations", "variable_config", "custom_variables"),
) ?: UiConfig()
}
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ import com.revenuecat.purchases.common.offerings.OfferingsManager
import com.revenuecat.purchases.common.remoteconfig.RemoteConfigManager
import com.revenuecat.purchases.common.workflows.WorkflowManager
import com.revenuecat.purchases.common.offlineentitlements.OfflineEntitlementsManager
import com.revenuecat.purchases.common.uiconfig.UiConfigProvider
import com.revenuecat.purchases.deeplinks.WebPurchaseRedemptionHelper
import com.revenuecat.purchases.google.toInAppStoreProduct
import com.revenuecat.purchases.google.toStoreTransaction
Expand Down Expand Up @@ -95,6 +96,7 @@ internal open class BasePurchasesTest {
internal val mockPurchaseParamsValidator = mockk<PurchaseParamsValidator>()
internal val mockWorkflowManager = mockk<WorkflowManager>(relaxed = true)
internal val mockRemoteConfigManager = mockk<RemoteConfigManager>(relaxed = true)
internal val mockUiConfigProvider = mockk<UiConfigProvider>(relaxed = true)
private val mockBlockstoreHelper = mockk<BlockstoreHelper>()
private val purchasesStateProvider = PurchasesStateCache(PurchasesState())

Expand Down Expand Up @@ -513,6 +515,7 @@ internal open class BasePurchasesTest {
purchaseParamsValidator = mockPurchaseParamsValidator,
workflowManager = mockWorkflowManager,
remoteConfigManager = mockRemoteConfigManager,
uiConfigProvider = mockUiConfigProvider,
)

purchases = Purchases(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,8 @@ import com.revenuecat.purchases.utils.stubPricingPhase
import com.revenuecat.purchases.utils.stubStoreProduct
import com.revenuecat.purchases.utils.stubSubscriptionOption
import io.mockk.Runs
import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.every
import io.mockk.just
import io.mockk.mockk
Expand All @@ -50,6 +52,7 @@ import io.mockk.verify
import io.mockk.verifyAll
import io.mockk.verifyOrder
import org.assertj.core.api.Assertions.assertThat
import org.assertj.core.api.Assertions.assertThatExceptionOfType
import org.junit.After
import org.junit.Assert.fail
import org.junit.Test
Expand All @@ -58,6 +61,7 @@ import org.robolectric.annotation.Config
import java.util.Collections.emptyList
import java.util.Date
import kotlin.time.Duration.Companion.milliseconds
import kotlinx.coroutines.runBlocking

@RunWith(AndroidJUnit4::class)
@Config(manifest = Config.NONE)
Expand Down Expand Up @@ -2913,6 +2917,43 @@ internal class PurchasesCommonTest: BasePurchasesTest() {

// endregion

// region getUiConfig

@Test
fun `getUiConfig delivers the provider's result to the caller`() {
val expected = UiConfig()
coEvery { mockUiConfigProvider.getUiConfig() } returns expected

val received = runBlocking { purchases.purchasesOrchestrator.getUiConfig() }

assertThat(received).isEqualTo(expected)
}

@Test
fun `getUiConfig propagates a provider failure to the caller without wrapping it`() {
val failure = RuntimeException("boom")
coEvery { mockUiConfigProvider.getUiConfig() } throws failure

assertThatExceptionOfType(RuntimeException::class.java)
.isThrownBy { runBlocking { purchases.purchasesOrchestrator.getUiConfig() } }
.isSameAs(failure)
}

@Test
fun `getUiConfig throws ConfigurationError and never calls the provider when uiPreviewMode is true`() {
buildPurchases(anonymous = true, uiPreviewMode = true)

assertThatExceptionOfType(PurchasesException::class.java)
.isThrownBy { runBlocking { purchases.purchasesOrchestrator.getUiConfig() } }
.extracting { it.code }
.isEqualTo(PurchasesErrorCode.ConfigurationError)
coVerify(exactly = 0) {
mockUiConfigProvider.getUiConfig()
}
}

// endregion

// region queryPurchases

@Test
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
package com.revenuecat.purchases.common.uiconfig

import com.revenuecat.purchases.InternalRevenueCatAPI
import com.revenuecat.purchases.LogHandler
import com.revenuecat.purchases.UiConfig
import com.revenuecat.purchases.common.currentLogHandler
import com.revenuecat.purchases.common.remoteconfig.RemoteConfigManager
import com.revenuecat.purchases.common.remoteconfig.RemoteConfigTopic
import com.revenuecat.purchases.paywalls.components.common.LocaleId
import com.revenuecat.purchases.paywalls.components.common.VariableLocalizationKey
import io.mockk.CapturingSlot
import io.mockk.coEvery
import io.mockk.mockk
import io.mockk.slot
import kotlinx.coroutines.test.runTest
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.put
import kotlinx.serialization.json.putJsonArray
import kotlinx.serialization.json.putJsonObject
import org.assertj.core.api.Assertions.assertThat
import org.junit.After
import org.junit.Before
import org.junit.Test

@OptIn(InternalRevenueCatAPI::class)
internal class UiConfigProviderTest {

private val manager = mockk<RemoteConfigManager>()
private val provider = UiConfigProvider(manager)

// This is a plain JUnit test (no Robolectric), so the default log handler's android.util.Log calls aren't
// mocked. Swap in a no-op handler so the undecodable-merge path can log without blowing up.
private val originalLogHandler = currentLogHandler

@Before
fun setUp() {
currentLogHandler = object : LogHandler {
override fun v(tag: String, msg: String) {}
override fun d(tag: String, msg: String) {}
override fun i(tag: String, msg: String) {}
override fun w(tag: String, msg: String) {}
override fun e(tag: String, msg: String, throwable: Throwable?) {}
}
}

@After
fun tearDown() {
currentLogHandler = originalLogHandler
}

@Test
fun `getUiConfig decodes the merged four-part object into one UiConfig`() = runTest {
// Production serves every ui_config part (app, localizations, variable_config, custom_variables) as
// its own blob-ref item under the topic — never as inline item metadata. An earlier revision read
// item.metadata directly, which silently produced an all-defaults UiConfig against real backend data.
val requestedKeys = stubMergedRead(
buildJsonObject {
putJsonObject("app") {
put("colors", buildJsonObject {})
put("fonts", buildJsonObject {})
}
putJsonObject("localizations") {
putJsonObject("en_US") { put("day", "Day") }
}
putJsonObject("variable_config") {
putJsonObject("variable_compatibility_map") { put("old_var", "new_var") }
put("function_compatibility_map", buildJsonObject {})
}
putJsonObject("custom_variables") {
putJsonObject("user_name") {
put("type", "string")
put("default_value", "Friend")
}
}
},
)

val uiConfig = provider.getUiConfig()

assertThat(requestedKeys.captured)
.containsExactly("app", "localizations", "variable_config", "custom_variables")
assertThat(uiConfig.localizations)
.isEqualTo(mapOf(LocaleId("en_US") to mapOf(VariableLocalizationKey.DAY to "Day")))
assertThat(uiConfig.variableConfig.variableCompatibilityMap).isEqualTo(mapOf("old_var" to "new_var"))
assertThat(uiConfig.customVariables).containsKey("user_name")
assertThat(uiConfig.customVariables["user_name"]?.type).isEqualTo("string")
assertThat(uiConfig.customVariables["user_name"]?.defaultValue).isEqualTo("Friend")
}

@Test
fun `getUiConfig returns an all-defaults UiConfig when the merged read returns null`() = runTest {
// mergeItemsBlobData is all-or-nothing: any unresolvable part nulls the whole merge, so the provider
// falls back to a fully-default UiConfig rather than a partially-populated one.
coEvery {
manager.mergeItemsBlobData(RemoteConfigTopic.UiConfig, any(), any<(JsonObject) -> UiConfig?>())
} returns null

val uiConfig = provider.getUiConfig()

assertThat(uiConfig).isEqualTo(UiConfig())
}

@Test
fun `getUiConfig returns an all-defaults UiConfig when the merged object doesn't decode`() = runTest {
// A localizations part that isn't the expected locale->map shape makes the whole merged object
// undecodable; the reified mergeItemsBlobData swallows that to null instead of throwing out of the
// provider, so the caller gets a default UiConfig.
stubMergedRead(
buildJsonObject {
put("app", buildJsonObject {})
putJsonArray("localizations") { add(JsonPrimitive("not an object")) }
put("variable_config", buildJsonObject {})
put("custom_variables", buildJsonObject {})
},
)

val uiConfig = provider.getUiConfig()

assertThat(uiConfig).isEqualTo(UiConfig())
}

// The provider calls the reified mergeItemsBlobData overload, which compiles down to the transform
// overload. Stubbing that overload to run the provided transform against [merged] exercises the real
// UiConfig decode — including each field's real serializer — exactly as production does.
private fun stubMergedRead(merged: JsonObject): CapturingSlot<Collection<String>> {
val keys = slot<Collection<String>>()
coEvery {
manager.mergeItemsBlobData(RemoteConfigTopic.UiConfig, capture(keys), any<(JsonObject) -> UiConfig?>())
} answers {
thirdArg<(JsonObject) -> UiConfig?>().invoke(merged)
}
return keys
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import com.revenuecat.purchases.Offerings
import com.revenuecat.purchases.PurchaseParams
import com.revenuecat.purchases.PurchaseResult
import com.revenuecat.purchases.PurchasesAreCompletedBy
import com.revenuecat.purchases.UiConfig
import com.revenuecat.purchases.common.events.FeatureEvent
import com.revenuecat.purchases.common.workflows.WorkflowDataResult
import com.revenuecat.purchases.customercenter.CustomerCenterConfigData
Expand Down Expand Up @@ -65,5 +66,9 @@ internal class MockPurchasesType(
throw NotImplementedError("Mock implementation for previews only")
}

override suspend fun awaitGetUiConfig(): UiConfig {
throw NotImplementedError("Mock implementation for previews only")
}

override fun workflowIdForOfferingId(offeringId: String): String? = null
}
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import com.revenuecat.purchases.PurchaseResult
import com.revenuecat.purchases.Purchases
import com.revenuecat.purchases.PurchasesAreCompletedBy
import com.revenuecat.purchases.PurchasesException
import com.revenuecat.purchases.UiConfig
import com.revenuecat.purchases.awaitCreateSupportTicket
import com.revenuecat.purchases.awaitCustomerCenterConfigData
import com.revenuecat.purchases.awaitCustomerInfo
Expand Down Expand Up @@ -69,6 +70,9 @@ internal interface PurchasesType {
@Throws(PurchasesException::class)
suspend fun awaitGetWorkflow(workflowId: String): WorkflowDataResult

@Throws(PurchasesException::class)
suspend fun awaitGetUiConfig(): UiConfig

fun workflowIdForOfferingId(offeringId: String): String?

val useWorkflows: Boolean
Expand Down Expand Up @@ -146,6 +150,9 @@ internal class PurchasesImpl(private val purchases: Purchases = Purchases.shared
return purchases.awaitGetWorkflow(workflowId)
}

@Throws(PurchasesException::class)
override suspend fun awaitGetUiConfig(): UiConfig = purchases.awaitGetUiConfig()

@OptIn(InternalRevenueCatAPI::class)
override fun workflowIdForOfferingId(offeringId: String): String? =
purchases.workflowIdForOfferingId(offeringId)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import com.revenuecat.purchases.Offerings
import com.revenuecat.purchases.PurchaseParams
import com.revenuecat.purchases.PurchaseResult
import com.revenuecat.purchases.PurchasesAreCompletedBy
import com.revenuecat.purchases.UiConfig
import com.revenuecat.purchases.common.events.FeatureEvent
import com.revenuecat.purchases.common.workflows.WorkflowDataResult
import com.revenuecat.purchases.customercenter.CustomerCenterConfigData
Expand Down Expand Up @@ -62,5 +63,9 @@ internal class MockPurchasesType(
throw NotImplementedError("Mock implementation")
}

override suspend fun awaitGetUiConfig(): UiConfig {
throw NotImplementedError("Mock implementation")
}

override fun workflowIdForOfferingId(offeringId: String): String? = null
}