@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
@@ -0,0 +1,173 @@
1
+ import React, { useCallback, useRef, useState } from 'react';
2
+ import { View } from 'react-native';
3
+ import {
4
+ Camera,
5
+ useCameraDevice,
6
+ useCameraPermission,
7
+ useFrameOutput,
8
+ } from 'react-native-vision-camera';
9
+ import { runOnJS } from 'react-native-worklets';
10
+
11
+ import { radius, spacing } from '../config/theme';
12
+ import { useTheme } from '../components/runtime';
13
+ import { MyazaText } from '../components/Typography';
14
+ import { MyazaButton } from '../components/MyazaButton';
15
+ import { MyazaPulseLoader } from '../components/MyazaPulseLoader';
16
+ import { extractMrz } from '../mrz/extract';
17
+ import { recognizeMrzLines, MRZ_BAND_FRACTION } from '../mrz/textRecognizer';
18
+ import type { MrzScan } from '../mrz/parse';
19
+
20
+ // ---------------------------------------------------------------------------
21
+ // Live MRZ scan — the camera fallback that unlocks the chip.
22
+ //
23
+ // The MRZ printed on the photo page IS the chip's access key (document number,
24
+ // date of birth, expiry). The document step normally lifts it off the photo the
25
+ // user already took; when that read fails there was previously nowhere to go,
26
+ // and the chip simply could not be opened. This is that second chance, and it
27
+ // is the same capability the Flutter SDK offers.
28
+ //
29
+ // Frames are recognised one at a time — `dropFramesWhileBusy` skips whatever
30
+ // arrives mid-recognition rather than queueing it. OCR is far slower than the
31
+ // camera, so a queue would only serve progressively staler frames.
32
+ //
33
+ // A frame that does not yield a CHECK-DIGIT-VALID MRZ is discarded silently and
34
+ // the next one tried. That is what makes continuous scanning feel instant while
35
+ // still refusing a misread: the check digits are the whole reason this can be
36
+ // trusted to run unattended, since a wrong key just makes the chip refuse.
37
+ // ---------------------------------------------------------------------------
38
+
39
+ export function MrzScanView({
40
+ onScanned,
41
+ }: {
42
+ onScanned: (scan: MrzScan) => void;
43
+ }): React.ReactElement {
44
+ const { colors } = useTheme();
45
+ const device = useCameraDevice('back');
46
+ const { hasPermission, requestPermission } = useCameraPermission();
47
+ const [denied, setDenied] = useState(false);
48
+
49
+ // A ref, not state: the worklet callback lands on the JS thread after the
50
+ // frame that found the MRZ, and a stale render would let a second frame
51
+ // report the same document again.
52
+ const doneRef = useRef(false);
53
+
54
+ const handleLines = useCallback(
55
+ (lines: string[]) => {
56
+ if (doneRef.current || lines.length === 0) return;
57
+ const scan = extractMrz(lines);
58
+ if (!scan) return; // partial or misread — keep looking
59
+ doneRef.current = true;
60
+ onScanned(scan);
61
+ },
62
+ [onScanned],
63
+ );
64
+
65
+ const frameOutput = useFrameOutput({
66
+ // Both recognisers (Apple Vision, ML Kit) consume YUV directly, so this
67
+ // avoids a conversion per frame.
68
+ pixelFormat: 'yuv',
69
+ dropFramesWhileBusy: true,
70
+ onFrame: (frame) => {
71
+ 'worklet';
72
+ const lines = recognizeMrzLines(frame);
73
+ frame.dispose();
74
+ runOnJS(handleLines)(lines);
75
+ },
76
+ // Drops are the normal state of affairs here, not a fault worth logging.
77
+ onFrameDropped: () => {
78
+ 'worklet';
79
+ },
80
+ });
81
+
82
+ const askForCamera = useCallback(() => {
83
+ void requestPermission().then((granted) => {
84
+ if (!granted) setDenied(true);
85
+ });
86
+ }, [requestPermission]);
87
+
88
+ if (!hasPermission) {
89
+ return (
90
+ <Centered>
91
+ <MyazaText variant="bodyMedium" style={{ textAlign: 'center' }}>
92
+ {denied
93
+ ? 'Camera access is off, so the printed code cannot be read. You can still continue without the chip.'
94
+ : 'We need the camera to read the printed code on your document.'}
95
+ </MyazaText>
96
+ {denied ? null : (
97
+ <>
98
+ <View style={{ height: spacing.md }} />
99
+ <MyazaButton label="Allow camera" onPress={askForCamera} />
100
+ </>
101
+ )}
102
+ </Centered>
103
+ );
104
+ }
105
+
106
+ if (device == null) {
107
+ return (
108
+ <Centered>
109
+ <MyazaText variant="bodyMedium" color={colors.error} style={{ textAlign: 'center' }}>
110
+ No camera is available on this device.
111
+ </MyazaText>
112
+ </Centered>
113
+ );
114
+ }
115
+
116
+ return (
117
+ <View
118
+ style={{
119
+ // 1.42:1 — a passport photo page is wider than it is tall, and framing
120
+ // the preview to the document is what stops users holding it too far
121
+ // away for the MRZ glyphs to resolve.
122
+ aspectRatio: 1.42,
123
+ borderRadius: radius.md,
124
+ overflow: 'hidden',
125
+ backgroundColor: colors.textDark,
126
+ }}
127
+ >
128
+ <Camera
129
+ style={{ flex: 1 }}
130
+ device={device}
131
+ isActive
132
+ outputs={[frameOutput]}
133
+ />
134
+
135
+ {/* The band the recogniser actually reads, drawn so the user aims the
136
+ strip into it rather than guessing. Kept in lockstep with
137
+ MRZ_BAND_FRACTION — a guide that disagrees with the crop would send
138
+ people to aim at the wrong part of the page. */}
139
+ <View
140
+ pointerEvents="none"
141
+ style={{
142
+ position: 'absolute',
143
+ left: 0,
144
+ right: 0,
145
+ bottom: 0,
146
+ height: `${MRZ_BAND_FRACTION * 100}%`,
147
+ borderTopWidth: 2,
148
+ borderColor: `${colors.primary}CC`,
149
+ backgroundColor: `${colors.primary}14`,
150
+ }}
151
+ />
152
+
153
+ <View
154
+ pointerEvents="none"
155
+ style={{
156
+ position: 'absolute',
157
+ left: 0,
158
+ right: 0,
159
+ bottom: spacing.sm,
160
+ alignItems: 'center',
161
+ }}
162
+ >
163
+ <MyazaPulseLoader size={20} />
164
+ </View>
165
+ </View>
166
+ );
167
+ }
168
+
169
+ function Centered({ children }: { children: React.ReactNode }): React.ReactElement {
170
+ return (
171
+ <View style={{ paddingVertical: spacing.xl, alignItems: 'center' }}>{children}</View>
172
+ );
173
+ }
@@ -0,0 +1,290 @@
1
+ import React, { useCallback, useEffect, useRef, useState } from 'react';
2
+ import { View } from 'react-native';
3
+
4
+ import { spacing } from '../config/theme';
5
+ import { useKyc, useKycConfig, useKycStore, useTheme } from '../components/runtime';
6
+ import { MyazaText } from '../components/Typography';
7
+ import { MyazaButton } from '../components/MyazaButton';
8
+ import { MyazaPulseLoader } from '../components/MyazaPulseLoader';
9
+ import { Icon } from '../components/Icon';
10
+ import { cancelChipRead, nfcUnavailableReason, readPassportChip, type EmrtdReadResult } from '../emrtd';
11
+ import { useNfcAvailability } from './nfc/useNfcAvailability';
12
+ import { readErrorMessage } from './nfc/readErrorMessage';
13
+ import { successHaptic } from '../services/haptics';
14
+ import { NfcSuccessPanel } from './nfc/NfcSuccessPanel';
15
+ import { NfcMrzPrompt } from './nfc/NfcMrzPrompt';
16
+ import { NfcReadBody } from './nfc/NfcReadBody';
17
+ import type { NfcReadStage } from '../emrtd';
18
+ import type { MrzScan } from '../mrz/parse';
19
+
20
+ // ---------------------------------------------------------------------------
21
+ // Reading the passport's chip.
22
+ //
23
+ // The chip holds the same biodata as the printed page, but SIGNED by the
24
+ // issuing government — which is what makes it worth reading. The MRZ scanned
25
+ // from the photo page is the key that unlocks it: possession of the document is
26
+ // the access control.
27
+ //
28
+ // Everything here is BEST-EFFORT. A phone without NFC, a chip that will not
29
+ // read, a user who gives up — all of them continue the flow. Absence of a chip
30
+ // read is not evidence of anything; the document checks that already ran still
31
+ // stand, and the server treats a missing chip as a missing signal rather than a
32
+ // failure. The ONE exception lives on the server: a chip that positively fails
33
+ // its cryptographic checks fails the verification, because that is proof of
34
+ // tampering rather than absence of proof.
35
+ // ---------------------------------------------------------------------------
36
+
37
+ export const nfcMeta = {
38
+ title: 'Scan Document Chip',
39
+ description: 'Hold your document to the back of your phone.',
40
+ };
41
+
42
+ type Phase = 'idle' | 'reading' | 'done' | 'failed';
43
+
44
+ // Attempt-first skip: the escape hatch is EARNED, never ambient. It appears
45
+ // after the first failed read — or after this long on the step, which covers
46
+ // the reads that never fail but never end either (Android reader mode sits
47
+ // listening for a tag the user can't position) and an MRZ hunt that won't lock
48
+ // on. Showing it on arrival invites "don't feel like it" exits from users
49
+ // whose chip would have read fine; hiding it forever traps the ones whose chip
50
+ // never will. Once revealed it stays.
51
+ const SKIP_REVEAL_MS = 15_000;
52
+
53
+ export function NfcStep(): React.ReactElement {
54
+ const config = useKycConfig();
55
+ const store = useKycStore();
56
+ const { colors } = useTheme();
57
+ const mrz = useKyc((s) => s.mrzScan);
58
+
59
+ const [phase, setPhase] = useState<Phase>('idle');
60
+ const [error, setError] = useState<string | null>(null);
61
+ // Dev builds also show WHERE the read died and the raw platform error — the
62
+ // friendly message deliberately hides both, which made field reports
63
+ // ("the connection dropped") impossible to tell apart: a session that never
64
+ // opened, a BAC refusal at first contact, and a mid-transfer drop all
65
+ // rendered identically.
66
+ const [result, setResult] = useState<EmrtdReadResult | null>(null);
67
+ const [stage, setStage] = useState<NfcReadStage>('waiting');
68
+ const stageRef = useRef<NfcReadStage>('waiting');
69
+ const liveRef = useRef(true);
70
+ // Tracked separately from `phase` because the cleanup below runs after the
71
+ // last render and would otherwise close over a stale value.
72
+ const readingRef = useRef(false);
73
+ useEffect(() => () => {
74
+ liveRef.current = false;
75
+ // Leaving this step must close the session UNCONDITIONALLY. Opening one
76
+ // BLOCKS until a document is presented — up to a minute on Android — so a
77
+ // user who backs out during that wait would otherwise strand reader mode
78
+ // on an activity the flow has left, and hold iOS's system sheet over the
79
+ // next screen. It used to be gated on `readingRef`, which is cleared in
80
+ // the read's `finally` — so after a FAILED read, leaving performed no
81
+ // cleanup at all and reader mode outlived the screen for the rest of the
82
+ // process. cancelChipRead is idempotent; calling it with nothing open is
83
+ // free.
84
+ void cancelChipRead();
85
+ }, []);
86
+
87
+ // Settled, not sampled: a false on the first render can just mean the native
88
+ // module has not landed yet, and acting on it deletes this step entirely.
89
+ const availability = useNfcAvailability();
90
+ const advance = useCallback(() => store.getState().nextStep(), [store]);
91
+
92
+ // Attempt-first skip reveal (see SKIP_REVEAL_MS above). A failed read also
93
+ // reveals it immediately, in the read's catch.
94
+ const allowSkip = config.nfc?.allowSkip === true;
95
+ const [skipRevealed, setSkipRevealed] = useState(false);
96
+ useEffect(() => {
97
+ if (!allowSkip || skipRevealed) return;
98
+ const timer = setTimeout(() => setSkipRevealed(true), SKIP_REVEAL_MS);
99
+ return () => clearTimeout(timer);
100
+ }, [allowSkip, skipRevealed]);
101
+
102
+ useEffect(() => {
103
+ // NOTHING auto-skips this step any more.
104
+ //
105
+ // `readingAvailable` was the gate, and it has been wrong every time we could
106
+ // check it against reality — reporting unsupported on a phone that had read
107
+ // chips minutes earlier, across app restarts, a foreground transition and a
108
+ // reinstall. Two plausible causes (the Nitro registry not yet populated,
109
+ // then iOS answering mid-foreground-transition) each explained one
110
+ // occurrence and not the next, so the real one is still unidentified.
111
+ //
112
+ // Gating on a signal we cannot trust is worse than not gating at all, and
113
+ // the costs are wildly asymmetric: a wrong skip silently removes a
114
+ // verification factor and tells nobody, while a wrong render costs one tap
115
+ // on a device with no NFC — where the user still has the skip affordance and
116
+ // the back button.
117
+ //
118
+ // So the step renders regardless and the READ decides. If NFC genuinely is
119
+ // unavailable, iOS says so when the session opens: an honest answer, and a
120
+ // far better diagnostic than a screen that never appeared.
121
+ if (__DEV__ && availability === 'unsupported') {
122
+ console.log('[myaza] NFC reported unavailable, showing the step anyway:', nfcUnavailableReason() ?? 'unknown');
123
+ }
124
+ }, [availability]);
125
+
126
+ // A read that has been superseded (a newer read started, or the step was
127
+ // left) still settles eventually — the retry loop sleeps between attempts —
128
+ // and its late rejection must not clobber the state of whatever replaced it.
129
+ const readSeqRef = useRef(0);
130
+ const read = useCallback(async () => {
131
+ if (!mrz) return;
132
+ const seq = ++readSeqRef.current;
133
+ setPhase('reading');
134
+ setError(null);
135
+ setStage('waiting');
136
+ stageRef.current = 'waiting';
137
+ readingRef.current = true;
138
+ try {
139
+ const result = await readPassportChip(
140
+ {
141
+ documentNumber: mrz.documentNumber,
142
+ dateOfBirth: toMrzDate(mrz.dateOfBirth),
143
+ dateOfExpiry: toMrzDate(mrz.dateOfExpiry),
144
+ },
145
+ {
146
+ onStage: (s) => {
147
+ if (!liveRef.current || seq !== readSeqRef.current) return;
148
+ stageRef.current = s;
149
+ setStage(s);
150
+ },
151
+ },
152
+ );
153
+ if (!liveRef.current || seq !== readSeqRef.current) return;
154
+ store.getState().setChipData(result);
155
+ setResult(result);
156
+ setPhase('done');
157
+ // A chip read has no shutter, no sound, nothing physical — the haptic is
158
+ // what makes it land as a completed action. Fire-and-forget: hardware
159
+ // without one simply does nothing.
160
+ successHaptic();
161
+ } catch (err) {
162
+ if (!liveRef.current || seq !== readSeqRef.current) return;
163
+ setPhase('failed');
164
+ // A real attempt just failed — the skip is earned now, no need to make
165
+ // the user wait out the reveal timer on top of a failure.
166
+ setSkipRevealed(true);
167
+ // Never the platform's own string — see readErrorMessage. The stage
168
+ // disambiguates a reader that never started from a chip that dropped.
169
+ setError(readErrorMessage(err, stageRef.current));
170
+ // Console ONLY — a stack trace on a KYC screen reads as a crash to the
171
+ // person holding the passport, and dev builds are exactly where real
172
+ // documents get tested. The friendly message above is the UI.
173
+ if (__DEV__) {
174
+ const raw = err instanceof Error ? err.message : String(err ?? '');
175
+ console.warn(`[kyc.nfc] read died at '${stageRef.current}' — ${raw}`);
176
+ }
177
+ } finally {
178
+ if (seq === readSeqRef.current) readingRef.current = false;
179
+ }
180
+ }, [mrz, store, advance]);
181
+
182
+ // Start the read as soon as the step opens.
183
+ //
184
+ // The MRZ was already lifted off the photo page during document capture, so
185
+ // everything needed to unlock the chip is in hand — asking the user to press
186
+ // a button first adds a step that answers no question. Flutter goes straight
187
+ // to the read for the same reason, and a user who has just been told to hold
188
+ // the document to the phone expects it to be listening.
189
+ //
190
+ // Once per arrival: `startedRef` is cleared only when the step is left, so a
191
+ // failure shows its own Try again rather than being restarted underneath the
192
+ // user.
193
+ const startedRef = useRef(false);
194
+ useEffect(() => {
195
+ // Attempt the read on any settled verdict, not only a positive one — the
196
+ // availability check is no longer trusted to veto (see above). `checking`
197
+ // still waits, so a session is never opened before the module has resolved.
198
+ if (availability === 'checking' || !mrz) return;
199
+ if (startedRef.current) return;
200
+ startedRef.current = true;
201
+ void read();
202
+ }, [availability, mrz, read]);
203
+
204
+ // The success panel lives in its own file (200-line rule) — it is the only
205
+ // part of this step with real layout.
206
+ if (phase === 'done' && result) {
207
+ return (
208
+ <NfcSuccessPanel
209
+ result={result}
210
+ mrz={mrz}
211
+ onContinue={advance}
212
+ onRescan={() => {
213
+ // Straight back into a read — there is no idle "Scan chip" button any
214
+ // more (the step reads on arrival, like Flutter), so parking on idle
215
+ // would dead-end.
216
+ setResult(null);
217
+ void read();
218
+ }}
219
+ />
220
+ );
221
+ }
222
+
223
+
224
+ // The live MRZ scan lives in its own file (200-line rule).
225
+ if (!mrz) {
226
+ return (
227
+ <NfcMrzPrompt
228
+ onScanned={(scan) => store.getState().setMrzScan(scan)}
229
+ onSkip={allowSkip && skipRevealed ? advance : null}
230
+ />
231
+ );
232
+ }
233
+
234
+ return (
235
+ <View style={{ alignItems: 'center' }}>
236
+ <NfcReadBody reading={phase === 'reading'} stage={stage} scan={mrz} />
237
+
238
+ {error ? (
239
+ <>
240
+ <View style={{ height: spacing.md }} />
241
+ <MyazaText variant="bodySmall" color={colors.error} style={{ textAlign: 'center' }}>
242
+ {error}
243
+ </MyazaText>
244
+ </>
245
+ ) : null}
246
+
247
+ <View style={{ height: spacing.lg }} />
248
+ <View style={{ alignSelf: 'stretch' }}>
249
+ {/* No "Scan chip" button: the read starts on arrival (like Flutter),
250
+ so a button naming an action already underway only invites a tap
251
+ that restarts it. The retry appears only once there is something to
252
+ retry. */}
253
+ {phase === 'failed' ? (
254
+ <MyazaButton label="Try scanning the chip again" onPress={() => void read()} />
255
+ ) : null}
256
+ {/* Attempt-first: hidden until a failed read (or the reveal timer)
257
+ earns it — see SKIP_REVEAL_MS. Opt-IN, matching Flutter
258
+ (`allowSkip: json['allowSkip'] ?? false`): an absent flag shows no
259
+ skip, because that is what the server sends unless an org turns it
260
+ on. Once revealed it stays tappable even mid-read while the reader
261
+ is still WAITING for a tag — that wait can be endless on Android,
262
+ and leaving cancels the session (the unmount cleanup) — but not
263
+ once a chip is actually transferring, where a tap would rip up a
264
+ read that is about to succeed. */}
265
+ {allowSkip && skipRevealed ? (
266
+ <>
267
+ {phase === 'failed' ? <View style={{ height: spacing.sm }} /> : null}
268
+ <MyazaButton
269
+ label="Continue without the chip"
270
+ variant="ghost"
271
+ disabled={phase === 'reading' && stage !== 'waiting'}
272
+ onPress={advance}
273
+ />
274
+ </>
275
+ ) : null}
276
+ </View>
277
+ </View>
278
+ );
279
+ }
280
+
281
+ /**
282
+ * ISO date (YYYY-MM-DD) → the MRZ's YYMMDD.
283
+ *
284
+ * The BAC key is computed over the MRZ form, so this has to be the two-digit
285
+ * year the document actually prints — not the four-digit one the rest of the
286
+ * SDK passes around.
287
+ */
288
+ function toMrzDate(iso: string): string {
289
+ return iso.slice(2, 4) + iso.slice(5, 7) + iso.slice(8, 10);
290
+ }