Recognise a forwarded message (§6.16)
An inbound forward is shown as one rather than as the sender's own words. Both flag shapes accepted, like ctl: org.json's optBoolean does not coerce a number, so an engine reading one shape would silently miss every forward the other two send. ⚠️ The subtle one: Content.text returns raw bytes with no envelope at all when there is no id, reply or link to carry. A forward that was not counted in that condition would lose its flag and arrive looking like the sender's own composition — so is part of the test for whether an envelope is needed, not just a field inside it. Codec only. Originating a forward needs the transcript work in #10. Refs paulhitt/flamenet-messenger-ios#28 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
4b295c2d73
commit
3acaf429e5
@@ -205,6 +205,22 @@ data class Content(
|
|||||||
raw?.optBoolean("ctl", false) == true ||
|
raw?.optBoolean("ctl", false) == true ||
|
||||||
(raw?.optInt("ctl", 0) ?: 0) != 0
|
(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.
|
* A kind this build does not know, which is not control traffic.
|
||||||
*
|
*
|
||||||
@@ -339,10 +355,16 @@ data class Content(
|
|||||||
messageId: String? = null,
|
messageId: String? = null,
|
||||||
about: Reference? = null,
|
about: Reference? = null,
|
||||||
link: LinkPreview? = null,
|
link: LinkPreview? = null,
|
||||||
|
forwarded: Boolean = false,
|
||||||
): String =
|
): 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,
|
else assemble("text", caption = body, messageId = messageId, about = about,
|
||||||
link = link)
|
link = link, forwarded = forwarded)
|
||||||
|
|
||||||
fun groupText(
|
fun groupText(
|
||||||
body: String,
|
body: String,
|
||||||
@@ -351,9 +373,43 @@ data class Content(
|
|||||||
about: Reference? = null,
|
about: Reference? = null,
|
||||||
groupName: String? = null,
|
groupName: String? = null,
|
||||||
mentions: List<Int> = emptyList(),
|
mentions: List<Int> = emptyList(),
|
||||||
|
forwarded: Boolean = false,
|
||||||
): 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)
|
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.
|
* A story that is only words.
|
||||||
@@ -391,37 +447,6 @@ data class Content(
|
|||||||
assemble("text", caption = body, messageId = messageId,
|
assemble("text", caption = body, messageId = messageId,
|
||||||
about = Reference(storyKey, author, preview), aboutAStory = true)
|
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
|
* ⚠️ An empty emoji takes a previous reaction back. Removal has to be a
|
||||||
* message rather than an absence: the other device has already rendered
|
* message rather than an absence: the other device has already rendered
|
||||||
@@ -484,8 +509,12 @@ data class Content(
|
|||||||
label: Pair<Int, List<Int>>? = null,
|
label: Pair<Int, List<Int>>? = null,
|
||||||
mentions: List<Int> = emptyList(),
|
mentions: List<Int> = emptyList(),
|
||||||
aboutAStory: Boolean = false,
|
aboutAStory: Boolean = false,
|
||||||
|
forwarded: Boolean = false,
|
||||||
): String {
|
): String {
|
||||||
val parts = mutableListOf("\"fnc\":1", "\"kind\":${quote(kind)}")
|
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)}" }
|
messageId?.let { parts += "\"mid\":${quote(it)}" }
|
||||||
about?.let { parts += "\"re\":${it.toJson()}" }
|
about?.let { parts += "\"re\":${it.toJson()}" }
|
||||||
caption?.let { parts += "\"caption\":${quote(it)}" }
|
caption?.let { parts += "\"caption\":${quote(it)}" }
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user