From 28922a24ee3e7c8194edfa9cc13c96985eac73ec Mon Sep 17 00:00:00 2001 From: Alexander Repty Date: Thu, 25 Jun 2026 14:51:30 +0200 Subject: [PATCH 01/13] feat(paywalls): add web_view component schema MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduces the Paywalls V2 `web_view` component (`WebViewComponent`) and registers it in the `PaywallComponent` sealed hierarchy / serializer. Adds the minimal cross-module `when` branches required for the project to compile now that the sealed type has a new member (StyleFactory returns no style yet; image pre-download and unsupported-condition checks treat it as a leaf / traverse its fallback). No rendering, CSP, messaging, or pre-warming yet — those land in later PRs in the stack. Recommended labels: pr:other --- .../paywalls/components/PaywallComponent.kt | 1 + .../paywalls/components/WebViewComponent.kt | 39 ++++++ .../utils/PaywallComponentFilterExtension.kt | 2 + .../PaywallComponentsImagePreDownloader.kt | 2 + .../components/WebViewComponentTests.kt | 127 ++++++++++++++++++ .../components/style/StyleFactory.kt | 2 + .../helpers/OfferingToStateMapper.kt | 2 + .../components/tabs/TabsComponentViewTests.kt | 4 +- upstream/paywall-preview-resources | 2 +- 9 files changed, 179 insertions(+), 2 deletions(-) create mode 100644 purchases/src/main/kotlin/com/revenuecat/purchases/paywalls/components/WebViewComponent.kt create mode 100644 purchases/src/test/java/com/revenuecat/purchases/paywalls/components/WebViewComponentTests.kt diff --git a/purchases/src/main/kotlin/com/revenuecat/purchases/paywalls/components/PaywallComponent.kt b/purchases/src/main/kotlin/com/revenuecat/purchases/paywalls/components/PaywallComponent.kt index 815cd7dabc..2ed5cb1700 100644 --- a/purchases/src/main/kotlin/com/revenuecat/purchases/paywalls/components/PaywallComponent.kt +++ b/purchases/src/main/kotlin/com/revenuecat/purchases/paywalls/components/PaywallComponent.kt @@ -57,6 +57,7 @@ internal class PaywallComponentSerializer : KSerializer { "tabs" -> jsonDecoder.json.decodeFromJsonElement(json) "video" -> jsonDecoder.json.decodeFromJsonElement(json) "countdown" -> jsonDecoder.json.decodeFromJsonElement(json) + "web_view" -> jsonDecoder.json.decodeFromJsonElement(json) "fallback_header" -> FallbackHeaderComponent else -> json["fallback"] ?.let { it as? JsonObject } diff --git a/purchases/src/main/kotlin/com/revenuecat/purchases/paywalls/components/WebViewComponent.kt b/purchases/src/main/kotlin/com/revenuecat/purchases/paywalls/components/WebViewComponent.kt new file mode 100644 index 0000000000..69f4204b2d --- /dev/null +++ b/purchases/src/main/kotlin/com/revenuecat/purchases/paywalls/components/WebViewComponent.kt @@ -0,0 +1,39 @@ +package com.revenuecat.purchases.paywalls.components + +import androidx.compose.runtime.Immutable +import com.revenuecat.purchases.InternalRevenueCatAPI +import com.revenuecat.purchases.paywalls.components.properties.Size +import com.revenuecat.purchases.paywalls.components.properties.SizeConstraint +import com.revenuecat.purchases.paywalls.components.properties.SizeConstraint.Fill +import dev.drewhamilton.poko.Poko +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +/** + * A Paywalls V2 component that renders generic hosted web content (a web bundle entrypoint) inside a + * web view. + * + * Only [protocolVersion] `1` is currently supported. When [protocolVersion] is present the SDK + * isolates the web content from external sources (see the content blocking applied in the UI module): + * the bundle must be fully self-contained. + */ +@InternalRevenueCatAPI +@Poko +@Serializable +@SerialName("web_view") +@Immutable +public class WebViewComponent( + @get:JvmSynthetic + public val url: String, + @get:JvmSynthetic + public val id: String? = null, + @get:JvmSynthetic + public val name: String? = null, + @get:JvmSynthetic + public val visible: Boolean? = null, + @SerialName("protocol_version") + @get:JvmSynthetic + public val protocolVersion: Int? = null, + @get:JvmSynthetic + public val size: Size = Size(width = Fill, height = SizeConstraint.Fit), +) : PaywallComponent diff --git a/purchases/src/main/kotlin/com/revenuecat/purchases/utils/PaywallComponentFilterExtension.kt b/purchases/src/main/kotlin/com/revenuecat/purchases/utils/PaywallComponentFilterExtension.kt index 02fe600c29..33fec17525 100644 --- a/purchases/src/main/kotlin/com/revenuecat/purchases/utils/PaywallComponentFilterExtension.kt +++ b/purchases/src/main/kotlin/com/revenuecat/purchases/utils/PaywallComponentFilterExtension.kt @@ -20,6 +20,7 @@ import com.revenuecat.purchases.paywalls.components.TabsComponent import com.revenuecat.purchases.paywalls.components.TextComponent import com.revenuecat.purchases.paywalls.components.TimelineComponent import com.revenuecat.purchases.paywalls.components.VideoComponent +import com.revenuecat.purchases.paywalls.components.WebViewComponent /** * Returns all PaywallComponent that satisfy the predicate. @@ -74,6 +75,7 @@ internal fun PaywallComponent.filter(predicate: (PaywallComponent) -> Boolean): is ImageComponent, is IconComponent, is TextComponent, + is WebViewComponent, -> { // These don't have child components. } diff --git a/purchases/src/main/kotlin/com/revenuecat/purchases/utils/PaywallComponentsImagePreDownloader.kt b/purchases/src/main/kotlin/com/revenuecat/purchases/utils/PaywallComponentsImagePreDownloader.kt index bf1673c4d6..c111216282 100644 --- a/purchases/src/main/kotlin/com/revenuecat/purchases/utils/PaywallComponentsImagePreDownloader.kt +++ b/purchases/src/main/kotlin/com/revenuecat/purchases/utils/PaywallComponentsImagePreDownloader.kt @@ -26,6 +26,7 @@ import com.revenuecat.purchases.paywalls.components.TabsComponent import com.revenuecat.purchases.paywalls.components.TextComponent import com.revenuecat.purchases.paywalls.components.TimelineComponent import com.revenuecat.purchases.paywalls.components.VideoComponent +import com.revenuecat.purchases.paywalls.components.WebViewComponent import com.revenuecat.purchases.paywalls.components.common.Background import com.revenuecat.purchases.paywalls.components.common.ComponentOverride import com.revenuecat.purchases.paywalls.components.common.PaywallComponentsConfig @@ -115,6 +116,7 @@ internal class PaywallComponentsImagePreDownloader( is TabControlToggleComponent, is TextComponent, is TimelineComponent, + is WebViewComponent, -> emptySet() } } diff --git a/purchases/src/test/java/com/revenuecat/purchases/paywalls/components/WebViewComponentTests.kt b/purchases/src/test/java/com/revenuecat/purchases/paywalls/components/WebViewComponentTests.kt new file mode 100644 index 0000000000..0a06f40fa6 --- /dev/null +++ b/purchases/src/test/java/com/revenuecat/purchases/paywalls/components/WebViewComponentTests.kt @@ -0,0 +1,127 @@ +package com.revenuecat.purchases.paywalls.components + +import com.revenuecat.purchases.JsonTools +import com.revenuecat.purchases.paywalls.components.properties.Size +import com.revenuecat.purchases.paywalls.components.properties.SizeConstraint +import com.revenuecat.purchases.utils.filter +import org.assertj.core.api.Assertions.assertThat +import org.intellij.lang.annotations.Language +import org.junit.Test +import kotlin.test.assertEquals + +class WebViewComponentTests { + + @Language("json") + private val fullJson = """ + { + "id": "promo_web_view", + "type": "web_view", + "protocol_version": 1, + "url": "https://assets.pawwalls.com/web_bundles/123/index.html", + "size": { + "width": { "type": "fill" }, + "height": { "type": "fit" } + }, + "visible": true, + "name": "Promo web component" + } + """ + + @Test + fun `deserializes full Khepri schema`() { + val actual = JsonTools.json.decodeFromString(fullJson) + + assertThat(actual).isInstanceOf(WebViewComponent::class.java) + val webView = actual as WebViewComponent + + assertThat(webView.id).isEqualTo("promo_web_view") + assertThat(webView.name).isEqualTo("Promo web component") + assertThat(webView.visible).isTrue() + assertThat(webView.protocolVersion).isEqualTo(1) + assertThat(webView.url).isEqualTo("https://assets.pawwalls.com/web_bundles/123/index.html") + assertThat(webView.size).isEqualTo(Size(width = SizeConstraint.Fill, height = SizeConstraint.Fit)) + } + + @Test + fun `ignores capabilities declared by the schema`() { + // v1 isolates entirely from external sources via a fixed CSP, so any schema-declared + // capabilities are decoded-and-ignored rather than failing to parse. + @Language("json") + val json = """ + { + "type": "web_view", + "url": "https://paywalls.revenuecat.com/index.html", + "capabilities": { + "network_access": { "allowed_domains": ["api.segment.io"] }, + "camera": true, + "microphone": true, + "clipboard_write": true, + "clipboard_read": true, + "geolocation": true + } + } + """ + + val actual = JsonTools.json.decodeFromString(json) + + assertEquals( + WebViewComponent(url = "https://paywalls.revenuecat.com/index.html"), + actual, + ) + } + + @Test + fun `deserializes template url correctly`() { + @Language("json") + val templateJson = """ + { + "type": "web_view", + "protocol_version": 1, + "url": "https://paywalls.revenuecat.com/{{ custom.animal }}.html" + } + """ + + val actual = JsonTools.json.decodeFromString(templateJson) + + assertEquals( + WebViewComponent( + url = "https://paywalls.revenuecat.com/{{ custom.animal }}.html", + protocolVersion = 1, + ), + actual, + ) + } + + @Test + fun `deserializes minimal shape with defaults for missing optional fields`() { + // Older/partial configs may omit protocol_version and size. These should still + // decode without crashing, using defaults. + @Language("json") + val minimalJson = """ + { + "type": "web_view", + "url": "https://paywalls.revenuecat.com/index.html" + } + """ + + val actual = JsonTools.json.decodeFromString(minimalJson) + + assertEquals( + WebViewComponent(url = "https://paywalls.revenuecat.com/index.html"), + actual, + ) + val webView = actual as WebViewComponent + assertThat(webView.protocolVersion).isNull() + assertThat(webView.size).isEqualTo(Size(width = SizeConstraint.Fill, height = SizeConstraint.Fit)) + } + + @Test + fun `filter treats web_view as a leaf component`() { + // web_view has no child components, so filtering a tree should return only the component itself. + val webView = WebViewComponent(url = "https://paywalls.revenuecat.com/index.html") + + val matches = webView.filter { it is WebViewComponent } + + assertThat(matches).containsExactly(webView) + } +} diff --git a/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/style/StyleFactory.kt b/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/style/StyleFactory.kt index 93efdf7f1c..fbc77e633f 100644 --- a/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/style/StyleFactory.kt +++ b/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/style/StyleFactory.kt @@ -27,6 +27,7 @@ import com.revenuecat.purchases.paywalls.components.TabsComponent import com.revenuecat.purchases.paywalls.components.TextComponent import com.revenuecat.purchases.paywalls.components.TimelineComponent import com.revenuecat.purchases.paywalls.components.VideoComponent +import com.revenuecat.purchases.paywalls.components.WebViewComponent import com.revenuecat.purchases.paywalls.components.common.Background import com.revenuecat.purchases.paywalls.components.common.LocaleId import com.revenuecat.purchases.paywalls.components.common.LocalizationKey @@ -573,6 +574,7 @@ internal class StyleFactory( is TabsComponent -> createTabsComponentStyle(component) is VideoComponent -> createVideoComponentStyle(component) is FallbackHeaderComponent -> Result.Success(null) + is WebViewComponent -> Result.Success(null) is CountdownComponent -> createCountdownComponentStyle( component, ) diff --git a/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/helpers/OfferingToStateMapper.kt b/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/helpers/OfferingToStateMapper.kt index b297671d4f..98f2110219 100644 --- a/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/helpers/OfferingToStateMapper.kt +++ b/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/helpers/OfferingToStateMapper.kt @@ -25,6 +25,7 @@ import com.revenuecat.purchases.paywalls.components.TabsComponent import com.revenuecat.purchases.paywalls.components.TextComponent import com.revenuecat.purchases.paywalls.components.TimelineComponent import com.revenuecat.purchases.paywalls.components.VideoComponent +import com.revenuecat.purchases.paywalls.components.WebViewComponent import com.revenuecat.purchases.paywalls.components.common.ComponentOverride import com.revenuecat.purchases.paywalls.components.common.LocalizationData import com.revenuecat.purchases.paywalls.components.common.LocalizationKey @@ -516,6 +517,7 @@ internal fun PaywallComponent.containsUnsupportedCondition(): Boolean = when (th is TabControlToggleComponent -> false is TabControlComponent -> false is FallbackHeaderComponent -> false + is WebViewComponent -> false } @JvmSynthetic diff --git a/ui/revenuecatui/src/test/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/tabs/TabsComponentViewTests.kt b/ui/revenuecatui/src/test/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/tabs/TabsComponentViewTests.kt index 46bf2554f0..8134b7b89d 100644 --- a/ui/revenuecatui/src/test/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/tabs/TabsComponentViewTests.kt +++ b/ui/revenuecatui/src/test/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/tabs/TabsComponentViewTests.kt @@ -39,6 +39,7 @@ import com.revenuecat.purchases.paywalls.components.TabsComponent import com.revenuecat.purchases.paywalls.components.TextComponent import com.revenuecat.purchases.paywalls.components.TimelineComponent import com.revenuecat.purchases.paywalls.components.VideoComponent +import com.revenuecat.purchases.paywalls.components.WebViewComponent import com.revenuecat.purchases.paywalls.components.common.Background import com.revenuecat.purchases.paywalls.components.common.ComponentOverride import com.revenuecat.purchases.paywalls.components.common.ComponentsConfig @@ -1298,7 +1299,8 @@ class TabsComponentViewTests { is IconComponent, is TextComponent, is VideoComponent, - -> { + is WebViewComponent, + -> { // These don't have child components. } } diff --git a/upstream/paywall-preview-resources b/upstream/paywall-preview-resources index f7be246d8b..382820b234 160000 --- a/upstream/paywall-preview-resources +++ b/upstream/paywall-preview-resources @@ -1 +1 @@ -Subproject commit f7be246d8b508d36e493dd9c69543e8096c63fdc +Subproject commit 382820b234e80430838c7d2abc2155c73a3e5333 From 0a92fea819d4970345ad75c6472f949c08de05a4 Mon Sep 17 00:00:00 2001 From: Alexander Repty Date: Thu, 25 Jun 2026 14:57:42 +0200 Subject: [PATCH 02/13] feat(paywalls): render web_view component Adds the Compose rendering path for the Paywalls V2 `web_view` component: a `WebView`-backed `WebViewComponentView`, the `WebViewComponentStyle`, the `StyleFactory` mapping, `ComponentView` dispatch, and `WebViewUrlResolver` (template resolution + HTTPS/host validation). When the URL is missing or invalid the component renders its fallback stack. No CSP, messaging, or pre-warming yet. Recommended labels: pr:other, pr:RevenueCatUI, feat:Paywalls_V2 --- .../revenuecatui/components/ComponentView.kt | 7 ++ .../components/style/StyleFactory.kt | 13 ++- .../components/style/WebViewComponentStyle.kt | 13 +++ .../webview/WebViewComponentView.kt | 78 +++++++++++++++++ .../components/webview/WebViewUrlResolver.kt | 47 +++++++++++ .../components/style/StyleFactoryTests.kt | 20 +++++ .../webview/WebViewUrlResolverTest.kt | 83 +++++++++++++++++++ 7 files changed, 260 insertions(+), 1 deletion(-) create mode 100644 ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/style/WebViewComponentStyle.kt create mode 100644 ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/webview/WebViewComponentView.kt create mode 100644 ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/webview/WebViewUrlResolver.kt create mode 100644 ui/revenuecatui/src/test/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/webview/WebViewUrlResolverTest.kt diff --git a/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/ComponentView.kt b/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/ComponentView.kt index 8a41190d5b..254629551a 100644 --- a/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/ComponentView.kt +++ b/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/ComponentView.kt @@ -30,12 +30,14 @@ import com.revenuecat.purchases.ui.revenuecatui.components.style.TabsComponentSt import com.revenuecat.purchases.ui.revenuecatui.components.style.TextComponentStyle import com.revenuecat.purchases.ui.revenuecatui.components.style.TimelineComponentStyle import com.revenuecat.purchases.ui.revenuecatui.components.style.VideoComponentStyle +import com.revenuecat.purchases.ui.revenuecatui.components.style.WebViewComponentStyle import com.revenuecat.purchases.ui.revenuecatui.components.tabs.TabControlButtonView import com.revenuecat.purchases.ui.revenuecatui.components.tabs.TabControlToggleView import com.revenuecat.purchases.ui.revenuecatui.components.tabs.TabsComponentView import com.revenuecat.purchases.ui.revenuecatui.components.text.TextComponentView import com.revenuecat.purchases.ui.revenuecatui.components.timeline.TimelineComponentView import com.revenuecat.purchases.ui.revenuecatui.components.video.VideoComponentView +import com.revenuecat.purchases.ui.revenuecatui.components.webview.WebViewComponentView import com.revenuecat.purchases.ui.revenuecatui.data.PaywallState import com.revenuecat.purchases.ui.revenuecatui.helpers.PaywallComponentInteractionTracker @@ -73,6 +75,11 @@ internal fun ComponentView( modifier = modifier, ) } + is WebViewComponentStyle -> WebViewComponentView( + style = style, + state = state, + modifier = modifier, + ) is ButtonComponentStyle -> ButtonComponentView( style = style, state = state, diff --git a/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/style/StyleFactory.kt b/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/style/StyleFactory.kt index fbc77e633f..e26a640eba 100644 --- a/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/style/StyleFactory.kt +++ b/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/style/StyleFactory.kt @@ -574,13 +574,24 @@ internal class StyleFactory( is TabsComponent -> createTabsComponentStyle(component) is VideoComponent -> createVideoComponentStyle(component) is FallbackHeaderComponent -> Result.Success(null) - is WebViewComponent -> Result.Success(null) + is WebViewComponent -> createWebViewComponentStyle(component) is CountdownComponent -> createCountdownComponentStyle( component, ) } } + private fun createWebViewComponentStyle( + component: WebViewComponent, + ): Result> = + Result.Success( + WebViewComponentStyle( + urlTemplate = component.url, + visible = component.visible ?: DEFAULT_VISIBILITY, + size = component.size, + ), + ) + private fun StyleFactoryScope.createCountdownComponentStyle( component: CountdownComponent, ): Result> = diff --git a/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/style/WebViewComponentStyle.kt b/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/style/WebViewComponentStyle.kt new file mode 100644 index 0000000000..cfafb443d7 --- /dev/null +++ b/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/style/WebViewComponentStyle.kt @@ -0,0 +1,13 @@ +@file:JvmSynthetic + +package com.revenuecat.purchases.ui.revenuecatui.components.style + +import androidx.compose.runtime.Immutable +import com.revenuecat.purchases.paywalls.components.properties.Size + +@Immutable +internal data class WebViewComponentStyle( + val urlTemplate: String, + override val visible: Boolean, + override val size: Size, +) : ComponentStyle diff --git a/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/webview/WebViewComponentView.kt b/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/webview/WebViewComponentView.kt new file mode 100644 index 0000000000..01ae1875af --- /dev/null +++ b/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/webview/WebViewComponentView.kt @@ -0,0 +1,78 @@ +@file:JvmSynthetic + +package com.revenuecat.purchases.ui.revenuecatui.components.webview + +import android.graphics.Color +import android.webkit.WebResourceRequest +import android.webkit.WebSettings +import android.webkit.WebView +import android.webkit.WebViewClient +import androidx.compose.runtime.Composable +import androidx.compose.runtime.key +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.viewinterop.AndroidView +import com.revenuecat.purchases.ui.revenuecatui.components.modifier.size +import com.revenuecat.purchases.ui.revenuecatui.components.style.WebViewComponentStyle +import com.revenuecat.purchases.ui.revenuecatui.data.PaywallState + +@JvmSynthetic +@Composable +internal fun WebViewComponentView( + style: WebViewComponentStyle, + state: PaywallState.Loaded.Components, + modifier: Modifier = Modifier, +) { + if (!style.visible) return + + // Key on state.locale (a derivedState over the paywall's mutable locale) as well: a locale change + // mutates the same PaywallState instance in place, so without it the resolved URL — and the + // key(resolvedUrl) below — would stay stale for a locale-dependent template. + val resolvedUrl = remember(style.urlTemplate, state, state.locale) { + WebViewUrlResolver.resolve(style.urlTemplate, state) + } + // The web view URL is missing or did not resolve to a valid HTTPS URL with a host. web_view + // availability is gated by SDK version on the frontend, so a delivered web_view is expected to + // always resolve; render nothing rather than crashing if it doesn't. + if (resolvedUrl == null) return + + // Key on the resolved URL so the WebView is created (and the page loaded) exactly once per intended + // URL. We deliberately do NOT reload on every recomposition: in-page navigation changes WebView.url, + // and reloading whenever it differs from resolvedUrl would reset a multi-step web flow. The WebView + // is only recreated when the SDK-resolved URL itself changes (e.g. a locale-dependent template). + key(resolvedUrl) { + AndroidView( + factory = { context -> + WebView(context).apply { + configure() + loadUrl(resolvedUrl.toString()) + } + }, + onRelease = { webView -> + webView.stopLoading() + webView.webViewClient = WebViewClient() + webView.destroy() + }, + modifier = modifier.size(style.size), + ) + } +} + +private fun WebView.configure() { + setBackgroundColor(Color.TRANSPARENT) + isVerticalScrollBarEnabled = false + isHorizontalScrollBarEnabled = false + settings.allowContentAccess = false + settings.allowFileAccess = false + settings.cacheMode = WebSettings.LOAD_DEFAULT + settings.domStorageEnabled = true + settings.javaScriptEnabled = true + settings.mixedContentMode = WebSettings.MIXED_CONTENT_NEVER_ALLOW + webViewClient = object : WebViewClient() { + override fun shouldOverrideUrlLoading(view: WebView, request: WebResourceRequest): Boolean { + return request.url.scheme != HTTPS_SCHEME + } + } +} + +private const val HTTPS_SCHEME = "https" diff --git a/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/webview/WebViewUrlResolver.kt b/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/webview/WebViewUrlResolver.kt new file mode 100644 index 0000000000..9f51386a67 --- /dev/null +++ b/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/webview/WebViewUrlResolver.kt @@ -0,0 +1,47 @@ +@file:JvmSynthetic + +package com.revenuecat.purchases.ui.revenuecatui.components.webview + +import com.revenuecat.purchases.UiConfig +import com.revenuecat.purchases.ui.revenuecatui.CustomVariableValue +import com.revenuecat.purchases.ui.revenuecatui.components.ktx.toJavaLocale +import com.revenuecat.purchases.ui.revenuecatui.data.PaywallState +import com.revenuecat.purchases.ui.revenuecatui.data.processed.VariableProcessorV2 +import java.net.URL +import java.util.Locale + +internal object WebViewUrlResolver { + + fun resolve( + urlTemplate: String, + state: PaywallState.Loaded.Components, + ): URL? = resolve( + urlTemplate = urlTemplate, + variableConfig = state.variableConfig, + customVariables = state.customVariables, + defaultCustomVariables = state.defaultCustomVariables, + locale = state.locale.toJavaLocale(), + ) + + fun resolve( + urlTemplate: String, + variableConfig: UiConfig.VariableConfig, + customVariables: Map, + defaultCustomVariables: Map, + locale: Locale, + ): URL? { + val resolvedUrl = VariableProcessorV2.processVariables( + template = urlTemplate, + variableConfig = variableConfig, + dateLocale = locale, + customVariables = customVariables, + defaultCustomVariables = defaultCustomVariables, + ) + + return runCatching { URL(resolvedUrl) } + .getOrNull() + ?.takeIf { it.protocol == HTTPS_SCHEME && it.host.isNotBlank() } + } + + private const val HTTPS_SCHEME = "https" +} diff --git a/ui/revenuecatui/src/test/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/style/StyleFactoryTests.kt b/ui/revenuecatui/src/test/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/style/StyleFactoryTests.kt index 9ee0a41b55..f01a756396 100644 --- a/ui/revenuecatui/src/test/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/style/StyleFactoryTests.kt +++ b/ui/revenuecatui/src/test/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/style/StyleFactoryTests.kt @@ -22,6 +22,7 @@ import com.revenuecat.purchases.paywalls.components.TabControlComponent import com.revenuecat.purchases.paywalls.components.TabControlToggleComponent import com.revenuecat.purchases.paywalls.components.TabsComponent import com.revenuecat.purchases.paywalls.components.TextComponent +import com.revenuecat.purchases.paywalls.components.WebViewComponent import com.revenuecat.purchases.paywalls.components.common.Background import com.revenuecat.purchases.paywalls.components.common.ComponentOverride import com.revenuecat.purchases.paywalls.components.common.LocaleId @@ -118,6 +119,25 @@ class StyleFactoryTests { assertThat(colorStyle.color).isEqualTo(expectedColor) } + @Test + fun `Should create a WebViewComponentStyle for a WebViewComponent`() { + val size = Size(width = SizeConstraint.Fill, height = SizeConstraint.Fit) + val component = WebViewComponent( + url = "https://paywalls.revenuecat.com/{{ custom.animal }}.html", + id = "promo_web_view", + visible = false, + size = size, + ) + + val result = styleFactory.create(component) + + assertThat(result).isInstanceOf(Result.Success::class.java) + val style = (result as Result.Success).value.componentStyle as WebViewComponentStyle + assertThat(style.urlTemplate).isEqualTo("https://paywalls.revenuecat.com/{{ custom.animal }}.html") + assertThat(style.visible).isFalse() + assertThat(style.size).isEqualTo(size) + } + @Test fun `Should create a StackComponentStyle with children for a StackComponent with children`() { // Arrange diff --git a/ui/revenuecatui/src/test/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/webview/WebViewUrlResolverTest.kt b/ui/revenuecatui/src/test/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/webview/WebViewUrlResolverTest.kt new file mode 100644 index 0000000000..1bab7ca6d0 --- /dev/null +++ b/ui/revenuecatui/src/test/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/webview/WebViewUrlResolverTest.kt @@ -0,0 +1,83 @@ +package com.revenuecat.purchases.ui.revenuecatui.components.webview + +import com.revenuecat.purchases.UiConfig +import com.revenuecat.purchases.ui.revenuecatui.CustomVariableValue +import org.assertj.core.api.Assertions.assertThat +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import java.util.Locale + +@RunWith(RobolectricTestRunner::class) +class WebViewUrlResolverTest { + + @Test + fun `resolves static https url unchanged`() { + val result = resolve("https://paywalls.revenuecat.com/index.html") + + assertThat(result?.toString()).isEqualTo("https://paywalls.revenuecat.com/index.html") + } + + @Test + fun `resolves static https url with characters accepted by WebView`() { + val result = resolve("https://paywalls.revenuecat.com/index.html?filters=a|b") + + assertThat(result?.toString()).isEqualTo("https://paywalls.revenuecat.com/index.html?filters=a|b") + } + + @Test + fun `substitutes custom variables into the template before validating`() { + val result = resolve( + template = "https://paywalls.revenuecat.com/{{ custom.animal }}.html", + customVariables = mapOf("animal" to CustomVariableValue.String("dog")), + ) + + assertThat(result?.toString()).isEqualTo("https://paywalls.revenuecat.com/dog.html") + } + + @Test + fun `returns null for malformed url`() { + val result = resolve("not a url") + + assertThat(result).isNull() + } + + @Test + fun `returns null for malformed https url`() { + val result = resolve("https:///missing-host") + + assertThat(result).isNull() + } + + @Test + fun `returns null for non https url`() { + val result = resolve("http://paywalls.revenuecat.com/index.html") + + assertThat(result).isNull() + } + + @Test + fun `returns null for file url`() { + val result = resolve("file:///android_asset/index.html") + + assertThat(result).isNull() + } + + @Test + fun `returns null for custom scheme url`() { + val result = resolve("myapp://paywalls.revenuecat.com/index.html") + + assertThat(result).isNull() + } + + private fun resolve( + template: String, + customVariables: Map = emptyMap(), + ) = WebViewUrlResolver.resolve( + urlTemplate = template, + variableConfig = UiConfig.VariableConfig(), + customVariables = customVariables, + defaultCustomVariables = emptyMap(), + locale = Locale.US, + ) +} From c25018a6e3d65013d120c3327d2fdccdf7062592 Mon Sep 17 00:00:00 2001 From: Alexander Repty Date: Thu, 25 Jun 2026 15:01:15 +0200 Subject: [PATCH 03/13] feat(paywalls): isolate web_view content with a fixed CSP When a `web_view` declares a `protocol_version`, the renderer injects a fixed Content-Security-Policy meta tag at document start to isolate the content from external sources: only same-origin images/scripts/fonts are allowed, `data:` references for images/fonts are blocked, and XHR/fetch/WebSocket are disallowed (`connect-src 'none'`). Geolocation is disabled. The bundle must be fully self-contained. Recommended labels: pr:other, pr:RevenueCatUI, feat:Paywalls_V2 --- .../components/style/StyleFactory.kt | 1 + .../components/style/WebViewComponentStyle.kt | 6 ++ .../webview/WebViewComponentView.kt | 19 +++++- .../webview/WebViewContentSecurityPolicy.kt | 57 +++++++++++++++++ .../components/style/StyleFactoryTests.kt | 15 +++++ .../WebViewContentSecurityPolicyTest.kt | 64 +++++++++++++++++++ 6 files changed, 160 insertions(+), 2 deletions(-) create mode 100644 ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/webview/WebViewContentSecurityPolicy.kt create mode 100644 ui/revenuecatui/src/test/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/webview/WebViewContentSecurityPolicyTest.kt diff --git a/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/style/StyleFactory.kt b/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/style/StyleFactory.kt index e26a640eba..c2c4fc8365 100644 --- a/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/style/StyleFactory.kt +++ b/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/style/StyleFactory.kt @@ -589,6 +589,7 @@ internal class StyleFactory( urlTemplate = component.url, visible = component.visible ?: DEFAULT_VISIBILITY, size = component.size, + protocolVersion = component.protocolVersion, ), ) diff --git a/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/style/WebViewComponentStyle.kt b/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/style/WebViewComponentStyle.kt index cfafb443d7..9b4cb357a0 100644 --- a/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/style/WebViewComponentStyle.kt +++ b/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/style/WebViewComponentStyle.kt @@ -10,4 +10,10 @@ internal data class WebViewComponentStyle( val urlTemplate: String, override val visible: Boolean, override val size: Size, + /** + * The schema-declared `protocol_version`. When present, the SDK isolates the web content from + * external sources by enforcing a fixed Content-Security-Policy. `null` for legacy/partial configs + * that omit it, in which case no policy is enforced. + */ + val protocolVersion: Int? = null, ) : ComponentStyle diff --git a/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/webview/WebViewComponentView.kt b/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/webview/WebViewComponentView.kt index 01ae1875af..0d5780a386 100644 --- a/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/webview/WebViewComponentView.kt +++ b/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/webview/WebViewComponentView.kt @@ -2,6 +2,7 @@ package com.revenuecat.purchases.ui.revenuecatui.components.webview +import android.graphics.Bitmap import android.graphics.Color import android.webkit.WebResourceRequest import android.webkit.WebSettings @@ -36,6 +37,10 @@ internal fun WebViewComponentView( // always resolve; render nothing rather than crashing if it doesn't. if (resolvedUrl == null) return + // For v1, the presence of a protocol_version means the web content is isolated from external + // sources via a fixed Content-Security-Policy. Legacy configs without it get no policy. + val enforceContentSecurityPolicy = style.protocolVersion != null + // Key on the resolved URL so the WebView is created (and the page loaded) exactly once per intended // URL. We deliberately do NOT reload on every recomposition: in-page navigation changes WebView.url, // and reloading whenever it differs from resolvedUrl would reset a multi-step web flow. The WebView @@ -44,7 +49,7 @@ internal fun WebViewComponentView( AndroidView( factory = { context -> WebView(context).apply { - configure() + configure(enforceContentSecurityPolicy) loadUrl(resolvedUrl.toString()) } }, @@ -58,7 +63,7 @@ internal fun WebViewComponentView( } } -private fun WebView.configure() { +private fun WebView.configure(enforceContentSecurityPolicy: Boolean) { setBackgroundColor(Color.TRANSPARENT) isVerticalScrollBarEnabled = false isHorizontalScrollBarEnabled = false @@ -68,7 +73,17 @@ private fun WebView.configure() { settings.domStorageEnabled = true settings.javaScriptEnabled = true settings.mixedContentMode = WebSettings.MIXED_CONTENT_NEVER_ALLOW + settings.setGeolocationEnabled(false) webViewClient = object : WebViewClient() { + override fun onPageStarted(view: WebView, url: String?, favicon: Bitmap?) { + super.onPageStarted(view, url, favicon) + // Install the isolation Content-Security-Policy before any of the page's own resources or + // scripts run. + if (enforceContentSecurityPolicy) { + view.evaluateJavascript(contentSecurityPolicyMetaScript(), null) + } + } + override fun shouldOverrideUrlLoading(view: WebView, request: WebResourceRequest): Boolean { return request.url.scheme != HTTPS_SCHEME } diff --git a/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/webview/WebViewContentSecurityPolicy.kt b/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/webview/WebViewContentSecurityPolicy.kt new file mode 100644 index 0000000000..775170e224 --- /dev/null +++ b/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/webview/WebViewContentSecurityPolicy.kt @@ -0,0 +1,57 @@ +@file:JvmSynthetic + +package com.revenuecat.purchases.ui.revenuecatui.components.webview + +/** + * Fixed Content-Security-Policy used to isolate Paywalls V2 `web_view` content from external sources + * (v1 behavior, applied whenever the component declares a `protocol_version`). The bundle must be + * fully self-contained: it may use its own packaged and inlined resources, but cannot reach any + * external origin. + * + * - `img-src 'self' data:` / `font-src 'self' data:`: same-origin and inlined (`data:`) images/fonts + * are allowed (self-contained bundles routinely inline assets); remote images/fonts are blocked. + * - `script-src 'self'`: remote scripts are blocked, same-origin scripts are allowed. `'unsafe-inline'` + * / `'unsafe-eval'` keep inline/eval'd first-party scripts working — the goal here is network + * isolation, not locking down the (trusted) bundle's own inline code. The native bridge runs via + * `evaluateJavascript`, which is privileged and unaffected by CSP. + * - `connect-src 'self'`: XHR/fetch/WebSocket are allowed to the bundle's own origin only (so it can + * load its packaged JSON — e.g. Lottie animation data); cross-origin requests are blocked. + * - `default-src 'self'`: anchors every other resource type to same-origin (no remote). + * + * These functions are pure so the policy and its injection wrapper can be unit tested without an + * Android `WebView`. + */ +internal const val WEB_VIEW_CONTENT_SECURITY_POLICY: String = + "default-src 'self'; " + + "script-src 'self' 'unsafe-inline' 'unsafe-eval'; " + + "style-src 'self' 'unsafe-inline'; " + + "img-src 'self' data:; " + + "font-src 'self' data:; " + + "connect-src 'self'" + +/** + * Builds the document-start script that installs [policy] as a `` Content-Security- + * Policy. Injected from `WebViewClient.onPageStarted` (before the page's own scripts run) and inserted + * as the first child of `` so it precedes any resource-loading markup. The install is idempotent + * via a window flag, so re-injection on redirects is a no-op. + */ +internal fun contentSecurityPolicyMetaScript(policy: String = WEB_VIEW_CONTENT_SECURITY_POLICY): String = + """ + (function() { + if (window.__revenueCatCspInstalled) { return; } + window.__revenueCatCspInstalled = true; + var meta = document.createElement('meta'); + meta.setAttribute('http-equiv', 'Content-Security-Policy'); + meta.setAttribute('content', "${policy.escapeForMetaContent()}"); + var head = document.head || document.getElementsByTagName('head')[0] || document.documentElement; + if (head.firstChild) { head.insertBefore(meta, head.firstChild); } else { head.appendChild(meta); } + })(); + """.trimIndent() + +/** + * The policy is embedded inside a double-quoted JS string literal. Escape backslashes and double + * quotes so a policy value can never break out of the literal. The canonical policy contains neither, + * but this keeps the wrapper safe for any caller-supplied [policy]. + */ +private fun String.escapeForMetaContent(): String = + replace("\\", "\\\\").replace("\"", "\\\"") diff --git a/ui/revenuecatui/src/test/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/style/StyleFactoryTests.kt b/ui/revenuecatui/src/test/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/style/StyleFactoryTests.kt index f01a756396..412d4d4030 100644 --- a/ui/revenuecatui/src/test/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/style/StyleFactoryTests.kt +++ b/ui/revenuecatui/src/test/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/style/StyleFactoryTests.kt @@ -136,6 +136,21 @@ class StyleFactoryTests { assertThat(style.urlTemplate).isEqualTo("https://paywalls.revenuecat.com/{{ custom.animal }}.html") assertThat(style.visible).isFalse() assertThat(style.size).isEqualTo(size) + assertThat(style.protocolVersion).isNull() + } + + @Test + fun `Should pass protocolVersion through to the WebViewComponentStyle`() { + val component = WebViewComponent( + url = "https://paywalls.revenuecat.com/index.html", + protocolVersion = 1, + ) + + val result = styleFactory.create(component) + + assertThat(result).isInstanceOf(Result.Success::class.java) + val style = (result as Result.Success).value.componentStyle as WebViewComponentStyle + assertThat(style.protocolVersion).isEqualTo(1) } @Test diff --git a/ui/revenuecatui/src/test/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/webview/WebViewContentSecurityPolicyTest.kt b/ui/revenuecatui/src/test/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/webview/WebViewContentSecurityPolicyTest.kt new file mode 100644 index 0000000000..2871a2c4a7 --- /dev/null +++ b/ui/revenuecatui/src/test/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/webview/WebViewContentSecurityPolicyTest.kt @@ -0,0 +1,64 @@ +package com.revenuecat.purchases.ui.revenuecatui.components.webview + +import org.assertj.core.api.Assertions.assertThat +import org.junit.Test + +class WebViewContentSecurityPolicyTest { + + @Test + fun `policy allows same-origin and inlined images and fonts`() { + // Self-contained bundles routinely inline assets as data: URIs; remote hosts stay blocked. + assertThat(WEB_VIEW_CONTENT_SECURITY_POLICY).contains("img-src 'self' data:") + assertThat(WEB_VIEW_CONTENT_SECURITY_POLICY).contains("font-src 'self' data:") + } + + @Test + fun `policy allows only same-origin scripts`() { + assertThat(WEB_VIEW_CONTENT_SECURITY_POLICY).contains("script-src 'self'") + } + + @Test + fun `policy restricts XHR and other connections to the same origin`() { + // Same-origin fetch/XHR is allowed so the bundle can load its own packaged JSON (e.g. Lottie), + // but cross-origin connections are not. + assertThat(WEB_VIEW_CONTENT_SECURITY_POLICY).contains("connect-src 'self'") + } + + @Test + fun `policy anchors everything else to same-origin via default-src`() { + assertThat(WEB_VIEW_CONTENT_SECURITY_POLICY).contains("default-src 'self'") + } + + @Test + fun `policy whitelists no external origins`() { + // Isolation invariant: only 'self'/data:/inline keywords — never a wildcard or a remote host. + assertThat(WEB_VIEW_CONTENT_SECURITY_POLICY).doesNotContain("*") + assertThat(WEB_VIEW_CONTENT_SECURITY_POLICY).doesNotContain("http") + } + + @Test + fun `meta script installs the policy as an http-equiv meta element`() { + val script = contentSecurityPolicyMetaScript("default-src 'self'") + + assertThat(script).contains("http-equiv") + assertThat(script).contains("Content-Security-Policy") + assertThat(script).contains("default-src 'self'") + // Inserted before any other markup so it precedes resource-loading elements. + assertThat(script).contains("insertBefore") + } + + @Test + fun `meta script is idempotent via a window flag`() { + val script = contentSecurityPolicyMetaScript() + + assertThat(script).contains("__revenueCatCspInstalled") + } + + @Test + fun `meta script escapes double quotes and backslashes in the policy`() { + val script = contentSecurityPolicyMetaScript("default-src \"x\\y\"") + + // The double quotes and backslash are escaped so they cannot break out of the JS string literal. + assertThat(script).contains("""default-src \"x\\y\"""") + } +} From 9cad6e351bfa509576ea10aeaf317803ea90655b Mon Sep 17 00:00:00 2001 From: Alexander Repty Date: Fri, 26 Jun 2026 12:41:13 +0200 Subject: [PATCH 04/13] feat(paywalls): add web_view bidirectional messaging Adds a bidirectional JS<->native bridge for the Paywalls V2 `web_view` component: a document-start `window.RevenueCatWebView` shim, message parsing/validation (`WebViewMessageParser`, `WebViewMessageType`), the public `PaywallWebViewMessage` / `PaywallWebViewValue` / `PaywallWebViewMessageHandler` / `PaywallWebViewController` surface, and an SDK-managed variables provider (locale, color scheme, custom variables). Apps opt in via `PaywallOptions.setWebViewMessageHandler(...)`, threaded through `PaywallState` / `OfferingToStateMapper`. Regenerates ui/revenuecatui/api.txt for the new public API. Recommended labels: pr:feat, pr:RevenueCatUI, feat:Paywalls_V2 --- ui/revenuecatui/api.txt | 40 ++++++ .../ui/revenuecatui/PaywallWebViewValue.kt | 135 ++++++++++++++++++ .../revenuecatui/PaywallWebViewValueTest.kt | 85 +++++++++++ 3 files changed, 260 insertions(+) create mode 100644 ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/PaywallWebViewValue.kt create mode 100644 ui/revenuecatui/src/test/kotlin/com/revenuecat/purchases/ui/revenuecatui/PaywallWebViewValueTest.kt diff --git a/ui/revenuecatui/api.txt b/ui/revenuecatui/api.txt index 692de54744..a78cd9ec64 100644 --- a/ui/revenuecatui/api.txt +++ b/ui/revenuecatui/api.txt @@ -150,6 +150,46 @@ package com.revenuecat.purchases.ui.revenuecatui { method public abstract void performRestoreWithCompletion(com.revenuecat.purchases.CustomerInfo customerInfo, kotlin.jvm.functions.Function1 completion); } + public abstract class PaywallWebViewValue { + } + + public static final class PaywallWebViewValue.Array extends com.revenuecat.purchases.ui.revenuecatui.PaywallWebViewValue { + ctor public PaywallWebViewValue.Array(java.util.List value); + method public java.util.List getValue(); + property public final java.util.List value; + } + + public static final class PaywallWebViewValue.Boolean extends com.revenuecat.purchases.ui.revenuecatui.PaywallWebViewValue { + ctor public PaywallWebViewValue.Boolean(boolean value); + method public boolean getValue(); + property public final boolean value; + } + + public static final class PaywallWebViewValue.Null extends com.revenuecat.purchases.ui.revenuecatui.PaywallWebViewValue { + field public static final com.revenuecat.purchases.ui.revenuecatui.PaywallWebViewValue.Null INSTANCE; + } + + public static final class PaywallWebViewValue.Number extends com.revenuecat.purchases.ui.revenuecatui.PaywallWebViewValue { + ctor public PaywallWebViewValue.Number(double value); + ctor public PaywallWebViewValue.Number(float value); + ctor public PaywallWebViewValue.Number(int value); + ctor public PaywallWebViewValue.Number(long value); + method public double getValue(); + property public final double value; + } + + public static final class PaywallWebViewValue.Object extends com.revenuecat.purchases.ui.revenuecatui.PaywallWebViewValue { + ctor public PaywallWebViewValue.Object(java.util.Map value); + method public java.util.Map getValue(); + property public final java.util.Map value; + } + + public static final class PaywallWebViewValue.String extends com.revenuecat.purchases.ui.revenuecatui.PaywallWebViewValue { + ctor public PaywallWebViewValue.String(String value); + method public String getValue(); + property public final String value; + } + @Deprecated public interface PurchaseLogic extends com.revenuecat.purchases.ui.revenuecatui.PaywallPurchaseLogic { method @Deprecated public suspend Object? performPurchase(android.app.Activity activity, com.revenuecat.purchases.Package rcPackage, kotlin.coroutines.Continuation); method @Deprecated public default suspend Object? performPurchase(android.app.Activity activity, com.revenuecat.purchases.ui.revenuecatui.PaywallPurchaseLogicParams params, kotlin.coroutines.Continuation); diff --git a/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/PaywallWebViewValue.kt b/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/PaywallWebViewValue.kt new file mode 100644 index 0000000000..c5b31527d8 --- /dev/null +++ b/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/PaywallWebViewValue.kt @@ -0,0 +1,135 @@ +package com.revenuecat.purchases.ui.revenuecatui + +import org.json.JSONArray +import org.json.JSONObject + +/** + * A JSON-compatible value used in messages exchanged with a Paywalls V2 `web_view` component. + * + * Only JSON-like values are supported: [String], [Number], [Boolean], [Object], [Array] and [Null]. + * Functions, binary blobs, dates, and platform-specific objects are intentionally not representable. + * + * @see PaywallWebViewMessage + * @see PaywallWebViewController + */ +// Modeled as an abstract class with a closed set of subtypes (mirroring [CustomVariableValue]) rather +// than a sealed class, to stay binary-compatible as public API. +public abstract class PaywallWebViewValue internal constructor() { + + /** + * A string value. + */ + public class String(public val value: kotlin.String) : PaywallWebViewValue() { + override fun equals(other: Any?): kotlin.Boolean = other is String && value == other.value + override fun hashCode(): Int = value.hashCode() + override fun toString(): kotlin.String = "PaywallWebViewValue.String(value=$value)" + } + + /** + * A numeric value (integer or decimal). Stored as a [Double]. + */ + public class Number(public val value: kotlin.Double) : PaywallWebViewValue() { + public constructor(value: kotlin.Int) : this(value.toDouble()) + public constructor(value: kotlin.Long) : this(value.toDouble()) + public constructor(value: kotlin.Float) : this(value.toDouble()) + + override fun equals(other: Any?): kotlin.Boolean = other is Number && value == other.value + override fun hashCode(): Int = value.hashCode() + override fun toString(): kotlin.String = "PaywallWebViewValue.Number(value=$value)" + } + + /** + * A boolean value. + */ + public class Boolean(public val value: kotlin.Boolean) : PaywallWebViewValue() { + override fun equals(other: Any?): kotlin.Boolean = other is Boolean && value == other.value + override fun hashCode(): Int = value.hashCode() + override fun toString(): kotlin.String = "PaywallWebViewValue.Boolean(value=$value)" + } + + /** + * A JSON object: a map of string keys to [PaywallWebViewValue]s. + */ + public class Object(public val value: Map) : PaywallWebViewValue() { + override fun equals(other: Any?): kotlin.Boolean = other is Object && value == other.value + override fun hashCode(): Int = value.hashCode() + override fun toString(): kotlin.String = "PaywallWebViewValue.Object(value=$value)" + } + + /** + * A JSON array: an ordered list of [PaywallWebViewValue]s. + */ + public class Array(public val value: List) : PaywallWebViewValue() { + override fun equals(other: Any?): kotlin.Boolean = other is Array && value == other.value + override fun hashCode(): Int = value.hashCode() + override fun toString(): kotlin.String = "PaywallWebViewValue.Array(value=$value)" + } + + /** + * A JSON `null` value. + */ + public object Null : PaywallWebViewValue() { + override fun toString(): kotlin.String = "PaywallWebViewValue.Null" + } + + internal companion object { + /** + * Converts an `org.json` value (or Kotlin primitive) into a [PaywallWebViewValue], rejecting + * anything that is not JSON-compatible or that exceeds [remainingDepth] levels of nesting. + * + * @return the converted value, or `null` if the value is not JSON-compatible or too deeply nested. + */ + @Suppress("ReturnCount", "CyclomaticComplexMethod") + fun fromJson(value: Any?, remainingDepth: Int): PaywallWebViewValue? { + return when (value) { + null, JSONObject.NULL -> Null + is kotlin.String -> String(value) + is kotlin.Boolean -> Boolean(value) + is Int -> Number(value) + is Long -> Number(value) + is Double -> Number(value) + is Float -> Number(value) + is JSONObject -> { + if (remainingDepth <= 0) return null + val map = LinkedHashMap(value.length()) + for (key in value.keys()) { + map[key] = fromJson(value.get(key), remainingDepth - 1) ?: return null + } + Object(map) + } + is JSONArray -> { + if (remainingDepth <= 0) return null + val list = ArrayList(value.length()) + for (index in 0 until value.length()) { + list.add(fromJson(value.get(index), remainingDepth - 1) ?: return null) + } + Array(list) + } + // Numbers stored by org.json as BigDecimal/BigInteger, or any other type, are not supported. + else -> (value as? kotlin.Number)?.let { Number(it.toDouble()) } + } + } + } +} + +/** + * Converts this value into a representation accepted by [JSONObject]/[JSONArray] when serializing a + * message to send into the web view. Whole numbers are emitted as integers (e.g. `100`, not `100.0`). + */ +internal fun PaywallWebViewValue.toJsonRepresentation(): Any = when (this) { + is PaywallWebViewValue.String -> value + is PaywallWebViewValue.Boolean -> value + is PaywallWebViewValue.Number -> + if (value % 1.0 == 0.0 && !value.isInfinite()) value.toLong() else value + is PaywallWebViewValue.Object -> JSONObject().apply { + value.forEach { (key, child) -> put(key, child.toJsonRepresentation()) } + } + is PaywallWebViewValue.Array -> JSONArray().apply { + value.forEach { child -> put(child.toJsonRepresentation()) } + } + else -> JSONObject.NULL +} + +internal fun Map.toJsonObject(): JSONObject = JSONObject().apply { + forEach { (key, value) -> put(key, value.toJsonRepresentation()) } +} diff --git a/ui/revenuecatui/src/test/kotlin/com/revenuecat/purchases/ui/revenuecatui/PaywallWebViewValueTest.kt b/ui/revenuecatui/src/test/kotlin/com/revenuecat/purchases/ui/revenuecatui/PaywallWebViewValueTest.kt new file mode 100644 index 0000000000..558176ae68 --- /dev/null +++ b/ui/revenuecatui/src/test/kotlin/com/revenuecat/purchases/ui/revenuecatui/PaywallWebViewValueTest.kt @@ -0,0 +1,85 @@ +package com.revenuecat.purchases.ui.revenuecatui + +import org.assertj.core.api.Assertions.assertThat +import org.json.JSONArray +import org.json.JSONObject +import org.junit.Test + +internal class PaywallWebViewValueTest { + + @Test + fun `fromJson converts primitives`() { + assertThat(PaywallWebViewValue.fromJson("hello", 8)).isEqualTo(PaywallWebViewValue.String("hello")) + assertThat(PaywallWebViewValue.fromJson(true, 8)).isEqualTo(PaywallWebViewValue.Boolean(true)) + assertThat(PaywallWebViewValue.fromJson(42, 8)).isEqualTo(PaywallWebViewValue.Number(42)) + assertThat(PaywallWebViewValue.fromJson(3.5, 8)).isEqualTo(PaywallWebViewValue.Number(3.5)) + assertThat(PaywallWebViewValue.fromJson(null, 8)).isEqualTo(PaywallWebViewValue.Null) + assertThat(PaywallWebViewValue.fromJson(JSONObject.NULL, 8)).isEqualTo(PaywallWebViewValue.Null) + } + + @Test + fun `fromJson converts nested objects and arrays`() { + val json = JSONObject("""{"a":1,"b":["x",false],"c":{"d":null}}""") + + val value = PaywallWebViewValue.fromJson(json, 8) + + assertThat(value).isInstanceOf(PaywallWebViewValue.Object::class.java) + val obj = (value as PaywallWebViewValue.Object).value + assertThat(obj["a"]).isEqualTo(PaywallWebViewValue.Number(1)) + assertThat(obj["b"]).isEqualTo( + PaywallWebViewValue.Array(listOf(PaywallWebViewValue.String("x"), PaywallWebViewValue.Boolean(false))), + ) + assertThat(obj["c"]).isEqualTo( + PaywallWebViewValue.Object(mapOf("d" to PaywallWebViewValue.Null)), + ) + } + + @Test + fun `fromJson returns null when too deeply nested`() { + val json = JSONObject("""{"a":{"b":{"c":1}}}""") + + // Allow only 2 levels: top object (1) -> a (2) -> b would exceed. + assertThat(PaywallWebViewValue.fromJson(json, 2)).isNull() + } + + @Test + fun `toJsonRepresentation emits whole numbers as integers`() { + assertThat(PaywallWebViewValue.Number(100.0).toJsonRepresentation()).isEqualTo(100L) + assertThat(PaywallWebViewValue.Number(99.99).toJsonRepresentation()).isEqualTo(99.99) + } + + @Test + fun `toJsonRepresentation keeps non-finite numbers as doubles`() { + // Infinity/NaN are not whole numbers, so they must not be collapsed to Long. + assertThat(PaywallWebViewValue.Number(Double.POSITIVE_INFINITY).toJsonRepresentation()) + .isEqualTo(Double.POSITIVE_INFINITY) + assertThat(PaywallWebViewValue.Number(Double.NaN).toJsonRepresentation()) + .isEqualTo(Double.NaN) + } + + @Test + fun `toJsonObject round-trips a map`() { + val map = mapOf( + "locale" to PaywallWebViewValue.String("en-US"), + "custom" to PaywallWebViewValue.Object( + mapOf( + "plan" to PaywallWebViewValue.String("annual"), + "count" to PaywallWebViewValue.Number(3), + "flag" to PaywallWebViewValue.Boolean(true), + "list" to PaywallWebViewValue.Array(listOf(PaywallWebViewValue.Number(1))), + "nothing" to PaywallWebViewValue.Null, + ), + ), + ) + + val json = map.toJsonObject() + + assertThat(json.getString("locale")).isEqualTo("en-US") + val custom = json.getJSONObject("custom") + assertThat(custom.getString("plan")).isEqualTo("annual") + assertThat(custom.getInt("count")).isEqualTo(3) + assertThat(custom.getBoolean("flag")).isTrue() + assertThat(custom.get("list")).isInstanceOf(JSONArray::class.java) + assertThat(custom.isNull("nothing")).isTrue() + } +} From f8d54ec9c5b59d850265720b824b162fed0aae62 Mon Sep 17 00:00:00 2001 From: Alexander Repty Date: Fri, 26 Jun 2026 12:41:13 +0200 Subject: [PATCH 05/13] feat(paywalls): add web_view bidirectional messaging Adds a bidirectional JS<->native bridge for the Paywalls V2 `web_view` component: a document-start `window.RevenueCatWebView` shim, message parsing/validation (`WebViewMessageParser`, `WebViewMessageType`), the public `PaywallWebViewMessage` / `PaywallWebViewValue` / `PaywallWebViewMessageHandler` / `PaywallWebViewController` surface, and an SDK-managed variables provider (locale, color scheme, custom variables). Apps opt in via `PaywallOptions.setWebViewMessageHandler(...)`, threaded through `PaywallState` / `OfferingToStateMapper`. Regenerates ui/revenuecatui/api.txt for the new public API. Recommended labels: pr:feat, pr:RevenueCatUI, feat:Paywalls_V2 --- ui/revenuecatui/api.txt | 21 +++ .../ui/revenuecatui/PaywallWebViewMessage.kt | 94 +++++++++++ .../webview/WebViewMessageParser.kt | 112 +++++++++++++ .../components/webview/WebViewMessageType.kt | 28 ++++ .../webview/WebViewMessageParserTest.kt | 154 ++++++++++++++++++ 5 files changed, 409 insertions(+) create mode 100644 ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/PaywallWebViewMessage.kt create mode 100644 ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/webview/WebViewMessageParser.kt create mode 100644 ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/webview/WebViewMessageType.kt create mode 100644 ui/revenuecatui/src/test/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/webview/WebViewMessageParserTest.kt diff --git a/ui/revenuecatui/api.txt b/ui/revenuecatui/api.txt index a78cd9ec64..68b18aa318 100644 --- a/ui/revenuecatui/api.txt +++ b/ui/revenuecatui/api.txt @@ -150,6 +150,27 @@ package com.revenuecat.purchases.ui.revenuecatui { method public abstract void performRestoreWithCompletion(com.revenuecat.purchases.CustomerInfo customerInfo, kotlin.jvm.functions.Function1 completion); } + public interface PaywallWebViewController { + method public void postMessage(String componentId, String type, java.util.Map variables); + method public void postVariables(String componentId, java.util.Map variables); + } + + @dev.drewhamilton.poko.Poko public final class PaywallWebViewMessage { + ctor public PaywallWebViewMessage(String componentId, String type, optional java.util.Map? responses, optional String? error); + method public String getComponentId(); + method public String? getError(); + method public java.util.Map? getResponses(); + method public String getType(); + property public final String componentId; + property public final String? error; + property public final java.util.Map? responses; + property public final String type; + } + + public fun interface PaywallWebViewMessageHandler { + method public void onMessage(com.revenuecat.purchases.ui.revenuecatui.PaywallWebViewMessage message, com.revenuecat.purchases.ui.revenuecatui.PaywallWebViewController controller); + } + public abstract class PaywallWebViewValue { } diff --git a/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/PaywallWebViewMessage.kt b/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/PaywallWebViewMessage.kt new file mode 100644 index 0000000000..0bedf88c6d --- /dev/null +++ b/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/PaywallWebViewMessage.kt @@ -0,0 +1,94 @@ +package com.revenuecat.purchases.ui.revenuecatui + +import dev.drewhamilton.poko.Poko + +/** + * A validated message received from a Paywalls V2 `web_view` component. + * + * Messages follow the RevenueCat web view postMessage envelope. Known [type]s include: + * - `rc:step-loaded` + * - `rc:step-complete` (carries [responses]) + * - `rc:request-variables` + * - `rc:error` (carries [error]) + * + * @property componentId The canonical component id (the `web_view.id` from the paywall schema). This + * matches the id API consumers see in the paywall configuration. + * @property type The message type, e.g. `rc:step-complete`. + * @property responses The structured responses for a `rc:step-complete` message, if any. + * @property error The error description for a `rc:error` message, if any. + */ +@Poko +public class PaywallWebViewMessage( + public val componentId: String, + public val type: String, + public val responses: Map? = null, + public val error: String? = null, +) + +/** + * Lets app code send messages back into a Paywalls V2 `web_view` component, for example in response to + * a `rc:request-variables` message. + * + * Outgoing messages preserve the RevenueCat web view postMessage envelope and are validated before + * being delivered to the web view. + */ +public interface PaywallWebViewController { + + /** + * Sends a `rc:variables` message into the web view targeting [componentId]. The SDK already replies + * with SDK-managed variables (such as `locale`); use this to provide additional values, typically + * nested under the `custom` key. + * + * Reserved SDK-managed top-level keys cannot be overwritten; provide app-specific values under + * `custom`. + */ + public fun postVariables( + componentId: String, + variables: Map, + ) + + /** + * Sends a message of the given [type] into the web view targeting [componentId], following the web + * view protocol envelope `{ "type", "component_id", "variables" }`. [variables] is delivered under + * the `variables` key. Use [postVariables] for the common `rc:variables` case. + */ + public fun postMessage( + componentId: String, + type: String, + variables: Map, + ) +} + +/** + * Receives validated messages from Paywalls V2 `web_view` components. + * + * Set this on [PaywallOptions.Builder.setWebViewMessageHandler]. The handler is always invoked on the + * main thread. The app decides what to do with each message: the SDK does not automatically dismiss the + * paywall or trigger a purchase in response to `rc:step-complete`. + * + * ### Usage + * ```kotlin + * PaywallOptions.Builder { /* dismiss */ } + * .setWebViewMessageHandler { message, controller -> + * when (message.type) { + * "rc:request-variables" -> controller.postVariables( + * componentId = message.componentId, + * variables = mapOf( + * "custom" to PaywallWebViewValue.Object( + * mapOf("app_segment" to PaywallWebViewValue.String("high_intent")), + * ), + * ), + * ) + * "rc:step-complete" -> { /* read message.responses, navigate, log analytics, etc. */ } + * "rc:error" -> { /* log message.error */ } + * } + * } + * .build() + * ``` + */ +public fun interface PaywallWebViewMessageHandler { + public fun onMessage( + message: PaywallWebViewMessage, + controller: PaywallWebViewController, + ) +} diff --git a/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/webview/WebViewMessageParser.kt b/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/webview/WebViewMessageParser.kt new file mode 100644 index 0000000000..e7d69e5a7d --- /dev/null +++ b/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/webview/WebViewMessageParser.kt @@ -0,0 +1,112 @@ +@file:JvmSynthetic + +package com.revenuecat.purchases.ui.revenuecatui.components.webview + +import com.revenuecat.purchases.ui.revenuecatui.PaywallWebViewMessage +import com.revenuecat.purchases.ui.revenuecatui.PaywallWebViewValue +import com.revenuecat.purchases.ui.revenuecatui.helpers.Logger +import org.json.JSONObject + +/** + * Parses and validates raw JSON strings received from a `web_view` component into typed + * [PaywallWebViewMessage]s before they reach app code. + * + * Validation rules (see the RevenueCat `web_view` postMessage protocol): + * - The payload must not exceed [MAX_PAYLOAD_BYTES] and must not nest deeper than [MAX_NESTING_DEPTH]. + * - The body must be a JSON object with a non-empty string `type` and a string `component_id`. + * - `component_id` must equal the expected component id; otherwise the message is rejected. + * - `rc:step-complete` may carry a `responses` JSON object of JSON-compatible values. + * - `rc:error` must carry a string `error`. + * - `rc:step-loaded` and `rc:request-variables` require no extra fields. + * - Unknown message types are dropped for v1. + * + * Returns `null` for any payload that is too large, malformed, fails validation, targets a different + * component, or has an unknown type. + */ +internal object WebViewMessageParser { + + const val MAX_PAYLOAD_BYTES: Int = 65_536 + const val MAX_NESTING_DEPTH: Int = 16 + + @Suppress("ReturnCount") + fun parse(rawJson: String, expectedComponentId: String): PaywallWebViewMessage? { + if (rawJson.toByteArray(Charsets.UTF_8).size > MAX_PAYLOAD_BYTES) { + Logger.w("Dropping web view message: payload exceeds $MAX_PAYLOAD_BYTES bytes.") + return null + } + + val json = try { + JSONObject(rawJson) + } catch (@Suppress("SwallowedException") e: org.json.JSONException) { + Logger.w("Dropping web view message: body is not a JSON object.") + return null + } + + val type = (json.opt(WebViewMessageField.TYPE) as? String)?.takeIf { it.isNotEmpty() } + if (type == null) { + Logger.w("Dropping web view message: missing or non-string 'type'.") + return null + } + + val componentId = json.opt(WebViewMessageField.COMPONENT_ID) as? String + if (componentId == null) { + Logger.w("Dropping web view message: missing or non-string 'component_id'.") + return null + } + if (componentId != expectedComponentId) { + Logger.w("Dropping web view message: 'component_id' does not match the rendered web_view.") + return null + } + + return when (type) { + WebViewMessageType.STEP_LOADED, + WebViewMessageType.REQUEST_VARIABLES, + -> PaywallWebViewMessage(componentId = componentId, type = type) + + WebViewMessageType.STEP_COMPLETE -> { + val responses = parseResponses(json) ?: return null + PaywallWebViewMessage(componentId = componentId, type = type, responses = responses.value) + } + + WebViewMessageType.ERROR -> { + val error = json.opt(WebViewMessageField.ERROR) as? String + if (error == null) { + Logger.w("Dropping web view message: 'rc:error' requires a string 'error'.") + return null + } + PaywallWebViewMessage(componentId = componentId, type = type, error = error) + } + + else -> { + // Unknown message types are dropped for protocol_version 1. + Logger.d("Dropping web view message: unknown type '$type'.") + null + } + } + } + + /** + * Parses the optional `responses` object of a `rc:step-complete` message. Returns an empty object + * when absent, or `null` when present but malformed (not a JSON object, non-JSON values, or too + * deeply nested), which causes the whole message to be rejected. + */ + private class ParsedResponses(val value: Map) + + @Suppress("ReturnCount") + private fun parseResponses(json: JSONObject): ParsedResponses? { + if (!json.has(WebViewMessageField.RESPONSES) || json.isNull(WebViewMessageField.RESPONSES)) { + return ParsedResponses(emptyMap()) + } + val responsesJson = json.opt(WebViewMessageField.RESPONSES) as? JSONObject + if (responsesJson == null) { + Logger.w("Dropping web view message: 'responses' must be a JSON object.") + return null + } + val converted = PaywallWebViewValue.fromJson(responsesJson, MAX_NESTING_DEPTH) + if (converted !is PaywallWebViewValue.Object) { + Logger.w("Dropping web view message: 'responses' contains non-JSON values or is too deeply nested.") + return null + } + return ParsedResponses(converted.value) + } +} diff --git a/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/webview/WebViewMessageType.kt b/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/webview/WebViewMessageType.kt new file mode 100644 index 0000000000..7df881e96a --- /dev/null +++ b/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/webview/WebViewMessageType.kt @@ -0,0 +1,28 @@ +@file:JvmSynthetic + +package com.revenuecat.purchases.ui.revenuecatui.components.webview + +/** + * Message type identifiers for the RevenueCat `web_view` postMessage protocol (`protocol_version: 1`). + * These mirror the shapes used by the web implementation and must not diverge. + */ +internal object WebViewMessageType { + const val STEP_LOADED = "rc:step-loaded" + const val STEP_COMPLETE = "rc:step-complete" + const val REQUEST_VARIABLES = "rc:request-variables" + const val ERROR = "rc:error" + const val VARIABLES = "rc:variables" +} + +/** + * Field names used in the flat message envelope. The envelope is intentionally flat: message-specific + * fields such as [RESPONSES], [ERROR] and [VARIABLES] live at the top level rather than under a + * generic `payload` key. + */ +internal object WebViewMessageField { + const val TYPE = "type" + const val COMPONENT_ID = "component_id" + const val RESPONSES = "responses" + const val ERROR = "error" + const val VARIABLES = "variables" +} diff --git a/ui/revenuecatui/src/test/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/webview/WebViewMessageParserTest.kt b/ui/revenuecatui/src/test/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/webview/WebViewMessageParserTest.kt new file mode 100644 index 0000000000..ee36066409 --- /dev/null +++ b/ui/revenuecatui/src/test/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/webview/WebViewMessageParserTest.kt @@ -0,0 +1,154 @@ +package com.revenuecat.purchases.ui.revenuecatui.components.webview + +import com.revenuecat.purchases.ui.revenuecatui.PaywallWebViewValue +import org.assertj.core.api.Assertions.assertThat +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +internal class WebViewMessageParserTest { + + private val componentId = "promo_web_view" + + @Test + fun `parses rc step-loaded`() { + val message = parse("""{"type":"rc:step-loaded","component_id":"promo_web_view"}""") + + assertThat(message).isNotNull + assertThat(message!!.type).isEqualTo("rc:step-loaded") + assertThat(message.componentId).isEqualTo("promo_web_view") + assertThat(message.responses).isNull() + assertThat(message.error).isNull() + } + + @Test + fun `parses rc step-complete with responses`() { + val message = parse( + """ + { + "type":"rc:step-complete", + "component_id":"promo_web_view", + "responses":{"selected_plan":"annual","accepted_terms":true,"count":3} + } + """.trimIndent(), + ) + + assertThat(message).isNotNull + val responses = message!!.responses + assertThat(responses).isNotNull + assertThat(responses!!["selected_plan"]).isEqualTo(PaywallWebViewValue.String("annual")) + assertThat(responses["accepted_terms"]).isEqualTo(PaywallWebViewValue.Boolean(true)) + assertThat(responses["count"]).isEqualTo(PaywallWebViewValue.Number(3)) + } + + @Test + fun `parses rc step-complete without responses as empty map`() { + val message = parse("""{"type":"rc:step-complete","component_id":"promo_web_view"}""") + + assertThat(message).isNotNull + assertThat(message!!.responses).isEmpty() + } + + @Test + fun `parses rc request-variables`() { + val message = parse("""{"type":"rc:request-variables","component_id":"promo_web_view"}""") + + assertThat(message).isNotNull + assertThat(message!!.type).isEqualTo("rc:request-variables") + } + + @Test + fun `parses rc error`() { + val message = parse("""{"type":"rc:error","component_id":"promo_web_view","error":"Boom"}""") + + assertThat(message).isNotNull + assertThat(message!!.error).isEqualTo("Boom") + } + + @Test + fun `rejects message without type`() { + assertThat(parse("""{"component_id":"promo_web_view"}""")).isNull() + } + + @Test + fun `rejects message with non-string type`() { + assertThat(parse("""{"type":123,"component_id":"promo_web_view"}""")).isNull() + } + + @Test + fun `rejects message without component_id`() { + assertThat(parse("""{"type":"rc:step-loaded"}""")).isNull() + } + + @Test + fun `rejects message with wrong component_id`() { + assertThat(parse("""{"type":"rc:step-loaded","component_id":"other_web_view"}""")).isNull() + } + + @Test + fun `rejects rc step-complete with non-object responses`() { + assertThat( + parse("""{"type":"rc:step-complete","component_id":"promo_web_view","responses":"nope"}"""), + ).isNull() + } + + @Test + fun `rejects rc error without error string`() { + assertThat(parse("""{"type":"rc:error","component_id":"promo_web_view"}""")).isNull() + } + + @Test + fun `drops unknown message types`() { + assertThat(parse("""{"type":"rc:something-new","component_id":"promo_web_view"}""")).isNull() + } + + @Test + fun `rejects malformed json`() { + assertThat(parse("""not json""")).isNull() + } + + @Test + fun `rejects non-object json`() { + assertThat(parse("""["a","b"]""")).isNull() + } + + @Test + fun `rejects oversized payload`() { + val hugeValue = "x".repeat(WebViewMessageParser.MAX_PAYLOAD_BYTES + 1) + val raw = """{"type":"rc:error","component_id":"promo_web_view","error":"$hugeValue"}""" + assertThat(parse(raw)).isNull() + } + + @Test + fun `rejects excessively nested responses`() { + // Build responses nested deeper than MAX_NESTING_DEPTH. + val depth = WebViewMessageParser.MAX_NESTING_DEPTH + 2 + val opening = "{\"a\":".repeat(depth) + val closing = "}".repeat(depth) + val nested = opening + "1" + closing + val raw = """{"type":"rc:step-complete","component_id":"promo_web_view","responses":$nested}""" + assertThat(parse(raw)).isNull() + } + + @Test + fun `accepts null and nested json-compatible values in responses`() { + val message = parse( + """ + { + "type":"rc:step-complete", + "component_id":"promo_web_view", + "responses":{"maybe":null,"list":[1,"two",false],"obj":{"k":"v"}} + } + """.trimIndent(), + ) + + assertThat(message).isNotNull + val responses = message!!.responses!! + assertThat(responses["maybe"]).isEqualTo(PaywallWebViewValue.Null) + assertThat(responses["list"]).isInstanceOf(PaywallWebViewValue.Array::class.java) + assertThat(responses["obj"]).isInstanceOf(PaywallWebViewValue.Object::class.java) + } + + private fun parse(raw: String) = WebViewMessageParser.parse(raw, expectedComponentId = componentId) +} From f976cf706b70f6086beff7623d9754cd6cedb193 Mon Sep 17 00:00:00 2001 From: Alexander Repty Date: Fri, 26 Jun 2026 12:44:26 +0200 Subject: [PATCH 06/13] feat(paywalls): add web_view bidirectional messaging Adds a bidirectional JS<->native bridge for the Paywalls V2 `web_view` component: a document-start `window.RevenueCatWebView` shim, message parsing/validation (`WebViewMessageParser`, `WebViewMessageType`), the public `PaywallWebViewMessage` / `PaywallWebViewValue` / `PaywallWebViewMessageHandler` / `PaywallWebViewController` surface, and an SDK-managed variables provider (locale, color scheme, custom variables). Apps opt in via `PaywallOptions.setWebViewMessageHandler(...)`, threaded through `PaywallState` / `OfferingToStateMapper`. Regenerates ui/revenuecatui/api.txt for the new public API. Recommended labels: pr:feat, pr:RevenueCatUI, feat:Paywalls_V2 --- .../PaywallWebViewVariablesProvider.kt | 58 +++++++++++++++++++ .../PaywallWebViewVariablesProviderTest.kt | 34 +++++++++++ 2 files changed, 92 insertions(+) create mode 100644 ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/webview/PaywallWebViewVariablesProvider.kt create mode 100644 ui/revenuecatui/src/test/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/webview/PaywallWebViewVariablesProviderTest.kt diff --git a/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/webview/PaywallWebViewVariablesProvider.kt b/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/webview/PaywallWebViewVariablesProvider.kt new file mode 100644 index 0000000000..d88e1f24f4 --- /dev/null +++ b/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/webview/PaywallWebViewVariablesProvider.kt @@ -0,0 +1,58 @@ +@file:JvmSynthetic + +package com.revenuecat.purchases.ui.revenuecatui.components.webview + +import com.revenuecat.purchases.ui.revenuecatui.PaywallWebViewValue +import com.revenuecat.purchases.ui.revenuecatui.helpers.Logger + +/** + * Builds the `variables` payload sent to a `web_view` component in response to `rc:request-variables`. + * + * Produces a flat map shaped like: + * ```json + * { "locale": "en-US" } + * ``` + * + * Only safe, already-available system context is exposed: the paywall locale. The paywall's + * dashboard-defined custom variables are intentionally NOT passed across the bridge in v1. API keys, + * email addresses, the Purchases SDK instance, and storage are never included. + * + * [KEY_LOCALE] is a reserved SDK-managed top-level key: app-provided variables may not overwrite it. + */ +internal object PaywallWebViewVariablesProvider { + + const val KEY_LOCALE: String = "locale" + + val reservedKeys: Set = setOf(KEY_LOCALE) + + /** + * The SDK-managed system variables exposed to the web view. + * + * @param locale a BCP-47-ish locale string, e.g. `en-US`. + */ + fun sdkManagedVariables( + locale: String, + ): Map = linkedMapOf( + KEY_LOCALE to PaywallWebViewValue.String(locale), + ) + + /** + * Removes reserved SDK-managed top-level keys from app-provided variables so they cannot overwrite + * SDK values, logging a warning for any that were dropped. + */ + fun sanitizeAppProvidedVariables( + variables: Map, + ): Map { + val sanitized = variables.filterKeys { key -> + val reserved = key in reservedKeys + if (reserved) { + Logger.w( + "Ignoring reserved web view variable key '$key'. Reserved SDK-managed keys cannot be " + + "overwritten.", + ) + } + !reserved + } + return sanitized + } +} diff --git a/ui/revenuecatui/src/test/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/webview/PaywallWebViewVariablesProviderTest.kt b/ui/revenuecatui/src/test/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/webview/PaywallWebViewVariablesProviderTest.kt new file mode 100644 index 0000000000..2ea5defb15 --- /dev/null +++ b/ui/revenuecatui/src/test/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/webview/PaywallWebViewVariablesProviderTest.kt @@ -0,0 +1,34 @@ +package com.revenuecat.purchases.ui.revenuecatui.components.webview + +import com.revenuecat.purchases.ui.revenuecatui.PaywallWebViewValue +import org.assertj.core.api.Assertions.assertThat +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +internal class PaywallWebViewVariablesProviderTest { + + @Test + fun `sdkManagedVariables exposes only the locale system variable`() { + val variables = PaywallWebViewVariablesProvider.sdkManagedVariables(locale = "en-US") + + // Only the locale system variable is exposed; nothing else is passed across the bridge in v1. + assertThat(variables).hasSize(1) + assertThat(variables[PaywallWebViewVariablesProvider.KEY_LOCALE]) + .isEqualTo(PaywallWebViewValue.String("en-US")) + } + + @Test + fun `sanitizeAppProvidedVariables drops reserved keys`() { + val sanitized = PaywallWebViewVariablesProvider.sanitizeAppProvidedVariables( + mapOf( + "locale" to PaywallWebViewValue.String("zz-ZZ"), + "app_segment" to PaywallWebViewValue.String("high_intent"), + ), + ) + + assertThat(sanitized).doesNotContainKey("locale") + assertThat(sanitized).containsKey("app_segment") + } +} From 4c7fdb8fd7a9c24909fb55c859834366351ff226 Mon Sep 17 00:00:00 2001 From: Alexander Repty Date: Fri, 26 Jun 2026 12:44:26 +0200 Subject: [PATCH 07/13] feat(paywalls): add web_view bidirectional messaging Adds a bidirectional JS<->native bridge for the Paywalls V2 `web_view` component: a document-start `window.RevenueCatWebView` shim, message parsing/validation (`WebViewMessageParser`, `WebViewMessageType`), the public `PaywallWebViewMessage` / `PaywallWebViewValue` / `PaywallWebViewMessageHandler` / `PaywallWebViewController` surface, and an SDK-managed variables provider (locale, color scheme, custom variables). Apps opt in via `PaywallOptions.setWebViewMessageHandler(...)`, threaded through `PaywallState` / `OfferingToStateMapper`. Regenerates ui/revenuecatui/api.txt for the new public API. Recommended labels: pr:feat, pr:RevenueCatUI, feat:Paywalls_V2 --- .../webview/WebViewJavaScriptBridge.kt | 242 +++++++++++++ .../webview/WebViewJavaScriptBridgeTest.kt | 329 ++++++++++++++++++ 2 files changed, 571 insertions(+) create mode 100644 ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/webview/WebViewJavaScriptBridge.kt create mode 100644 ui/revenuecatui/src/test/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/webview/WebViewJavaScriptBridgeTest.kt diff --git a/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/webview/WebViewJavaScriptBridge.kt b/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/webview/WebViewJavaScriptBridge.kt new file mode 100644 index 0000000000..fa781cff2e --- /dev/null +++ b/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/webview/WebViewJavaScriptBridge.kt @@ -0,0 +1,242 @@ +@file:JvmSynthetic + +package com.revenuecat.purchases.ui.revenuecatui.components.webview + +import android.os.Handler +import android.os.Looper +import android.webkit.JavascriptInterface +import android.webkit.WebView +import androidx.annotation.MainThread +import com.revenuecat.purchases.ui.revenuecatui.PaywallWebViewController +import com.revenuecat.purchases.ui.revenuecatui.PaywallWebViewMessageHandler +import com.revenuecat.purchases.ui.revenuecatui.PaywallWebViewValue +import com.revenuecat.purchases.ui.revenuecatui.helpers.Logger +import com.revenuecat.purchases.ui.revenuecatui.toJsonObject +import org.json.JSONObject +import java.lang.ref.WeakReference +import java.net.URL + +/** + * Installs and drives the bidirectional bridge between a Paywalls V2 `web_view` component and native + * code. One bridge is created per rendered `web_view`. + * + * ## Transport + * This implementation uses [WebView.addJavascriptInterface] (the project does not depend on + * `androidx.webkit`). Exactly one native method is exposed — [postMessage] — under the private object + * name [NATIVE_OBJECT_NAME]. A small document-start shim (see [injectBridgeScript]) then exposes the + * stable public surface `window.RevenueCatWebView.postMessage(message)`, accepting either an object + * (serialized to JSON) or a JSON string, without overwriting an existing bridge. + * + * Because `addJavascriptInterface` provides no platform origin scoping, the bridge enforces the origin + * itself: before delivering any inbound message it verifies the WebView's current URL still has the + * same origin (scheme + host + port) as the resolved component URL, rejecting messages received after + * navigation to an unexpected origin. + * + * ## Native → web + * Native messages are delivered by invoking `window.__revenueCatReceiveMessage(message)` via + * [WebView.evaluateJavascript], preserving the flat protocol envelope. + * + * ## Lifecycle & threading + * The bridge holds only a [WeakReference] to the [WebView] (no Activity/Context), and stops delivering + * messages once [release] is called. Inbound JavaScript callbacks arrive on a binder thread and are + * hopped onto the main thread; app callbacks and all WebView interactions happen on the main thread. + */ +@Suppress("LongParameterList", "TooManyFunctions") +internal class WebViewJavaScriptBridge( + webView: WebView, + private val componentId: String, + expectedUrl: URL, + locale: String, + messageHandler: PaywallWebViewMessageHandler?, + private val mainHandler: Handler = Handler(Looper.getMainLooper()), +) : PaywallWebViewController { + + private val webViewRef = WeakReference(webView) + private val expectedOrigin: String? = expectedUrl.toOriginOrNull() + + // Refreshed from the latest paywall state on every recomposition via update(). + @Volatile private var locale: String = locale + + @Volatile private var messageHandler: PaywallWebViewMessageHandler? = messageHandler + + @Volatile private var released: Boolean = false + + /** + * Registers the native interface on the WebView. Call once, when the WebView is created. + */ + @MainThread + fun attach() { + webViewRef.get()?.addJavascriptInterface(this, NATIVE_OBJECT_NAME) + } + + /** + * Injects the `window.RevenueCatWebView` shim. Call from `WebViewClient.onPageStarted` so the + * surface is available before the page's own scripts run. + */ + @MainThread + fun injectBridgeScript() { + if (released) return + webViewRef.get()?.evaluateJavascript(BRIDGE_SCRIPT, null) + } + + /** + * Refreshes the locale and the app message handler from the latest paywall state. Call from + * `AndroidView`'s update block. + */ + fun update( + locale: String, + messageHandler: PaywallWebViewMessageHandler?, + ) { + this.locale = locale + this.messageHandler = messageHandler + } + + /** + * Stops the bridge. After this call no further inbound messages are delivered and no outbound + * messages are sent. Call from `AndroidView`'s onRelease block. + */ + @MainThread + fun release() { + released = true + webViewRef.get()?.removeJavascriptInterface(NATIVE_OBJECT_NAME) + } + + /** + * Entry point for the injected `window.RevenueCatWebView`. Invoked on a binder thread; we hop to + * the main thread before touching the WebView or validating the message. + */ + @JavascriptInterface + fun postMessage(json: String) { + if (released) return + mainHandler.post { handleInboundMessage(json) } + } + + @MainThread + @Suppress("ReturnCount") + private fun handleInboundMessage(json: String) { + if (released) return + val webView = webViewRef.get() ?: return + + if (!isCurrentOriginExpected(webView)) { + Logger.w("Dropping inbound web view message: current origin does not match the resolved component origin.") + return + } + + val message = WebViewMessageParser.parse(json, expectedComponentId = componentId) ?: return + + // On a request for variables, the SDK first sends its managed system variables, then invokes + // the app handler so the app may add more under `custom`. + if (message.type == WebViewMessageType.REQUEST_VARIABLES) { + postMessage( + componentId = componentId, + type = WebViewMessageType.VARIABLES, + variables = PaywallWebViewVariablesProvider.sdkManagedVariables(locale = locale), + ) + } + + messageHandler?.onMessage(message, this) + } + + override fun postVariables(componentId: String, variables: Map) { + postMessage( + componentId = componentId, + type = WebViewMessageType.VARIABLES, + variables = PaywallWebViewVariablesProvider.sanitizeAppProvidedVariables(variables), + ) + } + + override fun postMessage(componentId: String, type: String, variables: Map) { + // The payload is delivered under the `variables` key, following the web view protocol envelope + // `{ "type", "component_id", "variables" }` (matches the other RevenueCat SDK platforms). + val envelope = JSONObject().apply { + put(WebViewMessageField.TYPE, type) + put(WebViewMessageField.COMPONENT_ID, componentId) + put(WebViewMessageField.VARIABLES, variables.toJsonObject()) + } + deliverToWebView(envelope) + } + + private fun deliverToWebView(envelope: JSONObject) { + runOnMainThread { + if (released) return@runOnMainThread + val webView = webViewRef.get() ?: return@runOnMainThread + // Symmetric with the inbound guard: never deliver into content that navigated to a + // different origin (https navigation to any host is allowed), so SDK envelopes such as + // `rc:variables` can't leak across an origin boundary. + if (!isCurrentOriginExpected(webView)) { + Logger.w( + "Dropping outbound web view message: current origin does not match the resolved component origin.", + ) + return@runOnMainThread + } + val payload = envelope.toString().escapeForJavaScript() + webView.evaluateJavascript( + "if (window.$RECEIVE_FUNCTION) { window.$RECEIVE_FUNCTION($payload); }", + null, + ) + } + } + + /** + * Whether the WebView's current URL still has the same origin (scheme + host + port) as the + * resolved component URL. Both inbound and outbound messaging are gated on this so messages never + * cross an origin boundary after a navigation to an unexpected origin. + */ + @MainThread + private fun isCurrentOriginExpected(webView: WebView): Boolean { + val currentOrigin = webView.url?.let { runCatching { URL(it).toOriginOrNull() }.getOrNull() } + return expectedOrigin != null && currentOrigin != null && currentOrigin == expectedOrigin + } + + private fun runOnMainThread(block: () -> Unit) { + if (Looper.myLooper() == Looper.getMainLooper()) { + block() + } else { + mainHandler.post(block) + } + } + + private companion object { + // Private native object name. The public surface (window.RevenueCatWebView) is created by the + // injected shim below; this avoids exposing the raw native interface to web content directly. + const val NATIVE_OBJECT_NAME = "__RevenueCatNativeBridge" + const val PUBLIC_OBJECT_NAME = "RevenueCatWebView" + const val RECEIVE_FUNCTION = "__revenueCatReceiveMessage" + + val BRIDGE_SCRIPT = """ + (function() { + if (window.$PUBLIC_OBJECT_NAME) { return; } + var nativeBridge = window.$NATIVE_OBJECT_NAME; + if (!nativeBridge) { return; } + window.$PUBLIC_OBJECT_NAME = { + postMessage: function(message) { + nativeBridge.postMessage( + typeof message === 'string' ? message : JSON.stringify(message) + ); + } + }; + })(); + """.trimIndent() + + /** + * JSON is a subset of JS object-literal syntax, but the U+2028/U+2029 separators are valid in + * JSON strings yet terminate JS statements. Escape them so the payload is safe to embed. + */ + fun String.escapeForJavaScript(): String = + replace("\u2028", "\\u2028").replace("\u2029", "\\u2029") + } +} + +/** + * The origin of this URL as `scheme://host[:port]`, or `null` if it lacks a host. Only HTTPS URLs are + * expected here (the resolver already enforces this), but the origin string includes the scheme so a + * scheme change would also be detected. + */ +private fun URL.toOriginOrNull(): String? { + val host = host?.takeIf { it.isNotBlank() } ?: return null + // Normalize the default port so e.g. `https://host` and `https://host:443` compare as the same + // origin: an explicit default port and an omitted one must not cause spurious message drops. + val effectivePort = if (port == -1) defaultPort else port + val portSuffix = if (effectivePort == -1) "" else ":$effectivePort" + return "$protocol://$host$portSuffix" +} diff --git a/ui/revenuecatui/src/test/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/webview/WebViewJavaScriptBridgeTest.kt b/ui/revenuecatui/src/test/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/webview/WebViewJavaScriptBridgeTest.kt new file mode 100644 index 0000000000..84a5fd1441 --- /dev/null +++ b/ui/revenuecatui/src/test/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/webview/WebViewJavaScriptBridgeTest.kt @@ -0,0 +1,329 @@ +package com.revenuecat.purchases.ui.revenuecatui.components.webview + +import android.os.Looper +import android.webkit.WebView +import androidx.test.core.app.ApplicationProvider +import com.revenuecat.purchases.ui.revenuecatui.PaywallWebViewController +import com.revenuecat.purchases.ui.revenuecatui.PaywallWebViewMessage +import com.revenuecat.purchases.ui.revenuecatui.PaywallWebViewMessageHandler +import com.revenuecat.purchases.ui.revenuecatui.PaywallWebViewValue +import org.assertj.core.api.Assertions.assertThat +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.Shadows.shadowOf +import org.robolectric.shadows.ShadowWebView +import java.net.URL + +@RunWith(RobolectricTestRunner::class) +internal class WebViewJavaScriptBridgeTest { + + private val componentId = "promo_web_view" + private val expectedUrl = URL("https://assets.example.com/promo/index.html") + + private lateinit var webView: WebView + private lateinit var shadowWebView: ShadowWebView + private val received = mutableListOf() + + @Before + fun setUp() { + webView = WebView(ApplicationProvider.getApplicationContext()) + shadowWebView = shadowOf(webView) + received.clear() + } + + private fun bridge( + handler: PaywallWebViewMessageHandler? = PaywallWebViewMessageHandler { message, _ -> received.add(message) }, + locale: String = "en-US", + navigateTo: URL = expectedUrl, + ): WebViewJavaScriptBridge { + val bridge = WebViewJavaScriptBridge( + webView = webView, + componentId = componentId, + expectedUrl = expectedUrl, + locale = locale, + messageHandler = handler, + ) + bridge.attach() + // Robolectric's ShadowWebView reports the last loaded URL via getUrl(). + webView.loadUrl(navigateTo.toString()) + return bridge + } + + private fun idleMainLooper() { + shadowOf(Looper.getMainLooper()).idle() + } + + @Test + fun `delivers valid message using the canonical component id`() { + bridge().postMessage("""{"type":"rc:step-loaded","component_id":"promo_web_view"}""") + idleMainLooper() + + assertThat(received).hasSize(1) + assertThat(received.single().componentId).isEqualTo("promo_web_view") + assertThat(received.single().type).isEqualTo("rc:step-loaded") + } + + @Test + fun `rejects messages for a different component id`() { + bridge().postMessage("""{"type":"rc:step-loaded","component_id":"other_web_view"}""") + idleMainLooper() + + assertThat(received).isEmpty() + } + + @Test + fun `request-variables auto-sends rc variables with locale only`() { + bridge().postMessage("""{"type":"rc:request-variables","component_id":"promo_web_view"}""") + idleMainLooper() + + // The app handler is still notified... + assertThat(received.single().type).isEqualTo("rc:request-variables") + // ...and the SDK sent rc:variables back into the web view via the receive hook. + val script = shadowWebView.lastEvaluatedJavascript + assertThat(script).contains("window.__revenueCatReceiveMessage(") + assertThat(script).contains("\"rc:variables\"") + assertThat(script).contains("\"component_id\":\"promo_web_view\"") + assertThat(script).contains("\"locale\":\"en-US\"") + // Dashboard custom variables are not passed across the bridge. + assertThat(script).doesNotContain("\"custom\"") + } + + @Test + fun `app can reply with extra variables through the controller`() { + val handler = PaywallWebViewMessageHandler { message, controller -> + received.add(message) + if (message.type == WebViewMessageType.REQUEST_VARIABLES) { + controller.postVariables( + componentId = message.componentId, + variables = mapOf( + "custom" to PaywallWebViewValue.Object( + mapOf("app_segment" to PaywallWebViewValue.String("high_intent")), + ), + ), + ) + } + } + bridge(handler = handler) + .postMessage("""{"type":"rc:request-variables","component_id":"promo_web_view"}""") + idleMainLooper() + + // The controller's reply is the most recent script delivered to the web view. + val script = shadowWebView.lastEvaluatedJavascript + assertThat(script).contains("\"rc:variables\"") + assertThat(script).contains("\"app_segment\":\"high_intent\"") + } + + @Test + fun `controller postVariables drops reserved keys`() { + val controllerHolder = arrayOfNulls(1) + val handler = PaywallWebViewMessageHandler { _, controller -> controllerHolder[0] = controller } + bridge(handler = handler) + .postMessage("""{"type":"rc:step-loaded","component_id":"promo_web_view"}""") + idleMainLooper() + + controllerHolder[0]!!.postVariables( + componentId = componentId, + variables = mapOf( + "locale" to PaywallWebViewValue.String("zz-ZZ"), + "custom" to PaywallWebViewValue.Object(mapOf("k" to PaywallWebViewValue.String("v"))), + ), + ) + idleMainLooper() + + val script = shadowWebView.lastEvaluatedJavascript + assertThat(script).doesNotContain("zz-ZZ") + assertThat(script).contains("\"k\":\"v\"") + } + + @Test + fun `does not deliver messages after release`() { + val bridge = bridge() + bridge.release() + + bridge.postMessage("""{"type":"rc:step-loaded","component_id":"promo_web_view"}""") + idleMainLooper() + + assertThat(received).isEmpty() + } + + @Test + fun `rejects messages after navigation to an unexpected origin`() { + bridge(navigateTo = URL("https://evil.example.org/phish.html")) + .postMessage("""{"type":"rc:step-loaded","component_id":"promo_web_view"}""") + idleMainLooper() + + assertThat(received).isEmpty() + } + + @Test + fun `allows messages from the same origin on a different path`() { + bridge(navigateTo = URL("https://assets.example.com/promo/step-two.html")) + .postMessage("""{"type":"rc:step-loaded","component_id":"promo_web_view"}""") + idleMainLooper() + + assertThat(received).hasSize(1) + } + + @Test + fun `does not deliver outbound messages after navigation to an unexpected origin`() { + // Symmetric with the inbound guard: outbound envelopes must not leak into foreign content. + bridge(navigateTo = URL("https://evil.example.org/phish.html")).postMessage( + componentId = componentId, + type = "rc:custom", + variables = mapOf("foo" to PaywallWebViewValue.String("bar")), + ) + idleMainLooper() + + assertThat(shadowWebView.lastEvaluatedJavascript).isNull() + } + + @Test + fun `treats the default https port as the same origin`() { + // expectedUrl omits the port; navigating to the explicit :443 must still match (inbound + outbound). + bridge(navigateTo = URL("https://assets.example.com:443/promo/index.html")).postMessage( + componentId = componentId, + type = "rc:custom", + variables = mapOf("foo" to PaywallWebViewValue.String("bar")), + ) + idleMainLooper() + + assertThat(shadowWebView.lastEvaluatedJavascript).contains("\"type\":\"rc:custom\"") + } + + @Test + fun `delivers rc step-complete responses to the handler without sending an outbound message`() { + bridge().postMessage( + """ + { + "type":"rc:step-complete", + "component_id":"promo_web_view", + "responses":{"selected_plan":"annual","accepted_terms":true} + } + """.trimIndent(), + ) + idleMainLooper() + + val message = received.single() + assertThat(message.type).isEqualTo("rc:step-complete") + assertThat(message.responses?.get("selected_plan")).isEqualTo(PaywallWebViewValue.String("annual")) + assertThat(message.responses?.get("accepted_terms")).isEqualTo(PaywallWebViewValue.Boolean(true)) + // rc:step-complete must not auto-dismiss or send anything back; the app decides. + assertThat(shadowWebView.lastEvaluatedJavascript).isNull() + } + + @Test + fun `delivers rc error to the handler`() { + bridge().postMessage( + """{"type":"rc:error","component_id":"promo_web_view","error":"Something went wrong"}""", + ) + idleMainLooper() + + val message = received.single() + assertThat(message.type).isEqualTo("rc:error") + assertThat(message.error).isEqualTo("Something went wrong") + } + + @Test + fun `does not deliver malformed messages to the handler`() { + bridge().postMessage("""not even json""") + idleMainLooper() + + assertThat(received).isEmpty() + } + + @Test + fun `auto-sends rc variables even when no handler is set`() { + bridge(handler = null) + .postMessage("""{"type":"rc:request-variables","component_id":"promo_web_view"}""") + idleMainLooper() + + assertThat(received).isEmpty() + val script = shadowWebView.lastEvaluatedJavascript + assertThat(script).contains("\"rc:variables\"") + assertThat(script).contains("\"locale\":\"en-US\"") + } + + @Test + fun `update refreshes the variables sent on a subsequent request`() { + val bridge = bridge(locale = "en-US") + bridge.update( + locale = "fr-FR", + messageHandler = null, + ) + + bridge.postMessage("""{"type":"rc:request-variables","component_id":"promo_web_view"}""") + idleMainLooper() + + val script = shadowWebView.lastEvaluatedJavascript + assertThat(script).contains("\"locale\":\"fr-FR\"") + assertThat(script).doesNotContain("en-US") + } + + @Test + fun `generic postMessage nests the payload under the variables key`() { + bridge().postMessage( + componentId = componentId, + type = "rc:custom", + variables = mapOf("foo" to PaywallWebViewValue.String("bar")), + ) + idleMainLooper() + + val script = shadowWebView.lastEvaluatedJavascript + assertThat(script).contains("window.__revenueCatReceiveMessage(") + assertThat(script).contains("\"type\":\"rc:custom\"") + assertThat(script).contains("\"component_id\":\"promo_web_view\"") + // The payload is delivered under the `variables` key, never merged into the envelope top level. + assertThat(script).contains("\"variables\":{\"foo\":\"bar\"}") + } + + @Test + fun `escapes line and paragraph separators in outbound payloads`() { + // U+2028/U+2029 are valid in JSON strings but terminate JS statements; they must be escaped. + val raw = "a\u2028b\u2029c" + bridge().postVariables( + componentId = componentId, + variables = mapOf( + "custom" to PaywallWebViewValue.Object(mapOf("note" to PaywallWebViewValue.String(raw))), + ), + ) + idleMainLooper() + + val script = shadowWebView.lastEvaluatedJavascript + assertThat(script).doesNotContain("\u2028") + assertThat(script).doesNotContain("\u2029") + assertThat(script).contains("\\u2028") + assertThat(script).contains("\\u2029") + } + + @Test + fun `attach registers a single native interface under the private name`() { + bridge() + + assertThat(shadowWebView.getJavascriptInterface("__RevenueCatNativeBridge")).isNotNull + // The public object name is created by the injected shim, not exposed as a native interface. + assertThat(shadowWebView.getJavascriptInterface("RevenueCatWebView")).isNull() + } + + @Test + fun `injectBridgeScript exposes the public surface without overwriting an existing bridge`() { + bridge().injectBridgeScript() + + val script = shadowWebView.lastEvaluatedJavascript + assertThat(script).contains("window.RevenueCatWebView") + assertThat(script).contains("window.__RevenueCatNativeBridge") + // Guard so an existing bridge is not clobbered. + assertThat(script).contains("if (window.RevenueCatWebView)") + } + + @Test + fun `release removes the native interface`() { + val bridge = bridge() + assertThat(shadowWebView.getJavascriptInterface("__RevenueCatNativeBridge")).isNotNull + + bridge.release() + + assertThat(shadowWebView.getJavascriptInterface("__RevenueCatNativeBridge")).isNull() + } +} From c43a1f4da66bcda1d69c1fd8f948ed579221fcc7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=81lvaro=20Brey?= Date: Mon, 6 Jul 2026 16:55:45 +0200 Subject: [PATCH 08/13] Align web_view bridge with workflow-web-components-sdk native contract. --- .../components/webview/WebViewEnvelope.kt | 111 +++++++++ .../webview/WebViewJavaScriptBridge.kt | 223 +++++++++++------ .../webview/WebViewMessageParser.kt | 140 +++++++---- .../components/webview/WebViewMessageType.kt | 5 + .../webview/WebViewJavaScriptBridgeTest.kt | 229 +++++++++++++----- .../webview/WebViewMessageParserTest.kt | 211 ++++++++++++---- 6 files changed, 692 insertions(+), 227 deletions(-) create mode 100644 ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/webview/WebViewEnvelope.kt diff --git a/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/webview/WebViewEnvelope.kt b/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/webview/WebViewEnvelope.kt new file mode 100644 index 0000000000..a9b1eb2678 --- /dev/null +++ b/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/webview/WebViewEnvelope.kt @@ -0,0 +1,111 @@ +@file:JvmSynthetic + +package com.revenuecat.purchases.ui.revenuecatui.components.webview + +import org.json.JSONObject + +/** + * Wire-format envelopes for the `workflow-web-components-sdk` transport layer. + * + * Matches [RevenueCat/workflow-web-components-sdk](https://github.com/RevenueCat/workflow-web-components-sdk): + * channel `rc-web-components`, handshake kinds `connect` / `init` / `reject`, and app frames + * `message` / `request` / `response` / `error`. + */ +internal object WebViewEnvelope { + + const val CHANNEL: String = "rc-web-components" + const val NATIVE_OBJECT_NAME: String = "rcWebComponents" + const val RECEIVE_FUNCTION: String = "__rcWebComponentsReceive" + const val DEFAULT_PROTOCOL_VERSION: Int = 1 + + const val KIND_CONNECT: String = "connect" + const val KIND_INIT: String = "init" + const val KIND_REJECT: String = "reject" + const val KIND_MESSAGE: String = "message" + const val KIND_REQUEST: String = "request" + const val KIND_RESPONSE: String = "response" + const val KIND_ERROR: String = "error" + + private val ENVELOPE_KINDS: Set = setOf( + KIND_CONNECT, + KIND_INIT, + KIND_REJECT, + KIND_MESSAGE, + KIND_REQUEST, + KIND_RESPONSE, + KIND_ERROR, + ) + + data class Parsed( + val kind: String, + val protocolVersion: Int, + val componentId: String, + val type: String?, + val id: String?, + val payload: JSONObject?, + val error: String?, + ) + + @Suppress("ReturnCount") + fun parse(rawJson: String): Parsed? { + val json = try { + JSONObject(rawJson) + } catch (@Suppress("SwallowedException") _: org.json.JSONException) { + return null + } + + if (json.opt(WebViewMessageField.CHANNEL) != CHANNEL) return null + + val protocolVersion = json.opt(WebViewMessageField.PROTOCOL_VERSION) + if (protocolVersion !is Number || !protocolVersion.toDouble().isFinite()) return null + + val kind = json.opt(WebViewMessageField.KIND) as? String + if (kind == null || kind !in ENVELOPE_KINDS) return null + + val componentId = json.opt(WebViewMessageField.COMPONENT_ID) as? String ?: return null + + val type = json.opt(WebViewMessageField.TYPE) as? String + if (json.has(WebViewMessageField.TYPE) && type == null) return null + + val id = json.opt(WebViewMessageField.ID) as? String + if (json.has(WebViewMessageField.ID) && id == null) return null + + val error = json.opt(WebViewMessageField.ERROR) as? String + if (json.has(WebViewMessageField.ERROR) && error == null) return null + + val payload = when (val payloadValue = json.opt(WebViewMessageField.PAYLOAD)) { + null, JSONObject.NULL -> null + is JSONObject -> payloadValue + else -> return null + } + + return Parsed( + kind = kind, + protocolVersion = protocolVersion.toInt(), + componentId = componentId, + type = type, + id = id, + payload = payload, + error = error, + ) + } + + fun build( + kind: String, + protocolVersion: Int, + componentId: String, + type: String? = null, + id: String? = null, + payload: JSONObject? = null, + error: String? = null, + ): JSONObject = JSONObject().apply { + put(WebViewMessageField.CHANNEL, CHANNEL) + put(WebViewMessageField.PROTOCOL_VERSION, protocolVersion) + put(WebViewMessageField.KIND, kind) + put(WebViewMessageField.COMPONENT_ID, componentId) + type?.let { put(WebViewMessageField.TYPE, it) } + id?.let { put(WebViewMessageField.ID, it) } + payload?.let { put(WebViewMessageField.PAYLOAD, it) } + error?.let { put(WebViewMessageField.ERROR, it) } + } +} diff --git a/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/webview/WebViewJavaScriptBridge.kt b/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/webview/WebViewJavaScriptBridge.kt index fa781cff2e..c362a6b2b5 100644 --- a/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/webview/WebViewJavaScriptBridge.kt +++ b/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/webview/WebViewJavaScriptBridge.kt @@ -21,21 +21,19 @@ import java.net.URL * code. One bridge is created per rendered `web_view`. * * ## Transport - * This implementation uses [WebView.addJavascriptInterface] (the project does not depend on - * `androidx.webkit`). Exactly one native method is exposed — [postMessage] — under the private object - * name [NATIVE_OBJECT_NAME]. A small document-start shim (see [injectBridgeScript]) then exposes the - * stable public surface `window.RevenueCatWebView.postMessage(message)`, accepting either an object - * (serialized to JSON) or a JSON string, without overwriting an existing bridge. + * Implements the native Android host contract from `workflow-web-components-sdk`: + * - JS → native: `window.rcWebComponents.postMessage(jsonString)` via [addJavascriptInterface] + * - native → JS: `window.__rcWebComponentsReceive(data)` via [WebView.evaluateJavascript] + * - Wire format: `{ channel: "rc-web-components", kind, protocol_version, component_id, … }` + * + * The content SDK (`window.RC`) auto-detects Android when [NATIVE_OBJECT_NAME] is present and runs + * the same connect/init handshake as on web. No document-start shim is injected. * * Because `addJavascriptInterface` provides no platform origin scoping, the bridge enforces the origin * itself: before delivering any inbound message it verifies the WebView's current URL still has the * same origin (scheme + host + port) as the resolved component URL, rejecting messages received after * navigation to an unexpected origin. * - * ## Native → web - * Native messages are delivered by invoking `window.__revenueCatReceiveMessage(message)` via - * [WebView.evaluateJavascript], preserving the flat protocol envelope. - * * ## Lifecycle & threading * The bridge holds only a [WeakReference] to the [WebView] (no Activity/Context), and stops delivering * messages once [release] is called. Inbound JavaScript callbacks arrive on a binder thread and are @@ -48,11 +46,13 @@ internal class WebViewJavaScriptBridge( expectedUrl: URL, locale: String, messageHandler: PaywallWebViewMessageHandler?, + protocolVersion: Int = WebViewEnvelope.DEFAULT_PROTOCOL_VERSION, private val mainHandler: Handler = Handler(Looper.getMainLooper()), ) : PaywallWebViewController { private val webViewRef = WeakReference(webView) private val expectedOrigin: String? = expectedUrl.toOriginOrNull() + private val protocolVersion: Int = protocolVersion // Refreshed from the latest paywall state on every recomposition via update(). @Volatile private var locale: String = locale @@ -61,22 +61,14 @@ internal class WebViewJavaScriptBridge( @Volatile private var released: Boolean = false + @Volatile private var channelOpen: Boolean = false + /** * Registers the native interface on the WebView. Call once, when the WebView is created. */ @MainThread fun attach() { - webViewRef.get()?.addJavascriptInterface(this, NATIVE_OBJECT_NAME) - } - - /** - * Injects the `window.RevenueCatWebView` shim. Call from `WebViewClient.onPageStarted` so the - * surface is available before the page's own scripts run. - */ - @MainThread - fun injectBridgeScript() { - if (released) return - webViewRef.get()?.evaluateJavascript(BRIDGE_SCRIPT, null) + webViewRef.get()?.addJavascriptInterface(this, WebViewEnvelope.NATIVE_OBJECT_NAME) } /** @@ -98,12 +90,13 @@ internal class WebViewJavaScriptBridge( @MainThread fun release() { released = true - webViewRef.get()?.removeJavascriptInterface(NATIVE_OBJECT_NAME) + channelOpen = false + webViewRef.get()?.removeJavascriptInterface(WebViewEnvelope.NATIVE_OBJECT_NAME) } /** - * Entry point for the injected `window.RevenueCatWebView`. Invoked on a binder thread; we hop to - * the main thread before touching the WebView or validating the message. + * Entry point for `window.rcWebComponents.postMessage`. Invoked on a binder thread; we hop to the + * main thread before touching the WebView or validating the message. */ @JavascriptInterface fun postMessage(json: String) { @@ -117,53 +110,144 @@ internal class WebViewJavaScriptBridge( if (released) return val webView = webViewRef.get() ?: return - if (!isCurrentOriginExpected(webView)) { - Logger.w("Dropping inbound web view message: current origin does not match the resolved component origin.") + if (json.toByteArray(Charsets.UTF_8).size > WebViewMessageParser.MAX_PAYLOAD_BYTES) { + Logger.w("Dropping inbound web view message: payload exceeds ${WebViewMessageParser.MAX_PAYLOAD_BYTES} bytes.") return } - val message = WebViewMessageParser.parse(json, expectedComponentId = componentId) ?: return + val envelope = WebViewEnvelope.parse(json) ?: run { + Logger.w("Dropping inbound web view message: not a valid transport envelope.") + return + } - // On a request for variables, the SDK first sends its managed system variables, then invokes - // the app handler so the app may add more under `custom`. - if (message.type == WebViewMessageType.REQUEST_VARIABLES) { - postMessage( - componentId = componentId, - type = WebViewMessageType.VARIABLES, - variables = PaywallWebViewVariablesProvider.sdkManagedVariables(locale = locale), + when (envelope.kind) { + WebViewEnvelope.KIND_CONNECT -> { + if (!isOriginTrusted(webView, allowBeforeNavigation = true)) { + Logger.w( + "Dropping inbound web view connect: current origin does not match the resolved component origin.", + ) + return + } + handleConnect(envelope) + } + WebViewEnvelope.KIND_MESSAGE, + WebViewEnvelope.KIND_REQUEST, + -> { + if (!isOriginTrusted(webView, allowBeforeNavigation = false)) { + Logger.w( + "Dropping inbound web view message: current origin does not match the resolved component origin.", + ) + return + } + handleAppFrame(json) + } + else -> Unit + } + } + + @MainThread + private fun handleConnect(envelope: WebViewEnvelope.Parsed) { + if (channelOpen || released) return + + if (envelope.protocolVersion != protocolVersion) { + deliverEnvelope( + WebViewEnvelope.build( + kind = WebViewEnvelope.KIND_REJECT, + protocolVersion = protocolVersion, + componentId = "", + error = "Unsupported protocol_version ${envelope.protocolVersion}; " + + "native host supports $protocolVersion", + ), ) + return + } + + channelOpen = true + deliverEnvelope( + WebViewEnvelope.build( + kind = WebViewEnvelope.KIND_INIT, + protocolVersion = protocolVersion, + componentId = componentId, + ), + ) + } + + @MainThread + private fun handleAppFrame(rawJson: String) { + if (!channelOpen) return + + val parsed = WebViewMessageParser.parse( + rawJson = rawJson, + expectedComponentId = componentId, + ) ?: return + + val message = parsed.message + + if (message.type == WebViewMessageType.REQUEST_VARIABLES) { + val variables = PaywallWebViewVariablesProvider.sdkManagedVariables(locale = locale) + val requestId = parsed.requestId + if (requestId != null) { + deliverEnvelope( + WebViewEnvelope.build( + kind = WebViewEnvelope.KIND_RESPONSE, + protocolVersion = protocolVersion, + componentId = componentId, + type = parsed.requestType, + id = requestId, + payload = variables.toJsonObject(), + ), + ) + } else { + postVariablesMessage(componentId = componentId, variables = variables) + } } messageHandler?.onMessage(message, this) } override fun postVariables(componentId: String, variables: Map) { - postMessage( + postVariablesMessage( componentId = componentId, - type = WebViewMessageType.VARIABLES, variables = PaywallWebViewVariablesProvider.sanitizeAppProvidedVariables(variables), ) } override fun postMessage(componentId: String, type: String, variables: Map) { - // The payload is delivered under the `variables` key, following the web view protocol envelope - // `{ "type", "component_id", "variables" }` (matches the other RevenueCat SDK platforms). - val envelope = JSONObject().apply { - put(WebViewMessageField.TYPE, type) - put(WebViewMessageField.COMPONENT_ID, componentId) - put(WebViewMessageField.VARIABLES, variables.toJsonObject()) - } - deliverToWebView(envelope) + postAppMessage( + componentId = componentId, + type = type, + payload = variables.toJsonObject(), + ) + } + + private fun postVariablesMessage(componentId: String, variables: Map) { + postAppMessage( + componentId = componentId, + type = WebViewMessageType.VARIABLES, + payload = variables.toJsonObject(), + ) } - private fun deliverToWebView(envelope: JSONObject) { + private fun postAppMessage(componentId: String, type: String, payload: JSONObject) { + if (!channelOpen) return + deliverEnvelope( + WebViewEnvelope.build( + kind = WebViewEnvelope.KIND_MESSAGE, + protocolVersion = protocolVersion, + componentId = componentId, + type = type, + payload = payload, + ), + ) + } + + private fun deliverEnvelope(envelope: JSONObject) { runOnMainThread { if (released) return@runOnMainThread val webView = webViewRef.get() ?: return@runOnMainThread - // Symmetric with the inbound guard: never deliver into content that navigated to a - // different origin (https navigation to any host is allowed), so SDK envelopes such as - // `rc:variables` can't leak across an origin boundary. - if (!isCurrentOriginExpected(webView)) { + val kind = envelope.optString(WebViewMessageField.KIND) + val allowBeforeNavigation = kind in HANDSHAKE_OUTBOUND_KINDS + if (!isOriginTrusted(webView, allowBeforeNavigation = allowBeforeNavigation)) { Logger.w( "Dropping outbound web view message: current origin does not match the resolved component origin.", ) @@ -171,7 +255,9 @@ internal class WebViewJavaScriptBridge( } val payload = envelope.toString().escapeForJavaScript() webView.evaluateJavascript( - "if (window.$RECEIVE_FUNCTION) { window.$RECEIVE_FUNCTION($payload); }", + "if (window.${WebViewEnvelope.RECEIVE_FUNCTION}) { " + + "window.${WebViewEnvelope.RECEIVE_FUNCTION}($payload); " + + "}", null, ) } @@ -179,13 +265,18 @@ internal class WebViewJavaScriptBridge( /** * Whether the WebView's current URL still has the same origin (scheme + host + port) as the - * resolved component URL. Both inbound and outbound messaging are gated on this so messages never - * cross an origin boundary after a navigation to an unexpected origin. + * resolved component URL. When [allowBeforeNavigation] is true and the WebView has not committed + * a URL yet, the expected origin is trusted so the connect/init handshake can complete before + * the first navigation. */ @MainThread - private fun isCurrentOriginExpected(webView: WebView): Boolean { + private fun isOriginTrusted(webView: WebView, allowBeforeNavigation: Boolean): Boolean { + if (expectedOrigin == null) return false val currentOrigin = webView.url?.let { runCatching { URL(it).toOriginOrNull() }.getOrNull() } - return expectedOrigin != null && currentOrigin != null && currentOrigin == expectedOrigin + return when { + currentOrigin == null -> allowBeforeNavigation + else -> currentOrigin == expectedOrigin + } } private fun runOnMainThread(block: () -> Unit) { @@ -197,26 +288,10 @@ internal class WebViewJavaScriptBridge( } private companion object { - // Private native object name. The public surface (window.RevenueCatWebView) is created by the - // injected shim below; this avoids exposing the raw native interface to web content directly. - const val NATIVE_OBJECT_NAME = "__RevenueCatNativeBridge" - const val PUBLIC_OBJECT_NAME = "RevenueCatWebView" - const val RECEIVE_FUNCTION = "__revenueCatReceiveMessage" - - val BRIDGE_SCRIPT = """ - (function() { - if (window.$PUBLIC_OBJECT_NAME) { return; } - var nativeBridge = window.$NATIVE_OBJECT_NAME; - if (!nativeBridge) { return; } - window.$PUBLIC_OBJECT_NAME = { - postMessage: function(message) { - nativeBridge.postMessage( - typeof message === 'string' ? message : JSON.stringify(message) - ); - } - }; - })(); - """.trimIndent() + private val HANDSHAKE_OUTBOUND_KINDS: Set = setOf( + WebViewEnvelope.KIND_INIT, + WebViewEnvelope.KIND_REJECT, + ) /** * JSON is a subset of JS object-literal syntax, but the U+2028/U+2029 separators are valid in @@ -234,8 +309,6 @@ internal class WebViewJavaScriptBridge( */ private fun URL.toOriginOrNull(): String? { val host = host?.takeIf { it.isNotBlank() } ?: return null - // Normalize the default port so e.g. `https://host` and `https://host:443` compare as the same - // origin: an explicit default port and an omitted one must not cause spurious message drops. val effectivePort = if (port == -1) defaultPort else port val portSuffix = if (effectivePort == -1) "" else ":$effectivePort" return "$protocol://$host$portSuffix" diff --git a/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/webview/WebViewMessageParser.kt b/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/webview/WebViewMessageParser.kt index e7d69e5a7d..552111bea7 100644 --- a/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/webview/WebViewMessageParser.kt +++ b/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/webview/WebViewMessageParser.kt @@ -11,97 +11,142 @@ import org.json.JSONObject * Parses and validates raw JSON strings received from a `web_view` component into typed * [PaywallWebViewMessage]s before they reach app code. * - * Validation rules (see the RevenueCat `web_view` postMessage protocol): - * - The payload must not exceed [MAX_PAYLOAD_BYTES] and must not nest deeper than [MAX_NESTING_DEPTH]. - * - The body must be a JSON object with a non-empty string `type` and a string `component_id`. - * - `component_id` must equal the expected component id; otherwise the message is rejected. - * - `rc:step-complete` may carry a `responses` JSON object of JSON-compatible values. - * - `rc:error` must carry a string `error`. - * - `rc:step-loaded` and `rc:request-variables` require no extra fields. - * - Unknown message types are dropped for v1. + * Payloads use the `workflow-web-components-sdk` transport envelope (`channel`, + * `protocol_version`, `kind`, `component_id`, …). App-level messages arrive as `kind` + * `message` or `request` with a `type` such as `rc:step-loaded`. * - * Returns `null` for any payload that is too large, malformed, fails validation, targets a different - * component, or has an unknown type. + * Returns `null` for handshake frames, transport errors, malformed payloads, wrong + * `component_id`, or unknown app message types. */ internal object WebViewMessageParser { const val MAX_PAYLOAD_BYTES: Int = 65_536 const val MAX_NESTING_DEPTH: Int = 16 + data class ParsedAppMessage( + val message: PaywallWebViewMessage, + /** Set when the inbound frame is a transport `request` that expects a `response`. */ + val requestId: String? = null, + val requestType: String? = null, + ) + @Suppress("ReturnCount") - fun parse(rawJson: String, expectedComponentId: String): PaywallWebViewMessage? { + fun parse(rawJson: String, expectedComponentId: String): ParsedAppMessage? { if (rawJson.toByteArray(Charsets.UTF_8).size > MAX_PAYLOAD_BYTES) { Logger.w("Dropping web view message: payload exceeds $MAX_PAYLOAD_BYTES bytes.") return null } - val json = try { - JSONObject(rawJson) - } catch (@Suppress("SwallowedException") e: org.json.JSONException) { - Logger.w("Dropping web view message: body is not a JSON object.") + val envelope = WebViewEnvelope.parse(rawJson) ?: run { + Logger.w("Dropping web view message: not a valid transport envelope.") return null } - val type = (json.opt(WebViewMessageField.TYPE) as? String)?.takeIf { it.isNotEmpty() } - if (type == null) { - Logger.w("Dropping web view message: missing or non-string 'type'.") - return null + return when (envelope.kind) { + WebViewEnvelope.KIND_MESSAGE, + WebViewEnvelope.KIND_REQUEST, + -> parseAppMessage(envelope, expectedComponentId) + + else -> null } + } - val componentId = json.opt(WebViewMessageField.COMPONENT_ID) as? String - if (componentId == null) { - Logger.w("Dropping web view message: missing or non-string 'component_id'.") + @Suppress("ReturnCount") + private fun parseAppMessage( + envelope: WebViewEnvelope.Parsed, + expectedComponentId: String, + ): ParsedAppMessage? { + if (envelope.componentId != expectedComponentId) { + Logger.w("Dropping web view message: 'component_id' does not match the rendered web_view.") return null } - if (componentId != expectedComponentId) { - Logger.w("Dropping web view message: 'component_id' does not match the rendered web_view.") + + val type = envelope.type?.takeIf { it.isNotEmpty() } + if (type == null) { + Logger.w("Dropping web view message: missing or non-string 'type'.") return null } - return when (type) { + val message = when (type) { WebViewMessageType.STEP_LOADED, WebViewMessageType.REQUEST_VARIABLES, - -> PaywallWebViewMessage(componentId = componentId, type = type) + -> PaywallWebViewMessage(componentId = envelope.componentId, type = type) WebViewMessageType.STEP_COMPLETE -> { - val responses = parseResponses(json) ?: return null - PaywallWebViewMessage(componentId = componentId, type = type, responses = responses.value) + val responses = parseResponses(envelope.payload) ?: return null + PaywallWebViewMessage( + componentId = envelope.componentId, + type = type, + responses = responses.value, + ) } WebViewMessageType.ERROR -> { - val error = json.opt(WebViewMessageField.ERROR) as? String - if (error == null) { - Logger.w("Dropping web view message: 'rc:error' requires a string 'error'.") - return null - } - PaywallWebViewMessage(componentId = componentId, type = type, error = error) + val error = parseError(envelope) ?: return null + PaywallWebViewMessage(componentId = envelope.componentId, type = type, error = error) } else -> { - // Unknown message types are dropped for protocol_version 1. Logger.d("Dropping web view message: unknown type '$type'.") - null + return null } } + + val requestId = if (envelope.kind == WebViewEnvelope.KIND_REQUEST) envelope.id else null + if (envelope.kind == WebViewEnvelope.KIND_REQUEST && requestId == null) { + Logger.w("Dropping web view message: transport request is missing 'id'.") + return null + } + + return ParsedAppMessage( + message = message, + requestId = requestId, + requestType = type, + ) + } + + private fun parseError(envelope: WebViewEnvelope.Parsed): String? { + val payloadError = envelope.payload?.opt(WebViewMessageField.ERROR) as? String + val error = payloadError ?: envelope.error + if (error == null) { + Logger.w("Dropping web view message: 'rc:error' requires a string 'error'.") + return null + } + return error } /** - * Parses the optional `responses` object of a `rc:step-complete` message. Returns an empty object - * when absent, or `null` when present but malformed (not a JSON object, non-JSON values, or too - * deeply nested), which causes the whole message to be rejected. + * Parses the `responses` object for a `rc:step-complete` message. The responses may live under + * `payload.responses` or directly in `payload` when the content sends only response fields. */ private class ParsedResponses(val value: Map) @Suppress("ReturnCount") - private fun parseResponses(json: JSONObject): ParsedResponses? { - if (!json.has(WebViewMessageField.RESPONSES) || json.isNull(WebViewMessageField.RESPONSES)) { + private fun parseResponses(payload: JSONObject?): ParsedResponses? { + if (payload == null || payload.length() == 0) { return ParsedResponses(emptyMap()) } - val responsesJson = json.opt(WebViewMessageField.RESPONSES) as? JSONObject + + val responsesJson = when { + payload.has(WebViewMessageField.RESPONSES) && !payload.isNull(WebViewMessageField.RESPONSES) -> + payload.opt(WebViewMessageField.RESPONSES) as? JSONObject + + payload.keys().asSequence().any { it in STEP_COMPLETE_RESERVED_PAYLOAD_KEYS } -> { + Logger.w( + "Dropping web view message: 'rc:step-complete' payload contains transport fields " + + "without a 'responses' object.", + ) + return null + } + + else -> payload + } + if (responsesJson == null) { Logger.w("Dropping web view message: 'responses' must be a JSON object.") return null } + val converted = PaywallWebViewValue.fromJson(responsesJson, MAX_NESTING_DEPTH) if (converted !is PaywallWebViewValue.Object) { Logger.w("Dropping web view message: 'responses' contains non-JSON values or is too deeply nested.") @@ -109,4 +154,15 @@ internal object WebViewMessageParser { } return ParsedResponses(converted.value) } + + private val STEP_COMPLETE_RESERVED_PAYLOAD_KEYS: Set = setOf( + WebViewMessageField.CHANNEL, + WebViewMessageField.PROTOCOL_VERSION, + WebViewMessageField.KIND, + WebViewMessageField.TYPE, + WebViewMessageField.COMPONENT_ID, + WebViewMessageField.ID, + WebViewMessageField.ERROR, + WebViewMessageField.VARIABLES, + ) } diff --git a/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/webview/WebViewMessageType.kt b/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/webview/WebViewMessageType.kt index 7df881e96a..728e29878e 100644 --- a/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/webview/WebViewMessageType.kt +++ b/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/webview/WebViewMessageType.kt @@ -20,8 +20,13 @@ internal object WebViewMessageType { * generic `payload` key. */ internal object WebViewMessageField { + const val CHANNEL = "channel" + const val PROTOCOL_VERSION = "protocol_version" + const val KIND = "kind" const val TYPE = "type" const val COMPONENT_ID = "component_id" + const val ID = "id" + const val PAYLOAD = "payload" const val RESPONSES = "responses" const val ERROR = "error" const val VARIABLES = "variables" diff --git a/ui/revenuecatui/src/test/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/webview/WebViewJavaScriptBridgeTest.kt b/ui/revenuecatui/src/test/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/webview/WebViewJavaScriptBridgeTest.kt index 84a5fd1441..50af45e01f 100644 --- a/ui/revenuecatui/src/test/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/webview/WebViewJavaScriptBridgeTest.kt +++ b/ui/revenuecatui/src/test/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/webview/WebViewJavaScriptBridgeTest.kt @@ -36,7 +36,7 @@ internal class WebViewJavaScriptBridgeTest { private fun bridge( handler: PaywallWebViewMessageHandler? = PaywallWebViewMessageHandler { message, _ -> received.add(message) }, locale: String = "en-US", - navigateTo: URL = expectedUrl, + navigateTo: URL? = expectedUrl, ): WebViewJavaScriptBridge { val bridge = WebViewJavaScriptBridge( webView = webView, @@ -46,8 +46,7 @@ internal class WebViewJavaScriptBridgeTest { messageHandler = handler, ) bridge.attach() - // Robolectric's ShadowWebView reports the last loaded URL via getUrl(). - webView.loadUrl(navigateTo.toString()) + navigateTo?.let { webView.loadUrl(it.toString()) } return bridge } @@ -55,9 +54,71 @@ internal class WebViewJavaScriptBridgeTest { shadowOf(Looper.getMainLooper()).idle() } + private fun connect(bridge: WebViewJavaScriptBridge) { + bridge.postMessage( + """ + {"channel":"rc-web-components","protocol_version":1,"kind":"connect","component_id":""} + """.trimIndent(), + ) + idleMainLooper() + } + + private fun appMessage( + type: String, + payload: String? = null, + kind: String = WebViewEnvelope.KIND_MESSAGE, + id: String? = null, + ): String { + val payloadField = payload?.let { ""","payload":$it""" } ?: "" + val idField = id?.let { ""","id":"$it"""" } ?: "" + return """ + {"channel":"rc-web-components","protocol_version":1,"kind":"$kind","component_id":"$componentId","type":"$type"$payloadField$idField} + """.trimIndent() + } + + @Test + fun `completes connect handshake with init`() { + val bridge = bridge() + connect(bridge) + + val script = shadowWebView.lastEvaluatedJavascript + assertThat(script).contains("window.__rcWebComponentsReceive(") + assertThat(script).contains("\"kind\":\"init\"") + assertThat(script).contains("\"component_id\":\"promo_web_view\"") + } + + @Test + fun `completes connect handshake before the web view URL is available`() { + val bridge = bridge(navigateTo = null) + assertThat(webView.url).isNull() + + connect(bridge) + + val script = shadowWebView.lastEvaluatedJavascript + assertThat(script).contains("\"kind\":\"init\"") + assertThat(script).contains("\"component_id\":\"promo_web_view\"") + } + @Test - fun `delivers valid message using the canonical component id`() { - bridge().postMessage("""{"type":"rc:step-loaded","component_id":"promo_web_view"}""") + fun `rejects unsupported protocol version`() { + val bridge = bridge() + bridge.postMessage( + """ + {"channel":"rc-web-components","protocol_version":2,"kind":"connect","component_id":""} + """.trimIndent(), + ) + idleMainLooper() + + val script = shadowWebView.lastEvaluatedJavascript + assertThat(script).contains("\"kind\":\"reject\"") + assertThat(script).contains("Unsupported protocol_version 2") + } + + @Test + fun `delivers valid message after handshake`() { + val bridge = bridge() + connect(bridge) + bridge.postMessage(appMessage(WebViewMessageType.STEP_LOADED)) idleMainLooper() assertThat(received).hasSize(1) @@ -65,9 +126,23 @@ internal class WebViewJavaScriptBridgeTest { assertThat(received.single().type).isEqualTo("rc:step-loaded") } + @Test + fun `ignores app messages before handshake`() { + bridge().postMessage(appMessage(WebViewMessageType.STEP_LOADED)) + idleMainLooper() + + assertThat(received).isEmpty() + } + @Test fun `rejects messages for a different component id`() { - bridge().postMessage("""{"type":"rc:step-loaded","component_id":"other_web_view"}""") + val bridge = bridge() + connect(bridge) + bridge.postMessage( + appMessage( + type = WebViewMessageType.STEP_LOADED, + ).replace(componentId, "other_web_view"), + ) idleMainLooper() assertThat(received).isEmpty() @@ -75,21 +150,41 @@ internal class WebViewJavaScriptBridgeTest { @Test fun `request-variables auto-sends rc variables with locale only`() { - bridge().postMessage("""{"type":"rc:request-variables","component_id":"promo_web_view"}""") + val bridge = bridge() + connect(bridge) + bridge.postMessage(appMessage(WebViewMessageType.REQUEST_VARIABLES)) idleMainLooper() - // The app handler is still notified... assertThat(received.single().type).isEqualTo("rc:request-variables") - // ...and the SDK sent rc:variables back into the web view via the receive hook. val script = shadowWebView.lastEvaluatedJavascript - assertThat(script).contains("window.__revenueCatReceiveMessage(") + assertThat(script).contains("window.__rcWebComponentsReceive(") + assertThat(script).contains("\"kind\":\"message\"") assertThat(script).contains("\"rc:variables\"") assertThat(script).contains("\"component_id\":\"promo_web_view\"") assertThat(script).contains("\"locale\":\"en-US\"") - // Dashboard custom variables are not passed across the bridge. assertThat(script).doesNotContain("\"custom\"") } + @Test + fun `request-variables transport request also sends response`() { + val bridge = bridge() + connect(bridge) + bridge.postMessage( + appMessage( + type = WebViewMessageType.REQUEST_VARIABLES, + kind = WebViewEnvelope.KIND_REQUEST, + id = "req-1", + ), + ) + idleMainLooper() + + val script = shadowWebView.lastEvaluatedJavascript + assertThat(script).contains("\"kind\":\"response\"") + assertThat(script).contains("\"id\":\"req-1\"") + assertThat(script).contains("\"locale\":\"en-US\"") + assertThat(script).doesNotContain("\"rc:variables\"") + } + @Test fun `app can reply with extra variables through the controller`() { val handler = PaywallWebViewMessageHandler { message, controller -> @@ -105,11 +200,11 @@ internal class WebViewJavaScriptBridgeTest { ) } } - bridge(handler = handler) - .postMessage("""{"type":"rc:request-variables","component_id":"promo_web_view"}""") + val bridge = bridge(handler = handler) + connect(bridge) + bridge.postMessage(appMessage(WebViewMessageType.REQUEST_VARIABLES)) idleMainLooper() - // The controller's reply is the most recent script delivered to the web view. val script = shadowWebView.lastEvaluatedJavascript assertThat(script).contains("\"rc:variables\"") assertThat(script).contains("\"app_segment\":\"high_intent\"") @@ -119,8 +214,9 @@ internal class WebViewJavaScriptBridgeTest { fun `controller postVariables drops reserved keys`() { val controllerHolder = arrayOfNulls(1) val handler = PaywallWebViewMessageHandler { _, controller -> controllerHolder[0] = controller } - bridge(handler = handler) - .postMessage("""{"type":"rc:step-loaded","component_id":"promo_web_view"}""") + val bridge = bridge(handler = handler) + connect(bridge) + bridge.postMessage(appMessage(WebViewMessageType.STEP_LOADED)) idleMainLooper() controllerHolder[0]!!.postVariables( @@ -140,9 +236,10 @@ internal class WebViewJavaScriptBridgeTest { @Test fun `does not deliver messages after release`() { val bridge = bridge() + connect(bridge) bridge.release() - bridge.postMessage("""{"type":"rc:step-loaded","component_id":"promo_web_view"}""") + bridge.postMessage(appMessage(WebViewMessageType.STEP_LOADED)) idleMainLooper() assertThat(received).isEmpty() @@ -150,8 +247,9 @@ internal class WebViewJavaScriptBridgeTest { @Test fun `rejects messages after navigation to an unexpected origin`() { - bridge(navigateTo = URL("https://evil.example.org/phish.html")) - .postMessage("""{"type":"rc:step-loaded","component_id":"promo_web_view"}""") + val bridge = bridge(navigateTo = URL("https://evil.example.org/phish.html")) + connect(bridge) + bridge.postMessage(appMessage(WebViewMessageType.STEP_LOADED)) idleMainLooper() assertThat(received).isEmpty() @@ -159,8 +257,9 @@ internal class WebViewJavaScriptBridgeTest { @Test fun `allows messages from the same origin on a different path`() { - bridge(navigateTo = URL("https://assets.example.com/promo/step-two.html")) - .postMessage("""{"type":"rc:step-loaded","component_id":"promo_web_view"}""") + val bridge = bridge(navigateTo = URL("https://assets.example.com/promo/step-two.html")) + connect(bridge) + bridge.postMessage(appMessage(WebViewMessageType.STEP_LOADED)) idleMainLooper() assertThat(received).hasSize(1) @@ -168,8 +267,9 @@ internal class WebViewJavaScriptBridgeTest { @Test fun `does not deliver outbound messages after navigation to an unexpected origin`() { - // Symmetric with the inbound guard: outbound envelopes must not leak into foreign content. - bridge(navigateTo = URL("https://evil.example.org/phish.html")).postMessage( + val bridge = bridge(navigateTo = URL("https://evil.example.org/phish.html")) + connect(bridge) + bridge.postMessage( componentId = componentId, type = "rc:custom", variables = mapOf("foo" to PaywallWebViewValue.String("bar")), @@ -181,8 +281,9 @@ internal class WebViewJavaScriptBridgeTest { @Test fun `treats the default https port as the same origin`() { - // expectedUrl omits the port; navigating to the explicit :443 must still match (inbound + outbound). - bridge(navigateTo = URL("https://assets.example.com:443/promo/index.html")).postMessage( + val bridge = bridge(navigateTo = URL("https://assets.example.com:443/promo/index.html")) + connect(bridge) + bridge.postMessage( componentId = componentId, type = "rc:custom", variables = mapOf("foo" to PaywallWebViewValue.String("bar")), @@ -194,14 +295,14 @@ internal class WebViewJavaScriptBridgeTest { @Test fun `delivers rc step-complete responses to the handler without sending an outbound message`() { - bridge().postMessage( - """ - { - "type":"rc:step-complete", - "component_id":"promo_web_view", - "responses":{"selected_plan":"annual","accepted_terms":true} - } - """.trimIndent(), + val bridge = bridge() + connect(bridge) + val scriptAfterConnect = shadowWebView.lastEvaluatedJavascript + bridge.postMessage( + appMessage( + type = WebViewMessageType.STEP_COMPLETE, + payload = """{"responses":{"selected_plan":"annual","accepted_terms":true}}""", + ), ) idleMainLooper() @@ -209,14 +310,18 @@ internal class WebViewJavaScriptBridgeTest { assertThat(message.type).isEqualTo("rc:step-complete") assertThat(message.responses?.get("selected_plan")).isEqualTo(PaywallWebViewValue.String("annual")) assertThat(message.responses?.get("accepted_terms")).isEqualTo(PaywallWebViewValue.Boolean(true)) - // rc:step-complete must not auto-dismiss or send anything back; the app decides. - assertThat(shadowWebView.lastEvaluatedJavascript).isNull() + assertThat(shadowWebView.lastEvaluatedJavascript).isEqualTo(scriptAfterConnect) } @Test fun `delivers rc error to the handler`() { - bridge().postMessage( - """{"type":"rc:error","component_id":"promo_web_view","error":"Something went wrong"}""", + val bridge = bridge() + connect(bridge) + bridge.postMessage( + appMessage( + type = WebViewMessageType.ERROR, + payload = """{"error":"Something went wrong"}""", + ), ) idleMainLooper() @@ -227,7 +332,9 @@ internal class WebViewJavaScriptBridgeTest { @Test fun `does not deliver malformed messages to the handler`() { - bridge().postMessage("""not even json""") + val bridge = bridge() + connect(bridge) + bridge.postMessage("""not even json""") idleMainLooper() assertThat(received).isEmpty() @@ -235,8 +342,9 @@ internal class WebViewJavaScriptBridgeTest { @Test fun `auto-sends rc variables even when no handler is set`() { - bridge(handler = null) - .postMessage("""{"type":"rc:request-variables","component_id":"promo_web_view"}""") + val bridge = bridge(handler = null) + connect(bridge) + bridge.postMessage(appMessage(WebViewMessageType.REQUEST_VARIABLES)) idleMainLooper() assertThat(received).isEmpty() @@ -248,12 +356,13 @@ internal class WebViewJavaScriptBridgeTest { @Test fun `update refreshes the variables sent on a subsequent request`() { val bridge = bridge(locale = "en-US") + connect(bridge) bridge.update( locale = "fr-FR", messageHandler = null, ) - bridge.postMessage("""{"type":"rc:request-variables","component_id":"promo_web_view"}""") + bridge.postMessage(appMessage(WebViewMessageType.REQUEST_VARIABLES)) idleMainLooper() val script = shadowWebView.lastEvaluatedJavascript @@ -262,8 +371,10 @@ internal class WebViewJavaScriptBridgeTest { } @Test - fun `generic postMessage nests the payload under the variables key`() { - bridge().postMessage( + fun `generic postMessage sends transport envelope with payload`() { + val bridge = bridge() + connect(bridge) + bridge.postMessage( componentId = componentId, type = "rc:custom", variables = mapOf("foo" to PaywallWebViewValue.String("bar")), @@ -271,18 +382,19 @@ internal class WebViewJavaScriptBridgeTest { idleMainLooper() val script = shadowWebView.lastEvaluatedJavascript - assertThat(script).contains("window.__revenueCatReceiveMessage(") + assertThat(script).contains("window.__rcWebComponentsReceive(") + assertThat(script).contains("\"kind\":\"message\"") assertThat(script).contains("\"type\":\"rc:custom\"") assertThat(script).contains("\"component_id\":\"promo_web_view\"") - // The payload is delivered under the `variables` key, never merged into the envelope top level. - assertThat(script).contains("\"variables\":{\"foo\":\"bar\"}") + assertThat(script).contains("\"payload\":{\"foo\":\"bar\"}") } @Test fun `escapes line and paragraph separators in outbound payloads`() { - // U+2028/U+2029 are valid in JSON strings but terminate JS statements; they must be escaped. val raw = "a\u2028b\u2029c" - bridge().postVariables( + val bridge = bridge() + connect(bridge) + bridge.postVariables( componentId = componentId, variables = mapOf( "custom" to PaywallWebViewValue.Object(mapOf("note" to PaywallWebViewValue.String(raw))), @@ -298,32 +410,19 @@ internal class WebViewJavaScriptBridgeTest { } @Test - fun `attach registers a single native interface under the private name`() { + fun `attach registers native interface under rcWebComponents`() { bridge() - assertThat(shadowWebView.getJavascriptInterface("__RevenueCatNativeBridge")).isNotNull - // The public object name is created by the injected shim, not exposed as a native interface. - assertThat(shadowWebView.getJavascriptInterface("RevenueCatWebView")).isNull() - } - - @Test - fun `injectBridgeScript exposes the public surface without overwriting an existing bridge`() { - bridge().injectBridgeScript() - - val script = shadowWebView.lastEvaluatedJavascript - assertThat(script).contains("window.RevenueCatWebView") - assertThat(script).contains("window.__RevenueCatNativeBridge") - // Guard so an existing bridge is not clobbered. - assertThat(script).contains("if (window.RevenueCatWebView)") + assertThat(shadowWebView.getJavascriptInterface("rcWebComponents")).isNotNull } @Test fun `release removes the native interface`() { val bridge = bridge() - assertThat(shadowWebView.getJavascriptInterface("__RevenueCatNativeBridge")).isNotNull + assertThat(shadowWebView.getJavascriptInterface("rcWebComponents")).isNotNull bridge.release() - assertThat(shadowWebView.getJavascriptInterface("__RevenueCatNativeBridge")).isNull() + assertThat(shadowWebView.getJavascriptInterface("rcWebComponents")).isNull() } } diff --git a/ui/revenuecatui/src/test/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/webview/WebViewMessageParserTest.kt b/ui/revenuecatui/src/test/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/webview/WebViewMessageParserTest.kt index ee36066409..333372396b 100644 --- a/ui/revenuecatui/src/test/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/webview/WebViewMessageParserTest.kt +++ b/ui/revenuecatui/src/test/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/webview/WebViewMessageParserTest.kt @@ -13,29 +13,33 @@ internal class WebViewMessageParserTest { @Test fun `parses rc step-loaded`() { - val message = parse("""{"type":"rc:step-loaded","component_id":"promo_web_view"}""") + val parsed = parse( + envelope( + kind = WebViewEnvelope.KIND_MESSAGE, + type = WebViewMessageType.STEP_LOADED, + ), + ) - assertThat(message).isNotNull - assertThat(message!!.type).isEqualTo("rc:step-loaded") + assertThat(parsed).isNotNull + val message = parsed!!.message + assertThat(message.type).isEqualTo("rc:step-loaded") assertThat(message.componentId).isEqualTo("promo_web_view") assertThat(message.responses).isNull() assertThat(message.error).isNull() } @Test - fun `parses rc step-complete with responses`() { - val message = parse( - """ - { - "type":"rc:step-complete", - "component_id":"promo_web_view", - "responses":{"selected_plan":"annual","accepted_terms":true,"count":3} - } - """.trimIndent(), + fun `parses rc step-complete with responses in payload`() { + val parsed = parse( + envelope( + kind = WebViewEnvelope.KIND_MESSAGE, + type = WebViewMessageType.STEP_COMPLETE, + payload = """{"responses":{"selected_plan":"annual","accepted_terms":true,"count":3}}""", + ), ) - assertThat(message).isNotNull - val responses = message!!.responses + assertThat(parsed).isNotNull + val responses = parsed!!.message.responses assertThat(responses).isNotNull assertThat(responses!!["selected_plan"]).isEqualTo(PaywallWebViewValue.String("annual")) assertThat(responses["accepted_terms"]).isEqualTo(PaywallWebViewValue.Boolean(true)) @@ -44,63 +48,150 @@ internal class WebViewMessageParserTest { @Test fun `parses rc step-complete without responses as empty map`() { - val message = parse("""{"type":"rc:step-complete","component_id":"promo_web_view"}""") + val parsed = parse( + envelope( + kind = WebViewEnvelope.KIND_MESSAGE, + type = WebViewMessageType.STEP_COMPLETE, + ), + ) - assertThat(message).isNotNull - assertThat(message!!.responses).isEmpty() + assertThat(parsed).isNotNull + assertThat(parsed!!.message.responses).isEmpty() } @Test - fun `parses rc request-variables`() { - val message = parse("""{"type":"rc:request-variables","component_id":"promo_web_view"}""") + fun `parses rc request-variables as message`() { + val parsed = parse( + envelope( + kind = WebViewEnvelope.KIND_MESSAGE, + type = WebViewMessageType.REQUEST_VARIABLES, + ), + ) - assertThat(message).isNotNull - assertThat(message!!.type).isEqualTo("rc:request-variables") + assertThat(parsed).isNotNull + assertThat(parsed!!.message.type).isEqualTo("rc:request-variables") + assertThat(parsed.requestId).isNull() } @Test - fun `parses rc error`() { - val message = parse("""{"type":"rc:error","component_id":"promo_web_view","error":"Boom"}""") + fun `parses rc request-variables as transport request`() { + val parsed = parse( + envelope( + kind = WebViewEnvelope.KIND_REQUEST, + type = WebViewMessageType.REQUEST_VARIABLES, + id = "req-1", + ), + ) + + assertThat(parsed).isNotNull + assertThat(parsed!!.message.type).isEqualTo("rc:request-variables") + assertThat(parsed.requestId).isEqualTo("req-1") + } + + @Test + fun `parses rc error from payload`() { + val parsed = parse( + envelope( + kind = WebViewEnvelope.KIND_MESSAGE, + type = WebViewMessageType.ERROR, + payload = """{"error":"Boom"}""", + ), + ) - assertThat(message).isNotNull - assertThat(message!!.error).isEqualTo("Boom") + assertThat(parsed).isNotNull + assertThat(parsed!!.message.error).isEqualTo("Boom") } @Test fun `rejects message without type`() { - assertThat(parse("""{"component_id":"promo_web_view"}""")).isNull() + assertThat( + parse( + """{"channel":"rc-web-components","protocol_version":1,"kind":"message","component_id":"promo_web_view"}""", + ), + ).isNull() } @Test fun `rejects message with non-string type`() { - assertThat(parse("""{"type":123,"component_id":"promo_web_view"}""")).isNull() + assertThat( + parse( + """ + {"channel":"rc-web-components","protocol_version":1,"kind":"message","component_id":"promo_web_view","type":123} + """.trimIndent(), + ), + ).isNull() } @Test fun `rejects message without component_id`() { - assertThat(parse("""{"type":"rc:step-loaded"}""")).isNull() + assertThat( + parse( + """ + {"channel":"rc-web-components","protocol_version":1,"kind":"message","type":"rc:step-loaded"} + """.trimIndent(), + ), + ).isNull() } @Test fun `rejects message with wrong component_id`() { - assertThat(parse("""{"type":"rc:step-loaded","component_id":"other_web_view"}""")).isNull() + assertThat( + envelope( + kind = WebViewEnvelope.KIND_MESSAGE, + type = WebViewMessageType.STEP_LOADED, + componentId = "other_web_view", + ).let(::parse), + ).isNull() } @Test fun `rejects rc step-complete with non-object responses`() { assertThat( - parse("""{"type":"rc:step-complete","component_id":"promo_web_view","responses":"nope"}"""), + parse( + envelope( + kind = WebViewEnvelope.KIND_MESSAGE, + type = WebViewMessageType.STEP_COMPLETE, + payload = """{"responses":"nope"}""", + ), + ), + ).isNull() + } + + @Test + fun `rejects rc step-complete payload with transport fields and no responses object`() { + assertThat( + parse( + envelope( + kind = WebViewEnvelope.KIND_MESSAGE, + type = WebViewMessageType.STEP_COMPLETE, + payload = """{"type":"rc:step-complete","selected_plan":"annual"}""", + ), + ), ).isNull() } @Test fun `rejects rc error without error string`() { - assertThat(parse("""{"type":"rc:error","component_id":"promo_web_view"}""")).isNull() + assertThat( + parse( + envelope( + kind = WebViewEnvelope.KIND_MESSAGE, + type = WebViewMessageType.ERROR, + ), + ), + ).isNull() } @Test fun `drops unknown message types`() { - assertThat(parse("""{"type":"rc:something-new","component_id":"promo_web_view"}""")).isNull() + assertThat( + parse( + envelope( + kind = WebViewEnvelope.KIND_MESSAGE, + type = "rc:something-new", + ), + ), + ).isNull() } @Test @@ -116,39 +207,69 @@ internal class WebViewMessageParserTest { @Test fun `rejects oversized payload`() { val hugeValue = "x".repeat(WebViewMessageParser.MAX_PAYLOAD_BYTES + 1) - val raw = """{"type":"rc:error","component_id":"promo_web_view","error":"$hugeValue"}""" + val raw = envelope( + kind = WebViewEnvelope.KIND_MESSAGE, + type = WebViewMessageType.ERROR, + payload = """{"error":"$hugeValue"}""", + ) assertThat(parse(raw)).isNull() } @Test fun `rejects excessively nested responses`() { - // Build responses nested deeper than MAX_NESTING_DEPTH. val depth = WebViewMessageParser.MAX_NESTING_DEPTH + 2 val opening = "{\"a\":".repeat(depth) val closing = "}".repeat(depth) val nested = opening + "1" + closing - val raw = """{"type":"rc:step-complete","component_id":"promo_web_view","responses":$nested}""" + val raw = envelope( + kind = WebViewEnvelope.KIND_MESSAGE, + type = WebViewMessageType.STEP_COMPLETE, + payload = """{"responses":$nested}""", + ) assertThat(parse(raw)).isNull() } @Test fun `accepts null and nested json-compatible values in responses`() { - val message = parse( - """ - { - "type":"rc:step-complete", - "component_id":"promo_web_view", - "responses":{"maybe":null,"list":[1,"two",false],"obj":{"k":"v"}} - } - """.trimIndent(), + val parsed = parse( + envelope( + kind = WebViewEnvelope.KIND_MESSAGE, + type = WebViewMessageType.STEP_COMPLETE, + payload = """{"responses":{"maybe":null,"list":[1,"two",false],"obj":{"k":"v"}}}""", + ), ) - assertThat(message).isNotNull - val responses = message!!.responses!! + assertThat(parsed).isNotNull + val responses = parsed!!.message.responses!! assertThat(responses["maybe"]).isEqualTo(PaywallWebViewValue.Null) assertThat(responses["list"]).isInstanceOf(PaywallWebViewValue.Array::class.java) assertThat(responses["obj"]).isInstanceOf(PaywallWebViewValue.Object::class.java) } + @Test + fun `ignores handshake frames`() { + assertThat( + parse( + """ + {"channel":"rc-web-components","protocol_version":1,"kind":"connect","component_id":""} + """.trimIndent(), + ), + ).isNull() + } + private fun parse(raw: String) = WebViewMessageParser.parse(raw, expectedComponentId = componentId) + + private fun envelope( + kind: String, + type: String, + componentId: String = this.componentId, + payload: String? = null, + id: String? = null, + ): String { + val payloadField = payload?.let { ""","payload":$it""" } ?: "" + val idField = id?.let { ""","id":"$it"""" } ?: "" + return """ + {"channel":"rc-web-components","protocol_version":1,"kind":"$kind","component_id":"$componentId","type":"$type"$payloadField$idField} + """.trimIndent() + } } From 6bccde240d457a79b55f72a89e05cf936da27fe8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=81lvaro=20Brey?= Date: Mon, 6 Jul 2026 17:01:22 +0200 Subject: [PATCH 09/13] Size web_view height to content via fit/resize bridge messages. --- .../revenuecatui/components/ComponentView.kt | 2 + .../components/style/StyleFactory.kt | 10 +- .../components/style/WebViewComponentStyle.kt | 7 + .../webview/WebViewComponentView.kt | 136 +++++++++++++++--- .../webview/WebViewJavaScriptBridge.kt | 36 +++++ .../components/webview/WebViewMessageType.kt | 6 + .../webview/WebViewJavaScriptBridgeTest.kt | 42 ++++++ 7 files changed, 218 insertions(+), 21 deletions(-) diff --git a/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/ComponentView.kt b/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/ComponentView.kt index 254629551a..9f393b6b8f 100644 --- a/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/ComponentView.kt +++ b/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/ComponentView.kt @@ -79,6 +79,8 @@ internal fun ComponentView( style = style, state = state, modifier = modifier, + onClick = onClick, + componentInteractionTracker = componentInteractionTracker, ) is ButtonComponentStyle -> ButtonComponentView( style = style, diff --git a/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/style/StyleFactory.kt b/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/style/StyleFactory.kt index c2c4fc8365..881b7e7c0e 100644 --- a/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/style/StyleFactory.kt +++ b/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/style/StyleFactory.kt @@ -581,17 +581,19 @@ internal class StyleFactory( } } - private fun createWebViewComponentStyle( + private fun StyleFactoryScope.createWebViewComponentStyle( component: WebViewComponent, ): Result> = - Result.Success( + component.fallback?.let { createStackComponentStyle(it) }.orSuccessfullyNull().map { fallbackStyle -> WebViewComponentStyle( urlTemplate = component.url, visible = component.visible ?: DEFAULT_VISIBILITY, size = component.size, + componentId = component.id, protocolVersion = component.protocolVersion, - ), - ) + fallbackStackComponentStyle = fallbackStyle, + ) + } private fun StyleFactoryScope.createCountdownComponentStyle( component: CountdownComponent, diff --git a/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/style/WebViewComponentStyle.kt b/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/style/WebViewComponentStyle.kt index 9b4cb357a0..0539dda8d4 100644 --- a/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/style/WebViewComponentStyle.kt +++ b/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/style/WebViewComponentStyle.kt @@ -10,10 +10,17 @@ internal data class WebViewComponentStyle( val urlTemplate: String, override val visible: Boolean, override val size: Size, + /** + * The canonical component id from the schema (`web_view.id`), used to scope bidirectional + * messages. May be null for legacy/partial configs that omit it, in which case the message bridge + * is not installed. + */ + val componentId: String? = null, /** * The schema-declared `protocol_version`. When present, the SDK isolates the web content from * external sources by enforcing a fixed Content-Security-Policy. `null` for legacy/partial configs * that omit it, in which case no policy is enforced. */ val protocolVersion: Int? = null, + val fallbackStackComponentStyle: StackComponentStyle? = null, ) : ComponentStyle diff --git a/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/webview/WebViewComponentView.kt b/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/webview/WebViewComponentView.kt index 0d5780a386..a4a4fd00ac 100644 --- a/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/webview/WebViewComponentView.kt +++ b/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/webview/WebViewComponentView.kt @@ -4,66 +4,149 @@ package com.revenuecat.purchases.ui.revenuecatui.components.webview import android.graphics.Bitmap import android.graphics.Color +import android.webkit.WebResourceError import android.webkit.WebResourceRequest +import android.webkit.WebResourceResponse import android.webkit.WebSettings import android.webkit.WebView import android.webkit.WebViewClient import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue import androidx.compose.runtime.key +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.viewinterop.AndroidView +import com.revenuecat.purchases.paywalls.components.properties.Size +import com.revenuecat.purchases.paywalls.components.properties.SizeConstraint +import com.revenuecat.purchases.paywalls.components.properties.SizeConstraint.Fill +import com.revenuecat.purchases.paywalls.components.properties.SizeConstraint.Fit +import com.revenuecat.purchases.paywalls.components.properties.SizeConstraint.Fixed +import com.revenuecat.purchases.ui.revenuecatui.components.PaywallAction import com.revenuecat.purchases.ui.revenuecatui.components.modifier.size +import com.revenuecat.purchases.ui.revenuecatui.components.stack.StackComponentView import com.revenuecat.purchases.ui.revenuecatui.components.style.WebViewComponentStyle import com.revenuecat.purchases.ui.revenuecatui.data.PaywallState +import com.revenuecat.purchases.ui.revenuecatui.helpers.PaywallComponentInteractionTracker @JvmSynthetic @Composable +@Suppress("LongMethod") internal fun WebViewComponentView( style: WebViewComponentStyle, state: PaywallState.Loaded.Components, modifier: Modifier = Modifier, + onClick: suspend (PaywallAction) -> Unit = {}, + componentInteractionTracker: PaywallComponentInteractionTracker = PaywallComponentInteractionTracker { _ -> }, ) { if (!style.visible) return - // Key on state.locale (a derivedState over the paywall's mutable locale) as well: a locale change - // mutates the same PaywallState instance in place, so without it the resolved URL — and the - // key(resolvedUrl) below — would stay stale for a locale-dependent template. val resolvedUrl = remember(style.urlTemplate, state, state.locale) { WebViewUrlResolver.resolve(style.urlTemplate, state) } - // The web view URL is missing or did not resolve to a valid HTTPS URL with a host. web_view - // availability is gated by SDK version on the frontend, so a delivered web_view is expected to - // always resolve; render nothing rather than crashing if it doesn't. if (resolvedUrl == null) return - // For v1, the presence of a protocol_version means the web content is isolated from external - // sources via a fixed Content-Security-Policy. Legacy configs without it get no policy. + val componentId = style.componentId val enforceContentSecurityPolicy = style.protocolVersion != null + val locale = state.locale.toLanguageTag() + val sizeToContentWidth = style.size.width is Fit + val sizeToContentHeight = style.size.height is Fit + + var contentWidthCssPx by remember(resolvedUrl) { mutableIntStateOf(0) } + var contentHeightCssPx by remember(resolvedUrl) { mutableIntStateOf(0) } + var loadFailed by remember(resolvedUrl) { mutableStateOf(false) } + val bridgeHolder = remember { WebViewBridgeHolder() } + + val effectiveSize = remember(style.size, contentWidthCssPx, contentHeightCssPx) { + webViewEffectiveSize( + declaredSize = style.size, + contentWidthCssPx = contentWidthCssPx, + contentHeightCssPx = contentHeightCssPx, + ) + } + + val fallbackStyle = style.fallbackStackComponentStyle + if (loadFailed && fallbackStyle != null) { + StackComponentView( + style = fallbackStyle, + state = state, + clickHandler = onClick, + componentInteractionTracker = componentInteractionTracker, + modifier = modifier.size(effectiveSize), + ) + return + } - // Key on the resolved URL so the WebView is created (and the page loaded) exactly once per intended - // URL. We deliberately do NOT reload on every recomposition: in-page navigation changes WebView.url, - // and reloading whenever it differs from resolvedUrl would reset a multi-step web flow. The WebView - // is only recreated when the SDK-resolved URL itself changes (e.g. a locale-dependent template). key(resolvedUrl) { AndroidView( factory = { context -> WebView(context).apply { - configure(enforceContentSecurityPolicy) + val bridge = componentId?.let { id -> + WebViewJavaScriptBridge( + webView = this, + componentId = id, + expectedUrl = resolvedUrl, + locale = locale, + messageHandler = null, + protocolVersion = style.protocolVersion ?: WebViewEnvelope.DEFAULT_PROTOCOL_VERSION, + sizeToContentWidth = sizeToContentWidth, + sizeToContentHeight = sizeToContentHeight, + onContentResize = { widthCssPx, heightCssPx -> + widthCssPx?.takeIf { it > 0 }?.let { contentWidthCssPx = it } + heightCssPx?.takeIf { it > 0 }?.let { contentHeightCssPx = it } + }, + ).also { createdBridge -> createdBridge.attach() } + } + bridgeHolder.bridge = bridge + configure( + enforceContentSecurityPolicy = enforceContentSecurityPolicy, + onMainFrameLoadFailed = { loadFailed = true }, + ) loadUrl(resolvedUrl.toString()) } }, + update = { + bridgeHolder.bridge?.update(locale = locale, messageHandler = null) + }, onRelease = { webView -> + bridgeHolder.bridge?.release() + bridgeHolder.bridge = null webView.stopLoading() webView.webViewClient = WebViewClient() webView.destroy() }, - modifier = modifier.size(style.size), + modifier = modifier.size(effectiveSize), ) } } -private fun WebView.configure(enforceContentSecurityPolicy: Boolean) { +private fun webViewEffectiveSize( + declaredSize: Size, + contentWidthCssPx: Int, + contentHeightCssPx: Int, +): Size { + val width = when (val declaredWidth = declaredSize.width) { + is Fit -> if (contentWidthCssPx > 0) Fixed(contentWidthCssPx.toUInt()) else declaredWidth + else -> declaredWidth + } + val height = when (val declaredHeight = declaredSize.height) { + is Fit -> if (contentHeightCssPx > 0) Fixed(contentHeightCssPx.toUInt()) else declaredHeight + else -> declaredHeight + } + return Size(width = width, height = height) +} + +/** Mutable holder for the per-WebView bridge instance, shared across factory/update/onRelease. */ +private class WebViewBridgeHolder { + var bridge: WebViewJavaScriptBridge? = null +} + +private fun WebView.configure( + enforceContentSecurityPolicy: Boolean, + onMainFrameLoadFailed: () -> Unit, +) { setBackgroundColor(Color.TRANSPARENT) isVerticalScrollBarEnabled = false isHorizontalScrollBarEnabled = false @@ -77,8 +160,6 @@ private fun WebView.configure(enforceContentSecurityPolicy: Boolean) { webViewClient = object : WebViewClient() { override fun onPageStarted(view: WebView, url: String?, favicon: Bitmap?) { super.onPageStarted(view, url, favicon) - // Install the isolation Content-Security-Policy before any of the page's own resources or - // scripts run. if (enforceContentSecurityPolicy) { view.evaluateJavascript(contentSecurityPolicyMetaScript(), null) } @@ -87,7 +168,28 @@ private fun WebView.configure(enforceContentSecurityPolicy: Boolean) { override fun shouldOverrideUrlLoading(view: WebView, request: WebResourceRequest): Boolean { return request.url.scheme != HTTPS_SCHEME } + + override fun onReceivedError( + view: WebView, + request: WebResourceRequest, + error: WebResourceError, + ) { + if (request.isForMainFrame) { + onMainFrameLoadFailed() + } + } + + override fun onReceivedHttpError( + view: WebView, + request: WebResourceRequest, + errorResponse: WebResourceResponse, + ) { + if (request.isForMainFrame && errorResponse.statusCode >= HTTP_ERROR_STATUS_MIN) { + onMainFrameLoadFailed() + } + } } } private const val HTTPS_SCHEME = "https" +private const val HTTP_ERROR_STATUS_MIN = 400 diff --git a/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/webview/WebViewJavaScriptBridge.kt b/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/webview/WebViewJavaScriptBridge.kt index c362a6b2b5..db0216e82e 100644 --- a/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/webview/WebViewJavaScriptBridge.kt +++ b/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/webview/WebViewJavaScriptBridge.kt @@ -47,6 +47,9 @@ internal class WebViewJavaScriptBridge( locale: String, messageHandler: PaywallWebViewMessageHandler?, protocolVersion: Int = WebViewEnvelope.DEFAULT_PROTOCOL_VERSION, + private val sizeToContentWidth: Boolean = false, + private val sizeToContentHeight: Boolean = false, + private val onContentResize: (widthCssPx: Int?, heightCssPx: Int?) -> Unit = { _, _ -> }, private val mainHandler: Handler = Handler(Looper.getMainLooper()), ) : PaywallWebViewController { @@ -170,12 +173,36 @@ internal class WebViewJavaScriptBridge( componentId = componentId, ), ) + sendFitIfNeeded() + } + + private fun sendFitIfNeeded() { + if (!sizeToContentWidth && !sizeToContentHeight) return + val payload = JSONObject().apply { + if (sizeToContentWidth) put("width", true) + if (sizeToContentHeight) put("height", true) + } + deliverEnvelope( + WebViewEnvelope.build( + kind = WebViewEnvelope.KIND_MESSAGE, + protocolVersion = protocolVersion, + componentId = componentId, + type = WebViewMessageType.FIT, + payload = payload, + ), + ) } @MainThread private fun handleAppFrame(rawJson: String) { if (!channelOpen) return + val transport = WebViewEnvelope.parse(rawJson) ?: return + if (transport.kind == WebViewEnvelope.KIND_MESSAGE && transport.type == WebViewMessageType.RESIZE) { + handleResize(transport) + return + } + val parsed = WebViewMessageParser.parse( rawJson = rawJson, expectedComponentId = componentId, @@ -205,6 +232,15 @@ internal class WebViewJavaScriptBridge( messageHandler?.onMessage(message, this) } + @MainThread + private fun handleResize(envelope: WebViewEnvelope.Parsed) { + if (envelope.componentId != componentId) return + val payload = envelope.payload ?: return + val width = payload.optInt("width").takeIf { payload.has("width") } + val height = payload.optInt("height").takeIf { payload.has("height") } + onContentResize(width, height) + } + override fun postVariables(componentId: String, variables: Map) { postVariablesMessage( componentId = componentId, diff --git a/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/webview/WebViewMessageType.kt b/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/webview/WebViewMessageType.kt index 728e29878e..ae19c0db30 100644 --- a/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/webview/WebViewMessageType.kt +++ b/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/webview/WebViewMessageType.kt @@ -12,6 +12,12 @@ internal object WebViewMessageType { const val REQUEST_VARIABLES = "rc:request-variables" const val ERROR = "rc:error" const val VARIABLES = "rc:variables" + + /** Host → content: which axes the native host sizes to the content (`fit`). */ + const val FIT = "fit" + + /** Content → host: reported content box size in CSS pixels. */ + const val RESIZE = "resize" } /** diff --git a/ui/revenuecatui/src/test/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/webview/WebViewJavaScriptBridgeTest.kt b/ui/revenuecatui/src/test/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/webview/WebViewJavaScriptBridgeTest.kt index 50af45e01f..ea5eeadeee 100644 --- a/ui/revenuecatui/src/test/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/webview/WebViewJavaScriptBridgeTest.kt +++ b/ui/revenuecatui/src/test/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/webview/WebViewJavaScriptBridgeTest.kt @@ -37,6 +37,9 @@ internal class WebViewJavaScriptBridgeTest { handler: PaywallWebViewMessageHandler? = PaywallWebViewMessageHandler { message, _ -> received.add(message) }, locale: String = "en-US", navigateTo: URL? = expectedUrl, + sizeToContentWidth: Boolean = false, + sizeToContentHeight: Boolean = false, + onContentResize: (widthCssPx: Int?, heightCssPx: Int?) -> Unit = { _, _ -> }, ): WebViewJavaScriptBridge { val bridge = WebViewJavaScriptBridge( webView = webView, @@ -44,6 +47,9 @@ internal class WebViewJavaScriptBridgeTest { expectedUrl = expectedUrl, locale = locale, messageHandler = handler, + sizeToContentWidth = sizeToContentWidth, + sizeToContentHeight = sizeToContentHeight, + onContentResize = onContentResize, ) bridge.attach() navigateTo?.let { webView.loadUrl(it.toString()) } @@ -425,4 +431,40 @@ internal class WebViewJavaScriptBridgeTest { assertThat(shadowWebView.getJavascriptInterface("rcWebComponents")).isNull() } + + @Test + fun `sends fit message after init when height is fit`() { + val bridge = bridge(sizeToContentHeight = true) + connect(bridge) + + val script = shadowWebView.lastEvaluatedJavascript + assertThat(script).contains("\"type\":\"fit\"") + assertThat(script).contains("\"height\":true") + assertThat(script).doesNotContain("\"width\":true") + } + + @Test + fun `reports content resize from inbound resize message`() { + var reportedWidth: Int? = null + var reportedHeight: Int? = null + val bridge = bridge( + handler = null, + onContentResize = { widthCssPx, heightCssPx -> + reportedWidth = widthCssPx + reportedHeight = heightCssPx + }, + ) + connect(bridge) + bridge.postMessage( + appMessage( + type = WebViewMessageType.RESIZE, + payload = """{"width":320,"height":480}""", + ), + ) + idleMainLooper() + + assertThat(reportedWidth).isEqualTo(320) + assertThat(reportedHeight).isEqualTo(480) + assertThat(received).isEmpty() + } } From 0fcfd74bdb1c208eb6cc8fe413b823d1035ffd54 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=81lvaro=20Brey?= Date: Mon, 6 Jul 2026 17:01:22 +0200 Subject: [PATCH 10/13] Add web_view fallback stack schema and render it on load failure. --- .../purchases/paywalls/components/WebViewComponent.kt | 3 +++ .../paywalls/components/WebViewComponentTests.kt | 8 +++++++- 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/purchases/src/main/kotlin/com/revenuecat/purchases/paywalls/components/WebViewComponent.kt b/purchases/src/main/kotlin/com/revenuecat/purchases/paywalls/components/WebViewComponent.kt index 69f4204b2d..a61204d55a 100644 --- a/purchases/src/main/kotlin/com/revenuecat/purchases/paywalls/components/WebViewComponent.kt +++ b/purchases/src/main/kotlin/com/revenuecat/purchases/paywalls/components/WebViewComponent.kt @@ -5,6 +5,7 @@ import com.revenuecat.purchases.InternalRevenueCatAPI import com.revenuecat.purchases.paywalls.components.properties.Size import com.revenuecat.purchases.paywalls.components.properties.SizeConstraint import com.revenuecat.purchases.paywalls.components.properties.SizeConstraint.Fill +import com.revenuecat.purchases.paywalls.components.StackComponent import dev.drewhamilton.poko.Poko import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable @@ -36,4 +37,6 @@ public class WebViewComponent( public val protocolVersion: Int? = null, @get:JvmSynthetic public val size: Size = Size(width = Fill, height = SizeConstraint.Fit), + @get:JvmSynthetic + public val fallback: StackComponent? = null, ) : PaywallComponent diff --git a/purchases/src/test/java/com/revenuecat/purchases/paywalls/components/WebViewComponentTests.kt b/purchases/src/test/java/com/revenuecat/purchases/paywalls/components/WebViewComponentTests.kt index 0a06f40fa6..3f810986c0 100644 --- a/purchases/src/test/java/com/revenuecat/purchases/paywalls/components/WebViewComponentTests.kt +++ b/purchases/src/test/java/com/revenuecat/purchases/paywalls/components/WebViewComponentTests.kt @@ -23,7 +23,11 @@ class WebViewComponentTests { "height": { "type": "fit" } }, "visible": true, - "name": "Promo web component" + "name": "Promo web component", + "fallback": { + "type": "stack", + "components": [] + } } """ @@ -40,6 +44,8 @@ class WebViewComponentTests { assertThat(webView.protocolVersion).isEqualTo(1) assertThat(webView.url).isEqualTo("https://assets.pawwalls.com/web_bundles/123/index.html") assertThat(webView.size).isEqualTo(Size(width = SizeConstraint.Fill, height = SizeConstraint.Fit)) + assertThat(webView.fallback).isNotNull + assertThat(webView.fallback?.components).isEmpty() } @Test From a1da0e451c7f2cba71b703e6549555620da02ba7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=81lvaro=20Brey?= Date: Tue, 7 Jul 2026 15:52:53 +0200 Subject: [PATCH 11/13] Fix detekt violations in web_view components --- .../paywalls/components/WebViewComponent.kt | 1 - .../components/webview/WebViewComponentView.kt | 4 +--- .../components/webview/WebViewEnvelope.kt | 5 +++-- .../components/webview/WebViewJavaScriptBridge.kt | 12 +++++++++--- .../components/webview/WebViewMessageParser.kt | 2 +- 5 files changed, 14 insertions(+), 10 deletions(-) diff --git a/purchases/src/main/kotlin/com/revenuecat/purchases/paywalls/components/WebViewComponent.kt b/purchases/src/main/kotlin/com/revenuecat/purchases/paywalls/components/WebViewComponent.kt index a61204d55a..a9a90d04fe 100644 --- a/purchases/src/main/kotlin/com/revenuecat/purchases/paywalls/components/WebViewComponent.kt +++ b/purchases/src/main/kotlin/com/revenuecat/purchases/paywalls/components/WebViewComponent.kt @@ -5,7 +5,6 @@ import com.revenuecat.purchases.InternalRevenueCatAPI import com.revenuecat.purchases.paywalls.components.properties.Size import com.revenuecat.purchases.paywalls.components.properties.SizeConstraint import com.revenuecat.purchases.paywalls.components.properties.SizeConstraint.Fill -import com.revenuecat.purchases.paywalls.components.StackComponent import dev.drewhamilton.poko.Poko import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable diff --git a/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/webview/WebViewComponentView.kt b/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/webview/WebViewComponentView.kt index a4a4fd00ac..3328444183 100644 --- a/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/webview/WebViewComponentView.kt +++ b/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/webview/WebViewComponentView.kt @@ -20,8 +20,6 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.viewinterop.AndroidView import com.revenuecat.purchases.paywalls.components.properties.Size -import com.revenuecat.purchases.paywalls.components.properties.SizeConstraint -import com.revenuecat.purchases.paywalls.components.properties.SizeConstraint.Fill import com.revenuecat.purchases.paywalls.components.properties.SizeConstraint.Fit import com.revenuecat.purchases.paywalls.components.properties.SizeConstraint.Fixed import com.revenuecat.purchases.ui.revenuecatui.components.PaywallAction @@ -33,7 +31,7 @@ import com.revenuecat.purchases.ui.revenuecatui.helpers.PaywallComponentInteract @JvmSynthetic @Composable -@Suppress("LongMethod") +@Suppress("LongMethod", "ReturnCount") internal fun WebViewComponentView( style: WebViewComponentStyle, state: PaywallState.Loaded.Components, diff --git a/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/webview/WebViewEnvelope.kt b/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/webview/WebViewEnvelope.kt index a9b1eb2678..5192bb25fb 100644 --- a/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/webview/WebViewEnvelope.kt +++ b/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/webview/WebViewEnvelope.kt @@ -36,7 +36,7 @@ internal object WebViewEnvelope { KIND_ERROR, ) - data class Parsed( + internal data class Parsed( val kind: String, val protocolVersion: Int, val componentId: String, @@ -46,7 +46,7 @@ internal object WebViewEnvelope { val error: String?, ) - @Suppress("ReturnCount") + @Suppress("ReturnCount", "CyclomaticComplexMethod") fun parse(rawJson: String): Parsed? { val json = try { JSONObject(rawJson) @@ -90,6 +90,7 @@ internal object WebViewEnvelope { ) } + @Suppress("LongParameterList") fun build( kind: String, protocolVersion: Int, diff --git a/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/webview/WebViewJavaScriptBridge.kt b/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/webview/WebViewJavaScriptBridge.kt index db0216e82e..e9b70be13f 100644 --- a/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/webview/WebViewJavaScriptBridge.kt +++ b/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/webview/WebViewJavaScriptBridge.kt @@ -114,7 +114,10 @@ internal class WebViewJavaScriptBridge( val webView = webViewRef.get() ?: return if (json.toByteArray(Charsets.UTF_8).size > WebViewMessageParser.MAX_PAYLOAD_BYTES) { - Logger.w("Dropping inbound web view message: payload exceeds ${WebViewMessageParser.MAX_PAYLOAD_BYTES} bytes.") + Logger.w( + "Dropping inbound web view message: payload exceeds " + + "${WebViewMessageParser.MAX_PAYLOAD_BYTES} bytes.", + ) return } @@ -127,7 +130,8 @@ internal class WebViewJavaScriptBridge( WebViewEnvelope.KIND_CONNECT -> { if (!isOriginTrusted(webView, allowBeforeNavigation = true)) { Logger.w( - "Dropping inbound web view connect: current origin does not match the resolved component origin.", + "Dropping inbound web view connect: current origin does not match the " + + "resolved component origin.", ) return } @@ -138,7 +142,8 @@ internal class WebViewJavaScriptBridge( -> { if (!isOriginTrusted(webView, allowBeforeNavigation = false)) { Logger.w( - "Dropping inbound web view message: current origin does not match the resolved component origin.", + "Dropping inbound web view message: current origin does not match the " + + "resolved component origin.", ) return } @@ -194,6 +199,7 @@ internal class WebViewJavaScriptBridge( } @MainThread + @Suppress("ReturnCount") private fun handleAppFrame(rawJson: String) { if (!channelOpen) return diff --git a/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/webview/WebViewMessageParser.kt b/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/webview/WebViewMessageParser.kt index 552111bea7..6e36e5e8dd 100644 --- a/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/webview/WebViewMessageParser.kt +++ b/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/webview/WebViewMessageParser.kt @@ -23,7 +23,7 @@ internal object WebViewMessageParser { const val MAX_PAYLOAD_BYTES: Int = 65_536 const val MAX_NESTING_DEPTH: Int = 16 - data class ParsedAppMessage( + internal data class ParsedAppMessage( val message: PaywallWebViewMessage, /** Set when the inbound frame is a transport `request` that expects a `response`. */ val requestId: String? = null, From d1015825b3f052c3b791f3389f4505111a983119 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=81lvaro=20Brey?= Date: Mon, 6 Jul 2026 17:07:13 +0200 Subject: [PATCH 12/13] Wire PaywallOptions web view message handler through paywall state. --- ui/revenuecatui/api.txt | 3 ++ .../ui/revenuecatui/PaywallOptions.kt | 23 ++++++++++ .../webview/WebViewComponentView.kt | 8 +++- .../ui/revenuecatui/data/PaywallState.kt | 19 +++++++- .../ui/revenuecatui/data/PaywallViewModel.kt | 19 ++++++++ .../helpers/OfferingToStateMapper.kt | 3 ++ .../PaywallOptionsWebViewHandlerTest.kt | 44 +++++++++++++++++++ .../components/style/StyleFactoryTests.kt | 1 + .../revenuecatui/data/PaywallViewModelTest.kt | 39 ++++++++++++++++ .../data/PaywallViewModelWorkflowTest.kt | 33 ++++++++++++++ 10 files changed, 189 insertions(+), 3 deletions(-) create mode 100644 ui/revenuecatui/src/test/kotlin/com/revenuecat/purchases/ui/revenuecatui/PaywallOptionsWebViewHandlerTest.kt diff --git a/ui/revenuecatui/api.txt b/ui/revenuecatui/api.txt index 68b18aa318..c2b06c367d 100644 --- a/ui/revenuecatui/api.txt +++ b/ui/revenuecatui/api.txt @@ -96,11 +96,13 @@ package com.revenuecat.purchases.ui.revenuecatui { method public com.revenuecat.purchases.ui.revenuecatui.fonts.FontProvider? getFontProvider(); method public com.revenuecat.purchases.ui.revenuecatui.PaywallListener? getListener(); method public com.revenuecat.purchases.ui.revenuecatui.PaywallPurchaseLogic? getPurchaseLogic(); + method public com.revenuecat.purchases.ui.revenuecatui.PaywallWebViewMessageHandler? getWebViewMessageHandler(); property public final java.util.Map customVariables; property public final kotlin.jvm.functions.Function0 dismissRequest; property public final com.revenuecat.purchases.ui.revenuecatui.fonts.FontProvider? fontProvider; property public final com.revenuecat.purchases.ui.revenuecatui.PaywallListener? listener; property public final com.revenuecat.purchases.ui.revenuecatui.PaywallPurchaseLogic? purchaseLogic; + property public final com.revenuecat.purchases.ui.revenuecatui.PaywallWebViewMessageHandler? webViewMessageHandler; field public static final com.revenuecat.purchases.ui.revenuecatui.PaywallOptions.Companion Companion; } @@ -113,6 +115,7 @@ package com.revenuecat.purchases.ui.revenuecatui { method public com.revenuecat.purchases.ui.revenuecatui.PaywallOptions.Builder setOffering(com.revenuecat.purchases.Offering? offering); method public com.revenuecat.purchases.ui.revenuecatui.PaywallOptions.Builder setPurchaseLogic(com.revenuecat.purchases.ui.revenuecatui.PaywallPurchaseLogic? purchaseLogic); method public com.revenuecat.purchases.ui.revenuecatui.PaywallOptions.Builder setShouldDisplayDismissButton(boolean shouldDisplayDismissButton); + method public com.revenuecat.purchases.ui.revenuecatui.PaywallOptions.Builder setWebViewMessageHandler(com.revenuecat.purchases.ui.revenuecatui.PaywallWebViewMessageHandler? handler); } public static final class PaywallOptions.Companion { diff --git a/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/PaywallOptions.kt b/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/PaywallOptions.kt index 84d05cee2e..c692d7b9d5 100644 --- a/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/PaywallOptions.kt +++ b/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/PaywallOptions.kt @@ -60,6 +60,11 @@ public class PaywallOptions internal constructor( * `{{ $custom.key }}` placeholders in the paywall configuration. */ public val customVariables: Map = emptyMap(), + /** + * Handler for messages sent by Paywalls V2 `web_view` components. Lets app code receive validated + * messages and reply to them. See [PaywallWebViewMessageHandler]. + */ + public val webViewMessageHandler: PaywallWebViewMessageHandler? = null, internal val injectedWorkflow: WorkflowDataResult? = null, internal val injectedWorkflowUiConfig: UiConfig = UiConfig(), ) { @@ -77,6 +82,7 @@ public class PaywallOptions internal constructor( dismissRequest = builder.dismissRequest, dismissRequestWithExitOffering = builder.dismissRequestWithExitOffering, customVariables = builder.customVariables, + webViewMessageHandler = builder.webViewMessageHandler, injectedWorkflow = builder.injectedWorkflow, injectedWorkflowUiConfig = builder.injectedWorkflowUiConfig, ) @@ -106,6 +112,7 @@ public class PaywallOptions internal constructor( this.purchaseLogic != other.purchaseLogic -> false this.mode != other.mode -> false this.customVariables != other.customVariables -> false + this.webViewMessageHandler != other.webViewMessageHandler -> false this.injectedWorkflow != other.injectedWorkflow -> false this.injectedWorkflowUiConfig != other.injectedWorkflowUiConfig -> false else -> this.dismissRequest == other.dismissRequest @@ -122,6 +129,7 @@ public class PaywallOptions internal constructor( dismissRequest: () -> Unit = this.dismissRequest, dismissRequestWithExitOffering: ((Offering?, PaywallResult?) -> Unit)? = this.dismissRequestWithExitOffering, customVariables: Map = this.customVariables, + webViewMessageHandler: PaywallWebViewMessageHandler? = this.webViewMessageHandler, injectedWorkflow: WorkflowDataResult? = this.injectedWorkflow, injectedWorkflowUiConfig: UiConfig = this.injectedWorkflowUiConfig, ): PaywallOptions = PaywallOptions( @@ -134,6 +142,7 @@ public class PaywallOptions internal constructor( dismissRequest = dismissRequest, dismissRequestWithExitOffering = dismissRequestWithExitOffering, customVariables = customVariables, + webViewMessageHandler = webViewMessageHandler, injectedWorkflow = injectedWorkflow, injectedWorkflowUiConfig = injectedWorkflowUiConfig, ) @@ -150,6 +159,7 @@ public class PaywallOptions internal constructor( internal var mode: PaywallMode = PaywallMode.default internal var dismissRequestWithExitOffering: ((Offering?, PaywallResult?) -> Unit)? = null internal var customVariables: Map = emptyMap() + internal var webViewMessageHandler: PaywallWebViewMessageHandler? = null internal var injectedWorkflow: WorkflowDataResult? = null internal var injectedWorkflowUiConfig: UiConfig = UiConfig() @@ -213,6 +223,19 @@ public class PaywallOptions internal constructor( this.customVariables = CustomVariableKeyValidator.validateAndFilter(variables) } + /** + * Sets a handler for messages sent by Paywalls V2 `web_view` components. The handler receives + * validated messages (such as `rc:step-complete`, `rc:request-variables`, and `rc:error`) on + * the main thread, along with a [PaywallWebViewController] for replying to the web view. + * + * Bidirectional messaging is always enabled for `web_view` components; this handler is how the + * app observes and responds to those messages. The SDK does not automatically dismiss the + * paywall or trigger a purchase in response to any message. + */ + public fun setWebViewMessageHandler(handler: PaywallWebViewMessageHandler?): Builder = apply { + this.webViewMessageHandler = handler + } + /** * Injects a pre-built workflow (multipage paywall) to render locally without fetching * it from the backend, together with the [Offering] it renders against. Internal diff --git a/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/webview/WebViewComponentView.kt b/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/webview/WebViewComponentView.kt index 3328444183..ae40cd9bad 100644 --- a/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/webview/WebViewComponentView.kt +++ b/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/webview/WebViewComponentView.kt @@ -49,6 +49,7 @@ internal fun WebViewComponentView( val componentId = style.componentId val enforceContentSecurityPolicy = style.protocolVersion != null val locale = state.locale.toLanguageTag() + val messageHandler = state.webViewMessageHandler val sizeToContentWidth = style.size.width is Fit val sizeToContentHeight = style.size.height is Fit @@ -87,7 +88,7 @@ internal fun WebViewComponentView( componentId = id, expectedUrl = resolvedUrl, locale = locale, - messageHandler = null, + messageHandler = messageHandler, protocolVersion = style.protocolVersion ?: WebViewEnvelope.DEFAULT_PROTOCOL_VERSION, sizeToContentWidth = sizeToContentWidth, sizeToContentHeight = sizeToContentHeight, @@ -106,7 +107,10 @@ internal fun WebViewComponentView( } }, update = { - bridgeHolder.bridge?.update(locale = locale, messageHandler = null) + bridgeHolder.bridge?.update( + locale = locale, + messageHandler = messageHandler, + ) }, onRelease = { webView -> bridgeHolder.bridge?.release() diff --git a/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/data/PaywallState.kt b/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/data/PaywallState.kt index 6836c3bafa..42453bd0d1 100644 --- a/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/data/PaywallState.kt +++ b/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/data/PaywallState.kt @@ -17,6 +17,7 @@ import com.revenuecat.purchases.Package import com.revenuecat.purchases.UiConfig.VariableConfig import com.revenuecat.purchases.paywalls.components.common.LocaleId import com.revenuecat.purchases.ui.revenuecatui.CustomVariableValue +import com.revenuecat.purchases.ui.revenuecatui.PaywallWebViewMessageHandler import com.revenuecat.purchases.ui.revenuecatui.components.ktx.getBestMatch import com.revenuecat.purchases.ui.revenuecatui.components.ktx.toComposeLocale import com.revenuecat.purchases.ui.revenuecatui.components.ktx.toJavaLocale @@ -85,7 +86,7 @@ internal sealed interface PaywallState { } } - @Suppress("LongParameterList") + @Suppress("LongParameterList", "TooManyFunctions") @Stable class Components( val stack: ComponentStyle, @@ -116,6 +117,7 @@ internal sealed interface PaywallState { * Default custom variables from the dashboard configuration. */ val defaultCustomVariables: Map = emptyMap(), + initialWebViewMessageHandler: PaywallWebViewMessageHandler? = null, initialLocaleList: LocaleList = LocaleList.current, initialSelectedTabIndex: Int? = null, initialSheetState: SimpleSheetState = SimpleSheetState(), @@ -188,6 +190,17 @@ internal sealed interface PaywallState { private var localeId by mutableStateOf(initialLocaleList.toLocaleId()) + /** + * Optional handler for messages sent by `web_view` components, set via + * [com.revenuecat.purchases.ui.revenuecatui.PaywallOptions.Builder.setWebViewMessageHandler]. + * + * Backed by snapshot state and refreshed in place (without rebuilding the paywall state) when + * the handler changes on [com.revenuecat.purchases.ui.revenuecatui.PaywallOptions]; see + * `PaywallViewModelImpl.updateOptions`. + */ + var webViewMessageHandler by mutableStateOf(initialWebViewMessageHandler) + private set + // We find all available device locales with the same country as the storefront country. private val availableStorefrontCountryLocalesByLanguage: Map by lazy { if (storefrontCountryCode.isNullOrBlank()) { @@ -333,6 +346,10 @@ internal sealed interface PaywallState { if (actionInProgress != null) this.actionInProgress = actionInProgress } + fun update(webViewMessageHandler: PaywallWebViewMessageHandler?) { + this.webViewMessageHandler = webViewMessageHandler + } + fun update(selectedPackageUniqueId: String) { this.selectedPackageUniqueId = selectedPackageUniqueId diff --git a/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/data/PaywallViewModel.kt b/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/data/PaywallViewModel.kt index c36384d3dc..777924eb3a 100644 --- a/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/data/PaywallViewModel.kt +++ b/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/data/PaywallViewModel.kt @@ -275,9 +275,22 @@ internal class PaywallViewModelImpl( val needsUpdateState = this.options.hashCode() != options.hashCode() // Some properties not considered for equality (hashCode) may have changed // (e.g. the listener may change in some re-renderers) + val previousWebViewMessageHandler = this.options.webViewMessageHandler this.options = options if (needsUpdateState) { updateState() + } else if (previousWebViewMessageHandler !== options.webViewMessageHandler) { + // The web view message handler is excluded from hashCode and is baked into the loaded + // Components state, so a handler-only change won't rebuild state. Refresh it in place + // (like refreshStateIfLocaleChanged) so web_view components get the latest handler without + // re-resolving the offering or resetting selections. + val currentState = _state.value + if (currentState is PaywallState.Loaded.Components) { + currentState.update(webViewMessageHandler = options.webViewMessageHandler) + workflowStepStateCache.values.forEach { + it.update(webViewMessageHandler = options.webViewMessageHandler) + } + } } } @@ -1169,7 +1182,12 @@ internal class PaywallViewModelImpl( } if (computed is PaywallState.Loaded.Components && stepId !in workflowStepStateCache) { workflowStepStateCache[stepId] = computed + // Re-stamp the locale and web view message handler from the latest values at insertion + // time: this step's state was computed on the background dispatcher with a snapshot + // taken before any concurrent updateOptions/locale change, and updateOptions only + // refreshes steps already present in the cache when it runs. computed.update(localeList = _lastLocaleList.value.toFrameworkLocaleList()) + computed.update(webViewMessageHandler = options.webViewMessageHandler) workflow.singleStepFallbackId ?.let { workflowStepStateCache[it]?.selectedPackageInfo } ?.let { computed.setDefaultPackage(it) } @@ -1445,6 +1463,7 @@ internal class PaywallViewModelImpl( purchases = purchases, customVariables = options.customVariables, defaultCustomVariables = extractDefaultCustomVariables(offering), + webViewMessageHandler = options.webViewMessageHandler, ) } } diff --git a/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/helpers/OfferingToStateMapper.kt b/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/helpers/OfferingToStateMapper.kt index 98f2110219..a0a3d8b40d 100644 --- a/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/helpers/OfferingToStateMapper.kt +++ b/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/helpers/OfferingToStateMapper.kt @@ -36,6 +36,7 @@ import com.revenuecat.purchases.paywalls.components.properties.Size import com.revenuecat.purchases.paywalls.components.properties.SizeConstraint import com.revenuecat.purchases.ui.revenuecatui.CustomVariableValue import com.revenuecat.purchases.ui.revenuecatui.PaywallMode +import com.revenuecat.purchases.ui.revenuecatui.PaywallWebViewMessageHandler import com.revenuecat.purchases.ui.revenuecatui.components.ktx.getBestMatch import com.revenuecat.purchases.ui.revenuecatui.components.properties.FontSpec import com.revenuecat.purchases.ui.revenuecatui.components.properties.determineFontSpecs @@ -367,6 +368,7 @@ internal fun Offering.toComponentsPaywallState( purchases: PurchasesType, customVariables: Map = emptyMap(), defaultCustomVariables: Map = emptyMap(), + webViewMessageHandler: PaywallWebViewMessageHandler? = null, ): PaywallState.Loaded.Components { val showPricesWithDecimals = storefrontCountryCode?.let { !validationResult.zeroDecimalPlaceCountries.contains(it) @@ -387,6 +389,7 @@ internal fun Offering.toComponentsPaywallState( packages = validationResult.packages, customVariables = customVariables, defaultCustomVariables = defaultCustomVariables, + initialWebViewMessageHandler = webViewMessageHandler, initialSelectedTabIndex = validationResult.initialSelectedTabIndex, mainStackHasHeroImage = validationResult.mainStackHasHeroImage, purchases = purchases, diff --git a/ui/revenuecatui/src/test/kotlin/com/revenuecat/purchases/ui/revenuecatui/PaywallOptionsWebViewHandlerTest.kt b/ui/revenuecatui/src/test/kotlin/com/revenuecat/purchases/ui/revenuecatui/PaywallOptionsWebViewHandlerTest.kt new file mode 100644 index 0000000000..37bee38233 --- /dev/null +++ b/ui/revenuecatui/src/test/kotlin/com/revenuecat/purchases/ui/revenuecatui/PaywallOptionsWebViewHandlerTest.kt @@ -0,0 +1,44 @@ +package com.revenuecat.purchases.ui.revenuecatui + +import org.assertj.core.api.Assertions.assertThat +import org.junit.Test + +internal class PaywallOptionsWebViewHandlerTest { + + private fun options(handler: PaywallWebViewMessageHandler?): PaywallOptions = + PaywallOptions.Builder(dismissRequest = {}) + .setWebViewMessageHandler(handler) + .build() + + @Test + fun `builder stores the web view message handler`() { + val handler = PaywallWebViewMessageHandler { _, _ -> } + + assertThat(options(handler).webViewMessageHandler).isSameAs(handler) + } + + @Test + fun `defaults to no web view message handler`() { + val options = PaywallOptions.Builder(dismissRequest = {}).build() + + assertThat(options.webViewMessageHandler).isNull() + } + + @Test + fun `options differing only by handler are not equal`() { + val handlerA = PaywallWebViewMessageHandler { _, _ -> } + val handlerB = PaywallWebViewMessageHandler { _, _ -> } + + assertThat(options(handlerA)).isNotEqualTo(options(handlerB)) + } + + @Test + fun `handler is excluded from hashCode so state updates are not triggered by it alone`() { + val handlerA = PaywallWebViewMessageHandler { _, _ -> } + val handlerB = PaywallWebViewMessageHandler { _, _ -> } + + // hashCode intentionally ignores the handler (like listener) so swapping handlers does not + // force a paywall state refresh; see PaywallViewModel.updateOptions. + assertThat(options(handlerA).hashCode()).isEqualTo(options(handlerB).hashCode()) + } +} diff --git a/ui/revenuecatui/src/test/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/style/StyleFactoryTests.kt b/ui/revenuecatui/src/test/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/style/StyleFactoryTests.kt index 412d4d4030..e60ad00b1c 100644 --- a/ui/revenuecatui/src/test/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/style/StyleFactoryTests.kt +++ b/ui/revenuecatui/src/test/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/style/StyleFactoryTests.kt @@ -136,6 +136,7 @@ class StyleFactoryTests { assertThat(style.urlTemplate).isEqualTo("https://paywalls.revenuecat.com/{{ custom.animal }}.html") assertThat(style.visible).isFalse() assertThat(style.size).isEqualTo(size) + assertThat(style.componentId).isEqualTo("promo_web_view") assertThat(style.protocolVersion).isNull() } diff --git a/ui/revenuecatui/src/test/kotlin/com/revenuecat/purchases/ui/revenuecatui/data/PaywallViewModelTest.kt b/ui/revenuecatui/src/test/kotlin/com/revenuecat/purchases/ui/revenuecatui/data/PaywallViewModelTest.kt index 223be5b92a..e43fc92d97 100644 --- a/ui/revenuecatui/src/test/kotlin/com/revenuecat/purchases/ui/revenuecatui/data/PaywallViewModelTest.kt +++ b/ui/revenuecatui/src/test/kotlin/com/revenuecat/purchases/ui/revenuecatui/data/PaywallViewModelTest.kt @@ -50,6 +50,7 @@ import com.revenuecat.purchases.ui.revenuecatui.OfferingSelection import com.revenuecat.purchases.ui.revenuecatui.PaywallListener import com.revenuecat.purchases.ui.revenuecatui.PaywallMode import com.revenuecat.purchases.ui.revenuecatui.PaywallOptions +import com.revenuecat.purchases.ui.revenuecatui.PaywallWebViewMessageHandler import com.revenuecat.purchases.ui.revenuecatui.PaywallPurchaseLogicParams import com.revenuecat.purchases.ui.revenuecatui.PaywallPurchaseLogic import com.revenuecat.purchases.ui.revenuecatui.PaywallPurchaseLogicWithCallback @@ -662,6 +663,44 @@ class PaywallViewModelTest { coVerify(exactly = 1) { purchases.awaitOfferings() } } + @Test + fun `updateOptions refreshes the web view message handler in place without rebuilding`(): Unit = runBlocking { + val offering = Offering( + identifier = "offering-id", + serverDescription = "description", + metadata = emptyMap(), + availablePackages = listOf(TestData.Packages.monthly, TestData.Packages.annual), + paywallComponents = Offering.PaywallComponents(UiConfig(), emptyPaywallComponentsData), + ) + val handlerA = PaywallWebViewMessageHandler { _, _ -> } + val handlerB = PaywallWebViewMessageHandler { _, _ -> } + fun optionsWith(handler: PaywallWebViewMessageHandler) = + PaywallOptions.Builder(dismissRequest = { dismissInvoked = true }) + .setListener(listener) + .setOffering(offering) + .setWebViewMessageHandler(handler) + .build() + + val model = PaywallViewModelImpl( + MockResourceProvider(), + purchases, + optionsWith(handlerA), + TestData.Constants.currentColorScheme, + isDarkMode = false, + shouldDisplayBlock = null, + ) + val state = model.state.value as PaywallState.Loaded.Components + assertThat(state.webViewMessageHandler).isSameAs(handlerA) + + // Same paywall, only the handler differs (handler is excluded from hashCode). + model.updateOptions(optionsWith(handlerB)) + + // No rebuild: the loaded state instance is preserved (same selections/scroll), and it now + // carries the new handler so web_view components see the latest one. + assertThat(model.state.value).isSameAs(state) + assertThat(state.webViewMessageHandler).isSameAs(handlerB) + } + @Test fun `Should load default offering`() { val model = create() diff --git a/ui/revenuecatui/src/test/kotlin/com/revenuecat/purchases/ui/revenuecatui/data/PaywallViewModelWorkflowTest.kt b/ui/revenuecatui/src/test/kotlin/com/revenuecat/purchases/ui/revenuecatui/data/PaywallViewModelWorkflowTest.kt index adc10814db..27cb60d91d 100644 --- a/ui/revenuecatui/src/test/kotlin/com/revenuecat/purchases/ui/revenuecatui/data/PaywallViewModelWorkflowTest.kt +++ b/ui/revenuecatui/src/test/kotlin/com/revenuecat/purchases/ui/revenuecatui/data/PaywallViewModelWorkflowTest.kt @@ -46,6 +46,7 @@ import com.revenuecat.purchases.paywalls.components.properties.ColorInfo import com.revenuecat.purchases.paywalls.components.properties.ColorScheme import com.revenuecat.purchases.ui.revenuecatui.PaywallListener import com.revenuecat.purchases.ui.revenuecatui.PaywallOptions +import com.revenuecat.purchases.ui.revenuecatui.PaywallWebViewMessageHandler import com.revenuecat.purchases.ui.revenuecatui.PaywallPurchaseLogic import com.revenuecat.purchases.ui.revenuecatui.PaywallPurchaseLogicParams import com.revenuecat.purchases.ui.revenuecatui.PurchaseLogicResult @@ -657,6 +658,38 @@ class PaywallViewModelWorkflowTest { ).isEqualTo("gold") } + @Test + fun `pre-warmed workflow steps carry the web view message handler and follow updateOptions swaps`() { + val handlerA = PaywallWebViewMessageHandler { _, _ -> } + val handlerB = PaywallWebViewMessageHandler { _, _ -> } + fun optionsWith(handler: PaywallWebViewMessageHandler) = + PaywallOptions.Builder(dismissRequest = {}) + .setWebViewMessageHandler(handler) + .build() + val vm = PaywallViewModelImpl( + resourceProvider = MockResourceProvider(), + purchases = purchases, + options = optionsWith(handlerA), + colorScheme = TestData.Constants.currentColorScheme, + isDarkMode = false, + shouldDisplayBlock = null, + backgroundDispatcher = testDispatcher, + ) + + vm.startWorkflowPresentationFromResult(fetchResult, testOfferings, null, uiConfig) + // Let the background pre-warm finish so step-2 is cached without navigating to it. + testDispatcher.scheduler.advanceUntilIdle() + + // The pre-warmed (not navigated-to) step carries the handler from options. + assertThat(vm.workflowState.value?.stepStates?.get("step-2")?.webViewMessageHandler) + .isSameAs(handlerA) + + // Swapping the handler refreshes already-cached pre-warmed steps in place. + vm.updateOptions(optionsWith(handlerB)) + assertThat(vm.workflowState.value?.stepStates?.get("step-2")?.webViewMessageHandler) + .isSameAs(handlerB) + } + // endregion // region onTransitionComplete From 9891f169cc75a5ba7506f9375ec3285d1bf134f6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=81lvaro=20Brey?= Date: Mon, 6 Jul 2026 17:23:15 +0200 Subject: [PATCH 13/13] Add paywall-tester sample for web_view manual QA. Uses example.com as placeholder content until the real bundle URL is available. --- .../paywallstester/SamplePaywalls.kt | 4 + .../paywallstester/paywalls/WebViewSample.kt | 134 ++++++++++++++++++ .../screens/main/paywalls/PaywallsScreen.kt | 55 +++++-- 3 files changed, 184 insertions(+), 9 deletions(-) create mode 100644 examples/paywall-tester/src/main/java/com/revenuecat/paywallstester/paywalls/WebViewSample.kt diff --git a/examples/paywall-tester/src/main/java/com/revenuecat/paywallstester/SamplePaywalls.kt b/examples/paywall-tester/src/main/java/com/revenuecat/paywallstester/SamplePaywalls.kt index 24d0804113..36f11eb1e1 100644 --- a/examples/paywall-tester/src/main/java/com/revenuecat/paywallstester/SamplePaywalls.kt +++ b/examples/paywall-tester/src/main/java/com/revenuecat/paywallstester/SamplePaywalls.kt @@ -6,6 +6,7 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.toArgb import com.revenuecat.paywallstester.paywalls.tabsWithButtons import com.revenuecat.paywallstester.paywalls.tabsWithToggle +import com.revenuecat.paywallstester.paywalls.webViewSample import com.revenuecat.purchases.FontAlias import com.revenuecat.purchases.InternalRevenueCatAPI import com.revenuecat.purchases.Offering @@ -85,6 +86,7 @@ class SamplePaywallsLoader { ) } + @Suppress("CyclomaticComplexMethod") private fun paywallForTemplate(template: SamplePaywalls.SampleTemplate): SampleData { return when (template) { SamplePaywalls.SampleTemplate.TEMPLATE_1 -> SamplePaywalls.template1() @@ -101,6 +103,7 @@ class SamplePaywallsLoader { SamplePaywalls.SampleTemplate.TABS_BUTTONS -> tabsWithButtons() SamplePaywalls.SampleTemplate.TABS_TOGGLE -> tabsWithToggle() + SamplePaywalls.SampleTemplate.COMPONENTS_WEB_VIEW -> webViewSample() SamplePaywalls.SampleTemplate.UNRECOGNIZED_TEMPLATE -> SamplePaywalls.unrecognizedTemplate() } } @@ -128,6 +131,7 @@ object SamplePaywalls { COMPONENTS_BLESS_GOOGLE_FONT("#11: Components - bless. - Google font"), TABS_BUTTONS("#12: Tabs - buttons"), TABS_TOGGLE("#13 Tabs - toggle"), + COMPONENTS_WEB_VIEW("#14: Components - web_view"), UNRECOGNIZED_TEMPLATE("Default template"), } diff --git a/examples/paywall-tester/src/main/java/com/revenuecat/paywallstester/paywalls/WebViewSample.kt b/examples/paywall-tester/src/main/java/com/revenuecat/paywallstester/paywalls/WebViewSample.kt new file mode 100644 index 0000000000..01cf6662d3 --- /dev/null +++ b/examples/paywall-tester/src/main/java/com/revenuecat/paywallstester/paywalls/WebViewSample.kt @@ -0,0 +1,134 @@ +package com.revenuecat.paywallstester.paywalls + +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.toArgb +import com.revenuecat.paywallstester.SampleData +import com.revenuecat.purchases.InternalRevenueCatAPI +import com.revenuecat.purchases.paywalls.components.ButtonComponent +import com.revenuecat.purchases.paywalls.components.StackComponent +import com.revenuecat.purchases.paywalls.components.TextComponent +import com.revenuecat.purchases.paywalls.components.WebViewComponent +import com.revenuecat.purchases.paywalls.components.common.Background +import com.revenuecat.purchases.paywalls.components.common.ComponentsConfig +import com.revenuecat.purchases.paywalls.components.common.LocaleId +import com.revenuecat.purchases.paywalls.components.common.LocalizationData +import com.revenuecat.purchases.paywalls.components.common.LocalizationKey +import com.revenuecat.purchases.paywalls.components.common.PaywallComponentsConfig +import com.revenuecat.purchases.paywalls.components.common.PaywallComponentsData +import com.revenuecat.purchases.paywalls.components.properties.ColorInfo +import com.revenuecat.purchases.paywalls.components.properties.ColorScheme +import com.revenuecat.purchases.paywalls.components.properties.Dimension.Vertical +import com.revenuecat.purchases.paywalls.components.properties.Dimension.ZLayer +import com.revenuecat.purchases.paywalls.components.properties.FlexDistribution.END +import com.revenuecat.purchases.paywalls.components.properties.FontWeight +import com.revenuecat.purchases.paywalls.components.properties.HorizontalAlignment.LEADING +import com.revenuecat.purchases.paywalls.components.properties.Padding +import com.revenuecat.purchases.paywalls.components.properties.Shape +import com.revenuecat.purchases.paywalls.components.properties.Size +import com.revenuecat.purchases.paywalls.components.properties.SizeConstraint.Fill +import com.revenuecat.purchases.paywalls.components.properties.SizeConstraint.Fit +import com.revenuecat.purchases.paywalls.components.properties.SizeConstraint.Fixed +import com.revenuecat.purchases.paywalls.components.properties.TwoDimensionalAlignment +import java.net.URL + +private const val PLACEHOLDER_WEB_VIEW_URL = "https://example.com" + +@OptIn(InternalRevenueCatAPI::class) +@Suppress("LongMethod") +internal fun webViewSample(): SampleData.Components { + val textColor = ColorScheme( + light = ColorInfo.Hex(Color.Black.toArgb()), + dark = ColorInfo.Hex(Color.White.toArgb()), + ) + val backgroundColor = ColorScheme( + light = ColorInfo.Hex(Color.White.toArgb()), + dark = ColorInfo.Hex(Color.Black.toArgb()), + ) + val accentColor = ColorScheme( + light = ColorInfo.Hex(Color(red = 5, green = 124, blue = 91).toArgb()), + ) + + return SampleData.Components( + data = PaywallComponentsData( + id = "sample_web_view_paywall_id", + templateName = "template", + assetBaseURL = URL("https://assets.pawwalls.com"), + componentsConfig = ComponentsConfig( + base = PaywallComponentsConfig( + stack = StackComponent( + components = listOf( + TextComponent( + text = LocalizationKey("title"), + color = textColor, + fontWeight = FontWeight.BOLD, + fontSize = 24, + horizontalAlignment = LEADING, + size = Size(width = Fill, height = Fit), + margin = Padding(top = 16.0, bottom = 8.0, leading = 16.0, trailing = 16.0), + ), + WebViewComponent( + id = "sample_web_view", + url = PLACEHOLDER_WEB_VIEW_URL, + size = Size(width = Fill, height = Fixed(400u)), + fallback = StackComponent( + components = listOf( + TextComponent( + text = LocalizationKey("fallback"), + color = textColor, + horizontalAlignment = LEADING, + size = Size(width = Fill, height = Fit), + padding = Padding( + top = 16.0, + bottom = 16.0, + leading = 16.0, + trailing = 16.0, + ), + ), + ), + dimension = ZLayer(alignment = TwoDimensionalAlignment.CENTER), + size = Size(width = Fill, height = Fixed(400u)), + ), + ), + ButtonComponent( + action = ButtonComponent.Action.RestorePurchases, + stack = StackComponent( + components = listOf( + TextComponent( + text = LocalizationKey("cta"), + color = ColorScheme(light = ColorInfo.Hex(Color.White.toArgb())), + fontWeight = FontWeight.BOLD, + ), + ), + dimension = ZLayer(alignment = TwoDimensionalAlignment.CENTER), + size = Size(width = Fit, height = Fit), + backgroundColor = accentColor, + padding = Padding( + top = 8.0, + bottom = 8.0, + leading = 32.0, + trailing = 32.0, + ), + margin = Padding(top = 16.0, bottom = 16.0, leading = 16.0, trailing = 16.0), + shape = Shape.Pill, + ), + ), + ), + dimension = Vertical(alignment = LEADING, distribution = END), + size = Size(width = Fill, height = Fill), + backgroundColor = backgroundColor, + ), + background = Background.Color(backgroundColor), + stickyFooter = null, + ), + ), + componentsLocalizations = mapOf( + LocaleId("en_US") to mapOf( + LocalizationKey("title") to LocalizationData.Text("Web view placeholder"), + LocalizationKey("fallback") to LocalizationData.Text("Could not load the web view."), + LocalizationKey("cta") to LocalizationData.Text("Restore purchases"), + ), + ), + defaultLocaleIdentifier = LocaleId("en_US"), + ), + ) +} diff --git a/examples/paywall-tester/src/main/java/com/revenuecat/paywallstester/ui/screens/main/paywalls/PaywallsScreen.kt b/examples/paywall-tester/src/main/java/com/revenuecat/paywallstester/ui/screens/main/paywalls/PaywallsScreen.kt index 07df1affeb..57c92ba426 100644 --- a/examples/paywall-tester/src/main/java/com/revenuecat/paywallstester/ui/screens/main/paywalls/PaywallsScreen.kt +++ b/examples/paywall-tester/src/main/java/com/revenuecat/paywallstester/ui/screens/main/paywalls/PaywallsScreen.kt @@ -44,15 +44,19 @@ import com.revenuecat.purchases.Package import com.revenuecat.purchases.PurchasesError import com.revenuecat.purchases.PurchasesErrorCode import com.revenuecat.purchases.ui.revenuecatui.OriginalTemplatePaywallFooter +import com.revenuecat.purchases.ui.revenuecatui.Paywall import com.revenuecat.purchases.ui.revenuecatui.PaywallDialog import com.revenuecat.purchases.ui.revenuecatui.PaywallDialogOptions import com.revenuecat.purchases.ui.revenuecatui.PaywallOptions +import com.revenuecat.purchases.ui.revenuecatui.PaywallWebViewMessageHandler import com.revenuecat.purchases.ui.revenuecatui.PurchaseLogic import com.revenuecat.purchases.ui.revenuecatui.PurchaseLogicResult import com.revenuecat.purchases.ui.revenuecatui.PurchaseLogicWithCallback import com.revenuecat.purchases.ui.revenuecatui.fonts.CustomFontProvider import com.revenuecat.purchases.ui.revenuecatui.fonts.FontProvider +private const val TAG = "PaywallTester" + private class TestAppPurchaseLogicSuspend : PurchaseLogic { companion object { @@ -127,6 +131,16 @@ fun PaywallsScreen( } } + val webViewMessageHandler = remember { + PaywallWebViewMessageHandler { message, _ -> + Log.d( + TAG, + "web_view message: type=${message.type} componentId=${message.componentId} " + + "error=${message.error}", + ) + } + } + LazyColumn( modifier = modifier.testTag("paywall_screen"), ) { @@ -144,6 +158,11 @@ fun PaywallsScreen( displayPaywallState = DisplayPaywallState.FullScreen( offering, purchaseLogic = myAppPurchaseLogic, + webViewMessageHandler = if (template == SamplePaywalls.SampleTemplate.COMPONENTS_WEB_VIEW) { + webViewMessageHandler + } else { + null + }, ) }, emoji = "\uD83D\uDCF1", @@ -207,15 +226,32 @@ fun PaywallsScreen( @Composable private fun FullScreenDialog(currentState: DisplayPaywallState.FullScreen, onDismiss: () -> Unit) { - PaywallDialog( - PaywallDialogOptions.Builder() - .setDismissRequest(onDismiss) - .setOffering(currentState.offering) - .setFontProvider(currentState.fontProvider) - .setCustomVariables(CustomVariablesHolder.customVariables) - .setCustomPurchaseLogic(currentState.purchaseLogic) - .build(), - ) + val paywallOptions = PaywallOptions.Builder(onDismiss) + .setOffering(currentState.offering) + .setFontProvider(currentState.fontProvider) + .setCustomVariables(CustomVariablesHolder.customVariables) + .setPurchaseLogic(currentState.purchaseLogic) + .setWebViewMessageHandler(currentState.webViewMessageHandler) + .build() + + if (currentState.webViewMessageHandler != null) { + Dialog( + onDismissRequest = onDismiss, + properties = DialogProperties(usePlatformDefaultWidth = false), + ) { + Paywall(paywallOptions) + } + } else { + PaywallDialog( + PaywallDialogOptions.Builder() + .setDismissRequest(onDismiss) + .setOffering(currentState.offering) + .setFontProvider(currentState.fontProvider) + .setCustomVariables(CustomVariablesHolder.customVariables) + .setCustomPurchaseLogic(currentState.purchaseLogic) + .build(), + ) + } } @Composable @@ -251,6 +287,7 @@ private sealed class DisplayPaywallState { val offering: Offering? = null, val fontProvider: FontProvider? = null, var purchaseLogic: PurchaseLogic? = null, + val webViewMessageHandler: PaywallWebViewMessageHandler? = null, ) : DisplayPaywallState() data class Footer(