From b6f32da10ebc427d29ff363753e86273e22874ca Mon Sep 17 00:00:00 2001 From: Thais Damasceno Date: Thu, 6 Aug 2026 13:49:51 -0700 Subject: [PATCH 1/8] feat(davinci): pooling and QRCode collectors (SDKS-5130) --- PingSampleApp/android/app/build.gradle | 1 - PingSampleApp/ios/Podfile.lock | 158 +++--- PingSampleApp/src/styles/davinciStyles.ts | 16 + .../molecules/DaVinciFieldRenderer.tsx | 6 + .../molecules/DaVinciPollingField.tsx | 100 ++++ .../molecules/DaVinciQrCodeField.tsx | 49 ++ .../ui/davinci/components/molecules/types.ts | 18 + .../organisms/DaVinciClientPanel.tsx | 2 + .../organisms/DaVinciContinueNodePanel.tsx | 23 +- .../hooks/useDaVinciClientPanelController.ts | 37 +- .../integration/davinci-polling.test.ts | 430 +++++++++++++++ PingTestRunner/android/app/build.gradle | 1 - .../PingTestRunnerUITests/BaseTestCase.swift | 11 + .../DaVinciUITests.swift | 129 +++++ .../TestEnvironment.swift | 22 + PingTestRunner/ios/Podfile.lock | 4 +- PingTestRunner/scenarios/DaVinciScenario.tsx | 8 + packages/binding/android/build.gradle | 2 + packages/browser/android/build.gradle | 2 + packages/core/android/build.gradle | 2 + packages/davinci/README.md | 95 +++- packages/davinci/RNPingDavinci.podspec | 2 + packages/davinci/android/build.gradle | 2 + .../rndavinci/RNPingDavinciCommon.kt | 161 +++++- .../rndavinci/RNPingDavinciEvents.kt | 14 + .../rndavinci/error/DaVinciErrorCodes.kt | 2 + .../rndavinci/mapper/DaVinciNodeMapper.kt | 62 +++ .../rndavinci/RNPingDavinciModule.kt | 13 +- .../rndavinci/RNPingDavinciClassicModule.kt | 14 +- .../rndavinci/RNPingDavinciCommonTest.kt | 369 +++++++++++++ .../rndavinci/mapper/DaVinciNodeMapperTest.kt | 191 +++++++ .../davinci/ios/Error/DaVinciErrorCodes.swift | 1 + .../ios/Mapper/DaVinciNodeMapper.swift | 63 +++ packages/davinci/ios/RNPingDavinci.mm | 57 ++ packages/davinci/ios/RNPingDavinciClassic.mm | 57 ++ .../davinci/ios/RNPingDavinciCommon.swift | 198 +++++++ .../ios/RNPingDavinciEventEmitterGate.h | 22 + .../ios/RNPingDavinciEventEmitterGate.mm | 46 ++ .../davinci/ios/RNPingDavinciEvents.swift | 22 + packages/davinci/ios/RNPingDavinciImpl.swift | 19 + .../ios/Tests/DaVinciNodeMapperTests.swift | 166 ++++++ .../ios/Tests/RNPingDavinciCommonTests.swift | 495 ++++++++++++++++++ packages/davinci/src/NativeRNPingDavinci.ts | 21 + .../src/__tests__/collectorHelpers.test.ts | 86 +++ .../src/__tests__/createDaVinciClient.test.ts | 232 ++++++++ .../src/__tests__/davinciMethods.test.ts | 68 +++ .../davinci/src/__tests__/useDavinci.test.tsx | 137 +++++ packages/davinci/src/collectorHelpers.ts | 24 +- packages/davinci/src/davinci.ts | 72 ++- packages/davinci/src/davinciMethods.ts | 43 ++ packages/davinci/src/events.ts | 11 + packages/davinci/src/index.tsx | 4 + packages/davinci/src/types/client.types.ts | 31 +- packages/davinci/src/types/error.types.ts | 1 + packages/davinci/src/types/form.types.ts | 4 + packages/davinci/src/types/node.types.ts | 116 +++- packages/davinci/src/useDavinci.tsx | 28 + packages/device-client/android/build.gradle | 2 + packages/device-id/android/build.gradle | 2 + packages/device-profile/android/build.gradle | 2 + .../external-idp/RNPingExternalIdp.podspec | 1 + packages/external-idp/android/build.gradle | 2 +- packages/fido/android/build.gradle | 2 + packages/journey/android/build.gradle | 2 + packages/logger/android/build.gradle | 2 + packages/oath/android/build.gradle | 2 + packages/oidc/android/build.gradle | 2 + packages/push/android/build.gradle | 2 + packages/storage/android/build.gradle | 2 + 69 files changed, 3883 insertions(+), 110 deletions(-) create mode 100644 PingSampleApp/ui/davinci/components/molecules/DaVinciPollingField.tsx create mode 100644 PingSampleApp/ui/davinci/components/molecules/DaVinciQrCodeField.tsx create mode 100644 PingTestRunner/__tests__/integration/davinci-polling.test.ts create mode 100644 PingTestRunner/ios/PingTestRunnerUITests/DaVinciUITests.swift create mode 100644 packages/davinci/android/src/main/java/com/pingidentity/rndavinci/RNPingDavinciEvents.kt create mode 100644 packages/davinci/ios/RNPingDavinciEventEmitterGate.h create mode 100644 packages/davinci/ios/RNPingDavinciEventEmitterGate.mm create mode 100644 packages/davinci/ios/RNPingDavinciEvents.swift create mode 100644 packages/davinci/src/events.ts diff --git a/PingSampleApp/android/app/build.gradle b/PingSampleApp/android/app/build.gradle index 154476feb..ecd9062be 100644 --- a/PingSampleApp/android/app/build.gradle +++ b/PingSampleApp/android/app/build.gradle @@ -129,7 +129,6 @@ 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..67c45161d 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) @@ -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 @@ -3696,10 +3697,11 @@ SPEC CHECKSUMS: RNSVG: e8fb86f41fccd7b67c4480bb9e179e0ad5785b80 RNVectorIcons: 54df27a2e90ddeb674c7237d76060ec9762d0bc5 SocketRocket: d4aabe649be1e368d1318fdf28a022d714d65748 - VisionCamera: e82024ee7ce0fb6168a4c468ef43c711f4486baa - VisionCameraBarcodeScanner: a65d708fd3672e6d43e94cce39b548465851752b + VisionCamera: 3914b29387fc40c64f7022830cfdc604f94eb966 + VisionCameraBarcodeScanner: 0b6fe2e3c52ca68a0dbd5d25719c7523c68d807f Yoga: daa1e4de4b971b977b23bc842aaa3e135324f1f3 -PODFILE CHECKSUM: 35159028c49678100eea2347153c0cf4cd45998f +PODFILE CHECKSUM: 028568894d00fcf91d539f78055479b61112c6a8 + -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..fb0cbd841 --- /dev/null +++ b/PingTestRunner/__tests__/integration/davinci-polling.test.ts @@ -0,0 +1,430 @@ +/* + * 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('resolves subscriptionId before any tick is delivered, then streams continue ticks', 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); + + expect(mock.pollDaVinci).toHaveBeenCalledWith('davinci-id-mock', {}); + + 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(2); + expect(onStatus).toHaveBeenNthCalledWith(1, { + subscriptionId: 'sub-1', + 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({ + subscriptionId: 'sub-1', + 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({ + subscriptionId: 'sub-1', + 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/PingTestRunnerUITests/BaseTestCase.swift b/PingTestRunner/ios/PingTestRunnerUITests/BaseTestCase.swift index 54b7ad505..008be3963 100644 --- a/PingTestRunner/ios/PingTestRunnerUITests/BaseTestCase.swift +++ b/PingTestRunner/ios/PingTestRunnerUITests/BaseTestCase.swift @@ -163,4 +163,15 @@ class BaseTestCase: XCTestCase { line: line ) } + + /// Skips the test if DaVinci env vars are not set. + /// Mirrors hasDaVinciEnv() from e2e/davinci.test.ts. + func skipIfNoDaVinciEnv(file: StaticString = #file, line: UInt = #line) throws { + try XCTSkipUnless( + env.hasDaVinciEnv, + "Skipping: PINGONE_DISCOVERY_ENDPOINT, PINGONE_CLIENT_ID, PINGONE_USERNAME, PINGONE_PASSWORD not set.", + file: file, + line: line + ) + } } diff --git a/PingTestRunner/ios/PingTestRunnerUITests/DaVinciUITests.swift b/PingTestRunner/ios/PingTestRunnerUITests/DaVinciUITests.swift new file mode 100644 index 000000000..fb84586e8 --- /dev/null +++ b/PingTestRunner/ios/PingTestRunnerUITests/DaVinciUITests.swift @@ -0,0 +1,129 @@ +/* + * 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 XCTest + +/// XCUITest equivalent of e2e/davinci.test.ts (Tier 2 — DaVinci server required). +/// +/// Mirrors the Detox happy-path suite step-for-step against the same live-env +/// gate (PINGONE_DISCOVERY_ENDPOINT, PINGONE_CLIENT_ID, PINGONE_USERNAME, +/// PINGONE_PASSWORD) and the same testIDs (`davinci-field-{key}` scheme). +final class DaVinciUITests: BaseTestCase { + + override func setUp() { + super.setUp() + var extras: [String: String] = [:] + if !env.daVinciDiscoveryEndpoint.isEmpty { + extras["PING_DISCOVERY_ENDPOINT"] = env.daVinciDiscoveryEndpoint + } + if !env.daVinciClientId.isEmpty { + extras["PING_CLIENT_ID"] = env.daVinciClientId + } + if !env.daVinciRedirectUri.isEmpty { + extras["PING_REDIRECT_URI"] = env.daVinciRedirectUri + } + if !env.daVinciAcrValues.isEmpty { + extras["PING_ACR_VALUES"] = env.daVinciAcrValues + } + launchApp(scenario: "davinci", extras: extras) + } + + func testAppLaunchesAndRootIsVisible() { + assertAppReady() + } + + func testStartButtonIsRendered() { + XCTAssertTrue( + elementWithTestID("davinci-start-btn").waitForExistence(timeout: netTimeout), + "Expected davinci-start-btn to be visible" + ) + } + + func testStartRendersLoginForm() throws { + try skipIfNoDaVinciEnv() + elementWithTestID("davinci-start-btn").tapWhenReady() + XCTAssertTrue( + elementWithTestID("davinci-field-username").waitForExistence(timeout: netTimeout), + "Expected davinci-field-username after start()" + ) + XCTAssertTrue( + elementWithTestID("davinci-field-password").waitForExistence(timeout: netTimeout), + "Expected davinci-field-password after start()" + ) + } + + func testNextWithValidCredentialsReturnsSuccessNode() throws { + try skipIfNoDaVinciEnv() + elementWithTestID("davinci-start-btn").tapWhenReady() + waitForElementWithTestID("davinci-field-username", timeout: netTimeout) + elementWithTestID("davinci-field-username").typeTextWhenReady(env.daVinciUsername) + elementWithTestID("davinci-field-password").typeTextWhenReady(env.daVinciPassword) + elementWithTestID("davinci-submit-btn").tapWhenReady() + XCTAssertTrue( + elementWithTestID("davinci-success").waitForExistence(timeout: netTimeout), + "Expected davinci-success after valid credentials" + ) + } + + func testAccessTokenIsAvailable() throws { + try skipIfNoDaVinciEnv() + loginWithValidCredentials() + let token = textContentOfElement(withTestID: "davinci-token-result", timeout: netTimeout) + XCTAssertFalse(token.isEmpty, "Expected a non-empty access token after login") + XCTAssertNotEqual(token, "null", "Access token must not be the string 'null'") + XCTAssertNotEqual(token, "undefined", "Access token must not be the string 'undefined'") + } + + func testUserinfoReturnsPayloadContainingSub() throws { + try skipIfNoDaVinciEnv() + loginWithValidCredentials() + elementWithTestID("davinci-userinfo-btn").tapWhenReady() + let userinfo = textContentOfElement(withTestID: "davinci-userinfo-result", timeout: netTimeout) + XCTAssertTrue(userinfo.contains("\"sub\""), "Expected userinfo payload to contain 'sub', got '\(userinfo)'") + } + + func testRefreshObtainsNewToken() throws { + try skipIfNoDaVinciEnv() + loginWithValidCredentials() + elementWithTestID("davinci-refresh-btn").tapWhenReady() + XCTAssertTrue( + elementWithTestID("davinci-refreshed").waitForExistence(timeout: netTimeout), + "Expected davinci-refreshed after refresh()" + ) + } + + func testRevokeInvalidatesSession() throws { + try skipIfNoDaVinciEnv() + loginWithValidCredentials() + elementWithTestID("davinci-revoke-btn").tapWhenReady() + XCTAssertTrue( + elementWithTestID("davinci-revoked").waitForExistence(timeout: netTimeout), + "Expected davinci-revoked after revoke()" + ) + } + + func testLogoutClearsSession() throws { + try skipIfNoDaVinciEnv() + loginWithValidCredentials() + elementWithTestID("davinci-logout-btn").tapWhenReady() + XCTAssertTrue( + elementWithTestID("davinci-logged-out").waitForExistence(timeout: netTimeout), + "Expected davinci-logged-out after logoutUser()" + ) + } + + // MARK: - Helpers + + private func loginWithValidCredentials() { + elementWithTestID("davinci-start-btn").tapWhenReady() + waitForElementWithTestID("davinci-field-username", timeout: netTimeout) + elementWithTestID("davinci-field-username").typeTextWhenReady(env.daVinciUsername) + elementWithTestID("davinci-field-password").typeTextWhenReady(env.daVinciPassword) + elementWithTestID("davinci-submit-btn").tapWhenReady() + waitForElementWithTestID("davinci-success", timeout: netTimeout) + } +} diff --git a/PingTestRunner/ios/PingTestRunnerUITests/TestEnvironment.swift b/PingTestRunner/ios/PingTestRunnerUITests/TestEnvironment.swift index c978e8c76..7500f2219 100644 --- a/PingTestRunner/ios/PingTestRunnerUITests/TestEnvironment.swift +++ b/PingTestRunner/ios/PingTestRunnerUITests/TestEnvironment.swift @@ -27,6 +27,14 @@ struct TestEnvironment { let redirectUri: String let callbackTreesEnabled: Bool + // DaVinci — mirrors DAVINCI_ENV in e2e/setup.ts (PINGONE_* env vars). + let daVinciDiscoveryEndpoint: String + let daVinciClientId: String + let daVinciRedirectUri: String + let daVinciUsername: String + let daVinciPassword: String + let daVinciAcrValues: String + private init() { let e = ProcessInfo.processInfo.environment serverUrl = e["PING_SERVER_URL"] ?? "" @@ -39,6 +47,13 @@ struct TestEnvironment { clientId = e["PING_CLIENT_ID"] ?? "" redirectUri = e["PING_REDIRECT_URI"] ?? "org.forgerock.demo://oauth2redirect" callbackTreesEnabled = e["PING_CALLBACK_TREES_ENABLED"] != "false" + + daVinciDiscoveryEndpoint = e["PINGONE_DISCOVERY_ENDPOINT"] ?? "" + daVinciClientId = e["PINGONE_CLIENT_ID"] ?? "" + daVinciRedirectUri = e["PINGONE_REDIRECT_URI"] ?? "org.forgerock.demo://oauth2redirect" + daVinciUsername = e["PINGONE_USERNAME"] ?? "" + daVinciPassword = e["PINGONE_PASSWORD"] ?? "" + daVinciAcrValues = e["PINGONE_ACR_VALUES"] ?? "" } /// True when all vars required for Journey Tier 2 tests are set. @@ -50,4 +65,11 @@ struct TestEnvironment { var hasLiveAuthEnv: Bool { hasJourneyEnv && !discoveryEndpoint.isEmpty && !clientId.isEmpty } + + /// True when all vars required for the DaVinci live E2E flow are set. + /// Mirrors hasDaVinciEnv() in e2e/davinci.test.ts. + var hasDaVinciEnv: Bool { + !daVinciDiscoveryEndpoint.isEmpty && !daVinciClientId.isEmpty + && !daVinciUsername.isEmpty && !daVinciPassword.isEmpty + } } diff --git a/PingTestRunner/ios/Podfile.lock b/PingTestRunner/ios/Podfile.lock index fb77b2f84..c0ace14ab 100644 --- a/PingTestRunner/ios/Podfile.lock +++ b/PingTestRunner/ios/Podfile.lock @@ -2404,6 +2404,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) @@ -2440,6 +2441,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) @@ -3588,7 +3590,7 @@ SPEC CHECKSUMS: RNPingBinding: 1731d5254b7d026df05ee056ab7802f4888537e9 RNPingBrowser: b6afbf0fa46c9ae0a10daeb46927f05eca5c7c6d RNPingCore: 3926e39f9ab60d83fb4d58225303864ca0d8171b - RNPingDavinci: 81122638cb43d20c7e24e66eea5bd8700fd48aae + RNPingDavinci: 605780d605c87d00216376bb2b3f4f5ee72817e8 RNPingDeviceClient: 0ff89a604e97d1168eff3aba4b1d889139619bd7 RNPingDeviceId: 6eb3e9fc68a78e1aee87652e025f6e1ec6d5d67e RNPingDeviceProfile: 3086f14fc5024196a64290afbbf7917432dd5900 diff --git a/PingTestRunner/scenarios/DaVinciScenario.tsx b/PingTestRunner/scenarios/DaVinciScenario.tsx index 9e7d0066f..d1493bfbf 100644 --- a/PingTestRunner/scenarios/DaVinciScenario.tsx +++ b/PingTestRunner/scenarios/DaVinciScenario.tsx @@ -505,6 +505,14 @@ function CollectorField({ ); } + if (collector.type === 'QR_CODE') { + return {collector.fallbackText}; + } + + if (collector.type === 'POLLING') { + return Polling…; + } + if (collector.type === 'DEVICE_AUTHENTICATION') { return ( diff --git a/packages/binding/android/build.gradle b/packages/binding/android/build.gradle index 5d85ce84f..1bbcd2817 100644 --- a/packages/binding/android/build.gradle +++ b/packages/binding/android/build.gradle @@ -4,6 +4,8 @@ * This software may be modified and distributed under the terms * of the MIT license. See the LICENSE file for details. */ + +// TEMP(SDKS-5130): bumped to SDK 2.1.0 / JVM 21 ahead of the SDKS-5245 bump PR; revert this file's version/JVM changes when it merges to main. buildscript { ext.RNPingBinding = [ kotlinVersion: "2.2.10", diff --git a/packages/browser/android/build.gradle b/packages/browser/android/build.gradle index db4644fd2..72e2f5044 100644 --- a/packages/browser/android/build.gradle +++ b/packages/browser/android/build.gradle @@ -5,6 +5,8 @@ * of the MIT license. See the LICENSE file for details. */ +// TEMP(SDKS-5130): bumped to SDK 2.1.0 / JVM 21 ahead of the SDKS-5245 bump PR; revert this file's version/JVM changes when it merges to main. + buildscript { ext.getExtOrDefault = {name -> return rootProject.ext.has(name) ? rootProject.ext.get(name) : project.properties['RNPingBrowser_' + name] diff --git a/packages/core/android/build.gradle b/packages/core/android/build.gradle index 6f8c13142..3d31328b1 100644 --- a/packages/core/android/build.gradle +++ b/packages/core/android/build.gradle @@ -5,6 +5,8 @@ * of the MIT license. See the LICENSE file for details. */ +// TEMP(SDKS-5130): bumped to SDK 2.1.0 / JVM 21 ahead of the SDKS-5245 bump PR; revert this file's version/JVM changes when it merges to main. + buildscript { ext.getExtOrDefault = {name -> return rootProject.ext.has(name) ? rootProject.ext.get(name) : project.properties['RNPingCore_' + name] diff --git a/packages/davinci/README.md b/packages/davinci/README.md index 22f9ed43d..21cdbec72 100644 --- a/packages/davinci/README.md +++ b/packages/davinci/README.md @@ -278,30 +278,86 @@ 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 (`executionMode: 'integration_required'`). +### Polling and QR code flows + +`POLLING` and `QR_CODE` collectors support out-of-band authentication (push approval, QR +scan, email verification) where the flow waits for user action on another device or +channel. Both are display/output-only — neither participates in form submission. + +A `ContinueNode` commonly carries a `QR_CODE` collector (rendered for the user to scan) +alongside a `POLLING` collector (which reports when the out-of-band action completes): + +```ts +const node = await client.start(); + +if (node.type === 'ContinueNode') { + const qr = node.collectors.find((c) => c.type === 'QR_CODE'); + if (qr) { + // Render `qr.content` (a full data URI, e.g. "data:image/png;base64,...") + // as an or similar. + } + + const unsubscribe = await client.pollStatus((status) => { + switch (status.status) { + case 'continue': + console.log( + `Waiting… attempt ${status.retryCount}/${status.maxRetries}`, + ); + break; + case 'complete': + client.next({ collectors: [] }); // advance explicitly + break; + case 'timedOut': + case 'expired': + case 'error': + // Surface the failure to the user; call unsubscribe() if abandoning the poll. + break; + } + }); + + // Later, e.g. on screen unmount: + // unsubscribe(); +} +``` + +- `client.pollStatus(onStatus, options?)` resolves the active `PollingCollector` on the + current node (pass `options.key` to disambiguate when a node has more than one), + starts streaming native polling ticks, and returns an `unsubscribe` function. +- `pollStatus` does **not** auto-advance the flow — call `next()` explicitly on any + terminal status (`complete`, `timedOut`, `expired`, `error`) to progress past it. +- Calling the returned `unsubscribe()` stops **local event delivery only**. Neither + native SDK exposes a primitive to cancel an in-flight poll, so the native poll keeps + running to completion (bounded by `pollRetries` × `pollInterval`) even after + unsubscribing. +- `pollInterval` and `pollRetries` on the `PollingCollector` payload are normalized to + `number` on both platforms. + ### Unsupported fields When the native SDK cannot instantiate a collector from the server payload, the bridge surfaces it in `ContinueNode.unsupportedFields`: @@ -340,6 +396,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..5e8fb106c 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'] + # TEMP(SDKS-5130): bumped to 2.1.0 to consume new collectors ahead of the SDKS-5245 bump PR; revert when it merges to main. 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..75da92397 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,17 @@ 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.serialization.json.JsonObject +import java.lang.ref.WeakReference +import java.util.UUID import java.util.concurrent.ConcurrentHashMap /** @@ -58,6 +68,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 +104,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 +140,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 +175,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 +518,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.getOrPut(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] before emitting, matching the convention in + * `RNPingPushCommon`/`RNPingBindingCommon` — [scope] runs on [Dispatchers.Default] for the + * poll job itself. + */ + private 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) + } + } + scope.launch(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..77d473d28 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,7 @@ internal object DaVinciNodeMapper { private const val TAG = "DaVinciNodeMapper" internal const val SOCIAL_LOGIN_BUTTON = "SOCIAL_LOGIN_BUTTON" + private const val QR_CODE = "QR_CODE" private val json = Json { ignoreUnknownKeys = true } @@ -146,6 +149,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 +174,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 +198,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 +216,7 @@ internal object DaVinciNodeMapper { is PhoneNumberCollector -> mapPhoneNumberCollector(collector) is DeviceRegistrationCollector -> mapDeviceRegistrationCollector(collector) is DeviceAuthenticationCollector -> mapDeviceAuthenticationCollector(collector) + is PollingCollector -> mapPollingCollector(collector) else -> { logWarning( logger, @@ -395,6 +414,49 @@ 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. + * + * 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): Map { + val map = baseCollectorMap(collector) + map["pollInterval"] = collector.pollInterval.toIntOrNull() ?: collector.pollInterval + map["pollRetries"] = collector.pollRetries.toIntOrNull() ?: collector.pollRetries + 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..0f442a94a 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,193 @@ 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 mapPollingCollectorFallsBackToRawStringWhenPollIntervalIsNonNumeric() { + // `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] + + assertEquals("not-a-number", 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..b094fb30b 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,50 @@ #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]; +} + +/** + * 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 +81,7 @@ - (RNPingDavinciImpl *)swiftImpl */ - (void)invalidate { + [[NSNotificationCenter defaultCenter] removeObserver:self]; [[self swiftImpl] invalidate]; } @@ -198,6 +246,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..bfad41235 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,41 @@ + (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]; +} + +/** + * 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 +199,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..323765f56 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,76 @@ 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 taskRef = Ref>() + let task = Task { + for await status in collector.poll() { + if Task.isCancelled { break } + emitPollingStatus(davinciId: davinciId, subscriptionId: subscriptionId, status: status) + } + if let completedTask = taskRef.value { + pollJobStore.remove(davinciId: davinciId, task: completedTask) + } + } + taskRef.value = task + pollJobStore.register(davinciId: davinciId, task: task) + + promise.resolve(["subscriptionId": subscriptionId] as NSDictionary) + } + // MARK: - Helpers /// Resolves a DaVinci instance from the shared core registry. @@ -497,6 +606,95 @@ 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 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: Set>]() + + /// Registers a poll task before the promise resolves. + func register(davinciId: String, task: Task) { + lock.lock() + tasksByDaVinciId[davinciId, default: []].insert(task) + lock.unlock() + } + + /// Removes a completed poll task's bookkeeping without cancelling it (it already finished). + func remove(davinciId: String, task: Task) { + lock.lock() + tasksByDaVinciId[davinciId]?.remove(task) + if tasksByDaVinciId[davinciId]?.isEmpty == true { + tasksByDaVinciId.removeValue(forKey: davinciId) + } + lock.unlock() + } + + /// Cancels every tracked poll task for `davinciId`. + func cancelAll(for davinciId: String) { + lock.lock() + let tasks = tasksByDaVinciId.removeValue(forKey: davinciId) ?? [] + 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 } + tasksByDaVinciId.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..7bdd74c2a --- /dev/null +++ b/packages/davinci/ios/RNPingDavinciEventEmitterGate.h @@ -0,0 +1,22 @@ +/* + * 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` exactly once for the process lifetime; subsequent callers get `NO`. +FOUNDATION_EXPORT BOOL RNPingDavinciClaimEventEmitterOwnership(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..fbfa6e5fa --- /dev/null +++ b/packages/davinci/ios/RNPingDavinciEventEmitterGate.mm @@ -0,0 +1,46 @@ +/* + * 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 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; +} + +BOOL RNPingDavinciClaimEventEmitterOwnership(NSString *ownerId) +{ + (void)ownerId; + NSLock *lock = RNPingDavinciEventEmitterGateLock(); + [lock lock]; + static BOOL hasEventEmitterOwner = NO; + BOOL didClaim = NO; + if (!hasEventEmitterOwner) { + hasEventEmitterOwner = YES; + didClaim = YES; + } + [lock unlock]; + return didClaim; +} diff --git a/packages/davinci/ios/RNPingDavinciEvents.swift b/packages/davinci/ios/RNPingDavinciEvents.swift new file mode 100644 index 000000000..e2adabe1f --- /dev/null +++ b/packages/davinci/ios/RNPingDavinciEvents.swift @@ -0,0 +1,22 @@ +/* + * 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 { + 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 { + 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..192bf1498 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,342 @@ 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") + 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") + 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 +896,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 +1005,112 @@ 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 + Task { + if delayNanoseconds > 0 { + try? await Task.sleep(nanoseconds: delayNanoseconds) + } + for status in statuses { + if Task.isCancelled { break } + continuation.yield(status) + if delayBetweenEmissionsNanoseconds > 0 { + try? await Task.sleep(nanoseconds: delayBetweenEmissionsNanoseconds) + } + } + continuation.finish() + } + } + } +} + +/// 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; } let _nativeModule: Spec | null = null; @@ -212,6 +230,9 @@ const NativeRNPingDavinci: Spec = { dispose(davinciId) { return getNativeModule().dispose(davinciId); }, + pollDaVinci(davinciId, options) { + return getNativeModule().pollDaVinci(davinciId, options); + }, }; export default NativeRNPingDavinci; diff --git a/packages/davinci/src/__tests__/collectorHelpers.test.ts b/packages/davinci/src/__tests__/collectorHelpers.test.ts index e5b73f6a3..7bd358519 100644 --- a/packages/davinci/src/__tests__/collectorHelpers.test.ts +++ b/packages/davinci/src/__tests__/collectorHelpers.test.ts @@ -72,6 +72,14 @@ describe('resolveExecutionMode', () => { expect(resolveExecutionMode('')).toBe('unsupported'); }); + it('returns output_only for POLLING', () => { + expect(resolveExecutionMode('POLLING')).toBe('output_only'); + }); + + it('returns output_only for QR_CODE', () => { + expect(resolveExecutionMode('QR_CODE')).toBe('output_only'); + }); + it('never returns integration_required for any base-registry type', () => { const allKnownTypes = [...manualTypes, 'LABEL', ...immediateTypes]; allKnownTypes.forEach((type) => { @@ -141,6 +149,14 @@ describe('resolveFieldKind', () => { expect(resolveFieldKind('UNKNOWN_TYPE')).toBe('unknown'); expect(resolveFieldKind('')).toBe('unknown'); }); + + it('returns polling for POLLING', () => { + expect(resolveFieldKind('POLLING')).toBe('polling'); + }); + + it('returns qrCode for QR_CODE', () => { + expect(resolveFieldKind('QR_CODE')).toBe('qrCode'); + }); }); describe('normalizeCollectors', () => { @@ -293,6 +309,46 @@ describe('normalizeCollectors', () => { }); }); +describe('normalizeCollectors — POLLING and QR_CODE', () => { + const pollingCollector: DaVinciCollector = { + key: 'poll-1', + type: 'POLLING', + label: 'Waiting for approval', + required: false, + pollInterval: 2000, + pollRetries: 60, + pollChallengeStatus: false, + challenge: '', + } as DaVinciCollector; + + const qrCodeCollector: DaVinciCollector = { + key: 'qr-1', + type: 'QR_CODE', + content: 'data:image/png;base64,abc123', + fallbackText: 'Scan this code', + } as DaVinciCollector; + + it('classifies POLLING as output_only with kind polling and no required input', () => { + const [normalized] = normalizeCollectors([pollingCollector]); + expect(normalized).toMatchObject({ + key: 'poll-1', + executionMode: 'output_only', + requiresUserInput: false, + kind: 'polling', + }); + }); + + it('classifies QR_CODE as output_only with kind qrCode and no required input', () => { + const [normalized] = normalizeCollectors([qrCodeCollector]); + expect(normalized).toMatchObject({ + key: 'qr-1', + executionMode: 'output_only', + requiresUserInput: false, + kind: 'qrCode', + }); + }); +}); + describe('buildNextInput — no active node', () => { it('returns NO_ACTIVE_CONTINUE_NODE when node is null', () => { const result = buildNextInput(null, {}); @@ -606,6 +662,36 @@ describe('buildNextInput — excluded modes', () => { ]); }); + it('excludes POLLING and QR_CODE collectors from payload entirely', () => { + const node = makeNode([ + { + key: 'poll-1', + type: 'POLLING', + label: 'Waiting', + required: false, + pollInterval: 2000, + pollRetries: 60, + pollChallengeStatus: false, + challenge: '', + } as DaVinciCollector, + { + key: 'qr-1', + type: 'QR_CODE', + content: 'data:image/png;base64,abc', + fallbackText: 'fallback', + } as DaVinciCollector, + baseField('username', 'TEXT'), + ]); + + const result = buildNextInput(node, { username: 'alice' }); + + expect(result.input.collectors).toEqual([ + { key: 'username', value: 'alice' }, + ]); + expect(result.canSubmit).toBe(true); + expect(result.issues).toEqual([]); + }); + it('excludes integration_required collectors from payload and blocks submit when unhandled', () => { const node = makeNode([baseField('idp', 'SOCIAL_LOGIN_BUTTON')]); const result = buildNextInput(node, {}); diff --git a/packages/davinci/src/__tests__/createDaVinciClient.test.ts b/packages/davinci/src/__tests__/createDaVinciClient.test.ts index e3e096106..cb5d5af8b 100644 --- a/packages/davinci/src/__tests__/createDaVinciClient.test.ts +++ b/packages/davinci/src/__tests__/createDaVinciClient.test.ts @@ -15,6 +15,7 @@ type NativeDaVinciModuleMock = { userinfo: jest.Mock; logout: jest.Mock; dispose: jest.Mock; + pollDaVinci: jest.Mock; }; const createNativeMock = ( @@ -32,6 +33,7 @@ const createNativeMock = ( userinfo: jest.fn(async () => ({ sub: 'user-1' })), logout: jest.fn(async () => undefined), dispose: jest.fn(async () => undefined), + pollDaVinci: jest.fn(async () => ({ subscriptionId: 'sub-1' })), ...overrides, }); @@ -795,3 +797,233 @@ describe('createDaVinciClient — error propagation', () => { ); }); }); + +describe('createDaVinciClient — pollStatus', () => { + it('resolves the subscriptionId and subscribes before any event can be missed', async () => { + const native = createNativeMock(); + const { createDaVinciClient } = loadModule(native); + // eslint-disable-next-line @typescript-eslint/no-require-imports + const { DeviceEventEmitter } = require('react-native'); + // eslint-disable-next-line @typescript-eslint/no-require-imports + const { DaVinciEvents } = require('../events'); + + const client = createDaVinciClient(VALID_CONFIG); + const onStatus = jest.fn(); + + await client.pollStatus(onStatus); + + expect(native.pollDaVinci).toHaveBeenCalledWith('davinci-id-1', {}); + + DeviceEventEmitter.emit(DaVinciEvents.POLLING_STATUS, { + subscriptionId: 'sub-1', + status: 'continue', + retryCount: 1, + maxRetries: 10, + }); + + expect(onStatus).toHaveBeenCalledWith({ + subscriptionId: 'sub-1', + status: 'continue', + retryCount: 1, + maxRetries: 10, + }); + }); + + it('forwards the key option to the bridge', async () => { + const native = createNativeMock(); + const { createDaVinciClient } = loadModule(native); + const client = createDaVinciClient(VALID_CONFIG); + + await client.pollStatus(jest.fn(), { key: 'poll-key' }); + + expect(native.pollDaVinci).toHaveBeenCalledWith('davinci-id-1', { + key: 'poll-key', + }); + }); + + it('ignores events tagged with a different subscriptionId', async () => { + const native = createNativeMock(); + const { createDaVinciClient } = loadModule(native); + // eslint-disable-next-line @typescript-eslint/no-require-imports + const { DeviceEventEmitter } = require('react-native'); + // eslint-disable-next-line @typescript-eslint/no-require-imports + const { DaVinciEvents } = require('../events'); + + const client = createDaVinciClient(VALID_CONFIG); + const onStatus = jest.fn(); + await client.pollStatus(onStatus); + + DeviceEventEmitter.emit(DaVinciEvents.POLLING_STATUS, { + subscriptionId: 'some-other-subscription', + status: 'continue', + retryCount: 1, + maxRetries: 10, + }); + + expect(onStatus).not.toHaveBeenCalled(); + }); + + it('removes the listener after a terminal status and stops forwarding further ticks', async () => { + const native = createNativeMock(); + const { createDaVinciClient } = loadModule(native); + // eslint-disable-next-line @typescript-eslint/no-require-imports + const { DeviceEventEmitter } = require('react-native'); + // eslint-disable-next-line @typescript-eslint/no-require-imports + const { DaVinciEvents } = require('../events'); + + const client = createDaVinciClient(VALID_CONFIG); + const onStatus = jest.fn(); + await client.pollStatus(onStatus); + + DeviceEventEmitter.emit(DaVinciEvents.POLLING_STATUS, { + subscriptionId: 'sub-1', + status: 'complete', + value: 'success', + }); + DeviceEventEmitter.emit(DaVinciEvents.POLLING_STATUS, { + subscriptionId: 'sub-1', + status: 'continue', + retryCount: 2, + maxRetries: 10, + }); + + expect(onStatus).toHaveBeenCalledTimes(1); + expect(onStatus).toHaveBeenCalledWith({ + subscriptionId: 'sub-1', + status: 'complete', + value: 'success', + }); + }); + + it.each(['complete', 'timedOut', 'expired', 'error'])( + 'removes the listener on terminal status %s', + async (status) => { + const native = createNativeMock(); + const { createDaVinciClient } = loadModule(native); + // eslint-disable-next-line @typescript-eslint/no-require-imports + const { DeviceEventEmitter } = require('react-native'); + // eslint-disable-next-line @typescript-eslint/no-require-imports + const { DaVinciEvents } = require('../events'); + + const client = createDaVinciClient(VALID_CONFIG); + const onStatus = jest.fn(); + await client.pollStatus(onStatus); + + DeviceEventEmitter.emit(DaVinciEvents.POLLING_STATUS, { + subscriptionId: 'sub-1', + status, + }); + DeviceEventEmitter.emit(DaVinciEvents.POLLING_STATUS, { + subscriptionId: 'sub-1', + status: 'continue', + retryCount: 1, + maxRetries: 10, + }); + + expect(onStatus).toHaveBeenCalledTimes(1); + }, + ); + + it('does not call next() internally on any status', async () => { + const native = createNativeMock(); + const { createDaVinciClient } = loadModule(native); + // eslint-disable-next-line @typescript-eslint/no-require-imports + const { DeviceEventEmitter } = require('react-native'); + // eslint-disable-next-line @typescript-eslint/no-require-imports + const { DaVinciEvents } = require('../events'); + + const client = createDaVinciClient(VALID_CONFIG); + await client.pollStatus(jest.fn()); + + DeviceEventEmitter.emit(DaVinciEvents.POLLING_STATUS, { + subscriptionId: 'sub-1', + status: 'complete', + value: 'success', + }); + + expect(native.next).not.toHaveBeenCalled(); + }); + + it('unsubscribe stops onStatus from firing on subsequent events (local listener removal only)', async () => { + const native = createNativeMock(); + const { createDaVinciClient } = loadModule(native); + // eslint-disable-next-line @typescript-eslint/no-require-imports + const { DeviceEventEmitter } = require('react-native'); + // eslint-disable-next-line @typescript-eslint/no-require-imports + const { DaVinciEvents } = require('../events'); + + const client = createDaVinciClient(VALID_CONFIG); + const onStatus = jest.fn(); + const unsubscribe = await client.pollStatus(onStatus); + + unsubscribe(); + + DeviceEventEmitter.emit(DaVinciEvents.POLLING_STATUS, { + subscriptionId: 'sub-1', + status: 'continue', + retryCount: 1, + maxRetries: 10, + }); + + expect(onStatus).not.toHaveBeenCalled(); + }); + + // Backpressure characterization: pollStatus has no batching/throttling + // layer between the native DeviceEventEmitter and the consumer's onStatus + // callback, and pollInterval/pollRetries are server-controlled, so a + // misconfigured DaVinci flow can emit ticks far faster than a + // UI can render them. This test fires a synchronous burst and asserts a + // strict 1:1 delivery — every tick reaches onStatus, none are coalesced or + // dropped — to prove the lack of backpressure at the SDK boundary. Because + // a typical consumer calls setState inside onStatus, this call count is a + // direct proxy for its re-render count under burst load; the fix (if any) + // is the consumer's or the SDK's responsibility to throttle, not this test's. + it('delivers every tick of a burst synchronously with no coalescing or drops', async () => { + const native = createNativeMock(); + const { createDaVinciClient } = loadModule(native); + // eslint-disable-next-line @typescript-eslint/no-require-imports + const { DeviceEventEmitter } = require('react-native'); + // eslint-disable-next-line @typescript-eslint/no-require-imports + const { DaVinciEvents } = require('../events'); + + const client = createDaVinciClient(VALID_CONFIG); + const onStatus = jest.fn(); + await client.pollStatus(onStatus); + + const TICK_COUNT = 2000; + for (let i = 1; i <= TICK_COUNT; i++) { + DeviceEventEmitter.emit(DaVinciEvents.POLLING_STATUS, { + subscriptionId: 'sub-1', + status: 'continue', + retryCount: i, + maxRetries: TICK_COUNT, + }); + } + + expect(onStatus).toHaveBeenCalledTimes(TICK_COUNT); + onStatus.mock.calls.forEach(([status], index) => { + expect(status).toMatchObject({ retryCount: index + 1 }); + }); + }); + + it('propagates native pollDaVinci rejection as DaVinciError', async () => { + const native = createNativeMock({ + pollDaVinci: jest.fn(async () => { + throw { + type: 'state_error', + error: 'DAVINCI_POLL_ERROR', + message: 'no active PollingCollector', + }; + }), + }); + const { createDaVinciClient } = loadModule(native); + + assertDaVinciError( + await createDaVinciClient(VALID_CONFIG) + .pollStatus(jest.fn()) + .catch((e: unknown) => e), + 'DAVINCI_POLL_ERROR', + 'no active PollingCollector', + ); + }); +}); diff --git a/packages/davinci/src/__tests__/davinciMethods.test.ts b/packages/davinci/src/__tests__/davinciMethods.test.ts index 471f4f6b1..c5c733b0a 100644 --- a/packages/davinci/src/__tests__/davinciMethods.test.ts +++ b/packages/davinci/src/__tests__/davinciMethods.test.ts @@ -15,6 +15,7 @@ type NativeDaVinciModuleMock = { userinfo: jest.Mock; logout: jest.Mock; dispose: jest.Mock; + pollDaVinci: jest.Mock; }; const createNativeMock = ( @@ -29,6 +30,7 @@ const createNativeMock = ( userinfo: jest.fn(async () => ({ sub: 'user-1' })), logout: jest.fn(async () => undefined), dispose: jest.fn(async () => undefined), + pollDaVinci: jest.fn(async () => ({ subscriptionId: 'sub-1' })), ...overrides, }); @@ -354,4 +356,70 @@ describe('davinciMethods', () => { assertDaVinciError(err, 'DAVINCI_DISPOSE_ERROR', 'dispose failed'); }); }); + + describe('pollDaVinci', () => { + it('calls native pollDaVinci and returns the subscriptionId', async () => { + const native = createNativeMock(); + const { pollDaVinci } = loadMethods(native); + + const subscriptionId = await pollDaVinci('davinci-id-1'); + + expect(subscriptionId).toBe('sub-1'); + expect(native.pollDaVinci).toHaveBeenCalledWith('davinci-id-1', {}); + }); + + it('forwards the key option to native pollDaVinci', async () => { + const native = createNativeMock(); + const { pollDaVinci } = loadMethods(native); + + await pollDaVinci('davinci-id-1', { key: 'poll-key' }); + + expect(native.pollDaVinci).toHaveBeenCalledWith('davinci-id-1', { + key: 'poll-key', + }); + }); + + it('throws DAVINCI_POLL_ERROR when native resolves a malformed payload', async () => { + const native = createNativeMock({ + pollDaVinci: jest.fn(async () => ({})), + }); + const { pollDaVinci } = loadMethods(native); + + const err = await pollDaVinci('davinci-id-1').catch((e: unknown) => e); + + assertDaVinciError(err, 'DAVINCI_POLL_ERROR'); + }); + + it('throws DAVINCI_POLL_ERROR when native resolves null', async () => { + const native = createNativeMock({ + pollDaVinci: jest.fn(async () => null), + }); + const { pollDaVinci } = loadMethods(native); + + const err = await pollDaVinci('davinci-id-1').catch((e: unknown) => e); + + assertDaVinciError(err, 'DAVINCI_POLL_ERROR'); + }); + + it('coerces native rejection to DaVinciError', async () => { + const native = createNativeMock({ + pollDaVinci: jest.fn(async () => { + throw { + type: 'state_error', + error: 'DAVINCI_POLL_ERROR', + message: 'no active PollingCollector', + }; + }), + }); + const { pollDaVinci } = loadMethods(native); + + const err = await pollDaVinci('davinci-id-1').catch((e: unknown) => e); + + assertDaVinciError( + err, + 'DAVINCI_POLL_ERROR', + 'no active PollingCollector', + ); + }); + }); }); diff --git a/packages/davinci/src/__tests__/useDavinci.test.tsx b/packages/davinci/src/__tests__/useDavinci.test.tsx index 9fdf299ff..309c3c244 100644 --- a/packages/davinci/src/__tests__/useDavinci.test.tsx +++ b/packages/davinci/src/__tests__/useDavinci.test.tsx @@ -12,6 +12,7 @@ import { useDaVinci } from '../useDavinci'; type DaVinciClient = import('../types').DaVinciClient; type DaVinciNode = import('../types').DaVinciNode; type DaVinciHookResult = import('../useDavinci').DaVinciHookResult; +type PollingStatus = import('../types').PollingStatus; type Harness = { client: DaVinciClient; @@ -94,6 +95,8 @@ function createDaVinciClientMock( userinfo: jest.fn(async () => null), logoutUser: jest.fn(async () => undefined), dispose: jest.fn(async () => undefined), + getId: jest.fn(async () => 'davinci-id'), + pollStatus: jest.fn(async () => jest.fn()), ...overrides, }; } @@ -309,4 +312,138 @@ describe('useDaVinci', () => { expect(result.error?.name).toBe('DaVinciError'); expect((result.error as { code: string }).code).toBe('DAVINCI_START_ERROR'); }); + + it('user() delegates to the client and returns its result', async () => { + const session = { value: 'tok' }; + const client = createDaVinciClientMock({ + user: jest.fn(async () => session), + }); + let latest: DaVinciHookResult | null = null; + + render( + { + latest = r; + }} + />, + ); + + let result: unknown; + await act(async () => { + result = await requireLatest(latest).user(); + }); + + expect(result).toBe(session); + expect(client.user).toHaveBeenCalledTimes(1); + }); + + it('refresh() delegates to the client and returns its result', async () => { + const session = { value: 'refreshed' }; + const client = createDaVinciClientMock({ + refresh: jest.fn(async () => session), + }); + let latest: DaVinciHookResult | null = null; + + render( + { + latest = r; + }} + />, + ); + + let result: unknown; + await act(async () => { + result = await requireLatest(latest).refresh(); + }); + + expect(result).toBe(session); + expect(client.refresh).toHaveBeenCalledTimes(1); + }); + + it('userinfo() delegates to the client and returns its result', async () => { + const claims = { sub: 'user-1' }; + const client = createDaVinciClientMock({ + userinfo: jest.fn(async () => claims), + }); + let latest: DaVinciHookResult | null = null; + + render( + { + latest = r; + }} + />, + ); + + let result: unknown; + await act(async () => { + result = await requireLatest(latest).userinfo(); + }); + + expect(result).toBe(claims); + expect(client.userinfo).toHaveBeenCalledTimes(1); + }); + + it('pollStatus() delegates to the client with the callback and options', async () => { + const unsubscribe = jest.fn(); + const pollStatusMock = jest.fn(async () => unsubscribe); + const client = createDaVinciClientMock({ pollStatus: pollStatusMock }); + let latest: DaVinciHookResult | null = null; + + render( + { + latest = r; + }} + />, + ); + + const onStatus = jest.fn((_status: PollingStatus) => undefined); + let result: (() => void) | undefined; + await act(async () => { + result = await requireLatest(latest).pollStatus(onStatus, { + key: 'poll-key', + }); + }); + + expect(pollStatusMock).toHaveBeenCalledWith(onStatus, { key: 'poll-key' }); + expect(result).toBe(unsubscribe); + }); + + it('pollStatus() propagates rejection when no PollingCollector is resolved', async () => { + const pollError = { + type: 'state_error', + error: 'DAVINCI_STATE_ERROR', + message: 'No active PollingCollector resolved.', + }; + const client = createDaVinciClientMock({ + pollStatus: jest.fn(async () => { + throw pollError; + }), + }); + let latest: DaVinciHookResult | null = null; + + render( + { + latest = r; + }} + />, + ); + + let err: unknown; + await act(async () => { + err = await requireLatest(latest) + .pollStatus(jest.fn()) + .catch((e: unknown) => e); + }); + + expect(err).toBe(pollError); + }); }); diff --git a/packages/davinci/src/collectorHelpers.ts b/packages/davinci/src/collectorHelpers.ts index 7ace2b47f..cb94c8f8e 100644 --- a/packages/davinci/src/collectorHelpers.ts +++ b/packages/davinci/src/collectorHelpers.ts @@ -35,7 +35,21 @@ const manualCollectorTypes = new Set([ 'DEVICE_AUTHENTICATION', ]); -const outputOnlyCollectorTypes = new Set(['LABEL']); +/** + * Output-only collector types — no user input, not submitted via `next()`. + * + * @remarks + * `POLLING` is driven by {@link DaVinciClient.pollStatus}, not by form + * submission; `QR_CODE` is a display-only image. Both classify as + * `output_only` for execution-mode purposes even though they represent very + * different UI affordances (see {@link resolveFieldKind}, which distinguishes + * them via the `polling`/`qrCode` kinds). + */ +const outputOnlyCollectorTypes = new Set([ + 'LABEL', + 'POLLING', + 'QR_CODE', +]); const immediateCollectorTypes = new Set([ 'SUBMIT_BUTTON', @@ -97,6 +111,8 @@ const deviceFieldKindTypes = new Set([ 'DEVICE_AUTHENTICATION', ]); const outputFieldKindTypes = new Set(['LABEL']); +const pollingFieldKindTypes = new Set(['POLLING']); +const qrCodeFieldKindTypes = new Set(['QR_CODE']); const flowFieldKindTypes = new Set([ 'SUBMIT_BUTTON', 'ACTION', @@ -189,6 +205,12 @@ export function resolveFieldKind(type: string): DaVinciFieldKind { if (outputFieldKindTypes.has(type)) { return 'output'; } + if (pollingFieldKindTypes.has(type)) { + return 'polling'; + } + if (qrCodeFieldKindTypes.has(type)) { + return 'qrCode'; + } if (flowFieldKindTypes.has(type)) { return 'flow'; } diff --git a/packages/davinci/src/davinci.ts b/packages/davinci/src/davinci.ts index 9ce1760a1..62555ad0f 100644 --- a/packages/davinci/src/davinci.ts +++ b/packages/davinci/src/davinci.ts @@ -12,14 +12,23 @@ import { getDaVinciUserInfo, logoutDaVinci, nextDaVinci, + pollDaVinci, refreshDaVinciSession, revokeDaVinciSession, startDaVinci, } from './davinciMethods'; +import { DaVinciEvents } from './events'; import type { NativeDaVinciConfig } from './NativeRNPingDavinci'; -import type { DaVinciClient, DaVinciConfig, DaVinciNextInput } from './types'; +import type { + DaVinciClient, + DaVinciConfig, + DaVinciNextInput, + DaVinciPollStatusOptions, + PollingStatus, +} from './types'; import { DaVinciError } from './types/error.types'; import { noopLogger } from '@ping-identity/rn-types'; +import { DeviceEventEmitter } from 'react-native'; /** * Resolves and validates the OIDC storage handle id from the config. @@ -391,6 +400,67 @@ export function createDaVinciClient(config: DaVinciConfig): DaVinciClient { return ensureConfigured(); }, + /** + * Streams `PollingStatus` updates for the active `PollingCollector`. + * + * @remarks + * Resolves the bridge's early-resolving `{ subscriptionId }` promise, then + * — synchronously in that same continuation — subscribes to the shared + * `DeviceEventEmitter` channel filtered by `subscriptionId`, so there is no + * window where a tick can arrive before the listener is attached. The + * listener removes itself on any terminal status (`complete`, `timedOut`, + * `expired`, `error`). Does not call `next()` — the caller must advance + * the flow explicitly on a terminal status. + * + * The returned unsubscribe function stops **local event delivery only** — + * neither native SDK exposes a primitive to cancel an in-flight poll, so + * the native poll continues running to completion (bounded by + * `pollRetries` × `pollInterval`) even after `unsubscribe()` is called. + * + * @param onStatus - Callback invoked with each streamed status tick. + * @param options - Optional collector selection. + * @returns An unsubscribe function that stops local event delivery. The + * native poll keeps running to completion; it cannot be cancelled. + * @throws {DaVinciError} When no active `PollingCollector` is resolved. + */ + async pollStatus( + onStatus: (status: PollingStatus) => void, + options: DaVinciPollStatusOptions = {}, + ) { + const id = await ensureConfigured(); + logDebug('DaVinci pollStatus requested', { davinciId: id }); + let subscriptionId: string; + try { + subscriptionId = await pollDaVinci(id, options); + } catch (error) { + logError('DaVinci pollStatus failed', error, { davinciId: id }); + throw error; + } + + const subscription = DeviceEventEmitter.addListener( + DaVinciEvents.POLLING_STATUS, + (event: Record) => { + if (event.subscriptionId !== subscriptionId) { + return; + } + const status = event as unknown as PollingStatus; + if (status.status !== 'continue') { + subscription.remove(); + } + onStatus(status); + }, + ); + + logInfo('DaVinci pollStatus succeeded', { + davinciId: id, + subscriptionId, + }); + + return () => { + subscription.remove(); + }; + }, + /** * Disposes the native DaVinci instance and releases runtime state. * diff --git a/packages/davinci/src/davinciMethods.ts b/packages/davinci/src/davinciMethods.ts index 194c039d5..f4bfd10ed 100644 --- a/packages/davinci/src/davinciMethods.ts +++ b/packages/davinci/src/davinciMethods.ts @@ -14,6 +14,16 @@ import type { } from './types'; import { DaVinciError } from './types/error.types'; +/** + * Options accepted by {@link pollDaVinci}. + * + * @internal + */ +export type PollDaVinciOptions = { + /** Collector key to poll, when more than one `PollingCollector` is present. */ + key?: string; +}; + /** * Configure a native DaVinci workflow instance. * @@ -211,3 +221,36 @@ export async function disposeDaVinci(davinciId: string): Promise { throw DaVinciError.from(error); } } + +/** + * Start streaming polling status updates for the active `PollingCollector`. + * + * @param davinciId - Native DaVinci instance identifier. + * @param options - Optional collector selection. + * @returns Native-generated subscription id used to filter `PollingStatus` events. + * @throws {DaVinciError} When no active `PollingCollector` is resolved, or the + * bridge returns a malformed subscription payload. + */ +export async function pollDaVinci( + davinciId: string, + options: PollDaVinciOptions = {}, +): Promise { + try { + const result = await NativeRNPingDavinci.pollDaVinci(davinciId, options); + if ( + result === null || + typeof result !== 'object' || + typeof (result as { subscriptionId?: unknown }).subscriptionId !== + 'string' + ) { + throw new DaVinciError( + '[@ping-identity/rn-davinci] Native bridge returned a malformed subscription payload.', + 'DAVINCI_POLL_ERROR', + 'native_error', + ); + } + return (result as { subscriptionId: string }).subscriptionId; + } catch (error) { + throw DaVinciError.from(error); + } +} diff --git a/packages/davinci/src/events.ts b/packages/davinci/src/events.ts new file mode 100644 index 000000000..bbb0a22ce --- /dev/null +++ b/packages/davinci/src/events.ts @@ -0,0 +1,11 @@ +/* + * 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. + */ + +/** Internal DeviceEventEmitter event names used by the RNPingDavinci bridge. */ +export const DaVinciEvents = { + POLLING_STATUS: 'com.pingidentity.rndavinci.PollingStatus', +} as const; diff --git a/packages/davinci/src/index.tsx b/packages/davinci/src/index.tsx index d891cd34b..d55525c1b 100644 --- a/packages/davinci/src/index.tsx +++ b/packages/davinci/src/index.tsx @@ -39,6 +39,10 @@ export type { * DaVinci error class. */ export { DaVinciError } from './types/error.types'; +/** + * Internal DeviceEventEmitter event names used by the RNPingDavinci bridge. + */ +export { DaVinciEvents } from './events'; /** * All DaVinci public type contracts. */ diff --git a/packages/davinci/src/types/client.types.ts b/packages/davinci/src/types/client.types.ts index 08aacd37d..d0df7bb1f 100644 --- a/packages/davinci/src/types/client.types.ts +++ b/packages/davinci/src/types/client.types.ts @@ -6,7 +6,12 @@ */ import type { DaVinciNextInput } from './config.types'; -import type { DaVinciNode, DaVinciUserSession } from './node.types'; +import type { + DaVinciNode, + DaVinciPollStatusOptions, + DaVinciUserSession, + PollingStatus, +} from './node.types'; /** * DaVinci imperative client API contract. @@ -112,4 +117,28 @@ export type DaVinciClient = { * @throws {DaVinciError} When configuration fails. */ getId: () => Promise; + + /** + * Streams {@link PollingStatus} updates for the active `PollingCollector` + * on the current node. + * + * @remarks + * Does not auto-advance the flow — the consumer must call `next()` + * explicitly on any terminal status (`complete`, `timedOut`, `expired`, + * `error`) to progress past it. The returned unsubscribe function stops + * local event delivery only — neither native SDK exposes a primitive to + * cancel an in-flight poll, so the native poll keeps running to completion + * (bounded by `pollRetries` × `pollInterval`) even after unsubscribing. + * + * @param onStatus - Callback invoked with each streamed {@link PollingStatus} tick. + * @param options - Optional collector selection (`key`), when more than one + * `PollingCollector` is present on the active node. + * @returns An unsubscribe function that stops local event delivery. It does + * not stop the native poll, which cannot be cancelled. + * @throws {DaVinciError} When no active `PollingCollector` is resolved. + */ + pollStatus: ( + onStatus: (status: PollingStatus) => void, + options?: DaVinciPollStatusOptions, + ) => Promise<() => void>; }; diff --git a/packages/davinci/src/types/error.types.ts b/packages/davinci/src/types/error.types.ts index 819e8bffa..6ea2e62ae 100644 --- a/packages/davinci/src/types/error.types.ts +++ b/packages/davinci/src/types/error.types.ts @@ -58,6 +58,7 @@ export type DaVinciErrorCode = | 'DAVINCI_SESSION_ERROR' // user() / refresh() / revoke() / userinfo() | 'DAVINCI_LOGOUT_ERROR' // logoutUser() | 'DAVINCI_DISPOSE_ERROR' // dispose() + | 'DAVINCI_POLL_ERROR' // pollStatus(): poll start/subscription failure | 'DAVINCI_ARGUMENT_ERROR' // JS guardrails: invalid argument at call site | 'DAVINCI_STATE_ERROR' // operation on invalid state (e.g. no active node) | 'DAVINCI_MISSING_INTEGRATION_ERROR' // collector requires additional module integration diff --git a/packages/davinci/src/types/form.types.ts b/packages/davinci/src/types/form.types.ts index 6eca86ffc..dc3bf0c16 100644 --- a/packages/davinci/src/types/form.types.ts +++ b/packages/davinci/src/types/form.types.ts @@ -56,6 +56,8 @@ export type DaVinciExecutionMode = * - `phone` — phone number input (PHONE_NUMBER) * - `device` — device picker (DEVICE_REGISTRATION, DEVICE_AUTHENTICATION) * - `output` — display-only content (LABEL) + * - `polling` — async out-of-band polling status (POLLING), driven by `pollStatus` + * - `qrCode` — display-only QR code image (QR_CODE) * - `flow` — immediate-submit action (SUBMIT_BUTTON, ACTION, FLOW_BUTTON, FLOW_LINK) * - `integration` — handled by an external integration package (future collectors) * - `unknown` — unrecognised type @@ -70,6 +72,8 @@ export type DaVinciFieldKind = | 'phone' | 'device' | 'output' + | 'polling' + | 'qrCode' | 'flow' | 'integration' | 'unknown'; diff --git a/packages/davinci/src/types/node.types.ts b/packages/davinci/src/types/node.types.ts index 25a87b1bd..4d2c9fbcc 100644 --- a/packages/davinci/src/types/node.types.ts +++ b/packages/davinci/src/types/node.types.ts @@ -390,6 +390,79 @@ export type IdpCollector = { raw?: Record; }; +/** + * Async polling collector — waits for out-of-band user action (push approval, + * QR scan, email verification) to complete on another device or channel. + * + * @remarks + * Driven by {@link DaVinciClient.pollStatus}, not by form submission — `pollStatus` + * resolves the active `PollingCollector` on the current {@link ContinueNode} and + * streams {@link PollingStatus} events until a terminal status is reached. + * + * `pollInterval`/`pollRetries` are normalized to `number` across platforms — + * iOS's native collector exposes them as `Int`, Android's as `String`; the + * bridge coerces Android's values before serialising. + * + * @public + */ +export type PollingCollector = BaseCollector & { + type: 'POLLING'; + /** Polling interval in milliseconds between each attempt. */ + pollInterval: number; + /** Maximum number of polling attempts before timing out. */ + pollRetries: number; + /** Whether this collector polls a server challenge-status endpoint rather than simple retry counting. */ + pollChallengeStatus: boolean; + /** Challenge identifier used to construct the polling endpoint URL when `pollChallengeStatus` is `true`. */ + challenge: string; +}; + +/** + * Display-only QR code collector — renders a scannable code for out-of-band + * authentication (e.g. push registration, cross-device sign-in). + * + * @remarks + * Does not extend {@link BaseCollector} — like `LabelCollector`, the native + * `QRCodeCollector` (Android: `Collector`; iOS: `Collector`) does not + * implement `FieldCollector` and therefore has no `label` or `required`. + * + * Does not participate in form submission — native `payload()` always returns + * `nil`/`null` on both platforms. + * + * `key` is not exposed by the native Android `QRCodeCollector` (2.1.0) — the + * server sends it in the raw field JSON, but the collector's `init()` never + * reads it, and its `id()` falls back to a fresh random UUID on every call. + * On Android the bridge emits `key: ""` for this collector until the native + * SDK exposes it; see `DaVinciNodeMapper.kt`'s `TODO-SDK-PARITY` comment. + * iOS's native collector does parse `key` and is unaffected. + * + * @public + */ +export type QRCodeCollector = { + /** Unique collector key identifying this field in the form. */ + key: string; + type: 'QR_CODE'; + /** + * Full data URI of the QR code image (e.g. `"data:image/png;base64,..."`). + * + * @remarks + * Android's native `content` getter retains the raw data URI as received + * from the server. iOS's native collector strips the `"data:...base64,"` + * prefix during `init` and only retains decoded `imageData` bytes — the + * bridge reconstructs the data URI on iOS (see `DaVinciNodeMapper.swift`). + */ + content: string; + /** Alternative text to display when the QR code cannot be rendered or scanned. */ + fallbackText: string; + /** + * Raw server-side field JSON from `node.input.form.components.fields[]`. + * + * @remarks + * Populated by the native mapper. + */ + raw?: Record; +}; + /** * Discriminated union of all collector types returned by the DaVinci bridge. * @@ -410,7 +483,48 @@ export type DaVinciCollector = | PhoneNumberCollector | DeviceRegistrationCollector | DeviceAuthenticationCollector - | IdpCollector; + | IdpCollector + | PollingCollector + | QRCodeCollector; + +/** + * Discriminated union of streamed polling status events emitted by + * {@link DaVinciClient.pollStatus}. + * + * @remarks + * Mirrors the native `PollingStatus` streamed by iOS `PollingCollector.poll()` + * (`AsyncStream`) and Android `PollingCollector.pollStatus()` + * (`Flow`). `continue` is an intermediate tick; `complete`, + * `timedOut`, `expired`, and `error` are terminal — no further ticks are + * emitted for the same `subscriptionId` after one of these fires. + * + * `pollStatus` does not auto-advance the flow on a terminal status — the + * consumer must call `next()` explicitly to progress past it. + * + * @public + */ +export type PollingStatus = + | { status: 'continue'; retryCount: number; maxRetries: number } + | { status: 'complete'; value: string } + | { status: 'timedOut' } + | { status: 'expired' } + | { status: 'error'; error: { message: string } }; + +/** + * Options accepted by {@link DaVinciClient.pollStatus}. + * + * @public + */ +export type DaVinciPollStatusOptions = { + /** + * Collector key to poll, when more than one {@link PollingCollector} is + * present on the active {@link ContinueNode}. + * + * @remarks + * When omitted, the bridge resolves the first `PollingCollector` on the node. + */ + key?: string; +}; // --------------------------------------------------------------------------- // Node shapes diff --git a/packages/davinci/src/useDavinci.tsx b/packages/davinci/src/useDavinci.tsx index 87303090d..dd674a1de 100644 --- a/packages/davinci/src/useDavinci.tsx +++ b/packages/davinci/src/useDavinci.tsx @@ -19,7 +19,9 @@ import type { DaVinciConfig, DaVinciNextInput, DaVinciNode, + DaVinciPollStatusOptions, DaVinciUserSession, + PollingStatus, } from './types'; /** @@ -79,6 +81,20 @@ export type DaVinciHookActions = { * @throws {DaVinciError} When logout fails. */ logoutUser: () => Promise; + /** + * Streams {@link PollingStatus} updates for the active `PollingCollector` + * on the current node. + * + * @param onStatus - Callback invoked with each streamed status tick. + * @param options - Optional collector selection. + * @returns An unsubscribe function that removes the listener and cancels + * the native poll if still active. + * @throws {DaVinciError} When no active `PollingCollector` is resolved. + */ + pollStatus: ( + onStatus: (status: PollingStatus) => void, + options?: DaVinciPollStatusOptions, + ) => Promise<() => void>; /** * Dispose the native DaVinci instance and reset local hook state. * @@ -160,6 +176,9 @@ const missingDaVinciClient: DaVinciClient = { async getId(): Promise { throw missingDaVinciClientError; }, + async pollStatus(): Promise<() => void> { + throw missingDaVinciClientError; + }, }; /** @@ -246,6 +265,14 @@ function useDaVinciState(client: DaVinciClient): DaVinciHookResult { setNode(null); }, [client]); + const pollStatus = useCallback( + async ( + onStatus: (status: PollingStatus) => void, + options?: DaVinciPollStatusOptions, + ): Promise<() => void> => await client.pollStatus(onStatus, options), + [client], + ); + const dispose = useCallback(async (): Promise => { await client.dispose(); setNode(null); @@ -261,6 +288,7 @@ function useDaVinciState(client: DaVinciClient): DaVinciHookResult { revoke, userinfo, logoutUser, + pollStatus, dispose, loading, error, diff --git a/packages/device-client/android/build.gradle b/packages/device-client/android/build.gradle index 1afb86a69..12645438e 100644 --- a/packages/device-client/android/build.gradle +++ b/packages/device-client/android/build.gradle @@ -4,6 +4,8 @@ * This software may be modified and distributed under the terms * of the MIT license. See the LICENSE file for details. */ + +// TEMP(SDKS-5130): bumped to SDK 2.1.0 / JVM 21 ahead of the SDKS-5245 bump PR; revert this file's version/JVM changes when it merges to main. buildscript { ext.RNPingDeviceClient = [ kotlinVersion: "2.2.10", diff --git a/packages/device-id/android/build.gradle b/packages/device-id/android/build.gradle index a6a93c763..f2c41cef0 100644 --- a/packages/device-id/android/build.gradle +++ b/packages/device-id/android/build.gradle @@ -4,6 +4,8 @@ * This software may be modified and distributed under the terms * of the MIT license. See the LICENSE file for details. */ + +// TEMP(SDKS-5130): bumped to SDK 2.1.0 / JVM 21 ahead of the SDKS-5245 bump PR; revert this file's version/JVM changes when it merges to main. buildscript { ext.RNPingDeviceId = [ kotlinVersion: "2.2.10", diff --git a/packages/device-profile/android/build.gradle b/packages/device-profile/android/build.gradle index 11643e465..4dac7b0df 100644 --- a/packages/device-profile/android/build.gradle +++ b/packages/device-profile/android/build.gradle @@ -4,6 +4,8 @@ * This software may be modified and distributed under the terms * of the MIT license. See the LICENSE file for details. */ + +// TEMP(SDKS-5130): bumped to SDK 2.1.0 / JVM 21 ahead of the SDKS-5245 bump PR; revert this file's version/JVM changes when it merges to main. buildscript { ext.RNPingDeviceProfile = [ diff --git a/packages/external-idp/RNPingExternalIdp.podspec b/packages/external-idp/RNPingExternalIdp.podspec index e0a9f9a25..2df8abe6e 100644 --- a/packages/external-idp/RNPingExternalIdp.podspec +++ b/packages/external-idp/RNPingExternalIdp.podspec @@ -35,6 +35,7 @@ Pod::Spec.new do |s| s.private_header_files = "ios/**/*.h" s.swift_version = ['5.0', '5.1', '6.0'] + # TEMP(SDKS-5130): bumped to 2.1.0 to match RNPingDavinci's dependency versions in the shared Podfile; revert when SDKS-5245 merges to main. # Native Ping SDK dependency s.dependency "RNPingCore" s.dependency "PingExternalIdP", '2.1.0' diff --git a/packages/external-idp/android/build.gradle b/packages/external-idp/android/build.gradle index edd60d4df..c1dc14bae 100644 --- a/packages/external-idp/android/build.gradle +++ b/packages/external-idp/android/build.gradle @@ -103,7 +103,7 @@ dependencies { implementation("com.pingidentity.sdks:logger:2.1.0") implementation("com.pingidentity.sdks:external-idp:2.1.0") implementation("com.pingidentity.sdks:journey-plugin:2.1.0") - compileOnly("com.pingidentity.sdks:davinci:2.1.0") + compileOnly("com.pingidentity.sdks:davinci:2.1.0") // IdpCollector is in davinci; compileOnly so apps without rn-davinci don't bundle it implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:1.9.0" implementation "org.jetbrains.kotlinx:kotlinx-serialization-json:1.9.0" implementation(project(":ping-identity_rn-core")) diff --git a/packages/fido/android/build.gradle b/packages/fido/android/build.gradle index d9c21954e..2f5455b44 100644 --- a/packages/fido/android/build.gradle +++ b/packages/fido/android/build.gradle @@ -4,6 +4,8 @@ * This software may be modified and distributed under the terms * of the MIT license. See the LICENSE file for details. */ + +// TEMP(SDKS-5130): bumped to SDK 2.1.0 / JVM 21 ahead of the SDKS-5245 bump PR; revert this file's version/JVM changes when it merges to main. buildscript { ext.RNPingFido = [ kotlinVersion: "2.2.10", diff --git a/packages/journey/android/build.gradle b/packages/journey/android/build.gradle index 61645eb8d..ffc213436 100644 --- a/packages/journey/android/build.gradle +++ b/packages/journey/android/build.gradle @@ -4,6 +4,8 @@ * This software may be modified and distributed under the terms * of the MIT license. See the LICENSE file for details. */ + +// TEMP(SDKS-5130): bumped to SDK 2.1.0 / JVM 21 ahead of the SDKS-5245 bump PR; revert this file's version/JVM changes when it merges to main. buildscript { ext.getExtOrDefault = {name -> return rootProject.ext.has(name) ? rootProject.ext.get(name) : project.properties['RNPingJourney_' + name] diff --git a/packages/logger/android/build.gradle b/packages/logger/android/build.gradle index 16e269594..0a1afbbec 100644 --- a/packages/logger/android/build.gradle +++ b/packages/logger/android/build.gradle @@ -5,6 +5,8 @@ * of the MIT license. See the LICENSE file for details. */ +// TEMP(SDKS-5130): bumped to SDK 2.1.0 / JVM 21 ahead of the SDKS-5245 bump PR; revert this file's version/JVM changes when it merges to main. + import groovy.json.JsonSlurper buildscript { diff --git a/packages/oath/android/build.gradle b/packages/oath/android/build.gradle index 8ace01f2d..32865581e 100644 --- a/packages/oath/android/build.gradle +++ b/packages/oath/android/build.gradle @@ -4,6 +4,8 @@ * This software may be modified and distributed under the terms * of the MIT license. See the LICENSE file for details. */ + +// TEMP(SDKS-5130): bumped to SDK 2.1.0 / JVM 21 ahead of the SDKS-5245 bump PR; revert this file's version/JVM changes when it merges to main. buildscript { ext.RNPingOath = [ kotlinVersion: "2.2.10", diff --git a/packages/oidc/android/build.gradle b/packages/oidc/android/build.gradle index f76270e65..12a4f5aa1 100644 --- a/packages/oidc/android/build.gradle +++ b/packages/oidc/android/build.gradle @@ -5,6 +5,8 @@ * of the MIT license. See the LICENSE file for details. */ +// TEMP(SDKS-5130): bumped to SDK 2.1.0 / JVM 21 ahead of the SDKS-5245 bump PR; revert this file's version/JVM changes when it merges to main. + buildscript { ext.RNPingOidc = [ kotlinVersion: "2.2.10", diff --git a/packages/push/android/build.gradle b/packages/push/android/build.gradle index a6631ff84..1f502d3e2 100644 --- a/packages/push/android/build.gradle +++ b/packages/push/android/build.gradle @@ -4,6 +4,8 @@ * This software may be modified and distributed under the terms * of the MIT license. See the LICENSE file for details. */ + +// TEMP(SDKS-5130): bumped to SDK 2.1.0 / JVM 21 ahead of the SDKS-5245 bump PR; revert this file's version/JVM changes when it merges to main. buildscript { ext.RNPingPush = [ kotlinVersion: "2.2.10", diff --git a/packages/storage/android/build.gradle b/packages/storage/android/build.gradle index f6ea95c75..a36439d9e 100644 --- a/packages/storage/android/build.gradle +++ b/packages/storage/android/build.gradle @@ -5,6 +5,8 @@ * of the MIT license. See the LICENSE file for details. */ +// TEMP(SDKS-5130): bumped to SDK 2.1.0 / JVM 21 ahead of the SDKS-5245 bump PR; revert this file's version/JVM changes when it merges to main. + buildscript { ext.getExtOrDefault = {name -> return rootProject.ext.has(name) ? rootProject.ext.get(name) : project.properties['RNPingStorage_' + name] From 05a6d3dc140709eff06767e9db1d517ef917f791 Mon Sep 17 00:00:00 2001 From: Thais Damasceno Date: Wed, 12 Aug 2026 14:21:46 -0700 Subject: [PATCH 2/8] fix: remove temp comments --- .gitignore | 1 + PingSampleApp/ios/Podfile.lock | 7 +++---- PingTestRunner/android/app/build.gradle | 1 + packages/binding/android/build.gradle | 1 - packages/browser/android/build.gradle | 2 -- packages/core/android/build.gradle | 2 -- packages/davinci/RNPingDavinci.podspec | 2 +- packages/device-client/android/build.gradle | 1 - packages/device-id/android/build.gradle | 1 - packages/device-profile/android/build.gradle | 2 -- packages/external-idp/RNPingExternalIdp.podspec | 1 - packages/fido/android/build.gradle | 1 - packages/journey/android/build.gradle | 1 - packages/logger/android/build.gradle | 2 -- packages/oath/android/build.gradle | 1 - packages/oidc/android/build.gradle | 2 -- packages/push/android/build.gradle | 1 - packages/storage/android/build.gradle | 2 -- 18 files changed, 6 insertions(+), 25 deletions(-) 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/ios/Podfile.lock b/PingSampleApp/ios/Podfile.lock index 67c45161d..c0c43d92b 100644 --- a/PingSampleApp/ios/Podfile.lock +++ b/PingSampleApp/ios/Podfile.lock @@ -2932,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 @@ -3691,7 +3691,7 @@ SPEC CHECKSUMS: RNPingLogger: b7dd73f42b3fea86a56fc7a35999cf4333fbde20 RNPingOath: 261b44b957975c0c036b1bed6ef953c4767b8fcc RNPingOidc: d490892cb09a11c189b2259bca0fecfc09a109fd - RNPingPush: 3f02ee0f3d971c0d8affdb13b8fcefc4de789de1 + RNPingPush: d160e2fac65d1f19b0e95d9196f618cc5aaaddf1 RNPingStorage: 2c45c6d29cfcf8e1ed8ec3e32df1443e29dabd67 RNScreens: 846d53087db560ed5fbc34feb0643adb5f9602c5 RNSVG: e8fb86f41fccd7b67c4480bb9e179e0ad5785b80 @@ -3701,7 +3701,6 @@ SPEC CHECKSUMS: VisionCameraBarcodeScanner: 0b6fe2e3c52ca68a0dbd5d25719c7523c68d807f Yoga: daa1e4de4b971b977b23bc842aaa3e135324f1f3 -PODFILE CHECKSUM: 028568894d00fcf91d539f78055479b61112c6a8 - +PODFILE CHECKSUM: 35159028c49678100eea2347153c0cf4cd45998f COCOAPODS: 1.15.2 diff --git a/PingTestRunner/android/app/build.gradle b/PingTestRunner/android/app/build.gradle index 2964e93b3..30efb5ea1 100644 --- a/PingTestRunner/android/app/build.gradle +++ b/PingTestRunner/android/app/build.gradle @@ -80,6 +80,7 @@ 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/packages/binding/android/build.gradle b/packages/binding/android/build.gradle index 1bbcd2817..4747a3831 100644 --- a/packages/binding/android/build.gradle +++ b/packages/binding/android/build.gradle @@ -5,7 +5,6 @@ * of the MIT license. See the LICENSE file for details. */ -// TEMP(SDKS-5130): bumped to SDK 2.1.0 / JVM 21 ahead of the SDKS-5245 bump PR; revert this file's version/JVM changes when it merges to main. buildscript { ext.RNPingBinding = [ kotlinVersion: "2.2.10", diff --git a/packages/browser/android/build.gradle b/packages/browser/android/build.gradle index 72e2f5044..db4644fd2 100644 --- a/packages/browser/android/build.gradle +++ b/packages/browser/android/build.gradle @@ -5,8 +5,6 @@ * of the MIT license. See the LICENSE file for details. */ -// TEMP(SDKS-5130): bumped to SDK 2.1.0 / JVM 21 ahead of the SDKS-5245 bump PR; revert this file's version/JVM changes when it merges to main. - buildscript { ext.getExtOrDefault = {name -> return rootProject.ext.has(name) ? rootProject.ext.get(name) : project.properties['RNPingBrowser_' + name] diff --git a/packages/core/android/build.gradle b/packages/core/android/build.gradle index 3d31328b1..6f8c13142 100644 --- a/packages/core/android/build.gradle +++ b/packages/core/android/build.gradle @@ -5,8 +5,6 @@ * of the MIT license. See the LICENSE file for details. */ -// TEMP(SDKS-5130): bumped to SDK 2.1.0 / JVM 21 ahead of the SDKS-5245 bump PR; revert this file's version/JVM changes when it merges to main. - buildscript { ext.getExtOrDefault = {name -> return rootProject.ext.has(name) ? rootProject.ext.get(name) : project.properties['RNPingCore_' + name] diff --git a/packages/davinci/RNPingDavinci.podspec b/packages/davinci/RNPingDavinci.podspec index 5e8fb106c..44a80d515 100644 --- a/packages/davinci/RNPingDavinci.podspec +++ b/packages/davinci/RNPingDavinci.podspec @@ -36,7 +36,7 @@ Pod::Spec.new do |s| s.private_header_files = "ios/**/*.h" s.swift_version = ['5.0', '5.1', '6.0'] - # TEMP(SDKS-5130): bumped to 2.1.0 to consume new collectors ahead of the SDKS-5245 bump PR; revert when it merges to main. + s.dependency "RNPingCore" s.dependency "PingDavinci", '2.1.0' s.dependency "PingDavinciPlugin", '2.1.0' diff --git a/packages/device-client/android/build.gradle b/packages/device-client/android/build.gradle index 12645438e..9781f2e25 100644 --- a/packages/device-client/android/build.gradle +++ b/packages/device-client/android/build.gradle @@ -5,7 +5,6 @@ * of the MIT license. See the LICENSE file for details. */ -// TEMP(SDKS-5130): bumped to SDK 2.1.0 / JVM 21 ahead of the SDKS-5245 bump PR; revert this file's version/JVM changes when it merges to main. buildscript { ext.RNPingDeviceClient = [ kotlinVersion: "2.2.10", diff --git a/packages/device-id/android/build.gradle b/packages/device-id/android/build.gradle index f2c41cef0..8f7fb28ec 100644 --- a/packages/device-id/android/build.gradle +++ b/packages/device-id/android/build.gradle @@ -5,7 +5,6 @@ * of the MIT license. See the LICENSE file for details. */ -// TEMP(SDKS-5130): bumped to SDK 2.1.0 / JVM 21 ahead of the SDKS-5245 bump PR; revert this file's version/JVM changes when it merges to main. buildscript { ext.RNPingDeviceId = [ kotlinVersion: "2.2.10", diff --git a/packages/device-profile/android/build.gradle b/packages/device-profile/android/build.gradle index 4dac7b0df..e5fad2290 100644 --- a/packages/device-profile/android/build.gradle +++ b/packages/device-profile/android/build.gradle @@ -5,8 +5,6 @@ * of the MIT license. See the LICENSE file for details. */ -// TEMP(SDKS-5130): bumped to SDK 2.1.0 / JVM 21 ahead of the SDKS-5245 bump PR; revert this file's version/JVM changes when it merges to main. - buildscript { ext.RNPingDeviceProfile = [ kotlinVersion: "2.2.10", diff --git a/packages/external-idp/RNPingExternalIdp.podspec b/packages/external-idp/RNPingExternalIdp.podspec index 2df8abe6e..e0a9f9a25 100644 --- a/packages/external-idp/RNPingExternalIdp.podspec +++ b/packages/external-idp/RNPingExternalIdp.podspec @@ -35,7 +35,6 @@ Pod::Spec.new do |s| s.private_header_files = "ios/**/*.h" s.swift_version = ['5.0', '5.1', '6.0'] - # TEMP(SDKS-5130): bumped to 2.1.0 to match RNPingDavinci's dependency versions in the shared Podfile; revert when SDKS-5245 merges to main. # Native Ping SDK dependency s.dependency "RNPingCore" s.dependency "PingExternalIdP", '2.1.0' diff --git a/packages/fido/android/build.gradle b/packages/fido/android/build.gradle index 2f5455b44..124818ed3 100644 --- a/packages/fido/android/build.gradle +++ b/packages/fido/android/build.gradle @@ -5,7 +5,6 @@ * of the MIT license. See the LICENSE file for details. */ -// TEMP(SDKS-5130): bumped to SDK 2.1.0 / JVM 21 ahead of the SDKS-5245 bump PR; revert this file's version/JVM changes when it merges to main. buildscript { ext.RNPingFido = [ kotlinVersion: "2.2.10", diff --git a/packages/journey/android/build.gradle b/packages/journey/android/build.gradle index ffc213436..e8f0a9645 100644 --- a/packages/journey/android/build.gradle +++ b/packages/journey/android/build.gradle @@ -5,7 +5,6 @@ * of the MIT license. See the LICENSE file for details. */ -// TEMP(SDKS-5130): bumped to SDK 2.1.0 / JVM 21 ahead of the SDKS-5245 bump PR; revert this file's version/JVM changes when it merges to main. buildscript { ext.getExtOrDefault = {name -> return rootProject.ext.has(name) ? rootProject.ext.get(name) : project.properties['RNPingJourney_' + name] diff --git a/packages/logger/android/build.gradle b/packages/logger/android/build.gradle index 0a1afbbec..16e269594 100644 --- a/packages/logger/android/build.gradle +++ b/packages/logger/android/build.gradle @@ -5,8 +5,6 @@ * of the MIT license. See the LICENSE file for details. */ -// TEMP(SDKS-5130): bumped to SDK 2.1.0 / JVM 21 ahead of the SDKS-5245 bump PR; revert this file's version/JVM changes when it merges to main. - import groovy.json.JsonSlurper buildscript { diff --git a/packages/oath/android/build.gradle b/packages/oath/android/build.gradle index 32865581e..dc0df263f 100644 --- a/packages/oath/android/build.gradle +++ b/packages/oath/android/build.gradle @@ -5,7 +5,6 @@ * of the MIT license. See the LICENSE file for details. */ -// TEMP(SDKS-5130): bumped to SDK 2.1.0 / JVM 21 ahead of the SDKS-5245 bump PR; revert this file's version/JVM changes when it merges to main. buildscript { ext.RNPingOath = [ kotlinVersion: "2.2.10", diff --git a/packages/oidc/android/build.gradle b/packages/oidc/android/build.gradle index 12a4f5aa1..f76270e65 100644 --- a/packages/oidc/android/build.gradle +++ b/packages/oidc/android/build.gradle @@ -5,8 +5,6 @@ * of the MIT license. See the LICENSE file for details. */ -// TEMP(SDKS-5130): bumped to SDK 2.1.0 / JVM 21 ahead of the SDKS-5245 bump PR; revert this file's version/JVM changes when it merges to main. - buildscript { ext.RNPingOidc = [ kotlinVersion: "2.2.10", diff --git a/packages/push/android/build.gradle b/packages/push/android/build.gradle index 1f502d3e2..cf1d920ab 100644 --- a/packages/push/android/build.gradle +++ b/packages/push/android/build.gradle @@ -5,7 +5,6 @@ * of the MIT license. See the LICENSE file for details. */ -// TEMP(SDKS-5130): bumped to SDK 2.1.0 / JVM 21 ahead of the SDKS-5245 bump PR; revert this file's version/JVM changes when it merges to main. buildscript { ext.RNPingPush = [ kotlinVersion: "2.2.10", diff --git a/packages/storage/android/build.gradle b/packages/storage/android/build.gradle index a36439d9e..f6ea95c75 100644 --- a/packages/storage/android/build.gradle +++ b/packages/storage/android/build.gradle @@ -5,8 +5,6 @@ * of the MIT license. See the LICENSE file for details. */ -// TEMP(SDKS-5130): bumped to SDK 2.1.0 / JVM 21 ahead of the SDKS-5245 bump PR; revert this file's version/JVM changes when it merges to main. - buildscript { ext.getExtOrDefault = {name -> return rootProject.ext.has(name) ? rootProject.ext.get(name) : project.properties['RNPingStorage_' + name] From e53a11f9d1a42d1b90c5301eb54b3e4b3bc8de2d Mon Sep 17 00:00:00 2001 From: Thais Damasceno Date: Wed, 12 Aug 2026 14:29:31 -0700 Subject: [PATCH 3/8] fix: cleaning up branch after rebasing --- PingSampleApp/android/app/build.gradle | 1 + PingTestRunner/android/app/build.gradle | 1 - packages/binding/android/build.gradle | 1 - packages/device-client/android/build.gradle | 1 - packages/device-id/android/build.gradle | 1 - packages/device-profile/android/build.gradle | 1 - packages/fido/android/build.gradle | 1 - packages/journey/android/build.gradle | 1 - packages/oath/android/build.gradle | 1 - packages/push/android/build.gradle | 1 - 10 files changed, 1 insertion(+), 9 deletions(-) diff --git a/PingSampleApp/android/app/build.gradle b/PingSampleApp/android/app/build.gradle index ecd9062be..60c1f6014 100644 --- a/PingSampleApp/android/app/build.gradle +++ b/PingSampleApp/android/app/build.gradle @@ -129,6 +129,7 @@ android { excludes += "/META-INF/versions/9/OSGI-INF/MANIFEST.MF" } } + compileOptions { sourceCompatibility JavaVersion.VERSION_21 targetCompatibility JavaVersion.VERSION_21 diff --git a/PingTestRunner/android/app/build.gradle b/PingTestRunner/android/app/build.gradle index 30efb5ea1..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/packages/binding/android/build.gradle b/packages/binding/android/build.gradle index 4747a3831..5d85ce84f 100644 --- a/packages/binding/android/build.gradle +++ b/packages/binding/android/build.gradle @@ -4,7 +4,6 @@ * This software may be modified and distributed under the terms * of the MIT license. See the LICENSE file for details. */ - buildscript { ext.RNPingBinding = [ kotlinVersion: "2.2.10", diff --git a/packages/device-client/android/build.gradle b/packages/device-client/android/build.gradle index 9781f2e25..1afb86a69 100644 --- a/packages/device-client/android/build.gradle +++ b/packages/device-client/android/build.gradle @@ -4,7 +4,6 @@ * This software may be modified and distributed under the terms * of the MIT license. See the LICENSE file for details. */ - buildscript { ext.RNPingDeviceClient = [ kotlinVersion: "2.2.10", diff --git a/packages/device-id/android/build.gradle b/packages/device-id/android/build.gradle index 8f7fb28ec..a6a93c763 100644 --- a/packages/device-id/android/build.gradle +++ b/packages/device-id/android/build.gradle @@ -4,7 +4,6 @@ * This software may be modified and distributed under the terms * of the MIT license. See the LICENSE file for details. */ - buildscript { ext.RNPingDeviceId = [ kotlinVersion: "2.2.10", diff --git a/packages/device-profile/android/build.gradle b/packages/device-profile/android/build.gradle index e5fad2290..a2bca1b33 100644 --- a/packages/device-profile/android/build.gradle +++ b/packages/device-profile/android/build.gradle @@ -4,7 +4,6 @@ * This software may be modified and distributed under the terms * of the MIT license. See the LICENSE file for details. */ - buildscript { ext.RNPingDeviceProfile = [ kotlinVersion: "2.2.10", diff --git a/packages/fido/android/build.gradle b/packages/fido/android/build.gradle index 124818ed3..d9c21954e 100644 --- a/packages/fido/android/build.gradle +++ b/packages/fido/android/build.gradle @@ -4,7 +4,6 @@ * This software may be modified and distributed under the terms * of the MIT license. See the LICENSE file for details. */ - buildscript { ext.RNPingFido = [ kotlinVersion: "2.2.10", diff --git a/packages/journey/android/build.gradle b/packages/journey/android/build.gradle index e8f0a9645..61645eb8d 100644 --- a/packages/journey/android/build.gradle +++ b/packages/journey/android/build.gradle @@ -4,7 +4,6 @@ * This software may be modified and distributed under the terms * of the MIT license. See the LICENSE file for details. */ - buildscript { ext.getExtOrDefault = {name -> return rootProject.ext.has(name) ? rootProject.ext.get(name) : project.properties['RNPingJourney_' + name] diff --git a/packages/oath/android/build.gradle b/packages/oath/android/build.gradle index dc0df263f..8ace01f2d 100644 --- a/packages/oath/android/build.gradle +++ b/packages/oath/android/build.gradle @@ -4,7 +4,6 @@ * This software may be modified and distributed under the terms * of the MIT license. See the LICENSE file for details. */ - buildscript { ext.RNPingOath = [ kotlinVersion: "2.2.10", diff --git a/packages/push/android/build.gradle b/packages/push/android/build.gradle index cf1d920ab..a6631ff84 100644 --- a/packages/push/android/build.gradle +++ b/packages/push/android/build.gradle @@ -4,7 +4,6 @@ * This software may be modified and distributed under the terms * of the MIT license. See the LICENSE file for details. */ - buildscript { ext.RNPingPush = [ kotlinVersion: "2.2.10", From e86d7802b73742fd89158bde89978190c78c4fb9 Mon Sep 17 00:00:00 2001 From: Thais Damasceno Date: Wed, 12 Aug 2026 16:46:24 -0700 Subject: [PATCH 4/8] fix: addressing review comments --- .../integration/davinci-polling.test.ts | 35 +++++++++-- .../DaVinciUITests.swift | 2 +- packages/davinci/README.md | 19 ++++-- .../rndavinci/RNPingDavinciCommon.kt | 13 +++-- .../rndavinci/mapper/DaVinciNodeMapper.kt | 32 ++++++++-- .../rndavinci/mapper/DaVinciNodeMapperTest.kt | 5 +- packages/davinci/ios/RNPingDavinci.mm | 2 + packages/davinci/ios/RNPingDavinciClassic.mm | 1 + .../davinci/ios/RNPingDavinciCommon.swift | 52 ++++++++++------- .../ios/RNPingDavinciEventEmitterGate.h | 8 ++- .../ios/RNPingDavinciEventEmitterGate.mm | 25 ++++++-- .../davinci/ios/RNPingDavinciEvents.swift | 2 + .../ios/Tests/RNPingDavinciCommonTests.swift | 2 + .../src/__tests__/createDaVinciClient.test.ts | 36 ++++++++++++ packages/davinci/src/davinci.ts | 58 +++++++++++++------ packages/davinci/src/index.tsx | 4 -- packages/davinci/src/useDavinci.tsx | 4 +- 17 files changed, 223 insertions(+), 77 deletions(-) diff --git a/PingTestRunner/__tests__/integration/davinci-polling.test.ts b/PingTestRunner/__tests__/integration/davinci-polling.test.ts index fb0cbd841..126431db4 100644 --- a/PingTestRunner/__tests__/integration/davinci-polling.test.ts +++ b/PingTestRunner/__tests__/integration/davinci-polling.test.ts @@ -156,9 +156,28 @@ describe('@ping-identity/rn-davinci — polling integration', () => { afterEach(() => jest.restoreAllMocks()); describe('simple polling', () => { - it('resolves subscriptionId before any tick is delivered, then streams continue ticks', async () => { - const mock = makeMock(); - const { mod, emitter } = await loadDaVinci(mock); + 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(); @@ -166,6 +185,12 @@ describe('@ping-identity/rn-davinci — polling integration', () => { await client.pollStatus(onStatus); expect(mock.pollDaVinci).toHaveBeenCalledWith('davinci-id-mock', {}); + expect(onStatus).toHaveBeenNthCalledWith(1, { + subscriptionId: 'sub-1', + status: 'continue', + retryCount: 0, + maxRetries: 60, + }); emitter.emit(POLLING_STATUS_EVENT, { subscriptionId: 'sub-1', @@ -180,8 +205,8 @@ describe('@ping-identity/rn-davinci — polling integration', () => { maxRetries: 60, }); - expect(onStatus).toHaveBeenCalledTimes(2); - expect(onStatus).toHaveBeenNthCalledWith(1, { + expect(onStatus).toHaveBeenCalledTimes(3); + expect(onStatus).toHaveBeenNthCalledWith(2, { subscriptionId: 'sub-1', status: 'continue', retryCount: 1, diff --git a/PingTestRunner/ios/PingTestRunnerUITests/DaVinciUITests.swift b/PingTestRunner/ios/PingTestRunnerUITests/DaVinciUITests.swift index fb84586e8..d246021bb 100644 --- a/PingTestRunner/ios/PingTestRunnerUITests/DaVinciUITests.swift +++ b/PingTestRunner/ios/PingTestRunnerUITests/DaVinciUITests.swift @@ -83,7 +83,7 @@ final class DaVinciUITests: BaseTestCase { loginWithValidCredentials() elementWithTestID("davinci-userinfo-btn").tapWhenReady() let userinfo = textContentOfElement(withTestID: "davinci-userinfo-result", timeout: netTimeout) - XCTAssertTrue(userinfo.contains("\"sub\""), "Expected userinfo payload to contain 'sub', got '\(userinfo)'") + XCTAssertTrue(userinfo.contains("\"sub\""), "Expected userinfo payload to contain 'sub'") } func testRefreshObtainsNewToken() throws { diff --git a/packages/davinci/README.md b/packages/davinci/README.md index 21cdbec72..86db7338e 100644 --- a/packages/davinci/README.md +++ b/packages/davinci/README.md @@ -314,6 +314,8 @@ A `ContinueNode` commonly carries a `QR_CODE` collector (rendered for the user t alongside a `POLLING` collector (which reports when the out-of-band action completes): ```ts +import { DaVinciError } from '@ping-identity/rn-davinci'; + const node = await client.start(); if (node.type === 'ContinueNode') { @@ -323,20 +325,27 @@ if (node.type === 'ContinueNode') { // as an or similar. } - const unsubscribe = await client.pollStatus((status) => { + const unsubscribe = await client.pollStatus(async (status) => { switch (status.status) { case 'continue': console.log( `Waiting… attempt ${status.retryCount}/${status.maxRetries}`, ); break; - case 'complete': - client.next({ collectors: [] }); // advance explicitly - break; case 'timedOut': case 'expired': case 'error': - // Surface the failure to the user; call unsubscribe() if abandoning the poll. + // Surface the failure to the user before progressing past the node. + console.warn(`Poll ended with status: ${status.status}`); + // eslint-disable-next-line no-fallthrough + case 'complete': + try { + await client.next({ collectors: [] }); // required on every terminal status + } catch (err) { + if (err instanceof DaVinciError) { + console.log(err.code, err.type, err.message); + } + } break; } }); 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 75da92397..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 @@ -44,6 +44,7 @@ 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 @@ -586,7 +587,7 @@ internal object RNPingDavinciCommon { pollJobsByDaVinciId[davinciId]?.remove(job) } } - pollJobsByDaVinciId.getOrPut(davinciId) { ConcurrentHashMap.newKeySet() }.add(job) + pollJobsByDaVinciId.computeIfAbsent(davinciId) { ConcurrentHashMap.newKeySet() }.add(job) val result = Arguments.createMap() result.putString("subscriptionId", subscriptionId) @@ -599,11 +600,11 @@ internal object RNPingDavinciCommon { * 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] before emitting, matching the convention in - * `RNPingPushCommon`/`RNPingBindingCommon` — [scope] runs on [Dispatchers.Default] for the - * poll job itself. + * 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 fun emitPollingStatus(davinciId: String, subscriptionId: String, status: PollingStatus) { + private suspend fun emitPollingStatus(davinciId: String, subscriptionId: String, status: PollingStatus) { val emitter = reactContextRef?.get() ?.getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter::class.java) ?: return @@ -630,7 +631,7 @@ internal object RNPingDavinciCommon { params.putMap("error", errorMap) } } - scope.launch(Dispatchers.Main) { emitter.emit(RNPingDavinciEvents.POLLING_STATUS, params) } + withContext(Dispatchers.Main) { emitter.emit(RNPingDavinciEvents.POLLING_STATUS, params) } } // ---- Private helpers ---- 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 77d473d28..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 @@ -47,6 +47,12 @@ internal object 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 } /** @@ -216,7 +222,7 @@ internal object DaVinciNodeMapper { is PhoneNumberCollector -> mapPhoneNumberCollector(collector) is DeviceRegistrationCollector -> mapDeviceRegistrationCollector(collector) is DeviceAuthenticationCollector -> mapDeviceAuthenticationCollector(collector) - is PollingCollector -> mapPollingCollector(collector) + is PollingCollector -> mapPollingCollector(collector, logger) else -> { logWarning( logger, @@ -420,7 +426,9 @@ internal object DaVinciNodeMapper { * @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. + * 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 @@ -428,10 +436,24 @@ internal object DaVinciNodeMapper { * `SharedContext.Keys.pollingRetriesRemaining(...)`, read back in `continueNode`'s `didSet`. * Not fixable from the bridge; file against the native SDK. */ - private fun mapPollingCollector(collector: PollingCollector): Map { + private fun mapPollingCollector(collector: PollingCollector, logger: Logger? = null): Map { val map = baseCollectorMap(collector) - map["pollInterval"] = collector.pollInterval.toIntOrNull() ?: collector.pollInterval - map["pollRetries"] = collector.pollRetries.toIntOrNull() ?: collector.pollRetries + 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 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 0f442a94a..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 @@ -740,7 +740,7 @@ class DaVinciNodeMapperTest { } @Test - fun mapPollingCollectorFallsBackToRawStringWhenPollIntervalIsNonNumeric() { + 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. @@ -760,7 +760,8 @@ class DaVinciNodeMapperTest { val result = DaVinciNodeMapper.mapNodePayload(node) val c = result.asList("collectors")!![0] - assertEquals("not-a-number", c["pollInterval"]) + // 2000 matches native PollingCollector.pollStatus()'s own fallback for an unparseable pollInterval. + assertEquals(2000, c["pollInterval"]) assertEquals(60, c["pollRetries"]) } diff --git a/packages/davinci/ios/RNPingDavinci.mm b/packages/davinci/ios/RNPingDavinci.mm index b094fb30b..d68ac17c6 100644 --- a/packages/davinci/ios/RNPingDavinci.mm +++ b/packages/davinci/ios/RNPingDavinci.mm @@ -50,6 +50,7 @@ - (instancetype)init - (void)dealloc { [[NSNotificationCenter defaultCenter] removeObserver:self]; + RNPingDavinciReleaseEventEmitterOwnership(@"turbo"); } /** @@ -82,6 +83,7 @@ - (RNPingDavinciImpl *)swiftImpl - (void)invalidate { [[NSNotificationCenter defaultCenter] removeObserver:self]; + RNPingDavinciReleaseEventEmitterOwnership(@"turbo"); [[self swiftImpl] invalidate]; } diff --git a/packages/davinci/ios/RNPingDavinciClassic.mm b/packages/davinci/ios/RNPingDavinciClassic.mm index bfad41235..2e937d2af 100644 --- a/packages/davinci/ios/RNPingDavinciClassic.mm +++ b/packages/davinci/ios/RNPingDavinciClassic.mm @@ -62,6 +62,7 @@ - (instancetype)init - (void)dealloc { [[NSNotificationCenter defaultCenter] removeObserver:self]; + RNPingDavinciReleaseEventEmitterOwnership(@"classic"); } /** diff --git a/packages/davinci/ios/RNPingDavinciCommon.swift b/packages/davinci/ios/RNPingDavinciCommon.swift index 323765f56..66417276b 100644 --- a/packages/davinci/ios/RNPingDavinciCommon.swift +++ b/packages/davinci/ios/RNPingDavinciCommon.swift @@ -506,18 +506,14 @@ public final class RNPingDavinciCommon: NSObject { } let subscriptionId = UUID().uuidString - let taskRef = Ref>() let task = Task { for await status in collector.poll() { if Task.isCancelled { break } emitPollingStatus(davinciId: davinciId, subscriptionId: subscriptionId, status: status) } - if let completedTask = taskRef.value { - pollJobStore.remove(davinciId: davinciId, task: completedTask) - } + pollJobStore.remove(davinciId: davinciId, subscriptionId: subscriptionId) } - taskRef.value = task - pollJobStore.register(davinciId: davinciId, task: task) + pollJobStore.register(davinciId: davinciId, subscriptionId: subscriptionId, task: task) promise.resolve(["subscriptionId": subscriptionId] as NSDictionary) } @@ -641,39 +637,52 @@ public final class RNPingDavinciCommon: NSObject { } } -/// Tracks in-flight poll `Task`s 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. +/// 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: Set>]() + 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 before the promise resolves. - func register(davinciId: String, task: Task) { + /// 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() - tasksByDaVinciId[davinciId, default: []].insert(task) - lock.unlock() + 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, task: Task) { + func remove(davinciId: String, subscriptionId: String) { lock.lock() - tasksByDaVinciId[davinciId]?.remove(task) + defer { lock.unlock() } + if tasksByDaVinciId[davinciId]?.removeValue(forKey: subscriptionId) == nil { + completedSubscriptions.insert(subscriptionId) + } if tasksByDaVinciId[davinciId]?.isEmpty == true { tasksByDaVinciId.removeValue(forKey: davinciId) } - lock.unlock() } /// Cancels every tracked poll task for `davinciId`. func cancelAll(for davinciId: String) { lock.lock() - let tasks = tasksByDaVinciId.removeValue(forKey: davinciId) ?? [] + let tasks = tasksByDaVinciId.removeValue(forKey: davinciId).map { Array($0.values) } ?? [] lock.unlock() tasks.forEach { $0.cancel() } } @@ -681,8 +690,9 @@ private final class PollJobStore: @unchecked Sendable { /// Cancels every tracked poll task, across all DaVinci instances. func removeAll() { lock.lock() - let tasks = tasksByDaVinciId.values.flatMap { $0 } + let tasks = tasksByDaVinciId.values.flatMap { $0.values } tasksByDaVinciId.removeAll() + completedSubscriptions.removeAll() lock.unlock() tasks.forEach { $0.cancel() } } diff --git a/packages/davinci/ios/RNPingDavinciEventEmitterGate.h b/packages/davinci/ios/RNPingDavinciEventEmitterGate.h index 7bdd74c2a..124f4962a 100644 --- a/packages/davinci/ios/RNPingDavinciEventEmitterGate.h +++ b/packages/davinci/ios/RNPingDavinciEventEmitterGate.h @@ -16,7 +16,13 @@ NS_ASSUME_NONNULL_BEGIN /// subscribe simultaneously and every polling status event would fire twice /// on the JS side. /// -/// Returns `YES` exactly once for the process lifetime; subsequent callers get `NO`. +/// 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 index fbfa6e5fa..15fe6a35b 100644 --- a/packages/davinci/ios/RNPingDavinciEventEmitterGate.mm +++ b/packages/davinci/ios/RNPingDavinciEventEmitterGate.mm @@ -15,8 +15,11 @@ // // RNPingDavinciClaimEventEmitterOwnership lets whichever module initialises // first claim the single forwarding slot (returns YES). The other module -// receives NO and stays silent. The Swift layer (RNPingDavinciCommon.emitEvent) -// posts to NotificationCenter without caring which arch won. +// 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" @@ -30,17 +33,27 @@ return lock; } +static NSString *_Nullable gRNPingDavinciEventEmitterOwnerId = nil; + BOOL RNPingDavinciClaimEventEmitterOwnership(NSString *ownerId) { - (void)ownerId; NSLock *lock = RNPingDavinciEventEmitterGateLock(); [lock lock]; - static BOOL hasEventEmitterOwner = NO; BOOL didClaim = NO; - if (!hasEventEmitterOwner) { - hasEventEmitterOwner = YES; + 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 index e2adabe1f..1232b9eb1 100644 --- a/packages/davinci/ios/RNPingDavinciEvents.swift +++ b/packages/davinci/ios/RNPingDavinciEvents.swift @@ -10,6 +10,7 @@ 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" } @@ -18,5 +19,6 @@ public class RNPingDavinciEvents: NSObject { /// `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/Tests/RNPingDavinciCommonTests.swift b/packages/davinci/ios/Tests/RNPingDavinciCommonTests.swift index 192bf1498..8328d3966 100644 --- a/packages/davinci/ios/Tests/RNPingDavinciCommonTests.swift +++ b/packages/davinci/ios/Tests/RNPingDavinciCommonTests.swift @@ -784,6 +784,7 @@ final class RNPingDavinciCommonTests: XCTestCase { let observer = EventObserver() let firstEventExpectation = expectation(description: "first event received") + firstEventExpectation.assertForOverFulfill = false observer.onEvent = { _ in firstEventExpectation.fulfill() } let resolveExpectation = expectation(description: "poll resolve") @@ -835,6 +836,7 @@ final class RNPingDavinciCommonTests: XCTestCase { let observer = EventObserver() let firstEventExpectation = expectation(description: "first event received") + firstEventExpectation.assertForOverFulfill = false observer.onEvent = { _ in firstEventExpectation.fulfill() } let resolveExpectation = expectation(description: "poll resolve") diff --git a/packages/davinci/src/__tests__/createDaVinciClient.test.ts b/packages/davinci/src/__tests__/createDaVinciClient.test.ts index cb5d5af8b..516821903 100644 --- a/packages/davinci/src/__tests__/createDaVinciClient.test.ts +++ b/packages/davinci/src/__tests__/createDaVinciClient.test.ts @@ -829,6 +829,42 @@ describe('createDaVinciClient — pollStatus', () => { }); }); + it('delivers a tick emitted before pollDaVinci resolves its subscriptionId', async () => { + // Reproduces the native race where the polling task (iOS's unstructured + // `Task`, or a poorly-sequenced Android job) emits its first tick before + // the bridge promise carrying `subscriptionId` settles. Emitting inside + // the mocked native call, before it returns, models "event message + // ordered ahead of the resolve message" — the scenario a fixed + // pollStatus must not drop. + const native = createNativeMock({ + pollDaVinci: jest.fn(async () => { + // eslint-disable-next-line @typescript-eslint/no-require-imports + const { DeviceEventEmitter } = require('react-native'); + // eslint-disable-next-line @typescript-eslint/no-require-imports + const { DaVinciEvents } = require('../events'); + DeviceEventEmitter.emit(DaVinciEvents.POLLING_STATUS, { + subscriptionId: 'sub-1', + status: 'continue', + retryCount: 1, + maxRetries: 10, + }); + return { subscriptionId: 'sub-1' }; + }), + }); + const { createDaVinciClient } = loadModule(native); + + const client = createDaVinciClient(VALID_CONFIG); + const onStatus = jest.fn(); + await client.pollStatus(onStatus); + + expect(onStatus).toHaveBeenCalledWith({ + subscriptionId: 'sub-1', + status: 'continue', + retryCount: 1, + maxRetries: 10, + }); + }); + it('forwards the key option to the bridge', async () => { const native = createNativeMock(); const { createDaVinciClient } = loadModule(native); diff --git a/packages/davinci/src/davinci.ts b/packages/davinci/src/davinci.ts index 62555ad0f..7a16789d1 100644 --- a/packages/davinci/src/davinci.ts +++ b/packages/davinci/src/davinci.ts @@ -404,13 +404,17 @@ export function createDaVinciClient(config: DaVinciConfig): DaVinciClient { * Streams `PollingStatus` updates for the active `PollingCollector`. * * @remarks - * Resolves the bridge's early-resolving `{ subscriptionId }` promise, then - * — synchronously in that same continuation — subscribes to the shared - * `DeviceEventEmitter` channel filtered by `subscriptionId`, so there is no - * window where a tick can arrive before the listener is attached. The - * listener removes itself on any terminal status (`complete`, `timedOut`, - * `expired`, `error`). Does not call `next()` — the caller must advance - * the flow explicitly on a terminal status. + * Subscribes to the shared `DeviceEventEmitter` channel *before* calling + * the bridge, buffering any ticks that arrive while `subscriptionId` is + * still unknown. Neither native side guarantees that its polling task + * starts only after the `{ subscriptionId }` promise resolves — iOS + * schedules the poll on an unstructured `Task` that can emit a tick + * before `pollDaVinci`'s promise settles — so subscribing first and + * replaying buffered ticks once `subscriptionId` is known is the only + * way to guarantee no tick is dropped. The listener removes itself on + * any terminal status (`complete`, `timedOut`, `expired`, `error`). + * Does not call `next()` — the caller must advance the flow explicitly + * on a terminal status. * * The returned unsubscribe function stops **local event delivery only** — * neither native SDK exposes a primitive to cancel an in-flight poll, so @@ -429,28 +433,44 @@ export function createDaVinciClient(config: DaVinciConfig): DaVinciClient { ) { const id = await ensureConfigured(); logDebug('DaVinci pollStatus requested', { davinciId: id }); - let subscriptionId: string; - try { - subscriptionId = await pollDaVinci(id, options); - } catch (error) { - logError('DaVinci pollStatus failed', error, { davinciId: id }); - throw error; - } + + let subscriptionId: string | undefined; + const buffered: Record[] = []; + + const deliver = (event: Record) => { + const status = event as unknown as PollingStatus; + if (status.status !== 'continue') { + subscription.remove(); + } + onStatus(status); + }; const subscription = DeviceEventEmitter.addListener( DaVinciEvents.POLLING_STATUS, (event: Record) => { - if (event.subscriptionId !== subscriptionId) { + if (subscriptionId === undefined) { + buffered.push(event); return; } - const status = event as unknown as PollingStatus; - if (status.status !== 'continue') { - subscription.remove(); + if (event.subscriptionId !== subscriptionId) { + return; } - onStatus(status); + deliver(event); }, ); + try { + subscriptionId = await pollDaVinci(id, options); + } catch (error) { + subscription.remove(); + logError('DaVinci pollStatus failed', error, { davinciId: id }); + throw error; + } + + buffered + .filter((event) => event.subscriptionId === subscriptionId) + .forEach(deliver); + logInfo('DaVinci pollStatus succeeded', { davinciId: id, subscriptionId, diff --git a/packages/davinci/src/index.tsx b/packages/davinci/src/index.tsx index d55525c1b..d891cd34b 100644 --- a/packages/davinci/src/index.tsx +++ b/packages/davinci/src/index.tsx @@ -39,10 +39,6 @@ export type { * DaVinci error class. */ export { DaVinciError } from './types/error.types'; -/** - * Internal DeviceEventEmitter event names used by the RNPingDavinci bridge. - */ -export { DaVinciEvents } from './events'; /** * All DaVinci public type contracts. */ diff --git a/packages/davinci/src/useDavinci.tsx b/packages/davinci/src/useDavinci.tsx index dd674a1de..b2caf257b 100644 --- a/packages/davinci/src/useDavinci.tsx +++ b/packages/davinci/src/useDavinci.tsx @@ -87,8 +87,8 @@ export type DaVinciHookActions = { * * @param onStatus - Callback invoked with each streamed status tick. * @param options - Optional collector selection. - * @returns An unsubscribe function that removes the listener and cancels - * the native poll if still active. + * @returns An unsubscribe function that removes the local status listener + * only; it does not cancel the active native poll. * @throws {DaVinciError} When no active `PollingCollector` is resolved. */ pollStatus: ( From 85e9bf47aa5a499cb75340d92b6c757356ede359 Mon Sep 17 00:00:00 2001 From: Thais Damasceno Date: Thu, 13 Aug 2026 11:11:43 -0700 Subject: [PATCH 5/8] docs(rn-oidc,rn-browser): flag pending native SDK bug fixes (SDKS-5295, SDKS-5296). Add NOTE comments above the affected bridge code so we remember to revisit once upstream ships fixes --- packages/browser/android/build.gradle | 6 ++++++ packages/oidc/android/build.gradle | 6 ++++++ packages/oidc/ios/RNPingOidcCommon.swift | 11 +++++++++++ 3 files changed, 23 insertions(+) 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/oidc/android/build.gradle b/packages/oidc/android/build.gradle index f76270e65..772b6aa5c 100644 --- a/packages/oidc/android/build.gradle +++ b/packages/oidc/android/build.gradle @@ -101,6 +101,12 @@ dependencies { implementation "org.jetbrains.kotlin:kotlin-stdlib:$kotlin_version" implementation("com.pingidentity.sdks:android:2.1.0") implementation("com.pingidentity.sdks:oidc:2.1.0") + // 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:orchestrate:2.1.0") implementation("com.pingidentity.sdks:storage:2.1.0") diff --git a/packages/oidc/ios/RNPingOidcCommon.swift b/packages/oidc/ios/RNPingOidcCommon.swift index 2e7960beb..9adec6aec 100644 --- a/packages/oidc/ios/RNPingOidcCommon.swift +++ b/packages/oidc/ios/RNPingOidcCommon.swift @@ -358,6 +358,17 @@ public class RNPingOidcCommon: NSObject { return } + // NOTE(SDKS-5295): PingOidc/PingBrowser 2.1.0's OidcWebClient.authorize() + // collapses every FailureNode.cause (including browser cancellation) into + // OidcError.unknown(message:) before returning it via `result`, discarding + // the typed BrowserError/ASWebAuthenticationSessionError. Because + // handle.web.authorize returns a Result rather than throwing on + // cancellation, the `catch let error as BrowserError` / + // `catch let error as ASWebAuthenticationSessionError` blocks below are + // dead code in the normal cancel flow — they only fire if authorize() + // itself throws before start() runs. Once the upstream fix preserves the + // typed cause, revisit whether cancellation detection should move into + // the `.failure(let error)` branch above instead. do { let result = try await handle.web.authorize { config in config.additionalParameters = params From dd2735582183064522fc59628240ff3f5420d41a Mon Sep 17 00:00:00 2001 From: Thais Damasceno Date: Mon, 17 Aug 2026 10:59:16 -0700 Subject: [PATCH 6/8] chore: address pr review comments - part 2 --- .../integration/davinci-polling.test.ts | 4 -- packages/davinci/README.md | 63 ------------------- .../src/__tests__/createDaVinciClient.test.ts | 3 - packages/davinci/src/davinci.ts | 6 +- 4 files changed, 4 insertions(+), 72 deletions(-) diff --git a/PingTestRunner/__tests__/integration/davinci-polling.test.ts b/PingTestRunner/__tests__/integration/davinci-polling.test.ts index 126431db4..1541f5065 100644 --- a/PingTestRunner/__tests__/integration/davinci-polling.test.ts +++ b/PingTestRunner/__tests__/integration/davinci-polling.test.ts @@ -186,7 +186,6 @@ describe('@ping-identity/rn-davinci — polling integration', () => { expect(mock.pollDaVinci).toHaveBeenCalledWith('davinci-id-mock', {}); expect(onStatus).toHaveBeenNthCalledWith(1, { - subscriptionId: 'sub-1', status: 'continue', retryCount: 0, maxRetries: 60, @@ -207,7 +206,6 @@ describe('@ping-identity/rn-davinci — polling integration', () => { expect(onStatus).toHaveBeenCalledTimes(3); expect(onStatus).toHaveBeenNthCalledWith(2, { - subscriptionId: 'sub-1', status: 'continue', retryCount: 1, maxRetries: 60, @@ -237,7 +235,6 @@ describe('@ping-identity/rn-davinci — polling integration', () => { expect(onStatus).toHaveBeenCalledTimes(1); expect(onStatus).toHaveBeenCalledWith({ - subscriptionId: 'sub-1', status: 'complete', value: 'ok', }); @@ -377,7 +374,6 @@ describe('@ping-identity/rn-davinci — polling integration', () => { expect(onStatus).toHaveBeenCalledTimes(2); expect(onStatus).toHaveBeenLastCalledWith({ - subscriptionId: 'sub-1', status: 'expired', }); }); diff --git a/packages/davinci/README.md b/packages/davinci/README.md index 86db7338e..31317b005 100644 --- a/packages/davinci/README.md +++ b/packages/davinci/README.md @@ -304,69 +304,6 @@ Integration-dependent collectors (for example, social IdP, FIDO, or PingOne Prot surfaced in node payloads and require client-side integration before submission (`executionMode: 'integration_required'`). -### Polling and QR code flows - -`POLLING` and `QR_CODE` collectors support out-of-band authentication (push approval, QR -scan, email verification) where the flow waits for user action on another device or -channel. Both are display/output-only — neither participates in form submission. - -A `ContinueNode` commonly carries a `QR_CODE` collector (rendered for the user to scan) -alongside a `POLLING` collector (which reports when the out-of-band action completes): - -```ts -import { DaVinciError } from '@ping-identity/rn-davinci'; - -const node = await client.start(); - -if (node.type === 'ContinueNode') { - const qr = node.collectors.find((c) => c.type === 'QR_CODE'); - if (qr) { - // Render `qr.content` (a full data URI, e.g. "data:image/png;base64,...") - // as an or similar. - } - - const unsubscribe = await client.pollStatus(async (status) => { - switch (status.status) { - case 'continue': - console.log( - `Waiting… attempt ${status.retryCount}/${status.maxRetries}`, - ); - break; - case 'timedOut': - case 'expired': - case 'error': - // Surface the failure to the user before progressing past the node. - console.warn(`Poll ended with status: ${status.status}`); - // eslint-disable-next-line no-fallthrough - case 'complete': - try { - await client.next({ collectors: [] }); // required on every terminal status - } catch (err) { - if (err instanceof DaVinciError) { - console.log(err.code, err.type, err.message); - } - } - break; - } - }); - - // Later, e.g. on screen unmount: - // unsubscribe(); -} -``` - -- `client.pollStatus(onStatus, options?)` resolves the active `PollingCollector` on the - current node (pass `options.key` to disambiguate when a node has more than one), - starts streaming native polling ticks, and returns an `unsubscribe` function. -- `pollStatus` does **not** auto-advance the flow — call `next()` explicitly on any - terminal status (`complete`, `timedOut`, `expired`, `error`) to progress past it. -- Calling the returned `unsubscribe()` stops **local event delivery only**. Neither - native SDK exposes a primitive to cancel an in-flight poll, so the native poll keeps - running to completion (bounded by `pollRetries` × `pollInterval`) even after - unsubscribing. -- `pollInterval` and `pollRetries` on the `PollingCollector` payload are normalized to - `number` on both platforms. - ### Unsupported fields When the native SDK cannot instantiate a collector from the server payload, the bridge surfaces it in `ContinueNode.unsupportedFields`: diff --git a/packages/davinci/src/__tests__/createDaVinciClient.test.ts b/packages/davinci/src/__tests__/createDaVinciClient.test.ts index 516821903..6fab23e57 100644 --- a/packages/davinci/src/__tests__/createDaVinciClient.test.ts +++ b/packages/davinci/src/__tests__/createDaVinciClient.test.ts @@ -822,7 +822,6 @@ describe('createDaVinciClient — pollStatus', () => { }); expect(onStatus).toHaveBeenCalledWith({ - subscriptionId: 'sub-1', status: 'continue', retryCount: 1, maxRetries: 10, @@ -858,7 +857,6 @@ describe('createDaVinciClient — pollStatus', () => { await client.pollStatus(onStatus); expect(onStatus).toHaveBeenCalledWith({ - subscriptionId: 'sub-1', status: 'continue', retryCount: 1, maxRetries: 10, @@ -925,7 +923,6 @@ describe('createDaVinciClient — pollStatus', () => { expect(onStatus).toHaveBeenCalledTimes(1); expect(onStatus).toHaveBeenCalledWith({ - subscriptionId: 'sub-1', status: 'complete', value: 'success', }); diff --git a/packages/davinci/src/davinci.ts b/packages/davinci/src/davinci.ts index 7a16789d1..0f9f427dd 100644 --- a/packages/davinci/src/davinci.ts +++ b/packages/davinci/src/davinci.ts @@ -438,11 +438,13 @@ export function createDaVinciClient(config: DaVinciConfig): DaVinciClient { const buffered: Record[] = []; const deliver = (event: Record) => { - const status = event as unknown as PollingStatus; + const status = { ...event }; + delete status.subscriptionId; + delete status.daVinciId; if (status.status !== 'continue') { subscription.remove(); } - onStatus(status); + onStatus(status as PollingStatus); }; const subscription = DeviceEventEmitter.addListener( From 3ff914d21336f27a0c6483e68c4bfc45c6e3a60e Mon Sep 17 00:00:00 2001 From: Thais Damasceno Date: Tue, 18 Aug 2026 16:13:42 -0700 Subject: [PATCH 7/8] fix(rn-davinci): address davinci poll flaky test; cancel fake polling producers on stream termination (SDKS-5130) --- .../ios/Tests/RNPingDavinciCommonTests.swift | 28 ++++++++++++++++--- 1 file changed, 24 insertions(+), 4 deletions(-) diff --git a/packages/davinci/ios/Tests/RNPingDavinciCommonTests.swift b/packages/davinci/ios/Tests/RNPingDavinciCommonTests.swift index 8328d3966..533efeb99 100644 --- a/packages/davinci/ios/Tests/RNPingDavinciCommonTests.swift +++ b/packages/davinci/ios/Tests/RNPingDavinciCommonTests.swift @@ -1061,19 +1061,39 @@ private final class FakePollingCollector: Collector, PollableCollector, @uncheck _pollWasCalled = true lock.unlock() return AsyncStream { continuation in - Task { + let producerTask = Task { [ + statuses, + delayNanoseconds, + delayBetweenEmissionsNanoseconds + ] in if delayNanoseconds > 0 { - try? await Task.sleep(nanoseconds: delayNanoseconds) + do { + try await Task.sleep(nanoseconds: delayNanoseconds) + } catch { + continuation.finish() + return + } } for status in statuses { - if Task.isCancelled { break } + if Task.isCancelled { + continuation.finish() + return + } continuation.yield(status) if delayBetweenEmissionsNanoseconds > 0 { - try? await Task.sleep(nanoseconds: delayBetweenEmissionsNanoseconds) + do { + try await Task.sleep(nanoseconds: delayBetweenEmissionsNanoseconds) + } catch { + continuation.finish() + return + } } } continuation.finish() } + continuation.onTermination = { @Sendable _ in + producerTask.cancel() + } } } } From 9e6e3c194b5e40e4fd3c8297ba5ec0b22913023e Mon Sep 17 00:00:00 2001 From: Thais Damasceno Date: Tue, 18 Aug 2026 19:18:21 -0700 Subject: [PATCH 8/8] fix(ci): remove parallel testing in iOS for davinci testing --- .../xcshareddata/xcschemes/RNPackagesTests.xcscheme | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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">