@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 +198 -18
- package/android/LocationEvents.kt +82 -0
- package/android/LocationPlugin.kt +216 -34
- package/android/LocationReceiver.kt +27 -0
- package/android/LocationRegions.kt +73 -0
- package/android/LocationService.kt +64 -0
- package/android/LocationUpdates.kt +120 -0
- package/apple/LocationEvents.swift +149 -0
- package/apple/LocationPlugin.swift +347 -56
- package/apple/LocationRegions.swift +154 -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 +122 -25
- package/src/options.ts +54 -0
- package/src/types.ts +163 -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, 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
|
-
|
|
17
|
-
|
|
18
|
-
|
|
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
|
-
|
|
40
|
+
`watchPosition(next, error)` calls `next` with each new position until the
|
|
41
|
+
function it returns is called.
|
|
29
42
|
|
|
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
|
|
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
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
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
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
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 (
|
|
125
|
+
if (hasForegroundPermission()) {
|
|
68
126
|
action(true)
|
|
69
127
|
return
|
|
70
128
|
}
|
|
71
|
-
host.requestPermissions(
|
|
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):
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
"
|
|
156
|
-
"
|
|
157
|
-
"
|
|
158
|
-
"
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
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 ||
|
|
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
|
-
|
|
183
|
-
|
|
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
|
|
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
|
}
|