@tokamakdev/plugin-location 0.1.0-beta.102 → 0.1.0-beta.104

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.
@@ -1,20 +1,29 @@
1
1
  package com.tokamak.plugins.location
2
2
 
3
3
  import android.Manifest
4
+ import android.app.ForegroundServiceStartNotAllowedException
5
+ import android.content.ComponentName
4
6
  import android.content.Context
7
+ import android.content.Intent
5
8
  import android.content.pm.PackageManager
6
9
  import android.location.Location
7
10
  import android.location.LocationListener
8
11
  import android.location.LocationManager
12
+ import android.net.Uri
9
13
  import android.os.Bundle
10
14
  import android.os.CancellationSignal
11
15
  import android.os.Handler
12
16
  import android.os.Looper
13
17
  import android.os.SystemClock
18
+ import android.provider.Settings
14
19
  import com.tokamak.runtime.TokamakHost
15
20
  import com.tokamak.runtime.TokamakPlugin
16
21
  import com.tokamak.runtime.TokamakPluginError
17
22
  import com.tokamak.runtime.TokamakPluginReply
23
+ import com.tokamak.runtime.optionalString
24
+ import com.tokamak.runtime.requireObject
25
+ import com.tokamak.runtime.requireString
26
+ import org.json.JSONArray
18
27
  import org.json.JSONObject
19
28
 
20
29
  class TokamakLocationPlugin(
@@ -27,14 +36,62 @@ class TokamakLocationPlugin(
27
36
  context.getSystemService(Context.LOCATION_SERVICE) as LocationManager
28
37
  private val handler = Handler(Looper.getMainLooper())
29
38
 
39
+ /** The permissions the app's manifest requests. */
40
+ private val declared: Set<String> by lazy {
41
+ context.packageManager
42
+ .getPackageInfo(context.packageName, PackageManager.GET_PERMISSIONS)
43
+ .requestedPermissions
44
+ .orEmpty()
45
+ .toSet()
46
+ }
47
+
48
+ /** Foreground location permissions, which the `precision` build setting declares. */
49
+ private val foreground: Set<String> by lazy { setOf(COARSE, FINE).filter(declared::contains).toSet() }
50
+
51
+ private val updates = LocationUpdates(context, LocationUpdates.UPDATES)
52
+ private val significantChanges = LocationUpdates(context, LocationUpdates.SIGNIFICANT_CHANGES)
53
+ private val regions = LocationRegions(context)
54
+ private val events = LocationEvents(host)
55
+
56
+ /** Whether the app's manifest declares the foreground service, as the `background-updates` build setting does. */
57
+ private val declaresService: Boolean by lazy {
58
+ runCatching { context.packageManager.getServiceInfo(ComponentName(context, TokamakLocationService::class.java), 0) }
59
+ .isSuccess
60
+ }
61
+
62
+ init {
63
+ restore(updatesRemoved = false)
64
+ }
65
+
66
+ /** Starts the updates' foreground service again once the app is on screen. */
67
+ override fun onIntent(intent: Intent) {
68
+ if (host.isInForeground) startService()
69
+ }
70
+
30
71
  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())
72
+ when (method) {
73
+ "setup" -> setup(requireObject(arguments), reply)
74
+ "getCurrentPosition" ->
75
+ withPermission { granted ->
76
+ if (granted) currentPosition(arguments as? JSONObject, reply) else reply(Result.failure(permissionDenied()))
77
+ }
78
+ "permission" -> reply(Result.success(permission()))
79
+ "requestPermission" -> requestPermission(requireObject(arguments), reply)
80
+ "servicesEnabled" -> reply(Result.success(manager.isLocationEnabled))
81
+ "openSettings" -> openSettings(reply)
82
+ "startUpdates" -> startUpdates(requireObject(arguments), reply)
83
+ "stopUpdates" -> {
84
+ stopUpdates()
85
+ reply(Result.success(null))
86
+ }
87
+ "getUpdates" -> reply(Result.success(updates.options))
88
+ "addRegion" -> addRegion(requireObject(arguments), reply)
89
+ "getRegions" -> reply(Result.success(JSONArray(regions.all())))
90
+ "removeRegion" -> {
91
+ regions.remove(requireObject(arguments).requireString("id"))
92
+ reply(Result.success(null))
93
+ }
94
+ else -> super.call(method, arguments, reply)
38
95
  }
39
96
  }
40
97
 
@@ -43,12 +100,13 @@ class TokamakLocationPlugin(
43
100
  arguments: Any?,
44
101
  reply: TokamakPluginReply,
45
102
  ): () -> Unit {
103
+ events.listen(method, reply)?.let { return it }
46
104
  if (method != "watchPosition") return super.subscribe(method, arguments, reply)
47
105
  var cancelled = false
48
106
  var stop = {}
49
107
  withPermission { granted ->
50
108
  if (!granted) {
51
- reply(permissionDenied())
109
+ reply(Result.failure(permissionDenied()))
52
110
  } else if (!cancelled) {
53
111
  stop = watchPosition(reply)
54
112
  }
@@ -64,11 +122,169 @@ class TokamakLocationPlugin(
64
122
  * Throws `NeedsUIError` when asking would show UI while the app is in the background.
65
123
  */
66
124
  private fun withPermission(action: (Boolean) -> Unit) {
67
- if (PERMISSIONS.any { context.checkSelfPermission(it) == PackageManager.PERMISSION_GRANTED }) {
68
- action(true)
69
- return
125
+ if (hasForegroundPermission()) action(true) else askForeground(foreground, action)
126
+ }
127
+
128
+ /**
129
+ * Asks for the foreground [permissions], then passes [action] whether the app has one, first
130
+ * starting what waited for permission.
131
+ */
132
+ private fun askForeground(permissions: Set<String>, action: (Boolean) -> Unit) {
133
+ host.requestPermissions(permissions) { granted ->
134
+ val allowed = granted.containsValue(true)
135
+ if (allowed) restore(updatesRemoved = false)
136
+ action(allowed)
70
137
  }
71
- host.requestPermissions(PERMISSIONS) { granted -> action(granted.containsValue(true)) }
138
+ }
139
+
140
+ private fun granted(permission: String): Boolean =
141
+ context.checkSelfPermission(permission) == PackageManager.PERMISSION_GRANTED
142
+
143
+ private fun hasForegroundPermission(): Boolean = granted(FINE) || granted(COARSE)
144
+
145
+ /** The app's in-use and background permission, and the precision it gets, or would get once allowed. */
146
+ private fun permission(): JSONObject {
147
+ val inUse = if (hasForegroundPermission()) "granted" else state(COARSE)
148
+ val background = if (BACKGROUND in declared) state(BACKGROUND) else "denied"
149
+ val precise = granted(FINE) || (!granted(COARSE) && FINE in declared)
150
+ return JSONObject()
151
+ .put("inUse", inUse)
152
+ .put("background", background)
153
+ .put("precision", if (precise) "precise" else "approximate")
154
+ }
155
+
156
+ private fun state(permission: String): String =
157
+ when {
158
+ granted(permission) -> "granted"
159
+ host.wouldPrompt(permission) -> "prompt"
160
+ else -> "denied"
161
+ }
162
+
163
+ /**
164
+ * Asks for foreground permission, then for background permission when [options] ask for it, as
165
+ * Android grants background permission only to an app with foreground permission.
166
+ */
167
+ private fun requestPermission(options: JSONObject, reply: TokamakPluginReply) {
168
+ val background = options.optBoolean("background")
169
+ if (background && BACKGROUND !in declared) {
170
+ throw TokamakPluginError.notSupported("Background location needs the location plugin's background build setting")
171
+ }
172
+ val asked = if (options.optionalString("precision") == "approximate") setOf(COARSE) else foreground
173
+ askForeground(asked) { allowed ->
174
+ if (!background || !allowed) return@askForeground reply(Result.success(permission()))
175
+ runCatching { host.requestPermissions(setOf(BACKGROUND)) { reply(Result.success(permission())) } }
176
+ .onFailure { reply(Result.failure(it)) }
177
+ }
178
+ }
179
+
180
+ private fun requirePermission() {
181
+ if (!hasForegroundPermission()) throw TokamakPluginError("NotAllowedError", "Location permission has not been granted")
182
+ }
183
+
184
+ /** Starts updates in the foreground service when [options] name one, which stops otherwise. On failure, none run. */
185
+ private fun startUpdates(options: JSONObject, reply: TokamakPluginReply) {
186
+ requirePermission()
187
+ val notification = options.optJSONObject("foregroundService")
188
+ if (notification != null) requireService()
189
+ runCatching {
190
+ if (notification == null) TokamakLocationService.stop(context) else TokamakLocationService.start(context, notification)
191
+ updates.start(options)
192
+ }.onSuccess { reply(Result.success(null)) }
193
+ .onFailure {
194
+ stopUpdates()
195
+ reply(Result.failure(locationError(it)))
196
+ }
197
+ }
198
+
199
+ private fun stopUpdates() {
200
+ updates.stop()
201
+ TokamakLocationService.stop(context)
202
+ }
203
+
204
+ private fun startService() {
205
+ updates.options?.optJSONObject("foregroundService")?.let { TokamakLocationService.start(context, it) }
206
+ }
207
+
208
+ /**
209
+ * Starts significant changes when [config] names them, and stops them otherwise. Without
210
+ * permission they start once the app has it.
211
+ */
212
+ private fun setup(config: JSONObject, reply: TokamakPluginReply) {
213
+ val options = JSONObject(SIGNIFICANT_CHANGE_OPTIONS)
214
+ runCatching {
215
+ when {
216
+ !config.optBoolean("significantChanges") -> significantChanges.stop()
217
+ hasForegroundPermission() -> significantChanges.start(options)
218
+ else -> significantChanges.keep(options)
219
+ }
220
+ }.onSuccess { reply(Result.success(null)) }
221
+ .onFailure { reply(Result.failure(locationError(it))) }
222
+ }
223
+
224
+ /**
225
+ * Throws unless the app declares the foreground service and may start it now: Android 14 and
226
+ * later allow a location foreground service off screen only with background permission.
227
+ */
228
+ private fun requireService() {
229
+ if (!declaresService) {
230
+ throw TokamakPluginError.notSupported("The foreground service needs the location plugin's background-updates build setting")
231
+ }
232
+ if (!host.isInForeground && !granted(BACKGROUND)) throw TokamakPluginError.needsUI()
233
+ }
234
+
235
+ /** Monitors a region, which needs precise location. */
236
+ private fun addRegion(region: JSONObject, reply: TokamakPluginReply) {
237
+ if (!granted(FINE)) throw TokamakPluginError("NotAllowedError", "Regions need precise location")
238
+ runCatching { regions.add(region) }
239
+ .onSuccess { reply(Result.success(null)) }
240
+ .onFailure { reply(Result.failure(locationError(it))) }
241
+ }
242
+
243
+ /**
244
+ * Restores updates, significant changes and regions where the app has permission, as Android
245
+ * pauses them without it, and starts the updates' foreground service on screen, or after a restart
246
+ * or an update with background permission. Reports a failure to `onLocationError`.
247
+ */
248
+ internal fun restore(updatesRemoved: Boolean) {
249
+ if (!hasForegroundPermission()) return
250
+ runCatching {
251
+ updates.restore(updatesRemoved)
252
+ significantChanges.restore(updatesRemoved)
253
+ if (host.isInForeground || (updatesRemoved && granted(BACKGROUND))) startService()
254
+ }.onFailure { events.fail(locationError(it)) }
255
+ runCatching { regions.restore() }.onFailure { events.fail(locationError(it)) }
256
+ }
257
+
258
+ /** Delivers the updates or region transition [intent] carries, then calls [finish]. Call it on the main thread. */
259
+ internal fun receive(intent: Intent, finish: () -> Unit) {
260
+ if (intent.action == LocationRegions.REGION_ACTION) return transition(intent, finish)
261
+ if (intent.hasExtra(LocationManager.KEY_PROVIDER_ENABLED)) {
262
+ if (intent.getBooleanExtra(LocationManager.KEY_PROVIDER_ENABLED, true)) return finish()
263
+ return events.fail(TokamakPluginError("NotReadableError", "The location provider was turned off"), finish)
264
+ }
265
+ val received = locations(intent).map(::position)
266
+ if (received.isEmpty()) return finish()
267
+ events.update(received, finish)
268
+ }
269
+
270
+ /** Delivers a region's entry or exit, when the region reports it. */
271
+ private fun transition(intent: Intent, finish: () -> Unit) {
272
+ val id = intent.identifier ?: return finish()
273
+ val entered = intent.getBooleanExtra(LocationManager.KEY_PROXIMITY_ENTERING, false)
274
+ val region = regions.get(id) ?: return finish()
275
+ if (!region.optBoolean(if (entered) "onEntry" else "onExit", true)) return finish()
276
+ events.transition(id, entered, finish)
277
+ }
278
+
279
+ @Suppress("DEPRECATION")
280
+ private fun locations(intent: Intent): List<Location> =
281
+ intent.getParcelableArrayExtra(LocationManager.KEY_LOCATIONS)?.filterIsInstance<Location>()
282
+ ?: listOfNotNull(intent.getParcelableExtra(LocationManager.KEY_LOCATION_CHANGED))
283
+
284
+ private fun openSettings(reply: TokamakPluginReply) {
285
+ val app = Uri.fromParts("package", context.packageName, null)
286
+ host.requireForegroundActivity().startActivity(Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS, app))
287
+ reply(Result.success(null))
72
288
  }
73
289
 
74
290
  /**
@@ -147,29 +363,31 @@ class TokamakLocationPlugin(
147
363
  ).firstOrNull(manager::isProviderEnabled)
148
364
  ?: manager.getProviders(true).firstOrNull()
149
365
 
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"))
366
+ private fun position(location: Location): JSONObject =
367
+ JSONObject()
368
+ .put(
369
+ "coords",
370
+ JSONObject()
371
+ .put("latitude", location.latitude)
372
+ .put("longitude", location.longitude)
373
+ .put("accuracy", location.accuracy.toDouble())
374
+ .put("altitude", nullable(location.hasAltitude(), location.altitude))
375
+ .put(
376
+ "altitudeAccuracy",
377
+ nullable(location.hasVerticalAccuracy(), location.verticalAccuracyMeters.toDouble()),
378
+ )
379
+ .put("heading", nullable(location.hasBearing(), location.bearing.toDouble()))
380
+ .put("speed", nullable(location.hasSpeed(), location.speed.toDouble())),
381
+ )
382
+ .put("timestamp", location.time)
383
+
384
+ private fun nullable(available: Boolean, value: Double): Any = if (available) value else JSONObject.NULL
385
+
386
+ private fun permissionDenied() = TokamakPluginError("NotAllowedError", "Location permission was denied")
169
387
 
170
388
  /** Why no fix came: Android gives none in the background without background location permission. */
171
389
  private fun missingFix(): Result<Any?> =
172
- if (host.isInForeground || context.checkSelfPermission(BACKGROUND) == PackageManager.PERMISSION_GRANTED) {
390
+ if (host.isInForeground || granted(BACKGROUND)) {
173
391
  unavailable("Location is unavailable")
174
392
  } else {
175
393
  Result.failure(TokamakPluginError("NotAllowedError", "Location permission does not cover the background"))
@@ -178,14 +396,24 @@ class TokamakLocationPlugin(
178
396
  private fun unavailable(message: String): Result<Any?> =
179
397
  Result.failure(TokamakPluginError("NotReadableError", message))
180
398
 
181
- private fun locationFailure(error: Throwable): Result<Any?> =
182
- if (error is SecurityException) permissionDenied()
183
- else unavailable(error.message ?: "Location is unavailable")
399
+ private fun locationFailure(error: Throwable): Result<Any?> = Result.failure(locationError(error))
400
+
401
+ private fun locationError(error: Throwable): TokamakPluginError =
402
+ when (error) {
403
+ is SecurityException -> permissionDenied()
404
+ // Off screen, Android starts a foreground service only in response to some events.
405
+ is ForegroundServiceStartNotAllowedException -> TokamakPluginError.needsUI()
406
+ else -> TokamakPluginError("NotReadableError", error.message ?: "Location is unavailable")
407
+ }
184
408
 
185
409
  private companion object {
186
410
  /** The `timeout` that never expires, as the page sends it. */
187
411
  const val UNLIMITED = 0xffffffffL
188
- val PERMISSIONS = setOf(Manifest.permission.ACCESS_FINE_LOCATION, Manifest.permission.ACCESS_COARSE_LOCATION)
412
+ const val FINE = Manifest.permission.ACCESS_FINE_LOCATION
413
+ const val COARSE = Manifest.permission.ACCESS_COARSE_LOCATION
189
414
  const val BACKGROUND = Manifest.permission.ACCESS_BACKGROUND_LOCATION
415
+
416
+ /** Apple's significant-change service's thresholds: 500 metres or more, at most every 5 minutes, without GPS. */
417
+ const val SIGNIFICANT_CHANGE_OPTIONS = """{"accuracy":"low","interval":300000,"distance":500}"""
190
418
  }
191
419
  }
@@ -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,75 @@
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.Context
9
+ import android.content.Intent
10
+ import android.content.pm.ServiceInfo
11
+ import android.os.IBinder
12
+ import org.json.JSONObject
13
+
14
+ private const val CHANNEL = "tokamak.location"
15
+ private const val NOTIFICATION_ID = 1
16
+
17
+ /**
18
+ * Runs while updates have a `foregroundService`. Android neither throttles location for an app with
19
+ * a location foreground service nor withholds it off screen. The service requests no location
20
+ * itself, and Android restarts it after ending it for memory.
21
+ */
22
+ class TokamakLocationService : Service() {
23
+ override fun onBind(intent: Intent?): IBinder? = null
24
+
25
+ /** Shows the notification the start [intent] carries, or on a restart, the running updates'. */
26
+ override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
27
+ val content =
28
+ intent?.getStringExtra(NOTIFICATION_EXTRA)?.let(::JSONObject)
29
+ ?: LocationUpdates(this, LocationUpdates.UPDATES).options?.optJSONObject("foregroundService")
30
+ if (content == null) {
31
+ stopSelf()
32
+ return START_NOT_STICKY
33
+ }
34
+ startForeground(NOTIFICATION_ID, notification(content), ServiceInfo.FOREGROUND_SERVICE_TYPE_LOCATION)
35
+ return START_STICKY
36
+ }
37
+
38
+ /** The notification [content] describes, in the location channel it names, opening the app. */
39
+ private fun notification(content: JSONObject): Notification {
40
+ val channel = NotificationChannel(CHANNEL, content.getString("channel"), NotificationManager.IMPORTANCE_LOW)
41
+ getSystemService(NotificationManager::class.java).createNotificationChannel(channel)
42
+ val open =
43
+ packageManager.getLaunchIntentForPackage(packageName)?.let {
44
+ PendingIntent.getActivity(this, 0, it, PendingIntent.FLAG_IMMUTABLE)
45
+ }
46
+ return Notification.Builder(this, CHANNEL)
47
+ .setSmallIcon(icon())
48
+ .setContentTitle(content.getString("title"))
49
+ .setContentText(content.getString("body"))
50
+ .setContentIntent(open)
51
+ .setOngoing(true)
52
+ .build()
53
+ }
54
+
55
+ /** The app's monochrome `drawable/tokamak_notification`, or its launcher icon. */
56
+ private fun icon(): Int =
57
+ resources.getIdentifier("tokamak_notification", "drawable", packageName).takeIf { it != 0 }
58
+ ?: applicationInfo.icon.takeIf { it != 0 }
59
+ ?: android.R.drawable.sym_def_app_icon
60
+
61
+ companion object {
62
+ /** The start intent's notification, as JSON. */
63
+ private const val NOTIFICATION_EXTRA = "com.tokamak.plugins.location.NOTIFICATION"
64
+
65
+ /** Starts the service showing [notification], or shows it in the running service. */
66
+ fun start(context: Context, notification: JSONObject) {
67
+ val start = Intent(context, TokamakLocationService::class.java).putExtra(NOTIFICATION_EXTRA, notification.toString())
68
+ context.startForegroundService(start)
69
+ }
70
+
71
+ fun stop(context: Context) {
72
+ context.stopService(Intent(context, TokamakLocationService::class.java))
73
+ }
74
+ }
75
+ }
@@ -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
+ /** The JSON of each kind of updates' options, by name. */
13
+ private const val PREFERENCES = "tokamak.location"
14
+ private const val DEFAULT_INTERVAL_MILLIS = 10_000L
15
+
16
+ private val QUALITIES =
17
+ mapOf(
18
+ "high" to LocationRequest.QUALITY_HIGH_ACCURACY,
19
+ "balanced" to LocationRequest.QUALITY_BALANCED_POWER_ACCURACY,
20
+ "low" to LocationRequest.QUALITY_LOW_POWER,
21
+ )
22
+
23
+ /**
24
+ * Location updates of one kind, [name]: `startUpdates`' or significant changes'. `LocationManager`
25
+ * sends them to [TokamakLocationReceiver] through a `PendingIntent`, starting the app's process when
26
+ * it has ended. Their options persist until [stop], and [restore] requests them where they are not
27
+ * running. Use it on the main thread.
28
+ */
29
+ internal class LocationUpdates(private val context: Context, private val name: String) {
30
+ private val manager = context.getSystemService(LocationManager::class.java)
31
+ private val preferences = context.getSharedPreferences(PREFERENCES, Context.MODE_PRIVATE)
32
+
33
+ /** The options of the updates, or null. */
34
+ val options: JSONObject?
35
+ get() = preferences.getString(name, null)?.let(::JSONObject)
36
+
37
+ /** Keeps [options], which [restore] requests. */
38
+ fun keep(options: JSONObject) {
39
+ preferences.edit().putString(name, options.toString()).apply()
40
+ }
41
+
42
+ /**
43
+ * Keeps [options] and requests updates with them, replacing others; the same options running
44
+ * change nothing. Throws `SecurityException` without permission.
45
+ */
46
+ fun start(options: JSONObject) {
47
+ if (options.toString() == preferences.getString(name, null) && isRequested()) return
48
+ keep(options)
49
+ request(options)
50
+ }
51
+
52
+ fun stop() {
53
+ preferences.edit().remove(name).apply()
54
+ pendingIntent(PendingIntent.FLAG_NO_CREATE)?.let {
55
+ manager.removeUpdates(it)
56
+ it.cancel()
57
+ }
58
+ }
59
+
60
+ /**
61
+ * Requests the running updates again when Android [removed] the request, when their
62
+ * `PendingIntent` no longer exists, or after a force stop, which removes the request on Android
63
+ * 14. Throws `SecurityException` when the app no longer has permission.
64
+ */
65
+ fun restore(removed: Boolean) {
66
+ val options = options ?: return
67
+ if (removed || forceStopped() || !isRequested()) request(options)
68
+ }
69
+
70
+ private fun isRequested(): Boolean = pendingIntent(PendingIntent.FLAG_NO_CREATE) != null
71
+
72
+ /** Whether the app's process last ended by the user's force stop. */
73
+ private fun forceStopped(): Boolean =
74
+ context.getSystemService(ActivityManager::class.java)
75
+ .getHistoricalProcessExitReasons(null, 0, 1)
76
+ .firstOrNull()
77
+ ?.reason == ApplicationExitInfo.REASON_USER_REQUESTED
78
+
79
+ private fun request(options: JSONObject) {
80
+ val accuracy = options.optString("accuracy", "high")
81
+ val request =
82
+ LocationRequest.Builder(options.optLong("interval", DEFAULT_INTERVAL_MILLIS))
83
+ .setQuality(QUALITIES[accuracy] ?: LocationRequest.QUALITY_HIGH_ACCURACY)
84
+ .setMinUpdateDistanceMeters(options.optDouble("distance", 0.0).toFloat())
85
+ .build()
86
+ val updates = checkNotNull(pendingIntent(PendingIntent.FLAG_UPDATE_CURRENT))
87
+ try {
88
+ manager.requestLocationUpdates(provider(accuracy), request, updates)
89
+ } catch (error: Exception) {
90
+ // Without the `PendingIntent`, [restore] requests them again.
91
+ updates.cancel()
92
+ throw error
93
+ }
94
+ }
95
+
96
+ /** The fused provider when the device has one, and otherwise GPS for high accuracy and the network for less. */
97
+ private fun provider(accuracy: String): String =
98
+ when {
99
+ manager.hasProvider(LocationManager.FUSED_PROVIDER) -> LocationManager.FUSED_PROVIDER
100
+ accuracy == "high" -> LocationManager.GPS_PROVIDER
101
+ else -> LocationManager.NETWORK_PROVIDER
102
+ }
103
+
104
+ /** The updates' `PendingIntent`, mutable so `LocationManager` adds the locations. */
105
+ private fun pendingIntent(flags: Int): PendingIntent? =
106
+ PendingIntent.getBroadcast(
107
+ context,
108
+ 0,
109
+ Intent(context, TokamakLocationReceiver::class.java).setAction("com.tokamak.plugins.location.${name.uppercase()}"),
110
+ flags or PendingIntent.FLAG_MUTABLE,
111
+ )
112
+
113
+ companion object {
114
+ /** `startUpdates`' updates. */
115
+ const val UPDATES = "updates"
116
+
117
+ /** Significant changes, as `setup` starts them. */
118
+ const val SIGNIFICANT_CHANGES = "significantChanges"
119
+ }
120
+ }