Ask for a board on connecting

2effc9d · Gabriel Morell · 2026-09-03 16:22

16 files +92 -55
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