expo-background-tracking 1.0.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.
@@ -0,0 +1,1032 @@
1
+ import * as ExpoLocation from 'expo-location';
2
+ import * as TaskManager from 'expo-task-manager';
3
+ import * as Battery from 'expo-battery';
4
+ import * as Network from 'expo-network';
5
+ import * as Device from 'expo-device';
6
+ import { Platform } from 'react-native';
7
+ import { Accelerometer, Gyroscope, Magnetometer } from 'expo-sensors';
8
+
9
+ import { database } from './Database';
10
+ import { logger } from './Logger';
11
+ import { EventBus } from './EventBus';
12
+ import { HttpService } from './HttpService';
13
+ import { MotionDetector } from './MotionDetector';
14
+ import { GeofenceManager, GEOFENCE_TASK } from './GeofenceManager';
15
+ import { Scheduler } from './Scheduler';
16
+ import { haversine, uuidv4 } from './geo';
17
+ import { isBackgroundCapable, isExpoGo } from './environment';
18
+ import {
19
+ AccuracyAuthorization,
20
+ AuthorizationStatus,
21
+ DesiredAccuracy,
22
+ LogLevel,
23
+ PersistMode,
24
+ type ActivityChangeEvent,
25
+ type Config,
26
+ type ConnectivityChangeEvent,
27
+ type CurrentPositionRequest,
28
+ type DeviceInfo,
29
+ type GeofencesChangeEvent,
30
+ type Geofence,
31
+ type GeofenceEvent,
32
+ type HeadlessEvent,
33
+ type HeadlessTask,
34
+ type HeartbeatEvent,
35
+ type HttpEvent,
36
+ type Location,
37
+ type MotionChangeEvent,
38
+ type ProviderChangeEvent,
39
+ type ScheduleEvent,
40
+ type Sensors,
41
+ type SQLQuery,
42
+ type State,
43
+ type Subscription,
44
+ type WatchPositionRequest,
45
+ type AuthorizationEvent,
46
+ } from './types';
47
+
48
+ export const LOCATION_TASK = 'expo-background-tracking.location';
49
+
50
+ const DEFAULT_CONFIG: Config = {
51
+ desiredAccuracy: DesiredAccuracy.HIGH,
52
+ distanceFilter: 10,
53
+ stationaryRadius: 25,
54
+ stopTimeout: 5,
55
+ motionTriggerDelay: 0,
56
+ disableMotionActivityUpdates: false,
57
+ disableStopDetection: false,
58
+ isMoving: false,
59
+ elasticityMultiplier: 1,
60
+ disableElasticity: false,
61
+ geofenceProximityRadius: 1000,
62
+ heartbeatInterval: 60,
63
+ autoSync: true,
64
+ autoSyncThreshold: 0,
65
+ batchSync: false,
66
+ maxBatchSize: -1,
67
+ method: 'POST',
68
+ httpRootProperty: 'location',
69
+ httpTimeout: 60_000,
70
+ maxDaysToPersist: 1,
71
+ maxRecordsToPersist: -1,
72
+ persistMode: PersistMode.ALL,
73
+ stopOnTerminate: true,
74
+ startOnBoot: false,
75
+ debug: false,
76
+ logLevel: LogLevel.INFO,
77
+ logMaxDays: 3,
78
+ locationAuthorizationRequest: 'Always',
79
+ foregroundService: true,
80
+ };
81
+
82
+ class BackgroundGeolocationImpl {
83
+ // re-exported enums (parity with transistorsoft statics)
84
+ readonly DESIRED_ACCURACY_NAVIGATION = DesiredAccuracy.NAVIGATION;
85
+ readonly DESIRED_ACCURACY_HIGH = DesiredAccuracy.HIGH;
86
+ readonly DESIRED_ACCURACY_MEDIUM = DesiredAccuracy.MEDIUM;
87
+ readonly DESIRED_ACCURACY_LOW = DesiredAccuracy.LOW;
88
+ readonly DESIRED_ACCURACY_VERY_LOW = DesiredAccuracy.VERY_LOW;
89
+ readonly LOG_LEVEL_OFF = LogLevel.OFF;
90
+ readonly LOG_LEVEL_ERROR = LogLevel.ERROR;
91
+ readonly LOG_LEVEL_WARNING = LogLevel.WARNING;
92
+ readonly LOG_LEVEL_INFO = LogLevel.INFO;
93
+ readonly LOG_LEVEL_DEBUG = LogLevel.DEBUG;
94
+ readonly LOG_LEVEL_VERBOSE = LogLevel.VERBOSE;
95
+ readonly PERSIST_MODE_ALL = PersistMode.ALL;
96
+ readonly PERSIST_MODE_LOCATION = PersistMode.LOCATION;
97
+ readonly PERSIST_MODE_GEOFENCE = PersistMode.GEOFENCE;
98
+ readonly PERSIST_MODE_NONE = PersistMode.NONE;
99
+ readonly AUTHORIZATION_STATUS_ALWAYS = AuthorizationStatus.ALWAYS;
100
+ readonly AUTHORIZATION_STATUS_WHEN_IN_USE = AuthorizationStatus.WHEN_IN_USE;
101
+ readonly AUTHORIZATION_STATUS_DENIED = AuthorizationStatus.DENIED;
102
+
103
+ readonly logger = logger;
104
+
105
+ private bus = new EventBus();
106
+ private http = new HttpService(this.bus);
107
+ private geofenceManager = new GeofenceManager(this.bus);
108
+ private motion: MotionDetector;
109
+ private scheduler: Scheduler;
110
+
111
+ private config: Config = { ...DEFAULT_CONFIG };
112
+ private enabled = false;
113
+ private trackingMode: 0 | 1 = 1;
114
+ private odometer = 0;
115
+ private lastLocation: Location | null = null;
116
+ private lastPersistedCoords: { latitude: number; longitude: number } | null = null;
117
+ private isReady = false;
118
+
119
+ private foregroundSub: ExpoLocation.LocationSubscription | null = null;
120
+ private watchSub: ExpoLocation.LocationSubscription | null = null;
121
+ private heartbeatTimer: ReturnType<typeof setInterval> | null = null;
122
+ private providerTimer: ReturnType<typeof setInterval> | null = null;
123
+ private powerSaveSub: { remove(): void } | null = null;
124
+ private lastProviderState: string | null = null;
125
+ private headlessTask: HeadlessTask | null = null;
126
+ private bgTaskCounter = 0;
127
+
128
+ constructor() {
129
+ this.motion = new MotionDetector({
130
+ stopTimeoutMinutes: DEFAULT_CONFIG.stopTimeout!,
131
+ motionTriggerDelayMs: DEFAULT_CONFIG.motionTriggerDelay!,
132
+ disabled: false,
133
+ onMotionChange: (isMoving) => void this.handleMotionChange(isMoving),
134
+ onActivityChange: (activity) => this.bus.emit('activitychange', activity),
135
+ });
136
+ this.scheduler = new Scheduler((scheduleEnabled) => {
137
+ void (async () => {
138
+ if (scheduleEnabled && !this.enabled) await this.start();
139
+ else if (!scheduleEnabled && this.enabled) await this.stop();
140
+ const state = await this.getState();
141
+ this.bus.emit('schedule', { enabled: scheduleEnabled, state } satisfies ScheduleEvent);
142
+ })();
143
+ });
144
+ try {
145
+ this.defineTasks();
146
+ } catch (e) {
147
+ // never crash the app at import time (e.g. exotic environments)
148
+ console.warn('[expo-background-tracking] defineTasks failed:', e);
149
+ }
150
+ }
151
+
152
+ // -------------------------------------------------------------------------
153
+ // TaskManager background tasks (effective in dev builds; inert in Expo Go)
154
+ // -------------------------------------------------------------------------
155
+
156
+ private defineTasks(): void {
157
+ if (!TaskManager.isTaskDefined(LOCATION_TASK)) {
158
+ TaskManager.defineTask(LOCATION_TASK, async ({ data, error }) => {
159
+ if (error) {
160
+ logger.error(`Background location task error: ${error.message}`);
161
+ return;
162
+ }
163
+ const { locations } = (data ?? {}) as { locations?: ExpoLocation.LocationObject[] };
164
+ if (!locations?.length) return;
165
+ for (const raw of locations) {
166
+ const location = await this.buildLocation(raw);
167
+ await this.processLocation(location);
168
+ await this.dispatchHeadless({ name: 'location', params: location });
169
+ }
170
+ });
171
+ }
172
+ if (!TaskManager.isTaskDefined(GEOFENCE_TASK)) {
173
+ TaskManager.defineTask(GEOFENCE_TASK, async ({ data, error }) => {
174
+ if (error) {
175
+ logger.error(`Geofence task error: ${error.message}`);
176
+ return;
177
+ }
178
+ const event = data as {
179
+ eventType: ExpoLocation.GeofencingEventType;
180
+ region: ExpoLocation.LocationRegion;
181
+ };
182
+ const action =
183
+ event.eventType === ExpoLocation.GeofencingEventType.Enter ? 'ENTER' : 'EXIT';
184
+ const location =
185
+ this.lastLocation ?? (await this.fetchCurrentLocation().catch(() => null));
186
+ if (location && event.region.identifier) {
187
+ await this.geofenceManager.onNativeEvent(event.region.identifier, action, location);
188
+ await this.dispatchHeadless({
189
+ name: 'geofence',
190
+ params: { identifier: event.region.identifier, action, location },
191
+ });
192
+ }
193
+ });
194
+ }
195
+ }
196
+
197
+ private async dispatchHeadless(event: HeadlessEvent): Promise<void> {
198
+ if (this.headlessTask) {
199
+ try {
200
+ await this.headlessTask(event);
201
+ } catch (e) {
202
+ logger.error(`Headless task error: ${String(e)}`);
203
+ }
204
+ }
205
+ }
206
+
207
+ // -------------------------------------------------------------------------
208
+ // Lifecycle
209
+ // -------------------------------------------------------------------------
210
+
211
+ async ready(config: Config = {}): Promise<State> {
212
+ if (this.isReady && !config.reset) return this.getState();
213
+
214
+ const persisted = config.reset
215
+ ? null
216
+ : await database.getKV<Config>('config');
217
+ this.config = { ...DEFAULT_CONFIG, ...(persisted ?? {}), ...config };
218
+ await database.setKV('config', this.config);
219
+
220
+ logger.level = this.config.logLevel ?? LogLevel.INFO;
221
+ logger.debug = this.config.debug ?? false;
222
+ this.http.setConfig(this.config);
223
+ this.geofenceManager.setProximityRadius(this.config.geofenceProximityRadius ?? 1000);
224
+ this.motion.setOptions({
225
+ stopTimeoutMinutes: this.config.stopTimeout ?? 5,
226
+ motionTriggerDelayMs: this.config.motionTriggerDelay ?? 0,
227
+ disabled: this.config.disableMotionActivityUpdates ?? false,
228
+ });
229
+ if (this.config.schedule?.length) this.scheduler.setSchedule(this.config.schedule);
230
+
231
+ this.odometer = (await database.getKV<number>('odometer')) ?? 0;
232
+ const wasEnabled = (await database.getKV<boolean>('enabled')) ?? false;
233
+ this.trackingMode = ((await database.getKV<number>('trackingMode')) ?? 1) as 0 | 1;
234
+
235
+ await database.prune(
236
+ this.config.maxDaysToPersist ?? 1,
237
+ this.config.maxRecordsToPersist ?? -1
238
+ );
239
+ await database.pruneLogs(this.config.logMaxDays ?? 3);
240
+
241
+ this.http.startConnectivityMonitoring();
242
+ this.startProviderMonitoring();
243
+ void this.startPowerSaveMonitoring();
244
+
245
+ this.isReady = true;
246
+ logger.info(
247
+ `ready() — env=${isExpoGo() ? 'Expo Go (foreground-only)' : 'dev build (background OK)'}`
248
+ );
249
+
250
+ if (wasEnabled && this.config.stopOnTerminate === false) {
251
+ // restore tracking after app relaunch
252
+ if (this.trackingMode === 1) await this.start();
253
+ else await this.startGeofences();
254
+ }
255
+ return this.getState();
256
+ }
257
+
258
+ async start(): Promise<State> {
259
+ this.assertReady('start');
260
+ this.trackingMode = 1;
261
+ await database.setKV('trackingMode', 1);
262
+ await this.requestPermission();
263
+
264
+ await this.startForegroundWatch();
265
+ await this.startBackgroundUpdates();
266
+ await this.geofenceManager.start();
267
+ await this.motion.start();
268
+ this.startHeartbeat();
269
+
270
+ this.setEnabled(true);
271
+
272
+ // fire an immediate motionchange location, like transistorsoft
273
+ const location = await this.fetchCurrentLocation().catch(() => null);
274
+ if (location) {
275
+ location.event = 'motionchange';
276
+ await this.processLocation(location);
277
+ }
278
+ return this.getState();
279
+ }
280
+
281
+ async stop(): Promise<State> {
282
+ this.foregroundSub?.remove();
283
+ this.foregroundSub = null;
284
+ this.stopHeartbeat();
285
+ this.motion.stop();
286
+ await this.geofenceManager.stop();
287
+ if (isBackgroundCapable()) {
288
+ try {
289
+ const started = await ExpoLocation.hasStartedLocationUpdatesAsync(LOCATION_TASK);
290
+ if (started) await ExpoLocation.stopLocationUpdatesAsync(LOCATION_TASK);
291
+ } catch {
292
+ /* ignore */
293
+ }
294
+ }
295
+ this.setEnabled(false);
296
+ return this.getState();
297
+ }
298
+
299
+ /** Geofences-only mode: no continuous location tracking. */
300
+ async startGeofences(): Promise<State> {
301
+ this.assertReady('startGeofences');
302
+ this.trackingMode = 0;
303
+ await database.setKV('trackingMode', 0);
304
+ await this.requestPermission();
305
+ await this.geofenceManager.start();
306
+ // low-power significant-movement watch to evaluate software geofences
307
+ await this.startForegroundWatch(/* lowPower */ true);
308
+ this.setEnabled(true);
309
+ return this.getState();
310
+ }
311
+
312
+ startSchedule(): Promise<State> {
313
+ this.assertReady('startSchedule');
314
+ if (this.config.schedule?.length) this.scheduler.setSchedule(this.config.schedule);
315
+ this.scheduler.start();
316
+ return this.getState();
317
+ }
318
+
319
+ stopSchedule(): Promise<State> {
320
+ this.scheduler.stop();
321
+ return this.getState();
322
+ }
323
+
324
+ async changePace(isMoving: boolean): Promise<void> {
325
+ this.assertReady('changePace');
326
+ this.motion.setMoving(isMoving);
327
+ }
328
+
329
+ private setEnabled(enabled: boolean): void {
330
+ if (this.enabled === enabled) return;
331
+ this.enabled = enabled;
332
+ void database.setKV('enabled', enabled);
333
+ this.bus.emit('enabledchange', enabled);
334
+ logger.info(`enabledchange: ${enabled}`);
335
+ }
336
+
337
+ private assertReady(method: string): void {
338
+ if (!this.isReady) {
339
+ throw new Error(`BackgroundGeolocation.${method}() called before ready()`);
340
+ }
341
+ }
342
+
343
+ // -------------------------------------------------------------------------
344
+ // Location streams
345
+ // -------------------------------------------------------------------------
346
+
347
+ private mapAccuracy(a?: DesiredAccuracy): ExpoLocation.LocationAccuracy {
348
+ switch (a) {
349
+ case DesiredAccuracy.NAVIGATION:
350
+ return ExpoLocation.LocationAccuracy.BestForNavigation;
351
+ case DesiredAccuracy.HIGH:
352
+ return ExpoLocation.LocationAccuracy.Highest;
353
+ case DesiredAccuracy.MEDIUM:
354
+ return ExpoLocation.LocationAccuracy.Balanced;
355
+ case DesiredAccuracy.LOW:
356
+ return ExpoLocation.LocationAccuracy.Low;
357
+ case DesiredAccuracy.VERY_LOW:
358
+ case DesiredAccuracy.LOWEST:
359
+ return ExpoLocation.LocationAccuracy.Lowest;
360
+ default:
361
+ return ExpoLocation.LocationAccuracy.High;
362
+ }
363
+ }
364
+
365
+ private async startForegroundWatch(lowPower = false): Promise<void> {
366
+ this.foregroundSub?.remove();
367
+ this.foregroundSub = await ExpoLocation.watchPositionAsync(
368
+ {
369
+ accuracy: lowPower
370
+ ? ExpoLocation.LocationAccuracy.Balanced
371
+ : this.mapAccuracy(this.config.desiredAccuracy),
372
+ distanceInterval: this.effectiveDistanceFilter(),
373
+ timeInterval: this.config.deferTime,
374
+ },
375
+ (raw) => {
376
+ void (async () => {
377
+ const location = await this.buildLocation(raw);
378
+ await this.processLocation(location);
379
+ })();
380
+ }
381
+ );
382
+ }
383
+
384
+ private async startBackgroundUpdates(): Promise<void> {
385
+ if (!isBackgroundCapable()) {
386
+ logger.warn(
387
+ 'Expo Go detected: background tracking unavailable (foreground-only). ' +
388
+ 'Use a development build for full background support.'
389
+ );
390
+ return;
391
+ }
392
+ try {
393
+ await ExpoLocation.startLocationUpdatesAsync(LOCATION_TASK, {
394
+ accuracy: this.mapAccuracy(this.config.desiredAccuracy),
395
+ distanceInterval: this.effectiveDistanceFilter(),
396
+ deferredUpdatesInterval: this.config.deferTime,
397
+ pausesUpdatesAutomatically: this.config.pausesLocationUpdatesAutomatically,
398
+ showsBackgroundLocationIndicator: this.config.showsBackgroundLocationIndicator,
399
+ activityType: this.config.activityType as unknown as ExpoLocation.LocationActivityType,
400
+ foregroundService:
401
+ Platform.OS === 'android' && this.config.foregroundService !== false
402
+ ? {
403
+ notificationTitle:
404
+ this.config.notification?.title ?? 'Location tracking',
405
+ notificationBody:
406
+ this.config.notification?.text ?? 'Tracking is active',
407
+ notificationColor: this.config.notification?.color,
408
+ killServiceOnDestroy: this.config.stopOnTerminate !== false,
409
+ }
410
+ : undefined,
411
+ });
412
+ logger.info('Background location updates started');
413
+ } catch (e) {
414
+ logger.error(`startLocationUpdatesAsync failed: ${String(e)}`);
415
+ }
416
+ }
417
+
418
+ private effectiveDistanceFilter(): number {
419
+ const base = this.config.distanceFilter ?? 10;
420
+ if (this.config.disableElasticity) return base;
421
+ const speed = this.lastLocation?.coords.speed ?? 0;
422
+ if (speed <= 0) return base;
423
+ // elasticity: scale filter with speed, like transistorsoft
424
+ const multiplier = this.config.elasticityMultiplier ?? 1;
425
+ return Math.round(base + (Math.max(0, speed) * multiplier * base) / 10);
426
+ }
427
+
428
+ private async buildLocation(
429
+ raw: ExpoLocation.LocationObject,
430
+ extras?: Record<string, unknown>,
431
+ sample = false
432
+ ): Promise<Location> {
433
+ let batteryLevel = -1;
434
+ let charging = false;
435
+ try {
436
+ batteryLevel = await Battery.getBatteryLevelAsync();
437
+ const state = await Battery.getBatteryStateAsync();
438
+ charging =
439
+ state === Battery.BatteryState.CHARGING ||
440
+ state === Battery.BatteryState.FULL;
441
+ } catch {
442
+ /* battery info unavailable */
443
+ }
444
+
445
+ const prev = this.lastPersistedCoords;
446
+ if (prev && this.motion.moving && !sample) {
447
+ this.odometer += haversine(
448
+ prev.latitude,
449
+ prev.longitude,
450
+ raw.coords.latitude,
451
+ raw.coords.longitude
452
+ );
453
+ }
454
+
455
+ this.motion.feedSpeed(raw.coords.speed);
456
+
457
+ return {
458
+ uuid: uuidv4(),
459
+ timestamp: new Date(raw.timestamp).toISOString(),
460
+ odometer: Math.round(this.odometer * 100) / 100,
461
+ is_moving: this.motion.moving,
462
+ coords: {
463
+ latitude: raw.coords.latitude,
464
+ longitude: raw.coords.longitude,
465
+ accuracy: raw.coords.accuracy ?? -1,
466
+ speed: raw.coords.speed ?? -1,
467
+ heading: raw.coords.heading ?? -1,
468
+ altitude: raw.coords.altitude ?? -1,
469
+ altitude_accuracy: raw.coords.altitudeAccuracy ?? undefined,
470
+ },
471
+ activity: { activity: this.motion.moving ? 'on_foot' : 'still', confidence: 75 },
472
+ battery: { level: batteryLevel, is_charging: charging },
473
+ extras: { ...(this.config.extras ?? {}), ...(extras ?? {}) },
474
+ mock: (raw as { mocked?: boolean }).mocked,
475
+ sample,
476
+ };
477
+ }
478
+
479
+ private shouldPersist(location: Location): boolean {
480
+ if (location.sample) return false;
481
+ const mode = this.config.persistMode ?? PersistMode.ALL;
482
+ if (mode === PersistMode.NONE) return false;
483
+ if (mode === PersistMode.GEOFENCE && location.event !== 'geofence') return false;
484
+ if (mode === PersistMode.LOCATION && location.event === 'geofence') return false;
485
+ return true;
486
+ }
487
+
488
+ private async processLocation(location: Location): Promise<void> {
489
+ this.lastLocation = location;
490
+ this.lastPersistedCoords = {
491
+ latitude: location.coords.latitude,
492
+ longitude: location.coords.longitude,
493
+ };
494
+ await database.setKV('odometer', this.odometer);
495
+
496
+ if (this.shouldPersist(location)) {
497
+ await database.insertLocation(location);
498
+ await database.prune(
499
+ this.config.maxDaysToPersist ?? 1,
500
+ this.config.maxRecordsToPersist ?? -1
501
+ );
502
+ }
503
+
504
+ this.bus.emit('location', location);
505
+ await this.geofenceManager.evaluate(location);
506
+
507
+ if (this.config.url && this.config.autoSync !== false) {
508
+ void this.http.autoSync().catch(() => undefined);
509
+ }
510
+ }
511
+
512
+ private async handleMotionChange(isMoving: boolean): Promise<void> {
513
+ logger.info(`motionchange: ${isMoving ? 'moving' : 'stationary'}`);
514
+ const location =
515
+ (await this.fetchCurrentLocation().catch(() => null)) ?? this.lastLocation;
516
+ if (location) {
517
+ location.event = 'motionchange';
518
+ location.is_moving = isMoving;
519
+ this.bus.emit('motionchange', { isMoving, location } satisfies MotionChangeEvent);
520
+ if (this.enabled) await this.processLocation(location);
521
+ }
522
+ // battery optimization: relax accuracy while stationary
523
+ if (this.enabled && this.trackingMode === 1) {
524
+ if (!isMoving && this.config.stopOnStationary) {
525
+ await this.stop();
526
+ } else {
527
+ await this.startForegroundWatch(!isMoving);
528
+ }
529
+ }
530
+ }
531
+
532
+ private async fetchCurrentLocation(): Promise<Location> {
533
+ const raw = await ExpoLocation.getCurrentPositionAsync({
534
+ accuracy: this.mapAccuracy(this.config.desiredAccuracy),
535
+ });
536
+ return this.buildLocation(raw);
537
+ }
538
+
539
+ // -------------------------------------------------------------------------
540
+ // getCurrentPosition / watchPosition
541
+ // -------------------------------------------------------------------------
542
+
543
+ async getCurrentPosition(options: CurrentPositionRequest = {}): Promise<Location> {
544
+ const { timeout = 30, maximumAge = 0, samples = 3, persist = true } = options;
545
+
546
+ if (maximumAge > 0 && this.lastLocation) {
547
+ const age = Date.now() - Date.parse(this.lastLocation.timestamp);
548
+ if (age <= maximumAge) return this.lastLocation;
549
+ }
550
+
551
+ const fetchOne = async (): Promise<Location> => {
552
+ const raw = await ExpoLocation.getCurrentPositionAsync({
553
+ accuracy: this.mapAccuracy(this.config.desiredAccuracy),
554
+ });
555
+ return this.buildLocation(raw, options.extras, true);
556
+ };
557
+
558
+ const withTimeout = <T>(p: Promise<T>): Promise<T> =>
559
+ Promise.race([
560
+ p,
561
+ new Promise<T>((_, reject) =>
562
+ setTimeout(() => reject(Object.assign(new Error('timeout'), { code: 408 })), timeout * 1000)
563
+ ),
564
+ ]);
565
+
566
+ let best: Location | null = null;
567
+ const n = Math.max(1, Math.min(samples, 10));
568
+ for (let i = 0; i < n; i++) {
569
+ const loc = await withTimeout(fetchOne());
570
+ if (
571
+ !best ||
572
+ (loc.coords.accuracy > 0 && loc.coords.accuracy < best.coords.accuracy)
573
+ ) {
574
+ best = loc;
575
+ }
576
+ if (
577
+ options.desiredAccuracy &&
578
+ best.coords.accuracy > 0 &&
579
+ best.coords.accuracy <= options.desiredAccuracy
580
+ ) {
581
+ break;
582
+ }
583
+ }
584
+
585
+ const result = { ...best!, sample: false };
586
+ if (persist && this.shouldPersist(result)) {
587
+ await database.insertLocation(result);
588
+ if (this.config.url && this.config.autoSync !== false) {
589
+ void this.http.autoSync().catch(() => undefined);
590
+ }
591
+ }
592
+ this.bus.emit('location', result);
593
+ return result;
594
+ }
595
+
596
+ async watchPosition(
597
+ success: (location: Location) => void,
598
+ failure?: (error: unknown) => void,
599
+ options: WatchPositionRequest = {}
600
+ ): Promise<void> {
601
+ try {
602
+ this.watchSub?.remove();
603
+ this.watchSub = await ExpoLocation.watchPositionAsync(
604
+ {
605
+ accuracy: this.mapAccuracy(options.desiredAccuracy ?? this.config.desiredAccuracy),
606
+ timeInterval: options.interval ?? 1000,
607
+ distanceInterval: 0,
608
+ },
609
+ (raw) => {
610
+ void (async () => {
611
+ const location = await this.buildLocation(raw, options.extras, !options.persist);
612
+ if (options.persist) await database.insertLocation(location);
613
+ success(location);
614
+ })();
615
+ }
616
+ );
617
+ } catch (e) {
618
+ failure?.(e);
619
+ }
620
+ }
621
+
622
+ async stopWatchPosition(): Promise<void> {
623
+ this.watchSub?.remove();
624
+ this.watchSub = null;
625
+ }
626
+
627
+ // -------------------------------------------------------------------------
628
+ // State & config
629
+ // -------------------------------------------------------------------------
630
+
631
+ async getState(): Promise<State> {
632
+ return {
633
+ ...this.config,
634
+ enabled: this.enabled,
635
+ isMoving: this.motion.moving,
636
+ schedulerEnabled: this.scheduler.enabled,
637
+ trackingMode: this.trackingMode,
638
+ odometer: Math.round(this.odometer * 100) / 100,
639
+ };
640
+ }
641
+
642
+ async setConfig(config: Config): Promise<State> {
643
+ this.config = { ...this.config, ...config };
644
+ await database.setKV('config', this.config);
645
+ logger.level = this.config.logLevel ?? LogLevel.INFO;
646
+ logger.debug = this.config.debug ?? false;
647
+ this.http.setConfig(this.config);
648
+ this.geofenceManager.setProximityRadius(this.config.geofenceProximityRadius ?? 1000);
649
+ this.motion.setOptions({
650
+ stopTimeoutMinutes: this.config.stopTimeout ?? 5,
651
+ motionTriggerDelayMs: this.config.motionTriggerDelay ?? 0,
652
+ disabled: this.config.disableMotionActivityUpdates ?? false,
653
+ });
654
+ if (config.schedule) this.scheduler.setSchedule(config.schedule);
655
+ if (this.enabled && this.trackingMode === 1) {
656
+ await this.startForegroundWatch();
657
+ await this.startBackgroundUpdates();
658
+ }
659
+ return this.getState();
660
+ }
661
+
662
+ async reset(config: Config = {}): Promise<State> {
663
+ this.config = { ...DEFAULT_CONFIG, ...config };
664
+ await database.setKV('config', this.config);
665
+ return this.getState();
666
+ }
667
+
668
+ // -------------------------------------------------------------------------
669
+ // Persistence API
670
+ // -------------------------------------------------------------------------
671
+
672
+ getLocations(query?: SQLQuery): Promise<Location[]> {
673
+ return database.getLocations(query);
674
+ }
675
+
676
+ getCount(): Promise<number> {
677
+ return database.getCount();
678
+ }
679
+
680
+ async insertLocation(location: Partial<Location>): Promise<string> {
681
+ const record: Location = {
682
+ uuid: location.uuid ?? uuidv4(),
683
+ timestamp: location.timestamp ?? new Date().toISOString(),
684
+ odometer: location.odometer ?? this.odometer,
685
+ is_moving: location.is_moving ?? this.motion.moving,
686
+ coords: location.coords ?? {
687
+ latitude: 0,
688
+ longitude: 0,
689
+ accuracy: -1,
690
+ speed: -1,
691
+ heading: -1,
692
+ altitude: -1,
693
+ },
694
+ activity: location.activity ?? { activity: 'unknown', confidence: 0 },
695
+ battery: location.battery ?? { level: -1, is_charging: false },
696
+ extras: location.extras,
697
+ };
698
+ await database.insertLocation(record);
699
+ return record.uuid;
700
+ }
701
+
702
+ async destroyLocations(): Promise<void> {
703
+ await database.destroyLocations();
704
+ }
705
+
706
+ async destroyLocation(uuid: string): Promise<void> {
707
+ await database.destroyLocation(uuid);
708
+ }
709
+
710
+ sync(): Promise<Location[]> {
711
+ return this.http.sync();
712
+ }
713
+
714
+ // -------------------------------------------------------------------------
715
+ // Odometer
716
+ // -------------------------------------------------------------------------
717
+
718
+ async getOdometer(): Promise<number> {
719
+ return Math.round(this.odometer * 100) / 100;
720
+ }
721
+
722
+ async setOdometer(value: number): Promise<Location> {
723
+ this.odometer = value;
724
+ await database.setKV('odometer', value);
725
+ return this.getCurrentPosition({ persist: false, samples: 1 });
726
+ }
727
+
728
+ resetOdometer(): Promise<Location> {
729
+ return this.setOdometer(0);
730
+ }
731
+
732
+ // -------------------------------------------------------------------------
733
+ // Geofencing API
734
+ // -------------------------------------------------------------------------
735
+
736
+ addGeofence(geofence: Geofence): Promise<boolean> {
737
+ return this.geofenceManager.add(geofence);
738
+ }
739
+
740
+ addGeofences(geofences: Geofence[]): Promise<boolean> {
741
+ return this.geofenceManager.addMany(geofences);
742
+ }
743
+
744
+ removeGeofence(identifier: string): Promise<boolean> {
745
+ return this.geofenceManager.remove(identifier);
746
+ }
747
+
748
+ removeGeofences(identifiers?: string[]): Promise<boolean> {
749
+ return this.geofenceManager.removeAll(identifiers);
750
+ }
751
+
752
+ getGeofences(): Promise<Geofence[]> {
753
+ return this.geofenceManager.getGeofences();
754
+ }
755
+
756
+ getGeofence(identifier: string): Promise<Geofence | null> {
757
+ return this.geofenceManager.getGeofence(identifier);
758
+ }
759
+
760
+ geofenceExists(identifier: string): Promise<boolean> {
761
+ return this.geofenceManager.exists(identifier);
762
+ }
763
+
764
+ // -------------------------------------------------------------------------
765
+ // Device / sensors / power
766
+ // -------------------------------------------------------------------------
767
+
768
+ async getSensors(): Promise<Sensors> {
769
+ const [acc, gyro, mag] = await Promise.all([
770
+ Accelerometer.isAvailableAsync().catch(() => false),
771
+ Gyroscope.isAvailableAsync().catch(() => false),
772
+ Magnetometer.isAvailableAsync().catch(() => false),
773
+ ]);
774
+ return {
775
+ platform: Platform.OS,
776
+ accelerometer: acc,
777
+ gyroscope: gyro,
778
+ magnetometer: mag,
779
+ motion_hardware: acc && gyro,
780
+ };
781
+ }
782
+
783
+ async getDeviceInfo(): Promise<DeviceInfo> {
784
+ return {
785
+ model: Device.modelName ?? 'unknown',
786
+ manufacturer: Device.manufacturer ?? 'unknown',
787
+ version: Device.osVersion ?? 'unknown',
788
+ platform: Platform.OS,
789
+ framework: 'react-native (expo)',
790
+ };
791
+ }
792
+
793
+ async isPowerSaveMode(): Promise<boolean> {
794
+ try {
795
+ return await Battery.isLowPowerModeEnabledAsync();
796
+ } catch {
797
+ return false;
798
+ }
799
+ }
800
+
801
+ private async startPowerSaveMonitoring(): Promise<void> {
802
+ try {
803
+ this.powerSaveSub?.remove();
804
+ this.powerSaveSub = Battery.addLowPowerModeListener(({ lowPowerMode }) => {
805
+ this.bus.emit('powersavechange', lowPowerMode);
806
+ });
807
+ } catch {
808
+ /* unsupported */
809
+ }
810
+ }
811
+
812
+ // -------------------------------------------------------------------------
813
+ // Permissions / provider
814
+ // -------------------------------------------------------------------------
815
+
816
+ async requestPermission(): Promise<AuthorizationStatus> {
817
+ const fg = await ExpoLocation.requestForegroundPermissionsAsync();
818
+ if (fg.status !== 'granted') {
819
+ const e = Object.assign(new Error('Location permission denied'), {
820
+ code: AuthorizationStatus.DENIED,
821
+ });
822
+ throw e;
823
+ }
824
+ if (
825
+ this.config.locationAuthorizationRequest !== 'WhenInUse' &&
826
+ isBackgroundCapable()
827
+ ) {
828
+ const bg = await ExpoLocation.requestBackgroundPermissionsAsync().catch(() => null);
829
+ if (bg?.status === 'granted') return AuthorizationStatus.ALWAYS;
830
+ }
831
+ return AuthorizationStatus.WHEN_IN_USE;
832
+ }
833
+
834
+ async requestTemporaryFullAccuracy(_purpose: string): Promise<AccuracyAuthorization> {
835
+ // expo-location requests full accuracy via its permission flow;
836
+ // reduced accuracy is reported on the permission response (iOS 14+)
837
+ const fg = await ExpoLocation.getForegroundPermissionsAsync();
838
+ const reduced =
839
+ (fg as { ios?: { scope?: string } }).ios?.scope === 'reduced' ||
840
+ (fg as { accuracy?: string }).accuracy === 'reduced';
841
+ return reduced ? AccuracyAuthorization.REDUCED : AccuracyAuthorization.FULL;
842
+ }
843
+
844
+ async getProviderState(): Promise<ProviderChangeEvent> {
845
+ const [services, fg] = await Promise.all([
846
+ ExpoLocation.hasServicesEnabledAsync().catch(() => false),
847
+ ExpoLocation.getForegroundPermissionsAsync(),
848
+ ]);
849
+ const bg = isBackgroundCapable()
850
+ ? await ExpoLocation.getBackgroundPermissionsAsync().catch(() => null)
851
+ : null;
852
+ let status = AuthorizationStatus.NOT_DETERMINED;
853
+ if (fg.status === 'denied') status = AuthorizationStatus.DENIED;
854
+ else if (bg?.status === 'granted') status = AuthorizationStatus.ALWAYS;
855
+ else if (fg.status === 'granted') status = AuthorizationStatus.WHEN_IN_USE;
856
+ const reduced =
857
+ (fg as { ios?: { scope?: string } }).ios?.scope === 'reduced';
858
+ return {
859
+ enabled: services && fg.status === 'granted',
860
+ status,
861
+ network: true,
862
+ gps: services,
863
+ accuracyAuthorization: reduced
864
+ ? AccuracyAuthorization.REDUCED
865
+ : AccuracyAuthorization.FULL,
866
+ };
867
+ }
868
+
869
+ private startProviderMonitoring(): void {
870
+ if (this.providerTimer) return;
871
+ this.providerTimer = setInterval(() => {
872
+ void (async () => {
873
+ try {
874
+ const state = await this.getProviderState();
875
+ const key = JSON.stringify(state);
876
+ if (this.lastProviderState !== null && key !== this.lastProviderState) {
877
+ this.bus.emit('providerchange', state);
878
+ }
879
+ this.lastProviderState = key;
880
+ } catch {
881
+ /* ignore */
882
+ }
883
+ })();
884
+ }, 15_000);
885
+ }
886
+
887
+ // -------------------------------------------------------------------------
888
+ // Heartbeat
889
+ // -------------------------------------------------------------------------
890
+
891
+ private startHeartbeat(): void {
892
+ this.stopHeartbeat();
893
+ const interval = (this.config.heartbeatInterval ?? 60) * 1000;
894
+ if (interval <= 0) return;
895
+ this.heartbeatTimer = setInterval(() => {
896
+ void (async () => {
897
+ if (!this.enabled || this.motion.moving) return;
898
+ const location = this.lastLocation;
899
+ if (location) {
900
+ this.bus.emit('heartbeat', {
901
+ location: { ...location, event: 'heartbeat' as const },
902
+ } satisfies HeartbeatEvent);
903
+ }
904
+ })();
905
+ }, interval);
906
+ }
907
+
908
+ private stopHeartbeat(): void {
909
+ if (this.heartbeatTimer) {
910
+ clearInterval(this.heartbeatTimer);
911
+ this.heartbeatTimer = null;
912
+ }
913
+ }
914
+
915
+ // -------------------------------------------------------------------------
916
+ // Background tasks (iOS-style grace period emulation)
917
+ // -------------------------------------------------------------------------
918
+
919
+ async startBackgroundTask(): Promise<number> {
920
+ return ++this.bgTaskCounter;
921
+ }
922
+
923
+ async stopBackgroundTask(_taskId: number): Promise<void> {
924
+ /* no-op: JS timers run while app is alive; dev-build background
925
+ execution is handled by expo-task-manager */
926
+ }
927
+
928
+ // -------------------------------------------------------------------------
929
+ // Headless
930
+ // -------------------------------------------------------------------------
931
+
932
+ registerHeadlessTask(task: HeadlessTask): void {
933
+ this.headlessTask = task;
934
+ }
935
+
936
+ // -------------------------------------------------------------------------
937
+ // Logs / misc
938
+ // -------------------------------------------------------------------------
939
+
940
+ async getLog(): Promise<string> {
941
+ return logger.getLog();
942
+ }
943
+
944
+ async destroyLog(): Promise<void> {
945
+ return logger.destroyLog();
946
+ }
947
+
948
+ async emailLog(_email: string): Promise<string> {
949
+ // Without native mail-composer access we return the log text;
950
+ // apps can share it via expo-sharing / mailto.
951
+ return logger.getLog();
952
+ }
953
+
954
+ async playSound(_soundId: number | string): Promise<void> {
955
+ /* debug sounds require native assets — no-op */
956
+ }
957
+
958
+ // -------------------------------------------------------------------------
959
+ // Events
960
+ // -------------------------------------------------------------------------
961
+
962
+ onLocation(
963
+ success: (location: Location) => void,
964
+ failure?: (error: unknown) => void
965
+ ): Subscription {
966
+ void failure;
967
+ return this.bus.on('location', (e) => success(e as Location));
968
+ }
969
+
970
+ onMotionChange(fn: (event: MotionChangeEvent) => void): Subscription {
971
+ return this.bus.on('motionchange', (e) => fn(e as MotionChangeEvent));
972
+ }
973
+
974
+ onActivityChange(fn: (event: ActivityChangeEvent) => void): Subscription {
975
+ return this.bus.on('activitychange', (e) => fn(e as ActivityChangeEvent));
976
+ }
977
+
978
+ onGeofence(fn: (event: GeofenceEvent) => void): Subscription {
979
+ return this.bus.on('geofence', (e) => fn(e as GeofenceEvent));
980
+ }
981
+
982
+ onGeofencesChange(fn: (event: GeofencesChangeEvent) => void): Subscription {
983
+ return this.bus.on('geofenceschange', (e) => fn(e as GeofencesChangeEvent));
984
+ }
985
+
986
+ onHeartbeat(fn: (event: HeartbeatEvent) => void): Subscription {
987
+ return this.bus.on('heartbeat', (e) => fn(e as HeartbeatEvent));
988
+ }
989
+
990
+ onHttp(fn: (event: HttpEvent) => void): Subscription {
991
+ return this.bus.on('http', (e) => fn(e as HttpEvent));
992
+ }
993
+
994
+ onProviderChange(fn: (event: ProviderChangeEvent) => void): Subscription {
995
+ return this.bus.on('providerchange', (e) => fn(e as ProviderChangeEvent));
996
+ }
997
+
998
+ onConnectivityChange(fn: (event: ConnectivityChangeEvent) => void): Subscription {
999
+ return this.bus.on('connectivitychange', (e) => fn(e as ConnectivityChangeEvent));
1000
+ }
1001
+
1002
+ onPowerSaveChange(fn: (isPowerSaveMode: boolean) => void): Subscription {
1003
+ return this.bus.on('powersavechange', (e) => fn(e as boolean));
1004
+ }
1005
+
1006
+ onEnabledChange(fn: (enabled: boolean) => void): Subscription {
1007
+ return this.bus.on('enabledchange', (e) => fn(e as boolean));
1008
+ }
1009
+
1010
+ onSchedule(fn: (event: ScheduleEvent) => void): Subscription {
1011
+ return this.bus.on('schedule', (e) => fn(e as ScheduleEvent));
1012
+ }
1013
+
1014
+ onAuthorization(fn: (event: AuthorizationEvent) => void): Subscription {
1015
+ return this.bus.on('authorization', (e) => fn(e as AuthorizationEvent));
1016
+ }
1017
+
1018
+ onNotificationAction(fn: (buttonId: string) => void): Subscription {
1019
+ // Requires custom native code — never fires in Expo-managed apps.
1020
+ return this.bus.on('notificationaction', (e) => fn(e as string));
1021
+ }
1022
+
1023
+ removeListeners(): void {
1024
+ this.bus.removeAll();
1025
+ }
1026
+
1027
+ removeAllListeners(): void {
1028
+ this.bus.removeAll();
1029
+ }
1030
+ }
1031
+
1032
+ export const BackgroundGeolocation = new BackgroundGeolocationImpl();