Skip to content

Commit 61039df

Browse files
committed
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
1 parent 6b4ecb1 commit 61039df

7 files changed

Lines changed: 342 additions & 1 deletion

File tree

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

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,12 +30,14 @@ import com.revenuecat.purchases.ui.revenuecatui.components.style.TabsComponentSt
3030
import com.revenuecat.purchases.ui.revenuecatui.components.style.TextComponentStyle
3131
import com.revenuecat.purchases.ui.revenuecatui.components.style.TimelineComponentStyle
3232
import com.revenuecat.purchases.ui.revenuecatui.components.style.VideoComponentStyle
33+
import com.revenuecat.purchases.ui.revenuecatui.components.style.WebViewComponentStyle
3334
import com.revenuecat.purchases.ui.revenuecatui.components.tabs.TabControlButtonView
3435
import com.revenuecat.purchases.ui.revenuecatui.components.tabs.TabControlToggleView
3536
import com.revenuecat.purchases.ui.revenuecatui.components.tabs.TabsComponentView
3637
import com.revenuecat.purchases.ui.revenuecatui.components.text.TextComponentView
3738
import com.revenuecat.purchases.ui.revenuecatui.components.timeline.TimelineComponentView
3839
import com.revenuecat.purchases.ui.revenuecatui.components.video.VideoComponentView
40+
import com.revenuecat.purchases.ui.revenuecatui.components.webview.WebViewComponentView
3941
import com.revenuecat.purchases.ui.revenuecatui.data.PaywallState
4042
import com.revenuecat.purchases.ui.revenuecatui.helpers.PaywallComponentInteractionTracker
4143

@@ -73,6 +75,13 @@ internal fun ComponentView(
7375
modifier = modifier,
7476
)
7577
}
78+
is WebViewComponentStyle -> WebViewComponentView(
79+
style = style,
80+
state = state,
81+
onClick = onClick,
82+
componentInteractionTracker = componentInteractionTracker,
83+
modifier = modifier,
84+
)
7685
is ButtonComponentStyle -> ButtonComponentView(
7786
style = style,
7887
state = state,

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

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -574,13 +574,26 @@ internal class StyleFactory(
574574
is TabsComponent -> createTabsComponentStyle(component)
575575
is VideoComponent -> createVideoComponentStyle(component)
576576
is FallbackHeaderComponent -> Result.Success(null)
577-
is WebViewComponent -> Result.Success(null)
577+
is WebViewComponent -> createWebViewComponentStyle(component)
578578
is CountdownComponent -> createCountdownComponentStyle(
579579
component,
580580
)
581581
}
582582
}
583583

584+
private fun StyleFactoryScope.createWebViewComponentStyle(
585+
component: WebViewComponent,
586+
): Result<WebViewComponentStyle, NonEmptyList<PaywallValidationError>> =
587+
(component.fallback?.let { createStackComponentStyle(it) }.orSuccessfullyNull())
588+
.map { fallbackStyle ->
589+
WebViewComponentStyle(
590+
urlTemplate = component.url,
591+
visible = component.visible ?: DEFAULT_VISIBILITY,
592+
size = component.size,
593+
fallbackStackComponentStyle = fallbackStyle,
594+
)
595+
}
596+
584597
private fun StyleFactoryScope.createCountdownComponentStyle(
585598
component: CountdownComponent,
586599
): Result<CountdownComponentStyle, NonEmptyList<PaywallValidationError>> =
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
@file:JvmSynthetic
2+
3+
package com.revenuecat.purchases.ui.revenuecatui.components.style
4+
5+
import androidx.compose.runtime.Immutable
6+
import com.revenuecat.purchases.paywalls.components.properties.Size
7+
8+
@Immutable
9+
internal data class WebViewComponentStyle(
10+
val urlTemplate: String,
11+
override val visible: Boolean,
12+
override val size: Size,
13+
val fallbackStackComponentStyle: StackComponentStyle? = null,
14+
) : ComponentStyle
Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
@file:JvmSynthetic
2+
3+
package com.revenuecat.purchases.ui.revenuecatui.components.webview
4+
5+
import android.graphics.Color
6+
import android.webkit.WebResourceRequest
7+
import android.webkit.WebSettings
8+
import android.webkit.WebView
9+
import android.webkit.WebViewClient
10+
import androidx.compose.runtime.Composable
11+
import androidx.compose.runtime.remember
12+
import androidx.compose.ui.Modifier
13+
import androidx.compose.ui.viewinterop.AndroidView
14+
import com.revenuecat.purchases.ui.revenuecatui.components.PaywallAction
15+
import com.revenuecat.purchases.ui.revenuecatui.components.modifier.size
16+
import com.revenuecat.purchases.ui.revenuecatui.components.stack.StackComponentView
17+
import com.revenuecat.purchases.ui.revenuecatui.components.style.WebViewComponentStyle
18+
import com.revenuecat.purchases.ui.revenuecatui.data.PaywallState
19+
import com.revenuecat.purchases.ui.revenuecatui.helpers.PaywallComponentInteractionTracker
20+
21+
@JvmSynthetic
22+
@Composable
23+
internal fun WebViewComponentView(
24+
style: WebViewComponentStyle,
25+
state: PaywallState.Loaded.Components,
26+
onClick: suspend (PaywallAction) -> Unit,
27+
modifier: Modifier = Modifier,
28+
componentInteractionTracker: PaywallComponentInteractionTracker = PaywallComponentInteractionTracker { _ -> },
29+
) {
30+
if (!style.visible) return
31+
32+
val resolvedUrl = remember(style.urlTemplate, state) {
33+
WebViewUrlResolver.resolve(style.urlTemplate, state)
34+
}
35+
if (resolvedUrl == null) {
36+
// The web view URL is missing or did not resolve to a valid HTTPS URL with a host, so we
37+
// render the fallback stack instead of rendering nothing.
38+
// Note: rendering the fallback when the web content fails to load at runtime
39+
// (WebViewClient.onReceivedError) is intentionally deferred to a future change.
40+
style.fallbackStackComponentStyle?.let { fallbackStyle ->
41+
StackComponentView(
42+
style = fallbackStyle,
43+
state = state,
44+
clickHandler = onClick,
45+
modifier = modifier,
46+
componentInteractionTracker = componentInteractionTracker,
47+
)
48+
}
49+
return
50+
}
51+
52+
AndroidView(
53+
factory = { context ->
54+
WebView(context).apply {
55+
configure()
56+
loadUrl(resolvedUrl.toString())
57+
}
58+
},
59+
update = { webView ->
60+
if (webView.url != resolvedUrl.toString()) {
61+
webView.loadUrl(resolvedUrl.toString())
62+
}
63+
},
64+
onRelease = { webView ->
65+
webView.stopLoading()
66+
webView.webViewClient = WebViewClient()
67+
webView.destroy()
68+
},
69+
modifier = modifier.size(style.size),
70+
)
71+
}
72+
73+
private fun WebView.configure() {
74+
setBackgroundColor(Color.TRANSPARENT)
75+
isVerticalScrollBarEnabled = false
76+
isHorizontalScrollBarEnabled = false
77+
settings.allowContentAccess = false
78+
settings.allowFileAccess = false
79+
settings.cacheMode = WebSettings.LOAD_DEFAULT
80+
settings.domStorageEnabled = true
81+
settings.javaScriptEnabled = true
82+
settings.mixedContentMode = WebSettings.MIXED_CONTENT_NEVER_ALLOW
83+
webViewClient = object : WebViewClient() {
84+
override fun shouldOverrideUrlLoading(view: WebView, request: WebResourceRequest): Boolean {
85+
return request.url.scheme != HTTPS_SCHEME
86+
}
87+
}
88+
}
89+
90+
private const val HTTPS_SCHEME = "https"
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
package com.revenuecat.purchases.ui.revenuecatui.components.webview
2+
3+
import com.revenuecat.purchases.UiConfig
4+
import com.revenuecat.purchases.ui.revenuecatui.CustomVariableValue
5+
import com.revenuecat.purchases.ui.revenuecatui.components.ktx.toJavaLocale
6+
import com.revenuecat.purchases.ui.revenuecatui.data.PaywallState
7+
import com.revenuecat.purchases.ui.revenuecatui.data.processed.VariableProcessorV2
8+
import java.net.URL
9+
import java.util.Locale
10+
11+
internal object WebViewUrlResolver {
12+
13+
fun resolve(
14+
urlTemplate: String,
15+
state: PaywallState.Loaded.Components,
16+
): URL? = resolve(
17+
urlTemplate = urlTemplate,
18+
variableConfig = state.variableConfig,
19+
customVariables = state.customVariables,
20+
defaultCustomVariables = state.defaultCustomVariables,
21+
locale = state.locale.toJavaLocale(),
22+
)
23+
24+
fun resolve(
25+
urlTemplate: String,
26+
variableConfig: UiConfig.VariableConfig,
27+
customVariables: Map<String, CustomVariableValue>,
28+
defaultCustomVariables: Map<String, CustomVariableValue>,
29+
locale: Locale,
30+
): URL? {
31+
val resolvedUrl = VariableProcessorV2.processVariables(
32+
template = urlTemplate,
33+
variableConfig = variableConfig,
34+
dateLocale = locale,
35+
customVariables = customVariables,
36+
defaultCustomVariables = defaultCustomVariables,
37+
)
38+
39+
return runCatching { URL(resolvedUrl) }
40+
.getOrNull()
41+
?.takeIf { it.protocol == HTTPS_SCHEME && it.host.isNotBlank() }
42+
}
43+
44+
private const val HTTPS_SCHEME = "https"
45+
}

ui/revenuecatui/src/test/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/style/StyleFactoryTests.kt

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import com.revenuecat.purchases.paywalls.components.TabControlComponent
2222
import com.revenuecat.purchases.paywalls.components.TabControlToggleComponent
2323
import com.revenuecat.purchases.paywalls.components.TabsComponent
2424
import com.revenuecat.purchases.paywalls.components.TextComponent
25+
import com.revenuecat.purchases.paywalls.components.WebViewComponent
2526
import com.revenuecat.purchases.paywalls.components.common.Background
2627
import com.revenuecat.purchases.paywalls.components.common.ComponentOverride
2728
import com.revenuecat.purchases.paywalls.components.common.LocaleId
@@ -118,6 +119,50 @@ class StyleFactoryTests {
118119
assertThat(colorStyle.color).isEqualTo(expectedColor)
119120
}
120121

122+
@Test
123+
fun `Should create a WebViewComponentStyle for a WebViewComponent`() {
124+
val size = Size(width = SizeConstraint.Fill, height = SizeConstraint.Fit)
125+
val component = WebViewComponent(
126+
url = "https://paywalls.revenuecat.com/{{ custom.animal }}.html",
127+
id = "promo_web_view",
128+
visible = false,
129+
size = size,
130+
)
131+
132+
val result = styleFactory.create(component)
133+
134+
assertThat(result).isInstanceOf(Result.Success::class.java)
135+
val style = (result as Result.Success).value.componentStyle as WebViewComponentStyle
136+
assertThat(style.urlTemplate).isEqualTo("https://paywalls.revenuecat.com/{{ custom.animal }}.html")
137+
assertThat(style.visible).isFalse()
138+
assertThat(style.size).isEqualTo(size)
139+
assertThat(style.fallbackStackComponentStyle).isNull()
140+
}
141+
142+
@Test
143+
fun `Should create a WebViewComponentStyle with a fallback stack for a WebViewComponent with fallback`() {
144+
val component = WebViewComponent(
145+
url = "https://paywalls.revenuecat.com/index.html",
146+
fallback = StackComponent(
147+
components = listOf(
148+
TextComponent(
149+
text = LOCALIZATION_KEY_TEXT_1,
150+
color = ColorScheme(light = ColorInfo.Hex(Color.Yellow.toArgb())),
151+
),
152+
),
153+
),
154+
)
155+
156+
val result = styleFactory.create(component)
157+
158+
assertThat(result).isInstanceOf(Result.Success::class.java)
159+
val style = (result as Result.Success).value.componentStyle as WebViewComponentStyle
160+
val fallback = style.fallbackStackComponentStyle
161+
assertThat(fallback).isNotNull
162+
assertThat(fallback!!.children).hasSize(1)
163+
assertThat(fallback.children[0]).isInstanceOf(TextComponentStyle::class.java)
164+
}
165+
121166
@Test
122167
fun `Should create a StackComponentStyle with children for a StackComponent with children`() {
123168
// Arrange
Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
1+
package com.revenuecat.purchases.ui.revenuecatui.components.webview
2+
3+
import com.revenuecat.purchases.UiConfig
4+
import com.revenuecat.purchases.ui.revenuecatui.CustomVariableValue
5+
import org.assertj.core.api.Assertions.assertThat
6+
import org.junit.Test
7+
import org.junit.runner.RunWith
8+
import org.robolectric.RobolectricTestRunner
9+
import java.util.Locale
10+
11+
@RunWith(RobolectricTestRunner::class)
12+
class WebViewUrlResolverTest {
13+
14+
@Test
15+
fun `resolves static https url unchanged`() {
16+
val result = resolve("https://paywalls.revenuecat.com/index.html")
17+
18+
assertThat(result?.toString()).isEqualTo("https://paywalls.revenuecat.com/index.html")
19+
}
20+
21+
@Test
22+
fun `resolves static https url with characters accepted by WebView`() {
23+
val result = resolve("https://paywalls.revenuecat.com/index.html?filters=a|b")
24+
25+
assertThat(result?.toString()).isEqualTo("https://paywalls.revenuecat.com/index.html?filters=a|b")
26+
}
27+
28+
@Test
29+
fun `resolves runtime custom variable`() {
30+
val result = resolve(
31+
template = "https://paywalls.revenuecat.com/{{ custom.animal }}.html",
32+
customVariables = mapOf("animal" to CustomVariableValue.String("dog")),
33+
)
34+
35+
assertThat(result?.toString()).isEqualTo("https://paywalls.revenuecat.com/dog.html")
36+
}
37+
38+
@Test
39+
fun `falls back to dashboard default custom variable`() {
40+
val result = resolve(
41+
template = "https://paywalls.revenuecat.com/{{ custom.animal }}.html",
42+
defaultCustomVariables = mapOf("animal" to CustomVariableValue.String("cat")),
43+
)
44+
45+
assertThat(result?.toString()).isEqualTo("https://paywalls.revenuecat.com/cat.html")
46+
}
47+
48+
@Test
49+
fun `runtime custom variable overrides dashboard default`() {
50+
val result = resolve(
51+
template = "https://paywalls.revenuecat.com/{{ custom.animal }}.html",
52+
customVariables = mapOf("animal" to CustomVariableValue.String("bird")),
53+
defaultCustomVariables = mapOf("animal" to CustomVariableValue.String("cat")),
54+
)
55+
56+
assertThat(result?.toString()).isEqualTo("https://paywalls.revenuecat.com/bird.html")
57+
}
58+
59+
@Test
60+
fun `supports custom variable functions`() {
61+
val result = resolve(
62+
template = "https://paywalls.revenuecat.com/{{ custom.animal | lowercase }}.html",
63+
customVariables = mapOf("animal" to CustomVariableValue.String("DOG")),
64+
)
65+
66+
assertThat(result?.toString()).isEqualTo("https://paywalls.revenuecat.com/dog.html")
67+
}
68+
69+
@Test
70+
fun `missing custom variable can still resolve to valid url`() {
71+
val result = resolve("https://paywalls.revenuecat.com/{{ custom.missing }}.html")
72+
73+
assertThat(result?.toString()).isEqualTo("https://paywalls.revenuecat.com/.html")
74+
}
75+
76+
@Test
77+
fun `returns null for malformed url`() {
78+
val result = resolve("not a url")
79+
80+
assertThat(result).isNull()
81+
}
82+
83+
@Test
84+
fun `returns null for malformed https url`() {
85+
val result = resolve("https:///missing-host")
86+
87+
assertThat(result).isNull()
88+
}
89+
90+
@Test
91+
fun `returns null for non https url`() {
92+
val result = resolve(
93+
template = "http://paywalls.revenuecat.com/{{ custom.animal }}.html",
94+
customVariables = mapOf("animal" to CustomVariableValue.String("dog")),
95+
)
96+
97+
assertThat(result).isNull()
98+
}
99+
100+
@Test
101+
fun `returns null for file url`() {
102+
val result = resolve("file:///android_asset/index.html")
103+
104+
assertThat(result).isNull()
105+
}
106+
107+
@Test
108+
fun `returns null for custom scheme url`() {
109+
val result = resolve("myapp://paywalls.revenuecat.com/index.html")
110+
111+
assertThat(result).isNull()
112+
}
113+
114+
private fun resolve(
115+
template: String,
116+
customVariables: Map<String, CustomVariableValue> = emptyMap(),
117+
defaultCustomVariables: Map<String, CustomVariableValue> = emptyMap(),
118+
) = WebViewUrlResolver.resolve(
119+
urlTemplate = template,
120+
variableConfig = UiConfig.VariableConfig(),
121+
customVariables = customVariables,
122+
defaultCustomVariables = defaultCustomVariables,
123+
locale = Locale.US,
124+
)
125+
}

0 commit comments

Comments
 (0)