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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ apollo-ios-cli

# Android / Gradle
.gradle/
.kotlin/
build/
captures/
.externalNativeBuild
Expand Down
22 changes: 22 additions & 0 deletions dev.yml
Original file line number Diff line number Diff line change
Expand Up @@ -264,6 +264,28 @@ commands:
build:
desc: Build the @shopify/checkout-kit-react-native module
run: cd platforms/react-native && pnpm module build
test:
desc: Run React Native module tests (JS + iOS + Android)
long_desc: |
Runs unit tests across all three React Native targets:
- JS: Jest tests in `platforms/react-native/modules/@shopify/checkout-kit-react-native/tests/`
- iOS: Swift Package tests at `platforms/react-native/modules/@shopify/checkout-kit-react-native/ios/`
- Android: Gradle JVM tests for `:shopify_checkout-kit-react-native` (requires a local Maven publish of `:lib`)
run: |
set -e
cd platforms/react-native && pnpm test
cd modules/@shopify/checkout-kit-react-native/ios && swift test
cd ../../../../sample/android && USE_LOCAL_SDK=1 ./gradlew :shopify_checkout-kit-react-native:test
subcommands:
js:
desc: Run JS unit tests via jest
run: cd platforms/react-native && pnpm test
ios:
desc: Run native iOS unit tests (Swift Package at modules/.../ios)
run: cd platforms/react-native/modules/@shopify/checkout-kit-react-native/ios && swift test
android:
desc: Run native Android unit tests for the RN module (uses local Maven publish of :lib)
run: cd platforms/react-native/sample/android && USE_LOCAL_SDK=1 ./gradlew :shopify_checkout-kit-react-native:test
lint:
desc: Run all React Native lint checks (Swift, module, sample)
aliases: [style]
Expand Down
Original file line number Diff line number Diff line change
@@ -1,16 +1,22 @@
buildscript {
ext.kotlin_version = rootProject.ext.has("kotlinVersion") ? rootProject.ext.get("kotlinVersion") : "2.1.20"

repositories {
google()
mavenCentral()
}

dependencies {
classpath "com.android.tools.build:gradle:8.11.0"
classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version"
classpath "org.jetbrains.kotlin:kotlin-serialization:$kotlin_version"
}
}

apply plugin: "com.android.library"
apply plugin: "com.facebook.react"
apply plugin: "org.jetbrains.kotlin.android"
apply plugin: "org.jetbrains.kotlin.plugin.serialization"

def getExtOrIntegerDefault(name) {
return rootProject.ext.has(name) ? rootProject.ext.get(name) : (project.properties[name]).toInteger()
Expand Down Expand Up @@ -73,8 +79,17 @@ android {
sourceCompatibility JavaVersion.VERSION_1_8
targetCompatibility JavaVersion.VERSION_1_8
}

kotlinOptions {
jvmTarget = "1.8"
}

testOptions {
unitTests.includeAndroidResources = true
}
}


repositories {
mavenLocal()
mavenCentral()
Expand All @@ -97,6 +112,11 @@ dependencies {

implementation(shopifySdkArtifact)
implementation("com.fasterxml.jackson.core:jackson-databind:2.12.5")
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.6.3")
debugImplementation(shopifySdkArtifact)

testImplementation "junit:junit:4.13.2"
testImplementation "org.assertj:assertj-core:3.27.7"
testImplementation "org.robolectric:robolectric:4.16.1"
}

Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,8 @@ targetSdkVersion=35
compileSdkVersion=36
ndkVersion=23.1.7779620
buildToolsVersion = "35.0.0"

# Opt out of the React Native Gradle plugin's JdkConfiguratorUtils, which otherwise
# silently rewrites compileOptions to 17 and pins the Kotlin JVM toolchain to 17 for
# every com.android.library it sees. We mirror :lib's pinned JVM 1.8 contract instead.
react.internal.disableJavaVersionAlignment=true
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
/*
* MIT License
*
* Copyright 2023-present, Shopify Inc.
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.shopify.reactnative.checkoutkit

import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.decodeFromJsonElement
import kotlinx.serialization.json.encodeToJsonElement

/**
* Bridges typed snake_case payloads (per @SerialName annotations on the native models)
* with camelCase JSON expected by JavaScript consumers.
*/
internal object CasingTransform {

private const val CAMEL_TO_SNAKE_BUFFER_PADDING: Int = 4

internal val json: Json = Json { ignoreUnknownKeys = true }

fun snakeToCamel(s: String): String {
if (s.isEmpty() || !s.contains('_')) return s
val builder = StringBuilder(s.length)
var upperNext = false
for (ch in s) {
if (ch == '_') {
upperNext = true
} else if (upperNext) {
builder.append(ch.uppercaseChar())
upperNext = false
} else {
builder.append(ch)
}
}
return builder.toString()
}

fun camelToSnake(s: String): String {
if (s.isEmpty()) return s
val builder = StringBuilder(s.length + CAMEL_TO_SNAKE_BUFFER_PADDING)
for (ch in s) {
if (ch.isUpperCase()) {
builder.append('_').append(ch.lowercaseChar())
} else {
builder.append(ch)
}
}
return builder.toString()
}

fun transformKeys(element: JsonElement, fn: (String) -> String): JsonElement = when (element) {
is JsonObject -> JsonObject(element.entries.associate { (key, value) -> fn(key) to transformKeys(value, fn) })
is JsonArray -> JsonArray(element.map { transformKeys(it, fn) })
else -> element
}

inline fun <reified T> encodeForJS(payload: T): String {
val element = json.encodeToJsonElement(payload)
val transformed = transformKeys(element, ::snakeToCamel)
return json.encodeToString(JsonElement.serializer(), transformed)
}

inline fun <reified T> decodeFromJS(json: String): T {
val element = Json.parseToJsonElement(json)
val transformed = transformKeys(element, ::camelToSnake)
return CasingTransform.json.decodeFromJsonElement(transformed)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
/*
* MIT License
*
* Copyright 2023-present, Shopify Inc.
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.shopify.reactnative.checkoutkit

import kotlinx.serialization.Serializable

@Serializable
internal data class DispatchEnvelope<P>(
val type: String,
val payload: P,
)
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
/*
* MIT License
*
* Copyright 2023-present, Shopify Inc.
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.shopify.reactnative.checkoutkit

import com.shopify.checkoutkit.CheckoutProtocol

fun interface DispatchCallback {
fun invoke(json: String)
}

object ProtocolRelay {

@JvmStatic
fun makeClient(
subscribedMethods: List<String>,
dispatch: DispatchCallback,
): CheckoutProtocol.Client {
var client = CheckoutProtocol.Client()
for (method in subscribedMethods) {
when (method) {
CheckoutProtocol.start.method -> {
client = client.on(CheckoutProtocol.start) { checkout ->
forwardEnvelope(method, checkout, dispatch)
}
}
}
}
return client
}

private inline fun <reified P> forwardEnvelope(
type: String,
payload: P,
dispatch: DispatchCallback,
) {
try {
dispatch.invoke(CasingTransform.encodeForJS(DispatchEnvelope(type, payload)))
} catch (e: Throwable) {
// dispatch failures are swallowed — there is no native consumer for them
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,9 @@ of this software and associated documentation files (the "Software"), to deal
import com.shopify.checkoutkit.NativeShopifyCheckoutKitSpec;
import com.shopify.checkoutkit.*;

import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;

Expand Down Expand Up @@ -82,13 +84,29 @@ public void removeListeners(double count) {
}

@ReactMethod
public void present(String checkoutURL, @Nullable Callback dispatch) {
public void present(String checkoutURL, ReadableArray subscribedMethods, @Nullable Callback dispatch) {
Activity currentActivity = getCurrentActivity();
if (currentActivity instanceof ComponentActivity) {
checkoutEventProcessor = new CustomCheckoutEventProcessor(currentActivity, this.reactContext, dispatch);

List<String> methods = new ArrayList<>();
for (int i = 0; i < subscribedMethods.size(); i++) {
String method = subscribedMethods.getString(i);
if (method != null) {
methods.add(method);
}
}
CheckoutProtocol.Client client = ProtocolRelay.makeClient(
methods,
json -> {
if (dispatch != null) {
dispatch.invoke(json);
}
});

currentActivity.runOnUiThread(() -> {
checkoutSheet = ShopifyCheckoutKit.present(checkoutURL, (ComponentActivity) currentActivity,
checkoutEventProcessor);
checkoutEventProcessor, client);
});
}
}
Expand Down
Loading
Loading