@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.
@@ -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 com.tokamak.runtime.TokamakPluginReply
17
- import com.tokamak.runtime.optionalString
18
- import com.tokamak.runtime.requireObject
19
- import com.tokamak.runtime.requireString
20
- import org.json.JSONArray
21
- import org.json.JSONObject
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
- * Asks for the foreground [permissions], then passes [action] whether the app has one, first
124
- * starting what waited for permission.
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
- private fun askForeground(permissions: Set<String>, action: (Boolean) -> Unit) {
127
- host.requestPermissions(permissions) { granted ->
128
- val allowed = granted.containsValue(true)
129
- if (allowed) restore(updatesRemoved = false)
130
- action(allowed)
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
- private fun granted(permission: String): Boolean =
135
- context.checkSelfPermission(permission) == PackageManager.PERMISSION_GRANTED
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
- private fun hasForegroundPermission(): Boolean = granted(FINE) || granted(COARSE)
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
- private fun permission(): JSONObject {
141
- val inUse = if (hasForegroundPermission()) "granted" else state(COARSE)
142
- val background = if (BACKGROUND in declared) state(BACKGROUND) else "denied"
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 JSONObject()
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 [options] ask for it, as
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
- private fun requestPermission(options: JSONObject, reply: TokamakPluginReply) {
162
- val background = options.optBoolean("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 (options.optionalString("precision") == "approximate") setOf(COARSE) else foreground
167
- askForeground(asked) { allowed ->
168
- if (!background || !allowed) return@askForeground reply(Result.success(permission()))
169
- runCatching { host.requestPermissions(setOf(BACKGROUND)) { reply(Result.success(permission())) } }
170
- .onFailure { reply(Result.failure(it)) }
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
- private fun requirePermission() {
175
- if (!hasForegroundPermission()) throw TokamakPluginError("NotAllowedError", "Location permission has not been granted")
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
- private fun startUpdates(options: JSONObject, reply: TokamakPluginReply) {
122
+ fun startUpdates(options: UpdateOptions?) {
123
+ val running = options ?: UpdateOptions(null, null, null, null)
180
124
  requirePermission()
181
- val notification = options.optJSONObject("foregroundService")
182
- if (notification != null) requireService()
183
- runCatching {
184
- if (notification == null) TokamakLocationService.stop(context) else TokamakLocationService.start(context, notification)
185
- updates.start(options)
186
- }.onSuccess { reply(Result.success(null)) }
187
- .onFailure {
188
- stopUpdates()
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
- private fun stopUpdates() {
135
+ fun stopUpdates() {
194
136
  updates.stop()
195
137
  TokamakLocationService.stop(context)
196
138
  }
197
139
 
198
- private fun startService() {
199
- updates.options?.optJSONObject("foregroundService")?.let { TokamakLocationService.start(context, it) }
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
- * Starts significant changes when [config] names them, and stops them otherwise. Without
204
- * permission they start once the app has it.
205
- */
206
- private fun setup(config: JSONObject, reply: TokamakPluginReply) {
207
- val options = JSONObject(SIGNIFICANT_CHANGE_OPTIONS)
208
- runCatching {
209
- when {
210
- !config.optBoolean("significantChanges") -> significantChanges.stop()
211
- hasForegroundPermission() -> significantChanges.start(options)
212
- else -> significantChanges.keep(options)
213
- }
214
- }.onSuccess { reply(Result.success(null)) }
215
- .onFailure { reply(Result.failure(locationError(it))) }
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(::position), finish)
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 (!region.optBoolean(if (entered) "onEntry" else "onExit", true)) return finish()
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
- private fun openSettings(reply: TokamakPluginReply) {
280
- val app = Uri.fromParts("package", context.packageName, null)
281
- host.requireForegroundActivity().startActivity(Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS, app))
282
- reply(Result.success(null))
283
- }
284
-
285
- private fun position(location: Location): JSONObject =
286
- JSONObject()
287
- .put(
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
- const val SIGNIFICANT_CHANGE_OPTIONS = """{"accuracy":"low","interval":300000,"distance":500}"""
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
- /** Passes [found] a new fix, or null when Android finds none, and returns the cancellation. */
22
- fun current(found: (Location?) -> Unit): () -> Unit {
23
- val signal = CancellationSignal()
24
- manager.getCurrentLocation(provider(), highAccuracy(0), signal, context.mainExecutor) { found(it) }
25
- return signal::cancel
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
- override fun onProviderDisabled(provider: String) = disabled()
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, as `addRegion` received it, by id. */
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<JSONObject> = preferences.all.values.map { JSONObject(it as String) }
21
+ fun all(): List<Region> = preferences.all.values.map { Region.from(it as String) }
23
22
 
24
- fun get(id: String): JSONObject? = preferences.getString(id, null)?.let(::JSONObject)
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: JSONObject) {
31
- val id = region.getString("id")
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
- 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()
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.getString("id"), PendingIntent.FLAG_NO_CREATE) == null }.forEach(::add)
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(::JSONObject)
29
- ?: LocationUpdates(this, LocationUpdates.UPDATES).options?.optJSONObject("foregroundService")
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: JSONObject): Notification {
40
- val channel = NotificationChannel(CHANNEL, content.getString("channel"), NotificationManager.IMPORTANCE_LOW)
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.getString("title"))
49
- .setContentText(content.getString("body"))
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 notification, as JSON. */
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 [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())
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
+ )