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/README.md b/README.md index dda44dd8..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) @@ -144,8 +148,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 @@ -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/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..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 @@ -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; @@ -41,25 +43,70 @@ 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; + public CustomCheckoutEventProcessor(Context context, ReactApplicationContext reactContext) { super(context); - this.reactContext = reactContext; } + // Public methods + + public void invokeGeolocationCallback(boolean allow) { + if (geolocationCallback != null) { + boolean retainGeolocationForFutureRequests = false; + 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 onCheckoutCompleted(@NonNull CheckoutCompletedEvent event) { + 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(event); - sendEventWithStringData("completed", data); + Map event = new HashMap<>(); + event.put("origin", origin); + sendEventWithStringData("geolocationRequest", mapper.writeValueAsString(event)); } catch (IOException e) { - Log.e("ShopifyCheckoutSheetKit", "Error processing completed event", e); + Log.e("ShopifyCheckoutSheetKit", "Error emitting \"geolocationRequest\" event", e); } } + @Override + public void onGeolocationPermissionsHidePrompt() { + super.onGeolocationPermissionsHidePrompt(); + + // Reset the geolocation callback and origin when the prompt is hidden. + this.geolocationCallback = null; + this.geolocationOrigin = null; + } + @Override public void onWebPixelEvent(@NonNull PixelEvent event) { try { @@ -80,6 +127,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)); @@ -110,11 +174,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..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); @@ -117,6 +118,59 @@ 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) { + if (checkoutEventProcessor != null) { + checkoutEventProcessor.invokeGeolocationCallback(allow); + } + } + + // Private + private ColorScheme getColorScheme(String colorScheme) { switch (colorScheme) { case "web_default": @@ -233,50 +287,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/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/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..2101d906 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,21 @@ export type Configuration = CommonConfiguration & } ); -export type CheckoutEvent = 'close' | 'completed' | 'error' | 'pixel'; +export type CheckoutEvent = + | 'close' + | 'completed' + | 'error' + | 'geolocationRequest' + | 'pixel'; + +export interface GeolocationRequestEvent { + origin: string; +} export type CloseEventCallback = () => void; +export type GeolocationRequestEventCallback = ( + event: GeolocationRequestEvent, +) => void; export type PixelEventCallback = (event: PixelEvent) => void; export type CheckoutExceptionCallback = (error: CheckoutException) => void; export type CheckoutCompletedEventCallback = ( @@ -140,6 +164,7 @@ export type CheckoutEventCallback = | CloseEventCallback | CheckoutExceptionCallback | CheckoutCompletedEventCallback + | GeolocationRequestEventCallback | PixelEventCallback; function addEventListener( @@ -162,6 +187,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 +228,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..4dda85a7 100644 --- a/modules/@shopify/checkout-sheet-kit/src/index.ts +++ b/modules/@shopify/checkout-sheet-kit/src/index.ts @@ -21,14 +21,26 @@ 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, + GeolocationRequestEvent, + Maybe, ShopifyCheckoutSheetKit, } from './index.d'; import type {CheckoutException, CheckoutNativeError} from './errors.d'; @@ -54,43 +66,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 +179,12 @@ class ShopifyCheckoutSheet implements ShopifyCheckoutSheetKit { this.parseCheckoutError, ); break; + case 'geolocationRequest': + eventCallback = this.interceptEventEmission( + 'geolocationRequest', + callback, + ); + break; default: eventCallback = callback; } @@ -123,12 +193,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 +288,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 +313,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, @@ -252,6 +401,8 @@ export type { CheckoutException, Configuration, CustomEvent, + GeolocationRequestEvent, + Features, PixelEvent, StandardEvent, }; diff --git a/modules/@shopify/checkout-sheet-kit/tests/index.test.ts b/modules/@shopify/checkout-sheet-kit/tests/index.test.ts index 72b8f7da..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,9 +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: { @@ -80,6 +87,8 @@ describe('ShopifyCheckoutSheetKit', () => { // Clear mock listeners NativeModules._listeners = []; + + jest.clearAllMocks(); }); describe('instantiation', () => { @@ -434,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(); + }); + }); + }); }); 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 diff --git a/sample/src/App.tsx b/sample/src/App.tsx index 5f4044cd..490c820a 100644 --- a/sample/src/App.tsx +++ b/sample/src/App.tsx @@ -342,7 +342,9 @@ function Routes() { function App() { return ( - +