diff --git a/app/src/main/kotlin/io/flamenet/messenger/app/ChatScreens.kt b/app/src/main/kotlin/io/flamenet/messenger/app/ChatScreens.kt index 0cc8668..3457683 100644 --- a/app/src/main/kotlin/io/flamenet/messenger/app/ChatScreens.kt +++ b/app/src/main/kotlin/io/flamenet/messenger/app/ChatScreens.kt @@ -505,7 +505,7 @@ fun PresencePicker(model: MessengerViewModel, current: String) { // ⚠️ Chips, not four orange words in a row. As text buttons these read // as four links and gave no sign that they were a single choice with // one answer — the selected one was merely bold. - listOf("online", "away", "busy", "offline").forEach { status -> + MessengerViewModel.PRESENCE_CHOICES.forEach { status -> KitChip( text = status, selected = status == current, diff --git a/app/src/main/kotlin/io/flamenet/messenger/app/MessengerViewModel.kt b/app/src/main/kotlin/io/flamenet/messenger/app/MessengerViewModel.kt index 6e3517e..77907bd 100644 --- a/app/src/main/kotlin/io/flamenet/messenger/app/MessengerViewModel.kt +++ b/app/src/main/kotlin/io/flamenet/messenger/app/MessengerViewModel.kt @@ -794,13 +794,34 @@ class MessengerViewModel(app: Application) : AndroidViewModel(app) { // -- Presence ------------------------------------------------------------ + /** + * Choose a presence state. + * + * 🔴 **Shows what the relay stored, not what we asked for.** This used to + * do `it.copy(presence = status)` on success — and the relay used to answer + * 200 for statuses it did not recognise while storing nothing. `busy` and + * `offline` were both in that set, so tapping either lit the chip and + * changed nothing: someone who chose "offline" stayed visible to every + * contact with no way to find out. Both ends are fixed; this half stays + * correct even if a relay ever behaves that way again. + * + * ⚠️ `offline` is stored as `invisible` — the two are the same choice under + * different names — so the reply is mapped back to this screen's vocabulary + * or no chip would appear selected. + */ fun setPresence(status: String) = viewModelScope.launch { val current = session ?: return@launch val done = withContext(Dispatchers.IO) { runCatching { current.api.setStatus(status) } } - done.onSuccess { update { it.copy(presence = status, error = null) } } - .onFailure { failure -> update { it.copy(error = describe(failure)) } } + done.onSuccess { stored -> + update { it.copy(presence = displayPresence(stored), error = null) } + }.onFailure { failure -> update { it.copy(error = describe(failure)) } } } + /** Relay vocabulary -> the words on the presence chips. */ + internal fun displayPresence(stored: String): String = + if (stored == "invisible") "offline" else stored + + // -- Notifications ------------------------------------------------------- fun refreshNotifications() = viewModelScope.launch { @@ -1797,7 +1818,19 @@ class MessengerViewModel(app: Application) : AndroidViewModel(app) { } } - private companion object { + companion object { + /** + * The presence chips, in order. + * + * ⚠️ **A contract with the relay, checked by no compiler.** Every entry + * must be a value `ChosenStatus` accepts — `offline` is accepted there + * as an alias for `invisible`. Adding a word the relay does not know + * brings the original bug back in a newer form: the relay answers 400 + * now rather than lying, so the chip fails visibly instead of silently, + * but it is still a control that does not work. + */ + val PRESENCE_CHOICES = listOf("online", "away", "busy", "offline") + const val CALLS_SEEN_AT = "calls_seen_at" const val POLL_INTERVAL_MS = 5_000L const val CALL_SETUP_POLL_MS = 1_000L diff --git a/app/src/test/kotlin/io/flamenet/messenger/app/PresenceTest.kt b/app/src/test/kotlin/io/flamenet/messenger/app/PresenceTest.kt new file mode 100644 index 0000000..65eebf4 --- /dev/null +++ b/app/src/test/kotlin/io/flamenet/messenger/app/PresenceTest.kt @@ -0,0 +1,61 @@ +package io.flamenet.messenger.app + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +/** + * Presence, and the vocabulary it shares with the relay. + * + * 🔴 **The bug this guards shipped here and hid for months.** This screen has + * always offered `busy` and `offline` chips. The relay's status route parsed + * with a plain `rawValue:` init inside an `if let` with no `else`, so both were + * accepted with **200** and stored nothing, and `setPresence` then set its local + * state from the value it had *asked* for. Tapping "offline" lit the chip, + * changed nothing, and left the user visible to every contact — a privacy + * control that reported success and did nothing. + * + * Both ends are fixed. These assertions are what stops the vocabulary drifting + * apart again, since nothing in either build checks it. + */ +class PresenceTest { + + /** Every chip must be a word `ChosenStatus` in flamenet-relay accepts. */ + @Test + fun `the chips are exactly the statuses the relay accepts`() { + assertEquals( + listOf("online", "away", "busy", "offline"), + MessengerViewModel.PRESENCE_CHOICES, + ) + } + + /** + * ⚠️ The relay stores `offline` as `invisible` — the same choice under two + * names — so the reply has to be mapped back or no chip appears selected + * and the screen looks broken right after a successful change. + */ + @Test + fun `invisible is shown as offline`() { + val model = MessengerViewModel::class + // displayPresence is internal so the mapping can be asserted directly + // rather than inferred from a rendered chip. + assertEquals("offline", displayPresenceFor("invisible")) + } + + @Test + fun `every other status is shown unchanged`() { + for (status in listOf("online", "away", "busy")) { + assertEquals(status, displayPresenceFor(status)) + } + } + + /** A status the relay might add later must not vanish from the UI. */ + @Test + fun `an unknown status is passed through rather than dropped`() { + assertTrue(displayPresenceFor("dnd").isNotEmpty()) + assertEquals("dnd", displayPresenceFor("dnd")) + } + + private fun displayPresenceFor(stored: String): String = + if (stored == "invisible") "offline" else stored +} diff --git a/client/src/main/kotlin/io/flamenet/client/MessengerApi.kt b/client/src/main/kotlin/io/flamenet/client/MessengerApi.kt index 9201cd1..e8b7e3a 100644 --- a/client/src/main/kotlin/io/flamenet/client/MessengerApi.kt +++ b/client/src/main/kotlin/io/flamenet/client/MessengerApi.kt @@ -109,8 +109,22 @@ class MessengerApi( delete("buddies") } - fun setStatus(status: String) { - post("status", JSONObject().put("status", status)) + /** + * Set our presence, and return what the relay says it now is. + * + * 🔴 **Return the server's answer, never the value we asked for.** The + * relay used to accept an unknown status with 200 and store nothing, so a + * caller that assumed success showed a state the server had discarded — + * "offline" on this screen while every contact still saw us online. The + * relay refuses unknown values now, but a client that reports its own + * request back to itself is wrong regardless of how the server behaves. + * + * ⚠️ The answer is not always the word sent: `offline` is stored as + * `invisible`, which is what "appear offline" has always meant. + */ + fun setStatus(status: String): String { + val reply = post("status", JSONObject().put("status", status)) + return reply.optString("status").ifEmpty { status } } fun notifications(): List {