Skip to content

Commit 9e93b6c

Browse files
committed
feat(paywalls): add web_view component schema
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
1 parent 4d368aa commit 9e93b6c

8 files changed

Lines changed: 197 additions & 1 deletion

File tree

purchases/src/main/kotlin/com/revenuecat/purchases/paywalls/components/PaywallComponent.kt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,7 @@ internal class PaywallComponentSerializer : KSerializer<PaywallComponent> {
5757
"tabs" -> jsonDecoder.json.decodeFromJsonElement<TabsComponent>(json)
5858
"video" -> jsonDecoder.json.decodeFromJsonElement<VideoComponent>(json)
5959
"countdown" -> jsonDecoder.json.decodeFromJsonElement<CountdownComponent>(json)
60+
"web_view" -> jsonDecoder.json.decodeFromJsonElement<WebViewComponent>(json)
6061
"fallback_header" -> FallbackHeaderComponent
6162
else -> json["fallback"]
6263
?.let { it as? JsonObject }
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
package com.revenuecat.purchases.paywalls.components
2+
3+
import androidx.compose.runtime.Immutable
4+
import com.revenuecat.purchases.InternalRevenueCatAPI
5+
import com.revenuecat.purchases.paywalls.components.properties.Size
6+
import com.revenuecat.purchases.paywalls.components.properties.SizeConstraint
7+
import com.revenuecat.purchases.paywalls.components.properties.SizeConstraint.Fill
8+
import dev.drewhamilton.poko.Poko
9+
import kotlinx.serialization.SerialName
10+
import kotlinx.serialization.Serializable
11+
12+
/**
13+
* A Paywalls V2 component that renders generic hosted web content (a web bundle entrypoint) inside a
14+
* web view.
15+
*
16+
* Only [protocolVersion] `1` is currently supported. When [protocolVersion] is present the SDK
17+
* isolates the web content from external sources (see the content blocking applied in the UI module):
18+
* the bundle must be fully self-contained.
19+
*
20+
* Any schema-declared `capabilities` are intentionally ignored for v1.
21+
*/
22+
@InternalRevenueCatAPI
23+
@Poko
24+
@Serializable
25+
@SerialName("web_view")
26+
@Immutable
27+
public class WebViewComponent(
28+
@get:JvmSynthetic
29+
public val url: String,
30+
@get:JvmSynthetic
31+
public val id: String? = null,
32+
@get:JvmSynthetic
33+
public val name: String? = null,
34+
@get:JvmSynthetic
35+
public val visible: Boolean? = null,
36+
@SerialName("protocol_version")
37+
@get:JvmSynthetic
38+
public val protocolVersion: Int? = null,
39+
@get:JvmSynthetic
40+
public val size: Size = Size(width = Fill, height = SizeConstraint.Fit),
41+
@get:JvmSynthetic
42+
public val fallback: StackComponent? = null,
43+
) : PaywallComponent

purchases/src/main/kotlin/com/revenuecat/purchases/utils/PaywallComponentFilterExtension.kt

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import com.revenuecat.purchases.paywalls.components.TabsComponent
2020
import com.revenuecat.purchases.paywalls.components.TextComponent
2121
import com.revenuecat.purchases.paywalls.components.TimelineComponent
2222
import com.revenuecat.purchases.paywalls.components.VideoComponent
23+
import com.revenuecat.purchases.paywalls.components.WebViewComponent
2324

2425
/**
2526
* Returns all PaywallComponent that satisfy the predicate.
@@ -67,6 +68,10 @@ internal fun PaywallComponent.filter(predicate: (PaywallComponent) -> Boolean):
6768
current.fallback?.let { queue.add(it) }
6869
}
6970

71+
is WebViewComponent -> {
72+
current.fallback?.let { queue.add(it) }
73+
}
74+
7075
is FallbackHeaderComponent,
7176
is VideoComponent,
7277
is TabControlToggleComponent,

purchases/src/main/kotlin/com/revenuecat/purchases/utils/PaywallComponentsImagePreDownloader.kt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ import com.revenuecat.purchases.paywalls.components.TabsComponent
2626
import com.revenuecat.purchases.paywalls.components.TextComponent
2727
import com.revenuecat.purchases.paywalls.components.TimelineComponent
2828
import com.revenuecat.purchases.paywalls.components.VideoComponent
29+
import com.revenuecat.purchases.paywalls.components.WebViewComponent
2930
import com.revenuecat.purchases.paywalls.components.common.Background
3031
import com.revenuecat.purchases.paywalls.components.common.ComponentOverride
3132
import com.revenuecat.purchases.paywalls.components.common.PaywallComponentsConfig
@@ -106,6 +107,7 @@ internal class PaywallComponentsImagePreDownloader(
106107
is TabControlToggleComponent,
107108
is TextComponent,
108109
is TimelineComponent,
110+
is WebViewComponent,
109111
-> emptySet()
110112
}
111113
}
Lines changed: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,139 @@
1+
package com.revenuecat.purchases.paywalls.components
2+
3+
import com.revenuecat.purchases.JsonTools
4+
import com.revenuecat.purchases.paywalls.components.properties.Size
5+
import com.revenuecat.purchases.paywalls.components.properties.SizeConstraint
6+
import org.assertj.core.api.Assertions.assertThat
7+
import org.intellij.lang.annotations.Language
8+
import org.junit.Test
9+
import kotlin.test.assertEquals
10+
11+
class WebViewComponentTests {
12+
13+
@Language("json")
14+
private val fullJson = """
15+
{
16+
"id": "promo_web_view",
17+
"type": "web_view",
18+
"protocol_version": 1,
19+
"url": "https://assets.pawwalls.com/web_bundles/123/index.html",
20+
"size": {
21+
"width": { "type": "fill" },
22+
"height": { "type": "fit" }
23+
},
24+
"visible": true,
25+
"name": "Promo web component",
26+
"fallback": {
27+
"id": "promo_web_view_fallback",
28+
"type": "stack",
29+
"components": [
30+
{ "type": "stack", "components": [] }
31+
],
32+
"size": {
33+
"width": { "type": "fill" },
34+
"height": { "type": "fit" }
35+
},
36+
"dimension": {
37+
"type": "vertical",
38+
"alignment": "center",
39+
"distribution": "start"
40+
},
41+
"padding": { "top": 0, "bottom": 0, "leading": 0, "trailing": 0 },
42+
"margin": { "top": 0, "bottom": 0, "leading": 0, "trailing": 0 }
43+
}
44+
}
45+
"""
46+
47+
@Test
48+
fun `deserializes full Khepri schema`() {
49+
val actual = JsonTools.json.decodeFromString<PaywallComponent>(fullJson)
50+
51+
assertThat(actual).isInstanceOf(WebViewComponent::class.java)
52+
val webView = actual as WebViewComponent
53+
54+
assertThat(webView.id).isEqualTo("promo_web_view")
55+
assertThat(webView.name).isEqualTo("Promo web component")
56+
assertThat(webView.visible).isTrue()
57+
assertThat(webView.protocolVersion).isEqualTo(1)
58+
assertThat(webView.url).isEqualTo("https://assets.pawwalls.com/web_bundles/123/index.html")
59+
assertThat(webView.size).isEqualTo(Size(width = SizeConstraint.Fill, height = SizeConstraint.Fit))
60+
61+
// The fallback is decoded as a normal Stack, and its children are not stripped.
62+
val fallback = webView.fallback
63+
assertThat(fallback).isNotNull
64+
assertThat(fallback!!.components).hasSize(1)
65+
assertThat(fallback.components.first()).isInstanceOf(StackComponent::class.java)
66+
}
67+
68+
@Test
69+
fun `ignores capabilities declared by the schema`() {
70+
// v1 isolates entirely from external sources via a fixed CSP, so any schema-declared
71+
// capabilities are decoded-and-ignored rather than failing to parse.
72+
@Language("json")
73+
val json = """
74+
{
75+
"type": "web_view",
76+
"url": "https://paywalls.revenuecat.com/index.html",
77+
"capabilities": {
78+
"network_access": { "allowed_domains": ["api.segment.io"] },
79+
"camera": true,
80+
"microphone": true,
81+
"clipboard_write": true,
82+
"clipboard_read": true,
83+
"geolocation": true
84+
}
85+
}
86+
"""
87+
88+
val actual = JsonTools.json.decodeFromString<PaywallComponent>(json)
89+
90+
assertEquals(
91+
WebViewComponent(url = "https://paywalls.revenuecat.com/index.html"),
92+
actual,
93+
)
94+
}
95+
96+
@Test
97+
fun `deserializes template url correctly`() {
98+
@Language("json")
99+
val templateJson = """
100+
{
101+
"type": "web_view",
102+
"protocol_version": 1,
103+
"url": "https://paywalls.revenuecat.com/{{ custom.animal }}.html"
104+
}
105+
"""
106+
107+
val actual = JsonTools.json.decodeFromString<PaywallComponent>(templateJson)
108+
109+
assertEquals(
110+
WebViewComponent(
111+
url = "https://paywalls.revenuecat.com/{{ custom.animal }}.html",
112+
protocolVersion = 1,
113+
),
114+
actual,
115+
)
116+
}
117+
118+
@Test
119+
fun `deserializes minimal shape with defaults for missing optional fields`() {
120+
// Older/partial configs may omit protocol_version, size and fallback. These should still
121+
// decode without crashing, using defaults.
122+
@Language("json")
123+
val minimalJson = """
124+
{
125+
"type": "web_view",
126+
"url": "https://paywalls.revenuecat.com/index.html"
127+
}
128+
"""
129+
130+
val actual = JsonTools.json.decodeFromString<PaywallComponent>(minimalJson)
131+
132+
assertEquals(
133+
WebViewComponent(url = "https://paywalls.revenuecat.com/index.html"),
134+
actual,
135+
)
136+
assertThat((actual as WebViewComponent).fallback).isNull()
137+
assertThat(actual.protocolVersion).isNull()
138+
}
139+
}

ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/style/StyleFactory.kt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ import com.revenuecat.purchases.paywalls.components.TabsComponent
2727
import com.revenuecat.purchases.paywalls.components.TextComponent
2828
import com.revenuecat.purchases.paywalls.components.TimelineComponent
2929
import com.revenuecat.purchases.paywalls.components.VideoComponent
30+
import com.revenuecat.purchases.paywalls.components.WebViewComponent
3031
import com.revenuecat.purchases.paywalls.components.common.Background
3132
import com.revenuecat.purchases.paywalls.components.common.LocaleId
3233
import com.revenuecat.purchases.paywalls.components.common.LocalizationKey
@@ -573,6 +574,7 @@ internal class StyleFactory(
573574
is TabsComponent -> createTabsComponentStyle(component)
574575
is VideoComponent -> createVideoComponentStyle(component)
575576
is FallbackHeaderComponent -> Result.Success(null)
577+
is WebViewComponent -> Result.Success(null)
576578
is CountdownComponent -> createCountdownComponentStyle(
577579
component,
578580
)

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ import com.revenuecat.purchases.paywalls.components.TabsComponent
2525
import com.revenuecat.purchases.paywalls.components.TextComponent
2626
import com.revenuecat.purchases.paywalls.components.TimelineComponent
2727
import com.revenuecat.purchases.paywalls.components.VideoComponent
28+
import com.revenuecat.purchases.paywalls.components.WebViewComponent
2829
import com.revenuecat.purchases.paywalls.components.common.ComponentOverride
2930
import com.revenuecat.purchases.paywalls.components.common.LocalizationData
3031
import com.revenuecat.purchases.paywalls.components.common.LocalizationKey
@@ -516,6 +517,7 @@ internal fun PaywallComponent.containsUnsupportedCondition(): Boolean = when (th
516517
is TabControlToggleComponent -> false
517518
is TabControlComponent -> false
518519
is FallbackHeaderComponent -> false
520+
is WebViewComponent -> fallback?.containsUnsupportedCondition() == true
519521
}
520522

521523
@JvmSynthetic

ui/revenuecatui/src/test/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/tabs/TabsComponentViewTests.kt

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ import com.revenuecat.purchases.paywalls.components.TabsComponent
3939
import com.revenuecat.purchases.paywalls.components.TextComponent
4040
import com.revenuecat.purchases.paywalls.components.TimelineComponent
4141
import com.revenuecat.purchases.paywalls.components.VideoComponent
42+
import com.revenuecat.purchases.paywalls.components.WebViewComponent
4243
import com.revenuecat.purchases.paywalls.components.common.Background
4344
import com.revenuecat.purchases.paywalls.components.common.ComponentOverride
4445
import com.revenuecat.purchases.paywalls.components.common.ComponentsConfig
@@ -1298,7 +1299,8 @@ class TabsComponentViewTests {
12981299
is IconComponent,
12991300
is TextComponent,
13001301
is VideoComponent,
1301-
-> {
1302+
is WebViewComponent,
1303+
-> {
13021304
// These don't have child components.
13031305
}
13041306
}

0 commit comments

Comments
 (0)