diff --git a/platforms/android/lib/api/lib.api b/platforms/android/lib/api/lib.api index 3466f9ef..c4251747 100644 --- a/platforms/android/lib/api/lib.api +++ b/platforms/android/lib/api/lib.api @@ -630,7 +630,6 @@ public final class com/shopify/checkoutkit/Checkout$Companion { } public abstract interface class com/shopify/checkoutkit/CheckoutCommunicationClient { - public abstract fun openExternalUrl (Landroid/net/Uri;)Z public abstract fun process (Ljava/lang/String;)Ljava/lang/String; } @@ -669,7 +668,6 @@ public abstract interface class com/shopify/checkoutkit/CheckoutEventProcessor { public abstract fun onCheckoutCanceled ()V public abstract fun onCheckoutCompleted (Lcom/shopify/checkoutkit/lifecycleevents/CheckoutCompletedEvent;)V public abstract fun onCheckoutFailed (Lcom/shopify/checkoutkit/CheckoutException;)V - public abstract fun onCheckoutLinkClicked (Landroid/net/Uri;)V public abstract fun onGeolocationPermissionsHidePrompt ()V public abstract fun onGeolocationPermissionsShowPrompt (Ljava/lang/String;Landroid/webkit/GeolocationPermissions$Callback;)V public abstract fun onPermissionRequest (Landroid/webkit/PermissionRequest;)V @@ -773,8 +771,6 @@ public final class com/shopify/checkoutkit/CheckoutProtocol { public final class com/shopify/checkoutkit/CheckoutProtocol$Client : com/shopify/checkoutkit/CheckoutCommunicationClient { public fun ()V public final fun on (Lcom/shopify/checkoutkit/NotificationDescriptor;Lkotlin/jvm/functions/Function1;)Lcom/shopify/checkoutkit/CheckoutProtocol$Client; - public final fun onOpenExternalUrl (Lkotlin/jvm/functions/Function1;)Lcom/shopify/checkoutkit/CheckoutProtocol$Client; - public fun openExternalUrl (Landroid/net/Uri;)Z public fun process (Ljava/lang/String;)Ljava/lang/String; } @@ -1368,10 +1364,7 @@ public final class com/shopify/checkoutkit/CredentialResult$Companion { } public abstract class com/shopify/checkoutkit/DefaultCheckoutEventProcessor : com/shopify/checkoutkit/CheckoutEventProcessor { - public fun (Landroid/content/Context;)V - public fun (Landroid/content/Context;Lcom/shopify/checkoutkit/LogWrapper;)V - public synthetic fun (Landroid/content/Context;Lcom/shopify/checkoutkit/LogWrapper;ILkotlin/jvm/internal/DefaultConstructorMarker;)V - public fun onCheckoutLinkClicked (Landroid/net/Uri;)V + public fun ()V public fun onGeolocationPermissionsHidePrompt ()V public fun onGeolocationPermissionsShowPrompt (Ljava/lang/String;Landroid/webkit/GeolocationPermissions$Callback;)V public fun onPermissionRequest (Landroid/webkit/PermissionRequest;)V diff --git a/platforms/android/lib/src/main/java/com/shopify/checkoutkit/CheckoutCommunicationClient.kt b/platforms/android/lib/src/main/java/com/shopify/checkoutkit/CheckoutCommunicationClient.kt index 627b6304..dbc4b112 100644 --- a/platforms/android/lib/src/main/java/com/shopify/checkoutkit/CheckoutCommunicationClient.kt +++ b/platforms/android/lib/src/main/java/com/shopify/checkoutkit/CheckoutCommunicationClient.kt @@ -22,8 +22,6 @@ */ package com.shopify.checkoutkit -import android.net.Uri - /** * Implement this interface to handle Embedded Checkout Protocol (ECP) messages beyond * the built-in methods handled natively by the SDK. @@ -34,20 +32,14 @@ public interface CheckoutCommunicationClient { /** * Process a JSON-RPC 2.0 ECP message from the checkout web page. * - * Called for all EC notifications (ec.start, ec.error, ec.complete, ec.*.change) - * and any unknown methods. For requests, return a JSON-RPC 2.0 response string; - * for notifications, return null (no response is sent). + * Called for EC notifications (ec.start, ec.error, ec.complete, ec.*.change) and + * any unknown methods the kit doesn't handle natively. Delegations such as + * `ec.window.open_request` are handled internally by the kit and are not forwarded + * here. 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 */ public fun process(message: String): String? - - /** - * Called when checkout requests that a URL be opened externally (ec.window.open_request). - * - * @param url the URL checkout wants opened in an external browser or app - * @return true if the URL was handled and displayed externally, false otherwise - */ - public fun openExternalUrl(url: Uri): Boolean } diff --git a/platforms/android/lib/src/main/java/com/shopify/checkoutkit/CheckoutEventProcessor.kt b/platforms/android/lib/src/main/java/com/shopify/checkoutkit/CheckoutEventProcessor.kt index f3761978..58eb15fc 100644 --- a/platforms/android/lib/src/main/java/com/shopify/checkoutkit/CheckoutEventProcessor.kt +++ b/platforms/android/lib/src/main/java/com/shopify/checkoutkit/CheckoutEventProcessor.kt @@ -22,9 +22,6 @@ */ package com.shopify.checkoutkit -import android.annotation.SuppressLint -import android.content.Context -import android.content.Intent import android.net.Uri import android.webkit.GeolocationPermissions import android.webkit.PermissionRequest @@ -57,12 +54,6 @@ public interface CheckoutEventProcessor { */ public fun onCheckoutCanceled() - /** - * Event indicating that a link has been clicked within checkout that should be opened outside - * of the WebView, e.g. in a system browser or email client. Protocols can be http/https/mailto/tel - */ - public fun onCheckoutLinkClicked(uri: Uri) - /** * A permission has been requested by the web chrome client, e.g. to access the camera */ @@ -103,10 +94,6 @@ internal class NoopEventProcessor : CheckoutEventProcessor { /* noop */ } - override fun onCheckoutLinkClicked(uri: Uri) { - /* noop */ - } - override fun onShowFileChooser( webView: WebView, filePathCallback: ValueCallback>, @@ -130,22 +117,9 @@ internal class NoopEventProcessor : CheckoutEventProcessor { /** * An abstract class that provides a default implementation of the CheckoutEventProcessor interface - * for handling checkout events and interacting with the Android operating system. - * @param context from which we will launch intents. + * for the optional permission and file-chooser callbacks. Override in subclasses as needed. */ -public abstract class DefaultCheckoutEventProcessor @JvmOverloads constructor( - private val context: Context, - private val log: LogWrapper = LogWrapper(), -) : CheckoutEventProcessor { - - override fun onCheckoutLinkClicked(uri: Uri) { - when (uri.scheme) { - "tel" -> context.launchPhoneApp(uri.schemeSpecificPart) - "mailto" -> context.launchEmailApp(uri.schemeSpecificPart) - "https", "http" -> context.launchBrowser(uri) - else -> context.tryLaunchDeepLink(uri) - } - } +public abstract class DefaultCheckoutEventProcessor : CheckoutEventProcessor { override fun onPermissionRequest(permissionRequest: PermissionRequest) { // no-op override to implement @@ -166,42 +140,4 @@ public abstract class DefaultCheckoutEventProcessor @JvmOverloads constructor( override fun onGeolocationPermissionsHidePrompt() { // no-op override to implement } - - private fun Context.launchEmailApp(to: String) { - log.d(LOG_TAG, "Attempting to launch email app.") - val intent = Intent(Intent.ACTION_SEND) - intent.type = "vnd.android.cursor.item/email" - intent.putExtra(Intent.EXTRA_EMAIL, arrayOf(to)) - startActivity(intent) - } - - private fun Context.launchBrowser(uri: Uri) { - log.d(LOG_TAG, "Attempting to launch browser for $uri.") - val intent = Intent(Intent.ACTION_VIEW) - intent.data = uri - startActivity(intent) - } - - private fun Context.launchPhoneApp(phone: String) { - log.d(LOG_TAG, "Attempting to launch phone app.") - val intent = Intent(Intent.ACTION_DIAL, Uri.fromParts("tel", phone, null)) - startActivity(intent) - } - - @SuppressLint("QueryPermissionsNeeded") - private fun Context.tryLaunchDeepLink(uri: Uri) { - log.d(LOG_TAG, "Attempting to launch deep link for uri $uri.") - val intent = Intent(Intent.ACTION_VIEW) - intent.data = uri - if (context.packageManager.queryIntentActivities(intent, 0).isNotEmpty()) { - startActivity(intent) - } else { - log.w(TAG, "Unrecognized scheme for link clicked in checkout '$uri'") - } - } - - private companion object { - private const val LOG_TAG = "DefaultCheckoutEventProcessor" - private const val TAG = "DefaultCheckoutEventProcessor" - } } diff --git a/platforms/android/lib/src/main/java/com/shopify/checkoutkit/CheckoutProtocol.kt b/platforms/android/lib/src/main/java/com/shopify/checkoutkit/CheckoutProtocol.kt index 59130a6a..356c3940 100644 --- a/platforms/android/lib/src/main/java/com/shopify/checkoutkit/CheckoutProtocol.kt +++ b/platforms/android/lib/src/main/java/com/shopify/checkoutkit/CheckoutProtocol.kt @@ -23,12 +23,23 @@ package com.shopify.checkoutkit import android.net.Uri +import android.os.Looper +import androidx.core.net.toUri import com.shopify.checkoutkit.ShopifyCheckoutKit.log import kotlinx.serialization.Serializable import kotlinx.serialization.json.Json import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonNull +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.contentOrNull import kotlinx.serialization.json.decodeFromJsonElement +import kotlinx.serialization.json.encodeToJsonElement import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import kotlinx.serialization.json.put +import kotlinx.serialization.json.putJsonObject +import java.util.concurrent.CountDownLatch /** * Entry point for the typed Embedded Checkout Protocol (ECP) client. @@ -41,7 +52,6 @@ import kotlinx.serialization.json.jsonObject * val client = CheckoutProtocol.Client() * .on(CheckoutProtocol.start) { checkout -> showProgressUI(checkout) } * .on(CheckoutProtocol.complete) { checkout -> navigateToConfirmation(checkout) } - * .onOpenExternalUrl { uri -> startActivity(Intent(Intent.ACTION_VIEW, uri)); true } * * ShopifyCheckoutKit.present(url, activity, eventProcessor, client) * ``` @@ -71,6 +81,19 @@ public object CheckoutProtocol { } ) + // Delegations — kit-internal. Consumers cannot override; [EmbeddedCheckoutProtocol] + // wires the kit's default handler via [defaultDelegationClient]. + internal val windowOpen: DelegationDescriptor = DelegationDescriptor( + method = "ec.window.open_request", + decode = { params -> + params?.jsonObject?.get("url")?.jsonPrimitive?.contentOrNull + ?.takeIf { it.isNotBlank() } + ?.let { runCatching { it.toUri() }.getOrNull() } + ?.let(::WindowOpenRequest) + }, + encode = { result -> encodeWindowOpenResult(result) }, + ) + private fun checkoutDescriptor(method: String): NotificationDescriptor = NotificationDescriptor( method = method, @@ -86,6 +109,27 @@ public object CheckoutProtocol { } ) + private fun encodeWindowOpenResult(result: WindowOpenResult): JsonObject = when (result) { + is WindowOpenResult.Success -> + json.encodeToJsonElement( + WindowOpenSuccessDto(UcpEnvelope(specVersion, "success")) + ).jsonObject + is WindowOpenResult.Rejected -> + json.encodeToJsonElement( + WindowOpenErrorDto( + ucp = UcpEnvelope(specVersion, "error"), + messages = listOf( + UcpMessage( + type = "error", + code = "window_open_rejected_error", + content = result.reason ?: "Window open rejected", + severity = "unrecoverable", + ) + ), + ) + ).jsonObject + } + internal val json: Json = Json { ignoreUnknownKeys = true } /** @@ -96,10 +140,10 @@ public object CheckoutProtocol { */ public class Client private constructor( private val handlers: Map, - private val urlHandler: ((Uri) -> Boolean)?, + private val delegations: Map, ) : CheckoutCommunicationClient { - public constructor() : this(emptyMap(), null) + public constructor() : this(emptyMap(), emptyMap()) /** * Register a handler for an EC notification descriptor. @@ -117,42 +161,54 @@ public object CheckoutProtocol { decode = descriptor.decode, invoke = { payload -> (payload as? P)?.let { handler(it) } }, ) - return Client(handlers + (descriptor.method to entry), urlHandler) + return Client(handlers + (descriptor.method to entry), delegations) } /** - * Register a handler for [ec.window.open_request]. + * Register a handler for an EC delegation descriptor. * - * Called on the **main thread** (the SDK uses a latch to dispatch from the - * JavascriptInterface thread). Return `true` if the URL was opened externally, - * `false` to let the SDK report an error back to the page. + * Internal: used by the kit to wire its own default delegation handlers + * (see [EmbeddedCheckoutProtocol.defaultDelegationClient]). Not exposed to + * consumers — delegations like `ec.window.open_request` are handled internally. */ - public fun onOpenExternalUrl(handler: (Uri) -> Boolean): Client = - Client(handlers, handler) + internal fun

on( + descriptor: DelegationDescriptor, + handler: (P) -> R, + ): Client = Client(handlers, delegations + (descriptor.method to Delegation.Typed(descriptor, handler))) /** Called by [EmbeddedCheckoutProtocol] for every delegated EC message. */ override fun process(message: String): String? { - try { + return try { val request = json.decodeFromString(message) - val handler = handlers[request.method] - if (handler == null) { - log.d(LOG_TAG, "No handler registered for method=${request.method}") - } else { - val payload = handler.decode(request.params) - log.d(LOG_TAG, "Decoded payload for method=${request.method}: ${payload ?: "null, skipping"}") - payload?.let { onMainThread { handler.invoke(it) } } - } + delegations[request.method]?.let { return it.dispatch(request) } + dispatchNotification(request) + null } catch (e: Exception) { log.d(LOG_TAG, "Error processing ECP message in typed client: $e") + null } - return null } - /** Called by [EmbeddedCheckoutProtocol] on the main thread for [ec.window.open_request]. */ - override fun openExternalUrl(url: Uri): Boolean = urlHandler?.invoke(url) ?: false + /** + * Direct, typed invocation of a registered delegation handler. + * + * Used by the kit to dispatch synthesized delegations (e.g. direct anchor-tag + * clicks intercepted by the WebView) without round-tripping through JSON-RPC. + * Returns `null` if no handler is registered for [descriptor]. + */ + @Suppress("UNCHECKED_CAST") + internal fun

invoke(descriptor: DelegationDescriptor, payload: P): R? = + delegations[descriptor.method]?.let { invokeOnMainThread { it.invokeRaw(payload) } } as? R - private companion object { - private const val LOG_TAG = BaseWebView.ECP_LOG_TAG + private fun dispatchNotification(request: EcpRequest) { + val handler = handlers[request.method] + if (handler == null) { + log.d(LOG_TAG, "No handler registered for method=${request.method}") + return + } + val payload = handler.decode(request.params) + log.d(LOG_TAG, "Decoded payload for method=${request.method}: ${payload ?: "null, skipping"}") + payload?.let { onMainThread { handler.invoke(it) } } } } @@ -160,6 +216,70 @@ public object CheckoutProtocol { val decode: (JsonElement?) -> Any?, val invoke: (Any) -> Unit, ) + + private sealed class Delegation { + abstract fun dispatch(request: EcpRequest): String + abstract fun invokeRaw(payload: Any): Any? + + class Typed

( + private val descriptor: DelegationDescriptor, + private val handler: (P) -> R, + ) : Delegation() { + override fun dispatch(request: EcpRequest): String { + val payload = runCatching { descriptor.decode(request.params) }.getOrElse { e -> + log.d(LOG_TAG, "Decode failed for ${request.method}: $e") + null + } ?: return jsonRpcError( + request.id, + CODE_INVALID_PARAMS, + "Invalid params for ${request.method}", + ) + val result = invokeOnMainThread { handler(payload) } + return jsonRpcResult(request.id, descriptor.encode(result)) + } + + @Suppress("UNCHECKED_CAST") + override fun invokeRaw(payload: Any): Any? = handler(payload as P) + } + } + + private const val LOG_TAG = BaseWebView.ECP_LOG_TAG + private const val CODE_INVALID_PARAMS = -32602 + + private fun jsonRpcResult(id: JsonElement?, result: JsonElement): String = + json.encodeToString( + JsonObject.serializer(), + buildJsonObject { + put("jsonrpc", "2.0") + put("id", id ?: JsonNull) + put("result", result) + } + ) + + private fun jsonRpcError(id: JsonElement?, code: Int, message: String): String = + json.encodeToString( + JsonObject.serializer(), + buildJsonObject { + put("jsonrpc", "2.0") + put("id", id ?: JsonNull) + putJsonObject("error") { + put("code", code) + put("message", message) + } + } + ) + + private fun invokeOnMainThread(block: () -> R): R { + if (Looper.myLooper() == Looper.getMainLooper()) return block() + var result: Result? = null + val latch = CountDownLatch(1) + onMainThread { + result = runCatching { block() } + latch.countDown() + } + latch.await() + return result!!.getOrThrow() + } } /** @@ -172,6 +292,34 @@ public class NotificationDescriptor

internal constructor( internal val decode: (JsonElement?) -> P?, ) +/** + * Describes a typed EC delegation handler binding. + * + * Delegations are request-response: the kit's default handler returns a typed result + * that gets encoded into a JSON-RPC response back to the checkout page. + * Internal — delegations are not consumer-overridable. + */ +internal class DelegationDescriptor

( + val method: String, + val decode: (JsonElement?) -> P?, + val encode: (R) -> JsonElement, +) + +/** Payload delivered with the [CheckoutProtocol.windowOpen] delegation. */ +internal data class WindowOpenRequest(val url: Uri) + +/** + * Outcome the kit's default [CheckoutProtocol.windowOpen] handler returns to the checkout page. + * + * [Success] indicates the URL was opened externally. + * [Rejected] indicates no activity resolved the URL — the page receives a UCP + * `window_open_rejected_error` envelope and may surface a fallback message to the buyer. + */ +internal sealed class WindowOpenResult { + object Success : WindowOpenResult() + data class Rejected(val reason: String? = null) : WindowOpenResult() +} + /** Payload delivered with the [CheckoutProtocol.error] notification. */ @Serializable public data class CheckoutError internal constructor( @@ -179,3 +327,28 @@ public data class CheckoutError internal constructor( public val content: String? = null, public val severity: String? = null, ) + +// UCP wire envelopes for delegation responses. Mirror Swift's UCPSuccess / UCPError / +// WindowOpenRejectedBody (origin/swift/window.open_request: ShopifyCheckoutProtocol/Codec.swift + +// WindowOpen.swift). UcpEnvelope / UcpMessage are intentionally generic so the next delegation +// (ec.auth, ec.payment.*) can reuse them; promote out of this file once a second call site lands. + +@Serializable +private data class UcpEnvelope(val version: String, val status: String) + +@Serializable +private data class UcpMessage( + val type: String, + val code: String, + val content: String, + val severity: String, +) + +@Serializable +private data class WindowOpenSuccessDto(val ucp: UcpEnvelope) + +@Serializable +private data class WindowOpenErrorDto( + val ucp: UcpEnvelope, + val messages: List, +) diff --git a/platforms/android/lib/src/main/java/com/shopify/checkoutkit/CheckoutWebView.kt b/platforms/android/lib/src/main/java/com/shopify/checkoutkit/CheckoutWebView.kt index 0fc5e492..b4f5ab44 100644 --- a/platforms/android/lib/src/main/java/com/shopify/checkoutkit/CheckoutWebView.kt +++ b/platforms/android/lib/src/main/java/com/shopify/checkoutkit/CheckoutWebView.kt @@ -24,7 +24,6 @@ package com.shopify.checkoutkit import android.content.Context import android.graphics.Bitmap -import android.net.Uri import android.os.Handler import android.os.Looper import android.util.AttributeSet @@ -115,51 +114,21 @@ internal class CheckoutWebView(context: Context, attributeSet: AttributeSet? = n view: WebView?, request: WebResourceRequest? ): Boolean { - if ( - request?.hasExternalAnnotation() == true || - request?.url?.isContactLink() == true || - request?.url?.isDeepLink() == true - ) { - log.d(LOG_TAG, "Overriding URL loading to invoke onCheckoutLinkClicked for request: $request.") - checkoutBridge.getEventProcessor().onCheckoutViewLinkClicked(request.trimmedUri()) - return true + val uri = request?.url + if (uri == null || (!uri.isContactLink() && !uri.isDeepLink())) return false + + when (val result = ExternalUriLauncher.launch(context, uri)) { + is ExternalUriLauncher.Result.Launched -> + log.d(LOG_TAG, "Deep link intercepted: $uri — allowed") + is ExternalUriLauncher.Result.Rejected -> + log.d(LOG_TAG, "Deep link intercepted: $uri — rejected (${result.reason})") } - return false - } - - private fun WebResourceRequest.hasExternalAnnotation(): Boolean { - if (!this.url.isWebLink()) { - return false - } - val openExternallyParam = this.url.getQueryParameter(OPEN_EXTERNALLY_PARAM) - val hasOpenExternallyParam = setOf("true", "1").contains(openExternallyParam?.lowercase()?.trim()) - log.d(LOG_TAG, "open_externally param found on request URL.") - return hasOpenExternallyParam - } - - private fun WebResourceRequest.trimmedUri(): Uri { - if (!setOf(Scheme.HTTP, Scheme.HTTPS).contains(this.url.scheme)) { - return this.url - } - - val trimmedUri = Uri.Builder() - .scheme(this.url.scheme) - .authority(this.url.authority) - .path(this.url.path) - - this.url.queryParameterNames.forEach { - if (it != OPEN_EXTERNALLY_PARAM) { - trimmedUri.appendQueryParameter(it, this.url.getQueryParameter(it)) - } - } - - return trimmedUri.build() + return true } } companion object { private const val LOG_TAG = "CheckoutWebView" - private const val OPEN_EXTERNALLY_PARAM = "open_externally" private const val JAVASCRIPT_INTERFACE_NAME = "android" internal var cacheEntry: CheckoutWebViewCacheEntry? = null diff --git a/platforms/android/lib/src/main/java/com/shopify/checkoutkit/CheckoutWebViewEventProcessor.kt b/platforms/android/lib/src/main/java/com/shopify/checkoutkit/CheckoutWebViewEventProcessor.kt index 732f7203..1419d11f 100644 --- a/platforms/android/lib/src/main/java/com/shopify/checkoutkit/CheckoutWebViewEventProcessor.kt +++ b/platforms/android/lib/src/main/java/com/shopify/checkoutkit/CheckoutWebViewEventProcessor.kt @@ -58,11 +58,6 @@ internal class CheckoutWebViewEventProcessor( } } - fun onCheckoutViewLinkClicked(uri: Uri) { - log.d(LOG_TAG, "Calling onCheckoutLinkClicked.") - eventProcessor.onCheckoutLinkClicked(uri) - } - fun onCheckoutViewFailedWithError(error: CheckoutException) { onMainThread { closeCheckoutDialogWithError(error) diff --git a/platforms/android/lib/src/main/java/com/shopify/checkoutkit/EmbeddedCheckoutProtocol.kt b/platforms/android/lib/src/main/java/com/shopify/checkoutkit/EmbeddedCheckoutProtocol.kt index 58fdbfe9..659fe3a1 100644 --- a/platforms/android/lib/src/main/java/com/shopify/checkoutkit/EmbeddedCheckoutProtocol.kt +++ b/platforms/android/lib/src/main/java/com/shopify/checkoutkit/EmbeddedCheckoutProtocol.kt @@ -22,17 +22,22 @@ */ package com.shopify.checkoutkit -import android.net.Uri +import android.content.Context import android.webkit.JavascriptInterface -import androidx.core.net.toUri import com.shopify.checkoutkit.ShopifyCheckoutKit.log import kotlinx.serialization.Serializable import kotlinx.serialization.json.Json import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.add +import kotlinx.serialization.json.buildJsonObject import kotlinx.serialization.json.contentOrNull +import kotlinx.serialization.json.jsonArray import kotlinx.serialization.json.jsonObject import kotlinx.serialization.json.jsonPrimitive -import java.util.concurrent.CountDownLatch +import kotlinx.serialization.json.put +import kotlinx.serialization.json.putJsonArray +import kotlinx.serialization.json.putJsonObject /** * Handles the Embedded Checkout Protocol (ECP) JS bridge. @@ -46,6 +51,7 @@ internal class EmbeddedCheckoutProtocol( @Volatile private var client: CheckoutCommunicationClient? = null, ) { private val decoder = Json { ignoreUnknownKeys = true } + private val defaultClient: CheckoutProtocol.Client = defaultDelegationClient(view.context) internal fun setClient(client: CheckoutCommunicationClient?) { this.client = client @@ -64,7 +70,7 @@ 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(request) + request.method == METHOD_WINDOW_OPEN_REQUEST -> handleWindowOpenRequest(message) request.method == METHOD_START -> handleStart(message) else -> handleClientMessage(request.method, message) } @@ -75,10 +81,28 @@ internal class EmbeddedCheckoutProtocol( } private fun handleReady(request: EcpRequest) { - log.d(LOG_TAG, "Handling $METHOD_READY, sending ACK.") - sendResult(request.id, UCP_SUCCESS) + val requested = request.params?.jsonObject?.get("delegate")?.jsonArray + ?.mapNotNull { it.jsonPrimitive.contentOrNull } + ?: emptyList() + val accepted = requested.filter { it in KIT_SUPPORTED_DELEGATIONS } + log.d(LOG_TAG, "Handling $METHOD_READY, requested=$requested accepted=$accepted") + sendResult(request.id, ucpReadyResult(accepted)) } + private fun ucpReadyResult(acceptedDelegations: List): String = + decoder.encodeToString( + JsonObject.serializer(), + buildJsonObject { + putJsonObject("ucp") { + put("version", CheckoutProtocol.specVersion) + put("status", "success") + } + if (acceptedDelegations.isNotEmpty()) { + putJsonArray("delegate") { acceptedDelegations.forEach { add(it) } } + } + } + ) + private fun handleStart(message: String) { log.d(LOG_TAG, "Handling $METHOD_START: showing progress bar and bubbling up.") onMainThread { @@ -87,39 +111,13 @@ internal class EmbeddedCheckoutProtocol( } } - private fun handleWindowOpenRequest(request: EcpRequest) { - val urlString = request.params?.jsonObject?.get("url")?.jsonPrimitive?.contentOrNull - log.d(LOG_TAG, "Handling $METHOD_WINDOW_OPEN_REQUEST: url=$urlString") - if (urlString == null) { - sendError(request.id, CODE_INVALID_PARAMS, "Missing url parameter") - return - } - val uri = urlString.toUri() - if (uri.scheme != "https" && uri.scheme != "http") { - log.d(LOG_TAG, "Rejecting $METHOD_WINDOW_OPEN_REQUEST with non-web scheme: ${uri.scheme}") - sendError(request.id, CODE_INVALID_PARAMS, "Only http and https URIs are supported") - return - } - if (!openExternalUrlOnMainThread(uri)) { - log.d(LOG_TAG, "No external URL handler; falling back to default link click behavior.") - onMainThread { view.getEventProcessor().onCheckoutViewLinkClicked(uri) } - } - sendResult(request.id, UCP_SUCCESS) - } - - private fun openExternalUrlOnMainThread(uri: Uri): Boolean { - val currentClient = client ?: return false - var handled = false - val latch = CountDownLatch(1) - onMainThread { - try { - handled = currentClient.openExternalUrl(uri) - } finally { - latch.countDown() - } - } - latch.await() - return handled + /** + * Handle `ec.window.open_request` via the kit-owned default delegation client. + * Consumers cannot override this — `window.open` is kit-internal. + */ + private fun handleWindowOpenRequest(message: String) { + log.d(LOG_TAG, "Handling $METHOD_WINDOW_OPEN_REQUEST") + defaultClient.process(message)?.let { sendRaw(it) } } private fun handleClientMessage(method: String, message: String) { @@ -170,6 +168,11 @@ internal class EmbeddedCheckoutProtocol( 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 merchant-requested ∩ 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( @@ -179,13 +182,26 @@ internal class EmbeddedCheckoutProtocol( "ec.fulfillment.address_change_request", ) - // UCP-compliant success envelope required by the spec for all result responses. - private val UCP_SUCCESS = - """{"ucp":{"version":"${CheckoutProtocol.specVersion}","status":"success"}}""" - private const val CODE_PARSE_ERROR = -32700 private const val CODE_METHOD_NOT_SUPPORTED = -32601 - private const val CODE_INVALID_PARAMS = -32602 + + /** + * 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) + } + } + } } } diff --git a/platforms/android/lib/src/main/java/com/shopify/checkoutkit/ExternalUriLauncher.kt b/platforms/android/lib/src/main/java/com/shopify/checkoutkit/ExternalUriLauncher.kt new file mode 100644 index 00000000..4338ad9a --- /dev/null +++ b/platforms/android/lib/src/main/java/com/shopify/checkoutkit/ExternalUriLauncher.kt @@ -0,0 +1,55 @@ +/* + * MIT License + * + * Copyright 2023-present, Shopify Inc. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.shopify.checkoutkit + +import android.content.ActivityNotFoundException +import android.content.Context +import android.content.Intent +import android.net.Uri + +/** + * Single point of entry for launching an external `ACTION_VIEW` Intent. + * + * Used by both [CheckoutWebView.shouldOverrideUrlLoading] (for `mailto:` / `tel:` / custom-scheme + * deep links intercepted during navigation) and [EmbeddedCheckoutProtocol.defaultDelegationClient] + * (for `ec.window.open_request` payloads from the page). Centralising the resolver check and + * `startActivity` failure handling keeps the two paths from drifting apart. + */ +internal object ExternalUriLauncher { + sealed class Result { + object Launched : Result() + data class Rejected(val reason: String? = null) : Result() + } + + fun launch(context: Context, uri: Uri): Result { + val intent = Intent(Intent.ACTION_VIEW, uri).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + return try { + context.startActivity(intent) + Result.Launched + } catch (e: ActivityNotFoundException) { + Result.Rejected(reason = e.message ?: "No activity resolves $uri") + } catch (e: Exception) { + Result.Rejected(reason = e.message) + } + } +} diff --git a/platforms/android/lib/src/test/java/com/shopify/checkoutkit/CheckoutDialogTest.kt b/platforms/android/lib/src/test/java/com/shopify/checkoutkit/CheckoutDialogTest.kt index 8770a3de..96d89349 100644 --- a/platforms/android/lib/src/test/java/com/shopify/checkoutkit/CheckoutDialogTest.kt +++ b/platforms/android/lib/src/test/java/com/shopify/checkoutkit/CheckoutDialogTest.kt @@ -65,7 +65,7 @@ class CheckoutDialogTest { it.preloading = Preloading(enabled = false) } activity = Robolectric.buildActivity(ComponentActivity::class.java).get() - processor = noopDefaultCheckoutEventProcessor(activity) + processor = noopDefaultCheckoutEventProcessor() } @After diff --git a/platforms/android/lib/src/test/java/com/shopify/checkoutkit/CheckoutProtocolTest.kt b/platforms/android/lib/src/test/java/com/shopify/checkoutkit/CheckoutProtocolTest.kt index 4f49925a..22a3c104 100644 --- a/platforms/android/lib/src/test/java/com/shopify/checkoutkit/CheckoutProtocolTest.kt +++ b/platforms/android/lib/src/test/java/com/shopify/checkoutkit/CheckoutProtocolTest.kt @@ -22,7 +22,6 @@ */ package com.shopify.checkoutkit -import android.net.Uri import android.os.Looper import org.assertj.core.api.Assertions.assertThat import org.junit.Test @@ -108,10 +107,10 @@ class CheckoutProtocolTest { // endregion - // region process — always returns null + // region process — return value semantics @Test - fun `process always returns null`() { + fun `process returns null for registered notifications`() { val client = CheckoutProtocol.Client() .on(CheckoutProtocol.start) { /* no-op */ } @@ -183,48 +182,6 @@ class CheckoutProtocolTest { assertThat(received).hasSize(1) } - @Test - fun `onOpenExternalUrl returns new client leaving original unchanged`() { - val uri = Uri.parse("https://example.com") - val base = CheckoutProtocol.Client() - val withHandler = base.onOpenExternalUrl { true } - - assertThat(base.openExternalUrl(uri)).isFalse() - assertThat(withHandler.openExternalUrl(uri)).isTrue() - } - - // endregion - - // region openExternalUrl - - @Test - fun `openExternalUrl returns false when no handler registered`() { - val client = CheckoutProtocol.Client() - assertThat(client.openExternalUrl(Uri.parse("https://example.com"))).isFalse() - } - - @Test - fun `openExternalUrl delegates to registered handler`() { - val seen = mutableListOf() - val client = CheckoutProtocol.Client() - .onOpenExternalUrl { uri -> - seen.add(uri) - true - } - - val uri = Uri.parse("https://shop.example.com/page") - val result = client.openExternalUrl(uri) - - assertThat(result).isTrue() - assertThat(seen).containsExactly(uri) - } - - @Test - fun `openExternalUrl respects handler returning false`() { - val client = CheckoutProtocol.Client().onOpenExternalUrl { false } - assertThat(client.openExternalUrl(Uri.parse("https://example.com"))).isFalse() - } - // endregion // region specVersion diff --git a/platforms/android/lib/src/test/java/com/shopify/checkoutkit/CheckoutWebViewClientTest.kt b/platforms/android/lib/src/test/java/com/shopify/checkoutkit/CheckoutWebViewClientTest.kt index 6326b632..262049f6 100644 --- a/platforms/android/lib/src/test/java/com/shopify/checkoutkit/CheckoutWebViewClientTest.kt +++ b/platforms/android/lib/src/test/java/com/shopify/checkoutkit/CheckoutWebViewClientTest.kt @@ -22,6 +22,9 @@ */ package com.shopify.checkoutkit +import android.content.Intent +import android.content.pm.ActivityInfo +import android.content.pm.ResolveInfo import android.net.Uri import android.webkit.RenderProcessGoneDetail import android.webkit.WebResourceError @@ -43,6 +46,7 @@ import org.mockito.kotlin.verify import org.mockito.kotlin.whenever import org.robolectric.Robolectric import org.robolectric.RobolectricTestRunner +import org.robolectric.Shadows.shadowOf import org.robolectric.annotation.Config import org.robolectric.shadows.ShadowLooper import java.net.HttpURLConnection @@ -58,46 +62,71 @@ class CheckoutWebViewClientTest { @Before fun setUp() { activity = Robolectric.buildActivity(ComponentActivity::class.java).get() + // Mirror real-Android behavior: startActivity throws ActivityNotFoundException when + // no activity resolves the intent. Robolectric defaults to silently recording the + // intent instead — turning on checkActivities aligns the shadow with production. + shadowOf(activity.application).checkActivities(true) } @Test - fun `overrides url loading to call event processor for mailto links`() { - val mockRequest = mockWebRequest(Uri.parse("mailto:daniel.kift@shopify.com")) + fun `overrides url loading for mailto links and launches intent when resolvable`() { + val uri = Uri.parse("mailto:daniel.kift@shopify.com") + registerResolverFor(uri) + val mockRequest = mockWebRequest(uri) val view = viewWithProcessor(activity) val webViewClient = view.CheckoutWebViewClient() val overridden = webViewClient.shouldOverrideUrlLoading(view, mockRequest) assertThat(overridden).isTrue - verify(mockEventProcessor).onCheckoutLinkClicked(mockRequest.url) + val launched = shadowOf(activity).nextStartedActivity + assertThat(launched).isNotNull + assertThat(launched.action).isEqualTo(Intent.ACTION_VIEW) + assertThat(launched.data).isEqualTo(uri) } @Test - fun `overrides url loading to call event processor for tel links`() { - val mockRequest = mockWebRequest(Uri.parse("tel:0123456789")) + fun `overrides url loading for tel links and launches intent when resolvable`() { + val uri = Uri.parse("tel:0123456789") + registerResolverFor(uri) + val mockRequest = mockWebRequest(uri) val view = viewWithProcessor(activity) val webViewClient = view.CheckoutWebViewClient() val overridden = webViewClient.shouldOverrideUrlLoading(view, mockRequest) assertThat(overridden).isTrue - verify(mockEventProcessor).onCheckoutLinkClicked(mockRequest.url) + assertThat(shadowOf(activity).nextStartedActivity).isNotNull } @Test - fun `overrides url loading to call event processor for deep links`() { - val mockRequest = mockWebRequest(Uri.parse("geo:40.712776,-74.005974?q=Statue+of+Liberty")) + fun `overrides url loading for deep links and launches intent when resolvable`() { + val uri = Uri.parse("geo:40.712776,-74.005974?q=Statue+of+Liberty") + registerResolverFor(uri) + val mockRequest = mockWebRequest(uri) val view = viewWithProcessor(activity) val webViewClient = view.CheckoutWebViewClient() val overridden = webViewClient.shouldOverrideUrlLoading(view, mockRequest) assertThat(overridden).isTrue - verify(mockEventProcessor).onCheckoutLinkClicked(mockRequest.url) + assertThat(shadowOf(activity).nextStartedActivity).isNotNull } @Test - fun `does not override url loading to call event processor for about blank`() { + fun `overrides url loading for unresolvable deep link but launches no intent`() { + val mockRequest = mockWebRequest(Uri.parse("myapp://path")) + + val view = viewWithProcessor(activity) + val webViewClient = view.CheckoutWebViewClient() + val overridden = webViewClient.shouldOverrideUrlLoading(view, mockRequest) + + assertThat(overridden).isTrue + assertThat(shadowOf(activity).nextStartedActivity).isNull() + } + + @Test + fun `does not override url loading for about blank`() { val mockRequest = mockWebRequest(Uri.parse("about:blank")) val view = viewWithProcessor(activity) @@ -105,11 +134,10 @@ class CheckoutWebViewClientTest { val overridden = webViewClient.shouldOverrideUrlLoading(view, mockRequest) assertThat(overridden).isFalse() - verify(mockEventProcessor, never()).onCheckoutLinkClicked(any()) } @Test - fun `does not override url loading to call event processor for web links`() { + fun `does not override url loading for web links`() { val mockRequest = mockWebRequest(Uri.parse("https://checkout-sdk.myshopify.com")) val view = viewWithProcessor(activity) @@ -117,7 +145,7 @@ class CheckoutWebViewClientTest { val overridden = webViewClient.shouldOverrideUrlLoading(view, mockRequest) assertThat(overridden).isFalse - verify(mockEventProcessor, never()).onCheckoutLinkClicked(mockRequest.url) + assertThat(shadowOf(activity).nextStartedActivity).isNull() } @Test @@ -286,18 +314,20 @@ class CheckoutWebViewClientTest { .hasDescription("HTTP 502 Error") } + // Deliberate trade-off (matches Swift PR #82): the `?open_externally=true` query-param intercept + // is dropped. Policy/contact links that previously relied on this param will be handled inline + // by the WebView until checkout-side `ec.window.open_request` dispatch ships server-side. @Test - fun `links with open_externally are delegated to the contact link function`() { - val host = "https://go.shop.com" - val loadedUri = Uri.parse("$host?open_externally=true&random_param=1") + fun `does not override url loading for https links carrying open_externally`() { + val loadedUri = Uri.parse("https://go.shop.com?open_externally=true&random_param=1") val mockRequest = mockWebRequest(loadedUri) val view = viewWithProcessor(activity) val webViewClient = view.CheckoutWebViewClient() val overridden = webViewClient.shouldOverrideUrlLoading(view, mockRequest) - assertThat(overridden).isTrue - verify(mockEventProcessor).onCheckoutLinkClicked(Uri.parse("$host?random_param=1")) + assertThat(overridden).isFalse + assertThat(shadowOf(activity).nextStartedActivity).isNull() } @Test @@ -365,6 +395,17 @@ class CheckoutWebViewClientTest { ShadowLooper.shadowMainLooper().runToEndOfTasks() } + private fun registerResolverFor(uri: Uri) { + val intent = Intent(Intent.ACTION_VIEW, uri) + val resolveInfo = ResolveInfo().apply { + activityInfo = ActivityInfo().apply { + packageName = "com.fake.handler" + name = "FakeHandlerActivity" + } + } + shadowOf(activity.packageManager).addResolveInfoForIntent(intent, resolveInfo) + } + private fun mockWebRequest(uri: Uri, forMainFrame: Boolean = false): WebResourceRequest { val mockRequest = mock() whenever(mockRequest.url).thenReturn(uri) diff --git a/platforms/android/lib/src/test/java/com/shopify/checkoutkit/DefaultCheckoutEventProcessorTest.kt b/platforms/android/lib/src/test/java/com/shopify/checkoutkit/DefaultCheckoutEventProcessorTest.kt index a993b809..2d8c9c49 100644 --- a/platforms/android/lib/src/test/java/com/shopify/checkoutkit/DefaultCheckoutEventProcessorTest.kt +++ b/platforms/android/lib/src/test/java/com/shopify/checkoutkit/DefaultCheckoutEventProcessorTest.kt @@ -22,21 +22,14 @@ */ package com.shopify.checkoutkit -import android.content.Intent -import android.content.IntentFilter -import android.content.pm.PackageManager -import android.net.Uri import androidx.activity.ComponentActivity import com.shopify.checkoutkit.lifecycleevents.CheckoutCompletedEvent import org.assertj.core.api.Assertions.assertThat import org.junit.Before import org.junit.Test import org.junit.runner.RunWith -import org.mockito.kotlin.mock -import org.mockito.kotlin.verify import org.robolectric.Robolectric import org.robolectric.RobolectricTestRunner -import org.robolectric.RuntimeEnvironment import org.robolectric.Shadows.shadowOf import org.robolectric.shadows.ShadowActivity @@ -52,82 +45,12 @@ class DefaultCheckoutEventProcessorTest { shadowActivity = shadowOf(activity) } - @Test - fun `onCheckoutLinkClicked with http scheme launches action view intent with uri as data`() { - val processor = noopDefaultCheckoutEventProcessor(activity) - val uri = Uri.parse("https://shopify.com") - - processor.onCheckoutLinkClicked(uri) - - val intent = shadowActivity.peekNextStartedActivityForResult().intent - assertThat(intent.data).isEqualTo(uri) - assertThat(intent.action).isEqualTo(Intent.ACTION_VIEW) - } - - @Test - fun `onCheckoutLinkClicked with mailto scheme launches email intent with to address`() { - val processor = noopDefaultCheckoutEventProcessor(activity) - val uri = Uri.parse("mailto:test.user@shopify.com") - - processor.onCheckoutLinkClicked(uri) - - val intent = shadowActivity.peekNextStartedActivityForResult().intent - assertThat(intent.getStringArrayExtra(Intent.EXTRA_EMAIL)).isEqualTo(arrayOf("test.user@shopify.com")) - assertThat(intent.action).isEqualTo("android.intent.action.SEND") - } - - @Test - fun `onCheckoutLinkClicked with tel scheme launches action dial intent with phone number`() { - val processor = noopDefaultCheckoutEventProcessor(activity) - val uri = Uri.parse("tel:0123456789") - - processor.onCheckoutLinkClicked(uri) - - val intent = shadowActivity.peekNextStartedActivityForResult().intent - assertThat(intent.data).isEqualTo(uri) - assertThat(intent.action).isEqualTo(Intent.ACTION_DIAL) - } - - @Test - fun `onCheckoutLinkedClick with known deep link scheme`() { - val uri = Uri.parse("geo:40.712776,-74.005974?q=Statue+of+Liberty") - - val pm: PackageManager = RuntimeEnvironment.getApplication().packageManager - val shadowPackageManager = shadowOf(pm) - - val intentFilter = IntentFilter(Intent.ACTION_VIEW).apply { - addDataScheme("geo") - } - shadowPackageManager.addIntentFilterForActivity(activity.componentName, intentFilter) - - val processor = noopDefaultCheckoutEventProcessor(activity) - processor.onCheckoutLinkClicked(uri) - - val intent = shadowActivity.nextStartedActivity - assertThat(intent.data).isEqualTo(uri) - assertThat(intent.action).isEqualTo(Intent.ACTION_VIEW) - } - - @Test - fun `onCheckoutLinkedClick with unhandled scheme logs warning`() { - val log = mock() - val processor = noopDefaultCheckoutEventProcessor(activity, log) - - val uri = Uri.parse("ftp:random") - - processor.onCheckoutLinkClicked(uri) - - assertThat(shadowActivity.peekNextStartedActivityForResult()).isNull() - verify(log).w("DefaultCheckoutEventProcessor", "Unrecognized scheme for link clicked in checkout 'ftp:random'") - } - @Test fun `onCheckoutFailed returns an error description`() { - val log = mock() var description = "" var recoverable: Boolean? = null val processor = - object : DefaultCheckoutEventProcessor(activity, log) { + object : DefaultCheckoutEventProcessor() { override fun onCheckoutCompleted(checkoutCompletedEvent: CheckoutCompletedEvent) { /* not implemented */ } diff --git a/platforms/android/lib/src/test/java/com/shopify/checkoutkit/EmbeddedCheckoutProtocolTest.kt b/platforms/android/lib/src/test/java/com/shopify/checkoutkit/EmbeddedCheckoutProtocolTest.kt index 357ae19c..d10b03e4 100644 --- a/platforms/android/lib/src/test/java/com/shopify/checkoutkit/EmbeddedCheckoutProtocolTest.kt +++ b/platforms/android/lib/src/test/java/com/shopify/checkoutkit/EmbeddedCheckoutProtocolTest.kt @@ -22,6 +22,9 @@ */ package com.shopify.checkoutkit +import android.content.Intent +import android.content.pm.ActivityInfo +import android.content.pm.ResolveInfo import android.net.Uri import android.os.Looper import androidx.activity.ComponentActivity @@ -55,6 +58,10 @@ class EmbeddedCheckoutProtocolTest { shadowOf(Looper.getMainLooper()).runToEndOfTasks() activity = Robolectric.buildActivity(ComponentActivity::class.java).setup().get() + // Mirror real-Android behavior: startActivity throws ActivityNotFoundException when + // no activity resolves the intent. Robolectric defaults to silently recording the + // intent instead — turning on checkActivities aligns the shadow with production. + shadowOf(activity.application).checkActivities(true) viewSpy = Mockito.spy(CheckoutWebView(activity)) mockEventProcessor = mock() whenever(viewSpy.getEventProcessor()).thenReturn(mockEventProcessor) @@ -99,6 +106,57 @@ class EmbeddedCheckoutProtocolTest { assertThat(js).contains(".postMessage(") } + @Test + fun `ec ready echoes window open delegation when requested`() { + val js = captureEvaluatedJs { + ecp.postMessage( + """{"jsonrpc":"2.0","method":"ec.ready","id":"r1","params":{"delegate":["window.open"]}}""" + ) + } + assertThat(js).contains("\"delegate\":[\"window.open\"]") + assertThat(js).contains("\"status\":\"success\"") + } + + @Test + fun `ec ready filters unsupported delegations down to intersection`() { + val js = captureEvaluatedJs { + ecp.postMessage( + """{"jsonrpc":"2.0","method":"ec.ready","id":"r2","params":{"delegate":["window.open","payment.credential"]}}""" + ) + } + assertThat(js).contains("\"delegate\":[\"window.open\"]") + assertThat(js).doesNotContain("payment.credential") + } + + @Test + fun `ec ready omits delegate field when no supported delegations requested`() { + val js = captureEvaluatedJs { + ecp.postMessage( + """{"jsonrpc":"2.0","method":"ec.ready","id":"r3","params":{"delegate":["fulfillment.address_change"]}}""" + ) + } + assertThat(js).doesNotContain("\"delegate\"") + assertThat(js).contains("\"status\":\"success\"") + } + + @Test + fun `ec ready omits delegate field when delegate array is empty`() { + val js = captureEvaluatedJs { + ecp.postMessage("""{"jsonrpc":"2.0","method":"ec.ready","id":"r4","params":{"delegate":[]}}""") + } + assertThat(js).doesNotContain("\"delegate\"") + assertThat(js).contains("\"status\":\"success\"") + } + + @Test + fun `ec ready omits delegate field when params has no delegate key`() { + val js = captureEvaluatedJs { + ecp.postMessage("""{"jsonrpc":"2.0","method":"ec.ready","id":"r5","params":{}}""") + } + assertThat(js).doesNotContain("\"delegate\"") + assertThat(js).contains("\"status\":\"success\"") + } + // endregion // region unsupported methods — explicit error response @@ -167,104 +225,76 @@ class EmbeddedCheckoutProtocolTest { // endregion - // region ec.window.open_request + // region ec.window.open_request — handled by kit-owned default delegation client @Test - fun `ec window open request calls openExternalUrl on client with parsed uri`() { - val client = mock() - whenever(client.openExternalUrl(any())).thenReturn(true) - ecp.setClient(client) - - ecp.postMessage( - """{"jsonrpc":"2.0","method":"ec.window.open_request","id":"5","params":{"url":"https://example.com/page"}}""" - ) - shadowOf(Looper.getMainLooper()).runToEndOfTasks() - - val captor = argumentCaptor() - verify(client).openExternalUrl(captor.capture()) - assertThat(captor.firstValue.toString()).isEqualTo("https://example.com/page") - } - - @Test - fun `ec window open request sends UCP success result when client returns true`() { - val client = mock() - whenever(client.openExternalUrl(any())).thenReturn(true) - ecp.setClient(client) + fun `window open launches intent when activity resolves the uri`() { + registerFakeBrowserFor("https://example.com") val js = captureEvaluatedJs { - ecp.postMessage( - """{"jsonrpc":"2.0","method":"ec.window.open_request","id":"5","params":{"url":"https://example.com"}}""" - ) - } - assertThat(js).contains("\"result\"") - assertThat(js).contains("\"ucp\"") - assertThat(js).contains("\"status\":\"success\"") - assertThat(js).doesNotContain("\"error\"") - } - - @Test - fun `ec window open request falls back to event processor when client returns false`() { - val client = mock() - whenever(client.openExternalUrl(any())).thenReturn(false) - ecp.setClient(client) - - val js = captureEvaluatedJs { - ecp.postMessage( - """{"jsonrpc":"2.0","method":"ec.window.open_request","id":"6","params":{"url":"https://example.com"}}""" - ) + ecp.postMessage(windowOpenRequest(id = "\"7\"", url = "https://example.com")) } shadowOf(Looper.getMainLooper()).runToEndOfTasks() - assertThat(js).contains("\"result\"") - assertThat(js).contains("\"ucp\"") assertThat(js).contains("\"status\":\"success\"") - assertThat(js).doesNotContain("\"error\"") - verify(mockEventProcessor).onCheckoutViewLinkClicked(Uri.parse("https://example.com")) + val launched = shadowOf(activity).nextStartedActivity + assertThat(launched).isNotNull() + assertThat(launched.action).isEqualTo(Intent.ACTION_VIEW) + assertThat(launched.data.toString()).isEqualTo("https://example.com") } @Test - fun `ec window open request falls back to event processor when no client is set`() { + fun `window open emits UCP rejection when no activity resolves the uri`() { val js = captureEvaluatedJs { - ecp.postMessage( - """{"jsonrpc":"2.0","method":"ec.window.open_request","id":"7","params":{"url":"https://example.com"}}""" - ) + ecp.postMessage(windowOpenRequest(id = "\"42\"", url = "https://nothing-resolves.invalid")) } shadowOf(Looper.getMainLooper()).runToEndOfTasks() - assertThat(js).contains("\"result\"") - assertThat(js).contains("\"ucp\"") - assertThat(js).contains("\"status\":\"success\"") - assertThat(js).doesNotContain("\"error\"") - verify(mockEventProcessor).onCheckoutViewLinkClicked(Uri.parse("https://example.com")) + assertThat(js).contains("\"code\":\"window_open_rejected_error\"") + assertThat(js).contains("\"severity\":\"unrecoverable\"") + assertThat(shadowOf(activity).nextStartedActivity).isNull() } @Test - fun `ec window open request does not call event processor when client handles it`() { - val client = mock() - whenever(client.openExternalUrl(any())).thenReturn(true) - ecp.setClient(client) + fun `window open ignores consumer client — kit default always handles it`() { + registerFakeBrowserFor("https://example.com") + val consumerClient = mock() + ecp.setClient(consumerClient) - ecp.postMessage( - """{"jsonrpc":"2.0","method":"ec.window.open_request","id":"8","params":{"url":"https://example.com"}}""" - ) + ecp.postMessage(windowOpenRequest(id = "\"8\"", url = "https://example.com")) shadowOf(Looper.getMainLooper()).runToEndOfTasks() - verify(mockEventProcessor, never()).onCheckoutViewLinkClicked(any()) + verify(consumerClient, never()).process(any()) + assertThat(shadowOf(activity).nextStartedActivity).isNotNull() } @Test - fun `ec window open request rejects non-http schemes`() { - val client = mock() - ecp.setClient(client) + fun `window open emits invalid params when params url is missing`() { + val js = captureEvaluatedJs { + ecp.postMessage("""{"jsonrpc":"2.0","method":"ec.window.open_request","id":"9","params":{}}""") + } + assertThat(js).contains("\"error\"") + assertThat(js).contains("-32602") + } + @Test + fun `window open emits invalid params when params url is not a string`() { val js = captureEvaluatedJs { ecp.postMessage( - """{"jsonrpc":"2.0","method":"ec.window.open_request","id":"8","params":{"url":"intent://evil"}}""" + """{"jsonrpc":"2.0","method":"ec.window.open_request","id":"10","params":{"url":{}}}""" ) } assertThat(js).contains("\"error\"") assertThat(js).contains("-32602") - verify(client, never()).openExternalUrl(any()) + } + + @Test + fun `window open emits invalid params when params is not an object`() { + val js = captureEvaluatedJs { + ecp.postMessage("""{"jsonrpc":"2.0","method":"ec.window.open_request","id":"11","params":[]}""") + } + assertThat(js).contains("\"error\"") + assertThat(js).contains("-32602") } // endregion @@ -414,6 +444,9 @@ class EmbeddedCheckoutProtocolTest { return """{"jsonrpc":"2.0","method":"ec.ready","id":$id,"params":$params}""" } + private fun windowOpenRequest(id: String, url: String): String = + """{"jsonrpc":"2.0","method":"ec.window.open_request","id":$id,"params":{"url":"$url"}}""" + /** * Runs [block], drains the main-thread queue, captures the first JS string * passed to [CheckoutWebView.evaluateJavascript]. @@ -426,5 +459,21 @@ class EmbeddedCheckoutProtocolTest { return captor.firstValue } + /** + * Makes [uri] resolvable through Robolectric's shadow package manager so that + * `queryIntentActivities(Intent.ACTION_VIEW, uri)` returns a non-empty list. + * Mirrors the behavior of a real device with a browser installed. + */ + private fun registerFakeBrowserFor(uri: String) { + val intent = Intent(Intent.ACTION_VIEW, Uri.parse(uri)) + val resolveInfo = ResolveInfo().apply { + activityInfo = ActivityInfo().apply { + packageName = "com.fake.browser" + name = "FakeBrowserActivity" + } + } + shadowOf(activity.packageManager).addResolveInfoForIntent(intent, resolveInfo) + } + // endregion } diff --git a/platforms/android/lib/src/test/java/com/shopify/checkoutkit/Helpers.kt b/platforms/android/lib/src/test/java/com/shopify/checkoutkit/Helpers.kt index 9bf4c16c..1539f495 100644 --- a/platforms/android/lib/src/test/java/com/shopify/checkoutkit/Helpers.kt +++ b/platforms/android/lib/src/test/java/com/shopify/checkoutkit/Helpers.kt @@ -22,7 +22,6 @@ */ package com.shopify.checkoutkit -import android.app.Activity import com.shopify.checkoutkit.lifecycleevents.CheckoutCompletedEvent import org.assertj.core.api.AbstractAssert @@ -103,18 +102,10 @@ class CheckoutExceptionAssert(actual: CheckoutException) : } } -fun noopDefaultCheckoutEventProcessor(activity: Activity, log: LogWrapper = LogWrapper()): DefaultCheckoutEventProcessor { - return object : DefaultCheckoutEventProcessor(activity, log) { - override fun onCheckoutCompleted(checkoutCompletedEvent: CheckoutCompletedEvent) { - // no-op - } - - override fun onCheckoutFailed(error: CheckoutException) { - // no-op - } - - override fun onCheckoutCanceled() { - // no-op - } +fun noopDefaultCheckoutEventProcessor(): DefaultCheckoutEventProcessor { + return object : DefaultCheckoutEventProcessor() { + override fun onCheckoutCompleted(checkoutCompletedEvent: CheckoutCompletedEvent) = Unit + override fun onCheckoutFailed(error: CheckoutException) = Unit + override fun onCheckoutCanceled() = Unit } } diff --git a/platforms/android/lib/src/test/java/com/shopify/checkoutkit/InteropTest.java b/platforms/android/lib/src/test/java/com/shopify/checkoutkit/InteropTest.java index b4c847a0..4c65994f 100644 --- a/platforms/android/lib/src/test/java/com/shopify/checkoutkit/InteropTest.java +++ b/platforms/android/lib/src/test/java/com/shopify/checkoutkit/InteropTest.java @@ -146,7 +146,7 @@ public void tearDown() { @Test public void canInstantiateCustomEventProcessorWithDefaultArg() { try (ActivityController controller = Robolectric.buildActivity(ComponentActivity.class)) { - DefaultCheckoutEventProcessor processor = new DefaultCheckoutEventProcessor(controller.get()) { + DefaultCheckoutEventProcessor processor = new DefaultCheckoutEventProcessor() { @Override public void onCheckoutCompleted(@NonNull CheckoutCompletedEvent checkoutCompletedEvent) { @@ -248,7 +248,7 @@ public void presentReturnsAHandleToAllowDismissingDialog() { CheckoutKitDialog dialog = ShopifyCheckoutKit.present( "https://shopify.dev", activity, - new DefaultCheckoutEventProcessor(activity) { + new DefaultCheckoutEventProcessor() { @Override public void onCheckoutCompleted(@NonNull CheckoutCompletedEvent checkoutCompletedEvent) { // do nothing diff --git a/platforms/android/lib/src/test/java/com/shopify/checkoutkit/ShopifyCheckoutKitTest.kt b/platforms/android/lib/src/test/java/com/shopify/checkoutkit/ShopifyCheckoutKitTest.kt index 35bf1dea..db9835a7 100644 --- a/platforms/android/lib/src/test/java/com/shopify/checkoutkit/ShopifyCheckoutKitTest.kt +++ b/platforms/android/lib/src/test/java/com/shopify/checkoutkit/ShopifyCheckoutKitTest.kt @@ -136,7 +136,7 @@ class ShopifyCheckoutKitTest { ShopifyCheckoutKit.present( "https://one.com", activity, - noopDefaultCheckoutEventProcessor(activity) + noopDefaultCheckoutEventProcessor() ) ShadowLooper.shadowMainLooper().runToEndOfTasks() @@ -175,7 +175,7 @@ class ShopifyCheckoutKitTest { ShopifyCheckoutKit.present( "https://one.com", activity, - noopDefaultCheckoutEventProcessor(activity) + noopDefaultCheckoutEventProcessor() ) ShadowLooper.shadowMainLooper().runToEndOfTasks() diff --git a/platforms/android/samples/MobileBuyIntegration/app/src/main/java/com/shopify/checkout_kit_mobile_buy_integration_sample/common/MobileBuyEventProcessor.kt b/platforms/android/samples/MobileBuyIntegration/app/src/main/java/com/shopify/checkout_kit_mobile_buy_integration_sample/common/MobileBuyEventProcessor.kt index c43f9bbf..57193b68 100644 --- a/platforms/android/samples/MobileBuyIntegration/app/src/main/java/com/shopify/checkout_kit_mobile_buy_integration_sample/common/MobileBuyEventProcessor.kt +++ b/platforms/android/samples/MobileBuyIntegration/app/src/main/java/com/shopify/checkout_kit_mobile_buy_integration_sample/common/MobileBuyEventProcessor.kt @@ -49,7 +49,7 @@ class MobileBuyEventProcessor( private val navController: NavController, private val logger: Logger, private val context: Context -) : DefaultCheckoutEventProcessor(context) { +) : DefaultCheckoutEventProcessor() { override fun onCheckoutCompleted(checkoutCompletedEvent: CheckoutCompletedEvent) { logger.log(checkoutCompletedEvent)