Skip to content

Commit 86b7b79

Browse files
committed
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
1 parent 74610d7 commit 86b7b79

5 files changed

Lines changed: 409 additions & 0 deletions

File tree

ui/revenuecatui/api.txt

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -150,6 +150,27 @@ package com.revenuecat.purchases.ui.revenuecatui {
150150
method public abstract void performRestoreWithCompletion(com.revenuecat.purchases.CustomerInfo customerInfo, kotlin.jvm.functions.Function1<? super com.revenuecat.purchases.ui.revenuecatui.PurchaseLogicResult,kotlin.Unit> completion);
151151
}
152152

153+
public interface PaywallWebViewController {
154+
method public void postMessage(String componentId, String type, java.util.Map<java.lang.String,? extends com.revenuecat.purchases.ui.revenuecatui.PaywallWebViewValue> variables);
155+
method public void postVariables(String componentId, java.util.Map<java.lang.String,? extends com.revenuecat.purchases.ui.revenuecatui.PaywallWebViewValue> variables);
156+
}
157+
158+
@dev.drewhamilton.poko.Poko public final class PaywallWebViewMessage {
159+
ctor public PaywallWebViewMessage(String componentId, String type, optional java.util.Map<java.lang.String,? extends com.revenuecat.purchases.ui.revenuecatui.PaywallWebViewValue>? responses, optional String? error);
160+
method public String getComponentId();
161+
method public String? getError();
162+
method public java.util.Map<java.lang.String,com.revenuecat.purchases.ui.revenuecatui.PaywallWebViewValue>? getResponses();
163+
method public String getType();
164+
property public final String componentId;
165+
property public final String? error;
166+
property public final java.util.Map<java.lang.String,com.revenuecat.purchases.ui.revenuecatui.PaywallWebViewValue>? responses;
167+
property public final String type;
168+
}
169+
170+
public fun interface PaywallWebViewMessageHandler {
171+
method public void onMessage(com.revenuecat.purchases.ui.revenuecatui.PaywallWebViewMessage message, com.revenuecat.purchases.ui.revenuecatui.PaywallWebViewController controller);
172+
}
173+
153174
public abstract class PaywallWebViewValue {
154175
}
155176

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
package com.revenuecat.purchases.ui.revenuecatui
2+
3+
import dev.drewhamilton.poko.Poko
4+
5+
/**
6+
* A validated message received from a Paywalls V2 `web_view` component.
7+
*
8+
* Messages follow the RevenueCat web view postMessage envelope. Known [type]s include:
9+
* - `rc:step-loaded`
10+
* - `rc:step-complete` (carries [responses])
11+
* - `rc:request-variables`
12+
* - `rc:error` (carries [error])
13+
*
14+
* @property componentId The canonical component id (the `web_view.id` from the paywall schema). This
15+
* matches the id API consumers see in the paywall configuration.
16+
* @property type The message type, e.g. `rc:step-complete`.
17+
* @property responses The structured responses for a `rc:step-complete` message, if any.
18+
* @property error The error description for a `rc:error` message, if any.
19+
*/
20+
@Poko
21+
public class PaywallWebViewMessage(
22+
public val componentId: String,
23+
public val type: String,
24+
public val responses: Map<String, PaywallWebViewValue>? = null,
25+
public val error: String? = null,
26+
)
27+
28+
/**
29+
* Lets app code send messages back into a Paywalls V2 `web_view` component, for example in response to
30+
* a `rc:request-variables` message.
31+
*
32+
* Outgoing messages preserve the RevenueCat web view postMessage envelope and are validated before
33+
* being delivered to the web view.
34+
*/
35+
public interface PaywallWebViewController {
36+
37+
/**
38+
* Sends a `rc:variables` message into the web view targeting [componentId]. The SDK already replies
39+
* with SDK-managed variables (such as `locale`); use this to provide additional values, typically
40+
* nested under the `custom` key.
41+
*
42+
* Reserved SDK-managed top-level keys cannot be overwritten; provide app-specific values under
43+
* `custom`.
44+
*/
45+
public fun postVariables(
46+
componentId: String,
47+
variables: Map<String, PaywallWebViewValue>,
48+
)
49+
50+
/**
51+
* Sends a message of the given [type] into the web view targeting [componentId], following the web
52+
* view protocol envelope `{ "type", "component_id", "variables" }`. [variables] is delivered under
53+
* the `variables` key. Use [postVariables] for the common `rc:variables` case.
54+
*/
55+
public fun postMessage(
56+
componentId: String,
57+
type: String,
58+
variables: Map<String, PaywallWebViewValue>,
59+
)
60+
}
61+
62+
/**
63+
* Receives validated messages from Paywalls V2 `web_view` components.
64+
*
65+
* Set this on [PaywallOptions.Builder.setWebViewMessageHandler]. The handler is always invoked on the
66+
* main thread. The app decides what to do with each message: the SDK does not automatically dismiss the
67+
* paywall or trigger a purchase in response to `rc:step-complete`.
68+
*
69+
* ### Usage
70+
* ```kotlin
71+
* PaywallOptions.Builder { /* dismiss */ }
72+
* .setWebViewMessageHandler { message, controller ->
73+
* when (message.type) {
74+
* "rc:request-variables" -> controller.postVariables(
75+
* componentId = message.componentId,
76+
* variables = mapOf(
77+
* "custom" to PaywallWebViewValue.Object(
78+
* mapOf("app_segment" to PaywallWebViewValue.String("high_intent")),
79+
* ),
80+
* ),
81+
* )
82+
* "rc:step-complete" -> { /* read message.responses, navigate, log analytics, etc. */ }
83+
* "rc:error" -> { /* log message.error */ }
84+
* }
85+
* }
86+
* .build()
87+
* ```
88+
*/
89+
public fun interface PaywallWebViewMessageHandler {
90+
public fun onMessage(
91+
message: PaywallWebViewMessage,
92+
controller: PaywallWebViewController,
93+
)
94+
}
Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
@file:JvmSynthetic
2+
3+
package com.revenuecat.purchases.ui.revenuecatui.components.webview
4+
5+
import com.revenuecat.purchases.ui.revenuecatui.PaywallWebViewMessage
6+
import com.revenuecat.purchases.ui.revenuecatui.PaywallWebViewValue
7+
import com.revenuecat.purchases.ui.revenuecatui.helpers.Logger
8+
import org.json.JSONObject
9+
10+
/**
11+
* Parses and validates raw JSON strings received from a `web_view` component into typed
12+
* [PaywallWebViewMessage]s before they reach app code.
13+
*
14+
* Validation rules (see the RevenueCat `web_view` postMessage protocol):
15+
* - The payload must not exceed [MAX_PAYLOAD_BYTES] and must not nest deeper than [MAX_NESTING_DEPTH].
16+
* - The body must be a JSON object with a non-empty string `type` and a string `component_id`.
17+
* - `component_id` must equal the expected component id; otherwise the message is rejected.
18+
* - `rc:step-complete` may carry a `responses` JSON object of JSON-compatible values.
19+
* - `rc:error` must carry a string `error`.
20+
* - `rc:step-loaded` and `rc:request-variables` require no extra fields.
21+
* - Unknown message types are dropped for v1.
22+
*
23+
* Returns `null` for any payload that is too large, malformed, fails validation, targets a different
24+
* component, or has an unknown type.
25+
*/
26+
internal object WebViewMessageParser {
27+
28+
const val MAX_PAYLOAD_BYTES: Int = 65_536
29+
const val MAX_NESTING_DEPTH: Int = 16
30+
31+
@Suppress("ReturnCount")
32+
fun parse(rawJson: String, expectedComponentId: String): PaywallWebViewMessage? {
33+
if (rawJson.toByteArray(Charsets.UTF_8).size > MAX_PAYLOAD_BYTES) {
34+
Logger.w("Dropping web view message: payload exceeds $MAX_PAYLOAD_BYTES bytes.")
35+
return null
36+
}
37+
38+
val json = try {
39+
JSONObject(rawJson)
40+
} catch (@Suppress("SwallowedException") e: org.json.JSONException) {
41+
Logger.w("Dropping web view message: body is not a JSON object.")
42+
return null
43+
}
44+
45+
val type = (json.opt(WebViewMessageField.TYPE) as? String)?.takeIf { it.isNotEmpty() }
46+
if (type == null) {
47+
Logger.w("Dropping web view message: missing or non-string 'type'.")
48+
return null
49+
}
50+
51+
val componentId = json.opt(WebViewMessageField.COMPONENT_ID) as? String
52+
if (componentId == null) {
53+
Logger.w("Dropping web view message: missing or non-string 'component_id'.")
54+
return null
55+
}
56+
if (componentId != expectedComponentId) {
57+
Logger.w("Dropping web view message: 'component_id' does not match the rendered web_view.")
58+
return null
59+
}
60+
61+
return when (type) {
62+
WebViewMessageType.STEP_LOADED,
63+
WebViewMessageType.REQUEST_VARIABLES,
64+
-> PaywallWebViewMessage(componentId = componentId, type = type)
65+
66+
WebViewMessageType.STEP_COMPLETE -> {
67+
val responses = parseResponses(json) ?: return null
68+
PaywallWebViewMessage(componentId = componentId, type = type, responses = responses.value)
69+
}
70+
71+
WebViewMessageType.ERROR -> {
72+
val error = json.opt(WebViewMessageField.ERROR) as? String
73+
if (error == null) {
74+
Logger.w("Dropping web view message: 'rc:error' requires a string 'error'.")
75+
return null
76+
}
77+
PaywallWebViewMessage(componentId = componentId, type = type, error = error)
78+
}
79+
80+
else -> {
81+
// Unknown message types are dropped for protocol_version 1.
82+
Logger.d("Dropping web view message: unknown type '$type'.")
83+
null
84+
}
85+
}
86+
}
87+
88+
/**
89+
* Parses the optional `responses` object of a `rc:step-complete` message. Returns an empty object
90+
* when absent, or `null` when present but malformed (not a JSON object, non-JSON values, or too
91+
* deeply nested), which causes the whole message to be rejected.
92+
*/
93+
private class ParsedResponses(val value: Map<String, PaywallWebViewValue>)
94+
95+
@Suppress("ReturnCount")
96+
private fun parseResponses(json: JSONObject): ParsedResponses? {
97+
if (!json.has(WebViewMessageField.RESPONSES) || json.isNull(WebViewMessageField.RESPONSES)) {
98+
return ParsedResponses(emptyMap())
99+
}
100+
val responsesJson = json.opt(WebViewMessageField.RESPONSES) as? JSONObject
101+
if (responsesJson == null) {
102+
Logger.w("Dropping web view message: 'responses' must be a JSON object.")
103+
return null
104+
}
105+
val converted = PaywallWebViewValue.fromJson(responsesJson, MAX_NESTING_DEPTH)
106+
if (converted !is PaywallWebViewValue.Object) {
107+
Logger.w("Dropping web view message: 'responses' contains non-JSON values or is too deeply nested.")
108+
return null
109+
}
110+
return ParsedResponses(converted.value)
111+
}
112+
}
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
@file:JvmSynthetic
2+
3+
package com.revenuecat.purchases.ui.revenuecatui.components.webview
4+
5+
/**
6+
* Message type identifiers for the RevenueCat `web_view` postMessage protocol (`protocol_version: 1`).
7+
* These mirror the shapes used by the web implementation and must not diverge.
8+
*/
9+
internal object WebViewMessageType {
10+
const val STEP_LOADED = "rc:step-loaded"
11+
const val STEP_COMPLETE = "rc:step-complete"
12+
const val REQUEST_VARIABLES = "rc:request-variables"
13+
const val ERROR = "rc:error"
14+
const val VARIABLES = "rc:variables"
15+
}
16+
17+
/**
18+
* Field names used in the flat message envelope. The envelope is intentionally flat: message-specific
19+
* fields such as [RESPONSES], [ERROR] and [VARIABLES] live at the top level rather than under a
20+
* generic `payload` key.
21+
*/
22+
internal object WebViewMessageField {
23+
const val TYPE = "type"
24+
const val COMPONENT_ID = "component_id"
25+
const val RESPONSES = "responses"
26+
const val ERROR = "error"
27+
const val VARIABLES = "variables"
28+
}

0 commit comments

Comments
 (0)