diff --git a/platforms/android/README.md b/platforms/android/README.md index 8fb983e3..20f5991c 100644 --- a/platforms/android/README.md +++ b/platforms/android/README.md @@ -113,7 +113,7 @@ function provided by the SDK: ```kotlin fun presentCheckout() { val checkoutUrl = cart.checkoutUrl - ShopifyCheckoutKit.present(checkoutUrl, context, checkoutEventProcessor) + ShopifyCheckoutKit.present(checkoutUrl, context, checkoutListener) } ``` @@ -332,15 +332,10 @@ A preloaded checkout _is not_ automatically invalidated when checkout is closed. ## Monitoring the lifecycle of a checkout session -Extend the `DefaultCheckoutEventProcessor` abstract class to register callbacks for key lifecycle events during the checkout session: +Extend the `DefaultCheckoutListener` abstract class to register callbacks for key lifecycle events during the checkout session: ```kotlin -val processor = object : DefaultCheckoutEventProcessor(activity) { - override fun onCheckoutCompleted(checkoutCompletedEvent: CheckoutCompletedEvent) { - // Called when the checkout was completed successfully by the buyer. - // Use this to update UI, reset cart state, etc. - } - +val listener = object : DefaultCheckoutListener(activity) { override fun onCheckoutCanceled() { // Called when the checkout was canceled by the buyer. // Note: This will also be received after closing a completed checkout @@ -350,23 +345,6 @@ val processor = object : DefaultCheckoutEventProcessor(activity) { // Called when the checkout encountered an error and has been aborted. } - override fun onCheckoutLinkClicked(uri: Uri) { - // Called when the buyer clicks a link within the checkout experience: - // - email address (`mailto:`) - // - telephone number (`tel:`) - // - web (http:) - // - deep link (e.g. myapp://checkout) - // and is being directed outside the application. - - // Note: to support deep links on Android 11+ using the `DefaultCheckoutEventProcessor`, - // the client app should add a queries element in its manifest declaring which apps it should interact with. - // See the MobileBuyIntegration sample's manifest for an example. - // Queries reference - https://developer.android.com/guide/topics/manifest/queries-element - - // If no app can be queried to deal with the link, the processor will log a warning: - // `Unrecognized scheme for link clicked in checkout` along with the uri. - } - override fun onShowFileChooser( webView: WebView, filePathCallback: ValueCallback>, @@ -395,7 +373,7 @@ val processor = object : DefaultCheckoutEventProcessor(activity) { ``` > [!Note] -> The `DefaultCheckoutEventProcessor` provides default implementations for current and future callback functions (such as `onLinkClicked()`), which can be overridden by clients wanting to change default behavior. +> The `DefaultCheckoutListener` provides default implementations for the OS-level prompt callbacks, which can be overridden by clients wanting to change default behavior. Checkout completion and external link clicks are delivered through the Embedded Checkout Protocol client instead — register a handler on `CheckoutProtocol.complete` and implement `CheckoutCommunicationClient.openExternalUrl(...)`, then pass the client as the 4th argument of `ShopifyCheckoutKit.present(...)`. ### Error handling @@ -407,7 +385,7 @@ In the event of a checkout error occurring, the Checkout Kit _may_ attempt to re There are some caveats to note when this scenario occurs: 1. The checkout experience may look different to buyers. Though the sheet kit will attempt to load any checkoput customizations for the storefront, there is no guarantee they will show in recovery mode. -2. The `onCheckoutCompleted(checkoutCompletedEvent: CheckoutCompletedEvent)` will be emitted with partial data. Invocations will only received the order ID via `checkoutCompletedEvent.orderDetails.id`. +2. The `CheckoutProtocol.complete` handler will be invoked with partial data — only the order ID is guaranteed. Should you wish to opt-out of this fallback experience entirely, you can do so by overriding `shouldRecoverFromError`. Errors given to the `onCheckoutFailed(error: CheckoutException)` lifecycle method will contain an `isRecoverable` property by default indicating whether the request should be retried or not. diff --git a/platforms/android/lib/api/lib.api b/platforms/android/lib/api/lib.api index 39c63409..1ed55352 100644 --- a/platforms/android/lib/api/lib.api +++ b/platforms/android/lib/api/lib.api @@ -665,17 +665,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 onCheckoutLinkClicked (Landroid/net/Uri;)V - public abstract fun onGeolocationPermissionsHidePrompt ()V - public abstract fun onGeolocationPermissionsShowPrompt (Ljava/lang/String;Landroid/webkit/GeolocationPermissions$Callback;)V - public abstract fun onPermissionRequest (Landroid/webkit/PermissionRequest;)V - 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 @@ -758,6 +747,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/CheckoutProtocol { public static final field INSTANCE Lcom/shopify/checkoutkit/CheckoutProtocol; public static final field specVersion Ljava/lang/String; @@ -1366,11 +1364,10 @@ 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 (Landroid/content/Context;)V public fun (Landroid/content/Context;Lcom/shopify/checkoutkit/LogWrapper;)V public synthetic fun (Landroid/content/Context;Lcom/shopify/checkoutkit/LogWrapper;ILkotlin/jvm/internal/DefaultConstructorMarker;)V - public fun onCheckoutLinkClicked (Landroid/net/Uri;)V public fun onGeolocationPermissionsHidePrompt ()V public fun onGeolocationPermissionsShowPrompt (Ljava/lang/String;Landroid/webkit/GeolocationPermissions$Callback;)V public fun onPermissionRequest (Landroid/webkit/PermissionRequest;)V @@ -3931,9 +3928,9 @@ public final class com/shopify/checkoutkit/ShopifyCheckoutKit { public static final fun getConfiguration ()Lcom/shopify/checkoutkit/Configuration; public static final fun invalidate ()V public static final fun preload (Ljava/lang/String;Landroidx/activity/ComponentActivity;)V - public static final fun present (Ljava/lang/String;Landroidx/activity/ComponentActivity;Lcom/shopify/checkoutkit/DefaultCheckoutEventProcessor;)Lcom/shopify/checkoutkit/CheckoutKitDialog; - public static final fun present (Ljava/lang/String;Landroidx/activity/ComponentActivity;Lcom/shopify/checkoutkit/DefaultCheckoutEventProcessor;Lcom/shopify/checkoutkit/CheckoutCommunicationClient;)Lcom/shopify/checkoutkit/CheckoutKitDialog; - public static 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 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 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..5329ce49 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,17 @@ internal class CheckoutDialog( addWebViewToContainer( ShopifyCheckoutKit.configuration.colorScheme, FallbackWebView(context).apply { - setEventProcessor(eventProcessor()) + setListener(webViewListener()) + setClient(communicationClient) 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 57% 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 f3761978..866ec7ec 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 @@ -22,27 +22,23 @@ */ package com.shopify.checkoutkit -import android.annotation.SuppressLint import android.content.Context -import android.content.Intent import android.net.Uri import android.webkit.GeolocationPermissions import android.webkit.PermissionRequest 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. + * + * In-checkout events such as completion, errors, totals/line items/messages changes, + * and external link requests flow through [CheckoutCommunicationClient] / the + * Embedded Checkout Protocol — not through this interface. */ -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. @@ -53,18 +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() /** - * Event indicating that a link has been clicked within checkout that should be opened outside - * of the WebView, e.g. in a system browser or email client. Protocols can be http/https/mailto/tel - */ - public fun onCheckoutLinkClicked(uri: Uri) - - /** - * A permission has been requested by the web chrome client, e.g. to access the camera + * A permission has been requested by the web chrome client, e.g. to access the camera. */ public fun onPermissionRequest(permissionRequest: PermissionRequest) @@ -80,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 */ } @@ -103,10 +89,6 @@ internal class NoopEventProcessor : CheckoutEventProcessor { /* noop */ } - override fun onCheckoutLinkClicked(uri: Uri) { - /* noop */ - } - override fun onShowFileChooser( webView: WebView, filePathCallback: ValueCallback>, @@ -129,23 +111,16 @@ internal class NoopEventProcessor : CheckoutEventProcessor { } /** - * An abstract class that provides a default implementation of the CheckoutEventProcessor interface - * for handling checkout events and interacting with the Android operating system. - * @param context from which we will launch intents. + * An abstract class that provides a default implementation of the [CheckoutListener] interface + * for handling checkout events. + * + * @param context retained for subclass convenience (e.g. launching intents from + * permission / file-chooser callbacks). */ -public abstract class DefaultCheckoutEventProcessor @JvmOverloads constructor( - private val context: Context, - private val log: LogWrapper = LogWrapper(), -) : CheckoutEventProcessor { - - override fun onCheckoutLinkClicked(uri: Uri) { - when (uri.scheme) { - "tel" -> context.launchPhoneApp(uri.schemeSpecificPart) - "mailto" -> context.launchEmailApp(uri.schemeSpecificPart) - "https", "http" -> context.launchBrowser(uri) - else -> context.tryLaunchDeepLink(uri) - } - } +public abstract class DefaultCheckoutListener @JvmOverloads constructor( + @Suppress("UnusedPrivateProperty") private val context: Context, + @Suppress("UnusedPrivateProperty") private val log: LogWrapper = LogWrapper(), +) : CheckoutListener { override fun onPermissionRequest(permissionRequest: PermissionRequest) { // no-op override to implement @@ -166,42 +141,4 @@ public abstract class DefaultCheckoutEventProcessor @JvmOverloads constructor( override fun onGeolocationPermissionsHidePrompt() { // no-op override to implement } - - private fun Context.launchEmailApp(to: String) { - log.d(LOG_TAG, "Attempting to launch email app.") - val intent = Intent(Intent.ACTION_SEND) - intent.type = "vnd.android.cursor.item/email" - intent.putExtra(Intent.EXTRA_EMAIL, arrayOf(to)) - startActivity(intent) - } - - private fun Context.launchBrowser(uri: Uri) { - log.d(LOG_TAG, "Attempting to launch browser for $uri.") - val intent = Intent(Intent.ACTION_VIEW) - intent.data = uri - startActivity(intent) - } - - private fun Context.launchPhoneApp(phone: String) { - log.d(LOG_TAG, "Attempting to launch phone app.") - val intent = Intent(Intent.ACTION_DIAL, Uri.fromParts("tel", phone, null)) - startActivity(intent) - } - - @SuppressLint("QueryPermissionsNeeded") - private fun Context.tryLaunchDeepLink(uri: Uri) { - log.d(LOG_TAG, "Attempting to launch deep link for uri $uri.") - val intent = Intent(Intent.ACTION_VIEW) - intent.data = uri - if (context.packageManager.queryIntentActivities(intent, 0).isNotEmpty()) { - startActivity(intent) - } else { - log.w(TAG, "Unrecognized scheme for link clicked in checkout '$uri'") - } - } - - private companion object { - private const val LOG_TAG = "DefaultCheckoutEventProcessor" - private const val TAG = "DefaultCheckoutEventProcessor" - } } diff --git a/platforms/android/lib/src/main/java/com/shopify/checkoutkit/CheckoutProtocol.kt b/platforms/android/lib/src/main/java/com/shopify/checkoutkit/CheckoutProtocol.kt index d95dcda9..385b975d 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 @@ -43,7 +43,7 @@ import kotlinx.serialization.json.jsonObject * .on(CheckoutProtocol.complete) { checkout -> navigateToConfirmation(checkout) } * .onOpenExternalUrl { uri -> startActivity(Intent(Intent.ACTION_VIEW, uri)); true } * - * 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 73e8caf9..a86dc8d2 100644 --- a/platforms/android/lib/src/main/java/com/shopify/checkoutkit/CheckoutWebView.kt +++ b/platforms/android/lib/src/main/java/com/shopify/checkoutkit/CheckoutWebView.kt @@ -24,11 +24,9 @@ package com.shopify.checkoutkit import android.content.Context import android.graphics.Bitmap -import android.net.Uri import android.os.Handler import android.os.Looper import android.util.AttributeSet -import android.webkit.WebResourceRequest import android.webkit.WebView import androidx.activity.ComponentActivity import com.shopify.checkoutkit.ShopifyCheckoutKit.log @@ -42,7 +40,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 @@ -55,9 +53,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?) { @@ -65,8 +63,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() { @@ -98,65 +96,19 @@ 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() - } - - override fun shouldOverrideUrlLoading( - view: WebView?, - request: WebResourceRequest? - ): Boolean { - if ( - request?.hasExternalAnnotation() == true || - request?.url?.isContactLink() == true || - request?.url?.isDeepLink() == true - ) { - log.d(LOG_TAG, "Overriding URL loading to invoke onCheckoutLinkClicked for request: $request.") - checkoutBridge.getEventProcessor().onCheckoutViewLinkClicked(request.trimmedUri()) - return true - } - return false - } - - private fun WebResourceRequest.hasExternalAnnotation(): Boolean { - if (!this.url.isWebLink()) { - return false - } - val openExternallyParam = this.url.getQueryParameter(OPEN_EXTERNALLY_PARAM) - val hasOpenExternallyParam = setOf("true", "1").contains(openExternallyParam?.lowercase()?.trim()) - log.d(LOG_TAG, "open_externally param found on request URL.") - return hasOpenExternallyParam - } - - private fun WebResourceRequest.trimmedUri(): Uri { - if (!setOf(Scheme.HTTP, Scheme.HTTPS).contains(this.url.scheme)) { - return this.url - } - - val trimmedUri = Uri.Builder() - .scheme(this.url.scheme) - .authority(this.url.authority) - .path(this.url.path) - - this.url.queryParameterNames.forEach { - if (it != OPEN_EXTERNALLY_PARAM) { - trimmedUri.appendQueryParameter(it, this.url.getQueryParameter(it)) - } - } - - return trimmedUri.build() + getListener().onCheckoutViewLoadComplete() } } companion object { private const val LOG_TAG = "CheckoutWebView" - private const val OPEN_EXTERNALLY_PARAM = "open_externally" private const val JAVASCRIPT_INTERFACE_NAME = "android" internal var cacheEntry: CheckoutWebViewCacheEntry? = null diff --git a/platforms/android/lib/src/main/java/com/shopify/checkoutkit/CheckoutWebViewEventProcessor.kt b/platforms/android/lib/src/main/java/com/shopify/checkoutkit/CheckoutWebViewListener.kt similarity index 68% 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 732f7203..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,39 +30,25 @@ 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) } } - fun onCheckoutViewLinkClicked(uri: Uri) { - log.d(LOG_TAG, "Calling onCheckoutLinkClicked.") - eventProcessor.onCheckoutLinkClicked(uri) - } - fun onCheckoutViewFailedWithError(error: CheckoutException) { onMainThread { closeCheckoutDialogWithError(error) @@ -70,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( @@ -82,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) } } @@ -108,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 58fdbfe9..e3c273cc 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 @@ -82,7 +82,7 @@ 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) } } @@ -95,16 +95,12 @@ internal class EmbeddedCheckoutProtocol( return } val uri = urlString.toUri() - if (uri.scheme != "https" && uri.scheme != "http") { - log.d(LOG_TAG, "Rejecting $METHOD_WINDOW_OPEN_REQUEST with non-web scheme: ${uri.scheme}") - sendError(request.id, CODE_INVALID_PARAMS, "Only http and https URIs are supported") - return - } - if (!openExternalUrlOnMainThread(uri)) { - log.d(LOG_TAG, "No external URL handler; falling back to default link click behavior.") - onMainThread { view.getEventProcessor().onCheckoutViewLinkClicked(uri) } + if (openExternalUrlOnMainThread(uri)) { + sendResult(request.id, UCP_SUCCESS) + } else { + log.d(LOG_TAG, "External URL $uri was not handled; returning protocol error to checkout.") + sendError(request.id, CODE_INTERNAL_ERROR, "External URL was not handled by the host app") } - sendResult(request.id, UCP_SUCCESS) } private fun openExternalUrlOnMainThread(uri: Uri): Boolean { @@ -124,6 +120,10 @@ internal class EmbeddedCheckoutProtocol( private fun handleClientMessage(method: String, message: String) { log.d(LOG_TAG, "Delegating $method to client.") + if (method == METHOD_COMPLETE) { + log.d(LOG_TAG, "Checkout completed; invalidating preload cache.") + CheckoutWebView.markCacheEntryStale() + } onMainThread { val response = client?.process(message) log.d(LOG_TAG, " client response: $response") @@ -167,6 +167,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" @@ -186,6 +187,7 @@ internal class EmbeddedCheckoutProtocol( private const val CODE_PARSE_ERROR = -32700 private const val CODE_METHOD_NOT_SUPPORTED = -32601 private const val CODE_INVALID_PARAMS = -32602 + private const val CODE_INTERNAL_ERROR = -32603 } } 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..ed8e1a73 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,10 @@ 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 +39,23 @@ 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 + @Volatile + private var client: CheckoutCommunicationClient? = null + + fun setListener(listener: CheckoutWebViewListener) { + log.d(LOG_TAG, "Setting listener $listener.") + this.listener = listener + } + + fun setClient(client: CheckoutCommunicationClient?) { + log.d(LOG_TAG, "Setting communication client $client.") + this.client = client } - override fun getEventProcessor(): CheckoutWebViewEventProcessor { - return checkoutEventProcessor + override fun getListener(): CheckoutWebViewListener { + return listener } inner class FallbackWebViewClient : BaseWebView.BaseWebViewClient() { @@ -64,18 +70,25 @@ 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() + getListener().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)) - ) + log.d(LOG_TAG, "Confirmation page reached in fallback; synthesizing ec.complete with partial data.") + CheckoutWebView.markCacheEntryStale() + client?.process(syntheticCompleteMessage(orderId = uri.getQueryParameter("order_id"))) } } + } - private fun getOrderIdFromQueryString(uri: Uri): String? = uri.getQueryParameter("order_id") + private fun syntheticCompleteMessage(orderId: String?): String { + val orderJson = orderId?.let { ""","order":{"id":"$it"}""" } ?: "" + return """ + |{"jsonrpc":"2.0","method":"${EmbeddedCheckoutProtocol.METHOD_COMPLETE}", + |"params":{"id":"${orderId ?: ""}","currency":"","line_items":[],"links":[], + |"status":"completed","totals":[], + |"ucp":{"version":"${CheckoutProtocol.specVersion}","status":"success"}$orderJson}} + """.trimMargin().replace("\n", "") } 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 f01dffb1..1bf0d3da 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. * @@ -123,8 +123,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, external link clicks). + * @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 @@ -133,10 +133,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.") @@ -145,7 +145,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/main/java/com/shopify/checkoutkit/UriExtensions.kt b/platforms/android/lib/src/main/java/com/shopify/checkoutkit/UriExtensions.kt index 928487af..04e0244d 100644 --- a/platforms/android/lib/src/main/java/com/shopify/checkoutkit/UriExtensions.kt +++ b/platforms/android/lib/src/main/java/com/shopify/checkoutkit/UriExtensions.kt @@ -25,12 +25,6 @@ package com.shopify.checkoutkit import android.net.Uri import androidx.core.net.toUri -internal fun Uri?.isWebLink(): Boolean = setOf(Scheme.HTTP, Scheme.HTTPS).contains(this?.scheme) -internal fun Uri?.isMailtoLink(): Boolean = this?.scheme == Scheme.MAILTO -internal fun Uri?.isTelLink(): Boolean = this?.scheme == Scheme.TEL -internal fun Uri?.isAboutScheme(): Boolean = this?.scheme == Scheme.ABOUT -internal fun Uri?.isContactLink(): Boolean = this.isMailtoLink() || this.isTelLink() -internal fun Uri?.isDeepLink(): Boolean = this != null && !this.isWebLink() && !this.isContactLink() && !this.isAboutScheme() internal fun Uri?.isConfirmationPage(): Boolean = this?.pathSegments?.any { CONFIRMATION_PATH_REGEX.matches(it) } == true internal fun String.isOneTimeUse(): Boolean = this.contains("multipass") @@ -59,11 +53,3 @@ private val CONFIRMATION_PATH_REGEX = Regex(pattern = "^(thank[-_]+you)$", optio private const val EC_VERSION_PARAM = "ec_version" private const val EC_DELEGATE_PARAM = "ec_delegate" private const val EC_DELEGATE_VALUE = "window.open" - -internal object Scheme { - const val HTTP = "http" - const val HTTPS = "https" - const val TEL = "tel" - const val MAILTO = "mailto" - const val ABOUT = "about" -} 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 8770a3de..d0cabf0e 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(activity) + processor = noopDefaultCheckoutListener(activity) } @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/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 6326b632..3a04aacf 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 @@ -52,74 +52,14 @@ 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() { activity = Robolectric.buildActivity(ComponentActivity::class.java).get() } - @Test - fun `overrides url loading to call event processor for mailto links`() { - val mockRequest = mockWebRequest(Uri.parse("mailto:daniel.kift@shopify.com")) - - val view = viewWithProcessor(activity) - val webViewClient = view.CheckoutWebViewClient() - val overridden = webViewClient.shouldOverrideUrlLoading(view, mockRequest) - - assertThat(overridden).isTrue - verify(mockEventProcessor).onCheckoutLinkClicked(mockRequest.url) - } - - @Test - fun `overrides url loading to call event processor for tel links`() { - val mockRequest = mockWebRequest(Uri.parse("tel:0123456789")) - - val view = viewWithProcessor(activity) - val webViewClient = view.CheckoutWebViewClient() - val overridden = webViewClient.shouldOverrideUrlLoading(view, mockRequest) - - assertThat(overridden).isTrue - verify(mockEventProcessor).onCheckoutLinkClicked(mockRequest.url) - } - - @Test - fun `overrides url loading to call event processor for deep links`() { - val mockRequest = mockWebRequest(Uri.parse("geo:40.712776,-74.005974?q=Statue+of+Liberty")) - - val view = viewWithProcessor(activity) - val webViewClient = view.CheckoutWebViewClient() - val overridden = webViewClient.shouldOverrideUrlLoading(view, mockRequest) - - assertThat(overridden).isTrue - verify(mockEventProcessor).onCheckoutLinkClicked(mockRequest.url) - } - - @Test - fun `does not override url loading to call event processor for about blank`() { - val mockRequest = mockWebRequest(Uri.parse("about:blank")) - - val view = viewWithProcessor(activity) - val webViewClient = view.CheckoutWebViewClient() - val overridden = webViewClient.shouldOverrideUrlLoading(view, mockRequest) - - assertThat(overridden).isFalse() - verify(mockEventProcessor, never()).onCheckoutLinkClicked(any()) - } - - @Test - fun `does not override url loading to call event processor for web links`() { - val mockRequest = mockWebRequest(Uri.parse("https://checkout-sdk.myshopify.com")) - - val view = viewWithProcessor(activity) - val webViewClient = view.CheckoutWebViewClient() - val overridden = webViewClient.shouldOverrideUrlLoading(view, mockRequest) - - assertThat(overridden).isFalse - verify(mockEventProcessor, never()).onCheckoutLinkClicked(mockRequest.url) - } - @Test fun `should call event processor and clear cache on web resource load error for main frame`() { val mockRequest = mockWebRequest(Uri.parse("https://checkout-sdk.myshopify.com"), true) @@ -133,7 +73,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() @@ -150,7 +90,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() @@ -168,7 +108,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) @@ -187,7 +127,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() @@ -204,7 +144,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() @@ -221,7 +161,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() @@ -240,7 +180,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() @@ -259,7 +199,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) @@ -278,7 +218,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) @@ -286,20 +226,6 @@ class CheckoutWebViewClientTest { .hasDescription("HTTP 502 Error") } - @Test - fun `links with open_externally are delegated to the contact link function`() { - val host = "https://go.shop.com" - val loadedUri = Uri.parse("$host?open_externally=true&random_param=1") - val mockRequest = mockWebRequest(loadedUri) - - val view = viewWithProcessor(activity) - val webViewClient = view.CheckoutWebViewClient() - val overridden = webViewClient.shouldOverrideUrlLoading(view, mockRequest) - - assertThat(overridden).isTrue - verify(mockEventProcessor).onCheckoutLinkClicked(Uri.parse("$host?random_param=1")) - } - @Test fun `onPageFinished calls delegate to remove progress indicator`() { val view = viewWithProcessor(activity) @@ -307,7 +233,7 @@ class CheckoutWebViewClientTest { webViewClient.onPageFinished(view, "https://anything") - verify(checkoutWebViewEventProcessor).onCheckoutViewLoadComplete() + verify(checkoutWebViewListener).onCheckoutViewLoadComplete() } @Test @@ -320,7 +246,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]) @@ -334,7 +260,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]) @@ -349,7 +275,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.") @@ -376,7 +302,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 a993b809..00000000 --- a/platforms/android/lib/src/test/java/com/shopify/checkoutkit/DefaultCheckoutEventProcessorTest.kt +++ /dev/null @@ -1,152 +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 android.content.Intent -import android.content.IntentFilter -import android.content.pm.PackageManager -import android.net.Uri -import androidx.activity.ComponentActivity -import com.shopify.checkoutkit.lifecycleevents.CheckoutCompletedEvent -import org.assertj.core.api.Assertions.assertThat -import org.junit.Before -import org.junit.Test -import org.junit.runner.RunWith -import org.mockito.kotlin.mock -import org.mockito.kotlin.verify -import org.robolectric.Robolectric -import org.robolectric.RobolectricTestRunner -import org.robolectric.RuntimeEnvironment -import org.robolectric.Shadows.shadowOf -import org.robolectric.shadows.ShadowActivity - -@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 `onCheckoutLinkClicked with http scheme launches action view intent with uri as data`() { - val processor = noopDefaultCheckoutEventProcessor(activity) - val uri = Uri.parse("https://shopify.com") - - processor.onCheckoutLinkClicked(uri) - - val intent = shadowActivity.peekNextStartedActivityForResult().intent - assertThat(intent.data).isEqualTo(uri) - assertThat(intent.action).isEqualTo(Intent.ACTION_VIEW) - } - - @Test - fun `onCheckoutLinkClicked with mailto scheme launches email intent with to address`() { - val processor = noopDefaultCheckoutEventProcessor(activity) - val uri = Uri.parse("mailto:test.user@shopify.com") - - processor.onCheckoutLinkClicked(uri) - - val intent = shadowActivity.peekNextStartedActivityForResult().intent - assertThat(intent.getStringArrayExtra(Intent.EXTRA_EMAIL)).isEqualTo(arrayOf("test.user@shopify.com")) - assertThat(intent.action).isEqualTo("android.intent.action.SEND") - } - - @Test - fun `onCheckoutLinkClicked with tel scheme launches action dial intent with phone number`() { - val processor = noopDefaultCheckoutEventProcessor(activity) - val uri = Uri.parse("tel:0123456789") - - processor.onCheckoutLinkClicked(uri) - - val intent = shadowActivity.peekNextStartedActivityForResult().intent - assertThat(intent.data).isEqualTo(uri) - assertThat(intent.action).isEqualTo(Intent.ACTION_DIAL) - } - - @Test - fun `onCheckoutLinkedClick with known deep link scheme`() { - val uri = Uri.parse("geo:40.712776,-74.005974?q=Statue+of+Liberty") - - val pm: PackageManager = RuntimeEnvironment.getApplication().packageManager - val shadowPackageManager = shadowOf(pm) - - val intentFilter = IntentFilter(Intent.ACTION_VIEW).apply { - addDataScheme("geo") - } - shadowPackageManager.addIntentFilterForActivity(activity.componentName, intentFilter) - - val processor = noopDefaultCheckoutEventProcessor(activity) - processor.onCheckoutLinkClicked(uri) - - val intent = shadowActivity.nextStartedActivity - assertThat(intent.data).isEqualTo(uri) - assertThat(intent.action).isEqualTo(Intent.ACTION_VIEW) - } - - @Test - fun `onCheckoutLinkedClick with unhandled scheme logs warning`() { - val log = mock() - val processor = noopDefaultCheckoutEventProcessor(activity, log) - - val uri = Uri.parse("ftp:random") - - processor.onCheckoutLinkClicked(uri) - - assertThat(shadowActivity.peekNextStartedActivityForResult()).isNull() - verify(log).w("DefaultCheckoutEventProcessor", "Unrecognized scheme for link clicked in checkout 'ftp:random'") - } - - @Test - fun `onCheckoutFailed returns an error description`() { - val log = mock() - var description = "" - var recoverable: Boolean? = null - val processor = - object : DefaultCheckoutEventProcessor(activity, log) { - 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 357ae19c..1297cd1f 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 @@ -46,7 +46,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 @@ -56,8 +56,8 @@ class EmbeddedCheckoutProtocolTest { activity = Robolectric.buildActivity(ComponentActivity::class.java).setup().get() viewSpy = Mockito.spy(CheckoutWebView(activity)) - mockEventProcessor = mock() - whenever(viewSpy.getEventProcessor()).thenReturn(mockEventProcessor) + mockListener = mock() + whenever(viewSpy.getListener()).thenReturn(mockListener) ecp = EmbeddedCheckoutProtocol(viewSpy) } @@ -203,7 +203,7 @@ class EmbeddedCheckoutProtocolTest { } @Test - fun `ec window open request falls back to event processor when client returns false`() { + fun `ec window open request returns protocol error when client returns false`() { val client = mock() whenever(client.openExternalUrl(any())).thenReturn(false) ecp.setClient(client) @@ -215,15 +215,13 @@ class EmbeddedCheckoutProtocolTest { } shadowOf(Looper.getMainLooper()).runToEndOfTasks() - assertThat(js).contains("\"result\"") - assertThat(js).contains("\"ucp\"") - assertThat(js).contains("\"status\":\"success\"") - assertThat(js).doesNotContain("\"error\"") - verify(mockEventProcessor).onCheckoutViewLinkClicked(Uri.parse("https://example.com")) + assertThat(js).contains("\"error\"") + assertThat(js).contains("-32603") + assertThat(js).doesNotContain("\"result\"") } @Test - fun `ec window open request falls back to event processor when no client is set`() { + fun `ec window open request returns protocol error when no client is set`() { val js = captureEvaluatedJs { ecp.postMessage( """{"jsonrpc":"2.0","method":"ec.window.open_request","id":"7","params":{"url":"https://example.com"}}""" @@ -231,15 +229,13 @@ class EmbeddedCheckoutProtocolTest { } shadowOf(Looper.getMainLooper()).runToEndOfTasks() - assertThat(js).contains("\"result\"") - assertThat(js).contains("\"ucp\"") - assertThat(js).contains("\"status\":\"success\"") - assertThat(js).doesNotContain("\"error\"") - verify(mockEventProcessor).onCheckoutViewLinkClicked(Uri.parse("https://example.com")) + assertThat(js).contains("\"error\"") + assertThat(js).contains("-32603") + assertThat(js).doesNotContain("\"result\"") } @Test - fun `ec window open request does not call event processor when client handles it`() { + fun `ec window open request invokes client openExternalUrl with the requested uri`() { val client = mock() whenever(client.openExternalUrl(any())).thenReturn(true) ecp.setClient(client) @@ -249,22 +245,21 @@ class EmbeddedCheckoutProtocolTest { ) shadowOf(Looper.getMainLooper()).runToEndOfTasks() - verify(mockEventProcessor, never()).onCheckoutViewLinkClicked(any()) + verify(client).openExternalUrl(Uri.parse("https://example.com")) } @Test - fun `ec window open request rejects non-http schemes`() { + fun `ec window open request passes non-http schemes through to client`() { val client = mock() + whenever(client.openExternalUrl(any())).thenReturn(true) ecp.setClient(client) - val js = captureEvaluatedJs { - ecp.postMessage( - """{"jsonrpc":"2.0","method":"ec.window.open_request","id":"8","params":{"url":"intent://evil"}}""" - ) - } - assertThat(js).contains("\"error\"") - assertThat(js).contains("-32602") - verify(client, never()).openExternalUrl(any()) + ecp.postMessage( + """{"jsonrpc":"2.0","method":"ec.window.open_request","id":"8","params":{"url":"mailto:help@example.com"}}""" + ) + shadowOf(Looper.getMainLooper()).runToEndOfTasks() + + verify(client).openExternalUrl(Uri.parse("mailto:help@example.com")) } // endregion @@ -275,7 +270,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 @@ -330,6 +325,20 @@ class EmbeddedCheckoutProtocolTest { verify(client).process(rawMessage) } + @Test + fun `ec complete invalidates the preload cache`() { + ShopifyCheckoutKit.configuration.preloading = Preloading(enabled = true) + val cached = CheckoutWebView.cacheableCheckoutView(CHECKOUT_URL, activity, isPreload = true) + shadowOf(Looper.getMainLooper()).runToEndOfTasks() + assertThat(CheckoutWebView.cacheEntry!!.isValid(CHECKOUT_URL)).isTrue() + + ecp.postMessage("""{"jsonrpc":"2.0","method":"ec.complete","params":{"checkout":{}}}""") + shadowOf(Looper.getMainLooper()).runToEndOfTasks() + + assertThat(CheckoutWebView.cacheEntry!!.isValid(CHECKOUT_URL)).isFalse() + assertThat(cached).isNotNull() + } + // endregion // region client delegation — requests @@ -427,4 +436,8 @@ class EmbeddedCheckoutProtocolTest { } // endregion + + private companion object { + const val CHECKOUT_URL = "https://shop.example/checkout" + } } 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..43545168 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 @@ -32,9 +32,13 @@ import org.junit.Test import org.junit.runner.RunWith import org.mockito.Mockito.mock import org.mockito.Mockito.verify +import org.mockito.kotlin.any +import org.mockito.kotlin.argumentCaptor +import org.mockito.kotlin.never import org.robolectric.Robolectric import org.robolectric.RobolectricTestRunner import org.robolectric.Shadows.shadowOf +import org.mockito.kotlin.verify as verifyKt @RunWith(RobolectricTestRunner::class) class FallbackWebViewTest { @@ -76,16 +80,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) } } @@ -96,4 +100,34 @@ class FallbackWebViewTest { assertThat(view.recoverErrors).isFalse() } } + + @Test + fun `onPageFinished on confirmation page synthesizes ec complete to client`() { + Robolectric.buildActivity(ComponentActivity::class.java).use { activityController -> + val view = FallbackWebView(activityController.get()) + val client = mock(CheckoutCommunicationClient::class.java) + view.setClient(client) + + shadowOf(view).webViewClient?.onPageFinished(view, "https://shop.example/thank-you?order_id=12345") + + val captor = argumentCaptor() + verifyKt(client).process(captor.capture()) + assertThat(captor.firstValue).contains("\"method\":\"ec.complete\"") + assertThat(captor.firstValue).contains("\"id\":\"12345\"") + assertThat(captor.firstValue).contains("\"order\":{\"id\":\"12345\"}") + } + } + + @Test + fun `onPageFinished on non-confirmation page does not synthesize ec complete`() { + Robolectric.buildActivity(ComponentActivity::class.java).use { activityController -> + val view = FallbackWebView(activityController.get()) + val client = mock(CheckoutCommunicationClient::class.java) + view.setClient(client) + + shadowOf(view).webViewClient?.onPageFinished(view, "https://shop.example/checkout") + + verifyKt(client, never()).process(any()) + } + } } diff --git a/platforms/android/lib/src/test/java/com/shopify/checkoutkit/Helpers.kt b/platforms/android/lib/src/test/java/com/shopify/checkoutkit/Helpers.kt index 9bf4c16c..c349a5f0 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 @@ -23,7 +23,6 @@ package com.shopify.checkoutkit import android.app.Activity -import com.shopify.checkoutkit.lifecycleevents.CheckoutCompletedEvent import org.assertj.core.api.AbstractAssert fun withPreloadingEnabled(block: () -> Unit) { @@ -103,12 +102,8 @@ class CheckoutExceptionAssert(actual: CheckoutException) : } } -fun noopDefaultCheckoutEventProcessor(activity: Activity, log: LogWrapper = LogWrapper()): DefaultCheckoutEventProcessor { - return object : DefaultCheckoutEventProcessor(activity, log) { - override fun onCheckoutCompleted(checkoutCompletedEvent: CheckoutCompletedEvent) { - // no-op - } - +fun noopDefaultCheckoutListener(activity: Activity, log: LogWrapper = LogWrapper()): DefaultCheckoutListener { + return object : DefaultCheckoutListener(activity, log) { override fun onCheckoutFailed(error: CheckoutException) { // 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 b4c847a0..5364b812 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,14 +144,9 @@ public void tearDown() { } @Test - public void canInstantiateCustomEventProcessorWithDefaultArg() { + public void canInstantiateCustomListenerWithDefaultArg() { try (ActivityController controller = Robolectric.buildActivity(ComponentActivity.class)) { - DefaultCheckoutEventProcessor processor = new DefaultCheckoutEventProcessor(controller.get()) { - @Override - public void onCheckoutCompleted(@NonNull CheckoutCompletedEvent checkoutCompletedEvent) { - - } - + DefaultCheckoutListener listener = new DefaultCheckoutListener(controller.get()) { @Override public void onCheckoutFailed(@NonNull CheckoutException error) { @@ -163,7 +158,7 @@ public void onCheckoutCanceled() { } }; - assertThat(processor).isNotNull(); + assertThat(listener).isNotNull(); } } @@ -248,12 +243,7 @@ public void presentReturnsAHandleToAllowDismissingDialog() { CheckoutKitDialog dialog = ShopifyCheckoutKit.present( "https://shopify.dev", activity, - new DefaultCheckoutEventProcessor(activity) { - @Override - public void onCheckoutCompleted(@NonNull CheckoutCompletedEvent checkoutCompletedEvent) { - // do nothing - } - + new DefaultCheckoutListener(activity) { @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 35bf1dea..18e72019 100644 --- a/platforms/android/lib/src/test/java/com/shopify/checkoutkit/ShopifyCheckoutKitTest.kt +++ b/platforms/android/lib/src/test/java/com/shopify/checkoutkit/ShopifyCheckoutKitTest.kt @@ -136,7 +136,7 @@ class ShopifyCheckoutKitTest { ShopifyCheckoutKit.present( "https://one.com", activity, - noopDefaultCheckoutEventProcessor(activity) + noopDefaultCheckoutListener(activity) ) ShadowLooper.shadowMainLooper().runToEndOfTasks() @@ -175,7 +175,7 @@ class ShopifyCheckoutKitTest { ShopifyCheckoutKit.present( "https://one.com", activity, - noopDefaultCheckoutEventProcessor(activity) + noopDefaultCheckoutListener(activity) ) ShadowLooper.shadowMainLooper().runToEndOfTasks() diff --git a/platforms/android/samples/MobileBuyIntegration/app/src/main/java/com/shopify/checkout_kit_mobile_buy_integration_sample/cart/CartView.kt b/platforms/android/samples/MobileBuyIntegration/app/src/main/java/com/shopify/checkout_kit_mobile_buy_integration_sample/cart/CartView.kt index c3cb22bb..974f6d42 100644 --- a/platforms/android/samples/MobileBuyIntegration/app/src/main/java/com/shopify/checkout_kit_mobile_buy_integration_sample/cart/CartView.kt +++ b/platforms/android/samples/MobileBuyIntegration/app/src/main/java/com/shopify/checkout_kit_mobile_buy_integration_sample/cart/CartView.kt @@ -69,15 +69,17 @@ import com.shopify.checkout_kit_mobile_buy_integration_sample.common.components. import com.shopify.checkout_kit_mobile_buy_integration_sample.common.components.Header2 import com.shopify.checkout_kit_mobile_buy_integration_sample.common.components.MoneyText import com.shopify.checkout_kit_mobile_buy_integration_sample.common.components.ProgressIndicator +import com.shopify.checkout_kit_mobile_buy_integration_sample.common.logs.Logger import com.shopify.checkout_kit_mobile_buy_integration_sample.common.ui.theme.horizontalPadding import com.shopify.checkout_kit_mobile_buy_integration_sample.common.ui.theme.verticalPadding -import com.shopify.checkoutkit.DefaultCheckoutEventProcessor +import com.shopify.checkoutkit.DefaultCheckoutListener @Composable -fun CartView( +fun CartView( navController: NavController, - checkoutEventProcessor: T, + checkoutListener: T, cartViewModel: CartViewModel, + logger: Logger, ) { val state = cartViewModel.cartState.collectAsState().value @@ -120,7 +122,9 @@ fun CartView( cartViewModel.presentCheckout( state.checkoutUrl, activity, - checkoutEventProcessor + navController, + logger, + checkoutListener, ) }, totalAmount = state.cartTotals.totalAmount, diff --git a/platforms/android/samples/MobileBuyIntegration/app/src/main/java/com/shopify/checkout_kit_mobile_buy_integration_sample/cart/CartViewModel.kt b/platforms/android/samples/MobileBuyIntegration/app/src/main/java/com/shopify/checkout_kit_mobile_buy_integration_sample/cart/CartViewModel.kt index 6643f608..c7a6d682 100644 --- a/platforms/android/samples/MobileBuyIntegration/app/src/main/java/com/shopify/checkout_kit_mobile_buy_integration_sample/cart/CartViewModel.kt +++ b/platforms/android/samples/MobileBuyIntegration/app/src/main/java/com/shopify/checkout_kit_mobile_buy_integration_sample/cart/CartViewModel.kt @@ -32,11 +32,12 @@ import com.shopify.checkout_kit_mobile_buy_integration_sample.cart.data.CartStat import com.shopify.checkout_kit_mobile_buy_integration_sample.common.ID import com.shopify.checkout_kit_mobile_buy_integration_sample.common.SnackbarController import com.shopify.checkout_kit_mobile_buy_integration_sample.common.SnackbarEvent +import com.shopify.checkout_kit_mobile_buy_integration_sample.common.logs.Logger import com.shopify.checkout_kit_mobile_buy_integration_sample.common.navigation.Screen import com.shopify.checkout_kit_mobile_buy_integration_sample.settings.PreferencesManager import com.shopify.checkout_kit_mobile_buy_integration_sample.settings.authentication.data.CustomerRepository import com.shopify.checkoutkit.CheckoutProtocol -import com.shopify.checkoutkit.DefaultCheckoutEventProcessor +import com.shopify.checkoutkit.DefaultCheckoutListener import com.shopify.checkoutkit.ShopifyCheckoutKit import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow @@ -107,20 +108,26 @@ class CartViewModel( _cartState.value = CartState.Empty } - fun presentCheckout( + fun presentCheckout( url: String, activity: ComponentActivity, - eventProcessor: T + navController: NavController, + logger: Logger, + checkoutListener: T, ) { Timber.i("Presenting checkout with $url") val client = CheckoutProtocol.Client() .on(CheckoutProtocol.start) { Timber.i("ECP ec.start: $it") } - .on(CheckoutProtocol.complete) { Timber.i("ECP ec.complete: $it") } + .on(CheckoutProtocol.complete) { event -> + logger.log("Checkout completed: $event") + clearCart() + navController.popBackStack(Screen.Product.route, false) + } .on(CheckoutProtocol.error) { Timber.i("ECP ec.error: $it") } .on(CheckoutProtocol.totalsChange) { Timber.i("ECP ec.totals.change: $it") } .on(CheckoutProtocol.lineItemsChange) { Timber.i("ECP ec.line_items.change: $it") } .on(CheckoutProtocol.messagesChange) { Timber.i("ECP ec.messages.change: $it") } - ShopifyCheckoutKit.present(url, activity, eventProcessor, client) + ShopifyCheckoutKit.present(url, activity, checkoutListener, client) } fun preloadCheckout( diff --git a/platforms/android/samples/MobileBuyIntegration/app/src/main/java/com/shopify/checkout_kit_mobile_buy_integration_sample/common/MobileBuyEventProcessor.kt b/platforms/android/samples/MobileBuyIntegration/app/src/main/java/com/shopify/checkout_kit_mobile_buy_integration_sample/common/MobileBuyCheckoutListener.kt similarity index 82% rename from platforms/android/samples/MobileBuyIntegration/app/src/main/java/com/shopify/checkout_kit_mobile_buy_integration_sample/common/MobileBuyEventProcessor.kt rename to platforms/android/samples/MobileBuyIntegration/app/src/main/java/com/shopify/checkout_kit_mobile_buy_integration_sample/common/MobileBuyCheckoutListener.kt index c43f9bbf..dfbf5206 100644 --- a/platforms/android/samples/MobileBuyIntegration/app/src/main/java/com/shopify/checkout_kit_mobile_buy_integration_sample/common/MobileBuyEventProcessor.kt +++ b/platforms/android/samples/MobileBuyIntegration/app/src/main/java/com/shopify/checkout_kit_mobile_buy_integration_sample/common/MobileBuyCheckoutListener.kt @@ -29,36 +29,23 @@ import android.webkit.ValueCallback import android.webkit.WebChromeClient import android.webkit.WebView import android.widget.Toast -import androidx.navigation.NavController import com.shopify.checkout_kit_mobile_buy_integration_sample.MainActivity import com.shopify.checkout_kit_mobile_buy_integration_sample.R import com.shopify.checkout_kit_mobile_buy_integration_sample.cart.CartViewModel import com.shopify.checkout_kit_mobile_buy_integration_sample.common.logs.Logger -import com.shopify.checkout_kit_mobile_buy_integration_sample.common.navigation.Screen import com.shopify.checkoutkit.CheckoutException -import com.shopify.checkoutkit.DefaultCheckoutEventProcessor -import com.shopify.checkoutkit.lifecycleevents.CheckoutCompletedEvent +import com.shopify.checkoutkit.DefaultCheckoutListener import kotlinx.coroutines.DelicateCoroutinesApi import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.GlobalScope import kotlinx.coroutines.launch @OptIn(DelicateCoroutinesApi::class) -class MobileBuyEventProcessor( +class MobileBuyCheckoutListener( private val cartViewModel: CartViewModel, - private val navController: NavController, private val logger: Logger, private val context: Context -) : DefaultCheckoutEventProcessor(context) { - override fun onCheckoutCompleted(checkoutCompletedEvent: CheckoutCompletedEvent) { - logger.log(checkoutCompletedEvent) - - cartViewModel.clearCart() - GlobalScope.launch(Dispatchers.Main) { - navController.popBackStack(Screen.Product.route, false) - } - } - +) : DefaultCheckoutListener(context) { override fun onCheckoutFailed(error: CheckoutException) { logger.log("Checkout failed", error) diff --git a/platforms/android/samples/MobileBuyIntegration/app/src/main/java/com/shopify/checkout_kit_mobile_buy_integration_sample/common/navigation/CheckoutKitNavHost.kt b/platforms/android/samples/MobileBuyIntegration/app/src/main/java/com/shopify/checkout_kit_mobile_buy_integration_sample/common/navigation/CheckoutKitNavHost.kt index 897d5dc7..19f2daf2 100644 --- a/platforms/android/samples/MobileBuyIntegration/app/src/main/java/com/shopify/checkout_kit_mobile_buy_integration_sample/common/navigation/CheckoutKitNavHost.kt +++ b/platforms/android/samples/MobileBuyIntegration/app/src/main/java/com/shopify/checkout_kit_mobile_buy_integration_sample/common/navigation/CheckoutKitNavHost.kt @@ -31,7 +31,7 @@ import androidx.navigation.compose.composable import androidx.navigation.compose.rememberNavController import com.shopify.checkout_kit_mobile_buy_integration_sample.cart.CartView import com.shopify.checkout_kit_mobile_buy_integration_sample.cart.CartViewModel -import com.shopify.checkout_kit_mobile_buy_integration_sample.common.MobileBuyEventProcessor +import com.shopify.checkout_kit_mobile_buy_integration_sample.common.MobileBuyCheckoutListener import com.shopify.checkout_kit_mobile_buy_integration_sample.common.logs.Logger import com.shopify.checkout_kit_mobile_buy_integration_sample.home.HomeView import com.shopify.checkout_kit_mobile_buy_integration_sample.logs.LogsView @@ -129,9 +129,9 @@ fun CheckoutKitNavHost( CartView( cartViewModel = cartViewModel, navController = navController, - checkoutEventProcessor = MobileBuyEventProcessor( + logger = logger, + checkoutListener = MobileBuyCheckoutListener( cartViewModel, - navController, logger, activity, )