Skip to content

Commit 4a673d3

Browse files
vegaroclaude
andcommitted
refactor(workflows): give ui_config its own independent read path
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>
1 parent ae737e8 commit 4a673d3

9 files changed

Lines changed: 192 additions & 0 deletions

File tree

purchases/src/defaults/kotlin/com/revenuecat/purchases/Purchases.kt

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -425,6 +425,16 @@ public class Purchases internal constructor(
425425
)
426426
}
427427

428+
@InternalRevenueCatAPI
429+
@JvmSynthetic
430+
@Throws(PurchasesException::class)
431+
public suspend fun awaitGetUiConfig(): UiConfig = suspendCoroutine { continuation ->
432+
purchasesOrchestrator.getUiConfig(
433+
onSuccess = continuation::resume,
434+
onError = { continuation.resumeWithException(PurchasesException(it)) },
435+
)
436+
}
437+
428438
@InternalRevenueCatAPI
429439
public fun workflowIdForOfferingId(offeringId: String): String? =
430440
purchasesOrchestrator.workflowIdForOfferingId(offeringId)

purchases/src/main/kotlin/com/revenuecat/purchases/PurchasesFactory.kt

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ import com.revenuecat.purchases.common.offlineentitlements.PurchasedProductsFetc
4040
import com.revenuecat.purchases.common.remoteconfig.RemoteConfigBlobStore
4141
import com.revenuecat.purchases.common.remoteconfig.RemoteConfigDiskCache
4242
import com.revenuecat.purchases.common.remoteconfig.RemoteConfigManager
43+
import com.revenuecat.purchases.common.remoteconfig.UiConfigProvider
4344
import com.revenuecat.purchases.common.verification.SignatureVerificationMode
4445
import com.revenuecat.purchases.common.verification.SigningManager
4546
import com.revenuecat.purchases.common.warnLog
@@ -286,6 +287,8 @@ internal class PurchasesFactory(
286287
null
287288
}
288289

290+
val uiConfigProvider = remoteConfigManager?.let { UiConfigProvider(it) }
291+
289292
val identityManager = IdentityManager(
290293
cache,
291294
subscriberAttributesCache,
@@ -492,6 +495,7 @@ internal class PurchasesFactory(
492495
workflowManager = workflowManager,
493496
fileRepository = fileRepository,
494497
remoteConfigManager = remoteConfigManager,
498+
uiConfigProvider = uiConfigProvider,
495499
)
496500

497501
return Purchases(purchasesOrchestrator)

purchases/src/main/kotlin/com/revenuecat/purchases/PurchasesOrchestrator.kt

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@ import com.revenuecat.purchases.common.log
4848
import com.revenuecat.purchases.common.offerings.OfferingsManager
4949
import com.revenuecat.purchases.common.offlineentitlements.OfflineEntitlementsManager
5050
import com.revenuecat.purchases.common.remoteconfig.RemoteConfigManager
51+
import com.revenuecat.purchases.common.remoteconfig.UiConfigProvider
5152
import com.revenuecat.purchases.common.sha1
5253
import com.revenuecat.purchases.common.subscriberattributes.SubscriberAttributeKey
5354
import com.revenuecat.purchases.common.verboseLog
@@ -106,6 +107,11 @@ import com.revenuecat.purchases.utils.Result
106107
import com.revenuecat.purchases.utils.isAndroidNOrNewer
107108
import com.revenuecat.purchases.virtualcurrencies.VirtualCurrencies
108109
import com.revenuecat.purchases.virtualcurrencies.VirtualCurrencyManager
110+
import kotlinx.coroutines.CoroutineScope
111+
import kotlinx.coroutines.Dispatchers
112+
import kotlinx.coroutines.SupervisorJob
113+
import kotlinx.coroutines.cancel
114+
import kotlinx.coroutines.launch
109115
import java.net.URL
110116
import java.util.Collections
111117
import java.util.Date
@@ -162,10 +168,13 @@ internal class PurchasesOrchestrator(
162168
private val backupManager: BackupManager = BackupManager(application),
163169
val fileRepository: FileRepository = DefaultFileRepository(application),
164170
private val remoteConfigManager: RemoteConfigManager? = null,
171+
private val uiConfigProvider: UiConfigProvider? = null,
165172
@OptIn(ExperimentalPreviewRevenueCatPurchasesAPI::class)
166173
val adTracker: AdTracker = AdTracker(adEventsManager),
167174
) : LifecycleDelegate, CustomActivityLifecycleHandler {
168175

176+
private val uiConfigScope: CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
177+
169178
internal var state: PurchasesState
170179
get() = purchasesStateCache.purchasesState
171180
set(value) {
@@ -619,6 +628,44 @@ internal class PurchasesOrchestrator(
619628
fun workflowIdForOfferingId(offeringId: String): String? =
620629
workflowManager?.workflowIdForOfferingId(offeringId)
621630

631+
fun getUiConfig(
632+
onSuccess: (UiConfig) -> Unit,
633+
onError: (PurchasesError) -> Unit,
634+
) {
635+
if (appConfig.uiPreviewMode) {
636+
dispatch {
637+
onError(
638+
PurchasesError(
639+
PurchasesErrorCode.ConfigurationError,
640+
"UI config cannot be fetched in UI preview mode.",
641+
),
642+
)
643+
}
644+
return
645+
}
646+
if (uiConfigProvider == null) {
647+
dispatch {
648+
onError(
649+
PurchasesError(
650+
PurchasesErrorCode.ConfigurationError,
651+
"UI config is not enabled.",
652+
),
653+
)
654+
}
655+
return
656+
}
657+
uiConfigScope.launch {
658+
try {
659+
val uiConfig = uiConfigProvider.getUiConfig()
660+
dispatch { onSuccess(uiConfig) }
661+
} catch (@Suppress("TooGenericExceptionCaught") e: Exception) {
662+
dispatch {
663+
onError(PurchasesError(PurchasesErrorCode.UnknownError, "Failed to fetch UI config: ${e.message}"))
664+
}
665+
}
666+
}
667+
}
668+
622669
fun getProducts(
623670
productIds: List<String>,
624671
type: ProductType? = null,
@@ -852,6 +899,7 @@ internal class PurchasesOrchestrator(
852899
}
853900
this.backend.close()
854901
this.remoteConfigManager?.close()
902+
this.uiConfigScope.cancel()
855903
this.workflowManager?.close()
856904

857905
billing.close()
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
package com.revenuecat.purchases.common.remoteconfig
2+
3+
import com.revenuecat.purchases.InternalRevenueCatAPI
4+
import com.revenuecat.purchases.JsonTools
5+
import com.revenuecat.purchases.UiConfig
6+
import kotlinx.serialization.json.JsonObject
7+
import kotlinx.serialization.json.decodeFromJsonElement
8+
9+
/**
10+
* The topic-specific front door for `ui_config`: three independently-updated parts — `app`, `localizations`,
11+
* `variable_config` — that together deserialize as one [UiConfig], the same shape the legacy offerings response
12+
* sends pre-assembled in a single JSON object. Each part is inline item metadata (no blob), so it's read straight
13+
* off the topic's item index. A part with no body (topic absent or item absent) is simply omitted, so [UiConfig]'s
14+
* own field defaults fill the gap.
15+
*/
16+
@OptIn(InternalRevenueCatAPI::class)
17+
internal class UiConfigProvider(
18+
private val manager: RemoteConfigManager,
19+
) {
20+
21+
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()
24+
return JsonTools.json.decodeFromJsonElement(JsonObject(parts))
25+
}
26+
27+
private companion object {
28+
private val PART_KEYS = listOf("app", "localizations", "variable_config")
29+
}
30+
}

purchases/src/test/java/com/revenuecat/purchases/BasePurchasesTest.kt

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ import com.revenuecat.purchases.common.offerings.OfferingsManager
2929
import com.revenuecat.purchases.common.remoteconfig.RemoteConfigManager
3030
import com.revenuecat.purchases.common.workflows.WorkflowManager
3131
import com.revenuecat.purchases.common.offlineentitlements.OfflineEntitlementsManager
32+
import com.revenuecat.purchases.common.remoteconfig.UiConfigProvider
3233
import com.revenuecat.purchases.deeplinks.WebPurchaseRedemptionHelper
3334
import com.revenuecat.purchases.google.toInAppStoreProduct
3435
import com.revenuecat.purchases.google.toStoreTransaction
@@ -95,6 +96,7 @@ internal open class BasePurchasesTest {
9596
internal val mockPurchaseParamsValidator = mockk<PurchaseParamsValidator>()
9697
internal val mockWorkflowManager = mockk<WorkflowManager>(relaxed = true)
9798
internal val mockRemoteConfigManager = mockk<RemoteConfigManager>(relaxed = true)
99+
internal val mockUiConfigProvider = mockk<UiConfigProvider>(relaxed = true)
98100
private val mockBlockstoreHelper = mockk<BlockstoreHelper>()
99101
private val purchasesStateProvider = PurchasesStateCache(PurchasesState())
100102

@@ -513,6 +515,7 @@ internal open class BasePurchasesTest {
513515
purchaseParamsValidator = mockPurchaseParamsValidator,
514516
workflowManager = mockWorkflowManager,
515517
remoteConfigManager = mockRemoteConfigManager,
518+
uiConfigProvider = mockUiConfigProvider,
516519
)
517520

518521
purchases = Purchases(

purchases/src/test/java/com/revenuecat/purchases/PurchasesCommonTest.kt

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55

66
package com.revenuecat.purchases
77

8+
import android.os.Looper
89
import androidx.test.ext.junit.runners.AndroidJUnit4
910
import com.android.billingclient.api.BillingClient
1011
import com.android.billingclient.api.BillingResult
@@ -41,6 +42,8 @@ import com.revenuecat.purchases.utils.stubPricingPhase
4142
import com.revenuecat.purchases.utils.stubStoreProduct
4243
import com.revenuecat.purchases.utils.stubSubscriptionOption
4344
import io.mockk.Runs
45+
import io.mockk.coEvery
46+
import io.mockk.coVerify
4447
import io.mockk.every
4548
import io.mockk.just
4649
import io.mockk.mockk
@@ -55,8 +58,11 @@ import org.junit.Assert.fail
5558
import org.junit.Test
5659
import org.junit.runner.RunWith
5760
import org.robolectric.annotation.Config
61+
import org.robolectric.Shadows.shadowOf
5862
import java.util.Collections.emptyList
5963
import java.util.Date
64+
import java.util.concurrent.CountDownLatch
65+
import java.util.concurrent.TimeUnit
6066
import kotlin.time.Duration.Companion.milliseconds
6167

6268
@RunWith(AndroidJUnit4::class)
@@ -71,6 +77,7 @@ internal class PurchasesCommonTest: BasePurchasesTest() {
7177
private val subPurchaseToken = "token_sub"
7278

7379
private val initiationSource = PostReceiptInitiationSource.PURCHASE
80+
private val defaultTimeout = 2000L
7481

7582
@After
7683
fun removeMocks() {
@@ -2913,6 +2920,79 @@ internal class PurchasesCommonTest: BasePurchasesTest() {
29132920

29142921
// endregion
29152922

2923+
// region getUiConfig
2924+
2925+
@Test
2926+
fun `getUiConfig delivers the provider's result to the caller`() {
2927+
val expected = UiConfig()
2928+
coEvery { mockUiConfigProvider.getUiConfig() } returns expected
2929+
2930+
var received: UiConfig? = null
2931+
var receivedError: PurchasesError? = null
2932+
val latch = CountDownLatch(1)
2933+
purchases.purchasesOrchestrator.getUiConfig(
2934+
onSuccess = { received = it; latch.countDown() },
2935+
onError = { receivedError = it; latch.countDown() },
2936+
)
2937+
2938+
awaitMainLooperCallback(latch)
2939+
assertThat(received).isEqualTo(expected)
2940+
assertThat(receivedError).isNull()
2941+
}
2942+
2943+
@Test
2944+
fun `getUiConfig delivers a failure as an error to the caller`() {
2945+
coEvery { mockUiConfigProvider.getUiConfig() } throws RuntimeException("boom")
2946+
2947+
var received: UiConfig? = null
2948+
var receivedError: PurchasesError? = null
2949+
val latch = CountDownLatch(1)
2950+
purchases.purchasesOrchestrator.getUiConfig(
2951+
onSuccess = { received = it; latch.countDown() },
2952+
onError = { receivedError = it; latch.countDown() },
2953+
)
2954+
2955+
awaitMainLooperCallback(latch)
2956+
assertThat(receivedError).isNotNull
2957+
assertThat(received).isNull()
2958+
}
2959+
2960+
/**
2961+
* `getUiConfig` delivers through `uiConfigScope` (a real `Dispatchers.IO` coroutine), then posts the
2962+
* callback to the main-thread handler. Robolectric's main looper is paused by default, so the posted
2963+
* callback sits queued until idled; poll-idle it while waiting on [latch] so we pick the callback up as
2964+
* soon as the background coroutine posts it, instead of guessing a fixed delay.
2965+
*/
2966+
private fun awaitMainLooperCallback(latch: CountDownLatch) {
2967+
val deadline = System.currentTimeMillis() + defaultTimeout
2968+
while (latch.count > 0 && System.currentTimeMillis() < deadline) {
2969+
shadowOf(Looper.getMainLooper()).idle()
2970+
latch.await(10, TimeUnit.MILLISECONDS)
2971+
}
2972+
assertThat(latch.await(0, TimeUnit.MILLISECONDS)).isTrue()
2973+
}
2974+
2975+
@Test
2976+
fun `getUiConfig returns ConfigurationError and never calls the provider when uiPreviewMode is true`() {
2977+
buildPurchases(anonymous = true, uiPreviewMode = true)
2978+
2979+
var received: UiConfig? = null
2980+
var receivedError: PurchasesError? = null
2981+
purchases.purchasesOrchestrator.getUiConfig(
2982+
onSuccess = { received = it },
2983+
onError = { receivedError = it },
2984+
)
2985+
2986+
assertThat(receivedError).isNotNull
2987+
assertThat(receivedError!!.code).isEqualTo(PurchasesErrorCode.ConfigurationError)
2988+
assertThat(received).isNull()
2989+
coVerify(exactly = 0) {
2990+
mockUiConfigProvider.getUiConfig()
2991+
}
2992+
}
2993+
2994+
// endregion
2995+
29162996
// region queryPurchases
29172997

29182998
@Test

ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/data/MockPurchasesType.kt

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import com.revenuecat.purchases.Offerings
77
import com.revenuecat.purchases.PurchaseParams
88
import com.revenuecat.purchases.PurchaseResult
99
import com.revenuecat.purchases.PurchasesAreCompletedBy
10+
import com.revenuecat.purchases.UiConfig
1011
import com.revenuecat.purchases.common.events.FeatureEvent
1112
import com.revenuecat.purchases.common.workflows.WorkflowDataResult
1213
import com.revenuecat.purchases.customercenter.CustomerCenterConfigData
@@ -65,5 +66,9 @@ internal class MockPurchasesType(
6566
throw NotImplementedError("Mock implementation for previews only")
6667
}
6768

69+
override suspend fun awaitGetUiConfig(): UiConfig {
70+
throw NotImplementedError("Mock implementation for previews only")
71+
}
72+
6873
override fun workflowIdForOfferingId(offeringId: String): String? = null
6974
}

ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/data/PurchasesType.kt

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import com.revenuecat.purchases.PurchaseResult
1010
import com.revenuecat.purchases.Purchases
1111
import com.revenuecat.purchases.PurchasesAreCompletedBy
1212
import com.revenuecat.purchases.PurchasesException
13+
import com.revenuecat.purchases.UiConfig
1314
import com.revenuecat.purchases.awaitCreateSupportTicket
1415
import com.revenuecat.purchases.awaitCustomerCenterConfigData
1516
import com.revenuecat.purchases.awaitCustomerInfo
@@ -69,6 +70,9 @@ internal interface PurchasesType {
6970
@Throws(PurchasesException::class)
7071
suspend fun awaitGetWorkflow(workflowId: String): WorkflowDataResult
7172

73+
@Throws(PurchasesException::class)
74+
suspend fun awaitGetUiConfig(): UiConfig
75+
7276
fun workflowIdForOfferingId(offeringId: String): String?
7377

7478
val useWorkflows: Boolean
@@ -146,6 +150,9 @@ internal class PurchasesImpl(private val purchases: Purchases = Purchases.shared
146150
return purchases.awaitGetWorkflow(workflowId)
147151
}
148152

153+
@Throws(PurchasesException::class)
154+
override suspend fun awaitGetUiConfig(): UiConfig = purchases.awaitGetUiConfig()
155+
149156
@OptIn(InternalRevenueCatAPI::class)
150157
override fun workflowIdForOfferingId(offeringId: String): String? =
151158
purchases.workflowIdForOfferingId(offeringId)

ui/revenuecatui/src/test/kotlin/com/revenuecat/purchases/ui/revenuecatui/data/MockPurchasesType.kt

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import com.revenuecat.purchases.Offerings
77
import com.revenuecat.purchases.PurchaseParams
88
import com.revenuecat.purchases.PurchaseResult
99
import com.revenuecat.purchases.PurchasesAreCompletedBy
10+
import com.revenuecat.purchases.UiConfig
1011
import com.revenuecat.purchases.common.events.FeatureEvent
1112
import com.revenuecat.purchases.common.workflows.WorkflowDataResult
1213
import com.revenuecat.purchases.customercenter.CustomerCenterConfigData
@@ -62,5 +63,9 @@ internal class MockPurchasesType(
6263
throw NotImplementedError("Mock implementation")
6364
}
6465

66+
override suspend fun awaitGetUiConfig(): UiConfig {
67+
throw NotImplementedError("Mock implementation")
68+
}
69+
6570
override fun workflowIdForOfferingId(offeringId: String): String? = null
6671
}

0 commit comments

Comments
 (0)