@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 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).
@@ -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 };