Recognise mentions in a group message (§6.18)

Codec only — the app does not compose or act on them yet — but it must
already understand an envelope the iPhone app sends, and more
importantly must not be made to do work by one.

🔴 Bounded and deduplicated on receipt. The list comes off the wire, and
a hostile sender could name ten thousand ids in one envelope; the cost
of that lands on every recipient's device rather than theirs. Capped at
256, far above any real roster.

A malformed list does not break the message: it is an ordinary message
with an odd field, not a reason to drop what somebody wrote.

Refs paulhitt/flamenet-messenger-ios#49

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Paul Hitt
2026-09-02 15:56:26 -04:00
co-authored by Claude Opus 4.8
parent d5c2db4e6e
commit 19f4247b71
3 changed files with 110 additions and 2 deletions
+1
View File
@@ -98,6 +98,7 @@ From `E2E/E2EInbox.swift`, which is the real feature list.
| Drafts that survive a relaunch | ✅ sealed in the vault, not SharedPreferences |
| Forwarding (§6.16) | 🟡 codec done, in progress — an inbound forward is recognised and shown as one; originating needs #10 |
| Group message ids and quotes (§6.9) | ✅ transcript stores both; sent and queued copies share one id |
| Mentions (§6.18) | 🟡 codec done, in progress — an inbound mention parses and is bounded; composing and notifying need the UI |
| Photos | ✅ send via the system picker, render inline |
| Voice notes (record + play) | ✅ AAC/MP4, tap to record, tap to send |
| Stories, and seen-state | ✅ 24h, sent per-contact, expired bytes deleted |
@@ -70,6 +70,15 @@ data class Content(
* there, or it does nothing.
*/
val editOf: String? = null,
/**
* Who this group message is addressed to (§6.18).
*
* ⚠️ A claim by the sender, like every other field in here. It decides
* whether the RECEIVER is notified and nothing else — so the worst a
* dishonest one can achieve is a notification the reader is able to turn
* off, which is the rule the whole section rests on.
*/
val mentions: List<Long> = emptyList(),
) {
/**
@@ -347,9 +356,10 @@ data class Content(
about: Reference? = null,
groupName: String? = null,
forwarded: Boolean = false,
mentions: List<Long> = emptyList(),
): String = assemble("text", caption = body, groupId = groupId,
messageId = messageId, about = about, groupName = groupName,
forwarded = forwarded)
forwarded = forwarded, mentions = mentions)
fun attachment(
ref: Attachment.Ref,
@@ -433,11 +443,16 @@ data class Content(
streamUrl: String? = null,
groupName: String? = null,
forwarded: Boolean = false,
mentions: List<Long> = emptyList(),
): String {
val parts = mutableListOf("\"fnc\":1", "\"kind\":${quote(kind)}")
// ⚠️ `1`, not `true` — §6.10's shape for every flag on this
// envelope, and what the other two engines read first.
if (forwarded) parts += "\"fwd\":1"
// §6.18 — omitted when there are none, which is nearly every message.
if (mentions.isNotEmpty()) {
parts += "\"ment\":[${mentions.joinToString(",")}]"
}
messageId?.let { parts += "\"mid\":${quote(it)}" }
about?.let { parts += "\"re\":${it.toJson()}" }
caption?.let { parts += "\"caption\":${quote(it)}" }
@@ -548,6 +563,24 @@ data class Content(
* silently disappears is the worst possible way to handle a version
* this client does not know about.
*/
/**
* ⚠️ **Bounded and deduplicated.** The list comes off the wire, and a
* hostile sender could name ten thousand ids in one envelope — the cost
* of which lands on every recipient's device, not theirs. A group's
* roster is far smaller than this cap; anything near it is not real.
*/
const val MAXIMUM_MENTIONS = 256
private fun mentions(json: JSONObject): List<Long> {
val array = json.optJSONArray("ment") ?: return emptyList()
val seen = LinkedHashSet<Long>()
for (i in 0 until array.length()) {
if (seen.size >= MAXIMUM_MENTIONS) break
seen += array.optLong(i)
}
return seen.toList()
}
fun parse(plaintext: String): Content {
if (!plaintext.startsWith(MARKER)) return Content(plaintext)
val json = runCatching { JSONObject(plaintext) }.getOrNull()
@@ -616,7 +649,7 @@ data class Content(
if (kind == Kind.TEXT) {
val link = json.optJSONObject("lnk")?.let { LinkPreview.fromJson(it) }
return Content(caption, kind = kind, raw = json, groupId = groupId, link = link,
messageId = messageId, about = about)
messageId = messageId, about = about, mentions = mentions(json))
}
val ref = json.optJSONObject("att")
@@ -0,0 +1,74 @@
package io.flamenet.e2e
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertTrue
/**
* Addressing one person inside a group (§6.18).
*
* ⚠️ **The codec half.** The Android app does not compose or act on mentions
* yet, but it must already understand an envelope the iPhone app sends — and,
* more importantly, must not be made to do work by one. The bound below is the
* part that matters today.
*/
class MentionsTest {
@Test
fun `mentions ride the envelope`() {
val wire = Content.groupText("@Ada look", "g1", messageId = "m",
mentions = listOf(2L, 4L))
assertEquals(listOf(2L, 4L), Content.parse(wire).mentions)
}
/** Omitted when there are none, which is nearly every message. */
@Test
fun `a message with no mentions carries none`() {
val wire = Content.groupText("hello", "g1", messageId = "m")
assertFalse(wire.contains("ment"), "wrote an empty mention list: $wire")
assertTrue(Content.parse(wire).mentions.isEmpty())
}
/**
* 🔴 **Bounded.** The list comes off the wire, and a hostile sender could
* name ten thousand ids in one envelope — the cost of which lands on every
* recipient's device rather than theirs.
*/
@Test
fun `an absurd mention list is bounded`() {
val huge = (1..10_000).joinToString(",")
val wire = """{"fnc":1,"kind":"text","ment":[$huge],"gid":"g1","caption":"hi"}"""
assertEquals(Content.MAXIMUM_MENTIONS, Content.parse(wire).mentions.size)
}
@Test
fun `a repeated mention is counted once`() {
val wire = """{"fnc":1,"kind":"text","ment":[2,2,2,4],"gid":"g1","caption":"hi"}"""
assertEquals(listOf(2L, 4L), Content.parse(wire).mentions)
}
/** ⚠️ A malformed list must not throw — it is an ordinary message with an
* odd field, not a reason to drop what somebody wrote. */
@Test
fun `a malformed mention list does not break the message`() {
val wire = """{"fnc":1,"kind":"text","ment":"not an array","gid":"g1","caption":"hi"}"""
val content = Content.parse(wire)
assertEquals("hi", content.text)
assertTrue(content.mentions.isEmpty())
}
/** ⚠️ `fnc` first, like every other envelope. */
@Test
fun `the envelope starts with the marker`() {
assertTrue(Content.groupText("hi", "g1", mentions = listOf(2L))
.startsWith("{\"fnc\":1,"))
}
/** A mention does not stop a message being a message. */
@Test
fun `a mentioning message is still renderable`() {
assertTrue(Content.parse(
Content.groupText("@Ada hi", "g1", mentions = listOf(2L))).isRenderable)
}
}