-
Notifications
You must be signed in to change notification settings - Fork 108
feat(paywalls): add web_view message model and parsing (PWENG-98) #3654
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Closed
alexrepty
wants to merge
1
commit into
alexrepty/paywalls-web-view-value-types
from
alexrepty/paywalls-web-view-message-model
Closed
Changes from all commits
Commits
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
94 changes: 94 additions & 0 deletions
94
...uecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/PaywallWebViewMessage.kt
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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, | ||
| ) | ||
| } |
112 changes: 112 additions & 0 deletions
112
...otlin/com/revenuecat/purchases/ui/revenuecatui/components/webview/WebViewMessageParser.kt
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 | ||
| } | ||
|
|
||
| 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 | ||
| } | ||
|
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) | ||
| } | ||
| } | ||
28 changes: 28 additions & 0 deletions
28
.../kotlin/com/revenuecat/purchases/ui/revenuecatui/components/webview/WebViewMessageType.kt
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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" | ||
| } |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.