@tokamakdev/plugin-location 0.1.0-beta.103 → 0.1.0-beta.104
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +45 -26
- package/android/LocationPlugin.kt +63 -17
- package/android/LocationService.kt +13 -2
- package/android/LocationUpdates.kt +37 -37
- 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 +15 -5
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
|
```
|
|
@@ -131,13 +135,38 @@ The plugin's `onLocationUpdate`, `onRegionTransition`, `onVisit` and
|
|
|
131
135
|
`onLocationError` methods receive the same events while the listening page or
|
|
132
136
|
Worker runs, and return the function that removes the listener.
|
|
133
137
|
|
|
134
|
-
##
|
|
135
|
-
|
|
136
|
-
`
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
138
|
+
## Long-term awareness
|
|
139
|
+
|
|
140
|
+
`setup(config)` keeps the app aware of where the device is over days, with
|
|
141
|
+
little power and no indicator. Call it from `onStart` in `src/tokamak.ts`, so
|
|
142
|
+
it applies at every launch; the plugin also keeps it and applies it again
|
|
143
|
+
itself. The configuration replaces the previous one, and an invalid one
|
|
144
|
+
rejects with `TypeError` and changes nothing. Apple platforms reject with
|
|
145
|
+
`NotSupportedError` where the device cannot monitor significant changes.
|
|
146
|
+
|
|
147
|
+
- `significantChanges` delivers moves of 500 metres or more, at most every 5
|
|
148
|
+
minutes, to `onLocationUpdate`, located from cellular and Wi-Fi rather than
|
|
149
|
+
GPS. Apple platforms use the significant-change service. Android uses a
|
|
150
|
+
low-power request with the same thresholds, which it limits to a few an hour
|
|
151
|
+
off screen. Without permission they start once the app has it on Apple
|
|
152
|
+
platforms, and on Android after the plugin's prompt or at the next launch.
|
|
153
|
+
- `visits`, Apple only, delivers arrivals at and departures from the places
|
|
154
|
+
the user stays to `onVisit`. It uses the least power of all.
|
|
155
|
+
|
|
156
|
+
With background permission, iOS relaunches the app for both, and Android starts
|
|
157
|
+
the app's process for significant changes. [Regions](#regions) wake the app
|
|
158
|
+
the same way.
|
|
159
|
+
|
|
160
|
+
## Continuous tracking
|
|
161
|
+
|
|
162
|
+
`startUpdates(options)` starts continuous updates for an activity the user
|
|
163
|
+
follows, such as a run or navigation, replacing any running, until
|
|
164
|
+
`stopUpdates()`. Off screen the system can show that the app is tracking:
|
|
165
|
+
iOS's blue location pill with in-use permission, and the notification of
|
|
166
|
+
Android's foreground service.
|
|
167
|
+
`getUpdates()` resolves with the running updates' options, or null. Updates
|
|
168
|
+
need in-use permission, and reject with `NotAllowedError` without it. The
|
|
169
|
+
plugin keeps them across launches and restarts.
|
|
141
170
|
|
|
142
171
|
**Apple** uses `CLLocationUpdate.liveUpdates`.
|
|
143
172
|
|
|
@@ -147,9 +176,9 @@ restarts.
|
|
|
147
176
|
- Updates pause while the device is still and resume when it moves.
|
|
148
177
|
- If iOS ends the app while updates run, it launches the app for the next
|
|
149
178
|
update, and the plugin resumes them.
|
|
150
|
-
- Off screen, updates need the `background-updates`
|
|
151
|
-
|
|
152
|
-
with `NeedsUIError` off screen on iOS.
|
|
179
|
+
- Off screen, updates need Always permission, or the `background-updates`
|
|
180
|
+
build setting, whose background activity session shows the pill.
|
|
181
|
+
`startUpdates` rejects with `NeedsUIError` off screen on iOS.
|
|
153
182
|
- From iOS 18, failures such as denied permission or lost location reach
|
|
154
183
|
`onLocationError` once each.
|
|
155
184
|
- `getCurrentPosition` and `watchPosition` can change the coordinates updates
|
|
@@ -197,16 +226,6 @@ and background permission off screen.
|
|
|
197
226
|
happens. Adding a region again, as the plugin does after a restart, reports
|
|
198
227
|
an entry again for a device inside.
|
|
199
228
|
|
|
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
229
|
## Platforms
|
|
211
230
|
|
|
212
231
|
The web uses `navigator.geolocation`. `permission()` reports `inUse` from the
|
|
@@ -48,7 +48,8 @@ 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
|
|
|
@@ -64,13 +65,12 @@ class TokamakLocationPlugin(
|
|
|
64
65
|
|
|
65
66
|
/** Starts the updates' foreground service again once the app is on screen. */
|
|
66
67
|
override fun onIntent(intent: Intent) {
|
|
67
|
-
if (host.isInForeground)
|
|
68
|
+
if (host.isInForeground) startService()
|
|
68
69
|
}
|
|
69
70
|
|
|
70
71
|
override fun call(method: String, arguments: Any?, reply: TokamakPluginReply) {
|
|
71
72
|
when (method) {
|
|
72
|
-
|
|
73
|
-
"setup" -> reply(Result.success(null))
|
|
73
|
+
"setup" -> setup(requireObject(arguments), reply)
|
|
74
74
|
"getCurrentPosition" ->
|
|
75
75
|
withPermission { granted ->
|
|
76
76
|
if (granted) currentPosition(arguments as? JSONObject, reply) else reply(Result.failure(permissionDenied()))
|
|
@@ -81,7 +81,7 @@ class TokamakLocationPlugin(
|
|
|
81
81
|
"openSettings" -> openSettings(reply)
|
|
82
82
|
"startUpdates" -> startUpdates(requireObject(arguments), reply)
|
|
83
83
|
"stopUpdates" -> {
|
|
84
|
-
|
|
84
|
+
stopUpdates()
|
|
85
85
|
reply(Result.success(null))
|
|
86
86
|
}
|
|
87
87
|
"getUpdates" -> reply(Result.success(updates.options))
|
|
@@ -122,11 +122,19 @@ class TokamakLocationPlugin(
|
|
|
122
122
|
* Throws `NeedsUIError` when asking would show UI while the app is in the background.
|
|
123
123
|
*/
|
|
124
124
|
private fun withPermission(action: (Boolean) -> Unit) {
|
|
125
|
-
if (hasForegroundPermission())
|
|
126
|
-
|
|
127
|
-
|
|
125
|
+
if (hasForegroundPermission()) action(true) else askForeground(foreground, action)
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Asks for the foreground [permissions], then passes [action] whether the app has one, first
|
|
130
|
+
* starting what waited for permission.
|
|
131
|
+
*/
|
|
132
|
+
private fun askForeground(permissions: Set<String>, action: (Boolean) -> Unit) {
|
|
133
|
+
host.requestPermissions(permissions) { granted ->
|
|
134
|
+
val allowed = granted.containsValue(true)
|
|
135
|
+
if (allowed) restore(updatesRemoved = false)
|
|
136
|
+
action(allowed)
|
|
128
137
|
}
|
|
129
|
-
host.requestPermissions(foreground) { granted -> action(granted.containsValue(true)) }
|
|
130
138
|
}
|
|
131
139
|
|
|
132
140
|
private fun granted(permission: String): Boolean =
|
|
@@ -162,8 +170,8 @@ class TokamakLocationPlugin(
|
|
|
162
170
|
throw TokamakPluginError.notSupported("Background location needs the location plugin's background build setting")
|
|
163
171
|
}
|
|
164
172
|
val asked = if (options.optionalString("precision") == "approximate") setOf(COARSE) else foreground
|
|
165
|
-
|
|
166
|
-
if (!background || !
|
|
173
|
+
askForeground(asked) { allowed ->
|
|
174
|
+
if (!background || !allowed) return@askForeground reply(Result.success(permission()))
|
|
167
175
|
runCatching { host.requestPermissions(setOf(BACKGROUND)) { reply(Result.success(permission())) } }
|
|
168
176
|
.onFailure { reply(Result.failure(it)) }
|
|
169
177
|
}
|
|
@@ -173,11 +181,43 @@ class TokamakLocationPlugin(
|
|
|
173
181
|
if (!hasForegroundPermission()) throw TokamakPluginError("NotAllowedError", "Location permission has not been granted")
|
|
174
182
|
}
|
|
175
183
|
|
|
184
|
+
/** Starts updates in the foreground service when [options] name one, which stops otherwise. On failure, none run. */
|
|
176
185
|
private fun startUpdates(options: JSONObject, reply: TokamakPluginReply) {
|
|
177
186
|
requirePermission()
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
187
|
+
val notification = options.optJSONObject("foregroundService")
|
|
188
|
+
if (notification != null) requireService()
|
|
189
|
+
runCatching {
|
|
190
|
+
if (notification == null) TokamakLocationService.stop(context) else TokamakLocationService.start(context, notification)
|
|
191
|
+
updates.start(options)
|
|
192
|
+
}.onSuccess { reply(Result.success(null)) }
|
|
193
|
+
.onFailure {
|
|
194
|
+
stopUpdates()
|
|
195
|
+
reply(Result.failure(locationError(it)))
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
private fun stopUpdates() {
|
|
200
|
+
updates.stop()
|
|
201
|
+
TokamakLocationService.stop(context)
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
private fun startService() {
|
|
205
|
+
updates.options?.optJSONObject("foregroundService")?.let { TokamakLocationService.start(context, it) }
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* Starts significant changes when [config] names them, and stops them otherwise. Without
|
|
210
|
+
* permission they start once the app has it.
|
|
211
|
+
*/
|
|
212
|
+
private fun setup(config: JSONObject, reply: TokamakPluginReply) {
|
|
213
|
+
val options = JSONObject(SIGNIFICANT_CHANGE_OPTIONS)
|
|
214
|
+
runCatching {
|
|
215
|
+
when {
|
|
216
|
+
!config.optBoolean("significantChanges") -> significantChanges.stop()
|
|
217
|
+
hasForegroundPermission() -> significantChanges.start(options)
|
|
218
|
+
else -> significantChanges.keep(options)
|
|
219
|
+
}
|
|
220
|
+
}.onSuccess { reply(Result.success(null)) }
|
|
181
221
|
.onFailure { reply(Result.failure(locationError(it))) }
|
|
182
222
|
}
|
|
183
223
|
|
|
@@ -201,13 +241,16 @@ class TokamakLocationPlugin(
|
|
|
201
241
|
}
|
|
202
242
|
|
|
203
243
|
/**
|
|
204
|
-
* Restores updates and regions
|
|
205
|
-
*
|
|
244
|
+
* Restores updates, significant changes and regions where the app has permission, as Android
|
|
245
|
+
* pauses them without it, and starts the updates' foreground service on screen, or after a restart
|
|
246
|
+
* or an update with background permission. Reports a failure to `onLocationError`.
|
|
206
247
|
*/
|
|
207
248
|
internal fun restore(updatesRemoved: Boolean) {
|
|
249
|
+
if (!hasForegroundPermission()) return
|
|
208
250
|
runCatching {
|
|
209
251
|
updates.restore(updatesRemoved)
|
|
210
|
-
|
|
252
|
+
significantChanges.restore(updatesRemoved)
|
|
253
|
+
if (host.isInForeground || (updatesRemoved && granted(BACKGROUND))) startService()
|
|
211
254
|
}.onFailure { events.fail(locationError(it)) }
|
|
212
255
|
runCatching { regions.restore() }.onFailure { events.fail(locationError(it)) }
|
|
213
256
|
}
|
|
@@ -369,5 +412,8 @@ class TokamakLocationPlugin(
|
|
|
369
412
|
const val FINE = Manifest.permission.ACCESS_FINE_LOCATION
|
|
370
413
|
const val COARSE = Manifest.permission.ACCESS_COARSE_LOCATION
|
|
371
414
|
const val BACKGROUND = Manifest.permission.ACCESS_BACKGROUND_LOCATION
|
|
415
|
+
|
|
416
|
+
/** Apple's significant-change service's thresholds: 500 metres or more, at most every 5 minutes, without GPS. */
|
|
417
|
+
const val SIGNIFICANT_CHANGE_OPTIONS = """{"accuracy":"low","interval":300000,"distance":500}"""
|
|
372
418
|
}
|
|
373
419
|
}
|
|
@@ -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
|
}
|
|
@@ -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.104",
|
|
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.104"
|
|
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;
|