@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,52 @@
1
+ import { KYCApiError } from '../services/api';
2
+ import { KYCError } from '../types/verification';
3
+ import type { BiometricAuthResponse } from '../services/api-types-biometric';
4
+
5
+ // ---------------------------------------------------------------------------
6
+ // Biometric re-authentication — the pure half. Mirrors the web SDK's
7
+ // MyazaBiometricAuth error mapping and outcome shapes (keep in lockstep).
8
+ // ---------------------------------------------------------------------------
9
+
10
+ export type BiometricAuthOutcome =
11
+ | { kind: 'success'; result: BiometricAuthResponse }
12
+ | { kind: 'failed'; result: BiometricAuthResponse }
13
+ | { kind: 'error'; message: string };
14
+
15
+ export interface BiometricAuthenticated {
16
+ attemptId: string;
17
+ confidence: number | null;
18
+ /** Single-use proof; verify from your backend at `/biometric/verify-proof`. */
19
+ token?: string;
20
+ }
21
+
22
+ export interface BiometricAuthFailed {
23
+ status: BiometricAuthResponse['status'];
24
+ attemptId: string;
25
+ confidence: number | null;
26
+ }
27
+
28
+ /** A technical failure, in the SDK's typed vocabulary + a message a person
29
+ * can read. Never the server's own wording — that is an internal contract. */
30
+ export function mapAuthError(err: unknown): KYCError {
31
+ if (err instanceof KYCApiError) {
32
+ if (err.statusCode === 404 && err.code === 'not_enrolled') {
33
+ return new KYCError('unknown', "You're not set up for face verification yet.");
34
+ }
35
+ if (err.statusCode === 402) {
36
+ return new KYCError('insufficient_credits', 'Face verification is temporarily unavailable.');
37
+ }
38
+ if (err.statusCode === 401 || err.statusCode === 403) {
39
+ return new KYCError('invalid_api_key', 'This app is not authorised for face verification.');
40
+ }
41
+ }
42
+ return new KYCError('network_error', 'Something went wrong. Please try again.');
43
+ }
44
+
45
+ /** The verdict as the callbacks and the result screen read it. */
46
+ export function outcomeOf(result: BiometricAuthResponse): BiometricAuthOutcome {
47
+ return result.authenticated ? { kind: 'success', result } : { kind: 'failed', result };
48
+ }
49
+
50
+ export function defaultReauthLabel(companyName?: string): string {
51
+ return companyName ? `Verify it's you with ${companyName}` : "Verify it's you";
52
+ }
@@ -0,0 +1,51 @@
1
+ import { configScope } from './scope';
2
+ import { fillTokens } from '../utils/tokens';
3
+ import type { BiometricCopy, BiometricCopyText } from '../config/biometricOptions';
4
+ import type { MyazaKYCConfig } from '../types/config';
5
+
6
+ // ─── The org's copy on the biometric screens, ready to render ───────────────
7
+ //
8
+ // Pure. Resolves `config.biometric.copy` for the screens: tokens filled from
9
+ // `userData` exactly as the consent and success copy are, a field that empties
10
+ // out once its tokens resolve treated as absent (the default shows rather than
11
+ // a blank title), and everything null off the biometric scopes, where the
12
+ // block never applies. The screens hand the result to describeWaiting /
13
+ // describeOutcome (lib/result-copy.ts) as overrides. Mirrors the web SDK's
14
+ // lib/biometric-copy.ts and Flutter's config/biometric_copy.dart; keep the
15
+ // three in lockstep.
16
+
17
+ export interface ResolvedBiometricCopy {
18
+ waiting: BiometricCopyText | null;
19
+ verified: BiometricCopyText | null;
20
+ declined: BiometricCopyText | null;
21
+ }
22
+
23
+ export const NO_BIOMETRIC_COPY: ResolvedBiometricCopy = { waiting: null, verified: null, declined: null };
24
+
25
+ type CopyConfigLike = {
26
+ scope?: string;
27
+ biometric?: { copy?: BiometricCopy } | null;
28
+ userData?: MyazaKYCConfig['userData'];
29
+ };
30
+
31
+ export function biometricCopyFor(config: CopyConfigLike): ResolvedBiometricCopy {
32
+ const scope = configScope(config);
33
+ if (scope !== 'biometric-authentication' && scope !== 'biometric-enrollment') return NO_BIOMETRIC_COPY;
34
+ const copy = config.biometric?.copy;
35
+ if (!copy) return NO_BIOMETRIC_COPY;
36
+ const fill = (text: BiometricCopyText | undefined): BiometricCopyText | null => {
37
+ if (!text) return null;
38
+ const title = text.title ? fillTokens(text.title, config.userData) : '';
39
+ const description = text.description ? fillTokens(text.description, config.userData) : '';
40
+ const out: BiometricCopyText = {};
41
+ if (title) out.title = title;
42
+ if (description) out.description = description;
43
+ return Object.keys(out).length > 0 ? out : null;
44
+ };
45
+ return {
46
+ waiting: fill(copy.waiting),
47
+ // Enrolment shows no verdict, so a stray key there has no screen to land on.
48
+ verified: scope === 'biometric-authentication' ? fill(copy.verified) : null,
49
+ declined: scope === 'biometric-authentication' ? fill(copy.declined) : null,
50
+ };
51
+ }
@@ -0,0 +1,83 @@
1
+ import type { LivenessPhase } from '../liveness/types';
2
+
3
+ // The liveness ring's arithmetic — a MIRROR of the web SDK's
4
+ // components/CaptureRing.tsx and hooks/useLiveness.ts updateLivenessProgress
5
+ // (and of the Flutter SDK's liveness/capture_ring.dart). Change a rule in one
6
+ // and change it in all three in the same commit.
7
+ //
8
+ // Two numbers. The TARGET is where the test has actually got to and only ever
9
+ // moves forward. The DISPLAY eases toward it on real elapsed time, critically
10
+ // damped, so a gesture landing (a whole segment in one go) and the detector's
11
+ // own frame rate both arrive as motion rather than cuts.
12
+
13
+ /** Time constant of the display's approach, seconds. ~95% of a gap in three. */
14
+ export const TAU = 0.09;
15
+
16
+ /** How far a challenge segment may fill on its clock alone: time running out
17
+ * is not progress, so the clock never completes a segment — the gesture
18
+ * landing does. */
19
+ export const CHALLENGE_SEGMENT_CAP = 0.85;
20
+
21
+ /** The native still takes a beat after `capturing` begins; its segment fills
22
+ * across this window. It never CLOSES on it: the clock is capped like a
23
+ * challenge's, and only `complete` (the still in hand) reaches 1. On this
24
+ * SDK the still is settle → native photo → compress, seconds on a slow
25
+ * phone, and the ring used to sit closed for all of it. */
26
+ export const CAPTURE_WINDOW_SEC = 0.4;
27
+
28
+ const clamp01 = (n: number): number => (n < 0 ? 0 : n > 1 ? 1 : Number.isFinite(n) ? n : 0);
29
+
30
+ export function advanceTarget(target: number, real: number): number {
31
+ return Math.max(target, clamp01(real));
32
+ }
33
+
34
+ export function easeToward(shown: number, target: number, dt: number): number {
35
+ if (dt <= 0) return shown;
36
+ return shown + (target - shown) * (1 - Math.exp(-dt / TAU));
37
+ }
38
+
39
+ export interface LivenessProgressInput {
40
+ phase: LivenessPhase;
41
+ /** Steps passed so far. On this SDK the count is bumped at
42
+ * `challenge_passed` — read off the hook, not assumed. */
43
+ completedCount: number;
44
+ /** Every step the user performs: the gestures, plus the flash when it runs. */
45
+ totalCount: number;
46
+ /** Seconds since the current phase began. */
47
+ elapsedInPhase: number;
48
+ /** The running challenge's timeout, seconds. */
49
+ challengeTimeout: number;
50
+ }
51
+
52
+ /**
53
+ * Where the WHOLE test has got to, 0..1, in equal segments: positioning, each
54
+ * step, the capture. Each segment is measured by what actually gates it here —
55
+ * this SDK advances out of positioning the instant the face is framed and lit
56
+ * (no steady-frame counter), so that segment fills on the transition; a
57
+ * challenge fills on its clock; the capture fills across the still's window.
58
+ */
59
+ export function livenessProgress(s: LivenessProgressInput): number {
60
+ const seg = 1 / (s.totalCount + 2);
61
+ const done = s.completedCount; // steps whose segments are complete
62
+ switch (s.phase) {
63
+ case 'challenge':
64
+ case 'flash':
65
+ return seg * (1 + done + Math.min(CHALLENGE_SEGMENT_CAP, s.elapsedInPhase / Math.max(s.challengeTimeout, 1)));
66
+ case 'challenge_passed':
67
+ return seg * (1 + done + 0);
68
+ case 'capturing':
69
+ return seg * (1 + s.totalCount + Math.min(CHALLENGE_SEGMENT_CAP, s.elapsedInPhase / CAPTURE_WINDOW_SEC));
70
+ case 'complete':
71
+ return 1;
72
+ default:
73
+ return 0;
74
+ }
75
+ }
76
+
77
+ /** Linear mix of two #rrggbb colours, for the close-to-green beat. */
78
+ export function mixHex(a: string, b: string, t: number): string {
79
+ const k = clamp01(t);
80
+ const ch = (h: string, i: number) => parseInt(h.slice(i, i + 2), 16);
81
+ const out = [1, 3, 5].map((i) => Math.round(ch(a, i) + (ch(b, i) - ch(a, i)) * k));
82
+ return '#' + out.map((v) => v.toString(16).padStart(2, '0')).join('');
83
+ }
@@ -0,0 +1,89 @@
1
+ // ---------------------------------------------------------------------------
2
+ // Declared-country adoption.
3
+ //
4
+ // ONE table decides when evidence about where the applicant IS may change the
5
+ // country they are DECLARING: a reverse-geocoded pin or current-location fix
6
+ // (a geocode), or the address they picked from search (explicit). A mirror of
7
+ // the web SDK's steps/address/country-adoption.ts and Flutter's
8
+ // config/country_adoption.dart; the three run the shared vectors in
9
+ // kyc-sdk-flutter/test/country_adoption_vectors.json, so a drift in any one
10
+ // fails there.
11
+ //
12
+ // The rules, and why each exists:
13
+ // - GEOCODED EVIDENCE outranks every GUESS. The address scope's default is
14
+ // the IP country, and on a dev box that once declared US while the device
15
+ // sat in Calabar (the search returned California), so the fix's own
16
+ // geocode corrects a guess the moment it resolves.
17
+ // - An EXPLICIT declaration is never overridden by a geocode; a PICKED
18
+ // address replaces even that, since the pick is the applicant's newer and
19
+ // more specific statement, and is then itself explicit.
20
+ // - Outside the address scope `selectedCountry` is the ID-VERIFICATION
21
+ // country, and changing it resets the ID choice, so only a guessed value
22
+ // is ever corrected there, picked address or not.
23
+ // - The org's accepted list (proofOfAddress.countries) gates every path: a
24
+ // value the submission gate would refuse never becomes the declaration.
25
+ // ---------------------------------------------------------------------------
26
+
27
+ const ISO2 = /^[A-Z]{2}$/;
28
+
29
+ /** A trimmed, upper-cased ISO-2, or null for anything that is not one. */
30
+ export function normaliseIso2(raw: string | null | undefined): string | null {
31
+ const code = raw?.trim().toUpperCase();
32
+ return code && ISO2.test(code) ? code : null;
33
+ }
34
+
35
+ /** Whether the org's accepted-country list admits `code`. Null or empty
36
+ * accepts everyone. */
37
+ export function countryAccepted(accepted: readonly string[] | null | undefined, code: string): boolean {
38
+ if (!accepted?.length) return true;
39
+ return accepted.some((c) => c.trim().toUpperCase() === code);
40
+ }
41
+
42
+ export interface AdoptionInput {
43
+ /** The country the evidence named (a geocode's `parts.country`, a pick's own). */
44
+ country: string | null | undefined;
45
+ selectedCountry: string | null | undefined;
46
+ countryAutoPicked: boolean;
47
+ /** The workflow scope; null is a full verification. */
48
+ scope: string | null | undefined;
49
+ accepted: readonly string[] | null | undefined;
50
+ /** The country came from an address the applicant PICKED. */
51
+ explicit?: boolean;
52
+ }
53
+
54
+ export interface CountryAdoption {
55
+ /** The normalised ISO-2 to declare. */
56
+ country: string;
57
+ /** Declare it as a GUESS (later evidence may correct it) rather than as
58
+ * the applicant's own explicit statement. */
59
+ auto: boolean;
60
+ }
61
+
62
+ /** The adoption rule. See the file header for what each guard is for. */
63
+ export function adoptionDecision(input: AdoptionInput): CountryAdoption | null {
64
+ const code = normaliseIso2(input.country);
65
+ if (!code) return null;
66
+ const onAddressScope = input.scope === 'address';
67
+ const guessed = input.countryAutoPicked || (onAddressScope && input.selectedCountry == null);
68
+ if (!guessed && !(input.explicit && onAddressScope)) return null;
69
+ if (code === normaliseIso2(input.selectedCountry)) return null;
70
+ if (!countryAccepted(input.accepted, code)) return null;
71
+ return { country: code, auto: !input.explicit };
72
+ }
73
+
74
+ /**
75
+ * The address scope's default: the visitor's IP country, once, while nothing
76
+ * is declared, and only when the accepted list admits it. A full flow never
77
+ * defaults from the IP (there the country is the ID's).
78
+ */
79
+ export function geoDefaultCountry(input: {
80
+ geoCountry: string | null | undefined;
81
+ selectedCountry: string | null | undefined;
82
+ scope: string | null | undefined;
83
+ accepted: readonly string[] | null | undefined;
84
+ }): string | null {
85
+ if (input.scope !== 'address' || input.selectedCountry != null) return null;
86
+ const code = normaliseIso2(input.geoCountry);
87
+ if (!code || !countryAccepted(input.accepted, code)) return null;
88
+ return code;
89
+ }
@@ -0,0 +1,65 @@
1
+ import { tryRequire } from '../services/fingerprint-sources';
2
+
3
+ // ---------------------------------------------------------------------------
4
+ // The visitor's most likely country, for DEFAULTS only (never evidence). A
5
+ // mirror of the web SDK's lib/inferred-country.ts — keep the three in
6
+ // lockstep.
7
+ //
8
+ // Two tiers: the server's IP-derived `geoCountry` when it exists, else the
9
+ // device's own locale region (en-NG → NG). The second tier is what makes
10
+ // inference work where the IP cannot answer at all — a dev server on a
11
+ // loopback address, a carrier GeoLite2 cannot place — from a signal the
12
+ // device already carries. Both are guesses a person can correct; nothing
13
+ // recorded branches on them.
14
+ // ---------------------------------------------------------------------------
15
+
16
+ const ISO2 = /^[A-Z]{2}$/;
17
+
18
+ interface LocalizationLike {
19
+ getLocales?: () => Array<{ regionCode?: string | null; languageTag?: string | null }>;
20
+ }
21
+
22
+ /** The region subtag of a BCP-47 tag (`en-NG`, `en_NG`, `yo-Latn-NG`). The
23
+ * first subtag is the LANGUAGE ("en"), never a region, so it is skipped. */
24
+ export function regionOfLocaleTag(tag: string | null | undefined): string | null {
25
+ if (!tag) return null;
26
+ const [, ...rest] = tag.split(/[-_]/);
27
+ const region = rest.find((part) => /^[A-Za-z]{2}$/.test(part))?.toUpperCase();
28
+ return region && ISO2.test(region) ? region : null;
29
+ }
30
+
31
+ /** The device's locale tags, best first. expo-localization when the host
32
+ * installed it (it carries an explicit region), else the JS runtime's. */
33
+ export function deviceLocaleTags(): string[] {
34
+ const tags: string[] = [];
35
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
36
+ const localization = tryRequire<LocalizationLike>(() => require('expo-localization'));
37
+ try {
38
+ for (const locale of localization?.getLocales?.() ?? []) {
39
+ if (locale.regionCode) tags.push(`und-${locale.regionCode}`);
40
+ else if (locale.languageTag) tags.push(locale.languageTag);
41
+ }
42
+ } catch {
43
+ // A partial module; fall through to the runtime.
44
+ }
45
+ try {
46
+ const runtime = Intl.DateTimeFormat().resolvedOptions().locale;
47
+ if (runtime) tags.push(runtime);
48
+ } catch {
49
+ // No Intl on this runtime.
50
+ }
51
+ return tags;
52
+ }
53
+
54
+ export function inferredCountry(
55
+ geoCountry?: string | null,
56
+ localeTags: string[] = deviceLocaleTags(),
57
+ ): string | null {
58
+ const geo = geoCountry?.trim().toUpperCase();
59
+ if (geo && ISO2.test(geo)) return geo;
60
+ for (const tag of localeTags) {
61
+ const region = regionOfLocaleTag(tag);
62
+ if (region) return region;
63
+ }
64
+ return null;
65
+ }
@@ -0,0 +1,49 @@
1
+ import { spacing } from '../config/theme';
2
+
3
+ // ─── How big the selfie circle and the gesture avatar may be ────────────────
4
+ //
5
+ // The circle used to be sized from the window's WIDTH alone (capped at 300),
6
+ // which is the web SDK's rule and is right on a tall phone. On a Samsung S24
7
+ // (360×780 dp, 176 dp shorter than an iPhone 16 Pro Max) the width still gave
8
+ // 296, the circle owned most of the sheet, and the avatar demonstrating the
9
+ // gesture sat below the fold — the one thing the step exists to show. So the
10
+ // HEIGHT bounds it too: whatever the chrome, the instruction, the step dots,
11
+ // the avatar and the footer leave over is what the circle may take, and when
12
+ // it is the height that bound the circle, the avatar shrinks with it.
13
+ //
14
+ // Mirrored in Flutter's liveness/liveness_layout.dart; the real phones below
15
+ // are the shared vectors. Change a number in one and change both.
16
+
17
+ /** The web's phone circle is 256 and its desktop one 320; 300 is where this
18
+ * SDK has always capped, and the tall phones keep it. */
19
+ export const CIRCLE_MAX = 300;
20
+ /** A face is still usable at 200; below that the ring and the oval guide start
21
+ * to crowd it, so a very short screen scrolls a little instead. */
22
+ export const CIRCLE_MIN = 200;
23
+ /** Everything on the sheet that is NOT the circle, measured on device:
24
+ * banner + brand row + title + step bar (~250), the instruction line, the
25
+ * dots, the large avatar, three gaps, the step padding and the footer. */
26
+ export const CIRCLE_HEIGHT_BUDGET = 540;
27
+ /** The circle keeps the step's own side padding on both sides. */
28
+ export const CIRCLE_SIDE_GUTTER = spacing.md * 4;
29
+
30
+ const clamp = (n: number, lo: number, hi: number): number => Math.min(hi, Math.max(lo, n));
31
+
32
+ export interface LivenessLayout {
33
+ /** Diameter of the camera circle, dp. */
34
+ circle: number;
35
+ /** Diameter of the gesture avatar badge, dp. */
36
+ avatar: number;
37
+ /** The fallback icon inside the avatar when the GIF cannot load. */
38
+ avatarIcon: number;
39
+ }
40
+
41
+ export function livenessLayout(window: { width: number; height: number }): LivenessLayout {
42
+ const byWidth = window.width - CIRCLE_SIDE_GUTTER;
43
+ const byHeight = window.height - CIRCLE_HEIGHT_BUDGET;
44
+ const circle = clamp(Math.min(byWidth, byHeight, CIRCLE_MAX), CIRCLE_MIN, CIRCLE_MAX);
45
+ // Only a SHORT screen shrinks the avatar: a narrow one that is tall enough
46
+ // has the room for it whatever the width did to the circle.
47
+ const large = byHeight >= CIRCLE_MAX;
48
+ return { circle, avatar: large ? 96 : 72, avatarIcon: large ? 40 : 30 };
49
+ }
@@ -0,0 +1,159 @@
1
+ import type { LatLng } from './map-tiles';
2
+
3
+ // The framed Google-map picker's client half, for a WebView (the OkHi model):
4
+ // the SDK loads OUR hosted /embed/map page TOP-LEVEL in a WebView and talks to
5
+ // it over the WebView's own bridge. A mirror of the web SDK's lib/map-frame.ts
6
+ // and the Flutter SDK's config/map_frame.dart — keep the three in lockstep.
7
+ //
8
+ // Message shapes (add-only; the page mirrors them):
9
+ // page -> app: { source: 'myaza-map', type: 'ready' | 'failed' }
10
+ // { source: 'myaza-map', type: 'pin', lat, lng }
11
+ // app -> page: window.__myazaMapCommand(JSON of
12
+ // { source: 'myaza-sdk', type: 'center', lat, lng, zoom? })
13
+
14
+ export const MAP_FRAME_SOURCE = 'myaza-map';
15
+ export const MAP_PARENT_SOURCE = 'myaza-sdk';
16
+
17
+ /** How long to wait for `ready` before falling back to the OSM picker.
18
+ * Generous: the page loads Google's script on a cold cache. */
19
+ export const MAP_FRAME_READY_TIMEOUT_MS = 8000;
20
+
21
+ export interface MapFrameOptions {
22
+ center: LatLng;
23
+ zoom: number;
24
+ /** Recentre-and-zoom-in when the app already holds a pin. */
25
+ hasPin: boolean;
26
+ theme?: 'light' | 'dark';
27
+ primaryColor?: string;
28
+ }
29
+
30
+ /** The full page URL: the server-minted frame URL (which already carries the
31
+ * signed APP grant and `mode=app`) plus the render-time parameters. There is
32
+ * no `origin` — an app has none, and the page proves that instead. */
33
+ export function buildMapFrameSrc(frameUrl: string, opts: MapFrameOptions): string {
34
+ const url = new URL(frameUrl);
35
+ url.searchParams.set('lat', String(opts.center.lat));
36
+ url.searchParams.set('lng', String(opts.center.lng));
37
+ url.searchParams.set('zoom', String(opts.hasPin ? 16 : opts.zoom));
38
+ if (opts.theme) url.searchParams.set('theme', opts.theme);
39
+ if (opts.primaryColor) url.searchParams.set('primary', opts.primaryColor);
40
+ return url.toString();
41
+ }
42
+
43
+ export type MapFrameMessage =
44
+ | { type: 'ready' }
45
+ | { type: 'failed' }
46
+ | { type: 'pin'; lat: number; lng: number };
47
+
48
+ const finite = (v: unknown): v is number => typeof v === 'number' && Number.isFinite(v);
49
+
50
+ /** Validate-and-drop: the bridge delivers strings, so a JSON string is
51
+ * accepted too; anything not shaped exactly like a frame message is null. */
52
+ export function parseMapFrameMessage(data: unknown): MapFrameMessage | null {
53
+ let msg: unknown = data;
54
+ if (typeof data === 'string') {
55
+ try {
56
+ msg = JSON.parse(data);
57
+ } catch {
58
+ return null;
59
+ }
60
+ }
61
+ if (!msg || typeof msg !== 'object') return null;
62
+ const m = msg as Record<string, unknown>;
63
+ if (m.source !== MAP_FRAME_SOURCE) return null;
64
+ if (m.type === 'ready') return { type: 'ready' };
65
+ if (m.type === 'failed') return { type: 'failed' };
66
+ if (m.type === 'pin' && finite(m.lat) && finite(m.lng)) {
67
+ if (Math.abs(m.lat) > 90 || Math.abs(m.lng) > 180) return null;
68
+ return { type: 'pin', lat: m.lat, lng: m.lng };
69
+ }
70
+ return null;
71
+ }
72
+
73
+ /** The recentre command as the script the WebView injects (Use my location,
74
+ * restored progress). Ends in `true` because injected scripts must resolve to
75
+ * something serialisable on iOS. */
76
+ export function centerCommandScript(pin: LatLng): string {
77
+ const command = JSON.stringify({ source: MAP_PARENT_SOURCE, type: 'center', lat: pin.lat, lng: pin.lng, zoom: 16 });
78
+ return `window.__myazaMapCommand && window.__myazaMapCommand(${JSON.stringify(command)}); true;`;
79
+ }
80
+
81
+ export function samePin(a: LatLng | null, b: LatLng): boolean {
82
+ return !!a && Math.abs(a.lat - b.lat) < 1e-7 && Math.abs(a.lng - b.lng) < 1e-7;
83
+ }
84
+
85
+ // ── The framed STREET VIEW page (the entrance framing) ──────────────────────
86
+ //
87
+ // Same origin, same APP grant, sibling page: /embed/street-view lives beside
88
+ // /embed/map and the grant unlocks the key for either, so the SDK DERIVES its
89
+ // URL from the server-minted mapsFrameUrl by swapping the path — the two
90
+ // pages ship together with this SDK, and the coupling is recorded on both
91
+ // sides. The page is deliberately dumb: it renders the panorama and streams
92
+ // the current view (`sv-pov`) over the same bridge the map uses; the framing
93
+ // chrome, the frame-subtended fov maths and the capture decision stay in the
94
+ // SDK, so hosted, embedded and native applicants meet the identical
95
+ // instrument. Mirror of the web SDK's street-view half; keep in lockstep.
96
+ //
97
+ // page -> app: { source: 'myaza-map', type: 'sv-ready' | 'sv-unavailable' }
98
+ // { source: 'myaza-map', type: 'sv-pov', panoId, heading, pitch, viewFov }
99
+
100
+ /** The street-view page's URL derived from the map frame's, or null when the
101
+ * frame URL is not the page family this SDK knows. */
102
+ export function streetViewFrameUrlOf(mapsFrameUrl: string): string | null {
103
+ try {
104
+ const url = new URL(mapsFrameUrl);
105
+ if (!url.pathname.endsWith('/embed/map')) return null;
106
+ url.pathname = url.pathname.replace(/\/embed\/map$/, '/embed/street-view');
107
+ return url.toString();
108
+ } catch {
109
+ return null;
110
+ }
111
+ }
112
+
113
+ /** The full page URL: the derived page (carrying the signed APP grant and
114
+ * `mode=app`) plus the pin the panorama should look from. No `origin`: an
115
+ * app has none, and the page proves that instead. */
116
+ export function buildStreetViewFrameSrc(
117
+ frameUrl: string,
118
+ opts: { pin: LatLng; theme?: 'light' | 'dark' },
119
+ ): string {
120
+ const url = new URL(frameUrl);
121
+ url.searchParams.set('lat', String(opts.pin.lat));
122
+ url.searchParams.set('lng', String(opts.pin.lng));
123
+ if (opts.theme) url.searchParams.set('theme', opts.theme);
124
+ return url.toString();
125
+ }
126
+
127
+ export type StreetViewFrameMessage =
128
+ | { type: 'sv-ready' }
129
+ | { type: 'sv-unavailable' }
130
+ | { type: 'sv-pov'; panoId: string; heading: number; pitch: number; viewFov: number };
131
+
132
+ /** Validate-and-drop for the street-view page's messages; a JSON string is
133
+ * accepted too, since the bridge delivers strings. */
134
+ export function parseStreetViewFrameMessage(data: unknown): StreetViewFrameMessage | null {
135
+ let msg: unknown = data;
136
+ if (typeof data === 'string') {
137
+ try {
138
+ msg = JSON.parse(data);
139
+ } catch {
140
+ return null;
141
+ }
142
+ }
143
+ if (!msg || typeof msg !== 'object') return null;
144
+ const m = msg as Record<string, unknown>;
145
+ if (m.source !== MAP_FRAME_SOURCE) return null;
146
+ if (m.type === 'sv-ready') return { type: 'sv-ready' };
147
+ if (m.type === 'sv-unavailable') return { type: 'sv-unavailable' };
148
+ if (
149
+ m.type === 'sv-pov' &&
150
+ typeof m.panoId === 'string' &&
151
+ m.panoId.length > 0 &&
152
+ finite(m.heading) &&
153
+ finite(m.pitch) &&
154
+ finite(m.viewFov)
155
+ ) {
156
+ return { type: 'sv-pov', panoId: m.panoId, heading: m.heading, pitch: m.pitch, viewFov: m.viewFov };
157
+ }
158
+ return null;
159
+ }