Skip to content

Commit dee7e16

Browse files
committed
Add ECP support to Android Checkout Kit
1 parent 04ed7d5 commit dee7e16

68 files changed

Lines changed: 10342 additions & 638 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

android/lib/api/lib.api

Lines changed: 3535 additions & 302 deletions
Large diffs are not rendered by default.

android/lib/build.gradle

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ def resolveEnvVarValue(name, defaultValue) {
1616
return rawValue ? rawValue : defaultValue
1717
}
1818

19-
def versionName = resolveEnvVarValue("CHECKOUT_KIT_VERSION", "3.6.0")
19+
def versionName = resolveEnvVarValue("CHECKOUT_KIT_VERSION", "1.0.0")
2020

2121
ext {
2222
app_compat_version = '1.7.1'

android/lib/src/main/java/com/shopify/checkoutkit/BaseWebView.kt

Lines changed: 4 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -57,8 +57,6 @@ internal abstract class BaseWebView(context: Context, attributeSet: AttributeSet
5757

5858
abstract fun getEventProcessor(): CheckoutWebViewEventProcessor
5959
abstract val recoverErrors: Boolean
60-
abstract val variant: String
61-
abstract val cspSchema: String
6260

6361
private fun configureWebView() {
6462
visibility = VISIBLE
@@ -125,11 +123,11 @@ internal abstract class BaseWebView(context: Context, attributeSet: AttributeSet
125123
private fun isOnConfirmationPage(): Boolean = url?.let(Uri::parse).isConfirmationPage()
126124

127125
internal fun userAgentSuffix(): String {
128-
val theme = ShopifyCheckoutKit.configuration.colorScheme.id
129-
val version = ShopifyCheckoutKit.version.split("-").first()
130126
val platform = ShopifyCheckoutKit.configuration.platform
131-
val platformSuffix = if (platform != null) " ${platform.displayName}" else ""
132-
val suffix = "ShopifyCheckoutSDK/$version ($cspSchema;$theme;$variant)$platformSuffix"
127+
val suffix = buildString {
128+
append("ShopifyCheckoutKit/${BuildConfig.SDK_VERSION} android")
129+
if (platform != null) append(" ${platform.displayName}")
130+
}
133131
log.d(LOG_TAG, "Setting User-Agent suffix $suffix")
134132
return suffix
135133
}

android/lib/src/main/java/com/shopify/checkoutkit/CheckoutBridge.kt

Lines changed: 0 additions & 69 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,6 @@
2323
package com.shopify.checkoutkit
2424

2525
import android.webkit.JavascriptInterface
26-
import android.webkit.WebView
2726
import com.shopify.checkoutkit.CheckoutBridge.CheckoutWebOperation.COMPLETED
2827
import com.shopify.checkoutkit.CheckoutBridge.CheckoutWebOperation.ERROR
2928
import com.shopify.checkoutkit.CheckoutBridge.CheckoutWebOperation.MODAL
@@ -33,7 +32,6 @@ import com.shopify.checkoutkit.errorevents.CheckoutErrorDecoder
3332
import com.shopify.checkoutkit.lifecycleevents.CheckoutCompletedEventDecoder
3433
import com.shopify.checkoutkit.pixelevents.PixelEventDecoder
3534
import kotlinx.serialization.Serializable
36-
import kotlinx.serialization.encodeToString
3735
import kotlinx.serialization.json.Json
3836

3937
internal class CheckoutBridge(
@@ -66,11 +64,6 @@ internal class CheckoutBridge(
6664
}
6765
}
6866

69-
sealed class SDKOperation(val key: String) {
70-
data object Presented : SDKOperation("presented")
71-
class Instrumentation(val payload: InstrumentationPayload) : SDKOperation("instrumentation")
72-
}
73-
7467
// Allows Web to postMessages back to the SDK
7568
@Suppress("SwallowedException")
7669
@JavascriptInterface
@@ -137,73 +130,11 @@ internal class CheckoutBridge(
137130
}
138131
}
139132

140-
// Send messages from SDK to Web
141-
@Suppress("SwallowedException")
142-
fun sendMessage(view: WebView, operation: SDKOperation) {
143-
val script = when (operation) {
144-
is SDKOperation.Presented -> {
145-
log.d(LOG_TAG, "Sending presented message to checkout, informing it that the sheet is now visible.")
146-
dispatchMessageTemplate("'${operation.key}'")
147-
}
148-
149-
is SDKOperation.Instrumentation -> {
150-
log.d(LOG_TAG, "Sending instrumentation message to checkout.")
151-
val body = Json.encodeToString(SdkToWebEvent(operation.payload))
152-
dispatchMessageTemplate("'${operation.key}', $body")
153-
}
154-
}
155-
try {
156-
view.evaluateJavascript(script, null)
157-
} catch (e: Exception) {
158-
log.d(LOG_TAG, "Failed to send message to checkout, invoking onCheckoutViewFailedWithError")
159-
onMainThread {
160-
eventProcessor.onCheckoutViewFailedWithError(
161-
CheckoutKitException(
162-
errorDescription = "Failed to send '${operation.key}' message to checkout, some features may not work.",
163-
errorCode = CheckoutKitException.ERROR_SENDING_MESSAGE_TO_CHECKOUT,
164-
isRecoverable = true,
165-
)
166-
)
167-
}
168-
}
169-
}
170-
171133
companion object {
172134
private const val LOG_TAG = "CheckoutBridge"
173-
const val SCHEMA_VERSION_NUMBER: String = "8.1"
174-
175-
private fun dispatchMessageTemplate(body: String) = """|
176-
|if (window.MobileCheckoutSdk && window.MobileCheckoutSdk.dispatchMessage) {
177-
| window.MobileCheckoutSdk.dispatchMessage($body);
178-
|} else {
179-
| window.addEventListener('mobileCheckoutBridgeReady', function () {
180-
| window.MobileCheckoutSdk.dispatchMessage($body);
181-
| }, {passive: true, once: true});
182-
|}
183-
|
184-
""".trimMargin()
185135
}
186136
}
187137

188-
@Serializable
189-
internal data class SdkToWebEvent<T>(
190-
val detail: T
191-
)
192-
193-
@Serializable
194-
internal data class InstrumentationPayload(
195-
val name: String,
196-
val value: Long,
197-
val type: InstrumentationType,
198-
val tags: Map<String, String>
199-
)
200-
201-
@Suppress("EnumNaming", "EnumEntryNameCase")
202-
@Serializable
203-
internal enum class InstrumentationType {
204-
histogram
205-
}
206-
207138
@Serializable
208139
internal data class WebToSdkEvent(
209140
val name: String,
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
/*
2+
* MIT License
3+
*
4+
* Copyright 2023-present, Shopify Inc.
5+
*
6+
* Permission is hereby granted, free of charge, to any person obtaining a copy
7+
* of this software and associated documentation files (the "Software"), to deal
8+
* in the Software without restriction, including without limitation the rights
9+
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
10+
* copies of the Software, and to permit persons to whom the Software is
11+
* furnished to do so, subject to the following conditions:
12+
*
13+
* The above copyright notice and this permission notice shall be included in all
14+
* copies or substantial portions of the Software.
15+
*
16+
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
17+
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
18+
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
19+
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
20+
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
21+
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
22+
*/
23+
package com.shopify.checkoutkit
24+
25+
import android.net.Uri
26+
27+
/**
28+
* Implement this interface to handle Embedded Checkout Protocol (ECP) messages beyond
29+
* the built-in methods handled natively by the SDK.
30+
*
31+
* Register an implementation via [ShopifyCheckoutKit.present].
32+
*/
33+
public interface CheckoutCommunicationClient {
34+
/**
35+
* Process a JSON-RPC 2.0 ECP message from the checkout web page.
36+
*
37+
* Called for all EC notifications (ec.start, ec.error, ec.complete, ec.*.change)
38+
* and any unknown methods. For requests, return a JSON-RPC 2.0 response string;
39+
* for notifications, return null (no response is sent).
40+
*
41+
* @param message JSON-RPC 2.0 encoded message string
42+
* @return JSON-RPC 2.0 encoded response string, or null to send no response
43+
*/
44+
public fun process(message: String): String?
45+
46+
/**
47+
* Called when checkout requests that a URL be opened externally (ec.window.open_request).
48+
*
49+
* @param url the URL checkout wants opened in an external browser or app
50+
* @return true if the URL was handled and displayed externally, false otherwise
51+
*/
52+
public fun openExternalUrl(url: Uri): Boolean
53+
}

android/lib/src/main/java/com/shopify/checkoutkit/CheckoutDialog.kt

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,7 @@ internal class CheckoutDialog(
5555
private val checkoutUrl: String,
5656
private val checkoutEventProcessor: CheckoutEventProcessor,
5757
context: Context,
58+
private val communicationClient: CheckoutCommunicationClient? = null,
5859
) : ComponentDialog(context) {
5960

6061
internal var recoveryAttemptCount = 0
@@ -91,6 +92,8 @@ internal class CheckoutDialog(
9192
checkoutWebView.onResume()
9293
log.d(LOG_TAG, "Setting event processor on WebView.")
9394
checkoutWebView.setEventProcessor(eventProcessor())
95+
log.d(LOG_TAG, "Setting communication client on WebView.")
96+
checkoutWebView.setClient(communicationClient)
9497

9598
val colorScheme = ShopifyCheckoutKit.configuration.colorScheme
9699
log.d(LOG_TAG, "Configured colorScheme $colorScheme")
@@ -124,11 +127,6 @@ internal class CheckoutDialog(
124127
removeWebViewFromContainer()
125128
}
126129

127-
setOnShowListener {
128-
log.d(LOG_TAG, "On show listener invoked, calling WebView notifyPresented.")
129-
checkoutWebView.notifyPresented()
130-
}
131-
132130
log.d(LOG_TAG, "Showing dialog.")
133131
show()
134132
}
Lines changed: 164 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,164 @@
1+
/*
2+
* MIT License
3+
*
4+
* Copyright 2023-present, Shopify Inc.
5+
*
6+
* Permission is hereby granted, free of charge, to any person obtaining a copy
7+
* of this software and associated documentation files (the "Software"), to deal
8+
* in the Software without restriction, including without limitation the rights
9+
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
10+
* copies of the Software, and to permit persons to whom the Software is
11+
* furnished to do so, subject to the following conditions:
12+
*
13+
* The above copyright notice and this permission notice shall be included in all
14+
* copies or substantial portions of the Software.
15+
*
16+
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
17+
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
18+
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
19+
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
20+
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
21+
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
22+
*/
23+
package com.shopify.checkoutkit
24+
25+
import android.net.Uri
26+
import com.shopify.checkoutkit.ShopifyCheckoutKit.log
27+
import kotlinx.serialization.json.Json
28+
import kotlinx.serialization.json.JsonElement
29+
import kotlinx.serialization.json.decodeFromJsonElement
30+
import kotlinx.serialization.json.jsonObject
31+
32+
/**
33+
* Entry point for the typed Embedded Checkout Protocol (ECP) client.
34+
*
35+
* Provides static [NotificationDescriptor] instances for every EC notification method,
36+
* plus a fluent [Client] builder that implements [CheckoutCommunicationClient].
37+
*
38+
* Example usage:
39+
* ```kotlin
40+
* val client = CheckoutProtocol.Client()
41+
* .on(CheckoutProtocol.start) { checkout -> showProgressUI(checkout) }
42+
* .on(CheckoutProtocol.complete) { checkout -> navigateToConfirmation(checkout) }
43+
* .onOpenExternalUrl { uri -> startActivity(Intent(Intent.ACTION_VIEW, uri)); true }
44+
*
45+
* ShopifyCheckoutKit.present(url, activity, eventProcessor, client)
46+
* ```
47+
*/
48+
public object CheckoutProtocol {
49+
50+
public val specVersion: String = "2026.01.23"
51+
52+
// Notifications — checkout carries the full current state
53+
public val start: NotificationDescriptor<Checkout> = checkoutDescriptor("ec.start")
54+
public val complete: NotificationDescriptor<Checkout> = checkoutDescriptor("ec.complete")
55+
public val messagesChange: NotificationDescriptor<Checkout> = checkoutDescriptor("ec.messages.change")
56+
public val lineItemsChange: NotificationDescriptor<Checkout> = checkoutDescriptor("ec.line_items.change")
57+
public val buyerChange: NotificationDescriptor<Checkout> = checkoutDescriptor("ec.buyer.change")
58+
public val paymentChange: NotificationDescriptor<Checkout> = checkoutDescriptor("ec.payment.change")
59+
60+
/** Fires on the initial handshake; payload carries the delegations the page has requested. */
61+
public val ready: NotificationDescriptor<ReadyPayload> = NotificationDescriptor(
62+
method = "ec.ready",
63+
decode = { params ->
64+
val delegate = params?.jsonObject?.get("delegate")
65+
val delegations = delegate?.let {
66+
try { json.decodeFromJsonElement<List<String>>(it) } catch (_: Exception) { emptyList() }
67+
} ?: emptyList()
68+
ReadyPayload(delegations)
69+
}
70+
)
71+
72+
private fun checkoutDescriptor(method: String): NotificationDescriptor<Checkout> =
73+
NotificationDescriptor(
74+
method = method,
75+
decode = { params ->
76+
params?.jsonObject?.get("checkout")?.let {
77+
try { json.decodeFromJsonElement<Checkout>(it) } catch (_: Exception) { null }
78+
}
79+
}
80+
)
81+
82+
internal val json: Json = Json { ignoreUnknownKeys = true }
83+
84+
/**
85+
* A typed, fluent implementation of [CheckoutCommunicationClient].
86+
*
87+
* Each [on] call returns a new [Client] instance (value semantics),
88+
* making it safe to share a base configuration across multiple presents.
89+
*/
90+
public class Client private constructor(
91+
private val handlers: Map<String, HandlerEntry>,
92+
private val urlHandler: ((Uri) -> Boolean)?,
93+
) : CheckoutCommunicationClient {
94+
95+
public constructor() : this(emptyMap(), null)
96+
97+
/**
98+
* Register a handler for an EC notification descriptor.
99+
*
100+
* The handler is invoked on the **main thread** whenever the checkout page
101+
* sends the corresponding notification. Returning from the handler sends
102+
* no response to the page (notifications are fire-and-forget).
103+
*/
104+
public fun <P : Any> on(
105+
descriptor: NotificationDescriptor<P>,
106+
handler: (P) -> Unit,
107+
): Client {
108+
@Suppress("UNCHECKED_CAST")
109+
val entry = HandlerEntry(
110+
decode = descriptor.decode,
111+
invoke = { payload -> (payload as? P)?.let { handler(it) } },
112+
)
113+
return Client(handlers + (descriptor.method to entry), urlHandler)
114+
}
115+
116+
/**
117+
* Register a handler for [ec.window.open_request].
118+
*
119+
* Called on the **main thread** (the SDK uses a latch to dispatch from the
120+
* JavascriptInterface thread). Return `true` if the URL was opened externally,
121+
* `false` to let the SDK report an error back to the page.
122+
*/
123+
public fun onOpenExternalUrl(handler: (Uri) -> Boolean): Client =
124+
Client(handlers, handler)
125+
126+
/** Called by [EmbeddedCheckoutProtocol] for every delegated EC message. */
127+
override fun process(message: String): String? {
128+
try {
129+
val request = json.decodeFromString<EcpRequest>(message)
130+
val entry = handlers[request.method] ?: return null
131+
val payload = entry.decode(request.params) ?: return null
132+
onMainThread { entry.invoke(payload) }
133+
} catch (e: Exception) {
134+
log.d(LOG_TAG, "Error processing ECP message in typed client: $e")
135+
}
136+
return null
137+
}
138+
139+
/** Called by [EmbeddedCheckoutProtocol] on the main thread for [ec.window.open_request]. */
140+
override fun openExternalUrl(url: Uri): Boolean = urlHandler?.invoke(url) ?: false
141+
142+
private companion object {
143+
private const val LOG_TAG = "CheckoutProtocol.Client"
144+
}
145+
}
146+
147+
private class HandlerEntry(
148+
val decode: (JsonElement?) -> Any?,
149+
val invoke: (Any) -> Unit,
150+
)
151+
}
152+
153+
/**
154+
* Describes a typed EC notification handler binding.
155+
*
156+
* Create instances via [CheckoutProtocol] static properties; do not instantiate directly.
157+
*/
158+
public class NotificationDescriptor<P : Any> @PublishedApi internal constructor(
159+
public val method: String,
160+
internal val decode: (JsonElement?) -> P?,
161+
)
162+
163+
/** Payload delivered with the [CheckoutProtocol.ready] notification. */
164+
public data class ReadyPayload(public val delegations: List<String>)

0 commit comments

Comments
 (0)