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
@@ -1,6 +1,5 @@
package com.shopify.checkoutkit

import android.content.Context
import android.webkit.JavascriptInterface
import com.shopify.checkoutkit.ShopifyCheckoutKit.log
import kotlinx.serialization.Serializable
Expand Down Expand Up @@ -29,7 +28,7 @@ internal class EmbeddedCheckoutProtocol(
@Volatile private var client: CheckoutCommunicationClient? = null,
) {
private val decoder = Json { ignoreUnknownKeys = true }
private val defaultClient: CheckoutProtocol.Client = defaultDelegationClient(view.context)
private val defaultClient: CheckoutProtocol.Client = defaultDelegationClient()

internal fun setClient(client: CheckoutCommunicationClient?) {
this.client = client
Expand All @@ -48,9 +47,9 @@ internal class EmbeddedCheckoutProtocol(
// 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 == METHOD_WINDOW_OPEN_REQUEST -> handleWindowOpenRequest(message)
request.method == METHOD_START -> handleStart(message)
request.method == METHOD_COMPLETE -> handleComplete(message)
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)
}
} catch (e: SerializationException) {
Expand Down Expand Up @@ -101,15 +100,15 @@ internal class EmbeddedCheckoutProtocol(
)

private fun handleStart(message: String) {
log.d(LOG_TAG, "Handling $METHOD_START: hiding progress bar and bubbling up.")
log.d(LOG_TAG, "Handling ${CheckoutProtocol.start.method}: hiding progress bar and bubbling up.")
onMainThread {
view.getListener().onCheckoutViewLoadComplete()
client?.process(message)
}
}

private fun handleComplete(message: String) {
log.d(LOG_TAG, "Handling $METHOD_COMPLETE: bubbling up.")
log.d(LOG_TAG, "Handling ${CheckoutProtocol.complete.method}: bubbling up.")
onMainThread {
client?.process(message)
}
Expand All @@ -124,7 +123,7 @@ internal class EmbeddedCheckoutProtocol(
* `Intent.ACTION_VIEW` (see [defaultDelegationClient]).
*/
private fun handleWindowOpenRequest(message: String) {
log.d(LOG_TAG, "Handling $METHOD_WINDOW_OPEN_REQUEST")
log.d(LOG_TAG, "Handling ${CheckoutProtocol.windowOpen.method}")
onMainThread {
val merchantResponse = client?.process(message)
if (merchantResponse != null) {
Expand All @@ -135,12 +134,22 @@ 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`.
*/
private fun handleClientMessage(method: String, message: String) {
log.d(LOG_TAG, "Delegating $method to client.")
onMainThread {
val response = client?.process(message)
log.d(LOG_TAG, " client response: $response")
response?.let { sendRaw(it) }
if (method == CheckoutProtocol.error.method) {
// Unrecoverable ec.error is kit behavior: emit to the client, then run
// the default handler so checkout is dismissed even if the client responded.
defaultClient.process(message)?.let { sendRaw(it) }
}
}
}

Expand Down Expand Up @@ -169,6 +178,37 @@ internal class EmbeddedCheckoutProtocol(
}
}

/**
* Kit-owned client that handles delegations and kit-mandated notifications,
* mirroring Swift's `defaultsClient`. Currently:
* - [CheckoutProtocol.windowOpen] - launches the URI via `Intent.ACTION_VIEW`, or
* returns [WindowOpenResult.Rejected] with `window_open_rejected_error` semantics.
* - [CheckoutProtocol.error] - when any message carries `severity: "unrecoverable"`,
* dismiss the kit via the listener. Per UCP spec, `unrecoverable` means no valid
* resource exists to act on, so consumers don't have to wire dismissal in every
* error handler.
*/
private fun defaultDelegationClient(): CheckoutProtocol.Client =
CheckoutProtocol.Client()
.on(CheckoutProtocol.windowOpen) { request ->
when (val result = ExternalUriLauncher.launch(view.context, request.url)) {
is ExternalUriLauncher.Result.Launched -> WindowOpenResult.Success
is ExternalUriLauncher.Result.Rejected -> {
log.d(LOG_TAG, "window.open rejected for ${request.url}: ${result.reason}")
WindowOpenResult.Rejected(reason = result.reason)
}
}
}
.on(CheckoutProtocol.error) { payload ->
if (payload.messages.none { it.severity == Severity.Unrecoverable }) return@on
log.d(LOG_TAG, "ec.error unrecoverable; dismissing checkout via event processor")
view.getListener().onCheckoutViewFailedWithError(
ClientException(
errorDescription = "Embedded checkout reported unrecoverable error.",
),
)
}

companion object {
private const val LOG_TAG = BaseWebView.ECP_LOG_TAG

Expand All @@ -179,10 +219,6 @@ internal class EmbeddedCheckoutProtocol(
private const val ECP_RESPONSE_GLOBAL = "EmbeddedCheckoutProtocol"

internal const val METHOD_READY = "ec.ready"
internal const val METHOD_START = "ec.start"
internal const val METHOD_COMPLETE = "ec.complete"

private const val METHOD_WINDOW_OPEN_REQUEST = "ec.window.open_request"

// Delegations this SDK supports. Echoed back in the ec.ready response as the
// intersection of checkout-accepted ∩ kit-supported. Must align with the
Expand All @@ -200,24 +236,6 @@ internal class EmbeddedCheckoutProtocol(

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

/**
* The kit's default [CheckoutProtocol.windowOpen] handler.
*
* Mirrors Swift's `defaultsClient`: launches the URI via `Intent.ACTION_VIEW`
* if any activity resolves it, otherwise returns [WindowOpenResult.Rejected]
* with `window_open_rejected_error` semantics.
*/
internal fun defaultDelegationClient(context: Context): CheckoutProtocol.Client =
CheckoutProtocol.Client().on(CheckoutProtocol.windowOpen) { request ->
when (val result = ExternalUriLauncher.launch(context, request.url)) {
is ExternalUriLauncher.Result.Launched -> WindowOpenResult.Success
is ExternalUriLauncher.Result.Rejected -> {
log.d(LOG_TAG, "window.open rejected for ${request.url}: ${result.reason}")
WindowOpenResult.Rejected(reason = result.reason)
}
}
}
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,7 @@ public object ShopifyCheckoutKit {
* @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][EmbeddedCheckoutProtocol.METHOD_START]) are handled automatically by the SDK.
* [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 @@ -7,11 +7,13 @@ import android.net.Uri
import android.os.Looper
import androidx.activity.ComponentActivity
import org.assertj.core.api.Assertions.assertThat
import org.junit.Assert.fail
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.mockito.Mockito
import org.mockito.kotlin.any
import org.mockito.kotlin.argThat
import org.mockito.kotlin.argumentCaptor
import org.mockito.kotlin.isNull
import org.mockito.kotlin.mock
Expand Down Expand Up @@ -360,20 +362,121 @@ class EmbeddedCheckoutProtocolTest {

// endregion

// region delegated notifications
// region ec.error — severity-driven dismissal

@Test
fun `ec error is forwarded to client regardless of severity`() {
val rawMessage = ecErrorMessage(severity = "recoverable")
val client = mock<CheckoutCommunicationClient>()
ecp.setClient(client)

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

verify(client).process(rawMessage)
}

@Test
fun `ec error with unrecoverable severity dismisses via listener`() {
val rawMessage = ecErrorMessage(severity = "unrecoverable")
val client = mock<CheckoutCommunicationClient>()
ecp.setClient(client)

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

verify(client).process(rawMessage)
val captor = argumentCaptor<CheckoutException>()
verify(mockListener).onCheckoutViewFailedWithError(captor.capture())
assertThat(captor.firstValue).isInstanceOf(ClientException::class.java)
assertThat(captor.firstValue.errorDescription)
.isEqualTo("Embedded checkout reported unrecoverable error.")
}

@Test
fun `ec error is delegated to client`() {
val rawMessage = """{"jsonrpc":"2.0","method":"ec.error","params":{"error":{"code":-1,"message":"fail"}}}"""
fun `ec error with unrecoverable severity dismisses even when client returns response`() {
val rawMessage = ecErrorMessage(severity = "unrecoverable")
val client = mock<CheckoutCommunicationClient>()
whenever(client.process(rawMessage)).thenReturn("""{"jsonrpc":"2.0","id":null,"result":{}}""")
ecp.setClient(client)

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

verify(client).process(rawMessage)
verify(mockListener).onCheckoutViewFailedWithError(
argThat { this is ClientException },
)
}

@Test
fun `ec error with recoverable severity does not dismiss`() {
val rawMessage = ecErrorMessage(severity = "recoverable")
ecp.setClient(mock())

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

verify(mockListener, never()).onCheckoutViewFailedWithError(any())
}

@Test
fun `ec error with requires_buyer_input severity does not dismiss`() {
val rawMessage = ecErrorMessage(severity = "requires_buyer_input")
ecp.setClient(mock())

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

verify(mockListener, never()).onCheckoutViewFailedWithError(any())
}

@Test
fun `ec error with requires_buyer_review severity does not dismiss`() {
val rawMessage = ecErrorMessage(severity = "requires_buyer_review")
ecp.setClient(mock())

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

verify(mockListener, never()).onCheckoutViewFailedWithError(any())
}

@Test
fun `ec error dismisses when any message has unrecoverable severity`() {
val messages = """[
|{"type":"error","code":"a","content":"x","severity":"recoverable"},
|{"type":"error","code":"b","content":"y","severity":"unrecoverable"}
|]
""".trimMargin()
val rawMessage = ecErrorMessageWithMessages(messages)
ecp.setClient(mock())

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

verify(mockListener).onCheckoutViewFailedWithError(
argThat { this is ClientException },
)
}

@Test
fun `ec error without required messages field is ignored by typed handler`() {
val rawMessage = """{"jsonrpc":"2.0","method":"ec.error","params":{"error":{$ERROR_RESPONSE_UCP}}}"""
val client = CheckoutProtocol.Client()
.on(CheckoutProtocol.error) { fail("Malformed ec.error should not dispatch") }
ecp.setClient(client)

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

verify(mockListener, never()).onCheckoutViewFailedWithError(any())
}

// endregion

// region delegated notifications

@Test
fun `ec complete is delegated to client`() {
val rawMessage = """{"jsonrpc":"2.0","method":"ec.complete","params":{"checkout":{}}}"""
Expand Down Expand Up @@ -491,6 +594,21 @@ 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 ecErrorMessage(severity: String): String {
val messages =
"""[{"type":"error","code":"session_failed","content":"Session failed","severity":"$severity"}]"""
return ecErrorMessageWithMessages(messages)
}

private fun ecErrorMessageWithMessages(messages: String): String {
val error = """{$ERROR_RESPONSE_UCP,"messages":$messages}"""
return """{"jsonrpc":"2.0","method":"ec.error","params":{"error":$error}}"""
}

private companion object {
private const val ERROR_RESPONSE_UCP = """"ucp":{"version":"2026-04-08","status":"error"}"""
}

/**
* Runs [block], drains the main-thread queue, captures the first JS string
* passed to [CheckoutWebView.evaluateJavascript].
Expand Down
54 changes: 41 additions & 13 deletions platforms/swift/Sources/ShopifyCheckoutKit/CheckoutWebView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,31 @@ class CheckoutWebView: WKWebView {

var client: (any CheckoutCommunicationProtocol)?

/// Kit-owned client that handles delegations and kit-mandated notifications. Currently:
/// - `window.open` - falls back to `UIApplication.shared.open(...)` after a
/// `canOpenURL` check (consumers may still override via their own client).
/// - `ec.error` - when the payload carries `severity: "unrecoverable"`, dismiss
/// the kit via `viewDelegate`. Per UCP spec, `unrecoverable` means no valid
/// resource exists to act on, so consumers don't have to wire dismissal in
/// every error handler.
lazy var defaultsClient: CheckoutProtocol.Client = .init()
.on(CheckoutProtocol.windowOpen) { request in
guard UIApplication.shared.canOpenURL(request.url) else {
return .rejected(reason: "canOpenURL returned false")
}
UIApplication.shared.open(request.url)
return .success
}
.on(CheckoutProtocol.error) { [weak self] payload in
guard payload.messages.contains(where: { $0.severity == .unrecoverable }) else { return }
self?.viewDelegate?.checkoutViewDidFailWithError(
error: .checkoutUnavailable(
message: "Embedded checkout reported unrecoverable error.",
code: .clientError(code: .unknown)
)
)
}

static func `for`(checkout url: URL, entryPoint: MetaData.EntryPoint? = nil) -> CheckoutWebView {
OSLogger.shared.debug("Creating webview for URL: \(url.absoluteString)")
return CheckoutWebView(entryPoint: entryPoint)
Expand Down Expand Up @@ -133,28 +158,31 @@ extension CheckoutWebView: WKScriptMessageHandler {
}

Task {
if let response = await client?.process(body) {
let isErrorNotification = CheckoutWebView.message(body, hasMethod: CheckoutProtocol.error.method)
let clientResponse = await client?.process(body)
if let response = clientResponse {
checkoutBridge.sendResponse(self, messageBody: response)
return
}

if let response = await CheckoutWebView.defaultsClient.process(body) {
// Defaults are fallback handlers except for ec.error: unrecoverable errors
// must still dismiss checkout after being emitted to the client.
if isErrorNotification || clientResponse == nil,
let response = await defaultsClient.process(body)
{
checkoutBridge.sendResponse(self, messageBody: response)
}
}
}

/// Kit-owned client that handles delegations the consumer did not register.
/// Today the only default is `window.open`, which falls back to
/// `UIApplication.shared.open(...)` after a `canOpenURL` check.
static let defaultsClient = CheckoutProtocol.Client()
.on(CheckoutProtocol.windowOpen) { request in
guard UIApplication.shared.canOpenURL(request.url) else {
return .rejected(reason: "canOpenURL returned false")
}
UIApplication.shared.open(request.url)
return .success
private static func message(_ body: String, hasMethod method: String) -> Bool {
guard
let object = try? JSONSerialization.jsonObject(with: Data(body.utf8)) as? [String: Any],
object["method"] as? String == method
else {
return false
}
return true
}
}

extension CheckoutWebView: WKNavigationDelegate {
Expand Down
Loading
Loading