@tokamakdev/plugin-location 0.1.0-beta.127 → 0.1.0-beta.129
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 +2 -10
- package/android/Exports.generated.kt +264 -0
- package/android/Exports.kt +30 -0
- package/android/LocationEvents.kt +19 -33
- package/android/LocationPlugin.kt +103 -140
- package/android/LocationPositions.kt +67 -0
- package/android/LocationRegions.kt +8 -16
- package/android/LocationService.kt +10 -11
- package/android/LocationTypes.kt +132 -0
- package/android/LocationUpdates.kt +16 -17
- package/apple/Exports.generated.swift +134 -0
- package/apple/Exports.swift +48 -0
- package/apple/LocationEvents.swift +45 -68
- package/apple/LocationPlugin.swift +171 -175
- package/apple/LocationPositions.swift +59 -0
- package/apple/LocationRegions.swift +29 -34
- package/apple/LocationTypes.swift +158 -0
- package/apple/LocationUpdates.swift +20 -28
- package/package.json +5 -6
- package/src/android.generated.ts +170 -0
- package/src/apple.generated.ts +183 -0
- package/src/errors.ts +0 -17
- package/src/events.ts +31 -0
- package/src/index.ts +115 -153
- package/src/types.ts +9 -20
- package/tokamak-plugin.json +3 -43
- package/web/index.ts +11 -10
- package/android/Bindings.kt +0 -997
- package/android/bindings.ts +0 -4150
- package/apple/Bindings.swift +0 -1059
- package/apple/bindings.ts +0 -1314
- package/events/index.ts +0 -18
- package/src/android.ts +0 -150
- package/src/apple.ts +0 -117
|
@@ -13,12 +13,12 @@ import android.provider.Settings
|
|
|
13
13
|
import com.tokamak.runtime.TokamakHost
|
|
14
14
|
import com.tokamak.runtime.TokamakPlugin
|
|
15
15
|
import com.tokamak.runtime.TokamakPluginError
|
|
16
|
-
import
|
|
17
|
-
import
|
|
18
|
-
import
|
|
19
|
-
import
|
|
20
|
-
import
|
|
21
|
-
import
|
|
16
|
+
import kotlin.coroutines.cancellation.CancellationException
|
|
17
|
+
import kotlin.coroutines.resume
|
|
18
|
+
import kotlinx.coroutines.flow.Flow
|
|
19
|
+
import kotlinx.coroutines.flow.catch
|
|
20
|
+
import kotlinx.coroutines.flow.map
|
|
21
|
+
import kotlinx.coroutines.suspendCancellableCoroutine
|
|
22
22
|
|
|
23
23
|
class TokamakLocationPlugin(
|
|
24
24
|
private val host: TokamakHost,
|
|
@@ -45,6 +45,7 @@ class TokamakLocationPlugin(
|
|
|
45
45
|
private val significantChanges = LocationUpdates(context, LocationUpdates.SIGNIFICANT_CHANGES)
|
|
46
46
|
private val regions = LocationRegions(context)
|
|
47
47
|
private val events = LocationEvents(host)
|
|
48
|
+
private val positions = LocationPositions(context, manager)
|
|
48
49
|
|
|
49
50
|
/** Whether the provider was on when last reported, as each request reports its changes. */
|
|
50
51
|
private var providerEnabled = true
|
|
@@ -64,134 +65,119 @@ class TokamakLocationPlugin(
|
|
|
64
65
|
if (host.isInForeground) startService()
|
|
65
66
|
}
|
|
66
67
|
|
|
67
|
-
override fun call(method: String, arguments: Any?, reply: TokamakPluginReply): (() -> Unit)? {
|
|
68
|
-
when (method) {
|
|
69
|
-
"setup" -> setup(requireObject(arguments), reply)
|
|
70
|
-
"permission" -> reply(Result.success(permission()))
|
|
71
|
-
"requestPermission" -> requestPermission(requireObject(arguments), reply)
|
|
72
|
-
"servicesEnabled" -> reply(Result.success(manager.isLocationEnabled))
|
|
73
|
-
"openSettings" -> openSettings(reply)
|
|
74
|
-
"startUpdates" -> startUpdates(requireObject(arguments), reply)
|
|
75
|
-
"stopUpdates" -> {
|
|
76
|
-
stopUpdates()
|
|
77
|
-
reply(Result.success(null))
|
|
78
|
-
}
|
|
79
|
-
"getUpdates" -> reply(Result.success(updates.options))
|
|
80
|
-
"addRegion" -> addRegion(requireObject(arguments), reply)
|
|
81
|
-
"getRegions" -> reply(Result.success(JSONArray(regions.all())))
|
|
82
|
-
"removeRegion" -> {
|
|
83
|
-
regions.remove(requireObject(arguments).requireString("id"))
|
|
84
|
-
reply(Result.success(null))
|
|
85
|
-
}
|
|
86
|
-
else -> super.call(method, arguments, reply)
|
|
87
|
-
}
|
|
88
|
-
return null
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
override fun subscribe(
|
|
92
|
-
method: String,
|
|
93
|
-
arguments: Any?,
|
|
94
|
-
reply: TokamakPluginReply,
|
|
95
|
-
): () -> Unit = events.listen(method, reply) ?: super.subscribe(method, arguments, reply)
|
|
96
|
-
|
|
97
|
-
/**
|
|
98
|
-
* Passes [action] whether the app may read fine or coarse location, asking when it may not.
|
|
99
|
-
* Throws `NeedsUIError` when asking would show UI while the app is in the background.
|
|
100
|
-
*/
|
|
101
68
|
/**
|
|
102
|
-
*
|
|
103
|
-
*
|
|
69
|
+
* Starts significant changes when [config] names them, and stops them otherwise. Without
|
|
70
|
+
* permission they start once the app has it.
|
|
104
71
|
*/
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
72
|
+
fun setup(config: SetupConfig) =
|
|
73
|
+
located {
|
|
74
|
+
when {
|
|
75
|
+
config.significantChanges != true -> significantChanges.stop()
|
|
76
|
+
hasForegroundPermission() -> significantChanges.start(SIGNIFICANT_CHANGES)
|
|
77
|
+
else -> significantChanges.keep(SIGNIFICANT_CHANGES)
|
|
78
|
+
}
|
|
110
79
|
}
|
|
111
|
-
}
|
|
112
80
|
|
|
113
|
-
|
|
114
|
-
|
|
81
|
+
/** The provider's last known position when it is no older than [maximumAge] milliseconds, or null. */
|
|
82
|
+
fun recentPosition(maximumAge: Double): Position? = located { positions.recent(maximumAge)?.let(::Position) }
|
|
115
83
|
|
|
116
|
-
|
|
84
|
+
/** A new fix, or null when Android finds none. */
|
|
85
|
+
suspend fun currentPosition(): Position? = located { positions.current()?.let(::Position) }
|
|
86
|
+
|
|
87
|
+
/** Each position as the device moves, until the provider turns off. */
|
|
88
|
+
fun watchPosition(): Flow<Position> = positions.watch().map(::Position).catch { throw locationError(it) }
|
|
117
89
|
|
|
118
90
|
/** The app's in-use and background permission, and the precision it gets, or would get once allowed. */
|
|
119
|
-
|
|
120
|
-
val inUse = if (hasForegroundPermission())
|
|
121
|
-
val background = if (BACKGROUND in declared) state(BACKGROUND) else
|
|
91
|
+
fun permission(): LocationPermission {
|
|
92
|
+
val inUse = if (hasForegroundPermission()) Permission.granted else state(COARSE)
|
|
93
|
+
val background = if (BACKGROUND in declared) state(BACKGROUND) else Permission.denied
|
|
122
94
|
val precise = granted(FINE) || (!granted(COARSE) && FINE in declared)
|
|
123
|
-
return
|
|
124
|
-
.put("inUse", inUse)
|
|
125
|
-
.put("background", background)
|
|
126
|
-
.put("precision", if (precise) "precise" else "approximate")
|
|
95
|
+
return LocationPermission(inUse, background, if (precise) Precision.precise else Precision.approximate)
|
|
127
96
|
}
|
|
128
97
|
|
|
129
|
-
private fun state(permission: String): String =
|
|
130
|
-
when {
|
|
131
|
-
granted(permission) -> "granted"
|
|
132
|
-
host.wouldPrompt(permission) -> "prompt"
|
|
133
|
-
else -> "denied"
|
|
134
|
-
}
|
|
135
|
-
|
|
136
98
|
/**
|
|
137
|
-
* Asks for foreground permission, then for background permission when [
|
|
99
|
+
* Asks for foreground permission, then for background permission when [request] asks for it, as
|
|
138
100
|
* Android grants background permission only to an app with foreground permission.
|
|
139
101
|
*/
|
|
140
|
-
|
|
141
|
-
val background =
|
|
102
|
+
suspend fun requestPermission(request: PermissionRequest?): LocationPermission {
|
|
103
|
+
val background = request?.background == true
|
|
142
104
|
if (background && BACKGROUND !in declared) {
|
|
143
105
|
throw TokamakPluginError.notSupported("Background location needs the location plugin's background build setting")
|
|
144
106
|
}
|
|
145
|
-
val asked = if (
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
}
|
|
107
|
+
val asked = if (request?.precision == Precision.approximate) setOf(COARSE) else foreground
|
|
108
|
+
val allowed = ask(asked).containsValue(true)
|
|
109
|
+
if (allowed) restore(updatesRemoved = false)
|
|
110
|
+
if (background && allowed) ask(setOf(BACKGROUND))
|
|
111
|
+
return permission()
|
|
151
112
|
}
|
|
152
113
|
|
|
153
|
-
|
|
154
|
-
|
|
114
|
+
fun servicesEnabled(): Boolean = manager.isLocationEnabled
|
|
115
|
+
|
|
116
|
+
fun openSettings() {
|
|
117
|
+
val app = Uri.fromParts("package", context.packageName, null)
|
|
118
|
+
host.requireForegroundActivity().startActivity(Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS, app))
|
|
155
119
|
}
|
|
156
120
|
|
|
157
121
|
/** Starts updates in the foreground service when [options] name one, which stops otherwise. On failure, none run. */
|
|
158
|
-
|
|
122
|
+
fun startUpdates(options: UpdateOptions?) {
|
|
123
|
+
val running = options ?: UpdateOptions(null, null, null, null)
|
|
159
124
|
requirePermission()
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
reply(Result.failure(locationError(it)))
|
|
169
|
-
}
|
|
125
|
+
if (running.foregroundService != null) requireService()
|
|
126
|
+
try {
|
|
127
|
+
if (running.foregroundService == null) TokamakLocationService.stop(context) else TokamakLocationService.start(context, running)
|
|
128
|
+
updates.start(running)
|
|
129
|
+
} catch (error: Exception) {
|
|
130
|
+
stopUpdates()
|
|
131
|
+
throw locationError(error)
|
|
132
|
+
}
|
|
170
133
|
}
|
|
171
134
|
|
|
172
|
-
|
|
135
|
+
fun stopUpdates() {
|
|
173
136
|
updates.stop()
|
|
174
137
|
TokamakLocationService.stop(context)
|
|
175
138
|
}
|
|
176
139
|
|
|
177
|
-
|
|
178
|
-
|
|
140
|
+
fun getUpdates(): UpdateOptions? = updates.options
|
|
141
|
+
|
|
142
|
+
/** Monitors a region, which needs precise location. */
|
|
143
|
+
fun addRegion(region: Region) {
|
|
144
|
+
if (!granted(FINE)) throw TokamakPluginError("NotAllowedError", "Regions need precise location")
|
|
145
|
+
located { regions.add(region) }
|
|
179
146
|
}
|
|
180
147
|
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
148
|
+
fun getRegions(): List<MonitoredRegion> =
|
|
149
|
+
regions.all().map { MonitoredRegion(it.id, it.latitude, it.longitude, it.radius, it.onEntry ?: true, it.onExit ?: true) }
|
|
150
|
+
|
|
151
|
+
fun removeRegion(id: String) = regions.remove(id)
|
|
152
|
+
|
|
153
|
+
fun onLocationUpdate(): Flow<LocationUpdate> = events.updates.stream()
|
|
154
|
+
|
|
155
|
+
fun onRegionTransition(): Flow<RegionTransition> = events.transitions.stream()
|
|
156
|
+
|
|
157
|
+
fun onLocationError(): Flow<LocationErrorEvent> = events.errors.stream()
|
|
158
|
+
|
|
159
|
+
/** Asks the user for [permissions], returning whether each is granted. */
|
|
160
|
+
private suspend fun ask(permissions: Set<String>): Map<String, Boolean> =
|
|
161
|
+
suspendCancellableCoroutine { continuation -> host.requestPermissions(permissions) { continuation.resume(it) } }
|
|
162
|
+
|
|
163
|
+
private fun granted(permission: String): Boolean =
|
|
164
|
+
context.checkSelfPermission(permission) == PackageManager.PERMISSION_GRANTED
|
|
165
|
+
|
|
166
|
+
private fun hasForegroundPermission(): Boolean = granted(FINE) || granted(COARSE)
|
|
167
|
+
|
|
168
|
+
private fun state(permission: String): Permission =
|
|
169
|
+
when {
|
|
170
|
+
granted(permission) -> Permission.granted
|
|
171
|
+
host.wouldPrompt(permission) -> Permission.prompt
|
|
172
|
+
else -> Permission.denied
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
private fun requirePermission() {
|
|
176
|
+
if (!hasForegroundPermission()) throw TokamakPluginError("NotAllowedError", "Location permission has not been granted")
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
private fun startService() {
|
|
180
|
+
updates.options?.takeIf { it.foregroundService != null }?.let { TokamakLocationService.start(context, it) }
|
|
195
181
|
}
|
|
196
182
|
|
|
197
183
|
/**
|
|
@@ -205,14 +191,6 @@ class TokamakLocationPlugin(
|
|
|
205
191
|
if (!host.isInForeground && !granted(BACKGROUND)) throw TokamakPluginError.needsUI()
|
|
206
192
|
}
|
|
207
193
|
|
|
208
|
-
/** Monitors a region, which needs precise location. */
|
|
209
|
-
private fun addRegion(region: JSONObject, reply: TokamakPluginReply) {
|
|
210
|
-
if (!granted(FINE)) throw TokamakPluginError("NotAllowedError", "Regions need precise location")
|
|
211
|
-
runCatching { regions.add(region) }
|
|
212
|
-
.onSuccess { reply(Result.success(null)) }
|
|
213
|
-
.onFailure { reply(Result.failure(locationError(it))) }
|
|
214
|
-
}
|
|
215
|
-
|
|
216
194
|
/**
|
|
217
195
|
* Restores updates, significant changes and regions where the app has permission, as Android
|
|
218
196
|
* pauses them without it, and starts the updates' foreground service on screen, or after a restart
|
|
@@ -238,7 +216,7 @@ class TokamakLocationPlugin(
|
|
|
238
216
|
if (!turnedOff) return finish()
|
|
239
217
|
return events.fail(TokamakPluginError("NotReadableError", "The location provider was turned off"), finish)
|
|
240
218
|
}
|
|
241
|
-
events.update(locations(intent).map(::
|
|
219
|
+
events.update(locations(intent).map(::Position), finish)
|
|
242
220
|
}
|
|
243
221
|
|
|
244
222
|
/** Delivers a region's entry or exit, when the region reports it. */
|
|
@@ -246,7 +224,7 @@ class TokamakLocationPlugin(
|
|
|
246
224
|
val id = intent.identifier ?: return finish()
|
|
247
225
|
val entered = intent.getBooleanExtra(LocationManager.KEY_PROXIMITY_ENTERING, false)
|
|
248
226
|
val region = regions.get(id) ?: return finish()
|
|
249
|
-
if (
|
|
227
|
+
if ((if (entered) region.onEntry else region.onExit) == false) return finish()
|
|
250
228
|
events.transition(id, entered, finish)
|
|
251
229
|
}
|
|
252
230
|
|
|
@@ -255,36 +233,21 @@ class TokamakLocationPlugin(
|
|
|
255
233
|
intent.getParcelableArrayExtra(LocationManager.KEY_LOCATIONS)?.filterIsInstance<Location>()
|
|
256
234
|
?: listOfNotNull(intent.getParcelableExtra(LocationManager.KEY_LOCATION_CHANGED))
|
|
257
235
|
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
"coords",
|
|
268
|
-
JSONObject()
|
|
269
|
-
.put("latitude", location.latitude)
|
|
270
|
-
.put("longitude", location.longitude)
|
|
271
|
-
.put("accuracy", location.accuracy.toDouble())
|
|
272
|
-
.put("altitude", nullable(location.hasAltitude(), location.altitude))
|
|
273
|
-
.put(
|
|
274
|
-
"altitudeAccuracy",
|
|
275
|
-
nullable(location.hasVerticalAccuracy(), location.verticalAccuracyMeters.toDouble()),
|
|
276
|
-
)
|
|
277
|
-
.put("heading", nullable(location.hasBearing(), location.bearing.toDouble()))
|
|
278
|
-
.put("speed", nullable(location.hasSpeed(), location.speed.toDouble())),
|
|
279
|
-
)
|
|
280
|
-
.put("timestamp", location.time)
|
|
281
|
-
|
|
282
|
-
private fun nullable(available: Boolean, value: Double): Any = if (available) value else JSONObject.NULL
|
|
236
|
+
/** Runs [operation], translating its failure into the error the Worker receives. */
|
|
237
|
+
private inline fun <T> located(operation: () -> T): T =
|
|
238
|
+
try {
|
|
239
|
+
operation()
|
|
240
|
+
} catch (error: CancellationException) {
|
|
241
|
+
throw error
|
|
242
|
+
} catch (error: Exception) {
|
|
243
|
+
throw locationError(error)
|
|
244
|
+
}
|
|
283
245
|
|
|
284
246
|
private fun permissionDenied() = TokamakPluginError("NotAllowedError", "Location permission was denied")
|
|
285
247
|
|
|
286
248
|
private fun locationError(error: Throwable): TokamakPluginError =
|
|
287
249
|
when (error) {
|
|
250
|
+
is TokamakPluginError -> error
|
|
288
251
|
is SecurityException -> permissionDenied()
|
|
289
252
|
// Off screen, Android starts a foreground service only in response to some events.
|
|
290
253
|
is ForegroundServiceStartNotAllowedException -> TokamakPluginError.needsUI()
|
|
@@ -297,6 +260,6 @@ class TokamakLocationPlugin(
|
|
|
297
260
|
const val BACKGROUND = Manifest.permission.ACCESS_BACKGROUND_LOCATION
|
|
298
261
|
|
|
299
262
|
/** Apple's significant-change service's thresholds: 500 metres or more, at most every 5 minutes, without GPS. */
|
|
300
|
-
|
|
263
|
+
val SIGNIFICANT_CHANGES = UpdateOptions(Accuracy.low, distance = 500.0, interval = 300_000, foregroundService = null)
|
|
301
264
|
}
|
|
302
265
|
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
package com.tokamak.plugins.location
|
|
2
|
+
|
|
3
|
+
import android.content.Context
|
|
4
|
+
import android.location.Location
|
|
5
|
+
import android.location.LocationListener
|
|
6
|
+
import android.location.LocationManager
|
|
7
|
+
import android.location.LocationRequest
|
|
8
|
+
import android.os.CancellationSignal
|
|
9
|
+
import android.os.SystemClock
|
|
10
|
+
import com.tokamak.runtime.TokamakPluginError
|
|
11
|
+
import kotlin.coroutines.resume
|
|
12
|
+
import kotlinx.coroutines.channels.SendChannel
|
|
13
|
+
import kotlinx.coroutines.channels.awaitClose
|
|
14
|
+
import kotlinx.coroutines.flow.Flow
|
|
15
|
+
import kotlinx.coroutines.flow.callbackFlow
|
|
16
|
+
import kotlinx.coroutines.suspendCancellableCoroutine
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Positions for `getCurrentPosition` and `watchPosition`, at the provider's highest accuracy, from
|
|
20
|
+
* the first enabled of the fused provider, which combines GPS with Wi-Fi and cell signals, GPS and
|
|
21
|
+
* the network, or any other enabled provider. Each throws `SecurityException` without permission.
|
|
22
|
+
*/
|
|
23
|
+
internal class LocationPositions(private val context: Context, private val manager: LocationManager) {
|
|
24
|
+
/** The provider's last known position when it is no older than [maximumAge] milliseconds. */
|
|
25
|
+
fun recent(maximumAge: Double): Location? = manager.getLastKnownLocation(provider())?.takeIf { age(it) <= maximumAge }
|
|
26
|
+
|
|
27
|
+
/** A new fix, or null when Android finds none. */
|
|
28
|
+
suspend fun current(): Location? =
|
|
29
|
+
suspendCancellableCoroutine { continuation ->
|
|
30
|
+
val signal = CancellationSignal()
|
|
31
|
+
continuation.invokeOnCancellation { signal.cancel() }
|
|
32
|
+
manager.getCurrentLocation(provider(), highAccuracy(0), signal, context.mainExecutor) { continuation.resume(it) }
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Each position as the device moves, until the provider turns off. */
|
|
36
|
+
fun watch(): Flow<Location> =
|
|
37
|
+
callbackFlow {
|
|
38
|
+
val listener = Forwarder(this)
|
|
39
|
+
manager.requestLocationUpdates(provider(), highAccuracy(1000), context.mainExecutor, listener)
|
|
40
|
+
awaitClose { manager.removeUpdates(listener) }
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
private fun provider(): String =
|
|
44
|
+
listOf(LocationManager.FUSED_PROVIDER, LocationManager.GPS_PROVIDER, LocationManager.NETWORK_PROVIDER)
|
|
45
|
+
.firstOrNull(manager::isProviderEnabled)
|
|
46
|
+
?: manager.getProviders(true).firstOrNull()
|
|
47
|
+
?: throw TokamakPluginError("NotReadableError", "No location provider is available")
|
|
48
|
+
|
|
49
|
+
/** A request for the most accurate fixes the provider gives, every [interval] milliseconds. */
|
|
50
|
+
private fun highAccuracy(interval: Long): LocationRequest =
|
|
51
|
+
LocationRequest.Builder(interval).setQuality(LocationRequest.QUALITY_HIGH_ACCURACY).build()
|
|
52
|
+
|
|
53
|
+
/** The milliseconds since Android took [location]. */
|
|
54
|
+
private fun age(location: Location): Double =
|
|
55
|
+
(SystemClock.elapsedRealtimeNanos() - location.elapsedRealtimeNanos) / 1_000_000.0
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Sends each location to [positions], which a disabled provider closes. */
|
|
59
|
+
private class Forwarder(private val positions: SendChannel<Location>) : LocationListener {
|
|
60
|
+
override fun onLocationChanged(location: Location) {
|
|
61
|
+
positions.trySend(location)
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
override fun onProviderDisabled(provider: String) {
|
|
65
|
+
positions.close(TokamakPluginError("NotReadableError", "Location provider is unavailable"))
|
|
66
|
+
}
|
|
67
|
+
}
|
|
@@ -4,9 +4,8 @@ import android.app.PendingIntent
|
|
|
4
4
|
import android.content.Context
|
|
5
5
|
import android.content.Intent
|
|
6
6
|
import android.location.LocationManager
|
|
7
|
-
import org.json.JSONObject
|
|
8
7
|
|
|
9
|
-
/** The JSON of each monitored region,
|
|
8
|
+
/** The JSON of each monitored region, by id. */
|
|
10
9
|
private const val PREFERENCES = "tokamak.location.regions"
|
|
11
10
|
|
|
12
11
|
/**
|
|
@@ -19,26 +18,19 @@ internal class LocationRegions(private val context: Context) {
|
|
|
19
18
|
private val manager = context.getSystemService(LocationManager::class.java)
|
|
20
19
|
private val preferences = context.getSharedPreferences(PREFERENCES, Context.MODE_PRIVATE)
|
|
21
20
|
|
|
22
|
-
fun all(): List<
|
|
21
|
+
fun all(): List<Region> = preferences.all.values.map { Region.from(it as String) }
|
|
23
22
|
|
|
24
|
-
fun get(id: String):
|
|
23
|
+
fun get(id: String): Region? = preferences.getString(id, null)?.let(Region::from)
|
|
25
24
|
|
|
26
25
|
/**
|
|
27
26
|
* Monitors [region], replacing one with the same id. It starts in an unknown state, so a device
|
|
28
27
|
* already inside reports an entry. Throws `SecurityException` without precise location.
|
|
29
28
|
*/
|
|
30
|
-
fun add(region:
|
|
31
|
-
val
|
|
32
|
-
val alert = checkNotNull(pendingIntent(id, PendingIntent.FLAG_UPDATE_CURRENT))
|
|
29
|
+
fun add(region: Region) {
|
|
30
|
+
val alert = checkNotNull(pendingIntent(region.id, PendingIntent.FLAG_UPDATE_CURRENT))
|
|
33
31
|
manager.removeProximityAlert(alert)
|
|
34
|
-
manager.addProximityAlert(
|
|
35
|
-
|
|
36
|
-
region.getDouble("longitude"),
|
|
37
|
-
region.getDouble("radius").toFloat(),
|
|
38
|
-
-1,
|
|
39
|
-
alert,
|
|
40
|
-
)
|
|
41
|
-
preferences.edit().putString(id, region.toString()).apply()
|
|
32
|
+
manager.addProximityAlert(region.latitude, region.longitude, region.radius.toFloat(), -1, alert)
|
|
33
|
+
preferences.edit().putString(region.id, region.json().toString()).apply()
|
|
42
34
|
}
|
|
43
35
|
|
|
44
36
|
fun remove(id: String) {
|
|
@@ -55,7 +47,7 @@ internal class LocationRegions(private val context: Context) {
|
|
|
55
47
|
* location.
|
|
56
48
|
*/
|
|
57
49
|
fun restore() {
|
|
58
|
-
all().filter { pendingIntent(it.
|
|
50
|
+
all().filter { pendingIntent(it.id, PendingIntent.FLAG_NO_CREATE) == null }.forEach(::add)
|
|
59
51
|
}
|
|
60
52
|
|
|
61
53
|
/** The region [id]'s `PendingIntent`, mutable so `LocationManager` adds whether the device entered. */
|
|
@@ -9,7 +9,6 @@ import android.content.Context
|
|
|
9
9
|
import android.content.Intent
|
|
10
10
|
import android.content.pm.ServiceInfo
|
|
11
11
|
import android.os.IBinder
|
|
12
|
-
import org.json.JSONObject
|
|
13
12
|
|
|
14
13
|
private const val CHANNEL = "tokamak.location"
|
|
15
14
|
private const val NOTIFICATION_ID = 1
|
|
@@ -25,8 +24,8 @@ class TokamakLocationService : Service() {
|
|
|
25
24
|
/** Shows the notification the start [intent] carries, or on a restart, the running updates'. */
|
|
26
25
|
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
|
27
26
|
val content =
|
|
28
|
-
intent?.getStringExtra(NOTIFICATION_EXTRA)?.let(
|
|
29
|
-
?: LocationUpdates(this, LocationUpdates.UPDATES).options?.
|
|
27
|
+
intent?.getStringExtra(NOTIFICATION_EXTRA)?.let { UpdateOptions.from(it).foregroundService }
|
|
28
|
+
?: LocationUpdates(this, LocationUpdates.UPDATES).options?.foregroundService
|
|
30
29
|
if (content == null) {
|
|
31
30
|
stopSelf()
|
|
32
31
|
return START_NOT_STICKY
|
|
@@ -36,8 +35,8 @@ class TokamakLocationService : Service() {
|
|
|
36
35
|
}
|
|
37
36
|
|
|
38
37
|
/** The notification [content] describes, in the location channel it names, opening the app. */
|
|
39
|
-
private fun notification(content:
|
|
40
|
-
val channel = NotificationChannel(CHANNEL, content.
|
|
38
|
+
private fun notification(content: ServiceNotification): Notification {
|
|
39
|
+
val channel = NotificationChannel(CHANNEL, content.channel, NotificationManager.IMPORTANCE_LOW)
|
|
41
40
|
getSystemService(NotificationManager::class.java).createNotificationChannel(channel)
|
|
42
41
|
val open =
|
|
43
42
|
packageManager.getLaunchIntentForPackage(packageName)?.let {
|
|
@@ -45,8 +44,8 @@ class TokamakLocationService : Service() {
|
|
|
45
44
|
}
|
|
46
45
|
return Notification.Builder(this, CHANNEL)
|
|
47
46
|
.setSmallIcon(icon())
|
|
48
|
-
.setContentTitle(content.
|
|
49
|
-
.setContentText(content.
|
|
47
|
+
.setContentTitle(content.title)
|
|
48
|
+
.setContentText(content.body)
|
|
50
49
|
.setContentIntent(open)
|
|
51
50
|
.setOngoing(true)
|
|
52
51
|
.build()
|
|
@@ -59,12 +58,12 @@ class TokamakLocationService : Service() {
|
|
|
59
58
|
?: android.R.drawable.sym_def_app_icon
|
|
60
59
|
|
|
61
60
|
companion object {
|
|
62
|
-
/** The start intent's
|
|
61
|
+
/** The start intent's updates' options, as JSON. */
|
|
63
62
|
private const val NOTIFICATION_EXTRA = "com.tokamak.plugins.location.NOTIFICATION"
|
|
64
63
|
|
|
65
|
-
/** Starts the service showing [
|
|
66
|
-
fun start(context: Context,
|
|
67
|
-
val start = Intent(context, TokamakLocationService::class.java).putExtra(NOTIFICATION_EXTRA,
|
|
64
|
+
/** Starts the service showing the notification of [options], or shows it in the running service. */
|
|
65
|
+
fun start(context: Context, options: UpdateOptions) {
|
|
66
|
+
val start = Intent(context, TokamakLocationService::class.java).putExtra(NOTIFICATION_EXTRA, options.json().toString())
|
|
68
67
|
context.startForegroundService(start)
|
|
69
68
|
}
|
|
70
69
|
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
package com.tokamak.plugins.location
|
|
2
|
+
|
|
3
|
+
import android.location.Location
|
|
4
|
+
import com.tokamak.runtime.TokamakValue
|
|
5
|
+
import org.json.JSONObject
|
|
6
|
+
|
|
7
|
+
data class Coordinates(
|
|
8
|
+
val latitude: Double,
|
|
9
|
+
val longitude: Double,
|
|
10
|
+
val accuracy: Double,
|
|
11
|
+
val altitude: Double?,
|
|
12
|
+
val altitudeAccuracy: Double?,
|
|
13
|
+
val heading: Double?,
|
|
14
|
+
val speed: Double?,
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
data class Position(val coords: Coordinates, val timestamp: Long) {
|
|
18
|
+
constructor(location: Location) : this(
|
|
19
|
+
Coordinates(
|
|
20
|
+
latitude = location.latitude,
|
|
21
|
+
longitude = location.longitude,
|
|
22
|
+
accuracy = location.accuracy.toDouble(),
|
|
23
|
+
altitude = location.altitude.takeIf { location.hasAltitude() },
|
|
24
|
+
altitudeAccuracy = location.verticalAccuracyMeters.toDouble().takeIf { location.hasVerticalAccuracy() },
|
|
25
|
+
heading = location.bearing.toDouble().takeIf { location.hasBearing() },
|
|
26
|
+
speed = location.speed.toDouble().takeIf { location.hasSpeed() },
|
|
27
|
+
),
|
|
28
|
+
location.time,
|
|
29
|
+
)
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
data class LocationUpdate(val positions: List<Position>)
|
|
33
|
+
|
|
34
|
+
enum class Transition { enter, exit }
|
|
35
|
+
|
|
36
|
+
data class RegionTransition(val id: String, val transition: Transition, val timestamp: Long)
|
|
37
|
+
|
|
38
|
+
data class LocationErrorEvent(val name: String, val message: String)
|
|
39
|
+
|
|
40
|
+
enum class Permission { granted, denied, prompt }
|
|
41
|
+
|
|
42
|
+
enum class Precision { precise, approximate }
|
|
43
|
+
|
|
44
|
+
data class LocationPermission(val inUse: Permission, val background: Permission, val precision: Precision)
|
|
45
|
+
|
|
46
|
+
data class PermissionRequest(val background: Boolean?, val precision: Precision?)
|
|
47
|
+
|
|
48
|
+
data class SetupConfig(val significantChanges: Boolean?)
|
|
49
|
+
|
|
50
|
+
/** The request's quality, precision traded against battery use. */
|
|
51
|
+
enum class Accuracy { high, balanced, low }
|
|
52
|
+
|
|
53
|
+
/** The foreground service's notification, set from JavaScript so apps can translate it. */
|
|
54
|
+
data class ServiceNotification(val title: String, val body: String, val channel: String)
|
|
55
|
+
|
|
56
|
+
data class UpdateOptions(
|
|
57
|
+
val accuracy: Accuracy?,
|
|
58
|
+
/** Metres the device moves before the next update. */
|
|
59
|
+
val distance: Double?,
|
|
60
|
+
/** Milliseconds between updates. */
|
|
61
|
+
val interval: Long?,
|
|
62
|
+
val foregroundService: ServiceNotification?,
|
|
63
|
+
) {
|
|
64
|
+
fun json(): JSONObject {
|
|
65
|
+
val notification =
|
|
66
|
+
foregroundService?.let { JSONObject().put("title", it.title).put("body", it.body).put("channel", it.channel) }
|
|
67
|
+
return JSONObject()
|
|
68
|
+
.putOpt("accuracy", accuracy?.name)
|
|
69
|
+
.putOpt("distance", distance)
|
|
70
|
+
.putOpt("interval", interval)
|
|
71
|
+
.putOpt("foregroundService", notification)
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
companion object {
|
|
75
|
+
/** The options [json] holds, as [json] wrote them. */
|
|
76
|
+
fun from(json: String): UpdateOptions {
|
|
77
|
+
val options = TokamakValue(JSONObject(json), "")
|
|
78
|
+
return UpdateOptions(
|
|
79
|
+
accuracy = options["accuracy"].nullable { it.enum(Accuracy.entries) },
|
|
80
|
+
distance = options["distance"].nullable { it.double() },
|
|
81
|
+
interval = options["interval"].nullable { it.long() },
|
|
82
|
+
foregroundService =
|
|
83
|
+
options["foregroundService"].nullable {
|
|
84
|
+
ServiceNotification(it["title"].string(), it["body"].string(), it["channel"].string())
|
|
85
|
+
},
|
|
86
|
+
)
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
data class Region(
|
|
92
|
+
val id: String,
|
|
93
|
+
val latitude: Double,
|
|
94
|
+
val longitude: Double,
|
|
95
|
+
/** In metres. */
|
|
96
|
+
val radius: Double,
|
|
97
|
+
val onEntry: Boolean?,
|
|
98
|
+
val onExit: Boolean?,
|
|
99
|
+
) {
|
|
100
|
+
fun json(): JSONObject =
|
|
101
|
+
JSONObject()
|
|
102
|
+
.put("id", id)
|
|
103
|
+
.put("latitude", latitude)
|
|
104
|
+
.put("longitude", longitude)
|
|
105
|
+
.put("radius", radius)
|
|
106
|
+
.putOpt("onEntry", onEntry)
|
|
107
|
+
.putOpt("onExit", onExit)
|
|
108
|
+
|
|
109
|
+
companion object {
|
|
110
|
+
/** The region [json] holds, as [json] wrote it. */
|
|
111
|
+
fun from(json: String): Region {
|
|
112
|
+
val region = TokamakValue(JSONObject(json), "")
|
|
113
|
+
return Region(
|
|
114
|
+
id = region["id"].string(),
|
|
115
|
+
latitude = region["latitude"].double(),
|
|
116
|
+
longitude = region["longitude"].double(),
|
|
117
|
+
radius = region["radius"].double(),
|
|
118
|
+
onEntry = region["onEntry"].nullable { it.boolean() },
|
|
119
|
+
onExit = region["onExit"].nullable { it.boolean() },
|
|
120
|
+
)
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
data class MonitoredRegion(
|
|
126
|
+
val id: String,
|
|
127
|
+
val latitude: Double,
|
|
128
|
+
val longitude: Double,
|
|
129
|
+
val radius: Double,
|
|
130
|
+
val onEntry: Boolean,
|
|
131
|
+
val onExit: Boolean,
|
|
132
|
+
)
|