@damn-coding/geopulse-react-native 0.0.0-stage → 0.5.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 CHANGED
@@ -1,3 +1,329 @@
1
- # Temporary Holding Version
1
+ # @damn-coding/geopulse-react-native
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
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
+
9
+ On-device geofence detection and event sync for React Native / Expo apps. Fetches an
10
+ application's configured geofences from GeoPulse, watches location (real device or the
11
+ built-in simulator), and reports `geofence.enter` / `geofence.exit` events to the GeoPulse
12
+ backend, with a persistent, backoff-retried offline queue so a flaky connection never loses
13
+ an event.
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
+
30
+ ## Installation
31
+
32
+ ```bash
33
+ npm install @damn-coding/geopulse-react-native
34
+ ```
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
+
44
+ ## Initialization
45
+
46
+ Get `apiKey` and `applicationId` from the admin portal's `/sdk` page (the application created
47
+ automatically for you during onboarding is named "Playground"). The SDK connects to the hosted
48
+ GeoPulse backend automatically, with nothing else to configure:
49
+
50
+ ```ts
51
+ import { GeoPulse } from "@damn-coding/geopulse-react-native";
52
+
53
+ await GeoPulse.initialize({
54
+ apiKey: "gp_test_xxx",
55
+ applicationId: "app_xxx",
56
+ });
57
+ ```
58
+
59
+ `initialize()` fetches the application's currently active geofences from the backend
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.
63
+
64
+ ## Start / stop
65
+
66
+ ```ts
67
+ await GeoPulse.start(); // requests location permission, begins watching location
68
+ GeoPulse.stop();
69
+ ```
70
+
71
+ `start()` throws if `initialize()` hasn't completed successfully, or if location permission is
72
+ denied.
73
+
74
+ ## Events
75
+
76
+ ```ts
77
+ const onEnter = (event) => console.log("entered", event.geofenceId, event);
78
+ GeoPulse.on("geofence.enter", onEnter);
79
+ GeoPulse.on("geofence.exit", (event) => console.log("exited", event));
80
+
81
+ // later
82
+ GeoPulse.off("geofence.enter", onEnter);
83
+ ```
84
+
85
+ An event fires at most once per actual state transition: moving around *inside* a geofence
86
+ never re-fires `geofence.enter`, and a geofence that's still active in your config never fires
87
+ `geofence.exit` just because you didn't move (see `src/geofencing/state-machine.ts` in this
88
+ package's source for the exact transition rules this guarantees).
89
+
90
+ ## Experiences (campaign delivery)
91
+
92
+ If a geofence event matches an active Campaign in the admin portal, the SDK surfaces the
93
+ associated Experience (a banner/card/message):
94
+
95
+ ```ts
96
+ GeoPulse.on("experience.received", (payload) => {
97
+ // payload: ExperienceDeliveryPayload: { deliveryId, campaignId, revision, isUpdate, experience: {...}, event: {...} }
98
+ showBanner(payload.experience);
99
+ });
100
+
101
+ GeoPulse.on("experience.removed", ({ deliveryId, campaignId }) => {
102
+ hideBanner(deliveryId);
103
+ });
104
+ ```
105
+
106
+ ### Experience types, and more than one at once
107
+
108
+ An experience has a `type`: `alert`, `banner`, `card` or `message`.
109
+
110
+ - **`alert`** is the popup: show it over whatever the user is doing (a React Native `Modal`).
111
+ - **`banner`**, **`card`** and **`message`** belong inline in one of your own screens. Do not pop them up.
112
+
113
+ `ExperienceView` draws any of them; `isAlertExperience(payload)` tells you which way to show it. Several
114
+ campaigns can match the same place (an admin can add up to 5 per geofence), and each one arrives as its own
115
+ `experience.received`, so keep a list, replace an entry that has the same `deliveryId` (that is an update),
116
+ and remove it on `experience.removed`:
117
+
118
+ ```tsx
119
+ import { ExperienceView, isAlertExperience } from "@damn-coding/geopulse-react-native";
120
+
121
+ const [inline, setInline] = useState<ExperienceDeliveryPayload[]>([]);
122
+ const [alerts, setAlerts] = useState<ExperienceDeliveryPayload[]>([]); // shown one at a time
123
+
124
+ useEffect(() => {
125
+ const upsert = (list: ExperienceDeliveryPayload[], p: ExperienceDeliveryPayload) => [...list.filter((x) => x.deliveryId !== p.deliveryId), p];
126
+ const onReceived = (p: ExperienceDeliveryPayload) =>
127
+ isAlertExperience(p) ? setAlerts((l) => upsert(l, p)) : setInline((l) => upsert(l, p));
128
+ const onRemoved = ({ deliveryId }: { deliveryId: string }) => {
129
+ setAlerts((l) => l.filter((x) => x.deliveryId !== deliveryId));
130
+ setInline((l) => l.filter((x) => x.deliveryId !== deliveryId));
131
+ };
132
+ GeoPulse.on("experience.received", onReceived);
133
+ GeoPulse.on("experience.removed", onRemoved);
134
+ return () => {
135
+ GeoPulse.off("experience.received", onReceived);
136
+ GeoPulse.off("experience.removed", onRemoved);
137
+ };
138
+ }, []);
139
+
140
+ // inline.map((p) => <ExperienceView key={p.deliveryId} experience={p.experience} />) inside your screen,
141
+ // and <Modal visible={alerts.length > 0}> showing <ExperienceView experience={alerts[0].experience} /> with a Dismiss button.
142
+ ```
143
+
144
+ - `isUpdate` is `true` when an admin edited an experience the user was already shown: show the new
145
+ content in place of the old (use `deliveryId` to find it).
146
+ - `experience.removed` fires when an admin deleted or disabled the campaign while the user is still
147
+ inside the location: remove what you showed.
148
+
149
+ No push notifications are involved. The SDK fetches its own device's pending experiences over the
150
+ connection it already uses for config/ingestion: once on `start()`, right after an event the backend
151
+ says created one, and on a slow fallback timer (`GeoPulseConfig.experiencePollIntervalMs`, default 15
152
+ minutes). While the user is **inside a geofence with the app open**, the SDK also keeps one WebSocket
153
+ open so the server can tell it the moment an admin changes something that affects them; it closes when
154
+ they leave. Idle devices send no repeated requests. The SDK acknowledges each experience
155
+ automatically, so you never need to call anything to mark it "seen", and it never re-fires the same
156
+ `deliveryId` and `revision` twice. There's no `getActiveExperiences()`: the two events above are the
157
+ whole surface: the SDK transports and notifies, your app owns the UI. How long each kind of admin
158
+ change takes to arrive is in the portal's Setup Guide ("Why changes take a moment").
159
+
160
+ ## How often the SDK talks to the server
161
+
162
+ It only sends an event when the user **enters or leaves** a geofence, never while they stand still.
163
+ To keep call volume (and your bill) low, the SDK holds itself back:
164
+
165
+ - one event per geofence per 2 minutes, at most 3 uploads a minute, whatever the user does;
166
+ - leaving only counts once the user is clearly outside (about 20 m past the edge, or more if the GPS fix
167
+ is less accurate); GPS fixes worse than 100 m are ignored;
168
+ - the state is saved, so reopening the app while still inside does not report a second "enter";
169
+ - when the server answers "too many requests" the SDK waits as long as it is told; a request the
170
+ server will never accept (for example a deleted geofence) is dropped instead of retried forever;
171
+ - the offline queue holds at most 100 events and drops anything older than 24 hours;
172
+ - the geofence list is re-read about once an hour while the app is open, and on every launch.
173
+
174
+ ## Status / diagnostics
175
+
176
+ Beyond the two required by the spec (`getStatus`, `getPermissionStatus`), a rich diagnostics
177
+ snapshot and a few small read-only accessors exist so a host app can build a status screen
178
+ without duplicating SDK-internal state:
179
+
180
+ ```ts
181
+ GeoPulse.getStatus(); // "idle" | "initializing" | "initialized" | "running" | "stopped" | "error"
182
+ await GeoPulse.getPermissionStatus(); // "granted" | "denied" | "undetermined"
183
+ await GeoPulse.getDiagnostics(); // rich snapshot: network status, config status, pending events, last sync, etc.
184
+ GeoPulse.getApplicationId(); // string | null, resolved server-side, not just an echo of what you passed in
185
+ GeoPulse.getDeviceId(); // string | null, persisted across app restarts
186
+ // getDiagnostics() also includes `locationError: string | null`, set if the location provider
187
+ // failed to start (e.g. OS location services off).
188
+ GeoPulse.getConfiguredGeofences(); // the geofences fetched by initialize()
189
+ GeoPulse.getLastKnownLocation(); // most recent location update, or null
190
+ await GeoPulse.getPendingEventCount(); // events waiting in the offline queue right now
191
+ ```
192
+
193
+ ## Simulator
194
+
195
+ Real device movement isn't required to test any of this. In development builds (`__DEV__ ===
196
+ true`), `GeoPulse` runs on a simulated location provider by default:
197
+
198
+ ```ts
199
+ GeoPulse.simulator.setLocation({ latitude: 33.4484, longitude: -112.074 });
200
+ ```
201
+
202
+ Each call runs through the exact same geofence engine and event pipeline as a real device
203
+ update would. The one difference: while the simulator is running, the 2-minute per-geofence cooldown is off and
204
+ the saved "inside" state is not kept across reloads, so you can enter and exit as quickly as you like and
205
+ an "enter" fires again after a reload. (The backend still applies its own limits, for example a webhook rule
206
+ 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
207
+ directly.
208
+
209
+ **The simulator is inert in production builds.** `GeoPulse.simulator.setLocation(...)` is a
210
+ no-op (with a console warning) once `__DEV__` is false. Release builds run on
211
+ `DeviceLocationProvider` (real `expo-location`) instead, automatically, with no code change
212
+ required. If you need real device location *during development* instead of the simulator,
213
+ construct your own instance rather than using the `GeoPulse` singleton:
214
+
215
+ ```ts
216
+ import { GeoPulseSdk, DeviceLocationProvider, ExpoPermissionProvider } from "@damn-coding/geopulse-react-native";
217
+
218
+ const sdk = new GeoPulseSdk({
219
+ locationProvider: new DeviceLocationProvider(),
220
+ permissionProvider: new ExpoPermissionProvider(),
221
+ });
222
+ ```
223
+
224
+ ## Permissions
225
+
226
+ This SDK requests **foreground location only**, never background location. Concretely:
227
+
228
+ Minimal Expo `app.json` that covers both platforms:
229
+
230
+ ```json
231
+ {
232
+ "expo": {
233
+ "plugins": [
234
+ ["expo-location", { "locationWhenInUsePermission": "This app uses your location to detect nearby places." }]
235
+ ],
236
+ "android": { "permissions": ["ACCESS_COARSE_LOCATION", "ACCESS_FINE_LOCATION"] }
237
+ }
238
+ }
239
+ ```
240
+
241
+ Use your own wording for the permission text: it is what your users see, and the stores review it.
242
+
243
+ - **iOS**: `NSLocationWhenInUseUsageDescription` must be set in your app's `app.json` (or
244
+ `Info.plist` directly for a bare RN app); `expo-location`'s config plugin sets this from the
245
+ `locationWhenInUsePermission` option. No `NSLocationAlwaysAndWhenInUseUsageDescription` is
246
+ requested, so the SDK will never prompt for "Always" access.
247
+ - **Android**: `ACCESS_FINE_LOCATION` (and `ACCESS_COARSE_LOCATION` as a fallback) in the
248
+ manifest, via your app's `app.json`'s `android.permissions`. No `ACCESS_BACKGROUND_LOCATION`.
249
+ - **Foreground-only limitation**: location updates (and therefore geofence detection) only run
250
+ while the app is in the foreground. Backgrounding the app pauses detection: an enter/exit
251
+ that happens while the app is backgrounded will not be caught until the app is foregrounded
252
+ again and a new location update arrives. This is a deliberate scope boundary for this phase,
253
+ not an oversight. Background geofencing needs a materially different architecture (a native
254
+ background task, plus battery/OS-throttling tradeoffs) and is planned for a later phase. Do
255
+ not present this SDK as providing production-grade background geofencing.
256
+ - **Battery**: `DeviceLocationProvider` uses `Location.Accuracy.Balanced` with a 10m distance
257
+ filter and a 5s minimum interval. That's a reasonable foreground default, not tuned for
258
+ high-precision or high-frequency tracking.
259
+
260
+ ## Privacy and store disclosures
261
+
262
+ Your app sends its end users' location data to GeoPulse, so **you** must disclose that to them and
263
+ to the app stores. What the SDK sends:
264
+
265
+ - **When:** only when a user enters or exits one of your geofences, while the app is in the
266
+ foreground. Continuous location is processed on the device and is not uploaded.
267
+ - **What:** the user's precise coordinates and a timestamp at that moment, the geofence and
268
+ location ids, your application id, a random device id the SDK generates and stores on the
269
+ device (not an advertising id), the platform (`ios`/`android`) and the SDK version.
270
+ - **Where it goes:** your GeoPulse project. It is visible to the members of your organization in
271
+ the portal and is forwarded to any webhook endpoints you configure.
272
+
273
+ What that means for you:
274
+
275
+ - Your **privacy policy** must say you collect precise location and a device identifier, why, and
276
+ who processes it (GeoPulse acts as your service provider).
277
+ - **Apple App Privacy ("nutrition label"):** declare *Precise Location* and *Device ID*, collected
278
+ for app functionality and/or analytics, according to how you use them.
279
+ - **Google Play Data safety form:** declare *Approximate and Precise location* and *Device or other
280
+ IDs* as collected, with the purposes that apply to your app.
281
+ - Show a clear, honest permission prompt text (see [Permissions](#permissions)), and ask for
282
+ consent where the law of your users' region requires it.
283
+
284
+ This section is guidance, not legal advice: the store forms are your responsibility, so check
285
+ them against how your app really behaves.
286
+
287
+ ## Troubleshooting
288
+
289
+ - **`initialize()` rejects with a fetch/network error**: check `functionsBaseUrl` is reachable
290
+ from the device (an emulator URL like `127.0.0.1` is not reachable from a physical device or
291
+ even always from a simulator, so use your machine's LAN IP or a deployed URL instead).
292
+ - **`start()` rejects with "Location permission not granted"**: the user denied the OS
293
+ permission prompt (real device), or `PermissionProvider.request()` didn't resolve
294
+ `"granted"` (simulator mode always grants; this only happens with `DeviceLocationProvider`).
295
+ - **No `geofence.enter` ever fires in the simulator**: confirm `GeoPulse.getConfiguredGeofences()`
296
+ isn't empty; if it is, no geofence exists yet for this application's project in the admin
297
+ portal, or `initialize()` was called before the SDK key's application had one created.
298
+ - **Events show up in `getPendingEventCount()` but never seem to send**: each queued event has
299
+ its own exponential-backoff schedule; a recently-failed event isn't retried again immediately.
300
+ Retries also fire as soon as the network monitor reports connectivity restored, so this should
301
+ resolve quickly once you're actually back online. If it doesn't, check `functionsBaseUrl` is
302
+ reachable at all.
303
+
304
+ ## Internal architecture
305
+
306
+ ```
307
+ src/
308
+ core/ SDK lifecycle + diagnostics (GeoPulseSdk), Logger. The only module that wires everything else together
309
+ config/ GeoPulseConfig type, device-id persistence, config caching
310
+ location/ LocationProvider abstraction: SimulatedLocationProvider (default in dev) + DeviceLocationProvider (expo-location)
311
+ geofencing/ GeofenceStateMachine: haversine distance + INSIDE/OUTSIDE transition tracking
312
+ events/ Event payload construction + id generation
313
+ storage/ Offline pending-event queue (InMemoryEventQueueStore for tests, AsyncStorageEventQueueStore for the real app)
314
+ sync/ SyncManager (send-or-queue, backoff retry), centralized retry policy, NetworkMonitor
315
+ permissions/ PermissionProvider abstraction: SimulatedPermissionProvider (default in dev) + ExpoPermissionProvider
316
+ firebase/ ApiClient: plain-fetch HTTPS client for the Cloud Functions endpoints (no Firebase SDK on-device)
317
+ index.ts Public API (GeoPulse). Wires the real RN adapters; everything else is injectable for testing
318
+ ```
319
+
320
+ Every module except `location/device-location-provider.ts`,
321
+ `permissions/expo-permission-provider.ts`, `storage/async-storage-store.ts`,
322
+ `config/async-storage-key-value-store.ts`, and `sync/netinfo-network-monitor.ts` has zero React
323
+ Native dependencies and runs under plain Node/Vitest. Those five are the only files that
324
+ import an RN-only package, and nothing in `core/`, `geofencing/`, `events/`, `sync/`
325
+ (excluding the one file above), or `firebase/` imports them (see the comments at the top of
326
+ `storage/index.ts`, `location/index.ts`, `config/index.ts`, `permissions/index.ts`, and
327
+ `sync/index.ts`). That split is what makes `GeoPulseSdk` and the geofence engine unit-testable
328
+ without a device, simulator, or Metro bundler. Run `pnpm test` from this package's directory
329
+ (55 tests as of this phase).