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.
- package/LICENSE +21 -0
- package/README.md +260 -0
- package/build/BackgroundGeolocation.d.ts +122 -0
- package/build/BackgroundGeolocation.js +901 -0
- package/build/Database.d.ts +30 -0
- package/build/Database.js +206 -0
- package/build/EventBus.d.ts +10 -0
- package/build/EventBus.js +37 -0
- package/build/GeofenceManager.d.ts +42 -0
- package/build/GeofenceManager.js +281 -0
- package/build/HttpService.d.ts +27 -0
- package/build/HttpService.js +265 -0
- package/build/Logger.d.ts +15 -0
- package/build/Logger.js +52 -0
- package/build/MotionDetector.d.ts +40 -0
- package/build/MotionDetector.js +141 -0
- package/build/Scheduler.d.ts +19 -0
- package/build/Scheduler.js +79 -0
- package/build/environment.d.ts +8 -0
- package/build/environment.js +26 -0
- package/build/geo.d.ts +10 -0
- package/build/geo.js +46 -0
- package/build/index.d.ts +6 -0
- package/build/index.js +28 -0
- package/build/types.d.ts +275 -0
- package/build/types.js +56 -0
- package/package.json +351 -0
- package/src/BackgroundGeolocation.ts +1032 -0
- package/src/Database.ts +236 -0
- package/src/EventBus.ts +33 -0
- package/src/GeofenceManager.ts +269 -0
- package/src/HttpService.ts +232 -0
- package/src/Logger.ts +52 -0
- package/src/MotionDetector.ts +149 -0
- package/src/Scheduler.ts +90 -0
- package/src/environment.ts +22 -0
- package/src/geo.ts +57 -0
- package/src/index.ts +7 -0
- package/src/types.ts +410 -0
|
@@ -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
|
+
}
|
package/src/Scheduler.ts
ADDED
|
@@ -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;
|