@tokamakdev/plugin-location 0.1.0-beta.101 → 0.1.0-beta.103
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 +198 -18
- package/android/LocationEvents.kt +82 -0
- package/android/LocationPlugin.kt +216 -34
- package/android/LocationReceiver.kt +27 -0
- package/android/LocationRegions.kt +73 -0
- package/android/LocationService.kt +64 -0
- package/android/LocationUpdates.kt +120 -0
- package/apple/LocationEvents.swift +149 -0
- package/apple/LocationPlugin.swift +347 -56
- package/apple/LocationRegions.swift +154 -0
- package/apple/LocationUpdates.swift +137 -0
- package/build/declarations.d.mts +19 -0
- package/build/declarations.mjs +105 -0
- package/build/index.mjs +17 -0
- package/events/index.ts +18 -0
- package/package.json +6 -3
- package/src/checks.ts +65 -0
- package/src/index.ts +122 -25
- package/src/options.ts +54 -0
- package/src/types.ts +163 -0
- package/tokamak-plugin.json +52 -6
- package/web/index.ts +15 -1
- package/android/AndroidManifest.xml +0 -4
- package/apple/ios/Info.plist +0 -8
- package/apple/macos/Info.plist +0 -8
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
package com.tokamak.plugins.location
|
|
2
|
+
|
|
3
|
+
import android.content.BroadcastReceiver
|
|
4
|
+
import android.content.Context
|
|
5
|
+
import android.content.Intent
|
|
6
|
+
import com.tokamak.runtime.tokamakPlugin
|
|
7
|
+
|
|
8
|
+
/** Receives updates and region transitions, which Android sends even after the app's process ended. */
|
|
9
|
+
class TokamakLocationReceiver : BroadcastReceiver() {
|
|
10
|
+
override fun onReceive(context: Context, intent: Intent) {
|
|
11
|
+
val plugin = context.tokamakPlugin("location") as? TokamakLocationPlugin ?: return
|
|
12
|
+
val pending = goAsync()
|
|
13
|
+
plugin.receive(intent) { pending.finish() }
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** Requests updates and regions again after a restart or an app update, which remove update requests. */
|
|
18
|
+
class TokamakLocationBootReceiver : BroadcastReceiver() {
|
|
19
|
+
override fun onReceive(context: Context, intent: Intent) {
|
|
20
|
+
if (intent.action !in ACTIONS) return
|
|
21
|
+
(context.tokamakPlugin("location") as? TokamakLocationPlugin)?.restore(updatesRemoved = true)
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
private companion object {
|
|
25
|
+
val ACTIONS = setOf(Intent.ACTION_BOOT_COMPLETED, Intent.ACTION_MY_PACKAGE_REPLACED)
|
|
26
|
+
}
|
|
27
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
package com.tokamak.plugins.location
|
|
2
|
+
|
|
3
|
+
import android.app.PendingIntent
|
|
4
|
+
import android.content.Context
|
|
5
|
+
import android.content.Intent
|
|
6
|
+
import android.location.LocationManager
|
|
7
|
+
import org.json.JSONObject
|
|
8
|
+
|
|
9
|
+
/** The JSON of each monitored region, as `addRegion` received it, by id. */
|
|
10
|
+
private const val PREFERENCES = "tokamak.location.regions"
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Circular regions, as proximity alerts that `LocationManager` sends to [TokamakLocationReceiver]
|
|
14
|
+
* through a `PendingIntent` per region, starting the app's process when it has ended. The regions
|
|
15
|
+
* persist until [remove], and [restore] adds them again where Android removed them. Use it on the
|
|
16
|
+
* main thread.
|
|
17
|
+
*/
|
|
18
|
+
internal class LocationRegions(private val context: Context) {
|
|
19
|
+
private val manager = context.getSystemService(LocationManager::class.java)
|
|
20
|
+
private val preferences = context.getSharedPreferences(PREFERENCES, Context.MODE_PRIVATE)
|
|
21
|
+
|
|
22
|
+
fun all(): List<JSONObject> = preferences.all.values.map { JSONObject(it as String) }
|
|
23
|
+
|
|
24
|
+
fun get(id: String): JSONObject? = preferences.getString(id, null)?.let(::JSONObject)
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Monitors [region], replacing one with the same id. It starts in an unknown state, so a device
|
|
28
|
+
* already inside reports an entry. Throws `SecurityException` without precise location.
|
|
29
|
+
*/
|
|
30
|
+
fun add(region: JSONObject) {
|
|
31
|
+
val id = region.getString("id")
|
|
32
|
+
val alert = checkNotNull(pendingIntent(id, PendingIntent.FLAG_UPDATE_CURRENT))
|
|
33
|
+
manager.removeProximityAlert(alert)
|
|
34
|
+
manager.addProximityAlert(
|
|
35
|
+
region.getDouble("latitude"),
|
|
36
|
+
region.getDouble("longitude"),
|
|
37
|
+
region.getDouble("radius").toFloat(),
|
|
38
|
+
-1,
|
|
39
|
+
alert,
|
|
40
|
+
)
|
|
41
|
+
preferences.edit().putString(id, region.toString()).apply()
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
fun remove(id: String) {
|
|
45
|
+
preferences.edit().remove(id).apply()
|
|
46
|
+
pendingIntent(id, PendingIntent.FLAG_NO_CREATE)?.let {
|
|
47
|
+
manager.removeProximityAlert(it)
|
|
48
|
+
it.cancel()
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Adds the regions whose `PendingIntent` no longer exists again, as after a restart or Android
|
|
54
|
+
* 15's force stop. Adding one resets its state. Throws `SecurityException` without precise
|
|
55
|
+
* location.
|
|
56
|
+
*/
|
|
57
|
+
fun restore() {
|
|
58
|
+
all().filter { pendingIntent(it.getString("id"), PendingIntent.FLAG_NO_CREATE) == null }.forEach(::add)
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** The region [id]'s `PendingIntent`, mutable so `LocationManager` adds whether the device entered. */
|
|
62
|
+
private fun pendingIntent(id: String, flags: Int): PendingIntent? =
|
|
63
|
+
PendingIntent.getBroadcast(
|
|
64
|
+
context,
|
|
65
|
+
0,
|
|
66
|
+
Intent(context, TokamakLocationReceiver::class.java).setAction(REGION_ACTION).setIdentifier(id),
|
|
67
|
+
flags or PendingIntent.FLAG_MUTABLE,
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
companion object {
|
|
71
|
+
const val REGION_ACTION = "com.tokamak.plugins.location.REGION"
|
|
72
|
+
}
|
|
73
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
package com.tokamak.plugins.location
|
|
2
|
+
|
|
3
|
+
import android.app.Notification
|
|
4
|
+
import android.app.NotificationChannel
|
|
5
|
+
import android.app.NotificationManager
|
|
6
|
+
import android.app.PendingIntent
|
|
7
|
+
import android.app.Service
|
|
8
|
+
import android.content.Intent
|
|
9
|
+
import android.content.pm.ServiceInfo
|
|
10
|
+
import android.os.IBinder
|
|
11
|
+
import org.json.JSONObject
|
|
12
|
+
|
|
13
|
+
private const val CHANNEL = "tokamak.location"
|
|
14
|
+
private const val NOTIFICATION_ID = 1
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Runs while updates have a `foregroundService`. Android neither throttles location for an app with
|
|
18
|
+
* a location foreground service nor withholds it off screen. The service requests no location
|
|
19
|
+
* itself, and Android restarts it after ending it for memory.
|
|
20
|
+
*/
|
|
21
|
+
class TokamakLocationService : Service() {
|
|
22
|
+
override fun onBind(intent: Intent?): IBinder? = null
|
|
23
|
+
|
|
24
|
+
/** Shows the notification the start [intent] carries, or on a restart, the running updates'. */
|
|
25
|
+
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
|
26
|
+
val content =
|
|
27
|
+
intent?.getStringExtra(NOTIFICATION_EXTRA)?.let(::JSONObject)
|
|
28
|
+
?: LocationUpdates(this).options?.optJSONObject("foregroundService")
|
|
29
|
+
if (content == null) {
|
|
30
|
+
stopSelf()
|
|
31
|
+
return START_NOT_STICKY
|
|
32
|
+
}
|
|
33
|
+
startForeground(NOTIFICATION_ID, notification(content), ServiceInfo.FOREGROUND_SERVICE_TYPE_LOCATION)
|
|
34
|
+
return START_STICKY
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** The notification [content] describes, in the location channel it names, opening the app. */
|
|
38
|
+
private fun notification(content: JSONObject): Notification {
|
|
39
|
+
val channel = NotificationChannel(CHANNEL, content.getString("channel"), NotificationManager.IMPORTANCE_LOW)
|
|
40
|
+
getSystemService(NotificationManager::class.java).createNotificationChannel(channel)
|
|
41
|
+
val open =
|
|
42
|
+
packageManager.getLaunchIntentForPackage(packageName)?.let {
|
|
43
|
+
PendingIntent.getActivity(this, 0, it, PendingIntent.FLAG_IMMUTABLE)
|
|
44
|
+
}
|
|
45
|
+
return Notification.Builder(this, CHANNEL)
|
|
46
|
+
.setSmallIcon(icon())
|
|
47
|
+
.setContentTitle(content.getString("title"))
|
|
48
|
+
.setContentText(content.getString("body"))
|
|
49
|
+
.setContentIntent(open)
|
|
50
|
+
.setOngoing(true)
|
|
51
|
+
.build()
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** The app's monochrome `drawable/tokamak_notification`, or its launcher icon. */
|
|
55
|
+
private fun icon(): Int =
|
|
56
|
+
resources.getIdentifier("tokamak_notification", "drawable", packageName).takeIf { it != 0 }
|
|
57
|
+
?: applicationInfo.icon.takeIf { it != 0 }
|
|
58
|
+
?: android.R.drawable.sym_def_app_icon
|
|
59
|
+
|
|
60
|
+
companion object {
|
|
61
|
+
/** The start intent's notification, as JSON. */
|
|
62
|
+
const val NOTIFICATION_EXTRA = "com.tokamak.plugins.location.NOTIFICATION"
|
|
63
|
+
}
|
|
64
|
+
}
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
package com.tokamak.plugins.location
|
|
2
|
+
|
|
3
|
+
import android.app.ActivityManager
|
|
4
|
+
import android.app.ApplicationExitInfo
|
|
5
|
+
import android.app.PendingIntent
|
|
6
|
+
import android.content.Context
|
|
7
|
+
import android.content.Intent
|
|
8
|
+
import android.location.LocationManager
|
|
9
|
+
import android.location.LocationRequest
|
|
10
|
+
import org.json.JSONObject
|
|
11
|
+
|
|
12
|
+
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
|
+
private const val DEFAULT_INTERVAL_MILLIS = 10_000L
|
|
18
|
+
|
|
19
|
+
private val QUALITIES =
|
|
20
|
+
mapOf(
|
|
21
|
+
"high" to LocationRequest.QUALITY_HIGH_ACCURACY,
|
|
22
|
+
"balanced" to LocationRequest.QUALITY_BALANCED_POWER_ACCURACY,
|
|
23
|
+
"low" to LocationRequest.QUALITY_LOW_POWER,
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
/**
|
|
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.
|
|
31
|
+
*/
|
|
32
|
+
internal class LocationUpdates(private val context: Context) {
|
|
33
|
+
private val manager = context.getSystemService(LocationManager::class.java)
|
|
34
|
+
private val preferences = context.getSharedPreferences(PREFERENCES, Context.MODE_PRIVATE)
|
|
35
|
+
|
|
36
|
+
/** The options of the running updates, or null. */
|
|
37
|
+
val options: JSONObject?
|
|
38
|
+
get() = preferences.getString(UPDATES, null)?.let(::JSONObject)
|
|
39
|
+
|
|
40
|
+
/**
|
|
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
|
+
*/
|
|
44
|
+
fun start(options: JSONObject) {
|
|
45
|
+
val notification = options.optJSONObject("foregroundService")
|
|
46
|
+
if (notification == null) stopService() else startService(notification)
|
|
47
|
+
request(options)
|
|
48
|
+
preferences.edit().putString(UPDATES, options.toString()).apply()
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
fun stop() {
|
|
52
|
+
preferences.edit().remove(UPDATES).apply()
|
|
53
|
+
stopService()
|
|
54
|
+
pendingIntent(PendingIntent.FLAG_NO_CREATE)?.let {
|
|
55
|
+
manager.removeUpdates(it)
|
|
56
|
+
it.cancel()
|
|
57
|
+
}
|
|
58
|
+
}
|
|
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
|
+
/**
|
|
77
|
+
* Requests the running updates again when Android [removed] the request, when their
|
|
78
|
+
* `PendingIntent` no longer exists, or after a force stop, which removes the request on Android
|
|
79
|
+
* 14. Throws `SecurityException` when the app no longer has permission.
|
|
80
|
+
*/
|
|
81
|
+
fun restore(removed: Boolean) {
|
|
82
|
+
val options = options ?: return
|
|
83
|
+
if (removed || forceStopped() || pendingIntent(PendingIntent.FLAG_NO_CREATE) == null) request(options)
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Whether the app's process last ended by the user's force stop. */
|
|
87
|
+
private fun forceStopped(): Boolean =
|
|
88
|
+
context.getSystemService(ActivityManager::class.java)
|
|
89
|
+
.getHistoricalProcessExitReasons(null, 0, 1)
|
|
90
|
+
.firstOrNull()
|
|
91
|
+
?.reason == ApplicationExitInfo.REASON_USER_REQUESTED
|
|
92
|
+
|
|
93
|
+
private fun request(options: JSONObject) {
|
|
94
|
+
val accuracy = options.optString("accuracy", "high")
|
|
95
|
+
val request =
|
|
96
|
+
LocationRequest.Builder(options.optLong("interval", DEFAULT_INTERVAL_MILLIS))
|
|
97
|
+
.setQuality(QUALITIES[accuracy] ?: LocationRequest.QUALITY_HIGH_ACCURACY)
|
|
98
|
+
.setMinUpdateDistanceMeters(options.optDouble("distance", 0.0).toFloat())
|
|
99
|
+
.build()
|
|
100
|
+
val updates = checkNotNull(pendingIntent(PendingIntent.FLAG_UPDATE_CURRENT))
|
|
101
|
+
manager.requestLocationUpdates(provider(accuracy), request, updates)
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** The fused provider when the device has one, and otherwise GPS for high accuracy and the network for less. */
|
|
105
|
+
private fun provider(accuracy: String): String =
|
|
106
|
+
when {
|
|
107
|
+
manager.hasProvider(LocationManager.FUSED_PROVIDER) -> LocationManager.FUSED_PROVIDER
|
|
108
|
+
accuracy == "high" -> LocationManager.GPS_PROVIDER
|
|
109
|
+
else -> LocationManager.NETWORK_PROVIDER
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** The updates' `PendingIntent`, mutable so `LocationManager` adds the locations. */
|
|
113
|
+
private fun pendingIntent(flags: Int): PendingIntent? =
|
|
114
|
+
PendingIntent.getBroadcast(
|
|
115
|
+
context,
|
|
116
|
+
0,
|
|
117
|
+
Intent(context, TokamakLocationReceiver::class.java).setAction(UPDATES_ACTION),
|
|
118
|
+
flags or PendingIntent.FLAG_MUTABLE,
|
|
119
|
+
)
|
|
120
|
+
}
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
import CoreLocation
|
|
2
|
+
import Foundation
|
|
3
|
+
|
|
4
|
+
/// How long the Worker's listeners have for an event, within the few seconds
|
|
5
|
+
/// the system gives an app it launches for one.
|
|
6
|
+
private let eventDeadline: TimeInterval = 8
|
|
7
|
+
|
|
8
|
+
/// The listener methods, which receive the Worker's events.
|
|
9
|
+
private let listenerMethods: Set = [
|
|
10
|
+
"onLocationUpdate", "onRegionTransition", "onVisit", "onLocationError",
|
|
11
|
+
]
|
|
12
|
+
|
|
13
|
+
/// Delivers the plugin's events to the Worker's listeners and the page's. Use
|
|
14
|
+
/// it on the main thread.
|
|
15
|
+
final class TokamakLocationEvents {
|
|
16
|
+
private let host: TokamakHost
|
|
17
|
+
private var listeners: [String: [UUID: TokamakPluginReply]] = [:]
|
|
18
|
+
/// Positions that arrived while the Worker handled the previous update.
|
|
19
|
+
private var positions: [[String: Any]] = []
|
|
20
|
+
private var stationary: Bool?
|
|
21
|
+
private var updating = false
|
|
22
|
+
/// The messages of the problems last reported, by region id, or nil for
|
|
23
|
+
/// updates.
|
|
24
|
+
private var problems: [String?: Set<String>] = [:]
|
|
25
|
+
|
|
26
|
+
init(host: TokamakHost) {
|
|
27
|
+
self.host = host
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/// Adds a page's or Worker's listener of `method`, returning its removal.
|
|
31
|
+
func listen(_ method: String, _ reply: @escaping TokamakPluginReply) -> (() -> Void)? {
|
|
32
|
+
guard listenerMethods.contains(method) else { return nil }
|
|
33
|
+
let key = UUID()
|
|
34
|
+
listeners[method, default: [:]][key] = reply
|
|
35
|
+
return { [weak self] in
|
|
36
|
+
self?.listeners[method]?.removeValue(forKey: key)
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/// Delivers `location`, with Apple's `stationary` flag when the system
|
|
41
|
+
/// reports it. Positions that arrive while the Worker handles an update
|
|
42
|
+
/// reach it together as the next.
|
|
43
|
+
func update(_ location: CLLocation?, stationary: Bool? = nil) {
|
|
44
|
+
positions += location.map { [Self.position($0)] } ?? []
|
|
45
|
+
if let stationary {
|
|
46
|
+
self.stationary = stationary
|
|
47
|
+
}
|
|
48
|
+
if !updating {
|
|
49
|
+
sendUpdate()
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
private func sendUpdate() {
|
|
54
|
+
guard !positions.isEmpty || stationary == true else {
|
|
55
|
+
updating = false
|
|
56
|
+
return
|
|
57
|
+
}
|
|
58
|
+
var event: [String: Any] = ["positions": positions]
|
|
59
|
+
event["stationary"] = stationary
|
|
60
|
+
positions = []
|
|
61
|
+
stationary = nil
|
|
62
|
+
updating = true
|
|
63
|
+
send("onLocationUpdate", "update", event) { self.sendUpdate() }
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
func transition(_ id: String, entered: Bool, at date: Date) {
|
|
67
|
+
let event: [String: Any] = [
|
|
68
|
+
"id": id, "transition": entered ? "enter" : "exit", "timestamp": Self.milliseconds(date),
|
|
69
|
+
]
|
|
70
|
+
send("onRegionTransition", "region", event)
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
func visit(_ visit: CLVisit) {
|
|
74
|
+
let event: [String: Any] = [
|
|
75
|
+
"latitude": visit.coordinate.latitude,
|
|
76
|
+
"longitude": visit.coordinate.longitude,
|
|
77
|
+
"accuracy": visit.horizontalAccuracy,
|
|
78
|
+
"arrival": visit.arrivalDate == .distantPast
|
|
79
|
+
? NSNull() : Self.milliseconds(visit.arrivalDate),
|
|
80
|
+
"departure": visit.departureDate == .distantFuture
|
|
81
|
+
? NSNull() : Self.milliseconds(visit.departureDate),
|
|
82
|
+
]
|
|
83
|
+
send("onVisit", "visit", event)
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/// Reports each of `problems` that updates, or the region `id`, did not
|
|
87
|
+
/// report last time.
|
|
88
|
+
func report(_ problems: [TokamakPluginError], region id: String? = nil) {
|
|
89
|
+
let previous = self.problems[id] ?? []
|
|
90
|
+
for problem in problems where !previous.contains(problem.message) {
|
|
91
|
+
fail(problem, region: id)
|
|
92
|
+
}
|
|
93
|
+
self.problems[id] = Set(problems.map(\.message))
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/// Reports a background service's failure, which concerns the region `id`
|
|
97
|
+
/// when given.
|
|
98
|
+
func fail(_ error: TokamakPluginError, region id: String? = nil) {
|
|
99
|
+
var event: [String: Any] = ["name": error.name, "message": error.message]
|
|
100
|
+
event["id"] = id
|
|
101
|
+
send("onLocationError", "error", event)
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
private func send(
|
|
105
|
+
_ method: String, _ name: String, _ event: [String: Any], completion: @escaping () -> Void = {}
|
|
106
|
+
) {
|
|
107
|
+
for reply in (listeners[method] ?? [:]).values {
|
|
108
|
+
reply(.success(event))
|
|
109
|
+
}
|
|
110
|
+
host.emit(name, event: event, timeout: eventDeadline) { result in
|
|
111
|
+
if case .failure(let error) = result {
|
|
112
|
+
print("location \(name) failed: \(error)")
|
|
113
|
+
}
|
|
114
|
+
completion()
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/// `location` as the page's `Position`.
|
|
119
|
+
static func position(_ location: CLLocation) -> [String: Any] {
|
|
120
|
+
let coordinates = location.coordinate
|
|
121
|
+
return [
|
|
122
|
+
"coords": [
|
|
123
|
+
"latitude": coordinates.latitude,
|
|
124
|
+
"longitude": coordinates.longitude,
|
|
125
|
+
"accuracy": location.horizontalAccuracy,
|
|
126
|
+
"altitude": nullable(location.verticalAccuracy >= 0, location.altitude),
|
|
127
|
+
"altitudeAccuracy": nullable(location.verticalAccuracy >= 0, location.verticalAccuracy),
|
|
128
|
+
"heading": nullable(location.course >= 0, location.course),
|
|
129
|
+
"speed": nullable(location.speed >= 0, location.speed),
|
|
130
|
+
],
|
|
131
|
+
"timestamp": milliseconds(location.timestamp),
|
|
132
|
+
]
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
private static func nullable(_ available: Bool, _ value: Double) -> Any {
|
|
136
|
+
available ? value : NSNull()
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
private static func milliseconds(_ date: Date) -> Double {
|
|
140
|
+
date.timeIntervalSince1970 * 1000
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
extension TokamakPluginError {
|
|
145
|
+
/// Location could not be found, as `message` says.
|
|
146
|
+
static func locationUnavailable(_ message: String) -> Self {
|
|
147
|
+
Self(name: "NotReadableError", message: message)
|
|
148
|
+
}
|
|
149
|
+
}
|