@tokamakdev/plugin-location 0.1.0-beta.103 → 0.1.0-beta.106
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 +50 -29
- package/android/LocationEvents.kt +9 -3
- package/android/LocationPlugin.kt +71 -21
- package/android/LocationService.kt +13 -2
- package/android/LocationUpdates.kt +37 -37
- package/apple/LocationEvents.swift +10 -4
- package/apple/LocationPlugin.swift +4 -1
- package/apple/LocationRegions.swift +36 -15
- package/package.json +2 -2
- package/src/index.ts +9 -7
- package/src/types.ts +20 -7
package/README.md
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
# @tokamakdev/plugin-location
|
|
2
2
|
|
|
3
|
-
Location for tokamak applications and the web: positions on request,
|
|
4
|
-
|
|
5
|
-
off screen.
|
|
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
|
|
@@ -15,16 +15,20 @@ const position = await location.getCurrentPosition();
|
|
|
15
15
|
const stop = location.watchPosition((next) => console.log(next.coords), console.error);
|
|
16
16
|
|
|
17
17
|
await location.requestPermission({ background: true });
|
|
18
|
-
await location.startUpdates({ activity: "fitness", accuracy: "high", distance: 10 });
|
|
19
18
|
await location.addRegion({ id: "home", latitude: 51.5, longitude: -0.12, radius: 150 });
|
|
20
19
|
```
|
|
21
20
|
|
|
22
21
|
```ts
|
|
23
22
|
// src/tokamak.ts
|
|
23
|
+
import { location } from "@tokamakdev/plugin-location";
|
|
24
24
|
import { onLocationUpdate, onRegionTransition } from "@tokamakdev/plugin-location/events";
|
|
25
|
+
import { onStart } from "@tokamakdev/tok/events";
|
|
25
26
|
|
|
26
|
-
|
|
27
|
-
await
|
|
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) });
|
|
28
32
|
});
|
|
29
33
|
onRegionTransition(({ id, transition }) => console.log(id, transition));
|
|
30
34
|
```
|
|
@@ -123,21 +127,48 @@ event:
|
|
|
123
127
|
| `onLocationError` | `{ name, message, id? }` when a background service fails, with the region's `id` when it concerns one. |
|
|
124
128
|
|
|
125
129
|
`positions` holds the positions that arrived while the Worker handled the
|
|
126
|
-
previous update, oldest first, so a slow Worker never builds a queue.
|
|
127
|
-
|
|
128
|
-
|
|
130
|
+
previous update, oldest first, so a slow Worker never builds a queue. Each is
|
|
131
|
+
newer than those delivered before it, so a position that both updates and
|
|
132
|
+
significant changes report arrives once. Apple's `stationary`, from iOS 18
|
|
133
|
+
and macOS 15, is true on the last update before updates pause while the
|
|
134
|
+
device is still.
|
|
129
135
|
|
|
130
136
|
The plugin's `onLocationUpdate`, `onRegionTransition`, `onVisit` and
|
|
131
137
|
`onLocationError` methods receive the same events while the listening page or
|
|
132
138
|
Worker runs, and return the function that removes the listener.
|
|
133
139
|
|
|
134
|
-
##
|
|
135
|
-
|
|
136
|
-
`
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
140
|
+
## Long-term awareness
|
|
141
|
+
|
|
142
|
+
`setup(config)` keeps the app aware of where the device is over days, with
|
|
143
|
+
little power and no indicator. Call it from `onStart` in `src/tokamak.ts`, so
|
|
144
|
+
it applies at every launch; the plugin also keeps it and applies it again
|
|
145
|
+
itself. The configuration replaces the previous one, and an invalid one
|
|
146
|
+
rejects with `TypeError` and changes nothing. Apple platforms reject with
|
|
147
|
+
`NotSupportedError` where the device cannot monitor significant changes.
|
|
148
|
+
|
|
149
|
+
- `significantChanges` delivers moves of 500 metres or more, at most every 5
|
|
150
|
+
minutes, to `onLocationUpdate`, located from cellular and Wi-Fi rather than
|
|
151
|
+
GPS. Apple platforms use the significant-change service. Android uses a
|
|
152
|
+
low-power request with the same thresholds, which it limits to a few an hour
|
|
153
|
+
off screen. Without permission they start once the app has it on Apple
|
|
154
|
+
platforms, and on Android after the plugin's prompt or at the next launch.
|
|
155
|
+
- `visits`, Apple only, delivers arrivals at and departures from the places
|
|
156
|
+
the user stays to `onVisit`. It uses the least power of all.
|
|
157
|
+
|
|
158
|
+
With background permission, iOS relaunches the app for both, and Android starts
|
|
159
|
+
the app's process for significant changes. [Regions](#regions) wake the app
|
|
160
|
+
the same way.
|
|
161
|
+
|
|
162
|
+
## Continuous tracking
|
|
163
|
+
|
|
164
|
+
`startUpdates(options)` starts continuous updates for an activity the user
|
|
165
|
+
follows, such as a run or navigation, replacing any running, until
|
|
166
|
+
`stopUpdates()`. Off screen the system can show that the app is tracking:
|
|
167
|
+
iOS's blue location pill with in-use permission, and the notification of
|
|
168
|
+
Android's foreground service.
|
|
169
|
+
`getUpdates()` resolves with the running updates' options, or null. Updates
|
|
170
|
+
need in-use permission, and reject with `NotAllowedError` without it. The
|
|
171
|
+
plugin keeps them across launches and restarts.
|
|
141
172
|
|
|
142
173
|
**Apple** uses `CLLocationUpdate.liveUpdates`.
|
|
143
174
|
|
|
@@ -147,9 +178,9 @@ restarts.
|
|
|
147
178
|
- Updates pause while the device is still and resume when it moves.
|
|
148
179
|
- If iOS ends the app while updates run, it launches the app for the next
|
|
149
180
|
update, and the plugin resumes them.
|
|
150
|
-
- Off screen, updates need the `background-updates`
|
|
151
|
-
|
|
152
|
-
with `NeedsUIError` off screen on iOS.
|
|
181
|
+
- Off screen, updates need Always permission, or the `background-updates`
|
|
182
|
+
build setting, whose background activity session shows the pill.
|
|
183
|
+
`startUpdates` rejects with `NeedsUIError` off screen on iOS.
|
|
153
184
|
- From iOS 18, failures such as denied permission or lost location reach
|
|
154
185
|
`onLocationError` once each.
|
|
155
186
|
- `getCurrentPosition` and `watchPosition` can change the coordinates updates
|
|
@@ -197,16 +228,6 @@ and background permission off screen.
|
|
|
197
228
|
happens. Adding a region again, as the plugin does after a restart, reports
|
|
198
229
|
an entry again for a device inside.
|
|
199
230
|
|
|
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.
|
|
209
|
-
|
|
210
231
|
## Platforms
|
|
211
232
|
|
|
212
233
|
The web uses `navigator.geolocation`. `permission()` reports `inUse` from the
|
|
@@ -23,6 +23,9 @@ internal class LocationEvents(private val host: TokamakHost) {
|
|
|
23
23
|
private val finishes = mutableListOf<() -> Unit>()
|
|
24
24
|
private var updating = false
|
|
25
25
|
|
|
26
|
+
/** The time of the newest position delivered, as requests that run together receive the same positions. */
|
|
27
|
+
private var latest = Long.MIN_VALUE
|
|
28
|
+
|
|
26
29
|
/** Adds a page's or Worker's listener of [method], returning its removal, or null for another method. */
|
|
27
30
|
fun listen(method: String, reply: TokamakPluginReply): (() -> Unit)? {
|
|
28
31
|
if (method !in LISTENERS) return null
|
|
@@ -32,11 +35,14 @@ internal class LocationEvents(private val host: TokamakHost) {
|
|
|
32
35
|
}
|
|
33
36
|
|
|
34
37
|
/**
|
|
35
|
-
* Delivers [received] positions, then calls [finish]. Positions
|
|
36
|
-
* handles an update reach it together as the next.
|
|
38
|
+
* Delivers the [received] positions newer than those delivered, then calls [finish]. Positions
|
|
39
|
+
* that arrive while the Worker handles an update reach it together as the next.
|
|
37
40
|
*/
|
|
38
41
|
fun update(received: List<JSONObject>, finish: () -> Unit) {
|
|
39
|
-
|
|
42
|
+
val newer = received.filter { it.getLong("timestamp") > latest }
|
|
43
|
+
if (newer.isEmpty()) return finish()
|
|
44
|
+
latest = newer.maxOf { it.getLong("timestamp") }
|
|
45
|
+
positions += newer
|
|
40
46
|
finishes += finish
|
|
41
47
|
if (!updating) sendUpdate()
|
|
42
48
|
}
|
|
@@ -48,10 +48,14 @@ class TokamakLocationPlugin(
|
|
|
48
48
|
/** Foreground location permissions, which the `precision` build setting declares. */
|
|
49
49
|
private val foreground: Set<String> by lazy { setOf(COARSE, FINE).filter(declared::contains).toSet() }
|
|
50
50
|
|
|
51
|
-
private val updates = LocationUpdates(context)
|
|
51
|
+
private val updates = LocationUpdates(context, LocationUpdates.UPDATES)
|
|
52
|
+
private val significantChanges = LocationUpdates(context, LocationUpdates.SIGNIFICANT_CHANGES)
|
|
52
53
|
private val regions = LocationRegions(context)
|
|
53
54
|
private val events = LocationEvents(host)
|
|
54
55
|
|
|
56
|
+
/** Whether the provider was on when last reported, as each request reports its changes. */
|
|
57
|
+
private var providerEnabled = true
|
|
58
|
+
|
|
55
59
|
/** Whether the app's manifest declares the foreground service, as the `background-updates` build setting does. */
|
|
56
60
|
private val declaresService: Boolean by lazy {
|
|
57
61
|
runCatching { context.packageManager.getServiceInfo(ComponentName(context, TokamakLocationService::class.java), 0) }
|
|
@@ -64,13 +68,12 @@ class TokamakLocationPlugin(
|
|
|
64
68
|
|
|
65
69
|
/** Starts the updates' foreground service again once the app is on screen. */
|
|
66
70
|
override fun onIntent(intent: Intent) {
|
|
67
|
-
if (host.isInForeground)
|
|
71
|
+
if (host.isInForeground) startService()
|
|
68
72
|
}
|
|
69
73
|
|
|
70
74
|
override fun call(method: String, arguments: Any?, reply: TokamakPluginReply) {
|
|
71
75
|
when (method) {
|
|
72
|
-
|
|
73
|
-
"setup" -> reply(Result.success(null))
|
|
76
|
+
"setup" -> setup(requireObject(arguments), reply)
|
|
74
77
|
"getCurrentPosition" ->
|
|
75
78
|
withPermission { granted ->
|
|
76
79
|
if (granted) currentPosition(arguments as? JSONObject, reply) else reply(Result.failure(permissionDenied()))
|
|
@@ -81,7 +84,7 @@ class TokamakLocationPlugin(
|
|
|
81
84
|
"openSettings" -> openSettings(reply)
|
|
82
85
|
"startUpdates" -> startUpdates(requireObject(arguments), reply)
|
|
83
86
|
"stopUpdates" -> {
|
|
84
|
-
|
|
87
|
+
stopUpdates()
|
|
85
88
|
reply(Result.success(null))
|
|
86
89
|
}
|
|
87
90
|
"getUpdates" -> reply(Result.success(updates.options))
|
|
@@ -122,11 +125,19 @@ class TokamakLocationPlugin(
|
|
|
122
125
|
* Throws `NeedsUIError` when asking would show UI while the app is in the background.
|
|
123
126
|
*/
|
|
124
127
|
private fun withPermission(action: (Boolean) -> Unit) {
|
|
125
|
-
if (hasForegroundPermission())
|
|
126
|
-
|
|
127
|
-
|
|
128
|
+
if (hasForegroundPermission()) action(true) else askForeground(foreground, action)
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Asks for the foreground [permissions], then passes [action] whether the app has one, first
|
|
133
|
+
* starting what waited for permission.
|
|
134
|
+
*/
|
|
135
|
+
private fun askForeground(permissions: Set<String>, action: (Boolean) -> Unit) {
|
|
136
|
+
host.requestPermissions(permissions) { granted ->
|
|
137
|
+
val allowed = granted.containsValue(true)
|
|
138
|
+
if (allowed) restore(updatesRemoved = false)
|
|
139
|
+
action(allowed)
|
|
128
140
|
}
|
|
129
|
-
host.requestPermissions(foreground) { granted -> action(granted.containsValue(true)) }
|
|
130
141
|
}
|
|
131
142
|
|
|
132
143
|
private fun granted(permission: String): Boolean =
|
|
@@ -162,8 +173,8 @@ class TokamakLocationPlugin(
|
|
|
162
173
|
throw TokamakPluginError.notSupported("Background location needs the location plugin's background build setting")
|
|
163
174
|
}
|
|
164
175
|
val asked = if (options.optionalString("precision") == "approximate") setOf(COARSE) else foreground
|
|
165
|
-
|
|
166
|
-
if (!background || !
|
|
176
|
+
askForeground(asked) { allowed ->
|
|
177
|
+
if (!background || !allowed) return@askForeground reply(Result.success(permission()))
|
|
167
178
|
runCatching { host.requestPermissions(setOf(BACKGROUND)) { reply(Result.success(permission())) } }
|
|
168
179
|
.onFailure { reply(Result.failure(it)) }
|
|
169
180
|
}
|
|
@@ -173,11 +184,43 @@ class TokamakLocationPlugin(
|
|
|
173
184
|
if (!hasForegroundPermission()) throw TokamakPluginError("NotAllowedError", "Location permission has not been granted")
|
|
174
185
|
}
|
|
175
186
|
|
|
187
|
+
/** Starts updates in the foreground service when [options] name one, which stops otherwise. On failure, none run. */
|
|
176
188
|
private fun startUpdates(options: JSONObject, reply: TokamakPluginReply) {
|
|
177
189
|
requirePermission()
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
190
|
+
val notification = options.optJSONObject("foregroundService")
|
|
191
|
+
if (notification != null) requireService()
|
|
192
|
+
runCatching {
|
|
193
|
+
if (notification == null) TokamakLocationService.stop(context) else TokamakLocationService.start(context, notification)
|
|
194
|
+
updates.start(options)
|
|
195
|
+
}.onSuccess { reply(Result.success(null)) }
|
|
196
|
+
.onFailure {
|
|
197
|
+
stopUpdates()
|
|
198
|
+
reply(Result.failure(locationError(it)))
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
private fun stopUpdates() {
|
|
203
|
+
updates.stop()
|
|
204
|
+
TokamakLocationService.stop(context)
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
private fun startService() {
|
|
208
|
+
updates.options?.optJSONObject("foregroundService")?.let { TokamakLocationService.start(context, it) }
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* Starts significant changes when [config] names them, and stops them otherwise. Without
|
|
213
|
+
* permission they start once the app has it.
|
|
214
|
+
*/
|
|
215
|
+
private fun setup(config: JSONObject, reply: TokamakPluginReply) {
|
|
216
|
+
val options = JSONObject(SIGNIFICANT_CHANGE_OPTIONS)
|
|
217
|
+
runCatching {
|
|
218
|
+
when {
|
|
219
|
+
!config.optBoolean("significantChanges") -> significantChanges.stop()
|
|
220
|
+
hasForegroundPermission() -> significantChanges.start(options)
|
|
221
|
+
else -> significantChanges.keep(options)
|
|
222
|
+
}
|
|
223
|
+
}.onSuccess { reply(Result.success(null)) }
|
|
181
224
|
.onFailure { reply(Result.failure(locationError(it))) }
|
|
182
225
|
}
|
|
183
226
|
|
|
@@ -201,13 +244,16 @@ class TokamakLocationPlugin(
|
|
|
201
244
|
}
|
|
202
245
|
|
|
203
246
|
/**
|
|
204
|
-
* Restores updates and regions
|
|
205
|
-
*
|
|
247
|
+
* Restores updates, significant changes and regions where the app has permission, as Android
|
|
248
|
+
* pauses them without it, and starts the updates' foreground service on screen, or after a restart
|
|
249
|
+
* or an update with background permission. Reports a failure to `onLocationError`.
|
|
206
250
|
*/
|
|
207
251
|
internal fun restore(updatesRemoved: Boolean) {
|
|
252
|
+
if (!hasForegroundPermission()) return
|
|
208
253
|
runCatching {
|
|
209
254
|
updates.restore(updatesRemoved)
|
|
210
|
-
|
|
255
|
+
significantChanges.restore(updatesRemoved)
|
|
256
|
+
if (host.isInForeground || (updatesRemoved && granted(BACKGROUND))) startService()
|
|
211
257
|
}.onFailure { events.fail(locationError(it)) }
|
|
212
258
|
runCatching { regions.restore() }.onFailure { events.fail(locationError(it)) }
|
|
213
259
|
}
|
|
@@ -216,12 +262,13 @@ class TokamakLocationPlugin(
|
|
|
216
262
|
internal fun receive(intent: Intent, finish: () -> Unit) {
|
|
217
263
|
if (intent.action == LocationRegions.REGION_ACTION) return transition(intent, finish)
|
|
218
264
|
if (intent.hasExtra(LocationManager.KEY_PROVIDER_ENABLED)) {
|
|
219
|
-
|
|
265
|
+
val enabled = intent.getBooleanExtra(LocationManager.KEY_PROVIDER_ENABLED, true)
|
|
266
|
+
val turnedOff = providerEnabled && !enabled
|
|
267
|
+
providerEnabled = enabled
|
|
268
|
+
if (!turnedOff) return finish()
|
|
220
269
|
return events.fail(TokamakPluginError("NotReadableError", "The location provider was turned off"), finish)
|
|
221
270
|
}
|
|
222
|
-
|
|
223
|
-
if (received.isEmpty()) return finish()
|
|
224
|
-
events.update(received, finish)
|
|
271
|
+
events.update(locations(intent).map(::position), finish)
|
|
225
272
|
}
|
|
226
273
|
|
|
227
274
|
/** Delivers a region's entry or exit, when the region reports it. */
|
|
@@ -369,5 +416,8 @@ class TokamakLocationPlugin(
|
|
|
369
416
|
const val FINE = Manifest.permission.ACCESS_FINE_LOCATION
|
|
370
417
|
const val COARSE = Manifest.permission.ACCESS_COARSE_LOCATION
|
|
371
418
|
const val BACKGROUND = Manifest.permission.ACCESS_BACKGROUND_LOCATION
|
|
419
|
+
|
|
420
|
+
/** Apple's significant-change service's thresholds: 500 metres or more, at most every 5 minutes, without GPS. */
|
|
421
|
+
const val SIGNIFICANT_CHANGE_OPTIONS = """{"accuracy":"low","interval":300000,"distance":500}"""
|
|
372
422
|
}
|
|
373
423
|
}
|
|
@@ -5,6 +5,7 @@ import android.app.NotificationChannel
|
|
|
5
5
|
import android.app.NotificationManager
|
|
6
6
|
import android.app.PendingIntent
|
|
7
7
|
import android.app.Service
|
|
8
|
+
import android.content.Context
|
|
8
9
|
import android.content.Intent
|
|
9
10
|
import android.content.pm.ServiceInfo
|
|
10
11
|
import android.os.IBinder
|
|
@@ -25,7 +26,7 @@ class TokamakLocationService : Service() {
|
|
|
25
26
|
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
|
26
27
|
val content =
|
|
27
28
|
intent?.getStringExtra(NOTIFICATION_EXTRA)?.let(::JSONObject)
|
|
28
|
-
?: LocationUpdates(this).options?.optJSONObject("foregroundService")
|
|
29
|
+
?: LocationUpdates(this, LocationUpdates.UPDATES).options?.optJSONObject("foregroundService")
|
|
29
30
|
if (content == null) {
|
|
30
31
|
stopSelf()
|
|
31
32
|
return START_NOT_STICKY
|
|
@@ -59,6 +60,16 @@ class TokamakLocationService : Service() {
|
|
|
59
60
|
|
|
60
61
|
companion object {
|
|
61
62
|
/** The start intent's notification, as JSON. */
|
|
62
|
-
const val NOTIFICATION_EXTRA = "com.tokamak.plugins.location.NOTIFICATION"
|
|
63
|
+
private const val NOTIFICATION_EXTRA = "com.tokamak.plugins.location.NOTIFICATION"
|
|
64
|
+
|
|
65
|
+
/** Starts the service showing [notification], or shows it in the running service. */
|
|
66
|
+
fun start(context: Context, notification: JSONObject) {
|
|
67
|
+
val start = Intent(context, TokamakLocationService::class.java).putExtra(NOTIFICATION_EXTRA, notification.toString())
|
|
68
|
+
context.startForegroundService(start)
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
fun stop(context: Context) {
|
|
72
|
+
context.stopService(Intent(context, TokamakLocationService::class.java))
|
|
73
|
+
}
|
|
63
74
|
}
|
|
64
75
|
}
|
|
@@ -9,11 +9,8 @@ import android.location.LocationManager
|
|
|
9
9
|
import android.location.LocationRequest
|
|
10
10
|
import org.json.JSONObject
|
|
11
11
|
|
|
12
|
+
/** The JSON of each kind of updates' options, by name. */
|
|
12
13
|
private const val PREFERENCES = "tokamak.location"
|
|
13
|
-
|
|
14
|
-
/** The JSON of the running updates' options, as `startUpdates` received them. */
|
|
15
|
-
private const val UPDATES = "updates"
|
|
16
|
-
private const val UPDATES_ACTION = "com.tokamak.plugins.location.UPDATES"
|
|
17
14
|
private const val DEFAULT_INTERVAL_MILLIS = 10_000L
|
|
18
15
|
|
|
19
16
|
private val QUALITIES =
|
|
@@ -24,55 +21,42 @@ private val QUALITIES =
|
|
|
24
21
|
)
|
|
25
22
|
|
|
26
23
|
/**
|
|
27
|
-
*
|
|
28
|
-
* `PendingIntent`, starting the app's process when
|
|
29
|
-
*
|
|
30
|
-
*
|
|
24
|
+
* Location updates of one kind, [name]: `startUpdates`' or significant changes'. `LocationManager`
|
|
25
|
+
* sends them to [TokamakLocationReceiver] through a `PendingIntent`, starting the app's process when
|
|
26
|
+
* it has ended. Their options persist until [stop], and [restore] requests them where they are not
|
|
27
|
+
* running. Use it on the main thread.
|
|
31
28
|
*/
|
|
32
|
-
internal class LocationUpdates(private val context: Context) {
|
|
29
|
+
internal class LocationUpdates(private val context: Context, private val name: String) {
|
|
33
30
|
private val manager = context.getSystemService(LocationManager::class.java)
|
|
34
31
|
private val preferences = context.getSharedPreferences(PREFERENCES, Context.MODE_PRIVATE)
|
|
35
32
|
|
|
36
|
-
/** The options of the
|
|
33
|
+
/** The options of the updates, or null. */
|
|
37
34
|
val options: JSONObject?
|
|
38
|
-
get() = preferences.getString(
|
|
35
|
+
get() = preferences.getString(name, null)?.let(::JSONObject)
|
|
36
|
+
|
|
37
|
+
/** Keeps [options], which [restore] requests. */
|
|
38
|
+
fun keep(options: JSONObject) {
|
|
39
|
+
preferences.edit().putString(name, options.toString()).apply()
|
|
40
|
+
}
|
|
39
41
|
|
|
40
42
|
/**
|
|
41
|
-
*
|
|
42
|
-
*
|
|
43
|
+
* Keeps [options] and requests updates with them, replacing others; the same options running
|
|
44
|
+
* change nothing. Throws `SecurityException` without permission.
|
|
43
45
|
*/
|
|
44
46
|
fun start(options: JSONObject) {
|
|
45
|
-
|
|
46
|
-
|
|
47
|
+
if (options.toString() == preferences.getString(name, null) && isRequested()) return
|
|
48
|
+
keep(options)
|
|
47
49
|
request(options)
|
|
48
|
-
preferences.edit().putString(UPDATES, options.toString()).apply()
|
|
49
50
|
}
|
|
50
51
|
|
|
51
52
|
fun stop() {
|
|
52
|
-
preferences.edit().remove(
|
|
53
|
-
stopService()
|
|
53
|
+
preferences.edit().remove(name).apply()
|
|
54
54
|
pendingIntent(PendingIntent.FLAG_NO_CREATE)?.let {
|
|
55
55
|
manager.removeUpdates(it)
|
|
56
56
|
it.cancel()
|
|
57
57
|
}
|
|
58
58
|
}
|
|
59
59
|
|
|
60
|
-
/** Starts the running updates' foreground service, if they have one. */
|
|
61
|
-
fun startService() {
|
|
62
|
-
options?.optJSONObject("foregroundService")?.let(::startService)
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
private fun startService(notification: JSONObject) {
|
|
66
|
-
val start =
|
|
67
|
-
Intent(context, TokamakLocationService::class.java)
|
|
68
|
-
.putExtra(TokamakLocationService.NOTIFICATION_EXTRA, notification.toString())
|
|
69
|
-
context.startForegroundService(start)
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
private fun stopService() {
|
|
73
|
-
context.stopService(Intent(context, TokamakLocationService::class.java))
|
|
74
|
-
}
|
|
75
|
-
|
|
76
60
|
/**
|
|
77
61
|
* Requests the running updates again when Android [removed] the request, when their
|
|
78
62
|
* `PendingIntent` no longer exists, or after a force stop, which removes the request on Android
|
|
@@ -80,9 +64,11 @@ internal class LocationUpdates(private val context: Context) {
|
|
|
80
64
|
*/
|
|
81
65
|
fun restore(removed: Boolean) {
|
|
82
66
|
val options = options ?: return
|
|
83
|
-
if (removed || forceStopped() ||
|
|
67
|
+
if (removed || forceStopped() || !isRequested()) request(options)
|
|
84
68
|
}
|
|
85
69
|
|
|
70
|
+
private fun isRequested(): Boolean = pendingIntent(PendingIntent.FLAG_NO_CREATE) != null
|
|
71
|
+
|
|
86
72
|
/** Whether the app's process last ended by the user's force stop. */
|
|
87
73
|
private fun forceStopped(): Boolean =
|
|
88
74
|
context.getSystemService(ActivityManager::class.java)
|
|
@@ -98,7 +84,13 @@ internal class LocationUpdates(private val context: Context) {
|
|
|
98
84
|
.setMinUpdateDistanceMeters(options.optDouble("distance", 0.0).toFloat())
|
|
99
85
|
.build()
|
|
100
86
|
val updates = checkNotNull(pendingIntent(PendingIntent.FLAG_UPDATE_CURRENT))
|
|
101
|
-
|
|
87
|
+
try {
|
|
88
|
+
manager.requestLocationUpdates(provider(accuracy), request, updates)
|
|
89
|
+
} catch (error: Exception) {
|
|
90
|
+
// Without the `PendingIntent`, [restore] requests them again.
|
|
91
|
+
updates.cancel()
|
|
92
|
+
throw error
|
|
93
|
+
}
|
|
102
94
|
}
|
|
103
95
|
|
|
104
96
|
/** The fused provider when the device has one, and otherwise GPS for high accuracy and the network for less. */
|
|
@@ -114,7 +106,15 @@ internal class LocationUpdates(private val context: Context) {
|
|
|
114
106
|
PendingIntent.getBroadcast(
|
|
115
107
|
context,
|
|
116
108
|
0,
|
|
117
|
-
Intent(context, TokamakLocationReceiver::class.java).setAction(
|
|
109
|
+
Intent(context, TokamakLocationReceiver::class.java).setAction("com.tokamak.plugins.location.${name.uppercase()}"),
|
|
118
110
|
flags or PendingIntent.FLAG_MUTABLE,
|
|
119
111
|
)
|
|
112
|
+
|
|
113
|
+
companion object {
|
|
114
|
+
/** `startUpdates`' updates. */
|
|
115
|
+
const val UPDATES = "updates"
|
|
116
|
+
|
|
117
|
+
/** Significant changes, as `setup` starts them. */
|
|
118
|
+
const val SIGNIFICANT_CHANGES = "significantChanges"
|
|
119
|
+
}
|
|
120
120
|
}
|
|
@@ -19,6 +19,9 @@ final class TokamakLocationEvents {
|
|
|
19
19
|
private var positions: [[String: Any]] = []
|
|
20
20
|
private var stationary: Bool?
|
|
21
21
|
private var updating = false
|
|
22
|
+
/// The time of the newest position delivered, as services that run together
|
|
23
|
+
/// report the same positions.
|
|
24
|
+
private var latest = Date.distantPast
|
|
22
25
|
/// The messages of the problems last reported, by region id, or nil for
|
|
23
26
|
/// updates.
|
|
24
27
|
private var problems: [String?: Set<String>] = [:]
|
|
@@ -37,11 +40,14 @@ final class TokamakLocationEvents {
|
|
|
37
40
|
}
|
|
38
41
|
}
|
|
39
42
|
|
|
40
|
-
/// Delivers `location
|
|
41
|
-
///
|
|
42
|
-
/// reach it together as the next.
|
|
43
|
+
/// Delivers `location` when it is newer than the positions delivered, with
|
|
44
|
+
/// Apple's `stationary` flag when the system reports it. Positions that
|
|
45
|
+
/// arrive while the Worker handles an update reach it together as the next.
|
|
43
46
|
func update(_ location: CLLocation?, stationary: Bool? = nil) {
|
|
44
|
-
|
|
47
|
+
if let location, location.timestamp > latest {
|
|
48
|
+
latest = location.timestamp
|
|
49
|
+
positions.append(Self.position(location))
|
|
50
|
+
}
|
|
45
51
|
if let stationary {
|
|
46
52
|
self.stationary = stationary
|
|
47
53
|
}
|
|
@@ -239,8 +239,11 @@ final class TokamakLocationPlugin: NSObject, TokamakPlugin,
|
|
|
239
239
|
}
|
|
240
240
|
}
|
|
241
241
|
|
|
242
|
+
/// Core Location delivers each visit to every manager's delegate.
|
|
242
243
|
func locationManager(_ manager: CLLocationManager, didVisit visit: CLVisit) {
|
|
243
|
-
|
|
244
|
+
if manager === monitoring {
|
|
245
|
+
events.visit(visit)
|
|
246
|
+
}
|
|
244
247
|
}
|
|
245
248
|
|
|
246
249
|
/// Starts the significant changes and visits `config` names, and stops the
|
|
@@ -7,6 +7,8 @@ import Foundation
|
|
|
7
7
|
|
|
8
8
|
/// The JSON of each monitored region, as `addRegion` received it, by id.
|
|
9
9
|
private let regionsKey = "tokamak.location.regions"
|
|
10
|
+
/// The ids of the regions whose last transition was an entry.
|
|
11
|
+
private let insideKey = "tokamak.location.inside"
|
|
10
12
|
/// `CLMonitor` monitors at most 20 conditions for an app, and reports no
|
|
11
13
|
/// error beyond them.
|
|
12
14
|
private let regionLimit = 20
|
|
@@ -24,7 +26,7 @@ final class TokamakLocationRegions {
|
|
|
24
26
|
init(events: TokamakLocationEvents) {
|
|
25
27
|
self.events = events
|
|
26
28
|
let monitor = Task { @MainActor in
|
|
27
|
-
await Self.
|
|
29
|
+
await Self.firstUnlock()
|
|
28
30
|
return await CLMonitor(monitorName)
|
|
29
31
|
}
|
|
30
32
|
self.monitor = monitor
|
|
@@ -51,6 +53,11 @@ final class TokamakLocationRegions {
|
|
|
51
53
|
}
|
|
52
54
|
}
|
|
53
55
|
|
|
56
|
+
private var inside: Set<String> {
|
|
57
|
+
get { Set(defaults.stringArray(forKey: insideKey) ?? []) }
|
|
58
|
+
set { defaults.set(Array(newValue), forKey: insideKey) }
|
|
59
|
+
}
|
|
60
|
+
|
|
54
61
|
/// Monitors the region `region` describes, replacing one with the same id.
|
|
55
62
|
/// It starts outside, so a device already inside reports an entry, as on
|
|
56
63
|
/// Android.
|
|
@@ -71,11 +78,13 @@ final class TokamakLocationRegions {
|
|
|
71
78
|
let center = CLLocationCoordinate2D(latitude: latitude, longitude: longitude)
|
|
72
79
|
let condition = CLMonitor.CircularGeographicCondition(center: center, radius: radius)
|
|
73
80
|
regions[id] = region
|
|
81
|
+
inside.remove(id)
|
|
74
82
|
await monitor.add(condition, identifier: id, assuming: .unsatisfied)
|
|
75
83
|
}
|
|
76
84
|
|
|
77
85
|
func remove(_ id: String) async {
|
|
78
86
|
regions[id] = nil
|
|
87
|
+
inside.remove(id)
|
|
79
88
|
events.report([], region: id)
|
|
80
89
|
await monitor.value.remove(id)
|
|
81
90
|
}
|
|
@@ -98,19 +107,20 @@ final class TokamakLocationRegions {
|
|
|
98
107
|
return all
|
|
99
108
|
}
|
|
100
109
|
|
|
110
|
+
/// Reports the event's problems, and its transition when it changes whether the device is
|
|
111
|
+
/// inside the region, as events can repeat the state.
|
|
101
112
|
private func receive(_ event: CLMonitor.Event) {
|
|
102
113
|
let id = event.identifier
|
|
103
114
|
if #available(iOS 18, macOS 15, *) {
|
|
104
115
|
events.report(Self.problems(event), region: id)
|
|
105
116
|
}
|
|
106
|
-
guard let region = regions[id]
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
break
|
|
117
|
+
guard let region = regions[id], event.state == .satisfied || event.state == .unsatisfied
|
|
118
|
+
else { return }
|
|
119
|
+
let entered = event.state == .satisfied
|
|
120
|
+
guard entered != inside.contains(id) else { return }
|
|
121
|
+
inside = entered ? inside.union([id]) : inside.subtracting([id])
|
|
122
|
+
if region[entered ? "onEntry" : "onExit"] as? Bool != false {
|
|
123
|
+
events.transition(id, entered: entered, at: event.date)
|
|
114
124
|
}
|
|
115
125
|
}
|
|
116
126
|
|
|
@@ -133,22 +143,33 @@ final class TokamakLocationRegions {
|
|
|
133
143
|
),
|
|
134
144
|
(
|
|
135
145
|
event.persistenceUnavailable, "NotReadableError",
|
|
136
|
-
"
|
|
146
|
+
"The system cannot store the app's regions"
|
|
137
147
|
),
|
|
138
148
|
]
|
|
139
149
|
return flags.filter(\.0).map { TokamakPluginError(name: $0.1, message: $0.2) }
|
|
140
150
|
}
|
|
141
151
|
|
|
142
|
-
/// Returns once
|
|
152
|
+
/// Returns once the device has been unlocked since it booted, from when `CLMonitor` can read the
|
|
153
|
+
/// file that keeps its conditions, even while the device is locked.
|
|
143
154
|
@MainActor
|
|
144
|
-
private static func
|
|
155
|
+
private static func firstUnlock() async {
|
|
145
156
|
#if os(iOS)
|
|
146
|
-
|
|
147
|
-
let available = NotificationCenter.default.notifications(
|
|
157
|
+
let unlocked = NotificationCenter.default.notifications(
|
|
148
158
|
named: UIApplication.protectedDataDidBecomeAvailableNotification)
|
|
149
|
-
|
|
159
|
+
guard !unlockedSinceBoot() else { return }
|
|
160
|
+
for await _ in unlocked {
|
|
150
161
|
return
|
|
151
162
|
}
|
|
152
163
|
#endif
|
|
153
164
|
}
|
|
165
|
+
|
|
166
|
+
#if os(iOS)
|
|
167
|
+
/// Whether the device has been unlocked since it booted, as only then can a file protected
|
|
168
|
+
/// until the first unlock be written.
|
|
169
|
+
private static func unlockedSinceBoot() -> Bool {
|
|
170
|
+
let probe = FileManager.default.temporaryDirectory.appending(path: "TokamakLocation.unlocked")
|
|
171
|
+
let protection = Data.WritingOptions.completeFileProtectionUntilFirstUserAuthentication
|
|
172
|
+
return (try? Data().write(to: probe, options: protection)) != nil
|
|
173
|
+
}
|
|
174
|
+
#endif
|
|
154
175
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tokamakdev/plugin-location",
|
|
3
|
-
"version": "0.1.0-beta.
|
|
3
|
+
"version": "0.1.0-beta.106",
|
|
4
4
|
"repository": {
|
|
5
5
|
"type": "git",
|
|
6
6
|
"url": "git+https://github.com/mantty/tokamak.git",
|
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
"./events": "./events/index.ts"
|
|
13
13
|
},
|
|
14
14
|
"peerDependencies": {
|
|
15
|
-
"@tokamakdev/plugin": "0.1.0-beta.
|
|
15
|
+
"@tokamakdev/plugin": "0.1.0-beta.106"
|
|
16
16
|
},
|
|
17
17
|
"files": [
|
|
18
18
|
"android",
|
package/src/index.ts
CHANGED
|
@@ -29,9 +29,10 @@ class Location extends Plugin {
|
|
|
29
29
|
}
|
|
30
30
|
|
|
31
31
|
/**
|
|
32
|
-
* Applies
|
|
32
|
+
* Applies long-term location awareness, replacing the previous configuration, which the plugin keeps
|
|
33
33
|
* and applies again at each start. Rejects with `TypeError`, changing nothing, when a value is
|
|
34
|
-
* invalid
|
|
34
|
+
* invalid, and on Apple platforms with `NotSupportedError` where the device cannot monitor
|
|
35
|
+
* significant changes.
|
|
35
36
|
*/
|
|
36
37
|
async setup(config: SetupConfig): Promise<void> {
|
|
37
38
|
validateSetup(config, "config");
|
|
@@ -77,11 +78,12 @@ class Location extends Plugin {
|
|
|
77
78
|
}
|
|
78
79
|
|
|
79
80
|
/**
|
|
80
|
-
* Starts continuous
|
|
81
|
-
* `onLocationUpdate`, including after the system
|
|
82
|
-
* without in-use permission, and on iOS with
|
|
83
|
-
* `
|
|
84
|
-
* setting, and with `NeedsUIError` when Android does not
|
|
81
|
+
* Starts continuous tracking, replacing any running, until `stopUpdates`; `setup` gives long-term
|
|
82
|
+
* awareness instead. The updates' positions reach `onLocationUpdate`, including after the system
|
|
83
|
+
* ended the app. Rejects with `NotAllowedError` without in-use permission, and on iOS with
|
|
84
|
+
* `NeedsUIError` off screen. Android's `foregroundService` rejects with `NotSupportedError`
|
|
85
|
+
* without the `background-updates` build setting, and with `NeedsUIError` when Android does not
|
|
86
|
+
* allow the service to start off screen.
|
|
85
87
|
*/
|
|
86
88
|
async startUpdates(options: UpdateOptions = {}): Promise<void> {
|
|
87
89
|
validateUpdateOptions(options, "options");
|
package/src/types.ts
CHANGED
|
@@ -25,13 +25,18 @@ export type PositionCallback = (position: Position) => void;
|
|
|
25
25
|
export type PositionErrorCallback = (error: DOMException) => void;
|
|
26
26
|
|
|
27
27
|
/**
|
|
28
|
-
*
|
|
29
|
-
*
|
|
28
|
+
* Long-term location awareness, which runs across launches and restarts, uses little power and shows
|
|
29
|
+
* no indicator: `setup` starts what it names and stops what it leaves out. Off screen it needs
|
|
30
|
+
* background permission, with which the system launches the app for its events.
|
|
30
31
|
*/
|
|
31
32
|
export interface SetupConfig {
|
|
32
|
-
/**
|
|
33
|
+
/**
|
|
34
|
+
* Delivers moves of 500 metres or more, at most every 5 minutes, to `onLocationUpdate`, located
|
|
35
|
+
* from cellular and Wi-Fi rather than GPS: Apple's significant-change service, and on Android a
|
|
36
|
+
* low-power request with the same thresholds, which Android limits to a few an hour off screen.
|
|
37
|
+
*/
|
|
33
38
|
readonly significantChanges?: boolean;
|
|
34
|
-
/**
|
|
39
|
+
/** Apple only: delivers arrivals at and departures from places to `onVisit`. */
|
|
35
40
|
readonly visits?: boolean;
|
|
36
41
|
}
|
|
37
42
|
|
|
@@ -75,7 +80,12 @@ export type Activity =
|
|
|
75
80
|
| "airborne"
|
|
76
81
|
| "maritime";
|
|
77
82
|
|
|
78
|
-
/**
|
|
83
|
+
/**
|
|
84
|
+
* `startUpdates` options, for continuous tracking such as a run or navigation, which can show the
|
|
85
|
+
* system's indicator: the blue pill on iOS with in-use permission, and the notification of
|
|
86
|
+
* Android's `foregroundService`. Each option is platform-specific: Apple's relaunching updates
|
|
87
|
+
* take only an `activity`.
|
|
88
|
+
*/
|
|
79
89
|
export interface UpdateOptions {
|
|
80
90
|
/** Apple only: `"default"` by default. */
|
|
81
91
|
readonly activity?: Activity;
|
|
@@ -102,11 +112,14 @@ export interface ServiceNotification {
|
|
|
102
112
|
|
|
103
113
|
/** Positions from continuous updates or significant changes. */
|
|
104
114
|
export interface LocationUpdate {
|
|
105
|
-
/**
|
|
115
|
+
/**
|
|
116
|
+
* Oldest first: those that arrived while the Worker handled the previous update come together. Each
|
|
117
|
+
* is newer than those delivered before it, so one that several services report arrives once.
|
|
118
|
+
*/
|
|
106
119
|
readonly positions: readonly Position[];
|
|
107
120
|
/**
|
|
108
121
|
* Apple only, from iOS 18 and macOS 15: true on the last update before updates pause while the
|
|
109
|
-
* device is still. `positions` is empty when the update has no position.
|
|
122
|
+
* device is still. `positions` is empty when the update has no new position.
|
|
110
123
|
*/
|
|
111
124
|
readonly stationary?: boolean;
|
|
112
125
|
}
|