diff --git a/platforms/android/CLAUDE.md b/platforms/android/CLAUDE.md index bb8e8ade..35a44004 100644 --- a/platforms/android/CLAUDE.md +++ b/platforms/android/CLAUDE.md @@ -28,7 +28,8 @@ The sample is a separate Gradle composite (`samples/MobileBuyIntegration/setting - **`FallbackWebView.kt`** — minimal WebView swapped in during error recovery when the primary path fails. - **`CheckoutBridge.kt`** — the JS ↔ native bridge. `SCHEMA_VERSION` is a cross-boundary contract with the web checkout team; bumping it requires coordination with them. - **`Configuration.kt`** — runtime config container (color scheme, preload enable, log level, error recovery policy). Any config change clears the WebView cache. -- **`CheckoutEventProcessor.kt`** + **`DefaultCheckoutEventProcessor`** — consumer-implemented lifecycle interface (completion, failure, permission prompts, link clicks). Changes here are consumer API changes. +- **`CheckoutListener.kt`** + **`DefaultCheckoutListener`** — consumer-implemented lifecycle interface (failure, cancellation, permission prompts, file chooser). Changes here are consumer API changes. +- **`CheckoutPresentation.kt`** — Kotlin-first builder for per-presentation callbacks (`onFail`, `onCancel`, browser/system hooks, ECP `connect(...)`). Builds a `DefaultCheckoutListener` internally. ## Testing patterns diff --git a/platforms/android/README.md b/platforms/android/README.md index 8a1fd761..7e7588aa 100644 --- a/platforms/android/README.md +++ b/platforms/android/README.md @@ -299,10 +299,10 @@ ShopifyCheckoutKit.present(checkoutUrl, activity) { ``` If you prefer a reusable object, or are integrating from Java, extend -`DefaultCheckoutEventProcessor` and pass it to the existing `present(...)` overload: +`DefaultCheckoutListener` and pass it to the existing `present(...)` overload: ```kotlin -val processor = object : DefaultCheckoutEventProcessor() { +val listener = object : DefaultCheckoutListener() { override fun onShowFileChooser( webView: WebView, filePathCallback: ValueCallback>, @@ -324,11 +324,11 @@ val processor = object : DefaultCheckoutEventProcessor() { } } -ShopifyCheckoutKit.present(checkoutUrl, context, processor) +ShopifyCheckoutKit.present(checkoutUrl, context, listener) ``` > [!Note] -> The `DefaultCheckoutEventProcessor` overload remains available for reusable or Java-facing +> The `DefaultCheckoutListener` overload remains available for reusable or Java-facing > integrations and provides default implementations for optional browser/system callbacks. ### Error handling diff --git a/platforms/android/lib/api/lib.api b/platforms/android/lib/api/lib.api index b75c5e00..246dc1fb 100644 --- a/platforms/android/lib/api/lib.api +++ b/platforms/android/lib/api/lib.api @@ -664,16 +664,6 @@ public final class com/shopify/checkoutkit/CheckoutError$Companion { public final fun serializer ()Lkotlinx/serialization/KSerializer; } -public abstract interface class com/shopify/checkoutkit/CheckoutEventProcessor { - public abstract fun onCheckoutCanceled ()V - public abstract fun onCheckoutCompleted (Lcom/shopify/checkoutkit/lifecycleevents/CheckoutCompletedEvent;)V - public abstract fun onCheckoutFailed (Lcom/shopify/checkoutkit/CheckoutException;)V - public abstract fun onGeolocationPermissionsHidePrompt ()V - public abstract fun onGeolocationPermissionsShowPrompt (Ljava/lang/String;Landroid/webkit/GeolocationPermissions$Callback;)V - public abstract fun onPermissionRequest (Landroid/webkit/PermissionRequest;)V - public abstract fun onShowFileChooser (Landroid/webkit/WebView;Landroid/webkit/ValueCallback;Landroid/webkit/WebChromeClient$FileChooserParams;)Z -} - public abstract class com/shopify/checkoutkit/CheckoutException : java/lang/Exception { public static final field Companion Lcom/shopify/checkoutkit/CheckoutException$Companion; public synthetic fun (ILjava/lang/String;Ljava/lang/String;ZLkotlinx/serialization/internal/SerializationConstructorMarker;)V @@ -756,6 +746,15 @@ public final class com/shopify/checkoutkit/CheckoutLineItem$Companion { public final fun serializer ()Lkotlinx/serialization/KSerializer; } +public abstract interface class com/shopify/checkoutkit/CheckoutListener { + public abstract fun onCheckoutCanceled ()V + public abstract fun onCheckoutFailed (Lcom/shopify/checkoutkit/CheckoutException;)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 + public abstract fun onShowFileChooser (Landroid/webkit/WebView;Landroid/webkit/ValueCallback;Landroid/webkit/WebChromeClient$FileChooserParams;)Z +} + 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 @@ -1372,7 +1371,7 @@ public final class com/shopify/checkoutkit/CredentialResult$Companion { public final fun serializer ()Lkotlinx/serialization/KSerializer; } -public abstract class com/shopify/checkoutkit/DefaultCheckoutEventProcessor : com/shopify/checkoutkit/CheckoutEventProcessor { +public abstract class com/shopify/checkoutkit/DefaultCheckoutListener : com/shopify/checkoutkit/CheckoutListener { public fun ()V public fun onGeolocationPermissionsHidePrompt ()V public fun onGeolocationPermissionsShowPrompt (Ljava/lang/String;Landroid/webkit/GeolocationPermissions$Callback;)V @@ -3933,10 +3932,10 @@ public final class com/shopify/checkoutkit/ShopifyCheckoutKit { public static final fun configure (Lcom/shopify/checkoutkit/ConfigurationUpdater;)V public static final fun getConfiguration ()Lcom/shopify/checkoutkit/Configuration; public static final fun invalidate ()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 fun present (Ljava/lang/String;Landroidx/activity/ComponentActivity;Lcom/shopify/checkoutkit/DefaultCheckoutListener;)Lcom/shopify/checkoutkit/CheckoutKitDialog; + public static final fun present (Ljava/lang/String;Landroidx/activity/ComponentActivity;Lcom/shopify/checkoutkit/DefaultCheckoutListener;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; + public static synthetic fun present$default (Ljava/lang/String;Landroidx/activity/ComponentActivity;Lcom/shopify/checkoutkit/DefaultCheckoutListener;Lcom/shopify/checkoutkit/CheckoutCommunicationClient;ILjava/lang/Object;)Lcom/shopify/checkoutkit/CheckoutKitDialog; } public final class com/shopify/checkoutkit/Signals { diff --git a/platforms/android/lib/src/main/java/com/shopify/checkoutkit/BaseWebView.kt b/platforms/android/lib/src/main/java/com/shopify/checkoutkit/BaseWebView.kt index b9278dc4..d7ab5bff 100644 --- a/platforms/android/lib/src/main/java/com/shopify/checkoutkit/BaseWebView.kt +++ b/platforms/android/lib/src/main/java/com/shopify/checkoutkit/BaseWebView.kt @@ -55,7 +55,7 @@ internal abstract class BaseWebView(context: Context, attributeSet: AttributeSet configureWebView() } - abstract fun getEventProcessor(): CheckoutWebViewEventProcessor + abstract fun getListener(): CheckoutWebViewListener abstract val recoverErrors: Boolean private fun configureWebView() { @@ -77,22 +77,22 @@ internal abstract class BaseWebView(context: Context, attributeSet: AttributeSet override fun onProgressChanged(view: WebView?, newProgress: Int) { super.onProgressChanged(view, newProgress) log.d(LOG_TAG, "On progress change called. New progress $newProgress.") - getEventProcessor().updateProgressBar(newProgress) + getListener().updateProgressBar(newProgress) } override fun onGeolocationPermissionsShowPrompt(origin: String, callback: GeolocationPermissions.Callback) { - log.d(LOG_TAG, "onGeolocationPermissionsShowPrompt called, origin $origin, invoking eventProcessor callback.") - getEventProcessor().onGeolocationPermissionsShowPrompt(origin, callback) + log.d(LOG_TAG, "onGeolocationPermissionsShowPrompt called, origin $origin, invoking listener callback.") + getListener().onGeolocationPermissionsShowPrompt(origin, callback) } override fun onGeolocationPermissionsHidePrompt() { - log.d(LOG_TAG, "onGeolocationPermissionsHidePrompt called, invoking eventProcessor callback.") - getEventProcessor().onGeolocationPermissionsHidePrompt() + log.d(LOG_TAG, "onGeolocationPermissionsHidePrompt called, invoking listener callback.") + getListener().onGeolocationPermissionsHidePrompt() } override fun onPermissionRequest(request: PermissionRequest) { - log.d(LOG_TAG, "onPermissionRequest called $request, invoking eventProcessor callback.") - getEventProcessor().onPermissionRequest(request) + log.d(LOG_TAG, "onPermissionRequest called $request, invoking listener callback.") + getListener().onPermissionRequest(request) } override fun onShowFileChooser( @@ -100,8 +100,8 @@ internal abstract class BaseWebView(context: Context, attributeSet: AttributeSet filePathCallback: ValueCallback>, fileChooserParams: FileChooserParams, ): Boolean { - log.d(LOG_TAG, "onShowFileChooser called, invoking eventProcessor callback.") - return getEventProcessor().onShowFileChooser(webView, filePathCallback, fileChooserParams) + log.d(LOG_TAG, "onShowFileChooser called, invoking listener callback.") + return getListener().onShowFileChooser(webView, filePathCallback, fileChooserParams) } } isHorizontalScrollBarEnabled = false @@ -144,8 +144,8 @@ internal abstract class BaseWebView(context: Context, attributeSet: AttributeSet return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O && !detail.didCrash()) { // Renderer was killed because system ran out of memory. log.d(LOG_TAG, "onRenderProcessGone called, calling onCheckoutFailedWithError") - val eventProcessor = getEventProcessor() - eventProcessor.onCheckoutViewFailedWithError( + val listener = getListener() + listener.onCheckoutViewFailedWithError( CheckoutKitException( errorDescription = "Render process gone.", errorCode = CheckoutKitException.RENDER_PROCESS_GONE, @@ -207,11 +207,11 @@ internal abstract class BaseWebView(context: Context, attributeSet: AttributeSet LOG_TAG, "Handling error for main frame. URL: ${request.url}, errorCode: $errorCode, errorDescription: $errorDescription" ) - val processor = getEventProcessor() + val listener = getListener() when { errorCode == HTTP_GONE -> { log.d(LOG_TAG, "Failing with cart expired. Recoverable: false") - processor.onCheckoutViewFailedWithError( + listener.onCheckoutViewFailedWithError( CheckoutExpiredException( isRecoverable = false, errorCode = CheckoutExpiredException.CART_EXPIRED @@ -222,7 +222,7 @@ internal abstract class BaseWebView(context: Context, attributeSet: AttributeSet else -> { val recoverable = isRecoverable(errorCode) log.d(LOG_TAG, "Failing with other error. Code: $errorCode. Recoverable $recoverable") - processor.onCheckoutViewFailedWithError( + listener.onCheckoutViewFailedWithError( HttpException( errorDescription = errorDescription, statusCode = errorCode, diff --git a/platforms/android/lib/src/main/java/com/shopify/checkoutkit/CheckoutBridge.kt b/platforms/android/lib/src/main/java/com/shopify/checkoutkit/CheckoutBridge.kt index 37354192..7f97b38f 100644 --- a/platforms/android/lib/src/main/java/com/shopify/checkoutkit/CheckoutBridge.kt +++ b/platforms/android/lib/src/main/java/com/shopify/checkoutkit/CheckoutBridge.kt @@ -23,33 +23,26 @@ package com.shopify.checkoutkit import android.webkit.JavascriptInterface -import com.shopify.checkoutkit.CheckoutBridge.CheckoutWebOperation.COMPLETED import com.shopify.checkoutkit.CheckoutBridge.CheckoutWebOperation.ERROR import com.shopify.checkoutkit.CheckoutBridge.CheckoutWebOperation.MODAL import com.shopify.checkoutkit.ShopifyCheckoutKit.log import com.shopify.checkoutkit.errorevents.CheckoutErrorDecoder -import com.shopify.checkoutkit.lifecycleevents.CheckoutCompletedEventDecoder import kotlinx.serialization.Serializable import kotlinx.serialization.json.Json internal class CheckoutBridge( - private var eventProcessor: CheckoutWebViewEventProcessor, + private var listener: CheckoutWebViewListener, private val decoder: Json = Json { ignoreUnknownKeys = true }, - private val checkoutCompletedEventDecoder: CheckoutCompletedEventDecoder = CheckoutCompletedEventDecoder( - decoder, - log - ), private val checkoutErrorDecoder: CheckoutErrorDecoder = CheckoutErrorDecoder(decoder, log), ) { - fun setEventProcessor(eventProcessor: CheckoutWebViewEventProcessor) { - this.eventProcessor = eventProcessor + fun setListener(listener: CheckoutWebViewListener) { + this.listener = listener } - fun getEventProcessor(): CheckoutWebViewEventProcessor = this.eventProcessor + fun getListener(): CheckoutWebViewListener = this.listener enum class CheckoutWebOperation(val key: String) { - COMPLETED("completed"), MODAL("checkoutBlockingEvent"), ERROR("error"); @@ -69,23 +62,13 @@ internal class CheckoutBridge( val decodedMsg = decoder.decodeFromString(message) when (CheckoutWebOperation.fromKey(decodedMsg.name)) { - COMPLETED -> { - log.d(LOG_TAG, "Received Completed message. Attempting to decode.") - checkoutCompletedEventDecoder.decode(decodedMsg).let { event -> - log.d(LOG_TAG, "Decoded message $event.") - onMainThread { - eventProcessor.onCheckoutViewComplete(event) - } - } - } - MODAL -> { log.d(LOG_TAG, "Received Modal message.") val modalVisible = decodedMsg.body.toBooleanStrictOrNull() modalVisible?.let { log.d(LOG_TAG, "Modal visible $it") onMainThread { - eventProcessor.onCheckoutViewModalToggled(modalVisible) + listener.onCheckoutViewModalToggled(modalVisible) } } } @@ -95,7 +78,7 @@ internal class CheckoutBridge( checkoutErrorDecoder.decode(decodedMsg)?.let { exception -> log.d(LOG_TAG, "Decoded message $exception.") onMainThread { - eventProcessor.onCheckoutViewFailedWithError(exception) + listener.onCheckoutViewFailedWithError(exception) } } } @@ -105,7 +88,7 @@ internal class CheckoutBridge( } catch (e: Exception) { log.d(LOG_TAG, "Failed to decode message with error: $e. Calling onCheckoutFailedWithError") onMainThread { - eventProcessor.onCheckoutViewFailedWithError( + listener.onCheckoutViewFailedWithError( CheckoutKitException( errorDescription = "Error decoding message from checkout.", errorCode = CheckoutKitException.ERROR_RECEIVING_MESSAGE_FROM_CHECKOUT, diff --git a/platforms/android/lib/src/main/java/com/shopify/checkoutkit/CheckoutDialog.kt b/platforms/android/lib/src/main/java/com/shopify/checkoutkit/CheckoutDialog.kt index b8155ac2..4707a306 100644 --- a/platforms/android/lib/src/main/java/com/shopify/checkoutkit/CheckoutDialog.kt +++ b/platforms/android/lib/src/main/java/com/shopify/checkoutkit/CheckoutDialog.kt @@ -53,7 +53,7 @@ import com.shopify.checkoutkit.ShopifyCheckoutKit.log internal class CheckoutDialog( private val checkoutUrl: String, - private val checkoutEventProcessor: CheckoutEventProcessor, + private val checkoutListener: CheckoutListener, context: Context, private val communicationClient: CheckoutCommunicationClient? = null, ) : ComponentDialog(context) { @@ -90,8 +90,8 @@ internal class CheckoutDialog( ) checkoutWebView.onResume() - log.d(LOG_TAG, "Setting event processor on WebView.") - checkoutWebView.setEventProcessor(eventProcessor()) + log.d(LOG_TAG, "Setting listener on WebView.") + checkoutWebView.setListener(webViewListener()) log.d(LOG_TAG, "Setting communication client on WebView.") checkoutWebView.setClient(communicationClient) @@ -119,7 +119,7 @@ internal class CheckoutDialog( setOnCancelListener { log.d(LOG_TAG, "Cancel listener invoked, invoking onCheckoutCanceled.") CheckoutWebViewContainer.retainCacheEntry = RetainCacheEntry.IF_NOT_STALE - checkoutEventProcessor.onCheckoutCanceled() + checkoutListener.onCheckoutCanceled() } setOnDismissListener { @@ -203,7 +203,7 @@ internal class CheckoutDialog( log.d(LOG_TAG, "Closing dialog with error, marking cache entry stale, calling onCheckoutFailed.") recoveryAttemptCount++ CheckoutWebView.markCacheEntryStale() - checkoutEventProcessor.onCheckoutFailed(exception) + checkoutListener.onCheckoutFailed(exception) val isOneTimeUseUrl = this.checkoutUrl.isOneTimeUse() val shouldRecover = ShopifyCheckoutKit.configuration.errorRecovery.shouldRecoverFromError(exception) @@ -233,16 +233,16 @@ internal class CheckoutDialog( addWebViewToContainer( ShopifyCheckoutKit.configuration.colorScheme, FallbackWebView(context).apply { - setEventProcessor(eventProcessor()) + setListener(webViewListener()) loadUrl(checkoutUrl) } ) return true } - private fun eventProcessor(): CheckoutWebViewEventProcessor { - return CheckoutWebViewEventProcessor( - eventProcessor = checkoutEventProcessor, + private fun webViewListener(): CheckoutWebViewListener { + return CheckoutWebViewListener( + listener = checkoutListener, toggleHeader = ::toggleHeader, closeCheckoutDialogWithError = ::closeCheckoutDialogWithError, setProgressBarVisibility = ::setProgressBarVisibility, diff --git a/platforms/android/lib/src/main/java/com/shopify/checkoutkit/CheckoutEventProcessor.kt b/platforms/android/lib/src/main/java/com/shopify/checkoutkit/CheckoutListener.kt similarity index 83% rename from platforms/android/lib/src/main/java/com/shopify/checkoutkit/CheckoutEventProcessor.kt rename to platforms/android/lib/src/main/java/com/shopify/checkoutkit/CheckoutListener.kt index 58eb15fc..304af567 100644 --- a/platforms/android/lib/src/main/java/com/shopify/checkoutkit/CheckoutEventProcessor.kt +++ b/platforms/android/lib/src/main/java/com/shopify/checkoutkit/CheckoutListener.kt @@ -28,18 +28,17 @@ import android.webkit.PermissionRequest import android.webkit.ValueCallback import android.webkit.WebChromeClient import android.webkit.WebView -import com.shopify.checkoutkit.lifecycleevents.CheckoutCompletedEvent /** * Interface to implement to allow responding to lifecycle events in checkout. - * We'd strongly recommend extending DefaultCheckoutEventProcessor where possible + * We'd strongly recommend extending DefaultCheckoutListener where possible. + * + * Completion (`ec.complete`) and in-checkout state updates (totals, line items, + * messages) flow through [CheckoutCommunicationClient] / the Embedded Checkout + * Protocol — not through this interface. Kit-level failures continue to surface + * here via [onCheckoutFailed]. */ -public interface CheckoutEventProcessor { - /** - * Event representing the successful completion of a checkout. - */ - public fun onCheckoutCompleted(checkoutCompletedEvent: CheckoutCompletedEvent) - +public interface CheckoutListener { /** * Event representing an error that occurred during checkout. This can be used to display * error messages for example. @@ -50,12 +49,12 @@ public interface CheckoutEventProcessor { public fun onCheckoutFailed(error: CheckoutException) /** - * Event representing the cancellation/closing of checkout by the buyer + * Event representing the cancellation/closing of checkout by the buyer. */ public fun onCheckoutCanceled() /** - * A permission has been requested by the web chrome client, e.g. to access the camera + * A permission has been requested by the web chrome client, e.g. to access the camera. */ public fun onPermissionRequest(permissionRequest: PermissionRequest) @@ -71,21 +70,17 @@ public interface CheckoutEventProcessor { /** * Called when the client should show a location permissions prompt. For example when using 'Use my location' for - * pickup points in checkout + * pickup points in checkout. */ public fun onGeolocationPermissionsShowPrompt(origin: String, callback: GeolocationPermissions.Callback) /** - * Called when the client should hide the location permissions prompt, e.g. if th request is cancelled + * Called when the client should hide the location permissions prompt, e.g. if the request is cancelled. */ public fun onGeolocationPermissionsHidePrompt() } -internal class NoopEventProcessor : CheckoutEventProcessor { - override fun onCheckoutCompleted(checkoutCompletedEvent: CheckoutCompletedEvent) { - /* noop */ - } - +internal class NoopCheckoutListener : CheckoutListener { override fun onCheckoutFailed(error: CheckoutException) { /* noop */ } @@ -116,10 +111,10 @@ internal class NoopEventProcessor : CheckoutEventProcessor { } /** - * An abstract class that provides a default implementation of the CheckoutEventProcessor interface - * for the optional permission and file-chooser callbacks. Override in subclasses as needed. + * An abstract class that provides a default implementation of the [CheckoutListener] interface + * for handling checkout events. */ -public abstract class DefaultCheckoutEventProcessor : CheckoutEventProcessor { +public abstract class DefaultCheckoutListener : CheckoutListener { 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 2856e971..8f02a621 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 @@ -28,7 +28,6 @@ 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. @@ -103,10 +102,8 @@ public class CheckoutPresentation internal constructor() { communicationClient = client } - internal fun buildEventProcessor(): DefaultCheckoutEventProcessor = - object : DefaultCheckoutEventProcessor() { - override fun onCheckoutCompleted(checkoutCompletedEvent: CheckoutCompletedEvent) = Unit - + internal fun buildListener(): DefaultCheckoutListener = + object : DefaultCheckoutListener() { override fun onCheckoutFailed(error: CheckoutException) { onFail?.invoke(error) } 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 50e44729..133675ba 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 @@ -53,7 +53,7 @@ import java.util.concurrent.CountDownLatch * .on(CheckoutProtocol.start) { checkout -> showProgressUI(checkout) } * .on(CheckoutProtocol.complete) { checkout -> navigateToConfirmation(checkout) } * - * ShopifyCheckoutKit.present(url, activity, eventProcessor, client) + * ShopifyCheckoutKit.present(url, activity, checkoutListener, client) * ``` */ public object CheckoutProtocol { 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 ec5e2a95..530b5e1b 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 @@ -41,7 +41,7 @@ internal class CheckoutWebView(context: Context, attributeSet: AttributeSet? = n override val recoverErrors = true var isPreload = false - private val checkoutBridge = CheckoutBridge(CheckoutWebViewEventProcessor(NoopEventProcessor())) + private val checkoutBridge = CheckoutBridge(CheckoutWebViewListener(NoopCheckoutListener())) private val embeddedCheckoutProtocol = EmbeddedCheckoutProtocol(this) private var loadComplete = false @@ -54,9 +54,9 @@ internal class CheckoutWebView(context: Context, attributeSet: AttributeSet? = n fun hasFinishedLoading() = loadComplete - fun setEventProcessor(eventProcessor: CheckoutWebViewEventProcessor) { - log.d(LOG_TAG, "Setting event processor $eventProcessor.") - checkoutBridge.setEventProcessor(eventProcessor) + fun setListener(listener: CheckoutWebViewListener) { + log.d(LOG_TAG, "Setting listener $listener.") + checkoutBridge.setListener(listener) } fun setClient(client: CheckoutCommunicationClient?) { @@ -64,8 +64,8 @@ internal class CheckoutWebView(context: Context, attributeSet: AttributeSet? = n embeddedCheckoutProtocol.setClient(client) } - override fun getEventProcessor(): CheckoutWebViewEventProcessor { - return checkoutBridge.getEventProcessor() + override fun getListener(): CheckoutWebViewListener { + return checkoutBridge.getListener() } override fun onAttachedToWindow() { @@ -97,14 +97,14 @@ internal class CheckoutWebView(context: Context, attributeSet: AttributeSet? = n override fun onPageStarted(view: WebView?, url: String?, favicon: Bitmap?) { super.onPageStarted(view, url, favicon) log.d(LOG_TAG, "onPageStarted called $url.") - checkoutBridge.getEventProcessor().onCheckoutViewLoadStarted() + checkoutBridge.getListener().onCheckoutViewLoadStarted() } override fun onPageFinished(view: WebView, url: String) { super.onPageFinished(view, url) log.d(LOG_TAG, "onPageFinished called $url.") loadComplete = true - getEventProcessor().onCheckoutViewLoadComplete() + getListener().onCheckoutViewLoadComplete() } override fun shouldOverrideUrlLoading( diff --git a/platforms/android/lib/src/main/java/com/shopify/checkoutkit/CheckoutWebViewEventProcessor.kt b/platforms/android/lib/src/main/java/com/shopify/checkoutkit/CheckoutWebViewListener.kt similarity index 71% rename from platforms/android/lib/src/main/java/com/shopify/checkoutkit/CheckoutWebViewEventProcessor.kt rename to platforms/android/lib/src/main/java/com/shopify/checkoutkit/CheckoutWebViewListener.kt index 1419d11f..51d8dd80 100644 --- a/platforms/android/lib/src/main/java/com/shopify/checkoutkit/CheckoutWebViewEventProcessor.kt +++ b/platforms/android/lib/src/main/java/com/shopify/checkoutkit/CheckoutWebViewListener.kt @@ -30,28 +30,19 @@ import android.webkit.PermissionRequest import android.webkit.ValueCallback import android.webkit.WebChromeClient.FileChooserParams import android.webkit.WebView -import com.shopify.checkoutkit.ShopifyCheckoutKit.log -import com.shopify.checkoutkit.lifecycleevents.CheckoutCompletedEvent /** - * Event processor that can handle events internally, delegate to the CheckoutEventProcessor - * passed into ShopifyCheckoutKit.present(), or preprocess arguments and then delegate + * Internal wrapper around the consumer-provided CheckoutListener. Handles dialog-internal + * behavior (progress bar, modal header toggling, error close) and delegates the rest to + * the listener. */ -internal class CheckoutWebViewEventProcessor( - private val eventProcessor: CheckoutEventProcessor, +internal class CheckoutWebViewListener( + private val listener: CheckoutListener, private val toggleHeader: (Boolean) -> Unit = {}, private val closeCheckoutDialogWithError: (CheckoutException) -> Unit = { CheckoutWebView.clearCache() }, private val setProgressBarVisibility: (Int) -> Unit = {}, private val updateProgressBarPercentage: (Int) -> Unit = {}, ) { - fun onCheckoutViewComplete(checkoutCompletedEvent: CheckoutCompletedEvent) { - log.d(LOG_TAG, "Clearing WebView cache after checkout completion.") - CheckoutWebView.markCacheEntryStale() - - log.d(LOG_TAG, "Calling onCheckoutCompleted $checkoutCompletedEvent.") - eventProcessor.onCheckoutCompleted(checkoutCompletedEvent) - } - fun onCheckoutViewModalToggled(modalVisible: Boolean) { onMainThread { toggleHeader(modalVisible) @@ -65,11 +56,11 @@ internal class CheckoutWebViewEventProcessor( } fun onGeolocationPermissionsShowPrompt(origin: String, callback: GeolocationPermissions.Callback) { - return eventProcessor.onGeolocationPermissionsShowPrompt(origin, callback) + return listener.onGeolocationPermissionsShowPrompt(origin, callback) } fun onGeolocationPermissionsHidePrompt() { - return eventProcessor.onGeolocationPermissionsHidePrompt() + return listener.onGeolocationPermissionsHidePrompt() } fun onShowFileChooser( @@ -77,12 +68,12 @@ internal class CheckoutWebViewEventProcessor( filePathCallback: ValueCallback>, fileChooserParams: FileChooserParams, ): Boolean { - return eventProcessor.onShowFileChooser(webView, filePathCallback, fileChooserParams) + return listener.onShowFileChooser(webView, filePathCallback, fileChooserParams) } fun onPermissionRequest(permissionRequest: PermissionRequest) { onMainThread { - eventProcessor.onPermissionRequest(permissionRequest) + listener.onPermissionRequest(permissionRequest) } } @@ -103,8 +94,4 @@ internal class CheckoutWebViewEventProcessor( setProgressBarVisibility(VISIBLE) } } - - companion object { - private const val LOG_TAG = "CheckoutWebViewEventProcessor" - } } diff --git a/platforms/android/lib/src/main/java/com/shopify/checkoutkit/EmbeddedCheckoutProtocol.kt b/platforms/android/lib/src/main/java/com/shopify/checkoutkit/EmbeddedCheckoutProtocol.kt index 659fe3a1..d8e923bc 100644 --- a/platforms/android/lib/src/main/java/com/shopify/checkoutkit/EmbeddedCheckoutProtocol.kt +++ b/platforms/android/lib/src/main/java/com/shopify/checkoutkit/EmbeddedCheckoutProtocol.kt @@ -72,6 +72,7 @@ internal class EmbeddedCheckoutProtocol( log.d(LOG_TAG, "Ignoring out-of-scope ep method: ${request.method}.") request.method == METHOD_WINDOW_OPEN_REQUEST -> handleWindowOpenRequest(message) request.method == METHOD_START -> handleStart(message) + request.method == METHOD_COMPLETE -> handleComplete(message) else -> handleClientMessage(request.method, message) } } catch (e: Exception) { @@ -106,7 +107,18 @@ internal class EmbeddedCheckoutProtocol( private fun handleStart(message: String) { log.d(LOG_TAG, "Handling $METHOD_START: showing progress bar and bubbling up.") onMainThread { - view.getEventProcessor().onCheckoutViewLoadStarted() + view.getListener().onCheckoutViewLoadStarted() + client?.process(message) + } + } + + private fun handleComplete(message: String) { + // Cache invalidation on completion is a kit invariant — independent of whether + // a merchant client is attached. Mark stale before delegating so a completed + // checkout is never reused from cache on the next present(...). + log.d(LOG_TAG, "Handling $METHOD_COMPLETE: marking cache stale and bubbling up.") + CheckoutWebView.markCacheEntryStale() + onMainThread { client?.process(message) } } @@ -165,6 +177,7 @@ internal class EmbeddedCheckoutProtocol( internal const val METHOD_READY = "ec.ready" internal const val METHOD_START = "ec.start" + internal const val METHOD_COMPLETE = "ec.complete" private const val METHOD_WINDOW_OPEN_REQUEST = "ec.window.open_request" diff --git a/platforms/android/lib/src/main/java/com/shopify/checkoutkit/FallbackWebView.kt b/platforms/android/lib/src/main/java/com/shopify/checkoutkit/FallbackWebView.kt index 310f305d..d0abc5db 100644 --- a/platforms/android/lib/src/main/java/com/shopify/checkoutkit/FallbackWebView.kt +++ b/platforms/android/lib/src/main/java/com/shopify/checkoutkit/FallbackWebView.kt @@ -23,12 +23,9 @@ package com.shopify.checkoutkit import android.content.Context -import android.net.Uri import android.util.AttributeSet import android.webkit.WebView -import androidx.core.net.toUri import com.shopify.checkoutkit.ShopifyCheckoutKit.log -import com.shopify.checkoutkit.lifecycleevents.emptyCompletedEvent internal class FallbackWebView(context: Context, attributeSet: AttributeSet? = null) : BaseWebView(context, attributeSet) { @@ -41,15 +38,15 @@ internal class FallbackWebView(context: Context, attributeSet: AttributeSet? = n settings.userAgentString = "${settings.userAgentString} ${userAgentSuffix()}" } - private var checkoutEventProcessor = CheckoutWebViewEventProcessor(NoopEventProcessor()) + private var listener = CheckoutWebViewListener(NoopCheckoutListener()) - fun setEventProcessor(processor: CheckoutWebViewEventProcessor) { - log.d(LOG_TAG, "Setting event processor $processor.") - this.checkoutEventProcessor = processor + fun setListener(listener: CheckoutWebViewListener) { + log.d(LOG_TAG, "Setting listener $listener.") + this.listener = listener } - override fun getEventProcessor(): CheckoutWebViewEventProcessor { - return checkoutEventProcessor + override fun getListener(): CheckoutWebViewListener { + return listener } inner class FallbackWebViewClient : BaseWebView.BaseWebViewClient() { @@ -64,18 +61,8 @@ internal class FallbackWebView(context: Context, attributeSet: AttributeSet? = n override fun onPageFinished(view: WebView, url: String) { super.onPageFinished(view, url) log.d(LOG_TAG, "onPageFinished called.") - getEventProcessor().onCheckoutViewLoadComplete() - - val uri = url.toUri() - if (uri.isConfirmationPage()) { - log.d(LOG_TAG, "Finished page has confirmationUrl. Emitting minimal checkout completed event.") - getEventProcessor().onCheckoutViewComplete( - emptyCompletedEvent(id = getOrderIdFromQueryString(uri)) - ) - } + getListener().onCheckoutViewLoadComplete() } - - private fun getOrderIdFromQueryString(uri: Uri): String? = uri.getQueryParameter("order_id") } companion object { 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 8b0815aa..2219ed61 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 @@ -83,7 +83,7 @@ public object ShopifyCheckoutKit { * Preloads a Shopify checkout in the background. * * Preloading checkout is fully optional, but allows reducing the time taken between calling - * {@link ShopifyCheckoutKit#present(String, ComponentActivity, CheckoutEventProcessor)} and having a fully interactive checkout. + * {@link ShopifyCheckoutKit#present(String, ComponentActivity, CheckoutListener)} and having a fully interactive checkout. * Note: Preload must be called on all cart changes to avoid stale checkouts being presented. * Preloaded checkouts also have a TTL of 5 minutes, after checkout will be re-loaded on calling present. * @@ -140,7 +140,7 @@ public object ShopifyCheckoutKit { return present( checkoutUrl = checkoutUrl, context = context, - checkoutEventProcessor = presentation.buildEventProcessor(), + checkoutListener = presentation.buildListener(), communicationClient = presentation.communicationClient, ) } @@ -150,8 +150,8 @@ 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 checkoutEventProcessor provides callbacks to allow clients to listen for and respond to checkout lifecycle events such as - * (failure, completion, cancellation, and browser/system prompts). + * @param checkoutListener provides callbacks to allow clients to listen for and respond to checkout lifecycle events + * (failure, cancellation, permission prompts, file chooser). * @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 @@ -160,10 +160,10 @@ public object ShopifyCheckoutKit { */ @JvmOverloads @JvmStatic - public fun present( + public fun present( checkoutUrl: String, context: ComponentActivity, - checkoutEventProcessor: T, + checkoutListener: T, communicationClient: CheckoutCommunicationClient? = null, ): CheckoutKitDialog? { log.d("ShopifyCheckoutKit", "Present called with checkoutUrl $checkoutUrl.") @@ -172,7 +172,7 @@ public object ShopifyCheckoutKit { return null } log.d("ShopifyCheckoutKit", "Constructing Dialog") - val dialog = CheckoutDialog(checkoutUrl, checkoutEventProcessor, context, communicationClient) + val dialog = CheckoutDialog(checkoutUrl, checkoutListener, context, communicationClient) context.lifecycle.addObserver(object : DefaultLifecycleObserver { override fun onDestroy(owner: LifecycleOwner) { log.d("ShopifyCheckoutKit", "Context is being destroyed, dismissing dialog.") diff --git a/platforms/android/lib/src/test/java/com/shopify/checkoutkit/CheckoutBridgeTest.kt b/platforms/android/lib/src/test/java/com/shopify/checkoutkit/CheckoutBridgeTest.kt index 04a32af2..cb546d7d 100644 --- a/platforms/android/lib/src/test/java/com/shopify/checkoutkit/CheckoutBridgeTest.kt +++ b/platforms/android/lib/src/test/java/com/shopify/checkoutkit/CheckoutBridgeTest.kt @@ -22,7 +22,6 @@ */ package com.shopify.checkoutkit -import com.shopify.checkoutkit.CheckoutBridge.CheckoutWebOperation.COMPLETED import com.shopify.checkoutkit.CheckoutBridge.CheckoutWebOperation.MODAL import kotlinx.serialization.encodeToString import kotlinx.serialization.json.Json @@ -30,7 +29,6 @@ import org.assertj.core.api.Assertions.assertThat 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.timeout @@ -41,18 +39,12 @@ import org.robolectric.RobolectricTestRunner @RunWith(RobolectricTestRunner::class) class CheckoutBridgeTest { - private var mockEventProcessor = mock() + private var mockListener = mock() private lateinit var checkoutBridge: CheckoutBridge @Before fun init() { - checkoutBridge = CheckoutBridge(mockEventProcessor) - } - - @Test - fun `postMessage calls web event processor onCheckoutViewComplete when completed message received`() { - checkoutBridge.postMessage(Json.encodeToString(WebToSdkEvent(COMPLETED.key))) - verify(mockEventProcessor).onCheckoutViewComplete(any()) + checkoutBridge = CheckoutBridge(mockListener) } @Test @@ -65,7 +57,7 @@ class CheckoutBridgeTest { ) ) ) - verify(mockEventProcessor).onCheckoutViewModalToggled(false) + verify(mockListener).onCheckoutViewModalToggled(false) } @Test @@ -78,13 +70,13 @@ class CheckoutBridgeTest { ) ) ) - verify(mockEventProcessor).onCheckoutViewModalToggled(true) + verify(mockListener).onCheckoutViewModalToggled(true) } @Test fun `postMessage does not issue a msg to the event processor when unsupported message received`() { checkoutBridge.postMessage(Json.encodeToString(WebToSdkEvent("boom"))) - verifyNoInteractions(mockEventProcessor) + verifyNoInteractions(mockListener) } @Test @@ -105,7 +97,7 @@ class CheckoutBridgeTest { checkoutBridge.postMessage(eventString) val captor = argumentCaptor() - verify(mockEventProcessor, timeout(2000).times(1)).onCheckoutViewFailedWithError(captor.capture()) + verify(mockListener, timeout(2000).times(1)).onCheckoutViewFailedWithError(captor.capture()) val error = captor.firstValue assertThat(error).isInstanceOf(CheckoutExpiredException::class.java) @@ -132,7 +124,7 @@ class CheckoutBridgeTest { checkoutBridge.postMessage(eventString) val captor = argumentCaptor() - verify(mockEventProcessor, timeout(2000).times(1)).onCheckoutViewFailedWithError(captor.capture()) + verify(mockListener, timeout(2000).times(1)).onCheckoutViewFailedWithError(captor.capture()) val error = captor.firstValue assertThat(error).isInstanceOf(CheckoutExpiredException::class.java) @@ -156,7 +148,7 @@ class CheckoutBridgeTest { checkoutBridge.postMessage(eventString) val captor = argumentCaptor() - verify(mockEventProcessor, timeout(2000).times(1)).onCheckoutViewFailedWithError(captor.capture()) + verify(mockListener, timeout(2000).times(1)).onCheckoutViewFailedWithError(captor.capture()) val error = captor.firstValue assertThat(error).isInstanceOf(CheckoutExpiredException::class.java) @@ -184,7 +176,7 @@ class CheckoutBridgeTest { checkoutBridge.postMessage(eventString) val captor = argumentCaptor() - verify(mockEventProcessor, timeout(2000).times(1)).onCheckoutViewFailedWithError(captor.capture()) + verify(mockListener, timeout(2000).times(1)).onCheckoutViewFailedWithError(captor.capture()) val error = captor.firstValue assertThat(error).isInstanceOf(CheckoutUnavailableException::class.java) @@ -210,7 +202,7 @@ class CheckoutBridgeTest { checkoutBridge.postMessage(eventString) val captor = argumentCaptor() - verify(mockEventProcessor, timeout(2000).times(1)).onCheckoutViewFailedWithError(captor.capture()) + verify(mockListener, timeout(2000).times(1)).onCheckoutViewFailedWithError(captor.capture()) val error = captor.firstValue assertThat(error).isInstanceOf(ConfigurationException::class.java) @@ -235,7 +227,7 @@ class CheckoutBridgeTest { checkoutBridge.postMessage(eventString) - verifyNoInteractions(mockEventProcessor) + verifyNoInteractions(mockListener) } @Test @@ -250,7 +242,7 @@ class CheckoutBridgeTest { checkoutBridge.postMessage(eventString) val captor = argumentCaptor() - verify(mockEventProcessor).onCheckoutViewFailedWithError(captor.capture()) + verify(mockListener).onCheckoutViewFailedWithError(captor.capture()) val error = captor.firstValue assertThat(error).isInstanceOf(CheckoutKitException::class.java) diff --git a/platforms/android/lib/src/test/java/com/shopify/checkoutkit/CheckoutDialogTest.kt b/platforms/android/lib/src/test/java/com/shopify/checkoutkit/CheckoutDialogTest.kt index 96d89349..0f411010 100644 --- a/platforms/android/lib/src/test/java/com/shopify/checkoutkit/CheckoutDialogTest.kt +++ b/platforms/android/lib/src/test/java/com/shopify/checkoutkit/CheckoutDialogTest.kt @@ -31,7 +31,6 @@ import android.widget.RelativeLayout import androidx.activity.ComponentActivity import androidx.appcompat.widget.Toolbar import androidx.core.view.children -import com.shopify.checkoutkit.lifecycleevents.emptyCompletedEvent import org.assertj.core.api.Assertions.assertThat import org.assertj.core.api.Assertions.fail import org.awaitility.Awaitility.await @@ -55,7 +54,7 @@ import java.util.concurrent.TimeUnit class CheckoutDialogTest { private lateinit var activity: ComponentActivity - private lateinit var processor: DefaultCheckoutEventProcessor + private lateinit var processor: DefaultCheckoutListener private lateinit var configuration: Configuration @Before @@ -65,7 +64,7 @@ class CheckoutDialogTest { it.preloading = Preloading(enabled = false) } activity = Robolectric.buildActivity(ComponentActivity::class.java).get() - processor = noopDefaultCheckoutEventProcessor() + processor = noopDefaultCheckoutListener() } @After @@ -144,23 +143,23 @@ class CheckoutDialogTest { @Test fun `calls onCheckoutCanceled if cancel is called`() { - val mockEventProcessor = mock() - ShopifyCheckoutKit.present("https://shopify.com", activity, mockEventProcessor) + val mockListener = mock() + ShopifyCheckoutKit.present("https://shopify.com", activity, mockListener) val dialog = ShadowDialog.getLatestDialog() dialog.cancel() shadowOf(Looper.getMainLooper()).runToEndOfTasks() - verify(mockEventProcessor).onCheckoutCanceled() - verify(mockEventProcessor, never()).onCheckoutFailed(any()) + verify(mockListener).onCheckoutCanceled() + verify(mockListener, never()).onCheckoutFailed(any()) } @Test fun `closeCheckoutDialogWithError marks cache entry stale`() { withPreloadingEnabled { - val mockEventProcessor = mock() + val mockListener = mock() ShopifyCheckoutKit.preload("https://shopify.com", activity) - ShopifyCheckoutKit.present("https://shopify.com", activity, mockEventProcessor) + ShopifyCheckoutKit.present("https://shopify.com", activity, mockListener) assertThat(CheckoutWebView.cacheEntry).isNotNull() @@ -177,8 +176,8 @@ class CheckoutDialogTest { @Test fun `calls onCheckoutFailed if closeCheckoutDialogWithError for non-recoverable error`() { - val mockEventProcessor = mock() - ShopifyCheckoutKit.present("https://shopify.com", activity, mockEventProcessor) + val mockListener = mock() + ShopifyCheckoutKit.present("https://shopify.com", activity, mockListener) val dialog = ShadowDialog.getLatestDialog() val checkoutDialog = dialog as CheckoutDialog @@ -187,14 +186,14 @@ class CheckoutDialogTest { checkoutDialog.closeCheckoutDialogWithError(error) shadowOf(Looper.getMainLooper()).runToEndOfTasks() - verify(mockEventProcessor, never()).onCheckoutCanceled() - verify(mockEventProcessor).onCheckoutFailed(error) + verify(mockListener, never()).onCheckoutCanceled() + verify(mockListener).onCheckoutFailed(error) } @Test fun `calls attemptToRecoverFromError if closeCheckoutDialogWithError is called with recoverable error`() { - val mockEventProcessor = mock() - ShopifyCheckoutKit.present("https://shopify.com", activity, mockEventProcessor) + val mockListener = mock() + ShopifyCheckoutKit.present("https://shopify.com", activity, mockListener) val checkoutDialog = ShadowDialog.getLatestDialog() as CheckoutDialog assertThat(checkoutDialog.containsChildOfType(CheckoutWebView::class.java)).isTrue() @@ -205,14 +204,14 @@ class CheckoutDialogTest { // attemptToRecoverFromError creates a FallbackWebView and removes the CheckoutWebView assertThat(checkoutDialog.containsChildOfType(FallbackWebView::class.java)).isTrue() assertThat(checkoutDialog.containsChildOfType(CheckoutWebView::class.java)).isFalse() - verify(mockEventProcessor, never()).onCheckoutCanceled() - verify(mockEventProcessor).onCheckoutFailed(any()) + verify(mockListener, never()).onCheckoutCanceled() + verify(mockListener).onCheckoutFailed(any()) } @Test fun `does not call attemptToRecoverFromError if closeCheckoutDialogWithError is called when url contains multipass`() { - val mockEventProcessor = mock() - ShopifyCheckoutKit.present("https://shopify.com/account/login/multipass", activity, mockEventProcessor) + val mockListener = mock() + ShopifyCheckoutKit.present("https://shopify.com/account/login/multipass", activity, mockListener) val checkoutDialog = ShadowDialog.getLatestDialog() as CheckoutDialog assertThat(checkoutDialog.containsChildOfType(CheckoutWebView::class.java)).isTrue() @@ -223,13 +222,13 @@ class CheckoutDialogTest { // attemptToRecoverFromError creates a FallbackWebView and removes the CheckoutWebView assertThat(checkoutDialog.containsChildOfType(FallbackWebView::class.java)).isFalse() assertThat(checkoutDialog.containsChildOfType(CheckoutWebView::class.java)).isFalse() - verify(mockEventProcessor, never()).onCheckoutCanceled() - verify(mockEventProcessor).onCheckoutFailed(any()) + verify(mockListener, never()).onCheckoutCanceled() + verify(mockListener).onCheckoutFailed(any()) } @Test fun `can disable fallback behaviour via shouldRecoverFromError`() { - val mockEventProcessor = mock() + val mockListener = mock() ShopifyCheckoutKit.configure { it.errorRecovery = object : ErrorRecovery { override fun shouldRecoverFromError(checkoutException: CheckoutException): Boolean { @@ -237,7 +236,7 @@ class CheckoutDialogTest { } } } - ShopifyCheckoutKit.present("https://shopify.com", activity, mockEventProcessor) + ShopifyCheckoutKit.present("https://shopify.com", activity, mockListener) val checkoutDialog = ShadowDialog.getLatestDialog() as CheckoutDialog assertThat(checkoutDialog.containsChildOfType(CheckoutWebView::class.java)).isTrue() @@ -249,14 +248,14 @@ class CheckoutDialogTest { // attemptToRecoverFromError creates a FallbackWebView and removes the CheckoutWebView assertThat(checkoutDialog.containsChildOfType(FallbackWebView::class.java)).isFalse() assertThat(checkoutDialog.containsChildOfType(CheckoutWebView::class.java)).isFalse() - verify(mockEventProcessor, never()).onCheckoutCanceled() - verify(mockEventProcessor).onCheckoutFailed(error) + verify(mockListener, never()).onCheckoutCanceled() + verify(mockListener).onCheckoutFailed(error) } @Test fun `calls onCheckoutCanceled if close menu item is clicked`() { - val mockEventProcessor = mock() - ShopifyCheckoutKit.present("https://shopify.com", activity, mockEventProcessor) + val mockListener = mock() + ShopifyCheckoutKit.present("https://shopify.com", activity, mockListener) val dialog = ShadowDialog.getLatestDialog() assertThat(dialog.containsChildOfType(CheckoutWebView::class.java)).isTrue() @@ -266,7 +265,7 @@ class CheckoutDialogTest { header.menu.performIdentifierAction(R.id.checkoutKitCloseBtn, 0) ShadowLooper.runUiThreadTasks() - verify(mockEventProcessor, timeout(2000)).onCheckoutCanceled() + verify(mockListener, timeout(2000)).onCheckoutCanceled() } @Test @@ -316,8 +315,8 @@ class CheckoutDialogTest { @Test fun `closeCheckoutDialogWithError does not recover on second recoverable error - prevents infinite loop`() { - val mockEventProcessor = mock() - ShopifyCheckoutKit.present("https://shopify.com", activity, mockEventProcessor) + val mockListener = mock() + ShopifyCheckoutKit.present("https://shopify.com", activity, mockListener) val checkoutDialog = ShadowDialog.getLatestDialog() as CheckoutDialog assertThat(checkoutDialog.containsChildOfType(CheckoutWebView::class.java)).isTrue() @@ -340,8 +339,8 @@ class CheckoutDialogTest { @Test fun `closeCheckoutDialogWithError increments recovery attempt count`() { - val mockEventProcessor = mock() - ShopifyCheckoutKit.present("https://shopify.com", activity, mockEventProcessor) + val mockListener = mock() + ShopifyCheckoutKit.present("https://shopify.com", activity, mockListener) val checkoutDialog = ShadowDialog.getLatestDialog() as CheckoutDialog assertThat(checkoutDialog.recoveryAttemptCount).isEqualTo(0) @@ -401,25 +400,6 @@ class CheckoutDialogTest { assertThat(shadowOf(fallbackView).lastLoadedUrl).isEqualTo(checkoutUrl) } - @Test - fun `attemptToRecoverFromError sets event processor`() { - val checkoutUrl = "https://shopify.com" - val mockProcessor = mock() - ShopifyCheckoutKit.present(checkoutUrl, activity, mockProcessor) - - val dialog = ShadowDialog.getLatestDialog() as CheckoutDialog - dialog.attemptToRecoverFromError(checkoutException(isRecoverable = true)) - shadowOf(Looper.getMainLooper()).runToEndOfTasks() - - val layout = dialog.findViewById(R.id.checkoutKitContainer) - val fallbackView = layout.children.first { it is FallbackWebView } as FallbackWebView - - val completedEvent = emptyCompletedEvent() - - fallbackView.getEventProcessor().onCheckoutViewComplete(completedEvent) - verify(mockProcessor).onCheckoutCompleted(completedEvent) - } - @Test fun `dialog applies custom close icon when provided`() { val customIcon = DrawableResource(android.R.drawable.ic_delete) @@ -435,7 +415,7 @@ class CheckoutDialogTest { ) } - ShopifyCheckoutKit.present("https://shopify.com", activity, mock()) + ShopifyCheckoutKit.present("https://shopify.com", activity, mock()) shadowOf(Looper.getMainLooper()).runToEndOfTasks() val dialog = ShadowDialog.getLatestDialog() as CheckoutDialog @@ -459,7 +439,7 @@ class CheckoutDialogTest { } } - ShopifyCheckoutKit.present("https://shopify.com", activity, mock()) + ShopifyCheckoutKit.present("https://shopify.com", activity, mock()) shadowOf(Looper.getMainLooper()).runToEndOfTasks() val dialog = ShadowDialog.getLatestDialog() as CheckoutDialog @@ -481,7 +461,7 @@ class CheckoutDialogTest { ShopifyCheckoutKit.configure { it.colorScheme = ColorScheme.Light() // Default colors, no custom icon or tint } - val mockProcessor = mock() + val mockProcessor = mock() ShopifyCheckoutKit.present("https://shopify.com", activity, mockProcessor) shadowOf(Looper.getMainLooper()).runToEndOfTasks() @@ -522,7 +502,7 @@ class CheckoutDialogTest { ) ShopifyCheckoutKit.configure { it.colorScheme = colorScheme } - val mockProcessor = mock() + val mockProcessor = mock() ShopifyCheckoutKit.present("https://shopify.com", activity, mockProcessor) shadowOf(Looper.getMainLooper()).runToEndOfTasks() @@ -542,22 +522,22 @@ class CheckoutDialogTest { @Test fun `back press cancels dialog when WebView has no history to navigate`() { - val mockEventProcessor = mock() - ShopifyCheckoutKit.present("https://shopify.com", activity, mockEventProcessor) + val mockListener = mock() + ShopifyCheckoutKit.present("https://shopify.com", activity, mockListener) shadowOf(Looper.getMainLooper()).runToEndOfTasks() val dialog = ShadowDialog.getLatestDialog() as CheckoutDialog dialog.onBackPressedDispatcher.onBackPressed() shadowOf(Looper.getMainLooper()).runToEndOfTasks() - verify(mockEventProcessor).onCheckoutCanceled() + verify(mockListener).onCheckoutCanceled() assertThat(dialog.isShowing).isFalse() } @Test fun `back press navigates WebView history when history exists and not on confirmation page`() { - val mockEventProcessor = mock() - ShopifyCheckoutKit.present("https://shopify.com/checkouts/c/abc", activity, mockEventProcessor) + val mockListener = mock() + ShopifyCheckoutKit.present("https://shopify.com/checkouts/c/abc", activity, mockListener) shadowOf(Looper.getMainLooper()).runToEndOfTasks() val dialog = ShadowDialog.getLatestDialog() as CheckoutDialog @@ -569,15 +549,15 @@ class CheckoutDialogTest { dialog.onBackPressedDispatcher.onBackPressed() shadowOf(Looper.getMainLooper()).runToEndOfTasks() - verify(mockEventProcessor, never()).onCheckoutCanceled() + verify(mockListener, never()).onCheckoutCanceled() assertThat(dialog.isShowing).isTrue() assertThat(shadowOf(webView).goBackInvocations).isGreaterThan(0) } @Test fun `back press cancels dialog when on confirmation page even with history`() { - val mockEventProcessor = mock() - ShopifyCheckoutKit.present("https://shopify.com/checkouts/c/abc", activity, mockEventProcessor) + val mockListener = mock() + ShopifyCheckoutKit.present("https://shopify.com/checkouts/c/abc", activity, mockListener) shadowOf(Looper.getMainLooper()).runToEndOfTasks() val dialog = ShadowDialog.getLatestDialog() as CheckoutDialog @@ -589,7 +569,7 @@ class CheckoutDialogTest { dialog.onBackPressedDispatcher.onBackPressed() shadowOf(Looper.getMainLooper()).runToEndOfTasks() - verify(mockEventProcessor).onCheckoutCanceled() + verify(mockListener).onCheckoutCanceled() assertThat(dialog.isShowing).isFalse() assertThat(shadowOf(webView).goBackInvocations).isEqualTo(0) } 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 91d4f809..85a00b6d 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 @@ -32,7 +32,6 @@ 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 @@ -139,7 +138,7 @@ class CheckoutPresentationTest { val dialog = ShadowDialog.getLatestDialog() as CheckoutDialog - dialog.currentWebView().getEventProcessor().onPermissionRequest(permissionRequest) + dialog.currentWebView().getListener().onPermissionRequest(permissionRequest) shadowOf(Looper.getMainLooper()).runToEndOfTasks() assertThat(received).isSameAs(permissionRequest) @@ -166,7 +165,7 @@ class CheckoutPresentationTest { val dialog = ShadowDialog.getLatestDialog() as CheckoutDialog - val handled = dialog.currentWebView().getEventProcessor().onShowFileChooser( + val handled = dialog.currentWebView().getListener().onShowFileChooser( webView, filePathCallback, fileChooserParams, @@ -194,7 +193,7 @@ class CheckoutPresentationTest { val dialog = ShadowDialog.getLatestDialog() as CheckoutDialog - dialog.currentWebView().getEventProcessor().onGeolocationPermissionsShowPrompt("origin", callback) + dialog.currentWebView().getListener().onGeolocationPermissionsShowPrompt("origin", callback) assertThat(receivedOrigin).isEqualTo("origin") assertThat(receivedCallback).isSameAs(callback) @@ -211,7 +210,7 @@ class CheckoutPresentationTest { val dialog = ShadowDialog.getLatestDialog() as CheckoutDialog - dialog.currentWebView().getEventProcessor().onGeolocationPermissionsHidePrompt() + dialog.currentWebView().getListener().onGeolocationPermissionsHidePrompt() assertThat(hidden).isTrue() } @@ -223,7 +222,6 @@ class CheckoutPresentationTest { val dialog = ShadowDialog.getLatestDialog() as CheckoutDialog - dialog.currentWebView().getEventProcessor().onCheckoutViewComplete(emptyCompletedEvent()) dialogHandle?.dismiss() shadowOf(Looper.getMainLooper()).runToEndOfTasks() diff --git a/platforms/android/lib/src/test/java/com/shopify/checkoutkit/CheckoutWebViewCacheTest.kt b/platforms/android/lib/src/test/java/com/shopify/checkoutkit/CheckoutWebViewCacheTest.kt index 516383bc..f7bd71f9 100644 --- a/platforms/android/lib/src/test/java/com/shopify/checkoutkit/CheckoutWebViewCacheTest.kt +++ b/platforms/android/lib/src/test/java/com/shopify/checkoutkit/CheckoutWebViewCacheTest.kt @@ -40,7 +40,7 @@ import kotlin.time.Duration.Companion.minutes class CheckoutWebViewCacheTest { private lateinit var activity: ComponentActivity - private lateinit var eventProcessor: CheckoutEventProcessor + private lateinit var listener: CheckoutListener @Before fun setUp() { @@ -48,7 +48,7 @@ class CheckoutWebViewCacheTest { shadowOf(Looper.getMainLooper()).runToEndOfTasks() activity = Robolectric.buildActivity(ComponentActivity::class.java).get() - eventProcessor = eventProcessor() + listener = listener() } @Test @@ -190,7 +190,7 @@ class CheckoutWebViewCacheTest { } } - private fun eventProcessor(): CheckoutEventProcessor = NoopEventProcessor() + private fun listener(): CheckoutListener = NoopCheckoutListener() companion object { private const val URL = "https://a.checkout.testurl" diff --git a/platforms/android/lib/src/test/java/com/shopify/checkoutkit/CheckoutWebViewClientTest.kt b/platforms/android/lib/src/test/java/com/shopify/checkoutkit/CheckoutWebViewClientTest.kt index 262049f6..62640cd5 100644 --- a/platforms/android/lib/src/test/java/com/shopify/checkoutkit/CheckoutWebViewClientTest.kt +++ b/platforms/android/lib/src/test/java/com/shopify/checkoutkit/CheckoutWebViewClientTest.kt @@ -56,8 +56,8 @@ import kotlin.time.Duration.Companion.minutes class CheckoutWebViewClientTest { private lateinit var activity: ComponentActivity - private val mockEventProcessor = mock() - private val checkoutWebViewEventProcessor = spy(CheckoutWebViewEventProcessor(mockEventProcessor)) + private val mockListener = mock() + private val checkoutWebViewListener = spy(CheckoutWebViewListener(mockListener)) @Before fun setUp() { @@ -161,7 +161,7 @@ class CheckoutWebViewClientTest { ShadowLooper.shadowMainLooper().runToEndOfTasks() val captor = argumentCaptor() - verify(checkoutWebViewEventProcessor).onCheckoutViewFailedWithError(captor.capture()) + verify(checkoutWebViewListener).onCheckoutViewFailedWithError(captor.capture()) assertThat(captor.firstValue) .isInstanceOf(CheckoutExpiredException::class.java) .isNotRecoverable() @@ -178,7 +178,7 @@ class CheckoutWebViewClientTest { triggerOnReceivedHttpError(mockRequest, mockResponse) val captor = argumentCaptor() - verify(checkoutWebViewEventProcessor).onCheckoutViewFailedWithError(captor.capture()) + verify(checkoutWebViewListener).onCheckoutViewFailedWithError(captor.capture()) assertThat(captor.firstValue) .isInstanceOf(CheckoutExpiredException::class.java) .isNotRecoverable() @@ -196,7 +196,7 @@ class CheckoutWebViewClientTest { triggerOnReceivedHttpError(mockRequest, mockResponse) val captor = argumentCaptor() - verify(checkoutWebViewEventProcessor).onCheckoutViewFailedWithError(captor.capture()) + verify(checkoutWebViewListener).onCheckoutViewFailedWithError(captor.capture()) assertThat(captor.firstValue) .isInstanceOf(HttpException::class.java) .hasErrorCode(CheckoutUnavailableException.HTTP_ERROR) @@ -215,7 +215,7 @@ class CheckoutWebViewClientTest { triggerOnReceivedHttpError(mockRequest, mockResponse) val captor = argumentCaptor() - verify(checkoutWebViewEventProcessor).onCheckoutViewFailedWithError(captor.capture()) + verify(checkoutWebViewListener).onCheckoutViewFailedWithError(captor.capture()) assertThat(captor.firstValue) .isInstanceOf(CheckoutUnavailableException::class.java) .isRecoverable() @@ -232,7 +232,7 @@ class CheckoutWebViewClientTest { triggerOnReceivedHttpError(mockRequest, mockResponse) val captor = argumentCaptor() - verify(checkoutWebViewEventProcessor).onCheckoutViewFailedWithError(captor.capture()) + verify(checkoutWebViewListener).onCheckoutViewFailedWithError(captor.capture()) assertThat(captor.firstValue) .isInstanceOf(CheckoutUnavailableException::class.java) .isRecoverable() @@ -249,7 +249,7 @@ class CheckoutWebViewClientTest { triggerOnReceivedHttpError(mockRequest, mockResponse) val captor = argumentCaptor() - verify(checkoutWebViewEventProcessor).onCheckoutViewFailedWithError(captor.capture()) + verify(checkoutWebViewListener).onCheckoutViewFailedWithError(captor.capture()) assertThat(captor.firstValue) .isInstanceOf(CheckoutUnavailableException::class.java) .isRecoverable() @@ -268,7 +268,7 @@ class CheckoutWebViewClientTest { triggerOnReceivedHttpError(mockRequest, mockResponse) val captor = argumentCaptor() - verify(checkoutWebViewEventProcessor).onCheckoutViewFailedWithError(captor.capture()) + verify(checkoutWebViewListener).onCheckoutViewFailedWithError(captor.capture()) assertThat(captor.firstValue) .isInstanceOf(CheckoutUnavailableException::class.java) .isNotRecoverable() @@ -287,7 +287,7 @@ class CheckoutWebViewClientTest { triggerOnReceivedHttpError(mockRequest, mockResponse) val captor = argumentCaptor() - verify(checkoutWebViewEventProcessor).onCheckoutViewFailedWithError(captor.capture()) + verify(checkoutWebViewListener).onCheckoutViewFailedWithError(captor.capture()) assertThat(captor.firstValue) .isNotRecoverable() .isInstanceOf(CheckoutUnavailableException::class.java) @@ -306,7 +306,7 @@ class CheckoutWebViewClientTest { triggerOnReceivedHttpError(mockRequest, mockResponse) val captor = argumentCaptor() - verify(checkoutWebViewEventProcessor).onCheckoutViewFailedWithError(captor.capture()) + verify(checkoutWebViewListener).onCheckoutViewFailedWithError(captor.capture()) assertThat(captor.firstValue) .isRecoverable() .isInstanceOf(HttpException::class.java) @@ -337,7 +337,7 @@ class CheckoutWebViewClientTest { webViewClient.onPageFinished(view, "https://anything") - verify(checkoutWebViewEventProcessor).onCheckoutViewLoadComplete() + verify(checkoutWebViewListener).onCheckoutViewLoadComplete() } @Test @@ -350,7 +350,7 @@ class CheckoutWebViewClientTest { val result = webViewClient.onRenderProcessGone(view, detail) assertThat(result).isFalse - verify(checkoutWebViewEventProcessor, never()).onCheckoutViewFailedWithError(any()) + verify(checkoutWebViewListener, never()).onCheckoutViewFailedWithError(any()) } @Config(sdk = [26]) @@ -364,7 +364,7 @@ class CheckoutWebViewClientTest { val result = webViewClient.onRenderProcessGone(view, detail) assertThat(result).isFalse - verify(checkoutWebViewEventProcessor, never()).onCheckoutViewFailedWithError(any()) + verify(checkoutWebViewListener, never()).onCheckoutViewFailedWithError(any()) } @Config(sdk = [26]) @@ -379,7 +379,7 @@ class CheckoutWebViewClientTest { assertThat(result).isTrue val captor = argumentCaptor() - verify(checkoutWebViewEventProcessor).onCheckoutViewFailedWithError(captor.capture()) + verify(checkoutWebViewListener).onCheckoutViewFailedWithError(captor.capture()) assertThat(captor.firstValue) .isInstanceOf(CheckoutKitException::class.java) .hasDescription("Render process gone.") @@ -417,7 +417,7 @@ class CheckoutWebViewClientTest { activity: ComponentActivity, ): CheckoutWebView { val view = CheckoutWebView(activity) - view.setEventProcessor(checkoutWebViewEventProcessor) + view.setListener(checkoutWebViewListener) return view } diff --git a/platforms/android/lib/src/test/java/com/shopify/checkoutkit/CheckoutWebViewTest.kt b/platforms/android/lib/src/test/java/com/shopify/checkoutkit/CheckoutWebViewTest.kt index a6bd2de6..107ee732 100644 --- a/platforms/android/lib/src/test/java/com/shopify/checkoutkit/CheckoutWebViewTest.kt +++ b/platforms/android/lib/src/test/java/com/shopify/checkoutkit/CheckoutWebViewTest.kt @@ -165,23 +165,23 @@ class CheckoutWebViewTest { @Test fun `calls update progress when new progress is reported by WebChromeClient`() { val view = CheckoutWebView.cacheableCheckoutView(URL, activity) - val webViewEventProcessor = mock() - view.setEventProcessor(webViewEventProcessor) + val webViewListener = mock() + view.setListener(webViewListener) val shadow = shadowOf(view) shadow.webChromeClient?.onProgressChanged(view, 20) - verify(webViewEventProcessor).updateProgressBar(20) + verify(webViewListener).updateProgressBar(20) shadow.webChromeClient?.onProgressChanged(view, 50) - verify(webViewEventProcessor).updateProgressBar(50) + verify(webViewListener).updateProgressBar(50) } @Test fun `calls processors onPermissionRequest when resource permission requested`() { val view = CheckoutWebView.cacheableCheckoutView(URL, activity) - val webViewEventProcessor = mock() - view.setEventProcessor(webViewEventProcessor) + val webViewListener = mock() + view.setListener(webViewListener) val permissionRequest = mock() val requestedResources = arrayOf(PermissionRequest.RESOURCE_VIDEO_CAPTURE) @@ -190,14 +190,14 @@ class CheckoutWebViewTest { val shadow = shadowOf(view) shadow.webChromeClient?.onPermissionRequest(permissionRequest) - verify(webViewEventProcessor).onPermissionRequest(permissionRequest) + verify(webViewListener).onPermissionRequest(permissionRequest) } @Test fun `calls processors onShowFileChooser when called on webChromeClient`() { val view = CheckoutWebView.cacheableCheckoutView(URL, activity) - val webViewEventProcessor = mock() - view.setEventProcessor(webViewEventProcessor) + val webViewListener = mock() + view.setListener(webViewListener) val shadow = shadowOf(view) val filePathCallback = mock>>() @@ -205,14 +205,14 @@ class CheckoutWebViewTest { shadow.webChromeClient.onShowFileChooser(view, filePathCallback, fileChooserParams) - verify(webViewEventProcessor).onShowFileChooser(view, filePathCallback, fileChooserParams) + verify(webViewListener).onShowFileChooser(view, filePathCallback, fileChooserParams) } @Test fun `calls processors onGeolocationPermissionsShowPrompt when called on webChromeClient`() { val view = CheckoutWebView.cacheableCheckoutView(URL, activity) - val webViewEventProcessor = mock() - view.setEventProcessor(webViewEventProcessor) + val webViewListener = mock() + view.setListener(webViewListener) val shadow = shadowOf(view) @@ -221,7 +221,7 @@ class CheckoutWebViewTest { shadow.webChromeClient.onGeolocationPermissionsShowPrompt(origin, callback) - verify(webViewEventProcessor).onGeolocationPermissionsShowPrompt(origin, callback) + verify(webViewListener).onGeolocationPermissionsShowPrompt(origin, callback) } @Test diff --git a/platforms/android/lib/src/test/java/com/shopify/checkoutkit/DefaultCheckoutEventProcessorTest.kt b/platforms/android/lib/src/test/java/com/shopify/checkoutkit/DefaultCheckoutEventProcessorTest.kt deleted file mode 100644 index 2d8c9c49..00000000 --- a/platforms/android/lib/src/test/java/com/shopify/checkoutkit/DefaultCheckoutEventProcessorTest.kt +++ /dev/null @@ -1,75 +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.checkoutkit - -import androidx.activity.ComponentActivity -import com.shopify.checkoutkit.lifecycleevents.CheckoutCompletedEvent -import org.assertj.core.api.Assertions.assertThat -import org.junit.Before -import org.junit.Test -import org.junit.runner.RunWith -import org.robolectric.Robolectric -import org.robolectric.RobolectricTestRunner -import org.robolectric.Shadows.shadowOf -import org.robolectric.shadows.ShadowActivity - -@RunWith(RobolectricTestRunner::class) -class DefaultCheckoutEventProcessorTest { - - private lateinit var activity: ComponentActivity - private lateinit var shadowActivity: ShadowActivity - - @Before - fun setUp() { - activity = Robolectric.buildActivity(ComponentActivity::class.java).get() - shadowActivity = shadowOf(activity) - } - - @Test - fun `onCheckoutFailed returns an error description`() { - var description = "" - var recoverable: Boolean? = null - val processor = - object : DefaultCheckoutEventProcessor() { - override fun onCheckoutCompleted(checkoutCompletedEvent: CheckoutCompletedEvent) { - /* not implemented */ - } - - override fun onCheckoutFailed(error: CheckoutException) { - description = error.errorDescription - recoverable = error.isRecoverable - } - - override fun onCheckoutCanceled() { - /* not implemented */ - } - } - - val error = object : CheckoutUnavailableException("error description", "unknown", true) {} - - processor.onCheckoutFailed(error) - - assertThat(description).isEqualTo("error description") - assertThat(recoverable).isTrue() - } -} diff --git a/platforms/android/lib/src/test/java/com/shopify/checkoutkit/EmbeddedCheckoutProtocolTest.kt b/platforms/android/lib/src/test/java/com/shopify/checkoutkit/EmbeddedCheckoutProtocolTest.kt index d10b03e4..1aa4b403 100644 --- a/platforms/android/lib/src/test/java/com/shopify/checkoutkit/EmbeddedCheckoutProtocolTest.kt +++ b/platforms/android/lib/src/test/java/com/shopify/checkoutkit/EmbeddedCheckoutProtocolTest.kt @@ -49,7 +49,7 @@ class EmbeddedCheckoutProtocolTest { private lateinit var activity: ComponentActivity private lateinit var viewSpy: CheckoutWebView - private lateinit var mockEventProcessor: CheckoutWebViewEventProcessor + private lateinit var mockListener: CheckoutWebViewListener private lateinit var ecp: EmbeddedCheckoutProtocol @Before @@ -63,8 +63,8 @@ class EmbeddedCheckoutProtocolTest { // intent instead — turning on checkActivities aligns the shadow with production. shadowOf(activity.application).checkActivities(true) viewSpy = Mockito.spy(CheckoutWebView(activity)) - mockEventProcessor = mock() - whenever(viewSpy.getEventProcessor()).thenReturn(mockEventProcessor) + mockListener = mock() + whenever(viewSpy.getListener()).thenReturn(mockListener) ecp = EmbeddedCheckoutProtocol(viewSpy) } @@ -305,7 +305,7 @@ class EmbeddedCheckoutProtocolTest { fun `ec start shows progress bar`() { ecp.postMessage("""{"jsonrpc":"2.0","method":"ec.start","params":{"checkout":{}}}""") shadowOf(Looper.getMainLooper()).runToEndOfTasks() - verify(mockEventProcessor).onCheckoutViewLoadStarted() + verify(mockListener).onCheckoutViewLoadStarted() } @Test @@ -360,6 +360,23 @@ class EmbeddedCheckoutProtocolTest { verify(client).process(rawMessage) } + @Test + fun `ec complete marks the preloaded cache entry stale`() { + ShopifyCheckoutKit.configure { it.preloading = Preloading(enabled = true) } + try { + CheckoutWebView.cacheableCheckoutView("https://shopify.com/checkout", activity, isPreload = true) + shadowOf(Looper.getMainLooper()).runToEndOfTasks() + assertThat(CheckoutWebView.cacheEntry!!.isValid("https://shopify.com/checkout")).isTrue() + + ecp.postMessage("""{"jsonrpc":"2.0","method":"ec.complete","params":{"checkout":{}}}""") + shadowOf(Looper.getMainLooper()).runToEndOfTasks() + + assertThat(CheckoutWebView.cacheEntry!!.isValid("https://shopify.com/checkout")).isFalse() + } finally { + ShopifyCheckoutKit.configure { it.preloading = Preloading(enabled = false) } + } + } + // endregion // region client delegation — requests diff --git a/platforms/android/lib/src/test/java/com/shopify/checkoutkit/FallbackWebViewClientTest.kt b/platforms/android/lib/src/test/java/com/shopify/checkoutkit/FallbackWebViewClientTest.kt deleted file mode 100644 index 5ff86d86..00000000 --- a/platforms/android/lib/src/test/java/com/shopify/checkoutkit/FallbackWebViewClientTest.kt +++ /dev/null @@ -1,108 +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.checkoutkit - -import androidx.activity.ComponentActivity -import com.shopify.checkoutkit.lifecycleevents.emptyCompletedEvent -import org.junit.Test -import org.junit.runner.RunWith -import org.mockito.Mockito.mock -import org.mockito.Mockito.never -import org.mockito.Mockito.verify -import org.mockito.kotlin.any -import org.robolectric.Robolectric -import org.robolectric.RobolectricTestRunner - -@RunWith(RobolectricTestRunner::class) -class FallbackWebViewClientTest { - - @Test - fun `should call onCheckoutCompleted in onPageFinished if url looks like a typ - hyphen`() { - Robolectric.buildActivity(ComponentActivity::class.java).use { activityController -> - val view = FallbackWebView(activityController.get()) - val mockProcessor = mock() - view.setEventProcessor(mockProcessor) - - val client = view.FallbackWebViewClient() - client.onPageFinished(view, "https://abc.com/cn-12345678/thank-you?a=b") - - verify(mockProcessor).onCheckoutViewComplete(emptyCompletedEvent()) - } - } - - @Test - fun `should call onCheckoutCompleted in onPageFinished if url looks like a typ - underscore`() { - Robolectric.buildActivity(ComponentActivity::class.java).use { activityController -> - val view = FallbackWebView(activityController.get()) - val mockProcessor = mock() - view.setEventProcessor(mockProcessor) - - val client = view.FallbackWebViewClient() - client.onPageFinished(view, "https://abc.com/cn-12345678/thank_you") - - verify(mockProcessor).onCheckoutViewComplete(emptyCompletedEvent()) - } - } - - @Test - fun `should call onCheckoutCompleted in onPageFinished if url looks like a typ - mixed case`() { - Robolectric.buildActivity(ComponentActivity::class.java).use { activityController -> - val view = FallbackWebView(activityController.get()) - val mockProcessor = mock() - view.setEventProcessor(mockProcessor) - - val client = view.FallbackWebViewClient() - client.onPageFinished(view, "https://abc.com/cn-12345678/tHAnk_you") - - verify(mockProcessor).onCheckoutViewComplete(emptyCompletedEvent()) - } - } - - @Test - fun `should call onCheckoutCompleted with order id in onPageFinished if url looks like a typ and query param present`() { - Robolectric.buildActivity(ComponentActivity::class.java).use { activityController -> - val view = FallbackWebView(activityController.get()) - val mockProcessor = mock() - view.setEventProcessor(mockProcessor) - - val client = view.FallbackWebViewClient() - client.onPageFinished(view, "https://abc.com/cn-12345678/thank-you?order_id=123") - - verify(mockProcessor).onCheckoutViewComplete(emptyCompletedEvent(id = "123")) - } - } - - @Test - fun `should not call onCheckoutCompleted in onPageFinished if url does not look like a typ`() { - Robolectric.buildActivity(ComponentActivity::class.java).use { activityController -> - val view = FallbackWebView(activityController.get()) - val mockProcessor = mock() - view.setEventProcessor(mockProcessor) - - val client = view.FallbackWebViewClient() - client.onPageFinished(view, "https://abc.com/cn-12345678/processing?a=b") - - verify(mockProcessor, never()).onCheckoutViewComplete(any()) - } - } -} diff --git a/platforms/android/lib/src/test/java/com/shopify/checkoutkit/FallbackWebViewTest.kt b/platforms/android/lib/src/test/java/com/shopify/checkoutkit/FallbackWebViewTest.kt index bd1e7357..48d176ca 100644 --- a/platforms/android/lib/src/test/java/com/shopify/checkoutkit/FallbackWebViewTest.kt +++ b/platforms/android/lib/src/test/java/com/shopify/checkoutkit/FallbackWebViewTest.kt @@ -76,16 +76,16 @@ class FallbackWebViewTest { fun `calls update progress when new progress is reported by WebChromeClient`() { Robolectric.buildActivity(ComponentActivity::class.java).use { activityController -> val view = FallbackWebView(activityController.get()) - val webViewEventProcessor = mock() - view.setEventProcessor(webViewEventProcessor) + val webViewListener = mock() + view.setListener(webViewListener) val shadow = shadowOf(view) shadow.webChromeClient?.onProgressChanged(view, 20) - verify(webViewEventProcessor).updateProgressBar(20) + verify(webViewListener).updateProgressBar(20) shadow.webChromeClient?.onProgressChanged(view, 50) - verify(webViewEventProcessor).updateProgressBar(50) + verify(webViewListener).updateProgressBar(50) } } diff --git a/platforms/android/lib/src/test/java/com/shopify/checkoutkit/Helpers.kt b/platforms/android/lib/src/test/java/com/shopify/checkoutkit/Helpers.kt index 1539f495..861bae3d 100644 --- a/platforms/android/lib/src/test/java/com/shopify/checkoutkit/Helpers.kt +++ b/platforms/android/lib/src/test/java/com/shopify/checkoutkit/Helpers.kt @@ -22,7 +22,6 @@ */ package com.shopify.checkoutkit -import com.shopify.checkoutkit.lifecycleevents.CheckoutCompletedEvent import org.assertj.core.api.AbstractAssert fun withPreloadingEnabled(block: () -> Unit) { @@ -102,10 +101,14 @@ class CheckoutExceptionAssert(actual: CheckoutException) : } } -fun noopDefaultCheckoutEventProcessor(): DefaultCheckoutEventProcessor { - return object : DefaultCheckoutEventProcessor() { - override fun onCheckoutCompleted(checkoutCompletedEvent: CheckoutCompletedEvent) = Unit - override fun onCheckoutFailed(error: CheckoutException) = Unit - override fun onCheckoutCanceled() = Unit +fun noopDefaultCheckoutListener(): DefaultCheckoutListener { + return object : DefaultCheckoutListener() { + override fun onCheckoutFailed(error: CheckoutException) { + // no-op + } + + override fun onCheckoutCanceled() { + // no-op + } } } 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 4c65994f..302de966 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 @@ -144,27 +144,20 @@ public void tearDown() { } @Test - public void canInstantiateCustomEventProcessorWithDefaultArg() { - try (ActivityController controller = Robolectric.buildActivity(ComponentActivity.class)) { - DefaultCheckoutEventProcessor processor = new DefaultCheckoutEventProcessor() { - @Override - public void onCheckoutCompleted(@NonNull CheckoutCompletedEvent checkoutCompletedEvent) { - - } + public void canInstantiateCustomListener() { + DefaultCheckoutListener listener = new DefaultCheckoutListener() { + @Override + public void onCheckoutFailed(@NonNull CheckoutException error) { - @Override - public void onCheckoutFailed(@NonNull CheckoutException error) { - - } + } - @Override - public void onCheckoutCanceled() { + @Override + public void onCheckoutCanceled() { - } - }; + } + }; - assertThat(processor).isNotNull(); - } + assertThat(listener).isNotNull(); } @SuppressWarnings("all") @@ -248,12 +241,7 @@ public void presentReturnsAHandleToAllowDismissingDialog() { CheckoutKitDialog dialog = ShopifyCheckoutKit.present( "https://shopify.dev", activity, - new DefaultCheckoutEventProcessor() { - @Override - public void onCheckoutCompleted(@NonNull CheckoutCompletedEvent checkoutCompletedEvent) { - // do nothing - } - + new DefaultCheckoutListener() { @Override public void onCheckoutFailed(@NonNull CheckoutException error) { // 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 db9835a7..cffa011a 100644 --- a/platforms/android/lib/src/test/java/com/shopify/checkoutkit/ShopifyCheckoutKitTest.kt +++ b/platforms/android/lib/src/test/java/com/shopify/checkoutkit/ShopifyCheckoutKitTest.kt @@ -136,7 +136,7 @@ class ShopifyCheckoutKitTest { ShopifyCheckoutKit.present( "https://one.com", activity, - noopDefaultCheckoutEventProcessor() + noopDefaultCheckoutListener() ) ShadowLooper.shadowMainLooper().runToEndOfTasks() @@ -175,7 +175,7 @@ class ShopifyCheckoutKitTest { ShopifyCheckoutKit.present( "https://one.com", activity, - noopDefaultCheckoutEventProcessor() + noopDefaultCheckoutListener() ) ShadowLooper.shadowMainLooper().runToEndOfTasks() diff --git a/platforms/android/.kotlin/sessions/kotlin-compiler-15548690111378567774.salive b/platforms/android/samples/MobileBuyIntegration/.kotlin/sessions/kotlin-compiler-5704622805265865419.salive similarity index 100% rename from platforms/android/.kotlin/sessions/kotlin-compiler-15548690111378567774.salive rename to platforms/android/samples/MobileBuyIntegration/.kotlin/sessions/kotlin-compiler-5704622805265865419.salive diff --git a/platforms/swift/Gemfile.lock b/platforms/swift/Gemfile.lock index 46c25dd5..ac806319 100644 --- a/platforms/swift/Gemfile.lock +++ b/platforms/swift/Gemfile.lock @@ -102,6 +102,7 @@ GEM PLATFORMS arm64-darwin-21 arm64-darwin-23 + arm64-darwin-24 x86_64-darwin-19 x86_64-darwin-22 x86_64-linux diff --git a/platforms/swift/README.md b/platforms/swift/README.md index bedb7b5b..4414adf1 100644 --- a/platforms/swift/README.md +++ b/platforms/swift/README.md @@ -144,7 +144,7 @@ struct ContentView: View { } .sheet(isPresented: $isPresented) { if let url = checkoutURL { - ShopifyCheckout(url: url) + ShopifyCheckout(checkout: url) /// Configuration .title("Checkout") .colorScheme(.automatic) @@ -156,17 +156,9 @@ struct ContentView: View { .onCancel { isPresented = false } - .onComplete { event in - handleCompletedEvent(event) - } .onFail { error in handleError(error) } - .onLinkClick { url in - if UIApplication.shared.canOpenURL(url) { - UIApplication.shared.open(url) - } - } .edgesIgnoringSafeArea(.all) } } @@ -373,15 +365,10 @@ A preloaded checkout _is not_ automatically invalidated when checkout sheet is c ## Monitoring the lifecycle of a checkout session -You can use the `ShopifyCheckoutKitDelegate` protocol to register callbacks for key lifecycle events during the checkout session: +You can use the `CheckoutDelegate` protocol to register callbacks for lifecycle events the host app needs to react to: ```swift -extension MyViewController: ShopifyCheckoutKitDelegate { - func checkoutDidComplete(event: CheckoutCompletedEvent) { - // Called when the checkout was completed successfully by the buyer. - // Use this to update UI, reset cart state, etc. - } - +extension MyViewController: CheckoutDelegate { func checkoutDidCancel() { // Called when the checkout was canceled by the buyer. // Use this to call `dismiss(animated:)`, etc. @@ -389,35 +376,25 @@ extension MyViewController: ShopifyCheckoutKitDelegate { func checkoutDidFail(error: CheckoutError) { // Called when the checkout encountered an error and has been aborted. The callback - // provides a `CheckoutError` enum, with one of the following values: - // Internal error: exception within the Checkout SDK code - // You can inspect and log the Erorr and stacktrace to identify the problem. - case sdkError(underlying: Swift.Error) + // provides a `CheckoutError` enum, with one of the following cases: - // Issued when the provided checkout URL results in an error related to shop configuration. - // Note: The SDK only supports stores migrated for extensibility. - case configurationError(message: String) + // Internal error: exception within the Checkout SDK code. + // Inspect the underlying error to identify the problem. + case sdkError(underlying: Swift.Error, recoverable: Bool) - // Unavailable error: checkout cannot be initiated or completed, e.g. due to network or server-side error + // Checkout cannot be initiated or completed, e.g. due to network or server-side error. // The provided message describes the error and may be logged and presented to the buyer. - case checkoutUnavailable(message: String) + case checkoutUnavailable(message: String, code: CheckoutUnavailable, recoverable: Bool) - // Expired error: checkout session associated with provided checkoutURL is no longer available. + // Checkout session associated with the provided checkoutURL is no longer available. // The provided message describes the error and may be logged and presented to the buyer. - case checkoutExpired(message: String) + case checkoutExpired(message: String, code: CheckoutErrorCode, recoverable: Bool) } - - func checkoutDidClickLink(url: URL) { - // Called when the buyer clicks a link within the checkout experience: - // - email address (`mailto:`), - // - telephone number (`tel:`), - // - web (`http:`) - // and is being directed outside the application. - } - } ``` +Completion events and other in-checkout messages flow through `CheckoutCommunicationProtocol` (UCP) — register handlers on a `CheckoutProtocol.Client` and pass it to `present(checkout:from:delegate:client:)`. See `Samples/MobileBuyIntegration` for a full example. + ## Error handling In the event of a checkout error occurring, the Checkout Kit _may_ attempt a 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: @@ -428,15 +405,9 @@ In the event of a checkout error occurring, the Checkout Kit _may_ attempt a ret There are some caveats to note when this scenario occurs: 1. The checkout experience may look different to buyers. Though the kit will attempt to load any checkout customizations for the storefront, there is no guarantee they will show in recovery mode. -2. The `checkoutDidComplete(event:)` will be emitted with partial data. Invocations will only receive the order ID via `event.orderDetails.id`. +2. Completion events delivered via `CheckoutProtocol.complete` during recovery may contain partial data — typically only the order ID. -Should you wish to opt-out of this fallback experience entirely, you can do so by adding a `shouldRecoverFromError(error:)` method to your delegate controller. Errors given to the `checkoutDidFail(error:)` lifecycle method, will contain an `isRecoverable` property by default indicating whether the request should be retried or not. - -```swift -func shouldRecoverFromError(error: CheckoutError) { - return error.isRecoverable // default -} -``` +Errors given to `checkoutDidFail(error:)` carry an `isRecoverable` property indicating whether recovery will be attempted. ### `CheckoutError` @@ -495,15 +466,7 @@ Certain payment providers finalize transactions by redirecting customers to exte See the [Universal Links guide](https://github.com/Shopify/checkout-kit/blob/main/platforms/swift/documentation/universal_links.md) for information on how to get started with adding support for Offsite Payments in your app. -It is crucial for your app to be configured to handle URL clicks during the checkout process effectively. By default, the kit includes the following delegate method to manage these interactions. This code ensures that external links, such as HTTPS and deep links, are opened correctly by iOS. - -```swift -public func checkoutDidClickLink(url: URL) { - if UIApplication.shared.canOpenURL(url) { - UIApplication.shared.open(url) - } -} -``` +External links opened from within checkout (HTTPS, deep links, `mailto:`, `tel:`) are forwarded to `UIApplication.shared.open(_:)` by the kit, so universal links and Offsite Payments redirects route back to your app automatically once the rest of the universal-links setup is in place. ## Accelerated Checkouts diff --git a/platforms/swift/Samples/MobileBuyIntegration/MobileBuyIntegration/Sources/App/CartResettingCheckoutDelegate.swift b/platforms/swift/Samples/MobileBuyIntegration/MobileBuyIntegration/Sources/App/CartResettingCheckoutDelegate.swift new file mode 100644 index 00000000..48617b60 --- /dev/null +++ b/platforms/swift/Samples/MobileBuyIntegration/MobileBuyIntegration/Sources/App/CartResettingCheckoutDelegate.swift @@ -0,0 +1,43 @@ +/* + 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. + */ + +import ShopifyCheckoutKit + +@MainActor +final class CartResettingCheckoutDelegate: CheckoutDelegate { + private var completed = false + + func markCompleted() { + completed = true + } + + nonisolated func checkoutDidCancel() { + MainActor.assumeIsolated { + guard completed else { return } + completed = false + CartManager.shared.resetCart() + } + } + + nonisolated func checkoutDidFail(error _: CheckoutError) {} +} diff --git a/platforms/swift/Samples/MobileBuyIntegration/MobileBuyIntegration/Sources/App/CheckoutCoordinator.swift b/platforms/swift/Samples/MobileBuyIntegration/MobileBuyIntegration/Sources/App/CheckoutCoordinator.swift index 283e2a98..ba9c0399 100644 --- a/platforms/swift/Samples/MobileBuyIntegration/MobileBuyIntegration/Sources/App/CheckoutCoordinator.swift +++ b/platforms/swift/Samples/MobileBuyIntegration/MobileBuyIntegration/Sources/App/CheckoutCoordinator.swift @@ -30,13 +30,14 @@ class CheckoutCoordinator: UIViewController { var window: UIWindow? var root: UIViewController? - private let client = CheckoutProtocol.Client() + private let checkoutDelegate = CartResettingCheckoutDelegate() + private lazy var client = CheckoutProtocol.Client() .on(CheckoutProtocol.start) { checkout in OSLogger.shared.debug("[UCP] Checkout started: \(checkout.id)") } - .on(CheckoutProtocol.complete) { checkout in + .on(CheckoutProtocol.complete) { [checkoutDelegate] checkout in OSLogger.shared.debug("[UCP] Checkout completed: \(checkout.order?.id ?? "unknown")") - CartManager.shared.resetCart() + checkoutDelegate.markCompleted() } init(window: UIWindow?) { @@ -53,7 +54,7 @@ class CheckoutCoordinator: UIViewController { public func present(checkout url: URL) { if let rootViewController = window?.topMostViewController() { - ShopifyCheckoutKit.present(checkout: url, from: rootViewController, client: client) + ShopifyCheckoutKit.present(checkout: url, from: rootViewController, delegate: checkoutDelegate, client: client) root = rootViewController } } diff --git a/platforms/swift/Samples/MobileBuyIntegration/MobileBuyIntegration/Sources/CheckoutProtocolClient.swift b/platforms/swift/Samples/MobileBuyIntegration/MobileBuyIntegration/Sources/CheckoutProtocolClient.swift index 171b5951..d0ffd1a4 100644 --- a/platforms/swift/Samples/MobileBuyIntegration/MobileBuyIntegration/Sources/CheckoutProtocolClient.swift +++ b/platforms/swift/Samples/MobileBuyIntegration/MobileBuyIntegration/Sources/CheckoutProtocolClient.swift @@ -33,8 +33,10 @@ extension CheckoutProtocol.Client { print("[UCP] ec.start: \(checkout.id)") } .on(CheckoutProtocol.complete) { checkout in + // Do NOT reset the cart here — the cart drives a SwiftUI `if let` in CartView, + // and nil-ing it auto-collapses the .sheet, hiding the order confirmation page. + // Reset on user dismiss instead (see CartView .onCancel + isCompleted). print("[UCP] ec.complete: \(checkout.order?.id ?? "unknown")") - CartManager.shared.resetCart() } .on(CheckoutProtocol.lineItemsChange) { checkout in print("[UCP] ec.line_items.change: \(checkout.id)") diff --git a/platforms/swift/Samples/MobileBuyIntegration/MobileBuyIntegration/Sources/Scenes/Cart/CartView.swift b/platforms/swift/Samples/MobileBuyIntegration/MobileBuyIntegration/Sources/Scenes/Cart/CartView.swift index 542e7bf6..c332155f 100644 --- a/platforms/swift/Samples/MobileBuyIntegration/MobileBuyIntegration/Sources/Scenes/Cart/CartView.swift +++ b/platforms/swift/Samples/MobileBuyIntegration/MobileBuyIntegration/Sources/Scenes/Cart/CartView.swift @@ -114,7 +114,13 @@ struct CartView: View { .sheet(isPresented: $showCheckoutSheet) { if let url = cartManager.cart?.checkoutURL { ShopifyCheckout(checkout: url) - .connect(client) + .connect(client.on(CheckoutProtocol.complete) { checkout in + // Set the flag here; defer the cart reset until the user dismisses + // the sheet (in .onCancel). Resetting now would nil the cart and + // SwiftUI would auto-collapse this sheet, hiding the confirmation page. + print("[UCP] ec.complete: \(checkout.order?.id ?? "unknown")") + isCompleted = true + }) .colorScheme(.automatic) .onCancel { print("[ShopifyCheckoutKit] CANCEL") diff --git a/platforms/swift/Samples/MobileBuyIntegration/MobileBuyIntegration/Sources/Scenes/Cart/CartViewController.swift b/platforms/swift/Samples/MobileBuyIntegration/MobileBuyIntegration/Sources/Scenes/Cart/CartViewController.swift index 4c44c8d1..69893ebc 100644 --- a/platforms/swift/Samples/MobileBuyIntegration/MobileBuyIntegration/Sources/Scenes/Cart/CartViewController.swift +++ b/platforms/swift/Samples/MobileBuyIntegration/MobileBuyIntegration/Sources/Scenes/Cart/CartViewController.swift @@ -151,13 +151,14 @@ class CartViewController: UIViewController, UITableViewDelegate, UITableViewData private var buttonStackView: UIStackView! private var checkoutButton: UIButton! - private let client = CheckoutProtocol.Client() + private let checkoutDelegate = CartResettingCheckoutDelegate() + private lazy var client = CheckoutProtocol.Client() .on(CheckoutProtocol.start) { checkout in print("[UCP] Checkout started: \(checkout.id)") } - .on(CheckoutProtocol.complete) { checkout in + .on(CheckoutProtocol.complete) { [checkoutDelegate] checkout in print("[UCP] Checkout completed: \(checkout.order?.id ?? "unknown")") - CartManager.shared.resetCart() + checkoutDelegate.markCompleted() } // MARK: Initializers @@ -404,7 +405,7 @@ class CartViewController: UIViewController, UITableViewDelegate, UITableViewData @objc private func presentCheckout() { guard let url = CartManager.shared.cart?.checkoutURL else { return } - ShopifyCheckoutKit.present(checkout: url, from: self, client: client) + ShopifyCheckoutKit.present(checkout: url, from: self, delegate: checkoutDelegate, client: client) } @objc private func resetCart() { diff --git a/platforms/swift/Sources/ShopifyCheckoutKit/CheckoutDelegate.swift b/platforms/swift/Sources/ShopifyCheckoutKit/CheckoutDelegate.swift new file mode 100644 index 00000000..c56586a8 --- /dev/null +++ b/platforms/swift/Sources/ShopifyCheckoutKit/CheckoutDelegate.swift @@ -0,0 +1,33 @@ +/* + 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. + */ + +import Foundation + +/// A delegate protocol for managing checkout lifecycle events. +public protocol CheckoutDelegate: AnyObject { + /// Tells the delegate that the checkout was cancelled by the buyer. + func checkoutDidCancel() + + /// Tells the delegate that the checkout encountered one or more errors. + func checkoutDidFail(error: CheckoutError) +} diff --git a/platforms/swift/Sources/ShopifyCheckoutKit/CheckoutViewController.swift b/platforms/swift/Sources/ShopifyCheckoutKit/CheckoutViewController.swift index 2c401a8a..fd4e486f 100644 --- a/platforms/swift/Sources/ShopifyCheckoutKit/CheckoutViewController.swift +++ b/platforms/swift/Sources/ShopifyCheckoutKit/CheckoutViewController.swift @@ -28,15 +28,15 @@ import SwiftUI import UIKit public class CheckoutViewController: UINavigationController { - public init(checkout url: URL, client: (any CheckoutCommunicationProtocol)? = nil) { - let rootViewController = CheckoutWebViewController(checkoutURL: url, client: client, entryPoint: nil) + public init(checkout url: URL, delegate: (any CheckoutDelegate)? = nil, client: (any CheckoutCommunicationProtocol)? = nil) { + let rootViewController = CheckoutWebViewController(checkoutURL: url, delegate: delegate, client: client, entryPoint: nil) rootViewController.notifyPresented() super.init(rootViewController: rootViewController) presentationController?.delegate = rootViewController } - package init(checkout url: URL, client: (any CheckoutCommunicationProtocol)? = nil, entryPoint: MetaData.EntryPoint? = nil) { - let rootViewController = CheckoutWebViewController(checkoutURL: url, client: client, entryPoint: entryPoint) + package init(checkout url: URL, delegate: (any CheckoutDelegate)? = nil, client: (any CheckoutCommunicationProtocol)? = nil, entryPoint: MetaData.EntryPoint? = nil) { + let rootViewController = CheckoutWebViewController(checkoutURL: url, delegate: delegate, client: client, entryPoint: entryPoint) rootViewController.notifyPresented() super.init(rootViewController: rootViewController) presentationController?.delegate = rootViewController diff --git a/platforms/swift/Sources/ShopifyCheckoutKit/CheckoutWebViewController.swift b/platforms/swift/Sources/ShopifyCheckoutKit/CheckoutWebViewController.swift index 547bd73c..fafadfba 100644 --- a/platforms/swift/Sources/ShopifyCheckoutKit/CheckoutWebViewController.swift +++ b/platforms/swift/Sources/ShopifyCheckoutKit/CheckoutWebViewController.swift @@ -27,6 +27,7 @@ import WebKit class CheckoutWebViewController: UIViewController, UIAdaptivePresentationControllerDelegate { var onCancel: (() -> Void)? var onFail: ((CheckoutError) -> Void)? + weak var delegate: (any CheckoutDelegate)? var client: (any CheckoutCommunicationProtocol)? var checkoutViewDidFailWithErrorCount = 0 @@ -77,8 +78,9 @@ class CheckoutWebViewController: UIViewController, UIAdaptivePresentationControl // MARK: Initializers - public init(checkoutURL url: URL, client: (any CheckoutCommunicationProtocol)? = nil, entryPoint: MetaData.EntryPoint? = nil) { + public init(checkoutURL url: URL, delegate: (any CheckoutDelegate)? = nil, client: (any CheckoutCommunicationProtocol)? = nil, entryPoint: MetaData.EntryPoint? = nil) { checkoutURL = url + self.delegate = delegate self.client = client let checkoutView = CheckoutWebView.for(checkout: url, entryPoint: entryPoint) @@ -182,6 +184,7 @@ class CheckoutWebViewController: UIViewController, UIAdaptivePresentationControl } onCancel?() + delegate?.checkoutDidCancel() } package func presentFallbackViewController(url: URL) { @@ -192,6 +195,7 @@ class CheckoutWebViewController: UIViewController, UIAdaptivePresentationControl checkoutView.translatesAutoresizingMaskIntoConstraints = false checkoutView.scrollView.contentInsetAdjustmentBehavior = .never checkoutView.viewDelegate = self + checkoutView.client = client checkoutView.alpha = 1 view.addSubview(checkoutView) @@ -233,11 +237,12 @@ extension CheckoutWebViewController: CheckoutWebViewDelegate { func checkoutViewDidFailWithError(error: CheckoutError) { checkoutViewDidFailWithErrorCount += 1 CheckoutWebView.invalidate() - onFail?(error) if shouldAttemptRecovery(for: error) { presentFallbackViewController(url: checkoutURL) } else { + onFail?(error) + delegate?.checkoutDidFail(error: error) dismiss(animated: true) } } diff --git a/platforms/swift/Sources/ShopifyCheckoutKit/ShopifyCheckoutKit.swift b/platforms/swift/Sources/ShopifyCheckoutKit/ShopifyCheckoutKit.swift index 6b2f1e0b..d3a889b9 100644 --- a/platforms/swift/Sources/ShopifyCheckoutKit/ShopifyCheckoutKit.swift +++ b/platforms/swift/Sources/ShopifyCheckoutKit/ShopifyCheckoutKit.swift @@ -63,17 +63,17 @@ public func invalidate() { } @discardableResult -public func present(checkout url: URL, from: UIViewController, client: (any CheckoutCommunicationProtocol)? = nil) -> CheckoutViewController { +public func present(checkout url: URL, from: UIViewController, delegate: (any CheckoutDelegate)? = nil, client: (any CheckoutCommunicationProtocol)? = nil) -> CheckoutViewController { let decorated = CheckoutProtocol.url(for: url) - let viewController = CheckoutViewController(checkout: decorated, client: client) + let viewController = CheckoutViewController(checkout: decorated, delegate: delegate, client: client) from.present(viewController, animated: true) return viewController } @discardableResult -package func present(checkout url: URL, from: UIViewController, entryPoint: MetaData.EntryPoint, client: (any CheckoutCommunicationProtocol)? = nil) -> CheckoutViewController { +package func present(checkout url: URL, from: UIViewController, entryPoint: MetaData.EntryPoint, delegate: (any CheckoutDelegate)? = nil, client: (any CheckoutCommunicationProtocol)? = nil) -> CheckoutViewController { let decorated = CheckoutProtocol.url(for: url) - let viewController = CheckoutViewController(checkout: decorated, client: client, entryPoint: entryPoint) + let viewController = CheckoutViewController(checkout: decorated, delegate: delegate, client: client, entryPoint: entryPoint) from.present(viewController, animated: true) return viewController } diff --git a/platforms/swift/Tests/ShopifyCheckoutKitTests/CheckoutWebViewControllerTests.swift b/platforms/swift/Tests/ShopifyCheckoutKitTests/CheckoutWebViewControllerTests.swift index 2e2d0dcd..54d3641b 100644 --- a/platforms/swift/Tests/ShopifyCheckoutKitTests/CheckoutWebViewControllerTests.swift +++ b/platforms/swift/Tests/ShopifyCheckoutKitTests/CheckoutWebViewControllerTests.swift @@ -144,6 +144,43 @@ class CheckoutWebViewControllerTests: XCTestCase { XCTAssertTrue(viewController.dismissAnimated) } + func test_checkoutViewDidFailWithError_invokesDelegate() { + let delegate = MockCheckoutDelegate() + let viewController = TestableCheckoutWebViewController(checkoutURL: url, delegate: delegate, entryPoint: nil) + + viewController.checkoutViewDidFailWithError(error: nonRecoverableError) + + XCTAssertEqual(delegate.didFailErrors.count, 1) + } + + func test_checkoutViewDidFailWithError_doesNotInvokeDelegateWhileRecovering() { + let delegate = MockCheckoutDelegate() + var onFailCount = 0 + let viewController = TestableCheckoutWebViewController(checkoutURL: url, delegate: delegate, entryPoint: nil) + viewController.onFail = { _ in onFailCount += 1 } + + viewController.checkoutViewDidFailWithError(error: recoverableError) + + XCTAssertTrue(viewController.presentFallbackViewControllerCalled) + XCTAssertEqual(delegate.didFailErrors.count, 0) + XCTAssertEqual(onFailCount, 0) + + viewController.checkoutViewDidFailWithError(error: recoverableError) + + XCTAssertTrue(viewController.dismissCalled) + XCTAssertEqual(delegate.didFailErrors.count, 1) + XCTAssertEqual(onFailCount, 1) + } + + func test_presentationControllerDidDismiss_invokesDelegateCancel() { + let delegate = MockCheckoutDelegate() + let viewController = TestableCheckoutWebViewController(checkoutURL: url, delegate: delegate, entryPoint: nil) + + viewController.presentationControllerDidDismiss(UIPresentationController(presentedViewController: viewController, presenting: nil)) + + XCTAssertEqual(delegate.didCancelCount, 1) + } + func test_checkoutViewDidFailWithError_respectsErrorRecoverableProperty() { struct TestCase { let name: String diff --git a/platforms/swift/Tests/ShopifyCheckoutKitTests/Mocks/MockCheckoutDelegate.swift b/platforms/swift/Tests/ShopifyCheckoutKitTests/Mocks/MockCheckoutDelegate.swift index cecc5fd9..31774b88 100644 --- a/platforms/swift/Tests/ShopifyCheckoutKitTests/Mocks/MockCheckoutDelegate.swift +++ b/platforms/swift/Tests/ShopifyCheckoutKitTests/Mocks/MockCheckoutDelegate.swift @@ -32,3 +32,16 @@ struct MockBridgeClient: CheckoutCommunicationProtocol { return responseMessage } } + +final class MockCheckoutDelegate: CheckoutDelegate { + private(set) var didCancelCount = 0 + private(set) var didFailErrors: [CheckoutError] = [] + + func checkoutDidCancel() { + didCancelCount += 1 + } + + func checkoutDidFail(error: CheckoutError) { + didFailErrors.append(error) + } +} diff --git a/platforms/swift/Tests/ShopifyCheckoutKitTests/ShopifyCheckoutKitTests.swift b/platforms/swift/Tests/ShopifyCheckoutKitTests/ShopifyCheckoutKitTests.swift index 004261e7..cd1f3560 100644 --- a/platforms/swift/Tests/ShopifyCheckoutKitTests/ShopifyCheckoutKitTests.swift +++ b/platforms/swift/Tests/ShopifyCheckoutKitTests/ShopifyCheckoutKitTests.swift @@ -63,6 +63,26 @@ class ShopifyCheckoutKitTests: XCTestCase { ) } + func test_present_propagatesDelegateAndClientToWebViewController() throws { + let delegate = MockCheckoutDelegate() + let client = MockBridgeClient() + let presenter = UIViewController() + let checkoutURL = try XCTUnwrap(URL(string: "https://shop.example/checkouts/cn/123")) + + let viewController = ShopifyCheckoutKit.present( + checkout: checkoutURL, + from: presenter, + delegate: delegate, + client: client + ) + + let webViewController = try XCTUnwrap( + viewController.viewControllers.compactMap { $0 as? CheckoutWebViewController }.first + ) + XCTAssertTrue(webViewController.delegate === delegate) + XCTAssertNotNil(webViewController.client) + } + func test_logger_withDifferentLogLevels_shouldHaveCorrectLogLevel() { ShopifyCheckoutKit.configuration.logLevel = .all XCTAssertEqual(