Open on the board, not on the form

fb93a66 · Gabriel Morell · 2026-09-02 19:54

38 files +661 -152
Message
{commit_body(@commit)}

Files changed

modified clients/android/README.md
+6 −0
@@ -39,6 +39,12 @@ stops, each choosing its own query when Smartspacer opens the setup screen.
39 39 they arrive.
40 40 4. In Smartspacer: Targets, add, Apollo's Crib. Pick the query the target shows.
41 41
42 +Once there is a working connection the app opens on the board rather than the
43 +form: every query in the vision, drawn from the same description its Smartspace
44 +card is built from, updating as payloads land. What is on that screen is what a
45 +Target added for that query will say. The connection is behind *Connection* in
46 +the corner, and reconnects by itself on launch once it has worked once.
47 +
42 48 The connection is a foreground service with an ongoing notification. That is
43 49 what an always-open socket costs on Android; the alternative is polling, which
44 50 this deliberately does not do.
modified clients/android/app/src/main/java/io/neiam/apolloscrib/data/Settings.kt
+13 −0
@@ -64,6 +64,18 @@ class Settings(context: Context) {
64 64 val isConfigured: Boolean
65 65 get() = host.isNotEmpty() && topic.isNotEmpty()
66 66
67 + /**
68 + * Whether this connection has ever actually worked.
69 + *
70 + * Set the first time a subscription lands, and the licence to reconnect
71 + * without being asked. Configured is not the same thing: a typed-in host
72 + * that has never answered should not have the app dialling it on every
73 + * launch.
74 + */
75 + var hasConnected: Boolean
76 + get() = prefs.getBoolean(KEY_HAS_CONNECTED, false)
77 + set(value) = prefs.edit { putBoolean(KEY_HAS_CONNECTED, value) }
78 +
67 79 /**
68 80 * The entry key (`"gtfs-12"`) a given Target instance shows, chosen in the
69 81 * configuration activity Smartspacer opens. Null means "not chosen yet",
@@ -99,6 +111,7 @@ class Settings(context: Context) {
99 111 private const val KEY_TOPIC = "topic"
100 112 private const val KEY_TLS = "tls"
101 113 private const val KEY_THEME = "theme"
114 + private const val KEY_HAS_CONNECTED = "has_connected"
102 115 private const val KEY_CLIENT_ID = "client_id"
103 116 private const val KEY_BINDING_PREFIX = "binding_"
104 117 private const val KEY_DISMISSED = "dismissed"
modified clients/android/app/src/main/java/io/neiam/apolloscrib/data/VisionStore.kt
+6 −0
@@ -43,6 +43,12 @@ class VisionStore(context: Context) {
43 43
44 44 fun entry(key: String): VisionEntry? = entries().firstOrNull { it.key == key }
45 45
46 + /** Source types on the board that this build cannot draw. */
47 + fun unsupported(): List<String> {
48 + val payload = runCatching { file.readText() }.getOrNull() ?: return emptyList()
49 + return runCatching { Wire.unsupported(payload) }.getOrDefault(emptyList())
50 + }
51 +
46 52 /** When the stored payload last arrived, or null if nothing ever has. */
47 53 fun lastUpdated(): Long? = file.takeIf { it.exists() }?.lastModified()
48 54
modified clients/android/app/src/main/java/io/neiam/apolloscrib/mqtt/AnkyraClient.kt
+20 −2
@@ -43,6 +43,22 @@ class AnkyraClient(
43 43 .serverHost(settings.host)
44 44 .serverPort(settings.port)
45 45 .automaticReconnectWithDefaultConfig()
46 + // The connection outlives the call that opened it: HiveMQ
47 + // reconnects on its own, and a state read only from the callbacks
48 + // below would still be saying "not connected" while payloads
49 + // arrived. These follow the connection itself.
50 + .addConnectedListener {
51 + // Subscribing again on every connect rather than trusting the
52 + // reconnect to carry the old one. A repeat subscription to the
53 + // same filter replaces it at the broker, so this costs nothing
54 + // and removes the case where we are connected and deaf.
55 + client?.let { subscribe(it) }
56 + }
57 + .addDisconnectedListener { context ->
58 + onState(
59 + if (context.reconnector.isReconnect) State.Connecting else State.Disconnected
60 + )
61 + }
46 62 .apply { if (settings.useTls) sslWithDefaultConfig() }
47 63 .buildAsync()
48 64 client = built
@@ -65,11 +81,13 @@ class AnkyraClient(
65 81 .send()
66 82 .whenComplete { _, error ->
67 83 if (error != null) {
84 + // Not fatal: the reconnector keeps trying, and the
85 + // listeners above will say so when one lands.
68 86 Log.w(TAG, "connect failed", error)
69 87 onState(State.Failed)
70 − } else {
71 − subscribe(built)
72 88 }
89 + // The success path is the connected listener's -- it also runs
90 + // for every reconnect after this one.
73 91 }
74 92 }
75 93
modified clients/android/app/src/main/java/io/neiam/apolloscrib/mqtt/AnkyraService.kt
+17 −2
@@ -53,9 +53,8 @@ class AnkyraService : Service() {
53 53 settings = settings,
54 54 onMessage = ::onPayload,
55 55 onState = ::onState
56 − )
56 + ).also { it.connect() }
57 57 }
58 − client?.connect()
59 58 // Sticky: if Android kills this for memory, the board should come back
60 59 // when there is room again, without the user opening the app.
61 60 return START_STICKY
@@ -72,6 +71,9 @@ class AnkyraService : Service() {
72 71
73 72 private fun onState(newState: AnkyraClient.State) {
74 73 state.value = newState
74 + // Proof the settings are right, which is what licenses reconnecting on
75 + // a later launch without asking.
76 + if (newState == AnkyraClient.State.Connected) settings.hasConnected = true
75 77 // The notification is the only place a connection problem is visible,
76 78 // since the Targets themselves fall back to the stored board.
77 79 runCatching {
@@ -148,6 +150,19 @@ class AnkyraService : Service() {
148 150 context.startForegroundService(Intent(context, AnkyraService::class.java))
149 151 }
150 152
153 + /**
154 + * Reconnect on launch, if this connection has worked before.
155 + *
156 + * START_STICKY covers the service being killed while the phone stays
157 + * up; this covers everything else -- a reboot the receiver missed, a
158 + * force-stop, an app update. Cheap when the service is already
159 + * running: [onStartCommand] finds a live client and leaves it alone.
160 + */
161 + fun resume(context: Context) {
162 + val settings = Settings(context)
163 + if (settings.isConfigured && settings.hasConnected) start(context)
164 + }
165 +
151 166 fun stop(context: Context) {
152 167 context.startService(
153 168 Intent(context, AnkyraService::class.java).setAction(ACTION_STOP)
modified clients/android/app/src/main/java/io/neiam/apolloscrib/targets/ApollosTargetProvider.kt
+8 −5

Click to load diff…

modified clients/android/app/src/main/java/io/neiam/apolloscrib/targets/GbfsRenderer.kt
+4 −5

Click to load diff…

modified clients/android/app/src/main/java/io/neiam/apolloscrib/targets/GtfsRenderer.kt
+14 −31

Click to load diff…

modified clients/android/app/src/main/java/io/neiam/apolloscrib/targets/Renderers.kt
+62 −43

Click to load diff…

modified clients/android/app/src/main/java/io/neiam/apolloscrib/targets/WeatherRenderer.kt
+4 −4

Click to load diff…

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

Click to load diff…

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

Click to load diff…

modified clients/android/app/src/main/java/io/neiam/apolloscrib/ui/MainActivity.kt
+40 −60

Click to load diff…

added clients/android/app/src/test/java/io/neiam/apolloscrib/targets/RendererTest.kt
+164 −0

Click to load diff…

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

Click to load diff…

added clients/android/app/src/test/resources/prod_board.json
+1 −0

Click to load diff…

Parents: e1827fb