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

The screen, the Storage Access Framework resolver behind
`LocalVault.ReferenceResolver`, and the passphrase-protected identity the
vault opens under.

🔴 **Android asks for a Files passphrase, as iOS does, and that 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, with the Keystore seal
around it 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.

🔴 **`OpenDocument`, never `GetContent`.** `GetContent` hands back a URI
whose read grant dies with the process, so a linked file works for the
rest of the session and is broken on the next launch — and whoever added
it watched it work. `filesReference` REFUSES when the grant cannot be
persisted and offers to copy the file in instead, rather than filing a row
that will be dead tomorrow.

🔴 **Locking drops the identity; it does not hide a screen.** The open
vault is a field, deliberately not part of `FilesState` — a state object
gets logged, compared and copied into recompositions, none of which should
ever have a private key in it.

The classification ordinals are pinned by a change-detector test, on
purpose: the NUMBER travels with a file and the word is only how it is
drawn, so renumbering them would file a Restricted document as Internal on
the other phone with nothing erroring and neither screen looking wrong.

⚠️ **Not yet run on a device.** The machine was carrying an iOS simulator
at 99% CPU on 8 cores, and booting an emulator on top of that produces a
flaky run rather than evidence. Two things need a real device before this
is done: the SAF picker with its persistable grant, and whether a linked
file still reads after a reboot. Neither can be faked on the JVM.

⚠️ Files is its own destination, not a section of the wallet. They share an
unlock, which is true and is an implementation detail; iOS grouped them
once and what it produced was somebody pressing "wallet" and arriving at a
file list.

Full build and all JVM suites green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Paul Hitt
2026-09-04 15:22:56 -04:00
co-authored by Claude Opus 5
parent 3a2c10f7bc
commit fbf2c4c531
8 changed files with 1161 additions and 3 deletions
@@ -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
}
}