@tokamakdev/plugin-location 0.1.0-beta.127 → 0.1.0-beta.128

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
@@ -254,14 +254,6 @@ The web uses `navigator.geolocation`. `permission()` reports `inUse` from the
254
254
  Permissions API, and the other additions reject with `NotSupportedError`,
255
255
  including the event-stream methods. Native support is provided on Apple and Android.
256
256
 
257
- ## Platform bindings
258
-
259
- `@tokamakdev/plugin-location/apple` is Core Location, and
260
- `@tokamakdev/plugin-location/android` is `android.location` with the
261
- `Context`, `CancellationSignal`, `SystemClock`, `Executor` and `Consumer`
262
- types it uses, for Worker code that needs what this API does not offer.
263
- `getCurrentPosition` and `watchPosition` are built on them.
264
-
265
257
  ## Errors
266
258
 
267
259
  Rejections and `onLocationError` events are `DOMException` names:
@@ -45,6 +45,7 @@ class TokamakLocationPlugin(
45
45
  private val significantChanges = LocationUpdates(context, LocationUpdates.SIGNIFICANT_CHANGES)
46
46
  private val regions = LocationRegions(context)
47
47
  private val events = LocationEvents(host)
48
+ private val positions = LocationPositions(context, manager)
48
49
 
49
50
  /** Whether the provider was on when last reported, as each request reports its changes. */
50
51
  private var providerEnabled = true
@@ -67,6 +68,8 @@ class TokamakLocationPlugin(
67
68
  override fun call(method: String, arguments: Any?, reply: TokamakPluginReply): (() -> Unit)? {
68
69
  when (method) {
69
70
  "setup" -> setup(requireObject(arguments), reply)
71
+ "recentPosition" -> reply(Result.success(recentPosition(requireObject(arguments))))
72
+ "currentPosition" -> return currentPosition(reply)
70
73
  "permission" -> reply(Result.success(permission()))
71
74
  "requestPermission" -> requestPermission(requireObject(arguments), reply)
72
75
  "servicesEnabled" -> reply(Result.success(manager.isLocationEnabled))
@@ -92,12 +95,30 @@ class TokamakLocationPlugin(
92
95
  method: String,
93
96
  arguments: Any?,
94
97
  reply: TokamakPluginReply,
95
- ): () -> Unit = events.listen(method, reply) ?: super.subscribe(method, arguments, reply)
98
+ ): () -> Unit =
99
+ when (method) {
100
+ "watchPosition" -> watchPosition(reply)
101
+ else -> events.listen(method, reply) ?: super.subscribe(method, arguments, reply)
102
+ }
103
+
104
+ /** The provider's last known position when it is no older than the options' `maximumAge`, or null. */
105
+ private fun recentPosition(options: JSONObject): JSONObject? =
106
+ runCatching { positions.recent(options.optDouble("maximumAge", 0.0))?.let(::position) }
107
+ .getOrElse { throw locationError(it) }
108
+
109
+ /** Replies with a new fix, or null when Android finds none, unless the returned cancellation runs first. */
110
+ private fun currentPosition(reply: TokamakPluginReply): () -> Unit =
111
+ runCatching { positions.current { reply(Result.success(it?.let(::position))) } }
112
+ .getOrElse { throw locationError(it) }
113
+
114
+ /** Replies with each position as the device moves, until the provider turns off or the returned release runs. */
115
+ private fun watchPosition(reply: TokamakPluginReply): () -> Unit =
116
+ runCatching {
117
+ positions.watch({ reply(Result.success(position(it))) }) {
118
+ reply(Result.failure(TokamakPluginError("NotReadableError", "Location provider is unavailable")))
119
+ }
120
+ }.getOrElse { throw locationError(it) }
96
121
 
97
- /**
98
- * Passes [action] whether the app may read fine or coarse location, asking when it may not.
99
- * Throws `NeedsUIError` when asking would show UI while the app is in the background.
100
- */
101
122
  /**
102
123
  * Asks for the foreground [permissions], then passes [action] whether the app has one, first
103
124
  * starting what waited for permission.
@@ -0,0 +1,52 @@
1
+ package com.tokamak.plugins.location
2
+
3
+ import android.content.Context
4
+ import android.location.Location
5
+ import android.location.LocationListener
6
+ import android.location.LocationManager
7
+ import android.location.LocationRequest
8
+ import android.os.CancellationSignal
9
+ import android.os.SystemClock
10
+ import com.tokamak.runtime.TokamakPluginError
11
+
12
+ /**
13
+ * Positions for `getCurrentPosition` and `watchPosition`, at the provider's highest accuracy, from
14
+ * the first enabled of the fused provider, which combines GPS with Wi-Fi and cell signals, GPS and
15
+ * the network, or any other enabled provider. Each throws `SecurityException` without permission.
16
+ */
17
+ internal class LocationPositions(private val context: Context, private val manager: LocationManager) {
18
+ /** The provider's last known position when it is no older than [maximumAge] milliseconds. */
19
+ fun recent(maximumAge: Double): Location? = manager.getLastKnownLocation(provider())?.takeIf { age(it) <= maximumAge }
20
+
21
+ /** Passes [found] a new fix, or null when Android finds none, and returns the cancellation. */
22
+ fun current(found: (Location?) -> Unit): () -> Unit {
23
+ val signal = CancellationSignal()
24
+ manager.getCurrentLocation(provider(), highAccuracy(0), signal, context.mainExecutor) { found(it) }
25
+ return signal::cancel
26
+ }
27
+
28
+ /** Passes [next] each position, and [disabled] the provider turning off, until the returned release runs. */
29
+ fun watch(next: (Location) -> Unit, disabled: () -> Unit): () -> Unit {
30
+ val listener = object : LocationListener {
31
+ override fun onLocationChanged(location: Location) = next(location)
32
+
33
+ override fun onProviderDisabled(provider: String) = disabled()
34
+ }
35
+ manager.requestLocationUpdates(provider(), highAccuracy(1000), context.mainExecutor, listener)
36
+ return { manager.removeUpdates(listener) }
37
+ }
38
+
39
+ private fun provider(): String =
40
+ listOf(LocationManager.FUSED_PROVIDER, LocationManager.GPS_PROVIDER, LocationManager.NETWORK_PROVIDER)
41
+ .firstOrNull(manager::isProviderEnabled)
42
+ ?: manager.getProviders(true).firstOrNull()
43
+ ?: throw TokamakPluginError("NotReadableError", "No location provider is available")
44
+
45
+ /** A request for the most accurate fixes the provider gives, every [interval] milliseconds. */
46
+ private fun highAccuracy(interval: Long): LocationRequest =
47
+ LocationRequest.Builder(interval).setQuality(LocationRequest.QUALITY_HIGH_ACCURACY).build()
48
+
49
+ /** The milliseconds since Android took [location]. */
50
+ private fun age(location: Location): Double =
51
+ (SystemClock.elapsedRealtimeNanos() - location.elapsedRealtimeNanos) / 1_000_000.0
52
+ }
@@ -152,4 +152,13 @@ extension TokamakPluginError {
152
152
  static func locationUnavailable(_ message: String) -> Self {
153
153
  Self(name: "NotReadableError", message: message)
154
154
  }
155
+
156
+ /// Core Location's `error`: `NotAllowedError` without permission, and
157
+ /// `NotReadableError` otherwise.
158
+ static func location(_ error: Error) -> Self {
159
+ if (error as NSError).code == CLError.Code.denied.rawValue {
160
+ return Self(name: "NotAllowedError", message: "Location permission was denied")
161
+ }
162
+ return .locationUnavailable(error.localizedDescription)
163
+ }
155
164
  }
@@ -19,10 +19,6 @@ final class TokamakLocationPlugin: NSObject, TokamakPlugin,
19
19
  {
20
20
  let id = "location"
21
21
 
22
- private static let permissionDenied = TokamakPluginError(
23
- name: "NotAllowedError",
24
- message: "Location permission was denied"
25
- )
26
22
  /// A `requestPermission` waiting for the user to answer a prompt.
27
23
  private struct Prompt {
28
24
  /// The purpose key of the temporary precise location to ask for next.
@@ -91,6 +87,13 @@ final class TokamakLocationPlugin: NSObject, TokamakPlugin,
91
87
  apply(options)
92
88
  defaults.set(try? JSONSerialization.data(withJSONObject: options), forKey: setupKey)
93
89
  reply(.success(nil))
90
+ case "recentPosition":
91
+ reply(.success(recentPosition(maximumAge: (options["maximumAge"] as? Double ?? 0) / 1000)))
92
+ case "currentPosition":
93
+ let fix = TokamakLocationPositions(reply: reply)
94
+ fix.locate()
95
+ // The cancellation owns the location manager until the call ends.
96
+ return fix.stop
94
97
  case "permission":
95
98
  reply(.success(permission()))
96
99
  case "requestPermission":
@@ -151,6 +154,12 @@ final class TokamakLocationPlugin: NSObject, TokamakPlugin,
151
154
  arguments: Any,
152
155
  reply: @escaping TokamakPluginReply
153
156
  ) throws(TokamakPluginError) -> (() -> Void) {
157
+ if method == "watchPosition" {
158
+ let watch = TokamakLocationPositions(reply: reply)
159
+ watch.watch()
160
+ // The release owns the location manager until the subscription ends.
161
+ return watch.stop
162
+ }
154
163
  guard let remove = events.listen(method, reply) else {
155
164
  throw .notSupported("\(id).\(method) is not supported")
156
165
  }
@@ -185,7 +194,7 @@ final class TokamakLocationPlugin: NSObject, TokamakPlugin,
185
194
  ) {
186
195
  guard manager === monitoring, (error as NSError).code != CLError.Code.locationUnknown.rawValue
187
196
  else { return }
188
- events.fail(locationError(error))
197
+ events.fail(.location(error))
189
198
  }
190
199
 
191
200
  /// Core Location delivers each visit to every manager's delegate.
@@ -195,6 +204,14 @@ final class TokamakLocationPlugin: NSObject, TokamakPlugin,
195
204
  }
196
205
  }
197
206
 
207
+ /// Core Location's latest position when it is no older than `maximumAge`
208
+ /// seconds, or nil.
209
+ private func recentPosition(maximumAge: TimeInterval) -> Any? {
210
+ guard let recent = authorization.location, -recent.timestamp.timeIntervalSinceNow <= maximumAge
211
+ else { return nil }
212
+ return TokamakLocationEvents.position(recent)
213
+ }
214
+
198
215
  /// Starts the significant changes and visits `config` names, and stops the
199
216
  /// others. After a launch for one of their events, starting them again
200
217
  /// delivers it.
@@ -381,11 +398,4 @@ final class TokamakLocationPlugin: NSObject, TokamakPlugin,
381
398
  reply(opened == true ? .success(nil) : .failure(notOpened))
382
399
  #endif
383
400
  }
384
-
385
- private func locationError(_ error: Error) -> TokamakPluginError {
386
- if (error as NSError).code == CLError.Code.denied.rawValue {
387
- return Self.permissionDenied
388
- }
389
- return .locationUnavailable(error.localizedDescription)
390
- }
391
401
  }
@@ -0,0 +1,49 @@
1
+ import CoreLocation
2
+ import Foundation
3
+
4
+ /// Positions from a location manager of its own, for one `getCurrentPosition`
5
+ /// or `watchPosition`. Use it on the main thread.
6
+ final class TokamakLocationPositions: NSObject, CLLocationManagerDelegate {
7
+ private let manager = CLLocationManager()
8
+ private let reply: TokamakPluginReply
9
+ private var watching = false
10
+
11
+ /// Passes `reply` the new fix, or each position as the device moves, or a
12
+ /// failure.
13
+ init(reply: @escaping TokamakPluginReply) {
14
+ self.reply = reply
15
+ super.init()
16
+ manager.delegate = self
17
+ manager.desiredAccuracy = kCLLocationAccuracyBest
18
+ }
19
+
20
+ func locate() {
21
+ manager.requestLocation()
22
+ }
23
+
24
+ func watch() {
25
+ watching = true
26
+ manager.startUpdatingLocation()
27
+ }
28
+
29
+ func stop() {
30
+ manager.stopUpdatingLocation()
31
+ }
32
+
33
+ func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) {
34
+ for location in watching ? locations : locations.suffix(1) {
35
+ reply(.success(TokamakLocationEvents.position(location)))
36
+ }
37
+ }
38
+
39
+ /// Asks again, or keeps watching, while Core Location does not know the
40
+ /// position yet.
41
+ func locationManager(_ manager: CLLocationManager, didFailWithError error: Error) {
42
+ guard (error as NSError).code == CLError.Code.locationUnknown.rawValue else {
43
+ return reply(.failure(.location(error)))
44
+ }
45
+ if !watching {
46
+ manager.requestLocation()
47
+ }
48
+ }
49
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tokamakdev/plugin-location",
3
- "version": "0.1.0-beta.127",
3
+ "version": "0.1.0-beta.128",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "git+https://github.com/mantty/tokamak.git",
@@ -9,12 +9,10 @@
9
9
  "type": "module",
10
10
  "exports": {
11
11
  ".": "./src/index.ts",
12
- "./events": "./events/index.ts",
13
- "./apple": "./apple/bindings.ts",
14
- "./android": "./android/bindings.ts"
12
+ "./events": "./events/index.ts"
15
13
  },
16
14
  "peerDependencies": {
17
- "@tokamakdev/plugin": "0.1.0-beta.127"
15
+ "@tokamakdev/plugin": "0.1.0-beta.128"
18
16
  },
19
17
  "files": [
20
18
  "android",
package/src/errors.ts CHANGED
@@ -15,20 +15,3 @@ export function timedOut(): DOMException {
15
15
  export function unavailable(message: string): DOMException {
16
16
  return new DOMException(message, "NotReadableError");
17
17
  }
18
-
19
- /**
20
- * Rejects once `timeout` milliseconds pass without `found` settling, running `stop` either way. An
21
- * undefined `timeout` waits as long as it takes.
22
- */
23
- export async function within<T>(found: Promise<T>, timeout: number | undefined, stop: () => void): Promise<T> {
24
- let timer: ReturnType<typeof setTimeout> | undefined;
25
- const expired = new Promise<never>((_, reject) => {
26
- if (timeout !== undefined) timer = setTimeout(() => { reject(timedOut()); }, timeout);
27
- });
28
- try {
29
- return await Promise.race([found, expired]);
30
- } finally {
31
- clearTimeout(timer);
32
- stop();
33
- }
34
- }
package/src/index.ts CHANGED
@@ -1,9 +1,6 @@
1
1
  import { Plugin, lifecycleStage, type JsonStream, type TokamakContext } from "@tokamakdev/plugin";
2
- import { valueStream } from "@tokamakdev/plugin/streams";
3
2
 
4
- import * as android from "./android.js";
5
- import * as apple from "./apple.js";
6
- import { backgroundDenied, permissionDenied, unavailable } from "./errors.js";
3
+ import { backgroundDenied, permissionDenied, timedOut, unavailable } from "./errors.js";
7
4
  import type {
8
5
  LocationErrorEvent,
9
6
  LocationPermission,
@@ -41,9 +38,15 @@ class Location extends Plugin {
41
38
  * when location is unavailable. Asking off screen rejects with `NeedsUIError`, as
42
39
  * `requestPermission` does.
43
40
  */
44
- async getCurrentPosition(options: PositionOptions = {}): Promise<Position> {
41
+ async getCurrentPosition({ maximumAge = 0, timeout }: PositionOptions = {}): Promise<Position> {
45
42
  await this.#authorize();
46
- const position = await this.#platform().currentPosition(this.ctx, options);
43
+ // JSON has no Infinity, which accepts any recent position.
44
+ const recent = await this.call<Position | null>("recentPosition", { maximumAge: Math.min(maximumAge, Number.MAX_VALUE) });
45
+ if (recent) return recent;
46
+ const located = new AbortController();
47
+ const timer = timeout === undefined ? undefined : setTimeout(() => { located.abort(timedOut()); }, timeout);
48
+ const position = await this.call<Position | null>("currentPosition", null, { signal: located.signal })
49
+ .finally(() => { clearTimeout(timer); });
47
50
  if (position) return position;
48
51
  // Android finds no position in the background without background permission.
49
52
  const background = await lifecycleStage(this.ctx) === "background";
@@ -55,10 +58,7 @@ class Location extends Plugin {
55
58
  * with `NeedsUIError` off screen. A slow reader receives the newest.
56
59
  */
57
60
  watchPosition(): JsonStream<Position> {
58
- return valueStream<Position>(async (next, error) => {
59
- await this.#authorize();
60
- return this.#platform().watchPositions(this.ctx, next, error);
61
- }, "dropOldest");
61
+ return this.stream("watchPosition", this.#authorize().then(() => null), "dropOldest");
62
62
  }
63
63
 
64
64
  /** Requires location services and in-use permission, asking for it where the user has not answered. */
@@ -69,19 +69,6 @@ class Location extends Plugin {
69
69
  if (inUse !== "granted") throw permissionDenied();
70
70
  }
71
71
 
72
- /** The platform's composition of its location SDK. */
73
- #platform(): typeof apple | typeof android {
74
- switch (this.ctx.platform) {
75
- case "ios":
76
- case "macos":
77
- return apple;
78
- case "android":
79
- return android;
80
- default:
81
- throw new DOMException("Location is not supported on this platform", "NotSupportedError");
82
- }
83
- }
84
-
85
72
  /** The app's in-use and background permission, and the precision it gets. Never asks. */
86
73
  permission(): Promise<LocationPermission> {
87
74
  return this.call("permission");
@@ -1,26 +1,6 @@
1
1
  {
2
2
  "schemaVersion": 2,
3
3
  "id": "location",
4
- "bindings": {
5
- "apple": {
6
- "sdk": "26.2",
7
- "modules": [
8
- "CoreLocation",
9
- "_LocationEssentials"
10
- ]
11
- },
12
- "android": {
13
- "sdk": 35,
14
- "modules": [
15
- "android.location",
16
- "android.content.Context",
17
- "android.os.CancellationSignal",
18
- "android.os.SystemClock",
19
- "java.util.concurrent.Executor",
20
- "java.util.function.Consumer"
21
- ]
22
- }
23
- },
24
4
  "settings": {
25
5
  "background": {
26
6
  "kind": "boolean",
@@ -62,6 +42,7 @@
62
42
  "sources": [
63
43
  "apple/LocationEvents.swift",
64
44
  "apple/LocationPlugin.swift",
45
+ "apple/LocationPositions.swift",
65
46
  "apple/LocationRegions.swift",
66
47
  "apple/LocationUpdates.swift"
67
48
  ]
@@ -71,6 +52,7 @@
71
52
  "sources": [
72
53
  "apple/LocationEvents.swift",
73
54
  "apple/LocationPlugin.swift",
55
+ "apple/LocationPositions.swift",
74
56
  "apple/LocationRegions.swift",
75
57
  "apple/LocationUpdates.swift"
76
58
  ]
@@ -80,6 +62,7 @@
80
62
  "sources": [
81
63
  "android/LocationEvents.kt",
82
64
  "android/LocationPlugin.kt",
65
+ "android/LocationPositions.kt",
83
66
  "android/LocationReceiver.kt",
84
67
  "android/LocationRegions.kt",
85
68
  "android/LocationService.kt",