Merge branch 'feat/files-screen' into 'main'

Files on Android: set up, unlock, browse, add, folders, delete, promote

See merge request paulhitt/flamenet-messenger-android!52
This commit was merged in pull request #79.
This commit is contained in:
Paul Hitt
2026-09-04 20:49:58 +00:00
8 changed files with 1161 additions and 3 deletions
+17 -2
View File
@@ -339,6 +339,7 @@ the messenger rather than staying separate apps.
| Files engine: names, key wrapping, chunked content | ✅ pinned to the **browser's** published vectors, not to the Swift engine |
| Files engine: KDF and identity record | ✅ an identity written by the browser unlocks here |
| Local vault: add, organise, delete | ✅ index, folders, both storage modes, bulk delete with its report |
| Files screen: set up, unlock, browse, add, folders, delete, promote | ✅ ⚠️ **not yet run on a device** — see below |
| Bell–LaPadula labels and clearance | 🟡 lattice, encoding and clearance in Kotlin, pinned to independently-computed vectors; the ★-property is unenforced on **both** clients — android#15 |
| Share a file into a chat or group | ⚪ |
| Wallet engine: BIP-39, Base58, SLIP-0010 | ✅ pinned to the published vectors |
@@ -347,8 +348,22 @@ the messenger rather than staying separate apps.
| Wallet: fresh receive address per receive | ✅ counter in the vault; the screen says why the address changes |
| Wallet: balance and transactions from a node | ✅ both, with **unread drawn as an em dash and never as zero** |
⚠️ Nothing above the engine exists on Android yet. This is the largest
remaining gap and it is a build, not a port of a screen.
⚠️ **The Files screen compiles and every layer under it is tested, but it has
not been exercised on a device or emulator.** The machine was carrying an iOS
simulator at 99% CPU when it landed, and booting an emulator on top of that
produces a flaky run rather than evidence. Two things specifically need a real
device before this can be called done: the Storage Access Framework picker and
its persistable read grant, and whether a reopened app can still read a linked
file after a reboot. Neither can be faked on the JVM.
🔴 **Android asks for a Files passphrase, exactly as iOS does, and this was a
decision rather than a port.** The Android wallet sits behind the Keystore-sealed
vault alone, so an unlocked phone opens it. Files does not: the identity record
is encrypted under a key stretched from a passphrase this app deliberately keeps
no copy of. The Keystore seal around it stays as the second layer. Dropping the
inner one for convenience would be a real reduction and not a refactor — a vault
that opens because the phone is open is a folder with a lock drawn on it.
🔴 **The Files crypto is held to `vectors/files-v1.json`, which the JavaScript
engine in the browser produced — a third implementation, not the Swift one.**
@@ -764,6 +764,29 @@ fun ColumnScope.AccountScreen(
)
}
// ⚠️ Beside the wallet and not in the tab bar, for the reason written
// above it: the bar is icons-only by design and Alerts was taken OUT of it
// in August for being a fifth. Files earns the slot no more than a wallet
// does.
SectionBand()
SettingsSection {
Text("Files", fontWeight = FontWeight.Medium, fontSize = 16.sp)
Spacer(Modifier.height(Space.xs))
Text(
"Files encrypted on this phone, with a passphrase of their own. "
+ "Nothing is stored on a server.",
fontSize = 13.sp,
color = Palette.SecondaryText,
)
Spacer(Modifier.height(Space.sm))
KitSecondaryButton(
text = "Open Files",
onClick = { model.openFiles() },
modifier = Modifier.fillMaxWidth(),
tag = "open-files",
)
}
SectionBand()
SettingsSection { AppLockSection() }
@@ -0,0 +1,541 @@
package io.flamenet.messenger.app
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Checkbox
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import io.flamenet.client.LocalVault
import java.util.UUID
/**
* Files, on this phone.
*
* 🔴 **Local-first. Nothing here talks to a server.** Files are encrypted into
* a vault in this app's own container, and the only thing that ever leaves the
* device is a transfer somebody asks for — where the relay is a courier holding
* ciphertext until the other device collects it, and then not.
*
* ⚠️ **What that changed, and the words this screen must not reuse.** When the
* rows lived in Postgres, read-down was a database predicate: the server would
* not return a row above your clearance whatever the client asked. There is no
* database now. Labels still gate decryption completely — no label key, no
* plaintext — but "what you're cleared for" now means *the keys this phone
* holds*, not *what a server will show you*. Those are different claims and only
* one of them is still true, so the screen says the true one.
*
* 🔴 It asks for a passphrase, and that is not friction to be optimised away.
* Messenger deliberately keeps no copy of one, and the Files identity is
* protected with it — which is what makes a borrowed, unlocked phone still not
* open somebody's files.
*/
@Composable
fun ColumnScope.FilesScreen(
model: MessengerViewModel,
phase: MessengerViewModel.Phase.SignedIn,
) {
val files = phase.files ?: MessengerViewModel.FilesState()
Text("Files", fontWeight = FontWeight.SemiBold, fontSize = 22.sp,
modifier = Modifier.padding(bottom = Space.xs))
when {
!files.hasIdentity -> FilesSetUp(model, files)
!files.unlocked -> FilesUnlock(model, files)
else -> FilesBrowser(model, files)
}
FilesMessages(model, files)
}
/** Whatever the last action wanted to say. */
@Composable
private fun ColumnScope.FilesMessages(
model: MessengerViewModel,
files: MessengerViewModel.FilesState,
) {
val message = files.problem ?: files.notice ?: return
Spacer(Modifier.height(Space.sm))
Column(
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(Radius.small))
.background(if (files.problem != null) Palette.AccentSoft else Palette.SurfaceAlt)
.clickable { model.filesClearMessages() }
.padding(Space.md)
.testTag("files-message"),
) {
Text(message, fontSize = 13.sp,
color = if (files.problem != null) Palette.Danger else Palette.SecondaryText)
}
}
// ---- Setting up ----------------------------------------------------------
/**
* 🔴 **The one-way door is named before it is walked through, not after.**
* Forgetting this passphrase does not lock somebody out temporarily — the label
* keys are derived from the identity it protects and exist nowhere else, so
* every vaulted file becomes permanently unreadable. A screen that discovers
* that for somebody afterwards has failed at the only moment it mattered.
*/
@Composable
private fun ColumnScope.FilesSetUp(
model: MessengerViewModel,
files: MessengerViewModel.FilesState,
) {
var passphrase by remember { mutableStateOf("") }
var again by remember { mutableStateOf("") }
Text(
"Files on this phone are encrypted with a passphrase of their own. "
+ "Flamenet doesn't keep it, so nobody can open them by picking up "
+ "your unlocked phone.",
fontSize = 14.sp, color = Palette.SecondaryText,
)
Spacer(Modifier.height(Space.sm))
Text(
"If you forget it, the files are gone for good — there's no reset, "
+ "because there's nothing anywhere that could do one.",
fontSize = 13.sp, color = Palette.Warning,
)
Spacer(Modifier.height(Space.md))
OutlinedTextField(
value = passphrase, onValueChange = { passphrase = it },
label = { Text("Choose a passphrase") },
visualTransformation = PasswordVisualTransformation(),
singleLine = true,
modifier = Modifier.fillMaxWidth().testTag("files-new-passphrase"),
)
Spacer(Modifier.height(Space.sm))
OutlinedTextField(
value = again, onValueChange = { again = it },
label = { Text("Type it again") },
visualTransformation = PasswordVisualTransformation(),
singleLine = true,
// ⚠️ Confirmed rather than trusted. Everywhere else a typo means one
// failed sign-in; here it means a vault nobody can ever open, and the
// person would not find out until the files were already in it.
isError = again.isNotEmpty() && again != passphrase,
modifier = Modifier.fillMaxWidth().testTag("files-new-passphrase-again"),
)
Spacer(Modifier.height(Space.md))
KitPrimaryButton(
text = files.busy ?: "Set up Files",
onClick = { model.createFilesIdentity(passphrase) },
enabled = files.busy == null && passphrase.isNotEmpty() && passphrase == again,
modifier = Modifier.fillMaxWidth(),
tag = "files-create",
)
}
@Composable
private fun ColumnScope.FilesUnlock(
model: MessengerViewModel,
files: MessengerViewModel.FilesState,
) {
var passphrase by remember { mutableStateOf("") }
Text("Enter your Files passphrase to open them.",
fontSize = 14.sp, color = Palette.SecondaryText)
Spacer(Modifier.height(Space.md))
OutlinedTextField(
value = passphrase, onValueChange = { passphrase = it },
label = { Text("Passphrase") },
visualTransformation = PasswordVisualTransformation(),
singleLine = true,
modifier = Modifier.fillMaxWidth().testTag("files-passphrase"),
)
Spacer(Modifier.height(Space.md))
KitPrimaryButton(
text = files.busy ?: "Unlock",
onClick = { model.unlockFiles(passphrase) },
enabled = files.busy == null && passphrase.isNotEmpty(),
modifier = Modifier.fillMaxWidth(),
tag = "files-unlock",
)
}
// ---- Browsing ------------------------------------------------------------
@Composable
private fun ColumnScope.FilesBrowser(
model: MessengerViewModel,
files: MessengerViewModel.FilesState,
) {
val context = LocalContext.current
var pending by remember { mutableStateOf<PendingFile?>(null) }
var namingFolder by remember { mutableStateOf(false) }
/**
* 🔴 `OpenDocument`, never `GetContent`. `GetContent` returns a URI whose
* read grant dies with this process, so a linked file works all session and
* is broken tomorrow — and whoever added it watched it work.
*/
val pick = rememberLauncherForActivityResult(ActivityResultContracts.OpenDocument()) { uri ->
uri ?: return@rememberLauncherForActivityResult
val saf = SafReferences(context)
pending = PendingFile(uri, saf.displayName(uri) ?: "Untitled")
}
FilesSectionSwitch(model, files)
Spacer(Modifier.height(Space.sm))
FilesBreadcrumb(model, files)
// ⚠️ Folders show in BOTH sections. A folder has no storage of its own — it
// is a place, not a file — and hiding them from the linked section would
// make anything filed inside one unreachable from there.
val rows = files.entries.filter { it.isFolder || it.storage == files.section }
if (rows.isEmpty()) {
val empty = if (files.section == LocalVault.Storage.VAULT) {
"Nothing in the vault here yet. Anything you add is encrypted on this phone."
} else {
"No linked files here. A link points at a file where it already is — " +
"nothing is copied, and nothing is encrypted until you send it."
}
Spacer(Modifier.height(Space.md))
Text(empty, fontSize = 13.sp, color = Palette.SecondaryText)
}
for (entry in rows) {
FileRow(model, files, entry)
}
Spacer(Modifier.height(Space.md))
Row(horizontalArrangement = Arrangement.spacedBy(Space.sm)) {
KitSecondaryButton(
text = "Add a file",
// Everything: the vault does not care what a file is.
onClick = { pick.launch(arrayOf("*/*")) },
modifier = Modifier.weight(1f), tag = "files-add",
)
KitSecondaryButton(
text = "New folder",
onClick = { namingFolder = true },
modifier = Modifier.weight(1f), tag = "files-new-folder",
)
}
Spacer(Modifier.height(Space.sm))
Row(horizontalArrangement = Arrangement.spacedBy(Space.sm)) {
KitSecondaryButton(
text = if (files.selecting) "Done" else "Select",
onClick = { model.filesToggleSelecting() },
modifier = Modifier.weight(1f), tag = "files-select",
)
if (files.selecting) {
KitSecondaryButton(
text = "Delete ${files.selected.size}",
onClick = { model.filesDeleteSelected() },
enabled = files.selected.isNotEmpty(),
modifier = Modifier.weight(1f), tag = "files-delete",
)
}
KitSecondaryButton(
text = "Lock",
onClick = { model.lockFiles() },
modifier = Modifier.weight(1f), tag = "files-lock",
)
}
if (namingFolder) {
FolderNameSheet(
onCancel = { namingFolder = false },
onCreate = { name, level ->
namingFolder = false
model.filesCreateFolder(name, level)
},
)
}
pending?.let { file ->
AddFileSheet(
file = file,
onCancel = { pending = null },
onVault = { name, level -> pending = null; model.filesAdd(file.uri, name, level) },
onLink = { name, level -> pending = null; model.filesReference(file.uri, name, level) },
)
}
}
private data class PendingFile(val uri: android.net.Uri, val name: String)
/**
* ⚠️ **Two sections, not one mixed list.** The vault and the links are not
* equally protected — one is ciphertext in this app's container, the other is
* somebody's own file with a note attached — and a list that interleaved them
* would be a list where "encrypted" is invisible.
*/
@Composable
private fun ColumnScope.FilesSectionSwitch(
model: MessengerViewModel,
files: MessengerViewModel.FilesState,
) {
Row(horizontalArrangement = Arrangement.spacedBy(Space.sm)) {
KitChip(
text = "In the vault",
selected = files.section == LocalVault.Storage.VAULT,
onClick = { model.filesSetSection(LocalVault.Storage.VAULT) },
tag = "files-section-vault",
)
KitChip(
text = "Linked",
selected = files.section == LocalVault.Storage.REFERENCE,
onClick = { model.filesSetSection(LocalVault.Storage.REFERENCE) },
tag = "files-section-linked",
)
}
}
@Composable
private fun ColumnScope.FilesBreadcrumb(
model: MessengerViewModel,
files: MessengerViewModel.FilesState,
) {
if (files.breadcrumb.isEmpty()) return
Row(
modifier = Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()),
verticalAlignment = Alignment.CenterVertically,
) {
Text("Files", fontSize = 13.sp, color = Palette.Accent,
modifier = Modifier.clickable { model.filesGoTo(0) }.testTag("files-crumb-root"))
files.breadcrumb.forEachIndexed { index, crumb ->
Text(" › ", fontSize = 13.sp, color = Palette.SecondaryText)
Text(
crumb.name, fontSize = 13.sp,
color = if (index == files.breadcrumb.lastIndex) Palette.SecondaryText
else Palette.Accent,
modifier = Modifier.clickable { model.filesGoTo(index + 1) },
)
}
}
}
@Composable
private fun ColumnScope.FileRow(
model: MessengerViewModel,
files: MessengerViewModel.FilesState,
entry: LocalVault.Entry,
) {
Row(
modifier = Modifier
.fillMaxWidth()
.clickable(enabled = entry.isFolder && !files.selecting) {
model.filesOpenFolder(entry)
}
.padding(vertical = Space.sm)
.testTag("file-row"),
verticalAlignment = Alignment.CenterVertically,
) {
if (files.selecting) {
Checkbox(
checked = entry.id in files.selected,
onCheckedChange = { model.filesToggleSelected(entry.id) },
)
Spacer(Modifier.width(Space.xs))
}
Column(Modifier.weight(1f)) {
Text(
// 🔴 A row this phone cannot read is still SHOWN, with its
// classification. Hiding it would leave somebody unable to see
// that anything is there at all — and the label is not a secret;
// it has to be readable on a row you cannot open.
text = entry.name ?: "Encrypted for someone else",
fontSize = 15.sp,
fontWeight = if (entry.isFolder) FontWeight.Medium else FontWeight.Normal,
color = if (entry.name == null) Palette.SecondaryText else MaterialTheme.colorScheme.onSurface,
maxLines = 1, overflow = TextOverflow.Ellipsis,
)
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
FileLevels.levelName(entry.label.classification),
fontSize = 11.sp, color = Palette.SecondaryText,
)
if (entry.isMissing) {
Text(" · file has moved", fontSize = 11.sp, color = Palette.Warning)
} else if (!entry.isFolder && entry.byteCount > 0) {
Text(" · ${bytes(entry.byteCount)}",
fontSize = 11.sp, color = Palette.SecondaryText)
}
}
}
// Promoting a link is the one per-row action, and only where it means
// something.
if (!files.selecting && entry.storage == LocalVault.Storage.REFERENCE
&& !entry.isFolder && !entry.isMissing) {
KitSecondaryButton(
text = "Encrypt",
onClick = { model.filesEncrypt(entry.id) },
tag = "file-encrypt",
)
}
}
}
private fun bytes(count: Int): String = when {
count >= 1_000_000 -> "%.1f MB".format(count / 1_000_000.0)
count >= 1_000 -> "%.0f kB".format(count / 1_000.0)
else -> "$count bytes"
}
// ---- Sheets --------------------------------------------------------------
/**
* 🔴 **The choice between copying and linking is made here, with both consequences
* written out**, rather than by a default somebody discovers later. They are not
* two ways of doing one thing: one produces ciphertext this app owns, the other
* produces a pointer at a file it does not.
*/
@Composable
private fun ColumnScope.AddFileSheet(
file: PendingFile,
onCancel: () -> Unit,
onVault: (String, Short) -> Unit,
onLink: (String, Short) -> Unit,
) {
var name by remember { mutableStateOf(file.name) }
var level by remember { mutableStateOf(1.toShort()) }
Spacer(Modifier.height(Space.md))
Column(
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(Radius.small))
.background(Palette.SurfaceAlt)
.padding(Space.md)
.testTag("files-add-sheet"),
) {
Text("Add a file", fontWeight = FontWeight.Medium, fontSize = 16.sp)
Spacer(Modifier.height(Space.sm))
OutlinedTextField(
value = name, onValueChange = { name = it },
label = { Text("Name") }, singleLine = true,
modifier = Modifier.fillMaxWidth().testTag("files-add-name"),
)
Spacer(Modifier.height(Space.sm))
LevelPicker(level) { level = it }
Spacer(Modifier.height(Space.md))
Text(
"Copy it in and encrypt it. A stolen phone or backup gives up "
+ "nothing but ciphertext.",
fontSize = 12.sp, color = Palette.SecondaryText,
)
Spacer(Modifier.height(Space.xs))
KitPrimaryButton(
text = "Copy in and encrypt",
onClick = { onVault(name.trim().ifEmpty { file.name }, level) },
modifier = Modifier.fillMaxWidth(), tag = "files-add-vault",
)
Spacer(Modifier.height(Space.md))
Text(
"Or link to it where it is. Nothing is copied and nothing is "
+ "encrypted until you send it — until then the classification "
+ "is a note, not a lock.",
fontSize = 12.sp, color = Palette.SecondaryText,
)
Spacer(Modifier.height(Space.xs))
KitSecondaryButton(
text = "Link to it instead",
onClick = { onLink(name.trim().ifEmpty { file.name }, level) },
modifier = Modifier.fillMaxWidth(), tag = "files-add-link",
)
Spacer(Modifier.height(Space.sm))
KitSecondaryButton(text = "Cancel", onClick = onCancel,
modifier = Modifier.fillMaxWidth(), tag = "files-add-cancel")
}
}
@Composable
private fun ColumnScope.FolderNameSheet(
onCancel: () -> Unit,
onCreate: (String, Short) -> Unit,
) {
var name by remember { mutableStateOf("") }
var level by remember { mutableStateOf(1.toShort()) }
Spacer(Modifier.height(Space.md))
Column(
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(Radius.small))
.background(Palette.SurfaceAlt)
.padding(Space.md)
.testTag("files-folder-sheet"),
) {
Text("New folder", fontWeight = FontWeight.Medium, fontSize = 16.sp)
Spacer(Modifier.height(Space.sm))
OutlinedTextField(
value = name, onValueChange = { name = it },
label = { Text("Name") }, singleLine = true,
modifier = Modifier.fillMaxWidth().testTag("files-folder-name"),
)
Spacer(Modifier.height(Space.sm))
LevelPicker(level) { level = it }
Spacer(Modifier.height(Space.xs))
// ⚠️ Said here because the tree deliberately allows the opposite of what
// people assume: a folder's level does not constrain what goes in it.
Text(
"A folder's level doesn't limit what you put inside it — each file "
+ "keeps its own.",
fontSize = 12.sp, color = Palette.SecondaryText,
)
Spacer(Modifier.height(Space.md))
Row(horizontalArrangement = Arrangement.spacedBy(Space.sm)) {
KitSecondaryButton(text = "Cancel", onClick = onCancel,
modifier = Modifier.weight(1f), tag = "files-folder-cancel")
KitPrimaryButton(
text = "Create", onClick = { onCreate(name, level) },
enabled = name.isNotBlank(),
modifier = Modifier.weight(1f), tag = "files-folder-create",
)
}
}
}
@Composable
private fun ColumnScope.LevelPicker(selected: Short, onPick: (Short) -> Unit) {
Text("Classification", fontSize = 12.sp, color = Palette.SecondaryText)
Spacer(Modifier.height(Space.xs))
Row(
modifier = Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()),
horizontalArrangement = Arrangement.spacedBy(Space.xs),
) {
for ((ordinal, name) in FileLevels.LOCAL_LEVELS) {
KitChip(
text = name, selected = ordinal == selected,
onClick = { onPick(ordinal) },
tag = "files-level-$ordinal",
)
}
}
}
@@ -289,6 +289,14 @@ private fun Root(model: MessengerViewModel = viewModel()) {
StoriesFeed(model, current)
MessengerViewModel.Phase.Screen.WALLET ->
WalletScreen(model, current)
// 🔴 Its own destination, not a section of the
// wallet. iOS put them together once because
// they share an unlock — true, and an
// implementation detail. What it produced was
// somebody pressing "wallet" and arriving at a
// file list.
MessengerViewModel.Phase.Screen.FILES ->
FilesScreen(model, current)
MessengerViewModel.Phase.Screen.NOTIFICATIONS ->
NotificationsScreen(model, current)
MessengerViewModel.Phase.Screen.ACCOUNT ->
@@ -25,6 +25,11 @@ import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.util.UUID
import io.flamenet.e2e.X25519
import io.flamenet.e2e.Label
import io.flamenet.e2e.FilesIdentity
import io.flamenet.client.LocalVault
/**
* What the app is currently doing, and everything the screens read.
@@ -62,6 +67,8 @@ class MessengerViewModel(app: Application) : AndroidViewModel(app) {
val relay: String,
/** What the wallet screen draws. Null until it is opened. */
val wallet: WalletState? = null,
/** What the Files screen draws. Null until it is opened. */
val files: FilesState? = null,
val status: String,
/**
@@ -201,7 +208,7 @@ class MessengerViewModel(app: Application) : AndroidViewModel(app) {
* entry point — that is where you see there IS something — but a
* card feed cannot live above a contact list without pushing it off.
*/
enum class Screen { CONTACTS, MESSAGES, CALLS, STORIES, NOTIFICATIONS, ACCOUNT, WALLET }
enum class Screen { CONTACTS, MESSAGES, CALLS, STORIES, NOTIFICATIONS, ACCOUNT, WALLET, FILES }
/** What the call UI needs to draw itself. */
data class CallState(
@@ -1309,6 +1316,353 @@ class MessengerViewModel(app: Application) : AndroidViewModel(app) {
refreshWallet()
}
// -- Files ----------------------------------------------------------------
/**
* What the Files screen draws.
*
* ⚠️ **`unlocked` is not "the passphrase was right once".** The identity
* lives in [filesVault] for as long as the screen is open and is dropped on
* lock — so this flag and that field are always set together, and neither is
* a cache of the other.
*/
data class FilesState(
/** Whether Files has ever been set up on this phone. */
val hasIdentity: Boolean = false,
val unlocked: Boolean = false,
val entries: List<LocalVault.Entry> = emptyList(),
/** Deepest last. Empty means the top of the tree. */
val breadcrumb: List<Crumb> = emptyList(),
/**
* 🔴 Which of the two ways of holding a file is being looked at. They
* are separate sections rather than one mixed list because they are not
* equally protected, and a list that mixed them would be a list where
* "encrypted" is invisible.
*/
val section: LocalVault.Storage = LocalVault.Storage.VAULT,
val selecting: Boolean = false,
val selected: Set<UUID> = emptySet(),
val busy: String? = null,
val problem: String? = null,
/** Something worth saying that is not a failure. */
val notice: String? = null,
) {
val parentId: UUID? get() = breadcrumb.lastOrNull()?.id
}
data class Crumb(val id: UUID, val name: String)
/**
* The four levels the local vault offers, and no compartments.
*
* ⚠️ Matches iOS's `Vocabularies.local` exactly, ordinals included. The
* number is what travels with a file — the word is only how it is drawn —
* so a client that renumbered these would file somebody's Restricted
* document as Internal on the other phone, with no error anywhere.
*/
/**
* The open vault, held outside the state.
*
* 🔴 Not in [FilesState]. It holds the unlocked identity key, and state
* objects get logged, compared and copied into recompositions — none of
* which should ever have a private key in it. Dropping this field IS the
* lock.
*/
private var filesVault: LocalVault? = null
private fun filesUpdate(change: (FilesState) -> FilesState) = update {
it.copy(files = change(it.files ?: FilesState()))
}
private fun safReferences() = SafReferences(getApplication())
private fun filesRoot(handle: String): java.io.File =
java.io.File(java.io.File(getApplication<Application>().filesDir, "files"), handle)
/** Show the screen, and say whether Files has been set up here. */
fun openFiles() = viewModelScope.launch {
showScreen(Phase.Screen.FILES)
val store = vault ?: return@launch
val exists = withContext(Dispatchers.IO) {
runCatching { store.filesIdentityRecord() != null }.getOrDefault(false)
}
filesUpdate {
it.copy(hasIdentity = exists, unlocked = filesVault != null, problem = null)
}
if (filesVault != null) refreshFiles()
}
/**
* Set Files up for the first time.
*
* 🔴 **A passphrase this app does not keep.** Messenger deliberately holds
* no copy of the account passphrase, and the Files identity is protected the
* same way — which is what makes a borrowed, unlocked phone still not open
* somebody's files. It is a real cost: forget it and every vaulted file is
* unreadable, because the label keys are derived from the identity and exist
* nowhere else. The screen has to say that here, once, rather than let it be
* discovered.
*/
fun createFilesIdentity(passphrase: String) = viewModelScope.launch {
val store = vault ?: return@launch
val phase = signedIn() ?: return@launch
if (passphrase.length < 8) {
filesUpdate { it.copy(problem = "Use at least 8 characters.") }
return@launch
}
filesUpdate { it.copy(busy = "Setting up…", problem = null) }
val made = withContext(Dispatchers.IO) {
runCatching {
// 🔴 A fresh X25519 key, not one derived from the passphrase.
// Deriving it would mean changing the passphrase changes the
// identity — and with it every label key, which would leave
// every existing file unreadable. The record is re-wrapped on a
// change; the key underneath never moves.
val raw = FilesIdentity.rawFromPkcs8(
X25519.generate().private.encoded)
store.saveFilesIdentityRecord(FilesIdentity.protect(raw, passphrase))
openVaultFor(phase.handle, raw)
}
}
made.fold(
onSuccess = {
filesUpdate {
it.copy(hasIdentity = true, unlocked = true, busy = null,
notice = "Your files are set up. If you forget this "
+ "passphrase there is no way back into them.")
}
refreshFiles()
},
onFailure = { e ->
filesUpdate { it.copy(busy = null, problem = e.message ?: "That didn't work.") }
},
)
}
fun unlockFiles(passphrase: String) = viewModelScope.launch {
val store = vault ?: return@launch
val phase = signedIn() ?: return@launch
filesUpdate { it.copy(busy = "Unlocking…", problem = null) }
val opened = withContext(Dispatchers.IO) {
runCatching {
val record = store.filesIdentityRecord()
?: throw IllegalStateException("Files aren't set up on this phone yet.")
openVaultFor(phase.handle, FilesIdentity.unlock(record, passphrase).rawPrivate)
}
}
opened.fold(
onSuccess = {
filesUpdate { it.copy(unlocked = true, busy = null, problem = null) }
refreshFiles()
},
onFailure = { e ->
filesUpdate {
it.copy(busy = null, unlocked = false,
problem = e.message ?: "That passphrase didn't work.")
}
},
)
}
private fun openVaultFor(handle: String, rawPrivate: ByteArray) {
val opened = LocalVault(filesRoot(handle), rawPrivate, safReferences())
opened.load()
filesVault = opened
}
/**
* 🔴 **Locking drops the identity, it does not hide a screen.** Anything
* short of that leaves the key in memory behind a boolean, which is a lock
* that a screenshot of the state object defeats.
*/
fun lockFiles() {
filesVault = null
filesUpdate {
FilesState(hasIdentity = it.hasIdentity, unlocked = false)
}
}
fun refreshFiles() = viewModelScope.launch {
val open = filesVault ?: return@launch
val current = signedIn()?.files ?: FilesState()
val rows = withContext(Dispatchers.IO) {
runCatching { open.list(current.parentId) }.getOrDefault(emptyList())
}
filesUpdate { it.copy(entries = rows) }
}
fun filesOpenFolder(entry: LocalVault.Entry) {
filesUpdate {
it.copy(
breadcrumb = it.breadcrumb + Crumb(entry.id, entry.name ?: "Folder"),
selecting = false, selected = emptySet(), notice = null,
)
}
refreshFiles()
}
fun filesGoUp() {
filesUpdate {
it.copy(breadcrumb = it.breadcrumb.dropLast(1),
selecting = false, selected = emptySet(), notice = null)
}
refreshFiles()
}
fun filesGoTo(depth: Int) {
filesUpdate {
it.copy(breadcrumb = it.breadcrumb.take(depth),
selecting = false, selected = emptySet(), notice = null)
}
refreshFiles()
}
fun filesSetSection(section: LocalVault.Storage) {
filesUpdate { it.copy(section = section, selecting = false, selected = emptySet()) }
}
fun filesToggleSelecting() {
filesUpdate { it.copy(selecting = !it.selecting, selected = emptySet(), notice = null) }
}
fun filesToggleSelected(id: UUID) {
filesUpdate {
it.copy(selected = if (id in it.selected) it.selected - id else it.selected + id)
}
}
/** Copy a file in and encrypt it. */
fun filesAdd(uri: android.net.Uri, name: String, classification: Short) =
viewModelScope.launch {
val open = filesVault ?: return@launch
val parent = signedIn()?.files?.parentId
filesUpdate { it.copy(busy = "Encrypting…", problem = null, notice = null) }
val done = withContext(Dispatchers.IO) {
runCatching {
val bytes = getApplication<Application>().contentResolver
.openInputStream(uri)?.use { it.readBytes() }
?: throw IllegalStateException("That file couldn't be read.")
open.add(bytes, name, Label(classification), parent)
}
}
finishFilesWork(done, "Encrypted and filed.")
}
/**
* Link a file that stays where it is.
*
* 🔴 **Refuses when the read grant cannot be persisted.** A row made from a
* one-shot URI works for the rest of the session and is broken on the next
* launch — and whoever added it watched it work, which is the worst shape a
* bug can have. Saying so and offering to copy it in instead is the honest
* answer.
*/
fun filesReference(uri: android.net.Uri, name: String, classification: Short) =
viewModelScope.launch {
val open = filesVault ?: return@launch
val parent = signedIn()?.files?.parentId
val saf = safReferences()
filesUpdate { it.copy(busy = "Linking…", problem = null, notice = null) }
val done = withContext(Dispatchers.IO) {
runCatching {
if (!saf.takePersistablePermission(uri)) {
throw IllegalStateException(
"This app can't keep access to that file, so the link would "
+ "stop working next time. Copy it into the vault instead.")
}
open.reference(uri.toString(), name, Label(classification), parent)
}
}
finishFilesWork(done, "Linked. Nothing was copied.")
}
fun filesCreateFolder(name: String, classification: Short) = viewModelScope.launch {
val open = filesVault ?: return@launch
val parent = signedIn()?.files?.parentId
if (name.isBlank()) {
filesUpdate { it.copy(problem = "Give the folder a name.") }
return@launch
}
val done = withContext(Dispatchers.IO) {
runCatching { open.createFolder(name.trim(), Label(classification), parent) }
}
finishFilesWork(done, null)
}
/**
* Promote a link into an encrypted copy.
*
* ⚠️ The person's own file is left exactly where it is. Deleting it is
* theirs to decide, and the notice says so rather than doing it quietly.
*/
fun filesEncrypt(id: UUID) = viewModelScope.launch {
val open = filesVault ?: return@launch
filesUpdate { it.copy(busy = "Encrypting…", problem = null, notice = null) }
val done = withContext(Dispatchers.IO) { runCatching { open.encrypt(id) } }
done.fold(
onSuccess = {
filesUpdate {
it.copy(busy = null, section = LocalVault.Storage.VAULT,
notice = "Encrypted into the vault. Your original file is "
+ "still where you left it.")
}
refreshFiles()
},
onFailure = { e ->
filesUpdate { it.copy(busy = null, problem = e.message ?: "That didn't work.") }
},
)
}
/**
* Delete what is selected, and say what actually happened.
*
* 🔴 **The report is not decoration.** Selecting eleven things and having
* nine go is a data-loss-shaped event in the other direction: the two that
* survived are the ones somebody now believes are gone.
*/
fun filesDeleteSelected() = viewModelScope.launch {
val open = filesVault ?: return@launch
val chosen = signedIn()?.files?.selected.orEmpty()
if (chosen.isEmpty()) return@launch
filesUpdate { it.copy(busy = "Deleting…", problem = null, notice = null) }
val done = withContext(Dispatchers.IO) { runCatching { open.remove(chosen.toList()) } }
done.fold(
onSuccess = { result ->
filesUpdate {
it.copy(
busy = null, selecting = false, selected = emptySet(),
notice = if (result.isCompletelyDone) null else {
val kept = result.keptNonEmpty.size
"Deleted ${result.removed.size}. " +
(if (kept == 1) "One folder was kept" else "$kept folders were kept") +
" because there's still something inside that you didn't select."
},
)
}
refreshFiles()
},
onFailure = { e ->
filesUpdate { it.copy(busy = null, problem = e.message ?: "That didn't work.") }
},
)
}
fun filesClearMessages() = filesUpdate { it.copy(problem = null, notice = null) }
private fun finishFilesWork(result: Result<*>, notice: String?) {
result.fold(
onSuccess = {
filesUpdate { it.copy(busy = null, notice = notice) }
refreshFiles()
},
onFailure = { e ->
filesUpdate { it.copy(busy = null, problem = e.message ?: "That didn't work.") }
},
)
}
fun showScreen(screen: Phase.Screen) {
// Opening the Calls tab is what clears its missed badge.
if (screen == Phase.Screen.CALLS) markCallsSeen()
@@ -3136,3 +3490,30 @@ class MessengerViewModel(app: Application) : AndroidViewModel(app) {
super.onCleared()
}
}
/**
* The four levels the local vault offers, and no compartments.
*
* 🔴 **Matches iOS's `Vocabularies.local` exactly, ordinals included.** The
* NUMBER is what travels with a file — the word is only how it is drawn — so a
* client that renumbered these would file somebody's Restricted document as
* Internal on the other phone, with no error anywhere and nothing on either
* screen looking wrong.
*
* ⚠️ No compartments here, deliberately. The lattice supports 64 of them and
* the local vault offers none: there is nobody to agree with about what area
* 7 means, and an area that only one phone understands is a label that stops
* a file opening rather than one that protects it.
*/
object FileLevels {
val LOCAL_LEVELS: List<Pair<Short, String>> = listOf(
0.toShort() to "Unclassified",
1.toShort() to "Internal",
2.toShort() to "Confidential",
3.toShort() to "Restricted",
)
fun levelName(classification: Short): String =
LOCAL_LEVELS.firstOrNull { it.first == classification }?.second
?: "Level $classification"
}
@@ -0,0 +1,107 @@
package io.flamenet.messenger.app
import android.content.Context
import android.content.Intent
import android.net.Uri
import android.provider.OpenableColumns
import io.flamenet.client.LocalVault
/**
* Files that stay where their owner put them, reached through the Storage
* Access Framework.
*
* This is the Android half of [LocalVault.ReferenceResolver] — the piece the
* vault deliberately does not know about, so that the vault itself can be
* tested on a laptop with no emulator.
*
* 🔴 **The permission must be persisted or the row is broken tomorrow.**
* `ActivityResultContracts.GetContent` hands back a URI whose read grant dies
* with the process. A reference made from one works perfectly for the rest of
* the session and then fails on the next launch — the worst shape of bug, since
* whoever added the file watched it work. `OpenDocument` plus
* [takePersistablePermission] is the combination that survives a reboot, and it
* is why the picker for this screen is not the picker the chat composer uses.
*
* ⚠️ **A persisted grant is still revocable.** The owner can move the file,
* delete it, or clear the app's access from Settings, and the row is then
* pointing at nothing. That is allowed — it is their file — so every path here
* reports it rather than throwing something a screen cannot phrase.
*/
class SafReferences(private val context: Context) : LocalVault.ReferenceResolver {
/**
* Keep read access across relaunches.
*
* Returns false when the provider refuses to make the grant persistable,
* which some do. 🔴 The caller must NOT file a reference in that case: a row
* that cannot be reopened is worse than a picker that says "this file can't
* be linked to — copy it in instead", because the first one looks like it
* worked.
*/
fun takePersistablePermission(uri: Uri): Boolean = try {
context.contentResolver.takePersistableUriPermission(
uri, Intent.FLAG_GRANT_READ_URI_PERMISSION)
true
} catch (e: SecurityException) {
false
}
fun releasePersistablePermission(bookmark: String) {
runCatching {
context.contentResolver.releasePersistableUriPermission(
Uri.parse(bookmark), Intent.FLAG_GRANT_READ_URI_PERMISSION)
}
}
/**
* ⚠️ **Opening the stream, not asking the provider whether the file
* exists.** `DocumentsContract` will happily describe a document that can no
* longer be read — a cloud provider with the account signed out is the
* everyday case — and a row that says it is fine and then fails on tap is
* the thing this whole distinction exists to avoid.
*/
override fun exists(bookmark: String): Boolean = try {
context.contentResolver.openInputStream(Uri.parse(bookmark))?.use { true } ?: false
} catch (e: Exception) {
// Deliberately broad. A provider that has gone away throws whatever it
// likes — SecurityException, FileNotFoundException, IllegalArgument,
// and on at least one vendor's provider a plain NullPointerException.
// Every one of them means the same thing to a person: it isn't there.
false
}
override fun read(bookmark: String): ByteArray =
context.contentResolver.openInputStream(Uri.parse(bookmark))?.use { it.readBytes() }
?: throw LocalVault.VaultException(
"That file isn't where it was — it may have been moved or deleted.")
/**
* ⚠️ Zero when the provider will not say, which is a real answer and not a
* failure. `OpenableColumns.SIZE` is optional, and several providers return
* null for it — the row then shows no size rather than a wrong one.
*/
override fun size(bookmark: String): Int = try {
context.contentResolver.query(
Uri.parse(bookmark), arrayOf(OpenableColumns.SIZE), null, null, null
)?.use { cursor ->
if (!cursor.moveToFirst()) return@use 0
val column = cursor.getColumnIndex(OpenableColumns.SIZE)
if (column < 0 || cursor.isNull(column)) 0 else cursor.getLong(column).toInt()
} ?: 0
} catch (e: Exception) {
0
}
/** The name the provider shows, for pre-filling the file's name in the vault. */
fun displayName(uri: Uri): String? = try {
context.contentResolver.query(
uri, arrayOf(OpenableColumns.DISPLAY_NAME), null, null, null
)?.use { cursor ->
if (!cursor.moveToFirst()) return@use null
val column = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME)
if (column < 0 || cursor.isNull(column)) null else cursor.getString(column)
}
} catch (e: Exception) {
null
}
}
@@ -0,0 +1,50 @@
package io.flamenet.messenger.app
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertTrue
/**
* The classification vocabulary the local vault offers.
*
* 🔴 **This is a change detector, and that is exactly what it should be.** The
* ORDINAL is what travels with a file — the word is only how it is drawn — so
* renumbering these would file somebody's Restricted document as Internal on
* the other phone. Nothing would error and neither screen would look wrong; the
* only symptom is a document sitting at a level nobody chose.
*
* These four, in this order, are `Vocabularies.local` in
* `Sources/Views/Files/FilesView.swift` on iOS. If that list changes, both have
* to change together and this test is where somebody is made to notice.
*/
class FileLevelsTest {
@Test
fun `the four local levels match the ordinals iOS files with`() {
assertEquals(
listOf(
0.toShort() to "Unclassified",
1.toShort() to "Internal",
2.toShort() to "Confidential",
3.toShort() to "Restricted",
),
FileLevels.LOCAL_LEVELS,
"the local vocabulary changed — iOS's Vocabularies.local must change with it, " +
"or a file classified on one phone arrives at a different level on the other",
)
}
@Test
fun `every level has a name, and an unknown one is not blank`() {
for ((ordinal, name) in FileLevels.LOCAL_LEVELS) {
assertEquals(name, FileLevels.levelName(ordinal))
}
// ⚠️ A file can arrive from a client with a wider vocabulary. Drawing an
// empty chip for it would show a row with no classification at all,
// which reads as "unclassified" — the most permissive thing it could
// possibly mean, and a guess.
assertTrue(FileLevels.levelName(9).contains("9"),
"an unknown level must still say WHICH level, not render as blank")
assertTrue(FileLevels.levelName(-1).isNotBlank())
}
}
@@ -1720,6 +1720,39 @@ class Vault(private val storage: Storage, private val key: SecretKey) {
/** Forget the wallet entirely. The phrase is then the only copy. */
fun forgetWallet() = storage.delete(walletName)
// -- The Files identity --------------------------------------------------
private val filesIdentityName = "files-identity"
/**
* The passphrase-protected Files identity.
*
* 🔴 **Sealed twice, and the second seal is the one that matters.** The
* vault around it is bound to this device's Keystore key, so the record does
* not leave the phone. The record ITSELF is encrypted under a key stretched
* from a passphrase this app deliberately does not keep — which is what
* makes an unlocked, borrowed phone still not open somebody's files.
*
* ⚠️ Dropping the inner seal for convenience would be a real reduction and
* not a refactor: everything about Files assumes the classification means
* something, and a vault that opens because the phone is open is a folder
* with a lock drawn on it.
*/
fun filesIdentityRecord(): FilesIdentity.Record? =
readJson(filesIdentityName)?.let {
runCatching { FilesIdentity.Record.parse(it.toString()) }.getOrNull()
}
fun saveFilesIdentityRecord(record: FilesIdentity.Record) =
writeJson(filesIdentityName, JSONObject(record.json()))
/**
* ⚠️ Forgetting this makes every vaulted file permanently unreadable — the
* label keys are derived from the identity and exist nowhere else. Only
* "erase this phone" and "delete account" should ever reach it.
*/
fun forgetFilesIdentity() = storage.delete(filesIdentityName)
// -- Home-record rollback floor (§20.4) ----------------------------------
private fun homeSerialName(fnid: String) = "home-serial-${Bytes.b64(Bytes.sha256(fnid.toByteArray(Charsets.UTF_8))).take(22).replace('/', '_')}"