diff --git a/platforms/android/README.md b/platforms/android/README.md index aa32c926..8185566a 100644 --- a/platforms/android/README.md +++ b/platforms/android/README.md @@ -41,6 +41,8 @@ - JDK 17+ - Android `minSdk` 23+ - Android `compileSdk` 35+ for consuming apps. This repository currently builds the library with `compileSdk` 36. +- WebMessageListener support in the WebView installed on the buyer's device. This is expected for Android System WebView + or Chrome 83+ (released May 2020). If unsupported, `present` fails with `web_view_not_supported`. ## Install @@ -318,7 +320,7 @@ Checkout failures are delivered as `CheckoutException` values. Checkout web erro | `CheckoutExpiredException` | `invalid_cart` | The cart is invalid or empty. | Rebuild the cart before presenting checkout. | | `HttpException` | `http_error` | Checkout returned an unexpected HTTP response. | Treat as fatal for this attempt; retry with a fresh URL if appropriate. | | `ClientException` | `client_error` | Checkout could not load for a client-side reason. | Show a recoverable error and log details. | -| `CheckoutKitException` | `error_receiving_message`, `error_sending_message`, `render_process_gone`, `unknown` | Checkout Kit encountered an SDK or WebView issue. | Log details and open an issue if it persists. | +| `CheckoutKitException` | `error_receiving_message`, `error_sending_message`, `render_process_gone`, `web_view_not_supported`, `unknown` | Checkout Kit encountered an SDK or WebView issue. | Log details and open an issue if it persists. | ## Browser and system callbacks @@ -394,6 +396,10 @@ Checkout Kit opens external HTTPS links, `mailto:`, `tel:`, and custom-scheme li ``` - If checkout reports an expired, completed, or invalid cart, create a fresh cart and use its new `checkoutUrl`. - If checkout cannot access camera, file upload, or location features, check your manifest permissions and runtime permission flow. +- If checkout fails with `web_view_not_supported`, the installed WebView provider is expected to be older than Android + System WebView or Chrome 83. Open the checkout URL in Mobile Chrome, Chrome Custom Tabs, or another full mobile + browser instead of presenting Checkout Kit in the app WebView. You can also prompt the buyer to update Chrome or + Android System WebView before trying embedded checkout again. - If offsite payment redirects do not return to your app, verify App Links/deep link intent filters and domain association. - Password-protected storefronts return `storefront_password_required` and are not supported by Checkout Kit. diff --git a/platforms/android/lib/src/main/java/com/shopify/checkoutkit/CheckoutBottomSheet.kt b/platforms/android/lib/src/main/java/com/shopify/checkoutkit/CheckoutBottomSheet.kt index 43101ba6..f836cb5b 100644 --- a/platforms/android/lib/src/main/java/com/shopify/checkoutkit/CheckoutBottomSheet.kt +++ b/platforms/android/lib/src/main/java/com/shopify/checkoutkit/CheckoutBottomSheet.kt @@ -36,6 +36,7 @@ internal class CheckoutBottomSheet( private val checkoutListener: CheckoutListener, private val activity: ComponentActivity, private val protocolClient: CheckoutProtocol.Client? = null, + private val webMessageTransport: WebMessageTransport = AndroidXWebMessageTransport, ) : ComponentDialog(activity, R.style.CheckoutKitBottomSheetDialog) { private var presentedCheckoutWebView: CheckoutWebView? = null @@ -83,7 +84,7 @@ internal class CheckoutBottomSheet( onBackPressedDispatcher.addCallback(backNavigationCallback) log.d(LOG_TAG, "Finding or creating WebView.") - val checkoutWebView = CheckoutWebView.checkoutViewFor(checkoutUrl, activity) + val checkoutWebView = CheckoutWebView.checkoutViewFor(checkoutUrl, activity, webMessageTransport) presentedCheckoutWebView = checkoutWebView checkoutWebView.onResume() 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 c2694d1b..f72b7dfa 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 @@ -11,14 +11,25 @@ import android.webkit.WebResourceRequest import android.webkit.WebResourceResponse import android.webkit.WebView import androidx.activity.ComponentActivity +import androidx.webkit.WebMessageCompat import com.shopify.checkoutkit.ShopifyCheckoutKit.log import java.util.concurrent.CountDownLatch -internal class CheckoutWebView(context: Context, attributeSet: AttributeSet? = null) : - BaseWebView(context, attributeSet) { +internal class CheckoutWebView private constructor( + context: Context, + attributeSet: AttributeSet?, + private val webMessageTransport: WebMessageTransport, +) : BaseWebView(context, attributeSet) { + + constructor(context: Context, attributeSet: AttributeSet? = null) : + this(context, attributeSet, AndroidXWebMessageTransport) + + internal constructor(context: Context, webMessageTransport: WebMessageTransport) : + this(context, null, webMessageTransport) private var listener = CheckoutWebViewListener(NoopCheckoutListener()) private val embeddedCheckoutProtocol = EmbeddedCheckoutProtocolBridge(this) + private var webMessageListenerAttached = false private var loadComplete = false internal var isPresented = false private set @@ -27,7 +38,7 @@ internal class CheckoutWebView(context: Context, attributeSet: AttributeSet? = n init { webViewClient = CheckoutWebViewClient() - addJavascriptInterface(embeddedCheckoutProtocol, EmbeddedCheckoutProtocolBridge.INTERFACE_NAME) + attachProtocolBridge() settings.userAgentString = "${settings.userAgentString} ${userAgentSuffix()}" } @@ -53,14 +64,14 @@ internal class CheckoutWebView(context: Context, attributeSet: AttributeSet? = n override fun onAttachedToWindow() { super.onAttachedToWindow() - log.d(LOG_TAG, "Attached to window. Adding JavaScript interfaces.") - addJavascriptInterface(embeddedCheckoutProtocol, EmbeddedCheckoutProtocolBridge.INTERFACE_NAME) + log.d(LOG_TAG, "Attached to window. Adding protocol bridge.") + attachProtocolBridge() } override fun onDetachedFromWindow() { super.onDetachedFromWindow() - log.d(LOG_TAG, "Detached from window. Removing JavaScript interfaces.") - removeJavascriptInterface(EmbeddedCheckoutProtocolBridge.INTERFACE_NAME) + log.d(LOG_TAG, "Detached from window. Removing protocol bridge.") + detachProtocolBridge() } fun loadCheckout(url: String, isPreload: Boolean = false) { @@ -78,6 +89,50 @@ internal class CheckoutWebView(context: Context, attributeSet: AttributeSet? = n } } + internal fun receiveWebMessage(message: String?, isMainFrame: Boolean = true) { + if (!isMainFrame) { + log.d(LOG_TAG, "Ignoring ECP WebMessage from a child frame.") + return + } + + if (message == null) { + log.d(LOG_TAG, "Ignoring ECP WebMessage with null payload.") + return + } + + embeddedCheckoutProtocol.receiveMessage(message) + } + + private fun attachProtocolBridge() { + if (webMessageListenerAttached) return + + log.d(LOG_TAG, "Adding ECP WebMessageListener bridge.") + val attached = webMessageTransport.attach( + webView = this, + jsObjectName = EmbeddedCheckoutProtocolBridge.INTERFACE_NAME, + allowedOriginRules = WEB_MESSAGE_ALLOWED_ORIGIN_RULES, + ) { _, webMessage, _, isMainFrame, _ -> + if (webMessage.type != WebMessageCompat.TYPE_STRING) { + log.d(LOG_TAG, "Ignoring ECP WebMessage with non-string payload.") + } else { + receiveWebMessage(webMessage.data, isMainFrame) + } + } + if (!attached) { + val error = UnsupportedWebViewException() + log.w(LOG_TAG, error.checkoutError.errorDescription) + throw error + } + webMessageListenerAttached = true + } + + private fun detachProtocolBridge() { + if (!webMessageListenerAttached) return + + webMessageTransport.detach(this, EmbeddedCheckoutProtocolBridge.INTERFACE_NAME) + webMessageListenerAttached = false + } + internal fun markPreloadConsumed() { isPreloadRequest = false } @@ -145,31 +200,45 @@ internal class CheckoutWebView(context: Context, attributeSet: AttributeSet? = n private const val LOG_TAG = "CheckoutWebView" private const val SHOPIFY_PURPOSE_HEADER = "Shopify-Purpose" private const val PREFETCH_PURPOSE = "prefetch" + private val WEB_MESSAGE_ALLOWED_ORIGIN_RULES = setOf("*") private val preloadCache = PreloadCache() + internal var cacheClock: PreloadCache.Clock get() = preloadCache.clock set(value) { preloadCache.clock = value } - fun preload(url: String, activity: ComponentActivity) { + fun preload( + url: String, + activity: ComponentActivity, + webMessageTransport: WebMessageTransport = AndroidXWebMessageTransport, + ) { if (!ShopifyCheckoutKit.configuration.preloading.enabled) { return } - runOnUiThreadBlocking(activity) { - invalidate() - val view = CheckoutWebView(activity as Context).apply { - loadCheckout(url, isPreload = true) - log.d(LOG_TAG, "Pausing preloaded WebView.") - onPause() + try { + runOnUiThreadBlocking(activity) { + invalidate() + val view = CheckoutWebView(activity as Context, webMessageTransport).apply { + loadCheckout(url, isPreload = true) + log.d(LOG_TAG, "Pausing preloaded WebView.") + onPause() + } + preloadCache.store(PreloadKey.forUrl(url), view) } - preloadCache.store(PreloadKey.forUrl(url), view) + } catch (_: UnsupportedWebViewException) { + return } } - fun checkoutViewFor(url: String, activity: ComponentActivity): CheckoutWebView { + fun checkoutViewFor( + url: String, + activity: ComponentActivity, + webMessageTransport: WebMessageTransport = AndroidXWebMessageTransport, + ): CheckoutWebView { var checkoutWebView: CheckoutWebView? = null runOnUiThreadBlocking(activity) { val cachedView = if (ShopifyCheckoutKit.configuration.preloading.enabled) { @@ -180,7 +249,7 @@ internal class CheckoutWebView(context: Context, attributeSet: AttributeSet? = n } checkoutWebView = cachedView ?: run { - CheckoutWebView(activity as Context).apply { + CheckoutWebView(activity as Context, webMessageTransport).apply { loadCheckout(url) } } @@ -206,12 +275,14 @@ internal class CheckoutWebView(context: Context, attributeSet: AttributeSet? = n return } + var result: Result? = null val countDownLatch = CountDownLatch(1) activity.runOnUiThread { - action() + result = runCatching { action() } countDownLatch.countDown() } countDownLatch.await() + result?.getOrThrow() } private fun runOnMainThread(action: () -> Unit) { diff --git a/platforms/android/lib/src/main/java/com/shopify/checkoutkit/EmbeddedCheckoutProtocolBridge.kt b/platforms/android/lib/src/main/java/com/shopify/checkoutkit/EmbeddedCheckoutProtocolBridge.kt index 03756df0..9ebf57e7 100644 --- a/platforms/android/lib/src/main/java/com/shopify/checkoutkit/EmbeddedCheckoutProtocolBridge.kt +++ b/platforms/android/lib/src/main/java/com/shopify/checkoutkit/EmbeddedCheckoutProtocolBridge.kt @@ -1,6 +1,5 @@ package com.shopify.checkoutkit -import android.webkit.JavascriptInterface import androidx.core.net.toUri import com.shopify.checkoutkit.ShopifyCheckoutKit.log import com.shopify.ucp.embedded.checkout.InstrumentsChangeResultUcp @@ -14,17 +13,29 @@ import com.shopify.ucp.embedded.checkout.windowOpenSuccess import kotlinx.serialization.SerializationException import kotlinx.serialization.json.JsonElement import java.net.URI +import java.util.concurrent.Executor +import java.util.concurrent.Executors + +private object ProtocolMessageExecutor { + // WebMessageListener invokes callbacks on the UI thread; keep protocol parsing off it + // to prevent potentially freezing the application UI with an "app not responding" (ANR) error. + val executor: Executor = Executors.newSingleThreadExecutor { runnable -> + Thread(runnable, "ShopifyCheckoutKit-ECP").apply { + isDaemon = true + } + } +} /** - * Handles the Embedded Checkout Protocol (ECP) JS bridge. + * Handles Embedded Checkout Protocol (ECP) messages received from checkout. * - * Registered on the WebView as [INTERFACE_NAME] so checkout can call - * `window.EmbeddedCheckoutProtocolConsumer.postMessage(jsonRpcString)`. + * [CheckoutWebView] forwards messages from the WebMessageListener named [INTERFACE_NAME]. * Responses are sent back via `window.EmbeddedCheckoutProtocol.postMessage(responseString)`. */ internal class EmbeddedCheckoutProtocolBridge( private val view: CheckoutWebView, @Volatile private var client: CheckoutProtocol.Client? = null, + private val messageExecutor: Executor = ProtocolMessageExecutor.executor, ) { private val defaultClient: CheckoutProtocol.Client = defaultDelegationClient() private val defaultClientBindings: Map = mapOf( @@ -55,8 +66,13 @@ internal class EmbeddedCheckoutProtocolBridge( this.client = client } - @JavascriptInterface - fun postMessage(message: String) { + internal fun receiveMessage(message: String) { + messageExecutor.execute { + processMessage(message) + } + } + + private fun processMessage(message: String) { try { val request = decodeProtocolRequest(message) val method = CheckoutProtocol.supportedProtocolMethod(request) @@ -85,15 +101,13 @@ internal class EmbeddedCheckoutProtocolBridge( log.d(LOG_TAG, "Handling ${CheckoutProtocol.start.method}: hiding progress bar and bubbling up.") onMainThread { view.getListener().onCheckoutViewLoadComplete() - composedClient.process(message) } + composedClient.process(message) } private fun handleComplete(message: String) { log.d(LOG_TAG, "Handling ${CheckoutProtocol.complete.method}: bubbling up.") - onMainThread { - composedClient.process(message) - } + composedClient.process(message) } /** @@ -106,9 +120,7 @@ internal class EmbeddedCheckoutProtocolBridge( */ private fun handleWindowOpenRequest(message: String) { log.d(LOG_TAG, "Handling ${CheckoutProtocol.windowOpen.method}") - onMainThread { - composedClient.process(message)?.let { sendRaw(it) } - } + composedClient.process(message)?.let { sendRaw(it) } } /** @@ -119,11 +131,9 @@ internal class EmbeddedCheckoutProtocolBridge( */ private fun handleClientMessage(method: String, message: String) { log.d(LOG_TAG, "Delegating $method to client.") - onMainThread { - val response = composedClient.process(message) - log.d(LOG_TAG, " client response: $response") - response?.let { sendRaw(it) } - } + val response = composedClient.process(message) + log.d(LOG_TAG, " client response: $response") + response?.let { sendRaw(it) } } private fun sendError(id: JsonElement?, code: Int, message: String) { 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 b71446af..d6da97a5 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 @@ -71,6 +71,14 @@ public object ShopifyCheckoutKit { */ @JvmStatic public fun preload(checkoutUrl: String, context: ComponentActivity) { + preload(checkoutUrl, context, AndroidXWebMessageTransport) + } + + internal fun preload( + checkoutUrl: String, + context: ComponentActivity, + webMessageTransport: WebMessageTransport, + ) { log.d("ShopifyCheckoutKit", "Preload called with checkoutUrl ${checkoutUrl.redactedUrlForLogging()}.") if (!configuration.preloading.enabled) { log.d("ShopifyCheckoutKit", "Preloading disabled, ignoring preload.") @@ -82,7 +90,7 @@ public object ShopifyCheckoutKit { return } - CheckoutWebView.preload(checkoutUrl, context) + CheckoutWebView.preload(checkoutUrl, context, webMessageTransport) } /** @@ -110,6 +118,22 @@ public object ShopifyCheckoutKit { ) } + internal fun present( + checkoutUrl: String, + context: ComponentActivity, + webMessageTransport: WebMessageTransport, + configure: CheckoutPresentation.() -> Unit, + ): CheckoutHandle? { + val presentation = CheckoutPresentation().apply(configure) + return present( + checkoutUrl = checkoutUrl, + context = context, + checkoutListener = presentation.buildListener(), + protocolClient = presentation.protocolClient, + webMessageTransport = webMessageTransport, + ) + } + /** * Presents a Shopify checkout within a bottom sheet * @@ -130,25 +154,59 @@ public object ShopifyCheckoutKit { context: ComponentActivity, checkoutListener: T, protocolClient: CheckoutProtocol.Client? = null, + ): CheckoutHandle? { + return present( + checkoutUrl = checkoutUrl, + context = context, + checkoutListener = checkoutListener, + protocolClient = protocolClient, + webMessageTransport = AndroidXWebMessageTransport, + ) + } + + internal fun present( + checkoutUrl: String, + context: ComponentActivity, + checkoutListener: T, + protocolClient: CheckoutProtocol.Client? = null, + webMessageTransport: WebMessageTransport, ): CheckoutHandle? { log.d("ShopifyCheckoutKit", "Present called with checkoutUrl ${checkoutUrl.redactedUrlForLogging()}.") if (context.isDestroyed || context.isFinishing) { log.d("ShopifyCheckoutKit", "Context is destroyed or finishing, returning null.") return null } + log.d("ShopifyCheckoutKit", "Constructing bottom sheet") - val checkout = CheckoutBottomSheet(checkoutUrl, checkoutListener, context, protocolClient) - context.lifecycle.addObserver(object : DefaultLifecycleObserver { + val checkout = CheckoutBottomSheet( + checkoutUrl, + checkoutListener, + context, + protocolClient, + webMessageTransport, + ) + val lifecycleObserver = object : DefaultLifecycleObserver { override fun onDestroy(owner: LifecycleOwner) { log.d("ShopifyCheckoutKit", "Context is being destroyed, dismissing bottom sheet.") checkout.dismiss(animate = false) super.onDestroy(owner) } - }) + } + context.lifecycle.addObserver(lifecycleObserver) log.d("ShopifyCheckoutKit", "Starting bottom sheet.") - checkout.start() - return CheckoutHandle { checkout.dismiss() } + val checkoutStarted = try { + checkout.start() + true + } catch (error: UnsupportedWebViewException) { + context.lifecycle.removeObserver(lifecycleObserver) + checkout.dismiss(animate = false) + + log.w("ShopifyCheckoutKit", "WebView is not supported, failing checkout presentation.") + checkoutListener.onCheckoutFailed(error.checkoutError) + false + } + return if (checkoutStarted) CheckoutHandle { checkout.dismiss() } else null } } diff --git a/platforms/android/lib/src/main/java/com/shopify/checkoutkit/WebMessageTransport.kt b/platforms/android/lib/src/main/java/com/shopify/checkoutkit/WebMessageTransport.kt new file mode 100644 index 00000000..e2e9a0b2 --- /dev/null +++ b/platforms/android/lib/src/main/java/com/shopify/checkoutkit/WebMessageTransport.kt @@ -0,0 +1,56 @@ +package com.shopify.checkoutkit + +import android.webkit.WebView +import androidx.webkit.WebViewCompat +import androidx.webkit.WebViewFeature + +internal interface WebMessageTransport { + fun attach( + webView: WebView, + jsObjectName: String, + allowedOriginRules: Set, + listener: WebViewCompat.WebMessageListener, + ): Boolean + + fun detach(webView: WebView, jsObjectName: String) +} + +internal object AndroidXWebMessageTransport : WebMessageTransport { + override fun attach( + webView: WebView, + jsObjectName: String, + allowedOriginRules: Set, + listener: WebViewCompat.WebMessageListener, + ): Boolean { + if (!WebViewFeature.isFeatureSupported(WebViewFeature.WEB_MESSAGE_LISTENER)) return false + + return try { + WebViewCompat.addWebMessageListener(webView, jsObjectName, allowedOriginRules, listener) + true + } catch (_: UnsupportedOperationException) { + false + } + } + + override fun detach(webView: WebView, jsObjectName: String) { + if (!WebViewFeature.isFeatureSupported(WebViewFeature.WEB_MESSAGE_LISTENER)) return + + try { + WebViewCompat.removeWebMessageListener(webView, jsObjectName) + } catch (_: UnsupportedOperationException) { + // The WebView provider can change independently of the app process. + } + } +} + +internal class UnsupportedWebViewException : IllegalStateException(ERROR_DESCRIPTION) { + val checkoutError = CheckoutKitException( + errorDescription = ERROR_DESCRIPTION, + errorCode = ERROR_CODE, + ) + + private companion object { + private const val ERROR_CODE = "web_view_not_supported" + private const val ERROR_DESCRIPTION = "This Android WebView does not support Shopify Checkout Kit." + } +} diff --git a/platforms/android/lib/src/test/java/com/shopify/checkoutkit/CheckoutBottomSheetAccessibilityTest.kt b/platforms/android/lib/src/test/java/com/shopify/checkoutkit/CheckoutBottomSheetAccessibilityTest.kt index 85ee4dda..83610a90 100644 --- a/platforms/android/lib/src/test/java/com/shopify/checkoutkit/CheckoutBottomSheetAccessibilityTest.kt +++ b/platforms/android/lib/src/test/java/com/shopify/checkoutkit/CheckoutBottomSheetAccessibilityTest.kt @@ -22,6 +22,7 @@ class CheckoutBottomSheetAccessibilityTest { private lateinit var activity: ComponentActivity private lateinit var configuration: Configuration + private val webMessageTransport = FakeWebMessageTransport() @Before fun setUp() { @@ -93,6 +94,7 @@ class CheckoutBottomSheetAccessibilityTest { checkoutUrl = "https://shopify.com", checkoutListener = noopDefaultCheckoutListener(), activity = activity, + webMessageTransport = webMessageTransport, ).also { it.start() } private fun CheckoutBottomSheet.closeButton(): View = diff --git a/platforms/android/lib/src/test/java/com/shopify/checkoutkit/CheckoutBottomSheetOptionsTest.kt b/platforms/android/lib/src/test/java/com/shopify/checkoutkit/CheckoutBottomSheetOptionsTest.kt index be9e0093..3451a771 100644 --- a/platforms/android/lib/src/test/java/com/shopify/checkoutkit/CheckoutBottomSheetOptionsTest.kt +++ b/platforms/android/lib/src/test/java/com/shopify/checkoutkit/CheckoutBottomSheetOptionsTest.kt @@ -31,6 +31,7 @@ class CheckoutBottomSheetOptionsTest { private lateinit var activity: ComponentActivity private lateinit var initialConfiguration: Configuration private var presentedSheet: CheckoutBottomSheet? = null + private val webMessageTransport = FakeWebMessageTransport() @Before fun setUp() { @@ -252,7 +253,12 @@ class CheckoutBottomSheetOptionsTest { checkoutUrl: String = "https://shopify.com", checkoutListener: CheckoutListener = noopDefaultCheckoutListener(), ): CheckoutBottomSheet = - CheckoutBottomSheet(checkoutUrl, checkoutListener, activity).also { sheet -> + CheckoutBottomSheet( + checkoutUrl, + checkoutListener, + activity, + webMessageTransport = webMessageTransport, + ).also { sheet -> presentedSheet = sheet sheet.start() } diff --git a/platforms/android/lib/src/test/java/com/shopify/checkoutkit/CheckoutBottomSheetTest.kt b/platforms/android/lib/src/test/java/com/shopify/checkoutkit/CheckoutBottomSheetTest.kt index 621d5ec5..5e5f8b0a 100644 --- a/platforms/android/lib/src/test/java/com/shopify/checkoutkit/CheckoutBottomSheetTest.kt +++ b/platforms/android/lib/src/test/java/com/shopify/checkoutkit/CheckoutBottomSheetTest.kt @@ -46,6 +46,7 @@ class CheckoutBottomSheetTest { private lateinit var activity: ComponentActivity private lateinit var processor: DefaultCheckoutListener private lateinit var configuration: Configuration + private val webMessageTransport = FakeWebMessageTransport() @Before fun setUp() { @@ -431,7 +432,7 @@ class CheckoutBottomSheetTest { @Test fun `bottom sheet uses cached preloaded checkoutView for matching URL`() { - CheckoutWebView.preload("https://shopify.com/cart/123", activity) + CheckoutWebView.preload("https://shopify.com/cart/123", activity, webMessageTransport) ShadowLooper.shadowMainLooper().runToEndOfTasks() val cachedWebView = CheckoutWebView.cachedPreloadViewForTesting()!! @@ -465,7 +466,7 @@ class CheckoutBottomSheetTest { @Test fun `dismiss() destroys consumed preloaded checkoutView`() { - CheckoutWebView.preload("https://shopify.com/cart/123", activity) + CheckoutWebView.preload("https://shopify.com/cart/123", activity, webMessageTransport) ShadowLooper.shadowMainLooper().runToEndOfTasks() val cachedWebView = CheckoutWebView.cachedPreloadViewForTesting()!! @@ -504,7 +505,12 @@ class CheckoutBottomSheetTest { @Test fun `present returns handle allowing dismissal of checkout`() { - val checkout = ShopifyCheckoutKit.present("https://shopify.com", activity, processor) + val checkout = ShopifyCheckoutKit.present( + "https://shopify.com", + activity, + processor, + webMessageTransport = webMessageTransport, + ) val sheet = ShadowDialog.getLatestDialog() as CheckoutBottomSheet val container = sheet.findViewById(R.id.checkoutKitContainer)!! val webView = container.children.first { it is CheckoutWebView } as CheckoutWebView @@ -527,18 +533,18 @@ class CheckoutBottomSheetTest { val client = CheckoutProtocol.Client() .on(CheckoutProtocol.messagesChange) { received = true } - ShopifyCheckoutKit.present("https://shopify.com", activity) { + ShopifyCheckoutKit.present("https://shopify.com", activity, webMessageTransport) { connect(client) } val sheet = ShadowDialog.getLatestDialog() as CheckoutBottomSheet val webView = sheet.currentCheckoutWebView() - val bridge = shadowOf(webView) - .getJavascriptInterface(EmbeddedCheckoutProtocolBridge.INTERFACE_NAME) as EmbeddedCheckoutProtocolBridge - bridge.postMessage(ecMessagesChangeMessage()) - shadowOf(Looper.getMainLooper()).runToEndOfTasks() + webView.receiveWebMessage(ecMessagesChangeMessage()) - assertThat(received).isTrue() + await().pollInSameThread().atMost(2, TimeUnit.SECONDS).untilAsserted { + shadowOf(Looper.getMainLooper()).runToEndOfTasks() + assertThat(received).isTrue() + } } @Test @@ -882,7 +888,13 @@ class CheckoutBottomSheetTest { checkoutListener: CheckoutListener = processor, protocolClient: CheckoutProtocol.Client? = null, ): CheckoutBottomSheet = - CheckoutBottomSheet(checkoutUrl, checkoutListener, activity, protocolClient).also { sheet -> + CheckoutBottomSheet( + checkoutUrl, + checkoutListener, + activity, + protocolClient, + webMessageTransport, + ).also { sheet -> sheet.start() } 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 ab60db88..da0ce0b7 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 @@ -12,6 +12,7 @@ import android.webkit.WebViewClient.ERROR_BAD_URL import androidx.activity.ComponentActivity import com.shopify.checkoutkit.CheckoutExceptionAssert.Companion.assertThat import org.assertj.core.api.Assertions.assertThat +import org.junit.After import org.junit.Before import org.junit.Test import org.junit.runner.RunWith @@ -35,6 +36,7 @@ class CheckoutWebViewClientTest { private lateinit var activity: ComponentActivity private val mockListener = mock() private val checkoutWebViewListener = spy(CheckoutWebViewListener(mockListener)) + private val webMessageTransport = FakeWebMessageTransport() @Before fun setUp() { @@ -45,6 +47,12 @@ class CheckoutWebViewClientTest { shadowOf(activity.application).checkActivities(true) } + @After + fun tearDown() { + CheckoutWebView.clearCache() + ShadowLooper.shadowMainLooper().runToEndOfTasks() + } + @Test fun `overrides url loading for mailto links and launches intent when resolvable`() { val uri = Uri.parse("mailto:daniel.kift@shopify.com") @@ -381,7 +389,7 @@ class CheckoutWebViewClientTest { private fun viewWithProcessor( activity: ComponentActivity, ): CheckoutWebView { - val view = CheckoutWebView(activity) + val view = CheckoutWebView(activity, webMessageTransport) 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 408a2e3e..a5040dab 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 @@ -1,5 +1,6 @@ package com.shopify.checkoutkit +import android.content.Context import android.graphics.Color import android.net.Uri import android.os.Looper @@ -11,6 +12,8 @@ import android.webkit.WebChromeClient.FileChooserParams import android.widget.FrameLayout import androidx.activity.ComponentActivity import org.assertj.core.api.Assertions.assertThat +import org.assertj.core.api.Assertions.assertThatThrownBy +import org.awaitility.Awaitility.await import org.junit.After import org.junit.Before import org.junit.Test @@ -22,16 +25,19 @@ import org.robolectric.Robolectric import org.robolectric.RobolectricTestRunner import org.robolectric.Shadows.shadowOf import org.robolectric.shadows.ShadowLooper +import java.util.concurrent.TimeUnit @RunWith(RobolectricTestRunner::class) class CheckoutWebViewTest { private lateinit var activity: ComponentActivity private lateinit var initialConfiguration: Configuration + private lateinit var webMessageTransport: FakeWebMessageTransport @Before fun setUp() { initialConfiguration = ShopifyCheckoutKit.getConfiguration() + webMessageTransport = FakeWebMessageTransport() CheckoutWebView.clearCache() ShadowLooper.shadowMainLooper().runToEndOfTasks() activity = Robolectric.buildActivity(ComponentActivity::class.java).get() @@ -53,7 +59,7 @@ class CheckoutWebViewTest { @Test fun `configures web view on initialization`() { - val view = CheckoutWebView(activity) + val view = checkoutWebView(activity) assertThat(view.visibility).isEqualTo(VISIBLE) assertThat(view.settings.javaScriptEnabled).isTrue @@ -61,13 +67,25 @@ class CheckoutWebViewTest { assertThat(view.id).isNotNull assertThat(shadowOf(view).webViewClient.javaClass).isEqualTo(CheckoutWebView.CheckoutWebViewClient::class.java) assertThat(shadowOf(view).backgroundColor).isEqualTo(Color.TRANSPARENT) - assertThat(shadowOf(view).getJavascriptInterface(EmbeddedCheckoutProtocolBridge.INTERFACE_NAME).javaClass) - .isEqualTo(EmbeddedCheckoutProtocolBridge::class.java) + assertThat(shadowOf(view).getJavascriptInterface(EmbeddedCheckoutProtocolBridge.INTERFACE_NAME)).isNull() + assertThat(webMessageTransport.attachCount).isEqualTo(1) + } + + @Test + fun `detaches and reattaches WebMessage transport with view lifecycle`() { + val view = checkoutWebView(activity) + val shadow = shadowOf(view) + + shadow.callOnDetachedFromWindow() + shadow.callOnAttachedToWindow() + + assertThat(webMessageTransport.detachCount).isEqualTo(1) + assertThat(webMessageTransport.attachCount).isEqualTo(2) } @Test fun `user agent suffix contains ShopifyCheckoutKit version and android platform`() { - val view = CheckoutWebView(activity) + val view = checkoutWebView(activity) assertThat(view.settings.userAgentString).contains("ShopifyCheckoutKit/") assertThat(view.settings.userAgentString).contains("(Android;") @@ -76,7 +94,7 @@ class CheckoutWebViewTest { @Test fun `user agent suffix appends platform identifier and version when set`() { ShopifyCheckoutKit.configuration.platform = Platform.ReactNative("0.80.0") - val view = CheckoutWebView(activity) + val view = checkoutWebView(activity) val kotlinVersion = KotlinVersion.CURRENT.let { "${it.major}.${it.minor}" } assertThat(view.settings.userAgentString) @@ -88,7 +106,7 @@ class CheckoutWebViewTest { @Test fun `user agent suffix omits version when platform version is null`() { ShopifyCheckoutKit.configuration.platform = Platform.ReactNative() - val view = CheckoutWebView(activity) + val view = checkoutWebView(activity) val kotlinVersion = KotlinVersion.CURRENT.let { "${it.major}.${it.minor}" } assertThat(view.settings.userAgentString) @@ -96,29 +114,19 @@ class CheckoutWebViewTest { } @Test - fun `attaches javascript interface onAttachedToWindow`() { - val view = CheckoutWebView(activity) - - val shadow = shadowOf(view) - shadow.callOnAttachedToWindow() - - assertThat(shadow.getJavascriptInterface(EmbeddedCheckoutProtocolBridge.INTERFACE_NAME).javaClass) - .isEqualTo(EmbeddedCheckoutProtocolBridge::class.java) - } - - @Test - fun `removes javascript interface onDetachedFromWindow`() { - val view = CheckoutWebView(activity) + fun `throws unsupported WebView exception when WebMessageListener is unsupported`() { + webMessageTransport.supported = false - val shadow = shadowOf(view) - shadow.callOnDetachedFromWindow() - - assertThat(shadow.getJavascriptInterface(EmbeddedCheckoutProtocolBridge.INTERFACE_NAME)).isNull() + assertThatThrownBy { + checkoutWebView(activity) + } + .isInstanceOf(UnsupportedWebViewException::class.java) + .hasMessage("This Android WebView does not support Shopify Checkout Kit.") } @Test fun `calls update progress when new progress is reported by WebChromeClient`() { - val view = CheckoutWebView(activity) + val view = checkoutWebView(activity) val webViewListener = mock() view.setListener(webViewListener) @@ -133,7 +141,7 @@ class CheckoutWebViewTest { @Test fun `calls processors onPermissionRequest when resource permission requested`() { - val view = CheckoutWebView(activity) + val view = checkoutWebView(activity) val webViewListener = mock() view.setListener(webViewListener) @@ -149,7 +157,7 @@ class CheckoutWebViewTest { @Test fun `calls processors onShowFileChooser when called on webChromeClient`() { - val view = CheckoutWebView(activity) + val view = checkoutWebView(activity) val webViewListener = mock() view.setListener(webViewListener) @@ -164,7 +172,7 @@ class CheckoutWebViewTest { @Test fun `calls processors onGeolocationPermissionsShowPrompt when called on webChromeClient`() { - val view = CheckoutWebView(activity) + val view = checkoutWebView(activity) val webViewListener = mock() view.setListener(webViewListener) @@ -178,11 +186,47 @@ class CheckoutWebViewTest { verify(webViewListener).onGeolocationPermissionsShowPrompt(origin, callback) } + // region ECP WebMessage transport + + @Test + fun `web message is received by protocol bridge`() { + val view = checkoutWebView(activity) + var received = false + view.setClient( + CheckoutProtocol.Client() + .on(CheckoutProtocol.messagesChange) { received = true }, + ) + + view.receiveWebMessage(ecMessagesChangeMessage()) + + await().pollInSameThread().atMost(2, TimeUnit.SECONDS).untilAsserted { + ShadowLooper.shadowMainLooper().runToEndOfTasks() + assertThat(received).isTrue() + } + } + + @Test + fun `web message from child frame is ignored`() { + val view = checkoutWebView(activity) + var received = false + view.setClient( + CheckoutProtocol.Client() + .on(CheckoutProtocol.messagesChange) { received = true }, + ) + + view.receiveWebMessage(ecMessagesChangeMessage(), isMainFrame = false) + ShadowLooper.shadowMainLooper().runToEndOfTasks() + + assertThat(received).isFalse() + } + + // endregion + @Test fun `removeFromParent() should remove parent if a parent exists but not destroy WebView`() { Robolectric.buildActivity(ComponentActivity::class.java).use { activityController -> val ctx = activityController.get() - val webView = CheckoutWebView(ctx) + val webView = checkoutWebView(ctx) val container = FrameLayout(ctx) container.addView(webView) assertThat(webView.parent).isNotNull() @@ -200,7 +244,7 @@ class CheckoutWebViewTest { fun `removeFromParent() should do nothing if no parent exists`() { Robolectric.buildActivity(ComponentActivity::class.java).use { activityController -> val ctx = activityController.get() - val webView = CheckoutWebView(ctx) + val webView = checkoutWebView(ctx) webView.removeFromParent() shadowOf(Looper.getMainLooper()).runToEndOfTasks() @@ -214,7 +258,7 @@ class CheckoutWebViewTest { @Test fun `loadCheckout appends ec_version to URL when absent`() { - val view = CheckoutWebView(activity) + val view = checkoutWebView(activity) view.loadCheckout("https://checkout.shopify.com/cart/123") ShadowLooper.shadowMainLooper().runToEndOfTasks() @@ -223,7 +267,7 @@ class CheckoutWebViewTest { @Test fun `loadCheckout preserves existing query params alongside ec_version`() { - val view = CheckoutWebView(activity) + val view = checkoutWebView(activity) view.loadCheckout("https://checkout.shopify.com/cart/123?foo=bar") ShadowLooper.shadowMainLooper().runToEndOfTasks() @@ -234,7 +278,7 @@ class CheckoutWebViewTest { @Test fun `loadCheckout replaces ec_version when already present`() { - val view = CheckoutWebView(activity) + val view = checkoutWebView(activity) val callerSuppliedVersion = "2026-01-23" val urlWithVersion = "https://checkout.shopify.com/cart/123?ec_version=$callerSuppliedVersion" @@ -253,7 +297,7 @@ class CheckoutWebViewTest { @Test fun `preload creates cached checkout view with prefetch header`() { - CheckoutWebView.preload("https://checkout.shopify.com/cart/123", activity) + preload("https://checkout.shopify.com/cart/123") ShadowLooper.shadowMainLooper().runToEndOfTasks() val view = CheckoutWebView.cachedPreloadViewForTesting()!! @@ -266,11 +310,11 @@ class CheckoutWebViewTest { @Test fun `present consumes cached checkout view for matching URL`() { - CheckoutWebView.preload("https://checkout.shopify.com/cart/123", activity) + preload("https://checkout.shopify.com/cart/123") ShadowLooper.shadowMainLooper().runToEndOfTasks() val cachedView = CheckoutWebView.cachedPreloadViewForTesting()!! - val presentedView = CheckoutWebView.checkoutViewFor("https://checkout.shopify.com/cart/123", activity) + val presentedView = checkoutViewFor("https://checkout.shopify.com/cart/123") ShadowLooper.shadowMainLooper().runToEndOfTasks() assertThat(presentedView).isSameAs(cachedView) @@ -281,11 +325,11 @@ class CheckoutWebViewTest { @Test fun `present discards cached checkout view for mismatched URL`() { - CheckoutWebView.preload("https://checkout.shopify.com/cart/123", activity) + preload("https://checkout.shopify.com/cart/123") ShadowLooper.shadowMainLooper().runToEndOfTasks() val cachedView = CheckoutWebView.cachedPreloadViewForTesting()!! - val presentedView = CheckoutWebView.checkoutViewFor("https://checkout.shopify.com/cart/456", activity) + val presentedView = checkoutViewFor("https://checkout.shopify.com/cart/456") ShadowLooper.shadowMainLooper().runToEndOfTasks() assertThat(presentedView).isNotSameAs(cachedView) @@ -295,14 +339,11 @@ class CheckoutWebViewTest { @Test fun `present discards cached checkout view for mismatched query params`() { - CheckoutWebView.preload("https://checkout.shopify.com/cart/123?cart=first", activity) + preload("https://checkout.shopify.com/cart/123?cart=first") ShadowLooper.shadowMainLooper().runToEndOfTasks() val cachedView = CheckoutWebView.cachedPreloadViewForTesting()!! - val presentedView = CheckoutWebView.checkoutViewFor( - "https://checkout.shopify.com/cart/123?cart=second", - activity, - ) + val presentedView = checkoutViewFor("https://checkout.shopify.com/cart/123?cart=second") ShadowLooper.shadowMainLooper().runToEndOfTasks() assertThat(presentedView).isNotSameAs(cachedView) @@ -316,12 +357,12 @@ class CheckoutWebViewTest { CheckoutWebView.cacheClock = object : PreloadCache.Clock() { override fun currentTimeMillis(): Long = now } - CheckoutWebView.preload("https://checkout.shopify.com/cart/123", activity) + preload("https://checkout.shopify.com/cart/123") ShadowLooper.shadowMainLooper().runToEndOfTasks() val cachedView = CheckoutWebView.cachedPreloadViewForTesting()!! now += 5 * 60 * 1000L - val presentedView = CheckoutWebView.checkoutViewFor("https://checkout.shopify.com/cart/123", activity) + val presentedView = checkoutViewFor("https://checkout.shopify.com/cart/123") ShadowLooper.shadowMainLooper().runToEndOfTasks() assertThat(presentedView).isNotSameAs(cachedView) @@ -330,7 +371,7 @@ class CheckoutWebViewTest { @Test fun `invalidate destroys unpresented cached checkout view`() { - CheckoutWebView.preload("https://checkout.shopify.com/cart/123", activity) + preload("https://checkout.shopify.com/cart/123") ShadowLooper.shadowMainLooper().runToEndOfTasks() val cachedView = CheckoutWebView.cachedPreloadViewForTesting()!! @@ -343,9 +384,9 @@ class CheckoutWebViewTest { @Test fun `invalidate does not destroy presented checkout view`() { - CheckoutWebView.preload("https://checkout.shopify.com/cart/123", activity) + preload("https://checkout.shopify.com/cart/123") ShadowLooper.shadowMainLooper().runToEndOfTasks() - val presentedView = CheckoutWebView.checkoutViewFor("https://checkout.shopify.com/cart/123", activity) + val presentedView = checkoutViewFor("https://checkout.shopify.com/cart/123") presentedView.markPresented() CheckoutWebView.invalidate() @@ -360,7 +401,7 @@ class CheckoutWebViewTest { it.preloading = Preloading(enabled = false) } - CheckoutWebView.preload("https://checkout.shopify.com/cart/123", activity) + preload("https://checkout.shopify.com/cart/123") ShadowLooper.shadowMainLooper().runToEndOfTasks() assertThat(CheckoutWebView.cachedPreloadViewForTesting()).isNull() @@ -368,7 +409,7 @@ class CheckoutWebViewTest { @Test fun `loadCheckout does not send prefetch header for normal loads`() { - val view = CheckoutWebView(activity) + val view = checkoutWebView(activity) view.loadCheckout("https://checkout.shopify.com/cart/123") ShadowLooper.shadowMainLooper().runToEndOfTasks() @@ -382,7 +423,7 @@ class CheckoutWebViewTest { @Test fun `loadCheckout appends ec_delegate=window_open to URL`() { - val view = CheckoutWebView(activity) + val view = checkoutWebView(activity) view.loadCheckout("https://checkout.shopify.com/cart/123") ShadowLooper.shadowMainLooper().runToEndOfTasks() @@ -391,7 +432,7 @@ class CheckoutWebViewTest { @Test fun `loadCheckout replaces ec_delegate when already present`() { - val view = CheckoutWebView(activity) + val view = checkoutWebView(activity) val callerSuppliedDelegate = "custom" val urlWithDelegate = "https://checkout.shopify.com/cart/123?ec_delegate=$callerSuppliedDelegate" @@ -405,4 +446,22 @@ class CheckoutWebViewTest { } // endregion + + private fun checkoutWebView(context: Context): CheckoutWebView = + CheckoutWebView(context, webMessageTransport) + + private fun preload(url: String) { + CheckoutWebView.preload(url, activity, webMessageTransport) + } + + private fun checkoutViewFor(url: String): CheckoutWebView = + CheckoutWebView.checkoutViewFor(url, activity, webMessageTransport) + + private fun ecMessagesChangeMessage(): String = + """{"jsonrpc":"2.0","method":"ec.messages.change","params":{"checkout":${checkoutJson()}}}""" + + private fun checkoutJson(): String { + val ucp = """{"payment_handlers":{},"version":"1.0"}""" + return """{"id":"chk1","currency":"USD","status":"incomplete","line_items":[],"totals":[],"links":[],"ucp":$ucp}""" + } } diff --git a/platforms/android/lib/src/test/java/com/shopify/checkoutkit/EmbeddedCheckoutProtocolBridgeTest.kt b/platforms/android/lib/src/test/java/com/shopify/checkoutkit/EmbeddedCheckoutProtocolBridgeTest.kt index 6adc5be3..a34a257f 100644 --- a/platforms/android/lib/src/test/java/com/shopify/checkoutkit/EmbeddedCheckoutProtocolBridgeTest.kt +++ b/platforms/android/lib/src/test/java/com/shopify/checkoutkit/EmbeddedCheckoutProtocolBridgeTest.kt @@ -10,6 +10,7 @@ import com.shopify.ucp.embedded.checkout.WindowOpenRequest import com.shopify.ucp.embedded.checkout.windowOpenRejected import com.shopify.ucp.embedded.checkout.windowOpenSuccess import org.assertj.core.api.Assertions.assertThat +import org.junit.After import org.junit.Assert.fail import org.junit.Before import org.junit.Test @@ -26,14 +27,18 @@ import org.mockito.kotlin.whenever import org.robolectric.Robolectric import org.robolectric.RobolectricTestRunner import org.robolectric.Shadows.shadowOf +import java.util.concurrent.Executor @RunWith(RobolectricTestRunner::class) +@Suppress("LargeClass") class EmbeddedCheckoutProtocolBridgeTest { private lateinit var activity: ComponentActivity private lateinit var viewSpy: CheckoutWebView private lateinit var mockListener: CheckoutWebViewListener private lateinit var ecp: EmbeddedCheckoutProtocolBridge + private val directExecutor = Executor { it.run() } + private val webMessageTransport = FakeWebMessageTransport() @Before fun setUp() { @@ -44,10 +49,35 @@ class EmbeddedCheckoutProtocolBridgeTest { // no activity resolves the intent. Robolectric defaults to silently recording the // intent instead — turning on checkActivities aligns the shadow with production. shadowOf(activity.application).checkActivities(true) - viewSpy = Mockito.spy(CheckoutWebView(activity)) + viewSpy = Mockito.spy(CheckoutWebView(activity, webMessageTransport)) mockListener = mock() whenever(viewSpy.getListener()).thenReturn(mockListener) - ecp = EmbeddedCheckoutProtocolBridge(viewSpy) + ecp = EmbeddedCheckoutProtocolBridge(viewSpy, messageExecutor = directExecutor) + } + + @After + fun tearDown() { + CheckoutWebView.clearCache() + shadowOf(Looper.getMainLooper()).runToEndOfTasks() + } + + @Test + fun `receive message queues protocol processing on message executor`() { + var queuedCommand: Runnable? = null + val deferredExecutor = Executor { command -> + queuedCommand = command + } + val asyncBridge = EmbeddedCheckoutProtocolBridge(viewSpy, messageExecutor = deferredExecutor) + + asyncBridge.receiveMessage(ecReadyMessage()) + + assertThat(queuedCommand).isNotNull() + verify(viewSpy, never()).evaluateJavascript(any(), any()) + + queuedCommand!!.run() + shadowOf(Looper.getMainLooper()).runToEndOfTasks() + + verify(viewSpy).evaluateJavascript(any(), isNull()) } // region ec.ready @@ -55,7 +85,7 @@ class EmbeddedCheckoutProtocolBridgeTest { @Test fun `ec ready returns a ucp success result without a delegate echo`() { val js = captureEvaluatedJs { - ecp.postMessage(ecReadyMessage()) + ecp.receiveMessage(ecReadyMessage()) } assertThat(js).contains("\"result\"") assertThat(js).contains("\"ucp\"") @@ -68,7 +98,7 @@ class EmbeddedCheckoutProtocolBridgeTest { @Test fun `ec ready ACK echoes string request id`() { val js = captureEvaluatedJs { - ecp.postMessage(ecReadyMessage(id = "\"req-42\"")) + ecp.receiveMessage(ecReadyMessage(id = "\"req-42\"")) } assertThat(js).contains("\"id\":\"req-42\"") } @@ -76,7 +106,7 @@ class EmbeddedCheckoutProtocolBridgeTest { @Test fun `ec ready ACK echoes numeric id`() { val js = captureEvaluatedJs { - ecp.postMessage("""{"jsonrpc":"2.0","method":"ec.ready","id":7,"params":{"delegate":[]}}""") + ecp.receiveMessage("""{"jsonrpc":"2.0","method":"ec.ready","id":7,"params":{"delegate":[]}}""") } assertThat(js).contains("\"id\":7") } @@ -84,7 +114,7 @@ class EmbeddedCheckoutProtocolBridgeTest { @Test fun `ec ready ACK echoes null request id`() { val js = captureEvaluatedJs { - ecp.postMessage("""{"jsonrpc":"2.0","method":"ec.ready","id":null,"params":{"delegate":[]}}""") + ecp.receiveMessage("""{"jsonrpc":"2.0","method":"ec.ready","id":null,"params":{"delegate":[]}}""") } assertThat(js).contains("\"id\":null") } @@ -102,7 +132,7 @@ class EmbeddedCheckoutProtocolBridgeTest { @Test fun `ec ready response dispatches via window EmbeddedCheckoutProtocol`() { val js = captureEvaluatedJs { - ecp.postMessage(ecReadyMessage()) + ecp.receiveMessage(ecReadyMessage()) } assertThat(js).contains("window.EmbeddedCheckoutProtocol") assertThat(js).contains(".postMessage(") @@ -111,7 +141,7 @@ class EmbeddedCheckoutProtocolBridgeTest { @Test fun `ec ready with non-string delegate values sends invalid params`() { val js = captureEvaluatedJs { - ecp.postMessage( + ecp.receiveMessage( """{"jsonrpc":"2.0","method":"ec.ready","id":"r2","params":{"delegate":["window.open",null,{}]}}""" ) } @@ -123,7 +153,7 @@ class EmbeddedCheckoutProtocolBridgeTest { @Test fun `ec ready omits delegate field when no supported delegations requested`() { val js = captureEvaluatedJs { - ecp.postMessage( + ecp.receiveMessage( """{"jsonrpc":"2.0","method":"ec.ready","id":"r3","params":{"delegate":["fulfillment.address_change"]}}""" ) } @@ -134,7 +164,7 @@ class EmbeddedCheckoutProtocolBridgeTest { @Test fun `ec ready omits delegate field when delegate array is empty`() { val js = captureEvaluatedJs { - ecp.postMessage("""{"jsonrpc":"2.0","method":"ec.ready","id":"r4","params":{"delegate":[]}}""") + ecp.receiveMessage("""{"jsonrpc":"2.0","method":"ec.ready","id":"r4","params":{"delegate":[]}}""") } assertThat(js).doesNotContain("\"delegate\"") assertThat(js).contains("\"status\":\"success\"") @@ -143,7 +173,7 @@ class EmbeddedCheckoutProtocolBridgeTest { @Test fun `ec ready without a delegate key sends invalid params`() { val js = captureEvaluatedJs { - ecp.postMessage("""{"jsonrpc":"2.0","method":"ec.ready","id":"r5","params":{}}""") + ecp.receiveMessage("""{"jsonrpc":"2.0","method":"ec.ready","id":"r5","params":{}}""") } assertThat(js).contains("\"error\"") assertThat(js).contains("-32602") @@ -215,7 +245,7 @@ class EmbeddedCheckoutProtocolBridgeTest { registerFakeBrowserFor("https://example.com") val js = captureEvaluatedJs { - ecp.postMessage(windowOpenRequest(id = "\"7\"", url = "https://example.com")) + ecp.receiveMessage(windowOpenRequest(id = "\"7\"", url = "https://example.com")) } shadowOf(Looper.getMainLooper()).runToEndOfTasks() @@ -229,7 +259,7 @@ class EmbeddedCheckoutProtocolBridgeTest { @Test fun `window open emits UCP rejection when no activity resolves the uri`() { val js = captureEvaluatedJs { - ecp.postMessage(windowOpenRequest(id = "\"42\"", url = "https://nothing-resolves.invalid")) + ecp.receiveMessage(windowOpenRequest(id = "\"42\"", url = "https://nothing-resolves.invalid")) } shadowOf(Looper.getMainLooper()).runToEndOfTasks() @@ -245,7 +275,7 @@ class EmbeddedCheckoutProtocolBridgeTest { ecp.setClient(CheckoutProtocol.Client()) val js = captureEvaluatedJs { - ecp.postMessage(windowOpenRequest(id = "\"8\"", url = "https://example.com")) + ecp.receiveMessage(windowOpenRequest(id = "\"8\"", url = "https://example.com")) } shadowOf(Looper.getMainLooper()).runToEndOfTasks() @@ -263,7 +293,7 @@ class EmbeddedCheckoutProtocolBridgeTest { ecp.setClient(merchantClient) val js = captureEvaluatedJs { - ecp.postMessage(windowOpenRequest(id = "\"8\"", url = "https://example.com")) + ecp.receiveMessage(windowOpenRequest(id = "\"8\"", url = "https://example.com")) } shadowOf(Looper.getMainLooper()).runToEndOfTasks() @@ -283,7 +313,7 @@ class EmbeddedCheckoutProtocolBridgeTest { } ecp.setClient(merchantClient) - ecp.postMessage(windowOpenRequest(id = "\"8\"", url = "https://example.com/promo?id=42")) + ecp.receiveMessage(windowOpenRequest(id = "\"8\"", url = "https://example.com/promo?id=42")) shadowOf(Looper.getMainLooper()).runToEndOfTasks() assertThat(captured).isNotNull() @@ -293,7 +323,7 @@ class EmbeddedCheckoutProtocolBridgeTest { @Test fun `window open emits invalid params when params url is missing`() { val js = captureEvaluatedJs { - ecp.postMessage("""{"jsonrpc":"2.0","method":"ec.window.open_request","id":"9","params":{}}""") + ecp.receiveMessage("""{"jsonrpc":"2.0","method":"ec.window.open_request","id":"9","params":{}}""") } assertThat(js).contains("\"error\"") assertThat(js).contains("-32602") @@ -302,7 +332,7 @@ class EmbeddedCheckoutProtocolBridgeTest { @Test fun `window open emits invalid params when params url is null`() { val js = captureEvaluatedJs { - ecp.postMessage( + ecp.receiveMessage( """{"jsonrpc":"2.0","method":"ec.window.open_request","id":"10","params":{"url":null}}""" ) } @@ -313,7 +343,7 @@ class EmbeddedCheckoutProtocolBridgeTest { @Test fun `window open emits invalid params when params url is not a string`() { val js = captureEvaluatedJs { - ecp.postMessage( + ecp.receiveMessage( """{"jsonrpc":"2.0","method":"ec.window.open_request","id":"11","params":{"url":{}}}""" ) } @@ -324,7 +354,7 @@ class EmbeddedCheckoutProtocolBridgeTest { @Test fun `window open emits UCP rejection when url does not resolve to an activity`() { val js = captureEvaluatedJs { - ecp.postMessage(windowOpenRequest(id = "\"13\"", url = "https://example.com/a b")) + ecp.receiveMessage(windowOpenRequest(id = "\"13\"", url = "https://example.com/a b")) } shadowOf(Looper.getMainLooper()).runToEndOfTasks() @@ -338,7 +368,7 @@ class EmbeddedCheckoutProtocolBridgeTest { registerFakeBrowserFor("https://example.com/a b") val js = captureEvaluatedJs { - ecp.postMessage(windowOpenRequest(id = "\"14\"", url = "https://example.com/a b")) + ecp.receiveMessage(windowOpenRequest(id = "\"14\"", url = "https://example.com/a b")) } shadowOf(Looper.getMainLooper()).runToEndOfTasks() @@ -350,7 +380,7 @@ class EmbeddedCheckoutProtocolBridgeTest { @Test fun `window open rejects a blank url before launching`() { val js = captureEvaluatedJs { - ecp.postMessage(windowOpenRequest(id = "\"15\"", url = " ")) + ecp.receiveMessage(windowOpenRequest(id = "\"15\"", url = " ")) } shadowOf(Looper.getMainLooper()).runToEndOfTasks() @@ -361,7 +391,7 @@ class EmbeddedCheckoutProtocolBridgeTest { @Test fun `window open emits invalid params when params is not an object`() { val js = captureEvaluatedJs { - ecp.postMessage("""{"jsonrpc":"2.0","method":"ec.window.open_request","id":"12","params":[]}""") + ecp.receiveMessage("""{"jsonrpc":"2.0","method":"ec.window.open_request","id":"12","params":[]}""") } assertThat(js).contains("\"error\"") assertThat(js).contains("-32602") @@ -374,7 +404,7 @@ class EmbeddedCheckoutProtocolBridgeTest { ecp.setClient(merchantClient) val js = captureEvaluatedJs { - ecp.postMessage(windowOpenRequest(id = "null", url = "https://example.com")) + ecp.receiveMessage(windowOpenRequest(id = "null", url = "https://example.com")) } assertThat(js).contains("\"id\":null") @@ -399,7 +429,7 @@ class EmbeddedCheckoutProtocolBridgeTest { @Test fun `ec start hides progress bar`() { - ecp.postMessage("""{"jsonrpc":"2.0","method":"ec.start","params":{"checkout":{}}}""") + ecp.receiveMessage("""{"jsonrpc":"2.0","method":"ec.start","params":{"checkout":{}}}""") shadowOf(Looper.getMainLooper()).runToEndOfTasks() verify(mockListener).onCheckoutViewLoadComplete() } @@ -411,7 +441,7 @@ class EmbeddedCheckoutProtocolBridgeTest { .on(CheckoutProtocol.start) { received = true } ecp.setClient(client) - ecp.postMessage(ecStartMessage()) + ecp.receiveMessage(ecStartMessage()) shadowOf(Looper.getMainLooper()).runToEndOfTasks() assertThat(received).isTrue() @@ -421,7 +451,7 @@ class EmbeddedCheckoutProtocolBridgeTest { fun `ec start sends no response to checkout`() { ecp.setClient(CheckoutProtocol.Client()) - ecp.postMessage(ecStartMessage()) + ecp.receiveMessage(ecStartMessage()) shadowOf(Looper.getMainLooper()).runToEndOfTasks() verify(viewSpy, never()).evaluateJavascript(any(), any()) @@ -439,7 +469,7 @@ class EmbeddedCheckoutProtocolBridgeTest { .on(CheckoutProtocol.error) { received = true } ecp.setClient(client) - ecp.postMessage(rawMessage) + ecp.receiveMessage(rawMessage) shadowOf(Looper.getMainLooper()).runToEndOfTasks() assertThat(received).isTrue() @@ -453,7 +483,7 @@ class EmbeddedCheckoutProtocolBridgeTest { .on(CheckoutProtocol.error) { received = true } ecp.setClient(client) - ecp.postMessage(rawMessage) + ecp.receiveMessage(rawMessage) shadowOf(Looper.getMainLooper()).runToEndOfTasks() assertThat(received).isTrue() @@ -466,11 +496,11 @@ class EmbeddedCheckoutProtocolBridgeTest { @Test fun `ec error with unrecoverable severity invalidates cached preload`() { - CheckoutWebView.preload("https://shopify.dev/cart/123", activity) + CheckoutWebView.preload("https://shopify.dev/cart/123", activity, webMessageTransport) shadowOf(Looper.getMainLooper()).runToEndOfTasks() val cachedWebView = CheckoutWebView.cachedPreloadViewForTesting()!! - ecp.postMessage(ecErrorMessage(severity = "unrecoverable")) + ecp.receiveMessage(ecErrorMessage(severity = "unrecoverable")) shadowOf(Looper.getMainLooper()).runToEndOfTasks() assertThat(CheckoutWebView.cachedPreloadViewForTesting()).isNull() @@ -485,7 +515,7 @@ class EmbeddedCheckoutProtocolBridgeTest { .on(CheckoutProtocol.error) { received = true } ecp.setClient(client) - ecp.postMessage(rawMessage) + ecp.receiveMessage(rawMessage) shadowOf(Looper.getMainLooper()).runToEndOfTasks() assertThat(received).isTrue() @@ -499,7 +529,7 @@ class EmbeddedCheckoutProtocolBridgeTest { val rawMessage = ecErrorMessage(severity = "recoverable") ecp.setClient(CheckoutProtocol.Client()) - ecp.postMessage(rawMessage) + ecp.receiveMessage(rawMessage) shadowOf(Looper.getMainLooper()).runToEndOfTasks() verify(mockListener, never()).onCheckoutViewFailedWithError(any()) @@ -510,7 +540,7 @@ class EmbeddedCheckoutProtocolBridgeTest { val rawMessage = ecErrorMessage(severity = "requires_buyer_input") ecp.setClient(CheckoutProtocol.Client()) - ecp.postMessage(rawMessage) + ecp.receiveMessage(rawMessage) shadowOf(Looper.getMainLooper()).runToEndOfTasks() verify(mockListener, never()).onCheckoutViewFailedWithError(any()) @@ -521,7 +551,7 @@ class EmbeddedCheckoutProtocolBridgeTest { val rawMessage = ecErrorMessage(severity = "requires_buyer_review") ecp.setClient(CheckoutProtocol.Client()) - ecp.postMessage(rawMessage) + ecp.receiveMessage(rawMessage) shadowOf(Looper.getMainLooper()).runToEndOfTasks() verify(mockListener, never()).onCheckoutViewFailedWithError(any()) @@ -537,7 +567,7 @@ class EmbeddedCheckoutProtocolBridgeTest { val rawMessage = ecErrorMessageWithMessages(messages) ecp.setClient(CheckoutProtocol.Client()) - ecp.postMessage(rawMessage) + ecp.receiveMessage(rawMessage) shadowOf(Looper.getMainLooper()).runToEndOfTasks() verify(mockListener).onCheckoutViewFailedWithError( @@ -552,7 +582,7 @@ class EmbeddedCheckoutProtocolBridgeTest { .on(CheckoutProtocol.error) { fail("Malformed ec.error should not dispatch") } ecp.setClient(client) - ecp.postMessage(rawMessage) + ecp.receiveMessage(rawMessage) shadowOf(Looper.getMainLooper()).runToEndOfTasks() verify(mockListener, never()).onCheckoutViewFailedWithError(any()) @@ -569,7 +599,7 @@ class EmbeddedCheckoutProtocolBridgeTest { .on(CheckoutProtocol.complete) { received = true } ecp.setClient(client) - ecp.postMessage(ecCompleteMessage()) + ecp.receiveMessage(ecCompleteMessage()) shadowOf(Looper.getMainLooper()).runToEndOfTasks() assertThat(received).isTrue() @@ -577,11 +607,11 @@ class EmbeddedCheckoutProtocolBridgeTest { @Test fun `ec complete invalidates cached preload`() { - CheckoutWebView.preload("https://shopify.dev/cart/123", activity) + CheckoutWebView.preload("https://shopify.dev/cart/123", activity, webMessageTransport) shadowOf(Looper.getMainLooper()).runToEndOfTasks() val cachedWebView = CheckoutWebView.cachedPreloadViewForTesting()!! - ecp.postMessage(ecCompleteMessage()) + ecp.receiveMessage(ecCompleteMessage()) shadowOf(Looper.getMainLooper()).runToEndOfTasks() assertThat(CheckoutWebView.cachedPreloadViewForTesting()).isNull() @@ -607,7 +637,7 @@ class EmbeddedCheckoutProtocolBridgeTest { } ecp.setClient(client) - val js = captureEvaluatedJs { ecp.postMessage(rawMessage) } + val js = captureEvaluatedJs { ecp.receiveMessage(rawMessage) } assertThat(js).contains("window.EmbeddedCheckoutProtocol") assertThat(js).contains(".postMessage(") @@ -621,7 +651,7 @@ class EmbeddedCheckoutProtocolBridgeTest { .on(CheckoutProtocol.messagesChange) { /* no-op */ } ecp.setClient(client) - ecp.postMessage(rawMessage) + ecp.receiveMessage(rawMessage) shadowOf(Looper.getMainLooper()).runToEndOfTasks() verify(viewSpy, never()).evaluateJavascript(any(), any()) @@ -635,7 +665,7 @@ class EmbeddedCheckoutProtocolBridgeTest { @Test fun `unknown request with no client returns method not found`() { val js = captureEvaluatedJs { - ecp.postMessage("""{"jsonrpc":"2.0","method":"unknownMethod","id":"11"}""") + ecp.receiveMessage("""{"jsonrpc":"2.0","method":"unknownMethod","id":"11"}""") } assertThat(js).contains("\"error\"") @@ -647,7 +677,7 @@ class EmbeddedCheckoutProtocolBridgeTest { @Test fun `unknown request with null id returns method not found`() { val js = captureEvaluatedJs { - ecp.postMessage("""{"jsonrpc":"2.0","method":"unknownMethod","id":null}""") + ecp.receiveMessage("""{"jsonrpc":"2.0","method":"unknownMethod","id":null}""") } assertThat(js).contains("\"error\"") @@ -670,7 +700,7 @@ class EmbeddedCheckoutProtocolBridgeTest { @Test fun `malformed JSON sends parse error`() { val js = captureEvaluatedJs { - ecp.postMessage("not valid json {{{") + ecp.receiveMessage("not valid json {{{") } assertThat(js).contains("\"error\"") assertThat(js).contains("-32700") @@ -679,7 +709,7 @@ class EmbeddedCheckoutProtocolBridgeTest { @Test fun `message missing method field sends parse error`() { val js = captureEvaluatedJs { - ecp.postMessage("""{"jsonrpc":"2.0","id":"12"}""") + ecp.receiveMessage("""{"jsonrpc":"2.0","id":"12"}""") } assertThat(js).contains("\"error\"") assertThat(js).contains("-32700") @@ -688,7 +718,7 @@ class EmbeddedCheckoutProtocolBridgeTest { @Test fun `ec ready with non-object params sends invalid params`() { val js = captureEvaluatedJs { - ecp.postMessage("""{"jsonrpc":"2.0","method":"ec.ready","id":"13","params":[]}""") + ecp.receiveMessage("""{"jsonrpc":"2.0","method":"ec.ready","id":"13","params":[]}""") } assertThat(js).contains("\"error\"") assertThat(js).contains("-32602") @@ -698,7 +728,7 @@ class EmbeddedCheckoutProtocolBridgeTest { @Test fun `ec ready with non-array delegate sends invalid params`() { val js = captureEvaluatedJs { - ecp.postMessage("""{"jsonrpc":"2.0","method":"ec.ready","id":"14","params":{"delegate":{}}}""") + ecp.receiveMessage("""{"jsonrpc":"2.0","method":"ec.ready","id":"14","params":{"delegate":{}}}""") } assertThat(js).contains("\"error\"") assertThat(js).contains("-32602") @@ -720,7 +750,7 @@ class EmbeddedCheckoutProtocolBridgeTest { private fun assertIgnoredByBridge(rawMessage: String) { ecp.setClient(CheckoutProtocol.Client()) - ecp.postMessage(rawMessage) + ecp.receiveMessage(rawMessage) shadowOf(Looper.getMainLooper()).runToEndOfTasks() verify(viewSpy, never()).evaluateJavascript(any(), any()) @@ -730,7 +760,7 @@ class EmbeddedCheckoutProtocolBridgeTest { ecp.setClient(CheckoutProtocol.Client()) val js = captureEvaluatedJs { - ecp.postMessage(rawMessage) + ecp.receiveMessage(rawMessage) } assertThat(js).contains("\"error\"") diff --git a/platforms/android/lib/src/test/java/com/shopify/checkoutkit/FakeWebMessageTransport.kt b/platforms/android/lib/src/test/java/com/shopify/checkoutkit/FakeWebMessageTransport.kt new file mode 100644 index 00000000..1ff21239 --- /dev/null +++ b/platforms/android/lib/src/test/java/com/shopify/checkoutkit/FakeWebMessageTransport.kt @@ -0,0 +1,33 @@ +package com.shopify.checkoutkit + +import android.webkit.WebView +import androidx.webkit.WebViewCompat + +internal class FakeWebMessageTransport( + var supported: Boolean = true, +) : WebMessageTransport { + var listener: WebViewCompat.WebMessageListener? = null + private set + var attachCount = 0 + private set + var detachCount = 0 + private set + + override fun attach( + webView: WebView, + jsObjectName: String, + allowedOriginRules: Set, + listener: WebViewCompat.WebMessageListener, + ): Boolean { + attachCount += 1 + if (!supported) return false + + this.listener = listener + return true + } + + override fun detach(webView: WebView, jsObjectName: String) { + detachCount += 1 + listener = null + } +} 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 af8b9ba2..c555f802 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 @@ -6,11 +6,15 @@ import androidx.activity.ComponentActivity; import androidx.annotation.NonNull; +import androidx.webkit.WebViewCompat; +import androidx.webkit.WebViewFeature; import org.junit.After; import org.junit.Before; import org.junit.Test; import org.junit.runner.RunWith; +import org.mockito.MockedStatic; +import org.mockito.Mockito; import org.robolectric.Robolectric; import org.robolectric.RobolectricTestRunner; import org.robolectric.android.controller.ActivityController; @@ -146,7 +150,14 @@ public void canPreloadAndInvalidate() { @Test public void presentReturnsAHandleToAllowDismissingCheckout() { - try (ActivityController controller = Robolectric.buildActivity(ComponentActivity.class)) { + try ( + MockedStatic featureSupport = Mockito.mockStatic(WebViewFeature.class); + MockedStatic webViewCompat = Mockito.mockStatic(WebViewCompat.class); + ActivityController controller = Robolectric.buildActivity(ComponentActivity.class) + ) { + featureSupport.when( + () -> WebViewFeature.isFeatureSupported(WebViewFeature.WEB_MESSAGE_LISTENER) + ).thenReturn(true); ComponentActivity activity = controller.get(); CheckoutHandle checkout = ShopifyCheckoutKit.present( "https://shopify.dev", 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 cb651fb5..592c123b 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 @@ -8,6 +8,9 @@ import org.junit.After import org.junit.Before import org.junit.Test import org.junit.runner.RunWith +import org.mockito.kotlin.argumentCaptor +import org.mockito.kotlin.mock +import org.mockito.kotlin.verify import org.robolectric.Robolectric import org.robolectric.RobolectricTestRunner import org.robolectric.Shadows.shadowOf @@ -18,10 +21,12 @@ import org.robolectric.shadows.ShadowLooper class ShopifyCheckoutKitTest { private lateinit var initialConfiguration: Configuration + private lateinit var webMessageTransport: FakeWebMessageTransport @Before fun setUp() { initialConfiguration = ShopifyCheckoutKit.getConfiguration() + webMessageTransport = FakeWebMessageTransport() CheckoutWebView.clearCache() ShadowLooper.shadowMainLooper().runToEndOfTasks() } @@ -39,6 +44,29 @@ class ShopifyCheckoutKitTest { } } + @Test + fun `present emits unsupported WebView error and returns null`() { + webMessageTransport.supported = false + Robolectric.buildActivity(ComponentActivity::class.java).use { activityController -> + val activity = activityController.get() + val listener = mock() + + val checkout = ShopifyCheckoutKit.present( + "https://shopify.dev", + activity, + listener, + webMessageTransport = webMessageTransport, + ) + + val captor = argumentCaptor() + assertThat(checkout).isNull() + verify(listener).onCheckoutFailed(captor.capture()) + CheckoutExceptionAssert.assertThat(captor.firstValue) + .hasDescription("This Android WebView does not support Shopify Checkout Kit.") + .hasErrorCode("web_view_not_supported") + } + } + @Test fun `present returns null when activity is finishing`() { Robolectric.buildActivity(ComponentActivity::class.java).use { activityController -> @@ -48,7 +76,8 @@ class ShopifyCheckoutKitTest { val checkout = ShopifyCheckoutKit.present( "https://shopify.dev", activity, - noopDefaultCheckoutListener() + noopDefaultCheckoutListener(), + webMessageTransport = webMessageTransport, ) assertThat(checkout).isNull() @@ -63,7 +92,8 @@ class ShopifyCheckoutKitTest { ShopifyCheckoutKit.present( "https://shopify.dev", activity, - noopDefaultCheckoutListener() + noopDefaultCheckoutListener(), + webMessageTransport = webMessageTransport, ) val sheet = ShadowDialog.getLatestDialog() as CheckoutBottomSheet val checkoutWebView = sheet.findViewById(R.id.checkoutKitContainer)!! @@ -83,7 +113,7 @@ class ShopifyCheckoutKitTest { Robolectric.buildActivity(ComponentActivity::class.java).use { activityController -> val activity = activityController.get() - ShopifyCheckoutKit.preload("https://shopify.dev/cart/123", activity) + preload("https://shopify.dev/cart/123", activity) ShadowLooper.shadowMainLooper().runToEndOfTasks() val cachedView = CheckoutWebView.cachedPreloadViewForTesting() @@ -93,13 +123,26 @@ class ShopifyCheckoutKitTest { } } + @Test + fun `preload does nothing when WebView is unsupported`() { + webMessageTransport.supported = false + Robolectric.buildActivity(ComponentActivity::class.java).use { activityController -> + val activity = activityController.get() + + preload("https://shopify.dev/cart/123", activity) + ShadowLooper.shadowMainLooper().runToEndOfTasks() + + assertThat(CheckoutWebView.cachedPreloadViewForTesting()).isNull() + } + } + @Test fun `preload does nothing when activity is finishing`() { Robolectric.buildActivity(ComponentActivity::class.java).use { activityController -> val activity = activityController.get() activity.finish() - ShopifyCheckoutKit.preload("https://shopify.dev/cart/123", activity) + preload("https://shopify.dev/cart/123", activity) ShadowLooper.shadowMainLooper().runToEndOfTasks() assertThat(CheckoutWebView.cachedPreloadViewForTesting()).isNull() @@ -114,7 +157,7 @@ class ShopifyCheckoutKitTest { Robolectric.buildActivity(ComponentActivity::class.java).use { activityController -> val activity = activityController.get() - ShopifyCheckoutKit.preload("https://shopify.dev/cart/123", activity) + preload("https://shopify.dev/cart/123", activity) ShadowLooper.shadowMainLooper().runToEndOfTasks() assertThat(CheckoutWebView.cachedPreloadViewForTesting()).isNull() @@ -125,7 +168,7 @@ class ShopifyCheckoutKitTest { fun `configure clears cached checkout view`() { Robolectric.buildActivity(ComponentActivity::class.java).use { activityController -> val activity = activityController.get() - ShopifyCheckoutKit.preload("https://shopify.dev/cart/123", activity) + preload("https://shopify.dev/cart/123", activity) ShadowLooper.shadowMainLooper().runToEndOfTasks() val cachedView = CheckoutWebView.cachedPreloadViewForTesting()!! @@ -143,7 +186,7 @@ class ShopifyCheckoutKitTest { fun `invalidate clears cached checkout view`() { Robolectric.buildActivity(ComponentActivity::class.java).use { activityController -> val activity = activityController.get() - ShopifyCheckoutKit.preload("https://shopify.dev/cart/123", activity) + preload("https://shopify.dev/cart/123", activity) ShadowLooper.shadowMainLooper().runToEndOfTasks() val cachedView = CheckoutWebView.cachedPreloadViewForTesting()!! @@ -155,6 +198,10 @@ class ShopifyCheckoutKitTest { } } + private fun preload(url: String, activity: ComponentActivity) { + ShopifyCheckoutKit.preload(url, activity, webMessageTransport) + } + private companion object { private const val TEST_SHEET_SIZE = 1000 }