@robono/location-react-native 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/expo.d.ts ADDED
@@ -0,0 +1,65 @@
1
+ import { LocationClient, type Position } from "@robono/location";
2
+ export interface TrackingContext {
3
+ sessionId: string;
4
+ expiresAt: string;
5
+ intervalSeconds: number;
6
+ stopping?: boolean;
7
+ nextAttemptAt?: number;
8
+ }
9
+ export interface TrackingStore {
10
+ load: () => Promise<TrackingContext | null>;
11
+ save: (value: TrackingContext | null) => Promise<void>;
12
+ }
13
+ export declare class LocationPermissionError extends Error {
14
+ permission: "foreground" | "background";
15
+ canAskAgain: boolean;
16
+ constructor(permission: "foreground" | "background", canAskAgain: boolean);
17
+ }
18
+ /** Locate the viewer for map framing only. This does not publish or retain a position. */
19
+ export declare function getForegroundPosition(signal?: AbortSignal): Promise<Position>;
20
+ /** User-requested high accuracy. Emits real fixes immediately and refines for at
21
+ * most 12 seconds. The returned radius is the provider's estimate, never inches
22
+ * inferred from coordinates. GPS stops on completion, cancellation or error. */
23
+ export declare function getPrecisePosition(options?: {
24
+ signal?: AbortSignal;
25
+ onPosition?: (position: Position) => void;
26
+ timeoutMs?: number;
27
+ targetAccuracyMeters?: number;
28
+ }): Promise<Position>;
29
+ /** Register once at module scope. Store only session metadata, never a network or provider API key. */
30
+ export declare function defineLocationTask(options: {
31
+ taskName: string;
32
+ store: TrackingStore;
33
+ getClient: () => Promise<Pick<LocationClient, "update" | "stop">>;
34
+ createId: () => string;
35
+ onError?: (error: unknown) => void;
36
+ onPrecision?: (position: Position) => void;
37
+ }): void;
38
+ export declare function stopLocationTask(taskName: string, store: TrackingStore, clear?: boolean): Promise<void>;
39
+ /** GPS starts only after explicit user action. Hosts persist auth independently of sharing metadata. */
40
+ export declare function createExpoLocationSharing(options: {
41
+ client: Pick<LocationClient, "start" | "update" | "stop">;
42
+ startWithPosition?: (input: {
43
+ grantId: string;
44
+ mode: "pin" | "live" | "ongoing";
45
+ durationMinutes?: number;
46
+ }, position: Position, requestId: string) => ReturnType<LocationClient["start"]>;
47
+ createId: () => string;
48
+ taskName?: string;
49
+ store?: TrackingStore;
50
+ notificationTitle?: string;
51
+ notificationBody?: string;
52
+ onError?: (error: unknown) => void;
53
+ onPrecision?: (position: Position) => void;
54
+ }): {
55
+ start(input: {
56
+ grantId: string;
57
+ mode: "pin" | "live" | "ongoing";
58
+ durationMinutes?: number;
59
+ intervalSeconds: number;
60
+ background?: boolean;
61
+ precise?: boolean;
62
+ }): Promise<import("@robono/location").LocationStart>;
63
+ stop(): Promise<void>;
64
+ dispose(): void;
65
+ };
package/dist/expo.js ADDED
@@ -0,0 +1,425 @@
1
+ import * as Location from "expo-location";
2
+ import * as TaskManager from "expo-task-manager";
3
+ import { createPositionPublisher, LocationError, validatePosition, bestPosition, } from "@robono/location";
4
+ function fix(value) {
5
+ return validatePosition({
6
+ latitude: value.coords.latitude,
7
+ longitude: value.coords.longitude,
8
+ accuracyMeters: value.coords.accuracy ?? 10000,
9
+ capturedAt: new Date(value.timestamp).toISOString(),
10
+ speedMps: value.coords.speed != null && value.coords.speed >= 0
11
+ ? value.coords.speed
12
+ : null,
13
+ headingDegrees: value.coords.heading != null && value.coords.heading >= 0
14
+ ? value.coords.heading
15
+ : null,
16
+ });
17
+ }
18
+ // A fixed pin needs a recent usable fix, not an indefinite high-accuracy GPS lock.
19
+ function currentPinPosition() {
20
+ let cancel = () => { };
21
+ const promise = new Promise((resolve, reject) => {
22
+ let settled = false;
23
+ let watch;
24
+ const finish = (error, position) => {
25
+ if (settled)
26
+ return;
27
+ settled = true;
28
+ clearTimeout(timer);
29
+ watch?.remove();
30
+ if (error)
31
+ reject(error);
32
+ else
33
+ resolve(position);
34
+ };
35
+ const timer = setTimeout(() => finish(Error("Could not get a precise location. Please try again.")), 10000);
36
+ cancel = () => finish(Error("Location sharing was cancelled."));
37
+ const accept = (value) => {
38
+ if (!value || settled)
39
+ return;
40
+ const age = Date.now() - value.timestamp;
41
+ if (age < -1000 || age > 10000 || value.coords.accuracy == null || value.coords.accuracy > 100)
42
+ return;
43
+ try {
44
+ finish(undefined, fix(value));
45
+ }
46
+ catch { /* Wait for a valid fix. */ }
47
+ };
48
+ void (async () => {
49
+ try {
50
+ accept(await Location.getLastKnownPositionAsync({ maxAge: 10000, requiredAccuracy: 100 }));
51
+ }
52
+ catch { /* A missing cached fix should still allow a fresh one. */ }
53
+ if (settled)
54
+ return;
55
+ try {
56
+ watch = await Location.watchPositionAsync({
57
+ accuracy: Location.Accuracy.Balanced,
58
+ timeInterval: 1000,
59
+ distanceInterval: 0,
60
+ }, accept, () => finish(Error("Could not get your location. Please try again.")));
61
+ // Some platforms deliver the first fix before returning the subscription.
62
+ if (settled)
63
+ watch.remove();
64
+ }
65
+ catch {
66
+ finish(Error("Could not get your location. Please try again."));
67
+ }
68
+ })();
69
+ });
70
+ return { promise, cancel: () => cancel() };
71
+ }
72
+ export class LocationPermissionError extends Error {
73
+ permission;
74
+ canAskAgain;
75
+ constructor(permission, canAskAgain) {
76
+ super(permission === "background"
77
+ ? "Allow background location to keep sharing when you leave the app or lock your phone."
78
+ : "Allow location access to share your location.");
79
+ this.permission = permission;
80
+ this.canAskAgain = canAskAgain;
81
+ this.name = "LocationPermissionError";
82
+ }
83
+ }
84
+ async function permission(background) {
85
+ const foreground = await Location.requestForegroundPermissionsAsync();
86
+ if (foreground.status !== "granted") {
87
+ throw new LocationPermissionError("foreground", foreground.canAskAgain);
88
+ }
89
+ if (background) {
90
+ const result = await Location.requestBackgroundPermissionsAsync();
91
+ if (result.status !== "granted") {
92
+ throw new LocationPermissionError("background", result.canAskAgain);
93
+ }
94
+ }
95
+ }
96
+ /** Locate the viewer for map framing only. This does not publish or retain a position. */
97
+ export async function getForegroundPosition(signal) {
98
+ if (signal?.aborted)
99
+ throw Error("Location request was cancelled.");
100
+ await permission(false);
101
+ if (signal?.aborted)
102
+ throw Error("Location request was cancelled.");
103
+ const pending = currentPinPosition();
104
+ const abort = () => pending.cancel();
105
+ signal?.addEventListener("abort", abort, { once: true });
106
+ try {
107
+ return await pending.promise;
108
+ }
109
+ finally {
110
+ signal?.removeEventListener("abort", abort);
111
+ }
112
+ }
113
+ /** User-requested high accuracy. Emits real fixes immediately and refines for at
114
+ * most 12 seconds. The returned radius is the provider's estimate, never inches
115
+ * inferred from coordinates. GPS stops on completion, cancellation or error. */
116
+ export async function getPrecisePosition(options = {}) {
117
+ const timeoutMs = options.timeoutMs ?? 12000, target = options.targetAccuracyMeters ?? 3;
118
+ if (!Number.isFinite(timeoutMs) || timeoutMs < 1000 || timeoutMs > 30000 || !Number.isFinite(target) || target <= 0 || target > 100)
119
+ throw Error('Invalid precision request.');
120
+ if (options.signal?.aborted)
121
+ throw Error('Location request was cancelled.');
122
+ await permission(false);
123
+ if (options.signal?.aborted)
124
+ throw Error('Location request was cancelled.');
125
+ return new Promise((resolve, reject) => {
126
+ let closed = false, watch;
127
+ const samples = [];
128
+ const finish = (error) => { if (closed)
129
+ return; closed = true; clearTimeout(timer); watch?.remove(); options.signal?.removeEventListener('abort', abort); const best = bestPosition(samples); if (error)
130
+ reject(error);
131
+ else if (best)
132
+ resolve(best);
133
+ else
134
+ reject(Error('A recent location is unavailable. Try again outdoors.')); };
135
+ const abort = () => finish(Error('Location request was cancelled.'));
136
+ const timer = setTimeout(() => finish(), timeoutMs);
137
+ options.signal?.addEventListener('abort', abort, { once: true });
138
+ void Location.watchPositionAsync({ accuracy: Location.Accuracy.BestForNavigation, timeInterval: 500, distanceInterval: 0 }, value => {
139
+ if (closed)
140
+ return;
141
+ try {
142
+ const p = fix(value);
143
+ samples.push(p);
144
+ if (samples.length > 100)
145
+ samples.shift();
146
+ const best = bestPosition(samples);
147
+ if (best) {
148
+ options.onPosition?.(best);
149
+ if (best.accuracyMeters <= target)
150
+ finish();
151
+ }
152
+ }
153
+ catch { /* Await a usable fix. */ }
154
+ }, () => finish()).then(value => { watch = value; if (closed)
155
+ value.remove(); }).catch(() => finish(Error('Could not obtain your location.')));
156
+ });
157
+ }
158
+ /** Register once at module scope. Store only session metadata, never a network or provider API key. */
159
+ export function defineLocationTask(options) {
160
+ if (TaskManager.isTaskDefined(options.taskName)) {
161
+ throw Error("This location task is already registered.");
162
+ }
163
+ TaskManager.defineTask(options.taskName, async ({ data, error }) => {
164
+ if (error) {
165
+ options.onError?.(error);
166
+ return;
167
+ }
168
+ const context = await options.store.load();
169
+ if (!context || Date.parse(context.expiresAt) <= Date.now()) {
170
+ await stopLocationTask(options.taskName, options.store);
171
+ return;
172
+ }
173
+ if (context.nextAttemptAt && context.nextAttemptAt > Date.now())
174
+ return;
175
+ if (context.stopping) {
176
+ try {
177
+ await (await options.getClient()).stop(context.sessionId);
178
+ await options.store.save(null);
179
+ }
180
+ catch (e) {
181
+ options.onError?.(e);
182
+ }
183
+ return;
184
+ }
185
+ const last = data?.locations?.at(-1);
186
+ if (!last)
187
+ return;
188
+ try {
189
+ const client = await options.getClient();
190
+ const current = await options.store.load();
191
+ if (current?.sessionId !== context.sessionId || current.stopping) {
192
+ return;
193
+ }
194
+ const response = await client.update(context.sessionId, fix(last), options.createId());
195
+ if (response?.stopped)
196
+ await stopLocationTask(options.taskName, options.store);
197
+ }
198
+ catch (e) {
199
+ if (e instanceof LocationError && [401, 403, 404, 410].includes(e.status))
200
+ await stopLocationTask(options.taskName, options.store);
201
+ else if (e instanceof LocationError && e.retryAfterMs) {
202
+ const current = await options.store.load();
203
+ if (current?.sessionId === context.sessionId && !current.stopping) {
204
+ await options.store.save({
205
+ ...current,
206
+ nextAttemptAt: Date.now() + e.retryAfterMs,
207
+ });
208
+ }
209
+ }
210
+ options.onError?.(e);
211
+ }
212
+ });
213
+ }
214
+ export async function stopLocationTask(taskName, store, clear = true) {
215
+ try {
216
+ if (await Location.hasStartedLocationUpdatesAsync(taskName)) {
217
+ await Location.stopLocationUpdatesAsync(taskName);
218
+ }
219
+ }
220
+ finally {
221
+ if (clear)
222
+ await store.save(null);
223
+ }
224
+ }
225
+ /** GPS starts only after explicit user action. Hosts persist auth independently of sharing metadata. */
226
+ export function createExpoLocationSharing(options) {
227
+ let subscription, publisher, sessionId, pendingStop, busy = false, epoch = 0;
228
+ let pendingPin;
229
+ let precisionAbort;
230
+ const endGps = () => {
231
+ precisionAbort?.abort();
232
+ precisionAbort = undefined;
233
+ pendingPin?.cancel();
234
+ pendingPin = undefined;
235
+ subscription?.remove();
236
+ subscription = undefined;
237
+ publisher?.stop();
238
+ publisher = undefined;
239
+ };
240
+ return {
241
+ async start(input) {
242
+ if (busy || sessionId)
243
+ throw Error("Location sharing is already active.");
244
+ busy = true;
245
+ const run = ++epoch;
246
+ const check = () => {
247
+ if (run !== epoch)
248
+ throw Error("Location sharing was cancelled.");
249
+ };
250
+ try {
251
+ const previous = await options.store?.load();
252
+ if (previous?.stopping || pendingStop) {
253
+ await options.client.stop(pendingStop ?? previous.sessionId);
254
+ pendingStop = undefined;
255
+ await options.store?.save(null);
256
+ }
257
+ else if (previous && Date.parse(previous.expiresAt) > Date.now()) {
258
+ throw Error("Location sharing is already active.");
259
+ }
260
+ check();
261
+ const background = !!input.background && input.mode !== "pin";
262
+ if (background &&
263
+ (!options.taskName || !options.store ||
264
+ !TaskManager.isTaskDefined(options.taskName))) {
265
+ throw Error("Register the background location task before starting sharing.");
266
+ }
267
+ await permission(background);
268
+ check();
269
+ let first;
270
+ if (input.precise) {
271
+ precisionAbort = new AbortController();
272
+ try {
273
+ first = await getPrecisePosition({ onPosition: options.onPrecision, signal: precisionAbort.signal });
274
+ }
275
+ finally {
276
+ precisionAbort = undefined;
277
+ }
278
+ }
279
+ else if (input.mode === "pin") {
280
+ pendingPin = currentPinPosition();
281
+ first = await pendingPin.promise;
282
+ pendingPin = undefined;
283
+ }
284
+ else {
285
+ first = fix(await Location.getCurrentPositionAsync({ accuracy: Location.Accuracy.High }));
286
+ }
287
+ check();
288
+ const requestId = options.createId();
289
+ const result = options.startWithPosition
290
+ ? await options.startWithPosition(input, first, requestId)
291
+ : await options.client.start(input.grantId, input.mode, requestId, input.durationMinutes);
292
+ if (result.stopped)
293
+ throw Error("This location share has ended.");
294
+ sessionId = result.sessionId;
295
+ check();
296
+ if (!options.startWithPosition)
297
+ await options.client.update(sessionId, first, options.createId());
298
+ check();
299
+ if (input.mode === "pin") {
300
+ sessionId = undefined;
301
+ return result;
302
+ }
303
+ if (background) {
304
+ await options.store.save({
305
+ sessionId: sessionId,
306
+ expiresAt: result.expiresAt,
307
+ intervalSeconds: input.intervalSeconds,
308
+ });
309
+ await Location.startLocationUpdatesAsync(options.taskName, {
310
+ accuracy: Location.Accuracy.Balanced,
311
+ timeInterval: Math.max(5000, input.intervalSeconds * 1000),
312
+ distanceInterval: 0,
313
+ deferredUpdatesInterval: Math.max(5000, input.intervalSeconds * 1000),
314
+ pausesUpdatesAutomatically: false,
315
+ showsBackgroundLocationIndicator: true,
316
+ foregroundService: {
317
+ notificationTitle: options.notificationTitle ??
318
+ "Location sharing is active",
319
+ notificationBody: options.notificationBody ??
320
+ "Open the app to stop sharing.",
321
+ killServiceOnDestroy: true,
322
+ },
323
+ });
324
+ }
325
+ else {
326
+ publisher = createPositionPublisher({ update: async (...args) => {
327
+ const result = await options.client.update(...args);
328
+ if (result?.stopped) {
329
+ endGps();
330
+ sessionId = undefined;
331
+ await options.store?.save(null);
332
+ }
333
+ return result;
334
+ } }, {
335
+ sessionId: sessionId,
336
+ intervalMs: input.intervalSeconds * 1000,
337
+ createId: options.createId,
338
+ onError: (e) => {
339
+ if (e instanceof LocationError &&
340
+ [401, 403, 404, 410].includes(e.status)) {
341
+ endGps();
342
+ sessionId = undefined;
343
+ }
344
+ options.onError?.(e);
345
+ },
346
+ });
347
+ subscription = await Location.watchPositionAsync({
348
+ accuracy: Location.Accuracy.Balanced,
349
+ timeInterval: Math.max(5000, input.intervalSeconds * 1000),
350
+ distanceInterval: 0,
351
+ }, (value) => {
352
+ if (Date.parse(result.expiresAt) <= Date.now()) {
353
+ endGps();
354
+ sessionId = undefined;
355
+ return;
356
+ }
357
+ try {
358
+ publisher?.push(fix(value));
359
+ }
360
+ catch (e) {
361
+ options.onError?.(e);
362
+ }
363
+ }, (message) => options.onError?.(Error(message)));
364
+ }
365
+ check();
366
+ return result;
367
+ }
368
+ catch (e) {
369
+ endGps();
370
+ const failed = sessionId;
371
+ sessionId = undefined;
372
+ if (failed && options.taskName && options.store) {
373
+ await stopLocationTask(options.taskName, options.store).catch(() => { });
374
+ }
375
+ if (failed) {
376
+ try {
377
+ await options.client.stop(failed);
378
+ }
379
+ catch {
380
+ pendingStop = failed;
381
+ await options.store?.save({
382
+ sessionId: failed,
383
+ expiresAt: new Date(Date.now() + 86400000).toISOString(),
384
+ intervalSeconds: 15,
385
+ stopping: true,
386
+ });
387
+ }
388
+ }
389
+ throw e;
390
+ }
391
+ finally {
392
+ busy = false;
393
+ }
394
+ },
395
+ async stop() {
396
+ epoch++;
397
+ endGps();
398
+ if (options.taskName && options.store) {
399
+ await stopLocationTask(options.taskName, options.store, false);
400
+ }
401
+ const saved = await options.store?.load();
402
+ const current = sessionId ?? pendingStop ?? saved?.sessionId;
403
+ sessionId = undefined;
404
+ pendingStop = current;
405
+ if (current && options.store) {
406
+ await options.store.save({
407
+ ...saved,
408
+ sessionId: current,
409
+ expiresAt: saved?.expiresAt ??
410
+ new Date(Date.now() + 86400000).toISOString(),
411
+ intervalSeconds: saved?.intervalSeconds ?? 15,
412
+ stopping: true,
413
+ });
414
+ }
415
+ if (current) {
416
+ await options.client.stop(current);
417
+ pendingStop = undefined;
418
+ await options.store?.save(null);
419
+ }
420
+ },
421
+ dispose() {
422
+ endGps();
423
+ },
424
+ };
425
+ }
@@ -0,0 +1,4 @@
1
+ export { LocationMessage, LocationScreen, type LocationScreenProps, } from "./LocationScreen.js";
2
+ export { RobonoMapView } from "./MapView.js";
3
+ export { type GeofenceDraft, GeofenceEditor } from "./GeofenceEditor.js";
4
+ export { AvatarLocationMap } from './AvatarLocationMap.js';
package/dist/index.js ADDED
@@ -0,0 +1,4 @@
1
+ export { LocationMessage, LocationScreen, } from "./LocationScreen.js";
2
+ export { RobonoMapView } from "./MapView.js";
3
+ export { GeofenceEditor } from "./GeofenceEditor.js";
4
+ export { AvatarLocationMap } from './AvatarLocationMap.js';
@@ -0,0 +1,16 @@
1
+ import { createMovementSharing, type MotionReading } from '@robono/location';
2
+ /** Explicitly start after the person enables movement sharing. Raw readings stay
3
+ * on the device. Foreground only: stale/missing readings must display unavailable. */
4
+ export declare function observeDeviceMovement(options: {
5
+ onReading: (reading: MotionReading) => void;
6
+ onError?: (error: Error) => void;
7
+ }): Promise<{
8
+ stop(): void;
9
+ }>;
10
+ /** Ready-to-use publisher with explicit consent and durable stop recovery. */
11
+ export { finishPendingMovementStop } from '@robono/location';
12
+ export declare function createExpoMovementSharing(options: Omit<Parameters<typeof createMovementSharing>[0], 'observe'>): {
13
+ stop: () => Promise<void>;
14
+ recover: () => Promise<void>;
15
+ start(id: string): Promise<void>;
16
+ };
package/dist/motion.js ADDED
@@ -0,0 +1,45 @@
1
+ import { AppState } from 'react-native';
2
+ import { DeviceMotion } from 'expo-sensors';
3
+ import { createMovementSharing, classifyMotion } from '@robono/location';
4
+ /** Explicitly start after the person enables movement sharing. Raw readings stay
5
+ * on the device. Foreground only: stale/missing readings must display unavailable. */
6
+ export async function observeDeviceMovement(options) {
7
+ if (!await DeviceMotion.isAvailableAsync())
8
+ throw Error('Movement sensors are unavailable on this device.');
9
+ if (!(await DeviceMotion.requestPermissionsAsync()).granted)
10
+ throw Error('Movement access was not granted.');
11
+ let stopped = false, samples = [], subscription, last = 0;
12
+ const pause = () => { subscription?.remove(); subscription = undefined; samples = []; last = 0; };
13
+ const start = () => {
14
+ if (stopped || AppState.currentState !== 'active' || subscription)
15
+ return;
16
+ DeviceMotion.setUpdateInterval(100);
17
+ subscription = DeviceMotion.addListener(value => {
18
+ if (!value.acceleration || !value.rotationRate)
19
+ return;
20
+ const now = Date.now(), a = value.acceleration, r = value.rotationRate;
21
+ samples.push({ timestampMs: now, acceleration: { x: a.x, y: a.y, z: a.z }, rotationRate: { x: r.beta, y: r.gamma, z: r.alpha } });
22
+ samples = samples.filter(s => now - s.timestampMs <= 5000).slice(-100);
23
+ if (samples.length >= 30 && now - last >= 5000) {
24
+ last = now;
25
+ try {
26
+ options.onReading(classifyMotion(samples));
27
+ }
28
+ catch (e) {
29
+ options.onError?.(e instanceof Error ? e : Error(String(e)));
30
+ }
31
+ }
32
+ });
33
+ };
34
+ const state = AppState.addEventListener('change', value => { pause(); if (value === 'active')
35
+ start();
36
+ else
37
+ options.onReading(classifyMotion([])); });
38
+ start();
39
+ return { stop() { stopped = true; pause(); state.remove(); } };
40
+ }
41
+ /** Ready-to-use publisher with explicit consent and durable stop recovery. */
42
+ export { finishPendingMovementStop } from '@robono/location';
43
+ export function createExpoMovementSharing(options) {
44
+ return createMovementSharing({ ...options, observe: observeDeviceMovement });
45
+ }