@myazahq/kyc-sdk-react-native 2.4.0 → 2.6.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 (246) hide show
  1. package/README.md +151 -5
  2. package/android/build.gradle +35 -3
  3. package/android/src/main/java/com/margelo/nitro/myazakyc/HybridMyazaFaceDetector.kt +77 -0
  4. package/app.plugin.js +89 -8
  5. package/ios/HybridMyazaFaceDetector.swift +16 -3
  6. package/nitrogen/generated/android/c++/JHybridMyazaFaceDetectorSpec.cpp +23 -0
  7. package/nitrogen/generated/android/c++/JHybridMyazaFaceDetectorSpec.hpp +2 -0
  8. package/nitrogen/generated/android/kotlin/com/margelo/nitro/myazakyc/HybridMyazaFaceDetectorSpec.kt +9 -0
  9. package/nitrogen/generated/ios/KycSdkReactNative-Swift-Cxx-Bridge.cpp +8 -0
  10. package/nitrogen/generated/ios/KycSdkReactNative-Swift-Cxx-Bridge.hpp +43 -0
  11. package/nitrogen/generated/ios/c++/HybridMyazaFaceDetectorSpecSwift.hpp +17 -0
  12. package/nitrogen/generated/ios/swift/Func_void_bool.swift +46 -0
  13. package/nitrogen/generated/ios/swift/HybridMyazaFaceDetectorSpec.swift +2 -0
  14. package/nitrogen/generated/ios/swift/HybridMyazaFaceDetectorSpec_cxx.swift +31 -0
  15. package/nitrogen/generated/shared/c++/HybridMyazaFaceDetectorSpec.cpp +2 -0
  16. package/nitrogen/generated/shared/c++/HybridMyazaFaceDetectorSpec.hpp +3 -0
  17. package/package.json +14 -2
  18. package/src/MyazaBiometricAuth.tsx +119 -0
  19. package/src/MyazaKYC.tsx +12 -0
  20. package/src/components/CountryField.tsx +8 -0
  21. package/src/components/CountryRegionPicker.tsx +184 -0
  22. package/src/components/DashedBorder.tsx +55 -0
  23. package/src/components/DialCodePicker.tsx +53 -67
  24. package/src/components/DialCodeRow.tsx +102 -0
  25. package/src/components/FramedMapPicker.tsx +122 -0
  26. package/src/components/GeoBadge.tsx +34 -0
  27. package/src/components/Icon.tsx +2 -176
  28. package/src/components/KycFlow.tsx +60 -32
  29. package/src/components/LineSkeleton.tsx +100 -0
  30. package/src/components/MapChrome.tsx +67 -0
  31. package/src/components/MapPinMarker.tsx +54 -0
  32. package/src/components/MapPinPicker.tsx +179 -0
  33. package/src/components/MilestoneTrack.tsx +155 -0
  34. package/src/components/MultiIdProgress.tsx +107 -0
  35. package/src/components/MyazaInput.tsx +39 -4
  36. package/src/components/MyazaSelect.tsx +2 -1
  37. package/src/components/OptionRow.tsx +22 -3
  38. package/src/components/OwnershipSlider.tsx +102 -0
  39. package/src/components/PhoneNumberInput.tsx +45 -4
  40. package/src/components/PoweredBy.tsx +5 -5
  41. package/src/components/PresenceBlocks.tsx +153 -0
  42. package/src/components/StaggerIn.tsx +63 -0
  43. package/src/components/StepView.tsx +43 -0
  44. package/src/components/StickyActions.tsx +56 -0
  45. package/src/components/dialCodeRows.ts +64 -0
  46. package/src/components/glass/FloatingSheet.tsx +73 -21
  47. package/src/components/glass/GlassSheet.tsx +14 -2
  48. package/src/components/icon-map.ts +176 -0
  49. package/src/components/stepHeaderMeta.tsx +45 -5
  50. package/src/config/addressCollection.ts +125 -0
  51. package/src/config/biometricOptions.ts +100 -0
  52. package/src/config/business.ts +26 -7
  53. package/src/config/businessDetailsValidity.ts +52 -0
  54. package/src/config/businessPrefill.ts +72 -0
  55. package/src/config/businessSteps.ts +20 -3
  56. package/src/config/consentStep.ts +19 -0
  57. package/src/config/keyPeople.ts +111 -7
  58. package/src/config/keyPeopleCorporate.ts +19 -0
  59. package/src/config/keyPeopleOwnerTypes.ts +12 -0
  60. package/src/config/keyPeopleOwners.ts +26 -0
  61. package/src/config/keyPeoplePrefill.ts +138 -0
  62. package/src/config/keyPeopleSectionDefs.ts +65 -0
  63. package/src/config/keyPeopleSections.ts +167 -0
  64. package/src/config/proofOfAddress.ts +54 -11
  65. package/src/config/regions.ts +24 -0
  66. package/src/config/registrationHint.ts +1 -1
  67. package/src/config/stepOrder.ts +100 -6
  68. package/src/config/theme.ts +8 -2
  69. package/src/config/uploadLimits.ts +35 -0
  70. package/src/config/website.ts +36 -0
  71. package/src/config/workflowMerge.ts +16 -5
  72. package/src/emrtd/activeAuth.ts +100 -0
  73. package/src/emrtd/extras.ts +76 -0
  74. package/src/emrtd/files.ts +5 -0
  75. package/src/emrtd/index.ts +1 -0
  76. package/src/emrtd/read.ts +8 -1
  77. package/src/emrtd/session.ts +41 -0
  78. package/src/emrtd/stages.ts +11 -0
  79. package/src/index.ts +54 -1
  80. package/src/lib/address-current-location.ts +171 -0
  81. package/src/lib/address-field-modes.ts +119 -0
  82. package/src/lib/address-flow.ts +172 -0
  83. package/src/lib/address-helpers.ts +48 -0
  84. package/src/lib/address-line.ts +76 -0
  85. package/src/lib/address-pin-move.ts +97 -0
  86. package/src/lib/address-step-recovery.ts +63 -0
  87. package/src/lib/authed-image.ts +78 -0
  88. package/src/lib/biometric-auth.ts +52 -0
  89. package/src/lib/biometric-copy.ts +51 -0
  90. package/src/lib/captureRing.ts +83 -0
  91. package/src/lib/contact-recovery.ts +44 -0
  92. package/src/lib/country-adoption.ts +89 -0
  93. package/src/lib/inferred-country.ts +65 -0
  94. package/src/lib/livenessLayout.ts +49 -0
  95. package/src/lib/map-frame.ts +159 -0
  96. package/src/lib/map-tiles.ts +143 -0
  97. package/src/lib/multi-id.ts +210 -0
  98. package/src/lib/poa-country-gate.ts +27 -0
  99. package/src/lib/resubmit.ts +130 -0
  100. package/src/lib/result-copy.ts +116 -0
  101. package/src/lib/result-wait.ts +53 -0
  102. package/src/lib/review-map-surface.ts +26 -0
  103. package/src/lib/scope.ts +31 -0
  104. package/src/lib/screen-corners.ts +50 -0
  105. package/src/lib/selfie-upload-wait.ts +79 -0
  106. package/src/lib/step-log.ts +24 -3
  107. package/src/lib/street-view-fov.ts +42 -0
  108. package/src/lib/use-multi-id-plan.ts +29 -0
  109. package/src/lib/webview-available.ts +47 -0
  110. package/src/liveness/useLiveness.ts +6 -1
  111. package/src/liveness/useModelReady.ts +65 -0
  112. package/src/liveness/visionCameraFaceDetector.ts +35 -0
  113. package/src/presence/background-math.ts +100 -0
  114. package/src/presence/background-store.ts +82 -0
  115. package/src/presence/background.ts +165 -0
  116. package/src/presence/foreground-service.ts +193 -0
  117. package/src/presence/fs.ts +51 -0
  118. package/src/presence/geofence.ts +41 -0
  119. package/src/presence/math.ts +44 -0
  120. package/src/presence/post.ts +40 -0
  121. package/src/presence/report.ts +87 -0
  122. package/src/presence/sampler.ts +110 -0
  123. package/src/presence/status.ts +92 -0
  124. package/src/presence/store.ts +94 -0
  125. package/src/presence/tier.ts +36 -0
  126. package/src/presence/watch-wait.ts +81 -0
  127. package/src/screens/AddressCountryControl.tsx +115 -0
  128. package/src/screens/ApplicantRoleStep.tsx +9 -5
  129. package/src/screens/BusinessCheckPanel.tsx +89 -0
  130. package/src/screens/BusinessDetailsFields.tsx +112 -0
  131. package/src/screens/BusinessDetailsStep.tsx +133 -145
  132. package/src/screens/BusinessDocumentSlot.tsx +5 -5
  133. package/src/screens/BusinessDocumentsStep.tsx +5 -56
  134. package/src/screens/BusinessKeyPeopleStep.tsx +103 -71
  135. package/src/screens/BusinessPickedCard.tsx +86 -0
  136. package/src/screens/BusinessPickedSection.tsx +51 -0
  137. package/src/screens/BusinessRegistryPickers.tsx +67 -0
  138. package/src/screens/BusinessSandboxToggle.tsx +114 -0
  139. package/src/screens/BusinessSearch.tsx +199 -0
  140. package/src/screens/BusinessSearchResults.tsx +115 -0
  141. package/src/screens/CompanyInfoFields.tsx +91 -51
  142. package/src/screens/ConsentStep.tsx +37 -17
  143. package/src/screens/ContactActions.tsx +54 -0
  144. package/src/screens/ContactDestinationField.tsx +5 -0
  145. package/src/screens/ContactEntryPanel.tsx +75 -0
  146. package/src/screens/ContactVerificationStep.tsx +43 -42
  147. package/src/screens/CountrySelectStep.tsx +13 -147
  148. package/src/screens/IdTypeStep.tsx +12 -5
  149. package/src/screens/KeepLinksSheet.tsx +88 -0
  150. package/src/screens/KeyPeopleAwaitCard.tsx +204 -0
  151. package/src/screens/KeyPeopleAwaitList.tsx +126 -0
  152. package/src/screens/KeyPeoplePending.tsx +154 -0
  153. package/src/screens/KeyPeopleSection.tsx +162 -0
  154. package/src/screens/KeyPeopleSectionsList.tsx +103 -0
  155. package/src/screens/KeyPeopleUboExemption.tsx +72 -0
  156. package/src/screens/KeyPersonCard.tsx +36 -4
  157. package/src/screens/KeyPersonForm.tsx +195 -36
  158. package/src/screens/KeyPersonKindToggle.tsx +68 -0
  159. package/src/screens/KeyPersonOwners.tsx +109 -0
  160. package/src/screens/KeyPersonRoleChips.tsx +68 -0
  161. package/src/screens/KeyPersonSheet.tsx +92 -108
  162. package/src/screens/LivenessAvatar.tsx +15 -3
  163. package/src/screens/LivenessStep.tsx +172 -52
  164. package/src/screens/NfcStep.tsx +14 -0
  165. package/src/screens/PoaDocumentTypeList.tsx +66 -0
  166. package/src/screens/ProofOfAddressParts.tsx +151 -0
  167. package/src/screens/ProofOfAddressStep.tsx +58 -96
  168. package/src/screens/SubmittedBadge.tsx +25 -0
  169. package/src/screens/SubmittedError.tsx +64 -0
  170. package/src/screens/SubmittedResult.tsx +117 -0
  171. package/src/screens/SubmittedStep.tsx +82 -119
  172. package/src/screens/SubmittedSuccess.tsx +127 -0
  173. package/src/screens/SubmittedWaiting.tsx +45 -0
  174. package/src/screens/address/AddressEntranceStep.tsx +161 -0
  175. package/src/screens/address/AddressIntroGate.tsx +148 -0
  176. package/src/screens/address/AddressMapStub.tsx +50 -0
  177. package/src/screens/address/AddressPinStep.tsx +178 -0
  178. package/src/screens/address/AddressReviewStep.tsx +162 -0
  179. package/src/screens/address/AddressSandboxTabs.tsx +158 -0
  180. package/src/screens/address/AddressSearchField.tsx +115 -0
  181. package/src/screens/address/AddressSearchStep.tsx +94 -0
  182. package/src/screens/address/CurrentLocationRow.tsx +135 -0
  183. package/src/screens/address/DetailsSheet.tsx +175 -0
  184. package/src/screens/address/DetailsSheetFields.tsx +184 -0
  185. package/src/screens/address/EntranceDropzone.tsx +178 -0
  186. package/src/screens/address/EntranceFraming.tsx +66 -0
  187. package/src/screens/address/EntrancePills.tsx +60 -0
  188. package/src/screens/address/FramedStreetView.tsx +155 -0
  189. package/src/screens/address/IntroDisclosures.tsx +210 -0
  190. package/src/screens/address/LabelDecisionRow.tsx +113 -0
  191. package/src/screens/address/PinSummaryRow.tsx +105 -0
  192. package/src/screens/address/ReviewAddressBand.tsx +111 -0
  193. package/src/screens/address/ReviewEntranceThumbs.tsx +64 -0
  194. package/src/screens/address/ReviewMapPicture.tsx +106 -0
  195. package/src/screens/address/SearchResults.tsx +105 -0
  196. package/src/screens/address/SearchScreen.tsx +176 -0
  197. package/src/screens/address/SkipForNow.tsx +43 -0
  198. package/src/screens/address/StreetViewChrome.tsx +81 -0
  199. package/src/screens/address/detail-values.ts +22 -0
  200. package/src/screens/address/fix-source.ts +27 -0
  201. package/src/screens/address/index.ts +8 -0
  202. package/src/screens/address/meta.ts +44 -0
  203. package/src/screens/address/use-address-flow.ts +200 -0
  204. package/src/screens/address/use-label-pin.ts +80 -0
  205. package/src/screens/address/use-pin-actions.ts +192 -0
  206. package/src/screens/biometric/BiometricAuthFlow.tsx +170 -0
  207. package/src/screens/consent/model.ts +79 -10
  208. package/src/screens/liveness/CaptureRing.tsx +91 -0
  209. package/src/screens/liveness/LivenessCamera.tsx +63 -0
  210. package/src/screens/liveness/LivenessHandover.tsx +43 -0
  211. package/src/screens/liveness/LivenessOutcome.tsx +4 -3
  212. package/src/screens/liveness/SelfiePreview.tsx +13 -4
  213. package/src/screens/liveness/ShutterFlash.tsx +25 -0
  214. package/src/screens/liveness/index.ts +4 -0
  215. package/src/screens/liveness/useSelfieUpload.ts +23 -3
  216. package/src/screens/nfc/NfcReadProgress.tsx +1 -0
  217. package/src/screens/nfc/NfcScanIllustration.tsx +7 -18
  218. package/src/screens/useAddressPhotoAttach.ts +77 -0
  219. package/src/screens/useAwaitingPeople.ts +90 -0
  220. package/src/screens/useBusinessDocumentAttach.ts +93 -0
  221. package/src/screens/usePoaAttach.ts +12 -15
  222. package/src/services/api-biometric.ts +37 -0
  223. package/src/services/api-types-biometric.ts +41 -0
  224. package/src/services/api-types.ts +251 -3
  225. package/src/services/api-verify-types.ts +90 -1
  226. package/src/services/api.ts +237 -3
  227. package/src/services/deviceMetadata.ts +1 -1
  228. package/src/services/errors.ts +10 -2
  229. package/src/services/location.ts +234 -0
  230. package/src/services/uploadErrors.ts +30 -0
  231. package/src/services/workflowGate.ts +8 -0
  232. package/src/specs/MyazaFaceDetector.nitro.ts +29 -0
  233. package/src/store/address.ts +97 -0
  234. package/src/store/businessCheck.ts +113 -0
  235. package/src/store/derive.ts +44 -4
  236. package/src/store/kycStore.ts +352 -16
  237. package/src/store/serverConfig.ts +20 -0
  238. package/src/store/session.ts +227 -0
  239. package/src/store/state.ts +289 -2
  240. package/src/store/submit.ts +80 -13
  241. package/src/store/submitApplicant.ts +5 -11
  242. package/src/types/business.ts +23 -2
  243. package/src/types/config.ts +70 -2
  244. package/src/types/verification.ts +13 -1
  245. package/src/types/workflow.ts +112 -1
  246. package/src/screens/KeyPeopleInviteLinks.tsx +0 -239
@@ -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,
@@ -31,12 +30,15 @@ import {
31
30
  import { Icon } from '../components/Icon';
32
31
  import { useToast } from '../components/toast';
33
32
  import { CameraPermissionView, CameraUnavailableView, CameraPermissionPrimingView } from '../components/CameraPermissionView';
33
+ import { useFaceModelReady } from '../liveness/useModelReady';
34
34
  import { ReadyPrimer } from '../components/ReadyPrimer';
35
35
  import { READY_LIVENESS } from '../components/readyPrimerContent';
36
36
  import { LivenessAvatar } from './LivenessAvatar';
37
+ import { livenessLayout } from '../lib/livenessLayout';
37
38
  import { detectFaceOnFrame } from '../liveness/visionCameraFaceDetector';
38
39
  import { buildLivenessIntegrity } from '../liveness/integritySignals';
39
40
  import { DEFAULT_LIVENESS_CONFIG } from '../liveness/types';
41
+ import { livenessProgress } from '../lib/captureRing';
40
42
  import { useFlashSequence } from './useFlashSequence';
41
43
  import { useFlashHole } from './useFlashHole';
42
44
  import {
@@ -44,13 +46,19 @@ import {
44
46
  INSTRUCTION_HEIGHT,
45
47
  LightingBanner,
46
48
  ProgressDots,
49
+ CaptureRing,
50
+ ShutterFlash,
47
51
  SelfiePreview,
48
52
  StyleAbsFill,
49
53
  resolveGuidance,
50
54
  useSelfieUpload,
55
+ LivenessCamera,
51
56
  LivenessComplete,
52
57
  LivenessFailed,
58
+ LivenessHandover,
59
+ useSelfieAutoAdvance,
53
60
  } from './liveness';
61
+ import { showsSelfieReview } from '../config/biometricOptions';
54
62
  import {
55
63
  lightingGuidanceText,
56
64
  positionGuidanceText,
@@ -84,6 +92,7 @@ export function LivenessStep(): React.ReactElement {
84
92
  const setMediaId = useKyc((s) => s.setMediaId);
85
93
  const setCaptureIntegrity = useKyc((s) => s.setCaptureIntegrity);
86
94
  const nextStep = useKyc((s) => s.nextStep);
95
+ const clearSelfie = useKyc((s) => s.clearSelfie);
87
96
 
88
97
  const device = useCameraDevice('front');
89
98
  const { hasPermission, requestPermission } = useCameraPermission();
@@ -113,6 +122,11 @@ export function LivenessStep(): React.ReactElement {
113
122
  const [perm, setPerm] = useState<'priming' | 'requesting' | 'granted' | 'denied'>(
114
123
  hasPermission ? 'granted' : 'priming',
115
124
  );
125
+
126
+ // Android fetches the face model through Play Services rather than bundling
127
+ // it, so it can be absent on a fresh install. Normally already 'ready' here —
128
+ // the flow primes the download at open, behind consent and ID-type selection.
129
+ const modelState = useFaceModelReady();
116
130
  const permReportedRef = useRef(false);
117
131
 
118
132
  // Camera-availability grace (a simulator has no front camera).
@@ -170,6 +184,16 @@ export function LivenessStep(): React.ReactElement {
170
184
  uploadSelfieAndVideo,
171
185
  } = upload;
172
186
 
187
+ // A stored selfie with no uploaded mediaId is an interrupted upload (failed,
188
+ // or the user left mid-flight): resume it once on mount. Idempotent — with a
189
+ // mediaId there is nothing to do.
190
+ useEffect(() => {
191
+ if (selfieUri && !selfieIdRef.current && !uploading && !uploadError) {
192
+ void uploadSelfieAndVideo(selfieUri, videoPathRef.current);
193
+ }
194
+ // eslint-disable-next-line react-hooks/exhaustive-deps
195
+ }, []);
196
+
173
197
  // ── Liveness state machine ─────────────────────────────────────────────────
174
198
  // `handleCapture` is created after `liveness` (it calls liveness.markComplete),
175
199
  // so the machine gets it via a ref to avoid a definition cycle.
@@ -268,6 +292,66 @@ export function LivenessStep(): React.ReactElement {
268
292
  // always reads the latest state-machine callbacks.
269
293
  const livenessRef = useRef(liveness);
270
294
  livenessRef.current = liveness;
295
+
296
+ // The circle and the gesture avatar are sized from the WINDOW, not the width
297
+ // alone, so a short phone keeps the avatar on screen (lib/livenessLayout).
298
+ // A hook, so it lives up here with the ring refs, above the gates below.
299
+ const windowSize = useWindowDimensions();
300
+ const layout = livenessLayout(windowSize);
301
+
302
+ // Progress for the ring, read every frame from a ref — never from render.
303
+ // Declared HERE, above the early returns below: a hook after a conditional
304
+ // return renders "more hooks than during the previous render" the moment the
305
+ // gate dismisses, which is exactly what the ready primer does on its way out.
306
+ const phaseStartedRef = useRef(Date.now());
307
+ const phaseKeyRef = useRef(`${liveness.phase}:${liveness.completedCount}`);
308
+ const phaseKey = `${liveness.phase}:${liveness.completedCount}`;
309
+ if (phaseKeyRef.current !== phaseKey) {
310
+ phaseKeyRef.current = phaseKey;
311
+ phaseStartedRef.current = Date.now();
312
+ }
313
+ const timeoutRef = useRef(
314
+ liveness.timeoutRemaining || DEFAULT_LIVENESS_CONFIG.timeoutPerChallenge,
315
+ );
316
+ if (liveness.phase === 'challenge' && liveness.timeoutRemaining > timeoutRef.current) {
317
+ timeoutRef.current = liveness.timeoutRemaining;
318
+ }
319
+ const getRingTarget = useCallback(() => {
320
+ const l = livenessRef.current;
321
+ return livenessProgress({
322
+ phase: l.phase,
323
+ completedCount: l.completedCount,
324
+ totalCount: l.totalCount,
325
+ elapsedInPhase: (Date.now() - phaseStartedRef.current) / 1000,
326
+ challengeTimeout: timeoutRef.current,
327
+ });
328
+ }, []);
329
+
330
+ // The selfie URI lands BEFORE the machine reaches `complete` (capture →
331
+ // compress → setSelfieUri → markComplete), and the review is gated on that
332
+ // URI — so the camera circle used to unmount on the very frame the ring's
333
+ // target reached the end: the close, the green, the shutter, none of it
334
+ // was ever seen. Hold the frame for the closing beat, then hand over.
335
+ const [closeSeen, setCloseSeen] = useState(false);
336
+ useEffect(() => {
337
+ if (liveness.phase !== 'complete') return;
338
+ const t = setTimeout(() => setCloseSeen(true), 700);
339
+ return () => clearTimeout(t);
340
+ }, [liveness.phase]);
341
+ const holdingForClose =
342
+ (liveness.phase === 'capturing' || liveness.phase === 'complete') && !closeSeen;
343
+ // The biometric scopes skip the review by default: hand over the moment the
344
+ // ring has closed, WITHOUT waiting for the upload (see LivenessHandover).
345
+ // The upload reports to the store and the submitted step waits on it, so
346
+ // the person sees one loading screen rather than one per step. A restored
347
+ // selfie (media id, no local file) is ready at once; an upload that already
348
+ // failed keeps the review, whose Try Again is the recovery.
349
+ const selfieReview = showsSelfieReview(config);
350
+ useSelfieAutoAdvance({
351
+ enabled: !selfieReview,
352
+ ready: (!!selfieUri || !!selfieIdRef.current) && !holdingForClose && !uploadError,
353
+ onAdvance: nextStep,
354
+ });
271
355
  // A plain JS function the worklet hands the per-frame result to. It is wrapped
272
356
  // with `runOnJS(...)` INSIDE the worklet (the canonical react-native-worklets
273
357
  // pattern) rather than pre-wrapped — pre-wrapping produced a "non-worklet
@@ -347,10 +431,54 @@ export function LivenessStep(): React.ReactElement {
347
431
  },
348
432
  });
349
433
 
434
+ // ── Review (selfie already exists) ──────────────────────────────────────────
435
+ // Checked BEFORE every capture-flow gate (ready primer, permission, model):
436
+ // a selfie captured on an earlier visit — or a restored session where only
437
+ // the uploaded mediaId survived — means there is nothing to capture, so the
438
+ // step opens on review exactly as the document steps reopen on theirs.
439
+ // A restored selfie (earlier visit) opens straight on review; the one being
440
+ // captured RIGHT NOW waits for the ring to close first.
441
+ if (selfieIdRef.current || (selfieUri && !holdingForClose)) {
442
+ // Review hidden: the hook above advances on this render; the view is the
443
+ // submitted step's own loading screen, so the hand-over is invisible. A
444
+ // FAILED upload still gets the review, for its retry.
445
+ if (!selfieReview && !uploadError) {
446
+ return <LivenessHandover />;
447
+ }
448
+ return (
449
+ <LivenessComplete
450
+ selfieUri={selfieUri}
451
+ upload={upload}
452
+ onRetake={() => {
453
+ setSelfieUri(null);
454
+ setCloseSeen(false);
455
+ setUploadError(null);
456
+ selfieIdRef.current = null;
457
+ videoPathRef.current = null;
458
+ clearSelfie();
459
+ liveness.reset();
460
+ }}
461
+ onContinue={nextStep}
462
+ />
463
+ );
464
+ }
465
+
350
466
  // ── Error states ────────────────────────────────────────────────────────────
351
467
  if (cameraUnavailable) {
352
468
  return <CameraUnavailableView />;
353
469
  }
470
+ // The face model is fetched on demand on Android (see useModelReady), so it
471
+ // can be absent on a fresh install or a device without Play Services. Checked
472
+ // here, before the camera opens, because detectFace reports a missing model
473
+ // and an empty frame identically — gating on frames would leave the user on
474
+ // "position your face" forever with nothing to explain it.
475
+ if (modelState === 'unavailable') {
476
+ return (
477
+ <CameraUnavailableView
478
+ message="Face verification couldn't start on this device. Check your connection and try again, or use a different device."
479
+ />
480
+ );
481
+ }
354
482
  // "Here's what happens next", BEFORE the OS prompt — and shown even when
355
483
  // permission is already granted, because it is about what the step will ask
356
484
  // of the user, not about access. Opening the camera unannounced is what makes
@@ -365,24 +493,6 @@ export function LivenessStep(): React.ReactElement {
365
493
  return <CameraPermissionView onRetry={() => setPerm('requesting')} />;
366
494
  }
367
495
 
368
- // ── Review (selfie captured) ─────────────────────────────────────────────────
369
- if (liveness.phase === 'complete' && selfieUri) {
370
- return (
371
- <LivenessComplete
372
- selfieUri={selfieUri}
373
- upload={upload}
374
- onRetake={() => {
375
- setSelfieUri(null);
376
- setUploadError(null);
377
- selfieIdRef.current = null;
378
- videoPathRef.current = null;
379
- liveness.reset();
380
- }}
381
- onContinue={nextStep}
382
- />
383
- );
384
- }
385
-
386
496
  if (liveness.phase === 'failed') {
387
497
  return <LivenessFailed reason={liveness.failureReason} onRetry={liveness.reset} />;
388
498
  }
@@ -398,31 +508,32 @@ export function LivenessStep(): React.ReactElement {
398
508
  liveness.lightingGuidance != null;
399
509
  const isCamLoading = phase === 'loading';
400
510
 
511
+ // The ring is on the frame from positioning to the shutter, so for that whole
512
+ // stretch the border is its TRACK. Phase colour stays on the instruction text
513
+ // and the passed flash; a warning still turns the track red under the arc.
514
+ // `failed` has already returned above, so loading is the only phase without it.
515
+ const ringOnFrame = !isCamLoading;
401
516
  const ringColor = isCamLoading
402
517
  ? colors.gray300
403
518
  : hasWarning
404
519
  ? colors.error
405
- : phase === 'challenge_passed' || phase === 'capturing'
406
- ? colors.success
407
- : phase === 'challenge'
408
- ? colors.warning
409
- : liveness.faceDetected
410
- ? colors.primary
411
- : colors.gray300;
520
+ : ringOnFrame
521
+ ? `${colors.primary}33`
522
+ : colors.gray300;
412
523
 
413
524
  const guidance = resolveGuidance(liveness);
414
525
  const instrColor = isCamLoading
415
526
  ? colors.textMuted
416
527
  : guidance.tone === 'error'
417
528
  ? colors.error
418
- : phase === 'challenge_passed'
529
+ : phase === 'challenge_passed' || phase === 'complete'
419
530
  ? colors.success
420
531
  : phase === 'challenge'
421
532
  ? colors.warning
422
533
  : colors.textDark;
423
534
 
424
535
  // Fixed circle, sized to fit the sheet width (caps at 300).
425
- const CIRCLE = Math.min(Dimensions.get('window').width - spacing.md * 4, 300);
536
+ const CIRCLE = layout.circle;
426
537
  const lighting = liveness.lightingGuidance;
427
538
 
428
539
  // Rendered INSIDE the sheet, so the SDK header and the "powered by" footer
@@ -438,7 +549,10 @@ export function LivenessStep(): React.ReactElement {
438
549
  {phase === 'flash' ? '' : guidance.text}
439
550
  </MyazaText>
440
551
 
441
- {/* Circular camera with a thick colour-state ring */}
552
+ {/* Circular camera. Two boxes: the inner one CLIPS the preview to a circle,
553
+ the outer one lets the progress ring sit on that border rather than be
554
+ clipped by it. */}
555
+ <View style={{ width: CIRCLE, height: CIRCLE }}>
442
556
  <View
443
557
  ref={flashHoleRef}
444
558
  collapsable={false}
@@ -452,21 +566,14 @@ export function LivenessStep(): React.ReactElement {
452
566
  backgroundColor: '#111111',
453
567
  }}
454
568
  >
455
- {device ? (
456
- <Camera
457
- style={{ flex: 1 }}
458
- device={device}
459
- isActive={perm === 'granted'}
460
- outputs={[photoOutput, videoOutput, frameOutput]}
461
- // Mirror the front-camera preview + capture so it reads like a
462
- // mirror (matches the web SDK's scaleX(-1) video).
463
- mirrorMode="on"
464
- />
465
- ) : (
466
- <View style={{ flex: 1, alignItems: 'center', justifyContent: 'center' }}>
467
- <ActivityIndicator color="#FFFFFF" />
468
- </View>
469
- )}
569
+ {/* The Camera lives in its own borderless wrapper — see LivenessCamera
570
+ for why a bordered parent shifts the Android preview by one border. */}
571
+ <LivenessCamera
572
+ device={device}
573
+ active={perm === 'granted'}
574
+ outputs={[photoOutput, videoOutput, frameOutput]}
575
+ circle={CIRCLE}
576
+ />
470
577
 
471
578
  {/* Dashed face-guide ellipse (taller than wide) */}
472
579
  <Svg style={{ position: 'absolute', top: 0, left: 0, right: 0, bottom: 0 }} width={CIRCLE} height={CIRCLE}>
@@ -501,12 +608,9 @@ export function LivenessStep(): React.ReactElement {
501
608
  </View>
502
609
  ) : null}
503
610
 
504
- {/* Capturing flash — white wash + "Got it!" */}
505
- {phase === 'capturing' ? (
506
- <View style={[StyleAbsFill, { backgroundColor: 'rgba(255,255,255,0.3)', alignItems: 'center', justifyContent: 'center' }]}>
507
- <MyazaText variant="heading3" color="#FFFFFF">Got it!</MyazaText>
508
- </View>
509
- ) : null}
611
+ {/* The shutter: a white flash as the still is taken. It used to sit on
612
+ `capturing` reading "Got it!", announcing a photo not yet taken. */}
613
+ {phase === 'complete' ? <ShutterFlash /> : null}
510
614
 
511
615
  {/* Timeout countdown */}
512
616
  {phase === 'challenge' && liveness.timeoutRemaining > 0 ? (
@@ -516,6 +620,20 @@ export function LivenessStep(): React.ReactElement {
516
620
  ) : null}
517
621
  </View>
518
622
 
623
+ {/* The ring — outside the clipping box, on the frame's own edge. One line,
624
+ building one way for the whole test; primary while it builds, green
625
+ only as it closes, on the same frame as the shutter. */}
626
+ {ringOnFrame ? (
627
+ <CaptureRing
628
+ size={CIRCLE}
629
+ getTarget={getRingTarget}
630
+ color={colors.primary}
631
+ successColor={colors.success}
632
+ complete={phase === 'complete'}
633
+ />
634
+ ) : null}
635
+ </View>
636
+
519
637
  {/* Lighting warning banner — mirrors Flutter (amber-50/200/800 + lightbulb) */}
520
638
  {lighting ? <LightingBanner text={lightingGuidanceText(lighting)} /> : null}
521
639
 
@@ -523,7 +641,9 @@ export function LivenessStep(): React.ReactElement {
523
641
  <ProgressDots total={liveness.totalCount} completed={liveness.completedCount} active={phase === 'challenge' || phase === 'positioning' || phase === 'flash'} />
524
642
 
525
643
  {/* Gesture demo avatar */}
526
- {liveness.activeChallenge ? <LivenessAvatar challenge={liveness.activeChallenge} /> : null}
644
+ {liveness.activeChallenge ? (
645
+ <LivenessAvatar challenge={liveness.activeChallenge} size={layout.avatar} iconSize={layout.avatarIcon} />
646
+ ) : null}
527
647
  </View>
528
648
  );
529
649
  }
@@ -8,6 +8,7 @@ import { MyazaButton } from '../components/MyazaButton';
8
8
  import { MyazaPulseLoader } from '../components/MyazaPulseLoader';
9
9
  import { Icon } from '../components/Icon';
10
10
  import { cancelChipRead, nfcUnavailableReason, readPassportChip, type EmrtdReadResult } from '../emrtd';
11
+ import { fromBase64 } from '../emrtd/bytes';
11
12
  import { useNfcAvailability } from './nfc/useNfcAvailability';
12
13
  import { readErrorMessage } from './nfc/readErrorMessage';
13
14
  import { successHaptic } from '../services/haptics';
@@ -136,6 +137,18 @@ export function NfcStep(): React.ReactElement {
136
137
  stageRef.current = 'waiting';
137
138
  readingRef.current = true;
138
139
  try {
140
+ // The anti-clone challenge, asked for BEFORE the read so the whole thing
141
+ // happens in one session on the document. Best-effort by design: the
142
+ // server may not answer, and a chip read without a challenge is exactly
143
+ // the read this SDK did before Active Authentication existed. It must
144
+ // never be the reason a passport cannot be scanned.
145
+ const aaChallenge = await store
146
+ .getState()
147
+ .api.nfcChallenge()
148
+ .then((res) => ({ id: res.challengeId, bytes: fromBase64(res.challenge) }))
149
+ .catch(() => undefined);
150
+ if (!liveRef.current || seq !== readSeqRef.current) return;
151
+
139
152
  const result = await readPassportChip(
140
153
  {
141
154
  documentNumber: mrz.documentNumber,
@@ -143,6 +156,7 @@ export function NfcStep(): React.ReactElement {
143
156
  dateOfExpiry: toMrzDate(mrz.dateOfExpiry),
144
157
  },
145
158
  {
159
+ ...(aaChallenge ? { aaChallenge } : {}),
146
160
  onStage: (s) => {
147
161
  if (!liveRef.current || seq !== readSeqRef.current) return;
148
162
  stageRef.current = s;
@@ -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
+ }
@@ -0,0 +1,151 @@
1
+ import React from 'react';
2
+ import { ActivityIndicator, Image, Pressable, View } from 'react-native';
3
+
4
+ import { radius, spacing } from '../config/theme';
5
+ import { UPLOAD_HINT } from '../config/uploadLimits';
6
+ import { useTheme } from '../components/runtime';
7
+ import { MyazaText } from '../components/Typography';
8
+ import { Icon } from '../components/Icon';
9
+ import { DashedBorder } from '../components/DashedBorder';
10
+ import { CountryFlag } from '../components/CountryFlag';
11
+
12
+ // ---------------------------------------------------------------------------
13
+ // Proof of Address — the two states of the attachment area, split out of the
14
+ // step (200-line rule; mirrors the Flutter SDK's proof_of_address_parts).
15
+ // Pure presentation: the step owns the picking, uploading and removing.
16
+ //
17
+ // Both states carry the country the document is for (user decision
18
+ // 2026-09-05): the flag before the call to action on the drop zone, and before
19
+ // the document kind on the uploaded row, so a person on a multi-market flow
20
+ // sees which market the paper is being read against. Null when the flow does
21
+ // not know it yet (the address scope before a pick) — nothing is invented.
22
+ // ---------------------------------------------------------------------------
23
+
24
+ function Flag({ country, size }: { country: string | null; size: number }): React.ReactElement | null {
25
+ if (!country) return null;
26
+ return (
27
+ <>
28
+ <CountryFlag country={country} size={size} />
29
+ <View style={{ width: spacing.xs }} />
30
+ </>
31
+ );
32
+ }
33
+
34
+ export function PoaUploadedRow({
35
+ preview,
36
+ fileName,
37
+ typeLabel,
38
+ country,
39
+ onRemove,
40
+ }: {
41
+ preview: { uri: string; isPdf: boolean } | null;
42
+ fileName: string | null;
43
+ typeLabel: string;
44
+ country: string | null;
45
+ onRemove: () => void;
46
+ }): React.ReactElement {
47
+ const { colors } = useTheme();
48
+ return (
49
+ <View
50
+ style={{
51
+ flexDirection: 'row',
52
+ alignItems: 'center',
53
+ padding: spacing.md,
54
+ borderRadius: radius.md,
55
+ borderWidth: 1,
56
+ borderColor: colors.border,
57
+ backgroundColor: colors.backgroundSecondary,
58
+ }}
59
+ >
60
+ {preview && !preview.isPdf ? (
61
+ <Image
62
+ source={{ uri: preview.uri }}
63
+ style={{ width: 48, height: 48, borderRadius: radius.sm }}
64
+ resizeMode="cover"
65
+ />
66
+ ) : (
67
+ <View
68
+ style={{
69
+ width: 48,
70
+ height: 48,
71
+ borderRadius: radius.sm,
72
+ borderWidth: 1,
73
+ borderColor: colors.border,
74
+ alignItems: 'center',
75
+ justifyContent: 'center',
76
+ }}
77
+ >
78
+ <Icon name="file-text" size={22} color={colors.primary} />
79
+ </View>
80
+ )}
81
+ <View style={{ width: spacing.md, flexShrink: 0 }} />
82
+ <View style={{ flex: 1, minWidth: 0 }}>
83
+ <MyazaText variant="bodySmall" style={{ fontWeight: '600' }} numberOfLines={1}>
84
+ {fileName ?? 'Document uploaded'}
85
+ </MyazaText>
86
+ <View style={{ flexDirection: 'row', alignItems: 'center' }}>
87
+ <Flag country={country} size={14} />
88
+ <MyazaText variant="bodySmall" color={colors.textSecondary} numberOfLines={1} style={{ flexShrink: 1 }}>
89
+ {typeLabel}
90
+ </MyazaText>
91
+ </View>
92
+ </View>
93
+ <Pressable
94
+ onPress={onRemove}
95
+ accessibilityRole="button"
96
+ accessibilityLabel="Remove document"
97
+ hitSlop={8}
98
+ >
99
+ <Icon name="x" size={18} color={colors.textSecondary} />
100
+ </Pressable>
101
+ </View>
102
+ );
103
+ }
104
+
105
+ /** The DASHED drop zone that NAMES the document being asked for. */
106
+ export function PoaDropzone({
107
+ busy,
108
+ typeLabel,
109
+ country,
110
+ onPress,
111
+ }: {
112
+ busy: boolean;
113
+ typeLabel: string;
114
+ country: string | null;
115
+ onPress: () => void;
116
+ }): React.ReactElement {
117
+ const { colors } = useTheme();
118
+ return (
119
+ <Pressable
120
+ onPress={() => {
121
+ if (!busy) onPress();
122
+ }}
123
+ accessibilityRole="button"
124
+ accessibilityLabel={`Upload your ${typeLabel.toLowerCase()}`}
125
+ style={{
126
+ alignItems: 'center',
127
+ justifyContent: 'center',
128
+ paddingVertical: spacing.xl,
129
+ paddingHorizontal: spacing.lg,
130
+ borderRadius: radius.md,
131
+ }}
132
+ >
133
+ <DashedBorder color={colors.border} radius={radius.md} strokeWidth={1.5} />
134
+ {busy ? (
135
+ <ActivityIndicator size="small" color={colors.primary} />
136
+ ) : (
137
+ <Icon name="upload" size={30} color={colors.textSecondary} />
138
+ )}
139
+ <View style={{ height: spacing.sm }} />
140
+ <View style={{ flexDirection: 'row', alignItems: 'center' }}>
141
+ <Flag country={country} size={18} />
142
+ <MyazaText variant="bodySmall" style={{ fontWeight: '600' }}>
143
+ {busy ? 'Uploading…' : `Upload your ${typeLabel.toLowerCase()}`}
144
+ </MyazaText>
145
+ </View>
146
+ <MyazaText variant="bodySmall" color={colors.textSecondary}>
147
+ {UPLOAD_HINT}
148
+ </MyazaText>
149
+ </Pressable>
150
+ );
151
+ }