@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.
- package/README.md +151 -5
- package/android/build.gradle +35 -3
- package/android/src/main/java/com/margelo/nitro/myazakyc/HybridMyazaFaceDetector.kt +77 -0
- package/app.plugin.js +89 -8
- package/ios/HybridMyazaFaceDetector.swift +16 -3
- package/nitrogen/generated/android/c++/JHybridMyazaFaceDetectorSpec.cpp +23 -0
- package/nitrogen/generated/android/c++/JHybridMyazaFaceDetectorSpec.hpp +2 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/myazakyc/HybridMyazaFaceDetectorSpec.kt +9 -0
- package/nitrogen/generated/ios/KycSdkReactNative-Swift-Cxx-Bridge.cpp +8 -0
- package/nitrogen/generated/ios/KycSdkReactNative-Swift-Cxx-Bridge.hpp +43 -0
- package/nitrogen/generated/ios/c++/HybridMyazaFaceDetectorSpecSwift.hpp +17 -0
- package/nitrogen/generated/ios/swift/Func_void_bool.swift +46 -0
- package/nitrogen/generated/ios/swift/HybridMyazaFaceDetectorSpec.swift +2 -0
- package/nitrogen/generated/ios/swift/HybridMyazaFaceDetectorSpec_cxx.swift +31 -0
- package/nitrogen/generated/shared/c++/HybridMyazaFaceDetectorSpec.cpp +2 -0
- package/nitrogen/generated/shared/c++/HybridMyazaFaceDetectorSpec.hpp +3 -0
- package/package.json +14 -2
- package/src/MyazaBiometricAuth.tsx +119 -0
- package/src/MyazaKYC.tsx +12 -0
- package/src/components/CountryField.tsx +8 -0
- package/src/components/CountryRegionPicker.tsx +184 -0
- package/src/components/DashedBorder.tsx +55 -0
- package/src/components/DialCodePicker.tsx +53 -67
- 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 -176
- 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/MultiIdProgress.tsx +107 -0
- package/src/components/MyazaInput.tsx +39 -4
- package/src/components/MyazaSelect.tsx +2 -1
- package/src/components/OptionRow.tsx +22 -3
- package/src/components/OwnershipSlider.tsx +102 -0
- package/src/components/PhoneNumberInput.tsx +45 -4
- package/src/components/PoweredBy.tsx +5 -5
- package/src/components/PresenceBlocks.tsx +153 -0
- package/src/components/StaggerIn.tsx +63 -0
- package/src/components/StepView.tsx +43 -0
- package/src/components/StickyActions.tsx +56 -0
- package/src/components/dialCodeRows.ts +64 -0
- package/src/components/glass/FloatingSheet.tsx +73 -21
- package/src/components/glass/GlassSheet.tsx +14 -2
- package/src/components/icon-map.ts +176 -0
- package/src/components/stepHeaderMeta.tsx +45 -5
- package/src/config/addressCollection.ts +125 -0
- package/src/config/biometricOptions.ts +100 -0
- package/src/config/business.ts +26 -7
- package/src/config/businessDetailsValidity.ts +52 -0
- package/src/config/businessPrefill.ts +72 -0
- package/src/config/businessSteps.ts +20 -3
- package/src/config/consentStep.ts +19 -0
- package/src/config/keyPeople.ts +111 -7
- package/src/config/keyPeopleCorporate.ts +19 -0
- package/src/config/keyPeopleOwnerTypes.ts +12 -0
- package/src/config/keyPeopleOwners.ts +26 -0
- package/src/config/keyPeoplePrefill.ts +138 -0
- package/src/config/keyPeopleSectionDefs.ts +65 -0
- package/src/config/keyPeopleSections.ts +167 -0
- package/src/config/proofOfAddress.ts +54 -11
- package/src/config/regions.ts +24 -0
- package/src/config/registrationHint.ts +1 -1
- package/src/config/stepOrder.ts +100 -6
- package/src/config/theme.ts +8 -2
- package/src/config/uploadLimits.ts +35 -0
- package/src/config/website.ts +36 -0
- package/src/config/workflowMerge.ts +16 -5
- package/src/emrtd/activeAuth.ts +100 -0
- package/src/emrtd/extras.ts +76 -0
- package/src/emrtd/files.ts +5 -0
- package/src/emrtd/index.ts +1 -0
- package/src/emrtd/read.ts +8 -1
- package/src/emrtd/session.ts +41 -0
- package/src/emrtd/stages.ts +11 -0
- 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/contact-recovery.ts +44 -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/multi-id.ts +210 -0
- package/src/lib/poa-country-gate.ts +27 -0
- package/src/lib/resubmit.ts +130 -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/screen-corners.ts +50 -0
- package/src/lib/selfie-upload-wait.ts +79 -0
- package/src/lib/step-log.ts +24 -3
- package/src/lib/street-view-fov.ts +42 -0
- package/src/lib/use-multi-id-plan.ts +29 -0
- package/src/lib/webview-available.ts +47 -0
- package/src/liveness/useLiveness.ts +6 -1
- package/src/liveness/useModelReady.ts +65 -0
- package/src/liveness/visionCameraFaceDetector.ts +35 -0
- package/src/presence/background-math.ts +100 -0
- package/src/presence/background-store.ts +82 -0
- package/src/presence/background.ts +165 -0
- package/src/presence/foreground-service.ts +193 -0
- package/src/presence/fs.ts +51 -0
- package/src/presence/geofence.ts +41 -0
- package/src/presence/math.ts +44 -0
- package/src/presence/post.ts +40 -0
- package/src/presence/report.ts +87 -0
- package/src/presence/sampler.ts +110 -0
- package/src/presence/status.ts +92 -0
- package/src/presence/store.ts +94 -0
- package/src/presence/tier.ts +36 -0
- package/src/presence/watch-wait.ts +81 -0
- package/src/screens/AddressCountryControl.tsx +115 -0
- package/src/screens/ApplicantRoleStep.tsx +9 -5
- package/src/screens/BusinessCheckPanel.tsx +89 -0
- package/src/screens/BusinessDetailsFields.tsx +112 -0
- package/src/screens/BusinessDetailsStep.tsx +133 -145
- package/src/screens/BusinessDocumentSlot.tsx +5 -5
- package/src/screens/BusinessDocumentsStep.tsx +5 -56
- package/src/screens/BusinessKeyPeopleStep.tsx +103 -71
- package/src/screens/BusinessPickedCard.tsx +86 -0
- package/src/screens/BusinessPickedSection.tsx +51 -0
- package/src/screens/BusinessRegistryPickers.tsx +67 -0
- package/src/screens/BusinessSandboxToggle.tsx +114 -0
- package/src/screens/BusinessSearch.tsx +199 -0
- package/src/screens/BusinessSearchResults.tsx +115 -0
- package/src/screens/CompanyInfoFields.tsx +91 -51
- package/src/screens/ConsentStep.tsx +37 -17
- package/src/screens/ContactActions.tsx +54 -0
- package/src/screens/ContactDestinationField.tsx +5 -0
- package/src/screens/ContactEntryPanel.tsx +75 -0
- package/src/screens/ContactVerificationStep.tsx +43 -42
- package/src/screens/CountrySelectStep.tsx +13 -147
- package/src/screens/IdTypeStep.tsx +12 -5
- package/src/screens/KeepLinksSheet.tsx +88 -0
- package/src/screens/KeyPeopleAwaitCard.tsx +204 -0
- package/src/screens/KeyPeopleAwaitList.tsx +126 -0
- package/src/screens/KeyPeoplePending.tsx +154 -0
- package/src/screens/KeyPeopleSection.tsx +162 -0
- package/src/screens/KeyPeopleSectionsList.tsx +103 -0
- package/src/screens/KeyPeopleUboExemption.tsx +72 -0
- package/src/screens/KeyPersonCard.tsx +36 -4
- package/src/screens/KeyPersonForm.tsx +195 -36
- package/src/screens/KeyPersonKindToggle.tsx +68 -0
- package/src/screens/KeyPersonOwners.tsx +109 -0
- package/src/screens/KeyPersonRoleChips.tsx +68 -0
- package/src/screens/KeyPersonSheet.tsx +92 -108
- package/src/screens/LivenessAvatar.tsx +15 -3
- package/src/screens/LivenessStep.tsx +172 -52
- package/src/screens/NfcStep.tsx +14 -0
- package/src/screens/PoaDocumentTypeList.tsx +66 -0
- package/src/screens/ProofOfAddressParts.tsx +151 -0
- package/src/screens/ProofOfAddressStep.tsx +58 -96
- 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 +82 -119
- 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/NfcReadProgress.tsx +1 -0
- package/src/screens/nfc/NfcScanIllustration.tsx +7 -18
- package/src/screens/useAddressPhotoAttach.ts +77 -0
- package/src/screens/useAwaitingPeople.ts +90 -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 +251 -3
- package/src/services/api-verify-types.ts +90 -1
- package/src/services/api.ts +237 -3
- package/src/services/deviceMetadata.ts +1 -1
- package/src/services/errors.ts +10 -2
- package/src/services/location.ts +234 -0
- package/src/services/uploadErrors.ts +30 -0
- package/src/services/workflowGate.ts +8 -0
- package/src/specs/MyazaFaceDetector.nitro.ts +29 -0
- package/src/store/address.ts +97 -0
- package/src/store/businessCheck.ts +113 -0
- package/src/store/derive.ts +44 -4
- package/src/store/kycStore.ts +352 -16
- package/src/store/serverConfig.ts +20 -0
- package/src/store/session.ts +227 -0
- package/src/store/state.ts +289 -2
- package/src/store/submit.ts +80 -13
- package/src/store/submitApplicant.ts +5 -11
- package/src/types/business.ts +23 -2
- package/src/types/config.ts +70 -2
- package/src/types/verification.ts +13 -1
- package/src/types/workflow.ts +112 -1
- 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
|
+
}
|