Test the interface as a user sees it, and let a debug build tell on itself

Fifteen rendering tests across four files are the first tests in this project that compose a screen,
and they cover the claims that used to be prose only: the app bar's star exists where there is a folder
to star and says Add or Remove according to the state, the selection bar offers Rename to several files
and Details only to one, the batch dialog spells out the names it will produce and refuses a collision
with the file named, the shared name dialog refuses a slash while it is typed, and a favourite that has
gone says "Folder not found" rather than showing a path that is not there. They run on the JVM through
Robolectric - the Compose test artifacts plus the test manifest - so they belong to the same verify.sh
gate as everything else instead of needing a device: 386 tests in total.

Writing them turned up one behaviour worth recording. The batch dialog continues the folder's series by
default, so a collision cannot be shown unless the start number is forced back to 1 - which is precisely
why the feature works in practice, and now also why two of these tests set the field explicitly.

The debug build now enables StrictMode (disk reads and writes, network, leaked closables; logged, not
fatal) and attaches LeakCanary. A file manager is the worst possible app for a blocking disk read on the
main thread, and a debug build is the last place where noticing it is still cheap. Logging rather than a
crash is deliberate: turning a slow screen into a crash on someone's phone is the worse trade. The flag
is read from the manifest, not from BuildConfig, which this build does not generate.

Performance has a number for the first time, and it immediately paid for itself. tools/measure-startup.sh
takes cold start with `am start -W` - median of several runs, one warm-up discarded because the first
launch after an install pays for dexopt - and docs/PERFORMANCE.md records 1814 ms over five runs, in the
debug build, which is the slow case by construction. StrictMode's first run then produced five violations
against that number: 492 ms resolving `app.cacheDir` inside the view-model factory, 397 ms reading free
space with StatFs for every volume inside refresh(), 477 ms of the platform loading its own typefaces,
and two 54 ms OEM font hooks. That is about 1.5 s of main-thread disk I/O in a 1.8 s start, of which
~0.9 s is ours and mechanical to move. Neither of ours is fixed here on purpose: the numbers and the two
fixes are recorded in PERFORMANCE.md, and they are the first items of the speed work.

What is not measured is stated in the same file rather than left to be assumed: folder open at 1 000 /
5 000 / 20 000 files, thumbnail paint while scrolling, and keystroke-to-result all need a `:benchmark`
module and a profileable build under the release package id, and this device waits for a confirmation
dialog on the first install of a new package id. The module is deliberately absent instead of
half-configured: build wiring nobody can run is worse than a recorded gap.

Gate green (map current, detekt, 386 tests, lint), and CHANGELOG, README, AGENTS.md, ROADMAP,
PERFORMANCE.md, EDGE_CASE_TESTING and the regenerated map carry it.
This commit is contained in:
2026-10-17 09:23:11 +02:00
parent b1bc805af0
commit 0bbee25ab3
15 changed files with 637 additions and 8 deletions
+33 -6
View File
@@ -2,7 +2,7 @@
Generated from the source by `tools/make-code-map.py` - do not edit by hand. Regenerate it after a change (`python3 tools/make-code-map.py`); `--check` exits non-zero when this file is stale.
76 source files, 12463 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.
76 source files, 12492 lines - 33 test files, 386 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`.
@@ -96,17 +96,17 @@ The browser screen: view model, state, list and grid, dialogs, selection bar
| `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` | 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` | 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() | - |
| `BrowserDialogs.kt` | 279 | Renaming several files at once, with the numbering worked out before anything moves. 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` | 591 | BrowserScreen() | - |
| `BrowserTopBar.kt` | 238 | The browser's app bar: title, up, the favourite star, and the search / sort / overflow actions. BrowserTopBar(), SEARCH_FIELD_WIDTH | - |
| `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` | 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() | - |
| `IncomingRequest.kt` | 46 | What another app asked this one to do. IncomingRequest | - |
| `SelectionBar.kt` | 176 | The contextual action bar shown while entries are selected. StorageAccessPrompt(), SelectionBar() | - |
| `SelectionBar.kt` | 176 | The contextual action bar shown while entries are selected. StorageAccessPrompt(), SelectionBar() | `SelectionBarTest` (2) |
| `SortSheet.kt` | 128 | Sort key, direction, hidden files and list/grid — all in one sheet. sortKeyLabel(), SortSheet() | - |
## ui/viewer/
@@ -161,7 +161,7 @@ Compose theme, colours, type
| file | lines | what it holds | tests |
|---|---|---|---|
| `FileManagerApplication.kt` | 97 | Application entry point and the app's tiny object graph. FileManagerApplication | - |
| `FileManagerApplication.kt` | 126 | Application entry point and the app's tiny object graph. FileManagerApplication | - |
| `MainActivity.kt` | 65 | MainActivity | - |
## Behaviour index
@@ -534,6 +534,28 @@ 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/BrowserDialogsTest.kt` (5)
- the batch dialog spells the names out before anything moves
- a name the folder already holds is named, and the button is off
- files that already hold the names are said to be done
- a name with a slash is refused while it is typed
- a plain name is accepted
### `ui/browser/BrowserRootsTest.kt` (4)
- a starred folder shows its label, where it is, and how to remove it
- a favourite that has gone says so instead of its path
- a library the index knows nothing about shows no count
- a library with an index behind it shows what it holds
### `ui/browser/BrowserTopBarTest.kt` (4)
- inside a folder the star invites the folder to be starred
- a starred folder says so instead
- a library or the root list has nothing to star
- the star gives way to the search field
### `ui/browser/BrowserViewModelTest.kt` (89)
- starts on the root list
@@ -626,6 +648,11 @@ Every test name in the suite, by file: the fastest way to see whether something
- while providing, tapping a file chooses it instead of opening it
- while providing, a folder still navigates so the file can actually be reached
### `ui/browser/SelectionBarTest.kt` (2)
- several files are offered a rename and not the details
- a single file keeps both
### Test files with no source file of the same name
- `ProjectInvariantsTest.kt` (4 tests) - ProjectInvariants, ProjectInvariants
+3
View File
@@ -264,6 +264,9 @@ row's wording and the message are the same strings those tests assert.
opens the row instead of selecting it. Driving it manually on the API 35 AVD then paid for the
caution: the compress/extract pass below found the selection bar clipping its own actions. The
archive engine itself is covered by end-to-end JVM tests, including a zip-slip archive.
- **Folder open, thumbnail paint and search timings.** Only cold start has been measured — see
[PERFORMANCE](PERFORMANCE.md), which also records why the rest needs a benchmark module and a
profileable build, and why that module does not exist yet.
- **The SAF folder-picker round trip.** Driving the system picker and granting a tree was
not attempted; the code path is unit-tested against a stand-in `DocumentsProvider`.
- **Image viewer and thumbnails.** Verified on the AVD in M4/M5; not repeated on the phone.
+68
View File
@@ -0,0 +1,68 @@
# Performance
What has been measured, on what, and — explicitly — what has **not** been measured at all. The rule
this file exists to enforce comes from the competitive survey: the market leader's top complaint is
thumbnails that load late, its own support reply to a speed complaint was *"your SD card is slow"*,
and the lesson recorded there was to measure what was measured and never close a performance
complaint by blaming the hardware.
Until this file existed, the app had **no performance number of any kind**.
## Cold start
| build | device | runs (ms) | median |
|---|---|---|---|
| debug, `1.0.0-debug` | Xiaomi phone, Android 15 | 1 758 · 1 773 · 1 814 · 1 926 · 2 043 | **1 814 ms** |
Taken with `./tools/measure-startup.sh 5`: `am start -W`, and one warm-up start discarded because the
first launch after an install pays for dexopt and folding that into a median is how a baseline gets
written that nothing can meet.
The debug build is deliberately the *slow* case — StrictMode on, LeakCanary attached, no R8 — so this
is an upper bound rather than what a user sees. **The release number is the one worth publishing, and
it has not been taken yet.** Until it is, no speed claim belongs in the README or the store listing.
### What StrictMode found on the first cold start
The same debug build now logs StrictMode violations, and the first cold start produced five, against a
1 814 ms start:
| duration | where | ours? |
|---|---|---|
| 492 ms | `BrowserViewModel.Factory` → `app.cacheDir` | **yes** — `Context.getCacheDir()` checks and creates the private cache directory while the view model is being created, on the main thread |
| 397 ms | `BrowserViewModel.rootsOf` → `StorageProvider.space` → `VolumeRepository.stat` | **yes** — free space for every volume is read with `StatFs` inside `refresh()`, which the screen calls from a `LaunchedEffect` |
| 477 ms | `FileManagerTheme` → `typography = FileManagerTypography` | no — the platform loading its own typefaces on first composition |
| 54 ms × 2 | `miui.util.font.FontSettings` via `MainActivity.onCreate` | no — the OEM's font hook doing `File.exists` while the activity is created |
That is **~1.5 s of main-thread disk I/O inside a 1.8 s start**, of which **~0.9 s is ours**. Both of
ours are mechanical to move and neither is fixed yet: the cache directory is only needed when a preview
copies something, and the free-space line on the roots screen can arrive a frame later. They are the
first items of the speed work.
## Not measured yet, and why
| number | how it would be taken | why it is not here |
|---|---|---|
| Folder open, 1 000 / 5 000 / 20 000 files | macrobenchmark `StartupTimingMetric` over a fixture volume, or `TraceSectionMetric` around the listing | needs a `:benchmark` module and a profileable build installed under the **release package id**; on this MIUI phone the first install of a new package id waits for a confirmation dialog, so it cannot run unattended |
| Thumbnail paint while scrolling a photo grid | `FrameTimingMetric` | same |
| Keystroke → search result | `TraceSectionMetric` around the query and the index read | same |
The module does not exist yet, deliberately: half-configured build wiring that nobody can run is worse
than an honest gap. It is the next measurement step, and it belongs with the baseline-profile work,
which needs the same module.
## What is measured instead, meanwhile
The gate measures things that are not latency but do not rot: the test count, lint, detekt against its
baseline, and whether `docs/CODE_MAP.md` still matches the tree. Those are the numbers that can be
kept true by automation; the three above need a device and a decision about which device.
## Two rules
- **No number that was not taken from a real build on a real device.** Not from a debug build
pretending to be release, and not from reading the code.
- **No performance change judged by reading the code.** The bigger-thumbnail change (28 → 48 dp in a
row, 40 → 96 dp in a grid) is the standing example: it may be exactly right, and nobody measured
either side of it. `docs/COMPETITIVE_ANALYSIS.md` had already written "a thumbnail that is bigger and
slower is strictly worse than one that is smaller and instant" — which is a hypothesis until it is a
number.
+1
View File
@@ -34,6 +34,7 @@ Legend: ✅ done · 🚧 in progress · ⏳ not started · 💡 candidate (propo
| Storage access | `MANAGE_EXTERNAL_STORAGE` first, SAF fallback | Google Play explicitly permits "All files access" for file managers; SAF keeps the app usable if the user declines |
| Connectivity | Local-first (no `INTERNET` in v1) | Preserves the "nothing leaves the device" claim and keeps the Data Safety form trivial |
| File sharing | Receives `SEND`/`SEND_MULTIPLE`, serves `GET_CONTENT`/`OPEN_DOCUMENT`, and deliberately does **not** register `ACTION_VIEW` | A file manager should be able to receive a file and to supply one, and neither costs a permission. Registering as a viewer as well would put Plain Files in the "open with" chooser the app itself uses, offering it back to itself |
| Performance | Measured, not assumed: [PERFORMANCE](PERFORMANCE.md) holds the numbers and `tools/measure-startup.sh` takes one of them | The survey's top complaint is latency, so a speed claim has to be a number — and a number nobody can reproduce is worse than none |
| Quality gate | `tools/verify.sh` — map check, detekt, tests, lint; `--release` adds R8 — run by CI, by the pre-push hook and by hand | One command that means the same thing in all three places. The detekt baseline is what makes adopting static analysis possible without a backlog-clearing week, while still failing on anything new |
| Monetisation | One-time paid (€4.49; €2.29 launch price), set in the Console | No Billing client, no `BILLING` permission, no purchase code — which is what keeps "nothing leaves the device" true in the shipped APK. Free → paid is impossible after the first upload, so the price must be set beforehand. See [MONETISATION](MONETISATION.md) |