@tokamakdev/plugin-location 0.1.0-beta.33

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 ADDED
@@ -0,0 +1,19 @@
1
+ # @tokamakdev/plugin-location
2
+
3
+ Location for Tokamak applications and the web.
4
+
5
+ ```ts
6
+ import { location } from "@tokamakdev/plugin-location";
7
+
8
+ const position = await location.getCurrentPosition();
9
+
10
+ const stop = location.watchPosition(
11
+ (next) => console.log(next.coords.latitude, next.coords.longitude),
12
+ console.error,
13
+ );
14
+ ```
15
+
16
+ The web implementation uses `navigator.geolocation`. Native Tokamak builds use
17
+ Core Location on Apple platforms, `LocationManager` on Android, and WebView2's
18
+ location implementation on Windows. Calling the API on a platform without
19
+ location support throws `NotSupportedError`.
@@ -0,0 +1,180 @@
1
+ package com.tokamak.plugins.location
2
+
3
+ import android.Manifest
4
+ import android.app.Activity
5
+ import android.content.Context
6
+ import android.content.pm.PackageManager
7
+ import android.location.Location
8
+ import android.location.LocationListener
9
+ import android.location.LocationManager
10
+ import android.os.Bundle
11
+ import android.os.CancellationSignal
12
+ import android.os.Looper
13
+ import com.tokamak.runtime.TokamakPlugin
14
+ import com.tokamak.runtime.TokamakPluginError
15
+ import com.tokamak.runtime.TokamakPluginReply
16
+
17
+ internal class TokamakLocationPlugin(
18
+ private val activity: Activity,
19
+ ) : TokamakPlugin {
20
+ override val id = "location"
21
+
22
+ private val manager =
23
+ activity.getSystemService(Context.LOCATION_SERVICE) as LocationManager
24
+ private val permissionRequests = mutableListOf<(Boolean) -> Unit>()
25
+
26
+ override fun call(method: String, arguments: Any?, reply: TokamakPluginReply) {
27
+ if (method != "getCurrentPosition") {
28
+ super.call(method, arguments, reply)
29
+ return
30
+ }
31
+ withPermission { granted ->
32
+ if (granted) currentPosition(reply) else reply(permissionDenied())
33
+ }
34
+ }
35
+
36
+ override fun subscribe(
37
+ method: String,
38
+ arguments: Any?,
39
+ reply: TokamakPluginReply,
40
+ ): () -> Unit {
41
+ if (method != "watchPosition") return super.subscribe(method, arguments, reply)
42
+ var cancelled = false
43
+ var stop = {}
44
+ withPermission { granted ->
45
+ if (!granted) {
46
+ reply(permissionDenied())
47
+ } else if (!cancelled) {
48
+ stop = watchPosition(reply)
49
+ }
50
+ }
51
+ return {
52
+ cancelled = true
53
+ stop()
54
+ }
55
+ }
56
+
57
+ override fun onRequestPermissionsResult(
58
+ requestCode: Int,
59
+ permissions: Array<out String>,
60
+ grantResults: IntArray,
61
+ ) {
62
+ if (requestCode != LOCATION_PERMISSION_REQUEST) return
63
+ val granted = grantResults.any { it == PackageManager.PERMISSION_GRANTED }
64
+ val requests = permissionRequests.toList()
65
+ permissionRequests.clear()
66
+ requests.forEach { it(granted) }
67
+ }
68
+
69
+ private fun withPermission(action: (Boolean) -> Unit) {
70
+ if (hasPermission()) {
71
+ action(true)
72
+ return
73
+ }
74
+ permissionRequests.add(action)
75
+ if (permissionRequests.size > 1) return
76
+ activity.requestPermissions(
77
+ arrayOf(
78
+ Manifest.permission.ACCESS_FINE_LOCATION,
79
+ Manifest.permission.ACCESS_COARSE_LOCATION,
80
+ ),
81
+ LOCATION_PERMISSION_REQUEST,
82
+ )
83
+ }
84
+
85
+ private fun hasPermission(): Boolean =
86
+ activity.checkSelfPermission(Manifest.permission.ACCESS_FINE_LOCATION) ==
87
+ PackageManager.PERMISSION_GRANTED ||
88
+ activity.checkSelfPermission(Manifest.permission.ACCESS_COARSE_LOCATION) ==
89
+ PackageManager.PERMISSION_GRANTED
90
+
91
+ private fun currentPosition(reply: TokamakPluginReply) {
92
+ val provider = runCatching(::provider).getOrElse {
93
+ reply(locationFailure(it, "Location is unavailable"))
94
+ return
95
+ }
96
+ if (provider == null) {
97
+ reply(unavailable("No location provider is available"))
98
+ return
99
+ }
100
+ runCatching {
101
+ manager.getCurrentLocation(
102
+ provider,
103
+ CancellationSignal(),
104
+ activity.mainExecutor,
105
+ ) { location ->
106
+ if (location == null) reply(unavailable("Location is unavailable"))
107
+ else reply(Result.success(position(location)))
108
+ }
109
+ }.onFailure { reply(locationFailure(it, "Location is unavailable")) }
110
+ }
111
+
112
+ private fun watchPosition(reply: TokamakPluginReply): () -> Unit {
113
+ val provider = runCatching(::provider).getOrElse {
114
+ reply(locationFailure(it, "Location is unavailable"))
115
+ return {}
116
+ }
117
+ if (provider == null) {
118
+ reply(unavailable("No location provider is available"))
119
+ return {}
120
+ }
121
+ val listener = object : LocationListener {
122
+ override fun onLocationChanged(location: Location) {
123
+ reply(Result.success(position(location)))
124
+ }
125
+
126
+ override fun onProviderDisabled(provider: String) {
127
+ reply(unavailable("Location provider is unavailable"))
128
+ }
129
+
130
+ override fun onStatusChanged(provider: String?, status: Int, extras: Bundle?) = Unit
131
+ }
132
+ val started = runCatching {
133
+ manager.requestLocationUpdates(provider, 1000, 0F, listener, Looper.getMainLooper())
134
+ }
135
+ if (started.isFailure) {
136
+ reply(locationFailure(started.exceptionOrNull(), "Location is unavailable"))
137
+ return {}
138
+ }
139
+ return { manager.removeUpdates(listener) }
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
+ private fun unavailable(message: String): Result<Any?> =
171
+ Result.failure(TokamakPluginError("NotReadableError", message))
172
+
173
+ private fun locationFailure(error: Throwable?, fallback: String): Result<Any?> =
174
+ if (error is SecurityException) permissionDenied()
175
+ else unavailable(error?.message ?: fallback)
176
+
177
+ private companion object {
178
+ const val LOCATION_PERMISSION_REQUEST = 0xA771
179
+ }
180
+ }
@@ -0,0 +1,174 @@
1
+ import CoreLocation
2
+ import Foundation
3
+
4
+ final class TokamakLocationPlugin: NSObject, TokamakPlugin,
5
+ CLLocationManagerDelegate
6
+ {
7
+ let id = "location"
8
+
9
+ private let manager = CLLocationManager()
10
+ private var current: [TokamakPluginReply] = []
11
+ private var watchers: [UUID: TokamakPluginReply] = [:]
12
+
13
+ override init() {
14
+ super.init()
15
+ manager.delegate = self
16
+ manager.desiredAccuracy = kCLLocationAccuracyBest
17
+ }
18
+
19
+ func call(
20
+ method: String,
21
+ arguments: Any,
22
+ reply: @escaping TokamakPluginReply
23
+ ) {
24
+ guard method == "getCurrentPosition" else {
25
+ reply(.failure(.notSupported("\(id).\(method) is not supported")))
26
+ return
27
+ }
28
+ current.append(reply)
29
+ start()
30
+ }
31
+
32
+ func subscribe(
33
+ method: String,
34
+ arguments: Any,
35
+ reply: @escaping TokamakPluginReply
36
+ ) -> (() -> Void) {
37
+ guard method == "watchPosition" else {
38
+ reply(.failure(.notSupported("\(id).\(method) is not supported")))
39
+ return {}
40
+ }
41
+ let subscription = UUID()
42
+ watchers[subscription] = reply
43
+ start()
44
+ return { [weak self] in
45
+ self?.watchers.removeValue(forKey: subscription)
46
+ self?.stopIfIdle()
47
+ }
48
+ }
49
+
50
+ func locationManagerDidChangeAuthorization(
51
+ _ manager: CLLocationManager
52
+ ) {
53
+ switch manager.authorizationStatus {
54
+ case .authorizedAlways, .authorizedWhenInUse:
55
+ manager.startUpdatingLocation()
56
+ case .denied, .restricted:
57
+ fail(
58
+ TokamakPluginError(
59
+ name: "NotAllowedError",
60
+ message: "Location permission was denied"
61
+ ))
62
+ case .notDetermined:
63
+ break
64
+ @unknown default:
65
+ fail(.notSupported("Location authorization is not supported"))
66
+ }
67
+ }
68
+
69
+ func locationManager(
70
+ _ manager: CLLocationManager,
71
+ didUpdateLocations locations: [CLLocation]
72
+ ) {
73
+ guard let location = locations.last else { return }
74
+ let result = Result<Any?, TokamakPluginError>.success(position(location))
75
+ let waiting = current
76
+ current.removeAll()
77
+ for reply in waiting {
78
+ reply(result)
79
+ }
80
+ for reply in watchers.values {
81
+ reply(result)
82
+ }
83
+ stopIfIdle()
84
+ }
85
+
86
+ func locationManager(
87
+ _ manager: CLLocationManager,
88
+ didFailWithError error: Error
89
+ ) {
90
+ if (error as NSError).code == CLError.Code.locationUnknown.rawValue {
91
+ return
92
+ }
93
+ fail(locationError(error))
94
+ }
95
+
96
+ private func start() {
97
+ guard CLLocationManager.locationServicesEnabled() else {
98
+ fail(unavailable("Location services are disabled"))
99
+ return
100
+ }
101
+ switch manager.authorizationStatus {
102
+ case .authorizedAlways, .authorizedWhenInUse:
103
+ manager.startUpdatingLocation()
104
+ case .notDetermined:
105
+ manager.requestWhenInUseAuthorization()
106
+ case .denied, .restricted:
107
+ fail(
108
+ TokamakPluginError(
109
+ name: "NotAllowedError",
110
+ message: "Location permission was denied"
111
+ ))
112
+ @unknown default:
113
+ fail(.notSupported("Location authorization is not supported"))
114
+ }
115
+ }
116
+
117
+ private func fail(_ error: TokamakPluginError) {
118
+ let result = Result<Any?, TokamakPluginError>.failure(error)
119
+ let replies = current + Array(watchers.values)
120
+ current.removeAll()
121
+ for reply in replies {
122
+ reply(result)
123
+ }
124
+ if watchers.isEmpty {
125
+ manager.stopUpdatingLocation()
126
+ }
127
+ }
128
+
129
+ private func locationError(_ error: Error) -> TokamakPluginError {
130
+ if (error as NSError).code == CLError.Code.denied.rawValue {
131
+ return TokamakPluginError(
132
+ name: "NotAllowedError",
133
+ message: "Location permission was denied"
134
+ )
135
+ }
136
+ return unavailable(error.localizedDescription)
137
+ }
138
+
139
+ private func unavailable(_ message: String) -> TokamakPluginError {
140
+ TokamakPluginError(name: "NotReadableError", message: message)
141
+ }
142
+
143
+ private func stopIfIdle() {
144
+ if current.isEmpty && watchers.isEmpty {
145
+ manager.stopUpdatingLocation()
146
+ }
147
+ }
148
+
149
+ private func position(_ location: CLLocation) -> [String: Any] {
150
+ let coordinates = location.coordinate
151
+ return [
152
+ "coords": [
153
+ "latitude": coordinates.latitude,
154
+ "longitude": coordinates.longitude,
155
+ "accuracy": location.horizontalAccuracy,
156
+ "altitude": nullable(
157
+ location.verticalAccuracy >= 0,
158
+ location.altitude
159
+ ),
160
+ "altitudeAccuracy": nullable(
161
+ location.verticalAccuracy >= 0,
162
+ location.verticalAccuracy
163
+ ),
164
+ "heading": nullable(location.course >= 0, location.course),
165
+ "speed": nullable(location.speed >= 0, location.speed),
166
+ ],
167
+ "timestamp": location.timestamp.timeIntervalSince1970 * 1000,
168
+ ]
169
+ }
170
+
171
+ private func nullable(_ available: Bool, _ value: Double) -> Any {
172
+ available ? value : NSNull()
173
+ }
174
+ }
package/package.json ADDED
@@ -0,0 +1,27 @@
1
+ {
2
+ "name": "@tokamakdev/plugin-location",
3
+ "version": "0.1.0-beta.33",
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
+ "dependencies": {
14
+ "@tokamakdev/plugin": "0.1.0-beta.33"
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,44 @@
1
+ import { FrontendPlugin } 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
+ export type PositionCallback = (position: Position) => void;
20
+ export type PositionErrorCallback = (error: DOMException) => void;
21
+
22
+ class Location extends FrontendPlugin {
23
+ constructor() {
24
+ super("location");
25
+ }
26
+
27
+ getCurrentPosition(): Promise<Position> {
28
+ if (this.hasNativeTransport) {
29
+ return this.call("getCurrentPosition");
30
+ }
31
+ return web.getCurrentPosition();
32
+ }
33
+
34
+ watchPosition(next: PositionCallback, error: PositionErrorCallback): () => void {
35
+ if (this.hasNativeTransport) {
36
+ return this.subscribe("watchPosition", (position) => {
37
+ next(position as Position);
38
+ }, error);
39
+ }
40
+ return web.watchPosition(next, error);
41
+ }
42
+ }
43
+
44
+ export const location = new Location();
@@ -0,0 +1,39 @@
1
+ {
2
+ "schemaVersion": 1,
3
+ "id": "location",
4
+ "kind": "frontend",
5
+ "platforms": {
6
+ "macos": {
7
+ "class": "TokamakLocationPlugin",
8
+ "sources": ["apple/LocationPlugin.swift"],
9
+ "frameworks": ["CoreLocation"],
10
+ "plist": {
11
+ "NSLocationUsageDescription": "This app uses your location when you request location features."
12
+ }
13
+ },
14
+ "ios": {
15
+ "class": "TokamakLocationPlugin",
16
+ "sources": ["apple/LocationPlugin.swift"],
17
+ "frameworks": ["CoreLocation"],
18
+ "plist": {
19
+ "NSLocationWhenInUseUsageDescription": "This app uses your location when you request location features."
20
+ }
21
+ },
22
+ "ios-simulator": {
23
+ "class": "TokamakLocationPlugin",
24
+ "sources": ["apple/LocationPlugin.swift"],
25
+ "frameworks": ["CoreLocation"],
26
+ "plist": {
27
+ "NSLocationWhenInUseUsageDescription": "This app uses your location when you request location features."
28
+ }
29
+ },
30
+ "android": {
31
+ "class": "com.tokamak.plugins.location.TokamakLocationPlugin",
32
+ "sources": ["android/LocationPlugin.kt"],
33
+ "permissions": [
34
+ "android.permission.ACCESS_COARSE_LOCATION",
35
+ "android.permission.ACCESS_FINE_LOCATION"
36
+ ]
37
+ }
38
+ }
39
+ }
package/web/index.ts ADDED
@@ -0,0 +1,65 @@
1
+ import type { Position, PositionCallback, PositionErrorCallback } from "../src/index.js";
2
+
3
+ export function getCurrentPosition(): Promise<Position> {
4
+ return new Promise((resolve, reject) => {
5
+ const browserLocation = browserLocationApi();
6
+ browserLocation.getCurrentPosition(
7
+ (position) => {
8
+ resolve(copyPosition(position));
9
+ },
10
+ (error) => {
11
+ reject(positionError(error));
12
+ },
13
+ );
14
+ });
15
+ }
16
+
17
+ export function watchPosition(
18
+ next: PositionCallback,
19
+ error: PositionErrorCallback,
20
+ ): () => void {
21
+ const browserLocation = browserLocationApi();
22
+ const id = browserLocation.watchPosition(
23
+ (position) => {
24
+ next(copyPosition(position));
25
+ },
26
+ (failure) => {
27
+ error(positionError(failure));
28
+ },
29
+ );
30
+ return () => {
31
+ browserLocation.clearWatch(id);
32
+ };
33
+ }
34
+
35
+ function browserLocationApi(): Geolocation {
36
+ const root: { navigator?: { geolocation?: Geolocation } } = globalThis;
37
+ const browserLocation = root.navigator?.geolocation;
38
+ if (browserLocation) return browserLocation;
39
+ throw new DOMException("Location is unavailable", "NotSupportedError");
40
+ }
41
+
42
+ function copyPosition(position: GeolocationPosition): Position {
43
+ const { coords } = position;
44
+ return {
45
+ coords: {
46
+ latitude: coords.latitude,
47
+ longitude: coords.longitude,
48
+ accuracy: coords.accuracy,
49
+ altitude: coords.altitude,
50
+ altitudeAccuracy: coords.altitudeAccuracy,
51
+ heading: coords.heading,
52
+ speed: coords.speed,
53
+ },
54
+ timestamp: position.timestamp,
55
+ };
56
+ }
57
+
58
+ function positionError(error: GeolocationPositionError): DOMException {
59
+ const name = {
60
+ [error.PERMISSION_DENIED]: "NotAllowedError",
61
+ [error.POSITION_UNAVAILABLE]: "NotReadableError",
62
+ [error.TIMEOUT]: "TimeoutError",
63
+ }[error.code];
64
+ return new DOMException(error.message, name ?? "UnknownError");
65
+ }