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,232 @@
1
+ import * as Network from 'expo-network';
2
+ import { database } from './Database';
3
+ import { logger } from './Logger';
4
+ import type { EventBus } from './EventBus';
5
+ import type {
6
+ Authorization,
7
+ AuthorizationEvent,
8
+ Config,
9
+ HttpEvent,
10
+ Location,
11
+ } from './types';
12
+
13
+ /**
14
+ * HTTP upload service: autoSync, batchSync, templates, JWT refresh,
15
+ * offline queue with re-sync on reconnect. Pure `fetch` — no Firebase.
16
+ */
17
+ export class HttpService {
18
+ private config: Config = {};
19
+ private syncing = false;
20
+ private connectivityTimer: ReturnType<typeof setInterval> | null = null;
21
+ private lastConnected: boolean | null = null;
22
+
23
+ constructor(private bus: EventBus) {}
24
+
25
+ setConfig(config: Config): void {
26
+ this.config = config;
27
+ }
28
+
29
+ startConnectivityMonitoring(intervalMs = 10_000): void {
30
+ this.stopConnectivityMonitoring();
31
+ this.connectivityTimer = setInterval(() => {
32
+ void this.checkConnectivity();
33
+ }, intervalMs);
34
+ void this.checkConnectivity();
35
+ }
36
+
37
+ stopConnectivityMonitoring(): void {
38
+ if (this.connectivityTimer) {
39
+ clearInterval(this.connectivityTimer);
40
+ this.connectivityTimer = null;
41
+ }
42
+ }
43
+
44
+ private async checkConnectivity(): Promise<void> {
45
+ try {
46
+ const state = await Network.getNetworkStateAsync();
47
+ const connected = !!(state.isConnected && state.isInternetReachable !== false);
48
+ if (this.lastConnected !== null && connected !== this.lastConnected) {
49
+ this.bus.emit('connectivitychange', { connected });
50
+ if (connected && this.config.autoSync !== false) {
51
+ void this.sync();
52
+ }
53
+ }
54
+ this.lastConnected = connected;
55
+ } catch {
56
+ /* ignore */
57
+ }
58
+ }
59
+
60
+ /** Called after each persisted location when autoSync is enabled. */
61
+ async autoSync(): Promise<void> {
62
+ if (!this.config.url || this.config.autoSync === false) return;
63
+ const threshold = this.config.autoSyncThreshold ?? 0;
64
+ if (threshold > 0) {
65
+ const count = await database.getCount();
66
+ if (count < threshold) return;
67
+ }
68
+ await this.sync();
69
+ }
70
+
71
+ /** Upload all queued locations. Resolves with the uploaded records. */
72
+ async sync(): Promise<Location[]> {
73
+ if (!this.config.url) {
74
+ throw new Error('sync() requires config.url');
75
+ }
76
+ if (this.syncing) return [];
77
+ this.syncing = true;
78
+ try {
79
+ const queue = await database.getLocations({ order: 1 });
80
+ if (!queue.length) return [];
81
+
82
+ const batchSync = this.config.batchSync === true;
83
+ const maxBatch = this.config.maxBatchSize && this.config.maxBatchSize > 0
84
+ ? this.config.maxBatchSize
85
+ : Infinity;
86
+
87
+ if (batchSync) {
88
+ let i = 0;
89
+ while (i < queue.length) {
90
+ const batch = queue.slice(i, i + (isFinite(maxBatch) ? maxBatch : queue.length));
91
+ const ok = await this.upload(batch);
92
+ if (!ok) break;
93
+ await database.deleteLocations(batch.map((l) => l.uuid));
94
+ i += batch.length;
95
+ }
96
+ } else {
97
+ for (const location of queue) {
98
+ const ok = await this.upload([location]);
99
+ if (!ok) break;
100
+ await database.destroyLocation(location.uuid);
101
+ }
102
+ }
103
+ return queue;
104
+ } finally {
105
+ this.syncing = false;
106
+ }
107
+ }
108
+
109
+ private renderTemplate(template: string, location: Location): string {
110
+ return template.replace(/<%=?\s*([\w.]+)\s*%>/g, (_m, path: string) => {
111
+ const flat: Record<string, unknown> = {
112
+ ...location.coords,
113
+ uuid: location.uuid,
114
+ timestamp: location.timestamp,
115
+ odometer: location.odometer,
116
+ is_moving: location.is_moving,
117
+ activity: location.activity?.activity,
118
+ 'activity.type': location.activity?.activity,
119
+ 'activity.confidence': location.activity?.confidence,
120
+ 'battery.level': location.battery?.level,
121
+ 'battery.is_charging': location.battery?.is_charging,
122
+ };
123
+ const v = flat[path];
124
+ return v === undefined ? 'null' : JSON.stringify(v);
125
+ });
126
+ }
127
+
128
+ private buildBody(locations: Location[]): unknown {
129
+ const template = this.config.locationTemplate;
130
+ const extras = this.config.extras ?? {};
131
+ const records = locations.map((l) => {
132
+ if (template) {
133
+ try {
134
+ return JSON.parse(this.renderTemplate(template, l));
135
+ } catch {
136
+ return { ...l, extras: { ...extras, ...l.extras } };
137
+ }
138
+ }
139
+ return { ...l, extras: { ...extras, ...l.extras } };
140
+ });
141
+ const root = this.config.httpRootProperty ?? 'location';
142
+ const payload = locations.length === 1 && !this.config.batchSync ? records[0] : records;
143
+ if (root === '.') return payload;
144
+ return { [root]: payload, ...(this.config.params ?? {}) };
145
+ }
146
+
147
+ private async authHeaders(): Promise<Record<string, string>> {
148
+ const auth = this.config.authorization;
149
+ if (!auth?.accessToken) return {};
150
+ return { Authorization: `Bearer ${auth.accessToken}` };
151
+ }
152
+
153
+ private async refreshToken(auth: Authorization): Promise<boolean> {
154
+ if (!auth.refreshUrl || !auth.refreshToken) return false;
155
+ try {
156
+ const payload: Record<string, string> = {};
157
+ const tpl = auth.refreshPayload ?? { refresh_token: '{refreshToken}' };
158
+ for (const [k, v] of Object.entries(tpl)) {
159
+ payload[k] = v.replace('{refreshToken}', auth.refreshToken);
160
+ }
161
+ const res = await fetch(auth.refreshUrl, {
162
+ method: 'POST',
163
+ headers: { 'Content-Type': 'application/json', ...(auth.refreshHeaders ?? {}) },
164
+ body: JSON.stringify(payload),
165
+ });
166
+ const json = (await res.json().catch(() => ({}))) as Record<string, unknown>;
167
+ const event: AuthorizationEvent = res.ok
168
+ ? { success: true, status: res.status, response: json }
169
+ : { success: false, status: res.status, error: 'refresh failed' };
170
+ if (res.ok) {
171
+ const token =
172
+ (json.access_token as string) ?? (json.accessToken as string) ?? null;
173
+ const refresh =
174
+ (json.refresh_token as string) ?? (json.refreshToken as string) ?? null;
175
+ if (token) auth.accessToken = token;
176
+ if (refresh) auth.refreshToken = refresh;
177
+ }
178
+ this.bus.emit('authorization', event);
179
+ return res.ok;
180
+ } catch (e) {
181
+ this.bus.emit('authorization', {
182
+ success: false,
183
+ error: String(e),
184
+ } satisfies AuthorizationEvent);
185
+ return false;
186
+ }
187
+ }
188
+
189
+ private async upload(locations: Location[], retried = false): Promise<boolean> {
190
+ const url = this.config.url!;
191
+ const method = this.config.method ?? 'POST';
192
+ const timeout = this.config.httpTimeout ?? 60_000;
193
+ const controller = new AbortController();
194
+ const timer = setTimeout(() => controller.abort(), timeout);
195
+ try {
196
+ const res = await fetch(url, {
197
+ method,
198
+ headers: {
199
+ 'Content-Type': 'application/json',
200
+ ...(this.config.headers ?? {}),
201
+ ...(await this.authHeaders()),
202
+ },
203
+ body: JSON.stringify(this.buildBody(locations)),
204
+ signal: controller.signal,
205
+ });
206
+ const text = await res.text().catch(() => '');
207
+ const event: HttpEvent = {
208
+ success: res.ok,
209
+ status: res.status,
210
+ responseText: text,
211
+ };
212
+ this.bus.emit('http', event);
213
+
214
+ if (res.status === 401 && !retried && this.config.authorization) {
215
+ const refreshed = await this.refreshToken(this.config.authorization);
216
+ if (refreshed) return this.upload(locations, true);
217
+ }
218
+ if (!res.ok) logger.warn(`HTTP ${res.status}: ${text.slice(0, 200)}`);
219
+ return res.ok;
220
+ } catch (e) {
221
+ logger.warn(`HTTP error: ${String(e)}`);
222
+ this.bus.emit('http', {
223
+ success: false,
224
+ status: 0,
225
+ responseText: String(e),
226
+ } satisfies HttpEvent);
227
+ return false;
228
+ } finally {
229
+ clearTimeout(timer);
230
+ }
231
+ }
232
+ }
package/src/Logger.ts ADDED
@@ -0,0 +1,52 @@
1
+ import { database } from './Database';
2
+ import { LogLevel } from './types';
3
+
4
+ /** SQLite-backed logger, mirrors BackgroundGeolocation.logger. */
5
+ export class Logger {
6
+ level: LogLevel = LogLevel.INFO;
7
+ debug = false;
8
+
9
+ private write(level: LogLevel, tag: string, message: string): void {
10
+ if (level > this.level) return;
11
+ const line = `[${tag}] ${message}`;
12
+ if (this.debug || level <= LogLevel.WARNING) {
13
+ const fn =
14
+ level === LogLevel.ERROR
15
+ ? console.error
16
+ : level === LogLevel.WARNING
17
+ ? console.warn
18
+ : console.log;
19
+ fn(`[expo-background-tracking]${line}`);
20
+ }
21
+ database.insertLog(LogLevel[level], line).catch(() => undefined);
22
+ }
23
+
24
+ error(message: string): void {
25
+ this.write(LogLevel.ERROR, 'ERROR', message);
26
+ }
27
+ warn(message: string): void {
28
+ this.write(LogLevel.WARNING, 'WARN', message);
29
+ }
30
+ info(message: string): void {
31
+ this.write(LogLevel.INFO, 'INFO', message);
32
+ }
33
+ debugLog(message: string): void {
34
+ this.write(LogLevel.DEBUG, 'DEBUG', message);
35
+ }
36
+ notice(message: string): void {
37
+ this.write(LogLevel.INFO, 'NOTICE', message);
38
+ }
39
+
40
+ async getLog(): Promise<string> {
41
+ const entries = await database.getLogs();
42
+ return entries
43
+ .map((e) => `${e.timestamp} ${e.level} ${e.message}`)
44
+ .join('\n');
45
+ }
46
+
47
+ async destroyLog(): Promise<void> {
48
+ await database.destroyLogs();
49
+ }
50
+ }
51
+
52
+ export const logger = new Logger();
@@ -0,0 +1,149 @@
1
+ import { Accelerometer } from 'expo-sensors';
2
+ import type { ActivityChangeEvent, MotionActivityType } from './types';
3
+
4
+ export interface MotionDetectorOptions {
5
+ /** Minutes of stillness before declaring stationary (stopTimeout). */
6
+ stopTimeoutMinutes: number;
7
+ /** ms of sustained motion before declaring moving (motionTriggerDelay). */
8
+ motionTriggerDelayMs: number;
9
+ /** Disable accelerometer sampling (disableMotionActivityUpdates). */
10
+ disabled: boolean;
11
+ onMotionChange: (isMoving: boolean) => void;
12
+ onActivityChange: (activity: ActivityChangeEvent) => void;
13
+ }
14
+
15
+ /**
16
+ * Battery-conscious motion intelligence:
17
+ * - accelerometer variance → moving / stationary transitions
18
+ * - GPS speed + accelerometer → activity classification
19
+ * (still, on_foot, walking, running, on_bicycle, in_vehicle)
20
+ */
21
+ export class MotionDetector {
22
+ private sub: { remove(): void } | null = null;
23
+ private samples: number[] = [];
24
+ private isMoving = false;
25
+ private stillSince: number | null = null;
26
+ private movingSince: number | null = null;
27
+ private lastActivity: MotionActivityType = 'still';
28
+ private lastSpeed = 0;
29
+ private opts: MotionDetectorOptions;
30
+
31
+ // m/s² variance thresholds (device acceleration, gravity removed approx.)
32
+ private static MOTION_THRESHOLD = 0.05;
33
+
34
+ constructor(opts: MotionDetectorOptions) {
35
+ this.opts = opts;
36
+ }
37
+
38
+ setOptions(opts: Partial<MotionDetectorOptions>): void {
39
+ this.opts = { ...this.opts, ...opts };
40
+ }
41
+
42
+ get moving(): boolean {
43
+ return this.isMoving;
44
+ }
45
+
46
+ /** Force pace (changePace). */
47
+ setMoving(isMoving: boolean): void {
48
+ if (this.isMoving === isMoving) return;
49
+ this.isMoving = isMoving;
50
+ this.stillSince = null;
51
+ this.movingSince = null;
52
+ this.opts.onMotionChange(isMoving);
53
+ }
54
+
55
+ /** Feed GPS speed (m/s) for activity classification. */
56
+ feedSpeed(speed: number | null): void {
57
+ if (speed == null || speed < 0) return;
58
+ this.lastSpeed = speed;
59
+ this.classify();
60
+ // GPS speed is also strong evidence of motion
61
+ if (speed > 1 && !this.isMoving) this.noteMotion(true);
62
+ }
63
+
64
+ async start(): Promise<void> {
65
+ if (this.opts.disabled) return;
66
+ try {
67
+ const available = await Accelerometer.isAvailableAsync();
68
+ if (!available) return;
69
+ } catch {
70
+ return;
71
+ }
72
+ Accelerometer.setUpdateInterval(1000);
73
+ this.sub = Accelerometer.addListener(({ x, y, z }) => {
74
+ const magnitude = Math.abs(Math.sqrt(x * x + y * y + z * z) - 1); // gravity-normalized
75
+ this.samples.push(magnitude);
76
+ if (this.samples.length > 10) this.samples.shift();
77
+ if (this.samples.length >= 5) this.evaluate();
78
+ });
79
+ }
80
+
81
+ stop(): void {
82
+ this.sub?.remove();
83
+ this.sub = null;
84
+ this.samples = [];
85
+ this.stillSince = null;
86
+ this.movingSince = null;
87
+ }
88
+
89
+ private evaluate(): void {
90
+ const mean = this.samples.reduce((a, b) => a + b, 0) / this.samples.length;
91
+ const variance =
92
+ this.samples.reduce((a, b) => a + (b - mean) ** 2, 0) / this.samples.length;
93
+ const active =
94
+ variance > MotionDetector.MOTION_THRESHOLD ||
95
+ mean > MotionDetector.MOTION_THRESHOLD * 2;
96
+ this.noteMotion(active);
97
+ this.classify();
98
+ }
99
+
100
+ private noteMotion(active: boolean): void {
101
+ const now = Date.now();
102
+ if (active) {
103
+ this.stillSince = null;
104
+ if (!this.isMoving) {
105
+ if (this.movingSince == null) this.movingSince = now;
106
+ if (now - this.movingSince >= this.opts.motionTriggerDelayMs) {
107
+ this.isMoving = true;
108
+ this.movingSince = null;
109
+ this.opts.onMotionChange(true);
110
+ }
111
+ }
112
+ } else {
113
+ this.movingSince = null;
114
+ if (this.isMoving) {
115
+ if (this.stillSince == null) this.stillSince = now;
116
+ if (now - this.stillSince >= this.opts.stopTimeoutMinutes * 60_000) {
117
+ this.isMoving = false;
118
+ this.stillSince = null;
119
+ this.opts.onMotionChange(false);
120
+ }
121
+ }
122
+ }
123
+ }
124
+
125
+ private classify(): void {
126
+ const speed = this.lastSpeed;
127
+ let activity: MotionActivityType;
128
+ let confidence = 75;
129
+ if (!this.isMoving && speed < 0.5) {
130
+ activity = 'still';
131
+ confidence = 90;
132
+ } else if (speed < 2) {
133
+ activity = 'walking';
134
+ } else if (speed < 3.5) {
135
+ activity = 'running';
136
+ confidence = 60;
137
+ } else if (speed < 8) {
138
+ activity = 'on_bicycle';
139
+ confidence = 55;
140
+ } else {
141
+ activity = 'in_vehicle';
142
+ confidence = 85;
143
+ }
144
+ if (activity !== this.lastActivity) {
145
+ this.lastActivity = activity;
146
+ this.opts.onActivityChange({ activity, confidence });
147
+ }
148
+ }
149
+ }
@@ -0,0 +1,90 @@
1
+ import { logger } from './Logger';
2
+ import type { ScheduleItem } from './types';
3
+
4
+ interface ParsedSchedule {
5
+ days: Set<number>; // 1=Mon … 7=Sun
6
+ startMinutes: number;
7
+ endMinutes: number;
8
+ }
9
+
10
+ /**
11
+ * Weekly schedule engine — "1-5 09:00-17:00" style entries
12
+ * (same syntax as transistorsoft). JS timer-based: evaluated every
13
+ * 30 s while the app runs; state re-evaluated on resume.
14
+ */
15
+ export class Scheduler {
16
+ private timer: ReturnType<typeof setInterval> | null = null;
17
+ private schedules: ParsedSchedule[] = [];
18
+ private lastShouldBeEnabled: boolean | null = null;
19
+
20
+ constructor(
21
+ private onScheduleChange: (enabled: boolean) => void
22
+ ) {}
23
+
24
+ get enabled(): boolean {
25
+ return this.timer != null;
26
+ }
27
+
28
+ setSchedule(items: ScheduleItem[]): void {
29
+ this.schedules = items
30
+ .map((s) => this.parse(s))
31
+ .filter((s): s is ParsedSchedule => s != null);
32
+ }
33
+
34
+ start(): void {
35
+ if (this.timer) return;
36
+ if (!this.schedules.length) {
37
+ logger.warn('startSchedule: no schedule configured');
38
+ return;
39
+ }
40
+ this.timer = setInterval(() => this.evaluate(), 30_000);
41
+ this.evaluate();
42
+ logger.info('Schedule started');
43
+ }
44
+
45
+ stop(): void {
46
+ if (this.timer) {
47
+ clearInterval(this.timer);
48
+ this.timer = null;
49
+ }
50
+ this.lastShouldBeEnabled = null;
51
+ logger.info('Schedule stopped');
52
+ }
53
+
54
+ private evaluate(): void {
55
+ const now = new Date();
56
+ const isoDay = now.getDay() === 0 ? 7 : now.getDay();
57
+ const minutes = now.getHours() * 60 + now.getMinutes();
58
+ const shouldBeEnabled = this.schedules.some(
59
+ (s) =>
60
+ s.days.has(isoDay) && minutes >= s.startMinutes && minutes < s.endMinutes
61
+ );
62
+ if (shouldBeEnabled !== this.lastShouldBeEnabled) {
63
+ this.lastShouldBeEnabled = shouldBeEnabled;
64
+ this.onScheduleChange(shouldBeEnabled);
65
+ }
66
+ }
67
+
68
+ private parse(item: string): ParsedSchedule | null {
69
+ // "1-5 09:00-17:00" | "7 10:00-12:00" | "1,3,5 08:30-18:00"
70
+ const m = item.trim().match(/^([\d,\-]+)\s+(\d{1,2}):(\d{2})-(\d{1,2}):(\d{2})$/);
71
+ if (!m) {
72
+ logger.warn(`Invalid schedule item: "${item}"`);
73
+ return null;
74
+ }
75
+ const days = new Set<number>();
76
+ for (const part of m[1].split(',')) {
77
+ const range = part.split('-').map(Number);
78
+ if (range.length === 2) {
79
+ for (let d = range[0]; d <= range[1]; d++) days.add(d);
80
+ } else {
81
+ days.add(range[0]);
82
+ }
83
+ }
84
+ return {
85
+ days,
86
+ startMinutes: Number(m[2]) * 60 + Number(m[3]),
87
+ endMinutes: Number(m[4]) * 60 + Number(m[5]),
88
+ };
89
+ }
90
+ }
@@ -0,0 +1,22 @@
1
+ import Constants from 'expo-constants';
2
+
3
+ /** True when running inside the Expo Go sandbox app. */
4
+ export function isExpoGo(): boolean {
5
+ try {
6
+ return (
7
+ (Constants as { executionEnvironment?: string } | undefined)
8
+ ?.executionEnvironment === 'storeClient'
9
+ );
10
+ } catch {
11
+ return false;
12
+ }
13
+ }
14
+
15
+ /**
16
+ * Background location (expo-task-manager location tasks) requires a
17
+ * development build — it is not available inside Expo Go.
18
+ * https://docs.expo.dev/versions/latest/sdk/location/
19
+ */
20
+ export function isBackgroundCapable(): boolean {
21
+ return !isExpoGo();
22
+ }
package/src/geo.ts ADDED
@@ -0,0 +1,57 @@
1
+ /** Geodesic helpers. */
2
+
3
+ const R = 6_371_000; // earth radius, metres
4
+
5
+ export function haversine(
6
+ lat1: number,
7
+ lon1: number,
8
+ lat2: number,
9
+ lon2: number
10
+ ): number {
11
+ const toRad = (d: number) => (d * Math.PI) / 180;
12
+ const dLat = toRad(lat2 - lat1);
13
+ const dLon = toRad(lon2 - lon1);
14
+ const a =
15
+ Math.sin(dLat / 2) ** 2 +
16
+ Math.cos(toRad(lat1)) * Math.cos(toRad(lat2)) * Math.sin(dLon / 2) ** 2;
17
+ return 2 * R * Math.asin(Math.sqrt(a));
18
+ }
19
+
20
+ /** Point-in-polygon (ray casting) — vertices as [lat, lon]. */
21
+ export function pointInPolygon(
22
+ lat: number,
23
+ lon: number,
24
+ vertices: Array<[number, number]>
25
+ ): boolean {
26
+ let inside = false;
27
+ for (let i = 0, j = vertices.length - 1; i < vertices.length; j = i++) {
28
+ const [yi, xi] = vertices[i];
29
+ const [yj, xj] = vertices[j];
30
+ if (yi > lat !== yj > lat && lon < ((xj - xi) * (lat - yi)) / (yj - yi) + xi) {
31
+ inside = !inside;
32
+ }
33
+ }
34
+ return inside;
35
+ }
36
+
37
+ /** Centroid of a polygon's vertices ([lat, lon]). */
38
+ export function centroid(
39
+ vertices: Array<[number, number]>
40
+ ): { latitude: number; longitude: number } {
41
+ const n = vertices.length || 1;
42
+ let lat = 0;
43
+ let lon = 0;
44
+ for (const [vLat, vLon] of vertices) {
45
+ lat += vLat;
46
+ lon += vLon;
47
+ }
48
+ return { latitude: lat / n, longitude: lon / n };
49
+ }
50
+
51
+ export function uuidv4(): string {
52
+ return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => {
53
+ const r = (Math.random() * 16) | 0;
54
+ const v = c === 'x' ? r : (r & 0x3) | 0x8;
55
+ return v.toString(16);
56
+ });
57
+ }
package/src/index.ts ADDED
@@ -0,0 +1,7 @@
1
+ export { BackgroundGeolocation, LOCATION_TASK } from './BackgroundGeolocation';
2
+ export { GEOFENCE_TASK } from './GeofenceManager';
3
+ export { isExpoGo, isBackgroundCapable } from './environment';
4
+ export * from './types';
5
+
6
+ import { BackgroundGeolocation } from './BackgroundGeolocation';
7
+ export default BackgroundGeolocation;