diff --git a/app/src/main/kotlin/io/flamenet/messenger/app/FilesScreen.kt b/app/src/main/kotlin/io/flamenet/messenger/app/FilesScreen.kt index 2627f3e..313cd97 100644 --- a/app/src/main/kotlin/io/flamenet/messenger/app/FilesScreen.kt +++ b/app/src/main/kotlin/io/flamenet/messenger/app/FilesScreen.kt @@ -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) -> Unit, + onLink: (String, Short, Set) -> Unit, ) { var name by remember { mutableStateOf(file.name) } var level by remember { mutableStateOf(1.toShort()) } + var areas by remember { mutableStateOf(emptySet()) } 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) -> Unit, ) { var name by remember { mutableStateOf("") } var level by remember { mutableStateOf(1.toShort()) } + var areas by remember { mutableStateOf(emptySet()) } 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, + onChange: (Set) -> Unit, +) { + val context = LocalContext.current + var areas by remember { mutableStateOf(LocalAreas.all(context)) } + var adding by remember { mutableStateOf("") } + var renaming by remember { mutableStateOf?>(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, diff --git a/app/src/main/kotlin/io/flamenet/messenger/app/LocalAreas.kt b/app/src/main/kotlin/io/flamenet/messenger/app/LocalAreas.kt new file mode 100644 index 0000000..48a3e9e --- /dev/null +++ b/app/src/main/kotlin/io/flamenet/messenger/app/LocalAreas.kt @@ -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> = + 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, "") +} diff --git a/app/src/main/kotlin/io/flamenet/messenger/app/MessengerViewModel.kt b/app/src/main/kotlin/io/flamenet/messenger/app/MessengerViewModel.kt index 4517ec3..92fdf56 100644 --- a/app/src/main/kotlin/io/flamenet/messenger/app/MessengerViewModel.kt +++ b/app/src/main/kotlin/io/flamenet/messenger/app/MessengerViewModel.kt @@ -1530,6 +1530,7 @@ class MessengerViewModel( uri: android.net.Uri, name: String, classification: Short, + areas: Set = 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 = 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 = 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) } diff --git a/app/src/test/kotlin/io/flamenet/messenger/app/LocalAreasTest.kt b/app/src/test/kotlin/io/flamenet/messenger/app/LocalAreasTest.kt new file mode 100644 index 0000000..f56e59c --- /dev/null +++ b/app/src/test/kotlin/io/flamenet/messenger/app/LocalAreasTest.kt @@ -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() + + @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")) + } +}