@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,77 @@
1
+ import React from 'react';
2
+ import { View } from 'react-native';
3
+
4
+ import { spacing } from '../../config/theme';
5
+ import { MyazaAlert } from '../../components/MyazaAlert';
6
+ import { MyazaButton } from '../../components/MyazaButton';
7
+ import { documentReviewCopy } from '../../components/documentReviewCopy';
8
+ import {
9
+ CAPTURE_CHECK_CONTINUE_ANYWAY,
10
+ CAPTURE_CHECK_TITLE,
11
+ captureProblemMessage,
12
+ captureRetakeLabel,
13
+ captureRetakeSides,
14
+ type CaptureProblem,
15
+ } from '../../lib/documentCaptureCheck';
16
+ import type { DocumentCaptureSide } from '../../services/api-types';
17
+
18
+ // ─── The review footer when a document photo will not read ───────────────────
19
+ //
20
+ // Shown in place of Continue after the server's capture check found no face on
21
+ // the printed photo or no readable barcode (lib/documentCaptureCheck). The
22
+ // words come from that module so they stay in lockstep with the web and
23
+ // Flutter SDKs.
24
+ //
25
+ // A notice, never a gate: the detectors can miss, so "Continue anyway" is
26
+ // always offered and moves on with the photos already uploaded. The first
27
+ // retake is the primary action because a retake is the likelier fix.
28
+
29
+ export function CaptureCheckNotice({
30
+ problems,
31
+ uploadOnly,
32
+ onRetake,
33
+ onContinueAnyway,
34
+ }: {
35
+ problems: readonly CaptureProblem[];
36
+ /** The sides were picked from the device, so they are replaced, not retaken. */
37
+ uploadOnly: boolean;
38
+ onRetake: (side: DocumentCaptureSide) => void;
39
+ onContinueAnyway: () => void;
40
+ }): React.ReactElement {
41
+ // Once per kind: two sides with the same problem need one sentence, not two.
42
+ const messages = [...new Set(problems.map((p) => captureProblemMessage(p.kind)))];
43
+ const sides = captureRetakeSides(problems);
44
+ const copy = documentReviewCopy(uploadOnly ? 'upload' : 'scan', true);
45
+
46
+ return (
47
+ <View style={{ gap: spacing.sm }}>
48
+ {/* Grouped and announced: the notice replaces the button the applicant
49
+ just pressed, so a screen reader has to say why nothing moved on. */}
50
+ <View
51
+ accessible
52
+ accessibilityRole="alert"
53
+ accessibilityLiveRegion="polite"
54
+ accessibilityLabel={`${CAPTURE_CHECK_TITLE}. ${messages.join(' ')}`}
55
+ >
56
+ <MyazaAlert variant="warning" title={CAPTURE_CHECK_TITLE} message={messages.join('\n\n')} />
57
+ </View>
58
+ {sides.map((side, i) => (
59
+ <MyazaButton
60
+ key={side}
61
+ label={captureRetakeLabel(side, uploadOnly)}
62
+ variant={i === 0 ? 'primary' : 'outline'}
63
+ leadingIcon="refresh"
64
+ accessibilityLabel={copy.redoAccessibility(side === 'front' ? 'Front' : 'Back')}
65
+ onPress={() => onRetake(side)}
66
+ />
67
+ ))}
68
+ <MyazaButton
69
+ label={CAPTURE_CHECK_CONTINUE_ANYWAY}
70
+ variant="outline"
71
+ accessibilityLabel={CAPTURE_CHECK_CONTINUE_ANYWAY}
72
+ accessibilityHint="Carries on with these photos as they are"
73
+ onPress={onContinueAnyway}
74
+ />
75
+ </View>
76
+ );
77
+ }
@@ -0,0 +1,177 @@
1
+ import React from 'react';
2
+ import { ActivityIndicator, Pressable, View } from 'react-native';
3
+
4
+ import { radius, spacing } from '../../config/theme';
5
+ import { useTheme } from '../../components/runtime';
6
+ import { MyazaText } from '../../components/Typography';
7
+ import { Icon, type IconName } from '../../components/Icon';
8
+ import { DashedBorder } from '../../components/DashedBorder';
9
+ import type { DocumentCapturePhase } from '../../store/state';
10
+ import { RequiredPill } from './RequiredPill';
11
+
12
+ // ─── Document capture without the camera ──────────────────────────────────────
13
+ //
14
+ // One side of the document when the workflow switches scanning off
15
+ // (`allowDocumentScan: false`): a photo chosen from the device, never a camera
16
+ // shot. It renders in place of the camera gates, and nothing here touches
17
+ // VisionCamera, so an upload-only flow never asks for camera access and never
18
+ // records a capture video.
19
+ //
20
+ // Only the ASK differs. What happens after the pick is the step's own and shared
21
+ // with the camera path: the interactive cropper, the compress, the MRZ read off
22
+ // the front, the front preview and the review.
23
+
24
+ /** The sheet header for an upload-only capture phase (see documentCaptureMeta). */
25
+ export function documentUploadMeta(
26
+ phase: DocumentCapturePhase,
27
+ documentLabel: string,
28
+ ): { title: string; description: string } {
29
+ switch (phase) {
30
+ case 'front':
31
+ return {
32
+ title: `Upload Your ${documentLabel}`,
33
+ description: `Choose a clear photo of your ${documentLabel} from your device.`,
34
+ };
35
+ case 'front-preview':
36
+ return {
37
+ title: 'Front Side Added',
38
+ description: 'Looks good? Tap Next to add a photo of the back.',
39
+ };
40
+ case 'back':
41
+ return {
42
+ title: 'Upload Back Side',
43
+ description: `Now choose a photo of the back of your ${documentLabel}.`,
44
+ };
45
+ case 'review':
46
+ default:
47
+ return {
48
+ title: `Review Your ${documentLabel}`,
49
+ description: 'Tap Continue to upload and submit your document.',
50
+ };
51
+ }
52
+ }
53
+
54
+ const TIPS: ReadonlyArray<{ icon: IconName; label: string }> = [
55
+ { icon: 'id-card', label: 'The whole document in view, all four corners' },
56
+ { icon: 'sun', label: 'Sharp and evenly lit, with no glare' },
57
+ ];
58
+
59
+ export interface UploadPhaseProps {
60
+ isBack: boolean;
61
+ documentLabel: string;
62
+ isTwoSided: boolean;
63
+ /** A picked photo is being cropped and compressed. */
64
+ busy: boolean;
65
+ onUpload: () => void;
66
+ }
67
+
68
+ export function UploadPhase(p: UploadPhaseProps): React.ReactElement {
69
+ const { colors } = useTheme();
70
+ const subject = !p.isTwoSided ? `your ${p.documentLabel}` : p.isBack ? 'the back' : 'the front';
71
+ const ask = `Add a photo of ${subject}`;
72
+ const choose = () => {
73
+ if (!p.busy) p.onUpload();
74
+ };
75
+
76
+ return (
77
+ <View>
78
+ <RequiredPill
79
+ documentLabel={p.documentLabel}
80
+ sideBadge={p.isTwoSided ? (p.isBack ? 'Back Side' : 'Front Side') : undefined}
81
+ stepLabel={p.isTwoSided ? (p.isBack ? 'Step 2 of 2' : 'Step 1 of 2') : undefined}
82
+ />
83
+ {p.isBack ? (
84
+ <View style={{ flexDirection: 'row', alignItems: 'center', marginTop: spacing.sm, marginBottom: spacing.xs }}>
85
+ <Icon name="credit-card" size={14} color={colors.primary} />
86
+ <View style={{ width: 4 }} />
87
+ <MyazaText variant="bodySmall" color={colors.primary} style={{ fontWeight: '500' }}>
88
+ Turn the card over and add a photo of the other side
89
+ </MyazaText>
90
+ </View>
91
+ ) : null}
92
+ <View style={{ height: spacing.md }} />
93
+
94
+ {/* The whole card is the target and there is no button beside it: on a
95
+ screen whose only job is to open the photo picker, the card is where
96
+ the thumb goes. A press deepens the fill and solidifies the border;
97
+ the primary line at the bottom says what a tap does. */}
98
+ <Pressable
99
+ testID={`kyc.document.upload.${p.isBack ? 'back' : 'front'}`}
100
+ onPress={choose}
101
+ disabled={p.busy}
102
+ accessibilityRole="button"
103
+ accessibilityLabel={p.busy ? 'Preparing your photo' : ask}
104
+ accessibilityHint="Opens your photos"
105
+ accessibilityState={{ busy: p.busy, disabled: p.busy }}
106
+ style={({ pressed }) => ({
107
+ alignItems: 'center',
108
+ paddingVertical: spacing.xl,
109
+ paddingHorizontal: spacing.lg,
110
+ borderRadius: radius.md,
111
+ backgroundColor: pressed ? `${colors.primary}1A` : `${colors.primary}0D`,
112
+ transform: [{ scale: pressed ? 0.99 : 1 }],
113
+ })}
114
+ >
115
+ {({ pressed }) => (
116
+ <>
117
+ <DashedBorder
118
+ color={pressed ? colors.primary : `${colors.primary}66`}
119
+ radius={radius.md}
120
+ strokeWidth={1.5}
121
+ />
122
+ <View
123
+ style={{
124
+ width: 64,
125
+ height: 64,
126
+ borderRadius: radius.full,
127
+ backgroundColor: `${colors.primary}1A`,
128
+ alignItems: 'center',
129
+ justifyContent: 'center',
130
+ }}
131
+ >
132
+ {p.busy ? (
133
+ <ActivityIndicator size="small" color={colors.primary} />
134
+ ) : (
135
+ <Icon name="image" size={30} color={colors.primary} />
136
+ )}
137
+ </View>
138
+ <View style={{ height: spacing.md }} />
139
+ <MyazaText variant="heading3" style={{ textAlign: 'center' }}>
140
+ {p.busy ? 'Preparing your photo…' : ask}
141
+ </MyazaText>
142
+ <View style={{ height: spacing.xs }} />
143
+ <MyazaText variant="bodySmall" color={colors.textSecondary} style={{ textAlign: 'center' }}>
144
+ Choose a clear photo from your device. You can crop it on the next screen.
145
+ </MyazaText>
146
+ <View style={{ height: spacing.md }} />
147
+ {/* Stays in the layout while busy so the card keeps its height. */}
148
+ <View
149
+ style={{ flexDirection: 'row', alignItems: 'center', opacity: p.busy ? 0 : 1 }}
150
+ accessibilityElementsHidden
151
+ importantForAccessibility="no-hide-descendants"
152
+ >
153
+ <Icon name="upload" size={16} color={colors.primary} />
154
+ <View style={{ width: spacing.xs }} />
155
+ <MyazaText variant="label" color={colors.primary} style={{ fontWeight: '600' }}>
156
+ Tap to choose a photo
157
+ </MyazaText>
158
+ <View style={{ width: 2 }} />
159
+ <Icon name="chevron-right" size={16} color={colors.primary} />
160
+ </View>
161
+ </>
162
+ )}
163
+ </Pressable>
164
+
165
+ <View style={{ height: spacing.md }} />
166
+ {TIPS.map((tip) => (
167
+ <View key={tip.label} style={{ flexDirection: 'row', alignItems: 'center', marginBottom: spacing.xs }}>
168
+ <Icon name={tip.icon} size={16} color={colors.textMuted} />
169
+ <View style={{ width: spacing.sm }} />
170
+ <MyazaText variant="bodySmall" color={colors.textSecondary} style={{ flexShrink: 1 }}>
171
+ {tip.label}
172
+ </MyazaText>
173
+ </View>
174
+ ))}
175
+ </View>
176
+ );
177
+ }
@@ -0,0 +1,109 @@
1
+ import { useCallback, useEffect, useRef, useState } from 'react';
2
+ import { useCameraDevice, useCameraPermission } from 'react-native-vision-camera';
3
+
4
+ import { safeReportError } from '../../services/errors';
5
+ import { KYCError } from '../../types/verification';
6
+
7
+ // The document camera's permission and hardware state, lifted out of
8
+ // DocumentCaptureStep so that only a workflow which SCANS ever calls
9
+ // VisionCamera's hooks. An upload-only workflow (`allowDocumentScan: false`)
10
+ // never mounts the component that uses this, so it never reads the camera
11
+ // permission, never enumerates devices and never prompts. The logic is the
12
+ // step's own, moved rather than changed.
13
+
14
+ export type DocumentCameraPermission = 'priming' | 'requesting' | 'granted' | 'denied';
15
+
16
+ export interface DocumentCamera {
17
+ perm: DocumentCameraPermission;
18
+ /** A back camera exists (false on a simulator, which has none). */
19
+ hasDevice: boolean;
20
+ /** No camera hardware at all, once the device list has had a moment. */
21
+ cameraUnavailable: boolean;
22
+ /** The "Allow camera access" primer, shown before the OS prompt. */
23
+ showPrimer: boolean;
24
+ /** A real camera exists but the OS blocked access. */
25
+ permissionDenied: boolean;
26
+ /** Fire the real OS prompt: the primer's "Grant access", or a retry. */
27
+ requestAccess: () => void;
28
+ }
29
+
30
+ export function useDocumentCamera(onError: ((error: KYCError) => void) | undefined): DocumentCamera {
31
+ // ── Camera permission ──────────────────────────────────────────────────────
32
+ // `perm` is derived from the ASYNC requestPermission result, not synchronously
33
+ // from `hasPermission` — otherwise the brief window while the OS prompt is open
34
+ // (hasPermission still false) would read as "denied" and fire onError early.
35
+ // 'priming' shows the "Allow camera access" screen BEFORE the OS prompt
36
+ // (Stripe-style); the prompt only fires (→ 'requesting') once the user taps
37
+ // "Grant access".
38
+ const { hasPermission, requestPermission } = useCameraPermission();
39
+ const [perm, setPerm] = useState<DocumentCameraPermission>(hasPermission ? 'granted' : 'priming');
40
+ const permReportedRef = useRef(false);
41
+
42
+ // ── Camera availability ─────────────────────────────────────────────────────
43
+ // Even with permission granted, there may be no usable back camera (the iOS/
44
+ // Android simulator has none; a real device may fail to init). Give the device
45
+ // list a moment to resolve, then surface a proper "Camera not available" error
46
+ // with an upload fallback — on every iOS version (glass or not) and Android.
47
+ // Device enumeration does NOT need camera permission (iOS AVCaptureDevice /
48
+ // Android CameraManager list hardware regardless), so `!device` reliably means
49
+ // "no back-camera hardware" — true on every simulator. That's a different state
50
+ // from "permission denied": no hardware → nothing to grant.
51
+ const device = useCameraDevice('back');
52
+ const [cameraGrace, setCameraGrace] = useState(false);
53
+ useEffect(() => {
54
+ const t = setTimeout(() => setCameraGrace(true), 1500);
55
+ return () => clearTimeout(t);
56
+ }, []);
57
+ // No camera hardware at all → "Camera not available" (regardless of what the
58
+ // permission API says — on a camera-less sim it may even report denied).
59
+ const cameraUnavailable = cameraGrace && !device;
60
+
61
+ // Reflect an externally-granted permission.
62
+ useEffect(() => {
63
+ if (hasPermission) setPerm('granted');
64
+ }, [hasPermission]);
65
+
66
+ // Fire the real OS prompt only after the user taps "Grant access" (or retry).
67
+ useEffect(() => {
68
+ if (perm !== 'requesting') return;
69
+ let cancelled = false;
70
+ void (async () => {
71
+ const granted = await requestPermission();
72
+ if (!cancelled) setPerm(granted ? 'granted' : 'denied');
73
+ })();
74
+ return () => {
75
+ cancelled = true;
76
+ };
77
+ }, [perm, requestPermission]);
78
+
79
+ // A *genuine* permission denial requires a camera to exist but be blocked. On a
80
+ // camera-less sim the OS may report denied — that's "not available", not a
81
+ // permission problem, so don't treat it as denied or report onError there.
82
+ const permissionDenied = perm === 'denied' && !!device;
83
+ useEffect(() => {
84
+ if (permissionDenied && !permReportedRef.current) {
85
+ permReportedRef.current = true;
86
+ safeReportError(
87
+ onError,
88
+ new KYCError(
89
+ 'camera_permission_denied',
90
+ 'Camera access is required to photograph your document. Allow camera access or upload a photo instead.',
91
+ ),
92
+ );
93
+ }
94
+ if (!permissionDenied) permReportedRef.current = false;
95
+ }, [permissionDenied, onError]);
96
+
97
+ const requestAccess = useCallback(() => {
98
+ setPerm('requesting');
99
+ }, []);
100
+
101
+ return {
102
+ perm,
103
+ hasDevice: !!device,
104
+ cameraUnavailable,
105
+ showPrimer: perm === 'priming' && !!device,
106
+ permissionDenied,
107
+ requestAccess,
108
+ };
109
+ }
@@ -0,0 +1,91 @@
1
+ import React, { useEffect, useRef } from 'react';
2
+ import Svg, { Circle } from 'react-native-svg';
3
+ import { advanceTarget, easeToward, mixHex } from '../../lib/captureRing';
4
+
5
+ // A single line traced around the camera circle's edge for the length of the
6
+ // liveness test: empty when it starts, closed at the shutter, one way only, no
7
+ // rotation. The frame's own border filling in, not a spinner on top of it.
8
+ //
9
+ // Driven OUTSIDE React. `getTarget` is read every frame in a requestAnimationFrame
10
+ // loop and the arc is written with setNativeProps — no state, no re-render.
11
+ // react-native-reanimated is deliberately not a dependency of this SDK, and a
12
+ // native module is the wrong price for one ring; setNativeProps per frame is
13
+ // the no-render path core React Native offers, and the write is a single prop
14
+ // on a single leaf node. Judge it on a release build on the slowest Android
15
+ // you support.
16
+
17
+ const STROKE = 4; // the frame's own border width
18
+ const GREEN_MS = 300;
19
+
20
+ export function CaptureRing({
21
+ size,
22
+ getTarget,
23
+ color,
24
+ successColor,
25
+ complete,
26
+ }: {
27
+ size: number;
28
+ /** Latest 0..1 progress. Read every frame; must be a stable function. */
29
+ getTarget: () => number;
30
+ color: string;
31
+ successColor: string;
32
+ /** Turns the closed ring green — on the same frame as the shutter. */
33
+ complete: boolean;
34
+ }) {
35
+ const ref = useRef<React.ElementRef<typeof Circle>>(null);
36
+ const targetRef = useRef(0);
37
+ const shownRef = useRef(0);
38
+ const completeRef = useRef(complete);
39
+ const greenSinceRef = useRef<number | null>(null);
40
+ completeRef.current = complete;
41
+
42
+ const r = size / 2 - STROKE / 2;
43
+ const circumference = 2 * Math.PI * r;
44
+
45
+ useEffect(() => {
46
+ let frame = 0;
47
+ let last = Date.now();
48
+ const tick = () => {
49
+ const now = Date.now();
50
+ const dt = Math.min((now - last) / 1000, 0.1); // a backgrounded app must not jump
51
+ last = now;
52
+ targetRef.current = advanceTarget(targetRef.current, getTarget());
53
+ shownRef.current = easeToward(shownRef.current, targetRef.current, dt);
54
+ if (completeRef.current && greenSinceRef.current == null) greenSinceRef.current = now;
55
+ const green = greenSinceRef.current == null ? 0 : (now - greenSinceRef.current) / GREEN_MS;
56
+ ref.current?.setNativeProps({
57
+ strokeDashoffset: circumference * (1 - shownRef.current),
58
+ stroke: green > 0 ? mixHex(color, successColor, green) : color,
59
+ });
60
+ frame = requestAnimationFrame(tick);
61
+ };
62
+ frame = requestAnimationFrame(tick);
63
+ return () => cancelAnimationFrame(frame);
64
+ }, [getTarget, circumference, color, successColor]);
65
+
66
+ return (
67
+ <Svg
68
+ pointerEvents="none"
69
+ style={{ position: 'absolute', top: 0, left: 0 }}
70
+ width={size}
71
+ height={size}
72
+ // Static origin, not motion: the arc starts at twelve o'clock.
73
+ rotation={-90}
74
+ originX={size / 2}
75
+ originY={size / 2}
76
+ >
77
+ <Circle
78
+ ref={ref}
79
+ cx={size / 2}
80
+ cy={size / 2}
81
+ r={r}
82
+ fill="none"
83
+ stroke={color}
84
+ strokeWidth={STROKE}
85
+ strokeLinecap="butt"
86
+ strokeDasharray={[circumference]}
87
+ strokeDashoffset={circumference}
88
+ />
89
+ </Svg>
90
+ );
91
+ }
@@ -0,0 +1,63 @@
1
+ import React from 'react';
2
+ import { ActivityIndicator, View } from 'react-native';
3
+ import { Camera, type CameraDevice, type CameraViewProps } from 'react-native-vision-camera';
4
+
5
+ // ─── The front camera inside the liveness circle ────────────────────────────
6
+ //
7
+ // The Camera is mounted inside a plain, BORDERLESS wrapper, and that wrapper is
8
+ // the fix for a preview that did not reach the right-hand edge of the circle
9
+ // on Android (Galaxy S24, 2026-09-07: a vertical line one border-width in from
10
+ // the ring, the clip box's ground showing through).
11
+ //
12
+ // VisionCamera installs a "hierarchy fitter" on its Android PreviewView: the
13
+ // moment CameraX adds the SurfaceView, it re-runs `layout(0, 0, w, h)` on the
14
+ // PreviewView so the new child gets measured (React Native never lays native
15
+ // children out on its own). That call keeps the SIZE React Native gave the view
16
+ // and throws away its POSITION — so a Camera that is the direct child of a
17
+ // bordered box snaps from (border, border) back to the box's outer corner. The
18
+ // 3:4 preview buffer overflows the circle vertically and hides the shift on
19
+ // that axis; horizontally it fits exactly, and the missing border-width is the
20
+ // line. iOS has no fitter, which is why only Android showed it.
21
+ //
22
+ // A wrapper with no border sits at (border, border) itself, laid out by React
23
+ // Native, so the fitter's (0, 0) is now the right answer. The wrapper must be
24
+ // `collapsable={false}`: a View that carries nothing but layout is FLATTENED on
25
+ // Android (it never exists natively, its child is re-parented to the
26
+ // grandparent), which put the Camera straight back inside the bordered box
27
+ // and reproduced the line on the first attempt. Never mount VisionCamera's
28
+ // Camera as the direct child of a view with `borderWidth`.
29
+ // Pinned by __tests__/livenessCameraWrapper.test.ts.
30
+
31
+ interface LivenessCameraProps {
32
+ device: CameraDevice | undefined;
33
+ active: boolean;
34
+ outputs: NonNullable<CameraViewProps['outputs']>;
35
+ /** Diameter of the circle, for the Camera's own rounded clip. */
36
+ circle: number;
37
+ }
38
+
39
+ export function LivenessCamera({ device, active, outputs, circle }: LivenessCameraProps): React.ReactElement {
40
+ return (
41
+ <View style={{ flex: 1 }} collapsable={false}>
42
+ {device ? (
43
+ <Camera
44
+ // borderRadius repeated on the Camera — iOS does not reliably clip
45
+ // a native child to a rounded parent, so the preview's square
46
+ // corners showed through the circle. Same fix the selfie preview
47
+ // and the header brand bar already carry.
48
+ style={{ flex: 1, borderRadius: circle / 2, overflow: 'hidden' }}
49
+ device={device}
50
+ isActive={active}
51
+ outputs={outputs}
52
+ // Mirror the front-camera preview + capture so it reads like a
53
+ // mirror (matches the web SDK's scaleX(-1) video).
54
+ mirrorMode="on"
55
+ />
56
+ ) : (
57
+ <View style={{ flex: 1, alignItems: 'center', justifyContent: 'center' }}>
58
+ <ActivityIndicator color="#FFFFFF" />
59
+ </View>
60
+ )}
61
+ </View>
62
+ );
63
+ }
@@ -0,0 +1,43 @@
1
+ import React, { useEffect, useRef } from 'react';
2
+
3
+ import { useKycConfig } from '../../components/runtime';
4
+ import { configScope } from '../../lib/scope';
5
+ import { describeWaiting } from '../../lib/result-copy';
6
+ import { waitsForResult } from '../../config/biometricOptions';
7
+ import { biometricCopyFor } from '../../lib/biometric-copy';
8
+ import { SubmittedWaiting } from '../SubmittedWaiting';
9
+
10
+ // ─── Handing over without the review ────────────────────────────────────────
11
+ //
12
+ // On the biometric scopes the selfie review (Retake / Continue) is OFF by
13
+ // default (config/biometricOptions.ts): a re-authentication is a few-second
14
+ // check and a review screen is a stop in the middle of it. The step hands
15
+ // over the moment the ring has closed, WITHOUT waiting for the upload: the
16
+ // upload keeps running and reports to the store, and the submitted step waits
17
+ // on that record (lib/selfie-upload-wait.ts). So the person sees one loading
18
+ // screen from the shutter to the verdict, not one per step. The view below
19
+ // exists for the single render between "ready" and the step change, and it
20
+ // is the same screen the submitted step shows, so nothing visibly changes.
21
+ // An upload that fails BEFORE the ring closes still gets the review, whose
22
+ // Try Again is the recovery.
23
+
24
+ /** Advance exactly once, the first render on which `ready` holds. */
25
+ export function useSelfieAutoAdvance(opts: { enabled: boolean; ready: boolean; onAdvance: () => void }): void {
26
+ const advanced = useRef(false);
27
+ const { enabled, ready, onAdvance } = opts;
28
+ useEffect(() => {
29
+ if (!enabled || !ready || advanced.current) return;
30
+ advanced.current = true;
31
+ onAdvance();
32
+ }, [enabled, ready, onAdvance]);
33
+ }
34
+
35
+ export function LivenessHandover(): React.ReactElement {
36
+ const config = useKycConfig();
37
+ const copy = describeWaiting({
38
+ scope: configScope(config),
39
+ waitsForResult: waitsForResult(config),
40
+ override: biometricCopyFor(config).waiting,
41
+ });
42
+ return <SubmittedWaiting title={copy.title} description={copy.description} />;
43
+ }
@@ -23,11 +23,15 @@ import type { LivenessFailureReason } from '../../liveness/types';
23
23
 
24
24
  export function LivenessComplete({
25
25
  selfieUri,
26
+ soft = false,
26
27
  upload,
27
28
  onRetake,
28
29
  onContinue,
29
30
  }: {
30
- selfieUri: string;
31
+ /** Null on a restored session: the mediaId survived, the local file did not. */
32
+ selfieUri: string | null;
33
+ /** The still reads out of focus. A notice on the preview, never a gate. */
34
+ soft?: boolean;
31
35
  upload: SelfieUpload;
32
36
  onRetake: () => void;
33
37
  onContinue: () => void;
@@ -37,10 +41,10 @@ export function LivenessComplete({
37
41
  upload;
38
42
  return (
39
43
  <View>
40
- <SelfiePreview uri={selfieUri} uploading={uploading && !uploadError} />
44
+ <SelfiePreview uri={selfieUri} uploading={uploading && !uploadError} soft={soft} />
41
45
  {retryInfo && uploading ? (
42
46
  <MyazaText variant="bodySmall" color={colors.warning} style={{ textAlign: 'center', marginTop: spacing.sm }}>
43
- {`Upload failed — retrying (${retryInfo.attempt}/${retryInfo.total})…`}
47
+ {`Upload failed, retrying (${retryInfo.attempt}/${retryInfo.total})`}
44
48
  </MyazaText>
45
49
  ) : null}
46
50
  <View style={{ height: spacing.md }} />
@@ -51,7 +55,7 @@ export function LivenessComplete({
51
55
  // Disabled while the retry is in flight, not spinning — the progress
52
56
  // indicator belongs in the selfie frame, where the work is.
53
57
  disabled={uploading}
54
- onPress={() => void uploadSelfieAndVideo(selfieUri, videoPathRef.current)}
58
+ onPress={() => selfieUri && void uploadSelfieAndVideo(selfieUri, videoPathRef.current)}
55
59
  />
56
60
  ) : (
57
61
  <View style={{ flexDirection: 'row', gap: spacing.md }}>
@@ -11,15 +11,33 @@ import { StyleAbsFill } from './constants';
11
11
  // The captured selfie, shown back while it uploads.
12
12
  // ---------------------------------------------------------------------------
13
13
 
14
- export function SelfiePreview({ uri, uploading }: { uri: string; uploading?: boolean }): React.ReactElement {
14
+ export function SelfiePreview({
15
+ uri,
16
+ uploading,
17
+ soft = false,
18
+ }: {
19
+ uri: string | null;
20
+ uploading?: boolean;
21
+ /** The still reads out of focus (lib/selfie-sharpness). */
22
+ soft?: boolean;
23
+ }): React.ReactElement {
15
24
  const { colors } = useTheme();
16
25
  const S = 200;
17
26
  return (
18
27
  <View style={{ alignItems: 'center', justifyContent: 'center', alignSelf: 'stretch' }}>
19
- <View style={{ width: S, height: S, borderRadius: S / 2, borderWidth: 4, backgroundColor: '#111111', borderColor: `${colors.primary}33`, overflow: 'hidden' }}>
28
+ <View style={{ width: S, height: S, borderRadius: S / 2, borderWidth: 4, backgroundColor: uri ? '#111111' : `${colors.primary}14`, borderColor: `${colors.primary}33`, overflow: 'hidden', alignItems: 'center', justifyContent: 'center' }}>
20
29
  {/* borderRadius repeated on the Image — iOS doesn't reliably clip an Image
21
- child to a rounded parent (same fix as the header brand bar). */}
22
- <Image source={{ uri }} style={{ width: '100%', height: '100%', borderRadius: S / 2 }} resizeMode="cover" />
30
+ child to a rounded parent (same fix as the header brand bar). A null
31
+ uri is a restored session whose local file is gone: the uploaded
32
+ selfie is the durable record, so show a completed state rather than
33
+ a broken image. */}
34
+ {uri ? (
35
+ <Image source={{ uri }} style={{ width: '100%', height: '100%', borderRadius: S / 2 }} resizeMode="cover" />
36
+ ) : (
37
+ <MyazaText variant="bodySmall" color={colors.textSecondary} style={{ textAlign: 'center', paddingHorizontal: spacing.lg }}>
38
+ Selfie already captured
39
+ </MyazaText>
40
+ )}
23
41
  {/* Standard upload loader inside the preview circle (mirrors web/Flutter). */}
24
42
  {uploading ? (
25
43
  <View style={[StyleAbsFill, { backgroundColor: 'rgba(0,0,0,0.45)', alignItems: 'center', justifyContent: 'center' }]}>
@@ -28,11 +46,17 @@ export function SelfiePreview({ uri, uploading }: { uri: string; uploading?: boo
28
46
  ) : null}
29
47
  </View>
30
48
  <View style={{ height: spacing.md }} />
31
- <MyazaText variant="heading3" style={{ textAlign: 'center' }}>
32
- Looking good!
49
+ {/* The heading is the review's verdict on the photo, so a soft still must
50
+ not read "Looking good!". Continue stays available either way: the
51
+ floor is uncalibrated, and a wrong one should cost a sentence, never
52
+ a retake loop. */}
53
+ <MyazaText variant="heading3" color={soft ? colors.warning : undefined} style={{ textAlign: 'center' }}>
54
+ {soft ? 'This photo looks blurry' : 'Looking good!'}
33
55
  </MyazaText>
34
56
  <MyazaText variant="bodySmall" color={colors.textSecondary} style={{ textAlign: 'center' }}>
35
- Tap Continue to submit, or Retake to try again.
57
+ {soft
58
+ ? 'For the best chance of a match, retake it holding the phone steady until your face is sharp.'
59
+ : 'Tap Continue to submit, or Retake to try again.'}
36
60
  </MyazaText>
37
61
  </View>
38
62
  );