Put a vision on an Android home screen

04f2bcb · Gabriel Morell · 2026-09-02 18:05

75 files +2614 -0
Message
{commit_body(@commit)}

Files changed

modified README.md
+12 −0
@@ -22,6 +22,18 @@ Visions are interpreted by the Pythiae who forward them to clients via Ankyra.
22 22 Foci are anchors for querying in physical space
23 23 Plani can be configured to provide the equivalent to an unanchored Foci updated via Ankyra
24 24
25 +## Clients
26 +
27 +Ankyra clients subscribe to a rabbit user's MQTT topic and draw whatever the
28 +Pythiae publishes to it.
29 +
30 +- [`clients/lilygo4.7`](clients/lilygo4.7) — a LilyGo 4.7" e-paper panel, MicroPython
31 +- [`clients/android`](clients/android) — a [Smartspacer](https://github.com/KieronQuinn/Smartspacer)
32 + plugin putting a vision on the Android home and lock screen
33 +
34 +[`apollos-types`](https://github.com/neiam/apollos-types) is the source of truth
35 +for the published wire format.
36 +
25 37 ## Public Demo
26 38
27 39 The public demo instance https://ac.gmp.io/ is here, and the landing data displays queries for various entities around
added clients/android/.gitignore
+10 −0
@@ -0,0 +1,10 @@
1 +*.iml
2 +.gradle/
3 +local.properties
4 +.idea/
5 +.DS_Store
6 +build/
7 +captures/
8 +.externalNativeBuild
9 +.cxx
10 +*.apk
added clients/android/README.md
+93 −0
@@ -0,0 +1,93 @@
1 +# Apollo's Crib for Android
2 +
3 +A [Smartspacer](https://github.com/KieronQuinn/Smartspacer) plugin that puts a
4 +vision's queries on the Android home and lock screen.
5 +
6 +It is an Ankyra client, the same as `clients/lilygo4.7`: it subscribes to a
7 +rabbit user's MQTT topic, keeps the last board it heard on disk, and draws it
8 +as Smartspacer Targets. Nothing is polled and no HTTP endpoint is involved --
9 +the Pythiae publishes on change and the phone redraws when it does.
10 +
11 +## What it shows
12 +
13 +| Target | Source | What it draws |
14 +| --- | --- | --- |
15 +| Transit | `gtfs` | The next departures from a stop, soonest route first, with a separate target for a severe or stop-specific alert |
16 +| Bikeshare | `gbfs` | Bikes and docks at a station, or loose bikes for an area query |
17 +| Weather | `weather` | Current conditions at a focus |
18 +
19 +Each is added once per query: add the Transit target twice and you get two
20 +stops, each choosing its own query when Smartspacer opens the setup screen.
21 +
22 +## Setting it up
23 +
24 +1. In Apollo's Crib, go to `cfg/ankyra` and note the rabbit user's **username**,
25 + **password** and **topic**. Leave auto-registration on, or add the client id
26 + the app shows you to that Ankyra's client ids -- the broker will refuse the
27 + connection otherwise, and Ankyra counts its consumers by that id.
28 +2. Point a Pythiae at the vision you want and give it that Ankyra.
29 +3. Install this app, fill in the broker host, port (1883 for the RabbitMQ MQTT
30 + plugin) and those credentials, and connect. The screen shows the queries as
31 + they arrive.
32 +4. In Smartspacer: Targets, add, Apollo's Crib. Pick the query the target shows.
33 +
34 +The connection is a foreground service with an ongoing notification. That is
35 +what an always-open socket costs on Android; the alternative is polling, which
36 +this deliberately does not do.
37 +
38 +## Building
39 +
40 +```sh
41 +echo "sdk.dir=$ANDROID_HOME" > local.properties
42 +./gradlew :app:assembleDebug :app:testDebugUnitTest
43 +```
44 +
45 +The debug APK is large (~36 MB) because HiveMQ's MQTT client brings Netty and
46 +RxJava. Release builds have optimization switched off in `app/build.gradle.kts`
47 +for now -- turning it on wants the SDK keep rule from the Smartspacer wiki.
48 +
49 +## The wire format
50 +
51 +The payload is one JSON object per tick, keyed `"<type>-<queryId>"`:
52 +
53 +```json
54 +{
55 + "gtfs-12": {
56 + "data": [
57 + {"route": "14", "route_name": "14", "dest": "Heath St", "dir": "Inbound",
58 + "mode": "Bus", "times": ["08:00:00"], "color": "#FFC72C"}
59 + ],
60 + "query": {"name": "Forest Hills", "meta": {}}
61 + }
62 +}
63 +```
64 +
65 +A publisher that could not resolve the query sends the bare list instead of the
66 +`{data, query}` wrapper; both are read.
67 +
68 +**[`apollos-types`](https://github.com/neiam/apollos-types) is the source of
69 +truth for these shapes.** The Kotlin types in `types/` mirror that crate --
70 +when a field is added there, add it here. Unknown fields are ignored rather
71 +than fatal, so a newer publisher does not break an older phone.
72 +
73 +## Adding a source
74 +
75 +The seam is deliberate. Weather is in here as the worked example of how small
76 +it should be:
77 +
78 +1. A type in `types/`, mirroring the crate's struct.
79 +2. An entry in `SourceType`.
80 +3. A `SourceRenderer` in `targets/`, which is the only place that decides what
81 + the thing looks like.
82 +4. A four-line `ApollosTargetProvider` subclass, added to `Targets`.
83 +5. A `<provider>` in the manifest with its own authority.
84 +
85 +Everything else -- the subscription, the store, staleness, dismissal, binding a
86 +target to a query -- is already handled and does not need touching.
87 +
88 +## Look
89 +
90 +The palette and typography are lifted from the DMS Android app: the same OKLCh
91 +themes ("Her", "After Dark", "Forest" ...) that the Scribus public routes render
92 +under, and B612 for text. The Smartspace targets themselves are drawn by the
93 +launcher and are not ours to type.
added clients/android/app/build.gradle.kts
+64 −0
@@ -0,0 +1,64 @@
1 +plugins {
2 + alias(libs.plugins.android.application)
3 + alias(libs.plugins.kotlin.compose)
4 + alias(libs.plugins.kotlin.serialization)
5 +}
6 +
7 +android {
8 + namespace = "io.neiam.apolloscrib"
9 + compileSdk {
10 + version = release(36) {
11 + minorApiLevel = 1
12 + }
13 + }
14 +
15 + defaultConfig {
16 + applicationId = "io.neiam.apolloscrib"
17 + // Smartspacer itself requires 12, and the foreground service type
18 + // declarations below are 14-era. 31 keeps the manifest honest.
19 + minSdk = 31
20 + targetSdk = 36
21 + versionCode = 1
22 + versionName = "0.1.0"
23 + }
24 +
25 + buildTypes {
26 + release {
27 + optimization {
28 + enable = false
29 + }
30 + }
31 + }
32 + compileOptions {
33 + sourceCompatibility = JavaVersion.VERSION_17
34 + targetCompatibility = JavaVersion.VERSION_17
35 + }
36 + buildFeatures {
37 + compose = true
38 + }
39 + packaging {
40 + resources {
41 + excludes += "META-INF/INDEX.LIST"
42 + excludes += "META-INF/io.netty.versions.properties"
43 + }
44 + }
45 +}
46 +
47 +dependencies {
48 + implementation(libs.smartspacer.sdk.plugin)
49 + implementation(libs.hivemq.mqtt.client)
50 + implementation(libs.kotlinx.serialization.json)
51 +
52 + implementation(platform(libs.androidx.compose.bom))
53 + implementation(libs.androidx.activity.compose)
54 + implementation(libs.androidx.compose.material3)
55 + implementation(libs.androidx.compose.ui)
56 + implementation(libs.androidx.compose.ui.tooling.preview)
57 + implementation(libs.androidx.core.ktx)
58 + implementation(libs.androidx.lifecycle.runtime.ktx)
59 + implementation(libs.androidx.lifecycle.runtime.compose)
60 +
61 + debugImplementation(libs.androidx.compose.ui.tooling)
62 + testImplementation(libs.junit)
63 + testImplementation(libs.kotlinx.serialization.json)
64 +}
added clients/android/app/src/main/AndroidManifest.xml
+86 −0
@@ -0,0 +1,86 @@
1 +<?xml version="1.0" encoding="utf-8"?>
2 +<manifest xmlns:android="http://schemas.android.com/apk/res/android">
3 +
4 + <uses-permission android:name="android.permission.INTERNET" />
5 + <uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
6 + <!-- The Ankyra subscription is a socket held open while nothing else in
7 + this app is running, which on modern Android is a foreground service. -->
8 + <uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
9 + <uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
10 + <uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
11 + <uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
12 +
13 + <application
14 + android:allowBackup="true"
15 + android:icon="@mipmap/ic_launcher"
16 + android:label="@string/app_name"
17 + android:supportsRtl="true"
18 + android:theme="@style/Theme.ApollosCrib">
19 +
20 + <activity
21 + android:name=".ui.MainActivity"
22 + android:exported="true"
23 + android:label="@string/app_name">
24 + <intent-filter>
25 + <action android:name="android.intent.action.MAIN" />
26 + <category android:name="android.intent.category.LAUNCHER" />
27 + </intent-filter>
28 + </activity>
29 +
30 + <!-- Opened by Smartspacer as both the setup and the config activity for
31 + every one of the Targets below; which query it binds comes in as an
32 + extra. Exported because Smartspacer is the one launching it. -->
33 + <activity
34 + android:name=".ui.ConfigurationActivity"
35 + android:exported="true"
36 + android:label="@string/configure" />
37 +
38 + <service
39 + android:name=".mqtt.AnkyraService"
40 + android:exported="false"
41 + android:foregroundServiceType="dataSync" />
42 +
43 + <receiver
44 + android:name=".mqtt.BootReceiver"
45 + android:exported="true">
46 + <intent-filter>
47 + <action android:name="android.intent.action.BOOT_COMPLETED" />
48 + </intent-filter>
49 + </receiver>
50 +
51 + <!-- One provider per source type. Smartspacer keys everything off the
52 + authority, so separate authorities are what let a user add transit
53 + and bikeshare independently. All three must be exported, with this
54 + action and this permission, or Smartspacer will not see them. -->
55 + <provider
56 + android:name=".targets.GtfsTargetProvider"
57 + android:authorities="${applicationId}.target.gtfs"
58 + android:permission="com.kieronquinn.app.smartspacer.permission.ACCESS_SMARTSPACER_TARGETS"
59 + android:exported="true">
60 + <intent-filter>
61 + <action android:name="com.kieronquinn.app.smartspacer.TARGET" />
62 + </intent-filter>
63 + </provider>
64 +
65 + <provider
66 + android:name=".targets.GbfsTargetProvider"
67 + android:authorities="${applicationId}.target.gbfs"
68 + android:permission="com.kieronquinn.app.smartspacer.permission.ACCESS_SMARTSPACER_TARGETS"
69 + android:exported="true">
70 + <intent-filter>
71 + <action android:name="com.kieronquinn.app.smartspacer.TARGET" />
72 + </intent-filter>
73 + </provider>
74 +
75 + <provider
76 + android:name=".targets.WeatherTargetProvider"
77 + android:authorities="${applicationId}.target.weather"
78 + android:permission="com.kieronquinn.app.smartspacer.permission.ACCESS_SMARTSPACER_TARGETS"
79 + android:exported="true">
80 + <intent-filter>
81 + <action android:name="com.kieronquinn.app.smartspacer.TARGET" />
82 + </intent-filter>
83 + </provider>
84 +
85 + </application>
86 +</manifest>
added clients/android/app/src/main/java/io/neiam/apolloscrib/data/Settings.kt
+106 −0

Click to load diff…

added clients/android/app/src/main/java/io/neiam/apolloscrib/data/VisionStore.kt
+57 −0

Click to load diff…

added clients/android/app/src/main/java/io/neiam/apolloscrib/mqtt/AnkyraClient.kt
+108 −0

Click to load diff…

added clients/android/app/src/main/java/io/neiam/apolloscrib/mqtt/AnkyraService.kt
+157 −0

Click to load diff…

added clients/android/app/src/main/java/io/neiam/apolloscrib/mqtt/BootReceiver.kt
+17 −0

Click to load diff…

added clients/android/app/src/main/java/io/neiam/apolloscrib/targets/ApollosTargetProvider.kt
+107 −0

Click to load diff…

added clients/android/app/src/main/java/io/neiam/apolloscrib/targets/GbfsRenderer.kt
+61 −0

Click to load diff…

added clients/android/app/src/main/java/io/neiam/apolloscrib/targets/GtfsRenderer.kt
+97 −0

Click to load diff…

added clients/android/app/src/main/java/io/neiam/apolloscrib/targets/Providers.kt
+21 −0

Click to load diff…

added clients/android/app/src/main/java/io/neiam/apolloscrib/targets/Renderers.kt
+142 −0

Click to load diff…

added clients/android/app/src/main/java/io/neiam/apolloscrib/targets/WeatherRenderer.kt
+40 −0

Click to load diff…

added clients/android/app/src/main/java/io/neiam/apolloscrib/types/Gbfs.kt
+41 −0

Click to load diff…

added clients/android/app/src/main/java/io/neiam/apolloscrib/types/Gtfs.kt
+53 −0

Click to load diff…

added clients/android/app/src/main/java/io/neiam/apolloscrib/types/Weather.kt
+35 −0

Click to load diff…

added clients/android/app/src/main/java/io/neiam/apolloscrib/types/Wire.kt
+103 −0

Click to load diff…

added clients/android/app/src/main/java/io/neiam/apolloscrib/ui/ConfigurationActivity.kt
+129 −0

Click to load diff…

added clients/android/app/src/main/java/io/neiam/apolloscrib/ui/MainActivity.kt
+252 −0

Click to load diff…

added clients/android/app/src/main/java/io/neiam/apolloscrib/ui/theme/AppTheme.kt
+152 −0

Click to load diff…

added clients/android/app/src/main/java/io/neiam/apolloscrib/ui/theme/CribTheme.kt
+34 −0

Click to load diff…

added clients/android/app/src/main/java/io/neiam/apolloscrib/ui/theme/Typography.kt
+42 −0

Click to load diff…

added clients/android/app/src/main/res/drawable/ic_alert.xml
+7 −0

Click to load diff…

added clients/android/app/src/main/res/drawable/ic_bike.xml
+7 −0

Click to load diff…

added clients/android/app/src/main/res/drawable/ic_crib.xml
+7 −0

Click to load diff…

added clients/android/app/src/main/res/drawable/ic_launcher_foreground.xml
+9 −0

Click to load diff…

added clients/android/app/src/main/res/drawable/ic_transit.xml
+7 −0

Click to load diff…

added clients/android/app/src/main/res/drawable/ic_weather.xml
+7 −0

Click to load diff…

modified ?
+0 −0

Click to load diff…

modified ?
+0 −0

Click to load diff…

added clients/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml
+5 −0

Click to load diff…

added clients/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml
+5 −0

Click to load diff…

added clients/android/app/src/main/res/values/ic_launcher_background.xml
+4 −0

Click to load diff…

added clients/android/app/src/main/res/values/strings.xml
+5 −0

Click to load diff…

added clients/android/app/src/main/res/values/themes.xml
+6 −0

Click to load diff…

added clients/android/app/src/test/java/io/neiam/apolloscrib/types/WireTest.kt
+101 −0

Click to load diff…

added clients/android/build.gradle.kts
+5 −0

Click to load diff…

added clients/android/gradle.properties
+5 −0

Click to load diff…

added clients/android/gradle/libs.versions.toml
+36 −0

Click to load diff…

modified ?
+0 −0

Click to load diff…

added clients/android/gradle/wrapper/gradle-wrapper.properties
+8 −0

Click to load diff…

added clients/android/gradlew
+251 −0

Click to load diff…

added clients/android/gradlew.bat
+94 −0

Click to load diff…

added clients/android/settings.gradle.kts
+26 −0

Click to load diff…

Parents: 86d2342