@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
@@ -1,29 +1,46 @@
1
1
  import React, { useEffect, useRef, useState } from 'react';
2
2
  import { Animated, Easing, Image } from 'react-native';
3
3
 
4
- import { useTheme } from '../components/runtime';
4
+ import { useTheme, useKycConfig } from '../components/runtime';
5
5
  import { Icon } from '../components/Icon';
6
+ import { livenessAvatarUrl } from '../liveness/avatarSource';
6
7
  import type { LivenessChallenge } from '../liveness/types';
7
8
 
8
9
  // Animated GIF avatar demonstrating the requested gesture — the RN mirror of the
9
- // web/Flutter `LivenessAvatar`. Shows the same Nod/Turn/Blink/Smile GIFs in a
10
- // circular badge (primary-tinted, like Flutter), sliding up + fading in when the
11
- // challenge changes. RN's <Image> animates GIFs natively.
10
+ // web/Flutter `LivenessAvatar`. Shows the same Nod/Turn/Blink/Smile animations in
11
+ // a circular badge (primary-tinted, like Flutter), sliding up + fading in when
12
+ // the challenge changes. RN's <Image> animates GIFs natively.
13
+ //
14
+ // The animations are FETCHED (see liveness/avatarSource) rather than bundled.
15
+ // They were 5.5 MB of this package, in every integrator's app, for a badge the
16
+ // liveness step shows for a few seconds. The flow prefetches all four at open,
17
+ // so by the time this renders the file is normally already on the device; when
18
+ // it is not, `broken` renders the gesture icon exactly as it always did for a
19
+ // failed decode.
12
20
 
13
- const GIFS: Record<LivenessChallenge, number> = {
14
- nod: require('../assets/liveness/Nod.gif'),
15
- turn: require('../assets/liveness/Turn.gif'),
16
- blink: require('../assets/liveness/Blink.gif'),
17
- smile: require('../assets/liveness/Smile.gif'),
18
- };
21
+ /** The badge on a tall phone; the step hands a smaller size down on a short one
22
+ * (lib/livenessLayout), so the gesture stays on screen beside the circle. */
23
+ const DEFAULT_SIZE = 96;
24
+ const DEFAULT_ICON = 40;
19
25
 
20
- const SIZE = 96;
21
-
22
- export function LivenessAvatar({ challenge }: { challenge: LivenessChallenge }): React.ReactElement {
26
+ export function LivenessAvatar({
27
+ challenge,
28
+ size = DEFAULT_SIZE,
29
+ iconSize = DEFAULT_ICON,
30
+ }: {
31
+ challenge: LivenessChallenge;
32
+ size?: number;
33
+ iconSize?: number;
34
+ }): React.ReactElement {
35
+ const SIZE = size;
23
36
  const { colors } = useTheme();
37
+ const { apiKey, devUrl } = useKycConfig();
24
38
  const [displayed, setDisplayed] = useState<LivenessChallenge>(challenge);
25
39
  const [broken, setBroken] = useState(false);
26
40
  const anim = useRef(new Animated.Value(1)).current;
41
+ // `displayed`, not `challenge`: the badge cross-fades, so the image must keep
42
+ // showing the outgoing gesture until the transition swaps it.
43
+ const uri = livenessAvatarUrl(displayed, apiKey, devUrl);
27
44
 
28
45
  // Slide-up + fade transition when the challenge changes (mirrors Flutter's
29
46
  // AnimatedSwitcher: Offset(0, 0.4) → 0, easeOutCubic, ~350ms).
@@ -54,11 +71,11 @@ export function LivenessAvatar({ challenge }: { challenge: LivenessChallenge }):
54
71
  transform: [{ translateY: anim.interpolate({ inputRange: [0, 1], outputRange: [SIZE * 0.4, 0] }) }],
55
72
  }}
56
73
  >
57
- {broken ? (
58
- <Icon name="scan-face" size={40} color={colors.primary} />
74
+ {broken || uri == null ? (
75
+ <Icon name="scan-face" size={iconSize} color={colors.primary} />
59
76
  ) : (
60
77
  <Image
61
- source={GIFS[displayed]}
78
+ source={{ uri }}
62
79
  style={{ width: '100%', height: '100%' }}
63
80
  resizeMode="cover"
64
81
  onError={() => setBroken(true)}
@@ -1,8 +1,7 @@
1
1
  import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
2
- import { ActivityIndicator, Animated, Dimensions, Easing, Image, View } from 'react-native';
2
+ import { Animated, Easing, Image, useWindowDimensions, View } from 'react-native';
3
3
  import Svg, { Ellipse, Path } from 'react-native-svg';
4
4
  import {
5
- Camera,
6
5
  useCameraDevice,
7
6
  useCameraPermission,
8
7
  useFrameOutput,
@@ -15,6 +14,7 @@ import { radius, spacing } from '../config/theme';
15
14
  import { withRetry } from '../services/retry';
16
15
  import { mapToKycError, safeReportError } from '../services/errors';
17
16
  import { compressSelfieImage, compressVideo } from '../services/mediaCompress';
17
+ import { isSelfieBlurry, measureSelfieSharpness } from '../lib/selfie-sharpness';
18
18
  import { KYCError } from '../types/verification';
19
19
  import { useStore } from 'zustand';
20
20
  import { useKyc, useKycConfig, useKycStore, useTheme } from '../components/runtime';
@@ -35,9 +35,11 @@ import { useFaceModelReady } from '../liveness/useModelReady';
35
35
  import { ReadyPrimer } from '../components/ReadyPrimer';
36
36
  import { READY_LIVENESS } from '../components/readyPrimerContent';
37
37
  import { LivenessAvatar } from './LivenessAvatar';
38
+ import { livenessLayout } from '../lib/livenessLayout';
38
39
  import { detectFaceOnFrame } from '../liveness/visionCameraFaceDetector';
39
40
  import { buildLivenessIntegrity } from '../liveness/integritySignals';
40
41
  import { DEFAULT_LIVENESS_CONFIG } from '../liveness/types';
42
+ import { livenessProgress } from '../lib/captureRing';
41
43
  import { useFlashSequence } from './useFlashSequence';
42
44
  import { useFlashHole } from './useFlashHole';
43
45
  import {
@@ -45,13 +47,19 @@ import {
45
47
  INSTRUCTION_HEIGHT,
46
48
  LightingBanner,
47
49
  ProgressDots,
50
+ CaptureRing,
51
+ ShutterFlash,
48
52
  SelfiePreview,
49
53
  StyleAbsFill,
50
54
  resolveGuidance,
51
55
  useSelfieUpload,
56
+ LivenessCamera,
52
57
  LivenessComplete,
53
58
  LivenessFailed,
59
+ LivenessHandover,
60
+ useSelfieAutoAdvance,
54
61
  } from './liveness';
62
+ import { showsSelfieReview } from '../config/biometricOptions';
55
63
  import {
56
64
  lightingGuidanceText,
57
65
  positionGuidanceText,
@@ -85,6 +93,7 @@ export function LivenessStep(): React.ReactElement {
85
93
  const setMediaId = useKyc((s) => s.setMediaId);
86
94
  const setCaptureIntegrity = useKyc((s) => s.setCaptureIntegrity);
87
95
  const nextStep = useKyc((s) => s.nextStep);
96
+ const clearSelfie = useKyc((s) => s.clearSelfie);
88
97
 
89
98
  const device = useCameraDevice('front');
90
99
  const { hasPermission, requestPermission } = useCameraPermission();
@@ -176,6 +185,21 @@ export function LivenessStep(): React.ReactElement {
176
185
  uploadSelfieAndVideo,
177
186
  } = upload;
178
187
 
188
+ // The still the review should flag as soft, held BY URI, so a measurement
189
+ // that lands after a retake can never flag the next photo. A notice only,
190
+ // never a gate: see lib/selfie-sharpness.
191
+ const [softSelfieUri, setSoftSelfieUri] = useState<string | null>(null);
192
+
193
+ // A stored selfie with no uploaded mediaId is an interrupted upload (failed,
194
+ // or the user left mid-flight): resume it once on mount. Idempotent — with a
195
+ // mediaId there is nothing to do.
196
+ useEffect(() => {
197
+ if (selfieUri && !selfieIdRef.current && !uploading && !uploadError) {
198
+ void uploadSelfieAndVideo(selfieUri, videoPathRef.current);
199
+ }
200
+ // eslint-disable-next-line react-hooks/exhaustive-deps
201
+ }, []);
202
+
179
203
  // ── Liveness state machine ─────────────────────────────────────────────────
180
204
  // `handleCapture` is created after `liveness` (it calls liveness.markComplete),
181
205
  // so the machine gets it via a ref to avoid a definition cycle.
@@ -237,6 +261,11 @@ export function LivenessStep(): React.ReactElement {
237
261
  const raw = `file://${file.filePath}`;
238
262
  const compressed = await compressSelfieImage(raw).catch(() => raw);
239
263
  setSelfieUri(compressed);
264
+ // Measured on the still the applicant is about to see. It never holds
265
+ // the review back: the notice appears when the answer does.
266
+ void measureSelfieSharpness(compressed).then((score) => {
267
+ if (isSelfieBlurry(score)) setSoftSelfieUri(compressed);
268
+ });
240
269
  // Recorded at capture, not at submit: by then the liveness hook is gone
241
270
  // and its flash result with it.
242
271
  setCaptureIntegrity({
@@ -274,6 +303,66 @@ export function LivenessStep(): React.ReactElement {
274
303
  // always reads the latest state-machine callbacks.
275
304
  const livenessRef = useRef(liveness);
276
305
  livenessRef.current = liveness;
306
+
307
+ // The circle and the gesture avatar are sized from the WINDOW, not the width
308
+ // alone, so a short phone keeps the avatar on screen (lib/livenessLayout).
309
+ // A hook, so it lives up here with the ring refs, above the gates below.
310
+ const windowSize = useWindowDimensions();
311
+ const layout = livenessLayout(windowSize);
312
+
313
+ // Progress for the ring, read every frame from a ref — never from render.
314
+ // Declared HERE, above the early returns below: a hook after a conditional
315
+ // return renders "more hooks than during the previous render" the moment the
316
+ // gate dismisses, which is exactly what the ready primer does on its way out.
317
+ const phaseStartedRef = useRef(Date.now());
318
+ const phaseKeyRef = useRef(`${liveness.phase}:${liveness.completedCount}`);
319
+ const phaseKey = `${liveness.phase}:${liveness.completedCount}`;
320
+ if (phaseKeyRef.current !== phaseKey) {
321
+ phaseKeyRef.current = phaseKey;
322
+ phaseStartedRef.current = Date.now();
323
+ }
324
+ const timeoutRef = useRef(
325
+ liveness.timeoutRemaining || DEFAULT_LIVENESS_CONFIG.timeoutPerChallenge,
326
+ );
327
+ if (liveness.phase === 'challenge' && liveness.timeoutRemaining > timeoutRef.current) {
328
+ timeoutRef.current = liveness.timeoutRemaining;
329
+ }
330
+ const getRingTarget = useCallback(() => {
331
+ const l = livenessRef.current;
332
+ return livenessProgress({
333
+ phase: l.phase,
334
+ completedCount: l.completedCount,
335
+ totalCount: l.totalCount,
336
+ elapsedInPhase: (Date.now() - phaseStartedRef.current) / 1000,
337
+ challengeTimeout: timeoutRef.current,
338
+ });
339
+ }, []);
340
+
341
+ // The selfie URI lands BEFORE the machine reaches `complete` (capture →
342
+ // compress → setSelfieUri → markComplete), and the review is gated on that
343
+ // URI — so the camera circle used to unmount on the very frame the ring's
344
+ // target reached the end: the close, the green, the shutter, none of it
345
+ // was ever seen. Hold the frame for the closing beat, then hand over.
346
+ const [closeSeen, setCloseSeen] = useState(false);
347
+ useEffect(() => {
348
+ if (liveness.phase !== 'complete') return;
349
+ const t = setTimeout(() => setCloseSeen(true), 700);
350
+ return () => clearTimeout(t);
351
+ }, [liveness.phase]);
352
+ const holdingForClose =
353
+ (liveness.phase === 'capturing' || liveness.phase === 'complete') && !closeSeen;
354
+ // The biometric scopes skip the review by default: hand over the moment the
355
+ // ring has closed, WITHOUT waiting for the upload (see LivenessHandover).
356
+ // The upload reports to the store and the submitted step waits on it, so
357
+ // the person sees one loading screen rather than one per step. A restored
358
+ // selfie (media id, no local file) is ready at once; an upload that already
359
+ // failed keeps the review, whose Try Again is the recovery.
360
+ const selfieReview = showsSelfieReview(config);
361
+ useSelfieAutoAdvance({
362
+ enabled: !selfieReview,
363
+ ready: (!!selfieUri || !!selfieIdRef.current) && !holdingForClose && !uploadError,
364
+ onAdvance: nextStep,
365
+ });
277
366
  // A plain JS function the worklet hands the per-frame result to. It is wrapped
278
367
  // with `runOnJS(...)` INSIDE the worklet (the canonical react-native-worklets
279
368
  // pattern) rather than pre-wrapped — pre-wrapping produced a "non-worklet
@@ -353,6 +442,39 @@ export function LivenessStep(): React.ReactElement {
353
442
  },
354
443
  });
355
444
 
445
+ // ── Review (selfie already exists) ──────────────────────────────────────────
446
+ // Checked BEFORE every capture-flow gate (ready primer, permission, model):
447
+ // a selfie captured on an earlier visit — or a restored session where only
448
+ // the uploaded mediaId survived — means there is nothing to capture, so the
449
+ // step opens on review exactly as the document steps reopen on theirs.
450
+ // A restored selfie (earlier visit) opens straight on review; the one being
451
+ // captured RIGHT NOW waits for the ring to close first.
452
+ if (selfieIdRef.current || (selfieUri && !holdingForClose)) {
453
+ // Review hidden: the hook above advances on this render; the view is the
454
+ // submitted step's own loading screen, so the hand-over is invisible. A
455
+ // FAILED upload still gets the review, for its retry.
456
+ if (!selfieReview && !uploadError) {
457
+ return <LivenessHandover />;
458
+ }
459
+ return (
460
+ <LivenessComplete
461
+ selfieUri={selfieUri}
462
+ soft={!!selfieUri && softSelfieUri === selfieUri}
463
+ upload={upload}
464
+ onRetake={() => {
465
+ setSelfieUri(null);
466
+ setCloseSeen(false);
467
+ setUploadError(null);
468
+ selfieIdRef.current = null;
469
+ videoPathRef.current = null;
470
+ clearSelfie();
471
+ liveness.reset();
472
+ }}
473
+ onContinue={nextStep}
474
+ />
475
+ );
476
+ }
477
+
356
478
  // ── Error states ────────────────────────────────────────────────────────────
357
479
  if (cameraUnavailable) {
358
480
  return <CameraUnavailableView />;
@@ -382,22 +504,29 @@ export function LivenessStep(): React.ReactElement {
382
504
  if (permissionDenied) {
383
505
  return <CameraPermissionView onRetry={() => setPerm('requesting')} />;
384
506
  }
385
-
386
- // ── Review (selfie captured) ─────────────────────────────────────────────────
387
- if (liveness.phase === 'complete' && selfieUri) {
507
+ // The model is still arriving. Measured at ~9s on a freshly installed device
508
+ // over fast wifi, and the connections this SDK actually serves are slower, so
509
+ // the head start from opening the flow does not always win the race.
510
+ //
511
+ // Without this the camera opened anyway and `detectFace` returned
512
+ // `faceCount: 0` for every frame, which is indistinguishable from "no face in
513
+ // shot" — so the user was told to position a face that was never going to
514
+ // register, for as long as the download took. That is the exact failure the
515
+ // readiness gate exists to prevent, and MrzScanView already answers it this
516
+ // way; only this screen was missing it.
517
+ //
518
+ // Deliberately AFTER the primer and the permission prompt: those need no
519
+ // model, so letting them run first keeps the download overlapping something
520
+ // the user is already reading. Only the camera itself waits.
521
+ if (modelState === 'preparing') {
388
522
  return (
389
- <LivenessComplete
390
- selfieUri={selfieUri}
391
- upload={upload}
392
- onRetake={() => {
393
- setSelfieUri(null);
394
- setUploadError(null);
395
- selfieIdRef.current = null;
396
- videoPathRef.current = null;
397
- liveness.reset();
398
- }}
399
- onContinue={nextStep}
400
- />
523
+ <View style={{ flex: 1, alignItems: 'center', justifyContent: 'center', padding: spacing.lg }}>
524
+ <MyazaPulseLoader size={24} />
525
+ <View style={{ height: spacing.md }} />
526
+ <MyazaText variant="bodyMedium" style={{ textAlign: 'center' }}>
527
+ Getting face verification ready. This only happens once.
528
+ </MyazaText>
529
+ </View>
401
530
  );
402
531
  }
403
532
 
@@ -416,31 +545,32 @@ export function LivenessStep(): React.ReactElement {
416
545
  liveness.lightingGuidance != null;
417
546
  const isCamLoading = phase === 'loading';
418
547
 
548
+ // The ring is on the frame from positioning to the shutter, so for that whole
549
+ // stretch the border is its TRACK. Phase colour stays on the instruction text
550
+ // and the passed flash; a warning still turns the track red under the arc.
551
+ // `failed` has already returned above, so loading is the only phase without it.
552
+ const ringOnFrame = !isCamLoading;
419
553
  const ringColor = isCamLoading
420
554
  ? colors.gray300
421
555
  : hasWarning
422
556
  ? colors.error
423
- : phase === 'challenge_passed' || phase === 'capturing'
424
- ? colors.success
425
- : phase === 'challenge'
426
- ? colors.warning
427
- : liveness.faceDetected
428
- ? colors.primary
429
- : colors.gray300;
557
+ : ringOnFrame
558
+ ? `${colors.primary}33`
559
+ : colors.gray300;
430
560
 
431
561
  const guidance = resolveGuidance(liveness);
432
562
  const instrColor = isCamLoading
433
563
  ? colors.textMuted
434
564
  : guidance.tone === 'error'
435
565
  ? colors.error
436
- : phase === 'challenge_passed'
566
+ : phase === 'challenge_passed' || phase === 'complete'
437
567
  ? colors.success
438
568
  : phase === 'challenge'
439
569
  ? colors.warning
440
570
  : colors.textDark;
441
571
 
442
572
  // Fixed circle, sized to fit the sheet width (caps at 300).
443
- const CIRCLE = Math.min(Dimensions.get('window').width - spacing.md * 4, 300);
573
+ const CIRCLE = layout.circle;
444
574
  const lighting = liveness.lightingGuidance;
445
575
 
446
576
  // Rendered INSIDE the sheet, so the SDK header and the "powered by" footer
@@ -456,7 +586,10 @@ export function LivenessStep(): React.ReactElement {
456
586
  {phase === 'flash' ? '' : guidance.text}
457
587
  </MyazaText>
458
588
 
459
- {/* Circular camera with a thick colour-state ring */}
589
+ {/* Circular camera. Two boxes: the inner one CLIPS the preview to a circle,
590
+ the outer one lets the progress ring sit on that border rather than be
591
+ clipped by it. */}
592
+ <View style={{ width: CIRCLE, height: CIRCLE }}>
460
593
  <View
461
594
  ref={flashHoleRef}
462
595
  collapsable={false}
@@ -470,21 +603,14 @@ export function LivenessStep(): React.ReactElement {
470
603
  backgroundColor: '#111111',
471
604
  }}
472
605
  >
473
- {device ? (
474
- <Camera
475
- style={{ flex: 1 }}
476
- device={device}
477
- isActive={perm === 'granted'}
478
- outputs={[photoOutput, videoOutput, frameOutput]}
479
- // Mirror the front-camera preview + capture so it reads like a
480
- // mirror (matches the web SDK's scaleX(-1) video).
481
- mirrorMode="on"
482
- />
483
- ) : (
484
- <View style={{ flex: 1, alignItems: 'center', justifyContent: 'center' }}>
485
- <ActivityIndicator color="#FFFFFF" />
486
- </View>
487
- )}
606
+ {/* The Camera lives in its own borderless wrapper — see LivenessCamera
607
+ for why a bordered parent shifts the Android preview by one border. */}
608
+ <LivenessCamera
609
+ device={device}
610
+ active={perm === 'granted'}
611
+ outputs={[photoOutput, videoOutput, frameOutput]}
612
+ circle={CIRCLE}
613
+ />
488
614
 
489
615
  {/* Dashed face-guide ellipse (taller than wide) */}
490
616
  <Svg style={{ position: 'absolute', top: 0, left: 0, right: 0, bottom: 0 }} width={CIRCLE} height={CIRCLE}>
@@ -519,12 +645,9 @@ export function LivenessStep(): React.ReactElement {
519
645
  </View>
520
646
  ) : null}
521
647
 
522
- {/* Capturing flash — white wash + "Got it!" */}
523
- {phase === 'capturing' ? (
524
- <View style={[StyleAbsFill, { backgroundColor: 'rgba(255,255,255,0.3)', alignItems: 'center', justifyContent: 'center' }]}>
525
- <MyazaText variant="heading3" color="#FFFFFF">Got it!</MyazaText>
526
- </View>
527
- ) : null}
648
+ {/* The shutter: a white flash as the still is taken. It used to sit on
649
+ `capturing` reading "Got it!", announcing a photo not yet taken. */}
650
+ {phase === 'complete' ? <ShutterFlash /> : null}
528
651
 
529
652
  {/* Timeout countdown */}
530
653
  {phase === 'challenge' && liveness.timeoutRemaining > 0 ? (
@@ -534,6 +657,20 @@ export function LivenessStep(): React.ReactElement {
534
657
  ) : null}
535
658
  </View>
536
659
 
660
+ {/* The ring — outside the clipping box, on the frame's own edge. One line,
661
+ building one way for the whole test; primary while it builds, green
662
+ only as it closes, on the same frame as the shutter. */}
663
+ {ringOnFrame ? (
664
+ <CaptureRing
665
+ size={CIRCLE}
666
+ getTarget={getRingTarget}
667
+ color={colors.primary}
668
+ successColor={colors.success}
669
+ complete={phase === 'complete'}
670
+ />
671
+ ) : null}
672
+ </View>
673
+
537
674
  {/* Lighting warning banner — mirrors Flutter (amber-50/200/800 + lightbulb) */}
538
675
  {lighting ? <LightingBanner text={lightingGuidanceText(lighting)} /> : null}
539
676
 
@@ -541,7 +678,9 @@ export function LivenessStep(): React.ReactElement {
541
678
  <ProgressDots total={liveness.totalCount} completed={liveness.completedCount} active={phase === 'challenge' || phase === 'positioning' || phase === 'flash'} />
542
679
 
543
680
  {/* Gesture demo avatar */}
544
- {liveness.activeChallenge ? <LivenessAvatar challenge={liveness.activeChallenge} /> : null}
681
+ {liveness.activeChallenge ? (
682
+ <LivenessAvatar challenge={liveness.activeChallenge} size={layout.avatar} iconSize={layout.avatarIcon} />
683
+ ) : null}
545
684
  </View>
546
685
  );
547
686
  }
@@ -15,6 +15,7 @@ import { MyazaButton } from '../components/MyazaButton';
15
15
  import { MyazaPulseLoader } from '../components/MyazaPulseLoader';
16
16
  import { extractMrz } from '../mrz/extract';
17
17
  import { recognizeMrzLines, MRZ_BAND_FRACTION } from '../mrz/textRecognizer';
18
+ import { useTextModelReady } from '../mrz/useTextModelReady';
18
19
  import type { MrzScan } from '../mrz/parse';
19
20
 
20
21
  // ---------------------------------------------------------------------------
@@ -45,6 +46,11 @@ export function MrzScanView({
45
46
  const device = useCameraDevice('back');
46
47
  const { hasPermission, requestPermission } = useCameraPermission();
47
48
  const [denied, setDenied] = useState(false);
49
+ // Android fetches the text model through Play Services rather than bundling
50
+ // it. A recogniser that cannot run reports exactly what a blank page reports
51
+ // — no lines — so without this the user would aim at their passport while a
52
+ // scanner that can never read sat there pulsing. iOS is always ready.
53
+ const modelState = useTextModelReady();
48
54
 
49
55
  // A ref, not state: the worklet callback lands on the JS thread after the
50
56
  // frame that found the MRZ, and a stale render would let a second frame
@@ -113,6 +119,33 @@ export function MrzScanView({
113
119
  );
114
120
  }
115
121
 
122
+ // Worded like the camera-denied case above, and for the same reason: the chip
123
+ // is optional, so the honest thing is to say the code cannot be read and let
124
+ // the user carry on rather than leave them waiting on something that will not
125
+ // arrive.
126
+ if (modelState === 'unavailable') {
127
+ return (
128
+ <Centered>
129
+ <MyazaText variant="bodyMedium" style={{ textAlign: 'center' }}>
130
+ The text reader could not be set up on this device, so the printed code
131
+ cannot be read. You can still continue without the chip.
132
+ </MyazaText>
133
+ </Centered>
134
+ );
135
+ }
136
+
137
+ if (modelState === 'preparing') {
138
+ return (
139
+ <Centered>
140
+ <MyazaPulseLoader size={24} />
141
+ <View style={{ height: spacing.md }} />
142
+ <MyazaText variant="bodyMedium" style={{ textAlign: 'center' }}>
143
+ Getting the text reader ready. This only happens once.
144
+ </MyazaText>
145
+ </Centered>
146
+ );
147
+ }
148
+
116
149
  return (
117
150
  <View
118
151
  style={{
@@ -0,0 +1,66 @@
1
+ import React from 'react';
2
+ import { View } from 'react-native';
3
+
4
+ import { useTheme } from '../components/runtime';
5
+ import { Icon, type IconName } from '../components/Icon';
6
+ import { OptionRow } from '../components/OptionRow';
7
+ import type { PoaDocumentType } from '../types/workflow';
8
+
9
+ // ---------------------------------------------------------------------------
10
+ // The document kinds LISTED OUT as the questionnaire's multi-select cards (a
11
+ // square check, primary when picked, an icon beside the label) rather than
12
+ // hidden behind a select — the options are the information, and a sheet makes
13
+ // the person open it to find out what is on offer (user decision 2026-09-05).
14
+ // ONE kind is picked, because one document is uploaded: the cards are radios
15
+ // wearing the multi-select's square check. Locked once a file is attached, so
16
+ // the kind cannot drift from the document already uploaded. Mirrors the web
17
+ // SDK's steps/PoaDocumentTypeList and Flutter's proof_of_address_kinds.
18
+ // ---------------------------------------------------------------------------
19
+
20
+ /** One glyph per kind — the SAME Lucide names the web and Flutter maps carry. */
21
+ export const POA_TYPE_ICONS: Record<PoaDocumentType, IconName> = {
22
+ utility_bill: 'zap',
23
+ bank_statement: 'landmark',
24
+ tenancy_agreement: 'house',
25
+ government_document: 'stamp',
26
+ other: 'file-text',
27
+ };
28
+
29
+ export function PoaDocumentTypeList({
30
+ options,
31
+ value,
32
+ disabled,
33
+ onChange,
34
+ }: {
35
+ options: Array<{ value: PoaDocumentType; label: string }>;
36
+ value: PoaDocumentType;
37
+ disabled?: boolean;
38
+ onChange: (value: PoaDocumentType) => void;
39
+ }): React.ReactElement {
40
+ const { colors } = useTheme();
41
+ return (
42
+ <View>
43
+ {options.map((opt) => {
44
+ const selected = opt.value === value;
45
+ return (
46
+ <OptionRow
47
+ key={opt.value}
48
+ label={opt.label}
49
+ selected={selected}
50
+ multi
51
+ role="radio"
52
+ disabled={disabled}
53
+ icon={
54
+ <Icon
55
+ name={POA_TYPE_ICONS[opt.value]}
56
+ size={20}
57
+ color={selected ? colors.primary : colors.textSecondary}
58
+ />
59
+ }
60
+ onPress={() => onChange(opt.value)}
61
+ />
62
+ );
63
+ })}
64
+ </View>
65
+ );
66
+ }