From 848304204a318b6fe04a2d9603b82e843d321ea9 Mon Sep 17 00:00:00 2001 From: Alexander Repty Date: Fri, 26 Jun 2026 12:44:26 +0200 Subject: [PATCH] feat(paywalls): add web_view bidirectional messaging Adds a bidirectional JS<->native bridge for the Paywalls V2 `web_view` component: a document-start `window.RevenueCatWebView` shim, message parsing/validation (`WebViewMessageParser`, `WebViewMessageType`), the public `PaywallWebViewMessage` / `PaywallWebViewValue` / `PaywallWebViewMessageHandler` / `PaywallWebViewController` surface, and an SDK-managed variables provider (locale, color scheme, custom variables). Apps opt in via `PaywallOptions.setWebViewMessageHandler(...)`, threaded through `PaywallState` / `OfferingToStateMapper`. Regenerates ui/revenuecatui/api.txt for the new public API. Recommended labels: pr:feat, pr:RevenueCatUI, feat:Paywalls_V2 --- .../webview/WebViewJavaScriptBridge.kt | 247 ++++++++++++++ .../webview/WebViewJavaScriptBridgeTest.kt | 317 ++++++++++++++++++ 2 files changed, 564 insertions(+) create mode 100644 ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/webview/WebViewJavaScriptBridge.kt create mode 100644 ui/revenuecatui/src/test/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/webview/WebViewJavaScriptBridgeTest.kt 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 000000000..a3091c7e9 --- /dev/null +++ b/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/webview/WebViewJavaScriptBridge.kt @@ -0,0 +1,247 @@ +@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.CustomVariableValue +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 com.revenuecat.purchases.ui.revenuecatui.toJsonRepresentation +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 + * This implementation uses [WebView.addJavascriptInterface] (the project does not depend on + * `androidx.webkit`). Exactly one native method is exposed — [postMessage] — under the private object + * name [NATIVE_OBJECT_NAME]. A small document-start shim (see [injectBridgeScript]) then exposes the + * stable public surface `window.RevenueCatWebView.postMessage(message)`, accepting either an object + * (serialized to JSON) or a JSON string, without overwriting an existing bridge. + * + * 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. + * + * ## Native → web + * Native messages are delivered by invoking `window.__revenueCatReceiveMessage(message)` via + * [WebView.evaluateJavascript], preserving the flat RFC envelope. + * + * ## 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, + customVariables: Map, + messageHandler: PaywallWebViewMessageHandler?, + private val mainHandler: Handler = Handler(Looper.getMainLooper()), +) : PaywallWebViewController { + + private val webViewRef = WeakReference(webView) + private val expectedOrigin: String? = expectedUrl.toOriginOrNull() + + // Refreshed from the latest paywall state on every recomposition via update(). + @Volatile private var locale: String = locale + + @Volatile private var customVariables: Map = customVariables + + @Volatile private var messageHandler: PaywallWebViewMessageHandler? = messageHandler + + @Volatile private var released: Boolean = false + + /** + * Registers the native interface on the WebView. Call once, when the WebView is created. + */ + @MainThread + fun attach() { + webViewRef.get()?.addJavascriptInterface(this, NATIVE_OBJECT_NAME) + } + + /** + * Injects the `window.RevenueCatWebView` shim. Call from `WebViewClient.onPageStarted` so the + * surface is available before the page's own scripts run. + */ + @MainThread + fun injectBridgeScript() { + if (released) return + webViewRef.get()?.evaluateJavascript(BRIDGE_SCRIPT, null) + } + + /** + * Refreshes the SDK-managed variable sources and the app message handler from the latest paywall + * state. Call from `AndroidView`'s update block. + */ + fun update( + locale: String, + customVariables: Map, + messageHandler: PaywallWebViewMessageHandler?, + ) { + this.locale = locale + this.customVariables = customVariables + 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 + webViewRef.get()?.removeJavascriptInterface(NATIVE_OBJECT_NAME) + } + + /** + * Entry point for the injected `window.RevenueCatWebView`. 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 + + val currentOrigin = webView.url?.let { runCatching { URL(it).toOriginOrNull() }.getOrNull() } + if (expectedOrigin == null || currentOrigin == null || currentOrigin != expectedOrigin) { + Logger.w("Dropping web view message: current origin does not match the resolved component origin.") + return + } + + val message = WebViewMessageParser.parse(json, expectedComponentId = componentId) ?: return + + // On a request for variables, the SDK first sends its managed variables (and the paywall's + // custom variables), then invokes the app handler so the app may add more under `custom`. + if (message.type == WebViewMessageType.REQUEST_VARIABLES) { + sendVariables( + componentId = componentId, + variables = PaywallWebViewVariablesProvider.sdkManagedVariables( + locale = locale, + customVariables = customVariables, + ), + sanitize = false, + ) + } + + messageHandler?.onMessage(message, this) + } + + override fun postVariables(componentId: String, variables: Map) { + sendVariables(componentId = componentId, variables = variables, sanitize = true) + } + + override fun postMessage(componentId: String, type: String, message: Map) { + val envelope = JSONObject().apply { + put(WebViewMessageField.TYPE, type) + put(WebViewMessageField.COMPONENT_ID, componentId) + message.forEach { (key, value) -> + // Don't let arbitrary fields overwrite the envelope identity fields. + if (key != WebViewMessageField.TYPE && key != WebViewMessageField.COMPONENT_ID) { + put(key, value.toJsonRepresentation()) + } + } + } + deliverToWebView(envelope) + } + + private fun sendVariables( + componentId: String, + variables: Map, + sanitize: Boolean, + ) { + val finalVariables = if (sanitize) { + PaywallWebViewVariablesProvider.sanitizeAppProvidedVariables(variables) + } else { + variables + } + val envelope = JSONObject().apply { + put(WebViewMessageField.TYPE, WebViewMessageType.VARIABLES) + put(WebViewMessageField.COMPONENT_ID, componentId) + put(WebViewMessageField.VARIABLES, finalVariables.toJsonObject()) + } + deliverToWebView(envelope) + } + + private fun deliverToWebView(envelope: JSONObject) { + runOnMainThread { + if (released) return@runOnMainThread + val webView = webViewRef.get() ?: return@runOnMainThread + val payload = envelope.toString().escapeForJavaScript() + webView.evaluateJavascript( + "if (window.$RECEIVE_FUNCTION) { window.$RECEIVE_FUNCTION($payload); }", + null, + ) + } + } + + private fun runOnMainThread(block: () -> Unit) { + if (Looper.myLooper() == Looper.getMainLooper()) { + block() + } else { + mainHandler.post(block) + } + } + + private companion object { + // Private native object name. The public surface (window.RevenueCatWebView) is created by the + // injected shim below; this avoids exposing the raw native interface to web content directly. + const val NATIVE_OBJECT_NAME = "__RevenueCatNativeBridge" + const val PUBLIC_OBJECT_NAME = "RevenueCatWebView" + const val RECEIVE_FUNCTION = "__revenueCatReceiveMessage" + + val BRIDGE_SCRIPT = """ + (function() { + if (window.$PUBLIC_OBJECT_NAME) { return; } + var nativeBridge = window.$NATIVE_OBJECT_NAME; + if (!nativeBridge) { return; } + window.$PUBLIC_OBJECT_NAME = { + postMessage: function(message) { + nativeBridge.postMessage( + typeof message === 'string' ? message : JSON.stringify(message) + ); + } + }; + })(); + """.trimIndent() + + /** + * 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 portSuffix = if (port == -1) "" else ":$port" + return "$protocol://$host$portSuffix" +} 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 000000000..b7d028026 --- /dev/null +++ b/ui/revenuecatui/src/test/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/webview/WebViewJavaScriptBridgeTest.kt @@ -0,0 +1,317 @@ +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.CustomVariableValue +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) }, + customVariables: Map = emptyMap(), + navigateTo: URL = expectedUrl, + ): WebViewJavaScriptBridge { + val bridge = WebViewJavaScriptBridge( + webView = webView, + componentId = componentId, + expectedUrl = expectedUrl, + locale = "en-US", + customVariables = customVariables, + messageHandler = handler, + ) + bridge.attach() + // Robolectric's ShadowWebView reports the last loaded URL via getUrl(). + webView.loadUrl(navigateTo.toString()) + return bridge + } + + private fun idleMainLooper() { + shadowOf(Looper.getMainLooper()).idle() + } + + @Test + fun `delivers valid message using the canonical component id`() { + bridge().postMessage("""{"type":"rc:step-loaded","component_id":"promo_web_view"}""") + idleMainLooper() + + assertThat(received).hasSize(1) + assertThat(received.single().componentId).isEqualTo("promo_web_view") + assertThat(received.single().type).isEqualTo("rc:step-loaded") + } + + @Test + fun `rejects messages for a different component id`() { + bridge().postMessage("""{"type":"rc:step-loaded","component_id":"other_web_view"}""") + idleMainLooper() + + assertThat(received).isEmpty() + } + + @Test + fun `request-variables auto-sends rc variables with locale and color scheme`() { + bridge( + handler = PaywallWebViewMessageHandler { message, _ -> received.add(message) }, + customVariables = mapOf("plan" to CustomVariableValue.String("annual")), + ).postMessage("""{"type":"rc:request-variables","component_id":"promo_web_view"}""") + idleMainLooper() + + // The app handler is still notified... + assertThat(received.single().type).isEqualTo("rc:request-variables") + // ...and the SDK sent rc:variables back into the web view via the receive hook. + val script = shadowWebView.lastEvaluatedJavascript + assertThat(script).contains("window.__revenueCatReceiveMessage(") + assertThat(script).contains("\"rc:variables\"") + assertThat(script).contains("\"component_id\":\"promo_web_view\"") + assertThat(script).contains("\"locale\":\"en-US\"") + assertThat(script).contains("\"plan\":\"annual\"") + } + + @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")), + ), + ), + ) + } + } + bridge(handler = handler) + .postMessage("""{"type":"rc:request-variables","component_id":"promo_web_view"}""") + idleMainLooper() + + // The controller's reply is the most recent script delivered to the web view. + 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 } + bridge(handler = handler) + .postMessage("""{"type":"rc:step-loaded","component_id":"promo_web_view"}""") + 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() + bridge.release() + + bridge.postMessage("""{"type":"rc:step-loaded","component_id":"promo_web_view"}""") + idleMainLooper() + + assertThat(received).isEmpty() + } + + @Test + fun `rejects messages after navigation to an unexpected origin`() { + bridge(navigateTo = URL("https://evil.example.org/phish.html")) + .postMessage("""{"type":"rc:step-loaded","component_id":"promo_web_view"}""") + idleMainLooper() + + assertThat(received).isEmpty() + } + + @Test + fun `allows messages from the same origin on a different path`() { + bridge(navigateTo = URL("https://assets.example.com/promo/step-two.html")) + .postMessage("""{"type":"rc:step-loaded","component_id":"promo_web_view"}""") + idleMainLooper() + + assertThat(received).hasSize(1) + } + + @Test + fun `delivers rc step-complete responses to the handler without sending an outbound message`() { + bridge().postMessage( + """ + { + "type":"rc:step-complete", + "component_id":"promo_web_view", + "responses":{"selected_plan":"annual","accepted_terms":true} + } + """.trimIndent(), + ) + 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)) + // rc:step-complete must not auto-dismiss or send anything back; the app decides. + assertThat(shadowWebView.lastEvaluatedJavascript).isNull() + } + + @Test + fun `delivers rc error to the handler`() { + bridge().postMessage( + """{"type":"rc:error","component_id":"promo_web_view","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`() { + bridge().postMessage("""not even json""") + idleMainLooper() + + assertThat(received).isEmpty() + } + + @Test + fun `auto-sends rc variables even when no handler is set`() { + bridge(handler = null) + .postMessage("""{"type":"rc:request-variables","component_id":"promo_web_view"}""") + 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( + customVariables = mapOf("plan" to CustomVariableValue.String("annual")), + ) + bridge.update( + locale = "fr-FR", + customVariables = mapOf("plan" to CustomVariableValue.String("monthly")), + messageHandler = null, + ) + + bridge.postMessage("""{"type":"rc:request-variables","component_id":"promo_web_view"}""") + idleMainLooper() + + val script = shadowWebView.lastEvaluatedJavascript + assertThat(script).contains("\"locale\":\"fr-FR\"") + assertThat(script).contains("\"plan\":\"monthly\"") + assertThat(script).doesNotContain("annual") + } + + @Test + fun `generic postMessage builds a flat envelope and protects identity fields`() { + bridge().postMessage( + componentId = componentId, + type = "rc:custom", + message = mapOf( + "foo" to PaywallWebViewValue.String("bar"), + // Attempting to override the envelope identity fields must be ignored. + "type" to PaywallWebViewValue.String("evil"), + "component_id" to PaywallWebViewValue.String("other"), + ), + ) + idleMainLooper() + + val script = shadowWebView.lastEvaluatedJavascript + assertThat(script).contains("window.__revenueCatReceiveMessage(") + assertThat(script).contains("\"type\":\"rc:custom\"") + assertThat(script).contains("\"component_id\":\"promo_web_view\"") + assertThat(script).contains("\"foo\":\"bar\"") + assertThat(script).doesNotContain("evil") + assertThat(script).doesNotContain("\"other\"") + } + + @Test + fun `escapes line and paragraph separators in outbound payloads`() { + // U+2028/U+2029 are valid in JSON strings but terminate JS statements; they must be escaped. + val raw = "a\u2028b\u2029c" + 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 a single native interface under the private name`() { + bridge() + + assertThat(shadowWebView.getJavascriptInterface("__RevenueCatNativeBridge")).isNotNull + // The public object name is created by the injected shim, not exposed as a native interface. + assertThat(shadowWebView.getJavascriptInterface("RevenueCatWebView")).isNull() + } + + @Test + fun `injectBridgeScript exposes the public surface without overwriting an existing bridge`() { + bridge().injectBridgeScript() + + val script = shadowWebView.lastEvaluatedJavascript + assertThat(script).contains("window.RevenueCatWebView") + assertThat(script).contains("window.__RevenueCatNativeBridge") + // Guard so an existing bridge is not clobbered. + assertThat(script).contains("if (window.RevenueCatWebView)") + } + + @Test + fun `release removes the native interface`() { + val bridge = bridge() + assertThat(shadowWebView.getJavascriptInterface("__RevenueCatNativeBridge")).isNotNull + + bridge.release() + + assertThat(shadowWebView.getJavascriptInterface("__RevenueCatNativeBridge")).isNull() + } +}