Skip to content
Merged
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
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,10 @@ public interface CheckoutCommunicationClient {
/**
* Process a JSON-RPC 2.0 ECP message from the checkout web page.
*
* Called for EC notifications (ec.start, ec.error, ec.complete, ec.*.change),
* merchant-overridable delegations such as `ec.window.open_request`, and any
* unknown methods the kit doesn't handle natively. For requests, return a JSON-RPC
* 2.0 response string; for notifications, return null (no response is sent).
* Called for supported EC notifications (ec.start, ec.error, ec.complete,
* ec.*.change) and merchant-overridable delegations such as
* `ec.window.open_request`. For requests, return a JSON-RPC 2.0 response string;
* for notifications, return null (no response is sent).
*
* @param message JSON-RPC 2.0 encoded message string
* @return JSON-RPC 2.0 encoded response string, or null to send no response
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ import java.util.concurrent.CountDownLatch
public object CheckoutProtocol {

public const val SPEC_VERSION: String = "2026-04-08"
internal const val READY_METHOD: String = "ec.ready"

// Notifications — checkout carries the full current state
public val start: NotificationDescriptor<Checkout> = checkoutDescriptor("ec.start")
Expand Down Expand Up @@ -74,6 +75,31 @@ public object CheckoutProtocol {
encode = { result -> encodeWindowOpenResult(result) },
)

internal val supportedProtocolMethods: Set<String> = setOf(
READY_METHOD,
start.method,
complete.method,
error.method,
lineItemsChange.method,
messagesChange.method,
totalsChange.method,
windowOpen.method,
)

internal fun supportedProtocolMethod(message: String): String? =
decodeProtocolRequest(message)?.let(::supportedProtocolMethod)

internal fun supportedProtocolMethod(request: EcpRequest): String? =
request.method.takeIf {
request.jsonrpc == "2.0" && request.method in supportedProtocolMethods
}

private fun decodeProtocolRequest(message: String): EcpRequest? = try {
json.decodeFromString<EcpRequest>(message)
} catch (_: SerializationException) {
null
}

private fun checkoutDescriptor(method: String): NotificationDescriptor<Checkout> =
NotificationDescriptor(
method = method,
Expand Down Expand Up @@ -292,6 +318,14 @@ public class DelegationDescriptor<P : Any, R : Any> internal constructor(
internal val encode: (R) -> JsonElement,
)

@Serializable
internal data class EcpRequest(
val jsonrpc: String = "2.0",
val method: String,
val id: JsonElement? = null,
val params: JsonElement? = null,
)

/** Payload delivered with the [CheckoutProtocol.windowOpen] delegation. */
@ConsistentCopyVisibility
public data class WindowOpenRequest internal constructor(public val url: Uri)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@ package com.shopify.checkoutkit

import android.webkit.JavascriptInterface
import com.shopify.checkoutkit.ShopifyCheckoutKit.log
import kotlinx.serialization.Serializable
import kotlinx.serialization.SerializationException
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonArray
Expand Down Expand Up @@ -57,19 +56,15 @@ internal class EmbeddedCheckoutProtocol(
fun postMessage(message: String) {
try {
val request = decoder.decodeFromString<EcpRequest>(message)
val method = CheckoutProtocol.supportedProtocolMethod(request)
log.d(LOG_TAG, "Received bridge message: method=${request.method} id=${request.id}")
when {
request.method == METHOD_READY -> handleReady(request)
// Respond with explicit "not supported" so web-side promises don't hang
request.method in UNSUPPORTED_METHODS ->
sendError(request.id, CODE_METHOD_NOT_SUPPORTED, "Method not supported by this SDK")
// ep.cart.* is out of scope for the checkout bridge
request.method.startsWith("ep.") ->
log.d(LOG_TAG, "Ignoring out-of-scope ep method: ${request.method}.")
request.method == CheckoutProtocol.windowOpen.method -> handleWindowOpenRequest(message)
request.method == CheckoutProtocol.start.method -> handleStart(message)
request.method == CheckoutProtocol.complete.method -> handleComplete(message)
else -> handleClientMessage(request.method, message)
when (method) {
CheckoutProtocol.READY_METHOD -> handleReady(request)
CheckoutProtocol.windowOpen.method -> handleWindowOpenRequest(message)
CheckoutProtocol.start.method -> handleStart(message)
CheckoutProtocol.complete.method -> handleComplete(message)
null -> log.d(LOG_TAG, "Ignoring unsupported ECP method: ${request.method}.")
else -> handleClientMessage(method, message)
}
} catch (e: SerializationException) {
log.d(LOG_TAG, "Failed to decode ECP message: $e raw=$message")
Expand All @@ -82,7 +77,7 @@ internal class EmbeddedCheckoutProtocol(
val negotiatedDelegations = checkoutAcceptedDelegations.filter { it in KIT_SUPPORTED_DELEGATIONS }
log.d(
LOG_TAG,
"Handling $METHOD_READY, " +
"Handling ${CheckoutProtocol.READY_METHOD}, " +
"isPreload=${view.isPreloadRequest} " +
"checkoutAcceptedDelegations=$checkoutAcceptedDelegations " +
"checkoutKitSupportedDelegations=$KIT_SUPPORTED_DELEGATIONS " +
Expand All @@ -93,12 +88,13 @@ internal class EmbeddedCheckoutProtocol(

private fun checkoutAcceptedDelegations(params: JsonElement?): List<String> = when (params) {
null -> emptyList()
!is JsonObject -> throw SerializationException("$METHOD_READY params must be an object")
!is JsonObject -> throw SerializationException("${CheckoutProtocol.READY_METHOD} params must be an object")
else -> params["delegate"]?.let(::delegationStrings) ?: emptyList()
}

private fun delegationStrings(delegate: JsonElement): List<String> {
val delegateArray = delegate as? JsonArray ?: throw SerializationException("$METHOD_READY delegate must be an array")
val delegateArray = delegate as? JsonArray
?: throw SerializationException("${CheckoutProtocol.READY_METHOD} delegate must be an array")
return delegateArray.mapNotNull(::delegationStringOrNull)
}

Expand Down Expand Up @@ -150,9 +146,10 @@ internal class EmbeddedCheckoutProtocol(
}

/**
* Dispatch a message through the consumer client. `ec.error` also runs through the
* kit-owned [defaultClient] regardless of the consumer response so unrecoverable
* session errors always close checkout while still reaching `CheckoutProtocol.error`.
* Dispatch a supported protocol message through the consumer client. `ec.error` also
* runs through the kit-owned [defaultClient] regardless of the consumer response so
* unrecoverable session errors always close checkout while still reaching
* `CheckoutProtocol.error`.
*/
private fun handleClientMessage(method: String, message: String) {
log.d(LOG_TAG, "Delegating $method to client.")
Expand Down Expand Up @@ -233,31 +230,11 @@ internal class EmbeddedCheckoutProtocol(
/** Global JS object the checkout uses to receive responses. */
private const val ECP_RESPONSE_GLOBAL = "EmbeddedCheckoutProtocol"

internal const val METHOD_READY = "ec.ready"

// Delegations this SDK supports. Echoed back in the ec.ready response as the
// intersection of checkout-accepted ∩ kit-supported. Must align with the
// `ec_delegate` URL param emitted from [UriExtensions.appendEcpParams].
private val KIT_SUPPORTED_DELEGATIONS = setOf("window.open")

// Requests the SDK explicitly does not support — send a protocol-level error so the
// web-side promise resolves rather than hanging indefinitely.
private val UNSUPPORTED_METHODS = setOf(
"ec.auth",
"ec.payment.instruments_change_request",
"ec.payment.credential_request",
"ec.fulfillment.address_change_request",
)

private const val CODE_PARSE_ERROR = -32700
private const val CODE_METHOD_NOT_SUPPORTED = -32601
}
}

@Serializable
internal data class EcpRequest(
val jsonrpc: String = "2.0",
val method: String,
val id: JsonElement? = null,
val params: JsonElement? = null,
)
Original file line number Diff line number Diff line change
Expand Up @@ -117,10 +117,10 @@ public object ShopifyCheckoutKit {
* @param context The context the checkout is being presented from
* @param checkoutListener provides callbacks to allow clients to listen for and respond to checkout lifecycle events
* (failure, cancellation, permission prompts, file chooser).
* @param communicationClient optional handler for Embedded Checkout Protocol (ECP) messages.
* Implement [CheckoutCommunicationClient] to intercept arbitrary ECP messages from the checkout
* web page. Built-in messages ([ec.ready][EmbeddedCheckoutProtocol.METHOD_READY] and
* [ec.start][CheckoutProtocol.start]) are handled automatically by the SDK.
* @param communicationClient optional handler for supported Embedded Checkout Protocol (ECP)
* messages from the checkout web page. Built-in messages
* (`ec.ready` and [ec.start][CheckoutProtocol.start])
* are handled automatically by the SDK.
* @return An instance of [CheckoutKitDialog] if the dialog was successfully created and displayed.
*/
@JvmOverloads
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,7 @@ class CheckoutPresentationTest {

@Test
fun `present builder forwards connected client to embedded checkout protocol`() {
val rawMessage = """{"jsonrpc":"2.0","method":"customMethod","id":"1"}"""
val rawMessage = """{"jsonrpc":"2.0","method":"ec.messages.change","params":{"checkout":{}}}"""
val client = mock<CheckoutCommunicationClient>()
whenever(client.process(rawMessage)).thenReturn(null)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,47 @@ class CheckoutProtocolTest {

// endregion

// region supported protocol methods

@Test
fun `supported protocol methods include ready notifications and delegations`() {
assertThat(CheckoutProtocol.supportedProtocolMethods).containsExactlyInAnyOrder(
CheckoutProtocol.READY_METHOD,
CheckoutProtocol.start.method,
CheckoutProtocol.complete.method,
CheckoutProtocol.error.method,
CheckoutProtocol.lineItemsChange.method,
CheckoutProtocol.messagesChange.method,
CheckoutProtocol.totalsChange.method,
CheckoutProtocol.windowOpen.method,
)
}

@Test
fun `supported protocol methods exclude internal or unsupported methods`() {
assertThat(CheckoutProtocol.supportedProtocolMethods).doesNotContain(
CheckoutProtocol.buyerChange.method,
"ec.payment.credential_request",
"ep.cart.ready",
)
}

@Test
fun `supported protocol method parses valid supported message`() {
val message = """{"jsonrpc":"2.0","method":"ec.start","params":{"checkout":{}}}"""

assertThat(CheckoutProtocol.supportedProtocolMethod(message)).isEqualTo(CheckoutProtocol.start.method)
}

@Test
fun `supported protocol method rejects unsupported or invalid message`() {
assertThat(CheckoutProtocol.supportedProtocolMethod("""{"jsonrpc":"2.0","method":"custom"}""")).isNull()
assertThat(CheckoutProtocol.supportedProtocolMethod("""{"jsonrpc":"1.0","method":"ec.start"}""")).isNull()
assertThat(CheckoutProtocol.supportedProtocolMethod("not json")).isNull()
}

// endregion

// region process — notification dispatch

@Test
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -150,68 +150,42 @@ class EmbeddedCheckoutProtocolTest {

// endregion

// region unsupported methods — explicit error response
// region unsupported methods — silently ignored

@Test
fun `ec auth sends method not supported error`() {
val js = captureEvaluatedJs {
ecp.postMessage("""{"jsonrpc":"2.0","method":"ec.auth","id":"1","params":{"type":"oauth"}}""")
}
assertThat(js).contains("\"error\"")
assertThat(js).contains("-32601")
fun `ec auth is silently ignored and not delegated to client`() {

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

bringing this back in PR 3, but across all platforms

assertIgnoredByBridge("""{"jsonrpc":"2.0","method":"ec.auth","id":"1","params":{"type":"oauth"}}""")
}

@Test
fun `ec auth does not invoke client`() {
val client = mock<CheckoutCommunicationClient>()
ecp.setClient(client)
ecp.postMessage("""{"jsonrpc":"2.0","method":"ec.auth","id":"1","params":{"type":"oauth"}}""")
verify(client, never()).process(any())
fun `ec payment instruments change request is silently ignored and not delegated to client`() {
assertIgnoredByBridge(
"""{"jsonrpc":"2.0","method":"ec.payment.instruments_change_request","id":"2","params":{}}"""
)
}

@Test
fun `ec payment instruments change request sends method not supported error`() {
val js = captureEvaluatedJs {
ecp.postMessage(
"""{"jsonrpc":"2.0","method":"ec.payment.instruments_change_request","id":"2","params":{}}"""
)
}
assertThat(js).contains("\"error\"")
assertThat(js).contains("-32601")
fun `ec payment credential request is silently ignored and not delegated to client`() {
assertIgnoredByBridge(
"""{"jsonrpc":"2.0","method":"ec.payment.credential_request","id":"3","params":{}}"""
)
}

@Test
fun `ec payment credential request sends method not supported error`() {
val js = captureEvaluatedJs {
ecp.postMessage(
"""{"jsonrpc":"2.0","method":"ec.payment.credential_request","id":"3","params":{}}"""
)
}
assertThat(js).contains("\"error\"")
assertThat(js).contains("-32601")
fun `ec fulfillment address change request is silently ignored and not delegated to client`() {
assertIgnoredByBridge(
"""{"jsonrpc":"2.0","method":"ec.fulfillment.address_change_request","id":"4","params":{}}"""
)
}

@Test
fun `ec fulfillment address change request sends method not supported error`() {
val js = captureEvaluatedJs {
ecp.postMessage(
"""{"jsonrpc":"2.0","method":"ec.fulfillment.address_change_request","id":"4","params":{}}"""
)
}
assertThat(js).contains("\"error\"")
assertThat(js).contains("-32601")
fun `ec buyer change is silently ignored and not delegated to client`() {
assertIgnoredByBridge("""{"jsonrpc":"2.0","method":"ec.buyer.change","params":{"checkout":{}}}""")
}

@Test
fun `ep cart methods are silently ignored and not delegated to client`() {
val client = mock<CheckoutCommunicationClient>()
ecp.setClient(client)

ecp.postMessage("""{"jsonrpc":"2.0","method":"ep.cart.ready","id":"5","params":{}}""")
shadowOf(Looper.getMainLooper()).runToEndOfTasks()

verify(viewSpy, never()).evaluateJavascript(any(), any())
verify(client, never()).process(any())
fun `ep cart methods fall through as unsupported and are not delegated to client`() {
assertIgnoredByBridge("""{"jsonrpc":"2.0","method":"ep.cart.ready","id":"5","params":{}}""")
}

// endregion
Expand Down Expand Up @@ -522,21 +496,14 @@ class EmbeddedCheckoutProtocolTest {
// region client delegation — requests

@Test
fun `unknown method is delegated to client`() {
fun `unknown method is silently ignored and not delegated to client`() {
val rawMessage = """{"jsonrpc":"2.0","method":"customMethod","id":"8","params":{}}"""
val client = mock<CheckoutCommunicationClient>()
whenever(client.process(rawMessage)).thenReturn(null)
ecp.setClient(client)

ecp.postMessage(rawMessage)
shadowOf(Looper.getMainLooper()).runToEndOfTasks()

verify(client).process(rawMessage)
assertIgnoredByBridge(rawMessage)
}

@Test
fun `non-null client response is sent back to checkout`() {
val rawMessage = """{"jsonrpc":"2.0","method":"customMethod","id":"9"}"""
fun `non-null client response for supported request is sent back to checkout`() {
val rawMessage = windowOpenRequest(id = "\"9\"", url = "https://example.com")
val clientResponse = """{"jsonrpc":"2.0","id":"9","result":{"data":"ok"}}"""
val client = mock<CheckoutCommunicationClient>()
whenever(client.process(rawMessage)).thenReturn(clientResponse)
Expand All @@ -550,8 +517,8 @@ class EmbeddedCheckoutProtocolTest {
}

@Test
fun `null client response sends nothing to checkout`() {
val rawMessage = """{"jsonrpc":"2.0","method":"customMethod","id":"10"}"""
fun `null client response for supported notification sends nothing to checkout`() {
val rawMessage = """{"jsonrpc":"2.0","method":"ec.messages.change","params":{"checkout":{}}}"""
val client = mock<CheckoutCommunicationClient>()
whenever(client.process(rawMessage)).thenReturn(null)
ecp.setClient(client)
Expand Down Expand Up @@ -622,6 +589,17 @@ class EmbeddedCheckoutProtocolTest {
private fun windowOpenRequest(id: String, url: String): String =
"""{"jsonrpc":"2.0","method":"ec.window.open_request","id":$id,"params":{"url":"$url"}}"""

private fun assertIgnoredByBridge(rawMessage: String) {
val client = mock<CheckoutCommunicationClient>()
ecp.setClient(client)

ecp.postMessage(rawMessage)
shadowOf(Looper.getMainLooper()).runToEndOfTasks()

verify(viewSpy, never()).evaluateJavascript(any(), any())
verify(client, never()).process(any())
}

private fun ecErrorMessage(severity: String): String {
val messages =
"""[{"type":"error","code":"session_failed","content":"Session failed","severity":"$severity"}]"""
Expand Down
Loading
Loading