Report where a client is, if it is asked to

fb8519f · Gabriel Morell · 2026-09-03 16:01

19 files +316 -1
Message
{commit_body(@commit)}

Files changed

modified clients/android/app/src/main/AndroidManifest.xml
+8 −1
@@ -10,6 +10,13 @@
10 10 <uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
11 11 <uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
12 12
13 + <!-- Only for the optional uplink: a client that reports where it is, for a
14 + foci that travels with it. Off by default, and never asked for until
15 + somebody turns it on — most of these are on a wall. -->
16 + <uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
17 + <uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
18 + <uses-permission android:name="android.permission.FOREGROUND_SERVICE_LOCATION" />
19 +
13 20 <application
14 21 android:allowBackup="true"
15 22 android:icon="@mipmap/ic_launcher"
@@ -49,7 +56,7 @@
49 56 <service
50 57 android:name=".mqtt.AnkyraService"
51 58 android:exported="false"
52 − android:foregroundServiceType="dataSync" />
59 + android:foregroundServiceType="dataSync|location" />
53 60
54 61 <!-- The board on the home screen, a card at a time. -->
55 62 <receiver
modified clients/android/app/src/main/java/io/neiam/apolloscrib/data/Settings.kt
+13 −0
@@ -38,6 +38,18 @@ class Settings(context: Context) {
38 38 get() = prefs.getString(KEY_TOPIC, "").orEmpty()
39 39 set(value) = prefs.edit { putString(KEY_TOPIC, value.trim()) }
40 40
41 + /**
42 + * Whether this client reports where it is.
43 + *
44 + * Off, and stays off until somebody turns it on. Most of these are on a
45 + * wall showing a fixed vision and have no business asking for a location
46 + * permission, let alone publishing one -- so nothing about location
47 + * happens, including the permission request, until this is true.
48 + */
49 + var publishLocation: Boolean
50 + get() = prefs.getBoolean(KEY_PUBLISH_LOCATION, false)
51 + set(value) = prefs.edit { putBoolean(KEY_PUBLISH_LOCATION, value) }
52 +
41 53 /**
42 54 * Which of the ported palettes the app's own screens use. Not a
43 55 * light/dark switch: these are the same named themes the Scribus routes
@@ -112,6 +124,7 @@ class Settings(context: Context) {
112 124 private const val KEY_TLS = "tls"
113 125 private const val KEY_THEME = "theme"
114 126 private const val KEY_HAS_CONNECTED = "has_connected"
127 + private const val KEY_PUBLISH_LOCATION = "publish_location"
115 128 private const val KEY_CLIENT_ID = "client_id"
116 129 private const val KEY_BINDING_PREFIX = "binding_"
117 130 private const val KEY_DISMISSED = "dismissed"
modified clients/android/app/src/main/java/io/neiam/apolloscrib/mqtt/AnkyraClient.kt
+20 −0
@@ -160,6 +160,26 @@ class AnkyraClient(
160 160 }
161 161 }
162 162
163 + /**
164 + * Send something back to the Ankyra.
165 + *
166 + * Only under the uplink prefix: the broker refuses anything above it, and
167 + * rightly -- a client writing the board topic would be believed by every
168 + * other client subscribed to it.
169 + */
170 + fun publish(topic: String, payload: String, onResult: (Boolean) -> Unit = {}) {
171 + val live = client ?: return onResult(false)
172 + live.publishWith()
173 + .topic(topic)
174 + .payload(payload.toByteArray(StandardCharsets.UTF_8))
175 + .qos(MqttQos.AT_LEAST_ONCE)
176 + .send()
177 + .whenComplete { _, error ->
178 + if (error != null) Log.w(TAG, "publish to $topic failed", error)
179 + onResult(error == null)
180 + }
181 + }
182 +
163 183 fun disconnect() {
164 184 // Retires the current attempt, so whatever the old client says on its
165 185 // way out lands after its own generation has passed and is ignored.
modified clients/android/app/src/main/java/io/neiam/apolloscrib/mqtt/AnkyraService.kt
+16 −0
@@ -14,6 +14,8 @@ import android.util.Log
14 14 import io.neiam.apolloscrib.R
15 15 import io.neiam.apolloscrib.data.Settings
16 16 import io.neiam.apolloscrib.data.VisionStore
17 +import kotlinx.serialization.encodeToString
18 +import kotlinx.serialization.json.Json
17 19 import io.neiam.apolloscrib.targets.Targets
18 20 import io.neiam.apolloscrib.widget.BoardWidget
19 21 import io.neiam.apolloscrib.ui.MainActivity
@@ -33,11 +35,21 @@ class AnkyraService : Service() {
33 35 private lateinit var settings: Settings
34 36 private lateinit var store: VisionStore
35 37 private var client: AnkyraClient? = null
38 + private var location: LocationReporter? = null
36 39
37 40 override fun onCreate() {
38 41 super.onCreate()
39 42 settings = Settings(this)
40 43 store = VisionStore(this)
44 + location = LocationReporter(this, settings) { uplink ->
45 + client?.publish(
46 + topic = LocationUplink.topicFor(settings.topic),
47 + payload = Json.encodeToString(uplink),
48 + onResult = { ok ->
49 + if (ok) location?.onPublishSucceeded() else location?.onPublishFailed()
50 + }
51 + )
52 + }
41 53 createChannel()
42 54 }
43 55
@@ -56,6 +68,9 @@ class AnkyraService : Service() {
56 68 onState = ::onState
57 69 ).also { it.connect() }
58 70 }
71 + // Whether this reports its location is a setting, so every start is a
72 + // chance for it to have changed.
73 + location?.sync()
59 74 // Sticky: if Android kills this for memory, the board should come back
60 75 // when there is room again, without the user opening the app.
61 76 return START_STICKY
@@ -130,6 +145,7 @@ class AnkyraService : Service() {
130 145 }
131 146
132 147 override fun onDestroy() {
148 + location?.shutDown()
133 149 client?.disconnect()
134 150 client = null
135 151 super.onDestroy()
added clients/android/app/src/main/java/io/neiam/apolloscrib/mqtt/LocationReporter.kt
+163 −0
@@ -0,0 +1,163 @@
1 +package io.neiam.apolloscrib.mqtt
2 +
3 +import android.Manifest
4 +import android.content.Context
5 +import android.content.pm.PackageManager
6 +import android.location.Location
7 +import android.location.LocationListener
8 +import android.location.LocationManager
9 +import android.os.Build
10 +import android.util.Log
11 +import androidx.core.content.ContextCompat
12 +import io.neiam.apolloscrib.data.Settings
13 +
14 +/**
15 + * Reports where this client is, when it has been asked to.
16 + *
17 + * Opt-in and silent otherwise: most of these are on a wall showing a fixed
18 + * vision, and have no business holding a location permission. Nothing here
19 + * runs -- no permission checked, no provider registered -- unless
20 + * [Settings.publishLocation] is on.
21 + *
22 + * The system's own provider rather than Play Services: this app has no other
23 + * dependency on Google's libraries and a fix good enough to say which stop you
24 + * are near does not need one.
25 + */
26 +class LocationReporter(
27 + private val context: Context,
28 + private val settings: Settings,
29 + private val publish: (LocationUplink) -> Unit
30 +) {
31 +
32 + private val manager =
33 + context.getSystemService(Context.LOCATION_SERVICE) as? LocationManager
34 +
35 + private var listening = false
36 +
37 + /**
38 + * Set when the broker will not take the uplink.
39 + *
40 + * A client that publishes where it has no write permission is not sent an
41 + * error -- RabbitMQ closes the connection. So a client reporting into a
42 + * server that has not been upgraded loses its board every couple of
43 + * minutes: connect, publish, disconnected, reconnect, publish. Giving up
44 + * on the uplink keeps the board, which is what the app is for.
45 + */
46 + @Volatile
47 + private var blocked = false
48 +
49 + private var failures = 0
50 +
51 + private val listener = LocationListener { location -> report(location) }
52 +
53 + /** True when the user has asked for this and Android has allowed it. */
54 + fun canReport(): Boolean =
55 + settings.publishLocation && !blocked && hasPermission()
56 +
57 + /**
58 + * Told when a publish did not land. A few in a row means the server will
59 + * not take this at all, rather than the network having been away.
60 + */
61 + fun onPublishFailed() {
62 + failures += 1
63 + if (failures >= GIVE_UP_AFTER && !blocked) {
64 + blocked = true
65 + Log.w(
66 + TAG,
67 + "giving up on the uplink after $failures failures: the board matters more"
68 + )
69 + stop()
70 + }
71 + }
72 +
73 + fun onPublishSucceeded() {
74 + failures = 0
75 + }
76 +
77 + /** Turning the setting off and on again is a deliberate retry. */
78 + fun retry() {
79 + blocked = false
80 + failures = 0
81 + }
82 +
83 + private fun hasPermission(): Boolean =
84 + ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_FINE_LOCATION) ==
85 + PackageManager.PERMISSION_GRANTED ||
86 + ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_COARSE_LOCATION) ==
87 + PackageManager.PERMISSION_GRANTED
88 +
89 + /**
90 + * Start, stop, or leave alone, according to the setting. Called whenever
91 + * either the setting or the connection changes, so there is one place that
92 + * decides whether the provider should be running.
93 + */
94 + fun sync() {
95 + if (canReport()) start() else stop()
96 + }
97 +
98 + /** Called when the service goes away; there is nothing left to report to. */
99 + fun shutDown() = stop()
100 +
101 + private fun start() {
102 + if (listening) return
103 + val manager = manager ?: return
104 +
105 + val provider = when {
106 + Build.VERSION.SDK_INT >= Build.VERSION_CODES.S &&
107 + manager.allProviders.contains(LocationManager.FUSED_PROVIDER) ->
108 + LocationManager.FUSED_PROVIDER
109 + manager.allProviders.contains(LocationManager.NETWORK_PROVIDER) ->
110 + LocationManager.NETWORK_PROVIDER
111 + else -> LocationManager.GPS_PROVIDER
112 + }
113 +
114 + runCatching {
115 + manager.requestLocationUpdates(
116 + provider,
117 + MIN_INTERVAL_MS,
118 + MIN_DISTANCE_M,
119 + listener,
120 + context.mainLooper
121 + )
122 + listening = true
123 + Log.d(TAG, "reporting location from $provider")
124 + // The last known fix rather than waiting out the first interval:
125 + // a foci that travels is most wrong right after it is turned on.
126 + manager.getLastKnownLocation(provider)?.let(::report)
127 + }.onFailure { Log.w(TAG, "could not start location updates", it) }
128 + }
129 +
130 + private fun stop() {
131 + if (!listening) return
132 + runCatching { manager?.removeUpdates(listener) }
133 + listening = false
134 + Log.d(TAG, "stopped reporting location")
135 + }
136 +
137 + private fun report(location: Location) {
138 + // Checked again at the moment of sending: the setting can be turned
139 + // off between a fix being requested and arriving, and that fix should
140 + // not be the one that gets out.
141 + if (!canReport()) return
142 + publish(LocationUplink.from(location, settings.clientId))
143 + }
144 +
145 + companion object {
146 + private const val TAG = "LocationReporter"
147 +
148 + /**
149 + * Two minutes and a hundred metres, whichever the provider reaches
150 + * first. A foci that travels is answering "which stop am I near",
151 + * which does not change at walking pace within a block -- and the
152 + * cost of asking more often is the phone's battery.
153 + */
154 + private const val MIN_INTERVAL_MS = 2 * 60 * 1000L
155 + private const val MIN_DISTANCE_M = 100f
156 +
157 + /**
158 + * Three refusals is a server that does not want this, not a network
159 + * that was briefly away.
160 + */
161 + private const val GIVE_UP_AFTER = 3
162 + }
163 +}
added clients/android/app/src/main/java/io/neiam/apolloscrib/mqtt/LocationUplink.kt
+54 −0
@@ -0,0 +1,54 @@
1 +package io.neiam.apolloscrib.mqtt
2 +
3 +import android.location.Location
4 +import kotlinx.serialization.SerialName
5 +import kotlinx.serialization.Serializable
6 +import java.time.Instant
7 +
8 +/**
9 + * Where a client is, published back to its Ankyra.
10 + *
11 + * The point of it is a foci that travels: a vision anchored to the phone
12 + * rather than to a place, so the stops it shows are the ones you are near.
13 + * Nothing consumes this yet -- the Plani the README describes is not built --
14 + * so this is the half that can exist on its own, and the shape the other half
15 + * would read.
16 + *
17 + * Field names match the condensed data the other direction uses: short, flat,
18 + * and no more precision than is meant. When it is consumed, this belongs in
19 + * the apollos-types crate alongside the rest of the wire format.
20 + */
21 +@Serializable
22 +data class LocationUplink(
23 + val lat: Double,
24 + val lon: Double,
25 + /** Metres, as the provider reports it; a fix worse than this is not a fix. */
26 + @SerialName("accuracy_m") val accuracyM: Float? = null,
27 + /** Metres per second, where the provider has one. */
28 + @SerialName("speed_mps") val speedMps: Float? = null,
29 + /** Degrees from true north, where the provider has one. */
30 + @SerialName("heading_deg") val headingDeg: Float? = null,
31 + /** When the fix was taken, not when it was sent. */
32 + val at: String,
33 + /** Which client this is, so an Ankyra with two can tell them apart. */
34 + @SerialName("client_id") val clientId: String
35 +) {
36 + companion object {
37 + /**
38 + * The uplink's own topic, under the prefix the broker allows a client
39 + * to write. See `RoomHermesWeb.TopicController.uplink_prefix/1`: a
40 + * client may write `<topic>.up.` and below, and nothing above it.
41 + */
42 + fun topicFor(ankyraTopic: String): String = "$ankyraTopic.up.loc"
43 +
44 + fun from(location: Location, clientId: String): LocationUplink = LocationUplink(
45 + lat = location.latitude,
46 + lon = location.longitude,
47 + accuracyM = location.accuracy.takeIf { location.hasAccuracy() },
48 + speedMps = location.speed.takeIf { location.hasSpeed() },
49 + headingDeg = location.bearing.takeIf { location.hasBearing() },
50 + at = Instant.ofEpochMilli(location.time).toString(),
51 + clientId = clientId
52 + )
53 + }
54 +}
modified clients/android/app/src/main/java/io/neiam/apolloscrib/ui/MainActivity.kt
+42 −0
@@ -172,6 +172,8 @@ private fun ConnectionScreen(
172 172 ActivityResultContracts.RequestPermission()
173 173 ) { }
174 174
175 + val context = LocalContext.current
176 +
175 177 Column(
176 178 modifier = modifier.verticalScroll(rememberScrollState()).padding(16.dp),
177 179 verticalArrangement = Arrangement.spacedBy(12.dp)
@@ -226,6 +228,46 @@ private fun ConnectionScreen(
226 228 Switch(checked = tls, onCheckedChange = { tls = it })
227 229 }
228 230
231 + // Opt-in, and the permission is asked for only when it is switched
232 + // on: a client on a wall showing a fixed vision has no business
233 + // holding one.
234 + var reportLocation by remember(pairingKey) { mutableStateOf(settings.publishLocation) }
235 + val locationPermission = rememberLauncherForActivityResult(
236 + ActivityResultContracts.RequestMultiplePermissions()
237 + ) { granted ->
238 + val allowed = granted.values.any { it }
239 + settings.publishLocation = allowed
240 + reportLocation = allowed
241 + AnkyraService.start(context)
242 + }
243 +
244 + Column {
245 + Text("Report this client's location", style = MaterialTheme.typography.bodyLarge)
246 + Text(
247 + "Publishes where this device is to the Ankyra, for a focus that " +
248 + "travels with it. Off unless you turn it on.",
249 + style = MaterialTheme.typography.bodySmall,
250 + color = LocalAppTheme.current.dim
251 + )
252 + Switch(
253 + checked = reportLocation,
254 + onCheckedChange = { wanted ->
255 + if (wanted) {
256 + locationPermission.launch(
257 + arrayOf(
258 + Manifest.permission.ACCESS_COARSE_LOCATION,
259 + Manifest.permission.ACCESS_FINE_LOCATION
260 + )
261 + )
262 + } else {
263 + settings.publishLocation = false
264 + reportLocation = false
265 + AnkyraService.start(context)
266 + }
267 + }
268 + )
269 + }
270 +
229 271 Text("Client id: ${settings.clientId}", style = MaterialTheme.typography.bodySmall)
230 272 // Only of interest to somebody wiring this up as a launcher feed, but
231 273 // that is exactly who cannot get it any other way.

Parents: e8ac20e