@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.
@@ -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
+ }