Skip to content
Draft
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
2 changes: 1 addition & 1 deletion Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ let package = Package(
),
.target(
name: "ShopifyAcceleratedCheckouts",
dependencies: ["ShopifyCheckoutKit"],
dependencies: ["ShopifyCheckoutKit", "ShopifyCheckoutProtocol"],
path: "platforms/swift/Sources/ShopifyAcceleratedCheckouts",
resources: [.process("Localizable.xcstrings"), .process("Media.xcassets")]
),
Expand Down
13 changes: 4 additions & 9 deletions platforms/android/lib/api/lib.api
Original file line number Diff line number Diff line change
Expand Up @@ -305,10 +305,6 @@ public final class com/shopify/checkoutkit/Checkout$Companion {
public final fun serializer ()Lkotlinx/serialization/KSerializer;
}

public abstract interface class com/shopify/checkoutkit/CheckoutCommunicationClient {
public abstract fun process (Ljava/lang/String;)Ljava/lang/String;
}

public final class com/shopify/checkoutkit/CheckoutDiscounts {
public static final field Companion Lcom/shopify/checkoutkit/CheckoutDiscounts$Companion;
public fun <init> ()V
Expand Down Expand Up @@ -427,7 +423,7 @@ public abstract interface class com/shopify/checkoutkit/CheckoutListener {
}

public final class com/shopify/checkoutkit/CheckoutPresentation {
public final fun connect (Lcom/shopify/checkoutkit/CheckoutCommunicationClient;)V
public final fun connect (Lcom/shopify/checkoutkit/CheckoutProtocol$Client;)V
public final fun onCancel (Lkotlin/jvm/functions/Function0;)V
public final fun onFail (Lkotlin/jvm/functions/Function1;)V
public final fun onGeolocationPermissionsHidePrompt (Lkotlin/jvm/functions/Function0;)V
Expand All @@ -448,11 +444,10 @@ public final class com/shopify/checkoutkit/CheckoutProtocol {
public final fun getWindowOpen ()Lcom/shopify/checkoutkit/DelegationDescriptor;
}

public final class com/shopify/checkoutkit/CheckoutProtocol$Client : com/shopify/checkoutkit/CheckoutCommunicationClient {
public final class com/shopify/checkoutkit/CheckoutProtocol$Client {
public fun <init> ()V
public final fun on (Lcom/shopify/checkoutkit/DelegationDescriptor;Lkotlin/jvm/functions/Function1;)Lcom/shopify/checkoutkit/CheckoutProtocol$Client;
public final fun on (Lcom/shopify/checkoutkit/NotificationDescriptor;Lkotlin/jvm/functions/Function1;)Lcom/shopify/checkoutkit/CheckoutProtocol$Client;
public fun process (Ljava/lang/String;)Ljava/lang/String;
}

public final class com/shopify/checkoutkit/CheckoutStatus : java/lang/Enum {
Expand Down Expand Up @@ -2502,9 +2497,9 @@ public final class com/shopify/checkoutkit/ShopifyCheckoutKit {
public static final fun invalidate ()V
public static final fun preload (Ljava/lang/String;Landroidx/activity/ComponentActivity;)V
public static final fun present (Ljava/lang/String;Landroidx/activity/ComponentActivity;Lcom/shopify/checkoutkit/DefaultCheckoutListener;)Lcom/shopify/checkoutkit/CheckoutKitDialog;
public static final fun present (Ljava/lang/String;Landroidx/activity/ComponentActivity;Lcom/shopify/checkoutkit/DefaultCheckoutListener;Lcom/shopify/checkoutkit/CheckoutCommunicationClient;)Lcom/shopify/checkoutkit/CheckoutKitDialog;
public static final fun present (Ljava/lang/String;Landroidx/activity/ComponentActivity;Lcom/shopify/checkoutkit/DefaultCheckoutListener;Lcom/shopify/checkoutkit/CheckoutProtocol$Client;)Lcom/shopify/checkoutkit/CheckoutKitDialog;
public static final synthetic fun present (Ljava/lang/String;Landroidx/activity/ComponentActivity;Lkotlin/jvm/functions/Function1;)Lcom/shopify/checkoutkit/CheckoutKitDialog;
public static synthetic fun present$default (Ljava/lang/String;Landroidx/activity/ComponentActivity;Lcom/shopify/checkoutkit/DefaultCheckoutListener;Lcom/shopify/checkoutkit/CheckoutCommunicationClient;ILjava/lang/Object;)Lcom/shopify/checkoutkit/CheckoutKitDialog;
public static synthetic fun present$default (Ljava/lang/String;Landroidx/activity/ComponentActivity;Lcom/shopify/checkoutkit/DefaultCheckoutListener;Lcom/shopify/checkoutkit/CheckoutProtocol$Client;ILjava/lang/Object;)Lcom/shopify/checkoutkit/CheckoutKitDialog;
}

public final class com/shopify/checkoutkit/Signals {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,12 +1,10 @@
package com.shopify.checkoutkit

/**
* Implement this interface to handle Embedded Checkout Protocol (ECP) messages beyond
* the built-in methods handled natively by the SDK.
*
* Register an implementation via [ShopifyCheckoutKit.present].
* Internal bridge abstraction for processing raw Embedded Checkout Protocol (ECP)
* messages after the WebView has filtered them to supported methods.
*/
public interface CheckoutCommunicationClient {
internal interface CheckoutCommunicationClient {
/**
* Process a JSON-RPC 2.0 ECP message from the checkout web page.
*
Expand All @@ -18,5 +16,14 @@ public interface CheckoutCommunicationClient {
* @param message JSON-RPC 2.0 encoded message string
* @return JSON-RPC 2.0 encoded response string, or null to send no response
*/
public fun process(message: String): String?
fun process(message: String): String?
}

internal fun CheckoutProtocol.Client.asCommunicationClient(): CheckoutCommunicationClient =
CheckoutProtocolClientAdapter(this)

private class CheckoutProtocolClientAdapter(
private val client: CheckoutProtocol.Client,
) : CheckoutCommunicationClient {
override fun process(message: String): String? = client.process(message)
}
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ internal class CheckoutDialog(
private val checkoutUrl: String,
private val checkoutListener: CheckoutListener,
context: Context,
private val communicationClient: CheckoutCommunicationClient? = null,
private val communicationClient: CheckoutProtocol.Client? = null,
) : ComponentDialog(context) {

private var presentedCheckoutWebView: CheckoutWebView? = null
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ public class CheckoutPresentation internal constructor() {
internal var onGeolocationPermissionsShowPrompt:
((String, GeolocationPermissions.Callback) -> Unit)? = null
internal var onGeolocationPermissionsHidePrompt: (() -> Unit)? = null
internal var communicationClient: CheckoutCommunicationClient? = null
internal var communicationClient: CheckoutProtocol.Client? = null

/**
* Called when checkout fails.
Expand Down Expand Up @@ -76,7 +76,7 @@ public class CheckoutPresentation internal constructor() {
/**
* Connects a communication client for Embedded Checkout Protocol messages.
*/
public fun connect(client: CheckoutCommunicationClient?) {
public fun connect(client: CheckoutProtocol.Client?) {
communicationClient = client
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ import java.util.concurrent.CountDownLatch
* Entry point for the typed Embedded Checkout Protocol (ECP) client.
*
* Provides static [NotificationDescriptor] instances for every EC notification method,
* plus a fluent [Client] builder that implements [CheckoutCommunicationClient].
* plus a fluent [Client] builder for typed protocol callbacks.
*
* Example usage:
* ```kotlin
Expand Down Expand Up @@ -140,15 +140,15 @@ public object CheckoutProtocol {
internal val json: Json = Json { ignoreUnknownKeys = true }

/**
* A typed, fluent implementation of [CheckoutCommunicationClient].
* A typed, fluent protocol client.
*
* Each [on] call returns a new [Client] instance (value semantics),
* making it safe to share a base configuration across multiple presents.
*/
public class Client private constructor(
private val handlers: Map<String, Handler>,
private val delegations: Map<String, Delegation>,
) : CheckoutCommunicationClient {
) {

public constructor() : this(emptyMap(), emptyMap())

Expand Down Expand Up @@ -185,7 +185,7 @@ public object CheckoutProtocol {
): Client = Client(handlers, delegations + (descriptor.method to Delegation.Typed(descriptor, handler)))

/** Called by [EmbeddedCheckoutProtocol] for every delegated EC message. */
override fun process(message: String): String? =
internal fun process(message: String): String? =
decodeRequest(message)?.let { request ->
val delegation = delegations[request.method]
if (delegation != null) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -38,9 +38,9 @@ internal class CheckoutWebView(context: Context, attributeSet: AttributeSet? = n
this.listener = listener
}

fun setClient(client: CheckoutCommunicationClient?) {
fun setClient(client: CheckoutProtocol.Client?) {
log.d(LOG_TAG, "Setting communication client $client.")
embeddedCheckoutProtocol.setClient(client)
embeddedCheckoutProtocol.setClient(client?.asCommunicationClient())
}

fun markPresented() {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,13 +26,14 @@ internal class EmbeddedCheckoutProtocol(
) {
private val decoder = Json { ignoreUnknownKeys = true }
private val defaultClient: CheckoutProtocol.Client = defaultDelegationClient()
private val defaultCommunicationClient: CheckoutCommunicationClient = defaultClient.asCommunicationClient()
private val defaultClientBindings: Map<String, DefaultClientBinding> = mapOf(
CheckoutProtocol.windowOpen.method to DefaultClientBinding(
client = defaultClient,
client = defaultCommunicationClient,
policy = DefaultClientPolicy.RunIfUnhandled,
),
CheckoutProtocol.error.method to DefaultClientBinding(
client = defaultClient,
client = defaultCommunicationClient,
policy = DefaultClientPolicy.AlwaysRunAfterMerchant,
),
CheckoutProtocol.complete.method to DefaultClientBinding(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -117,7 +117,7 @@ public object ShopifyCheckoutKit {
* @param context The context the checkout is being presented from
* @param checkoutListener provides callbacks to allow clients to listen for and respond to checkout lifecycle events
* (failure, cancellation, permission prompts, file chooser).
* @param communicationClient optional handler for supported Embedded Checkout Protocol (ECP)
* @param communicationClient optional typed handler for supported Embedded Checkout Protocol (ECP)
* messages from the checkout web page. Built-in messages
* (`ec.ready` and [ec.start][CheckoutProtocol.start])
* are handled automatically by the SDK.
Expand All @@ -129,7 +129,7 @@ public object ShopifyCheckoutKit {
checkoutUrl: String,
context: ComponentActivity,
checkoutListener: T,
communicationClient: CheckoutCommunicationClient? = null,
communicationClient: CheckoutProtocol.Client? = null,
): CheckoutKitDialog? {
log.d("ShopifyCheckoutKit", "Present called with checkoutUrl ${checkoutUrl.redactedUrlForLogging()}.")
if (context.isDestroyed || context.isFinishing) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,6 @@ import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.mockito.kotlin.mock
import org.mockito.kotlin.verify
import org.mockito.kotlin.whenever
import org.robolectric.Robolectric
import org.robolectric.RobolectricTestRunner
import org.robolectric.Shadows.shadowOf
Expand Down Expand Up @@ -79,10 +77,10 @@ class CheckoutPresentationTest {
}

@Test
fun `present builder forwards connected client to embedded checkout protocol`() {
val rawMessage = """{"jsonrpc":"2.0","method":"ec.messages.change","params":{"checkout":{}}}"""
val client = mock<CheckoutCommunicationClient>()
whenever(client.process(rawMessage)).thenReturn(null)
fun `present builder dispatches connected protocol client`() {
var received: Checkout? = null
val client = CheckoutProtocol.Client()
.on(CheckoutProtocol.start) { checkout -> received = checkout }

ShopifyCheckoutKit.present("https://shopify.com", activity) {
connect(client)
Expand All @@ -92,10 +90,10 @@ class CheckoutPresentationTest {
val dialog = ShadowDialog.getLatestDialog() as CheckoutDialog
val webView = dialog.currentWebView()

webView.embeddedCheckoutProtocol().postMessage(rawMessage)
webView.embeddedCheckoutProtocol().postMessage(ecStartMessage(currency = "USD"))
shadowOf(Looper.getMainLooper()).runToEndOfTasks()

verify(client).process(rawMessage)
assertThat(received?.currency).isEqualTo("USD")
}

@Test
Expand Down Expand Up @@ -209,4 +207,19 @@ class CheckoutPresentationTest {
field.isAccessible = true
return field.get(this) as EmbeddedCheckoutProtocol
}

private fun ecStartMessage(currency: String): String {
val checkout = """
{
"id":"chk1",
"currency":"$currency",
"status":"incomplete",
"line_items":[],
"totals":[],
"links":[],
"ucp":{"payment_handlers":{},"version":"1.0"}
}
""".trimIndent()
return """{"jsonrpc":"2.0","method":"ec.start","params":{"checkout":$checkout}}"""
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -258,7 +258,7 @@ class EmbeddedCheckoutProtocolTest {
fun `window open falls back to kit default when consumer client has no handler`() {
registerFakeBrowserFor("https://example.com")
// Empty typed client — no .on(CheckoutProtocol.windowOpen) registered.
ecp.setClient(CheckoutProtocol.Client())
ecp.setClient(CheckoutProtocol.Client().asCommunicationClient())

val js = captureEvaluatedJs {
ecp.postMessage(windowOpenRequest(id = "\"8\"", url = "https://example.com"))
Expand All @@ -276,7 +276,7 @@ class EmbeddedCheckoutProtocolTest {
.on(CheckoutProtocol.windowOpen) { _ ->
WindowOpenResult.Rejected(reason = "merchant says no")
}
ecp.setClient(merchantClient)
ecp.setClient(merchantClient.asCommunicationClient())

val js = captureEvaluatedJs {
ecp.postMessage(windowOpenRequest(id = "\"8\"", url = "https://example.com"))
Expand All @@ -297,7 +297,7 @@ class EmbeddedCheckoutProtocolTest {
captured = request
WindowOpenResult.Success
}
ecp.setClient(merchantClient)
ecp.setClient(merchantClient.asCommunicationClient())

ecp.postMessage(windowOpenRequest(id = "\"8\"", url = "https://example.com/promo?id=42"))
shadowOf(Looper.getMainLooper()).runToEndOfTasks()
Expand Down Expand Up @@ -525,7 +525,7 @@ class EmbeddedCheckoutProtocolTest {
val rawMessage = """{"jsonrpc":"2.0","method":"ec.error","params":{"error":{$ERROR_RESPONSE_UCP}}}"""
val client = CheckoutProtocol.Client()
.on(CheckoutProtocol.error) { fail("Malformed ec.error should not dispatch") }
ecp.setClient(client)
ecp.setClient(client.asCommunicationClient())

ecp.postMessage(rawMessage)
shadowOf(Looper.getMainLooper()).runToEndOfTasks()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package com.shopify.reactnative.checkoutkit

import android.os.Looper
import android.util.Log
import com.shopify.checkoutkit.CheckoutProtocol
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import kotlinx.serialization.encodeToString
Expand Down Expand Up @@ -44,7 +45,7 @@ class ProtocolRelayTest {
DispatchCallback { json -> captured = json },
)

client.process(ecStartNotificationFixture)
client.processForTest(ecStartNotificationFixture)
shadowOf(Looper.getMainLooper()).runToEndOfTasks()

val json = captured
Expand Down Expand Up @@ -73,7 +74,7 @@ class ProtocolRelayTest {
DispatchCallback { throw failure },
)

client.process(ecStartNotificationFixture)
client.processForTest(ecStartNotificationFixture)
shadowOf(Looper.getMainLooper()).runToEndOfTasks()

val logs = ShadowLog.getLogsForTag("ShopifyCheckoutKit")
Expand All @@ -100,7 +101,7 @@ class ProtocolRelayTest {
DispatchCallback { json -> captured = json },
)

client.process(checkoutNotificationFixture(method))
client.processForTest(checkoutNotificationFixture(method))
shadowOf(Looper.getMainLooper()).runToEndOfTasks()

val json = captured
Expand All @@ -119,7 +120,7 @@ class ProtocolRelayTest {
DispatchCallback { json -> captured = json },
)

client.process(ecErrorNotificationFixture)
client.processForTest(ecErrorNotificationFixture)
shadowOf(Looper.getMainLooper()).runToEndOfTasks()

val json = captured
Expand All @@ -141,7 +142,7 @@ class ProtocolRelayTest {
DispatchCallback { json -> captured = json },
)

client.process(ecStartNotificationFixture)
client.processForTest(ecStartNotificationFixture)
shadowOf(Looper.getMainLooper()).runToEndOfTasks()

assertThat(captured).isNull()
Expand All @@ -157,7 +158,7 @@ class ProtocolRelayTest {
)

dispatch.release()
client.process(ecStartNotificationFixture)
client.processForTest(ecStartNotificationFixture)
shadowOf(Looper.getMainLooper()).runToEndOfTasks()

assertThat(captured).isNull()
Expand All @@ -170,6 +171,15 @@ private data class SnakePayload(
@SerialName("line_items") val lineItems: List<String>,
)

private fun CheckoutProtocol.Client.processForTest(message: String): String? {
val processMethod = javaClass.declaredMethods.first {
it.name.startsWith("process") &&
it.parameterTypes.contentEquals(arrayOf(String::class.java))
}
processMethod.isAccessible = true
return processMethod.invoke(this, message) as? String
}

private fun checkoutNotificationFixture(method: String) = ecStartNotificationFixture.replace(
"\"method\": \"ec.start\"",
"\"method\": \"$method\"",
Expand Down
Original file line number Diff line number Diff line change
@@ -1,8 +1,6 @@
import Foundation
#if COCOAPODS
import ShopifyCheckoutKit

extension CheckoutProtocol.Client: @retroactive CheckoutCommunicationProtocol {}
#else
import ShopifyCheckoutProtocol
#endif
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
import PassKit
import ShopifyCheckoutKit
#if !COCOAPODS
import ShopifyCheckoutProtocol
#endif
import SwiftUI

/// Render state for AcceleratedCheckoutButtons
Expand Down Expand Up @@ -232,7 +235,7 @@ extension AcceleratedCheckoutButtons {
return newView
}

public func connect(_ client: (any CheckoutCommunicationProtocol)?) -> AcceleratedCheckoutButtons {
public func connect(_ client: CheckoutProtocol.Client?) -> AcceleratedCheckoutButtons {
var newView = self
newView.clientContainer = CheckoutProtocolClientContainer(client)
return newView
Expand Down
Loading
Loading