Skip to content

Commit 43a0cec

Browse files
authored
Dismiss on unrecoverable (#153)
1 parent 4bb6ba1 commit 43a0cec

7 files changed

Lines changed: 392 additions & 57 deletions

File tree

platforms/android/lib/src/main/java/com/shopify/checkoutkit/EmbeddedCheckoutProtocol.kt

Lines changed: 48 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
package com.shopify.checkoutkit
22

3-
import android.content.Context
43
import android.webkit.JavascriptInterface
54
import com.shopify.checkoutkit.ShopifyCheckoutKit.log
65
import kotlinx.serialization.Serializable
@@ -29,7 +28,7 @@ internal class EmbeddedCheckoutProtocol(
2928
@Volatile private var client: CheckoutCommunicationClient? = null,
3029
) {
3130
private val decoder = Json { ignoreUnknownKeys = true }
32-
private val defaultClient: CheckoutProtocol.Client = defaultDelegationClient(view.context)
31+
private val defaultClient: CheckoutProtocol.Client = defaultDelegationClient()
3332

3433
internal fun setClient(client: CheckoutCommunicationClient?) {
3534
this.client = client
@@ -48,9 +47,9 @@ internal class EmbeddedCheckoutProtocol(
4847
// ep.cart.* is out of scope for the checkout bridge
4948
request.method.startsWith("ep.") ->
5049
log.d(LOG_TAG, "Ignoring out-of-scope ep method: ${request.method}.")
51-
request.method == METHOD_WINDOW_OPEN_REQUEST -> handleWindowOpenRequest(message)
52-
request.method == METHOD_START -> handleStart(message)
53-
request.method == METHOD_COMPLETE -> handleComplete(message)
50+
request.method == CheckoutProtocol.windowOpen.method -> handleWindowOpenRequest(message)
51+
request.method == CheckoutProtocol.start.method -> handleStart(message)
52+
request.method == CheckoutProtocol.complete.method -> handleComplete(message)
5453
else -> handleClientMessage(request.method, message)
5554
}
5655
} catch (e: SerializationException) {
@@ -101,15 +100,15 @@ internal class EmbeddedCheckoutProtocol(
101100
)
102101

103102
private fun handleStart(message: String) {
104-
log.d(LOG_TAG, "Handling $METHOD_START: hiding progress bar and bubbling up.")
103+
log.d(LOG_TAG, "Handling ${CheckoutProtocol.start.method}: hiding progress bar and bubbling up.")
105104
onMainThread {
106105
view.getListener().onCheckoutViewLoadComplete()
107106
client?.process(message)
108107
}
109108
}
110109

111110
private fun handleComplete(message: String) {
112-
log.d(LOG_TAG, "Handling $METHOD_COMPLETE: bubbling up.")
111+
log.d(LOG_TAG, "Handling ${CheckoutProtocol.complete.method}: bubbling up.")
113112
onMainThread {
114113
client?.process(message)
115114
}
@@ -124,7 +123,7 @@ internal class EmbeddedCheckoutProtocol(
124123
* `Intent.ACTION_VIEW` (see [defaultDelegationClient]).
125124
*/
126125
private fun handleWindowOpenRequest(message: String) {
127-
log.d(LOG_TAG, "Handling $METHOD_WINDOW_OPEN_REQUEST")
126+
log.d(LOG_TAG, "Handling ${CheckoutProtocol.windowOpen.method}")
128127
onMainThread {
129128
val merchantResponse = client?.process(message)
130129
if (merchantResponse != null) {
@@ -135,12 +134,22 @@ internal class EmbeddedCheckoutProtocol(
135134
}
136135
}
137136

137+
/**
138+
* Dispatch a message through the consumer client. `ec.error` also runs through the
139+
* kit-owned [defaultClient] regardless of the consumer response so unrecoverable
140+
* session errors always close checkout while still reaching `CheckoutProtocol.error`.
141+
*/
138142
private fun handleClientMessage(method: String, message: String) {
139143
log.d(LOG_TAG, "Delegating $method to client.")
140144
onMainThread {
141145
val response = client?.process(message)
142146
log.d(LOG_TAG, " client response: $response")
143147
response?.let { sendRaw(it) }
148+
if (method == CheckoutProtocol.error.method) {
149+
// Unrecoverable ec.error is kit behavior: emit to the client, then run
150+
// the default handler so checkout is dismissed even if the client responded.
151+
defaultClient.process(message)?.let { sendRaw(it) }
152+
}
144153
}
145154
}
146155

@@ -169,6 +178,37 @@ internal class EmbeddedCheckoutProtocol(
169178
}
170179
}
171180

181+
/**
182+
* Kit-owned client that handles delegations and kit-mandated notifications,
183+
* mirroring Swift's `defaultsClient`. Currently:
184+
* - [CheckoutProtocol.windowOpen] - launches the URI via `Intent.ACTION_VIEW`, or
185+
* returns [WindowOpenResult.Rejected] with `window_open_rejected_error` semantics.
186+
* - [CheckoutProtocol.error] - when any message carries `severity: "unrecoverable"`,
187+
* dismiss the kit via the listener. Per UCP spec, `unrecoverable` means no valid
188+
* resource exists to act on, so consumers don't have to wire dismissal in every
189+
* error handler.
190+
*/
191+
private fun defaultDelegationClient(): CheckoutProtocol.Client =
192+
CheckoutProtocol.Client()
193+
.on(CheckoutProtocol.windowOpen) { request ->
194+
when (val result = ExternalUriLauncher.launch(view.context, request.url)) {
195+
is ExternalUriLauncher.Result.Launched -> WindowOpenResult.Success
196+
is ExternalUriLauncher.Result.Rejected -> {
197+
log.d(LOG_TAG, "window.open rejected for ${request.url}: ${result.reason}")
198+
WindowOpenResult.Rejected(reason = result.reason)
199+
}
200+
}
201+
}
202+
.on(CheckoutProtocol.error) { payload ->
203+
if (payload.messages.none { it.severity == Severity.Unrecoverable }) return@on
204+
log.d(LOG_TAG, "ec.error unrecoverable; dismissing checkout via event processor")
205+
view.getListener().onCheckoutViewFailedWithError(
206+
ClientException(
207+
errorDescription = "Embedded checkout reported unrecoverable error.",
208+
),
209+
)
210+
}
211+
172212
companion object {
173213
private const val LOG_TAG = BaseWebView.ECP_LOG_TAG
174214

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

181221
internal const val METHOD_READY = "ec.ready"
182-
internal const val METHOD_START = "ec.start"
183-
internal const val METHOD_COMPLETE = "ec.complete"
184-
185-
private const val METHOD_WINDOW_OPEN_REQUEST = "ec.window.open_request"
186222

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

201237
private const val CODE_PARSE_ERROR = -32700
202238
private const val CODE_METHOD_NOT_SUPPORTED = -32601
203-
204-
/**
205-
* The kit's default [CheckoutProtocol.windowOpen] handler.
206-
*
207-
* Mirrors Swift's `defaultsClient`: launches the URI via `Intent.ACTION_VIEW`
208-
* if any activity resolves it, otherwise returns [WindowOpenResult.Rejected]
209-
* with `window_open_rejected_error` semantics.
210-
*/
211-
internal fun defaultDelegationClient(context: Context): CheckoutProtocol.Client =
212-
CheckoutProtocol.Client().on(CheckoutProtocol.windowOpen) { request ->
213-
when (val result = ExternalUriLauncher.launch(context, request.url)) {
214-
is ExternalUriLauncher.Result.Launched -> WindowOpenResult.Success
215-
is ExternalUriLauncher.Result.Rejected -> {
216-
log.d(LOG_TAG, "window.open rejected for ${request.url}: ${result.reason}")
217-
WindowOpenResult.Rejected(reason = result.reason)
218-
}
219-
}
220-
}
221239
}
222240
}
223241

platforms/android/lib/src/main/java/com/shopify/checkoutkit/ShopifyCheckoutKit.kt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -81,7 +81,7 @@ public object ShopifyCheckoutKit {
8181
* @param communicationClient optional handler for Embedded Checkout Protocol (ECP) messages.
8282
* Implement [CheckoutCommunicationClient] to intercept arbitrary ECP messages from the checkout
8383
* web page. Built-in messages ([ec.ready][EmbeddedCheckoutProtocol.METHOD_READY] and
84-
* [ec.start][EmbeddedCheckoutProtocol.METHOD_START]) are handled automatically by the SDK.
84+
* [ec.start][CheckoutProtocol.start]) are handled automatically by the SDK.
8585
* @return An instance of [CheckoutKitDialog] if the dialog was successfully created and displayed.
8686
*/
8787
@JvmOverloads

platforms/android/lib/src/test/java/com/shopify/checkoutkit/EmbeddedCheckoutProtocolTest.kt

Lines changed: 121 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,11 +7,13 @@ import android.net.Uri
77
import android.os.Looper
88
import androidx.activity.ComponentActivity
99
import org.assertj.core.api.Assertions.assertThat
10+
import org.junit.Assert.fail
1011
import org.junit.Before
1112
import org.junit.Test
1213
import org.junit.runner.RunWith
1314
import org.mockito.Mockito
1415
import org.mockito.kotlin.any
16+
import org.mockito.kotlin.argThat
1517
import org.mockito.kotlin.argumentCaptor
1618
import org.mockito.kotlin.isNull
1719
import org.mockito.kotlin.mock
@@ -360,20 +362,121 @@ class EmbeddedCheckoutProtocolTest {
360362

361363
// endregion
362364

363-
// region delegated notifications
365+
// region ec.error — severity-driven dismissal
366+
367+
@Test
368+
fun `ec error is forwarded to client regardless of severity`() {
369+
val rawMessage = ecErrorMessage(severity = "recoverable")
370+
val client = mock<CheckoutCommunicationClient>()
371+
ecp.setClient(client)
372+
373+
ecp.postMessage(rawMessage)
374+
shadowOf(Looper.getMainLooper()).runToEndOfTasks()
375+
376+
verify(client).process(rawMessage)
377+
}
378+
379+
@Test
380+
fun `ec error with unrecoverable severity dismisses via listener`() {
381+
val rawMessage = ecErrorMessage(severity = "unrecoverable")
382+
val client = mock<CheckoutCommunicationClient>()
383+
ecp.setClient(client)
384+
385+
ecp.postMessage(rawMessage)
386+
shadowOf(Looper.getMainLooper()).runToEndOfTasks()
387+
388+
verify(client).process(rawMessage)
389+
val captor = argumentCaptor<CheckoutException>()
390+
verify(mockListener).onCheckoutViewFailedWithError(captor.capture())
391+
assertThat(captor.firstValue).isInstanceOf(ClientException::class.java)
392+
assertThat(captor.firstValue.errorDescription)
393+
.isEqualTo("Embedded checkout reported unrecoverable error.")
394+
}
364395

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

371403
ecp.postMessage(rawMessage)
372404
shadowOf(Looper.getMainLooper()).runToEndOfTasks()
373405

374406
verify(client).process(rawMessage)
407+
verify(mockListener).onCheckoutViewFailedWithError(
408+
argThat { this is ClientException },
409+
)
375410
}
376411

412+
@Test
413+
fun `ec error with recoverable severity does not dismiss`() {
414+
val rawMessage = ecErrorMessage(severity = "recoverable")
415+
ecp.setClient(mock())
416+
417+
ecp.postMessage(rawMessage)
418+
shadowOf(Looper.getMainLooper()).runToEndOfTasks()
419+
420+
verify(mockListener, never()).onCheckoutViewFailedWithError(any())
421+
}
422+
423+
@Test
424+
fun `ec error with requires_buyer_input severity does not dismiss`() {
425+
val rawMessage = ecErrorMessage(severity = "requires_buyer_input")
426+
ecp.setClient(mock())
427+
428+
ecp.postMessage(rawMessage)
429+
shadowOf(Looper.getMainLooper()).runToEndOfTasks()
430+
431+
verify(mockListener, never()).onCheckoutViewFailedWithError(any())
432+
}
433+
434+
@Test
435+
fun `ec error with requires_buyer_review severity does not dismiss`() {
436+
val rawMessage = ecErrorMessage(severity = "requires_buyer_review")
437+
ecp.setClient(mock())
438+
439+
ecp.postMessage(rawMessage)
440+
shadowOf(Looper.getMainLooper()).runToEndOfTasks()
441+
442+
verify(mockListener, never()).onCheckoutViewFailedWithError(any())
443+
}
444+
445+
@Test
446+
fun `ec error dismisses when any message has unrecoverable severity`() {
447+
val messages = """[
448+
|{"type":"error","code":"a","content":"x","severity":"recoverable"},
449+
|{"type":"error","code":"b","content":"y","severity":"unrecoverable"}
450+
|]
451+
""".trimMargin()
452+
val rawMessage = ecErrorMessageWithMessages(messages)
453+
ecp.setClient(mock())
454+
455+
ecp.postMessage(rawMessage)
456+
shadowOf(Looper.getMainLooper()).runToEndOfTasks()
457+
458+
verify(mockListener).onCheckoutViewFailedWithError(
459+
argThat { this is ClientException },
460+
)
461+
}
462+
463+
@Test
464+
fun `ec error without required messages field is ignored by typed handler`() {
465+
val rawMessage = """{"jsonrpc":"2.0","method":"ec.error","params":{"error":{$ERROR_RESPONSE_UCP}}}"""
466+
val client = CheckoutProtocol.Client()
467+
.on(CheckoutProtocol.error) { fail("Malformed ec.error should not dispatch") }
468+
ecp.setClient(client)
469+
470+
ecp.postMessage(rawMessage)
471+
shadowOf(Looper.getMainLooper()).runToEndOfTasks()
472+
473+
verify(mockListener, never()).onCheckoutViewFailedWithError(any())
474+
}
475+
476+
// endregion
477+
478+
// region delegated notifications
479+
377480
@Test
378481
fun `ec complete is delegated to client`() {
379482
val rawMessage = """{"jsonrpc":"2.0","method":"ec.complete","params":{"checkout":{}}}"""
@@ -491,6 +594,21 @@ class EmbeddedCheckoutProtocolTest {
491594
private fun windowOpenRequest(id: String, url: String): String =
492595
"""{"jsonrpc":"2.0","method":"ec.window.open_request","id":$id,"params":{"url":"$url"}}"""
493596

597+
private fun ecErrorMessage(severity: String): String {
598+
val messages =
599+
"""[{"type":"error","code":"session_failed","content":"Session failed","severity":"$severity"}]"""
600+
return ecErrorMessageWithMessages(messages)
601+
}
602+
603+
private fun ecErrorMessageWithMessages(messages: String): String {
604+
val error = """{$ERROR_RESPONSE_UCP,"messages":$messages}"""
605+
return """{"jsonrpc":"2.0","method":"ec.error","params":{"error":$error}}"""
606+
}
607+
608+
private companion object {
609+
private const val ERROR_RESPONSE_UCP = """"ucp":{"version":"2026-04-08","status":"error"}"""
610+
}
611+
494612
/**
495613
* Runs [block], drains the main-thread queue, captures the first JS string
496614
* passed to [CheckoutWebView.evaluateJavascript].

platforms/swift/Sources/ShopifyCheckoutKit/CheckoutWebView.swift

Lines changed: 41 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,31 @@ class CheckoutWebView: WKWebView {
1919

2020
var client: (any CheckoutCommunicationProtocol)?
2121

22+
/// Kit-owned client that handles delegations and kit-mandated notifications. Currently:
23+
/// - `window.open` - falls back to `UIApplication.shared.open(...)` after a
24+
/// `canOpenURL` check (consumers may still override via their own client).
25+
/// - `ec.error` - when the payload carries `severity: "unrecoverable"`, dismiss
26+
/// the kit via `viewDelegate`. Per UCP spec, `unrecoverable` means no valid
27+
/// resource exists to act on, so consumers don't have to wire dismissal in
28+
/// every error handler.
29+
lazy var defaultsClient: CheckoutProtocol.Client = .init()
30+
.on(CheckoutProtocol.windowOpen) { request in
31+
guard UIApplication.shared.canOpenURL(request.url) else {
32+
return .rejected(reason: "canOpenURL returned false")
33+
}
34+
UIApplication.shared.open(request.url)
35+
return .success
36+
}
37+
.on(CheckoutProtocol.error) { [weak self] payload in
38+
guard payload.messages.contains(where: { $0.severity == .unrecoverable }) else { return }
39+
self?.viewDelegate?.checkoutViewDidFailWithError(
40+
error: .checkoutUnavailable(
41+
message: "Embedded checkout reported unrecoverable error.",
42+
code: .clientError(code: .unknown)
43+
)
44+
)
45+
}
46+
2247
static func `for`(checkout url: URL, entryPoint: MetaData.EntryPoint? = nil) -> CheckoutWebView {
2348
OSLogger.shared.debug("Creating webview for URL: \(url.absoluteString)")
2449
return CheckoutWebView(entryPoint: entryPoint)
@@ -133,28 +158,31 @@ extension CheckoutWebView: WKScriptMessageHandler {
133158
}
134159

135160
Task {
136-
if let response = await client?.process(body) {
161+
let isErrorNotification = CheckoutWebView.message(body, hasMethod: CheckoutProtocol.error.method)
162+
let clientResponse = await client?.process(body)
163+
if let response = clientResponse {
137164
checkoutBridge.sendResponse(self, messageBody: response)
138-
return
139165
}
140166

141-
if let response = await CheckoutWebView.defaultsClient.process(body) {
167+
// Defaults are fallback handlers except for ec.error: unrecoverable errors
168+
// must still dismiss checkout after being emitted to the client.
169+
if isErrorNotification || clientResponse == nil,
170+
let response = await defaultsClient.process(body)
171+
{
142172
checkoutBridge.sendResponse(self, messageBody: response)
143173
}
144174
}
145175
}
146176

147-
/// Kit-owned client that handles delegations the consumer did not register.
148-
/// Today the only default is `window.open`, which falls back to
149-
/// `UIApplication.shared.open(...)` after a `canOpenURL` check.
150-
static let defaultsClient = CheckoutProtocol.Client()
151-
.on(CheckoutProtocol.windowOpen) { request in
152-
guard UIApplication.shared.canOpenURL(request.url) else {
153-
return .rejected(reason: "canOpenURL returned false")
154-
}
155-
UIApplication.shared.open(request.url)
156-
return .success
177+
private static func message(_ body: String, hasMethod method: String) -> Bool {
178+
guard
179+
let object = try? JSONSerialization.jsonObject(with: Data(body.utf8)) as? [String: Any],
180+
object["method"] as? String == method
181+
else {
182+
return false
157183
}
184+
return true
185+
}
158186
}
159187

160188
extension CheckoutWebView: WKNavigationDelegate {

0 commit comments

Comments
 (0)