@myazahq/kyc-sdk-react-native 2.4.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 (246) hide show
  1. package/README.md +151 -5
  2. package/android/build.gradle +35 -3
  3. package/android/src/main/java/com/margelo/nitro/myazakyc/HybridMyazaFaceDetector.kt +77 -0
  4. package/app.plugin.js +89 -8
  5. package/ios/HybridMyazaFaceDetector.swift +16 -3
  6. package/nitrogen/generated/android/c++/JHybridMyazaFaceDetectorSpec.cpp +23 -0
  7. package/nitrogen/generated/android/c++/JHybridMyazaFaceDetectorSpec.hpp +2 -0
  8. package/nitrogen/generated/android/kotlin/com/margelo/nitro/myazakyc/HybridMyazaFaceDetectorSpec.kt +9 -0
  9. package/nitrogen/generated/ios/KycSdkReactNative-Swift-Cxx-Bridge.cpp +8 -0
  10. package/nitrogen/generated/ios/KycSdkReactNative-Swift-Cxx-Bridge.hpp +43 -0
  11. package/nitrogen/generated/ios/c++/HybridMyazaFaceDetectorSpecSwift.hpp +17 -0
  12. package/nitrogen/generated/ios/swift/Func_void_bool.swift +46 -0
  13. package/nitrogen/generated/ios/swift/HybridMyazaFaceDetectorSpec.swift +2 -0
  14. package/nitrogen/generated/ios/swift/HybridMyazaFaceDetectorSpec_cxx.swift +31 -0
  15. package/nitrogen/generated/shared/c++/HybridMyazaFaceDetectorSpec.cpp +2 -0
  16. package/nitrogen/generated/shared/c++/HybridMyazaFaceDetectorSpec.hpp +3 -0
  17. package/package.json +14 -2
  18. package/src/MyazaBiometricAuth.tsx +119 -0
  19. package/src/MyazaKYC.tsx +12 -0
  20. package/src/components/CountryField.tsx +8 -0
  21. package/src/components/CountryRegionPicker.tsx +184 -0
  22. package/src/components/DashedBorder.tsx +55 -0
  23. package/src/components/DialCodePicker.tsx +53 -67
  24. package/src/components/DialCodeRow.tsx +102 -0
  25. package/src/components/FramedMapPicker.tsx +122 -0
  26. package/src/components/GeoBadge.tsx +34 -0
  27. package/src/components/Icon.tsx +2 -176
  28. package/src/components/KycFlow.tsx +60 -32
  29. package/src/components/LineSkeleton.tsx +100 -0
  30. package/src/components/MapChrome.tsx +67 -0
  31. package/src/components/MapPinMarker.tsx +54 -0
  32. package/src/components/MapPinPicker.tsx +179 -0
  33. package/src/components/MilestoneTrack.tsx +155 -0
  34. package/src/components/MultiIdProgress.tsx +107 -0
  35. package/src/components/MyazaInput.tsx +39 -4
  36. package/src/components/MyazaSelect.tsx +2 -1
  37. package/src/components/OptionRow.tsx +22 -3
  38. package/src/components/OwnershipSlider.tsx +102 -0
  39. package/src/components/PhoneNumberInput.tsx +45 -4
  40. package/src/components/PoweredBy.tsx +5 -5
  41. package/src/components/PresenceBlocks.tsx +153 -0
  42. package/src/components/StaggerIn.tsx +63 -0
  43. package/src/components/StepView.tsx +43 -0
  44. package/src/components/StickyActions.tsx +56 -0
  45. package/src/components/dialCodeRows.ts +64 -0
  46. package/src/components/glass/FloatingSheet.tsx +73 -21
  47. package/src/components/glass/GlassSheet.tsx +14 -2
  48. package/src/components/icon-map.ts +176 -0
  49. package/src/components/stepHeaderMeta.tsx +45 -5
  50. package/src/config/addressCollection.ts +125 -0
  51. package/src/config/biometricOptions.ts +100 -0
  52. package/src/config/business.ts +26 -7
  53. package/src/config/businessDetailsValidity.ts +52 -0
  54. package/src/config/businessPrefill.ts +72 -0
  55. package/src/config/businessSteps.ts +20 -3
  56. package/src/config/consentStep.ts +19 -0
  57. package/src/config/keyPeople.ts +111 -7
  58. package/src/config/keyPeopleCorporate.ts +19 -0
  59. package/src/config/keyPeopleOwnerTypes.ts +12 -0
  60. package/src/config/keyPeopleOwners.ts +26 -0
  61. package/src/config/keyPeoplePrefill.ts +138 -0
  62. package/src/config/keyPeopleSectionDefs.ts +65 -0
  63. package/src/config/keyPeopleSections.ts +167 -0
  64. package/src/config/proofOfAddress.ts +54 -11
  65. package/src/config/regions.ts +24 -0
  66. package/src/config/registrationHint.ts +1 -1
  67. package/src/config/stepOrder.ts +100 -6
  68. package/src/config/theme.ts +8 -2
  69. package/src/config/uploadLimits.ts +35 -0
  70. package/src/config/website.ts +36 -0
  71. package/src/config/workflowMerge.ts +16 -5
  72. package/src/emrtd/activeAuth.ts +100 -0
  73. package/src/emrtd/extras.ts +76 -0
  74. package/src/emrtd/files.ts +5 -0
  75. package/src/emrtd/index.ts +1 -0
  76. package/src/emrtd/read.ts +8 -1
  77. package/src/emrtd/session.ts +41 -0
  78. package/src/emrtd/stages.ts +11 -0
  79. package/src/index.ts +54 -1
  80. package/src/lib/address-current-location.ts +171 -0
  81. package/src/lib/address-field-modes.ts +119 -0
  82. package/src/lib/address-flow.ts +172 -0
  83. package/src/lib/address-helpers.ts +48 -0
  84. package/src/lib/address-line.ts +76 -0
  85. package/src/lib/address-pin-move.ts +97 -0
  86. package/src/lib/address-step-recovery.ts +63 -0
  87. package/src/lib/authed-image.ts +78 -0
  88. package/src/lib/biometric-auth.ts +52 -0
  89. package/src/lib/biometric-copy.ts +51 -0
  90. package/src/lib/captureRing.ts +83 -0
  91. package/src/lib/contact-recovery.ts +44 -0
  92. package/src/lib/country-adoption.ts +89 -0
  93. package/src/lib/inferred-country.ts +65 -0
  94. package/src/lib/livenessLayout.ts +49 -0
  95. package/src/lib/map-frame.ts +159 -0
  96. package/src/lib/map-tiles.ts +143 -0
  97. package/src/lib/multi-id.ts +210 -0
  98. package/src/lib/poa-country-gate.ts +27 -0
  99. package/src/lib/resubmit.ts +130 -0
  100. package/src/lib/result-copy.ts +116 -0
  101. package/src/lib/result-wait.ts +53 -0
  102. package/src/lib/review-map-surface.ts +26 -0
  103. package/src/lib/scope.ts +31 -0
  104. package/src/lib/screen-corners.ts +50 -0
  105. package/src/lib/selfie-upload-wait.ts +79 -0
  106. package/src/lib/step-log.ts +24 -3
  107. package/src/lib/street-view-fov.ts +42 -0
  108. package/src/lib/use-multi-id-plan.ts +29 -0
  109. package/src/lib/webview-available.ts +47 -0
  110. package/src/liveness/useLiveness.ts +6 -1
  111. package/src/liveness/useModelReady.ts +65 -0
  112. package/src/liveness/visionCameraFaceDetector.ts +35 -0
  113. package/src/presence/background-math.ts +100 -0
  114. package/src/presence/background-store.ts +82 -0
  115. package/src/presence/background.ts +165 -0
  116. package/src/presence/foreground-service.ts +193 -0
  117. package/src/presence/fs.ts +51 -0
  118. package/src/presence/geofence.ts +41 -0
  119. package/src/presence/math.ts +44 -0
  120. package/src/presence/post.ts +40 -0
  121. package/src/presence/report.ts +87 -0
  122. package/src/presence/sampler.ts +110 -0
  123. package/src/presence/status.ts +92 -0
  124. package/src/presence/store.ts +94 -0
  125. package/src/presence/tier.ts +36 -0
  126. package/src/presence/watch-wait.ts +81 -0
  127. package/src/screens/AddressCountryControl.tsx +115 -0
  128. package/src/screens/ApplicantRoleStep.tsx +9 -5
  129. package/src/screens/BusinessCheckPanel.tsx +89 -0
  130. package/src/screens/BusinessDetailsFields.tsx +112 -0
  131. package/src/screens/BusinessDetailsStep.tsx +133 -145
  132. package/src/screens/BusinessDocumentSlot.tsx +5 -5
  133. package/src/screens/BusinessDocumentsStep.tsx +5 -56
  134. package/src/screens/BusinessKeyPeopleStep.tsx +103 -71
  135. package/src/screens/BusinessPickedCard.tsx +86 -0
  136. package/src/screens/BusinessPickedSection.tsx +51 -0
  137. package/src/screens/BusinessRegistryPickers.tsx +67 -0
  138. package/src/screens/BusinessSandboxToggle.tsx +114 -0
  139. package/src/screens/BusinessSearch.tsx +199 -0
  140. package/src/screens/BusinessSearchResults.tsx +115 -0
  141. package/src/screens/CompanyInfoFields.tsx +91 -51
  142. package/src/screens/ConsentStep.tsx +37 -17
  143. package/src/screens/ContactActions.tsx +54 -0
  144. package/src/screens/ContactDestinationField.tsx +5 -0
  145. package/src/screens/ContactEntryPanel.tsx +75 -0
  146. package/src/screens/ContactVerificationStep.tsx +43 -42
  147. package/src/screens/CountrySelectStep.tsx +13 -147
  148. package/src/screens/IdTypeStep.tsx +12 -5
  149. package/src/screens/KeepLinksSheet.tsx +88 -0
  150. package/src/screens/KeyPeopleAwaitCard.tsx +204 -0
  151. package/src/screens/KeyPeopleAwaitList.tsx +126 -0
  152. package/src/screens/KeyPeoplePending.tsx +154 -0
  153. package/src/screens/KeyPeopleSection.tsx +162 -0
  154. package/src/screens/KeyPeopleSectionsList.tsx +103 -0
  155. package/src/screens/KeyPeopleUboExemption.tsx +72 -0
  156. package/src/screens/KeyPersonCard.tsx +36 -4
  157. package/src/screens/KeyPersonForm.tsx +195 -36
  158. package/src/screens/KeyPersonKindToggle.tsx +68 -0
  159. package/src/screens/KeyPersonOwners.tsx +109 -0
  160. package/src/screens/KeyPersonRoleChips.tsx +68 -0
  161. package/src/screens/KeyPersonSheet.tsx +92 -108
  162. package/src/screens/LivenessAvatar.tsx +15 -3
  163. package/src/screens/LivenessStep.tsx +172 -52
  164. package/src/screens/NfcStep.tsx +14 -0
  165. package/src/screens/PoaDocumentTypeList.tsx +66 -0
  166. package/src/screens/ProofOfAddressParts.tsx +151 -0
  167. package/src/screens/ProofOfAddressStep.tsx +58 -96
  168. package/src/screens/SubmittedBadge.tsx +25 -0
  169. package/src/screens/SubmittedError.tsx +64 -0
  170. package/src/screens/SubmittedResult.tsx +117 -0
  171. package/src/screens/SubmittedStep.tsx +82 -119
  172. package/src/screens/SubmittedSuccess.tsx +127 -0
  173. package/src/screens/SubmittedWaiting.tsx +45 -0
  174. package/src/screens/address/AddressEntranceStep.tsx +161 -0
  175. package/src/screens/address/AddressIntroGate.tsx +148 -0
  176. package/src/screens/address/AddressMapStub.tsx +50 -0
  177. package/src/screens/address/AddressPinStep.tsx +178 -0
  178. package/src/screens/address/AddressReviewStep.tsx +162 -0
  179. package/src/screens/address/AddressSandboxTabs.tsx +158 -0
  180. package/src/screens/address/AddressSearchField.tsx +115 -0
  181. package/src/screens/address/AddressSearchStep.tsx +94 -0
  182. package/src/screens/address/CurrentLocationRow.tsx +135 -0
  183. package/src/screens/address/DetailsSheet.tsx +175 -0
  184. package/src/screens/address/DetailsSheetFields.tsx +184 -0
  185. package/src/screens/address/EntranceDropzone.tsx +178 -0
  186. package/src/screens/address/EntranceFraming.tsx +66 -0
  187. package/src/screens/address/EntrancePills.tsx +60 -0
  188. package/src/screens/address/FramedStreetView.tsx +155 -0
  189. package/src/screens/address/IntroDisclosures.tsx +210 -0
  190. package/src/screens/address/LabelDecisionRow.tsx +113 -0
  191. package/src/screens/address/PinSummaryRow.tsx +105 -0
  192. package/src/screens/address/ReviewAddressBand.tsx +111 -0
  193. package/src/screens/address/ReviewEntranceThumbs.tsx +64 -0
  194. package/src/screens/address/ReviewMapPicture.tsx +106 -0
  195. package/src/screens/address/SearchResults.tsx +105 -0
  196. package/src/screens/address/SearchScreen.tsx +176 -0
  197. package/src/screens/address/SkipForNow.tsx +43 -0
  198. package/src/screens/address/StreetViewChrome.tsx +81 -0
  199. package/src/screens/address/detail-values.ts +22 -0
  200. package/src/screens/address/fix-source.ts +27 -0
  201. package/src/screens/address/index.ts +8 -0
  202. package/src/screens/address/meta.ts +44 -0
  203. package/src/screens/address/use-address-flow.ts +200 -0
  204. package/src/screens/address/use-label-pin.ts +80 -0
  205. package/src/screens/address/use-pin-actions.ts +192 -0
  206. package/src/screens/biometric/BiometricAuthFlow.tsx +170 -0
  207. package/src/screens/consent/model.ts +79 -10
  208. package/src/screens/liveness/CaptureRing.tsx +91 -0
  209. package/src/screens/liveness/LivenessCamera.tsx +63 -0
  210. package/src/screens/liveness/LivenessHandover.tsx +43 -0
  211. package/src/screens/liveness/LivenessOutcome.tsx +4 -3
  212. package/src/screens/liveness/SelfiePreview.tsx +13 -4
  213. package/src/screens/liveness/ShutterFlash.tsx +25 -0
  214. package/src/screens/liveness/index.ts +4 -0
  215. package/src/screens/liveness/useSelfieUpload.ts +23 -3
  216. package/src/screens/nfc/NfcReadProgress.tsx +1 -0
  217. package/src/screens/nfc/NfcScanIllustration.tsx +7 -18
  218. package/src/screens/useAddressPhotoAttach.ts +77 -0
  219. package/src/screens/useAwaitingPeople.ts +90 -0
  220. package/src/screens/useBusinessDocumentAttach.ts +93 -0
  221. package/src/screens/usePoaAttach.ts +12 -15
  222. package/src/services/api-biometric.ts +37 -0
  223. package/src/services/api-types-biometric.ts +41 -0
  224. package/src/services/api-types.ts +251 -3
  225. package/src/services/api-verify-types.ts +90 -1
  226. package/src/services/api.ts +237 -3
  227. package/src/services/deviceMetadata.ts +1 -1
  228. package/src/services/errors.ts +10 -2
  229. package/src/services/location.ts +234 -0
  230. package/src/services/uploadErrors.ts +30 -0
  231. package/src/services/workflowGate.ts +8 -0
  232. package/src/specs/MyazaFaceDetector.nitro.ts +29 -0
  233. package/src/store/address.ts +97 -0
  234. package/src/store/businessCheck.ts +113 -0
  235. package/src/store/derive.ts +44 -4
  236. package/src/store/kycStore.ts +352 -16
  237. package/src/store/serverConfig.ts +20 -0
  238. package/src/store/session.ts +227 -0
  239. package/src/store/state.ts +289 -2
  240. package/src/store/submit.ts +80 -13
  241. package/src/store/submitApplicant.ts +5 -11
  242. package/src/types/business.ts +23 -2
  243. package/src/types/config.ts +70 -2
  244. package/src/types/verification.ts +13 -1
  245. package/src/types/workflow.ts +112 -1
  246. package/src/screens/KeyPeopleInviteLinks.tsx +0 -239
@@ -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
+ }
@@ -0,0 +1,40 @@
1
+ // ---------------------------------------------------------------------------
2
+ // The one wire path for presence observations — shared by the foreground
3
+ // reporter and the background geofence flush, so the two tiers can never
4
+ // drift on the request shape. Best-effort by contract: a failure returns
5
+ // false and the caller keeps its queue.
6
+ // ---------------------------------------------------------------------------
7
+
8
+ import { resolveBaseUrl } from '../services/resolveUrl';
9
+
10
+ export interface WireObservation {
11
+ day: string;
12
+ source: 'geofence' | 'foreground';
13
+ dwellMinutes: number;
14
+ nightPresent: boolean;
15
+ samples: number;
16
+ integrity?: { mockLocation?: boolean; emulator?: boolean };
17
+ }
18
+
19
+ export async function postObservations(
20
+ apiKey: string,
21
+ devUrl: string | undefined,
22
+ externalUserId: string,
23
+ observations: WireObservation[],
24
+ ): Promise<boolean> {
25
+ if (observations.length === 0) return true;
26
+ try {
27
+ const base = resolveBaseUrl(apiKey, devUrl);
28
+ const response = await fetch(`${base}/api/kyc/address/observations`, {
29
+ method: 'POST',
30
+ headers: {
31
+ 'Content-Type': 'application/json',
32
+ Authorization: `Bearer ${apiKey}`,
33
+ },
34
+ body: JSON.stringify({ externalUserId, observations }),
35
+ });
36
+ return response.ok;
37
+ } catch {
38
+ return false;
39
+ }
40
+ }
@@ -0,0 +1,87 @@
1
+ // ---------------------------------------------------------------------------
2
+ // The FOREGROUND presence reporter — the default tier, no background
3
+ // permission. The host app calls reportAddressPresence() on app open, or the
4
+ // moment the flow submits (watch-wait.ts makes that safe: the watch is minted
5
+ // seconds after the submission is accepted, and a fresh pin waits for it);
6
+ // the SDK takes one while-in-use fix, evaluates the fence ON-DEVICE against
7
+ // the pin stored at capture, and posts a single per-day aggregate. Everything is best-effort: a denied permission, a missing pin, or
8
+ // a network fault returns a reason, never a throw — a presence report must
9
+ // never break the host app's startup path.
10
+ // ---------------------------------------------------------------------------
11
+
12
+ import * as Location from 'expo-location';
13
+ import { currentPosition } from '../services/location';
14
+ import { insideFence, localDayAndNight } from './math';
15
+ import { postObservations } from './post';
16
+ import { loadPresencePin } from './store';
17
+ import { awaitWatch, fetchWatchStatus, pinIsFresh } from './watch-wait';
18
+
19
+ export interface ReportPresenceOptions {
20
+ /** The org's PUBLISHABLE key (the same one the KYC flow mounts with). */
21
+ apiKey: string;
22
+ /** The org's user reference — must match the KYC flow's `userId`. */
23
+ externalUserId: string;
24
+ /** Dev-server override, exactly like the SDK config's `devUrl`. */
25
+ devUrl?: string;
26
+ }
27
+
28
+ export interface ReportPresenceResult {
29
+ reported: boolean;
30
+ /** Whether the fix landed inside the fence (null when nothing was reported). */
31
+ inside: boolean | null;
32
+ reason:
33
+ | 'reported'
34
+ | 'no_pin'
35
+ | 'services_off'
36
+ | 'no_fix'
37
+ | 'outside_fence'
38
+ /** Nothing is monitoring this user right now, so a report would be dropped. */
39
+ | 'no_watch'
40
+ | 'network_error';
41
+ }
42
+
43
+ export async function reportAddressPresence(
44
+ options: ReportPresenceOptions,
45
+ ): Promise<ReportPresenceResult> {
46
+ const pin = loadPresencePin(options.externalUserId);
47
+ if (!pin) return { reported: false, inside: null, reason: 'no_pin' };
48
+
49
+ // The phone's location toggle, checked before the permission dance: off,
50
+ // every fix fails, and `no_fix` told the host nothing about why.
51
+ const services = await Location.hasServicesEnabledAsync().catch(() => true);
52
+ if (!services) return { reported: false, inside: null, reason: 'services_off' };
53
+
54
+ const fix = await currentPosition();
55
+ if (!fix) return { reported: false, inside: null, reason: 'no_fix' };
56
+
57
+ const inside = insideFence(pin, fix);
58
+ // An outside fix is NOT evidence of absence (people go to work) — the server
59
+ // scores presence, never absence — so there is nothing worth sending.
60
+ if (!inside && fix.mocked !== true) {
61
+ return { reported: false, inside: false, reason: 'outside_fence' };
62
+ }
63
+
64
+ // The watch is minted seconds after a submission is accepted, and the
65
+ // ingest drops a report that arrives before it (watch-wait.ts).
66
+ const watch = await awaitWatch(pinIsFresh(pin), {
67
+ fetchStatus: () => fetchWatchStatus(options.apiKey, options.devUrl, options.externalUserId),
68
+ });
69
+ if (watch === 'absent') return { reported: false, inside, reason: 'no_watch' };
70
+
71
+ const { day, nightPresent } = localDayAndNight();
72
+ // One wire path for both tiers (post.ts) — the request shape cannot fork.
73
+ const ok = await postObservations(options.apiKey, options.devUrl, options.externalUserId, [
74
+ {
75
+ day,
76
+ source: 'foreground',
77
+ dwellMinutes: 0,
78
+ nightPresent,
79
+ samples: 1,
80
+ // A mocked fix is REPORTED, flagged — evidence OF fraud is worth
81
+ // more to the watch than silence.
82
+ ...(fix.mocked === true ? { integrity: { mockLocation: true } } : {}),
83
+ },
84
+ ]);
85
+ if (!ok) return { reported: false, inside, reason: 'network_error' };
86
+ return { reported: true, inside, reason: 'reported' };
87
+ }