@myazahq/kyc-sdk-react-native 2.0.1 → 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 (283) 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/CameraPermissionView.tsx +39 -3
  99. package/src/components/CameraViewfinder.tsx +226 -70
  100. package/src/components/ContactCodeEntry.tsx +139 -0
  101. package/src/components/CountryField.tsx +87 -0
  102. package/src/components/CountryFlag.tsx +15 -15
  103. package/src/components/DialCodePicker.tsx +186 -0
  104. package/src/components/DocumentCropper.tsx +1 -1
  105. package/src/components/DocumentReview.tsx +142 -0
  106. package/src/components/DocumentReviewSide.tsx +135 -0
  107. package/src/components/DocumentReviewZoom.tsx +119 -0
  108. package/src/components/ExpiryCountdown.tsx +52 -0
  109. package/src/components/FlashOverlay.tsx +156 -0
  110. package/src/components/Icon.tsx +67 -1
  111. package/src/components/KycFlow.tsx +66 -94
  112. package/src/components/KycSheet.tsx +148 -18
  113. package/src/components/MediaSourceSheet.tsx +163 -0
  114. package/src/components/MyazaButton.tsx +14 -2
  115. package/src/components/MyazaDateField.tsx +240 -0
  116. package/src/components/MyazaInput.tsx +73 -26
  117. package/src/components/MyazaSelect.tsx +283 -0
  118. package/src/components/MyazaWordmark.tsx +33 -0
  119. package/src/components/OptionRow.tsx +132 -0
  120. package/src/components/PhoneNumberInput.tsx +149 -0
  121. package/src/components/PoweredBy.tsx +89 -0
  122. package/src/components/ReadyPrimer.tsx +149 -0
  123. package/src/components/StepView.tsx +91 -0
  124. package/src/components/Typography.tsx +18 -3
  125. package/src/components/VerifiedNotice.tsx +38 -0
  126. package/src/components/WhatsAppIcon.tsx +29 -0
  127. package/src/components/WorkflowGate.tsx +128 -0
  128. package/src/components/brand-font.ts +113 -0
  129. package/src/components/flashHoleGeometry.ts +35 -0
  130. package/src/components/flow/FatalConfigError.tsx +56 -0
  131. package/src/components/fonts.ts +2 -19
  132. package/src/components/readyPrimerContent.ts +52 -0
  133. package/src/components/runtime.tsx +38 -43
  134. package/src/components/stepHeaderMeta.tsx +87 -0
  135. package/src/components/theme-provider.tsx +89 -0
  136. package/src/components/useWorkflowMount.ts +116 -0
  137. package/src/components/viewfinder/CardGuide.tsx +60 -0
  138. package/src/components/viewfinder/DocumentGhost.tsx +149 -0
  139. package/src/components/viewfinder/ImmersiveBottomBar.tsx +172 -0
  140. package/src/components/viewfinder/ImmersiveControls.tsx +127 -0
  141. package/src/components/viewfinder/ImmersiveGuide.tsx +174 -0
  142. package/src/components/viewfinder/ImmersiveOverlay.tsx +79 -0
  143. package/src/components/viewfinder/ViewfinderControls.tsx +154 -0
  144. package/src/config/brand.ts +97 -0
  145. package/src/config/business.ts +179 -0
  146. package/src/config/businessSteps.ts +105 -0
  147. package/src/config/contact.ts +159 -0
  148. package/src/config/countryNames.g.ts +254 -0
  149. package/src/config/currencyFlags.ts +144 -0
  150. package/src/config/font-resolve.ts +78 -0
  151. package/src/config/idTypes.ts +98 -1
  152. package/src/config/keyPeople.ts +152 -0
  153. package/src/config/phone.ts +39 -0
  154. package/src/config/proofOfAddress.ts +75 -0
  155. package/src/config/questionnaire.ts +153 -0
  156. package/src/config/regions.ts +118 -0
  157. package/src/config/registrationHint.ts +87 -0
  158. package/src/config/stepOrder.ts +118 -0
  159. package/src/config/theme.ts +38 -2
  160. package/src/config/workflowMerge.ts +124 -0
  161. package/src/emrtd/bac.ts +128 -0
  162. package/src/emrtd/bytes.ts +127 -0
  163. package/src/emrtd/crypto.ts +163 -0
  164. package/src/emrtd/der.ts +192 -0
  165. package/src/emrtd/dg2.ts +111 -0
  166. package/src/emrtd/files.ts +215 -0
  167. package/src/emrtd/index.ts +30 -0
  168. package/src/emrtd/mrzKey.ts +54 -0
  169. package/src/emrtd/native.ts +79 -0
  170. package/src/emrtd/optionalRead.ts +42 -0
  171. package/src/emrtd/read.ts +224 -0
  172. package/src/emrtd/retry.ts +98 -0
  173. package/src/emrtd/secureMessaging.ts +178 -0
  174. package/src/emrtd/session.ts +200 -0
  175. package/src/emrtd/stages.ts +122 -0
  176. package/src/index.ts +110 -0
  177. package/src/lib/calendar.ts +71 -0
  178. package/src/lib/use-keyboard-inset.ts +34 -0
  179. package/src/liveness/challengeManager.ts +27 -1
  180. package/src/liveness/faceContinuity.ts +153 -0
  181. package/src/liveness/flashDetector.ts +145 -0
  182. package/src/liveness/flashReadyGate.ts +106 -0
  183. package/src/liveness/flashRunner.ts +118 -0
  184. package/src/liveness/integritySignals.ts +60 -0
  185. package/src/liveness/livenessState.ts +139 -0
  186. package/src/liveness/speech.ts +19 -1
  187. package/src/liveness/types.ts +48 -1
  188. package/src/liveness/useFlashPhase.ts +78 -0
  189. package/src/liveness/useLiveness.ts +260 -75
  190. package/src/liveness/visionCameraFaceDetector.ts +14 -0
  191. package/src/mrz/extract.ts +101 -0
  192. package/src/mrz/parse.ts +178 -0
  193. package/src/mrz/textRecognizer.ts +149 -0
  194. package/src/screens/ApplicantRoleStep.tsx +331 -0
  195. package/src/screens/BusinessDetailsStep.tsx +212 -0
  196. package/src/screens/BusinessDocumentSlot.tsx +167 -0
  197. package/src/screens/BusinessDocumentsStep.tsx +213 -0
  198. package/src/screens/BusinessKeyPeopleStep.tsx +151 -0
  199. package/src/screens/BusinessKeyPersonRow.tsx +170 -0
  200. package/src/screens/CompanyInfoFields.tsx +83 -0
  201. package/src/screens/ConsentStep.tsx +68 -68
  202. package/src/screens/ContactChannelChoice.tsx +133 -0
  203. package/src/screens/ContactCodeStep.tsx +130 -0
  204. package/src/screens/ContactDestinationField.tsx +70 -0
  205. package/src/screens/ContactFooterNote.tsx +34 -0
  206. package/src/screens/ContactVerificationStep.tsx +199 -0
  207. package/src/screens/ContactVerifiedPanel.tsx +36 -0
  208. package/src/screens/CountrySelectStep.tsx +210 -0
  209. package/src/screens/DocumentCaptureStep.tsx +190 -145
  210. package/src/screens/IdInputStep.tsx +19 -9
  211. package/src/screens/IdTypeStep.tsx +74 -33
  212. package/src/screens/KeyPeopleInviteLinks.tsx +239 -0
  213. package/src/screens/LivenessStep.tsx +181 -267
  214. package/src/screens/MrzScanView.tsx +173 -0
  215. package/src/screens/NfcStep.tsx +290 -0
  216. package/src/screens/ProofOfAddressStep.tsx +238 -0
  217. package/src/screens/QuestionnaireField.tsx +219 -0
  218. package/src/screens/QuestionnaireMoneyField.tsx +134 -0
  219. package/src/screens/QuestionnaireStep.tsx +76 -0
  220. package/src/screens/SubmittedStep.tsx +5 -0
  221. package/src/screens/consent/model.ts +109 -0
  222. package/src/screens/document/CameraPhase.tsx +150 -0
  223. package/src/screens/document/RequiredPill.tsx +60 -0
  224. package/src/screens/liveness/LightingBanner.tsx +50 -0
  225. package/src/screens/liveness/LivenessOutcome.tsx +109 -0
  226. package/src/screens/liveness/ProgressDots.tsx +67 -0
  227. package/src/screens/liveness/SelfiePreview.tsx +39 -0
  228. package/src/screens/liveness/constants.ts +31 -0
  229. package/src/screens/liveness/index.ts +8 -0
  230. package/src/screens/liveness/resolveGuidance.ts +27 -0
  231. package/src/screens/liveness/useSelfieUpload.ts +107 -0
  232. package/src/screens/nfc/NfcMrzPrompt.tsx +40 -0
  233. package/src/screens/nfc/NfcReadBody.tsx +63 -0
  234. package/src/screens/nfc/NfcReadProgress.tsx +96 -0
  235. package/src/screens/nfc/NfcScanIllustration.tsx +244 -0
  236. package/src/screens/nfc/NfcScannedSummary.tsx +74 -0
  237. package/src/screens/nfc/NfcSuccessPanel.tsx +162 -0
  238. package/src/screens/nfc/availability.ts +73 -0
  239. package/src/screens/nfc/readErrorMessage.ts +93 -0
  240. package/src/screens/nfc/useChipPortrait.ts +58 -0
  241. package/src/screens/nfc/useNfcAvailability.ts +82 -0
  242. package/src/screens/useFlashHole.ts +105 -0
  243. package/src/screens/useFlashSequence.ts +96 -0
  244. package/src/screens/usePoaAttach.ts +121 -0
  245. package/src/services/api-types.ts +164 -0
  246. package/src/services/api-verify-types.ts +99 -0
  247. package/src/services/api.ts +72 -102
  248. package/src/services/cardCrop.ts +25 -10
  249. package/src/services/contactErrors.ts +43 -0
  250. package/src/services/deviceMetadata.ts +1 -1
  251. package/src/services/documentPicker.ts +20 -0
  252. package/src/services/fingerprint-sources.ts +129 -0
  253. package/src/services/fingerprint.ts +101 -0
  254. package/src/services/haptics.ts +38 -0
  255. package/src/services/mediaCompress.ts +7 -2
  256. package/src/services/resolveUrl.ts +3 -2
  257. package/src/services/validators.ts +3 -0
  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 -119
  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
@@ -0,0 +1,162 @@
1
+ import React, { useState } from 'react';
2
+ import { Image, View } from 'react-native';
3
+
4
+ import { spacing } from '../../config/theme';
5
+ import { useTheme } from '../../components/runtime';
6
+ import { MyazaText } from '../../components/Typography';
7
+ import { MyazaButton } from '../../components/MyazaButton';
8
+ import { Icon } from '../../components/Icon';
9
+ import { useChipPortrait } from './useChipPortrait';
10
+ import { NfcScannedSummary } from './NfcScannedSummary';
11
+ import type { EmrtdReadResult } from '../../emrtd';
12
+ import type { MrzScan } from '../../mrz/parse';
13
+
14
+ // ---------------------------------------------------------------------------
15
+ // "Chip read successfully".
16
+ //
17
+ // The read used to advance silently, which gave the user nothing to show for
18
+ // the one step that asks them to hold a phone against a passport.
19
+ //
20
+ // The chip's OWN portrait is the confirmation when we can show it: it is the
21
+ // issuing government's photo of the holder, which is a far stronger signal
22
+ // than a tick. Most passports store it as JPEG 2000, which React Native cannot
23
+ // decode, so the generic mark is the ordinary case rather than a failure — the
24
+ // read stands either way and the bytes still go to the server.
25
+ // ---------------------------------------------------------------------------
26
+
27
+ export function NfcSuccessPanel({
28
+ result,
29
+ mrz,
30
+ onContinue,
31
+ onRescan,
32
+ }: {
33
+ result: EmrtdReadResult;
34
+ mrz: MrzScan | null;
35
+ onContinue: () => void;
36
+ onRescan: () => void;
37
+ }): React.ReactElement {
38
+ const { colors } = useTheme();
39
+ const portraitUri = useChipPortrait(result.dg2);
40
+ // A URI that turns out unrenderable (a corrupt slice, a format the Image
41
+ // component rejects) must degrade to the generic mark, not an empty circle.
42
+ const [portraitBroken, setPortraitBroken] = useState(false);
43
+ const portrait = portraitBroken ? null : portraitUri;
44
+ const name = [mrz?.firstName, mrz?.lastName].filter(Boolean).join(' ');
45
+
46
+ return (
47
+ <View style={{ alignItems: 'center' }}>
48
+ <View style={{ height: spacing.lg }} />
49
+ {/* The chip's own portrait, circular, with the success mark tucked into
50
+ its lower-right — 1:1 with Flutter.
51
+
52
+ Circular and large on purpose: this is the issuing government's photo
53
+ of the holder, and seeing it is a far stronger confirmation than a
54
+ tick. The tick rides ON the portrait rather than replacing it, so the
55
+ screen says "verified" and "this is who it says" at once. */}
56
+ <View style={{ width: PORTRAIT, height: PORTRAIT }}>
57
+ {portrait ? (
58
+ <Image
59
+ source={{ uri: portrait }}
60
+ style={{
61
+ width: PORTRAIT,
62
+ height: PORTRAIT,
63
+ borderRadius: PORTRAIT / 2,
64
+ backgroundColor: colors.backgroundSecondary,
65
+ }}
66
+ resizeMode="cover"
67
+ onError={() => {
68
+ if (__DEV__) console.log('[myaza] chip portrait: Image failed to render the URI');
69
+ setPortraitBroken(true);
70
+ }}
71
+ />
72
+ ) : (
73
+ // No renderable portrait — the generic mark, at the same size, so the
74
+ // layout does not jump between documents that carry one and those
75
+ // that do not.
76
+ <View
77
+ style={{
78
+ width: PORTRAIT,
79
+ height: PORTRAIT,
80
+ borderRadius: PORTRAIT / 2,
81
+ backgroundColor: `${colors.primary}1A`,
82
+ alignItems: 'center',
83
+ justifyContent: 'center',
84
+ }}
85
+ >
86
+ <Icon name="badge-check" size={56} color={colors.primary} />
87
+ </View>
88
+ )}
89
+ <View
90
+ style={{
91
+ position: 'absolute',
92
+ right: 0,
93
+ bottom: 2,
94
+ width: BADGE,
95
+ height: BADGE,
96
+ borderRadius: BADGE / 2,
97
+ backgroundColor: colors.success,
98
+ alignItems: 'center',
99
+ justifyContent: 'center',
100
+ // Punched out of the portrait rather than floating over it.
101
+ borderWidth: 3,
102
+ borderColor: colors.background,
103
+ }}
104
+ >
105
+ <Icon name="check" size={20} color="#FFFFFF" />
106
+ </View>
107
+ </View>
108
+
109
+ <View style={{ height: spacing.lg }} />
110
+ <MyazaText variant="heading2" style={{ textAlign: 'center' }}>
111
+ Chip read successfully
112
+ </MyazaText>
113
+ <View style={{ height: spacing.xs }} />
114
+ <MyazaText variant="bodyMedium" color={colors.textSecondary} style={{ textAlign: 'center' }}>
115
+ {name
116
+ ? `We read the secure chip in ${name}’s document.`
117
+ : 'The secure chip in your document was read and verified.'}
118
+ </MyazaText>
119
+
120
+ {/* Said plainly rather than hidden: without the signed security object
121
+ the server cannot verify the chip data against the issuer, so the read
122
+ is worth materially less — and the user is the only one who can
123
+ retry it. */}
124
+ {!result.sod ? (
125
+ <>
126
+ <View style={{ height: spacing.md }} />
127
+ <MyazaText variant="bodySmall" color={colors.textMuted} style={{ textAlign: 'center' }}>
128
+ The document&#8217;s security file could not be read, so the chip data
129
+ cannot be fully verified. You can try again or continue.
130
+ </MyazaText>
131
+ </>
132
+ ) : null}
133
+
134
+ {/* What was read, shown back — the same card the pre-read screen uses, so
135
+ the user checks the same two fields against the printed page. */}
136
+ {mrz ? (
137
+ <>
138
+ <View style={{ height: spacing.lg }} />
139
+ <View style={{ alignSelf: 'stretch' }}>
140
+ <NfcScannedSummary scan={mrz} />
141
+ </View>
142
+ </>
143
+ ) : null}
144
+
145
+ <View style={{ height: spacing.lg }} />
146
+ <View style={{ alignSelf: 'stretch' }}>
147
+ <MyazaButton label="Continue" onPress={onContinue} />
148
+ {!result.sod ? (
149
+ <>
150
+ <View style={{ height: spacing.sm }} />
151
+ <MyazaButton label="Scan again" variant="ghost" onPress={onRescan} />
152
+ </>
153
+ ) : null}
154
+ </View>
155
+ </View>
156
+ );
157
+ }
158
+
159
+ /** Portrait diameter. Large enough to read a face at arm's length. */
160
+ const PORTRAIT = 132;
161
+ /** The success mark overlapping its lower-right corner. */
162
+ const BADGE = 40;
@@ -0,0 +1,73 @@
1
+ // ---------------------------------------------------------------------------
2
+ // Is NFC actually unavailable, or has the native module simply not appeared yet?
3
+ //
4
+ // These two look identical at the first render and mean opposite things. Nitro
5
+ // populates its registry asynchronously, so `isNfcAvailable()` can report false
6
+ // for a moment on a phone that reads chips perfectly well — observed on a real
7
+ // device where three consecutive reloads reported available and the fourth did
8
+ // not, with no code change between them.
9
+ //
10
+ // The consequence is out of proportion to the cause: the chip step SKIPS ITSELF
11
+ // when NFC is unavailable, so one unlucky sample deletes the whole NFC screen
12
+ // with nothing logged. That is how it first surfaced — reported as the screen
13
+ // simply not being there.
14
+ //
15
+ // Deliberately free of native imports so the rule is testable on its own.
16
+ // ---------------------------------------------------------------------------
17
+
18
+ /**
19
+ * How long to keep asking before believing the answer is no.
20
+ *
21
+ * Two failures wear the same face and deserve different patience:
22
+ *
23
+ * • `device_unsupported` — the native module answered, and the answer is that
24
+ * this phone cannot read chips. Final; no amount of waiting changes it.
25
+ * • `module_not_registered` — Nitro's registry has not produced the object
26
+ * yet. That is OUR race, not the device's, and it resolves in milliseconds
27
+ * once the registry lands.
28
+ *
29
+ * The window was 1200ms and the capability probe was still reporting false at
30
+ * 1500ms on real launches — with the rectangle detector failing alongside it,
31
+ * which has nothing to do with NFC hardware and so proves the registry, not the
32
+ * phone, was the problem. Three seconds costs a one-off wait only on devices
33
+ * that genuinely have no NFC, and buys back a step that was vanishing on
34
+ * perfectly capable ones.
35
+ */
36
+ export const SETTLE_MS = 3000;
37
+ /** Gap between samples. Short — the registry usually lands on the first retry. */
38
+ export const POLL_MS = 120;
39
+
40
+ export type NfcAvailability =
41
+ /** Still resolving — render nothing yet. */
42
+ | 'checking'
43
+ /** The chip step can run. */
44
+ | 'available'
45
+ /** The native module answered: this device cannot read chips. Skip. */
46
+ | 'unsupported'
47
+ /**
48
+ * The native module never registered. NOT the same as the device saying no —
49
+ * this is our own wiring failing, and it must not delete a verification step.
50
+ */
51
+ | 'module_missing';
52
+
53
+ /**
54
+ * Asymmetric on purpose: a positive answer is trusted immediately, a negative
55
+ * one only after the window has elapsed.
56
+ *
57
+ * The two errors are not equally costly. Believing a false negative removes a
58
+ * verification step entirely; believing a premature positive costs nothing,
59
+ * because the read itself still has to succeed on its own merits.
60
+ */
61
+ export function settleVerdict(
62
+ present: boolean,
63
+ elapsedMs: number,
64
+ reason?: 'module_not_registered' | 'device_unsupported' | null,
65
+ ): NfcAvailability {
66
+ if (present) return 'available';
67
+ if (elapsedMs < SETTLE_MS) return 'checking';
68
+ // Whose failure is it? A device that says it cannot read chips is a fact to
69
+ // act on. A module that never registered is OUR bug, and silently dropping
70
+ // the chip read to hide it is the worst of the three options — the user is
71
+ // told nothing and the verification quietly loses a factor.
72
+ return reason === 'device_unsupported' ? 'unsupported' : 'module_missing';
73
+ }
@@ -0,0 +1,93 @@
1
+ import { EmrtdSessionError } from '../../emrtd/session';
2
+
3
+ // ---------------------------------------------------------------------------
4
+ // What to tell the user when a chip read fails.
5
+ //
6
+ // NEVER the platform's own string. iOS produces things like
7
+ // Error Domain=NFCError Code=202 "Session invalidated unexpectedly"
8
+ // which was being rendered verbatim on the screen: it tells the user nothing
9
+ // they can act on, and reads as a crash rather than a retryable hiccup.
10
+ //
11
+ // Each message names the thing the user can actually change — where the phone
12
+ // is, which document was scanned, whether to try again. Mirrors the Flutter
13
+ // SDK's `_messageFor`.
14
+ // ---------------------------------------------------------------------------
15
+
16
+ /** Raw platform error shapes worth distinguishing, matched in the string. */
17
+ const RAW_PATTERNS: Array<[RegExp, string]> = [
18
+ // 200 — the user dismissed the system sheet. Not a failure worth apologising
19
+ // for; just offer the read again.
20
+ [/Code=200\b/, 'Chip reading was cancelled. You can try again.'],
21
+ // 201 — the sheet timed out waiting for a document.
22
+ [
23
+ /Code=201\b/,
24
+ 'No chip was found in time. Hold the top edge of your document flat against the back of your phone and try again.',
25
+ ],
26
+ // 202 — the session ended on its own. Almost always the document shifting out
27
+ // of range mid-read, which is exactly what the user should adjust.
28
+ [
29
+ /Code=202\b/,
30
+ 'The connection to the chip dropped. Hold the document still against the back of your phone and try again.',
31
+ ],
32
+ // 203 — the NFC radio is busy elsewhere (Apple Pay, another reader).
33
+ [/Code=203\b/, 'The NFC reader is busy. Wait a moment and try again.'],
34
+ // 100–104 — the transceive-level family: how a physical drop surfaces while a
35
+ // command is in flight (tag lost / retry exceeded / response error / session
36
+ // invalidated / not connected). Same user remedy as 202: keep it still.
37
+ [
38
+ /Code=10[0-4]\b/,
39
+ 'The connection to the chip dropped. Hold the document still against the back of your phone and try again.',
40
+ ],
41
+ // Android's IsoDep raises TagLostException("Tag was lost.") for the same
42
+ // physical case.
43
+ [
44
+ /tag was lost/i,
45
+ 'The connection to the chip dropped. Hold the document still against the back of your phone and try again.',
46
+ ],
47
+ // Android's own detection timeout (see HybridMyazaEmrtd.kt) — the reader
48
+ // waited and no document ever arrived.
49
+ [
50
+ /no chip was detected/i,
51
+ 'No chip was found in time. Hold the top edge of your document flat against the back of your phone and try again.',
52
+ ],
53
+ ];
54
+
55
+ /**
56
+ * A human sentence for a failed chip read.
57
+ *
58
+ * `stageAtFailure` (when the caller tracked it) disambiguates the one code
59
+ * that means two different things: a 202 AFTER chip contact is the document
60
+ * shifting out of range, but a 202 while still `'waiting'` — before any chip
61
+ * was ever detected — means the READER itself failed to start (a wedged NFC
62
+ * daemon, or a build signed without the NFC entitlement). Telling that user
63
+ * to hold the document still sends them chasing a positioning problem that
64
+ * does not exist; a phone restart is what actually clears a wedged reader.
65
+ */
66
+ export function readErrorMessage(err: unknown, stageAtFailure?: string): string {
67
+ if (stageAtFailure === 'waiting') {
68
+ const raw = err instanceof Error ? err.message : String(err ?? '');
69
+ if (/Code=202\b/.test(raw)) {
70
+ return "The NFC reader couldn't start. Restart your phone and try again.";
71
+ }
72
+ }
73
+ if (err instanceof EmrtdSessionError) {
74
+ switch (err.code) {
75
+ case 'bac_failed':
76
+ // The MRZ is check-digit validated, so this is not a typo — the chip
77
+ // belongs to a different document than the page that was scanned.
78
+ return "Couldn't unlock the chip with this document's details. Make sure you scanned the photo page of the same document.";
79
+ case 'select_failed':
80
+ return "This document's chip could not be opened. It may not be an electronic document.";
81
+ case 'read_failed':
82
+ return 'The chip moved out of range. Hold the document still against the back of your phone.';
83
+ default:
84
+ break;
85
+ }
86
+ }
87
+
88
+ const raw = err instanceof Error ? err.message : String(err ?? '');
89
+ for (const [pattern, message] of RAW_PATTERNS) {
90
+ if (pattern.test(raw)) return message;
91
+ }
92
+ return "Couldn't read the chip. Please try again.";
93
+ }
@@ -0,0 +1,58 @@
1
+ import { useEffect, useState } from 'react';
2
+
3
+ import { decodeChipImage } from '../../emrtd';
4
+ import { extractChipPortrait } from '../../emrtd/dg2';
5
+
6
+ // ---------------------------------------------------------------------------
7
+ // The chip's own portrait, made renderable.
8
+ //
9
+ // Seeing the photo the issuing government stored on the chip is a far stronger
10
+ // confirmation than a tick — it is the one moment the user can check the
11
+ // document against itself.
12
+ //
13
+ // Most passports store DG2 as JPEG 2000, which React Native cannot display. The
14
+ // portrait was therefore being read, hash-verified against the signed security
15
+ // object, and then discarded unshown on the majority of documents. The platform
16
+ // has a decoder (ImageIO on iOS, BitmapFactory on Android), so the JP2 case is
17
+ // handed to it and comes back as JPEG.
18
+ //
19
+ // Best-effort throughout: a portrait that cannot be decoded yields null and the
20
+ // panel falls back to its generic mark. The chip read's verdict never depends
21
+ // on whether the picture could be drawn.
22
+ // ---------------------------------------------------------------------------
23
+
24
+ export function useChipPortrait(dg2: string | undefined): string | null {
25
+ // JPEG needs no help — use it immediately rather than waiting a frame on a
26
+ // native round trip that has nothing to do.
27
+ const direct = extractChipPortrait(dg2);
28
+ const [decoded, setDecoded] = useState<string | null>(null);
29
+
30
+ useEffect(() => {
31
+ // Four ways to end up with no picture, and they need different fixes: the
32
+ // chip never gave up DG2 (SOD failed, so it was never requested — or the
33
+ // document lifted during the photo read, the longest transfer), DG2
34
+ // arrived in a format we did not recognise, the platform declined to
35
+ // decode it, or a direct-JPEG slice that does not actually render. Logged
36
+ // on EVERY path — the direct-JPEG case used to be silent, which made "no
37
+ // photo" undiagnosable from the field.
38
+ if (__DEV__) {
39
+ console.log('[myaza] chip portrait:', {
40
+ dg2Bytes: dg2 ? dg2.length : 0,
41
+ format: direct.format,
42
+ direct: direct.dataUri != null,
43
+ });
44
+ }
45
+ if (direct.dataUri || !direct.imageBase64) return;
46
+ let live = true;
47
+ // The extracted IMAGE, not the DG2 container — see ChipPortrait.imageBase64.
48
+ void decodeChipImage(direct.imageBase64).then((jpegBase64) => {
49
+ if (__DEV__) console.log('[myaza] chip portrait decode:', jpegBase64 ? 'ok' : 'declined');
50
+ if (live && jpegBase64) setDecoded(`data:image/jpeg;base64,${jpegBase64}`);
51
+ });
52
+ return () => {
53
+ live = false;
54
+ };
55
+ }, [dg2, direct.dataUri, direct.imageBase64]);
56
+
57
+ return direct.dataUri ?? decoded;
58
+ }
@@ -0,0 +1,82 @@
1
+ import { useEffect, useState } from 'react';
2
+ import { AppState, type AppStateStatus } from 'react-native';
3
+
4
+ import { isNfcAvailable, nfcUnavailableReason } from '../../emrtd';
5
+ import { POLL_MS, settleVerdict, type NfcAvailability } from './availability';
6
+
7
+ export { settleVerdict, type NfcAvailability } from './availability';
8
+
9
+ // ---------------------------------------------------------------------------
10
+ // Is NFC actually unavailable, or has the native module simply not appeared yet?
11
+ //
12
+ // These two look identical at first render and mean opposite things. Nitro's
13
+ // registry is populated asynchronously, so `isNfcAvailable()` can report false
14
+ // for a moment on a phone that reads chips perfectly well — observed on a
15
+ // device where three consecutive reloads reported true and the fourth false,
16
+ // with no code change between them.
17
+ //
18
+ // That mattered because the chip step SKIPS ITSELF when NFC is unavailable. A
19
+ // single unlucky read therefore deleted the whole NFC screen with nothing
20
+ // logged and no way to get back to it — indistinguishable from a phone with no
21
+ // radio, which is why it was first reported as "there was no NFC screen".
22
+ //
23
+ // So: don't decide on one sample. Re-check across a short window and only
24
+ // conclude "unavailable" once it has stayed false the whole time. A device
25
+ // that genuinely has no radio pays a fraction of a second before skipping; a
26
+ // device that has one no longer loses the step to a race.
27
+ // ---------------------------------------------------------------------------
28
+
29
+ /**
30
+ * Availability as a settled verdict rather than a snapshot.
31
+ *
32
+ * `available` is returned as soon as it is true — the wait only ever applies to
33
+ * the negative answer, because that is the one that is destructive if wrong.
34
+ */
35
+ export function useNfcAvailability(): NfcAvailability {
36
+ const [state, setState] = useState<NfcAvailability>(() =>
37
+ isNfcAvailable() ? 'available' : 'checking',
38
+ );
39
+
40
+ // A verdict reached while the app was not fully foregrounded is not a verdict.
41
+ //
42
+ // `NFCTagReaderSession.readingAvailable` is documented as a device capability,
43
+ // but iOS reports it FALSE while the app is backgrounded or mid-transition —
44
+ // and this flow sends users away and back (a passport arrives from a chat, the
45
+ // phone is unlocked mid-step). A device that reads chips perfectly well then
46
+ // answers "unsupported" once, permanently, for that mount.
47
+ //
48
+ // So a settled negative is re-opened when the app becomes active again. The
49
+ // positive is never re-examined: it cannot become wrong.
50
+ useEffect(() => {
51
+ const sub = AppState.addEventListener('change', (next: AppStateStatus) => {
52
+ if (next === 'active') setState((prev) => (prev === 'available' ? prev : 'checking'));
53
+ });
54
+ return () => sub.remove();
55
+ }, []);
56
+
57
+ useEffect(() => {
58
+ if (state !== 'checking') return;
59
+
60
+ let live = true;
61
+ const startedAt = Date.now();
62
+
63
+ const tick = (): void => {
64
+ if (!live) return;
65
+ const verdict = settleVerdict(isNfcAvailable(), Date.now() - startedAt, nfcUnavailableReason());
66
+ if (verdict !== 'checking') {
67
+ setState(verdict);
68
+ return;
69
+ }
70
+ timer = setTimeout(tick, POLL_MS);
71
+ };
72
+
73
+ let timer = setTimeout(tick, POLL_MS);
74
+ return () => {
75
+ live = false;
76
+ clearTimeout(timer);
77
+ };
78
+ // Runs once: after the first resolution the verdict is final for this mount.
79
+ }, [state]);
80
+
81
+ return state;
82
+ }
@@ -0,0 +1,105 @@
1
+ import { useCallback, useEffect, useRef, useState } from 'react';
2
+ import type { View } from 'react-native';
3
+
4
+ import type { FlashHole } from '../components/flashHoleGeometry';
5
+
6
+ // ---------------------------------------------------------------------------
7
+ // Where the preview circle actually sits on the physical screen.
8
+ //
9
+ // The flash paints in a full-screen modal over the sheet, so the hole has to be
10
+ // given in window coordinates — it cannot be derived from the step's own
11
+ // spacing constants.
12
+ //
13
+ // Measuring ONCE is not enough, and this is the subtle part: iOS stacks a
14
+ // presenting sheet BACK when a modal is presented over it. The sheet scales
15
+ // down and shifts, so the preview MOVES as a direct consequence of showing the
16
+ // overlay that is measuring it. A single reading taken before or during that
17
+ // animation describes a position the preview has already left — the observed
18
+ // result was a hole both larger than the preview and higher up, off by exactly
19
+ // the transform.
20
+ //
21
+ // So it re-measures until the answer stops changing, then stops. Nothing else
22
+ // on screen moves during a flash (guidance banners are suppressed), so a
23
+ // settled value stays valid for the rest of the sequence.
24
+ //
25
+ // Failure is not fatal: a null hole paints a solid flash, which still lights
26
+ // the face. See FlashOverlay.
27
+ // ---------------------------------------------------------------------------
28
+
29
+ /** How often to re-read while the position is still settling. */
30
+ const POLL_MS = 120;
31
+
32
+ /**
33
+ * How long to keep watching. Comfortably longer than the sheet-stacking
34
+ * animation, and it stops early as soon as two readings agree.
35
+ */
36
+ const SETTLE_WINDOW_MS = 1000;
37
+
38
+ /** Sub-pixel differences are not movement. */
39
+ const EPSILON = 0.5;
40
+
41
+ function same(a: FlashHole | null, b: FlashHole): boolean {
42
+ return (
43
+ a !== null &&
44
+ Math.abs(a.x - b.x) < EPSILON &&
45
+ Math.abs(a.y - b.y) < EPSILON &&
46
+ Math.abs(a.size - b.size) < EPSILON
47
+ );
48
+ }
49
+
50
+ export function useFlashHole(active: boolean): {
51
+ ref: React.RefObject<View | null>;
52
+ hole: FlashHole | null;
53
+ } {
54
+ const ref = useRef<View | null>(null);
55
+ const [hole, setHole] = useState<FlashHole | null>(null);
56
+ const latest = useRef<FlashHole | null>(null);
57
+
58
+ const measure = useCallback((onSettled: () => void) => {
59
+ const node = ref.current;
60
+ if (!node) return;
61
+ node.measureInWindow((x, y, width, height) => {
62
+ // A zero-sized measurement means the view is not laid out yet; punching a
63
+ // hole there would leave a gap in the wrong place, which is worse than
64
+ // painting solid.
65
+ if (!width || !height) return;
66
+ // The preview is a circle in a square box, so the smaller side is the
67
+ // diameter — guards against a reading taken mid-layout.
68
+ const next = { x, y, size: Math.min(width, height) };
69
+ if (same(latest.current, next)) {
70
+ onSettled();
71
+ return;
72
+ }
73
+ latest.current = next;
74
+ setHole(next);
75
+ });
76
+ }, []);
77
+
78
+ useEffect(() => {
79
+ if (!active) {
80
+ latest.current = null;
81
+ setHole(null);
82
+ return;
83
+ }
84
+
85
+ let timer: ReturnType<typeof setTimeout> | null = null;
86
+ let stopped = false;
87
+ const stop = (): void => {
88
+ stopped = true;
89
+ if (timer) clearTimeout(timer);
90
+ };
91
+ const deadline = Date.now() + SETTLE_WINDOW_MS;
92
+
93
+ const tick = (): void => {
94
+ if (stopped) return;
95
+ measure(stop);
96
+ if (!stopped && Date.now() < deadline) timer = setTimeout(tick, POLL_MS);
97
+ };
98
+
99
+ // One frame's grace so the flash phase's own render has committed.
100
+ timer = setTimeout(tick, 0);
101
+ return stop;
102
+ }, [active, measure]);
103
+
104
+ return { ref, hole };
105
+ }
@@ -0,0 +1,96 @@
1
+ import { useEffect, useRef, useState } from 'react';
2
+
3
+ import { runFlashSequence } from '../liveness/flashRunner';
4
+ import { DEFAULT_FLASH_SEQUENCE, generateFlashSequence } from '../liveness/flashDetector';
5
+ import type { FlashResult, Rgb } from '../liveness/flashDetector';
6
+
7
+ // ---------------------------------------------------------------------------
8
+ // Running the flash sequence from the liveness screen.
9
+ //
10
+ // Split out of LivenessStep (200-line rule). It starts once when the machine
11
+ // asks, and tears down on unmount so a half-finished sequence can never leave
12
+ // the screen painted a solid colour with no way back.
13
+ // ---------------------------------------------------------------------------
14
+
15
+ /** A sequence that could not run at all — unmeasured, which fails soft. */
16
+ const UNMEASURED: FlashResult = {
17
+ passed: true,
18
+ score: 0,
19
+ matched: 0,
20
+ total: 0,
21
+ inconclusive: 0,
22
+ sequence: [],
23
+ };
24
+
25
+ export function useFlashSequence({
26
+ active,
27
+ shouldContinue,
28
+ sequenceLength,
29
+ readFaceRgb,
30
+ onComplete,
31
+ }: {
32
+ /** True while the machine is in the flash phase. */
33
+ active: boolean;
34
+ /**
35
+ * Aborts mid-sequence when it returns false — a second face, or a swapped
36
+ * one. Finishing would measure a reflection off somebody who is not the
37
+ * subject, which is worse than no measurement at all.
38
+ */
39
+ shouldContinue?: () => boolean;
40
+ sequenceLength: number | undefined;
41
+ readFaceRgb: () => Rgb | null;
42
+ onComplete: (result: FlashResult) => void;
43
+ }): { flashColor: string | null } {
44
+ const [flashColor, setFlashColor] = useState<string | null>(null);
45
+ const startedRef = useRef(false);
46
+ const liveRef = useRef(true);
47
+ // The callbacks are read through refs so the effect below never re-runs on
48
+ // their identity — restarting a sequence mid-flight would leave the screen
49
+ // painted and the counter of measured flashes wrong.
50
+ const completeRef = useRef(onComplete);
51
+ completeRef.current = onComplete;
52
+ const readRef = useRef(readFaceRgb);
53
+ readRef.current = readFaceRgb;
54
+ const continueRef = useRef(shouldContinue);
55
+ continueRef.current = shouldContinue;
56
+
57
+ useEffect(
58
+ () => () => {
59
+ liveRef.current = false;
60
+ },
61
+ [],
62
+ );
63
+
64
+ useEffect(() => {
65
+ // Re-arm when the phase leaves flash. The guard exists to stop the sequence
66
+ // restarting while it is ALREADY running (the effect re-runs whenever the
67
+ // screen re-renders), but it was never cleared — so a retake found it still
68
+ // set from the first attempt, returned immediately, and left the machine
69
+ // parked in the flash phase with nothing driving it. The step hung with no
70
+ // error, because from its point of view a sequence was still in flight.
71
+ if (!active) {
72
+ startedRef.current = false;
73
+ return;
74
+ }
75
+ if (startedRef.current) return;
76
+ startedRef.current = true;
77
+ void runFlashSequence(
78
+ {
79
+ setColor: setFlashColor,
80
+ readFaceRgb: () => readRef.current(),
81
+ isActive: () => liveRef.current && (continueRef.current?.() ?? true),
82
+ },
83
+ generateFlashSequence(sequenceLength ?? DEFAULT_FLASH_SEQUENCE),
84
+ )
85
+ .then((result) => completeRef.current(result))
86
+ .catch(() => {
87
+ // A failed sequence is a MISSING measurement, not a failed check — the
88
+ // user is not at fault for a camera hiccup, and the server re-analyses
89
+ // the recorded video regardless.
90
+ setFlashColor(null);
91
+ completeRef.current(UNMEASURED);
92
+ });
93
+ }, [active, sequenceLength]);
94
+
95
+ return { flashColor };
96
+ }