@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.
- package/README.md +217 -18
- package/android/LocationEvents.kt +82 -0
- package/android/LocationPlugin.kt +264 -36
- package/android/LocationReceiver.kt +27 -0
- package/android/LocationRegions.kt +73 -0
- package/android/LocationService.kt +75 -0
- package/android/LocationUpdates.kt +120 -0
- package/apple/LocationEvents.swift +149 -0
- package/apple/LocationPlugin.swift +350 -56
- package/apple/LocationRegions.swift +175 -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 +124 -25
- package/src/options.ts +54 -0
- package/src/types.ts +173 -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
|
@@ -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
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
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 (
|
|
68
|
-
|
|
69
|
-
|
|
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
|
-
|
|
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):
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
"
|
|
156
|
-
"
|
|
157
|
-
"
|
|
158
|
-
"
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
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 ||
|
|
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
|
-
|
|
183
|
-
|
|
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
|
|
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
|
+
}
|