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.
Ask for a board on connecting
2effc9d · Gabriel Morell · 2026-09-03 16:22
Message
{commit_body(@commit)}
Files changed
modified
clients/android/app/src/main/java/io/neiam/apolloscrib/feed/FeedOverlayService.kt
+3
−0
@@ -72,6 +72,9 @@ class FeedOverlayService : Service() {
| 72 | 72 | // A board nobody has connected shows nothing, so the page is |
| 73 | 73 | // the moment to make sure the subscription is up. |
| 74 | 74 | AnkyraService.resume(this@FeedOverlayService) |
| 75 | + // Swiping onto the page is the same moment as opening the | |
| 76 | + // app: whatever is on it should be current. | |
| 77 | + AnkyraService.requestBoard(this@FeedOverlayService) | |
| 75 | 78 | } |
| 76 | 79 | } |
| 77 | 80 |
modified
clients/android/app/src/main/java/io/neiam/apolloscrib/mqtt/AnkyraService.kt
+78
−9
@@ -37,18 +37,25 @@ class AnkyraService : Service() {
| 37 | 37 | private var client: AnkyraClient? = null |
| 38 | 38 | private var location: LocationReporter? = null |
| 39 | 39 | |
| 40 | + /** | |
| 41 | + * Set when the broker will not take anything this client sends. | |
| 42 | + * | |
| 43 | + * A publish to a topic a client may not write is not answered with an | |
| 44 | + * error -- RabbitMQ closes the connection -- so an uplink the server does | |
| 45 | + * not know about costs the board every couple of minutes. After a few | |
| 46 | + * refusals the uplink stops and the board goes on, which is the right one | |
| 47 | + * to keep. | |
| 48 | + */ | |
| 49 | + private var uplinkFailures = 0 | |
| 50 | + private var uplinkBlocked = false | |
| 51 | + | |
| 40 | 52 | override fun onCreate() { |
| 41 | 53 | super.onCreate() |
| 54 | + running = this | |
| 42 | 55 | settings = Settings(this) |
| 43 | 56 | 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 | − ) | |
| 57 | + location = LocationReporter(this, settings, allowed = { !uplinkBlocked }) { uplink -> | |
| 58 | + sendUplink(LocationUplink.topicFor(settings.topic), Json.encodeToString(uplink)) | |
| 52 | 59 | } |
| 53 | 60 | createChannel() |
| 54 | 61 | } |
@@ -76,6 +83,41 @@ class AnkyraService : Service() {
| 76 | 83 | return START_STICKY |
| 77 | 84 | } |
| 78 | 85 | |
| 86 | + /** | |
| 87 | + * Send something to the server, while it is still taking things. | |
| 88 | + * | |
| 89 | + * Everything a client says goes through here so one refusal count covers | |
| 90 | + * all of it: a location and a request for a board are refused for the same | |
| 91 | + * reason, and either one costs the same connection. | |
| 92 | + */ | |
| 93 | + private fun sendUplink(topic: String, payload: String) { | |
| 94 | + if (uplinkBlocked) return | |
| 95 | + client?.publish(topic, payload) { ok -> | |
| 96 | + if (ok) { | |
| 97 | + uplinkFailures = 0 | |
| 98 | + } else { | |
| 99 | + uplinkFailures += 1 | |
| 100 | + if (uplinkFailures >= GIVE_UP_AFTER) { | |
| 101 | + uplinkBlocked = true | |
| 102 | + Log.w(TAG, "giving up on the uplink: the board matters more") | |
| 103 | + location?.sync() | |
| 104 | + } | |
| 105 | + } | |
| 106 | + } | |
| 107 | + } | |
| 108 | + | |
| 109 | + /** | |
| 110 | + * Ask for a board. | |
| 111 | + * | |
| 112 | + * A Pythiae publishes on change and on its own tick, so a client that has | |
| 113 | + * just woken up can be looking at something old with no way to say so. | |
| 114 | + * Cheap to ask and debounced at the other end. | |
| 115 | + */ | |
| 116 | + fun requestBoard() { | |
| 117 | + if (!settings.isConfigured) return | |
| 118 | + sendUplink("${settings.topic}.publish.board", "{}") | |
| 119 | + } | |
| 120 | + | |
| 79 | 121 | private fun onPayload(payload: String) { |
| 80 | 122 | Log.d(TAG, "payload: ${payload.length} bytes") |
| 81 | 123 | store.save(payload) |
@@ -90,7 +132,14 @@ class AnkyraService : Service() {
| 90 | 132 | state.value = newState |
| 91 | 133 | // Proof the settings are right, which is what licenses reconnecting on |
| 92 | 134 | // a later launch without asking. |
| 93 | − if (newState == AnkyraClient.State.Connected) settings.hasConnected = true | |
| 135 | + if (newState == AnkyraClient.State.Connected) { | |
| 136 | + settings.hasConnected = true | |
| 137 | + // Just subscribed, so whatever is on disk is from before the gap. | |
| 138 | + // This covers the cases the callers below cannot: a cold start, | |
| 139 | + // where the service is not up yet when the screen asks, and every | |
| 140 | + // reconnect after a tunnel. | |
| 141 | + requestBoard() | |
| 142 | + } | |
| 94 | 143 | // The notification is the only place a connection problem is visible, |
| 95 | 144 | // since the Targets themselves fall back to the stored board. |
| 96 | 145 | runCatching { |
@@ -145,6 +194,7 @@ class AnkyraService : Service() {
| 145 | 194 | } |
| 146 | 195 | |
| 147 | 196 | override fun onDestroy() { |
| 197 | + running = null | |
| 148 | 198 | location?.shutDown() |
| 149 | 199 | client?.disconnect() |
| 150 | 200 | client = null |
@@ -159,6 +209,15 @@ class AnkyraService : Service() {
| 159 | 209 | private const val NOTIFICATION_ID = 1 |
| 160 | 210 | const val ACTION_STOP = "io.neiam.apolloscrib.STOP" |
| 161 | 211 | |
| 212 | + /** Three refusals is a server that will not take this, not a blip. */ | |
| 213 | + private const val GIVE_UP_AFTER = 3 | |
| 214 | + | |
| 215 | + /** | |
| 216 | + * The running service, for the few callers that need to reach the | |
| 217 | + * live connection rather than start one. | |
| 218 | + */ | |
| 219 | + private var running: AnkyraService? = null | |
| 220 | + | |
| 162 | 221 | /** Connection state, for the settings screen to show. */ |
| 163 | 222 | val state = MutableStateFlow(AnkyraClient.State.Disconnected) |
| 164 | 223 | val connectionState: StateFlow<AnkyraClient.State> get() = state |
@@ -181,6 +240,16 @@ class AnkyraService : Service() {
| 181 | 240 | if (settings.isConfigured && settings.hasConnected) start(context) |
| 182 | 241 | } |
| 183 | 242 | |
| 243 | + /** | |
| 244 | + * Ask for a fresh board, if anything is listening. | |
| 245 | + * | |
| 246 | + * Sent through the running service rather than a new connection: the | |
| 247 | + * one that is subscribed is the one the answer comes back on. | |
| 248 | + */ | |
| 249 | + fun requestBoard(context: Context) { | |
| 250 | + running?.requestBoard() | |
| 251 | + } | |
| 252 | + | |
| 184 | 253 | fun stop(context: Context) { |
| 185 | 254 | context.startService( |
| 186 | 255 | Intent(context, AnkyraService::class.java).setAction(ACTION_STOP) |
modified
clients/android/app/src/main/java/io/neiam/apolloscrib/mqtt/LocationReporter.kt
+3
−46
@@ -26,6 +26,8 @@ import io.neiam.apolloscrib.data.Settings
| 26 | 26 | class LocationReporter( |
| 27 | 27 | private val context: Context, |
| 28 | 28 | private val settings: Settings, |
| 29 | + /** Whether the server is still taking anything from this client. */ | |
| 30 | + private val allowed: () -> Boolean, | |
| 29 | 31 | private val publish: (LocationUplink) -> Unit |
| 30 | 32 | ) { |
| 31 | 33 |
@@ -34,51 +36,12 @@ class LocationReporter(
| 34 | 36 | |
| 35 | 37 | private var listening = false |
| 36 | 38 | |
| 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 | 39 | |
| 51 | 40 | private val listener = LocationListener { location -> report(location) } |
| 52 | 41 | |
| 53 | 42 | /** True when the user has asked for this and Android has allowed it. */ |
| 54 | 43 | 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 | − } | |
| 44 | + settings.publishLocation && allowed() && hasPermission() | |
| 82 | 45 | |
| 83 | 46 | private fun hasPermission(): Boolean = |
| 84 | 47 | ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_FINE_LOCATION) == |
@@ -153,11 +116,5 @@ class LocationReporter(
| 153 | 116 | */ |
| 154 | 117 | private const val MIN_INTERVAL_MS = 2 * 60 * 1000L |
| 155 | 118 | 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 | 119 | } |
| 163 | 120 | } |
modified
clients/android/app/src/main/java/io/neiam/apolloscrib/ui/MainActivity.kt
+8
−0
@@ -124,6 +124,14 @@ class MainActivity : ComponentActivity() {
| 124 | 124 | } |
| 125 | 125 | } |
| 126 | 126 | |
| 127 | + override fun onResume() { | |
| 128 | + super.onResume() | |
| 129 | + // Opening the app is the moment a board from an hour ago is most | |
| 130 | + // obviously wrong, so ask for one. Costs nothing if the server does | |
| 131 | + // not take requests. | |
| 132 | + AnkyraService.requestBoard(this) | |
| 133 | + } | |
| 134 | + | |
| 127 | 135 | // singleTop, so a scan while the app is already open arrives here rather |
| 128 | 136 | // than as a second copy of the activity. |
| 129 | 137 | override fun onNewIntent(intent: Intent) { |
Parents: 9c01b9b