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(); }; -