The picker names a volume the way the browser does

Two fixes from driving Copy to… and Move to… on the phone.

The dialog was nested inside the conflict-prompt block, so it could only appear when a conflict was already on screen - which is never. Nothing in the unit tests could see that: they test the view model, and the view model was right.

And the picker named internal storage by the platform's own name for it, 0: as the volume row, and in the trail above it (0 / AADst). The roots screen has always shown Internal storage there; the picker now follows the same rule, through a label resource on PickerFolder rather than a second copy of the string logic. A test pins the label on both the row and the crumb.

On the device: copy leaves the source and the selection in place, move empties the source, clears the selection and says Moved 1 item. Recorded in EDGE_CASE_TESTING.md.
This commit is contained in:
2026-10-17 22:44:53 +02:00
parent f3abef6c44
commit 1ec71381a0
8 changed files with 94 additions and 35 deletions
@@ -71,8 +71,12 @@ fun TransferToDialog(
text = {
Column {
if (picker.crumbs.isNotEmpty()) {
// `map` is inline, so the labels can be resolved here; the trail is then plain text.
val trail = picker.crumbs.map { crumb ->
crumb.labelRes?.let { res -> stringResource(res) } ?: crumb.title
}
Text(
text = picker.crumbs.joinToString(" / ") { it.name },
text = trail.joinToString(" / "),
style = MaterialTheme.typography.bodySmall,
maxLines = 1,
)
@@ -102,7 +106,7 @@ fun TransferToDialog(
)
Spacer(modifier = Modifier.width(12.dp))
Text(
text = folder.title,
text = folder.labelRes?.let { stringResource(it) } ?: folder.title,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
@@ -582,18 +582,17 @@ fun BrowserScreen(
)
}
state.conflict?.let { prompt ->
val transferPicker = state.transferPicker
if (transferPicker != null) {
TransferToDialog(
picker = transferPicker,
onOpen = viewModel::pickerOpen,
onUp = viewModel::pickerUp,
onConfirm = viewModel::confirmTransferTo,
onDismiss = viewModel::cancelTransferTo,
)
}
state.transferPicker?.let { picker ->
TransferToDialog(
picker = picker,
onOpen = viewModel::pickerOpen,
onUp = viewModel::pickerUp,
onConfirm = viewModel::confirmTransferTo,
onDismiss = viewModel::cancelTransferTo,
)
}
state.conflict?.let { prompt ->
ConflictDialog(
prompt = prompt,
onChoice = { policy -> viewModel.resolveConflict(policy) },
@@ -42,9 +42,14 @@ data class Clipboard(val paths: List<StoragePath>, val cut: Boolean) {
* [label] is the platform's name for a volume, which is the only place the picker has something better
* than a path to show — internal storage's own name is `0`.
*/
data class PickerFolder(val path: StoragePath, val label: String? = null) {
data class PickerFolder(
val path: StoragePath,
val label: String? = null,
/** A localised name for the volumes the platform does not name — internal storage is `0` to it. */
@StringRes val labelRes: Int? = null,
) {
/** What the row says: the volume's label when there is one, else the folder's own name. */
/** What the row says: the volume's own name, else the folder's name. */
val title: String get() = label ?: path.name
}
@@ -61,8 +66,8 @@ data class TransferPicker(
/** The folder being shown, or `null` for the volumes. */
val path: StoragePath? = null,
val folders: List<PickerFolder> = emptyList(),
/** The trail back to the volumes, ending with [path] when one is open. */
val crumbs: List<StoragePath> = emptyList(),
/** The trail back to the volumes, ending with [path] when one is open; a root keeps its label. */
val crumbs: List<PickerFolder> = emptyList(),
val loading: Boolean = false,
)
@@ -517,7 +517,7 @@ class BrowserViewModel(
fun pickerUp() {
val picker = _state.value.transferPicker ?: return
val crumbs = picker.crumbs
openPicker(if (crumbs.size > 1) crumbs[crumbs.size - 2] else null)
openPicker(if (crumbs.size > 1) crumbs[crumbs.size - 2].path else null)
}
fun cancelTransferTo() {
@@ -574,7 +574,15 @@ class BrowserViewModel(
}
viewModelScope.launch {
val folders = if (path == null) {
storage.volumes().map { PickerFolder(path = it.root, label = it.label) }
storage.volumes().map { volume ->
PickerFolder(
path = volume.root,
label = volume.label,
// The same rule as the roots screen: internal storage has no platform label, and
// showing its own name (`0`) in a copy dialog would be a riddle.
labelRes = if (volume.label == null) R.string.browser_internal_storage else null,
)
}
} else {
val source = storage.activeSource()
val entries = if (source == null) {
@@ -599,17 +607,25 @@ class BrowserViewModel(
}
/** The trail down to [path], starting at its volume root; empty for the volume list itself. */
private fun pickerCrumbs(path: StoragePath?): List<StoragePath> {
private fun pickerCrumbs(path: StoragePath?): List<PickerFolder> {
if (path == null) return emptyList()
val roots = storage.volumes().mapTo(HashSet()) { it.root }
val volumes = storage.volumes().associateBy { it.root }
val crumbs = ArrayList<StoragePath>()
var current: StoragePath? = path
while (current != null) {
crumbs += current
if (current in roots) break
if (current in volumes) break
current = current.parent
}
return crumbs.reversed()
return crumbs.reversed().map { crumb ->
// A volume root in the trail is named the way the volume list names it.
val volume = volumes[crumb] ?: return@map PickerFolder(path = crumb)
PickerFolder(
path = crumb,
label = volume.label,
labelRes = if (volume.label == null) R.string.browser_internal_storage else null,
)
}
}
// ----------------------------------------------------------------- libraries
@@ -284,6 +284,23 @@ class BrowserViewModelTest {
assertEquals(setOf(documents, downloads), folders.toSet())
}
@Test
fun `the picker names a volume the way the browser does`() {
val model = viewModel()
model.openDirectory(downloads)
model.toggleSelection(downloads.child("a.txt"))
model.startTransferTo(cut = false)
val volumes = model.state.value.transferPicker!!.folders
assertEquals(listOf(root), volumes.map { it.path })
// Internal storage has no platform label; showing its own name, `0`, would be a riddle.
assertEquals(R.string.browser_internal_storage, volumes.single().labelRes)
model.pickerOpen(downloads)
val crumb = model.state.value.transferPicker!!.crumbs.first()
assertEquals(R.string.browser_internal_storage, crumb.labelRes)
}
@Test
fun `the picker walks up to the volumes and back down`() {
val model = viewModel()
@@ -292,16 +309,16 @@ class BrowserViewModelTest {
model.startTransferTo(cut = false)
// The picker opens on the volumes, with no trail behind it.
assertEquals(emptyList<StoragePath>(), model.state.value.transferPicker!!.crumbs)
assertTrue(model.state.value.transferPicker!!.crumbs.isEmpty())
model.pickerOpen(downloads)
assertEquals(listOf(root, downloads), model.state.value.transferPicker!!.crumbs)
assertEquals(listOf(root, downloads), model.state.value.transferPicker!!.crumbs.map { it.path })
model.pickerUp()
assertEquals(listOf(root), model.state.value.transferPicker!!.crumbs)
assertEquals(listOf(root), model.state.value.transferPicker!!.crumbs.map { it.path })
model.pickerUp()
assertEquals(emptyList<StoragePath>(), model.state.value.transferPicker!!.crumbs)
assertTrue(model.state.value.transferPicker!!.crumbs.isEmpty())
}
@Test