@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 +328 -2
- package/dist/index.d.ts +702 -0
- package/dist/index.js +1764 -0
- package/dist/index.js.map +1 -0
- package/package.json +44 -4
package/README.md
CHANGED
|
@@ -1,3 +1,329 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @damn-coding/geopulse-react-native
|
|
2
2
|
|
|
3
|
-
|
|
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).
|