neiam
/apollos_crib
It's Apollo's Crib
public · Issues · Pulls 1 · Labels · Forks · Compare · Actions failure · Badges · Packages
⭐
Log in to mark this repository.
Report where a client is, if it is asked to
fb8519f · Gabriel Morell · 2026-09-03 16:01
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