@tokamakdev/plugin-location 0.1.0-beta.75 → 0.1.0-beta.77

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
@@ -19,6 +19,12 @@ const stop = location.watchPosition(
19
19
  );
20
20
  ```
21
21
 
22
+ `getCurrentPosition` takes the Geolocation API's `maximumAge` and `timeout`
23
+ options. It resolves with a recent position no older than `maximumAge`
24
+ milliseconds, 0 by default, or takes a new one, and rejects with
25
+ `TimeoutError` when none arrives within `timeout` milliseconds, unlimited by
26
+ default. The timeout starts once the user allows location.
27
+
22
28
  The web implementation uses `navigator.geolocation`. Native Tokamak builds use
23
29
  Core Location on Apple platforms, `LocationManager` on Android, and WebView2's
24
30
  location implementation on Windows. Calling the API on a platform without
@@ -8,11 +8,14 @@ import android.location.LocationListener
8
8
  import android.location.LocationManager
9
9
  import android.os.Bundle
10
10
  import android.os.CancellationSignal
11
+ import android.os.Handler
11
12
  import android.os.Looper
13
+ import android.os.SystemClock
12
14
  import com.tokamak.runtime.TokamakHost
13
15
  import com.tokamak.runtime.TokamakPlugin
14
16
  import com.tokamak.runtime.TokamakPluginError
15
17
  import com.tokamak.runtime.TokamakPluginReply
18
+ import org.json.JSONObject
16
19
 
17
20
  class TokamakLocationPlugin(
18
21
  private val host: TokamakHost,
@@ -22,14 +25,16 @@ class TokamakLocationPlugin(
22
25
  private val context = host.context
23
26
  private val manager =
24
27
  context.getSystemService(Context.LOCATION_SERVICE) as LocationManager
28
+ private val handler = Handler(Looper.getMainLooper())
25
29
 
26
30
  override fun call(method: String, arguments: Any?, reply: TokamakPluginReply) {
27
31
  if (method != "getCurrentPosition") {
28
32
  super.call(method, arguments, reply)
29
33
  return
30
34
  }
35
+ val options = arguments as? JSONObject
31
36
  withPermission { granted ->
32
- if (granted) currentPosition(reply) else reply(permissionDenied())
37
+ if (granted) currentPosition(options, reply) else reply(permissionDenied())
33
38
  }
34
39
  }
35
40
 
@@ -63,20 +68,43 @@ class TokamakLocationPlugin(
63
68
  host.requestPermissions(PERMISSIONS) { granted -> action(granted.containsValue(true)) }
64
69
  }
65
70
 
66
- private fun currentPosition(reply: TokamakPluginReply) {
71
+ /**
72
+ * Replies with a fix no older than the `maximumAge` option, taking a new one when there is
73
+ * none, or fails after the `timeout` option.
74
+ */
75
+ private fun currentPosition(options: JSONObject?, reply: TokamakPluginReply) {
67
76
  val provider = availableProvider(reply) ?: return
77
+ val maximumAge = options?.optLong("maximumAge", 0) ?: 0
78
+ val recent = runCatching { manager.getLastKnownLocation(provider) }.getOrNull()
79
+ if (recent != null && ageMillis(recent) <= maximumAge) {
80
+ reply(Result.success(position(recent)))
81
+ return
82
+ }
83
+ val cancellation = CancellationSignal()
84
+ val timeout = Runnable {
85
+ cancellation.cancel()
86
+ reply(Result.failure(TokamakPluginError("TimeoutError", "Location was not found in time")))
87
+ }
88
+ handler.postDelayed(timeout, options?.optLong("timeout", UNLIMITED) ?: UNLIMITED)
68
89
  runCatching {
69
90
  manager.getCurrentLocation(
70
91
  provider,
71
- CancellationSignal(),
92
+ cancellation,
72
93
  context.mainExecutor,
73
94
  ) { location ->
95
+ handler.removeCallbacks(timeout)
74
96
  if (location == null) reply(unavailable("Location is unavailable"))
75
97
  else reply(Result.success(position(location)))
76
98
  }
77
- }.onFailure { reply(locationFailure(it)) }
99
+ }.onFailure {
100
+ handler.removeCallbacks(timeout)
101
+ reply(locationFailure(it))
102
+ }
78
103
  }
79
104
 
105
+ private fun ageMillis(location: Location): Long =
106
+ (SystemClock.elapsedRealtimeNanos() - location.elapsedRealtimeNanos) / 1_000_000
107
+
80
108
  private fun watchPosition(reply: TokamakPluginReply): () -> Unit {
81
109
  val provider = availableProvider(reply) ?: return {}
82
110
  val listener = object : LocationListener {
@@ -145,6 +173,8 @@ class TokamakLocationPlugin(
145
173
  else unavailable(error.message ?: "Location is unavailable")
146
174
 
147
175
  private companion object {
176
+ /** The `timeout` that never expires, as the page sends it. */
177
+ const val UNLIMITED = 0xffffffffL
148
178
  val PERMISSIONS = setOf(Manifest.permission.ACCESS_FINE_LOCATION, Manifest.permission.ACCESS_COARSE_LOCATION)
149
179
  }
150
180
  }
@@ -10,15 +10,32 @@ final class TokamakLocationPlugin: NSObject, TokamakPlugin,
10
10
  name: "NotAllowedError",
11
11
  message: "Location permission was denied"
12
12
  )
13
+ private static let timedOut = TokamakPluginError(
14
+ name: "TimeoutError",
15
+ message: "Location was not found in time"
16
+ )
17
+
18
+ /// A current position request, timed from when locating starts.
19
+ private struct Request {
20
+ let reply: TokamakPluginReply
21
+ let maximumAge: TimeInterval
22
+ let timeout: TimeInterval
23
+ var timer: DispatchWorkItem?
24
+ }
13
25
 
14
- private let manager = CLLocationManager()
15
- private var current: [TokamakPluginReply] = []
26
+ /// Updates watchers.
27
+ private let updates = CLLocationManager()
28
+ /// Takes single fixes for current position requests, apart from watching.
29
+ private let fixes = CLLocationManager()
30
+ private var current: [UUID: Request] = [:]
16
31
  private var watchers: [UUID: TokamakPluginReply] = [:]
17
32
 
18
33
  init(host: TokamakHost) {
19
34
  super.init()
20
- manager.delegate = self
21
- manager.desiredAccuracy = kCLLocationAccuracyBest
35
+ for manager in [updates, fixes] {
36
+ manager.delegate = self
37
+ manager.desiredAccuracy = kCLLocationAccuracyBest
38
+ }
22
39
  }
23
40
 
24
41
  func call(
@@ -30,7 +47,12 @@ final class TokamakLocationPlugin: NSObject, TokamakPlugin,
30
47
  reply(.failure(.notSupported("\(id).\(method) is not supported")))
31
48
  return
32
49
  }
33
- current.append(reply)
50
+ let options = arguments as? [String: Any] ?? [:]
51
+ current[UUID()] = Request(
52
+ reply: reply,
53
+ maximumAge: (options["maximumAge"] as? Double ?? 0) / 1000,
54
+ timeout: (options["timeout"] as? Double ?? Double(UInt32.max)) / 1000
55
+ )
34
56
  start()
35
57
  }
36
58
 
@@ -48,14 +70,19 @@ final class TokamakLocationPlugin: NSObject, TokamakPlugin,
48
70
  start()
49
71
  return { [weak self] in
50
72
  self?.watchers.removeValue(forKey: subscription)
51
- self?.stopIfIdle()
73
+ if self?.watchers.isEmpty == true {
74
+ self?.updates.stopUpdatingLocation()
75
+ }
52
76
  }
53
77
  }
54
78
 
79
+ /// Also called when each manager is created, before anything asks for a
80
+ /// position. Both managers report the app's one authorization.
55
81
  func locationManagerDidChangeAuthorization(
56
82
  _ manager: CLLocationManager
57
83
  ) {
58
- startIfAuthorized(manager.authorizationStatus)
84
+ guard manager === updates, !current.isEmpty || !watchers.isEmpty else { return }
85
+ serve(manager.authorizationStatus)
59
86
  }
60
87
 
61
88
  func locationManager(
@@ -63,38 +90,53 @@ final class TokamakLocationPlugin: NSObject, TokamakPlugin,
63
90
  didUpdateLocations locations: [CLLocation]
64
91
  ) {
65
92
  guard let location = locations.last else { return }
66
- deliver(.success(position(location)))
93
+ deliver(.success(position(location)), from: manager)
67
94
  }
68
95
 
96
+ /// A fix that is not yet available is taken again; updates continue.
69
97
  func locationManager(
70
98
  _ manager: CLLocationManager,
71
99
  didFailWithError error: Error
72
100
  ) {
73
- if (error as NSError).code == CLError.Code.locationUnknown.rawValue {
101
+ guard (error as NSError).code == CLError.Code.locationUnknown.rawValue else {
102
+ deliver(.failure(locationError(error)), from: manager)
74
103
  return
75
104
  }
76
- fail(locationError(error))
105
+ if manager === fixes && !current.isEmpty {
106
+ fixes.requestLocation()
107
+ }
77
108
  }
78
109
 
110
+ /// Checks location services off the main thread, which the check can block.
79
111
  private func start() {
80
- guard CLLocationManager.locationServicesEnabled() else {
112
+ DispatchQueue.global(qos: .userInitiated).async {
113
+ let enabled = CLLocationManager.locationServicesEnabled()
114
+ DispatchQueue.main.async { self.start(servicesEnabled: enabled) }
115
+ }
116
+ }
117
+
118
+ private func start(servicesEnabled: Bool) {
119
+ guard servicesEnabled else {
81
120
  fail(unavailable("Location services are disabled"))
82
121
  return
83
122
  }
84
- let status = manager.authorizationStatus
123
+ let status = updates.authorizationStatus
85
124
  if status == .notDetermined {
86
- manager.requestWhenInUseAuthorization()
125
+ updates.requestWhenInUseAuthorization()
87
126
  } else {
88
- startIfAuthorized(status)
127
+ serve(status)
89
128
  }
90
129
  }
91
130
 
92
- /// Starts updates when `status` allows them and fails every request when it
93
- /// forbids them.
94
- private func startIfAuthorized(_ status: CLAuthorizationStatus) {
131
+ /// Locates for the waiting requests and watchers when `status` allows it,
132
+ /// and fails them when it forbids it.
133
+ private func serve(_ status: CLAuthorizationStatus) {
95
134
  switch status {
96
135
  case .authorizedAlways, .authorizedWhenInUse:
97
- manager.startUpdatingLocation()
136
+ if !watchers.isEmpty {
137
+ updates.startUpdatingLocation()
138
+ }
139
+ locate()
98
140
  case .denied, .restricted:
99
141
  fail(Self.permissionDenied)
100
142
  case .notDetermined:
@@ -104,19 +146,49 @@ final class TokamakLocationPlugin: NSObject, TokamakPlugin,
104
146
  }
105
147
  }
106
148
 
149
+ /// Answers each new request with a fix no older than its `maximumAge`, and
150
+ /// takes a fix for the rest, each failing after its `timeout`.
151
+ private func locate() {
152
+ for (key, request) in current where request.timer == nil {
153
+ if let fix = fixes.location, -fix.timestamp.timeIntervalSinceNow <= request.maximumAge {
154
+ finish(key, .success(position(fix)))
155
+ continue
156
+ }
157
+ let timer = DispatchWorkItem { [weak self] in self?.finish(key, .failure(Self.timedOut)) }
158
+ current[key]?.timer = timer
159
+ DispatchQueue.main.asyncAfter(deadline: .now() + request.timeout, execute: timer)
160
+ }
161
+ if !current.isEmpty {
162
+ fixes.requestLocation()
163
+ }
164
+ }
165
+
166
+ /// Answers the waiting requests with a fix, or the watchers with an update.
167
+ private func deliver(_ result: Result<Any?, TokamakPluginError>, from manager: CLLocationManager)
168
+ {
169
+ guard manager === fixes else {
170
+ for reply in watchers.values {
171
+ reply(result)
172
+ }
173
+ return
174
+ }
175
+ for key in current.keys {
176
+ finish(key, result)
177
+ }
178
+ }
179
+
107
180
  private func fail(_ error: TokamakPluginError) {
108
- deliver(.failure(error))
181
+ deliver(.failure(error), from: fixes)
182
+ deliver(.failure(error), from: updates)
109
183
  }
110
184
 
111
- /// Replies to the waiting requests and every watcher, then stops when none
112
- /// remain.
113
- private func deliver(_ result: Result<Any?, TokamakPluginError>) {
114
- let replies = current + Array(watchers.values)
115
- current.removeAll()
116
- for reply in replies {
117
- reply(result)
185
+ private func finish(_ key: UUID, _ result: Result<Any?, TokamakPluginError>) {
186
+ guard let request = current.removeValue(forKey: key) else { return }
187
+ request.timer?.cancel()
188
+ request.reply(result)
189
+ if current.isEmpty {
190
+ fixes.stopUpdatingLocation()
118
191
  }
119
- stopIfIdle()
120
192
  }
121
193
 
122
194
  private func locationError(_ error: Error) -> TokamakPluginError {
@@ -130,12 +202,6 @@ final class TokamakLocationPlugin: NSObject, TokamakPlugin,
130
202
  TokamakPluginError(name: "NotReadableError", message: message)
131
203
  }
132
204
 
133
- private func stopIfIdle() {
134
- if current.isEmpty && watchers.isEmpty {
135
- manager.stopUpdatingLocation()
136
- }
137
- }
138
-
139
205
  private func position(_ location: CLLocation) -> [String: Any] {
140
206
  let coordinates = location.coordinate
141
207
  return [
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tokamakdev/plugin-location",
3
- "version": "0.1.0-beta.75",
3
+ "version": "0.1.0-beta.77",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "git+https://github.com/mantty/tokamak.git",
@@ -11,7 +11,7 @@
11
11
  ".": "./src/index.ts"
12
12
  },
13
13
  "dependencies": {
14
- "@tokamakdev/plugin": "0.1.0-beta.75"
14
+ "@tokamakdev/plugin": "0.1.0-beta.77"
15
15
  },
16
16
  "files": [
17
17
  "android",
package/src/index.ts CHANGED
@@ -16,6 +16,14 @@ export interface Position {
16
16
  readonly timestamp: number;
17
17
  }
18
18
 
19
+ /** Options for `getCurrentPosition`, as the Geolocation API reads them. */
20
+ export interface PositionOptions {
21
+ /** The age in milliseconds of a recent position to accept instead of a new one; 0 by default. */
22
+ readonly maximumAge?: number;
23
+ /** Milliseconds to wait for a position before rejecting with `TimeoutError`; unlimited by default. */
24
+ readonly timeout?: number;
25
+ }
26
+
19
27
  export type PositionCallback = (position: Position) => void;
20
28
  export type PositionErrorCallback = (error: DOMException) => void;
21
29
 
@@ -24,8 +32,12 @@ class Location extends FrontendPlugin {
24
32
  super("location");
25
33
  }
26
34
 
27
- getCurrentPosition(): Promise<Position> {
28
- return this.hasNativeTransport ? this.call("getCurrentPosition") : web.getCurrentPosition();
35
+ getCurrentPosition(options: PositionOptions = {}): Promise<Position> {
36
+ if (!this.hasNativeTransport) return web.getCurrentPosition(options);
37
+ return this.call("getCurrentPosition", {
38
+ maximumAge: milliseconds(options.maximumAge ?? 0),
39
+ timeout: milliseconds(options.timeout ?? unlimited),
40
+ });
29
41
  }
30
42
 
31
43
  watchPosition(next: PositionCallback, error: PositionErrorCallback): () => void {
@@ -34,4 +46,11 @@ class Location extends FrontendPlugin {
34
46
  }
35
47
  }
36
48
 
49
+ const unlimited = 0xffffffff;
50
+
51
+ /** `value` clamped to whole milliseconds, as the Geolocation API reads its options. */
52
+ function milliseconds(value: number): number {
53
+ return Number.isNaN(value) ? 0 : Math.min(Math.max(Math.round(value), 0), unlimited);
54
+ }
55
+
37
56
  export const location = new Location();
package/web/index.ts CHANGED
@@ -1,6 +1,11 @@
1
- import type { Position, PositionCallback, PositionErrorCallback } from "../src/index.js";
1
+ import type {
2
+ Position,
3
+ PositionCallback,
4
+ PositionErrorCallback,
5
+ PositionOptions,
6
+ } from "../src/index.js";
2
7
 
3
- export function getCurrentPosition(): Promise<Position> {
8
+ export function getCurrentPosition(options: PositionOptions): Promise<Position> {
4
9
  return new Promise((resolve, reject) => {
5
10
  const browserLocation = browserLocationApi();
6
11
  browserLocation.getCurrentPosition(
@@ -10,6 +15,7 @@ export function getCurrentPosition(): Promise<Position> {
10
15
  (error) => {
11
16
  reject(positionError(error));
12
17
  },
18
+ options,
13
19
  );
14
20
  });
15
21
  }