Skip to content

Commit bc0f4e0

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

7 files changed

Lines changed: 260 additions & 1 deletion

File tree

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

Lines changed: 7 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,11 @@ internal fun ComponentView(
7375
modifier = modifier,
7476
)
7577
}
78+
is WebViewComponentStyle -> WebViewComponentView(
79+
style = style,
80+
state = state,
81+
modifier = modifier,
82+
)
7683
is ButtonComponentStyle -> ButtonComponentView(
7784
style = style,
7885
state = state,

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

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -574,13 +574,24 @@ 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 createWebViewComponentStyle(
585+
component: WebViewComponent,
586+
): Result<WebViewComponentStyle, NonEmptyList<PaywallValidationError>> =
587+
Result.Success(
588+
WebViewComponentStyle(
589+
urlTemplate = component.url,
590+
visible = component.visible ?: DEFAULT_VISIBILITY,
591+
size = component.size,
592+
),
593+
)
594+
584595
private fun StyleFactoryScope.createCountdownComponentStyle(
585596
component: CountdownComponent,
586597
): Result<CountdownComponentStyle, NonEmptyList<PaywallValidationError>> =
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
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+
) : ComponentStyle
Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
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.key
12+
import androidx.compose.runtime.remember
13+
import androidx.compose.ui.Modifier
14+
import androidx.compose.ui.viewinterop.AndroidView
15+
import com.revenuecat.purchases.ui.revenuecatui.components.modifier.size
16+
import com.revenuecat.purchases.ui.revenuecatui.components.style.WebViewComponentStyle
17+
import com.revenuecat.purchases.ui.revenuecatui.data.PaywallState
18+
19+
@JvmSynthetic
20+
@Composable
21+
internal fun WebViewComponentView(
22+
style: WebViewComponentStyle,
23+
state: PaywallState.Loaded.Components,
24+
modifier: Modifier = Modifier,
25+
) {
26+
if (!style.visible) return
27+
28+
// Key on state.locale (a derivedState over the paywall's mutable locale) as well: a locale change
29+
// mutates the same PaywallState instance in place, so without it the resolved URL — and the
30+
// key(resolvedUrl) below — would stay stale for a locale-dependent template.
31+
val resolvedUrl = remember(style.urlTemplate, state, state.locale) {
32+
WebViewUrlResolver.resolve(style.urlTemplate, state)
33+
}
34+
// The web view URL is missing or did not resolve to a valid HTTPS URL with a host. web_view
35+
// availability is gated by SDK version on the frontend, so a delivered web_view is expected to
36+
// always resolve; render nothing rather than crashing if it doesn't.
37+
if (resolvedUrl == null) return
38+
39+
// Key on the resolved URL so the WebView is created (and the page loaded) exactly once per intended
40+
// URL. We deliberately do NOT reload on every recomposition: in-page navigation changes WebView.url,
41+
// and reloading whenever it differs from resolvedUrl would reset a multi-step web flow. The WebView
42+
// is only recreated when the SDK-resolved URL itself changes (e.g. a locale-dependent template).
43+
key(resolvedUrl) {
44+
AndroidView(
45+
factory = { context ->
46+
WebView(context).apply {
47+
configure()
48+
loadUrl(resolvedUrl.toString())
49+
}
50+
},
51+
onRelease = { webView ->
52+
webView.stopLoading()
53+
webView.webViewClient = WebViewClient()
54+
webView.destroy()
55+
},
56+
modifier = modifier.size(style.size),
57+
)
58+
}
59+
}
60+
61+
private fun WebView.configure() {
62+
setBackgroundColor(Color.TRANSPARENT)
63+
isVerticalScrollBarEnabled = false
64+
isHorizontalScrollBarEnabled = false
65+
settings.allowContentAccess = false
66+
settings.allowFileAccess = false
67+
settings.cacheMode = WebSettings.LOAD_DEFAULT
68+
settings.domStorageEnabled = true
69+
settings.javaScriptEnabled = true
70+
settings.mixedContentMode = WebSettings.MIXED_CONTENT_NEVER_ALLOW
71+
webViewClient = object : WebViewClient() {
72+
override fun shouldOverrideUrlLoading(view: WebView, request: WebResourceRequest): Boolean {
73+
return request.url.scheme != HTTPS_SCHEME
74+
}
75+
}
76+
}
77+
78+
private const val HTTPS_SCHEME = "https"
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
@file:JvmSynthetic
2+
3+
package com.revenuecat.purchases.ui.revenuecatui.components.webview
4+
5+
import com.revenuecat.purchases.UiConfig
6+
import com.revenuecat.purchases.ui.revenuecatui.CustomVariableValue
7+
import com.revenuecat.purchases.ui.revenuecatui.components.ktx.toJavaLocale
8+
import com.revenuecat.purchases.ui.revenuecatui.data.PaywallState
9+
import com.revenuecat.purchases.ui.revenuecatui.data.processed.VariableProcessorV2
10+
import java.net.URL
11+
import java.util.Locale
12+
13+
internal object WebViewUrlResolver {
14+
15+
fun resolve(
16+
urlTemplate: String,
17+
state: PaywallState.Loaded.Components,
18+
): URL? = resolve(
19+
urlTemplate = urlTemplate,
20+
variableConfig = state.variableConfig,
21+
customVariables = state.customVariables,
22+
defaultCustomVariables = state.defaultCustomVariables,
23+
locale = state.locale.toJavaLocale(),
24+
)
25+
26+
fun resolve(
27+
urlTemplate: String,
28+
variableConfig: UiConfig.VariableConfig,
29+
customVariables: Map<String, CustomVariableValue>,
30+
defaultCustomVariables: Map<String, CustomVariableValue>,
31+
locale: Locale,
32+
): URL? {
33+
val resolvedUrl = VariableProcessorV2.processVariables(
34+
template = urlTemplate,
35+
variableConfig = variableConfig,
36+
dateLocale = locale,
37+
customVariables = customVariables,
38+
defaultCustomVariables = defaultCustomVariables,
39+
)
40+
41+
return runCatching { URL(resolvedUrl) }
42+
.getOrNull()
43+
?.takeIf { it.protocol == HTTPS_SCHEME && it.host.isNotBlank() }
44+
}
45+
46+
private const val HTTPS_SCHEME = "https"
47+
}

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

Lines changed: 20 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,25 @@ 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+
}
140+
121141
@Test
122142
fun `Should create a StackComponentStyle with children for a StackComponent with children`() {
123143
// Arrange
Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
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 `substitutes custom variables into the template before validating`() {
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 `returns null for malformed url`() {
40+
val result = resolve("not a url")
41+
42+
assertThat(result).isNull()
43+
}
44+
45+
@Test
46+
fun `returns null for malformed https url`() {
47+
val result = resolve("https:///missing-host")
48+
49+
assertThat(result).isNull()
50+
}
51+
52+
@Test
53+
fun `returns null for non https url`() {
54+
val result = resolve("http://paywalls.revenuecat.com/index.html")
55+
56+
assertThat(result).isNull()
57+
}
58+
59+
@Test
60+
fun `returns null for file url`() {
61+
val result = resolve("file:///android_asset/index.html")
62+
63+
assertThat(result).isNull()
64+
}
65+
66+
@Test
67+
fun `returns null for custom scheme url`() {
68+
val result = resolve("myapp://paywalls.revenuecat.com/index.html")
69+
70+
assertThat(result).isNull()
71+
}
72+
73+
private fun resolve(
74+
template: String,
75+
customVariables: Map<String, CustomVariableValue> = emptyMap(),
76+
) = WebViewUrlResolver.resolve(
77+
urlTemplate = template,
78+
variableConfig = UiConfig.VariableConfig(),
79+
customVariables = customVariables,
80+
defaultCustomVariables = emptyMap(),
81+
locale = Locale.US,
82+
)
83+
}

0 commit comments

Comments
 (0)