diff --git a/.gitignore b/.gitignore
index f5f66d945..feb025477 100644
--- a/.gitignore
+++ b/.gitignore
@@ -8,6 +8,7 @@ yarn-error.log
# Builds
android/.gradle/
+**/android/.gradle/
android/build/
**/android/build/
ios/build/
diff --git a/PingSampleApp/android/app/build.gradle b/PingSampleApp/android/app/build.gradle
index 154476feb..60c1f6014 100644
--- a/PingSampleApp/android/app/build.gradle
+++ b/PingSampleApp/android/app/build.gradle
@@ -129,7 +129,7 @@ android {
excludes += "/META-INF/versions/9/OSGI-INF/MANIFEST.MF"
}
}
-
+
compileOptions {
sourceCompatibility JavaVersion.VERSION_21
targetCompatibility JavaVersion.VERSION_21
diff --git a/PingSampleApp/ios/Podfile.lock b/PingSampleApp/ios/Podfile.lock
index c90092f75..c0c43d92b 100644
--- a/PingSampleApp/ios/Podfile.lock
+++ b/PingSampleApp/ios/Podfile.lock
@@ -25,7 +25,7 @@ PODS:
- FBSDKCoreKit (= 16.3.1)
- fmt (11.0.2)
- glog (0.3.5)
- - GoogleDataTransport (10.1.0):
+ - GoogleDataTransport (10.1.1):
- nanopb (~> 3.30910.0)
- PromisesObjC (~> 2.4)
- GoogleMLKit/BarcodeScanning (8.0.0):
@@ -43,44 +43,44 @@ PODS:
- GoogleToolboxForMac/Defines (= 4.2.1)
- "GoogleToolboxForMac/NSData+zlib (4.2.1)":
- GoogleToolboxForMac/Defines (= 4.2.1)
- - GoogleUtilities (8.1.0):
- - GoogleUtilities/AppDelegateSwizzler (= 8.1.0)
- - GoogleUtilities/Environment (= 8.1.0)
- - GoogleUtilities/Logger (= 8.1.0)
- - GoogleUtilities/MethodSwizzler (= 8.1.0)
- - GoogleUtilities/Network (= 8.1.0)
- - "GoogleUtilities/NSData+zlib (= 8.1.0)"
- - GoogleUtilities/Privacy (= 8.1.0)
- - GoogleUtilities/Reachability (= 8.1.0)
- - GoogleUtilities/SwizzlerTestHelpers (= 8.1.0)
- - GoogleUtilities/UserDefaults (= 8.1.0)
- - GoogleUtilities/AppDelegateSwizzler (8.1.0):
+ - GoogleUtilities (8.1.2):
+ - GoogleUtilities/AppDelegateSwizzler (= 8.1.2)
+ - GoogleUtilities/Environment (= 8.1.2)
+ - GoogleUtilities/Logger (= 8.1.2)
+ - GoogleUtilities/MethodSwizzler (= 8.1.2)
+ - GoogleUtilities/Network (= 8.1.2)
+ - "GoogleUtilities/NSData+zlib (= 8.1.2)"
+ - GoogleUtilities/Privacy (= 8.1.2)
+ - GoogleUtilities/Reachability (= 8.1.2)
+ - GoogleUtilities/SwizzlerTestHelpers (= 8.1.2)
+ - GoogleUtilities/UserDefaults (= 8.1.2)
+ - GoogleUtilities/AppDelegateSwizzler (8.1.2):
- GoogleUtilities/Environment
- GoogleUtilities/Logger
- GoogleUtilities/Network
- GoogleUtilities/Privacy
- - GoogleUtilities/Environment (8.1.0):
+ - GoogleUtilities/Environment (8.1.2):
- GoogleUtilities/Privacy
- - GoogleUtilities/Logger (8.1.0):
+ - GoogleUtilities/Logger (8.1.2):
- GoogleUtilities/Environment
- GoogleUtilities/Privacy
- - GoogleUtilities/MethodSwizzler (8.1.0):
+ - GoogleUtilities/MethodSwizzler (8.1.2):
- GoogleUtilities/Logger
- GoogleUtilities/Privacy
- - GoogleUtilities/Network (8.1.0):
+ - GoogleUtilities/Network (8.1.2):
- GoogleUtilities/Logger
- "GoogleUtilities/NSData+zlib"
- GoogleUtilities/Privacy
- GoogleUtilities/Reachability
- - "GoogleUtilities/NSData+zlib (8.1.0)":
+ - "GoogleUtilities/NSData+zlib (8.1.2)":
- GoogleUtilities/Privacy
- - GoogleUtilities/Privacy (8.1.0)
- - GoogleUtilities/Reachability (8.1.0):
+ - GoogleUtilities/Privacy (8.1.2)
+ - GoogleUtilities/Reachability (8.1.2):
- GoogleUtilities/Logger
- GoogleUtilities/Privacy
- - GoogleUtilities/SwizzlerTestHelpers (8.1.0):
+ - GoogleUtilities/SwizzlerTestHelpers (8.1.2):
- GoogleUtilities/MethodSwizzler
- - GoogleUtilities/UserDefaults (8.1.0):
+ - GoogleUtilities/UserDefaults (8.1.2):
- GoogleUtilities/Logger
- GoogleUtilities/Privacy
- GTMAppAuth (5.0.0):
@@ -2607,6 +2607,7 @@ PODS:
- hermes-engine
- PingDavinci (= 2.1.0)
- PingDavinciPlugin (= 2.1.0)
+ - PingExternalIdP (= 2.1.0)
- PingLogger (= 2.1.0)
- PingOidc (= 2.1.0)
- PingOrchestrate (= 2.1.0)
@@ -2931,7 +2932,7 @@ PODS:
- fmt
- glog
- hermes-engine
- - PingLogger (~> 2.1.0)
+ - PingLogger (= 2.1.0)
- PingPush (= 2.1.0)
- PingStorage (= 2.1.0)
- RCT-Folly
@@ -3569,11 +3570,11 @@ SPEC CHECKSUMS:
FBSDKLoginKit: 572cca0bc6c90067ef197187697cb3b584310c52
fmt: a40bb5bd0294ea969aaaba240a927bd33d878cdd
glog: 5683914934d5b6e4240e497e0f4a3b42d1854183
- GoogleDataTransport: aae35b7ea0c09004c3797d53c8c41f66f219d6a7
+ GoogleDataTransport: a24e58982ab3ba2f64d79613e027fe7f57e88539
GoogleMLKit: ddd51d7dff36ff28defa69afedd9cdce684fd857
GoogleSignIn: c7f09cfbc85a1abf69187be091997c317cc33b77
GoogleToolboxForMac: d1a2cbf009c453f4d6ded37c105e2f67a32206d8
- GoogleUtilities: 00c88b9a86066ef77f0da2fab05f65d7768ed8e1
+ GoogleUtilities: 766ace00c6b10d8148408f329d10c4f051931850
GTMAppAuth: 217a876b249c3c585a54fd6f73e6b58c4f5c4238
GTMSessionFetcher: 5aea5ba6bd522a239e236100971f10cb71b96ab6
hermes-engine: 4f07404533b808de66cf48ac4200463068d0e95a
@@ -3615,61 +3616,61 @@ SPEC CHECKSUMS:
RCTTypeSafety: d877728097547d0a37786cc9130c43ad71739ac3
React: 4b0b9cb962e694611e5e8a697c1b0300a2510c21
React-callinvoker: 70f125c17c7132811a6b473946ac5e7ae93b5e57
- React-Core: bab40f5b1f46fe0c5896895a6f333e861a821a81
- React-CoreModules: 05647d952e521113c128360633896ba7ba652e82
- React-cxxreact: 2b4bac1ec6eecc6288ac8a6caea6afb42585740e
+ React-Core: 7cbc3118df2334b2ef597d9a515938b02c82109f
+ React-CoreModules: 7d8c14ecb889e7786a04637583b55b7d8f246baf
+ React-cxxreact: f32be07cba236c2f20f4e05ca200577ba5358e78
React-debug: deb3a146ef717fa3e8f4c23e0288369fe53199b7
- React-defaultsnativemodule: 11e2948787a15d3cf1b66d7f29f13770a177bff7
- React-domnativemodule: 2f4b279acdb2963736fb5de2f585811dd90070b5
- React-Fabric: 6f8d1a303c96f1d078c14d74c4005bf457e5b782
- React-FabricComponents: b106410970e9a0c4e592da656c7a7e0947306c23
- React-FabricImage: 1abaf230dfce9b58fdf53c4128f3f40c6e64af6a
- React-featureflags: f7ef58d91079efde3ad223bcca6d197e845d5bcf
- React-featureflagsnativemodule: ae5abc9849d1696f4f8f11ee3744bf5715e032cf
- React-graphics: b306856c6ed9aac32f717a229550406a53b28a6d
- React-hermes: b6edce8fa19388654b1aea30844497cbeade83bc
- React-idlecallbacksnativemodule: cb386712842cb9e479c89311edb234d529b64db4
- React-ImageManager: 8ce94417853eaa22faaad1f4cc1952dd3f8e2275
- React-jserrorhandler: ab827d67dc270a9c8703eef524230baeafaf6876
- React-jsi: 545342ec5c78ab1277af5f0dbe8d489e7e73db14
- React-jsiexecutor: 20210891c7c77255c16dec6762faf68b373f9f74
- React-jsinspector: 4e73460e488132d70d2b4894e5578cc856f2cb74
- React-jsinspectorcdp: 8b2bcb5779289cb2b9ca517f2965ed23eb2fd3e0
- React-jsinspectornetwork: b5e0cb9e488d294eed2d8209dc3dc0f9587210c1
- React-jsinspectortracing: f3c4036e7b984405ac910f878576d325dd9f2834
- React-jsitooling: 75bbfd221b6173a5e848ca5a6680506bac064a56
- React-jsitracing: 11ed7d821864dd988c159d4943e0a1e0937c11b1
- React-logger: 984ebd897afad067555d081deaf03f57c4315723
- React-Mapbuffer: 0c045c844ce6d85cde53e85ab163294c6adad349
- React-microtasksnativemodule: d9499269ad1f484ae71319bac1d9231447f2094e
- react-native-config: adc7d6e70302363192ed8f2eae027864d218ff71
- react-native-safe-area-context: 01f6d357d42395422eed7c3bfe715614728f2989
- React-NativeModulesApple: 983f3483ef0a3446b56d490f09d579fba2442e17
+ React-defaultsnativemodule: 2c13a4240c5f96c42d069d1ba2392de6b4145bbd
+ React-domnativemodule: 91349b0b1cb20310cec1341b87cdd461aaa85e57
+ React-Fabric: bdfc7ec2481f26d7a9b8f59461f29ba4d903c549
+ React-FabricComponents: 47898469543d1bfb4528a9846419ec5568be89b1
+ React-FabricImage: ac8fc85ef452e5e9ae935c41118814651bd9e7f3
+ React-featureflags: 793b911e4c53e680db4a7d9965d0d6dc87b2fa88
+ React-featureflagsnativemodule: 25c9516d0dd004493c9bbafeb97da20bf9bde7dc
+ React-graphics: e07281690425dd9eeba3875d1faad28bc1f6da3b
+ React-hermes: bc1440d0e0662cc813bbf1c5ffbf9e0db2993a0f
+ React-idlecallbacksnativemodule: a2a3bb4a1793280b34d06d00169153b094be8c16
+ React-ImageManager: c9fa7461f3cab08e7bc98cbf55455b499e71c8b3
+ React-jserrorhandler: 15e591702040afed99cfcd088cf2337a8d09d807
+ React-jsi: 512ab3a1a628bc8824c41de8bcbbb81b2ac6fa8d
+ React-jsiexecutor: 653ccd2dee1e5ea558eecaf2f27b8bba0f09add8
+ React-jsinspector: 9121ccd2676a3f7c079ac01c9f90183422e3190e
+ React-jsinspectorcdp: 5c723ff2a09d73f2fdc496a545fb7003e7fdc079
+ React-jsinspectornetwork: 9cb0173f69e8405cef33fc79030fad26bbc3c073
+ React-jsinspectortracing: 65dc04125dc2392d85a82b6916f8cb088ea77566
+ React-jsitooling: 21af93cc98f760dd88d65b06b9317e0d4849fbbc
+ React-jsitracing: 4cc1b7de8087ae41c61a0eeee2593bc3362908b6
+ React-logger: 2f0d40bc8e648fbb1ff3b6580ad54189a8753290
+ React-Mapbuffer: 9a7c65078c6851397c1999068989e4fc239d0c80
+ React-microtasksnativemodule: 4f1ef719ba6c7ebbd2d75346ffa2916f9b4771c9
+ react-native-config: 85858ab5e598762e783a43ac592561ad110c35a9
+ react-native-safe-area-context: d446989793f96dc2f44c33c42dbfb316d983f24e
+ React-NativeModulesApple: f6f696e510b9d89c3c06b7764f56947dc13ae922
React-oscompat: 114036cd8f064558c9c1a0c04fc9ae5e1453706a
- React-perflogger: e7287fee27c16e3c8bd4d470f2361572b63be16b
- React-performancetimeline: 8ebbaa31d2d0cea680b0a2a567500d3cab8954fc
+ React-perflogger: 4b2f88ae059b600daf268528a4a83366338eef05
+ React-performancetimeline: e15fd9798123436f99e46898422fe921fecf506b
React-RCTActionSheet: 68c68b0a7a5d2b0cfc255c64889b6e485974e988
- React-RCTAnimation: d6c5c728b888a967ce9aff1ff71a8ed71a68d069
- React-RCTAppDelegate: 0fc048666bda159cd469a6fb9befb04b3fa62be4
- React-RCTBlob: 12d8c699a1f906840113ee8d8bb575e69a05509f
- React-RCTFabric: 01e815845ebc185f44205dcbf50eeb712fec23fe
- React-RCTFBReactNativeSpec: f57927fb0af6ce2f25c19f8b894e2986138aa89f
- React-RCTImage: a82518168f4ee407913b23ca749ca79ef51959f3
- React-RCTLinking: 7f343b584c36f024f390fea563483568fe763ef6
- React-RCTNetwork: 3165eb757ceb62a7cde4cdad043d63314122e8a3
- React-RCTRuntime: feee590c459c4cb6aaa7a00f3abc8c04709b536f
- React-RCTSettings: 6bad0ae45d8d872c873059f332f586f99875621f
- React-RCTText: 657d60f35983062de8f0cea67c279aa7a3ea9858
- React-RCTVibration: 78f4770515141efb7f55f9b27c49dda95319c3a8
+ React-RCTAnimation: 6bf502c89c53076f92cd1a254f5ec8d63ee263de
+ React-RCTAppDelegate: c90f5732784684c3dd226d812eccb578cd954ad7
+ React-RCTBlob: d2905f01749b80efd6d3b86fb15e30ed26d5450b
+ React-RCTFabric: 435b3ffaad113fb1f274c2f2a677c9fcc9b5cf55
+ React-RCTFBReactNativeSpec: a3178b419f42af196e90ca4bf07710dce5d68301
+ React-RCTImage: 8f5ffa03461339180a68820ea452af6e20ace2c7
+ React-RCTLinking: 1151646834d31f97580d8a75d768a84b2533b7f9
+ React-RCTNetwork: 52008724d0db90a540f4058ed0de0e41c4b7943c
+ React-RCTRuntime: 10ce9a7cb27ba307544d29a2a04e6202dc7b3e9a
+ React-RCTSettings: f724cacbd892ee18f985e1aebdd97386e49c76f5
+ React-RCTText: 6e1b95d9126d808410dfa96e09bc4441ec6f36f7
+ React-RCTVibration: 862a4e5b36d49e6299c8cbfb86486fc31f86f6fa
React-rendererconsistency: f7baab26c6d0cd5b2eb7afcecfd2d8b957017b18
- React-renderercss: bdd2f83a4a054c3e4321fd61305c202b848e471b
- React-rendererdebug: 9f8865ee038127a9d99d4b034c9da4935d204993
+ React-renderercss: 62acb8f010a062309e3bd0e203aa14636162e3b3
+ React-rendererdebug: 3a89ac44f15c7160735264d585a29525655238d2
React-rncore: f7438473c4c71ee1963fb06a8635bb96013c9e1c
- React-RuntimeApple: 4d2ab9f72b9193da86eceded128a67254fc18aeb
- React-RuntimeCore: 5fd73030438d094975ca0f549d162dd97746ae38
+ React-RuntimeApple: 81f0a9ba81ce7eb203529b0471dc69bf18f5f637
+ React-RuntimeCore: 6356e89b2518ba66a989c39a2adb18122a5e3b7b
React-runtimeexecutor: 17c70842d5e611130cb66f91e247bc4a609c3508
- React-RuntimeHermes: 3c88e6e1ea7ea0899dcffc77c10d61ea46688cfd
- React-runtimescheduler: 024500621c7c93d65371498abb4ee26d34f5d47d
+ React-RuntimeHermes: 0a1d7ce2fe08cf182235de1a9330b51aa6b935cd
+ React-runtimescheduler: 10ae98e1417eff159be5df8fdc8fcdaac557aba6
React-timing: c3c923df2b86194e1682e01167717481232f1dc7
React-utils: 7791a96e194eec85cb41dc98a2045b5f07839598
ReactAppDependencyProvider: ba631a31783569c13056dd57ff39e19764abdd6f
@@ -3680,7 +3681,7 @@ SPEC CHECKSUMS:
RNPingBinding: 1731d5254b7d026df05ee056ab7802f4888537e9
RNPingBrowser: b6afbf0fa46c9ae0a10daeb46927f05eca5c7c6d
RNPingCore: 3926e39f9ab60d83fb4d58225303864ca0d8171b
- RNPingDavinci: 81122638cb43d20c7e24e66eea5bd8700fd48aae
+ RNPingDavinci: 605780d605c87d00216376bb2b3f4f5ee72817e8
RNPingDeviceClient: 0ff89a604e97d1168eff3aba4b1d889139619bd7
RNPingDeviceId: 6eb3e9fc68a78e1aee87652e025f6e1ec6d5d67e
RNPingDeviceProfile: 3086f14fc5024196a64290afbbf7917432dd5900
@@ -3690,16 +3691,16 @@ SPEC CHECKSUMS:
RNPingLogger: b7dd73f42b3fea86a56fc7a35999cf4333fbde20
RNPingOath: 261b44b957975c0c036b1bed6ef953c4767b8fcc
RNPingOidc: d490892cb09a11c189b2259bca0fecfc09a109fd
- RNPingPush: 3f02ee0f3d971c0d8affdb13b8fcefc4de789de1
+ RNPingPush: d160e2fac65d1f19b0e95d9196f618cc5aaaddf1
RNPingStorage: 2c45c6d29cfcf8e1ed8ec3e32df1443e29dabd67
RNScreens: 846d53087db560ed5fbc34feb0643adb5f9602c5
RNSVG: e8fb86f41fccd7b67c4480bb9e179e0ad5785b80
RNVectorIcons: 54df27a2e90ddeb674c7237d76060ec9762d0bc5
SocketRocket: d4aabe649be1e368d1318fdf28a022d714d65748
- VisionCamera: e82024ee7ce0fb6168a4c468ef43c711f4486baa
- VisionCameraBarcodeScanner: a65d708fd3672e6d43e94cce39b548465851752b
+ VisionCamera: 3914b29387fc40c64f7022830cfdc604f94eb966
+ VisionCameraBarcodeScanner: 0b6fe2e3c52ca68a0dbd5d25719c7523c68d807f
Yoga: daa1e4de4b971b977b23bc842aaa3e135324f1f3
PODFILE CHECKSUM: 35159028c49678100eea2347153c0cf4cd45998f
-COCOAPODS: 1.16.2
+COCOAPODS: 1.15.2
diff --git a/PingSampleApp/src/styles/davinciStyles.ts b/PingSampleApp/src/styles/davinciStyles.ts
index ce30e1d22..2b30379a6 100644
--- a/PingSampleApp/src/styles/davinciStyles.ts
+++ b/PingSampleApp/src/styles/davinciStyles.ts
@@ -342,4 +342,20 @@ export const davinciFieldStyles = StyleSheet.create({
fontWeight: '600',
textDecorationLine: 'underline',
},
+ qrCodeImage: {
+ width: 220,
+ height: 220,
+ alignSelf: 'center',
+ },
+ pollingStatusRow: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ justifyContent: 'center',
+ marginTop: 8,
+ },
+ pollingStatusText: {
+ color: colors.gray,
+ fontSize: 13,
+ marginLeft: 8,
+ },
});
diff --git a/PingSampleApp/ui/davinci/components/molecules/DaVinciFieldRenderer.tsx b/PingSampleApp/ui/davinci/components/molecules/DaVinciFieldRenderer.tsx
index 52b086463..5722f2398 100644
--- a/PingSampleApp/ui/davinci/components/molecules/DaVinciFieldRenderer.tsx
+++ b/PingSampleApp/ui/davinci/components/molecules/DaVinciFieldRenderer.tsx
@@ -13,6 +13,8 @@ import DaVinciLabelField from './DaVinciLabelField';
import DaVinciMultiSelectField from './DaVinciMultiSelectField';
import DaVinciPasswordField from './DaVinciPasswordField';
import DaVinciPhoneNumberField from './DaVinciPhoneNumberField';
+import DaVinciPollingField from './DaVinciPollingField';
+import DaVinciQrCodeField from './DaVinciQrCodeField';
import DaVinciSingleSelectField from './DaVinciSingleSelectField';
import DaVinciSubmitButton from './DaVinciSubmitButton';
import DaVinciTextField from './DaVinciTextField';
@@ -61,6 +63,10 @@ function DaVinciFieldRenderer(
return (
);
+ case 'POLLING':
+ return ;
+ case 'QR_CODE':
+ return ;
default:
return ;
}
diff --git a/PingSampleApp/ui/davinci/components/molecules/DaVinciPollingField.tsx b/PingSampleApp/ui/davinci/components/molecules/DaVinciPollingField.tsx
new file mode 100644
index 000000000..04afa8c38
--- /dev/null
+++ b/PingSampleApp/ui/davinci/components/molecules/DaVinciPollingField.tsx
@@ -0,0 +1,100 @@
+/*
+ * Copyright (c) 2026 Ping Identity Corporation. All rights reserved.
+ *
+ * This software may be modified and distributed under the terms
+ * of the MIT license. See the LICENSE file for details.
+ */
+
+import React, { useEffect, useState } from 'react';
+import { ActivityIndicator, Text, View } from 'react-native';
+import type {
+ PollingCollector,
+ PollingStatus,
+} from '@ping-identity/rn-davinci';
+import { colors } from '../../../../src/styles/colors';
+import { davinciFieldStyles } from '../../../../src/styles/davinciStyles';
+import type { DaVinciCollectorRendererProps } from './types';
+
+/**
+ * Resolves a human-readable message for a {@link PollingStatus} tick.
+ *
+ * @param status Streamed polling status.
+ * @returns User-facing status message.
+ */
+function describeStatus(status: PollingStatus): string {
+ switch (status.status) {
+ case 'continue':
+ return `Waiting for approval (${status.retryCount}/${status.maxRetries})...`;
+ case 'complete':
+ return 'Approved.';
+ case 'timedOut':
+ return 'Polling timed out.';
+ case 'expired':
+ return 'Polling expired.';
+ case 'error':
+ return status.error.message;
+ default:
+ return '';
+ }
+}
+
+/**
+ * Renders a {@link PollingCollector} by subscribing to `pollStatus` on mount
+ * and displaying the latest streamed status until a terminal tick arrives.
+ *
+ * @remarks
+ * Does not call `next()` itself — the controller's `onPollStatus` callback
+ * is expected to advance the flow once a terminal status is observed.
+ *
+ * @param props Renderer props.
+ * @returns Polling status field element.
+ */
+export default function DaVinciPollingField(
+ props: DaVinciCollectorRendererProps,
+): React.ReactElement {
+ const { collector, onPollStatus } = props;
+ const pollingCollector = collector as PollingCollector;
+ const [status, setStatus] = useState(null);
+
+ useEffect(() => {
+ let unsubscribe: (() => void) | undefined;
+ let cancelled = false;
+
+ onPollStatus(pollingCollector, nextStatus => {
+ if (!cancelled) {
+ setStatus(nextStatus);
+ }
+ })
+ .then(unsub => {
+ if (cancelled) {
+ unsub();
+ return;
+ }
+ unsubscribe = unsub;
+ })
+ .catch(() => {
+ // Surfaced via the panel's shared `error` state.
+ });
+
+ return () => {
+ cancelled = true;
+ unsubscribe?.();
+ };
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [pollingCollector.key]);
+
+ const isTerminal = status !== null && status.status !== 'continue';
+
+ return (
+
+
+ {isTerminal ? null : (
+
+ )}
+
+ {status ? describeStatus(status) : 'Waiting for approval...'}
+
+
+
+ );
+}
diff --git a/PingSampleApp/ui/davinci/components/molecules/DaVinciQrCodeField.tsx b/PingSampleApp/ui/davinci/components/molecules/DaVinciQrCodeField.tsx
new file mode 100644
index 000000000..61925666b
--- /dev/null
+++ b/PingSampleApp/ui/davinci/components/molecules/DaVinciQrCodeField.tsx
@@ -0,0 +1,49 @@
+/*
+ * Copyright (c) 2026 Ping Identity Corporation. All rights reserved.
+ *
+ * This software may be modified and distributed under the terms
+ * of the MIT license. See the LICENSE file for details.
+ */
+
+import React from 'react';
+import { Image, Text, View } from 'react-native';
+import type { QRCodeCollector } from '@ping-identity/rn-davinci';
+import { davinciFieldStyles } from '../../../../src/styles/davinciStyles';
+import type { DaVinciCollectorRendererProps } from './types';
+
+/**
+ * Renders a {@link QRCodeCollector} as a scannable image.
+ *
+ * @remarks
+ * `content` is a full data URI supplied by the bridge — no client-side QR
+ * generation is required. Falls back to `fallbackText` when the image fails
+ * to load.
+ *
+ * @param props Renderer props.
+ * @returns QR code field element.
+ */
+export default function DaVinciQrCodeField(
+ props: DaVinciCollectorRendererProps,
+): React.ReactElement {
+ const { collector } = props;
+ const qrCollector = collector as QRCodeCollector;
+ const [failed, setFailed] = React.useState(false);
+
+ return (
+
+ {failed || !qrCollector.content ? (
+
+ {qrCollector.fallbackText}
+
+ ) : (
+ setFailed(true)}
+ accessibilityLabel={qrCollector.fallbackText}
+ />
+ )}
+
+ );
+}
diff --git a/PingSampleApp/ui/davinci/components/molecules/types.ts b/PingSampleApp/ui/davinci/components/molecules/types.ts
index 53c41af0b..087b16248 100644
--- a/PingSampleApp/ui/davinci/components/molecules/types.ts
+++ b/PingSampleApp/ui/davinci/components/molecules/types.ts
@@ -9,6 +9,8 @@ import type {
DaVinciFormValue,
DaVinciNormalizedCollector,
IdpCollector,
+ PollingCollector,
+ PollingStatus,
} from '@ping-identity/rn-davinci';
/**
@@ -53,4 +55,20 @@ export type DaVinciCollectorRendererProps = {
* @param collector The IdpCollector to authorize.
*/
onIdpAuthorize: (collector: IdpCollector) => Promise;
+ /**
+ * Streams {@link PollingStatus} updates for a {@link PollingCollector}.
+ *
+ * @remarks
+ * Mirrors `DaVinciClient.pollStatus` scoped to a specific collector key —
+ * the molecule owns subscription lifecycle (start on mount, unsubscribe on
+ * unmount); the controller owns advancing the flow on a terminal status.
+ *
+ * @param collector The PollingCollector to poll.
+ * @param onStatus Callback invoked with each streamed status tick.
+ * @returns An unsubscribe function.
+ */
+ onPollStatus: (
+ collector: PollingCollector,
+ onStatus: (status: PollingStatus) => void,
+ ) => Promise<() => void>;
};
diff --git a/PingSampleApp/ui/davinci/components/organisms/DaVinciClientPanel.tsx b/PingSampleApp/ui/davinci/components/organisms/DaVinciClientPanel.tsx
index 69c7e8b31..a54cee18f 100644
--- a/PingSampleApp/ui/davinci/components/organisms/DaVinciClientPanel.tsx
+++ b/PingSampleApp/ui/davinci/components/organisms/DaVinciClientPanel.tsx
@@ -61,6 +61,7 @@ export default function DaVinciClientPanel(
onSubmit,
onFlowAction,
onIdpAuthorize,
+ onPollStatus,
onStart,
onLogout,
} = useDaVinciClientPanelController({ onAuthenticated });
@@ -76,6 +77,7 @@ export default function DaVinciClientPanel(
onSubmit={onSubmit}
onFlowAction={onFlowAction}
onIdpAuthorize={onIdpAuthorize}
+ onPollStatus={onPollStatus}
/>
) : null}
diff --git a/PingSampleApp/ui/davinci/components/organisms/DaVinciContinueNodePanel.tsx b/PingSampleApp/ui/davinci/components/organisms/DaVinciContinueNodePanel.tsx
index 71e42f936..2dfc1e3ff 100644
--- a/PingSampleApp/ui/davinci/components/organisms/DaVinciContinueNodePanel.tsx
+++ b/PingSampleApp/ui/davinci/components/organisms/DaVinciContinueNodePanel.tsx
@@ -12,6 +12,8 @@ import type {
DaVinciFormResult,
DaVinciNormalizedCollector,
IdpCollector,
+ PollingCollector,
+ PollingStatus,
UnsupportedDaVinciField,
} from '@ping-identity/rn-davinci';
import { commonStyles } from '../../../../src/styles/common';
@@ -52,6 +54,16 @@ export type DaVinciContinueNodePanelProps = {
* @param collector The IdpCollector to authorize.
*/
onIdpAuthorize: (collector: IdpCollector) => Promise;
+ /**
+ * Streams {@link PollingStatus} updates for a {@link PollingCollector}.
+ *
+ * @param collector The PollingCollector to poll.
+ * @param onStatus Callback invoked with each streamed status tick.
+ */
+ onPollStatus: (
+ collector: PollingCollector,
+ onStatus: (status: PollingStatus) => void,
+ ) => Promise<() => void>;
};
/**
@@ -111,7 +123,15 @@ function renderUnsupportedFieldsNotice(
export default function DaVinciContinueNodePanel(
props: DaVinciContinueNodePanelProps,
): React.ReactElement {
- const { node, form, loading, onSubmit, onFlowAction, onIdpAuthorize } = props;
+ const {
+ node,
+ form,
+ loading,
+ onSubmit,
+ onFlowAction,
+ onIdpAuthorize,
+ onPollStatus,
+ } = props;
const { fields, values, canSubmit, issues, setValue } = form;
const showFallbackSubmit = useMemo(
@@ -132,6 +152,7 @@ export default function DaVinciContinueNodePanel(
onSubmit={onSubmit}
onFlowAction={onFlowAction}
onIdpAuthorize={onIdpAuthorize}
+ onPollStatus={onPollStatus}
loading={loading}
canSubmit={canSubmit}
/>
diff --git a/PingSampleApp/ui/davinci/hooks/useDaVinciClientPanelController.ts b/PingSampleApp/ui/davinci/hooks/useDaVinciClientPanelController.ts
index 7f0930a65..8135d1d1b 100644
--- a/PingSampleApp/ui/davinci/hooks/useDaVinciClientPanelController.ts
+++ b/PingSampleApp/ui/davinci/hooks/useDaVinciClientPanelController.ts
@@ -14,6 +14,8 @@ import {
type DaVinciFormResult,
type DaVinciNode,
type IdpCollector,
+ type PollingCollector,
+ type PollingStatus,
} from '@ping-identity/rn-davinci';
import { createExternalIdpClient } from '@ping-identity/rn-external-idp';
import { logger } from '@ping-identity/rn-logger';
@@ -62,6 +64,18 @@ export type UseDaVinciClientPanelControllerResult = {
* @param collector - The IdpCollector to authorize.
*/
onIdpAuthorize: (collector: IdpCollector) => Promise;
+ /**
+ * Streams {@link PollingStatus} updates for a {@link PollingCollector},
+ * automatically advancing the flow via `next()` once a terminal status
+ * (`complete`, `timedOut`, `expired`, `error`) is observed.
+ *
+ * @param collector - The PollingCollector to poll.
+ * @param onStatus - Callback invoked with each streamed status tick.
+ */
+ onPollStatus: (
+ collector: PollingCollector,
+ onStatus: (status: PollingStatus) => void,
+ ) => Promise<() => void>;
/** Restarts the DaVinci flow. */
onStart: () => Promise;
/** Logs out the active user and clears local session state. */
@@ -96,7 +110,8 @@ export function useDaVinciClientPanelController(
): UseDaVinciClientPanelControllerResult {
const { onAuthenticated } = options;
const davinciContext = useDaVinciContext();
- const { node, loading, error, start, next, user, logoutUser } = useDaVinci();
+ const { node, loading, error, start, next, user, logoutUser, pollStatus } =
+ useDaVinci();
const externalIdpLogger = useMemo(() => logger({ level: 'debug' }), []);
const externalIdp = useMemo(
() =>
@@ -218,6 +233,25 @@ export function useDaVinciClientPanelController(
[form, loading],
);
+ const onPollStatus = useCallback(
+ async (
+ collector: PollingCollector,
+ onStatus: (status: PollingStatus) => void,
+ ): Promise<() => void> =>
+ pollStatus(
+ status => {
+ onStatus(status);
+ if (status.status !== 'continue') {
+ next({ collectors: [] }).catch(() => {
+ // `error` is already updated by the hook.
+ });
+ }
+ },
+ { key: collector.key },
+ ),
+ [next, pollStatus],
+ );
+
const onStartAction = useCallback(async (): Promise => {
await onStart();
}, [onStart]);
@@ -244,6 +278,7 @@ export function useDaVinciClientPanelController(
onSubmit,
onFlowAction,
onIdpAuthorize,
+ onPollStatus,
onStart: onStartAction,
onLogout,
};
diff --git a/PingTestRunner/__tests__/integration/davinci-polling.test.ts b/PingTestRunner/__tests__/integration/davinci-polling.test.ts
new file mode 100644
index 000000000..1541f5065
--- /dev/null
+++ b/PingTestRunner/__tests__/integration/davinci-polling.test.ts
@@ -0,0 +1,451 @@
+/*
+ * Copyright (c) 2026 Ping Identity Corporation. All rights reserved.
+ *
+ * This software may be modified and distributed under the terms
+ * of the MIT license. See the LICENSE file for details.
+ */
+
+/**
+ * Integration tests for @ping-identity/rn-davinci POLLING / QR_CODE support
+ *
+ * Validates, against a mocked native module, that the bridge + JS wiring for
+ * `pollStatus` works end-to-end for both polling modes described in
+ * plan.md §5.1/§5.3:
+ * - Simple polling: `subscriptionId` resolves before any tick is delivered,
+ * intermediate `continue` ticks stream through, and each terminal status
+ * (`complete`/`timedOut`/`expired`/`error`) stops delivery.
+ * - Challenge-status polling: same contract, driven by a `PollingCollector`
+ * with `pollChallengeStatus: true`, using the `key` option to disambiguate
+ * when the node also carries a `QR_CODE` collector.
+ * - `pollStatus` never calls `next()` internally on any status.
+ * - The returned unsubscribe function stops local event delivery only;
+ * neither native SDK exposes a poll-cancellation primitive.
+ */
+
+export {};
+
+type NativeDaVinciMock = {
+ configureDaVinci: jest.Mock;
+ start: jest.Mock;
+ next: jest.Mock;
+ getSession: jest.Mock;
+ refresh: jest.Mock;
+ revoke: jest.Mock;
+ userinfo: jest.Mock;
+ logout: jest.Mock;
+ dispose: jest.Mock;
+ pollDaVinci: jest.Mock;
+};
+
+type Listener = (event: Record) => void;
+
+/**
+ * Minimal `DeviceEventEmitter` stand-in with real emit/remove semantics.
+ *
+ * @remarks
+ * `davinci.ts`'s `pollStatus` calls `subscription.remove()` on itself from
+ * inside the listener when a terminal status fires — a mock that only
+ * captures the handler reference for direct invocation (as some other
+ * integration suites in this file's siblings do) would bypass that
+ * self-removal and let terminal-status tests pass for the wrong reason.
+ * `emit` snapshots the listener array before iterating so a listener that
+ * removes itself mid-emit doesn't skip a sibling listener.
+ */
+function createDeviceEventEmitter() {
+ const listeners = new Map>();
+ return {
+ addListener: jest.fn((eventName: string, handler: Listener) => {
+ if (!listeners.has(eventName)) {
+ listeners.set(eventName, new Set());
+ }
+ listeners.get(eventName)!.add(handler);
+ return {
+ remove: jest.fn(() => {
+ listeners.get(eventName)?.delete(handler);
+ }),
+ };
+ }),
+ emit: (eventName: string, event: Record) => {
+ for (const handler of [...(listeners.get(eventName) ?? [])]) {
+ handler(event);
+ }
+ },
+ };
+}
+
+function makeMock(
+ overrides: Partial = {},
+): NativeDaVinciMock {
+ return {
+ configureDaVinci: jest.fn(async () => 'davinci-id-mock'),
+ start: jest.fn(async () => ({
+ type: 'ContinueNode',
+ collectors: [
+ {
+ key: 'poll',
+ type: 'POLLING',
+ pollInterval: 2000,
+ pollRetries: 60,
+ pollChallengeStatus: false,
+ challenge: '',
+ },
+ ],
+ })),
+ next: jest.fn(async () => ({
+ type: 'SuccessNode',
+ session: { value: 'session-token' },
+ })),
+ getSession: jest.fn(async () => ({ accessToken: 'mock-access-token' })),
+ refresh: jest.fn(async () => ({ accessToken: 'mock-refreshed-token' })),
+ revoke: jest.fn(async () => true),
+ userinfo: jest.fn(async () => ({ sub: 'user-mock' })),
+ logout: jest.fn(async () => undefined),
+ dispose: jest.fn(async () => undefined),
+ pollDaVinci: jest.fn(async () => ({ subscriptionId: 'sub-1' })),
+ ...overrides,
+ };
+}
+
+function mockReactNative(
+ emitter: ReturnType,
+): void {
+ jest.doMock('react-native', () => ({
+ Platform: {
+ OS: 'ios',
+ select: (s: Record) => s.ios ?? s.default,
+ },
+ NativeModules: {},
+ TurboModuleRegistry: {
+ get: jest.fn(() => null),
+ getEnforcing: jest.fn(() => null),
+ },
+ DeviceEventEmitter: emitter,
+ }));
+}
+
+async function loadDaVinci(nativeMock: NativeDaVinciMock): Promise<{
+ mod: ReturnType;
+ emitter: ReturnType;
+}> {
+ jest.resetModules();
+ const emitter = createDeviceEventEmitter();
+ mockReactNative(emitter);
+ jest.doMock('../../../packages/davinci/src/NativeRNPingDavinci', () => ({
+ __esModule: true,
+ default: nativeMock,
+ }));
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
+ const mod = require('@ping-identity/rn-davinci');
+ return { mod, emitter };
+}
+
+const POLLING_STATUS_EVENT = 'com.pingidentity.rndavinci.PollingStatus';
+
+const VALID_CONFIG = {
+ modules: {
+ oidc: {
+ discoveryEndpoint:
+ 'https://auth.example.com/.well-known/openid-configuration',
+ clientId: 'davinci-client-id',
+ redirectUri: 'org.forgerock.demo://oauth2redirect',
+ },
+ },
+};
+
+describe('@ping-identity/rn-davinci — polling integration', () => {
+ afterEach(() => jest.restoreAllMocks());
+
+ describe('simple polling', () => {
+ it('delivers a tick emitted before subscriptionId resolves, then streams continue ticks', async () => {
+ const emitterRef: {
+ current?: ReturnType;
+ } = {};
+ const mock = makeMock({
+ pollDaVinci: jest.fn(async () => {
+ // Simulate the native side emitting a status tick over the
+ // DeviceEventEmitter channel before the pollDaVinci promise
+ // resolves — the two channels are independent, so this ordering
+ // is possible in production and must not drop the early event.
+ emitterRef.current!.emit(POLLING_STATUS_EVENT, {
+ subscriptionId: 'sub-1',
+ status: 'continue',
+ retryCount: 0,
+ maxRetries: 60,
+ });
+ return { subscriptionId: 'sub-1' };
+ }),
+ });
+ const loaded = await loadDaVinci(mock);
+ const { mod, emitter } = loaded;
+ emitterRef.current = emitter;
+ const client = mod.createDaVinciClient(VALID_CONFIG);
+ await client.start();
+
+ const onStatus = jest.fn();
+ await client.pollStatus(onStatus);
+
+ expect(mock.pollDaVinci).toHaveBeenCalledWith('davinci-id-mock', {});
+ expect(onStatus).toHaveBeenNthCalledWith(1, {
+ status: 'continue',
+ retryCount: 0,
+ maxRetries: 60,
+ });
+
+ emitter.emit(POLLING_STATUS_EVENT, {
+ subscriptionId: 'sub-1',
+ status: 'continue',
+ retryCount: 1,
+ maxRetries: 60,
+ });
+ emitter.emit(POLLING_STATUS_EVENT, {
+ subscriptionId: 'sub-1',
+ status: 'continue',
+ retryCount: 2,
+ maxRetries: 60,
+ });
+
+ expect(onStatus).toHaveBeenCalledTimes(3);
+ expect(onStatus).toHaveBeenNthCalledWith(2, {
+ status: 'continue',
+ retryCount: 1,
+ maxRetries: 60,
+ });
+ });
+
+ it('stops delivering ticks after a complete status', async () => {
+ const mock = makeMock();
+ const { mod, emitter } = await loadDaVinci(mock);
+ const client = mod.createDaVinciClient(VALID_CONFIG);
+ await client.start();
+
+ const onStatus = jest.fn();
+ await client.pollStatus(onStatus);
+
+ emitter.emit(POLLING_STATUS_EVENT, {
+ subscriptionId: 'sub-1',
+ status: 'complete',
+ value: 'ok',
+ });
+ emitter.emit(POLLING_STATUS_EVENT, {
+ subscriptionId: 'sub-1',
+ status: 'continue',
+ retryCount: 1,
+ maxRetries: 60,
+ });
+
+ expect(onStatus).toHaveBeenCalledTimes(1);
+ expect(onStatus).toHaveBeenCalledWith({
+ status: 'complete',
+ value: 'ok',
+ });
+ });
+
+ it.each(['timedOut', 'expired', 'error'])(
+ 'stops delivering ticks after a %s status',
+ async (status) => {
+ const mock = makeMock();
+ const { mod, emitter } = await loadDaVinci(mock);
+ const client = mod.createDaVinciClient(VALID_CONFIG);
+ await client.start();
+
+ const onStatus = jest.fn();
+ await client.pollStatus(onStatus);
+
+ emitter.emit(
+ POLLING_STATUS_EVENT,
+ status === 'error'
+ ? {
+ subscriptionId: 'sub-1',
+ status,
+ error: { message: 'boom' },
+ }
+ : { subscriptionId: 'sub-1', status },
+ );
+ emitter.emit(POLLING_STATUS_EVENT, {
+ subscriptionId: 'sub-1',
+ status: 'continue',
+ retryCount: 1,
+ maxRetries: 60,
+ });
+
+ expect(onStatus).toHaveBeenCalledTimes(1);
+ },
+ );
+
+ it('does not call next() internally on any terminal status', async () => {
+ const mock = makeMock();
+ const { mod, emitter } = await loadDaVinci(mock);
+ const client = mod.createDaVinciClient(VALID_CONFIG);
+ await client.start();
+
+ await client.pollStatus(jest.fn());
+ emitter.emit(POLLING_STATUS_EVENT, {
+ subscriptionId: 'sub-1',
+ status: 'complete',
+ value: 'ok',
+ });
+
+ expect(mock.next).not.toHaveBeenCalled();
+ });
+
+ it('unsubscribe stops onStatus from firing on subsequent events (local listener removal only)', async () => {
+ const mock = makeMock();
+ const { mod, emitter } = await loadDaVinci(mock);
+ const client = mod.createDaVinciClient(VALID_CONFIG);
+ await client.start();
+
+ const onStatus = jest.fn();
+ const unsubscribe = await client.pollStatus(onStatus);
+ unsubscribe();
+
+ emitter.emit(POLLING_STATUS_EVENT, {
+ subscriptionId: 'sub-1',
+ status: 'continue',
+ retryCount: 1,
+ maxRetries: 60,
+ });
+
+ expect(onStatus).not.toHaveBeenCalled();
+ });
+ });
+
+ describe('challenge-status polling', () => {
+ const challengeMock = () =>
+ makeMock({
+ start: jest.fn(async () => ({
+ type: 'ContinueNode',
+ collectors: [
+ {
+ key: 'qr',
+ type: 'QR_CODE',
+ content: 'data:image/png;base64,iVBORw0KGgo=',
+ fallbackText: 'Scan this code',
+ },
+ {
+ key: 'poll',
+ type: 'POLLING',
+ pollInterval: 1000,
+ pollRetries: 30,
+ pollChallengeStatus: true,
+ challenge: 'challenge-abc',
+ },
+ ],
+ })),
+ });
+
+ it('forwards the key option so the correct PollingCollector is resolved alongside a QR_CODE sibling', async () => {
+ const mock = challengeMock();
+ const { mod } = await loadDaVinci(mock);
+ const client = mod.createDaVinciClient(VALID_CONFIG);
+ await client.start();
+
+ await client.pollStatus(jest.fn(), { key: 'poll' });
+
+ expect(mock.pollDaVinci).toHaveBeenCalledWith('davinci-id-mock', {
+ key: 'poll',
+ });
+ });
+
+ it('streams challenge-status ticks and terminates on expired', async () => {
+ const mock = challengeMock();
+ const { mod, emitter } = await loadDaVinci(mock);
+ const client = mod.createDaVinciClient(VALID_CONFIG);
+ await client.start();
+
+ const onStatus = jest.fn();
+ await client.pollStatus(onStatus, { key: 'poll' });
+
+ emitter.emit(POLLING_STATUS_EVENT, {
+ subscriptionId: 'sub-1',
+ status: 'continue',
+ retryCount: 1,
+ maxRetries: 30,
+ });
+ emitter.emit(POLLING_STATUS_EVENT, {
+ subscriptionId: 'sub-1',
+ status: 'expired',
+ });
+ emitter.emit(POLLING_STATUS_EVENT, {
+ subscriptionId: 'sub-1',
+ status: 'continue',
+ retryCount: 2,
+ maxRetries: 30,
+ });
+
+ expect(onStatus).toHaveBeenCalledTimes(2);
+ expect(onStatus).toHaveBeenLastCalledWith({
+ status: 'expired',
+ });
+ });
+
+ it('normalizeCollectors classifies both POLLING and QR_CODE as output_only', async () => {
+ const mock = challengeMock();
+ const { mod } = await loadDaVinci(mock);
+ const client = mod.createDaVinciClient(VALID_CONFIG);
+ const node = await client.start();
+ const collectors = mod.normalizeCollectors(node.collectors);
+
+ expect(collectors[0]).toMatchObject({
+ type: 'QR_CODE',
+ executionMode: 'output_only',
+ requiresUserInput: false,
+ });
+ expect(collectors[1]).toMatchObject({
+ type: 'POLLING',
+ executionMode: 'output_only',
+ requiresUserInput: false,
+ });
+ });
+
+ it('buildNextInput excludes POLLING and QR_CODE from the submitted payload', async () => {
+ const mock = challengeMock();
+ const { mod } = await loadDaVinci(mock);
+ const client = mod.createDaVinciClient(VALID_CONFIG);
+ const node = await client.start();
+
+ const plan = mod.buildNextInput(node, {});
+ expect(plan.input.collectors).toEqual([]);
+ });
+ });
+
+ describe('failure paths', () => {
+ it('propagates a native pollDaVinci rejection as DaVinciError', async () => {
+ const mock = makeMock({
+ pollDaVinci: jest.fn(async () => {
+ throw {
+ type: 'state_error',
+ error: 'DAVINCI_POLL_ERROR',
+ message: 'no active PollingCollector',
+ };
+ }),
+ });
+ const { mod } = await loadDaVinci(mock);
+ const client = mod.createDaVinciClient(VALID_CONFIG);
+ await client.start();
+
+ await expect(client.pollStatus(jest.fn())).rejects.toMatchObject({
+ name: 'DaVinciError',
+ code: 'DAVINCI_POLL_ERROR',
+ });
+ });
+
+ it('events tagged with an unrelated subscriptionId are ignored', async () => {
+ const mock = makeMock();
+ const { mod, emitter } = await loadDaVinci(mock);
+ const client = mod.createDaVinciClient(VALID_CONFIG);
+ await client.start();
+
+ const onStatus = jest.fn();
+ await client.pollStatus(onStatus);
+
+ emitter.emit(POLLING_STATUS_EVENT, {
+ subscriptionId: 'unrelated-subscription',
+ status: 'continue',
+ retryCount: 1,
+ maxRetries: 60,
+ });
+
+ expect(onStatus).not.toHaveBeenCalled();
+ });
+ });
+});
diff --git a/PingTestRunner/android/app/build.gradle b/PingTestRunner/android/app/build.gradle
index 338ed2980..2964e93b3 100644
--- a/PingTestRunner/android/app/build.gradle
+++ b/PingTestRunner/android/app/build.gradle
@@ -80,7 +80,6 @@ android {
excludes += "/META-INF/versions/9/OSGI-INF/MANIFEST.MF"
}
}
-
// The Ping Android SDK's 2.1.0 artifacts are compiled for JVM 21, and every
// rn-* package targets JVM 21 to match (see e.g. packages/push/android/build.gradle).
// This app module must target the same JVM version, or compileDebugKotlin fails
diff --git a/PingTestRunner/ios/PingTestRunner.xcodeproj/xcshareddata/xcschemes/RNPackagesTests.xcscheme b/PingTestRunner/ios/PingTestRunner.xcodeproj/xcshareddata/xcschemes/RNPackagesTests.xcscheme
index 3e5f52057..1dad316d2 100644
--- a/PingTestRunner/ios/PingTestRunner.xcodeproj/xcshareddata/xcschemes/RNPackagesTests.xcscheme
+++ b/PingTestRunner/ios/PingTestRunner.xcodeproj/xcshareddata/xcschemes/RNPackagesTests.xcscheme
@@ -74,7 +74,7 @@
+ parallelizable = "NO">
{collector.fallbackText};
+ }
+
+ if (collector.type === 'POLLING') {
+ return Polling…;
+ }
+
if (collector.type === 'DEVICE_AUTHENTICATION') {
return (
diff --git a/packages/browser/android/build.gradle b/packages/browser/android/build.gradle
index db4644fd2..aaff56702 100644
--- a/packages/browser/android/build.gradle
+++ b/packages/browser/android/build.gradle
@@ -93,6 +93,12 @@ def kotlin_version = getExtOrDefault("kotlinVersion")
dependencies {
implementation "com.facebook.react:react-android"
implementation "org.jetbrains.kotlin:kotlin-stdlib:$kotlin_version"
+ // NOTE(SDKS-5296): browser 2.1.0's AAR ships CustomTabActivity's redirect
+ // scheme with the ${appRedirectUriScheme} manifest placeholder pre-resolved
+ // to the literal "com.pingidentity.test", so downstream manifestPlaceholders
+ // overrides (see PingSampleApp/PingTestRunner app build.gradle) have no
+ // effect. Verify the placeholder is left unresolved again before bumping
+ // past this version, and re-check that manifestPlaceholders overrides work.
implementation("com.pingidentity.sdks:browser:2.1.0")
implementation("com.pingidentity.sdks:logger:2.1.0")
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:1.9.0"
diff --git a/packages/davinci/README.md b/packages/davinci/README.md
index 22f9ed43d..31317b005 100644
--- a/packages/davinci/README.md
+++ b/packages/davinci/README.md
@@ -278,25 +278,27 @@ await form.submitFlow('forgot-password');
The following collector types are supported on Android and iOS:
-| Collector Type | Description | Input Handling |
-| ----------------------- | ------------------------------------------------- | -------------- |
-| `TEXT` | Single-line text input. | Manual input |
-| `PASSWORD` | Masked password input. | Manual input |
-| `PASSWORD_VERIFY` | Password-confirmation variant of `PASSWORD`. | Manual input |
-| `SINGLE_SELECT` | Single-select input. | Manual input |
-| `DROPDOWN` | Single-select dropdown. | Manual input |
-| `RADIO` | Single-select radio group. | Manual input |
-| `MULTI_SELECT` | Multi-select input. | Manual input |
-| `COMBOBOX` | Multi-select combobox. | Manual input |
-| `CHECKBOX` | Multi-select checkbox group. | Manual input |
-| `PHONE_NUMBER` | Phone number input with country code. | Manual input |
-| `DEVICE_REGISTRATION` | Device picker for registration. | Manual input |
-| `DEVICE_AUTHENTICATION` | Device picker for authentication. | Manual input |
-| `SUBMIT_BUTTON` | Triggers form submission immediately. | Immediate |
-| `ACTION` | Action button that advances the flow immediately. | Immediate |
-| `FLOW_BUTTON` | Flow button that advances the flow immediately. | Immediate |
-| `FLOW_LINK` | Flow link that advances the flow immediately. | Immediate |
-| `LABEL` | Read-only display content. | Output-only |
+| Collector Type | Description | Input Handling |
+| ----------------------- | -------------------------------------------------------------------------------------- | -------------- |
+| `TEXT` | Single-line text input. | Manual input |
+| `PASSWORD` | Masked password input. | Manual input |
+| `PASSWORD_VERIFY` | Password-confirmation variant of `PASSWORD`. | Manual input |
+| `SINGLE_SELECT` | Single-select input. | Manual input |
+| `DROPDOWN` | Single-select dropdown. | Manual input |
+| `RADIO` | Single-select radio group. | Manual input |
+| `MULTI_SELECT` | Multi-select input. | Manual input |
+| `COMBOBOX` | Multi-select combobox. | Manual input |
+| `CHECKBOX` | Multi-select checkbox group. | Manual input |
+| `PHONE_NUMBER` | Phone number input with country code. | Manual input |
+| `DEVICE_REGISTRATION` | Device picker for registration. | Manual input |
+| `DEVICE_AUTHENTICATION` | Device picker for authentication. | Manual input |
+| `SUBMIT_BUTTON` | Triggers form submission immediately. | Immediate |
+| `ACTION` | Action button that advances the flow immediately. | Immediate |
+| `FLOW_BUTTON` | Flow button that advances the flow immediately. | Immediate |
+| `FLOW_LINK` | Flow link that advances the flow immediately. | Immediate |
+| `LABEL` | Read-only display content. | Output-only |
+| `POLLING` | Async polling collector — see [Polling and QR code flows](#polling-and-qr-code-flows). | Output-only |
+| `QR_CODE` | Display-only QR code — see [Polling and QR code flows](#polling-and-qr-code-flows). | Output-only |
Integration-dependent collectors (for example, social IdP, FIDO, or PingOne Protect) are
surfaced in node payloads and require client-side integration before submission
@@ -340,6 +342,7 @@ Stable DaVinci error codes:
- `DAVINCI_SESSION_ERROR`
- `DAVINCI_LOGOUT_ERROR`
- `DAVINCI_DISPOSE_ERROR`
+- `DAVINCI_POLL_ERROR`
- `DAVINCI_ARGUMENT_ERROR`
- `DAVINCI_STATE_ERROR`
- `DAVINCI_MISSING_INTEGRATION_ERROR`
diff --git a/packages/davinci/RNPingDavinci.podspec b/packages/davinci/RNPingDavinci.podspec
index e6d341ab5..44a80d515 100644
--- a/packages/davinci/RNPingDavinci.podspec
+++ b/packages/davinci/RNPingDavinci.podspec
@@ -26,6 +26,7 @@ Pod::Spec.new do |s|
else
s.source_files = [
"ios/RNPingDavinciClassic.mm",
+ "ios/RNPingDavinciEventEmitterGate.mm",
"ios/**/*.swift",
"ios/**/*.h"
]
@@ -35,6 +36,7 @@ Pod::Spec.new do |s|
s.private_header_files = "ios/**/*.h"
s.swift_version = ['5.0', '5.1', '6.0']
+
s.dependency "RNPingCore"
s.dependency "PingDavinci", '2.1.0'
s.dependency "PingDavinciPlugin", '2.1.0'
diff --git a/packages/davinci/android/build.gradle b/packages/davinci/android/build.gradle
index 2b9ddb55e..ec18ae355 100644
--- a/packages/davinci/android/build.gradle
+++ b/packages/davinci/android/build.gradle
@@ -110,4 +110,6 @@ dependencies {
testImplementation "androidx.test:core:1.6.1"
testImplementation "org.robolectric:robolectric:4.12.2"
testImplementation("com.pingidentity.sdks:external-idp:2.1.0")
+ testImplementation "io.mockk:mockk:1.13.12"
+ testImplementation "org.jetbrains.kotlinx:kotlinx-coroutines-test:1.9.0"
}
diff --git a/packages/davinci/android/src/main/java/com/pingidentity/rndavinci/RNPingDavinciCommon.kt b/packages/davinci/android/src/main/java/com/pingidentity/rndavinci/RNPingDavinciCommon.kt
index 8cbee5b68..98cd06c79 100644
--- a/packages/davinci/android/src/main/java/com/pingidentity/rndavinci/RNPingDavinciCommon.kt
+++ b/packages/davinci/android/src/main/java/com/pingidentity/rndavinci/RNPingDavinciCommon.kt
@@ -9,7 +9,11 @@ package com.pingidentity.rndavinci
import com.facebook.react.bridge.Arguments
import com.facebook.react.bridge.Promise
+import com.facebook.react.bridge.ReactApplicationContext
import com.facebook.react.bridge.ReadableMap
+import com.facebook.react.modules.core.DeviceEventManagerModule
+import com.pingidentity.davinci.collector.PollingCollector
+import com.pingidentity.davinci.collector.PollingStatus
import com.pingidentity.davinci.user
import com.pingidentity.logger.Logger
import com.pingidentity.orchestrate.ContinueNode
@@ -32,11 +36,18 @@ import com.pingidentity.rndavinci.error.DaVinciErrorCodes
import com.pingidentity.rndavinci.error.DaVinciErrorMapper
import com.pingidentity.rndavinci.factory.DaVinciClientFactory
import com.pingidentity.rndavinci.mapper.DaVinciNodeMapper
+import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.CoroutineStart
import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.withContext
import kotlinx.serialization.json.JsonObject
+import java.lang.ref.WeakReference
+import java.util.UUID
import java.util.concurrent.ConcurrentHashMap
/**
@@ -58,6 +69,16 @@ internal object RNPingDavinciCommon {
private val nodeMap = ConcurrentHashMap()
private val continueNodeMap = ConcurrentHashMap()
+ private var reactContextRef: WeakReference? = null
+
+ /**
+ * In-flight poll [Job]s grouped by `davinciId`, so [dispose]/[cleanup] can cancel every
+ * outstanding poll for a given DaVinci instance as a teardown safety net. Native polling
+ * has no cancellation primitive, so this is the only way an in-flight poll is ever stopped
+ * early — it is not exposed to JS.
+ */
+ private val pollJobsByDaVinciId = ConcurrentHashMap>()
+
/**
* Handle storing a native DaVinci workflow instance.
*
@@ -84,9 +105,19 @@ internal object RNPingDavinciCommon {
* to JS through `ContinueNode.unsupportedFields` (see
* `DaVinciNodeMapper.unsupportedFieldsPayload`) so consumers can react. Re-
* evaluate once the SDKs register any new server-introduced field types.
+ *
+ * `reactContext` is captured (weakly, mirroring `RNPingBindingCommon`/
+ * `RNPingPushCommon`) so [pollDaVinci] can resolve the JS
+ * `RCTDeviceEventEmitter` to stream `PollingStatus` ticks. It is optional
+ * and re-applied on every call (even when [configured] is already `true`)
+ * because both `RNPingDavinciModule` and `RNPingDavinciClassicModule` call
+ * `configure()` from their constructors — the reference must stay current
+ * across module re-creation, not just the first init. Existing call sites
+ * that don't need event emission (e.g. tests) can omit it.
*/
@Synchronized
- fun configure() {
+ fun configure(reactContext: ReactApplicationContext? = null) {
+ reactContext?.let { reactContextRef = WeakReference(it) }
if (configured) return
val oidcStorageRegistry = CoreRuntime.oidcStorageConfigRegistry
@@ -110,9 +141,12 @@ internal object RNPingDavinciCommon {
if (!configured) return
CoreRuntime.davinciCollectorResolver = null
+ pollJobsByDaVinciId.values.forEach { jobs -> jobs.forEach { it.cancel() } }
+ pollJobsByDaVinciId.clear()
scope.cancel()
scope = createScope()
disposeAll()
+ reactContextRef = null
configured = false
}
@@ -142,6 +176,17 @@ internal object RNPingDavinciCommon {
continueNodeMap.remove(davinciId)?.let { node -> runCatching { node.close() } }
nodeMap.remove(davinciId)
davinciRegistry.remove(davinciId)
+ cancelPollsFor(davinciId)
+ }
+
+ /**
+ * Cancels every in-flight poll [Job] tracked for [davinciId] — a dispose()/cleanup()
+ * safety net so a disposed instance never leaves an orphaned coroutine running after
+ * the caller stops listening for its events.
+ */
+ private fun cancelPollsFor(davinciId: String) {
+ val jobs = pollJobsByDaVinciId.remove(davinciId) ?: return
+ jobs.forEach { it.cancel() }
}
private fun resolveWorkflow(davinciId: String): Workflow? =
@@ -474,6 +519,121 @@ internal object RNPingDavinciCommon {
}
}
+ /**
+ * Start streaming polling status updates for the active [PollingCollector].
+ *
+ * The poll [Job] is created with [CoroutineStart.LAZY] and registered into
+ * [pollJobsByDaVinciId] before the promise resolves. The promise is then resolved with a
+ * native-generated `subscriptionId`, and only after that does the job actually
+ * [Job.start], beginning to collect [PollingCollector.pollStatus] and emit
+ * [RNPingDavinciEvents.POLLING_STATUS] events tagged with that `subscriptionId`. Because
+ * the RN bridge delivers native→JS messages in FIFO order per channel, this ordering
+ * guarantees JS always has the id to filter on before the first event can arrive.
+ *
+ * Native polling has no cancellation primitive — once started, this [Job] runs to
+ * completion (bounded by the collector's `pollRetries`/`pollInterval`) unless [dispose]
+ * or [cleanup] cancels it as a teardown safety net.
+ *
+ * @param davinciId Native DaVinci instance id.
+ * @param options Bridge map with an optional `key` selecting which `PollingCollector`
+ * to poll when more than one is present on the active node; the first one is used
+ * when `key` is absent.
+ * @param promise Promise resolved with `{ subscriptionId }`.
+ */
+ fun pollDaVinci(davinciId: String, options: ReadableMap, promise: Promise) {
+ val node = continueNodeMap[davinciId]
+ if (node == null) {
+ promise.reject(
+ DaVinciErrorMapper.state(
+ DaVinciErrorCodes.POLL,
+ "No active ContinueNode found for davinci id=$davinciId"
+ )
+ )
+ return
+ }
+
+ val requestedKey = if (options.hasKey("key") && !options.isNull("key")) {
+ options.getString("key")
+ } else {
+ null
+ }
+ val collectors = node.actions.filterIsInstance()
+ val collector = (if (requestedKey != null) {
+ collectors.firstOrNull { it.id() == requestedKey }
+ } else {
+ collectors.firstOrNull()
+ })
+ if (collector == null) {
+ promise.reject(
+ DaVinciErrorMapper.state(
+ DaVinciErrorCodes.POLL,
+ "No active PollingCollector found for davinci id=$davinciId" +
+ (requestedKey?.let { " with key=$it" } ?: "")
+ )
+ )
+ return
+ }
+
+ val subscriptionId = UUID.randomUUID().toString()
+ lateinit var job: Job
+ job = scope.launch(start = CoroutineStart.LAZY) {
+ try {
+ collector.pollStatus().collect { status ->
+ emitPollingStatus(davinciId, subscriptionId, status)
+ }
+ } catch (e: CancellationException) {
+ throw e
+ } finally {
+ pollJobsByDaVinciId[davinciId]?.remove(job)
+ }
+ }
+ pollJobsByDaVinciId.computeIfAbsent(davinciId) { ConcurrentHashMap.newKeySet() }.add(job)
+
+ val result = Arguments.createMap()
+ result.putString("subscriptionId", subscriptionId)
+ promise.resolve(result)
+
+ job.start()
+ }
+
+ /**
+ * Emits one [PollingStatus] tick to JS via [DeviceEventManagerModule.RCTDeviceEventEmitter].
+ * No-op when the React context is unavailable (e.g. the module was invalidated mid-poll).
+ *
+ * Hops to [Dispatchers.Main] via [withContext] before emitting, staying on the calling
+ * poll [Job] so cancellation (unsubscribe/dispose) also cancels any in-flight emit instead
+ * of leaking a detached [Dispatchers.Main] coroutine.
+ */
+ private suspend fun emitPollingStatus(davinciId: String, subscriptionId: String, status: PollingStatus) {
+ val emitter = reactContextRef?.get()
+ ?.getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter::class.java)
+ ?: return
+
+ val params = Arguments.createMap()
+ params.putString("subscriptionId", subscriptionId)
+ params.putString("daVinciId", davinciId)
+ when (status) {
+ is PollingStatus.Continue -> {
+ params.putString("status", "continue")
+ params.putInt("retryCount", status.retryCount)
+ params.putInt("maxRetries", status.maxRetries)
+ }
+ is PollingStatus.Complete -> {
+ params.putString("status", "complete")
+ params.putString("value", status.status)
+ }
+ is PollingStatus.TimedOut -> params.putString("status", "timedOut")
+ is PollingStatus.Expired -> params.putString("status", "expired")
+ is PollingStatus.Error -> {
+ params.putString("status", "error")
+ val errorMap = Arguments.createMap()
+ errorMap.putString("message", status.exception.message ?: status.exception.toString())
+ params.putMap("error", errorMap)
+ }
+ }
+ withContext(Dispatchers.Main) { emitter.emit(RNPingDavinciEvents.POLLING_STATUS, params) }
+ }
+
// ---- Private helpers ----
private suspend fun mapSessionPayload(
diff --git a/packages/davinci/android/src/main/java/com/pingidentity/rndavinci/RNPingDavinciEvents.kt b/packages/davinci/android/src/main/java/com/pingidentity/rndavinci/RNPingDavinciEvents.kt
new file mode 100644
index 000000000..0cb16deb0
--- /dev/null
+++ b/packages/davinci/android/src/main/java/com/pingidentity/rndavinci/RNPingDavinciEvents.kt
@@ -0,0 +1,14 @@
+/*
+ * Copyright (c) 2026 Ping Identity Corporation. All rights reserved.
+ *
+ * This software may be modified and distributed under the terms
+ * of the MIT license. See the LICENSE file for details.
+ */
+package com.pingidentity.rndavinci
+
+/**
+ * DeviceEventEmitter event names used by the RNPingDavinci bridge.
+ */
+object RNPingDavinciEvents {
+ const val POLLING_STATUS = "com.pingidentity.rndavinci.PollingStatus"
+}
diff --git a/packages/davinci/android/src/main/java/com/pingidentity/rndavinci/error/DaVinciErrorCodes.kt b/packages/davinci/android/src/main/java/com/pingidentity/rndavinci/error/DaVinciErrorCodes.kt
index 6189b2fee..ca485062b 100644
--- a/packages/davinci/android/src/main/java/com/pingidentity/rndavinci/error/DaVinciErrorCodes.kt
+++ b/packages/davinci/android/src/main/java/com/pingidentity/rndavinci/error/DaVinciErrorCodes.kt
@@ -29,6 +29,8 @@ internal object DaVinciErrorCodes {
const val LOGOUT = "DAVINCI_LOGOUT_ERROR"
/** Client disposal failure. */
const val DISPOSE = "DAVINCI_DISPOSE_ERROR"
+ /** Poll start/subscription failure. */
+ const val POLL = "DAVINCI_POLL_ERROR"
/** JS guardrails: invalid argument at call site. */
const val ARGUMENT = "DAVINCI_ARGUMENT_ERROR"
/** Operation on invalid state (e.g. no active node). */
diff --git a/packages/davinci/android/src/main/java/com/pingidentity/rndavinci/mapper/DaVinciNodeMapper.kt b/packages/davinci/android/src/main/java/com/pingidentity/rndavinci/mapper/DaVinciNodeMapper.kt
index ada43075d..e134a0dc1 100644
--- a/packages/davinci/android/src/main/java/com/pingidentity/rndavinci/mapper/DaVinciNodeMapper.kt
+++ b/packages/davinci/android/src/main/java/com/pingidentity/rndavinci/mapper/DaVinciNodeMapper.kt
@@ -19,6 +19,8 @@ import com.pingidentity.davinci.collector.MultiSelectCollector
import com.pingidentity.davinci.collector.PasswordCollector
import com.pingidentity.davinci.collector.PasswordPolicy
import com.pingidentity.davinci.collector.PhoneNumberCollector
+import com.pingidentity.davinci.collector.PollingCollector
+import com.pingidentity.davinci.collector.QRCodeCollector
import com.pingidentity.davinci.collector.SingleSelectCollector
import com.pingidentity.davinci.collector.SubmitCollector
import com.pingidentity.davinci.collector.TextCollector
@@ -43,6 +45,13 @@ internal object DaVinciNodeMapper {
private const val TAG = "DaVinciNodeMapper"
internal const val SOCIAL_LOGIN_BUTTON = "SOCIAL_LOGIN_BUTTON"
+ private const val QR_CODE = "QR_CODE"
+
+ /** Matches native `PollingCollector.pollStatus()`'s own fallback when `pollInterval` fails to parse. */
+ private const val DEFAULT_POLL_INTERVAL = 2000
+
+ /** Matches native `PollingCollector.pollStatus()`'s own fallback when `pollRetries` fails to parse. */
+ private const val DEFAULT_POLL_RETRIES = 60
private val json = Json { ignoreUnknownKeys = true }
@@ -146,6 +155,13 @@ internal object DaVinciNodeMapper {
.mapNotNull { it.id() }
.toSet()
+ // TODO-SDK-PARITY: QRCodeCollector.id() (Android 2.1.0) falls back to the
+ // Collector interface default, which returns a fresh random UUID per call —
+ // it never matches the server field's real `key`. Excluding QR_CODE here
+ // avoids reporting a field the SDK *did* instantiate a collector for as
+ // dropped/unsupported. Remove this exclusion once the native SDK reads `key`.
+ val hasRegisteredQrCode = node.actions.filterIsInstance().isNotEmpty()
+
return buildList {
for (element in fields) {
val fieldJson = try {
@@ -164,6 +180,8 @@ internal object DaVinciNodeMapper {
// SOCIAL_LOGIN_BUTTON fields are always handled via IdpCollector — exclude them.
if (resolvedType == SOCIAL_LOGIN_BUTTON) continue
+ if (resolvedType == QR_CODE && hasRegisteredQrCode) continue
+
// A field is supported when the SDK instantiated a collector for its key.
if (registeredKeys.contains(key)) continue
@@ -186,6 +204,12 @@ internal object DaVinciNodeMapper {
node: ContinueNode,
logger: Logger? = null
): Map? {
+ // TODO-SDK-PARITY: QRCodeCollector (Android 2.1.0) does not override `id()`, so it
+ // falls back to the Collector interface default — a fresh random UUID on every call,
+ // never matching the server field's real `key`. Handled separately from the
+ // id()-based rawFieldKey() correlation below; mapQRCodeCollector emits `key: ""`.
+ if (collector is QRCodeCollector) return mapQRCodeCollector(collector)
+
val payload = when (collector) {
is IdpCollector -> mapIdpCollector(collector)
is TextCollector -> mapTextCollector(collector)
@@ -198,6 +222,7 @@ internal object DaVinciNodeMapper {
is PhoneNumberCollector -> mapPhoneNumberCollector(collector)
is DeviceRegistrationCollector -> mapDeviceRegistrationCollector(collector)
is DeviceAuthenticationCollector -> mapDeviceAuthenticationCollector(collector)
+ is PollingCollector -> mapPollingCollector(collector, logger)
else -> {
logWarning(
logger,
@@ -395,6 +420,65 @@ internal object DaVinciNodeMapper {
)
}
+ /**
+ * Serialize a [PollingCollector] to a payload map.
+ *
+ * @remarks
+ * `pollInterval`/`pollRetries` are coerced from the native `String` to `Int` — Android's
+ * native collector types them as `String`, iOS's as `Int`; the bridge normalizes to a
+ * single numeric shape (`number` in TS) across platforms. If a value fails to parse, it
+ * falls back to the same default the native `PollingCollector.pollStatus()` uses internally
+ * (`2000`/`60`) and logs a warning rather than leaking the raw string into a numeric field.
+ *
+ * TODO-SDK-PARITY: Android's `PollingCollector.retriesAllowed` (2.1.0) resets to
+ * `pollRetries.toInt()` on every `init(input)` — a fresh collector instance for the same
+ * simple-polling node loses any decrement from a prior instance. iOS persists this via
+ * `SharedContext.Keys.pollingRetriesRemaining(...)`, read back in `continueNode`'s `didSet`.
+ * Not fixable from the bridge; file against the native SDK.
+ */
+ private fun mapPollingCollector(collector: PollingCollector, logger: Logger? = null): Map {
+ val map = baseCollectorMap(collector)
+ map["pollInterval"] = collector.pollInterval.toIntOrNull() ?: run {
+ logWarning(
+ logger,
+ "Failed to coerce pollInterval=\"${collector.pollInterval}\" to Int, defaulting to $DEFAULT_POLL_INTERVAL",
+ NumberFormatException("Invalid pollInterval: ${collector.pollInterval}")
+ )
+ DEFAULT_POLL_INTERVAL
+ }
+ map["pollRetries"] = collector.pollRetries.toIntOrNull() ?: run {
+ logWarning(
+ logger,
+ "Failed to coerce pollRetries=\"${collector.pollRetries}\" to Int, defaulting to $DEFAULT_POLL_RETRIES",
+ NumberFormatException("Invalid pollRetries: ${collector.pollRetries}")
+ )
+ DEFAULT_POLL_RETRIES
+ }
+ map["pollChallengeStatus"] = collector.pollChallengeStatus
+ map["challenge"] = collector.challenge
+ return map
+ }
+
+ /**
+ * Serialize a [QRCodeCollector] to a payload map.
+ *
+ * @remarks
+ * TODO-SDK-PARITY: Android's native `QRCodeCollector` (2.1.0) does not parse a `key`
+ * from the server field JSON, even though the server sends one (unlike iOS's native
+ * collector, which does). Its `id()` also falls back to the `Collector` interface
+ * default — a fresh random UUID per call — so it cannot be used to correlate back to
+ * `node.input.form.components.fields[]` for a `raw` field lookup either. Emits `key: ""`
+ * until the native SDK exposes it; `raw` is omitted for the same reason.
+ */
+ private fun mapQRCodeCollector(collector: QRCodeCollector): Map {
+ return linkedMapOf(
+ "key" to "",
+ "type" to QR_CODE,
+ "content" to collector.content,
+ "fallbackText" to collector.fallbackText
+ )
+ }
+
private fun mapSingleSelectCollector(collector: SingleSelectCollector): Map {
val map = baseCollectorMap(collector)
map["value"] = collector.value ?: ""
diff --git a/packages/davinci/android/src/newarch/java/com/pingidentity/rndavinci/RNPingDavinciModule.kt b/packages/davinci/android/src/newarch/java/com/pingidentity/rndavinci/RNPingDavinciModule.kt
index 7ec385b3e..0b08d8b2b 100644
--- a/packages/davinci/android/src/newarch/java/com/pingidentity/rndavinci/RNPingDavinciModule.kt
+++ b/packages/davinci/android/src/newarch/java/com/pingidentity/rndavinci/RNPingDavinciModule.kt
@@ -19,7 +19,7 @@ class RNPingDavinciModule(reactContext: ReactApplicationContext) :
NativeRNPingDavinciSpec(reactContext) {
init {
- RNPingDavinciCommon.configure()
+ RNPingDavinciCommon.configure(reactContext)
}
/**
@@ -126,6 +126,17 @@ class RNPingDavinciModule(reactContext: ReactApplicationContext) :
RNPingDavinciCommon.dispose(davinciId, promise)
}
+ /**
+ * Start streaming polling status updates for the active `PollingCollector`.
+ *
+ * @param davinciId Native DaVinci client id.
+ * @param options Bridge map with an optional `key` collector selector.
+ * @param promise Promise resolved with `{ subscriptionId }`.
+ */
+ override fun pollDaVinci(davinciId: String, options: ReadableMap, promise: Promise) {
+ RNPingDavinciCommon.pollDaVinci(davinciId, options, promise)
+ }
+
companion object {
const val NAME = "RNPingDavinci"
}
diff --git a/packages/davinci/android/src/oldarch/java/com/pingidentity/rndavinci/RNPingDavinciClassicModule.kt b/packages/davinci/android/src/oldarch/java/com/pingidentity/rndavinci/RNPingDavinciClassicModule.kt
index eb5244f35..3145a8219 100644
--- a/packages/davinci/android/src/oldarch/java/com/pingidentity/rndavinci/RNPingDavinciClassicModule.kt
+++ b/packages/davinci/android/src/oldarch/java/com/pingidentity/rndavinci/RNPingDavinciClassicModule.kt
@@ -26,7 +26,7 @@ class RNPingDavinciClassicModule(
) : ReactContextBaseJavaModule(reactContext) {
init {
- RNPingDavinciCommon.configure()
+ RNPingDavinciCommon.configure(reactContext)
}
/**
@@ -142,6 +142,18 @@ class RNPingDavinciClassicModule(
RNPingDavinciCommon.dispose(davinciId, promise)
}
+ /**
+ * Start streaming polling status updates for the active `PollingCollector`.
+ *
+ * @param davinciId Native DaVinci client id.
+ * @param options Bridge map with an optional `key` collector selector.
+ * @param promise Promise resolved with `{ subscriptionId }`.
+ */
+ @ReactMethod
+ fun pollDaVinci(davinciId: String, options: ReadableMap, promise: Promise) {
+ RNPingDavinciCommon.pollDaVinci(davinciId, options, promise)
+ }
+
companion object {
const val NAME = "RNPingDavinciClassic"
}
diff --git a/packages/davinci/android/src/test/java/com/pingidentity/rndavinci/RNPingDavinciCommonTest.kt b/packages/davinci/android/src/test/java/com/pingidentity/rndavinci/RNPingDavinciCommonTest.kt
index 358970c26..dde84b18c 100644
--- a/packages/davinci/android/src/test/java/com/pingidentity/rndavinci/RNPingDavinciCommonTest.kt
+++ b/packages/davinci/android/src/test/java/com/pingidentity/rndavinci/RNPingDavinciCommonTest.kt
@@ -10,9 +10,13 @@ package com.pingidentity.rndavinci
import com.facebook.react.bridge.JavaOnlyArray
import com.facebook.react.bridge.JavaOnlyMap
import com.facebook.react.bridge.Promise
+import com.facebook.react.bridge.ReactApplicationContext
import com.facebook.react.bridge.WritableArray
import com.facebook.react.bridge.WritableMap
+import com.facebook.react.modules.core.DeviceEventManagerModule
import com.pingidentity.davinci.collector.PasswordCollector
+import com.pingidentity.davinci.collector.PollingCollector
+import com.pingidentity.davinci.collector.PollingStatus
import com.pingidentity.davinci.plugin.Collector
import com.pingidentity.network.HttpRequest
import com.pingidentity.oidc.OidcError
@@ -31,6 +35,17 @@ import com.pingidentity.orchestrate.Workflow
import com.pingidentity.orchestrate.WorkflowConfig
import com.pingidentity.storage.Storage
import com.pingidentity.utils.Result
+import io.mockk.every
+import io.mockk.mockk
+import io.mockk.verify
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.flow.flow
+import kotlinx.coroutines.flow.flowOf
+import kotlinx.coroutines.test.UnconfinedTestDispatcher
+import kotlinx.coroutines.test.resetMain
+import kotlinx.coroutines.test.setMain
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.put
@@ -51,18 +66,21 @@ import java.util.concurrent.TimeUnit
import java.util.concurrent.atomic.AtomicBoolean
import java.util.concurrent.atomic.AtomicReference
+@OptIn(ExperimentalCoroutinesApi::class)
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [29], shadows = [ShadowDaVinciCommonArguments::class])
class RNPingDavinciCommonTest {
@Before
fun setUp() {
+ Dispatchers.setMain(UnconfinedTestDispatcher())
RNPingDavinciCommon.configure()
}
@After
fun tearDown() {
RNPingDavinciCommon.cleanup()
+ Dispatchers.resetMain()
}
// ---- configure / cleanup ----
@@ -564,8 +582,359 @@ class RNPingDavinciCommonTest {
assertEquals(DaVinciErrorCodes.UNSUPPORTED_COLLECTOR, error.getString("error"))
}
+ // ---- pollDaVinci ----
+
+ @Test
+ fun pollDaVinci_rejectsStateErrorWhenNoContinueNode() {
+ val promise = TestPromise()
+
+ RNPingDavinciCommon.pollDaVinci("no-such-id", JavaOnlyMap(), promise)
+
+ val error = captureReject(promise)
+ assertEquals(ErrorType.STATE_ERROR.rawValue, error.getString("type"))
+ assertEquals(DaVinciErrorCodes.POLL, error.getString("error"))
+ }
+
+ @Test
+ fun pollDaVinci_rejectsStateErrorWhenNoPollingCollectorPresent() {
+ val davinciId = registerDaVinciHandle(Workflow(WorkflowConfig()))
+ setContinueNode(davinciId, DummyContinueNode(actions = emptyList()))
+ val promise = TestPromise()
+
+ RNPingDavinciCommon.pollDaVinci(davinciId, JavaOnlyMap(), promise)
+
+ val error = captureReject(promise)
+ assertEquals(ErrorType.STATE_ERROR.rawValue, error.getString("type"))
+ assertEquals(DaVinciErrorCodes.POLL, error.getString("error"))
+ }
+
+ @Test
+ fun pollDaVinci_rejectsStateErrorWhenKeyDoesNotMatchAnyPresentCollector() {
+ val davinciId = registerDaVinciHandle(Workflow(WorkflowConfig()))
+ val collector = mockk(relaxed = true)
+ every { collector.id() } returns "existing-key"
+ setContinueNode(davinciId, DummyContinueNode(actions = listOf(collector)))
+
+ val options = JavaOnlyMap().apply { putString("key", "no-such-key") }
+ val promise = TestPromise()
+
+ RNPingDavinciCommon.pollDaVinci(davinciId, options, promise)
+
+ val error = captureReject(promise)
+ assertEquals(ErrorType.STATE_ERROR.rawValue, error.getString("type"))
+ assertEquals(DaVinciErrorCodes.POLL, error.getString("error"))
+ verify(exactly = 0) { collector.pollStatus() }
+ }
+
+ @Test
+ fun pollDaVinci_selectsCollectorByKeyWhenMultiplePresent() {
+ val (reactContext, _) = mockReactContextWithEmitter()
+ RNPingDavinciCommon.configure(reactContext)
+
+ val davinciId = registerDaVinciHandle(Workflow(WorkflowConfig()))
+ val matching = mockk(relaxed = true)
+ every { matching.id() } returns "target-key"
+ every { matching.pollStatus() } returns flowOf(PollingStatus.Complete("approved"))
+ val other = mockk(relaxed = true)
+ every { other.id() } returns "other-key"
+ every { other.pollStatus() } returns flowOf(PollingStatus.Complete("wrong"))
+ setContinueNode(davinciId, DummyContinueNode(actions = listOf(other, matching)))
+
+ val options = JavaOnlyMap().apply { putString("key", "target-key") }
+ val promise = TestPromise()
+
+ RNPingDavinciCommon.pollDaVinci(davinciId, options, promise)
+ captureResolve(promise)
+
+ verify(timeout = 2000) { matching.pollStatus() }
+ verify(exactly = 0) { other.pollStatus() }
+ }
+
+ @Test
+ fun pollDaVinci_resolvesWithSubscriptionIdBeforeAnyEventIsEmitted() {
+ val (reactContext, emitter) = mockReactContextWithEmitter()
+ RNPingDavinciCommon.configure(reactContext)
+
+ val davinciId = registerDaVinciHandle(Workflow(WorkflowConfig()))
+ val collector = mockk(relaxed = true)
+ every { collector.pollStatus() } returns flow {
+ delay(200)
+ emit(PollingStatus.Complete("approved"))
+ }
+ setContinueNode(davinciId, DummyContinueNode(actions = listOf(collector)))
+ val promise = TestPromise()
+
+ RNPingDavinciCommon.pollDaVinci(davinciId, JavaOnlyMap(), promise)
+
+ val resolved = captureResolve(promise) as WritableMap
+ assertTrue(resolved.getString("subscriptionId")!!.isNotBlank())
+ verify(exactly = 0) { emitter.emit(any(), any()) }
+
+ verify(timeout = 2000) { emitter.emit(RNPingDavinciEvents.POLLING_STATUS, any()) }
+ }
+
+ @Test
+ fun pollDaVinci_emitsContinueThenCompleteEventPayloads() {
+ val (reactContext, emitter) = mockReactContextWithEmitter()
+ RNPingDavinciCommon.configure(reactContext)
+
+ val davinciId = registerDaVinciHandle(Workflow(WorkflowConfig()))
+ val collector = mockk(relaxed = true)
+ every { collector.pollStatus() } returns flowOf(
+ PollingStatus.Continue(retryCount = 1, maxRetries = 60),
+ PollingStatus.Complete("approved")
+ )
+ setContinueNode(davinciId, DummyContinueNode(actions = listOf(collector)))
+ val emitted = mutableListOf()
+ val latch = CountDownLatch(2)
+ every { emitter.emit(any(), any()) } answers {
+ emitted.add(secondArg())
+ latch.countDown()
+ }
+ val promise = TestPromise()
+
+ RNPingDavinciCommon.pollDaVinci(davinciId, JavaOnlyMap(), promise)
+ val subscriptionId = (captureResolve(promise) as WritableMap).getString("subscriptionId")
+
+ assertTrue("Expected both continue and complete events", latch.await(2, TimeUnit.SECONDS))
+ assertEquals(2, emitted.size)
+ assertEquals("continue", emitted[0].getString("status"))
+ assertEquals(1, emitted[0].getInt("retryCount"))
+ assertEquals(60, emitted[0].getInt("maxRetries"))
+ assertEquals(subscriptionId, emitted[0].getString("subscriptionId"))
+ assertEquals(davinciId, emitted[0].getString("daVinciId"))
+ assertEquals("complete", emitted[1].getString("status"))
+ assertEquals("approved", emitted[1].getString("value"))
+ }
+
+ @Test
+ fun pollDaVinci_emitsTimedOutEvent() {
+ val (reactContext, emitter) = mockReactContextWithEmitter()
+ RNPingDavinciCommon.configure(reactContext)
+
+ val davinciId = registerDaVinciHandle(Workflow(WorkflowConfig()))
+ val collector = mockk(relaxed = true)
+ every { collector.pollStatus() } returns flowOf(PollingStatus.TimedOut)
+ setContinueNode(davinciId, DummyContinueNode(actions = listOf(collector)))
+ val emitted = mutableListOf()
+ val latch = CountDownLatch(1)
+ every { emitter.emit(any(), any()) } answers {
+ emitted.add(secondArg())
+ latch.countDown()
+ }
+ val promise = TestPromise()
+
+ RNPingDavinciCommon.pollDaVinci(davinciId, JavaOnlyMap(), promise)
+ captureResolve(promise)
+
+ assertTrue(latch.await(2, TimeUnit.SECONDS))
+ assertEquals("timedOut", emitted[0].getString("status"))
+ }
+
+ @Test
+ fun pollDaVinci_emitsExpiredEvent() {
+ val (reactContext, emitter) = mockReactContextWithEmitter()
+ RNPingDavinciCommon.configure(reactContext)
+
+ val davinciId = registerDaVinciHandle(Workflow(WorkflowConfig()))
+ val collector = mockk(relaxed = true)
+ every { collector.pollStatus() } returns flowOf(PollingStatus.Expired)
+ setContinueNode(davinciId, DummyContinueNode(actions = listOf(collector)))
+ val emitted = mutableListOf()
+ val latch = CountDownLatch(1)
+ every { emitter.emit(any(), any()) } answers {
+ emitted.add(secondArg())
+ latch.countDown()
+ }
+ val promise = TestPromise()
+
+ RNPingDavinciCommon.pollDaVinci(davinciId, JavaOnlyMap(), promise)
+ captureResolve(promise)
+
+ assertTrue(latch.await(2, TimeUnit.SECONDS))
+ assertEquals("expired", emitted[0].getString("status"))
+ }
+
+ @Test
+ fun pollDaVinci_emitsErrorEventWithMessage() {
+ val (reactContext, emitter) = mockReactContextWithEmitter()
+ RNPingDavinciCommon.configure(reactContext)
+
+ val davinciId = registerDaVinciHandle(Workflow(WorkflowConfig()))
+ val collector = mockk(relaxed = true)
+ every { collector.pollStatus() } returns flowOf(PollingStatus.Error(RuntimeException("network down")))
+ setContinueNode(davinciId, DummyContinueNode(actions = listOf(collector)))
+ val emitted = mutableListOf()
+ val latch = CountDownLatch(1)
+ every { emitter.emit(any(), any()) } answers {
+ emitted.add(secondArg())
+ latch.countDown()
+ }
+ val promise = TestPromise()
+
+ RNPingDavinciCommon.pollDaVinci(davinciId, JavaOnlyMap(), promise)
+ captureResolve(promise)
+
+ assertTrue(latch.await(2, TimeUnit.SECONDS))
+ assertEquals("error", emitted[0].getString("status"))
+ assertEquals("network down", emitted[0].getMap("error")?.getString("message"))
+ }
+
+ @Test
+ fun pollDaVinci_doesNotCrashWhenReactContextIsUnavailable() {
+ // No RNPingDavinciCommon.configure(...) call — reactContextRef stays unset,
+ // exercising emitPollingStatus's no-op early return.
+ val davinciId = registerDaVinciHandle(Workflow(WorkflowConfig()))
+ val collector = mockk(relaxed = true)
+ val latch = CountDownLatch(1)
+ every { collector.pollStatus() } returns flow {
+ emit(PollingStatus.Complete("approved"))
+ latch.countDown()
+ }
+ setContinueNode(davinciId, DummyContinueNode(actions = listOf(collector)))
+ val promise = TestPromise()
+
+ RNPingDavinciCommon.pollDaVinci(davinciId, JavaOnlyMap(), promise)
+ val resolved = captureResolve(promise) as WritableMap
+
+ assertTrue(resolved.getString("subscriptionId")!!.isNotBlank())
+ assertTrue(
+ "Poll job must still run to completion without a React context",
+ latch.await(2, TimeUnit.SECONDS)
+ )
+ }
+
+ // ---- pollDaVinci natural completion does not leak the davinciId-keyed job tracking ----
+
+ @Test
+ fun pollDaVinci_removesJobFromDaVinciIdTrackingOnNaturalCompletion() {
+ val (reactContext, emitter) = mockReactContextWithEmitter()
+ RNPingDavinciCommon.configure(reactContext)
+
+ val davinciId = registerDaVinciHandle(Workflow(WorkflowConfig()))
+ val collector = mockk(relaxed = true)
+ every { collector.pollStatus() } returns flowOf(PollingStatus.Complete("approved"))
+ setContinueNode(davinciId, DummyContinueNode(actions = listOf(collector)))
+ val latch = CountDownLatch(1)
+ every { emitter.emit(any(), any()) } answers { latch.countDown() }
+ val pollPromise = TestPromise()
+
+ RNPingDavinciCommon.pollDaVinci(davinciId, JavaOnlyMap(), pollPromise)
+ captureResolve(pollPromise)
+ assertTrue(latch.await(2, TimeUnit.SECONDS))
+
+ val field = RNPingDavinciCommon::class.java.getDeclaredField("pollJobsByDaVinciId")
+ field.isAccessible = true
+ @Suppress("UNCHECKED_CAST")
+ val jobsByDaVinciId = field.get(RNPingDavinciCommon) as Map>
+
+ var attempts = 0
+ while (jobsByDaVinciId[davinciId]?.isNotEmpty() == true && attempts < 20) {
+ Thread.sleep(50)
+ attempts++
+ }
+
+ assertTrue(
+ "A naturally completed poll job must be removed from the davinciId-keyed " +
+ "tracking structure — otherwise finished jobs pile up for the client's lifetime",
+ jobsByDaVinciId[davinciId].isNullOrEmpty()
+ )
+ }
+
+ // ---- dispose()/cleanup() poll safety net ----
+
+ @Test
+ fun dispose_cancelsOutstandingPollJobForDaVinciId() {
+ val (reactContext, emitter) = mockReactContextWithEmitter()
+ RNPingDavinciCommon.configure(reactContext)
+
+ val davinciId = registerDaVinciHandle(Workflow(WorkflowConfig()))
+ val collector = mockk(relaxed = true)
+ every { collector.pollStatus() } returns flow {
+ var attempt = 0
+ while (true) {
+ emit(PollingStatus.Continue(retryCount = ++attempt, maxRetries = 60))
+ delay(50)
+ }
+ }
+ setContinueNode(davinciId, DummyContinueNode(actions = listOf(collector)))
+ val emitCount = java.util.concurrent.atomic.AtomicInteger(0)
+ val firstEmitLatch = CountDownLatch(1)
+ every { emitter.emit(any(), any()) } answers {
+ emitCount.incrementAndGet()
+ firstEmitLatch.countDown()
+ }
+ val pollPromise = TestPromise()
+
+ RNPingDavinciCommon.pollDaVinci(davinciId, JavaOnlyMap(), pollPromise)
+ captureResolve(pollPromise)
+ assertTrue(firstEmitLatch.await(2, TimeUnit.SECONDS))
+
+ val disposePromise = TestPromise()
+ RNPingDavinciCommon.dispose(davinciId, disposePromise)
+ captureResolve(disposePromise)
+
+ val countAtDispose = emitCount.get()
+ Thread.sleep(300)
+ assertEquals(
+ "dispose() must cancel outstanding poll jobs for the disposed davinciId",
+ countAtDispose,
+ emitCount.get()
+ )
+ }
+
+ @Test
+ fun cleanup_cancelsAllOutstandingPollJobs() {
+ val (reactContext, emitter) = mockReactContextWithEmitter()
+ RNPingDavinciCommon.configure(reactContext)
+
+ val davinciId = registerDaVinciHandle(Workflow(WorkflowConfig()))
+ val collector = mockk(relaxed = true)
+ every { collector.pollStatus() } returns flow {
+ var attempt = 0
+ while (true) {
+ emit(PollingStatus.Continue(retryCount = ++attempt, maxRetries = 60))
+ delay(50)
+ }
+ }
+ setContinueNode(davinciId, DummyContinueNode(actions = listOf(collector)))
+ val emitCount = java.util.concurrent.atomic.AtomicInteger(0)
+ val firstEmitLatch = CountDownLatch(1)
+ every { emitter.emit(any(), any()) } answers {
+ emitCount.incrementAndGet()
+ firstEmitLatch.countDown()
+ }
+ val pollPromise = TestPromise()
+
+ RNPingDavinciCommon.pollDaVinci(davinciId, JavaOnlyMap(), pollPromise)
+ captureResolve(pollPromise)
+ assertTrue(firstEmitLatch.await(2, TimeUnit.SECONDS))
+
+ RNPingDavinciCommon.cleanup()
+
+ val countAtCleanup = emitCount.get()
+ Thread.sleep(300)
+ assertEquals(
+ "cleanup() must cancel every outstanding poll job",
+ countAtCleanup,
+ emitCount.get()
+ )
+
+ RNPingDavinciCommon.configure()
+ }
+
// ---- helpers ----
+ private fun mockReactContextWithEmitter():
+ Pair {
+ val emitter = mockk(relaxed = true)
+ val reactContext = mockk(relaxed = true)
+ every {
+ reactContext.getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter::class.java)
+ } returns emitter
+ return reactContext to emitter
+ }
+
private fun captureResolve(promise: TestPromise): Any? {
promise.await()
return promise.resolvedValue
diff --git a/packages/davinci/android/src/test/java/com/pingidentity/rndavinci/mapper/DaVinciNodeMapperTest.kt b/packages/davinci/android/src/test/java/com/pingidentity/rndavinci/mapper/DaVinciNodeMapperTest.kt
index 6f09e4e7f..c514291e0 100644
--- a/packages/davinci/android/src/test/java/com/pingidentity/rndavinci/mapper/DaVinciNodeMapperTest.kt
+++ b/packages/davinci/android/src/test/java/com/pingidentity/rndavinci/mapper/DaVinciNodeMapperTest.kt
@@ -14,6 +14,8 @@ import com.pingidentity.davinci.collector.LabelCollector
import com.pingidentity.davinci.collector.MultiSelectCollector
import com.pingidentity.davinci.collector.PasswordCollector
import com.pingidentity.davinci.collector.PhoneNumberCollector
+import com.pingidentity.davinci.collector.PollingCollector
+import com.pingidentity.davinci.collector.QRCodeCollector
import com.pingidentity.davinci.collector.SingleSelectCollector
import com.pingidentity.davinci.collector.SubmitCollector
import com.pingidentity.davinci.collector.TextCollector
@@ -710,4 +712,194 @@ class DaVinciNodeMapperTest {
assertFalse(c.containsKey("passwordPolicy"))
}
+
+ // ---- PollingCollector ----
+
+ @Test
+ fun mapPollingCollectorCoercesIntervalAndRetriesToInt() {
+ val collector = PollingCollector().apply {
+ init(buildJsonObject {
+ put("key", "polling-field")
+ put("type", "POLLING")
+ put("label", "Polling")
+ put("pollInterval", "2000")
+ put("pollRetries", "60")
+ put("pollChallengeStatus", false)
+ put("challenge", "")
+ })
+ }
+ val node = makeNode(collector)
+
+ val result = DaVinciNodeMapper.mapNodePayload(node)
+ val c = result.asList("collectors")!![0]
+
+ assertEquals(2000, c["pollInterval"])
+ assertEquals(60, c["pollRetries"])
+ assertEquals(false, c["pollChallengeStatus"])
+ assertEquals("", c["challenge"])
+ }
+
+ @Test
+ fun mapPollingCollectorFallsBackToNativeDefaultWhenPollIntervalIsNonNumeric() {
+ // `pollRetries` must stay numeric — PollingCollector.init() unconditionally calls
+ // pollRetries.toInt() to seed retriesAllowed, so a non-numeric value throws at
+ // construction time, before the mapper's toIntOrNull() fallback is ever reached.
+ val collector = PollingCollector().apply {
+ init(buildJsonObject {
+ put("key", "polling-field")
+ put("type", "POLLING")
+ put("label", "Polling")
+ put("pollInterval", "not-a-number")
+ put("pollRetries", "60")
+ put("pollChallengeStatus", false)
+ put("challenge", "")
+ })
+ }
+ val node = makeNode(collector)
+
+ val result = DaVinciNodeMapper.mapNodePayload(node)
+ val c = result.asList("collectors")!![0]
+
+ // 2000 matches native PollingCollector.pollStatus()'s own fallback for an unparseable pollInterval.
+ assertEquals(2000, c["pollInterval"])
+ assertEquals(60, c["pollRetries"])
+ }
+
+ @Test
+ fun mapPollingCollectorIncludesChallengeFieldsForChallengeStatusMode() {
+ val collector = PollingCollector().apply {
+ init(buildJsonObject {
+ put("key", "polling-field")
+ put("type", "POLLING")
+ put("label", "Polling")
+ put("pollInterval", "1000")
+ put("pollRetries", "30")
+ put("pollChallengeStatus", true)
+ put("challenge", "abc-challenge-id")
+ })
+ }
+ val node = makeNode(collector)
+
+ val result = DaVinciNodeMapper.mapNodePayload(node)
+ val c = result.asList("collectors")!![0]
+
+ assertEquals(true, c["pollChallengeStatus"])
+ assertEquals("abc-challenge-id", c["challenge"])
+ }
+
+ @Test
+ fun mapPollingCollectorIncludesBaseFieldsAndRaw() {
+ val collector = PollingCollector().apply {
+ init(buildJsonObject {
+ put("key", "polling-field")
+ put("type", "POLLING")
+ put("label", "Waiting for approval")
+ put("pollInterval", "2000")
+ put("pollRetries", "60")
+ put("pollChallengeStatus", false)
+ put("challenge", "")
+ })
+ }
+ val input = buildJsonObject {
+ put("form", buildJsonObject {
+ put("components", buildJsonObject {
+ put("fields", buildJsonArray {
+ add(buildJsonObject {
+ put("key", "polling-field")
+ put("type", "POLLING")
+ })
+ })
+ })
+ })
+ }
+ val node = makeNode(input, collector)
+
+ val result = DaVinciNodeMapper.mapNodePayload(node)
+ val c = result.asList("collectors")!![0]
+
+ assertEquals("polling-field", c["key"])
+ assertEquals("Waiting for approval", c["label"])
+ assertNotNull(c["raw"])
+ }
+
+ // ---- QRCodeCollector ----
+
+ @Test
+ fun mapQRCodeCollectorEmitsContentAndFallbackText() {
+ val collector = QRCodeCollector().apply {
+ init(buildJsonObject {
+ put("content", "data:image/png;base64,iVBORw0KGgo=")
+ put("fallbackText", "Scan this code with your device")
+ })
+ }
+ val node = makeNode(collector)
+
+ val result = DaVinciNodeMapper.mapNodePayload(node)
+ val c = result.asList("collectors")!![0]
+
+ assertEquals("QR_CODE", c["type"])
+ assertEquals("data:image/png;base64,iVBORw0KGgo=", c["content"])
+ assertEquals("Scan this code with your device", c["fallbackText"])
+ }
+
+ @Test
+ fun mapQRCodeCollectorEmitsEmptyKeyBecauseNativeIdIsRandomUUID() {
+ // TODO-SDK-PARITY (see DaVinciNodeMapper.mapQRCodeCollector): Android's native
+ // QRCodeCollector.id() falls back to a random UUID per call, so it cannot be used
+ // as a stable key. The bridge emits "" until the native SDK exposes a real key.
+ val collector = QRCodeCollector().apply {
+ init(buildJsonObject {
+ put("content", "data:image/png;base64,iVBORw0KGgo=")
+ put("fallbackText", "Scan this code")
+ })
+ }
+ val node = makeNode(collector)
+
+ val result = DaVinciNodeMapper.mapNodePayload(node)
+ val c = result.asList("collectors")!![0]
+
+ assertEquals("", c["key"])
+ }
+
+ @Test
+ fun mapQRCodeCollectorOmitsRawFieldLookup() {
+ val collector = QRCodeCollector().apply {
+ init(buildJsonObject {
+ put("content", "data:image/png;base64,iVBORw0KGgo=")
+ put("fallbackText", "Scan this code")
+ })
+ }
+ val input = buildJsonObject {
+ put("form", buildJsonObject {
+ put("components", buildJsonObject {
+ put("fields", buildJsonArray {
+ add(buildJsonObject {
+ put("key", "qr-field")
+ put("type", "QR_CODE")
+ })
+ })
+ })
+ })
+ }
+ val node = makeNode(input, collector)
+
+ val result = DaVinciNodeMapper.mapNodePayload(node)
+ val c = result.asList("collectors")!![0]
+
+ assertFalse(c.containsKey("raw"))
+ }
+
+ @Test
+ fun mapQRCodeCollectorDefaultsToEmptyStringsWhenFieldsAbsent() {
+ val collector = QRCodeCollector().apply {
+ init(buildJsonObject { })
+ }
+ val node = makeNode(collector)
+
+ val result = DaVinciNodeMapper.mapNodePayload(node)
+ val c = result.asList("collectors")!![0]
+
+ assertEquals("", c["content"])
+ assertEquals("", c["fallbackText"])
+ }
}
diff --git a/packages/davinci/ios/Error/DaVinciErrorCodes.swift b/packages/davinci/ios/Error/DaVinciErrorCodes.swift
index 5fb42bc63..76a71bc01 100644
--- a/packages/davinci/ios/Error/DaVinciErrorCodes.swift
+++ b/packages/davinci/ios/Error/DaVinciErrorCodes.swift
@@ -22,6 +22,7 @@ enum DaVinciErrorCodes: String {
case sessionError = "DAVINCI_SESSION_ERROR"
case logoutError = "DAVINCI_LOGOUT_ERROR"
case disposeError = "DAVINCI_DISPOSE_ERROR"
+ case pollError = "DAVINCI_POLL_ERROR"
case argumentError = "DAVINCI_ARGUMENT_ERROR"
case stateError = "DAVINCI_STATE_ERROR"
case missingIntegrationError = "DAVINCI_MISSING_INTEGRATION_ERROR"
diff --git a/packages/davinci/ios/Mapper/DaVinciNodeMapper.swift b/packages/davinci/ios/Mapper/DaVinciNodeMapper.swift
index 2d549dced..aeed0f726 100644
--- a/packages/davinci/ios/Mapper/DaVinciNodeMapper.swift
+++ b/packages/davinci/ios/Mapper/DaVinciNodeMapper.swift
@@ -17,6 +17,7 @@ import RNPingCore
enum DaVinciNodeMapper {
private static let logTag = "DaVinciNodeMapper"
static let socialLoginButton = "SOCIAL_LOGIN_BUTTON"
+ private static let qrCode = "QR_CODE"
/// Converts a native DaVinci node to a bridge-friendly dictionary payload.
///
@@ -221,6 +222,13 @@ enum DaVinciNodeMapper {
map = mapDeviceRegistrationCollector(registrationCollector)
case let authenticationCollector as DeviceAuthenticationCollector:
map = mapDeviceAuthenticationCollector(authenticationCollector)
+ case let pollingCollector as PollingCollector:
+ map = mapPollingCollector(pollingCollector)
+ case let qrCodeCollector as QRCodeCollector:
+ // QRCodeCollector's `key` is parsed natively (unlike Android 2.1.0) but it does not
+ // extend FieldCollector — no `label`/`required` — so it's fully serialized here and
+ // returned early rather than routed through `applyRawField` below.
+ return mapQRCodeCollector(qrCodeCollector, node: node, logger: logger)
default:
logger?.w(
"[\(logTag)] Skipping unsupported collector type: \(String(describing: type(of: collector)))",
@@ -433,6 +441,61 @@ enum DaVinciNodeMapper {
return map
}
+ /// Serializes a `PollingCollector` to a bridge map.
+ ///
+ /// - Note: `pollInterval`/`pollRetries` are native `Int` on iOS — Android's native
+ /// collector types them as `String`; the bridge normalizes both to `number` in TS.
+ ///
+ /// - Parameter collector: PollingCollector instance.
+ /// - Returns: Serialized polling collector map.
+ private static func mapPollingCollector(_ collector: PollingCollector) -> [String: Any] {
+ var map = baseFieldCollectorMap(collector)
+ map["pollInterval"] = collector.pollInterval
+ map["pollRetries"] = collector.pollRetries
+ map["pollChallengeStatus"] = collector.pollChallengeStatus
+ map["challenge"] = collector.challenge
+ return map
+ }
+
+ /// Serializes a `QRCodeCollector` to a bridge map.
+ ///
+ /// - Note: iOS's native `QRCodeCollector` strips the `"data:...base64,"` prefix during
+ /// `init` and retains only decoded `imageData` bytes — the original data URI string is
+ /// discarded. The ticket contract requires exposing the full data URI as `content`.
+ /// Preferred source: the raw `content` string from `node.input.form.components.fields[]`
+ /// (via `findFieldJson`), which is the exact, untouched server value — no MIME type has
+ /// to be guessed. Fallback: reconstruct from `imageData` (re-encode to base64, re-prepend
+ /// a `"data:image/png;base64,"` prefix) when the raw field is absent, matching the MIME
+ /// type QR codes are documented to use. Android's native `content` getter retains the raw
+ /// data URI as received from the server and needs no reconstruction.
+ ///
+ /// - Parameters:
+ /// - collector: QRCodeCollector instance.
+ /// - node: Active continue node providing form field context for the `raw` field lookup.
+ /// - logger: Optional Ping logger for non-fatal mapping warnings.
+ /// - Returns: Serialized QR code collector map.
+ private static func mapQRCodeCollector(
+ _ collector: QRCodeCollector,
+ node: ContinueNode,
+ logger: Logger?
+ ) -> [String: Any] {
+ let field = findFieldJson(collector.key, node: node, logger: logger)
+ let content = (field?["content"] as? String)
+ ?? collector.imageData.map { "data:image/png;base64,\($0.base64EncodedString())" }
+ ?? ""
+
+ var map: [String: Any] = [
+ "key": collector.key,
+ "type": qrCode,
+ "content": content,
+ "fallbackText": collector.fallbackText
+ ]
+ if let field {
+ map["raw"] = JsonBridgeMapper.encodeJsonObject(field)
+ }
+ return map
+ }
+
/// Converts an array of `Option` values to serializable bridge maps.
///
/// - Parameter options: Native option values from a select collector.
diff --git a/packages/davinci/ios/RNPingDavinci.mm b/packages/davinci/ios/RNPingDavinci.mm
index 0211b8b88..d68ac17c6 100644
--- a/packages/davinci/ios/RNPingDavinci.mm
+++ b/packages/davinci/ios/RNPingDavinci.mm
@@ -8,6 +8,13 @@
#import
#import
+#if __has_include()
+#import
+#else
+@protocol RCTCallableJSModules
+- (void)invokeModule:(NSString *)moduleName method:(NSString *)methodName withArgs:(NSArray *)args;
+@end
+#endif
#import
/// Auto-generated Swift header.
@@ -16,10 +23,51 @@
#else
#import
#endif
+#import "RNPingDavinciEventEmitterGate.h"
@implementation RNPingDavinci
+
+// Receives callableJSModules from the RCT module registry (Old + New Arch).
+@synthesize callableJSModules = _callableJSModules;
+
RCT_EXPORT_MODULE()
+- (instancetype)init
+{
+ self = [super init];
+ if (self) {
+ if (RNPingDavinciClaimEventEmitterOwnership(@"turbo")) {
+ [[NSNotificationCenter defaultCenter]
+ addObserver:self
+ selector:@selector(onNativeEmit:)
+ name:@"RNPingDavinci_NativeEmit"
+ object:nil];
+ }
+ }
+ return self;
+}
+
+- (void)dealloc
+{
+ [[NSNotificationCenter defaultCenter] removeObserver:self];
+ RNPingDavinciReleaseEventEmitterOwnership(@"turbo");
+}
+
+/**
+ * Receives internal emit notifications from the Swift bridge and forwards
+ * them to JS DeviceEventEmitter via callableJSModules.
+ */
+- (void)onNativeEmit:(NSNotification *)notification
+{
+ NSString *name = notification.userInfo[@"eventName"];
+ id body = notification.userInfo[@"eventBody"];
+ if (!name || !_callableJSModules) {
+ return;
+ }
+ NSArray *args = body ? @[name, body] : @[name];
+ [_callableJSModules invokeModule:@"RCTDeviceEventEmitter" method:@"emit" withArgs:args];
+}
+
/**
Returns the shared Swift implementation that performs all native work.
*/
@@ -34,6 +82,8 @@ - (RNPingDavinciImpl *)swiftImpl
*/
- (void)invalidate
{
+ [[NSNotificationCenter defaultCenter] removeObserver:self];
+ RNPingDavinciReleaseEventEmitterOwnership(@"turbo");
[[self swiftImpl] invalidate];
}
@@ -198,6 +248,15 @@ - (void)dispose:(NSString *)davinciId
rejecter:reject];
}
+/// Bridges `pollDaVinci(davinciId, options)`.
+- (void)pollDaVinci:(NSString *)davinciId
+ options:(NSDictionary *)options
+ resolve:(RCTPromiseResolveBlock)resolve
+ reject:(RCTPromiseRejectBlock)reject
+{
+ [[self swiftImpl] pollDaVinci:davinciId options:options resolver:resolve rejecter:reject];
+}
+
- (std::shared_ptr)getTurboModule:
(const facebook::react::ObjCTurboModule::InitParams &)params
{
diff --git a/packages/davinci/ios/RNPingDavinciClassic.mm b/packages/davinci/ios/RNPingDavinciClassic.mm
index 3b29498c5..2e937d2af 100644
--- a/packages/davinci/ios/RNPingDavinciClassic.mm
+++ b/packages/davinci/ios/RNPingDavinciClassic.mm
@@ -5,12 +5,20 @@
* of the MIT license. See the LICENSE file for details.
*/
#import
+#if __has_include()
+#import
+#else
+@protocol RCTCallableJSModules
+- (void)invokeModule:(NSString *)moduleName method:(NSString *)methodName withArgs:(NSArray *)args;
+@end
+#endif
#if __has_include("RNPingDavinci-Swift.h")
#import "RNPingDavinci-Swift.h"
#else
#import
#endif
+#import "RNPingDavinciEventEmitterGate.h"
/**
* Classic (non-Turbo) React Native module for DaVinci operations.
@@ -23,6 +31,9 @@ @interface RNPingDavinciClassic : NSObject
@implementation RNPingDavinciClassic
+// Receives callableJSModules from the RCT bridge (Old Arch).
+@synthesize callableJSModules = _callableJSModules;
+
RCT_EXPORT_MODULE(RNPingDavinciClassic)
// Module init does not touch UIKit; main-thread hops are handled inside
@@ -33,6 +44,42 @@ + (BOOL)requiresMainQueueSetup
return NO;
}
+- (instancetype)init
+{
+ self = [super init];
+ if (self) {
+ if (RNPingDavinciClaimEventEmitterOwnership(@"classic")) {
+ [[NSNotificationCenter defaultCenter]
+ addObserver:self
+ selector:@selector(onNativeEmit:)
+ name:@"RNPingDavinci_NativeEmit"
+ object:nil];
+ }
+ }
+ return self;
+}
+
+- (void)dealloc
+{
+ [[NSNotificationCenter defaultCenter] removeObserver:self];
+ RNPingDavinciReleaseEventEmitterOwnership(@"classic");
+}
+
+/**
+ * Receives internal emit notifications from the Swift bridge and forwards
+ * them to JS DeviceEventEmitter via callableJSModules.
+ */
+- (void)onNativeEmit:(NSNotification *)notification
+{
+ NSString *name = notification.userInfo[@"eventName"];
+ id body = notification.userInfo[@"eventBody"];
+ if (!name || !_callableJSModules) {
+ return;
+ }
+ NSArray *args = body ? @[name, body] : @[name];
+ [_callableJSModules invokeModule:@"RCTDeviceEventEmitter" method:@"emit" withArgs:args];
+}
+
/**
* Executes a block with the shared Swift implementation on the main thread.
*
@@ -153,4 +200,15 @@ - (void)withSwiftImpl:(void (^)(RNPingDavinciImpl *impl))block
}];
}
+/// Starts streaming polling status updates for the active `PollingCollector`.
+RCT_EXPORT_METHOD(pollDaVinci:(NSString *)davinciId
+ options:(NSDictionary *)options
+ resolver:(RCTPromiseResolveBlock)resolve
+ rejecter:(RCTPromiseRejectBlock)reject)
+{
+ [self withSwiftImpl:^(RNPingDavinciImpl *impl) {
+ [impl pollDaVinci:davinciId options:options resolver:resolve rejecter:reject];
+ }];
+}
+
@end
diff --git a/packages/davinci/ios/RNPingDavinciCommon.swift b/packages/davinci/ios/RNPingDavinciCommon.swift
index 337b35a06..66417276b 100644
--- a/packages/davinci/ios/RNPingDavinciCommon.swift
+++ b/packages/davinci/ios/RNPingDavinciCommon.swift
@@ -13,6 +13,19 @@ import PingOidc
import PingOrchestrate
import RNPingCore
+/// Seam allowing `pollDaVinci` to be exercised in bridge-level unit tests without
+/// invoking `PollingCollector`'s real network/timing logic. `PollingCollector` is
+/// declared `public` (not `open`) in the iOS SDK, so it cannot be subclassed
+/// cross-module — conforming it to this protocol via the extension below lets test
+/// doubles substitute for it with no production behavior change (`id`/`poll()` are
+/// already implemented identically on the real type).
+protocol PollableCollector: Sendable {
+ var id: String { get }
+ func poll() -> AsyncStream
+}
+
+extension PollingCollector: PollableCollector {}
+
/// Serializes lifecycle operations that mutate shared DaVinci runtime state.
private actor DaVinciLifecycleCoordinator {
/// Tail task representing the latest enqueued lifecycle work item.
@@ -54,6 +67,8 @@ public final class RNPingDavinciCommon: NSObject {
public typealias BoolResolver = @Sendable (Bool) -> Void
/// Promise resolver for void results.
public typealias VoidResolver = @Sendable () -> Void
+ /// Promise resolver for poll subscription payloads.
+ public typealias PollResolver = @Sendable (NSDictionary) -> Void
/// Promise rejecter closure type used by the DaVinci Swift bridge.
public typealias PromiseRejecter = @Sendable (String, String, NSError?) -> Void
@@ -63,6 +78,8 @@ public final class RNPingDavinciCommon: NSObject {
private static let davinciRegistry: Registry = CoreRuntime.davinciRegistry
/// Lifecycle coordinator ensuring ordered configure/cleanup execution.
private static let lifecycleCoordinator = DaVinciLifecycleCoordinator()
+ /// In-flight poll tasks keyed by `subscriptionId`, grouped by `davinciId`.
+ private static let pollJobStore = PollJobStore()
/// Releases shared runtime state.
@objc
@@ -75,12 +92,29 @@ public final class RNPingDavinciCommon: NSObject {
/// Clears DaVinci runtime state in serialized lifecycle order.
private static func cleanupAsync() async {
CoreRuntime.setDaVinciCollectorResolver(nil)
+ pollJobStore.removeAll()
await lifecycleCoordinator.enqueue {
stateStore.removeAll()
await davinciRegistry.removeAll()
}
}
+#if DEBUG
+ /// Registers a `ContinueNode` for `davinciId` without a full `configureDaVinci` call.
+ ///
+ /// - Note: Test-only seam so `pollDaVinci` tests can install a fake `PollingCollector`
+ /// on a node without exercising native workflow construction.
+ static func _setContinueNodeForTesting(davinciId: String, node: ContinueNode) {
+ stateStore.setNode(davinciId: davinciId, node: node)
+ }
+
+ /// Test-only seam exposing the number of poll tasks currently tracked for
+ /// `davinciId`, to verify natural-completion cleanup doesn't leak tracking entries.
+ static func _trackedPollTaskCount(for davinciId: String) -> Int {
+ pollJobStore.trackedTaskCount(for: davinciId)
+ }
+#endif
+
// MARK: - Bridge methods
/// Configures a native DaVinci workflow from JS configuration.
@@ -395,6 +429,10 @@ public final class RNPingDavinciCommon: NSObject {
/// Disposes a DaVinci workflow and clears native state for that client.
///
+ /// Also cancels any outstanding poll `Task`s for `davinciId` — a safety net so a
+ /// disposed instance never leaves an orphaned poll running after the caller stops
+ /// listening for its events.
+ ///
/// - Parameters:
/// - davinciId: Native DaVinci instance id.
/// - resolver: Promise resolver called when dispose completes.
@@ -406,6 +444,7 @@ public final class RNPingDavinciCommon: NSObject {
rejecter: @escaping PromiseRejecter
) {
let promise = PromiseBridge(resolver: resolver, rejecter: rejecter)
+ pollJobStore.cancelAll(for: davinciId)
Task { @MainActor in
stateStore.clearNodeState(for: davinciId)
await davinciRegistry.remove(davinciId)
@@ -413,6 +452,72 @@ public final class RNPingDavinciCommon: NSObject {
}
}
+ /// Starts streaming polling status updates for the active `PollingCollector`.
+ ///
+ /// The poll `Task` is registered in the shared job store — keyed by `davinciId` —
+ /// before the promise resolves. Swift schedules a `Task`'s body asynchronously
+ /// rather than running it inline, so the actual `AsyncStream` consumption (and
+ /// therefore the first possible JS event) cannot begin until after this function
+ /// returns and the promise has resolved, mirroring the ordering guarantee on the
+ /// Android side.
+ ///
+ /// - Note: `PollingCollector.poll()` exposes no cancellation hook (no
+ /// `onTermination`, no `cancel()`) — a platform constraint of the iOS SDK. Once
+ /// started, this `Task` runs to its own terminal state unless `dispose`/
+ /// `cleanup` cancels it as a teardown safety net.
+ ///
+ /// - Parameters:
+ /// - davinciId: Native DaVinci instance id.
+ /// - options: Bridge map with an optional `key` selecting which
+ /// `PollingCollector` to poll when more than one is present on the active
+ /// node; the first one is used when `key` is absent.
+ /// - resolver: Promise resolver called with `{ subscriptionId }`.
+ /// - rejecter: Promise rejecter called with `GenericError`.
+ @objc
+ public static func pollDaVinci(
+ _ davinciId: String,
+ options: NSDictionary,
+ resolver: @escaping PollResolver,
+ rejecter: @escaping PromiseRejecter
+ ) {
+ let promise = PromiseBridge(resolver: resolver, rejecter: rejecter)
+ guard let node = stateStore.activeContinueNode(for: davinciId) else {
+ promise.reject(
+ DaVinciErrorMapper.state(
+ code: .pollError,
+ message: "No active ContinueNode found for davinci id=\(davinciId)"
+ )
+ )
+ return
+ }
+
+ let requestedKey = options["key"] as? String
+ let collectors = node.collectors.compactMap { $0 as? any PollableCollector }
+ let collector = requestedKey.map { key in collectors.first { $0.id == key } } ?? collectors.first
+ guard let collector else {
+ promise.reject(
+ DaVinciErrorMapper.state(
+ code: .pollError,
+ message: "No active PollingCollector found for davinci id=\(davinciId)"
+ + (requestedKey.map { " with key=\($0)" } ?? "")
+ )
+ )
+ return
+ }
+
+ let subscriptionId = UUID().uuidString
+ let task = Task {
+ for await status in collector.poll() {
+ if Task.isCancelled { break }
+ emitPollingStatus(davinciId: davinciId, subscriptionId: subscriptionId, status: status)
+ }
+ pollJobStore.remove(davinciId: davinciId, subscriptionId: subscriptionId)
+ }
+ pollJobStore.register(davinciId: davinciId, subscriptionId: subscriptionId, task: task)
+
+ promise.resolve(["subscriptionId": subscriptionId] as NSDictionary)
+ }
+
// MARK: - Helpers
/// Resolves a DaVinci instance from the shared core registry.
@@ -497,6 +602,109 @@ public final class RNPingDavinciCommon: NSObject {
return String(describing: value)
}
}
+
+ /// Emits one `PollingStatus` tick to JS via the shared `RNPingDavinci_NativeEmit`
+ /// notification, which the architecture-specific bridge module owning the event
+ /// emitter gate forwards to `RCTDeviceEventEmitter`.
+ ///
+ /// - Parameters:
+ /// - davinciId: Native DaVinci instance id.
+ /// - subscriptionId: Subscription id returned by `pollDaVinci`.
+ /// - status: Native polling status tick.
+ private static func emitPollingStatus(davinciId: String, subscriptionId: String, status: PollingStatus) {
+ var body: [String: Any] = ["subscriptionId": subscriptionId, "daVinciId": davinciId]
+ switch status {
+ case .continue(let retryCount, let maxRetries):
+ body["status"] = "continue"
+ body["retryCount"] = retryCount
+ body["maxRetries"] = maxRetries
+ case .complete(let value):
+ body["status"] = "complete"
+ body["value"] = value
+ case .timedOut:
+ body["status"] = "timedOut"
+ case .expired:
+ body["status"] = "expired"
+ case .error(let error):
+ body["status"] = "error"
+ body["error"] = ["message": error.localizedDescription]
+ }
+ NotificationCenter.default.post(
+ name: .pingDavinciNativeEmit,
+ object: nil,
+ userInfo: ["eventName": RNPingDavinciEvents.pollingStatus, "eventBody": body]
+ )
+ }
+}
+
+/// Tracks in-flight poll `Task`s keyed by `subscriptionId`, grouped by
+/// `davinciId`, so `dispose()`/`cleanup()` can cancel every outstanding poll
+/// for one instance — a safety net against orphaned polls. There is no public
+/// per-poll cancellation surface (neither native SDK exposes a cancellation
+/// primitive for an in-flight poll), so tasks are only ever removed here on
+/// natural completion or instance-scoped teardown.
+///
+/// - Note: `@unchecked Sendable` is used because this class owns a mutable map of
+/// `Task` handles. All reads/writes are synchronized with `NSLock`.
+private final class PollJobStore: @unchecked Sendable {
+ private let lock = NSLock()
+ private var tasksByDaVinciId = [String: [String: Task]]()
+ /// Tombstones `subscriptionId`s that completed before `register` was called for them.
+ private var completedSubscriptions = Set()
+
+ /// Registers a poll task, keyed by `subscriptionId`, before the promise resolves.
+ ///
+ /// - Note: A no-op if `subscriptionId` already completed and was removed —
+ /// an unstructured `Task` can start running concurrently with the caller
+ /// that spawned it, so completion may race ahead of this call. Without this
+ /// guard, a late `register` would resurrect an already-finished task.
+ func register(davinciId: String, subscriptionId: String, task: Task) {
+ lock.lock()
+ defer { lock.unlock() }
+ if completedSubscriptions.remove(subscriptionId) != nil {
+ return
+ }
+ tasksByDaVinciId[davinciId, default: [:]][subscriptionId] = task
+ }
+
+ /// Removes a completed poll task's bookkeeping without cancelling it (it already finished).
+ func remove(davinciId: String, subscriptionId: String) {
+ lock.lock()
+ defer { lock.unlock() }
+ if tasksByDaVinciId[davinciId]?.removeValue(forKey: subscriptionId) == nil {
+ completedSubscriptions.insert(subscriptionId)
+ }
+ if tasksByDaVinciId[davinciId]?.isEmpty == true {
+ tasksByDaVinciId.removeValue(forKey: davinciId)
+ }
+ }
+
+ /// Cancels every tracked poll task for `davinciId`.
+ func cancelAll(for davinciId: String) {
+ lock.lock()
+ let tasks = tasksByDaVinciId.removeValue(forKey: davinciId).map { Array($0.values) } ?? []
+ lock.unlock()
+ tasks.forEach { $0.cancel() }
+ }
+
+ /// Cancels every tracked poll task, across all DaVinci instances.
+ func removeAll() {
+ lock.lock()
+ let tasks = tasksByDaVinciId.values.flatMap { $0.values }
+ tasksByDaVinciId.removeAll()
+ completedSubscriptions.removeAll()
+ lock.unlock()
+ tasks.forEach { $0.cancel() }
+ }
+
+#if DEBUG
+ /// Test-only seam returning the number of tracked poll tasks for `davinciId`.
+ func trackedTaskCount(for davinciId: String) -> Int {
+ lock.lock()
+ defer { lock.unlock() }
+ return tasksByDaVinciId[davinciId]?.count ?? 0
+ }
+#endif
}
/// Handle for storing DaVinci client instances.
diff --git a/packages/davinci/ios/RNPingDavinciEventEmitterGate.h b/packages/davinci/ios/RNPingDavinciEventEmitterGate.h
new file mode 100644
index 000000000..124f4962a
--- /dev/null
+++ b/packages/davinci/ios/RNPingDavinciEventEmitterGate.h
@@ -0,0 +1,28 @@
+/*
+ * Copyright (c) 2026 Ping Identity Corporation. All rights reserved.
+ *
+ * This software may be modified and distributed under the terms
+ * of the MIT license. See the LICENSE file for details.
+ */
+
+#import
+
+NS_ASSUME_NONNULL_BEGIN
+
+/// Ensures only one of the two architecture-specific bridge modules
+/// (`RNPingDavinci` for new arch, `RNPingDavinciClassic` for old arch)
+/// subscribes to `RNPingDavinci_NativeEmit` notifications and forwards them
+/// to JS via `RCTDeviceEventEmitter`. Without this gate both modules would
+/// subscribe simultaneously and every polling status event would fire twice
+/// on the JS side.
+///
+/// Returns `YES` if `ownerId` is not already held by another owner; `NO` otherwise.
+FOUNDATION_EXPORT BOOL RNPingDavinciClaimEventEmitterOwnership(NSString *ownerId);
+
+/// Releases the event-emitter ownership slot previously claimed by `ownerId`,
+/// allowing a subsequent `RNPingDavinciClaimEventEmitterOwnership` call (e.g. from a
+/// module instance recreated after a bridge reload) to succeed. No-op if `ownerId`
+/// does not currently hold the slot.
+FOUNDATION_EXPORT void RNPingDavinciReleaseEventEmitterOwnership(NSString *ownerId);
+
+NS_ASSUME_NONNULL_END
diff --git a/packages/davinci/ios/RNPingDavinciEventEmitterGate.mm b/packages/davinci/ios/RNPingDavinciEventEmitterGate.mm
new file mode 100644
index 000000000..15fe6a35b
--- /dev/null
+++ b/packages/davinci/ios/RNPingDavinciEventEmitterGate.mm
@@ -0,0 +1,59 @@
+/*
+ * Copyright (c) 2026 Ping Identity Corporation. All rights reserved.
+ *
+ * This software may be modified and distributed under the terms
+ * of the MIT license. See the LICENSE file for details.
+ */
+
+// RNPingDavinciEventEmitterGate
+//
+// Both RNPingDavinci (new arch / TurboModule) and RNPingDavinciClassic (old arch)
+// can be loaded in the same process at startup. Each module wants to observe
+// RNPingDavinci_NativeEmit notifications and forward them to JS via
+// RCTDeviceEventEmitter. Without coordination both would subscribe and every
+// polling status event would fire twice on the JS side.
+//
+// RNPingDavinciClaimEventEmitterOwnership lets whichever module initialises
+// first claim the single forwarding slot (returns YES). The other module
+// receives NO and stays silent. The owner releases the slot on teardown via
+// RNPingDavinciReleaseEventEmitterOwnership, so a module instance recreated
+// after a bridge reload can reclaim it. The Swift layer
+// (RNPingDavinciCommon.emitEvent) posts to NotificationCenter without caring
+// which arch won.
+
+#import "RNPingDavinciEventEmitterGate.h"
+
+static NSLock *RNPingDavinciEventEmitterGateLock(void)
+{
+ static NSLock *lock = nil;
+ static dispatch_once_t onceToken;
+ dispatch_once(&onceToken, ^{
+ lock = [[NSLock alloc] init];
+ });
+ return lock;
+}
+
+static NSString *_Nullable gRNPingDavinciEventEmitterOwnerId = nil;
+
+BOOL RNPingDavinciClaimEventEmitterOwnership(NSString *ownerId)
+{
+ NSLock *lock = RNPingDavinciEventEmitterGateLock();
+ [lock lock];
+ BOOL didClaim = NO;
+ if (gRNPingDavinciEventEmitterOwnerId == nil) {
+ gRNPingDavinciEventEmitterOwnerId = [ownerId copy];
+ didClaim = YES;
+ }
+ [lock unlock];
+ return didClaim;
+}
+
+void RNPingDavinciReleaseEventEmitterOwnership(NSString *ownerId)
+{
+ NSLock *lock = RNPingDavinciEventEmitterGateLock();
+ [lock lock];
+ if ([gRNPingDavinciEventEmitterOwnerId isEqualToString:ownerId]) {
+ gRNPingDavinciEventEmitterOwnerId = nil;
+ }
+ [lock unlock];
+}
diff --git a/packages/davinci/ios/RNPingDavinciEvents.swift b/packages/davinci/ios/RNPingDavinciEvents.swift
new file mode 100644
index 000000000..1232b9eb1
--- /dev/null
+++ b/packages/davinci/ios/RNPingDavinciEvents.swift
@@ -0,0 +1,24 @@
+/*
+ * Copyright (c) 2026 Ping Identity Corporation. All rights reserved.
+ *
+ * This software may be modified and distributed under the terms
+ * of the MIT license. See the LICENSE file for details.
+ */
+
+import Foundation
+
+/// DeviceEventEmitter event names used by the RNPingDavinci bridge.
+@objcMembers
+public class RNPingDavinciEvents: NSObject {
+ /// Event name emitted to JS with the current DaVinci polling status.
+ public static let pollingStatus = "com.pingidentity.rndavinci.PollingStatus"
+}
+
+/// `NotificationCenter` notification used to forward events from the Swift common
+/// runtime to whichever architecture-specific bridge module (`RNPingDavinci` or
+/// `RNPingDavinciClassic`) currently owns JS event forwarding — see
+/// `RNPingDavinciEventEmitterGate`.
+public extension Notification.Name {
+ /// Notification posted when the Swift common runtime has a native event to forward to JS.
+ static let pingDavinciNativeEmit = Notification.Name("RNPingDavinci_NativeEmit")
+}
diff --git a/packages/davinci/ios/RNPingDavinciImpl.swift b/packages/davinci/ios/RNPingDavinciImpl.swift
index 16029c2c8..5e8af4580 100644
--- a/packages/davinci/ios/RNPingDavinciImpl.swift
+++ b/packages/davinci/ios/RNPingDavinciImpl.swift
@@ -22,6 +22,8 @@ public final class RNPingDavinciImpl: NSObject, @unchecked Sendable {
public typealias BoolResolver = @Sendable (Bool) -> Void
/// Promise resolver for void results.
public typealias VoidResolver = @Sendable () -> Void
+ /// Promise resolver for poll subscription payloads.
+ public typealias PollResolver = @Sendable (NSDictionary) -> Void
/// Promise rejecter closure type used by the DaVinci Swift bridge.
public typealias PromiseRejecter = @Sendable (String, String, NSError?) -> Void
@@ -174,4 +176,21 @@ public final class RNPingDavinciImpl: NSObject, @unchecked Sendable {
) {
RNPingDavinciCommon.dispose(davinciId, resolver: resolver, rejecter: rejecter)
}
+
+ /// Starts streaming polling status updates for the active `PollingCollector`.
+ ///
+ /// - Parameters:
+ /// - davinciId: Native DaVinci instance id.
+ /// - options: Bridge map with an optional `key` collector selector.
+ /// - resolver: Promise resolver called with `{ subscriptionId }`.
+ /// - rejecter: Promise rejecter called with `GenericError`.
+ @objc(pollDaVinci:options:resolver:rejecter:)
+ public func pollDaVinci(
+ _ davinciId: String,
+ options: NSDictionary,
+ resolver: @escaping PollResolver,
+ rejecter: @escaping PromiseRejecter
+ ) {
+ RNPingDavinciCommon.pollDaVinci(davinciId, options: options, resolver: resolver, rejecter: rejecter)
+ }
}
diff --git a/packages/davinci/ios/Tests/DaVinciNodeMapperTests.swift b/packages/davinci/ios/Tests/DaVinciNodeMapperTests.swift
index 727be44b1..2d9c7ae19 100644
--- a/packages/davinci/ios/Tests/DaVinciNodeMapperTests.swift
+++ b/packages/davinci/ios/Tests/DaVinciNodeMapperTests.swift
@@ -594,6 +594,145 @@ final class DaVinciNodeMapperTests: XCTestCase {
XCTAssertNil(first?["link"])
}
+ // MARK: - PollingCollector serialization
+
+ func testMapPollingCollectorIncludesPollFields() {
+ let node = makeContinueNode(collectors: [
+ makePollingCollector(key: "polling-field", pollInterval: 2000, pollRetries: 60)
+ ])
+
+ let payload = DaVinciNodeMapper.mapNodePayload(node)
+ let first = (payload["collectors"] as? [[String: Any]])?.first
+
+ XCTAssertEqual(first?["key"] as? String, "polling-field")
+ XCTAssertEqual(first?["pollInterval"] as? Int, 2000)
+ XCTAssertEqual(first?["pollRetries"] as? Int, 60)
+ XCTAssertEqual(first?["pollChallengeStatus"] as? Bool, false)
+ XCTAssertEqual(first?["challenge"] as? String, "")
+ }
+
+ func testMapPollingCollectorIncludesChallengeFieldsForChallengeStatusMode() {
+ let node = makeContinueNode(collectors: [
+ makePollingCollector(
+ key: "polling-field",
+ pollInterval: 1000,
+ pollRetries: 30,
+ pollChallengeStatus: true,
+ challenge: "abc-challenge-id"
+ )
+ ])
+
+ let payload = DaVinciNodeMapper.mapNodePayload(node)
+ let first = (payload["collectors"] as? [[String: Any]])?.first
+
+ XCTAssertEqual(first?["pollChallengeStatus"] as? Bool, true)
+ XCTAssertEqual(first?["challenge"] as? String, "abc-challenge-id")
+ }
+
+ func testMapPollingCollectorIncludesRawFieldWhenFormInputPresent() {
+ let input: [String: Any] = [
+ "form": [
+ "components": [
+ "fields": [
+ ["key": "polling-field", "type": "POLLING"]
+ ]
+ ]
+ ]
+ ]
+ let node = makeContinueNode(
+ collectors: [makePollingCollector(key: "polling-field", pollInterval: 2000, pollRetries: 60)],
+ input: input
+ )
+
+ let payload = DaVinciNodeMapper.mapNodePayload(node)
+ let first = (payload["collectors"] as? [[String: Any]])?.first
+
+ XCTAssertNotNil(first?["raw"])
+ }
+
+ // MARK: - QRCodeCollector serialization
+
+ func testMapQRCodeCollectorEmitsContentAndFallbackText() {
+ let node = makeContinueNode(collectors: [
+ makeQRCodeCollector(
+ key: "qr-field",
+ content: "data:image/png;base64,iVBORw0KGgo=",
+ fallbackText: "Scan this code with your device"
+ )
+ ])
+
+ let payload = DaVinciNodeMapper.mapNodePayload(node)
+ let first = (payload["collectors"] as? [[String: Any]])?.first
+
+ XCTAssertEqual(first?["type"] as? String, "QR_CODE")
+ XCTAssertEqual(first?["content"] as? String, "data:image/png;base64,iVBORw0KGgo=")
+ XCTAssertEqual(first?["fallbackText"] as? String, "Scan this code with your device")
+ }
+
+ func testMapQRCodeCollectorUsesStableKeyFromServerField() {
+ // Unlike Android 2.1.0 (whose native `id()` returns a random UUID per call), iOS's
+ // native `QRCodeCollector.key` is parsed from the server JSON and is stable.
+ let node = makeContinueNode(collectors: [
+ makeQRCodeCollector(key: "qr-field", content: "data:image/png;base64,iVBORw0KGgo=", fallbackText: "Scan")
+ ])
+
+ let payload = DaVinciNodeMapper.mapNodePayload(node)
+ let first = (payload["collectors"] as? [[String: Any]])?.first
+
+ XCTAssertEqual(first?["key"] as? String, "qr-field")
+ }
+
+ func testMapQRCodeCollectorReconstructsContentFromImageDataWhenRawFieldAbsent() {
+ let imageBytes = Data([0x01, 0x02, 0x03])
+ let base64 = imageBytes.base64EncodedString()
+ let node = makeContinueNode(collectors: [
+ makeQRCodeCollector(
+ key: "qr-field",
+ content: "data:image/png;base64,\(base64)",
+ fallbackText: "Scan"
+ )
+ ])
+
+ let payload = DaVinciNodeMapper.mapNodePayload(node)
+ let first = (payload["collectors"] as? [[String: Any]])?.first
+
+ XCTAssertEqual(first?["content"] as? String, "data:image/png;base64,\(base64)")
+ }
+
+ func testMapQRCodeCollectorDefaultsToEmptyStringWhenContentAndImageDataAbsent() {
+ // Omit "content" entirely (rather than passing an empty string) so
+ // QRCodeCollector.imageData stays nil — Data(base64Encoded: "") would otherwise
+ // decode to a non-nil empty Data, masking the mapper's final `?? ""` fallback.
+ let collector = QRCodeCollector(with: ["key": "qr-field", "fallbackText": "Scan"])
+ let node = makeContinueNode(collectors: [collector])
+
+ let payload = DaVinciNodeMapper.mapNodePayload(node)
+ let first = (payload["collectors"] as? [[String: Any]])?.first
+
+ XCTAssertEqual(first?["content"] as? String, "")
+ }
+
+ func testMapQRCodeCollectorIncludesRawFieldWhenFormInputPresent() {
+ let input: [String: Any] = [
+ "form": [
+ "components": [
+ "fields": [
+ ["key": "qr-field", "type": "QR_CODE", "content": "data:image/png;base64,iVBORw0KGgo="]
+ ]
+ ]
+ ]
+ ]
+ let node = makeContinueNode(
+ collectors: [makeQRCodeCollector(key: "qr-field", content: "data:image/png;base64,iVBORw0KGgo=", fallbackText: "Scan")],
+ input: input
+ )
+
+ let payload = DaVinciNodeMapper.mapNodePayload(node)
+ let first = (payload["collectors"] as? [[String: Any]])?.first
+
+ XCTAssertNotNil(first?["raw"])
+ }
+
// MARK: - multiple collector types
func testMapContinueNodeWithMultipleCollectorTypes() {
@@ -631,6 +770,33 @@ final class DaVinciNodeMapperTests: XCTestCase {
return PasswordCollector(with: ["key": key, "type": "PASSWORD", "label": key, "required": false])
}
+ private func makePollingCollector(
+ key: String,
+ pollInterval: Int,
+ pollRetries: Int,
+ pollChallengeStatus: Bool = false,
+ challenge: String = ""
+ ) -> PollingCollector {
+ return PollingCollector(with: [
+ "key": key,
+ "type": "POLLING",
+ "label": key,
+ "required": false,
+ "pollInterval": pollInterval,
+ "pollRetries": pollRetries,
+ "pollChallengeStatus": pollChallengeStatus,
+ "challenge": challenge
+ ])
+ }
+
+ private func makeQRCodeCollector(key: String, content: String, fallbackText: String) -> QRCodeCollector {
+ return QRCodeCollector(with: [
+ "key": key,
+ "content": content,
+ "fallbackText": fallbackText
+ ])
+ }
+
private func makeDaVinciSuccessNode(sessionValue: String) -> SuccessNode {
return SuccessNode(input: [:], session: StubSession(value: sessionValue))
}
diff --git a/packages/davinci/ios/Tests/RNPingDavinciCommonTests.swift b/packages/davinci/ios/Tests/RNPingDavinciCommonTests.swift
index 4f28466d0..533efeb99 100644
--- a/packages/davinci/ios/Tests/RNPingDavinciCommonTests.swift
+++ b/packages/davinci/ios/Tests/RNPingDavinciCommonTests.swift
@@ -9,7 +9,10 @@
//
import XCTest
+import PingDavinci
+import PingDavinciPlugin
import PingLogger
+import PingOrchestrate
@testable import RNPingCore
@testable import RNPingDavinci
@@ -526,6 +529,344 @@ final class RNPingDavinciCommonTests: XCTestCase {
assertGetSessionResolves(latestId)
}
+ // MARK: - pollDaVinci
+
+ func testPollDaVinciRejectsStateErrorWhenNoContinueNode() {
+ assertReject(
+ expectedCode: DaVinciErrorCodes.pollError.rawValue,
+ expectedType: .stateError
+ ) { rejecter, resolver in
+ RNPingDavinciCommon.pollDaVinci(
+ "missing",
+ options: [:],
+ resolver: { resolver($0) },
+ rejecter: rejecter
+ )
+ }
+ }
+
+ func testPollDaVinciRejectsStateErrorWhenNoPollingCollectorPresent() {
+ let davinciId = "no-polling-collector"
+ RNPingDavinciCommon._setContinueNodeForTesting(
+ davinciId: davinciId,
+ node: makeContinueNode(collectors: [])
+ )
+
+ assertReject(
+ expectedCode: DaVinciErrorCodes.pollError.rawValue,
+ expectedType: .stateError
+ ) { rejecter, resolver in
+ RNPingDavinciCommon.pollDaVinci(
+ davinciId,
+ options: [:],
+ resolver: { resolver($0) },
+ rejecter: rejecter
+ )
+ }
+ }
+
+ func testPollDaVinciRejectsStateErrorWhenKeyDoesNotMatchAnyPresentCollector() {
+ let davinciId = "key-not-among-present-collectors"
+ let existing = FakePollingCollector(key: "existing-key", statuses: [.complete(status: "approved")])
+ RNPingDavinciCommon._setContinueNodeForTesting(
+ davinciId: davinciId,
+ node: makeContinueNode(collectors: [existing])
+ )
+
+ assertReject(
+ expectedCode: DaVinciErrorCodes.pollError.rawValue,
+ expectedType: .stateError
+ ) { rejecter, resolver in
+ RNPingDavinciCommon.pollDaVinci(
+ davinciId,
+ options: ["key": "no-such-key"],
+ resolver: { resolver($0) },
+ rejecter: rejecter
+ )
+ }
+
+ XCTAssertFalse(existing.pollWasCalled)
+ }
+
+ func testPollDaVinciSelectsCollectorByKeyWhenMultiplePresent() {
+ let davinciId = "multi-collector"
+ let matching = FakePollingCollector(key: "target-key", statuses: [.complete(status: "approved")])
+ let other = FakePollingCollector(key: "other-key", statuses: [.complete(status: "wrong")])
+ RNPingDavinciCommon._setContinueNodeForTesting(
+ davinciId: davinciId,
+ node: makeContinueNode(collectors: [other, matching])
+ )
+
+ let resolveExpectation = expectation(description: "poll resolve")
+
+ RNPingDavinciCommon.pollDaVinci(
+ davinciId,
+ options: ["key": "target-key"],
+ resolver: { _ in
+ Task { @MainActor in resolveExpectation.fulfill() }
+ },
+ rejecter: { _, _, _ in }
+ )
+
+ wait(for: [resolveExpectation], timeout: 2.0)
+ let pollStartedExpectation = expectation(description: "matching collector polled")
+ Task {
+ while !matching.pollWasCalled { try? await Task.sleep(nanoseconds: 10_000_000) }
+ pollStartedExpectation.fulfill()
+ }
+ wait(for: [pollStartedExpectation], timeout: 2.0)
+
+ XCTAssertTrue(matching.pollWasCalled)
+ XCTAssertFalse(other.pollWasCalled)
+ }
+
+ func testPollDaVinciResolvesWithSubscriptionIdBeforeAnyEventIsEmitted() {
+ let davinciId = "ordering-check"
+ let collector = FakePollingCollector(
+ key: "polling-field",
+ statuses: [.complete(status: "approved")],
+ delayNanoseconds: 200_000_000
+ )
+ RNPingDavinciCommon._setContinueNodeForTesting(
+ davinciId: davinciId,
+ node: makeContinueNode(collectors: [collector])
+ )
+
+ let observer = EventObserver()
+ let resolveExpectation = expectation(description: "poll resolve")
+ let capture = StringCaptureBox()
+
+ RNPingDavinciCommon.pollDaVinci(
+ davinciId,
+ options: [:],
+ resolver: { payload in
+ if let subscriptionId = payload["subscriptionId"] as? String {
+ capture.set(subscriptionId)
+ }
+ // No event should have been observed yet: the fake collector sleeps before yielding.
+ XCTAssertEqual(observer.events.count, 0)
+ Task { @MainActor in resolveExpectation.fulfill() }
+ },
+ rejecter: { _, _, _ in }
+ )
+
+ wait(for: [resolveExpectation], timeout: 2.0)
+ XCTAssertNotNil(capture.value)
+
+ let eventExpectation = expectation(description: "event received")
+ observer.onEvent = { _ in eventExpectation.fulfill() }
+ wait(for: [eventExpectation], timeout: 2.0)
+ }
+
+ func testPollDaVinciEmitsContinueThenCompleteEventPayloads() {
+ let davinciId = "continue-then-complete"
+ let collector = FakePollingCollector(
+ key: "polling-field",
+ statuses: [.continue(retryCount: 1, maxRetries: 60), .complete(status: "approved")]
+ )
+ RNPingDavinciCommon._setContinueNodeForTesting(
+ davinciId: davinciId,
+ node: makeContinueNode(collectors: [collector])
+ )
+
+ let observer = EventObserver()
+ let twoEventsExpectation = expectation(description: "two events received")
+ observer.onEvent = { _ in
+ if observer.events.count == 2 { twoEventsExpectation.fulfill() }
+ }
+
+ let resolveExpectation = expectation(description: "poll resolve")
+ let capture = StringCaptureBox()
+ RNPingDavinciCommon.pollDaVinci(
+ davinciId,
+ options: [:],
+ resolver: { payload in
+ if let subscriptionId = payload["subscriptionId"] as? String {
+ capture.set(subscriptionId)
+ }
+ Task { @MainActor in resolveExpectation.fulfill() }
+ },
+ rejecter: { _, _, _ in }
+ )
+ wait(for: [resolveExpectation], timeout: 2.0)
+
+ wait(for: [twoEventsExpectation], timeout: 2.0)
+
+ XCTAssertEqual(observer.events[0]["status"] as? String, "continue")
+ XCTAssertEqual(observer.events[0]["retryCount"] as? Int, 1)
+ XCTAssertEqual(observer.events[0]["maxRetries"] as? Int, 60)
+ XCTAssertEqual(observer.events[0]["subscriptionId"] as? String, capture.value)
+ XCTAssertEqual(observer.events[0]["daVinciId"] as? String, davinciId)
+ XCTAssertEqual(observer.events[1]["status"] as? String, "complete")
+ XCTAssertEqual(observer.events[1]["value"] as? String, "approved")
+ }
+
+ func testPollDaVinciEmitsTimedOutEvent() {
+ assertSinglePollEvent(statuses: [.timedOut]) { event in
+ XCTAssertEqual(event["status"] as? String, "timedOut")
+ }
+ }
+
+ func testPollDaVinciEmitsExpiredEvent() {
+ assertSinglePollEvent(statuses: [.expired]) { event in
+ XCTAssertEqual(event["status"] as? String, "expired")
+ }
+ }
+
+ func testPollDaVinciEmitsErrorEventWithMessage() {
+ struct SampleError: LocalizedError {
+ var errorDescription: String? { "network down" }
+ }
+
+ assertSinglePollEvent(statuses: [.error(SampleError())]) { event in
+ XCTAssertEqual(event["status"] as? String, "error")
+ let errorBody = event["error"] as? [String: Any]
+ XCTAssertEqual(errorBody?["message"] as? String, "network down")
+ }
+ }
+
+ // MARK: - pollDaVinci natural completion does not leak the davinciId-keyed job tracking
+
+ func testPollDaVinciRemovesTaskFromDaVinciIdTrackingOnNaturalCompletion() {
+ let davinciId = "natural-completion-no-leak"
+ let collector = FakePollingCollector(
+ key: "polling-field",
+ statuses: [.complete(status: "approved")]
+ )
+ RNPingDavinciCommon._setContinueNodeForTesting(
+ davinciId: davinciId,
+ node: makeContinueNode(collectors: [collector])
+ )
+
+ let observer = EventObserver()
+ let eventExpectation = expectation(description: "event received")
+ observer.onEvent = { _ in eventExpectation.fulfill() }
+
+ let resolveExpectation = expectation(description: "poll resolve")
+ RNPingDavinciCommon.pollDaVinci(
+ davinciId,
+ options: [:],
+ resolver: { _ in
+ Task { @MainActor in resolveExpectation.fulfill() }
+ },
+ rejecter: { _, _, _ in }
+ )
+ wait(for: [resolveExpectation, eventExpectation], timeout: 2.0)
+
+ // Give the natural-completion cleanup a moment to run after the terminal event.
+ let settleExpectation = expectation(description: "settle period elapsed")
+ DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) {
+ settleExpectation.fulfill()
+ }
+ wait(for: [settleExpectation], timeout: 1.0)
+
+ XCTAssertEqual(
+ RNPingDavinciCommon._trackedPollTaskCount(for: davinciId),
+ 0,
+ "A naturally completed poll must be removed from the davinciId-keyed tracking " +
+ "structure — otherwise finished tasks pile up for the client's lifetime"
+ )
+ }
+
+ // MARK: - dispose()/cleanup() poll safety net
+
+ func testDisposeCancelsOutstandingPollJobForDaVinciId() {
+ let davinciId = "dispose-cancels-poll"
+ let collector = FakePollingCollector(
+ key: "polling-field",
+ statuses: (1...50).map { .continue(retryCount: $0, maxRetries: 60) },
+ delayBetweenEmissionsNanoseconds: 30_000_000
+ )
+ RNPingDavinciCommon._setContinueNodeForTesting(
+ davinciId: davinciId,
+ node: makeContinueNode(collectors: [collector])
+ )
+
+ let observer = EventObserver()
+ let firstEventExpectation = expectation(description: "first event received")
+ firstEventExpectation.assertForOverFulfill = false
+ observer.onEvent = { _ in firstEventExpectation.fulfill() }
+
+ let resolveExpectation = expectation(description: "poll resolve")
+ RNPingDavinciCommon.pollDaVinci(
+ davinciId,
+ options: [:],
+ resolver: { _ in
+ Task { @MainActor in resolveExpectation.fulfill() }
+ },
+ rejecter: { _, _, _ in }
+ )
+ wait(for: [resolveExpectation, firstEventExpectation], timeout: 2.0)
+
+ let disposeExpectation = expectation(description: "dispose resolve")
+ RNPingDavinciCommon.dispose(
+ davinciId,
+ resolver: {
+ Task { @MainActor in disposeExpectation.fulfill() }
+ },
+ rejecter: { _, _, _ in }
+ )
+ wait(for: [disposeExpectation], timeout: 1.0)
+
+ let countAtDispose = observer.events.count
+ let settleExpectation = expectation(description: "settle period elapsed")
+ DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) {
+ settleExpectation.fulfill()
+ }
+ wait(for: [settleExpectation], timeout: 1.0)
+
+ XCTAssertEqual(
+ observer.events.count,
+ countAtDispose,
+ "dispose() must cancel outstanding poll tasks for the disposed davinciId"
+ )
+ }
+
+ func testCleanupCancelsAllOutstandingPollTasks() {
+ let davinciId = "cleanup-cancels-poll"
+ let collector = FakePollingCollector(
+ key: "polling-field",
+ statuses: (1...50).map { .continue(retryCount: $0, maxRetries: 60) },
+ delayBetweenEmissionsNanoseconds: 30_000_000
+ )
+ RNPingDavinciCommon._setContinueNodeForTesting(
+ davinciId: davinciId,
+ node: makeContinueNode(collectors: [collector])
+ )
+
+ let observer = EventObserver()
+ let firstEventExpectation = expectation(description: "first event received")
+ firstEventExpectation.assertForOverFulfill = false
+ observer.onEvent = { _ in firstEventExpectation.fulfill() }
+
+ let resolveExpectation = expectation(description: "poll resolve")
+ RNPingDavinciCommon.pollDaVinci(
+ davinciId,
+ options: [:],
+ resolver: { _ in
+ Task { @MainActor in resolveExpectation.fulfill() }
+ },
+ rejecter: { _, _, _ in }
+ )
+ wait(for: [resolveExpectation, firstEventExpectation], timeout: 2.0)
+
+ RNPingDavinciCommon.cleanup()
+
+ let countAtCleanup = observer.events.count
+ let settleExpectation = expectation(description: "settle period elapsed")
+ DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) {
+ settleExpectation.fulfill()
+ }
+ wait(for: [settleExpectation], timeout: 1.0)
+
+ XCTAssertEqual(
+ observer.events.count,
+ countAtCleanup,
+ "cleanup() must cancel all outstanding poll tasks, matching Android's " +
+ "cleanup_cancelsAllOutstandingPollJobs parity test"
+ )
+ }
+
// MARK: - Helpers
private func assertReject(
@@ -557,6 +898,53 @@ final class RNPingDavinciCommonTests: XCTestCase {
XCTAssertEqual(capture.error?.userInfo["type"] as? String, expectedType.rawValue, file: file, line: line)
}
+ /// Runs `pollDaVinci` against a `FakePollingCollector` that yields a single terminal
+ /// status, waits for the corresponding `RNPingDavinci_NativeEmit` notification, and
+ /// hands the decoded event body to `assertions`.
+ private func assertSinglePollEvent(
+ statuses: [PollingStatus],
+ file: StaticString = #filePath,
+ line: UInt = #line,
+ assertions: ([String: Any]) -> Void
+ ) {
+ let davinciId = "single-event-\(UUID().uuidString)"
+ let collector = FakePollingCollector(key: "polling-field", statuses: statuses)
+ RNPingDavinciCommon._setContinueNodeForTesting(
+ davinciId: davinciId,
+ node: makeContinueNode(collectors: [collector])
+ )
+
+ let observer = EventObserver()
+ let eventExpectation = expectation(description: "event received")
+ observer.onEvent = { _ in eventExpectation.fulfill() }
+
+ let resolveExpectation = expectation(description: "poll resolve")
+ RNPingDavinciCommon.pollDaVinci(
+ davinciId,
+ options: [:],
+ resolver: { _ in
+ Task { @MainActor in resolveExpectation.fulfill() }
+ },
+ rejecter: { _, _, _ in }
+ )
+ wait(for: [resolveExpectation, eventExpectation], timeout: 2.0)
+
+ guard let event = observer.events.first else {
+ XCTFail("Expected one polling status event", file: file, line: line)
+ return
+ }
+ assertions(event)
+ }
+
+ private func makeContinueNode(collectors: [any Collector], input: [String: Any] = [:]) -> ContinueNode {
+ return TestContinueNode(
+ context: FlowContext(flowContext: SharedContext()),
+ workflow: Workflow(config: WorkflowConfig()),
+ input: input,
+ actions: collectors
+ )
+ }
+
private func configureDaVinciAndWait(loggerId: String? = nil) -> String {
let resolveExpectation = expectation(description: "configure resolve")
let rejectExpectation = expectation(description: "configure reject not called")
@@ -619,3 +1007,132 @@ private final class TestLoggerHandle: LoggerHandleContract, @unchecked Sendable
self.nativeLogger = LogManager.none
}
}
+
+private final class TestContinueNode: ContinueNode {
+ override func asRequest() -> Request {
+ return workflow.config.httpClient.request()
+ }
+}
+
+/// A bridge-level test double standing in for `PollingCollector` on the active
+/// `ContinueNode`. `PollingCollector` is declared `public` (not `open`) in the iOS
+/// SDK, so it cannot be subclassed cross-module — this conforms to `Collector` (so
+/// it can sit in `node.collectors`) and to `PollableCollector` (the seam
+/// `RNPingDavinciCommon.pollDaVinci` actually depends on), replacing the real
+/// `poll()` network/timing logic entirely rather than exercising it.
+private final class FakePollingCollector: Collector, PollableCollector, @unchecked Sendable {
+ typealias T = String
+
+ let id: String
+ private let statuses: [PollingStatus]
+ private let delayNanoseconds: UInt64
+ private let delayBetweenEmissionsNanoseconds: UInt64
+ private let lock = NSLock()
+ private var _pollWasCalled = false
+
+ var pollWasCalled: Bool {
+ lock.lock()
+ defer { lock.unlock() }
+ return _pollWasCalled
+ }
+
+ init(
+ key: String,
+ statuses: [PollingStatus],
+ delayNanoseconds: UInt64 = 0,
+ delayBetweenEmissionsNanoseconds: UInt64 = 0
+ ) {
+ self.id = key
+ self.statuses = statuses
+ self.delayNanoseconds = delayNanoseconds
+ self.delayBetweenEmissionsNanoseconds = delayBetweenEmissionsNanoseconds
+ }
+
+ init(with json: [String: Any]) {
+ fatalError("Use init(key:statuses:) for tests")
+ }
+
+ func initialize(with value: Any) {}
+
+ func payload() -> String? { nil }
+
+ func poll() -> AsyncStream {
+ lock.lock()
+ _pollWasCalled = true
+ lock.unlock()
+ return AsyncStream { continuation in
+ let producerTask = Task { [
+ statuses,
+ delayNanoseconds,
+ delayBetweenEmissionsNanoseconds
+ ] in
+ if delayNanoseconds > 0 {
+ do {
+ try await Task.sleep(nanoseconds: delayNanoseconds)
+ } catch {
+ continuation.finish()
+ return
+ }
+ }
+ for status in statuses {
+ if Task.isCancelled {
+ continuation.finish()
+ return
+ }
+ continuation.yield(status)
+ if delayBetweenEmissionsNanoseconds > 0 {
+ do {
+ try await Task.sleep(nanoseconds: delayBetweenEmissionsNanoseconds)
+ } catch {
+ continuation.finish()
+ return
+ }
+ }
+ }
+ continuation.finish()
+ }
+ continuation.onTermination = { @Sendable _ in
+ producerTask.cancel()
+ }
+ }
+ }
+}
+
+/// Observes `RNPingDavinci_NativeEmit` notifications posted by `emitPollingStatus`,
+/// decoding each `eventBody` for assertions without going through the Obj-C++ bridge
+/// modules (`RNPingDavinci`/`RNPingDavinciClassic`), which are not exercised by these
+/// Swift-layer unit tests.
+private final class EventObserver: @unchecked Sendable {
+ private let lock = NSLock()
+ private var _events: [[String: Any]] = []
+ var onEvent: (([String: Any]) -> Void)?
+
+ var events: [[String: Any]] {
+ lock.lock()
+ defer { lock.unlock() }
+ return _events
+ }
+
+ init() {
+ NotificationCenter.default.addObserver(
+ forName: .pingDavinciNativeEmit,
+ object: nil,
+ queue: nil
+ ) { [weak self] notification in
+ guard
+ let self,
+ let body = notification.userInfo?["eventBody"] as? [String: Any]
+ else {
+ return
+ }
+ self.lock.lock()
+ self._events.append(body)
+ self.lock.unlock()
+ self.onEvent?(body)
+ }
+ }
+
+ deinit {
+ NotificationCenter.default.removeObserver(self)
+ }
+}
diff --git a/packages/davinci/src/NativeRNPingDavinci.ts b/packages/davinci/src/NativeRNPingDavinci.ts
index 3d52673c2..32324c64a 100644
--- a/packages/davinci/src/NativeRNPingDavinci.ts
+++ b/packages/davinci/src/NativeRNPingDavinci.ts
@@ -138,6 +138,24 @@ export interface Spec extends TurboModule {
* @param davinciId - Native DaVinci instance identifier.
*/
dispose(davinciId: string): Promise;
+
+ /**
+ * Start streaming polling status updates for the active `PollingCollector`.
+ *
+ * @remarks
+ * Resolves immediately with a native-generated `subscriptionId` — it does not
+ * wait for a terminal polling status. Native begins emitting `PollingStatus`
+ * ticks via the global event emitter (event name
+ * `com.pingidentity.rndavinci.PollingStatus`, payload tagged with
+ * `subscriptionId`) only after this promise has resolved, so there is no
+ * window where an event can arrive before the caller has the id to filter on.
+ *
+ * @param davinciId - Native DaVinci instance identifier.
+ * @param options - Optional collector selection (`key`, when more than one
+ * `PollingCollector` is present on the active node).
+ * @returns Serialised `{ subscriptionId }` payload.
+ */
+ pollDaVinci(davinciId: string, options: Object): Promise