@myazahq/kyc-sdk-react-native 2.0.2 → 2.2.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 (283) hide show
  1. package/README.md +152 -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 +70 -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 +162 -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 +322 -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 +207 -0
  198. package/src/screens/CompanyInfoFields.tsx +83 -0
  199. package/src/screens/ConsentStep.tsx +68 -68
  200. package/src/screens/ContactChannelChoice.tsx +133 -0
  201. package/src/screens/ContactCodeStep.tsx +130 -0
  202. package/src/screens/ContactDestinationField.tsx +70 -0
  203. package/src/screens/ContactFooterNote.tsx +34 -0
  204. package/src/screens/ContactVerificationStep.tsx +199 -0
  205. package/src/screens/ContactVerifiedPanel.tsx +36 -0
  206. package/src/screens/CountrySelectStep.tsx +210 -0
  207. package/src/screens/DocumentCaptureStep.tsx +156 -132
  208. package/src/screens/IdInputStep.tsx +13 -5
  209. package/src/screens/IdTypeStep.tsx +74 -33
  210. package/src/screens/KeyPeopleInviteLinks.tsx +239 -0
  211. package/src/screens/KeyPersonCard.tsx +119 -0
  212. package/src/screens/KeyPersonForm.tsx +148 -0
  213. package/src/screens/KeyPersonSheet.tsx +184 -0
  214. package/src/screens/LivenessStep.tsx +155 -247
  215. package/src/screens/MrzScanView.tsx +173 -0
  216. package/src/screens/NfcStep.tsx +290 -0
  217. package/src/screens/ProofOfAddressStep.tsx +238 -0
  218. package/src/screens/QuestionnaireField.tsx +219 -0
  219. package/src/screens/QuestionnaireMoneyField.tsx +134 -0
  220. package/src/screens/QuestionnaireStep.tsx +76 -0
  221. package/src/screens/SubmittedStep.tsx +5 -0
  222. package/src/screens/consent/model.ts +109 -0
  223. package/src/screens/document/CameraPhase.tsx +150 -0
  224. package/src/screens/document/RequiredPill.tsx +60 -0
  225. package/src/screens/liveness/LightingBanner.tsx +50 -0
  226. package/src/screens/liveness/LivenessOutcome.tsx +109 -0
  227. package/src/screens/liveness/ProgressDots.tsx +67 -0
  228. package/src/screens/liveness/SelfiePreview.tsx +39 -0
  229. package/src/screens/liveness/constants.ts +31 -0
  230. package/src/screens/liveness/index.ts +8 -0
  231. package/src/screens/liveness/resolveGuidance.ts +27 -0
  232. package/src/screens/liveness/useSelfieUpload.ts +107 -0
  233. package/src/screens/nfc/NfcMrzPrompt.tsx +40 -0
  234. package/src/screens/nfc/NfcReadBody.tsx +63 -0
  235. package/src/screens/nfc/NfcReadProgress.tsx +96 -0
  236. package/src/screens/nfc/NfcScanIllustration.tsx +244 -0
  237. package/src/screens/nfc/NfcScannedSummary.tsx +74 -0
  238. package/src/screens/nfc/NfcSuccessPanel.tsx +162 -0
  239. package/src/screens/nfc/availability.ts +73 -0
  240. package/src/screens/nfc/readErrorMessage.ts +93 -0
  241. package/src/screens/nfc/useChipPortrait.ts +58 -0
  242. package/src/screens/nfc/useNfcAvailability.ts +82 -0
  243. package/src/screens/useFlashHole.ts +105 -0
  244. package/src/screens/useFlashSequence.ts +96 -0
  245. package/src/screens/usePoaAttach.ts +121 -0
  246. package/src/services/api-types.ts +164 -0
  247. package/src/services/api-verify-types.ts +99 -0
  248. package/src/services/api.ts +72 -102
  249. package/src/services/cardCrop.ts +25 -10
  250. package/src/services/contactErrors.ts +43 -0
  251. package/src/services/deviceMetadata.ts +1 -1
  252. package/src/services/documentPicker.ts +20 -0
  253. package/src/services/fingerprint-sources.ts +129 -0
  254. package/src/services/fingerprint.ts +101 -0
  255. package/src/services/haptics.ts +38 -0
  256. package/src/services/mediaCompress.ts +7 -2
  257. package/src/services/resolveUrl.ts +2 -2
  258. package/src/services/workflowGate.ts +138 -0
  259. package/src/specs/MyazaEmrtd.nitro.ts +114 -0
  260. package/src/specs/MyazaFaceDetector.nitro.ts +10 -0
  261. package/src/specs/MyazaRectDetector.nitro.ts +50 -0
  262. package/src/specs/MyazaTextRecognizer.nitro.ts +48 -0
  263. package/src/store/derive.ts +225 -0
  264. package/src/store/kycStore.ts +250 -149
  265. package/src/store/state.ts +253 -0
  266. package/src/store/submit.ts +101 -0
  267. package/src/store/submitApplicant.ts +81 -0
  268. package/src/types/appearance.ts +113 -0
  269. package/src/types/business.ts +131 -0
  270. package/src/types/config.ts +150 -124
  271. package/src/types/id-types.ts +57 -0
  272. package/src/types/verification.ts +5 -0
  273. package/src/types/workflow.ts +141 -0
  274. package/src/utils/amountFormat.ts +73 -0
  275. package/src/utils/tokens.ts +4 -2
  276. package/src/__tests__/cardCrop.test.ts +0 -39
  277. package/src/__tests__/deviceMetadata.test.ts +0 -34
  278. package/src/__tests__/errors.test.ts +0 -34
  279. package/src/__tests__/flow.test.ts +0 -61
  280. package/src/__tests__/gestureDetector.test.ts +0 -37
  281. package/src/__tests__/liveness.test.ts +0 -112
  282. package/src/__tests__/resolveUrl.test.ts +0 -64
  283. package/src/__tests__/validators.test.ts +0 -38
@@ -41,77 +41,67 @@ import {
41
41
  eyeAverageOpenProbability,
42
42
  pushHistory,
43
43
  } from './gestureDetector';
44
- import { ChallengeTracker, pickChallenges } from './challengeManager';
44
+ import { ChallengeTracker, modeRunsGestures, pickChallenges } from './challengeManager';
45
+ import { FlashReadyGate } from './flashReadyGate';
46
+ import { FaceContinuityGuard } from './faceContinuity';
47
+ import type { FlashResult } from './flashDetector';
48
+ import { useFlashPhase } from './useFlashPhase';
49
+
50
+ // State shape, options, guidance strings and phase predicates live in
51
+ // ./livenessState — re-exported here so existing imports keep working.
52
+ export {
53
+ positionGuidanceText,
54
+ lightingGuidanceText,
55
+ type PositionGuidance,
56
+ type LightingGuidance,
57
+ type LivenessUiState,
58
+ type UseLivenessOptions,
59
+ type UseLivenessReturn,
60
+ } from './livenessState';
45
61
  import { LivenessSpeaker } from './speech';
46
- import type { VoiceGuidanceOption } from '../types/config';
47
-
48
- const CHALLENGE_PASSED_MS = 700; // brief "Great!" before the next challenge
49
- const POSITION_TOO_FAR = 'too_far';
50
- const POSITION_TOO_CLOSE = 'too_close';
51
- const MULTIPLE_FACES_GUIDANCE = 'Make sure only your face is visible';
52
-
53
- export type PositionGuidance = typeof POSITION_TOO_FAR | typeof POSITION_TOO_CLOSE;
54
- export type LightingGuidance = 'dark' | 'bright';
55
-
56
- export interface LivenessUiState {
57
- phase: LivenessPhase;
58
- instruction: string;
59
- activeChallenge: LivenessChallenge | null;
60
- timeoutRemaining: number;
61
- completedCount: number;
62
- totalCount: number;
63
- positionGuidance: PositionGuidance | null;
64
- lightingGuidance: LightingGuidance | null;
65
- multipleFaces: boolean;
66
- wrongGesture: boolean;
67
- faceDetected: boolean;
68
- failureReason: LivenessFailureReason | null;
69
- }
70
-
71
- export interface UseLivenessOptions {
72
- config?: Partial<LivenessConfig>;
73
- voiceGuidance?: VoiceGuidanceOption;
74
- /** Fires once when all challenges pass and the phase enters `capturing`. */
75
- onReadyToCapture?: () => void;
76
- }
77
-
78
- export interface UseLivenessReturn extends LivenessUiState {
79
- /** Push a face frame into the machine (call from the frame processor via runOnJS). */
80
- onFace: (data: LivenessFaceData) => void;
81
- /** Push a "no face this frame" signal. */
82
- onNoFace: () => void;
83
- /** Feed live lighting quality (null = ok). */
84
- setLighting: (guidance: LightingGuidance | null) => void;
85
- /** Mark the selfie captured → phase `complete`. */
86
- markComplete: () => void;
87
- /** Restart the session with a fresh random challenge set. */
88
- reset: () => void;
89
- /** True while the machine wants a still captured (phase === 'capturing'). */
90
- shouldCapture: boolean;
91
- }
92
-
93
- /** Position guidance text (spoken + shown), matching the Flutter SDK exactly. */
94
- export function positionGuidanceText(g: PositionGuidance): string {
95
- return g === POSITION_TOO_FAR ? 'Kindly move closer' : 'Kindly move further away';
96
- }
97
- /** Lighting warning banner text — the full Flutter strings (shown, not spoken). */
98
- export function lightingGuidanceText(g: LightingGuidance): string {
99
- return g === 'dark'
100
- ? 'It looks dark here. Move to a brighter area or near a light source for better detection.'
101
- : 'Too bright — reduce glare or move away from direct light for better detection.';
102
- }
103
-
104
- function isTerminal(phase: LivenessPhase): boolean {
105
- return phase === 'complete' || phase === 'failed';
106
- }
62
+ import {
63
+ INTEGRITY_FAILED_PATCH,
64
+ CHALLENGE_PASSED_MS,
65
+ POSITION_TOO_FAR,
66
+ POSITION_TOO_CLOSE,
67
+ MULTIPLE_FACES_GUIDANCE,
68
+ isSettled,
69
+ isTerminal,
70
+ positionGuidanceText,
71
+ type LightingGuidance,
72
+ type PositionGuidance,
73
+ type LivenessUiState,
74
+ type UseLivenessOptions,
75
+ type UseLivenessReturn,
76
+ } from './livenessState';
107
77
 
108
78
  export function useLiveness(opts: UseLivenessOptions = {}): UseLivenessReturn {
109
- const { config, voiceGuidance, onReadyToCapture } = opts;
79
+ const { config, voiceGuidance, onReadyToCapture, announce = true } = opts;
110
80
 
111
81
  // Resolve the challenge set once per session (and on reset).
112
82
  const trackerRef = useRef<ChallengeTracker | null>(null);
83
+
84
+ // Tracks that the face performing the challenges is the face still in frame.
85
+ // Liveness without it proves a live human was present, not WHICH human.
86
+ const continuityRef = useRef<FaceContinuityGuard>(new FaceContinuityGuard());
87
+ // Flash-only has no challenges to act as a buffer, so it needs its own
88
+ // "framed, lit and held still" gate before the screen lights up.
89
+ const flashGateRef = useRef<FlashReadyGate | null>(null);
90
+ // "No lighting warning" is ambiguous until the sampler has actually reported:
91
+ // during warm-up, unknown looks identical to good.
92
+ const lightingSampledRef = useRef(false);
93
+ // When a face was last actually seen. The capture gate needs this to refuse a
94
+ // frame with nobody in it.
95
+ const lastFaceSeenRef = useRef<number | null>(null);
96
+
97
+ // Set when the session can no longer vouch for who is in frame. Recorded
98
+ // rather than shown during `capturing`: that phase runs the capture (and, in
99
+ // future, the flash overlay), and re-laying out the screen mid-capture is how
100
+ // the Flutter SDK ended up displacing its preview from the flash cutout.
101
+ const integrityBrokenRef = useRef(false);
113
102
  if (trackerRef.current === null) {
114
103
  trackerRef.current = new ChallengeTracker(pickChallenges(config));
104
+ flashGateRef.current = modeRunsGestures(config?.mode) ? null : new FlashReadyGate();
115
105
  }
116
106
  const initialTotalCount = trackerRef.current.totalCount;
117
107
 
@@ -175,12 +165,45 @@ export function useLiveness(opts: UseLivenessOptions = {}): UseLivenessReturn {
175
165
  [cancelTimer],
176
166
  );
177
167
 
168
+ const flash = useFlashPhase(config?.mode, setState);
169
+
170
+ // Speak whatever the screen is currently instructing.
171
+ //
172
+ // Only CHALLENGE instructions were ever spoken, so a flash-only flow — which
173
+ // has no challenges — was silent from start to finish, and "Position your face
174
+ // in the circle" was never spoken in any mode. The guidance is most needed
175
+ // exactly where it was missing: a user holding a phone at arm's length during
176
+ // a flash sequence is not reading the screen.
177
+ //
178
+ // Keyed on the instruction rather than the phase so a challenge-to-challenge
179
+ // change is announced too. The speaker de-dupes consecutive identical phrases,
180
+ // so this cannot stutter over a re-render. Multi-face and lighting prompts are
181
+ // spoken by their own handlers and take priority, so they are skipped here.
182
+ useEffect(() => {
183
+ if (!announce || !state.instruction || state.multipleFaces) return;
184
+ speak(state.instruction);
185
+ }, [announce, state.instruction, state.multipleFaces, speak]);
186
+ // Whether the flash occupies a slot in the progress indicator.
187
+ const flashIsStep = config?.mode === 'flash' || config?.mode === 'both';
188
+
178
189
  const startNextChallenge = useCallback(() => {
179
190
  const tracker = trackerRef.current!;
180
191
  const current = tracker.current;
181
192
  if (!current) {
182
- // No challenges left → ask for a still.
183
193
  cancelTimer();
194
+ // Flash runs LAST, after the gestures: by now the face is positioned and
195
+ // steady, which is what makes the reflection readable. Starting it during
196
+ // positioning would measure a moving target.
197
+ if (flash.pending()) {
198
+ setState((s) => ({
199
+ ...s,
200
+ phase: 'flash',
201
+ instruction: 'Hold still and look at the screen',
202
+ activeChallenge: null,
203
+ positionGuidance: null,
204
+ }));
205
+ return;
206
+ }
184
207
  setState((s) => ({
185
208
  ...s,
186
209
  phase: 'capturing',
@@ -231,11 +254,7 @@ export function useLiveness(opts: UseLivenessOptions = {}): UseLivenessReturn {
231
254
  // ── Position check (every frame; mirrors Flutter _checkFacePosition) ────────
232
255
  const checkPosition = useCallback((ratio: number) => {
233
256
  setState((s) => {
234
- if (
235
- s.phase === 'challenge_passed' ||
236
- s.phase === 'capturing' ||
237
- isTerminal(s.phase)
238
- ) {
257
+ if (s.phase === 'challenge_passed' || isSettled(s.phase) || isTerminal(s.phase)) {
239
258
  return s;
240
259
  }
241
260
  let next: PositionGuidance | null;
@@ -247,26 +266,99 @@ export function useLiveness(opts: UseLivenessOptions = {}): UseLivenessReturn {
247
266
  }, []);
248
267
 
249
268
  // ── Per-frame entry point ──────────────────────────────────────────────────
269
+ /**
270
+ * A different face is in frame, or one returned after an absence.
271
+ *
272
+ * `substituted` ends the session: nothing performed so far can be attributed
273
+ * to whoever is there now. `reacquired` is not proof — people look away — but
274
+ * nothing vouches for the returning face either, so the challenges are
275
+ * restarted rather than inherited.
276
+ *
277
+ * Mid-capture the verdict is RECORDED, not shown: re-laying out the screen
278
+ * while a capture (or flash overlay) is running is a separate class of bug,
279
+ * so the flow reports it once capture is done.
280
+ */
281
+ const onFaceIntegrityBroken = useCallback(
282
+ (kind: 'substituted' | 'reacquired') => {
283
+ const phase = stateRef.current.phase;
284
+ if (isTerminal(phase)) return;
285
+ integrityBrokenRef.current = true;
286
+ // Counted whether or not it is shown: a session with several
287
+ // discontinuities is worth flagging even when each one was recoverable.
288
+ flash.recordGlitch();
289
+ if (isSettled(phase)) return;
290
+
291
+ cancelTimer();
292
+ if (kind === 'substituted') {
293
+ setState((s) => ({ ...s, ...INTEGRITY_FAILED_PATCH }));
294
+ return;
295
+ }
296
+
297
+ // Re-acquired: start again from positioning with a fresh challenge set.
298
+ trackerRef.current = new ChallengeTracker(pickChallenges(config));
299
+ // The returning face has to earn the hold again — otherwise a face that
300
+ // left and came back would flash immediately on a gate that is still
301
+ // holding the departed face's dwell.
302
+ flashGateRef.current?.reset();
303
+ xHistoryRef.current = [];
304
+ earHistoryRef.current = [];
305
+ processingRef.current = false;
306
+ integrityBrokenRef.current = false;
307
+ setState((s) => ({
308
+ ...s,
309
+ phase: 'positioning',
310
+ instruction: 'Position your face in the circle',
311
+ activeChallenge: null,
312
+ completedCount: 0,
313
+ positionGuidance: null,
314
+ }));
315
+ },
316
+ [cancelTimer, config, flash],
317
+ );
318
+
250
319
  const onFace = useCallback(
251
320
  (data: LivenessFaceData) => {
252
321
  const phase = stateRef.current.phase;
253
322
  if (isTerminal(phase)) return;
254
323
 
324
+ lastFaceSeenRef.current = Date.now();
325
+
255
326
  if (!stateRef.current.faceDetected) {
256
327
  setState((s) => ({ ...s, faceDetected: true }));
257
328
  }
258
329
 
259
330
  // Multiple-faces guard — pause everything until a single face returns.
331
+ //
332
+ // Runs in EVERY non-terminal phase, including the flash and the capture
333
+ // itself. It used to be suppressed once the phase had settled, to keep
334
+ // the layout still underneath the flash's cutout — but that put the blind
335
+ // spot exactly where it matters most: the flash IS the liveness
336
+ // measurement, and the capture is the frame that becomes the selfie, so a
337
+ // second face arriving during either went unrecorded. A misaligned hole
338
+ // is a cosmetic defect; an unnoticed second face is the thing the check
339
+ // exists to catch. Flutter has never gated this either.
340
+ //
341
+ // The layout concern is handled where it belongs — the banner is
342
+ // suppressed at RENDER time during a flash, so the state is still true
343
+ // while nothing moves.
260
344
  if (data.faceCount > 1) {
261
- if (!stateRef.current.multipleFaces && phase !== 'capturing') {
345
+ if (isSettled(phase)) {
346
+ // The flash and the shutter are the two moments that actually prove
347
+ // liveness. There is no useful guidance to show mid-sequence, so
348
+ // instead of prompting, the session is marked BROKEN — the flash
349
+ // aborts and the capture refuses. Flagging without breaking is what
350
+ // made a second face during the flash have no effect at all.
351
+ integrityBrokenRef.current = true;
352
+ }
353
+ if (!stateRef.current.multipleFaces) {
262
354
  cancelTimer();
263
355
  setState((s) => ({
264
356
  ...s,
265
357
  multipleFaces: true,
266
- instruction: MULTIPLE_FACES_GUIDANCE,
358
+ instruction: isSettled(s.phase) ? s.instruction : MULTIPLE_FACES_GUIDANCE,
267
359
  wrongGesture: false,
268
360
  }));
269
- speak(MULTIPLE_FACES_GUIDANCE);
361
+ if (!isSettled(phase)) speak(MULTIPLE_FACES_GUIDANCE);
270
362
  }
271
363
  return;
272
364
  }
@@ -287,10 +379,37 @@ export function useLiveness(opts: UseLivenessOptions = {}): UseLivenessReturn {
287
379
  }
288
380
  }
289
381
 
382
+ // Same face as a moment ago? Nothing here identifies anyone — it only
383
+ // rejects a substitution, which is what stops one person performing the
384
+ // challenges while another is photographed.
385
+ const continuity = continuityRef.current.update(data, Date.now());
386
+ if (continuity === 'substituted' || continuity === 'reacquired') {
387
+ onFaceIntegrityBroken(continuity);
388
+ return;
389
+ }
390
+
290
391
  checkPosition(data.faceSizeRatio);
291
392
 
292
393
  const s = stateRef.current;
293
394
  if (s.phase === 'positioning') {
395
+ const gate = flashGateRef.current;
396
+ if (gate) {
397
+ // Flash-only. Advancing on a single good frame meant the screen
398
+ // flashed at a face that was merely passing through the right
399
+ // distance, before the "come closer / more light" guidance had any
400
+ // chance to show — and before lighting had actually been measured.
401
+ // Gestures don't need this because they take seconds and re-check
402
+ // framing throughout; nothing follows the flash, so it does.
403
+ const verdict = gate.update({
404
+ framed: s.positionGuidance == null,
405
+ lit: s.lightingGuidance == null,
406
+ lightingConfirmed: lightingSampledRef.current,
407
+ now: Date.now(),
408
+ });
409
+ if (s.positionGuidance) speak(positionGuidanceText(s.positionGuidance));
410
+ if (verdict.ready) startNextChallenge();
411
+ return;
412
+ }
294
413
  // Advance only at the right distance AND with acceptable lighting.
295
414
  if (s.positionGuidance == null && s.lightingGuidance == null) {
296
415
  startNextChallenge();
@@ -328,7 +447,7 @@ export function useLiveness(opts: UseLivenessOptions = {}): UseLivenessReturn {
328
447
  },
329
448
  // checkGesture is defined below and stable via refs
330
449
  // eslint-disable-next-line react-hooks/exhaustive-deps
331
- [cancelTimer, checkPosition, config, speak, startNextChallenge, startTimer],
450
+ [cancelTimer, checkPosition, config, onFaceIntegrityBroken, speak, startNextChallenge, startTimer],
332
451
  );
333
452
 
334
453
  // checkGesture reads the current challenge + histories from refs.
@@ -374,7 +493,29 @@ export function useLiveness(opts: UseLivenessOptions = {}): UseLivenessReturn {
374
493
  [onChallengePassed],
375
494
  );
376
495
 
496
+ /**
497
+ * Waits for a face detection that lands AFTER this call, up to `timeoutMs`.
498
+ *
499
+ * Deliberately not a recency check on the last-seen timestamp: a stamp from
500
+ * just before the face left still falls inside any short window, and that is
501
+ * exactly the blank-selfie case — the flash finishes, nobody is there, and the
502
+ * shutter fires on an empty frame anyway. Demanding a NEW detection, with the
503
+ * overlay gone and detection reliable again, means the face has to be there
504
+ * now rather than have been there a moment ago.
505
+ */
506
+ const awaitFreshFace = useCallback(async (timeoutMs: number): Promise<boolean> => {
507
+ const since = Date.now();
508
+ const deadline = since + timeoutMs;
509
+ while (Date.now() < deadline) {
510
+ const seen = lastFaceSeenRef.current;
511
+ if (seen !== null && seen > since) return true;
512
+ await new Promise((r) => setTimeout(r, 60));
513
+ }
514
+ return false;
515
+ }, []);
516
+
377
517
  const onNoFace = useCallback(() => {
518
+ continuityRef.current.reportNoFace();
378
519
  if (!stateRef.current.faceDetected) return;
379
520
  if (isTerminal(stateRef.current.phase)) return;
380
521
  setState((s) => ({ ...s, faceDetected: false, positionGuidance: null, multipleFaces: false }));
@@ -382,12 +523,15 @@ export function useLiveness(opts: UseLivenessOptions = {}): UseLivenessReturn {
382
523
 
383
524
  const setLighting = useCallback(
384
525
  (guidance: LightingGuidance | null) => {
526
+ // The sampler has produced a real reading — "no warning" now means
527
+ // measured-OK rather than not-yet-known.
528
+ lightingSampledRef.current = true;
385
529
  const prev = stateRef.current.lightingGuidance;
386
530
  const phase = stateRef.current.phase;
387
531
  setState((s) => {
388
532
  if (guidance === s.lightingGuidance) return s;
389
533
  // Lighting only matters before/while reaching capture.
390
- if (s.phase === 'capturing' || isTerminal(s.phase)) {
534
+ if (isSettled(s.phase) || isTerminal(s.phase)) {
391
535
  return s.lightingGuidance != null ? { ...s, lightingGuidance: null } : s;
392
536
  }
393
537
  return { ...s, lightingGuidance: guidance };
@@ -433,6 +577,14 @@ export function useLiveness(opts: UseLivenessOptions = {}): UseLivenessReturn {
433
577
  processingRef.current = false;
434
578
  readyFiredRef.current = false;
435
579
  speakerRef.current?.reset();
580
+ // A retry is a fresh session: whoever is in frame now becomes the reference.
581
+ continuityRef.current.reset();
582
+ flashGateRef.current?.reset();
583
+ lastFaceSeenRef.current = null;
584
+ integrityBrokenRef.current = false;
585
+ // A retry runs the whole check again, INCLUDING a fresh colour sequence —
586
+ // reusing the previous one would hand an attacker the answer.
587
+ flash.reset();
436
588
  setState({
437
589
  phase: 'positioning',
438
590
  instruction: 'Position your face in the circle',
@@ -457,6 +609,21 @@ export function useLiveness(opts: UseLivenessOptions = {}): UseLivenessReturn {
457
609
  }
458
610
  }, [state.phase, onReadyToCapture]);
459
611
 
612
+ /**
613
+ * Surfaces a failure detected during `capturing` and deliberately withheld.
614
+ *
615
+ * Called by the capture path once the still is done, so the re-layout happens
616
+ * against a settled screen. The Flutter SDK learned this the hard way: failing
617
+ * mid-capture moved the preview circle out from under the flash overlay's
618
+ * cutout, which is measured once when the flash starts.
619
+ */
620
+ const reportIntegrityFailure = useCallback(() => {
621
+ if (!integrityBrokenRef.current) return;
622
+ if (stateRef.current.phase === 'complete') return;
623
+ cancelTimer();
624
+ setState((s) => ({ ...s, ...INTEGRITY_FAILED_PATCH }));
625
+ }, [cancelTimer]);
626
+
460
627
  // Cleanup timers + speech on unmount.
461
628
  useEffect(() => {
462
629
  return () => {
@@ -471,12 +638,30 @@ export function useLiveness(opts: UseLivenessOptions = {}): UseLivenessReturn {
471
638
  ...state,
472
639
  onFace,
473
640
  onNoFace,
641
+ awaitFreshFace,
474
642
  setLighting,
475
643
  markComplete,
476
644
  reset,
477
645
  shouldCapture: state.phase === 'capturing',
646
+ shouldFlash: state.phase === 'flash',
647
+ completeFlash: flash.complete,
648
+ flashResult: flash.result(),
649
+ faceGlitches: flash.glitches(),
650
+ // The flash IS a step, so it gets a dot. Without this the indicator
651
+ // claimed the check was finished while the longest, most visible part of
652
+ // it was still to come — and in flash-only mode it showed no steps at all.
653
+ totalCount: state.totalCount + (flashIsStep ? 1 : 0),
654
+ completedCount: state.completedCount + (flashIsStep && flash.result() !== null ? 1 : 0),
655
+ integrityBroken: integrityBrokenRef.current,
656
+ // Read through a function, not a snapshot: integrity is recorded in a ref
657
+ // (deliberately — re-laying out mid-flash is its own class of bug), and a
658
+ // ref write does not re-render. Anything consulting it from a callback
659
+ // created BEFORE the flash would otherwise read the pre-flash value, which
660
+ // is precisely the window a substitution happens in.
661
+ isCompromised: () => integrityBrokenRef.current,
662
+ reportIntegrityFailure,
478
663
  }),
479
- [state, onFace, onNoFace, setLighting, markComplete, reset],
664
+ [state, onFace, onNoFace, awaitFreshFace, setLighting, markComplete, reset, reportIntegrityFailure, flash],
480
665
  );
481
666
  }
482
667
 
@@ -66,6 +66,20 @@ function toFaceData(raw: FaceResult | null): LivenessFaceData | null {
66
66
  faceSizeRatio: raw.faceSizeRatio,
67
67
  faceCount: raw.faceCount,
68
68
  brightness: raw.brightness,
69
+ // −1 is the natives' "not available" sentinel (iOS Vision has no tracking
70
+ // id; neither reports a centre for the no-face frame). Map it to undefined
71
+ // so the continuity guard sees an absent signal rather than a real value of
72
+ // −1 sitting outside the 0–1 range.
73
+ faceCenterX: raw.faceCenterX >= 0 ? raw.faceCenterX : undefined,
74
+ faceCenterY: raw.faceCenterY >= 0 ? raw.faceCenterY : undefined,
75
+ trackingId: raw.trackingId >= 0 ? raw.trackingId : undefined,
76
+ // −1 is the native "could not sample" sentinel; mapping it to undefined
77
+ // keeps "unknown" out of the arithmetic, where it would read as pure black
78
+ // and manufacture a colour shift.
79
+ faceRgb:
80
+ raw.faceR >= 0 && raw.faceG >= 0 && raw.faceB >= 0
81
+ ? ([raw.faceR, raw.faceG, raw.faceB] as const)
82
+ : undefined,
69
83
  };
70
84
  }
71
85
 
@@ -0,0 +1,101 @@
1
+ import { parseMrz, type MrzScan } from './parse';
2
+
3
+ // ---------------------------------------------------------------------------
4
+ // Pulling an MRZ out of whatever the text recogniser returned.
5
+ //
6
+ // OCR output is messy: it may return the MRZ lines separately, merge them into
7
+ // one, split one across two, or interleave them with the rest of the photo
8
+ // page. Rather than trusting layout, this sanitises every line, keeps the
9
+ // MRZ-shaped ones, and tries each plausible grouping.
10
+ //
11
+ // Guessing is safe because CORRECTNESS COMES FROM THE CHECK DIGITS, not from
12
+ // this file. Anything a grouping produces still has to validate in `parseMrz`,
13
+ // so a wrong guess is discarded rather than believed.
14
+ // ---------------------------------------------------------------------------
15
+
16
+ /** Strips everything that cannot appear in an MRZ and upper-cases the rest. */
17
+ export function sanitizeMrzLine(raw: string): string {
18
+ return (
19
+ raw
20
+ .toUpperCase()
21
+ // ML Kit's latin model (Android) reads OCR-B's '<' filler as guillemets
22
+ // constantly — '«' for a '<<' pair, '‹' for a single. Stripping them (the
23
+ // old behaviour) SHORTENED the line past what `fit` tolerates, which is a
24
+ // big part of why Android never had the MRZ after the first capture.
25
+ // Mapping is safe here for the same reason every guess in this file is:
26
+ // the check digits decide, not us.
27
+ .replace(/«/g, '<<')
28
+ .replace(/»/g, '<<')
29
+ .replace(/[‹›]/g, '<')
30
+ .replace(/[^A-Z0-9<]/g, '')
31
+ );
32
+ }
33
+
34
+ /**
35
+ * Whether a line looks like part of an MRZ rather than ordinary page text.
36
+ *
37
+ * MRZ lines are filler-padded, so `<` density is the strongest signal — far
38
+ * more reliable than length alone, since a passport's printed fields produce
39
+ * plenty of long lines.
40
+ */
41
+ export function looksLikeMrzLine(sanitized: string): boolean {
42
+ if (sanitized.length < 28) return false;
43
+ const fillers = (sanitized.match(/</g) ?? []).length;
44
+ return fillers >= 2;
45
+ }
46
+
47
+ /**
48
+ * Coerce a candidate to exactly `width`.
49
+ *
50
+ * Recognisers commonly clip a trailing filler or bolt on a stray glyph from the
51
+ * page edge, so a near-miss is worth one attempt — the check digits reject it
52
+ * if the guess was wrong, which costs nothing but a frame.
53
+ */
54
+ function fit(line: string, width: number): string | null {
55
+ if (line.length === width) return line;
56
+ // Short by a filler or two: MRZ lines are '<'-padded on the right.
57
+ if (line.length >= width - 2 && line.length < width) return line.padEnd(width, '<');
58
+ // Long by a stray glyph or three: drop the trailing noise.
59
+ if (line.length > width && line.length <= width + 3) return line.slice(0, width);
60
+ return null;
61
+ }
62
+
63
+ /**
64
+ * Read an MRZ out of one frame's recognised lines, or null when this frame does
65
+ * not carry a complete, valid one.
66
+ */
67
+ export function extractMrz(recognizedLines: string[], now: Date = new Date()): MrzScan | null {
68
+ const candidates = recognizedLines
69
+ .map(sanitizeMrzLine)
70
+ .filter(looksLikeMrzLine);
71
+ if (candidates.length === 0) return null;
72
+
73
+ // 1) A single line already holding the whole MRZ (some recognisers merge).
74
+ for (const line of candidates) {
75
+ if (line.length === 88 || line.length === 90) {
76
+ const scan = parseMrz(line, now);
77
+ if (scan) return scan;
78
+ }
79
+ }
80
+
81
+ // 2) TD3 — two adjacent 44-char lines.
82
+ for (let i = 0; i + 1 < candidates.length; i++) {
83
+ const a = fit(candidates[i]!, 44);
84
+ const b = fit(candidates[i + 1]!, 44);
85
+ if (!a || !b) continue;
86
+ const scan = parseMrz(a + b, now);
87
+ if (scan) return scan;
88
+ }
89
+
90
+ // 3) TD1 — three adjacent 30-char lines.
91
+ for (let i = 0; i + 2 < candidates.length; i++) {
92
+ const a = fit(candidates[i]!, 30);
93
+ const b = fit(candidates[i + 1]!, 30);
94
+ const c = fit(candidates[i + 2]!, 30);
95
+ if (!a || !b || !c) continue;
96
+ const scan = parseMrz(a + b + c, now);
97
+ if (scan) return scan;
98
+ }
99
+
100
+ return null;
101
+ }