diff --git a/platforms/android/README.md b/platforms/android/README.md index 8fb983e3..8a1fd761 100644 --- a/platforms/android/README.md +++ b/platforms/android/README.md @@ -19,13 +19,6 @@ - [Color Scheme](#color-scheme) - [Log Level](#log-level) - [Checkout Dialog Title](#checkout-dialog-title) -- [Preloading](#preloading) - - [Important considerations](#important-considerations) - - [Flash Sales](#flash-sales) - - [When to preload](#when-to-preload) - - [Cache invalidation](#cache-invalidation) - - [Lifecycle management for preloaded checkout](#lifecycle-management-for-preloaded-checkout) - - [Additional considerations for preloaded checkout](#additional-considerations-for-preloaded-checkout) - [Monitoring the lifecycle of a checkout session](#monitoring-the-lifecycle-of-a-checkout-session) - [Error handling](#error-handling) - [`CheckoutException`](#checkoutexception) @@ -113,13 +106,39 @@ function provided by the SDK: ```kotlin fun presentCheckout() { val checkoutUrl = cart.checkoutUrl - ShopifyCheckoutKit.present(checkoutUrl, context, checkoutEventProcessor) + ShopifyCheckoutKit.present(checkoutUrl, context) { + onFail { error -> + handleCheckoutError(error) + } + onCancel { + resetCheckoutUi() + } + } } ``` -> [!TIP] -> To help optimize and deliver the best experience the SDK also provides a -> [preloading API](#preloading) that can be used to initialize the checkout session ahead of time. +> [!NOTE] +> Pass the standard `checkoutUrl` returned by Storefront API or a cart permalink. +> Checkout Kit adds the required UCP query parameters automatically when it loads checkout, +> so you do not need to rewrite the URL yourself. + +If you also want typed Embedded Checkout Protocol (ECP) callbacks, connect a +`CheckoutProtocol.Client` inside the same Kotlin builder: + +```kotlin +val checkoutProtocolClient = CheckoutProtocol.Client() + .on(CheckoutProtocol.start) { checkout -> + // Checkout is ready and interactive. + } + .on(CheckoutProtocol.complete) { checkout -> + // Typed checkout payload emitted when checkout completes. + navigateToConfirmation(checkout) + } + +ShopifyCheckoutKit.present(checkoutUrl, activity) { + connect(checkoutProtocolClient) +} +``` ## Configuration @@ -240,196 +259,83 @@ To customize the title of the Dialog that the checkout WebView is displayed with Buy Now! ``` -## Preloading - -Initializing a checkout session requires communicating with Shopify servers, thus depending -on the network quality and bandwidth available to the buyer can result in undesirable waiting -time for the buyer. To help optimize and deliver the best experience, the SDK provides a -`preloading` "hint" that allows developers to signal that the checkout session should be -initialized in the background, ahead of time. - -Preloading is an advanced feature that can be disabled via a runtime flag: - -```kotlin -ShopifyCheckoutKit.configure { - it.preloading = Preloading(enabled = false) // defaults to true -} -``` - -Once enabled, preloading a checkout is as simple as calling -`preload(checkoutUrl)` with a valid `checkoutUrl`. - -```kotlin -ShopifyCheckoutKit.preload(checkoutUrl) -``` - -Setting enabled to `false` will cause all calls to the `preload` function to be ignored. This allows the application to selectively toggle preloading behavior as a remote feature flag or dynamically in response to client conditions - e.g. when data saver functionality is enabled by the user. - -```kotlin -ShopifyCheckoutKit.configure { - it.preloading = Preloading(enabled = false) -} -ShopifyCheckoutKit.preload(checkoutUrl) // no-op -``` - -### Important considerations - -1. Initiating preload results in background network requests and additional - CPU/memory utilization for the client, and should be used when there is a - high likelihood that the buyer will soon request to checkout—e.g. when the - buyer navigates to the cart overview or a similar app-specific experience. -2. A preloaded checkout session reflects the cart contents at the time when - `preload` is called. If the cart is updated after `preload` is called, the - application needs to call `preload` again to reflect the updated checkout - session. -3. Calling `preload(checkoutUrl)` is a hint, **not a guarantee**: the library - may debounce or ignore calls to this API depending on various conditions; the - preload may not complete before `present(checkoutUrl)` is called, in which - case the buyer may still see a spinner while the checkout session is - finalized. - -### Flash Sales - -It is important to note that during Flash Sales or periods of high amounts of traffic, buyers may be entered into a queue system. - -**Calls to preload which result in a buyer being enqueued will be rejected.** This means that a buyer will never enter the queue without their knowledge. - -### When to preload - -Calling `preload()` each time an item is added to a buyer's cart can put significant strain on Shopify systems, which in return can result in rejected requests. Rejected requests will not result in a visual error shown to users, but will degrade the experience since they will need to load checkout from scratch. - -Instead, a better approach is to call `preload()` when you have a strong enough signal that the buyer intends to check out. In some cases this might mean a buyer has navigated to a "cart" screen. - -### Cache invalidation - -Should you wish to manually clear the preload cache, there is a `ShopifyCheckoutKit.invalidate()` helper function to do so. This function will be a no-op if no checkout is preloaded. - -You may wish to do this if the buyer changes shortly before entering checkout, e.g. by changing cart quantity on a cart view. - -### Lifecycle management for preloaded checkout - -Preloading renders a checkout in a background webview, which is brought to foreground when `ShopifyCheckoutKit.present()` is called. The content of preloaded checkout reflects the state of the cart when `preload()` was initially called. If the cart is mutated after `preload()` is called, the application is responsible for invalidating the preloaded checkout to ensure that up-to-date checkout content is displayed to the buyer: - -1. To update preloaded contents: call `preload()` once again -2. To disable preloaded content: toggle the preload configuration setting - -The library will automatically invalidate/abort preload under the following conditions: - -- Request results in network error or non 2XX server response code -- The checkout has successfully completed, as indicated by the server response -- When `ShopifyCheckoutSheet.configure` is called (e.g. with theming changes). - -A preloaded checkout _is not_ automatically invalidated when checkout is closed. For example, if a buyer loads the checkout then exists, the preloaded checkout is retained and should be updated when cart contents change. - -#### Additional considerations for preloaded checkout - -1. Preloading is a hint, not a guarantee. The library may debounce or ignore - calls depending on various conditions; the preload may not complete before - `present(checkoutUrl)` is called, in which case the buyer may still see a progress/loading indicator while the checkout session is finalized. -2. Preloading results in background network requests and additional CPU/memory utilization - for the client, and should be used responsibly. For example, conditionally based on the state of the client and when there is a high likelihood that the buyer will soon - request to checkout. - ## Monitoring the lifecycle of a checkout session -Extend the `DefaultCheckoutEventProcessor` abstract class to register callbacks for key lifecycle events during the checkout session: +For Kotlin integrations, use `CheckoutProtocol.Client` to observe typed checkout notifications +such as `ec.start`, `ec.complete`, and the incremental checkout state updates. The same +`present(...)` builder can wire fail/cancel callbacks plus optional browser/system hooks: ```kotlin -val processor = object : DefaultCheckoutEventProcessor(activity) { - override fun onCheckoutCompleted(checkoutCompletedEvent: CheckoutCompletedEvent) { - // Called when the checkout was completed successfully by the buyer. - // Use this to update UI, reset cart state, etc. +ShopifyCheckoutKit.present(checkoutUrl, activity) { + connect( + CheckoutProtocol.Client() + .on(CheckoutProtocol.start) { checkout -> + // Observe typed checkout notifications. + } + .on(CheckoutProtocol.complete) { checkout -> + // Handle successful checkout completion. + } + ) + + onShowFileChooser { webView, filePathCallback, fileChooserParams -> + // Return true if the host app handled the chooser request. + false } - override fun onCheckoutCanceled() { - // Called when the checkout was canceled by the buyer. - // Note: This will also be received after closing a completed checkout + onGeolocationPermissionsShowPrompt { origin, callback -> + // Called to tell the client to show a geolocation permissions prompt as a geolocation + // request has been made. Invoked for example if a customer uses `Use my location` + // for pickup points. } - override fun onCheckoutFailed(error: CheckoutException) { - // Called when the checkout encountered an error and has been aborted. + onGeolocationPermissionsHidePrompt { + // Called to tell the client to hide the geolocation permissions prompt. } - override fun onCheckoutLinkClicked(uri: Uri) { - // Called when the buyer clicks a link within the checkout experience: - // - email address (`mailto:`) - // - telephone number (`tel:`) - // - web (http:) - // - deep link (e.g. myapp://checkout) - // and is being directed outside the application. - - // Note: to support deep links on Android 11+ using the `DefaultCheckoutEventProcessor`, - // the client app should add a queries element in its manifest declaring which apps it should interact with. - // See the MobileBuyIntegration sample's manifest for an example. - // Queries reference - https://developer.android.com/guide/topics/manifest/queries-element - - // If no app can be queried to deal with the link, the processor will log a warning: - // `Unrecognized scheme for link clicked in checkout` along with the uri. + onPermissionRequest { permissionRequest -> + // Called when a web permission has been requested, e.g. to access the camera. } +} +``` +If you prefer a reusable object, or are integrating from Java, extend +`DefaultCheckoutEventProcessor` and pass it to the existing `present(...)` overload: + +```kotlin +val processor = object : DefaultCheckoutEventProcessor() { override fun onShowFileChooser( webView: WebView, filePathCallback: ValueCallback>, fileChooserParams: FileChooserParams, ): Boolean { - // Called to tell the client to show a file chooser. This is called to handle HTML forms with 'file' input type, - // in response to the user pressing the "Select File" button. - // To cancel the request, call filePathCallback.onReceiveValue(null) and return true. + return activity.onShowFileChooser(filePathCallback, fileChooserParams) } override fun onGeolocationPermissionsShowPrompt(origin: String, callback: GeolocationPermissions.Callback) { - // Called to tell the client to show a geolocation permissions prompt as a geolocation permissions - // request has been made. - // Invoked for example if a customer uses `Use my location` for pickup points + activity.onGeolocationPermissionsShowPrompt(origin, callback) } override fun onGeolocationPermissionsHidePrompt() { - // Called to tell the client to hide the geolocation permissions prompt, e.g. as the request has been cancelled + activity.onGeolocationPermissionsHidePrompt() } override fun onPermissionRequest(permissionRequest: PermissionRequest) { - // Called when a permission has been requested, e.g. to access the camera - // implement to grant/deny/request permissions. + // Grant, deny, or proxy requested web permissions. } } + +ShopifyCheckoutKit.present(checkoutUrl, context, processor) ``` > [!Note] -> The `DefaultCheckoutEventProcessor` provides default implementations for current and future callback functions (such as `onLinkClicked()`), which can be overridden by clients wanting to change default behavior. +> The `DefaultCheckoutEventProcessor` overload remains available for reusable or Java-facing +> integrations and provides default implementations for optional browser/system callbacks. ### Error handling -In the event of a checkout error occurring, the Checkout Kit _may_ attempt to retry to recover from the error. Recovery will happen in the background by discarding the failed WebView and creating a new "recovery" instance. Recovery will be attempted in the following scenarios: - -- The WebView receives a 5XX status code -- An internal SDK error is emitted - -There are some caveats to note when this scenario occurs: - -1. The checkout experience may look different to buyers. Though the sheet kit will attempt to load any checkoput customizations for the storefront, there is no guarantee they will show in recovery mode. -2. The `onCheckoutCompleted(checkoutCompletedEvent: CheckoutCompletedEvent)` will be emitted with partial data. Invocations will only received the order ID via `checkoutCompletedEvent.orderDetails.id`. - -Should you wish to opt-out of this fallback experience entirely, you can do so by overriding `shouldRecoverFromError`. Errors given to the `onCheckoutFailed(error: CheckoutException)` lifecycle method will contain an `isRecoverable` property by default indicating whether the request should be retried or not. - -`preRecoveryActions()` can also be overridden to execute code before a fallback takes place, for example to add logging, or clear up any potentially problematic state such as in cookies. By default this function is a no-op. - -```kotlin -ShopifyCheckoutKit.configure { - it.errorRecovery = object: ErrorRecovery { - override fun shouldRecoverFromError(checkoutException: CheckoutException): Boolean { - // To disable recovery (default = checkoutException.isRecoverable) - return false - } - - override fun preRecoveryActions(exception: CheckoutException, checkoutUrl: String) { - // Perform actions prior to recovery, e.g. logging, clearing up cookies: - if (exception is HttpException) { - CookiePurger.purge(checkoutUrl) - } - } - } -} -``` +Checkout failures are delivered to `onFail { ... }` or `onCheckoutFailed(...)` as +`CheckoutException` values. Inspect the exception type and error code to decide whether to +recreate the cart, retry later, or show an error state in the host app. #### `CheckoutException` @@ -440,13 +346,13 @@ ShopifyCheckoutKit.configure { | `CheckoutExpiredException` | 'cart_expired' | The cart or checkout is no longer available. | Create a new cart and open a new checkout URL. | | `CheckoutExpiredException` | 'cart_completed' | The cart associated with the checkout has completed checkout. | Create new cart and open a new checkout URL. | | `CheckoutExpiredException` | 'invalid_cart' | The cart associated with the checkout is invalid (e.g. empty). | Create a new cart and open a new checkout URL. | -| `CheckoutKitException` | 'error_receiving_message' | Checkout Kit failed to receive a message from checkout. | Show checkout in a fallback WebView. | -| `CheckoutKitException` | 'error_sending_message' | Checkout Kit failed to send a message to checkout. | Show checkout in a fallback WebView. | -| `CheckoutKitException` | 'render_process_gone' | The render process for the checkout WebView is gone. | Show checkout in a fallback WebView. | -| `CheckoutKitException` | 'unknown' | An error in Checkout Kit has occurred, see error details for more info. | Show checkout in a fallback WebView. | -| `HttpException` | 'http_error' | An unexpected server error has been encountered. | Show checkout in a fallback WebView. | -| `ClientException` | 'client_error' | An unhandled client error was encountered. | Show checkout in a fallback WebView. | -| `CheckoutUnavailableException` | 'unknown' | Checkout is unavailable for another reason, see error details for more info. | Show checkout in a fallback WebView. | +| `CheckoutKitException` | 'error_receiving_message' | Checkout Kit failed to receive a message from checkout. | Handle as a checkout failure in the host app. | +| `CheckoutKitException` | 'error_sending_message' | Checkout Kit failed to send a message to checkout. | Handle as a checkout failure in the host app. | +| `CheckoutKitException` | 'render_process_gone' | The render process for the checkout WebView is gone. | Handle as a checkout failure in the host app. | +| `CheckoutKitException` | 'unknown' | An error in Checkout Kit has occurred, see error details for more info. | Handle as a checkout failure in the host app. | +| `HttpException` | 'http_error' | An unexpected server error has been encountered. | Handle as a checkout failure in the host app. | +| `ClientException` | 'client_error' | An unhandled client error was encountered. | Handle as a checkout failure in the host app. | +| `CheckoutUnavailableException` | 'unknown' | Checkout is unavailable for another reason, see error details for more info. | Handle as a checkout failure in the host app. | #### Exception Hierarchy @@ -465,8 +371,6 @@ classDiagram <> CheckoutException CheckoutException : +String errorDescription CheckoutException : +String errorCode - CheckoutException : +bool isRecoverable - class ConfigurationException{ note: "Store or checkout configuration issues." } @@ -492,7 +396,7 @@ classDiagram Buyer-aware checkout experience reduces friction and increases conversion. Depending on the context of the buyer (guest or signed-in), knowledge of buyer preferences, or account/identity system, the -application can use on of the following methods to initialize personalized and contextualized buyer +application can use one of the following methods to initialize personalized and contextualized buyer experience. ### Cart: buyer bag, identity, and preferences @@ -530,10 +434,6 @@ and initialize a buyer-aware checkout session. > the above JSON omits useful customer attributes that should be provided where possible and > encryption and signing should be done server-side to ensure Multipass keys are kept secret. -> [!NOTE] -> Multipass errors are not "recoverable" (See [Error Handling](#error-handling)) due to their one-time nature. Failed requests containing multipass URLs -> will require re-generating new tokens. - ### Shop Pay To initialize accelerated Shop Pay checkout, the cart can set a diff --git a/platforms/android/lib/api/lib.api b/platforms/android/lib/api/lib.api index c4251747..e0006f25 100644 --- a/platforms/android/lib/api/lib.api +++ b/platforms/android/lib/api/lib.api @@ -756,6 +756,16 @@ public final class com/shopify/checkoutkit/CheckoutLineItem$Companion { public final fun serializer ()Lkotlinx/serialization/KSerializer; } +public final class com/shopify/checkoutkit/CheckoutPresentation { + public final fun connect (Lcom/shopify/checkoutkit/CheckoutCommunicationClient;)V + public final fun onCancel (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 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 specVersion Ljava/lang/String; @@ -3927,6 +3937,7 @@ public final class com/shopify/checkoutkit/ShopifyCheckoutKit { public static final fun preload (Ljava/lang/String;Landroidx/activity/ComponentActivity;)V public static final fun present (Ljava/lang/String;Landroidx/activity/ComponentActivity;Lcom/shopify/checkoutkit/DefaultCheckoutEventProcessor;)Lcom/shopify/checkoutkit/CheckoutKitDialog; public static final fun present (Ljava/lang/String;Landroidx/activity/ComponentActivity;Lcom/shopify/checkoutkit/DefaultCheckoutEventProcessor;Lcom/shopify/checkoutkit/CheckoutCommunicationClient;)Lcom/shopify/checkoutkit/CheckoutKitDialog; + public static final synthetic fun present (Ljava/lang/String;Landroidx/activity/ComponentActivity;Lkotlin/jvm/functions/Function1;)Lcom/shopify/checkoutkit/CheckoutKitDialog; public static synthetic fun present$default (Ljava/lang/String;Landroidx/activity/ComponentActivity;Lcom/shopify/checkoutkit/DefaultCheckoutEventProcessor;Lcom/shopify/checkoutkit/CheckoutCommunicationClient;ILjava/lang/Object;)Lcom/shopify/checkoutkit/CheckoutKitDialog; } 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 new file mode 100644 index 00000000..2856e971 --- /dev/null +++ b/platforms/android/lib/src/main/java/com/shopify/checkoutkit/CheckoutPresentation.kt @@ -0,0 +1,138 @@ +/* + * MIT License + * + * Copyright 2023-present, Shopify Inc. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.shopify.checkoutkit + +import android.net.Uri +import android.webkit.GeolocationPermissions +import android.webkit.PermissionRequest +import android.webkit.ValueCallback +import android.webkit.WebChromeClient +import android.webkit.WebView +import com.shopify.checkoutkit.lifecycleevents.CheckoutCompletedEvent + +/** + * Kotlin-first builder for per-presentation checkout callbacks. + * + * Use through [ShopifyCheckoutKit.present]. + */ +public class CheckoutPresentation internal constructor() { + internal var onFail: ((CheckoutException) -> Unit)? = null + internal var onCancel: (() -> Unit)? = null + internal var onPermissionRequest: ((PermissionRequest) -> Unit)? = null + internal var onShowFileChooser: + ((WebView, ValueCallback>, WebChromeClient.FileChooserParams) -> Boolean)? = null + internal var onGeolocationPermissionsShowPrompt: + ((String, GeolocationPermissions.Callback) -> Unit)? = null + internal var onGeolocationPermissionsHidePrompt: (() -> Unit)? = null + internal var communicationClient: CheckoutCommunicationClient? = null + + /** + * Called when checkout fails. + */ + public fun onFail(handler: (CheckoutException) -> Unit) { + onFail = handler + } + + /** + * Called when checkout is canceled by the buyer. + */ + public fun onCancel(handler: () -> Unit) { + onCancel = handler + } + + /** + * Called when checkout requests a web permission, such as camera access. + */ + public fun onPermissionRequest(handler: (PermissionRequest) -> Unit) { + onPermissionRequest = handler + } + + /** + * Called when checkout requests that the host app present a file chooser. + */ + public fun onShowFileChooser( + handler: ( + webView: WebView, + filePathCallback: ValueCallback>, + fileChooserParams: WebChromeClient.FileChooserParams, + ) -> Boolean, + ) { + onShowFileChooser = handler + } + + /** + * Called when checkout requests that the host app present a geolocation prompt. + */ + public fun onGeolocationPermissionsShowPrompt( + handler: (origin: String, callback: GeolocationPermissions.Callback) -> Unit, + ) { + onGeolocationPermissionsShowPrompt = handler + } + + /** + * Called when checkout requests that any visible geolocation prompt be dismissed. + */ + public fun onGeolocationPermissionsHidePrompt(handler: () -> Unit) { + onGeolocationPermissionsHidePrompt = handler + } + + /** + * Connects a communication client for Embedded Checkout Protocol messages. + */ + public fun connect(client: CheckoutCommunicationClient?) { + communicationClient = client + } + + internal fun buildEventProcessor(): DefaultCheckoutEventProcessor = + object : DefaultCheckoutEventProcessor() { + override fun onCheckoutCompleted(checkoutCompletedEvent: CheckoutCompletedEvent) = Unit + + override fun onCheckoutFailed(error: CheckoutException) { + onFail?.invoke(error) + } + + override fun onCheckoutCanceled() { + onCancel?.invoke() + } + + override fun onPermissionRequest(permissionRequest: PermissionRequest) { + onPermissionRequest?.invoke(permissionRequest) + } + + override fun onShowFileChooser( + webView: WebView, + filePathCallback: ValueCallback>, + fileChooserParams: WebChromeClient.FileChooserParams, + ): Boolean { + return onShowFileChooser?.invoke(webView, filePathCallback, fileChooserParams) ?: false + } + + override fun onGeolocationPermissionsShowPrompt(origin: String, callback: GeolocationPermissions.Callback) { + onGeolocationPermissionsShowPrompt?.invoke(origin, callback) + } + + override fun onGeolocationPermissionsHidePrompt() { + onGeolocationPermissionsHidePrompt?.invoke() + } + } +} 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 f01dffb1..589da291 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 @@ -118,13 +118,38 @@ public object ShopifyCheckoutKit { } } + /** + * Presents a Shopify checkout within a Dialog + * + * @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/cancel callbacks, browser/system hooks, + * and an optional communication client + * @return An instance of [CheckoutKitDialog] if the dialog was successfully created and displayed. + */ + @JvmStatic + @JvmSynthetic + public fun present( + checkoutUrl: String, + context: ComponentActivity, + configure: CheckoutPresentation.() -> Unit, + ): CheckoutKitDialog? { + val presentation = CheckoutPresentation().apply(configure) + return present( + checkoutUrl = checkoutUrl, + context = context, + checkoutEventProcessor = presentation.buildEventProcessor(), + communicationClient = presentation.communicationClient, + ) + } + /** * Presents a Shopify checkout within a Dialog * * @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 checkoutEventProcessor provides callbacks to allow clients to listen for and respond to checkout lifecycle events such as - * (failure, completion, cancellation, external link clicks). + * (failure, completion, cancellation, and browser/system prompts). * @param communicationClient optional handler for Embedded Checkout Protocol (ECP) messages. * Implement [CheckoutCommunicationClient] to intercept arbitrary ECP messages from the checkout * web page. Built-in messages ([ec.ready][EmbeddedCheckoutProtocol.METHOD_READY] and 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 new file mode 100644 index 00000000..91d4f809 --- /dev/null +++ b/platforms/android/lib/src/test/java/com/shopify/checkoutkit/CheckoutPresentationTest.kt @@ -0,0 +1,242 @@ +/* + * MIT License + * + * Copyright 2023-present, Shopify Inc. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.shopify.checkoutkit + +import android.net.Uri +import android.os.Looper +import android.webkit.GeolocationPermissions +import android.webkit.PermissionRequest +import android.webkit.ValueCallback +import android.webkit.WebChromeClient.FileChooserParams +import android.webkit.WebView +import android.widget.RelativeLayout +import androidx.activity.ComponentActivity +import androidx.core.view.children +import com.shopify.checkoutkit.lifecycleevents.emptyCompletedEvent +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.mockito.kotlin.mock +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever +import org.robolectric.Robolectric +import org.robolectric.RobolectricTestRunner +import org.robolectric.Shadows.shadowOf +import org.robolectric.shadows.ShadowDialog + +@RunWith(RobolectricTestRunner::class) +class CheckoutPresentationTest { + + private lateinit var activity: ComponentActivity + private lateinit var configuration: Configuration + + @Before + fun setUp() { + configuration = ShopifyCheckoutKit.getConfiguration() + ShopifyCheckoutKit.configure { + it.preloading = Preloading(enabled = false) + } + activity = Robolectric.buildActivity(ComponentActivity::class.java).get() + } + + @After + fun tearDown() { + ShopifyCheckoutKit.configure { + it.preloading = configuration.preloading + it.colorScheme = configuration.colorScheme + it.errorRecovery = configuration.errorRecovery + it.platform = configuration.platform + it.logLevel = configuration.logLevel + } + CheckoutWebView.cacheEntry = null + } + + @Test + fun `present builder invokes onFail callback`() { + var received: CheckoutException? = null + + ShopifyCheckoutKit.present("https://shopify.com", activity) { + onFail { received = it } + } + shadowOf(Looper.getMainLooper()).runToEndOfTasks() + + val dialog = ShadowDialog.getLatestDialog() as CheckoutDialog + val error = CheckoutKitException("boom", isRecoverable = false) + + dialog.closeCheckoutDialogWithError(error) + shadowOf(Looper.getMainLooper()).runToEndOfTasks() + + assertThat(received).isSameAs(error) + } + + @Test + fun `present builder invokes onCancel callback`() { + var canceled = false + + ShopifyCheckoutKit.present("https://shopify.com", activity) { + onCancel { canceled = true } + } + shadowOf(Looper.getMainLooper()).runToEndOfTasks() + + val dialog = ShadowDialog.getLatestDialog() + dialog.cancel() + shadowOf(Looper.getMainLooper()).runToEndOfTasks() + + assertThat(canceled).isTrue() + } + + @Test + fun `present builder forwards connected client to embedded checkout protocol`() { + val rawMessage = """{"jsonrpc":"2.0","method":"customMethod","id":"1"}""" + val client = mock() + whenever(client.process(rawMessage)).thenReturn(null) + + ShopifyCheckoutKit.present("https://shopify.com", activity) { + connect(client) + } + shadowOf(Looper.getMainLooper()).runToEndOfTasks() + + val dialog = ShadowDialog.getLatestDialog() as CheckoutDialog + val webView = dialog.currentWebView() + + webView.embeddedCheckoutProtocol().postMessage(rawMessage) + shadowOf(Looper.getMainLooper()).runToEndOfTasks() + + verify(client).process(rawMessage) + } + + @Test + fun `present builder invokes onPermissionRequest callback`() { + var received: PermissionRequest? = null + val permissionRequest = mock() + + ShopifyCheckoutKit.present("https://shopify.com", activity) { + onPermissionRequest { received = it } + } + shadowOf(Looper.getMainLooper()).runToEndOfTasks() + + val dialog = ShadowDialog.getLatestDialog() as CheckoutDialog + + dialog.currentWebView().getEventProcessor().onPermissionRequest(permissionRequest) + shadowOf(Looper.getMainLooper()).runToEndOfTasks() + + assertThat(received).isSameAs(permissionRequest) + } + + @Test + fun `present builder invokes onShowFileChooser callback`() { + val webView = mock() + val filePathCallback = mock>>() + val fileChooserParams = mock() + var receivedWebView: WebView? = null + var receivedFilePathCallback: ValueCallback>? = null + var receivedFileChooserParams: FileChooserParams? = null + + ShopifyCheckoutKit.present("https://shopify.com", activity) { + onShowFileChooser { presentedWebView, callback, params -> + receivedWebView = presentedWebView + receivedFilePathCallback = callback + receivedFileChooserParams = params + true + } + } + shadowOf(Looper.getMainLooper()).runToEndOfTasks() + + val dialog = ShadowDialog.getLatestDialog() as CheckoutDialog + + val handled = dialog.currentWebView().getEventProcessor().onShowFileChooser( + webView, + filePathCallback, + fileChooserParams, + ) + + assertThat(handled).isTrue() + assertThat(receivedWebView).isSameAs(webView) + assertThat(receivedFilePathCallback).isSameAs(filePathCallback) + assertThat(receivedFileChooserParams).isSameAs(fileChooserParams) + } + + @Test + fun `present builder invokes onGeolocationPermissionsShowPrompt callback`() { + val callback = mock() + var receivedOrigin: String? = null + var receivedCallback: GeolocationPermissions.Callback? = null + + ShopifyCheckoutKit.present("https://shopify.com", activity) { + onGeolocationPermissionsShowPrompt { origin, geolocationCallback -> + receivedOrigin = origin + receivedCallback = geolocationCallback + } + } + shadowOf(Looper.getMainLooper()).runToEndOfTasks() + + val dialog = ShadowDialog.getLatestDialog() as CheckoutDialog + + dialog.currentWebView().getEventProcessor().onGeolocationPermissionsShowPrompt("origin", callback) + + assertThat(receivedOrigin).isEqualTo("origin") + assertThat(receivedCallback).isSameAs(callback) + } + + @Test + fun `present builder invokes onGeolocationPermissionsHidePrompt callback`() { + var hidden = false + + ShopifyCheckoutKit.present("https://shopify.com", activity) { + onGeolocationPermissionsHidePrompt { hidden = true } + } + shadowOf(Looper.getMainLooper()).runToEndOfTasks() + + val dialog = ShadowDialog.getLatestDialog() as CheckoutDialog + + dialog.currentWebView().getEventProcessor().onGeolocationPermissionsHidePrompt() + + assertThat(hidden).isTrue() + } + + @Test + fun `present builder with no callbacks is safe`() { + val dialogHandle = ShopifyCheckoutKit.present("https://shopify.com", activity) {} + shadowOf(Looper.getMainLooper()).runToEndOfTasks() + + val dialog = ShadowDialog.getLatestDialog() as CheckoutDialog + + dialog.currentWebView().getEventProcessor().onCheckoutViewComplete(emptyCompletedEvent()) + dialogHandle?.dismiss() + shadowOf(Looper.getMainLooper()).runToEndOfTasks() + + assertThat(dialog.isShowing).isFalse() + } + + private fun CheckoutDialog.currentWebView(): CheckoutWebView = + findViewById(R.id.checkoutKitContainer) + .children.first { it is CheckoutWebView } as CheckoutWebView + + private fun CheckoutWebView.embeddedCheckoutProtocol(): EmbeddedCheckoutProtocol { + val field = CheckoutWebView::class.java.getDeclaredField("embeddedCheckoutProtocol") + field.isAccessible = true + return field.get(this) as EmbeddedCheckoutProtocol + } +} diff --git a/platforms/android/samples/MobileBuyIntegration/README.md b/platforms/android/samples/MobileBuyIntegration/README.md index 66158cdf..cf054942 100644 --- a/platforms/android/samples/MobileBuyIntegration/README.md +++ b/platforms/android/samples/MobileBuyIntegration/README.md @@ -2,6 +2,13 @@ A sample Android app demonstrating how to integrate [Checkout Kit](../../README.md) with the Shopify Storefront API using [Apollo Kotlin](https://github.com/apollographql/apollo-kotlin). +## Checkout flow + +The sample's cart flow demonstrates the Kotlin-first `ShopifyCheckoutKit.present(checkoutUrl, activity) { ... }` +API. It connects a typed `CheckoutProtocol.Client` to observe checkout state changes, including +completion, and uses the presentation builder for fail/cancel plus the sample's file chooser and +geolocation host callbacks so browser and system hooks stay on the Kotlin-first path. + ## Architecture The app uses **Apollo GraphQL** for all Storefront API communication. GraphQL operations are defined as `.graphql` files, and Apollo Kotlin's code generation tool produces type-safe Kotlin data classes from them. diff --git a/platforms/android/samples/MobileBuyIntegration/app/src/main/java/com/shopify/checkout_kit_mobile_buy_integration_sample/FileChooserResultContract.kt b/platforms/android/samples/MobileBuyIntegration/app/src/main/java/com/shopify/checkout_kit_mobile_buy_integration_sample/FileChooserResultContract.kt index 03b7364a..4b314ba8 100644 --- a/platforms/android/samples/MobileBuyIntegration/app/src/main/java/com/shopify/checkout_kit_mobile_buy_integration_sample/FileChooserResultContract.kt +++ b/platforms/android/samples/MobileBuyIntegration/app/src/main/java/com/shopify/checkout_kit_mobile_buy_integration_sample/FileChooserResultContract.kt @@ -38,35 +38,37 @@ import java.util.Date import java.util.Locale /** - * For handling 3p apps that require a FileChooser / Camera in response to onShowFileChooser() + * Handles file chooser / camera requests triggered by checkout. */ class FileChooserResultContract : ActivityResultContract() { private var cameraImageUri: Uri? = null override fun createIntent(context: Context, input: FileChooserParams): Intent { - val fileChooserIntent = input.createIntent() - fileChooserIntent.addCategory(Intent.CATEGORY_OPENABLE) - var mimeType = if (input.acceptTypes == null) DEFAULT_MIME_TYPE else input.acceptTypes[0] + val fileChooserIntent = input.createIntent().apply { + addCategory(Intent.CATEGORY_OPENABLE) + } + + var mimeType = input.acceptTypes.firstOrNull().orEmpty() if (!ACCEPTABLE_MIME_TYPES.contains(mimeType)) { mimeType = DEFAULT_MIME_TYPE } - fileChooserIntent.setType(mimeType) + fileChooserIntent.type = mimeType val photoFile = createImageFile(context) cameraImageUri = FileProvider.getUriForFile(context, "${context.packageName}.provider", photoFile) val cameraIntent = Intent(MediaStore.ACTION_IMAGE_CAPTURE).apply { putExtra(MediaStore.EXTRA_OUTPUT, cameraImageUri) - addFlags(Intent.FLAG_GRANT_WRITE_URI_PERMISSION) // Ensures URI can be written + addFlags(Intent.FLAG_GRANT_WRITE_URI_PERMISSION) } - val chooserIntent = Intent.createChooser(fileChooserIntent, context.getText(R.string.filechooser_title)) - chooserIntent.putExtra(Intent.EXTRA_INITIAL_INTENTS, arrayOf(cameraIntent)) - return chooserIntent + return Intent.createChooser(fileChooserIntent, context.getText(R.string.filechooser_title)).apply { + putExtra(Intent.EXTRA_INITIAL_INTENTS, arrayOf(cameraIntent)) + } } - override fun parseResult(resultCode: Int, intent: Intent?) : Uri? { + override fun parseResult(resultCode: Int, intent: Intent?): Uri? { return if (resultCode == RESULT_OK) { - intent?.data ?: cameraImageUri // Return the image URI captured by the camera + intent?.data ?: cameraImageUri } else { null } @@ -78,8 +80,8 @@ class FileChooserResultContract : ActivityResultContract private lateinit var showFileChooserLauncher: ActivityResultLauncher private lateinit var geolocationLauncher: ActivityResultLauncher> - // State related to file chooser requests (e.g. for using a file chooser/camera for proving identity) private var filePathCallback: ValueCallback>? = null private var fileChooserParams: FileChooserParams? = null - // State related to geolocation requests (e.g. for pickup points - use my location) private var geolocationPermissionCallback: GeolocationPermissions.Callback? = null private var geolocationOrigin: String? = null @@ -73,60 +70,54 @@ class MainActivity : ComponentActivity() { requestPermissionLauncher = registerForActivityResult(ActivityResultContracts.RequestPermission()) { isGranted -> val fileChooserParams = this.fileChooserParams if (isGranted && fileChooserParams != null) { - this.showFileChooserLauncher.launch(fileChooserParams) + showFileChooserLauncher.launch(fileChooserParams) this.fileChooserParams = null } - // N.B. a file chooser intent (without camera) could be launched here if the permission was denied } showFileChooserLauncher = registerForActivityResult(FileChooserResultContract()) { uri: Uri? -> - // invoke the callback with the selected file filePathCallback?.onReceiveValue(if (uri != null) arrayOf(uri) else null) - - // reset fileChooser state filePathCallback = null fileChooserParams = null } geolocationLauncher = registerForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) { result -> val isGranted = result.any { it.value } - // invoke the callback with the permission result geolocationPermissionCallback?.invoke(geolocationOrigin, isGranted, false) - - // reset geolocation state geolocationPermissionCallback = null geolocationOrigin = null } } - // Show a file chooser when prompted by the event processor fun onShowFileChooser(filePathCallback: ValueCallback>, fileChooserParams: FileChooserParams): Boolean { this.filePathCallback = filePathCallback if (permissionAlreadyGranted(Manifest.permission.CAMERA)) { - // Permissions already granted, launch chooser immediately showFileChooserLauncher.launch(fileChooserParams) this.fileChooserParams = null } else { - // Permissions not yet granted, request permission before launching chooser this.fileChooserParams = fileChooserParams requestPermissionLauncher.launch(Manifest.permission.CAMERA) } return true } - // Deal with requests from Checkout to show the geolocation permissions prompt fun onGeolocationPermissionsShowPrompt(origin: String, callback: GeolocationPermissions.Callback) { - if (permissionAlreadyGranted(Manifest.permission.ACCESS_FINE_LOCATION) && permissionAlreadyGranted(Manifest.permission.ACCESS_COARSE_LOCATION)) { - // Permissions already granted, invoke callback immediately + if (permissionAlreadyGranted(Manifest.permission.ACCESS_FINE_LOCATION) && + permissionAlreadyGranted(Manifest.permission.ACCESS_COARSE_LOCATION) + ) { callback(origin, true, true) } else { - // Permissions not yet granted, request permissions before invoking callback geolocationPermissionCallback = callback geolocationOrigin = origin geolocationLauncher.launch(arrayOf(Manifest.permission.ACCESS_FINE_LOCATION, Manifest.permission.ACCESS_COARSE_LOCATION)) } } + fun onGeolocationPermissionsHidePrompt() { + geolocationPermissionCallback = null + geolocationOrigin = null + } + private fun permissionAlreadyGranted(permission: String): Boolean { return ContextCompat.checkSelfPermission(this, permission) == PackageManager.PERMISSION_GRANTED } diff --git a/platforms/android/samples/MobileBuyIntegration/app/src/main/java/com/shopify/checkout_kit_mobile_buy_integration_sample/cart/CartView.kt b/platforms/android/samples/MobileBuyIntegration/app/src/main/java/com/shopify/checkout_kit_mobile_buy_integration_sample/cart/CartView.kt index c3cb22bb..6c445671 100644 --- a/platforms/android/samples/MobileBuyIntegration/app/src/main/java/com/shopify/checkout_kit_mobile_buy_integration_sample/cart/CartView.kt +++ b/platforms/android/samples/MobileBuyIntegration/app/src/main/java/com/shopify/checkout_kit_mobile_buy_integration_sample/cart/CartView.kt @@ -71,12 +71,10 @@ import com.shopify.checkout_kit_mobile_buy_integration_sample.common.components. import com.shopify.checkout_kit_mobile_buy_integration_sample.common.components.ProgressIndicator import com.shopify.checkout_kit_mobile_buy_integration_sample.common.ui.theme.horizontalPadding import com.shopify.checkout_kit_mobile_buy_integration_sample.common.ui.theme.verticalPadding -import com.shopify.checkoutkit.DefaultCheckoutEventProcessor @Composable -fun CartView( +fun CartView( navController: NavController, - checkoutEventProcessor: T, cartViewModel: CartViewModel, ) { @@ -120,7 +118,7 @@ fun CartView( cartViewModel.presentCheckout( state.checkoutUrl, activity, - checkoutEventProcessor + navController, ) }, totalAmount = state.cartTotals.totalAmount, diff --git a/platforms/android/samples/MobileBuyIntegration/app/src/main/java/com/shopify/checkout_kit_mobile_buy_integration_sample/cart/CartViewModel.kt b/platforms/android/samples/MobileBuyIntegration/app/src/main/java/com/shopify/checkout_kit_mobile_buy_integration_sample/cart/CartViewModel.kt index dbe85084..7426a609 100644 --- a/platforms/android/samples/MobileBuyIntegration/app/src/main/java/com/shopify/checkout_kit_mobile_buy_integration_sample/cart/CartViewModel.kt +++ b/platforms/android/samples/MobileBuyIntegration/app/src/main/java/com/shopify/checkout_kit_mobile_buy_integration_sample/cart/CartViewModel.kt @@ -22,22 +22,27 @@ */ package com.shopify.checkout_kit_mobile_buy_integration_sample.cart +import android.widget.Toast import androidx.activity.ComponentActivity import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import androidx.navigation.NavController +import com.shopify.checkout_kit_mobile_buy_integration_sample.MainActivity import com.shopify.checkout_kit_mobile_buy_integration_sample.R import com.shopify.checkout_kit_mobile_buy_integration_sample.cart.data.CartRepository import com.shopify.checkout_kit_mobile_buy_integration_sample.cart.data.CartState import com.shopify.checkout_kit_mobile_buy_integration_sample.common.ID import com.shopify.checkout_kit_mobile_buy_integration_sample.common.SnackbarController import com.shopify.checkout_kit_mobile_buy_integration_sample.common.SnackbarEvent +import com.shopify.checkout_kit_mobile_buy_integration_sample.common.logs.Logger import com.shopify.checkout_kit_mobile_buy_integration_sample.common.navigation.Screen import com.shopify.checkout_kit_mobile_buy_integration_sample.settings.PreferencesManager import com.shopify.checkout_kit_mobile_buy_integration_sample.settings.authentication.data.CustomerRepository +import com.shopify.checkoutkit.Checkout import com.shopify.checkoutkit.CheckoutProtocol -import com.shopify.checkoutkit.DefaultCheckoutEventProcessor +import com.shopify.checkoutkit.CheckoutException import com.shopify.checkoutkit.ShopifyCheckoutKit +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow @@ -50,6 +55,7 @@ class CartViewModel( private val cartRepository: CartRepository, private val preferencesManager: PreferencesManager, private val customerRepository: CustomerRepository, + private val logger: Logger, ) : ViewModel() { private val _cartState = MutableStateFlow(CartState.Empty) @@ -107,21 +113,33 @@ class CartViewModel( _cartState.value = CartState.Empty } - fun presentCheckout( + fun presentCheckout( url: String, activity: ComponentActivity, - eventProcessor: T + navController: NavController, ) { Timber.i("Presenting checkout with $url") - val client = CheckoutProtocol.Client() - .on(CheckoutProtocol.start) { Timber.i("ECP ec.start: $it") } - .on(CheckoutProtocol.complete) { Timber.i("ECP ec.complete: $it") } - .on(CheckoutProtocol.error) { Timber.i("ECP ec.error: $it") } - .on(CheckoutProtocol.buyerChange) { Timber.i("ECP ec.buyer.change: $it") } - .on(CheckoutProtocol.totalsChange) { Timber.i("ECP ec.totals.change: $it") } - .on(CheckoutProtocol.lineItemsChange) { Timber.i("ECP ec.line_items.change: $it") } - .on(CheckoutProtocol.messagesChange) { Timber.i("ECP ec.messages.change: $it") } - ShopifyCheckoutKit.present(url, activity, eventProcessor, client) + val sampleActivity = activity as? MainActivity + ShopifyCheckoutKit.present(url, activity) { + onFail { error -> + handleCheckoutFailed(error, activity) + } + onCancel { + handleCheckoutCanceled() + } + sampleActivity?.let { mainActivity -> + onShowFileChooser { _, filePathCallback, fileChooserParams -> + mainActivity.onShowFileChooser(filePathCallback, fileChooserParams) + } + onGeolocationPermissionsShowPrompt { origin, callback -> + mainActivity.onGeolocationPermissionsShowPrompt(origin, callback) + } + onGeolocationPermissionsHidePrompt { + mainActivity.onGeolocationPermissionsHidePrompt() + } + } + connect(buildCommunicationClient(navController)) + } } fun preloadCheckout( @@ -141,6 +159,51 @@ class CartViewModel( navController.navigate(Screen.Products.route) } + private fun handleCheckoutCompleted( + checkout: Checkout, + navController: NavController, + ) { + logger.log(checkout) + clearCart() + viewModelScope.launch(Dispatchers.Main.immediate) { + navController.popBackStack(Screen.Product.route, false) + } + } + + private fun handleCheckoutFailed( + error: CheckoutException, + activity: ComponentActivity, + ) { + logger.log("Checkout failed", error) + if (!error.isRecoverable) { + clearCart() + viewModelScope.launch(Dispatchers.Main.immediate) { + Toast.makeText( + activity, + activity.getText(R.string.checkout_error), + Toast.LENGTH_SHORT, + ).show() + } + } + } + + private fun handleCheckoutCanceled() { + logger.log("Checkout canceled") + } + + private fun buildCommunicationClient(navController: NavController): CheckoutProtocol.Client = + CheckoutProtocol.Client() + .on(CheckoutProtocol.start) { Timber.i("ECP ec.start: $it") } + .on(CheckoutProtocol.complete) { checkout -> + Timber.i("ECP ec.complete: $checkout") + handleCheckoutCompleted(checkout, navController) + } + .on(CheckoutProtocol.error) { Timber.i("ECP ec.error: $it") } + .on(CheckoutProtocol.buyerChange) { Timber.i("ECP ec.buyer.change: $it") } + .on(CheckoutProtocol.totalsChange) { Timber.i("ECP ec.totals.change: $it") } + .on(CheckoutProtocol.lineItemsChange) { Timber.i("ECP ec.line_items.change: $it") } + .on(CheckoutProtocol.messagesChange) { Timber.i("ECP ec.messages.change: $it") } + private fun performCartLinesAdd(cartId: ID, variantId: ID, quantity: Int, onComplete: OnComplete) = viewModelScope.launch { Timber.i("Adding cart lines to existing cart: $cartId, variant: $variantId, and $quantity") try { diff --git a/platforms/android/samples/MobileBuyIntegration/app/src/main/java/com/shopify/checkout_kit_mobile_buy_integration_sample/common/MobileBuyEventProcessor.kt b/platforms/android/samples/MobileBuyIntegration/app/src/main/java/com/shopify/checkout_kit_mobile_buy_integration_sample/common/MobileBuyEventProcessor.kt deleted file mode 100644 index 57193b68..00000000 --- a/platforms/android/samples/MobileBuyIntegration/app/src/main/java/com/shopify/checkout_kit_mobile_buy_integration_sample/common/MobileBuyEventProcessor.kt +++ /dev/null @@ -1,93 +0,0 @@ -/* - * MIT License - * - * Copyright 2023-present, Shopify Inc. - * - * Permission is hereby granted, free of charge, to any person obtaining a copy - * of this software and associated documentation files (the "Software"), to deal - * in the Software without restriction, including without limitation the rights - * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell - * copies of the Software, and to permit persons to whom the Software is - * furnished to do so, subject to the following conditions: - * - * The above copyright notice and this permission notice shall be included in all - * copies or substantial portions of the Software. - * - * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR - * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, - * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE - * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER - * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, - * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - */ -package com.shopify.checkout_kit_mobile_buy_integration_sample.common - -import android.content.Context -import android.net.Uri -import android.webkit.GeolocationPermissions -import android.webkit.ValueCallback -import android.webkit.WebChromeClient -import android.webkit.WebView -import android.widget.Toast -import androidx.navigation.NavController -import com.shopify.checkout_kit_mobile_buy_integration_sample.MainActivity -import com.shopify.checkout_kit_mobile_buy_integration_sample.R -import com.shopify.checkout_kit_mobile_buy_integration_sample.cart.CartViewModel -import com.shopify.checkout_kit_mobile_buy_integration_sample.common.logs.Logger -import com.shopify.checkout_kit_mobile_buy_integration_sample.common.navigation.Screen -import com.shopify.checkoutkit.CheckoutException -import com.shopify.checkoutkit.DefaultCheckoutEventProcessor -import com.shopify.checkoutkit.lifecycleevents.CheckoutCompletedEvent -import kotlinx.coroutines.DelicateCoroutinesApi -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.GlobalScope -import kotlinx.coroutines.launch - -@OptIn(DelicateCoroutinesApi::class) -class MobileBuyEventProcessor( - private val cartViewModel: CartViewModel, - private val navController: NavController, - private val logger: Logger, - private val context: Context -) : DefaultCheckoutEventProcessor() { - override fun onCheckoutCompleted(checkoutCompletedEvent: CheckoutCompletedEvent) { - logger.log(checkoutCompletedEvent) - - cartViewModel.clearCart() - GlobalScope.launch(Dispatchers.Main) { - navController.popBackStack(Screen.Product.route, false) - } - } - - override fun onCheckoutFailed(error: CheckoutException) { - logger.log("Checkout failed", error) - - if (!error.isRecoverable) { - cartViewModel.clearCart() - GlobalScope.launch(Dispatchers.Main) { - Toast.makeText( - context, - context.getText(R.string.checkout_error), - Toast.LENGTH_SHORT - ).show() - } - } - } - - override fun onCheckoutCanceled() { - // optionally respond to checkout being canceled/closed - logger.log("Checkout canceled") - } - - override fun onGeolocationPermissionsShowPrompt(origin: String, callback: GeolocationPermissions.Callback) { - return (context as MainActivity).onGeolocationPermissionsShowPrompt(origin, callback) - } - - override fun onShowFileChooser( - webView: WebView, - filePathCallback: ValueCallback>, - fileChooserParams: WebChromeClient.FileChooserParams, - ): Boolean { - return (context as MainActivity).onShowFileChooser(filePathCallback, fileChooserParams) - } -} diff --git a/platforms/android/samples/MobileBuyIntegration/app/src/main/java/com/shopify/checkout_kit_mobile_buy_integration_sample/common/di/AppModule.kt b/platforms/android/samples/MobileBuyIntegration/app/src/main/java/com/shopify/checkout_kit_mobile_buy_integration_sample/common/di/AppModule.kt index afa3a26c..dc4ce1eb 100644 --- a/platforms/android/samples/MobileBuyIntegration/app/src/main/java/com/shopify/checkout_kit_mobile_buy_integration_sample/common/di/AppModule.kt +++ b/platforms/android/samples/MobileBuyIntegration/app/src/main/java/com/shopify/checkout_kit_mobile_buy_integration_sample/common/di/AppModule.kt @@ -146,6 +146,6 @@ val appModules = module { viewModelOf(::AccountViewModel) single { // singleton instance of shared cart view model - CartViewModel(get(), get(), get()) + CartViewModel(get(), get(), get(), get()) } } diff --git a/platforms/android/samples/MobileBuyIntegration/app/src/main/java/com/shopify/checkout_kit_mobile_buy_integration_sample/common/logs/LogDatabase.kt b/platforms/android/samples/MobileBuyIntegration/app/src/main/java/com/shopify/checkout_kit_mobile_buy_integration_sample/common/logs/LogDatabase.kt index 1086ca77..3a8d97ee 100644 --- a/platforms/android/samples/MobileBuyIntegration/app/src/main/java/com/shopify/checkout_kit_mobile_buy_integration_sample/common/logs/LogDatabase.kt +++ b/platforms/android/samples/MobileBuyIntegration/app/src/main/java/com/shopify/checkout_kit_mobile_buy_integration_sample/common/logs/LogDatabase.kt @@ -24,7 +24,6 @@ package com.shopify.checkout_kit_mobile_buy_integration_sample.common.logs import androidx.room.Database import androidx.room.RoomDatabase -import androidx.room.TypeConverters import androidx.room.migration.Migration import androidx.sqlite.db.SupportSQLiteDatabase @@ -33,7 +32,6 @@ import androidx.sqlite.db.SupportSQLiteDatabase version = 3, exportSchema = false, ) -@TypeConverters(Converters::class) abstract class LogDatabase : RoomDatabase() { abstract fun logDao(): LogDao } diff --git a/platforms/android/samples/MobileBuyIntegration/app/src/main/java/com/shopify/checkout_kit_mobile_buy_integration_sample/common/logs/LogLine.kt b/platforms/android/samples/MobileBuyIntegration/app/src/main/java/com/shopify/checkout_kit_mobile_buy_integration_sample/common/logs/LogLine.kt index 234c4087..d0eb0043 100644 --- a/platforms/android/samples/MobileBuyIntegration/app/src/main/java/com/shopify/checkout_kit_mobile_buy_integration_sample/common/logs/LogLine.kt +++ b/platforms/android/samples/MobileBuyIntegration/app/src/main/java/com/shopify/checkout_kit_mobile_buy_integration_sample/common/logs/LogLine.kt @@ -22,14 +22,10 @@ */ package com.shopify.checkout_kit_mobile_buy_integration_sample.common.logs +import androidx.room.ColumnInfo import androidx.room.Embedded import androidx.room.Entity import androidx.room.PrimaryKey -import androidx.room.TypeConverter -import com.shopify.checkoutkit.lifecycleevents.CheckoutCompletedEvent -import com.shopify.checkoutkit.lifecycleevents.OrderDetails -import kotlinx.serialization.encodeToString -import kotlinx.serialization.json.Json import java.util.Date import java.util.UUID @@ -40,7 +36,7 @@ data class LogLine( val message: String, val type: LogType, @Embedded(prefix = "error_details") val errorDetails: ErrorDetails? = null, - @Embedded(prefix = "checkout_completed") val checkoutCompleted: CheckoutCompletedEvent? = null, + @ColumnInfo(name = "checkout_completedorderDetails") val checkoutCompletedPayload: String? = null, ) enum class LogType { @@ -51,15 +47,3 @@ data class ErrorDetails( val type: String?, val message: String, ) - -class Converters { - @TypeConverter - fun orderDetailsToString(value: OrderDetails): String { - return Json.encodeToString(value) - } - - @TypeConverter - fun stringToOrderDetails(value: String): OrderDetails { - return Json.decodeFromString(value) - } -} diff --git a/platforms/android/samples/MobileBuyIntegration/app/src/main/java/com/shopify/checkout_kit_mobile_buy_integration_sample/common/logs/Logger.kt b/platforms/android/samples/MobileBuyIntegration/app/src/main/java/com/shopify/checkout_kit_mobile_buy_integration_sample/common/logs/Logger.kt index 9bfc8cb2..7700d1c1 100644 --- a/platforms/android/samples/MobileBuyIntegration/app/src/main/java/com/shopify/checkout_kit_mobile_buy_integration_sample/common/logs/Logger.kt +++ b/platforms/android/samples/MobileBuyIntegration/app/src/main/java/com/shopify/checkout_kit_mobile_buy_integration_sample/common/logs/Logger.kt @@ -22,10 +22,12 @@ */ package com.shopify.checkout_kit_mobile_buy_integration_sample.common.logs +import com.shopify.checkoutkit.Checkout import com.shopify.checkoutkit.CheckoutException -import com.shopify.checkoutkit.lifecycleevents.CheckoutCompletedEvent import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.launch +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json import java.util.Date import java.util.UUID @@ -42,12 +44,12 @@ class Logger( ) } - fun log(checkoutCompletedEvent: CheckoutCompletedEvent) { + fun log(checkout: Checkout) { insert( LogLine( type = LogType.CHECKOUT_COMPLETED, - message = "Checkout completed", - checkoutCompleted = checkoutCompletedEvent, + message = "Checkout completed: ${checkout.order?.id ?: "unknown"}", + checkoutCompletedPayload = Json.encodeToString(checkout), ) ) } diff --git a/platforms/android/samples/MobileBuyIntegration/app/src/main/java/com/shopify/checkout_kit_mobile_buy_integration_sample/common/navigation/CheckoutKitNavHost.kt b/platforms/android/samples/MobileBuyIntegration/app/src/main/java/com/shopify/checkout_kit_mobile_buy_integration_sample/common/navigation/CheckoutKitNavHost.kt index 897d5dc7..31d1dd88 100644 --- a/platforms/android/samples/MobileBuyIntegration/app/src/main/java/com/shopify/checkout_kit_mobile_buy_integration_sample/common/navigation/CheckoutKitNavHost.kt +++ b/platforms/android/samples/MobileBuyIntegration/app/src/main/java/com/shopify/checkout_kit_mobile_buy_integration_sample/common/navigation/CheckoutKitNavHost.kt @@ -23,7 +23,6 @@ package com.shopify.checkout_kit_mobile_buy_integration_sample.common.navigation import androidx.compose.runtime.Composable -import androidx.compose.ui.platform.LocalContext import androidx.navigation.NavBackStackEntry import androidx.navigation.NavHostController import androidx.navigation.compose.NavHost @@ -31,8 +30,6 @@ import androidx.navigation.compose.composable import androidx.navigation.compose.rememberNavController import com.shopify.checkout_kit_mobile_buy_integration_sample.cart.CartView import com.shopify.checkout_kit_mobile_buy_integration_sample.cart.CartViewModel -import com.shopify.checkout_kit_mobile_buy_integration_sample.common.MobileBuyEventProcessor -import com.shopify.checkout_kit_mobile_buy_integration_sample.common.logs.Logger import com.shopify.checkout_kit_mobile_buy_integration_sample.home.HomeView import com.shopify.checkout_kit_mobile_buy_integration_sample.logs.LogsView import com.shopify.checkout_kit_mobile_buy_integration_sample.logs.LogsViewModel @@ -43,7 +40,6 @@ import com.shopify.checkout_kit_mobile_buy_integration_sample.settings.SettingsV import com.shopify.checkout_kit_mobile_buy_integration_sample.settings.SettingsViewModel import com.shopify.checkout_kit_mobile_buy_integration_sample.settings.account.AccountView import com.shopify.checkout_kit_mobile_buy_integration_sample.settings.authentication.LoginView -import org.koin.compose.koinInject import java.net.URLEncoder import java.nio.charset.StandardCharsets @@ -101,7 +97,6 @@ fun CheckoutKitNavHost( cartViewModel: CartViewModel, settingsViewModel: SettingsViewModel, logsViewModel: LogsViewModel, - logger: Logger = koinInject(), ) { NavHost( navController = navController, @@ -125,16 +120,9 @@ fun CheckoutKitNavHost( } composable(Screen.Cart.route) { - val activity = LocalContext.current CartView( cartViewModel = cartViewModel, navController = navController, - checkoutEventProcessor = MobileBuyEventProcessor( - cartViewModel, - navController, - logger, - activity, - ) ) } diff --git a/platforms/android/samples/MobileBuyIntegration/app/src/main/java/com/shopify/checkout_kit_mobile_buy_integration_sample/logs/details/CheckoutCompletedDetails.kt b/platforms/android/samples/MobileBuyIntegration/app/src/main/java/com/shopify/checkout_kit_mobile_buy_integration_sample/logs/details/CheckoutCompletedDetails.kt index dccb1d1e..453d3bf1 100644 --- a/platforms/android/samples/MobileBuyIntegration/app/src/main/java/com/shopify/checkout_kit_mobile_buy_integration_sample/logs/details/CheckoutCompletedDetails.kt +++ b/platforms/android/samples/MobileBuyIntegration/app/src/main/java/com/shopify/checkout_kit_mobile_buy_integration_sample/logs/details/CheckoutCompletedDetails.kt @@ -27,25 +27,29 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier -import com.shopify.checkoutkit.lifecycleevents.CheckoutCompletedEvent +import kotlinx.serialization.decodeFromString import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.JsonElement import kotlinx.serialization.json.Json @Composable fun CheckoutCompletedDetails( - event: CheckoutCompletedEvent?, + payload: String?, prettyJson: Json, ) { LogDetails( header = "Details", - message = prettyJson.encodeDataToString(event), + message = prettyJson.prettyPrintedPayload(payload), modifier = Modifier .fillMaxWidth() .background(color = MaterialTheme.colorScheme.surface) ) } -private inline fun Json.encodeDataToString(el: T?, default: String = "n/a"): String { - if (el == null) return default - return encodeToString(el) +private fun Json.prettyPrintedPayload(payload: String?, default: String = "n/a"): String { + if (payload == null) return default + return runCatching { + val jsonElement: JsonElement = decodeFromString(payload) + encodeToString(JsonElement.serializer(), jsonElement) + }.getOrDefault(payload) } diff --git a/platforms/android/samples/MobileBuyIntegration/app/src/main/java/com/shopify/checkout_kit_mobile_buy_integration_sample/logs/details/LogDetailModal.kt b/platforms/android/samples/MobileBuyIntegration/app/src/main/java/com/shopify/checkout_kit_mobile_buy_integration_sample/logs/details/LogDetailModal.kt index eba90d44..97b9ba58 100644 --- a/platforms/android/samples/MobileBuyIntegration/app/src/main/java/com/shopify/checkout_kit_mobile_buy_integration_sample/logs/details/LogDetailModal.kt +++ b/platforms/android/samples/MobileBuyIntegration/app/src/main/java/com/shopify/checkout_kit_mobile_buy_integration_sample/logs/details/LogDetailModal.kt @@ -57,7 +57,7 @@ fun LogDetailModal( when (logLine?.type) { LogType.STANDARD -> LogDetails("Checkout Lifecycle Event", logLine.message, Modifier.fillMaxWidth()) LogType.ERROR -> LogDetails("Checkout Error", "${logLine.errorDetails}", Modifier.fillMaxWidth()) - LogType.CHECKOUT_COMPLETED -> CheckoutCompletedDetails(logLine.checkoutCompleted, prettyJson) + LogType.CHECKOUT_COMPLETED -> CheckoutCompletedDetails(logLine.checkoutCompletedPayload, prettyJson) else -> Text("Unknown log type ${logLine?.type}") } }