@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,143 @@
1
+ // Web Mercator math for the dependency-free map pin picker. Pure and
2
+ // unit-tested — the picker is a thin gesture shell over these.
3
+ //
4
+ // Deliberately NOT react-native-maps: that means native modules, per-platform
5
+ // setup and a Google Maps API key per org for what the picker needs — pan,
6
+ // zoom, one pin. The standard slippy-map maths below is ~60 lines, fully
7
+ // testable, and the tiles are plain <Image>s.
8
+ //
9
+ // A MIRROR of the web SDK's lib/map-tiles.ts — keep the two in lockstep.
10
+
11
+ export const TILE_SIZE = 256;
12
+ export const MIN_ZOOM = 3;
13
+ export const MAX_ZOOM = 19;
14
+
15
+ // Web Mercator's poles — beyond this the projection diverges.
16
+ const MAX_LAT = 85.05112878;
17
+
18
+ export interface LatLng {
19
+ lat: number;
20
+ lng: number;
21
+ }
22
+
23
+ export function clampLat(lat: number): number {
24
+ return Math.max(-MAX_LAT, Math.min(MAX_LAT, lat));
25
+ }
26
+
27
+ function wrapLng(lng: number): number {
28
+ let l = lng;
29
+ while (l > 180) l -= 360;
30
+ while (l < -180) l += 360;
31
+ return l;
32
+ }
33
+
34
+ /** World size in pixels at a zoom level. */
35
+ export function worldSize(zoom: number): number {
36
+ return TILE_SIZE * 2 ** zoom;
37
+ }
38
+
39
+ /** Project a coordinate to world pixels at a zoom level. */
40
+ export function latLngToWorld(point: LatLng, zoom: number): { x: number; y: number } {
41
+ const size = worldSize(zoom);
42
+ const lat = clampLat(point.lat);
43
+ const sin = Math.sin((lat * Math.PI) / 180);
44
+ // Clamped into the world: at the pole cap the log term lands a float
45
+ // epsilon outside [0, size], which would render as a phantom tile row.
46
+ const y = (0.5 - Math.log((1 + sin) / (1 - sin)) / (4 * Math.PI)) * size;
47
+ return {
48
+ x: ((wrapLng(point.lng) + 180) / 360) * size,
49
+ y: Math.min(size, Math.max(0, y)),
50
+ };
51
+ }
52
+
53
+ /** Unproject world pixels back to a coordinate. */
54
+ export function worldToLatLng(x: number, y: number, zoom: number): LatLng {
55
+ const size = worldSize(zoom);
56
+ const n = Math.PI - (2 * Math.PI * y) / size;
57
+ return {
58
+ lat: (180 / Math.PI) * Math.atan(0.5 * (Math.exp(n) - Math.exp(-n))),
59
+ lng: wrapLng((x / size) * 360 - 180),
60
+ };
61
+ }
62
+
63
+ export interface TilePlacement {
64
+ key: string;
65
+ url: string;
66
+ /** CSS position relative to the viewport's top-left corner. */
67
+ left: number;
68
+ top: number;
69
+ }
70
+
71
+ /**
72
+ * The OSM tiles covering a viewport centred on `center`. Tiles outside the
73
+ * world (above/below the poles) are skipped; longitude wraps.
74
+ */
75
+ export function visibleTiles(
76
+ center: LatLng,
77
+ zoom: number,
78
+ width: number,
79
+ height: number,
80
+ ): TilePlacement[] {
81
+ const world = latLngToWorld(center, zoom);
82
+ const tiles: TilePlacement[] = [];
83
+ const tileCount = 2 ** zoom;
84
+ const originX = world.x - width / 2;
85
+ const originY = world.y - height / 2;
86
+ const first = { x: Math.floor(originX / TILE_SIZE), y: Math.floor(originY / TILE_SIZE) };
87
+ const last = {
88
+ x: Math.floor((originX + width) / TILE_SIZE),
89
+ y: Math.floor((originY + height) / TILE_SIZE),
90
+ };
91
+ for (let ty = first.y; ty <= last.y; ty += 1) {
92
+ if (ty < 0 || ty >= tileCount) continue;
93
+ for (let tx = first.x; tx <= last.x; tx += 1) {
94
+ const wrappedX = ((tx % tileCount) + tileCount) % tileCount;
95
+ tiles.push({
96
+ key: `${zoom}/${tx}/${ty}`,
97
+ url: `https://tile.openstreetmap.org/${zoom}/${wrappedX}/${ty}.png`,
98
+ left: tx * TILE_SIZE - originX,
99
+ top: ty * TILE_SIZE - originY,
100
+ });
101
+ }
102
+ }
103
+ return tiles;
104
+ }
105
+
106
+ /** The centre after a drag of (dx, dy) viewport pixels. */
107
+ export function panCenter(center: LatLng, zoom: number, dx: number, dy: number): LatLng {
108
+ const world = latLngToWorld(center, zoom);
109
+ const next = worldToLatLng(world.x - dx, world.y - dy, zoom);
110
+ return { lat: clampLat(next.lat), lng: next.lng };
111
+ }
112
+
113
+ /** Sensible starting views: gov-DB countries at country zoom, else a world view. */
114
+ const COUNTRY_CENTERS: Record<string, LatLng> = {
115
+ NG: { lat: 9.06, lng: 8.68 },
116
+ GH: { lat: 7.95, lng: -1.02 },
117
+ KE: { lat: 0.02, lng: 37.9 },
118
+ ZA: { lat: -28.48, lng: 24.68 },
119
+ CI: { lat: 7.54, lng: -5.55 },
120
+ };
121
+
122
+ export function defaultMapView(country: string | undefined): { center: LatLng; zoom: number } {
123
+ const center = country ? COUNTRY_CENTERS[country.toUpperCase()] : undefined;
124
+ return center ? { center, zoom: 6 } : { center: { lat: 6.5, lng: 12 }, zoom: 3 };
125
+ }
126
+
127
+ /**
128
+ * The height of the map surface on the pin step, from the viewport.
129
+ *
130
+ * A phone gets 40% of its height, clamped to 240–360: on a phone the map
131
+ * owns every touch, so the taller it is the less page is left to scroll on,
132
+ * and Continue has to stay reachable. From a tablet-class width (the web
133
+ * SDK's `sm` breakpoint) it is a flat 420. Mirrors the web SDK's
134
+ * `h-[40vh] min-h-[240px] max-h-[360px] sm:h-[420px]` on AddressCollectionStep
135
+ * and the Flutter `mapSurfaceHeight`; keep the three in lockstep.
136
+ */
137
+ export const MAP_SURFACE = { phoneFraction: 0.4, phoneMin: 240, phoneMax: 360, wideBreakpoint: 640, wide: 420 } as const;
138
+
139
+ export function mapSurfaceHeight(viewport: { width: number; height: number }): number {
140
+ if (viewport.width >= MAP_SURFACE.wideBreakpoint) return MAP_SURFACE.wide;
141
+ const fraction = Math.round(viewport.height * MAP_SURFACE.phoneFraction);
142
+ return Math.min(MAP_SURFACE.phoneMax, Math.max(MAP_SURFACE.phoneMin, fraction));
143
+ }
@@ -0,0 +1,82 @@
1
+ import { useEffect, useRef, useState } from 'react';
2
+
3
+ // ─── The on-device model readiness gate ───────────────────────────────────────
4
+ //
5
+ // Android fetches ML Kit's models through Play Services rather than bundling
6
+ // them, which keeps ~18.5 MB per device out of the APK but leaves a window
7
+ // where a detector cannot run: first launch before the download lands, or a
8
+ // device with no Google Play Services at all.
9
+ //
10
+ // This has to be answered BEFORE the camera opens, and neither detector can
11
+ // answer it itself. The face detector reports through `FaceResult`, where a
12
+ // missing model and an empty frame are both `faceCount: 0`; the text recogniser
13
+ // reports through `TextResult`, where both are an empty `lines` array. Gating on
14
+ // the per-frame result would strand the user on "position your face", or aiming
15
+ // at a passport that never reads, with the SDK unable to say why. That single
16
+ // failure mode is the whole reason this gate exists.
17
+ //
18
+ // Shared by both because the rule and the reasoning are identical — a copy per
19
+ // detector would be two places for one decision to drift.
20
+ //
21
+ // iOS is always ready (Apple Vision is a system framework), so this resolves on
22
+ // the first tick there and costs nothing.
23
+
24
+ export type ModelReadyState = 'ready' | 'preparing' | 'unavailable';
25
+
26
+ /**
27
+ * How long to wait for a model before calling it unavailable. Generous on
28
+ * purpose: the text model is about 10 MB, the native side requests it as an
29
+ * urgent install, and a slow connection is the common case. The cost is that a
30
+ * phone with no Play Services at all waits the full minute before being told;
31
+ * the bundled build (`myazaKycBundledMlKit`) is the answer for those fleets.
32
+ */
33
+ const MODEL_WAIT_MS = 60_000;
34
+ /** Gap between readiness polls while the download is in flight. */
35
+ const POLL_INTERVAL_MS = 500;
36
+
37
+ /**
38
+ * Tracks whether an on-device model can run.
39
+ *
40
+ * Returns `'preparing'` while Play Services fetches it, `'ready'` once it can
41
+ * run, and `'unavailable'` when it could not be obtained within
42
+ * {@link MODEL_WAIT_MS} — no Play Services, no network, or a declined install.
43
+ *
44
+ * The flow primes the download at open (see `MyazaKYC.tsx`), so by the time the
45
+ * user reaches the step this is normally already `'ready'` and no waiting screen
46
+ * is ever shown.
47
+ *
48
+ * @param isReady whether the model can run right now
49
+ * @param prime starts the download; called again here because the step can be
50
+ * reached directly in a resumed flow, and priming is a no-op once ready
51
+ */
52
+ export function useNativeModelReady(
53
+ isReady: () => boolean,
54
+ prime: () => void,
55
+ ): ModelReadyState {
56
+ const [state, setState] = useState<ModelReadyState>(() =>
57
+ isReady() ? 'ready' : 'preparing',
58
+ );
59
+ const startedAt = useRef(Date.now());
60
+ // Refs, not deps: a caller passing inline arrows would otherwise restart the
61
+ // poll, and the wait clock with it, on every render.
62
+ const fns = useRef({ isReady, prime });
63
+ fns.current = { isReady, prime };
64
+
65
+ useEffect(() => {
66
+ if (state !== 'preparing') return;
67
+
68
+ fns.current.prime();
69
+
70
+ const id = setInterval(() => {
71
+ if (fns.current.isReady()) {
72
+ setState('ready');
73
+ } else if (Date.now() - startedAt.current > MODEL_WAIT_MS) {
74
+ setState('unavailable');
75
+ }
76
+ }, POLL_INTERVAL_MS);
77
+
78
+ return () => clearInterval(id);
79
+ }, [state]);
80
+
81
+ return state;
82
+ }
@@ -0,0 +1,27 @@
1
+ // ---------------------------------------------------------------------------
2
+ // The Proof of Address step's country gate.
3
+ //
4
+ // On the ADDRESS scope the declared country is the applicant's own claim about
5
+ // their market: it picks the document kinds on offer, the PoA vendor market and
6
+ // rides the submission as the verification's country, and the scope seeds none
7
+ // (see AddressCountryControl). A document uploaded with no country behind it is
8
+ // not yet a complete answer, so Continue holds until one is declared (user
9
+ // decision 2026-09-08). Elsewhere the flow's own country stands and the gate
10
+ // never bites; an org that accepts exactly ONE country has the control show it
11
+ // as a settled fact, which counts as declared.
12
+ //
13
+ // A THREE-WAY MIRROR of the web SDK's lib/poa-country-gate.ts and Flutter's
14
+ // config/poa_country_gate.dart; change the rule in one and change all three.
15
+ // ---------------------------------------------------------------------------
16
+
17
+ /** Whether the Proof of Address step may continue as far as the COUNTRY is
18
+ * concerned (the upload has its own gate). */
19
+ export function poaCountryDeclared(facts: {
20
+ scope: string | null;
21
+ selectedCountry: string | null | undefined;
22
+ offered: readonly string[];
23
+ }): boolean {
24
+ if (facts.scope !== 'address') return true;
25
+ if (facts.selectedCountry?.trim()) return true;
26
+ return facts.offered.length === 1;
27
+ }
@@ -0,0 +1,50 @@
1
+ import { useEffect, useRef } from 'react';
2
+
3
+ import { primeFaceModel } from '../liveness/visionCameraFaceDetector';
4
+ import { primeLivenessAvatars } from '../liveness/avatarSource';
5
+ import { primeTextModel } from '../mrz/textRecognizer';
6
+
7
+ // ─── Warming the on-device work the flow is about to need ────────────────────
8
+ //
9
+ // Android fetches ML Kit's models through Play Services rather than bundling
10
+ // them, and the gesture animations are served rather than shipped. All three
11
+ // are wanted in front of a camera, which is the worst possible moment to start
12
+ // a download — so they start the moment the flow opens instead, overlapping
13
+ // the screens the user is already reading (consent, ID type, the document
14
+ // step). Mirrors the web SDK's primeFaceMesh().
15
+ //
16
+ // Best-effort throughout: every step still gates on its own readiness, so a
17
+ // failure here costs a head start and nothing else.
18
+ //
19
+ // SHARED BY BOTH ENTRY POINTS, and that is the whole point of the file. This
20
+ // effect used to live inside the <MyazaKYC/> trigger component, so a consumer
21
+ // using useMyazaKYC() — the hook the SDK's own example uses, and the one
22
+ // documented for programmatic control — primed NOTHING. The face model was
23
+ // first requested when the liveness step mounted, in front of a camera the
24
+ // user was already looking at, and the gesture GIFs were fetched at the same
25
+ // moment. On a device that had never downloaded the model (a fresh install,
26
+ // the common case for a real applicant) that is a ~8 MB wait with the camera
27
+ // already up. A warm device hides it completely, which is why it survived:
28
+ // isModelReady() answers true on the first call and every path looks correct.
29
+ //
30
+ // The text model is the exception that proves it — useAutoCapture primes it at
31
+ // the document step, so it kept a head start on both paths regardless.
32
+ export function usePrimeModels(
33
+ wantOpen: boolean,
34
+ apiKey: string,
35
+ devUrl?: string,
36
+ ): void {
37
+ // Once per open sequence: priming is idempotent, but re-running it on every
38
+ // render would ask Play Services the same question a few times a second.
39
+ const primedRef = useRef(false);
40
+ useEffect(() => {
41
+ if (!wantOpen || primedRef.current) return;
42
+ primedRef.current = true;
43
+ primeFaceModel();
44
+ // The larger of the two, and wanted EARLIER in the flow than the face one
45
+ // (the document step comes before liveness), so it has the least time to
46
+ // arrive and the most to gain from the head start.
47
+ primeTextModel();
48
+ primeLivenessAvatars(apiKey, devUrl);
49
+ }, [wantOpen, apiKey, devUrl]);
50
+ }
@@ -17,6 +17,30 @@ export interface ResubmitConfig {
17
17
  steps: string[];
18
18
  /** Reviewer's note to the applicant. */
19
19
  message?: string | null;
20
+ /**
21
+ * The ID the verification being redone used, when the server CARRIES it.
22
+ *
23
+ * A redo keeps the verification id, so a reviewer who did not tick the ID
24
+ * has asked for nothing about it: the server keeps the original number,
25
+ * document photos, typed name and chip read, and says so by sending the
26
+ * idType here. Present only on an individual, single-ID send-back whose
27
+ * reviewer did not tick 'id-type'. Absent (an older server, a business redo,
28
+ * a multi-ID run, or the ID was ticked) keeps the old behaviour.
29
+ */
30
+ idType?: string | null;
31
+ }
32
+
33
+ /**
34
+ * The ID a redo keeps, or null when the applicant must name one again.
35
+ *
36
+ * Read defensively rather than trusted: an idType beside a plan that ticks the
37
+ * ID picker, or beside no plan at all, is not an instruction to skip it.
38
+ */
39
+ export function keptIdType(resubmit: ResubmitConfig | undefined | null): string | null {
40
+ const asked = resubmit?.steps;
41
+ if (!asked?.length || asked.includes('id-type')) return null;
42
+ const idType = resubmit?.idType;
43
+ return typeof idType === 'string' && idType.trim().length > 0 ? idType : null;
20
44
  }
21
45
 
22
46
  /**
@@ -48,10 +72,12 @@ const EVIDENCE: KYCStep[] = ['id-input', 'document-capture', 'nfc'];
48
72
  * Steps a narrowed flow keeps regardless, because without them it cannot
49
73
  * produce a submission at all.
50
74
  *
51
- * A resubmission is a NEW verification on a FRESH session: nothing is carried
52
- * forward from the one being redone, so the applicant must still say which ID
53
- * this is and supply it. `POST /verify` requires an `idType`, and a number-only
54
- * ID requires the number with it.
75
+ * Unless the server carries the original ID (`keptIdType`), nothing is carried
76
+ * forward from the attempt being redone, so the applicant must still say which
77
+ * ID this is and supply it. `POST /verify` requires an `idType`, and a
78
+ * number-only ID requires the number with it. When the ID IS carried, the
79
+ * identity steps drop out: the SDK preselects the kept ID and the server fills
80
+ * in the number, the document photos and the chip read.
55
81
  *
56
82
  * So narrowing removes the things arranged AROUND the identity — liveness,
57
83
  * proof of address, the questionnaire, contact checks — and never the identity
@@ -93,9 +119,12 @@ export function applyResubmitSteps(
93
119
 
94
120
  const wanted = new Set<string>(asked);
95
121
  if (wantsEvidence) for (const step of EVIDENCE) wanted.add(step);
96
- for (const step of order.includes('business-details') ? BUSINESS_REQUIRED : INDIVIDUAL_REQUIRED) {
97
- wanted.add(step);
98
- }
122
+ // A kept ID needs no picker and, unless the reviewer asked for the evidence,
123
+ // no evidence step either: the redo is only what was ticked. KYB never keeps
124
+ // an ID (the server never sends one for a business redo), so it stays as is.
125
+ const isBusiness = order.includes('business-details');
126
+ const required = isBusiness ? BUSINESS_REQUIRED : keptIdType(resubmit) ? [] : INDIVIDUAL_REQUIRED;
127
+ for (const step of required) wanted.add(step);
99
128
 
100
129
  const narrowed = order.filter((step) => wanted.has(step) || ALWAYS.includes(step));
101
130
 
@@ -0,0 +1,116 @@
1
+ import type { VerificationOutcome } from './result-wait';
2
+ import type { BiometricCopyText } from '../config/biometricOptions';
3
+
4
+ // ─── What the terminal screens say ──────────────────────────────────────────
5
+ //
6
+ // Pure so it is testable without React. The server's own reason wins on a
7
+ // decline or an error when it sent one: it is written for the applicant.
8
+ // UK English, no em dashes (user-facing copy rule).
9
+
10
+ export type ResultTone = 'success' | 'error' | 'info';
11
+
12
+ export interface ResultCopy {
13
+ tone: ResultTone;
14
+ title: string;
15
+ description: string;
16
+ }
17
+
18
+ export interface WaitingCopy {
19
+ title: string;
20
+ description: string;
21
+ }
22
+
23
+ /**
24
+ * The ONE loading screen after the capture. On a re-authentication that waits
25
+ * for its verdict it spans the selfie upload, the submission and the poll, so
26
+ * it names the check rather than any of the three steps behind it. A retry in
27
+ * flight replaces the description, never the title: the person is still
28
+ * waiting for the same thing. `override` is the org's own words for the
29
+ * screen (lib/biometric-copy.ts), field by field over the default.
30
+ */
31
+ export function describeWaiting(opts: {
32
+ scope: string | null;
33
+ waitsForResult: boolean;
34
+ retry?: { attempt: number; total: number } | null;
35
+ override?: BiometricCopyText | null;
36
+ }): WaitingCopy {
37
+ const base = withOverride(waitingCopyFor(opts.scope, opts.waitsForResult), opts.override);
38
+ if (opts.retry) {
39
+ return { title: base.title, description: `Connection issue, retrying (${opts.retry.attempt}/${opts.retry.total}).` };
40
+ }
41
+ return base;
42
+ }
43
+
44
+ function waitingCopyFor(scope: string | null, waitsForResult: boolean): WaitingCopy {
45
+ if (scope === 'biometric-authentication') {
46
+ return waitsForResult
47
+ ? { title: "Checking it's you", description: 'Matching your selfie against the photo on record. This usually takes a few seconds.' }
48
+ : { title: 'Sending your face check', description: 'This only takes a moment.' };
49
+ }
50
+ if (scope === 'biometric-enrollment') {
51
+ return { title: 'Saving your selfie', description: 'It becomes the reference for your future face checks.' };
52
+ }
53
+ return { title: 'Submitting your verification', description: 'Please wait a moment.' };
54
+ }
55
+
56
+ /** The org's own words for a screen, over the default, field by field. */
57
+ function withOverride<T extends { title: string; description: string }>(base: T, override?: BiometricCopyText | null): T {
58
+ if (!override) return base;
59
+ return {
60
+ ...base,
61
+ ...(override.title ? { title: override.title } : {}),
62
+ ...(override.description ? { description: override.description } : {}),
63
+ };
64
+ }
65
+
66
+ /** What the person is told, per outcome. The server's own reason wins on a
67
+ * decline or an error when it sent one; it is written for the applicant.
68
+ * `copy` is the org's own words for the two verdict screens: on a decline
69
+ * its description wins even over the server's reason, since the org chose
70
+ * to say that. */
71
+ export function describeOutcome(
72
+ outcome: VerificationOutcome,
73
+ copy?: { verified?: BiometricCopyText | null; declined?: BiometricCopyText | null },
74
+ ): ResultCopy {
75
+ if (outcome.kind === 'timeout') {
76
+ return {
77
+ tone: 'info',
78
+ title: 'Still checking',
79
+ description: "This is taking longer than usual. You'll be notified as soon as it's done.",
80
+ };
81
+ }
82
+ switch (outcome.status) {
83
+ case 'approved':
84
+ return withOverride(
85
+ { tone: 'success', title: "You're verified", description: 'Your face matched the photo on record.' },
86
+ copy?.verified,
87
+ );
88
+ case 'declined':
89
+ return withOverride(
90
+ {
91
+ tone: 'error',
92
+ title: "We couldn't confirm it's you",
93
+ description: outcome.reason ?? "Your face didn't match the photo on record.",
94
+ },
95
+ copy?.declined,
96
+ );
97
+ case 'in_review':
98
+ return {
99
+ tone: 'info',
100
+ title: 'Under review',
101
+ description: "A reviewer will take a look. You'll be notified of the outcome.",
102
+ };
103
+ case 'error':
104
+ return {
105
+ tone: 'error',
106
+ title: 'Something went wrong',
107
+ description: outcome.reason ?? "We couldn't complete your check. Please try again in a moment.",
108
+ };
109
+ default:
110
+ return {
111
+ tone: 'info',
112
+ title: 'Check submitted',
113
+ description: "You'll be notified of the result.",
114
+ };
115
+ }
116
+ }
@@ -0,0 +1,53 @@
1
+ import type { SessionStatus, VerificationStatusResponse } from '../services/api-types';
2
+
3
+ // ─── Waiting for a verdict in the flow ──────────────────────────────────────
4
+ //
5
+ // The platform is fire-and-forget: /verify answers in milliseconds and the
6
+ // worker settles the check afterwards. A re-authentication is the one flow
7
+ // whose verdict the person is waiting for RIGHT THERE, so the submitted step
8
+ // polls the publishable status endpoint (state + reason, never result data)
9
+ // until the check leaves its pending states. Pure and injectable, like the
10
+ // presence watch wait: the screen owns nothing but the rendering.
11
+
12
+ export const RESULT_WAIT_MS = 60 * 1000;
13
+ export const RESULT_POLL_MS = 1500;
14
+
15
+ /** The states a submitted check passes through before it settles. */
16
+ const PENDING: ReadonlySet<SessionStatus> = new Set(['not_started', 'in_progress', 'processing']);
17
+
18
+ export type VerificationOutcome =
19
+ | { kind: 'settled'; status: SessionStatus; reason: string | null; reasonCode: string | null }
20
+ | { kind: 'timeout' };
21
+
22
+ export interface AwaitOutcomeDeps {
23
+ /** One status read; null on any failure (the wait keeps going). */
24
+ fetchStatus: () => Promise<VerificationStatusResponse | null>;
25
+ sleep?: (ms: number) => Promise<void>;
26
+ now?: () => number;
27
+ waitMs?: number;
28
+ pollMs?: number;
29
+ }
30
+
31
+ export function isPendingStatus(status: SessionStatus): boolean {
32
+ return PENDING.has(status);
33
+ }
34
+
35
+ /**
36
+ * Poll until the check settles or the budget runs out. A failed read is not
37
+ * a verdict: it is skipped and the next poll tries again, so a network blip
38
+ * mid-wait never reads as an outcome.
39
+ */
40
+ export async function awaitVerificationOutcome(deps: AwaitOutcomeDeps): Promise<VerificationOutcome> {
41
+ const sleep = deps.sleep ?? ((ms) => new Promise<void>((resolve) => setTimeout(resolve, ms)));
42
+ const now = deps.now ?? Date.now;
43
+ const deadline = now() + (deps.waitMs ?? RESULT_WAIT_MS);
44
+ const pollMs = deps.pollMs ?? RESULT_POLL_MS;
45
+ for (;;) {
46
+ const read = await deps.fetchStatus();
47
+ if (read && !isPendingStatus(read.status)) {
48
+ return { kind: 'settled', status: read.status, reason: read.reason ?? null, reasonCode: read.reasonCode ?? null };
49
+ }
50
+ if (now() >= deadline) return { kind: 'timeout' };
51
+ await sleep(pollMs);
52
+ }
53
+ }
@@ -0,0 +1,26 @@
1
+ // The review card's map falls back in the SAME order on every SDK: the picture
2
+ // (the Maps Static API through the server), then the framed Google map the pin
3
+ // step drew, then the built-in tiles. Pure, so a test can pin the order — the
4
+ // Flutter card skipped the middle rung and confirmed the address on
5
+ // OpenStreetMap where RN confirmed it on Google (user report 2026-09-08).
6
+ // Mirrors kyc-sdk-flutter's reviewMapSurface; keep the two in lockstep.
7
+
8
+ export type ReviewMapSurface = 'stub' | 'picture' | 'pending' | 'framed' | 'builtIn';
9
+
10
+ export function reviewMapSurface(facts: {
11
+ vendorsStubbed: boolean;
12
+ /** The picture's bytes have landed. */
13
+ hasStaticMap: boolean;
14
+ /** The picture was refused (a project without the Static API 404s). */
15
+ staticMapFailed: boolean;
16
+ /** A server-minted maps frame URL this install can render. */
17
+ hasFrame: boolean;
18
+ }): ReviewMapSurface {
19
+ if (facts.vendorsStubbed) return 'stub';
20
+ if (facts.hasStaticMap) return 'picture';
21
+ // Nothing to draw yet and no verdict either: hold the picture's space rather
22
+ // than flashing the live map for the half second before the bytes land.
23
+ if (!facts.staticMapFailed) return 'pending';
24
+ if (facts.hasFrame) return 'framed';
25
+ return 'builtIn';
26
+ }
@@ -0,0 +1,31 @@
1
+ // Workflow SCOPE — what a flow verifies about the subject (mirror of the
2
+ // server's lib/workflows/scope.ts and the web SDK's lib/scope.ts; keep the
3
+ // three in lockstep). Absent = the full verification.
4
+
5
+ export type WorkflowScope =
6
+ | 'address'
7
+ | 'biometric-authentication'
8
+ | 'biometric-enrollment'
9
+ | 'questionnaire'
10
+ | 'contact';
11
+
12
+ /** The marker idType a scoped submission carries (the KYB product-in-idType
13
+ * convention — the server requires the matching published workflow). */
14
+ export const SCOPE_ID_TYPES: Record<WorkflowScope, string> = {
15
+ address: 'address',
16
+ 'biometric-authentication': 'biometric-auth',
17
+ 'biometric-enrollment': 'biometric-enroll',
18
+ questionnaire: 'questionnaire',
19
+ contact: 'contact',
20
+ };
21
+
22
+ export function configScope(config: { scope?: WorkflowScope | string }): WorkflowScope | null {
23
+ const value = config.scope;
24
+ return value && value in SCOPE_ID_TYPES ? (value as WorkflowScope) : null;
25
+ }
26
+
27
+ /** The biometric scopes run the liveness capture; every other scope has no
28
+ * camera step at all. */
29
+ export function isFaceScope(scope: WorkflowScope | null): boolean {
30
+ return scope === 'biometric-authentication' || scope === 'biometric-enrollment';
31
+ }