Skip to content

Commit 31f61de

Browse files
committed
Dismiss on unrecoverable
1 parent e99a519 commit 31f61de

7 files changed

Lines changed: 410 additions & 56 deletions

File tree

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

Lines changed: 33 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import android.net.Uri
44
import android.os.Looper
55
import androidx.core.net.toUri
66
import com.shopify.checkoutkit.ShopifyCheckoutKit.log
7+
import kotlinx.serialization.SerialName
78
import kotlinx.serialization.Serializable
89
import kotlinx.serialization.SerializationException
910
import kotlinx.serialization.json.Json
@@ -48,16 +49,7 @@ public object CheckoutProtocol {
4849
public val totalsChange: NotificationDescriptor<Checkout> = checkoutDescriptor("ec.totals.change")
4950
public val error: NotificationDescriptor<ErrorResponse> = NotificationDescriptor(
5051
method = "ec.error",
51-
decode = { params ->
52-
(params as? JsonObject)?.get("error")?.let {
53-
try {
54-
json.decodeFromJsonElement<ErrorResponse>(it)
55-
} catch (e: SerializationException) {
56-
log.d(BaseWebView.ECP_LOG_TAG, "Failed to decode ec.error params: $e raw=$it")
57-
null
58-
}
59-
}
60-
}
52+
decode = ::decodeErrorResponse,
6153
)
6254

6355
// Delegations — request-response. Merchant-overridable: if a consumer registers a
@@ -89,6 +81,29 @@ public object CheckoutProtocol {
8981
}
9082
)
9183

84+
private fun decodeErrorResponse(params: JsonElement?): ErrorResponse? {
85+
val paramsObject = params as? JsonObject ?: return null
86+
val payload = paramsObject["error"] ?: paramsObject
87+
return try {
88+
val rawError = json.decodeFromJsonElement<RawErrorResponse>(payload)
89+
rawError.toErrorResponse()
90+
} catch (e: Exception) {
91+
log.d(BaseWebView.ECP_LOG_TAG, "Failed to decode ec.error params: $e raw=$payload")
92+
null
93+
}
94+
}
95+
96+
private fun RawErrorResponse.toErrorResponse(): ErrorResponse {
97+
return ErrorResponse(
98+
continueURL = continueURL,
99+
messages = messages,
100+
ucp = ucp ?: ErrorResponseUcp(
101+
status = StatusEnum.Error,
102+
version = SPEC_VERSION,
103+
),
104+
)
105+
}
106+
92107
private fun encodeWindowOpenResult(result: WindowOpenResult): JsonObject = when (result) {
93108
is WindowOpenResult.Success ->
94109
json.encodeToJsonElement(
@@ -232,6 +247,14 @@ public object CheckoutProtocol {
232247
private const val LOG_TAG = BaseWebView.ECP_LOG_TAG
233248
private const val CODE_INVALID_PARAMS = -32602
234249

250+
@Serializable
251+
private data class RawErrorResponse(
252+
@SerialName("continue_url")
253+
val continueURL: String? = null,
254+
val messages: List<MessageElement>,
255+
val ucp: ErrorResponseUcp? = null,
256+
)
257+
235258
private fun jsonRpcResult(id: JsonElement?, result: JsonElement): String =
236259
json.encodeToString(
237260
JsonObject.serializer(),

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

Lines changed: 43 additions & 20 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
@@ -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 == METHOD_ERROR) {
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

@@ -181,6 +221,7 @@ internal class EmbeddedCheckoutProtocol(
181221
internal const val METHOD_READY = "ec.ready"
182222
internal const val METHOD_START = "ec.start"
183223
internal const val METHOD_COMPLETE = "ec.complete"
224+
private const val METHOD_ERROR = "ec.error"
184225

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

@@ -200,24 +241,6 @@ internal class EmbeddedCheckoutProtocol(
200241

201242
private const val CODE_PARSE_ERROR = -32700
202243
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-
}
221244
}
222245
}
223246

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

Lines changed: 112 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+
)
410+
}
411+
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())
375421
}
376422

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 = """{"jsonrpc":"2.0","method":"ec.error","params":{"messages":$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":{}}"""
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,12 @@ 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 """{"jsonrpc":"2.0","method":"ec.error","params":{"messages":$messages}}"""
601+
}
602+
494603
/**
495604
* Runs [block], drains the main-thread queue, captures the first JS string
496605
* passed to [CheckoutWebView.evaluateJavascript].

0 commit comments

Comments
 (0)