Share a file into a chat, and save one back — with the label it carries

Both directions: pick a document from the vault inside a conversation, and
save a received one into the vault at the classification it arrived with.

🔴 **This is the answer to android#15, and the codebase had already given
it.** `Attachment.FILE` has carried this since before the lattice existed:
*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.* That settles
the ★-property question. The key travels with the file by design, because
that is what makes sending work at all; there is no trusted computing base
to enforce against, since the person holds every key on a device they
control. A client-side refusal would be a suggestion, and **a suggestion
dressed as a control is worse than none** — it is the one people plan
around.

So neither client implements the ★-property, `LabelTest` keeps asserting
that absence, and PARITY.md now says plainly that Files is not mandatory
access control. What it IS: cryptographic read separation, plus a
classification that travels. The picker says so before the tap, in those
words.

Three things found on the way:

🔴 **A second copy of the vocabulary.** `classificationName` in
ChatScreens listed the same four names as `FileLevels`, agreeing by
coincidence with nothing making them agree. Rename a level for the Files
screen and the chat bubble keeps showing the old word for the same number,
in the same conversation, on the same phone. Collapsed to one.

🔴 **Attach and Camera did the same thing.** Both launched the gallery, and
there was no way to send a document at all. Attach now opens a small panel
— photo, or from Files — which keeps the kit's composer row at its four
controls.

🔴 **iOS's picker lists the root only**, so a file inside a folder cannot
be sent from a conversation, and nothing says so — it is simply absent,
which reads as "it isn't there". `LocalVault.allFiles()` walks the whole
tree. Filed as ios#68.

⚠️ The picker is the ONE place this vault hides a row it cannot read.
Everywhere else it insists on showing them, because hiding one leaves
somebody unable to see anything is there; a picker exists only for
choosing, and a row that can never be chosen is furniture.

⚠️ A received file is always saved as an encrypted copy, never a link —
there is no file on this phone to point at — and always at the label it
arrived with. Re-labelling somebody else's document on receipt would
launder its classification, and the number is the only thing that survived
the trip.

9 new tests. Each guard proved by breaking the code: writing the label for
every attachment kind fails the image test; dropping compartments fails
the two that name them; removing the fallback caption fails the
older-build test.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Paul Hitt
2026-09-04 15:34:29 -04:00
co-authored by Claude Opus 5
parent fbf2c4c531
commit 6ec626cde2
9 changed files with 562 additions and 14 deletions
@@ -1974,6 +1974,9 @@ fun ColumnScope.ChatScreen(
// this screen that did not belong to that row.
var showStickers by remember { mutableStateOf(false) }
var showEmoji by remember { mutableStateOf(false) }
var showAttach by remember { mutableStateOf(false) }
/** The Files picker, opened from the attach panel. */
var pickingFile by remember { mutableStateOf(false) }
var micGranted by remember {
mutableStateOf(
@@ -2074,8 +2077,19 @@ fun ColumnScope.ChatScreen(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(Space.sm),
) {
IconAction(icon = KitIcon.Attach, label = "Attach a photo", tag = "attach",
onClick = { pickImage.launch("image/*") })
// 🔴 **A choice, not a shortcut to the photo picker.** This was labelled
// "Attach a photo" and launched the gallery — which is also exactly what
// the camera button beside it does, so the row had two controls doing
// one thing and no way at all to send a document. Opening a small panel
// keeps the kit's row at its four controls while giving the second kind
// of attachment somewhere to live.
IconAction(icon = KitIcon.Attach,
label = if (showAttach) "Close attachments" else "Attach",
tag = "attach",
onClick = {
showAttach = !showAttach
if (showAttach) { showEmoji = false; showStickers = false }
})
// ⚠️ `BasicTextField` inside our own pill rather than a Material text
// field with its shape overridden: `OutlinedTextField` reserves room
@@ -2186,6 +2200,25 @@ fun ColumnScope.ChatScreen(
}
}
if (showAttach) {
AttachPanel(
model = model,
onPhoto = { showAttach = false; pickImage.launch("image/*") },
onFiles = { showAttach = false; pickingFile = true },
)
}
if (pickingFile) {
PickFileToSendSheet(
model = model,
onCancel = { pickingFile = false },
onPick = { entry ->
pickingFile = false
model.sendFileFromVault(entry, chat)
},
)
}
if (showStickers) {
StickerPicker { sticker ->
// A sticker IS the message. There is nothing to add it to and
@@ -2408,7 +2441,10 @@ private fun Bubble(
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)
io.flamenet.e2e.Attachment.FILE -> FileBubble(
ref, line.mine,
onSave = if (line.mine) null else { { model.saveAttachmentToFiles(ref) } },
)
else -> AttachedImage(ref, model)
}
}
@@ -3783,7 +3819,11 @@ fun ColumnScope.MailboxesSection(
* other half is built.
*/
@Composable
private fun FileBubble(ref: io.flamenet.e2e.Attachment.Ref, mine: Boolean) {
private fun FileBubble(
ref: io.flamenet.e2e.Attachment.Ref,
mine: Boolean,
onSave: (() -> Unit)? = null,
) {
val onColour = if (mine) MaterialTheme.colorScheme.onPrimary
else MaterialTheme.colorScheme.onSurface
Row(
@@ -3835,6 +3875,22 @@ private fun FileBubble(ref: io.flamenet.e2e.Attachment.Ref, mine: Boolean) {
)
}
}
// 🔴 **Offered on a received file only.** Saving something already
// in the vault back into it would make a second, unrelated copy at
// whatever label this bubble happens to carry — which is how one
// document quietly becomes two at different classifications.
if (onSave != null) {
Text(
"Save to Files",
fontSize = 12.sp,
fontWeight = FontWeight.Medium,
color = onColour,
modifier = Modifier
.clickable(onClick = onSave)
.padding(top = 2.dp)
.testTag("chat.file.save"),
)
}
}
}
}
@@ -3844,14 +3900,19 @@ private fun FileBubble(ref: io.flamenet.e2e.Attachment.Ref, mine: Boolean) {
* 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.
*
* 🔴 **One vocabulary, not two.** This was a second `when` listing the same
* four names — agreeing with [FileLevels] by coincidence, with nothing making
* them agree. Two places to change is how one of them gets changed: rename a
* level for the Files screen and the chat bubble keeps showing the old word for
* the same number, on the same phone, in the same conversation.
*
* ⚠️ It matters more than a rename. The ORDINAL is what travels on the wire,
* so the name is the only thing telling somebody what a number means — and two
* answers to that is worse than none.
*/
private fun classificationName(ordinal: Int): String = when (ordinal) {
0 -> "Unclassified"
1 -> "Internal"
2 -> "Confidential"
3 -> "Restricted"
else -> "Level $ordinal"
}
private fun classificationName(ordinal: Int): String =
FileLevels.levelName(ordinal.toShort())
/**
* What is being typed after an unfinished `@`, or null when there isn't one.
@@ -539,3 +539,115 @@ private fun ColumnScope.LevelPicker(selected: Short, onPick: (Short) -> Unit) {
}
}
}
// ---- Into and out of a conversation --------------------------------------
/**
* What can be attached to a message.
*
* ⚠️ Two kinds, and they are not variations of one thing: a photo comes from
* the gallery and is re-encoded for sending; a document comes from the vault
* and carries a classification with it.
*/
@Composable
fun ColumnScope.AttachPanel(
model: MessengerViewModel,
onPhoto: () -> Unit,
onFiles: () -> Unit,
) {
Column(
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(Radius.small))
.background(Palette.SurfaceAlt)
.padding(Space.md)
.testTag("attach-panel"),
verticalArrangement = Arrangement.spacedBy(Space.sm),
) {
KitSecondaryButton(text = "Photo", onClick = onPhoto,
modifier = Modifier.fillMaxWidth(), tag = "attach-photo")
KitSecondaryButton(
text = if (model.filesUnlocked()) "From Files" else "From Files (locked)",
onClick = onFiles,
modifier = Modifier.fillMaxWidth(), tag = "attach-files",
)
}
}
/**
* Choose a document from the vault, from inside a conversation.
*
* 🔴 **Sending is a downgrade, and the sheet says so before the tap.** The
* content key travels in the same envelope as the message, so a classification
* does not stop the recipient opening it — it goes so their phone files it the
* same way. Every word here has to carry that, because the alternative is a
* person believing "Restricted" did something on the way.
*
* ⚠️ **Never asks for the Files passphrase.** A conversation that prompted for
* one would be a conversation that has started collecting one, and Files goes
* out of its way not to hold it. Locked says so and points at the Files screen.
*/
@Composable
fun ColumnScope.PickFileToSendSheet(
model: MessengerViewModel,
onCancel: () -> Unit,
onPick: (LocalVault.Entry) -> Unit,
) {
val unlocked = model.filesUnlocked()
// Read once when the sheet opens: the vault does not change underneath it,
// and re-reading on every recomposition means decrypting every filename
// again for each frame.
val entries = remember(unlocked) { if (unlocked) model.filesForSending() else emptyList() }
Column(
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(Radius.small))
.background(Palette.SurfaceAlt)
.padding(Space.md)
.testTag("pick-file-sheet"),
) {
Text("Send a file", fontWeight = FontWeight.Medium, fontSize = 16.sp)
Spacer(Modifier.height(Space.sm))
if (!unlocked) {
Text(
"Files is locked. Open Files from your account and unlock it, "
+ "then come back — your passphrase isn't asked for here.",
fontSize = 13.sp, color = Palette.SecondaryText,
)
} else if (entries.isEmpty()) {
Text("Nothing in your files yet.", fontSize = 13.sp, color = Palette.SecondaryText)
} else {
Text(
"Whoever you send this to will be able to open it. The "
+ "classification travels with the file so it's filed the "
+ "same way on their phone — it doesn't stop them reading it.",
fontSize = 12.sp, color = Palette.SecondaryText,
)
Spacer(Modifier.height(Space.sm))
for (entry in entries) {
Row(
modifier = Modifier
.fillMaxWidth()
.clickable { onPick(entry) }
.padding(vertical = Space.sm)
.testTag("pick-file-row"),
verticalAlignment = Alignment.CenterVertically,
) {
Column(Modifier.weight(1f)) {
Text(entry.name ?: "Unnamed", fontSize = 15.sp,
maxLines = 1, overflow = TextOverflow.Ellipsis)
Text(
FileLevels.levelName(entry.label.classification),
fontSize = 11.sp, color = Palette.SecondaryText,
)
}
}
}
}
Spacer(Modifier.height(Space.sm))
KitSecondaryButton(text = "Cancel", onClick = onCancel,
modifier = Modifier.fillMaxWidth(), tag = "pick-file-cancel")
}
}
@@ -1649,6 +1649,112 @@ class MessengerViewModel(app: Application) : AndroidViewModel(app) {
)
}
// -- Files, into and out of a conversation --------------------------------
/**
* Whether a conversation can offer to send from Files right now.
*
* ⚠️ **A chat never asks for the Files passphrase.** A conversation that
* prompted for one would be a conversation that has started collecting one,
* and Files goes out of its way not to hold it. Locked means the picker says
* so and points at the Files screen.
*/
fun filesUnlocked(): Boolean = filesVault != null
/** Everything sendable, anywhere in the tree. */
fun filesForSending(): List<LocalVault.Entry> =
filesVault?.let { runCatching { it.allFiles() }.getOrDefault(emptyList()) } ?: emptyList()
/**
* Send a document from the vault into this conversation.
*
* 🔴 **This is a downgrade, and the screen says so before the tap rather
* than after.** The content key goes in the same envelope, so the
* classification does not stop the recipient reading it — it travels so
* their phone files it the same way. Nothing here refuses on the label,
* because nothing here could: refusing in a client whose user holds the
* keys is a suggestion, and one dressed as a control is worse than none.
*/
fun sendFileFromVault(entry: LocalVault.Entry, chat: Phase.Chat) =
viewModelScope.launch {
val open = filesVault ?: return@launch
val live = session ?: return@launch
filesUpdate { it.copy(busy = "Sending…", problem = null, notice = null) }
val done = withContext(Dispatchers.IO) {
runCatching {
val bytes = open.contents(entry.node)
val name = entry.name ?: "file"
val label = entry.label
val group = chat.group
if (group != null) {
live.sendGroupFile(group, bytes, name,
label.classification.toInt(), label.compartments.sorted())
} else {
live.sendFile(chat.peerId ?: return@runCatching,
bytes, name,
label.classification.toInt(), label.compartments.sorted())
}
}
}
done.fold(
onSuccess = {
filesUpdate { it.copy(busy = null) }
update {
it.copy(openChat = it.openChat?.copy(lines = live.history(chat.threadId)))
}
},
onFailure = { e ->
filesUpdate {
it.copy(busy = null,
problem = "Couldn't send that file. ${e.message ?: ""}".trim())
}
},
)
}
/**
* Save a received document into the vault.
*
* 🔴 **Always an encrypted copy, never a link.** There is no file on this
* phone to point at — the bytes arrived over the wire — so the two storage
* modes are not a choice here.
*
* ⚠️ **Filed under the label it arrived with, not under one this device
* chose.** Re-labelling somebody else's document on receipt would quietly
* launder its classification, and the number is the only thing that survived
* the trip.
*/
fun saveAttachmentToFiles(ref: io.flamenet.e2e.Attachment.Ref) = viewModelScope.launch {
val open = filesVault
if (open == null) {
filesUpdate {
it.copy(problem = "Files is locked. Open Files and unlock it, then "
+ "come back — your passphrase isn't asked for here.")
}
return@launch
}
val live = session ?: return@launch
filesUpdate { it.copy(busy = "Saving…", problem = null, notice = null) }
val done = withContext(Dispatchers.IO) {
runCatching {
val bytes = live.attachmentBytes(ref, getApplication<Application>().cacheDir)
val name = ref.filename.ifEmpty { "Received file" }
open.add(bytes, name,
Label(ref.classification.toShort(), ref.compartments.toSet()))
name
}
}
done.fold(
onSuccess = { name -> filesUpdate { it.copy(busy = null, notice = "Saved $name to Files.") } },
onFailure = { e ->
filesUpdate {
it.copy(busy = null,
problem = "Couldn't save that file. ${e.message ?: ""}".trim())
}
},
)
}
fun filesClearMessages() = filesUpdate { it.copy(problem = null, notice = null) }
private fun finishFilesWork(result: Result<*>, notice: String?) {
@@ -360,6 +360,61 @@ class Session(
return sent
}
/**
* Send a document from the Files vault.
*
* 🔴 **The label travels; it does not gate.** The content key is in the same
* envelope as the message, so the recipient can open this whatever their
* clearance — the classification goes so their phone files it the same way,
* and every sentence on the way to this call has to say that rather than
* implying a control. See `Attachment.FILE`.
*
* ⚠️ `application/octet-stream` and not a guess from the extension. A
* document announced as `image/png` renders as a broken image rather than a
* file somebody can save, and the vault stores no MIME type to be right
* with.
*/
fun sendFile(
peerUserId: Long,
bytes: ByteArray,
name: String,
classification: Int,
compartments: List<Int>,
): io.flamenet.client.Conversations.Sent {
val sent = conversations.sendAttachment(
peerUserId, bytes, "application/octet-stream", Attachment.FILE,
filename = name, classification = classification, compartments = compartments)
record(Vault.threadForUser(peerUserId), "", mine = true,
attachment = sent.attachment,
delivery = if (sent.reachedSomeone) Delivery.DELIVERED else Delivery.PENDING)
return sent
}
/**
* Send a document to a group.
*
* ⚠️ **Sealed and uploaded once, then fanned out** — one blob, one key, one
* upload, referenced by every member's envelope. And the count is worth
* saying out loud before tapping: a Restricted file into a group of eleven
* hands the key to eleven people.
*/
fun sendGroupFile(
group: RelayClient.Group,
bytes: ByteArray,
name: String,
classification: Int,
compartments: List<Int>,
): Conversations.Sent {
val sent = conversations.sendGroupAttachment(
group, userId, bytes, "application/octet-stream", width = 0, height = 0,
kind = Attachment.FILE, filename = name,
classification = classification, compartments = compartments)
record(Vault.threadForGroup(group.id), "", mine = true,
attachment = sent.attachment, author = userId,
delivery = if (sent.reachedSomeone) Delivery.DELIVERED else Delivery.PENDING)
return sent
}
/** Bytes for an attachment, cached on disk after the first fetch. */
fun attachmentBytes(ref: Attachment.Ref, cacheDir: java.io.File): ByteArray {
val cached = java.io.File(cacheDir, "att-" + ref.id.replace('/', '_'))