@tokamakdev/plugin-location 0.1.0-beta.100
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 +43 -0
- package/android/AndroidManifest.xml +4 -0
- package/android/LocationPlugin.kt +191 -0
- package/apple/LocationPlugin.swift +236 -0
- package/apple/ios/Info.plist +8 -0
- package/apple/macos/Info.plist +8 -0
- package/package.json +27 -0
- package/src/index.ts +56 -0
- package/tokamak-plugin.json +21 -0
- package/web/index.ts +71 -0
package/README.md
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# @tokamakdev/plugin-location
|
|
2
|
+
|
|
3
|
+
Location for Tokamak applications and the web.
|
|
4
|
+
|
|
5
|
+
Add it to your project's dependencies:
|
|
6
|
+
|
|
7
|
+
```sh
|
|
8
|
+
npm install @tokamakdev/plugin-location
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
```ts
|
|
12
|
+
import { location } from "@tokamakdev/plugin-location";
|
|
13
|
+
|
|
14
|
+
const position = await location.getCurrentPosition();
|
|
15
|
+
|
|
16
|
+
const stop = location.watchPosition(
|
|
17
|
+
(next) => console.log(next.coords.latitude, next.coords.longitude),
|
|
18
|
+
console.error,
|
|
19
|
+
);
|
|
20
|
+
```
|
|
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
|
+
|
|
28
|
+
## Off screen
|
|
29
|
+
|
|
30
|
+
The plugin asks for location while the app is in use only. While the app is
|
|
31
|
+
off screen on iOS or Android, as when Worker code runs in the background, a
|
|
32
|
+
call that would ask the user for permission rejects with `NeedsUIError`. With
|
|
33
|
+
permission granted, the platforms stop locating an app that is off screen
|
|
34
|
+
without background location permission, which the plugin does not request, so
|
|
35
|
+
`getCurrentPosition` rejects with `NotAllowedError` there. iOS still locates
|
|
36
|
+
the app for a few seconds after it leaves the screen.
|
|
37
|
+
|
|
38
|
+
## Platforms
|
|
39
|
+
|
|
40
|
+
The web implementation uses `navigator.geolocation`. Native Tokamak builds use
|
|
41
|
+
Core Location on Apple platforms, `LocationManager` on Android, and WebView2's
|
|
42
|
+
location implementation on Windows. Calling the API on a platform without
|
|
43
|
+
location support throws `NotSupportedError`.
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
package com.tokamak.plugins.location
|
|
2
|
+
|
|
3
|
+
import android.Manifest
|
|
4
|
+
import android.content.Context
|
|
5
|
+
import android.content.pm.PackageManager
|
|
6
|
+
import android.location.Location
|
|
7
|
+
import android.location.LocationListener
|
|
8
|
+
import android.location.LocationManager
|
|
9
|
+
import android.os.Bundle
|
|
10
|
+
import android.os.CancellationSignal
|
|
11
|
+
import android.os.Handler
|
|
12
|
+
import android.os.Looper
|
|
13
|
+
import android.os.SystemClock
|
|
14
|
+
import com.tokamak.runtime.TokamakHost
|
|
15
|
+
import com.tokamak.runtime.TokamakPlugin
|
|
16
|
+
import com.tokamak.runtime.TokamakPluginError
|
|
17
|
+
import com.tokamak.runtime.TokamakPluginReply
|
|
18
|
+
import org.json.JSONObject
|
|
19
|
+
|
|
20
|
+
class TokamakLocationPlugin(
|
|
21
|
+
private val host: TokamakHost,
|
|
22
|
+
) : TokamakPlugin {
|
|
23
|
+
override val id = "location"
|
|
24
|
+
|
|
25
|
+
private val context = host.context
|
|
26
|
+
private val manager =
|
|
27
|
+
context.getSystemService(Context.LOCATION_SERVICE) as LocationManager
|
|
28
|
+
private val handler = Handler(Looper.getMainLooper())
|
|
29
|
+
|
|
30
|
+
override fun call(method: String, arguments: Any?, reply: TokamakPluginReply) {
|
|
31
|
+
if (method != "getCurrentPosition") {
|
|
32
|
+
super.call(method, arguments, reply)
|
|
33
|
+
return
|
|
34
|
+
}
|
|
35
|
+
val options = arguments as? JSONObject
|
|
36
|
+
withPermission { granted ->
|
|
37
|
+
if (granted) currentPosition(options, reply) else reply(permissionDenied())
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
override fun subscribe(
|
|
42
|
+
method: String,
|
|
43
|
+
arguments: Any?,
|
|
44
|
+
reply: TokamakPluginReply,
|
|
45
|
+
): () -> Unit {
|
|
46
|
+
if (method != "watchPosition") return super.subscribe(method, arguments, reply)
|
|
47
|
+
var cancelled = false
|
|
48
|
+
var stop = {}
|
|
49
|
+
withPermission { granted ->
|
|
50
|
+
if (!granted) {
|
|
51
|
+
reply(permissionDenied())
|
|
52
|
+
} else if (!cancelled) {
|
|
53
|
+
stop = watchPosition(reply)
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
return {
|
|
57
|
+
cancelled = true
|
|
58
|
+
stop()
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Passes [action] whether the app may read fine or coarse location, asking when it may not.
|
|
64
|
+
* Throws `NeedsUIError` when asking would show UI while the app is in the background.
|
|
65
|
+
*/
|
|
66
|
+
private fun withPermission(action: (Boolean) -> Unit) {
|
|
67
|
+
if (PERMISSIONS.any { context.checkSelfPermission(it) == PackageManager.PERMISSION_GRANTED }) {
|
|
68
|
+
action(true)
|
|
69
|
+
return
|
|
70
|
+
}
|
|
71
|
+
host.requestPermissions(PERMISSIONS) { granted -> action(granted.containsValue(true)) }
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Replies with a fix no older than the `maximumAge` option, taking a new one when there is
|
|
76
|
+
* none, or fails after the `timeout` option.
|
|
77
|
+
*/
|
|
78
|
+
private fun currentPosition(options: JSONObject?, reply: TokamakPluginReply) {
|
|
79
|
+
val provider = availableProvider(reply) ?: return
|
|
80
|
+
val maximumAge = options?.optLong("maximumAge", 0) ?: 0
|
|
81
|
+
val recent = runCatching { manager.getLastKnownLocation(provider) }.getOrNull()
|
|
82
|
+
if (recent != null && ageMillis(recent) <= maximumAge) {
|
|
83
|
+
reply(Result.success(position(recent)))
|
|
84
|
+
return
|
|
85
|
+
}
|
|
86
|
+
val cancellation = CancellationSignal()
|
|
87
|
+
val timeout = Runnable {
|
|
88
|
+
cancellation.cancel()
|
|
89
|
+
reply(Result.failure(TokamakPluginError("TimeoutError", "Location was not found in time")))
|
|
90
|
+
}
|
|
91
|
+
handler.postDelayed(timeout, options?.optLong("timeout", UNLIMITED) ?: UNLIMITED)
|
|
92
|
+
runCatching {
|
|
93
|
+
manager.getCurrentLocation(
|
|
94
|
+
provider,
|
|
95
|
+
cancellation,
|
|
96
|
+
context.mainExecutor,
|
|
97
|
+
) { location ->
|
|
98
|
+
handler.removeCallbacks(timeout)
|
|
99
|
+
reply(if (location == null) missingFix() else Result.success(position(location)))
|
|
100
|
+
}
|
|
101
|
+
}.onFailure {
|
|
102
|
+
handler.removeCallbacks(timeout)
|
|
103
|
+
reply(locationFailure(it))
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
private fun ageMillis(location: Location): Long =
|
|
108
|
+
(SystemClock.elapsedRealtimeNanos() - location.elapsedRealtimeNanos) / 1_000_000
|
|
109
|
+
|
|
110
|
+
private fun watchPosition(reply: TokamakPluginReply): () -> Unit {
|
|
111
|
+
val provider = availableProvider(reply) ?: return {}
|
|
112
|
+
val listener = object : LocationListener {
|
|
113
|
+
override fun onLocationChanged(location: Location) {
|
|
114
|
+
reply(Result.success(position(location)))
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
override fun onProviderDisabled(provider: String) {
|
|
118
|
+
reply(unavailable("Location provider is unavailable"))
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
override fun onStatusChanged(provider: String?, status: Int, extras: Bundle?) = Unit
|
|
122
|
+
}
|
|
123
|
+
runCatching {
|
|
124
|
+
manager.requestLocationUpdates(provider, 1000, 0F, listener, Looper.getMainLooper())
|
|
125
|
+
}.onFailure {
|
|
126
|
+
reply(locationFailure(it))
|
|
127
|
+
return {}
|
|
128
|
+
}
|
|
129
|
+
return { manager.removeUpdates(listener) }
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** The provider to use, or null once [reply] has the reason there is none. */
|
|
133
|
+
private fun availableProvider(reply: TokamakPluginReply): String? {
|
|
134
|
+
val provider = runCatching(::provider).getOrElse {
|
|
135
|
+
reply(locationFailure(it))
|
|
136
|
+
return null
|
|
137
|
+
}
|
|
138
|
+
if (provider == null) reply(unavailable("No location provider is available"))
|
|
139
|
+
return provider
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
private fun provider(): String? =
|
|
143
|
+
listOf(
|
|
144
|
+
LocationManager.GPS_PROVIDER,
|
|
145
|
+
LocationManager.NETWORK_PROVIDER,
|
|
146
|
+
LocationManager.FUSED_PROVIDER,
|
|
147
|
+
).firstOrNull(manager::isProviderEnabled)
|
|
148
|
+
?: manager.getProviders(true).firstOrNull()
|
|
149
|
+
|
|
150
|
+
private fun position(location: Location): Map<String, Any?> =
|
|
151
|
+
mapOf(
|
|
152
|
+
"coords" to
|
|
153
|
+
mapOf(
|
|
154
|
+
"latitude" to location.latitude,
|
|
155
|
+
"longitude" to location.longitude,
|
|
156
|
+
"accuracy" to location.accuracy.toDouble(),
|
|
157
|
+
"altitude" to location.altitude.takeIf { location.hasAltitude() },
|
|
158
|
+
"altitudeAccuracy" to
|
|
159
|
+
location.verticalAccuracyMeters.toDouble()
|
|
160
|
+
.takeIf { location.hasVerticalAccuracy() },
|
|
161
|
+
"heading" to location.bearing.toDouble().takeIf { location.hasBearing() },
|
|
162
|
+
"speed" to location.speed.toDouble().takeIf { location.hasSpeed() },
|
|
163
|
+
),
|
|
164
|
+
"timestamp" to location.time,
|
|
165
|
+
)
|
|
166
|
+
|
|
167
|
+
private fun permissionDenied(): Result<Any?> =
|
|
168
|
+
Result.failure(TokamakPluginError("NotAllowedError", "Location permission was denied"))
|
|
169
|
+
|
|
170
|
+
/** Why no fix came: Android gives none in the background without background location permission. */
|
|
171
|
+
private fun missingFix(): Result<Any?> =
|
|
172
|
+
if (host.isInForeground || context.checkSelfPermission(BACKGROUND) == PackageManager.PERMISSION_GRANTED) {
|
|
173
|
+
unavailable("Location is unavailable")
|
|
174
|
+
} else {
|
|
175
|
+
Result.failure(TokamakPluginError("NotAllowedError", "Location permission does not cover the background"))
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
private fun unavailable(message: String): Result<Any?> =
|
|
179
|
+
Result.failure(TokamakPluginError("NotReadableError", message))
|
|
180
|
+
|
|
181
|
+
private fun locationFailure(error: Throwable): Result<Any?> =
|
|
182
|
+
if (error is SecurityException) permissionDenied()
|
|
183
|
+
else unavailable(error.message ?: "Location is unavailable")
|
|
184
|
+
|
|
185
|
+
private companion object {
|
|
186
|
+
/** The `timeout` that never expires, as the page sends it. */
|
|
187
|
+
const val UNLIMITED = 0xffffffffL
|
|
188
|
+
val PERMISSIONS = setOf(Manifest.permission.ACCESS_FINE_LOCATION, Manifest.permission.ACCESS_COARSE_LOCATION)
|
|
189
|
+
const val BACKGROUND = Manifest.permission.ACCESS_BACKGROUND_LOCATION
|
|
190
|
+
}
|
|
191
|
+
}
|
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
import CoreLocation
|
|
2
|
+
import Foundation
|
|
3
|
+
|
|
4
|
+
final class TokamakLocationPlugin: NSObject, TokamakPlugin,
|
|
5
|
+
CLLocationManagerDelegate
|
|
6
|
+
{
|
|
7
|
+
let id = "location"
|
|
8
|
+
|
|
9
|
+
private static let permissionDenied = TokamakPluginError(
|
|
10
|
+
name: "NotAllowedError",
|
|
11
|
+
message: "Location permission was denied"
|
|
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
|
+
}
|
|
25
|
+
|
|
26
|
+
private let host: TokamakHost
|
|
27
|
+
/// Updates watchers.
|
|
28
|
+
private let updates = CLLocationManager()
|
|
29
|
+
/// Takes single fixes for current position requests, apart from watching.
|
|
30
|
+
private let fixes = CLLocationManager()
|
|
31
|
+
private var current: [UUID: Request] = [:]
|
|
32
|
+
private var watchers: [UUID: TokamakPluginReply] = [:]
|
|
33
|
+
|
|
34
|
+
init(host: TokamakHost) {
|
|
35
|
+
self.host = host
|
|
36
|
+
super.init()
|
|
37
|
+
for manager in [updates, fixes] {
|
|
38
|
+
manager.delegate = self
|
|
39
|
+
manager.desiredAccuracy = kCLLocationAccuracyBest
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
func call(
|
|
44
|
+
method: String,
|
|
45
|
+
arguments: Any,
|
|
46
|
+
reply: @escaping TokamakPluginReply
|
|
47
|
+
) {
|
|
48
|
+
guard method == "getCurrentPosition" else {
|
|
49
|
+
reply(.failure(.notSupported("\(id).\(method) is not supported")))
|
|
50
|
+
return
|
|
51
|
+
}
|
|
52
|
+
let options = arguments as? [String: Any] ?? [:]
|
|
53
|
+
current[UUID()] = Request(
|
|
54
|
+
reply: reply,
|
|
55
|
+
maximumAge: (options["maximumAge"] as? Double ?? 0) / 1000,
|
|
56
|
+
timeout: (options["timeout"] as? Double ?? Double(UInt32.max)) / 1000
|
|
57
|
+
)
|
|
58
|
+
start()
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
func subscribe(
|
|
62
|
+
method: String,
|
|
63
|
+
arguments: Any,
|
|
64
|
+
reply: @escaping TokamakPluginReply
|
|
65
|
+
) throws(TokamakPluginError) -> (() -> Void) {
|
|
66
|
+
guard method == "watchPosition" else {
|
|
67
|
+
throw .notSupported("\(id).\(method) is not supported")
|
|
68
|
+
}
|
|
69
|
+
let subscription = UUID()
|
|
70
|
+
watchers[subscription] = reply
|
|
71
|
+
start()
|
|
72
|
+
return { [weak self] in
|
|
73
|
+
self?.watchers.removeValue(forKey: subscription)
|
|
74
|
+
if self?.watchers.isEmpty == true {
|
|
75
|
+
self?.updates.stopUpdatingLocation()
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/// Also called when each manager is created, before anything asks for a
|
|
81
|
+
/// position. Both managers report the app's one authorization.
|
|
82
|
+
func locationManagerDidChangeAuthorization(
|
|
83
|
+
_ manager: CLLocationManager
|
|
84
|
+
) {
|
|
85
|
+
guard manager === updates, !current.isEmpty || !watchers.isEmpty else { return }
|
|
86
|
+
serve(manager.authorizationStatus)
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
func locationManager(
|
|
90
|
+
_ manager: CLLocationManager,
|
|
91
|
+
didUpdateLocations locations: [CLLocation]
|
|
92
|
+
) {
|
|
93
|
+
guard let location = locations.last else { return }
|
|
94
|
+
deliver(.success(position(location)), from: manager)
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/// A fix that is not yet available is taken again; updates continue.
|
|
98
|
+
func locationManager(
|
|
99
|
+
_ manager: CLLocationManager,
|
|
100
|
+
didFailWithError error: Error
|
|
101
|
+
) {
|
|
102
|
+
guard (error as NSError).code == CLError.Code.locationUnknown.rawValue else {
|
|
103
|
+
deliver(.failure(locationError(error)), from: manager)
|
|
104
|
+
return
|
|
105
|
+
}
|
|
106
|
+
if manager === fixes && !current.isEmpty {
|
|
107
|
+
fixes.requestLocation()
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/// Checks location services off the main thread, which the check can block.
|
|
112
|
+
private func start() {
|
|
113
|
+
DispatchQueue.global(qos: .userInitiated).async {
|
|
114
|
+
let enabled = CLLocationManager.locationServicesEnabled()
|
|
115
|
+
DispatchQueue.main.async { self.start(servicesEnabled: enabled) }
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
private func start(servicesEnabled: Bool) {
|
|
120
|
+
guard servicesEnabled else {
|
|
121
|
+
fail(unavailable("Location services are disabled"))
|
|
122
|
+
return
|
|
123
|
+
}
|
|
124
|
+
let status = updates.authorizationStatus
|
|
125
|
+
guard status == .notDetermined else {
|
|
126
|
+
serve(status)
|
|
127
|
+
return
|
|
128
|
+
}
|
|
129
|
+
do throws(TokamakPluginError) {
|
|
130
|
+
try host.requireUI()
|
|
131
|
+
updates.requestWhenInUseAuthorization()
|
|
132
|
+
} catch {
|
|
133
|
+
fail(error)
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/// Locates for the waiting requests and watchers when `status` allows it,
|
|
138
|
+
/// and fails them when it forbids it.
|
|
139
|
+
private func serve(_ status: CLAuthorizationStatus) {
|
|
140
|
+
switch status {
|
|
141
|
+
case .authorizedAlways, .authorizedWhenInUse:
|
|
142
|
+
if !watchers.isEmpty {
|
|
143
|
+
updates.startUpdatingLocation()
|
|
144
|
+
}
|
|
145
|
+
locate()
|
|
146
|
+
case .denied, .restricted:
|
|
147
|
+
fail(Self.permissionDenied)
|
|
148
|
+
case .notDetermined:
|
|
149
|
+
break
|
|
150
|
+
@unknown default:
|
|
151
|
+
fail(.notSupported("Location authorization is not supported"))
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/// Answers each new request with a fix no older than its `maximumAge`, and
|
|
156
|
+
/// takes a fix for the rest, each failing after its `timeout`.
|
|
157
|
+
private func locate() {
|
|
158
|
+
for (key, request) in current where request.timer == nil {
|
|
159
|
+
if let fix = fixes.location, -fix.timestamp.timeIntervalSinceNow <= request.maximumAge {
|
|
160
|
+
finish(key, .success(position(fix)))
|
|
161
|
+
continue
|
|
162
|
+
}
|
|
163
|
+
let timer = DispatchWorkItem { [weak self] in self?.finish(key, .failure(Self.timedOut)) }
|
|
164
|
+
current[key]?.timer = timer
|
|
165
|
+
DispatchQueue.main.asyncAfter(deadline: .now() + request.timeout, execute: timer)
|
|
166
|
+
}
|
|
167
|
+
if !current.isEmpty {
|
|
168
|
+
fixes.requestLocation()
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/// Answers the waiting requests with a fix, or the watchers with an update.
|
|
173
|
+
private func deliver(_ result: Result<Any?, TokamakPluginError>, from manager: CLLocationManager)
|
|
174
|
+
{
|
|
175
|
+
guard manager === fixes else {
|
|
176
|
+
for reply in watchers.values {
|
|
177
|
+
reply(result)
|
|
178
|
+
}
|
|
179
|
+
return
|
|
180
|
+
}
|
|
181
|
+
for key in current.keys {
|
|
182
|
+
finish(key, result)
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
private func fail(_ error: TokamakPluginError) {
|
|
187
|
+
deliver(.failure(error), from: fixes)
|
|
188
|
+
deliver(.failure(error), from: updates)
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
private func finish(_ key: UUID, _ result: Result<Any?, TokamakPluginError>) {
|
|
192
|
+
guard let request = current.removeValue(forKey: key) else { return }
|
|
193
|
+
request.timer?.cancel()
|
|
194
|
+
request.reply(result)
|
|
195
|
+
if current.isEmpty {
|
|
196
|
+
fixes.stopUpdatingLocation()
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
private func locationError(_ error: Error) -> TokamakPluginError {
|
|
201
|
+
if (error as NSError).code == CLError.Code.denied.rawValue {
|
|
202
|
+
return Self.permissionDenied
|
|
203
|
+
}
|
|
204
|
+
return unavailable(error.localizedDescription)
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
private func unavailable(_ message: String) -> TokamakPluginError {
|
|
208
|
+
TokamakPluginError(name: "NotReadableError", message: message)
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
private func position(_ location: CLLocation) -> [String: Any] {
|
|
212
|
+
let coordinates = location.coordinate
|
|
213
|
+
return [
|
|
214
|
+
"coords": [
|
|
215
|
+
"latitude": coordinates.latitude,
|
|
216
|
+
"longitude": coordinates.longitude,
|
|
217
|
+
"accuracy": location.horizontalAccuracy,
|
|
218
|
+
"altitude": nullable(
|
|
219
|
+
location.verticalAccuracy >= 0,
|
|
220
|
+
location.altitude
|
|
221
|
+
),
|
|
222
|
+
"altitudeAccuracy": nullable(
|
|
223
|
+
location.verticalAccuracy >= 0,
|
|
224
|
+
location.verticalAccuracy
|
|
225
|
+
),
|
|
226
|
+
"heading": nullable(location.course >= 0, location.course),
|
|
227
|
+
"speed": nullable(location.speed >= 0, location.speed),
|
|
228
|
+
],
|
|
229
|
+
"timestamp": location.timestamp.timeIntervalSince1970 * 1000,
|
|
230
|
+
]
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
private func nullable(_ available: Bool, _ value: Double) -> Any {
|
|
234
|
+
available ? value : NSNull()
|
|
235
|
+
}
|
|
236
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
<?xml version="1.0" encoding="UTF-8"?>
|
|
2
|
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
3
|
+
<plist version="1.0">
|
|
4
|
+
<dict>
|
|
5
|
+
<key>NSLocationWhenInUseUsageDescription</key>
|
|
6
|
+
<string>This app uses your location when you request location features.</string>
|
|
7
|
+
</dict>
|
|
8
|
+
</plist>
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
<?xml version="1.0" encoding="UTF-8"?>
|
|
2
|
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
3
|
+
<plist version="1.0">
|
|
4
|
+
<dict>
|
|
5
|
+
<key>NSLocationUsageDescription</key>
|
|
6
|
+
<string>This app uses your location when you request location features.</string>
|
|
7
|
+
</dict>
|
|
8
|
+
</plist>
|
package/package.json
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@tokamakdev/plugin-location",
|
|
3
|
+
"version": "0.1.0-beta.100",
|
|
4
|
+
"repository": {
|
|
5
|
+
"type": "git",
|
|
6
|
+
"url": "git+https://github.com/mantty/tokamak.git",
|
|
7
|
+
"directory": "plugins/location"
|
|
8
|
+
},
|
|
9
|
+
"type": "module",
|
|
10
|
+
"exports": {
|
|
11
|
+
".": "./src/index.ts"
|
|
12
|
+
},
|
|
13
|
+
"peerDependencies": {
|
|
14
|
+
"@tokamakdev/plugin": "0.1.0-beta.100"
|
|
15
|
+
},
|
|
16
|
+
"files": [
|
|
17
|
+
"android",
|
|
18
|
+
"apple",
|
|
19
|
+
"src",
|
|
20
|
+
"web",
|
|
21
|
+
"tokamak-plugin.json",
|
|
22
|
+
"README.md"
|
|
23
|
+
],
|
|
24
|
+
"publishConfig": {
|
|
25
|
+
"access": "public"
|
|
26
|
+
}
|
|
27
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { Plugin } from "@tokamakdev/plugin";
|
|
2
|
+
import * as web from "../web/index.js";
|
|
3
|
+
|
|
4
|
+
export interface Coordinates {
|
|
5
|
+
readonly latitude: number;
|
|
6
|
+
readonly longitude: number;
|
|
7
|
+
readonly accuracy: number;
|
|
8
|
+
readonly altitude: number | null;
|
|
9
|
+
readonly altitudeAccuracy: number | null;
|
|
10
|
+
readonly heading: number | null;
|
|
11
|
+
readonly speed: number | null;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface Position {
|
|
15
|
+
readonly coords: Coordinates;
|
|
16
|
+
readonly timestamp: number;
|
|
17
|
+
}
|
|
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
|
+
|
|
27
|
+
export type PositionCallback = (position: Position) => void;
|
|
28
|
+
export type PositionErrorCallback = (error: DOMException) => void;
|
|
29
|
+
|
|
30
|
+
class Location extends Plugin {
|
|
31
|
+
constructor() {
|
|
32
|
+
super("location");
|
|
33
|
+
}
|
|
34
|
+
|
|
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
|
+
});
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
watchPosition(next: PositionCallback, error: PositionErrorCallback): () => void {
|
|
44
|
+
if (!this.hasNativeTransport) return web.watchPosition(next, error);
|
|
45
|
+
return this.listen("watchPosition", next, error);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
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
|
+
|
|
56
|
+
export const location = new Location();
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
{
|
|
2
|
+
"schemaVersion": 1,
|
|
3
|
+
"id": "location",
|
|
4
|
+
"platforms": {
|
|
5
|
+
"macos": {
|
|
6
|
+
"class": "TokamakLocationPlugin",
|
|
7
|
+
"sources": ["apple/LocationPlugin.swift"],
|
|
8
|
+
"plist": "apple/macos/Info.plist"
|
|
9
|
+
},
|
|
10
|
+
"ios": {
|
|
11
|
+
"class": "TokamakLocationPlugin",
|
|
12
|
+
"sources": ["apple/LocationPlugin.swift"],
|
|
13
|
+
"plist": "apple/ios/Info.plist"
|
|
14
|
+
},
|
|
15
|
+
"android": {
|
|
16
|
+
"class": "com.tokamak.plugins.location.TokamakLocationPlugin",
|
|
17
|
+
"sources": ["android/LocationPlugin.kt"],
|
|
18
|
+
"manifest": "android/AndroidManifest.xml"
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
}
|
package/web/index.ts
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
Position,
|
|
3
|
+
PositionCallback,
|
|
4
|
+
PositionErrorCallback,
|
|
5
|
+
PositionOptions,
|
|
6
|
+
} from "../src/index.js";
|
|
7
|
+
|
|
8
|
+
export function getCurrentPosition(options: PositionOptions): Promise<Position> {
|
|
9
|
+
return new Promise((resolve, reject) => {
|
|
10
|
+
const browserLocation = browserLocationApi();
|
|
11
|
+
browserLocation.getCurrentPosition(
|
|
12
|
+
(position) => {
|
|
13
|
+
resolve(copyPosition(position));
|
|
14
|
+
},
|
|
15
|
+
(error) => {
|
|
16
|
+
reject(positionError(error));
|
|
17
|
+
},
|
|
18
|
+
options,
|
|
19
|
+
);
|
|
20
|
+
});
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function watchPosition(
|
|
24
|
+
next: PositionCallback,
|
|
25
|
+
error: PositionErrorCallback,
|
|
26
|
+
): () => void {
|
|
27
|
+
const browserLocation = browserLocationApi();
|
|
28
|
+
const id = browserLocation.watchPosition(
|
|
29
|
+
(position) => {
|
|
30
|
+
next(copyPosition(position));
|
|
31
|
+
},
|
|
32
|
+
(failure) => {
|
|
33
|
+
error(positionError(failure));
|
|
34
|
+
},
|
|
35
|
+
);
|
|
36
|
+
return () => {
|
|
37
|
+
browserLocation.clearWatch(id);
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function browserLocationApi(): Geolocation {
|
|
42
|
+
const root: { navigator?: { geolocation?: Geolocation } } = globalThis;
|
|
43
|
+
const browserLocation = root.navigator?.geolocation;
|
|
44
|
+
if (browserLocation) return browserLocation;
|
|
45
|
+
throw new DOMException("Location is unavailable", "NotSupportedError");
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function copyPosition(position: GeolocationPosition): Position {
|
|
49
|
+
const { coords } = position;
|
|
50
|
+
return {
|
|
51
|
+
coords: {
|
|
52
|
+
latitude: coords.latitude,
|
|
53
|
+
longitude: coords.longitude,
|
|
54
|
+
accuracy: coords.accuracy,
|
|
55
|
+
altitude: coords.altitude,
|
|
56
|
+
altitudeAccuracy: coords.altitudeAccuracy,
|
|
57
|
+
heading: coords.heading,
|
|
58
|
+
speed: coords.speed,
|
|
59
|
+
},
|
|
60
|
+
timestamp: position.timestamp,
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function positionError(error: GeolocationPositionError): DOMException {
|
|
65
|
+
const name = {
|
|
66
|
+
[error.PERMISSION_DENIED]: "NotAllowedError",
|
|
67
|
+
[error.POSITION_UNAVAILABLE]: "NotReadableError",
|
|
68
|
+
[error.TIMEOUT]: "TimeoutError",
|
|
69
|
+
}[error.code];
|
|
70
|
+
return new DOMException(error.message, name ?? "UnknownError");
|
|
71
|
+
}
|