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/main/kotlin/io/flamenet/messenger/app/MessengerViewModel.kt b/app/src/main/kotlin/io/flamenet/messenger/app/MessengerViewModel.kt index 4f109cd..ec33c22 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), ), ) } @@ -2120,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 @@ -2885,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 @@ -4368,6 +4368,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 +4396,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 +4774,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/main/kotlin/io/flamenet/messenger/app/Session.kt b/app/src/main/kotlin/io/flamenet/messenger/app/Session.kt index be6a6f8..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`. @@ -1351,7 +1394,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. */ - private 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() @@ -1364,7 +1419,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/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/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") + } +} 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..51c0513 --- /dev/null +++ b/app/src/test/kotlin/io/flamenet/messenger/app/ASendTheRelayNeverSawIsKeptTest.kt @@ -0,0 +1,113 @@ +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() + // 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") } + + assertKept(session, "try again later") + } +} 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") + } +} 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", + ) + } +} 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) ---------------------------------------. /**