diff --git a/ui/revenuecatui/api.txt b/ui/revenuecatui/api.txt index a78cd9ec64..68b18aa318 100644 --- a/ui/revenuecatui/api.txt +++ b/ui/revenuecatui/api.txt @@ -150,6 +150,27 @@ 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 { } 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/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..e7d69e5a7d --- /dev/null +++ b/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/webview/WebViewMessageParser.kt @@ -0,0 +1,112 @@ +@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. + * + * Validation rules (see the RevenueCat `web_view` postMessage protocol): + * - The payload must not exceed [MAX_PAYLOAD_BYTES] and must not nest deeper than [MAX_NESTING_DEPTH]. + * - The body must be a JSON object with a non-empty string `type` and a string `component_id`. + * - `component_id` must equal the expected component id; otherwise the message is rejected. + * - `rc:step-complete` may carry a `responses` JSON object of JSON-compatible values. + * - `rc:error` must carry a string `error`. + * - `rc:step-loaded` and `rc:request-variables` require no extra fields. + * - Unknown message types are dropped for v1. + * + * Returns `null` for any payload that is too large, malformed, fails validation, targets a different + * component, or has an unknown type. + */ +internal object WebViewMessageParser { + + const val MAX_PAYLOAD_BYTES: Int = 65_536 + const val MAX_NESTING_DEPTH: Int = 16 + + @Suppress("ReturnCount") + fun parse(rawJson: String, expectedComponentId: String): PaywallWebViewMessage? { + 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 json = try { + JSONObject(rawJson) + } catch (@Suppress("SwallowedException") e: org.json.JSONException) { + Logger.w("Dropping web view message: body is not a JSON object.") + return null + } + + val type = (json.opt(WebViewMessageField.TYPE) as? String)?.takeIf { it.isNotEmpty() } + if (type == null) { + Logger.w("Dropping web view message: missing or non-string 'type'.") + return null + } + + val componentId = json.opt(WebViewMessageField.COMPONENT_ID) as? String + if (componentId == null) { + Logger.w("Dropping web view message: missing or non-string 'component_id'.") + return null + } + if (componentId != expectedComponentId) { + Logger.w("Dropping web view message: 'component_id' does not match the rendered web_view.") + return null + } + + return when (type) { + WebViewMessageType.STEP_LOADED, + WebViewMessageType.REQUEST_VARIABLES, + -> PaywallWebViewMessage(componentId = componentId, type = type) + + WebViewMessageType.STEP_COMPLETE -> { + val responses = parseResponses(json) ?: return null + PaywallWebViewMessage(componentId = componentId, type = type, responses = responses.value) + } + + WebViewMessageType.ERROR -> { + val error = json.opt(WebViewMessageField.ERROR) as? String + if (error == null) { + Logger.w("Dropping web view message: 'rc:error' requires a string 'error'.") + return null + } + PaywallWebViewMessage(componentId = componentId, type = type, error = error) + } + + else -> { + // Unknown message types are dropped for protocol_version 1. + Logger.d("Dropping web view message: unknown type '$type'.") + null + } + } + } + + /** + * Parses the optional `responses` object of a `rc:step-complete` message. Returns an empty object + * when absent, or `null` when present but malformed (not a JSON object, non-JSON values, or too + * deeply nested), which causes the whole message to be rejected. + */ + private class ParsedResponses(val value: Map) + + @Suppress("ReturnCount") + private fun parseResponses(json: JSONObject): ParsedResponses? { + if (!json.has(WebViewMessageField.RESPONSES) || json.isNull(WebViewMessageField.RESPONSES)) { + return ParsedResponses(emptyMap()) + } + val responsesJson = json.opt(WebViewMessageField.RESPONSES) as? JSONObject + 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) + } +} 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..7df881e96a --- /dev/null +++ b/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/webview/WebViewMessageType.kt @@ -0,0 +1,28 @@ +@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" +} + +/** + * 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 TYPE = "type" + const val COMPONENT_ID = "component_id" + const val RESPONSES = "responses" + const val ERROR = "error" + const val VARIABLES = "variables" +} 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..ee36066409 --- /dev/null +++ b/ui/revenuecatui/src/test/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/webview/WebViewMessageParserTest.kt @@ -0,0 +1,154 @@ +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 message = parse("""{"type":"rc:step-loaded","component_id":"promo_web_view"}""") + + assertThat(message).isNotNull + 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`() { + val message = parse( + """ + { + "type":"rc:step-complete", + "component_id":"promo_web_view", + "responses":{"selected_plan":"annual","accepted_terms":true,"count":3} + } + """.trimIndent(), + ) + + assertThat(message).isNotNull + val responses = 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 message = parse("""{"type":"rc:step-complete","component_id":"promo_web_view"}""") + + assertThat(message).isNotNull + assertThat(message!!.responses).isEmpty() + } + + @Test + fun `parses rc request-variables`() { + val message = parse("""{"type":"rc:request-variables","component_id":"promo_web_view"}""") + + assertThat(message).isNotNull + assertThat(message!!.type).isEqualTo("rc:request-variables") + } + + @Test + fun `parses rc error`() { + val message = parse("""{"type":"rc:error","component_id":"promo_web_view","error":"Boom"}""") + + assertThat(message).isNotNull + assertThat(message!!.error).isEqualTo("Boom") + } + + @Test + fun `rejects message without type`() { + assertThat(parse("""{"component_id":"promo_web_view"}""")).isNull() + } + + @Test + fun `rejects message with non-string type`() { + assertThat(parse("""{"type":123,"component_id":"promo_web_view"}""")).isNull() + } + + @Test + fun `rejects message without component_id`() { + assertThat(parse("""{"type":"rc:step-loaded"}""")).isNull() + } + + @Test + fun `rejects message with wrong component_id`() { + assertThat(parse("""{"type":"rc:step-loaded","component_id":"other_web_view"}""")).isNull() + } + + @Test + fun `rejects rc step-complete with non-object responses`() { + assertThat( + parse("""{"type":"rc:step-complete","component_id":"promo_web_view","responses":"nope"}"""), + ).isNull() + } + + @Test + fun `rejects rc error without error string`() { + assertThat(parse("""{"type":"rc:error","component_id":"promo_web_view"}""")).isNull() + } + + @Test + fun `drops unknown message types`() { + assertThat(parse("""{"type":"rc:something-new","component_id":"promo_web_view"}""")).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 = """{"type":"rc:error","component_id":"promo_web_view","error":"$hugeValue"}""" + assertThat(parse(raw)).isNull() + } + + @Test + fun `rejects excessively nested responses`() { + // Build responses nested deeper than MAX_NESTING_DEPTH. + val depth = WebViewMessageParser.MAX_NESTING_DEPTH + 2 + val opening = "{\"a\":".repeat(depth) + val closing = "}".repeat(depth) + val nested = opening + "1" + closing + val raw = """{"type":"rc:step-complete","component_id":"promo_web_view","responses":$nested}""" + assertThat(parse(raw)).isNull() + } + + @Test + fun `accepts null and nested json-compatible values in responses`() { + val message = parse( + """ + { + "type":"rc:step-complete", + "component_id":"promo_web_view", + "responses":{"maybe":null,"list":[1,"two",false],"obj":{"k":"v"}} + } + """.trimIndent(), + ) + + assertThat(message).isNotNull + val responses = message!!.responses!! + assertThat(responses["maybe"]).isEqualTo(PaywallWebViewValue.Null) + assertThat(responses["list"]).isInstanceOf(PaywallWebViewValue.Array::class.java) + assertThat(responses["obj"]).isInstanceOf(PaywallWebViewValue.Object::class.java) + } + + private fun parse(raw: String) = WebViewMessageParser.parse(raw, expectedComponentId = componentId) +}