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:
co-authored by
Claude Opus 4.8
parent
d5c2db4e6e
commit
19f4247b71
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user