diff --git a/PARITY.md b/PARITY.md index 29f8ed0..8737a4c 100644 --- a/PARITY.md +++ b/PARITY.md @@ -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.** 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 e8b7c28..49275b0 100644 --- a/app/src/main/kotlin/io/flamenet/messenger/app/ChatScreens.kt +++ b/app/src/main/kotlin/io/flamenet/messenger/app/ChatScreens.kt @@ -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() } diff --git a/app/src/main/kotlin/io/flamenet/messenger/app/FilesScreen.kt b/app/src/main/kotlin/io/flamenet/messenger/app/FilesScreen.kt new file mode 100644 index 0000000..1625f37 --- /dev/null +++ b/app/src/main/kotlin/io/flamenet/messenger/app/FilesScreen.kt @@ -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(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", + ) + } + } +} diff --git a/app/src/main/kotlin/io/flamenet/messenger/app/MainActivity.kt b/app/src/main/kotlin/io/flamenet/messenger/app/MainActivity.kt index 203bf25..06fc30a 100644 --- a/app/src/main/kotlin/io/flamenet/messenger/app/MainActivity.kt +++ b/app/src/main/kotlin/io/flamenet/messenger/app/MainActivity.kt @@ -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 -> diff --git a/app/src/main/kotlin/io/flamenet/messenger/app/MessengerViewModel.kt b/app/src/main/kotlin/io/flamenet/messenger/app/MessengerViewModel.kt index a98be2d..2ccd0ed 100644 --- a/app/src/main/kotlin/io/flamenet/messenger/app/MessengerViewModel.kt +++ b/app/src/main/kotlin/io/flamenet/messenger/app/MessengerViewModel.kt @@ -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 = emptyList(), + /** Deepest last. Empty means the top of the tree. */ + val breadcrumb: List = 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 = 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().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().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> = 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" +} diff --git a/app/src/main/kotlin/io/flamenet/messenger/app/SafReferences.kt b/app/src/main/kotlin/io/flamenet/messenger/app/SafReferences.kt new file mode 100644 index 0000000..cc0b579 --- /dev/null +++ b/app/src/main/kotlin/io/flamenet/messenger/app/SafReferences.kt @@ -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 + } +} diff --git a/app/src/test/kotlin/io/flamenet/messenger/app/FileLevelsTest.kt b/app/src/test/kotlin/io/flamenet/messenger/app/FileLevelsTest.kt new file mode 100644 index 0000000..bd37e98 --- /dev/null +++ b/app/src/test/kotlin/io/flamenet/messenger/app/FileLevelsTest.kt @@ -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()) + } +} diff --git a/engine/src/main/kotlin/io/flamenet/e2e/Vault.kt b/engine/src/main/kotlin/io/flamenet/e2e/Vault.kt index 78150e4..aa8f5ae 100644 --- a/engine/src/main/kotlin/io/flamenet/e2e/Vault.kt +++ b/engine/src/main/kotlin/io/flamenet/e2e/Vault.kt @@ -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('/', '_')}"