@tokamakdev/plugin-location 0.1.0-beta.106 → 0.1.0-beta.112
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +32 -14
- package/android/Bindings.kt +997 -0
- package/android/LocationPlugin.kt +3 -124
- package/android/bindings.ts +4150 -0
- package/apple/Bindings.swift +1057 -0
- package/apple/LocationPlugin.swift +28 -167
- package/apple/LocationRegions.swift +3 -1
- package/apple/bindings.ts +1314 -0
- package/events/index.ts +5 -5
- package/package.json +5 -3
- package/src/android.ts +138 -0
- package/src/apple.ts +117 -0
- package/src/errors.ts +34 -0
- package/src/index.ts +89 -64
- package/src/types.ts +12 -0
- package/tokamak-plugin.json +69 -2
- package/web/client.ts +11 -0
- package/src/checks.ts +0 -65
- package/src/options.ts +0 -54
package/events/index.ts
CHANGED
|
@@ -1,18 +1,18 @@
|
|
|
1
1
|
import { defineEvent } from "@tokamakdev/plugin/events";
|
|
2
2
|
|
|
3
|
-
import
|
|
3
|
+
import "../src/types.js";
|
|
4
4
|
|
|
5
5
|
/**
|
|
6
6
|
* Receives the positions of continuous updates and significant changes in native builds: on
|
|
7
7
|
* screen, off screen, or after the system launches the app for them.
|
|
8
8
|
*/
|
|
9
|
-
export const onLocationUpdate = defineEvent
|
|
9
|
+
export const onLocationUpdate = defineEvent("location.update");
|
|
10
10
|
|
|
11
11
|
/** Receives each region entry or exit, including one that launches the app. */
|
|
12
|
-
export const onRegionTransition = defineEvent
|
|
12
|
+
export const onRegionTransition = defineEvent("location.region");
|
|
13
13
|
|
|
14
14
|
/** Apple only: receives each arrival at or departure from a place, including one that launches the app. */
|
|
15
|
-
export const onVisit = defineEvent
|
|
15
|
+
export const onVisit = defineEvent("location.visit");
|
|
16
16
|
|
|
17
17
|
/** Receives failures of background location services, such as a region the system cannot monitor. */
|
|
18
|
-
export const onLocationError = defineEvent
|
|
18
|
+
export const onLocationError = defineEvent("location.error");
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tokamakdev/plugin-location",
|
|
3
|
-
"version": "0.1.0-beta.
|
|
3
|
+
"version": "0.1.0-beta.112",
|
|
4
4
|
"repository": {
|
|
5
5
|
"type": "git",
|
|
6
6
|
"url": "git+https://github.com/mantty/tokamak.git",
|
|
@@ -9,10 +9,12 @@
|
|
|
9
9
|
"type": "module",
|
|
10
10
|
"exports": {
|
|
11
11
|
".": "./src/index.ts",
|
|
12
|
-
"./events": "./events/index.ts"
|
|
12
|
+
"./events": "./events/index.ts",
|
|
13
|
+
"./apple": "./apple/bindings.ts",
|
|
14
|
+
"./android": "./android/bindings.ts"
|
|
13
15
|
},
|
|
14
16
|
"peerDependencies": {
|
|
15
|
-
"@tokamakdev/plugin": "0.1.0-beta.
|
|
17
|
+
"@tokamakdev/plugin": "0.1.0-beta.112"
|
|
16
18
|
},
|
|
17
19
|
"files": [
|
|
18
20
|
"android",
|
package/src/android.ts
ADDED
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
import type { TokamakContext } from "@tokamakdev/plugin";
|
|
2
|
+
import { applicationContext, release } from "@tokamakdev/plugin/bindings";
|
|
3
|
+
|
|
4
|
+
import {
|
|
5
|
+
CancellationSignal,
|
|
6
|
+
Consumer,
|
|
7
|
+
Context,
|
|
8
|
+
Location,
|
|
9
|
+
LocationListener,
|
|
10
|
+
LocationManager,
|
|
11
|
+
SystemClock,
|
|
12
|
+
type Executor,
|
|
13
|
+
} from "../android/bindings.js";
|
|
14
|
+
import { permissionDenied, unavailable, within } from "./errors.js";
|
|
15
|
+
import type { Position, PositionOptions } from "./types.js";
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* The device's position: the provider's last known when no older than `maximumAge`, or a new fix
|
|
19
|
+
* within `timeout`. Null when Android finds none.
|
|
20
|
+
*/
|
|
21
|
+
export async function currentPosition(ctx: TokamakContext, { maximumAge = 0, timeout }: PositionOptions): Promise<Position | null> {
|
|
22
|
+
const { context, manager } = await locationManager(ctx);
|
|
23
|
+
const name = await provider(manager);
|
|
24
|
+
const recent = await manager.getLastKnownLocation(name);
|
|
25
|
+
if (recent && await age(ctx, recent) <= maximumAge) return await position(recent);
|
|
26
|
+
const [signal, executor] = await Promise.all([CancellationSignal.new(ctx), mainExecutor(context)]);
|
|
27
|
+
let consumer: Consumer | undefined;
|
|
28
|
+
let finished = false;
|
|
29
|
+
const found = new Promise<Position | null>((resolve, reject) => {
|
|
30
|
+
Consumer.implement(ctx, {
|
|
31
|
+
accept(location) { resolve(location instanceof Location ? position(location) : null); },
|
|
32
|
+
}).then(async (created) => {
|
|
33
|
+
consumer = created;
|
|
34
|
+
if (finished) {
|
|
35
|
+
release(created);
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
await manager.getCurrentLocation(name, signal, executor, created);
|
|
39
|
+
}).catch((failure: unknown) => { reject(locationError(failure as Error)); });
|
|
40
|
+
});
|
|
41
|
+
return within(found, timeout, () => {
|
|
42
|
+
finished = true;
|
|
43
|
+
void signal.cancel();
|
|
44
|
+
if (consumer) release(consumer);
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Passes each position the provider reports to `next`, and its failures to `error`, until the returned function is called. */
|
|
49
|
+
export async function watchPositions(
|
|
50
|
+
ctx: TokamakContext,
|
|
51
|
+
next: (position: Position) => void,
|
|
52
|
+
error: (error: unknown) => void,
|
|
53
|
+
): Promise<() => void> {
|
|
54
|
+
const { context, manager } = await locationManager(ctx);
|
|
55
|
+
const [name, executor] = await Promise.all([provider(manager), mainExecutor(context)]);
|
|
56
|
+
const listener = await LocationListener.implement(ctx, {
|
|
57
|
+
async onLocationChanged(location) {
|
|
58
|
+
next(await position(location));
|
|
59
|
+
release(location);
|
|
60
|
+
},
|
|
61
|
+
onProviderDisabled() { error(unavailable("Location provider is unavailable")); },
|
|
62
|
+
});
|
|
63
|
+
try {
|
|
64
|
+
await manager.requestLocationUpdates(name, 1000, 0, executor, listener);
|
|
65
|
+
} catch (failure) {
|
|
66
|
+
release(listener);
|
|
67
|
+
throw locationError(failure as Error);
|
|
68
|
+
}
|
|
69
|
+
return () => {
|
|
70
|
+
void manager.removeUpdates(listener).finally(() => { release(listener); });
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
async function locationManager(ctx: TokamakContext): Promise<{ context: Context; manager: LocationManager }> {
|
|
75
|
+
const context = new Context(await applicationContext(ctx));
|
|
76
|
+
const manager = await context.getSystemService(Context.LOCATION_SERVICE);
|
|
77
|
+
if (!(manager instanceof LocationManager)) throw unavailable("The device has no location service");
|
|
78
|
+
return { context, manager };
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
async function mainExecutor(context: Context): Promise<Executor> {
|
|
82
|
+
const executor = await context.getMainExecutor();
|
|
83
|
+
if (!executor) throw unavailable("The app has no main executor");
|
|
84
|
+
return executor;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** The first enabled of the GPS, network and fused providers, or any other enabled provider. */
|
|
88
|
+
async function provider(manager: LocationManager): Promise<string> {
|
|
89
|
+
for (const name of [LocationManager.GPS_PROVIDER, LocationManager.NETWORK_PROVIDER, LocationManager.FUSED_PROVIDER]) {
|
|
90
|
+
if (await manager.isProviderEnabled(name)) return name;
|
|
91
|
+
}
|
|
92
|
+
const [other] = await manager.getProviders(true);
|
|
93
|
+
if (!other) throw unavailable("No location provider is available");
|
|
94
|
+
return other;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** The milliseconds since Android took `location`. */
|
|
98
|
+
async function age(ctx: TokamakContext, location: Location): Promise<number> {
|
|
99
|
+
const [now, taken] = await Promise.all([SystemClock.elapsedRealtimeNanos(ctx), location.getElapsedRealtimeNanos()]);
|
|
100
|
+
return (now - taken) / 1_000_000;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
async function position(location: Location): Promise<Position> {
|
|
104
|
+
const [latitude, longitude, accuracy, hasAltitude, altitude, hasVertical, vertical, hasBearing, bearing, hasSpeed, speed, time] =
|
|
105
|
+
await Promise.all([
|
|
106
|
+
location.getLatitude(),
|
|
107
|
+
location.getLongitude(),
|
|
108
|
+
location.getAccuracy(),
|
|
109
|
+
location.hasAltitude(),
|
|
110
|
+
location.getAltitude(),
|
|
111
|
+
location.hasVerticalAccuracy(),
|
|
112
|
+
location.getVerticalAccuracyMeters(),
|
|
113
|
+
location.hasBearing(),
|
|
114
|
+
location.getBearing(),
|
|
115
|
+
location.hasSpeed(),
|
|
116
|
+
location.getSpeed(),
|
|
117
|
+
location.getTime(),
|
|
118
|
+
]);
|
|
119
|
+
return {
|
|
120
|
+
coords: {
|
|
121
|
+
latitude,
|
|
122
|
+
longitude,
|
|
123
|
+
accuracy,
|
|
124
|
+
altitude: hasAltitude ? altitude : null,
|
|
125
|
+
altitudeAccuracy: hasVertical ? vertical : null,
|
|
126
|
+
heading: hasBearing ? bearing : null,
|
|
127
|
+
speed: hasSpeed ? speed : null,
|
|
128
|
+
},
|
|
129
|
+
timestamp: time,
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** The error a failed Android call rejected with, as location operations name it. */
|
|
134
|
+
function locationError(failure: Error): Error {
|
|
135
|
+
if (!(failure instanceof DOMException)) return failure;
|
|
136
|
+
const exception = (failure.cause as { exception?: string } | undefined)?.exception;
|
|
137
|
+
return exception === "java.lang.SecurityException" ? permissionDenied() : unavailable(failure.message);
|
|
138
|
+
}
|
package/src/apple.ts
ADDED
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import type { TokamakContext } from "@tokamakdev/plugin";
|
|
2
|
+
import { release } from "@tokamakdev/plugin/bindings";
|
|
3
|
+
|
|
4
|
+
import { CLLocationManager, CLLocationManagerDelegate, type CLLocation } from "../apple/bindings.js";
|
|
5
|
+
import { permissionDenied, unavailable, within } from "./errors.js";
|
|
6
|
+
import type { Position, PositionOptions } from "./types.js";
|
|
7
|
+
|
|
8
|
+
/** The Core Location error codes the plugin tells apart. */
|
|
9
|
+
const locationUnknown = 0;
|
|
10
|
+
const denied = 1;
|
|
11
|
+
|
|
12
|
+
/** The device's position: Core Location's latest when no older than `maximumAge`, or a new fix within `timeout`. */
|
|
13
|
+
export async function currentPosition(ctx: TokamakContext, { maximumAge = 0, timeout }: PositionOptions): Promise<Position> {
|
|
14
|
+
const manager = await CLLocationManager.init(ctx);
|
|
15
|
+
try {
|
|
16
|
+
const recent = await manager.location();
|
|
17
|
+
if (recent && Date.now() - (await recent.timestamp()).getTime() <= maximumAge) return await position(recent);
|
|
18
|
+
return await nextFix(ctx, manager, timeout);
|
|
19
|
+
} finally {
|
|
20
|
+
release(manager);
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** The next fix `manager` takes, asking again while Core Location does not know the position yet. */
|
|
25
|
+
function nextFix(ctx: TokamakContext, manager: CLLocationManager, timeout: number | undefined): Promise<Position> {
|
|
26
|
+
let delegate: CLLocationManagerDelegate | undefined;
|
|
27
|
+
let finished = false;
|
|
28
|
+
const found = new Promise<Position>((resolve, reject) => {
|
|
29
|
+
CLLocationManagerDelegate.implement(ctx, {
|
|
30
|
+
locationManagerDidUpdateLocations(_, locations) {
|
|
31
|
+
const latest = locations.at(-1);
|
|
32
|
+
if (latest) resolve(position(latest));
|
|
33
|
+
},
|
|
34
|
+
locationManagerDidFailWithError(_, error) {
|
|
35
|
+
if (code(error) === locationUnknown) manager.requestLocation().catch(reject);
|
|
36
|
+
else reject(locationError(error));
|
|
37
|
+
},
|
|
38
|
+
}).then(async (created) => {
|
|
39
|
+
delegate = created;
|
|
40
|
+
if (finished) {
|
|
41
|
+
release(created);
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
await manager.setDelegate(created);
|
|
45
|
+
await manager.requestLocation();
|
|
46
|
+
}).catch(reject);
|
|
47
|
+
});
|
|
48
|
+
return within(found, timeout, () => {
|
|
49
|
+
finished = true;
|
|
50
|
+
if (delegate) release(delegate);
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Passes each position Core Location reports to `next`, and its failures to `error`, until the returned function is called. */
|
|
55
|
+
export async function watchPositions(
|
|
56
|
+
ctx: TokamakContext,
|
|
57
|
+
next: (position: Position) => void,
|
|
58
|
+
error: (error: unknown) => void,
|
|
59
|
+
): Promise<() => void> {
|
|
60
|
+
const manager = await CLLocationManager.init(ctx);
|
|
61
|
+
const delegate = await CLLocationManagerDelegate.implement(ctx, {
|
|
62
|
+
async locationManagerDidUpdateLocations(updated, locations) {
|
|
63
|
+
release(updated);
|
|
64
|
+
for (const location of locations) {
|
|
65
|
+
next(await position(location));
|
|
66
|
+
release(location);
|
|
67
|
+
}
|
|
68
|
+
},
|
|
69
|
+
locationManagerDidFailWithError(updated, failure) {
|
|
70
|
+
release(updated);
|
|
71
|
+
if (code(failure) !== locationUnknown) error(locationError(failure));
|
|
72
|
+
},
|
|
73
|
+
});
|
|
74
|
+
await manager.setDelegate(delegate);
|
|
75
|
+
await manager.startUpdatingLocation();
|
|
76
|
+
return () => {
|
|
77
|
+
void manager.stopUpdatingLocation().finally(() => {
|
|
78
|
+
release(delegate);
|
|
79
|
+
release(manager);
|
|
80
|
+
});
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
async function position(location: CLLocation): Promise<Position> {
|
|
85
|
+
const [coordinate, accuracy, altitude, verticalAccuracy, course, speed, timestamp] = await Promise.all([
|
|
86
|
+
location.coordinate(),
|
|
87
|
+
location.horizontalAccuracy(),
|
|
88
|
+
location.altitude(),
|
|
89
|
+
location.verticalAccuracy(),
|
|
90
|
+
location.course(),
|
|
91
|
+
location.speed(),
|
|
92
|
+
location.timestamp(),
|
|
93
|
+
]);
|
|
94
|
+
// Core Location reports a negative accuracy, course or speed when it has none.
|
|
95
|
+
return {
|
|
96
|
+
coords: {
|
|
97
|
+
latitude: coordinate.latitude,
|
|
98
|
+
longitude: coordinate.longitude,
|
|
99
|
+
accuracy,
|
|
100
|
+
altitude: verticalAccuracy >= 0 ? altitude : null,
|
|
101
|
+
altitudeAccuracy: verticalAccuracy >= 0 ? verticalAccuracy : null,
|
|
102
|
+
heading: course >= 0 ? course : null,
|
|
103
|
+
speed: speed >= 0 ? speed : null,
|
|
104
|
+
},
|
|
105
|
+
timestamp: timestamp.getTime(),
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** The code of a Core Location error. */
|
|
110
|
+
function code(error: DOMException): number | undefined {
|
|
111
|
+
const cause = error.cause as { domain?: string; code?: number } | undefined;
|
|
112
|
+
return cause?.domain === "kCLErrorDomain" ? cause.code : undefined;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function locationError(error: DOMException): DOMException {
|
|
116
|
+
return code(error) === denied ? permissionDenied() : unavailable(error.message);
|
|
117
|
+
}
|
package/src/errors.ts
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/** The errors location operations reject with. */
|
|
2
|
+
|
|
3
|
+
export function permissionDenied(): DOMException {
|
|
4
|
+
return new DOMException("Location permission was denied", "NotAllowedError");
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export function backgroundDenied(): DOMException {
|
|
8
|
+
return new DOMException("Location permission does not cover the background", "NotAllowedError");
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function timedOut(): DOMException {
|
|
12
|
+
return new DOMException("Location was not found in time", "TimeoutError");
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function unavailable(message: string): DOMException {
|
|
16
|
+
return new DOMException(message, "NotReadableError");
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Rejects once `timeout` milliseconds pass without `found` settling, running `stop` either way. An
|
|
21
|
+
* undefined `timeout` waits as long as it takes.
|
|
22
|
+
*/
|
|
23
|
+
export async function within<T>(found: Promise<T>, timeout: number | undefined, stop: () => void): Promise<T> {
|
|
24
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
25
|
+
const expired = new Promise<never>((_, reject) => {
|
|
26
|
+
if (timeout !== undefined) timer = setTimeout(() => { reject(timedOut()); }, timeout);
|
|
27
|
+
});
|
|
28
|
+
try {
|
|
29
|
+
return await Promise.race([found, expired]);
|
|
30
|
+
} finally {
|
|
31
|
+
clearTimeout(timer);
|
|
32
|
+
stop();
|
|
33
|
+
}
|
|
34
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -1,18 +1,16 @@
|
|
|
1
|
-
import { Plugin } from "@tokamakdev/plugin";
|
|
2
|
-
import
|
|
3
|
-
|
|
4
|
-
import
|
|
1
|
+
import { Plugin, lifecycleStage, type JsonStream, type TokamakContext } from "@tokamakdev/plugin";
|
|
2
|
+
import { valueStream } from "@tokamakdev/plugin/streams";
|
|
3
|
+
|
|
4
|
+
import * as android from "./android.js";
|
|
5
|
+
import * as apple from "./apple.js";
|
|
6
|
+
import { backgroundDenied, permissionDenied, unavailable } from "./errors.js";
|
|
5
7
|
import type {
|
|
6
|
-
ErrorListener,
|
|
7
|
-
Listener,
|
|
8
8
|
LocationErrorEvent,
|
|
9
9
|
LocationPermission,
|
|
10
10
|
LocationUpdate,
|
|
11
11
|
MonitoredRegion,
|
|
12
12
|
PermissionRequest,
|
|
13
13
|
Position,
|
|
14
|
-
PositionCallback,
|
|
15
|
-
PositionErrorCallback,
|
|
16
14
|
PositionOptions,
|
|
17
15
|
Region,
|
|
18
16
|
RegionTransition,
|
|
@@ -24,37 +22,78 @@ import type {
|
|
|
24
22
|
export type * from "./types.js";
|
|
25
23
|
|
|
26
24
|
class Location extends Plugin {
|
|
27
|
-
|
|
28
|
-
|
|
25
|
+
readonly #ctx: TokamakContext;
|
|
26
|
+
|
|
27
|
+
constructor(ctx: TokamakContext) {
|
|
28
|
+
super(ctx, "location");
|
|
29
|
+
this.#ctx = ctx;
|
|
29
30
|
}
|
|
30
31
|
|
|
31
32
|
/**
|
|
32
33
|
* Applies long-term location awareness, replacing the previous configuration, which the plugin keeps
|
|
33
|
-
* and applies again at each start. Rejects
|
|
34
|
-
*
|
|
35
|
-
* significant changes.
|
|
34
|
+
* and applies again at each start. Rejects on Apple platforms with `NotSupportedError` where the
|
|
35
|
+
* device cannot monitor significant changes.
|
|
36
36
|
*/
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
return this.hasNativeTransport ? this.call("setup", config) : web.unsupported();
|
|
37
|
+
setup(config: SetupConfig): Promise<void> {
|
|
38
|
+
return this.call("setup", config);
|
|
40
39
|
}
|
|
41
40
|
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
41
|
+
/**
|
|
42
|
+
* The device's position, asking for in-use permission where the user has not answered. Rejects
|
|
43
|
+
* with `NotAllowedError` without it, `TimeoutError` after `timeout`, and `NotReadableError`
|
|
44
|
+
* when location is unavailable.
|
|
45
|
+
*/
|
|
46
|
+
async getCurrentPosition(options: PositionOptions = {}): Promise<Position> {
|
|
47
|
+
await this.#authorize();
|
|
48
|
+
const position = await this.#platform().currentPosition(this.#ctx, options);
|
|
49
|
+
if (position) return position;
|
|
50
|
+
// Android finds no position in the background without background permission.
|
|
51
|
+
const background = await lifecycleStage(this.#ctx) === "background";
|
|
52
|
+
throw background && (await this.permission()).background !== "granted" ? backgroundDenied() : unavailable("Location is unavailable");
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Positions as the device moves, after `getCurrentPosition`'s permission checks. A slow reader receives the newest. */
|
|
56
|
+
watchPosition(): JsonStream<Position> {
|
|
57
|
+
return valueStream<Position>((next, error) => {
|
|
58
|
+
let stop: (() => void) | undefined;
|
|
59
|
+
let stopped = false;
|
|
60
|
+
this.#authorize()
|
|
61
|
+
.then(() => this.#platform().watchPositions(this.#ctx, next, error))
|
|
62
|
+
.then((started) => {
|
|
63
|
+
if (stopped) started();
|
|
64
|
+
else stop = started;
|
|
65
|
+
}, error);
|
|
66
|
+
return () => {
|
|
67
|
+
stopped = true;
|
|
68
|
+
stop?.();
|
|
69
|
+
};
|
|
70
|
+
}, "dropOldest");
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Requires location services and in-use permission, asking for it where the user has not answered. */
|
|
74
|
+
async #authorize(): Promise<void> {
|
|
75
|
+
if (!await this.servicesEnabled()) throw unavailable("Location services are disabled");
|
|
76
|
+
let { inUse } = await this.permission();
|
|
77
|
+
if (inUse === "prompt") ({ inUse } = await this.requestPermission());
|
|
78
|
+
if (inUse !== "granted") throw permissionDenied();
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** The platform's composition of its location SDK. */
|
|
82
|
+
#platform(): typeof apple | typeof android {
|
|
83
|
+
switch (this.#ctx.platform) {
|
|
84
|
+
case "ios":
|
|
85
|
+
case "macos":
|
|
86
|
+
return apple;
|
|
87
|
+
case "android":
|
|
88
|
+
return android;
|
|
89
|
+
default:
|
|
90
|
+
throw new DOMException("Location is not supported on this platform", "NotSupportedError");
|
|
91
|
+
}
|
|
53
92
|
}
|
|
54
93
|
|
|
55
94
|
/** The app's in-use and background permission, and the precision it gets. Never asks. */
|
|
56
95
|
permission(): Promise<LocationPermission> {
|
|
57
|
-
return this.
|
|
96
|
+
return this.call("permission");
|
|
58
97
|
}
|
|
59
98
|
|
|
60
99
|
/**
|
|
@@ -62,19 +101,18 @@ class Location extends Plugin {
|
|
|
62
101
|
* Android asks for background permission once in-use permission is granted. Rejects with
|
|
63
102
|
* `NeedsUIError` when a prompt would show off screen.
|
|
64
103
|
*/
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
return this.hasNativeTransport ? this.call("requestPermission", options) : web.unsupported();
|
|
104
|
+
requestPermission(options: PermissionRequest = {}): Promise<LocationPermission> {
|
|
105
|
+
return this.call("requestPermission", options);
|
|
68
106
|
}
|
|
69
107
|
|
|
70
108
|
/** Whether location services are on for the device. While they are off, nothing locates. */
|
|
71
109
|
servicesEnabled(): Promise<boolean> {
|
|
72
|
-
return this.
|
|
110
|
+
return this.call("servicesEnabled");
|
|
73
111
|
}
|
|
74
112
|
|
|
75
113
|
/** Opens the app's page in the system settings, where the user changes its location permission. */
|
|
76
114
|
openSettings(): Promise<void> {
|
|
77
|
-
return this.
|
|
115
|
+
return this.call("openSettings");
|
|
78
116
|
}
|
|
79
117
|
|
|
80
118
|
/**
|
|
@@ -85,18 +123,17 @@ class Location extends Plugin {
|
|
|
85
123
|
* without the `background-updates` build setting, and with `NeedsUIError` when Android does not
|
|
86
124
|
* allow the service to start off screen.
|
|
87
125
|
*/
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
return this.hasNativeTransport ? this.call("startUpdates", options) : web.unsupported();
|
|
126
|
+
startUpdates(options: UpdateOptions = {}): Promise<void> {
|
|
127
|
+
return this.call("startUpdates", options);
|
|
91
128
|
}
|
|
92
129
|
|
|
93
130
|
stopUpdates(): Promise<void> {
|
|
94
|
-
return this.
|
|
131
|
+
return this.call("stopUpdates");
|
|
95
132
|
}
|
|
96
133
|
|
|
97
134
|
/** The options of the running updates, or null when none are running. */
|
|
98
135
|
getUpdates(): Promise<UpdateOptions | null> {
|
|
99
|
-
return this.
|
|
136
|
+
return this.call("getUpdates");
|
|
100
137
|
}
|
|
101
138
|
|
|
102
139
|
/**
|
|
@@ -105,51 +142,39 @@ class Location extends Plugin {
|
|
|
105
142
|
* region starts outside, so a device already inside reports an entry. Rejects with
|
|
106
143
|
* `NotAllowedError` without precise location, and with `QuotaExceededError` beyond Apple's 20.
|
|
107
144
|
*/
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
return this.hasNativeTransport ? this.call("addRegion", monitored) : web.unsupported();
|
|
145
|
+
addRegion(region: Region): Promise<void> {
|
|
146
|
+
return this.call("addRegion", region);
|
|
111
147
|
}
|
|
112
148
|
|
|
113
149
|
/** The monitored regions. */
|
|
114
150
|
getRegions(): Promise<MonitoredRegion[]> {
|
|
115
|
-
return this.
|
|
151
|
+
return this.call("getRegions");
|
|
116
152
|
}
|
|
117
153
|
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
return this.hasNativeTransport ? this.call("removeRegion", { id }) : web.unsupported();
|
|
154
|
+
removeRegion(id: string): Promise<void> {
|
|
155
|
+
return this.call("removeRegion", { id });
|
|
121
156
|
}
|
|
122
157
|
|
|
123
158
|
/** Receives the updates the Worker's `onLocationUpdate` receives, while the listening page or Worker runs. */
|
|
124
|
-
onLocationUpdate(
|
|
125
|
-
return this.
|
|
159
|
+
onLocationUpdate(): JsonStream<LocationUpdate> {
|
|
160
|
+
return this.stream("onLocationUpdate", null, "dropOldest");
|
|
126
161
|
}
|
|
127
162
|
|
|
128
163
|
/** Receives the transitions the Worker's `onRegionTransition` receives, while the listening page or Worker runs. */
|
|
129
|
-
onRegionTransition(
|
|
130
|
-
return this.
|
|
164
|
+
onRegionTransition(): JsonStream<RegionTransition> {
|
|
165
|
+
return this.stream("onRegionTransition");
|
|
131
166
|
}
|
|
132
167
|
|
|
133
168
|
/** Apple only: receives the visits the Worker's `onVisit` receives, while the listening page or Worker runs. */
|
|
134
|
-
onVisit(
|
|
135
|
-
return this.
|
|
169
|
+
onVisit(): JsonStream<Visit> {
|
|
170
|
+
return this.stream("onVisit");
|
|
136
171
|
}
|
|
137
172
|
|
|
138
173
|
/** Receives the failures the Worker's `onLocationError` receives, while the listening page or Worker runs. */
|
|
139
|
-
onLocationError(
|
|
140
|
-
return this.
|
|
174
|
+
onLocationError(): JsonStream<LocationErrorEvent> {
|
|
175
|
+
return this.stream("onLocationError");
|
|
141
176
|
}
|
|
142
177
|
}
|
|
143
178
|
|
|
144
|
-
function report(error: DOMException): void {
|
|
145
|
-
console.error(error);
|
|
146
|
-
}
|
|
147
|
-
|
|
148
|
-
const unlimited = 0xffffffff;
|
|
149
|
-
|
|
150
|
-
/** `value` clamped to whole milliseconds, as the Geolocation API reads its options. */
|
|
151
|
-
function milliseconds(value: number): number {
|
|
152
|
-
return Number.isNaN(value) ? 0 : Math.min(Math.max(Math.round(value), 0), unlimited);
|
|
153
|
-
}
|
|
154
179
|
|
|
155
|
-
export
|
|
180
|
+
export function location(ctx: TokamakContext): Location { return new Location(ctx); }
|
package/src/types.ts
CHANGED
|
@@ -174,3 +174,15 @@ export interface LocationErrorEvent {
|
|
|
174
174
|
|
|
175
175
|
export type Listener<T> = (event: T) => void;
|
|
176
176
|
export type ErrorListener = (error: DOMException) => void;
|
|
177
|
+
|
|
178
|
+
declare global {
|
|
179
|
+
// eslint-disable-next-line @typescript-eslint/no-namespace -- events are declared by merging into Tokamak.Events.
|
|
180
|
+
namespace Tokamak {
|
|
181
|
+
interface Events {
|
|
182
|
+
"location.update": LocationUpdate;
|
|
183
|
+
"location.region": RegionTransition;
|
|
184
|
+
"location.visit": Visit;
|
|
185
|
+
"location.error": LocationErrorEvent;
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
}
|
package/tokamak-plugin.json
CHANGED
|
@@ -1,6 +1,26 @@
|
|
|
1
1
|
{
|
|
2
|
-
"schemaVersion":
|
|
2
|
+
"schemaVersion": 2,
|
|
3
3
|
"id": "location",
|
|
4
|
+
"bindings": {
|
|
5
|
+
"apple": {
|
|
6
|
+
"sdk": "26.2",
|
|
7
|
+
"modules": [
|
|
8
|
+
"CoreLocation",
|
|
9
|
+
"_LocationEssentials"
|
|
10
|
+
]
|
|
11
|
+
},
|
|
12
|
+
"android": {
|
|
13
|
+
"sdk": 35,
|
|
14
|
+
"modules": [
|
|
15
|
+
"android.location",
|
|
16
|
+
"android.content.Context",
|
|
17
|
+
"android.os.CancellationSignal",
|
|
18
|
+
"android.os.SystemClock",
|
|
19
|
+
"java.util.concurrent.Executor",
|
|
20
|
+
"java.util.function.Consumer"
|
|
21
|
+
]
|
|
22
|
+
}
|
|
23
|
+
},
|
|
4
24
|
"settings": {
|
|
5
25
|
"background": {
|
|
6
26
|
"kind": "boolean",
|
|
@@ -14,7 +34,10 @@
|
|
|
14
34
|
},
|
|
15
35
|
"precision": {
|
|
16
36
|
"kind": "enum",
|
|
17
|
-
"values": [
|
|
37
|
+
"values": [
|
|
38
|
+
"precise",
|
|
39
|
+
"approximate"
|
|
40
|
+
],
|
|
18
41
|
"default": "precise",
|
|
19
42
|
"description": "The precision the app declares; approximate leaves out precise location"
|
|
20
43
|
},
|
|
@@ -63,5 +86,49 @@
|
|
|
63
86
|
"android/LocationUpdates.kt"
|
|
64
87
|
]
|
|
65
88
|
}
|
|
89
|
+
},
|
|
90
|
+
"api": {
|
|
91
|
+
"module": "src/index.ts",
|
|
92
|
+
"export": "location"
|
|
93
|
+
},
|
|
94
|
+
"web": {
|
|
95
|
+
"module": "web/client.ts",
|
|
96
|
+
"export": "location"
|
|
97
|
+
},
|
|
98
|
+
"operations": {
|
|
99
|
+
"getCurrentPosition": {
|
|
100
|
+
"foreground": [
|
|
101
|
+
"ios"
|
|
102
|
+
]
|
|
103
|
+
},
|
|
104
|
+
"watchPosition": {
|
|
105
|
+
"foreground": [
|
|
106
|
+
"ios"
|
|
107
|
+
]
|
|
108
|
+
},
|
|
109
|
+
"requestPermission": {
|
|
110
|
+
"foreground": [
|
|
111
|
+
"ios",
|
|
112
|
+
"android"
|
|
113
|
+
]
|
|
114
|
+
},
|
|
115
|
+
"openSettings": {
|
|
116
|
+
"foreground": [
|
|
117
|
+
"ios",
|
|
118
|
+
"android"
|
|
119
|
+
]
|
|
120
|
+
},
|
|
121
|
+
"startUpdates": {
|
|
122
|
+
"foreground": [
|
|
123
|
+
"ios",
|
|
124
|
+
"android"
|
|
125
|
+
]
|
|
126
|
+
},
|
|
127
|
+
"onVisit": {
|
|
128
|
+
"platforms": {
|
|
129
|
+
"ios": true,
|
|
130
|
+
"macos": true
|
|
131
|
+
}
|
|
132
|
+
}
|
|
66
133
|
}
|
|
67
134
|
}
|