@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 CHANGED
@@ -1,8 +1,8 @@
1
1
  # @tokamakdev/plugin-location
2
2
 
3
- Location for Tokamak applications and the web.
4
-
5
- Add it to your project's dependencies:
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
- const stop = location.watchPosition(
17
- (next) => console.log(next.coords.latitude, next.coords.longitude),
18
- console.error,
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
- ## Off screen
44
+ `watchPosition(next, error)` calls `next` with each new position until the
45
+ function it returns is called.
29
46
 
30
- The plugin asks for location while the app is in use only. While the app is
31
- off screen on iOS or Android, as when Worker code runs in the background, a
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 implementation uses `navigator.geolocation`. Native Tokamak builds use
41
- Core Location on Apple platforms, `LocationManager` on Android, and WebView2's
42
- location implementation on Windows. Calling the API on a platform without
43
- location support throws `NotSupportedError`.
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
+ }