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 androidx.compose.ui.unit.sp
|
||||||
import io.flamenet.client.LocalVault
|
import io.flamenet.client.LocalVault
|
||||||
import java.util.UUID
|
import java.util.UUID
|
||||||
|
import androidx.compose.material3.AlertDialog
|
||||||
|
import androidx.compose.material3.TextButton
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Files, on this phone.
|
* Files, on this phone.
|
||||||
@@ -270,9 +272,9 @@ private fun ColumnScope.FilesBrowser(
|
|||||||
if (namingFolder) {
|
if (namingFolder) {
|
||||||
FolderNameSheet(
|
FolderNameSheet(
|
||||||
onCancel = { namingFolder = false },
|
onCancel = { namingFolder = false },
|
||||||
onCreate = { name, level ->
|
onCreate = { name, level, areas ->
|
||||||
namingFolder = false
|
namingFolder = false
|
||||||
model.filesCreateFolder(name, level)
|
model.filesCreateFolder(name, level, areas)
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -280,13 +282,13 @@ private fun ColumnScope.FilesBrowser(
|
|||||||
AddFileSheet(
|
AddFileSheet(
|
||||||
file = file,
|
file = file,
|
||||||
onCancel = { pending = null },
|
onCancel = { pending = null },
|
||||||
onVault = { name, level ->
|
onVault = { name, level, areas ->
|
||||||
pending = null
|
pending = null
|
||||||
model.filesAdd(file.uri, name, level)
|
model.filesAdd(file.uri, name, level, areas)
|
||||||
},
|
},
|
||||||
onLink = { name, level ->
|
onLink = { name, level, areas ->
|
||||||
pending = null
|
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(
|
private fun ColumnScope.AddFileSheet(
|
||||||
file: PendingFile,
|
file: PendingFile,
|
||||||
onCancel: () -> Unit,
|
onCancel: () -> Unit,
|
||||||
onVault: (String, Short) -> Unit,
|
onVault: (String, Short, Set<Int>) -> Unit,
|
||||||
onLink: (String, Short) -> Unit,
|
onLink: (String, Short, Set<Int>) -> Unit,
|
||||||
) {
|
) {
|
||||||
var name by remember { mutableStateOf(file.name) }
|
var name by remember { mutableStateOf(file.name) }
|
||||||
var level by remember { mutableStateOf(1.toShort()) }
|
var level by remember { mutableStateOf(1.toShort()) }
|
||||||
|
var areas by remember { mutableStateOf(emptySet<Int>()) }
|
||||||
|
|
||||||
Spacer(Modifier.height(Space.md))
|
Spacer(Modifier.height(Space.md))
|
||||||
Column(
|
Column(
|
||||||
@@ -463,6 +466,8 @@ private fun ColumnScope.AddFileSheet(
|
|||||||
)
|
)
|
||||||
Spacer(Modifier.height(Space.sm))
|
Spacer(Modifier.height(Space.sm))
|
||||||
LevelPicker(level) { level = it }
|
LevelPicker(level) { level = it }
|
||||||
|
Spacer(Modifier.height(Space.sm))
|
||||||
|
AreaPicker(areas) { areas = it }
|
||||||
Spacer(Modifier.height(Space.md))
|
Spacer(Modifier.height(Space.md))
|
||||||
|
|
||||||
Text(
|
Text(
|
||||||
@@ -474,7 +479,7 @@ private fun ColumnScope.AddFileSheet(
|
|||||||
Spacer(Modifier.height(Space.xs))
|
Spacer(Modifier.height(Space.xs))
|
||||||
KitPrimaryButton(
|
KitPrimaryButton(
|
||||||
text = "Copy in and encrypt",
|
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(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
tag = "files-add-vault",
|
tag = "files-add-vault",
|
||||||
)
|
)
|
||||||
@@ -489,7 +494,7 @@ private fun ColumnScope.AddFileSheet(
|
|||||||
Spacer(Modifier.height(Space.xs))
|
Spacer(Modifier.height(Space.xs))
|
||||||
KitSecondaryButton(
|
KitSecondaryButton(
|
||||||
text = "Link to it instead",
|
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(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
tag = "files-add-link",
|
tag = "files-add-link",
|
||||||
)
|
)
|
||||||
@@ -506,10 +511,11 @@ private fun ColumnScope.AddFileSheet(
|
|||||||
@Composable
|
@Composable
|
||||||
private fun ColumnScope.FolderNameSheet(
|
private fun ColumnScope.FolderNameSheet(
|
||||||
onCancel: () -> Unit,
|
onCancel: () -> Unit,
|
||||||
onCreate: (String, Short) -> Unit,
|
onCreate: (String, Short, Set<Int>) -> Unit,
|
||||||
) {
|
) {
|
||||||
var name by remember { mutableStateOf("") }
|
var name by remember { mutableStateOf("") }
|
||||||
var level by remember { mutableStateOf(1.toShort()) }
|
var level by remember { mutableStateOf(1.toShort()) }
|
||||||
|
var areas by remember { mutableStateOf(emptySet<Int>()) }
|
||||||
|
|
||||||
Spacer(Modifier.height(Space.md))
|
Spacer(Modifier.height(Space.md))
|
||||||
Column(
|
Column(
|
||||||
@@ -532,6 +538,8 @@ private fun ColumnScope.FolderNameSheet(
|
|||||||
)
|
)
|
||||||
Spacer(Modifier.height(Space.sm))
|
Spacer(Modifier.height(Space.sm))
|
||||||
LevelPicker(level) { level = it }
|
LevelPicker(level) { level = it }
|
||||||
|
Spacer(Modifier.height(Space.sm))
|
||||||
|
AreaPicker(areas) { areas = it }
|
||||||
Spacer(Modifier.height(Space.xs))
|
Spacer(Modifier.height(Space.xs))
|
||||||
// Said here because the tree deliberately allows the opposite of what
|
// Said here because the tree deliberately allows the opposite of what
|
||||||
// people assume: a folder's level does not constrain what goes in it.
|
// people assume: a folder's level does not constrain what goes in it.
|
||||||
@@ -551,7 +559,7 @@ private fun ColumnScope.FolderNameSheet(
|
|||||||
)
|
)
|
||||||
KitPrimaryButton(
|
KitPrimaryButton(
|
||||||
text = "Create",
|
text = "Create",
|
||||||
onClick = { onCreate(name, level) },
|
onClick = { onCreate(name, level, areas) },
|
||||||
enabled = name.isNotBlank(),
|
enabled = name.isNotBlank(),
|
||||||
modifier = Modifier.weight(1f),
|
modifier = Modifier.weight(1f),
|
||||||
tag = "files-folder-create",
|
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
|
@Composable
|
||||||
private fun ColumnScope.LevelPicker(
|
private fun ColumnScope.LevelPicker(
|
||||||
selected: Short,
|
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,
|
uri: android.net.Uri,
|
||||||
name: String,
|
name: String,
|
||||||
classification: Short,
|
classification: Short,
|
||||||
|
areas: Set<Int> = emptySet(),
|
||||||
) = viewModelScope.launch {
|
) = viewModelScope.launch {
|
||||||
val open = filesVault ?: return@launch
|
val open = filesVault ?: return@launch
|
||||||
val parent = signedIn()?.files?.parentId
|
val parent = signedIn()?.files?.parentId
|
||||||
@@ -1543,7 +1544,7 @@ class MessengerViewModel(
|
|||||||
.openInputStream(uri)
|
.openInputStream(uri)
|
||||||
?.use { it.readBytes() }
|
?.use { it.readBytes() }
|
||||||
?: throw IllegalStateException("That file couldn't be read.")
|
?: 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.")
|
finishFilesWork(done, "Encrypted and filed.")
|
||||||
@@ -1556,6 +1557,7 @@ class MessengerViewModel(
|
|||||||
uri: android.net.Uri,
|
uri: android.net.Uri,
|
||||||
name: String,
|
name: String,
|
||||||
classification: Short,
|
classification: Short,
|
||||||
|
areas: Set<Int> = emptySet(),
|
||||||
) = viewModelScope.launch {
|
) = viewModelScope.launch {
|
||||||
val open = filesVault ?: return@launch
|
val open = filesVault ?: return@launch
|
||||||
val parent = signedIn()?.files?.parentId
|
val parent = signedIn()?.files?.parentId
|
||||||
@@ -1570,7 +1572,7 @@ class MessengerViewModel(
|
|||||||
"stop working next time. Copy it into the vault instead.",
|
"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.")
|
finishFilesWork(done, "Linked. Nothing was copied.")
|
||||||
@@ -1579,6 +1581,7 @@ class MessengerViewModel(
|
|||||||
fun filesCreateFolder(
|
fun filesCreateFolder(
|
||||||
name: String,
|
name: String,
|
||||||
classification: Short,
|
classification: Short,
|
||||||
|
areas: Set<Int> = emptySet(),
|
||||||
) = viewModelScope.launch {
|
) = viewModelScope.launch {
|
||||||
val open = filesVault ?: return@launch
|
val open = filesVault ?: return@launch
|
||||||
val parent = signedIn()?.files?.parentId
|
val parent = signedIn()?.files?.parentId
|
||||||
@@ -1588,7 +1591,9 @@ class MessengerViewModel(
|
|||||||
}
|
}
|
||||||
val done =
|
val done =
|
||||||
withContext(Dispatchers.IO) {
|
withContext(Dispatchers.IO) {
|
||||||
runCatching { open.createFolder(name.trim(), Label(classification), parent) }
|
runCatching {
|
||||||
|
open.createFolder(name.trim(), Label(classification, areas), parent)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
finishFilesWork(done, null)
|
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