@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 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
  ```
@@ -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
- ## 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.
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` build setting, which
151
- shows the location indicator, or Always permission. `startUpdates` rejects
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) updates.startService()
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
- // Significant changes and visits, setup's keys, are Apple's.
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
- updates.stop()
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
- action(true)
127
- return
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
- host.requestPermissions(asked) { granted ->
166
- if (!background || !granted.containsValue(true)) return@requestPermissions reply(Result.success(permission()))
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
- if (options.has("foregroundService")) requireService()
179
- runCatching { updates.start(options) }
180
- .onSuccess { reply(Result.success(null)) }
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, and starts the updates' foreground service on screen, or after a
205
- * restart or an update with background permission. Reports a failure to `onLocationError`.
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
- if (host.isInForeground || (updatesRemoved && granted(BACKGROUND))) updates.startService()
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
- * 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
  }
@@ -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.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.103"
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 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;