@myazahq/kyc-sdk-react-native 2.0.2 → 2.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (281) hide show
  1. package/README.md +111 -8
  2. package/android/build.gradle +6 -0
  3. package/android/src/main/AndroidManifest.xml +14 -4
  4. package/android/src/main/java/com/margelo/nitro/myazakyc/HybridMyazaEmrtd.kt +358 -0
  5. package/android/src/main/java/com/margelo/nitro/myazakyc/HybridMyazaFaceDetector.kt +88 -1
  6. package/android/src/main/java/com/margelo/nitro/myazakyc/HybridMyazaRectDetector.kt +34 -0
  7. package/android/src/main/java/com/margelo/nitro/myazakyc/HybridMyazaTextRecognizer.kt +118 -0
  8. package/android/src/main/java/com/margelo/nitro/myazakyc/Jp2Decoder.kt +117 -0
  9. package/app.plugin.js +102 -13
  10. package/ios/HybridMyazaEmrtd.swift +447 -0
  11. package/ios/HybridMyazaFaceDetector.swift +104 -2
  12. package/ios/HybridMyazaRectDetector.swift +103 -0
  13. package/ios/HybridMyazaTextRecognizer.swift +111 -0
  14. package/nitrogen/generated/.gitattributes +1 -0
  15. package/nitrogen/generated/android/KycSdkReactNative+autolinking.cmake +87 -0
  16. package/nitrogen/generated/android/KycSdkReactNative+autolinking.gradle +27 -0
  17. package/nitrogen/generated/android/KycSdkReactNativeOnLoad.cpp +102 -0
  18. package/nitrogen/generated/android/KycSdkReactNativeOnLoad.hpp +34 -0
  19. package/nitrogen/generated/android/c++/JDetectedRect.hpp +81 -0
  20. package/nitrogen/generated/android/c++/JEmrtdApduResponse.hpp +61 -0
  21. package/nitrogen/generated/android/c++/JEmrtdTagInfo.hpp +61 -0
  22. package/nitrogen/generated/android/c++/JFaceResult.hpp +113 -0
  23. package/nitrogen/generated/android/c++/JHybridMyazaEmrtdSpec.cpp +176 -0
  24. package/nitrogen/generated/android/c++/JHybridMyazaEmrtdSpec.hpp +75 -0
  25. package/nitrogen/generated/android/c++/JHybridMyazaFaceDetectorSpec.cpp +60 -0
  26. package/nitrogen/generated/android/c++/JHybridMyazaFaceDetectorSpec.hpp +63 -0
  27. package/nitrogen/generated/android/c++/JHybridMyazaRectDetectorSpec.cpp +60 -0
  28. package/nitrogen/generated/android/c++/JHybridMyazaRectDetectorSpec.hpp +63 -0
  29. package/nitrogen/generated/android/c++/JHybridMyazaTextRecognizerSpec.cpp +80 -0
  30. package/nitrogen/generated/android/c++/JHybridMyazaTextRecognizerSpec.hpp +64 -0
  31. package/nitrogen/generated/android/c++/JTextResult.hpp +76 -0
  32. package/nitrogen/generated/android/kotlin/com/margelo/nitro/myazakyc/DetectedRect.kt +81 -0
  33. package/nitrogen/generated/android/kotlin/com/margelo/nitro/myazakyc/EmrtdApduResponse.kt +56 -0
  34. package/nitrogen/generated/android/kotlin/com/margelo/nitro/myazakyc/EmrtdTagInfo.kt +56 -0
  35. package/nitrogen/generated/android/kotlin/com/margelo/nitro/myazakyc/FaceResult.kt +121 -0
  36. package/nitrogen/generated/android/kotlin/com/margelo/nitro/myazakyc/HybridMyazaEmrtdSpec.kt +103 -0
  37. package/nitrogen/generated/android/kotlin/com/margelo/nitro/myazakyc/HybridMyazaFaceDetectorSpec.kt +55 -0
  38. package/nitrogen/generated/android/kotlin/com/margelo/nitro/myazakyc/HybridMyazaRectDetectorSpec.kt +55 -0
  39. package/nitrogen/generated/android/kotlin/com/margelo/nitro/myazakyc/HybridMyazaTextRecognizerSpec.kt +60 -0
  40. package/nitrogen/generated/android/kotlin/com/margelo/nitro/myazakyc/KycSdkReactNativeOnLoad.kt +35 -0
  41. package/nitrogen/generated/android/kotlin/com/margelo/nitro/myazakyc/TextResult.kt +51 -0
  42. package/nitrogen/generated/ios/KycSdkReactNative+autolinking.rb +62 -0
  43. package/nitrogen/generated/ios/KycSdkReactNative-Swift-Cxx-Bridge.cpp +143 -0
  44. package/nitrogen/generated/ios/KycSdkReactNative-Swift-Cxx-Bridge.hpp +423 -0
  45. package/nitrogen/generated/ios/KycSdkReactNative-Swift-Cxx-Umbrella.hpp +81 -0
  46. package/nitrogen/generated/ios/KycSdkReactNativeAutolinking.mm +57 -0
  47. package/nitrogen/generated/ios/KycSdkReactNativeAutolinking.swift +62 -0
  48. package/nitrogen/generated/ios/c++/HybridMyazaEmrtdSpecSwift.cpp +11 -0
  49. package/nitrogen/generated/ios/c++/HybridMyazaEmrtdSpecSwift.hpp +184 -0
  50. package/nitrogen/generated/ios/c++/HybridMyazaFaceDetectorSpecSwift.cpp +11 -0
  51. package/nitrogen/generated/ios/c++/HybridMyazaFaceDetectorSpecSwift.hpp +87 -0
  52. package/nitrogen/generated/ios/c++/HybridMyazaRectDetectorSpecSwift.cpp +11 -0
  53. package/nitrogen/generated/ios/c++/HybridMyazaRectDetectorSpecSwift.hpp +87 -0
  54. package/nitrogen/generated/ios/c++/HybridMyazaTextRecognizerSpecSwift.cpp +11 -0
  55. package/nitrogen/generated/ios/c++/HybridMyazaTextRecognizerSpecSwift.hpp +98 -0
  56. package/nitrogen/generated/ios/swift/DetectedRect.swift +59 -0
  57. package/nitrogen/generated/ios/swift/EmrtdApduResponse.swift +34 -0
  58. package/nitrogen/generated/ios/swift/EmrtdTagInfo.swift +34 -0
  59. package/nitrogen/generated/ios/swift/FaceResult.swift +99 -0
  60. package/nitrogen/generated/ios/swift/Func_void.swift +46 -0
  61. package/nitrogen/generated/ios/swift/Func_void_EmrtdApduResponse.swift +46 -0
  62. package/nitrogen/generated/ios/swift/Func_void_EmrtdTagInfo.swift +46 -0
  63. package/nitrogen/generated/ios/swift/Func_void_TextResult.swift +46 -0
  64. package/nitrogen/generated/ios/swift/Func_void_std__exception_ptr.swift +46 -0
  65. package/nitrogen/generated/ios/swift/Func_void_std__string.swift +46 -0
  66. package/nitrogen/generated/ios/swift/HybridMyazaEmrtdSpec.swift +67 -0
  67. package/nitrogen/generated/ios/swift/HybridMyazaEmrtdSpec_cxx.swift +317 -0
  68. package/nitrogen/generated/ios/swift/HybridMyazaFaceDetectorSpec.swift +56 -0
  69. package/nitrogen/generated/ios/swift/HybridMyazaFaceDetectorSpec_cxx.swift +143 -0
  70. package/nitrogen/generated/ios/swift/HybridMyazaRectDetectorSpec.swift +56 -0
  71. package/nitrogen/generated/ios/swift/HybridMyazaRectDetectorSpec_cxx.swift +143 -0
  72. package/nitrogen/generated/ios/swift/HybridMyazaTextRecognizerSpec.swift +57 -0
  73. package/nitrogen/generated/ios/swift/HybridMyazaTextRecognizerSpec_cxx.swift +162 -0
  74. package/nitrogen/generated/ios/swift/TextResult.swift +35 -0
  75. package/nitrogen/generated/shared/c++/DetectedRect.hpp +107 -0
  76. package/nitrogen/generated/shared/c++/EmrtdApduResponse.hpp +87 -0
  77. package/nitrogen/generated/shared/c++/EmrtdTagInfo.hpp +87 -0
  78. package/nitrogen/generated/shared/c++/FaceResult.hpp +139 -0
  79. package/nitrogen/generated/shared/c++/HybridMyazaEmrtdSpec.cpp +33 -0
  80. package/nitrogen/generated/shared/c++/HybridMyazaEmrtdSpec.hpp +80 -0
  81. package/nitrogen/generated/shared/c++/HybridMyazaFaceDetectorSpec.cpp +21 -0
  82. package/nitrogen/generated/shared/c++/HybridMyazaFaceDetectorSpec.hpp +67 -0
  83. package/nitrogen/generated/shared/c++/HybridMyazaRectDetectorSpec.cpp +21 -0
  84. package/nitrogen/generated/shared/c++/HybridMyazaRectDetectorSpec.hpp +67 -0
  85. package/nitrogen/generated/shared/c++/HybridMyazaTextRecognizerSpec.cpp +22 -0
  86. package/nitrogen/generated/shared/c++/HybridMyazaTextRecognizerSpec.hpp +70 -0
  87. package/nitrogen/generated/shared/c++/TextResult.hpp +84 -0
  88. package/package.json +16 -4
  89. package/src/MyazaKYC.tsx +115 -22
  90. package/src/capture/documentIdentity.ts +153 -0
  91. package/src/capture/documentSignals.ts +177 -0
  92. package/src/capture/documentTextGate.ts +171 -0
  93. package/src/capture/hints.ts +57 -0
  94. package/src/capture/index.ts +24 -0
  95. package/src/capture/rectDetector.ts +84 -0
  96. package/src/capture/rectGate.ts +91 -0
  97. package/src/capture/useAutoCapture.ts +198 -0
  98. package/src/components/CameraViewfinder.tsx +226 -70
  99. package/src/components/ContactCodeEntry.tsx +139 -0
  100. package/src/components/CountryField.tsx +87 -0
  101. package/src/components/CountryFlag.tsx +15 -15
  102. package/src/components/DialCodePicker.tsx +186 -0
  103. package/src/components/DocumentCropper.tsx +1 -1
  104. package/src/components/DocumentReview.tsx +142 -0
  105. package/src/components/DocumentReviewSide.tsx +135 -0
  106. package/src/components/DocumentReviewZoom.tsx +119 -0
  107. package/src/components/ExpiryCountdown.tsx +52 -0
  108. package/src/components/FlashOverlay.tsx +156 -0
  109. package/src/components/Icon.tsx +67 -1
  110. package/src/components/KycFlow.tsx +66 -94
  111. package/src/components/KycSheet.tsx +148 -18
  112. package/src/components/MediaSourceSheet.tsx +163 -0
  113. package/src/components/MyazaButton.tsx +14 -2
  114. package/src/components/MyazaDateField.tsx +240 -0
  115. package/src/components/MyazaInput.tsx +73 -26
  116. package/src/components/MyazaSelect.tsx +283 -0
  117. package/src/components/MyazaWordmark.tsx +33 -0
  118. package/src/components/OptionRow.tsx +132 -0
  119. package/src/components/PhoneNumberInput.tsx +149 -0
  120. package/src/components/PoweredBy.tsx +89 -0
  121. package/src/components/ReadyPrimer.tsx +149 -0
  122. package/src/components/StepView.tsx +91 -0
  123. package/src/components/Typography.tsx +18 -3
  124. package/src/components/VerifiedNotice.tsx +38 -0
  125. package/src/components/WhatsAppIcon.tsx +29 -0
  126. package/src/components/WorkflowGate.tsx +128 -0
  127. package/src/components/brand-font.ts +113 -0
  128. package/src/components/flashHoleGeometry.ts +35 -0
  129. package/src/components/flow/FatalConfigError.tsx +56 -0
  130. package/src/components/fonts.ts +2 -19
  131. package/src/components/readyPrimerContent.ts +52 -0
  132. package/src/components/runtime.tsx +38 -43
  133. package/src/components/stepHeaderMeta.tsx +87 -0
  134. package/src/components/theme-provider.tsx +89 -0
  135. package/src/components/useWorkflowMount.ts +116 -0
  136. package/src/components/viewfinder/CardGuide.tsx +60 -0
  137. package/src/components/viewfinder/DocumentGhost.tsx +149 -0
  138. package/src/components/viewfinder/ImmersiveBottomBar.tsx +172 -0
  139. package/src/components/viewfinder/ImmersiveControls.tsx +127 -0
  140. package/src/components/viewfinder/ImmersiveGuide.tsx +174 -0
  141. package/src/components/viewfinder/ImmersiveOverlay.tsx +79 -0
  142. package/src/components/viewfinder/ViewfinderControls.tsx +154 -0
  143. package/src/config/brand.ts +97 -0
  144. package/src/config/business.ts +179 -0
  145. package/src/config/businessSteps.ts +105 -0
  146. package/src/config/contact.ts +159 -0
  147. package/src/config/countryNames.g.ts +254 -0
  148. package/src/config/currencyFlags.ts +144 -0
  149. package/src/config/font-resolve.ts +78 -0
  150. package/src/config/idTypes.ts +96 -1
  151. package/src/config/keyPeople.ts +152 -0
  152. package/src/config/phone.ts +39 -0
  153. package/src/config/proofOfAddress.ts +75 -0
  154. package/src/config/questionnaire.ts +153 -0
  155. package/src/config/regions.ts +118 -0
  156. package/src/config/registrationHint.ts +87 -0
  157. package/src/config/stepOrder.ts +118 -0
  158. package/src/config/theme.ts +38 -2
  159. package/src/config/workflowMerge.ts +124 -0
  160. package/src/emrtd/bac.ts +128 -0
  161. package/src/emrtd/bytes.ts +127 -0
  162. package/src/emrtd/crypto.ts +163 -0
  163. package/src/emrtd/der.ts +192 -0
  164. package/src/emrtd/dg2.ts +111 -0
  165. package/src/emrtd/files.ts +215 -0
  166. package/src/emrtd/index.ts +30 -0
  167. package/src/emrtd/mrzKey.ts +54 -0
  168. package/src/emrtd/native.ts +79 -0
  169. package/src/emrtd/optionalRead.ts +42 -0
  170. package/src/emrtd/read.ts +224 -0
  171. package/src/emrtd/retry.ts +98 -0
  172. package/src/emrtd/secureMessaging.ts +178 -0
  173. package/src/emrtd/session.ts +200 -0
  174. package/src/emrtd/stages.ts +122 -0
  175. package/src/index.ts +110 -0
  176. package/src/lib/calendar.ts +71 -0
  177. package/src/lib/use-keyboard-inset.ts +34 -0
  178. package/src/liveness/challengeManager.ts +27 -1
  179. package/src/liveness/faceContinuity.ts +153 -0
  180. package/src/liveness/flashDetector.ts +145 -0
  181. package/src/liveness/flashReadyGate.ts +106 -0
  182. package/src/liveness/flashRunner.ts +118 -0
  183. package/src/liveness/integritySignals.ts +60 -0
  184. package/src/liveness/livenessState.ts +139 -0
  185. package/src/liveness/speech.ts +19 -1
  186. package/src/liveness/types.ts +48 -1
  187. package/src/liveness/useFlashPhase.ts +78 -0
  188. package/src/liveness/useLiveness.ts +260 -75
  189. package/src/liveness/visionCameraFaceDetector.ts +14 -0
  190. package/src/mrz/extract.ts +101 -0
  191. package/src/mrz/parse.ts +178 -0
  192. package/src/mrz/textRecognizer.ts +149 -0
  193. package/src/screens/ApplicantRoleStep.tsx +331 -0
  194. package/src/screens/BusinessDetailsStep.tsx +212 -0
  195. package/src/screens/BusinessDocumentSlot.tsx +167 -0
  196. package/src/screens/BusinessDocumentsStep.tsx +213 -0
  197. package/src/screens/BusinessKeyPeopleStep.tsx +151 -0
  198. package/src/screens/BusinessKeyPersonRow.tsx +170 -0
  199. package/src/screens/CompanyInfoFields.tsx +83 -0
  200. package/src/screens/ConsentStep.tsx +68 -68
  201. package/src/screens/ContactChannelChoice.tsx +133 -0
  202. package/src/screens/ContactCodeStep.tsx +130 -0
  203. package/src/screens/ContactDestinationField.tsx +70 -0
  204. package/src/screens/ContactFooterNote.tsx +34 -0
  205. package/src/screens/ContactVerificationStep.tsx +199 -0
  206. package/src/screens/ContactVerifiedPanel.tsx +36 -0
  207. package/src/screens/CountrySelectStep.tsx +210 -0
  208. package/src/screens/DocumentCaptureStep.tsx +156 -132
  209. package/src/screens/IdInputStep.tsx +13 -5
  210. package/src/screens/IdTypeStep.tsx +74 -33
  211. package/src/screens/KeyPeopleInviteLinks.tsx +239 -0
  212. package/src/screens/LivenessStep.tsx +155 -247
  213. package/src/screens/MrzScanView.tsx +173 -0
  214. package/src/screens/NfcStep.tsx +290 -0
  215. package/src/screens/ProofOfAddressStep.tsx +238 -0
  216. package/src/screens/QuestionnaireField.tsx +219 -0
  217. package/src/screens/QuestionnaireMoneyField.tsx +134 -0
  218. package/src/screens/QuestionnaireStep.tsx +76 -0
  219. package/src/screens/SubmittedStep.tsx +5 -0
  220. package/src/screens/consent/model.ts +109 -0
  221. package/src/screens/document/CameraPhase.tsx +150 -0
  222. package/src/screens/document/RequiredPill.tsx +60 -0
  223. package/src/screens/liveness/LightingBanner.tsx +50 -0
  224. package/src/screens/liveness/LivenessOutcome.tsx +109 -0
  225. package/src/screens/liveness/ProgressDots.tsx +67 -0
  226. package/src/screens/liveness/SelfiePreview.tsx +39 -0
  227. package/src/screens/liveness/constants.ts +31 -0
  228. package/src/screens/liveness/index.ts +8 -0
  229. package/src/screens/liveness/resolveGuidance.ts +27 -0
  230. package/src/screens/liveness/useSelfieUpload.ts +107 -0
  231. package/src/screens/nfc/NfcMrzPrompt.tsx +40 -0
  232. package/src/screens/nfc/NfcReadBody.tsx +63 -0
  233. package/src/screens/nfc/NfcReadProgress.tsx +96 -0
  234. package/src/screens/nfc/NfcScanIllustration.tsx +244 -0
  235. package/src/screens/nfc/NfcScannedSummary.tsx +74 -0
  236. package/src/screens/nfc/NfcSuccessPanel.tsx +162 -0
  237. package/src/screens/nfc/availability.ts +73 -0
  238. package/src/screens/nfc/readErrorMessage.ts +93 -0
  239. package/src/screens/nfc/useChipPortrait.ts +58 -0
  240. package/src/screens/nfc/useNfcAvailability.ts +82 -0
  241. package/src/screens/useFlashHole.ts +105 -0
  242. package/src/screens/useFlashSequence.ts +96 -0
  243. package/src/screens/usePoaAttach.ts +121 -0
  244. package/src/services/api-types.ts +164 -0
  245. package/src/services/api-verify-types.ts +99 -0
  246. package/src/services/api.ts +72 -102
  247. package/src/services/cardCrop.ts +25 -10
  248. package/src/services/contactErrors.ts +43 -0
  249. package/src/services/deviceMetadata.ts +1 -1
  250. package/src/services/documentPicker.ts +20 -0
  251. package/src/services/fingerprint-sources.ts +129 -0
  252. package/src/services/fingerprint.ts +101 -0
  253. package/src/services/haptics.ts +38 -0
  254. package/src/services/mediaCompress.ts +7 -2
  255. package/src/services/resolveUrl.ts +2 -2
  256. package/src/services/workflowGate.ts +138 -0
  257. package/src/specs/MyazaEmrtd.nitro.ts +114 -0
  258. package/src/specs/MyazaFaceDetector.nitro.ts +10 -0
  259. package/src/specs/MyazaRectDetector.nitro.ts +50 -0
  260. package/src/specs/MyazaTextRecognizer.nitro.ts +48 -0
  261. package/src/store/derive.ts +225 -0
  262. package/src/store/kycStore.ts +250 -149
  263. package/src/store/state.ts +253 -0
  264. package/src/store/submit.ts +101 -0
  265. package/src/store/submitApplicant.ts +81 -0
  266. package/src/types/appearance.ts +113 -0
  267. package/src/types/business.ts +131 -0
  268. package/src/types/config.ts +150 -124
  269. package/src/types/id-types.ts +57 -0
  270. package/src/types/verification.ts +5 -0
  271. package/src/types/workflow.ts +141 -0
  272. package/src/utils/amountFormat.ts +73 -0
  273. package/src/utils/tokens.ts +4 -2
  274. package/src/__tests__/cardCrop.test.ts +0 -39
  275. package/src/__tests__/deviceMetadata.test.ts +0 -34
  276. package/src/__tests__/errors.test.ts +0 -34
  277. package/src/__tests__/flow.test.ts +0 -61
  278. package/src/__tests__/gestureDetector.test.ts +0 -37
  279. package/src/__tests__/liveness.test.ts +0 -112
  280. package/src/__tests__/resolveUrl.test.ts +0 -64
  281. package/src/__tests__/validators.test.ts +0 -38
@@ -16,7 +16,8 @@ import { withRetry } from '../services/retry';
16
16
  import { mapToKycError, safeReportError } from '../services/errors';
17
17
  import { compressSelfieImage, compressVideo } from '../services/mediaCompress';
18
18
  import { KYCError } from '../types/verification';
19
- import { useKyc, useKycConfig, useTheme } from '../components/runtime';
19
+ import { useStore } from 'zustand';
20
+ import { useKyc, useKycConfig, useKycStore, useTheme } from '../components/runtime';
20
21
  import { MyazaText } from '../components/Typography';
21
22
  import { MyazaButton } from '../components/MyazaButton';
22
23
  import { MyazaPulseLoader } from '../components/MyazaPulseLoader';
@@ -30,8 +31,26 @@ import {
30
31
  import { Icon } from '../components/Icon';
31
32
  import { useToast } from '../components/toast';
32
33
  import { CameraPermissionView, CameraUnavailableView, CameraPermissionPrimingView } from '../components/CameraPermissionView';
34
+ import { ReadyPrimer } from '../components/ReadyPrimer';
35
+ import { READY_LIVENESS } from '../components/readyPrimerContent';
33
36
  import { LivenessAvatar } from './LivenessAvatar';
34
37
  import { detectFaceOnFrame } from '../liveness/visionCameraFaceDetector';
38
+ import { buildLivenessIntegrity } from '../liveness/integritySignals';
39
+ import { DEFAULT_LIVENESS_CONFIG } from '../liveness/types';
40
+ import { useFlashSequence } from './useFlashSequence';
41
+ import { useFlashHole } from './useFlashHole';
42
+ import {
43
+ FRESH_FACE_TIMEOUT_MS,
44
+ INSTRUCTION_HEIGHT,
45
+ LightingBanner,
46
+ ProgressDots,
47
+ SelfiePreview,
48
+ StyleAbsFill,
49
+ resolveGuidance,
50
+ useSelfieUpload,
51
+ LivenessComplete,
52
+ LivenessFailed,
53
+ } from './liveness';
35
54
  import {
36
55
  lightingGuidanceText,
37
56
  positionGuidanceText,
@@ -63,6 +82,7 @@ export function LivenessStep(): React.ReactElement {
63
82
  const config = useKycConfig();
64
83
  const api = useKyc((s) => s.api);
65
84
  const setMediaId = useKyc((s) => s.setMediaId);
85
+ const setCaptureIntegrity = useKyc((s) => s.setCaptureIntegrity);
66
86
  const nextStep = useKyc((s) => s.nextStep);
67
87
 
68
88
  const device = useCameraDevice('front');
@@ -102,6 +122,8 @@ export function LivenessStep(): React.ReactElement {
102
122
  return () => clearTimeout(t);
103
123
  }, []);
104
124
  const cameraUnavailable = cameraGrace && !device;
125
+ // Gate the whole camera path on the user acknowledging the primer.
126
+ const [ready, setReady] = useState(false);
105
127
  const showPrimer = perm === 'priming' && !!device;
106
128
  const permissionDenied = perm === 'denied' && !!device;
107
129
 
@@ -134,56 +156,19 @@ export function LivenessStep(): React.ReactElement {
134
156
  if (!permissionDenied) permReportedRef.current = false;
135
157
  }, [permissionDenied, config.onError]);
136
158
 
137
- // ── Selfie capture + eager upload state (mirrors Flutter _handleComplete) ───
138
- const [selfieUri, setSelfieUri] = useState<string | null>(null);
139
- const [uploading, setUploading] = useState(false);
140
- const [retryInfo, setRetryInfo] = useState<{ attempt: number; total: number } | null>(null);
141
- const [uploadError, setUploadError] = useState<string | null>(null);
142
- const selfieIdRef = useRef<string | null>(null);
143
- // Holds the finished liveness-video path (from the recorder) so the review-screen
144
- // "Try Again" can re-upload it. The recorder itself lives in useVideoRecorder.
145
- const videoPathRef = useRef<string | null>(null);
146
-
147
- const uploadSelfieAndVideo = useCallback(
148
- async (selfie: string, videoPath: string | null) => {
149
- setUploading(true);
150
- setUploadError(null);
151
- setRetryInfo(null);
152
- const onRetry = (attempt: number, total: number) => setRetryInfo({ attempt, total });
153
- try {
154
- const selfieId = await withRetry(
155
- () => api.upload({ uri: selfie, type: 'image/jpeg' }, 'selfie'),
156
- { onRetry },
157
- );
158
- selfieIdRef.current = selfieId;
159
- setMediaId('selfie', selfieId);
160
- // Liveness video is best-effort — a failure here must not block the user.
161
- if (videoPath) {
162
- try {
163
- // Transcode the raw recording down to a small evidence clip first.
164
- const small = await compressVideo(videoPath);
165
- const videoId = await withRetry(
166
- () => api.upload({ uri: small, type: 'video/mp4' }, 'liveness_video', MAX_VIDEO_BYTES),
167
- { onRetry },
168
- );
169
- setMediaId('livenessVideo', videoId);
170
- } catch {
171
- /* keep the selfie, drop the video (incl. if it exceeded the 5MB cap) */
172
- }
173
- }
174
- setRetryInfo(null);
175
- setUploading(false);
176
- } catch (err) {
177
- setRetryInfo(null);
178
- setUploading(false);
179
- const kycError = mapToKycError(err, 'upload');
180
- setUploadError(kycError.message);
181
- toast.show({ variant: 'error', title: 'Upload failed', message: kycError.message });
182
- safeReportError(config.onError, kycError);
183
- }
184
- },
185
- [api, setMediaId, config.onError, toast],
186
- );
159
+ // Selfie + liveness-video upload (see ./liveness/useSelfieUpload).
160
+ const upload = useSelfieUpload();
161
+ const {
162
+ selfieUri,
163
+ setSelfieUri,
164
+ uploading,
165
+ retryInfo,
166
+ uploadError,
167
+ setUploadError,
168
+ selfieIdRef,
169
+ videoPathRef,
170
+ uploadSelfieAndVideo,
171
+ } = upload;
187
172
 
188
173
  // ── Liveness state machine ─────────────────────────────────────────────────
189
174
  // `handleCapture` is created after `liveness` (it calls liveness.markComplete),
@@ -191,7 +176,16 @@ export function LivenessStep(): React.ReactElement {
191
176
  const captureRef = useRef<() => void>(() => {});
192
177
  const liveness = useLiveness({
193
178
  voiceGuidance: config.voiceGuidance,
179
+ // Silent until the primer is dismissed and the camera is actually up. The
180
+ // machine is constructed behind the primer, so without this it talks to a
181
+ // user who has not started yet.
182
+ announce: ready && !showPrimer,
194
183
  onReadyToCapture: () => captureRef.current(),
184
+ config: {
185
+ ...DEFAULT_LIVENESS_CONFIG,
186
+ mode: config.livenessMode ?? 'gestures',
187
+ flashSequenceLength: config.flashSequenceLength,
188
+ },
195
189
  });
196
190
 
197
191
  const handleCapture = useCallback(async () => {
@@ -206,10 +200,46 @@ export function LivenessStep(): React.ReactElement {
206
200
  await new Promise((r) => setTimeout(r, SELFIE_SETTLE_MS));
207
201
  // v5: capture the selfie via the photo output (no camera ref). filePath is
208
202
  // a plain fs path — prepend file:// for the uploader/compressor.
203
+ // A different face appeared while the machine was in `capturing`. The
204
+ // verdict was recorded rather than shown (nothing may re-lay out mid
205
+ // capture), so it is acted on here — BEFORE the shutter, so no still of a
206
+ // stranger is ever taken, let alone uploaded.
207
+ // Live read. `liveness.integrityBroken` is a snapshot from the render
208
+ // this callback was created in — i.e. from BEFORE the flash — so a face
209
+ // swapped or added during the sequence was recorded and then never seen
210
+ // here.
211
+ if (liveness.isCompromised()) {
212
+ liveness.reportIntegrityFailure();
213
+ return;
214
+ }
215
+ // Nobody in frame? Do not photograph an empty room.
216
+ //
217
+ // The flash can leave the face briefly undetectable, so this waits for a
218
+ // FRESH detection now that the overlay is gone rather than trusting a
219
+ // timestamp from before it started — a stale stamp from just before the
220
+ // user walked away passes any recency check, which is precisely how a
221
+ // blank selfie got captured and submitted as a passing liveness result.
222
+ //
223
+ // Losing the face is not a failure, just an abandoned attempt: drop back
224
+ // to positioning with the camera still running, so the user re-frames and
225
+ // the gate fires again with no teardown and no permission re-prompt.
226
+ if (!(await liveness.awaitFreshFace(FRESH_FACE_TIMEOUT_MS))) {
227
+ liveness.reset();
228
+ return;
229
+ }
209
230
  const file = await photoOutput.capturePhotoToFile({ flashMode: 'off' }, {});
210
231
  const raw = `file://${file.filePath}`;
211
232
  const compressed = await compressSelfieImage(raw).catch(() => raw);
212
233
  setSelfieUri(compressed);
234
+ // Recorded at capture, not at submit: by then the liveness hook is gone
235
+ // and its flash result with it.
236
+ setCaptureIntegrity({
237
+ liveness: buildLivenessIntegrity(
238
+ config.livenessMode ?? 'gestures',
239
+ liveness.faceGlitches,
240
+ liveness.flashResult,
241
+ ),
242
+ });
213
243
  liveness.markComplete();
214
244
  // Stop the liveness video (best-effort) and start the eager upload. The
215
245
  // recorder resolves the finished file path (or null if nothing recorded);
@@ -220,7 +250,7 @@ export function LivenessStep(): React.ReactElement {
220
250
  } catch {
221
251
  liveness.reset();
222
252
  }
223
- }, [device, liveness, uploadSelfieAndVideo, photoOutput, videoRecorder]);
253
+ }, [device, liveness, uploadSelfieAndVideo, photoOutput, videoRecorder, setCaptureIntegrity, config.livenessMode]);
224
254
 
225
255
  useEffect(() => {
226
256
  captureRef.current = () => void handleCapture();
@@ -242,20 +272,61 @@ export function LivenessStep(): React.ReactElement {
242
272
  // with `runOnJS(...)` INSIDE the worklet (the canonical react-native-worklets
243
273
  // pattern) rather than pre-wrapped — pre-wrapping produced a "non-worklet
244
274
  // function" error when called from the frame-processor worklet.
275
+ // The most recent face-region RGB, for the flash sequence. A ref rather than
276
+ // state: this updates every frame, and re-rendering the camera preview at
277
+ // frame rate would be both wasteful and — during a flash — actively harmful,
278
+ // since the overlay's cutout is measured once.
279
+ const faceRgbRef = useRef<readonly [number, number, number] | null>(null);
280
+
245
281
  const handleFace = useCallback((data: LivenessFaceData | null) => {
246
282
  const l = livenessRef.current;
247
283
  if (!data) {
284
+ faceRgbRef.current = null;
248
285
  l.onNoFace();
249
286
  return;
250
287
  }
288
+ faceRgbRef.current = data.faceRgb ?? null;
251
289
  // Low-light gate (thresholds mirror Flutter's _BrightnessSampler: <62 dark,
252
290
  // >200 bright). Feeds the lighting warning + speech and blocks challenges so
253
291
  // gestures don't misbehave in poor light.
254
292
  l.setLighting(data.brightness < 62 ? 'dark' : data.brightness > 200 ? 'bright' : null);
255
293
  if (data.faceCount > 0) l.onFace(data);
256
- else l.onNoFace();
294
+ else {
295
+ faceRgbRef.current = null;
296
+ l.onNoFace();
297
+ }
257
298
  }, []);
258
299
 
300
+
301
+ // Hoisted above this component's several early returns — it is a hook, and
302
+ // calling it further down (next to the circle it measures, where it reads
303
+ // better) would make it conditional on which branch rendered.
304
+ const { ref: flashHoleRef, hole: flashHole } = useFlashHole(liveness.phase === 'flash');
305
+
306
+ const { flashColor } = useFlashSequence({
307
+ active: liveness.shouldFlash,
308
+ // Stop painting the moment a second face appears or the face is swapped:
309
+ // finishing the sequence would produce a reflection measurement for a face
310
+ // that is no longer the subject.
311
+ shouldContinue: () => !livenessRef.current.isCompromised(),
312
+ sequenceLength: config.flashSequenceLength,
313
+ readFaceRgb: () => faceRgbRef.current,
314
+ onComplete: (result) => livenessRef.current.completeFlash(result),
315
+ });
316
+
317
+ // Published to the sheet root rather than drawn in this step: an overlay here
318
+ // lights only the padded body, and the screen IS the light source. The sheet
319
+ // root is still the SAME tree as the preview, so the cutout and the preview
320
+ // scale together under any UIKit transform.
321
+ const flashStore = useKycStore();
322
+ const setFlashPaint = useStore(flashStore, (st) => st.setFlashPaint);
323
+ const isFlashing = liveness.phase === 'flash';
324
+ useEffect(() => {
325
+ setFlashPaint(isFlashing ? { color: flashColor, hole: flashHole } : null);
326
+ }, [isFlashing, flashColor, flashHole, setFlashPaint]);
327
+ // Clear on unmount so a colour can never outlive the step.
328
+ useEffect(() => () => setFlashPaint(null), [setFlashPaint]);
329
+
259
330
  const frameOutput = useFrameOutput({
260
331
  // ML Kit (Android) and Apple Vision both consume YUV efficiently.
261
332
  pixelFormat: 'yuv',
@@ -280,6 +351,13 @@ export function LivenessStep(): React.ReactElement {
280
351
  if (cameraUnavailable) {
281
352
  return <CameraUnavailableView />;
282
353
  }
354
+ // "Here's what happens next", BEFORE the OS prompt — and shown even when
355
+ // permission is already granted, because it is about what the step will ask
356
+ // of the user, not about access. Opening the camera unannounced is what makes
357
+ // people fumble the first attempt.
358
+ if (!ready) {
359
+ return <ReadyPrimer content={READY_LIVENESS} onReady={() => setReady(true)} />;
360
+ }
283
361
  if (showPrimer) {
284
362
  return <CameraPermissionPrimingView onGrant={() => setPerm('requesting')} />;
285
363
  }
@@ -290,67 +368,23 @@ export function LivenessStep(): React.ReactElement {
290
368
  // ── Review (selfie captured) ─────────────────────────────────────────────────
291
369
  if (liveness.phase === 'complete' && selfieUri) {
292
370
  return (
293
- <View>
294
- <SelfiePreview uri={selfieUri} uploading={uploading && !uploadError} />
295
- {retryInfo && uploading ? (
296
- <MyazaText variant="bodySmall" color={colors.warning} style={{ textAlign: 'center', marginTop: spacing.sm }}>
297
- {`Upload failed — retrying (${retryInfo.attempt}/${retryInfo.total})…`}
298
- </MyazaText>
299
- ) : null}
300
- <View style={{ height: spacing.md }} />
301
- {uploadError ? (
302
- // The error message itself is shown as a top toast; keep a retry action.
303
- <MyazaButton
304
- label="Try Again"
305
- loading={uploading}
306
- onPress={() => void uploadSelfieAndVideo(selfieUri, videoPathRef.current)}
307
- />
308
- ) : (
309
- <View style={{ flexDirection: 'row', gap: spacing.md }}>
310
- <View style={{ flex: 1 }}>
311
- <MyazaButton
312
- label="Retake"
313
- variant="outline"
314
- leadingIcon="refresh"
315
- disabled={uploading}
316
- onPress={() => {
317
- setSelfieUri(null);
318
- setUploadError(null);
319
- selfieIdRef.current = null;
320
- videoPathRef.current = null;
321
- liveness.reset();
322
- }}
323
- />
324
- </View>
325
- <View style={{ flex: 1 }}>
326
- <MyazaButton
327
- label="Continue"
328
- loading={uploading}
329
- disabled={uploading || !selfieIdRef.current}
330
- onPress={nextStep}
331
- />
332
- </View>
333
- </View>
334
- )}
335
- </View>
371
+ <LivenessComplete
372
+ selfieUri={selfieUri}
373
+ upload={upload}
374
+ onRetake={() => {
375
+ setSelfieUri(null);
376
+ setUploadError(null);
377
+ selfieIdRef.current = null;
378
+ videoPathRef.current = null;
379
+ liveness.reset();
380
+ }}
381
+ onContinue={nextStep}
382
+ />
336
383
  );
337
384
  }
338
385
 
339
- // ── Failed (timeout / face lost) — mirrors the Flutter failed view: a centred
340
- // red message and a full-width "Try Again" button (no icon). ───────────────
341
386
  if (liveness.phase === 'failed') {
342
- return (
343
- <View style={{ alignItems: 'center', justifyContent: 'center', paddingVertical: spacing.xl, gap: spacing.lg }}>
344
- <MyazaText variant="bodyMedium" color={colors.error} style={{ textAlign: 'center', fontWeight: '500' }}>
345
- {liveness.failureReason === 'timeout'
346
- ? "Time's up. Let's try again."
347
- : 'Face lost. Please try again.'}
348
- </MyazaText>
349
- <View style={{ alignSelf: 'stretch' }}>
350
- <MyazaButton label="Try Again" onPress={liveness.reset} />
351
- </View>
352
- </View>
353
- );
387
+ return <LivenessFailed reason={liveness.failureReason} onRetry={liveness.reset} />;
354
388
  }
355
389
 
356
390
  // ── Live camera (circular) + ring state machine + guidance ──────────────────
@@ -391,15 +425,23 @@ export function LivenessStep(): React.ReactElement {
391
425
  const CIRCLE = Math.min(Dimensions.get('window').width - spacing.md * 4, 300);
392
426
  const lighting = liveness.lightingGuidance;
393
427
 
428
+ // Rendered INSIDE the sheet, so the SDK header and the "powered by" footer
429
+ // stay visible throughout — only the flash itself goes full-screen.
394
430
  return (
395
431
  <View style={{ alignItems: 'center', gap: spacing.md }}>
396
432
  {/* Instruction text — above the circle */}
397
- <MyazaText variant="heading3" style={{ textAlign: 'center', minHeight: 26 }} color={instrColor}>
398
- {guidance.text}
433
+ <MyazaText
434
+ variant="heading3"
435
+ style={{ textAlign: 'center', minHeight: INSTRUCTION_HEIGHT }}
436
+ color={instrColor}
437
+ >
438
+ {phase === 'flash' ? '' : guidance.text}
399
439
  </MyazaText>
400
440
 
401
441
  {/* Circular camera with a thick colour-state ring */}
402
442
  <View
443
+ ref={flashHoleRef}
444
+ collapsable={false}
403
445
  style={{
404
446
  width: CIRCLE,
405
447
  height: CIRCLE,
@@ -478,144 +520,10 @@ export function LivenessStep(): React.ReactElement {
478
520
  {lighting ? <LightingBanner text={lightingGuidanceText(lighting)} /> : null}
479
521
 
480
522
  {/* Numbered progress dots with connectors */}
481
- <ProgressDots total={liveness.totalCount} completed={liveness.completedCount} active={phase === 'challenge' || phase === 'positioning'} />
523
+ <ProgressDots total={liveness.totalCount} completed={liveness.completedCount} active={phase === 'challenge' || phase === 'positioning' || phase === 'flash'} />
482
524
 
483
525
  {/* Gesture demo avatar */}
484
526
  {liveness.activeChallenge ? <LivenessAvatar challenge={liveness.activeChallenge} /> : null}
485
527
  </View>
486
528
  );
487
529
  }
488
-
489
- const StyleAbsFill = { position: 'absolute' as const, top: 0, left: 0, right: 0, bottom: 0 };
490
-
491
- // Lighting warning — 1:1 with the Flutter `_LightingWarningBanner`: amber-50 bg,
492
- // amber-200 border, amber-800 lightbulb + text, fading/sliding in (~300ms).
493
- const AMBER_50 = '#FFFBEB';
494
- const AMBER_200 = '#FDE68A';
495
- const AMBER_800 = '#92400E';
496
-
497
- function LightingBanner({ text }: { text: string }): React.ReactElement {
498
- const anim = useRef(new Animated.Value(0)).current;
499
- useEffect(() => {
500
- Animated.timing(anim, { toValue: 1, duration: 300, easing: Easing.out(Easing.cubic), useNativeDriver: true }).start();
501
- }, [anim]);
502
- return (
503
- <Animated.View
504
- style={{
505
- alignSelf: 'stretch',
506
- opacity: anim,
507
- transform: [{ translateY: anim.interpolate({ inputRange: [0, 1], outputRange: [-8, 0] }) }],
508
- }}
509
- >
510
- <View
511
- style={{
512
- flexDirection: 'row',
513
- alignItems: 'flex-start',
514
- gap: spacing.sm,
515
- borderRadius: radius.sm,
516
- borderWidth: 1,
517
- borderColor: AMBER_200,
518
- backgroundColor: AMBER_50,
519
- paddingHorizontal: spacing.sm + 4,
520
- paddingVertical: 10,
521
- }}
522
- >
523
- <Icon name="lightbulb" size={16} color={AMBER_800} />
524
- <MyazaText variant="bodySmall" color={AMBER_800} style={{ flexShrink: 1, lineHeight: 17 }}>
525
- {text}
526
- </MyazaText>
527
- </View>
528
- </Animated.View>
529
- );
530
- }
531
-
532
- /** The single guidance string + tone to show under the camera. */
533
- function resolveGuidance(l: ReturnType<typeof useLiveness>): { text: string; tone: 'normal' | 'error' } {
534
- if (l.multipleFaces) return { text: l.instruction, tone: 'error' };
535
- // Lighting is shown in its own banner (below), not as the main instruction.
536
- if (l.phase === 'positioning' && !l.faceDetected) {
537
- return { text: 'Position your face in the circle', tone: 'normal' };
538
- }
539
- if (l.positionGuidance) return { text: positionGuidanceText(l.positionGuidance), tone: 'error' };
540
- if (l.wrongGesture) return { text: 'Wrong gesture — follow the prompt', tone: 'error' };
541
- return { text: l.instruction, tone: 'normal' };
542
- }
543
-
544
- // Numbered progress steps with connectors — mirrors the web/Flutter SDKs:
545
- // passed = solid green + checkmark, active = green-outlined number, pending =
546
- // grey number. `active` marks the current (completed-th) step as in-progress.
547
- function ProgressDots({
548
- total,
549
- completed,
550
- active,
551
- }: {
552
- total: number;
553
- completed: number;
554
- active: boolean;
555
- }): React.ReactElement {
556
- const { colors } = useTheme();
557
- return (
558
- <View style={{ flexDirection: 'row', alignItems: 'center', justifyContent: 'center' }}>
559
- {Array.from({ length: total }).map((_, i) => {
560
- const state: 'passed' | 'active' | 'pending' =
561
- i < completed ? 'passed' : i === completed && active ? 'active' : 'pending';
562
- return (
563
- <React.Fragment key={i}>
564
- <View
565
- style={{
566
- width: 28,
567
- height: 28,
568
- borderRadius: 14,
569
- alignItems: 'center',
570
- justifyContent: 'center',
571
- backgroundColor: state === 'passed' ? colors.success : state === 'active' ? colors.background : colors.gray300,
572
- borderWidth: state === 'active' ? 2 : 0,
573
- borderColor: colors.success,
574
- }}
575
- >
576
- {state === 'passed' ? (
577
- <Svg width={14} height={14} viewBox="0 0 24 24">
578
- <Path d="M5 13l4 4L19 7" fill="none" stroke="#FFFFFF" strokeWidth={3} strokeLinecap="round" strokeLinejoin="round" />
579
- </Svg>
580
- ) : (
581
- <MyazaText variant="bodySmall" color={state === 'active' ? colors.success : colors.textMuted} style={{ fontWeight: '700' }}>
582
- {String(i + 1)}
583
- </MyazaText>
584
- )}
585
- </View>
586
- {i < total - 1 ? (
587
- <View style={{ width: 28, height: 2, backgroundColor: i < completed ? colors.success : colors.gray300 }} />
588
- ) : null}
589
- </React.Fragment>
590
- );
591
- })}
592
- </View>
593
- );
594
- }
595
-
596
- function SelfiePreview({ uri, uploading }: { uri: string; uploading?: boolean }): React.ReactElement {
597
- const { colors } = useTheme();
598
- const S = 200;
599
- return (
600
- <View style={{ alignItems: 'center', justifyContent: 'center', alignSelf: 'stretch' }}>
601
- <View style={{ width: S, height: S, borderRadius: S / 2, borderWidth: 4, backgroundColor: '#111111', borderColor: `${colors.primary}33`, overflow: 'hidden' }}>
602
- {/* borderRadius repeated on the Image — iOS doesn't reliably clip an Image
603
- child to a rounded parent (same fix as the header brand bar). */}
604
- <Image source={{ uri }} style={{ width: '100%', height: '100%', borderRadius: S / 2 }} resizeMode="cover" />
605
- {/* Standard upload loader inside the preview circle (mirrors web/Flutter). */}
606
- {uploading ? (
607
- <View style={[StyleAbsFill, { backgroundColor: 'rgba(0,0,0,0.45)', alignItems: 'center', justifyContent: 'center' }]}>
608
- <MyazaPulseLoader size={64} />
609
- </View>
610
- ) : null}
611
- </View>
612
- <View style={{ height: spacing.md }} />
613
- <MyazaText variant="heading3" style={{ textAlign: 'center' }}>
614
- Looking good!
615
- </MyazaText>
616
- <MyazaText variant="bodySmall" color={colors.textSecondary} style={{ textAlign: 'center' }}>
617
- Tap Continue to submit, or Retake to try again.
618
- </MyazaText>
619
- </View>
620
- );
621
- }