From 19f4247b71b75beb07c88625b29be90d15387c84 Mon Sep 17 00:00:00 2001 From: Paul Hitt Date: Wed, 2 Sep 2026 08:31:30 -0400 Subject: [PATCH] =?UTF-8?q?Recognise=20mentions=20in=20a=20group=20message?= =?UTF-8?q?=20(=C2=A76.18)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- PARITY.md | 1 + .../main/kotlin/io/flamenet/e2e/Content.kt | 37 +++++++++- .../kotlin/io/flamenet/e2e/MentionsTest.kt | 74 +++++++++++++++++++ 3 files changed, 110 insertions(+), 2 deletions(-) create mode 100644 engine/src/test/kotlin/io/flamenet/e2e/MentionsTest.kt diff --git a/PARITY.md b/PARITY.md index f38dd5e..c444ae9 100644 --- a/PARITY.md +++ b/PARITY.md @@ -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 | diff --git a/engine/src/main/kotlin/io/flamenet/e2e/Content.kt b/engine/src/main/kotlin/io/flamenet/e2e/Content.kt index eafb828..56f6dd1 100644 --- a/engine/src/main/kotlin/io/flamenet/e2e/Content.kt +++ b/engine/src/main/kotlin/io/flamenet/e2e/Content.kt @@ -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 = emptyList(), ) { /** @@ -347,9 +356,10 @@ data class Content( about: Reference? = null, groupName: String? = null, forwarded: Boolean = false, + mentions: List = 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 = 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 { + val array = json.optJSONArray("ment") ?: return emptyList() + val seen = LinkedHashSet() + 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") diff --git a/engine/src/test/kotlin/io/flamenet/e2e/MentionsTest.kt b/engine/src/test/kotlin/io/flamenet/e2e/MentionsTest.kt new file mode 100644 index 0000000..c64b39b --- /dev/null +++ b/engine/src/test/kotlin/io/flamenet/e2e/MentionsTest.kt @@ -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) + } +} -- 2.54.0