Let a folder be starred, and put the starred ones on the home screen
The app bar of any folder now carries a star, and a starred folder appears in a Favourites section on the home screen, above the libraries. Above, because it is the user's own list - and absent entirely while empty, so someone opening the app for the first time still meets the libraries first, which is the order they were given for a reason. The star in the row is the removal, which is what a star means everywhere else: the mark that says "this is a favourite" is the thing you tap to stop it being one. That avoids a long-press as well, and with it a destructive gesture nobody can see. A favourite is a reference, not a copy: data/BookmarkStore keeps one path per row and nothing about the folder's contents, so reading the whole list is one query and no filesystem work, which is what makes it affordable on every visit to the home screen. It has its own SQLite database rather than a table in the trash index - one table and three queries do not justify Room in a build that has no annotation processor, and the two stores have nothing to say to each other. Two behaviours there are decisions rather than defaults. A folder that has gone is kept and marked (Folder not found) instead of being deleted: a card out of its slot and a folder deleted in another app are indistinguishable from here, and silently forgetting someone's list because their SD card was out is the worse of the two mistakes. And opening the row re-checks existence at that moment, because the flag is as old as the screen - so the answer is a message rather than an empty listing, which would read as an empty folder instead of a missing one. Two smaller refusals came out of the same question. There is no star in a library or on the root screen, because neither is a folder and there would be nothing to come back to: the distinction that governs the new-folder button, and one the view model enforces too rather than trusting a stale screen. And a bookmark has no undo, because Undo means "the last change to the filesystem the app made" and a favourite is not one - the star is one tap to put back. The row's label comes from the same place the breadcrumbs do, which is why a starred volume root reads as "Internal storage" rather than as "0". Sixteen tests: nine against real SQLite through Robolectric (round trip, ordering, starring twice neither duplicating nor moving a row, and the same no-drop migration guard the trash index carries), and seven through the view model (toggling, outliving the view model that added it, newest first, the missing folder kept and marked, the missing folder refusing to open, and a library refusing to be starred). 371 JVM tests, lint clean. Verified on the phone: the star appears only inside a folder and flips from "Add to favourites" to "Remove from favourites" without leaving the screen; the home screen lists the volume root above the libraries with its own label and its path underneath; the row opens it with the star already filled; and the row's star takes the whole section away with a snackbar. The documentation moved with it - CHANGELOG, ROADMAP (M15), README, COMPETITIVE_ANALYSIS (that gap closes instead of joining the candidates), EDGE_CASE_TESTING, and the generated code map.
This commit is contained in:
+32
-11
@@ -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.
|
||||
|
||||
74 source files, 12036 lines - 28 test files, 355 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.
|
||||
76 source files, 12462 lines - 29 test files, 371 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`.
|
||||
|
||||
@@ -30,18 +30,20 @@ Pure Kotlin, **no Android imports**: paths, file names, kinds, sorting, formatti
|
||||
|
||||
## data/
|
||||
|
||||
Storage backends (all-files access and SAF), volumes, incoming shares, the SQLite search index and trash index
|
||||
Storage backends (all-files access and SAF), volumes, incoming shares, the SQLite search index, trash index and favourite list
|
||||
|
||||
| file | lines | what it holds | tests |
|
||||
|---|---|---|---|
|
||||
| `AllFilesSource.kt` | 125 | The "All files access" backend: plain java.io.File over absolute paths. AllFilesSource, File() | `AllFilesSourceTest` (13) |
|
||||
| `ApkInspector.kt` | 100 | What an APK says about itself, read without installing it. ApkInfo, interface(), PackageManagerApkInspector | - |
|
||||
| `BookmarkStore.kt` | 34 | The folders the user starred. BookmarkStore | - |
|
||||
| `IncomingSource.kt` | 48 | Something another app has handed to this one. IncomingSource, ContentResolverIncomingSource | - |
|
||||
| `MediaStoreDeleter.kt` | 73 | A best-effort DeleteRequestFactory backed by MediaStore. interface(), MediaStoreDeleter | - |
|
||||
| `SafDocumentIds.kt` | 40 | Maps a Storage Access Framework tree onto StoragePaths. SafDocumentIds | `SafDocumentIdsTest` (6) |
|
||||
| `SafGrant.kt` | 41 | Picking and keeping a folder tree for the SAF fallback. SafGrant | - |
|
||||
| `SafSource.kt` | 187 | The Storage Access Framework fallback backend. SafSource | `SafSourceTest` (5) |
|
||||
| `SearchIndex.kt` | 196 | A by-name index of what is on the volumes, so a search does not have to walk them. IndexedFile, VolumeStamp, SearchIndex | - |
|
||||
| `SqliteBookmarkStore.kt` | 117 | The starred folders, on SQLite. SqliteBookmarkStore | `SqliteBookmarkStoreTest` (9) |
|
||||
| `SqliteSearchIndex.kt` | 438 | The search index, on SQLite. SqliteSearchIndex | `SqliteSearchIndexTest` (27) |
|
||||
| `SqliteTrashIndex.kt` | 162 | The trash index, on SQLite. SqliteTrashIndex | `SqliteTrashIndexTest` (7) |
|
||||
| `StorageAccess.kt` | 80 | The Android-facing side of the storage-access decision. StorageAccess | - |
|
||||
@@ -92,14 +94,14 @@ The browser screen: view model, state, list and grid, dialogs, selection bar
|
||||
| file | lines | what it holds | tests |
|
||||
|---|---|---|---|
|
||||
| `BrowserComponents.kt` | 301 | The thumbnail an entry's leading visual gets in a list row. ListThumbnailSize, GridThumbnailSize, GridCellMinSize, EntryIcon(), FileEntry(), FileEntry(), VIDEO_FRAME_MILLIS, iconFor(), iconFor(), EntryRow(), EntryGridCell(), FileEntry() | - |
|
||||
| `BrowserContent.kt` | 420 | Everything below the app bar: the permission prompt, the roots, or a directory. WIDE_LAYOUT_MIN_WIDTH, MasterPaneWidth, BrowserContent(), LazyListScope(), LIBRARY_SECTION_KEY, DirectoryView(), FileEntry(), IndexStatusRow(), FileEntry() | - |
|
||||
| `BrowserContent.kt` | 463 | Everything below the app bar: the permission prompt, the roots, or a directory. WIDE_LAYOUT_MIN_WIDTH, MasterPaneWidth, BrowserContent(), LazyListScope(), LIBRARY_SECTION_KEY, LazyListScope(), BOOKMARK_SECTION_KEY, DirectoryView(), FileEntry(), IndexStatusRow(), FileEntry() | - |
|
||||
| `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` | 279 | Renaming several files at once, with the numbering worked out before anything moves. BatchRenameDialog(), PREVIEW_NAMES, batchRenameNote(), NameDialog(), ConfirmDialog(), ConflictDialog() | - |
|
||||
| `BrowserRoots.kt` | 193 | A row on the root screen: a volume, with its free space when the platform reports it. RootRow(), itemCountLabel(), EmptyState(), LibrarySectionHeader(), LibraryRow(), Breadcrumbs() | - |
|
||||
| `BrowserScreen.kt` | 585 | BrowserScreen() | - |
|
||||
| `BrowserTopBar.kt` | 210 | The browser's app bar: title, up, and the search / sort / overflow actions. BrowserTopBar(), SEARCH_FIELD_WIDTH | - |
|
||||
| `BrowserUiState.kt` | 287 | One immutable description of everything the browser shows. ViewMode, SearchScope, Clipboard, UiMessage, ConflictPrompt, RootItem, DetailFocus, Undo, UndoMove, Overlay, BrowserUiState | - |
|
||||
| `BrowserViewModel.kt` | 2011 | Drives the browser screen. BrowserViewModel, ArchivePreview | `BrowserViewModelTest` (82) |
|
||||
| `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() | - |
|
||||
| `BrowserScreen.kt` | 592 | BrowserScreen() | - |
|
||||
| `BrowserTopBar.kt` | 238 | The browser's app bar: title, up, the favourite star, and the search / sort / overflow actions. BrowserTopBar(), SEARCH_FIELD_WIDTH | - |
|
||||
| `BrowserUiState.kt` | 311 | One immutable description of everything the browser shows. ViewMode, SearchScope, Clipboard, UiMessage, ConflictPrompt, RootItem, Bookmark, DetailFocus, Undo, UndoMove, Overlay, BrowserUiState | - |
|
||||
| `BrowserViewModel.kt` | 2100 | Drives the browser screen. BrowserViewModel, ArchivePreview | `BrowserViewModelTest` (89) |
|
||||
| `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() | - |
|
||||
@@ -159,7 +161,7 @@ Compose theme, colours, type
|
||||
|
||||
| file | lines | what it holds | tests |
|
||||
|---|---|---|---|
|
||||
| `FileManagerApplication.kt` | 90 | Application entry point and the app's tiny object graph. FileManagerApplication | - |
|
||||
| `FileManagerApplication.kt` | 97 | Application entry point and the app's tiny object graph. FileManagerApplication | - |
|
||||
| `MainActivity.kt` | 65 | MainActivity | - |
|
||||
|
||||
## Behaviour index
|
||||
@@ -330,6 +332,18 @@ Every test name in the suite, by file: the fastest way to see whether something
|
||||
- exposes a label and exactly one root
|
||||
- reports no free space, since the framework cannot
|
||||
|
||||
### `data/SqliteBookmarkStoreTest.kt` (9)
|
||||
|
||||
- a starred folder reads back whole
|
||||
- the path comes back as the same value it was stored with
|
||||
- the newest favourite comes first
|
||||
- starring the same folder twice neither duplicates it nor moves it
|
||||
- removing a favourite takes only that one out
|
||||
- removing something that was never starred is not an error
|
||||
- an empty store reads back empty rather than throwing
|
||||
- a schema bump with no migration reports itself instead of emptying the list
|
||||
- an already current schema is left alone
|
||||
|
||||
### `data/SqliteSearchIndexTest.kt` (27)
|
||||
|
||||
- a plain query is a substring of the name, case-insensitively
|
||||
@@ -520,7 +534,7 @@ Every test name in the suite, by file: the fastest way to see whether something
|
||||
- a determinate operation shows the counter
|
||||
- starting the service puts it in the foreground with that title
|
||||
|
||||
### `ui/browser/BrowserViewModelTest.kt` (82)
|
||||
### `ui/browser/BrowserViewModelTest.kt` (89)
|
||||
|
||||
- starts on the root list
|
||||
- opening a folder lists it with directories first and natural order
|
||||
@@ -550,6 +564,13 @@ Every test name in the suite, by file: the fastest way to see whether something
|
||||
- a stale index is caught up, and the answer includes what the catch-up found
|
||||
- a changed hidden-files preference asks for a walk rather than a catch-up
|
||||
- a search from the volume list is answered everywhere, with no folder open
|
||||
- starring the folder on screen adds it to the favourites
|
||||
- starring it again takes it back out
|
||||
- a favourite outlives the view model that added it
|
||||
- the newest favourite comes first
|
||||
- a favourite whose folder has gone is kept and marked
|
||||
- opening a favourite that has gone says so instead of showing it empty
|
||||
- a library cannot be starred
|
||||
- opening a library lists the index's files of that kind and no folders
|
||||
- a library brings the index up to date behind the answer
|
||||
- a library row whose file has gone is dropped and forgotten
|
||||
@@ -611,4 +632,4 @@ Every test name in the suite, by file: the fastest way to see whether something
|
||||
|
||||
---
|
||||
|
||||
Purpose taken from the first KDoc rather than from a declaration sharing the file's name (15 of 74): `MainActivity.kt`, `ArchiveFormats.kt`, `FileNames.kt`, `ApkInspector.kt`, `OperationModels.kt`, `TextRes.kt`, `BrowserComponents.kt`, `BrowserDetailPane.kt`, `BrowserDialogs.kt`, `BrowserRoots.kt`, `BrowserScreen.kt`, `EntryIntents.kt`, `Color.kt`, `Theme.kt`, `Type.kt`
|
||||
Purpose taken from the first KDoc rather than from a declaration sharing the file's name (15 of 76): `MainActivity.kt`, `ArchiveFormats.kt`, `FileNames.kt`, `ApkInspector.kt`, `OperationModels.kt`, `TextRes.kt`, `BrowserComponents.kt`, `BrowserDetailPane.kt`, `BrowserDialogs.kt`, `BrowserRoots.kt`, `BrowserScreen.kt`, `EntryIntents.kt`, `Color.kt`, `Theme.kt`, `Type.kt`
|
||||
|
||||
@@ -308,7 +308,7 @@ requested feature in the market leader, and the permission line is a claim it ca
|
||||
| Automatic category libraries (Images / Audio / Videos / Documents / Downloads / New files / Recent) | **No** | The most-praised organisational feature in the reviews, and the one thing a brand-new user sees before they understand folders. We open on a volume list instead. This is the largest honest gap |
|
||||
| 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** | Their action list includes it; we have none |
|
||||
| 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** | Shipped in their 3.6.8 and cheap for us — the post-paste moment is when a user most needs a way back |
|
||||
| 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 |
|
||||
@@ -355,8 +355,9 @@ In the ROADMAP's own vocabulary (💡 = candidate, proposed, not scheduled; ✅
|
||||
2. ✅ **Duplicate finder** — shipped as **M12**. Asked for by name, by a user offering to pay.
|
||||
3. ✅ **Batch rename with sequence continuation** — shipped as **M14**, including the "continue from
|
||||
Ice (2)" case that the top complainant names.
|
||||
4. 💡 **Bookmarks, plus "Return to source folder" and "Copy again" after a paste.** Cheap, local,
|
||||
and the post-paste moment is the one the competitor's changelog calls out.
|
||||
4. ✅ **Bookmarks** — shipped as **M15**. 💡 **"Return to source folder" and "Copy again"** after a
|
||||
paste remain: cheap, local, and the post-paste moment is the one the competitor's changelog
|
||||
calls out.
|
||||
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
|
||||
decided cache policy and a placeholder, and record a large-folder device pass in
|
||||
|
||||
@@ -232,6 +232,30 @@ Two things this session added to the tooling notes:
|
||||
positions apart, which is also why the numbers quoted above come from a dump rather than from
|
||||
memory.
|
||||
|
||||
## The favourites session (M15)
|
||||
|
||||
Driven on the Xiaomi phone, starring the volume root — the smallest thing that can be starred, and
|
||||
the one where the label matters most, because its own name is `0`.
|
||||
|
||||
- **The star is offered exactly where it means something.** The app bar of the volume root shows it
|
||||
as *Add to favourites* (four actions now: star, search, sort, more); the home screen and a library
|
||||
show none, because neither is a folder. Tapping it flipped the description to *Remove from
|
||||
favourites* without leaving the screen, which means the state came back out of SQLite rather than
|
||||
out of the button.
|
||||
- **The home screen puts it where it belongs.** Back at the root view, a **Favourites** section
|
||||
appeared above *Libraries* with the volume's own label — *Internal storage*, not `0` — and
|
||||
`/storage/emulated/0` underneath, which is the path the details sheet would show as well.
|
||||
- **The row opens what it says.** Tapping it opened the volume with the star already filled.
|
||||
- **The row's star is the removal.** Tapping it on the home screen took the whole section away and
|
||||
said *Removed from favourites*; the libraries moved back to the top, which is the empty case a new
|
||||
user sees.
|
||||
- **Nothing was left behind.** No files were created for this pass and the one bookmark made was
|
||||
removed through the UI, so the device is as it was found.
|
||||
|
||||
Deliberately not done on the device: a favourite whose folder disappears. The case is covered by two
|
||||
JVM tests, and staging it would have meant deleting a folder on someone else's phone; the missing
|
||||
row's wording and the message are the same strings those tests assert.
|
||||
|
||||
## Not verified, and why
|
||||
|
||||
- **Long-press multi-select via adb: it works, but not every time.** `input swipe x y x y 900`
|
||||
|
||||
+7
-6
@@ -1,6 +1,6 @@
|
||||
# Roadmap
|
||||
|
||||
File Manager is built against a fixed milestone plan (**M0–M14**). This file is the
|
||||
File Manager is built against a fixed milestone plan (**M0–M15**). This file is the
|
||||
single source of truth for what is done and what remains.
|
||||
|
||||
Legend: ✅ done · 🚧 in progress · ⏳ not started · 💡 candidate (proposed, not scheduled)
|
||||
@@ -24,6 +24,8 @@ Legend: ✅ done · 🚧 in progress · ⏳ not started · 💡 candidate (propo
|
||||
| M12 | Duplicate finder | ✅ | ***Find duplicates*** **in the overflow.** Three passes, cheapest first: **size** (the index's `duplicateCandidates` query — one query and no reading at all), **the first 64 KB** of every candidate (a filter that may only rule files *out*), and the **whole file** to confirm (`domain/DuplicateFinder`, SHA-256, cancellable between files and between read buffers). Groups are ordered by reclaimable space; each header states the copies and what they cost; every row carries its folder, because identical names with no paths are unactionable; and **nothing is preselected** — the delete is the app's ordinary one, through the trash and the undo snackbar. A file that cannot be read is counted and reported rather than skipped, and the empty state says how many files were compared — or that the index has never run and there was nothing to compare. **323 JVM tests** (15 added). Verified on the Xiaomi phone against four genuine groups in its own storage; the delete was deliberately not pressed on a device that is not ours — see [EDGE_CASE_TESTING](EDGE_CASE_TESTING.md) |
|
||||
| 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) |
|
||||
|
||||
## Key decisions
|
||||
|
||||
| Decision | Choice | Rationale |
|
||||
@@ -47,8 +49,7 @@ Legend: ✅ done · 🚧 in progress · ⏳ not started · 💡 candidate (propo
|
||||
|
||||
A survey of the market leader (its features, and what its users praise and complain about) is in
|
||||
[COMPETITIVE_ANALYSIS](COMPETITIVE_ANALYSIS.md). Its gaps that cost **no** new permission are
|
||||
listed there as 💡 candidates. **Category libraries were the first of them and are now M11, the
|
||||
duplicate finder the second (M12) and batch rename with sequence continuation the third (M14)**; the
|
||||
rest — bookmarks, and the post-paste "copy again" / "return to source folder" — are proposals, not
|
||||
scheduled work, and adding one is a roadmap decision rather than a side effect of reading that
|
||||
document.
|
||||
listed there as 💡 candidates. **Four of them have since been shipped: category libraries (M11), the
|
||||
duplicate finder (M12), batch rename with sequence continuation (M14) and favourites (M15)**. What is
|
||||
left — the post-paste "copy again" / "return to source folder" — is a proposal, not scheduled work,
|
||||
and taking it on is a roadmap decision rather than a side effect of reading that document.
|
||||
|
||||
Reference in New Issue
Block a user