Merge branch 'crossplat/android-cross-platform-fixes' into 'main'

Cross-platform fixes: reactions, edits, reply quotes, polls, blocking, lost sends

See merge request extant2000/flamenet-messenger-android!160
This commit was merged in pull request #187.
This commit is contained in:
Paul Hitt
2026-10-09 12:14:23 +00:00
9 changed files with 704 additions and 28 deletions
@@ -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 @Composable
private fun Bubble( private fun Bubble(
line: Session.Line, line: Session.Line,
@@ -2691,6 +2740,8 @@ private fun Bubble(
.border(Kit.hairline, Portal.rule(), shape) .border(Kit.hairline, Portal.rule(), shape)
}.padding(Kit.bubblePadding), }.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. // The kit puts the time ABOVE the bubble, outside it.
line.attachment?.let { ref -> line.attachment?.let { ref ->
// One field, two very different things. // One field, two very different things.
@@ -974,10 +974,7 @@ class MessengerViewModel(
// Read here, not left to the next poll tick. A poll // Read here, not left to the next poll tick. A poll
// waiting on your vote is the reason you opened the // waiting on your vote is the reason you opened the
// group, and it was blank for the first few seconds. // group, and it was blank for the first few seconds.
polls = polls = pollCards(current.conversations.polls, group.id),
current.conversations.polls
.pollsIn(group.id)
.sortedBy { p -> p.id },
), ),
unread = it.unread - thread, unread = it.unread - thread,
) )
@@ -1300,10 +1297,7 @@ class MessengerViewModel(
state.copy( state.copy(
openChat = openChat =
state.openChat.copy( state.openChat.copy(
polls = polls = pollCards(current.conversations.polls, gid),
current.conversations.polls
.pollsIn(gid)
.sortedBy { it.id },
), ),
) )
} }
@@ -2120,7 +2114,11 @@ class MessengerViewModel(
if (blocked) current.conversations.forgetHeldEnvelopes(userId) if (blocked) current.conversations.forgetHeldEnvelopes(userId)
val revoke = val revoke =
if (blocked) runCatching { current.conversations.revokeDeliveryTokens(userId) } else null 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 done
@@ -2885,6 +2883,8 @@ class MessengerViewModel(
var added = false var added = false
// Fail closed. // Fail closed.
val blocked = runCatching { current.api.blocked() }.getOrNull() ?: return@launch 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) { for (id in strangers) {
runCatching { runCatching {
if (id in blocked) return@runCatching if (id in blocked) return@runCatching
@@ -4368,6 +4368,19 @@ class MessengerViewModel(
update { it.copy(avatars = it.avatars + live) } 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()) { if (threads.isNotEmpty()) {
// Whoever just wrote may not be on the list at all, // Whoever just wrote may not be on the list at all,
// see adoptStrangers. // see adoptStrangers.
@@ -4383,22 +4396,7 @@ class MessengerViewModel(
} else { } else {
it it
} }
// Poll traffic is control traffic: withLines
// 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 },
)
}
}, },
// A thread that is already open is being // A thread that is already open is being
// read, so it does not also become unread. // 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. " + "Only text can be sent to somebody on another server for now. " +
"Pictures, files and stickers cannot go there yet.", "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<io.flamenet.e2e.PollBook.Entry> =
book.pollsIn(groupId).sortedBy { it.id }.map {
it.copy(piecesHeld = it.piecesHeld.toMutableMap(), sumsPublished = it.sumsPublished.toMutableMap())
}
@@ -519,7 +519,33 @@ class Session(
link = link, link = link,
lifetimeSeconds = lifetimeSeconds, 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() val at = System.currentTimeMillis()
// The sender's own copy expires too. // The sender's own copy expires too.
record( record(
@@ -1043,6 +1069,23 @@ class Session(
* Try everything waiting. * 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 * Whether waiting will ever resolve this, mirroring iOS
* `E2EError.waitingCanFix`. * `E2EError.waitingCanFix`.
@@ -1351,7 +1394,19 @@ class Session(
* decrypted messages and did not absorb them would report success and * decrypted messages and did not absorb them would report success and
* show nothing, which is the failure this whole change exists to stop. * show nothing, which is the failure this whole change exists to stop.
*/ */
private fun absorb(everything: List<io.flamenet.client.Conversations.Received>): Set<String> { internal fun absorb(received: List<io.flamenet.client.Conversations.Received>): Set<String> {
// 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 // Control traffic consumed during the poll may have carried
// group-name claims. // group-name claims.
drainGroupNames() 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<String>() val touched = mutableSetOf<String>()
val now = DeviceClock.expirySeconds() val now = DeviceClock.expirySeconds()
val incomingStories = mutableListOf<Vault.Story>() val incomingStories = mutableListOf<Vault.Story>()
@@ -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<String, ByteArray>()
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")
}
}
@@ -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<ComponentActivity>()
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")
}
}
@@ -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<String, ByteArray>()
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")
}
}
@@ -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<String, ByteArray>()
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")
}
}
@@ -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",
)
}
}
@@ -91,6 +91,7 @@ class Vault(
private const val PINNED_PREFIX = "pinned-" private const val PINNED_PREFIX = "pinned-"
private const val UNREAD_THREADS = "unread-threads" private const val UNREAD_THREADS = "unread-threads"
private const val DISAPPEAR = "disappear" private const val DISAPPEAR = "disappear"
private const val BLOCKED_SENDERS = "blocked-senders"
private const val OFF_RECORD = "offrecord" private const val OFF_RECORD = "offrecord"
private const val POLLS = "polls" private const val POLLS = "polls"
@@ -1195,6 +1196,22 @@ class Vault(
.toMap() .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<Long> {
val list = readJson(BLOCKED_SENDERS)?.optJSONArray("ids") ?: return emptySet()
return (0 until list.length()).map { list.optLong(it) }.toSet()
}
fun saveBlockedSenders(ids: Set<Long>) =
writeJson(BLOCKED_SENDERS, JSONObject().put("ids", org.json.JSONArray(ids.sorted())))
// -- Disappearing messages (§6.11) ---------------------------------------. // -- Disappearing messages (§6.11) ---------------------------------------.
/** /**