Skip to content

Commit c93bab1

Browse files
committed
Wire PaywallOptions web view message handler through paywall state.
1 parent 8063d3f commit c93bab1

10 files changed

Lines changed: 189 additions & 3 deletions

File tree

ui/revenuecatui/api.txt

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,11 +96,13 @@ package com.revenuecat.purchases.ui.revenuecatui {
9696
method public com.revenuecat.purchases.ui.revenuecatui.fonts.FontProvider? getFontProvider();
9797
method public com.revenuecat.purchases.ui.revenuecatui.PaywallListener? getListener();
9898
method public com.revenuecat.purchases.ui.revenuecatui.PaywallPurchaseLogic? getPurchaseLogic();
99+
method public com.revenuecat.purchases.ui.revenuecatui.PaywallWebViewMessageHandler? getWebViewMessageHandler();
99100
property public final java.util.Map<java.lang.String,com.revenuecat.purchases.ui.revenuecatui.CustomVariableValue> customVariables;
100101
property public final kotlin.jvm.functions.Function0<kotlin.Unit> dismissRequest;
101102
property public final com.revenuecat.purchases.ui.revenuecatui.fonts.FontProvider? fontProvider;
102103
property public final com.revenuecat.purchases.ui.revenuecatui.PaywallListener? listener;
103104
property public final com.revenuecat.purchases.ui.revenuecatui.PaywallPurchaseLogic? purchaseLogic;
105+
property public final com.revenuecat.purchases.ui.revenuecatui.PaywallWebViewMessageHandler? webViewMessageHandler;
104106
field public static final com.revenuecat.purchases.ui.revenuecatui.PaywallOptions.Companion Companion;
105107
}
106108

@@ -113,6 +115,7 @@ package com.revenuecat.purchases.ui.revenuecatui {
113115
method public com.revenuecat.purchases.ui.revenuecatui.PaywallOptions.Builder setOffering(com.revenuecat.purchases.Offering? offering);
114116
method public com.revenuecat.purchases.ui.revenuecatui.PaywallOptions.Builder setPurchaseLogic(com.revenuecat.purchases.ui.revenuecatui.PaywallPurchaseLogic? purchaseLogic);
115117
method public com.revenuecat.purchases.ui.revenuecatui.PaywallOptions.Builder setShouldDisplayDismissButton(boolean shouldDisplayDismissButton);
118+
method public com.revenuecat.purchases.ui.revenuecatui.PaywallOptions.Builder setWebViewMessageHandler(com.revenuecat.purchases.ui.revenuecatui.PaywallWebViewMessageHandler? handler);
116119
}
117120

118121
public static final class PaywallOptions.Companion {

ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/PaywallOptions.kt

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,11 @@ public class PaywallOptions internal constructor(
5959
* `{{ $custom.key }}` placeholders in the paywall configuration.
6060
*/
6161
public val customVariables: Map<String, CustomVariableValue> = emptyMap(),
62+
/**
63+
* Handler for messages sent by Paywalls V2 `web_view` components. Lets app code receive validated
64+
* messages and reply to them. See [PaywallWebViewMessageHandler].
65+
*/
66+
public val webViewMessageHandler: PaywallWebViewMessageHandler? = null,
6267
internal val injectedWorkflow: WorkflowDataResult? = null,
6368
) {
6469
public companion object {
@@ -75,6 +80,7 @@ public class PaywallOptions internal constructor(
7580
dismissRequest = builder.dismissRequest,
7681
dismissRequestWithExitOffering = builder.dismissRequestWithExitOffering,
7782
customVariables = builder.customVariables,
83+
webViewMessageHandler = builder.webViewMessageHandler,
7884
injectedWorkflow = builder.injectedWorkflow,
7985
)
8086

@@ -102,6 +108,7 @@ public class PaywallOptions internal constructor(
102108
this.purchaseLogic != other.purchaseLogic -> false
103109
this.mode != other.mode -> false
104110
this.customVariables != other.customVariables -> false
111+
this.webViewMessageHandler != other.webViewMessageHandler -> false
105112
this.injectedWorkflow != other.injectedWorkflow -> false
106113
else -> this.dismissRequest == other.dismissRequest
107114
}
@@ -117,6 +124,7 @@ public class PaywallOptions internal constructor(
117124
dismissRequest: () -> Unit = this.dismissRequest,
118125
dismissRequestWithExitOffering: ((Offering?, PaywallResult?) -> Unit)? = this.dismissRequestWithExitOffering,
119126
customVariables: Map<String, CustomVariableValue> = this.customVariables,
127+
webViewMessageHandler: PaywallWebViewMessageHandler? = this.webViewMessageHandler,
120128
injectedWorkflow: WorkflowDataResult? = this.injectedWorkflow,
121129
): PaywallOptions = PaywallOptions(
122130
offeringSelection = offeringSelection,
@@ -128,6 +136,7 @@ public class PaywallOptions internal constructor(
128136
dismissRequest = dismissRequest,
129137
dismissRequestWithExitOffering = dismissRequestWithExitOffering,
130138
customVariables = customVariables,
139+
webViewMessageHandler = webViewMessageHandler,
131140
injectedWorkflow = injectedWorkflow,
132141
)
133142

@@ -143,6 +152,7 @@ public class PaywallOptions internal constructor(
143152
internal var mode: PaywallMode = PaywallMode.default
144153
internal var dismissRequestWithExitOffering: ((Offering?, PaywallResult?) -> Unit)? = null
145154
internal var customVariables: Map<String, CustomVariableValue> = emptyMap()
155+
internal var webViewMessageHandler: PaywallWebViewMessageHandler? = null
146156
internal var injectedWorkflow: WorkflowDataResult? = null
147157

148158
public fun setOffering(offering: Offering?): Builder = apply {
@@ -205,6 +215,19 @@ public class PaywallOptions internal constructor(
205215
this.customVariables = CustomVariableKeyValidator.validateAndFilter(variables)
206216
}
207217

218+
/**
219+
* Sets a handler for messages sent by Paywalls V2 `web_view` components. The handler receives
220+
* validated messages (such as `rc:step-complete`, `rc:request-variables`, and `rc:error`) on
221+
* the main thread, along with a [PaywallWebViewController] for replying to the web view.
222+
*
223+
* Bidirectional messaging is always enabled for `web_view` components; this handler is how the
224+
* app observes and responds to those messages. The SDK does not automatically dismiss the
225+
* paywall or trigger a purchase in response to any message.
226+
*/
227+
public fun setWebViewMessageHandler(handler: PaywallWebViewMessageHandler?): Builder = apply {
228+
this.webViewMessageHandler = handler
229+
}
230+
208231
/**
209232
* Injects a pre-built workflow (multipage paywall) to render locally without fetching
210233
* it from the backend, together with the [Offering] it renders against. Internal

ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/webview/WebViewComponentView.kt

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@ internal fun WebViewComponentView(
5151
val componentId = style.componentId
5252
val enforceContentSecurityPolicy = style.protocolVersion != null
5353
val locale = state.locale.toLanguageTag()
54+
val messageHandler = state.webViewMessageHandler
5455
val sizeToContentWidth = style.size.width is Fit
5556
val sizeToContentHeight = style.size.height is Fit
5657

@@ -89,7 +90,7 @@ internal fun WebViewComponentView(
8990
componentId = id,
9091
expectedUrl = resolvedUrl,
9192
locale = locale,
92-
messageHandler = null,
93+
messageHandler = messageHandler,
9394
protocolVersion = style.protocolVersion ?: WebViewEnvelope.DEFAULT_PROTOCOL_VERSION,
9495
sizeToContentWidth = sizeToContentWidth,
9596
sizeToContentHeight = sizeToContentHeight,
@@ -108,7 +109,10 @@ internal fun WebViewComponentView(
108109
}
109110
},
110111
update = {
111-
bridgeHolder.bridge?.update(locale = locale, messageHandler = null)
112+
bridgeHolder.bridge?.update(
113+
locale = locale,
114+
messageHandler = messageHandler,
115+
)
112116
},
113117
onRelease = { webView ->
114118
bridgeHolder.bridge?.release()

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

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import com.revenuecat.purchases.Package
1717
import com.revenuecat.purchases.UiConfig.VariableConfig
1818
import com.revenuecat.purchases.paywalls.components.common.LocaleId
1919
import com.revenuecat.purchases.ui.revenuecatui.CustomVariableValue
20+
import com.revenuecat.purchases.ui.revenuecatui.PaywallWebViewMessageHandler
2021
import com.revenuecat.purchases.ui.revenuecatui.components.ktx.getBestMatch
2122
import com.revenuecat.purchases.ui.revenuecatui.components.ktx.toComposeLocale
2223
import com.revenuecat.purchases.ui.revenuecatui.components.ktx.toJavaLocale
@@ -85,7 +86,7 @@ internal sealed interface PaywallState {
8586
}
8687
}
8788

88-
@Suppress("LongParameterList")
89+
@Suppress("LongParameterList", "TooManyFunctions")
8990
@Stable
9091
class Components(
9192
val stack: ComponentStyle,
@@ -116,6 +117,7 @@ internal sealed interface PaywallState {
116117
* Default custom variables from the dashboard configuration.
117118
*/
118119
val defaultCustomVariables: Map<String, CustomVariableValue> = emptyMap(),
120+
initialWebViewMessageHandler: PaywallWebViewMessageHandler? = null,
119121
initialLocaleList: LocaleList = LocaleList.current,
120122
initialSelectedTabIndex: Int? = null,
121123
initialSheetState: SimpleSheetState = SimpleSheetState(),
@@ -188,6 +190,17 @@ internal sealed interface PaywallState {
188190

189191
private var localeId by mutableStateOf(initialLocaleList.toLocaleId())
190192

193+
/**
194+
* Optional handler for messages sent by `web_view` components, set via
195+
* [com.revenuecat.purchases.ui.revenuecatui.PaywallOptions.Builder.setWebViewMessageHandler].
196+
*
197+
* Backed by snapshot state and refreshed in place (without rebuilding the paywall state) when
198+
* the handler changes on [com.revenuecat.purchases.ui.revenuecatui.PaywallOptions]; see
199+
* `PaywallViewModelImpl.updateOptions`.
200+
*/
201+
var webViewMessageHandler by mutableStateOf(initialWebViewMessageHandler)
202+
private set
203+
191204
// We find all available device locales with the same country as the storefront country.
192205
private val availableStorefrontCountryLocalesByLanguage: Map<String, Locale> by lazy {
193206
if (storefrontCountryCode.isNullOrBlank()) {
@@ -333,6 +346,10 @@ internal sealed interface PaywallState {
333346
if (actionInProgress != null) this.actionInProgress = actionInProgress
334347
}
335348

349+
fun update(webViewMessageHandler: PaywallWebViewMessageHandler?) {
350+
this.webViewMessageHandler = webViewMessageHandler
351+
}
352+
336353
fun update(selectedPackageUniqueId: String) {
337354
this.selectedPackageUniqueId = selectedPackageUniqueId
338355

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

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -273,9 +273,22 @@ internal class PaywallViewModelImpl(
273273
val needsUpdateState = this.options.hashCode() != options.hashCode()
274274
// Some properties not considered for equality (hashCode) may have changed
275275
// (e.g. the listener may change in some re-renderers)
276+
val previousWebViewMessageHandler = this.options.webViewMessageHandler
276277
this.options = options
277278
if (needsUpdateState) {
278279
updateState()
280+
} else if (previousWebViewMessageHandler !== options.webViewMessageHandler) {
281+
// The web view message handler is excluded from hashCode and is baked into the loaded
282+
// Components state, so a handler-only change won't rebuild state. Refresh it in place
283+
// (like refreshStateIfLocaleChanged) so web_view components get the latest handler without
284+
// re-resolving the offering or resetting selections.
285+
val currentState = _state.value
286+
if (currentState is PaywallState.Loaded.Components) {
287+
currentState.update(webViewMessageHandler = options.webViewMessageHandler)
288+
workflowStepStateCache.values.forEach {
289+
it.update(webViewMessageHandler = options.webViewMessageHandler)
290+
}
291+
}
279292
}
280293
}
281294

@@ -1150,7 +1163,12 @@ internal class PaywallViewModelImpl(
11501163
}
11511164
if (computed is PaywallState.Loaded.Components && stepId !in workflowStepStateCache) {
11521165
workflowStepStateCache[stepId] = computed
1166+
// Re-stamp the locale and web view message handler from the latest values at insertion
1167+
// time: this step's state was computed on the background dispatcher with a snapshot
1168+
// taken before any concurrent updateOptions/locale change, and updateOptions only
1169+
// refreshes steps already present in the cache when it runs.
11531170
computed.update(localeList = _lastLocaleList.value.toFrameworkLocaleList())
1171+
computed.update(webViewMessageHandler = options.webViewMessageHandler)
11541172
workflow.singleStepFallbackId
11551173
?.let { workflowStepStateCache[it]?.selectedPackageInfo }
11561174
?.let { computed.setDefaultPackage(it) }
@@ -1424,6 +1442,7 @@ internal class PaywallViewModelImpl(
14241442
purchases = purchases,
14251443
customVariables = options.customVariables,
14261444
defaultCustomVariables = extractDefaultCustomVariables(offering),
1445+
webViewMessageHandler = options.webViewMessageHandler,
14271446
)
14281447
}
14291448
}

ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/helpers/OfferingToStateMapper.kt

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ import com.revenuecat.purchases.paywalls.components.properties.Size
3636
import com.revenuecat.purchases.paywalls.components.properties.SizeConstraint
3737
import com.revenuecat.purchases.ui.revenuecatui.CustomVariableValue
3838
import com.revenuecat.purchases.ui.revenuecatui.PaywallMode
39+
import com.revenuecat.purchases.ui.revenuecatui.PaywallWebViewMessageHandler
3940
import com.revenuecat.purchases.ui.revenuecatui.components.ktx.getBestMatch
4041
import com.revenuecat.purchases.ui.revenuecatui.components.properties.FontSpec
4142
import com.revenuecat.purchases.ui.revenuecatui.components.properties.determineFontSpecs
@@ -367,6 +368,7 @@ internal fun Offering.toComponentsPaywallState(
367368
purchases: PurchasesType,
368369
customVariables: Map<String, CustomVariableValue> = emptyMap(),
369370
defaultCustomVariables: Map<String, CustomVariableValue> = emptyMap(),
371+
webViewMessageHandler: PaywallWebViewMessageHandler? = null,
370372
): PaywallState.Loaded.Components {
371373
val showPricesWithDecimals = storefrontCountryCode?.let {
372374
!validationResult.zeroDecimalPlaceCountries.contains(it)
@@ -387,6 +389,7 @@ internal fun Offering.toComponentsPaywallState(
387389
packages = validationResult.packages,
388390
customVariables = customVariables,
389391
defaultCustomVariables = defaultCustomVariables,
392+
initialWebViewMessageHandler = webViewMessageHandler,
390393
initialSelectedTabIndex = validationResult.initialSelectedTabIndex,
391394
mainStackHasHeroImage = validationResult.mainStackHasHeroImage,
392395
purchases = purchases,
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
package com.revenuecat.purchases.ui.revenuecatui
2+
3+
import org.assertj.core.api.Assertions.assertThat
4+
import org.junit.Test
5+
6+
internal class PaywallOptionsWebViewHandlerTest {
7+
8+
private fun options(handler: PaywallWebViewMessageHandler?): PaywallOptions =
9+
PaywallOptions.Builder(dismissRequest = {})
10+
.setWebViewMessageHandler(handler)
11+
.build()
12+
13+
@Test
14+
fun `builder stores the web view message handler`() {
15+
val handler = PaywallWebViewMessageHandler { _, _ -> }
16+
17+
assertThat(options(handler).webViewMessageHandler).isSameAs(handler)
18+
}
19+
20+
@Test
21+
fun `defaults to no web view message handler`() {
22+
val options = PaywallOptions.Builder(dismissRequest = {}).build()
23+
24+
assertThat(options.webViewMessageHandler).isNull()
25+
}
26+
27+
@Test
28+
fun `options differing only by handler are not equal`() {
29+
val handlerA = PaywallWebViewMessageHandler { _, _ -> }
30+
val handlerB = PaywallWebViewMessageHandler { _, _ -> }
31+
32+
assertThat(options(handlerA)).isNotEqualTo(options(handlerB))
33+
}
34+
35+
@Test
36+
fun `handler is excluded from hashCode so state updates are not triggered by it alone`() {
37+
val handlerA = PaywallWebViewMessageHandler { _, _ -> }
38+
val handlerB = PaywallWebViewMessageHandler { _, _ -> }
39+
40+
// hashCode intentionally ignores the handler (like listener) so swapping handlers does not
41+
// force a paywall state refresh; see PaywallViewModel.updateOptions.
42+
assertThat(options(handlerA).hashCode()).isEqualTo(options(handlerB).hashCode())
43+
}
44+
}

ui/revenuecatui/src/test/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/style/StyleFactoryTests.kt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -136,6 +136,7 @@ class StyleFactoryTests {
136136
assertThat(style.urlTemplate).isEqualTo("https://paywalls.revenuecat.com/{{ custom.animal }}.html")
137137
assertThat(style.visible).isFalse()
138138
assertThat(style.size).isEqualTo(size)
139+
assertThat(style.componentId).isEqualTo("promo_web_view")
139140
assertThat(style.protocolVersion).isNull()
140141
}
141142

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

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@ import com.revenuecat.purchases.ui.revenuecatui.OfferingSelection
5050
import com.revenuecat.purchases.ui.revenuecatui.PaywallListener
5151
import com.revenuecat.purchases.ui.revenuecatui.PaywallMode
5252
import com.revenuecat.purchases.ui.revenuecatui.PaywallOptions
53+
import com.revenuecat.purchases.ui.revenuecatui.PaywallWebViewMessageHandler
5354
import com.revenuecat.purchases.ui.revenuecatui.PaywallPurchaseLogicParams
5455
import com.revenuecat.purchases.ui.revenuecatui.PaywallPurchaseLogic
5556
import com.revenuecat.purchases.ui.revenuecatui.PaywallPurchaseLogicWithCallback
@@ -661,6 +662,44 @@ class PaywallViewModelTest {
661662
coVerify(exactly = 1) { purchases.awaitOfferings() }
662663
}
663664

665+
@Test
666+
fun `updateOptions refreshes the web view message handler in place without rebuilding`(): Unit = runBlocking {
667+
val offering = Offering(
668+
identifier = "offering-id",
669+
serverDescription = "description",
670+
metadata = emptyMap(),
671+
availablePackages = listOf(TestData.Packages.monthly, TestData.Packages.annual),
672+
paywallComponents = Offering.PaywallComponents(UiConfig(), emptyPaywallComponentsData),
673+
)
674+
val handlerA = PaywallWebViewMessageHandler { _, _ -> }
675+
val handlerB = PaywallWebViewMessageHandler { _, _ -> }
676+
fun optionsWith(handler: PaywallWebViewMessageHandler) =
677+
PaywallOptions.Builder(dismissRequest = { dismissInvoked = true })
678+
.setListener(listener)
679+
.setOffering(offering)
680+
.setWebViewMessageHandler(handler)
681+
.build()
682+
683+
val model = PaywallViewModelImpl(
684+
MockResourceProvider(),
685+
purchases,
686+
optionsWith(handlerA),
687+
TestData.Constants.currentColorScheme,
688+
isDarkMode = false,
689+
shouldDisplayBlock = null,
690+
)
691+
val state = model.state.value as PaywallState.Loaded.Components
692+
assertThat(state.webViewMessageHandler).isSameAs(handlerA)
693+
694+
// Same paywall, only the handler differs (handler is excluded from hashCode).
695+
model.updateOptions(optionsWith(handlerB))
696+
697+
// No rebuild: the loaded state instance is preserved (same selections/scroll), and it now
698+
// carries the new handler so web_view components see the latest one.
699+
assertThat(model.state.value).isSameAs(state)
700+
assertThat(state.webViewMessageHandler).isSameAs(handlerB)
701+
}
702+
664703
@Test
665704
fun `Should load default offering`() {
666705
val model = create()

0 commit comments

Comments
 (0)