From 8a9de92289289c2ac6949f03feef43463ca4894c Mon Sep 17 00:00:00 2001 From: Paul Hitt Date: Thu, 8 Oct 2026 17:48:56 -0400 Subject: [PATCH 1/6] Apply inbound reactions, edits and pins on Android absorb() kept only isRenderable envelopes before its loop. isRenderable is false for a reaction, an edit and a pin, so the branches that apply them never ran: each one decrypted ("opened by current") and was dropped. Found on the iPhone-to-Android pair against a local relay: the iPhone's reaction and its edit both reached the emulator and neither showed. Android-to-Android is affected the same way. The existing tests call applyEdit and applyReaction directly, which is why they stayed green. InboundControlReachesTheThreadTest drives absorb() with the bytes the iPhone writes. 3 red before, 3 green after; 41 related tests green. Co-Authored-By: Claude Opus 5.5 --- .../io/flamenet/messenger/app/Session.kt | 10 +- .../app/InboundControlReachesTheThreadTest.kt | 143 ++++++++++++++++++ 2 files changed, 151 insertions(+), 2 deletions(-) create mode 100644 app/src/test/kotlin/io/flamenet/messenger/app/InboundControlReachesTheThreadTest.kt diff --git a/app/src/main/kotlin/io/flamenet/messenger/app/Session.kt b/app/src/main/kotlin/io/flamenet/messenger/app/Session.kt index be6a6f8..f8d4a3b 100644 --- a/app/src/main/kotlin/io/flamenet/messenger/app/Session.kt +++ b/app/src/main/kotlin/io/flamenet/messenger/app/Session.kt @@ -1351,7 +1351,7 @@ class Session( * decrypted messages and did not absorb them would report success and * show nothing, which is the failure this whole change exists to stop. */ - private fun absorb(everything: List): Set { + internal fun absorb(everything: List): Set { // Control traffic consumed during the poll may have carried // group-name claims. drainGroupNames() @@ -1364,7 +1364,13 @@ class Session( } } } - val arrived = everything.filter { it.content.isRenderable } + // NOT `isRenderable`: that is false for a reaction, an edit and a pin, + // and those are handled inside the loop below. Filtering on it here + // made all three branches unreachable, so every one that arrived was + // decrypted and then dropped. Calls went above, control traffic was + // consumed during the poll, and the loop still files only + // renderable content as a message. + val arrived = everything.filter { it.content.call == null && !it.content.isControl } val touched = mutableSetOf() val now = DeviceClock.expirySeconds() val incomingStories = mutableListOf() diff --git a/app/src/test/kotlin/io/flamenet/messenger/app/InboundControlReachesTheThreadTest.kt b/app/src/test/kotlin/io/flamenet/messenger/app/InboundControlReachesTheThreadTest.kt new file mode 100644 index 0000000..4856573 --- /dev/null +++ b/app/src/test/kotlin/io/flamenet/messenger/app/InboundControlReachesTheThreadTest.kt @@ -0,0 +1,143 @@ +package io.flamenet.messenger.app + +import io.flamenet.client.Conversations +import io.flamenet.client.MessengerApi +import io.flamenet.client.RelayClient +import io.flamenet.e2e.Content +import io.flamenet.e2e.DeviceIdentity +import io.flamenet.e2e.Vault +import okhttp3.mockwebserver.MockWebServer +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import javax.crypto.KeyGenerator +import javax.crypto.SecretKey +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull + +/** + * A reaction, an edit and a pin that arrive in a POLL reach the thread. + * + * Found on the iPhone-to-Android pair, 2026-10-08: the iPhone's ❤️ and its + * edit both decrypted on the emulator ("opened by current") and neither ever + * showed. `absorb` kept only `isRenderable` envelopes before its loop, and + * `isRenderable` is false for exactly these three, so the branches that apply + * them never ran. The tests beside this one call `applyEdit` and + * `applyReaction` directly, which is why they stayed green. + * + * The envelopes below are the bytes the iPhone app writes + * (`E2EAttachment.encodeReaction`, `encodeEdit`, `encodePin`). + */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34]) +class InboundControlReachesTheThreadTest { + private lateinit var server: MockWebServer + + @BeforeTest fun start() { + server = MockWebServer().also { it.start() } + } + + @AfterTest fun stop() { + server.shutdown() + } + + private class MapStorage : Vault.Storage { + private val records = mutableMapOf() + + override fun read(name: String) = records[name] + + override fun write( + name: String, + bytes: ByteArray, + ) { + records[name] = bytes + } + + override fun delete(name: String) { + records.remove(name) + } + + override fun names() = records.keys.toList() + } + + private fun key(): SecretKey = KeyGenerator.getInstance("AES").apply { init(256) }.generateKey() + + private val me = 1L + private val them = 2L + private val thread = Vault.threadForUser(them) + + private fun session(): Session { + val identity = DeviceIdentity.create() + val vault = Vault(MapStorage(), key()).also { it.saveIdentity(identity) } + val base = server.url("/").toString() + return Session( + "alice", + me, + identity, + vault, + RelayClient(base, "alice", "app pass word"), + MessengerApi(base, "alice", "app pass word"), + ) + } + + private fun arrived(wire: String) = + Conversations.Received( + id = 1, + fromUser = them, + fromDevice = "their-phone", + content = Content.parse(wire)!!, + ) + + @Test + fun `a reaction in a poll lands on the message it names`() { + val s = session() + s.record(thread, "react to me", mine = true, author = me, messageId = "m1") + + val touched = + s.absorb( + listOf( + arrived( + """{"fnc":1,"kind":"react","re":{"mid":"m1","author":1,"preview":""},"caption":"❤️"}""", + ), + ), + ) + + assertEquals(setOf(thread), touched, "the thread was not marked as changed") + val line = s.history(thread).single { it.id == "m1" } + assertEquals(setOf(them), line.reactions["❤️"], "the reaction never reached the message") + } + + @Test + fun `an edit in a poll rewrites the message it names`() { + val s = session() + s.record(thread, "frist", mine = false, author = them, messageId = "m1") + + s.absorb( + listOf( + arrived("""{"fnc":1,"kind":"edit","re":{"mid":"m1","author":0,"preview":""},"caption":"first"}"""), + ), + ) + + val line = s.history(thread).single { it.id == "m1" } + assertEquals("first", line.text, "the edit never reached the message") + assertNotNull(line.editedAt, "the bubble cannot say it was edited") + assertEquals(1, s.history(thread).size, "the edit was filed as a message of its own") + } + + @Test + fun `a pin in a poll is applied and is not a bubble`() { + val s = session() + s.record(thread, "pin me", mine = false, author = them, messageId = "m1") + + val touched = + s.absorb( + listOf(arrived("""{"fnc":1,"kind":"pin","re":{"mid":"m1","author":0,"preview":""}}""")), + ) + + assertEquals(setOf(thread), touched, "the pin never reached the thread") + assertEquals(1, s.history(thread).size, "the pin was filed as a message of its own") + } +} From b10ad0a21ad3c7e685c14751ff5284aa86ad9ed4 Mon Sep 17 00:00:00 2001 From: Paul Hitt Date: Thu, 8 Oct 2026 17:59:22 -0400 Subject: [PATCH 2/6] Draw the quote on a reply on Android A reply arrives with its re reference and the quoted words (preview), and Session files them on Line.about. Nothing in ChatScreens read about, so on Android a reply drew only its own words and looked like an unrelated message. That covers replies from an iPhone and Android's own. Found on the iPhone-to-Android pair against a local relay: the iPhone showed the quote on Android's reply, Android showed none on the iPhone's. ReplyQuote draws the preview above the reply's words, inside the bubble, the way the iPhone does. AReplyShowsWhatItQuotesTest: 1 red before the bubble called it, 6 green after; ScreenshotTest 16 green. Co-Authored-By: Claude Opus 5.5 --- .../io/flamenet/messenger/app/ChatScreens.kt | 51 ++++++++++++ .../app/AReplyShowsWhatItQuotesTest.kt | 79 +++++++++++++++++++ 2 files changed, 130 insertions(+) create mode 100644 app/src/test/kotlin/io/flamenet/messenger/app/AReplyShowsWhatItQuotesTest.kt 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 220f4a3..afad8f2 100644 --- a/app/src/main/kotlin/io/flamenet/messenger/app/ChatScreens.kt +++ b/app/src/main/kotlin/io/flamenet/messenger/app/ChatScreens.kt @@ -2543,6 +2543,55 @@ private fun ColumnScope.VoiceComposer( } } +/** + * The words a reply quotes, or null for a message that answers nothing. + * + * Both apps send the quoted words with the reply (§6.9's `preview`), so this + * needs nothing from the thread and works for a message that has since + * scrolled away, been deleted here, or disappeared. + */ +internal fun quotedPreview(line: Session.Line): String? { + val about = line.about ?: return null + val words = about.preview.ifBlank { "a message" } + return if (line.replyToStory) "Story: $words" else words +} + +/** The quote above a reply's own words, inside its bubble. */ +@Composable +internal fun ReplyQuote( + preview: String, + mine: Boolean, +) { + val tint = + if (mine) { + MaterialTheme.colorScheme.onPrimary + } else { + MaterialTheme.colorScheme.onSurface + } + Row( + modifier = + Modifier + .padding(bottom = 4.dp) + .testTag("reply-quote") + .semantics(mergeDescendants = true) {}, + verticalAlignment = Alignment.CenterVertically, + ) { + Box( + Modifier + .width(3.dp) + .height(18.dp) + .background(tint.copy(alpha = 0.6f)), + ) + Spacer(Modifier.width(6.dp)) + Text( + preview, + fontSize = 13.sp, + color = tint.copy(alpha = 0.75f), + maxLines = 2, + ) + } +} + @Composable private fun Bubble( line: Session.Line, @@ -2691,6 +2740,8 @@ private fun Bubble( .border(Kit.hairline, Portal.rule(), shape) }.padding(Kit.bubblePadding), ) { + // What a reply answers, above its own words (§6.9). + quotedPreview(line)?.let { ReplyQuote(it, mine = line.mine) } // The kit puts the time ABOVE the bubble, outside it. line.attachment?.let { ref -> // One field, two very different things. diff --git a/app/src/test/kotlin/io/flamenet/messenger/app/AReplyShowsWhatItQuotesTest.kt b/app/src/test/kotlin/io/flamenet/messenger/app/AReplyShowsWhatItQuotesTest.kt new file mode 100644 index 0000000..22766d0 --- /dev/null +++ b/app/src/test/kotlin/io/flamenet/messenger/app/AReplyShowsWhatItQuotesTest.kt @@ -0,0 +1,79 @@ +package io.flamenet.messenger.app + +import androidx.activity.ComponentActivity +import androidx.compose.ui.test.assertTextContains +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.compose.ui.test.onNodeWithTag +import io.flamenet.e2e.Content +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import java.io.File +import kotlin.test.assertEquals +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** + * A reply shows what it answers. + * + * Found on the iPhone-to-Android pair, 2026-10-08: the iPhone sent a reply + * with its `re` reference and preview, the emulator filed it with + * `Line.about` set, and the bubble drew the reply's words alone. Nothing in + * ChatScreens ever read `about`, so on Android a reply looked like an + * unrelated message, both for replies from an iPhone and for Android's own. + * The iPhone draws the quote from the same preview. + */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34]) +class AReplyShowsWhatItQuotesTest { + @get:Rule val compose = createAndroidComposeRule() + + private fun line( + about: Content.Reference?, + toStory: Boolean = false, + ) = Session.Line( + text = "i2a reply", + mine = false, + at = 0, + id = "m2", + about = about, + replyToStory = toStory, + ) + + @Test + fun `a reply carries the words it quotes`() { + assertEquals("a2i two", quotedPreview(line(Content.Reference("m1", 3, "a2i two")))) + } + + @Test + fun `a message that answers nothing quotes nothing`() { + assertNull(quotedPreview(line(null))) + } + + /** A reference with no preview still says it is a reply. */ + @Test + fun `a reply with an empty preview still says it is one`() { + assertEquals("a message", quotedPreview(line(Content.Reference("m1", 3, "")))) + } + + @Test + fun `a reply to a story says so`() { + assertEquals("Story: sunrise", quotedPreview(line(Content.Reference("s1", 3, "sunrise"), toStory = true))) + } + + @Test + fun `the quote is drawn`() { + compose.setContent { ReplyQuote("a2i two", mine = false) } + compose.onNodeWithTag("reply-quote").assertTextContains("a2i two") + } + + /** The bubble is the only place a person sees it, so it has to call it. */ + @Test + fun `the message bubble draws the quote`() { + val screens = File("src/main/kotlin/io/flamenet/messenger/app/ChatScreens.kt").readText() + val bubble = screens.substringAfter("private fun Bubble(").substringBefore("\nprivate fun ") + assertTrue(bubble.contains("ReplyQuote("), "Bubble never draws what a reply quotes") + } +} From 325adb4208993b7d51531b261c2b10819f13d1fb Mon Sep 17 00:00:00 2001 From: Paul Hitt Date: Thu, 8 Oct 2026 18:42:54 -0400 Subject: [PATCH 3/6] Move the poll card when the poll moves, on Android Two faults left an open group's poll card where it was until the group was reopened: - The UI state held the PollBook's own entries. A vote, a piece or a sum changes an entry in place, so the state before and after compared equal and the StateFlow emitted nothing. Android's own vote looked like it did nothing. - The tick that re-reads the cards sat inside if (threads.isNotEmpty()). Poll traffic touches no thread, so a poll asked from the iPhone, and every share and sum after it, never reached an open group. Found on the iPhone-to-Android pair: the iPhone's poll and the Android vote both showed only after leaving and reopening the group. pollCards() hands the UI copies, and the refresh runs on every tick. PollCardsMoveTest: 3 red before, 3 green after; other poll tests green. Co-Authored-By: Claude Opus 5.5 --- .../messenger/app/MessengerViewModel.kt | 55 +++++++++------- .../messenger/app/PollCardsMoveTest.kt | 62 +++++++++++++++++++ 2 files changed, 93 insertions(+), 24 deletions(-) create mode 100644 app/src/test/kotlin/io/flamenet/messenger/app/PollCardsMoveTest.kt 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 4f109cd..c021898 100644 --- a/app/src/main/kotlin/io/flamenet/messenger/app/MessengerViewModel.kt +++ b/app/src/main/kotlin/io/flamenet/messenger/app/MessengerViewModel.kt @@ -974,10 +974,7 @@ class MessengerViewModel( // Read here, not left to the next poll tick. A poll // waiting on your vote is the reason you opened the // group, and it was blank for the first few seconds. - polls = - current.conversations.polls - .pollsIn(group.id) - .sortedBy { p -> p.id }, + polls = pollCards(current.conversations.polls, group.id), ), unread = it.unread - thread, ) @@ -1300,10 +1297,7 @@ class MessengerViewModel( state.copy( openChat = state.openChat.copy( - polls = - current.conversations.polls - .pollsIn(gid) - .sortedBy { it.id }, + polls = pollCards(current.conversations.polls, gid), ), ) } @@ -4368,6 +4362,19 @@ class MessengerViewModel( update { it.copy(avatars = it.avatars + live) } } } + // Poll traffic is control traffic: it touches no + // thread, so this runs on every tick and not only on + // one that carried a message. Copies compare equal + // when nothing changed, so a quiet tick emits nothing. + update { state -> + val open = state.openChat + val gid = open?.group?.id + if (open == null || gid == null) { + state + } else { + state.copy(openChat = open.copy(polls = pollCards(current.conversations.polls, gid))) + } + } if (threads.isNotEmpty()) { // Whoever just wrote may not be on the list at all, // see adoptStrangers. @@ -4383,22 +4390,7 @@ class MessengerViewModel( } else { it } - // Poll traffic is control traffic: - // it touches no thread, so it never - // appears in `threads` and a card - // waiting on somebody else's vote - // would never have moved. - val gid = withLines.group?.id - if (gid == null) { - withLines - } else { - withLines.copy( - polls = - current.conversations.polls - .pollsIn(gid) - .sortedBy { p -> p.id }, - ) - } + withLines }, // A thread that is already open is being // read, so it does not also become unread. @@ -4776,3 +4768,18 @@ internal class OnlyTextToAnotherRelay : "Only text can be sent to somebody on another server for now. " + "Pictures, files and stickers cannot go there yet.", ) + +/** + * The poll cards for one group, in a stable order, as COPIES. + * + * The book changes its entries in place when a vote, a piece or a sum lands. + * Handing the UI those same objects meant the state before and after compared + * equal, the StateFlow emitted nothing, and the card never moved. + */ +internal fun pollCards( + book: io.flamenet.e2e.PollBook, + groupId: String, +): List = + book.pollsIn(groupId).sortedBy { it.id }.map { + it.copy(piecesHeld = it.piecesHeld.toMutableMap(), sumsPublished = it.sumsPublished.toMutableMap()) + } diff --git a/app/src/test/kotlin/io/flamenet/messenger/app/PollCardsMoveTest.kt b/app/src/test/kotlin/io/flamenet/messenger/app/PollCardsMoveTest.kt new file mode 100644 index 0000000..f8cfa31 --- /dev/null +++ b/app/src/test/kotlin/io/flamenet/messenger/app/PollCardsMoveTest.kt @@ -0,0 +1,62 @@ +package io.flamenet.messenger.app + +import io.flamenet.e2e.PollBook +import java.io.File +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNotEquals + +/** + * A poll card changes when the poll does. + * + * Found on the iPhone-to-Android pair, 2026-10-08. Two separate faults left + * an open group's poll card where it was: + * + * 1. The card list in the UI state held the PollBook's own entries. Voting + * mutates an entry in place (`myChoice`, the piece and sum maps), so the + * new state compared equal to the old one, the StateFlow emitted nothing, + * and Android's own vote looked like it did nothing until the group was + * reopened. + * 2. The tick that re-reads the cards sat inside `if (threads.isNotEmpty())`. + * Poll traffic is control traffic and touches no thread, so a poll asked + * from the iPhone, and every share and sum after it, never reached an open + * group. + */ +class PollCardsMoveTest { + private val gid = "group-1" + + private fun book() = + PollBook().also { + it.remember("p1", gid, "Lunch?", listOf("Tacos", "Pizza"), PollBook.memberSet(listOf(6L, 7L))) + } + + @Test + fun `voting changes what the card is drawn from`() { + val book = book() + val before = pollCards(book, gid) + book.vote("p1", 6L, 1) + val after = pollCards(book, gid) + + assertEquals(1, after.single().myChoice) + assertNotEquals(before, after, "the state before the vote equals the state after it, so nothing redraws") + } + + @Test + fun `a share arriving changes what the card is drawn from`() { + val book = book() + val before = pollCards(book, gid) + book.receivePiece("p1", 7L, intArrayOf(3, -3)) + assertNotEquals(before, pollCards(book, gid)) + } + + @Test + fun `the open group's cards are not refreshed only when a message arrived`() { + val vm = File("src/main/kotlin/io/flamenet/messenger/app/MessengerViewModel.kt").readText() + val gated = vm.substringAfter("if (threads.isNotEmpty()) {").substringBefore("\n }\n") + assertFalse( + gated.contains("pollCards(") || gated.contains(".pollsIn("), + "poll cards are refreshed only on a tick that also carried a message", + ) + } +} From 88906aacb422003b26417f537b0a7a35789ac299 Mon Sep 17 00:00:00 2001 From: Paul Hitt Date: Thu, 8 Oct 2026 18:58:22 -0400 Subject: [PATCH 4/6] Keep a blocked contact out of the inbox on Android The relay refuses a blocked sender only on an identified send. A sealed send is authorised by the recipient's delivery key and does not say who sent it, so a blocked contact who already holds that key keeps getting through. Revoking delivery tokens does not close it: a refused token falls back to the delivery key by design. The recipient learns the sender when it unseals, so the recipient has to refuse. Found on the iPhone-to-Android pair: after the iPhone blocked the emulator, the emulator's next message went sealed, the relay accepted it (blocks row 7 -> 6 present) and the iPhone showed it. This app had no such check either. The blocked list is kept in the vault, written on every block and unblock and whenever the relay's list is fetched, and absorb() drops messages and call signals from anyone on it. ABlockedSenderStaysOutTest: 2 red before, 4 green after; engine Vault tests 75 green. Co-Authored-By: Claude Opus 5.5 --- .../messenger/app/MessengerViewModel.kt | 8 +- .../io/flamenet/messenger/app/Session.kt | 14 +- .../app/ABlockedSenderStaysOutTest.kt | 137 ++++++++++++++++++ .../src/main/kotlin/io/flamenet/e2e/Vault.kt | 17 +++ 4 files changed, 174 insertions(+), 2 deletions(-) create mode 100644 app/src/test/kotlin/io/flamenet/messenger/app/ABlockedSenderStaysOutTest.kt 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 c021898..ec33c22 100644 --- a/app/src/main/kotlin/io/flamenet/messenger/app/MessengerViewModel.kt +++ b/app/src/main/kotlin/io/flamenet/messenger/app/MessengerViewModel.kt @@ -2114,7 +2114,11 @@ class MessengerViewModel( if (blocked) current.conversations.forgetHeldEnvelopes(userId) val revoke = if (blocked) runCatching { current.conversations.revokeDeliveryTokens(userId) } else null - Triple(current.api.blocked().toSet(), current.roster(), revoke?.exceptionOrNull()) + val nowBlocked = current.api.blocked().toSet() + // Kept on the device: the relay cannot refuse a SEALED + // message from them, so the inbox has to. + current.vault.saveBlockedSenders(nowBlocked) + Triple(nowBlocked, current.roster(), revoke?.exceptionOrNull()) } } done @@ -2879,6 +2883,8 @@ class MessengerViewModel( var added = false // Fail closed. val blocked = runCatching { current.api.blocked() }.getOrNull() ?: return@launch + // The relay's list is the record; this device's copy follows it. + runCatching { current.vault.saveBlockedSenders(blocked.toSet()) } for (id in strangers) { runCatching { if (id in blocked) return@runCatching diff --git a/app/src/main/kotlin/io/flamenet/messenger/app/Session.kt b/app/src/main/kotlin/io/flamenet/messenger/app/Session.kt index f8d4a3b..1f5be6f 100644 --- a/app/src/main/kotlin/io/flamenet/messenger/app/Session.kt +++ b/app/src/main/kotlin/io/flamenet/messenger/app/Session.kt @@ -1351,7 +1351,19 @@ class Session( * decrypted messages and did not absorb them would report success and * show nothing, which is the failure this whole change exists to stop. */ - internal fun absorb(everything: List): Set { + internal fun absorb(received: List): Set { + // A blocked contact stays out, calls included. The relay refuses them + // only on an identified send; a sealed one is authorised by our + // delivery key and does not say who sent it, so this is the one place + // that can. The ratchet already advanced, which keeps the session in + // step if they are unblocked later. + val blocked = vault.blockedSenders() + val everything = + if (blocked.isEmpty()) { + received + } else { + received.filterNot { it.fromUser != userId && it.fromUser in blocked } + } // Control traffic consumed during the poll may have carried // group-name claims. drainGroupNames() diff --git a/app/src/test/kotlin/io/flamenet/messenger/app/ABlockedSenderStaysOutTest.kt b/app/src/test/kotlin/io/flamenet/messenger/app/ABlockedSenderStaysOutTest.kt new file mode 100644 index 0000000..8ec46cb --- /dev/null +++ b/app/src/test/kotlin/io/flamenet/messenger/app/ABlockedSenderStaysOutTest.kt @@ -0,0 +1,137 @@ +package io.flamenet.messenger.app + +import io.flamenet.client.Conversations +import io.flamenet.client.MessengerApi +import io.flamenet.client.RelayClient +import io.flamenet.e2e.Content +import io.flamenet.e2e.DeviceIdentity +import io.flamenet.e2e.Vault +import okhttp3.mockwebserver.MockWebServer +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import java.io.File +import javax.crypto.KeyGenerator +import javax.crypto.SecretKey +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +/** + * A blocked contact's messages do not reach the thread, sealed or not. + * + * Found on the iPhone-to-Android pair, 2026-10-08. The relay enforces a block + * only on an IDENTIFIED send (Routes.swift, `Block.blocks`). A sealed send is + * authorised by the recipient's delivery key and the relay does not know who + * sent it, so a blocked contact who already holds that key keeps getting + * through: the emulator sent sealed after the iPhone blocked it, the relay + * accepted it, and the iPhone showed it. Revoking delivery tokens does not + * help, a refused token falls back to the delivery key by design. The one + * place that knows the sender is the recipient, after it unseals, so the + * recipient has to refuse. This side did not either. + */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34]) +class ABlockedSenderStaysOutTest { + private lateinit var server: MockWebServer + + @BeforeTest fun start() { + server = MockWebServer().also { it.start() } + } + + @AfterTest fun stop() { + server.shutdown() + } + + private class MapStorage : Vault.Storage { + private val records = mutableMapOf() + + override fun read(name: String) = records[name] + + override fun write( + name: String, + bytes: ByteArray, + ) { + records[name] = bytes + } + + override fun delete(name: String) { + records.remove(name) + } + + override fun names() = records.keys.toList() + } + + private fun key(): SecretKey = KeyGenerator.getInstance("AES").apply { init(256) }.generateKey() + + private val me = 1L + private val blocked = 2L + private val friend = 3L + + private fun session(storage: Vault.Storage = MapStorage(), key: SecretKey = key()): Session { + val identity = DeviceIdentity.create() + val vault = Vault(storage, key).also { it.saveIdentity(identity) } + val base = server.url("/").toString() + return Session( + "alice", + me, + identity, + vault, + RelayClient(base, "alice", "app pass word"), + MessengerApi(base, "alice", "app pass word"), + ) + } + + private fun from( + user: Long, + text: String, + sealed: Boolean = true, + ) = Conversations.Received( + id = user * 100, + fromUser = user, + fromDevice = "phone-$user", + content = Content.parse(Content.text(text, messageId = "m-$user"))!!, + sealed = sealed, + ) + + @Test + fun `a sealed message from a blocked contact is not filed`() { + val s = session() + s.vault.saveBlockedSenders(setOf(blocked)) + + val touched = s.absorb(listOf(from(blocked, "you blocked me and here I am"), from(friend, "hello"))) + + assertTrue(s.history(Vault.threadForUser(blocked)).isEmpty(), "a blocked contact's message reached the thread") + assertEquals(setOf(Vault.threadForUser(friend)), touched, "the block also stopped somebody it does not name") + } + + /** Unblocking lets them back in. */ + @Test + fun `unblocking lets them back in`() { + val s = session() + s.vault.saveBlockedSenders(setOf(blocked)) + s.vault.saveBlockedSenders(emptySet()) + + s.absorb(listOf(from(blocked, "back again"))) + assertEquals(1, s.history(Vault.threadForUser(blocked)).size) + } + + /** It has to hold before the first fetch from the relay after a launch. */ + @Test + fun `the block survives a restart`() { + val storage = MapStorage() + val k = key() + session(storage, k).vault.saveBlockedSenders(setOf(blocked)) + assertEquals(setOf(blocked), session(storage, k).vault.blockedSenders()) + } + + /** Blocking and the relay's list both feed it. */ + @Test + fun `blocking records the sender locally`() { + val vm = File("src/main/kotlin/io/flamenet/messenger/app/MessengerViewModel.kt").readText() + val body = vm.substringAfter("fun setBlocked(").substringBefore("\n fun ") + assertTrue(body.contains("saveBlockedSenders("), "setBlocked never tells this device who is blocked") + } +} diff --git a/engine/src/main/kotlin/io/flamenet/e2e/Vault.kt b/engine/src/main/kotlin/io/flamenet/e2e/Vault.kt index 870a3ba..90a99f5 100644 --- a/engine/src/main/kotlin/io/flamenet/e2e/Vault.kt +++ b/engine/src/main/kotlin/io/flamenet/e2e/Vault.kt @@ -91,6 +91,7 @@ class Vault( private const val PINNED_PREFIX = "pinned-" private const val UNREAD_THREADS = "unread-threads" private const val DISAPPEAR = "disappear" + private const val BLOCKED_SENDERS = "blocked-senders" private const val OFF_RECORD = "offrecord" private const val POLLS = "polls" @@ -1195,6 +1196,22 @@ class Vault( .toMap() } + // -- Blocked senders -----------------------------------------------------. + + /** + * Who this account has blocked, kept here so a message from them can be + * refused on arrival. The relay can only enforce a block on an + * identified send: a sealed one does not say who sent it, so this device, + * which learns the sender on unsealing, is the only place that can. + */ + fun blockedSenders(): Set { + val list = readJson(BLOCKED_SENDERS)?.optJSONArray("ids") ?: return emptySet() + return (0 until list.length()).map { list.optLong(it) }.toSet() + } + + fun saveBlockedSenders(ids: Set) = + writeJson(BLOCKED_SENDERS, JSONObject().put("ids", org.json.JSONArray(ids.sorted()))) + // -- Disappearing messages (§6.11) ---------------------------------------. /** From 5b3d194d65171bcb3879858b834178f7a6d97093 Mon Sep 17 00:00:00 2001 From: Paul Hitt Date: Fri, 9 Oct 2026 06:08:27 -0400 Subject: [PATCH 5/6] Keep a message the relay never took, on Android The composer clears its draft the moment send is tapped, and Session.send recorded the line only after the relay answered. A send that threw on the way, no connection or a server error, left nothing behind: no bubble, no outbox entry, no draft. Found on the iPhone-to-Android pair with the local relay paused for ten seconds: the Android message appeared on neither phone, ever. A send that waiting can fix is now recorded as PENDING ("Undelivered, will send") and queued, so the outbox sends it when the relay is back, the same as a message to a peer with no device. Any other failure is recorded as FAILED, so it stays visible, and rethrown so the error is still shown. ASendTheRelayNeverSawIsKeptTest: 2 red before, 2 green after. Co-Authored-By: Claude Opus 5.5 --- .../io/flamenet/messenger/app/Session.kt | 45 +++++++- .../app/ASendTheRelayNeverSawIsKeptTest.kt | 108 ++++++++++++++++++ 2 files changed, 152 insertions(+), 1 deletion(-) create mode 100644 app/src/test/kotlin/io/flamenet/messenger/app/ASendTheRelayNeverSawIsKeptTest.kt diff --git a/app/src/main/kotlin/io/flamenet/messenger/app/Session.kt b/app/src/main/kotlin/io/flamenet/messenger/app/Session.kt index 1f5be6f..13cd2fb 100644 --- a/app/src/main/kotlin/io/flamenet/messenger/app/Session.kt +++ b/app/src/main/kotlin/io/flamenet/messenger/app/Session.kt @@ -519,7 +519,33 @@ class Session( link = link, lifetimeSeconds = lifetimeSeconds, ) - val sent = conversations.send(peerUserId, plaintext) + // The composer has already cleared the draft, so a send that throws + // must still leave the words somewhere. One the relay never answered + // is kept as PENDING and queued, the same as one to a peer with no + // device; anything else is shown as FAILED and the error rethrown. + val sent = + try { + conversations.send(peerUserId, plaintext) + } catch (e: Exception) { + val waiting = waitingCanFix(e) + record( + Vault.threadForUser(peerUserId), + text, + mine = true, + at = System.currentTimeMillis(), + link = link, + messageId = mid, + about = replyTo, + lifetimeSeconds = lifetimeSeconds, + delivery = if (waiting) Delivery.PENDING else Delivery.FAILED, + ) + if (!waiting) throw e + enqueue(peerUserId, plaintext, System.currentTimeMillis()) + // Nothing in `failed`: the bubble says "Undelivered, will + // send", and a banner telling them to send it again would be + // wrong, the outbox will. + return io.flamenet.client.Conversations.Sent(delivered = emptyList(), failed = emptyMap()) + } val at = System.currentTimeMillis() // The sender's own copy expires too. record( @@ -1043,6 +1069,23 @@ class Session( * Try everything waiting. */ + /** + * Whether a send that threw before the relay took it will go if tried + * again: no connection, a server error, or a rate limit. A refusal about + * keys or sealing will not, and is shown as failed instead. + */ + private fun waitingCanFix(e: Throwable): Boolean = + when (e) { + is io.flamenet.client.RelayException.Transport, + is io.flamenet.client.RelayException.RateLimited, + is java.io.IOException, + -> true + + is io.flamenet.client.RelayException.Http -> e.status >= 500 + + else -> false + } + /** * Whether waiting will ever resolve this, mirroring iOS * `E2EError.waitingCanFix`. diff --git a/app/src/test/kotlin/io/flamenet/messenger/app/ASendTheRelayNeverSawIsKeptTest.kt b/app/src/test/kotlin/io/flamenet/messenger/app/ASendTheRelayNeverSawIsKeptTest.kt new file mode 100644 index 0000000..7ebe590 --- /dev/null +++ b/app/src/test/kotlin/io/flamenet/messenger/app/ASendTheRelayNeverSawIsKeptTest.kt @@ -0,0 +1,108 @@ +package io.flamenet.messenger.app + +import io.flamenet.client.MessengerApi +import io.flamenet.client.RelayClient +import io.flamenet.e2e.DeviceIdentity +import io.flamenet.e2e.Vault +import okhttp3.mockwebserver.MockResponse +import okhttp3.mockwebserver.MockWebServer +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import javax.crypto.KeyGenerator +import javax.crypto.SecretKey +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals + +/** + * A message sent while the relay cannot be reached is kept, shown and retried. + * + * Found on the iPhone-to-Android pair, 2026-10-08, with the relay paused for + * ten seconds: the Android message never appeared on either phone. The + * composer clears its draft as soon as send is tapped, and `Session.send` + * recorded the line only AFTER the relay answered. A send that threw on the + * way left nothing: no bubble, no outbox entry, no draft. The outbox already + * handled a peer with no device; it never saw a relay that did not answer. + */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34]) +class ASendTheRelayNeverSawIsKeptTest { + private lateinit var server: MockWebServer + + @BeforeTest fun start() { + server = MockWebServer().also { it.start() } + } + + @AfterTest fun stop() { + runCatching { server.shutdown() } + } + + private class MapStorage : Vault.Storage { + private val records = mutableMapOf() + + override fun read(name: String) = records[name] + + override fun write( + name: String, + bytes: ByteArray, + ) { + records[name] = bytes + } + + override fun delete(name: String) { + records.remove(name) + } + + override fun names() = records.keys.toList() + } + + private fun key(): SecretKey = KeyGenerator.getInstance("AES").apply { init(256) }.generateKey() + + private val peer = 99L + + private fun session(): Session { + val identity = DeviceIdentity.create() + val vault = Vault(MapStorage(), key()).also { it.saveIdentity(identity) } + val base = server.url("/").toString() + return Session( + handle = "alice", + userId = 1, + identity = identity, + vault = vault, + relay = RelayClient(base, "alice", "app pass word"), + api = MessengerApi(base, "alice", "app pass word"), + ) + } + + private fun assertKept( + session: Session, + words: String, + ) { + val thread = session.history(Vault.threadForUser(peer)) + assertEquals(listOf(words), thread.map { it.text }, "the message vanished from the thread") + assertEquals(Session.Delivery.PENDING, thread.single().delivery, "it must say it has not gone") + assertEquals(1, session.vault.outbox().length(), "nothing will ever send it again") + } + + @Test + fun `a relay that does not answer at all`() { + val session = session() + server.shutdown() + + runCatching { session.send(peer, "are you there?") } + + assertKept(session, "are you there?") + } + + @Test + fun `a relay that answers with a server error`() { + val session = session() + repeat(4) { server.enqueue(MockResponse().setResponseCode(503)) } + + runCatching { session.send(peer, "try again later") } + + assertKept(session, "try again later") + } +} From cba942a64fc8fdd278fbdbe9e9da78b42c4530a0 Mon Sep 17 00:00:00 2001 From: Paul Hitt Date: Fri, 9 Oct 2026 06:48:45 -0400 Subject: [PATCH 6/6] Answer every request in the lost-send test, not a fixed four The server-error case queued four 503 responses. MockWebServer's queue blocks a request that arrives after the queue is empty, so a send making one request more than expected waits on the read timeout instead of failing. The unit-tests job on MR 160 sat in :app:testReleaseUnitTest for 29 minutes and was killed at the 30-minute limit; this is the one test on the branch with a fixed-length queue. A dispatcher now answers 503 to every request. The CI command, run locally with --rerun-tasks: app 711, engine 723, client 480 (30 live skipped), obfs4 75, 0 failures, 2m 3s. Co-Authored-By: Claude Opus 5.5 --- .../messenger/app/ASendTheRelayNeverSawIsKeptTest.kt | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/app/src/test/kotlin/io/flamenet/messenger/app/ASendTheRelayNeverSawIsKeptTest.kt b/app/src/test/kotlin/io/flamenet/messenger/app/ASendTheRelayNeverSawIsKeptTest.kt index 7ebe590..51c0513 100644 --- a/app/src/test/kotlin/io/flamenet/messenger/app/ASendTheRelayNeverSawIsKeptTest.kt +++ b/app/src/test/kotlin/io/flamenet/messenger/app/ASendTheRelayNeverSawIsKeptTest.kt @@ -99,7 +99,12 @@ class ASendTheRelayNeverSawIsKeptTest { @Test fun `a relay that answers with a server error`() { val session = session() - repeat(4) { server.enqueue(MockResponse().setResponseCode(503)) } + // Every request, however many the send makes. A queue of a fixed + // number would block the client on the first request past its end. + server.dispatcher = + object : okhttp3.mockwebserver.Dispatcher() { + override fun dispatch(request: okhttp3.mockwebserver.RecordedRequest) = MockResponse().setResponseCode(503) + } runCatching { session.send(peer, "try again later") }