diff --git a/examples/paywall-tester/src/main/java/com/revenuecat/paywallstester/SamplePaywalls.kt b/examples/paywall-tester/src/main/java/com/revenuecat/paywallstester/SamplePaywalls.kt index 24d0804113..36f11eb1e1 100644 --- a/examples/paywall-tester/src/main/java/com/revenuecat/paywallstester/SamplePaywalls.kt +++ b/examples/paywall-tester/src/main/java/com/revenuecat/paywallstester/SamplePaywalls.kt @@ -6,6 +6,7 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.toArgb import com.revenuecat.paywallstester.paywalls.tabsWithButtons import com.revenuecat.paywallstester.paywalls.tabsWithToggle +import com.revenuecat.paywallstester.paywalls.webViewSample import com.revenuecat.purchases.FontAlias import com.revenuecat.purchases.InternalRevenueCatAPI import com.revenuecat.purchases.Offering @@ -85,6 +86,7 @@ class SamplePaywallsLoader { ) } + @Suppress("CyclomaticComplexMethod") private fun paywallForTemplate(template: SamplePaywalls.SampleTemplate): SampleData { return when (template) { SamplePaywalls.SampleTemplate.TEMPLATE_1 -> SamplePaywalls.template1() @@ -101,6 +103,7 @@ class SamplePaywallsLoader { SamplePaywalls.SampleTemplate.TABS_BUTTONS -> tabsWithButtons() SamplePaywalls.SampleTemplate.TABS_TOGGLE -> tabsWithToggle() + SamplePaywalls.SampleTemplate.COMPONENTS_WEB_VIEW -> webViewSample() SamplePaywalls.SampleTemplate.UNRECOGNIZED_TEMPLATE -> SamplePaywalls.unrecognizedTemplate() } } @@ -128,6 +131,7 @@ object SamplePaywalls { COMPONENTS_BLESS_GOOGLE_FONT("#11: Components - bless. - Google font"), TABS_BUTTONS("#12: Tabs - buttons"), TABS_TOGGLE("#13 Tabs - toggle"), + COMPONENTS_WEB_VIEW("#14: Components - web_view"), UNRECOGNIZED_TEMPLATE("Default template"), } diff --git a/examples/paywall-tester/src/main/java/com/revenuecat/paywallstester/paywalls/WebViewSample.kt b/examples/paywall-tester/src/main/java/com/revenuecat/paywallstester/paywalls/WebViewSample.kt new file mode 100644 index 0000000000..01cf6662d3 --- /dev/null +++ b/examples/paywall-tester/src/main/java/com/revenuecat/paywallstester/paywalls/WebViewSample.kt @@ -0,0 +1,134 @@ +package com.revenuecat.paywallstester.paywalls + +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.toArgb +import com.revenuecat.paywallstester.SampleData +import com.revenuecat.purchases.InternalRevenueCatAPI +import com.revenuecat.purchases.paywalls.components.ButtonComponent +import com.revenuecat.purchases.paywalls.components.StackComponent +import com.revenuecat.purchases.paywalls.components.TextComponent +import com.revenuecat.purchases.paywalls.components.WebViewComponent +import com.revenuecat.purchases.paywalls.components.common.Background +import com.revenuecat.purchases.paywalls.components.common.ComponentsConfig +import com.revenuecat.purchases.paywalls.components.common.LocaleId +import com.revenuecat.purchases.paywalls.components.common.LocalizationData +import com.revenuecat.purchases.paywalls.components.common.LocalizationKey +import com.revenuecat.purchases.paywalls.components.common.PaywallComponentsConfig +import com.revenuecat.purchases.paywalls.components.common.PaywallComponentsData +import com.revenuecat.purchases.paywalls.components.properties.ColorInfo +import com.revenuecat.purchases.paywalls.components.properties.ColorScheme +import com.revenuecat.purchases.paywalls.components.properties.Dimension.Vertical +import com.revenuecat.purchases.paywalls.components.properties.Dimension.ZLayer +import com.revenuecat.purchases.paywalls.components.properties.FlexDistribution.END +import com.revenuecat.purchases.paywalls.components.properties.FontWeight +import com.revenuecat.purchases.paywalls.components.properties.HorizontalAlignment.LEADING +import com.revenuecat.purchases.paywalls.components.properties.Padding +import com.revenuecat.purchases.paywalls.components.properties.Shape +import com.revenuecat.purchases.paywalls.components.properties.Size +import com.revenuecat.purchases.paywalls.components.properties.SizeConstraint.Fill +import com.revenuecat.purchases.paywalls.components.properties.SizeConstraint.Fit +import com.revenuecat.purchases.paywalls.components.properties.SizeConstraint.Fixed +import com.revenuecat.purchases.paywalls.components.properties.TwoDimensionalAlignment +import java.net.URL + +private const val PLACEHOLDER_WEB_VIEW_URL = "https://example.com" + +@OptIn(InternalRevenueCatAPI::class) +@Suppress("LongMethod") +internal fun webViewSample(): SampleData.Components { + val textColor = ColorScheme( + light = ColorInfo.Hex(Color.Black.toArgb()), + dark = ColorInfo.Hex(Color.White.toArgb()), + ) + val backgroundColor = ColorScheme( + light = ColorInfo.Hex(Color.White.toArgb()), + dark = ColorInfo.Hex(Color.Black.toArgb()), + ) + val accentColor = ColorScheme( + light = ColorInfo.Hex(Color(red = 5, green = 124, blue = 91).toArgb()), + ) + + return SampleData.Components( + data = PaywallComponentsData( + id = "sample_web_view_paywall_id", + templateName = "template", + assetBaseURL = URL("https://assets.pawwalls.com"), + componentsConfig = ComponentsConfig( + base = PaywallComponentsConfig( + stack = StackComponent( + components = listOf( + TextComponent( + text = LocalizationKey("title"), + color = textColor, + fontWeight = FontWeight.BOLD, + fontSize = 24, + horizontalAlignment = LEADING, + size = Size(width = Fill, height = Fit), + margin = Padding(top = 16.0, bottom = 8.0, leading = 16.0, trailing = 16.0), + ), + WebViewComponent( + id = "sample_web_view", + url = PLACEHOLDER_WEB_VIEW_URL, + size = Size(width = Fill, height = Fixed(400u)), + fallback = StackComponent( + components = listOf( + TextComponent( + text = LocalizationKey("fallback"), + color = textColor, + horizontalAlignment = LEADING, + size = Size(width = Fill, height = Fit), + padding = Padding( + top = 16.0, + bottom = 16.0, + leading = 16.0, + trailing = 16.0, + ), + ), + ), + dimension = ZLayer(alignment = TwoDimensionalAlignment.CENTER), + size = Size(width = Fill, height = Fixed(400u)), + ), + ), + ButtonComponent( + action = ButtonComponent.Action.RestorePurchases, + stack = StackComponent( + components = listOf( + TextComponent( + text = LocalizationKey("cta"), + color = ColorScheme(light = ColorInfo.Hex(Color.White.toArgb())), + fontWeight = FontWeight.BOLD, + ), + ), + dimension = ZLayer(alignment = TwoDimensionalAlignment.CENTER), + size = Size(width = Fit, height = Fit), + backgroundColor = accentColor, + padding = Padding( + top = 8.0, + bottom = 8.0, + leading = 32.0, + trailing = 32.0, + ), + margin = Padding(top = 16.0, bottom = 16.0, leading = 16.0, trailing = 16.0), + shape = Shape.Pill, + ), + ), + ), + dimension = Vertical(alignment = LEADING, distribution = END), + size = Size(width = Fill, height = Fill), + backgroundColor = backgroundColor, + ), + background = Background.Color(backgroundColor), + stickyFooter = null, + ), + ), + componentsLocalizations = mapOf( + LocaleId("en_US") to mapOf( + LocalizationKey("title") to LocalizationData.Text("Web view placeholder"), + LocalizationKey("fallback") to LocalizationData.Text("Could not load the web view."), + LocalizationKey("cta") to LocalizationData.Text("Restore purchases"), + ), + ), + defaultLocaleIdentifier = LocaleId("en_US"), + ), + ) +} diff --git a/examples/paywall-tester/src/main/java/com/revenuecat/paywallstester/ui/screens/main/paywalls/PaywallsScreen.kt b/examples/paywall-tester/src/main/java/com/revenuecat/paywallstester/ui/screens/main/paywalls/PaywallsScreen.kt index 07df1affeb..57c92ba426 100644 --- a/examples/paywall-tester/src/main/java/com/revenuecat/paywallstester/ui/screens/main/paywalls/PaywallsScreen.kt +++ b/examples/paywall-tester/src/main/java/com/revenuecat/paywallstester/ui/screens/main/paywalls/PaywallsScreen.kt @@ -44,15 +44,19 @@ import com.revenuecat.purchases.Package import com.revenuecat.purchases.PurchasesError import com.revenuecat.purchases.PurchasesErrorCode import com.revenuecat.purchases.ui.revenuecatui.OriginalTemplatePaywallFooter +import com.revenuecat.purchases.ui.revenuecatui.Paywall import com.revenuecat.purchases.ui.revenuecatui.PaywallDialog import com.revenuecat.purchases.ui.revenuecatui.PaywallDialogOptions import com.revenuecat.purchases.ui.revenuecatui.PaywallOptions +import com.revenuecat.purchases.ui.revenuecatui.PaywallWebViewMessageHandler import com.revenuecat.purchases.ui.revenuecatui.PurchaseLogic import com.revenuecat.purchases.ui.revenuecatui.PurchaseLogicResult import com.revenuecat.purchases.ui.revenuecatui.PurchaseLogicWithCallback import com.revenuecat.purchases.ui.revenuecatui.fonts.CustomFontProvider import com.revenuecat.purchases.ui.revenuecatui.fonts.FontProvider +private const val TAG = "PaywallTester" + private class TestAppPurchaseLogicSuspend : PurchaseLogic { companion object { @@ -127,6 +131,16 @@ fun PaywallsScreen( } } + val webViewMessageHandler = remember { + PaywallWebViewMessageHandler { message, _ -> + Log.d( + TAG, + "web_view message: type=${message.type} componentId=${message.componentId} " + + "error=${message.error}", + ) + } + } + LazyColumn( modifier = modifier.testTag("paywall_screen"), ) { @@ -144,6 +158,11 @@ fun PaywallsScreen( displayPaywallState = DisplayPaywallState.FullScreen( offering, purchaseLogic = myAppPurchaseLogic, + webViewMessageHandler = if (template == SamplePaywalls.SampleTemplate.COMPONENTS_WEB_VIEW) { + webViewMessageHandler + } else { + null + }, ) }, emoji = "\uD83D\uDCF1", @@ -207,15 +226,32 @@ fun PaywallsScreen( @Composable private fun FullScreenDialog(currentState: DisplayPaywallState.FullScreen, onDismiss: () -> Unit) { - PaywallDialog( - PaywallDialogOptions.Builder() - .setDismissRequest(onDismiss) - .setOffering(currentState.offering) - .setFontProvider(currentState.fontProvider) - .setCustomVariables(CustomVariablesHolder.customVariables) - .setCustomPurchaseLogic(currentState.purchaseLogic) - .build(), - ) + val paywallOptions = PaywallOptions.Builder(onDismiss) + .setOffering(currentState.offering) + .setFontProvider(currentState.fontProvider) + .setCustomVariables(CustomVariablesHolder.customVariables) + .setPurchaseLogic(currentState.purchaseLogic) + .setWebViewMessageHandler(currentState.webViewMessageHandler) + .build() + + if (currentState.webViewMessageHandler != null) { + Dialog( + onDismissRequest = onDismiss, + properties = DialogProperties(usePlatformDefaultWidth = false), + ) { + Paywall(paywallOptions) + } + } else { + PaywallDialog( + PaywallDialogOptions.Builder() + .setDismissRequest(onDismiss) + .setOffering(currentState.offering) + .setFontProvider(currentState.fontProvider) + .setCustomVariables(CustomVariablesHolder.customVariables) + .setCustomPurchaseLogic(currentState.purchaseLogic) + .build(), + ) + } } @Composable @@ -251,6 +287,7 @@ private sealed class DisplayPaywallState { val offering: Offering? = null, val fontProvider: FontProvider? = null, var purchaseLogic: PurchaseLogic? = null, + val webViewMessageHandler: PaywallWebViewMessageHandler? = null, ) : DisplayPaywallState() data class Footer( 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..a9a90d04fe --- /dev/null +++ b/purchases/src/main/kotlin/com/revenuecat/purchases/paywalls/components/WebViewComponent.kt @@ -0,0 +1,41 @@ +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), + @get:JvmSynthetic + public val fallback: StackComponent? = null, +) : 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..3f810986c0 --- /dev/null +++ b/purchases/src/test/java/com/revenuecat/purchases/paywalls/components/WebViewComponentTests.kt @@ -0,0 +1,133 @@ +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", + "fallback": { + "type": "stack", + "components": [] + } + } + """ + + @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)) + assertThat(webView.fallback).isNotNull + assertThat(webView.fallback?.components).isEmpty() + } + + @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/api.txt b/ui/revenuecatui/api.txt index 692de54744..c2b06c367d 100644 --- a/ui/revenuecatui/api.txt +++ b/ui/revenuecatui/api.txt @@ -96,11 +96,13 @@ package com.revenuecat.purchases.ui.revenuecatui { method public com.revenuecat.purchases.ui.revenuecatui.fonts.FontProvider? getFontProvider(); method public com.revenuecat.purchases.ui.revenuecatui.PaywallListener? getListener(); method public com.revenuecat.purchases.ui.revenuecatui.PaywallPurchaseLogic? getPurchaseLogic(); + method public com.revenuecat.purchases.ui.revenuecatui.PaywallWebViewMessageHandler? getWebViewMessageHandler(); property public final java.util.Map customVariables; property public final kotlin.jvm.functions.Function0 dismissRequest; property public final com.revenuecat.purchases.ui.revenuecatui.fonts.FontProvider? fontProvider; property public final com.revenuecat.purchases.ui.revenuecatui.PaywallListener? listener; property public final com.revenuecat.purchases.ui.revenuecatui.PaywallPurchaseLogic? purchaseLogic; + property public final com.revenuecat.purchases.ui.revenuecatui.PaywallWebViewMessageHandler? webViewMessageHandler; field public static final com.revenuecat.purchases.ui.revenuecatui.PaywallOptions.Companion Companion; } @@ -113,6 +115,7 @@ package com.revenuecat.purchases.ui.revenuecatui { method public com.revenuecat.purchases.ui.revenuecatui.PaywallOptions.Builder setOffering(com.revenuecat.purchases.Offering? offering); method public com.revenuecat.purchases.ui.revenuecatui.PaywallOptions.Builder setPurchaseLogic(com.revenuecat.purchases.ui.revenuecatui.PaywallPurchaseLogic? purchaseLogic); method public com.revenuecat.purchases.ui.revenuecatui.PaywallOptions.Builder setShouldDisplayDismissButton(boolean shouldDisplayDismissButton); + method public com.revenuecat.purchases.ui.revenuecatui.PaywallOptions.Builder setWebViewMessageHandler(com.revenuecat.purchases.ui.revenuecatui.PaywallWebViewMessageHandler? handler); } public static final class PaywallOptions.Companion { @@ -150,6 +153,67 @@ package com.revenuecat.purchases.ui.revenuecatui { method public abstract void performRestoreWithCompletion(com.revenuecat.purchases.CustomerInfo customerInfo, kotlin.jvm.functions.Function1 completion); } + public interface PaywallWebViewController { + method public void postMessage(String componentId, String type, java.util.Map variables); + method public void postVariables(String componentId, java.util.Map variables); + } + + @dev.drewhamilton.poko.Poko public final class PaywallWebViewMessage { + ctor public PaywallWebViewMessage(String componentId, String type, optional java.util.Map? responses, optional String? error); + method public String getComponentId(); + method public String? getError(); + method public java.util.Map? getResponses(); + method public String getType(); + property public final String componentId; + property public final String? error; + property public final java.util.Map? responses; + property public final String type; + } + + public fun interface PaywallWebViewMessageHandler { + method public void onMessage(com.revenuecat.purchases.ui.revenuecatui.PaywallWebViewMessage message, com.revenuecat.purchases.ui.revenuecatui.PaywallWebViewController controller); + } + + public abstract class PaywallWebViewValue { + } + + public static final class PaywallWebViewValue.Array extends com.revenuecat.purchases.ui.revenuecatui.PaywallWebViewValue { + ctor public PaywallWebViewValue.Array(java.util.List value); + method public java.util.List getValue(); + property public final java.util.List value; + } + + public static final class PaywallWebViewValue.Boolean extends com.revenuecat.purchases.ui.revenuecatui.PaywallWebViewValue { + ctor public PaywallWebViewValue.Boolean(boolean value); + method public boolean getValue(); + property public final boolean value; + } + + public static final class PaywallWebViewValue.Null extends com.revenuecat.purchases.ui.revenuecatui.PaywallWebViewValue { + field public static final com.revenuecat.purchases.ui.revenuecatui.PaywallWebViewValue.Null INSTANCE; + } + + public static final class PaywallWebViewValue.Number extends com.revenuecat.purchases.ui.revenuecatui.PaywallWebViewValue { + ctor public PaywallWebViewValue.Number(double value); + ctor public PaywallWebViewValue.Number(float value); + ctor public PaywallWebViewValue.Number(int value); + ctor public PaywallWebViewValue.Number(long value); + method public double getValue(); + property public final double value; + } + + public static final class PaywallWebViewValue.Object extends com.revenuecat.purchases.ui.revenuecatui.PaywallWebViewValue { + ctor public PaywallWebViewValue.Object(java.util.Map value); + method public java.util.Map getValue(); + property public final java.util.Map value; + } + + public static final class PaywallWebViewValue.String extends com.revenuecat.purchases.ui.revenuecatui.PaywallWebViewValue { + ctor public PaywallWebViewValue.String(String value); + method public String getValue(); + property public final String value; + } + @Deprecated public interface PurchaseLogic extends com.revenuecat.purchases.ui.revenuecatui.PaywallPurchaseLogic { method @Deprecated public suspend Object? performPurchase(android.app.Activity activity, com.revenuecat.purchases.Package rcPackage, kotlin.coroutines.Continuation); method @Deprecated public default suspend Object? performPurchase(android.app.Activity activity, com.revenuecat.purchases.ui.revenuecatui.PaywallPurchaseLogicParams params, kotlin.coroutines.Continuation); diff --git a/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/PaywallOptions.kt b/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/PaywallOptions.kt index 84d05cee2e..c692d7b9d5 100644 --- a/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/PaywallOptions.kt +++ b/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/PaywallOptions.kt @@ -60,6 +60,11 @@ public class PaywallOptions internal constructor( * `{{ $custom.key }}` placeholders in the paywall configuration. */ public val customVariables: Map = emptyMap(), + /** + * Handler for messages sent by Paywalls V2 `web_view` components. Lets app code receive validated + * messages and reply to them. See [PaywallWebViewMessageHandler]. + */ + public val webViewMessageHandler: PaywallWebViewMessageHandler? = null, internal val injectedWorkflow: WorkflowDataResult? = null, internal val injectedWorkflowUiConfig: UiConfig = UiConfig(), ) { @@ -77,6 +82,7 @@ public class PaywallOptions internal constructor( dismissRequest = builder.dismissRequest, dismissRequestWithExitOffering = builder.dismissRequestWithExitOffering, customVariables = builder.customVariables, + webViewMessageHandler = builder.webViewMessageHandler, injectedWorkflow = builder.injectedWorkflow, injectedWorkflowUiConfig = builder.injectedWorkflowUiConfig, ) @@ -106,6 +112,7 @@ public class PaywallOptions internal constructor( this.purchaseLogic != other.purchaseLogic -> false this.mode != other.mode -> false this.customVariables != other.customVariables -> false + this.webViewMessageHandler != other.webViewMessageHandler -> false this.injectedWorkflow != other.injectedWorkflow -> false this.injectedWorkflowUiConfig != other.injectedWorkflowUiConfig -> false else -> this.dismissRequest == other.dismissRequest @@ -122,6 +129,7 @@ public class PaywallOptions internal constructor( dismissRequest: () -> Unit = this.dismissRequest, dismissRequestWithExitOffering: ((Offering?, PaywallResult?) -> Unit)? = this.dismissRequestWithExitOffering, customVariables: Map = this.customVariables, + webViewMessageHandler: PaywallWebViewMessageHandler? = this.webViewMessageHandler, injectedWorkflow: WorkflowDataResult? = this.injectedWorkflow, injectedWorkflowUiConfig: UiConfig = this.injectedWorkflowUiConfig, ): PaywallOptions = PaywallOptions( @@ -134,6 +142,7 @@ public class PaywallOptions internal constructor( dismissRequest = dismissRequest, dismissRequestWithExitOffering = dismissRequestWithExitOffering, customVariables = customVariables, + webViewMessageHandler = webViewMessageHandler, injectedWorkflow = injectedWorkflow, injectedWorkflowUiConfig = injectedWorkflowUiConfig, ) @@ -150,6 +159,7 @@ public class PaywallOptions internal constructor( internal var mode: PaywallMode = PaywallMode.default internal var dismissRequestWithExitOffering: ((Offering?, PaywallResult?) -> Unit)? = null internal var customVariables: Map = emptyMap() + internal var webViewMessageHandler: PaywallWebViewMessageHandler? = null internal var injectedWorkflow: WorkflowDataResult? = null internal var injectedWorkflowUiConfig: UiConfig = UiConfig() @@ -213,6 +223,19 @@ public class PaywallOptions internal constructor( this.customVariables = CustomVariableKeyValidator.validateAndFilter(variables) } + /** + * Sets a handler for messages sent by Paywalls V2 `web_view` components. The handler receives + * validated messages (such as `rc:step-complete`, `rc:request-variables`, and `rc:error`) on + * the main thread, along with a [PaywallWebViewController] for replying to the web view. + * + * Bidirectional messaging is always enabled for `web_view` components; this handler is how the + * app observes and responds to those messages. The SDK does not automatically dismiss the + * paywall or trigger a purchase in response to any message. + */ + public fun setWebViewMessageHandler(handler: PaywallWebViewMessageHandler?): Builder = apply { + this.webViewMessageHandler = handler + } + /** * Injects a pre-built workflow (multipage paywall) to render locally without fetching * it from the backend, together with the [Offering] it renders against. Internal diff --git a/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/PaywallWebViewMessage.kt b/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/PaywallWebViewMessage.kt new file mode 100644 index 0000000000..0bedf88c6d --- /dev/null +++ b/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/PaywallWebViewMessage.kt @@ -0,0 +1,94 @@ +package com.revenuecat.purchases.ui.revenuecatui + +import dev.drewhamilton.poko.Poko + +/** + * A validated message received from a Paywalls V2 `web_view` component. + * + * Messages follow the RevenueCat web view postMessage envelope. Known [type]s include: + * - `rc:step-loaded` + * - `rc:step-complete` (carries [responses]) + * - `rc:request-variables` + * - `rc:error` (carries [error]) + * + * @property componentId The canonical component id (the `web_view.id` from the paywall schema). This + * matches the id API consumers see in the paywall configuration. + * @property type The message type, e.g. `rc:step-complete`. + * @property responses The structured responses for a `rc:step-complete` message, if any. + * @property error The error description for a `rc:error` message, if any. + */ +@Poko +public class PaywallWebViewMessage( + public val componentId: String, + public val type: String, + public val responses: Map? = null, + public val error: String? = null, +) + +/** + * Lets app code send messages back into a Paywalls V2 `web_view` component, for example in response to + * a `rc:request-variables` message. + * + * Outgoing messages preserve the RevenueCat web view postMessage envelope and are validated before + * being delivered to the web view. + */ +public interface PaywallWebViewController { + + /** + * Sends a `rc:variables` message into the web view targeting [componentId]. The SDK already replies + * with SDK-managed variables (such as `locale`); use this to provide additional values, typically + * nested under the `custom` key. + * + * Reserved SDK-managed top-level keys cannot be overwritten; provide app-specific values under + * `custom`. + */ + public fun postVariables( + componentId: String, + variables: Map, + ) + + /** + * Sends a message of the given [type] into the web view targeting [componentId], following the web + * view protocol envelope `{ "type", "component_id", "variables" }`. [variables] is delivered under + * the `variables` key. Use [postVariables] for the common `rc:variables` case. + */ + public fun postMessage( + componentId: String, + type: String, + variables: Map, + ) +} + +/** + * Receives validated messages from Paywalls V2 `web_view` components. + * + * Set this on [PaywallOptions.Builder.setWebViewMessageHandler]. The handler is always invoked on the + * main thread. The app decides what to do with each message: the SDK does not automatically dismiss the + * paywall or trigger a purchase in response to `rc:step-complete`. + * + * ### Usage + * ```kotlin + * PaywallOptions.Builder { /* dismiss */ } + * .setWebViewMessageHandler { message, controller -> + * when (message.type) { + * "rc:request-variables" -> controller.postVariables( + * componentId = message.componentId, + * variables = mapOf( + * "custom" to PaywallWebViewValue.Object( + * mapOf("app_segment" to PaywallWebViewValue.String("high_intent")), + * ), + * ), + * ) + * "rc:step-complete" -> { /* read message.responses, navigate, log analytics, etc. */ } + * "rc:error" -> { /* log message.error */ } + * } + * } + * .build() + * ``` + */ +public fun interface PaywallWebViewMessageHandler { + public fun onMessage( + message: PaywallWebViewMessage, + controller: PaywallWebViewController, + ) +} diff --git a/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/PaywallWebViewValue.kt b/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/PaywallWebViewValue.kt new file mode 100644 index 0000000000..c5b31527d8 --- /dev/null +++ b/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/PaywallWebViewValue.kt @@ -0,0 +1,135 @@ +package com.revenuecat.purchases.ui.revenuecatui + +import org.json.JSONArray +import org.json.JSONObject + +/** + * A JSON-compatible value used in messages exchanged with a Paywalls V2 `web_view` component. + * + * Only JSON-like values are supported: [String], [Number], [Boolean], [Object], [Array] and [Null]. + * Functions, binary blobs, dates, and platform-specific objects are intentionally not representable. + * + * @see PaywallWebViewMessage + * @see PaywallWebViewController + */ +// Modeled as an abstract class with a closed set of subtypes (mirroring [CustomVariableValue]) rather +// than a sealed class, to stay binary-compatible as public API. +public abstract class PaywallWebViewValue internal constructor() { + + /** + * A string value. + */ + public class String(public val value: kotlin.String) : PaywallWebViewValue() { + override fun equals(other: Any?): kotlin.Boolean = other is String && value == other.value + override fun hashCode(): Int = value.hashCode() + override fun toString(): kotlin.String = "PaywallWebViewValue.String(value=$value)" + } + + /** + * A numeric value (integer or decimal). Stored as a [Double]. + */ + public class Number(public val value: kotlin.Double) : PaywallWebViewValue() { + public constructor(value: kotlin.Int) : this(value.toDouble()) + public constructor(value: kotlin.Long) : this(value.toDouble()) + public constructor(value: kotlin.Float) : this(value.toDouble()) + + override fun equals(other: Any?): kotlin.Boolean = other is Number && value == other.value + override fun hashCode(): Int = value.hashCode() + override fun toString(): kotlin.String = "PaywallWebViewValue.Number(value=$value)" + } + + /** + * A boolean value. + */ + public class Boolean(public val value: kotlin.Boolean) : PaywallWebViewValue() { + override fun equals(other: Any?): kotlin.Boolean = other is Boolean && value == other.value + override fun hashCode(): Int = value.hashCode() + override fun toString(): kotlin.String = "PaywallWebViewValue.Boolean(value=$value)" + } + + /** + * A JSON object: a map of string keys to [PaywallWebViewValue]s. + */ + public class Object(public val value: Map) : PaywallWebViewValue() { + override fun equals(other: Any?): kotlin.Boolean = other is Object && value == other.value + override fun hashCode(): Int = value.hashCode() + override fun toString(): kotlin.String = "PaywallWebViewValue.Object(value=$value)" + } + + /** + * A JSON array: an ordered list of [PaywallWebViewValue]s. + */ + public class Array(public val value: List) : PaywallWebViewValue() { + override fun equals(other: Any?): kotlin.Boolean = other is Array && value == other.value + override fun hashCode(): Int = value.hashCode() + override fun toString(): kotlin.String = "PaywallWebViewValue.Array(value=$value)" + } + + /** + * A JSON `null` value. + */ + public object Null : PaywallWebViewValue() { + override fun toString(): kotlin.String = "PaywallWebViewValue.Null" + } + + internal companion object { + /** + * Converts an `org.json` value (or Kotlin primitive) into a [PaywallWebViewValue], rejecting + * anything that is not JSON-compatible or that exceeds [remainingDepth] levels of nesting. + * + * @return the converted value, or `null` if the value is not JSON-compatible or too deeply nested. + */ + @Suppress("ReturnCount", "CyclomaticComplexMethod") + fun fromJson(value: Any?, remainingDepth: Int): PaywallWebViewValue? { + return when (value) { + null, JSONObject.NULL -> Null + is kotlin.String -> String(value) + is kotlin.Boolean -> Boolean(value) + is Int -> Number(value) + is Long -> Number(value) + is Double -> Number(value) + is Float -> Number(value) + is JSONObject -> { + if (remainingDepth <= 0) return null + val map = LinkedHashMap(value.length()) + for (key in value.keys()) { + map[key] = fromJson(value.get(key), remainingDepth - 1) ?: return null + } + Object(map) + } + is JSONArray -> { + if (remainingDepth <= 0) return null + val list = ArrayList(value.length()) + for (index in 0 until value.length()) { + list.add(fromJson(value.get(index), remainingDepth - 1) ?: return null) + } + Array(list) + } + // Numbers stored by org.json as BigDecimal/BigInteger, or any other type, are not supported. + else -> (value as? kotlin.Number)?.let { Number(it.toDouble()) } + } + } + } +} + +/** + * Converts this value into a representation accepted by [JSONObject]/[JSONArray] when serializing a + * message to send into the web view. Whole numbers are emitted as integers (e.g. `100`, not `100.0`). + */ +internal fun PaywallWebViewValue.toJsonRepresentation(): Any = when (this) { + is PaywallWebViewValue.String -> value + is PaywallWebViewValue.Boolean -> value + is PaywallWebViewValue.Number -> + if (value % 1.0 == 0.0 && !value.isInfinite()) value.toLong() else value + is PaywallWebViewValue.Object -> JSONObject().apply { + value.forEach { (key, child) -> put(key, child.toJsonRepresentation()) } + } + is PaywallWebViewValue.Array -> JSONArray().apply { + value.forEach { child -> put(child.toJsonRepresentation()) } + } + else -> JSONObject.NULL +} + +internal fun Map.toJsonObject(): JSONObject = JSONObject().apply { + forEach { (key, value) -> put(key, value.toJsonRepresentation()) } +} diff --git a/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/ComponentView.kt b/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/ComponentView.kt index 8a41190d5b..9f393b6b8f 100644 --- a/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/ComponentView.kt +++ b/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/ComponentView.kt @@ -30,12 +30,14 @@ import com.revenuecat.purchases.ui.revenuecatui.components.style.TabsComponentSt import com.revenuecat.purchases.ui.revenuecatui.components.style.TextComponentStyle import com.revenuecat.purchases.ui.revenuecatui.components.style.TimelineComponentStyle import com.revenuecat.purchases.ui.revenuecatui.components.style.VideoComponentStyle +import com.revenuecat.purchases.ui.revenuecatui.components.style.WebViewComponentStyle import com.revenuecat.purchases.ui.revenuecatui.components.tabs.TabControlButtonView import com.revenuecat.purchases.ui.revenuecatui.components.tabs.TabControlToggleView import com.revenuecat.purchases.ui.revenuecatui.components.tabs.TabsComponentView import com.revenuecat.purchases.ui.revenuecatui.components.text.TextComponentView import com.revenuecat.purchases.ui.revenuecatui.components.timeline.TimelineComponentView import com.revenuecat.purchases.ui.revenuecatui.components.video.VideoComponentView +import com.revenuecat.purchases.ui.revenuecatui.components.webview.WebViewComponentView import com.revenuecat.purchases.ui.revenuecatui.data.PaywallState import com.revenuecat.purchases.ui.revenuecatui.helpers.PaywallComponentInteractionTracker @@ -73,6 +75,13 @@ internal fun ComponentView( modifier = modifier, ) } + is WebViewComponentStyle -> WebViewComponentView( + style = style, + state = state, + modifier = modifier, + onClick = onClick, + componentInteractionTracker = componentInteractionTracker, + ) is ButtonComponentStyle -> ButtonComponentView( style = style, state = state, 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..881b7e7c0e 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,12 +574,27 @@ internal class StyleFactory( is TabsComponent -> createTabsComponentStyle(component) is VideoComponent -> createVideoComponentStyle(component) is FallbackHeaderComponent -> Result.Success(null) + is WebViewComponent -> createWebViewComponentStyle(component) is CountdownComponent -> createCountdownComponentStyle( component, ) } } + private fun StyleFactoryScope.createWebViewComponentStyle( + component: WebViewComponent, + ): Result> = + component.fallback?.let { createStackComponentStyle(it) }.orSuccessfullyNull().map { fallbackStyle -> + WebViewComponentStyle( + urlTemplate = component.url, + visible = component.visible ?: DEFAULT_VISIBILITY, + size = component.size, + componentId = component.id, + protocolVersion = component.protocolVersion, + fallbackStackComponentStyle = fallbackStyle, + ) + } + private fun StyleFactoryScope.createCountdownComponentStyle( component: CountdownComponent, ): Result> = diff --git a/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/style/WebViewComponentStyle.kt b/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/style/WebViewComponentStyle.kt new file mode 100644 index 0000000000..0539dda8d4 --- /dev/null +++ b/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/style/WebViewComponentStyle.kt @@ -0,0 +1,26 @@ +@file:JvmSynthetic + +package com.revenuecat.purchases.ui.revenuecatui.components.style + +import androidx.compose.runtime.Immutable +import com.revenuecat.purchases.paywalls.components.properties.Size + +@Immutable +internal data class WebViewComponentStyle( + val urlTemplate: String, + override val visible: Boolean, + override val size: Size, + /** + * The canonical component id from the schema (`web_view.id`), used to scope bidirectional + * messages. May be null for legacy/partial configs that omit it, in which case the message bridge + * is not installed. + */ + val componentId: String? = null, + /** + * The schema-declared `protocol_version`. When present, the SDK isolates the web content from + * external sources by enforcing a fixed Content-Security-Policy. `null` for legacy/partial configs + * that omit it, in which case no policy is enforced. + */ + val protocolVersion: Int? = null, + val fallbackStackComponentStyle: StackComponentStyle? = null, +) : ComponentStyle diff --git a/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/webview/PaywallWebViewVariablesProvider.kt b/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/webview/PaywallWebViewVariablesProvider.kt new file mode 100644 index 0000000000..d88e1f24f4 --- /dev/null +++ b/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/webview/PaywallWebViewVariablesProvider.kt @@ -0,0 +1,58 @@ +@file:JvmSynthetic + +package com.revenuecat.purchases.ui.revenuecatui.components.webview + +import com.revenuecat.purchases.ui.revenuecatui.PaywallWebViewValue +import com.revenuecat.purchases.ui.revenuecatui.helpers.Logger + +/** + * Builds the `variables` payload sent to a `web_view` component in response to `rc:request-variables`. + * + * Produces a flat map shaped like: + * ```json + * { "locale": "en-US" } + * ``` + * + * Only safe, already-available system context is exposed: the paywall locale. The paywall's + * dashboard-defined custom variables are intentionally NOT passed across the bridge in v1. API keys, + * email addresses, the Purchases SDK instance, and storage are never included. + * + * [KEY_LOCALE] is a reserved SDK-managed top-level key: app-provided variables may not overwrite it. + */ +internal object PaywallWebViewVariablesProvider { + + const val KEY_LOCALE: String = "locale" + + val reservedKeys: Set = setOf(KEY_LOCALE) + + /** + * The SDK-managed system variables exposed to the web view. + * + * @param locale a BCP-47-ish locale string, e.g. `en-US`. + */ + fun sdkManagedVariables( + locale: String, + ): Map = linkedMapOf( + KEY_LOCALE to PaywallWebViewValue.String(locale), + ) + + /** + * Removes reserved SDK-managed top-level keys from app-provided variables so they cannot overwrite + * SDK values, logging a warning for any that were dropped. + */ + fun sanitizeAppProvidedVariables( + variables: Map, + ): Map { + val sanitized = variables.filterKeys { key -> + val reserved = key in reservedKeys + if (reserved) { + Logger.w( + "Ignoring reserved web view variable key '$key'. Reserved SDK-managed keys cannot be " + + "overwritten.", + ) + } + !reserved + } + return sanitized + } +} diff --git a/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/webview/WebViewComponentView.kt b/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/webview/WebViewComponentView.kt new file mode 100644 index 0000000000..ae40cd9bad --- /dev/null +++ b/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/webview/WebViewComponentView.kt @@ -0,0 +1,197 @@ +@file:JvmSynthetic + +package com.revenuecat.purchases.ui.revenuecatui.components.webview + +import android.graphics.Bitmap +import android.graphics.Color +import android.webkit.WebResourceError +import android.webkit.WebResourceRequest +import android.webkit.WebResourceResponse +import android.webkit.WebSettings +import android.webkit.WebView +import android.webkit.WebViewClient +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.key +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.viewinterop.AndroidView +import com.revenuecat.purchases.paywalls.components.properties.Size +import com.revenuecat.purchases.paywalls.components.properties.SizeConstraint.Fit +import com.revenuecat.purchases.paywalls.components.properties.SizeConstraint.Fixed +import com.revenuecat.purchases.ui.revenuecatui.components.PaywallAction +import com.revenuecat.purchases.ui.revenuecatui.components.modifier.size +import com.revenuecat.purchases.ui.revenuecatui.components.stack.StackComponentView +import com.revenuecat.purchases.ui.revenuecatui.components.style.WebViewComponentStyle +import com.revenuecat.purchases.ui.revenuecatui.data.PaywallState +import com.revenuecat.purchases.ui.revenuecatui.helpers.PaywallComponentInteractionTracker + +@JvmSynthetic +@Composable +@Suppress("LongMethod", "ReturnCount") +internal fun WebViewComponentView( + style: WebViewComponentStyle, + state: PaywallState.Loaded.Components, + modifier: Modifier = Modifier, + onClick: suspend (PaywallAction) -> Unit = {}, + componentInteractionTracker: PaywallComponentInteractionTracker = PaywallComponentInteractionTracker { _ -> }, +) { + if (!style.visible) return + + val resolvedUrl = remember(style.urlTemplate, state, state.locale) { + WebViewUrlResolver.resolve(style.urlTemplate, state) + } + if (resolvedUrl == null) return + + val componentId = style.componentId + val enforceContentSecurityPolicy = style.protocolVersion != null + val locale = state.locale.toLanguageTag() + val messageHandler = state.webViewMessageHandler + val sizeToContentWidth = style.size.width is Fit + val sizeToContentHeight = style.size.height is Fit + + var contentWidthCssPx by remember(resolvedUrl) { mutableIntStateOf(0) } + var contentHeightCssPx by remember(resolvedUrl) { mutableIntStateOf(0) } + var loadFailed by remember(resolvedUrl) { mutableStateOf(false) } + val bridgeHolder = remember { WebViewBridgeHolder() } + + val effectiveSize = remember(style.size, contentWidthCssPx, contentHeightCssPx) { + webViewEffectiveSize( + declaredSize = style.size, + contentWidthCssPx = contentWidthCssPx, + contentHeightCssPx = contentHeightCssPx, + ) + } + + val fallbackStyle = style.fallbackStackComponentStyle + if (loadFailed && fallbackStyle != null) { + StackComponentView( + style = fallbackStyle, + state = state, + clickHandler = onClick, + componentInteractionTracker = componentInteractionTracker, + modifier = modifier.size(effectiveSize), + ) + return + } + + key(resolvedUrl) { + AndroidView( + factory = { context -> + WebView(context).apply { + val bridge = componentId?.let { id -> + WebViewJavaScriptBridge( + webView = this, + componentId = id, + expectedUrl = resolvedUrl, + locale = locale, + messageHandler = messageHandler, + protocolVersion = style.protocolVersion ?: WebViewEnvelope.DEFAULT_PROTOCOL_VERSION, + sizeToContentWidth = sizeToContentWidth, + sizeToContentHeight = sizeToContentHeight, + onContentResize = { widthCssPx, heightCssPx -> + widthCssPx?.takeIf { it > 0 }?.let { contentWidthCssPx = it } + heightCssPx?.takeIf { it > 0 }?.let { contentHeightCssPx = it } + }, + ).also { createdBridge -> createdBridge.attach() } + } + bridgeHolder.bridge = bridge + configure( + enforceContentSecurityPolicy = enforceContentSecurityPolicy, + onMainFrameLoadFailed = { loadFailed = true }, + ) + loadUrl(resolvedUrl.toString()) + } + }, + update = { + bridgeHolder.bridge?.update( + locale = locale, + messageHandler = messageHandler, + ) + }, + onRelease = { webView -> + bridgeHolder.bridge?.release() + bridgeHolder.bridge = null + webView.stopLoading() + webView.webViewClient = WebViewClient() + webView.destroy() + }, + modifier = modifier.size(effectiveSize), + ) + } +} + +private fun webViewEffectiveSize( + declaredSize: Size, + contentWidthCssPx: Int, + contentHeightCssPx: Int, +): Size { + val width = when (val declaredWidth = declaredSize.width) { + is Fit -> if (contentWidthCssPx > 0) Fixed(contentWidthCssPx.toUInt()) else declaredWidth + else -> declaredWidth + } + val height = when (val declaredHeight = declaredSize.height) { + is Fit -> if (contentHeightCssPx > 0) Fixed(contentHeightCssPx.toUInt()) else declaredHeight + else -> declaredHeight + } + return Size(width = width, height = height) +} + +/** Mutable holder for the per-WebView bridge instance, shared across factory/update/onRelease. */ +private class WebViewBridgeHolder { + var bridge: WebViewJavaScriptBridge? = null +} + +private fun WebView.configure( + enforceContentSecurityPolicy: Boolean, + onMainFrameLoadFailed: () -> Unit, +) { + setBackgroundColor(Color.TRANSPARENT) + isVerticalScrollBarEnabled = false + isHorizontalScrollBarEnabled = false + settings.allowContentAccess = false + settings.allowFileAccess = false + settings.cacheMode = WebSettings.LOAD_DEFAULT + settings.domStorageEnabled = true + settings.javaScriptEnabled = true + settings.mixedContentMode = WebSettings.MIXED_CONTENT_NEVER_ALLOW + settings.setGeolocationEnabled(false) + webViewClient = object : WebViewClient() { + override fun onPageStarted(view: WebView, url: String?, favicon: Bitmap?) { + super.onPageStarted(view, url, favicon) + if (enforceContentSecurityPolicy) { + view.evaluateJavascript(contentSecurityPolicyMetaScript(), null) + } + } + + override fun shouldOverrideUrlLoading(view: WebView, request: WebResourceRequest): Boolean { + return request.url.scheme != HTTPS_SCHEME + } + + override fun onReceivedError( + view: WebView, + request: WebResourceRequest, + error: WebResourceError, + ) { + if (request.isForMainFrame) { + onMainFrameLoadFailed() + } + } + + override fun onReceivedHttpError( + view: WebView, + request: WebResourceRequest, + errorResponse: WebResourceResponse, + ) { + if (request.isForMainFrame && errorResponse.statusCode >= HTTP_ERROR_STATUS_MIN) { + onMainFrameLoadFailed() + } + } + } +} + +private const val HTTPS_SCHEME = "https" +private const val HTTP_ERROR_STATUS_MIN = 400 diff --git a/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/webview/WebViewContentSecurityPolicy.kt b/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/webview/WebViewContentSecurityPolicy.kt new file mode 100644 index 0000000000..775170e224 --- /dev/null +++ b/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/webview/WebViewContentSecurityPolicy.kt @@ -0,0 +1,57 @@ +@file:JvmSynthetic + +package com.revenuecat.purchases.ui.revenuecatui.components.webview + +/** + * Fixed Content-Security-Policy used to isolate Paywalls V2 `web_view` content from external sources + * (v1 behavior, applied whenever the component declares a `protocol_version`). The bundle must be + * fully self-contained: it may use its own packaged and inlined resources, but cannot reach any + * external origin. + * + * - `img-src 'self' data:` / `font-src 'self' data:`: same-origin and inlined (`data:`) images/fonts + * are allowed (self-contained bundles routinely inline assets); remote images/fonts are blocked. + * - `script-src 'self'`: remote scripts are blocked, same-origin scripts are allowed. `'unsafe-inline'` + * / `'unsafe-eval'` keep inline/eval'd first-party scripts working — the goal here is network + * isolation, not locking down the (trusted) bundle's own inline code. The native bridge runs via + * `evaluateJavascript`, which is privileged and unaffected by CSP. + * - `connect-src 'self'`: XHR/fetch/WebSocket are allowed to the bundle's own origin only (so it can + * load its packaged JSON — e.g. Lottie animation data); cross-origin requests are blocked. + * - `default-src 'self'`: anchors every other resource type to same-origin (no remote). + * + * These functions are pure so the policy and its injection wrapper can be unit tested without an + * Android `WebView`. + */ +internal const val WEB_VIEW_CONTENT_SECURITY_POLICY: String = + "default-src 'self'; " + + "script-src 'self' 'unsafe-inline' 'unsafe-eval'; " + + "style-src 'self' 'unsafe-inline'; " + + "img-src 'self' data:; " + + "font-src 'self' data:; " + + "connect-src 'self'" + +/** + * Builds the document-start script that installs [policy] as a `` Content-Security- + * Policy. Injected from `WebViewClient.onPageStarted` (before the page's own scripts run) and inserted + * as the first child of `` so it precedes any resource-loading markup. The install is idempotent + * via a window flag, so re-injection on redirects is a no-op. + */ +internal fun contentSecurityPolicyMetaScript(policy: String = WEB_VIEW_CONTENT_SECURITY_POLICY): String = + """ + (function() { + if (window.__revenueCatCspInstalled) { return; } + window.__revenueCatCspInstalled = true; + var meta = document.createElement('meta'); + meta.setAttribute('http-equiv', 'Content-Security-Policy'); + meta.setAttribute('content', "${policy.escapeForMetaContent()}"); + var head = document.head || document.getElementsByTagName('head')[0] || document.documentElement; + if (head.firstChild) { head.insertBefore(meta, head.firstChild); } else { head.appendChild(meta); } + })(); + """.trimIndent() + +/** + * The policy is embedded inside a double-quoted JS string literal. Escape backslashes and double + * quotes so a policy value can never break out of the literal. The canonical policy contains neither, + * but this keeps the wrapper safe for any caller-supplied [policy]. + */ +private fun String.escapeForMetaContent(): String = + replace("\\", "\\\\").replace("\"", "\\\"") diff --git a/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/webview/WebViewEnvelope.kt b/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/webview/WebViewEnvelope.kt new file mode 100644 index 0000000000..5192bb25fb --- /dev/null +++ b/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/webview/WebViewEnvelope.kt @@ -0,0 +1,112 @@ +@file:JvmSynthetic + +package com.revenuecat.purchases.ui.revenuecatui.components.webview + +import org.json.JSONObject + +/** + * Wire-format envelopes for the `workflow-web-components-sdk` transport layer. + * + * Matches [RevenueCat/workflow-web-components-sdk](https://github.com/RevenueCat/workflow-web-components-sdk): + * channel `rc-web-components`, handshake kinds `connect` / `init` / `reject`, and app frames + * `message` / `request` / `response` / `error`. + */ +internal object WebViewEnvelope { + + const val CHANNEL: String = "rc-web-components" + const val NATIVE_OBJECT_NAME: String = "rcWebComponents" + const val RECEIVE_FUNCTION: String = "__rcWebComponentsReceive" + const val DEFAULT_PROTOCOL_VERSION: Int = 1 + + const val KIND_CONNECT: String = "connect" + const val KIND_INIT: String = "init" + const val KIND_REJECT: String = "reject" + const val KIND_MESSAGE: String = "message" + const val KIND_REQUEST: String = "request" + const val KIND_RESPONSE: String = "response" + const val KIND_ERROR: String = "error" + + private val ENVELOPE_KINDS: Set = setOf( + KIND_CONNECT, + KIND_INIT, + KIND_REJECT, + KIND_MESSAGE, + KIND_REQUEST, + KIND_RESPONSE, + KIND_ERROR, + ) + + internal data class Parsed( + val kind: String, + val protocolVersion: Int, + val componentId: String, + val type: String?, + val id: String?, + val payload: JSONObject?, + val error: String?, + ) + + @Suppress("ReturnCount", "CyclomaticComplexMethod") + fun parse(rawJson: String): Parsed? { + val json = try { + JSONObject(rawJson) + } catch (@Suppress("SwallowedException") _: org.json.JSONException) { + return null + } + + if (json.opt(WebViewMessageField.CHANNEL) != CHANNEL) return null + + val protocolVersion = json.opt(WebViewMessageField.PROTOCOL_VERSION) + if (protocolVersion !is Number || !protocolVersion.toDouble().isFinite()) return null + + val kind = json.opt(WebViewMessageField.KIND) as? String + if (kind == null || kind !in ENVELOPE_KINDS) return null + + val componentId = json.opt(WebViewMessageField.COMPONENT_ID) as? String ?: return null + + val type = json.opt(WebViewMessageField.TYPE) as? String + if (json.has(WebViewMessageField.TYPE) && type == null) return null + + val id = json.opt(WebViewMessageField.ID) as? String + if (json.has(WebViewMessageField.ID) && id == null) return null + + val error = json.opt(WebViewMessageField.ERROR) as? String + if (json.has(WebViewMessageField.ERROR) && error == null) return null + + val payload = when (val payloadValue = json.opt(WebViewMessageField.PAYLOAD)) { + null, JSONObject.NULL -> null + is JSONObject -> payloadValue + else -> return null + } + + return Parsed( + kind = kind, + protocolVersion = protocolVersion.toInt(), + componentId = componentId, + type = type, + id = id, + payload = payload, + error = error, + ) + } + + @Suppress("LongParameterList") + fun build( + kind: String, + protocolVersion: Int, + componentId: String, + type: String? = null, + id: String? = null, + payload: JSONObject? = null, + error: String? = null, + ): JSONObject = JSONObject().apply { + put(WebViewMessageField.CHANNEL, CHANNEL) + put(WebViewMessageField.PROTOCOL_VERSION, protocolVersion) + put(WebViewMessageField.KIND, kind) + put(WebViewMessageField.COMPONENT_ID, componentId) + type?.let { put(WebViewMessageField.TYPE, it) } + id?.let { put(WebViewMessageField.ID, it) } + payload?.let { put(WebViewMessageField.PAYLOAD, it) } + error?.let { put(WebViewMessageField.ERROR, it) } + } +} diff --git a/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/webview/WebViewJavaScriptBridge.kt b/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/webview/WebViewJavaScriptBridge.kt new file mode 100644 index 0000000000..e9b70be13f --- /dev/null +++ b/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/webview/WebViewJavaScriptBridge.kt @@ -0,0 +1,357 @@ +@file:JvmSynthetic + +package com.revenuecat.purchases.ui.revenuecatui.components.webview + +import android.os.Handler +import android.os.Looper +import android.webkit.JavascriptInterface +import android.webkit.WebView +import androidx.annotation.MainThread +import com.revenuecat.purchases.ui.revenuecatui.PaywallWebViewController +import com.revenuecat.purchases.ui.revenuecatui.PaywallWebViewMessageHandler +import com.revenuecat.purchases.ui.revenuecatui.PaywallWebViewValue +import com.revenuecat.purchases.ui.revenuecatui.helpers.Logger +import com.revenuecat.purchases.ui.revenuecatui.toJsonObject +import org.json.JSONObject +import java.lang.ref.WeakReference +import java.net.URL + +/** + * Installs and drives the bidirectional bridge between a Paywalls V2 `web_view` component and native + * code. One bridge is created per rendered `web_view`. + * + * ## Transport + * Implements the native Android host contract from `workflow-web-components-sdk`: + * - JS → native: `window.rcWebComponents.postMessage(jsonString)` via [addJavascriptInterface] + * - native → JS: `window.__rcWebComponentsReceive(data)` via [WebView.evaluateJavascript] + * - Wire format: `{ channel: "rc-web-components", kind, protocol_version, component_id, … }` + * + * The content SDK (`window.RC`) auto-detects Android when [NATIVE_OBJECT_NAME] is present and runs + * the same connect/init handshake as on web. No document-start shim is injected. + * + * Because `addJavascriptInterface` provides no platform origin scoping, the bridge enforces the origin + * itself: before delivering any inbound message it verifies the WebView's current URL still has the + * same origin (scheme + host + port) as the resolved component URL, rejecting messages received after + * navigation to an unexpected origin. + * + * ## Lifecycle & threading + * The bridge holds only a [WeakReference] to the [WebView] (no Activity/Context), and stops delivering + * messages once [release] is called. Inbound JavaScript callbacks arrive on a binder thread and are + * hopped onto the main thread; app callbacks and all WebView interactions happen on the main thread. + */ +@Suppress("LongParameterList", "TooManyFunctions") +internal class WebViewJavaScriptBridge( + webView: WebView, + private val componentId: String, + expectedUrl: URL, + locale: String, + messageHandler: PaywallWebViewMessageHandler?, + protocolVersion: Int = WebViewEnvelope.DEFAULT_PROTOCOL_VERSION, + private val sizeToContentWidth: Boolean = false, + private val sizeToContentHeight: Boolean = false, + private val onContentResize: (widthCssPx: Int?, heightCssPx: Int?) -> Unit = { _, _ -> }, + private val mainHandler: Handler = Handler(Looper.getMainLooper()), +) : PaywallWebViewController { + + private val webViewRef = WeakReference(webView) + private val expectedOrigin: String? = expectedUrl.toOriginOrNull() + private val protocolVersion: Int = protocolVersion + + // Refreshed from the latest paywall state on every recomposition via update(). + @Volatile private var locale: String = locale + + @Volatile private var messageHandler: PaywallWebViewMessageHandler? = messageHandler + + @Volatile private var released: Boolean = false + + @Volatile private var channelOpen: Boolean = false + + /** + * Registers the native interface on the WebView. Call once, when the WebView is created. + */ + @MainThread + fun attach() { + webViewRef.get()?.addJavascriptInterface(this, WebViewEnvelope.NATIVE_OBJECT_NAME) + } + + /** + * Refreshes the locale and the app message handler from the latest paywall state. Call from + * `AndroidView`'s update block. + */ + fun update( + locale: String, + messageHandler: PaywallWebViewMessageHandler?, + ) { + this.locale = locale + this.messageHandler = messageHandler + } + + /** + * Stops the bridge. After this call no further inbound messages are delivered and no outbound + * messages are sent. Call from `AndroidView`'s onRelease block. + */ + @MainThread + fun release() { + released = true + channelOpen = false + webViewRef.get()?.removeJavascriptInterface(WebViewEnvelope.NATIVE_OBJECT_NAME) + } + + /** + * Entry point for `window.rcWebComponents.postMessage`. Invoked on a binder thread; we hop to the + * main thread before touching the WebView or validating the message. + */ + @JavascriptInterface + fun postMessage(json: String) { + if (released) return + mainHandler.post { handleInboundMessage(json) } + } + + @MainThread + @Suppress("ReturnCount") + private fun handleInboundMessage(json: String) { + if (released) return + val webView = webViewRef.get() ?: return + + if (json.toByteArray(Charsets.UTF_8).size > WebViewMessageParser.MAX_PAYLOAD_BYTES) { + Logger.w( + "Dropping inbound web view message: payload exceeds " + + "${WebViewMessageParser.MAX_PAYLOAD_BYTES} bytes.", + ) + return + } + + val envelope = WebViewEnvelope.parse(json) ?: run { + Logger.w("Dropping inbound web view message: not a valid transport envelope.") + return + } + + when (envelope.kind) { + WebViewEnvelope.KIND_CONNECT -> { + if (!isOriginTrusted(webView, allowBeforeNavigation = true)) { + Logger.w( + "Dropping inbound web view connect: current origin does not match the " + + "resolved component origin.", + ) + return + } + handleConnect(envelope) + } + WebViewEnvelope.KIND_MESSAGE, + WebViewEnvelope.KIND_REQUEST, + -> { + if (!isOriginTrusted(webView, allowBeforeNavigation = false)) { + Logger.w( + "Dropping inbound web view message: current origin does not match the " + + "resolved component origin.", + ) + return + } + handleAppFrame(json) + } + else -> Unit + } + } + + @MainThread + private fun handleConnect(envelope: WebViewEnvelope.Parsed) { + if (channelOpen || released) return + + if (envelope.protocolVersion != protocolVersion) { + deliverEnvelope( + WebViewEnvelope.build( + kind = WebViewEnvelope.KIND_REJECT, + protocolVersion = protocolVersion, + componentId = "", + error = "Unsupported protocol_version ${envelope.protocolVersion}; " + + "native host supports $protocolVersion", + ), + ) + return + } + + channelOpen = true + deliverEnvelope( + WebViewEnvelope.build( + kind = WebViewEnvelope.KIND_INIT, + protocolVersion = protocolVersion, + componentId = componentId, + ), + ) + sendFitIfNeeded() + } + + private fun sendFitIfNeeded() { + if (!sizeToContentWidth && !sizeToContentHeight) return + val payload = JSONObject().apply { + if (sizeToContentWidth) put("width", true) + if (sizeToContentHeight) put("height", true) + } + deliverEnvelope( + WebViewEnvelope.build( + kind = WebViewEnvelope.KIND_MESSAGE, + protocolVersion = protocolVersion, + componentId = componentId, + type = WebViewMessageType.FIT, + payload = payload, + ), + ) + } + + @MainThread + @Suppress("ReturnCount") + private fun handleAppFrame(rawJson: String) { + if (!channelOpen) return + + val transport = WebViewEnvelope.parse(rawJson) ?: return + if (transport.kind == WebViewEnvelope.KIND_MESSAGE && transport.type == WebViewMessageType.RESIZE) { + handleResize(transport) + return + } + + val parsed = WebViewMessageParser.parse( + rawJson = rawJson, + expectedComponentId = componentId, + ) ?: return + + val message = parsed.message + + if (message.type == WebViewMessageType.REQUEST_VARIABLES) { + val variables = PaywallWebViewVariablesProvider.sdkManagedVariables(locale = locale) + val requestId = parsed.requestId + if (requestId != null) { + deliverEnvelope( + WebViewEnvelope.build( + kind = WebViewEnvelope.KIND_RESPONSE, + protocolVersion = protocolVersion, + componentId = componentId, + type = parsed.requestType, + id = requestId, + payload = variables.toJsonObject(), + ), + ) + } else { + postVariablesMessage(componentId = componentId, variables = variables) + } + } + + messageHandler?.onMessage(message, this) + } + + @MainThread + private fun handleResize(envelope: WebViewEnvelope.Parsed) { + if (envelope.componentId != componentId) return + val payload = envelope.payload ?: return + val width = payload.optInt("width").takeIf { payload.has("width") } + val height = payload.optInt("height").takeIf { payload.has("height") } + onContentResize(width, height) + } + + override fun postVariables(componentId: String, variables: Map) { + postVariablesMessage( + componentId = componentId, + variables = PaywallWebViewVariablesProvider.sanitizeAppProvidedVariables(variables), + ) + } + + override fun postMessage(componentId: String, type: String, variables: Map) { + postAppMessage( + componentId = componentId, + type = type, + payload = variables.toJsonObject(), + ) + } + + private fun postVariablesMessage(componentId: String, variables: Map) { + postAppMessage( + componentId = componentId, + type = WebViewMessageType.VARIABLES, + payload = variables.toJsonObject(), + ) + } + + private fun postAppMessage(componentId: String, type: String, payload: JSONObject) { + if (!channelOpen) return + deliverEnvelope( + WebViewEnvelope.build( + kind = WebViewEnvelope.KIND_MESSAGE, + protocolVersion = protocolVersion, + componentId = componentId, + type = type, + payload = payload, + ), + ) + } + + private fun deliverEnvelope(envelope: JSONObject) { + runOnMainThread { + if (released) return@runOnMainThread + val webView = webViewRef.get() ?: return@runOnMainThread + val kind = envelope.optString(WebViewMessageField.KIND) + val allowBeforeNavigation = kind in HANDSHAKE_OUTBOUND_KINDS + if (!isOriginTrusted(webView, allowBeforeNavigation = allowBeforeNavigation)) { + Logger.w( + "Dropping outbound web view message: current origin does not match the resolved component origin.", + ) + return@runOnMainThread + } + val payload = envelope.toString().escapeForJavaScript() + webView.evaluateJavascript( + "if (window.${WebViewEnvelope.RECEIVE_FUNCTION}) { " + + "window.${WebViewEnvelope.RECEIVE_FUNCTION}($payload); " + + "}", + null, + ) + } + } + + /** + * Whether the WebView's current URL still has the same origin (scheme + host + port) as the + * resolved component URL. When [allowBeforeNavigation] is true and the WebView has not committed + * a URL yet, the expected origin is trusted so the connect/init handshake can complete before + * the first navigation. + */ + @MainThread + private fun isOriginTrusted(webView: WebView, allowBeforeNavigation: Boolean): Boolean { + if (expectedOrigin == null) return false + val currentOrigin = webView.url?.let { runCatching { URL(it).toOriginOrNull() }.getOrNull() } + return when { + currentOrigin == null -> allowBeforeNavigation + else -> currentOrigin == expectedOrigin + } + } + + private fun runOnMainThread(block: () -> Unit) { + if (Looper.myLooper() == Looper.getMainLooper()) { + block() + } else { + mainHandler.post(block) + } + } + + private companion object { + private val HANDSHAKE_OUTBOUND_KINDS: Set = setOf( + WebViewEnvelope.KIND_INIT, + WebViewEnvelope.KIND_REJECT, + ) + + /** + * JSON is a subset of JS object-literal syntax, but the U+2028/U+2029 separators are valid in + * JSON strings yet terminate JS statements. Escape them so the payload is safe to embed. + */ + fun String.escapeForJavaScript(): String = + replace("\u2028", "\\u2028").replace("\u2029", "\\u2029") + } +} + +/** + * The origin of this URL as `scheme://host[:port]`, or `null` if it lacks a host. Only HTTPS URLs are + * expected here (the resolver already enforces this), but the origin string includes the scheme so a + * scheme change would also be detected. + */ +private fun URL.toOriginOrNull(): String? { + val host = host?.takeIf { it.isNotBlank() } ?: return null + val effectivePort = if (port == -1) defaultPort else port + val portSuffix = if (effectivePort == -1) "" else ":$effectivePort" + return "$protocol://$host$portSuffix" +} diff --git a/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/webview/WebViewMessageParser.kt b/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/webview/WebViewMessageParser.kt new file mode 100644 index 0000000000..6e36e5e8dd --- /dev/null +++ b/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/webview/WebViewMessageParser.kt @@ -0,0 +1,168 @@ +@file:JvmSynthetic + +package com.revenuecat.purchases.ui.revenuecatui.components.webview + +import com.revenuecat.purchases.ui.revenuecatui.PaywallWebViewMessage +import com.revenuecat.purchases.ui.revenuecatui.PaywallWebViewValue +import com.revenuecat.purchases.ui.revenuecatui.helpers.Logger +import org.json.JSONObject + +/** + * Parses and validates raw JSON strings received from a `web_view` component into typed + * [PaywallWebViewMessage]s before they reach app code. + * + * Payloads use the `workflow-web-components-sdk` transport envelope (`channel`, + * `protocol_version`, `kind`, `component_id`, …). App-level messages arrive as `kind` + * `message` or `request` with a `type` such as `rc:step-loaded`. + * + * Returns `null` for handshake frames, transport errors, malformed payloads, wrong + * `component_id`, or unknown app message types. + */ +internal object WebViewMessageParser { + + const val MAX_PAYLOAD_BYTES: Int = 65_536 + const val MAX_NESTING_DEPTH: Int = 16 + + internal data class ParsedAppMessage( + val message: PaywallWebViewMessage, + /** Set when the inbound frame is a transport `request` that expects a `response`. */ + val requestId: String? = null, + val requestType: String? = null, + ) + + @Suppress("ReturnCount") + fun parse(rawJson: String, expectedComponentId: String): ParsedAppMessage? { + if (rawJson.toByteArray(Charsets.UTF_8).size > MAX_PAYLOAD_BYTES) { + Logger.w("Dropping web view message: payload exceeds $MAX_PAYLOAD_BYTES bytes.") + return null + } + + val envelope = WebViewEnvelope.parse(rawJson) ?: run { + Logger.w("Dropping web view message: not a valid transport envelope.") + return null + } + + return when (envelope.kind) { + WebViewEnvelope.KIND_MESSAGE, + WebViewEnvelope.KIND_REQUEST, + -> parseAppMessage(envelope, expectedComponentId) + + else -> null + } + } + + @Suppress("ReturnCount") + private fun parseAppMessage( + envelope: WebViewEnvelope.Parsed, + expectedComponentId: String, + ): ParsedAppMessage? { + if (envelope.componentId != expectedComponentId) { + Logger.w("Dropping web view message: 'component_id' does not match the rendered web_view.") + return null + } + + val type = envelope.type?.takeIf { it.isNotEmpty() } + if (type == null) { + Logger.w("Dropping web view message: missing or non-string 'type'.") + return null + } + + val message = when (type) { + WebViewMessageType.STEP_LOADED, + WebViewMessageType.REQUEST_VARIABLES, + -> PaywallWebViewMessage(componentId = envelope.componentId, type = type) + + WebViewMessageType.STEP_COMPLETE -> { + val responses = parseResponses(envelope.payload) ?: return null + PaywallWebViewMessage( + componentId = envelope.componentId, + type = type, + responses = responses.value, + ) + } + + WebViewMessageType.ERROR -> { + val error = parseError(envelope) ?: return null + PaywallWebViewMessage(componentId = envelope.componentId, type = type, error = error) + } + + else -> { + Logger.d("Dropping web view message: unknown type '$type'.") + return null + } + } + + val requestId = if (envelope.kind == WebViewEnvelope.KIND_REQUEST) envelope.id else null + if (envelope.kind == WebViewEnvelope.KIND_REQUEST && requestId == null) { + Logger.w("Dropping web view message: transport request is missing 'id'.") + return null + } + + return ParsedAppMessage( + message = message, + requestId = requestId, + requestType = type, + ) + } + + private fun parseError(envelope: WebViewEnvelope.Parsed): String? { + val payloadError = envelope.payload?.opt(WebViewMessageField.ERROR) as? String + val error = payloadError ?: envelope.error + if (error == null) { + Logger.w("Dropping web view message: 'rc:error' requires a string 'error'.") + return null + } + return error + } + + /** + * Parses the `responses` object for a `rc:step-complete` message. The responses may live under + * `payload.responses` or directly in `payload` when the content sends only response fields. + */ + private class ParsedResponses(val value: Map) + + @Suppress("ReturnCount") + private fun parseResponses(payload: JSONObject?): ParsedResponses? { + if (payload == null || payload.length() == 0) { + return ParsedResponses(emptyMap()) + } + + val responsesJson = when { + payload.has(WebViewMessageField.RESPONSES) && !payload.isNull(WebViewMessageField.RESPONSES) -> + payload.opt(WebViewMessageField.RESPONSES) as? JSONObject + + payload.keys().asSequence().any { it in STEP_COMPLETE_RESERVED_PAYLOAD_KEYS } -> { + Logger.w( + "Dropping web view message: 'rc:step-complete' payload contains transport fields " + + "without a 'responses' object.", + ) + return null + } + + else -> payload + } + + if (responsesJson == null) { + Logger.w("Dropping web view message: 'responses' must be a JSON object.") + return null + } + + val converted = PaywallWebViewValue.fromJson(responsesJson, MAX_NESTING_DEPTH) + if (converted !is PaywallWebViewValue.Object) { + Logger.w("Dropping web view message: 'responses' contains non-JSON values or is too deeply nested.") + return null + } + return ParsedResponses(converted.value) + } + + private val STEP_COMPLETE_RESERVED_PAYLOAD_KEYS: Set = setOf( + WebViewMessageField.CHANNEL, + WebViewMessageField.PROTOCOL_VERSION, + WebViewMessageField.KIND, + WebViewMessageField.TYPE, + WebViewMessageField.COMPONENT_ID, + WebViewMessageField.ID, + WebViewMessageField.ERROR, + WebViewMessageField.VARIABLES, + ) +} diff --git a/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/webview/WebViewMessageType.kt b/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/webview/WebViewMessageType.kt new file mode 100644 index 0000000000..ae19c0db30 --- /dev/null +++ b/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/webview/WebViewMessageType.kt @@ -0,0 +1,39 @@ +@file:JvmSynthetic + +package com.revenuecat.purchases.ui.revenuecatui.components.webview + +/** + * Message type identifiers for the RevenueCat `web_view` postMessage protocol (`protocol_version: 1`). + * These mirror the shapes used by the web implementation and must not diverge. + */ +internal object WebViewMessageType { + const val STEP_LOADED = "rc:step-loaded" + const val STEP_COMPLETE = "rc:step-complete" + const val REQUEST_VARIABLES = "rc:request-variables" + const val ERROR = "rc:error" + const val VARIABLES = "rc:variables" + + /** Host → content: which axes the native host sizes to the content (`fit`). */ + const val FIT = "fit" + + /** Content → host: reported content box size in CSS pixels. */ + const val RESIZE = "resize" +} + +/** + * Field names used in the flat message envelope. The envelope is intentionally flat: message-specific + * fields such as [RESPONSES], [ERROR] and [VARIABLES] live at the top level rather than under a + * generic `payload` key. + */ +internal object WebViewMessageField { + const val CHANNEL = "channel" + const val PROTOCOL_VERSION = "protocol_version" + const val KIND = "kind" + const val TYPE = "type" + const val COMPONENT_ID = "component_id" + const val ID = "id" + const val PAYLOAD = "payload" + const val RESPONSES = "responses" + const val ERROR = "error" + const val VARIABLES = "variables" +} diff --git a/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/webview/WebViewUrlResolver.kt b/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/webview/WebViewUrlResolver.kt new file mode 100644 index 0000000000..9f51386a67 --- /dev/null +++ b/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/webview/WebViewUrlResolver.kt @@ -0,0 +1,47 @@ +@file:JvmSynthetic + +package com.revenuecat.purchases.ui.revenuecatui.components.webview + +import com.revenuecat.purchases.UiConfig +import com.revenuecat.purchases.ui.revenuecatui.CustomVariableValue +import com.revenuecat.purchases.ui.revenuecatui.components.ktx.toJavaLocale +import com.revenuecat.purchases.ui.revenuecatui.data.PaywallState +import com.revenuecat.purchases.ui.revenuecatui.data.processed.VariableProcessorV2 +import java.net.URL +import java.util.Locale + +internal object WebViewUrlResolver { + + fun resolve( + urlTemplate: String, + state: PaywallState.Loaded.Components, + ): URL? = resolve( + urlTemplate = urlTemplate, + variableConfig = state.variableConfig, + customVariables = state.customVariables, + defaultCustomVariables = state.defaultCustomVariables, + locale = state.locale.toJavaLocale(), + ) + + fun resolve( + urlTemplate: String, + variableConfig: UiConfig.VariableConfig, + customVariables: Map, + defaultCustomVariables: Map, + locale: Locale, + ): URL? { + val resolvedUrl = VariableProcessorV2.processVariables( + template = urlTemplate, + variableConfig = variableConfig, + dateLocale = locale, + customVariables = customVariables, + defaultCustomVariables = defaultCustomVariables, + ) + + return runCatching { URL(resolvedUrl) } + .getOrNull() + ?.takeIf { it.protocol == HTTPS_SCHEME && it.host.isNotBlank() } + } + + private const val HTTPS_SCHEME = "https" +} diff --git a/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/data/PaywallState.kt b/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/data/PaywallState.kt index 6836c3bafa..42453bd0d1 100644 --- a/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/data/PaywallState.kt +++ b/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/data/PaywallState.kt @@ -17,6 +17,7 @@ import com.revenuecat.purchases.Package import com.revenuecat.purchases.UiConfig.VariableConfig import com.revenuecat.purchases.paywalls.components.common.LocaleId import com.revenuecat.purchases.ui.revenuecatui.CustomVariableValue +import com.revenuecat.purchases.ui.revenuecatui.PaywallWebViewMessageHandler import com.revenuecat.purchases.ui.revenuecatui.components.ktx.getBestMatch import com.revenuecat.purchases.ui.revenuecatui.components.ktx.toComposeLocale import com.revenuecat.purchases.ui.revenuecatui.components.ktx.toJavaLocale @@ -85,7 +86,7 @@ internal sealed interface PaywallState { } } - @Suppress("LongParameterList") + @Suppress("LongParameterList", "TooManyFunctions") @Stable class Components( val stack: ComponentStyle, @@ -116,6 +117,7 @@ internal sealed interface PaywallState { * Default custom variables from the dashboard configuration. */ val defaultCustomVariables: Map = emptyMap(), + initialWebViewMessageHandler: PaywallWebViewMessageHandler? = null, initialLocaleList: LocaleList = LocaleList.current, initialSelectedTabIndex: Int? = null, initialSheetState: SimpleSheetState = SimpleSheetState(), @@ -188,6 +190,17 @@ internal sealed interface PaywallState { private var localeId by mutableStateOf(initialLocaleList.toLocaleId()) + /** + * Optional handler for messages sent by `web_view` components, set via + * [com.revenuecat.purchases.ui.revenuecatui.PaywallOptions.Builder.setWebViewMessageHandler]. + * + * Backed by snapshot state and refreshed in place (without rebuilding the paywall state) when + * the handler changes on [com.revenuecat.purchases.ui.revenuecatui.PaywallOptions]; see + * `PaywallViewModelImpl.updateOptions`. + */ + var webViewMessageHandler by mutableStateOf(initialWebViewMessageHandler) + private set + // We find all available device locales with the same country as the storefront country. private val availableStorefrontCountryLocalesByLanguage: Map by lazy { if (storefrontCountryCode.isNullOrBlank()) { @@ -333,6 +346,10 @@ internal sealed interface PaywallState { if (actionInProgress != null) this.actionInProgress = actionInProgress } + fun update(webViewMessageHandler: PaywallWebViewMessageHandler?) { + this.webViewMessageHandler = webViewMessageHandler + } + fun update(selectedPackageUniqueId: String) { this.selectedPackageUniqueId = selectedPackageUniqueId diff --git a/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/data/PaywallViewModel.kt b/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/data/PaywallViewModel.kt index c36384d3dc..777924eb3a 100644 --- a/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/data/PaywallViewModel.kt +++ b/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/data/PaywallViewModel.kt @@ -275,9 +275,22 @@ internal class PaywallViewModelImpl( val needsUpdateState = this.options.hashCode() != options.hashCode() // Some properties not considered for equality (hashCode) may have changed // (e.g. the listener may change in some re-renderers) + val previousWebViewMessageHandler = this.options.webViewMessageHandler this.options = options if (needsUpdateState) { updateState() + } else if (previousWebViewMessageHandler !== options.webViewMessageHandler) { + // The web view message handler is excluded from hashCode and is baked into the loaded + // Components state, so a handler-only change won't rebuild state. Refresh it in place + // (like refreshStateIfLocaleChanged) so web_view components get the latest handler without + // re-resolving the offering or resetting selections. + val currentState = _state.value + if (currentState is PaywallState.Loaded.Components) { + currentState.update(webViewMessageHandler = options.webViewMessageHandler) + workflowStepStateCache.values.forEach { + it.update(webViewMessageHandler = options.webViewMessageHandler) + } + } } } @@ -1169,7 +1182,12 @@ internal class PaywallViewModelImpl( } if (computed is PaywallState.Loaded.Components && stepId !in workflowStepStateCache) { workflowStepStateCache[stepId] = computed + // Re-stamp the locale and web view message handler from the latest values at insertion + // time: this step's state was computed on the background dispatcher with a snapshot + // taken before any concurrent updateOptions/locale change, and updateOptions only + // refreshes steps already present in the cache when it runs. computed.update(localeList = _lastLocaleList.value.toFrameworkLocaleList()) + computed.update(webViewMessageHandler = options.webViewMessageHandler) workflow.singleStepFallbackId ?.let { workflowStepStateCache[it]?.selectedPackageInfo } ?.let { computed.setDefaultPackage(it) } @@ -1445,6 +1463,7 @@ internal class PaywallViewModelImpl( purchases = purchases, customVariables = options.customVariables, defaultCustomVariables = extractDefaultCustomVariables(offering), + webViewMessageHandler = options.webViewMessageHandler, ) } } 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..a0a3d8b40d 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 @@ -35,6 +36,7 @@ import com.revenuecat.purchases.paywalls.components.properties.Size import com.revenuecat.purchases.paywalls.components.properties.SizeConstraint import com.revenuecat.purchases.ui.revenuecatui.CustomVariableValue import com.revenuecat.purchases.ui.revenuecatui.PaywallMode +import com.revenuecat.purchases.ui.revenuecatui.PaywallWebViewMessageHandler import com.revenuecat.purchases.ui.revenuecatui.components.ktx.getBestMatch import com.revenuecat.purchases.ui.revenuecatui.components.properties.FontSpec import com.revenuecat.purchases.ui.revenuecatui.components.properties.determineFontSpecs @@ -366,6 +368,7 @@ internal fun Offering.toComponentsPaywallState( purchases: PurchasesType, customVariables: Map = emptyMap(), defaultCustomVariables: Map = emptyMap(), + webViewMessageHandler: PaywallWebViewMessageHandler? = null, ): PaywallState.Loaded.Components { val showPricesWithDecimals = storefrontCountryCode?.let { !validationResult.zeroDecimalPlaceCountries.contains(it) @@ -386,6 +389,7 @@ internal fun Offering.toComponentsPaywallState( packages = validationResult.packages, customVariables = customVariables, defaultCustomVariables = defaultCustomVariables, + initialWebViewMessageHandler = webViewMessageHandler, initialSelectedTabIndex = validationResult.initialSelectedTabIndex, mainStackHasHeroImage = validationResult.mainStackHasHeroImage, purchases = purchases, @@ -516,6 +520,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/PaywallOptionsWebViewHandlerTest.kt b/ui/revenuecatui/src/test/kotlin/com/revenuecat/purchases/ui/revenuecatui/PaywallOptionsWebViewHandlerTest.kt new file mode 100644 index 0000000000..37bee38233 --- /dev/null +++ b/ui/revenuecatui/src/test/kotlin/com/revenuecat/purchases/ui/revenuecatui/PaywallOptionsWebViewHandlerTest.kt @@ -0,0 +1,44 @@ +package com.revenuecat.purchases.ui.revenuecatui + +import org.assertj.core.api.Assertions.assertThat +import org.junit.Test + +internal class PaywallOptionsWebViewHandlerTest { + + private fun options(handler: PaywallWebViewMessageHandler?): PaywallOptions = + PaywallOptions.Builder(dismissRequest = {}) + .setWebViewMessageHandler(handler) + .build() + + @Test + fun `builder stores the web view message handler`() { + val handler = PaywallWebViewMessageHandler { _, _ -> } + + assertThat(options(handler).webViewMessageHandler).isSameAs(handler) + } + + @Test + fun `defaults to no web view message handler`() { + val options = PaywallOptions.Builder(dismissRequest = {}).build() + + assertThat(options.webViewMessageHandler).isNull() + } + + @Test + fun `options differing only by handler are not equal`() { + val handlerA = PaywallWebViewMessageHandler { _, _ -> } + val handlerB = PaywallWebViewMessageHandler { _, _ -> } + + assertThat(options(handlerA)).isNotEqualTo(options(handlerB)) + } + + @Test + fun `handler is excluded from hashCode so state updates are not triggered by it alone`() { + val handlerA = PaywallWebViewMessageHandler { _, _ -> } + val handlerB = PaywallWebViewMessageHandler { _, _ -> } + + // hashCode intentionally ignores the handler (like listener) so swapping handlers does not + // force a paywall state refresh; see PaywallViewModel.updateOptions. + assertThat(options(handlerA).hashCode()).isEqualTo(options(handlerB).hashCode()) + } +} diff --git a/ui/revenuecatui/src/test/kotlin/com/revenuecat/purchases/ui/revenuecatui/PaywallWebViewValueTest.kt b/ui/revenuecatui/src/test/kotlin/com/revenuecat/purchases/ui/revenuecatui/PaywallWebViewValueTest.kt new file mode 100644 index 0000000000..558176ae68 --- /dev/null +++ b/ui/revenuecatui/src/test/kotlin/com/revenuecat/purchases/ui/revenuecatui/PaywallWebViewValueTest.kt @@ -0,0 +1,85 @@ +package com.revenuecat.purchases.ui.revenuecatui + +import org.assertj.core.api.Assertions.assertThat +import org.json.JSONArray +import org.json.JSONObject +import org.junit.Test + +internal class PaywallWebViewValueTest { + + @Test + fun `fromJson converts primitives`() { + assertThat(PaywallWebViewValue.fromJson("hello", 8)).isEqualTo(PaywallWebViewValue.String("hello")) + assertThat(PaywallWebViewValue.fromJson(true, 8)).isEqualTo(PaywallWebViewValue.Boolean(true)) + assertThat(PaywallWebViewValue.fromJson(42, 8)).isEqualTo(PaywallWebViewValue.Number(42)) + assertThat(PaywallWebViewValue.fromJson(3.5, 8)).isEqualTo(PaywallWebViewValue.Number(3.5)) + assertThat(PaywallWebViewValue.fromJson(null, 8)).isEqualTo(PaywallWebViewValue.Null) + assertThat(PaywallWebViewValue.fromJson(JSONObject.NULL, 8)).isEqualTo(PaywallWebViewValue.Null) + } + + @Test + fun `fromJson converts nested objects and arrays`() { + val json = JSONObject("""{"a":1,"b":["x",false],"c":{"d":null}}""") + + val value = PaywallWebViewValue.fromJson(json, 8) + + assertThat(value).isInstanceOf(PaywallWebViewValue.Object::class.java) + val obj = (value as PaywallWebViewValue.Object).value + assertThat(obj["a"]).isEqualTo(PaywallWebViewValue.Number(1)) + assertThat(obj["b"]).isEqualTo( + PaywallWebViewValue.Array(listOf(PaywallWebViewValue.String("x"), PaywallWebViewValue.Boolean(false))), + ) + assertThat(obj["c"]).isEqualTo( + PaywallWebViewValue.Object(mapOf("d" to PaywallWebViewValue.Null)), + ) + } + + @Test + fun `fromJson returns null when too deeply nested`() { + val json = JSONObject("""{"a":{"b":{"c":1}}}""") + + // Allow only 2 levels: top object (1) -> a (2) -> b would exceed. + assertThat(PaywallWebViewValue.fromJson(json, 2)).isNull() + } + + @Test + fun `toJsonRepresentation emits whole numbers as integers`() { + assertThat(PaywallWebViewValue.Number(100.0).toJsonRepresentation()).isEqualTo(100L) + assertThat(PaywallWebViewValue.Number(99.99).toJsonRepresentation()).isEqualTo(99.99) + } + + @Test + fun `toJsonRepresentation keeps non-finite numbers as doubles`() { + // Infinity/NaN are not whole numbers, so they must not be collapsed to Long. + assertThat(PaywallWebViewValue.Number(Double.POSITIVE_INFINITY).toJsonRepresentation()) + .isEqualTo(Double.POSITIVE_INFINITY) + assertThat(PaywallWebViewValue.Number(Double.NaN).toJsonRepresentation()) + .isEqualTo(Double.NaN) + } + + @Test + fun `toJsonObject round-trips a map`() { + val map = mapOf( + "locale" to PaywallWebViewValue.String("en-US"), + "custom" to PaywallWebViewValue.Object( + mapOf( + "plan" to PaywallWebViewValue.String("annual"), + "count" to PaywallWebViewValue.Number(3), + "flag" to PaywallWebViewValue.Boolean(true), + "list" to PaywallWebViewValue.Array(listOf(PaywallWebViewValue.Number(1))), + "nothing" to PaywallWebViewValue.Null, + ), + ), + ) + + val json = map.toJsonObject() + + assertThat(json.getString("locale")).isEqualTo("en-US") + val custom = json.getJSONObject("custom") + assertThat(custom.getString("plan")).isEqualTo("annual") + assertThat(custom.getInt("count")).isEqualTo(3) + assertThat(custom.getBoolean("flag")).isTrue() + assertThat(custom.get("list")).isInstanceOf(JSONArray::class.java) + assertThat(custom.isNull("nothing")).isTrue() + } +} diff --git a/ui/revenuecatui/src/test/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/style/StyleFactoryTests.kt b/ui/revenuecatui/src/test/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/style/StyleFactoryTests.kt index 9ee0a41b55..e60ad00b1c 100644 --- a/ui/revenuecatui/src/test/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/style/StyleFactoryTests.kt +++ b/ui/revenuecatui/src/test/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/style/StyleFactoryTests.kt @@ -22,6 +22,7 @@ import com.revenuecat.purchases.paywalls.components.TabControlComponent import com.revenuecat.purchases.paywalls.components.TabControlToggleComponent import com.revenuecat.purchases.paywalls.components.TabsComponent import com.revenuecat.purchases.paywalls.components.TextComponent +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.LocaleId @@ -118,6 +119,41 @@ class StyleFactoryTests { assertThat(colorStyle.color).isEqualTo(expectedColor) } + @Test + fun `Should create a WebViewComponentStyle for a WebViewComponent`() { + val size = Size(width = SizeConstraint.Fill, height = SizeConstraint.Fit) + val component = WebViewComponent( + url = "https://paywalls.revenuecat.com/{{ custom.animal }}.html", + id = "promo_web_view", + visible = false, + size = size, + ) + + val result = styleFactory.create(component) + + assertThat(result).isInstanceOf(Result.Success::class.java) + val style = (result as Result.Success).value.componentStyle as WebViewComponentStyle + assertThat(style.urlTemplate).isEqualTo("https://paywalls.revenuecat.com/{{ custom.animal }}.html") + assertThat(style.visible).isFalse() + assertThat(style.size).isEqualTo(size) + assertThat(style.componentId).isEqualTo("promo_web_view") + assertThat(style.protocolVersion).isNull() + } + + @Test + fun `Should pass protocolVersion through to the WebViewComponentStyle`() { + val component = WebViewComponent( + url = "https://paywalls.revenuecat.com/index.html", + protocolVersion = 1, + ) + + val result = styleFactory.create(component) + + assertThat(result).isInstanceOf(Result.Success::class.java) + val style = (result as Result.Success).value.componentStyle as WebViewComponentStyle + assertThat(style.protocolVersion).isEqualTo(1) + } + @Test fun `Should create a StackComponentStyle with children for a StackComponent with children`() { // Arrange 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/ui/revenuecatui/src/test/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/webview/PaywallWebViewVariablesProviderTest.kt b/ui/revenuecatui/src/test/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/webview/PaywallWebViewVariablesProviderTest.kt new file mode 100644 index 0000000000..2ea5defb15 --- /dev/null +++ b/ui/revenuecatui/src/test/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/webview/PaywallWebViewVariablesProviderTest.kt @@ -0,0 +1,34 @@ +package com.revenuecat.purchases.ui.revenuecatui.components.webview + +import com.revenuecat.purchases.ui.revenuecatui.PaywallWebViewValue +import org.assertj.core.api.Assertions.assertThat +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +internal class PaywallWebViewVariablesProviderTest { + + @Test + fun `sdkManagedVariables exposes only the locale system variable`() { + val variables = PaywallWebViewVariablesProvider.sdkManagedVariables(locale = "en-US") + + // Only the locale system variable is exposed; nothing else is passed across the bridge in v1. + assertThat(variables).hasSize(1) + assertThat(variables[PaywallWebViewVariablesProvider.KEY_LOCALE]) + .isEqualTo(PaywallWebViewValue.String("en-US")) + } + + @Test + fun `sanitizeAppProvidedVariables drops reserved keys`() { + val sanitized = PaywallWebViewVariablesProvider.sanitizeAppProvidedVariables( + mapOf( + "locale" to PaywallWebViewValue.String("zz-ZZ"), + "app_segment" to PaywallWebViewValue.String("high_intent"), + ), + ) + + assertThat(sanitized).doesNotContainKey("locale") + assertThat(sanitized).containsKey("app_segment") + } +} diff --git a/ui/revenuecatui/src/test/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/webview/WebViewContentSecurityPolicyTest.kt b/ui/revenuecatui/src/test/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/webview/WebViewContentSecurityPolicyTest.kt new file mode 100644 index 0000000000..2871a2c4a7 --- /dev/null +++ b/ui/revenuecatui/src/test/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/webview/WebViewContentSecurityPolicyTest.kt @@ -0,0 +1,64 @@ +package com.revenuecat.purchases.ui.revenuecatui.components.webview + +import org.assertj.core.api.Assertions.assertThat +import org.junit.Test + +class WebViewContentSecurityPolicyTest { + + @Test + fun `policy allows same-origin and inlined images and fonts`() { + // Self-contained bundles routinely inline assets as data: URIs; remote hosts stay blocked. + assertThat(WEB_VIEW_CONTENT_SECURITY_POLICY).contains("img-src 'self' data:") + assertThat(WEB_VIEW_CONTENT_SECURITY_POLICY).contains("font-src 'self' data:") + } + + @Test + fun `policy allows only same-origin scripts`() { + assertThat(WEB_VIEW_CONTENT_SECURITY_POLICY).contains("script-src 'self'") + } + + @Test + fun `policy restricts XHR and other connections to the same origin`() { + // Same-origin fetch/XHR is allowed so the bundle can load its own packaged JSON (e.g. Lottie), + // but cross-origin connections are not. + assertThat(WEB_VIEW_CONTENT_SECURITY_POLICY).contains("connect-src 'self'") + } + + @Test + fun `policy anchors everything else to same-origin via default-src`() { + assertThat(WEB_VIEW_CONTENT_SECURITY_POLICY).contains("default-src 'self'") + } + + @Test + fun `policy whitelists no external origins`() { + // Isolation invariant: only 'self'/data:/inline keywords — never a wildcard or a remote host. + assertThat(WEB_VIEW_CONTENT_SECURITY_POLICY).doesNotContain("*") + assertThat(WEB_VIEW_CONTENT_SECURITY_POLICY).doesNotContain("http") + } + + @Test + fun `meta script installs the policy as an http-equiv meta element`() { + val script = contentSecurityPolicyMetaScript("default-src 'self'") + + assertThat(script).contains("http-equiv") + assertThat(script).contains("Content-Security-Policy") + assertThat(script).contains("default-src 'self'") + // Inserted before any other markup so it precedes resource-loading elements. + assertThat(script).contains("insertBefore") + } + + @Test + fun `meta script is idempotent via a window flag`() { + val script = contentSecurityPolicyMetaScript() + + assertThat(script).contains("__revenueCatCspInstalled") + } + + @Test + fun `meta script escapes double quotes and backslashes in the policy`() { + val script = contentSecurityPolicyMetaScript("default-src \"x\\y\"") + + // The double quotes and backslash are escaped so they cannot break out of the JS string literal. + assertThat(script).contains("""default-src \"x\\y\"""") + } +} diff --git a/ui/revenuecatui/src/test/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/webview/WebViewJavaScriptBridgeTest.kt b/ui/revenuecatui/src/test/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/webview/WebViewJavaScriptBridgeTest.kt new file mode 100644 index 0000000000..ea5eeadeee --- /dev/null +++ b/ui/revenuecatui/src/test/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/webview/WebViewJavaScriptBridgeTest.kt @@ -0,0 +1,470 @@ +package com.revenuecat.purchases.ui.revenuecatui.components.webview + +import android.os.Looper +import android.webkit.WebView +import androidx.test.core.app.ApplicationProvider +import com.revenuecat.purchases.ui.revenuecatui.PaywallWebViewController +import com.revenuecat.purchases.ui.revenuecatui.PaywallWebViewMessage +import com.revenuecat.purchases.ui.revenuecatui.PaywallWebViewMessageHandler +import com.revenuecat.purchases.ui.revenuecatui.PaywallWebViewValue +import org.assertj.core.api.Assertions.assertThat +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.Shadows.shadowOf +import org.robolectric.shadows.ShadowWebView +import java.net.URL + +@RunWith(RobolectricTestRunner::class) +internal class WebViewJavaScriptBridgeTest { + + private val componentId = "promo_web_view" + private val expectedUrl = URL("https://assets.example.com/promo/index.html") + + private lateinit var webView: WebView + private lateinit var shadowWebView: ShadowWebView + private val received = mutableListOf() + + @Before + fun setUp() { + webView = WebView(ApplicationProvider.getApplicationContext()) + shadowWebView = shadowOf(webView) + received.clear() + } + + private fun bridge( + handler: PaywallWebViewMessageHandler? = PaywallWebViewMessageHandler { message, _ -> received.add(message) }, + locale: String = "en-US", + navigateTo: URL? = expectedUrl, + sizeToContentWidth: Boolean = false, + sizeToContentHeight: Boolean = false, + onContentResize: (widthCssPx: Int?, heightCssPx: Int?) -> Unit = { _, _ -> }, + ): WebViewJavaScriptBridge { + val bridge = WebViewJavaScriptBridge( + webView = webView, + componentId = componentId, + expectedUrl = expectedUrl, + locale = locale, + messageHandler = handler, + sizeToContentWidth = sizeToContentWidth, + sizeToContentHeight = sizeToContentHeight, + onContentResize = onContentResize, + ) + bridge.attach() + navigateTo?.let { webView.loadUrl(it.toString()) } + return bridge + } + + private fun idleMainLooper() { + shadowOf(Looper.getMainLooper()).idle() + } + + private fun connect(bridge: WebViewJavaScriptBridge) { + bridge.postMessage( + """ + {"channel":"rc-web-components","protocol_version":1,"kind":"connect","component_id":""} + """.trimIndent(), + ) + idleMainLooper() + } + + private fun appMessage( + type: String, + payload: String? = null, + kind: String = WebViewEnvelope.KIND_MESSAGE, + id: String? = null, + ): String { + val payloadField = payload?.let { ""","payload":$it""" } ?: "" + val idField = id?.let { ""","id":"$it"""" } ?: "" + return """ + {"channel":"rc-web-components","protocol_version":1,"kind":"$kind","component_id":"$componentId","type":"$type"$payloadField$idField} + """.trimIndent() + } + + @Test + fun `completes connect handshake with init`() { + val bridge = bridge() + connect(bridge) + + val script = shadowWebView.lastEvaluatedJavascript + assertThat(script).contains("window.__rcWebComponentsReceive(") + assertThat(script).contains("\"kind\":\"init\"") + assertThat(script).contains("\"component_id\":\"promo_web_view\"") + } + + @Test + fun `completes connect handshake before the web view URL is available`() { + val bridge = bridge(navigateTo = null) + assertThat(webView.url).isNull() + + connect(bridge) + + val script = shadowWebView.lastEvaluatedJavascript + assertThat(script).contains("\"kind\":\"init\"") + assertThat(script).contains("\"component_id\":\"promo_web_view\"") + } + + @Test + fun `rejects unsupported protocol version`() { + val bridge = bridge() + bridge.postMessage( + """ + {"channel":"rc-web-components","protocol_version":2,"kind":"connect","component_id":""} + """.trimIndent(), + ) + idleMainLooper() + + val script = shadowWebView.lastEvaluatedJavascript + assertThat(script).contains("\"kind\":\"reject\"") + assertThat(script).contains("Unsupported protocol_version 2") + } + + @Test + fun `delivers valid message after handshake`() { + val bridge = bridge() + connect(bridge) + bridge.postMessage(appMessage(WebViewMessageType.STEP_LOADED)) + idleMainLooper() + + assertThat(received).hasSize(1) + assertThat(received.single().componentId).isEqualTo("promo_web_view") + assertThat(received.single().type).isEqualTo("rc:step-loaded") + } + + @Test + fun `ignores app messages before handshake`() { + bridge().postMessage(appMessage(WebViewMessageType.STEP_LOADED)) + idleMainLooper() + + assertThat(received).isEmpty() + } + + @Test + fun `rejects messages for a different component id`() { + val bridge = bridge() + connect(bridge) + bridge.postMessage( + appMessage( + type = WebViewMessageType.STEP_LOADED, + ).replace(componentId, "other_web_view"), + ) + idleMainLooper() + + assertThat(received).isEmpty() + } + + @Test + fun `request-variables auto-sends rc variables with locale only`() { + val bridge = bridge() + connect(bridge) + bridge.postMessage(appMessage(WebViewMessageType.REQUEST_VARIABLES)) + idleMainLooper() + + assertThat(received.single().type).isEqualTo("rc:request-variables") + val script = shadowWebView.lastEvaluatedJavascript + assertThat(script).contains("window.__rcWebComponentsReceive(") + assertThat(script).contains("\"kind\":\"message\"") + assertThat(script).contains("\"rc:variables\"") + assertThat(script).contains("\"component_id\":\"promo_web_view\"") + assertThat(script).contains("\"locale\":\"en-US\"") + assertThat(script).doesNotContain("\"custom\"") + } + + @Test + fun `request-variables transport request also sends response`() { + val bridge = bridge() + connect(bridge) + bridge.postMessage( + appMessage( + type = WebViewMessageType.REQUEST_VARIABLES, + kind = WebViewEnvelope.KIND_REQUEST, + id = "req-1", + ), + ) + idleMainLooper() + + val script = shadowWebView.lastEvaluatedJavascript + assertThat(script).contains("\"kind\":\"response\"") + assertThat(script).contains("\"id\":\"req-1\"") + assertThat(script).contains("\"locale\":\"en-US\"") + assertThat(script).doesNotContain("\"rc:variables\"") + } + + @Test + fun `app can reply with extra variables through the controller`() { + val handler = PaywallWebViewMessageHandler { message, controller -> + received.add(message) + if (message.type == WebViewMessageType.REQUEST_VARIABLES) { + controller.postVariables( + componentId = message.componentId, + variables = mapOf( + "custom" to PaywallWebViewValue.Object( + mapOf("app_segment" to PaywallWebViewValue.String("high_intent")), + ), + ), + ) + } + } + val bridge = bridge(handler = handler) + connect(bridge) + bridge.postMessage(appMessage(WebViewMessageType.REQUEST_VARIABLES)) + idleMainLooper() + + val script = shadowWebView.lastEvaluatedJavascript + assertThat(script).contains("\"rc:variables\"") + assertThat(script).contains("\"app_segment\":\"high_intent\"") + } + + @Test + fun `controller postVariables drops reserved keys`() { + val controllerHolder = arrayOfNulls(1) + val handler = PaywallWebViewMessageHandler { _, controller -> controllerHolder[0] = controller } + val bridge = bridge(handler = handler) + connect(bridge) + bridge.postMessage(appMessage(WebViewMessageType.STEP_LOADED)) + idleMainLooper() + + controllerHolder[0]!!.postVariables( + componentId = componentId, + variables = mapOf( + "locale" to PaywallWebViewValue.String("zz-ZZ"), + "custom" to PaywallWebViewValue.Object(mapOf("k" to PaywallWebViewValue.String("v"))), + ), + ) + idleMainLooper() + + val script = shadowWebView.lastEvaluatedJavascript + assertThat(script).doesNotContain("zz-ZZ") + assertThat(script).contains("\"k\":\"v\"") + } + + @Test + fun `does not deliver messages after release`() { + val bridge = bridge() + connect(bridge) + bridge.release() + + bridge.postMessage(appMessage(WebViewMessageType.STEP_LOADED)) + idleMainLooper() + + assertThat(received).isEmpty() + } + + @Test + fun `rejects messages after navigation to an unexpected origin`() { + val bridge = bridge(navigateTo = URL("https://evil.example.org/phish.html")) + connect(bridge) + bridge.postMessage(appMessage(WebViewMessageType.STEP_LOADED)) + idleMainLooper() + + assertThat(received).isEmpty() + } + + @Test + fun `allows messages from the same origin on a different path`() { + val bridge = bridge(navigateTo = URL("https://assets.example.com/promo/step-two.html")) + connect(bridge) + bridge.postMessage(appMessage(WebViewMessageType.STEP_LOADED)) + idleMainLooper() + + assertThat(received).hasSize(1) + } + + @Test + fun `does not deliver outbound messages after navigation to an unexpected origin`() { + val bridge = bridge(navigateTo = URL("https://evil.example.org/phish.html")) + connect(bridge) + bridge.postMessage( + componentId = componentId, + type = "rc:custom", + variables = mapOf("foo" to PaywallWebViewValue.String("bar")), + ) + idleMainLooper() + + assertThat(shadowWebView.lastEvaluatedJavascript).isNull() + } + + @Test + fun `treats the default https port as the same origin`() { + val bridge = bridge(navigateTo = URL("https://assets.example.com:443/promo/index.html")) + connect(bridge) + bridge.postMessage( + componentId = componentId, + type = "rc:custom", + variables = mapOf("foo" to PaywallWebViewValue.String("bar")), + ) + idleMainLooper() + + assertThat(shadowWebView.lastEvaluatedJavascript).contains("\"type\":\"rc:custom\"") + } + + @Test + fun `delivers rc step-complete responses to the handler without sending an outbound message`() { + val bridge = bridge() + connect(bridge) + val scriptAfterConnect = shadowWebView.lastEvaluatedJavascript + bridge.postMessage( + appMessage( + type = WebViewMessageType.STEP_COMPLETE, + payload = """{"responses":{"selected_plan":"annual","accepted_terms":true}}""", + ), + ) + idleMainLooper() + + val message = received.single() + assertThat(message.type).isEqualTo("rc:step-complete") + assertThat(message.responses?.get("selected_plan")).isEqualTo(PaywallWebViewValue.String("annual")) + assertThat(message.responses?.get("accepted_terms")).isEqualTo(PaywallWebViewValue.Boolean(true)) + assertThat(shadowWebView.lastEvaluatedJavascript).isEqualTo(scriptAfterConnect) + } + + @Test + fun `delivers rc error to the handler`() { + val bridge = bridge() + connect(bridge) + bridge.postMessage( + appMessage( + type = WebViewMessageType.ERROR, + payload = """{"error":"Something went wrong"}""", + ), + ) + idleMainLooper() + + val message = received.single() + assertThat(message.type).isEqualTo("rc:error") + assertThat(message.error).isEqualTo("Something went wrong") + } + + @Test + fun `does not deliver malformed messages to the handler`() { + val bridge = bridge() + connect(bridge) + bridge.postMessage("""not even json""") + idleMainLooper() + + assertThat(received).isEmpty() + } + + @Test + fun `auto-sends rc variables even when no handler is set`() { + val bridge = bridge(handler = null) + connect(bridge) + bridge.postMessage(appMessage(WebViewMessageType.REQUEST_VARIABLES)) + idleMainLooper() + + assertThat(received).isEmpty() + val script = shadowWebView.lastEvaluatedJavascript + assertThat(script).contains("\"rc:variables\"") + assertThat(script).contains("\"locale\":\"en-US\"") + } + + @Test + fun `update refreshes the variables sent on a subsequent request`() { + val bridge = bridge(locale = "en-US") + connect(bridge) + bridge.update( + locale = "fr-FR", + messageHandler = null, + ) + + bridge.postMessage(appMessage(WebViewMessageType.REQUEST_VARIABLES)) + idleMainLooper() + + val script = shadowWebView.lastEvaluatedJavascript + assertThat(script).contains("\"locale\":\"fr-FR\"") + assertThat(script).doesNotContain("en-US") + } + + @Test + fun `generic postMessage sends transport envelope with payload`() { + val bridge = bridge() + connect(bridge) + bridge.postMessage( + componentId = componentId, + type = "rc:custom", + variables = mapOf("foo" to PaywallWebViewValue.String("bar")), + ) + idleMainLooper() + + val script = shadowWebView.lastEvaluatedJavascript + assertThat(script).contains("window.__rcWebComponentsReceive(") + assertThat(script).contains("\"kind\":\"message\"") + assertThat(script).contains("\"type\":\"rc:custom\"") + assertThat(script).contains("\"component_id\":\"promo_web_view\"") + assertThat(script).contains("\"payload\":{\"foo\":\"bar\"}") + } + + @Test + fun `escapes line and paragraph separators in outbound payloads`() { + val raw = "a\u2028b\u2029c" + val bridge = bridge() + connect(bridge) + bridge.postVariables( + componentId = componentId, + variables = mapOf( + "custom" to PaywallWebViewValue.Object(mapOf("note" to PaywallWebViewValue.String(raw))), + ), + ) + idleMainLooper() + + val script = shadowWebView.lastEvaluatedJavascript + assertThat(script).doesNotContain("\u2028") + assertThat(script).doesNotContain("\u2029") + assertThat(script).contains("\\u2028") + assertThat(script).contains("\\u2029") + } + + @Test + fun `attach registers native interface under rcWebComponents`() { + bridge() + + assertThat(shadowWebView.getJavascriptInterface("rcWebComponents")).isNotNull + } + + @Test + fun `release removes the native interface`() { + val bridge = bridge() + assertThat(shadowWebView.getJavascriptInterface("rcWebComponents")).isNotNull + + bridge.release() + + assertThat(shadowWebView.getJavascriptInterface("rcWebComponents")).isNull() + } + + @Test + fun `sends fit message after init when height is fit`() { + val bridge = bridge(sizeToContentHeight = true) + connect(bridge) + + val script = shadowWebView.lastEvaluatedJavascript + assertThat(script).contains("\"type\":\"fit\"") + assertThat(script).contains("\"height\":true") + assertThat(script).doesNotContain("\"width\":true") + } + + @Test + fun `reports content resize from inbound resize message`() { + var reportedWidth: Int? = null + var reportedHeight: Int? = null + val bridge = bridge( + handler = null, + onContentResize = { widthCssPx, heightCssPx -> + reportedWidth = widthCssPx + reportedHeight = heightCssPx + }, + ) + connect(bridge) + bridge.postMessage( + appMessage( + type = WebViewMessageType.RESIZE, + payload = """{"width":320,"height":480}""", + ), + ) + idleMainLooper() + + assertThat(reportedWidth).isEqualTo(320) + assertThat(reportedHeight).isEqualTo(480) + assertThat(received).isEmpty() + } +} diff --git a/ui/revenuecatui/src/test/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/webview/WebViewMessageParserTest.kt b/ui/revenuecatui/src/test/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/webview/WebViewMessageParserTest.kt new file mode 100644 index 0000000000..333372396b --- /dev/null +++ b/ui/revenuecatui/src/test/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/webview/WebViewMessageParserTest.kt @@ -0,0 +1,275 @@ +package com.revenuecat.purchases.ui.revenuecatui.components.webview + +import com.revenuecat.purchases.ui.revenuecatui.PaywallWebViewValue +import org.assertj.core.api.Assertions.assertThat +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +internal class WebViewMessageParserTest { + + private val componentId = "promo_web_view" + + @Test + fun `parses rc step-loaded`() { + val parsed = parse( + envelope( + kind = WebViewEnvelope.KIND_MESSAGE, + type = WebViewMessageType.STEP_LOADED, + ), + ) + + assertThat(parsed).isNotNull + val message = parsed!!.message + assertThat(message.type).isEqualTo("rc:step-loaded") + assertThat(message.componentId).isEqualTo("promo_web_view") + assertThat(message.responses).isNull() + assertThat(message.error).isNull() + } + + @Test + fun `parses rc step-complete with responses in payload`() { + val parsed = parse( + envelope( + kind = WebViewEnvelope.KIND_MESSAGE, + type = WebViewMessageType.STEP_COMPLETE, + payload = """{"responses":{"selected_plan":"annual","accepted_terms":true,"count":3}}""", + ), + ) + + assertThat(parsed).isNotNull + val responses = parsed!!.message.responses + assertThat(responses).isNotNull + assertThat(responses!!["selected_plan"]).isEqualTo(PaywallWebViewValue.String("annual")) + assertThat(responses["accepted_terms"]).isEqualTo(PaywallWebViewValue.Boolean(true)) + assertThat(responses["count"]).isEqualTo(PaywallWebViewValue.Number(3)) + } + + @Test + fun `parses rc step-complete without responses as empty map`() { + val parsed = parse( + envelope( + kind = WebViewEnvelope.KIND_MESSAGE, + type = WebViewMessageType.STEP_COMPLETE, + ), + ) + + assertThat(parsed).isNotNull + assertThat(parsed!!.message.responses).isEmpty() + } + + @Test + fun `parses rc request-variables as message`() { + val parsed = parse( + envelope( + kind = WebViewEnvelope.KIND_MESSAGE, + type = WebViewMessageType.REQUEST_VARIABLES, + ), + ) + + assertThat(parsed).isNotNull + assertThat(parsed!!.message.type).isEqualTo("rc:request-variables") + assertThat(parsed.requestId).isNull() + } + + @Test + fun `parses rc request-variables as transport request`() { + val parsed = parse( + envelope( + kind = WebViewEnvelope.KIND_REQUEST, + type = WebViewMessageType.REQUEST_VARIABLES, + id = "req-1", + ), + ) + + assertThat(parsed).isNotNull + assertThat(parsed!!.message.type).isEqualTo("rc:request-variables") + assertThat(parsed.requestId).isEqualTo("req-1") + } + + @Test + fun `parses rc error from payload`() { + val parsed = parse( + envelope( + kind = WebViewEnvelope.KIND_MESSAGE, + type = WebViewMessageType.ERROR, + payload = """{"error":"Boom"}""", + ), + ) + + assertThat(parsed).isNotNull + assertThat(parsed!!.message.error).isEqualTo("Boom") + } + + @Test + fun `rejects message without type`() { + assertThat( + parse( + """{"channel":"rc-web-components","protocol_version":1,"kind":"message","component_id":"promo_web_view"}""", + ), + ).isNull() + } + + @Test + fun `rejects message with non-string type`() { + assertThat( + parse( + """ + {"channel":"rc-web-components","protocol_version":1,"kind":"message","component_id":"promo_web_view","type":123} + """.trimIndent(), + ), + ).isNull() + } + + @Test + fun `rejects message without component_id`() { + assertThat( + parse( + """ + {"channel":"rc-web-components","protocol_version":1,"kind":"message","type":"rc:step-loaded"} + """.trimIndent(), + ), + ).isNull() + } + + @Test + fun `rejects message with wrong component_id`() { + assertThat( + envelope( + kind = WebViewEnvelope.KIND_MESSAGE, + type = WebViewMessageType.STEP_LOADED, + componentId = "other_web_view", + ).let(::parse), + ).isNull() + } + + @Test + fun `rejects rc step-complete with non-object responses`() { + assertThat( + parse( + envelope( + kind = WebViewEnvelope.KIND_MESSAGE, + type = WebViewMessageType.STEP_COMPLETE, + payload = """{"responses":"nope"}""", + ), + ), + ).isNull() + } + + @Test + fun `rejects rc step-complete payload with transport fields and no responses object`() { + assertThat( + parse( + envelope( + kind = WebViewEnvelope.KIND_MESSAGE, + type = WebViewMessageType.STEP_COMPLETE, + payload = """{"type":"rc:step-complete","selected_plan":"annual"}""", + ), + ), + ).isNull() + } + + @Test + fun `rejects rc error without error string`() { + assertThat( + parse( + envelope( + kind = WebViewEnvelope.KIND_MESSAGE, + type = WebViewMessageType.ERROR, + ), + ), + ).isNull() + } + + @Test + fun `drops unknown message types`() { + assertThat( + parse( + envelope( + kind = WebViewEnvelope.KIND_MESSAGE, + type = "rc:something-new", + ), + ), + ).isNull() + } + + @Test + fun `rejects malformed json`() { + assertThat(parse("""not json""")).isNull() + } + + @Test + fun `rejects non-object json`() { + assertThat(parse("""["a","b"]""")).isNull() + } + + @Test + fun `rejects oversized payload`() { + val hugeValue = "x".repeat(WebViewMessageParser.MAX_PAYLOAD_BYTES + 1) + val raw = envelope( + kind = WebViewEnvelope.KIND_MESSAGE, + type = WebViewMessageType.ERROR, + payload = """{"error":"$hugeValue"}""", + ) + assertThat(parse(raw)).isNull() + } + + @Test + fun `rejects excessively nested responses`() { + val depth = WebViewMessageParser.MAX_NESTING_DEPTH + 2 + val opening = "{\"a\":".repeat(depth) + val closing = "}".repeat(depth) + val nested = opening + "1" + closing + val raw = envelope( + kind = WebViewEnvelope.KIND_MESSAGE, + type = WebViewMessageType.STEP_COMPLETE, + payload = """{"responses":$nested}""", + ) + assertThat(parse(raw)).isNull() + } + + @Test + fun `accepts null and nested json-compatible values in responses`() { + val parsed = parse( + envelope( + kind = WebViewEnvelope.KIND_MESSAGE, + type = WebViewMessageType.STEP_COMPLETE, + payload = """{"responses":{"maybe":null,"list":[1,"two",false],"obj":{"k":"v"}}}""", + ), + ) + + assertThat(parsed).isNotNull + val responses = parsed!!.message.responses!! + assertThat(responses["maybe"]).isEqualTo(PaywallWebViewValue.Null) + assertThat(responses["list"]).isInstanceOf(PaywallWebViewValue.Array::class.java) + assertThat(responses["obj"]).isInstanceOf(PaywallWebViewValue.Object::class.java) + } + + @Test + fun `ignores handshake frames`() { + assertThat( + parse( + """ + {"channel":"rc-web-components","protocol_version":1,"kind":"connect","component_id":""} + """.trimIndent(), + ), + ).isNull() + } + + private fun parse(raw: String) = WebViewMessageParser.parse(raw, expectedComponentId = componentId) + + private fun envelope( + kind: String, + type: String, + componentId: String = this.componentId, + payload: String? = null, + id: String? = null, + ): String { + val payloadField = payload?.let { ""","payload":$it""" } ?: "" + val idField = id?.let { ""","id":"$it"""" } ?: "" + return """ + {"channel":"rc-web-components","protocol_version":1,"kind":"$kind","component_id":"$componentId","type":"$type"$payloadField$idField} + """.trimIndent() + } +} diff --git a/ui/revenuecatui/src/test/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/webview/WebViewUrlResolverTest.kt b/ui/revenuecatui/src/test/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/webview/WebViewUrlResolverTest.kt new file mode 100644 index 0000000000..1bab7ca6d0 --- /dev/null +++ b/ui/revenuecatui/src/test/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/webview/WebViewUrlResolverTest.kt @@ -0,0 +1,83 @@ +package com.revenuecat.purchases.ui.revenuecatui.components.webview + +import com.revenuecat.purchases.UiConfig +import com.revenuecat.purchases.ui.revenuecatui.CustomVariableValue +import org.assertj.core.api.Assertions.assertThat +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import java.util.Locale + +@RunWith(RobolectricTestRunner::class) +class WebViewUrlResolverTest { + + @Test + fun `resolves static https url unchanged`() { + val result = resolve("https://paywalls.revenuecat.com/index.html") + + assertThat(result?.toString()).isEqualTo("https://paywalls.revenuecat.com/index.html") + } + + @Test + fun `resolves static https url with characters accepted by WebView`() { + val result = resolve("https://paywalls.revenuecat.com/index.html?filters=a|b") + + assertThat(result?.toString()).isEqualTo("https://paywalls.revenuecat.com/index.html?filters=a|b") + } + + @Test + fun `substitutes custom variables into the template before validating`() { + val result = resolve( + template = "https://paywalls.revenuecat.com/{{ custom.animal }}.html", + customVariables = mapOf("animal" to CustomVariableValue.String("dog")), + ) + + assertThat(result?.toString()).isEqualTo("https://paywalls.revenuecat.com/dog.html") + } + + @Test + fun `returns null for malformed url`() { + val result = resolve("not a url") + + assertThat(result).isNull() + } + + @Test + fun `returns null for malformed https url`() { + val result = resolve("https:///missing-host") + + assertThat(result).isNull() + } + + @Test + fun `returns null for non https url`() { + val result = resolve("http://paywalls.revenuecat.com/index.html") + + assertThat(result).isNull() + } + + @Test + fun `returns null for file url`() { + val result = resolve("file:///android_asset/index.html") + + assertThat(result).isNull() + } + + @Test + fun `returns null for custom scheme url`() { + val result = resolve("myapp://paywalls.revenuecat.com/index.html") + + assertThat(result).isNull() + } + + private fun resolve( + template: String, + customVariables: Map = emptyMap(), + ) = WebViewUrlResolver.resolve( + urlTemplate = template, + variableConfig = UiConfig.VariableConfig(), + customVariables = customVariables, + defaultCustomVariables = emptyMap(), + locale = Locale.US, + ) +} diff --git a/ui/revenuecatui/src/test/kotlin/com/revenuecat/purchases/ui/revenuecatui/data/PaywallViewModelTest.kt b/ui/revenuecatui/src/test/kotlin/com/revenuecat/purchases/ui/revenuecatui/data/PaywallViewModelTest.kt index 223be5b92a..e43fc92d97 100644 --- a/ui/revenuecatui/src/test/kotlin/com/revenuecat/purchases/ui/revenuecatui/data/PaywallViewModelTest.kt +++ b/ui/revenuecatui/src/test/kotlin/com/revenuecat/purchases/ui/revenuecatui/data/PaywallViewModelTest.kt @@ -50,6 +50,7 @@ import com.revenuecat.purchases.ui.revenuecatui.OfferingSelection import com.revenuecat.purchases.ui.revenuecatui.PaywallListener import com.revenuecat.purchases.ui.revenuecatui.PaywallMode import com.revenuecat.purchases.ui.revenuecatui.PaywallOptions +import com.revenuecat.purchases.ui.revenuecatui.PaywallWebViewMessageHandler import com.revenuecat.purchases.ui.revenuecatui.PaywallPurchaseLogicParams import com.revenuecat.purchases.ui.revenuecatui.PaywallPurchaseLogic import com.revenuecat.purchases.ui.revenuecatui.PaywallPurchaseLogicWithCallback @@ -662,6 +663,44 @@ class PaywallViewModelTest { coVerify(exactly = 1) { purchases.awaitOfferings() } } + @Test + fun `updateOptions refreshes the web view message handler in place without rebuilding`(): Unit = runBlocking { + val offering = Offering( + identifier = "offering-id", + serverDescription = "description", + metadata = emptyMap(), + availablePackages = listOf(TestData.Packages.monthly, TestData.Packages.annual), + paywallComponents = Offering.PaywallComponents(UiConfig(), emptyPaywallComponentsData), + ) + val handlerA = PaywallWebViewMessageHandler { _, _ -> } + val handlerB = PaywallWebViewMessageHandler { _, _ -> } + fun optionsWith(handler: PaywallWebViewMessageHandler) = + PaywallOptions.Builder(dismissRequest = { dismissInvoked = true }) + .setListener(listener) + .setOffering(offering) + .setWebViewMessageHandler(handler) + .build() + + val model = PaywallViewModelImpl( + MockResourceProvider(), + purchases, + optionsWith(handlerA), + TestData.Constants.currentColorScheme, + isDarkMode = false, + shouldDisplayBlock = null, + ) + val state = model.state.value as PaywallState.Loaded.Components + assertThat(state.webViewMessageHandler).isSameAs(handlerA) + + // Same paywall, only the handler differs (handler is excluded from hashCode). + model.updateOptions(optionsWith(handlerB)) + + // No rebuild: the loaded state instance is preserved (same selections/scroll), and it now + // carries the new handler so web_view components see the latest one. + assertThat(model.state.value).isSameAs(state) + assertThat(state.webViewMessageHandler).isSameAs(handlerB) + } + @Test fun `Should load default offering`() { val model = create() diff --git a/ui/revenuecatui/src/test/kotlin/com/revenuecat/purchases/ui/revenuecatui/data/PaywallViewModelWorkflowTest.kt b/ui/revenuecatui/src/test/kotlin/com/revenuecat/purchases/ui/revenuecatui/data/PaywallViewModelWorkflowTest.kt index adc10814db..27cb60d91d 100644 --- a/ui/revenuecatui/src/test/kotlin/com/revenuecat/purchases/ui/revenuecatui/data/PaywallViewModelWorkflowTest.kt +++ b/ui/revenuecatui/src/test/kotlin/com/revenuecat/purchases/ui/revenuecatui/data/PaywallViewModelWorkflowTest.kt @@ -46,6 +46,7 @@ import com.revenuecat.purchases.paywalls.components.properties.ColorInfo import com.revenuecat.purchases.paywalls.components.properties.ColorScheme import com.revenuecat.purchases.ui.revenuecatui.PaywallListener import com.revenuecat.purchases.ui.revenuecatui.PaywallOptions +import com.revenuecat.purchases.ui.revenuecatui.PaywallWebViewMessageHandler import com.revenuecat.purchases.ui.revenuecatui.PaywallPurchaseLogic import com.revenuecat.purchases.ui.revenuecatui.PaywallPurchaseLogicParams import com.revenuecat.purchases.ui.revenuecatui.PurchaseLogicResult @@ -657,6 +658,38 @@ class PaywallViewModelWorkflowTest { ).isEqualTo("gold") } + @Test + fun `pre-warmed workflow steps carry the web view message handler and follow updateOptions swaps`() { + val handlerA = PaywallWebViewMessageHandler { _, _ -> } + val handlerB = PaywallWebViewMessageHandler { _, _ -> } + fun optionsWith(handler: PaywallWebViewMessageHandler) = + PaywallOptions.Builder(dismissRequest = {}) + .setWebViewMessageHandler(handler) + .build() + val vm = PaywallViewModelImpl( + resourceProvider = MockResourceProvider(), + purchases = purchases, + options = optionsWith(handlerA), + colorScheme = TestData.Constants.currentColorScheme, + isDarkMode = false, + shouldDisplayBlock = null, + backgroundDispatcher = testDispatcher, + ) + + vm.startWorkflowPresentationFromResult(fetchResult, testOfferings, null, uiConfig) + // Let the background pre-warm finish so step-2 is cached without navigating to it. + testDispatcher.scheduler.advanceUntilIdle() + + // The pre-warmed (not navigated-to) step carries the handler from options. + assertThat(vm.workflowState.value?.stepStates?.get("step-2")?.webViewMessageHandler) + .isSameAs(handlerA) + + // Swapping the handler refreshes already-cached pre-warmed steps in place. + vm.updateOptions(optionsWith(handlerB)) + assertThat(vm.workflowState.value?.stepStates?.get("step-2")?.webViewMessageHandler) + .isSameAs(handlerB) + } + // endregion // region onTransitionComplete 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