Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion platforms/android/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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.

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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()}"
}

Expand All @@ -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) {
Expand All @@ -78,6 +89,50 @@ internal class CheckoutWebView(context: Context, attributeSet: AttributeSet? = n
}
}

internal fun receiveWebMessage(message: String?, isMainFrame: Boolean = true) {
if (!isMainFrame) {

@kiftio kiftio Jul 10, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is a new check. Some potential for issues if we're relying on any sub-frames to emit message

I don't see why we would be though

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
}
Expand Down Expand Up @@ -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("*")

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Beginning wide-open, but with the option to tighten up in the future.

We could either apply here or add a sourceOrigin check on receipt of each message


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) {
Expand All @@ -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)
}
}
Expand All @@ -206,12 +275,14 @@ internal class CheckoutWebView(context: Context, attributeSet: AttributeSet? = n
return
}

var result: Result<Unit>? = null
val countDownLatch = CountDownLatch(1)
activity.runOnUiThread {
action()
result = runCatching { action() }
countDownLatch.countDown()
}
countDownLatch.await()
result?.getOrThrow()
}

private fun runOnMainThread(action: () -> Unit) {
Expand Down
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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<String, DefaultClientBinding> = mapOf(
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)
}

/**
Expand All @@ -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) }
}

/**
Expand All @@ -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) {
Expand Down
Loading
Loading