@sharib/geopulse-react-native 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +194 -0
- package/dist/index.d.ts +489 -0
- package/dist/index.js +1036 -0
- package/dist/index.js.map +1 -0
- package/package.json +40 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,1036 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __create = Object.create;
|
|
3
|
+
var __defProp = Object.defineProperty;
|
|
4
|
+
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
5
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
6
|
+
var __getProtoOf = Object.getPrototypeOf;
|
|
7
|
+
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
8
|
+
var __export = (target, all) => {
|
|
9
|
+
for (var name in all)
|
|
10
|
+
__defProp(target, name, { get: all[name], enumerable: true });
|
|
11
|
+
};
|
|
12
|
+
var __copyProps = (to, from, except, desc) => {
|
|
13
|
+
if (from && typeof from === "object" || typeof from === "function") {
|
|
14
|
+
for (let key of __getOwnPropNames(from))
|
|
15
|
+
if (!__hasOwnProp.call(to, key) && key !== except)
|
|
16
|
+
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
|
|
17
|
+
}
|
|
18
|
+
return to;
|
|
19
|
+
};
|
|
20
|
+
var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(
|
|
21
|
+
// If the importer is in node compatibility mode or this is not an ESM
|
|
22
|
+
// file that has been converted to a CommonJS file using a Babel-
|
|
23
|
+
// compatible transform (i.e. "__esModule" has not been set), then set
|
|
24
|
+
// "default" to the CommonJS "module.exports" for node compatibility.
|
|
25
|
+
isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target,
|
|
26
|
+
mod
|
|
27
|
+
));
|
|
28
|
+
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
|
|
29
|
+
|
|
30
|
+
// src/index.ts
|
|
31
|
+
var index_exports = {};
|
|
32
|
+
__export(index_exports, {
|
|
33
|
+
DeviceLocationProvider: () => DeviceLocationProvider,
|
|
34
|
+
ExpoPermissionProvider: () => ExpoPermissionProvider,
|
|
35
|
+
GeoPulse: () => GeoPulse,
|
|
36
|
+
GeoPulseSdk: () => GeoPulseSdk,
|
|
37
|
+
ManualNetworkMonitor: () => ManualNetworkMonitor,
|
|
38
|
+
NetInfoNetworkMonitor: () => NetInfoNetworkMonitor,
|
|
39
|
+
SimulatedLocationProvider: () => SimulatedLocationProvider,
|
|
40
|
+
SimulatedPermissionProvider: () => SimulatedPermissionProvider
|
|
41
|
+
});
|
|
42
|
+
module.exports = __toCommonJS(index_exports);
|
|
43
|
+
var import_react_native = require("react-native");
|
|
44
|
+
|
|
45
|
+
// src/config/async-storage-key-value-store.ts
|
|
46
|
+
var import_async_storage = __toESM(require("@react-native-async-storage/async-storage"));
|
|
47
|
+
var AsyncStorageKeyValueStore = class {
|
|
48
|
+
async getItem(key) {
|
|
49
|
+
return import_async_storage.default.getItem(key);
|
|
50
|
+
}
|
|
51
|
+
async setItem(key, value) {
|
|
52
|
+
await import_async_storage.default.setItem(key, value);
|
|
53
|
+
}
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
// src/config/types.ts
|
|
57
|
+
var SDK_VERSION = "0.3.0";
|
|
58
|
+
|
|
59
|
+
// src/config/key-value-store.ts
|
|
60
|
+
var InMemoryKeyValueStore = class {
|
|
61
|
+
data = /* @__PURE__ */ new Map();
|
|
62
|
+
async getItem(key) {
|
|
63
|
+
return this.data.get(key) ?? null;
|
|
64
|
+
}
|
|
65
|
+
async setItem(key, value) {
|
|
66
|
+
this.data.set(key, value);
|
|
67
|
+
}
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
// src/events/uuid.ts
|
|
71
|
+
function generateEventId() {
|
|
72
|
+
return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, (char) => {
|
|
73
|
+
const random = Math.random() * 16 | 0;
|
|
74
|
+
const value = char === "x" ? random : random & 3 | 8;
|
|
75
|
+
return value.toString(16);
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// src/config/device-id.ts
|
|
80
|
+
var DEVICE_ID_STORAGE_KEY = "@geopulse/device-id";
|
|
81
|
+
async function getOrCreateDeviceId(store) {
|
|
82
|
+
const existing = await store.getItem(DEVICE_ID_STORAGE_KEY);
|
|
83
|
+
if (existing) {
|
|
84
|
+
return existing;
|
|
85
|
+
}
|
|
86
|
+
const deviceId = generateEventId();
|
|
87
|
+
await store.setItem(DEVICE_ID_STORAGE_KEY, deviceId);
|
|
88
|
+
return deviceId;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// src/config/config-cache.ts
|
|
92
|
+
var CONFIG_CACHE_STORAGE_KEY = "@geopulse/config-cache";
|
|
93
|
+
var ConfigCache = class {
|
|
94
|
+
constructor(store) {
|
|
95
|
+
this.store = store;
|
|
96
|
+
}
|
|
97
|
+
store;
|
|
98
|
+
async save(config) {
|
|
99
|
+
const cached = { config, fetchedAt: (/* @__PURE__ */ new Date()).toISOString() };
|
|
100
|
+
await this.store.setItem(CONFIG_CACHE_STORAGE_KEY, JSON.stringify(cached));
|
|
101
|
+
}
|
|
102
|
+
async load() {
|
|
103
|
+
const raw = await this.store.getItem(CONFIG_CACHE_STORAGE_KEY);
|
|
104
|
+
if (!raw) return null;
|
|
105
|
+
try {
|
|
106
|
+
return JSON.parse(raw);
|
|
107
|
+
} catch {
|
|
108
|
+
return null;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
// src/protocol.ts
|
|
114
|
+
var SDK_KEY_HEADER = "x-geopulse-sdk-key";
|
|
115
|
+
var SDK_ENDPOINTS = {
|
|
116
|
+
GET_PROJECT_CONFIG: "getProjectConfig",
|
|
117
|
+
INGEST_LOCATION_EVENT: "ingestLocationEvent",
|
|
118
|
+
GET_PENDING_EXPERIENCES: "getPendingExperiences",
|
|
119
|
+
ACKNOWLEDGE_EXPERIENCE_DELIVERY: "acknowledgeExperienceDelivery"
|
|
120
|
+
};
|
|
121
|
+
var EARTH_RADIUS_METERS = 6371e3;
|
|
122
|
+
function toRadians(degrees) {
|
|
123
|
+
return degrees * Math.PI / 180;
|
|
124
|
+
}
|
|
125
|
+
function calculateDistanceMeters(from, to) {
|
|
126
|
+
const lat1 = toRadians(from.latitude);
|
|
127
|
+
const lat2 = toRadians(to.latitude);
|
|
128
|
+
const deltaLat = toRadians(to.latitude - from.latitude);
|
|
129
|
+
const deltaLng = toRadians(to.longitude - from.longitude);
|
|
130
|
+
const a = Math.sin(deltaLat / 2) ** 2 + Math.cos(lat1) * Math.cos(lat2) * Math.sin(deltaLng / 2) ** 2;
|
|
131
|
+
const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
|
|
132
|
+
return EARTH_RADIUS_METERS * c;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// src/firebase/api-client.ts
|
|
136
|
+
var REQUEST_TIMEOUT_MS = 15e3;
|
|
137
|
+
async function fetchWithTimeout(url, init) {
|
|
138
|
+
const controller = new AbortController();
|
|
139
|
+
const timeout = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
|
|
140
|
+
try {
|
|
141
|
+
return await fetch(url, { ...init, signal: controller.signal });
|
|
142
|
+
} finally {
|
|
143
|
+
clearTimeout(timeout);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
async function fetchOrThrow(callName, url, init) {
|
|
147
|
+
try {
|
|
148
|
+
return await fetchWithTimeout(url, init);
|
|
149
|
+
} catch (error) {
|
|
150
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
151
|
+
throw new Error(`${callName} request failed: ${message}`);
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
var ApiClient = class {
|
|
155
|
+
constructor(baseUrl, sdkKey) {
|
|
156
|
+
this.baseUrl = baseUrl;
|
|
157
|
+
this.sdkKey = sdkKey;
|
|
158
|
+
}
|
|
159
|
+
baseUrl;
|
|
160
|
+
sdkKey;
|
|
161
|
+
/**
|
|
162
|
+
* `identity` doubles as this device's registration/heartbeat (see
|
|
163
|
+
* functions/src/events/get-project-config.ts) — sent as query params
|
|
164
|
+
* since GET requests carry no body.
|
|
165
|
+
*/
|
|
166
|
+
async getProjectConfig(identity) {
|
|
167
|
+
const query = new URLSearchParams({
|
|
168
|
+
deviceId: identity.deviceId,
|
|
169
|
+
platform: identity.platform,
|
|
170
|
+
sdkVersion: identity.sdkVersion
|
|
171
|
+
});
|
|
172
|
+
const response = await fetchOrThrow("getProjectConfig", `${this.baseUrl}/${SDK_ENDPOINTS.GET_PROJECT_CONFIG}?${query.toString()}`, {
|
|
173
|
+
method: "GET",
|
|
174
|
+
headers: { [SDK_KEY_HEADER]: this.sdkKey }
|
|
175
|
+
});
|
|
176
|
+
if (!response.ok) {
|
|
177
|
+
throw new Error(`getProjectConfig failed with status ${response.status}`);
|
|
178
|
+
}
|
|
179
|
+
return await response.json();
|
|
180
|
+
}
|
|
181
|
+
async ingestEvent(payload) {
|
|
182
|
+
const response = await fetchOrThrow("ingestLocationEvent", `${this.baseUrl}/${SDK_ENDPOINTS.INGEST_LOCATION_EVENT}`, {
|
|
183
|
+
method: "POST",
|
|
184
|
+
headers: { [SDK_KEY_HEADER]: this.sdkKey, "Content-Type": "application/json" },
|
|
185
|
+
body: JSON.stringify(payload)
|
|
186
|
+
});
|
|
187
|
+
if (!response.ok) {
|
|
188
|
+
throw new Error(`ingestLocationEvent failed with status ${response.status}`);
|
|
189
|
+
}
|
|
190
|
+
return await response.json();
|
|
191
|
+
}
|
|
192
|
+
/** Polled by GeoPulseSdk's experience-polling loop — see core/geo-pulse-sdk.ts. */
|
|
193
|
+
async getPendingExperiences(deviceId) {
|
|
194
|
+
const query = new URLSearchParams({ deviceId });
|
|
195
|
+
const response = await fetchOrThrow("getPendingExperiences", `${this.baseUrl}/${SDK_ENDPOINTS.GET_PENDING_EXPERIENCES}?${query.toString()}`, {
|
|
196
|
+
method: "GET",
|
|
197
|
+
headers: { [SDK_KEY_HEADER]: this.sdkKey }
|
|
198
|
+
});
|
|
199
|
+
if (!response.ok) {
|
|
200
|
+
throw new Error(`getPendingExperiences failed with status ${response.status}`);
|
|
201
|
+
}
|
|
202
|
+
const body = await response.json();
|
|
203
|
+
return body.experiences;
|
|
204
|
+
}
|
|
205
|
+
/** Confirms a delivery was surfaced via `experience.received`, so it stops appearing in future polls. */
|
|
206
|
+
async acknowledgeExperienceDelivery(deliveryId) {
|
|
207
|
+
const response = await fetchOrThrow("acknowledgeExperienceDelivery", `${this.baseUrl}/${SDK_ENDPOINTS.ACKNOWLEDGE_EXPERIENCE_DELIVERY}`, {
|
|
208
|
+
method: "POST",
|
|
209
|
+
headers: { [SDK_KEY_HEADER]: this.sdkKey, "Content-Type": "application/json" },
|
|
210
|
+
body: JSON.stringify({ deliveryId })
|
|
211
|
+
});
|
|
212
|
+
if (!response.ok) {
|
|
213
|
+
throw new Error(`acknowledgeExperienceDelivery failed with status ${response.status}`);
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
};
|
|
217
|
+
|
|
218
|
+
// src/events/factory.ts
|
|
219
|
+
function createEventPayload(params) {
|
|
220
|
+
return {
|
|
221
|
+
id: generateEventId(),
|
|
222
|
+
type: params.type,
|
|
223
|
+
projectId: params.projectId,
|
|
224
|
+
applicationId: params.applicationId,
|
|
225
|
+
geofenceId: params.geofence.id,
|
|
226
|
+
locationId: params.geofence.locationId,
|
|
227
|
+
deviceId: params.deviceId,
|
|
228
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString(),
|
|
229
|
+
latitude: params.location.latitude,
|
|
230
|
+
longitude: params.location.longitude,
|
|
231
|
+
metadata: params.metadata ?? {},
|
|
232
|
+
sdkVersion: SDK_VERSION,
|
|
233
|
+
// First-try attempt count; SyncManager overwrites this with the real
|
|
234
|
+
// count if the event ends up going through the retry queue (see
|
|
235
|
+
// sync/sync-manager.ts#retryPending) — that's what makes the
|
|
236
|
+
// "Attempts" column in the admin's Event Explorer meaningful.
|
|
237
|
+
attempts: 1
|
|
238
|
+
};
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
// src/geofencing/state-machine.ts
|
|
242
|
+
var GeofenceStateMachine = class {
|
|
243
|
+
membershipByGeofenceId = /* @__PURE__ */ new Map();
|
|
244
|
+
evaluate(location, geofences) {
|
|
245
|
+
const transitions = [];
|
|
246
|
+
const activeIds = /* @__PURE__ */ new Set();
|
|
247
|
+
for (const geofence of geofences) {
|
|
248
|
+
activeIds.add(geofence.id);
|
|
249
|
+
const distance = calculateDistanceMeters(location, geofence.center);
|
|
250
|
+
const nextMembership = distance <= geofence.radiusMeters ? "INSIDE" : "OUTSIDE";
|
|
251
|
+
const previousMembership = this.membershipByGeofenceId.get(geofence.id) ?? "OUTSIDE";
|
|
252
|
+
if (previousMembership !== nextMembership) {
|
|
253
|
+
transitions.push({
|
|
254
|
+
geofence,
|
|
255
|
+
type: nextMembership === "INSIDE" ? "geofence.enter" : "geofence.exit"
|
|
256
|
+
});
|
|
257
|
+
}
|
|
258
|
+
this.membershipByGeofenceId.set(geofence.id, nextMembership);
|
|
259
|
+
}
|
|
260
|
+
for (const id of this.membershipByGeofenceId.keys()) {
|
|
261
|
+
if (!activeIds.has(id)) {
|
|
262
|
+
this.membershipByGeofenceId.delete(id);
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
return transitions;
|
|
266
|
+
}
|
|
267
|
+
getMembership(geofenceId) {
|
|
268
|
+
return this.membershipByGeofenceId.get(geofenceId) ?? "OUTSIDE";
|
|
269
|
+
}
|
|
270
|
+
reset() {
|
|
271
|
+
this.membershipByGeofenceId.clear();
|
|
272
|
+
}
|
|
273
|
+
};
|
|
274
|
+
|
|
275
|
+
// src/location/simulated-location-provider.ts
|
|
276
|
+
var SimulatedLocationProvider = class {
|
|
277
|
+
onUpdate = null;
|
|
278
|
+
currentLocation = null;
|
|
279
|
+
start(onUpdate) {
|
|
280
|
+
this.onUpdate = onUpdate;
|
|
281
|
+
if (this.currentLocation) {
|
|
282
|
+
onUpdate(this.currentLocation);
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
stop() {
|
|
286
|
+
this.onUpdate = null;
|
|
287
|
+
}
|
|
288
|
+
setLocation(coords) {
|
|
289
|
+
const location = { ...coords, timestamp: (/* @__PURE__ */ new Date()).toISOString() };
|
|
290
|
+
this.currentLocation = location;
|
|
291
|
+
this.onUpdate?.(location);
|
|
292
|
+
}
|
|
293
|
+
getCurrentLocation() {
|
|
294
|
+
return this.currentLocation;
|
|
295
|
+
}
|
|
296
|
+
};
|
|
297
|
+
|
|
298
|
+
// src/permissions/simulated-permission-provider.ts
|
|
299
|
+
var SimulatedPermissionProvider = class {
|
|
300
|
+
async getStatus() {
|
|
301
|
+
return "granted";
|
|
302
|
+
}
|
|
303
|
+
async request() {
|
|
304
|
+
return "granted";
|
|
305
|
+
}
|
|
306
|
+
};
|
|
307
|
+
|
|
308
|
+
// src/sync/retry-policy.ts
|
|
309
|
+
var RETRY_POLICY = {
|
|
310
|
+
/** Attempts beyond this stop increasing the delay — it plateaus at MAX_RETRY_DELAY_MS, but retrying never stops entirely (the SDK must never give up on an event; see storage/queue.ts). */
|
|
311
|
+
MAX_RETRIES: 6,
|
|
312
|
+
BASE_RETRY_DELAY_MS: 5e3,
|
|
313
|
+
MAX_RETRY_DELAY_MS: 5 * 6e4
|
|
314
|
+
};
|
|
315
|
+
function computeRetryDelayMs(attempts) {
|
|
316
|
+
const cappedAttempts = Math.min(attempts, RETRY_POLICY.MAX_RETRIES);
|
|
317
|
+
const exponential = RETRY_POLICY.BASE_RETRY_DELAY_MS * 2 ** Math.max(0, cappedAttempts - 1);
|
|
318
|
+
const delay = Math.min(exponential, RETRY_POLICY.MAX_RETRY_DELAY_MS);
|
|
319
|
+
const jitter = delay * 0.2 * (Math.random() * 2 - 1);
|
|
320
|
+
return Math.round(delay + jitter);
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
// src/storage/memory-store.ts
|
|
324
|
+
var InMemoryEventQueueStore = class {
|
|
325
|
+
eventsById = /* @__PURE__ */ new Map();
|
|
326
|
+
async getAll() {
|
|
327
|
+
return [...this.eventsById.values()];
|
|
328
|
+
}
|
|
329
|
+
async save(event) {
|
|
330
|
+
this.eventsById.set(event.eventId, event);
|
|
331
|
+
}
|
|
332
|
+
async remove(eventId) {
|
|
333
|
+
this.eventsById.delete(eventId);
|
|
334
|
+
}
|
|
335
|
+
};
|
|
336
|
+
|
|
337
|
+
// src/storage/queue.ts
|
|
338
|
+
var PendingEventQueue = class {
|
|
339
|
+
constructor(store = new InMemoryEventQueueStore()) {
|
|
340
|
+
this.store = store;
|
|
341
|
+
}
|
|
342
|
+
store;
|
|
343
|
+
async enqueue(payload) {
|
|
344
|
+
await this.store.save({
|
|
345
|
+
eventId: payload.id,
|
|
346
|
+
type: payload.type,
|
|
347
|
+
applicationId: payload.applicationId,
|
|
348
|
+
deviceId: payload.deviceId,
|
|
349
|
+
locationId: payload.locationId,
|
|
350
|
+
geofenceId: payload.geofenceId,
|
|
351
|
+
timestamp: payload.timestamp,
|
|
352
|
+
payload,
|
|
353
|
+
attempts: 0,
|
|
354
|
+
status: "pending",
|
|
355
|
+
createdAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
356
|
+
lastAttemptAt: null,
|
|
357
|
+
nextAttemptAt: null
|
|
358
|
+
});
|
|
359
|
+
}
|
|
360
|
+
/** Every queued event, regardless of retry timing — used for diagnostics/pending counts. */
|
|
361
|
+
async listPending() {
|
|
362
|
+
return this.store.getAll();
|
|
363
|
+
}
|
|
364
|
+
/** Only events whose backoff window has elapsed (or that have never been attempted) — used by SyncManager.retryPending(). */
|
|
365
|
+
async listReadyForRetry(now = /* @__PURE__ */ new Date()) {
|
|
366
|
+
const all = await this.store.getAll();
|
|
367
|
+
return all.filter(
|
|
368
|
+
(event) => event.status !== "sending" && (!event.nextAttemptAt || new Date(event.nextAttemptAt) <= now)
|
|
369
|
+
);
|
|
370
|
+
}
|
|
371
|
+
/** Marks an event as in-flight so a concurrent retry pass won't also pick it up. */
|
|
372
|
+
async markSending(eventId) {
|
|
373
|
+
const existing = await this.get(eventId);
|
|
374
|
+
if (!existing) return;
|
|
375
|
+
await this.store.save({ ...existing, status: "sending" });
|
|
376
|
+
}
|
|
377
|
+
async markAttemptFailed(eventId) {
|
|
378
|
+
const existing = await this.get(eventId);
|
|
379
|
+
if (!existing) return;
|
|
380
|
+
const attempts = existing.attempts + 1;
|
|
381
|
+
const now = /* @__PURE__ */ new Date();
|
|
382
|
+
await this.store.save({
|
|
383
|
+
...existing,
|
|
384
|
+
status: "failed",
|
|
385
|
+
attempts,
|
|
386
|
+
lastAttemptAt: now.toISOString(),
|
|
387
|
+
nextAttemptAt: new Date(now.getTime() + computeRetryDelayMs(attempts)).toISOString()
|
|
388
|
+
});
|
|
389
|
+
}
|
|
390
|
+
async markSent(eventId) {
|
|
391
|
+
await this.store.remove(eventId);
|
|
392
|
+
}
|
|
393
|
+
async get(eventId) {
|
|
394
|
+
return (await this.store.getAll()).find((event) => event.eventId === eventId);
|
|
395
|
+
}
|
|
396
|
+
};
|
|
397
|
+
|
|
398
|
+
// src/sync/network-monitor.ts
|
|
399
|
+
var ManualNetworkMonitor = class {
|
|
400
|
+
status = "online";
|
|
401
|
+
listeners = /* @__PURE__ */ new Set();
|
|
402
|
+
getStatus() {
|
|
403
|
+
return this.status;
|
|
404
|
+
}
|
|
405
|
+
setStatus(status) {
|
|
406
|
+
if (status === this.status) return;
|
|
407
|
+
this.status = status;
|
|
408
|
+
this.listeners.forEach((listener) => listener(status));
|
|
409
|
+
}
|
|
410
|
+
subscribe(listener) {
|
|
411
|
+
this.listeners.add(listener);
|
|
412
|
+
return () => this.listeners.delete(listener);
|
|
413
|
+
}
|
|
414
|
+
};
|
|
415
|
+
|
|
416
|
+
// src/sync/sync-manager.ts
|
|
417
|
+
var DEFAULT_RETRY_INTERVAL_MS = 3e4;
|
|
418
|
+
var SyncManager = class {
|
|
419
|
+
constructor(apiClient, queue, retryIntervalMs = DEFAULT_RETRY_INTERVAL_MS, logger) {
|
|
420
|
+
this.apiClient = apiClient;
|
|
421
|
+
this.queue = queue;
|
|
422
|
+
this.retryIntervalMs = retryIntervalMs;
|
|
423
|
+
this.logger = logger;
|
|
424
|
+
}
|
|
425
|
+
apiClient;
|
|
426
|
+
queue;
|
|
427
|
+
retryIntervalMs;
|
|
428
|
+
logger;
|
|
429
|
+
retryTimer = null;
|
|
430
|
+
lastSyncAt = null;
|
|
431
|
+
lastUploadResult = null;
|
|
432
|
+
/**
|
|
433
|
+
* Write-ahead: the event is durably queued *before* any network attempt is
|
|
434
|
+
* made, so an app kill mid-request can never lose it (spec: "true
|
|
435
|
+
* write-ahead event persistence" — a prior version of this method tried
|
|
436
|
+
* the network first and only queued on failure, which meant an event in
|
|
437
|
+
* flight when the process died was gone for good). `attemptDelivery` then
|
|
438
|
+
* runs the exact same success/failure bookkeeping a queued retry uses, so
|
|
439
|
+
* there is only one code path for "what happens when an upload settles."
|
|
440
|
+
*/
|
|
441
|
+
async sendOrQueue(payload) {
|
|
442
|
+
await this.queue.enqueue(payload);
|
|
443
|
+
await this.attemptDelivery(payload.id, payload);
|
|
444
|
+
}
|
|
445
|
+
/**
|
|
446
|
+
* Attempts queued events. By default only ones whose backoff window has
|
|
447
|
+
* elapsed (the timer-driven path, `startAutoRetry` — the whole point of
|
|
448
|
+
* backoff is to not hammer the backend on a fixed sweep). Pass
|
|
449
|
+
* `{ force: true }` to ignore that window and attempt every non-in-flight
|
|
450
|
+
* queued event regardless — connectivity just being confirmed restored
|
|
451
|
+
* (see `GeoPulseSdk.start`'s network-monitor subscription) is a stronger
|
|
452
|
+
* signal than "some fixed delay has passed," and a device that was
|
|
453
|
+
* offline for a while shouldn't have to wait out a stale backoff timer
|
|
454
|
+
* once it's actually back online.
|
|
455
|
+
*/
|
|
456
|
+
async retryPending(options = {}) {
|
|
457
|
+
const ready = options.force ? (await this.queue.listPending()).filter((event) => event.status !== "sending") : await this.queue.listReadyForRetry();
|
|
458
|
+
const result = { attempted: ready.length, delivered: 0, failed: 0 };
|
|
459
|
+
for (const event of ready) {
|
|
460
|
+
const attemptNumber = event.attempts + 1;
|
|
461
|
+
const delivered = await this.attemptDelivery(event.eventId, { ...event.payload, attempts: attemptNumber });
|
|
462
|
+
if (delivered) {
|
|
463
|
+
result.delivered++;
|
|
464
|
+
} else {
|
|
465
|
+
result.failed++;
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
return result;
|
|
469
|
+
}
|
|
470
|
+
/**
|
|
471
|
+
* The single place that actually talks to the backend: marks the queued
|
|
472
|
+
* record "sending" (so a concurrent retry pass skips it), attempts the
|
|
473
|
+
* upload, and resolves it to either removed-from-queue (success — a
|
|
474
|
+
* `deduplicated: true` response still counts, since it means the backend
|
|
475
|
+
* already has this event id, which is exactly the outcome a retry is
|
|
476
|
+
* supposed to produce) or a scheduled retry (failure). Returns whether it
|
|
477
|
+
* was delivered, purely so `retryPending` can tally its `SyncResult`.
|
|
478
|
+
*/
|
|
479
|
+
async attemptDelivery(eventId, payload) {
|
|
480
|
+
await this.queue.markSending(eventId);
|
|
481
|
+
this.logger?.log("Event upload started", { eventId, attempt: payload.attempts });
|
|
482
|
+
try {
|
|
483
|
+
await this.apiClient.ingestEvent(payload);
|
|
484
|
+
await this.queue.markSent(eventId);
|
|
485
|
+
this.lastSyncAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
486
|
+
this.lastUploadResult = "success";
|
|
487
|
+
this.logger?.log("Event upload succeeded", { eventId });
|
|
488
|
+
return true;
|
|
489
|
+
} catch (error) {
|
|
490
|
+
await this.queue.markAttemptFailed(eventId);
|
|
491
|
+
this.lastUploadResult = "failure";
|
|
492
|
+
this.logger?.warn("Event upload failed \u2014 retry scheduled", { eventId, error: String(error) });
|
|
493
|
+
return false;
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
startAutoRetry() {
|
|
497
|
+
if (this.retryTimer) return;
|
|
498
|
+
this.retryTimer = setInterval(() => {
|
|
499
|
+
void this.retryPending();
|
|
500
|
+
}, this.retryIntervalMs);
|
|
501
|
+
}
|
|
502
|
+
stopAutoRetry() {
|
|
503
|
+
if (this.retryTimer) {
|
|
504
|
+
clearInterval(this.retryTimer);
|
|
505
|
+
this.retryTimer = null;
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
getLastSyncAt() {
|
|
509
|
+
return this.lastSyncAt;
|
|
510
|
+
}
|
|
511
|
+
getLastUploadResult() {
|
|
512
|
+
return this.lastUploadResult;
|
|
513
|
+
}
|
|
514
|
+
};
|
|
515
|
+
|
|
516
|
+
// src/core/logger.ts
|
|
517
|
+
var Logger = class {
|
|
518
|
+
constructor(enabled) {
|
|
519
|
+
this.enabled = enabled;
|
|
520
|
+
}
|
|
521
|
+
enabled;
|
|
522
|
+
setEnabled(enabled) {
|
|
523
|
+
this.enabled = enabled;
|
|
524
|
+
}
|
|
525
|
+
log(message, details) {
|
|
526
|
+
if (!this.enabled) return;
|
|
527
|
+
if (details) {
|
|
528
|
+
console.log(`[GeoPulse] ${message}`, details);
|
|
529
|
+
} else {
|
|
530
|
+
console.log(`[GeoPulse] ${message}`);
|
|
531
|
+
}
|
|
532
|
+
}
|
|
533
|
+
warn(message, details) {
|
|
534
|
+
if (!this.enabled) return;
|
|
535
|
+
if (details) {
|
|
536
|
+
console.warn(`[GeoPulse] ${message}`, details);
|
|
537
|
+
} else {
|
|
538
|
+
console.warn(`[GeoPulse] ${message}`);
|
|
539
|
+
}
|
|
540
|
+
}
|
|
541
|
+
};
|
|
542
|
+
|
|
543
|
+
// src/core/geo-pulse-sdk.ts
|
|
544
|
+
var DEFAULT_EXPERIENCE_POLL_INTERVAL_MS = 3e4;
|
|
545
|
+
var MIN_TIMER_INTERVAL_MS = 1e3;
|
|
546
|
+
function clampIntervalMs(value) {
|
|
547
|
+
if (value === void 0) {
|
|
548
|
+
return void 0;
|
|
549
|
+
}
|
|
550
|
+
if (!Number.isFinite(value) || value < MIN_TIMER_INTERVAL_MS) {
|
|
551
|
+
return MIN_TIMER_INTERVAL_MS;
|
|
552
|
+
}
|
|
553
|
+
return value;
|
|
554
|
+
}
|
|
555
|
+
var GeoPulseSdk = class {
|
|
556
|
+
status = "idle";
|
|
557
|
+
config = null;
|
|
558
|
+
apiClient = null;
|
|
559
|
+
syncManager = null;
|
|
560
|
+
resolvedProjectId = null;
|
|
561
|
+
resolvedApplicationId = null;
|
|
562
|
+
applicationEnabled = true;
|
|
563
|
+
configVersion = null;
|
|
564
|
+
configurationStatus = "unsynced";
|
|
565
|
+
geofences = [];
|
|
566
|
+
lastKnownLocation = null;
|
|
567
|
+
lastEvent = null;
|
|
568
|
+
deviceId = null;
|
|
569
|
+
locationError = null;
|
|
570
|
+
unsubscribeNetwork = null;
|
|
571
|
+
stateMachine = new GeofenceStateMachine();
|
|
572
|
+
listeners = /* @__PURE__ */ new Map();
|
|
573
|
+
experienceListeners = /* @__PURE__ */ new Set();
|
|
574
|
+
/** 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. */
|
|
575
|
+
seenExperienceDeliveryIds = /* @__PURE__ */ new Set();
|
|
576
|
+
experiencePollTimer = null;
|
|
577
|
+
experiencesReceivedCount = 0;
|
|
578
|
+
logger;
|
|
579
|
+
locationProvider;
|
|
580
|
+
permissionProvider;
|
|
581
|
+
queue;
|
|
582
|
+
buildApiClient;
|
|
583
|
+
deviceIdStore;
|
|
584
|
+
configCache;
|
|
585
|
+
networkMonitor;
|
|
586
|
+
getPlatform;
|
|
587
|
+
experiencePollIntervalMs = DEFAULT_EXPERIENCE_POLL_INTERVAL_MS;
|
|
588
|
+
constructor(dependencies = {}) {
|
|
589
|
+
this.locationProvider = dependencies.locationProvider ?? new SimulatedLocationProvider();
|
|
590
|
+
this.permissionProvider = dependencies.permissionProvider ?? new SimulatedPermissionProvider();
|
|
591
|
+
this.queue = dependencies.queue ?? new PendingEventQueue();
|
|
592
|
+
this.buildApiClient = dependencies.createApiClient ?? ((config) => new ApiClient(config.functionsBaseUrl ?? "", config.apiKey));
|
|
593
|
+
this.deviceIdStore = dependencies.deviceIdStore ?? new InMemoryKeyValueStore();
|
|
594
|
+
this.configCache = new ConfigCache(dependencies.configCacheStore ?? new InMemoryKeyValueStore());
|
|
595
|
+
this.networkMonitor = dependencies.networkMonitor ?? new ManualNetworkMonitor();
|
|
596
|
+
this.getPlatform = dependencies.getPlatform ?? (() => "unknown");
|
|
597
|
+
this.logger = new Logger(dependencies.debug ?? false);
|
|
598
|
+
}
|
|
599
|
+
/**
|
|
600
|
+
* Resolves this device's persistent id, then fetches (or falls back to
|
|
601
|
+
* cached) project configuration. Must succeed — fresh or cached — before
|
|
602
|
+
* `start()`; if neither is available (first-ever run with no network),
|
|
603
|
+
* this rethrows and `getStatus()` reports "error".
|
|
604
|
+
*/
|
|
605
|
+
async initialize(config) {
|
|
606
|
+
if (this.status === "running") {
|
|
607
|
+
throw new Error("GeoPulse.initialize() cannot be called while already running \u2014 call GeoPulse.stop() first");
|
|
608
|
+
}
|
|
609
|
+
if (this.status === "initializing") {
|
|
610
|
+
throw new Error("GeoPulse.initialize() is already in progress \u2014 wait for the first call to resolve");
|
|
611
|
+
}
|
|
612
|
+
if (!config.apiKey || !config.applicationId) {
|
|
613
|
+
throw new Error("GeoPulse.initialize requires both apiKey and applicationId");
|
|
614
|
+
}
|
|
615
|
+
if (!config.functionsBaseUrl) {
|
|
616
|
+
throw new Error(
|
|
617
|
+
"GeoPulse.initialize requires functionsBaseUrl (the deployed Cloud Functions base URL, or the local emulator's URL in development)"
|
|
618
|
+
);
|
|
619
|
+
}
|
|
620
|
+
if (config.debug !== void 0) {
|
|
621
|
+
this.logger.setEnabled(config.debug);
|
|
622
|
+
}
|
|
623
|
+
this.logger.log("GeoPulse.initialize()", { applicationId: config.applicationId });
|
|
624
|
+
this.status = "initializing";
|
|
625
|
+
this.config = config;
|
|
626
|
+
this.experiencePollIntervalMs = clampIntervalMs(config.experiencePollIntervalMs) ?? DEFAULT_EXPERIENCE_POLL_INTERVAL_MS;
|
|
627
|
+
this.apiClient = this.buildApiClient(config);
|
|
628
|
+
this.syncManager = new SyncManager(this.apiClient, this.queue, clampIntervalMs(config.retryIntervalMs), this.logger);
|
|
629
|
+
this.deviceId = await getOrCreateDeviceId(this.deviceIdStore);
|
|
630
|
+
try {
|
|
631
|
+
const projectConfig = await this.apiClient.getProjectConfig({
|
|
632
|
+
deviceId: this.deviceId,
|
|
633
|
+
platform: this.getPlatform(),
|
|
634
|
+
sdkVersion: SDK_VERSION
|
|
635
|
+
});
|
|
636
|
+
this.applyProjectConfig(projectConfig);
|
|
637
|
+
this.configurationStatus = "synced";
|
|
638
|
+
await this.configCache.save(projectConfig);
|
|
639
|
+
this.status = "initialized";
|
|
640
|
+
this.logger.log("Configuration synced", { geofenceCount: projectConfig.geofences.length, configVersion: projectConfig.configVersion });
|
|
641
|
+
} catch (error) {
|
|
642
|
+
const cached = await this.configCache.load();
|
|
643
|
+
if (cached) {
|
|
644
|
+
this.applyProjectConfig(cached.config);
|
|
645
|
+
this.configurationStatus = "cached";
|
|
646
|
+
this.status = "initialized";
|
|
647
|
+
this.logger.warn("getProjectConfig failed \u2014 using cached configuration", { fetchedAt: cached.fetchedAt });
|
|
648
|
+
return;
|
|
649
|
+
}
|
|
650
|
+
this.configurationStatus = "unavailable";
|
|
651
|
+
this.status = "error";
|
|
652
|
+
throw error;
|
|
653
|
+
}
|
|
654
|
+
}
|
|
655
|
+
async start() {
|
|
656
|
+
if (this.status === "running") {
|
|
657
|
+
throw new Error("GeoPulse.start() called while already running \u2014 call GeoPulse.stop() first if you want to restart");
|
|
658
|
+
}
|
|
659
|
+
if (this.status !== "initialized" && this.status !== "stopped") {
|
|
660
|
+
throw new Error("GeoPulse.start called before GeoPulse.initialize succeeded");
|
|
661
|
+
}
|
|
662
|
+
if (!this.applicationEnabled) {
|
|
663
|
+
this.status = "error";
|
|
664
|
+
throw new Error("This application has been disabled in the GeoPulse admin portal");
|
|
665
|
+
}
|
|
666
|
+
const permission = await this.permissionProvider.request();
|
|
667
|
+
if (permission !== "granted") {
|
|
668
|
+
this.status = "error";
|
|
669
|
+
throw new Error(`Location permission not granted (status: ${permission})`);
|
|
670
|
+
}
|
|
671
|
+
this.locationError = null;
|
|
672
|
+
await this.locationProvider.start(
|
|
673
|
+
(location) => {
|
|
674
|
+
void this.handleLocationUpdate(location);
|
|
675
|
+
},
|
|
676
|
+
(error) => {
|
|
677
|
+
this.locationError = error.message;
|
|
678
|
+
this.logger.warn("Location provider failed to start", { error: error.message });
|
|
679
|
+
}
|
|
680
|
+
);
|
|
681
|
+
if (this.locationError) {
|
|
682
|
+
this.status = "error";
|
|
683
|
+
throw new Error(`GeoPulse failed to start location tracking: ${this.locationError}`);
|
|
684
|
+
}
|
|
685
|
+
this.unsubscribeNetwork = this.networkMonitor.subscribe((networkStatus) => {
|
|
686
|
+
if (networkStatus === "online") {
|
|
687
|
+
this.logger.log("Network available \u2014 retrying queued events");
|
|
688
|
+
void this.syncManager?.retryPending({ force: true });
|
|
689
|
+
}
|
|
690
|
+
});
|
|
691
|
+
this.syncManager?.startAutoRetry();
|
|
692
|
+
this.startExperiencePolling();
|
|
693
|
+
this.status = "running";
|
|
694
|
+
this.logger.log("GeoPulse.start()");
|
|
695
|
+
}
|
|
696
|
+
stop() {
|
|
697
|
+
this.locationProvider.stop();
|
|
698
|
+
this.syncManager?.stopAutoRetry();
|
|
699
|
+
this.stopExperiencePolling();
|
|
700
|
+
this.unsubscribeNetwork?.();
|
|
701
|
+
this.unsubscribeNetwork = null;
|
|
702
|
+
this.status = "stopped";
|
|
703
|
+
this.logger.log("GeoPulse.stop()");
|
|
704
|
+
}
|
|
705
|
+
getStatus() {
|
|
706
|
+
return this.status;
|
|
707
|
+
}
|
|
708
|
+
async getPermissionStatus() {
|
|
709
|
+
return this.permissionProvider.getStatus();
|
|
710
|
+
}
|
|
711
|
+
on(eventName, callback) {
|
|
712
|
+
if (eventName === "experience.received") {
|
|
713
|
+
this.experienceListeners.add(callback);
|
|
714
|
+
return;
|
|
715
|
+
}
|
|
716
|
+
if (!this.listeners.has(eventName)) {
|
|
717
|
+
this.listeners.set(eventName, /* @__PURE__ */ new Set());
|
|
718
|
+
}
|
|
719
|
+
this.listeners.get(eventName)?.add(callback);
|
|
720
|
+
}
|
|
721
|
+
off(eventName, callback) {
|
|
722
|
+
if (eventName === "experience.received") {
|
|
723
|
+
this.experienceListeners.delete(callback);
|
|
724
|
+
return;
|
|
725
|
+
}
|
|
726
|
+
this.listeners.get(eventName)?.delete(callback);
|
|
727
|
+
}
|
|
728
|
+
// --- Small read-only accessors beyond the core 7-method API, so a host
|
|
729
|
+
// app (see apps/playground) can build a status UI around the SDK without
|
|
730
|
+
// it needing to duplicate state the SDK already tracks internally. ---
|
|
731
|
+
getConfiguredGeofences() {
|
|
732
|
+
return this.geofences;
|
|
733
|
+
}
|
|
734
|
+
/** The server-resolved application id (from `getProjectConfig`), or null before `initialize()` completes. */
|
|
735
|
+
getApplicationId() {
|
|
736
|
+
return this.resolvedApplicationId;
|
|
737
|
+
}
|
|
738
|
+
getLastKnownLocation() {
|
|
739
|
+
return this.lastKnownLocation;
|
|
740
|
+
}
|
|
741
|
+
async getPendingEventCount() {
|
|
742
|
+
return (await this.queue.listPending()).length;
|
|
743
|
+
}
|
|
744
|
+
/** Null until `initialize()` resolves it — persisted across app restarts (see config/device-id.ts). */
|
|
745
|
+
getDeviceId() {
|
|
746
|
+
return this.deviceId;
|
|
747
|
+
}
|
|
748
|
+
/**
|
|
749
|
+
* The rich developer-diagnostics snapshot (spec section 10/11) — a
|
|
750
|
+
* superset of `getStatus()`, not a replacement for it (see core/types.ts's
|
|
751
|
+
* `DiagnosticsSnapshot` doc for why these are two separate methods).
|
|
752
|
+
*/
|
|
753
|
+
async getDiagnostics() {
|
|
754
|
+
const pending = await this.queue.listPending();
|
|
755
|
+
const permissionStatus = this.status === "idle" || this.status === "initializing" ? "unknown" : await this.permissionProvider.getStatus();
|
|
756
|
+
return {
|
|
757
|
+
initialized: this.status !== "idle" && this.status !== "initializing",
|
|
758
|
+
running: this.status === "running",
|
|
759
|
+
locationAvailable: this.lastKnownLocation !== null,
|
|
760
|
+
permissionStatus,
|
|
761
|
+
networkStatus: this.networkMonitor.getStatus(),
|
|
762
|
+
configurationStatus: this.configurationStatus,
|
|
763
|
+
configVersion: this.configVersion,
|
|
764
|
+
pendingEvents: pending.length,
|
|
765
|
+
totalRetryAttempts: pending.reduce((sum, event) => sum + event.attempts, 0),
|
|
766
|
+
locationError: this.locationError,
|
|
767
|
+
lastEventAt: this.lastEvent?.timestamp ?? null,
|
|
768
|
+
lastEventType: this.lastEvent?.type ?? null,
|
|
769
|
+
lastSyncAt: this.syncManager?.getLastSyncAt() ?? null,
|
|
770
|
+
lastUploadResult: this.syncManager?.getLastUploadResult() ?? null,
|
|
771
|
+
deviceId: this.deviceId,
|
|
772
|
+
sdkVersion: SDK_VERSION,
|
|
773
|
+
experiencesReceivedCount: this.experiencesReceivedCount
|
|
774
|
+
};
|
|
775
|
+
}
|
|
776
|
+
/**
|
|
777
|
+
* Only functional when the SDK is running the simulated location
|
|
778
|
+
* provider (the default outside of production builds — see
|
|
779
|
+
* src/index.ts) and gated by `__DEV__` so it's inert in a production
|
|
780
|
+
* bundle even if a caller somehow held a reference to it.
|
|
781
|
+
*/
|
|
782
|
+
simulateLocation(coords) {
|
|
783
|
+
const isDev2 = typeof __DEV__ === "undefined" ? true : __DEV__;
|
|
784
|
+
if (!isDev2) {
|
|
785
|
+
console.warn("GeoPulse.simulator is unavailable in production builds.");
|
|
786
|
+
return;
|
|
787
|
+
}
|
|
788
|
+
if (!(this.locationProvider instanceof SimulatedLocationProvider)) {
|
|
789
|
+
console.warn(
|
|
790
|
+
"GeoPulse.simulator.setLocation has no effect: the SDK is not running the simulated location provider."
|
|
791
|
+
);
|
|
792
|
+
return;
|
|
793
|
+
}
|
|
794
|
+
this.locationProvider.setLocation(coords);
|
|
795
|
+
}
|
|
796
|
+
/**
|
|
797
|
+
* The Phase 5B delivery mechanism: poll immediately on `start()` (so a
|
|
798
|
+
* demo doesn't wait a full interval for the first check), then on a
|
|
799
|
+
* timer. A poll failure (network blip, backend down) is logged and
|
|
800
|
+
* swallowed — it must never crash the SDK or the host app (spec section
|
|
801
|
+
* 13), and the next tick tries again on its own.
|
|
802
|
+
*/
|
|
803
|
+
startExperiencePolling() {
|
|
804
|
+
void this.pollForExperiences();
|
|
805
|
+
this.experiencePollTimer = setInterval(() => {
|
|
806
|
+
void this.pollForExperiences();
|
|
807
|
+
}, this.experiencePollIntervalMs);
|
|
808
|
+
}
|
|
809
|
+
stopExperiencePolling() {
|
|
810
|
+
if (this.experiencePollTimer) {
|
|
811
|
+
clearInterval(this.experiencePollTimer);
|
|
812
|
+
this.experiencePollTimer = null;
|
|
813
|
+
}
|
|
814
|
+
}
|
|
815
|
+
async pollForExperiences() {
|
|
816
|
+
if (!this.apiClient || !this.deviceId) {
|
|
817
|
+
return;
|
|
818
|
+
}
|
|
819
|
+
try {
|
|
820
|
+
const experiences = await this.apiClient.getPendingExperiences(this.deviceId);
|
|
821
|
+
for (const experience of experiences) {
|
|
822
|
+
if (this.seenExperienceDeliveryIds.has(experience.deliveryId)) {
|
|
823
|
+
continue;
|
|
824
|
+
}
|
|
825
|
+
this.seenExperienceDeliveryIds.add(experience.deliveryId);
|
|
826
|
+
this.experiencesReceivedCount += 1;
|
|
827
|
+
this.logger.log("Experience received", { deliveryId: experience.deliveryId, campaignId: experience.campaignId });
|
|
828
|
+
this.experienceListeners.forEach((listener) => listener(experience));
|
|
829
|
+
await this.apiClient.acknowledgeExperienceDelivery(experience.deliveryId).catch((error) => {
|
|
830
|
+
this.logger.warn("Failed to acknowledge experience delivery", {
|
|
831
|
+
deliveryId: experience.deliveryId,
|
|
832
|
+
error: error instanceof Error ? error.message : String(error)
|
|
833
|
+
});
|
|
834
|
+
});
|
|
835
|
+
}
|
|
836
|
+
} catch (error) {
|
|
837
|
+
this.logger.warn("Polling for experiences failed", { error: error instanceof Error ? error.message : String(error) });
|
|
838
|
+
}
|
|
839
|
+
}
|
|
840
|
+
applyProjectConfig(projectConfig) {
|
|
841
|
+
this.resolvedProjectId = projectConfig.projectId;
|
|
842
|
+
this.resolvedApplicationId = projectConfig.applicationId;
|
|
843
|
+
this.applicationEnabled = projectConfig.applicationEnabled;
|
|
844
|
+
this.configVersion = projectConfig.configVersion;
|
|
845
|
+
this.geofences = projectConfig.geofences;
|
|
846
|
+
}
|
|
847
|
+
async handleLocationUpdate(location) {
|
|
848
|
+
this.lastKnownLocation = location;
|
|
849
|
+
if (!this.resolvedProjectId || !this.resolvedApplicationId || !this.syncManager || !this.deviceId) {
|
|
850
|
+
return;
|
|
851
|
+
}
|
|
852
|
+
const transitions = this.stateMachine.evaluate(location, this.geofences);
|
|
853
|
+
for (const transition of transitions) {
|
|
854
|
+
this.logger.log("Geofence state changed", { geofenceId: transition.geofence.id, type: transition.type });
|
|
855
|
+
const event = createEventPayload({
|
|
856
|
+
type: transition.type,
|
|
857
|
+
projectId: this.resolvedProjectId,
|
|
858
|
+
applicationId: this.resolvedApplicationId,
|
|
859
|
+
geofence: transition.geofence,
|
|
860
|
+
location,
|
|
861
|
+
deviceId: this.deviceId
|
|
862
|
+
});
|
|
863
|
+
this.lastEvent = event;
|
|
864
|
+
this.logger.log("Event generated", { eventId: event.id, type: event.type });
|
|
865
|
+
this.listeners.get(transition.type)?.forEach((listener) => listener(event));
|
|
866
|
+
await this.syncManager.sendOrQueue(event);
|
|
867
|
+
}
|
|
868
|
+
}
|
|
869
|
+
};
|
|
870
|
+
|
|
871
|
+
// src/location/device-location-provider.ts
|
|
872
|
+
var Location = __toESM(require("expo-location"));
|
|
873
|
+
var DeviceLocationProvider = class {
|
|
874
|
+
subscription = null;
|
|
875
|
+
/**
|
|
876
|
+
* `watchPositionAsync` rejects if location services are off at the OS
|
|
877
|
+
* level (distinct from the app's own permission grant, which is checked
|
|
878
|
+
* before this ever runs) — caught here and reported via `onError` instead
|
|
879
|
+
* of left to reject this method's own promise, so a caller that doesn't
|
|
880
|
+
* wrap `GeoPulse.start()` in a try/catch still can't end up with an
|
|
881
|
+
* unhandled rejection from deep inside a native module.
|
|
882
|
+
*/
|
|
883
|
+
async start(onUpdate, onError) {
|
|
884
|
+
try {
|
|
885
|
+
this.subscription = await Location.watchPositionAsync(
|
|
886
|
+
{ accuracy: Location.Accuracy.Balanced, timeInterval: 5e3, distanceInterval: 10 },
|
|
887
|
+
(position) => {
|
|
888
|
+
onUpdate({
|
|
889
|
+
latitude: position.coords.latitude,
|
|
890
|
+
longitude: position.coords.longitude,
|
|
891
|
+
timestamp: new Date(position.timestamp).toISOString()
|
|
892
|
+
});
|
|
893
|
+
}
|
|
894
|
+
);
|
|
895
|
+
} catch (error) {
|
|
896
|
+
onError?.(error instanceof Error ? error : new Error(String(error)));
|
|
897
|
+
}
|
|
898
|
+
}
|
|
899
|
+
stop() {
|
|
900
|
+
this.subscription?.remove();
|
|
901
|
+
this.subscription = null;
|
|
902
|
+
}
|
|
903
|
+
};
|
|
904
|
+
|
|
905
|
+
// src/permissions/expo-permission-provider.ts
|
|
906
|
+
var Location2 = __toESM(require("expo-location"));
|
|
907
|
+
function mapExpoStatus(status) {
|
|
908
|
+
switch (status) {
|
|
909
|
+
case Location2.PermissionStatus.GRANTED:
|
|
910
|
+
return "granted";
|
|
911
|
+
case Location2.PermissionStatus.DENIED:
|
|
912
|
+
return "denied";
|
|
913
|
+
default:
|
|
914
|
+
return "undetermined";
|
|
915
|
+
}
|
|
916
|
+
}
|
|
917
|
+
var ExpoPermissionProvider = class {
|
|
918
|
+
async getStatus() {
|
|
919
|
+
const { status } = await Location2.getForegroundPermissionsAsync();
|
|
920
|
+
return mapExpoStatus(status);
|
|
921
|
+
}
|
|
922
|
+
async request() {
|
|
923
|
+
const { status } = await Location2.requestForegroundPermissionsAsync();
|
|
924
|
+
return mapExpoStatus(status);
|
|
925
|
+
}
|
|
926
|
+
};
|
|
927
|
+
|
|
928
|
+
// src/storage/async-storage-store.ts
|
|
929
|
+
var import_async_storage2 = __toESM(require("@react-native-async-storage/async-storage"));
|
|
930
|
+
var STORAGE_KEY = "@geopulse/pending-events";
|
|
931
|
+
var AsyncStorageEventQueueStore = class {
|
|
932
|
+
async getAll() {
|
|
933
|
+
const raw = await import_async_storage2.default.getItem(STORAGE_KEY);
|
|
934
|
+
if (!raw) return [];
|
|
935
|
+
try {
|
|
936
|
+
return JSON.parse(raw);
|
|
937
|
+
} catch {
|
|
938
|
+
return [];
|
|
939
|
+
}
|
|
940
|
+
}
|
|
941
|
+
async save(event) {
|
|
942
|
+
const all = await this.getAll();
|
|
943
|
+
const next = [...all.filter((existing) => existing.eventId !== event.eventId), event];
|
|
944
|
+
await import_async_storage2.default.setItem(STORAGE_KEY, JSON.stringify(next));
|
|
945
|
+
}
|
|
946
|
+
async remove(eventId) {
|
|
947
|
+
const all = await this.getAll();
|
|
948
|
+
await import_async_storage2.default.setItem(STORAGE_KEY, JSON.stringify(all.filter((existing) => existing.eventId !== eventId)));
|
|
949
|
+
}
|
|
950
|
+
};
|
|
951
|
+
|
|
952
|
+
// src/sync/netinfo-network-monitor.ts
|
|
953
|
+
var import_netinfo = __toESM(require("@react-native-community/netinfo"));
|
|
954
|
+
function toStatus(isConnected) {
|
|
955
|
+
if (isConnected === null) return "unknown";
|
|
956
|
+
return isConnected ? "online" : "offline";
|
|
957
|
+
}
|
|
958
|
+
var NetInfoNetworkMonitor = class {
|
|
959
|
+
status = "unknown";
|
|
960
|
+
constructor() {
|
|
961
|
+
import_netinfo.default.fetch().then((state) => {
|
|
962
|
+
this.status = toStatus(state.isConnected);
|
|
963
|
+
});
|
|
964
|
+
}
|
|
965
|
+
getStatus() {
|
|
966
|
+
return this.status;
|
|
967
|
+
}
|
|
968
|
+
subscribe(listener) {
|
|
969
|
+
return import_netinfo.default.addEventListener((state) => {
|
|
970
|
+
this.status = toStatus(state.isConnected);
|
|
971
|
+
listener(this.status);
|
|
972
|
+
});
|
|
973
|
+
}
|
|
974
|
+
};
|
|
975
|
+
|
|
976
|
+
// src/index.ts
|
|
977
|
+
var isDev = typeof __DEV__ === "undefined" ? true : __DEV__;
|
|
978
|
+
var useRealLocationInDev = process.env.EXPO_PUBLIC_GEOPULSE_USE_REAL_LOCATION === "true";
|
|
979
|
+
var useSimulatedProviders = isDev && !useRealLocationInDev;
|
|
980
|
+
function getPlatform() {
|
|
981
|
+
switch (import_react_native.Platform.OS) {
|
|
982
|
+
case "ios":
|
|
983
|
+
case "android":
|
|
984
|
+
case "web":
|
|
985
|
+
return import_react_native.Platform.OS;
|
|
986
|
+
default:
|
|
987
|
+
return "unknown";
|
|
988
|
+
}
|
|
989
|
+
}
|
|
990
|
+
var persistentKeyValueStore = new AsyncStorageKeyValueStore();
|
|
991
|
+
var sdk = new GeoPulseSdk({
|
|
992
|
+
locationProvider: useSimulatedProviders ? new SimulatedLocationProvider() : new DeviceLocationProvider(),
|
|
993
|
+
permissionProvider: useSimulatedProviders ? new SimulatedPermissionProvider() : new ExpoPermissionProvider(),
|
|
994
|
+
queue: new PendingEventQueue(new AsyncStorageEventQueueStore()),
|
|
995
|
+
deviceIdStore: persistentKeyValueStore,
|
|
996
|
+
configCacheStore: persistentKeyValueStore,
|
|
997
|
+
networkMonitor: new NetInfoNetworkMonitor(),
|
|
998
|
+
getPlatform,
|
|
999
|
+
debug: isDev
|
|
1000
|
+
});
|
|
1001
|
+
var GeoPulse = {
|
|
1002
|
+
initialize: (config) => sdk.initialize(config),
|
|
1003
|
+
start: () => sdk.start(),
|
|
1004
|
+
stop: () => sdk.stop(),
|
|
1005
|
+
getStatus: () => sdk.getStatus(),
|
|
1006
|
+
getPermissionStatus: () => sdk.getPermissionStatus(),
|
|
1007
|
+
on: sdk.on.bind(sdk),
|
|
1008
|
+
off: sdk.off.bind(sdk),
|
|
1009
|
+
// Small additions beyond the core 7-method API, for building a status UI
|
|
1010
|
+
// around the SDK (see apps/playground) — not part of the minimal public
|
|
1011
|
+
// surface the spec calls out, but plain read-only accessors for state the
|
|
1012
|
+
// SDK already tracks internally.
|
|
1013
|
+
getConfiguredGeofences: () => sdk.getConfiguredGeofences(),
|
|
1014
|
+
getApplicationId: () => sdk.getApplicationId(),
|
|
1015
|
+
getLastKnownLocation: () => sdk.getLastKnownLocation(),
|
|
1016
|
+
getPendingEventCount: () => sdk.getPendingEventCount(),
|
|
1017
|
+
getDeviceId: () => sdk.getDeviceId(),
|
|
1018
|
+
/** Rich developer-diagnostics snapshot — see core/types.ts's DiagnosticsSnapshot doc. */
|
|
1019
|
+
getDiagnostics: () => sdk.getDiagnostics(),
|
|
1020
|
+
/** Development-only. No-ops (with a console warning) in a production build — see GeoPulseSdk.simulateLocation. */
|
|
1021
|
+
simulator: {
|
|
1022
|
+
setLocation: (coords) => sdk.simulateLocation(coords)
|
|
1023
|
+
}
|
|
1024
|
+
};
|
|
1025
|
+
// Annotate the CommonJS export names for ESM import in node:
|
|
1026
|
+
0 && (module.exports = {
|
|
1027
|
+
DeviceLocationProvider,
|
|
1028
|
+
ExpoPermissionProvider,
|
|
1029
|
+
GeoPulse,
|
|
1030
|
+
GeoPulseSdk,
|
|
1031
|
+
ManualNetworkMonitor,
|
|
1032
|
+
NetInfoNetworkMonitor,
|
|
1033
|
+
SimulatedLocationProvider,
|
|
1034
|
+
SimulatedPermissionProvider
|
|
1035
|
+
});
|
|
1036
|
+
//# sourceMappingURL=index.js.map
|