@myazahq/kyc-sdk-react-native 2.5.0 → 3.0.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 (219) hide show
  1. package/README.md +277 -7
  2. package/android/build.gradle +30 -16
  3. package/android/consumer-rules.pro +22 -0
  4. package/android/src/main/AndroidManifest.xml +17 -6
  5. package/android/src/main/java/com/margelo/nitro/myazakyc/HybridMyazaFaceDetector.kt +27 -70
  6. package/android/src/main/java/com/margelo/nitro/myazakyc/HybridMyazaTextRecognizer.kt +14 -0
  7. package/android/src/main/java/com/margelo/nitro/myazakyc/MlKitModelReadiness.kt +154 -0
  8. package/app.plugin.js +146 -10
  9. package/ios/HybridMyazaFaceDetector.swift +8 -3
  10. package/ios/HybridMyazaTextRecognizer.swift +8 -0
  11. package/nitrogen/generated/android/c++/JHybridMyazaTextRecognizerSpec.cpp +21 -0
  12. package/nitrogen/generated/android/c++/JHybridMyazaTextRecognizerSpec.hpp +2 -0
  13. package/nitrogen/generated/android/kotlin/com/margelo/nitro/myazakyc/HybridMyazaTextRecognizerSpec.kt +8 -0
  14. package/nitrogen/generated/ios/c++/HybridMyazaTextRecognizerSpecSwift.hpp +16 -0
  15. package/nitrogen/generated/ios/swift/HybridMyazaTextRecognizerSpec.swift +2 -0
  16. package/nitrogen/generated/ios/swift/HybridMyazaTextRecognizerSpec_cxx.swift +31 -0
  17. package/nitrogen/generated/shared/c++/HybridMyazaTextRecognizerSpec.cpp +2 -0
  18. package/nitrogen/generated/shared/c++/HybridMyazaTextRecognizerSpec.hpp +2 -0
  19. package/package.json +30 -9
  20. package/src/MyazaKYC.tsx +4 -11
  21. package/src/assets/fonts/Karla_400Regular.ttf +0 -0
  22. package/src/assets/fonts/Karla_500Medium.ttf +0 -0
  23. package/src/assets/fonts/Karla_600SemiBold.ttf +0 -0
  24. package/src/assets/fonts/Karla_700Bold.ttf +0 -0
  25. package/src/assets/fonts/OFL-Karla.txt +93 -0
  26. package/src/assets/fonts/OFL-SpaceGrotesk.txt +93 -0
  27. package/src/assets/fonts/README.md +12 -0
  28. package/src/assets/fonts/SpaceGrotesk_500Medium.ttf +0 -0
  29. package/src/assets/fonts/SpaceGrotesk_600SemiBold.ttf +0 -0
  30. package/src/assets/fonts/SpaceGrotesk_700Bold.ttf +0 -0
  31. package/src/capture/useAutoCapture.ts +8 -1
  32. package/src/components/CountryField.tsx +8 -0
  33. package/src/components/CountryRegionPicker.tsx +184 -0
  34. package/src/components/DialCodePicker.tsx +47 -68
  35. package/src/components/DialCodeRow.tsx +102 -0
  36. package/src/components/DocumentCropper.tsx +30 -24
  37. package/src/components/DocumentReview.tsx +11 -1
  38. package/src/components/DocumentReviewSide.tsx +6 -2
  39. package/src/components/DocumentReviewZoom.tsx +5 -1
  40. package/src/components/FramedMapPicker.tsx +122 -0
  41. package/src/components/GeoBadge.tsx +34 -0
  42. package/src/components/Icon.tsx +10 -192
  43. package/src/components/KycFlow.tsx +69 -34
  44. package/src/components/LineSkeleton.tsx +100 -0
  45. package/src/components/MapChrome.tsx +67 -0
  46. package/src/components/MapPinMarker.tsx +54 -0
  47. package/src/components/MapPinPicker.tsx +179 -0
  48. package/src/components/MilestoneTrack.tsx +155 -0
  49. package/src/components/MyazaButton.tsx +9 -0
  50. package/src/components/MyazaInput.tsx +17 -1
  51. package/src/components/OptionRow.tsx +22 -3
  52. package/src/components/PhoneNumberInput.tsx +8 -0
  53. package/src/components/PoweredBy.tsx +5 -5
  54. package/src/components/PresenceBlocks.tsx +153 -0
  55. package/src/components/RequiredDocumentPill.tsx +114 -0
  56. package/src/components/StepHeader.tsx +23 -3
  57. package/src/components/StepView.tsx +17 -0
  58. package/src/components/StickyActions.tsx +56 -0
  59. package/src/components/dialCodeRows.ts +64 -0
  60. package/src/components/documentReviewCopy.ts +39 -0
  61. package/src/components/fonts.ts +14 -17
  62. package/src/components/icon-map.ts +176 -0
  63. package/src/components/stepHeaderMeta.tsx +48 -5
  64. package/src/config/addressCollection.ts +125 -0
  65. package/src/config/biometricOptions.ts +100 -0
  66. package/src/config/business.ts +20 -1
  67. package/src/config/businessSteps.ts +8 -2
  68. package/src/config/consentStep.ts +19 -0
  69. package/src/config/documentCaptureMethods.ts +31 -0
  70. package/src/config/proofOfAddress.ts +54 -11
  71. package/src/config/regions.ts +24 -0
  72. package/src/config/stepOrder.ts +79 -5
  73. package/src/config/uploadLimits.ts +35 -0
  74. package/src/config/workflowMerge.ts +13 -5
  75. package/src/index.ts +49 -1
  76. package/src/lib/address-current-location.ts +171 -0
  77. package/src/lib/address-field-modes.ts +119 -0
  78. package/src/lib/address-flow.ts +172 -0
  79. package/src/lib/address-helpers.ts +48 -0
  80. package/src/lib/address-line.ts +76 -0
  81. package/src/lib/address-pin-move.ts +97 -0
  82. package/src/lib/address-step-recovery.ts +63 -0
  83. package/src/lib/authed-image.ts +78 -0
  84. package/src/lib/biometric-copy.ts +51 -0
  85. package/src/lib/captureRing.ts +83 -0
  86. package/src/lib/country-adoption.ts +89 -0
  87. package/src/lib/documentCaptureCheck.ts +136 -0
  88. package/src/lib/inferred-country.ts +65 -0
  89. package/src/lib/livenessLayout.ts +49 -0
  90. package/src/lib/map-frame.ts +159 -0
  91. package/src/lib/map-tiles.ts +143 -0
  92. package/src/lib/model-ready.ts +82 -0
  93. package/src/lib/poa-country-gate.ts +27 -0
  94. package/src/lib/prime-models.ts +50 -0
  95. package/src/lib/resubmit.ts +36 -7
  96. package/src/lib/result-copy.ts +116 -0
  97. package/src/lib/result-wait.ts +53 -0
  98. package/src/lib/review-map-surface.ts +26 -0
  99. package/src/lib/scope.ts +31 -0
  100. package/src/lib/selfie-sharpness.ts +211 -0
  101. package/src/lib/selfie-upload-wait.ts +79 -0
  102. package/src/lib/street-view-fov.ts +42 -0
  103. package/src/lib/webview-available.ts +47 -0
  104. package/src/liveness/avatarSource.ts +59 -0
  105. package/src/liveness/useLiveness.ts +6 -1
  106. package/src/liveness/useModelReady.ts +9 -55
  107. package/src/mrz/extract.ts +71 -4
  108. package/src/mrz/textRecognizer.ts +36 -0
  109. package/src/mrz/useTextModelReady.ts +21 -0
  110. package/src/presence/background-math.ts +100 -0
  111. package/src/presence/background-store.ts +82 -0
  112. package/src/presence/background.ts +165 -0
  113. package/src/presence/foreground-service.ts +193 -0
  114. package/src/presence/fs.ts +51 -0
  115. package/src/presence/geofence.ts +41 -0
  116. package/src/presence/math.ts +44 -0
  117. package/src/presence/post.ts +40 -0
  118. package/src/presence/report.ts +87 -0
  119. package/src/presence/sampler.ts +110 -0
  120. package/src/presence/status.ts +92 -0
  121. package/src/presence/store.ts +94 -0
  122. package/src/presence/tier.ts +36 -0
  123. package/src/presence/watch-wait.ts +81 -0
  124. package/src/screens/AddressCountryControl.tsx +115 -0
  125. package/src/screens/BusinessDetailsFields.tsx +3 -0
  126. package/src/screens/BusinessDetailsStep.tsx +2 -0
  127. package/src/screens/BusinessDocumentSlot.tsx +3 -2
  128. package/src/screens/BusinessDocumentsStep.tsx +4 -55
  129. package/src/screens/CompanyInfoFields.tsx +6 -1
  130. package/src/screens/ConsentStep.tsx +6 -17
  131. package/src/screens/ContactActions.tsx +54 -0
  132. package/src/screens/ContactDestinationField.tsx +4 -0
  133. package/src/screens/ContactEntryPanel.tsx +75 -0
  134. package/src/screens/ContactVerificationStep.tsx +26 -56
  135. package/src/screens/CountrySelectStep.tsx +13 -147
  136. package/src/screens/DocumentCaptureStep.tsx +177 -93
  137. package/src/screens/LivenessAvatar.tsx +33 -16
  138. package/src/screens/LivenessStep.tsx +188 -49
  139. package/src/screens/MrzScanView.tsx +33 -0
  140. package/src/screens/PoaDocumentTypeList.tsx +66 -0
  141. package/src/screens/ProofOfAddressParts.tsx +151 -0
  142. package/src/screens/ProofOfAddressStep.tsx +58 -99
  143. package/src/screens/SubmittedBadge.tsx +25 -0
  144. package/src/screens/SubmittedError.tsx +64 -0
  145. package/src/screens/SubmittedResult.tsx +117 -0
  146. package/src/screens/SubmittedStep.tsx +71 -164
  147. package/src/screens/SubmittedSuccess.tsx +127 -0
  148. package/src/screens/SubmittedWaiting.tsx +45 -0
  149. package/src/screens/address/AddressEntranceStep.tsx +161 -0
  150. package/src/screens/address/AddressIntroGate.tsx +148 -0
  151. package/src/screens/address/AddressMapStub.tsx +50 -0
  152. package/src/screens/address/AddressPinStep.tsx +178 -0
  153. package/src/screens/address/AddressReviewStep.tsx +162 -0
  154. package/src/screens/address/AddressSandboxTabs.tsx +158 -0
  155. package/src/screens/address/AddressSearchField.tsx +115 -0
  156. package/src/screens/address/AddressSearchStep.tsx +94 -0
  157. package/src/screens/address/CurrentLocationRow.tsx +135 -0
  158. package/src/screens/address/DetailsSheet.tsx +175 -0
  159. package/src/screens/address/DetailsSheetFields.tsx +184 -0
  160. package/src/screens/address/EntranceDropzone.tsx +178 -0
  161. package/src/screens/address/EntranceFraming.tsx +66 -0
  162. package/src/screens/address/EntrancePills.tsx +60 -0
  163. package/src/screens/address/FramedStreetView.tsx +155 -0
  164. package/src/screens/address/IntroDisclosures.tsx +210 -0
  165. package/src/screens/address/LabelDecisionRow.tsx +113 -0
  166. package/src/screens/address/PinSummaryRow.tsx +105 -0
  167. package/src/screens/address/ReviewAddressBand.tsx +111 -0
  168. package/src/screens/address/ReviewEntranceThumbs.tsx +64 -0
  169. package/src/screens/address/ReviewMapPicture.tsx +106 -0
  170. package/src/screens/address/SearchResults.tsx +105 -0
  171. package/src/screens/address/SearchScreen.tsx +176 -0
  172. package/src/screens/address/SkipForNow.tsx +43 -0
  173. package/src/screens/address/StreetViewChrome.tsx +81 -0
  174. package/src/screens/address/detail-values.ts +22 -0
  175. package/src/screens/address/fix-source.ts +27 -0
  176. package/src/screens/address/index.ts +8 -0
  177. package/src/screens/address/meta.ts +44 -0
  178. package/src/screens/address/use-address-flow.ts +200 -0
  179. package/src/screens/address/use-label-pin.ts +80 -0
  180. package/src/screens/address/use-pin-actions.ts +192 -0
  181. package/src/screens/consent/model.ts +79 -10
  182. package/src/screens/document/CaptureCheckNotice.tsx +77 -0
  183. package/src/screens/document/UploadPhase.tsx +177 -0
  184. package/src/screens/document/useDocumentCamera.ts +109 -0
  185. package/src/screens/liveness/CaptureRing.tsx +91 -0
  186. package/src/screens/liveness/LivenessCamera.tsx +63 -0
  187. package/src/screens/liveness/LivenessHandover.tsx +43 -0
  188. package/src/screens/liveness/LivenessOutcome.tsx +8 -4
  189. package/src/screens/liveness/SelfiePreview.tsx +31 -7
  190. package/src/screens/liveness/ShutterFlash.tsx +25 -0
  191. package/src/screens/liveness/index.ts +4 -0
  192. package/src/screens/liveness/useSelfieUpload.ts +23 -3
  193. package/src/screens/nfc/NfcScanIllustration.tsx +7 -18
  194. package/src/screens/useAddressPhotoAttach.ts +77 -0
  195. package/src/screens/useBusinessDocumentAttach.ts +93 -0
  196. package/src/screens/usePoaAttach.ts +12 -15
  197. package/src/services/api-types.ts +119 -1
  198. package/src/services/api-verify-types.ts +37 -0
  199. package/src/services/api.ts +149 -3
  200. package/src/services/deviceMetadata.ts +1 -1
  201. package/src/services/location.ts +234 -0
  202. package/src/services/mediaCompress.ts +23 -5
  203. package/src/services/uploadErrors.ts +30 -0
  204. package/src/services/workflowGate.ts +7 -0
  205. package/src/specs/MyazaTextRecognizer.nitro.ts +29 -0
  206. package/src/store/address.ts +97 -0
  207. package/src/store/derive.ts +29 -4
  208. package/src/store/kycStore.ts +120 -5
  209. package/src/store/serverConfig.ts +18 -0
  210. package/src/store/session.ts +23 -4
  211. package/src/store/state.ts +134 -0
  212. package/src/store/submit.ts +18 -1
  213. package/src/types/config.ts +63 -1
  214. package/src/types/verification.ts +12 -0
  215. package/src/types/workflow.ts +97 -1
  216. package/src/assets/liveness/Blink.gif +0 -0
  217. package/src/assets/liveness/Nod.gif +0 -0
  218. package/src/assets/liveness/Smile.gif +0 -0
  219. package/src/assets/liveness/Turn.gif +0 -0
@@ -0,0 +1,211 @@
1
+ // ─── Was the selfie sharp enough? Measured the moment it is taken ────────────
2
+ //
3
+ // The React Native mirror of the web SDK's lib/selfie-sharpness.ts and the
4
+ // Flutter SDK's utils/selfie_sharpness.dart. Keep the three in lockstep: the
5
+ // floor, the crop fraction and the measuring size are what make a score mean
6
+ // the same thing on every platform.
7
+ //
8
+ // The server can already say a failed face check was caused by a blurry
9
+ // selfie, but that reaches the applicant by webhook long after the phone is
10
+ // back in a pocket. The review screen is the one place a retake costs two
11
+ // seconds, so the same question is asked here.
12
+ //
13
+ // A NOTICE, NEVER A GATE. The floor was not calibrated on real phone captures,
14
+ // and a wrong floor on a gate would trap a genuine applicant in a retake loop.
15
+ // As a notice the cost of a wrong floor is one sentence the applicant can
16
+ // ignore: Continue stays available whatever this says. The server follows the
17
+ // same rule, where capture quality explains a failure and never causes one.
18
+ //
19
+ // MEASURED ON THE FACE, NOT THE FRAME. Auto-capture only fires once the face is
20
+ // centred and fills a good part of the frame, so the centre of the still IS the
21
+ // face. A centred square is also unchanged by a 90 degree rotation or a mirror,
22
+ // so EXIF orientation cannot move the measurement onto the room.
23
+ //
24
+ // The crop and the resize run natively in Nitro Image, so JS only ever reads
25
+ // 160 by 160 pixels, never the full still.
26
+
27
+ /** Below this, the notice shows. A starting value, biased toward NOT showing. */
28
+ export const SELFIE_SHARPNESS_FLOOR = 18;
29
+
30
+ /** Share of the shorter side the centre crop takes. */
31
+ export const SELFIE_CROP_FRACTION = 0.5;
32
+
33
+ /**
34
+ * The crop is resized to a FIXED size before measuring, because Laplacian
35
+ * variance scales with resolution: without it the score would measure the
36
+ * phone's camera rather than the photograph.
37
+ */
38
+ export const SELFIE_MEASURE_SIZE = 160;
39
+
40
+ /**
41
+ * Variance of the 4-neighbour Laplacian over a single-channel plane.
42
+ *
43
+ * Blur is a low-pass filter, so it flattens second derivatives, so the spread
44
+ * of the Laplacian response collapses. Variance rather than mean, because the
45
+ * mean of a Laplacian is near zero on any image, sharp or not. The same measure
46
+ * the server runs, so the two readings can be compared.
47
+ */
48
+ export function laplacianVariance(gray: ArrayLike<number>, width: number, height: number): number {
49
+ // Interior pixels only: the kernel needs all four neighbours.
50
+ if (width < 3 || height < 3 || gray.length < width * height) return 0;
51
+
52
+ let sum = 0;
53
+ let sumSq = 0;
54
+ let n = 0;
55
+ for (let y = 1; y < height - 1; y += 1) {
56
+ const row = y * width;
57
+ for (let x = 1; x < width - 1; x += 1) {
58
+ const i = row + x;
59
+ const v = gray[i - width]! + gray[i + width]! + gray[i - 1]! + gray[i + 1]! - 4 * gray[i]!;
60
+ sum += v;
61
+ sumSq += v * v;
62
+ n += 1;
63
+ }
64
+ }
65
+ if (n === 0) return 0;
66
+ const mean = sum / n;
67
+ return Math.round((sumSq / n - mean * mean) * 100) / 100;
68
+ }
69
+
70
+ /** A centred square covering `fraction` of the shorter side. */
71
+ export function centreCrop(
72
+ width: number,
73
+ height: number,
74
+ fraction: number = SELFIE_CROP_FRACTION,
75
+ ): { sx: number; sy: number; sw: number; sh: number } {
76
+ const side = Math.max(1, Math.round(Math.min(width, height) * fraction));
77
+ return {
78
+ sx: Math.max(0, Math.round((width - side) / 2)),
79
+ sy: Math.max(0, Math.round((height - side) / 2)),
80
+ sw: side,
81
+ sh: side,
82
+ };
83
+ }
84
+
85
+ /**
86
+ * Whether to show the notice. An unmeasurable selfie is NOT blurry: "we could
87
+ * not look" is not evidence the photograph was soft, and saying so would ask a
88
+ * person to retake a photo that may be perfectly good.
89
+ */
90
+ export function isSelfieBlurry(score: number | null): boolean {
91
+ return score != null && score < SELFIE_SHARPNESS_FLOOR;
92
+ }
93
+
94
+ // Where each colour channel sits, per Nitro Image's `pixelFormat`, which names
95
+ // the BYTE order of the buffer it hands back.
96
+ const CHANNELS: Record<string, { r: number; g: number; b: number; bpp: number }> = {
97
+ RGBA: { r: 0, g: 1, b: 2, bpp: 4 },
98
+ RGBX: { r: 0, g: 1, b: 2, bpp: 4 },
99
+ BGRA: { r: 2, g: 1, b: 0, bpp: 4 },
100
+ BGRX: { r: 2, g: 1, b: 0, bpp: 4 },
101
+ ARGB: { r: 1, g: 2, b: 3, bpp: 4 },
102
+ XRGB: { r: 1, g: 2, b: 3, bpp: 4 },
103
+ ABGR: { r: 3, g: 2, b: 1, bpp: 4 },
104
+ XBGR: { r: 3, g: 2, b: 1, bpp: 4 },
105
+ RGB: { r: 0, g: 1, b: 2, bpp: 3 },
106
+ BGR: { r: 2, g: 1, b: 0, bpp: 3 },
107
+ };
108
+
109
+ /**
110
+ * Rec. 601 luminance from a raw pixel buffer, or null for a layout we cannot
111
+ * read. The stride is taken from the buffer itself, because a platform may pad
112
+ * rows and assuming a tight buffer would shear the image diagonally.
113
+ */
114
+ export function grayFromPixels(
115
+ buffer: ArrayBuffer,
116
+ width: number,
117
+ height: number,
118
+ pixelFormat: string,
119
+ ): Uint8Array | null {
120
+ const layout = Object.prototype.hasOwnProperty.call(CHANNELS, pixelFormat)
121
+ ? CHANNELS[pixelFormat]
122
+ : undefined;
123
+ if (!layout || width < 1 || height < 1) return null;
124
+ const bytes = new Uint8Array(buffer);
125
+ const stride = Math.floor(bytes.length / height);
126
+ if (stride < width * layout.bpp) return null;
127
+
128
+ const gray = new Uint8Array(width * height);
129
+ for (let y = 0; y < height; y += 1) {
130
+ const row = y * stride;
131
+ for (let x = 0; x < width; x += 1) {
132
+ const i = row + x * layout.bpp;
133
+ gray[y * width + x] = Math.round(
134
+ 0.299 * bytes[i + layout.r]! + 0.587 * bytes[i + layout.g]! + 0.114 * bytes[i + layout.b]!,
135
+ );
136
+ }
137
+ }
138
+ return gray;
139
+ }
140
+
141
+ /** Nitro Image wants a filesystem path, never a `file://` URL. */
142
+ export function fileUriToPath(uri: string): string {
143
+ if (!uri.startsWith('file://')) return uri;
144
+ const path = uri.slice('file://'.length);
145
+ try {
146
+ return decodeURIComponent(path);
147
+ } catch {
148
+ return path;
149
+ }
150
+ }
151
+
152
+ export interface RawPixels {
153
+ buffer: ArrayBuffer;
154
+ width: number;
155
+ height: number;
156
+ pixelFormat: string;
157
+ }
158
+
159
+ export interface NitroImageLike {
160
+ width: number;
161
+ height: number;
162
+ cropAsync(startX: number, startY: number, endX: number, endY: number): Promise<NitroImageLike>;
163
+ resizeAsync(width: number, height: number): Promise<NitroImageLike>;
164
+ toRawPixelDataAsync(allowGpu?: boolean): Promise<RawPixels>;
165
+ }
166
+
167
+ export interface ImageFactoryLike {
168
+ loadFromFileAsync(filePath: string): Promise<NitroImageLike>;
169
+ }
170
+
171
+ let factory: ImageFactoryLike | null | undefined;
172
+
173
+ // Resolved once. A require that failed (a test runner, an install whose native
174
+ // module did not link) will fail again, and the answer is the same: no notice.
175
+ function loadImages(): ImageFactoryLike | null {
176
+ if (factory === undefined) {
177
+ try {
178
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
179
+ const mod = require('react-native-nitro-image') as { Images?: ImageFactoryLike };
180
+ factory = mod?.Images ?? null;
181
+ } catch {
182
+ factory = null;
183
+ }
184
+ }
185
+ return factory;
186
+ }
187
+
188
+ /**
189
+ * Load the still, measure its centre. Null on any failure.
190
+ *
191
+ * `images` exists so a test can hand in a fake; callers never pass it.
192
+ */
193
+ export async function measureSelfieSharpness(
194
+ uri: string,
195
+ images: ImageFactoryLike | null = loadImages(),
196
+ ): Promise<number | null> {
197
+ if (!images) return null;
198
+ try {
199
+ const still = await images.loadFromFileAsync(fileUriToPath(uri));
200
+ if (!still.width || !still.height) return null;
201
+ // Nitro's crop takes END coordinates, not a width and height.
202
+ const c = centreCrop(still.width, still.height);
203
+ const face = await still.cropAsync(c.sx, c.sy, c.sx + c.sw, c.sy + c.sh);
204
+ const small = await face.resizeAsync(SELFIE_MEASURE_SIZE, SELFIE_MEASURE_SIZE);
205
+ const raw = await small.toRawPixelDataAsync();
206
+ const gray = grayFromPixels(raw.buffer, raw.width, raw.height, raw.pixelFormat);
207
+ return gray ? laplacianVariance(gray, raw.width, raw.height) : null;
208
+ } catch {
209
+ return null;
210
+ }
211
+ }
@@ -0,0 +1,79 @@
1
+ // ─── Waiting for the selfie upload from a later step ────────────────────────
2
+ //
3
+ // With the selfie review hidden (the biometric scopes' default), the liveness
4
+ // step hands over the moment the capture ring has closed rather than when the
5
+ // upload lands, so the person sees ONE loading screen from the shutter to the
6
+ // verdict instead of three. The upload keeps running in the background and
7
+ // reports to the store (`selfieUpload`, written by liveness/useSelfieUpload);
8
+ // the submitted step waits on that record before it submits. Pure and
9
+ // injectable, like the result wait: the screen owns nothing but the rendering.
10
+
11
+ export type SelfieUploadStatus = 'idle' | 'uploading' | 'done' | 'failed';
12
+
13
+ export interface SelfieUploadState {
14
+ status: SelfieUploadStatus;
15
+ /** The failure message, set only on `failed`. */
16
+ message: string | null;
17
+ }
18
+
19
+ export const IDLE_SELFIE_UPLOAD: SelfieUploadState = { status: 'idle', message: null };
20
+
21
+ export type SelfieUploadWait = { ok: true } | { ok: false; message: string };
22
+
23
+ export interface SelfieUploadSnapshot {
24
+ selfieUpload: SelfieUploadState;
25
+ /** `mediaIds.selfie`: the durable proof the selfie is on the server. */
26
+ selfieMediaId: string | undefined;
27
+ }
28
+
29
+ export const SELFIE_UPLOAD_WAIT_MS = 90 * 1000;
30
+
31
+ const TIMED_OUT = 'Your selfie could not be sent. Check your connection and try again.';
32
+
33
+ /**
34
+ * Whether the upload has settled, and how. A restored session carries the
35
+ * media id with the status still `idle` (nothing uploaded this visit), which
36
+ * counts as settled; an `idle` record with no media id is an upload that has
37
+ * not started yet, so the caller keeps waiting.
38
+ */
39
+ export function selfieUploadSettled(snapshot: SelfieUploadSnapshot): SelfieUploadWait | null {
40
+ const { status, message } = snapshot.selfieUpload;
41
+ if (status === 'failed') return { ok: false, message: message ?? TIMED_OUT };
42
+ if (status === 'done' || (status === 'idle' && !!snapshot.selfieMediaId)) return { ok: true };
43
+ return null;
44
+ }
45
+
46
+ export interface AwaitSelfieUploadDeps {
47
+ read: () => SelfieUploadSnapshot;
48
+ /** Fires the listener on every store change; returns the unsubscribe. */
49
+ subscribe: (listener: () => void) => () => void;
50
+ timeoutMs?: number;
51
+ setTimer?: (fn: () => void, ms: number) => unknown;
52
+ clearTimer?: (handle: unknown) => void;
53
+ }
54
+
55
+ /** Resolve once the upload has settled, or with a failure at the deadline. */
56
+ export function awaitSelfieUpload(deps: AwaitSelfieUploadDeps): Promise<SelfieUploadWait> {
57
+ const settled = selfieUploadSettled(deps.read());
58
+ if (settled) return Promise.resolve(settled);
59
+ const setTimer = deps.setTimer ?? ((fn, ms) => setTimeout(fn, ms));
60
+ const clearTimer = deps.clearTimer ?? ((h) => clearTimeout(h as ReturnType<typeof setTimeout>));
61
+ return new Promise((resolve) => {
62
+ let done = false;
63
+ const finish = (result: SelfieUploadWait) => {
64
+ if (done) return;
65
+ done = true;
66
+ unsubscribe();
67
+ clearTimer(timer);
68
+ resolve(result);
69
+ };
70
+ const unsubscribe = deps.subscribe(() => {
71
+ const next = selfieUploadSettled(deps.read());
72
+ if (next) finish(next);
73
+ });
74
+ const timer = setTimer(() => finish({ ok: false, message: TIMED_OUT }), deps.timeoutMs ?? SELFIE_UPLOAD_WAIT_MS);
75
+ // The store may have moved between the first read and the subscription.
76
+ const again = selfieUploadSettled(deps.read());
77
+ if (again) finish(again);
78
+ });
79
+ }
@@ -0,0 +1,42 @@
1
+ // The Street View entrance frame's maths. A mirror of the web SDK's
2
+ // StreetViewFramer `frameFov` and the Flutter street_view_fov.dart; keep the
3
+ // three in lockstep.
4
+
5
+ export const clamp = (v: number, lo: number, hi: number): number => Math.min(hi, Math.max(lo, v));
6
+
7
+ /** The frame the applicant captured: coordinates only, so the server fetches
8
+ * the image with its own key. */
9
+ export interface StreetViewFrame {
10
+ panoId: string;
11
+ heading: number;
12
+ pitch: number;
13
+ fov: number;
14
+ }
15
+
16
+ /**
17
+ * The field of view a centred sub-frame of the viewport actually subtends.
18
+ * The frame is entrance-sized guidance, so the STORED image must be what the
19
+ * frame showed, not the whole panorama — otherwise "fit your gate in the
20
+ * frame" captures a streetscape with the gate somewhere in it. Exact
21
+ * projection maths (a perspective view is a flat plane, so a width fraction
22
+ * maps through tan, not linearly).
23
+ */
24
+ export function frameFov(viewportFovDeg: number, widthFraction: number): number {
25
+ const fraction = clamp(widthFraction, 0.1, 1);
26
+ const half = (viewportFovDeg * Math.PI) / 360;
27
+ return (2 * Math.atan(fraction * Math.tan(half)) * 180) / Math.PI;
28
+ }
29
+
30
+ /** The frame to store for a reported view: the slice the frame subtends when
31
+ * both widths are known, else the whole view; fov 10..120, pitch ±90. */
32
+ export function captureStreetViewFrame(
33
+ pov: { panoId: string; heading: number; pitch: number; viewFov: number },
34
+ frameWidth: number,
35
+ viewWidth: number,
36
+ ): StreetViewFrame {
37
+ const fov =
38
+ frameWidth > 0 && viewWidth > 0
39
+ ? clamp(frameFov(pov.viewFov, frameWidth / viewWidth), 10, 120)
40
+ : clamp(pov.viewFov, 10, 120);
41
+ return { panoId: pov.panoId, heading: pov.heading, pitch: clamp(pov.pitch, -90, 90), fov };
42
+ }
@@ -0,0 +1,47 @@
1
+ import type React from 'react';
2
+
3
+ import { tryRequire } from '../services/fingerprint-sources';
4
+
5
+ // `react-native-webview` is an OPTIONAL peer. Absent, every surface that
6
+ // would ride it (the framed Google map, the framed Street View) degrades to
7
+ // its dependency-free path (the OSM picker, the entrance photo), and the
8
+ // FLOW MODEL must know that too, or it would offer a step the phone cannot
9
+ // render. Resolved once and cached: a require that failed will fail again.
10
+
11
+ export interface WebViewLike {
12
+ injectJavaScript(script: string): void;
13
+ }
14
+
15
+ export type WebViewComponent = React.ComponentType<{
16
+ ref?: React.Ref<WebViewLike>;
17
+ source: { uri: string };
18
+ style?: object;
19
+ javaScriptEnabled?: boolean;
20
+ /** Android: ask a parent ScrollView not to intercept the WebView's drags. */
21
+ nestedScrollEnabled?: boolean;
22
+ onMessage?: (event: { nativeEvent: { data: string } }) => void;
23
+ onError?: () => void;
24
+ }>;
25
+
26
+ let resolved: WebViewComponent | null | undefined;
27
+
28
+ export function loadWebView(): WebViewComponent | null {
29
+ if (resolved === undefined) {
30
+ const mod = tryRequire<{ WebView?: WebViewComponent; default?: WebViewComponent }>(() =>
31
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
32
+ require('react-native-webview'),
33
+ );
34
+ resolved = mod?.WebView ?? mod?.default ?? null;
35
+ }
36
+ return resolved;
37
+ }
38
+
39
+ /** Whether a WebView can be rendered on this install. */
40
+ export function webViewAvailable(): boolean {
41
+ return loadWebView() != null;
42
+ }
43
+
44
+ /** Test seam: forget the cached answer. */
45
+ export function __resetWebViewAvailability(): void {
46
+ resolved = undefined;
47
+ }
@@ -0,0 +1,59 @@
1
+ import { Image } from 'react-native';
2
+
3
+ import { resolveBaseUrl } from '../services/resolveUrl';
4
+ import type { LivenessChallenge } from './types';
5
+
6
+ // ---------------------------------------------------------------------------
7
+ // Where the gesture animations come from.
8
+ //
9
+ // They used to be four GIFs bundled in this package — 5.5 MB, shipped to every
10
+ // device whatever the workflow asked for, for a badge that is on screen for a
11
+ // few seconds of the liveness step. They are served by the API instead
12
+ // (`/api/kyc/assets/liveness/<gesture>.gif`), prefetched when the flow opens,
13
+ // and cached by the platform image loader from then on.
14
+ //
15
+ // GIF on BOTH platforms deliberately, even though the server also has WebP at a
16
+ // tenth of the size: React Native decodes only GIF animation on iOS, and
17
+ // animated WebP on Android needs Fresco's `animated-webp` module, which the
18
+ // host app enables or does not — invisible from in here. One URL, no platform
19
+ // branch, no dependence on the integrator's Fresco configuration.
20
+ //
21
+ // Nothing here throws and nothing here is awaited by the flow. A device that
22
+ // never gets the file shows the gesture icon the avatar has always fallen back
23
+ // to, and the instruction text above it says what to do regardless.
24
+ // ---------------------------------------------------------------------------
25
+
26
+ export function livenessAvatarUrl(
27
+ challenge: LivenessChallenge,
28
+ apiKey: string,
29
+ devUrl?: string,
30
+ ): string | null {
31
+ try {
32
+ // Throws on a malformed key, which is a real error everywhere else in the
33
+ // SDK and merely a missing cartoon here.
34
+ return `${resolveBaseUrl(apiKey, devUrl)}/api/kyc/assets/liveness/${challenge}.gif`;
35
+ } catch {
36
+ return null;
37
+ }
38
+ }
39
+
40
+ const GESTURES: readonly LivenessChallenge[] = ['nod', 'turn', 'blink', 'smile'];
41
+
42
+ /**
43
+ * Warm the image cache at flow open, so the avatar is already on the device by
44
+ * the time liveness renders.
45
+ *
46
+ * Called beside `primeFaceModel()` and for the same reason: the work overlaps
47
+ * consent and ID-type selection instead of stalling in front of the camera.
48
+ * All four are fetched because which gestures a session asks for is randomised
49
+ * per session, and together they are under 700 KB.
50
+ */
51
+ export function primeLivenessAvatars(apiKey: string, devUrl?: string): void {
52
+ for (const gesture of GESTURES) {
53
+ const url = livenessAvatarUrl(gesture, apiKey, devUrl);
54
+ if (url == null) return;
55
+ Image.prefetch(url).catch(() => {
56
+ /* best-effort: the avatar falls back to its icon */
57
+ });
58
+ }
59
+ }
@@ -562,7 +562,12 @@ export function useLiveness(opts: UseLivenessOptions = {}): UseLivenessReturn {
562
562
  setState((s) => ({
563
563
  ...s,
564
564
  phase: 'complete',
565
- instruction: 'Capture complete',
565
+ // No line at all. The ring closing green on the same frame as the
566
+ // shutter IS the completion signal, and the review screen follows within
567
+ // the beat — narrating the upload over it said nothing the user needed
568
+ // and read as a delay. The instruction also drives the voice guidance,
569
+ // so an empty one is silent as well as blank.
570
+ instruction: '',
566
571
  activeChallenge: null,
567
572
  positionGuidance: null,
568
573
  }));
@@ -1,65 +1,19 @@
1
- import { useEffect, useRef, useState } from 'react';
2
-
1
+ import { useNativeModelReady, type ModelReadyState } from '../lib/model-ready';
3
2
  import { isFaceModelReady, primeFaceModel } from './visionCameraFaceDetector';
4
3
 
5
- // ─── Face-model readiness gate ────────────────────────────────────────────────
6
- //
7
- // Android fetches ML Kit's face model through Play Services rather than bundling
8
- // it, which keeps ~8 MB per device out of the APK but leaves a window where
9
- // detection cannot run: first launch before the download lands, or a device with
10
- // no Google Play Services at all.
11
- //
12
- // This has to be answered BEFORE the camera opens. `detectFace` reports through
13
- // `FaceResult`, where a missing model and an empty frame are both
14
- // `faceCount: 0` — so gating on the per-frame result would strand the user on
15
- // "position your face" indefinitely, with the SDK unable to say why. That is the
16
- // single failure mode this whole gate exists to prevent.
17
- //
18
- // iOS is always ready (Apple Vision is a system framework), so this resolves on
19
- // the first tick there and costs nothing.
20
-
21
- export type ModelReadyState = 'ready' | 'preparing' | 'unavailable';
4
+ // The face half of the shared readiness gate. The rule, the polling and the
5
+ // reasoning live in ../lib/model-ready.ts, because the text recogniser needs
6
+ // exactly the same thing and one decision should not exist in two places.
22
7
 
23
- /** How long to wait for the model before calling it unavailable. */
24
- const MODEL_WAIT_MS = 20_000;
25
- /** Gap between readiness polls while the download is in flight. */
26
- const POLL_INTERVAL_MS = 500;
8
+ export type { ModelReadyState };
27
9
 
28
10
  /**
29
11
  * Tracks whether on-device face detection can run.
30
12
  *
31
- * Returns `'preparing'` while Play Services fetches the model, `'ready'` once it
32
- * can run, and `'unavailable'` when it could not be obtained within
33
- * {@link MODEL_WAIT_MS} — no Play Services, no network, or a declined install.
34
- *
35
- * The flow primes the download at open (see `MyazaKYC.tsx`), so by the time the
36
- * user reaches liveness this is normally already `'ready'` and no waiting screen
37
- * is ever shown.
13
+ * `'preparing'` while Play Services fetches the model, `'ready'` once it can
14
+ * run, `'unavailable'` when it could not be obtained — no Play Services, no
15
+ * network, or a declined install.
38
16
  */
39
17
  export function useFaceModelReady(): ModelReadyState {
40
- const [state, setState] = useState<ModelReadyState>(() =>
41
- isFaceModelReady() ? 'ready' : 'preparing',
42
- );
43
- const startedAt = useRef(Date.now());
44
-
45
- useEffect(() => {
46
- if (state !== 'preparing') return;
47
-
48
- // Re-prime rather than assume the open-time call ran: the step can be
49
- // reached directly in a resumed flow, and prepareModel() is a no-op once
50
- // the model is present.
51
- primeFaceModel();
52
-
53
- const id = setInterval(() => {
54
- if (isFaceModelReady()) {
55
- setState('ready');
56
- } else if (Date.now() - startedAt.current > MODEL_WAIT_MS) {
57
- setState('unavailable');
58
- }
59
- }, POLL_INTERVAL_MS);
60
-
61
- return () => clearInterval(id);
62
- }, [state]);
63
-
64
- return state;
18
+ return useNativeModelReady(isFaceModelReady, primeFaceModel);
65
19
  }
@@ -57,6 +57,15 @@ function fit(line: string, width: number): string | null {
57
57
  if (line.length >= width - 2 && line.length < width) return line.padEnd(width, '<');
58
58
  // Long by a stray glyph or three: drop the trailing noise.
59
59
  if (line.length > width && line.length <= width + 3) return line.slice(0, width);
60
+ // A filler run the recogniser miscounted. Android returns runs of '<' as
61
+ // guillemets whose number does not match the fillers printed, so the mapped
62
+ // run overshoots or falls short by many characters (a passport's first line
63
+ // came back 53 wide on the Flutter SDK, 2026-09-15). Trailing fillers are
64
+ // padding and carry no data, so only they are removed or added.
65
+ if (line.length > width && !/[^<]/.test(line.slice(width))) return line.slice(0, width);
66
+ if (line.length < width && line.length >= Math.floor(width / 2) && line.endsWith('<')) {
67
+ return line.padEnd(width, '<');
68
+ }
60
69
  return null;
61
70
  }
62
71
 
@@ -65,10 +74,9 @@ function fit(line: string, width: number): string | null {
65
74
  * not carry a complete, valid one.
66
75
  */
67
76
  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;
77
+ const pieces = recognizedLines.map(sanitizeMrzLine).filter((line) => line.length > 0);
78
+ const candidates = pieces.filter(looksLikeMrzLine);
79
+ if (candidates.length === 0) return fromFragments(pieces, now);
72
80
 
73
81
  // 1) A single line already holding the whole MRZ (some recognisers merge).
74
82
  for (const line of candidates) {
@@ -97,5 +105,64 @@ export function extractMrz(recognizedLines: string[], now: Date = new Date()): M
97
105
  if (scan) return scan;
98
106
  }
99
107
 
108
+ return fromFragments(pieces, now);
109
+ }
110
+
111
+ // ---------------------------------------------------------------------------
112
+ // Split lines.
113
+ //
114
+ // On a high-resolution still, Android's recogniser can return ONE printed MRZ
115
+ // line as two text lines (seen on the Flutter SDK, 2026-09-15: a passport's
116
+ // two-line band came back as three lines). Joining runs of adjacent pieces back
117
+ // to line width recovers it; a wrong join is harmless, as above. Mirrored in
118
+ // kyc-sdk-flutter's mrz_extract.dart.
119
+ // ---------------------------------------------------------------------------
120
+
121
+ /** The longest run of pieces one printed line is ever split into. */
122
+ const MAX_PIECES_PER_LINE = 4;
123
+
124
+ interface JoinedRow {
125
+ end: number;
126
+ text: string;
127
+ }
128
+
129
+ /** Joined rows of `width`, keyed by the index of the piece each one starts at. */
130
+ function rowsByStart(pieces: string[], width: number): Map<number, JoinedRow[]> {
131
+ const rows = new Map<number, JoinedRow[]>();
132
+ for (let i = 0; i < pieces.length; i++) {
133
+ let text = '';
134
+ for (let j = i; j < pieces.length && j < i + MAX_PIECES_PER_LINE; j++) {
135
+ text += pieces[j]!;
136
+ // Generous: a joined row may carry an over-counted filler run `fit` trims.
137
+ if (text.length > width * 2) break;
138
+ const fitted = fit(text, width);
139
+ if (fitted) rows.set(i, [...(rows.get(i) ?? []), { end: j, text: fitted }]);
140
+ }
141
+ }
142
+ return rows;
143
+ }
144
+
145
+ /** Consecutive joined rows of line width: TD3 two of 44, TD1 three of 30. */
146
+ function fromFragments(pieces: string[], now: Date): MrzScan | null {
147
+ const td3 = rowsByStart(pieces, 44);
148
+ for (const list of td3.values()) {
149
+ for (const a of list) {
150
+ for (const b of td3.get(a.end + 1) ?? []) {
151
+ const scan = parseMrz(a.text + b.text, now);
152
+ if (scan) return scan;
153
+ }
154
+ }
155
+ }
156
+ const td1 = rowsByStart(pieces, 30);
157
+ for (const list of td1.values()) {
158
+ for (const a of list) {
159
+ for (const b of td1.get(a.end + 1) ?? []) {
160
+ for (const c of td1.get(b.end + 1) ?? []) {
161
+ const scan = parseMrz(a.text + b.text + c.text, now);
162
+ if (scan) return scan;
163
+ }
164
+ }
165
+ }
166
+ }
100
167
  return null;
101
168
  }
@@ -71,6 +71,42 @@ export function hasTextRecognizer(): boolean {
71
71
  return resolve() !== null;
72
72
  }
73
73
 
74
+ /**
75
+ * Start fetching the text model, so it is warm by the time the document step
76
+ * runs.
77
+ *
78
+ * Android fetches ML Kit's models through Play Services rather than bundling
79
+ * them, which keeps ~18.5 MB per device out of the APK but leaves a window on
80
+ * first launch where recognition cannot work. Priming at flow start puts that
81
+ * download behind the consent and ID-type screens instead of in front of the
82
+ * camera.
83
+ *
84
+ * Best-effort and non-blocking, exactly like `primeFaceModel()`: a failure here
85
+ * is not fatal, because {@link isTextModelReady} is what the document step
86
+ * actually reads. iOS resolves immediately (Apple Vision is a system
87
+ * framework).
88
+ */
89
+ export function primeTextModel(): void {
90
+ resolve()?.prepareModel().catch(() => {
91
+ /* best-effort: isTextModelReady() is the real signal */
92
+ });
93
+ }
94
+
95
+ /**
96
+ * Whether text recognition can run right now.
97
+ *
98
+ * `recognizeText` cannot answer this — a missing model and a frame with no text
99
+ * are both an empty `lines` array — so auto-capture would silently never fire
100
+ * and the MRZ would never produce a chip key, with nothing able to say why.
101
+ *
102
+ * `false` when the native module is absent entirely (Expo Go), because in that
103
+ * build there is no recogniser to become ready. Callers treat both the same
104
+ * way: fall back to the manual shutter, which was always the primary route.
105
+ */
106
+ export function isTextModelReady(): boolean {
107
+ return resolve()?.isModelReady() ?? false;
108
+ }
109
+
74
110
  /**
75
111
  * The bottom slice of the frame the MRZ occupies.
76
112
  *