@tokamakdev/plugin-location 0.1.0-beta.102 → 0.1.0-beta.104
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 +217 -18
- package/android/LocationEvents.kt +82 -0
- package/android/LocationPlugin.kt +264 -36
- package/android/LocationReceiver.kt +27 -0
- package/android/LocationRegions.kt +73 -0
- package/android/LocationService.kt +75 -0
- package/android/LocationUpdates.kt +120 -0
- package/apple/LocationEvents.swift +149 -0
- package/apple/LocationPlugin.swift +350 -56
- package/apple/LocationRegions.swift +175 -0
- package/apple/LocationUpdates.swift +137 -0
- package/build/declarations.d.mts +19 -0
- package/build/declarations.mjs +105 -0
- package/build/index.mjs +17 -0
- package/events/index.ts +18 -0
- package/package.json +6 -3
- package/src/checks.ts +65 -0
- package/src/index.ts +124 -25
- package/src/options.ts +54 -0
- package/src/types.ts +173 -0
- package/tokamak-plugin.json +52 -6
- package/web/index.ts +15 -1
- package/android/AndroidManifest.xml +0 -4
- package/apple/ios/Info.plist +0 -8
- package/apple/macos/Info.plist +0 -8
package/README.md
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
# @tokamakdev/plugin-location
|
|
2
2
|
|
|
3
|
-
Location for
|
|
4
|
-
|
|
5
|
-
|
|
3
|
+
Location for tokamak applications and the web: positions on request,
|
|
4
|
+
long-term location awareness from significant changes, visits and regions,
|
|
5
|
+
and continuous tracking, all of which reach the app's Worker off screen.
|
|
6
6
|
|
|
7
7
|
```sh
|
|
8
8
|
npm install @tokamakdev/plugin-location
|
|
@@ -12,32 +12,231 @@ npm install @tokamakdev/plugin-location
|
|
|
12
12
|
import { location } from "@tokamakdev/plugin-location";
|
|
13
13
|
|
|
14
14
|
const position = await location.getCurrentPosition();
|
|
15
|
+
const stop = location.watchPosition((next) => console.log(next.coords), console.error);
|
|
16
|
+
|
|
17
|
+
await location.requestPermission({ background: true });
|
|
18
|
+
await location.addRegion({ id: "home", latitude: 51.5, longitude: -0.12, radius: 150 });
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
```ts
|
|
22
|
+
// src/tokamak.ts
|
|
23
|
+
import { location } from "@tokamakdev/plugin-location";
|
|
24
|
+
import { onLocationUpdate, onRegionTransition } from "@tokamakdev/plugin-location/events";
|
|
25
|
+
import { onStart } from "@tokamakdev/tok/events";
|
|
15
26
|
|
|
16
|
-
|
|
17
|
-
(
|
|
18
|
-
|
|
19
|
-
)
|
|
27
|
+
onStart(async () => {
|
|
28
|
+
await location.setup({ significantChanges: true, visits: true });
|
|
29
|
+
});
|
|
30
|
+
onLocationUpdate(async ({ positions }) => {
|
|
31
|
+
await fetch("https://example.com/whereabouts", { method: "POST", body: JSON.stringify(positions) });
|
|
32
|
+
});
|
|
33
|
+
onRegionTransition(({ id, transition }) => console.log(id, transition));
|
|
20
34
|
```
|
|
21
35
|
|
|
36
|
+
## Positions
|
|
37
|
+
|
|
22
38
|
`getCurrentPosition` takes the Geolocation API's `maximumAge` and `timeout`
|
|
23
39
|
options. It resolves with a recent position no older than `maximumAge`
|
|
24
40
|
milliseconds, 0 by default, or takes a new one, and rejects with
|
|
25
41
|
`TimeoutError` when none arrives within `timeout` milliseconds, unlimited by
|
|
26
42
|
default. The timeout starts once the user allows location.
|
|
27
43
|
|
|
28
|
-
|
|
44
|
+
`watchPosition(next, error)` calls `next` with each new position until the
|
|
45
|
+
function it returns is called.
|
|
29
46
|
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
call that would ask the user for permission rejects with `NeedsUIError`. With
|
|
33
|
-
permission granted, the platforms stop locating an app that is off screen
|
|
34
|
-
without background location permission, which the plugin does not request, so
|
|
47
|
+
Both ask for in-use permission when the user has not answered. Off screen
|
|
48
|
+
without background permission, the platforms stop locating the app, so
|
|
35
49
|
`getCurrentPosition` rejects with `NotAllowedError` there. iOS still locates
|
|
36
|
-
the app for a few seconds after it leaves the screen.
|
|
50
|
+
the app for a few seconds after it leaves the screen. With approximate
|
|
51
|
+
permission, Android gives an app a new position at most every 10 minutes, so
|
|
52
|
+
`getCurrentPosition` can wait that long.
|
|
53
|
+
|
|
54
|
+
## Permission
|
|
55
|
+
|
|
56
|
+
`permission()` reports the app's permission without asking:
|
|
57
|
+
|
|
58
|
+
- `inUse` and `background`, each `"granted"`, `"denied"` or `"prompt"`.
|
|
59
|
+
`background` is `"denied"` unless the `background` build setting declares
|
|
60
|
+
it. macOS has no separate background permission, so `background` equals
|
|
61
|
+
`inUse` there.
|
|
62
|
+
- `precision`: `"precise"` or `"approximate"`, what the app gets or would get
|
|
63
|
+
once allowed.
|
|
64
|
+
|
|
65
|
+
`requestPermission(options)` asks for in-use permission where the user has not
|
|
66
|
+
answered, and for what `options` names, then resolves with the permission:
|
|
67
|
+
|
|
68
|
+
- `background: true` asks for background permission.
|
|
69
|
+
- **iOS** shows the Always prompt once, from When In Use. Asked before the
|
|
70
|
+
user has answered, it gives provisional Always, which iOS confirms with
|
|
71
|
+
the user later. Without the `background` build setting it rejects with
|
|
72
|
+
`NotSupportedError`.
|
|
73
|
+
- **Android** asks for in-use permission first, as Android grants
|
|
74
|
+
background permission only after it. From Android 11 the request opens
|
|
75
|
+
the app's page in Settings, where the user picks "Allow all the time".
|
|
76
|
+
- `precision: "precise"` asks for precise location where the user allowed
|
|
77
|
+
approximate. Apple platforms grant it temporarily, while the app is in use,
|
|
78
|
+
for the reason the `accuracy-purposes` build setting gives for `purpose`.
|
|
79
|
+
`precision: "approximate"` asks Android for approximate location only.
|
|
80
|
+
|
|
81
|
+
Off screen on iOS and Android, a request that would show a prompt rejects with
|
|
82
|
+
`NeedsUIError`. `openSettings()` opens the app's page in the system settings,
|
|
83
|
+
or Location Services on macOS, where the user changes the permission.
|
|
84
|
+
`servicesEnabled()` reports whether location services are on for the device.
|
|
85
|
+
|
|
86
|
+
## Build settings
|
|
87
|
+
|
|
88
|
+
The plugin's declarations come from its build settings, set in the tokamak
|
|
89
|
+
Vite plugin's options, as `--location-<key>`, or as `TOKAMAK_LOCATION_<KEY>`:
|
|
90
|
+
|
|
91
|
+
```js
|
|
92
|
+
// vite.config.js
|
|
93
|
+
tokamak({
|
|
94
|
+
location: {
|
|
95
|
+
background: true,
|
|
96
|
+
"background-updates": true,
|
|
97
|
+
"accuracy-purposes": { Directions: "Shows directions from where you are" },
|
|
98
|
+
},
|
|
99
|
+
});
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
| Setting | Kind | Declares |
|
|
103
|
+
|---|---|---|
|
|
104
|
+
| `background` | boolean | Background permission: iOS's Always reason, Android's `ACCESS_BACKGROUND_LOCATION`. |
|
|
105
|
+
| `background-updates` | boolean | Updates off screen: iOS's `location` background mode, Android's location foreground service and its permissions. |
|
|
106
|
+
| `precision` | `precise` or `approximate` | `approximate` declares approximate location only: iOS's `NSLocationDefaultAccuracyReduced`, and Android without `ACCESS_FINE_LOCATION`. macOS has no such declaration. |
|
|
107
|
+
| `when-in-use-description` | string | Apple: the in-use prompt's reason, and macOS's only reason. |
|
|
108
|
+
| `always-description` | string | iOS: the Always prompt's reason. Needs `background`. |
|
|
109
|
+
| `accuracy-purposes` | map of string | Apple: the reasons for temporary precise location, by purpose key. |
|
|
110
|
+
|
|
111
|
+
Without a description the plugin uses a generic English reason. Declare only
|
|
112
|
+
what the app uses: App Review rejects background modes an app has no feature
|
|
113
|
+
for, and Google Play reviews background location and foreground service types
|
|
114
|
+
with a declaration and a video.
|
|
115
|
+
|
|
116
|
+
## Worker events
|
|
117
|
+
|
|
118
|
+
The Worker's listeners, from `@tokamakdev/plugin-location/events`, run whether
|
|
119
|
+
or not a page is loaded, including when the system launches the app for an
|
|
120
|
+
event:
|
|
121
|
+
|
|
122
|
+
| Event | Receives |
|
|
123
|
+
|---|---|
|
|
124
|
+
| `onLocationUpdate` | `{ positions, stationary? }` from updates and significant changes. |
|
|
125
|
+
| `onRegionTransition` | `{ id, transition, timestamp }`, where `transition` is `"enter"` or `"exit"`. |
|
|
126
|
+
| `onVisit` | Apple only: `{ latitude, longitude, accuracy, arrival, departure }`, times in milliseconds or null when unknown. |
|
|
127
|
+
| `onLocationError` | `{ name, message, id? }` when a background service fails, with the region's `id` when it concerns one. |
|
|
128
|
+
|
|
129
|
+
`positions` holds the positions that arrived while the Worker handled the
|
|
130
|
+
previous update, oldest first, so a slow Worker never builds a queue. Apple's
|
|
131
|
+
`stationary`, from iOS 18 and macOS 15, is true on the last update before
|
|
132
|
+
updates pause while the device is still.
|
|
133
|
+
|
|
134
|
+
The plugin's `onLocationUpdate`, `onRegionTransition`, `onVisit` and
|
|
135
|
+
`onLocationError` methods receive the same events while the listening page or
|
|
136
|
+
Worker runs, and return the function that removes the listener.
|
|
137
|
+
|
|
138
|
+
## Long-term awareness
|
|
139
|
+
|
|
140
|
+
`setup(config)` keeps the app aware of where the device is over days, with
|
|
141
|
+
little power and no indicator. Call it from `onStart` in `src/tokamak.ts`, so
|
|
142
|
+
it applies at every launch; the plugin also keeps it and applies it again
|
|
143
|
+
itself. The configuration replaces the previous one, and an invalid one
|
|
144
|
+
rejects with `TypeError` and changes nothing. Apple platforms reject with
|
|
145
|
+
`NotSupportedError` where the device cannot monitor significant changes.
|
|
146
|
+
|
|
147
|
+
- `significantChanges` delivers moves of 500 metres or more, at most every 5
|
|
148
|
+
minutes, to `onLocationUpdate`, located from cellular and Wi-Fi rather than
|
|
149
|
+
GPS. Apple platforms use the significant-change service. Android uses a
|
|
150
|
+
low-power request with the same thresholds, which it limits to a few an hour
|
|
151
|
+
off screen. Without permission they start once the app has it on Apple
|
|
152
|
+
platforms, and on Android after the plugin's prompt or at the next launch.
|
|
153
|
+
- `visits`, Apple only, delivers arrivals at and departures from the places
|
|
154
|
+
the user stays to `onVisit`. It uses the least power of all.
|
|
155
|
+
|
|
156
|
+
With background permission, iOS relaunches the app for both, and Android starts
|
|
157
|
+
the app's process for significant changes. [Regions](#regions) wake the app
|
|
158
|
+
the same way.
|
|
159
|
+
|
|
160
|
+
## Continuous tracking
|
|
161
|
+
|
|
162
|
+
`startUpdates(options)` starts continuous updates for an activity the user
|
|
163
|
+
follows, such as a run or navigation, replacing any running, until
|
|
164
|
+
`stopUpdates()`. Off screen the system can show that the app is tracking:
|
|
165
|
+
iOS's blue location pill with in-use permission, and the notification of
|
|
166
|
+
Android's foreground service.
|
|
167
|
+
`getUpdates()` resolves with the running updates' options, or null. Updates
|
|
168
|
+
need in-use permission, and reject with `NotAllowedError` without it. The
|
|
169
|
+
plugin keeps them across launches and restarts.
|
|
170
|
+
|
|
171
|
+
**Apple** uses `CLLocationUpdate.liveUpdates`.
|
|
172
|
+
|
|
173
|
+
- `activity` is the live configuration: `"default"`, `"automotiveNavigation"`,
|
|
174
|
+
`"otherNavigation"`, `"fitness"`, `"airborne"`, or `"maritime"` (iOS 27 and
|
|
175
|
+
macOS 27). It tunes when updates pause and how coordinates follow roads.
|
|
176
|
+
- Updates pause while the device is still and resume when it moves.
|
|
177
|
+
- If iOS ends the app while updates run, it launches the app for the next
|
|
178
|
+
update, and the plugin resumes them.
|
|
179
|
+
- Off screen, updates need Always permission, or the `background-updates`
|
|
180
|
+
build setting, whose background activity session shows the pill.
|
|
181
|
+
`startUpdates` rejects with `NeedsUIError` off screen on iOS.
|
|
182
|
+
- From iOS 18, failures such as denied permission or lost location reach
|
|
183
|
+
`onLocationError` once each.
|
|
184
|
+
- `getCurrentPosition` and `watchPosition` can change the coordinates updates
|
|
185
|
+
deliver while they run.
|
|
186
|
+
|
|
187
|
+
**Android** uses `LocationManager` with a `PendingIntent`, which starts the
|
|
188
|
+
app's process for each update.
|
|
189
|
+
|
|
190
|
+
- `accuracy` is `"high"` (default), `"balanced"` or `"low"`; `distance` the
|
|
191
|
+
metres between updates, 0 by default; `interval` the milliseconds between
|
|
192
|
+
them, 10 seconds by default.
|
|
193
|
+
- Off screen, updates need background permission and arrive a few times an
|
|
194
|
+
hour, unless they run in a foreground service.
|
|
195
|
+
- `foregroundService: { title, body, channel }` runs updates in a foreground
|
|
196
|
+
service, with that notification in a channel of that name, at the requested
|
|
197
|
+
rate off screen. It needs the `background-updates` build setting, and
|
|
198
|
+
rejects with `NotSupportedError` without it. Off screen it needs background
|
|
199
|
+
permission, and Android starts the service only within 10 seconds of an
|
|
200
|
+
update; otherwise it rejects with `NeedsUIError`. A region transition does
|
|
201
|
+
not allow the start.
|
|
202
|
+
- The plugin requests updates again after a restart, an app update and a
|
|
203
|
+
force stop, and starts the service again on screen, or after a restart or
|
|
204
|
+
update with background permission. Android restarts a service it ends for
|
|
205
|
+
memory.
|
|
206
|
+
|
|
207
|
+
## Regions
|
|
208
|
+
|
|
209
|
+
`addRegion({ id, latitude, longitude, radius, onEntry, onExit })` monitors a
|
|
210
|
+
circular region of `radius` metres, replacing one with the same `id`, until
|
|
211
|
+
`removeRegion(id)`. `onEntry` and `onExit` are true by default. Entries and
|
|
212
|
+
exits reach `onRegionTransition`, including when the system launches the app
|
|
213
|
+
for one. A region starts outside, so a device already inside reports an
|
|
214
|
+
entry. `getRegions()` resolves with the regions, each with its `state` on
|
|
215
|
+
Apple platforms: `"inside"`, `"outside"` or `"unknown"`.
|
|
216
|
+
|
|
217
|
+
Regions need precise location, and reject with `NotAllowedError` without it,
|
|
218
|
+
and background permission off screen.
|
|
219
|
+
|
|
220
|
+
- **Apple** uses `CLMonitor`, which monitors up to 20 regions; `addRegion`
|
|
221
|
+
rejects with `QuotaExceededError` beyond them. iOS relaunches the app for a
|
|
222
|
+
transition, even after the user quits it. macOS monitors only while the app
|
|
223
|
+
runs.
|
|
224
|
+
- **Android** uses proximity alerts, which the system evaluates about every 30
|
|
225
|
+
minutes or less often, so a transition can arrive that long after it
|
|
226
|
+
happens. Adding a region again, as the plugin does after a restart, reports
|
|
227
|
+
an entry again for a device inside.
|
|
37
228
|
|
|
38
229
|
## Platforms
|
|
39
230
|
|
|
40
|
-
The web
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
231
|
+
The web uses `navigator.geolocation`. `permission()` reports `inUse` from the
|
|
232
|
+
Permissions API, and the other additions reject with `NotSupportedError`,
|
|
233
|
+
while the listener methods receive nothing. Windows uses WebView2's location
|
|
234
|
+
for `getCurrentPosition` and `watchPosition`.
|
|
235
|
+
|
|
236
|
+
## Errors
|
|
237
|
+
|
|
238
|
+
Rejections and `onLocationError` events are `DOMException` names:
|
|
239
|
+
`NotAllowedError` (no permission, or none for the background),
|
|
240
|
+
`NotReadableError` (location unavailable, or services off), `NeedsUIError` (a
|
|
241
|
+
prompt or a start off screen), `NotSupportedError`, `QuotaExceededError`,
|
|
242
|
+
`TimeoutError` and `TypeError`.
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
package com.tokamak.plugins.location
|
|
2
|
+
|
|
3
|
+
import android.util.Log
|
|
4
|
+
import com.tokamak.runtime.TokamakHost
|
|
5
|
+
import com.tokamak.runtime.TokamakPluginError
|
|
6
|
+
import com.tokamak.runtime.TokamakPluginReply
|
|
7
|
+
import org.json.JSONArray
|
|
8
|
+
import org.json.JSONObject
|
|
9
|
+
|
|
10
|
+
/** How long the Worker's listeners have for an event, within the time Android gives a broadcast. */
|
|
11
|
+
private const val EVENT_DEADLINE_MILLIS = 8_000L
|
|
12
|
+
|
|
13
|
+
/** The listener methods, which receive the Worker's events. */
|
|
14
|
+
private val LISTENERS = setOf("onLocationUpdate", "onRegionTransition", "onVisit", "onLocationError")
|
|
15
|
+
|
|
16
|
+
/** Delivers the plugin's events to the Worker's listeners and the page's. Use it on the main thread. */
|
|
17
|
+
internal class LocationEvents(private val host: TokamakHost) {
|
|
18
|
+
private val listeners = mutableMapOf<String, MutableMap<Int, TokamakPluginReply>>()
|
|
19
|
+
private var nextListener = 0
|
|
20
|
+
|
|
21
|
+
/** Positions that arrived while the Worker handled the previous update, and the broadcasts to finish once it has them. */
|
|
22
|
+
private val positions = mutableListOf<JSONObject>()
|
|
23
|
+
private val finishes = mutableListOf<() -> Unit>()
|
|
24
|
+
private var updating = false
|
|
25
|
+
|
|
26
|
+
/** Adds a page's or Worker's listener of [method], returning its removal, or null for another method. */
|
|
27
|
+
fun listen(method: String, reply: TokamakPluginReply): (() -> Unit)? {
|
|
28
|
+
if (method !in LISTENERS) return null
|
|
29
|
+
val key = nextListener++
|
|
30
|
+
listeners.getOrPut(method, ::mutableMapOf)[key] = reply
|
|
31
|
+
return { listeners[method]?.remove(key) }
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Delivers [received] positions, then calls [finish]. Positions that arrive while the Worker
|
|
36
|
+
* handles an update reach it together as the next.
|
|
37
|
+
*/
|
|
38
|
+
fun update(received: List<JSONObject>, finish: () -> Unit) {
|
|
39
|
+
positions += received
|
|
40
|
+
finishes += finish
|
|
41
|
+
if (!updating) sendUpdate()
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
fun transition(id: String, entered: Boolean, finish: () -> Unit) {
|
|
45
|
+
val event =
|
|
46
|
+
JSONObject()
|
|
47
|
+
.put("id", id)
|
|
48
|
+
.put("transition", if (entered) "enter" else "exit")
|
|
49
|
+
.put("timestamp", System.currentTimeMillis())
|
|
50
|
+
send("onRegionTransition", "region", event, finish)
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Reports a background service's failure, then calls [finish]. */
|
|
54
|
+
fun fail(error: TokamakPluginError, finish: () -> Unit = {}) {
|
|
55
|
+
val event = JSONObject().put("name", error.errorName).put("message", error.message)
|
|
56
|
+
send("onLocationError", "error", event, finish)
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Sends the waiting positions as one update, then the positions that arrive meanwhile as the next. */
|
|
60
|
+
private fun sendUpdate() {
|
|
61
|
+
updating = positions.isNotEmpty()
|
|
62
|
+
if (!updating) return
|
|
63
|
+
val event = JSONObject().put("positions", JSONArray(positions.toList()))
|
|
64
|
+
val delivered = finishes.toList()
|
|
65
|
+
positions.clear()
|
|
66
|
+
finishes.clear()
|
|
67
|
+
send("onLocationUpdate", "update", event) {
|
|
68
|
+
delivered.forEach { it() }
|
|
69
|
+
sendUpdate()
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Delivers [event] to the page's [method] listeners and the Worker's [name] listeners, then calls [finish]. */
|
|
74
|
+
private fun send(method: String, name: String, event: JSONObject, finish: () -> Unit) {
|
|
75
|
+
listeners[method].orEmpty().values.toList().forEach { it(Result.success(event)) }
|
|
76
|
+
Thread {
|
|
77
|
+
runCatching { host.emit(name, event.toString(), EVENT_DEADLINE_MILLIS) }
|
|
78
|
+
.onFailure { Log.w("tokamak", "location $name failed: ${it.message}") }
|
|
79
|
+
host.context.mainExecutor.execute(finish)
|
|
80
|
+
}.start()
|
|
81
|
+
}
|
|
82
|
+
}
|