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.
Open on the board, not on the form
fb93a66 · Gabriel Morell · 2026-09-02 19:54
Message
{commit_body(@commit)}
Files changed
modified
clients/android/README.md
+6
−0
| 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 | 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 | 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 | 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 | 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 | 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 | 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 | 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 | 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