@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.
- package/README.md +277 -7
- package/android/build.gradle +30 -16
- package/android/consumer-rules.pro +22 -0
- package/android/src/main/AndroidManifest.xml +17 -6
- package/android/src/main/java/com/margelo/nitro/myazakyc/HybridMyazaFaceDetector.kt +27 -70
- package/android/src/main/java/com/margelo/nitro/myazakyc/HybridMyazaTextRecognizer.kt +14 -0
- package/android/src/main/java/com/margelo/nitro/myazakyc/MlKitModelReadiness.kt +154 -0
- package/app.plugin.js +146 -10
- package/ios/HybridMyazaFaceDetector.swift +8 -3
- package/ios/HybridMyazaTextRecognizer.swift +8 -0
- package/nitrogen/generated/android/c++/JHybridMyazaTextRecognizerSpec.cpp +21 -0
- package/nitrogen/generated/android/c++/JHybridMyazaTextRecognizerSpec.hpp +2 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/myazakyc/HybridMyazaTextRecognizerSpec.kt +8 -0
- package/nitrogen/generated/ios/c++/HybridMyazaTextRecognizerSpecSwift.hpp +16 -0
- package/nitrogen/generated/ios/swift/HybridMyazaTextRecognizerSpec.swift +2 -0
- package/nitrogen/generated/ios/swift/HybridMyazaTextRecognizerSpec_cxx.swift +31 -0
- package/nitrogen/generated/shared/c++/HybridMyazaTextRecognizerSpec.cpp +2 -0
- package/nitrogen/generated/shared/c++/HybridMyazaTextRecognizerSpec.hpp +2 -0
- package/package.json +30 -9
- package/src/MyazaKYC.tsx +4 -11
- package/src/assets/fonts/Karla_400Regular.ttf +0 -0
- package/src/assets/fonts/Karla_500Medium.ttf +0 -0
- package/src/assets/fonts/Karla_600SemiBold.ttf +0 -0
- package/src/assets/fonts/Karla_700Bold.ttf +0 -0
- package/src/assets/fonts/OFL-Karla.txt +93 -0
- package/src/assets/fonts/OFL-SpaceGrotesk.txt +93 -0
- package/src/assets/fonts/README.md +12 -0
- package/src/assets/fonts/SpaceGrotesk_500Medium.ttf +0 -0
- package/src/assets/fonts/SpaceGrotesk_600SemiBold.ttf +0 -0
- package/src/assets/fonts/SpaceGrotesk_700Bold.ttf +0 -0
- package/src/capture/useAutoCapture.ts +8 -1
- package/src/components/CountryField.tsx +8 -0
- package/src/components/CountryRegionPicker.tsx +184 -0
- package/src/components/DialCodePicker.tsx +47 -68
- package/src/components/DialCodeRow.tsx +102 -0
- package/src/components/DocumentCropper.tsx +30 -24
- package/src/components/DocumentReview.tsx +11 -1
- package/src/components/DocumentReviewSide.tsx +6 -2
- package/src/components/DocumentReviewZoom.tsx +5 -1
- package/src/components/FramedMapPicker.tsx +122 -0
- package/src/components/GeoBadge.tsx +34 -0
- package/src/components/Icon.tsx +10 -192
- package/src/components/KycFlow.tsx +69 -34
- package/src/components/LineSkeleton.tsx +100 -0
- package/src/components/MapChrome.tsx +67 -0
- package/src/components/MapPinMarker.tsx +54 -0
- package/src/components/MapPinPicker.tsx +179 -0
- package/src/components/MilestoneTrack.tsx +155 -0
- package/src/components/MyazaButton.tsx +9 -0
- package/src/components/MyazaInput.tsx +17 -1
- package/src/components/OptionRow.tsx +22 -3
- package/src/components/PhoneNumberInput.tsx +8 -0
- package/src/components/PoweredBy.tsx +5 -5
- package/src/components/PresenceBlocks.tsx +153 -0
- package/src/components/RequiredDocumentPill.tsx +114 -0
- package/src/components/StepHeader.tsx +23 -3
- package/src/components/StepView.tsx +17 -0
- package/src/components/StickyActions.tsx +56 -0
- package/src/components/dialCodeRows.ts +64 -0
- package/src/components/documentReviewCopy.ts +39 -0
- package/src/components/fonts.ts +14 -17
- package/src/components/icon-map.ts +176 -0
- package/src/components/stepHeaderMeta.tsx +48 -5
- package/src/config/addressCollection.ts +125 -0
- package/src/config/biometricOptions.ts +100 -0
- package/src/config/business.ts +20 -1
- package/src/config/businessSteps.ts +8 -2
- package/src/config/consentStep.ts +19 -0
- package/src/config/documentCaptureMethods.ts +31 -0
- package/src/config/proofOfAddress.ts +54 -11
- package/src/config/regions.ts +24 -0
- package/src/config/stepOrder.ts +79 -5
- package/src/config/uploadLimits.ts +35 -0
- package/src/config/workflowMerge.ts +13 -5
- package/src/index.ts +49 -1
- package/src/lib/address-current-location.ts +171 -0
- package/src/lib/address-field-modes.ts +119 -0
- package/src/lib/address-flow.ts +172 -0
- package/src/lib/address-helpers.ts +48 -0
- package/src/lib/address-line.ts +76 -0
- package/src/lib/address-pin-move.ts +97 -0
- package/src/lib/address-step-recovery.ts +63 -0
- package/src/lib/authed-image.ts +78 -0
- package/src/lib/biometric-copy.ts +51 -0
- package/src/lib/captureRing.ts +83 -0
- package/src/lib/country-adoption.ts +89 -0
- package/src/lib/documentCaptureCheck.ts +136 -0
- package/src/lib/inferred-country.ts +65 -0
- package/src/lib/livenessLayout.ts +49 -0
- package/src/lib/map-frame.ts +159 -0
- package/src/lib/map-tiles.ts +143 -0
- package/src/lib/model-ready.ts +82 -0
- package/src/lib/poa-country-gate.ts +27 -0
- package/src/lib/prime-models.ts +50 -0
- package/src/lib/resubmit.ts +36 -7
- package/src/lib/result-copy.ts +116 -0
- package/src/lib/result-wait.ts +53 -0
- package/src/lib/review-map-surface.ts +26 -0
- package/src/lib/scope.ts +31 -0
- package/src/lib/selfie-sharpness.ts +211 -0
- package/src/lib/selfie-upload-wait.ts +79 -0
- package/src/lib/street-view-fov.ts +42 -0
- package/src/lib/webview-available.ts +47 -0
- package/src/liveness/avatarSource.ts +59 -0
- package/src/liveness/useLiveness.ts +6 -1
- package/src/liveness/useModelReady.ts +9 -55
- package/src/mrz/extract.ts +71 -4
- package/src/mrz/textRecognizer.ts +36 -0
- package/src/mrz/useTextModelReady.ts +21 -0
- package/src/presence/background-math.ts +100 -0
- package/src/presence/background-store.ts +82 -0
- package/src/presence/background.ts +165 -0
- package/src/presence/foreground-service.ts +193 -0
- package/src/presence/fs.ts +51 -0
- package/src/presence/geofence.ts +41 -0
- package/src/presence/math.ts +44 -0
- package/src/presence/post.ts +40 -0
- package/src/presence/report.ts +87 -0
- package/src/presence/sampler.ts +110 -0
- package/src/presence/status.ts +92 -0
- package/src/presence/store.ts +94 -0
- package/src/presence/tier.ts +36 -0
- package/src/presence/watch-wait.ts +81 -0
- package/src/screens/AddressCountryControl.tsx +115 -0
- package/src/screens/BusinessDetailsFields.tsx +3 -0
- package/src/screens/BusinessDetailsStep.tsx +2 -0
- package/src/screens/BusinessDocumentSlot.tsx +3 -2
- package/src/screens/BusinessDocumentsStep.tsx +4 -55
- package/src/screens/CompanyInfoFields.tsx +6 -1
- package/src/screens/ConsentStep.tsx +6 -17
- package/src/screens/ContactActions.tsx +54 -0
- package/src/screens/ContactDestinationField.tsx +4 -0
- package/src/screens/ContactEntryPanel.tsx +75 -0
- package/src/screens/ContactVerificationStep.tsx +26 -56
- package/src/screens/CountrySelectStep.tsx +13 -147
- package/src/screens/DocumentCaptureStep.tsx +177 -93
- package/src/screens/LivenessAvatar.tsx +33 -16
- package/src/screens/LivenessStep.tsx +188 -49
- package/src/screens/MrzScanView.tsx +33 -0
- package/src/screens/PoaDocumentTypeList.tsx +66 -0
- package/src/screens/ProofOfAddressParts.tsx +151 -0
- package/src/screens/ProofOfAddressStep.tsx +58 -99
- package/src/screens/SubmittedBadge.tsx +25 -0
- package/src/screens/SubmittedError.tsx +64 -0
- package/src/screens/SubmittedResult.tsx +117 -0
- package/src/screens/SubmittedStep.tsx +71 -164
- package/src/screens/SubmittedSuccess.tsx +127 -0
- package/src/screens/SubmittedWaiting.tsx +45 -0
- package/src/screens/address/AddressEntranceStep.tsx +161 -0
- package/src/screens/address/AddressIntroGate.tsx +148 -0
- package/src/screens/address/AddressMapStub.tsx +50 -0
- package/src/screens/address/AddressPinStep.tsx +178 -0
- package/src/screens/address/AddressReviewStep.tsx +162 -0
- package/src/screens/address/AddressSandboxTabs.tsx +158 -0
- package/src/screens/address/AddressSearchField.tsx +115 -0
- package/src/screens/address/AddressSearchStep.tsx +94 -0
- package/src/screens/address/CurrentLocationRow.tsx +135 -0
- package/src/screens/address/DetailsSheet.tsx +175 -0
- package/src/screens/address/DetailsSheetFields.tsx +184 -0
- package/src/screens/address/EntranceDropzone.tsx +178 -0
- package/src/screens/address/EntranceFraming.tsx +66 -0
- package/src/screens/address/EntrancePills.tsx +60 -0
- package/src/screens/address/FramedStreetView.tsx +155 -0
- package/src/screens/address/IntroDisclosures.tsx +210 -0
- package/src/screens/address/LabelDecisionRow.tsx +113 -0
- package/src/screens/address/PinSummaryRow.tsx +105 -0
- package/src/screens/address/ReviewAddressBand.tsx +111 -0
- package/src/screens/address/ReviewEntranceThumbs.tsx +64 -0
- package/src/screens/address/ReviewMapPicture.tsx +106 -0
- package/src/screens/address/SearchResults.tsx +105 -0
- package/src/screens/address/SearchScreen.tsx +176 -0
- package/src/screens/address/SkipForNow.tsx +43 -0
- package/src/screens/address/StreetViewChrome.tsx +81 -0
- package/src/screens/address/detail-values.ts +22 -0
- package/src/screens/address/fix-source.ts +27 -0
- package/src/screens/address/index.ts +8 -0
- package/src/screens/address/meta.ts +44 -0
- package/src/screens/address/use-address-flow.ts +200 -0
- package/src/screens/address/use-label-pin.ts +80 -0
- package/src/screens/address/use-pin-actions.ts +192 -0
- package/src/screens/consent/model.ts +79 -10
- package/src/screens/document/CaptureCheckNotice.tsx +77 -0
- package/src/screens/document/UploadPhase.tsx +177 -0
- package/src/screens/document/useDocumentCamera.ts +109 -0
- package/src/screens/liveness/CaptureRing.tsx +91 -0
- package/src/screens/liveness/LivenessCamera.tsx +63 -0
- package/src/screens/liveness/LivenessHandover.tsx +43 -0
- package/src/screens/liveness/LivenessOutcome.tsx +8 -4
- package/src/screens/liveness/SelfiePreview.tsx +31 -7
- package/src/screens/liveness/ShutterFlash.tsx +25 -0
- package/src/screens/liveness/index.ts +4 -0
- package/src/screens/liveness/useSelfieUpload.ts +23 -3
- package/src/screens/nfc/NfcScanIllustration.tsx +7 -18
- package/src/screens/useAddressPhotoAttach.ts +77 -0
- package/src/screens/useBusinessDocumentAttach.ts +93 -0
- package/src/screens/usePoaAttach.ts +12 -15
- package/src/services/api-types.ts +119 -1
- package/src/services/api-verify-types.ts +37 -0
- package/src/services/api.ts +149 -3
- package/src/services/deviceMetadata.ts +1 -1
- package/src/services/location.ts +234 -0
- package/src/services/mediaCompress.ts +23 -5
- package/src/services/uploadErrors.ts +30 -0
- package/src/services/workflowGate.ts +7 -0
- package/src/specs/MyazaTextRecognizer.nitro.ts +29 -0
- package/src/store/address.ts +97 -0
- package/src/store/derive.ts +29 -4
- package/src/store/kycStore.ts +120 -5
- package/src/store/serverConfig.ts +18 -0
- package/src/store/session.ts +23 -4
- package/src/store/state.ts +134 -0
- package/src/store/submit.ts +18 -1
- package/src/types/config.ts +63 -1
- package/src/types/verification.ts +12 -0
- package/src/types/workflow.ts +97 -1
- package/src/assets/liveness/Blink.gif +0 -0
- package/src/assets/liveness/Nod.gif +0 -0
- package/src/assets/liveness/Smile.gif +0 -0
- package/src/assets/liveness/Turn.gif +0 -0
package/src/services/api.ts
CHANGED
|
@@ -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
|
-
//
|
|
74
|
-
//
|
|
75
|
-
|
|
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 = '
|
|
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
|
-
|
|
45
|
-
|
|
46
|
-
|
|
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
|
+
}
|