From aa84782953a93673357c0202680adda7f221fcca Mon Sep 17 00:00:00 2001 From: Alejandro Rosas Date: Wed, 7 Oct 2026 21:06:34 +0000 Subject: [PATCH 1/4] DID-5770: [H10] React Native SDK: Bank and Location steps pass-through (continuation of DID-5658) Opened by a BotInc run. Delivery follows the workflow's review and merge policy. --- README.md | 69 +++++++++++++- example-expo/app.json | 4 + example-expo/ios/DiditExpoExample/Info.plist | 7 ++ example/ios/SdkReactNativeExample/Info.plist | 7 ++ src/__tests__/index.test.tsx | 66 ++++++++++++- .../location-permission-entries.test.ts | 94 +++++++++++++++++++ 6 files changed, 245 insertions(+), 2 deletions(-) create mode 100644 src/__tests__/location-permission-entries.test.ts diff --git a/README.md b/README.md index 084ba19..71980ae 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, optionally NFC, and location only for the Location verification step (see [Bank and Location verification steps](#bank-and-location-verification-steps)). Native permissions are declared by the underlying native SDKs and merged automatically where the platform supports it. ### iOS @@ -85,6 +85,73 @@ 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. +## Bank and Location verification steps + +Workflows can include a **Bank** step (the person links a bank account) and a **Location** step (the device position is read once, with the person's consent). Both screens are drawn by the native SDKs, so this package needs no new API: they run inside `startVerification()` / `startVerificationWithWorkflow()`, and the outcome comes back as the same `VerificationResult`. + +> **Availability:** the native SDK version this package pins (`diditNativeSdkVersions` in `package.json`, currently 4.9.1) does not include these steps yet. Keep them out of the workflows your app runs until a release of this package pins a native SDK that has them. + +### Bank + +No setup. The SDK opens the bank's approval page in the system browser (a Custom Tab on Android, Safari in a sheet on iOS) and checks the result itself when the person comes back, so there is no permission, URL scheme or deep link to register. + +### Location on iOS + +Location lives in the native SDK's optional `DiditSDK/Location` module, so apps that never run the step never link Core Location. The released iOS binaries do not include that module yet; without it, or without `NSLocationWhenInUseUsageDescription`, the step shows no prompt, reports that the device cannot place the person, and the workflow's own rules decide. + +Add the purpose strings to `Info.plist`. `NSLocationTemporaryUsageDescriptionDictionary` is optional: it lets the step ask once for precise location when the person granted only an approximate one (iOS 14+), and its key must be exactly `DiditLocationVerification`. + +```xml +NSLocationWhenInUseUsageDescription +Your location is used to confirm where you are for this verification. +NSLocationTemporaryUsageDescriptionDictionary + + DiditLocationVerification + This verification needs your precise location once. + +``` + +iOS picks these strings by the device language, not by the SDK's `languageCode`, so translate them in each `.lproj/InfoPlist.strings`, for example `es.lproj/InfoPlist.strings`: + +``` +"NSLocationWhenInUseUsageDescription" = "Tu ubicación se usa para confirmar dónde estás en esta verificación."; +``` + +With Expo, set the same keys in `app.json` and translate them with [`locales`](https://docs.expo.dev/guides/localization/#translating-app-metadata): + +```json +{ + "expo": { + "ios": { + "infoPlist": { + "NSLocationWhenInUseUsageDescription": "Your location is used to confirm where you are for this verification.", + "NSLocationTemporaryUsageDescriptionDictionary": { + "DiditLocationVerification": "This verification needs your precise location once." + } + } + }, + "locales": { + "es": "./locales/es.json" + } + } +} +``` + +where `locales/es.json` is `{ "ios": { "NSLocationWhenInUseUsageDescription": "Tu ubicación se usa para confirmar dónde estás en esta verificación." } }`. + +Once your app links the module, declare **Precise Location** and **Coarse Location** in App Store Connect under **App Functionality**, linked to the user and not used for tracking. The SDK reads one position per attempt, only while the step is on screen, and never in the background. + +### Location on Android + +The native SDK declares `ACCESS_FINE_LOCATION` and `ACCESS_COARSE_LOCATION`, plus the location hardware features as optional (`android:required="false"`), and they merge into your app automatically. It asks for them at runtime only when a workflow reaches the step, after a screen that explains why, and the person can grant precise or approximate location. If your app never runs the step, you can remove them from the merged manifest (the `` element needs `xmlns:tools="http://schemas.android.com/tools"`): + +```xml + + +``` + +A session that still reaches the step then reports the location as unavailable, without a prompt. If you keep them, declare **Approximate location** and **Precise location** as collected data in your Google Play Data safety form. + ## 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..a9d3982 100644 --- a/example-expo/app.json +++ b/example-expo/app.json @@ -22,6 +22,10 @@ "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.", + "NSLocationWhenInUseUsageDescription": "Your location is used to confirm where you are for this verification.", + "NSLocationTemporaryUsageDescriptionDictionary": { + "DiditLocationVerification": "This verification needs your precise location once." + }, "com.apple.developer.nfc.readersession.iso7816.select-identifiers": [ "D23300000045737445494420763335", "A0000002471001", diff --git a/example-expo/ios/DiditExpoExample/Info.plist b/example-expo/ios/DiditExpoExample/Info.plist index d56a660..c42c63e 100644 --- a/example-expo/ios/DiditExpoExample/Info.plist +++ b/example-expo/ios/DiditExpoExample/Info.plist @@ -48,6 +48,13 @@ NSCameraUsageDescription Camera access is required for identity verification to capture documents and facial photos. + NSLocationTemporaryUsageDescriptionDictionary + + DiditLocationVerification + This verification needs your precise location once. + + 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..2c2387d 100644 --- a/example/ios/SdkReactNativeExample/Info.plist +++ b/example/ios/SdkReactNativeExample/Info.plist @@ -28,6 +28,13 @@ NFCReaderUsageDescription NFC is used to read passport chip data for identity verification. + NSLocationTemporaryUsageDescriptionDictionary + + DiditLocationVerification + This verification needs your precise location once. + + 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/src/__tests__/index.test.tsx b/src/__tests__/index.test.tsx index 6bdf882..a1746bb 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,61 @@ describe('verification configuration marshalling', () => { }); }); +/** + * Bank and Location are drawn by the native SDKs, so the wrapper adds no API + * for them: their screens follow the configured language, and a native SDK + * that cannot show a step ends the flow with a failure the host app can act on. + */ +describe('native-only verification steps', () => { + beforeEach(() => { + mockStartVerification.mockReset(); + mockStartWithWorkflow.mockReset(); + }); + + it('forwards the configured language to native from both entry points', async () => { + const completed = { + type: 'completed', + sessionId: 'test-session', + status: 'Pending', + }; + mockStartVerification.mockResolvedValue(completed); + mockStartWithWorkflow.mockResolvedValue(completed); + + await startVerification('test-token', { languageCode: 'es' }); + await startVerificationWithWorkflow('test-workflow', { + config: { languageCode: 'es' }, + }); + + expect(mockStartVerification).toHaveBeenCalledWith( + 'test-token', + expect.objectContaining({ languageCode: 'es' }) + ); + const [, , , , , workflowConfig] = mockStartWithWorkflow.mock.calls[0]; + expect(workflowConfig).toMatchObject({ languageCode: 'es' }); + }); + + it('returns an unsupported-step exit as an unknown failure that keeps the session', async () => { + mockStartVerification.mockResolvedValue({ + type: 'failed', + errorType: 'unknown', + errorMessage: 'Unsupported verification step', + sessionId: 'test-session', + status: 'Pending', + }); + + const result = await startVerification('test-token'); + + expect(result).toEqual({ + type: 'failed', + error: { type: 'unknown', message: 'Unsupported verification step' }, + session: { + sessionId: 'test-session', + 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..966bc6d --- /dev/null +++ b/src/__tests__/location-permission-entries.test.ts @@ -0,0 +1,94 @@ +import { readFileSync } from 'fs'; +import { join } from 'path'; + +/** + * The native Location step only prompts when the host app declares its iOS + * purpose string, and only asks once for precise location when the temporary + * purpose key is exactly `DiditLocationVerification`. A missing or misspelled + * entry fails silently: the step reports that the device cannot place the + * person. These tests keep the example apps and the README on the design copy. + */ + +const repoRoot = join(__dirname, '..', '..'); + +const read = (relativePath: string) => + readFileSync(join(repoRoot, relativePath), 'utf8'); + +const WHEN_IN_USE = + 'Your location is used to confirm where you are for this verification.'; +const WHEN_IN_USE_ES = + 'Tu ubicación se usa para confirmar dónde estás en esta verificación.'; +const PRECISE_ONCE = 'This verification needs your precise location once.'; +const PURPOSE_KEY = 'DiditLocationVerification'; + +const IOS_EXAMPLE_PLISTS = [ + 'example/ios/SdkReactNativeExample/Info.plist', + 'example-expo/ios/DiditExpoExample/Info.plist', +]; + +/** The `` value that follows `key`. */ +const plistString = (contents: string, key: string) => + contents.match( + new RegExp(`${key}\\s*([^<]*)`) + )?.[1]; + +/** The body of the `` that follows `key`. */ +const plistDict = (contents: string, key: string) => + contents.match( + new RegExp(`${key}\\s*([\\s\\S]*?)`) + )?.[1]; + +describe.each(IOS_EXAMPLE_PLISTS)( + 'iOS location purpose strings in %s', + (path) => { + const plist = read(path); + + it('declares the when-in-use purpose string', () => { + expect(plistString(plist, 'NSLocationWhenInUseUsageDescription')).toBe( + WHEN_IN_USE + ); + }); + + it('declares the precise-once purpose under the key the native step asks for', () => { + const temporary = + plistDict(plist, 'NSLocationTemporaryUsageDescriptionDictionary') ?? ''; + + expect(plistString(temporary, PURPOSE_KEY)).toBe(PRECISE_ONCE); + }); + } +); + +describe('Expo example app config', () => { + it('declares the same location purpose strings as its prebuilt Info.plist', () => { + const infoPlist = JSON.parse(read('example-expo/app.json')).expo.ios + .infoPlist; + + expect(infoPlist.NSLocationWhenInUseUsageDescription).toBe(WHEN_IN_USE); + expect(infoPlist.NSLocationTemporaryUsageDescriptionDictionary).toEqual({ + [PURPOSE_KEY]: PRECISE_ONCE, + }); + }); +}); + +describe('README location setup', () => { + const readme = read('README.md'); + + it('documents the same purpose strings and key as the example apps', () => { + const temporary = + plistDict(readme, 'NSLocationTemporaryUsageDescriptionDictionary') ?? ''; + + expect(plistString(readme, 'NSLocationWhenInUseUsageDescription')).toBe( + WHEN_IN_USE + ); + expect(plistString(temporary, PURPOSE_KEY)).toBe(PRECISE_ONCE); + }); + + it('documents the Spanish purpose string for InfoPlist.strings and Expo locales', () => { + expect(readme).toContain( + `"NSLocationWhenInUseUsageDescription" = "${WHEN_IN_USE_ES}";` + ); + expect(readme).toContain( + `{ "ios": { "NSLocationWhenInUseUsageDescription": "${WHEN_IN_USE_ES}" } }` + ); + }); +}); From 21b76bd05ea254357e493c25b3abd62a098baa12 Mon Sep 17 00:00:00 2001 From: Alejandro Rosas Date: Wed, 7 Oct 2026 21:36:10 +0000 Subject: [PATCH 2/4] DID-5770: [H10] React Native SDK: Bank and Location steps pass-through (continuation of DID-5658) Opened by a BotInc run. Delivery follows the workflow's review and merge policy. --- README.md | 18 ++++- example-expo/app.json | 3 + .../project.pbxproj | 13 ++++ .../Supporting/es.lproj/InfoPlist.strings | 2 + example-expo/locales/es.json | 6 ++ .../project.pbxproj | 17 +++++ .../es.lproj/InfoPlist.strings | 2 + .../location-permission-entries.test.ts | 75 +++++++++++++++++-- 8 files changed, 126 insertions(+), 10 deletions(-) create mode 100644 example-expo/ios/DiditExpoExample/Supporting/es.lproj/InfoPlist.strings create mode 100644 example-expo/locales/es.json create mode 100644 example/ios/SdkReactNativeExample/es.lproj/InfoPlist.strings diff --git a/README.md b/README.md index 71980ae..079e592 100644 --- a/README.md +++ b/README.md @@ -89,7 +89,7 @@ You do not need to request camera permission in your app code before calling `st Workflows can include a **Bank** step (the person links a bank account) and a **Location** step (the device position is read once, with the person's consent). Both screens are drawn by the native SDKs, so this package needs no new API: they run inside `startVerification()` / `startVerificationWithWorkflow()`, and the outcome comes back as the same `VerificationResult`. -> **Availability:** the native SDK version this package pins (`diditNativeSdkVersions` in `package.json`, currently 4.9.1) does not include these steps yet. Keep them out of the workflows your app runs until a release of this package pins a native SDK that has them. +> **Availability:** native SDK releases up to and including 4.9.1 do not include these steps. Check the native versions this package pins (`diditNativeSdkVersions` in `package.json`) and keep the steps out of the workflows your app runs until it pins a native SDK release that has them. ### Bank @@ -111,10 +111,11 @@ Add the purpose strings to `Info.plist`. `NSLocationTemporaryUsageDescriptionDic ``` -iOS picks these strings by the device language, not by the SDK's `languageCode`, so translate them in each `.lproj/InfoPlist.strings`, for example `es.lproj/InfoPlist.strings`: +iOS picks these strings by the device language, not by the SDK's `languageCode`, so translate them in each `.lproj/InfoPlist.strings`. The precise-location string is translated under its purpose key. For example `es.lproj/InfoPlist.strings`: ``` "NSLocationWhenInUseUsageDescription" = "Tu ubicación se usa para confirmar dónde estás en esta verificación."; +"DiditLocationVerification" = "Esta verificación necesita tu ubicación precisa una vez."; ``` With Expo, set the same keys in `app.json` and translate them with [`locales`](https://docs.expo.dev/guides/localization/#translating-app-metadata): @@ -137,7 +138,18 @@ With Expo, set the same keys in `app.json` and translate them with [`locales`](h } ``` -where `locales/es.json` is `{ "ios": { "NSLocationWhenInUseUsageDescription": "Tu ubicación se usa para confirmar dónde estás en esta verificación." } }`. +where `locales/es.json` is: + +```json +{ + "ios": { + "NSLocationWhenInUseUsageDescription": "Tu ubicación se usa para confirmar dónde estás en esta verificación.", + "DiditLocationVerification": "Esta verificación necesita tu ubicación precisa una vez." + } +} +``` + +Both example apps in this repository declare these strings in English and Spanish. Once your app links the module, declare **Precise Location** and **Coarse Location** in App Store Connect under **App Functionality**, linked to the user and not used for tracking. The SDK reads one position per attempt, only while the step is on screen, and never in the background. diff --git a/example-expo/app.json b/example-expo/app.json index a9d3982..6ea7ed0 100644 --- a/example-expo/app.json +++ b/example-expo/app.json @@ -34,6 +34,9 @@ ] } }, + "locales": { + "es": "./locales/es.json" + }, "android": { "package": "me.didit.sdk.reactnative.expoexample", "adaptiveIcon": { diff --git a/example-expo/ios/DiditExpoExample.xcodeproj/project.pbxproj b/example-expo/ios/DiditExpoExample.xcodeproj/project.pbxproj index f3b4737..5ed2ec0 100644 --- a/example-expo/ios/DiditExpoExample.xcodeproj/project.pbxproj +++ b/example-expo/ios/DiditExpoExample.xcodeproj/project.pbxproj @@ -14,6 +14,7 @@ BB2F792D24A3F905000567C9 /* Expo.plist in Resources */ = {isa = PBXBuildFile; fileRef = BB2F792C24A3F905000567C9 /* Expo.plist */; }; D3B2F5EDC02B66C5935838A5 /* ExpoModulesProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = A92FAC627B51295BC573C26A /* ExpoModulesProvider.swift */; }; F11748422D0307B40044C1D9 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = F11748412D0307B40044C1D9 /* AppDelegate.swift */; }; + 08781A61616F403BA05CE643 /* InfoPlist.strings in Resources */ = {isa = PBXBuildFile; fileRef = 9602DDAD615B4729B9401021 /* InfoPlist.strings */; }; /* End PBXBuildFile section */ /* Begin PBXFileReference section */ @@ -30,6 +31,7 @@ ED297162215061F000B7C4FE /* JavaScriptCore.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = JavaScriptCore.framework; path = System/Library/Frameworks/JavaScriptCore.framework; sourceTree = SDKROOT; }; F11748412D0307B40044C1D9 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = AppDelegate.swift; path = DiditExpoExample/AppDelegate.swift; sourceTree = ""; }; F11748442D0722820044C1D9 /* DiditExpoExample-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; name = "DiditExpoExample-Bridging-Header.h"; path = "DiditExpoExample/DiditExpoExample-Bridging-Header.h"; sourceTree = ""; }; + 9602DDAD615B4729B9401021 /* InfoPlist.strings */ = {isa = PBXFileReference; name = "InfoPlist.strings"; path = "es.lproj/InfoPlist.strings"; sourceTree = ""; fileEncoding = 4; lastKnownFileType = text.plist.strings; explicitFileType = undefined; includeInIndex = 0; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -127,11 +129,21 @@ isa = PBXGroup; children = ( BB2F792C24A3F905000567C9 /* Expo.plist */, + BBEEB4F65AD04F14A9D6BBC7 /* es.lproj */, ); name = Supporting; path = DiditExpoExample/Supporting; sourceTree = ""; }; + BBEEB4F65AD04F14A9D6BBC7 /* es.lproj */ = { + isa = "PBXGroup"; + children = ( + 9602DDAD615B4729B9401021 /* InfoPlist.strings */, + ); + name = es.lproj; + sourceTree = ""; + path = ""; + }; /* End PBXGroup section */ /* Begin PBXNativeTarget section */ @@ -197,6 +209,7 @@ 13B07FBF1A68108700A75B9A /* Images.xcassets in Resources */, 3E461D99554A48A4959DE609 /* SplashScreen.storyboard in Resources */, 36DE53FAFBF2A3780CD20F58 /* PrivacyInfo.xcprivacy in Resources */, + 08781A61616F403BA05CE643 /* InfoPlist.strings in Resources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/example-expo/ios/DiditExpoExample/Supporting/es.lproj/InfoPlist.strings b/example-expo/ios/DiditExpoExample/Supporting/es.lproj/InfoPlist.strings new file mode 100644 index 0000000..a32836b --- /dev/null +++ b/example-expo/ios/DiditExpoExample/Supporting/es.lproj/InfoPlist.strings @@ -0,0 +1,2 @@ +NSLocationWhenInUseUsageDescription = "Tu ubicación se usa para confirmar dónde estás en esta verificación."; +DiditLocationVerification = "Esta verificación necesita tu ubicación precisa una vez."; \ No newline at end of file diff --git a/example-expo/locales/es.json b/example-expo/locales/es.json new file mode 100644 index 0000000..a0d3e3e --- /dev/null +++ b/example-expo/locales/es.json @@ -0,0 +1,6 @@ +{ + "ios": { + "NSLocationWhenInUseUsageDescription": "Tu ubicación se usa para confirmar dónde estás en esta verificación.", + "DiditLocationVerification": "Esta verificación necesita tu ubicación precisa una vez." + } +} diff --git a/example/ios/SdkReactNativeExample.xcodeproj/project.pbxproj b/example/ios/SdkReactNativeExample.xcodeproj/project.pbxproj index 87b72cc..8b3e1d5 100644 --- a/example/ios/SdkReactNativeExample.xcodeproj/project.pbxproj +++ b/example/ios/SdkReactNativeExample.xcodeproj/project.pbxproj @@ -12,6 +12,7 @@ 22ABB861F5A1DCF99DA64262 /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = 13B07FB81A68108700A75B9A /* PrivacyInfo.xcprivacy */; }; 761780ED2CA45674006654EE /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 761780EC2CA45674006654EE /* AppDelegate.swift */; }; 81AB9BB82411601600AC10FF /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 81AB9BB72411601600AC10FF /* LaunchScreen.storyboard */; }; + A1C3E5F72E9B4D6100D1D1A1 /* InfoPlist.strings in Resources */ = {isa = PBXBuildFile; fileRef = A1C3E5F52E9B4D6100D1D1A1 /* InfoPlist.strings */; }; /* End PBXBuildFile section */ /* Begin PBXFileReference section */ @@ -24,6 +25,7 @@ 5DCACB8F33CDC322A6C60F78 /* libPods-SdkReactNativeExample.a */ = {isa = PBXFileReference; explicitFileType = archive.ar; includeInIndex = 0; path = "libPods-SdkReactNativeExample.a"; sourceTree = BUILT_PRODUCTS_DIR; }; 761780EC2CA45674006654EE /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = AppDelegate.swift; path = SdkReactNativeExample/AppDelegate.swift; sourceTree = ""; }; 81AB9BB72411601600AC10FF /* LaunchScreen.storyboard */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = file.storyboard; name = LaunchScreen.storyboard; path = SdkReactNativeExample/LaunchScreen.storyboard; sourceTree = ""; }; + A1C3E5F62E9B4D6100D1D1A1 /* es */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = es; path = es.lproj/InfoPlist.strings; sourceTree = ""; }; ED297162215061F000B7C4FE /* JavaScriptCore.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = JavaScriptCore.framework; path = System/Library/Frameworks/JavaScriptCore.framework; sourceTree = SDKROOT; }; /* End PBXFileReference section */ @@ -45,6 +47,7 @@ 13B07FB51A68108700A75B9A /* Images.xcassets */, 761780EC2CA45674006654EE /* AppDelegate.swift */, 13B07FB61A68108700A75B9A /* Info.plist */, + A1C3E5F52E9B4D6100D1D1A1 /* InfoPlist.strings */, 81AB9BB72411601600AC10FF /* LaunchScreen.storyboard */, 13B07FB81A68108700A75B9A /* PrivacyInfo.xcprivacy */, ); @@ -142,6 +145,7 @@ knownRegions = ( en, Base, + es, ); mainGroup = 83CBB9F61A601CBA00E9B192; productRefGroup = 83CBBA001A601CBA00E9B192 /* Products */; @@ -161,6 +165,7 @@ 81AB9BB82411601600AC10FF /* LaunchScreen.storyboard in Resources */, 13B07FBF1A68108700A75B9A /* Images.xcassets in Resources */, 22ABB861F5A1DCF99DA64262 /* PrivacyInfo.xcprivacy in Resources */, + A1C3E5F72E9B4D6100D1D1A1 /* InfoPlist.strings in Resources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -252,6 +257,18 @@ }; /* End PBXSourcesBuildPhase section */ +/* Begin PBXVariantGroup section */ + A1C3E5F52E9B4D6100D1D1A1 /* InfoPlist.strings */ = { + isa = PBXVariantGroup; + children = ( + A1C3E5F62E9B4D6100D1D1A1 /* es */, + ); + name = InfoPlist.strings; + path = SdkReactNativeExample; + sourceTree = ""; + }; +/* End PBXVariantGroup section */ + /* Begin XCBuildConfiguration section */ 13B07F941A680F5B00A75B9A /* Debug */ = { isa = XCBuildConfiguration; diff --git a/example/ios/SdkReactNativeExample/es.lproj/InfoPlist.strings b/example/ios/SdkReactNativeExample/es.lproj/InfoPlist.strings new file mode 100644 index 0000000..19d2ee4 --- /dev/null +++ b/example/ios/SdkReactNativeExample/es.lproj/InfoPlist.strings @@ -0,0 +1,2 @@ +"NSLocationWhenInUseUsageDescription" = "Tu ubicación se usa para confirmar dónde estás en esta verificación."; +"DiditLocationVerification" = "Esta verificación necesita tu ubicación precisa una vez."; diff --git a/src/__tests__/location-permission-entries.test.ts b/src/__tests__/location-permission-entries.test.ts index 966bc6d..9389006 100644 --- a/src/__tests__/location-permission-entries.test.ts +++ b/src/__tests__/location-permission-entries.test.ts @@ -6,7 +6,9 @@ import { join } from 'path'; * purpose string, and only asks once for precise location when the temporary * purpose key is exactly `DiditLocationVerification`. A missing or misspelled * entry fails silently: the step reports that the device cannot place the - * person. These tests keep the example apps and the README on the design copy. + * person. iOS reads the translations from `.lproj/InfoPlist.strings`, + * with the precise-once string under its purpose key. These tests keep the + * example apps and the README on the design copy, in English and Spanish. */ const repoRoot = join(__dirname, '..', '..'); @@ -19,6 +21,8 @@ const WHEN_IN_USE = const WHEN_IN_USE_ES = 'Tu ubicación se usa para confirmar dónde estás en esta verificación.'; const PRECISE_ONCE = 'This verification needs your precise location once.'; +const PRECISE_ONCE_ES = + 'Esta verificación necesita tu ubicación precisa una vez.'; const PURPOSE_KEY = 'DiditLocationVerification'; const IOS_EXAMPLE_PLISTS = [ @@ -26,12 +30,28 @@ const IOS_EXAMPLE_PLISTS = [ 'example-expo/ios/DiditExpoExample/Info.plist', ]; +const IOS_EXAMPLE_SPANISH_STRINGS = [ + { + stringsFile: 'example/ios/SdkReactNativeExample/es.lproj/InfoPlist.strings', + pbxproj: 'example/ios/SdkReactNativeExample.xcodeproj/project.pbxproj', + }, + { + stringsFile: + 'example-expo/ios/DiditExpoExample/Supporting/es.lproj/InfoPlist.strings', + pbxproj: 'example-expo/ios/DiditExpoExample.xcodeproj/project.pbxproj', + }, +]; + /** The `` value that follows `key`. */ const plistString = (contents: string, key: string) => contents.match( new RegExp(`${key}\\s*([^<]*)`) )?.[1]; +/** The value of `key` in an `InfoPlist.strings` file (the key may be unquoted). */ +const stringsValue = (contents: string, key: string) => + contents.match(new RegExp(`^"?${key}"?\\s*=\\s*"([^"]*)";`, 'm'))?.[1]; + /** The body of the `` that follows `key`. */ const plistDict = (contents: string, key: string) => contents.match( @@ -58,6 +78,29 @@ describe.each(IOS_EXAMPLE_PLISTS)( } ); +describe.each(IOS_EXAMPLE_SPANISH_STRINGS)( + 'iOS Spanish purpose strings in $stringsFile', + ({ stringsFile, pbxproj }) => { + it('translates both purpose strings', () => { + const strings = read(stringsFile); + + expect(stringsValue(strings, 'NSLocationWhenInUseUsageDescription')).toBe( + WHEN_IN_USE_ES + ); + expect(stringsValue(strings, PURPOSE_KEY)).toBe(PRECISE_ONCE_ES); + }); + + it('bundles the translation with the app', () => { + const project = read(pbxproj); + + expect(project).toMatch(/path = "?es\.lproj\/InfoPlist\.strings"?;/); + expect(project).toMatch( + /files = \([^)]*\/\* InfoPlist\.strings in Resources \*\// + ); + }); + } +); + describe('Expo example app config', () => { it('declares the same location purpose strings as its prebuilt Info.plist', () => { const infoPlist = JSON.parse(read('example-expo/app.json')).expo.ios @@ -68,6 +111,22 @@ describe('Expo example app config', () => { [PURPOSE_KEY]: PRECISE_ONCE, }); }); + + it('translates them to Spanish with the same locale file it was prebuilt from', () => { + const { locales } = JSON.parse(read('example-expo/app.json')).expo; + const spanish = JSON.parse(read(join('example-expo', locales.es))).ios; + const prebuilt = read( + 'example-expo/ios/DiditExpoExample/Supporting/es.lproj/InfoPlist.strings' + ); + + expect(spanish).toEqual({ + NSLocationWhenInUseUsageDescription: WHEN_IN_USE_ES, + [PURPOSE_KEY]: PRECISE_ONCE_ES, + }); + Object.entries(spanish).forEach(([key, value]) => + expect(stringsValue(prebuilt, key)).toBe(value) + ); + }); }); describe('README location setup', () => { @@ -83,12 +142,14 @@ describe('README location setup', () => { expect(plistString(temporary, PURPOSE_KEY)).toBe(PRECISE_ONCE); }); - it('documents the Spanish purpose string for InfoPlist.strings and Expo locales', () => { - expect(readme).toContain( - `"NSLocationWhenInUseUsageDescription" = "${WHEN_IN_USE_ES}";` - ); - expect(readme).toContain( - `{ "ios": { "NSLocationWhenInUseUsageDescription": "${WHEN_IN_USE_ES}" } }` + it('documents both Spanish purpose strings for InfoPlist.strings', () => { + expect(stringsValue(readme, 'NSLocationWhenInUseUsageDescription')).toBe( + WHEN_IN_USE_ES ); + expect(stringsValue(readme, PURPOSE_KEY)).toBe(PRECISE_ONCE_ES); + }); + + it('documents the same Spanish locale file as the Expo example', () => { + expect(readme).toContain(read('example-expo/locales/es.json').trim()); }); }); From 37b2a34af33185f9d0aa8854ef43a1ba61a4e614 Mon Sep 17 00:00:00 2001 From: Alejandro Rosas Date: Wed, 7 Oct 2026 23:14:58 +0000 Subject: [PATCH 3/4] DID-5770: [H10] React Native SDK: Bank and Location steps pass-through (continuation of DID-5658) Opened by a BotInc run. Delivery follows the workflow's review and merge policy. --- README.md | 1 + ios/DiditSdkBridge.swift | 2 + src/__tests__/native-error-types.test.ts | 116 +++++++++++++++++++++++ 3 files changed, 119 insertions(+) create mode 100644 src/__tests__/native-error-types.test.ts diff --git a/README.md b/README.md index 079e592..2194908 100644 --- a/README.md +++ b/README.md @@ -605,6 +605,7 @@ Both `startVerification` and `startVerificationWithWorkflow` return a `Promise ({ + __esModule: true, + default: { + startVerification: jest.fn(), + onTransactionUpdated: jest.fn(() => ({ remove: jest.fn() })), + }, +})); + +import NativeSdkReactNative from '../NativeSdkReactNative'; +import { startVerification } from '../index'; + +/** + * Each native bridge turns the native SDK's VerificationError into the + * `errorType` string the JS layer reads, and a case a bridge leaves out falls + * through to "unknown". The iOS bridge once had no retryBlocked case, so the + * same native error reached JS as "retryBlocked" on Android and "unknown" on + * iOS. Both switches are read here so the bridges keep sending the same string + * for every error both native SDKs share, and JS keeps every string they send. + */ + +const repoRoot = join(__dirname, '..', '..'); + +const read = (relativePath: string) => + readFileSync(join(repoRoot, relativePath), 'utf8'); + +/** The body of the bridge's mapErrorType, up to its closing brace. */ +function mapErrorTypeBody(source: string, signature: RegExp): string { + const start = source.search(signature); + expect(start).toBeGreaterThanOrEqual(0); + const end = source.indexOf('\n }\n', start); + expect(end).toBeGreaterThan(start); + return source.slice(start, end); +} + +/** Native case name in lower camel case -> errorType string sent to JS. */ +function iosErrorTypes(): Map { + const body = mapErrorTypeBody( + read('ios/DiditSdkBridge.swift'), + /private static func mapErrorType\(/ + ); + return new Map( + Array.from( + body.matchAll(/case \.(\w+)[^:]*:\s*return "(\w+)"/g), + (match) => [String(match[1]), String(match[2])] + ) + ); +} + +function androidErrorTypes(): Map { + const body = mapErrorTypeBody( + read('android/src/main/java/com/sdkreactnative/SdkReactNativeModule.kt'), + /private fun mapErrorType\(/ + ); + return new Map( + Array.from( + body.matchAll(/is VerificationError\.(\w+) -> "(\w+)"/g), + (match) => { + const name = String(match[1]); + return [name[0]!.toLowerCase() + name.slice(1), String(match[2])]; + } + ) + ); +} + +describe('native error type mapping', () => { + const ios = iosErrorTypes(); + const android = androidErrorTypes(); + + it('reads both bridge switches', () => { + const shared = ['sessionExpired', 'networkError', 'cameraAccessDenied']; + expect([...ios.keys()]).toEqual( + expect.arrayContaining([...shared, 'unknown']) + ); + expect([...android.keys()]).toEqual( + expect.arrayContaining([...shared, 'notInitialized', 'apiError']) + ); + }); + + it('sends retryBlocked from both bridges', () => { + expect(ios.get('retryBlocked')).toBe('retryBlocked'); + expect(android.get('retryBlocked')).toBe('retryBlocked'); + }); + + it('sends the same string from Android for every error the iOS bridge maps', () => { + for (const [nativeCase, errorType] of ios) { + expect([nativeCase, android.get(nativeCase)]).toEqual([ + nativeCase, + errorType, + ]); + } + }); + + it.each([...new Set([...ios.values(), ...android.values()])])( + 'keeps the %s error type in the JS result', + async (errorType) => { + (NativeSdkReactNative.startVerification as jest.Mock).mockResolvedValue({ + type: 'failed', + errorType, + errorMessage: 'native message', + sessionId: 'test-session', + status: 'Declined', + }); + + const result = await startVerification('test-token'); + + expect(result).toMatchObject({ + type: 'failed', + error: { type: errorType, message: 'native message' }, + session: { sessionId: 'test-session' }, + }); + } + ); +}); From ed2c661f5545e5f21f797761195a18b5f57e3e2c Mon Sep 17 00:00:00 2001 From: Alejandro Rosas Date: Thu, 8 Oct 2026 04:03:59 +0000 Subject: [PATCH 4/4] DID-5770: [H10] React Native SDK: Bank and Location steps pass-through (continuation of DID-5658) Opened by a BotInc run. Delivery follows the workflow's review and merge policy. --- .github/workflows/ci.yml | 13 ++ CONTRIBUTING.md | 10 ++ example/ios/Podfile | 5 + example/ios/Podfile.lock | 2 +- .../project.pbxproj | 155 ++++++++++++++++++ .../DiditSdkBridgeResultTests.swift | 61 +++++++ ios/DiditSdkBridge.swift | 23 ++- 7 files changed, 261 insertions(+), 8 deletions(-) create mode 100644 example/ios/SdkReactNativeExampleTests/DiditSdkBridgeResultTests.swift diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ded2c5a..7fed62a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -164,3 +164,16 @@ jobs: - name: Build example for iOS run: | yarn turbo run build:ios --cache-dir="${{ env.TURBO_CACHE_DIR }}" + + - name: Test iOS bridge + if: env.turbo_cache_hit != 1 + working-directory: example/ios + run: | + set -o pipefail + SIMULATOR_ID=$(xcrun simctl list devices available | grep -E '^[[:space:]]+iPhone' | grep -oE '[0-9A-F]{8}(-[0-9A-F]{4}){3}-[0-9A-F]{12}' | tail -n 1) + xcodebuild test \ + -workspace SdkReactNativeExample.xcworkspace \ + -scheme SdkReactNativeExample \ + -configuration Debug \ + -destination "id=$SIMULATOR_ID" \ + -only-testing:SdkReactNativeExampleTests | xcbeautify diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 1b384f9..c9c0c9e 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -90,6 +90,16 @@ Remember to add tests for your change if possible. Run the unit tests by: yarn test ``` +The iOS bridge has XCTests in `example/ios/SdkReactNativeExampleTests`, hosted by the example app. CI runs them in the iOS job; on a Mac, run them after `bundle exec pod install` in `example/ios`: + +```sh +cd example/ios +xcodebuild test \ + -workspace SdkReactNativeExample.xcworkspace \ + -scheme SdkReactNativeExample \ + -destination 'platform=iOS Simulator,name=' +``` + ### Commit message convention diff --git a/example/ios/Podfile b/example/ios/Podfile index 8d784cf..7efd12f 100644 --- a/example/ios/Podfile +++ b/example/ios/Podfile @@ -51,6 +51,11 @@ target 'SdkReactNativeExample' do :app_path => "#{Pod::Config.instance.installation_root}/.." ) + # The bridge's XCTests run inside the example app, which already links the pods. + target 'SdkReactNativeExampleTests' do + inherit! :search_paths + end + post_install do |installer| react_native_post_install( installer, diff --git a/example/ios/Podfile.lock b/example/ios/Podfile.lock index d99d7ac..417081d 100644 --- a/example/ios/Podfile.lock +++ b/example/ios/Podfile.lock @@ -2806,6 +2806,6 @@ SPEC CHECKSUMS: SocketRocket: d4aabe649be1e368d1318fdf28a022d714d65748 Yoga: 6ca93c8c13f56baeec55eb608577619b17a4d64e -PODFILE CHECKSUM: 24e175dec25ff17e5b9150dbd85e30c04dc87b94 +PODFILE CHECKSUM: 1175cc68b3547f6c408d7970385f57bff918c1ab COCOAPODS: 1.16.2 diff --git a/example/ios/SdkReactNativeExample.xcodeproj/project.pbxproj b/example/ios/SdkReactNativeExample.xcodeproj/project.pbxproj index 8b3e1d5..25a0ec0 100644 --- a/example/ios/SdkReactNativeExample.xcodeproj/project.pbxproj +++ b/example/ios/SdkReactNativeExample.xcodeproj/project.pbxproj @@ -7,15 +7,29 @@ objects = { /* Begin PBXBuildFile section */ + 00E356F31AD99517003FC87E /* DiditSdkBridgeResultTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 00E356F21AD99517003FC87E /* DiditSdkBridgeResultTests.swift */; }; 0C80B921A6F3F58F76C31292 /* libPods-SdkReactNativeExample.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5DCACB8F33CDC322A6C60F78 /* libPods-SdkReactNativeExample.a */; }; 13B07FBF1A68108700A75B9A /* Images.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 13B07FB51A68108700A75B9A /* Images.xcassets */; }; 22ABB861F5A1DCF99DA64262 /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = 13B07FB81A68108700A75B9A /* PrivacyInfo.xcprivacy */; }; + 2C0F1C6B0FF2962C9D0B282D /* libPods-SdkReactNativeExample-SdkReactNativeExampleTests.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 65CD951716839465CA77588F /* libPods-SdkReactNativeExample-SdkReactNativeExampleTests.a */; }; 761780ED2CA45674006654EE /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 761780EC2CA45674006654EE /* AppDelegate.swift */; }; 81AB9BB82411601600AC10FF /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 81AB9BB72411601600AC10FF /* LaunchScreen.storyboard */; }; A1C3E5F72E9B4D6100D1D1A1 /* InfoPlist.strings in Resources */ = {isa = PBXBuildFile; fileRef = A1C3E5F52E9B4D6100D1D1A1 /* InfoPlist.strings */; }; /* End PBXBuildFile section */ +/* Begin PBXContainerItemProxy section */ + 00E356F41AD99517003FC87E /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 83CBB9F71A601CBA00E9B192 /* Project object */; + proxyType = 1; + remoteGlobalIDString = 13B07F861A680F5B00A75B9A; + remoteInfo = SdkReactNativeExample; + }; +/* End PBXContainerItemProxy section */ + /* Begin PBXFileReference section */ + 00E356EE1AD99517003FC87E /* SdkReactNativeExampleTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = SdkReactNativeExampleTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; + 00E356F21AD99517003FC87E /* DiditSdkBridgeResultTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DiditSdkBridgeResultTests.swift; sourceTree = ""; }; 13B07F961A680F5B00A75B9A /* SdkReactNativeExample.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = SdkReactNativeExample.app; sourceTree = BUILT_PRODUCTS_DIR; }; 13B07FB51A68108700A75B9A /* Images.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; name = Images.xcassets; path = SdkReactNativeExample/Images.xcassets; sourceTree = ""; }; 13B07FB61A68108700A75B9A /* Info.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = Info.plist; path = SdkReactNativeExample/Info.plist; sourceTree = ""; }; @@ -23,13 +37,24 @@ 3B4392A12AC88292D35C810B /* Pods-SdkReactNativeExample.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-SdkReactNativeExample.debug.xcconfig"; path = "Target Support Files/Pods-SdkReactNativeExample/Pods-SdkReactNativeExample.debug.xcconfig"; sourceTree = ""; }; 5709B34CF0A7D63546082F79 /* Pods-SdkReactNativeExample.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-SdkReactNativeExample.release.xcconfig"; path = "Target Support Files/Pods-SdkReactNativeExample/Pods-SdkReactNativeExample.release.xcconfig"; sourceTree = ""; }; 5DCACB8F33CDC322A6C60F78 /* libPods-SdkReactNativeExample.a */ = {isa = PBXFileReference; explicitFileType = archive.ar; includeInIndex = 0; path = "libPods-SdkReactNativeExample.a"; sourceTree = BUILT_PRODUCTS_DIR; }; + 65CD951716839465CA77588F /* libPods-SdkReactNativeExample-SdkReactNativeExampleTests.a */ = {isa = PBXFileReference; explicitFileType = archive.ar; includeInIndex = 0; path = "libPods-SdkReactNativeExample-SdkReactNativeExampleTests.a"; sourceTree = BUILT_PRODUCTS_DIR; }; + 6CF1C5F710D90A466B8CE570 /* Pods-SdkReactNativeExample-SdkReactNativeExampleTests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-SdkReactNativeExample-SdkReactNativeExampleTests.debug.xcconfig"; path = "Target Support Files/Pods-SdkReactNativeExample-SdkReactNativeExampleTests/Pods-SdkReactNativeExample-SdkReactNativeExampleTests.debug.xcconfig"; sourceTree = ""; }; 761780EC2CA45674006654EE /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = AppDelegate.swift; path = SdkReactNativeExample/AppDelegate.swift; sourceTree = ""; }; 81AB9BB72411601600AC10FF /* LaunchScreen.storyboard */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = file.storyboard; name = LaunchScreen.storyboard; path = SdkReactNativeExample/LaunchScreen.storyboard; sourceTree = ""; }; A1C3E5F62E9B4D6100D1D1A1 /* es */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = es; path = es.lproj/InfoPlist.strings; sourceTree = ""; }; + D0E8BFFA6CE874103AF34566 /* Pods-SdkReactNativeExample-SdkReactNativeExampleTests.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-SdkReactNativeExample-SdkReactNativeExampleTests.release.xcconfig"; path = "Target Support Files/Pods-SdkReactNativeExample-SdkReactNativeExampleTests/Pods-SdkReactNativeExample-SdkReactNativeExampleTests.release.xcconfig"; sourceTree = ""; }; ED297162215061F000B7C4FE /* JavaScriptCore.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = JavaScriptCore.framework; path = System/Library/Frameworks/JavaScriptCore.framework; sourceTree = SDKROOT; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ + 00E356EB1AD99517003FC87E /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 2C0F1C6B0FF2962C9D0B282D /* libPods-SdkReactNativeExample-SdkReactNativeExampleTests.a in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; 13B07F8C1A680F5B00A75B9A /* Frameworks */ = { isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; @@ -41,6 +66,14 @@ /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ + 00E356EF1AD99517003FC87E /* SdkReactNativeExampleTests */ = { + isa = PBXGroup; + children = ( + 00E356F21AD99517003FC87E /* DiditSdkBridgeResultTests.swift */, + ); + path = SdkReactNativeExampleTests; + sourceTree = ""; + }; 13B07FAE1A68108700A75B9A /* SdkReactNativeExample */ = { isa = PBXGroup; children = ( @@ -59,6 +92,7 @@ children = ( ED297162215061F000B7C4FE /* JavaScriptCore.framework */, 5DCACB8F33CDC322A6C60F78 /* libPods-SdkReactNativeExample.a */, + 65CD951716839465CA77588F /* libPods-SdkReactNativeExample-SdkReactNativeExampleTests.a */, ); name = Frameworks; sourceTree = ""; @@ -74,6 +108,7 @@ isa = PBXGroup; children = ( 13B07FAE1A68108700A75B9A /* SdkReactNativeExample */, + 00E356EF1AD99517003FC87E /* SdkReactNativeExampleTests */, 832341AE1AAA6A7D00B99B32 /* Libraries */, 83CBBA001A601CBA00E9B192 /* Products */, 2D16E6871FA4F8E400B85C8A /* Frameworks */, @@ -88,6 +123,7 @@ isa = PBXGroup; children = ( 13B07F961A680F5B00A75B9A /* SdkReactNativeExample.app */, + 00E356EE1AD99517003FC87E /* SdkReactNativeExampleTests.xctest */, ); name = Products; sourceTree = ""; @@ -97,6 +133,8 @@ children = ( 3B4392A12AC88292D35C810B /* Pods-SdkReactNativeExample.debug.xcconfig */, 5709B34CF0A7D63546082F79 /* Pods-SdkReactNativeExample.release.xcconfig */, + 6CF1C5F710D90A466B8CE570 /* Pods-SdkReactNativeExample-SdkReactNativeExampleTests.debug.xcconfig */, + D0E8BFFA6CE874103AF34566 /* Pods-SdkReactNativeExample-SdkReactNativeExampleTests.release.xcconfig */, ); path = Pods; sourceTree = ""; @@ -104,6 +142,25 @@ /* End PBXGroup section */ /* Begin PBXNativeTarget section */ + 00E356ED1AD99517003FC87E /* SdkReactNativeExampleTests */ = { + isa = PBXNativeTarget; + buildConfigurationList = 00E357021AD99517003FC87E /* Build configuration list for PBXNativeTarget "SdkReactNativeExampleTests" */; + buildPhases = ( + 09B86318EBAF7EE8C45A9A6A /* [CP] Check Pods Manifest.lock */, + 00E356EA1AD99517003FC87E /* Sources */, + 00E356EB1AD99517003FC87E /* Frameworks */, + 00E356EC1AD99517003FC87E /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + 00E356F51AD99517003FC87E /* PBXTargetDependency */, + ); + name = SdkReactNativeExampleTests; + productName = SdkReactNativeExampleTests; + productReference = 00E356EE1AD99517003FC87E /* SdkReactNativeExampleTests.xctest */; + productType = "com.apple.product-type.bundle.unit-test"; + }; 13B07F861A680F5B00A75B9A /* SdkReactNativeExample */ = { isa = PBXNativeTarget; buildConfigurationList = 13B07F931A680F5B00A75B9A /* Build configuration list for PBXNativeTarget "SdkReactNativeExample" */; @@ -133,6 +190,10 @@ attributes = { LastUpgradeCheck = 1210; TargetAttributes = { + 00E356ED1AD99517003FC87E = { + CreatedOnToolsVersion = 6.2; + TestTargetID = 13B07F861A680F5B00A75B9A; + }; 13B07F861A680F5B00A75B9A = { LastSwiftMigration = 1120; }; @@ -153,11 +214,19 @@ projectRoot = ""; targets = ( 13B07F861A680F5B00A75B9A /* SdkReactNativeExample */, + 00E356ED1AD99517003FC87E /* SdkReactNativeExampleTests */, ); }; /* End PBXProject section */ /* Begin PBXResourcesBuildPhase section */ + 00E356EC1AD99517003FC87E /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; 13B07F8E1A680F5B00A75B9A /* Resources */ = { isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; @@ -205,6 +274,28 @@ shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-SdkReactNativeExample/Pods-SdkReactNativeExample-frameworks.sh\"\n"; showEnvVarsInLog = 0; }; + 09B86318EBAF7EE8C45A9A6A /* [CP] Check Pods Manifest.lock */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + "${PODS_PODFILE_DIR_PATH}/Podfile.lock", + "${PODS_ROOT}/Manifest.lock", + ); + name = "[CP] Check Pods Manifest.lock"; + outputFileListPaths = ( + ); + outputPaths = ( + "$(DERIVED_FILE_DIR)/Pods-SdkReactNativeExample-SdkReactNativeExampleTests-checkManifestLockResult.txt", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; + showEnvVarsInLog = 0; + }; C38B50BA6285516D6DCD4F65 /* [CP] Check Pods Manifest.lock */ = { isa = PBXShellScriptBuildPhase; buildActionMask = 2147483647; @@ -247,6 +338,14 @@ /* End PBXShellScriptBuildPhase section */ /* Begin PBXSourcesBuildPhase section */ + 00E356EA1AD99517003FC87E /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 00E356F31AD99517003FC87E /* DiditSdkBridgeResultTests.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; 13B07F871A680F5B00A75B9A /* Sources */ = { isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; @@ -257,6 +356,14 @@ }; /* End PBXSourcesBuildPhase section */ +/* Begin PBXTargetDependency section */ + 00E356F51AD99517003FC87E /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 13B07F861A680F5B00A75B9A /* SdkReactNativeExample */; + targetProxy = 00E356F41AD99517003FC87E /* PBXContainerItemProxy */; + }; +/* End PBXTargetDependency section */ + /* Begin PBXVariantGroup section */ A1C3E5F52E9B4D6100D1D1A1 /* InfoPlist.strings */ = { isa = PBXVariantGroup; @@ -270,6 +377,45 @@ /* End PBXVariantGroup section */ /* Begin XCBuildConfiguration section */ + 00E356F61AD99517003FC87E /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 6CF1C5F710D90A466B8CE570 /* Pods-SdkReactNativeExample-SdkReactNativeExampleTests.debug.xcconfig */; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + GENERATE_INFOPLIST_FILE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 15.1; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@loader_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = me.didit.sdk.reactnative.example.tests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/SdkReactNativeExample.app/SdkReactNativeExample"; + }; + name = Debug; + }; + 00E356F71AD99517003FC87E /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = D0E8BFFA6CE874103AF34566 /* Pods-SdkReactNativeExample-SdkReactNativeExampleTests.release.xcconfig */; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + GENERATE_INFOPLIST_FILE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 15.1; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@loader_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = me.didit.sdk.reactnative.example.tests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/SdkReactNativeExample.app/SdkReactNativeExample"; + }; + name = Release; + }; 13B07F941A680F5B00A75B9A /* Debug */ = { isa = XCBuildConfiguration; baseConfigurationReference = 3B4392A12AC88292D35C810B /* Pods-SdkReactNativeExample.debug.xcconfig */; @@ -475,6 +621,15 @@ /* End XCBuildConfiguration section */ /* Begin XCConfigurationList section */ + 00E357021AD99517003FC87E /* Build configuration list for PBXNativeTarget "SdkReactNativeExampleTests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 00E356F61AD99517003FC87E /* Debug */, + 00E356F71AD99517003FC87E /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; 13B07F931A680F5B00A75B9A /* Build configuration list for PBXNativeTarget "SdkReactNativeExample" */ = { isa = XCConfigurationList; buildConfigurations = ( diff --git a/example/ios/SdkReactNativeExampleTests/DiditSdkBridgeResultTests.swift b/example/ios/SdkReactNativeExampleTests/DiditSdkBridgeResultTests.swift new file mode 100644 index 0000000..9cd17bd --- /dev/null +++ b/example/ios/SdkReactNativeExampleTests/DiditSdkBridgeResultTests.swift @@ -0,0 +1,61 @@ +import XCTest +import DiditSDK +@testable import SdkReactNative + +/// Hands results built from the pinned DiditSDK to the bridge's delivery path +/// (the one its verification view calls) and reads the dictionary the +/// TurboModule resolves to JS. Every error type must match the string the +/// Android bridge sends for the same native error. +final class DiditSdkBridgeResultTests: XCTestCase { + + func testRetryBlockedReachesJavaScriptAsRetryBlocked() { + let result = deliver( + .failed( + error: .retryBlocked, + session: SessionData(sessionId: "test-session", status: .declined) + ) + ) + + XCTAssertEqual(result["type"] as? String, "failed") + XCTAssertEqual(result["errorType"] as? String, "retryBlocked") + XCTAssertEqual(result["sessionId"] as? String, "test-session") + XCTAssertEqual(result["status"] as? String, "Declined") + } + + func testEveryNativeErrorKeepsTheAndroidErrorType() { + let expected: [(VerificationError, String)] = [ + (.sessionExpired, "sessionExpired"), + (.retryBlocked, "retryBlocked"), + (.networkError, "networkError"), + (.cameraAccessDenied, "cameraAccessDenied"), + (.unknown("native message"), "unknown"), + ] + + for (error, errorType) in expected { + let result = deliver(.failed(error: error, session: nil)) + + XCTAssertEqual(result["errorType"] as? String, errorType, "\(error)") + XCTAssertEqual(result["errorMessage"] as? String, error.localizedDescription, "\(error)") + } + } + + /// Starts a presentation, hands it one native result and waits for what + /// the bridge resolves. + private func deliver(_ result: VerificationResult) -> NSDictionary { + let bridge = DiditSdkBridge() + let resolved = Resolved() + let delivered = expectation(description: "result delivered") + + bridge.deliverResult(result, generation: bridge.beginPresentation()) { dictionary in + resolved.dictionary = dictionary + delivered.fulfill() + } + + wait(for: [delivered], timeout: 5) + return resolved.dictionary + } +} + +private final class Resolved: @unchecked Sendable { + var dictionary: NSDictionary = [:] +} diff --git a/ios/DiditSdkBridge.swift b/ios/DiditSdkBridge.swift index 5c42623..c7fdc37 100644 --- a/ios/DiditSdkBridge.swift +++ b/ios/DiditSdkBridge.swift @@ -97,12 +97,7 @@ public class DiditSdkBridge: NSObject, @unchecked Sendable { let bridgeView = DiditBridgeView( onResult: { [weak self] result in - guard let self = self, self.claimResultDelivery(for: generation) else { return } - self.tearDownThenResolve( - generation: generation, - result: Self.mapVerificationResult(result), - completion: completion - ) + self?.deliverResult(result, generation: generation, completion: completion) } ) @@ -120,12 +115,26 @@ public class DiditSdkBridge: NSObject, @unchecked Sendable { hostingController = nil } - private func beginPresentation() -> Int { + func beginPresentation() -> Int { presentationGeneration += 1 hasDeliveredResult = false return presentationGeneration } + /// Sends the native result of one presentation to JS, once, after its host is dismissed. + func deliverResult( + _ result: VerificationResult, + generation: Int, + completion: @escaping @Sendable (NSDictionary) -> Void + ) { + guard claimResultDelivery(for: generation) else { return } + tearDownThenResolve( + generation: generation, + result: Self.mapVerificationResult(result), + completion: completion + ) + } + private func claimResultDelivery(for generation: Int) -> Bool { guard !hasDeliveredResult, presentationGeneration == generation else { return false } hasDeliveredResult = true