@sharib/geopulse-react-native 0.3.3 → 0.4.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 CHANGED
@@ -1,22 +1,51 @@
1
1
  # @sharib/geopulse-react-native
2
2
 
3
+ > **Beta.** GeoPulse and this SDK are in beta: the API may change before 1.0, and there is no
4
+ > uptime guarantee yet. Questions, bugs and feature requests:
5
+ > [GitHub issues](https://github.com/sharibkhan1/geofends/issues). The Terms of Service and
6
+ > Privacy Policy are in the admin portal at `/terms` and `/privacy`; see also
7
+ > [Privacy and store disclosures](#privacy-and-store-disclosures) below.
8
+
3
9
  On-device geofence detection and event sync for React Native / Expo apps. Fetches an
4
10
  application's configured geofences from GeoPulse, watches location (real device or the
5
11
  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
12
+ backend, with a persistent, backoff-retried offline queue so a flaky connection never loses
7
13
  an event.
8
14
 
15
+ ## Requirements
16
+
17
+ - **React Native ≥ 0.73 and React ≥ 18** (peer dependencies; developed and tested against React
18
+ Native 0.86 / React 19.2 only, so please report problems on older versions).
19
+ - **Expo, or a bare React Native app with Expo modules.** Location comes from `expo-location`,
20
+ which needs the Expo modules runtime. In an Expo app (including Expo Go and development
21
+ builds) that is already there. In a bare React Native app, run `npx install-expo-modules`
22
+ first.
23
+ - Native modules the SDK relies on: `expo-location`, `@react-native-async-storage/async-storage`
24
+ and `@react-native-community/netinfo`. They are dependencies of the SDK, so installing the SDK
25
+ installs them (see Installation). Rebuild your native app after adding the SDK.
26
+ - Location permission (foreground only) declared in your app config. See [Permissions](#permissions).
27
+ - A GeoPulse account: create a project and an application in the admin portal to get your
28
+ `apiKey` and `applicationId`.
29
+
9
30
  ## Installation
10
31
 
11
32
  ```bash
12
33
  npm install @sharib/geopulse-react-native
13
34
  ```
14
35
 
36
+ That is the only install command: the SDK brings the packages it needs. Two cases need one extra step:
37
+
38
+ - If `npx expo-doctor` says one of those packages is not the version your Expo SDK expects (npm installs
39
+ the newest allowed version when your app has none yet), run `npx expo install --fix`.
40
+ - In a bare React Native app without Expo, run `npx install-expo-modules` first. React Native's own
41
+ autolinking only links packages your app lists itself, so if a native module is reported as missing, add
42
+ it with `npx expo install <package>`.
43
+
15
44
  ## Initialization
16
45
 
17
46
  Get `apiKey` and `applicationId` from the admin portal's `/sdk` page (the application created
18
47
  automatically for you during onboarding is named "Playground"). The SDK connects to the hosted
19
- GeoPulse backend automatically — there's nothing else to configure:
48
+ GeoPulse backend automatically, with nothing else to configure:
20
49
 
21
50
  ```ts
22
51
  import { GeoPulse } from "@sharib/geopulse-react-native";
@@ -28,9 +57,9 @@ await GeoPulse.initialize({
28
57
  ```
29
58
 
30
59
  `initialize()` fetches the application's currently active geofences from the backend
31
- (`getProjectConfig`) — this is the "SDK receives project configuration" step. It throws if any
32
- of the three fields above are missing, or if the backend call fails (bad/revoked SDK key,
33
- network error, etc.) — check `GeoPulse.getStatus()` (`"error"`) if you need to react to that.
60
+ (`getProjectConfig`). This is the "SDK receives project configuration" step. It throws if
61
+ `apiKey` or `applicationId` is missing, or if the backend call fails (bad/revoked SDK key,
62
+ network error, etc.). Check `GeoPulse.getStatus()` (`"error"`) if you need to react to that.
34
63
 
35
64
  ## Start / stop
36
65
 
@@ -53,7 +82,7 @@ GeoPulse.on("geofence.exit", (event) => console.log("exited", event));
53
82
  GeoPulse.off("geofence.enter", onEnter);
54
83
  ```
55
84
 
56
- An event fires at most once per actual state transition — moving around *inside* a geofence
85
+ An event fires at most once per actual state transition: moving around *inside* a geofence
57
86
  never re-fires `geofence.enter`, and a geofence that's still active in your config never fires
58
87
  `geofence.exit` just because you didn't move (see `src/geofencing/state-machine.ts` in this
59
88
  package's source for the exact transition rules this guarantees).
@@ -65,17 +94,44 @@ associated Experience (a banner/card/message):
65
94
 
66
95
  ```ts
67
96
  GeoPulse.on("experience.received", (payload) => {
68
- // payload: ExperienceDeliveryPayload — { deliveryId, campaignId, experience: {...}, event: {...} }
97
+ // payload: ExperienceDeliveryPayload: { deliveryId, campaignId, revision, isUpdate, experience: {...}, event: {...} }
69
98
  showBanner(payload.experience);
70
99
  });
100
+
101
+ GeoPulse.on("experience.removed", ({ deliveryId, campaignId }) => {
102
+ hideBanner(deliveryId);
103
+ });
71
104
  ```
72
105
 
73
- No push notifications involved: the SDK polls its own device's pending experiences on a timer
74
- (`GeoPulseConfig.experiencePollIntervalMs`, defaults to 30s) over the same connection it already
75
- uses for config/ingestion, and acknowledges each one automatically once it's fired the event —
76
- you never need to call anything to mark it "seen." The SDK also never re-fires the same
77
- `deliveryId` twice. There's no `getActiveExperiences()`: `on("experience.received")` is the
78
- whole surface — the SDK transports and notifies, your app owns the UI.
106
+ - `isUpdate` is `true` when an admin edited an experience the user was already shown: show the new
107
+ content in place of the old (use `deliveryId` to find it).
108
+ - `experience.removed` fires when an admin deleted or disabled the campaign while the user is still
109
+ inside the location: remove what you showed.
110
+
111
+ No push notifications are involved. The SDK fetches its own device's pending experiences over the
112
+ connection it already uses for config/ingestion: once on `start()`, right after an event the backend
113
+ says created one, and on a slow fallback timer (`GeoPulseConfig.experiencePollIntervalMs`, default 15
114
+ minutes). While the user is **inside a geofence with the app open**, the SDK also keeps one WebSocket
115
+ open so the server can tell it the moment an admin changes something that affects them; it closes when
116
+ they leave. Idle devices send no repeated requests. The SDK acknowledges each experience
117
+ automatically, so you never need to call anything to mark it "seen", and it never re-fires the same
118
+ `deliveryId` and `revision` twice. There's no `getActiveExperiences()`: the two events above are the
119
+ whole surface: the SDK transports and notifies, your app owns the UI. How long each kind of admin
120
+ change takes to arrive is in the portal's Setup Guide ("Why changes take a moment").
121
+
122
+ ## How often the SDK talks to the server
123
+
124
+ It only sends an event when the user **enters or leaves** a geofence, never while they stand still.
125
+ To keep call volume (and your bill) low, the SDK holds itself back:
126
+
127
+ - one event per geofence per 2 minutes, at most 3 uploads a minute, whatever the user does;
128
+ - leaving only counts once the user is clearly outside (about 20 m past the edge, or more if the GPS fix
129
+ is less accurate); GPS fixes worse than 100 m are ignored;
130
+ - the state is saved, so reopening the app while still inside does not report a second "enter";
131
+ - when the server answers "too many requests" the SDK waits as long as it is told; a request the
132
+ server will never accept (for example a deleted geofence) is dropped instead of retried forever;
133
+ - the offline queue holds at most 100 events and drops anything older than 24 hours;
134
+ - the geofence list is re-read about once an hour while the app is open, and on every launch.
79
135
 
80
136
  ## Status / diagnostics
81
137
 
@@ -87,9 +143,9 @@ without duplicating SDK-internal state:
87
143
  GeoPulse.getStatus(); // "idle" | "initializing" | "initialized" | "running" | "stopped" | "error"
88
144
  await GeoPulse.getPermissionStatus(); // "granted" | "denied" | "undetermined"
89
145
  await GeoPulse.getDiagnostics(); // rich snapshot: network status, config status, pending events, last sync, etc.
90
- GeoPulse.getApplicationId(); // string | null — resolved server-side, not just an echo of what you passed in
91
- GeoPulse.getDeviceId(); // string | null — persisted across app restarts
92
- // getDiagnostics() also includes `locationError: string | null` — set if the location provider
146
+ GeoPulse.getApplicationId(); // string | null, resolved server-side, not just an echo of what you passed in
147
+ GeoPulse.getDeviceId(); // string | null, persisted across app restarts
148
+ // getDiagnostics() also includes `locationError: string | null`, set if the location provider
93
149
  // failed to start (e.g. OS location services off).
94
150
  GeoPulse.getConfiguredGeofences(); // the geofences fetched by initialize()
95
151
  GeoPulse.getLastKnownLocation(); // most recent location update, or null
@@ -106,11 +162,14 @@ GeoPulse.simulator.setLocation({ latitude: 33.4484, longitude: -112.074 });
106
162
  ```
107
163
 
108
164
  Each call runs through the exact same geofence engine and event pipeline as a real device
109
- update would — the simulator is a location *source*, not a shortcut that fakes an event
165
+ update would. The one difference: while the simulator is running, the 2-minute per-geofence cooldown is off and
166
+ the saved "inside" state is not kept across reloads, so you can enter and exit as quickly as you like and
167
+ an "enter" fires again after a reload. (The backend still applies its own limits, for example a webhook rule
168
+ fires at most once per 2 minutes for the same device and geofence.) The simulator is a location *source*, not a shortcut that fakes an event
110
169
  directly.
111
170
 
112
171
  **The simulator is inert in production builds.** `GeoPulse.simulator.setLocation(...)` is a
113
- no-op (with a console warning) once `__DEV__` is false — release builds run on
172
+ no-op (with a console warning) once `__DEV__` is false. Release builds run on
114
173
  `DeviceLocationProvider` (real `expo-location`) instead, automatically, with no code change
115
174
  required. If you need real device location *during development* instead of the simulator,
116
175
  construct your own instance rather than using the `GeoPulse` singleton:
@@ -126,65 +185,107 @@ const sdk = new GeoPulseSdk({
126
185
 
127
186
  ## Permissions
128
187
 
129
- This SDK requests **foreground location only** — never background location. Concretely:
188
+ This SDK requests **foreground location only**, never background location. Concretely:
189
+
190
+ Minimal Expo `app.json` that covers both platforms:
191
+
192
+ ```json
193
+ {
194
+ "expo": {
195
+ "plugins": [
196
+ ["expo-location", { "locationWhenInUsePermission": "This app uses your location to detect nearby places." }]
197
+ ],
198
+ "android": { "permissions": ["ACCESS_COARSE_LOCATION", "ACCESS_FINE_LOCATION"] }
199
+ }
200
+ }
201
+ ```
202
+
203
+ Use your own wording for the permission text: it is what your users see, and the stores review it.
130
204
 
131
205
  - **iOS**: `NSLocationWhenInUseUsageDescription` must be set in your app's `app.json` (or
132
206
  `Info.plist` directly for a bare RN app); `expo-location`'s config plugin sets this from the
133
207
  `locationWhenInUsePermission` option. No `NSLocationAlwaysAndWhenInUseUsageDescription` is
134
- requested — the SDK will never prompt for "Always" access.
208
+ requested, so the SDK will never prompt for "Always" access.
135
209
  - **Android**: `ACCESS_FINE_LOCATION` (and `ACCESS_COARSE_LOCATION` as a fallback) in the
136
210
  manifest, via your app's `app.json`'s `android.permissions`. No `ACCESS_BACKGROUND_LOCATION`.
137
211
  - **Foreground-only limitation**: location updates (and therefore geofence detection) only run
138
- while the app is in the foreground. Backgrounding the app pauses detection — an enter/exit
212
+ while the app is in the foreground. Backgrounding the app pauses detection: an enter/exit
139
213
  that happens while the app is backgrounded will not be caught until the app is foregrounded
140
214
  again and a new location update arrives. This is a deliberate scope boundary for this phase,
141
- not an oversight — background geofencing needs a materially different architecture (a native
215
+ not an oversight. Background geofencing needs a materially different architecture (a native
142
216
  background task, plus battery/OS-throttling tradeoffs) and is planned for a later phase. Do
143
217
  not present this SDK as providing production-grade background geofencing.
144
218
  - **Battery**: `DeviceLocationProvider` uses `Location.Accuracy.Balanced` with a 10m distance
145
- filter and a 5s minimum interval — a reasonable foreground default, not tuned for
219
+ filter and a 5s minimum interval. That's a reasonable foreground default, not tuned for
146
220
  high-precision or high-frequency tracking.
147
221
 
222
+ ## Privacy and store disclosures
223
+
224
+ Your app sends its end users' location data to GeoPulse, so **you** must disclose that to them and
225
+ to the app stores. What the SDK sends:
226
+
227
+ - **When:** only when a user enters or exits one of your geofences, while the app is in the
228
+ foreground. Continuous location is processed on the device and is not uploaded.
229
+ - **What:** the user's precise coordinates and a timestamp at that moment, the geofence and
230
+ location ids, your application id, a random device id the SDK generates and stores on the
231
+ device (not an advertising id), the platform (`ios`/`android`) and the SDK version.
232
+ - **Where it goes:** your GeoPulse project. It is visible to the members of your organization in
233
+ the portal and is forwarded to any webhook endpoints you configure.
234
+
235
+ What that means for you:
236
+
237
+ - Your **privacy policy** must say you collect precise location and a device identifier, why, and
238
+ who processes it (GeoPulse acts as your service provider).
239
+ - **Apple App Privacy ("nutrition label"):** declare *Precise Location* and *Device ID*, collected
240
+ for app functionality and/or analytics, according to how you use them.
241
+ - **Google Play Data safety form:** declare *Approximate and Precise location* and *Device or other
242
+ IDs* as collected, with the purposes that apply to your app.
243
+ - Show a clear, honest permission prompt text (see [Permissions](#permissions)), and ask for
244
+ consent where the law of your users' region requires it.
245
+
246
+ This section is guidance, not legal advice: the store forms are your responsibility, so check
247
+ them against how your app really behaves.
248
+
148
249
  ## Troubleshooting
149
250
 
150
- - **`initialize()` rejects with a fetch/network error** — check `functionsBaseUrl` is reachable
251
+ - **`initialize()` rejects with a fetch/network error**: check `functionsBaseUrl` is reachable
151
252
  from the device (an emulator URL like `127.0.0.1` is not reachable from a physical device or
152
- even always from a simulator — use your machine's LAN IP or a deployed URL instead).
153
- - **`start()` rejects with "Location permission not granted"** — the user denied the OS
253
+ even always from a simulator, so use your machine's LAN IP or a deployed URL instead).
254
+ - **`start()` rejects with "Location permission not granted"**: the user denied the OS
154
255
  permission prompt (real device), or `PermissionProvider.request()` didn't resolve
155
- `"granted"` (simulator mode always grants — this only happens with `DeviceLocationProvider`).
156
- - **No `geofence.enter` ever fires in the simulator** — confirm `GeoPulse.getConfiguredGeofences()`
256
+ `"granted"` (simulator mode always grants; this only happens with `DeviceLocationProvider`).
257
+ - **No `geofence.enter` ever fires in the simulator**: confirm `GeoPulse.getConfiguredGeofences()`
157
258
  isn't empty; if it is, no geofence exists yet for this application's project in the admin
158
259
  portal, or `initialize()` was called before the SDK key's application had one created.
159
- - **Events show up in `getPendingEventCount()` but never seem to send** — each queued event has
260
+ - **Events show up in `getPendingEventCount()` but never seem to send**: each queued event has
160
261
  its own exponential-backoff schedule; a recently-failed event isn't retried again immediately.
161
262
  Retries also fire as soon as the network monitor reports connectivity restored, so this should
162
- resolve quickly once you're actually back online — if it doesn't, check `functionsBaseUrl` is
263
+ resolve quickly once you're actually back online. If it doesn't, check `functionsBaseUrl` is
163
264
  reachable at all.
164
265
 
165
266
  ## Internal architecture
166
267
 
167
268
  ```
168
269
  src/
169
- core/ SDK lifecycle + diagnostics (GeoPulseSdk), Logger — the only module that wires everything else together
270
+ core/ SDK lifecycle + diagnostics (GeoPulseSdk), Logger. The only module that wires everything else together
170
271
  config/ GeoPulseConfig type, device-id persistence, config caching
171
272
  location/ LocationProvider abstraction: SimulatedLocationProvider (default in dev) + DeviceLocationProvider (expo-location)
172
- geofencing/ GeofenceStateMachine — haversine distance + INSIDE/OUTSIDE transition tracking
273
+ geofencing/ GeofenceStateMachine: haversine distance + INSIDE/OUTSIDE transition tracking
173
274
  events/ Event payload construction + id generation
174
275
  storage/ Offline pending-event queue (InMemoryEventQueueStore for tests, AsyncStorageEventQueueStore for the real app)
175
276
  sync/ SyncManager (send-or-queue, backoff retry), centralized retry policy, NetworkMonitor
176
277
  permissions/ PermissionProvider abstraction: SimulatedPermissionProvider (default in dev) + ExpoPermissionProvider
177
- firebase/ ApiClient — plain-fetch HTTPS client for the Cloud Functions endpoints (no Firebase SDK on-device)
178
- index.ts Public API (GeoPulse) — wires the real RN adapters; everything else is injectable for testing
278
+ firebase/ ApiClient: plain-fetch HTTPS client for the Cloud Functions endpoints (no Firebase SDK on-device)
279
+ index.ts Public API (GeoPulse). Wires the real RN adapters; everything else is injectable for testing
179
280
  ```
180
281
 
181
282
  Every module except `location/device-location-provider.ts`,
182
283
  `permissions/expo-permission-provider.ts`, `storage/async-storage-store.ts`,
183
284
  `config/async-storage-key-value-store.ts`, and `sync/netinfo-network-monitor.ts` has zero React
184
- Native dependencies and runs under plain Node/Vitest — those five are the only files that
285
+ Native dependencies and runs under plain Node/Vitest. Those five are the only files that
185
286
  import an RN-only package, and nothing in `core/`, `geofencing/`, `events/`, `sync/`
186
287
  (excluding the one file above), or `firebase/` imports them (see the comments at the top of
187
288
  `storage/index.ts`, `location/index.ts`, `config/index.ts`, `permissions/index.ts`, and
188
289
  `sync/index.ts`). That split is what makes `GeoPulseSdk` and the geofence engine unit-testable
189
- without a device, simulator, or Metro bundler — run `pnpm test` from this package's directory
290
+ without a device, simulator, or Metro bundler. Run `pnpm test` from this package's directory
190
291
  (55 tests as of this phase).
package/dist/index.d.ts CHANGED
@@ -90,6 +90,10 @@ interface ExperienceDeliveryPayload {
90
90
  type: "location.experience";
91
91
  deliveryId: string;
92
92
  campaignId: string;
93
+ /** Changes when the experience's content is edited; absent on older backends. */
94
+ revision?: number;
95
+ /** Set by the SDK, never the backend: true when this replaces content the user was already shown for the same delivery. */
96
+ isUpdate?: boolean;
93
97
  experience: {
94
98
  id: string;
95
99
  type: ExperienceType;
@@ -108,6 +112,16 @@ interface ExperienceDeliveryPayload {
108
112
  timestamp: string;
109
113
  };
110
114
  }
115
+ /** A delivery the user was already shown that the app must now remove (its campaign was deleted or disabled). */
116
+ interface RevokedExperience {
117
+ deliveryId: string;
118
+ campaignId: string;
119
+ }
120
+ /** Response body of `getPendingExperiences`; `revoked` is absent on older backends. */
121
+ interface PendingExperiencesResponse {
122
+ experiences: ExperienceDeliveryPayload[];
123
+ revoked?: RevokedExperience[];
124
+ }
111
125
 
112
126
  interface GeoPulseConfig {
113
127
  apiKey: string;
@@ -120,7 +134,11 @@ interface GeoPulseConfig {
120
134
  functionsBaseUrl?: string;
121
135
  /** How often to retry queued (previously failed) events, in milliseconds. Defaults to 30s. */
122
136
  retryIntervalMs?: number;
123
- /** 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. */
137
+ /**
138
+ * Fallback interval for polling pending campaign experiences, in milliseconds. Defaults to 15 minutes.
139
+ * Experiences are normally fetched right after an event the backend says created one, once on
140
+ * `start()`, and when the server pushes a change to a phone inside a geofence, so this only catches anything missed. Lower it only if you need that safety net faster.
141
+ */
124
142
  experiencePollIntervalMs?: number;
125
143
  /** 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. */
126
144
  debug?: boolean;
@@ -142,6 +160,12 @@ interface DeviceIdentity {
142
160
  platform: DevicePlatform;
143
161
  sdkVersion: string;
144
162
  }
163
+ interface IngestResult {
164
+ id: string;
165
+ deduplicated: boolean;
166
+ /** False when the backend created no experience for this event, so polling for one would find nothing. Absent on older backends — treat as "maybe". */
167
+ experiencesPending?: boolean;
168
+ }
145
169
  /**
146
170
  * Named `firebase/` per this project's spec ("communication with GeoPulse
147
171
  * backend"), but deliberately does NOT use the Firebase client SDK — it
@@ -166,20 +190,77 @@ declare class ApiClient {
166
190
  * since GET requests carry no body.
167
191
  */
168
192
  getProjectConfig(identity: DeviceIdentity): Promise<SdkProjectConfig>;
169
- ingestEvent(payload: LocationEventPayload): Promise<{
170
- id: string;
171
- deduplicated: boolean;
172
- }>;
193
+ ingestEvent(payload: LocationEventPayload): Promise<IngestResult>;
173
194
  /** Polled by GeoPulseSdk's experience-polling loop — see core/geo-pulse-sdk.ts. */
174
- getPendingExperiences(deviceId: string): Promise<ExperienceDeliveryPayload[]>;
195
+ getPendingExperiences(deviceId: string): Promise<Required<PendingExperiencesResponse>>;
175
196
  /** Confirms a delivery was surfaced via `experience.received`, so it stops appearing in future polls. */
176
197
  acknowledgeExperienceDelivery(deliveryId: string): Promise<void>;
177
198
  }
178
199
 
200
+ interface StateMachineOptions {
201
+ /** How far past the edge a position must be before an exit counts (GPS jitter at the boundary). Default 20 m, or the fix's accuracy if larger. */
202
+ exitBufferMeters?: number;
203
+ /** Fixes less accurate than this are ignored: they cannot tell inside from outside. Default 100 m. */
204
+ maxAccuracyMeters?: number;
205
+ /** Minimum time between two transitions of the same geofence. Default 2 minutes. */
206
+ cooldownMs?: number;
207
+ now?: () => number;
208
+ }
209
+
210
+ /**
211
+ * Structured, easy-to-disable debug logging (spec section 22). Every call
212
+ * site passes a short lifecycle label matching the examples in this
213
+ * package's README ("Configuration synced", "Event queued", ...) plus
214
+ * optional structured details — never the SDK key, never a raw event
215
+ * payload body, never anything from `GeoPulseConfig` beyond `applicationId`.
216
+ */
217
+ declare class Logger {
218
+ private enabled;
219
+ constructor(enabled: boolean);
220
+ setEnabled(enabled: boolean): void;
221
+ log(message: string, details?: Record<string, unknown>): void;
222
+ warn(message: string, details?: Record<string, unknown>): void;
223
+ }
224
+
225
+ /** The part of a WebSocket the client uses; React Native's built-in `WebSocket` satisfies it, and tests pass a fake. */
226
+ interface RealtimeSocket {
227
+ onopen: ((event: unknown) => void) | null;
228
+ onmessage: ((event: {
229
+ data: unknown;
230
+ }) => void) | null;
231
+ onclose: ((event: {
232
+ code: number;
233
+ }) => void) | null;
234
+ onerror: ((event: unknown) => void) | null;
235
+ send(data: string): void;
236
+ close(code?: number, reason?: string): void;
237
+ }
238
+ interface RealtimeOptions {
239
+ /** Backend base URL (https://...). The socket goes to the same host over wss://. */
240
+ baseUrl: string;
241
+ sdkKey: string;
242
+ applicationId: string;
243
+ deviceId: string;
244
+ /** The server says something changed for this device: fetch it. */
245
+ onChanged: () => void;
246
+ /** The server accepted the connection. Anything changed while we were disconnected is fetched now. */
247
+ onConnected: () => void;
248
+ createSocket?: (url: string) => RealtimeSocket;
249
+ logger?: Logger;
250
+ }
251
+ /** What the host app (index.ts) hands to GeoPulseSdk: just connect and disconnect. */
252
+ interface RealtimeConnection {
253
+ connect(): void;
254
+ disconnect(): void;
255
+ isConnected(): boolean;
256
+ }
257
+
179
258
  interface LocationUpdate {
180
259
  latitude: number;
181
260
  longitude: number;
182
261
  timestamp: string;
262
+ /** Horizontal accuracy radius in meters, when the provider knows it. A big value means the fix cannot be trusted near a geofence edge. */
263
+ accuracy?: number;
183
264
  }
184
265
  /**
185
266
  * The SDK's own abstraction over "something that produces location
@@ -254,20 +335,29 @@ interface EventQueueStore {
254
335
  * attempt and a successful retry — there's no "delivered" status because a
255
336
  * delivered event is simply removed (`markSent`), which is also what makes
256
337
  * retrying an already-sent event harmless: it's not in the queue to retry.
338
+ *
339
+ * Every operation runs one at a time. The store keeps the whole queue in one value, so each change is read, modify,
340
+ * write; two overlapping ones (a retry finishing while a new event is queued) would otherwise overwrite each other and
341
+ * lose an event.
257
342
  */
258
343
  declare class PendingEventQueue {
259
344
  private readonly store;
345
+ private chain;
260
346
  constructor(store?: EventQueueStore);
347
+ private serialized;
261
348
  enqueue(payload: LocationEventPayload): Promise<void>;
262
349
  /** Every queued event, regardless of retry timing — used for diagnostics/pending counts. */
263
350
  listPending(): Promise<QueuedEvent[]>;
264
- /** Only events whose backoff window has elapsed (or that have never been attempted) — used by SyncManager.retryPending(). */
351
+ /** Only events whose backoff window has elapsed (or that have never been attempted) — used by SyncManager.retryPending(). Drops events too old to ever be accepted. */
265
352
  listReadyForRetry(now?: Date): Promise<QueuedEvent[]>;
266
353
  /** Marks an event as in-flight so a concurrent retry pass won't also pick it up. */
267
354
  markSending(eventId: string): Promise<void>;
268
- markAttemptFailed(eventId: string): Promise<void>;
355
+ /** `minDelayMs` lets a 429's Retry-After push the next attempt later than the normal backoff. */
356
+ markAttemptFailed(eventId: string, minDelayMs?: number): Promise<void>;
269
357
  markSent(eventId: string): Promise<void>;
270
358
  private get;
359
+ /** Keeps the queue within MAX_QUEUE_SIZE by dropping the oldest events that are not being sent right now. */
360
+ private trim;
271
361
  }
272
362
 
273
363
  type NetworkStatus = "online" | "offline" | "unknown";
@@ -328,7 +418,7 @@ interface DiagnosticsSnapshot {
328
418
 
329
419
  type EventListener = (event: LocationEventPayload) => void;
330
420
  type ExperienceListener = (payload: ExperienceDeliveryPayload) => void;
331
- /** Mirrors SyncManager's own default retry interval — see sync/sync-manager.ts's DEFAULT_RETRY_INTERVAL_MS. A host app that wants faster demo feedback (see apps/playground) can override via GeoPulseConfig.experiencePollIntervalMs. */
421
+ type ExperienceRemovedListener = (payload: RevokedExperience) => void;
332
422
  /** The SDK key resolved to a different application than `config.applicationId`. */
333
423
  declare class ApplicationMismatchError extends Error {
334
424
  constructor();
@@ -351,6 +441,15 @@ interface GeoPulseSdkDependencies {
351
441
  getPlatform?: () => DevicePlatform;
352
442
  /** Overrides GeoPulseConfig.debug — mainly for tests that want to assert on log output. */
353
443
  debug?: boolean;
444
+ /** Where the geofence state machine keeps its memory across restarts. Defaults to memory only (lost on restart). */
445
+ stateStore?: KeyValueStore;
446
+ /** Tuning for tests (cooldown, exit buffer, clock). */
447
+ stateMachineOptions?: StateMachineOptions;
448
+ /**
449
+ * Builds the push connection used while the user is inside a geofence. Not set by default: the core has no
450
+ * WebSocket dependency, and src/index.ts supplies the real one.
451
+ */
452
+ createRealtimeClient?: (options: RealtimeOptions) => RealtimeConnection;
354
453
  }
355
454
  declare class GeoPulseSdk {
356
455
  private status;
@@ -369,11 +468,24 @@ declare class GeoPulseSdk {
369
468
  private locationError;
370
469
  private unsubscribeNetwork;
371
470
  private readonly stateMachine;
471
+ private readonly geofenceStateStore;
472
+ private geofenceStateRestored;
473
+ private realtime;
474
+ private readonly createRealtime;
475
+ private lastConfigFetchAt;
476
+ private configRefreshInFlight;
372
477
  private readonly listeners;
373
478
  private readonly experienceListeners;
374
479
  /** 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. */
375
- private readonly seenExperienceDeliveryIds;
480
+ private readonly seenExperienceRevisions;
481
+ private readonly experienceRemovedListeners;
482
+ private readonly removedDeliveryIds;
376
483
  private experiencePollTimer;
484
+ private experiencePollInFlight;
485
+ /** Set while a poll that was refused with "too many requests" waits to be tried again. */
486
+ private pollRetryTimer;
487
+ /** Set when a poll was asked for while one was already running; that one may have queried before the new delivery existed. */
488
+ private experiencePollRequested;
377
489
  private experiencesReceivedCount;
378
490
  private readonly logger;
379
491
  private readonly locationProvider;
@@ -399,8 +511,10 @@ declare class GeoPulseSdk {
399
511
  getPermissionStatus(): Promise<PermissionStatus>;
400
512
  on(eventName: GeofenceEventType, callback: EventListener): void;
401
513
  on(eventName: "experience.received", callback: ExperienceListener): void;
514
+ on(eventName: "experience.removed", callback: ExperienceRemovedListener): void;
402
515
  off(eventName: GeofenceEventType, callback: EventListener): void;
403
516
  off(eventName: "experience.received", callback: ExperienceListener): void;
517
+ off(eventName: "experience.removed", callback: ExperienceRemovedListener): void;
404
518
  getConfiguredGeofences(): SdkGeofenceConfig[];
405
519
  /** The server-resolved application id (from `getProjectConfig`), or null before `initialize()` completes. */
406
520
  getApplicationId(): string | null;
@@ -433,7 +547,25 @@ declare class GeoPulseSdk {
433
547
  */
434
548
  private startExperiencePolling;
435
549
  private stopExperiencePolling;
550
+ /**
551
+ * The backend says in its ingest response whether the event created an experience for this
552
+ * device. Poll right away if so (or if it doesn't say — an older backend), skip if it says
553
+ * there is nothing. Does nothing unless polling is active, so a stopped SDK never polls.
554
+ */
555
+ private handleEventDelivered;
556
+ /** One poll at a time: a second request while one is running makes it run once more afterwards, rather than overlapping. */
436
557
  private pollForExperiences;
558
+ private pollForExperiencesOnce;
559
+ private schedulePollRetry;
560
+ /**
561
+ * Re-reads the geofence list when it is an hour old, so a geofence an admin added, moved, resized or removed reaches a phone
562
+ * that keeps the app open (otherwise only a restart picks it up). Runs from the poll timer, so it costs no timer of its own.
563
+ * A failure keeps the list we have. A disabled application stops the SDK.
564
+ */
565
+ private refreshConfigIfStale;
566
+ /** Keeps the push connection open exactly while the user is inside a geofence. */
567
+ private syncRealtime;
568
+ private persistGeofenceState;
437
569
  private applyProjectConfig;
438
570
  private handleLocationUpdate;
439
571
  }
@@ -527,6 +659,7 @@ declare function ExperienceView({ experience }: {
527
659
  type OnOff = {
528
660
  (eventName: GeofenceEventType, callback: (event: LocationEventPayload) => void): void;
529
661
  (eventName: "experience.received", callback: (payload: ExperienceDeliveryPayload) => void): void;
662
+ (eventName: "experience.removed", callback: (payload: RevokedExperience) => void): void;
530
663
  };
531
664
  declare const GeoPulse: {
532
665
  initialize: (config: GeoPulseConfig) => Promise<void>;
@@ -552,4 +685,4 @@ declare const GeoPulse: {
552
685
  };
553
686
  };
554
687
 
555
- export { ApplicationMismatchError, BannerExperience, CardExperience, type ConfigurationStatus, DeviceLocationProvider, type DiagnosticsSnapshot, type ExperienceDeliveryPayload, ExperienceView, ExpoPermissionProvider, GeoPulse, type GeoPulseConfig, GeoPulseSdk, type GeofenceEventType, type LocationEventPayload, type LocationProvider, type LocationUpdate, ManualNetworkMonitor, MessageExperience, NetInfoNetworkMonitor, type NetworkMonitor, type NetworkStatus, type PermissionProvider, type PermissionStatus, type SdkGeofenceConfig, type SdkStatus, SimulatedLocationProvider, SimulatedPermissionProvider };
688
+ export { ApplicationMismatchError, BannerExperience, CardExperience, type ConfigurationStatus, DeviceLocationProvider, type DiagnosticsSnapshot, type ExperienceDeliveryPayload, ExperienceView, ExpoPermissionProvider, GeoPulse, type GeoPulseConfig, GeoPulseSdk, type GeofenceEventType, type LocationEventPayload, type LocationProvider, type LocationUpdate, ManualNetworkMonitor, MessageExperience, NetInfoNetworkMonitor, type NetworkMonitor, type NetworkStatus, type PermissionProvider, type PermissionStatus, type RevokedExperience, type SdkGeofenceConfig, type SdkStatus, SimulatedLocationProvider, SimulatedPermissionProvider };