Skip to content

Commit 8d10692

Browse files
AlvaroBreyJZDesign
authored andcommitted
Wire PaywallOptions web view message handler through paywall state.
1 parent 0723cd5 commit 8d10692

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
@@ -60,6 +60,11 @@ public class PaywallOptions internal constructor(
6060
* `{{ $custom.key }}` placeholders in the paywall configuration.
6161
*/
6262
public val customVariables: Map<String, CustomVariableValue> = emptyMap(),
63+
/**
64+
* Handler for messages sent by Paywalls V2 `web_view` components. Lets app code receive validated
65+
* messages and reply to them. See [PaywallWebViewMessageHandler].
66+
*/
67+
public val webViewMessageHandler: PaywallWebViewMessageHandler? = null,
6368
internal val injectedWorkflow: PublishedWorkflow? = null,
6469
internal val injectedWorkflowUiConfig: UiConfig = emptyUiConfig(),
6570
) {
@@ -77,6 +82,7 @@ public class PaywallOptions internal constructor(
7782
dismissRequest = builder.dismissRequest,
7883
dismissRequestWithExitOffering = builder.dismissRequestWithExitOffering,
7984
customVariables = builder.customVariables,
85+
webViewMessageHandler = builder.webViewMessageHandler,
8086
injectedWorkflow = builder.injectedWorkflow,
8187
injectedWorkflowUiConfig = builder.injectedWorkflowUiConfig,
8288
)
@@ -106,6 +112,7 @@ public class PaywallOptions internal constructor(
106112
this.purchaseLogic != other.purchaseLogic -> false
107113
this.mode != other.mode -> false
108114
this.customVariables != other.customVariables -> false
115+
this.webViewMessageHandler != other.webViewMessageHandler -> false
109116
this.injectedWorkflow != other.injectedWorkflow -> false
110117
this.injectedWorkflowUiConfig != other.injectedWorkflowUiConfig -> false
111118
else -> this.dismissRequest == other.dismissRequest
@@ -122,6 +129,7 @@ public class PaywallOptions internal constructor(
122129
dismissRequest: () -> Unit = this.dismissRequest,
123130
dismissRequestWithExitOffering: ((Offering?, PaywallResult?) -> Unit)? = this.dismissRequestWithExitOffering,
124131
customVariables: Map<String, CustomVariableValue> = this.customVariables,
132+
webViewMessageHandler: PaywallWebViewMessageHandler? = this.webViewMessageHandler,
125133
injectedWorkflow: PublishedWorkflow? = this.injectedWorkflow,
126134
injectedWorkflowUiConfig: UiConfig = this.injectedWorkflowUiConfig,
127135
): PaywallOptions = PaywallOptions(
@@ -134,6 +142,7 @@ public class PaywallOptions internal constructor(
134142
dismissRequest = dismissRequest,
135143
dismissRequestWithExitOffering = dismissRequestWithExitOffering,
136144
customVariables = customVariables,
145+
webViewMessageHandler = webViewMessageHandler,
137146
injectedWorkflow = injectedWorkflow,
138147
injectedWorkflowUiConfig = injectedWorkflowUiConfig,
139148
)
@@ -150,6 +159,7 @@ public class PaywallOptions internal constructor(
150159
internal var mode: PaywallMode = PaywallMode.default
151160
internal var dismissRequestWithExitOffering: ((Offering?, PaywallResult?) -> Unit)? = null
152161
internal var customVariables: Map<String, CustomVariableValue> = emptyMap()
162+
internal var webViewMessageHandler: PaywallWebViewMessageHandler? = null
153163
internal var injectedWorkflow: PublishedWorkflow? = null
154164
internal var injectedWorkflowUiConfig: UiConfig = emptyUiConfig()
155165

@@ -213,6 +223,19 @@ public class PaywallOptions internal constructor(
213223
this.customVariables = CustomVariableKeyValidator.validateAndFilter(variables)
214224
}
215225

226+
/**
227+
* Sets a handler for messages sent by Paywalls V2 `web_view` components. The handler receives
228+
* validated messages (such as `rc:step-complete`, `rc:request-variables`, and `rc:error`) on
229+
* the main thread, along with a [PaywallWebViewController] for replying to the web view.
230+
*
231+
* Bidirectional messaging is always enabled for `web_view` components; this handler is how the
232+
* app observes and responds to those messages. The SDK does not automatically dismiss the
233+
* paywall or trigger a purchase in response to any message.
234+
*/
235+
public fun setWebViewMessageHandler(handler: PaywallWebViewMessageHandler?): Builder = apply {
236+
this.webViewMessageHandler = handler
237+
}
238+
216239
/**
217240
* Injects a pre-built workflow (multipage paywall) to render locally without fetching
218241
* 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
@@ -48,6 +48,7 @@ internal fun WebViewComponentView(
4848

4949
val componentId = style.componentId
5050
val locale = state.locale.toLanguageTag()
51+
val messageHandler = state.webViewMessageHandler
5152
val sizeToContentWidth = style.size.width is Fit
5253
val sizeToContentHeight = style.size.height is Fit
5354

@@ -86,7 +87,7 @@ internal fun WebViewComponentView(
8687
componentId = id,
8788
expectedUrl = resolvedUrl,
8889
locale = locale,
89-
messageHandler = null,
90+
messageHandler = messageHandler,
9091
protocolVersion = style.protocolVersion ?: WebViewEnvelope.DEFAULT_PROTOCOL_VERSION,
9192
sizeToContentWidth = sizeToContentWidth,
9293
sizeToContentHeight = sizeToContentHeight,
@@ -104,7 +105,10 @@ internal fun WebViewComponentView(
104105
}
105106
},
106107
update = {
107-
bridgeHolder.bridge?.update(locale = locale, messageHandler = null)
108+
bridgeHolder.bridge?.update(
109+
locale = locale,
110+
messageHandler = messageHandler,
111+
)
108112
},
109113
onRelease = { webView ->
110114
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()) {
@@ -342,6 +355,10 @@ internal sealed interface PaywallState {
342355
if (actionInProgress != null) this.actionInProgress = actionInProgress
343356
}
344357

358+
fun update(webViewMessageHandler: PaywallWebViewMessageHandler?) {
359+
this.webViewMessageHandler = webViewMessageHandler
360+
}
361+
345362
fun update(selectedPackageUniqueId: String) {
346363
this.selectedPackageUniqueId = selectedPackageUniqueId
347364

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
@@ -275,9 +275,22 @@ internal class PaywallViewModelImpl(
275275
val needsUpdateState = this.options.hashCode() != options.hashCode()
276276
// Some properties not considered for equality (hashCode) may have changed
277277
// (e.g. the listener may change in some re-renderers)
278+
val previousWebViewMessageHandler = this.options.webViewMessageHandler
278279
this.options = options
279280
if (needsUpdateState) {
280281
updateState()
282+
} else if (previousWebViewMessageHandler !== options.webViewMessageHandler) {
283+
// The web view message handler is excluded from hashCode and is baked into the loaded
284+
// Components state, so a handler-only change won't rebuild state. Refresh it in place
285+
// (like refreshStateIfLocaleChanged) so web_view components get the latest handler without
286+
// re-resolving the offering or resetting selections.
287+
val currentState = _state.value
288+
if (currentState is PaywallState.Loaded.Components) {
289+
currentState.update(webViewMessageHandler = options.webViewMessageHandler)
290+
workflowStepStateCache.values.forEach {
291+
it.update(webViewMessageHandler = options.webViewMessageHandler)
292+
}
293+
}
281294
}
282295
}
283296

@@ -1186,7 +1199,12 @@ internal class PaywallViewModelImpl(
11861199
}
11871200
if (computed is PaywallState.Loaded.Components && stepId !in workflowStepStateCache) {
11881201
workflowStepStateCache[stepId] = computed
1202+
// Re-stamp the locale and web view message handler from the latest values at insertion
1203+
// time: this step's state was computed on the background dispatcher with a snapshot
1204+
// taken before any concurrent updateOptions/locale change, and updateOptions only
1205+
// refreshes steps already present in the cache when it runs.
11891206
computed.update(localeList = _lastLocaleList.value.toFrameworkLocaleList())
1207+
computed.update(webViewMessageHandler = options.webViewMessageHandler)
11901208
workflow.singleStepFallbackId
11911209
?.let { workflowStepStateCache[it]?.selectedPackageInfo }
11921210
?.let { computed.setDefaultPackage(it) }
@@ -1459,6 +1477,7 @@ internal class PaywallViewModelImpl(
14591477
purchases = purchases,
14601478
customVariables = options.customVariables,
14611479
defaultCustomVariables = extractDefaultCustomVariables(offering),
1480+
webViewMessageHandler = options.webViewMessageHandler,
14621481
)
14631482
}
14641483
}

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.url).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
@@ -49,6 +49,7 @@ import com.revenuecat.purchases.ui.revenuecatui.OfferingSelection
4949
import com.revenuecat.purchases.ui.revenuecatui.PaywallListener
5050
import com.revenuecat.purchases.ui.revenuecatui.PaywallMode
5151
import com.revenuecat.purchases.ui.revenuecatui.PaywallOptions
52+
import com.revenuecat.purchases.ui.revenuecatui.PaywallWebViewMessageHandler
5253
import com.revenuecat.purchases.ui.revenuecatui.PaywallPurchaseLogicParams
5354
import com.revenuecat.purchases.ui.revenuecatui.PaywallPurchaseLogic
5455
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)