@myazahq/kyc-sdk-react-native 2.5.0 → 3.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (219) hide show
  1. package/README.md +277 -7
  2. package/android/build.gradle +30 -16
  3. package/android/consumer-rules.pro +22 -0
  4. package/android/src/main/AndroidManifest.xml +17 -6
  5. package/android/src/main/java/com/margelo/nitro/myazakyc/HybridMyazaFaceDetector.kt +27 -70
  6. package/android/src/main/java/com/margelo/nitro/myazakyc/HybridMyazaTextRecognizer.kt +14 -0
  7. package/android/src/main/java/com/margelo/nitro/myazakyc/MlKitModelReadiness.kt +154 -0
  8. package/app.plugin.js +146 -10
  9. package/ios/HybridMyazaFaceDetector.swift +8 -3
  10. package/ios/HybridMyazaTextRecognizer.swift +8 -0
  11. package/nitrogen/generated/android/c++/JHybridMyazaTextRecognizerSpec.cpp +21 -0
  12. package/nitrogen/generated/android/c++/JHybridMyazaTextRecognizerSpec.hpp +2 -0
  13. package/nitrogen/generated/android/kotlin/com/margelo/nitro/myazakyc/HybridMyazaTextRecognizerSpec.kt +8 -0
  14. package/nitrogen/generated/ios/c++/HybridMyazaTextRecognizerSpecSwift.hpp +16 -0
  15. package/nitrogen/generated/ios/swift/HybridMyazaTextRecognizerSpec.swift +2 -0
  16. package/nitrogen/generated/ios/swift/HybridMyazaTextRecognizerSpec_cxx.swift +31 -0
  17. package/nitrogen/generated/shared/c++/HybridMyazaTextRecognizerSpec.cpp +2 -0
  18. package/nitrogen/generated/shared/c++/HybridMyazaTextRecognizerSpec.hpp +2 -0
  19. package/package.json +30 -9
  20. package/src/MyazaKYC.tsx +4 -11
  21. package/src/assets/fonts/Karla_400Regular.ttf +0 -0
  22. package/src/assets/fonts/Karla_500Medium.ttf +0 -0
  23. package/src/assets/fonts/Karla_600SemiBold.ttf +0 -0
  24. package/src/assets/fonts/Karla_700Bold.ttf +0 -0
  25. package/src/assets/fonts/OFL-Karla.txt +93 -0
  26. package/src/assets/fonts/OFL-SpaceGrotesk.txt +93 -0
  27. package/src/assets/fonts/README.md +12 -0
  28. package/src/assets/fonts/SpaceGrotesk_500Medium.ttf +0 -0
  29. package/src/assets/fonts/SpaceGrotesk_600SemiBold.ttf +0 -0
  30. package/src/assets/fonts/SpaceGrotesk_700Bold.ttf +0 -0
  31. package/src/capture/useAutoCapture.ts +8 -1
  32. package/src/components/CountryField.tsx +8 -0
  33. package/src/components/CountryRegionPicker.tsx +184 -0
  34. package/src/components/DialCodePicker.tsx +47 -68
  35. package/src/components/DialCodeRow.tsx +102 -0
  36. package/src/components/DocumentCropper.tsx +30 -24
  37. package/src/components/DocumentReview.tsx +11 -1
  38. package/src/components/DocumentReviewSide.tsx +6 -2
  39. package/src/components/DocumentReviewZoom.tsx +5 -1
  40. package/src/components/FramedMapPicker.tsx +122 -0
  41. package/src/components/GeoBadge.tsx +34 -0
  42. package/src/components/Icon.tsx +10 -192
  43. package/src/components/KycFlow.tsx +69 -34
  44. package/src/components/LineSkeleton.tsx +100 -0
  45. package/src/components/MapChrome.tsx +67 -0
  46. package/src/components/MapPinMarker.tsx +54 -0
  47. package/src/components/MapPinPicker.tsx +179 -0
  48. package/src/components/MilestoneTrack.tsx +155 -0
  49. package/src/components/MyazaButton.tsx +9 -0
  50. package/src/components/MyazaInput.tsx +17 -1
  51. package/src/components/OptionRow.tsx +22 -3
  52. package/src/components/PhoneNumberInput.tsx +8 -0
  53. package/src/components/PoweredBy.tsx +5 -5
  54. package/src/components/PresenceBlocks.tsx +153 -0
  55. package/src/components/RequiredDocumentPill.tsx +114 -0
  56. package/src/components/StepHeader.tsx +23 -3
  57. package/src/components/StepView.tsx +17 -0
  58. package/src/components/StickyActions.tsx +56 -0
  59. package/src/components/dialCodeRows.ts +64 -0
  60. package/src/components/documentReviewCopy.ts +39 -0
  61. package/src/components/fonts.ts +14 -17
  62. package/src/components/icon-map.ts +176 -0
  63. package/src/components/stepHeaderMeta.tsx +48 -5
  64. package/src/config/addressCollection.ts +125 -0
  65. package/src/config/biometricOptions.ts +100 -0
  66. package/src/config/business.ts +20 -1
  67. package/src/config/businessSteps.ts +8 -2
  68. package/src/config/consentStep.ts +19 -0
  69. package/src/config/documentCaptureMethods.ts +31 -0
  70. package/src/config/proofOfAddress.ts +54 -11
  71. package/src/config/regions.ts +24 -0
  72. package/src/config/stepOrder.ts +79 -5
  73. package/src/config/uploadLimits.ts +35 -0
  74. package/src/config/workflowMerge.ts +13 -5
  75. package/src/index.ts +49 -1
  76. package/src/lib/address-current-location.ts +171 -0
  77. package/src/lib/address-field-modes.ts +119 -0
  78. package/src/lib/address-flow.ts +172 -0
  79. package/src/lib/address-helpers.ts +48 -0
  80. package/src/lib/address-line.ts +76 -0
  81. package/src/lib/address-pin-move.ts +97 -0
  82. package/src/lib/address-step-recovery.ts +63 -0
  83. package/src/lib/authed-image.ts +78 -0
  84. package/src/lib/biometric-copy.ts +51 -0
  85. package/src/lib/captureRing.ts +83 -0
  86. package/src/lib/country-adoption.ts +89 -0
  87. package/src/lib/documentCaptureCheck.ts +136 -0
  88. package/src/lib/inferred-country.ts +65 -0
  89. package/src/lib/livenessLayout.ts +49 -0
  90. package/src/lib/map-frame.ts +159 -0
  91. package/src/lib/map-tiles.ts +143 -0
  92. package/src/lib/model-ready.ts +82 -0
  93. package/src/lib/poa-country-gate.ts +27 -0
  94. package/src/lib/prime-models.ts +50 -0
  95. package/src/lib/resubmit.ts +36 -7
  96. package/src/lib/result-copy.ts +116 -0
  97. package/src/lib/result-wait.ts +53 -0
  98. package/src/lib/review-map-surface.ts +26 -0
  99. package/src/lib/scope.ts +31 -0
  100. package/src/lib/selfie-sharpness.ts +211 -0
  101. package/src/lib/selfie-upload-wait.ts +79 -0
  102. package/src/lib/street-view-fov.ts +42 -0
  103. package/src/lib/webview-available.ts +47 -0
  104. package/src/liveness/avatarSource.ts +59 -0
  105. package/src/liveness/useLiveness.ts +6 -1
  106. package/src/liveness/useModelReady.ts +9 -55
  107. package/src/mrz/extract.ts +71 -4
  108. package/src/mrz/textRecognizer.ts +36 -0
  109. package/src/mrz/useTextModelReady.ts +21 -0
  110. package/src/presence/background-math.ts +100 -0
  111. package/src/presence/background-store.ts +82 -0
  112. package/src/presence/background.ts +165 -0
  113. package/src/presence/foreground-service.ts +193 -0
  114. package/src/presence/fs.ts +51 -0
  115. package/src/presence/geofence.ts +41 -0
  116. package/src/presence/math.ts +44 -0
  117. package/src/presence/post.ts +40 -0
  118. package/src/presence/report.ts +87 -0
  119. package/src/presence/sampler.ts +110 -0
  120. package/src/presence/status.ts +92 -0
  121. package/src/presence/store.ts +94 -0
  122. package/src/presence/tier.ts +36 -0
  123. package/src/presence/watch-wait.ts +81 -0
  124. package/src/screens/AddressCountryControl.tsx +115 -0
  125. package/src/screens/BusinessDetailsFields.tsx +3 -0
  126. package/src/screens/BusinessDetailsStep.tsx +2 -0
  127. package/src/screens/BusinessDocumentSlot.tsx +3 -2
  128. package/src/screens/BusinessDocumentsStep.tsx +4 -55
  129. package/src/screens/CompanyInfoFields.tsx +6 -1
  130. package/src/screens/ConsentStep.tsx +6 -17
  131. package/src/screens/ContactActions.tsx +54 -0
  132. package/src/screens/ContactDestinationField.tsx +4 -0
  133. package/src/screens/ContactEntryPanel.tsx +75 -0
  134. package/src/screens/ContactVerificationStep.tsx +26 -56
  135. package/src/screens/CountrySelectStep.tsx +13 -147
  136. package/src/screens/DocumentCaptureStep.tsx +177 -93
  137. package/src/screens/LivenessAvatar.tsx +33 -16
  138. package/src/screens/LivenessStep.tsx +188 -49
  139. package/src/screens/MrzScanView.tsx +33 -0
  140. package/src/screens/PoaDocumentTypeList.tsx +66 -0
  141. package/src/screens/ProofOfAddressParts.tsx +151 -0
  142. package/src/screens/ProofOfAddressStep.tsx +58 -99
  143. package/src/screens/SubmittedBadge.tsx +25 -0
  144. package/src/screens/SubmittedError.tsx +64 -0
  145. package/src/screens/SubmittedResult.tsx +117 -0
  146. package/src/screens/SubmittedStep.tsx +71 -164
  147. package/src/screens/SubmittedSuccess.tsx +127 -0
  148. package/src/screens/SubmittedWaiting.tsx +45 -0
  149. package/src/screens/address/AddressEntranceStep.tsx +161 -0
  150. package/src/screens/address/AddressIntroGate.tsx +148 -0
  151. package/src/screens/address/AddressMapStub.tsx +50 -0
  152. package/src/screens/address/AddressPinStep.tsx +178 -0
  153. package/src/screens/address/AddressReviewStep.tsx +162 -0
  154. package/src/screens/address/AddressSandboxTabs.tsx +158 -0
  155. package/src/screens/address/AddressSearchField.tsx +115 -0
  156. package/src/screens/address/AddressSearchStep.tsx +94 -0
  157. package/src/screens/address/CurrentLocationRow.tsx +135 -0
  158. package/src/screens/address/DetailsSheet.tsx +175 -0
  159. package/src/screens/address/DetailsSheetFields.tsx +184 -0
  160. package/src/screens/address/EntranceDropzone.tsx +178 -0
  161. package/src/screens/address/EntranceFraming.tsx +66 -0
  162. package/src/screens/address/EntrancePills.tsx +60 -0
  163. package/src/screens/address/FramedStreetView.tsx +155 -0
  164. package/src/screens/address/IntroDisclosures.tsx +210 -0
  165. package/src/screens/address/LabelDecisionRow.tsx +113 -0
  166. package/src/screens/address/PinSummaryRow.tsx +105 -0
  167. package/src/screens/address/ReviewAddressBand.tsx +111 -0
  168. package/src/screens/address/ReviewEntranceThumbs.tsx +64 -0
  169. package/src/screens/address/ReviewMapPicture.tsx +106 -0
  170. package/src/screens/address/SearchResults.tsx +105 -0
  171. package/src/screens/address/SearchScreen.tsx +176 -0
  172. package/src/screens/address/SkipForNow.tsx +43 -0
  173. package/src/screens/address/StreetViewChrome.tsx +81 -0
  174. package/src/screens/address/detail-values.ts +22 -0
  175. package/src/screens/address/fix-source.ts +27 -0
  176. package/src/screens/address/index.ts +8 -0
  177. package/src/screens/address/meta.ts +44 -0
  178. package/src/screens/address/use-address-flow.ts +200 -0
  179. package/src/screens/address/use-label-pin.ts +80 -0
  180. package/src/screens/address/use-pin-actions.ts +192 -0
  181. package/src/screens/consent/model.ts +79 -10
  182. package/src/screens/document/CaptureCheckNotice.tsx +77 -0
  183. package/src/screens/document/UploadPhase.tsx +177 -0
  184. package/src/screens/document/useDocumentCamera.ts +109 -0
  185. package/src/screens/liveness/CaptureRing.tsx +91 -0
  186. package/src/screens/liveness/LivenessCamera.tsx +63 -0
  187. package/src/screens/liveness/LivenessHandover.tsx +43 -0
  188. package/src/screens/liveness/LivenessOutcome.tsx +8 -4
  189. package/src/screens/liveness/SelfiePreview.tsx +31 -7
  190. package/src/screens/liveness/ShutterFlash.tsx +25 -0
  191. package/src/screens/liveness/index.ts +4 -0
  192. package/src/screens/liveness/useSelfieUpload.ts +23 -3
  193. package/src/screens/nfc/NfcScanIllustration.tsx +7 -18
  194. package/src/screens/useAddressPhotoAttach.ts +77 -0
  195. package/src/screens/useBusinessDocumentAttach.ts +93 -0
  196. package/src/screens/usePoaAttach.ts +12 -15
  197. package/src/services/api-types.ts +119 -1
  198. package/src/services/api-verify-types.ts +37 -0
  199. package/src/services/api.ts +149 -3
  200. package/src/services/deviceMetadata.ts +1 -1
  201. package/src/services/location.ts +234 -0
  202. package/src/services/mediaCompress.ts +23 -5
  203. package/src/services/uploadErrors.ts +30 -0
  204. package/src/services/workflowGate.ts +7 -0
  205. package/src/specs/MyazaTextRecognizer.nitro.ts +29 -0
  206. package/src/store/address.ts +97 -0
  207. package/src/store/derive.ts +29 -4
  208. package/src/store/kycStore.ts +120 -5
  209. package/src/store/serverConfig.ts +18 -0
  210. package/src/store/session.ts +23 -4
  211. package/src/store/state.ts +134 -0
  212. package/src/store/submit.ts +18 -1
  213. package/src/types/config.ts +63 -1
  214. package/src/types/verification.ts +12 -0
  215. package/src/types/workflow.ts +97 -1
  216. package/src/assets/liveness/Blink.gif +0 -0
  217. package/src/assets/liveness/Nod.gif +0 -0
  218. package/src/assets/liveness/Smile.gif +0 -0
  219. package/src/assets/liveness/Turn.gif +0 -0
@@ -1,13 +1,19 @@
1
1
  import { SDK_VERSION } from './deviceMetadata';
2
2
  import type {
3
+ AddressReverseResult,
4
+ AddressSearchHit,
3
5
  BusinessRegionsResponse,
4
6
  BusinessSearchResponse,
5
7
  BusinessSelectResponse,
6
8
  ContactCheckResponse,
9
+ DocumentCaptureCheckRequest,
10
+ DocumentCaptureCheckResponse,
7
11
  NfcChallengeResponse,
8
12
  ContactSendResponse,
9
13
  HealthResponse,
10
14
  MediaUploadType,
15
+ PlaceSuggestion,
16
+ ResolvedPlace,
11
17
  SdkConfigResponse,
12
18
  SessionStartResponse,
13
19
  SessionSummaryResponse,
@@ -70,9 +76,14 @@ function uriToBlob(uri: string, mimeType: string): Promise<Blob> {
70
76
  xhr.onload = () => {
71
77
  const blob = xhr.response as Blob | null;
72
78
  if (blob) {
73
- // Ensure the part carries the right content-type even if the platform
74
- // didn't infer one from the URI.
75
- resolve(blob.type ? blob : blob.slice(0, blob.size, mimeType));
79
+ // ALWAYS re-type the part, never only when the platform inferred
80
+ // nothing. Its guess comes from the URI's extension and describes the
81
+ // file we PICKED, not the one we are sending: an iPhone gallery photo
82
+ // arrives as HEIC, gets transcoded to JPEG on the way here, and the
83
+ // part still went out labelled image/heic — which the server refuses,
84
+ // so most camera-roll uploads failed with a message that blamed the
85
+ // document. `mimeType` is the normalised type the bytes actually are.
86
+ resolve(blob.type === mimeType ? blob : blob.slice(0, blob.size, mimeType));
76
87
  } else {
77
88
  reject(new Error(`Could not read file at ${uri}`));
78
89
  }
@@ -197,6 +208,23 @@ export function createKYCApi(baseUrl: string, apiKey: string) {
197
208
  return mediaId;
198
209
  },
199
210
 
211
+ /**
212
+ * Ask whether an uploaded document side will be readable: a face on the
213
+ * printed photo, a barcode that decodes. Best-effort by contract at the
214
+ * call site (lib/documentCaptureCheck runCaptureChecks): a failure or a
215
+ * timeout reads as "no problem" and never blocks the flow.
216
+ */
217
+ async checkDocumentCapture(
218
+ body: DocumentCaptureCheckRequest,
219
+ signal?: AbortSignal,
220
+ ): Promise<DocumentCaptureCheckResponse> {
221
+ return request<DocumentCaptureCheckResponse>('/document-capture/check', {
222
+ method: 'POST',
223
+ body: JSON.stringify(body),
224
+ ...(signal ? { signal } : {}),
225
+ });
226
+ },
227
+
200
228
  async verify(body: VerifyRequest): Promise<VerifyResponse> {
201
229
  return request<VerifyResponse>('/verify', {
202
230
  method: 'POST',
@@ -279,6 +307,10 @@ export function createKYCApi(baseUrl: string, apiKey: string) {
279
307
  workflowId?: string;
280
308
  /** Persistent device id — the anonymous-mount resume fallback. */
281
309
  deviceRef?: string;
310
+ /** The same device block the submission sends, so the dashboard's
311
+ * in-progress row shows the device and SDK from the moment the SDK
312
+ * loads rather than after the applicant finishes (2026-09-08). */
313
+ device?: Record<string, unknown>;
282
314
  }): Promise<SessionStartResponse> {
283
315
  return request<SessionStartResponse>('/session/start', {
284
316
  method: 'POST',
@@ -354,6 +386,120 @@ export function createKYCApi(baseUrl: string, apiKey: string) {
354
386
  });
355
387
  },
356
388
 
389
+ /**
390
+ * Forward address search for the address step's search box.
391
+ *
392
+ * EXPLICIT SUBMIT ONLY — never call this per keystroke. The server's map
393
+ * source forbids autocomplete, and the request budget has to be spent on
394
+ * the query the person actually meant rather than on every prefix of it.
395
+ */
396
+ async addressSearch(
397
+ query: string,
398
+ country?: string | null,
399
+ ): Promise<{ results: AddressSearchHit[] }> {
400
+ const qs = new URLSearchParams({ q: query });
401
+ if (country) qs.set('country', country);
402
+ return request<{ results: AddressSearchHit[] }>(`/address/search?${qs.toString()}`);
403
+ },
404
+
405
+ /**
406
+ * The framed Street View entrance, as an image SOURCE the review card can
407
+ * hand straight to <Image>.
408
+ *
409
+ * The browser key never reaches this SDK (the framed page holds it), so
410
+ * the picture comes through the server, which does. Returned as a URL plus
411
+ * the auth header rather than fetched bytes: React Native's Image carries
412
+ * headers itself, which keeps Blob, FileReader and base64 out of a path
413
+ * that only has to draw a thumbnail. Mirrors the web SDK's
414
+ * addressStreetViewPreview, which returns a Blob because a browser <img>
415
+ * cannot send an Authorization header.
416
+ */
417
+ /**
418
+ * The pinned location as a PICTURE, for the review card.
419
+ *
420
+ * A confirmation screen wants a photograph of the place, not a second
421
+ * instrument: a live map there invites a drag that goes nowhere and
422
+ * carries the vendor's own controls over the SDK's chrome. Same shape as
423
+ * the Street View source above, and the same reason for it: the key lives
424
+ * on the server. lib/authed-image fetches it with the header — an <Image>
425
+ * given `source.headers` drops them on Android.
426
+ */
427
+ staticMapSource(view: {
428
+ lat: number;
429
+ lng: number;
430
+ zoom?: number;
431
+ width?: number;
432
+ height?: number;
433
+ }): { uri: string; headers: Record<string, string> } {
434
+ const qs = new URLSearchParams({
435
+ lat: String(view.lat),
436
+ lng: String(view.lng),
437
+ zoom: String(view.zoom ?? 16),
438
+ width: String(Math.round(view.width ?? 640)),
439
+ height: String(Math.round(view.height ?? 360)),
440
+ });
441
+ return { uri: `${base}/address/static-map?${qs.toString()}`, headers };
442
+ },
443
+
444
+ streetViewPreviewSource(frame: {
445
+ panoId: string;
446
+ heading: number;
447
+ pitch: number;
448
+ fov: number;
449
+ }): { uri: string; headers: Record<string, string> } {
450
+ const qs = new URLSearchParams({
451
+ panoId: frame.panoId,
452
+ heading: String(frame.heading),
453
+ pitch: String(frame.pitch),
454
+ fov: String(frame.fov),
455
+ });
456
+ return {
457
+ uri: `${base}/address/street-view-preview?${qs.toString()}`,
458
+ headers,
459
+ };
460
+ },
461
+
462
+ /** The street line for a pin, for the summary card after a locate or a
463
+ * drag. Display only — it never decides anything. */
464
+ async addressReverse(lat: number, lng: number): Promise<AddressReverseResult> {
465
+ const qs = new URLSearchParams({ lat: String(lat), lng: String(lng) });
466
+ return request<AddressReverseResult>(`/address/reverse?${qs.toString()}`);
467
+ },
468
+
469
+ /**
470
+ * Places-backed as-you-type suggestions.
471
+ *
472
+ * `session` is ONE token per typing session: it is the billing unit, so
473
+ * Google bills per session rather than per keystroke. Mint it when the
474
+ * search screen opens, reuse it for every keystroke, and mint a fresh one
475
+ * after a details call — that call closes the session.
476
+ */
477
+ async addressAutocomplete(
478
+ query: string,
479
+ session: string,
480
+ country?: string | null,
481
+ near?: { lat: number; lng: number } | null,
482
+ ): Promise<{ suggestions: PlaceSuggestion[] }> {
483
+ const qs = new URLSearchParams({ q: query, session });
484
+ if (country) qs.set('country', country);
485
+ // The device fix, a RANKING bias so nearby streets come first: without
486
+ // it "Awolowo Road" in Calabar ranks against every Awolowo Road in the
487
+ // country. Mirrors the web SDK's api.addressAutocomplete.
488
+ if (near) {
489
+ qs.set('lat', String(near.lat));
490
+ qs.set('lng', String(near.lng));
491
+ }
492
+ return request<{ suggestions: PlaceSuggestion[] }>(`/address/autocomplete?${qs.toString()}`);
493
+ },
494
+
495
+ /** Resolve a picked suggestion to coordinates + structured pieces. */
496
+ async addressPlace(placeId: string, session: string): Promise<{ place: ResolvedPlace }> {
497
+ const qs = new URLSearchParams({ session });
498
+ return request<{ place: ResolvedPlace }>(
499
+ `/address/place/${encodeURIComponent(placeId)}?${qs.toString()}`,
500
+ );
501
+ },
502
+
357
503
  async health(): Promise<HealthResponse> {
358
504
  // Public endpoint — no auth needed, but the shared headers are harmless.
359
505
  return request<HealthResponse>('/health');
@@ -15,7 +15,7 @@ export type DeviceType = 'mobile' | 'tablet' | 'desktop' | 'unknown';
15
15
  * Single source of truth for the SDK version — also used by `services/api.ts`
16
16
  * for the `X-SDK-Version` header. Keep in sync with `package.json`.
17
17
  */
18
- export const SDK_VERSION = '2.5.0';
18
+ export const SDK_VERSION = '3.0.0';
19
19
 
20
20
  export interface ReactNativeDeviceMetadata {
21
21
  sdkType: 'react-native';
@@ -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
+ }
@@ -1,4 +1,3 @@
1
- import { Image } from 'react-native';
2
1
  import * as ImageManipulator from 'expo-image-manipulator';
3
2
  import { Video } from 'react-native-compressor';
4
3
 
@@ -41,10 +40,29 @@ export async function compressVideo(uri: string): Promise<string> {
41
40
  // • SELFIE — moderate: JPEG q0.8, capped to ~1280 px.
42
41
  // Runs natively via expo-image-manipulator (off the JS thread).
43
42
 
44
- export function imageSize(uri: string): Promise<{ width: number; height: number }> {
45
- return new Promise((resolve, reject) =>
46
- Image.getSize(uri, (width, height) => resolve({ width, height }), reject),
47
- );
43
+ /**
44
+ * The image's TRUE pixel dimensions.
45
+ *
46
+ * Measured through expo-image-manipulator — the same native module that does
47
+ * the cropping — because the two must agree, and they did not.
48
+ *
49
+ * This used `Image.getSize`, which on Android reports DP (pixels ÷ display
50
+ * density), while ImageManipulator crops in real pixels. On a density-2 phone
51
+ * a 3048x4064 photo measured 1524x2032, so a crop rect computed as "centred"
52
+ * was applied at half scale and landed in the UPPER-LEFT QUADRANT of the real
53
+ * image. Every Android document capture cropped the wrong region — the card
54
+ * the applicant framed was simply not in the stored photo. iOS was unaffected
55
+ * (getSize returns pixels there), which is how an iPhone-led test history
56
+ * never saw it. Found on a TECNO KM5 (density 320 → scale 2.0), 2026-09-20:
57
+ * reported 1524x2032 against a true 3048x4064, a factor of exactly 2.
58
+ *
59
+ * Correcting by `PixelRatio.get()` would also work on today's devices, but it
60
+ * re-states the cropper's units somewhere else and leaves the same class of
61
+ * bug one refactor away. Asking the cropper itself cannot drift from it.
62
+ */
63
+ export async function imageSize(uri: string): Promise<{ width: number; height: number }> {
64
+ const ref = await ImageManipulator.ImageManipulator.manipulate(uri).renderAsync();
65
+ return { width: ref.width, height: ref.height };
48
66
  }
49
67
 
50
68
  /**
@@ -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
  },
@@ -45,4 +45,33 @@ export interface MyazaTextRecognizer extends HybridObject<{ ios: 'swift'; androi
45
45
  * crop as the frame version.
46
46
  */
47
47
  recognizeTextInImage(uri: string, bottomFraction: number): Promise<TextResult>;
48
+
49
+ /**
50
+ * Whether the recogniser can run RIGHT NOW.
51
+ *
52
+ * Android fetches ML Kit's text model through Google Play Services rather
53
+ * than bundling it (see android/build.gradle), so there is a window — first
54
+ * launch, or a device with no GMS at all — where recognition cannot work.
55
+ * This MUST be checked before the camera opens: `recognizeText` can only
56
+ * answer in `TextResult`, where "model missing" and "no text in frame" are
57
+ * both an empty `lines` array, so relying on it would leave auto-capture
58
+ * silently never firing and the MRZ never producing a chip key, with nothing
59
+ * shown to explain why.
60
+ *
61
+ * iOS is always true — Apple Vision is a system framework with nothing to
62
+ * fetch.
63
+ */
64
+ isModelReady(): boolean;
65
+
66
+ /**
67
+ * Ask Play Services to download the model, resolving `true` once it is usable
68
+ * and `false` if it cannot be obtained (no GMS, no network, user declined).
69
+ *
70
+ * Call this EARLY — the SDK primes it at flow start, so the download overlaps
71
+ * the consent and ID-type screens and the model is warm by the time the
72
+ * document step runs.
73
+ *
74
+ * Safe to call repeatedly; resolves immediately when already ready.
75
+ */
76
+ prepareModel(): Promise<boolean>;
48
77
  }
@@ -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
+ }