@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,100 @@
1
+ // ---------------------------------------------------------------------------
2
+ // Pure maths for the background (OS geofence) presence tier. A geofence hands
3
+ // us ENTER/EXIT moments; the watch wants per-day aggregates — so a dwell SPAN
4
+ // is folded into the local calendar days it touched, each with its minutes
5
+ // and whether the span overlapped that day's night hours (20:00 to 05:59,
6
+ // the same definition the foreground reporter and the server use).
7
+ //
8
+ // THREE implementations of this fold exist — this file, the Flutter plugin's
9
+ // PresenceFold.kt (Android) and PresenceFold.swift (iOS) — because the
10
+ // killed-app flush must fold natively. All three are pinned to ONE vector
11
+ // file, packages/kyc-sdk-flutter/test/presence_fold_vectors.json, which is
12
+ // why the maths is integer arithmetic on an EXPLICIT UTC offset rather than
13
+ // platform date APIs: identical inputs must fold identically in every
14
+ // language. The offset is captured once per fold (east-positive minutes), a
15
+ // fixed-offset approximation that ignores a DST transition inside one span —
16
+ // at most an hour of dwell attribution shifts, and cross-platform parity is
17
+ // worth more than that edge.
18
+ // ---------------------------------------------------------------------------
19
+
20
+ export interface DayAggregate {
21
+ day: string;
22
+ dwellMinutes: number;
23
+ nightPresent: boolean;
24
+ }
25
+
26
+ /**
27
+ * The longest span one fold will credit. A missed EXIT event (the OS dropped
28
+ * it, the app was killed mid-fence) would otherwise fabricate days of dwell
29
+ * from one stale ENTER timestamp — a fence can fail to close, so the credit
30
+ * from any single span is bounded to one day's worth.
31
+ */
32
+ export const MAX_SPAN_MS = 24 * 60 * 60 * 1000;
33
+
34
+ const DAY_MS = 24 * 60 * 60 * 1000;
35
+ const HOUR_MS = 60 * 60 * 1000;
36
+ const NIGHT_END_H = 6;
37
+ const NIGHT_START_H = 20;
38
+
39
+ /** The device's UTC offset in east-positive minutes (Lagos = +60). JS's
40
+ * getTimezoneOffset() is west-positive, hence the sign flip. */
41
+ export const deviceUtcOffsetMinutes = (atMs: number): number =>
42
+ -new Date(atMs).getTimezoneOffset();
43
+
44
+ /** Civil date for a day index (days since 1970-01-01), as YYYY-MM-DD.
45
+ * Howard Hinnant's civil_from_days — pure integers, no date API, so the
46
+ * Kotlin and Swift mirrors are line-for-line identical. */
47
+ export function civilFromDays(z: number): string {
48
+ const zz = z + 719468;
49
+ const era = Math.floor(zz / 146097);
50
+ const doe = zz - era * 146097;
51
+ const yoe = Math.floor((doe - Math.floor(doe / 1460) + Math.floor(doe / 36524) - Math.floor(doe / 146096)) / 365);
52
+ const y = yoe + era * 400;
53
+ const doy = doe - (365 * yoe + Math.floor(yoe / 4) - Math.floor(yoe / 100));
54
+ const mp = Math.floor((5 * doy + 2) / 153);
55
+ const d = doy - Math.floor((153 * mp + 2) / 5) + 1;
56
+ const m = mp < 10 ? mp + 3 : mp - 9;
57
+ const year = m <= 2 ? y + 1 : y;
58
+ const pad = (n: number) => String(n).padStart(2, '0');
59
+ return `${year}-${pad(m)}-${pad(d)}`;
60
+ }
61
+
62
+ /**
63
+ * Fold one dwell span into per-local-day aggregates. `offsetMinutes` is the
64
+ * device's UTC offset (east-positive); it defaults to the device's own so
65
+ * ordinary callers never think about it. An inverted span yields nothing.
66
+ */
67
+ export function foldSpanIntoDays(
68
+ enterMs: number,
69
+ exitMs: number,
70
+ offsetMinutes: number = deviceUtcOffsetMinutes(enterMs),
71
+ ): DayAggregate[] {
72
+ if (!Number.isFinite(enterMs) || !Number.isFinite(exitMs)) return [];
73
+ if (exitMs <= enterMs) return [];
74
+ const offsetMs = offsetMinutes * 60_000;
75
+ const cappedExit = Math.min(exitMs, enterMs + MAX_SPAN_MS);
76
+
77
+ const out: DayAggregate[] = [];
78
+ let cursor = enterMs;
79
+ while (cursor < cappedExit) {
80
+ const shifted = cursor + offsetMs;
81
+ const dayIndex = Math.floor(shifted / DAY_MS);
82
+ // The real-clock moment this local day ends.
83
+ const dayEnd = (dayIndex + 1) * DAY_MS - offsetMs;
84
+ const sliceEnd = Math.min(dayEnd, cappedExit);
85
+ // Night = [00:00, 06:00) plus [20:00, 24:00) of this local day. Exact
86
+ // interval overlap — no sampling — so all three languages agree at the
87
+ // boundaries: a slice ENDING exactly at 20:00 has not touched the night.
88
+ const dayStartShifted = dayIndex * DAY_MS;
89
+ const nightPresent =
90
+ shifted < dayStartShifted + NIGHT_END_H * HOUR_MS ||
91
+ sliceEnd + offsetMs > dayStartShifted + NIGHT_START_H * HOUR_MS;
92
+ out.push({
93
+ day: civilFromDays(dayIndex),
94
+ dwellMinutes: Math.max(1, Math.round((sliceEnd - cursor) / 60_000)),
95
+ nightPresent,
96
+ });
97
+ cursor = sliceEnd;
98
+ }
99
+ return out;
100
+ }
@@ -0,0 +1,82 @@
1
+ // Persistence for the background geofence tier: the open ENTER timestamp,
2
+ // the unflushed per-day queue, and the wire config the headless task needs
3
+ // (it runs with no React context, so apiKey/devUrl are persisted beside the
4
+ // pin — the key is publishable by design). Kept apart from the pin store so
5
+ // each file stays small and the pin's own lifecycle rules stay untangled
6
+ // from flush bookkeeping.
7
+
8
+ import { readJsonFile, writeJsonFile } from './fs';
9
+ import type { WireObservation } from './post';
10
+
11
+ const FILE_NAME = 'myaza-kyc-presence-bg.json';
12
+
13
+ interface BgRecord {
14
+ enterAt?: number | null;
15
+ pending?: WireObservation[];
16
+ config?: { apiKey: string; devUrl?: string };
17
+ serviceUser?: string;
18
+ }
19
+
20
+ /** The foreground-service tier serves ONE user at a time (one notification,
21
+ * one location stream), and its task carries no region identifier the way a
22
+ * geofence event does — so the user it samples for is remembered under this
23
+ * reserved key, which no org user reference can collide with. */
24
+ const SERVICE_USER_KEY = '__service_user__';
25
+
26
+ function readAll(): Record<string, BgRecord> {
27
+ return readJsonFile(FILE_NAME) as Record<string, BgRecord>;
28
+ }
29
+
30
+ function write(userId: string, patch: Partial<BgRecord>): void {
31
+ const all = readAll();
32
+ all[userId] = { ...all[userId], ...patch };
33
+ writeJsonFile(FILE_NAME, all as Record<string, unknown>);
34
+ }
35
+
36
+ export function saveEnterAt(userId: string, at: number | null): void {
37
+ write(userId, { enterAt: at });
38
+ }
39
+
40
+ export function loadEnterAt(userId: string): number | null {
41
+ const at = readAll()[userId]?.enterAt;
42
+ return typeof at === 'number' ? at : null;
43
+ }
44
+
45
+ export function pendingObservations(userId: string): WireObservation[] {
46
+ const pending = readAll()[userId]?.pending;
47
+ return Array.isArray(pending) ? pending : [];
48
+ }
49
+
50
+ export function replacePending(userId: string, next: WireObservation[]): void {
51
+ write(userId, { pending: next });
52
+ }
53
+
54
+ /** Append fresh aggregates through the caller's merge (same-day spans fold). */
55
+ export function queueObservations(
56
+ userId: string,
57
+ fresh: WireObservation[],
58
+ merge: (existing: WireObservation[], fresh: WireObservation[]) => WireObservation[],
59
+ ): void {
60
+ write(userId, { pending: merge(pendingObservations(userId), fresh) });
61
+ }
62
+
63
+ export function saveReporterConfig(userId: string, config: { apiKey: string; devUrl?: string }): void {
64
+ write(userId, { config });
65
+ }
66
+
67
+ export function loadReporterConfig(userId: string): { apiKey: string; devUrl?: string } | null {
68
+ const cfg = readAll()[userId]?.config;
69
+ return cfg && typeof cfg.apiKey === 'string' ? cfg : null;
70
+ }
71
+
72
+ export function saveServiceUser(userId: string | null): void {
73
+ const all = readAll();
74
+ if (userId == null) delete all[SERVICE_USER_KEY];
75
+ else all[SERVICE_USER_KEY] = { serviceUser: userId };
76
+ writeJsonFile(FILE_NAME, all as Record<string, unknown>);
77
+ }
78
+
79
+ export function loadServiceUser(): string | null {
80
+ const id = readAll()[SERVICE_USER_KEY]?.serviceUser;
81
+ return typeof id === 'string' && id.length > 0 ? id : null;
82
+ }
@@ -0,0 +1,165 @@
1
+ // ---------------------------------------------------------------------------
2
+ // The BACKGROUND presence tier — the OkHi model (user decision 2026-08-31):
3
+ // one consent, then the OS wakes this SDK on geofence transitions and
4
+ // residency keeps being confirmed with nobody in the loop, until revoked.
5
+ //
6
+ // Mechanics: `registerBackgroundPresence()` MUST run at app-root module scope
7
+ // (Expo requires the task defined before the app registers; a task defined
8
+ // inside a component never fires headlessly). `enableBackgroundPresence()`
9
+ // then asks for the "allow all the time" permission and arms a 250m geofence
10
+ // on the stored pin. ENTER stamps a timestamp; EXIT folds the span into
11
+ // per-day aggregates (background-math.ts) and flushes them. Only the derived
12
+ // day records ever leave the device — same contract as the foreground tier.
13
+ //
14
+ // The Android foreground-service tier (foreground-service.ts) rides the same
15
+ // registration call and the same stored enterAt, so the two never
16
+ // double-count a stay; see sampler.ts for the cooperation rule.
17
+ //
18
+ // expo-task-manager is an OPTIONAL module (the expo-file-system pattern): a
19
+ // host without it simply has no background tier, never a crash. It is listed
20
+ // as an optional peer, not a dependency — the host opts into the native
21
+ // surface by installing it.
22
+ // ---------------------------------------------------------------------------
23
+
24
+ import * as Location from 'expo-location';
25
+ import { foldSpanIntoDays } from './background-math';
26
+ import { defineLocationTask } from './foreground-service';
27
+ import { PRESENCE_GEOFENCE_TASK, armGeofence } from './geofence';
28
+ import { postObservations } from './post';
29
+ import { mergeObservations } from './sampler';
30
+ import { loadPresencePin } from './store';
31
+ import {
32
+ loadEnterAt,
33
+ loadReporterConfig,
34
+ pendingObservations,
35
+ queueObservations,
36
+ replacePending,
37
+ saveEnterAt,
38
+ saveReporterConfig,
39
+ } from './background-store';
40
+
41
+ // Kept exported from here for hosts (and status.ts) that imported it before
42
+ // the primitive moved to geofence.ts.
43
+ export { PRESENCE_GEOFENCE_TASK };
44
+
45
+ interface TaskManagerLike {
46
+ defineTask(name: string, fn: (body: { data: unknown; error: unknown }) => void): void;
47
+ isTaskRegisteredAsync(name: string): Promise<boolean>;
48
+ }
49
+
50
+ function taskManager(): TaskManagerLike | null {
51
+ try {
52
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
53
+ const mod = require('expo-task-manager') as Partial<TaskManagerLike>;
54
+ return typeof mod?.defineTask === 'function' ? (mod as TaskManagerLike) : null;
55
+ } catch {
56
+ return null;
57
+ }
58
+ }
59
+
60
+ async function flushQueue(externalUserId: string): Promise<void> {
61
+ const cfg = loadReporterConfig(externalUserId);
62
+ if (!cfg) return;
63
+ const pending = pendingObservations(externalUserId);
64
+ if (pending.length === 0) return;
65
+ const ok = await postObservations(cfg.apiKey, cfg.devUrl, externalUserId, pending);
66
+ // The server ingest is idempotent per (watch, day, source) and MERGES, so a
67
+ // flush that half-landed is safe to retry whole next time.
68
+ if (ok) replacePending(externalUserId, []);
69
+ }
70
+
71
+ /**
72
+ * Define the background tasks (the geofence task AND the foreground-service
73
+ * location task). Call ONCE from the host app's root module (e.g. index.js),
74
+ * before the component tree mounts. Returns false when the optional
75
+ * expo-task-manager module is absent.
76
+ */
77
+ export function registerBackgroundPresence(): boolean {
78
+ const tm = taskManager();
79
+ if (!tm) return false;
80
+ tm.defineTask(PRESENCE_GEOFENCE_TASK, (body) => {
81
+ if (body.error) return;
82
+ const data = body.data as
83
+ | { eventType?: number; region?: { identifier?: string } }
84
+ | undefined;
85
+ const userId = data?.region?.identifier;
86
+ if (!userId) return;
87
+ // expo-location: GeofencingEventType.Enter = 1, Exit = 2.
88
+ if (data?.eventType === 1) {
89
+ // A stay the foreground-service sampler already opened stays open;
90
+ // re-stamping would shorten it.
91
+ if (loadEnterAt(userId) == null) saveEnterAt(userId, Date.now());
92
+ return;
93
+ }
94
+ if (data?.eventType === 2) {
95
+ const enteredAt = loadEnterAt(userId);
96
+ saveEnterAt(userId, null);
97
+ if (enteredAt == null) return;
98
+ const days = foldSpanIntoDays(enteredAt, Date.now());
99
+ if (days.length === 0) return;
100
+ queueObservations(
101
+ userId,
102
+ days.map((d) => ({ ...d, source: 'geofence' as const, samples: 1 })),
103
+ mergeObservations,
104
+ );
105
+ void flushQueue(userId);
106
+ }
107
+ });
108
+ defineLocationTask(tm);
109
+ return true;
110
+ }
111
+
112
+ export interface EnableBackgroundResult {
113
+ enabled: boolean;
114
+ reason:
115
+ | 'enabled'
116
+ | 'module_missing'
117
+ | 'no_pin'
118
+ | 'foreground_denied'
119
+ | 'background_denied'
120
+ | 'start_failed';
121
+ }
122
+
123
+ /**
124
+ * Arm the geofence on the stored pin. Asks for foreground THEN background
125
+ * permission (the order the OSes require); a refusal leaves the foreground
126
+ * tier working exactly as before — the tiers degrade, never break.
127
+ */
128
+ export async function enableBackgroundPresence(options: {
129
+ apiKey: string;
130
+ externalUserId: string;
131
+ devUrl?: string;
132
+ }): Promise<EnableBackgroundResult> {
133
+ const tm = taskManager();
134
+ if (!tm) return { enabled: false, reason: 'module_missing' };
135
+ const pin = loadPresencePin(options.externalUserId);
136
+ if (!pin) return { enabled: false, reason: 'no_pin' };
137
+
138
+ const fg = await Location.requestForegroundPermissionsAsync().catch(() => null);
139
+ if (fg?.status !== 'granted') return { enabled: false, reason: 'foreground_denied' };
140
+ const bg = await Location.requestBackgroundPermissionsAsync().catch(() => null);
141
+ if (bg?.status !== 'granted') return { enabled: false, reason: 'background_denied' };
142
+
143
+ // The task handler runs with no React context, so the wire config it needs
144
+ // to flush is persisted beside the pin. The key is publishable by design.
145
+ saveReporterConfig(options.externalUserId, {
146
+ apiKey: options.apiKey,
147
+ ...(options.devUrl ? { devUrl: options.devUrl } : {}),
148
+ });
149
+
150
+ try {
151
+ await armGeofence(options.externalUserId, pin);
152
+ return { enabled: true, reason: 'enabled' };
153
+ } catch {
154
+ return { enabled: false, reason: 'start_failed' };
155
+ }
156
+ }
157
+
158
+ /** Disarm the geofence. Never throws; a host may call it defensively. */
159
+ export async function disableBackgroundPresence(): Promise<void> {
160
+ try {
161
+ await Location.stopGeofencingAsync(PRESENCE_GEOFENCE_TASK);
162
+ } catch {
163
+ // Not armed, or the module is absent — either way, disarmed.
164
+ }
165
+ }
@@ -0,0 +1,193 @@
1
+ // ---------------------------------------------------------------------------
2
+ // The Android FOREGROUND SERVICE tier — the OkHi reliability move. A geofence
3
+ // alone is not reliable on Android 8+ once a manufacturer's battery manager
4
+ // decides an app is idle: transitions are dropped and nothing says so, the
5
+ // watch quietly lapses to inconclusive, and the phones on that list (Tecno,
6
+ // Infinix, itel, Xiaomi, Oppo, Vivo) are the ones our markets carry.
7
+ //
8
+ // A foreground service, with its persistent notification, is the one thing
9
+ // those managers leave alone. expo-location runs one for location updates;
10
+ // this file drives it: periodic low-power fixes arrive in a headless task,
11
+ // sampler.ts turns them into the same enter/exit spans the geofence tier
12
+ // folds (on the SAME stored enterAt, so the two cooperate), the queue flushes
13
+ // while the process is alive, and a dropped fence is re-armed.
14
+ //
15
+ // Android only, opt-in, and the notification is the HOST's to word — its
16
+ // title and body reach the person's status bar. On iOS the geofence tier is
17
+ // reliable on its own; asking for continuous background updates there would
18
+ // be the heavier posture for nothing, so enable() answers unsupported.
19
+ // ---------------------------------------------------------------------------
20
+
21
+ import { Platform } from 'react-native';
22
+ import * as Location from 'expo-location';
23
+ import { armGeofence, geofenceArmed } from './geofence';
24
+ import { applyLocationSamples, mergeObservations } from './sampler';
25
+ import { postObservations } from './post';
26
+ import { loadPresencePin } from './store';
27
+ import {
28
+ loadEnterAt,
29
+ loadReporterConfig,
30
+ loadServiceUser,
31
+ pendingObservations,
32
+ queueObservations,
33
+ replacePending,
34
+ saveEnterAt,
35
+ saveReporterConfig,
36
+ saveServiceUser,
37
+ } from './background-store';
38
+
39
+ export const PRESENCE_LOCATION_TASK = 'myaza-kyc-presence-location';
40
+
41
+ /** Low-power cadence: a fix every ten minutes or hundred metres is plenty to
42
+ * bracket a stay to the dwell floor, and cheap enough to run for a week. */
43
+ const SAMPLE_INTERVAL_MS = 10 * 60 * 1000;
44
+ const SAMPLE_DISTANCE_M = 100;
45
+
46
+ export interface PresenceNotification {
47
+ /** e.g. "Address verification in progress" */
48
+ title: string;
49
+ /** e.g. "Check the app to see your progress" */
50
+ body: string;
51
+ /** `#RRGGBB`; the notification accent. */
52
+ color?: string;
53
+ }
54
+
55
+ interface TaskManagerLike {
56
+ defineTask(name: string, fn: (body: { data: unknown; error: unknown }) => void): void;
57
+ }
58
+
59
+ type LocationLike = {
60
+ coords: { latitude: number; longitude: number; accuracy?: number | null };
61
+ timestamp?: number;
62
+ mocked?: boolean;
63
+ };
64
+
65
+ async function flushQueue(userId: string): Promise<void> {
66
+ const cfg = loadReporterConfig(userId);
67
+ if (!cfg) return;
68
+ const pending = pendingObservations(userId);
69
+ if (pending.length === 0) return;
70
+ const ok = await postObservations(cfg.apiKey, cfg.devUrl, userId, pending);
71
+ if (ok) replacePending(userId, []);
72
+ }
73
+
74
+ /** Define the location task. Called by registerBackgroundPresence(), so the
75
+ * host's one root-level call covers both background tiers. */
76
+ export function defineLocationTask(tm: TaskManagerLike): void {
77
+ tm.defineTask(PRESENCE_LOCATION_TASK, (body) => {
78
+ if (body.error) return;
79
+ const locations = (body.data as { locations?: LocationLike[] } | undefined)?.locations;
80
+ if (!Array.isArray(locations) || locations.length === 0) return;
81
+ const userId = loadServiceUser();
82
+ if (!userId) return;
83
+ const pin = loadPresencePin(userId);
84
+ if (!pin) return;
85
+
86
+ const result = applyLocationSamples(
87
+ pin,
88
+ locations.map((l) => ({
89
+ lat: l.coords.latitude,
90
+ lng: l.coords.longitude,
91
+ accuracy: typeof l.coords.accuracy === 'number' ? l.coords.accuracy : null,
92
+ timestamp: l.timestamp ?? Date.now(),
93
+ mocked: typeof l.mocked === 'boolean' ? l.mocked : null,
94
+ })),
95
+ loadEnterAt(userId),
96
+ );
97
+ saveEnterAt(userId, result.enterAt);
98
+ if (result.observations.length > 0) {
99
+ queueObservations(userId, result.observations, mergeObservations);
100
+ }
101
+ // A live process is the one moment a stuck queue (an EXIT flush that met
102
+ // no network) can drain, and a dropped fence can come back.
103
+ void flushQueue(userId);
104
+ void geofenceArmed().then((armed) => {
105
+ if (!armed) armGeofence(userId, pin).catch(() => undefined);
106
+ });
107
+ });
108
+ }
109
+
110
+ export interface EnableForegroundServiceResult {
111
+ enabled: boolean;
112
+ reason:
113
+ | 'enabled'
114
+ | 'unsupported_platform'
115
+ | 'module_missing'
116
+ | 'no_pin'
117
+ | 'foreground_denied'
118
+ | 'background_denied'
119
+ | 'start_failed';
120
+ }
121
+
122
+ /**
123
+ * Start the service on the stored pin. Asks for foreground THEN background
124
+ * permission (the order the OSes require); a refusal leaves whichever tier
125
+ * was running exactly as before — the tiers degrade, never break.
126
+ */
127
+ export async function enableForegroundService(options: {
128
+ apiKey: string;
129
+ externalUserId: string;
130
+ devUrl?: string;
131
+ notification: PresenceNotification;
132
+ }): Promise<EnableForegroundServiceResult> {
133
+ if (Platform.OS !== 'android') return { enabled: false, reason: 'unsupported_platform' };
134
+ try {
135
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
136
+ require('expo-task-manager');
137
+ } catch {
138
+ return { enabled: false, reason: 'module_missing' };
139
+ }
140
+ const pin = loadPresencePin(options.externalUserId);
141
+ if (!pin) return { enabled: false, reason: 'no_pin' };
142
+
143
+ const fg = await Location.requestForegroundPermissionsAsync().catch(() => null);
144
+ if (fg?.status !== 'granted') return { enabled: false, reason: 'foreground_denied' };
145
+ const bg = await Location.requestBackgroundPermissionsAsync().catch(() => null);
146
+ if (bg?.status !== 'granted') return { enabled: false, reason: 'background_denied' };
147
+
148
+ saveReporterConfig(options.externalUserId, {
149
+ apiKey: options.apiKey,
150
+ ...(options.devUrl ? { devUrl: options.devUrl } : {}),
151
+ });
152
+ saveServiceUser(options.externalUserId);
153
+
154
+ try {
155
+ await Location.startLocationUpdatesAsync(PRESENCE_LOCATION_TASK, {
156
+ accuracy: Location.Accuracy.Balanced,
157
+ timeInterval: SAMPLE_INTERVAL_MS,
158
+ distanceInterval: SAMPLE_DISTANCE_M,
159
+ deferredUpdatesInterval: SAMPLE_INTERVAL_MS,
160
+ foregroundService: {
161
+ notificationTitle: options.notification.title,
162
+ notificationBody: options.notification.body,
163
+ ...(options.notification.color ? { notificationColor: options.notification.color } : {}),
164
+ // The service outliving a swipe-away is the whole point.
165
+ killServiceOnDestroy: false,
166
+ },
167
+ });
168
+ return { enabled: true, reason: 'enabled' };
169
+ } catch {
170
+ saveServiceUser(null);
171
+ return { enabled: false, reason: 'start_failed' };
172
+ }
173
+ }
174
+
175
+ /** Stop the service and its notification. Never throws. */
176
+ export async function disableForegroundService(): Promise<void> {
177
+ saveServiceUser(null);
178
+ try {
179
+ await Location.stopLocationUpdatesAsync(PRESENCE_LOCATION_TASK);
180
+ } catch {
181
+ // Not running, or the module is absent — either way, stopped.
182
+ }
183
+ }
184
+
185
+ /** Whether the service is running right now. Never throws. */
186
+ export async function foregroundServiceRunning(): Promise<boolean> {
187
+ if (Platform.OS !== 'android') return false;
188
+ try {
189
+ return await Location.hasStartedLocationUpdatesAsync(PRESENCE_LOCATION_TASK);
190
+ } catch {
191
+ return false;
192
+ }
193
+ }
@@ -0,0 +1,51 @@
1
+ // The presence tier's tiny JSON persistence, via expo-file-system loaded
2
+ // through the SDK's optional-module pattern — a host without the module
3
+ // simply has no presence tier, never a crash. Shared by the pin store and
4
+ // the background tier's own store so the shim exists once.
5
+
6
+ interface FileLike {
7
+ exists: boolean;
8
+ create(options?: { intermediates?: boolean; overwrite?: boolean }): void;
9
+ write(content: string): void;
10
+ textSync(): string;
11
+ }
12
+
13
+ interface FileSystemModule {
14
+ File: new (directory: unknown, name: string) => FileLike;
15
+ Paths: { document: unknown };
16
+ }
17
+
18
+ function fsModule(): FileSystemModule | null {
19
+ try {
20
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
21
+ const mod = require('expo-file-system') as Partial<FileSystemModule>;
22
+ return mod?.File && mod?.Paths ? (mod as FileSystemModule) : null;
23
+ } catch {
24
+ return null;
25
+ }
26
+ }
27
+
28
+ export function readJsonFile(name: string): Record<string, unknown> {
29
+ const fs = fsModule();
30
+ if (!fs) return {};
31
+ try {
32
+ const file = new fs.File(fs.Paths.document, name);
33
+ if (!file.exists) return {};
34
+ const parsed = JSON.parse(file.textSync()) as unknown;
35
+ return parsed && typeof parsed === 'object' ? (parsed as Record<string, unknown>) : {};
36
+ } catch {
37
+ return {};
38
+ }
39
+ }
40
+
41
+ export function writeJsonFile(name: string, value: Record<string, unknown>): void {
42
+ const fs = fsModule();
43
+ if (!fs) return;
44
+ try {
45
+ const file = new fs.File(fs.Paths.document, name);
46
+ if (!file.exists) file.create({ intermediates: true, overwrite: true });
47
+ file.write(JSON.stringify(value));
48
+ } catch {
49
+ // Best-effort: a failed save costs the presence tier, never the flow.
50
+ }
51
+ }
@@ -0,0 +1,41 @@
1
+ // ---------------------------------------------------------------------------
2
+ // The one geofence primitive both background tiers share. The OS-geofence
3
+ // tier arms it at enable time; the foreground-service tier re-arms it when a
4
+ // location batch finds the fence has been dropped (a location-services toggle
5
+ // clears every registered fence on Android, silently). Kept apart from
6
+ // background.ts so foreground-service.ts can import it without a cycle.
7
+ // ---------------------------------------------------------------------------
8
+
9
+ import * as Location from 'expo-location';
10
+
11
+ export const PRESENCE_GEOFENCE_TASK = 'myaza-kyc-presence-geofence';
12
+
13
+ /** The server's at-address rule: 250m, so the fence and the scorer agree on
14
+ * what counts as "at the address". */
15
+ export const GEOFENCE_RADIUS_M = 250;
16
+
17
+ /** Registers the fence on the pin. Throws on failure; callers decide. */
18
+ export function armGeofence(
19
+ externalUserId: string,
20
+ pin: { lat: number; lng: number },
21
+ ): Promise<void> {
22
+ return Location.startGeofencingAsync(PRESENCE_GEOFENCE_TASK, [
23
+ {
24
+ identifier: externalUserId,
25
+ latitude: pin.lat,
26
+ longitude: pin.lng,
27
+ radius: GEOFENCE_RADIUS_M,
28
+ notifyOnEnter: true,
29
+ notifyOnExit: true,
30
+ },
31
+ ]);
32
+ }
33
+
34
+ /** Whether the fence is registered with the OS right now. Never throws. */
35
+ export async function geofenceArmed(): Promise<boolean> {
36
+ try {
37
+ return await Location.hasStartedGeofencingAsync(PRESENCE_GEOFENCE_TASK);
38
+ } catch {
39
+ return false;
40
+ }
41
+ }
@@ -0,0 +1,44 @@
1
+ // ---------------------------------------------------------------------------
2
+ // Presence math — the pure half of the foreground reporter. On-device
3
+ // evaluation is the contract: the phone decides "inside the fence?" and "what
4
+ // local day/night is it?", and only the derived record (day + flag) ever
5
+ // leaves the device. Raw coordinates never travel after capture.
6
+ //
7
+ // Mirrors the Flutter SDK's presence_math.dart — keep the two in lockstep.
8
+ // ---------------------------------------------------------------------------
9
+
10
+ const EARTH_RADIUS_M = 6_371_000;
11
+
12
+ export function haversineMeters(
13
+ lat1: number,
14
+ lng1: number,
15
+ lat2: number,
16
+ lng2: number,
17
+ ): number {
18
+ const rad = (d: number) => (d * Math.PI) / 180;
19
+ const dLat = rad(lat2 - lat1);
20
+ const dLng = rad(lng2 - lng1);
21
+ const a =
22
+ Math.sin(dLat / 2) ** 2 +
23
+ Math.cos(rad(lat1)) * Math.cos(rad(lat2)) * Math.sin(dLng / 2) ** 2;
24
+ return 2 * EARTH_RADIUS_M * Math.asin(Math.min(1, Math.sqrt(a)));
25
+ }
26
+
27
+ /** The server's at-address rule, applied on-device: 250 m, widened to the
28
+ * fix's reported accuracy, capped at 1 km. */
29
+ export function insideFence(
30
+ pin: { lat: number; lng: number },
31
+ fix: { lat: number; lng: number; accuracy: number | null },
32
+ ): boolean {
33
+ const radius = Math.min(1000, Math.max(250, fix.accuracy ?? 0));
34
+ return haversineMeters(pin.lat, pin.lng, fix.lat, fix.lng) <= radius;
35
+ }
36
+
37
+ /** The observation's day + night flag, from the DEVICE's local clock — that is
38
+ * the whole point of on-device evaluation. Night = 20:00–05:59 local. */
39
+ export function localDayAndNight(now = new Date()): { day: string; nightPresent: boolean } {
40
+ const pad = (n: number) => String(n).padStart(2, '0');
41
+ const day = `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}`;
42
+ const hour = now.getHours();
43
+ return { day, nightPresent: hour >= 20 || hour < 6 };
44
+ }