@sharib/geopulse-react-native 0.1.1
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 +194 -0
- package/dist/index.d.ts +489 -0
- package/dist/index.js +1036 -0
- package/dist/index.js.map +1 -0
- package/package.json +40 -0
package/README.md
ADDED
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
# @sharib/geopulse-react-native
|
|
2
|
+
|
|
3
|
+
On-device geofence detection and event sync for React Native / Expo apps. Fetches an
|
|
4
|
+
application's configured geofences from GeoPulse, watches location (real device or the
|
|
5
|
+
built-in simulator), and reports `geofence.enter` / `geofence.exit` events to the GeoPulse
|
|
6
|
+
backend — with a persistent, backoff-retried offline queue so a flaky connection never loses
|
|
7
|
+
an event.
|
|
8
|
+
|
|
9
|
+
## Installation
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
npm install @sharib/geopulse-react-native
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
## Initialization
|
|
16
|
+
|
|
17
|
+
Get `apiKey` and `applicationId` from the admin portal's `/sdk` page (the application created
|
|
18
|
+
automatically for you during onboarding is named "Playground"). `functionsBaseUrl` is the
|
|
19
|
+
deployed Cloud Functions base URL, e.g. `https://us-central1-<your-project-id>.cloudfunctions.net`
|
|
20
|
+
(or your local Functions emulator's URL during development):
|
|
21
|
+
|
|
22
|
+
```ts
|
|
23
|
+
import { GeoPulse } from "@sharib/geopulse-react-native";
|
|
24
|
+
|
|
25
|
+
await GeoPulse.initialize({
|
|
26
|
+
apiKey: "gp_test_xxx",
|
|
27
|
+
applicationId: "app_xxx",
|
|
28
|
+
functionsBaseUrl: "https://us-central1-your-project.cloudfunctions.net",
|
|
29
|
+
});
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
`initialize()` fetches the application's currently active geofences from the backend
|
|
33
|
+
(`getProjectConfig`) — this is the "SDK receives project configuration" step. It throws if any
|
|
34
|
+
of the three fields above are missing, or if the backend call fails (bad/revoked SDK key,
|
|
35
|
+
network error, etc.) — check `GeoPulse.getStatus()` (`"error"`) if you need to react to that.
|
|
36
|
+
|
|
37
|
+
## Start / stop
|
|
38
|
+
|
|
39
|
+
```ts
|
|
40
|
+
await GeoPulse.start(); // requests location permission, begins watching location
|
|
41
|
+
GeoPulse.stop();
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
`start()` throws if `initialize()` hasn't completed successfully, or if location permission is
|
|
45
|
+
denied.
|
|
46
|
+
|
|
47
|
+
## Events
|
|
48
|
+
|
|
49
|
+
```ts
|
|
50
|
+
const onEnter = (event) => console.log("entered", event.geofenceId, event);
|
|
51
|
+
GeoPulse.on("geofence.enter", onEnter);
|
|
52
|
+
GeoPulse.on("geofence.exit", (event) => console.log("exited", event));
|
|
53
|
+
|
|
54
|
+
// later
|
|
55
|
+
GeoPulse.off("geofence.enter", onEnter);
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
An event fires at most once per actual state transition — moving around *inside* a geofence
|
|
59
|
+
never re-fires `geofence.enter`, and a geofence that's still active in your config never fires
|
|
60
|
+
`geofence.exit` just because you didn't move (see `src/geofencing/state-machine.ts` in this
|
|
61
|
+
package's source for the exact transition rules this guarantees).
|
|
62
|
+
|
|
63
|
+
## Experiences (campaign delivery)
|
|
64
|
+
|
|
65
|
+
If a geofence event matches an active Campaign in the admin portal, the SDK surfaces the
|
|
66
|
+
associated Experience (a banner/card/message):
|
|
67
|
+
|
|
68
|
+
```ts
|
|
69
|
+
GeoPulse.on("experience.received", (payload) => {
|
|
70
|
+
// payload: ExperienceDeliveryPayload — { deliveryId, campaignId, experience: {...}, event: {...} }
|
|
71
|
+
showBanner(payload.experience);
|
|
72
|
+
});
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
No push notifications involved: the SDK polls its own device's pending experiences on a timer
|
|
76
|
+
(`GeoPulseConfig.experiencePollIntervalMs`, defaults to 30s) over the same connection it already
|
|
77
|
+
uses for config/ingestion, and acknowledges each one automatically once it's fired the event —
|
|
78
|
+
you never need to call anything to mark it "seen." The SDK also never re-fires the same
|
|
79
|
+
`deliveryId` twice. There's no `getActiveExperiences()`: `on("experience.received")` is the
|
|
80
|
+
whole surface — the SDK transports and notifies, your app owns the UI.
|
|
81
|
+
|
|
82
|
+
## Status / diagnostics
|
|
83
|
+
|
|
84
|
+
Beyond the two required by the spec (`getStatus`, `getPermissionStatus`), a rich diagnostics
|
|
85
|
+
snapshot and a few small read-only accessors exist so a host app can build a status screen
|
|
86
|
+
without duplicating SDK-internal state:
|
|
87
|
+
|
|
88
|
+
```ts
|
|
89
|
+
GeoPulse.getStatus(); // "idle" | "initializing" | "initialized" | "running" | "stopped" | "error"
|
|
90
|
+
await GeoPulse.getPermissionStatus(); // "granted" | "denied" | "undetermined"
|
|
91
|
+
await GeoPulse.getDiagnostics(); // rich snapshot: network status, config status, pending events, last sync, etc.
|
|
92
|
+
GeoPulse.getApplicationId(); // string | null — resolved server-side, not just an echo of what you passed in
|
|
93
|
+
GeoPulse.getDeviceId(); // string | null — persisted across app restarts
|
|
94
|
+
// getDiagnostics() also includes `locationError: string | null` — set if the location provider
|
|
95
|
+
// failed to start (e.g. OS location services off).
|
|
96
|
+
GeoPulse.getConfiguredGeofences(); // the geofences fetched by initialize()
|
|
97
|
+
GeoPulse.getLastKnownLocation(); // most recent location update, or null
|
|
98
|
+
await GeoPulse.getPendingEventCount(); // events waiting in the offline queue right now
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
## Simulator
|
|
102
|
+
|
|
103
|
+
Real device movement isn't required to test any of this. In development builds (`__DEV__ ===
|
|
104
|
+
true`), `GeoPulse` runs on a simulated location provider by default:
|
|
105
|
+
|
|
106
|
+
```ts
|
|
107
|
+
GeoPulse.simulator.setLocation({ latitude: 33.4484, longitude: -112.074 });
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
Each call runs through the exact same geofence engine and event pipeline as a real device
|
|
111
|
+
update would — the simulator is a location *source*, not a shortcut that fakes an event
|
|
112
|
+
directly.
|
|
113
|
+
|
|
114
|
+
**The simulator is inert in production builds.** `GeoPulse.simulator.setLocation(...)` is a
|
|
115
|
+
no-op (with a console warning) once `__DEV__` is false — release builds run on
|
|
116
|
+
`DeviceLocationProvider` (real `expo-location`) instead, automatically, with no code change
|
|
117
|
+
required. If you need real device location *during development* instead of the simulator,
|
|
118
|
+
construct your own instance rather than using the `GeoPulse` singleton:
|
|
119
|
+
|
|
120
|
+
```ts
|
|
121
|
+
import { GeoPulseSdk, DeviceLocationProvider, ExpoPermissionProvider } from "@sharib/geopulse-react-native";
|
|
122
|
+
|
|
123
|
+
const sdk = new GeoPulseSdk({
|
|
124
|
+
locationProvider: new DeviceLocationProvider(),
|
|
125
|
+
permissionProvider: new ExpoPermissionProvider(),
|
|
126
|
+
});
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
## Permissions
|
|
130
|
+
|
|
131
|
+
This SDK requests **foreground location only** — never background location. Concretely:
|
|
132
|
+
|
|
133
|
+
- **iOS**: `NSLocationWhenInUseUsageDescription` must be set in your app's `app.json` (or
|
|
134
|
+
`Info.plist` directly for a bare RN app); `expo-location`'s config plugin sets this from the
|
|
135
|
+
`locationWhenInUsePermission` option. No `NSLocationAlwaysAndWhenInUseUsageDescription` is
|
|
136
|
+
requested — the SDK will never prompt for "Always" access.
|
|
137
|
+
- **Android**: `ACCESS_FINE_LOCATION` (and `ACCESS_COARSE_LOCATION` as a fallback) in the
|
|
138
|
+
manifest, via your app's `app.json`'s `android.permissions`. No `ACCESS_BACKGROUND_LOCATION`.
|
|
139
|
+
- **Foreground-only limitation**: location updates (and therefore geofence detection) only run
|
|
140
|
+
while the app is in the foreground. Backgrounding the app pauses detection — an enter/exit
|
|
141
|
+
that happens while the app is backgrounded will not be caught until the app is foregrounded
|
|
142
|
+
again and a new location update arrives. This is a deliberate scope boundary for this phase,
|
|
143
|
+
not an oversight — background geofencing needs a materially different architecture (a native
|
|
144
|
+
background task, plus battery/OS-throttling tradeoffs) and is planned for a later phase. Do
|
|
145
|
+
not present this SDK as providing production-grade background geofencing.
|
|
146
|
+
- **Battery**: `DeviceLocationProvider` uses `Location.Accuracy.Balanced` with a 10m distance
|
|
147
|
+
filter and a 5s minimum interval — a reasonable foreground default, not tuned for
|
|
148
|
+
high-precision or high-frequency tracking.
|
|
149
|
+
|
|
150
|
+
## Troubleshooting
|
|
151
|
+
|
|
152
|
+
- **`initialize()` rejects with "requires functionsBaseUrl"** — you only passed
|
|
153
|
+
`apiKey`/`applicationId`; add `functionsBaseUrl` (see Initialization above).
|
|
154
|
+
- **`initialize()` rejects with a fetch/network error** — check `functionsBaseUrl` is reachable
|
|
155
|
+
from the device (an emulator URL like `127.0.0.1` is not reachable from a physical device or
|
|
156
|
+
even always from a simulator — use your machine's LAN IP or a deployed URL instead).
|
|
157
|
+
- **`start()` rejects with "Location permission not granted"** — the user denied the OS
|
|
158
|
+
permission prompt (real device), or `PermissionProvider.request()` didn't resolve
|
|
159
|
+
`"granted"` (simulator mode always grants — this only happens with `DeviceLocationProvider`).
|
|
160
|
+
- **No `geofence.enter` ever fires in the simulator** — confirm `GeoPulse.getConfiguredGeofences()`
|
|
161
|
+
isn't empty; if it is, no geofence exists yet for this application's project in the admin
|
|
162
|
+
portal, or `initialize()` was called before the SDK key's application had one created.
|
|
163
|
+
- **Events show up in `getPendingEventCount()` but never seem to send** — each queued event has
|
|
164
|
+
its own exponential-backoff schedule; a recently-failed event isn't retried again immediately.
|
|
165
|
+
Retries also fire as soon as the network monitor reports connectivity restored, so this should
|
|
166
|
+
resolve quickly once you're actually back online — if it doesn't, check `functionsBaseUrl` is
|
|
167
|
+
reachable at all.
|
|
168
|
+
|
|
169
|
+
## Internal architecture
|
|
170
|
+
|
|
171
|
+
```
|
|
172
|
+
src/
|
|
173
|
+
core/ SDK lifecycle + diagnostics (GeoPulseSdk), Logger — the only module that wires everything else together
|
|
174
|
+
config/ GeoPulseConfig type, device-id persistence, config caching
|
|
175
|
+
location/ LocationProvider abstraction: SimulatedLocationProvider (default in dev) + DeviceLocationProvider (expo-location)
|
|
176
|
+
geofencing/ GeofenceStateMachine — haversine distance + INSIDE/OUTSIDE transition tracking
|
|
177
|
+
events/ Event payload construction + id generation
|
|
178
|
+
storage/ Offline pending-event queue (InMemoryEventQueueStore for tests, AsyncStorageEventQueueStore for the real app)
|
|
179
|
+
sync/ SyncManager (send-or-queue, backoff retry), centralized retry policy, NetworkMonitor
|
|
180
|
+
permissions/ PermissionProvider abstraction: SimulatedPermissionProvider (default in dev) + ExpoPermissionProvider
|
|
181
|
+
firebase/ ApiClient — plain-fetch HTTPS client for the Cloud Functions endpoints (no Firebase SDK on-device)
|
|
182
|
+
index.ts Public API (GeoPulse) — wires the real RN adapters; everything else is injectable for testing
|
|
183
|
+
```
|
|
184
|
+
|
|
185
|
+
Every module except `location/device-location-provider.ts`,
|
|
186
|
+
`permissions/expo-permission-provider.ts`, `storage/async-storage-store.ts`,
|
|
187
|
+
`config/async-storage-key-value-store.ts`, and `sync/netinfo-network-monitor.ts` has zero React
|
|
188
|
+
Native dependencies and runs under plain Node/Vitest — those five are the only files that
|
|
189
|
+
import an RN-only package, and nothing in `core/`, `geofencing/`, `events/`, `sync/`
|
|
190
|
+
(excluding the one file above), or `firebase/` imports them (see the comments at the top of
|
|
191
|
+
`storage/index.ts`, `location/index.ts`, `config/index.ts`, `permissions/index.ts`, and
|
|
192
|
+
`sync/index.ts`). That split is what makes `GeoPulseSdk` and the geofence engine unit-testable
|
|
193
|
+
without a device, simulator, or Metro bundler — run `pnpm test` from this package's directory
|
|
194
|
+
(55 tests as of this phase).
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,489 @@
|
|
|
1
|
+
declare const GEOFENCE_EVENT_TYPES: readonly ["geofence.enter", "geofence.exit"];
|
|
2
|
+
type GeofenceEventType = (typeof GEOFENCE_EVENT_TYPES)[number];
|
|
3
|
+
declare const DEVICE_PLATFORMS: readonly ["ios", "android", "web", "unknown"];
|
|
4
|
+
type DevicePlatform = (typeof DEVICE_PLATFORMS)[number];
|
|
5
|
+
declare const EXPERIENCE_TYPES: readonly ["banner", "card", "message"];
|
|
6
|
+
type ExperienceType = (typeof EXPERIENCE_TYPES)[number];
|
|
7
|
+
interface ExperienceStyle {
|
|
8
|
+
backgroundColor: string | null;
|
|
9
|
+
textColor: string | null;
|
|
10
|
+
accentColor: string | null;
|
|
11
|
+
}
|
|
12
|
+
interface GeofenceCenter {
|
|
13
|
+
latitude: number;
|
|
14
|
+
longitude: number;
|
|
15
|
+
}
|
|
16
|
+
interface SdkGeofenceConfig {
|
|
17
|
+
id: string;
|
|
18
|
+
name: string;
|
|
19
|
+
locationId: string;
|
|
20
|
+
locationName: string;
|
|
21
|
+
center: GeofenceCenter;
|
|
22
|
+
radiusMeters: number;
|
|
23
|
+
}
|
|
24
|
+
interface SdkProjectConfig {
|
|
25
|
+
applicationId: string;
|
|
26
|
+
projectId: string;
|
|
27
|
+
applicationEnabled: boolean;
|
|
28
|
+
configVersion: string;
|
|
29
|
+
geofences: SdkGeofenceConfig[];
|
|
30
|
+
}
|
|
31
|
+
interface LocationEventPayload {
|
|
32
|
+
id: string;
|
|
33
|
+
type: GeofenceEventType;
|
|
34
|
+
projectId: string;
|
|
35
|
+
applicationId: string;
|
|
36
|
+
geofenceId: string;
|
|
37
|
+
locationId: string;
|
|
38
|
+
deviceId: string;
|
|
39
|
+
timestamp: string;
|
|
40
|
+
latitude: number;
|
|
41
|
+
longitude: number;
|
|
42
|
+
metadata: Record<string, string>;
|
|
43
|
+
sdkVersion: string;
|
|
44
|
+
attempts: number;
|
|
45
|
+
}
|
|
46
|
+
interface ExperienceDeliveryPayload {
|
|
47
|
+
type: "location.experience";
|
|
48
|
+
deliveryId: string;
|
|
49
|
+
campaignId: string;
|
|
50
|
+
experience: {
|
|
51
|
+
id: string;
|
|
52
|
+
type: ExperienceType;
|
|
53
|
+
title: string;
|
|
54
|
+
body: string;
|
|
55
|
+
imageUrl: string | null;
|
|
56
|
+
ctaLabel: string | null;
|
|
57
|
+
ctaUrl: string | null;
|
|
58
|
+
style: ExperienceStyle | null;
|
|
59
|
+
};
|
|
60
|
+
event: {
|
|
61
|
+
id: string;
|
|
62
|
+
type: GeofenceEventType;
|
|
63
|
+
geofenceId: string;
|
|
64
|
+
timestamp: string;
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
interface GeoPulseConfig {
|
|
69
|
+
apiKey: string;
|
|
70
|
+
applicationId: string;
|
|
71
|
+
/**
|
|
72
|
+
* Base URL of the deployed GeoPulse Cloud Functions (e.g.
|
|
73
|
+
* `https://us-central1-<project-id>.cloudfunctions.net`, or the local
|
|
74
|
+
* Functions emulator's URL during development). Not part of the minimal
|
|
75
|
+
* `{ apiKey, applicationId }` example in the docs because it's
|
|
76
|
+
* deployment-specific, but required in practice — `initialize()` throws a
|
|
77
|
+
* clear error if it's missing.
|
|
78
|
+
*/
|
|
79
|
+
functionsBaseUrl?: string;
|
|
80
|
+
/** How often to retry queued (previously failed) events, in milliseconds. Defaults to 30s. */
|
|
81
|
+
retryIntervalMs?: number;
|
|
82
|
+
/** How often to poll for pending campaign experiences (Phase 5B), in milliseconds. Defaults to 30s, mirroring `retryIntervalMs`. A host app that wants faster demo feedback (see apps/playground) can pass a smaller value. */
|
|
83
|
+
experiencePollIntervalMs?: number;
|
|
84
|
+
/** Enables the lifecycle/event debug log lines described in this package's README. Defaults to `__DEV__`. Never logs the SDK key or event payload bodies. */
|
|
85
|
+
debug?: boolean;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Minimal persistent key-value abstraction, shared by device-id.ts and
|
|
90
|
+
* config-cache.ts — deliberately smaller than `storage/EventQueueStore`
|
|
91
|
+
* (which manages a whole collection) since these two only ever need "get
|
|
92
|
+
* one string" / "set one string".
|
|
93
|
+
*/
|
|
94
|
+
interface KeyValueStore {
|
|
95
|
+
getItem(key: string): Promise<string | null>;
|
|
96
|
+
setItem(key: string, value: string): Promise<void>;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
interface DeviceIdentity {
|
|
100
|
+
deviceId: string;
|
|
101
|
+
platform: DevicePlatform;
|
|
102
|
+
sdkVersion: string;
|
|
103
|
+
}
|
|
104
|
+
/**
|
|
105
|
+
* Named `firebase/` per this project's spec ("communication with GeoPulse
|
|
106
|
+
* backend"), but deliberately does NOT use the Firebase client SDK — it
|
|
107
|
+
* talks to the Cloud Functions HTTPS endpoints over plain `fetch` (built
|
|
108
|
+
* into both React Native's JS runtime and Node 18+, so this file is
|
|
109
|
+
* testable without any RN runtime at all). That keeps the SDK's dependency
|
|
110
|
+
* footprint small and means device requests carry only the app-scoped SDK
|
|
111
|
+
* key, never a Firebase credential of any kind.
|
|
112
|
+
*/
|
|
113
|
+
declare class ApiClient {
|
|
114
|
+
private readonly baseUrl;
|
|
115
|
+
private readonly sdkKey;
|
|
116
|
+
constructor(baseUrl: string, sdkKey: string);
|
|
117
|
+
/**
|
|
118
|
+
* `identity` doubles as this device's registration/heartbeat (see
|
|
119
|
+
* functions/src/events/get-project-config.ts) — sent as query params
|
|
120
|
+
* since GET requests carry no body.
|
|
121
|
+
*/
|
|
122
|
+
getProjectConfig(identity: DeviceIdentity): Promise<SdkProjectConfig>;
|
|
123
|
+
ingestEvent(payload: LocationEventPayload): Promise<{
|
|
124
|
+
id: string;
|
|
125
|
+
deduplicated: boolean;
|
|
126
|
+
}>;
|
|
127
|
+
/** Polled by GeoPulseSdk's experience-polling loop — see core/geo-pulse-sdk.ts. */
|
|
128
|
+
getPendingExperiences(deviceId: string): Promise<ExperienceDeliveryPayload[]>;
|
|
129
|
+
/** Confirms a delivery was surfaced via `experience.received`, so it stops appearing in future polls. */
|
|
130
|
+
acknowledgeExperienceDelivery(deliveryId: string): Promise<void>;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
interface LocationUpdate {
|
|
134
|
+
latitude: number;
|
|
135
|
+
longitude: number;
|
|
136
|
+
timestamp: string;
|
|
137
|
+
}
|
|
138
|
+
/**
|
|
139
|
+
* The SDK's own abstraction over "something that produces location
|
|
140
|
+
* updates" — deliberately small (start/stop) so both the mandatory
|
|
141
|
+
* simulator and a real device provider implement it identically, and the
|
|
142
|
+
* core lifecycle (../core) never needs to know which one it's driving.
|
|
143
|
+
*/
|
|
144
|
+
interface LocationProvider {
|
|
145
|
+
/**
|
|
146
|
+
* `onError` covers a provider that fails to start at all (e.g. a real
|
|
147
|
+
* device with location services turned off) — implementations must catch
|
|
148
|
+
* their own internal failures and report them here rather than letting an
|
|
149
|
+
* exception escape `start()` unhandled (see `DeviceLocationProvider` for
|
|
150
|
+
* why: an uncaught rejection from a native module is exactly the kind of
|
|
151
|
+
* thing that shouldn't be able to crash the host app).
|
|
152
|
+
*/
|
|
153
|
+
start(onUpdate: (location: LocationUpdate) => void, onError?: (error: Error) => void): Promise<void> | void;
|
|
154
|
+
stop(): void;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
type PermissionStatus = "granted" | "denied" | "undetermined";
|
|
158
|
+
interface PermissionProvider {
|
|
159
|
+
getStatus(): Promise<PermissionStatus>;
|
|
160
|
+
request(): Promise<PermissionStatus>;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* "sending" is a short-lived marker set immediately before an upload
|
|
165
|
+
* attempt and cleared right after (success removes the record entirely;
|
|
166
|
+
* failure moves it back to "pending"/"failed") — it exists specifically so
|
|
167
|
+
* two overlapping retry passes (e.g. a manual retry racing the timer) don't
|
|
168
|
+
* both attempt the same event at once. There is no "delivered" status
|
|
169
|
+
* stored here: a delivered event is removed from the queue, not
|
|
170
|
+
* transitioned — see PendingEventQueue's class doc for why.
|
|
171
|
+
*/
|
|
172
|
+
declare const QUEUED_EVENT_STATUSES: readonly ["pending", "sending", "failed"];
|
|
173
|
+
type QueuedEventStatus = (typeof QUEUED_EVENT_STATUSES)[number];
|
|
174
|
+
interface QueuedEvent {
|
|
175
|
+
eventId: string;
|
|
176
|
+
/** Flattened copies of the corresponding `payload` fields, purely so diagnostics/inspection don't need to unwrap `payload` for the common case. */
|
|
177
|
+
type: GeofenceEventType;
|
|
178
|
+
applicationId: string;
|
|
179
|
+
deviceId: string;
|
|
180
|
+
locationId: string;
|
|
181
|
+
geofenceId: string;
|
|
182
|
+
timestamp: string;
|
|
183
|
+
payload: LocationEventPayload;
|
|
184
|
+
attempts: number;
|
|
185
|
+
status: QueuedEventStatus;
|
|
186
|
+
createdAt: string;
|
|
187
|
+
lastAttemptAt: string | null;
|
|
188
|
+
/** Backoff scheduling — retryPending() skips any event whose nextAttemptAt is still in the future. Null means "ready now" (never attempted, or immediately retryable). */
|
|
189
|
+
nextAttemptAt: string | null;
|
|
190
|
+
}
|
|
191
|
+
/**
|
|
192
|
+
* Storage abstraction so the queue's retry/dedupe logic (storage/queue.ts)
|
|
193
|
+
* is testable in plain Node — `InMemoryEventQueueStore` for tests and as a
|
|
194
|
+
* safe default, `AsyncStorageEventQueueStore` for the real RN app. Kept
|
|
195
|
+
* intentionally minimal (get/save/remove over the whole set) so a future
|
|
196
|
+
* SQLite-backed store is a drop-in replacement without touching
|
|
197
|
+
* `PendingEventQueue`'s logic — see this package's README.
|
|
198
|
+
*/
|
|
199
|
+
interface EventQueueStore {
|
|
200
|
+
getAll(): Promise<QueuedEvent[]>;
|
|
201
|
+
save(event: QueuedEvent): Promise<void>;
|
|
202
|
+
remove(eventId: string): Promise<void>;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* The local pending-event queue (spec: "Offline queue" / "reliable SDK
|
|
207
|
+
* event queue"). An event is only ever in this queue between a failed send
|
|
208
|
+
* attempt and a successful retry — there's no "delivered" status because a
|
|
209
|
+
* delivered event is simply removed (`markSent`), which is also what makes
|
|
210
|
+
* retrying an already-sent event harmless: it's not in the queue to retry.
|
|
211
|
+
*/
|
|
212
|
+
declare class PendingEventQueue {
|
|
213
|
+
private readonly store;
|
|
214
|
+
constructor(store?: EventQueueStore);
|
|
215
|
+
enqueue(payload: LocationEventPayload): Promise<void>;
|
|
216
|
+
/** Every queued event, regardless of retry timing — used for diagnostics/pending counts. */
|
|
217
|
+
listPending(): Promise<QueuedEvent[]>;
|
|
218
|
+
/** Only events whose backoff window has elapsed (or that have never been attempted) — used by SyncManager.retryPending(). */
|
|
219
|
+
listReadyForRetry(now?: Date): Promise<QueuedEvent[]>;
|
|
220
|
+
/** Marks an event as in-flight so a concurrent retry pass won't also pick it up. */
|
|
221
|
+
markSending(eventId: string): Promise<void>;
|
|
222
|
+
markAttemptFailed(eventId: string): Promise<void>;
|
|
223
|
+
markSent(eventId: string): Promise<void>;
|
|
224
|
+
private get;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
type NetworkStatus = "online" | "offline" | "unknown";
|
|
228
|
+
interface NetworkMonitor {
|
|
229
|
+
getStatus(): NetworkStatus;
|
|
230
|
+
/** Calls `listener` whenever connectivity changes. Returns an unsubscribe function. */
|
|
231
|
+
subscribe(listener: (status: NetworkStatus) => void): () => void;
|
|
232
|
+
}
|
|
233
|
+
/**
|
|
234
|
+
* Default/test implementation — starts "online" (optimistic default: if we
|
|
235
|
+
* can't observe connectivity, assume the network call itself is the
|
|
236
|
+
* ultimate source of truth rather than blocking on a monitor). Tests (and
|
|
237
|
+
* the Node/non-RN environment generally) drive it explicitly via
|
|
238
|
+
* `setStatus`; see `sync/netinfo-network-monitor.ts` for the real RN
|
|
239
|
+
* implementation, kept in its own file for the same reason
|
|
240
|
+
* `storage/async-storage-store.ts` is — see that file's comment.
|
|
241
|
+
*/
|
|
242
|
+
declare class ManualNetworkMonitor implements NetworkMonitor {
|
|
243
|
+
private status;
|
|
244
|
+
private readonly listeners;
|
|
245
|
+
getStatus(): NetworkStatus;
|
|
246
|
+
setStatus(status: NetworkStatus): void;
|
|
247
|
+
subscribe(listener: (status: NetworkStatus) => void): () => void;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
type SdkStatus = "idle" | "initializing" | "initialized" | "running" | "stopped" | "error";
|
|
251
|
+
type ConfigurationStatus = "unsynced" | "synced" | "cached" | "unavailable";
|
|
252
|
+
/**
|
|
253
|
+
* `GeoPulse.getDiagnostics()` — a superset of `getStatus()`, purpose-built
|
|
254
|
+
* for a developer-facing status screen (spec section 10/11) rather than
|
|
255
|
+
* for the SDK's own internal decisions, which is why this is a separate
|
|
256
|
+
* method instead of changing what `getStatus()` returns: the existing
|
|
257
|
+
* playground code (and any Phase 2 integration) already depends on
|
|
258
|
+
* `getStatus()` returning the plain `SdkStatus` string.
|
|
259
|
+
*/
|
|
260
|
+
interface DiagnosticsSnapshot {
|
|
261
|
+
initialized: boolean;
|
|
262
|
+
running: boolean;
|
|
263
|
+
locationAvailable: boolean;
|
|
264
|
+
permissionStatus: PermissionStatus | "unknown";
|
|
265
|
+
networkStatus: "online" | "offline" | "unknown";
|
|
266
|
+
configurationStatus: ConfigurationStatus;
|
|
267
|
+
configVersion: string | null;
|
|
268
|
+
pendingEvents: number;
|
|
269
|
+
/** Sum of `attempts` across every currently-queued event — a rough "how much retrying is backed up" signal for a diagnostics screen. */
|
|
270
|
+
totalRetryAttempts: number;
|
|
271
|
+
/** Set when the location provider failed to start (e.g. OS location services disabled) — see LocationProvider's `onError`. Null when location started cleanly or hasn't been attempted yet. */
|
|
272
|
+
locationError: string | null;
|
|
273
|
+
lastEventAt: string | null;
|
|
274
|
+
lastEventType: GeofenceEventType | null;
|
|
275
|
+
lastSyncAt: string | null;
|
|
276
|
+
lastUploadResult: "success" | "failure" | null;
|
|
277
|
+
deviceId: string | null;
|
|
278
|
+
sdkVersion: string;
|
|
279
|
+
/** Count of distinct campaign experiences received via `experience.received` this session (Phase 5B). Resets on app restart, not persisted — purely a live "is the pipeline working" signal for a status screen. */
|
|
280
|
+
experiencesReceivedCount: number;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
type EventListener = (event: LocationEventPayload) => void;
|
|
284
|
+
type ExperienceListener = (payload: ExperienceDeliveryPayload) => void;
|
|
285
|
+
/**
|
|
286
|
+
* Everything the SDK talks to outside of itself, injectable so
|
|
287
|
+
* `GeoPulseSdk` (this whole lifecycle) is unit-testable without an RN
|
|
288
|
+
* runtime — tests construct it with fakes; `src/index.ts` (the real public
|
|
289
|
+
* entrypoint) constructs the default instance with the real RN adapters.
|
|
290
|
+
*/
|
|
291
|
+
interface GeoPulseSdkDependencies {
|
|
292
|
+
locationProvider?: LocationProvider;
|
|
293
|
+
permissionProvider?: PermissionProvider;
|
|
294
|
+
queue?: PendingEventQueue;
|
|
295
|
+
createApiClient?: (config: GeoPulseConfig) => ApiClient;
|
|
296
|
+
deviceIdStore?: KeyValueStore;
|
|
297
|
+
configCacheStore?: KeyValueStore;
|
|
298
|
+
networkMonitor?: NetworkMonitor;
|
|
299
|
+
/** Defaults to "unknown" — src/index.ts supplies the real `Platform.OS` so this file stays RN-free. */
|
|
300
|
+
getPlatform?: () => DevicePlatform;
|
|
301
|
+
/** Overrides GeoPulseConfig.debug — mainly for tests that want to assert on log output. */
|
|
302
|
+
debug?: boolean;
|
|
303
|
+
}
|
|
304
|
+
declare class GeoPulseSdk {
|
|
305
|
+
private status;
|
|
306
|
+
private config;
|
|
307
|
+
private apiClient;
|
|
308
|
+
private syncManager;
|
|
309
|
+
private resolvedProjectId;
|
|
310
|
+
private resolvedApplicationId;
|
|
311
|
+
private applicationEnabled;
|
|
312
|
+
private configVersion;
|
|
313
|
+
private configurationStatus;
|
|
314
|
+
private geofences;
|
|
315
|
+
private lastKnownLocation;
|
|
316
|
+
private lastEvent;
|
|
317
|
+
private deviceId;
|
|
318
|
+
private locationError;
|
|
319
|
+
private unsubscribeNetwork;
|
|
320
|
+
private readonly stateMachine;
|
|
321
|
+
private readonly listeners;
|
|
322
|
+
private readonly experienceListeners;
|
|
323
|
+
/** Client-side duplicate protection (spec section 14) — belt-and-suspenders alongside the server-side ack; a delivery already acked but re-observed before the ack lands must not fire `experience.received` twice. */
|
|
324
|
+
private readonly seenExperienceDeliveryIds;
|
|
325
|
+
private experiencePollTimer;
|
|
326
|
+
private experiencesReceivedCount;
|
|
327
|
+
private readonly logger;
|
|
328
|
+
private readonly locationProvider;
|
|
329
|
+
private readonly permissionProvider;
|
|
330
|
+
private readonly queue;
|
|
331
|
+
private readonly buildApiClient;
|
|
332
|
+
private readonly deviceIdStore;
|
|
333
|
+
private readonly configCache;
|
|
334
|
+
private readonly networkMonitor;
|
|
335
|
+
private readonly getPlatform;
|
|
336
|
+
private experiencePollIntervalMs;
|
|
337
|
+
constructor(dependencies?: GeoPulseSdkDependencies);
|
|
338
|
+
/**
|
|
339
|
+
* Resolves this device's persistent id, then fetches (or falls back to
|
|
340
|
+
* cached) project configuration. Must succeed — fresh or cached — before
|
|
341
|
+
* `start()`; if neither is available (first-ever run with no network),
|
|
342
|
+
* this rethrows and `getStatus()` reports "error".
|
|
343
|
+
*/
|
|
344
|
+
initialize(config: GeoPulseConfig): Promise<void>;
|
|
345
|
+
start(): Promise<void>;
|
|
346
|
+
stop(): void;
|
|
347
|
+
getStatus(): SdkStatus;
|
|
348
|
+
getPermissionStatus(): Promise<PermissionStatus>;
|
|
349
|
+
on(eventName: GeofenceEventType, callback: EventListener): void;
|
|
350
|
+
on(eventName: "experience.received", callback: ExperienceListener): void;
|
|
351
|
+
off(eventName: GeofenceEventType, callback: EventListener): void;
|
|
352
|
+
off(eventName: "experience.received", callback: ExperienceListener): void;
|
|
353
|
+
getConfiguredGeofences(): SdkGeofenceConfig[];
|
|
354
|
+
/** The server-resolved application id (from `getProjectConfig`), or null before `initialize()` completes. */
|
|
355
|
+
getApplicationId(): string | null;
|
|
356
|
+
getLastKnownLocation(): LocationUpdate | null;
|
|
357
|
+
getPendingEventCount(): Promise<number>;
|
|
358
|
+
/** Null until `initialize()` resolves it — persisted across app restarts (see config/device-id.ts). */
|
|
359
|
+
getDeviceId(): string | null;
|
|
360
|
+
/**
|
|
361
|
+
* The rich developer-diagnostics snapshot (spec section 10/11) — a
|
|
362
|
+
* superset of `getStatus()`, not a replacement for it (see core/types.ts's
|
|
363
|
+
* `DiagnosticsSnapshot` doc for why these are two separate methods).
|
|
364
|
+
*/
|
|
365
|
+
getDiagnostics(): Promise<DiagnosticsSnapshot>;
|
|
366
|
+
/**
|
|
367
|
+
* Only functional when the SDK is running the simulated location
|
|
368
|
+
* provider (the default outside of production builds — see
|
|
369
|
+
* src/index.ts) and gated by `__DEV__` so it's inert in a production
|
|
370
|
+
* bundle even if a caller somehow held a reference to it.
|
|
371
|
+
*/
|
|
372
|
+
simulateLocation(coords: {
|
|
373
|
+
latitude: number;
|
|
374
|
+
longitude: number;
|
|
375
|
+
}): void;
|
|
376
|
+
/**
|
|
377
|
+
* The Phase 5B delivery mechanism: poll immediately on `start()` (so a
|
|
378
|
+
* demo doesn't wait a full interval for the first check), then on a
|
|
379
|
+
* timer. A poll failure (network blip, backend down) is logged and
|
|
380
|
+
* swallowed — it must never crash the SDK or the host app (spec section
|
|
381
|
+
* 13), and the next tick tries again on its own.
|
|
382
|
+
*/
|
|
383
|
+
private startExperiencePolling;
|
|
384
|
+
private stopExperiencePolling;
|
|
385
|
+
private pollForExperiences;
|
|
386
|
+
private applyProjectConfig;
|
|
387
|
+
private handleLocationUpdate;
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
/**
|
|
391
|
+
* Real, foreground-only device location via expo-location. Does not
|
|
392
|
+
* request or use background location — see this package's README for the
|
|
393
|
+
* platform permission requirements and why background tracking is
|
|
394
|
+
* explicitly out of scope for this phase.
|
|
395
|
+
*/
|
|
396
|
+
declare class DeviceLocationProvider implements LocationProvider {
|
|
397
|
+
private subscription;
|
|
398
|
+
/**
|
|
399
|
+
* `watchPositionAsync` rejects if location services are off at the OS
|
|
400
|
+
* level (distinct from the app's own permission grant, which is checked
|
|
401
|
+
* before this ever runs) — caught here and reported via `onError` instead
|
|
402
|
+
* of left to reject this method's own promise, so a caller that doesn't
|
|
403
|
+
* wrap `GeoPulse.start()` in a try/catch still can't end up with an
|
|
404
|
+
* unhandled rejection from deep inside a native module.
|
|
405
|
+
*/
|
|
406
|
+
start(onUpdate: (location: LocationUpdate) => void, onError?: (error: Error) => void): Promise<void>;
|
|
407
|
+
stop(): void;
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
/**
|
|
411
|
+
* The mandatory simulation provider (spec section 7) — lets the geofence
|
|
412
|
+
* flow be exercised end to end without physically travelling anywhere.
|
|
413
|
+
* `setLocation` is the playground simulator screen's only way to move the
|
|
414
|
+
* "user"; everything downstream (geofence engine, event generation, sync)
|
|
415
|
+
* runs exactly as it would for a real device update, because both
|
|
416
|
+
* providers speak the same `LocationProvider` interface.
|
|
417
|
+
*/
|
|
418
|
+
declare class SimulatedLocationProvider implements LocationProvider {
|
|
419
|
+
private onUpdate;
|
|
420
|
+
private currentLocation;
|
|
421
|
+
start(onUpdate: (location: LocationUpdate) => void): void;
|
|
422
|
+
stop(): void;
|
|
423
|
+
setLocation(coords: {
|
|
424
|
+
latitude: number;
|
|
425
|
+
longitude: number;
|
|
426
|
+
}): void;
|
|
427
|
+
getCurrentLocation(): LocationUpdate | null;
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
/**
|
|
431
|
+
* Foreground-only — see this package's README ("Permissions" section) for
|
|
432
|
+
* why background permission is explicitly out of scope for this phase.
|
|
433
|
+
* Requests only the foreground permission; never touches
|
|
434
|
+
* `requestBackgroundPermissionsAsync`.
|
|
435
|
+
*/
|
|
436
|
+
declare class ExpoPermissionProvider implements PermissionProvider {
|
|
437
|
+
getStatus(): Promise<PermissionStatus>;
|
|
438
|
+
request(): Promise<PermissionStatus>;
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
/**
|
|
442
|
+
* Used with the simulated location provider (see ../location) — a
|
|
443
|
+
* simulated user obviously isn't gated by the OS location permission
|
|
444
|
+
* dialog, so this always reports "granted" rather than requiring a real
|
|
445
|
+
* device's permission state to test the geofence flow end to end.
|
|
446
|
+
*/
|
|
447
|
+
declare class SimulatedPermissionProvider implements PermissionProvider {
|
|
448
|
+
getStatus(): Promise<PermissionStatus>;
|
|
449
|
+
request(): Promise<PermissionStatus>;
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
/** Real connectivity detection for the RN app. Isolated in its own file — see storage/async-storage-store.ts's comment for why. */
|
|
453
|
+
declare class NetInfoNetworkMonitor implements NetworkMonitor {
|
|
454
|
+
private status;
|
|
455
|
+
constructor();
|
|
456
|
+
getStatus(): NetworkStatus;
|
|
457
|
+
subscribe(listener: (status: NetworkStatus) => void): () => void;
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
/** `sdk.on`/`sdk.off` are already overloaded this way (see core/geo-pulse-sdk.ts) — this just re-states the same public shape for the plain-object `GeoPulse` singleton below. */
|
|
461
|
+
type OnOff = {
|
|
462
|
+
(eventName: GeofenceEventType, callback: (event: LocationEventPayload) => void): void;
|
|
463
|
+
(eventName: "experience.received", callback: (payload: ExperienceDeliveryPayload) => void): void;
|
|
464
|
+
};
|
|
465
|
+
declare const GeoPulse: {
|
|
466
|
+
initialize: (config: GeoPulseConfig) => Promise<void>;
|
|
467
|
+
start: () => Promise<void>;
|
|
468
|
+
stop: () => void;
|
|
469
|
+
getStatus: () => SdkStatus;
|
|
470
|
+
getPermissionStatus: () => Promise<PermissionStatus>;
|
|
471
|
+
on: OnOff;
|
|
472
|
+
off: OnOff;
|
|
473
|
+
getConfiguredGeofences: () => SdkGeofenceConfig[];
|
|
474
|
+
getApplicationId: () => string | null;
|
|
475
|
+
getLastKnownLocation: () => LocationUpdate | null;
|
|
476
|
+
getPendingEventCount: () => Promise<number>;
|
|
477
|
+
getDeviceId: () => string | null;
|
|
478
|
+
/** Rich developer-diagnostics snapshot — see core/types.ts's DiagnosticsSnapshot doc. */
|
|
479
|
+
getDiagnostics: () => Promise<DiagnosticsSnapshot>;
|
|
480
|
+
/** Development-only. No-ops (with a console warning) in a production build — see GeoPulseSdk.simulateLocation. */
|
|
481
|
+
simulator: {
|
|
482
|
+
setLocation: (coords: {
|
|
483
|
+
latitude: number;
|
|
484
|
+
longitude: number;
|
|
485
|
+
}) => void;
|
|
486
|
+
};
|
|
487
|
+
};
|
|
488
|
+
|
|
489
|
+
export { type ConfigurationStatus, DeviceLocationProvider, type DiagnosticsSnapshot, type ExperienceDeliveryPayload, ExpoPermissionProvider, GeoPulse, type GeoPulseConfig, GeoPulseSdk, type GeofenceEventType, type LocationEventPayload, type LocationProvider, type LocationUpdate, ManualNetworkMonitor, NetInfoNetworkMonitor, type NetworkMonitor, type NetworkStatus, type PermissionProvider, type PermissionStatus, type SdkGeofenceConfig, type SdkStatus, SimulatedLocationProvider, SimulatedPermissionProvider };
|