diff --git a/CHANGELOG.md b/CHANGELOG.md index 32fc821..de5ea4f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -55,11 +55,12 @@ upload needs a **higher `versionCode`** than every previous upload. description, and the deferred and interrupted impact. Every `OperationKind` is local — `COPY`, `MOVE`, `DELETE`, `COMPRESS`, `EXTRACT` — and the app has no `INTERNET` permission, so network processing, backup/restore and media transcoding are explicitly not ticked. It also records that - the required video is **not** the All files access one: [FGS_VIDEO](docs/FGS_VIDEO.md) holds an - **18.0 s** captioned take of the local copy — selection, Copy, Paste here, the notification with - its progress bar, and the completed folder — cut from a 35.2 s raw recording to remove the two - waits, with every filename synthetic. The traps are written down too: no Up after Copy, and open - the shade only once the notification exists. + the required video is **not** the All files access one: [FGS_VIDEO](docs/FGS_VIDEO.md) holds a + **16.1 s** captioned take of the local copy — selection, **Copy to…**, the destination picker, + **Copy here**, the notification with its progress bar, and the completed folder — cut from a + 41.0 s raw recording to remove the two waits, with every filename synthetic. The traps are written + down too: the picker recentres at every level, and open the shade only once the notification + exists and let it settle. - **A dot marks what an operation just produced.** Copy something, extract an archive, rename a file or make a folder, and the rows it created carry a small dot until you open that folder again — so "what did that 4 GB @@ -277,6 +278,20 @@ upload needs a **higher `versionCode`** than every previous upload. ### Changed +- **Copy and Cut are now Copy to… and Cut to…, and the clipboard is gone.** The selection bar's + first two actions open the destination picker directly; choosing a folder starts the copy — or the + cut — without leaving the folder you are in. The separate clipboard state, **Paste here**, and the + overflow's duplicate **Copy to… / Move to…** entries are removed, and the picker's cut verb reads + **Cut to… / Cut here**. A copy still keeps the selection for a second destination; a cut still + clears it because those rows leave; conflicts still ask, naming the picked folder. Five tests that + exercised the clipboard were removed, and the mark, restart and undo tests now go through the + picker; the foreground-service video was re-recorded against the new bar. Driven on the Xiaomi + phone: Copy to… copied 120 × 16 MB and Cut to… moved one file, and the top-bar overflow has no + Paste. + + Why: the clipboard made a copy a two-step detour through another folder, which is exactly what M16 + built the picker to avoid. Keeping both would have left two meanings for “Copy” in one bar. + - **File thumbnails are bigger.** The leading visual in a list row went from 28 dp to 48 dp and the one in a grid cell from 40 dp to 96 dp, with the grid's minimum column width raised from 108 dp to 144 dp so the larger tile and the two-line name under it still fit. Neither size was diff --git a/README.md b/README.md index a559900..53ed80b 100644 --- a/README.md +++ b/README.md @@ -15,7 +15,7 @@ no advertising or tracking SDKs. **M20 — launcher shortcuts: done.** Long-press the app icon for **Images**, **Recent** and **Trash**. Since the last release build: **M19** gave the trash its 24-hour window, **Empty trash** and a sweep on open; **M18** let compression store files instead of squeezing them; **M17** put a dot on the rows an -operation produced; and **M16** added **Copy to… / Move to…** with a destination picker that does not +operation produced; and **M16** added **Copy to… / Cut to…** with a destination picker that does not take you out of the folder you are in. Before them: **M15 — favourites** (a star puts a folder on the home screen), **M14 — batch rename** (one pattern, a start number, and a series that carries on from the folder's own), **M13 — video thumbnails** (a frame of the video itself rather than a film icon), diff --git a/app/src/main/java/de/softwareapp_hb/filemanager/core/TransferPlanner.kt b/app/src/main/java/de/softwareapp_hb/filemanager/core/TransferPlanner.kt index 6ac84b9..12f909c 100644 --- a/app/src/main/java/de/softwareapp_hb/filemanager/core/TransferPlanner.kt +++ b/app/src/main/java/de/softwareapp_hb/filemanager/core/TransferPlanner.kt @@ -31,7 +31,7 @@ data class TransferItem( ) /** - * Turns "copy/paste these items into that folder" into a per-item decision, before a + * Turns "copy or move these items into that folder" into a per-item decision, before a * single byte is written. * * Doing this as pure logic keeps the awkward cases — a name colliding with another diff --git a/app/src/main/java/de/softwareapp_hb/filemanager/ui/browser/BrowserDialogs.kt b/app/src/main/java/de/softwareapp_hb/filemanager/ui/browser/BrowserDialogs.kt index 59b5850..c085a17 100644 --- a/app/src/main/java/de/softwareapp_hb/filemanager/ui/browser/BrowserDialogs.kt +++ b/app/src/main/java/de/softwareapp_hb/filemanager/ui/browser/BrowserDialogs.kt @@ -103,14 +103,14 @@ fun CompressDialog( } /** - * The **Copy to… / Move to…** picker. + * The **Copy to… / Cut to…** picker. * * A folder browser in a dialog: the volumes first, then one level of folders at a time, with the trail * above them. Files are never offered — a file cannot be a destination — and the confirm button stays * disabled on the volume list, because "copy into the volume" is a folder, not the list of volumes. * - * The picker only chooses; the copy itself goes through the same plan and conflict question as a paste, - * with the picked folder as the target (see `BrowserViewModel.confirmTransferTo`). + * The picker only chooses; the copy itself goes through the same plan and conflict question as any + * other transfer, with the picked folder as the target (see `BrowserViewModel.confirmTransferTo`). */ @Composable fun TransferToDialog( @@ -188,11 +188,11 @@ fun TransferToDialog( /** The dialog's title for the verb. */ private fun titleFor(cut: Boolean): Int = - if (cut) R.string.transfer_picker_move_title else R.string.transfer_picker_copy_title + if (cut) R.string.transfer_picker_cut_title else R.string.transfer_picker_copy_title /** The confirm button's label for the verb. */ private fun hereFor(cut: Boolean): Int = - if (cut) R.string.transfer_picker_move_here else R.string.transfer_picker_copy_here + if (cut) R.string.transfer_picker_cut_here else R.string.transfer_picker_copy_here /** * Renaming several files at once, with the numbering worked out before anything moves. @@ -400,7 +400,7 @@ fun ConfirmDialog( } /** - * The prompt shown when a paste would overwrite something. + * The prompt shown when a transfer would overwrite something. * * The engine refuses to guess ([ConflictPolicy.ASK] is surfaced rather than resolved), so * the choice is offered here and then applied to the whole batch. diff --git a/app/src/main/java/de/softwareapp_hb/filemanager/ui/browser/BrowserScreen.kt b/app/src/main/java/de/softwareapp_hb/filemanager/ui/browser/BrowserScreen.kt index ab9d6ab..0b70f50 100644 --- a/app/src/main/java/de/softwareapp_hb/filemanager/ui/browser/BrowserScreen.kt +++ b/app/src/main/java/de/softwareapp_hb/filemanager/ui/browser/BrowserScreen.kt @@ -397,7 +397,6 @@ fun BrowserScreen( searchScope = state.searchScope, // The choice only exists when there is a folder to make it about. scopeChoosable = state.path != null, - canPaste = state.clipboard?.isEmpty == false, onUp = viewModel::navigateUp, onQuery = viewModel::setQuery, onSearchOpen = { open -> @@ -422,7 +421,6 @@ fun BrowserScreen( ) }, onSort = { showSort = true }, - onPaste = viewModel::paste, onToggleBookmark = viewModel::toggleBookmark, onRefresh = viewModel::refresh, onUsage = viewModel::openUsage, @@ -446,10 +444,8 @@ fun BrowserScreen( if (state.isSelecting) { SelectionBar( state = state, - onCopy = { viewModel.copyToClipboard(state.selection.toList(), cut = false) }, - onCut = { viewModel.copyToClipboard(state.selection.toList(), cut = true) }, onCopyTo = { viewModel.startTransferTo(cut = false) }, - onMoveTo = { viewModel.startTransferTo(cut = true) }, + onCutTo = { viewModel.startTransferTo(cut = true) }, onDelete = { confirmDelete = state.selection.toList() }, onDetails = { detailsFor = state.selectedEntries.firstOrNull() }, onRename = { diff --git a/app/src/main/java/de/softwareapp_hb/filemanager/ui/browser/BrowserTopBar.kt b/app/src/main/java/de/softwareapp_hb/filemanager/ui/browser/BrowserTopBar.kt index 9550dbd..564f842 100644 --- a/app/src/main/java/de/softwareapp_hb/filemanager/ui/browser/BrowserTopBar.kt +++ b/app/src/main/java/de/softwareapp_hb/filemanager/ui/browser/BrowserTopBar.kt @@ -11,7 +11,6 @@ import androidx.compose.material.icons.filled.FileCopy import androidx.compose.material.icons.filled.MoreVert import androidx.compose.material.icons.filled.PieChart import androidx.compose.material.icons.filled.Public -import androidx.compose.material.icons.filled.ContentPaste import androidx.compose.material.icons.filled.Refresh import androidx.compose.material.icons.filled.Search import androidx.compose.material.icons.filled.Sort @@ -42,6 +41,7 @@ import androidx.compose.ui.unit.dp import de.softwareapp_hb.filemanager.R /** The browser's app bar: title, up, the favourite star, and the search / sort / overflow actions. */ +@Suppress("LongParameterList") @OptIn(ExperimentalMaterial3Api::class) @Composable fun BrowserTopBar( @@ -55,13 +55,11 @@ fun BrowserTopBar( searchScope: SearchScope, /** Whether the scope is the user's to choose at all; see the note beside the toggle. */ scopeChoosable: Boolean, - canPaste: Boolean, onUp: () -> Unit, onQuery: (String) -> Unit, onSearchOpen: (Boolean) -> Unit, onSearchScope: () -> Unit, onSort: () -> Unit, - onPaste: () -> Unit, onToggleBookmark: () -> Unit, onRefresh: () -> Unit, onUsage: () -> Unit, @@ -101,58 +99,15 @@ fun BrowserTopBar( }, actions = { if (searchOpen) { - OutlinedTextField( - value = query, - onValueChange = onQuery, - // The hint says what the search will actually do, so the scope is legible - // without decoding the icon beside it. - placeholder = { - Text( - stringResource( - if (searchScope == SearchScope.EVERYWHERE) { - R.string.search_scope_everywhere - } else { - R.string.browser_search_hint - }, - ), - ) - }, - singleLine = true, - modifier = Modifier - .width(SEARCH_FIELD_WIDTH) - .focusRequester(searchField), + SearchFieldActions( + query = query, + searchScope = searchScope, + scopeChoosable = scopeChoosable, + searchField = searchField, + onQuery = onQuery, + onSearchScope = onSearchScope, + onSearchOpen = onSearchOpen, ) - // Which folder tree a search answers for is a property of the search, not of - // the folder being browsed, so it lives with the field rather than in the - // overflow menu — and while the field is open it is one tap from either - // answer to the other. It is absent when there is no folder to choose about - // (the volume list, or a library, neither of which is one): a control whose - // two settings do the same thing is worse than no control at all, because it - // implies a choice the user does not have. - if (scopeChoosable) { - IconButton(onClick = onSearchScope) { - Icon( - imageVector = if (searchScope == SearchScope.EVERYWHERE) { - Icons.Filled.Public - } else { - Icons.Filled.Folder - }, - contentDescription = stringResource( - if (searchScope == SearchScope.EVERYWHERE) { - R.string.search_scope_folder - } else { - R.string.search_scope_everywhere - }, - ), - ) - } - } - IconButton(onClick = { onSearchOpen(false); onQuery("") }) { - Icon( - imageVector = Icons.Filled.Close, - contentDescription = stringResource(R.string.browser_search_clear), - ) - } } else { if (canBookmark) { IconButton(onClick = onToggleBookmark) { @@ -194,41 +149,119 @@ fun BrowserTopBar( contentDescription = stringResource(R.string.browser_action_more), ) } - DropdownMenu(expanded = menuOpen, onDismissRequest = { menuOpen = false }) { - if (canPaste) { - DropdownMenuItem( - text = { Text(stringResource(R.string.browser_action_paste)) }, - leadingIcon = { Icon(Icons.Filled.ContentPaste, contentDescription = null) }, - onClick = { menuOpen = false; onPaste() }, - ) - } - DropdownMenuItem( - text = { Text(stringResource(R.string.browser_action_duplicates)) }, - leadingIcon = { Icon(Icons.Filled.FileCopy, contentDescription = null) }, - onClick = { menuOpen = false; onDuplicates() }, - ) - DropdownMenuItem( - text = { Text(stringResource(R.string.browser_action_disk_usage)) }, - leadingIcon = { Icon(Icons.Filled.PieChart, contentDescription = null) }, - onClick = { menuOpen = false; onUsage() }, - ) - DropdownMenuItem( - text = { Text(stringResource(R.string.browser_action_trash)) }, - leadingIcon = { Icon(Icons.Filled.Delete, contentDescription = null) }, - onClick = { menuOpen = false; onTrash() }, - ) - DropdownMenuItem( - text = { Text(stringResource(R.string.browser_action_refresh)) }, - leadingIcon = { Icon(Icons.Filled.Refresh, contentDescription = null) }, - onClick = { menuOpen = false; onRefresh() }, - ) - } + BrowserOverflowMenu( + expanded = menuOpen, + onDismiss = { menuOpen = false }, + onDuplicates = onDuplicates, + onUsage = onUsage, + onTrash = onTrash, + onRefresh = onRefresh, + ) } } }, ) } +/** The app bar while the search field is open: the field, its scope toggle, and the close action. */ +@Composable +private fun SearchFieldActions( + query: String, + searchScope: SearchScope, + scopeChoosable: Boolean, + searchField: FocusRequester, + onQuery: (String) -> Unit, + onSearchScope: () -> Unit, + onSearchOpen: (Boolean) -> Unit, +) { + OutlinedTextField( + value = query, + onValueChange = onQuery, + // The hint says what the search will actually do, so the scope is legible + // without decoding the icon beside it. + placeholder = { + Text( + stringResource( + if (searchScope == SearchScope.EVERYWHERE) { + R.string.search_scope_everywhere + } else { + R.string.browser_search_hint + }, + ), + ) + }, + singleLine = true, + modifier = Modifier + .width(SEARCH_FIELD_WIDTH) + .focusRequester(searchField), + ) + // Which folder tree a search answers for is a property of the search, not of + // the folder being browsed, so it lives with the field rather than in the + // overflow menu — and while the field is open it is one tap from either + // answer to the other. It is absent when there is no folder to choose about + // (the volume list, or a library, neither of which is one): a control whose + // two settings do the same thing is worse than no control at all, because it + // implies a choice the user does not have. + if (scopeChoosable) { + IconButton(onClick = onSearchScope) { + Icon( + imageVector = if (searchScope == SearchScope.EVERYWHERE) { + Icons.Filled.Public + } else { + Icons.Filled.Folder + }, + contentDescription = stringResource( + if (searchScope == SearchScope.EVERYWHERE) { + R.string.search_scope_folder + } else { + R.string.search_scope_everywhere + }, + ), + ) + } + } + IconButton(onClick = { onSearchOpen(false); onQuery("") }) { + Icon( + imageVector = Icons.Filled.Close, + contentDescription = stringResource(R.string.browser_search_clear), + ) + } +} + +/** The app bar's overflow: the four actions that do not deserve a permanent icon. */ +@Composable +private fun BrowserOverflowMenu( + expanded: Boolean, + onDismiss: () -> Unit, + onDuplicates: () -> Unit, + onUsage: () -> Unit, + onTrash: () -> Unit, + onRefresh: () -> Unit, +) { + DropdownMenu(expanded = expanded, onDismissRequest = onDismiss) { + DropdownMenuItem( + text = { Text(stringResource(R.string.browser_action_duplicates)) }, + leadingIcon = { Icon(Icons.Filled.FileCopy, contentDescription = null) }, + onClick = { onDismiss(); onDuplicates() }, + ) + DropdownMenuItem( + text = { Text(stringResource(R.string.browser_action_disk_usage)) }, + leadingIcon = { Icon(Icons.Filled.PieChart, contentDescription = null) }, + onClick = { onDismiss(); onUsage() }, + ) + DropdownMenuItem( + text = { Text(stringResource(R.string.browser_action_trash)) }, + leadingIcon = { Icon(Icons.Filled.Delete, contentDescription = null) }, + onClick = { onDismiss(); onTrash() }, + ) + DropdownMenuItem( + text = { Text(stringResource(R.string.browser_action_refresh)) }, + leadingIcon = { Icon(Icons.Filled.Refresh, contentDescription = null) }, + onClick = { onDismiss(); onRefresh() }, + ) + } +} + /** * How wide the search field may be. * diff --git a/app/src/main/java/de/softwareapp_hb/filemanager/ui/browser/BrowserUiState.kt b/app/src/main/java/de/softwareapp_hb/filemanager/ui/browser/BrowserUiState.kt index cb92f07..a891246 100644 --- a/app/src/main/java/de/softwareapp_hb/filemanager/ui/browser/BrowserUiState.kt +++ b/app/src/main/java/de/softwareapp_hb/filemanager/ui/browser/BrowserUiState.kt @@ -32,11 +32,6 @@ enum class SearchScope { EVERYWHERE, } -/** What the user has cut or copied, waiting to be pasted somewhere. */ -data class Clipboard(val paths: List, val cut: Boolean) { - val isEmpty: Boolean get() = paths.isEmpty() -} - /** A compress waiting for its two answers: the archive's name, and whether to compress it. */ data class CompressRequest(val paths: List, val name: String) @@ -58,11 +53,10 @@ data class PickerFolder( } /** - * The destination picker behind **Copy to…** and **Move to…**. + * The destination picker behind **Copy to…** and **Cut to…**. * - * The operation itself is [Clipboard] with an explicit target instead of "the folder I am in", so this - * holds what the picker needs to draw and nothing about the copy: [paths] is what was selected when the - * picker opened, [cut] says which verb the user chose. + * It holds what the picker needs to draw and nothing about the operation: [paths] is what was + * selected when the picker opened, [cut] says whether the chosen verb is Cut or Copy. */ data class TransferPicker( val paths: List, @@ -118,7 +112,7 @@ data class UiMessage( } /** - * A paste that hit existing names, waiting for the user to choose how to resolve it. + * A transfer that hit existing names, waiting for the user to choose how to resolve it. * * The engine deliberately does not answer conflicts ([ConflictPolicy.ASK] is surfaced * rather than guessed), so this is where that decision lives. @@ -173,7 +167,7 @@ data class DetailFocus( */ sealed interface Undo { /** - * Move each item back where it came from. Covers a rename and a pasted cut alike, + * Move each item back where it came from. Covers a rename and a cut alike, * because both are the same operation seen from different angles. */ data class Relocate(val moves: List) : Undo @@ -325,7 +319,6 @@ data class BrowserUiState( val selection: Set = emptySet(), /** The entry the wide-screen detail pane is showing; `null` until the user picks one. */ val focus: DetailFocus? = null, - val clipboard: Clipboard? = null, /** * The rows an operation produced in this folder since it was last read, drawn as a dot. * @@ -333,7 +326,7 @@ data class BrowserUiState( * cleared by opening the folder again and forgotten when the process ends. */ val changed: Set = emptySet(), - /** The **Copy to… / Move to…** destination picker, when one is open. */ + /** The **Copy to… / Cut to…** destination picker, when one is open. */ val transferPicker: TransferPicker? = null, /** A compress waiting for its name and its compression choice. */ val compressRequest: CompressRequest? = null, diff --git a/app/src/main/java/de/softwareapp_hb/filemanager/ui/browser/BrowserViewModel.kt b/app/src/main/java/de/softwareapp_hb/filemanager/ui/browser/BrowserViewModel.kt index 05f7efc..b9bb09a 100644 --- a/app/src/main/java/de/softwareapp_hb/filemanager/ui/browser/BrowserViewModel.kt +++ b/app/src/main/java/de/softwareapp_hb/filemanager/ui/browser/BrowserViewModel.kt @@ -139,7 +139,7 @@ class BrowserViewModel( /** * What each folder gained since it was last read, waiting to be shown as a dot. * - * In memory and per folder, for the same reason the clipboard is: the mark means "new since you last + * In memory and per folder, for the same reason the selection is: the mark means "new since you last * looked here", so it is spent by the read that shows it, and a restart starts clean. Nothing here * needs a timestamp, a timer or a background job — a row is new because an operation said so. */ @@ -515,12 +515,12 @@ class BrowserViewModel( _state.update { state -> if (state.path == path) state.copy(freeBytes = free) else state } } - // ------------------------------------------------------- copy to… / move to… + // ------------------------------------------------------- copy to… / cut to… /** * Opens the destination picker for the current selection. * - * The same two verbs [paste] offers, except that the destination is chosen here instead of being + * The two verbs the selection bar offers, with the destination chosen here instead of being * whatever folder happens to be on screen — which is the point: copying into another folder no * longer means leaving the one you are in, and because the selection stays, a second destination is * two taps. @@ -552,9 +552,9 @@ class BrowserViewModel( /** * Copies — or moves — the selection into the folder the picker is showing. * - * A name conflict asks exactly what a paste asks, and the prompt carries the picked folder, so the - * answer applies to the right one. A copy leaves the selection in place, which is what makes a - * second destination two taps; a move clears it, because those rows are about to go. + * A name conflict asks before overwriting, and the prompt carries the picked folder, so the answer + * applies to the right one. A copy leaves the selection in place, which is what makes a second + * destination two taps; a move clears it, because those rows are about to go. */ fun confirmTransferTo() { val picker = _state.value.transferPicker ?: return @@ -794,10 +794,9 @@ class BrowserViewModel( * * Deliberately narrow. A folder is worth coming back to — losing your place deep in a * tree is the most annoying thing an Android file manager can do — and a sort order or - * view mode that resets on every process death is just noise. Three things are left + * view mode that resets on every process death is just noise. Two things are left * out on purpose: the **search query**, because restoring one would hide the folder's - * contents on launch with no visible cause; the **selection**, which is transient; and - * the **clipboard**, because a paste that survived a reboot would be a nasty surprise. + * contents on launch with no visible cause; and the **selection**, which is transient. */ private fun restoredViewState(): BrowserUiState { val savedSortKey = savedState.get(KEY_SORT_KEY) @@ -1925,40 +1924,6 @@ class BrowserViewModel( _state.update { it.copy(scratch = null) } } - // ---------------------------------------------------------------- clipboard - - fun copyToClipboard(paths: List, cut: Boolean) { - _state.update { it.copy(clipboard = Clipboard(paths, cut), selection = emptySet()) } - } - - /** - * Paste the clipboard into the current directory. - * - * A clash is not resolved here: the plan is checked first and, if anything would be - * overwritten, the user is asked through [BrowserUiState.conflict]. - */ - fun paste() { - val clipboard = _state.value.clipboard ?: return - val target = _state.value.path ?: return - val source = storage.activeSource() ?: return - - viewModelScope.launch { - val conflicts = runCatching { - FileOperations(source) - .planTransfer(clipboard.paths, target, ConflictPolicy.ASK) - .count { it.action == TransferAction.ASK } - }.getOrDefault(0) - - if (conflicts > 0) { - _state.update { - it.copy(conflict = ConflictPrompt(clipboard.paths, target, clipboard.cut, conflicts)) - } - } else { - transfer(clipboard.paths, target, clipboard.cut, ConflictPolicy.KEEP_BOTH) - } - } - } - fun resolveConflict(policy: ConflictPolicy) { val prompt = _state.value.conflict ?: return _state.update { it.copy(conflict = null) } @@ -2000,7 +1965,7 @@ class BrowserViewModel( notifier.stop() } - _state.update { it.copy(loading = false, clipboard = if (cut) null else it.clipboard) } + _state.update { it.copy(loading = false) } if (result.succeeded) { val landedNow = targetNames(source, target) markChanged(target, (landedNow - landedBefore).map { target.child(it) }) @@ -2048,7 +2013,7 @@ class BrowserViewModel( * Save the shared files into [folder] — wherever the user navigated to. * * Names come from the sending app where it offers one, and clashes go through the same - * [NameAllocator] a paste does, so sharing the same file twice cannot overwrite the + * [NameAllocator] a copy does, so sharing the same file twice cannot overwrite the * first copy. */ fun receiveInto(folder: StoragePath) { diff --git a/app/src/main/java/de/softwareapp_hb/filemanager/ui/browser/SelectionBar.kt b/app/src/main/java/de/softwareapp_hb/filemanager/ui/browser/SelectionBar.kt index 57810f7..b6ff990 100644 --- a/app/src/main/java/de/softwareapp_hb/filemanager/ui/browser/SelectionBar.kt +++ b/app/src/main/java/de/softwareapp_hb/filemanager/ui/browser/SelectionBar.kt @@ -1,13 +1,6 @@ package de.softwareapp_hb.filemanager.ui.browser import androidx.compose.foundation.horizontalScroll -import androidx.compose.foundation.layout.Box -import androidx.compose.material3.DropdownMenu -import androidx.compose.material3.DropdownMenuItem -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row @@ -23,7 +16,6 @@ import androidx.compose.material.icons.filled.ContentCut import androidx.compose.material.icons.filled.Delete import androidx.compose.material.icons.filled.Edit import androidx.compose.material.icons.filled.Info -import androidx.compose.material.icons.filled.MoreVert import androidx.compose.material.icons.filled.OpenInNew import androidx.compose.material.icons.filled.SelectAll import androidx.compose.material.icons.filled.Share @@ -86,10 +78,8 @@ fun StorageAccessPrompt( @Suppress("LongParameterList") fun SelectionBar( state: BrowserUiState, - onCopy: () -> Unit, - onCut: () -> Unit, onCopyTo: () -> Unit, - onMoveTo: () -> Unit, + onCutTo: () -> Unit, onDelete: () -> Unit, onDetails: () -> Unit, onRename: () -> Unit, @@ -127,43 +117,17 @@ fun SelectionBar( .padding(start = 8.dp), verticalAlignment = Alignment.CenterVertically, ) { - IconButton(onClick = onCopy) { - Icon(Icons.Filled.ContentCopy, contentDescription = stringResource(R.string.browser_action_copy)) + IconButton(onClick = onCopyTo) { + Icon( + Icons.Filled.ContentCopy, + contentDescription = stringResource(R.string.browser_action_copy_to), + ) } - IconButton(onClick = onCut) { - Icon(Icons.Filled.ContentCut, contentDescription = stringResource(R.string.browser_action_cut)) - } - /* - * Copy to… and Move to… are the same two verbs with a destination picker attached, so they sit - * behind one overflow button rather than adding two more icons to a bar that already scrolls. - */ - var destinationMenu by remember { mutableStateOf(false) } - Box { - IconButton(onClick = { destinationMenu = true }) { - Icon( - Icons.Filled.MoreVert, - contentDescription = stringResource(R.string.browser_action_more), - ) - } - DropdownMenu( - expanded = destinationMenu, - onDismissRequest = { destinationMenu = false }, - ) { - DropdownMenuItem( - text = { Text(stringResource(R.string.browser_action_copy_to)) }, - onClick = { - destinationMenu = false - onCopyTo() - }, - ) - DropdownMenuItem( - text = { Text(stringResource(R.string.browser_action_move_to)) }, - onClick = { - destinationMenu = false - onMoveTo() - }, - ) - } + IconButton(onClick = onCutTo) { + Icon( + Icons.Filled.ContentCut, + contentDescription = stringResource(R.string.browser_action_cut_to), + ) } /* * Rename is offered for any selection: one file is renamed by name and several by diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 1b1cc64..44b5add 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -105,9 +105,6 @@ New folder Sort Details - Copy - Move - Paste here Compress Archive name @@ -115,13 +112,13 @@ Off stores the files as they are: faster to build, and ready to copy off the phone. Compress Cancel - + Copy to… - Move to… + Cut to… Copy to… - Move to… + Cut to… Copy here - Move here + Cut here Up Cancel No folders here diff --git a/app/src/test/java/de/softwareapp_hb/filemanager/ui/browser/BrowserTopBarTest.kt b/app/src/test/java/de/softwareapp_hb/filemanager/ui/browser/BrowserTopBarTest.kt index aa843ab..8a42d0c 100644 --- a/app/src/test/java/de/softwareapp_hb/filemanager/ui/browser/BrowserTopBarTest.kt +++ b/app/src/test/java/de/softwareapp_hb/filemanager/ui/browser/BrowserTopBarTest.kt @@ -43,13 +43,11 @@ class BrowserTopBarTest { searchOpen = searchOpen, searchScope = SearchScope.FOLDER, scopeChoosable = true, - canPaste = false, onUp = {}, onQuery = {}, onSearchOpen = {}, onSearchScope = {}, onSort = {}, - onPaste = {}, onToggleBookmark = {}, onRefresh = {}, onUsage = {}, diff --git a/app/src/test/java/de/softwareapp_hb/filemanager/ui/browser/BrowserViewModelTest.kt b/app/src/test/java/de/softwareapp_hb/filemanager/ui/browser/BrowserViewModelTest.kt index d02c783..859ec17 100644 --- a/app/src/test/java/de/softwareapp_hb/filemanager/ui/browser/BrowserViewModelTest.kt +++ b/app/src/test/java/de/softwareapp_hb/filemanager/ui/browser/BrowserViewModelTest.kt @@ -418,23 +418,6 @@ class BrowserViewModelTest { 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() @@ -469,17 +452,6 @@ class BrowserViewModelTest { assertEquals(R.string.browser_action_undo, message.actionLabelRes) } - @Test - fun `a copy into the folder on screen marks the copy`() { - val model = viewModel() - model.openDirectory(documents) - model.copyToClipboard(listOf(downloads.child("a.txt")), cut = false) - - model.paste() - - assertEquals(setOf(documents.child("a.txt")), model.state.value.changed) - } - @Test fun `a copy into another folder marks it there, not here`() { val model = viewModel() @@ -500,9 +472,13 @@ class BrowserViewModelTest { @Test fun `reading the folder again spends its marks`() { val model = viewModel() + model.openDirectory(downloads) + model.toggleSelection(downloads.child("a.txt")) + model.startTransferTo(cut = false) + model.pickerOpen(documents) + model.confirmTransferTo() + model.openDirectory(documents) - model.copyToClipboard(listOf(downloads.child("a.txt")), cut = false) - model.paste() assertTrue(model.state.value.changed.isNotEmpty()) model.openDirectory(root) @@ -1596,57 +1572,6 @@ class BrowserViewModelTest { assertTrue(recordedNotifications.isEmpty()) } - @Test - fun `copy then paste duplicates into the current folder`() { - val model = viewModel() - model.openDirectory(root) - model.copyToClipboard(listOf(downloads.child("a.txt")), cut = false) - assertNotNull(model.state.value.clipboard) - - model.openDirectory(documents) - model.paste() - - assertTrue(storage.contains(documents.child("a.txt"))) - assertTrue(storage.contains(downloads.child("a.txt"))) - assertEquals(listOf(OperationKind.COPY), recordedNotifications) - assertNotNull(model.state.value.clipboard) - } - - @Test - fun `cut then paste moves and clears the clipboard`() { - val model = viewModel() - model.openDirectory(root) - model.copyToClipboard(listOf(downloads.child("a.txt")), cut = true) - - model.openDirectory(documents) - model.paste() - - assertTrue(storage.contains(documents.child("a.txt"))) - assertFalse(storage.contains(downloads.child("a.txt"))) - assertEquals(listOf(OperationKind.MOVE), recordedNotifications) - assertNull(model.state.value.clipboard) - } - - @Test - fun `a pasted name that already exists asks before overwriting`() { - val model = viewModel() - storage.addFile(documents.child("a.txt"), "already here") - - model.openDirectory(root) - model.copyToClipboard(listOf(downloads.child("a.txt")), cut = false) - model.openDirectory(documents) - model.paste() - - val prompt = model.state.value.conflict - assertNotNull(prompt) - assertEquals(1, prompt!!.conflicts) - assertEquals("already here", storage.contentOf(documents.child("a.txt"))) - - model.resolveConflict(ConflictPolicy.REPLACE) - assertEquals("first", storage.contentOf(documents.child("a.txt"))) - assertNull(model.state.value.conflict) - } - @Test fun `without any grant the browser asks for a folder`() { location.setSource(null) @@ -1792,18 +1717,16 @@ class BrowserViewModelTest { } @Test - fun `a selection and a clipboard do not survive a restart`() { + fun `a selection does not survive a restart`() { val saved = SavedStateHandle() viewModel(savedState = saved).apply { openDirectory(downloads) toggleSelection(downloads.child("a.txt")) - copyToClipboard(listOf(downloads.child("a.txt")), cut = false) } val relaunched = viewModel(savedState = saved) assertFalse(relaunched.state.value.isSelecting) - assertNull(relaunched.state.value.clipboard) } @Test @@ -2010,12 +1933,13 @@ class BrowserViewModelTest { } @Test - fun `undoing a cut-paste moves the item back`() { + fun `undoing a cut to a chosen folder moves the item back`() { val model = viewModel() model.openDirectory(downloads) - model.copyToClipboard(listOf(downloads.child("a.txt")), cut = true) - model.openDirectory(documents) - model.paste() + model.toggleSelection(downloads.child("a.txt")) + model.startTransferTo(cut = true) + model.pickerOpen(documents) + model.confirmTransferTo() assertTrue(storage.contains(documents.child("a.txt"))) model.undo() @@ -2025,13 +1949,13 @@ class BrowserViewModelTest { } @Test - fun `a copy is not undoable, because nothing was changed`() { + fun `a copy to a chosen folder is not undoable, because nothing was changed`() { val model = viewModel() - model.openDirectory(root) - model.copyToClipboard(listOf(downloads.child("a.txt")), cut = false) - model.openDirectory(documents) - - model.paste() + model.openDirectory(downloads) + model.toggleSelection(downloads.child("a.txt")) + model.startTransferTo(cut = false) + model.pickerOpen(documents) + model.confirmTransferTo() assertNull(model.state.value.undo) } diff --git a/app/src/test/java/de/softwareapp_hb/filemanager/ui/browser/SelectionBarTest.kt b/app/src/test/java/de/softwareapp_hb/filemanager/ui/browser/SelectionBarTest.kt index 373e39e..71366b2 100644 --- a/app/src/test/java/de/softwareapp_hb/filemanager/ui/browser/SelectionBarTest.kt +++ b/app/src/test/java/de/softwareapp_hb/filemanager/ui/browser/SelectionBarTest.kt @@ -3,7 +3,6 @@ package de.softwareapp_hb.filemanager.ui.browser import androidx.compose.ui.test.junit4.createComposeRule import androidx.compose.ui.test.onNodeWithContentDescription import androidx.compose.ui.test.onNodeWithText -import androidx.compose.ui.test.performClick import de.softwareapp_hb.filemanager.core.FileEntry import de.softwareapp_hb.filemanager.core.StoragePath import de.softwareapp_hb.filemanager.ui.theme.FileManagerTheme @@ -44,10 +43,8 @@ class SelectionBarTest { entries = entries, selection = entries.map { it.path }.toSet(), ), - onCopy = {}, - onCut = {}, onCopyTo = {}, - onMoveTo = {}, + onCutTo = {}, onDelete = {}, onDetails = {}, onRename = {}, @@ -63,13 +60,11 @@ class SelectionBarTest { } @Test - fun `the destination verbs live in the overflow menu`() { + fun `the destination verbs are on the bar`() { bar(selected = 1) - compose.onNodeWithContentDescription("More options").performClick() - - compose.onNodeWithText("Copy to…").assertExists() - compose.onNodeWithText("Move to…").assertExists() + compose.onNodeWithContentDescription("Copy to…").assertExists() + compose.onNodeWithContentDescription("Cut to…").assertExists() } @Test diff --git a/docs/CODE_MAP.md b/docs/CODE_MAP.md index 2eff0b4..c4367eb 100644 --- a/docs/CODE_MAP.md +++ b/docs/CODE_MAP.md @@ -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. -80 source files, 13902 lines - 37 test files, 431 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. +80 source files, 13853 lines - 37 test files, 426 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`. @@ -29,7 +29,7 @@ Pure Kotlin, **no Android imports**: paths, file names, kinds, sorting, formatti | `StoragePath.kt` | 89 | A normalized, absolute, backend-agnostic storage path. StoragePath | `StoragePathTest` (8) | | `ThumbnailKey.kt` | 22 | The identity of a thumbnail: which file it came from, and which version of that file. ThumbnailKey | `ThumbnailKeyTest` (4) | | `Timestamps.kt` | 20 | Timestamps for the details sheet. Timestamps | - | -| `TransferPlanner.kt` | 105 | Turns "copy/paste these items into that folder" into a per-item decision, before a single byte is written. ConflictPolicy, TransferAction, TransferSource, TransferItem, TransferPlanner | `TransferPlannerTest` (5) | +| `TransferPlanner.kt` | 105 | Turns "copy or move these items into that folder" into a per-item decision, before a single byte is written. ConflictPolicy, TransferAction, TransferSource, TransferItem, TransferPlanner | `TransferPlannerTest` (5) | ## data/ @@ -101,15 +101,15 @@ 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() | - | | `BrowserDialogs.kt` | 444 | Compressing something, with the one choice that changes what comes out. CompressDialog(), 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) | -| `BrowserScreen.kt` | 661 | The browser, and everything that can be on top of it. 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) | -| `BrowserUiState.kt` | 391 | One immutable description of everything the browser shows. ViewMode, SearchScope, Clipboard, CompressRequest, PickerFolder, TransferPicker, MessageAction, UiMessage, ConflictPrompt, RootItem, Bookmark, DetailFocus, Undo, UndoMove, Overlay, BrowserUiState | - | -| `BrowserViewModel.kt` | 2601 | Drives the browser screen. BrowserViewModel, ArchivePreview | `BrowserViewModelTest` (117) | +| `BrowserScreen.kt` | 657 | The browser, and everything that can be on top of it. BrowserScreen(), messageText() | - | +| `BrowserTopBar.kt` | 271 | The browser's app bar: title, up, the favourite star, and the search / sort / overflow actions. BrowserTopBar(), SearchFieldActions(), BrowserOverflowMenu(), SEARCH_FIELD_WIDTH | `BrowserTopBarTest` (4) | +| `BrowserUiState.kt` | 384 | One immutable description of everything the browser shows. ViewMode, SearchScope, CompressRequest, PickerFolder, TransferPicker, MessageAction, UiMessage, ConflictPrompt, RootItem, Bookmark, DetailFocus, Undo, UndoMove, Overlay, BrowserUiState | - | +| `BrowserViewModel.kt` | 2566 | Drives the browser screen. BrowserViewModel, ArchivePreview | `BrowserViewModelTest` (112) | | `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() | - | | `IncomingBar.kt` | 60 | The bar shown while another app is waiting on this one. IncomingBar() | - | | `IncomingRequest.kt` | 46 | What another app asked this one to do. IncomingRequest | - | -| `SelectionBar.kt` | 219 | The contextual action bar shown while entries are selected. StorageAccessPrompt(), SelectionBar() | `SelectionBarTest` (3) | +| `SelectionBar.kt` | 183 | The contextual action bar shown while entries are selected. StorageAccessPrompt(), SelectionBar() | `SelectionBarTest` (3) | | `SortSheet.kt` | 128 | Sort key, direction, hidden files and list/grid — all in one sheet. sortKeyLabel(), SortSheet() | - | ## ui/viewer/ @@ -595,7 +595,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 - the star gives way to the search field -### `ui/browser/BrowserViewModelTest.kt` (117) +### `ui/browser/BrowserViewModelTest.kt` (112) - starts on the root list - opening a folder lists it with directories first and natural order @@ -613,10 +613,8 @@ Every test name in the suite, by file: the fastest way to see whether something - 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 -- a copy into the folder on screen marks the copy - a copy into another folder marks it there, not here - reading the folder again spends its marks - a rename marks the name it produced @@ -676,9 +674,6 @@ Every test name in the suite, by file: the fastest way to see whether something - rename moves the entry to the new name - delete removes the selected entries directly when the platform has no dialog - delete prefers the platform dialog and leaves the files alone until it returns -- copy then paste duplicates into the current folder -- cut then paste moves and clears the clipboard -- a pasted name that already exists asks before overwriting - without any grant the browser asks for a folder - a granted tree becomes the only root - focusing a folder in the master pane lists it for the detail pane @@ -688,7 +683,7 @@ Every test name in the suite, by file: the fastest way to see whether something - a focused volume previews the contents of that volume - the browsed folder and view settings survive a restart - a search query is not restored, so the folder is not hidden on launch -- a selection and a clipboard do not survive a restart +- a selection does not survive a restart - restarting on the roots screen stays on the roots screen - search reaches into a subfolder, not just the one being browsed - clearing the query brings the folder back @@ -703,8 +698,8 @@ Every test name in the suite, by file: the fastest way to see whether something - a filename cannot be a path - a batch rename cannot be given a path either - undoing a rename puts the old name back -- undoing a cut-paste moves the item back -- a copy is not undoable, because nothing was changed +- undoing a cut to a chosen folder moves the item back +- a copy to a chosen folder is not undoable, because nothing was changed - the undo handle is cleared once it has been used - a shared file is saved into the folder the user chose - a shared file never overwrites one already in the folder @@ -717,7 +712,7 @@ Every test name in the suite, by file: the fastest way to see whether something ### `ui/browser/SelectionBarTest.kt` (3) -- the destination verbs live in the overflow menu +- the destination verbs are on the bar - several files are offered a rename and not the details - a single file keeps both diff --git a/docs/COMPETITIVE_ANALYSIS.md b/docs/COMPETITIVE_ANALYSIS.md index de19573..59c5b57 100644 --- a/docs/COMPETITIVE_ANALYSIS.md +++ b/docs/COMPETITIVE_ANALYSIS.md @@ -309,7 +309,7 @@ requested feature in the market leader, and the permission line is a claim it ca | Duplicate finder | **No** | Explicitly requested, with an offer to pay. Pure local computation over a listing we can already do | | Batch rename, including continuing an existing numbering sequence | **No** | **Closed in M14.** Several files are renamed to one name with a number after each, and the numbering continues the folder's own series, so the "Ice (3), Ice (4)" wish from the top complainant is what the dialog now defaults to | | Bookmarks | **No** | **Closed in M15.** A star on any folder puts it in **Favourites** on the home screen, above the libraries; a folder that has gone is kept and marked rather than dropped | -| "Copy again" / "Return to source folder" after a paste | **No** | **Closed as superseded.** Both exist because their flow makes you leave the source folder to paste; [ROADMAP](ROADMAP.md) specifies **Copy to… / Move to… (M16)** instead, which never leaves the source — so the selection is still there for the next destination and there is no "back to source" to offer | +| "Copy again" / "Return to source folder" after a paste | **No** | **Closed as superseded.** Both exist because their flow makes you leave the source folder to paste; [ROADMAP](ROADMAP.md) specifies **Copy to… / Cut to… (M16)** instead, which never leaves the source — so the selection is still there for the next destination and there is no "back to source" to offer | | New-file dot after an operation | **No** | Their 3.6.5. Cheap, and it answers "what did that 4 GB copy just do?" | | Date-format setting | **No** | We deliberately leave the timestamp pattern unlocalised (README); a *user setting* is a different thing | | Music-player engine switch, video gesture toggle | **No** | Deep settings; low priority for a first release | @@ -357,7 +357,7 @@ In the ROADMAP's own vocabulary (💡 = candidate, proposed, not scheduled; ✅ Ice (2)" case that the top complainant names. 4. ✅ **Bookmarks** — shipped as **M15**. ~~💡 "Return to source folder" and "Copy again" after a paste remain~~ — **closed as superseded**: both are workarounds for having to leave the source folder - to paste, and **Copy to… / Move to…** ([ROADMAP](ROADMAP.md), M16) removes that constraint, so the + to paste, and **Copy to… / Cut to…** ([ROADMAP](ROADMAP.md), M16) removes that constraint, so the selection is still there for the next destination and there is no "back to source" to offer. 5. 💡 **Highlight files changed by an operation.** Their 3.6.5; answers an obvious question. 6. **Thumbnail hardening before the next release**: register an explicit Coil `ImageLoader` with a diff --git a/docs/EDGE_CASE_TESTING.md b/docs/EDGE_CASE_TESTING.md index e4bf405..5c1f18f 100644 --- a/docs/EDGE_CASE_TESTING.md +++ b/docs/EDGE_CASE_TESTING.md @@ -64,7 +64,7 @@ track to report back. | Predictive back | **Correct.** `BACK` from inside a folder returns to the root and the activity stays resumed, so the `BackHandler` composed after the overlay branch works under the `OnBackInvoked` dispatcher too. | | Permission request | **Broke, fixed.** On the shipped build Android 13+ was asked for nothing at all; see below. | | The progress notification | **Fixed, then verified.** After the fix, a real 220 MB copy posts notification `1001` on channel `file-operations` with `ONGOING_EVENT\|NO_CLEAR\|FOREGROUND_SERVICE` and `category=progress`, then removes it and stops the service when the copy finishes. Before the fix the app's notification importance was `NONE`. | -| Copy to completion | The staged copy landed at `/sdcard/bigfile.bin`, byte-count identical, via the overflow menu's "Paste here". | +| Copy to completion | The staged copy landed at `/sdcard/bigfile.bin`, byte-count identical, via the then-current "Paste here" (the clipboard flow that has since become Copy to… / Cut to…). | | Compress and extract from the UI | **Found a bug, fixed.** Long-pressing a folder and choosing "Compress to ZIP" produced a `tocompress.zip` that the host's `unzip` read back with the right relative paths and contents. Selecting that `.zip` then exposed the selection bar clipping its own actions — see below. | | One archive of **every** supported format | **Found a bug, fixed.** A real `.zip`, `.7z`, `.tar`, `.tar.gz`, `.tar.bz2`, `.txt.xz`, `.txt.gz` and `.txt.bz2` were built on the host and all eight extracted through the UI. Each landed with the right entry names and byte-for-byte content — except `.tar.bz2`, which produced a bare `.tar`, recorded below. | | Long-press from adb | `input swipe x y x y 700` on a single point *did* open the selection bar and reach Copy. The same gesture has been flaky on the MIUI phone, so it still deserves a manual pass. | @@ -342,6 +342,23 @@ ran inside the search's own job, which is replaced on every letter) and the next first folder, so the row read a fresh, small number every time. The walk finished on the last sample and stamped the volume, which is what makes the *next* search cheap instead of another walk. +## The Copy to… / Cut to… session + +The selection bar's clipboard was replaced by **Copy to…** and **Cut to…**, both opening the destination +picker. Driven on the Xiaomi phone (release build, Android 12), against `AADemo`: + +| What | Result | +|---|---| +| The bar | **Copy to…** and **Cut to…** only — no Copy/Move labels, and no destination items in its overflow. | +| Copy to… | `Source` → picker (`Internal storage → AADemo → Target`) → **Copy here**: 120 × 16 MB copied; **Copying files** in the shade; completion **Copied 121 items to Target** with **Show**. | +| Cut to… | `file001.bin` → the same picker, titled **Cut to…** with **Cut here**: moved to `Target`; the source count fell from 120 to 119; the selection cleared and **Undo** was offered. | +| A name in the way | Cutting `Source` into a `Target` that already held one raised **Items already exist here**, with Replace / Skip / Cancel / Keep both; Cancel moved nothing. | +| Paste | The top-bar overflow lists Find duplicates, Storage usage, Trash and Refresh — no **Paste here**. | + +The copy was recorded as the foreground-service video ([FGS_VIDEO](FGS_VIDEO.md)). Not repeated here: +the SAF-mode picker (the same dialog, but the granted-tree root was not driven), tablet/landscape layout, +and a cut interrupted mid-batch. + ## Not verified, and why - **Long-press multi-select via adb: it works, but not every time.** `input swipe x y x y 900` diff --git a/docs/FGS_VIDEO.md b/docs/FGS_VIDEO.md index 005873d..7222012 100644 --- a/docs/FGS_VIDEO.md +++ b/docs/FGS_VIDEO.md @@ -9,8 +9,8 @@ record of how it was made. The All files access declaration has its own video an | file | length | what it is | |---|---|---| -| `store-assets/video/fgs-import-export.mp4` | **18.0 s** | The submission copy: 1080×2400, H.264, **no audio**, English captions burned in | -| `store-assets/video/fgs-import-export-uncaptioned.mp4` | 16.8 s | The same cut without captions, for a voice-over | +| `store-assets/video/fgs-import-export.mp4` | **16.1 s** | The submission copy: 1080×2400, H.264, **no audio**, English captions burned in | +| `store-assets/video/fgs-import-export-uncaptioned.mp4` | 18.8 s | The same cut without captions, for a voice-over | | `store-assets/video/fgs-import-export.srt` | — | The five captions and their timings | **The file is not a link.** Upload it to YouTube **Unlisted**, or Drive shared with "anyone with the @@ -21,33 +21,34 @@ reviewed. | time | on screen | what it proves | |---|---|---| -| 0:00 | `AADemo`, with `Source` selected and **Copy** in the action bar | The user starts a local import/export | -| 0:03 | `Target`, then the overflow's **Paste here** | The user chooses the local destination and triggers it | +| 0:00 | `AADemo`, with `Source` selected and **Copy to…** in the action bar | The user starts a local import/export | +| 0:02 | The **Copy to…** picker: `Internal storage`, then `AADemo` | The destination is chosen locally, without leaving the source | +| 0:06 | The picker on `Target`, then **Copy here** | The user triggers the local copy | | 0:10 | The notification shade: **Copying files** with a moving progress bar | The `dataSync` foreground service is doing the work and reporting it | -| 0:12 | `Target` with `Source` and the snackbar **Copied 121 items** | The operation completed at the destination | +| 0:14 | `AADemo` with the snackbar **Copied 121 items to Target** and **Show** | The operation completed at the chosen destination | The captions, in order: -1. *Copy a 1.8 GB folder to another folder* -2. *Paste here starts a local copy* -3. *The copy runs in the dataSync service* -4. *The ongoing notification reports progress* -5. *Done: the folder is at the destination* +1. *Copy a 1.8 GB folder with Copy to…* +2. *Pick Internal storage, then AADemo* +3. *Choose Target, then Copy here* +4. *The foreground service reports progress* +5. *Done: Copied 121 items to Target* ## What was cut, and why -The raw take was **35.2 s**; the published copy is three pieces of it: +The raw take was **41.0 s**; the published copy is three pieces of it: | kept | what it is | |---|---| -| 0–10 s | Select `Source`, Copy, open `Target`, Paste here | -| 17–19.5 s | The shade with **Copying files** and its progress bar | -| 22–27 s | The completed `Target` and its snackbar | +| 0–9.5 s | Select `Source`, **Copy to…**, pick `Internal storage → AADemo → Target`, **Copy here** | +| 21–25.5 s | The shade with **Copying files** and its progress bar | +| 26–31 s | The completed folder and the **Copied 121 items to Target** snackbar | -The two gaps are elapsed time, not evidence: the wait between Paste and the notification appearing, -and the wait between the shade closing and the listing refreshing. **The app's target listing stays -empty until the copy finishes and then refreshes itself**, so the empty frames are the operation in -progress, not a failure. The notification and the result are from the same take. +The two gaps are elapsed time, not evidence: the wait between **Copy here** and the notification +appearing, and the wait between the shade closing and the listing refreshing. **The app's target +listing stays empty until the copy finishes and then refreshes itself**, so the empty frames are the +operation in progress, not a failure. The notification and the result are from the same take. Nothing else is edited. No home screen, launcher or library view appears — the recording starts inside `AADemo`. The shade was cleared of the phone's weather and Play cards before the take; what remains @@ -74,59 +75,70 @@ Clear the notification shade before recording: open it, tap **Clear all**, and c in a video that will be published; USB debugging/transfer cards are generic and may stay. Open the app on **Internal storage → AADemo**, long-press `Source`, and verify the bar says -**1 item** with **Copy** before recording. Then: +**1 item** with **Copy to…** before recording. Then: ```bash adb shell rm -f /sdcard/fgs.mp4 -adb shell screenrecord --size 1080x2400 --bit-rate 8000000 --time-limit 50 /sdcard/fgs.mp4 & +adb shell screenrecord --size 1080x2400 --bit-rate 8000000 --time-limit 55 /sdcard/fgs.mp4 & sleep 1.5 -adb shell input tap 231 2155 # Copy in the selection bar -sleep 1.5 -adb shell input tap 540 644 # Target — already inside AADemo +adb shell input tap 231 2155 # Copy to… in the selection bar sleep 2 -adb shell input tap 1003 181 # overflow -sleep 1.5 -adb shell input tap 864 335 # Paste here -# The notification exists only after Paste. Wait for it, or the shade shows only USB cards. +adb shell input tap 540 1148 # Internal storage in the picker +sleep 2 +adb shell input tap 540 994 # AADemo +sleep 2 +adb shell input tap 540 1302 # Target +sleep 2 +adb shell input tap 740 1396 # Copy here +# The notification exists only after Copy here. Wait for it, or the shade shows only USB cards. for i in $(seq 1 16); do adb shell cmd notification list | grep -q 'de.softwareapp_hb.filemanager' && break sleep 0.5 done adb shell cmd statusbar expand-notifications -sleep 4 +sleep 6 # the card can rank below the USB pair for the first second adb shell cmd statusbar collapse sleep 18 # the copy must finish and the listing refresh adb shell pkill -INT screenrecord adb pull /sdcard/fgs.mp4 ``` -Cut the two waits, keeping the trigger, the notification and the result: +Cut the two waits, keeping the trigger, the notification and the result. Use `-ss`/`-t` per segment +rather than a filter graph: the phone records variable-frame video, and a `trim` concat came out 6 s +short on this take. ```bash -ffmpeg -y -i fgs.mp4 -filter_complex \ - "[0:v]trim=0:10,setpts=PTS-STARTPTS[a];[0:v]trim=17:19.5,setpts=PTS-STARTPTS[b];[0:v]trim=22:27,setpts=PTS-STARTPTS[c];[a][b][c]concat=n=3:v=1[v]" \ - -map "[v]" -c:v libx264 -crf 20 -pix_fmt yuv420p -an fgs-cut.mp4 +ffmpeg -y -ss 0 -t 9.5 -i fgs.mp4 -an -c:v libx264 -crf 20 -pix_fmt yuv420p seg-a.mp4 +ffmpeg -y -ss 21 -t 4.5 -i fgs.mp4 -an -c:v libx264 -crf 20 -pix_fmt yuv420p seg-b.mp4 +ffmpeg -y -ss 26 -t 5 -i fgs.mp4 -an -c:v libx264 -crf 20 -pix_fmt yuv420p seg-c.mp4 +printf "file 'seg-a.mp4'\nfile 'seg-b.mp4'\nfile 'seg-c.mp4'\n" > segments.txt +ffmpeg -y -f concat -safe 0 -i segments.txt -c copy fgs-cut.mp4 ``` -The times are this take's; check the frames before reusing them. +The times are this take's; check the frames before reusing them. The published file is 16.1 s after +the caption pass, because the last segment is shorter than its nominal five seconds. -Two traps cost takes here: +Three traps cost takes here: -- **Do not press Up after Copy.** The selection is made *in* `AADemo`; Copy clears the bar and - `Target` is the next row. An extra Up goes to the card root, the following taps open `AADemo` - again, and Paste then targets `AADemo` — which already holds `Source`, so the app correctly asks - **“Items already exist here”**. The first attempt did exactly that. -- **Open the shade only once the notification exists.** Opening it straight after Paste can beat the - service's first post and shows nothing but Android's USB cards — a video with the trigger and the - result but no evidence of the service. The `cmd notification list` loop above is the guard. +- **The picker recentres as the trail grows.** Its rows and its **Copy here** button move with each + level, so map each screen with `adb shell uiautomator dump`; the coordinates above are this phone's + 1080×2400 layout, not a scale factor. +- **Never run `assembleRelease` and `adb install` in one parallel batch.** That race installed the + previous APK, and the bar still said **Copy / Move** even though the new strings were in the built + APK. Install sequentially, then check the phone: + `aapt2 dump resources | grep browser_action_cut_to`. +- **Open the shade only once the notification exists, and hold it.** Opening it straight after + **Copy here** can show only Android's USB cards, and even when the app's card exists it can rank + below them for the first second. The `cmd notification list` loop plus the 6 s hold is what made + **Copying files** readable. Also expect the target listing to stay **empty** until the copy completes and the ViewModel reloads -it. That is the operation still running, not a missed Paste; `du -sh /sdcard/AADemo/Target` is the -independent check. +it. That is the operation still running, not a missed **Copy here**; `du -sh /sdcard/AADemo/Target` +is the independent check. The captions are five PNG overlays at **y = 1900**, not the 2130 used by the all-files video: the -selection bar sits at 2133–2177 on this phone, and a caption there hides the Copy button — the very -action the video exists to show. +selection bar sits at 2133–2177 on this phone, and a caption there hides the **Copy to…** button — +the very action the video exists to show. ## Uploading it diff --git a/docs/PLAY_RELEASE_CHECKLIST.md b/docs/PLAY_RELEASE_CHECKLIST.md index 939ab16..c209083 100644 --- a/docs/PLAY_RELEASE_CHECKLIST.md +++ b/docs/PLAY_RELEASE_CHECKLIST.md @@ -183,8 +183,8 @@ asks about this separately from All files access. - **Use case: Local processing → Importing, exporting.** Play's own chart describes the use case as *"Includes migration of files from SD card. Show the user manually triggering an import or export - of files locally (for example, as moving files from an SD card)"* — which is what **Paste** and - **Copy to… / Move to…** do. Do **not** tick network processing, backing up/restoring, media + of files locally (for example, as moving files from an SD card)"* — which is what **Copy to…** and + **Cut to…** do. Do **not** tick network processing, backing up/restoring, media transcoding or "Other tasks": every `OperationKind` is local — `COPY`, `MOVE`, `DELETE`, `COMPRESS`, `EXTRACT` — and none of them touches a network. @@ -204,10 +204,11 @@ asks about this separately from All files access. the destination, and the user has to start the operation again. Nothing is overwritten without asking, and deletes are staged in the 24-hour trash, so completed deletes remain recoverable."* -- [ ] **Upload the foreground-service video.** `store-assets/video/fgs-import-export.mp4` (**18.0 s**, +- [ ] **Upload the foreground-service video.** `store-assets/video/fgs-import-export.mp4` (**16.1 s**, captions burned in) to YouTube **Unlisted** or Drive with link sharing, then paste that URL into - the foreground-service declaration. It shows the selection, **Copy**, **Paste here**, the - **Copying files** notification with its progress bar, and the completed folder. It is a + the foreground-service declaration. It shows the selection, **Copy to…**, the destination + picker (`Internal storage → AADemo → Target`), **Copy here**, the **Copying files** + notification with its progress bar, and **Copied 121 items to Target**. It is a different field from the All files access video. What was cut, the privacy checks, and how to re-shoot it: [FGS_VIDEO](FGS_VIDEO.md). diff --git a/docs/RELEASE_NOTES.md b/docs/RELEASE_NOTES.md index 732b7e1..2054240 100644 --- a/docs/RELEASE_NOTES.md +++ b/docs/RELEASE_NOTES.md @@ -66,11 +66,11 @@ name — `file2` before `file10` — size, date or type; hidden files on request search that either stays in the folder you are in or looks everywhere through an on-device index which says how current its answer is. -**Moving files around.** Copy, move, rename and delete one item or many. **Copy to…** and **Move +**Moving files around.** Copy, move, rename and delete one item or many. **Copy to…** and **Cut to…** open a folder picker — volumes first, then one level at a time — and start the transfer into that folder without taking you out of the one you are standing in. A finished copy says -*Copied to folder* and offers **Show**. A name that already exists asks what Paste asks instead of -overwriting, and a batch that cannot succeed is refused as a whole rather than performed until it +*Copied to folder* and offers **Show**. A name that already exists asks before overwriting, and a +batch that cannot succeed is refused as a whole rather than performed until it hits the problem. The rows an operation produced carry a dot until you next open that folder, so "what did that 4 GB copy do?" has an answer even when the destination was elsewhere. diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index a4ed33f..119c47b 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -26,7 +26,7 @@ Legend: ✅ done · 🚧 in progress · ⏳ not started · 💡 candidate (propo | M13 | Video thumbnails | ✅ | A video row shows **a frame of the video** instead of a film icon: images and videos take the same path through Coil, with the video request asking for a frame a second in. One dependency — `coil-video` at the Coil version already pinned, whose POM adds only coil-core/stdlib/core-ktx — the platform's own `MediaMetadataRetriever` behind it, **no new permission** (no `READ_MEDIA_VIDEO`), and the decoder registered **explicitly** against an `ImageLoader` in `FileManagerApplication` — because R8 strips `coil-video`'s ServiceLoader entry from a release build, which would otherwise have left frames decoding in debug and quietly falling back to icons for real users. A decode that fails settles on the icon rather than an empty square. Verified on the phone against two purpose-made clips and a real video of the device's own, with the demo library's stand-ins falling back as intended, and **re-verified on the release APK**. **324 JVM tests** | | M14 | Batch rename with sequence continuation | ✅ | Selecting more than one file and choosing Rename opens a **pattern** dialog: a name, a start number, and a live preview of the names it will produce. `core/BatchRename` plans the whole batch before a single file moves and returns one of three answers — **Ready**, **Blocked** (naming the target the folder already holds) or **NothingToDo** — with the deliberate absence of a fourth: a batch that renamed nine of twelve and stopped would leave the user to work out which nine. The numbering **continues the folder's own series** `nextNumber` from the highest `base (n)` it finds, which is the market survey's most-helpful complaint answered by name; the extension is kept per file, the series is counted across extensions, and continuing beats filling the first gap. `SelectionBar` now offers Rename to any selection (one file keeps the name dialog, several get the pattern one), and the batch's renames are **one** `Undo.Relocate` step. The same commit hardens the single rename: `core/FileNames.isUsableName` refuses a name that is not one path segment, because `notes/final.txt` was being resolved as a path and **moved** the file instead of renaming it. **350 JVM tests** (26 added) | | M15 | Favourites | ✅ | A star in the app bar of any folder, and a **Favourites** section on the home screen above the libraries — absent while empty, so a first-time user still meets the libraries first. `data/BookmarkStore` + `SqliteBookmarkStore` keep one path per row in their own database: a **reference, never a copy**, so the list costs one query and no filesystem work. `BrowserUiState.Bookmark` carries an `exists` flag, and a folder that has gone is **kept and marked** rather than deleted — an unmounted card and a deleted folder are the same thing from here — with opening re-checked at that moment so the answer is a message instead of an empty listing. The row's filled star is the removal (the mark that says "favourite" is the thing you tap to stop it being one), there is no star in a library or on the root screen, and no `Undo` for a bookmark because that type is "the last change to the filesystem" and a favourite is not one. **371 JVM tests** (16 added) | -| M16 | Copy to… / Move to… | ✅ | A destination picker behind the selection bar's overflow — volumes, then folders one level at a time, with Up and the trail above them — that copies or moves **without leaving the folder you are in**. The selection survives a copy, so a second destination is two taps; a move clears it. Conflicts ask what a paste asks, naming the picked folder; a copy answers "Copied to *folder*" with **Show**. Detailed below | +| M16 | Copy to… / Cut to… | ✅ | A destination picker behind the selection bar's **Copy to…** and **Cut to…** actions — volumes, then folders one level at a time, with Up and the trail above them — that copies or moves **without leaving the folder you are in**. The selection survives a copy, so a second destination is two taps; a cut clears it. Conflicts ask before overwriting, naming the picked folder; a copy answers "Copied to *folder*" with **Show**. Detailed below | | M17 | The mark of a change | ✅ | A dot on the rows an operation produced, spent by the next read of that folder. In memory, per folder: no timer, no watcher, no badge. `BrowserUiState.changed` + `markChanged` on every operation that creates something; the dot is drawn by `MarkOfChange` in both list and grid, with a content description TalkBack reads. **7 tests** (4 view model, 3 Compose) | | M18 | Store-only archives | ✅ | Compressing asks two things: the name, and whether to compress. Off stores the files as they are — `Deflater.NO_COMPRESSION`, one pass — which is what a bulk copy off the phone actually wants. A `STORED` entry would need each file's size and CRC before its header, i.e. reading everything twice. **4 tests** | | M19 | The trash says what it does | ✅ | The trash states its window ("Deleted files are kept for 24 hours") and offers **Empty trash** behind one confirmation; opening the trash sweeps expired batches, so the 24-hour retention no longer waits for the next delete to run. **4 tests** | @@ -63,7 +63,7 @@ duplicate finder (M12), batch rename with sequence continuation (M14) and favour The last one standing there — the post-paste **"copy again"** and **"return to source folder"** — is **closed as superseded**, not deferred: both exist only because that app's flow makes you leave the -source folder to paste. `Copy to…` / `Move to…` (in the queue below) removes that constraint, and with it +source folder to paste. `Copy to…` / `Cut to…` (in the queue below) removes that constraint, and with it both features: you never leave the source, so the selection is still there for the next destination, and there is no "back to source" to offer. Keeping them would be building a workaround for a limitation we would no longer have. @@ -71,7 +71,7 @@ would no longer have. ## The queue after 1.0 Ordered by what it protects, not by what is fun. **Four of the five items are done** — thumbnails, the -scaling numbers (which turned into progressive listing), Copy to… / Move to…, and the CI benchmark. What is +scaling numbers (which turned into progressive listing), Copy to… / Cut 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. @@ -96,10 +96,11 @@ deliberately not started: its acceptance needs a TV device pass this machine can outlier; the outlier was investigated and the real fix was progressive listing (200-entry batches, O(n+k) merge), after which 20 000 files opens in **10–16 frames** with a 32–48 max across two runs. The whole suite at that size, and what search costs there, is in [PERFORMANCE](PERFORMANCE.md). -4. **Copy to… / Move to… (M16)** — **done**: the selection bar's overflow opens a folder picker (volumes, - then folders one level at a time, Up and the trail above them) and confirming copies or moves into the - picked folder without leaving the current one. The selection survives a copy so a second destination is - two taps; a move clears it. Conflicts ask the same question as a paste, naming the picked folder. Seven +4. **Copy to… / Cut to… (M16)** — **done**: the selection bar's **Copy to…** and **Cut to…** actions + open a folder picker (volumes, then folders one level at a time, Up and the trail above them) and + confirming copies or moves into the picked folder without leaving the current one. The selection + survives a copy so a second destination is two taps; a cut clears it. Conflicts ask before + overwriting, naming the picked folder. Seven 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 picker naming internal storage by its raw name `0`. A copy into a picked folder says **"Copied to @@ -115,24 +116,23 @@ deliberately not started: its acceptance needs a TV device pass this machine can `frameOverrunMs` after all (P50 13.9 ms / 19.0 ms for the 1 000-file open), so `PERFORMANCE.md`'s "counts only" finding was a property of the earlier runs, not of the device; corrected there. -### M16 — Copy to… / Move to… +### M16 — Copy to… / Cut to… -**What**: from a selection, choose a destination folder and start the copy (or move) immediately, without +**What**: from a selection, choose a destination folder and start the copy (or cut) immediately, without leaving the folder you are in. -**Why this shape**: `BrowserViewModel.paste()` already calls `planTransfer(paths, target, policy)` with the -current folder as `target`; the engine is target-agnostic, so the operation half needs no new machinery — -`FileOpService` and the conflict prompt work regardless of what is on screen. What is missing is a -destination picker. +**Why this shape**: the transfer engine already takes an explicit target — `planTransfer(paths, target, +policy)` — and is otherwise independent of what is on screen; `FileOpService` and the conflict prompt +work regardless. What was missing was a destination picker. **Acceptance criteria** - Selecting files and choosing *Copy to…* opens a folder picker: folders only, one level at a time, with Up and breadcrumbs, every root the app can see (internal and SD card), and the granted tree in SAF mode. - Confirming copies into the chosen folder and **leaves the user where they were**, with the selection - still intact — so a second destination is two taps. `Move to…` is the same picker with `cut = true`. -- A name conflict asks the same question as a paste does, with the picked folder named in it, and resolve - applies to that folder. + still intact — so a second destination is two taps. `Cut to…` is the same picker with `cut = true`. +- A name conflict asks before overwriting, with the picked folder named in it, and resolve applies to + that folder. - Progress belongs to the existing operation notification, not to the screen the user is on. - After a copy, a message names the destination and offers to show it. After a move the selection is cleared and the listing refreshed, because the rows are gone. diff --git a/store-assets/video/fgs-import-export-uncaptioned.mp4 b/store-assets/video/fgs-import-export-uncaptioned.mp4 index 340255e..21aa2ac 100644 Binary files a/store-assets/video/fgs-import-export-uncaptioned.mp4 and b/store-assets/video/fgs-import-export-uncaptioned.mp4 differ diff --git a/store-assets/video/fgs-import-export.mp4 b/store-assets/video/fgs-import-export.mp4 index d44cdd9..0567a89 100644 Binary files a/store-assets/video/fgs-import-export.mp4 and b/store-assets/video/fgs-import-export.mp4 differ diff --git a/store-assets/video/fgs-import-export.srt b/store-assets/video/fgs-import-export.srt index 9cfee40..3c3e6a7 100644 --- a/store-assets/video/fgs-import-export.srt +++ b/store-assets/video/fgs-import-export.srt @@ -1,19 +1,19 @@ 1 -00:00:00,200 --> 00:00:03,000 -Copy a 1.8 GB folder to another folder +00:00:00,200 --> 00:00:02,800 +Copy a 1.8 GB folder with Copy to… 2 -00:00:03,000 --> 00:00:06,500 -Paste here starts a local copy +00:00:02,800 --> 00:00:06,200 +Pick Internal storage, then AADemo 3 -00:00:06,500 --> 00:00:10,000 -The copy runs in the dataSync service +00:00:06,200 --> 00:00:09,600 +Choose Target, then Copy here 4 -00:00:10,000 --> 00:00:12,500 -The ongoing notification reports progress +00:00:09,600 --> 00:00:14,200 +The foreground service reports progress 5 -00:00:12,500 --> 00:00:17,900 -Done: the folder is at the destination +00:00:14,200 --> 00:00:16,000 +Done: Copied 121 items to Target