diff --git a/modules/@shopify/checkout-sheet-kit/android/build.gradle b/modules/@shopify/checkout-sheet-kit/android/build.gradle
index 652db019..bc7636a9 100644
--- a/modules/@shopify/checkout-sheet-kit/android/build.gradle
+++ b/modules/@shopify/checkout-sheet-kit/android/build.gradle
@@ -100,5 +100,10 @@ dependencies {
implementation("com.shopify:checkout-sheet-kit:${SHOPIFY_CHECKOUT_SDK_VERSION}")
implementation("com.fasterxml.jackson.core:jackson-databind:2.12.5")
debugImplementation("com.shopify:checkout-sheet-kit:${SHOPIFY_CHECKOUT_SDK_VERSION}")
+
+ testImplementation 'junit:junit:4.13.2'
+ testImplementation 'org.mockito:mockito-core:4.11.0'
+ testImplementation 'org.mockito:mockito-inline:5.2.0'
+ testImplementation 'org.assertj:assertj-core:3.27.6'
}
diff --git a/modules/@shopify/checkout-sheet-kit/android/src/main/java/com/shopify/reactnative/checkoutsheetkit/CheckoutEvent.java b/modules/@shopify/checkout-sheet-kit/android/src/main/java/com/shopify/reactnative/checkoutsheetkit/CheckoutEvent.java
new file mode 100644
index 00000000..1f0e8964
--- /dev/null
+++ b/modules/@shopify/checkout-sheet-kit/android/src/main/java/com/shopify/reactnative/checkoutsheetkit/CheckoutEvent.java
@@ -0,0 +1,63 @@
+/*
+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.checkoutsheetkit;
+
+import androidx.annotation.NonNull;
+
+import com.facebook.react.bridge.Arguments;
+import com.facebook.react.bridge.WritableMap;
+import com.facebook.react.uimanager.events.Event;
+
+/**
+ * Can be used to send events back to props of instances of components
+ *
+ * private void sendEvent(String eventName, WritableMap params) {
+ * ReactContext reactContext = this.context.getReactApplicationContext();
+ * int viewId = getId();
+ * EventDispatcher eventDispatcher = UIManagerHelper.getEventDispatcherForReactTag(reactContext, viewId);
+ * int surfaceId = UIManagerHelper.getSurfaceId(reactContext);
+ * eventDispatcher.dispatchEvent(new CheckoutEvent(surfaceId, viewId, eventName, params));
+ * }
+ **/
+public class CheckoutEvent extends Event {
+ private final String eventName;
+ private final WritableMap payload;
+
+ public CheckoutEvent(int surfaceId, int viewId, String eventName, WritableMap payload) {
+ super(surfaceId, viewId);
+ this.eventName = eventName;
+ this.payload = payload;
+ }
+
+ @NonNull
+ @Override
+ public String getEventName() {
+ return eventName;
+ }
+
+ @Override
+ protected WritableMap getEventData() {
+ return payload != null ? payload : Arguments.createMap();
+ }
+}
diff --git a/modules/@shopify/checkout-sheet-kit/android/src/main/java/com/shopify/reactnative/checkoutsheetkit/RCTCheckoutWebView.java b/modules/@shopify/checkout-sheet-kit/android/src/main/java/com/shopify/reactnative/checkoutsheetkit/RCTCheckoutWebView.java
new file mode 100644
index 00000000..512133da
--- /dev/null
+++ b/modules/@shopify/checkout-sheet-kit/android/src/main/java/com/shopify/reactnative/checkoutsheetkit/RCTCheckoutWebView.java
@@ -0,0 +1,361 @@
+/*
+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.checkoutsheetkit;
+
+import android.app.Activity;
+import android.net.Uri;
+import android.os.Handler;
+import android.os.Looper;
+import android.util.AttributeSet;
+import android.util.Log;
+import android.widget.FrameLayout;
+
+import androidx.annotation.NonNull;
+
+import com.facebook.react.bridge.Arguments;
+import com.facebook.react.bridge.ReactContext;
+import com.facebook.react.bridge.WritableMap;
+import com.facebook.react.uimanager.ThemedReactContext;
+import com.facebook.react.uimanager.UIManagerHelper;
+import com.facebook.react.uimanager.events.EventDispatcher;
+
+import com.shopify.checkoutsheetkit.Authentication;
+import com.shopify.checkoutsheetkit.CheckoutException;
+import com.shopify.checkoutsheetkit.DefaultCheckoutEventProcessor;
+import com.shopify.checkoutsheetkit.CheckoutOptions;
+import com.shopify.checkoutsheetkit.CheckoutWebView;
+import com.shopify.checkoutsheetkit.CheckoutWebViewEventProcessor;
+import com.shopify.checkoutsheetkit.HttpException;
+import com.shopify.checkoutsheetkit.CheckoutExpiredException;
+import com.shopify.checkoutsheetkit.ClientException;
+import com.shopify.checkoutsheetkit.ConfigurationException;
+import com.shopify.checkoutsheetkit.CheckoutSheetKitException;
+import com.shopify.checkoutsheetkit.lifecycleevents.CheckoutCompleteEvent;
+import com.shopify.checkoutsheetkit.lifecycleevents.CheckoutStartEvent;
+import com.shopify.checkoutsheetkit.rpc.events.CheckoutAddressChangeStart;
+import com.shopify.checkoutsheetkit.rpc.events.CheckoutAddressChangeStartEvent;
+
+import com.fasterxml.jackson.core.type.TypeReference;
+import com.fasterxml.jackson.databind.ObjectMapper;
+
+import java.util.HashMap;
+import java.util.Map;
+import java.util.Objects;
+
+import kotlin.Unit;
+
+public class RCTCheckoutWebView extends FrameLayout {
+ private static final String TAG = "RCTCheckoutWebView";
+ private final ThemedReactContext context;
+ private final ObjectMapper mapper = new ObjectMapper();
+
+ private CheckoutWebView checkoutWebView;
+ private String checkoutUrl;
+ private String auth;
+ private boolean pendingSetup = false;
+ private CheckoutConfiguration lastConfiguration = null;
+ private final Handler mainHandler = new Handler(Looper.getMainLooper());
+
+ private static class CheckoutConfiguration {
+ private final String url;
+ private final String authToken;
+
+ CheckoutConfiguration(String url, String authToken) {
+ this.url = url;
+ this.authToken = authToken;
+ }
+
+ @Override
+ public boolean equals(Object o) {
+ if (this == o) return true;
+ if (o == null || getClass() != o.getClass()) return false;
+ CheckoutConfiguration that = (CheckoutConfiguration) o;
+ return Objects.equals(url, that.url) && Objects.equals(authToken, that.authToken);
+ }
+
+ @Override
+ public int hashCode() {
+ return Objects.hash(url, authToken);
+ }
+ }
+
+ public RCTCheckoutWebView(ThemedReactContext context) {
+ super(context);
+ this.context = context;
+ }
+
+ public void setCheckoutUrl(String url) {
+ if (Objects.equals(url, checkoutUrl)) {
+ return;
+ }
+
+ checkoutUrl = url;
+
+ if (url == null) {
+ removeCheckout();
+ } else {
+ scheduleSetupIfNeeded();
+ }
+ }
+
+ public void setAuth(String authToken) {
+ if (Objects.equals(authToken, auth)) {
+ return;
+ }
+
+ auth = authToken;
+ scheduleSetupIfNeeded();
+ }
+
+ void scheduleSetupIfNeeded() {
+ if (pendingSetup) {
+ return;
+ }
+
+ pendingSetup = true;
+ mainHandler.post(this::setup);
+ }
+
+ private void setup() {
+ pendingSetup = false;
+
+ if (checkoutUrl == null) {
+ removeCheckout();
+ return;
+ }
+
+ CheckoutConfiguration newConfiguration = new CheckoutConfiguration(checkoutUrl, auth);
+ if (newConfiguration.equals(lastConfiguration)) {
+ return;
+ }
+
+ setupCheckoutWebView(checkoutUrl, newConfiguration);
+ }
+
+ public void setupCheckoutWebView(String url, CheckoutConfiguration configuration) {
+ Log.d(TAG, "setupCheckoutWebView: Setting up new webview for URL: " + url);
+ removeCheckout();
+
+ checkoutWebView = new CheckoutWebView(this.context, null);
+ Log.d(TAG, "setupCheckoutWebView: New CheckoutWebView created");
+
+ CheckoutWebViewEventProcessor webViewEventProcessor = getCheckoutWebViewEventProcessor();
+ checkoutWebView.setEventProcessor(webViewEventProcessor);
+
+ CheckoutOptions options = new CheckoutOptions();
+ if (auth != null && !auth.isEmpty()) {
+ options = new CheckoutOptions(new Authentication.Token(auth));
+ }
+ checkoutWebView.loadCheckout(url, options);
+ checkoutWebView.notifyPresented();
+
+ LayoutParams params = new LayoutParams(
+ LayoutParams.MATCH_PARENT,
+ LayoutParams.MATCH_PARENT
+ );
+ addView(checkoutWebView, params);
+ /// Works around a race condition where onLayout executes before setupCheckoutWebView
+ /// resulting in an empty view being rendered. Cannot move setup to constructor as
+ /// checkoutUrl is undefined until setCheckoutUrl is called by RCTCheckoutWebViewManager
+ /// requestLayout / invalidate were unsuccessful in remedying this
+ checkoutWebView.layout(0, 0, getWidth(), getHeight());
+ lastConfiguration = configuration;
+ }
+
+ @Override
+ protected void onLayout(boolean changed, int left, int top, int right, int bottom) {
+ super.onLayout(changed, left, top, right, bottom);
+ if (checkoutWebView != null) {
+ checkoutWebView.layout(0, 0, right - left, bottom - top);
+ }
+ }
+
+ @NonNull
+ private CheckoutWebViewEventProcessor getCheckoutWebViewEventProcessor() {
+ Activity currentActivity = this.context.getCurrentActivity();
+ InlineCheckoutEventProcessor eventProcessor = new InlineCheckoutEventProcessor(currentActivity);
+
+ return new CheckoutWebViewEventProcessor(
+ eventProcessor,
+ (visible) -> Unit.INSTANCE, // toggleHeader
+ (error) -> Unit.INSTANCE, // closeCheckoutDialogWithError
+ (visibility) -> Unit.INSTANCE, // setProgressBarVisibility
+ (percentage) -> Unit.INSTANCE // updateProgressBarPercentage
+ );
+ }
+
+ void removeCheckout() {
+ Log.d(TAG, "removeCheckout: Called, webview exists: " + (checkoutWebView != null));
+ if (checkoutWebView != null) {
+ Log.d(TAG, "removeCheckout: Destroying webview");
+ removeView(checkoutWebView);
+ checkoutWebView.destroy();
+ checkoutWebView = null;
+ }
+ lastConfiguration = null;
+ }
+
+ public void reload() {
+ if (checkoutWebView != null) {
+ checkoutWebView.reload();
+ } else if (checkoutUrl != null) {
+ // Re-setup if WebView was destroyed but URL is still set
+ CheckoutConfiguration configuration = new CheckoutConfiguration(checkoutUrl, auth);
+ setupCheckoutWebView(checkoutUrl, configuration);
+ }
+ }
+
+ public void respondToEvent(String eventId, String responseData) {
+ Log.d(TAG, "Responding to event: " + eventId + " with data: " + responseData);
+
+ if (checkoutWebView != null) {
+ checkoutWebView.respondToEvent(eventId, responseData);
+ } else {
+ Log.e(TAG, "CheckoutWebView is null when trying to respond to event: " + eventId);
+ }
+ }
+
+ @Override
+ protected void onAttachedToWindow() {
+ super.onAttachedToWindow();
+ Log.d(TAG, "onAttachedToWindow: View attached to window, webview exists: " + (checkoutWebView != null));
+ }
+
+ @Override
+ protected void onDetachedFromWindow() {
+ super.onDetachedFromWindow();
+ Log.d(TAG, "onDetachedFromWindow: View detached from window (not destroying webview), webview exists: " + (checkoutWebView != null));
+ // NOTE: We do NOT destroy the webview here to prevent issues during navigation
+ // The webview will be properly cleaned up in the ViewManager's onDropViewInstance instead
+ }
+
+ private WritableMap serializeToWritableMap(Object event) {
+ Map map = mapper.convertValue(event, new TypeReference<>() {
+ });
+ return Arguments.makeNativeMap(map);
+ }
+
+ private WritableMap buildErrorMap(CheckoutException error) {
+ WritableMap errorMap = Arguments.createMap();
+ errorMap.putString("__typename", getErrorTypeName(error));
+ errorMap.putString("message", error.getErrorDescription());
+ errorMap.putBoolean("recoverable", error.isRecoverable());
+ errorMap.putString("code", error.getErrorCode());
+ if (error instanceof HttpException) {
+ errorMap.putInt("statusCode", ((HttpException) error).getStatusCode());
+ }
+ return errorMap;
+ }
+
+ private String getErrorTypeName(CheckoutException error) {
+ if (error instanceof CheckoutExpiredException) {
+ return "CheckoutExpiredError";
+ } else if (error instanceof ClientException) {
+ return "CheckoutClientError";
+ } else if (error instanceof HttpException) {
+ return "CheckoutHTTPError";
+ } else if (error instanceof ConfigurationException) {
+ return "ConfigurationError";
+ } else if (error instanceof CheckoutSheetKitException) {
+ return "InternalError";
+ } else {
+ return "UnknownError";
+ }
+ }
+
+ private void sendEvent(String eventName, WritableMap params) {
+ ReactContext reactContext = this.context.getReactApplicationContext();
+ int viewId = getId();
+
+ EventDispatcher eventDispatcher = UIManagerHelper.getEventDispatcherForReactTag(reactContext, viewId);
+ if (eventDispatcher == null) {
+ Log.w(TAG, "Cannot send event '" + eventName + "': EventDispatcher not available (viewId=" + viewId + ")");
+ return;
+ }
+
+ int surfaceId = UIManagerHelper.getSurfaceId(reactContext);
+ eventDispatcher.dispatchEvent(new CheckoutEvent(surfaceId, viewId, eventName, params));
+ }
+
+ private class InlineCheckoutEventProcessor extends DefaultCheckoutEventProcessor {
+
+ public InlineCheckoutEventProcessor(android.content.Context context) {
+ super(context);
+ }
+
+ @Override
+ public void onStart(@NonNull CheckoutStartEvent event) {
+ try {
+ WritableMap data = serializeToWritableMap(event);
+ sendEvent("onStart", data);
+ } catch (Exception e) {
+ Log.e(TAG, "Error processing start event", e);
+ }
+ }
+
+ @Override
+ public void onComplete(@NonNull CheckoutCompleteEvent event) {
+ try {
+ WritableMap data = serializeToWritableMap(event);
+ sendEvent("onComplete", data);
+ } catch (Exception e) {
+ Log.e(TAG, "Error processing complete event", e);
+ }
+ }
+
+ @Override
+ public void onFail(@NonNull CheckoutException error) {
+ sendEvent("onError", buildErrorMap(error));
+ }
+
+ @Override
+ public void onCancel() {
+ sendEvent("onCancel", null);
+ }
+
+ @Override
+ public void onAddressChangeStart(@NonNull CheckoutAddressChangeStart event) {
+ try {
+ CheckoutAddressChangeStartEvent params = event.getParams();
+ Map eventData = new HashMap<>();
+
+ eventData.put("id", event.getId());
+ eventData.put("type", "addressChangeStart");
+ eventData.put("addressType", params.getAddressType());
+ eventData.put("cart", params.getCart());
+
+ sendEvent("onAddressChangeStart", serializeToWritableMap(eventData));
+ } catch (Exception e) {
+ Log.e(TAG, "Error processing address change start event", e);
+ }
+ }
+
+ @Override
+ public void onLinkClick(@NonNull Uri uri) {
+ WritableMap params = Arguments.createMap();
+ params.putString("url", uri.toString());
+ sendEvent("onLinkClick", params);
+ }
+ }
+}
diff --git a/modules/@shopify/checkout-sheet-kit/android/src/main/java/com/shopify/reactnative/checkoutsheetkit/RCTCheckoutWebViewManager.java b/modules/@shopify/checkout-sheet-kit/android/src/main/java/com/shopify/reactnative/checkoutsheetkit/RCTCheckoutWebViewManager.java
new file mode 100644
index 00000000..96ac7206
--- /dev/null
+++ b/modules/@shopify/checkout-sheet-kit/android/src/main/java/com/shopify/reactnative/checkoutsheetkit/RCTCheckoutWebViewManager.java
@@ -0,0 +1,110 @@
+/*
+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.checkoutsheetkit;
+
+import android.util.Log;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+
+import com.facebook.react.bridge.ReadableArray;
+import com.facebook.react.uimanager.SimpleViewManager;
+import com.facebook.react.uimanager.ThemedReactContext;
+import com.facebook.react.uimanager.annotations.ReactProp;
+
+import java.util.HashMap;
+import java.util.Map;
+
+public class RCTCheckoutWebViewManager extends SimpleViewManager {
+ private static final String TAG = "RCTCheckoutWebViewManager";
+ private static final String REACT_CLASS = "RCTCheckoutWebView";
+
+ @NonNull
+ @Override
+ public String getName() {
+ return REACT_CLASS;
+ }
+
+ @NonNull
+ @Override
+ protected RCTCheckoutWebView createViewInstance(@NonNull ThemedReactContext context) {
+ return new RCTCheckoutWebView(context);
+ }
+
+ @ReactProp(name = "checkoutUrl")
+ public void setCheckoutUrl(RCTCheckoutWebView view, @Nullable String url) {
+ view.setCheckoutUrl(url);
+ }
+
+ @ReactProp(name = "auth")
+ public void setAuth(RCTCheckoutWebView view, @Nullable String authToken) {
+ view.setAuth(authToken);
+ }
+
+ @Override
+ public void receiveCommand(@NonNull RCTCheckoutWebView view, String commandId, @Nullable ReadableArray args) {
+ switch (commandId) {
+ case "reload":
+ view.reload();
+ break;
+ case "respondToEvent":
+ if (args != null && args.size() >= 2) {
+ String eventId = args.getString(0);
+ String responseData = args.getString(1);
+ view.respondToEvent(eventId, responseData);
+ } else {
+ Log.e(TAG, "respondToEvent command requires eventId and responseData arguments");
+ }
+ break;
+ default:
+ Log.e(TAG, "Unsupported command: " + commandId);
+ }
+ }
+
+ @Override
+ public Map getExportedCustomDirectEventTypeConstants() {
+ Map events = new HashMap<>();
+ events.put("onStart", createEventMap("onStart"));
+ events.put("onError", createEventMap("onError"));
+ events.put("onComplete", createEventMap("onComplete"));
+ events.put("onCancel", createEventMap("onCancel"));
+ events.put("onLinkClick", createEventMap("onLinkClick"));
+ events.put("onAddressChangeStart", createEventMap("onAddressChangeStart"));
+ return events;
+ }
+
+ private Map createEventMap(String eventName) {
+ Map event = new HashMap<>();
+ event.put("registrationName", eventName);
+ return event;
+ }
+
+ @Override
+ public void onDropViewInstance(@NonNull RCTCheckoutWebView view) {
+ Log.d(TAG, "onDropViewInstance: Properly cleaning up CheckoutWebView");
+ // Clean up the webview when React actually unmounts the component
+ view.setCheckoutUrl(null);
+ super.onDropViewInstance(view);
+ }
+}
diff --git a/modules/@shopify/checkout-sheet-kit/android/src/main/java/com/shopify/reactnative/checkoutsheetkit/CustomCheckoutEventProcessor.java b/modules/@shopify/checkout-sheet-kit/android/src/main/java/com/shopify/reactnative/checkoutsheetkit/SheetCheckoutEventProcessor.java
similarity index 77%
rename from modules/@shopify/checkout-sheet-kit/android/src/main/java/com/shopify/reactnative/checkoutsheetkit/CustomCheckoutEventProcessor.java
rename to modules/@shopify/checkout-sheet-kit/android/src/main/java/com/shopify/reactnative/checkoutsheetkit/SheetCheckoutEventProcessor.java
index ffdedcd8..45a3279e 100644
--- a/modules/@shopify/checkout-sheet-kit/android/src/main/java/com/shopify/reactnative/checkoutsheetkit/CustomCheckoutEventProcessor.java
+++ b/modules/@shopify/checkout-sheet-kit/android/src/main/java/com/shopify/reactnative/checkoutsheetkit/SheetCheckoutEventProcessor.java
@@ -30,7 +30,13 @@ of this software and associated documentation files (the "Software"), to deal
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
-import com.shopify.checkoutsheetkit.*;
+import com.shopify.checkoutsheetkit.CheckoutException;
+import com.shopify.checkoutsheetkit.CheckoutExpiredException;
+import com.shopify.checkoutsheetkit.CheckoutSheetKitException;
+import com.shopify.checkoutsheetkit.ClientException;
+import com.shopify.checkoutsheetkit.ConfigurationException;
+import com.shopify.checkoutsheetkit.DefaultCheckoutEventProcessor;
+import com.shopify.checkoutsheetkit.HttpException;
import com.facebook.react.modules.core.DeviceEventManagerModule;
import com.facebook.react.bridge.WritableNativeMap;
import com.facebook.react.bridge.ReactApplicationContext;
@@ -39,11 +45,14 @@ of this software and associated documentation files (the "Software"), to deal
import com.shopify.checkoutsheetkit.rpc.events.CheckoutAddressChangeStart;
import com.shopify.checkoutsheetkit.rpc.events.CheckoutAddressChangeStartEvent;
import com.fasterxml.jackson.databind.ObjectMapper;
+
import java.io.IOException;
import java.util.HashMap;
import java.util.Map;
-public class CustomCheckoutEventProcessor extends DefaultCheckoutEventProcessor {
+public class SheetCheckoutEventProcessor extends DefaultCheckoutEventProcessor {
+ private static final String TAG = "SheetCheckoutEventProcessor";
+
private final ReactApplicationContext reactContext;
private final ObjectMapper mapper = new ObjectMapper();
@@ -52,7 +61,7 @@ public class CustomCheckoutEventProcessor extends DefaultCheckoutEventProcessor
private String geolocationOrigin;
private GeolocationPermissions.Callback geolocationCallback;
- public CustomCheckoutEventProcessor(Context context, ReactApplicationContext reactContext) {
+ public SheetCheckoutEventProcessor(Context context, ReactApplicationContext reactContext) {
super(context);
this.reactContext = reactContext;
}
@@ -72,7 +81,7 @@ public void invokeGeolocationCallback(boolean allow) {
/**
* This method is called when the checkout sheet webpage requests geolocation
* permissions.
- *
+ *
* Since the app needs to request permissions first before granting, we store
* the callback and origin in memory and emit a "geolocationRequest" event to
* the app. The app will then request the necessary geolocation permissions
@@ -82,8 +91,10 @@ public void invokeGeolocationCallback(boolean allow) {
* @param callback - The callback to invoke when the app requests permissions
*/
@Override
- public void onGeolocationPermissionsShowPrompt(@NonNull String origin,
- @NonNull GeolocationPermissions.Callback callback) {
+ public void onGeolocationPermissionsShowPrompt(
+ @NonNull String origin,
+ @NonNull GeolocationPermissions.Callback callback
+ ) {
// Store the callback and origin in memory. The kit will wait for the app to
// request permissions first before granting.
@@ -96,7 +107,7 @@ public void onGeolocationPermissionsShowPrompt(@NonNull String origin,
event.put("origin", origin);
sendEventWithStringData("geolocationRequest", mapper.writeValueAsString(event));
} catch (IOException e) {
- Log.e("ShopifyCheckoutSheetKit", "Error emitting \"geolocationRequest\" event", e);
+ Log.e(TAG, "Error emitting \"geolocationRequest\" event", e);
}
}
@@ -110,12 +121,12 @@ public void onGeolocationPermissionsHidePrompt() {
}
@Override
- public void onFail(CheckoutException checkoutError) {
+ public void onFail(@NonNull CheckoutException checkoutError) {
try {
String data = mapper.writeValueAsString(populateErrorDetails(checkoutError));
sendEventWithStringData("error", data);
} catch (IOException e) {
- Log.e("ShopifyCheckoutSheetKit", "Error processing checkout failed event", e);
+ Log.e(TAG, "Error processing checkout failed event", e);
}
}
@@ -130,7 +141,7 @@ public void onComplete(@NonNull CheckoutCompleteEvent event) {
String data = mapper.writeValueAsString(event);
sendEventWithStringData("complete", data);
} catch (IOException e) {
- Log.e("ShopifyCheckoutSheetKit", "Error processing complete event", e);
+ Log.e(TAG, "Error processing complete event", e);
}
}
@@ -140,7 +151,7 @@ public void onStart(@NonNull CheckoutStartEvent event) {
String data = mapper.writeValueAsString(event);
sendEventWithStringData("start", data);
} catch (IOException e) {
- Log.e("ShopifyCheckoutSheetKit", "Error processing start event", e);
+ Log.e(TAG, "Error processing start event", e);
}
}
@@ -148,11 +159,6 @@ public void onStart(@NonNull CheckoutStartEvent event) {
public void onAddressChangeStart(@NonNull CheckoutAddressChangeStart event) {
try {
CheckoutAddressChangeStartEvent params = event.getParams();
- if (params == null) {
- Log.e("ShopifyCheckoutSheetKit", "Address change event has null params");
- return;
- }
-
Map eventData = new HashMap<>();
eventData.put("id", event.getId());
eventData.put("type", "addressChangeStart");
@@ -162,21 +168,22 @@ public void onAddressChangeStart(@NonNull CheckoutAddressChangeStart event) {
String data = mapper.writeValueAsString(eventData);
sendEventWithStringData("addressChangeStart", data);
} catch (IOException e) {
- Log.e("ShopifyCheckoutSheetKit", "Error processing address change start event", e);
+ Log.e(TAG, "Error processing address change start event", e);
}
}
// Private
- private Map populateErrorDetails(CheckoutException checkoutError) {
- Map errorMap = new HashMap();
- errorMap.put("__typename", getErrorTypeName(checkoutError));
- errorMap.put("message", checkoutError.getErrorDescription());
- errorMap.put("recoverable", checkoutError.isRecoverable());
- errorMap.put("code", checkoutError.getErrorCode());
+ private Map populateErrorDetails(CheckoutException error) {
+ Map errorMap = new HashMap<>(Map.of(
+ "__typename", getErrorTypeName(error),
+ "message", error.getErrorDescription(),
+ "recoverable", error.isRecoverable(),
+ "code", error.getErrorCode()
+ ));
- if (checkoutError instanceof HttpException) {
- errorMap.put("statusCode", ((HttpException) checkoutError).getStatusCode());
+ if (error instanceof HttpException) {
+ errorMap.put("statusCode", ((HttpException) error).getStatusCode());
}
return errorMap;
@@ -200,13 +207,13 @@ private String getErrorTypeName(CheckoutException error) {
private void sendEvent(String eventName, @Nullable WritableNativeMap params) {
reactContext
- .getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter.class)
- .emit(eventName, params);
+ .getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter.class)
+ .emit(eventName, params);
}
private void sendEventWithStringData(String name, String data) {
reactContext
- .getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter.class)
- .emit(name, data);
+ .getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter.class)
+ .emit(name, data);
}
}
diff --git a/modules/@shopify/checkout-sheet-kit/android/src/main/java/com/shopify/reactnative/checkoutsheetkit/ShopifyCheckoutSheetKitModule.java b/modules/@shopify/checkout-sheet-kit/android/src/main/java/com/shopify/reactnative/checkoutsheetkit/ShopifyCheckoutSheetKitModule.java
index 04a69685..aee0500b 100644
--- a/modules/@shopify/checkout-sheet-kit/android/src/main/java/com/shopify/reactnative/checkoutsheetkit/ShopifyCheckoutSheetKitModule.java
+++ b/modules/@shopify/checkout-sheet-kit/android/src/main/java/com/shopify/reactnative/checkoutsheetkit/ShopifyCheckoutSheetKitModule.java
@@ -25,8 +25,10 @@ of this software and associated documentation files (the "Software"), to deal
import android.app.Activity;
import android.content.Context;
+
import androidx.activity.ComponentActivity;
import androidx.annotation.NonNull;
+
import com.facebook.react.bridge.Promise;
import com.facebook.react.bridge.ReactApplicationContext;
import com.facebook.react.bridge.ReactContextBaseJavaModule;
@@ -48,7 +50,7 @@ public class ShopifyCheckoutSheetKitModule extends ReactContextBaseJavaModule {
private CheckoutSheetKitDialog checkoutSheet;
- private CustomCheckoutEventProcessor checkoutEventProcessor;
+ private SheetCheckoutEventProcessor checkoutEventProcessor;
public ShopifyCheckoutSheetKitModule(ReactApplicationContext reactContext) {
super(reactContext);
@@ -88,11 +90,11 @@ public void removeListeners(Integer count) {
public void present(String checkoutURL, ReadableMap options) {
Activity currentActivity = getCurrentActivity();
if (currentActivity instanceof ComponentActivity) {
- checkoutEventProcessor = new CustomCheckoutEventProcessor(currentActivity, this.reactContext);
+ checkoutEventProcessor = new SheetCheckoutEventProcessor(currentActivity, this.reactContext);
CheckoutOptions checkoutOptions = parseCheckoutOptions(options);
currentActivity.runOnUiThread(() -> {
checkoutSheet = ShopifyCheckoutSheetKit.present(checkoutURL, (ComponentActivity) currentActivity,
- checkoutEventProcessor, checkoutOptions);
+ checkoutEventProcessor, checkoutOptions);
});
}
}
@@ -215,7 +217,7 @@ private boolean isValidColorConfig(ReadableMap config) {
return false;
}
- String[] requiredColorKeys = { "backgroundColor", "progressIndicator", "headerTextColor", "headerBackgroundColor" };
+ String[] requiredColorKeys = {"backgroundColor", "progressIndicator", "headerTextColor", "headerBackgroundColor"};
for (String key : requiredColorKeys) {
if (!config.hasKey(key) || config.getString(key) == null || parseColor(config.getString(key)) == null) {
@@ -274,14 +276,14 @@ private Colors createColorsFromConfig(ReadableMap config) {
if (webViewBackground != null && progressIndicator != null && headerFont != null && headerBackground != null) {
return new Colors(
- webViewBackground,
- headerBackground,
- headerFont,
- progressIndicator,
- // Parameter allows passing a custom drawable, we'll just support custom color for now
- null,
- closeButtonColor
- );
+ webViewBackground,
+ headerBackground,
+ headerFont,
+ progressIndicator,
+ // Parameter allows passing a custom drawable, we'll just support custom color for now
+ null,
+ closeButtonColor
+ );
}
return null;
diff --git a/modules/@shopify/checkout-sheet-kit/android/src/main/java/com/shopify/reactnative/checkoutsheetkit/ShopifyCheckoutSheetKitPackage.java b/modules/@shopify/checkout-sheet-kit/android/src/main/java/com/shopify/reactnative/checkoutsheetkit/ShopifyCheckoutSheetKitPackage.java
index 5300fb63..548b0c38 100644
--- a/modules/@shopify/checkout-sheet-kit/android/src/main/java/com/shopify/reactnative/checkoutsheetkit/ShopifyCheckoutSheetKitPackage.java
+++ b/modules/@shopify/checkout-sheet-kit/android/src/main/java/com/shopify/reactnative/checkoutsheetkit/ShopifyCheckoutSheetKitPackage.java
@@ -39,7 +39,9 @@ public class ShopifyCheckoutSheetKitPackage implements ReactPackage {
@NonNull
@Override
public List createViewManagers(@NonNull ReactApplicationContext reactContext) {
- return Collections.emptyList();
+ List managers = new ArrayList<>();
+ managers.add(new RCTCheckoutWebViewManager());
+ return managers;
}
@NonNull
diff --git a/modules/@shopify/checkout-sheet-kit/android/src/test/java/com/shopify/reactnative/checkoutsheetkit/RCTCheckoutWebViewTest.java b/modules/@shopify/checkout-sheet-kit/android/src/test/java/com/shopify/reactnative/checkoutsheetkit/RCTCheckoutWebViewTest.java
new file mode 100644
index 00000000..f8ab3226
--- /dev/null
+++ b/modules/@shopify/checkout-sheet-kit/android/src/test/java/com/shopify/reactnative/checkoutsheetkit/RCTCheckoutWebViewTest.java
@@ -0,0 +1,432 @@
+/*
+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.checkoutsheetkit;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyInt;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.*;
+
+import android.app.Activity;
+import android.os.Handler;
+import android.os.Looper;
+import android.util.Log;
+
+import com.facebook.react.bridge.Arguments;
+import com.facebook.react.bridge.ReactApplicationContext;
+import com.facebook.react.bridge.WritableMap;
+import com.facebook.react.uimanager.ThemedReactContext;
+import com.facebook.react.uimanager.UIManagerHelper;
+import com.facebook.react.uimanager.events.EventDispatcher;
+
+import com.shopify.checkoutsheetkit.CheckoutExpiredException;
+import com.shopify.checkoutsheetkit.CheckoutSheetKitException;
+import com.shopify.checkoutsheetkit.CheckoutWebView;
+import com.shopify.checkoutsheetkit.ClientException;
+import com.shopify.checkoutsheetkit.ConfigurationException;
+import com.shopify.checkoutsheetkit.HttpException;
+
+import org.junit.After;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mockito.ArgumentCaptor;
+import org.mockito.Captor;
+import org.mockito.Mock;
+import org.mockito.MockedStatic;
+import org.mockito.Mockito;
+import org.mockito.junit.MockitoJUnitRunner;
+
+import java.lang.reflect.Field;
+import java.lang.reflect.Method;
+
+@RunWith(MockitoJUnitRunner.Silent.class)
+public class RCTCheckoutWebViewTest {
+ @Mock
+ private ThemedReactContext mockContext;
+ @Mock
+ private ReactApplicationContext mockReactAppContext;
+ @Mock
+ private Activity mockActivity;
+ @Mock
+ private EventDispatcher mockEventDispatcher;
+ @Mock
+ private CheckoutWebView mockCheckoutWebView;
+
+ @Captor
+ private ArgumentCaptor runnableCaptor;
+
+ private RCTCheckoutWebView webView;
+ private MockedStatic mockedLooper;
+ private MockedStatic mockedUIManagerHelper;
+ private MockedStatic mockedArguments;
+ private MockedStatic mockedLog;
+
+ @Before
+ public void setup() {
+ mockedLooper = Mockito.mockStatic(Looper.class);
+ Looper mockMainLooper = mock(Looper.class);
+ mockedLooper.when(Looper::getMainLooper).thenReturn(mockMainLooper);
+
+ mockedUIManagerHelper = Mockito.mockStatic(UIManagerHelper.class);
+ mockedUIManagerHelper.when(() -> UIManagerHelper.getEventDispatcherForReactTag(any(), anyInt()))
+ .thenReturn(mockEventDispatcher);
+ mockedUIManagerHelper.when(() -> UIManagerHelper.getSurfaceId(any(ReactApplicationContext.class)))
+ .thenReturn(1);
+
+ mockedArguments = Mockito.mockStatic(Arguments.class);
+ mockedArguments.when(Arguments::createMap).thenReturn(mock(WritableMap.class));
+
+ mockedLog = Mockito.mockStatic(Log.class);
+ mockedLog.when(() -> Log.d(any(), any())).thenReturn(0);
+ mockedLog.when(() -> Log.e(any(), any())).thenReturn(0);
+ mockedLog.when(() -> Log.e(any(), any(), any())).thenReturn(0);
+ mockedLog.when(() -> Log.w(any(), any(String.class))).thenReturn(0);
+
+ when(mockContext.getCurrentActivity()).thenReturn(mockActivity);
+ when(mockContext.getReactApplicationContext()).thenReturn(mockReactAppContext);
+
+ webView = new RCTCheckoutWebView(mockContext);
+ }
+
+ @After
+ public void tearDown() {
+ mockedLooper.close();
+ mockedUIManagerHelper.close();
+ mockedArguments.close();
+ mockedLog.close();
+ }
+
+ @Test
+ public void testSetCheckoutUrl_withNewUrl_storesUrlAndSchedulesSetup() throws Exception {
+ Handler mockHandler = mock(Handler.class);
+ when(mockHandler.post(any(Runnable.class))).thenReturn(true);
+ setPrivateField(webView, "mainHandler", mockHandler);
+
+ webView.setCheckoutUrl("https://shopify.com/checkout");
+
+ assertThat(getPrivateField(webView, "checkoutUrl")).isEqualTo("https://shopify.com/checkout");
+ verify(mockHandler).post(any(Runnable.class));
+ }
+
+ @Test
+ public void testSetCheckoutUrl_withSameUrl_doesNotScheduleSetup() throws Exception {
+ Handler mockHandler = mock(Handler.class);
+ when(mockHandler.post(any(Runnable.class))).thenReturn(true);
+ setPrivateField(webView, "mainHandler", mockHandler);
+
+ webView.setCheckoutUrl("https://shopify.com/checkout");
+ reset(mockHandler);
+ when(mockHandler.post(any(Runnable.class))).thenReturn(true);
+
+ webView.setCheckoutUrl("https://shopify.com/checkout");
+
+ verify(mockHandler, never()).post(any(Runnable.class));
+ }
+
+ @Test
+ public void testSetCheckoutUrl_withNull_clearsUrlAndLastConfiguration() throws Exception {
+ RCTCheckoutWebView spyWebView = spy(new RCTCheckoutWebView(mockContext));
+ doNothing().when(spyWebView).scheduleSetupIfNeeded();
+ doNothing().when(spyWebView).removeCheckout();
+
+ spyWebView.setCheckoutUrl("https://shopify.com/checkout");
+ spyWebView.setCheckoutUrl(null);
+
+ assertThat(getPrivateField(spyWebView, "checkoutUrl")).isNull();
+ assertThat(getPrivateField(spyWebView, "lastConfiguration")).isNull();
+ }
+
+ @Test
+ public void testSetAuth_withNewToken_schedulesSetup() throws Exception {
+ Handler mockHandler = mock(Handler.class);
+ when(mockHandler.post(any(Runnable.class))).thenReturn(true);
+ setPrivateField(webView, "mainHandler", mockHandler);
+
+ webView.setAuth("new-auth-token");
+
+ verify(mockHandler).post(any(Runnable.class));
+ }
+
+ @Test
+ public void testSetAuth_withSameToken_doesNotScheduleSetup() throws Exception {
+ Handler mockHandler = mock(Handler.class);
+ when(mockHandler.post(any(Runnable.class))).thenReturn(true);
+ setPrivateField(webView, "mainHandler", mockHandler);
+
+ webView.setAuth("existing-token");
+ reset(mockHandler);
+ when(mockHandler.post(any(Runnable.class))).thenReturn(true);
+
+ webView.setAuth("existing-token");
+
+ verify(mockHandler, never()).post(any(Runnable.class));
+ }
+
+ @Test
+ public void testReload_withExistingWebView_reloadsWebView() throws Exception {
+ setPrivateField(webView, "checkoutWebView", mockCheckoutWebView);
+
+ webView.reload();
+
+ verify(mockCheckoutWebView).reload();
+ }
+
+ @Test
+ public void testRespondToEvent_withExistingWebView_delegatesToWebView() throws Exception {
+ setPrivateField(webView, "checkoutWebView", mockCheckoutWebView);
+
+ webView.respondToEvent("event-123", "{\"key\": \"value\"}");
+
+ verify(mockCheckoutWebView).respondToEvent("event-123", "{\"key\": \"value\"}");
+ }
+
+ @Test
+ public void testRespondToEvent_withNullWebView_doesNotThrow() throws Exception {
+ setPrivateField(webView, "checkoutWebView", null);
+
+ webView.respondToEvent("event-123", "{\"key\": \"value\"}");
+
+ verify(mockCheckoutWebView, never()).respondToEvent(any(), any());
+ }
+
+ @Test
+ public void testGetErrorTypeName_forCheckoutExpiredException() throws Exception {
+ CheckoutExpiredException exception = mock(CheckoutExpiredException.class);
+
+ String typeName = invokePrivateMethod(webView, "getErrorTypeName", exception);
+
+ assertThat(typeName).isEqualTo("CheckoutExpiredError");
+ }
+
+ @Test
+ public void testGetErrorTypeName_forClientException() throws Exception {
+ ClientException exception = mock(ClientException.class);
+
+ String typeName = invokePrivateMethod(webView, "getErrorTypeName", exception);
+
+ assertThat(typeName).isEqualTo("CheckoutClientError");
+ }
+
+ @Test
+ public void testGetErrorTypeName_forHttpException() throws Exception {
+ HttpException exception = mock(HttpException.class);
+
+ String typeName = invokePrivateMethod(webView, "getErrorTypeName", exception);
+
+ assertThat(typeName).isEqualTo("CheckoutHTTPError");
+ }
+
+ @Test
+ public void testGetErrorTypeName_forConfigurationException() throws Exception {
+ ConfigurationException exception = mock(ConfigurationException.class);
+
+ String typeName = invokePrivateMethod(webView, "getErrorTypeName", exception);
+
+ assertThat(typeName).isEqualTo("ConfigurationError");
+ }
+
+ @Test
+ public void testGetErrorTypeName_forCheckoutSheetKitException() throws Exception {
+ CheckoutSheetKitException exception = mock(CheckoutSheetKitException.class);
+
+ String typeName = invokePrivateMethod(webView, "getErrorTypeName", exception);
+
+ assertThat(typeName).isEqualTo("InternalError");
+ }
+
+ @Test
+ public void testBuildErrorMap_includesAllRequiredFields() throws Exception {
+ HttpException exception = mock(HttpException.class);
+ when(exception.getErrorDescription()).thenReturn("Not Found");
+ when(exception.getErrorCode()).thenReturn("http_error");
+ when(exception.isRecoverable()).thenReturn(false);
+ when(exception.getStatusCode()).thenReturn(404);
+
+ WritableMap mockMap = mock(WritableMap.class);
+ mockedArguments.when(Arguments::createMap).thenReturn(mockMap);
+
+ invokePrivateMethod(webView, "buildErrorMap", exception);
+
+ verify(mockMap).putString("__typename", "CheckoutHTTPError");
+ verify(mockMap).putString("message", "Not Found");
+ verify(mockMap).putBoolean("recoverable", false);
+ verify(mockMap).putString("code", "http_error");
+ verify(mockMap).putInt("statusCode", 404);
+ }
+
+ @Test
+ public void testBuildErrorMap_forNonHttpException_doesNotIncludeStatusCode() throws Exception {
+ ClientException exception = mock(ClientException.class);
+ when(exception.getErrorDescription()).thenReturn("Client error");
+ when(exception.getErrorCode()).thenReturn("client_error");
+ when(exception.isRecoverable()).thenReturn(true);
+
+ WritableMap mockMap = mock(WritableMap.class);
+ mockedArguments.when(Arguments::createMap).thenReturn(mockMap);
+
+ invokePrivateMethod(webView, "buildErrorMap", exception);
+
+ verify(mockMap).putString("__typename", "CheckoutClientError");
+ verify(mockMap).putString("message", "Client error");
+ verify(mockMap).putBoolean("recoverable", true);
+ verify(mockMap).putString("code", "client_error");
+ verify(mockMap, never()).putInt(eq("statusCode"), anyInt());
+ }
+
+ @Test
+ public void testScheduleSetupIfNeeded_withPendingSetup_doesNotScheduleAgain() throws Exception {
+ Handler mockHandler = mock(Handler.class);
+ when(mockHandler.post(any(Runnable.class))).thenReturn(true);
+ setPrivateField(webView, "mainHandler", mockHandler);
+ setPrivateField(webView, "pendingSetup", true);
+
+ invokePrivateMethod(webView, "scheduleSetupIfNeeded");
+
+ verify(mockHandler, never()).post(any(Runnable.class));
+ }
+
+ @Test
+ public void testScheduleSetupIfNeeded_withoutPendingSetup_schedulesSetup() throws Exception {
+ Handler mockHandler = mock(Handler.class);
+ when(mockHandler.post(any(Runnable.class))).thenReturn(true);
+ setPrivateField(webView, "mainHandler", mockHandler);
+ setPrivateField(webView, "pendingSetup", false);
+
+ invokePrivateMethod(webView, "scheduleSetupIfNeeded");
+
+ verify(mockHandler).post(any(Runnable.class));
+ assertThat(getPrivateField(webView, "pendingSetup")).isEqualTo(true);
+ }
+
+ @Test
+ public void testSetup_alwaysResetsPendingSetupFlag() throws Exception {
+ setPrivateField(webView, "checkoutUrl", null);
+ setPrivateField(webView, "pendingSetup", true);
+
+ invokePrivateMethod(webView, "setup");
+
+ assertThat(getPrivateField(webView, "pendingSetup")).isEqualTo(false);
+ }
+
+ @Test
+ public void testSetCheckoutUrl_withNonNullUrl_callsScheduleSetupIfNeeded() {
+ RCTCheckoutWebView spyWebView = spy(new RCTCheckoutWebView(mockContext));
+ doNothing().when(spyWebView).scheduleSetupIfNeeded();
+
+ spyWebView.setCheckoutUrl("https://shopify.com/checkout");
+
+ verify(spyWebView).scheduleSetupIfNeeded();
+ }
+
+ @Test
+ public void testSetCheckoutUrl_withNullUrl_callsRemoveCheckout() {
+ RCTCheckoutWebView spyWebView = spy(new RCTCheckoutWebView(mockContext));
+ doNothing().when(spyWebView).scheduleSetupIfNeeded();
+ doNothing().when(spyWebView).removeCheckout();
+
+ spyWebView.setCheckoutUrl("https://shopify.com/checkout");
+ spyWebView.setCheckoutUrl(null);
+
+ verify(spyWebView).removeCheckout();
+ }
+
+ @Test
+ public void testSetAuth_callsScheduleSetupIfNeeded() {
+ RCTCheckoutWebView spyWebView = spy(new RCTCheckoutWebView(mockContext));
+ doNothing().when(spyWebView).scheduleSetupIfNeeded();
+
+ spyWebView.setAuth("auth-token");
+
+ verify(spyWebView).scheduleSetupIfNeeded();
+ }
+
+ @Test
+ public void testSetCheckoutUrlAndAuth_batchesToSingleSetup() throws Exception {
+ Handler mockHandler = mock(Handler.class);
+ when(mockHandler.post(any(Runnable.class))).thenReturn(true);
+ setPrivateField(webView, "mainHandler", mockHandler);
+
+ webView.setCheckoutUrl("https://shopify.com/checkout");
+ webView.setAuth("auth-token");
+
+ verify(mockHandler, times(1)).post(any(Runnable.class));
+ }
+
+ private void setPrivateField(Object object, String fieldName, Object value) throws Exception {
+ Field field = object.getClass().getDeclaredField(fieldName);
+ field.setAccessible(true);
+ field.set(object, value);
+ }
+
+ private Object getPrivateField(Object object, String fieldName) throws Exception {
+ Field field = object.getClass().getDeclaredField(fieldName);
+ field.setAccessible(true);
+ return field.get(object);
+ }
+
+ @SuppressWarnings("unchecked")
+ private T invokePrivateMethod(Object object, String methodName, Object... args) throws Exception {
+ Class>[] paramTypes = new Class>[args.length];
+ for (int i = 0; i < args.length; i++) {
+ paramTypes[i] = getParameterType(args[i]);
+ }
+
+ Method method = findMethod(object.getClass(), methodName, paramTypes);
+ method.setAccessible(true);
+ return (T) method.invoke(object, args);
+ }
+
+ private Class> getParameterType(Object arg) {
+ if (arg instanceof CheckoutExpiredException) return CheckoutExpiredException.class;
+ if (arg instanceof ClientException) return ClientException.class;
+ if (arg instanceof HttpException) return HttpException.class;
+ if (arg instanceof ConfigurationException) return ConfigurationException.class;
+ if (arg instanceof CheckoutSheetKitException) return CheckoutSheetKitException.class;
+ if (arg instanceof com.shopify.checkoutsheetkit.CheckoutException) {
+ return com.shopify.checkoutsheetkit.CheckoutException.class;
+ }
+ return arg.getClass();
+ }
+
+ private Method findMethod(Class> clazz, String methodName, Class>[] paramTypes) throws NoSuchMethodException {
+ for (Method method : clazz.getDeclaredMethods()) {
+ if (!method.getName().equals(methodName)) continue;
+
+ Class>[] methodParamTypes = method.getParameterTypes();
+ if (methodParamTypes.length != paramTypes.length) continue;
+
+ boolean matches = true;
+ for (int i = 0; i < paramTypes.length; i++) {
+ if (!methodParamTypes[i].isAssignableFrom(paramTypes[i])) {
+ matches = false;
+ break;
+ }
+ }
+ if (matches) return method;
+ }
+ throw new NoSuchMethodException(methodName);
+ }
+}
diff --git a/modules/@shopify/checkout-sheet-kit/ios/RCTCheckoutWebView.swift b/modules/@shopify/checkout-sheet-kit/ios/RCTCheckoutWebView.swift
index cc39f2b7..52179437 100644
--- a/modules/@shopify/checkout-sheet-kit/ios/RCTCheckoutWebView.swift
+++ b/modules/@shopify/checkout-sheet-kit/ios/RCTCheckoutWebView.swift
@@ -78,7 +78,6 @@ class RCTCheckoutWebView: UIView {
scheduleSetupIfNeeded()
}
}
- @objc var onLoad: RCTDirectEventBlock?
@objc var onStart: RCTBubblingEventBlock?
@objc var onError: RCTBubblingEventBlock?
@objc var onComplete: RCTBubblingEventBlock?
@@ -154,7 +153,6 @@ class RCTCheckoutWebView: UIView {
checkoutWebViewController?.view.frame = bounds
webViewController.notifyPresented()
- onLoad?(["url": url.absoluteString])
if let configuration {
lastConfiguration = configuration
}
diff --git a/modules/@shopify/checkout-sheet-kit/ios/ShopifyCheckoutSheetKit.mm b/modules/@shopify/checkout-sheet-kit/ios/ShopifyCheckoutSheetKit.mm
index d13633e5..873083bb 100644
--- a/modules/@shopify/checkout-sheet-kit/ios/ShopifyCheckoutSheetKit.mm
+++ b/modules/@shopify/checkout-sheet-kit/ios/ShopifyCheckoutSheetKit.mm
@@ -98,11 +98,6 @@ @interface RCT_EXTERN_MODULE (RCTCheckoutWebViewManager, RCTViewManager)
*/
RCT_EXPORT_VIEW_PROPERTY(checkoutOptions, NSDictionary*)
- /**
- * Emitted when the webview loads
- */
- RCT_EXPORT_VIEW_PROPERTY(onLoad, RCTDirectEventBlock)
-
/**
* Emitted when checkout starts
*/
diff --git a/modules/@shopify/checkout-sheet-kit/package.snapshot.json b/modules/@shopify/checkout-sheet-kit/package.snapshot.json
index ba3accb6..ad074165 100644
--- a/modules/@shopify/checkout-sheet-kit/package.snapshot.json
+++ b/modules/@shopify/checkout-sheet-kit/package.snapshot.json
@@ -6,9 +6,13 @@
"android/proguard-rules.pro",
"android/src/main/AndroidManifest.xml",
"android/src/main/AndroidManifestNew.xml",
- "android/src/main/java/com/shopify/reactnative/checkoutsheetkit/CustomCheckoutEventProcessor.java",
+ "android/src/main/java/com/shopify/reactnative/checkoutsheetkit/CheckoutEvent.java",
+ "android/src/main/java/com/shopify/reactnative/checkoutsheetkit/RCTCheckoutWebView.java",
+ "android/src/main/java/com/shopify/reactnative/checkoutsheetkit/RCTCheckoutWebViewManager.java",
+ "android/src/main/java/com/shopify/reactnative/checkoutsheetkit/SheetCheckoutEventProcessor.java",
"android/src/main/java/com/shopify/reactnative/checkoutsheetkit/ShopifyCheckoutSheetKitModule.java",
"android/src/main/java/com/shopify/reactnative/checkoutsheetkit/ShopifyCheckoutSheetKitPackage.java",
+ "android/src/test/java/com/shopify/reactnative/checkoutsheetkit/RCTCheckoutWebViewTest.java",
"ios/AcceleratedCheckoutButtons.swift",
"ios/AcceleratedCheckoutButtons+Extensions.swift",
"ios/RCTCheckoutWebView.swift",
diff --git a/modules/@shopify/checkout-sheet-kit/src/CheckoutEventProvider.tsx b/modules/@shopify/checkout-sheet-kit/src/CheckoutEventProvider.tsx
index ad6fbf96..6694fd7b 100644
--- a/modules/@shopify/checkout-sheet-kit/src/CheckoutEventProvider.tsx
+++ b/modules/@shopify/checkout-sheet-kit/src/CheckoutEventProvider.tsx
@@ -18,7 +18,7 @@ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SO
*/
import React, {createContext, useContext, useRef, useCallback} from 'react';
-import {UIManager, findNodeHandle, Platform} from 'react-native';
+import {UIManager, findNodeHandle} from 'react-native';
interface CheckoutEventContextType {
registerWebView: (webViewRef: React.RefObject) => void;
@@ -57,24 +57,22 @@ export const CheckoutEventProvider = ({
return false;
}
- if (Platform.OS !== 'ios') {
- return false;
- }
-
try {
const handle = findNodeHandle(webViewRef.current.current);
if (!handle) {
return false;
}
+ const viewConfig = UIManager.getViewManagerConfig('RCTCheckoutWebView');
+ const commandId =
+ viewConfig?.Commands?.respondToEvent ?? 'respondToEvent';
+
// Call the native method to respond to the event
// Native side will handle event lookup and validation
- UIManager.dispatchViewManagerCommand(
- handle,
- UIManager.getViewManagerConfig('RCTCheckoutWebView')?.Commands
- ?.respondToEvent ?? 'respondToEvent',
- [eventId, JSON.stringify(response)],
- );
+ UIManager.dispatchViewManagerCommand(handle, commandId, [
+ eventId,
+ JSON.stringify(response),
+ ]);
return true;
} catch (error) {
diff --git a/modules/@shopify/checkout-sheet-kit/src/components/Checkout.tsx b/modules/@shopify/checkout-sheet-kit/src/components/Checkout.tsx
index 0a3d359b..194ff601 100644
--- a/modules/@shopify/checkout-sheet-kit/src/components/Checkout.tsx
+++ b/modules/@shopify/checkout-sheet-kit/src/components/Checkout.tsx
@@ -26,7 +26,6 @@ import React, {
} from 'react';
import {
requireNativeComponent,
- Platform,
UIManager,
findNodeHandle,
} from 'react-native';
@@ -36,7 +35,7 @@ import type {
CheckoutException,
} from '..';
import {useCheckoutEvents} from '../CheckoutEventProvider';
-import type {CheckoutAddressChangeStart, CheckoutPaymentChangeIntent, CheckoutStartEvent} from '../events';
+import type {CheckoutAddressChangeStart, CheckoutStartEvent} from '../events';
export interface CheckoutProps {
/**
@@ -49,11 +48,6 @@ export interface CheckoutProps {
*/
auth?: string;
- /**
- * Called when the webview loads
- */
- onLoad?: (event: {url: string}) => void;
-
/**
* Called when checkout starts, providing the initial cart state
*/
@@ -87,11 +81,6 @@ export interface CheckoutProps {
*/
onAddressChangeStart?: (event: CheckoutAddressChangeStart) => void;
- /**
- * Called when checkout requests a payment method change (e.g., for native payment selector)
- */
- onPaymentChangeIntent?: (event: CheckoutPaymentChangeIntent) => void;
-
/**
* Style for the webview container
*/
@@ -115,23 +104,12 @@ interface NativeCheckoutWebViewProps {
auth?: string;
style?: ViewStyle;
testID?: string;
- onLoad?: (event: {nativeEvent: {url: string}}) => void;
onStart?: (event: {nativeEvent: CheckoutStartEvent}) => void;
onError?: (event: {nativeEvent: CheckoutException}) => void;
onComplete?: (event: {nativeEvent: CheckoutCompleteEvent}) => void;
onCancel?: () => void;
onLinkClick?: (event: {nativeEvent: {url: string}}) => void;
onAddressChangeStart?: (event: {nativeEvent: CheckoutAddressChangeStart}) => void;
- onPaymentChangeIntent?: (event: {
- nativeEvent: {
- id: string;
- type: string;
- currentCard?: {
- last4: string;
- brand: string;
- };
- };
- }) => void;
}
const RCTCheckoutWebView =
@@ -183,14 +161,12 @@ export const Checkout = forwardRef(
{
checkoutUrl,
auth,
- onLoad,
onStart,
onError,
onComplete,
onCancel,
onLinkClick,
onAddressChangeStart,
- onPaymentChangeIntent,
style,
testID,
},
@@ -209,14 +185,6 @@ export const Checkout = forwardRef(
return () => eventContext.unregisterWebView();
}, [eventContext]);
- const handleLoad = useCallback<
- Required['onLoad']
- >(
- event => {
- onLoad?.(event.nativeEvent);
- },
- [onLoad],
- );
const handleStart = useCallback<
Required['onStart']
@@ -271,22 +239,6 @@ export const Checkout = forwardRef(
[onAddressChangeStart],
);
- const handlePaymentChangeIntent = useCallback<
- Required['onPaymentChangeIntent']
- >(
- (event: {
- nativeEvent: {
- id: string;
- type: string;
- currentCard?: {last4: string; brand: string};
- };
- }) => {
- if (!event.nativeEvent) return;
- onPaymentChangeIntent?.(event.nativeEvent);
- },
- [onPaymentChangeIntent],
- );
-
const reload = useCallback(() => {
if (!webViewRef.current) {
return;
@@ -296,23 +248,18 @@ export const Checkout = forwardRef(
return;
}
+ const viewConfig = UIManager.getViewManagerConfig('RCTCheckoutWebView');
+ const commandId = viewConfig?.Commands?.reload ?? 'reload';
+
UIManager.dispatchViewManagerCommand(
handle,
- UIManager.getViewManagerConfig('RCTCheckoutWebView')?.Commands
- ?.reload ?? 1,
+ commandId,
[],
);
}, []);
useImperativeHandle(ref, () => ({reload}), [reload]);
- // Only render on iOS as the native module is iOS-only
- if (Platform.OS !== 'ios') {
- // eslint-disable-next-line no-console
- console.error('Checkout is only available on iOS');
- return null;
- }
-
return (
(
auth={auth}
style={style}
testID={testID}
- onLoad={handleLoad}
onStart={handleStart}
onError={handleError}
onComplete={handleComplete}
onCancel={handleCancel}
onLinkClick={handleLinkClick}
onAddressChangeStart={handleAddressChangeStart}
- onPaymentChangeIntent={handlePaymentChangeIntent}
/>
);
},
diff --git a/sample/android/app/src/test/java/com/shopify/checkoutkitreactnative/ShopifyCheckoutSheetKitModuleTest.java b/sample/android/app/src/test/java/com/shopify/checkoutkitreactnative/ShopifyCheckoutSheetKitModuleTest.java
index e63566f3..89359c87 100644
--- a/sample/android/app/src/test/java/com/shopify/checkoutkitreactnative/ShopifyCheckoutSheetKitModuleTest.java
+++ b/sample/android/app/src/test/java/com/shopify/checkoutkitreactnative/ShopifyCheckoutSheetKitModuleTest.java
@@ -7,16 +7,12 @@
import com.facebook.react.bridge.JavaOnlyMap;
import com.facebook.react.bridge.ReactApplicationContext;
import com.facebook.react.modules.core.DeviceEventManagerModule;
-import com.shopify.checkoutsheetkit.CheckoutException;
import com.shopify.checkoutsheetkit.CheckoutExpiredException;
-import com.shopify.checkoutsheetkit.CheckoutSheetKitException;
import com.shopify.checkoutsheetkit.ClientException;
-import com.shopify.checkoutsheetkit.ConfigurationException;
import com.shopify.checkoutsheetkit.HttpException;
import com.shopify.checkoutsheetkit.ShopifyCheckoutSheetKit;
import com.shopify.checkoutsheetkit.Preloading;
import com.shopify.checkoutsheetkit.ColorScheme;
-import com.shopify.checkoutsheetkit.CheckoutOptions;
import com.shopify.checkoutsheetkit.Authentication;
import com.shopify.checkoutsheetkit.lifecycleevents.CheckoutCompleteEvent;
import com.shopify.checkoutsheetkit.lifecycleevents.CheckoutStartEvent;
@@ -29,7 +25,7 @@
import com.shopify.checkoutsheetkit.rpc.events.CheckoutAddressChangeStart;
import com.shopify.checkoutsheetkit.rpc.events.CheckoutAddressChangeStartEvent;
import com.shopify.reactnative.checkoutsheetkit.ShopifyCheckoutSheetKitModule;
-import com.shopify.reactnative.checkoutsheetkit.CustomCheckoutEventProcessor;
+import com.shopify.reactnative.checkoutsheetkit.SheetCheckoutEventProcessor;
import org.junit.After;
import org.junit.Before;
@@ -48,7 +44,6 @@
import android.content.Context;
import java.util.ArrayList;
-import java.util.List;
@RunWith(MockitoJUnitRunner.class)
public class ShopifyCheckoutSheetKitModuleTest {
@@ -400,7 +395,7 @@ public void testCanSetConfigWithInvalidCloseButtonColor() {
@Test
public void testCanProcessCheckoutCompleteEvents() {
- CustomCheckoutEventProcessor processor = new CustomCheckoutEventProcessor(mockContext, mockReactContext);
+ SheetCheckoutEventProcessor processor = new SheetCheckoutEventProcessor(mockContext, mockReactContext);
Cart cart = buildMinimalCart("cart-123", "100.00", "USD");
@@ -424,7 +419,7 @@ public void testCanProcessCheckoutCompleteEvents() {
@Test
public void testCanProcessCheckoutStartEvents() {
- CustomCheckoutEventProcessor processor = new CustomCheckoutEventProcessor(mockContext, mockReactContext);
+ SheetCheckoutEventProcessor processor = new SheetCheckoutEventProcessor(mockContext, mockReactContext);
Cart cart = buildMinimalCart("cart-456", "75.00", "CAD");
@@ -441,7 +436,7 @@ public void testCanProcessCheckoutStartEvents() {
@Test
public void testCanProcessCheckoutAddressChangeStartEvent() {
- CustomCheckoutEventProcessor processor = new CustomCheckoutEventProcessor(mockContext, mockReactContext);
+ SheetCheckoutEventProcessor processor = new SheetCheckoutEventProcessor(mockContext, mockReactContext);
// Create a mock CheckoutAddressChangeStart event
CheckoutAddressChangeStart addressChangeEvent = mock(CheckoutAddressChangeStart.class);
@@ -471,7 +466,7 @@ public void testCanProcessCheckoutAddressChangeStartEvent() {
@Test
public void testCanProcessCheckoutExpiredErrors() {
- CustomCheckoutEventProcessor processor = new CustomCheckoutEventProcessor(mockContext, mockReactContext);
+ SheetCheckoutEventProcessor processor = new SheetCheckoutEventProcessor(mockContext, mockReactContext);
// Use minimal mocking - just enough to test the processing logic
CheckoutExpiredException mockException = mock(CheckoutExpiredException.class);
@@ -489,7 +484,7 @@ public void testCanProcessCheckoutExpiredErrors() {
@Test
public void testCanProcessClientErrors() {
- CustomCheckoutEventProcessor processor = new CustomCheckoutEventProcessor(mockContext, mockReactContext);
+ SheetCheckoutEventProcessor processor = new SheetCheckoutEventProcessor(mockContext, mockReactContext);
ClientException mockException = mock(ClientException.class);
when(mockException.getErrorDescription()).thenReturn("Customer account required");
@@ -506,7 +501,7 @@ public void testCanProcessClientErrors() {
@Test
public void testCanProcessHttpErrors() {
- CustomCheckoutEventProcessor processor = new CustomCheckoutEventProcessor(mockContext, mockReactContext);
+ SheetCheckoutEventProcessor processor = new SheetCheckoutEventProcessor(mockContext, mockReactContext);
HttpException mockException = mock(HttpException.class);
when(mockException.getErrorDescription()).thenReturn("Not Found");
diff --git a/sample/src/components/BuyNowButton.tsx b/sample/src/components/BuyNowButton.tsx
index 099c58eb..ec94dbb7 100644
--- a/sample/src/components/BuyNowButton.tsx
+++ b/sample/src/components/BuyNowButton.tsx
@@ -1,10 +1,5 @@
import React, {useState} from 'react';
-import {
- Pressable,
- Text,
- ActivityIndicator,
- StyleSheet,
-} from 'react-native';
+import {Pressable, Text, ActivityIndicator, StyleSheet} from 'react-native';
import {useNavigation} from '@react-navigation/native';
import type {NavigationProp} from '@react-navigation/native';
import {useMutation} from '@apollo/client';
diff --git a/sample/src/screens/BuyNow/CheckoutScreen.tsx b/sample/src/screens/BuyNow/CheckoutScreen.tsx
index 2a5e9ba6..73832f6b 100644
--- a/sample/src/screens/BuyNow/CheckoutScreen.tsx
+++ b/sample/src/screens/BuyNow/CheckoutScreen.tsx
@@ -30,7 +30,6 @@ import {
import type {BuyNowStackParamList} from './types';
import {StyleSheet} from 'react-native';
-
// This component represents a screen in the consumers app that
// wraps the shopify Checkout and provides it the auth param
export default function CheckoutScreen(props: {
@@ -40,27 +39,26 @@ export default function CheckoutScreen(props: {
const ref = useRef(null);
const onCheckoutStart = (event: CheckoutStartEvent) => {
- console.log('Start', JSON.stringify(event, null, 2));
+ console.log(' onCheckoutStart: ', event);
};
const onAddressChangeStart = (event: CheckoutAddressChangeStart) => {
+ console.log(' onAddressChangeStart: ', event);
navigation.navigate('Address', {id: event.id});
};
- const onPaymentChangeIntent = (event: {id: string}) => {
- navigation.navigate('Payment', {id: event.id});
- };
-
const onCancel = () => {
+ console.log(' onCancel: ');
navigation.getParent()?.goBack();
};
- const onError = () => {
+ const onError = (error: unknown) => {
+ console.log(' onError: ', error);
ref.current?.reload();
};
const onComplete = (event: CheckoutCompleteEvent) => {
- console.log('Checkout complete', JSON.stringify(event, null, 2));
+ console.log(' onComplete: ', event);
navigation.getParent()?.goBack();
};
@@ -72,7 +70,6 @@ export default function CheckoutScreen(props: {
style={styles.container}
onStart={onCheckoutStart}
onAddressChangeStart={onAddressChangeStart}
- onPaymentChangeIntent={onPaymentChangeIntent}
onCancel={onCancel}
onError={onError}
onComplete={onComplete}
diff --git a/sample/src/services/TokenClient.ts b/sample/src/services/TokenClient.ts
index 255b2eec..a0490954 100644
--- a/sample/src/services/TokenClient.ts
+++ b/sample/src/services/TokenClient.ts
@@ -1,4 +1,5 @@
import Config from 'react-native-config';
+import {Platform} from 'react-native';
/**
* Response from Shopify's access token endpoint
@@ -23,7 +24,10 @@ interface TokenClientConfig {
* Error thrown when token fetching fails
*/
export class TokenClientError extends Error {
- constructor(message: string, public readonly statusCode?: number) {
+ constructor(
+ message: string,
+ public readonly statusCode?: number,
+ ) {
super(message);
this.name = 'TokenClientError';
}
@@ -50,8 +54,8 @@ export class TokenClient {
isConfigured(): boolean {
return Boolean(
this.config.clientId &&
- this.config.clientSecret &&
- this.config.authEndpoint
+ this.config.clientSecret &&
+ this.config.authEndpoint,
);
}
@@ -66,20 +70,27 @@ export class TokenClient {
return undefined;
}
+ const authUrl = this.config.authEndpoint;
+ const requestBody = {
+ client_id: this.config.clientId,
+ client_secret: this.config.clientSecret,
+ grant_type: 'client_credentials',
+ };
+
try {
const controller = new AbortController();
- const timeoutId = setTimeout(() => controller.abort(), this.config.timeoutMs);
+ const timeoutId = setTimeout(
+ () => controller.abort(),
+ this.config.timeoutMs,
+ );
- const response = await fetch(this.config.authEndpoint, {
+ const response = await fetch(authUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
+ 'User-Agent': `ReactNative/${Platform.OS}`,
},
- body: JSON.stringify({
- client_id: this.config.clientId,
- client_secret: this.config.clientSecret,
- grant_type: 'client_credentials',
- }),
+ body: JSON.stringify(requestBody),
signal: controller.signal,
});
@@ -124,11 +135,13 @@ export class TokenClient {
errorMessage = 'Access denied: Check client permissions and scopes';
break;
case 429:
- errorMessage = 'Rate limit exceeded: Too many requests, please try again later';
+ errorMessage =
+ 'Rate limit exceeded: Too many requests, please try again later';
break;
default:
if (response.status >= 500) {
- errorMessage = 'Server error: Authentication service is temporarily unavailable';
+ errorMessage =
+ 'Server error: Authentication service is temporarily unavailable';
}
break;
}
@@ -139,11 +152,15 @@ export class TokenClient {
/**
* Parse the JSON response safely
*/
- private async parseResponse(response: Response): Promise {
+ private async parseResponse(
+ response: Response,
+ ): Promise {
try {
return await response.json();
} catch (jsonError) {
- throw new TokenClientError('Invalid response format: Unable to parse authentication response');
+ throw new TokenClientError(
+ 'Invalid response format: Unable to parse authentication response',
+ );
}
}
@@ -152,7 +169,9 @@ export class TokenClient {
*/
private validateTokenResponse(data: AccessTokenResponse): void {
if (!data.access_token || typeof data.access_token !== 'string') {
- throw new TokenClientError('Invalid response: Missing or invalid access token in response');
+ throw new TokenClientError(
+ 'Invalid response: Missing or invalid access token in response',
+ );
}
}
@@ -160,21 +179,59 @@ export class TokenClient {
* Handle and log errors appropriately
*/
private handleError(error: unknown): void {
- let errorMessage = 'Unknown error occurred while fetching authentication token';
+ let errorMessage =
+ 'Unknown error occurred while fetching authentication token';
+ let errorDetails: any = {};
if (error instanceof TokenClientError) {
errorMessage = error.message;
+ errorDetails.statusCode = error.statusCode;
} else if (error instanceof Error) {
if (error.name === 'AbortError') {
- errorMessage = 'Request timeout: Authentication service took too long to respond';
- } else if (error.message.includes('Network request failed') || error.message.includes('Failed to fetch')) {
- errorMessage = 'Network error: Unable to connect to authentication service';
+ errorMessage =
+ 'Request timeout: Authentication service took too long to respond';
+ errorDetails.timeout = `${this.config.timeoutMs}ms`;
+ } else if (
+ error.message.includes('Network request failed') ||
+ error.message.includes('Failed to fetch')
+ ) {
+ errorMessage =
+ 'Network error: Unable to connect to authentication service';
+ errorDetails.platform = Platform.OS;
+ errorDetails.endpoint = this.config.authEndpoint;
+ errorDetails.possibleCauses = [
+ 'SSL/TLS certificate issues',
+ 'Android emulator network configuration',
+ 'Firewall or proxy blocking the request',
+ 'DNS resolution failure',
+ 'Invalid or unreachable endpoint URL',
+ ];
+
+ if (Platform.OS === 'android') {
+ errorDetails.androidTips = [
+ 'Ensure the auth endpoint is accessible from Android emulator',
+ 'Try using actual device IP instead of localhost/127.0.0.1',
+ 'Check if cleartext traffic is allowed in network_security_config.xml',
+ 'Verify Android emulator has internet access',
+ 'Test with a public test endpoint like https://httpbin.org/post',
+ ];
+ }
} else {
errorMessage = error.message;
}
+ errorDetails.errorName = error.name;
+ errorDetails.errorStack = error.stack;
}
- console.error('TokenClient: Error fetching auth token:', errorMessage, error);
+ console.error(
+ 'TokenClient: Error fetching auth token:',
+ errorMessage,
+ error,
+ );
+ console.error(
+ 'TokenClient: Error details:',
+ JSON.stringify(errorDetails, null, 2),
+ );
}
}
@@ -189,4 +246,3 @@ export const defaultTokenClient = new TokenClient();
export const fetchToken = (): Promise => {
return defaultTokenClient.fetchToken();
};
-