Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions ui/revenuecatui/api.txt
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,27 @@ package com.revenuecat.purchases.ui.revenuecatui {
method public abstract void performRestoreWithCompletion(com.revenuecat.purchases.CustomerInfo customerInfo, kotlin.jvm.functions.Function1<? super com.revenuecat.purchases.ui.revenuecatui.PurchaseLogicResult,kotlin.Unit> completion);
}

public interface PaywallWebViewController {
method public void postMessage(String componentId, String type, java.util.Map<java.lang.String,? extends com.revenuecat.purchases.ui.revenuecatui.PaywallWebViewValue> variables);
method public void postVariables(String componentId, java.util.Map<java.lang.String,? extends com.revenuecat.purchases.ui.revenuecatui.PaywallWebViewValue> variables);
}

@dev.drewhamilton.poko.Poko public final class PaywallWebViewMessage {
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);
method public String getComponentId();
method public String? getError();
method public java.util.Map<java.lang.String,com.revenuecat.purchases.ui.revenuecatui.PaywallWebViewValue>? getResponses();
method public String getType();
property public final String componentId;
property public final String? error;
property public final java.util.Map<java.lang.String,com.revenuecat.purchases.ui.revenuecatui.PaywallWebViewValue>? 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 {
}

Expand Down
Original file line number Diff line number Diff line change
@@ -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<String, PaywallWebViewValue>? = 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<String, PaywallWebViewValue>,
)

/**
* 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<String, PaywallWebViewValue>,
)
}

/**
* 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,
)
}
Original file line number Diff line number Diff line change
@@ -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
Comment thread
JZDesign marked this conversation as resolved.
}

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
}
Comment thread
cursor[bot] marked this conversation as resolved.

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<String, PaywallWebViewValue>)

@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)
}
}
Original file line number Diff line number Diff line change
@@ -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"
}
Loading