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:
@@ -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('/', '_')}"
|
||||
|
||||
Reference in New Issue
Block a user