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:
co-authored by
Claude Opus 5
parent
e4daaa6a35
commit
f7ddb263e5
@@ -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"))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user