@tokamakdev/plugin-location 0.1.0-beta.102 → 0.1.0-beta.103

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, and
4
+ updates, regions, significant changes and visits that reach the app's Worker
5
+ off screen.
6
6
 
7
7
  ```sh
8
8
  npm install @tokamakdev/plugin-location
@@ -12,32 +12,212 @@ 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.startUpdates({ activity: "fitness", accuracy: "high", distance: 10 });
19
+ await location.addRegion({ id: "home", latitude: 51.5, longitude: -0.12, radius: 150 });
20
+ ```
21
+
22
+ ```ts
23
+ // src/tokamak.ts
24
+ import { onLocationUpdate, onRegionTransition } from "@tokamakdev/plugin-location/events";
15
25
 
16
- const stop = location.watchPosition(
17
- (next) => console.log(next.coords.latitude, next.coords.longitude),
18
- console.error,
19
- );
26
+ onLocationUpdate(async ({ positions }, env) => {
27
+ await fetch("https://example.com/track", { method: "POST", body: JSON.stringify(positions) });
28
+ });
29
+ onRegionTransition(({ id, transition }) => console.log(id, transition));
20
30
  ```
21
31
 
32
+ ## Positions
33
+
22
34
  `getCurrentPosition` takes the Geolocation API's `maximumAge` and `timeout`
23
35
  options. It resolves with a recent position no older than `maximumAge`
24
36
  milliseconds, 0 by default, or takes a new one, and rejects with
25
37
  `TimeoutError` when none arrives within `timeout` milliseconds, unlimited by
26
38
  default. The timeout starts once the user allows location.
27
39
 
28
- ## Off screen
40
+ `watchPosition(next, error)` calls `next` with each new position until the
41
+ function it returns is called.
29
42
 
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
43
+ Both ask for in-use permission when the user has not answered. Off screen
44
+ without background permission, the platforms stop locating the app, so
35
45
  `getCurrentPosition` rejects with `NotAllowedError` there. iOS still locates
36
- the app for a few seconds after it leaves the screen.
46
+ the app for a few seconds after it leaves the screen. With approximate
47
+ permission, Android gives an app a new position at most every 10 minutes, so
48
+ `getCurrentPosition` can wait that long.
49
+
50
+ ## Permission
51
+
52
+ `permission()` reports the app's permission without asking:
53
+
54
+ - `inUse` and `background`, each `"granted"`, `"denied"` or `"prompt"`.
55
+ `background` is `"denied"` unless the `background` build setting declares
56
+ it. macOS has no separate background permission, so `background` equals
57
+ `inUse` there.
58
+ - `precision`: `"precise"` or `"approximate"`, what the app gets or would get
59
+ once allowed.
60
+
61
+ `requestPermission(options)` asks for in-use permission where the user has not
62
+ answered, and for what `options` names, then resolves with the permission:
63
+
64
+ - `background: true` asks for background permission.
65
+ - **iOS** shows the Always prompt once, from When In Use. Asked before the
66
+ user has answered, it gives provisional Always, which iOS confirms with
67
+ the user later. Without the `background` build setting it rejects with
68
+ `NotSupportedError`.
69
+ - **Android** asks for in-use permission first, as Android grants
70
+ background permission only after it. From Android 11 the request opens
71
+ the app's page in Settings, where the user picks "Allow all the time".
72
+ - `precision: "precise"` asks for precise location where the user allowed
73
+ approximate. Apple platforms grant it temporarily, while the app is in use,
74
+ for the reason the `accuracy-purposes` build setting gives for `purpose`.
75
+ `precision: "approximate"` asks Android for approximate location only.
76
+
77
+ Off screen on iOS and Android, a request that would show a prompt rejects with
78
+ `NeedsUIError`. `openSettings()` opens the app's page in the system settings,
79
+ or Location Services on macOS, where the user changes the permission.
80
+ `servicesEnabled()` reports whether location services are on for the device.
81
+
82
+ ## Build settings
83
+
84
+ The plugin's declarations come from its build settings, set in the tokamak
85
+ Vite plugin's options, as `--location-<key>`, or as `TOKAMAK_LOCATION_<KEY>`:
86
+
87
+ ```js
88
+ // vite.config.js
89
+ tokamak({
90
+ location: {
91
+ background: true,
92
+ "background-updates": true,
93
+ "accuracy-purposes": { Directions: "Shows directions from where you are" },
94
+ },
95
+ });
96
+ ```
97
+
98
+ | Setting | Kind | Declares |
99
+ |---|---|---|
100
+ | `background` | boolean | Background permission: iOS's Always reason, Android's `ACCESS_BACKGROUND_LOCATION`. |
101
+ | `background-updates` | boolean | Updates off screen: iOS's `location` background mode, Android's location foreground service and its permissions. |
102
+ | `precision` | `precise` or `approximate` | `approximate` declares approximate location only: iOS's `NSLocationDefaultAccuracyReduced`, and Android without `ACCESS_FINE_LOCATION`. macOS has no such declaration. |
103
+ | `when-in-use-description` | string | Apple: the in-use prompt's reason, and macOS's only reason. |
104
+ | `always-description` | string | iOS: the Always prompt's reason. Needs `background`. |
105
+ | `accuracy-purposes` | map of string | Apple: the reasons for temporary precise location, by purpose key. |
106
+
107
+ Without a description the plugin uses a generic English reason. Declare only
108
+ what the app uses: App Review rejects background modes an app has no feature
109
+ for, and Google Play reviews background location and foreground service types
110
+ with a declaration and a video.
111
+
112
+ ## Worker events
113
+
114
+ The Worker's listeners, from `@tokamakdev/plugin-location/events`, run whether
115
+ or not a page is loaded, including when the system launches the app for an
116
+ event:
117
+
118
+ | Event | Receives |
119
+ |---|---|
120
+ | `onLocationUpdate` | `{ positions, stationary? }` from updates and significant changes. |
121
+ | `onRegionTransition` | `{ id, transition, timestamp }`, where `transition` is `"enter"` or `"exit"`. |
122
+ | `onVisit` | Apple only: `{ latitude, longitude, accuracy, arrival, departure }`, times in milliseconds or null when unknown. |
123
+ | `onLocationError` | `{ name, message, id? }` when a background service fails, with the region's `id` when it concerns one. |
124
+
125
+ `positions` holds the positions that arrived while the Worker handled the
126
+ previous update, oldest first, so a slow Worker never builds a queue. Apple's
127
+ `stationary`, from iOS 18 and macOS 15, is true on the last update before
128
+ updates pause while the device is still.
129
+
130
+ The plugin's `onLocationUpdate`, `onRegionTransition`, `onVisit` and
131
+ `onLocationError` methods receive the same events while the listening page or
132
+ Worker runs, and return the function that removes the listener.
133
+
134
+ ## Updates
135
+
136
+ `startUpdates(options)` starts continuous updates, replacing any running,
137
+ until `stopUpdates()`. `getUpdates()` resolves with the running updates'
138
+ options, or null. Updates need in-use permission, and reject with
139
+ `NotAllowedError` without it. The plugin keeps them across launches and
140
+ restarts.
141
+
142
+ **Apple** uses `CLLocationUpdate.liveUpdates`.
143
+
144
+ - `activity` is the live configuration: `"default"`, `"automotiveNavigation"`,
145
+ `"otherNavigation"`, `"fitness"`, `"airborne"`, or `"maritime"` (iOS 27 and
146
+ macOS 27). It tunes when updates pause and how coordinates follow roads.
147
+ - Updates pause while the device is still and resume when it moves.
148
+ - If iOS ends the app while updates run, it launches the app for the next
149
+ update, and the plugin resumes them.
150
+ - Off screen, updates need the `background-updates` build setting, which
151
+ shows the location indicator, or Always permission. `startUpdates` rejects
152
+ with `NeedsUIError` off screen on iOS.
153
+ - From iOS 18, failures such as denied permission or lost location reach
154
+ `onLocationError` once each.
155
+ - `getCurrentPosition` and `watchPosition` can change the coordinates updates
156
+ deliver while they run.
157
+
158
+ **Android** uses `LocationManager` with a `PendingIntent`, which starts the
159
+ app's process for each update.
160
+
161
+ - `accuracy` is `"high"` (default), `"balanced"` or `"low"`; `distance` the
162
+ metres between updates, 0 by default; `interval` the milliseconds between
163
+ them, 10 seconds by default.
164
+ - Off screen, updates need background permission and arrive a few times an
165
+ hour, unless they run in a foreground service.
166
+ - `foregroundService: { title, body, channel }` runs updates in a foreground
167
+ service, with that notification in a channel of that name, at the requested
168
+ rate off screen. It needs the `background-updates` build setting, and
169
+ rejects with `NotSupportedError` without it. Off screen it needs background
170
+ permission, and Android starts the service only within 10 seconds of an
171
+ update; otherwise it rejects with `NeedsUIError`. A region transition does
172
+ not allow the start.
173
+ - The plugin requests updates again after a restart, an app update and a
174
+ force stop, and starts the service again on screen, or after a restart or
175
+ update with background permission. Android restarts a service it ends for
176
+ memory.
177
+
178
+ ## Regions
179
+
180
+ `addRegion({ id, latitude, longitude, radius, onEntry, onExit })` monitors a
181
+ circular region of `radius` metres, replacing one with the same `id`, until
182
+ `removeRegion(id)`. `onEntry` and `onExit` are true by default. Entries and
183
+ exits reach `onRegionTransition`, including when the system launches the app
184
+ for one. A region starts outside, so a device already inside reports an
185
+ entry. `getRegions()` resolves with the regions, each with its `state` on
186
+ Apple platforms: `"inside"`, `"outside"` or `"unknown"`.
187
+
188
+ Regions need precise location, and reject with `NotAllowedError` without it,
189
+ and background permission off screen.
190
+
191
+ - **Apple** uses `CLMonitor`, which monitors up to 20 regions; `addRegion`
192
+ rejects with `QuotaExceededError` beyond them. iOS relaunches the app for a
193
+ transition, even after the user quits it. macOS monitors only while the app
194
+ runs.
195
+ - **Android** uses proximity alerts, which the system evaluates about every 30
196
+ minutes or less often, so a transition can arrive that long after it
197
+ happens. Adding a region again, as the plugin does after a restart, reports
198
+ an entry again for a device inside.
199
+
200
+ ## Significant changes and visits
201
+
202
+ On Apple platforms, `setup(config)` starts what runs at every launch:
203
+ `significantChanges` delivers significant changes of location to
204
+ `onLocationUpdate`, and `visits` delivers visits to `onVisit`. Call it from
205
+ `onStart` in `src/tokamak.ts`. The configuration replaces the previous one,
206
+ which the plugin keeps and applies again at each launch; an invalid one
207
+ rejects with `TypeError` and changes nothing. With Always permission, the
208
+ system relaunches the app for both. Android ignores `setup`'s keys.
37
209
 
38
210
  ## Platforms
39
211
 
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`.
212
+ The web uses `navigator.geolocation`. `permission()` reports `inUse` from the
213
+ Permissions API, and the other additions reject with `NotSupportedError`,
214
+ while the listener methods receive nothing. Windows uses WebView2's location
215
+ for `getCurrentPosition` and `watchPosition`.
216
+
217
+ ## Errors
218
+
219
+ Rejections and `onLocationError` events are `DOMException` names:
220
+ `NotAllowedError` (no permission, or none for the background),
221
+ `NotReadableError` (location unavailable, or services off), `NeedsUIError` (a
222
+ prompt or a start off screen), `NotSupportedError`, `QuotaExceededError`,
223
+ `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
+ }
@@ -1,20 +1,29 @@
1
1
  package com.tokamak.plugins.location
2
2
 
3
3
  import android.Manifest
4
+ import android.app.ForegroundServiceStartNotAllowedException
5
+ import android.content.ComponentName
4
6
  import android.content.Context
7
+ import android.content.Intent
5
8
  import android.content.pm.PackageManager
6
9
  import android.location.Location
7
10
  import android.location.LocationListener
8
11
  import android.location.LocationManager
12
+ import android.net.Uri
9
13
  import android.os.Bundle
10
14
  import android.os.CancellationSignal
11
15
  import android.os.Handler
12
16
  import android.os.Looper
13
17
  import android.os.SystemClock
18
+ import android.provider.Settings
14
19
  import com.tokamak.runtime.TokamakHost
15
20
  import com.tokamak.runtime.TokamakPlugin
16
21
  import com.tokamak.runtime.TokamakPluginError
17
22
  import com.tokamak.runtime.TokamakPluginReply
23
+ import com.tokamak.runtime.optionalString
24
+ import com.tokamak.runtime.requireObject
25
+ import com.tokamak.runtime.requireString
26
+ import org.json.JSONArray
18
27
  import org.json.JSONObject
19
28
 
20
29
  class TokamakLocationPlugin(
@@ -27,14 +36,62 @@ class TokamakLocationPlugin(
27
36
  context.getSystemService(Context.LOCATION_SERVICE) as LocationManager
28
37
  private val handler = Handler(Looper.getMainLooper())
29
38
 
39
+ /** The permissions the app's manifest requests. */
40
+ private val declared: Set<String> by lazy {
41
+ context.packageManager
42
+ .getPackageInfo(context.packageName, PackageManager.GET_PERMISSIONS)
43
+ .requestedPermissions
44
+ .orEmpty()
45
+ .toSet()
46
+ }
47
+
48
+ /** Foreground location permissions, which the `precision` build setting declares. */
49
+ private val foreground: Set<String> by lazy { setOf(COARSE, FINE).filter(declared::contains).toSet() }
50
+
51
+ private val updates = LocationUpdates(context)
52
+ private val regions = LocationRegions(context)
53
+ private val events = LocationEvents(host)
54
+
55
+ /** Whether the app's manifest declares the foreground service, as the `background-updates` build setting does. */
56
+ private val declaresService: Boolean by lazy {
57
+ runCatching { context.packageManager.getServiceInfo(ComponentName(context, TokamakLocationService::class.java), 0) }
58
+ .isSuccess
59
+ }
60
+
61
+ init {
62
+ restore(updatesRemoved = false)
63
+ }
64
+
65
+ /** Starts the updates' foreground service again once the app is on screen. */
66
+ override fun onIntent(intent: Intent) {
67
+ if (host.isInForeground) updates.startService()
68
+ }
69
+
30
70
  override fun call(method: String, arguments: Any?, reply: TokamakPluginReply) {
31
- if (method != "getCurrentPosition") {
32
- super.call(method, arguments, reply)
33
- return
34
- }
35
- val options = arguments as? JSONObject
36
- withPermission { granted ->
37
- if (granted) currentPosition(options, reply) else reply(permissionDenied())
71
+ when (method) {
72
+ // Significant changes and visits, setup's keys, are Apple's.
73
+ "setup" -> reply(Result.success(null))
74
+ "getCurrentPosition" ->
75
+ withPermission { granted ->
76
+ if (granted) currentPosition(arguments as? JSONObject, reply) else reply(Result.failure(permissionDenied()))
77
+ }
78
+ "permission" -> reply(Result.success(permission()))
79
+ "requestPermission" -> requestPermission(requireObject(arguments), reply)
80
+ "servicesEnabled" -> reply(Result.success(manager.isLocationEnabled))
81
+ "openSettings" -> openSettings(reply)
82
+ "startUpdates" -> startUpdates(requireObject(arguments), reply)
83
+ "stopUpdates" -> {
84
+ updates.stop()
85
+ reply(Result.success(null))
86
+ }
87
+ "getUpdates" -> reply(Result.success(updates.options))
88
+ "addRegion" -> addRegion(requireObject(arguments), reply)
89
+ "getRegions" -> reply(Result.success(JSONArray(regions.all())))
90
+ "removeRegion" -> {
91
+ regions.remove(requireObject(arguments).requireString("id"))
92
+ reply(Result.success(null))
93
+ }
94
+ else -> super.call(method, arguments, reply)
38
95
  }
39
96
  }
40
97
 
@@ -43,12 +100,13 @@ class TokamakLocationPlugin(
43
100
  arguments: Any?,
44
101
  reply: TokamakPluginReply,
45
102
  ): () -> Unit {
103
+ events.listen(method, reply)?.let { return it }
46
104
  if (method != "watchPosition") return super.subscribe(method, arguments, reply)
47
105
  var cancelled = false
48
106
  var stop = {}
49
107
  withPermission { granted ->
50
108
  if (!granted) {
51
- reply(permissionDenied())
109
+ reply(Result.failure(permissionDenied()))
52
110
  } else if (!cancelled) {
53
111
  stop = watchPosition(reply)
54
112
  }
@@ -64,11 +122,126 @@ class TokamakLocationPlugin(
64
122
  * Throws `NeedsUIError` when asking would show UI while the app is in the background.
65
123
  */
66
124
  private fun withPermission(action: (Boolean) -> Unit) {
67
- if (PERMISSIONS.any { context.checkSelfPermission(it) == PackageManager.PERMISSION_GRANTED }) {
125
+ if (hasForegroundPermission()) {
68
126
  action(true)
69
127
  return
70
128
  }
71
- host.requestPermissions(PERMISSIONS) { granted -> action(granted.containsValue(true)) }
129
+ host.requestPermissions(foreground) { granted -> action(granted.containsValue(true)) }
130
+ }
131
+
132
+ private fun granted(permission: String): Boolean =
133
+ context.checkSelfPermission(permission) == PackageManager.PERMISSION_GRANTED
134
+
135
+ private fun hasForegroundPermission(): Boolean = granted(FINE) || granted(COARSE)
136
+
137
+ /** The app's in-use and background permission, and the precision it gets, or would get once allowed. */
138
+ private fun permission(): JSONObject {
139
+ val inUse = if (hasForegroundPermission()) "granted" else state(COARSE)
140
+ val background = if (BACKGROUND in declared) state(BACKGROUND) else "denied"
141
+ val precise = granted(FINE) || (!granted(COARSE) && FINE in declared)
142
+ return JSONObject()
143
+ .put("inUse", inUse)
144
+ .put("background", background)
145
+ .put("precision", if (precise) "precise" else "approximate")
146
+ }
147
+
148
+ private fun state(permission: String): String =
149
+ when {
150
+ granted(permission) -> "granted"
151
+ host.wouldPrompt(permission) -> "prompt"
152
+ else -> "denied"
153
+ }
154
+
155
+ /**
156
+ * Asks for foreground permission, then for background permission when [options] ask for it, as
157
+ * Android grants background permission only to an app with foreground permission.
158
+ */
159
+ private fun requestPermission(options: JSONObject, reply: TokamakPluginReply) {
160
+ val background = options.optBoolean("background")
161
+ if (background && BACKGROUND !in declared) {
162
+ throw TokamakPluginError.notSupported("Background location needs the location plugin's background build setting")
163
+ }
164
+ val asked = if (options.optionalString("precision") == "approximate") setOf(COARSE) else foreground
165
+ host.requestPermissions(asked) { granted ->
166
+ if (!background || !granted.containsValue(true)) return@requestPermissions reply(Result.success(permission()))
167
+ runCatching { host.requestPermissions(setOf(BACKGROUND)) { reply(Result.success(permission())) } }
168
+ .onFailure { reply(Result.failure(it)) }
169
+ }
170
+ }
171
+
172
+ private fun requirePermission() {
173
+ if (!hasForegroundPermission()) throw TokamakPluginError("NotAllowedError", "Location permission has not been granted")
174
+ }
175
+
176
+ private fun startUpdates(options: JSONObject, reply: TokamakPluginReply) {
177
+ requirePermission()
178
+ if (options.has("foregroundService")) requireService()
179
+ runCatching { updates.start(options) }
180
+ .onSuccess { reply(Result.success(null)) }
181
+ .onFailure { reply(Result.failure(locationError(it))) }
182
+ }
183
+
184
+ /**
185
+ * Throws unless the app declares the foreground service and may start it now: Android 14 and
186
+ * later allow a location foreground service off screen only with background permission.
187
+ */
188
+ private fun requireService() {
189
+ if (!declaresService) {
190
+ throw TokamakPluginError.notSupported("The foreground service needs the location plugin's background-updates build setting")
191
+ }
192
+ if (!host.isInForeground && !granted(BACKGROUND)) throw TokamakPluginError.needsUI()
193
+ }
194
+
195
+ /** Monitors a region, which needs precise location. */
196
+ private fun addRegion(region: JSONObject, reply: TokamakPluginReply) {
197
+ if (!granted(FINE)) throw TokamakPluginError("NotAllowedError", "Regions need precise location")
198
+ runCatching { regions.add(region) }
199
+ .onSuccess { reply(Result.success(null)) }
200
+ .onFailure { reply(Result.failure(locationError(it))) }
201
+ }
202
+
203
+ /**
204
+ * Restores updates and regions, and starts the updates' foreground service on screen, or after a
205
+ * restart or an update with background permission. Reports a failure to `onLocationError`.
206
+ */
207
+ internal fun restore(updatesRemoved: Boolean) {
208
+ runCatching {
209
+ updates.restore(updatesRemoved)
210
+ if (host.isInForeground || (updatesRemoved && granted(BACKGROUND))) updates.startService()
211
+ }.onFailure { events.fail(locationError(it)) }
212
+ runCatching { regions.restore() }.onFailure { events.fail(locationError(it)) }
213
+ }
214
+
215
+ /** Delivers the updates or region transition [intent] carries, then calls [finish]. Call it on the main thread. */
216
+ internal fun receive(intent: Intent, finish: () -> Unit) {
217
+ if (intent.action == LocationRegions.REGION_ACTION) return transition(intent, finish)
218
+ if (intent.hasExtra(LocationManager.KEY_PROVIDER_ENABLED)) {
219
+ if (intent.getBooleanExtra(LocationManager.KEY_PROVIDER_ENABLED, true)) return finish()
220
+ return events.fail(TokamakPluginError("NotReadableError", "The location provider was turned off"), finish)
221
+ }
222
+ val received = locations(intent).map(::position)
223
+ if (received.isEmpty()) return finish()
224
+ events.update(received, finish)
225
+ }
226
+
227
+ /** Delivers a region's entry or exit, when the region reports it. */
228
+ private fun transition(intent: Intent, finish: () -> Unit) {
229
+ val id = intent.identifier ?: return finish()
230
+ val entered = intent.getBooleanExtra(LocationManager.KEY_PROXIMITY_ENTERING, false)
231
+ val region = regions.get(id) ?: return finish()
232
+ if (!region.optBoolean(if (entered) "onEntry" else "onExit", true)) return finish()
233
+ events.transition(id, entered, finish)
234
+ }
235
+
236
+ @Suppress("DEPRECATION")
237
+ private fun locations(intent: Intent): List<Location> =
238
+ intent.getParcelableArrayExtra(LocationManager.KEY_LOCATIONS)?.filterIsInstance<Location>()
239
+ ?: listOfNotNull(intent.getParcelableExtra(LocationManager.KEY_LOCATION_CHANGED))
240
+
241
+ private fun openSettings(reply: TokamakPluginReply) {
242
+ val app = Uri.fromParts("package", context.packageName, null)
243
+ host.requireForegroundActivity().startActivity(Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS, app))
244
+ reply(Result.success(null))
72
245
  }
73
246
 
74
247
  /**
@@ -147,29 +320,31 @@ class TokamakLocationPlugin(
147
320
  ).firstOrNull(manager::isProviderEnabled)
148
321
  ?: manager.getProviders(true).firstOrNull()
149
322
 
150
- private fun position(location: Location): Map<String, Any?> =
151
- mapOf(
152
- "coords" to
153
- mapOf(
154
- "latitude" to location.latitude,
155
- "longitude" to location.longitude,
156
- "accuracy" to location.accuracy.toDouble(),
157
- "altitude" to location.altitude.takeIf { location.hasAltitude() },
158
- "altitudeAccuracy" to
159
- location.verticalAccuracyMeters.toDouble()
160
- .takeIf { location.hasVerticalAccuracy() },
161
- "heading" to location.bearing.toDouble().takeIf { location.hasBearing() },
162
- "speed" to location.speed.toDouble().takeIf { location.hasSpeed() },
163
- ),
164
- "timestamp" to location.time,
165
- )
166
-
167
- private fun permissionDenied(): Result<Any?> =
168
- Result.failure(TokamakPluginError("NotAllowedError", "Location permission was denied"))
323
+ private fun position(location: Location): JSONObject =
324
+ JSONObject()
325
+ .put(
326
+ "coords",
327
+ JSONObject()
328
+ .put("latitude", location.latitude)
329
+ .put("longitude", location.longitude)
330
+ .put("accuracy", location.accuracy.toDouble())
331
+ .put("altitude", nullable(location.hasAltitude(), location.altitude))
332
+ .put(
333
+ "altitudeAccuracy",
334
+ nullable(location.hasVerticalAccuracy(), location.verticalAccuracyMeters.toDouble()),
335
+ )
336
+ .put("heading", nullable(location.hasBearing(), location.bearing.toDouble()))
337
+ .put("speed", nullable(location.hasSpeed(), location.speed.toDouble())),
338
+ )
339
+ .put("timestamp", location.time)
340
+
341
+ private fun nullable(available: Boolean, value: Double): Any = if (available) value else JSONObject.NULL
342
+
343
+ private fun permissionDenied() = TokamakPluginError("NotAllowedError", "Location permission was denied")
169
344
 
170
345
  /** Why no fix came: Android gives none in the background without background location permission. */
171
346
  private fun missingFix(): Result<Any?> =
172
- if (host.isInForeground || context.checkSelfPermission(BACKGROUND) == PackageManager.PERMISSION_GRANTED) {
347
+ if (host.isInForeground || granted(BACKGROUND)) {
173
348
  unavailable("Location is unavailable")
174
349
  } else {
175
350
  Result.failure(TokamakPluginError("NotAllowedError", "Location permission does not cover the background"))
@@ -178,14 +353,21 @@ class TokamakLocationPlugin(
178
353
  private fun unavailable(message: String): Result<Any?> =
179
354
  Result.failure(TokamakPluginError("NotReadableError", message))
180
355
 
181
- private fun locationFailure(error: Throwable): Result<Any?> =
182
- if (error is SecurityException) permissionDenied()
183
- else unavailable(error.message ?: "Location is unavailable")
356
+ private fun locationFailure(error: Throwable): Result<Any?> = Result.failure(locationError(error))
357
+
358
+ private fun locationError(error: Throwable): TokamakPluginError =
359
+ when (error) {
360
+ is SecurityException -> permissionDenied()
361
+ // Off screen, Android starts a foreground service only in response to some events.
362
+ is ForegroundServiceStartNotAllowedException -> TokamakPluginError.needsUI()
363
+ else -> TokamakPluginError("NotReadableError", error.message ?: "Location is unavailable")
364
+ }
184
365
 
185
366
  private companion object {
186
367
  /** The `timeout` that never expires, as the page sends it. */
187
368
  const val UNLIMITED = 0xffffffffL
188
- val PERMISSIONS = setOf(Manifest.permission.ACCESS_FINE_LOCATION, Manifest.permission.ACCESS_COARSE_LOCATION)
369
+ const val FINE = Manifest.permission.ACCESS_FINE_LOCATION
370
+ const val COARSE = Manifest.permission.ACCESS_COARSE_LOCATION
189
371
  const val BACKGROUND = Manifest.permission.ACCESS_BACKGROUND_LOCATION
190
372
  }
191
373
  }