@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 CHANGED
@@ -1,8 +1,8 @@
1
1
  # @tokamakdev/plugin-location
2
2
 
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.
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
- onLocationUpdate(async ({ positions }, env) => {
27
- await fetch("https://example.com/track", { method: "POST", body: JSON.stringify(positions) });
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. 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.
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
- ## 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.
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` build setting, which
151
- shows the location indicator, or Always permission. `startUpdates` rejects
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 that arrive while the Worker
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
- positions += received
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) updates.startService()
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
- // Significant changes and visits, setup's keys, are Apple's.
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
- updates.stop()
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
- action(true)
127
- return
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
- host.requestPermissions(asked) { granted ->
166
- if (!background || !granted.containsValue(true)) return@requestPermissions reply(Result.success(permission()))
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
- if (options.has("foregroundService")) requireService()
179
- runCatching { updates.start(options) }
180
- .onSuccess { reply(Result.success(null)) }
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, and starts the updates' foreground service on screen, or after a
205
- * restart or an update with background permission. Reports a failure to `onLocationError`.
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
- if (host.isInForeground || (updatesRemoved && granted(BACKGROUND))) updates.startService()
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
- if (intent.getBooleanExtra(LocationManager.KEY_PROVIDER_ENABLED, true)) return finish()
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
- val received = locations(intent).map(::position)
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
- * Continuous updates, which `LocationManager` sends to [TokamakLocationReceiver] through a
28
- * `PendingIntent`, starting the app's process when it has ended, with [TokamakLocationService] when
29
- * their options name a `foregroundService`. The options persist until [stop], and [restore] requests
30
- * them again where Android removed the request. Use it on the main thread.
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 running updates, or null. */
33
+ /** The options of the updates, or null. */
37
34
  val options: JSONObject?
38
- get() = preferences.getString(UPDATES, null)?.let(::JSONObject)
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
- * Starts updates with [options], replacing any running, in the foreground service when they name
42
- * one, which stops otherwise. Throws `SecurityException` without permission.
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
- val notification = options.optJSONObject("foregroundService")
46
- if (notification == null) stopService() else startService(notification)
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(UPDATES).apply()
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() || pendingIntent(PendingIntent.FLAG_NO_CREATE) == null) request(options)
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
- manager.requestLocationUpdates(provider(accuracy), request, updates)
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(UPDATES_ACTION),
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`, with Apple's `stationary` flag when the system
41
- /// reports it. Positions that arrive while the Worker handles an update
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
- positions += location.map { [Self.position($0)] } ?? []
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
- events.visit(visit)
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.protectedData()
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] else { return }
107
- switch event.state {
108
- case .satisfied where region["onEntry"] as? Bool != false:
109
- events.transition(id, entered: true, at: event.date)
110
- case .unsatisfied where region["onExit"] as? Bool != false:
111
- events.transition(id, entered: false, at: event.date)
112
- default:
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
- "Regions are unavailable until the device is first unlocked"
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 protected data is available, which `CLMonitor` needs.
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 protectedData() async {
155
+ private static func firstUnlock() async {
145
156
  #if os(iOS)
146
- guard !UIApplication.shared.isProtectedDataAvailable else { return }
147
- let available = NotificationCenter.default.notifications(
157
+ let unlocked = NotificationCenter.default.notifications(
148
158
  named: UIApplication.protectedDataDidBecomeAvailableNotification)
149
- for await _ in available {
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.103",
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.103"
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 what runs at every launch, replacing the previous configuration, which the plugin keeps
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 updates, replacing any running, until `stopUpdates`. Their positions reach
81
- * `onLocationUpdate`, including after the system ended the app. Rejects with `NotAllowedError`
82
- * without in-use permission, and on iOS with `NeedsUIError` off screen. Android's
83
- * `foregroundService` rejects with `NotSupportedError` without the `background-updates` build
84
- * setting, and with `NeedsUIError` when Android does not allow the service to start off screen.
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
- * What runs at every launch: `setup` starts what it names and stops what it leaves out. Apple only;
29
- * other platforms ignore these keys. Each relaunches the app for its events with Always permission.
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
- /** Delivers significant changes of location to `onLocationUpdate`. */
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
- /** Delivers arrivals at and departures from places to `onVisit`. */
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
- /** `startUpdates` options. Each is platform-specific: Apple's relaunching updates take only an `activity`. */
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
- /** Oldest first: those that arrived while the Worker handled the previous update come together. */
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
  }