Understand stories and mentions from iOS
Engine parity for the five features iOS regained: text stories, mentions, story reactions, story replies. This build could already receive them — legibly, because each degrades to its caption — but understood none of them as what they are. 🔴 Every envelope in the test is the shape **iOS** assembles, not this engine's own output. Two implementations that each only test against themselves agree right up to the day somebody sends a real message, and the symptom is a mention that never rings or a story that arrives as "[unsupported attachment]", with nothing in either log to say why. Two things the tests caught that reasoning would not have: ⚠️ Kotlin prints a Double in scientific notation, so this engine writes `"exp":1.8E9` where iOS writes `1800000000.0`. Both are valid JSON and both sides read either — but a story's expiry decides whether it shows at all, and "arrived already expired" is indistinguishable from "never arrived". Now pinned on both sides. Not a new risk: picture stories have carried `exp` this way all along, which is the reason it works. ⚠️ An earlier version of one test built a JSONObject from an assembled envelope and re-serialised it — which reordered the keys and moved `fnc` off the front. It failed, correctly, and is a neat demonstration of why both engines hand-assemble these strings instead of encoding a map. UI comes next; this is the layer that decides whether the two apps agree about what a message means. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
688a519e0c
commit
bfe9fff430
@@ -61,6 +61,36 @@ data class Content(
|
||||
val link: LinkPreview? = null,
|
||||
/** A live stream this story embeds (§6.13). */
|
||||
val streamUrl: String? = null,
|
||||
|
||||
/**
|
||||
* Who was named in this message, by user id.
|
||||
*
|
||||
* 🔴 Ids, not names. A mention has to reach somebody whose group is muted,
|
||||
* and matching on a display name would mean anybody could be reached by
|
||||
* typing their name — or missed, because they had renamed themselves.
|
||||
*/
|
||||
val mentions: List<Int> = emptyList(),
|
||||
|
||||
/**
|
||||
* When this is a story made only of words, when it stops showing.
|
||||
*
|
||||
* 🔴 A story envelope with no attachment. Every story path here assumed a
|
||||
* picture, so a text story had nowhere to be carried — the same shape that
|
||||
* was missing on iOS.
|
||||
*/
|
||||
val textStoryExpires: Double? = null,
|
||||
|
||||
/**
|
||||
* This reaction is about a STORY, not a message.
|
||||
*
|
||||
* ⚠️ Both are `kind: "react"`; only this separates them. Without it a story
|
||||
* reaction tries to fold its emoji onto a message with that id — and if one
|
||||
* ever matched, onto entirely the wrong thing.
|
||||
*/
|
||||
val reactsToStory: Boolean = false,
|
||||
|
||||
/** This message replies to a story rather than to a bubble. */
|
||||
val repliesToStory: Boolean = false,
|
||||
) {
|
||||
|
||||
/**
|
||||
@@ -297,8 +327,46 @@ data class Content(
|
||||
messageId: String? = null,
|
||||
about: Reference? = null,
|
||||
groupName: String? = null,
|
||||
mentions: List<Int> = emptyList(),
|
||||
): String = assemble("text", caption = body, groupId = groupId,
|
||||
messageId = messageId, about = about, groupName = groupName)
|
||||
messageId = messageId, about = about, groupName = groupName,
|
||||
mentions = mentions)
|
||||
|
||||
/**
|
||||
* A story that is only words.
|
||||
*
|
||||
* ⚠️ Carries a [messageId], which is what identifies it across the
|
||||
* sender's devices — a picture story is identified by its blob token
|
||||
* and a text story has none. Without it the same story appears once
|
||||
* per phone the sender posted from.
|
||||
*/
|
||||
fun textStory(text: String, expires: Double, messageId: String): String =
|
||||
assemble(Attachment.STORY, caption = text, expires = expires,
|
||||
messageId = messageId)
|
||||
|
||||
/**
|
||||
* React to somebody's story.
|
||||
*
|
||||
* 🔴 Goes to the AUTHOR alone. A story is sent to each viewer rather
|
||||
* than hosted, so there is no shared object to attach a reaction to
|
||||
* and nobody else to tell — which is why other viewers never learn who
|
||||
* reacted: there is nowhere for them to look.
|
||||
*/
|
||||
fun storyReaction(emoji: String, storyKey: String, author: Long): String =
|
||||
assemble(Kind.REACT, caption = emoji,
|
||||
about = Reference(storyKey, author, ""), aboutAStory = true)
|
||||
|
||||
/**
|
||||
* Reply to somebody's story, as an ordinary sealed direct message.
|
||||
*
|
||||
* ⚠️ [preview] travels with the reply because stories expire: by the
|
||||
* time this is read the story may be gone, and "replied to your story"
|
||||
* with no idea which one cannot be understood.
|
||||
*/
|
||||
fun storyReply(body: String, storyKey: String, author: Long,
|
||||
preview: String, messageId: String): String =
|
||||
assemble("text", caption = body, messageId = messageId,
|
||||
about = Reference(storyKey, author, preview), aboutAStory = true)
|
||||
|
||||
fun attachment(
|
||||
ref: Attachment.Ref,
|
||||
@@ -374,6 +442,8 @@ data class Content(
|
||||
groupName: String? = null,
|
||||
filename: String? = null,
|
||||
label: Pair<Int, List<Int>>? = null,
|
||||
mentions: List<Int> = emptyList(),
|
||||
aboutAStory: Boolean = false,
|
||||
): String {
|
||||
val parts = mutableListOf("\"fnc\":1", "\"kind\":${quote(kind)}")
|
||||
messageId?.let { parts += "\"mid\":${quote(it)}" }
|
||||
@@ -396,6 +466,10 @@ data class Content(
|
||||
// predates them ignores them and still reads the caption, which is
|
||||
// what keeps a file message legible to an older peer.
|
||||
filename?.takeIf { it.isNotEmpty() }?.let { parts += "\"fn\":${quote(it)}" }
|
||||
if (mentions.isNotEmpty()) {
|
||||
parts += "\"men\":${mentions.sorted().joinToString(",", "[", "]")}"
|
||||
}
|
||||
if (aboutAStory) parts += "\"st\":1"
|
||||
if (label != null) {
|
||||
parts += "\"cls\":${label.first}"
|
||||
parts += "\"cmp\":${label.second.sorted().joinToString(",", "[", "]")}"
|
||||
@@ -450,13 +524,30 @@ data class Content(
|
||||
val messageId = json.optString("mid").takeIf { it.isNotEmpty() }
|
||||
val about = json.optJSONObject("re")?.let(Reference::fromJson)
|
||||
val caption = json.optString("caption")
|
||||
// Additive keys. An older peer omits them and every default here
|
||||
// is what that peer meant.
|
||||
val aboutAStory = json.optInt("st", 0) != 0 || json.optBoolean("st", false)
|
||||
val mentions = json.optJSONArray("men")?.let { array ->
|
||||
(0 until array.length()).map { array.optInt(it) }
|
||||
} ?: emptyList()
|
||||
|
||||
// A reaction is not a message and must never become a bubble.
|
||||
// Checked before everything else so a field-ordering change cannot
|
||||
// let one fall through and render as an empty line in the thread.
|
||||
if (kind == Kind.REACT && about != null) {
|
||||
return Content("", kind = kind, raw = json, groupId = groupId,
|
||||
messageId = messageId, about = about, reaction = caption)
|
||||
messageId = messageId, about = about, reaction = caption,
|
||||
reactsToStory = aboutAStory)
|
||||
}
|
||||
|
||||
// A story made of words. Checked before the attachment paths, which
|
||||
// would otherwise treat it as an attachment reference that failed
|
||||
// to parse — and before the "[unsupported attachment]" fallback an
|
||||
// older build correctly shows for it.
|
||||
if (kind == Attachment.STORY && json.optJSONObject("att") == null) {
|
||||
return Content(caption, kind = kind, raw = json, groupId = groupId,
|
||||
messageId = messageId,
|
||||
textStoryExpires = json.optDouble("exp", 0.0))
|
||||
}
|
||||
|
||||
// A sticker is renderable but is NOT text: it draws as a glyph with
|
||||
@@ -498,7 +589,9 @@ 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,
|
||||
repliesToStory = aboutAStory && about != null)
|
||||
}
|
||||
|
||||
val ref = json.optJSONObject("att")
|
||||
|
||||
@@ -0,0 +1,171 @@
|
||||
package io.flamenet.e2e
|
||||
|
||||
import org.json.JSONObject
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertFalse
|
||||
import kotlin.test.assertNotNull
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
/**
|
||||
* Stories and mentions, against envelopes the **iOS** build produces.
|
||||
*
|
||||
* 🔴 Every string below is the shape iOS assembles, not this engine's own
|
||||
* output. Two implementations that each only test against themselves agree
|
||||
* right up to the day somebody sends a real message — and the symptom is a
|
||||
* mention that never rings, or a story that arrives as "[unsupported
|
||||
* attachment]", with nothing in either log to say why.
|
||||
*/
|
||||
class StoryAndMentionInteropTest {
|
||||
|
||||
// MARK: text stories
|
||||
|
||||
private val iosTextStory =
|
||||
"""{"fnc":1,"kind":"story","mid":"story-1","caption":"only words","exp":1800000000.0}"""
|
||||
|
||||
@Test
|
||||
fun `a text story from iOS parses as a story rather than an attachment`() {
|
||||
val content = Content.parse(iosTextStory)
|
||||
assertEquals("only words", content.text)
|
||||
assertEquals(1800000000.0, content.textStoryExpires)
|
||||
assertEquals(null, content.attachment)
|
||||
assertEquals("story-1", content.messageId)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `this engine assembles a text story the same way`() {
|
||||
val wire = Content.textStory("hello", 1800000000.0, "m-1")
|
||||
// ⚠️ Asserted on the WIRE STRING, not on a JSONObject built from it.
|
||||
// Round-tripping through JSONObject reorders the keys and puts `fnc`
|
||||
// somewhere other than first — which is exactly the hazard that makes
|
||||
// both engines hand-assemble these strings. An earlier version of this
|
||||
// test did that and failed, correctly.
|
||||
assertTrue(wire.startsWith("{\"fnc\":1"), wire)
|
||||
assertTrue(wire.contains("\"kind\":\"story\""), wire)
|
||||
assertTrue(wire.contains("\"caption\":\"hello\""), wire)
|
||||
assertEquals("hello", Content.parse(wire).text)
|
||||
}
|
||||
|
||||
/**
|
||||
* ⚠️ Kotlin prints a Double in scientific notation, so this engine writes
|
||||
* `"exp":1.8E9` where iOS writes `1800000000.0`. Both are valid JSON and
|
||||
* both sides read either — but a story's expiry decides whether it shows
|
||||
* at all, so the shape is pinned here rather than assumed. (This is not
|
||||
* new: picture stories have carried `exp` this way all along.)
|
||||
*/
|
||||
@Test
|
||||
fun `an expiry written in scientific notation reads back`() {
|
||||
val wire = Content.textStory("x", 1800000000.0, "m")
|
||||
assertTrue(wire.contains("\"exp\":1.8E9"), wire)
|
||||
assertEquals(1800000000.0, Content.parse(wire).textStoryExpires)
|
||||
}
|
||||
|
||||
/** 🔴 A picture story must be completely unaffected. */
|
||||
@Test
|
||||
fun `a picture story still parses as an attachment`() {
|
||||
val ref = Attachment.Ref(
|
||||
kind = Attachment.STORY, id = "a".repeat(64),
|
||||
key = ByteArray(32) { 0x11 }, digest = ByteArray(32) { 0x22 },
|
||||
bytes = 10, mime = "image/jpeg", width = 4, height = 3, expires = 1800000000.0,
|
||||
)
|
||||
val content = Content.parse(Content.attachment(ref, "look"))
|
||||
assertNotNull(content.attachment)
|
||||
assertEquals(null, content.textStoryExpires)
|
||||
}
|
||||
|
||||
// MARK: mentions
|
||||
|
||||
@Test
|
||||
fun `mentions from iOS arrive as ids`() {
|
||||
val ios = """{"fnc":1,"kind":"text","caption":"morning @Ada","gid":"g1","men":[3,7]}"""
|
||||
val content = Content.parse(ios)
|
||||
assertEquals(listOf(3, 7), content.mentions)
|
||||
assertEquals("g1", content.groupId)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `this engine emits mentions the same way`() {
|
||||
val json = Content.groupText("hi @Ada", "g1", mentions = listOf(7, 3))
|
||||
assertTrue(json.contains("\"men\":[3,7]"), json)
|
||||
assertEquals(listOf(3, 7), Content.parse(json).mentions)
|
||||
}
|
||||
|
||||
/** ⚠️ A message that merely spells a name carries no mention. */
|
||||
@Test
|
||||
fun `spelling a name is not a mention`() {
|
||||
val json = Content.groupText("hello @Ada", "g1")
|
||||
assertFalse(json.contains("\"men\""))
|
||||
assertTrue(Content.parse(json).mentions.isEmpty())
|
||||
}
|
||||
|
||||
// MARK: story reactions
|
||||
|
||||
@Test
|
||||
fun `a story reaction from iOS is not mistaken for a message reaction`() {
|
||||
val ios = """{"fnc":1,"kind":"react","caption":"❤️","re":{"mid":"s-1","author":42,"preview":""},"st":1}"""
|
||||
val content = Content.parse(ios)
|
||||
assertTrue(content.reactsToStory)
|
||||
assertEquals("❤️", content.reaction)
|
||||
assertEquals("s-1", content.about?.messageId)
|
||||
}
|
||||
|
||||
/**
|
||||
* 🔴 The separation that matters. Both are `kind: "react"`; without the
|
||||
* flag a story reaction folds onto a message with that id — and if one
|
||||
* ever matched, onto entirely the wrong thing.
|
||||
*/
|
||||
@Test
|
||||
fun `an ordinary message reaction is not read as a story one`() {
|
||||
val json = Content.reaction("👍", Content.Reference("m-1", 5, ""))
|
||||
val content = Content.parse(json)
|
||||
assertFalse(content.reactsToStory)
|
||||
assertFalse(json.contains("\"st\""))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `this engine emits a story reaction the same way`() {
|
||||
val json = Content.storyReaction("😂", "s-2", 9L)
|
||||
assertTrue(json.contains("\"st\":1"), json)
|
||||
val content = Content.parse(json)
|
||||
assertTrue(content.reactsToStory)
|
||||
assertEquals("😂", content.reaction)
|
||||
}
|
||||
|
||||
// MARK: story replies
|
||||
|
||||
@Test
|
||||
fun `a story reply from iOS keeps what it is about`() {
|
||||
val ios = """{"fnc":1,"kind":"text","mid":"m-9","caption":"lovely","re":{"mid":"s-3","author":1,"preview":"at the beach"},"st":1}"""
|
||||
val content = Content.parse(ios)
|
||||
assertTrue(content.repliesToStory)
|
||||
assertEquals("lovely", content.text)
|
||||
assertEquals("at the beach", content.about?.preview)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an ordinary reply is not a story reply`() {
|
||||
val json = Content.text("sure", messageId = "m-1",
|
||||
about = Content.Reference("other", 2, "earlier"))
|
||||
assertFalse(Content.parse(json).repliesToStory)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `this engine emits a story reply the same way`() {
|
||||
val json = Content.storyReply("nice", "s-4", 3L, "sunset", "m-2")
|
||||
val content = Content.parse(json)
|
||||
assertTrue(content.repliesToStory)
|
||||
assertEquals("sunset", content.about?.preview)
|
||||
assertEquals("nice", content.text)
|
||||
}
|
||||
|
||||
/** 🔴 fnc stays first, whatever else was added. */
|
||||
@Test
|
||||
fun `every envelope this engine builds starts with fnc`() {
|
||||
listOf(
|
||||
Content.textStory("x", 1.0, "m"),
|
||||
Content.storyReaction("👍", "s", 1L),
|
||||
Content.storyReply("hi", "s", 1L, "p", "m"),
|
||||
Content.groupText("hi", "g", mentions = listOf(1)),
|
||||
).forEach { assertTrue(it.startsWith("{\"fnc\":1"), it) }
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user