Skip to content

Commit fa1fcdf

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 f252076 commit fa1fcdf

9 files changed

Lines changed: 179 additions & 2 deletions

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: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
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+
@InternalRevenueCatAPI
21+
@Poko
22+
@Serializable
23+
@SerialName("web_view")
24+
@Immutable
25+
public class WebViewComponent(
26+
@get:JvmSynthetic
27+
public val url: String,
28+
@get:JvmSynthetic
29+
public val id: String? = null,
30+
@get:JvmSynthetic
31+
public val name: String? = null,
32+
@get:JvmSynthetic
33+
public val visible: Boolean? = null,
34+
@SerialName("protocol_version")
35+
@get:JvmSynthetic
36+
public val protocolVersion: Int? = null,
37+
@get:JvmSynthetic
38+
public val size: Size = Size(width = Fill, height = SizeConstraint.Fit),
39+
) : PaywallComponent

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

Lines changed: 2 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.
@@ -74,6 +75,7 @@ internal fun PaywallComponent.filter(predicate: (PaywallComponent) -> Boolean):
7475
is ImageComponent,
7576
is IconComponent,
7677
is TextComponent,
78+
is WebViewComponent,
7779
-> {
7880
// These don't have child components.
7981
}

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: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
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 com.revenuecat.purchases.utils.filter
7+
import org.assertj.core.api.Assertions.assertThat
8+
import org.intellij.lang.annotations.Language
9+
import org.junit.Test
10+
import kotlin.test.assertEquals
11+
12+
class WebViewComponentTests {
13+
14+
@Language("json")
15+
private val fullJson = """
16+
{
17+
"id": "promo_web_view",
18+
"type": "web_view",
19+
"protocol_version": 1,
20+
"url": "https://assets.pawwalls.com/web_bundles/123/index.html",
21+
"size": {
22+
"width": { "type": "fill" },
23+
"height": { "type": "fit" }
24+
},
25+
"visible": true,
26+
"name": "Promo web component"
27+
}
28+
"""
29+
30+
@Test
31+
fun `deserializes full Khepri schema`() {
32+
val actual = JsonTools.json.decodeFromString<PaywallComponent>(fullJson)
33+
34+
assertThat(actual).isInstanceOf(WebViewComponent::class.java)
35+
val webView = actual as WebViewComponent
36+
37+
assertThat(webView.id).isEqualTo("promo_web_view")
38+
assertThat(webView.name).isEqualTo("Promo web component")
39+
assertThat(webView.visible).isTrue()
40+
assertThat(webView.protocolVersion).isEqualTo(1)
41+
assertThat(webView.url).isEqualTo("https://assets.pawwalls.com/web_bundles/123/index.html")
42+
assertThat(webView.size).isEqualTo(Size(width = SizeConstraint.Fill, height = SizeConstraint.Fit))
43+
}
44+
45+
@Test
46+
fun `ignores capabilities declared by the schema`() {
47+
// v1 isolates entirely from external sources via a fixed CSP, so any schema-declared
48+
// capabilities are decoded-and-ignored rather than failing to parse.
49+
@Language("json")
50+
val json = """
51+
{
52+
"type": "web_view",
53+
"url": "https://paywalls.revenuecat.com/index.html",
54+
"capabilities": {
55+
"network_access": { "allowed_domains": ["api.segment.io"] },
56+
"camera": true,
57+
"microphone": true,
58+
"clipboard_write": true,
59+
"clipboard_read": true,
60+
"geolocation": true
61+
}
62+
}
63+
"""
64+
65+
val actual = JsonTools.json.decodeFromString<PaywallComponent>(json)
66+
67+
assertEquals(
68+
WebViewComponent(url = "https://paywalls.revenuecat.com/index.html"),
69+
actual,
70+
)
71+
}
72+
73+
@Test
74+
fun `deserializes template url correctly`() {
75+
@Language("json")
76+
val templateJson = """
77+
{
78+
"type": "web_view",
79+
"protocol_version": 1,
80+
"url": "https://paywalls.revenuecat.com/{{ custom.animal }}.html"
81+
}
82+
"""
83+
84+
val actual = JsonTools.json.decodeFromString<PaywallComponent>(templateJson)
85+
86+
assertEquals(
87+
WebViewComponent(
88+
url = "https://paywalls.revenuecat.com/{{ custom.animal }}.html",
89+
protocolVersion = 1,
90+
),
91+
actual,
92+
)
93+
}
94+
95+
@Test
96+
fun `deserializes minimal shape with defaults for missing optional fields`() {
97+
// Older/partial configs may omit protocol_version and size. These should still
98+
// decode without crashing, using defaults.
99+
@Language("json")
100+
val minimalJson = """
101+
{
102+
"type": "web_view",
103+
"url": "https://paywalls.revenuecat.com/index.html"
104+
}
105+
"""
106+
107+
val actual = JsonTools.json.decodeFromString<PaywallComponent>(minimalJson)
108+
109+
assertEquals(
110+
WebViewComponent(url = "https://paywalls.revenuecat.com/index.html"),
111+
actual,
112+
)
113+
val webView = actual as WebViewComponent
114+
assertThat(webView.protocolVersion).isNull()
115+
assertThat(webView.size).isEqualTo(Size(width = SizeConstraint.Fill, height = SizeConstraint.Fit))
116+
}
117+
118+
@Test
119+
fun `filter treats web_view as a leaf component`() {
120+
// web_view has no child components, so filtering a tree should return only the component itself.
121+
val webView = WebViewComponent(url = "https://paywalls.revenuecat.com/index.html")
122+
123+
val matches = webView.filter { it is WebViewComponent }
124+
125+
assertThat(matches).containsExactly(webView)
126+
}
127+
}

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 -> false
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
}

upstream/paywall-preview-resources

0 commit comments

Comments
 (0)