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:
@@ -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) ---------------------------------------.
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
Reference in New Issue
Block a user