@tokamakdev/plugin-location 0.1.0-beta.128 → 0.1.0-beta.130
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 -2
- package/android/Exports.generated.kt +264 -0
- package/android/Exports.kt +30 -0
- package/android/LocationEvents.kt +19 -33
- package/android/LocationPlugin.kt +102 -160
- package/android/LocationPositions.kt +30 -15
- 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 +36 -68
- package/apple/LocationPlugin.swift +172 -186
- package/apple/LocationPositions.swift +34 -24
- package/apple/LocationRegions.swift +29 -34
- package/apple/LocationTypes.swift +158 -0
- package/apple/LocationUpdates.swift +20 -28
- package/package.json +5 -4
- package/src/android.generated.ts +170 -0
- package/src/apple.generated.ts +183 -0
- package/src/events.ts +31 -0
- package/src/index.ts +114 -139
- package/src/types.ts +9 -20
- package/tokamak-plugin.json +3 -26
- package/web/index.ts +11 -10
- package/events/index.ts +0 -18
|
@@ -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,
|
|
@@ -65,154 +65,119 @@ class TokamakLocationPlugin(
|
|
|
65
65
|
if (host.isInForeground) startService()
|
|
66
66
|
}
|
|
67
67
|
|
|
68
|
-
override fun call(method: String, arguments: Any?, reply: TokamakPluginReply): (() -> Unit)? {
|
|
69
|
-
when (method) {
|
|
70
|
-
"setup" -> setup(requireObject(arguments), reply)
|
|
71
|
-
"recentPosition" -> reply(Result.success(recentPosition(requireObject(arguments))))
|
|
72
|
-
"currentPosition" -> return currentPosition(reply)
|
|
73
|
-
"permission" -> reply(Result.success(permission()))
|
|
74
|
-
"requestPermission" -> requestPermission(requireObject(arguments), reply)
|
|
75
|
-
"servicesEnabled" -> reply(Result.success(manager.isLocationEnabled))
|
|
76
|
-
"openSettings" -> openSettings(reply)
|
|
77
|
-
"startUpdates" -> startUpdates(requireObject(arguments), reply)
|
|
78
|
-
"stopUpdates" -> {
|
|
79
|
-
stopUpdates()
|
|
80
|
-
reply(Result.success(null))
|
|
81
|
-
}
|
|
82
|
-
"getUpdates" -> reply(Result.success(updates.options))
|
|
83
|
-
"addRegion" -> addRegion(requireObject(arguments), reply)
|
|
84
|
-
"getRegions" -> reply(Result.success(JSONArray(regions.all())))
|
|
85
|
-
"removeRegion" -> {
|
|
86
|
-
regions.remove(requireObject(arguments).requireString("id"))
|
|
87
|
-
reply(Result.success(null))
|
|
88
|
-
}
|
|
89
|
-
else -> super.call(method, arguments, reply)
|
|
90
|
-
}
|
|
91
|
-
return null
|
|
92
|
-
}
|
|
93
|
-
|
|
94
|
-
override fun subscribe(
|
|
95
|
-
method: String,
|
|
96
|
-
arguments: Any?,
|
|
97
|
-
reply: TokamakPluginReply,
|
|
98
|
-
): () -> Unit =
|
|
99
|
-
when (method) {
|
|
100
|
-
"watchPosition" -> watchPosition(reply)
|
|
101
|
-
else -> events.listen(method, reply) ?: super.subscribe(method, arguments, reply)
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
/** The provider's last known position when it is no older than the options' `maximumAge`, or null. */
|
|
105
|
-
private fun recentPosition(options: JSONObject): JSONObject? =
|
|
106
|
-
runCatching { positions.recent(options.optDouble("maximumAge", 0.0))?.let(::position) }
|
|
107
|
-
.getOrElse { throw locationError(it) }
|
|
108
|
-
|
|
109
|
-
/** Replies with a new fix, or null when Android finds none, unless the returned cancellation runs first. */
|
|
110
|
-
private fun currentPosition(reply: TokamakPluginReply): () -> Unit =
|
|
111
|
-
runCatching { positions.current { reply(Result.success(it?.let(::position))) } }
|
|
112
|
-
.getOrElse { throw locationError(it) }
|
|
113
|
-
|
|
114
|
-
/** Replies with each position as the device moves, until the provider turns off or the returned release runs. */
|
|
115
|
-
private fun watchPosition(reply: TokamakPluginReply): () -> Unit =
|
|
116
|
-
runCatching {
|
|
117
|
-
positions.watch({ reply(Result.success(position(it))) }) {
|
|
118
|
-
reply(Result.failure(TokamakPluginError("NotReadableError", "Location provider is unavailable")))
|
|
119
|
-
}
|
|
120
|
-
}.getOrElse { throw locationError(it) }
|
|
121
|
-
|
|
122
68
|
/**
|
|
123
|
-
*
|
|
124
|
-
*
|
|
69
|
+
* Starts significant changes when [config] names them, and stops them otherwise. Without
|
|
70
|
+
* permission they start once the app has it.
|
|
125
71
|
*/
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
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
|
+
}
|
|
131
79
|
}
|
|
132
|
-
}
|
|
133
80
|
|
|
134
|
-
|
|
135
|
-
|
|
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) }
|
|
136
83
|
|
|
137
|
-
|
|
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) }
|
|
138
89
|
|
|
139
90
|
/** The app's in-use and background permission, and the precision it gets, or would get once allowed. */
|
|
140
|
-
|
|
141
|
-
val inUse = if (hasForegroundPermission())
|
|
142
|
-
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
|
|
143
94
|
val precise = granted(FINE) || (!granted(COARSE) && FINE in declared)
|
|
144
|
-
return
|
|
145
|
-
.put("inUse", inUse)
|
|
146
|
-
.put("background", background)
|
|
147
|
-
.put("precision", if (precise) "precise" else "approximate")
|
|
95
|
+
return LocationPermission(inUse, background, if (precise) Precision.precise else Precision.approximate)
|
|
148
96
|
}
|
|
149
97
|
|
|
150
|
-
private fun state(permission: String): String =
|
|
151
|
-
when {
|
|
152
|
-
granted(permission) -> "granted"
|
|
153
|
-
host.wouldPrompt(permission) -> "prompt"
|
|
154
|
-
else -> "denied"
|
|
155
|
-
}
|
|
156
|
-
|
|
157
98
|
/**
|
|
158
|
-
* Asks for foreground permission, then for background permission when [
|
|
99
|
+
* Asks for foreground permission, then for background permission when [request] asks for it, as
|
|
159
100
|
* Android grants background permission only to an app with foreground permission.
|
|
160
101
|
*/
|
|
161
|
-
|
|
162
|
-
val background =
|
|
102
|
+
suspend fun requestPermission(request: PermissionRequest?): LocationPermission {
|
|
103
|
+
val background = request?.background == true
|
|
163
104
|
if (background && BACKGROUND !in declared) {
|
|
164
105
|
throw TokamakPluginError.notSupported("Background location needs the location plugin's background build setting")
|
|
165
106
|
}
|
|
166
|
-
val asked = if (
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
}
|
|
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()
|
|
172
112
|
}
|
|
173
113
|
|
|
174
|
-
|
|
175
|
-
|
|
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))
|
|
176
119
|
}
|
|
177
120
|
|
|
178
121
|
/** Starts updates in the foreground service when [options] name one, which stops otherwise. On failure, none run. */
|
|
179
|
-
|
|
122
|
+
fun startUpdates(options: UpdateOptions?) {
|
|
123
|
+
val running = options ?: UpdateOptions(null, null, null, null)
|
|
180
124
|
requirePermission()
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
reply(Result.failure(locationError(it)))
|
|
190
|
-
}
|
|
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
|
+
}
|
|
191
133
|
}
|
|
192
134
|
|
|
193
|
-
|
|
135
|
+
fun stopUpdates() {
|
|
194
136
|
updates.stop()
|
|
195
137
|
TokamakLocationService.stop(context)
|
|
196
138
|
}
|
|
197
139
|
|
|
198
|
-
|
|
199
|
-
|
|
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) }
|
|
200
146
|
}
|
|
201
147
|
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
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) }
|
|
216
181
|
}
|
|
217
182
|
|
|
218
183
|
/**
|
|
@@ -226,14 +191,6 @@ class TokamakLocationPlugin(
|
|
|
226
191
|
if (!host.isInForeground && !granted(BACKGROUND)) throw TokamakPluginError.needsUI()
|
|
227
192
|
}
|
|
228
193
|
|
|
229
|
-
/** Monitors a region, which needs precise location. */
|
|
230
|
-
private fun addRegion(region: JSONObject, reply: TokamakPluginReply) {
|
|
231
|
-
if (!granted(FINE)) throw TokamakPluginError("NotAllowedError", "Regions need precise location")
|
|
232
|
-
runCatching { regions.add(region) }
|
|
233
|
-
.onSuccess { reply(Result.success(null)) }
|
|
234
|
-
.onFailure { reply(Result.failure(locationError(it))) }
|
|
235
|
-
}
|
|
236
|
-
|
|
237
194
|
/**
|
|
238
195
|
* Restores updates, significant changes and regions where the app has permission, as Android
|
|
239
196
|
* pauses them without it, and starts the updates' foreground service on screen, or after a restart
|
|
@@ -259,7 +216,7 @@ class TokamakLocationPlugin(
|
|
|
259
216
|
if (!turnedOff) return finish()
|
|
260
217
|
return events.fail(TokamakPluginError("NotReadableError", "The location provider was turned off"), finish)
|
|
261
218
|
}
|
|
262
|
-
events.update(locations(intent).map(::
|
|
219
|
+
events.update(locations(intent).map(::Position), finish)
|
|
263
220
|
}
|
|
264
221
|
|
|
265
222
|
/** Delivers a region's entry or exit, when the region reports it. */
|
|
@@ -267,7 +224,7 @@ class TokamakLocationPlugin(
|
|
|
267
224
|
val id = intent.identifier ?: return finish()
|
|
268
225
|
val entered = intent.getBooleanExtra(LocationManager.KEY_PROXIMITY_ENTERING, false)
|
|
269
226
|
val region = regions.get(id) ?: return finish()
|
|
270
|
-
if (
|
|
227
|
+
if ((if (entered) region.onEntry else region.onExit) == false) return finish()
|
|
271
228
|
events.transition(id, entered, finish)
|
|
272
229
|
}
|
|
273
230
|
|
|
@@ -276,36 +233,21 @@ class TokamakLocationPlugin(
|
|
|
276
233
|
intent.getParcelableArrayExtra(LocationManager.KEY_LOCATIONS)?.filterIsInstance<Location>()
|
|
277
234
|
?: listOfNotNull(intent.getParcelableExtra(LocationManager.KEY_LOCATION_CHANGED))
|
|
278
235
|
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
"coords",
|
|
289
|
-
JSONObject()
|
|
290
|
-
.put("latitude", location.latitude)
|
|
291
|
-
.put("longitude", location.longitude)
|
|
292
|
-
.put("accuracy", location.accuracy.toDouble())
|
|
293
|
-
.put("altitude", nullable(location.hasAltitude(), location.altitude))
|
|
294
|
-
.put(
|
|
295
|
-
"altitudeAccuracy",
|
|
296
|
-
nullable(location.hasVerticalAccuracy(), location.verticalAccuracyMeters.toDouble()),
|
|
297
|
-
)
|
|
298
|
-
.put("heading", nullable(location.hasBearing(), location.bearing.toDouble()))
|
|
299
|
-
.put("speed", nullable(location.hasSpeed(), location.speed.toDouble())),
|
|
300
|
-
)
|
|
301
|
-
.put("timestamp", location.time)
|
|
302
|
-
|
|
303
|
-
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
|
+
}
|
|
304
245
|
|
|
305
246
|
private fun permissionDenied() = TokamakPluginError("NotAllowedError", "Location permission was denied")
|
|
306
247
|
|
|
307
248
|
private fun locationError(error: Throwable): TokamakPluginError =
|
|
308
249
|
when (error) {
|
|
250
|
+
is TokamakPluginError -> error
|
|
309
251
|
is SecurityException -> permissionDenied()
|
|
310
252
|
// Off screen, Android starts a foreground service only in response to some events.
|
|
311
253
|
is ForegroundServiceStartNotAllowedException -> TokamakPluginError.needsUI()
|
|
@@ -318,6 +260,6 @@ class TokamakLocationPlugin(
|
|
|
318
260
|
const val BACKGROUND = Manifest.permission.ACCESS_BACKGROUND_LOCATION
|
|
319
261
|
|
|
320
262
|
/** Apple's significant-change service's thresholds: 500 metres or more, at most every 5 minutes, without GPS. */
|
|
321
|
-
|
|
263
|
+
val SIGNIFICANT_CHANGES = UpdateOptions(Accuracy.low, distance = 500.0, interval = 300_000, foregroundService = null)
|
|
322
264
|
}
|
|
323
265
|
}
|
|
@@ -8,6 +8,12 @@ import android.location.LocationRequest
|
|
|
8
8
|
import android.os.CancellationSignal
|
|
9
9
|
import android.os.SystemClock
|
|
10
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
|
|
11
17
|
|
|
12
18
|
/**
|
|
13
19
|
* Positions for `getCurrentPosition` and `watchPosition`, at the provider's highest accuracy, from
|
|
@@ -18,23 +24,21 @@ internal class LocationPositions(private val context: Context, private val manag
|
|
|
18
24
|
/** The provider's last known position when it is no older than [maximumAge] milliseconds. */
|
|
19
25
|
fun recent(maximumAge: Double): Location? = manager.getLastKnownLocation(provider())?.takeIf { age(it) <= maximumAge }
|
|
20
26
|
|
|
21
|
-
/**
|
|
22
|
-
fun current(
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
/** Passes [next] each position, and [disabled] the provider turning off, until the returned release runs. */
|
|
29
|
-
fun watch(next: (Location) -> Unit, disabled: () -> Unit): () -> Unit {
|
|
30
|
-
val listener = object : LocationListener {
|
|
31
|
-
override fun onLocationChanged(location: Location) = next(location)
|
|
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
|
+
}
|
|
32
34
|
|
|
33
|
-
|
|
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) }
|
|
34
41
|
}
|
|
35
|
-
manager.requestLocationUpdates(provider(), highAccuracy(1000), context.mainExecutor, listener)
|
|
36
|
-
return { manager.removeUpdates(listener) }
|
|
37
|
-
}
|
|
38
42
|
|
|
39
43
|
private fun provider(): String =
|
|
40
44
|
listOf(LocationManager.FUSED_PROVIDER, LocationManager.GPS_PROVIDER, LocationManager.NETWORK_PROVIDER)
|
|
@@ -50,3 +54,14 @@ internal class LocationPositions(private val context: Context, private val manag
|
|
|
50
54
|
private fun age(location: Location): Double =
|
|
51
55
|
(SystemClock.elapsedRealtimeNanos() - location.elapsedRealtimeNanos) / 1_000_000.0
|
|
52
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
|
+
)
|