diff --git a/app/src/main/kotlin/io/flamenet/messenger/app/ChatScreens.kt b/app/src/main/kotlin/io/flamenet/messenger/app/ChatScreens.kt index 359f374..ac094f2 100644 --- a/app/src/main/kotlin/io/flamenet/messenger/app/ChatScreens.kt +++ b/app/src/main/kotlin/io/flamenet/messenger/app/ChatScreens.kt @@ -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" +} diff --git a/engine/src/main/kotlin/io/flamenet/e2e/Content.kt b/engine/src/main/kotlin/io/flamenet/e2e/Content.kt index 8cea422..0644683 100644 --- a/engine/src/main/kotlin/io/flamenet/e2e/Content.kt +++ b/engine/src/main/kotlin/io/flamenet/e2e/Content.kt @@ -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>? = 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 = 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 = 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, ) } } diff --git a/engine/src/test/kotlin/io/flamenet/e2e/FileEnvelopeInteropTest.kt b/engine/src/test/kotlin/io/flamenet/e2e/FileEnvelopeInteropTest.kt new file mode 100644 index 0000000..d7ad98a --- /dev/null +++ b/engine/src/test/kotlin/io/flamenet/e2e/FileEnvelopeInteropTest.kt @@ -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) + } +}