From 28922a24ee3e7c8194edfa9cc13c96985eac73ec Mon Sep 17 00:00:00 2001 From: Alexander Repty Date: Thu, 25 Jun 2026 14:51:30 +0200 Subject: [PATCH] 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