@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/dist/index.js
ADDED
|
@@ -0,0 +1,1764 @@
|
|
|
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
|
+
AlertExperience: () => AlertExperience,
|
|
34
|
+
ApplicationMismatchError: () => ApplicationMismatchError,
|
|
35
|
+
BannerExperience: () => BannerExperience,
|
|
36
|
+
CardExperience: () => CardExperience,
|
|
37
|
+
DeviceLocationProvider: () => DeviceLocationProvider,
|
|
38
|
+
ExperienceView: () => ExperienceView,
|
|
39
|
+
ExpoPermissionProvider: () => ExpoPermissionProvider,
|
|
40
|
+
GeoPulse: () => GeoPulse,
|
|
41
|
+
GeoPulseSdk: () => GeoPulseSdk,
|
|
42
|
+
ManualNetworkMonitor: () => ManualNetworkMonitor,
|
|
43
|
+
MessageExperience: () => MessageExperience,
|
|
44
|
+
NetInfoNetworkMonitor: () => NetInfoNetworkMonitor,
|
|
45
|
+
SimulatedLocationProvider: () => SimulatedLocationProvider,
|
|
46
|
+
SimulatedPermissionProvider: () => SimulatedPermissionProvider,
|
|
47
|
+
isAlertExperience: () => isAlertExperience
|
|
48
|
+
});
|
|
49
|
+
module.exports = __toCommonJS(index_exports);
|
|
50
|
+
var import_react_native2 = require("react-native");
|
|
51
|
+
|
|
52
|
+
// src/config/async-storage-key-value-store.ts
|
|
53
|
+
var import_async_storage = __toESM(require("@react-native-async-storage/async-storage"));
|
|
54
|
+
var AsyncStorageKeyValueStore = class {
|
|
55
|
+
async getItem(key) {
|
|
56
|
+
return import_async_storage.default.getItem(key);
|
|
57
|
+
}
|
|
58
|
+
async setItem(key, value) {
|
|
59
|
+
await import_async_storage.default.setItem(key, value);
|
|
60
|
+
}
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
// src/config/types.ts
|
|
64
|
+
var DEFAULT_BACKEND_URL = "https://api.damncoding.com";
|
|
65
|
+
var SDK_VERSION = "0.5.1";
|
|
66
|
+
|
|
67
|
+
// src/config/key-value-store.ts
|
|
68
|
+
var InMemoryKeyValueStore = class {
|
|
69
|
+
data = /* @__PURE__ */ new Map();
|
|
70
|
+
async getItem(key) {
|
|
71
|
+
return this.data.get(key) ?? null;
|
|
72
|
+
}
|
|
73
|
+
async setItem(key, value) {
|
|
74
|
+
this.data.set(key, value);
|
|
75
|
+
}
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
// src/events/uuid.ts
|
|
79
|
+
function generateEventId() {
|
|
80
|
+
const bytes = new Uint8Array(16);
|
|
81
|
+
const cryptoApi = globalThis.crypto;
|
|
82
|
+
if (cryptoApi?.getRandomValues) {
|
|
83
|
+
cryptoApi.getRandomValues(bytes);
|
|
84
|
+
} else {
|
|
85
|
+
for (let i = 0; i < bytes.length; i++) bytes[i] = Math.floor(Math.random() * 256);
|
|
86
|
+
}
|
|
87
|
+
bytes[6] = (bytes[6] ?? 0) & 15 | 64;
|
|
88
|
+
bytes[8] = (bytes[8] ?? 0) & 63 | 128;
|
|
89
|
+
const hex = [...bytes].map((byte) => byte.toString(16).padStart(2, "0")).join("");
|
|
90
|
+
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// src/config/device-id.ts
|
|
94
|
+
var DEVICE_ID_STORAGE_KEY = "@geopulse/device-id";
|
|
95
|
+
async function getOrCreateDeviceId(store) {
|
|
96
|
+
const existing = await store.getItem(DEVICE_ID_STORAGE_KEY);
|
|
97
|
+
if (existing) {
|
|
98
|
+
return existing;
|
|
99
|
+
}
|
|
100
|
+
const deviceId = generateEventId();
|
|
101
|
+
await store.setItem(DEVICE_ID_STORAGE_KEY, deviceId);
|
|
102
|
+
return deviceId;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// src/config/config-cache.ts
|
|
106
|
+
var CONFIG_CACHE_STORAGE_KEY = "@geopulse/config-cache";
|
|
107
|
+
var ConfigCache = class {
|
|
108
|
+
constructor(store) {
|
|
109
|
+
this.store = store;
|
|
110
|
+
}
|
|
111
|
+
store;
|
|
112
|
+
async save(config) {
|
|
113
|
+
const cached = { config, fetchedAt: (/* @__PURE__ */ new Date()).toISOString() };
|
|
114
|
+
await this.store.setItem(CONFIG_CACHE_STORAGE_KEY, JSON.stringify(cached));
|
|
115
|
+
}
|
|
116
|
+
async load() {
|
|
117
|
+
const raw = await this.store.getItem(CONFIG_CACHE_STORAGE_KEY);
|
|
118
|
+
if (!raw) return null;
|
|
119
|
+
try {
|
|
120
|
+
return JSON.parse(raw);
|
|
121
|
+
} catch {
|
|
122
|
+
return null;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
// src/protocol.ts
|
|
128
|
+
var SDK_KEY_HEADER = "x-geopulse-sdk-key";
|
|
129
|
+
var APPLICATION_ID_HEADER = "x-geopulse-application-id";
|
|
130
|
+
var SDK_ENDPOINTS = {
|
|
131
|
+
GET_PROJECT_CONFIG: "getProjectConfig",
|
|
132
|
+
INGEST_LOCATION_EVENT: "ingestLocationEvent",
|
|
133
|
+
GET_PENDING_EXPERIENCES: "getPendingExperiences",
|
|
134
|
+
ACKNOWLEDGE_EXPERIENCE_DELIVERY: "acknowledgeExperienceDelivery"
|
|
135
|
+
};
|
|
136
|
+
var EARTH_RADIUS_METERS = 6371e3;
|
|
137
|
+
function toRadians(degrees) {
|
|
138
|
+
return degrees * Math.PI / 180;
|
|
139
|
+
}
|
|
140
|
+
function calculateDistanceMeters(from, to) {
|
|
141
|
+
const lat1 = toRadians(from.latitude);
|
|
142
|
+
const lat2 = toRadians(to.latitude);
|
|
143
|
+
const deltaLat = toRadians(to.latitude - from.latitude);
|
|
144
|
+
const deltaLng = toRadians(to.longitude - from.longitude);
|
|
145
|
+
const a = Math.sin(deltaLat / 2) ** 2 + Math.cos(lat1) * Math.cos(lat2) * Math.sin(deltaLng / 2) ** 2;
|
|
146
|
+
const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
|
|
147
|
+
return EARTH_RADIUS_METERS * c;
|
|
148
|
+
}
|
|
149
|
+
var ANCHOR_POINTS = [
|
|
150
|
+
"top-left",
|
|
151
|
+
"top-center",
|
|
152
|
+
"top-right",
|
|
153
|
+
"center-left",
|
|
154
|
+
"center",
|
|
155
|
+
"center-right",
|
|
156
|
+
"bottom-left",
|
|
157
|
+
"bottom-center",
|
|
158
|
+
"bottom-right"
|
|
159
|
+
];
|
|
160
|
+
function isLegacyExperienceStyle(style) {
|
|
161
|
+
return !("version" in style);
|
|
162
|
+
}
|
|
163
|
+
var HEX_COLOR_PATTERN = /^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/;
|
|
164
|
+
var OFFSET_LIMIT = 500;
|
|
165
|
+
var ANCHOR_BASE_POSITIONS = {
|
|
166
|
+
"top-left": { top: 0, left: 0, bottom: null, right: null },
|
|
167
|
+
"top-center": { top: 0, left: null, bottom: null, right: null },
|
|
168
|
+
"top-right": { top: 0, left: null, bottom: null, right: 0 },
|
|
169
|
+
"center-left": { top: null, left: 0, bottom: null, right: null },
|
|
170
|
+
center: { top: null, left: null, bottom: null, right: null },
|
|
171
|
+
"center-right": { top: null, left: null, bottom: null, right: 0 },
|
|
172
|
+
"bottom-left": { top: null, left: 0, bottom: 0, right: null },
|
|
173
|
+
"bottom-center": { top: null, left: null, bottom: 0, right: null },
|
|
174
|
+
"bottom-right": { top: null, left: null, bottom: 0, right: 0 }
|
|
175
|
+
};
|
|
176
|
+
var DEFAULT_STYLES = {
|
|
177
|
+
card: {
|
|
178
|
+
container: { backgroundColor: "#ffffff", borderColor: null, borderWidth: 0, borderRadius: 16, padding: 20, backgroundImageUrl: null, backgroundImageFit: "cover", width: "fill", height: "auto" },
|
|
179
|
+
title: { visible: true, color: "#111827", fontSize: 18, fontWeight: "bold" },
|
|
180
|
+
body: { visible: true, color: "#374151", fontSize: 14, fontWeight: "normal" },
|
|
181
|
+
button: { visible: true, backgroundColor: "#2563eb", textColor: "#ffffff", borderRadius: 8, position: { top: null, bottom: 16, left: 16, right: null } }
|
|
182
|
+
},
|
|
183
|
+
banner: {
|
|
184
|
+
container: { backgroundColor: "#111827", borderColor: null, borderWidth: 0, borderRadius: 0, padding: 12, backgroundImageUrl: null, backgroundImageFit: "cover", width: "fill", height: "auto" },
|
|
185
|
+
title: { visible: true, color: "#ffffff", fontSize: 15, fontWeight: "bold" },
|
|
186
|
+
body: { visible: true, color: "#e5e7eb", fontSize: 13, fontWeight: "normal" },
|
|
187
|
+
button: { visible: false, backgroundColor: "#2563eb", textColor: "#ffffff", borderRadius: 6, position: { top: null, bottom: null, left: null, right: 12 } }
|
|
188
|
+
},
|
|
189
|
+
alert: {
|
|
190
|
+
container: { backgroundColor: "#ffffff", borderColor: null, borderWidth: 0, borderRadius: 16, padding: 24, backgroundImageUrl: null, backgroundImageFit: "cover", width: "fill", height: "auto" },
|
|
191
|
+
title: { visible: true, color: "#111827", fontSize: 18, fontWeight: "bold" },
|
|
192
|
+
body: { visible: true, color: "#374151", fontSize: 14, fontWeight: "normal" },
|
|
193
|
+
button: { visible: true, backgroundColor: "#2563eb", textColor: "#ffffff", borderRadius: 8, position: { top: null, bottom: 16, left: null, right: 16 } }
|
|
194
|
+
},
|
|
195
|
+
message: {
|
|
196
|
+
container: { backgroundColor: "#1f2937", borderColor: null, borderWidth: 0, borderRadius: 12, padding: 16, backgroundImageUrl: null, backgroundImageFit: "cover", width: "fill", height: "auto" },
|
|
197
|
+
title: { visible: false, color: "#ffffff", fontSize: 16, fontWeight: "bold" },
|
|
198
|
+
body: { visible: true, color: "#ffffff", fontSize: 14, fontWeight: "normal" },
|
|
199
|
+
button: { visible: false, backgroundColor: "#2563eb", textColor: "#ffffff", borderRadius: 8, position: { top: null, bottom: 16, left: null, right: 16 } }
|
|
200
|
+
}
|
|
201
|
+
};
|
|
202
|
+
function clamp(value, min, max) {
|
|
203
|
+
return Math.min(max, Math.max(min, value));
|
|
204
|
+
}
|
|
205
|
+
function resolveColor(value, fallback) {
|
|
206
|
+
return typeof value === "string" && HEX_COLOR_PATTERN.test(value) ? value : fallback;
|
|
207
|
+
}
|
|
208
|
+
function resolveOptionalColor(value, fallback) {
|
|
209
|
+
return typeof value === "string" && HEX_COLOR_PATTERN.test(value) ? value : fallback;
|
|
210
|
+
}
|
|
211
|
+
function resolveNonNegativeNumber(value, fallback) {
|
|
212
|
+
return typeof value === "number" && Number.isFinite(value) && value >= 0 ? value : fallback;
|
|
213
|
+
}
|
|
214
|
+
function resolvePosition(position, fallback) {
|
|
215
|
+
if (!position || !ANCHOR_POINTS.includes(position.anchor)) return fallback;
|
|
216
|
+
const offsetX = typeof position.offsetX === "number" && Number.isFinite(position.offsetX) ? clamp(position.offsetX, -OFFSET_LIMIT, OFFSET_LIMIT) : 0;
|
|
217
|
+
const offsetY = typeof position.offsetY === "number" && Number.isFinite(position.offsetY) ? clamp(position.offsetY, -OFFSET_LIMIT, OFFSET_LIMIT) : 0;
|
|
218
|
+
const base = ANCHOR_BASE_POSITIONS[position.anchor];
|
|
219
|
+
return {
|
|
220
|
+
top: base.top === null ? null : base.top + offsetY,
|
|
221
|
+
bottom: base.bottom === null ? null : base.bottom + offsetY,
|
|
222
|
+
left: base.left === null ? null : base.left + offsetX,
|
|
223
|
+
right: base.right === null ? null : base.right + offsetX
|
|
224
|
+
};
|
|
225
|
+
}
|
|
226
|
+
function resolveWidth(mode, value, fallback) {
|
|
227
|
+
if (mode === "fixed") {
|
|
228
|
+
return typeof value === "number" && Number.isFinite(value) && value > 0 ? value : fallback;
|
|
229
|
+
}
|
|
230
|
+
if (mode === "fill") return "fill";
|
|
231
|
+
return fallback;
|
|
232
|
+
}
|
|
233
|
+
function resolveHeight(mode, value, fallback) {
|
|
234
|
+
if (mode === "fixed") {
|
|
235
|
+
return typeof value === "number" && Number.isFinite(value) && value > 0 ? value : fallback;
|
|
236
|
+
}
|
|
237
|
+
if (mode === "auto") return "auto";
|
|
238
|
+
return fallback;
|
|
239
|
+
}
|
|
240
|
+
function resolveContainer(custom, fallback) {
|
|
241
|
+
return {
|
|
242
|
+
backgroundColor: resolveColor(custom?.backgroundColor, fallback.backgroundColor),
|
|
243
|
+
borderColor: resolveOptionalColor(custom?.borderColor, fallback.borderColor),
|
|
244
|
+
borderWidth: resolveNonNegativeNumber(custom?.borderWidth, fallback.borderWidth),
|
|
245
|
+
borderRadius: resolveNonNegativeNumber(custom?.borderRadius, fallback.borderRadius),
|
|
246
|
+
padding: resolveNonNegativeNumber(custom?.padding, fallback.padding),
|
|
247
|
+
backgroundImageUrl: typeof custom?.backgroundImageUrl === "string" && custom.backgroundImageUrl.length > 0 ? custom.backgroundImageUrl : fallback.backgroundImageUrl,
|
|
248
|
+
backgroundImageFit: custom?.backgroundImageFit ?? fallback.backgroundImageFit,
|
|
249
|
+
width: resolveWidth(custom?.widthMode, custom?.width, fallback.width),
|
|
250
|
+
height: resolveHeight(custom?.heightMode, custom?.height, fallback.height)
|
|
251
|
+
};
|
|
252
|
+
}
|
|
253
|
+
function resolveText(custom, fallback) {
|
|
254
|
+
return {
|
|
255
|
+
visible: custom?.visible ?? fallback.visible,
|
|
256
|
+
color: resolveColor(custom?.color, fallback.color),
|
|
257
|
+
fontSize: resolveNonNegativeNumber(custom?.fontSize, fallback.fontSize),
|
|
258
|
+
fontWeight: custom?.fontWeight ?? fallback.fontWeight
|
|
259
|
+
};
|
|
260
|
+
}
|
|
261
|
+
function resolveButton(custom, fallback) {
|
|
262
|
+
return {
|
|
263
|
+
visible: custom?.visible ?? fallback.visible,
|
|
264
|
+
backgroundColor: resolveColor(custom?.backgroundColor, fallback.backgroundColor),
|
|
265
|
+
textColor: resolveColor(custom?.textColor, fallback.textColor),
|
|
266
|
+
borderRadius: resolveNonNegativeNumber(custom?.borderRadius, fallback.borderRadius),
|
|
267
|
+
position: resolvePosition(custom?.position, fallback.position)
|
|
268
|
+
};
|
|
269
|
+
}
|
|
270
|
+
function upgradeLegacyStyle(legacy) {
|
|
271
|
+
return {
|
|
272
|
+
version: 2,
|
|
273
|
+
container: { backgroundColor: legacy.backgroundColor },
|
|
274
|
+
title: { color: legacy.textColor },
|
|
275
|
+
body: { color: legacy.textColor },
|
|
276
|
+
button: { backgroundColor: legacy.accentColor }
|
|
277
|
+
};
|
|
278
|
+
}
|
|
279
|
+
function resolveExperienceStyle(type, custom) {
|
|
280
|
+
const fallback = DEFAULT_STYLES[type] ?? DEFAULT_STYLES.card;
|
|
281
|
+
if (!custom) return fallback;
|
|
282
|
+
const v2 = isLegacyExperienceStyle(custom) ? upgradeLegacyStyle(custom) : custom;
|
|
283
|
+
return {
|
|
284
|
+
container: resolveContainer(v2.container, fallback.container),
|
|
285
|
+
title: resolveText(v2.title, fallback.title),
|
|
286
|
+
body: resolveText(v2.body, fallback.body),
|
|
287
|
+
button: resolveButton(v2.button, fallback.button)
|
|
288
|
+
};
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
// src/firebase/api-client.ts
|
|
292
|
+
var MAX_RETRY_AFTER_MS = 15 * 6e4;
|
|
293
|
+
var ApiError = class extends Error {
|
|
294
|
+
constructor(message, status, retryAfterMs = null) {
|
|
295
|
+
super(message);
|
|
296
|
+
this.status = status;
|
|
297
|
+
this.retryAfterMs = retryAfterMs;
|
|
298
|
+
this.name = "ApiError";
|
|
299
|
+
}
|
|
300
|
+
status;
|
|
301
|
+
retryAfterMs;
|
|
302
|
+
};
|
|
303
|
+
function retryAfterMsFrom(response) {
|
|
304
|
+
const seconds = Number(response.headers?.get?.("retry-after"));
|
|
305
|
+
return Number.isFinite(seconds) && seconds > 0 ? Math.min(seconds * 1e3, MAX_RETRY_AFTER_MS) : null;
|
|
306
|
+
}
|
|
307
|
+
function failure(callName, response) {
|
|
308
|
+
return new ApiError(`${callName} failed with status ${response.status}`, response.status, response.status === 429 ? retryAfterMsFrom(response) : null);
|
|
309
|
+
}
|
|
310
|
+
var REQUEST_TIMEOUT_MS = 15e3;
|
|
311
|
+
async function fetchWithTimeout(url, init) {
|
|
312
|
+
const controller = new AbortController();
|
|
313
|
+
const timeout = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
|
|
314
|
+
try {
|
|
315
|
+
return await fetch(url, { ...init, signal: controller.signal });
|
|
316
|
+
} finally {
|
|
317
|
+
clearTimeout(timeout);
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
async function fetchOrThrow(callName, url, init) {
|
|
321
|
+
try {
|
|
322
|
+
return await fetchWithTimeout(url, init);
|
|
323
|
+
} catch (error) {
|
|
324
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
325
|
+
throw new Error(`${callName} request failed: ${message}`);
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
var ApiClient = class {
|
|
329
|
+
constructor(baseUrl, sdkKey, applicationId) {
|
|
330
|
+
this.baseUrl = baseUrl;
|
|
331
|
+
this.sdkKey = sdkKey;
|
|
332
|
+
this.applicationId = applicationId;
|
|
333
|
+
}
|
|
334
|
+
baseUrl;
|
|
335
|
+
sdkKey;
|
|
336
|
+
applicationId;
|
|
337
|
+
headers(json = false) {
|
|
338
|
+
return {
|
|
339
|
+
[SDK_KEY_HEADER]: this.sdkKey,
|
|
340
|
+
...this.applicationId ? { [APPLICATION_ID_HEADER]: this.applicationId } : {},
|
|
341
|
+
...json ? { "Content-Type": "application/json" } : {}
|
|
342
|
+
};
|
|
343
|
+
}
|
|
344
|
+
/**
|
|
345
|
+
* `identity` doubles as this device's registration/heartbeat (see
|
|
346
|
+
* functions/src/events/get-project-config.ts) — sent as query params
|
|
347
|
+
* since GET requests carry no body.
|
|
348
|
+
*/
|
|
349
|
+
async getProjectConfig(identity) {
|
|
350
|
+
const query = new URLSearchParams({
|
|
351
|
+
deviceId: identity.deviceId,
|
|
352
|
+
platform: identity.platform,
|
|
353
|
+
sdkVersion: identity.sdkVersion
|
|
354
|
+
});
|
|
355
|
+
const response = await fetchOrThrow("getProjectConfig", `${this.baseUrl}/${SDK_ENDPOINTS.GET_PROJECT_CONFIG}?${query.toString()}`, {
|
|
356
|
+
method: "GET",
|
|
357
|
+
headers: this.headers()
|
|
358
|
+
});
|
|
359
|
+
if (!response.ok) {
|
|
360
|
+
throw failure("getProjectConfig", response);
|
|
361
|
+
}
|
|
362
|
+
return await response.json();
|
|
363
|
+
}
|
|
364
|
+
async ingestEvent(payload) {
|
|
365
|
+
const response = await fetchOrThrow("ingestLocationEvent", `${this.baseUrl}/${SDK_ENDPOINTS.INGEST_LOCATION_EVENT}`, {
|
|
366
|
+
method: "POST",
|
|
367
|
+
headers: this.headers(true),
|
|
368
|
+
body: JSON.stringify(payload)
|
|
369
|
+
});
|
|
370
|
+
if (!response.ok) {
|
|
371
|
+
throw failure("ingestLocationEvent", response);
|
|
372
|
+
}
|
|
373
|
+
return await response.json();
|
|
374
|
+
}
|
|
375
|
+
/** Polled by GeoPulseSdk's experience-polling loop — see core/geo-pulse-sdk.ts. */
|
|
376
|
+
async getPendingExperiences(deviceId) {
|
|
377
|
+
const query = new URLSearchParams({ deviceId });
|
|
378
|
+
const response = await fetchOrThrow("getPendingExperiences", `${this.baseUrl}/${SDK_ENDPOINTS.GET_PENDING_EXPERIENCES}?${query.toString()}`, {
|
|
379
|
+
method: "GET",
|
|
380
|
+
headers: this.headers()
|
|
381
|
+
});
|
|
382
|
+
if (!response.ok) {
|
|
383
|
+
throw failure("getPendingExperiences", response);
|
|
384
|
+
}
|
|
385
|
+
const body = await response.json();
|
|
386
|
+
return { experiences: body.experiences ?? [], revoked: body.revoked ?? [] };
|
|
387
|
+
}
|
|
388
|
+
/** Confirms a delivery was surfaced via `experience.received`, so it stops appearing in future polls. */
|
|
389
|
+
async acknowledgeExperienceDelivery(deliveryId) {
|
|
390
|
+
const response = await fetchOrThrow("acknowledgeExperienceDelivery", `${this.baseUrl}/${SDK_ENDPOINTS.ACKNOWLEDGE_EXPERIENCE_DELIVERY}`, {
|
|
391
|
+
method: "POST",
|
|
392
|
+
headers: this.headers(true),
|
|
393
|
+
body: JSON.stringify({ deliveryId })
|
|
394
|
+
});
|
|
395
|
+
if (!response.ok) {
|
|
396
|
+
throw failure("acknowledgeExperienceDelivery", response);
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
};
|
|
400
|
+
|
|
401
|
+
// src/events/factory.ts
|
|
402
|
+
function createEventPayload(params) {
|
|
403
|
+
return {
|
|
404
|
+
id: generateEventId(),
|
|
405
|
+
type: params.type,
|
|
406
|
+
projectId: params.projectId,
|
|
407
|
+
applicationId: params.applicationId,
|
|
408
|
+
geofenceId: params.geofence.id,
|
|
409
|
+
locationId: params.geofence.locationId,
|
|
410
|
+
deviceId: params.deviceId,
|
|
411
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString(),
|
|
412
|
+
latitude: params.location.latitude,
|
|
413
|
+
longitude: params.location.longitude,
|
|
414
|
+
metadata: params.metadata ?? {},
|
|
415
|
+
sdkVersion: SDK_VERSION,
|
|
416
|
+
// First-try attempt count; SyncManager overwrites this with the real
|
|
417
|
+
// count if the event ends up going through the retry queue (see
|
|
418
|
+
// sync/sync-manager.ts#retryPending) — that's what makes the
|
|
419
|
+
// "Attempts" column in the admin's Event Explorer meaningful.
|
|
420
|
+
attempts: 1
|
|
421
|
+
};
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
// src/geofencing/state-machine.ts
|
|
425
|
+
var DEFAULT_EXIT_BUFFER_METERS = 20;
|
|
426
|
+
var DEFAULT_MAX_ACCURACY_METERS = 100;
|
|
427
|
+
var DEFAULT_TRANSITION_COOLDOWN_MS = 2 * 6e4;
|
|
428
|
+
var GeofenceStateMachine = class {
|
|
429
|
+
membershipByGeofenceId = /* @__PURE__ */ new Map();
|
|
430
|
+
lastTransitionAtByGeofenceId = /* @__PURE__ */ new Map();
|
|
431
|
+
exitBufferMeters;
|
|
432
|
+
maxAccuracyMeters;
|
|
433
|
+
cooldownMs;
|
|
434
|
+
now;
|
|
435
|
+
constructor(options = {}) {
|
|
436
|
+
this.exitBufferMeters = options.exitBufferMeters ?? DEFAULT_EXIT_BUFFER_METERS;
|
|
437
|
+
this.maxAccuracyMeters = options.maxAccuracyMeters ?? DEFAULT_MAX_ACCURACY_METERS;
|
|
438
|
+
this.cooldownMs = options.cooldownMs ?? DEFAULT_TRANSITION_COOLDOWN_MS;
|
|
439
|
+
this.now = options.now ?? Date.now;
|
|
440
|
+
}
|
|
441
|
+
evaluate(location, geofences) {
|
|
442
|
+
if (location.accuracy !== void 0 && location.accuracy > this.maxAccuracyMeters) {
|
|
443
|
+
return [];
|
|
444
|
+
}
|
|
445
|
+
const transitions = [];
|
|
446
|
+
const activeIds = /* @__PURE__ */ new Set();
|
|
447
|
+
const exitBuffer = Math.max(this.exitBufferMeters, location.accuracy ?? 0);
|
|
448
|
+
const now = this.now();
|
|
449
|
+
for (const geofence of geofences) {
|
|
450
|
+
activeIds.add(geofence.id);
|
|
451
|
+
const distance = calculateDistanceMeters(location, geofence.center);
|
|
452
|
+
const previousMembership = this.membershipByGeofenceId.get(geofence.id) ?? "OUTSIDE";
|
|
453
|
+
const nextMembership = previousMembership === "INSIDE" ? distance > geofence.radiusMeters + exitBuffer ? "OUTSIDE" : "INSIDE" : distance <= geofence.radiusMeters ? "INSIDE" : "OUTSIDE";
|
|
454
|
+
if (previousMembership === nextMembership) {
|
|
455
|
+
this.membershipByGeofenceId.set(geofence.id, nextMembership);
|
|
456
|
+
continue;
|
|
457
|
+
}
|
|
458
|
+
const lastTransitionAt = this.lastTransitionAtByGeofenceId.get(geofence.id);
|
|
459
|
+
if (lastTransitionAt !== void 0 && now - lastTransitionAt < this.cooldownMs) {
|
|
460
|
+
this.membershipByGeofenceId.set(geofence.id, previousMembership);
|
|
461
|
+
continue;
|
|
462
|
+
}
|
|
463
|
+
this.membershipByGeofenceId.set(geofence.id, nextMembership);
|
|
464
|
+
this.lastTransitionAtByGeofenceId.set(geofence.id, now);
|
|
465
|
+
transitions.push({ geofence, type: nextMembership === "INSIDE" ? "geofence.enter" : "geofence.exit" });
|
|
466
|
+
}
|
|
467
|
+
for (const id of [...this.membershipByGeofenceId.keys()]) {
|
|
468
|
+
if (!activeIds.has(id)) {
|
|
469
|
+
this.membershipByGeofenceId.delete(id);
|
|
470
|
+
this.lastTransitionAtByGeofenceId.delete(id);
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
return transitions;
|
|
474
|
+
}
|
|
475
|
+
getMembership(geofenceId) {
|
|
476
|
+
return this.membershipByGeofenceId.get(geofenceId) ?? "OUTSIDE";
|
|
477
|
+
}
|
|
478
|
+
isInsideAny() {
|
|
479
|
+
for (const membership of this.membershipByGeofenceId.values()) {
|
|
480
|
+
if (membership === "INSIDE") return true;
|
|
481
|
+
}
|
|
482
|
+
return false;
|
|
483
|
+
}
|
|
484
|
+
exportState() {
|
|
485
|
+
return {
|
|
486
|
+
membership: Object.fromEntries(this.membershipByGeofenceId),
|
|
487
|
+
lastTransitionAt: Object.fromEntries(this.lastTransitionAtByGeofenceId)
|
|
488
|
+
};
|
|
489
|
+
}
|
|
490
|
+
/** Only for a machine that has not evaluated anything yet. */
|
|
491
|
+
restoreState(state) {
|
|
492
|
+
this.membershipByGeofenceId.clear();
|
|
493
|
+
this.lastTransitionAtByGeofenceId.clear();
|
|
494
|
+
for (const [id, membership] of Object.entries(state.membership)) {
|
|
495
|
+
if (membership === "INSIDE" || membership === "OUTSIDE") this.membershipByGeofenceId.set(id, membership);
|
|
496
|
+
}
|
|
497
|
+
for (const [id, at] of Object.entries(state.lastTransitionAt)) {
|
|
498
|
+
if (typeof at === "number" && Number.isFinite(at)) this.lastTransitionAtByGeofenceId.set(id, at);
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
reset() {
|
|
502
|
+
this.membershipByGeofenceId.clear();
|
|
503
|
+
this.lastTransitionAtByGeofenceId.clear();
|
|
504
|
+
}
|
|
505
|
+
};
|
|
506
|
+
|
|
507
|
+
// src/geofencing/state-store.ts
|
|
508
|
+
var STORAGE_KEY = "@geopulse/geofence-state";
|
|
509
|
+
var GEOFENCE_STATE_MAX_AGE_MS = 12 * 60 * 60 * 1e3;
|
|
510
|
+
var GeofenceStateStore = class {
|
|
511
|
+
constructor(store, now = Date.now) {
|
|
512
|
+
this.store = store;
|
|
513
|
+
this.now = now;
|
|
514
|
+
}
|
|
515
|
+
store;
|
|
516
|
+
now;
|
|
517
|
+
async load() {
|
|
518
|
+
try {
|
|
519
|
+
const raw = await this.store.getItem(STORAGE_KEY);
|
|
520
|
+
if (!raw) return null;
|
|
521
|
+
const saved = JSON.parse(raw);
|
|
522
|
+
if (typeof saved.savedAt !== "number" || this.now() - saved.savedAt > GEOFENCE_STATE_MAX_AGE_MS) return null;
|
|
523
|
+
if (typeof saved.membership !== "object" || saved.membership === null) return null;
|
|
524
|
+
return { membership: saved.membership, lastTransitionAt: saved.lastTransitionAt ?? {} };
|
|
525
|
+
} catch {
|
|
526
|
+
return null;
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
async save(state) {
|
|
530
|
+
try {
|
|
531
|
+
await this.store.setItem(STORAGE_KEY, JSON.stringify({ ...state, savedAt: this.now() }));
|
|
532
|
+
} catch {
|
|
533
|
+
}
|
|
534
|
+
}
|
|
535
|
+
};
|
|
536
|
+
|
|
537
|
+
// src/location/simulated-location-provider.ts
|
|
538
|
+
var SimulatedLocationProvider = class {
|
|
539
|
+
onUpdate = null;
|
|
540
|
+
currentLocation = null;
|
|
541
|
+
start(onUpdate) {
|
|
542
|
+
this.onUpdate = onUpdate;
|
|
543
|
+
if (this.currentLocation) {
|
|
544
|
+
onUpdate(this.currentLocation);
|
|
545
|
+
}
|
|
546
|
+
}
|
|
547
|
+
stop() {
|
|
548
|
+
this.onUpdate = null;
|
|
549
|
+
}
|
|
550
|
+
setLocation(coords) {
|
|
551
|
+
const location = { ...coords, timestamp: (/* @__PURE__ */ new Date()).toISOString() };
|
|
552
|
+
this.currentLocation = location;
|
|
553
|
+
this.onUpdate?.(location);
|
|
554
|
+
}
|
|
555
|
+
getCurrentLocation() {
|
|
556
|
+
return this.currentLocation;
|
|
557
|
+
}
|
|
558
|
+
};
|
|
559
|
+
|
|
560
|
+
// src/permissions/simulated-permission-provider.ts
|
|
561
|
+
var SimulatedPermissionProvider = class {
|
|
562
|
+
async getStatus() {
|
|
563
|
+
return "granted";
|
|
564
|
+
}
|
|
565
|
+
async request() {
|
|
566
|
+
return "granted";
|
|
567
|
+
}
|
|
568
|
+
};
|
|
569
|
+
|
|
570
|
+
// src/sync/retry-policy.ts
|
|
571
|
+
var RETRY_POLICY = {
|
|
572
|
+
/** 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). */
|
|
573
|
+
MAX_RETRIES: 6,
|
|
574
|
+
BASE_RETRY_DELAY_MS: 5e3,
|
|
575
|
+
MAX_RETRY_DELAY_MS: 5 * 6e4
|
|
576
|
+
};
|
|
577
|
+
function computeRetryDelayMs(attempts) {
|
|
578
|
+
const cappedAttempts = Math.min(attempts, RETRY_POLICY.MAX_RETRIES);
|
|
579
|
+
const exponential = RETRY_POLICY.BASE_RETRY_DELAY_MS * 2 ** Math.max(0, cappedAttempts - 1);
|
|
580
|
+
const delay = Math.min(exponential, RETRY_POLICY.MAX_RETRY_DELAY_MS);
|
|
581
|
+
const jitter = delay * 0.2 * (Math.random() * 2 - 1);
|
|
582
|
+
return Math.round(delay + jitter);
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
// src/storage/memory-store.ts
|
|
586
|
+
var InMemoryEventQueueStore = class {
|
|
587
|
+
eventsById = /* @__PURE__ */ new Map();
|
|
588
|
+
async getAll() {
|
|
589
|
+
return [...this.eventsById.values()];
|
|
590
|
+
}
|
|
591
|
+
async save(event) {
|
|
592
|
+
this.eventsById.set(event.eventId, event);
|
|
593
|
+
}
|
|
594
|
+
async remove(eventId) {
|
|
595
|
+
this.eventsById.delete(eventId);
|
|
596
|
+
}
|
|
597
|
+
};
|
|
598
|
+
|
|
599
|
+
// src/storage/queue.ts
|
|
600
|
+
var MAX_QUEUE_SIZE = 100;
|
|
601
|
+
var MAX_QUEUED_EVENT_AGE_MS = 24 * 60 * 60 * 1e3;
|
|
602
|
+
var PendingEventQueue = class {
|
|
603
|
+
constructor(store = new InMemoryEventQueueStore()) {
|
|
604
|
+
this.store = store;
|
|
605
|
+
}
|
|
606
|
+
store;
|
|
607
|
+
chain = Promise.resolve();
|
|
608
|
+
serialized(operation) {
|
|
609
|
+
const result = this.chain.then(operation, operation);
|
|
610
|
+
this.chain = result.catch(() => void 0);
|
|
611
|
+
return result;
|
|
612
|
+
}
|
|
613
|
+
enqueue(payload) {
|
|
614
|
+
return this.serialized(async () => {
|
|
615
|
+
await this.store.save({
|
|
616
|
+
eventId: payload.id,
|
|
617
|
+
type: payload.type,
|
|
618
|
+
applicationId: payload.applicationId,
|
|
619
|
+
deviceId: payload.deviceId,
|
|
620
|
+
locationId: payload.locationId,
|
|
621
|
+
geofenceId: payload.geofenceId,
|
|
622
|
+
timestamp: payload.timestamp,
|
|
623
|
+
payload,
|
|
624
|
+
attempts: 0,
|
|
625
|
+
status: "pending",
|
|
626
|
+
createdAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
627
|
+
lastAttemptAt: null,
|
|
628
|
+
nextAttemptAt: null
|
|
629
|
+
});
|
|
630
|
+
await this.trim();
|
|
631
|
+
});
|
|
632
|
+
}
|
|
633
|
+
/** Every queued event, regardless of retry timing — used for diagnostics/pending counts. */
|
|
634
|
+
listPending() {
|
|
635
|
+
return this.serialized(() => this.store.getAll());
|
|
636
|
+
}
|
|
637
|
+
/** 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. */
|
|
638
|
+
listReadyForRetry(now = /* @__PURE__ */ new Date()) {
|
|
639
|
+
return this.serialized(async () => {
|
|
640
|
+
let all = await this.store.getAll();
|
|
641
|
+
for (const event of all.filter((candidate) => isTooOld(candidate, now.getTime()))) {
|
|
642
|
+
await this.store.remove(event.eventId);
|
|
643
|
+
}
|
|
644
|
+
all = all.filter((event) => !isTooOld(event, now.getTime()));
|
|
645
|
+
return all.filter((event) => event.status !== "sending" && (!event.nextAttemptAt || new Date(event.nextAttemptAt) <= now));
|
|
646
|
+
});
|
|
647
|
+
}
|
|
648
|
+
/** Marks an event as in-flight so a concurrent retry pass won't also pick it up. */
|
|
649
|
+
markSending(eventId) {
|
|
650
|
+
return this.serialized(async () => {
|
|
651
|
+
const existing = await this.get(eventId);
|
|
652
|
+
if (!existing) return;
|
|
653
|
+
await this.store.save({ ...existing, status: "sending" });
|
|
654
|
+
});
|
|
655
|
+
}
|
|
656
|
+
/** `minDelayMs` lets a 429's Retry-After push the next attempt later than the normal backoff. */
|
|
657
|
+
markAttemptFailed(eventId, minDelayMs = 0) {
|
|
658
|
+
return this.serialized(async () => {
|
|
659
|
+
const existing = await this.get(eventId);
|
|
660
|
+
if (!existing) return;
|
|
661
|
+
const attempts = existing.attempts + 1;
|
|
662
|
+
const now = /* @__PURE__ */ new Date();
|
|
663
|
+
await this.store.save({
|
|
664
|
+
...existing,
|
|
665
|
+
status: "failed",
|
|
666
|
+
attempts,
|
|
667
|
+
lastAttemptAt: now.toISOString(),
|
|
668
|
+
nextAttemptAt: new Date(now.getTime() + Math.max(computeRetryDelayMs(attempts), minDelayMs)).toISOString()
|
|
669
|
+
});
|
|
670
|
+
});
|
|
671
|
+
}
|
|
672
|
+
markSent(eventId) {
|
|
673
|
+
return this.serialized(() => this.store.remove(eventId));
|
|
674
|
+
}
|
|
675
|
+
async get(eventId) {
|
|
676
|
+
return (await this.store.getAll()).find((event) => event.eventId === eventId);
|
|
677
|
+
}
|
|
678
|
+
/** Keeps the queue within MAX_QUEUE_SIZE by dropping the oldest events that are not being sent right now. */
|
|
679
|
+
async trim() {
|
|
680
|
+
const all = await this.store.getAll();
|
|
681
|
+
if (all.length <= MAX_QUEUE_SIZE) return;
|
|
682
|
+
const droppable = all.filter((event) => event.status !== "sending").sort((a, b) => Date.parse(a.timestamp) - Date.parse(b.timestamp));
|
|
683
|
+
for (const event of droppable.slice(0, all.length - MAX_QUEUE_SIZE)) {
|
|
684
|
+
await this.store.remove(event.eventId);
|
|
685
|
+
}
|
|
686
|
+
}
|
|
687
|
+
};
|
|
688
|
+
function isTooOld(event, nowMs) {
|
|
689
|
+
const eventTime = Date.parse(event.timestamp);
|
|
690
|
+
return !Number.isNaN(eventTime) && nowMs - eventTime > MAX_QUEUED_EVENT_AGE_MS;
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
// src/sync/network-monitor.ts
|
|
694
|
+
var ManualNetworkMonitor = class {
|
|
695
|
+
status = "online";
|
|
696
|
+
listeners = /* @__PURE__ */ new Set();
|
|
697
|
+
getStatus() {
|
|
698
|
+
return this.status;
|
|
699
|
+
}
|
|
700
|
+
setStatus(status) {
|
|
701
|
+
if (status === this.status) return;
|
|
702
|
+
this.status = status;
|
|
703
|
+
this.listeners.forEach((listener) => listener(status));
|
|
704
|
+
}
|
|
705
|
+
subscribe(listener) {
|
|
706
|
+
this.listeners.add(listener);
|
|
707
|
+
return () => this.listeners.delete(listener);
|
|
708
|
+
}
|
|
709
|
+
};
|
|
710
|
+
|
|
711
|
+
// src/sync/sync-manager.ts
|
|
712
|
+
var DEFAULT_RETRY_INTERVAL_MS = 3e4;
|
|
713
|
+
var MAX_UPLOADS_PER_MINUTE = 3;
|
|
714
|
+
var FORCED_RETRY_MIN_GAP_MS = 1e4;
|
|
715
|
+
var FORBIDDEN_PAUSE_MS = MAX_RETRY_AFTER_MS;
|
|
716
|
+
var DEFAULT_RATE_LIMIT_PAUSE_MS = 6e4;
|
|
717
|
+
var SyncManager = class {
|
|
718
|
+
constructor(apiClient, queue, retryIntervalMs = DEFAULT_RETRY_INTERVAL_MS, logger, onDelivered) {
|
|
719
|
+
this.apiClient = apiClient;
|
|
720
|
+
this.queue = queue;
|
|
721
|
+
this.retryIntervalMs = retryIntervalMs;
|
|
722
|
+
this.logger = logger;
|
|
723
|
+
this.onDelivered = onDelivered;
|
|
724
|
+
}
|
|
725
|
+
apiClient;
|
|
726
|
+
queue;
|
|
727
|
+
retryIntervalMs;
|
|
728
|
+
logger;
|
|
729
|
+
onDelivered;
|
|
730
|
+
retryTimer = null;
|
|
731
|
+
lastSyncAt = null;
|
|
732
|
+
lastUploadResult = null;
|
|
733
|
+
uploadTimes = [];
|
|
734
|
+
pausedUntil = 0;
|
|
735
|
+
lastForcedRetryAt = 0;
|
|
736
|
+
/**
|
|
737
|
+
* Write-ahead: the event is durably queued *before* any network attempt is
|
|
738
|
+
* made, so an app kill mid-request can never lose it (spec: "true
|
|
739
|
+
* write-ahead event persistence" — a prior version of this method tried
|
|
740
|
+
* the network first and only queued on failure, which meant an event in
|
|
741
|
+
* flight when the process died was gone for good). `attemptDelivery` then
|
|
742
|
+
* runs the exact same success/failure bookkeeping a queued retry uses, so
|
|
743
|
+
* there is only one code path for "what happens when an upload settles."
|
|
744
|
+
*/
|
|
745
|
+
async sendOrQueue(payload) {
|
|
746
|
+
await this.queue.enqueue(payload);
|
|
747
|
+
if (!this.canUploadNow()) {
|
|
748
|
+
this.logger?.log("Upload deferred (rate cap or server asked to wait)", { eventId: payload.id });
|
|
749
|
+
return;
|
|
750
|
+
}
|
|
751
|
+
await this.attemptDelivery(payload.id, payload);
|
|
752
|
+
}
|
|
753
|
+
canUploadNow() {
|
|
754
|
+
const now = Date.now();
|
|
755
|
+
if (now < this.pausedUntil) return false;
|
|
756
|
+
this.uploadTimes = this.uploadTimes.filter((at) => now - at < 6e4);
|
|
757
|
+
return this.uploadTimes.length < MAX_UPLOADS_PER_MINUTE;
|
|
758
|
+
}
|
|
759
|
+
/**
|
|
760
|
+
* Attempts queued events. By default only ones whose backoff window has
|
|
761
|
+
* elapsed (the timer-driven path, `startAutoRetry` — the whole point of
|
|
762
|
+
* backoff is to not hammer the backend on a fixed sweep). Pass
|
|
763
|
+
* `{ force: true }` to ignore that window and attempt every non-in-flight
|
|
764
|
+
* queued event regardless — connectivity just being confirmed restored
|
|
765
|
+
* (see `GeoPulseSdk.start`'s network-monitor subscription) is a stronger
|
|
766
|
+
* signal than "some fixed delay has passed," and a device that was
|
|
767
|
+
* offline for a while shouldn't have to wait out a stale backoff timer
|
|
768
|
+
* once it's actually back online.
|
|
769
|
+
*/
|
|
770
|
+
async retryPending(options = {}) {
|
|
771
|
+
let force = options.force === true;
|
|
772
|
+
if (force) {
|
|
773
|
+
const now = Date.now();
|
|
774
|
+
if (now - this.lastForcedRetryAt < FORCED_RETRY_MIN_GAP_MS) force = false;
|
|
775
|
+
else this.lastForcedRetryAt = now;
|
|
776
|
+
}
|
|
777
|
+
const ready = force ? (await this.queue.listPending()).filter((event) => event.status !== "sending") : await this.queue.listReadyForRetry();
|
|
778
|
+
const result = { attempted: 0, delivered: 0, failed: 0 };
|
|
779
|
+
for (const event of ready) {
|
|
780
|
+
if (!this.canUploadNow()) break;
|
|
781
|
+
result.attempted++;
|
|
782
|
+
const attemptNumber = event.attempts + 1;
|
|
783
|
+
const delivered = await this.attemptDelivery(event.eventId, { ...event.payload, attempts: attemptNumber });
|
|
784
|
+
if (delivered) {
|
|
785
|
+
result.delivered++;
|
|
786
|
+
} else {
|
|
787
|
+
result.failed++;
|
|
788
|
+
}
|
|
789
|
+
}
|
|
790
|
+
return result;
|
|
791
|
+
}
|
|
792
|
+
/**
|
|
793
|
+
* The single place that actually talks to the backend: marks the queued
|
|
794
|
+
* record "sending" (so a concurrent retry pass skips it), attempts the
|
|
795
|
+
* upload, and resolves it to either removed-from-queue (success — a
|
|
796
|
+
* `deduplicated: true` response still counts, since it means the backend
|
|
797
|
+
* already has this event id, which is exactly the outcome a retry is
|
|
798
|
+
* supposed to produce) or a scheduled retry (failure). Returns whether it
|
|
799
|
+
* was delivered, purely so `retryPending` can tally its `SyncResult`.
|
|
800
|
+
*/
|
|
801
|
+
async attemptDelivery(eventId, payload) {
|
|
802
|
+
this.uploadTimes.push(Date.now());
|
|
803
|
+
await this.queue.markSending(eventId);
|
|
804
|
+
this.logger?.log("Event upload started", { eventId, attempt: payload.attempts });
|
|
805
|
+
let result;
|
|
806
|
+
try {
|
|
807
|
+
result = await this.apiClient.ingestEvent(payload);
|
|
808
|
+
await this.queue.markSent(eventId);
|
|
809
|
+
this.lastSyncAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
810
|
+
this.lastUploadResult = "success";
|
|
811
|
+
this.logger?.log("Event upload succeeded", { eventId });
|
|
812
|
+
} catch (error) {
|
|
813
|
+
this.lastUploadResult = "failure";
|
|
814
|
+
if (error instanceof ApiError && error.status === 429) {
|
|
815
|
+
const waitMs = error.retryAfterMs ?? DEFAULT_RATE_LIMIT_PAUSE_MS;
|
|
816
|
+
this.pausedUntil = Date.now() + waitMs;
|
|
817
|
+
await this.queue.markAttemptFailed(eventId, waitMs);
|
|
818
|
+
this.logger?.warn("Server rate limit \u2014 pausing uploads", { eventId, waitMs });
|
|
819
|
+
} else if (error instanceof ApiError && isPermanentRejection(error.status)) {
|
|
820
|
+
await this.queue.markSent(eventId);
|
|
821
|
+
if (error.status === 403) this.pausedUntil = Date.now() + FORBIDDEN_PAUSE_MS;
|
|
822
|
+
this.logger?.warn("Event rejected permanently \u2014 dropped", { eventId, status: error.status });
|
|
823
|
+
} else {
|
|
824
|
+
await this.queue.markAttemptFailed(eventId);
|
|
825
|
+
this.logger?.warn("Event upload failed \u2014 retry scheduled", { eventId, error: String(error) });
|
|
826
|
+
}
|
|
827
|
+
return false;
|
|
828
|
+
}
|
|
829
|
+
try {
|
|
830
|
+
this.onDelivered?.(result);
|
|
831
|
+
} catch (error) {
|
|
832
|
+
this.logger?.warn("onDelivered handler threw", { eventId, error: String(error) });
|
|
833
|
+
}
|
|
834
|
+
return true;
|
|
835
|
+
}
|
|
836
|
+
startAutoRetry() {
|
|
837
|
+
if (this.retryTimer) return;
|
|
838
|
+
this.retryTimer = setInterval(() => {
|
|
839
|
+
void this.retryPending();
|
|
840
|
+
}, this.retryIntervalMs);
|
|
841
|
+
}
|
|
842
|
+
stopAutoRetry() {
|
|
843
|
+
if (this.retryTimer) {
|
|
844
|
+
clearInterval(this.retryTimer);
|
|
845
|
+
this.retryTimer = null;
|
|
846
|
+
}
|
|
847
|
+
}
|
|
848
|
+
getLastSyncAt() {
|
|
849
|
+
return this.lastSyncAt;
|
|
850
|
+
}
|
|
851
|
+
getLastUploadResult() {
|
|
852
|
+
return this.lastUploadResult;
|
|
853
|
+
}
|
|
854
|
+
};
|
|
855
|
+
function isPermanentRejection(status) {
|
|
856
|
+
return status >= 400 && status < 500 && status !== 408 && status !== 429;
|
|
857
|
+
}
|
|
858
|
+
|
|
859
|
+
// src/core/logger.ts
|
|
860
|
+
var Logger = class {
|
|
861
|
+
constructor(enabled) {
|
|
862
|
+
this.enabled = enabled;
|
|
863
|
+
}
|
|
864
|
+
enabled;
|
|
865
|
+
setEnabled(enabled) {
|
|
866
|
+
this.enabled = enabled;
|
|
867
|
+
}
|
|
868
|
+
log(message, details) {
|
|
869
|
+
if (!this.enabled) return;
|
|
870
|
+
if (details) {
|
|
871
|
+
console.log(`[GeoPulse] ${message}`, details);
|
|
872
|
+
} else {
|
|
873
|
+
console.log(`[GeoPulse] ${message}`);
|
|
874
|
+
}
|
|
875
|
+
}
|
|
876
|
+
warn(message, details) {
|
|
877
|
+
if (!this.enabled) return;
|
|
878
|
+
if (details) {
|
|
879
|
+
console.warn(`[GeoPulse] ${message}`, details);
|
|
880
|
+
} else {
|
|
881
|
+
console.warn(`[GeoPulse] ${message}`);
|
|
882
|
+
}
|
|
883
|
+
}
|
|
884
|
+
};
|
|
885
|
+
|
|
886
|
+
// src/core/geo-pulse-sdk.ts
|
|
887
|
+
var ApplicationMismatchError = class extends Error {
|
|
888
|
+
constructor() {
|
|
889
|
+
super("GeoPulse: the SDK key belongs to a different application than config.applicationId \u2014 check both values");
|
|
890
|
+
this.name = "ApplicationMismatchError";
|
|
891
|
+
}
|
|
892
|
+
};
|
|
893
|
+
var DEFAULT_EXPERIENCE_POLL_INTERVAL_MS = 15 * 6e4;
|
|
894
|
+
var CONFIG_REFRESH_INTERVAL_MS = 60 * 6e4;
|
|
895
|
+
var MIN_TIMER_INTERVAL_MS = 1e3;
|
|
896
|
+
function clampIntervalMs(value) {
|
|
897
|
+
if (value === void 0) {
|
|
898
|
+
return void 0;
|
|
899
|
+
}
|
|
900
|
+
if (!Number.isFinite(value) || value < MIN_TIMER_INTERVAL_MS) {
|
|
901
|
+
return MIN_TIMER_INTERVAL_MS;
|
|
902
|
+
}
|
|
903
|
+
return value;
|
|
904
|
+
}
|
|
905
|
+
var GeoPulseSdk = class {
|
|
906
|
+
status = "idle";
|
|
907
|
+
config = null;
|
|
908
|
+
apiClient = null;
|
|
909
|
+
syncManager = null;
|
|
910
|
+
resolvedProjectId = null;
|
|
911
|
+
resolvedApplicationId = null;
|
|
912
|
+
applicationEnabled = true;
|
|
913
|
+
configVersion = null;
|
|
914
|
+
configurationStatus = "unsynced";
|
|
915
|
+
geofences = [];
|
|
916
|
+
lastKnownLocation = null;
|
|
917
|
+
lastEvent = null;
|
|
918
|
+
deviceId = null;
|
|
919
|
+
locationError = null;
|
|
920
|
+
unsubscribeNetwork = null;
|
|
921
|
+
stateMachine;
|
|
922
|
+
geofenceStateStore;
|
|
923
|
+
geofenceStateRestored = false;
|
|
924
|
+
realtime = null;
|
|
925
|
+
createRealtime;
|
|
926
|
+
lastConfigFetchAt = 0;
|
|
927
|
+
configRefreshInFlight = false;
|
|
928
|
+
listeners = /* @__PURE__ */ new Map();
|
|
929
|
+
experienceListeners = /* @__PURE__ */ new Set();
|
|
930
|
+
/** 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. */
|
|
931
|
+
seenExperienceRevisions = /* @__PURE__ */ new Map();
|
|
932
|
+
experienceRemovedListeners = /* @__PURE__ */ new Set();
|
|
933
|
+
removedDeliveryIds = /* @__PURE__ */ new Set();
|
|
934
|
+
experiencePollTimer = null;
|
|
935
|
+
experiencePollInFlight = false;
|
|
936
|
+
/** Set while a poll that was refused with "too many requests" waits to be tried again. */
|
|
937
|
+
pollRetryTimer = null;
|
|
938
|
+
/** Set when a poll was asked for while one was already running; that one may have queried before the new delivery existed. */
|
|
939
|
+
experiencePollRequested = false;
|
|
940
|
+
experiencesReceivedCount = 0;
|
|
941
|
+
logger;
|
|
942
|
+
locationProvider;
|
|
943
|
+
permissionProvider;
|
|
944
|
+
queue;
|
|
945
|
+
buildApiClient;
|
|
946
|
+
deviceIdStore;
|
|
947
|
+
configCache;
|
|
948
|
+
networkMonitor;
|
|
949
|
+
getPlatform;
|
|
950
|
+
experiencePollIntervalMs = DEFAULT_EXPERIENCE_POLL_INTERVAL_MS;
|
|
951
|
+
constructor(dependencies = {}) {
|
|
952
|
+
this.locationProvider = dependencies.locationProvider ?? new SimulatedLocationProvider();
|
|
953
|
+
this.permissionProvider = dependencies.permissionProvider ?? new SimulatedPermissionProvider();
|
|
954
|
+
this.queue = dependencies.queue ?? new PendingEventQueue();
|
|
955
|
+
this.buildApiClient = dependencies.createApiClient ?? ((config) => new ApiClient(config.functionsBaseUrl || DEFAULT_BACKEND_URL, config.apiKey, config.applicationId));
|
|
956
|
+
this.deviceIdStore = dependencies.deviceIdStore ?? new InMemoryKeyValueStore();
|
|
957
|
+
this.configCache = new ConfigCache(dependencies.configCacheStore ?? new InMemoryKeyValueStore());
|
|
958
|
+
this.networkMonitor = dependencies.networkMonitor ?? new ManualNetworkMonitor();
|
|
959
|
+
this.getPlatform = dependencies.getPlatform ?? (() => "unknown");
|
|
960
|
+
this.logger = new Logger(dependencies.debug ?? false);
|
|
961
|
+
this.stateMachine = new GeofenceStateMachine(dependencies.stateMachineOptions);
|
|
962
|
+
this.geofenceStateStore = new GeofenceStateStore(dependencies.stateStore ?? new InMemoryKeyValueStore());
|
|
963
|
+
this.createRealtime = dependencies.createRealtimeClient;
|
|
964
|
+
}
|
|
965
|
+
/**
|
|
966
|
+
* Resolves this device's persistent id, then fetches (or falls back to
|
|
967
|
+
* cached) project configuration. Must succeed — fresh or cached — before
|
|
968
|
+
* `start()`; if neither is available (first-ever run with no network),
|
|
969
|
+
* this rethrows and `getStatus()` reports "error".
|
|
970
|
+
*/
|
|
971
|
+
async initialize(config) {
|
|
972
|
+
if (this.status === "running") {
|
|
973
|
+
throw new Error("GeoPulse.initialize() cannot be called while already running \u2014 call GeoPulse.stop() first");
|
|
974
|
+
}
|
|
975
|
+
if (this.status === "initializing") {
|
|
976
|
+
throw new Error("GeoPulse.initialize() is already in progress \u2014 wait for the first call to resolve");
|
|
977
|
+
}
|
|
978
|
+
if (!config.apiKey || !config.applicationId) {
|
|
979
|
+
throw new Error("GeoPulse.initialize requires both apiKey and applicationId");
|
|
980
|
+
}
|
|
981
|
+
if (config.debug !== void 0) {
|
|
982
|
+
this.logger.setEnabled(config.debug);
|
|
983
|
+
}
|
|
984
|
+
this.logger.log("GeoPulse.initialize()", { applicationId: config.applicationId });
|
|
985
|
+
this.status = "initializing";
|
|
986
|
+
this.config = config;
|
|
987
|
+
this.experiencePollIntervalMs = clampIntervalMs(config.experiencePollIntervalMs) ?? DEFAULT_EXPERIENCE_POLL_INTERVAL_MS;
|
|
988
|
+
this.apiClient = this.buildApiClient(config);
|
|
989
|
+
this.syncManager = new SyncManager(
|
|
990
|
+
this.apiClient,
|
|
991
|
+
this.queue,
|
|
992
|
+
clampIntervalMs(config.retryIntervalMs),
|
|
993
|
+
this.logger,
|
|
994
|
+
(result) => this.handleEventDelivered(result)
|
|
995
|
+
);
|
|
996
|
+
this.deviceId = await getOrCreateDeviceId(this.deviceIdStore);
|
|
997
|
+
this.realtime?.disconnect();
|
|
998
|
+
this.realtime = this.createRealtime ? this.createRealtime({
|
|
999
|
+
baseUrl: config.functionsBaseUrl || DEFAULT_BACKEND_URL,
|
|
1000
|
+
sdkKey: config.apiKey,
|
|
1001
|
+
applicationId: config.applicationId,
|
|
1002
|
+
deviceId: this.deviceId,
|
|
1003
|
+
onChanged: () => void this.pollForExperiences(),
|
|
1004
|
+
// Whatever changed while the socket was down is picked up now.
|
|
1005
|
+
onConnected: () => void this.pollForExperiences(),
|
|
1006
|
+
logger: this.logger
|
|
1007
|
+
}) : null;
|
|
1008
|
+
try {
|
|
1009
|
+
const projectConfig = await this.apiClient.getProjectConfig({
|
|
1010
|
+
deviceId: this.deviceId,
|
|
1011
|
+
platform: this.getPlatform(),
|
|
1012
|
+
sdkVersion: SDK_VERSION
|
|
1013
|
+
});
|
|
1014
|
+
if (projectConfig.applicationId !== config.applicationId) {
|
|
1015
|
+
throw new ApplicationMismatchError();
|
|
1016
|
+
}
|
|
1017
|
+
this.applyProjectConfig(projectConfig);
|
|
1018
|
+
this.configurationStatus = "synced";
|
|
1019
|
+
this.lastConfigFetchAt = Date.now();
|
|
1020
|
+
await this.configCache.save(projectConfig);
|
|
1021
|
+
this.status = "initialized";
|
|
1022
|
+
this.logger.log("Configuration synced", { geofenceCount: projectConfig.geofences.length, configVersion: projectConfig.configVersion });
|
|
1023
|
+
} catch (error) {
|
|
1024
|
+
if (error instanceof ApplicationMismatchError) {
|
|
1025
|
+
this.configurationStatus = "unavailable";
|
|
1026
|
+
this.status = "error";
|
|
1027
|
+
throw error;
|
|
1028
|
+
}
|
|
1029
|
+
const cached = await this.configCache.load();
|
|
1030
|
+
if (cached && cached.config.applicationId === config.applicationId) {
|
|
1031
|
+
this.applyProjectConfig(cached.config);
|
|
1032
|
+
this.configurationStatus = "cached";
|
|
1033
|
+
this.status = "initialized";
|
|
1034
|
+
this.logger.warn("getProjectConfig failed \u2014 using cached configuration", { fetchedAt: cached.fetchedAt });
|
|
1035
|
+
return;
|
|
1036
|
+
}
|
|
1037
|
+
this.configurationStatus = "unavailable";
|
|
1038
|
+
this.status = "error";
|
|
1039
|
+
throw error;
|
|
1040
|
+
}
|
|
1041
|
+
}
|
|
1042
|
+
async start() {
|
|
1043
|
+
if (this.status === "running") {
|
|
1044
|
+
throw new Error("GeoPulse.start() called while already running \u2014 call GeoPulse.stop() first if you want to restart");
|
|
1045
|
+
}
|
|
1046
|
+
if (this.status !== "initialized" && this.status !== "stopped") {
|
|
1047
|
+
throw new Error("GeoPulse.start called before GeoPulse.initialize succeeded");
|
|
1048
|
+
}
|
|
1049
|
+
if (!this.applicationEnabled) {
|
|
1050
|
+
this.status = "error";
|
|
1051
|
+
throw new Error("This application has been disabled in the GeoPulse admin portal");
|
|
1052
|
+
}
|
|
1053
|
+
const permission = await this.permissionProvider.request();
|
|
1054
|
+
if (permission !== "granted") {
|
|
1055
|
+
this.status = "error";
|
|
1056
|
+
throw new Error(`Location permission not granted (status: ${permission})`);
|
|
1057
|
+
}
|
|
1058
|
+
if (!this.geofenceStateRestored) {
|
|
1059
|
+
this.geofenceStateRestored = true;
|
|
1060
|
+
const saved = await this.geofenceStateStore.load();
|
|
1061
|
+
if (saved) this.stateMachine.restoreState(saved);
|
|
1062
|
+
}
|
|
1063
|
+
this.locationError = null;
|
|
1064
|
+
await this.locationProvider.start(
|
|
1065
|
+
(location) => {
|
|
1066
|
+
void this.handleLocationUpdate(location);
|
|
1067
|
+
},
|
|
1068
|
+
(error) => {
|
|
1069
|
+
this.locationError = error.message;
|
|
1070
|
+
this.logger.warn("Location provider failed to start", { error: error.message });
|
|
1071
|
+
}
|
|
1072
|
+
);
|
|
1073
|
+
if (this.locationError) {
|
|
1074
|
+
this.status = "error";
|
|
1075
|
+
throw new Error(`GeoPulse failed to start location tracking: ${this.locationError}`);
|
|
1076
|
+
}
|
|
1077
|
+
this.unsubscribeNetwork = this.networkMonitor.subscribe((networkStatus) => {
|
|
1078
|
+
if (networkStatus === "online") {
|
|
1079
|
+
this.logger.log("Network available \u2014 retrying queued events");
|
|
1080
|
+
void this.syncManager?.retryPending({ force: true });
|
|
1081
|
+
}
|
|
1082
|
+
});
|
|
1083
|
+
this.syncManager?.startAutoRetry();
|
|
1084
|
+
this.startExperiencePolling();
|
|
1085
|
+
this.status = "running";
|
|
1086
|
+
this.syncRealtime();
|
|
1087
|
+
this.logger.log("GeoPulse.start()");
|
|
1088
|
+
}
|
|
1089
|
+
stop() {
|
|
1090
|
+
this.locationProvider.stop();
|
|
1091
|
+
this.syncManager?.stopAutoRetry();
|
|
1092
|
+
this.stopExperiencePolling();
|
|
1093
|
+
this.realtime?.disconnect();
|
|
1094
|
+
this.unsubscribeNetwork?.();
|
|
1095
|
+
this.unsubscribeNetwork = null;
|
|
1096
|
+
this.status = "stopped";
|
|
1097
|
+
this.logger.log("GeoPulse.stop()");
|
|
1098
|
+
}
|
|
1099
|
+
getStatus() {
|
|
1100
|
+
return this.status;
|
|
1101
|
+
}
|
|
1102
|
+
async getPermissionStatus() {
|
|
1103
|
+
return this.permissionProvider.getStatus();
|
|
1104
|
+
}
|
|
1105
|
+
on(eventName, callback) {
|
|
1106
|
+
if (eventName === "experience.received") {
|
|
1107
|
+
this.experienceListeners.add(callback);
|
|
1108
|
+
return;
|
|
1109
|
+
}
|
|
1110
|
+
if (eventName === "experience.removed") {
|
|
1111
|
+
this.experienceRemovedListeners.add(callback);
|
|
1112
|
+
return;
|
|
1113
|
+
}
|
|
1114
|
+
if (!this.listeners.has(eventName)) {
|
|
1115
|
+
this.listeners.set(eventName, /* @__PURE__ */ new Set());
|
|
1116
|
+
}
|
|
1117
|
+
this.listeners.get(eventName)?.add(callback);
|
|
1118
|
+
}
|
|
1119
|
+
off(eventName, callback) {
|
|
1120
|
+
if (eventName === "experience.received") {
|
|
1121
|
+
this.experienceListeners.delete(callback);
|
|
1122
|
+
return;
|
|
1123
|
+
}
|
|
1124
|
+
if (eventName === "experience.removed") {
|
|
1125
|
+
this.experienceRemovedListeners.delete(callback);
|
|
1126
|
+
return;
|
|
1127
|
+
}
|
|
1128
|
+
this.listeners.get(eventName)?.delete(callback);
|
|
1129
|
+
}
|
|
1130
|
+
// --- Small read-only accessors beyond the core 7-method API, so a host
|
|
1131
|
+
// app (see apps/playground) can build a status UI around the SDK without
|
|
1132
|
+
// it needing to duplicate state the SDK already tracks internally. ---
|
|
1133
|
+
getConfiguredGeofences() {
|
|
1134
|
+
return this.geofences;
|
|
1135
|
+
}
|
|
1136
|
+
/** The server-resolved application id (from `getProjectConfig`), or null before `initialize()` completes. */
|
|
1137
|
+
getApplicationId() {
|
|
1138
|
+
return this.resolvedApplicationId;
|
|
1139
|
+
}
|
|
1140
|
+
getLastKnownLocation() {
|
|
1141
|
+
return this.lastKnownLocation;
|
|
1142
|
+
}
|
|
1143
|
+
async getPendingEventCount() {
|
|
1144
|
+
return (await this.queue.listPending()).length;
|
|
1145
|
+
}
|
|
1146
|
+
/** Null until `initialize()` resolves it — persisted across app restarts (see config/device-id.ts). */
|
|
1147
|
+
getDeviceId() {
|
|
1148
|
+
return this.deviceId;
|
|
1149
|
+
}
|
|
1150
|
+
/**
|
|
1151
|
+
* The rich developer-diagnostics snapshot (spec section 10/11) — a
|
|
1152
|
+
* superset of `getStatus()`, not a replacement for it (see core/types.ts's
|
|
1153
|
+
* `DiagnosticsSnapshot` doc for why these are two separate methods).
|
|
1154
|
+
*/
|
|
1155
|
+
async getDiagnostics() {
|
|
1156
|
+
const pending = await this.queue.listPending();
|
|
1157
|
+
const permissionStatus = this.status === "idle" || this.status === "initializing" ? "unknown" : await this.permissionProvider.getStatus();
|
|
1158
|
+
return {
|
|
1159
|
+
initialized: this.status !== "idle" && this.status !== "initializing",
|
|
1160
|
+
running: this.status === "running",
|
|
1161
|
+
locationAvailable: this.lastKnownLocation !== null,
|
|
1162
|
+
permissionStatus,
|
|
1163
|
+
networkStatus: this.networkMonitor.getStatus(),
|
|
1164
|
+
configurationStatus: this.configurationStatus,
|
|
1165
|
+
configVersion: this.configVersion,
|
|
1166
|
+
pendingEvents: pending.length,
|
|
1167
|
+
totalRetryAttempts: pending.reduce((sum, event) => sum + event.attempts, 0),
|
|
1168
|
+
locationError: this.locationError,
|
|
1169
|
+
lastEventAt: this.lastEvent?.timestamp ?? null,
|
|
1170
|
+
lastEventType: this.lastEvent?.type ?? null,
|
|
1171
|
+
lastSyncAt: this.syncManager?.getLastSyncAt() ?? null,
|
|
1172
|
+
lastUploadResult: this.syncManager?.getLastUploadResult() ?? null,
|
|
1173
|
+
deviceId: this.deviceId,
|
|
1174
|
+
sdkVersion: SDK_VERSION,
|
|
1175
|
+
experiencesReceivedCount: this.experiencesReceivedCount
|
|
1176
|
+
};
|
|
1177
|
+
}
|
|
1178
|
+
/**
|
|
1179
|
+
* Only functional when the SDK is running the simulated location
|
|
1180
|
+
* provider (the default outside of production builds — see
|
|
1181
|
+
* src/index.ts) and gated by `__DEV__` so it's inert in a production
|
|
1182
|
+
* bundle even if a caller somehow held a reference to it.
|
|
1183
|
+
*/
|
|
1184
|
+
simulateLocation(coords) {
|
|
1185
|
+
const isDev2 = typeof __DEV__ === "undefined" ? true : __DEV__;
|
|
1186
|
+
if (!isDev2) {
|
|
1187
|
+
console.warn("GeoPulse.simulator is unavailable in production builds.");
|
|
1188
|
+
return;
|
|
1189
|
+
}
|
|
1190
|
+
if (!(this.locationProvider instanceof SimulatedLocationProvider)) {
|
|
1191
|
+
console.warn(
|
|
1192
|
+
"GeoPulse.simulator.setLocation has no effect: the SDK is not running the simulated location provider."
|
|
1193
|
+
);
|
|
1194
|
+
return;
|
|
1195
|
+
}
|
|
1196
|
+
this.locationProvider.setLocation(coords);
|
|
1197
|
+
}
|
|
1198
|
+
/**
|
|
1199
|
+
* The Phase 5B delivery mechanism: poll immediately on `start()` (so a
|
|
1200
|
+
* demo doesn't wait a full interval for the first check), then on a
|
|
1201
|
+
* timer. A poll failure (network blip, backend down) is logged and
|
|
1202
|
+
* swallowed — it must never crash the SDK or the host app (spec section
|
|
1203
|
+
* 13), and the next tick tries again on its own.
|
|
1204
|
+
*/
|
|
1205
|
+
startExperiencePolling() {
|
|
1206
|
+
void this.pollForExperiences();
|
|
1207
|
+
this.experiencePollTimer = setInterval(() => {
|
|
1208
|
+
void this.pollForExperiences();
|
|
1209
|
+
void this.refreshConfigIfStale();
|
|
1210
|
+
}, this.experiencePollIntervalMs);
|
|
1211
|
+
}
|
|
1212
|
+
stopExperiencePolling() {
|
|
1213
|
+
if (this.pollRetryTimer) {
|
|
1214
|
+
clearTimeout(this.pollRetryTimer);
|
|
1215
|
+
this.pollRetryTimer = null;
|
|
1216
|
+
}
|
|
1217
|
+
if (this.experiencePollTimer) {
|
|
1218
|
+
clearInterval(this.experiencePollTimer);
|
|
1219
|
+
this.experiencePollTimer = null;
|
|
1220
|
+
}
|
|
1221
|
+
}
|
|
1222
|
+
/**
|
|
1223
|
+
* The backend says in its ingest response whether the event created an experience for this
|
|
1224
|
+
* device. Poll right away if so (or if it doesn't say — an older backend), skip if it says
|
|
1225
|
+
* there is nothing. Does nothing unless polling is active, so a stopped SDK never polls.
|
|
1226
|
+
*/
|
|
1227
|
+
handleEventDelivered(result) {
|
|
1228
|
+
if (this.experiencePollTimer === null || result?.experiencesPending === false) {
|
|
1229
|
+
return;
|
|
1230
|
+
}
|
|
1231
|
+
void this.pollForExperiences();
|
|
1232
|
+
}
|
|
1233
|
+
/** One poll at a time: a second request while one is running makes it run once more afterwards, rather than overlapping. */
|
|
1234
|
+
async pollForExperiences() {
|
|
1235
|
+
if (this.experiencePollInFlight) {
|
|
1236
|
+
this.experiencePollRequested = true;
|
|
1237
|
+
return;
|
|
1238
|
+
}
|
|
1239
|
+
this.experiencePollInFlight = true;
|
|
1240
|
+
try {
|
|
1241
|
+
do {
|
|
1242
|
+
this.experiencePollRequested = false;
|
|
1243
|
+
await this.pollForExperiencesOnce();
|
|
1244
|
+
} while (this.experiencePollRequested);
|
|
1245
|
+
} finally {
|
|
1246
|
+
this.experiencePollInFlight = false;
|
|
1247
|
+
}
|
|
1248
|
+
}
|
|
1249
|
+
async pollForExperiencesOnce() {
|
|
1250
|
+
if (!this.apiClient || !this.deviceId) {
|
|
1251
|
+
return;
|
|
1252
|
+
}
|
|
1253
|
+
try {
|
|
1254
|
+
const { experiences, revoked } = await this.apiClient.getPendingExperiences(this.deviceId);
|
|
1255
|
+
for (const experience of experiences) {
|
|
1256
|
+
const revision = experience.revision ?? 0;
|
|
1257
|
+
const seenRevision = this.seenExperienceRevisions.get(experience.deliveryId);
|
|
1258
|
+
if (seenRevision === revision) {
|
|
1259
|
+
continue;
|
|
1260
|
+
}
|
|
1261
|
+
const isUpdate = seenRevision !== void 0;
|
|
1262
|
+
this.seenExperienceRevisions.set(experience.deliveryId, revision);
|
|
1263
|
+
this.removedDeliveryIds.delete(experience.deliveryId);
|
|
1264
|
+
this.experiencesReceivedCount += 1;
|
|
1265
|
+
this.logger.log("Experience received", { deliveryId: experience.deliveryId, campaignId: experience.campaignId, isUpdate });
|
|
1266
|
+
this.experienceListeners.forEach((listener) => listener({ ...experience, isUpdate }));
|
|
1267
|
+
await this.apiClient.acknowledgeExperienceDelivery(experience.deliveryId).catch((error) => {
|
|
1268
|
+
this.logger.warn("Failed to acknowledge experience delivery", {
|
|
1269
|
+
deliveryId: experience.deliveryId,
|
|
1270
|
+
error: error instanceof Error ? error.message : String(error)
|
|
1271
|
+
});
|
|
1272
|
+
});
|
|
1273
|
+
}
|
|
1274
|
+
for (const removed of revoked) {
|
|
1275
|
+
if (!this.removedDeliveryIds.has(removed.deliveryId)) {
|
|
1276
|
+
this.removedDeliveryIds.add(removed.deliveryId);
|
|
1277
|
+
this.seenExperienceRevisions.delete(removed.deliveryId);
|
|
1278
|
+
this.logger.log("Experience removed", { deliveryId: removed.deliveryId, campaignId: removed.campaignId });
|
|
1279
|
+
this.experienceRemovedListeners.forEach((listener) => listener(removed));
|
|
1280
|
+
}
|
|
1281
|
+
await this.apiClient.acknowledgeExperienceDelivery(removed.deliveryId).catch((error) => {
|
|
1282
|
+
this.logger.warn("Failed to acknowledge experience removal", {
|
|
1283
|
+
deliveryId: removed.deliveryId,
|
|
1284
|
+
error: error instanceof Error ? error.message : String(error)
|
|
1285
|
+
});
|
|
1286
|
+
});
|
|
1287
|
+
}
|
|
1288
|
+
} catch (error) {
|
|
1289
|
+
this.logger.warn("Polling for experiences failed", { error: error instanceof Error ? error.message : String(error) });
|
|
1290
|
+
if (error instanceof ApiError && error.status === 429) {
|
|
1291
|
+
this.schedulePollRetry(error.retryAfterMs ?? 3e4);
|
|
1292
|
+
}
|
|
1293
|
+
}
|
|
1294
|
+
}
|
|
1295
|
+
schedulePollRetry(waitMs) {
|
|
1296
|
+
if (this.pollRetryTimer !== null || this.experiencePollTimer === null) {
|
|
1297
|
+
return;
|
|
1298
|
+
}
|
|
1299
|
+
this.pollRetryTimer = setTimeout(() => {
|
|
1300
|
+
this.pollRetryTimer = null;
|
|
1301
|
+
void this.pollForExperiences();
|
|
1302
|
+
}, waitMs + Math.random() * 5e3);
|
|
1303
|
+
}
|
|
1304
|
+
/**
|
|
1305
|
+
* Re-reads the geofence list when it is an hour old, so a geofence an admin added, moved, resized or removed reaches a phone
|
|
1306
|
+
* 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.
|
|
1307
|
+
* A failure keeps the list we have. A disabled application stops the SDK.
|
|
1308
|
+
*/
|
|
1309
|
+
async refreshConfigIfStale() {
|
|
1310
|
+
if (!this.apiClient || !this.deviceId || !this.config || this.configRefreshInFlight) {
|
|
1311
|
+
return;
|
|
1312
|
+
}
|
|
1313
|
+
if (Date.now() - this.lastConfigFetchAt < CONFIG_REFRESH_INTERVAL_MS) {
|
|
1314
|
+
return;
|
|
1315
|
+
}
|
|
1316
|
+
this.configRefreshInFlight = true;
|
|
1317
|
+
try {
|
|
1318
|
+
const projectConfig = await this.apiClient.getProjectConfig({ deviceId: this.deviceId, platform: this.getPlatform(), sdkVersion: SDK_VERSION });
|
|
1319
|
+
if (projectConfig.applicationId !== this.config.applicationId) {
|
|
1320
|
+
return;
|
|
1321
|
+
}
|
|
1322
|
+
this.lastConfigFetchAt = Date.now();
|
|
1323
|
+
this.applyProjectConfig(projectConfig);
|
|
1324
|
+
this.configurationStatus = "synced";
|
|
1325
|
+
await this.configCache.save(projectConfig);
|
|
1326
|
+
this.logger.log("Configuration refreshed", { geofenceCount: projectConfig.geofences.length, configVersion: projectConfig.configVersion });
|
|
1327
|
+
if (!projectConfig.applicationEnabled) {
|
|
1328
|
+
this.logger.warn("Application disabled \u2014 stopping");
|
|
1329
|
+
this.stop();
|
|
1330
|
+
this.status = "error";
|
|
1331
|
+
}
|
|
1332
|
+
} catch (error) {
|
|
1333
|
+
this.logger.warn("Configuration refresh failed \u2014 keeping the current geofences", { error: error instanceof Error ? error.message : String(error) });
|
|
1334
|
+
} finally {
|
|
1335
|
+
this.configRefreshInFlight = false;
|
|
1336
|
+
}
|
|
1337
|
+
}
|
|
1338
|
+
/** Keeps the push connection open exactly while the user is inside a geofence. */
|
|
1339
|
+
syncRealtime() {
|
|
1340
|
+
if (!this.realtime || this.status === "stopped") {
|
|
1341
|
+
return;
|
|
1342
|
+
}
|
|
1343
|
+
if (this.stateMachine.isInsideAny()) {
|
|
1344
|
+
this.realtime.connect();
|
|
1345
|
+
} else {
|
|
1346
|
+
this.realtime.disconnect();
|
|
1347
|
+
}
|
|
1348
|
+
}
|
|
1349
|
+
async persistGeofenceState() {
|
|
1350
|
+
await this.geofenceStateStore.save(this.stateMachine.exportState());
|
|
1351
|
+
}
|
|
1352
|
+
applyProjectConfig(projectConfig) {
|
|
1353
|
+
this.resolvedProjectId = projectConfig.projectId;
|
|
1354
|
+
this.resolvedApplicationId = projectConfig.applicationId;
|
|
1355
|
+
this.applicationEnabled = projectConfig.applicationEnabled;
|
|
1356
|
+
this.configVersion = projectConfig.configVersion;
|
|
1357
|
+
this.geofences = projectConfig.geofences;
|
|
1358
|
+
}
|
|
1359
|
+
async handleLocationUpdate(location) {
|
|
1360
|
+
this.lastKnownLocation = location;
|
|
1361
|
+
if (!this.resolvedProjectId || !this.resolvedApplicationId || !this.syncManager || !this.deviceId) {
|
|
1362
|
+
return;
|
|
1363
|
+
}
|
|
1364
|
+
const transitions = this.stateMachine.evaluate(location, this.geofences);
|
|
1365
|
+
if (transitions.length > 0) {
|
|
1366
|
+
void this.persistGeofenceState();
|
|
1367
|
+
}
|
|
1368
|
+
this.syncRealtime();
|
|
1369
|
+
for (const transition of transitions) {
|
|
1370
|
+
this.logger.log("Geofence state changed", { geofenceId: transition.geofence.id, type: transition.type });
|
|
1371
|
+
const event = createEventPayload({
|
|
1372
|
+
type: transition.type,
|
|
1373
|
+
projectId: this.resolvedProjectId,
|
|
1374
|
+
applicationId: this.resolvedApplicationId,
|
|
1375
|
+
geofence: transition.geofence,
|
|
1376
|
+
location,
|
|
1377
|
+
deviceId: this.deviceId
|
|
1378
|
+
});
|
|
1379
|
+
this.lastEvent = event;
|
|
1380
|
+
this.logger.log("Event generated", { eventId: event.id, type: event.type });
|
|
1381
|
+
this.listeners.get(transition.type)?.forEach((listener) => listener(event));
|
|
1382
|
+
await this.syncManager.sendOrQueue(event);
|
|
1383
|
+
}
|
|
1384
|
+
}
|
|
1385
|
+
};
|
|
1386
|
+
|
|
1387
|
+
// src/location/device-location-provider.ts
|
|
1388
|
+
var Location = __toESM(require("expo-location"));
|
|
1389
|
+
var DeviceLocationProvider = class {
|
|
1390
|
+
subscription = null;
|
|
1391
|
+
/**
|
|
1392
|
+
* `watchPositionAsync` rejects if location services are off at the OS
|
|
1393
|
+
* level (distinct from the app's own permission grant, which is checked
|
|
1394
|
+
* before this ever runs) — caught here and reported via `onError` instead
|
|
1395
|
+
* of left to reject this method's own promise, so a caller that doesn't
|
|
1396
|
+
* wrap `GeoPulse.start()` in a try/catch still can't end up with an
|
|
1397
|
+
* unhandled rejection from deep inside a native module.
|
|
1398
|
+
*/
|
|
1399
|
+
async start(onUpdate, onError) {
|
|
1400
|
+
try {
|
|
1401
|
+
this.subscription = await Location.watchPositionAsync(
|
|
1402
|
+
{ accuracy: Location.Accuracy.Balanced, timeInterval: 5e3, distanceInterval: 10 },
|
|
1403
|
+
(position) => {
|
|
1404
|
+
onUpdate({
|
|
1405
|
+
latitude: position.coords.latitude,
|
|
1406
|
+
longitude: position.coords.longitude,
|
|
1407
|
+
timestamp: new Date(position.timestamp).toISOString(),
|
|
1408
|
+
...typeof position.coords.accuracy === "number" ? { accuracy: position.coords.accuracy } : {}
|
|
1409
|
+
});
|
|
1410
|
+
}
|
|
1411
|
+
);
|
|
1412
|
+
} catch (error) {
|
|
1413
|
+
onError?.(error instanceof Error ? error : new Error(String(error)));
|
|
1414
|
+
}
|
|
1415
|
+
}
|
|
1416
|
+
stop() {
|
|
1417
|
+
this.subscription?.remove();
|
|
1418
|
+
this.subscription = null;
|
|
1419
|
+
}
|
|
1420
|
+
};
|
|
1421
|
+
|
|
1422
|
+
// src/permissions/expo-permission-provider.ts
|
|
1423
|
+
var Location2 = __toESM(require("expo-location"));
|
|
1424
|
+
function mapExpoStatus(status) {
|
|
1425
|
+
switch (status) {
|
|
1426
|
+
case Location2.PermissionStatus.GRANTED:
|
|
1427
|
+
return "granted";
|
|
1428
|
+
case Location2.PermissionStatus.DENIED:
|
|
1429
|
+
return "denied";
|
|
1430
|
+
default:
|
|
1431
|
+
return "undetermined";
|
|
1432
|
+
}
|
|
1433
|
+
}
|
|
1434
|
+
var ExpoPermissionProvider = class {
|
|
1435
|
+
async getStatus() {
|
|
1436
|
+
const { status } = await Location2.getForegroundPermissionsAsync();
|
|
1437
|
+
return mapExpoStatus(status);
|
|
1438
|
+
}
|
|
1439
|
+
async request() {
|
|
1440
|
+
const { status } = await Location2.requestForegroundPermissionsAsync();
|
|
1441
|
+
return mapExpoStatus(status);
|
|
1442
|
+
}
|
|
1443
|
+
};
|
|
1444
|
+
|
|
1445
|
+
// src/storage/async-storage-store.ts
|
|
1446
|
+
var import_async_storage2 = __toESM(require("@react-native-async-storage/async-storage"));
|
|
1447
|
+
var STORAGE_KEY2 = "@geopulse/pending-events";
|
|
1448
|
+
var AsyncStorageEventQueueStore = class {
|
|
1449
|
+
async getAll() {
|
|
1450
|
+
const raw = await import_async_storage2.default.getItem(STORAGE_KEY2);
|
|
1451
|
+
if (!raw) return [];
|
|
1452
|
+
try {
|
|
1453
|
+
return JSON.parse(raw);
|
|
1454
|
+
} catch {
|
|
1455
|
+
return [];
|
|
1456
|
+
}
|
|
1457
|
+
}
|
|
1458
|
+
async save(event) {
|
|
1459
|
+
const all = await this.getAll();
|
|
1460
|
+
const next = [...all.filter((existing) => existing.eventId !== event.eventId), event];
|
|
1461
|
+
await import_async_storage2.default.setItem(STORAGE_KEY2, JSON.stringify(next));
|
|
1462
|
+
}
|
|
1463
|
+
async remove(eventId) {
|
|
1464
|
+
const all = await this.getAll();
|
|
1465
|
+
await import_async_storage2.default.setItem(STORAGE_KEY2, JSON.stringify(all.filter((existing) => existing.eventId !== eventId)));
|
|
1466
|
+
}
|
|
1467
|
+
};
|
|
1468
|
+
|
|
1469
|
+
// src/realtime/realtime-client.ts
|
|
1470
|
+
var RECONNECT_BASE_MS = 5e3;
|
|
1471
|
+
var RECONNECT_MAX_MS = 5 * 6e4;
|
|
1472
|
+
var NO_RETRY_CODES = /* @__PURE__ */ new Set([4400, 4401, 4403]);
|
|
1473
|
+
function toRealtimeUrl(baseUrl) {
|
|
1474
|
+
return `${baseUrl.replace(/\/+$/, "").replace(/^http/, "ws")}/realtime`;
|
|
1475
|
+
}
|
|
1476
|
+
var RealtimeClient = class {
|
|
1477
|
+
constructor(options) {
|
|
1478
|
+
this.options = options;
|
|
1479
|
+
}
|
|
1480
|
+
options;
|
|
1481
|
+
socket = null;
|
|
1482
|
+
connected = false;
|
|
1483
|
+
wanted = false;
|
|
1484
|
+
failures = 0;
|
|
1485
|
+
reconnectTimer = null;
|
|
1486
|
+
isConnected() {
|
|
1487
|
+
return this.connected;
|
|
1488
|
+
}
|
|
1489
|
+
connect() {
|
|
1490
|
+
this.wanted = true;
|
|
1491
|
+
if (this.socket || this.reconnectTimer) return;
|
|
1492
|
+
this.open();
|
|
1493
|
+
}
|
|
1494
|
+
disconnect() {
|
|
1495
|
+
this.wanted = false;
|
|
1496
|
+
this.failures = 0;
|
|
1497
|
+
if (this.reconnectTimer) {
|
|
1498
|
+
clearTimeout(this.reconnectTimer);
|
|
1499
|
+
this.reconnectTimer = null;
|
|
1500
|
+
}
|
|
1501
|
+
const socket = this.socket;
|
|
1502
|
+
this.socket = null;
|
|
1503
|
+
this.connected = false;
|
|
1504
|
+
if (socket) {
|
|
1505
|
+
socket.onclose = null;
|
|
1506
|
+
socket.onmessage = null;
|
|
1507
|
+
socket.onerror = null;
|
|
1508
|
+
try {
|
|
1509
|
+
socket.close(1e3, "done");
|
|
1510
|
+
} catch {
|
|
1511
|
+
}
|
|
1512
|
+
}
|
|
1513
|
+
}
|
|
1514
|
+
open() {
|
|
1515
|
+
const create = this.options.createSocket ?? defaultCreateSocket;
|
|
1516
|
+
let socket;
|
|
1517
|
+
try {
|
|
1518
|
+
socket = create(toRealtimeUrl(this.options.baseUrl));
|
|
1519
|
+
} catch (error) {
|
|
1520
|
+
this.options.logger?.warn("Realtime unavailable", { error: String(error) });
|
|
1521
|
+
return;
|
|
1522
|
+
}
|
|
1523
|
+
if (!socket) return;
|
|
1524
|
+
this.socket = socket;
|
|
1525
|
+
socket.onopen = () => {
|
|
1526
|
+
socket.send(JSON.stringify({ type: "hello", sdkKey: this.options.sdkKey, applicationId: this.options.applicationId, deviceId: this.options.deviceId }));
|
|
1527
|
+
};
|
|
1528
|
+
socket.onmessage = (event) => {
|
|
1529
|
+
let message = null;
|
|
1530
|
+
try {
|
|
1531
|
+
message = JSON.parse(String(event.data));
|
|
1532
|
+
} catch {
|
|
1533
|
+
return;
|
|
1534
|
+
}
|
|
1535
|
+
if (message?.type === "ready") {
|
|
1536
|
+
this.connected = true;
|
|
1537
|
+
this.failures = 0;
|
|
1538
|
+
this.options.onConnected();
|
|
1539
|
+
} else if (message?.type === "changed") {
|
|
1540
|
+
this.options.onChanged();
|
|
1541
|
+
}
|
|
1542
|
+
};
|
|
1543
|
+
socket.onerror = () => {
|
|
1544
|
+
};
|
|
1545
|
+
socket.onclose = (event) => {
|
|
1546
|
+
this.socket = null;
|
|
1547
|
+
this.connected = false;
|
|
1548
|
+
if (!this.wanted) return;
|
|
1549
|
+
if (NO_RETRY_CODES.has(event.code)) {
|
|
1550
|
+
this.options.logger?.warn("Realtime rejected by the server; not retrying", { code: event.code });
|
|
1551
|
+
return;
|
|
1552
|
+
}
|
|
1553
|
+
this.failures += 1;
|
|
1554
|
+
const delay = Math.min(RECONNECT_BASE_MS * 2 ** (this.failures - 1), RECONNECT_MAX_MS);
|
|
1555
|
+
this.reconnectTimer = setTimeout(() => {
|
|
1556
|
+
this.reconnectTimer = null;
|
|
1557
|
+
if (this.wanted) this.open();
|
|
1558
|
+
}, Math.round(delay * (0.8 + Math.random() * 0.4)));
|
|
1559
|
+
};
|
|
1560
|
+
}
|
|
1561
|
+
};
|
|
1562
|
+
function defaultCreateSocket(url) {
|
|
1563
|
+
const Ctor = globalThis.WebSocket;
|
|
1564
|
+
return Ctor ? new Ctor(url) : null;
|
|
1565
|
+
}
|
|
1566
|
+
|
|
1567
|
+
// src/sync/netinfo-network-monitor.ts
|
|
1568
|
+
var import_netinfo = __toESM(require("@react-native-community/netinfo"));
|
|
1569
|
+
function toStatus(isConnected) {
|
|
1570
|
+
if (isConnected === null) return "unknown";
|
|
1571
|
+
return isConnected ? "online" : "offline";
|
|
1572
|
+
}
|
|
1573
|
+
var NetInfoNetworkMonitor = class {
|
|
1574
|
+
status = "unknown";
|
|
1575
|
+
constructor() {
|
|
1576
|
+
import_netinfo.default.fetch().then((state) => {
|
|
1577
|
+
this.status = toStatus(state.isConnected);
|
|
1578
|
+
});
|
|
1579
|
+
}
|
|
1580
|
+
getStatus() {
|
|
1581
|
+
return this.status;
|
|
1582
|
+
}
|
|
1583
|
+
subscribe(listener) {
|
|
1584
|
+
return import_netinfo.default.addEventListener((state) => {
|
|
1585
|
+
this.status = toStatus(state.isConnected);
|
|
1586
|
+
listener(this.status);
|
|
1587
|
+
});
|
|
1588
|
+
}
|
|
1589
|
+
};
|
|
1590
|
+
|
|
1591
|
+
// src/rendering/experience-components.tsx
|
|
1592
|
+
var import_react = __toESM(require("react"));
|
|
1593
|
+
var import_react_native = require("react-native");
|
|
1594
|
+
function isSafeExternalUrl(url) {
|
|
1595
|
+
return typeof url === "string" && /^https?:\/\/[^\s]+$/i.test(url);
|
|
1596
|
+
}
|
|
1597
|
+
function openUrl(url) {
|
|
1598
|
+
if (!isSafeExternalUrl(url)) return;
|
|
1599
|
+
import_react_native.Linking.openURL(url).catch(() => {
|
|
1600
|
+
});
|
|
1601
|
+
}
|
|
1602
|
+
function ExperienceButton({ style, ctaLabel, ctaUrl }) {
|
|
1603
|
+
if (!style.button.visible || !ctaLabel || !ctaUrl) return null;
|
|
1604
|
+
const { position } = style.button;
|
|
1605
|
+
return /* @__PURE__ */ import_react.default.createElement(
|
|
1606
|
+
import_react_native.TouchableOpacity,
|
|
1607
|
+
{
|
|
1608
|
+
onPress: () => openUrl(ctaUrl),
|
|
1609
|
+
style: {
|
|
1610
|
+
position: "absolute",
|
|
1611
|
+
top: position.top ?? void 0,
|
|
1612
|
+
bottom: position.bottom ?? void 0,
|
|
1613
|
+
left: position.left ?? void 0,
|
|
1614
|
+
right: position.right ?? void 0,
|
|
1615
|
+
backgroundColor: style.button.backgroundColor,
|
|
1616
|
+
borderRadius: style.button.borderRadius,
|
|
1617
|
+
paddingVertical: 10,
|
|
1618
|
+
paddingHorizontal: 16
|
|
1619
|
+
}
|
|
1620
|
+
},
|
|
1621
|
+
/* @__PURE__ */ import_react.default.createElement(import_react_native.Text, { style: { color: style.button.textColor, fontWeight: "600" } }, ctaLabel)
|
|
1622
|
+
);
|
|
1623
|
+
}
|
|
1624
|
+
function ExperienceContainer({ content, style, children }) {
|
|
1625
|
+
const containerStyle = {
|
|
1626
|
+
backgroundColor: style.container.backgroundColor,
|
|
1627
|
+
borderColor: style.container.borderColor ?? void 0,
|
|
1628
|
+
borderWidth: style.container.borderWidth,
|
|
1629
|
+
borderRadius: style.container.borderRadius,
|
|
1630
|
+
padding: style.container.padding,
|
|
1631
|
+
overflow: "hidden",
|
|
1632
|
+
width: style.container.width === "fill" ? "100%" : style.container.width,
|
|
1633
|
+
// "auto" means no explicit height at all — RN sizes a View to its content by default, so
|
|
1634
|
+
// omitting the property (rather than passing "auto", which RN's height type doesn't accept)
|
|
1635
|
+
// is what actually produces that behavior.
|
|
1636
|
+
// The button is absolutely positioned, so it takes no layout space — without a floor, an
|
|
1637
|
+
// "auto" container shrinks to title+body and the button overlaps them. 160 matches the
|
|
1638
|
+
// admin-web preview (experience-preview.tsx) so the device renders what the portal shows.
|
|
1639
|
+
...style.container.height === "auto" ? { minHeight: 160 } : { height: style.container.height }
|
|
1640
|
+
};
|
|
1641
|
+
const backgroundImage = isSafeExternalUrl(style.container.backgroundImageUrl) ? /* @__PURE__ */ import_react.default.createElement(
|
|
1642
|
+
import_react_native.Image,
|
|
1643
|
+
{
|
|
1644
|
+
source: { uri: style.container.backgroundImageUrl },
|
|
1645
|
+
resizeMode: style.container.backgroundImageFit,
|
|
1646
|
+
style: { position: "absolute", top: 0, left: 0, right: 0, bottom: 0 }
|
|
1647
|
+
}
|
|
1648
|
+
) : null;
|
|
1649
|
+
if (content.cardNavigationUrl) {
|
|
1650
|
+
const cardNavigationUrl = content.cardNavigationUrl;
|
|
1651
|
+
return /* @__PURE__ */ import_react.default.createElement(import_react_native.TouchableOpacity, { testID: "experience-container", onPress: () => openUrl(cardNavigationUrl), style: containerStyle }, backgroundImage, children);
|
|
1652
|
+
}
|
|
1653
|
+
return /* @__PURE__ */ import_react.default.createElement(import_react_native.View, { testID: "experience-container", style: containerStyle }, backgroundImage, children);
|
|
1654
|
+
}
|
|
1655
|
+
function ExperienceText({ visible, text, color, fontSize, fontWeight }) {
|
|
1656
|
+
if (!visible) return null;
|
|
1657
|
+
return /* @__PURE__ */ import_react.default.createElement(import_react_native.Text, { style: { color, fontSize, fontWeight } }, text);
|
|
1658
|
+
}
|
|
1659
|
+
function CardExperience({ content }) {
|
|
1660
|
+
const style = resolveExperienceStyle("card", content.style);
|
|
1661
|
+
return /* @__PURE__ */ import_react.default.createElement(ExperienceContainer, { content, style }, /* @__PURE__ */ import_react.default.createElement(ExperienceText, { visible: style.title.visible, text: content.title, color: style.title.color, fontSize: style.title.fontSize, fontWeight: style.title.fontWeight }), /* @__PURE__ */ import_react.default.createElement(ExperienceText, { visible: style.body.visible, text: content.body, color: style.body.color, fontSize: style.body.fontSize, fontWeight: style.body.fontWeight }), /* @__PURE__ */ import_react.default.createElement(ExperienceButton, { style, ctaLabel: content.ctaLabel, ctaUrl: content.ctaUrl }));
|
|
1662
|
+
}
|
|
1663
|
+
function BannerExperience({ content }) {
|
|
1664
|
+
const style = resolveExperienceStyle("banner", content.style);
|
|
1665
|
+
return /* @__PURE__ */ import_react.default.createElement(ExperienceContainer, { content, style }, /* @__PURE__ */ import_react.default.createElement(ExperienceText, { visible: style.title.visible, text: content.title, color: style.title.color, fontSize: style.title.fontSize, fontWeight: style.title.fontWeight }), /* @__PURE__ */ import_react.default.createElement(ExperienceText, { visible: style.body.visible, text: content.body, color: style.body.color, fontSize: style.body.fontSize, fontWeight: style.body.fontWeight }), /* @__PURE__ */ import_react.default.createElement(ExperienceButton, { style, ctaLabel: content.ctaLabel, ctaUrl: content.ctaUrl }));
|
|
1666
|
+
}
|
|
1667
|
+
function AlertExperience({ content }) {
|
|
1668
|
+
const style = resolveExperienceStyle("alert", content.style);
|
|
1669
|
+
return /* @__PURE__ */ import_react.default.createElement(ExperienceContainer, { content, style }, /* @__PURE__ */ import_react.default.createElement(ExperienceText, { visible: style.title.visible, text: content.title, color: style.title.color, fontSize: style.title.fontSize, fontWeight: style.title.fontWeight }), /* @__PURE__ */ import_react.default.createElement(ExperienceText, { visible: style.body.visible, text: content.body, color: style.body.color, fontSize: style.body.fontSize, fontWeight: style.body.fontWeight }), /* @__PURE__ */ import_react.default.createElement(ExperienceButton, { style, ctaLabel: content.ctaLabel, ctaUrl: content.ctaUrl }));
|
|
1670
|
+
}
|
|
1671
|
+
function isAlertExperience(payload) {
|
|
1672
|
+
return payload.experience.type === "alert";
|
|
1673
|
+
}
|
|
1674
|
+
function MessageExperience({ content }) {
|
|
1675
|
+
const style = resolveExperienceStyle("message", content.style);
|
|
1676
|
+
return /* @__PURE__ */ import_react.default.createElement(ExperienceContainer, { content, style }, /* @__PURE__ */ import_react.default.createElement(ExperienceText, { visible: style.title.visible, text: content.title, color: style.title.color, fontSize: style.title.fontSize, fontWeight: style.title.fontWeight }), /* @__PURE__ */ import_react.default.createElement(ExperienceText, { visible: style.body.visible, text: content.body, color: style.body.color, fontSize: style.body.fontSize, fontWeight: style.body.fontWeight }), /* @__PURE__ */ import_react.default.createElement(ExperienceButton, { style, ctaLabel: content.ctaLabel, ctaUrl: content.ctaUrl }));
|
|
1677
|
+
}
|
|
1678
|
+
function ExperienceView({ experience }) {
|
|
1679
|
+
switch (experience.type) {
|
|
1680
|
+
case "banner":
|
|
1681
|
+
return /* @__PURE__ */ import_react.default.createElement(BannerExperience, { content: experience });
|
|
1682
|
+
case "message":
|
|
1683
|
+
return /* @__PURE__ */ import_react.default.createElement(MessageExperience, { content: experience });
|
|
1684
|
+
case "alert":
|
|
1685
|
+
return /* @__PURE__ */ import_react.default.createElement(AlertExperience, { content: experience });
|
|
1686
|
+
case "card":
|
|
1687
|
+
default:
|
|
1688
|
+
return /* @__PURE__ */ import_react.default.createElement(CardExperience, { content: experience });
|
|
1689
|
+
}
|
|
1690
|
+
}
|
|
1691
|
+
|
|
1692
|
+
// src/index.ts
|
|
1693
|
+
var isDev = typeof __DEV__ === "undefined" ? true : __DEV__;
|
|
1694
|
+
var useRealLocationInDev = process.env.EXPO_PUBLIC_GEOPULSE_USE_REAL_LOCATION === "true";
|
|
1695
|
+
var useSimulatedProviders = isDev && !useRealLocationInDev;
|
|
1696
|
+
function getPlatform() {
|
|
1697
|
+
switch (import_react_native2.Platform.OS) {
|
|
1698
|
+
case "ios":
|
|
1699
|
+
case "android":
|
|
1700
|
+
case "web":
|
|
1701
|
+
return import_react_native2.Platform.OS;
|
|
1702
|
+
default:
|
|
1703
|
+
return "unknown";
|
|
1704
|
+
}
|
|
1705
|
+
}
|
|
1706
|
+
var persistentKeyValueStore = new AsyncStorageKeyValueStore();
|
|
1707
|
+
var sdk = new GeoPulseSdk({
|
|
1708
|
+
locationProvider: useSimulatedProviders ? new SimulatedLocationProvider() : new DeviceLocationProvider(),
|
|
1709
|
+
permissionProvider: useSimulatedProviders ? new SimulatedPermissionProvider() : new ExpoPermissionProvider(),
|
|
1710
|
+
queue: new PendingEventQueue(new AsyncStorageEventQueueStore()),
|
|
1711
|
+
deviceIdStore: persistentKeyValueStore,
|
|
1712
|
+
configCacheStore: persistentKeyValueStore,
|
|
1713
|
+
// The simulator is for trying things out: no 2-minute cooldown, and no saved "inside" state surviving a reload
|
|
1714
|
+
// (otherwise "enter" would not fire again after Fast Refresh while the saved state says you are still inside).
|
|
1715
|
+
stateStore: useSimulatedProviders ? void 0 : persistentKeyValueStore,
|
|
1716
|
+
stateMachineOptions: useSimulatedProviders ? { cooldownMs: 0 } : void 0,
|
|
1717
|
+
createRealtimeClient: (options) => new RealtimeClient(options),
|
|
1718
|
+
networkMonitor: new NetInfoNetworkMonitor(),
|
|
1719
|
+
getPlatform,
|
|
1720
|
+
debug: isDev
|
|
1721
|
+
});
|
|
1722
|
+
var GeoPulse = {
|
|
1723
|
+
initialize: (config) => sdk.initialize(config),
|
|
1724
|
+
start: () => sdk.start(),
|
|
1725
|
+
stop: () => sdk.stop(),
|
|
1726
|
+
getStatus: () => sdk.getStatus(),
|
|
1727
|
+
getPermissionStatus: () => sdk.getPermissionStatus(),
|
|
1728
|
+
on: sdk.on.bind(sdk),
|
|
1729
|
+
off: sdk.off.bind(sdk),
|
|
1730
|
+
// Small additions beyond the core 7-method API, for building a status UI
|
|
1731
|
+
// around the SDK (see apps/playground) — not part of the minimal public
|
|
1732
|
+
// surface the spec calls out, but plain read-only accessors for state the
|
|
1733
|
+
// SDK already tracks internally.
|
|
1734
|
+
getConfiguredGeofences: () => sdk.getConfiguredGeofences(),
|
|
1735
|
+
getApplicationId: () => sdk.getApplicationId(),
|
|
1736
|
+
getLastKnownLocation: () => sdk.getLastKnownLocation(),
|
|
1737
|
+
getPendingEventCount: () => sdk.getPendingEventCount(),
|
|
1738
|
+
getDeviceId: () => sdk.getDeviceId(),
|
|
1739
|
+
/** Rich developer-diagnostics snapshot — see core/types.ts's DiagnosticsSnapshot doc. */
|
|
1740
|
+
getDiagnostics: () => sdk.getDiagnostics(),
|
|
1741
|
+
/** Development-only. No-ops (with a console warning) in a production build — see GeoPulseSdk.simulateLocation. */
|
|
1742
|
+
simulator: {
|
|
1743
|
+
setLocation: (coords) => sdk.simulateLocation(coords)
|
|
1744
|
+
}
|
|
1745
|
+
};
|
|
1746
|
+
// Annotate the CommonJS export names for ESM import in node:
|
|
1747
|
+
0 && (module.exports = {
|
|
1748
|
+
AlertExperience,
|
|
1749
|
+
ApplicationMismatchError,
|
|
1750
|
+
BannerExperience,
|
|
1751
|
+
CardExperience,
|
|
1752
|
+
DeviceLocationProvider,
|
|
1753
|
+
ExperienceView,
|
|
1754
|
+
ExpoPermissionProvider,
|
|
1755
|
+
GeoPulse,
|
|
1756
|
+
GeoPulseSdk,
|
|
1757
|
+
ManualNetworkMonitor,
|
|
1758
|
+
MessageExperience,
|
|
1759
|
+
NetInfoNetworkMonitor,
|
|
1760
|
+
SimulatedLocationProvider,
|
|
1761
|
+
SimulatedPermissionProvider,
|
|
1762
|
+
isAlertExperience
|
|
1763
|
+
});
|
|
1764
|
+
//# sourceMappingURL=index.js.map
|