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,
|
val link: LinkPreview? = null,
|
||||||
/** A live stream this story embeds (§6.13). */
|
/** A live stream this story embeds (§6.13). */
|
||||||
val streamUrl: String? = null,
|
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,
|
messageId: String? = null,
|
||||||
about: Reference? = null,
|
about: Reference? = null,
|
||||||
groupName: String? = null,
|
groupName: String? = null,
|
||||||
|
mentions: List<Int> = emptyList(),
|
||||||
): String = assemble("text", caption = body, groupId = groupId,
|
): 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(
|
fun attachment(
|
||||||
ref: Attachment.Ref,
|
ref: Attachment.Ref,
|
||||||
@@ -374,6 +442,8 @@ data class Content(
|
|||||||
groupName: String? = null,
|
groupName: String? = null,
|
||||||
filename: String? = null,
|
filename: String? = null,
|
||||||
label: Pair<Int, List<Int>>? = null,
|
label: Pair<Int, List<Int>>? = null,
|
||||||
|
mentions: List<Int> = emptyList(),
|
||||||
|
aboutAStory: Boolean = false,
|
||||||
): String {
|
): String {
|
||||||
val parts = mutableListOf("\"fnc\":1", "\"kind\":${quote(kind)}")
|
val parts = mutableListOf("\"fnc\":1", "\"kind\":${quote(kind)}")
|
||||||
messageId?.let { parts += "\"mid\":${quote(it)}" }
|
messageId?.let { parts += "\"mid\":${quote(it)}" }
|
||||||
@@ -396,6 +466,10 @@ data class Content(
|
|||||||
// predates them ignores them and still reads the caption, which is
|
// predates them ignores them and still reads the caption, which is
|
||||||
// what keeps a file message legible to an older peer.
|
// what keeps a file message legible to an older peer.
|
||||||
filename?.takeIf { it.isNotEmpty() }?.let { parts += "\"fn\":${quote(it)}" }
|
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) {
|
if (label != null) {
|
||||||
parts += "\"cls\":${label.first}"
|
parts += "\"cls\":${label.first}"
|
||||||
parts += "\"cmp\":${label.second.sorted().joinToString(",", "[", "]")}"
|
parts += "\"cmp\":${label.second.sorted().joinToString(",", "[", "]")}"
|
||||||
@@ -450,13 +524,30 @@ data class Content(
|
|||||||
val messageId = json.optString("mid").takeIf { it.isNotEmpty() }
|
val messageId = json.optString("mid").takeIf { it.isNotEmpty() }
|
||||||
val about = json.optJSONObject("re")?.let(Reference::fromJson)
|
val about = json.optJSONObject("re")?.let(Reference::fromJson)
|
||||||
val caption = json.optString("caption")
|
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.
|
// A reaction is not a message and must never become a bubble.
|
||||||
// Checked before everything else so a field-ordering change cannot
|
// Checked before everything else so a field-ordering change cannot
|
||||||
// let one fall through and render as an empty line in the thread.
|
// let one fall through and render as an empty line in the thread.
|
||||||
if (kind == Kind.REACT && about != null) {
|
if (kind == Kind.REACT && about != null) {
|
||||||
return Content("", kind = kind, raw = json, groupId = groupId,
|
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
|
// 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) {
|
if (kind == Kind.TEXT) {
|
||||||
val link = json.optJSONObject("lnk")?.let { LinkPreview.fromJson(it) }
|
val link = json.optJSONObject("lnk")?.let { LinkPreview.fromJson(it) }
|
||||||
return Content(caption, kind = kind, raw = json, groupId = groupId, link = link,
|
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")
|
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