@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.
- package/README.md +151 -5
- package/android/src/main/java/com/margelo/nitro/myazakyc/HybridMyazaFaceDetector.kt +6 -4
- package/app.plugin.js +89 -8
- package/ios/HybridMyazaFaceDetector.swift +8 -3
- package/package.json +14 -2
- package/src/MyazaBiometricAuth.tsx +119 -0
- package/src/components/CountryField.tsx +8 -0
- package/src/components/CountryRegionPicker.tsx +184 -0
- package/src/components/DialCodePicker.tsx +47 -68
- package/src/components/DialCodeRow.tsx +102 -0
- package/src/components/FramedMapPicker.tsx +122 -0
- package/src/components/GeoBadge.tsx +34 -0
- package/src/components/Icon.tsx +2 -185
- package/src/components/KycFlow.tsx +60 -32
- package/src/components/LineSkeleton.tsx +100 -0
- package/src/components/MapChrome.tsx +67 -0
- package/src/components/MapPinMarker.tsx +54 -0
- package/src/components/MapPinPicker.tsx +179 -0
- package/src/components/MilestoneTrack.tsx +155 -0
- package/src/components/MyazaInput.tsx +17 -1
- package/src/components/OptionRow.tsx +22 -3
- package/src/components/PhoneNumberInput.tsx +8 -0
- package/src/components/PoweredBy.tsx +5 -5
- package/src/components/PresenceBlocks.tsx +153 -0
- package/src/components/StepView.tsx +17 -0
- package/src/components/StickyActions.tsx +56 -0
- package/src/components/dialCodeRows.ts +64 -0
- package/src/components/icon-map.ts +176 -0
- package/src/components/stepHeaderMeta.tsx +42 -4
- package/src/config/addressCollection.ts +125 -0
- package/src/config/biometricOptions.ts +100 -0
- package/src/config/business.ts +20 -1
- package/src/config/businessSteps.ts +8 -2
- package/src/config/consentStep.ts +19 -0
- package/src/config/proofOfAddress.ts +54 -11
- package/src/config/regions.ts +24 -0
- package/src/config/stepOrder.ts +79 -5
- package/src/config/uploadLimits.ts +35 -0
- package/src/config/workflowMerge.ts +11 -5
- package/src/index.ts +54 -1
- package/src/lib/address-current-location.ts +171 -0
- package/src/lib/address-field-modes.ts +119 -0
- package/src/lib/address-flow.ts +172 -0
- package/src/lib/address-helpers.ts +48 -0
- package/src/lib/address-line.ts +76 -0
- package/src/lib/address-pin-move.ts +97 -0
- package/src/lib/address-step-recovery.ts +63 -0
- package/src/lib/authed-image.ts +78 -0
- package/src/lib/biometric-auth.ts +52 -0
- package/src/lib/biometric-copy.ts +51 -0
- package/src/lib/captureRing.ts +83 -0
- package/src/lib/country-adoption.ts +89 -0
- package/src/lib/inferred-country.ts +65 -0
- package/src/lib/livenessLayout.ts +49 -0
- package/src/lib/map-frame.ts +159 -0
- package/src/lib/map-tiles.ts +143 -0
- package/src/lib/poa-country-gate.ts +27 -0
- package/src/lib/result-copy.ts +116 -0
- package/src/lib/result-wait.ts +53 -0
- package/src/lib/review-map-surface.ts +26 -0
- package/src/lib/scope.ts +31 -0
- package/src/lib/selfie-upload-wait.ts +79 -0
- package/src/lib/street-view-fov.ts +42 -0
- package/src/lib/webview-available.ts +47 -0
- package/src/liveness/useLiveness.ts +6 -1
- package/src/presence/background-math.ts +100 -0
- package/src/presence/background-store.ts +82 -0
- package/src/presence/background.ts +165 -0
- package/src/presence/foreground-service.ts +193 -0
- package/src/presence/fs.ts +51 -0
- package/src/presence/geofence.ts +41 -0
- package/src/presence/math.ts +44 -0
- package/src/presence/post.ts +40 -0
- package/src/presence/report.ts +87 -0
- package/src/presence/sampler.ts +110 -0
- package/src/presence/status.ts +92 -0
- package/src/presence/store.ts +94 -0
- package/src/presence/tier.ts +36 -0
- package/src/presence/watch-wait.ts +81 -0
- package/src/screens/AddressCountryControl.tsx +115 -0
- package/src/screens/BusinessDetailsFields.tsx +3 -0
- package/src/screens/BusinessDetailsStep.tsx +2 -0
- package/src/screens/BusinessDocumentSlot.tsx +3 -2
- package/src/screens/BusinessDocumentsStep.tsx +4 -55
- package/src/screens/CompanyInfoFields.tsx +6 -1
- package/src/screens/ConsentStep.tsx +6 -17
- package/src/screens/ContactActions.tsx +54 -0
- package/src/screens/ContactDestinationField.tsx +4 -0
- package/src/screens/ContactEntryPanel.tsx +75 -0
- package/src/screens/ContactVerificationStep.tsx +26 -56
- package/src/screens/CountrySelectStep.tsx +13 -147
- package/src/screens/LivenessAvatar.tsx +15 -3
- package/src/screens/LivenessStep.tsx +154 -52
- package/src/screens/PoaDocumentTypeList.tsx +66 -0
- package/src/screens/ProofOfAddressParts.tsx +151 -0
- package/src/screens/ProofOfAddressStep.tsx +58 -99
- package/src/screens/SubmittedBadge.tsx +25 -0
- package/src/screens/SubmittedError.tsx +64 -0
- package/src/screens/SubmittedResult.tsx +117 -0
- package/src/screens/SubmittedStep.tsx +71 -164
- package/src/screens/SubmittedSuccess.tsx +127 -0
- package/src/screens/SubmittedWaiting.tsx +45 -0
- package/src/screens/address/AddressEntranceStep.tsx +161 -0
- package/src/screens/address/AddressIntroGate.tsx +148 -0
- package/src/screens/address/AddressMapStub.tsx +50 -0
- package/src/screens/address/AddressPinStep.tsx +178 -0
- package/src/screens/address/AddressReviewStep.tsx +162 -0
- package/src/screens/address/AddressSandboxTabs.tsx +158 -0
- package/src/screens/address/AddressSearchField.tsx +115 -0
- package/src/screens/address/AddressSearchStep.tsx +94 -0
- package/src/screens/address/CurrentLocationRow.tsx +135 -0
- package/src/screens/address/DetailsSheet.tsx +175 -0
- package/src/screens/address/DetailsSheetFields.tsx +184 -0
- package/src/screens/address/EntranceDropzone.tsx +178 -0
- package/src/screens/address/EntranceFraming.tsx +66 -0
- package/src/screens/address/EntrancePills.tsx +60 -0
- package/src/screens/address/FramedStreetView.tsx +155 -0
- package/src/screens/address/IntroDisclosures.tsx +210 -0
- package/src/screens/address/LabelDecisionRow.tsx +113 -0
- package/src/screens/address/PinSummaryRow.tsx +105 -0
- package/src/screens/address/ReviewAddressBand.tsx +111 -0
- package/src/screens/address/ReviewEntranceThumbs.tsx +64 -0
- package/src/screens/address/ReviewMapPicture.tsx +106 -0
- package/src/screens/address/SearchResults.tsx +105 -0
- package/src/screens/address/SearchScreen.tsx +176 -0
- package/src/screens/address/SkipForNow.tsx +43 -0
- package/src/screens/address/StreetViewChrome.tsx +81 -0
- package/src/screens/address/detail-values.ts +22 -0
- package/src/screens/address/fix-source.ts +27 -0
- package/src/screens/address/index.ts +8 -0
- package/src/screens/address/meta.ts +44 -0
- package/src/screens/address/use-address-flow.ts +200 -0
- package/src/screens/address/use-label-pin.ts +80 -0
- package/src/screens/address/use-pin-actions.ts +192 -0
- package/src/screens/biometric/BiometricAuthFlow.tsx +170 -0
- package/src/screens/consent/model.ts +79 -10
- package/src/screens/liveness/CaptureRing.tsx +91 -0
- package/src/screens/liveness/LivenessCamera.tsx +63 -0
- package/src/screens/liveness/LivenessHandover.tsx +43 -0
- package/src/screens/liveness/LivenessOutcome.tsx +4 -3
- package/src/screens/liveness/SelfiePreview.tsx +13 -4
- package/src/screens/liveness/ShutterFlash.tsx +25 -0
- package/src/screens/liveness/index.ts +4 -0
- package/src/screens/liveness/useSelfieUpload.ts +23 -3
- package/src/screens/nfc/NfcScanIllustration.tsx +7 -18
- package/src/screens/useAddressPhotoAttach.ts +77 -0
- package/src/screens/useBusinessDocumentAttach.ts +93 -0
- package/src/screens/usePoaAttach.ts +12 -15
- package/src/services/api-biometric.ts +37 -0
- package/src/services/api-types-biometric.ts +41 -0
- package/src/services/api-types.ts +93 -1
- package/src/services/api-verify-types.ts +37 -0
- package/src/services/api.ts +133 -3
- package/src/services/deviceMetadata.ts +1 -1
- package/src/services/location.ts +234 -0
- package/src/services/uploadErrors.ts +30 -0
- package/src/services/workflowGate.ts +7 -0
- package/src/store/address.ts +97 -0
- package/src/store/derive.ts +29 -4
- package/src/store/kycStore.ts +112 -3
- package/src/store/serverConfig.ts +18 -0
- package/src/store/session.ts +23 -4
- package/src/store/state.ts +134 -0
- package/src/store/submit.ts +18 -1
- package/src/types/config.ts +48 -1
- package/src/types/verification.ts +12 -0
- 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
|
+
}
|