Understand a file somebody sent, rather than showing a line of text
iOS can now send a document from Files. This build treated `file` as a kind from a newer peer: legible, because a file envelope always carries a caption naming the document, but only as text. The engine now knows the kind, parses the additive `fn`/`cls`/`cmp` keys, and can assemble them — so sending works in both directions, not one. 🔴 The interop test parses an envelope produced by the iOS build rather than by this engine. Two implementations of one format that each only test against themselves agree right up to the day somebody sends a real file, and the symptom is "[unsupported attachment]" with no clue why. It also pins the two rules that keep older peers working: a file envelope is never captionless, and a photo envelope still carries none of the new keys. ⚠️ The bubble shows the classification as a MARKING. It says how the sender had it filed; it does not mean this device was cleared for it, and whoever can read the message already has the key. ⚠️ No "Save to Files" on this side yet — this build has no Files of its own, so the button would be one that cannot work. Showing what arrived is the honest half of parity while the other half is built. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
df00bea388
commit
688a519e0c
@@ -2148,8 +2148,13 @@ private fun Bubble(
|
||||
// to show and a duration that matters; an image has the
|
||||
// reverse. Routing on the kind rather than trying to render
|
||||
// both with one composable.
|
||||
if (ref.kind == io.flamenet.e2e.Attachment.AUDIO) VoiceBubble(ref, model)
|
||||
else AttachedImage(ref, model)
|
||||
when (ref.kind) {
|
||||
io.flamenet.e2e.Attachment.AUDIO -> VoiceBubble(ref, model)
|
||||
// A document, not a picture: AttachedImage would download
|
||||
// a PDF and draw nothing.
|
||||
io.flamenet.e2e.Attachment.FILE -> FileBubble(ref, line.mine)
|
||||
else -> AttachedImage(ref, model)
|
||||
}
|
||||
}
|
||||
if (line.text.isNotEmpty()) {
|
||||
Text(
|
||||
@@ -3474,3 +3479,83 @@ fun ColumnScope.MailboxesSection(
|
||||
tag = "confirm-add-mailbox",
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* A document that arrived in a conversation.
|
||||
*
|
||||
* 🔴 **The classification is shown, and it is a marking.** It says how the
|
||||
* sender had it filed — it does not mean this device was cleared for it, and
|
||||
* the row must not imply that. Whoever can read the message already has the
|
||||
* key.
|
||||
*
|
||||
* ⚠️ No "Save to Files" here yet: this build has no Files of its own, so
|
||||
* offering one would be a button that cannot work. It shows what arrived
|
||||
* rather than pretending to more, which is the honest half of parity while the
|
||||
* other half is built.
|
||||
*/
|
||||
@Composable
|
||||
private fun FileBubble(ref: io.flamenet.e2e.Attachment.Ref, mine: Boolean) {
|
||||
val onColour = if (mine) MaterialTheme.colorScheme.onPrimary
|
||||
else MaterialTheme.colorScheme.onSurface
|
||||
Row(
|
||||
verticalAlignment = Alignment.Top,
|
||||
horizontalArrangement = Arrangement.spacedBy(10.dp),
|
||||
modifier = Modifier.padding(vertical = 2.dp).testTag("chat.file"),
|
||||
) {
|
||||
Icon(
|
||||
Icons.Filled.InsertDriveFile,
|
||||
contentDescription = null,
|
||||
tint = onColour,
|
||||
modifier = Modifier.size(24.dp),
|
||||
)
|
||||
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||
Text(
|
||||
ref.filename.ifEmpty { "A file" },
|
||||
fontSize = 15.sp,
|
||||
fontWeight = FontWeight.Medium,
|
||||
color = onColour,
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
Text(
|
||||
android.text.format.Formatter.formatShortFileSize(
|
||||
LocalContext.current, ref.bytes.toLong()),
|
||||
fontSize = 12.sp,
|
||||
color = onColour.copy(alpha = 0.75f),
|
||||
)
|
||||
// Only when there is something to say: an Unclassified file in
|
||||
// no areas needs no chip announcing it.
|
||||
if (ref.classification != 0 || ref.compartments.isNotEmpty()) {
|
||||
Text(
|
||||
classificationName(ref.classification).uppercase(),
|
||||
fontSize = 10.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = onColour.copy(alpha = 0.9f),
|
||||
modifier = Modifier
|
||||
.background(
|
||||
onColour.copy(alpha = 0.14f),
|
||||
RoundedCornerShape(4.dp),
|
||||
)
|
||||
.padding(horizontal = 6.dp, vertical = 2.dp)
|
||||
.testTag("chat.file.classification"),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* ⚠️ Hard-coded, matching iOS's local vocabulary, because there is no server to
|
||||
* ask on either side any more. An ordinal outside the set is shown as itself
|
||||
* rather than guessed at — inventing "Secret" for a level this deployment calls
|
||||
* something else would be worse than a number.
|
||||
*/
|
||||
private fun classificationName(ordinal: Int): String = when (ordinal) {
|
||||
0 -> "Unclassified"
|
||||
1 -> "Internal"
|
||||
2 -> "Confidential"
|
||||
3 -> "Restricted"
|
||||
else -> "Level $ordinal"
|
||||
}
|
||||
|
||||
@@ -263,7 +263,7 @@ data class Content(
|
||||
*/
|
||||
val KNOWN = CONTROL + setOf(
|
||||
TEXT, REACT, STICKER,
|
||||
Attachment.IMAGE, Attachment.AUDIO, Attachment.STORY,
|
||||
Attachment.IMAGE, Attachment.AUDIO, Attachment.STORY, Attachment.FILE,
|
||||
CallSignal.OFFER, CallSignal.ANSWER, CallSignal.END,
|
||||
)
|
||||
}
|
||||
@@ -311,10 +311,20 @@ data class Content(
|
||||
groupName: String? = null,
|
||||
): String = assemble(
|
||||
kind = ref.kind,
|
||||
caption = caption?.takeIf { it.isNotEmpty() },
|
||||
// 🔴 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,
|
||||
@@ -362,6 +372,8 @@ data class Content(
|
||||
link: LinkPreview? = null,
|
||||
streamUrl: String? = null,
|
||||
groupName: String? = null,
|
||||
filename: String? = null,
|
||||
label: Pair<Int, List<Int>>? = null,
|
||||
): String {
|
||||
val parts = mutableListOf("\"fnc\":1", "\"kind\":${quote(kind)}")
|
||||
messageId?.let { parts += "\"mid\":${quote(it)}" }
|
||||
@@ -380,6 +392,14 @@ data class Content(
|
||||
attachment?.let { parts += "\"att\":${it.toJson()}" }
|
||||
link?.let { parts += "\"lnk\":${it.toJson()}" }
|
||||
streamUrl?.let { parts += "\"stream\":${quote(it)}" }
|
||||
// File envelopes only. Additive top-level keys: a build that
|
||||
// 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 (label != null) {
|
||||
parts += "\"cls\":${label.first}"
|
||||
parts += "\"cmp\":${label.second.sorted().joinToString(",", "[", "]")}"
|
||||
}
|
||||
return parts.joinToString(",", prefix = "{", postfix = "}")
|
||||
}
|
||||
|
||||
@@ -482,7 +502,16 @@ data class Content(
|
||||
}
|
||||
|
||||
val ref = json.optJSONObject("att")
|
||||
?.let { Attachment.Ref.fromJson(kind, it, json.optDouble("exp", 0.0)) }
|
||||
?.let {
|
||||
Attachment.Ref.fromJson(
|
||||
kind, it, json.optDouble("exp", 0.0),
|
||||
filename = json.optString("fn"),
|
||||
classification = json.optInt("cls", 0),
|
||||
compartments = json.optJSONArray("cmp")?.let { array ->
|
||||
(0 until array.length()).map { i -> array.optInt(i) }
|
||||
} ?: emptyList(),
|
||||
)
|
||||
}
|
||||
?: return Content(
|
||||
// The sender meant to say something; a message that
|
||||
// silently vanishes is the worst way to meet a version this
|
||||
@@ -513,6 +542,16 @@ object Attachment {
|
||||
/** An image for the story strip rather than a thread. */
|
||||
const val STORY = "story"
|
||||
|
||||
/**
|
||||
* A document from Files, carrying the label it was filed under.
|
||||
*
|
||||
* 🔴 The label is a MARKING, not a permission. The content key is in the
|
||||
* same envelope, so whoever can read the message can read the file — the
|
||||
* bubble shows the classification so it can be filed the same way here,
|
||||
* and must never imply this device was cleared for it.
|
||||
*/
|
||||
const val FILE = "file"
|
||||
|
||||
private val random = SecureRandom()
|
||||
|
||||
/** Everything needed to fetch, verify and open a blob. */
|
||||
@@ -531,6 +570,11 @@ object Attachment {
|
||||
val duration: Double = 0.0,
|
||||
/** Story only: unix seconds after which it must not render. */
|
||||
val expires: Double = 0.0,
|
||||
/** File only: the document's own name, for showing and for saving. */
|
||||
val filename: String = "",
|
||||
/** File only: the label it was filed under. See [FILE]. */
|
||||
val classification: Int = 0,
|
||||
val compartments: List<Int> = emptyList(),
|
||||
) {
|
||||
fun toJson(): JSONObject = JSONObject()
|
||||
.put("id", id)
|
||||
@@ -553,7 +597,20 @@ object Attachment {
|
||||
.also { if (expires > 0) it.put("exp", expires) }
|
||||
|
||||
companion object {
|
||||
fun fromJson(kind: String, json: JSONObject, expires: Double): Ref? {
|
||||
/**
|
||||
* ⚠️ `filename`, `classification` and `compartments` come from the
|
||||
* ENVELOPE, not from `att` — they are additive top-level keys, so
|
||||
* an older parser ignores them and a newer one has to be handed
|
||||
* them explicitly rather than reading them off the attachment.
|
||||
*/
|
||||
fun fromJson(
|
||||
kind: String,
|
||||
json: JSONObject,
|
||||
expires: Double,
|
||||
filename: String = "",
|
||||
classification: Int = 0,
|
||||
compartments: List<Int> = emptyList(),
|
||||
): Ref? {
|
||||
val key = runCatching { Bytes.unb64(json.optString("key")) }.getOrNull()
|
||||
val digest = runCatching { Bytes.unb64(json.optString("digest")) }.getOrNull()
|
||||
// Wrong-sized key material is a malformed reference, not a
|
||||
@@ -571,6 +628,9 @@ object Attachment {
|
||||
height = json.optInt("h"),
|
||||
duration = json.optDouble("dur", 0.0),
|
||||
expires = expires,
|
||||
filename = filename,
|
||||
classification = classification,
|
||||
compartments = compartments,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,164 @@
|
||||
package io.flamenet.e2e
|
||||
|
||||
import org.json.JSONObject
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertNotNull
|
||||
import kotlin.test.assertTrue
|
||||
import kotlin.test.assertFalse
|
||||
|
||||
/**
|
||||
* Documents sent between the two apps.
|
||||
*
|
||||
* 🔴 **The envelope in [IOS_FILE] was produced by the iOS build**, not by this
|
||||
* engine. Two implementations of one format that each only test against
|
||||
* themselves agree right up to the day somebody sends a real file — and the
|
||||
* symptom is a message that arrives as "[unsupported attachment]" with no clue
|
||||
* why. This is the test that catches a drift in the field names before then.
|
||||
*/
|
||||
class FileEnvelopeInteropTest {
|
||||
|
||||
// Standard base64 with padding, which is what Bytes.unb64 decodes.
|
||||
private val key = "ERERERERERERERERERERERERERERERERERERERERERE="
|
||||
private val digest = "IiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiI="
|
||||
|
||||
/** As iOS assembles it: `fn`, `cls`, `cmp` as additive top-level keys. */
|
||||
private val iosFile = """
|
||||
{"fnc":1,"kind":"file","caption":"📎 Lease agreement.pdf",
|
||||
"att":{"id":"${"a".repeat(64)}","key":"$key","digest":"$digest",
|
||||
"bytes":4096,"mime":"application/pdf","w":0,"h":0},
|
||||
"fn":"Lease agreement.pdf","cls":2,"cmp":[0,1]}
|
||||
""".trimIndent().replace("\n", "")
|
||||
|
||||
@Test
|
||||
fun `a file envelope from iOS parses with its name and label`() {
|
||||
val content = Content.parse(iosFile)
|
||||
val ref = assertNotNull(content.attachment, "no attachment parsed")
|
||||
assertEquals(Attachment.FILE, ref.kind)
|
||||
assertEquals("Lease agreement.pdf", ref.filename)
|
||||
assertEquals(2, ref.classification)
|
||||
assertEquals(listOf(0, 1), ref.compartments)
|
||||
assertEquals(4096, ref.bytes)
|
||||
assertEquals("application/pdf", ref.mime)
|
||||
}
|
||||
|
||||
/** 🔴 It must be recognised, not merely tolerated. */
|
||||
@Test
|
||||
fun `a file is a known kind rather than needing a newer client`() {
|
||||
val content = Content.parse(iosFile)
|
||||
assertFalse(content.needsNewerClient, "file is still treated as an unknown kind")
|
||||
assertFalse(content.isControl, "a document is not control traffic")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `this engine assembles the same shape it parses`() {
|
||||
val ref = Attachment.Ref(
|
||||
kind = Attachment.FILE,
|
||||
id = "b".repeat(64),
|
||||
key = ByteArray(32) { 0x11 },
|
||||
digest = ByteArray(32) { 0x22 },
|
||||
bytes = 2048,
|
||||
mime = "application/pdf",
|
||||
filename = "Settlement draft.docx",
|
||||
classification = 3,
|
||||
compartments = listOf(2),
|
||||
)
|
||||
val json = Content.attachment(ref)
|
||||
val parsed = JSONObject(json)
|
||||
assertEquals("file", parsed.getString("kind"))
|
||||
assertEquals("Settlement draft.docx", parsed.getString("fn"))
|
||||
assertEquals(3, parsed.getInt("cls"))
|
||||
assertEquals(1, parsed.getJSONArray("cmp").length())
|
||||
|
||||
val back = assertNotNull(Content.parse(json).attachment)
|
||||
assertEquals("Settlement draft.docx", back.filename)
|
||||
assertEquals(3, back.classification)
|
||||
assertEquals(listOf(2), back.compartments)
|
||||
}
|
||||
|
||||
/**
|
||||
* 🔴 The compatibility rule, on this side too: never captionless, because
|
||||
* a build that predates this kind shows only the caption.
|
||||
*/
|
||||
@Test
|
||||
fun `a file envelope always carries a caption naming the document`() {
|
||||
val ref = Attachment.Ref(
|
||||
kind = Attachment.FILE, id = "c".repeat(64),
|
||||
key = ByteArray(32) { 0x11 }, digest = ByteArray(32) { 0x22 },
|
||||
bytes = 10, mime = "application/pdf", filename = "notes.txt",
|
||||
)
|
||||
val json = Content.attachment(ref)
|
||||
assertTrue(json.contains("\"caption\""), "a file envelope went out captionless")
|
||||
assertEquals("📎 notes.txt", Content.parse(json).text)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a caption the sender wrote is not replaced`() {
|
||||
val ref = Attachment.Ref(
|
||||
kind = Attachment.FILE, id = "d".repeat(64),
|
||||
key = ByteArray(32) { 0x11 }, digest = ByteArray(32) { 0x22 },
|
||||
bytes = 10, mime = "application/pdf", filename = "notes.txt",
|
||||
)
|
||||
assertEquals("signed copy", Content.parse(Content.attachment(ref, "signed copy")).text)
|
||||
}
|
||||
|
||||
/** ⚠️ An older peer sends no `fn`/`cls`/`cmp`. It must parse, not throw. */
|
||||
@Test
|
||||
fun `an envelope without the new fields still parses`() {
|
||||
val older = """
|
||||
{"fnc":1,"kind":"file","caption":"📎 old.pdf",
|
||||
"att":{"id":"${"e".repeat(64)}","key":"$key","digest":"$digest",
|
||||
"bytes":10,"mime":"application/pdf","w":0,"h":0}}
|
||||
""".trimIndent().replace("\n", "")
|
||||
val ref = assertNotNull(Content.parse(older).attachment)
|
||||
assertEquals("", ref.filename)
|
||||
assertEquals(0, ref.classification)
|
||||
assertEquals(emptyList(), ref.compartments)
|
||||
}
|
||||
|
||||
/**
|
||||
* 🔴 The additive property that makes any of this safe: a photo envelope
|
||||
* from this engine still carries none of the file keys, so a peer that has
|
||||
* never heard of them reads it exactly as before.
|
||||
*/
|
||||
@Test
|
||||
fun `a photo envelope carries none of the file keys`() {
|
||||
val photo = Attachment.Ref(
|
||||
kind = Attachment.IMAGE, id = "f".repeat(64),
|
||||
key = ByteArray(32) { 0x11 }, digest = ByteArray(32) { 0x22 },
|
||||
bytes = 1000, mime = "image/jpeg", width = 100, height = 200,
|
||||
)
|
||||
val json = Content.attachment(photo, "hi")
|
||||
assertFalse(json.contains("\"fn\""), "a photo envelope carried file fields")
|
||||
assertFalse(json.contains("\"cls\""))
|
||||
assertFalse(json.contains("\"cmp\""))
|
||||
}
|
||||
|
||||
/**
|
||||
* ⚠️ A filename from a peer is display text and a name, never a path.
|
||||
* Carried verbatim, and nothing here should ever join it onto a directory.
|
||||
*/
|
||||
@Test
|
||||
fun `a hostile filename survives as plain text`() {
|
||||
val hostile = """
|
||||
{"fnc":1,"kind":"file","caption":"x",
|
||||
"att":{"id":"${"a".repeat(64)}","key":"$key","digest":"$digest",
|
||||
"bytes":1,"mime":"application/pdf","w":0,"h":0},
|
||||
"fn":"../../../etc/passwd","cls":0,"cmp":[]}
|
||||
""".trimIndent().replace("\n", "")
|
||||
assertEquals("../../../etc/passwd",
|
||||
assertNotNull(Content.parse(hostile).attachment).filename)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a file in a group keeps its routing`() {
|
||||
val ref = Attachment.Ref(
|
||||
kind = Attachment.FILE, id = "a".repeat(64),
|
||||
key = ByteArray(32) { 0x11 }, digest = ByteArray(32) { 0x22 },
|
||||
bytes = 1, mime = "application/pdf", filename = "g.pdf",
|
||||
)
|
||||
val content = Content.parse(Content.attachment(ref, groupId = "g-123"))
|
||||
assertEquals("g-123", content.groupId)
|
||||
assertEquals(Attachment.FILE, content.attachment?.kind)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user