From 1b3a806b73e3dd90118d437e3845ad8757967848 Mon Sep 17 00:00:00 2001 From: Mark Murray Date: Mon, 14 Sep 2026 13:14:17 +0100 Subject: [PATCH 1/2] Add protocol-agnostic checkout events for Android --- platforms/android/README.md | 157 +++++++---- platforms/android/lib/api/lib.api | 118 +++++++-- .../java/com/shopify/checkoutkit/Checkout.kt | 70 +++++ .../checkoutkit/CheckoutBottomSheet.kt | 4 +- .../com/shopify/checkoutkit/CheckoutEvent.kt | 13 + .../checkoutkit/CheckoutEventAdapter.kt | 63 +++++ .../com/shopify/checkoutkit/CheckoutLink.kt | 18 ++ .../shopify/checkoutkit/CheckoutListener.kt | 37 ++- .../checkoutkit/CheckoutPresentation.kt | 55 +++- .../shopify/checkoutkit/CheckoutProtocol.kt | 18 +- .../shopify/checkoutkit/CheckoutSerializer.kt | 84 ++++++ .../shopify/checkoutkit/CheckoutWebView.kt | 27 ++ .../checkoutkit/CheckoutWebViewListener.kt | 16 ++ .../EmbeddedCheckoutProtocolBridge.kt | 104 +++++--- .../com/shopify/checkoutkit/PreloadCache.kt | 3 + .../shopify/checkoutkit/ShopifyCheckout.kt | 19 +- .../shopify/checkoutkit/ShopifyCheckoutKit.kt | 16 +- .../CheckoutBottomSheetOptionsTest.kt | 2 +- .../checkoutkit/CheckoutBottomSheetTest.kt | 11 +- .../CheckoutCompletionCacheTest.kt | 193 ++++++++++++++ .../checkoutkit/CheckoutEventAdapterTest.kt | 139 ++++++++++ .../CheckoutEventsIntegrationTest.kt | 153 +++++++++++ .../checkoutkit/CheckoutExceptionAssert.kt | 2 +- .../checkoutkit/CheckoutPresentationTest.kt | 68 +++-- .../com/shopify/checkoutkit/CheckoutTest.kt | 243 ++++++++++++++++++ .../EmbeddedCheckoutProtocolBridgeTest.kt | 45 +++- .../com/shopify/checkoutkit/InteropTest.java | 61 ++++- .../checkoutkit/ShopifyCheckoutKitTest.kt | 4 +- .../checkoutkit/ShopifyCheckoutTest.kt | 59 ++++- .../samples/CheckoutKitAndroidDemo/README.md | 18 +- .../androiddemo/cart/AppOwnedCheckoutSheet.kt | 30 +-- .../androiddemo/cart/CartViewModel.kt | 136 +++++----- .../androiddemo/common/logs/Logger.kt | 8 +- .../androiddemo/logs/CheckoutLogContext.kt | 24 +- .../logs/CheckoutLogContextTest.kt | 80 ++++++ 35 files changed, 1772 insertions(+), 326 deletions(-) create mode 100644 platforms/android/lib/src/main/java/com/shopify/checkoutkit/Checkout.kt create mode 100644 platforms/android/lib/src/main/java/com/shopify/checkoutkit/CheckoutEvent.kt create mode 100644 platforms/android/lib/src/main/java/com/shopify/checkoutkit/CheckoutEventAdapter.kt create mode 100644 platforms/android/lib/src/main/java/com/shopify/checkoutkit/CheckoutLink.kt create mode 100644 platforms/android/lib/src/main/java/com/shopify/checkoutkit/CheckoutSerializer.kt create mode 100644 platforms/android/lib/src/test/java/com/shopify/checkoutkit/CheckoutCompletionCacheTest.kt create mode 100644 platforms/android/lib/src/test/java/com/shopify/checkoutkit/CheckoutEventAdapterTest.kt create mode 100644 platforms/android/lib/src/test/java/com/shopify/checkoutkit/CheckoutEventsIntegrationTest.kt create mode 100644 platforms/android/lib/src/test/java/com/shopify/checkoutkit/CheckoutTest.kt create mode 100644 platforms/android/samples/CheckoutKitAndroidDemo/app/src/test/java/com/shopify/checkoutkit/androiddemo/logs/CheckoutLogContextTest.kt diff --git a/platforms/android/README.md b/platforms/android/README.md index f9eb580f7..57d2536f8 100644 --- a/platforms/android/README.md +++ b/platforms/android/README.md @@ -29,6 +29,7 @@ - [Current configuration](#current-configuration) - [Checkout lifecycle](#checkout-lifecycle) - [Error handling](#error-handling) + - [Migrating from the protocol-client prerelease API](#migrating-from-the-protocol-client-prerelease-api) - [Browser and system callbacks](#browser-and-system-callbacks) - [Authentication and buyer identity](#authentication-and-buyer-identity) - [Offsite payments and links](#offsite-payments-and-links) @@ -50,6 +51,10 @@ For alpha testing, install the exact version shown below. The current Checkout Kit for Android alpha is `4.0.0-alpha.6`. +The checkout event API documented below is an unreleased prerelease change in this source tree. It replaces the +protocol-client API in `4.0.0-alpha.6`; see [the migration guide](#migrating-from-the-protocol-client-prerelease-api) +when upgrading to the release containing this change. + ### Gradle ```groovy @@ -89,8 +94,12 @@ import com.shopify.checkoutkit.ShopifyCheckoutKit fun presentCheckout(checkoutUrl: String, activity: ComponentActivity) { ShopifyCheckoutKit.present(checkoutUrl, activity) { - onFail { error -> - handleCheckoutError(error) + onComplete { event -> + handleCompletedCheckout(event.checkout) + } + + onFail { event -> + handleCheckoutError(event.error) } onDismiss { @@ -106,8 +115,12 @@ For Java integrations or shared listener implementations, extend `DefaultCheckou ```kotlin val listener = object : DefaultCheckoutListener() { - override fun onCheckoutFailed(error: CheckoutException) { - handleCheckoutError(error) + override fun onCheckoutCompleted(event: CheckoutCompleteEvent) { + handleCompletedCheckout(event.checkout) + } + + override fun onCheckoutFailed(event: CheckoutFailureEvent) { + handleCheckoutError(event.error) } override fun onCheckoutDismissed() { @@ -131,7 +144,7 @@ checkout?.dismiss() ## Embed checkout Use `ShopifyCheckout` when your app owns the presentation container. The view owns the checkout header, close control, -loading indicator, WebView, browser/system callbacks, and checkout protocol connection. Your app owns the surrounding +loading indicator, WebView, browser/system callbacks, and checkout events. Your app owns the surrounding sheet or navigation state, including its shape, scrim, drag handle, snap points, and dismissal gestures. Jetpack Compose apps can host `ShopifyCheckout` with `AndroidView`; Checkout Kit does not add a Compose dependency: @@ -158,13 +171,15 @@ fun CartScreen(checkoutUrl: String) { AndroidView( factory = { context -> ShopifyCheckout.create(context, checkoutUrl) { - connect(protocolClient) + onComplete { event -> + handleCompletedCheckout(event.checkout) + } onDismiss { dismissCheckout() } - onFail { error -> + onFail { event -> isCheckoutPresented = false - handleCheckoutError(error) + handleCheckoutError(event.error) } } }, @@ -175,7 +190,7 @@ fun CartScreen(checkoutUrl: String) { } ``` -View-system and Java hosts can construct `ShopifyCheckout(context, checkoutUrl, listener, protocolClient)` directly and +View-system and Java hosts can construct `ShopifyCheckout(context, checkoutUrl, listener)` directly and must follow the same `destroy()` contract when removing it from their hierarchy. The close control and system back invoke `onDismiss`; back navigates WebView history first when possible. Sheet gestures @@ -241,7 +256,7 @@ Checkout Kit can reuse a matching preloaded checkout when `present` is called la ```kotlin ShopifyCheckoutKit.present(checkoutUrl, activity) { - onFail { error -> handleCheckoutError(error) } + onFail { event -> handleCheckoutError(event.error) } onDismiss { resetCheckoutUi() } } ``` @@ -466,9 +481,8 @@ For example, `https://checkout.example.com/` is accepted, while entries require the scheme and match subdomains only; `https://*.example.org` does not match `https://example.org`. Use `"*"` to explicitly disable origin validation. -`CheckoutMessageIngressPolicy` evaluates the WebView's authenticated source origin and frame -metadata before a message reaches the protocol client. This keeps transport trust decisions at the -native WebView boundary while ensuring the protocol client only handles admitted checkout messages. +Checkout Kit evaluates the WebView's authenticated source origin and frame metadata before handling a message. +Origin validation happens at the native WebView boundary, before checkout events are delivered to your app. Rejected messages are dropped and logged at warning level. A rejected message is untrusted input, not evidence that checkout failed, so it does not fail a preload or invoke `onFail` or @@ -476,46 +490,42 @@ not evidence that checkout failed, so it does not fail a preload or invoke `onFa ## Checkout lifecycle -Use `onFail` and `onDismiss` for checkout outcomes handled by your app. Use `CheckoutProtocol.Client` for typed checkout state, including completion. These descriptors wrap checkout protocol messages defined in the [protocol schema](../../protocol/services/shopping/embedded.openrpc.json). +Register checkout callbacks directly when presenting or creating a checkout. Start, update, and completion events +each provide a typed `Checkout` snapshot through `event.checkout`. Failures provide a `CheckoutException` through +`event.error`. ```kotlin -import com.shopify.checkoutkit.CheckoutProtocol - -val protocolClient = CheckoutProtocol.Client() - .on(CheckoutProtocol.start) { checkout -> - // Checkout is loaded and interactive. - } - .on(CheckoutProtocol.complete) { checkout -> - // The order was completed. Clear or refresh the local cart. - } - .on(CheckoutProtocol.totalsChange) { checkout -> - // React to updated totals. - } - .on(CheckoutProtocol.lineItemsChange) { checkout -> - // React to line item changes. +ShopifyCheckoutKit.present(checkoutUrl, activity) { + onStart { event -> + recordCheckoutStarted(event.checkout) } - .on(CheckoutProtocol.messagesChange) { checkout -> - // React to checkout messages. + onUpdate { event -> + // Observe totals, line items, messages, and fulfillment changes. + updateCheckoutSummary(event.checkout) } - .on(CheckoutProtocol.fulfillmentChange) { checkout -> - // React to fulfillment option or delivery changes. + onComplete { event -> + // The order was completed. Clear or refresh the local cart. + handleCompletedCheckout(event.checkout) } - -ShopifyCheckoutKit.present(checkoutUrl, activity) { - connect(protocolClient) - onFail { error -> handleCheckoutError(error) } + onFail { event -> handleCheckoutError(event.error) } onDismiss { resetCheckoutUi() } } ``` -`ec.window.open_request` is handled by your registered `CheckoutProtocol.windowOpen` handler if you provide one. Otherwise, Checkout Kit opens web links in Android Custom Tabs and non-web links through Android intents. +`onStart` and `onUpdate` observe checkout state; they do not send mutations to the checkout running in the WebView. +Use `onComplete` to clear or refresh the cart so the app does not reuse a completed checkout. -The public `CheckoutProtocol` descriptors are typed wrappers over UCP-backed checkout protocol messages. +For Java integrations, override `onCheckoutStarted`, `onCheckoutUpdated`, and `onCheckoutCompleted` in +`DefaultCheckoutListener`. These receive `CheckoutStartEvent`, `CheckoutUpdateEvent`, and `CheckoutCompleteEvent`; +use `event.getCheckout()` to access their snapshot. Override `onCheckoutFailed` for `CheckoutFailureEvent` and +`onCheckoutDismissed` for buyer dismissal. Unregistered callbacks have safe defaults. + +Use `onLinkClick` to choose how checkout links open; see [Offsite payments and links](#offsite-payments-and-links). ### Error handling -A checkout lifecycle failure is delivered as a `CheckoutException` to `onFail` or -`onCheckoutFailed`. It has a stable `code`, diagnostic `message`, optional +A checkout lifecycle failure is delivered as a `CheckoutFailureEvent` to `onFail` or +`onCheckoutFailed`. Its `error` is a `CheckoutException` with a stable `code`, diagnostic `message`, optional `httpStatusCode`, and the optional native `cause`. Use the stable code for recovery and analytics. Use diagnostic text and causes only for debugging and logging. @@ -537,6 +547,7 @@ Record `code` (and `httpStatusCode` when available) in analytics as appropriate policy. Use `message` and `cause` only for debugging and logging; do not use them for recovery behavior. ```kotlin +val error = event.error when (error.code) { CheckoutErrorCode.CART_EXPIRED, CheckoutErrorCode.INVALID_CART -> createAndPresentFreshCart() @@ -551,24 +562,38 @@ opening a browser fallback, and re-presenting checkout. #### Checkout session errors -`ec.error` ends the embedded checkout session. Checkout Kit first forwards it to -`CheckoutProtocol.error`, then reports one lifecycle failure for a presented checkout. The first -unrecoverable error message determines the lifecycle code; if none is present, the code is -`UNKNOWN`. `ec.messages.change` reports checkout state only and never calls `onFail`. - -Add a protocol handler when you need the complete protocol payload; it runs before `onFail`: - -```kotlin -val protocolClient = CheckoutProtocol.Client() - .on(CheckoutProtocol.error) { terminalError -> - // Inspect the complete ECP terminal payload for advanced diagnostics. - } -``` +A terminal checkout session error reports one failure for a presented checkout through `onFail` or +`onCheckoutFailed`. The first unrecoverable error message determines the lifecycle code; if none is present, +the code is `UNKNOWN`. Checkout message changes are state updates delivered through `onUpdate`; they do not +trigger a lifecycle failure on their own. Failures during preload do not call `onFail` or `onCheckoutFailed`. Monitor them as `PreloadState.Failed` with the `PreloadStateListener` passed to `preload`, or with the returned `CheckoutPreload` handle's `listener`. A later `present` can load normally. +### Migrating from the protocol-client prerelease API + +This is a breaking change to the Android prerelease API. Checkout callbacks now belong to Checkout Kit, and apps +no longer construct or connect `CheckoutProtocol.Client` instances. Update both Kotlin builder calls and Java +listener overrides when upgrading. + +| Previous API | Checkout event API | +| --- | --- | +| `.on(CheckoutProtocol.start)` | `onStart { event -> ... }` or `onCheckoutStarted(event)` | +| `.on(CheckoutProtocol.complete)` | `onComplete { event -> ... }` or `onCheckoutCompleted(event)` | +| Totals, line-item, message, and fulfillment change handlers | `onUpdate { event -> ... }` or `onCheckoutUpdated(event)` | +| `onFail { error -> ... }` / `onCheckoutFailed(error)` | Receive `CheckoutFailureEvent` and read `event.error` | +| `.on(CheckoutProtocol.error)` | Handle the terminal failure through `onFail` / `onCheckoutFailed` | +| `.on(CheckoutProtocol.windowOpen)` | `onLinkClick { link -> ... }` or `onCheckoutLinkClicked(link)` | +| `connect(client)` and `protocolClient` presentation arguments | Remove them and register checkout callbacks directly | + +Start, update, and completion callbacks receive event wrappers with `event.checkout`, instead of a raw protocol +payload. Use the Kit-owned `Checkout` type for the snapshot. Failure callbacks receive `event.error`, so existing +recovery code can continue to inspect the same `CheckoutErrorCode` and optional HTTP status. + +Link handlers receive `CheckoutLink.url` as an Android `Uri` and return `CheckoutLinkAction.Open`, +`CheckoutLinkAction.Handled`, or `CheckoutLinkAction.Cancel` instead of a protocol response. + ## Browser and system callbacks Android apps must decide how to handle file choosers, web permissions, and geolocation prompts requested by checkout. @@ -620,7 +645,31 @@ Keep Multipass secrets out of client-side code. Some payment providers redirect buyers to external banking apps or web pages. Configure Android App Links or deep links so buyers can return to your app after those flows complete. -Checkout Kit opens delegated external HTTPS links in Android Custom Tabs by default. `mailto:`, `tel:`, and custom-scheme links still open through Android intents. If you want delegated web links to leave your app, register a `CheckoutProtocol.windowOpen` handler and launch an `Intent.ACTION_VIEW` yourself. +Checkout Kit opens delegated external HTTPS links in Android Custom Tabs by default. `mailto:`, `tel:`, and +custom-scheme links open through Android intents. Register `onLinkClick` to choose the action for a link: + +- Return `CheckoutLinkAction.Open` to use Checkout Kit's default handling. +- Return `CheckoutLinkAction.Handled` after your app has opened or routed the link itself. +- Return `CheckoutLinkAction.Cancel` to prevent the link from opening. + +For example, open links with Android intent handling instead of Custom Tabs: + +```kotlin +ShopifyCheckoutKit.present(checkoutUrl, activity) { + onLinkClick { link -> + try { + activity.startActivity(Intent(Intent.ACTION_VIEW, link.url)) + CheckoutLinkAction.Handled + } catch (_: ActivityNotFoundException) { + CheckoutLinkAction.Cancel + } catch (_: SecurityException) { + CheckoutLinkAction.Cancel + } + } +} +``` + +Java hosts can override `DefaultCheckoutListener.onCheckoutLinkClicked(CheckoutLink)` and return the same action. Make sure your app has: diff --git a/platforms/android/lib/api/lib.api b/platforms/android/lib/api/lib.api index afa597124..fcdc70672 100644 --- a/platforms/android/lib/api/lib.api +++ b/platforms/android/lib/api/lib.api @@ -6,6 +6,57 @@ public final class com/shopify/checkoutkit/BuildConfig { public fun ()V } +public final class com/shopify/checkoutkit/Checkout { + public static final field Companion Lcom/shopify/checkoutkit/Checkout$Companion; + public fun (Ljava/util/Map;Lcom/shopify/ucp/embedded/checkout/Buyer;Lcom/shopify/ucp/embedded/checkout/Context;Ljava/lang/String;Ljava/lang/String;Lcom/shopify/ucp/embedded/checkout/CheckoutDiscounts;Ljava/lang/String;Lcom/shopify/ucp/embedded/checkout/CheckoutFulfillment;Ljava/lang/String;Ljava/util/List;Ljava/util/List;Ljava/util/List;Lcom/shopify/ucp/embedded/checkout/OrderConfirmation;Lcom/shopify/ucp/embedded/checkout/Payment;Lkotlinx/serialization/json/JsonObject;Lcom/shopify/ucp/embedded/checkout/CheckoutStatus;Ljava/util/List;Ljava/util/Map;)V + public synthetic fun (Ljava/util/Map;Lcom/shopify/ucp/embedded/checkout/Buyer;Lcom/shopify/ucp/embedded/checkout/Context;Ljava/lang/String;Ljava/lang/String;Lcom/shopify/ucp/embedded/checkout/CheckoutDiscounts;Ljava/lang/String;Lcom/shopify/ucp/embedded/checkout/CheckoutFulfillment;Ljava/lang/String;Ljava/util/List;Ljava/util/List;Ljava/util/List;Lcom/shopify/ucp/embedded/checkout/OrderConfirmation;Lcom/shopify/ucp/embedded/checkout/Payment;Lkotlinx/serialization/json/JsonObject;Lcom/shopify/ucp/embedded/checkout/CheckoutStatus;Ljava/util/List;Ljava/util/Map;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public final fun component1 ()Ljava/util/Map; + public final fun component10 ()Ljava/util/List; + public final fun component11 ()Ljava/util/List; + public final fun component12 ()Ljava/util/List; + public final fun component13 ()Lcom/shopify/ucp/embedded/checkout/OrderConfirmation; + public final fun component14 ()Lcom/shopify/ucp/embedded/checkout/Payment; + public final fun component15 ()Lkotlinx/serialization/json/JsonObject; + public final fun component16 ()Lcom/shopify/ucp/embedded/checkout/CheckoutStatus; + public final fun component17 ()Ljava/util/List; + public final fun component18 ()Ljava/util/Map; + public final fun component2 ()Lcom/shopify/ucp/embedded/checkout/Buyer; + public final fun component3 ()Lcom/shopify/ucp/embedded/checkout/Context; + public final fun component4 ()Ljava/lang/String; + public final fun component5 ()Ljava/lang/String; + public final fun component6 ()Lcom/shopify/ucp/embedded/checkout/CheckoutDiscounts; + public final fun component7 ()Ljava/lang/String; + public final fun component8 ()Lcom/shopify/ucp/embedded/checkout/CheckoutFulfillment; + public final fun component9 ()Ljava/lang/String; + public final fun copy (Ljava/util/Map;Lcom/shopify/ucp/embedded/checkout/Buyer;Lcom/shopify/ucp/embedded/checkout/Context;Ljava/lang/String;Ljava/lang/String;Lcom/shopify/ucp/embedded/checkout/CheckoutDiscounts;Ljava/lang/String;Lcom/shopify/ucp/embedded/checkout/CheckoutFulfillment;Ljava/lang/String;Ljava/util/List;Ljava/util/List;Ljava/util/List;Lcom/shopify/ucp/embedded/checkout/OrderConfirmation;Lcom/shopify/ucp/embedded/checkout/Payment;Lkotlinx/serialization/json/JsonObject;Lcom/shopify/ucp/embedded/checkout/CheckoutStatus;Ljava/util/List;Ljava/util/Map;)Lcom/shopify/checkoutkit/Checkout; + public static synthetic fun copy$default (Lcom/shopify/checkoutkit/Checkout;Ljava/util/Map;Lcom/shopify/ucp/embedded/checkout/Buyer;Lcom/shopify/ucp/embedded/checkout/Context;Ljava/lang/String;Ljava/lang/String;Lcom/shopify/ucp/embedded/checkout/CheckoutDiscounts;Ljava/lang/String;Lcom/shopify/ucp/embedded/checkout/CheckoutFulfillment;Ljava/lang/String;Ljava/util/List;Ljava/util/List;Ljava/util/List;Lcom/shopify/ucp/embedded/checkout/OrderConfirmation;Lcom/shopify/ucp/embedded/checkout/Payment;Lkotlinx/serialization/json/JsonObject;Lcom/shopify/ucp/embedded/checkout/CheckoutStatus;Ljava/util/List;Ljava/util/Map;ILjava/lang/Object;)Lcom/shopify/checkoutkit/Checkout; + public fun equals (Ljava/lang/Object;)Z + public final fun getAdditionalProperties ()Ljava/util/Map; + public final fun getAttribution ()Ljava/util/Map; + public final fun getBuyer ()Lcom/shopify/ucp/embedded/checkout/Buyer; + public final fun getContext ()Lcom/shopify/ucp/embedded/checkout/Context; + public final fun getContinueURL ()Ljava/lang/String; + public final fun getCurrency ()Ljava/lang/String; + public final fun getDiscounts ()Lcom/shopify/ucp/embedded/checkout/CheckoutDiscounts; + public final fun getExpiresAt ()Ljava/lang/String; + public final fun getFulfillment ()Lcom/shopify/ucp/embedded/checkout/CheckoutFulfillment; + public final fun getId ()Ljava/lang/String; + public final fun getLineItems ()Ljava/util/List; + public final fun getLinks ()Ljava/util/List; + public final fun getMessages ()Ljava/util/List; + public final fun getOrder ()Lcom/shopify/ucp/embedded/checkout/OrderConfirmation; + public final fun getPayment ()Lcom/shopify/ucp/embedded/checkout/Payment; + public final fun getSignals ()Lkotlinx/serialization/json/JsonObject; + public final fun getStatus ()Lcom/shopify/ucp/embedded/checkout/CheckoutStatus; + public final fun getTotals ()Ljava/util/List; + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + +public final class com/shopify/checkoutkit/Checkout$Companion { + public final fun serializer ()Lkotlinx/serialization/KSerializer; +} + public abstract interface class com/shopify/checkoutkit/CheckoutAppearance { public static final field Companion Lcom/shopify/checkoutkit/CheckoutAppearance$Companion; } @@ -67,6 +118,10 @@ public final class com/shopify/checkoutkit/CheckoutAppearance$Storefront$Compani public final fun serializer ()Lkotlinx/serialization/KSerializer; } +public final class com/shopify/checkoutkit/CheckoutCompleteEvent { + public final fun getCheckout ()Lcom/shopify/checkoutkit/Checkout; +} + public final class com/shopify/checkoutkit/CheckoutErrorCode : java/lang/Enum { public static final field CART_COMPLETED Lcom/shopify/checkoutkit/CheckoutErrorCode; public static final field CART_EXPIRED Lcom/shopify/checkoutkit/CheckoutErrorCode; @@ -94,13 +149,34 @@ public final class com/shopify/checkoutkit/CheckoutException : java/lang/Excepti public fun getMessage ()Ljava/lang/String; } +public final class com/shopify/checkoutkit/CheckoutFailureEvent { + public final fun getError ()Lcom/shopify/checkoutkit/CheckoutException; +} + public abstract interface class com/shopify/checkoutkit/CheckoutHandle { public abstract fun dismiss ()V } +public final class com/shopify/checkoutkit/CheckoutLink { + public final fun getUrl ()Landroid/net/Uri; +} + +public final class com/shopify/checkoutkit/CheckoutLinkAction : java/lang/Enum { + public static final field Cancel Lcom/shopify/checkoutkit/CheckoutLinkAction; + public static final field Handled Lcom/shopify/checkoutkit/CheckoutLinkAction; + public static final field Open Lcom/shopify/checkoutkit/CheckoutLinkAction; + public static fun getEntries ()Lkotlin/enums/EnumEntries; + public static fun valueOf (Ljava/lang/String;)Lcom/shopify/checkoutkit/CheckoutLinkAction; + public static fun values ()[Lcom/shopify/checkoutkit/CheckoutLinkAction; +} + public abstract interface class com/shopify/checkoutkit/CheckoutListener { + public abstract fun onCheckoutCompleted (Lcom/shopify/checkoutkit/CheckoutCompleteEvent;)V public abstract fun onCheckoutDismissed ()V - public abstract fun onCheckoutFailed (Lcom/shopify/checkoutkit/CheckoutException;)V + public abstract fun onCheckoutFailed (Lcom/shopify/checkoutkit/CheckoutFailureEvent;)V + public abstract fun onCheckoutLinkClicked (Lcom/shopify/checkoutkit/CheckoutLink;)Lcom/shopify/checkoutkit/CheckoutLinkAction; + public abstract fun onCheckoutStarted (Lcom/shopify/checkoutkit/CheckoutStartEvent;)V + public abstract fun onCheckoutUpdated (Lcom/shopify/checkoutkit/CheckoutUpdateEvent;)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 @@ -114,32 +190,16 @@ public final class com/shopify/checkoutkit/CheckoutPreload { } public final class com/shopify/checkoutkit/CheckoutPresentation { - public final fun connect (Lcom/shopify/checkoutkit/CheckoutProtocol$Client;)V + public final fun onComplete (Lkotlin/jvm/functions/Function1;)V public final fun onDismiss (Lkotlin/jvm/functions/Function0;)V public final fun onFail (Lkotlin/jvm/functions/Function1;)V public final fun onGeolocationPermissionsHidePrompt (Lkotlin/jvm/functions/Function0;)V public final fun onGeolocationPermissionsShowPrompt (Lkotlin/jvm/functions/Function2;)V + public final fun onLinkClick (Lkotlin/jvm/functions/Function1;)V public final fun onPermissionRequest (Lkotlin/jvm/functions/Function1;)V public final fun onShowFileChooser (Lkotlin/jvm/functions/Function3;)V -} - -public final class com/shopify/checkoutkit/CheckoutProtocol { - public static final field INSTANCE Lcom/shopify/checkoutkit/CheckoutProtocol; - public static final field SPEC_VERSION Ljava/lang/String; - public final fun getComplete ()Lcom/shopify/ucp/embedded/checkout/NotificationDescriptor; - public final fun getError ()Lcom/shopify/ucp/embedded/checkout/NotificationDescriptor; - public final fun getFulfillmentChange ()Lcom/shopify/ucp/embedded/checkout/NotificationDescriptor; - public final fun getLineItemsChange ()Lcom/shopify/ucp/embedded/checkout/NotificationDescriptor; - public final fun getMessagesChange ()Lcom/shopify/ucp/embedded/checkout/NotificationDescriptor; - public final fun getStart ()Lcom/shopify/ucp/embedded/checkout/NotificationDescriptor; - public final fun getTotalsChange ()Lcom/shopify/ucp/embedded/checkout/NotificationDescriptor; - public final fun getWindowOpen ()Lcom/shopify/ucp/embedded/checkout/RequestDescriptor; -} - -public final class com/shopify/checkoutkit/CheckoutProtocol$Client { - public fun ()V - public final fun on (Lcom/shopify/ucp/embedded/checkout/NotificationDescriptor;Lkotlin/jvm/functions/Function1;)Lcom/shopify/checkoutkit/CheckoutProtocol$Client; - public final fun on (Lcom/shopify/ucp/embedded/checkout/RequestDescriptor;Lkotlin/jvm/functions/Function1;)Lcom/shopify/checkoutkit/CheckoutProtocol$Client; + public final fun onStart (Lkotlin/jvm/functions/Function1;)V + public final fun onUpdate (Lkotlin/jvm/functions/Function1;)V } public final class com/shopify/checkoutkit/CheckoutSheetDismissal { @@ -244,6 +304,14 @@ public final class com/shopify/checkoutkit/CheckoutSheetTitleAlignment : java/la public static fun values ()[Lcom/shopify/checkoutkit/CheckoutSheetTitleAlignment; } +public final class com/shopify/checkoutkit/CheckoutStartEvent { + public final fun getCheckout ()Lcom/shopify/checkoutkit/Checkout; +} + +public final class com/shopify/checkoutkit/CheckoutUpdateEvent { + public final fun getCheckout ()Lcom/shopify/checkoutkit/Checkout; +} + public abstract class com/shopify/checkoutkit/Color { public static final field Companion Lcom/shopify/checkoutkit/Color$Companion; public synthetic fun (ILkotlinx/serialization/internal/SerializationConstructorMarker;)V @@ -522,6 +590,10 @@ public abstract interface class com/shopify/checkoutkit/ConfigurationUpdater { public abstract class com/shopify/checkoutkit/DefaultCheckoutListener : com/shopify/checkoutkit/CheckoutListener { public fun ()V + public fun onCheckoutCompleted (Lcom/shopify/checkoutkit/CheckoutCompleteEvent;)V + public fun onCheckoutLinkClicked (Lcom/shopify/checkoutkit/CheckoutLink;)Lcom/shopify/checkoutkit/CheckoutLinkAction; + public fun onCheckoutStarted (Lcom/shopify/checkoutkit/CheckoutStartEvent;)V + public fun onCheckoutUpdated (Lcom/shopify/checkoutkit/CheckoutUpdateEvent;)V public fun onGeolocationPermissionsHidePrompt ()V public fun onGeolocationPermissionsShowPrompt (Ljava/lang/String;Landroid/webkit/GeolocationPermissions$Callback;)V public fun onPermissionRequest (Landroid/webkit/PermissionRequest;)V @@ -685,8 +757,6 @@ public final class com/shopify/checkoutkit/Preloading { public final class com/shopify/checkoutkit/ShopifyCheckout : android/widget/FrameLayout { public static final field Companion Lcom/shopify/checkoutkit/ShopifyCheckout$Companion; public fun (Landroid/content/Context;Ljava/lang/String;Lcom/shopify/checkoutkit/DefaultCheckoutListener;)V - public fun (Landroid/content/Context;Ljava/lang/String;Lcom/shopify/checkoutkit/DefaultCheckoutListener;Lcom/shopify/checkoutkit/CheckoutProtocol$Client;)V - public synthetic fun (Landroid/content/Context;Ljava/lang/String;Lcom/shopify/checkoutkit/DefaultCheckoutListener;Lcom/shopify/checkoutkit/CheckoutProtocol$Client;ILkotlin/jvm/internal/DefaultConstructorMarker;)V public final fun destroy ()V } @@ -704,9 +774,7 @@ public final class com/shopify/checkoutkit/ShopifyCheckoutKit { public static final fun preload (Ljava/lang/String;Landroidx/activity/ComponentActivity;Lcom/shopify/checkoutkit/PreloadStateListener;)Lcom/shopify/checkoutkit/CheckoutPreload; public static synthetic fun preload$default (Ljava/lang/String;Landroidx/activity/ComponentActivity;Lcom/shopify/checkoutkit/PreloadStateListener;ILjava/lang/Object;)Lcom/shopify/checkoutkit/CheckoutPreload; public static final fun present (Ljava/lang/String;Landroidx/activity/ComponentActivity;Lcom/shopify/checkoutkit/DefaultCheckoutListener;)Lcom/shopify/checkoutkit/CheckoutHandle; - public static final fun present (Ljava/lang/String;Landroidx/activity/ComponentActivity;Lcom/shopify/checkoutkit/DefaultCheckoutListener;Lcom/shopify/checkoutkit/CheckoutProtocol$Client;)Lcom/shopify/checkoutkit/CheckoutHandle; public static final synthetic fun present (Ljava/lang/String;Landroidx/activity/ComponentActivity;Lkotlin/jvm/functions/Function1;)Lcom/shopify/checkoutkit/CheckoutHandle; - public static synthetic fun present$default (Ljava/lang/String;Landroidx/activity/ComponentActivity;Lcom/shopify/checkoutkit/DefaultCheckoutListener;Lcom/shopify/checkoutkit/CheckoutProtocol$Client;ILjava/lang/Object;)Lcom/shopify/checkoutkit/CheckoutHandle; } public abstract interface class com/shopify/checkoutkit/StorefrontCustomizer { diff --git a/platforms/android/lib/src/main/java/com/shopify/checkoutkit/Checkout.kt b/platforms/android/lib/src/main/java/com/shopify/checkoutkit/Checkout.kt new file mode 100644 index 000000000..2597bb01a --- /dev/null +++ b/platforms/android/lib/src/main/java/com/shopify/checkoutkit/Checkout.kt @@ -0,0 +1,70 @@ +package com.shopify.checkoutkit + +import com.shopify.ucp.embedded.checkout.Buyer +import com.shopify.ucp.embedded.checkout.CheckoutDiscounts +import com.shopify.ucp.embedded.checkout.CheckoutFulfillment +import com.shopify.ucp.embedded.checkout.CheckoutStatus +import com.shopify.ucp.embedded.checkout.CheckoutTotal +import com.shopify.ucp.embedded.checkout.Context +import com.shopify.ucp.embedded.checkout.LineItem +import com.shopify.ucp.embedded.checkout.Link +import com.shopify.ucp.embedded.checkout.Message +import com.shopify.ucp.embedded.checkout.OrderConfirmation +import com.shopify.ucp.embedded.checkout.Payment +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonObject +import com.shopify.ucp.embedded.checkout.Checkout as ProtocolCheckout + +/** + * A complete checkout snapshot delivered by Checkout Kit lifecycle events. + * + * Domain fields retain the generated model types and optional values. Protocol metadata is + * excluded, while unrecognized checkout extensions remain available in [additionalProperties]. + * Extensions are encoded alongside the named fields when this snapshot is serialized to JSON. + */ +@Serializable(with = CheckoutSerializer::class) +@Suppress("LongParameterList") +public data class Checkout( + public val attribution: Map? = null, + public val buyer: Buyer? = null, + public val context: Context? = null, + public val continueURL: String? = null, + public val currency: String, + public val discounts: CheckoutDiscounts? = null, + public val expiresAt: String? = null, + public val fulfillment: CheckoutFulfillment? = null, + public val id: String, + public val lineItems: List, + public val links: List, + public val messages: List? = null, + public val order: OrderConfirmation? = null, + public val payment: Payment? = null, + public val signals: JsonObject? = null, + public val status: CheckoutStatus, + public val totals: List, + public val additionalProperties: Map = emptyMap(), +) { + public companion object { + internal fun fromProtocol(checkout: ProtocolCheckout): Checkout = Checkout( + attribution = checkout.attribution, + buyer = checkout.buyer, + context = checkout.context, + continueURL = checkout.continueURL, + currency = checkout.currency, + discounts = checkout.discounts, + expiresAt = checkout.expiresAt, + fulfillment = checkout.fulfillment, + id = checkout.id, + lineItems = checkout.lineItems, + links = checkout.links, + messages = checkout.messages, + order = checkout.order, + payment = checkout.payment, + signals = checkout.signals, + status = checkout.status, + totals = checkout.totals, + additionalProperties = checkout.additionalProperties.filterKeys { it !in CheckoutSerializer.reservedKeys }, + ) + } +} diff --git a/platforms/android/lib/src/main/java/com/shopify/checkoutkit/CheckoutBottomSheet.kt b/platforms/android/lib/src/main/java/com/shopify/checkoutkit/CheckoutBottomSheet.kt index 55a16f432..7f34a9f9e 100644 --- a/platforms/android/lib/src/main/java/com/shopify/checkoutkit/CheckoutBottomSheet.kt +++ b/platforms/android/lib/src/main/java/com/shopify/checkoutkit/CheckoutBottomSheet.kt @@ -82,7 +82,7 @@ internal class CheckoutBottomSheet( val initializationError = checkoutView.initializationError if (initializationError != null) { log.w(LOG_TAG, "WebView is not supported, failing checkout presentation.") - checkoutListener.onCheckoutFailed(initializationError) + checkoutListener.onCheckoutFailed(CheckoutFailureEvent(initializationError)) checkoutView.destroy() presentedCheckoutView = null } else { @@ -261,7 +261,7 @@ internal class CheckoutBottomSheet( */ internal fun closeCheckoutWithError(exception: CheckoutException) { log.d(LOG_TAG, "Closing with error, calling onCheckoutFailed.") - checkoutListener.onCheckoutFailed(exception) + checkoutListener.onCheckoutFailed(CheckoutFailureEvent(exception)) dismiss() } } diff --git a/platforms/android/lib/src/main/java/com/shopify/checkoutkit/CheckoutEvent.kt b/platforms/android/lib/src/main/java/com/shopify/checkoutkit/CheckoutEvent.kt new file mode 100644 index 000000000..947ccc698 --- /dev/null +++ b/platforms/android/lib/src/main/java/com/shopify/checkoutkit/CheckoutEvent.kt @@ -0,0 +1,13 @@ +package com.shopify.checkoutkit + +/** The payload delivered when checkout starts. */ +public class CheckoutStartEvent internal constructor(public val checkout: Checkout) + +/** The payload delivered when the buyer-visible checkout state changes. */ +public class CheckoutUpdateEvent internal constructor(public val checkout: Checkout) + +/** The payload delivered when checkout completes. */ +public class CheckoutCompleteEvent internal constructor(public val checkout: Checkout) + +/** The payload delivered when checkout cannot continue. */ +public class CheckoutFailureEvent internal constructor(public val error: CheckoutException) diff --git a/platforms/android/lib/src/main/java/com/shopify/checkoutkit/CheckoutEventAdapter.kt b/platforms/android/lib/src/main/java/com/shopify/checkoutkit/CheckoutEventAdapter.kt new file mode 100644 index 000000000..e2d975754 --- /dev/null +++ b/platforms/android/lib/src/main/java/com/shopify/checkoutkit/CheckoutEventAdapter.kt @@ -0,0 +1,63 @@ +package com.shopify.checkoutkit + +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonElement +import com.shopify.ucp.embedded.checkout.Checkout as ProtocolCheckout + +/** Translates supported protocol notifications for one checkout presentation. */ +internal class CheckoutEventAdapter( + private val listener: CheckoutWebViewListener, + onDecodeError: (String) -> Unit = {}, +) { + private var active = true + private var latestSnapshot: JsonElement? = null + private val client = CheckoutProtocol.Client() + .on(CheckoutProtocol.start, ::start) + .on(CheckoutProtocol.lineItemsChange, ::update) + .on(CheckoutProtocol.messagesChange, ::update) + .on(CheckoutProtocol.totalsChange, ::update) + .on(CheckoutProtocol.fulfillmentChange, ::update) + .on(CheckoutProtocol.complete, ::complete) + .withDecodeErrorObserver(onDecodeError) + + fun process(message: String) { + client.process(message) + } + + /** Called on the main thread before replacing or releasing the presentation listener. */ + fun invalidate() { + active = false + latestSnapshot = null + } + + fun actionForLink(link: CheckoutLink): CheckoutLinkAction = + if (active) listener.onCheckoutLinkClicked(link) else CheckoutLinkAction.Cancel + + private fun start(checkout: ProtocolCheckout) { + if (!active) return + val snapshot = Checkout.fromProtocol(checkout) + remember(snapshot) + listener.onCheckoutStarted(CheckoutStartEvent(snapshot)) + } + + private fun update(checkout: ProtocolCheckout) { + if (!active) return + val snapshot = Checkout.fromProtocol(checkout) + val comparison = Json.encodeToJsonElement(Checkout.serializer(), snapshot) + if (comparison == latestSnapshot) return + latestSnapshot = comparison + listener.onCheckoutUpdated(CheckoutUpdateEvent(snapshot)) + } + + private fun complete(checkout: ProtocolCheckout) { + if (!active) return + val snapshot = Checkout.fromProtocol(checkout) + remember(snapshot) + listener.onCheckoutCompleted(CheckoutCompleteEvent(snapshot)) + } + + private fun remember(checkout: Checkout) { + // Keep comparison state separate from collections exposed to Kotlin and Java consumers. + latestSnapshot = Json.encodeToJsonElement(Checkout.serializer(), checkout) + } +} diff --git a/platforms/android/lib/src/main/java/com/shopify/checkoutkit/CheckoutLink.kt b/platforms/android/lib/src/main/java/com/shopify/checkoutkit/CheckoutLink.kt new file mode 100644 index 000000000..181043747 --- /dev/null +++ b/platforms/android/lib/src/main/java/com/shopify/checkoutkit/CheckoutLink.kt @@ -0,0 +1,18 @@ +package com.shopify.checkoutkit + +import android.net.Uri + +/** A link that checkout asked the host app to open. */ +public class CheckoutLink internal constructor(public val url: Uri) + +/** The action Checkout Kit should take for a link clicked in checkout. */ +public enum class CheckoutLinkAction { + /** Open the link using Checkout Kit's default system behavior. */ + Open, + + /** The app handled the link itself. */ + Handled, + + /** Do not open the link. */ + Cancel, +} diff --git a/platforms/android/lib/src/main/java/com/shopify/checkoutkit/CheckoutListener.kt b/platforms/android/lib/src/main/java/com/shopify/checkoutkit/CheckoutListener.kt index b7101f3b3..9454eb8ec 100644 --- a/platforms/android/lib/src/main/java/com/shopify/checkoutkit/CheckoutListener.kt +++ b/platforms/android/lib/src/main/java/com/shopify/checkoutkit/CheckoutListener.kt @@ -10,20 +10,27 @@ import android.webkit.WebView /** * Interface to implement to allow responding to lifecycle events in checkout. * We'd strongly recommend extending DefaultCheckoutListener where possible. - * - * Completion (`ec.complete`) and in-checkout state updates (totals, line items, - * messages) flow through [CheckoutProtocol.Client] / the Embedded Checkout - * Protocol — not through this interface. Kit-level failures continue to surface - * here via [onCheckoutFailed]. */ public interface CheckoutListener { + /** Called when checkout starts. */ + public fun onCheckoutStarted(event: CheckoutStartEvent) + + /** Called when the buyer-visible checkout state changes. */ + public fun onCheckoutUpdated(event: CheckoutUpdateEvent) + + /** Called when checkout completes. */ + public fun onCheckoutCompleted(event: CheckoutCompleteEvent) + + /** Chooses how Checkout Kit handles a link that checkout asked the host app to open. */ + public fun onCheckoutLinkClicked(link: CheckoutLink): CheckoutLinkAction + /** * Called when checkout cannot continue. * * Use [CheckoutException.code] for your app's recovery policy. Use the * [CheckoutException.message] and exception cause only for debugging and logging. */ - public fun onCheckoutFailed(error: CheckoutException) + public fun onCheckoutFailed(event: CheckoutFailureEvent) /** * Event representing dismissal of checkout by the buyer. @@ -57,8 +64,8 @@ public interface CheckoutListener { public fun onGeolocationPermissionsHidePrompt() } -internal class NoopCheckoutListener : CheckoutListener { - override fun onCheckoutFailed(error: CheckoutException) { +internal class NoopCheckoutListener : DefaultCheckoutListener() { + override fun onCheckoutFailed(event: CheckoutFailureEvent) { /* noop */ } @@ -93,6 +100,20 @@ internal class NoopCheckoutListener : CheckoutListener { */ public abstract class DefaultCheckoutListener : CheckoutListener { + override fun onCheckoutStarted(event: CheckoutStartEvent) { + // no-op override to implement + } + + override fun onCheckoutUpdated(event: CheckoutUpdateEvent) { + // no-op override to implement + } + + override fun onCheckoutCompleted(event: CheckoutCompleteEvent) { + // no-op override to implement + } + + override fun onCheckoutLinkClicked(link: CheckoutLink): CheckoutLinkAction = CheckoutLinkAction.Open + override fun onPermissionRequest(permissionRequest: PermissionRequest) { // no-op override to implement } diff --git a/platforms/android/lib/src/main/java/com/shopify/checkoutkit/CheckoutPresentation.kt b/platforms/android/lib/src/main/java/com/shopify/checkoutkit/CheckoutPresentation.kt index 2c5bf9c6b..fb8200157 100644 --- a/platforms/android/lib/src/main/java/com/shopify/checkoutkit/CheckoutPresentation.kt +++ b/platforms/android/lib/src/main/java/com/shopify/checkoutkit/CheckoutPresentation.kt @@ -13,7 +13,11 @@ import android.webkit.WebView * Use through [ShopifyCheckoutKit.present] or [ShopifyCheckout.create]. */ public class CheckoutPresentation internal constructor() { - internal var onFail: ((CheckoutException) -> Unit)? = null + internal var onStart: ((CheckoutStartEvent) -> Unit)? = null + internal var onUpdate: ((CheckoutUpdateEvent) -> Unit)? = null + internal var onComplete: ((CheckoutCompleteEvent) -> Unit)? = null + internal var onFail: ((CheckoutFailureEvent) -> Unit)? = null + internal var onLinkClick: ((CheckoutLink) -> CheckoutLinkAction)? = null internal var onDismiss: (() -> Unit)? = null internal var onPermissionRequest: ((PermissionRequest) -> Unit)? = null internal var onShowFileChooser: @@ -21,14 +25,33 @@ public class CheckoutPresentation internal constructor() { internal var onGeolocationPermissionsShowPrompt: ((String, GeolocationPermissions.Callback) -> Unit)? = null internal var onGeolocationPermissionsHidePrompt: (() -> Unit)? = null - internal var protocolClient: CheckoutProtocol.Client? = null + + /** Called when checkout starts. */ + public fun onStart(handler: (CheckoutStartEvent) -> Unit) { + onStart = handler + } + + /** Called when the buyer-visible checkout state changes. */ + public fun onUpdate(handler: (CheckoutUpdateEvent) -> Unit) { + onUpdate = handler + } + + /** Called when checkout completes. */ + public fun onComplete(handler: (CheckoutCompleteEvent) -> Unit) { + onComplete = handler + } + + /** Chooses how Checkout Kit handles links. Defaults to [CheckoutLinkAction.Open]. */ + public fun onLinkClick(handler: (CheckoutLink) -> CheckoutLinkAction) { + onLinkClick = handler + } /** * Called when checkout cannot continue. * * Use [CheckoutException.code] for your app's recovery policy. */ - public fun onFail(handler: (CheckoutException) -> Unit) { + public fun onFail(handler: (CheckoutFailureEvent) -> Unit) { onFail = handler } @@ -75,17 +98,25 @@ public class CheckoutPresentation internal constructor() { onGeolocationPermissionsHidePrompt = handler } - /** - * Connects a typed client for supported Embedded Checkout Protocol callbacks. - */ - public fun connect(client: CheckoutProtocol.Client?) { - protocolClient = client - } - internal fun buildListener(): DefaultCheckoutListener = object : DefaultCheckoutListener() { - override fun onCheckoutFailed(error: CheckoutException) { - onFail?.invoke(error) + override fun onCheckoutStarted(event: CheckoutStartEvent) { + onStart?.invoke(event) + } + + override fun onCheckoutUpdated(event: CheckoutUpdateEvent) { + onUpdate?.invoke(event) + } + + override fun onCheckoutCompleted(event: CheckoutCompleteEvent) { + onComplete?.invoke(event) + } + + override fun onCheckoutLinkClicked(link: CheckoutLink): CheckoutLinkAction = + onLinkClick?.invoke(link) ?: CheckoutLinkAction.Open + + override fun onCheckoutFailed(event: CheckoutFailureEvent) { + onFail?.invoke(event) } override fun onCheckoutDismissed() { 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 b1a6f362b..bfd4de876 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 @@ -20,14 +20,13 @@ import java.util.concurrent.CountDownLatch import com.shopify.ucp.embedded.checkout.Client as ProtocolClient /** - * Consumer-facing typed Embedded Checkout Protocol API curated by Checkout Kit. + * Typed Embedded Checkout Protocol API curated for Checkout Kit's internal bridge. * * The lower-level `embedded-checkout-protocol` artifact owns generated models, raw wire * method names, and the generic dispatch [ProtocolClient]. Checkout Kit decides which of - * those methods are supported for app developers and exposes them through this typed - * namespace. + * those methods are supported and translates them into Kit-owned checkout events. */ -public object CheckoutProtocol { +internal object CheckoutProtocol { public const val SPEC_VERSION: String = EmbeddedCheckoutProtocol.SPEC_VERSION public val start: NotificationDescriptor = EmbeddedCheckoutProtocol.start.map { it.checkout } @@ -52,9 +51,8 @@ public object CheckoutProtocol { /** * Delivers the complete payload of a valid terminal `ec.error` notification. * - * This callback runs before Checkout Kit maps the terminal event to - * [CheckoutListener.onCheckoutFailed]. It is for advanced protocol diagnostics; use - * [CheckoutException.code] for normal lifecycle recovery. + * The bridge maps terminal errors to [CheckoutListener.onCheckoutFailed]. Consumers use + * [CheckoutException.code] for lifecycle recovery. */ public val error: NotificationDescriptor = EmbeddedCheckoutProtocol.error.map { it.error } @@ -107,16 +105,16 @@ public object CheckoutProtocol { ) /** - * A typed, fluent client for supported Checkout Kit protocol callbacks. + * A typed, fluent client for supported internal protocol callbacks. * * Wraps the generic protocol [ProtocolClient], adding Checkout Kit curation (only - * supported descriptors are registered), main-thread delivery of consumer handlers, + * supported descriptors are registered), main-thread delivery of handlers, * and unconditional logging of decode failures via the kit logger. * * Each [on] call returns a new [Client] instance, making it safe to share a base * configuration across multiple checkout presentations. */ - public class Client private constructor( + internal class Client private constructor( private val delegate: ProtocolClient, ) { public constructor() : this( diff --git a/platforms/android/lib/src/main/java/com/shopify/checkoutkit/CheckoutSerializer.kt b/platforms/android/lib/src/main/java/com/shopify/checkoutkit/CheckoutSerializer.kt new file mode 100644 index 000000000..1d3618ce1 --- /dev/null +++ b/platforms/android/lib/src/main/java/com/shopify/checkoutkit/CheckoutSerializer.kt @@ -0,0 +1,84 @@ +package com.shopify.checkoutkit + +import kotlinx.serialization.KSerializer +import kotlinx.serialization.SerializationException +import kotlinx.serialization.descriptors.SerialDescriptor +import kotlinx.serialization.descriptors.buildClassSerialDescriptor +import kotlinx.serialization.encoding.Decoder +import kotlinx.serialization.encoding.Encoder +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonDecoder +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonEncoder +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.decodeFromJsonElement +import kotlinx.serialization.json.encodeToJsonElement +import kotlinx.serialization.json.jsonObject + +internal object CheckoutSerializer : KSerializer { + override val descriptor: SerialDescriptor = buildClassSerialDescriptor("com.shopify.checkoutkit.Checkout") + + internal val reservedKeys: Set = setOf( + "attribution", "buyer", "context", "continue_url", "currency", "discounts", "expires_at", "fulfillment", + "id", "line_items", "links", "messages", "order", "payment", "signals", "status", "totals", "ucp", + ) + + override fun deserialize(decoder: Decoder): Checkout { + val input = decoder as? JsonDecoder + ?: throw SerializationException("Checkout can only be deserialized from JSON") + val fields = input.decodeJsonElement().jsonObject + val json = input.json + return Checkout( + attribution = fields.optional("attribution", json), + buyer = fields.optional("buyer", json), + context = fields.optional("context", json), + continueURL = fields.optional("continue_url", json), + currency = fields.required("currency", json), + discounts = fields.optional("discounts", json), + expiresAt = fields.optional("expires_at", json), + fulfillment = fields.optional("fulfillment", json), + id = fields.required("id", json), + lineItems = fields.required("line_items", json), + links = fields.required("links", json), + messages = fields.optional("messages", json), + order = fields.optional("order", json), + payment = fields.optional("payment", json), + signals = fields.optional("signals", json), + status = fields.required("status", json), + totals = fields.required("totals", json), + additionalProperties = fields.filterKeys { it !in reservedKeys }, + ) + } + + override fun serialize(encoder: Encoder, value: Checkout) { + val output = encoder as? JsonEncoder + ?: throw SerializationException("Checkout can only be serialized to JSON") + val json = output.json + val fields = linkedMapOf() + value.attribution?.let { fields["attribution"] = json.encodeToJsonElement(it) } + value.buyer?.let { fields["buyer"] = json.encodeToJsonElement(it) } + value.context?.let { fields["context"] = json.encodeToJsonElement(it) } + value.continueURL?.let { fields["continue_url"] = json.encodeToJsonElement(it) } + fields["currency"] = json.encodeToJsonElement(value.currency) + value.discounts?.let { fields["discounts"] = json.encodeToJsonElement(it) } + value.expiresAt?.let { fields["expires_at"] = json.encodeToJsonElement(it) } + value.fulfillment?.let { fields["fulfillment"] = json.encodeToJsonElement(it) } + fields["id"] = json.encodeToJsonElement(value.id) + fields["line_items"] = json.encodeToJsonElement(value.lineItems) + fields["links"] = json.encodeToJsonElement(value.links) + value.messages?.let { fields["messages"] = json.encodeToJsonElement(it) } + value.order?.let { fields["order"] = json.encodeToJsonElement(it) } + value.payment?.let { fields["payment"] = json.encodeToJsonElement(it) } + value.signals?.let { fields["signals"] = json.encodeToJsonElement(it) } + fields["status"] = json.encodeToJsonElement(value.status) + fields["totals"] = json.encodeToJsonElement(value.totals) + value.additionalProperties.filterKeys { it !in reservedKeys }.forEach { (key, element) -> fields[key] = element } + output.encodeJsonElement(JsonObject(fields)) + } + + private inline fun JsonObject.required(key: String, json: Json): T = + json.decodeFromJsonElement(get(key) ?: throw SerializationException("Missing $key for Checkout")) + + private inline fun JsonObject.optional(key: String, json: Json): T? = + get(key)?.let { json.decodeFromJsonElement(it) } +} 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 2af1fcf96..c23d65c6d 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 @@ -70,6 +70,7 @@ internal class CheckoutWebView private constructor( private set private var checkoutRequest: CheckoutRequest? = null + private var checkoutKey: PreloadKey? = null private var didRetryCheckoutRequest = false /** @@ -103,6 +104,7 @@ internal class CheckoutWebView private constructor( fun setListener(listener: CheckoutWebViewListener) { log.d(LOG_TAG, "Setting listener $listener.") this.listener = listener + embeddedCheckoutProtocol.setPresentationListener(listener) } fun setClient(client: CheckoutProtocol.Client?) { @@ -110,6 +112,16 @@ internal class CheckoutWebView private constructor( embeddedCheckoutProtocol.setClient(client) } + internal fun endPresentationEvents() { + embeddedCheckoutProtocol.invalidateEventAdapter() + } + + override fun destroy() { + endPresentationEvents() + embeddedCheckoutProtocol.detach() + super.destroy() + } + fun markPresented() { isPresented = true } @@ -150,6 +162,7 @@ internal class CheckoutWebView private constructor( ) loadComplete = false isPreloadRequest = isPreload + checkoutKey = PreloadKey.forUrl(url) checkoutOrigin = OriginAllowlist.originFromUrl(url) Handler(Looper.getMainLooper()).post { hasHandledTerminalFailure = false @@ -240,6 +253,7 @@ internal class CheckoutWebView private constructor( override fun onRenderProcessGone(view: WebView, detail: RenderProcessGoneDetail): Boolean { val shouldDeliverLifecycleFailure = !hasHandledTerminalFailure hasHandledTerminalFailure = true + endPresentationEvents() val wasBackgroundedUnconsumedPreload = evictForTerminalFailure( this@CheckoutWebView, PreloadState.FailureReason.WebContentUnavailable, @@ -586,6 +600,19 @@ internal class CheckoutWebView private constructor( } } + /** Clears the completed checkout and any replacement preload for its active presentation. */ + internal fun evictForCompletion(view: CheckoutWebView, wasPresented: Boolean = view.isPresented) { + runOnMainThread { + val cachedView = preloadCache.cachedView ?: return@runOnMainThread + val containsCompletedView = cachedView === view + val containsReplacement = wasPresented && + view.checkoutKey?.let { it == preloadCache.cachedKey } == true + if (containsCompletedView || containsReplacement) { + preloadCache.evict(PreloadState.Idle, view = cachedView) + } + } + } + fun clearCache() { if (!preloadCache.hasEntry) return invalidate() diff --git a/platforms/android/lib/src/main/java/com/shopify/checkoutkit/CheckoutWebViewListener.kt b/platforms/android/lib/src/main/java/com/shopify/checkoutkit/CheckoutWebViewListener.kt index 1915e0b5f..ef97edabf 100644 --- a/platforms/android/lib/src/main/java/com/shopify/checkoutkit/CheckoutWebViewListener.kt +++ b/platforms/android/lib/src/main/java/com/shopify/checkoutkit/CheckoutWebViewListener.kt @@ -20,6 +20,22 @@ internal class CheckoutWebViewListener( private val hideLoadingBackground: () -> Unit = {}, private val updateProgressBarPercentage: (Int) -> Unit = {}, ) { + internal val isNoop: Boolean = listener is NoopCheckoutListener + + fun onCheckoutStarted(event: CheckoutStartEvent) { + listener.onCheckoutStarted(event) + } + + fun onCheckoutUpdated(event: CheckoutUpdateEvent) { + listener.onCheckoutUpdated(event) + } + + fun onCheckoutCompleted(event: CheckoutCompleteEvent) { + listener.onCheckoutCompleted(event) + } + + fun onCheckoutLinkClicked(link: CheckoutLink): CheckoutLinkAction = listener.onCheckoutLinkClicked(link) + /** * Reports checkout load failure through the presentation close path. */ diff --git a/platforms/android/lib/src/main/java/com/shopify/checkoutkit/EmbeddedCheckoutProtocolBridge.kt b/platforms/android/lib/src/main/java/com/shopify/checkoutkit/EmbeddedCheckoutProtocolBridge.kt index 5a8e6dac7..889bef0ac 100644 --- a/platforms/android/lib/src/main/java/com/shopify/checkoutkit/EmbeddedCheckoutProtocolBridge.kt +++ b/platforms/android/lib/src/main/java/com/shopify/checkoutkit/EmbeddedCheckoutProtocolBridge.kt @@ -48,6 +48,8 @@ internal class EmbeddedCheckoutProtocolBridge( ) { private var isTransportAttached = false + @Volatile private var eventAdapter: CheckoutEventAdapter? = null + /** * True once a decode-error metric was recorded for the message currently * being processed. The contract counts undecodable messages, not decode @@ -63,14 +65,6 @@ internal class EmbeddedCheckoutProtocolBridge( client = defaultClient, policy = DefaultClientPolicy.KitOwned, ), - CheckoutProtocol.windowOpen.method to DefaultClientBinding( - client = defaultClient, - policy = DefaultClientPolicy.RunIfUnhandled, - ), - CheckoutProtocol.complete.method to DefaultClientBinding( - client = defaultClient, - policy = DefaultClientPolicy.AlwaysRunAfterMerchant, - ), ) private val composedClient: ComposedCheckoutProtocolClient get() = ComposedCheckoutProtocolClient( @@ -104,6 +98,16 @@ internal class EmbeddedCheckoutProtocolBridge( this.client = client } + internal fun setPresentationListener(listener: CheckoutWebViewListener) { + invalidateEventAdapter() + eventAdapter = if (listener.isNoop) null else CheckoutEventAdapter(listener, ::recordParamsDecodeErrorOnce) + } + + internal fun invalidateEventAdapter() { + eventAdapter?.invalidate() + eventAdapter = null + } + private fun receiveWebMessage(message: String, sourceOrigin: String, isMainFrame: Boolean) { val incomingMessage = IncomingCheckoutMessage( origin = sourceOrigin, @@ -134,12 +138,14 @@ internal class EmbeddedCheckoutProtocolBridge( } internal fun receiveMessage(message: String) { + // Bind queued messages to the presentation that received them. Preload messages are not replayed. + val adapter = eventAdapter protocolMessageExecutor.execute { - processMessage(message) + processMessage(message, adapter) } } - private fun processMessage(message: String) { + private fun processMessage(message: String, adapter: CheckoutEventAdapter?) { decodeErrorRecordedForMessage = false try { val request = decodeProtocolRequest(message) @@ -148,9 +154,9 @@ internal class EmbeddedCheckoutProtocolBridge( log.d(LOG_TAG, "Received bridge message: method=${request.method} id=${request.id}") when (method) { CheckoutProtocol.ready.method -> requestId?.let { handleClientMessage(method, message) } - CheckoutProtocol.windowOpen.method -> requestId?.let { handleWindowOpenRequest(message) } - CheckoutProtocol.start.method -> handleStart(message) - CheckoutProtocol.complete.method -> handleComplete(message) + CheckoutProtocol.windowOpen.method -> requestId?.let { handleWindowOpenRequest(message, adapter) } + CheckoutProtocol.start.method -> handleStart(message, adapter) + CheckoutProtocol.complete.method -> handleComplete(message, adapter) CheckoutProtocol.error.method -> handleTerminalError(message, request.params) null -> handleUnsupportedOrMalformedTerminalError( method = request.method, @@ -158,7 +164,10 @@ internal class EmbeddedCheckoutProtocolBridge( params = request.params, requestId = requestId, ) - else -> handleClientMessage(method, message) + else -> { + adapter?.process(message) + handleClientMessage(method, message) + } } } catch (e: SerializationException) { log.d(LOG_TAG, "Failed to decode ECP message: $e raw=$message") @@ -192,17 +201,30 @@ internal class EmbeddedCheckoutProtocolBridge( } } - private fun handleStart(message: String) { + private fun handleStart(message: String, adapter: CheckoutEventAdapter?) { log.d(LOG_TAG, "Handling ${CheckoutProtocol.start.method}: hiding progress bar and bubbling up.") onMainThread { view.listener.onCheckoutViewLoadComplete() } + adapter?.process(message) composedClient.process(message) } - private fun handleComplete(message: String) { + private fun handleComplete(message: String, adapter: CheckoutEventAdapter?) { log.d(LOG_TAG, "Handling ${CheckoutProtocol.complete.method}: bubbling up.") - composedClient.process(message) + var wasPresented = false + onMainThread { wasPresented = view.isPresented } + adapter?.process(message) + val completionClient = CheckoutProtocol.Client().on(CheckoutProtocol.complete) { + // The callback may remove its presentation before requesting a replacement preload. + CheckoutWebView.evictForCompletion(view, wasPresented) + } + processWithDefault( + message, + CheckoutProtocol.complete.method, + completionClient, + DefaultClientPolicy.AlwaysRunAfterMerchant + ) } /** @@ -211,13 +233,32 @@ internal class EmbeddedCheckoutProtocolBridge( * Tries the merchant's [client] first — if they registered a handler via * `.on(CheckoutProtocol.windowOpen) { ... }`, their response wins. Otherwise * falls back to the kit-owned [defaultClient], which launches web URLs in a - * Custom Tab and other URLs via `Intent.ACTION_VIEW` (see [defaultDelegationClient]). + * Custom Tab and other URLs via `Intent.ACTION_VIEW` (see [windowOpenClient]). */ - private fun handleWindowOpenRequest(message: String) { + private fun handleWindowOpenRequest(message: String, adapter: CheckoutEventAdapter?) { log.d(LOG_TAG, "Handling ${CheckoutProtocol.windowOpen.method}") - composedClient.process(message)?.let { sendRaw(it) } + processWithDefault( + message, + CheckoutProtocol.windowOpen.method, + windowOpenClient(adapter), + DefaultClientPolicy.RunIfUnhandled + ) + ?.let { sendRaw(it) } } + private fun processWithDefault( + message: String, + method: String, + defaultClient: CheckoutProtocol.Client, + policy: DefaultClientPolicy, + ): String? = + ComposedCheckoutProtocolClient( + merchant = client?.withDecodeErrorObserver(::recordParamsDecodeErrorOnce), + defaults = mapOf( + method to DefaultClientBinding(defaultClient.withDecodeErrorObserver(::recordParamsDecodeErrorOnce), policy), + ), + ).process(message) + /** Dispatch a supported protocol message through the consumer client. */ private fun handleClientMessage(method: String, message: String) { log.d(LOG_TAG, "Delegating $method to client.") @@ -246,6 +287,7 @@ internal class EmbeddedCheckoutProtocolBridge( return@onMainThread } view.hasHandledTerminalFailure = true + invalidateEventAdapter() // `ec.error` denotes a terminal session error. Message severity selects the public // lifecycle code, but does not keep the embedded session alive. @@ -289,12 +331,7 @@ internal class EmbeddedCheckoutProtocolBridge( } /** - * Kit-owned client that handles delegations and kit-mandated notifications, - * mirroring Swift's `defaultsClient`. Currently: - * - [CheckoutProtocol.windowOpen] - launches web URIs in a Custom Tab and other URIs - * via `Intent.ACTION_VIEW`, or returns [windowOpenRejected] with - * `window_open_rejected_error` semantics. - * - [CheckoutProtocol.complete] - evicts any cached preload state. + * Kit-owned client that acknowledges the ready handshake. * * Terminal `ec.error` is delivered to consumer protocol handlers before its separate * lifecycle failure mapping. @@ -313,15 +350,24 @@ internal class EmbeddedCheckoutProtocolBridge( ), ) } - .on(CheckoutProtocol.complete) { - CheckoutWebView.invalidate() - } + + /** Capture the requesting presentation so a queued link cannot invoke a later host's callback. */ + private fun windowOpenClient(adapter: CheckoutEventAdapter?): CheckoutProtocol.Client = + CheckoutProtocol.Client() .on(CheckoutProtocol.windowOpen) { request -> val url = request.url if (url.isBlank() || runCatching { URI(url) }.isFailure) { log.d(LOG_TAG, "window.open rejected: malformed URL ${url.redactedUrlForLogging()}") return@on windowOpenRejected(reason = "malformed URL") } + if (view.hasHandledTerminalFailure) { + return@on windowOpenRejected(reason = "checkout session ended") + } + when (adapter?.actionForLink(CheckoutLink(url.toUri())) ?: CheckoutLinkAction.Open) { + CheckoutLinkAction.Handled -> return@on windowOpenSuccess() + CheckoutLinkAction.Cancel -> return@on windowOpenRejected(reason = "link opening canceled") + CheckoutLinkAction.Open -> Unit + } when (val result = ExternalUriLauncher.launch(view.context, url.toUri())) { is ExternalUriLauncher.Result.Launched -> windowOpenSuccess() is ExternalUriLauncher.Result.Rejected -> { diff --git a/platforms/android/lib/src/main/java/com/shopify/checkoutkit/PreloadCache.kt b/platforms/android/lib/src/main/java/com/shopify/checkoutkit/PreloadCache.kt index 61322ab06..787b5ae18 100644 --- a/platforms/android/lib/src/main/java/com/shopify/checkoutkit/PreloadCache.kt +++ b/platforms/android/lib/src/main/java/com/shopify/checkoutkit/PreloadCache.kt @@ -50,6 +50,9 @@ internal class PreloadCache( val hasEntry: Boolean get() = entry != null + internal val cachedKey: PreloadKey? + get() = entry?.key + fun setObserver(observer: CheckoutPreload) { this.observer = observer } diff --git a/platforms/android/lib/src/main/java/com/shopify/checkoutkit/ShopifyCheckout.kt b/platforms/android/lib/src/main/java/com/shopify/checkoutkit/ShopifyCheckout.kt index d6bca7d02..84f193335 100644 --- a/platforms/android/lib/src/main/java/com/shopify/checkoutkit/ShopifyCheckout.kt +++ b/platforms/android/lib/src/main/java/com/shopify/checkoutkit/ShopifyCheckout.kt @@ -54,27 +54,25 @@ public class ShopifyCheckout @MainThread internal constructor( ) : FrameLayout(context) { /** - * Creates checkout content with a listener and optional typed protocol client. + * Creates checkout content with a listener for checkout events and browser hooks. * * Initialization failures are reported on the main thread after this constructor returns. The * returned view remains inert when initialization fails. */ @MainThread - @JvmOverloads public constructor( context: Context, checkoutUrl: String, checkoutListener: DefaultCheckoutListener, - protocolClient: CheckoutProtocol.Client? = null, ) : this( context = context, checkoutUrl = checkoutUrl, webMessageTransport = WebMessageListenerTransport, hostConfiguration = CheckoutHostConfiguration( listener = checkoutListener, - protocolClient = protocolClient, + protocolClient = null, onDismissRequest = checkoutListener::onCheckoutDismissed, - onFailure = checkoutListener::onCheckoutFailed, + onFailure = { checkoutListener.onCheckoutFailed(CheckoutFailureEvent(it)) }, reportInitializationFailure = true, ), ) @@ -307,7 +305,10 @@ public class ShopifyCheckout @MainThread internal constructor( private fun webViewListener(): CheckoutWebViewListener = CheckoutWebViewListener( listener = hostConfiguration.listener, - closeCheckoutWithError = hostConfiguration.onFailure, + closeCheckoutWithError = { error -> + checkoutWebView?.endPresentationEvents() + hostConfiguration.onFailure(error) + }, setProgressBarVisibility = { progressBar.visibility = it }, hideLoadingBackground = ::hideLoadingBackground, updateProgressBarPercentage = { percentage -> progressBar.setProgressCompat(percentage) }, @@ -347,7 +348,7 @@ public class ShopifyCheckout @MainThread internal constructor( /** * Creates checkout content using the Kotlin presentation builder. * - * Callbacks and the connected protocol client are fixed for the lifetime of this view. + * Callbacks are fixed for the lifetime of this view. * Initialization failures are reported through the configured `onFail` callback on the * main thread after this function returns. The returned view remains inert when * initialization fails. @@ -398,9 +399,9 @@ private fun buildCheckoutHostConfiguration( val listener = presentation.buildListener() return CheckoutHostConfiguration( listener = listener, - protocolClient = presentation.protocolClient, + protocolClient = null, onDismissRequest = listener::onCheckoutDismissed, - onFailure = listener::onCheckoutFailed, + onFailure = { listener.onCheckoutFailed(CheckoutFailureEvent(it)) }, reportInitializationFailure = true, ) } diff --git a/platforms/android/lib/src/main/java/com/shopify/checkoutkit/ShopifyCheckoutKit.kt b/platforms/android/lib/src/main/java/com/shopify/checkoutkit/ShopifyCheckoutKit.kt index c1f79394b..e0d8f8af2 100644 --- a/platforms/android/lib/src/main/java/com/shopify/checkoutkit/ShopifyCheckoutKit.kt +++ b/platforms/android/lib/src/main/java/com/shopify/checkoutkit/ShopifyCheckoutKit.kt @@ -130,8 +130,7 @@ public object ShopifyCheckoutKit { * * @param checkoutUrl The URL of the checkout to be presented, this can be obtained via the Storefront API * @param context The context the checkout is being presented from - * @param configure a Kotlin-first builder for fail/dismiss callbacks, browser/system hooks, - * and an optional typed protocol client + * @param configure a Kotlin-first builder for checkout events and browser/system hooks * @return A [CheckoutHandle] if the sheet was successfully created and displayed. */ @JvmStatic @@ -146,14 +145,13 @@ public object ShopifyCheckoutKit { checkoutUrl = checkoutUrl, context = context, checkoutListener = presentation.buildListener(), - protocolClient = presentation.protocolClient, ) } /** * Internal Kotlin presentation entry point that allows [webMessageTransport] to be injected. * - * Builds the callbacks and protocol client from [configure], then delegates to the core + * Builds the callbacks from [configure], then delegates to the core * presentation path. */ internal fun present( @@ -167,7 +165,6 @@ public object ShopifyCheckoutKit { checkoutUrl = checkoutUrl, context = context, checkoutListener = presentation.buildListener(), - protocolClient = presentation.protocolClient, webMessageTransport = webMessageTransport, ) } @@ -178,26 +175,19 @@ public object ShopifyCheckoutKit { * @param checkoutUrl The URL of the checkout to be presented, this can be obtained via the Storefront API * @param context The context the checkout is being presented from * @param checkoutListener provides callbacks to allow clients to listen for and respond to checkout lifecycle events - * (failure, dismissal, permission prompts, file chooser). - * @param protocolClient optional typed handler for supported Embedded Checkout Protocol (ECP) - * callbacks from the checkout web page. Built-in messages - * (`ec.ready` and [ec.start][CheckoutProtocol.start]) - * are handled automatically by the SDK. + * (start, updates, completion, failure, dismissal, links, permission prompts, file chooser). * @return A [CheckoutHandle] if the sheet was successfully created and displayed. */ - @JvmOverloads @JvmStatic public fun present( checkoutUrl: String, context: ComponentActivity, checkoutListener: T, - protocolClient: CheckoutProtocol.Client? = null, ): CheckoutHandle? { return present( checkoutUrl = checkoutUrl, context = context, checkoutListener = checkoutListener, - protocolClient = protocolClient, webMessageTransport = WebMessageListenerTransport, ) } diff --git a/platforms/android/lib/src/test/java/com/shopify/checkoutkit/CheckoutBottomSheetOptionsTest.kt b/platforms/android/lib/src/test/java/com/shopify/checkoutkit/CheckoutBottomSheetOptionsTest.kt index 26af0e763..30286c8cc 100644 --- a/platforms/android/lib/src/test/java/com/shopify/checkoutkit/CheckoutBottomSheetOptionsTest.kt +++ b/platforms/android/lib/src/test/java/com/shopify/checkoutkit/CheckoutBottomSheetOptionsTest.kt @@ -316,7 +316,7 @@ class CheckoutBottomSheetOptionsTest { private class RecordingCheckoutListener : DefaultCheckoutListener() { var dismissed = false - override fun onCheckoutFailed(error: CheckoutException) = Unit + override fun onCheckoutFailed(event: CheckoutFailureEvent) = Unit override fun onCheckoutDismissed() { dismissed = true diff --git a/platforms/android/lib/src/test/java/com/shopify/checkoutkit/CheckoutBottomSheetTest.kt b/platforms/android/lib/src/test/java/com/shopify/checkoutkit/CheckoutBottomSheetTest.kt index eb2516e43..1b0cfa761 100644 --- a/platforms/android/lib/src/test/java/com/shopify/checkoutkit/CheckoutBottomSheetTest.kt +++ b/platforms/android/lib/src/test/java/com/shopify/checkoutkit/CheckoutBottomSheetTest.kt @@ -28,6 +28,7 @@ import org.junit.Before import org.junit.Test import org.junit.runner.RunWith import org.mockito.kotlin.any +import org.mockito.kotlin.argumentCaptor import org.mockito.kotlin.mock import org.mockito.kotlin.never import org.mockito.kotlin.timeout @@ -695,13 +696,11 @@ class CheckoutBottomSheetTest { } @Test - fun `present connects protocol client to checkout WebView bridge`() { + fun `present delivers checkout updates without a protocol client`() { var received = false - val client = CheckoutProtocol.Client() - .on(CheckoutProtocol.messagesChange) { received = true } ShopifyCheckoutKit.present("https://shopify.com", activity, webMessageTransport) { - connect(client) + onUpdate { received = true } } webMessageTransport.dispatchMessage(ecMessagesChangeMessage()) @@ -736,7 +735,9 @@ class CheckoutBottomSheetTest { runDismissAnimation() verify(mockListener, never()).onCheckoutDismissed() - verify(mockListener).onCheckoutFailed(error) + val captor = argumentCaptor() + verify(mockListener).onCheckoutFailed(captor.capture()) + assertThat(captor.firstValue.error).isSameAs(error) assertThat(checkoutSheet.isShowing).isFalse() } diff --git a/platforms/android/lib/src/test/java/com/shopify/checkoutkit/CheckoutCompletionCacheTest.kt b/platforms/android/lib/src/test/java/com/shopify/checkoutkit/CheckoutCompletionCacheTest.kt new file mode 100644 index 000000000..6299abc64 --- /dev/null +++ b/platforms/android/lib/src/test/java/com/shopify/checkoutkit/CheckoutCompletionCacheTest.kt @@ -0,0 +1,193 @@ +package com.shopify.checkoutkit + +import android.os.Looper +import androidx.activity.ComponentActivity +import org.assertj.core.api.Assertions.assertThat +import org.junit.After +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.Robolectric +import org.robolectric.RobolectricTestRunner +import org.robolectric.Shadows.shadowOf +import java.util.concurrent.Executor + +@RunWith(RobolectricTestRunner::class) +class CheckoutCompletionCacheTest { + private lateinit var activity: ComponentActivity + private lateinit var initialConfiguration: Configuration + private val views = mutableListOf() + private val checkoutUrl = "https://example.com/cart/checkout-example" + private val nextCheckoutUrl = "https://example.com/cart/next-checkout-example" + + @Before + fun setUp() { + CheckoutTelemetry.overrideRecorderForTesting(NoOpTestCheckoutTelemetryRecorder) + initialConfiguration = ShopifyCheckoutKit.getConfiguration() + CheckoutWebView.clearCache() + shadowOf(Looper.getMainLooper()).idle() + ShopifyCheckoutKit.configure { it.preloading = Preloading(enabled = true) } + activity = Robolectric.buildActivity(ComponentActivity::class.java).setup().get() + } + + @After + fun tearDown() { + CheckoutWebView.clearCache() + views.distinct().forEach { view -> + if (!shadowOf(view).wasDestroyCalled()) { + view.markDismissed() + view.destroy() + } + } + shadowOf(Looper.getMainLooper()).idle() + ShopifyCheckoutKit.configure { it.preloading = initialConfiguration.preloading } + CheckoutTelemetry.overrideRecorderForTesting(null) + } + + @Test + fun `completion preserves a different checkout preloaded by the lifecycle callback`() { + val completed = presentCheckout() + var nextPreload: CheckoutWebView? = null + val bridge = bridge(completed) { nextPreload = preload(nextCheckoutUrl) } + + bridge.receiveMessage(completionMessage) + shadowOf(Looper.getMainLooper()).idle() + + assertThat(nextPreload).isNotNull() + assertThat(CheckoutWebView.cachedPreloadViewForTesting()).isSameAs(nextPreload) + assertThat(shadowOf(requireNotNull(nextPreload)).wasDestroyCalled()).isFalse() + } + + @Test + fun `completion evicts a same-checkout replacement preloaded by the lifecycle callback`() { + val completed = presentCheckout() + var replacement: CheckoutWebView? = null + val bridge = bridge(completed) { replacement = preload(checkoutUrl) } + + bridge.receiveMessage(completionMessage) + shadowOf(Looper.getMainLooper()).idle() + + assertThat(replacement).isNotNull() + assertThat(CheckoutWebView.cachedPreloadViewForTesting()).isNull() + assertThat(shadowOf(requireNotNull(replacement)).wasDestroyCalled()).isTrue() + assertThat(shadowOf(completed).wasDestroyCalled()).isFalse() + } + + @Test + fun `completion clears a cached presented checkout without destroying its presentation`() { + val cached = preload(checkoutUrl) + val completed = presentCheckout() + assertThat(completed).isSameAs(cached) + + bridge(completed).receiveMessage(completionMessage) + shadowOf(Looper.getMainLooper()).idle() + + assertThat(CheckoutWebView.cachedPreloadViewForTesting()).isNull() + assertThat(shadowOf(completed).wasDestroyCalled()).isFalse() + } + + @Test + fun `completion evicts same-checkout replacement when callback tears down the presentation first`() { + val completed = presentCheckout() + var replacement: CheckoutWebView? = null + val bridge = bridge(completed) { + CheckoutWebView.discardAfterPresentation(completed) + completed.destroy() + replacement = preload(checkoutUrl) + } + + bridge.receiveMessage(completionMessage) + shadowOf(Looper.getMainLooper()).idle() + + assertThat(replacement).isNotNull() + assertThat(CheckoutWebView.cachedPreloadViewForTesting()).isNull() + assertThat(shadowOf(requireNotNull(replacement)).wasDestroyCalled()).isTrue() + } + + @Test + fun `completion destroys its own background preload`() { + val completed = preload(checkoutUrl) + + CheckoutWebView.evictForCompletion(completed) + + assertThat(CheckoutWebView.cachedPreloadViewForTesting()).isNull() + assertThat(shadowOf(completed).wasDestroyCalled()).isTrue() + } + + @Test + fun `old background completion cannot evict a newer different checkout`() { + val completed = preload(checkoutUrl) + val newer = preload(nextCheckoutUrl) + + CheckoutWebView.evictForCompletion(completed) + + assertThat(CheckoutWebView.cachedPreloadViewForTesting()).isSameAs(newer) + assertThat(shadowOf(newer).wasDestroyCalled()).isFalse() + } + + @Test + fun `old background completion cannot evict a newer same-checkout preload`() { + val completed = preload(checkoutUrl) + val newer = preload(checkoutUrl) + + CheckoutWebView.evictForCompletion(completed) + + assertThat(CheckoutWebView.cachedPreloadViewForTesting()).isSameAs(newer) + assertThat(shadowOf(newer).wasDestroyCalled()).isFalse() + } + + @Test + fun `completion matches the original checkout after web navigation`() { + val completed = presentCheckout() + completed.loadUrl("https://example.com/order/order-example") + val replacement = preload(checkoutUrl) + + CheckoutWebView.evictForCompletion(completed) + + assertThat(CheckoutWebView.cachedPreloadViewForTesting()).isNull() + assertThat(shadowOf(replacement).wasDestroyCalled()).isTrue() + } + + private fun presentCheckout(): CheckoutWebView = + CheckoutWebView.checkoutViewFor(checkoutUrl, activity, FakeWebMessageTransport()).also { + it.markPresented() + views.add(it) + shadowOf(Looper.getMainLooper()).idle() + } + + private fun preload(url: String): CheckoutWebView { + CheckoutWebView.preload(url, activity, FakeWebMessageTransport()) + shadowOf(Looper.getMainLooper()).idle() + return requireNotNull(CheckoutWebView.cachedPreloadViewForTesting()).also(views::add) + } + + private fun bridge(view: CheckoutWebView, onComplete: () -> Unit = {}): EmbeddedCheckoutProtocolBridge { + val listener = CheckoutWebViewListener(object : DefaultCheckoutListener() { + override fun onCheckoutFailed(event: CheckoutFailureEvent) = Unit + + override fun onCheckoutDismissed() = Unit + + override fun onCheckoutCompleted(event: CheckoutCompleteEvent) { + onComplete() + } + }) + view.setListener(listener) + return EmbeddedCheckoutProtocolBridge( + view, + FakeWebMessageTransport(), + protocolMessageExecutor = Executor { it.run() }, + ).also { it.setPresentationListener(listener) } + } + + private val completionMessage = """ + { + "jsonrpc": "2.0", + "method": "ec.complete", + "params": {"checkout": { + "id": "checkout-example", "currency": "USD", "status": "completed", + "line_items": [], "links": [], "totals": [], + "ucp": {"payment_handlers": {}, "version": "2026-04-08"} + }} + } + """.trimIndent() +} diff --git a/platforms/android/lib/src/test/java/com/shopify/checkoutkit/CheckoutEventAdapterTest.kt b/platforms/android/lib/src/test/java/com/shopify/checkoutkit/CheckoutEventAdapterTest.kt new file mode 100644 index 000000000..47f59fc21 --- /dev/null +++ b/platforms/android/lib/src/test/java/com/shopify/checkoutkit/CheckoutEventAdapterTest.kt @@ -0,0 +1,139 @@ +package com.shopify.checkoutkit + +import android.os.Looper +import org.assertj.core.api.Assertions.assertThat +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.Shadows.shadowOf +import java.util.concurrent.Executors + +@RunWith(RobolectricTestRunner::class) +class CheckoutEventAdapterTest { + private val started = mutableListOf() + private val updated = mutableListOf() + private val completed = mutableListOf() + private val listener = CheckoutWebViewListener(object : DefaultCheckoutListener() { + override fun onCheckoutFailed(event: CheckoutFailureEvent) = Unit + + override fun onCheckoutDismissed() = Unit + + override fun onCheckoutStarted(event: CheckoutStartEvent) { + started.add(event) + } + + override fun onCheckoutUpdated(event: CheckoutUpdateEvent) { + updated.add(event) + } + + override fun onCheckoutCompleted(event: CheckoutCompleteEvent) { + completed.add(event) + } + }) + + @Test + fun `start projects checkout and all supported changes produce full snapshots`() { + val adapter = CheckoutEventAdapter(listener) + adapter.process(message("ec.start")) + listOf("line_items", "messages", "totals", "fulfillment").forEachIndexed { index, source -> + adapter.process(message("ec.$source.change", total = index + 1)) + } + + assertThat(started.single().checkout.id).isEqualTo("checkout-1") + assertThat(updated).hasSize(4) + assertThat(updated.map { it.checkout.totals.single().amount }).containsExactly(1L, 2L, 3L, 4L) + } + + @Test + fun `equal updates across sources and ucp-only changes are suppressed`() { + val adapter = CheckoutEventAdapter(listener) + adapter.process(message("ec.start")) + adapter.process(message("ec.messages.change")) + adapter.process(message("ec.totals.change", version = "2099-01-01")) + adapter.process(message("ec.fulfillment.change", total = 1)) + adapter.process(message("ec.line_items.change", total = 1)) + + assertThat(updated).hasSize(1) + } + + @Test + fun `start and complete always emit and update the comparison state`() { + val adapter = CheckoutEventAdapter(listener) + repeat(2) { adapter.process(message("ec.start")) } + repeat(2) { adapter.process(message("ec.complete", total = 1)) } + adapter.process(message("ec.totals.change", total = 1)) + + assertThat(started).hasSize(2) + assertThat(completed).hasSize(2) + assertThat(updated).isEmpty() + } + + @Test + fun `unknown extensions participate in deduplication without derived line item state`() { + val adapter = CheckoutEventAdapter(listener) + adapter.process(message("ec.messages.change", extension = "first")) + adapter.process(message("ec.messages.change", extension = "second")) + + assertThat(updated).hasSize(2) + assertThat(updated.last().checkout.lineItems).isEmpty() + assertThat(updated.last().checkout.additionalProperties).containsKey("com.example.extension") + } + + @Test + fun `unsupported buyer and payment changes do not emit`() { + val adapter = CheckoutEventAdapter(listener) + adapter.process(message("ec.buyer.change")) + adapter.process(message("ec.payment.change")) + + assertThat(updated).isEmpty() + } + + @Test + fun `invalid snapshots report decode errors without emitting`() { + val failures = mutableListOf() + val adapter = CheckoutEventAdapter(listener, failures::add) + adapter.process("""{"jsonrpc":"2.0","method":"ec.start","params":{"checkout":{}}}""") + + assertThat(failures).containsExactly("ec.start") + assertThat(started).isEmpty() + } + + @Test + fun `invalidating a presentation drops callbacks queued on the main thread`() { + val adapter = CheckoutEventAdapter(listener) + val executor = Executors.newSingleThreadExecutor() + try { + executor.submit { adapter.process(message("ec.start")) }.get() + adapter.invalidate() + shadowOf(Looper.getMainLooper()).idle() + + assertThat(started).isEmpty() + } finally { + executor.shutdownNow() + } + } + + @Test + fun `each presentation has fresh comparison state`() { + val first = CheckoutEventAdapter(listener) + first.process(message("ec.messages.change")) + first.invalidate() + CheckoutEventAdapter(listener).process(message("ec.messages.change")) + + assertThat(updated).hasSize(2) + } + + private fun message( + method: String, + total: Int = 0, + version: String = CheckoutProtocol.SPEC_VERSION, + extension: String = "value", + ): String = """{ + "jsonrpc":"2.0","method":"$method","params":{"checkout":{ + "id":"checkout-1","currency":"USD","status":"incomplete","line_items":[],"links":[], + "messages":[{"type":"error","code":"out_of_stock","content":"Unavailable"}], + "totals":[{"type":"total","amount":$total}],"com.example.extension":"$extension", + "ucp":{"payment_handlers":{},"version":"$version"} + }} + }""" +} diff --git a/platforms/android/lib/src/test/java/com/shopify/checkoutkit/CheckoutEventsIntegrationTest.kt b/platforms/android/lib/src/test/java/com/shopify/checkoutkit/CheckoutEventsIntegrationTest.kt new file mode 100644 index 000000000..adac77290 --- /dev/null +++ b/platforms/android/lib/src/test/java/com/shopify/checkoutkit/CheckoutEventsIntegrationTest.kt @@ -0,0 +1,153 @@ +package com.shopify.checkoutkit + +import android.os.Looper +import androidx.activity.ComponentActivity +import org.assertj.core.api.Assertions.assertThat +import org.junit.After +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.Robolectric +import org.robolectric.RobolectricTestRunner +import org.robolectric.Shadows.shadowOf +import java.util.concurrent.Executor +import java.util.concurrent.Executors + +@RunWith(RobolectricTestRunner::class) +class CheckoutEventsIntegrationTest { + private lateinit var view: CheckoutWebView + private val transport = FakeWebMessageTransport() + private val events = mutableListOf() + private val listener = CheckoutWebViewListener(object : DefaultCheckoutListener() { + override fun onCheckoutFailed(event: CheckoutFailureEvent) = Unit + + override fun onCheckoutDismissed() = Unit + + override fun onCheckoutStarted(event: CheckoutStartEvent) { + assertThat(Looper.myLooper()).isSameAs(Looper.getMainLooper()) + events.add("start") + } + + override fun onCheckoutUpdated(event: CheckoutUpdateEvent) { + assertThat(Looper.myLooper()).isSameAs(Looper.getMainLooper()) + events.add("update") + } + + override fun onCheckoutCompleted(event: CheckoutCompleteEvent) { + assertThat(Looper.myLooper()).isSameAs(Looper.getMainLooper()) + events.add("complete") + } + }) + + @Before + fun setUp() { + CheckoutTelemetry.overrideRecorderForTesting(NoOpTestCheckoutTelemetryRecorder) + val activity = Robolectric.buildActivity(ComponentActivity::class.java).setup().get() + view = CheckoutWebView(activity, transport) + } + + @After + fun tearDown() { + view.destroy() + CheckoutWebView.clearCache() + shadowOf(Looper.getMainLooper()).idle() + CheckoutTelemetry.overrideRecorderForTesting(null) + } + + @Test + fun `bridge delivers ordered main-thread events without a consumer protocol client`() { + val executor = Executors.newSingleThreadExecutor() + val bridge = EmbeddedCheckoutProtocolBridge(view, transport, protocolMessageExecutor = executor) + bridge.setPresentationListener(listener) + try { + bridge.receiveMessage(message("ec.start")) + bridge.receiveMessage(message("ec.totals.change", total = 1)) + bridge.receiveMessage(message("ec.complete", total = 1)) + executor.submit {}.get() + shadowOf(Looper.getMainLooper()).idle() + + assertThat(events).containsExactly("start", "update", "complete") + assertThat(transport.sentMessages).isEmpty() + } finally { + executor.shutdownNow() + } + } + + @Test + fun `queued old presentation messages do not reach a rebound listener`() { + val commands = mutableListOf() + val bridge = EmbeddedCheckoutProtocolBridge(view, transport, protocolMessageExecutor = Executor(commands::add)) + bridge.setPresentationListener(listener) + bridge.receiveMessage(message("ec.start")) + bridge.setPresentationListener(CheckoutWebViewListener(NoopCheckoutListener())) + bridge.setPresentationListener(listener) + commands.removeAt(0).run() + bridge.receiveMessage(message("ec.messages.change")) + commands.removeAt(0).run() + + assertThat(events).containsExactly("update") + } + + @Test + fun `messages received while preloading are not replayed on presentation`() { + val commands = mutableListOf() + val bridge = EmbeddedCheckoutProtocolBridge(view, transport, protocolMessageExecutor = Executor(commands::add)) + bridge.receiveMessage(message("ec.start")) + bridge.setPresentationListener(listener) + commands.removeAt(0).run() + + assertThat(events).isEmpty() + } + + @Test + fun `terminal failure suppresses subsequently queued lifecycle events`() { + val executor = Executors.newSingleThreadExecutor() + val bridge = EmbeddedCheckoutProtocolBridge(view, transport, protocolMessageExecutor = executor) + bridge.setPresentationListener(listener) + try { + bridge.receiveMessage("""{"jsonrpc":"2.0","method":"ec.error","params":{}}""") + bridge.receiveMessage(message("ec.start")) + bridge.receiveMessage(message("ec.messages.change")) + bridge.receiveMessage(message("ec.complete")) + executor.submit {}.get() + shadowOf(Looper.getMainLooper()).idle() + + assertThat(view.hasHandledTerminalFailure).isTrue() + assertThat(events).isEmpty() + } finally { + executor.shutdownNow() + } + } + + @Test + fun `queued links are rejected instead of reaching a subsequent presentation`() { + val commands = mutableListOf() + val bridge = EmbeddedCheckoutProtocolBridge(view, transport, protocolMessageExecutor = Executor(commands::add)) + val linkListener = CheckoutWebViewListener(object : DefaultCheckoutListener() { + override fun onCheckoutFailed(event: CheckoutFailureEvent) = Unit + override fun onCheckoutDismissed() = Unit + override fun onCheckoutLinkClicked(link: CheckoutLink): CheckoutLinkAction { + events.add("link") + return CheckoutLinkAction.Handled + } + }) + bridge.setPresentationListener(linkListener) + bridge.receiveMessage( + """{"jsonrpc":"2.0","id":"link","method":"ec.window.open_request","params":{"url":"https://example.com"}}""", + ) + bridge.setPresentationListener(CheckoutWebViewListener(NoopCheckoutListener())) + bridge.setPresentationListener(linkListener) + commands.removeAt(0).run() + + assertThat(events).isEmpty() + assertThat(transport.sentMessages.single().message).contains("window_open_rejected_error") + } + + private fun message(method: String, total: Int = 0): String = """{ + "jsonrpc":"2.0","method":"$method","params":{"checkout":{ + "id":"checkout-1","currency":"USD","status":"incomplete","line_items":[],"links":[], + "totals":[{"type":"total","amount":$total}], + "ucp":{"payment_handlers":{},"version":"${CheckoutProtocol.SPEC_VERSION}"} + }} + }""" +} diff --git a/platforms/android/lib/src/test/java/com/shopify/checkoutkit/CheckoutExceptionAssert.kt b/platforms/android/lib/src/test/java/com/shopify/checkoutkit/CheckoutExceptionAssert.kt index 29195587f..99d914f0c 100644 --- a/platforms/android/lib/src/test/java/com/shopify/checkoutkit/CheckoutExceptionAssert.kt +++ b/platforms/android/lib/src/test/java/com/shopify/checkoutkit/CheckoutExceptionAssert.kt @@ -44,7 +44,7 @@ class CheckoutExceptionAssert(actual: CheckoutException) : } fun noopDefaultCheckoutListener(): DefaultCheckoutListener = object : DefaultCheckoutListener() { - override fun onCheckoutFailed(error: CheckoutException) { + override fun onCheckoutFailed(event: CheckoutFailureEvent) { // no-op } diff --git a/platforms/android/lib/src/test/java/com/shopify/checkoutkit/CheckoutPresentationTest.kt b/platforms/android/lib/src/test/java/com/shopify/checkoutkit/CheckoutPresentationTest.kt index 8560b2686..c659d1508 100644 --- a/platforms/android/lib/src/test/java/com/shopify/checkoutkit/CheckoutPresentationTest.kt +++ b/platforms/android/lib/src/test/java/com/shopify/checkoutkit/CheckoutPresentationTest.kt @@ -1,7 +1,6 @@ package com.shopify.checkoutkit import android.net.Uri -import android.os.Looper import android.webkit.GeolocationPermissions import android.webkit.PermissionRequest import android.webkit.ValueCallback @@ -12,23 +11,24 @@ import org.junit.Test import org.junit.runner.RunWith import org.mockito.kotlin.mock import org.robolectric.RobolectricTestRunner -import org.robolectric.Shadows.shadowOf @RunWith(RobolectricTestRunner::class) class CheckoutPresentationTest { @Test fun `present builder invokes onFail callback`() { - var received: CheckoutException? = null + var received: CheckoutFailureEvent? = null val listener = listener { onFail { received = it } } val error = CheckoutException(code = CheckoutErrorCode.SDK_ERROR, message = "boom") - listener.onCheckoutFailed(error) + val event = CheckoutFailureEvent(error) + listener.onCheckoutFailed(event) - assertThat(received).isSameAs(error) + assertThat(received).isSameAs(event) + assertThat(received!!.error).isSameAs(error) } @Test @@ -44,19 +44,38 @@ class CheckoutPresentationTest { } @Test - fun `present builder stores connected client`() { - var received = false - val client = CheckoutProtocol.Client() - .on(CheckoutProtocol.messagesChange) { received = true } - - val presentation = presentation { - connect(client) + fun `present builder forwards typed checkout events`() { + val checkout = mock() + val start = CheckoutStartEvent(checkout) + val update = CheckoutUpdateEvent(checkout) + val complete = CheckoutCompleteEvent(checkout) + val received = mutableListOf() + val listener = listener { + onStart { received.add(it) } + onUpdate { received.add(it) } + onComplete { received.add(it) } } - presentation.protocolClient?.process(ecMessagesChangeMessage()) - shadowOf(Looper.getMainLooper()).runToEndOfTasks() + listener.onCheckoutStarted(start) + listener.onCheckoutUpdated(update) + listener.onCheckoutCompleted(complete) - assertThat(received).isTrue() + assertThat(received).containsExactly(start, update, complete) + } + + @Test + fun `present builder returns configured link action`() { + val link = CheckoutLink(Uri.parse("https://example.com/privacy")) + var received: CheckoutLink? = null + val listener = listener { + onLinkClick { + received = it + CheckoutLinkAction.Handled + } + } + + assertThat(listener.onCheckoutLinkClicked(link)).isEqualTo(CheckoutLinkAction.Handled) + assertThat(received).isSameAs(link) } @Test @@ -139,7 +158,13 @@ class CheckoutPresentationTest { fun `present builder with no callbacks is safe`() { val listener = listener {} - listener.onCheckoutFailed(CheckoutException(code = CheckoutErrorCode.SDK_ERROR, message = "boom")) + val checkout = mock() + listener.onCheckoutStarted(CheckoutStartEvent(checkout)) + listener.onCheckoutUpdated(CheckoutUpdateEvent(checkout)) + listener.onCheckoutCompleted(CheckoutCompleteEvent(checkout)) + listener.onCheckoutFailed( + CheckoutFailureEvent(CheckoutException(code = CheckoutErrorCode.SDK_ERROR, message = "boom")) + ) listener.onCheckoutDismissed() listener.onPermissionRequest(mock()) listener.onGeolocationPermissionsShowPrompt("origin", mock()) @@ -147,6 +172,8 @@ class CheckoutPresentationTest { val handled = listener.onShowFileChooser(mock(), mock(), mock()) assertThat(handled).isFalse() + assertThat(listener.onCheckoutLinkClicked(CheckoutLink(Uri.parse("https://example.com")))) + .isEqualTo(CheckoutLinkAction.Open) } private fun presentation(configure: CheckoutPresentation.() -> Unit): CheckoutPresentation = @@ -154,13 +181,4 @@ class CheckoutPresentationTest { private fun listener(configure: CheckoutPresentation.() -> Unit): DefaultCheckoutListener = presentation(configure).buildListener() - - private fun ecMessagesChangeMessage(): String = - """{"jsonrpc":"2.0","method":"ec.messages.change","params":{"checkout":$CHECKOUT_JSON}}""" - - private companion object { - private const val CHECKOUT_JSON = - """{"id":"chk1","currency":"USD","status":"incomplete","line_items":[],"totals":[],"links":[],"ucp":""" + - """{"payment_handlers":{},"version":"1.0"}}""" - } } diff --git a/platforms/android/lib/src/test/java/com/shopify/checkoutkit/CheckoutTest.kt b/platforms/android/lib/src/test/java/com/shopify/checkoutkit/CheckoutTest.kt new file mode 100644 index 000000000..17dba55eb --- /dev/null +++ b/platforms/android/lib/src/test/java/com/shopify/checkoutkit/CheckoutTest.kt @@ -0,0 +1,243 @@ +package com.shopify.checkoutkit + +import com.shopify.ucp.embedded.checkout.CheckoutDiscounts +import com.shopify.ucp.embedded.checkout.CheckoutFulfillment +import com.shopify.ucp.embedded.checkout.Payment +import kotlinx.serialization.SerializationException +import kotlinx.serialization.decodeFromString +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonNull +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.decodeFromJsonElement +import kotlinx.serialization.json.encodeToJsonElement +import kotlinx.serialization.json.jsonObject +import org.assertj.core.api.Assertions.assertThat +import org.assertj.core.api.Assertions.assertThatThrownBy +import org.junit.Test +import com.shopify.ucp.embedded.checkout.Checkout as ProtocolCheckout + +class CheckoutTest { + private val json = Json { ignoreUnknownKeys = true } + + @Test + fun `protocol projection preserves every domain field and nested extension`() { + val wire = json.parseToJsonElement(fullCheckout).jsonObject + val protocolCheckout = json.decodeFromJsonElement(wire) + + val checkout = Checkout.fromProtocol(protocolCheckout) + val encoded = json.encodeToJsonElement(checkout).jsonObject + + assertThat(encoded).isEqualTo(JsonObject(wire - "ucp")) + assertThat(encoded).doesNotContainKeys("ucp", "additionalProperties") + assertThat(encoded["line_items"]).isInstanceOf(JsonArray::class.java) + assertThat(checkout.lineItems).containsExactlyElementsOf(protocolCheckout.lineItems) + assertThat(checkout.additionalProperties["com.example.extension"]).isEqualTo(wire["com.example.extension"]) + } + + @Test + fun `snapshot serialization round trips without protocol metadata`() { + val checkout = Checkout.fromProtocol(json.decodeFromString(fullCheckout)) + + val encoded = json.encodeToString(checkout) + val decoded = json.decodeFromString(encoded) + + assertThat(decoded).isEqualTo(checkout) + assertThat(json.decodeFromString(fullCheckout)).isEqualTo(checkout) + } + + @Test + fun `empty optional collections remain distinct from absent values`() { + val protocolCheckout = json.decodeFromString(minimalCheckout) + val absent = Checkout.fromProtocol(protocolCheckout) + val empty = Checkout.fromProtocol( + protocolCheckout.copy( + attribution = emptyMap(), + discounts = CheckoutDiscounts(applied = emptyList(), codes = emptyList()), + fulfillment = CheckoutFulfillment(availableMethods = emptyList(), methods = emptyList()), + messages = emptyList(), + payment = Payment(instruments = emptyList()), + signals = JsonObject(emptyMap()), + ), + ) + + assertThat(absent.messages).isNull() + assertThat(empty.messages).isEmpty() + assertThat(empty).isNotEqualTo(absent) + assertThat(json.encodeToJsonElement(absent).jsonObject).doesNotContainKeys( + "attribution", + "discounts", + "fulfillment", + "messages", + "payment", + "signals", + ) + val encoded = json.encodeToJsonElement(empty).jsonObject + assertThat(encoded["attribution"]).isEqualTo(JsonObject(emptyMap())) + assertThat(encoded["signals"]).isEqualTo(JsonObject(emptyMap())) + assertThat(encoded["messages"]).isEqualTo(JsonArray(emptyList())) + assertThat(encoded["discounts"]?.jsonObject?.get("applied")).isEqualTo(JsonArray(emptyList())) + assertThat(encoded["fulfillment"]?.jsonObject?.get("methods")).isEqualTo(JsonArray(emptyList())) + assertThat(encoded["payment"]?.jsonObject?.get("instruments")).isEqualTo(JsonArray(emptyList())) + assertThat(json.decodeFromJsonElement(encoded)).isEqualTo(empty) + } + + @Test + fun `optional checkout nulls decode without becoming default values`() { + val fields = json.parseToJsonElement(minimalCheckout).jsonObject + val nullFields = listOf( + "attribution", "buyer", "context", "continue_url", "discounts", "expires_at", "fulfillment", + "messages", "order", "payment", "signals", + ).associateWith { JsonNull } + + val checkout = json.decodeFromJsonElement(JsonObject(fields + nullFields)) + + assertThat(checkout).isEqualTo(json.decodeFromJsonElement(fields)) + assertThat(json.encodeToJsonElement(checkout).jsonObject.keys).doesNotContainAnyElementsOf(nullFields.keys) + } + + @Test + fun `required checkout fields cannot be absent or null`() { + val fields = json.parseToJsonElement(minimalCheckout).jsonObject + + listOf("currency", "id", "line_items", "links", "status", "totals").forEach { key -> + assertThatThrownBy { + json.decodeFromJsonElement(JsonObject(fields - key)) + }.describedAs("Missing %s", key).isInstanceOf(SerializationException::class.java) + + assertThatThrownBy { + json.decodeFromJsonElement(JsonObject(fields + (key to JsonNull))) + }.describedAs("Null %s", key).isInstanceOf(SerializationException::class.java) + } + } + + @Test + fun `unknown checkout statuses are not coerced to known defaults`() { + val fields = json.parseToJsonElement(minimalCheckout).jsonObject + + assertThatThrownBy { + json.decodeFromJsonElement(JsonObject(fields + ("status" to JsonPrimitive("future_status")))) + }.isInstanceOf(SerializationException::class.java) + } + + @Test + fun `reserved extensions cannot override fields or restore protocol metadata`() { + val protocolCheckout = json.decodeFromString(minimalCheckout) + val extensions = mapOf( + "id" to JsonPrimitive("extension-checkout"), + "buyer" to JsonObject(mapOf("email" to JsonPrimitive("synthetic@example.com"))), + "line_items" to JsonObject(emptyMap()), + "ucp" to JsonObject(mapOf("version" to JsonPrimitive("future-version"))), + "com.example.empty" to JsonNull, + ) + + val projected = Checkout.fromProtocol(protocolCheckout.copy(additionalProperties = extensions)) + val constructed = projected.copy(additionalProperties = extensions) + val encoded = json.encodeToJsonElement(constructed).jsonObject + + assertThat(projected.additionalProperties).containsOnlyKeys("com.example.empty") + assertThat(encoded["id"]).isEqualTo(JsonPrimitive(protocolCheckout.id)) + assertThat(encoded["line_items"]).isEqualTo(JsonArray(emptyList())) + assertThat(encoded["com.example.empty"]).isEqualTo(JsonNull) + assertThat(encoded).doesNotContainKeys("buyer", "ucp") + assertThat(json.decodeFromJsonElement(encoded)).isEqualTo(projected) + } + + @Test + fun `snapshot equality includes nested values and extensions but excludes protocol metadata`() { + val protocolCheckout = json.decodeFromString(fullCheckout) + val checkout = Checkout.fromProtocol(protocolCheckout) + val newProtocolVersion = protocolCheckout.copy(ucp = protocolCheckout.ucp.copy(version = "2027-01-01")) + val newLineItem = protocolCheckout.lineItems.first().copy(quantity = 3) + val changedItems = protocolCheckout.copy(lineItems = listOf(newLineItem)) + val changedExtension = protocolCheckout.copy(additionalProperties = mapOf("com.example.extension" to JsonNull)) + + assertThat(Checkout.fromProtocol(newProtocolVersion)).isEqualTo(checkout) + assertThat(Checkout.fromProtocol(changedItems)).isNotEqualTo(checkout) + assertThat(Checkout.fromProtocol(changedExtension)).isNotEqualTo(checkout) + } + + private val minimalCheckout = """ + { + "id": "checkout-example", + "currency": "USD", + "line_items": [], + "links": [], + "status": "incomplete", + "totals": [], + "ucp": {"payment_handlers": {}, "version": "2026-04-08"} + } + """.trimIndent() + + private val fullCheckout = """ + { + "attribution": {"com.example.campaign": "example-campaign"}, + "buyer": { + "email": "synthetic@example.com", + "first_name": "Example", + "last_name": "Buyer", + "phone_number": "+12025550123", + "com.example.buyer": {"verified": true, "value": null} + }, + "context": {"language": "en", "currency": "USD", "com.example.context": [1, false, null]}, + "continue_url": "https://example.com/checkout", + "currency": "USD", + "discounts": { + "codes": ["EXAMPLE"], + "applied": [{"amount": 200, "title": "Example discount", "code": "EXAMPLE", "method": "across"}] + }, + "expires_at": "2026-09-14T12:00:00Z", + "fulfillment": { + "available_methods": [{"line_item_ids": ["line-example"], "type": "shipping", "com.example.stock": true}], + "methods": [{ + "id": "fulfillment-example", + "line_item_ids": ["line-example"], + "type": "shipping", + "com.example.fulfillment": {"value": null} + }] + }, + "id": "checkout-example", + "line_items": [{ + "id": "line-example", + "item": {"id": "item-example", "title": "Example item", "price": 1500, "image_url": "https://example.com/item.png"}, + "parent_id": "parent-example", + "quantity": 2, + "totals": [{"amount": 3000, "type": "subtotal", "display_text": "Subtotal"}] + }], + "links": [{"title": "Example policy", "type": "com.example.policy", "url": "https://example.com/policy"}], + "messages": [{ + "code": "example_message", + "content": "Example message", + "content_type": "plain", + "path": "$.line_items[0]", + "severity": "requires_buyer_review", + "type": "warning", + "image_url": "https://example.com/message.png", + "presentation": "disclosure", + "url": "https://example.com/message" + }], + "order": {"id": "order-example", "label": "Example order", "permalink_url": "https://example.com/order"}, + "payment": {"instruments": [{ + "handler_id": "handler-example", + "id": "instrument-example", + "type": "example_instrument", + "selected": true, + "display": {"label": "Example payment", "value": null}, + "credential": {"type": "example_credential", "com.example.credential": [false, null]}, + "com.example.instrument": {"custom": true} + }]}, + "signals": {"com.example.signal": {"flag": false, "value": null}}, + "status": "ready_for_complete", + "totals": [{ + "amount": 2800, + "type": "total", + "display_text": "Total", + "lines": [{"amount": 2800, "display_text": "Example breakdown"}] + }], + "ucp": {"payment_handlers": {}, "version": "2026-04-08"}, + "com.example.extension": {"array": [1, false, null], "large_number": 9223372036854775806, "empty": {}} + } + """.trimIndent() +} diff --git a/platforms/android/lib/src/test/java/com/shopify/checkoutkit/EmbeddedCheckoutProtocolBridgeTest.kt b/platforms/android/lib/src/test/java/com/shopify/checkoutkit/EmbeddedCheckoutProtocolBridgeTest.kt index 6bead8292..72eb2a6f3 100644 --- a/platforms/android/lib/src/test/java/com/shopify/checkoutkit/EmbeddedCheckoutProtocolBridgeTest.kt +++ b/platforms/android/lib/src/test/java/com/shopify/checkoutkit/EmbeddedCheckoutProtocolBridgeTest.kt @@ -50,12 +50,14 @@ class EmbeddedCheckoutProtocolBridgeTest { shadowOf(activity.application).checkActivities(true) viewSpy = Mockito.spy(CheckoutWebView(activity, webMessageTransport)) mockListener = mock() + whenever(mockListener.onCheckoutLinkClicked(any())).thenReturn(CheckoutLinkAction.Open) whenever(viewSpy.listener).thenReturn(mockListener) ecp = EmbeddedCheckoutProtocolBridge( viewSpy, webMessageTransport, protocolMessageExecutor = directExecutor, ) + ecp.setPresentationListener(mockListener) } @After @@ -248,6 +250,43 @@ class EmbeddedCheckoutProtocolBridgeTest { // region ec.window.open_request — merchant-overridable with kit fallback + @Test + fun `handled link acknowledges success without launching an activity`() { + whenever(mockListener.onCheckoutLinkClicked(any())).thenReturn(CheckoutLinkAction.Handled) + + val response = captureSentMessage { + ecp.receiveMessage(windowOpenRequest(id = "\"handled\"", url = "https://example.com")) + } + + assertThat(response).contains("\"status\":\"success\"") + assertThat(shadowOf(activity).nextStartedActivity).isNull() + val link = argumentCaptor() + verify(mockListener).onCheckoutLinkClicked(link.capture()) + assertThat(link.firstValue.url).isEqualTo(Uri.parse("https://example.com")) + } + + @Test + fun `cancelled link returns a protocol rejection without launching an activity`() { + whenever(mockListener.onCheckoutLinkClicked(any())).thenReturn(CheckoutLinkAction.Cancel) + + val response = captureSentMessage { + ecp.receiveMessage(windowOpenRequest(id = "\"cancelled\"", url = "https://example.com")) + } + + assertThat(response).contains("window_open_rejected_error", "link opening canceled") + assertThat(shadowOf(activity).nextStartedActivity).isNull() + } + + @Test + fun `invalid links are rejected before consulting the consumer`() { + val response = captureSentMessage { + ecp.receiveMessage(windowOpenRequest(id = "\"invalid\"", url = "https://example.com/invalid url")) + } + + assertThat(response).contains("window_open_rejected_error") + verify(mockListener, Mockito.never()).onCheckoutLinkClicked(any()) + } + @Test fun `window open launches Custom Tabs when activity resolves the uri`() { registerFakeBrowserFor("https://example.com") @@ -712,12 +751,14 @@ class EmbeddedCheckoutProtocolBridgeTest { } @Test - fun `ec complete invalidates cached preload`() { + fun `ec complete invalidates its own cached preload`() { CheckoutWebView.preload("https://shopify.dev/cart/123", activity, webMessageTransport) shadowOf(Looper.getMainLooper()).idle() val cachedWebView = CheckoutWebView.cachedPreloadViewForTesting()!! - ecp.receiveMessage(ecCompleteMessage()) + val bridge = + EmbeddedCheckoutProtocolBridge(cachedWebView, webMessageTransport, protocolMessageExecutor = directExecutor) + bridge.receiveMessage(ecCompleteMessage()) shadowOf(Looper.getMainLooper()).idle() assertThat(CheckoutWebView.cachedPreloadViewForTesting()).isNull() 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 24ea0e6bf..148edd949 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 @@ -1,6 +1,9 @@ package com.shopify.checkoutkit; import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; + +import android.net.Uri; import androidx.activity.ComponentActivity; import androidx.annotation.NonNull; @@ -45,7 +48,7 @@ public void tearDown() { public void canInstantiateCustomListener() { DefaultCheckoutListener listener = new DefaultCheckoutListener() { @Override - public void onCheckoutFailed(@NonNull CheckoutException error) { + public void onCheckoutFailed(@NonNull CheckoutFailureEvent event) { } @@ -58,6 +61,58 @@ public void onCheckoutDismissed() { assertThat(listener).isNotNull(); } + @Test + public void canConsumeCheckoutEventsAndChooseLinkActionsFromJava() { + List snapshots = new ArrayList<>(); + List errors = new ArrayList<>(); + DefaultCheckoutListener listener = new DefaultCheckoutListener() { + @Override + public void onCheckoutStarted(@NonNull CheckoutStartEvent event) { + snapshots.add(event.getCheckout()); + } + + @Override + public void onCheckoutUpdated(@NonNull CheckoutUpdateEvent event) { + snapshots.add(event.getCheckout()); + } + + @Override + public void onCheckoutCompleted(@NonNull CheckoutCompleteEvent event) { + snapshots.add(event.getCheckout()); + } + + @Override + public void onCheckoutFailed(@NonNull CheckoutFailureEvent event) { + errors.add(event.getError()); + } + + @Override + public CheckoutLinkAction onCheckoutLinkClicked(@NonNull CheckoutLink link) { + return "example.com".equals(link.getUrl().getHost()) + ? CheckoutLinkAction.Handled : CheckoutLinkAction.Cancel; + } + + @Override + public void onCheckoutDismissed() { + // do nothing + } + }; + Checkout checkout = mock(Checkout.class); + CheckoutException error = new CheckoutException(CheckoutErrorCode.NETWORK_ERROR, "Offline"); + + listener.onCheckoutStarted(new CheckoutStartEvent(checkout)); + listener.onCheckoutUpdated(new CheckoutUpdateEvent(checkout)); + listener.onCheckoutCompleted(new CheckoutCompleteEvent(checkout)); + listener.onCheckoutFailed(new CheckoutFailureEvent(error)); + + assertThat(snapshots).containsExactly(checkout, checkout, checkout); + assertThat(errors).containsExactly(error); + assertThat(listener.onCheckoutLinkClicked(new CheckoutLink(Uri.parse("https://example.com/privacy")))) + .isEqualTo(CheckoutLinkAction.Handled); + assertThat(listener.onCheckoutLinkClicked(new CheckoutLink(Uri.parse("https://shopify.dev")))) + .isEqualTo(CheckoutLinkAction.Cancel); + } + @Test public void canConstructCheckoutExceptionWithJavaOverloads() { CheckoutException withoutOptionalFields = new CheckoutException( @@ -231,7 +286,7 @@ public void canCallPresentFromJava() { activity, new DefaultCheckoutListener() { @Override - public void onCheckoutFailed(@NonNull CheckoutException error) { + public void onCheckoutFailed(@NonNull CheckoutFailureEvent event) { // do nothing } @@ -262,7 +317,7 @@ public void canCreateAndDestroyShopifyCheckoutFromJava() { ComponentActivity activity = controller.get(); DefaultCheckoutListener listener = new DefaultCheckoutListener() { @Override - public void onCheckoutFailed(@NonNull CheckoutException error) { + public void onCheckoutFailed(@NonNull CheckoutFailureEvent event) { // 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 ba8c965d7..12da2d9cc 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 @@ -60,10 +60,10 @@ class ShopifyCheckoutKitTest { webMessageTransport = webMessageTransport, ) - val captor = argumentCaptor() + val captor = argumentCaptor() assertThat(checkout).isNull() verify(listener).onCheckoutFailed(captor.capture()) - CheckoutExceptionAssert.assertThat(captor.firstValue) + CheckoutExceptionAssert.assertThat(captor.firstValue.error) .hasMessage("This Android WebView does not support Shopify Checkout Kit.") .hasCode(CheckoutErrorCode.WEB_VIEW_NOT_SUPPORTED) } diff --git a/platforms/android/lib/src/test/java/com/shopify/checkoutkit/ShopifyCheckoutTest.kt b/platforms/android/lib/src/test/java/com/shopify/checkoutkit/ShopifyCheckoutTest.kt index 06461686c..dd6db2dfb 100644 --- a/platforms/android/lib/src/test/java/com/shopify/checkoutkit/ShopifyCheckoutTest.kt +++ b/platforms/android/lib/src/test/java/com/shopify/checkoutkit/ShopifyCheckoutTest.kt @@ -8,6 +8,7 @@ import androidx.activity.ComponentActivity import androidx.appcompat.widget.Toolbar import androidx.core.view.children import org.assertj.core.api.Assertions.assertThat +import org.awaitility.Awaitility.await import org.junit.After import org.junit.Before import org.junit.Test @@ -17,6 +18,7 @@ import org.robolectric.RobolectricTestRunner import org.robolectric.Shadows.shadowOf import org.robolectric.android.controller.ActivityController import org.robolectric.shadows.ShadowLooper +import java.util.concurrent.TimeUnit @RunWith(RobolectricTestRunner::class) class ShopifyCheckoutTest { @@ -134,6 +136,55 @@ class ShopifyCheckoutTest { assertThat(shadowOf(webView).wasDestroyCalled()).isTrue() } + @Test + fun `app owned failure stops checkout events while host retains the view`() { + val events = mutableListOf() + var protocolMessages = 0 + var failure: CheckoutFailureEvent? = null + val view = ShopifyCheckout.create( + context = activity, + checkoutUrl = CHECKOUT_URL, + webMessageTransport = webMessageTransport, + ) { + onStart { events.add("start") } + onUpdate { events.add("update") } + onComplete { events.add("complete") } + onFail { failure = it } + } + activity.setContentView(view) + val webView = view.currentWebView() + webView.setClient( + CheckoutProtocol.Client() + .on(CheckoutProtocol.start) { protocolMessages += 1 } + .on(CheckoutProtocol.messagesChange) { protocolMessages += 1 } + .on(CheckoutProtocol.complete) { protocolMessages += 1 }, + ) + shadowOf(Looper.getMainLooper()).idle() + + webMessageTransport.dispatchMessage(checkoutMessage("ec.messages.change", "before-failure")) + await().pollInSameThread().atMost(2, TimeUnit.SECONDS).untilAsserted { + shadowOf(Looper.getMainLooper()).idle() + assertThat(protocolMessages).isEqualTo(1) + } + assertThat(events).containsExactly("update") + + val error = CheckoutException(CheckoutErrorCode.NETWORK_ERROR, "Offline") + webView.listener.onCheckoutViewFailedWithError(error) + listOf("ec.start", "ec.messages.change", "ec.complete").forEach { method -> + webMessageTransport.dispatchMessage(checkoutMessage(method, "after-failure")) + } + await().pollInSameThread().atMost(2, TimeUnit.SECONDS).untilAsserted { + shadowOf(Looper.getMainLooper()).idle() + assertThat(protocolMessages).isEqualTo(4) + } + + assertThat(failure!!.error).isSameAs(error) + assertThat(events).containsExactly("update") + assertThat(view.parent).isNotNull + assertThat(shadowOf(webView).wasDestroyCalled()).isFalse() + view.destroy() + } + @Test fun `unsupported WebView reports failure after construction and creates inert view`() { webMessageTransport.supported = false @@ -147,7 +198,7 @@ class ShopifyCheckoutTest { webMessageTransport = webMessageTransport, ) { onFail { - receivedError = it + receivedError = it.error failureReportedAfterConstruction = constructionComplete } } @@ -173,7 +224,7 @@ class ShopifyCheckoutTest { checkoutUrl = "http://checkout.shopify.com/cart/123", webMessageTransport = webMessageTransport, ) { - onFail { receivedError = it } + onFail { receivedError = it.error } } ShadowLooper.shadowMainLooper().runToEndOfTasks() @@ -306,6 +357,10 @@ class ShopifyCheckoutTest { .children .first { it is CheckoutWebView } as CheckoutWebView + private fun checkoutMessage(method: String, id: String): String = + """{"jsonrpc":"2.0","method":"$method","params":{"checkout":{"id":"$id","currency":"USD","status":"incomplete",""" + + """"line_items":[],"totals":[],"links":[],"ucp":{"payment_handlers":{},"version":"1.0"}}}}""" + private companion object { private const val CHECKOUT_URL = "https://shopify.com/checkouts/c/abc" } diff --git a/platforms/android/samples/CheckoutKitAndroidDemo/README.md b/platforms/android/samples/CheckoutKitAndroidDemo/README.md index 90ac3ff34..e79747312 100644 --- a/platforms/android/samples/CheckoutKitAndroidDemo/README.md +++ b/platforms/android/samples/CheckoutKitAndroidDemo/README.md @@ -7,16 +7,20 @@ This sample demonstrates how to integrate Checkout Kit with the Shopify Storefro - Product and collection browsing from the Storefront API - Cart create, add, update, remove, and fetch operations - `cart.checkoutUrl` presentation with either the Checkout Kit sheet or an app-owned Compose sheet -- Typed checkout lifecycle events through `CheckoutProtocol.Client` +- Typed checkout start, update, completion, and failure events - Checkout fail/dismiss callbacks and configurable sheet presentation -- Default intent and custom Chrome Custom Tabs handling for checkout window-open requests +- Default Custom Tabs and custom Android intent handling for checkout links - File chooser and geolocation host callbacks - Buyer identity demo data for checkout prefill - Customer Account API sign-in through Android Auth Tab (with a Custom Tabs fallback), secure token storage, and customer access token cart identity ## Checkout flow -The sample's cart flow can use the Kotlin-first `ShopifyCheckoutKit.present(checkoutUrl, activity) { ... }` API or embed `ShopifyCheckout` in an app-owned Compose sheet. Both modes connect a typed `CheckoutProtocol.Client` to observe checkout state changes, including completion, and configure fail/dismiss, file chooser, and geolocation callbacks. Settings also demonstrate Checkout Kit sheet presets and dismissal behavior, plus the SDK's default window-open handling or a custom Chrome Custom Tabs protocol handler. +The sample's cart flow can use the Kotlin-first `ShopifyCheckoutKit.present(checkoutUrl, activity) { ... }` API or embed +`ShopifyCheckout` in an app-owned Compose sheet. Both modes register `onStart`, `onUpdate`, and `onComplete` to observe +typed checkout snapshots, unwrap `onFail` events for cart recovery, and configure dismissal, file chooser, and +geolocation callbacks. Settings also demonstrate Checkout Kit sheet presets and dismissal behavior. The +`onLinkClick` callback keeps the SDK's default Custom Tabs handling or opens links through Android intents. ## Architecture @@ -51,7 +55,7 @@ CheckoutKitAndroidDemo/ | |-- products/product/data/ Product detail repository | |-- products/collection/data/ Collection repository | |-- settings/authentication/ Customer Account API sign-in flow -| |-- cart/CartViewModel.kt Checkout presentation and protocol handlers +| |-- cart/CartViewModel.kt Checkout presentation and event handlers | |-- cart/AppOwnedCheckoutSheet.kt App-owned Compose sheet integration | |-- settings/ Presentation and window-open settings | `-- MainActivity.kt File chooser and geolocation permission callbacks @@ -64,7 +68,9 @@ Do not edit files in `app/build/generated/source/apollo/` by hand. Update `.grap 1. `StorefrontApiClient.kt` wraps an `ApolloClient`, points it at the configured Storefront API endpoint, and executes generated query and mutation types. 2. Repository classes such as `CartRepository`, `ProductRepository`, and `ProductCollectionRepository` map generated Storefront API responses into local UI state. -3. `CartViewModel.kt` configures `ShopifyCheckoutKit.present` and the shared protocol client; `AppOwnedCheckoutSheet.kt` demonstrates embedding the same checkout in app-owned Compose UI. Both forward browser/system callbacks to `MainActivity`. +3. `CartViewModel.kt` shares checkout event and link handlers between `ShopifyCheckoutKit.present` and + `AppOwnedCheckoutSheet.kt`, which embeds checkout in app-owned Compose UI. Both forward browser/system callbacks + to `MainActivity`. The log viewer groups Kit events by checkout and compares successive checkout snapshots. 4. Apollo decodes responses into generated Kotlin types, so schema or operation changes surface as compile errors. ## Setup @@ -154,7 +160,7 @@ Open the project in Android Studio, sync Gradle, then build and run. | `products/collection/data/ProductCollectionRepository.kt` | Collection Storefront API calls. | | `settings/authentication/data/CustomerRepository.kt` | Customer Account API token exchange and customer lookup. | | `common/navigation/CheckoutKitNavHost.kt` | App navigation. | -| `cart/CartViewModel.kt` | Checkout Kit sheet presentation, fail/dismiss callbacks, protocol lifecycle handlers, and window-open routing. | +| `cart/CartViewModel.kt` | Checkout Kit sheet presentation, checkout event callbacks, cart recovery, and link routing. | | `cart/AppOwnedCheckoutSheet.kt` | App-owned Compose sheet containing an embedded `ShopifyCheckout`. | | `MainActivity.kt` | File chooser, geolocation, E2E control-link, and Auth Tab/Custom Tabs result callbacks. | | `settings/` | Checkout presentation mode, sheet style and dismissal, and window-open handler controls. | diff --git a/platforms/android/samples/CheckoutKitAndroidDemo/app/src/main/java/com/shopify/checkoutkit/androiddemo/cart/AppOwnedCheckoutSheet.kt b/platforms/android/samples/CheckoutKitAndroidDemo/app/src/main/java/com/shopify/checkoutkit/androiddemo/cart/AppOwnedCheckoutSheet.kt index b94a51624..d409ff258 100644 --- a/platforms/android/samples/CheckoutKitAndroidDemo/app/src/main/java/com/shopify/checkoutkit/androiddemo/cart/AppOwnedCheckoutSheet.kt +++ b/platforms/android/samples/CheckoutKitAndroidDemo/app/src/main/java/com/shopify/checkoutkit/androiddemo/cart/AppOwnedCheckoutSheet.kt @@ -24,7 +24,6 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.viewinterop.AndroidView import androidx.navigation.NavController import com.shopify.checkoutkit.ShopifyCheckout -import com.shopify.checkoutkit.androiddemo.MainActivity import com.shopify.checkoutkit.androiddemo.R @OptIn(ExperimentalMaterial3Api::class) @@ -39,7 +38,6 @@ internal fun AppOwnedCheckoutSheet( ) { val currentOnDismiss by rememberUpdatedState(onDismiss) val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) - val sampleActivity = activity as? MainActivity ModalBottomSheet( sheetState = sheetState, @@ -52,32 +50,18 @@ internal fun AppOwnedCheckoutSheet( ) { AndroidView( factory = { context -> - // Create one ShopifyCheckout for this presentation. Its checkout URL, callbacks, and protocol client + // Create one ShopifyCheckout for this presentation. Its checkout URL and callbacks // are fixed at creation, so create a new view when starting a new checkout. ShopifyCheckout.create( context = context, checkoutUrl = checkoutUrl, ) { - onFail { error -> - currentOnDismiss() - cartViewModel.handleCheckoutFailed(error) - } - onDismiss { - currentOnDismiss() - cartViewModel.handleCheckoutDismissed() - } - sampleActivity?.let { mainActivity -> - onShowFileChooser { _, filePathCallback, fileChooserParams -> - mainActivity.onShowFileChooser(filePathCallback, fileChooserParams) - } - onGeolocationPermissionsShowPrompt { origin, callback -> - mainActivity.onGeolocationPermissionsShowPrompt(origin, callback) - } - onGeolocationPermissionsHidePrompt { - mainActivity.onGeolocationPermissionsHidePrompt() - } - } - connect(cartViewModel.buildProtocolClient(navController, activity)) + cartViewModel.configureCheckout( + presentation = this, + activity = activity, + navController = navController, + dismissPresentation = { currentOnDismiss() }, + ) } }, modifier = Modifier diff --git a/platforms/android/samples/CheckoutKitAndroidDemo/app/src/main/java/com/shopify/checkoutkit/androiddemo/cart/CartViewModel.kt b/platforms/android/samples/CheckoutKitAndroidDemo/app/src/main/java/com/shopify/checkoutkit/androiddemo/cart/CartViewModel.kt index 8ae5c09c4..8552cf9ee 100644 --- a/platforms/android/samples/CheckoutKitAndroidDemo/app/src/main/java/com/shopify/checkoutkit/androiddemo/cart/CartViewModel.kt +++ b/platforms/android/samples/CheckoutKitAndroidDemo/app/src/main/java/com/shopify/checkoutkit/androiddemo/cart/CartViewModel.kt @@ -3,14 +3,14 @@ package com.shopify.checkoutkit.androiddemo.cart import android.content.ActivityNotFoundException import android.content.Intent import androidx.activity.ComponentActivity -import androidx.core.net.toUri import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import androidx.navigation.NavController import com.shopify.checkoutkit.CheckoutErrorCode import com.shopify.checkoutkit.CheckoutException +import com.shopify.checkoutkit.CheckoutLink +import com.shopify.checkoutkit.CheckoutLinkAction import com.shopify.checkoutkit.CheckoutPresentation -import com.shopify.checkoutkit.CheckoutProtocol import com.shopify.checkoutkit.PreloadState import com.shopify.checkoutkit.ShopifyCheckoutKit import com.shopify.checkoutkit.androiddemo.BuildConfig @@ -32,9 +32,6 @@ import com.shopify.checkoutkit.androiddemo.settings.authentication.data.Authenti import com.shopify.checkoutkit.androiddemo.settings.authentication.data.CustomerRepository import com.shopify.checkoutkit.androiddemo.settings.data.CheckoutPresentationMode import com.shopify.checkoutkit.androiddemo.settings.data.WindowOpenHandler -import com.shopify.ucp.embedded.checkout.WindowOpenResult -import com.shopify.ucp.embedded.checkout.windowOpenRejected -import com.shopify.ucp.embedded.checkout.windowOpenSuccess import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted @@ -167,19 +164,39 @@ class CartViewModel( checkoutUrl = url, context = activity, ) { - configureCheckout(activity, navController) + configureCheckout(this, activity, navController) } } - private fun CheckoutPresentation.configureCheckout( + internal fun configureCheckout( + presentation: CheckoutPresentation, activity: ComponentActivity, navController: NavController, - ) { + dismissPresentation: () -> Unit = {}, + ): Unit = with(presentation) { val sampleActivity = activity as? MainActivity - onFail(::handleCheckoutFailed) + val linkHandler = windowOpenHandler + onStart { event -> + recordSdkEvent("Checkout started", event.checkout) + } + onUpdate { event -> + recordSdkEvent("Checkout updated", event.checkout) + } + onComplete { event -> + recordSdkEvent("Checkout completed", event.checkout) + handleCheckoutCompleted(navController) + } + onFail { event -> + dismissPresentation() + handleCheckoutFailed(event.error) + } onDismiss { + dismissPresentation() handleCheckoutDismissed() } + onLinkClick { link -> + handleCheckoutLink(link, activity, linkHandler) + } sampleActivity?.let { mainActivity -> onShowFileChooser { _, filePathCallback, fileChooserParams -> mainActivity.onShowFileChooser(filePathCallback, fileChooserParams) @@ -191,7 +208,6 @@ class CartViewModel( mainActivity.onGeolocationPermissionsHidePrompt() } } - connect(buildProtocolClient(navController, activity)) } fun checkoutDismissedByHost() { @@ -284,89 +300,53 @@ class CartViewModel( logger.logSdkEvent("Checkout dismissed") } - internal fun buildProtocolClient( - navController: NavController, + private fun handleCheckoutLink( + link: CheckoutLink, activity: ComponentActivity, - ): CheckoutProtocol.Client { - val base = CheckoutProtocol.Client() - .on(CheckoutProtocol.start) { checkout -> - recordReceivedProtocolMessage(CheckoutProtocol.start.method, checkout) - } - .on(CheckoutProtocol.complete) { checkout -> - recordReceivedProtocolMessage(CheckoutProtocol.complete.method, checkout) - handleCheckoutCompleted(navController) - } - .on(CheckoutProtocol.error) { error -> - recordReceivedProtocolMessage(CheckoutProtocol.error.method, error, LogLevel.ERROR) - } - .on(CheckoutProtocol.totalsChange) { checkout -> - recordReceivedProtocolMessage(CheckoutProtocol.totalsChange.method, checkout) - } - .on(CheckoutProtocol.lineItemsChange) { checkout -> - recordReceivedProtocolMessage(CheckoutProtocol.lineItemsChange.method, checkout) - } - .on(CheckoutProtocol.messagesChange) { checkout -> - recordReceivedProtocolMessage(CheckoutProtocol.messagesChange.method, checkout) - } - .on(CheckoutProtocol.fulfillmentChange) { checkout -> - recordReceivedProtocolMessage(CheckoutProtocol.fulfillmentChange.method, checkout) - } - - return when (windowOpenHandler) { - // With no sample handler registered, Checkout Kit retains its default Custom Tab handling. - WindowOpenHandler.Default -> base - WindowOpenHandler.ExternalApp -> base.on(CheckoutProtocol.windowOpen) { request -> - recordReceivedProtocolMessage(CheckoutProtocol.windowOpen.method, request) - val uri = request.url.toUri() - Timber.i("ECP ec.window.open_request (${uri.scheme}) → external app") - try { - val intent = Intent(Intent.ACTION_VIEW, uri).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) - activity.startActivity(intent) - windowOpenSuccess().also { - recordWindowOpenResponse("success", it) - } - } catch (e: ActivityNotFoundException) { - Timber.w(e, "No activity resolved URL") - windowOpenRejected(reason = "no activity resolved URL").also { - recordWindowOpenResponse("rejected", it) - } - } catch (e: SecurityException) { - Timber.w(e, "External app launch blocked") - windowOpenRejected(reason = "external app launch blocked").also { - recordWindowOpenResponse("rejected", it) - } - } - } + handler: WindowOpenHandler, + ): CheckoutLinkAction { + // Returning Open keeps Checkout Kit's Custom Tab and non-web intent handling. + if (handler == WindowOpenHandler.Default) return CheckoutLinkAction.Open + + recordSdkEvent("Checkout link clicked", mapOf("url" to link.url.toString())) + return try { + val intent = Intent(Intent.ACTION_VIEW, link.url).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + activity.startActivity(intent) + recordSdkEvent("Checkout link handled", mapOf("url" to link.url.toString())) + CheckoutLinkAction.Handled + } catch (e: ActivityNotFoundException) { + Timber.w(e, "No activity resolved URL") + recordLinkCancellation(link, "no activity resolved URL") + CheckoutLinkAction.Cancel + } catch (e: SecurityException) { + Timber.w(e, "External app launch blocked") + recordLinkCancellation(link, "external app launch blocked") + CheckoutLinkAction.Cancel } } - private inline fun recordReceivedProtocolMessage( - method: String, - payload: T, - level: LogLevel = LogLevel.INFO, - ) { - recordProtocolMessage("Received: $method", payload, level) - } - - private fun recordWindowOpenResponse(outcome: String, payload: WindowOpenResult) { - val level = if (outcome == "success") LogLevel.INFO else LogLevel.ERROR - recordProtocolMessage("Sent: ${CheckoutProtocol.windowOpen.method} response ($outcome)", payload, level) + private fun recordLinkCancellation(link: CheckoutLink, reason: String) { + recordSdkEvent( + "Checkout link cancelled", + mapOf("url" to link.url.toString(), "reason" to reason), + LogLevel.ERROR, + ) } - private inline fun recordProtocolMessage( + private inline fun recordSdkEvent( message: String, payload: T, - level: LogLevel, + level: LogLevel = LogLevel.INFO, ) { val serializedPayload = runCatching { Json.encodeToString(payload) } .getOrElse { Timber.w(it, "Couldn't serialize $message payload") payload.toString() } - logger.logProtocolMessage(message, serializedPayload, level) + logger.logSdkEvent(message, serializedPayload, level) when (level) { - LogLevel.INFO -> Timber.i("ECP $message: $serializedPayload") - LogLevel.ERROR -> Timber.e("ECP $message: $serializedPayload") + LogLevel.INFO -> Timber.i("SDK $message: $serializedPayload") + LogLevel.ERROR -> Timber.e("SDK $message: $serializedPayload") } } diff --git a/platforms/android/samples/CheckoutKitAndroidDemo/app/src/main/java/com/shopify/checkoutkit/androiddemo/common/logs/Logger.kt b/platforms/android/samples/CheckoutKitAndroidDemo/app/src/main/java/com/shopify/checkoutkit/androiddemo/common/logs/Logger.kt index a6b579f64..e8e78359c 100644 --- a/platforms/android/samples/CheckoutKitAndroidDemo/app/src/main/java/com/shopify/checkoutkit/androiddemo/common/logs/Logger.kt +++ b/platforms/android/samples/CheckoutKitAndroidDemo/app/src/main/java/com/shopify/checkoutkit/androiddemo/common/logs/Logger.kt @@ -10,8 +10,8 @@ class Logger( private val logDb: LogDatabase, private val coroutineScope: CoroutineScope, ) { - fun logSdkEvent(message: String) { - log(LogSource.SDK, LogLevel.INFO, message) + fun logSdkEvent(message: String, payload: String? = null, level: LogLevel = LogLevel.INFO) { + log(LogSource.SDK, level, message, payload) } fun logSdkError(message: String, error: CheckoutException) { @@ -25,10 +25,6 @@ class Logger( log(LogSource.SDK, LogLevel.ERROR, message, payload) } - fun logProtocolMessage(message: String, payload: String, level: LogLevel) { - log(LogSource.PROTOCOL, level, message, payload) - } - private fun log( source: LogSource, level: LogLevel, diff --git a/platforms/android/samples/CheckoutKitAndroidDemo/app/src/main/java/com/shopify/checkoutkit/androiddemo/logs/CheckoutLogContext.kt b/platforms/android/samples/CheckoutKitAndroidDemo/app/src/main/java/com/shopify/checkoutkit/androiddemo/logs/CheckoutLogContext.kt index c31073d76..36e0bb453 100644 --- a/platforms/android/samples/CheckoutKitAndroidDemo/app/src/main/java/com/shopify/checkoutkit/androiddemo/logs/CheckoutLogContext.kt +++ b/platforms/android/samples/CheckoutKitAndroidDemo/app/src/main/java/com/shopify/checkoutkit/androiddemo/logs/CheckoutLogContext.kt @@ -1,13 +1,12 @@ package com.shopify.checkoutkit.androiddemo.logs -import com.shopify.checkoutkit.CheckoutProtocol import com.shopify.checkoutkit.androiddemo.common.logs.LogLine import com.shopify.checkoutkit.androiddemo.common.logs.LogSource import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonPrimitive import kotlinx.serialization.json.contentOrNull import kotlinx.serialization.json.jsonArray import kotlinx.serialization.json.jsonObject -import kotlinx.serialization.json.jsonPrimitive import java.util.UUID internal data class CheckoutLogContext( @@ -58,24 +57,28 @@ internal fun List.checkoutContextsByLogId(): Map Date: Thu, 24 Sep 2026 16:36:38 +0100 Subject: [PATCH 2/2] Address Android checkout event API review feedback --- platforms/android/README.md | 40 ++- platforms/android/lib/api/lib.api | 51 ++-- .../java/com/shopify/checkoutkit/Checkout.kt | 259 +++++++++++++++--- .../com/shopify/checkoutkit/CheckoutEvent.kt | 8 +- .../shopify/checkoutkit/CheckoutListener.kt | 7 +- .../checkoutkit/CheckoutPresentation.kt | 7 +- .../shopify/checkoutkit/CheckoutSerializer.kt | 44 +-- .../checkoutkit/CheckoutSerializerTest.kt | 20 ++ .../com/shopify/checkoutkit/CheckoutTest.kt | 100 ++++++- .../com/shopify/checkoutkit/InteropTest.java | 31 ++- .../androiddemo/CheckoutEventFixturesTest.kt | 37 +++ 11 files changed, 510 insertions(+), 94 deletions(-) create mode 100644 platforms/android/lib/src/test/java/com/shopify/checkoutkit/CheckoutSerializerTest.kt create mode 100644 platforms/android/samples/CheckoutKitAndroidDemo/app/src/test/java/com/shopify/checkoutkit/androiddemo/CheckoutEventFixturesTest.kt diff --git a/platforms/android/README.md b/platforms/android/README.md index 57d2536f8..9e0f2c5c5 100644 --- a/platforms/android/README.md +++ b/platforms/android/README.md @@ -28,6 +28,7 @@ - [Title localization](#title-localization) - [Current configuration](#current-configuration) - [Checkout lifecycle](#checkout-lifecycle) + - [Constructing event fixtures](#constructing-event-fixtures) - [Error handling](#error-handling) - [Migrating from the protocol-client prerelease API](#migrating-from-the-protocol-client-prerelease-api) - [Browser and system callbacks](#browser-and-system-callbacks) @@ -199,7 +200,7 @@ and tap-away belong to the host, so route `ModalBottomSheet.onDismissRequest` th `destroy()` once the view is permanently removed; Checkout Kit does not attempt to dismiss an unknown parent. Create a new `ShopifyCheckout` for any retry. -`ShopifyCheckout` callbacks and protocol client are fixed when the view is created. Create a new view for a new checkout +`ShopifyCheckout` callbacks are fixed when the view is created. Create a new view for a new checkout URL. If a Compose adapter accepts callbacks that can change during recomposition, forward them through stable delegates such as `rememberUpdatedState` rather than recreating an active checkout. @@ -265,6 +266,10 @@ Preloading is a best-effort performance hint, not a guarantee. If the preload is A valid checkout preloaded and presented with `ShopifyCheckoutKit.present` is retained when its bottom sheet is dismissed, so presenting the same checkout URL again can reuse the loaded checkout. Invalidate the preload when the cart changes or the loaded checkout should no longer be reused. +Checkout events received during preload, before presentation callbacks are bound, are not replayed when checkout is +presented. In particular, `onStart` only observes start events received during its presentation; it is not guaranteed +to run for every presentation or when reusing a loaded checkout. Use the preload listener to observe loading progress. + Avoid preloading on every add-to-cart or cart mutation. Preload only when buyer intent is strong enough to justify the additional client and network work. Clear unused preloaded checkout work with `invalidate`: @@ -515,6 +520,10 @@ ShopifyCheckoutKit.present(checkoutUrl, activity) { `onStart` and `onUpdate` observe checkout state; they do not send mutations to the checkout running in the WebView. Use `onComplete` to clear or refresh the cart so the app does not reuse a completed checkout. +Callbacks observe events received during the current presentation. Events received before callbacks are bound, +including during [preloading](#preload-checkout), are not replayed. `onStart` reports a checkout start event, not the +act of presenting a view, so do not rely on it to initialize UI for every presentation. + For Java integrations, override `onCheckoutStarted`, `onCheckoutUpdated`, and `onCheckoutCompleted` in `DefaultCheckoutListener`. These receive `CheckoutStartEvent`, `CheckoutUpdateEvent`, and `CheckoutCompleteEvent`; use `event.getCheckout()` to access their snapshot. Override `onCheckoutFailed` for `CheckoutFailureEvent` and @@ -522,6 +531,35 @@ use `event.getCheckout()` to access their snapshot. Override `onCheckoutFailed` Use `onLinkClick` to choose how checkout links open; see [Offsite payments and links](#offsite-payments-and-links). +### Constructing event fixtures + +Event constructors are public in Kotlin and Java: create `CheckoutStartEvent(checkout)`, `CheckoutUpdateEvent(checkout)`, +`CheckoutCompleteEvent(checkout)`, or `CheckoutFailureEvent(error)` to exercise your app's callback handlers. +Build checkout snapshots with `Checkout.Builder()` and derive variants with `toBuilder()`: + +```kotlin +import com.shopify.checkoutkit.Checkout +import com.shopify.checkoutkit.CheckoutStartEvent +import com.shopify.checkoutkit.CheckoutUpdateEvent +import com.shopify.ucp.embedded.checkout.CheckoutStatus + +val checkout = Checkout.Builder() + .id("fixture-checkout") + .currency("USD") + .status(CheckoutStatus.Incomplete) + .lineItems(emptyList()) + .links(emptyList()) + .totals(emptyList()) + .build() + +val start = CheckoutStartEvent(checkout) +val update = CheckoutUpdateEvent(checkout.toBuilder().currency("CAD").build()) +``` + +`Checkout` has a private constructor and no data-class `copy` method. Its builder permits consumer fixtures and +snapshot variants without exposing a constructor or `copy` signature containing every schema field, so adding +optional fields can preserve binary compatibility. Set all required fields before calling `build()`. + ### Error handling A checkout lifecycle failure is delivered as a `CheckoutFailureEvent` to `onFail` or diff --git a/platforms/android/lib/api/lib.api b/platforms/android/lib/api/lib.api index fcdc70672..371177802 100644 --- a/platforms/android/lib/api/lib.api +++ b/platforms/android/lib/api/lib.api @@ -8,28 +8,7 @@ public final class com/shopify/checkoutkit/BuildConfig { public final class com/shopify/checkoutkit/Checkout { public static final field Companion Lcom/shopify/checkoutkit/Checkout$Companion; - public fun (Ljava/util/Map;Lcom/shopify/ucp/embedded/checkout/Buyer;Lcom/shopify/ucp/embedded/checkout/Context;Ljava/lang/String;Ljava/lang/String;Lcom/shopify/ucp/embedded/checkout/CheckoutDiscounts;Ljava/lang/String;Lcom/shopify/ucp/embedded/checkout/CheckoutFulfillment;Ljava/lang/String;Ljava/util/List;Ljava/util/List;Ljava/util/List;Lcom/shopify/ucp/embedded/checkout/OrderConfirmation;Lcom/shopify/ucp/embedded/checkout/Payment;Lkotlinx/serialization/json/JsonObject;Lcom/shopify/ucp/embedded/checkout/CheckoutStatus;Ljava/util/List;Ljava/util/Map;)V - public synthetic fun (Ljava/util/Map;Lcom/shopify/ucp/embedded/checkout/Buyer;Lcom/shopify/ucp/embedded/checkout/Context;Ljava/lang/String;Ljava/lang/String;Lcom/shopify/ucp/embedded/checkout/CheckoutDiscounts;Ljava/lang/String;Lcom/shopify/ucp/embedded/checkout/CheckoutFulfillment;Ljava/lang/String;Ljava/util/List;Ljava/util/List;Ljava/util/List;Lcom/shopify/ucp/embedded/checkout/OrderConfirmation;Lcom/shopify/ucp/embedded/checkout/Payment;Lkotlinx/serialization/json/JsonObject;Lcom/shopify/ucp/embedded/checkout/CheckoutStatus;Ljava/util/List;Ljava/util/Map;ILkotlin/jvm/internal/DefaultConstructorMarker;)V - public final fun component1 ()Ljava/util/Map; - public final fun component10 ()Ljava/util/List; - public final fun component11 ()Ljava/util/List; - public final fun component12 ()Ljava/util/List; - public final fun component13 ()Lcom/shopify/ucp/embedded/checkout/OrderConfirmation; - public final fun component14 ()Lcom/shopify/ucp/embedded/checkout/Payment; - public final fun component15 ()Lkotlinx/serialization/json/JsonObject; - public final fun component16 ()Lcom/shopify/ucp/embedded/checkout/CheckoutStatus; - public final fun component17 ()Ljava/util/List; - public final fun component18 ()Ljava/util/Map; - public final fun component2 ()Lcom/shopify/ucp/embedded/checkout/Buyer; - public final fun component3 ()Lcom/shopify/ucp/embedded/checkout/Context; - public final fun component4 ()Ljava/lang/String; - public final fun component5 ()Ljava/lang/String; - public final fun component6 ()Lcom/shopify/ucp/embedded/checkout/CheckoutDiscounts; - public final fun component7 ()Ljava/lang/String; - public final fun component8 ()Lcom/shopify/ucp/embedded/checkout/CheckoutFulfillment; - public final fun component9 ()Ljava/lang/String; - public final fun copy (Ljava/util/Map;Lcom/shopify/ucp/embedded/checkout/Buyer;Lcom/shopify/ucp/embedded/checkout/Context;Ljava/lang/String;Ljava/lang/String;Lcom/shopify/ucp/embedded/checkout/CheckoutDiscounts;Ljava/lang/String;Lcom/shopify/ucp/embedded/checkout/CheckoutFulfillment;Ljava/lang/String;Ljava/util/List;Ljava/util/List;Ljava/util/List;Lcom/shopify/ucp/embedded/checkout/OrderConfirmation;Lcom/shopify/ucp/embedded/checkout/Payment;Lkotlinx/serialization/json/JsonObject;Lcom/shopify/ucp/embedded/checkout/CheckoutStatus;Ljava/util/List;Ljava/util/Map;)Lcom/shopify/checkoutkit/Checkout; - public static synthetic fun copy$default (Lcom/shopify/checkoutkit/Checkout;Ljava/util/Map;Lcom/shopify/ucp/embedded/checkout/Buyer;Lcom/shopify/ucp/embedded/checkout/Context;Ljava/lang/String;Ljava/lang/String;Lcom/shopify/ucp/embedded/checkout/CheckoutDiscounts;Ljava/lang/String;Lcom/shopify/ucp/embedded/checkout/CheckoutFulfillment;Ljava/lang/String;Ljava/util/List;Ljava/util/List;Ljava/util/List;Lcom/shopify/ucp/embedded/checkout/OrderConfirmation;Lcom/shopify/ucp/embedded/checkout/Payment;Lkotlinx/serialization/json/JsonObject;Lcom/shopify/ucp/embedded/checkout/CheckoutStatus;Ljava/util/List;Ljava/util/Map;ILjava/lang/Object;)Lcom/shopify/checkoutkit/Checkout; + public synthetic fun (Lcom/shopify/checkoutkit/Checkout$State;Lkotlin/jvm/internal/DefaultConstructorMarker;)V public fun equals (Ljava/lang/Object;)Z public final fun getAdditionalProperties ()Ljava/util/Map; public final fun getAttribution ()Ljava/util/Map; @@ -50,9 +29,33 @@ public final class com/shopify/checkoutkit/Checkout { public final fun getStatus ()Lcom/shopify/ucp/embedded/checkout/CheckoutStatus; public final fun getTotals ()Ljava/util/List; public fun hashCode ()I + public final fun toBuilder ()Lcom/shopify/checkoutkit/Checkout$Builder; public fun toString ()Ljava/lang/String; } +public final class com/shopify/checkoutkit/Checkout$Builder { + public fun ()V + public final fun additionalProperties (Ljava/util/Map;)Lcom/shopify/checkoutkit/Checkout$Builder; + public final fun attribution (Ljava/util/Map;)Lcom/shopify/checkoutkit/Checkout$Builder; + public final fun build ()Lcom/shopify/checkoutkit/Checkout; + public final fun buyer (Lcom/shopify/ucp/embedded/checkout/Buyer;)Lcom/shopify/checkoutkit/Checkout$Builder; + public final fun context (Lcom/shopify/ucp/embedded/checkout/Context;)Lcom/shopify/checkoutkit/Checkout$Builder; + public final fun continueURL (Ljava/lang/String;)Lcom/shopify/checkoutkit/Checkout$Builder; + public final fun currency (Ljava/lang/String;)Lcom/shopify/checkoutkit/Checkout$Builder; + public final fun discounts (Lcom/shopify/ucp/embedded/checkout/CheckoutDiscounts;)Lcom/shopify/checkoutkit/Checkout$Builder; + public final fun expiresAt (Ljava/lang/String;)Lcom/shopify/checkoutkit/Checkout$Builder; + public final fun fulfillment (Lcom/shopify/ucp/embedded/checkout/CheckoutFulfillment;)Lcom/shopify/checkoutkit/Checkout$Builder; + public final fun id (Ljava/lang/String;)Lcom/shopify/checkoutkit/Checkout$Builder; + public final fun lineItems (Ljava/util/List;)Lcom/shopify/checkoutkit/Checkout$Builder; + public final fun links (Ljava/util/List;)Lcom/shopify/checkoutkit/Checkout$Builder; + public final fun messages (Ljava/util/List;)Lcom/shopify/checkoutkit/Checkout$Builder; + public final fun order (Lcom/shopify/ucp/embedded/checkout/OrderConfirmation;)Lcom/shopify/checkoutkit/Checkout$Builder; + public final fun payment (Lcom/shopify/ucp/embedded/checkout/Payment;)Lcom/shopify/checkoutkit/Checkout$Builder; + public final fun signals (Lkotlinx/serialization/json/JsonObject;)Lcom/shopify/checkoutkit/Checkout$Builder; + public final fun status (Lcom/shopify/ucp/embedded/checkout/CheckoutStatus;)Lcom/shopify/checkoutkit/Checkout$Builder; + public final fun totals (Ljava/util/List;)Lcom/shopify/checkoutkit/Checkout$Builder; +} + public final class com/shopify/checkoutkit/Checkout$Companion { public final fun serializer ()Lkotlinx/serialization/KSerializer; } @@ -119,6 +122,7 @@ public final class com/shopify/checkoutkit/CheckoutAppearance$Storefront$Compani } public final class com/shopify/checkoutkit/CheckoutCompleteEvent { + public fun (Lcom/shopify/checkoutkit/Checkout;)V public final fun getCheckout ()Lcom/shopify/checkoutkit/Checkout; } @@ -150,6 +154,7 @@ public final class com/shopify/checkoutkit/CheckoutException : java/lang/Excepti } public final class com/shopify/checkoutkit/CheckoutFailureEvent { + public fun (Lcom/shopify/checkoutkit/CheckoutException;)V public final fun getError ()Lcom/shopify/checkoutkit/CheckoutException; } @@ -305,10 +310,12 @@ public final class com/shopify/checkoutkit/CheckoutSheetTitleAlignment : java/la } public final class com/shopify/checkoutkit/CheckoutStartEvent { + public fun (Lcom/shopify/checkoutkit/Checkout;)V public final fun getCheckout ()Lcom/shopify/checkoutkit/Checkout; } public final class com/shopify/checkoutkit/CheckoutUpdateEvent { + public fun (Lcom/shopify/checkoutkit/Checkout;)V public final fun getCheckout ()Lcom/shopify/checkoutkit/Checkout; } diff --git a/platforms/android/lib/src/main/java/com/shopify/checkoutkit/Checkout.kt b/platforms/android/lib/src/main/java/com/shopify/checkoutkit/Checkout.kt index 2597bb01a..00cf9cc67 100644 --- a/platforms/android/lib/src/main/java/com/shopify/checkoutkit/Checkout.kt +++ b/platforms/android/lib/src/main/java/com/shopify/checkoutkit/Checkout.kt @@ -22,49 +22,226 @@ import com.shopify.ucp.embedded.checkout.Checkout as ProtocolCheckout * Domain fields retain the generated model types and optional values. Protocol metadata is * excluded, while unrecognized checkout extensions remain available in [additionalProperties]. * Extensions are encoded alongside the named fields when this snapshot is serialized to JSON. + * + * Use [Builder] to construct snapshots for tests and [toBuilder] to create a modified snapshot. */ @Serializable(with = CheckoutSerializer::class) -@Suppress("LongParameterList") -public data class Checkout( - public val attribution: Map? = null, - public val buyer: Buyer? = null, - public val context: Context? = null, - public val continueURL: String? = null, - public val currency: String, - public val discounts: CheckoutDiscounts? = null, - public val expiresAt: String? = null, - public val fulfillment: CheckoutFulfillment? = null, - public val id: String, - public val lineItems: List, - public val links: List, - public val messages: List? = null, - public val order: OrderConfirmation? = null, - public val payment: Payment? = null, - public val signals: JsonObject? = null, - public val status: CheckoutStatus, - public val totals: List, - public val additionalProperties: Map = emptyMap(), -) { - public companion object { - internal fun fromProtocol(checkout: ProtocolCheckout): Checkout = Checkout( - attribution = checkout.attribution, - buyer = checkout.buyer, - context = checkout.context, - continueURL = checkout.continueURL, - currency = checkout.currency, - discounts = checkout.discounts, - expiresAt = checkout.expiresAt, - fulfillment = checkout.fulfillment, - id = checkout.id, - lineItems = checkout.lineItems, - links = checkout.links, - messages = checkout.messages, - order = checkout.order, - payment = checkout.payment, - signals = checkout.signals, - status = checkout.status, - totals = checkout.totals, - additionalProperties = checkout.additionalProperties.filterKeys { it !in CheckoutSerializer.reservedKeys }, +public class Checkout private constructor(private val state: State) { + public val attribution: Map? + get() = state.attribution + + public val buyer: Buyer? + get() = state.buyer + + public val context: Context? + get() = state.context + + public val continueURL: String? + get() = state.continueURL + + public val currency: String + get() = state.currency + + public val discounts: CheckoutDiscounts? + get() = state.discounts + + public val expiresAt: String? + get() = state.expiresAt + + public val fulfillment: CheckoutFulfillment? + get() = state.fulfillment + + public val id: String + get() = state.id + + public val lineItems: List + get() = state.lineItems + + public val links: List + get() = state.links + + public val messages: List? + get() = state.messages + + public val order: OrderConfirmation? + get() = state.order + + public val payment: Payment? + get() = state.payment + + public val signals: JsonObject? + get() = state.signals + + public val status: CheckoutStatus + get() = state.status + + public val totals: List + get() = state.totals + + public val additionalProperties: Map + get() = state.additionalProperties + + /** Returns a builder containing all values from this snapshot. */ + public fun toBuilder(): Builder = Builder() + .attribution(attribution) + .buyer(buyer) + .context(context) + .continueURL(continueURL) + .currency(currency) + .discounts(discounts) + .expiresAt(expiresAt) + .fulfillment(fulfillment) + .id(id) + .lineItems(lineItems) + .links(links) + .messages(messages) + .order(order) + .payment(payment) + .signals(signals) + .status(status) + .totals(totals) + .additionalProperties(additionalProperties) + + override fun equals(other: Any?): Boolean = other is Checkout && state == other.state + + override fun hashCode(): Int = state.hashCode() + + override fun toString(): String = "Checkout${state.toString().removePrefix("State")}" + + /** + * Constructs checkout snapshots without depending on a constructor tied to the checkout schema. + * + * [currency], [id], [lineItems], [links], [status], and [totals] must be supplied before [build]. + * Optional fields default to `null`; empty collections remain distinct from absent fields. + */ + @Suppress("TooManyFunctions") + public class Builder { + private var attribution: Map? = null + private var buyer: Buyer? = null + private var context: Context? = null + private var continueURL: String? = null + private var currency: String? = null + private var discounts: CheckoutDiscounts? = null + private var expiresAt: String? = null + private var fulfillment: CheckoutFulfillment? = null + private var id: String? = null + private var lineItems: List? = null + private var links: List? = null + private var messages: List? = null + private var order: OrderConfirmation? = null + private var payment: Payment? = null + private var signals: JsonObject? = null + private var status: CheckoutStatus? = null + private var totals: List? = null + private var additionalProperties: Map = emptyMap() + + public fun attribution(value: Map?): Builder = apply { attribution = value } + + public fun buyer(value: Buyer?): Builder = apply { buyer = value } + + public fun context(value: Context?): Builder = apply { context = value } + + public fun continueURL(value: String?): Builder = apply { continueURL = value } + + public fun currency(value: String): Builder = apply { currency = value } + + public fun discounts(value: CheckoutDiscounts?): Builder = apply { discounts = value } + + public fun expiresAt(value: String?): Builder = apply { expiresAt = value } + + public fun fulfillment(value: CheckoutFulfillment?): Builder = apply { fulfillment = value } + + public fun id(value: String): Builder = apply { id = value } + + public fun lineItems(value: List): Builder = apply { lineItems = value } + + public fun links(value: List): Builder = apply { links = value } + + public fun messages(value: List?): Builder = apply { messages = value } + + public fun order(value: OrderConfirmation?): Builder = apply { order = value } + + public fun payment(value: Payment?): Builder = apply { payment = value } + + public fun signals(value: JsonObject?): Builder = apply { signals = value } + + public fun status(value: CheckoutStatus): Builder = apply { status = value } + + public fun totals(value: List): Builder = apply { totals = value } + + public fun additionalProperties(value: Map): Builder = apply { additionalProperties = value } + + /** + * Returns a snapshot of the builder values, excluding reserved extension keys. + * + * @throws IllegalStateException if a required field has not been supplied. + */ + public fun build(): Checkout = Checkout( + State( + attribution = attribution, + buyer = buyer, + context = context, + continueURL = continueURL, + currency = checkNotNull(currency) { "Missing required checkout field: currency" }, + discounts = discounts, + expiresAt = expiresAt, + fulfillment = fulfillment, + id = checkNotNull(id) { "Missing required checkout field: id" }, + lineItems = checkNotNull(lineItems) { "Missing required checkout field: lineItems" }, + links = checkNotNull(links) { "Missing required checkout field: links" }, + messages = messages, + order = order, + payment = payment, + signals = signals, + status = checkNotNull(status) { "Missing required checkout field: status" }, + totals = checkNotNull(totals) { "Missing required checkout field: totals" }, + additionalProperties = additionalProperties.filterKeys { it !in CheckoutSerializer.reservedKeys }, + ), ) } + + @Suppress("LongParameterList") + private data class State( + val attribution: Map?, + val buyer: Buyer?, + val context: Context?, + val continueURL: String?, + val currency: String, + val discounts: CheckoutDiscounts?, + val expiresAt: String?, + val fulfillment: CheckoutFulfillment?, + val id: String, + val lineItems: List, + val links: List, + val messages: List?, + val order: OrderConfirmation?, + val payment: Payment?, + val signals: JsonObject?, + val status: CheckoutStatus, + val totals: List, + val additionalProperties: Map, + ) + + public companion object { + internal fun fromProtocol(checkout: ProtocolCheckout): Checkout = Builder() + .attribution(checkout.attribution) + .buyer(checkout.buyer) + .context(checkout.context) + .continueURL(checkout.continueURL) + .currency(checkout.currency) + .discounts(checkout.discounts) + .expiresAt(checkout.expiresAt) + .fulfillment(checkout.fulfillment) + .id(checkout.id) + .lineItems(checkout.lineItems) + .links(checkout.links) + .messages(checkout.messages) + .order(checkout.order) + .payment(checkout.payment) + .signals(checkout.signals) + .status(checkout.status) + .totals(checkout.totals) + .additionalProperties(checkout.additionalProperties) + .build() + } } diff --git a/platforms/android/lib/src/main/java/com/shopify/checkoutkit/CheckoutEvent.kt b/platforms/android/lib/src/main/java/com/shopify/checkoutkit/CheckoutEvent.kt index 947ccc698..04bf8eb8f 100644 --- a/platforms/android/lib/src/main/java/com/shopify/checkoutkit/CheckoutEvent.kt +++ b/platforms/android/lib/src/main/java/com/shopify/checkoutkit/CheckoutEvent.kt @@ -1,13 +1,13 @@ package com.shopify.checkoutkit /** The payload delivered when checkout starts. */ -public class CheckoutStartEvent internal constructor(public val checkout: Checkout) +public class CheckoutStartEvent public constructor(public val checkout: Checkout) /** The payload delivered when the buyer-visible checkout state changes. */ -public class CheckoutUpdateEvent internal constructor(public val checkout: Checkout) +public class CheckoutUpdateEvent public constructor(public val checkout: Checkout) /** The payload delivered when checkout completes. */ -public class CheckoutCompleteEvent internal constructor(public val checkout: Checkout) +public class CheckoutCompleteEvent public constructor(public val checkout: Checkout) /** The payload delivered when checkout cannot continue. */ -public class CheckoutFailureEvent internal constructor(public val error: CheckoutException) +public class CheckoutFailureEvent public constructor(public val error: CheckoutException) diff --git a/platforms/android/lib/src/main/java/com/shopify/checkoutkit/CheckoutListener.kt b/platforms/android/lib/src/main/java/com/shopify/checkoutkit/CheckoutListener.kt index 9454eb8ec..5c057ef0c 100644 --- a/platforms/android/lib/src/main/java/com/shopify/checkoutkit/CheckoutListener.kt +++ b/platforms/android/lib/src/main/java/com/shopify/checkoutkit/CheckoutListener.kt @@ -12,7 +12,12 @@ import android.webkit.WebView * We'd strongly recommend extending DefaultCheckoutListener where possible. */ public interface CheckoutListener { - /** Called when checkout starts. */ + /** + * Called for checkout start events received during this presentation. + * + * Events received before presentation callbacks are bound, including during preload, are not replayed. + * This callback is not guaranteed for every presentation or when reusing a loaded checkout. + */ public fun onCheckoutStarted(event: CheckoutStartEvent) /** Called when the buyer-visible checkout state changes. */ diff --git a/platforms/android/lib/src/main/java/com/shopify/checkoutkit/CheckoutPresentation.kt b/platforms/android/lib/src/main/java/com/shopify/checkoutkit/CheckoutPresentation.kt index fb8200157..cffcf156c 100644 --- a/platforms/android/lib/src/main/java/com/shopify/checkoutkit/CheckoutPresentation.kt +++ b/platforms/android/lib/src/main/java/com/shopify/checkoutkit/CheckoutPresentation.kt @@ -26,7 +26,12 @@ public class CheckoutPresentation internal constructor() { ((String, GeolocationPermissions.Callback) -> Unit)? = null internal var onGeolocationPermissionsHidePrompt: (() -> Unit)? = null - /** Called when checkout starts. */ + /** + * Called for checkout start events received during this presentation. + * + * Events received before presentation callbacks are bound, including during preload, are not replayed. + * This callback is not guaranteed for every presentation or when reusing a loaded checkout. + */ public fun onStart(handler: (CheckoutStartEvent) -> Unit) { onStart = handler } diff --git a/platforms/android/lib/src/main/java/com/shopify/checkoutkit/CheckoutSerializer.kt b/platforms/android/lib/src/main/java/com/shopify/checkoutkit/CheckoutSerializer.kt index 1d3618ce1..4fef00cac 100644 --- a/platforms/android/lib/src/main/java/com/shopify/checkoutkit/CheckoutSerializer.kt +++ b/platforms/android/lib/src/main/java/com/shopify/checkoutkit/CheckoutSerializer.kt @@ -13,7 +13,6 @@ import kotlinx.serialization.json.JsonEncoder import kotlinx.serialization.json.JsonObject import kotlinx.serialization.json.decodeFromJsonElement import kotlinx.serialization.json.encodeToJsonElement -import kotlinx.serialization.json.jsonObject internal object CheckoutSerializer : KSerializer { override val descriptor: SerialDescriptor = buildClassSerialDescriptor("com.shopify.checkoutkit.Checkout") @@ -26,28 +25,29 @@ internal object CheckoutSerializer : KSerializer { override fun deserialize(decoder: Decoder): Checkout { val input = decoder as? JsonDecoder ?: throw SerializationException("Checkout can only be deserialized from JSON") - val fields = input.decodeJsonElement().jsonObject + val fields = input.decodeJsonElement() as? JsonObject + ?: throw SerializationException("Checkout must be a JSON object") val json = input.json - return Checkout( - attribution = fields.optional("attribution", json), - buyer = fields.optional("buyer", json), - context = fields.optional("context", json), - continueURL = fields.optional("continue_url", json), - currency = fields.required("currency", json), - discounts = fields.optional("discounts", json), - expiresAt = fields.optional("expires_at", json), - fulfillment = fields.optional("fulfillment", json), - id = fields.required("id", json), - lineItems = fields.required("line_items", json), - links = fields.required("links", json), - messages = fields.optional("messages", json), - order = fields.optional("order", json), - payment = fields.optional("payment", json), - signals = fields.optional("signals", json), - status = fields.required("status", json), - totals = fields.required("totals", json), - additionalProperties = fields.filterKeys { it !in reservedKeys }, - ) + return Checkout.Builder() + .attribution(fields.optional("attribution", json)) + .buyer(fields.optional("buyer", json)) + .context(fields.optional("context", json)) + .continueURL(fields.optional("continue_url", json)) + .currency(fields.required("currency", json)) + .discounts(fields.optional("discounts", json)) + .expiresAt(fields.optional("expires_at", json)) + .fulfillment(fields.optional("fulfillment", json)) + .id(fields.required("id", json)) + .lineItems(fields.required("line_items", json)) + .links(fields.required("links", json)) + .messages(fields.optional("messages", json)) + .order(fields.optional("order", json)) + .payment(fields.optional("payment", json)) + .signals(fields.optional("signals", json)) + .status(fields.required("status", json)) + .totals(fields.required("totals", json)) + .additionalProperties(fields.filterKeys { it !in reservedKeys }) + .build() } override fun serialize(encoder: Encoder, value: Checkout) { diff --git a/platforms/android/lib/src/test/java/com/shopify/checkoutkit/CheckoutSerializerTest.kt b/platforms/android/lib/src/test/java/com/shopify/checkoutkit/CheckoutSerializerTest.kt new file mode 100644 index 000000000..15162f99c --- /dev/null +++ b/platforms/android/lib/src/test/java/com/shopify/checkoutkit/CheckoutSerializerTest.kt @@ -0,0 +1,20 @@ +package com.shopify.checkoutkit + +import kotlinx.serialization.SerializationException +import kotlinx.serialization.decodeFromString +import kotlinx.serialization.json.Json +import org.assertj.core.api.Assertions.assertThatThrownBy +import org.junit.Test + +class CheckoutSerializerTest { + @Test + fun nonObjectCheckoutJsonThrowsSerializationExceptions() { + listOf("[]", "null", "1", "true", "\"checkout\"").forEach { input -> + assertThatThrownBy { + Json.decodeFromString(input) + }.describedAs("Checkout payload %s", input) + .isInstanceOf(SerializationException::class.java) + .hasMessageContaining("JSON object") + } + } +} diff --git a/platforms/android/lib/src/test/java/com/shopify/checkoutkit/CheckoutTest.kt b/platforms/android/lib/src/test/java/com/shopify/checkoutkit/CheckoutTest.kt index 17dba55eb..9f0e0b344 100644 --- a/platforms/android/lib/src/test/java/com/shopify/checkoutkit/CheckoutTest.kt +++ b/platforms/android/lib/src/test/java/com/shopify/checkoutkit/CheckoutTest.kt @@ -2,6 +2,7 @@ package com.shopify.checkoutkit import com.shopify.ucp.embedded.checkout.CheckoutDiscounts import com.shopify.ucp.embedded.checkout.CheckoutFulfillment +import com.shopify.ucp.embedded.checkout.CheckoutStatus import com.shopify.ucp.embedded.checkout.Payment import kotlinx.serialization.SerializationException import kotlinx.serialization.decodeFromString @@ -134,10 +135,11 @@ class CheckoutTest { ) val projected = Checkout.fromProtocol(protocolCheckout.copy(additionalProperties = extensions)) - val constructed = projected.copy(additionalProperties = extensions) + val constructed = projected.toBuilder().additionalProperties(extensions).build() val encoded = json.encodeToJsonElement(constructed).jsonObject assertThat(projected.additionalProperties).containsOnlyKeys("com.example.empty") + assertThat(constructed).isEqualTo(projected) assertThat(encoded["id"]).isEqualTo(JsonPrimitive(protocolCheckout.id)) assertThat(encoded["line_items"]).isEqualTo(JsonArray(emptyList())) assertThat(encoded["com.example.empty"]).isEqualTo(JsonNull) @@ -159,6 +161,102 @@ class CheckoutTest { assertThat(Checkout.fromProtocol(changedExtension)).isNotEqualTo(checkout) } + @Test + fun `builder creates a fixture with only required values`() { + val checkout = Checkout.Builder() + .id("checkout-example") + .currency("USD") + .lineItems(emptyList()) + .links(emptyList()) + .status(CheckoutStatus.Incomplete) + .totals(emptyList()) + .build() + + val expected = json.decodeFromString(minimalCheckout) + assertThat(checkout).isEqualTo(expected) + assertThat(checkout.hashCode()).isEqualTo(expected.hashCode()) + assertThat(checkout.additionalProperties).isEmpty() + assertThat(json.encodeToJsonElement(checkout)).isEqualTo(json.encodeToJsonElement(expected)) + } + + @Test + fun `builder reports each missing required value`() { + val setters: Map Unit> = linkedMapOf( + "id" to { it.id("checkout-example") }, + "currency" to { it.currency("USD") }, + "lineItems" to { it.lineItems(emptyList()) }, + "links" to { it.links(emptyList()) }, + "status" to { it.status(CheckoutStatus.Incomplete) }, + "totals" to { it.totals(emptyList()) }, + ) + + setters.keys.forEach { missingField -> + val builder = Checkout.Builder() + setters.filterKeys { it != missingField }.values.forEach { setField -> setField(builder) } + + assertThatThrownBy { builder.build() } + .describedAs("Missing %s", missingField) + .isInstanceOf(IllegalStateException::class.java) + .hasMessage("Missing required checkout field: $missingField") + } + } + + @Test + fun `builder variants preserve every field and leave earlier snapshots unchanged`() { + val original = json.decodeFromString(fullCheckout) + val builder = original.toBuilder() + val rebuilt = builder.build() + + assertThat(rebuilt).isNotSameAs(original).isEqualTo(original) + assertThat(rebuilt.hashCode()).isEqualTo(original.hashCode()) + assertThat(json.encodeToJsonElement(rebuilt)).isEqualTo(json.encodeToJsonElement(original)) + + val changed = builder.id("checkout-variant").messages(null).lineItems(emptyList()).build() + val changedAgain = builder.id("checkout-another-variant").build() + + assertThat(changed.id).isEqualTo("checkout-variant") + assertThat(changed.messages).isNull() + assertThat(changed.lineItems).isEmpty() + assertThat(changedAgain.id).isEqualTo("checkout-another-variant") + assertThat(original.id).isEqualTo("checkout-example") + assertThat(original.messages).isNotEmpty() + assertThat(original.lineItems).isNotEmpty() + assertThat(rebuilt).isEqualTo(original) + } + + @Test + fun `snapshot equality accounts for every builder field`() { + val checkout = json.decodeFromString(fullCheckout) + val changes: Map Unit> = linkedMapOf( + "attribution" to { it.attribution(null) }, + "buyer" to { it.buyer(null) }, + "context" to { it.context(null) }, + "continueURL" to { it.continueURL(null) }, + "currency" to { it.currency("EUR") }, + "discounts" to { it.discounts(null) }, + "expiresAt" to { it.expiresAt(null) }, + "fulfillment" to { it.fulfillment(null) }, + "id" to { it.id("checkout-variant") }, + "lineItems" to { it.lineItems(emptyList()) }, + "links" to { it.links(emptyList()) }, + "messages" to { it.messages(null) }, + "order" to { it.order(null) }, + "payment" to { it.payment(null) }, + "signals" to { it.signals(null) }, + "status" to { it.status(CheckoutStatus.Incomplete) }, + "totals" to { it.totals(emptyList()) }, + "additionalProperties" to { it.additionalProperties(emptyMap()) }, + ) + + changes.forEach { (field, change) -> + val builder = checkout.toBuilder() + change(builder) + assertThat(builder.build()).describedAs("Changed %s", field).isNotEqualTo(checkout) + } + assertThat(checkout).isEqualTo(checkout).isNotEqualTo(null).isNotEqualTo("checkout-example") + assertThat(setOf(checkout, checkout.toBuilder().build())).hasSize(1) + } + private val minimalCheckout = """ { "id": "checkout-example", 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 148edd949..eabda2bff 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 @@ -5,6 +5,8 @@ import android.net.Uri; +import com.shopify.ucp.embedded.checkout.CheckoutStatus; + import androidx.activity.ComponentActivity; import androidx.annotation.NonNull; @@ -97,7 +99,14 @@ public void onCheckoutDismissed() { // do nothing } }; - Checkout checkout = mock(Checkout.class); + Checkout checkout = new Checkout.Builder() + .id("checkout-fixture") + .currency("USD") + .status(CheckoutStatus.Incomplete) + .lineItems(Collections.emptyList()) + .links(Collections.emptyList()) + .totals(Collections.emptyList()) + .build(); CheckoutException error = new CheckoutException(CheckoutErrorCode.NETWORK_ERROR, "Offline"); listener.onCheckoutStarted(new CheckoutStartEvent(checkout)); @@ -113,6 +122,26 @@ public void onCheckoutDismissed() { .isEqualTo(CheckoutLinkAction.Cancel); } + @Test + public void canDeriveCheckoutFixturesWithoutExposingSnapshotConstructors() { + Checkout checkout = new Checkout.Builder() + .id("checkout-fixture") + .currency("USD") + .status(CheckoutStatus.Incomplete) + .lineItems(Collections.emptyList()) + .links(Collections.emptyList()) + .totals(Collections.emptyList()) + .build(); + Checkout completed = checkout.toBuilder().status(CheckoutStatus.Completed).build(); + + assertThat(completed.getId()).isEqualTo(checkout.getId()); + assertThat(completed.getStatus()).isEqualTo(CheckoutStatus.Completed); + assertThat(checkout.getStatus()).isEqualTo(CheckoutStatus.Incomplete); + assertThat(Checkout.class.getConstructors()) + .filteredOn(constructor -> !constructor.isSynthetic()) + .isEmpty(); + } + @Test public void canConstructCheckoutExceptionWithJavaOverloads() { CheckoutException withoutOptionalFields = new CheckoutException( diff --git a/platforms/android/samples/CheckoutKitAndroidDemo/app/src/test/java/com/shopify/checkoutkit/androiddemo/CheckoutEventFixturesTest.kt b/platforms/android/samples/CheckoutKitAndroidDemo/app/src/test/java/com/shopify/checkoutkit/androiddemo/CheckoutEventFixturesTest.kt new file mode 100644 index 000000000..c15aa9669 --- /dev/null +++ b/platforms/android/samples/CheckoutKitAndroidDemo/app/src/test/java/com/shopify/checkoutkit/androiddemo/CheckoutEventFixturesTest.kt @@ -0,0 +1,37 @@ +package com.shopify.checkoutkit.androiddemo + +import com.shopify.checkoutkit.Checkout +import com.shopify.checkoutkit.CheckoutCompleteEvent +import com.shopify.checkoutkit.CheckoutErrorCode +import com.shopify.checkoutkit.CheckoutException +import com.shopify.checkoutkit.CheckoutFailureEvent +import com.shopify.checkoutkit.CheckoutStartEvent +import com.shopify.checkoutkit.CheckoutUpdateEvent +import com.shopify.ucp.embedded.checkout.CheckoutStatus +import org.assertj.core.api.Assertions.assertThat +import org.junit.Test + +/** Compiled in a consumer module so Kotlin internal visibility cannot make these fixtures pass. */ +class CheckoutEventFixturesTest { + @Test + fun consumersCanConstructSnapshotsAndEveryEventPayload() { + val checkout = Checkout.Builder() + .id("checkout-fixture") + .currency("USD") + .status(CheckoutStatus.Incomplete) + .lineItems(emptyList()) + .links(emptyList()) + .totals(emptyList()) + .build() + val updated = checkout.toBuilder().currency("CAD").build() + val completed = checkout.toBuilder().status(CheckoutStatus.Completed).build() + val error = CheckoutException(CheckoutErrorCode.NETWORK_ERROR, "Synthetic network failure") + + assertThat(CheckoutStartEvent(checkout).checkout).isSameAs(checkout) + assertThat(CheckoutUpdateEvent(updated).checkout.currency).isEqualTo("CAD") + assertThat(CheckoutCompleteEvent(completed).checkout.status).isEqualTo(CheckoutStatus.Completed) + assertThat(CheckoutFailureEvent(error).error).isSameAs(error) + assertThat(checkout.currency).isEqualTo("USD") + assertThat(checkout.status).isEqualTo(CheckoutStatus.Incomplete) + } +}