diff --git a/engine/src/main/kotlin/io/flamenet/e2e/Content.kt b/engine/src/main/kotlin/io/flamenet/e2e/Content.kt index 7cbc2df..1563b47 100644 --- a/engine/src/main/kotlin/io/flamenet/e2e/Content.kt +++ b/engine/src/main/kotlin/io/flamenet/e2e/Content.kt @@ -205,6 +205,22 @@ data class Content( raw?.optBoolean("ctl", false) == true || (raw?.optInt("ctl", 0) ?: 0) != 0 + /** + * True when the sender is passing on somebody else's words (§6.16). + * + * ⚠️ Says THAT it was forwarded and never from whom. The point of a forward + * is that the original author does not travel with it; this exists so + * second-hand words are not shown as the sender's own, which is a different + * thing from attribution. + * + * ⚠️ Both shapes accepted, for the same reason [isControl] accepts both: + * `org.json`'s `optBoolean` does not coerce a number, so reading only that + * would miss every `"fwd":1` the other engines send. + */ + val isForwarded: Boolean + get() = raw?.optBoolean("fwd", false) == true || + (raw?.optInt("fwd", 0) ?: 0) != 0 + /** * A kind this build does not know, which is not control traffic. * @@ -339,10 +355,16 @@ data class Content( messageId: String? = null, about: Reference? = null, link: LinkPreview? = null, + forwarded: Boolean = false, ): String = - if (messageId == null && about == null && link == null) body + // ⚠️ `forwarded` belongs in this condition, not just in the + // envelope. A plain message with nothing else to carry goes out as + // raw bytes with no envelope at all — so a forward that was not + // counted here would lose its flag and arrive looking like the + // sender's own words. + if (messageId == null && about == null && link == null && !forwarded) body else assemble("text", caption = body, messageId = messageId, about = about, - link = link) + link = link, forwarded = forwarded) fun groupText( body: String, @@ -351,9 +373,43 @@ data class Content( about: Reference? = null, groupName: String? = null, mentions: List = emptyList(), + forwarded: Boolean = false, ): String = assemble("text", caption = body, groupId = groupId, messageId = messageId, about = about, groupName = groupName, - mentions = mentions) + mentions = mentions, forwarded = forwarded) + + fun attachment( + ref: Attachment.Ref, + caption: String? = null, + groupId: String? = null, + messageId: String? = null, + about: Reference? = null, + /** A live stream this story embeds (§6.13); [ref] is then a poster. */ + streamUrl: String? = null, + groupName: String? = null, + forwarded: Boolean = false, + ): String = assemble( + kind = ref.kind, + // 🔴 A file envelope ALWAYS carries a caption, even with none + // written. Builds that predate this kind show the caption and drop + // the rest, so an empty one reaches them as "[unsupported + // attachment]" with no clue what was sent. Matches iOS exactly. + caption = caption?.takeIf { it.isNotEmpty() } + ?: "📎 ".plus(ref.filename.ifEmpty { "A file" }) + .takeIf { ref.kind == Attachment.FILE }, + expires = ref.expires.takeIf { ref.kind == Attachment.STORY && it > 0 }, + groupId = groupId, + attachment = ref, + filename = ref.filename.takeIf { ref.kind == Attachment.FILE }, + label = if (ref.kind == Attachment.FILE) { + ref.classification to ref.compartments + } else null, + messageId = messageId, + about = about, + streamUrl = streamUrl, + groupName = groupName, + forwarded = forwarded, + ) /** * A story that is only words. @@ -391,37 +447,6 @@ data class Content( assemble("text", caption = body, messageId = messageId, about = Reference(storyKey, author, preview), aboutAStory = true) - fun attachment( - ref: Attachment.Ref, - caption: String? = null, - groupId: String? = null, - messageId: String? = null, - about: Reference? = null, - /** A live stream this story embeds (§6.13); [ref] is then a poster. */ - streamUrl: String? = null, - groupName: String? = null, - ): String = assemble( - kind = ref.kind, - // 🔴 A file envelope ALWAYS carries a caption, even with none - // written. Builds that predate this kind show the caption and drop - // the rest, so an empty one reaches them as "[unsupported - // attachment]" with no clue what was sent. Matches iOS exactly. - caption = caption?.takeIf { it.isNotEmpty() } - ?: "📎 ".plus(ref.filename.ifEmpty { "A file" }) - .takeIf { ref.kind == Attachment.FILE }, - expires = ref.expires.takeIf { ref.kind == Attachment.STORY && it > 0 }, - groupId = groupId, - attachment = ref, - filename = ref.filename.takeIf { ref.kind == Attachment.FILE }, - label = if (ref.kind == Attachment.FILE) { - ref.classification to ref.compartments - } else null, - messageId = messageId, - about = about, - streamUrl = streamUrl, - groupName = groupName, - ) - /** * ⚠️ An empty emoji takes a previous reaction back. Removal has to be a * message rather than an absence: the other device has already rendered @@ -484,8 +509,12 @@ data class Content( label: Pair>? = null, mentions: List = emptyList(), aboutAStory: Boolean = false, + forwarded: Boolean = false, ): 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" messageId?.let { parts += "\"mid\":${quote(it)}" } about?.let { parts += "\"re\":${it.toJson()}" } caption?.let { parts += "\"caption\":${quote(it)}" } diff --git a/engine/src/test/kotlin/io/flamenet/e2e/ForwardWithoutProvenanceTest.kt b/engine/src/test/kotlin/io/flamenet/e2e/ForwardWithoutProvenanceTest.kt new file mode 100644 index 0000000..9b8218c --- /dev/null +++ b/engine/src/test/kotlin/io/flamenet/e2e/ForwardWithoutProvenanceTest.kt @@ -0,0 +1,96 @@ +package io.flamenet.e2e + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNull +import kotlin.test.assertTrue +import org.json.JSONObject + +/** + * Passing a message on without passing on where it came from (§6.16). + * + * ⚠️ **The codec half.** The Android app cannot forward yet — that needs the + * transcript work in #10 — but it must already understand an envelope the + * iPhone app sends, so a forwarded message arrives as a message and not as + * something the reader is told nothing about. + */ +class ForwardWithoutProvenanceTest { + + @Test + fun `a forwarded message is marked as forwarded`() { + val content = Content.parse(Content.text("meet at 6", forwarded = true)) + assertTrue(content.isForwarded) + assertEquals("meet at 6", content.text) + } + + @Test + fun `an ordinary message is not marked forwarded`() { + assertFalse(Content.parse(Content.text("meet at 6")).isForwarded) + } + + /** + * 🔴 The whole point: the envelope says THAT it was forwarded and nothing + * about who wrote it or what it answered. + */ + @Test + fun `the envelope carries no author and no reply chain`() { + val wire = Content.text("meet at 6", forwarded = true) + assertFalse(wire.contains("author"), "the envelope carries an author: $wire") + assertFalse(wire.contains("\"re\""), "the envelope carries a reply chain: $wire") + assertNull(Content.parse(wire).about) + } + + /** + * ⚠️ `1`, not `true` — §6.10's shape. `org.json`'s optBoolean does not + * coerce a number, so an engine that read only one shape would silently + * miss every forward the other two engines send. + */ + @Test + fun `both flag shapes are accepted`() { + val asNumber = Content.text("hi", forwarded = true) + assertTrue(asNumber.contains("\"fwd\":1"), "wrote the wrong shape: $asNumber") + assertTrue(Content.parse(asNumber).isForwarded) + + val asBool = asNumber.replace("\"fwd\":1", "\"fwd\":true") + assertTrue(Content.parse(asBool).isForwarded, + "an engine writing `true` would have its forwards read as ordinary messages") + } + + /** ⚠️ A forward is still a message: it renders, unlike control traffic. */ + @Test + fun `a forwarded message is still renderable`() { + assertTrue(Content.parse(Content.text("meet at 6", forwarded = true)).isRenderable) + } + + /** The flag rides an attachment too — a forwarded photo is still a forward. */ + @Test + fun `a forwarded attachment keeps the flag`() { + val ref = Attachment.Ref( + kind = Attachment.IMAGE, id = "a".repeat(64), + key = ByteArray(32) { 1 }, digest = ByteArray(32) { 2 }, + bytes = 1024, mime = "image/jpeg", width = 100, height = 100, + duration = 0.0, expires = 0.0, + ) + val content = Content.parse( + Content.attachment(ref, caption = "look", forwarded = true)) + assertTrue(content.isForwarded) + assertEquals("look", content.text) + } + + /** ⚠️ `fnc` first, like every other envelope. */ + @Test + fun `the envelope starts with the marker`() { + assertTrue(Content.text("hi", forwarded = true).startsWith("{\"fnc\":1,")) + } + + /** + * A missing flag is not a forward — the ordinary case, and the one that + * every message sent before today takes. + */ + @Test + fun `an envelope with no flag is not a forward`() { + val json = JSONObject("""{"fnc":1,"kind":"text","caption":"hi"}""") + assertFalse(Content.parse(json.toString()).isForwarded) + } +}