@myazahq/kyc-sdk-react-native 2.4.0 → 2.5.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 (117) hide show
  1. package/android/build.gradle +35 -3
  2. package/android/src/main/java/com/margelo/nitro/myazakyc/HybridMyazaFaceDetector.kt +75 -0
  3. package/ios/HybridMyazaFaceDetector.swift +8 -0
  4. package/nitrogen/generated/android/c++/JHybridMyazaFaceDetectorSpec.cpp +23 -0
  5. package/nitrogen/generated/android/c++/JHybridMyazaFaceDetectorSpec.hpp +2 -0
  6. package/nitrogen/generated/android/kotlin/com/margelo/nitro/myazakyc/HybridMyazaFaceDetectorSpec.kt +9 -0
  7. package/nitrogen/generated/ios/KycSdkReactNative-Swift-Cxx-Bridge.cpp +8 -0
  8. package/nitrogen/generated/ios/KycSdkReactNative-Swift-Cxx-Bridge.hpp +43 -0
  9. package/nitrogen/generated/ios/c++/HybridMyazaFaceDetectorSpecSwift.hpp +17 -0
  10. package/nitrogen/generated/ios/swift/Func_void_bool.swift +46 -0
  11. package/nitrogen/generated/ios/swift/HybridMyazaFaceDetectorSpec.swift +2 -0
  12. package/nitrogen/generated/ios/swift/HybridMyazaFaceDetectorSpec_cxx.swift +31 -0
  13. package/nitrogen/generated/shared/c++/HybridMyazaFaceDetectorSpec.cpp +2 -0
  14. package/nitrogen/generated/shared/c++/HybridMyazaFaceDetectorSpec.hpp +3 -0
  15. package/package.json +1 -1
  16. package/src/MyazaKYC.tsx +12 -0
  17. package/src/components/DashedBorder.tsx +55 -0
  18. package/src/components/DialCodePicker.tsx +14 -7
  19. package/src/components/Icon.tsx +9 -0
  20. package/src/components/MultiIdProgress.tsx +107 -0
  21. package/src/components/MyazaInput.tsx +23 -4
  22. package/src/components/MyazaSelect.tsx +2 -1
  23. package/src/components/OwnershipSlider.tsx +102 -0
  24. package/src/components/PhoneNumberInput.tsx +37 -4
  25. package/src/components/StaggerIn.tsx +63 -0
  26. package/src/components/StepView.tsx +26 -0
  27. package/src/components/glass/FloatingSheet.tsx +73 -21
  28. package/src/components/glass/GlassSheet.tsx +14 -2
  29. package/src/components/stepHeaderMeta.tsx +3 -1
  30. package/src/config/business.ts +6 -6
  31. package/src/config/businessDetailsValidity.ts +52 -0
  32. package/src/config/businessPrefill.ts +72 -0
  33. package/src/config/businessSteps.ts +14 -3
  34. package/src/config/keyPeople.ts +111 -7
  35. package/src/config/keyPeopleCorporate.ts +19 -0
  36. package/src/config/keyPeopleOwnerTypes.ts +12 -0
  37. package/src/config/keyPeopleOwners.ts +26 -0
  38. package/src/config/keyPeoplePrefill.ts +138 -0
  39. package/src/config/keyPeopleSectionDefs.ts +65 -0
  40. package/src/config/keyPeopleSections.ts +167 -0
  41. package/src/config/registrationHint.ts +1 -1
  42. package/src/config/stepOrder.ts +24 -4
  43. package/src/config/theme.ts +8 -2
  44. package/src/config/website.ts +36 -0
  45. package/src/config/workflowMerge.ts +5 -0
  46. package/src/emrtd/activeAuth.ts +100 -0
  47. package/src/emrtd/extras.ts +76 -0
  48. package/src/emrtd/files.ts +5 -0
  49. package/src/emrtd/index.ts +1 -0
  50. package/src/emrtd/read.ts +8 -1
  51. package/src/emrtd/session.ts +41 -0
  52. package/src/emrtd/stages.ts +11 -0
  53. package/src/lib/contact-recovery.ts +44 -0
  54. package/src/lib/multi-id.ts +210 -0
  55. package/src/lib/resubmit.ts +130 -0
  56. package/src/lib/screen-corners.ts +50 -0
  57. package/src/lib/step-log.ts +24 -3
  58. package/src/lib/use-multi-id-plan.ts +29 -0
  59. package/src/liveness/useModelReady.ts +65 -0
  60. package/src/liveness/visionCameraFaceDetector.ts +35 -0
  61. package/src/screens/ApplicantRoleStep.tsx +9 -5
  62. package/src/screens/BusinessCheckPanel.tsx +89 -0
  63. package/src/screens/BusinessDetailsFields.tsx +109 -0
  64. package/src/screens/BusinessDetailsStep.tsx +131 -145
  65. package/src/screens/BusinessDocumentSlot.tsx +2 -3
  66. package/src/screens/BusinessDocumentsStep.tsx +1 -1
  67. package/src/screens/BusinessKeyPeopleStep.tsx +103 -71
  68. package/src/screens/BusinessPickedCard.tsx +86 -0
  69. package/src/screens/BusinessPickedSection.tsx +51 -0
  70. package/src/screens/BusinessRegistryPickers.tsx +67 -0
  71. package/src/screens/BusinessSandboxToggle.tsx +114 -0
  72. package/src/screens/BusinessSearch.tsx +199 -0
  73. package/src/screens/BusinessSearchResults.tsx +115 -0
  74. package/src/screens/CompanyInfoFields.tsx +86 -51
  75. package/src/screens/ConsentStep.tsx +31 -0
  76. package/src/screens/ContactDestinationField.tsx +1 -0
  77. package/src/screens/ContactVerificationStep.tsx +34 -3
  78. package/src/screens/IdTypeStep.tsx +12 -5
  79. package/src/screens/KeepLinksSheet.tsx +88 -0
  80. package/src/screens/KeyPeopleAwaitCard.tsx +204 -0
  81. package/src/screens/KeyPeopleAwaitList.tsx +126 -0
  82. package/src/screens/KeyPeoplePending.tsx +154 -0
  83. package/src/screens/KeyPeopleSection.tsx +162 -0
  84. package/src/screens/KeyPeopleSectionsList.tsx +103 -0
  85. package/src/screens/KeyPeopleUboExemption.tsx +72 -0
  86. package/src/screens/KeyPersonCard.tsx +36 -4
  87. package/src/screens/KeyPersonForm.tsx +195 -36
  88. package/src/screens/KeyPersonKindToggle.tsx +68 -0
  89. package/src/screens/KeyPersonOwners.tsx +109 -0
  90. package/src/screens/KeyPersonRoleChips.tsx +68 -0
  91. package/src/screens/KeyPersonSheet.tsx +92 -108
  92. package/src/screens/LivenessStep.tsx +18 -0
  93. package/src/screens/NfcStep.tsx +14 -0
  94. package/src/screens/ProofOfAddressStep.tsx +6 -3
  95. package/src/screens/SubmittedStep.tsx +66 -10
  96. package/src/screens/nfc/NfcReadProgress.tsx +1 -0
  97. package/src/screens/useAwaitingPeople.ts +90 -0
  98. package/src/services/api-types.ts +158 -2
  99. package/src/services/api-verify-types.ts +53 -1
  100. package/src/services/api.ts +104 -0
  101. package/src/services/deviceMetadata.ts +1 -1
  102. package/src/services/errors.ts +10 -2
  103. package/src/services/workflowGate.ts +1 -0
  104. package/src/specs/MyazaFaceDetector.nitro.ts +29 -0
  105. package/src/store/businessCheck.ts +113 -0
  106. package/src/store/derive.ts +15 -0
  107. package/src/store/kycStore.ts +241 -14
  108. package/src/store/serverConfig.ts +2 -0
  109. package/src/store/session.ts +208 -0
  110. package/src/store/state.ts +155 -2
  111. package/src/store/submit.ts +63 -13
  112. package/src/store/submitApplicant.ts +5 -11
  113. package/src/types/business.ts +23 -2
  114. package/src/types/config.ts +22 -1
  115. package/src/types/verification.ts +1 -1
  116. package/src/types/workflow.ts +15 -0
  117. package/src/screens/KeyPeopleInviteLinks.tsx +0 -239
@@ -23,6 +23,8 @@ export type NfcReadStage =
23
23
  | 'readingSecurity'
24
24
  /** Reading DG2 — the chip's portrait. */
25
25
  | 'readingPhoto'
26
+ /** Reading the optional detail groups (DG7/DG11/DG12) — small and quick. */
27
+ | 'readingDetails'
26
28
  /** Everything that could be read has been. */
27
29
  | 'done';
28
30
 
@@ -33,6 +35,7 @@ export const NFC_STAGE_ORDER: NfcReadStage[] = [
33
35
  'readingData',
34
36
  'readingSecurity',
35
37
  'readingPhoto',
38
+ 'readingDetails',
36
39
  'done',
37
40
  ];
38
41
 
@@ -49,6 +52,8 @@ export function nfcStageLabel(stage: NfcReadStage): string {
49
52
  return 'Reading security data';
50
53
  case 'readingPhoto':
51
54
  return 'Reading photo';
55
+ case 'readingDetails':
56
+ return 'Reading extra details';
52
57
  case 'done':
53
58
  return 'Chip read complete';
54
59
  }
@@ -73,6 +78,8 @@ export function nfcStageDetail(stage: NfcReadStage): string {
73
78
  return 'Downloading the chip’s digital signature. This is the largest part and takes the longest — keep holding.';
74
79
  case 'readingPhoto':
75
80
  return 'Copying the photo stored on the chip.';
81
+ case 'readingDetails':
82
+ return 'Copying the optional details stored on the chip. These are small and quick.';
76
83
  case 'done':
77
84
  return 'Everything was read successfully.';
78
85
  }
@@ -98,6 +105,8 @@ export function nfcSheetMessage(stage: NfcReadStage): string {
98
105
  return 'Verifying security data — keep holding…';
99
106
  case 'readingPhoto':
100
107
  return 'Reading your photo — keep holding…';
108
+ case 'readingDetails':
109
+ return 'Almost done — keep holding…';
101
110
  case 'done':
102
111
  return 'Chip read complete';
103
112
  }
@@ -116,6 +125,8 @@ export function nfcStageProgress(stage: NfcReadStage): number {
116
125
  return 0.6;
117
126
  case 'readingPhoto':
118
127
  return 0.85;
128
+ case 'readingDetails':
129
+ return 0.95;
119
130
  case 'done':
120
131
  return 1;
121
132
  }
@@ -0,0 +1,44 @@
1
+ // Recovery from a submit refused over contact proofs. Mirrors the web SDK's
2
+ // steps/contact-recovery.ts — keep the two in lockstep.
3
+ //
4
+ // Contact proof tokens are SINGLE-USE and expire ~30 minutes after the OTP is
5
+ // checked, but they ride session progress and are restored on resume. A
6
+ // resumed attempt therefore carries a proof the server will (rightly)
7
+ // validate-and-drop at submit, while the contact step still shows "verified"
8
+ // — and a plain retry resubmits the same dead token forever.
9
+ //
10
+ // The 422 is recoverable in-flow: clear the stale proofs, walk the person
11
+ // back to the contact step (their data is untouched), and once re-verified
12
+ // the step routes straight back to 'submitted', which auto-submits with the
13
+ // fresh token.
14
+ import { KYCApiError } from '../services/api';
15
+ import type { KYCStep } from '../types/config';
16
+
17
+ export type ContactChannel = 'email' | 'phone';
18
+
19
+ /** The channels a 422 `contact_verification_required` names as missing. */
20
+ export function expiredContactChannels(err: unknown): ContactChannel[] {
21
+ if (!(err instanceof KYCApiError) || err.code !== 'contact_verification_required') return [];
22
+ const missing = Array.isArray(err.body?.missing) ? (err.body.missing as unknown[]) : [];
23
+ return missing.filter((c): c is ContactChannel => c === 'email' || c === 'phone');
24
+ }
25
+
26
+ export function contactStepFor(channel: ContactChannel): KYCStep {
27
+ return channel === 'email' ? 'email-verification' : 'phone-verification';
28
+ }
29
+
30
+ /**
31
+ * Where the step routes once this channel is verified. Normally the ordinary
32
+ * forward step; in recovery it returns to 'submitted' (which auto-submits
33
+ * with the fresh proof) via any OTHER still-refused channel first — the
34
+ * person never re-walks steps they already completed.
35
+ */
36
+ export function stepAfterContactVerified(opts: {
37
+ recovery: boolean;
38
+ expired: ContactChannel[];
39
+ channel: ContactChannel;
40
+ }): KYCStep | null {
41
+ if (!opts.recovery) return null;
42
+ const remaining = opts.expired.find((c) => c !== opts.channel);
43
+ return remaining ? contactStepFor(remaining) : 'submitted';
44
+ }
@@ -0,0 +1,210 @@
1
+ // Multi-ID flows on React Native. A PORT of the web SDK's lib/multi-id.ts,
2
+ // kept byte-for-byte in its logic: the server validates the pick sequence the
3
+ // client produced, so a client that computes options differently produces
4
+ // submissions the server rejects. If you change a rule here, change it in
5
+ // kyc-sdk-react/src/lib/multi-id.ts, kyc-core/src/lib/multi-id.ts and the
6
+ // dashboard's required-ids-model.ts in the same commit.
7
+ //
8
+ // Multi-ID flows: a workflow's `multiId` block asks for SEVERAL ID checks in
9
+ // one run — the applicant picks each slot's ID from what the admin allowed for
10
+ // THEIR country (a picked ID disappears from later slots), ONE selfie covers
11
+ // the whole run, and everything submits as ONE verification the server judges
12
+ // by the pass policy.
13
+ //
14
+ // Multi-region works: the country-select step comes first as usual, and the
15
+ // run then walks THAT country's slots (`countries[].multiIdSlots`). The pure
16
+ // helpers here MIRROR the server's lib/multi-id.ts exactly — the safe-options
17
+ // rule is what keeps an applicant from ever being offered a pick that would
18
+ // strand a later slot, and the server validates the same sequence.
19
+
20
+ export interface MultiIdConfig {
21
+ count: number;
22
+ minPassed: number;
23
+ }
24
+
25
+ export interface MultiIdCountry {
26
+ country: string;
27
+ idTypes?: string[];
28
+ /** This country's per-verification ID allowlists. */
29
+ multiIdSlots?: Array<{ idTypes?: string[] }>;
30
+ }
31
+
32
+ interface MultiIdSourceConfig {
33
+ multiId?: MultiIdConfig;
34
+ subjectType?: string;
35
+ /** The EFFECTIVE country (post country-select) — whose slots the run walks. */
36
+ country?: string;
37
+ countries?: MultiIdCountry[];
38
+ idTypes?: string[];
39
+ }
40
+
41
+ /** The workflow's multiId POLICY — null for ordinary flows and every KYB flow. */
42
+ export function multiIdConfigFrom(config: MultiIdSourceConfig): MultiIdConfig | null {
43
+ if (config.subjectType === 'business') return null;
44
+ const block = config.multiId;
45
+ if (!block || typeof block.count !== 'number') return null;
46
+ const count = Math.trunc(block.count);
47
+ // REJECT an out-of-range count rather than clamping it. The server returns
48
+ // null here, so clamping made the client walk 3 checks for a config the
49
+ // server does not consider multi-ID at all — and clamping `minPassed`
50
+ // against the RAW count could ask for 9 of 3 to pass, which nothing can
51
+ // satisfy. Same rule, both sides, or the client builds submissions the
52
+ // server rejects.
53
+ if (!Number.isInteger(count) || count < 2 || count > 3) return null;
54
+ return {
55
+ count,
56
+ minPassed:
57
+ typeof block.minPassed === 'number' && Number.isInteger(block.minPassed)
58
+ ? Math.min(Math.max(block.minPassed, 1), count)
59
+ : count,
60
+ };
61
+ }
62
+
63
+ /** The country entry the run walks: the EFFECTIVE country's, else the legacy
64
+ * single-country shape. */
65
+ export function multiIdCountryEntry(config: MultiIdSourceConfig): MultiIdCountry | null {
66
+ const country = config.country;
67
+ if (config.countries && config.countries.length > 0) {
68
+ return config.countries.find((c) => c.country === country) ?? null;
69
+ }
70
+ return country ? { country, idTypes: config.idTypes } : null;
71
+ }
72
+
73
+ /**
74
+ * The ID types the picked country offers: its pinned list, else everything the
75
+ * server granted there.
76
+ */
77
+ export function multiIdOfferedTypes(
78
+ config: MultiIdSourceConfig,
79
+ serverIdTypes: Array<{ country: string; idType: string }>,
80
+ ): string[] {
81
+ const entry = multiIdCountryEntry(config);
82
+ if (!entry) return [];
83
+ if (entry.idTypes && entry.idTypes.length > 0) return entry.idTypes;
84
+ return serverIdTypes.filter((row) => row.country === entry.country).map((row) => row.idType);
85
+ }
86
+
87
+ /** Per-slot option lists (a pinned slot keeps its list; others offer everything). */
88
+ export function multiIdSlotOptions(
89
+ count: number,
90
+ slots: Array<{ idTypes?: string[] }> | undefined,
91
+ offered: string[],
92
+ ): string[][] {
93
+ const offeredSet = new Set(offered);
94
+ return Array.from({ length: count }, (_, i) => {
95
+ const pinned = slots?.[i]?.idTypes;
96
+ const base = pinned && pinned.length > 0 ? pinned : offered;
97
+ return base.filter((t) => offeredSet.has(t));
98
+ });
99
+ }
100
+
101
+ /** The first reachable dead-end across pick orders, or null. */
102
+ export function multiIdFirstDeadEnd(
103
+ slotOptions: string[][],
104
+ ): { picks: string[]; slotIndex: number } | null {
105
+ const walk = (index: number, picked: string[]): { picks: string[]; slotIndex: number } | null => {
106
+ if (index >= slotOptions.length) return null;
107
+ const available = slotOptions[index]!.filter((t) => !picked.includes(t));
108
+ if (available.length === 0) return { picks: picked, slotIndex: index };
109
+ for (const pick of available) {
110
+ const deadEnd = walk(index + 1, [...picked, pick]);
111
+ if (deadEnd) return deadEnd;
112
+ }
113
+ return null;
114
+ };
115
+ return walk(0, []);
116
+ }
117
+
118
+ /** The picks a slot may SAFELY offer: unused AND non-stranding. */
119
+ export function multiIdSafeOptions(
120
+ slotOptions: string[][],
121
+ slotIndex: number,
122
+ picked: string[],
123
+ ): string[] {
124
+ const remaining = slotOptions.slice(slotIndex + 1);
125
+ return (slotOptions[slotIndex] ?? [])
126
+ .filter((t) => !picked.includes(t))
127
+ .filter(
128
+ (t) =>
129
+ multiIdFirstDeadEnd(
130
+ remaining.map((opts) => opts.filter((o) => o !== t && !picked.includes(o))),
131
+ ) === null,
132
+ );
133
+ }
134
+
135
+ export interface MultiIdPlan {
136
+ count: number;
137
+ minPassed: number;
138
+ /** Which slot is being walked (clamped; equals count once every slot committed). */
139
+ index: number;
140
+ /** The current slot is the final one. */
141
+ last: boolean;
142
+ /** ID types committed so far, in order. */
143
+ picked: string[];
144
+ /** What the CURRENT slot's picker may offer. */
145
+ safeOptions: string[];
146
+ }
147
+
148
+ /** The active plan, or null for ordinary flows. */
149
+ export function multiIdPlan(
150
+ config: MultiIdSourceConfig,
151
+ state: { multiIdSlotIndex: number; multiIdSlots: Array<{ idType: string }> },
152
+ serverIdTypes: Array<{ country: string; idType: string }>,
153
+ ): MultiIdPlan | null {
154
+ const cfg = multiIdConfigFrom(config);
155
+ if (!cfg) return null;
156
+ const offered = multiIdOfferedTypes(config, serverIdTypes);
157
+ const options = multiIdSlotOptions(cfg.count, multiIdCountryEntry(config)?.multiIdSlots, offered);
158
+ const picked = state.multiIdSlots.map((s) => s.idType);
159
+ const index = Math.min(Math.max(state.multiIdSlotIndex, 0), cfg.count);
160
+ return {
161
+ count: cfg.count,
162
+ minPassed: cfg.minPassed,
163
+ index,
164
+ last: index >= cfg.count - 1,
165
+ picked,
166
+ safeOptions: index < cfg.count ? multiIdSafeOptions(options, index, picked) : [],
167
+ };
168
+ }
169
+
170
+ /** Which evidence step one slot's ID opens on. */
171
+ export function multiIdEvidenceStep(
172
+ def: { requiresDocumentCapture: boolean } | null | undefined,
173
+ ): 'id-input' | 'document-capture' {
174
+ return def && def.requiresDocumentCapture === false ? 'id-input' : 'document-capture';
175
+ }
176
+
177
+ /**
178
+ * The committed slots as the WIRE sees them — an explicit whitelist, because
179
+ * the slots also carry local preview images for the back journey and those must
180
+ * never reach the submission or the session-progress blob. Whitelist, not
181
+ * spread: a field added to the slot later cannot leak by default.
182
+ */
183
+ export function multiIdWireSlots(
184
+ slots: Array<{
185
+ idType: string;
186
+ idNumber?: string;
187
+ documentFront?: string;
188
+ documentBack?: string;
189
+ documentFrontVideo?: string;
190
+ documentBackVideo?: string;
191
+ }>,
192
+ ): Array<{
193
+ idType: string;
194
+ idNumber?: string;
195
+ documentFront?: string;
196
+ documentBack?: string;
197
+ documentFrontVideo?: string;
198
+ documentBackVideo?: string;
199
+ }> {
200
+ // The chip is attached by the caller after this whitelist (it is a payload,
201
+ // not a mediaId), so it is absent here and not stripped.
202
+ return slots.map((s) => ({
203
+ idType: s.idType,
204
+ ...(s.idNumber ? { idNumber: s.idNumber } : {}),
205
+ ...(s.documentFront ? { documentFront: s.documentFront } : {}),
206
+ ...(s.documentBack ? { documentBack: s.documentBack } : {}),
207
+ ...(s.documentFrontVideo ? { documentFrontVideo: s.documentFrontVideo } : {}),
208
+ ...(s.documentBackVideo ? { documentBackVideo: s.documentBackVideo } : {}),
209
+ }));
210
+ }
@@ -0,0 +1,130 @@
1
+ // Narrowing a flow to the steps a reviewer asked the applicant to redo.
2
+ //
3
+ // A reviewer looked at a finished verification and sent it back — usually
4
+ // because one thing was unreadable, not because everything was wrong. Making
5
+ // somebody retake a passport photo is reasonable; making them redo consent, the
6
+ // ID picker, liveness and a questionnaire to fix that photo is how you lose them.
7
+ //
8
+ // The instruction rides the session's config snapshot (`config.resubmit`), which
9
+ // is how every other per-session flow instruction travels. An SDK that predates
10
+ // this simply does not read the key and runs the whole flow, which is the safe
11
+ // degradation: asking for too much is recoverable, silently skipping a step the
12
+ // reviewer wanted is not.
13
+ import type { KYCStep } from '../types/config';
14
+
15
+ export interface ResubmitConfig {
16
+ /** Steps to redo, in flow order. Never empty — the server omits the key instead. */
17
+ steps: string[];
18
+ /** Reviewer's note to the applicant. */
19
+ message?: string | null;
20
+ }
21
+
22
+ /**
23
+ * Steps that are always kept, whatever the reviewer ticked.
24
+ *
25
+ * `consent` because a flow with no first screen is disorienting, and it is where
26
+ * the redo is explained. `submitted` because a flow has to end somewhere.
27
+ * Neither is something the applicant is being asked to redo — they are the
28
+ * frame around what is.
29
+ */
30
+ const ALWAYS: KYCStep[] = ['consent', 'submitted'];
31
+
32
+ /**
33
+ * One ID's evidence — a FAMILY, not alternatives a reviewer picks between.
34
+ *
35
+ * Which member a flow contains depends on the ID type: a number-only ID has
36
+ * `id-input`, a document ID has `document-capture` (and maybe `nfc`). At the
37
+ * moment the order is built nobody has chosen one yet, so the flow is shaped by
38
+ * a default. A plan naming `id-input` against an order still shaped for
39
+ * `document-capture` therefore matched NOTHING, fell through to the safety net
40
+ * below, and silently ran the entire flow — which is exactly the bug a reviewer
41
+ * sees as "I asked for the ID and it made them do everything again".
42
+ *
43
+ * Asking for one member asks for whichever this flow turns out to have.
44
+ */
45
+ const EVIDENCE: KYCStep[] = ['id-input', 'document-capture', 'nfc'];
46
+
47
+ /**
48
+ * Steps a narrowed flow keeps regardless, because without them it cannot
49
+ * produce a submission at all.
50
+ *
51
+ * A resubmission is a NEW verification on a FRESH session: nothing is carried
52
+ * forward from the one being redone, so the applicant must still say which ID
53
+ * this is and supply it. `POST /verify` requires an `idType`, and a number-only
54
+ * ID requires the number with it.
55
+ *
56
+ * So narrowing removes the things arranged AROUND the identity — liveness,
57
+ * proof of address, the questionnaire, contact checks — and never the identity
58
+ * itself. The alternative is a two-screen flow that collects a photo and then
59
+ * fails to submit, which is worse for the applicant than being asked for one
60
+ * extra screen.
61
+ */
62
+ const INDIVIDUAL_REQUIRED: KYCStep[] = ['id-type', ...EVIDENCE];
63
+ const BUSINESS_REQUIRED: KYCStep[] = ['business-details'];
64
+
65
+ /**
66
+ * Narrow a full step order to the redo, preserving flow order.
67
+ *
68
+ * Order comes from `order`, never from the reviewer's list: they ticked
69
+ * checkboxes, and walking somebody through liveness before document capture
70
+ * because that is the order the boxes were ticked in would be nonsense.
71
+ *
72
+ * Returns the ORIGINAL order untouched when the instruction is absent, empty, or
73
+ * matches nothing we know. That last case matters: a server that learns a new
74
+ * step name before this SDK does must not produce a two-screen flow that
75
+ * collects nothing.
76
+ */
77
+ export function applyResubmitSteps(
78
+ order: KYCStep[],
79
+ resubmit: ResubmitConfig | undefined | null,
80
+ ): KYCStep[] {
81
+ const asked = resubmit?.steps;
82
+ if (!asked?.length) return order;
83
+
84
+ const wantsEvidence = asked.some((step) => EVIDENCE.includes(step as KYCStep));
85
+
86
+ // Does the instruction name anything this flow actually has? If not, it came
87
+ // from a server that knows a step name this SDK does not — and narrowing on it
88
+ // would quietly drop whatever was really asked for. Checked BEFORE the
89
+ // required steps are added, or those alone would make every unknown plan look
90
+ // recognised and turn the safety net below into dead code.
91
+ const recognised = wantsEvidence || asked.some((step) => order.includes(step as KYCStep));
92
+ if (!recognised) return order;
93
+
94
+ const wanted = new Set<string>(asked);
95
+ if (wantsEvidence) for (const step of EVIDENCE) wanted.add(step);
96
+ for (const step of order.includes('business-details') ? BUSINESS_REQUIRED : INDIVIDUAL_REQUIRED) {
97
+ wanted.add(step);
98
+ }
99
+
100
+ const narrowed = order.filter((step) => wanted.has(step) || ALWAYS.includes(step));
101
+
102
+ // Nothing but the frame survived, so the instruction named steps this flow
103
+ // does not contain. Run everything rather than nothing.
104
+ const collects = narrowed.some((step) => !ALWAYS.includes(step));
105
+ return collects ? narrowed : order;
106
+ }
107
+
108
+ /** Whether this mount is a targeted redo, for the wording on screen. */
109
+ export function isResubmission(resubmit: ResubmitConfig | undefined | null): boolean {
110
+ return Boolean(resubmit?.steps?.length);
111
+ }
112
+
113
+ /**
114
+ * The reviewer's note to the applicant, when this mount is a targeted redo.
115
+ *
116
+ * The dashboard's send-back dialog asks a reviewer to explain the problem — its
117
+ * placeholder is literally "Your document photo was too dark to read, please
118
+ * retake it in good light." The server stamps that note onto the session and
119
+ * every SDK parses it into config, and until now NONE of them displayed it. So
120
+ * a send-back reached the applicant as a flow that had silently lost most of
121
+ * its steps, with nothing saying why they were back or what to do differently.
122
+ *
123
+ * Returns null when there is nothing to show, so a caller can render this
124
+ * unconditionally.
125
+ */
126
+ export function resubmitNote(resubmit: ResubmitConfig | undefined | null): string | null {
127
+ if (!isResubmission(resubmit)) return null;
128
+ const note = resubmit?.message?.trim();
129
+ return note && note.length > 0 ? note : null;
130
+ }
@@ -0,0 +1,50 @@
1
+ import { Dimensions, Platform } from 'react-native';
2
+ import { initialWindowMetrics } from 'react-native-safe-area-context';
3
+
4
+ // Apple's grammar for nested corners is CONCENTRIC: an inset card's radius is
5
+ // the DISPLAY's corner radius minus the gap between card and screen edge —
6
+ // that is why the system share sheet's curve runs parallel to the phone's.
7
+ // No public API exposes the display radius, so iOS resolves it from the
8
+ // model's logical screen size (the per-device values Apple ships, from
9
+ // UIScreen's private _displayCornerRadius, in points). Unknown hardware falls
10
+ // back on whether the display is rounded at all: home-indicator devices are,
11
+ // square-corner devices are not.
12
+
13
+ /** `short x long` logical points → display corner radius in points. */
14
+ const IOS_RADII: Record<string, number> = {
15
+ // X / XS / 11 Pro are 39 on the same canvas as the 12–13 mini's 44; the
16
+ // minis are the ones still in pockets, so the shared key takes their value.
17
+ '375x812': 44,
18
+ '414x896': 41.5, // XR / 11 / XS Max / 11 Pro Max
19
+ '390x844': 47.33, // 12 / 12 Pro / 13 / 13 Pro / 14
20
+ '428x926': 53.33, // 12–13 Pro Max / 14 Plus
21
+ '393x852': 55, // 14 Pro / 15 / 15 Pro / 16 / 16e
22
+ '430x932': 55, // 14 Pro Max / 15 Plus / 15 Pro Max / 16 Plus
23
+ '402x874': 62, // 16 Pro
24
+ '440x956': 62, // 16 Pro Max
25
+ };
26
+
27
+ /**
28
+ * The display's own corner radius, in points/dp. 0 means a square-cornered
29
+ * screen (or one we cannot judge), and the caller picks its flat-world look.
30
+ */
31
+ export function displayCornerRadius(): number {
32
+ const { width, height } = Dimensions.get('screen');
33
+ const key = `${Math.round(Math.min(width, height))}x${Math.round(Math.max(width, height))}`;
34
+ const bottomInset = initialWindowMetrics?.insets?.bottom ?? 0;
35
+
36
+ if (Platform.OS === 'ios') {
37
+ const known = IOS_RADII[key];
38
+ if (known != null) return known;
39
+ // Unlisted model: a home indicator means a rounded display; 47 is the
40
+ // middle of the modern range.
41
+ return bottomInset > 0 ? 47 : 0;
42
+ }
43
+
44
+ // Android has no cheap signal (the RoundedCorner API needs native code).
45
+ // A small bottom inset is gesture navigation — overwhelmingly the modern,
46
+ // rounded-display phones — while ~48dp is the opaque 3-button bar, which
47
+ // says nothing. Modest on purpose: an under-estimate still looks composed,
48
+ // an over-estimate looks like a mistake.
49
+ return bottomInset > 0 && bottomInset <= 32 ? 32 : 0;
50
+ }
@@ -11,6 +11,20 @@
11
11
  export interface StepLogEntry {
12
12
  step: string;
13
13
  at: string;
14
+ /**
15
+ * Which multi-ID check the applicant was on (1-based). Absent on ordinary
16
+ * runs and on the first check.
17
+ *
18
+ * The SERVER cannot tell a slot advance from a back-press: a multi-ID run
19
+ * legitimately returns to the ID picker for its next ID, and by step name
20
+ * that is identical to pressing Back. Only the client knows a slot was
21
+ * committed, so only the client can say.
22
+ */
23
+ slot?: number;
24
+ /** The ID type selected AT THAT MOMENT — a slot's final pick is not what the
25
+ * applicant was doing earlier in it (pick one ID, go back, settle on
26
+ * another, and the earlier step would be named for an ID not yet chosen). */
27
+ idType?: string;
14
28
  }
15
29
 
16
30
  export interface StepLog {
@@ -29,10 +43,17 @@ export function resetStepLog(): void {
29
43
 
30
44
  /** Records a step visit. Consecutive duplicates are collapsed; back-and-forth
31
45
  * navigation is kept — repeat visits are honest journey data. */
32
- export function recordStep(step: string): void {
46
+ export function recordStep(step: string, slot?: number, idType?: string): void {
33
47
  if (entries.length >= MAX_ENTRIES) return;
34
- if (entries[entries.length - 1]?.step === step) return;
35
- entries.push({ step, at: new Date().toISOString() });
48
+ const last = entries[entries.length - 1];
49
+ // A revisit on a NEW check, or on a DIFFERENT ID, is a different visit.
50
+ if (last?.step === step && last.slot === slot && last.idType === idType) return;
51
+ entries.push({
52
+ step,
53
+ at: new Date().toISOString(),
54
+ ...(slot ? { slot } : {}),
55
+ ...(idType ? { idType } : {}),
56
+ });
36
57
  }
37
58
 
38
59
  /** Snapshot attached to the verify submission. Null when nothing was recorded
@@ -0,0 +1,29 @@
1
+ import { useMemo } from 'react';
2
+
3
+ import { multiIdPlan, type MultiIdPlan } from './multi-id';
4
+ import { useEffectiveCountry, useKyc, useKycConfig } from '../components/runtime';
5
+
6
+ /**
7
+ * The active multi-ID plan for the current state, or null on an ordinary run.
8
+ *
9
+ * One hook so the picker and the position strip cannot disagree about which
10
+ * check the applicant is on or what it may offer — the server validates the
11
+ * pick sequence, so two answers here means submissions it rejects.
12
+ */
13
+ export function useMultiIdPlan(): MultiIdPlan | null {
14
+ const config = useKycConfig();
15
+ const country = useEffectiveCountry();
16
+ const multiIdSlotIndex = useKyc((s) => s.multiIdSlotIndex);
17
+ const multiIdSlots = useKyc((s) => s.multiIdSlots);
18
+ const serverConfig = useKyc((s) => s.serverConfig);
19
+
20
+ return useMemo(
21
+ () =>
22
+ multiIdPlan(
23
+ { ...config, country },
24
+ { multiIdSlotIndex, multiIdSlots },
25
+ serverConfig.status === 'ready' ? serverConfig.idTypes : [],
26
+ ),
27
+ [config, country, multiIdSlotIndex, multiIdSlots, serverConfig],
28
+ );
29
+ }
@@ -0,0 +1,65 @@
1
+ import { useEffect, useRef, useState } from 'react';
2
+
3
+ import { isFaceModelReady, primeFaceModel } from './visionCameraFaceDetector';
4
+
5
+ // ─── Face-model readiness gate ────────────────────────────────────────────────
6
+ //
7
+ // Android fetches ML Kit's face model through Play Services rather than bundling
8
+ // it, which keeps ~8 MB per device out of the APK but leaves a window where
9
+ // detection cannot run: first launch before the download lands, or a device with
10
+ // no Google Play Services at all.
11
+ //
12
+ // This has to be answered BEFORE the camera opens. `detectFace` reports through
13
+ // `FaceResult`, where a missing model and an empty frame are both
14
+ // `faceCount: 0` — so gating on the per-frame result would strand the user on
15
+ // "position your face" indefinitely, with the SDK unable to say why. That is the
16
+ // single failure mode this whole gate exists to prevent.
17
+ //
18
+ // iOS is always ready (Apple Vision is a system framework), so this resolves on
19
+ // the first tick there and costs nothing.
20
+
21
+ export type ModelReadyState = 'ready' | 'preparing' | 'unavailable';
22
+
23
+ /** How long to wait for the model before calling it unavailable. */
24
+ const MODEL_WAIT_MS = 20_000;
25
+ /** Gap between readiness polls while the download is in flight. */
26
+ const POLL_INTERVAL_MS = 500;
27
+
28
+ /**
29
+ * Tracks whether on-device face detection can run.
30
+ *
31
+ * Returns `'preparing'` while Play Services fetches the model, `'ready'` once it
32
+ * can run, and `'unavailable'` when it could not be obtained within
33
+ * {@link MODEL_WAIT_MS} — no Play Services, no network, or a declined install.
34
+ *
35
+ * The flow primes the download at open (see `MyazaKYC.tsx`), so by the time the
36
+ * user reaches liveness this is normally already `'ready'` and no waiting screen
37
+ * is ever shown.
38
+ */
39
+ export function useFaceModelReady(): ModelReadyState {
40
+ const [state, setState] = useState<ModelReadyState>(() =>
41
+ isFaceModelReady() ? 'ready' : 'preparing',
42
+ );
43
+ const startedAt = useRef(Date.now());
44
+
45
+ useEffect(() => {
46
+ if (state !== 'preparing') return;
47
+
48
+ // Re-prime rather than assume the open-time call ran: the step can be
49
+ // reached directly in a resumed flow, and prepareModel() is a no-op once
50
+ // the model is present.
51
+ primeFaceModel();
52
+
53
+ const id = setInterval(() => {
54
+ if (isFaceModelReady()) {
55
+ setState('ready');
56
+ } else if (Date.now() - startedAt.current > MODEL_WAIT_MS) {
57
+ setState('unavailable');
58
+ }
59
+ }, POLL_INTERVAL_MS);
60
+
61
+ return () => clearInterval(id);
62
+ }, [state]);
63
+
64
+ return state;
65
+ }
@@ -45,6 +45,41 @@ const boxedDetector: BoxedHybridObject<MyazaFaceDetector> | null = detector
45
45
  ? NitroModules.box(detector)
46
46
  : null;
47
47
 
48
+ /**
49
+ * Start fetching the face model, so it is warm by the time liveness runs.
50
+ *
51
+ * Android fetches ML Kit's models through Play Services rather than bundling
52
+ * them, which keeps ~18.5 MB per device out of the APK but leaves a window on
53
+ * first launch where detection cannot work. Priming at flow start puts that
54
+ * download behind the consent and ID-type screens instead of in front of the
55
+ * camera.
56
+ *
57
+ * Best-effort and non-blocking, exactly like the web SDK's `primeFaceMesh()`:
58
+ * a failure here is not fatal, because {@link isFaceModelReady} is what the
59
+ * liveness step actually gates on. iOS resolves immediately (Apple Vision is a
60
+ * system framework).
61
+ */
62
+ export function primeFaceModel(): void {
63
+ detector?.prepareModel().catch(() => {
64
+ /* best-effort: isFaceModelReady() is the real gate */
65
+ });
66
+ }
67
+
68
+ /**
69
+ * Whether face detection can run right now.
70
+ *
71
+ * Checked BEFORE the camera opens. `detectFace` cannot answer this — a missing
72
+ * model and an empty frame are both `faceCount: 0`, so gating on the per-frame
73
+ * result would strand the user on "position your face" with no explanation.
74
+ *
75
+ * `true` when the native module is absent entirely (Expo Go), because that path
76
+ * already degrades to the existing camera-unavailable screen and should not be
77
+ * reported as a model problem.
78
+ */
79
+ export function isFaceModelReady(): boolean {
80
+ return detector?.isModelReady() ?? true;
81
+ }
82
+
48
83
  /**
49
84
  * Maps the native {@link FaceResult} to a {@link LivenessFaceData}, or `null`
50
85
  * when no face is present (`faceCount === 0`). A worklet — runs on the camera