From 644b82a1b21d541927903d4cbe0d13b26dd3ae09 Mon Sep 17 00:00:00 2001 From: Mark Murray Date: Sat, 7 Dec 2024 19:32:07 +0000 Subject: [PATCH 1/7] Autogenerate manifest from env, update intents --- sample/scripts/generate_manifest | 33 ++++++++++++++++++++++++++++++++ sample/scripts/prestart | 5 +++++ 2 files changed, 38 insertions(+) create mode 100755 sample/scripts/generate_manifest create mode 100755 sample/scripts/prestart diff --git a/sample/scripts/generate_manifest b/sample/scripts/generate_manifest new file mode 100755 index 00000000..62d4486d --- /dev/null +++ b/sample/scripts/generate_manifest @@ -0,0 +1,33 @@ +#!/usr/bin/env bash + +set -e + +DIR=android/app/src/main +MANIFEST=AndroidManifest.xml + +# Ensure env exists +if [ ! -f .env ]; then + echo "sample/.env file not found!" >&2 + exit 1 +fi + +# Read env file +source .env + +# Ensure STOREFRONT_DOMAIN is set +if [ -z "$STOREFRONT_DOMAIN" ]; then + echo "Error: STOREFRONT_DOMAIN is missing from .env" >&2 + exit 1 +fi + +# Check if manifest already exists +if [ -f "$DIR/$MANIFEST" ]; then + echo "✅ \"$MANIFEST\" already exists. Skipping template replacement." + exit 0 +else + cp "$DIR/$MANIFEST.template" "$DIR/$MANIFEST" || { echo "Failed to copy manifest template" >&2; exit 1; } + + # Replace STOREFRONT_DOMAIN value with the value from .env + sed -i '' "s|{STOREFRONT_DOMAIN}|$STOREFRONT_DOMAIN|g" "$DIR/$MANIFEST" || { echo "Failed to generate $MANIFEST from template" >&2; exit 1; } + echo "✅ Generated \"$DIR/$MANIFEST\"" +fi diff --git a/sample/scripts/prestart b/sample/scripts/prestart new file mode 100755 index 00000000..3af49835 --- /dev/null +++ b/sample/scripts/prestart @@ -0,0 +1,5 @@ +#!/usr/bin/env bash + +set -e + +./scripts/generate_manifest From 2c068ff32011bc3396f0cadef7cf4701da40805f Mon Sep 17 00:00:00 2001 From: Mark Murray Date: Sat, 7 Dec 2024 19:52:25 +0000 Subject: [PATCH 2/7] Upgrade Android kit --- .../android/gradle.properties | 2 +- .../CustomCheckoutEventProcessor.java | 17 +++++++++++++++++ sample/android/gradle.properties | 2 +- 3 files changed, 19 insertions(+), 2 deletions(-) diff --git a/modules/@shopify/checkout-sheet-kit/android/gradle.properties b/modules/@shopify/checkout-sheet-kit/android/gradle.properties index 250e0718..e1758975 100644 --- a/modules/@shopify/checkout-sheet-kit/android/gradle.properties +++ b/modules/@shopify/checkout-sheet-kit/android/gradle.properties @@ -5,4 +5,4 @@ ndkVersion=23.1.7779620 buildToolsVersion = "33.0.0" # Version of Shopify Checkout SDK to use with React Native -SHOPIFY_CHECKOUT_SDK_VERSION=3.2.2 +SHOPIFY_CHECKOUT_SDK_VERSION=3.3.0 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/CustomCheckoutEventProcessor.java index 0d5d8e0d..1d3fa191 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/CustomCheckoutEventProcessor.java @@ -25,6 +25,8 @@ of this software and associated documentation files (the "Software"), to deal import android.content.Context; import android.util.Log; +import android.webkit.GeolocationPermissions; + import androidx.annotation.NonNull; import androidx.annotation.Nullable; @@ -50,6 +52,21 @@ public CustomCheckoutEventProcessor(Context context, ReactApplicationContext rea this.reactContext = reactContext; } + @Override + public void onGeolocationPermissionsShowPrompt(@NonNull String origin, @NonNull GeolocationPermissions.Callback callback) { + try { + String data = mapper.writeValueAsString(origin); + sendEventWithStringData("geolocation_permissions_prompt", data); + } catch (IOException e) { + Log.e("ShopifyCheckoutSheetKit", "Error processing onGeolocationPermissionsShowPrompt", e); + } + } + + @Override + public void onGeolocationPermissionsHidePrompt() { + super.onGeolocationPermissionsHidePrompt(); + } + @Override public void onCheckoutCompleted(@NonNull CheckoutCompletedEvent event) { try { diff --git a/sample/android/gradle.properties b/sample/android/gradle.properties index 79a3c35b..796f3a91 100644 --- a/sample/android/gradle.properties +++ b/sample/android/gradle.properties @@ -41,4 +41,4 @@ newArchEnabled=false hermesEnabled=true # Note: only used here for testing -SHOPIFY_CHECKOUT_SDK_VERSION=3.2.2 +SHOPIFY_CHECKOUT_SDK_VERSION=3.3.0 From f037c3a61b88c9642bfbf991ad652b1a03f7dce8 Mon Sep 17 00:00:00 2001 From: Mark Murray Date: Tue, 17 Dec 2024 17:02:41 +0000 Subject: [PATCH 3/7] WIP - Handle geolocation requests --- README.md | 4 +- .../CustomCheckoutEventProcessor.java | 80 ++++++--- .../ShopifyCheckoutSheetKitModule.java | 95 ++++++----- .../checkout-sheet-kit/src/context.tsx | 7 +- .../checkout-sheet-kit/src/index.d.ts | 31 +++- .../@shopify/checkout-sheet-kit/src/index.ts | 156 +++++++++++++++++- .../checkout-sheet-kit/tests/index.test.ts | 3 + sample/scripts/generate_manifest | 33 ---- sample/scripts/prestart | 5 - 9 files changed, 301 insertions(+), 113 deletions(-) delete mode 100755 sample/scripts/generate_manifest delete mode 100755 sample/scripts/prestart diff --git a/README.md b/README.md index dda44dd8..7fb5a877 100644 --- a/README.md +++ b/README.md @@ -144,8 +144,8 @@ function App() { } ``` -See [Usage with the Storefront API](#usage-with-the-storefront-api) below on how -to get a checkout URL to pass to the kit. +See [usage with the Storefront API](#usage-with-the-storefront-api) below for details on how +to obtain a checkout URL to pass to the kit. > [!NOTE] > The recommended usage of the library is through a 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/CustomCheckoutEventProcessor.java index 1d3fa191..6cd9da65 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/CustomCheckoutEventProcessor.java @@ -43,22 +43,58 @@ of this software and associated documentation files (the "Software"), to deal public class CustomCheckoutEventProcessor extends DefaultCheckoutEventProcessor { private final ReactApplicationContext reactContext; - private final ObjectMapper mapper = new ObjectMapper(); + // Geolocation-specific variables + + private String geolocationOrigin; + private GeolocationPermissions.Callback geolocationCallback; + private boolean retainGeolocationForFutureRequests = true; + public CustomCheckoutEventProcessor(Context context, ReactApplicationContext reactContext) { super(context); - this.reactContext = reactContext; } + // Public methods + + public void invokeGeolocationCallback(boolean allow) { + if (geolocationCallback != null) { + geolocationCallback.invoke(geolocationOrigin, allow, retainGeolocationForFutureRequests); + geolocationCallback = null; + } + } + + // Lifecycle events + + /** + * 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 + * and invoke the native callback with the result. + * + * @param origin - The origin of the request + * @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. + this.geolocationCallback = callback; + this.geolocationOrigin = origin; + + // Emit a "geolocationRequest" event to the app. try { - String data = mapper.writeValueAsString(origin); - sendEventWithStringData("geolocation_permissions_prompt", data); + Map event = new HashMap(); + event.put("origin", origin.toString()); + sendEventWithStringData("geolocationRequest", mapper.writeValueAsString(event)); } catch (IOException e) { - Log.e("ShopifyCheckoutSheetKit", "Error processing onGeolocationPermissionsShowPrompt", e); + Log.e("ShopifyCheckoutSheetKit", "Error emitting \"geolocationRequest\" event", e); } } @@ -67,16 +103,6 @@ public void onGeolocationPermissionsHidePrompt() { super.onGeolocationPermissionsHidePrompt(); } - @Override - public void onCheckoutCompleted(@NonNull CheckoutCompletedEvent event) { - try { - String data = mapper.writeValueAsString(event); - sendEventWithStringData("completed", data); - } catch (IOException e) { - Log.e("ShopifyCheckoutSheetKit", "Error processing completed event", e); - } - } - @Override public void onWebPixelEvent(@NonNull PixelEvent event) { try { @@ -97,6 +123,23 @@ public void onCheckoutFailed(CheckoutException checkoutError) { } } + @Override + public void onCheckoutCanceled() { + sendEvent("close", null); + } + + @Override + public void onCheckoutCompleted(@NonNull CheckoutCompletedEvent event) { + try { + String data = mapper.writeValueAsString(event); + sendEventWithStringData("completed", data); + } catch (IOException e) { + Log.e("ShopifyCheckoutSheetKit", "Error processing completed event", e); + } + } + + // Private + private Map populateErrorDetails(CheckoutException checkoutError) { Map errorMap = new HashMap(); errorMap.put("__typename", getErrorTypeName(checkoutError)); @@ -127,11 +170,6 @@ private String getErrorTypeName(CheckoutException error) { } } - @Override - public void onCheckoutCanceled() { - sendEvent("close", null); - } - private void sendEvent(String eventName, @Nullable WritableNativeMap params) { reactContext .getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter.class) 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 ef594dae..35607841 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 @@ -117,6 +117,57 @@ public void invalidateCache() { ShopifyCheckoutSheetKit.invalidate(); } + @ReactMethod + public void getConfig(Promise promise) { + WritableNativeMap resultConfig = new WritableNativeMap(); + + resultConfig.putBoolean("preloading", checkoutConfig.getPreloading().getEnabled()); + resultConfig.putString("colorScheme", colorSchemeToString(checkoutConfig.getColorScheme())); + + promise.resolve(resultConfig); + } + + @ReactMethod + public void setConfig(ReadableMap config) { + Context context = getReactApplicationContext(); + + ShopifyCheckoutSheetKit.configure(configuration -> { + if (config.hasKey("preloading")) { + configuration.setPreloading(new Preloading(config.getBoolean("preloading"))); + } + + if (config.hasKey("colorScheme")) { + ColorScheme colorScheme = getColorScheme(Objects.requireNonNull(config.getString("colorScheme"))); + ReadableMap colorsConfig = config.hasKey("colors") ? config.getMap("colors") : null; + ReadableMap androidConfig = null; + + if (colorsConfig != null && colorsConfig.hasKey("android")) { + androidConfig = colorsConfig.getMap("android"); + } + + if (this.isValidColorConfig(androidConfig)) { + ColorScheme colorSchemeWithOverrides = getColors(colorScheme, androidConfig); + if (colorSchemeWithOverrides != null) { + configuration.setColorScheme(colorSchemeWithOverrides); + checkoutConfig = configuration; + return; + } + } + + configuration.setColorScheme(colorScheme); + } + + checkoutConfig = configuration; + }); + } + + @ReactMethod + public void initiateGeolocationRequest(Boolean allow) { + checkoutEventProcessor.invokeGeolocationCallback(allow); + } + + // Private + private ColorScheme getColorScheme(String colorScheme) { switch (colorScheme) { case "web_default": @@ -233,50 +284,6 @@ private ColorScheme getColors(ColorScheme colorScheme, ReadableMap config) { return null; } - @ReactMethod - public void setConfig(ReadableMap config) { - Context context = getReactApplicationContext(); - - ShopifyCheckoutSheetKit.configure(configuration -> { - if (config.hasKey("preloading")) { - configuration.setPreloading(new Preloading(config.getBoolean("preloading"))); - } - - if (config.hasKey("colorScheme")) { - ColorScheme colorScheme = getColorScheme(Objects.requireNonNull(config.getString("colorScheme"))); - ReadableMap colorsConfig = config.hasKey("colors") ? config.getMap("colors") : null; - ReadableMap androidConfig = null; - - if (colorsConfig != null && colorsConfig.hasKey("android")) { - androidConfig = colorsConfig.getMap("android"); - } - - if (this.isValidColorConfig(androidConfig)) { - ColorScheme colorSchemeWithOverrides = getColors(colorScheme, androidConfig); - if (colorSchemeWithOverrides != null) { - configuration.setColorScheme(colorSchemeWithOverrides); - checkoutConfig = configuration; - return; - } - } - - configuration.setColorScheme(colorScheme); - } - - checkoutConfig = configuration; - }); - } - - @ReactMethod - public void getConfig(Promise promise) { - WritableNativeMap resultConfig = new WritableNativeMap(); - - resultConfig.putBoolean("preloading", checkoutConfig.getPreloading().getEnabled()); - resultConfig.putString("colorScheme", colorSchemeToString(checkoutConfig.getColorScheme())); - - promise.resolve(resultConfig); - } - private Color parseColor(String colorStr) { try { colorStr = colorStr.replace("#", ""); diff --git a/modules/@shopify/checkout-sheet-kit/src/context.tsx b/modules/@shopify/checkout-sheet-kit/src/context.tsx index e57ee92f..ad9bf831 100644 --- a/modules/@shopify/checkout-sheet-kit/src/context.tsx +++ b/modules/@shopify/checkout-sheet-kit/src/context.tsx @@ -23,8 +23,9 @@ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SO import React, {useCallback, useMemo, useRef} from 'react'; import type {PropsWithChildren} from 'react'; -import type {EmitterSubscription} from 'react-native'; +import {type EmitterSubscription} from 'react-native'; import {ShopifyCheckoutSheet} from './index'; +import type {Features} from './index.d'; import type { AddEventListener, RemoveEventListeners, @@ -61,17 +62,19 @@ const ShopifyCheckoutSheetContext = React.createContext({ }); interface Props { + features?: Partial; configuration?: Configuration; } export function ShopifyCheckoutSheetProvider({ + features, configuration, children, }: PropsWithChildren) { const instance = useRef(null); if (!instance.current) { - instance.current = new ShopifyCheckoutSheet(configuration); + instance.current = new ShopifyCheckoutSheet(configuration, features); } const addEventListener: AddEventListener = useCallback( diff --git a/modules/@shopify/checkout-sheet-kit/src/index.d.ts b/modules/@shopify/checkout-sheet-kit/src/index.d.ts index 122d2e12..955e8f79 100644 --- a/modules/@shopify/checkout-sheet-kit/src/index.d.ts +++ b/modules/@shopify/checkout-sheet-kit/src/index.d.ts @@ -28,6 +28,18 @@ import type {CheckoutException} from './errors'; export type Maybe = T | undefined; +/** + * Configuration options for checkout sheet kit features + */ +export interface Features { + /** + * When enabled, the checkout will handle geolocation permission requests internally. + * If disabled, geolocation requests will emit a 'geolocationRequest' event that + * must be handled by the application. + */ + handleGeolocationRequests: boolean; +} + export enum ColorScheme { automatic = 'automatic', light = 'light', @@ -127,9 +139,15 @@ export type Configuration = CommonConfiguration & } ); -export type CheckoutEvent = 'close' | 'completed' | 'error' | 'pixel'; +export type CheckoutEvent = + | 'close' + | 'completed' + | 'error' + | 'geolocationRequest' + | 'pixel'; export type CloseEventCallback = () => void; +export type GeolocationRequestEventCallback = () => void; export type PixelEventCallback = (event: PixelEvent) => void; export type CheckoutExceptionCallback = (error: CheckoutException) => void; export type CheckoutCompletedEventCallback = ( @@ -162,6 +180,11 @@ function addEventListener( callback: PixelEventCallback, ): Maybe; +function addEventListener( + event: 'geolocationRequest', + callback: GeolocationRequestEventCallback, +): Maybe; + function removeEventListeners(event: CheckoutEvent): void; export type AddEventListener = typeof addEventListener; @@ -198,7 +221,11 @@ export interface ShopifyCheckoutSheetKit { */ addEventListener: AddEventListener; /** - * Remove subscriptions to checkout events + * Remove subscriptions to checkout events. */ removeEventListeners: RemoveEventListeners; + /** + * Cleans up any event callbacks to prevent memory leaks. + */ + teardown(): void; } diff --git a/modules/@shopify/checkout-sheet-kit/src/index.ts b/modules/@shopify/checkout-sheet-kit/src/index.ts index 82fee184..49716bc4 100644 --- a/modules/@shopify/checkout-sheet-kit/src/index.ts +++ b/modules/@shopify/checkout-sheet-kit/src/index.ts @@ -21,14 +21,25 @@ 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. */ -import {NativeModules, NativeEventEmitter} from 'react-native'; -import type {EmitterSubscription} from 'react-native'; +import { + NativeModules, + NativeEventEmitter, + PermissionsAndroid, + Platform, +} from 'react-native'; +import type { + EmitterSubscription, + EventSubscription, + PermissionStatus, +} from 'react-native'; import {ShopifyCheckoutSheetProvider, useShopifyCheckoutSheet} from './context'; import {ColorScheme} from './index.d'; import type { CheckoutEvent, CheckoutEventCallback, Configuration, + Features, + Maybe, ShopifyCheckoutSheetKit, } from './index.d'; import type {CheckoutException, CheckoutNativeError} from './errors.d'; @@ -54,43 +65,95 @@ if (!('ShopifyCheckoutSheetKit' in NativeModules)) { If you are building for iOS, make sure to run "pod install" first and restart the metro server.`); } +const defaultFeatures: Features = { + handleGeolocationRequests: true, +}; + class ShopifyCheckoutSheet implements ShopifyCheckoutSheetKit { private static eventEmitter: NativeEventEmitter = new NativeEventEmitter( RNShopifyCheckoutSheetKit, ); - constructor(configuration?: Configuration) { + private features: Features; + private geolocationCallback: Maybe; + + /** + * Initializes a new ShopifyCheckoutSheet instance + * @param configuration Optional configuration settings for the checkout + * @param features Optional feature flags to customize behavior, defaults to defaultFeatures + */ + constructor( + configuration?: Configuration, + features: Partial = defaultFeatures, + ) { + this.features = {...defaultFeatures, ...features}; + if (configuration != null) { this.setConfig(configuration); } + + if ( + Platform.OS === 'android' && + this.featureEnabled('handleGeolocationRequests') + ) { + this.subscribeToGeolocationRequestPrompts(); + } } public readonly version: string = RNShopifyCheckoutSheetKit.version; + /** + * Dismisses the currently displayed checkout sheet + */ public dismiss(): void { RNShopifyCheckoutSheetKit.dismiss(); } + /** + * Invalidates the checkout that was cached using preload + */ public invalidate(): void { RNShopifyCheckoutSheetKit.invalidateCache(); } + /** + * Preloads checkout for a given URL to improve performance + * @param checkoutUrl The URL of the checkout to preload + */ public preload(checkoutUrl: string): void { RNShopifyCheckoutSheetKit.preload(checkoutUrl); } + /** + * Presents the checkout sheet for a given checkout URL + * @param checkoutUrl The URL of the checkout to display + */ public present(checkoutUrl: string): void { RNShopifyCheckoutSheetKit.present(checkoutUrl); } + /** + * Retrieves the current checkout configuration + * @returns Promise containing the current Configuration + */ public async getConfig(): Promise { return RNShopifyCheckoutSheetKit.getConfig(); } + /** + * Updates the checkout configuration + * @param configuration New configuration settings to apply + */ public setConfig(configuration: Configuration): void { RNShopifyCheckoutSheetKit.setConfig(configuration); } + /** + * Adds an event listener for checkout events + * @param event The type of event to listen for + * @param callback Function to be called when the event occurs + * @returns An EmitterSubscription that can be used to remove the listener + */ public addEventListener( event: CheckoutEvent, callback: CheckoutEventCallback, @@ -115,6 +178,12 @@ class ShopifyCheckoutSheet implements ShopifyCheckoutSheetKit { this.parseCheckoutError, ); break; + case 'geolocationRequest': + eventCallback = this.interceptEventEmission( + 'geolocationRequest', + callback, + ); + break; default: eventCallback = callback; } @@ -123,12 +192,82 @@ class ShopifyCheckoutSheet implements ShopifyCheckoutSheetKit { return ShopifyCheckoutSheet.eventEmitter.addListener(event, eventCallback); } + /** + * Removes all event listeners for a specific event type + * @param event The type of event to remove listeners for + */ public removeEventListeners(event: CheckoutEvent) { ShopifyCheckoutSheet.eventEmitter.removeAllListeners(event); } + /** + * Cleans up resources and event listeners used by the checkout sheet + */ + public teardown() { + this.geolocationCallback?.remove(); + } + + /** + * Initiates a geolocation request for Android devices + * Only needed if features.handleGeolocationRequests is false + */ + public async initiateGeolocationRequest(allow: boolean) { + if (Platform.OS === 'android') { + RNShopifyCheckoutSheetKit.initiateGeolocationRequest?.(allow); + } + } + // --- + /** + * Checks if a specific feature is enabled in the configuration + * @param feature The feature to check + * @returns boolean indicating if the feature is enabled + */ + private featureEnabled(feature: keyof Features) { + return this.features[feature] ?? true; + } + + /** + * Sets up geolocation request handling for Android devices + */ + private subscribeToGeolocationRequestPrompts() { + this.geolocationCallback = this.addEventListener( + 'geolocationRequest', + async () => { + const coarseOrFineGrainAccessGranted = await this.requestGeolocation(); + + this.initiateGeolocationRequest(coarseOrFineGrainAccessGranted); + }, + ); + } + + /** + * Requests geolocation permissions on Android + * @returns Promise indicating if permission was granted + */ + private async requestGeolocation(): Promise { + const coarse = 'android.permission.ACCESS_COARSE_LOCATION'; + const fine = 'android.permission.ACCESS_FINE_LOCATION'; + const results = await PermissionsAndroid.requestMultiple([coarse, fine]); + + return [results[coarse], results[fine]].some(this.permissionGranted); + } + + /** + * Checks if the given permission status indicates that permission was granted + * @param status The permission status to check + * @returns boolean indicating if the permission was granted + */ + private permissionGranted(status: PermissionStatus): boolean { + return status === 'granted'; + } + + /** + * Parses custom pixel event data from string to object if needed + * @param eventData The pixel event data to parse + * @returns Parsed PixelEvent object + */ private parseCustomPixelData(eventData: PixelEvent): PixelEvent { if ( isCustomPixelEvent(eventData) && @@ -148,6 +287,11 @@ class ShopifyCheckoutSheet implements ShopifyCheckoutSheetKit { return eventData; } + /** + * Converts native checkout errors into appropriate error class instances + * @param exception The native error to parse + * @returns Appropriate CheckoutException instance + */ private parseCheckoutError( exception: CheckoutNativeError, ): CheckoutException { @@ -168,7 +312,11 @@ class ShopifyCheckoutSheet implements ShopifyCheckoutSheetKit { } /** - * Event data can be sent back as either a parsed Event object or a JSON string. + * Handles event emission parsing and transformation + * @param event The type of event being intercepted + * @param callback The callback to execute with the parsed data + * @param transformData Optional function to transform the event data + * @returns Function that handles the event emission */ private interceptEventEmission( event: CheckoutEvent, diff --git a/modules/@shopify/checkout-sheet-kit/tests/index.test.ts b/modules/@shopify/checkout-sheet-kit/tests/index.test.ts index 72b8f7da..41c76387 100644 --- a/modules/@shopify/checkout-sheet-kit/tests/index.test.ts +++ b/modules/@shopify/checkout-sheet-kit/tests/index.test.ts @@ -56,6 +56,9 @@ jest.mock('react-native', () => { }; return { + Platform: { + OS: 'ios', + }, _listeners: listeners, NativeEventEmitter, NativeModules: { diff --git a/sample/scripts/generate_manifest b/sample/scripts/generate_manifest deleted file mode 100755 index 62d4486d..00000000 --- a/sample/scripts/generate_manifest +++ /dev/null @@ -1,33 +0,0 @@ -#!/usr/bin/env bash - -set -e - -DIR=android/app/src/main -MANIFEST=AndroidManifest.xml - -# Ensure env exists -if [ ! -f .env ]; then - echo "sample/.env file not found!" >&2 - exit 1 -fi - -# Read env file -source .env - -# Ensure STOREFRONT_DOMAIN is set -if [ -z "$STOREFRONT_DOMAIN" ]; then - echo "Error: STOREFRONT_DOMAIN is missing from .env" >&2 - exit 1 -fi - -# Check if manifest already exists -if [ -f "$DIR/$MANIFEST" ]; then - echo "✅ \"$MANIFEST\" already exists. Skipping template replacement." - exit 0 -else - cp "$DIR/$MANIFEST.template" "$DIR/$MANIFEST" || { echo "Failed to copy manifest template" >&2; exit 1; } - - # Replace STOREFRONT_DOMAIN value with the value from .env - sed -i '' "s|{STOREFRONT_DOMAIN}|$STOREFRONT_DOMAIN|g" "$DIR/$MANIFEST" || { echo "Failed to generate $MANIFEST from template" >&2; exit 1; } - echo "✅ Generated \"$DIR/$MANIFEST\"" -fi diff --git a/sample/scripts/prestart b/sample/scripts/prestart deleted file mode 100755 index 3af49835..00000000 --- a/sample/scripts/prestart +++ /dev/null @@ -1,5 +0,0 @@ -#!/usr/bin/env bash - -set -e - -./scripts/generate_manifest From b9c0393b55771441c58fac6d40d20fbbcb5dca64 Mon Sep 17 00:00:00 2001 From: Mark Murray Date: Wed, 18 Dec 2024 10:11:22 +0000 Subject: [PATCH 4/7] Add tests --- .../checkout-sheet-kit/tests/index.test.ts | 150 +++++++++++++++++- 1 file changed, 149 insertions(+), 1 deletion(-) diff --git a/modules/@shopify/checkout-sheet-kit/tests/index.test.ts b/modules/@shopify/checkout-sheet-kit/tests/index.test.ts index 41c76387..3ac0f5d1 100644 --- a/modules/@shopify/checkout-sheet-kit/tests/index.test.ts +++ b/modules/@shopify/checkout-sheet-kit/tests/index.test.ts @@ -12,7 +12,7 @@ import { GenericError, } from '../src'; import {ColorScheme, CheckoutNativeErrorType, type Configuration} from '../src'; -import {NativeModules} from 'react-native'; +import {NativeModules, PermissionsAndroid, Platform} from 'react-native'; const checkoutUrl = 'https://shopify.com/checkout'; const config: Configuration = { @@ -53,12 +53,16 @@ jest.mock('react-native', () => { setConfig: jest.fn(), addEventListener: jest.fn(), removeEventListeners: jest.fn(), + initiateGeolocationRequest: jest.fn(), }; return { Platform: { OS: 'ios', }, + PermissionsAndroid: { + requestMultiple: jest.fn(), + }, _listeners: listeners, NativeEventEmitter, NativeModules: { @@ -83,6 +87,8 @@ describe('ShopifyCheckoutSheetKit', () => { // Clear mock listeners NativeModules._listeners = []; + + jest.clearAllMocks(); }); describe('instantiation', () => { @@ -437,4 +443,146 @@ describe('ShopifyCheckoutSheetKit', () => { expect(eventEmitter.removeAllListeners).toHaveBeenCalledWith('close'); }); }); + + describe('Geolocation', () => { + const defaultConfig = {}; + + async function emitGeolocationRequest() { + await new Promise(resolve => { + eventEmitter.emit('geolocationRequest', { + origin: 'https://shopify.com', + }); + setTimeout(resolve); + }); + } + + describe('Android', () => { + const originalPlatform = Platform.OS; + + beforeEach(() => { + Platform.OS = 'android'; + }); + + afterAll(() => { + Platform.OS = originalPlatform; + }); + + it('subscribes to geolocation requests on Android when feature is enabled', () => { + new ShopifyCheckoutSheet(defaultConfig); + + expect(eventEmitter.addListener).toHaveBeenCalledWith( + 'geolocationRequest', + expect.any(Function), + ); + }); + + it('does not subscribe to geolocation requests when feature is disabled', () => { + new ShopifyCheckoutSheet(defaultConfig, { + handleGeolocationRequests: false, + }); + + expect(eventEmitter.addListener).not.toHaveBeenCalledWith( + 'geolocationRequest', + expect.any(Function), + ); + }); + + it('handles geolocation permission grant correctly', async () => { + const mockPermissions = { + 'android.permission.ACCESS_COARSE_LOCATION': 'granted', + 'android.permission.ACCESS_FINE_LOCATION': 'denied', + }; + + (PermissionsAndroid.requestMultiple as jest.Mock).mockResolvedValue( + mockPermissions, + ); + + new ShopifyCheckoutSheet(); + + await emitGeolocationRequest(); + + expect(PermissionsAndroid.requestMultiple).toHaveBeenCalledWith([ + 'android.permission.ACCESS_COARSE_LOCATION', + 'android.permission.ACCESS_FINE_LOCATION', + ]); + expect( + NativeModules.ShopifyCheckoutSheetKit.initiateGeolocationRequest, + ).toHaveBeenCalledWith(true); + }); + + it('handles geolocation permission denial correctly', async () => { + const mockPermissions = { + 'android.permission.ACCESS_COARSE_LOCATION': 'denied', + 'android.permission.ACCESS_FINE_LOCATION': 'denied', + }; + + (PermissionsAndroid.requestMultiple as jest.Mock).mockResolvedValue( + mockPermissions, + ); + + new ShopifyCheckoutSheet(); + + await emitGeolocationRequest(); + + expect(PermissionsAndroid.requestMultiple).toHaveBeenCalledWith([ + 'android.permission.ACCESS_COARSE_LOCATION', + 'android.permission.ACCESS_FINE_LOCATION', + ]); + expect( + NativeModules.ShopifyCheckoutSheetKit.initiateGeolocationRequest, + ).toHaveBeenCalledWith(false); + }); + + it('cleans up geolocation callback on teardown', () => { + const sheet = new ShopifyCheckoutSheet(); + const mockRemove = jest.fn(); + + // @ts-expect-error + sheet.geolocationCallback = { + remove: mockRemove, + }; + + sheet.teardown(); + + expect(mockRemove).toHaveBeenCalled(); + }); + }); + + describe('iOS', () => { + const originalPlatform = Platform.OS; + + beforeEach(() => { + Platform.OS = 'ios'; + }); + + afterAll(() => { + Platform.OS = originalPlatform; + }); + + it('does not subscribe to geolocation requests', () => { + new ShopifyCheckoutSheet(); + + expect(eventEmitter.addListener).not.toHaveBeenCalledWith( + 'geolocationRequest', + expect.any(Function), + ); + }); + + it('does not call the native function, even if an event is emitted', async () => { + new ShopifyCheckoutSheet(); + + await emitGeolocationRequest(); + + expect( + NativeModules.ShopifyCheckoutSheetKit.initiateGeolocationRequest, + ).not.toHaveBeenCalled(); + }); + + it('tears down gracefully', () => { + const sheet = new ShopifyCheckoutSheet(); + + expect(() => sheet.teardown()).not.toThrow(); + }); + }); + }); }); From a5a35600bd25c0e262936c6ea434c4d2cf7ffa48 Mon Sep 17 00:00:00 2001 From: Mark Murray Date: Wed, 18 Dec 2024 11:28:59 +0000 Subject: [PATCH 5/7] Minor fixes --- .../checkoutsheetkit/CustomCheckoutEventProcessor.java | 10 +++++++--- .../ShopifyCheckoutSheetKitModule.java | 9 ++++++--- 2 files changed, 13 insertions(+), 6 deletions(-) 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/CustomCheckoutEventProcessor.java index 6cd9da65..f1a194a2 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/CustomCheckoutEventProcessor.java @@ -49,7 +49,6 @@ public class CustomCheckoutEventProcessor extends DefaultCheckoutEventProcessor private String geolocationOrigin; private GeolocationPermissions.Callback geolocationCallback; - private boolean retainGeolocationForFutureRequests = true; public CustomCheckoutEventProcessor(Context context, ReactApplicationContext reactContext) { super(context); @@ -60,6 +59,7 @@ public CustomCheckoutEventProcessor(Context context, ReactApplicationContext rea public void invokeGeolocationCallback(boolean allow) { if (geolocationCallback != null) { + boolean retainGeolocationForFutureRequests = false; geolocationCallback.invoke(geolocationOrigin, allow, retainGeolocationForFutureRequests); geolocationCallback = null; } @@ -90,8 +90,8 @@ public void onGeolocationPermissionsShowPrompt(@NonNull String origin, // Emit a "geolocationRequest" event to the app. try { - Map event = new HashMap(); - event.put("origin", origin.toString()); + Map event = new HashMap<>(); + event.put("origin", origin); sendEventWithStringData("geolocationRequest", mapper.writeValueAsString(event)); } catch (IOException e) { Log.e("ShopifyCheckoutSheetKit", "Error emitting \"geolocationRequest\" event", e); @@ -101,6 +101,10 @@ public void onGeolocationPermissionsShowPrompt(@NonNull String origin, @Override public void onGeolocationPermissionsHidePrompt() { super.onGeolocationPermissionsHidePrompt(); + + // Reset the geolocation callback and origin when the prompt is hidden. + this.geolocationCallback = null; + this.geolocationOrigin = null; } @Override 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 35607841..ef2638b2 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 @@ -48,6 +48,8 @@ public class ShopifyCheckoutSheetKitModule extends ReactContextBaseJavaModule { private CheckoutSheetKitDialog checkoutSheet; + private CustomCheckoutEventProcessor checkoutEventProcessor; + public ShopifyCheckoutSheetKitModule(ReactApplicationContext reactContext) { super(reactContext); @@ -86,8 +88,7 @@ public void removeListeners(Integer count) { public void present(String checkoutURL) { Activity currentActivity = getCurrentActivity(); if (currentActivity instanceof ComponentActivity) { - DefaultCheckoutEventProcessor checkoutEventProcessor = new CustomCheckoutEventProcessor(currentActivity, - this.reactContext); + checkoutEventProcessor = new CustomCheckoutEventProcessor(currentActivity, this.reactContext); currentActivity.runOnUiThread(() -> { checkoutSheet = ShopifyCheckoutSheetKit.present(checkoutURL, (ComponentActivity) currentActivity, checkoutEventProcessor); @@ -163,7 +164,9 @@ public void setConfig(ReadableMap config) { @ReactMethod public void initiateGeolocationRequest(Boolean allow) { - checkoutEventProcessor.invokeGeolocationCallback(allow); + if (checkoutEventProcessor != null) { + checkoutEventProcessor.invokeGeolocationCallback(allow); + } } // Private From ee73733852a7410eabbf8491b0d20b670f0c6bd0 Mon Sep 17 00:00:00 2001 From: Mark Murray Date: Wed, 18 Dec 2024 12:22:27 +0000 Subject: [PATCH 6/7] Export types, update README --- README.md | 103 +++++++++++++++++- .../checkout-sheet-kit/src/index.d.ts | 8 +- .../@shopify/checkout-sheet-kit/src/index.ts | 3 + sample/src/App.tsx | 9 +- 4 files changed, 114 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index 7fb5a877..92de06d1 100644 --- a/README.md +++ b/README.md @@ -33,8 +33,8 @@ experiences. - [Colors](#colors) - [Localization](#localization) - [Checkout Sheet title](#checkout-sheet-title) - - [iOS](#ios) - - [Android](#android) + - [iOS - Localization](#ios---localization) + - [Android - Localization](#android---localization) - [Currency](#currency) - [Language](#language) - [Preloading](#preloading) @@ -53,6 +53,10 @@ experiences. - [Customer Account API](#customer-account-api) - [Offsite Payments](#offsite-payments) - [Universal Links - iOS](#universal-links---ios) +- [Pickup points / Pickup in store](#pickup-points--pickup-in-store) + - [Geolocation - iOS](#geolocation---ios) + - [Geolocation - Android](#geolocation---android) + - [Opting out of the default behavior](#opting-out-of-the-default-behavior) - [Contributing](#contributing) - [License](#license) @@ -434,9 +438,7 @@ function AppWithContext() { #### Checkout Sheet title -There are several ways to change the title of the Checkout Sheet. - -##### iOS +##### iOS - Localization On iOS, you can set a localized value on the `title` attribute of the configuration. @@ -447,7 +449,7 @@ following: 1. Create a `Localizable.xcstrings` file under "ios/{YourApplicationName}" 2. Add an entry for the key `"shopify_checkout_sheet_title"` -##### Android +##### Android - Localization On Android, you can add a string entry for the key `"checkout_web_view_title"` to the "android/app/src/res/values/strings.xml" file for your application. @@ -742,6 +744,95 @@ public func checkoutDidClickLink(url: URL) { } ``` +## Pickup points / Pickup in store + +### Geolocation - iOS + +Geolocation permission requests are handled out of the box by iOS, provided you've added the required location usage description to your `Info.plist` file: + +```xml +NSLocationWhenInUseUsageDescription +Your location is required to locate pickup points near you. +``` + +> [!TIP] +> Consider also adding `NSLocationAlwaysAndWhenInUseUsageDescription` if your app needs background location access for other features. + +### Geolocation - Android + +Android differs to iOS in that permission requests must be handled in two places: +(1) in your `AndroidManifest.xml` and (2) at runtime. + +```xml + + +``` + +The Checkout Sheet Kit native module will emit a `geolocationRequest` event when the webview requests geolocation +information. By default, the kit will listen for this event and request access to both coarse and fine access when +invoked. + +The geolocation request flow follows this sequence: + +1. When checkout needs location data (e.g., to show nearby pickup points), it triggers a geolocation request. +2. The native module emits a `geolocationRequest` event. +3. If using default behavior, the module automatically handles the Android runtime permission request. +4. The result is passed back to checkout, which then proceeds to show relevant pickup points if permission was granted. + +> [!NOTE] +> If the user denies location permissions, the checkout will still function but will not be able to show nearby pickup points. Users can manually enter their location instead. + +#### Opting out of the default behavior + +> [!NOTE] +> This section is only applicable for Android. + +In order to opt-out of the default permission handling, you can set `features.handleGeolocationRequests` to `false` +when you instantiate the `ShopifyCheckoutSheet` class. + +If you're using the sheet programmatically, you can do so by specifying a `features` object as the second argument: + +```tsx +const checkoutSheetKit = new ShopifyCheckoutSheet(config, {handleGeolocationRequests: false}); +``` + +If you're using the context provider, you can pass the same `features` object as a prop to the `ShopifyCheckoutSheetProvider` component: + +```tsx + + {children} + +``` + +When opting out, you'll need to implement your own permission handling logic and communicate the result back to the checkout sheet. This can be useful if you want to: + +- Customize the permission request UI/UX +- Coordinate location permissions with other app features +- Implement custom fallback behavior when permissions are denied + +The steps here to implement your own logic are to: + +1. Listen for the `geolocationRequest` +2. Request the desired permissions +3. Invoke the native callback by calling `initiateGeolocationRequest` with the permission status + +```tsx +// Listen for "geolocationRequest" events +shopify.addEventListener('geolocationRequest', async (event: GeolocationRequestEvent) => { + const coarse = 'android.permission.ACCESS_COARSE_LOCATION'; + const fine = 'android.permission.ACCESS_FINE_LOCATION'; + + // Request one or many permissions at once + const results = await PermissionsAndroid.requestMultiple([coarse, fine]); + + // Check the permission status results + const permissionGranted = results[coarse] === 'granted' || results[fine] === 'granted'; + + // Dispatch an event to the native module to invoke the native callback with the permission status + shopify.initiateGeolocationRequest(permissionGranted); +}) +``` + --- ## Contributing diff --git a/modules/@shopify/checkout-sheet-kit/src/index.d.ts b/modules/@shopify/checkout-sheet-kit/src/index.d.ts index 955e8f79..0ca4625d 100644 --- a/modules/@shopify/checkout-sheet-kit/src/index.d.ts +++ b/modules/@shopify/checkout-sheet-kit/src/index.d.ts @@ -146,8 +146,14 @@ export type CheckoutEvent = | 'geolocationRequest' | 'pixel'; +export interface GeolocationRequestEvent { + origin: string; +} + export type CloseEventCallback = () => void; -export type GeolocationRequestEventCallback = () => void; +export type GeolocationRequestEventCallback = ( + event: GeolocationRequestEvent, +) => void; export type PixelEventCallback = (event: PixelEvent) => void; export type CheckoutExceptionCallback = (error: CheckoutException) => void; export type CheckoutCompletedEventCallback = ( diff --git a/modules/@shopify/checkout-sheet-kit/src/index.ts b/modules/@shopify/checkout-sheet-kit/src/index.ts index 49716bc4..4dda85a7 100644 --- a/modules/@shopify/checkout-sheet-kit/src/index.ts +++ b/modules/@shopify/checkout-sheet-kit/src/index.ts @@ -39,6 +39,7 @@ import type { CheckoutEventCallback, Configuration, Features, + GeolocationRequestEvent, Maybe, ShopifyCheckoutSheetKit, } from './index.d'; @@ -400,6 +401,8 @@ export type { CheckoutException, Configuration, CustomEvent, + GeolocationRequestEvent, + Features, PixelEvent, StandardEvent, }; diff --git a/sample/src/App.tsx b/sample/src/App.tsx index 5f4044cd..ba58be37 100644 --- a/sample/src/App.tsx +++ b/sample/src/App.tsx @@ -39,7 +39,10 @@ import Icon from 'react-native-vector-icons/Entypo'; import CatalogScreen from './screens/CatalogScreen'; import SettingsScreen from './screens/SettingsScreen'; -import type {Configuration} from '@shopify/checkout-sheet-kit'; +import type { + Configuration, + GeolocationRequestEvent, +} from '@shopify/checkout-sheet-kit'; import { ColorScheme, ShopifyCheckoutSheetProvider, @@ -342,7 +345,9 @@ function Routes() { function App() { return ( - + From d58ea8b502bbb7ee8aab308c0d01d75882380b2e Mon Sep 17 00:00:00 2001 From: Mark Murray Date: Wed, 18 Dec 2024 12:30:49 +0000 Subject: [PATCH 7/7] Bump: minor (3.2.0) + changelog entry --- CHANGELOG.md | 4 ++++ modules/@shopify/checkout-sheet-kit/package.json | 2 +- modules/@shopify/checkout-sheet-kit/src/index.d.ts | 1 + sample/src/App.tsx | 5 +---- 4 files changed, 7 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 049db0d0..51318ce2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,9 @@ # Changelog +## 3.2.0 - December 18, 2024 + +- Handle geolocation requests for Android devices + ## 3.1.2 - November 4, 2024 - Add `consumerProguardRules` build.gradle option to prevent minification of diff --git a/modules/@shopify/checkout-sheet-kit/package.json b/modules/@shopify/checkout-sheet-kit/package.json index 7b1e4934..1602e715 100644 --- a/modules/@shopify/checkout-sheet-kit/package.json +++ b/modules/@shopify/checkout-sheet-kit/package.json @@ -1,7 +1,7 @@ { "name": "@shopify/checkout-sheet-kit", "license": "MIT", - "version": "3.1.2", + "version": "3.2.0", "main": "lib/commonjs/index.js", "types": "src/index.ts", "source": "src/index.ts", diff --git a/modules/@shopify/checkout-sheet-kit/src/index.d.ts b/modules/@shopify/checkout-sheet-kit/src/index.d.ts index 0ca4625d..2101d906 100644 --- a/modules/@shopify/checkout-sheet-kit/src/index.d.ts +++ b/modules/@shopify/checkout-sheet-kit/src/index.d.ts @@ -164,6 +164,7 @@ export type CheckoutEventCallback = | CloseEventCallback | CheckoutExceptionCallback | CheckoutCompletedEventCallback + | GeolocationRequestEventCallback | PixelEventCallback; function addEventListener( diff --git a/sample/src/App.tsx b/sample/src/App.tsx index ba58be37..490c820a 100644 --- a/sample/src/App.tsx +++ b/sample/src/App.tsx @@ -39,10 +39,7 @@ import Icon from 'react-native-vector-icons/Entypo'; import CatalogScreen from './screens/CatalogScreen'; import SettingsScreen from './screens/SettingsScreen'; -import type { - Configuration, - GeolocationRequestEvent, -} from '@shopify/checkout-sheet-kit'; +import type {Configuration} from '@shopify/checkout-sheet-kit'; import { ColorScheme, ShopifyCheckoutSheetProvider,