Areas can be chosen, and named

The engine has always carried compartments and both apps sealed them in,
honoured them in clearance checks and rendered them on a received file. No
screen let anybody set one, so a label was half-settable: how sensitive, but
never which part of the business. The public page promised both.

The bits travel with the file. The names do not: they are this organisation's
words, so a file from somebody else shows its areas as numbers, which is
correct rather than a gap.

Nothing is named for you. The site says areas are whatever your organisation
already calls them, so this is where you say so.

Forgetting a name does not free the bit. A file already filed under it still
carries it, and handing that bit to a different area would silently relabel
that file.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Paul Hitt
2026-09-10 07:56:05 -04:00
co-authored by Claude Opus 5
parent e4daaa6a35
commit f7ddb263e5
4 changed files with 277 additions and 15 deletions
@@ -38,6 +38,8 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import io.flamenet.client.LocalVault
import java.util.UUID
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.TextButton
/**
* Files, on this phone.
@@ -270,9 +272,9 @@ private fun ColumnScope.FilesBrowser(
if (namingFolder) {
FolderNameSheet(
onCancel = { namingFolder = false },
onCreate = { name, level ->
onCreate = { name, level, areas ->
namingFolder = false
model.filesCreateFolder(name, level)
model.filesCreateFolder(name, level, areas)
},
)
}
@@ -280,13 +282,13 @@ private fun ColumnScope.FilesBrowser(
AddFileSheet(
file = file,
onCancel = { pending = null },
onVault = { name, level ->
onVault = { name, level, areas ->
pending = null
model.filesAdd(file.uri, name, level)
model.filesAdd(file.uri, name, level, areas)
},
onLink = { name, level ->
onLink = { name, level, areas ->
pending = null
model.filesReference(file.uri, name, level)
model.filesReference(file.uri, name, level, areas)
},
)
}
@@ -436,11 +438,12 @@ private fun bytes(count: Int): String =
private fun ColumnScope.AddFileSheet(
file: PendingFile,
onCancel: () -> Unit,
onVault: (String, Short) -> Unit,
onLink: (String, Short) -> Unit,
onVault: (String, Short, Set<Int>) -> Unit,
onLink: (String, Short, Set<Int>) -> Unit,
) {
var name by remember { mutableStateOf(file.name) }
var level by remember { mutableStateOf(1.toShort()) }
var areas by remember { mutableStateOf(emptySet<Int>()) }
Spacer(Modifier.height(Space.md))
Column(
@@ -463,6 +466,8 @@ private fun ColumnScope.AddFileSheet(
)
Spacer(Modifier.height(Space.sm))
LevelPicker(level) { level = it }
Spacer(Modifier.height(Space.sm))
AreaPicker(areas) { areas = it }
Spacer(Modifier.height(Space.md))
Text(
@@ -474,7 +479,7 @@ private fun ColumnScope.AddFileSheet(
Spacer(Modifier.height(Space.xs))
KitPrimaryButton(
text = "Copy in and encrypt",
onClick = { onVault(name.trim().ifEmpty { file.name }, level) },
onClick = { onVault(name.trim().ifEmpty { file.name }, level, areas) },
modifier = Modifier.fillMaxWidth(),
tag = "files-add-vault",
)
@@ -489,7 +494,7 @@ private fun ColumnScope.AddFileSheet(
Spacer(Modifier.height(Space.xs))
KitSecondaryButton(
text = "Link to it instead",
onClick = { onLink(name.trim().ifEmpty { file.name }, level) },
onClick = { onLink(name.trim().ifEmpty { file.name }, level, areas) },
modifier = Modifier.fillMaxWidth(),
tag = "files-add-link",
)
@@ -506,10 +511,11 @@ private fun ColumnScope.AddFileSheet(
@Composable
private fun ColumnScope.FolderNameSheet(
onCancel: () -> Unit,
onCreate: (String, Short) -> Unit,
onCreate: (String, Short, Set<Int>) -> Unit,
) {
var name by remember { mutableStateOf("") }
var level by remember { mutableStateOf(1.toShort()) }
var areas by remember { mutableStateOf(emptySet<Int>()) }
Spacer(Modifier.height(Space.md))
Column(
@@ -532,6 +538,8 @@ private fun ColumnScope.FolderNameSheet(
)
Spacer(Modifier.height(Space.sm))
LevelPicker(level) { level = it }
Spacer(Modifier.height(Space.sm))
AreaPicker(areas) { areas = 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.
@@ -551,7 +559,7 @@ private fun ColumnScope.FolderNameSheet(
)
KitPrimaryButton(
text = "Create",
onClick = { onCreate(name, level) },
onClick = { onCreate(name, level, areas) },
enabled = name.isNotBlank(),
modifier = Modifier.weight(1f),
tag = "files-folder-create",
@@ -560,6 +568,112 @@ private fun ColumnScope.FolderNameSheet(
}
}
/**
* Which part of the business a file belongs to.
*
* The label carries bit numbers and those travel with the file; the names are
* this device's. A file from another organisation shows its areas as numbers,
* which is correct rather than a gap. Mirrors the iOS picker.
*/
@Composable
private fun ColumnScope.AreaPicker(
chosen: Set<Int>,
onChange: (Set<Int>) -> Unit,
) {
val context = LocalContext.current
var areas by remember { mutableStateOf(LocalAreas.all(context)) }
var adding by remember { mutableStateOf("") }
var renaming by remember { mutableStateOf<Pair<Int, String>?>(null) }
Text("Areas", fontSize = 12.sp, color = Palette.SecondaryText)
Spacer(Modifier.height(Space.xs))
if (areas.isEmpty()) {
Text(
"Nothing here is named for you. Add the words your organisation " +
"already uses.",
fontSize = 12.sp,
color = Palette.SecondaryText,
modifier = Modifier.testTag("files-areas-empty"),
)
Spacer(Modifier.height(Space.xs))
}
Row(
modifier = Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()),
horizontalArrangement = Arrangement.spacedBy(Space.xs),
) {
for ((bit, name) in areas) {
KitChip(
text = name,
selected = bit in chosen,
onClick = {
onChange(if (bit in chosen) chosen - bit else chosen + bit)
},
tag = "files-area-$bit",
)
}
}
Spacer(Modifier.height(Space.xs))
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(Space.xs),
) {
OutlinedTextField(
value = adding,
onValueChange = { adding = it },
label = { Text("Add an area") },
singleLine = true,
modifier = Modifier.weight(1f).testTag("files-area-new"),
)
TextButton(
enabled = adding.isNotBlank() && LocalAreas.nextFreeBit(context) != null,
onClick = {
LocalAreas.add(context, adding)
adding = ""
areas = LocalAreas.all(context)
},
modifier = Modifier.testTag("files-area-add"),
) { Text("Add") }
}
if (areas.isNotEmpty()) {
Spacer(Modifier.height(Space.xs))
Text(
"Long-press an area to rename or forget it. A file carries the area " +
"itself; the name is this phone's.",
fontSize = 12.sp,
color = Palette.SecondaryText,
)
}
renaming?.let { (bit, current) ->
var text by remember(bit) { mutableStateOf(current) }
AlertDialog(
onDismissRequest = { renaming = null },
title = { Text("Rename area") },
text = {
OutlinedTextField(
value = text,
onValueChange = { text = it },
singleLine = true,
modifier = Modifier.testTag("files-area-rename"),
)
},
confirmButton = {
TextButton(onClick = {
LocalAreas.rename(context, bit, text)
renaming = null
areas = LocalAreas.all(context)
}) { Text("Rename") }
},
dismissButton = {
TextButton(onClick = {
LocalAreas.forget(context, bit)
renaming = null
areas = LocalAreas.all(context)
}) { Text("Forget") }
},
)
}
}
@Composable
private fun ColumnScope.LevelPicker(
selected: Short,
@@ -0,0 +1,71 @@
package io.flamenet.messenger.app
import android.content.Context
/**
* The areas this device knows the names of.
*
* A label carries **bit numbers**, 0 to 63, and those travel with the file.
* The names do not: they are this organisation's words for those bits, and a
* file from somebody else's organisation shows its areas as numbers.
*
* So the names are stored here rather than shipped. Nothing is invented for
* you: the site says areas are whatever your organisation already calls them,
* and this is where you say so. Mirrors `LocalAreas` on iOS.
*/
object LocalAreas {
private const val PREFS = "files.areas.v1"
private fun prefs(context: Context) = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
/** Bit number to name, lowest bit first. */
fun all(context: Context): List<Pair<Int, String>> =
prefs(context)
.all
.mapNotNull { (key, value) ->
val bit = key.toIntOrNull() ?: return@mapNotNull null
if (bit !in 0..63) return@mapNotNull null
val name = (value as? String)?.trim().orEmpty()
if (name.isEmpty()) null else bit to name
}.sortedBy { it.first }
/** The lowest bit not yet spoken for, or null when all 64 are taken. */
fun nextFreeBit(context: Context): Int? {
val taken = all(context).map { it.first }.toSet()
return (0..63).firstOrNull { it !in taken }
}
fun add(
context: Context,
name: String,
): Int? {
val trimmed = name.trim()
if (trimmed.isEmpty()) return null
val bit = nextFreeBit(context) ?: return null
rename(context, bit, trimmed)
return bit
}
fun rename(
context: Context,
bit: Int,
name: String,
) {
if (bit !in 0..63) return
val trimmed = name.trim()
prefs(context).edit().apply {
if (trimmed.isEmpty()) remove(bit.toString()) else putString(bit.toString(), trimmed)
}.apply()
}
/**
* Forget the name. The bit stays on any file already filed under it, which
* is why this is a rename to nothing rather than a deletion: the file keeps
* its area and shows it as a number again, and the bit is not handed to
* something else.
*/
fun forget(
context: Context,
bit: Int,
) = rename(context, bit, "")
}
@@ -1530,6 +1530,7 @@ class MessengerViewModel(
uri: android.net.Uri,
name: String,
classification: Short,
areas: Set<Int> = emptySet(),
) = viewModelScope.launch {
val open = filesVault ?: return@launch
val parent = signedIn()?.files?.parentId
@@ -1543,7 +1544,7 @@ class MessengerViewModel(
.openInputStream(uri)
?.use { it.readBytes() }
?: throw IllegalStateException("That file couldn't be read.")
open.add(bytes, name, Label(classification), parent)
open.add(bytes, name, Label(classification, areas), parent)
}
}
finishFilesWork(done, "Encrypted and filed.")
@@ -1556,6 +1557,7 @@ class MessengerViewModel(
uri: android.net.Uri,
name: String,
classification: Short,
areas: Set<Int> = emptySet(),
) = viewModelScope.launch {
val open = filesVault ?: return@launch
val parent = signedIn()?.files?.parentId
@@ -1570,7 +1572,7 @@ class MessengerViewModel(
"stop working next time. Copy it into the vault instead.",
)
}
open.reference(uri.toString(), name, Label(classification), parent)
open.reference(uri.toString(), name, Label(classification, areas), parent)
}
}
finishFilesWork(done, "Linked. Nothing was copied.")
@@ -1579,6 +1581,7 @@ class MessengerViewModel(
fun filesCreateFolder(
name: String,
classification: Short,
areas: Set<Int> = emptySet(),
) = viewModelScope.launch {
val open = filesVault ?: return@launch
val parent = signedIn()?.files?.parentId
@@ -1588,7 +1591,9 @@ class MessengerViewModel(
}
val done =
withContext(Dispatchers.IO) {
runCatching { open.createFolder(name.trim(), Label(classification), parent) }
runCatching {
open.createFolder(name.trim(), Label(classification, areas), parent)
}
}
finishFilesWork(done, null)
}
@@ -0,0 +1,72 @@
package io.flamenet.messenger.app
import androidx.test.core.app.ApplicationProvider
import kotlin.test.assertEquals
import kotlin.test.assertNull
import kotlin.test.assertTrue
import org.junit.After
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
/**
* Naming the areas a label's bits stand for.
*
* The bits travel with the file; the names are this device's. Mirrors
* `LocalAreasTests` on iOS, case for case.
*/
@RunWith(RobolectricTestRunner::class)
class LocalAreasTest {
private val context = ApplicationProvider.getApplicationContext<android.app.Application>()
@After
fun clear() {
for (bit in 0..63) LocalAreas.forget(context, bit)
}
@Test
fun `a fresh device knows no areas and invents none`() {
assertTrue(LocalAreas.all(context).isEmpty())
}
@Test
fun `adding takes the lowest free bit`() {
assertEquals(0, LocalAreas.add(context, "Legal"))
assertEquals(1, LocalAreas.add(context, "Payroll"))
assertEquals(listOf("Legal", "Payroll"), LocalAreas.all(context).map { it.second })
}
@Test
fun `a blank name is not an area`() {
assertNull(LocalAreas.add(context, " "))
assertTrue(LocalAreas.all(context).isEmpty())
}
@Test
fun `renaming keeps the bit so already-filed material keeps its area`() {
val bit = LocalAreas.add(context, "Legal")!!
LocalAreas.rename(context, bit, "Legal and risk")
assertEquals(listOf(0 to "Legal and risk"), LocalAreas.all(context))
}
/**
* Forgetting a name must not pretend the bit is free: a file already filed
* under it still carries it, and handing the same bit to a different area
* would silently relabel that file.
*/
@Test
fun `forgetting a name does not hand the bit to something else`() {
LocalAreas.add(context, "Legal")
LocalAreas.add(context, "Payroll")
LocalAreas.forget(context, 0)
assertEquals(listOf("Payroll"), LocalAreas.all(context).map { it.second })
assertEquals(0, LocalAreas.add(context, "Board"))
}
@Test
fun `sixty-four areas is the ceiling and it is reported rather than crashed`() {
for (i in 0..63) assertEquals(i, LocalAreas.add(context, "Area $i"))
assertNull(LocalAreas.nextFreeBit(context))
assertNull(LocalAreas.add(context, "One too many"))
}
}