diff --git a/README.md b/README.md
index 1cf8816..61c22e4 100644
--- a/README.md
+++ b/README.md
@@ -17,7 +17,7 @@ A React Native wrapper for the Didit Identity Verification SDK. Supports both iO
## Permissions
-The SDK uses the camera, location, and optionally NFC on both platforms. Native permissions are declared by the underlying native SDKs and merged automatically where the platform supports it.
+The SDK uses the camera and, optionally, NFC on both platforms, plus the device location in workflows with a Location step (see [Location and Bank verification steps](#location-and-bank-verification-steps)). Native permissions are declared by the underlying native SDKs and merged automatically where the platform supports it.
### iOS
@@ -32,6 +32,8 @@ Add the following keys to your app's `Info.plist`:
Photo library access is required to upload document images.
NFCReaderUsageDescription
NFC is used to read passport chip data for identity verification.
+NSLocationWhenInUseUsageDescription
+Your location is used to confirm where you are for this verification.
```
Missing required iOS privacy keys will cause iOS to terminate the app as soon as the SDK accesses that protected resource.
@@ -85,6 +87,68 @@ The SDK handles Android runtime permission requests automatically. When the user
You do not need to request camera permission in your app code before calling `startVerification()` — the SDK manages this internally.
+### Location and Bank verification steps
+
+Workflows can include a Location step, which confirms where the user is from the device position, and a Bank step, which connects a bank account. The native SDKs draw both steps inside the verification flow, so the React Native API does not change: there is no new function, configuration option, result type or error type.
+
+> **Availability:** the native SDK versions this package pins do not include these two steps yet. Keep them out of workflows you start from the mobile app until this package's release notes announce them. Adding the location purpose string now means the upgrade needs no further app change.
+
+#### Location
+
+The SDK asks for location access only when the session reaches a Location step, and only while the app is in use, never in the background. A Location step set to use the IP address only never asks.
+
+- **iOS:** add `NSLocationWhenInUseUsageDescription` (shown in the `Info.plist` example above). Add it even if your workflows never use the Location step: once the native SDK contains the step, App Store Connect rejects builds whose code references location APIs without a purpose string (ITMS-90683). To show the prompt in other languages, add an `InfoPlist.strings` file to the app target in Xcode, localize it, and set the key for each language, for example in `es.lproj/InfoPlist.strings`:
+
+ ```
+ "NSLocationWhenInUseUsageDescription" = "Tu ubicación se usa para confirmar dónde estás en esta verificación.";
+ ```
+
+ With Expo, set the key in `ios.infoPlist` and its translations through `locales`:
+
+ ```json
+ {
+ "expo": {
+ "ios": {
+ "infoPlist": {
+ "CFBundleAllowMixedLocalizations": true,
+ "NSLocationWhenInUseUsageDescription": "Your location is used to confirm where you are for this verification."
+ }
+ },
+ "locales": { "es": "./locales/es.json" }
+ }
+ }
+ ```
+
+ `locales/es.json`:
+
+ ```json
+ {
+ "ios": {
+ "NSLocationWhenInUseUsageDescription": "Tu ubicación se usa para confirmar dónde estás en esta verificación."
+ }
+ }
+ ```
+
+- **Android:** the native SDK declares `ACCESS_FINE_LOCATION` and `ACCESS_COARSE_LOCATION`, which merge into your app, and requests them at runtime the same way as the camera permission. On Android 12 and later the user can grant approximate location only; the SDK sends whichever location the user allows and Didit's servers decide whether its accuracy meets the workflow's settings. If none of your workflows use the Location step, you can remove both permissions from the merged manifest:
+
+ ```xml
+
+
+
+
+ ```
+
+- **Store disclosures:** a workflow with a Location step sends the device location to Didit. Declare location data in your App Store privacy details and in your Google Play Data safety form.
+
+#### Bank
+
+The Bank step needs no permission, no `Info.plist` key, and no URL scheme or intent filter. The SDK opens the bank's own consent page in an in-app browser and continues the flow once the connection finishes.
+
+#### Results
+
+Didit's servers decide both steps. Their outcome reaches your app in the same `VerificationResult` as every other step: `completed` with the session status (`Approved`, `Pending` or `Declined`) when the flow ends, `cancelled` if the user closes it, or `failed` on an error.
+
## Native SDK Variants
NFC and auto-detection are enabled by default on both platforms.
diff --git a/example-expo/app.json b/example-expo/app.json
index 5e52d84..ecf19d1 100644
--- a/example-expo/app.json
+++ b/example-expo/app.json
@@ -19,6 +19,7 @@
"bundleIdentifier": "me.didit.sdk.reactnative.expo-example",
"infoPlist": {
"NSCameraUsageDescription": "Camera access is required for identity verification to capture documents and facial photos.",
+ "NSLocationWhenInUseUsageDescription": "Your location is used to confirm where you are for this verification.",
"NSMicrophoneUsageDescription": "Microphone access is required to record video for liveness verification.",
"NSPhotoLibraryUsageDescription": "Photo library access is required to upload document images.",
"NFCReaderUsageDescription": "NFC is used to read passport chip data for identity verification.",
diff --git a/example-expo/ios/DiditExpoExample/Info.plist b/example-expo/ios/DiditExpoExample/Info.plist
index d56a660..6c034bd 100644
--- a/example-expo/ios/DiditExpoExample/Info.plist
+++ b/example-expo/ios/DiditExpoExample/Info.plist
@@ -48,6 +48,8 @@
NSCameraUsageDescription
Camera access is required for identity verification to capture documents and facial photos.
+ NSLocationWhenInUseUsageDescription
+ Your location is used to confirm where you are for this verification.
NSMicrophoneUsageDescription
Microphone access is required to record video for liveness verification.
NSPhotoLibraryUsageDescription
diff --git a/example/ios/SdkReactNativeExample/Info.plist b/example/ios/SdkReactNativeExample/Info.plist
index c3e3858..005dc88 100644
--- a/example/ios/SdkReactNativeExample/Info.plist
+++ b/example/ios/SdkReactNativeExample/Info.plist
@@ -41,6 +41,8 @@
NSCameraUsageDescription
Camera access is required for identity verification to capture documents and facial photos.
+ NSLocationWhenInUseUsageDescription
+ Your location is used to confirm where you are for this verification.
RCTNewArchEnabled
UILaunchStoryboardName
diff --git a/src/__tests__/index.test.tsx b/src/__tests__/index.test.tsx
index 6bdf882..6907459 100644
--- a/src/__tests__/index.test.tsx
+++ b/src/__tests__/index.test.tsx
@@ -4,6 +4,7 @@ jest.mock('../NativeSdkReactNative', () => ({
__esModule: true,
default: {
startVerification: jest.fn(),
+ startVerificationWithWorkflow: jest.fn(),
submitTransaction: jest.fn(),
getTransaction: jest.fn(),
onTransactionUpdated: jest.fn(() => ({ remove: jest.fn() })),
@@ -11,10 +12,18 @@ jest.mock('../NativeSdkReactNative', () => ({
}));
import NativeSdkReactNative from '../NativeSdkReactNative';
-import { startVerification, submitTransaction, getTransaction } from '../index';
+import {
+ startVerification,
+ startVerificationWithWorkflow,
+ submitTransaction,
+ getTransaction,
+ VerificationStatus,
+} from '../index';
const mockStartVerification =
NativeSdkReactNative.startVerification as jest.Mock;
+const mockStartWithWorkflow =
+ NativeSdkReactNative.startVerificationWithWorkflow as jest.Mock;
const mockSubmit = NativeSdkReactNative.submitTransaction as jest.Mock;
const mockGet = NativeSdkReactNative.getTransaction as jest.Mock;
const mockOnUpdated = NativeSdkReactNative.onTransactionUpdated as jest.Mock;
@@ -82,6 +91,88 @@ describe('verification configuration marshalling', () => {
});
});
+/**
+ * The Bank and Location steps are drawn by the native SDKs and decided by the
+ * server, so the bridge adds nothing for them: the language reaches native on
+ * both entry points and the decided session comes back as an ordinary result.
+ */
+describe('Bank and Location steps pass-through', () => {
+ beforeEach(() => {
+ mockStartVerification.mockReset();
+ mockStartWithWorkflow.mockReset();
+ });
+
+ it('forwards the language to native when starting with a session token', async () => {
+ mockStartVerification.mockResolvedValue({ type: 'cancelled' });
+
+ await startVerification('test-token', { languageCode: 'es' });
+
+ expect(mockStartVerification).toHaveBeenCalledWith(
+ 'test-token',
+ expect.objectContaining({ languageCode: 'es' })
+ );
+ });
+
+ it('forwards the language to native when starting with a workflow', async () => {
+ mockStartWithWorkflow.mockResolvedValue({ type: 'cancelled' });
+
+ await startVerificationWithWorkflow('workflow-id', {
+ config: { languageCode: 'es' },
+ });
+
+ const [workflowId, , , , , nativeConfig] =
+ mockStartWithWorkflow.mock.calls[0];
+ expect(workflowId).toBe('workflow-id');
+ expect(nativeConfig).toMatchObject({ languageCode: 'es' });
+ });
+
+ it.each([
+ ['Approved', VerificationStatus.Approved],
+ ['Pending', VerificationStatus.Pending],
+ ['Declined', VerificationStatus.Declined],
+ ])(
+ 'returns a session the server decided as %s as a completed result',
+ async (nativeStatus, expected) => {
+ mockStartWithWorkflow.mockResolvedValue({
+ type: 'completed',
+ sessionId: 'session-step',
+ status: nativeStatus,
+ });
+
+ const result = await startVerificationWithWorkflow('workflow-id');
+
+ expect(result).toEqual({
+ type: 'completed',
+ session: { sessionId: 'session-step', status: expected },
+ });
+ }
+ );
+
+ it('keeps the native message when a newer native step reports an error type the bridge does not know', async () => {
+ mockStartVerification.mockResolvedValue({
+ type: 'failed',
+ errorType: 'locationUnavailable',
+ errorMessage: 'Location could not be determined.',
+ sessionId: 'session-step',
+ status: 'Pending',
+ });
+
+ const result = await startVerification('test-token');
+
+ expect(result).toEqual({
+ type: 'failed',
+ error: {
+ type: 'unknown',
+ message: 'Location could not be determined.',
+ },
+ session: {
+ sessionId: 'session-step',
+ status: VerificationStatus.Pending,
+ },
+ });
+ });
+});
+
describe('transaction result marshalling', () => {
beforeEach(() => {
mockSubmit.mockReset();
diff --git a/src/__tests__/location-permission-entries.test.ts b/src/__tests__/location-permission-entries.test.ts
new file mode 100644
index 0000000..419d723
--- /dev/null
+++ b/src/__tests__/location-permission-entries.test.ts
@@ -0,0 +1,98 @@
+import { readFileSync } from 'fs';
+import { join } from 'path';
+
+/**
+ * The Location verification step asks the OS for "when in use" location. iOS
+ * terminates an app that requests it without NSLocationWhenInUseUsageDescription,
+ * and App Store Connect rejects a build whose code references location APIs
+ * without one, so every example app and the README carry the same purpose
+ * string. The copy is the Location step's permission prompt in the design.
+ *
+ * The Expo example's ios/ folder is checked in next to the app.json it was
+ * prebuilt from; when they drift, a fresh prebuild ships different copy than
+ * the one the repository shows.
+ */
+
+const repoRoot = join(__dirname, '..', '..');
+
+const read = (relativePath: string) =>
+ readFileSync(join(repoRoot, relativePath), 'utf8');
+
+const LOCATION_KEY = 'NSLocationWhenInUseUsageDescription';
+const LOCATION_PURPOSE =
+ 'Your location is used to confirm where you are for this verification.';
+const LOCATION_PURPOSE_ES =
+ 'Tu ubicación se usa para confirmar dónde estás en esta verificación.';
+
+const CLI_PLIST = 'example/ios/SdkReactNativeExample/Info.plist';
+const EXPO_PLIST = 'example-expo/ios/DiditExpoExample/Info.plist';
+const EXPO_APP_JSON = 'example-expo/app.json';
+const CLI_ANDROID_MANIFEST = 'example/android/app/src/main/AndroidManifest.xml';
+
+const escapeRegExp = (value: string) =>
+ value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
+
+/** The value of a top-level plist key, or undefined when absent. */
+function plistString(contents: string, key: string): string | undefined {
+ const match = contents.match(
+ new RegExp(`${escapeRegExp(key)}\\s*([^<]*)`)
+ );
+ return match?.[1];
+}
+
+function usageDescriptionKeys(contents: string): string[] {
+ return Array.from(
+ contents.matchAll(/(\w+UsageDescription)<\/key>/g),
+ (match) => String(match[1])
+ ).sort();
+}
+
+const expoInfoPlist = (): Record =>
+ JSON.parse(read(EXPO_APP_JSON)).expo.ios.infoPlist;
+
+describe('location purpose string', () => {
+ it.each([CLI_PLIST, EXPO_PLIST])('is declared in %s', (plistPath) => {
+ expect(plistString(read(plistPath), LOCATION_KEY)).toBe(LOCATION_PURPOSE);
+ });
+
+ it('is declared in the Expo example app.json', () => {
+ expect(expoInfoPlist()[LOCATION_KEY]).toBe(LOCATION_PURPOSE);
+ });
+
+ it('is the one the README tells integrators to add', () => {
+ expect(plistString(read('README.md'), LOCATION_KEY)).toBe(LOCATION_PURPOSE);
+ });
+
+ it('is documented with the same Spanish translation for bare and Expo apps', () => {
+ const readme = read('README.md');
+
+ expect(readme).toContain(`"${LOCATION_KEY}" = "${LOCATION_PURPOSE_ES}";`);
+ expect(readme).toContain(`"${LOCATION_KEY}": "${LOCATION_PURPOSE_ES}"`);
+ });
+});
+
+describe('Expo example native project', () => {
+ it('declares exactly the usage descriptions of its app.json, with the same copy', () => {
+ const plist = read(EXPO_PLIST);
+ const infoPlist = expoInfoPlist();
+ const appJsonKeys = Object.keys(infoPlist)
+ .filter((key) => key.endsWith('UsageDescription'))
+ .sort();
+
+ expect(usageDescriptionKeys(plist)).toEqual(appJsonKeys);
+ appJsonKeys.forEach((key) => {
+ expect(plistString(plist, key)).toBe(infoPlist[key]);
+ });
+ });
+});
+
+describe('Android example location permissions', () => {
+ it.each(['ACCESS_FINE_LOCATION', 'ACCESS_COARSE_LOCATION'])(
+ 'declares %s so precise and approximate grants both work',
+ (permission) => {
+ expect(read(CLI_ANDROID_MANIFEST)).toContain(
+ ``
+ );
+ }
+ );
+});