@myazahq/kyc-sdk-react-native 2.5.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 (167) hide show
  1. package/README.md +151 -5
  2. package/android/src/main/java/com/margelo/nitro/myazakyc/HybridMyazaFaceDetector.kt +6 -4
  3. package/app.plugin.js +89 -8
  4. package/ios/HybridMyazaFaceDetector.swift +8 -3
  5. package/package.json +14 -2
  6. package/src/MyazaBiometricAuth.tsx +119 -0
  7. package/src/components/CountryField.tsx +8 -0
  8. package/src/components/CountryRegionPicker.tsx +184 -0
  9. package/src/components/DialCodePicker.tsx +47 -68
  10. package/src/components/DialCodeRow.tsx +102 -0
  11. package/src/components/FramedMapPicker.tsx +122 -0
  12. package/src/components/GeoBadge.tsx +34 -0
  13. package/src/components/Icon.tsx +2 -185
  14. package/src/components/KycFlow.tsx +60 -32
  15. package/src/components/LineSkeleton.tsx +100 -0
  16. package/src/components/MapChrome.tsx +67 -0
  17. package/src/components/MapPinMarker.tsx +54 -0
  18. package/src/components/MapPinPicker.tsx +179 -0
  19. package/src/components/MilestoneTrack.tsx +155 -0
  20. package/src/components/MyazaInput.tsx +17 -1
  21. package/src/components/OptionRow.tsx +22 -3
  22. package/src/components/PhoneNumberInput.tsx +8 -0
  23. package/src/components/PoweredBy.tsx +5 -5
  24. package/src/components/PresenceBlocks.tsx +153 -0
  25. package/src/components/StepView.tsx +17 -0
  26. package/src/components/StickyActions.tsx +56 -0
  27. package/src/components/dialCodeRows.ts +64 -0
  28. package/src/components/icon-map.ts +176 -0
  29. package/src/components/stepHeaderMeta.tsx +42 -4
  30. package/src/config/addressCollection.ts +125 -0
  31. package/src/config/biometricOptions.ts +100 -0
  32. package/src/config/business.ts +20 -1
  33. package/src/config/businessSteps.ts +8 -2
  34. package/src/config/consentStep.ts +19 -0
  35. package/src/config/proofOfAddress.ts +54 -11
  36. package/src/config/regions.ts +24 -0
  37. package/src/config/stepOrder.ts +79 -5
  38. package/src/config/uploadLimits.ts +35 -0
  39. package/src/config/workflowMerge.ts +11 -5
  40. package/src/index.ts +54 -1
  41. package/src/lib/address-current-location.ts +171 -0
  42. package/src/lib/address-field-modes.ts +119 -0
  43. package/src/lib/address-flow.ts +172 -0
  44. package/src/lib/address-helpers.ts +48 -0
  45. package/src/lib/address-line.ts +76 -0
  46. package/src/lib/address-pin-move.ts +97 -0
  47. package/src/lib/address-step-recovery.ts +63 -0
  48. package/src/lib/authed-image.ts +78 -0
  49. package/src/lib/biometric-auth.ts +52 -0
  50. package/src/lib/biometric-copy.ts +51 -0
  51. package/src/lib/captureRing.ts +83 -0
  52. package/src/lib/country-adoption.ts +89 -0
  53. package/src/lib/inferred-country.ts +65 -0
  54. package/src/lib/livenessLayout.ts +49 -0
  55. package/src/lib/map-frame.ts +159 -0
  56. package/src/lib/map-tiles.ts +143 -0
  57. package/src/lib/poa-country-gate.ts +27 -0
  58. package/src/lib/result-copy.ts +116 -0
  59. package/src/lib/result-wait.ts +53 -0
  60. package/src/lib/review-map-surface.ts +26 -0
  61. package/src/lib/scope.ts +31 -0
  62. package/src/lib/selfie-upload-wait.ts +79 -0
  63. package/src/lib/street-view-fov.ts +42 -0
  64. package/src/lib/webview-available.ts +47 -0
  65. package/src/liveness/useLiveness.ts +6 -1
  66. package/src/presence/background-math.ts +100 -0
  67. package/src/presence/background-store.ts +82 -0
  68. package/src/presence/background.ts +165 -0
  69. package/src/presence/foreground-service.ts +193 -0
  70. package/src/presence/fs.ts +51 -0
  71. package/src/presence/geofence.ts +41 -0
  72. package/src/presence/math.ts +44 -0
  73. package/src/presence/post.ts +40 -0
  74. package/src/presence/report.ts +87 -0
  75. package/src/presence/sampler.ts +110 -0
  76. package/src/presence/status.ts +92 -0
  77. package/src/presence/store.ts +94 -0
  78. package/src/presence/tier.ts +36 -0
  79. package/src/presence/watch-wait.ts +81 -0
  80. package/src/screens/AddressCountryControl.tsx +115 -0
  81. package/src/screens/BusinessDetailsFields.tsx +3 -0
  82. package/src/screens/BusinessDetailsStep.tsx +2 -0
  83. package/src/screens/BusinessDocumentSlot.tsx +3 -2
  84. package/src/screens/BusinessDocumentsStep.tsx +4 -55
  85. package/src/screens/CompanyInfoFields.tsx +6 -1
  86. package/src/screens/ConsentStep.tsx +6 -17
  87. package/src/screens/ContactActions.tsx +54 -0
  88. package/src/screens/ContactDestinationField.tsx +4 -0
  89. package/src/screens/ContactEntryPanel.tsx +75 -0
  90. package/src/screens/ContactVerificationStep.tsx +26 -56
  91. package/src/screens/CountrySelectStep.tsx +13 -147
  92. package/src/screens/LivenessAvatar.tsx +15 -3
  93. package/src/screens/LivenessStep.tsx +154 -52
  94. package/src/screens/PoaDocumentTypeList.tsx +66 -0
  95. package/src/screens/ProofOfAddressParts.tsx +151 -0
  96. package/src/screens/ProofOfAddressStep.tsx +58 -99
  97. package/src/screens/SubmittedBadge.tsx +25 -0
  98. package/src/screens/SubmittedError.tsx +64 -0
  99. package/src/screens/SubmittedResult.tsx +117 -0
  100. package/src/screens/SubmittedStep.tsx +71 -164
  101. package/src/screens/SubmittedSuccess.tsx +127 -0
  102. package/src/screens/SubmittedWaiting.tsx +45 -0
  103. package/src/screens/address/AddressEntranceStep.tsx +161 -0
  104. package/src/screens/address/AddressIntroGate.tsx +148 -0
  105. package/src/screens/address/AddressMapStub.tsx +50 -0
  106. package/src/screens/address/AddressPinStep.tsx +178 -0
  107. package/src/screens/address/AddressReviewStep.tsx +162 -0
  108. package/src/screens/address/AddressSandboxTabs.tsx +158 -0
  109. package/src/screens/address/AddressSearchField.tsx +115 -0
  110. package/src/screens/address/AddressSearchStep.tsx +94 -0
  111. package/src/screens/address/CurrentLocationRow.tsx +135 -0
  112. package/src/screens/address/DetailsSheet.tsx +175 -0
  113. package/src/screens/address/DetailsSheetFields.tsx +184 -0
  114. package/src/screens/address/EntranceDropzone.tsx +178 -0
  115. package/src/screens/address/EntranceFraming.tsx +66 -0
  116. package/src/screens/address/EntrancePills.tsx +60 -0
  117. package/src/screens/address/FramedStreetView.tsx +155 -0
  118. package/src/screens/address/IntroDisclosures.tsx +210 -0
  119. package/src/screens/address/LabelDecisionRow.tsx +113 -0
  120. package/src/screens/address/PinSummaryRow.tsx +105 -0
  121. package/src/screens/address/ReviewAddressBand.tsx +111 -0
  122. package/src/screens/address/ReviewEntranceThumbs.tsx +64 -0
  123. package/src/screens/address/ReviewMapPicture.tsx +106 -0
  124. package/src/screens/address/SearchResults.tsx +105 -0
  125. package/src/screens/address/SearchScreen.tsx +176 -0
  126. package/src/screens/address/SkipForNow.tsx +43 -0
  127. package/src/screens/address/StreetViewChrome.tsx +81 -0
  128. package/src/screens/address/detail-values.ts +22 -0
  129. package/src/screens/address/fix-source.ts +27 -0
  130. package/src/screens/address/index.ts +8 -0
  131. package/src/screens/address/meta.ts +44 -0
  132. package/src/screens/address/use-address-flow.ts +200 -0
  133. package/src/screens/address/use-label-pin.ts +80 -0
  134. package/src/screens/address/use-pin-actions.ts +192 -0
  135. package/src/screens/biometric/BiometricAuthFlow.tsx +170 -0
  136. package/src/screens/consent/model.ts +79 -10
  137. package/src/screens/liveness/CaptureRing.tsx +91 -0
  138. package/src/screens/liveness/LivenessCamera.tsx +63 -0
  139. package/src/screens/liveness/LivenessHandover.tsx +43 -0
  140. package/src/screens/liveness/LivenessOutcome.tsx +4 -3
  141. package/src/screens/liveness/SelfiePreview.tsx +13 -4
  142. package/src/screens/liveness/ShutterFlash.tsx +25 -0
  143. package/src/screens/liveness/index.ts +4 -0
  144. package/src/screens/liveness/useSelfieUpload.ts +23 -3
  145. package/src/screens/nfc/NfcScanIllustration.tsx +7 -18
  146. package/src/screens/useAddressPhotoAttach.ts +77 -0
  147. package/src/screens/useBusinessDocumentAttach.ts +93 -0
  148. package/src/screens/usePoaAttach.ts +12 -15
  149. package/src/services/api-biometric.ts +37 -0
  150. package/src/services/api-types-biometric.ts +41 -0
  151. package/src/services/api-types.ts +93 -1
  152. package/src/services/api-verify-types.ts +37 -0
  153. package/src/services/api.ts +133 -3
  154. package/src/services/deviceMetadata.ts +1 -1
  155. package/src/services/location.ts +234 -0
  156. package/src/services/uploadErrors.ts +30 -0
  157. package/src/services/workflowGate.ts +7 -0
  158. package/src/store/address.ts +97 -0
  159. package/src/store/derive.ts +29 -4
  160. package/src/store/kycStore.ts +112 -3
  161. package/src/store/serverConfig.ts +18 -0
  162. package/src/store/session.ts +23 -4
  163. package/src/store/state.ts +134 -0
  164. package/src/store/submit.ts +18 -1
  165. package/src/types/config.ts +48 -1
  166. package/src/types/verification.ts +12 -0
  167. package/src/types/workflow.ts +97 -1
@@ -0,0 +1,234 @@
1
+ // ---------------------------------------------------------------------------
2
+ // One-shot geolocation for the address-collection step.
3
+ //
4
+ // Best-effort BY CONTRACT, mirroring the web SDK's address-helpers: a denied
5
+ // permission, a device with location off, or a slow fix costs the `attested`
6
+ // tier (or the recentre convenience) — never the flow. The attest fix resolves
7
+ // to null / {} on any failure; the PIN's fix says WHY it failed, so the copy
8
+ // can send the person to the right remedy (see lib/address-current-location).
9
+ // ---------------------------------------------------------------------------
10
+
11
+ import * as Location from 'expo-location';
12
+
13
+ const FIX_TIMEOUT_MS = 8_000;
14
+
15
+ /** Accuracy at which a fix is good enough to stop waiting for the GPS. */
16
+ const PRECISE_ENOUGH_M = 25;
17
+ const PRECISE_WINDOW_MS = 8_000;
18
+ /**
19
+ * Once ANY fix exists, wait only this much longer for a better one. An indoor
20
+ * or wifi-derived fix never reaches 25m, and sitting out the whole window for
21
+ * an accuracy that is not coming reads as "it keeps loading".
22
+ */
23
+ const FIRST_FIX_GRACE_MS = 3_000;
24
+
25
+ export interface DeviceFix {
26
+ lat: number;
27
+ lng: number;
28
+ accuracy: number | null;
29
+ timestamp: number;
30
+ /** Android reports a mock-location provider; iOS has no equivalent (null). */
31
+ mocked: boolean | null;
32
+ }
33
+
34
+ /**
35
+ * Why a fix could not be taken. A refused permission, a phone that cannot
36
+ * place itself (location switched off, no provider), and a fix that took
37
+ * longer than the window are three different problems with three different
38
+ * remedies. Mirrors the web SDK's LocationFailure and Flutter's enum.
39
+ */
40
+ export type LocationFailure = 'denied' | 'unavailable' | 'timeout' | 'unsupported';
41
+
42
+ export type PreciseFixOutcome = { fix: DeviceFix } | { failure: LocationFailure };
43
+
44
+ function toFix(pos: Location.LocationObject): DeviceFix {
45
+ return {
46
+ lat: pos.coords.latitude,
47
+ lng: pos.coords.longitude,
48
+ accuracy: typeof pos.coords.accuracy === 'number' ? pos.coords.accuracy : null,
49
+ timestamp: pos.timestamp || Date.now(),
50
+ mocked: typeof pos.mocked === 'boolean' ? pos.mocked : null,
51
+ };
52
+ }
53
+
54
+ function withTimeout<T>(promise: Promise<T>, ms: number): Promise<T> {
55
+ return new Promise<T>((resolve, reject) => {
56
+ const timer = setTimeout(() => reject(new Error('location timeout')), ms);
57
+ promise.then(
58
+ (v) => {
59
+ clearTimeout(timer);
60
+ resolve(v);
61
+ },
62
+ (e) => {
63
+ clearTimeout(timer);
64
+ reject(e);
65
+ },
66
+ );
67
+ });
68
+ }
69
+
70
+ // The most recent fix ANY read produced, kept so the confirm-time attest read
71
+ // can fall back on it (see deviceFixFields).
72
+ let lastGoodFix: DeviceFix | null = null;
73
+ function remember(fix: DeviceFix): DeviceFix {
74
+ lastGoodFix = fix;
75
+ return fix;
76
+ }
77
+
78
+ /** A fix older than this no longer says where the phone is NOW. */
79
+ export const LAST_KNOWN_MAX_AGE_MS = 10 * 60_000;
80
+
81
+ /**
82
+ * One fix for a presence report, or null. Asks for foreground permission on
83
+ * first use.
84
+ *
85
+ * Resolves the way the pin step's precise read learned to, in three rungs:
86
+ * a fresh read at BALANCED accuracy (the fence is 250m or wider, so a 100m
87
+ * fix is plenty; Expo's iOS read waits for a fix that MEETS the requested
88
+ * accuracy, and a High request indoors never delivered one inside the
89
+ * window, so every RN report on the iPhone came back `no_fix` while
90
+ * Flutter's Geolocator, which returns the first update, reported fine,
91
+ * 2026-09-08), then the platform's last known position, then the last fix
92
+ * this module itself took (the pin step's, minutes earlier), each accepted
93
+ * only within LAST_KNOWN_MAX_AGE_MS. Flutter's reporter carries the same
94
+ * last-known fallback.
95
+ */
96
+ export async function currentPosition(): Promise<DeviceFix | null> {
97
+ try {
98
+ const { status } = await Location.requestForegroundPermissionsAsync();
99
+ if (status !== 'granted') return null;
100
+ } catch {
101
+ return null;
102
+ }
103
+ const fresh = await withTimeout(
104
+ Location.getCurrentPositionAsync({ accuracy: Location.Accuracy.Balanced }),
105
+ FIX_TIMEOUT_MS,
106
+ ).then(toFix, () => null);
107
+ if (fresh) return remember(fresh);
108
+ const known = await Location.getLastKnownPositionAsync({ maxAge: LAST_KNOWN_MAX_AGE_MS }).then(
109
+ (pos) => (pos ? toFix(pos) : null),
110
+ () => null,
111
+ );
112
+ if (known) return remember(known);
113
+ if (lastGoodFix && Date.now() - lastGoodFix.timestamp <= LAST_KNOWN_MAX_AGE_MS) return lastGoodFix;
114
+ return null;
115
+ }
116
+
117
+ /**
118
+ * A PRECISE fix: watch the position for up to ~8s, keep the most accurate
119
+ * reading, and resolve early once it is within 25m.
120
+ *
121
+ * A single `getCurrentPositionAsync` routinely answers with the first coarse
122
+ * wifi/cell reading — hundreds of metres out, before the GPS has warmed up —
123
+ * which is exactly the pin landing on the wrong compound. A WATCH also never
124
+ * hands back a cached fix, which is the other half of what a pin needs.
125
+ *
126
+ * Never throws: every failure is CLASSIFIED, and every caller falls back to
127
+ * placing the pin by hand.
128
+ */
129
+ export async function precisePositionOutcome(): Promise<PreciseFixOutcome> {
130
+ let granted = false;
131
+ try {
132
+ const { status } = await Location.requestForegroundPermissionsAsync();
133
+ granted = status === 'granted';
134
+ } catch {
135
+ // No location module behind the call: nothing on this device can answer.
136
+ return { failure: 'unsupported' };
137
+ }
138
+ if (!granted) return { failure: 'denied' };
139
+ try {
140
+ // Permission granted with the toggle OFF is the failure people hit most,
141
+ // and it is the one "allow location access" sends them the wrong way on.
142
+ if (!(await Location.hasServicesEnabledAsync())) return { failure: 'unavailable' };
143
+ } catch {
144
+ // An older module without the probe: let the watch decide.
145
+ }
146
+
147
+ return new Promise<PreciseFixOutcome>((resolve) => {
148
+ let best: Location.LocationObject | null = null;
149
+ let settled = false;
150
+ let watchFailed = false;
151
+ let sub: Location.LocationSubscription | null = null;
152
+ let grace: ReturnType<typeof setTimeout> | null = null;
153
+
154
+ const finish = (): void => {
155
+ if (settled) return;
156
+ settled = true;
157
+ clearTimeout(windowTimer);
158
+ if (grace) clearTimeout(grace);
159
+ sub?.remove();
160
+ if (best) resolve({ fix: remember(toFix(best)) });
161
+ else resolve({ failure: watchFailed ? 'unavailable' : 'timeout' });
162
+ };
163
+
164
+ const windowTimer = setTimeout(finish, PRECISE_WINDOW_MS);
165
+
166
+ Location.watchPositionAsync(
167
+ { accuracy: Location.Accuracy.BestForNavigation, distanceInterval: 0 },
168
+ (pos) => {
169
+ const acc = pos.coords.accuracy ?? Infinity;
170
+ if (!best || acc < (best.coords.accuracy ?? Infinity)) best = pos;
171
+ if (acc <= PRECISE_ENOUGH_M) {
172
+ finish();
173
+ return;
174
+ }
175
+ if (!grace) grace = setTimeout(finish, FIRST_FIX_GRACE_MS);
176
+ },
177
+ )
178
+ .then((subscription) => {
179
+ // The window may already have elapsed while the watch was starting —
180
+ // hold no subscription nobody will ever remove.
181
+ if (settled) subscription.remove();
182
+ else sub = subscription;
183
+ })
184
+ .catch(() => {
185
+ watchFailed = true;
186
+ finish();
187
+ });
188
+ });
189
+ }
190
+
191
+ /**
192
+ * The attest-presence device fix, as the fields the verify body carries.
193
+ * Empty when no fix could be taken — the submission simply goes without the
194
+ * `attested` tier.
195
+ */
196
+ /** How old a fix from earlier in the SAME address flow may be and still stand
197
+ * in for the confirm-time read. Placing a pin takes a minute or two; a fix
198
+ * from that window still says the device was here, and the server judges it
199
+ * by its own `capturedAt` anyway. */
200
+ export const RECENT_FIX_MAX_AGE_MS = 3 * 60_000;
201
+
202
+ /** The reading the attest step should send: a fresh one when the read
203
+ * answered, else the recent one the flow already took, else nothing. Pure,
204
+ * so the rule is testable without a GPS. */
205
+ export function pickDeviceFix(
206
+ fresh: DeviceFix | null,
207
+ recent: DeviceFix | null,
208
+ now: number = Date.now(),
209
+ ): DeviceFix | null {
210
+ if (fresh) return fresh;
211
+ if (recent && now - recent.timestamp <= RECENT_FIX_MAX_AGE_MS && recent.mocked !== true) return recent;
212
+ return null;
213
+ }
214
+
215
+ export async function deviceFixFields(): Promise<{
216
+ deviceLat?: number;
217
+ deviceLng?: number;
218
+ deviceAccuracy?: number;
219
+ capturedAt?: string;
220
+ }> {
221
+ // A single getCurrentPositionAsync at confirm routinely times out on iOS
222
+ // while the GPS is still settling, and the submission then went out with
223
+ // no fix at all even though "Use my location" had just placed the pin on
224
+ // one (iPhone 16 Pro Max, 2026-09-07: every address run read "No device
225
+ // fix taken"). The fix the flow already holds is the fallback.
226
+ const fix = pickDeviceFix(await currentPosition(), lastGoodFix);
227
+ if (!fix) return {};
228
+ return {
229
+ deviceLat: fix.lat,
230
+ deviceLng: fix.lng,
231
+ ...(fix.accuracy != null ? { deviceAccuracy: fix.accuracy } : {}),
232
+ capturedAt: new Date(fix.timestamp).toISOString(),
233
+ };
234
+ }
@@ -0,0 +1,30 @@
1
+ import { KYCApiError } from './api';
2
+ import { UPLOAD_HINT } from '../config/uploadLimits';
3
+
4
+ // ---------------------------------------------------------------------------
5
+ // Why an upload failed, in words the person can act on.
6
+ //
7
+ // Every upload used to fail as "we could not upload that document", which tells
8
+ // the applicant nothing about whether to pick a smaller file, a different
9
+ // format, or simply try again on better signal. The server already says which
10
+ // it is; this reads its answer and hands back the sentence that matches.
11
+ // ---------------------------------------------------------------------------
12
+
13
+ /** The refusal, as something the applicant can do something about. */
14
+ export function uploadFailureMessage(error: unknown): string {
15
+ const status = error instanceof KYCApiError ? error.statusCode : null;
16
+ const raw = error instanceof Error ? error.message.toLowerCase() : '';
17
+
18
+ if (status === 413 || raw.includes('too large')) {
19
+ return `That file is too large. ${UPLOAD_HINT}`;
20
+ }
21
+ if (raw.includes('mimetype') || raw.includes('mime type') || raw.includes('unsupported')) {
22
+ return `That file type is not supported. ${UPLOAD_HINT}`;
23
+ }
24
+ // A 4xx we have no better words for still says the file is the problem, so
25
+ // the person retries with a DIFFERENT one rather than the same one twice.
26
+ if (status !== null && status >= 400 && status < 500) {
27
+ return `We could not read that file. ${UPLOAD_HINT}`;
28
+ }
29
+ return 'We could not upload that document. Please check your connection and try again.';
30
+ }
@@ -103,6 +103,13 @@ export async function resolveWorkflow(
103
103
  idTypes: res.idTypes,
104
104
  branding: res.branding,
105
105
  geoCountry: res.geoCountry,
106
+ // The resolution route does not serve these yet, so a workflow mount
107
+ // reads undefined and the address flow offers no search screen. That
108
+ // degrades correctly, and the passthrough means it starts working the
109
+ // day the server carries them.
110
+ addressSearch: res.addressSearch,
111
+ addressSearchMode: res.addressSearchMode,
112
+ mapsFrameUrl: res.mapsFrameUrl ?? null,
106
113
  environment: res.environment,
107
114
  fatal: false,
108
115
  },
@@ -0,0 +1,97 @@
1
+ import type { AddressState } from './state';
2
+
3
+ // ---------------------------------------------------------------------------
4
+ // Restoring a saved address pin.
5
+ //
6
+ // The whole address object is written into session progress verbatim, so the
7
+ // snapshot was written by WHATEVER build saved it: coerce every field back to
8
+ // its declared type and degrade to restoring less, never to breaking the flow.
9
+ //
10
+ // Deliberately does NOT restore the device fix (deviceLat / deviceLng /
11
+ // deviceAccuracy / capturedAt). That is the attest-presence reading, and it is
12
+ // taken fresh at confirm — a stale one would claim the applicant stood at the
13
+ // address on a day they did not.
14
+ //
15
+ // Split from session.ts per the 200-line rule. A MIRROR of the web SDK's
16
+ // RESTORE_PROGRESS address branch and the Flutter port.
17
+ // ---------------------------------------------------------------------------
18
+
19
+ function str(v: unknown): string | null {
20
+ return typeof v === 'string' ? v : null;
21
+ }
22
+
23
+ /** The `parts` breakdown, when the snapshot carries an object for it. */
24
+ function restoreParts(v: unknown): Pick<AddressState, 'parts'> {
25
+ if (!v || typeof v !== 'object') return {};
26
+ const p = v as Record<string, unknown>;
27
+ return {
28
+ parts: {
29
+ street: str(p['street']),
30
+ area: str(p['area']),
31
+ city: str(p['city']),
32
+ state: str(p['state']),
33
+ postcode: str(p['postcode']),
34
+ country: str(p['country']),
35
+ },
36
+ };
37
+ }
38
+
39
+ /** The picked-label anchor, only when it is a real coordinate pair. */
40
+ function restorePickedAt(v: unknown): Pick<AddressState, 'pickedAt'> {
41
+ if (!v || typeof v !== 'object') return {};
42
+ const p = v as Record<string, unknown>;
43
+ return typeof p['lat'] === 'number' && typeof p['lng'] === 'number'
44
+ ? { pickedAt: { lat: p['lat'], lng: p['lng'] } }
45
+ : {};
46
+ }
47
+
48
+ /** The Street View frame, only when every field of it survived. A partial
49
+ * frame cannot be re-rendered, and half of one is not a capture. */
50
+ function restoreStreetView(v: unknown): Pick<AddressState, 'streetView'> {
51
+ if (!v || typeof v !== 'object') return {};
52
+ const sv = v as Record<string, unknown>;
53
+ return typeof sv['panoId'] === 'string' &&
54
+ typeof sv['heading'] === 'number' &&
55
+ typeof sv['pitch'] === 'number' &&
56
+ typeof sv['fov'] === 'number'
57
+ ? {
58
+ streetView: {
59
+ panoId: sv['panoId'],
60
+ heading: sv['heading'],
61
+ pitch: sv['pitch'],
62
+ fov: sv['fov'],
63
+ },
64
+ }
65
+ : {};
66
+ }
67
+
68
+ /**
69
+ * Rebuild the address from a progress snapshot. The caller has already checked
70
+ * that `lat` and `lng` are numbers — without those there is no pin, and there
71
+ * is nothing to restore.
72
+ */
73
+ export function restoreAddress(a: Record<string, unknown>): AddressState {
74
+ return {
75
+ lat: a['lat'] as number,
76
+ lng: a['lng'] as number,
77
+ accuracy: typeof a['accuracy'] === 'number' ? a['accuracy'] : null,
78
+ directions: typeof a['directions'] === 'string' ? a['directions'] : '',
79
+ propertyName: typeof a['propertyName'] === 'string' ? a['propertyName'] : '',
80
+ propertyNumber: typeof a['propertyNumber'] === 'string' ? a['propertyNumber'] : '',
81
+ // The resolved line and its breakdown survive a restart too. Dropping them
82
+ // is why a resumed session showed raw coordinates where the applicant had
83
+ // picked an address.
84
+ ...(typeof a['label'] === 'string' ? { label: a['label'] } : {}),
85
+ ...(typeof a['street'] === 'string' ? { street: a['street'] } : {}),
86
+ // The edit-details claims survive a restart like every other typed field.
87
+ ...(typeof a['unit'] === 'string' ? { unit: a['unit'] } : {}),
88
+ ...(typeof a['neighbourhood'] === 'string' ? { neighbourhood: a['neighbourhood'] } : {}),
89
+ ...(typeof a['city'] === 'string' ? { city: a['city'] } : {}),
90
+ ...(typeof a['state'] === 'string' ? { state: a['state'] } : {}),
91
+ ...(typeof a['postcode'] === 'string' ? { postcode: a['postcode'] } : {}),
92
+ ...(a['labelKept'] === true ? { labelKept: true } : {}),
93
+ ...restorePickedAt(a['pickedAt']),
94
+ ...restoreParts(a['parts']),
95
+ ...restoreStreetView(a['streetView']),
96
+ };
97
+ }
@@ -7,14 +7,19 @@
7
7
  // keeps a single answer to each of those questions.
8
8
  // ---------------------------------------------------------------------------
9
9
 
10
+ import { configScope } from '../lib/scope';
10
11
  import { requiresDocumentCapture, supportsNfcChip } from '../config/idTypes';
11
12
  import {
13
+ buildStepOrder,
12
14
  getStepProgress,
13
15
  nextStepInOrder,
14
16
  previousStepInOrder,
15
17
  type StepOrderOptions,
16
18
  } from '../config/stepOrder';
17
19
  import { hasProofOfAddressStep } from '../config/proofOfAddress';
20
+ import { addressFlowFor, hasAddressCollectionStep } from '../config/addressCollection';
21
+ import { webViewAvailable } from '../lib/webview-available';
22
+ import { addressVendorsStubbed } from '../lib/address-flow';
18
23
  import { businessProductsForCountry, isBusinessFlow } from '../config/business';
19
24
  import {
20
25
  hasApplicantVerification,
@@ -22,6 +27,7 @@ import {
22
27
  hasKeyPeopleCollection,
23
28
  } from '../config/businessSteps';
24
29
  import { hasActiveQuestionnaire } from '../config/questionnaire';
30
+ import { hasConsentStep, openingStepOf } from '../config/consentStep';
25
31
  import { applicantSelfCountry, keyPeoplePayload } from '../config/keyPeople';
26
32
  import { featuresFor } from './serverConfig';
27
33
  import type { VerifyRequest } from '../services/api';
@@ -58,6 +64,7 @@ export function stepOrderOptions(state: KycState): StepOrderOptions {
58
64
  const idType = state.selectedIdType;
59
65
  return {
60
66
  isBusiness: isBusinessFlow(config),
67
+ scope: configScope(config),
61
68
  business: config.business,
62
69
  hasDocCapture: idType ? requiresDocumentCapture(idType) : true,
63
70
  hasNfc: nfcEnabled(state),
@@ -72,9 +79,20 @@ export function stepOrderOptions(state: KycState): StepOrderOptions {
72
79
  hasCountrySelect:
73
80
  countrySelectOptions(state).length > 1 &&
74
81
  applicantSelfCountry(state.businessApplication) === null,
82
+ hasConsent: hasConsentStep(config),
75
83
  hasEmailVerification: config.emailVerification?.enabled === true,
76
84
  hasPhoneVerification: config.phoneVerification?.enabled === true,
77
85
  hasPoa: hasProofOfAddressStep(config.proofOfAddress),
86
+ hasAddressCollection: hasAddressCollectionStep(config.addressCollection),
87
+ // An absent search flag means no search SCREEN, never an error: the
88
+ // applicant places the pin by hand, the fallback every address failure
89
+ // degrades to.
90
+ addressFlow: addressFlowFor(
91
+ config,
92
+ state.serverConfig.addressSearch === true,
93
+ addressVendorsStubbed({ environment: state.serverConfig.environment }),
94
+ Boolean(state.serverConfig.mapsFrameUrl) && webViewAvailable(),
95
+ ),
78
96
  hasQuestionnaire: hasActiveQuestionnaire(config.questionnaire),
79
97
  // Present only on a session a reviewer sent back. Narrows the order to the
80
98
  // steps they ticked, so somebody fixing one blurry photo is not walked
@@ -237,11 +255,18 @@ export function previousStepBefore(step: KYCStep, state: KycState): KYCStep {
237
255
  return previousStepInOrder(step, stepOrderOptions(state));
238
256
  }
239
257
 
258
+ /**
259
+ * The step this flow OPENS on: 'consent', or the first real step when the
260
+ * workflow switched the consent screen off (`consentStep: false`). The store
261
+ * starts and resets here, the progress watcher judges "untouched" against it,
262
+ * and the flow hides Back on it. Never depends on anything the applicant has
263
+ * yet to choose: only later steps do.
264
+ */
265
+ export function openingStep(state: KycState): KYCStep {
266
+ return openingStepOf(buildStepOrder(stepOrderOptions(state)));
267
+ }
268
+
240
269
  /** Percentage complete, for the sheet's progress indicator. */
241
270
  export function stepProgress(step: KYCStep, state: KycState): number {
242
271
  return getStepProgress(step, stepOrderOptions(state));
243
272
  }
244
-
245
- // ---------------------------------------------------------------------------
246
- // Store factory
247
- // ---------------------------------------------------------------------------
@@ -27,7 +27,7 @@ import {
27
27
  type KycState,
28
28
  type KycStore,
29
29
  } from './state';
30
- import { nextStepAfter, nfcDecision, previousStepBefore } from './derive';
30
+ import { nextStepAfter, nfcDecision, openingStep, previousStepBefore } from './derive';
31
31
 
32
32
  /** The steps that make up ONE ID's evidence. Leaving this set is what ends a
33
33
  * multi-ID check — the leg has several exits depending on the ID. */
@@ -54,9 +54,11 @@ function multiIdPlanFor(s: KycState): ReturnType<typeof multiIdPlan> {
54
54
  );
55
55
  }
56
56
  import { recordStep, resetStepLog } from '../lib/step-log';
57
+ import { resetAutoLocate, resetCurrentFix } from '../lib/address-current-location';
57
58
  import { multiIdPlan } from '../lib/multi-id';
58
59
  import { resolveIdTypeDefinition } from '../config/idTypes';
59
60
  import { buildVerifyRequest } from './submit';
61
+ import { IDLE_SELFIE_UPLOAD } from '../lib/selfie-upload-wait';
60
62
  import { resetBusinessCheck, runBusinessCheck } from './businessCheck';
61
63
  import { startAttemptSession, watchSessionProgress } from './session';
62
64
  import { applicantMediaCaptured, buildApplicantVerifyRequest } from './submitApplicant';
@@ -99,11 +101,14 @@ export function createKycStore(
99
101
  sessionUrl: null,
100
102
  businessCheck: { ...EMPTY_BUSINESS_CHECK },
101
103
  selectedCountry: null,
104
+ countryAutoPicked: false,
102
105
  selectedIdType: null,
103
106
  idNumber: null,
104
107
  multiIdSlotIndex: 0,
105
108
  multiIdSlots: [],
106
109
  multiIdRestored: null,
110
+ selfiePreviewUri: null,
111
+ selfieUpload: IDLE_SELFIE_UPLOAD,
107
112
  mediaIds: {},
108
113
  submissionResult: null,
109
114
  serverConfig: serverConfig ?? INITIAL_SERVER_CONFIG,
@@ -124,6 +129,11 @@ export function createKycStore(
124
129
  chipData: null,
125
130
  poaDocumentType: null,
126
131
  poaFileName: null,
132
+ address: null,
133
+ addressPhotoPreview: null,
134
+ addressIntroSeen: false,
135
+ addressEntranceFraming: false,
136
+ addressSandboxOutcome: null,
127
137
  isLoading: false,
128
138
  error: null,
129
139
 
@@ -137,16 +147,27 @@ export function createKycStore(
137
147
  const branding = res.branding
138
148
  ? { ...res.branding, logo: normalizeDevAssetUrl(res.branding.logo, baseUrl) }
139
149
  : res.branding;
150
+ // The facts that just landed can add a step AHEAD of the one the flow
151
+ // opened on (the address search step, on a consent-less address
152
+ // flow). Someone still standing on the placeholder's opening step,
153
+ // having done nothing, is moved to the real one; anyone who has
154
+ // moved is left alone. Mirrors Flutter's _loadServerConfig.
155
+ const before = openingStep(get());
140
156
  set({
141
157
  serverConfig: {
142
158
  status: 'ready',
143
159
  idTypes: res.idTypes,
144
160
  branding,
145
161
  geoCountry: res.geoCountry,
162
+ addressSearch: res.addressSearch,
163
+ addressSearchMode: res.addressSearchMode,
164
+ mapsFrameUrl: res.mapsFrameUrl ?? null,
146
165
  environment: res.environment,
147
166
  fatal: false,
148
167
  },
149
168
  });
169
+ const after = openingStep(get());
170
+ if (get().currentStep === before && after !== before) set({ currentStep: after });
150
171
  } catch (err) {
151
172
  const described = describeConfigError(err);
152
173
  set((s) => ({
@@ -163,6 +184,7 @@ export function createKycStore(
163
184
  const same = s.selectedCountry === country;
164
185
  return {
165
186
  selectedCountry: country,
187
+ countryAutoPicked: false,
166
188
  selectedIdType: same ? s.selectedIdType : null,
167
189
  idNumber: same ? s.idNumber : null,
168
190
  // A multi-ID run's committed slots belong to the country they were
@@ -175,6 +197,11 @@ export function createKycStore(
175
197
  });
176
198
  },
177
199
 
200
+ setCountryAuto(country) {
201
+ get().setCountry(country);
202
+ if (!get().countryAutoPicked) set({ countryAutoPicked: true });
203
+ },
204
+
178
205
  commitMultiIdSlot(nextStep, previews) {
179
206
  set((s) => {
180
207
  if (!s.selectedIdType) return {};
@@ -257,6 +284,22 @@ export function createKycStore(
257
284
  set((s) => ({ mediaIds: { ...s.mediaIds, [key]: mediaId } }));
258
285
  },
259
286
 
287
+ setSelfiePreview(uri) {
288
+ set({ selfiePreviewUri: uri });
289
+ },
290
+
291
+ clearSelfie() {
292
+ set((s) => ({
293
+ selfiePreviewUri: null,
294
+ selfieUpload: IDLE_SELFIE_UPLOAD,
295
+ mediaIds: { ...s.mediaIds, selfie: undefined, livenessVideo: undefined },
296
+ }));
297
+ },
298
+
299
+ setSelfieUpload(upload) {
300
+ set({ selfieUpload: upload });
301
+ },
302
+
260
303
  setDocumentMediaId(mediaId, side) {
261
304
  set((s) => ({
262
305
  mediaIds: {
@@ -423,6 +466,41 @@ export function createKycStore(
423
466
  });
424
467
  },
425
468
 
469
+ setAddress(address) {
470
+ set({ address });
471
+ },
472
+
473
+ setAddressSandboxOutcome(addressSandboxOutcome) {
474
+ set({ addressSandboxOutcome });
475
+ },
476
+
477
+ setAddressPhoto(mediaId) {
478
+ set((s) => {
479
+ if (mediaId) return { mediaIds: { ...s.mediaIds, addressPhoto: mediaId } };
480
+ const { addressPhoto: _dropped, ...rest } = s.mediaIds;
481
+ return { mediaIds: rest };
482
+ });
483
+ },
484
+
485
+ setAddressPhotoPreview(uri) {
486
+ set({ addressPhotoPreview: uri });
487
+ },
488
+
489
+ markAddressIntroSeen() {
490
+ set({ addressIntroSeen: true });
491
+ },
492
+
493
+ setAddressEntranceFraming(framing) {
494
+ if (get().addressEntranceFraming !== framing) set({ addressEntranceFraming: framing });
495
+ },
496
+
497
+ clearAddress() {
498
+ set((s) => {
499
+ const { addressPhoto: _dropped, ...rest } = s.mediaIds;
500
+ return { address: null, mediaIds: rest, addressPhotoPreview: null };
501
+ });
502
+ },
503
+
426
504
  setDocumentCapturePhase(phase) {
427
505
  if (get().documentCapturePhase !== phase) set({ documentCapturePhase: phase });
428
506
  },
@@ -569,15 +647,27 @@ export function createKycStore(
569
647
  // already sitting on 'consent' (the subscribe below only fires on
570
648
  // change, and recordStep dedupes if it fires too).
571
649
  resetStepLog();
572
- recordStep('consent', multiIdSlotOf(get()), selectedIdTypeOf(get()));
650
+ // The cached GPS fix and the once-per-verification auto-locate are
651
+ // scoped to ONE verification: an app session spans many opens of the
652
+ // SDK, and a fix taken an hour ago would drop the pin at wherever the
653
+ // phone was then.
654
+ resetCurrentFix();
655
+ resetAutoLocate();
656
+ // The opening step never depends on the fields reset below (only later
657
+ // steps do), so it is safe to read before the write.
658
+ const opening = openingStep(get());
659
+ recordStep(opening, multiIdSlotOf(get()), selectedIdTypeOf(get()));
573
660
  set({
574
- currentStep: 'consent',
661
+ currentStep: opening,
575
662
  selectedCountry: null,
663
+ countryAutoPicked: false,
576
664
  selectedIdType: null,
577
665
  idNumber: null,
578
666
  multiIdSlotIndex: 0,
579
667
  multiIdSlots: [],
580
668
  multiIdRestored: null,
669
+ selfiePreviewUri: null,
670
+ selfieUpload: IDLE_SELFIE_UPLOAD,
581
671
  mediaIds: {},
582
672
  submissionResult: null,
583
673
  documentScanPhase: 'front',
@@ -599,6 +689,11 @@ export function createKycStore(
599
689
  chipData: null,
600
690
  poaDocumentType: null,
601
691
  poaFileName: null,
692
+ address: null,
693
+ addressPhotoPreview: null,
694
+ addressIntroSeen: false,
695
+ addressEntranceFraming: false,
696
+ addressSandboxOutcome: null,
602
697
  isLoading: false,
603
698
  error: null,
604
699
  });
@@ -622,6 +717,20 @@ export function createKycStore(
622
717
  // subscription above only fires on CHANGE, and a new store already sits on
623
718
  // 'consent').
624
719
  resetStepLog();
720
+ // The module-level GPS cache and the auto-locate latch are scoped to ONE
721
+ // verification, and this — not reset() — is where a verification begins.
722
+ // Clearing them only in reset() left both alive across modal launches, so a
723
+ // second verification in the same app session dropped its pin wherever the
724
+ // phone had been on the first (possibly hours and a city away) and skipped
725
+ // the automatic locate entirely, since the latch was already spent.
726
+ resetCurrentFix();
727
+ resetAutoLocate();
728
+ // A workflow that switched the consent screen off opens on its first real
729
+ // step; the literal 'consent' above is only the shape before the config is
730
+ // consulted. Set BEFORE the journey is recorded, so the log opens where the
731
+ // person does.
732
+ const opening = openingStep(store.getState());
733
+ if (opening !== store.getState().currentStep) store.setState({ currentStep: opening });
625
734
  recordStep(
626
735
  store.getState().currentStep,
627
736
  multiIdSlotOf(store.getState()),