Skip to content
Draft
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 @@ -26,6 +26,7 @@ import android.net.Uri
import android.os.Looper
import androidx.core.net.toUri
import com.shopify.checkoutkit.ShopifyCheckoutKit.log
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonElement
Expand Down Expand Up @@ -69,16 +70,7 @@ public object CheckoutProtocol {
public val totalsChange: NotificationDescriptor<Checkout> = checkoutDescriptor("ec.totals.change")
public val error: NotificationDescriptor<ErrorResponse> = NotificationDescriptor(
method = "ec.error",
decode = { params ->
params?.jsonObject?.get("error")?.let {
try {
json.decodeFromJsonElement<ErrorResponse>(it)
} catch (e: Exception) {
log.d(BaseWebView.ECP_LOG_TAG, "Failed to decode ec.error params: $e raw=$it")
null
}
}
}
decode = ::decodeErrorResponse,
)

// Delegations — kit-internal. Consumers cannot override; [EmbeddedCheckoutProtocol]
Expand Down Expand Up @@ -109,6 +101,30 @@ public object CheckoutProtocol {
}
)

private fun decodeErrorResponse(params: JsonElement?): ErrorResponse? {
val paramsObject = params as? JsonObject ?: return null
val payload = paramsObject["error"] ?: paramsObject
return try {
val rawError = json.decodeFromJsonElement<RawErrorResponse>(payload)
rawError.toErrorResponse()
} catch (e: Exception) {
log.d(BaseWebView.ECP_LOG_TAG, "Failed to decode ec.error params: $e raw=$payload")
null
}
}

private fun RawErrorResponse.toErrorResponse(): ErrorResponse? {
val messages = messages ?: return null
return ErrorResponse(
continueURL = continueURL,
messages = messages,
ucp = ucp ?: ErrorResponseUcp(
status = StatusEnum.Error,
version = specVersion,
),
)
}

private fun encodeWindowOpenResult(result: WindowOpenResult): JsonObject = when (result) {
is WindowOpenResult.Success ->
json.encodeToJsonElement(
Expand Down Expand Up @@ -246,6 +262,14 @@ public object CheckoutProtocol {
private const val LOG_TAG = BaseWebView.ECP_LOG_TAG
private const val CODE_INVALID_PARAMS = -32602

@Serializable
private data class RawErrorResponse(
@SerialName("continue_url")
val continueURL: String? = null,
val messages: List<MessageElement>? = null,
val ucp: ErrorResponseUcp? = null,
)

private fun jsonRpcResult(id: JsonElement?, result: JsonElement): String =
json.encodeToString(
JsonObject.serializer(),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,6 @@
*/
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 @@ -51,7 +50,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 Down Expand Up @@ -132,12 +131,23 @@ internal class EmbeddedCheckoutProtocol(
defaultClient.process(message)?.let { sendRaw(it) }
}

/**
* Dispatch a message through the consumer client; when the consumer returns no response,
* fall through to the kit-owned [defaultClient]. For notifications (e.g. `ec.error`), both
* clients return `null`, so each gets to fire its own typed handler — the consumer's
* `on(CheckoutProtocol.error) { ... }` runs first, then the kit's default error handler
* decides whether to dismiss based on severity.
*/
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 (response != null) {
sendRaw(response)
} else {
defaultClient.process(message)?.let { sendRaw(it) }
}
}
}

Expand Down Expand Up @@ -166,6 +176,39 @@ internal class EmbeddedCheckoutProtocol(
}
}

/**
* Kit-owned client that handles delegations and notifications the consumer did not
* register, 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 the first 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. Inspects the full `messages` list — any message with
* `severity: "unrecoverable"` triggers dismissal.
*/
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.",
isRecoverable = false,
),
)
}

companion object {
private val LOG_TAG = BaseWebView.ECP_LOG_TAG

Expand Down Expand Up @@ -197,24 +240,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 @@ -34,6 +34,7 @@ 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 @@ -334,11 +335,11 @@ class EmbeddedCheckoutProtocolTest {

// endregion

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

@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 is forwarded to client regardless of severity`() {
val rawMessage = ecErrorMessage(severity = "recoverable")
val client = mock<CheckoutCommunicationClient>()
ecp.setClient(client)

Expand All @@ -348,6 +349,88 @@ class EmbeddedCheckoutProtocolTest {
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.isRecoverable).isFalse()
}

@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 = """{"jsonrpc":"2.0","method":"ec.error","params":{"messages":$messages}}"""
ecp.setClient(mock())

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

verify(mockListener).onCheckoutViewFailedWithError(
argThat { (this as? ClientException)?.isRecoverable == false },
)
}

@Test
fun `ec error without messages field does not dismiss`() {
val rawMessage = """{"jsonrpc":"2.0","method":"ec.error","params":{}}"""
ecp.setClient(mock())

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 @@ -464,6 +547,12 @@ 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 """{"jsonrpc":"2.0","method":"ec.error","params":{"messages":$messages}}"""
}

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

var client: (any CheckoutCommunicationProtocol)?

/// Kit-owned client that handles delegations and notifications the consumer did not
/// register. 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),
recoverable: false
)
)
}

var isRecovery = false {
didSet {
isBridgeAttached = false
Expand Down Expand Up @@ -256,26 +283,11 @@ extension CheckoutWebView: WKScriptMessageHandler {
Task {
if let response = await client?.process(body) {
checkoutBridge.sendResponse(self, messageBody: response)
return
}

if let response = await CheckoutWebView.defaultsClient.process(body) {
} else if 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
}
}

extension CheckoutWebView: WKNavigationDelegate {
Expand Down
Loading
Loading