A copy into a picked folder says where it went, and Show takes you there

The last acceptance criterion M16 had left open. After Copy to..., the snackbar reads 'Copied to AADst' with a Show button; after a move to a picked folder it keeps the undoable message instead, because the sources are gone and Show would lead nowhere.

Two things needed care rather than a string. The action button was hard-wired to undo() - the label was a parameter beside it, which let the two disagree - so UiMessage now carries a MessageAction, and the label follows from it: Undo for an undo, Show for a jump. And a message cannot name a folder from the path alone: a volume root has no name of its own, and internal storage's is 0. The message carries the picked PickerFolder, and the screen resolves its label the way breadcrumbs do - so copying into the root reads 'Copied to Internal storage', which is the case a plain path.name would have got wrong.

Four tests: the message names the destination and offers Show; Show opens it; a paste into the folder on screen still names nothing and offers nothing (its destination is the screen); a move to a picked folder offers Undo. All four driven through the view model, and both snackbar variants checked on the phone, including the volume-root one.

detekt's SpreadOperator baseline entries moved with the expression, so the message building is now a small helper with a deliberate suppression and a reason, and the baseline was regenerated: three obsolete entries removed, none added.
This commit is contained in:
2026-10-17 23:17:03 +02:00
parent 747a6f7784
commit 17078bf613
10 changed files with 219 additions and 26 deletions
+2 -1
View File
@@ -18,7 +18,8 @@ upload needs a **higher `versionCode`** than every previous upload.
folder **without leaving the one you are in**. The files stay selected afterwards, so sending the same folder **without leaving the one you are in**. The files stay selected afterwards, so sending the same
set to a second destination is two taps. A name that already exists there asks exactly what Paste asks, set to a second destination is two taps. A name that already exists there asks exactly what Paste asks,
naming that folder; progress belongs to the same operation notification, and a move clears the selection naming that folder; progress belongs to the same operation notification, and a move clears the selection
because those rows are gone. Underneath it is the paste path with an explicit target: the engine was because those rows are gone. A finished copy answers with **"Copied to *folder*"** and a **Show** button
that opens the destination — so the file you just put somewhere is one tap away from being looked at. Underneath it is the paste path with an explicit target: the engine was
always target-agnostic, so the new part is the picker and the rules around it. always target-agnostic, so the new part is the picker and the rules around it.
- **Several files can be renamed at once, numbered in a series that carries on.** Selecting more - **Several files can be renamed at once, numbered in a series that carries on.** Selecting more
@@ -320,17 +320,18 @@ fun BrowserScreen(
val message = state.message val message = state.message
val messageText = message?.let { // A message that names a folder cannot be built from the path alone: a volume root has no name of its
it.pluralQuantity?.let { quantity -> // own (`0`), which is the one thing only the screen can resolve — the same rule breadcrumbs use.
pluralStringResource(it.textRes, quantity, *it.args.toTypedArray()) val destinationLabel = message?.destination?.let { folder ->
} ?: stringResource(it.textRes, *it.args.toTypedArray()) folder.labelRes?.let { stringResource(it) } ?: folder.title
} }
val messageText = message?.let { messageText(it, destinationLabel) }
// Resolved out here because `stringResource` is a composable call and the effect is not. // Resolved out here because `stringResource` is a composable call and the effect is not.
val undoLabel = message?.actionLabelRes?.let { stringResource(it) } val actionLabel = message?.actionLabelRes?.let { stringResource(it) }
LaunchedEffect(message?.id) { LaunchedEffect(message?.id) {
if (messageText != null) { if (messageText != null) {
val outcome = snackbarHostState.showSnackbar(messageText, actionLabel = undoLabel) val outcome = snackbarHostState.showSnackbar(messageText, actionLabel = actionLabel)
if (outcome == SnackbarResult.ActionPerformed) viewModel.undo() if (outcome == SnackbarResult.ActionPerformed) viewModel.performMessageAction()
viewModel.consumeMessage() viewModel.consumeMessage()
} }
} }
@@ -601,3 +602,21 @@ fun BrowserScreen(
} }
} }
/**
* The text of a snackbar message.
*
* A message that names a folder cannot carry its label as an argument — a volume root has no name of its own
* (internal storage's is `0`) — so the screen resolves it the way breadcrumbs are resolved and passes it as
* the first argument.
*
* `SpreadOperator` is suppressed on purpose: the `vararg` resource helpers take an array, and this runs once
* per message rather than once per row or per frame, so the copy the rule warns about is not a hot path.
*/
@Suppress("SpreadOperator")
@Composable
private fun messageText(message: UiMessage, destinationLabel: String?): String {
val args = if (destinationLabel != null) listOf<Any>(destinationLabel) + message.args else message.args
return message.pluralQuantity?.let { quantity ->
pluralStringResource(message.textRes, quantity, *args.toTypedArray())
} ?: stringResource(message.textRes, *args.toTypedArray())
}
@@ -4,6 +4,7 @@ import android.net.Uri
import androidx.activity.result.IntentSenderRequest import androidx.activity.result.IntentSenderRequest
import androidx.annotation.AnyRes import androidx.annotation.AnyRes
import androidx.annotation.StringRes import androidx.annotation.StringRes
import de.softwareapp_hb.filemanager.R
import de.softwareapp_hb.filemanager.core.ConflictPolicy import de.softwareapp_hb.filemanager.core.ConflictPolicy
import de.softwareapp_hb.filemanager.core.FileCategory import de.softwareapp_hb.filemanager.core.FileCategory
import de.softwareapp_hb.filemanager.core.FileEntry import de.softwareapp_hb.filemanager.core.FileEntry
@@ -71,6 +72,21 @@ data class TransferPicker(
val loading: Boolean = false, val loading: Boolean = false,
) )
/**
* What a snackbar's action button does.
*
* The label follows from the action rather than being passed beside it, so the two cannot disagree: an
* action that offers "Undo" is an undo, and one that offers "Show" is a jump.
*/
sealed interface MessageAction {
/** Take the change back — the undo of the operation that has just finished. */
data object Undo : MessageAction
/** Open the folder an operation wrote to. */
data class Show(val path: StoragePath) : MessageAction
}
/** A one-shot message for a snackbar. [id] makes repeats of the same text distinguishable. */ /** A one-shot message for a snackbar. [id] makes repeats of the same text distinguishable. */
data class UiMessage( data class UiMessage(
/** A `@StringRes` or `@PluralsRes` value; see [pluralQuantity]. */ /** A `@StringRes` or `@PluralsRes` value; see [pluralQuantity]. */
@@ -78,10 +94,25 @@ data class UiMessage(
val args: List<Any> = emptyList(), val args: List<Any> = emptyList(),
/** Set when [textRes] names a `<plurals>` resource; the UI resolves it accordingly. */ /** Set when [textRes] names a `<plurals>` resource; the UI resolves it accordingly. */
val pluralQuantity: Int? = null, val pluralQuantity: Int? = null,
/** When set, the snackbar offers a single action button carrying this label. */ /** What the snackbar's action button does; `null` for a message with no action. */
@StringRes val actionLabelRes: Int? = null, val action: MessageAction? = null,
/**
* A folder the text names, whose label only the UI can resolve: a volume root has no name of its own —
* internal storage's is `0`. When set, its title becomes the text's first argument.
*/
val destination: PickerFolder? = null,
val id: Long = System.nanoTime(), val id: Long = System.nanoTime(),
) ) {
/** The action button's label, or `null` when there is no action. */
@get:StringRes
val actionLabelRes: Int?
get() = when (action) {
MessageAction.Undo -> R.string.browser_action_undo
is MessageAction.Show -> R.string.browser_action_show
null -> null
}
}
/** /**
* A paste that hit existing names, waiting for the user to choose how to resolve it. * A paste that hit existing names, waiting for the user to choose how to resolve it.
@@ -555,7 +555,14 @@ class BrowserViewModel(
it.copy(conflict = ConflictPrompt(picker.paths, target, picker.cut, conflicts)) it.copy(conflict = ConflictPrompt(picker.paths, target, picker.cut, conflicts))
} }
} else { } else {
transfer(picker.paths, target, picker.cut, ConflictPolicy.KEEP_BOTH) transfer(
paths = picker.paths,
target = target,
cut = picker.cut,
policy = ConflictPolicy.KEEP_BOTH,
// Only a copy: a move's message is the undoable one, and its sources are gone.
announce = if (picker.cut) null else picker.crumbs.lastOrNull() ?: PickerFolder(target),
)
} }
} }
} }
@@ -1807,6 +1814,8 @@ class BrowserViewModel(
target: StoragePath, target: StoragePath,
cut: Boolean, cut: Boolean,
policy: ConflictPolicy, policy: ConflictPolicy,
/** Set when the destination was chosen by hand, so the message can name it and offer to show it. */
announce: PickerFolder? = null,
) { ) {
val source = storage.activeSource() ?: return val source = storage.activeSource() ?: return
val operations = FileOperations(source) val operations = FileOperations(source)
@@ -1838,6 +1847,8 @@ class BrowserViewModel(
_state.update { it.copy(undo = Undo.Relocate(moves)) } _state.update { it.copy(undo = Undo.Relocate(moves)) }
} }
messageUndoablePlural(R.plurals.message_moved, result.completed) messageUndoablePlural(R.plurals.message_moved, result.completed)
} else if (announce != null) {
messageCopiedTo(announce, result.completed)
} else { } else {
messagePlural(R.plurals.message_copied, result.completed) messagePlural(R.plurals.message_copied, result.completed)
} }
@@ -2224,6 +2235,20 @@ class BrowserViewModel(
// ----------------------------------------------------------------- messages // ----------------------------------------------------------------- messages
/**
* Run what the current message's action button offered.
*
* Undo is the usual one. A **Copy to…** offers Show instead, because a copy leaves its sources where
* they were — the interesting place is the one it wrote to, which may be several folders away.
*/
fun performMessageAction() {
when (val action = _state.value.message?.action) {
MessageAction.Undo -> undo()
is MessageAction.Show -> openDirectory(action.path)
null -> Unit
}
}
fun consumeMessage() { fun consumeMessage() {
_state.update { it.copy(message = null) } _state.update { it.copy(message = null) }
} }
@@ -2246,7 +2271,7 @@ class BrowserViewModel(
/** A message whose snackbar also offers to take the change back. */ /** A message whose snackbar also offers to take the change back. */
private fun messageUndoable(@StringRes textRes: Int, vararg args: Any) { private fun messageUndoable(@StringRes textRes: Int, vararg args: Any) {
postMessage( postMessage(
UiMessage(textRes, args.toList(), actionLabelRes = R.string.browser_action_undo), UiMessage(textRes, args.toList(), action = MessageAction.Undo),
) )
} }
@@ -2256,7 +2281,20 @@ class BrowserViewModel(
textRes, textRes,
listOf(count), listOf(count),
pluralQuantity = count, pluralQuantity = count,
actionLabelRes = R.string.browser_action_undo, action = MessageAction.Undo,
),
)
}
/** A copy into a folder the user picked: names it, and offers to go there. */
private fun messageCopiedTo(destination: PickerFolder, count: Int) {
postMessage(
UiMessage(
R.plurals.message_copied_to,
listOf(count),
pluralQuantity = count,
action = MessageAction.Show(destination.path),
destination = destination,
), ),
) )
} }
+8
View File
@@ -125,6 +125,8 @@
<string name="browser_action_select_all">Select all</string> <string name="browser_action_select_all">Select all</string>
<string name="browser_action_clear_selection">Clear selection</string> <string name="browser_action_clear_selection">Clear selection</string>
<string name="browser_action_undo">Undo</string> <string name="browser_action_undo">Undo</string>
<!-- The action on a "copied to X" message: go to the folder it was copied into. -->
<string name="browser_action_show">Show</string>
<!-- ===== Browser: files another app has handed over ===== --> <!-- ===== Browser: files another app has handed over ===== -->
<plurals name="incoming_save_here"> <plurals name="incoming_save_here">
@@ -238,6 +240,12 @@
<string name="message_folder_created">Folder created</string> <string name="message_folder_created">Folder created</string>
<string name="message_renamed">Renamed</string> <string name="message_renamed">Renamed</string>
<string name="message_name_in_use">That name is already in use</string> <string name="message_name_in_use">That name is already in use</string>
<!-- %1$s is the destination folder; a copy that went somewhere the user chose says where. -->
<plurals name="message_copied_to">
<item quantity="one">Copied to %1$s</item>
<item quantity="other">Copied %2$d items to %1$s</item>
</plurals>
<plurals name="message_copied"> <plurals name="message_copied">
<item quantity="one">Copied %1$d item</item> <item quantity="one">Copied %1$d item</item>
<item quantity="other">Copied %1$d items</item> <item quantity="other">Copied %1$d items</item>
@@ -353,6 +353,92 @@ class BrowserViewModelTest {
assertTrue(recordedNotifications.isEmpty()) assertTrue(recordedNotifications.isEmpty())
} }
@Test
fun `a copy says where it went and offers to show it`() {
val model = viewModel()
model.openDirectory(downloads)
model.toggleSelection(downloads.child("a.txt"))
model.startTransferTo(cut = false)
model.pickerOpen(documents)
model.confirmTransferTo()
val message = model.state.value.message
assertNotNull(message)
assertEquals(R.plurals.message_copied_to, message!!.textRes)
// The count is the only argument the view model owns; the folder's label is the screen's job.
assertEquals(listOf(1), message.args)
assertEquals(documents, message.destination?.path)
assertEquals(MessageAction.Show(documents), message.action)
assertEquals(R.string.browser_action_show, message.actionLabelRes)
}
@Test
fun `show takes the user to the folder the copy went to`() {
val model = viewModel()
model.openDirectory(downloads)
model.toggleSelection(downloads.child("a.txt"))
model.startTransferTo(cut = false)
model.pickerOpen(documents)
model.confirmTransferTo()
model.performMessageAction()
assertEquals(documents, model.state.value.path)
assertTrue(storage.contains(documents.child("a.txt")))
}
@Test
fun `a paste into the folder on screen names no destination and offers no action`() {
val model = viewModel()
model.openDirectory(root)
model.copyToClipboard(listOf(downloads.child("a.txt")), cut = false)
model.openDirectory(documents)
model.paste()
val message = model.state.value.message
assertNotNull(message)
assertEquals(R.plurals.message_copied, message!!.textRes)
assertNull(message.destination)
assertNull(message.action)
assertNull(message.actionLabelRes)
}
@Test
fun `the message's undo action is the undo`() {
val model = viewModel()
model.openDirectory(downloads)
model.toggleSelection(downloads.child("a.txt"))
model.startTransferTo(cut = true)
model.pickerOpen(documents)
model.confirmTransferTo()
assertFalse(storage.contains(downloads.child("a.txt")))
// What the snackbar's button does — the UI no longer calls `undo()` itself, so this is the path
// that has to work for Undo to work at all.
model.performMessageAction()
assertTrue(storage.contains(downloads.child("a.txt")))
}
@Test
fun `a move to a chosen folder offers undo rather than show`() {
val model = viewModel()
model.openDirectory(downloads)
model.toggleSelection(downloads.child("a.txt"))
model.startTransferTo(cut = true)
model.pickerOpen(documents)
model.confirmTransferTo()
val message = model.state.value.message
assertNotNull(message)
assertEquals(MessageAction.Undo, message!!.action)
assertEquals(R.string.browser_action_undo, message.actionLabelRes)
}
@Test @Test
fun `navigating up goes to the parent then to the roots`() { fun `navigating up goes to the parent then to the roots`() {
val model = viewModel() val model = viewModel()
-3
View File
@@ -22,7 +22,6 @@
<ID>LongParameterList:BrowserContent.kt$( state: BrowserUiState, contentPadding: PaddingValues, crumbLabel: (StoragePath) -&gt; String, onOpenEntry: (FileEntry) -&gt; Unit, onFocusEntry: (FileEntry) -&gt; Unit, onFocusRoot: (RootItem) -&gt; Unit, onToggleSelection: (StoragePath) -&gt; Unit, onOpenRoot: (StoragePath) -&gt; Unit, onNavigate: (StoragePath) -&gt; Unit, onOpenCategory: (FileCategory) -&gt; Unit, onOpenBookmark: (StoragePath) -&gt; Unit, onRemoveBookmark: (StoragePath) -&gt; Unit, onGrantAllFiles: () -&gt; Unit, onPickFolder: () -&gt; Unit, onRebuildIndex: () -&gt; Unit, /** Hoisted by the screen so the scroll position outlives a viewer taking over. */ listState: LazyListState = rememberLazyListState(), gridState: LazyGridState = rememberLazyGridState(), )</ID> <ID>LongParameterList:BrowserContent.kt$( state: BrowserUiState, contentPadding: PaddingValues, crumbLabel: (StoragePath) -&gt; String, onOpenEntry: (FileEntry) -&gt; Unit, onFocusEntry: (FileEntry) -&gt; Unit, onFocusRoot: (RootItem) -&gt; Unit, onToggleSelection: (StoragePath) -&gt; Unit, onOpenRoot: (StoragePath) -&gt; Unit, onNavigate: (StoragePath) -&gt; Unit, onOpenCategory: (FileCategory) -&gt; Unit, onOpenBookmark: (StoragePath) -&gt; Unit, onRemoveBookmark: (StoragePath) -&gt; Unit, onGrantAllFiles: () -&gt; Unit, onPickFolder: () -&gt; Unit, onRebuildIndex: () -&gt; Unit, /** Hoisted by the screen so the scroll position outlives a viewer taking over. */ listState: LazyListState = rememberLazyListState(), gridState: LazyGridState = rememberLazyGridState(), )</ID>
<ID>LongParameterList:BrowserContent.kt$( state: BrowserUiState, crumbLabel: (StoragePath) -&gt; String, wide: Boolean, listState: LazyListState, gridState: LazyGridState, onOpenEntry: (FileEntry) -&gt; Unit, onFocusEntry: (FileEntry) -&gt; Unit, onToggleSelection: (StoragePath) -&gt; Unit, onNavigate: (StoragePath) -&gt; Unit, onGrantAllFiles: () -&gt; Unit, onRebuildIndex: () -&gt; Unit, modifier: Modifier = Modifier, )</ID> <ID>LongParameterList:BrowserContent.kt$( state: BrowserUiState, crumbLabel: (StoragePath) -&gt; String, wide: Boolean, listState: LazyListState, gridState: LazyGridState, onOpenEntry: (FileEntry) -&gt; Unit, onFocusEntry: (FileEntry) -&gt; Unit, onToggleSelection: (StoragePath) -&gt; Unit, onNavigate: (StoragePath) -&gt; Unit, onGrantAllFiles: () -&gt; Unit, onRebuildIndex: () -&gt; Unit, modifier: Modifier = Modifier, )</ID>
<ID>LongParameterList:BrowserTopBar.kt$( title: String, canNavigateUp: Boolean, /** Whether this view is a folder, and so can be starred at all. */ canBookmark: Boolean, bookmarked: Boolean, query: String, searchOpen: Boolean, searchScope: SearchScope, /** Whether the scope is the user's to choose at all; see the note beside the toggle. */ scopeChoosable: Boolean, canPaste: Boolean, onUp: () -&gt; Unit, onQuery: (String) -&gt; Unit, onSearchOpen: (Boolean) -&gt; Unit, onSearchScope: () -&gt; Unit, onSort: () -&gt; Unit, onPaste: () -&gt; Unit, onToggleBookmark: () -&gt; Unit, onRefresh: () -&gt; Unit, onUsage: () -&gt; Unit, onDuplicates: () -&gt; Unit, onTrash: () -&gt; Unit, )</ID> <ID>LongParameterList:BrowserTopBar.kt$( title: String, canNavigateUp: Boolean, /** Whether this view is a folder, and so can be starred at all. */ canBookmark: Boolean, bookmarked: Boolean, query: String, searchOpen: Boolean, searchScope: SearchScope, /** Whether the scope is the user's to choose at all; see the note beside the toggle. */ scopeChoosable: Boolean, canPaste: Boolean, onUp: () -&gt; Unit, onQuery: (String) -&gt; Unit, onSearchOpen: (Boolean) -&gt; Unit, onSearchScope: () -&gt; Unit, onSort: () -&gt; Unit, onPaste: () -&gt; Unit, onToggleBookmark: () -&gt; Unit, onRefresh: () -&gt; Unit, onUsage: () -&gt; Unit, onDuplicates: () -&gt; Unit, onTrash: () -&gt; Unit, )</ID>
<ID>LongParameterList:SelectionBar.kt$( state: BrowserUiState, onCopy: () -&gt; Unit, onCut: () -&gt; Unit, onDelete: () -&gt; Unit, onDetails: () -&gt; Unit, onRename: () -&gt; Unit, onOpenWith: () -&gt; Unit, onShare: () -&gt; Unit, onCompress: () -&gt; Unit, onExtract: () -&gt; Unit, onSelectAll: () -&gt; Unit, onClear: () -&gt; Unit, )</ID>
<ID>LoopWithTooManyJumpStatements:ArchiveEngine.kt$ArchiveEngine$while</ID> <ID>LoopWithTooManyJumpStatements:ArchiveEngine.kt$ArchiveEngine$while</ID>
<ID>LoopWithTooManyJumpStatements:BatchRename.kt$BatchRename$for</ID> <ID>LoopWithTooManyJumpStatements:BatchRename.kt$BatchRename$for</ID>
<ID>LoopWithTooManyJumpStatements:DuplicateFinder.kt$DuplicateFinder$while</ID> <ID>LoopWithTooManyJumpStatements:DuplicateFinder.kt$DuplicateFinder$while</ID>
@@ -68,8 +67,6 @@
<ID>RethrowCaughtException:BrowserViewModel.kt$BrowserViewModel$throw cancellation</ID> <ID>RethrowCaughtException:BrowserViewModel.kt$BrowserViewModel$throw cancellation</ID>
<ID>ReturnCount:ArchiveEngine.kt$ArchiveEngine$private suspend fun withEntry( archive: StoragePath, name: String, sink: suspend (InputStream) -&gt; Unit, ): Boolean</ID> <ID>ReturnCount:ArchiveEngine.kt$ArchiveEngine$private suspend fun withEntry( archive: StoragePath, name: String, sink: suspend (InputStream) -&gt; Unit, ): Boolean</ID>
<ID>ReturnCount:BrowserScreen.kt$@OptIn(ExperimentalMaterial3Api::class) @Composable fun BrowserScreen( incoming: IncomingRequest? = null, onDeliver: (FileEntry) -&gt; Unit = {}, onDone: () -&gt; Unit = {}, viewModel: BrowserViewModel = viewModel(factory = BrowserViewModel.Factory), )</ID> <ID>ReturnCount:BrowserScreen.kt$@OptIn(ExperimentalMaterial3Api::class) @Composable fun BrowserScreen( incoming: IncomingRequest? = null, onDeliver: (FileEntry) -&gt; Unit = {}, onDone: () -&gt; Unit = {}, viewModel: BrowserViewModel = viewModel(factory = BrowserViewModel.Factory), )</ID>
<ID>SpreadOperator:BrowserScreen.kt$(it.textRes, *it.args.toTypedArray())</ID>
<ID>SpreadOperator:BrowserScreen.kt$(it.textRes, quantity, *it.args.toTypedArray())</ID>
<ID>TooGenericExceptionCaught:ArchiveEngine.kt$ArchiveEngine$failure: Exception</ID> <ID>TooGenericExceptionCaught:ArchiveEngine.kt$ArchiveEngine$failure: Exception</ID>
<ID>TooGenericExceptionCaught:BrowserViewModel.kt$BrowserViewModel$failure: Exception</ID> <ID>TooGenericExceptionCaught:BrowserViewModel.kt$BrowserViewModel$failure: Exception</ID>
<ID>TooGenericExceptionCaught:FileOperations.kt$FileOperations$failure: Exception</ID> <ID>TooGenericExceptionCaught:FileOperations.kt$FileOperations$failure: Exception</ID>
+10 -5
View File
@@ -2,7 +2,7 @@
Generated from the source by `tools/make-code-map.py` - do not edit by hand. Regenerate it after a change (`python3 tools/make-code-map.py`); `--check` exits non-zero when this file is stale. Generated from the source by `tools/make-code-map.py` - do not edit by hand. Regenerate it after a change (`python3 tools/make-code-map.py`); `--check` exits non-zero when this file is stale.
79 source files, 13393 lines - 35 test files, 404 tests. The behaviour index at the end lists every test name, and in this project a test name is a sentence about what the app does, so it doubles as the specification. 79 source files, 13481 lines - 35 test files, 409 tests. The behaviour index at the end lists every test name, and in this project a test name is a sentence about what the app does, so it doubles as the specification.
Start at `AGENTS.md`. What is done, and why: `docs/ROADMAP.md` and `CHANGELOG.md`. What was verified by hand rather than by a test: `docs/EDGE_CASE_TESTING.md`. Start at `AGENTS.md`. What is done, and why: `docs/ROADMAP.md` and `CHANGELOG.md`. What was verified by hand rather than by a test: `docs/EDGE_CASE_TESTING.md`.
@@ -100,10 +100,10 @@ The browser screen: view model, state, list and grid, dialogs, selection bar
| `BrowserDetailPane.kt` | 131 | The second pane of the wide layout: what is inside the folder the user picked, or a preview and the details of the file they picked. DetailPane(), FolderPreview(), ImagePreview() | - | | `BrowserDetailPane.kt` | 131 | The second pane of the wide layout: what is inside the folder the user picked, or a preview and the details of the file they picked. DetailPane(), FolderPreview(), ImagePreview() | - |
| `BrowserDialogs.kt` | 385 | The Copy to… / Move to… picker. TransferToDialog(), titleFor(), hereFor(), BatchRenameDialog(), PREVIEW_NAMES, batchRenameNote(), NameDialog(), ConfirmDialog(), ConflictDialog() | `BrowserDialogsTest` (5) | | `BrowserDialogs.kt` | 385 | The Copy to… / Move to… picker. TransferToDialog(), titleFor(), hereFor(), BatchRenameDialog(), PREVIEW_NAMES, batchRenameNote(), NameDialog(), ConfirmDialog(), ConflictDialog() | `BrowserDialogsTest` (5) |
| `BrowserRoots.kt` | 270 | A row on the root screen: a volume, with its free space when the platform reports it. RootRow(), itemCountLabel(), EmptyState(), LibrarySectionHeader(), BookmarkSectionHeader(), SectionHeader(), BookmarkRow(), LibraryRow(), Breadcrumbs() | `BrowserRootsTest` (4) | | `BrowserRoots.kt` | 270 | A row on the root screen: a volume, with its free space when the platform reports it. RootRow(), itemCountLabel(), EmptyState(), LibrarySectionHeader(), BookmarkSectionHeader(), SectionHeader(), BookmarkRow(), LibraryRow(), Breadcrumbs() | `BrowserRootsTest` (4) |
| `BrowserScreen.kt` | 603 | BrowserScreen() | - | | `BrowserScreen.kt` | 622 | The text of a snackbar message. BrowserScreen(), messageText() | - |
| `BrowserTopBar.kt` | 238 | The browser's app bar: title, up, the favourite star, and the search / sort / overflow actions. BrowserTopBar(), SEARCH_FIELD_WIDTH | `BrowserTopBarTest` (4) | | `BrowserTopBar.kt` | 238 | The browser's app bar: title, up, the favourite star, and the search / sort / overflow actions. BrowserTopBar(), SEARCH_FIELD_WIDTH | `BrowserTopBarTest` (4) |
| `BrowserUiState.kt` | 348 | One immutable description of everything the browser shows. ViewMode, SearchScope, Clipboard, PickerFolder, TransferPicker, UiMessage, ConflictPrompt, RootItem, Bookmark, DetailFocus, Undo, UndoMove, Overlay, BrowserUiState | - | | `BrowserUiState.kt` | 379 | One immutable description of everything the browser shows. ViewMode, SearchScope, Clipboard, PickerFolder, TransferPicker, MessageAction, UiMessage, ConflictPrompt, RootItem, Bookmark, DetailFocus, Undo, UndoMove, Overlay, BrowserUiState | - |
| `BrowserViewModel.kt` | 2392 | Drives the browser screen. BrowserViewModel, ArchivePreview | `BrowserViewModelTest` (97) | | `BrowserViewModel.kt` | 2430 | Drives the browser screen. BrowserViewModel, ArchivePreview | `BrowserViewModelTest` (102) |
| `DetailsSheet.kt` | 109 | Name, location, size, type and modified date for one entry. DetailsSheet(), DetailLines(), DetailLine() | - | | `DetailsSheet.kt` | 109 | Name, location, size, type and modified date for one entry. DetailsSheet(), DetailLines(), DetailLine() | - |
| `EntryIntents.kt` | 58 | Wraps an entry's path in a content:// URI another app may read. contentUriFor(), viewIntent(), shareIntent() | - | | `EntryIntents.kt` | 58 | Wraps an entry's path in a content:// URI another app may read. contentUriFor(), viewIntent(), shareIntent() | - |
| `IncomingBar.kt` | 60 | The bar shown while another app is waiting on this one. IncomingBar() | - | | `IncomingBar.kt` | 60 | The bar shown while another app is waiting on this one. IncomingBar() | - |
@@ -581,7 +581,7 @@ Every test name in the suite, by file: the fastest way to see whether something
- a library or the root list has nothing to star - a library or the root list has nothing to star
- the star gives way to the search field - the star gives way to the search field
### `ui/browser/BrowserViewModelTest.kt` (97) ### `ui/browser/BrowserViewModelTest.kt` (102)
- starts on the root list - starts on the root list
- opening a folder lists it with directories first and natural order - opening a folder lists it with directories first and natural order
@@ -596,6 +596,11 @@ Every test name in the suite, by file: the fastest way to see whether something
- the picker walks up to the volumes and back down - the picker walks up to the volumes and back down
- a conflicting destination asks before overwriting, naming that folder - a conflicting destination asks before overwriting, naming that folder
- cancelling the picker copies nothing - cancelling the picker copies nothing
- a copy says where it went and offers to show it
- show takes the user to the folder the copy went to
- a paste into the folder on screen names no destination and offers no action
- the message's undo action is the undo
- a move to a chosen folder offers undo rather than show
- navigating up goes to the parent then to the roots - navigating up goes to the parent then to the roots
- selection survives toggling and clears on navigation - selection survives toggling and clears on navigation
- a file with no preview of its own is handed to the system - a file with no preview of its own is handed to the system
+4
View File
@@ -298,6 +298,10 @@ Driven on the phone through the real UI (`adb` taps on `uiautomator` dumps), wit
- **Move** into a picked folder: the same walk into `AAMv`. `AASrc` ended up empty, `AAMv` held the - **Move** into a picked folder: the same walk into `AAMv`. `AASrc` ended up empty, `AAMv` held the
file, the snackbar said *Moved 1 item*, the listing said *This folder is empty*, and the selection bar file, the snackbar said *Moved 1 item*, the listing said *This folder is empty*, and the selection bar
was gone. was gone.
- **The message and its Show button.** After a copy into `AADst` the snackbar read *Copied to AADst* with
**Show**, and tapping Show opened `AADst` with the copied file in it. Repeated into a volume root — the
case where the path has no name of its own — it read *Copied to Internal storage*, which is the label
rule doing its job rather than `0` leaking into a sentence.
- **The trail and the volume row.** Both first read the platform's own name for internal storage — - **The trail and the volume row.** Both first read the platform's own name for internal storage —
`0 / AADst` and a row labelled `0` — which is correct as a path and useless as a label. Fixed by `0 / AADst` and a row labelled `0` — which is correct as a path and useless as a label. Fixed by
giving the picker the same label rule as the roots screen; the second pass reads *Internal storage / giving the picker the same label rule as the roots screen; the second pass reads *Internal storage /
+8 -4
View File
@@ -64,8 +64,10 @@ would no longer have.
## The queue after 1.0 ## The queue after 1.0
Ordered by what it protects, not by what is fun. Nothing here is scheduled; the first two are what a Ordered by what it protects, not by what is fun. **Four of the five items are done** — thumbnails, the
release should not go out without. scaling numbers (which turned into progressive listing), Copy to… / Move to…, and the CI benchmark. What is
left is the baseline profile's *measurement*, which needs a physical API 33+ device and cannot be finished
on this machine; everything else in the list says what it produced.
1. **Baseline profile** — **generated and committed**: `app/src/main/baseline-prof.txt` is bundled in the 1. **Baseline profile** — **generated and committed**: `app/src/main/baseline-prof.txt` is bundled in the
release APK, written by the `:baselineprofile` module. What remains is its *measurement*: collection release APK, written by the `:baselineprofile` module. What remains is its *measurement*: collection
@@ -90,8 +92,10 @@ release should not go out without.
two taps; a move clears it. Conflicts ask the same question as a paste, naming the picked folder. Seven two taps; a move clears it. Conflicts ask the same question as a paste, naming the picked folder. Seven
tests cover the verbs, the picker's walk, folders-only listing, the conflict target, cancellation, and tests cover the verbs, the picker's walk, folders-only listing, the conflict target, cancellation, and
the selection rule. Driven on the phone for both verbs (see `EDGE_CASE_TESTING.md`), which is what caught the selection rule. Driven on the phone for both verbs (see `EDGE_CASE_TESTING.md`), which is what caught
the picker naming internal storage by its raw name `0`. **Not done**: the "copied to X — Show" message the picker naming internal storage by its raw name `0`. A copy into a picked folder says **"Copied to
with a jump to the destination; the copy reports itself the way a paste does. <folder>"** and offers **Show**, which opens that folder — the destination's label resolved the way
breadcrumbs are, so a volume root reads *Internal storage* rather than `0`. A move keeps the undoable
message instead, because its sources are gone.
5. **A CI run of the benchmarks on an emulator** — **done**: `.github/workflows/benchmark.yml` runs the four 5. **A CI run of the benchmarks on an emulator** — **done**: `.github/workflows/benchmark.yml` runs the four
benchmarks on API 35 (`reactivecircus/android-emulator-runner`, `google_apis`, x86_64, SwiftShader) on benchmarks on API 35 (`reactivecircus/android-emulator-runner`, `google_apis`, x86_64, SwiftShader) on
demand and every Monday, installs the app and resets its compilation before instrumenting it, pulls the demand and every Monday, installs the app and resets its compilation before instrumenting it, pulls the