Harden the thumbnail path: versioned cache keys, the icon as the placeholder, and a decided cache

Three things, each a failure the thumbnail path could have caused. First, correctness: Coil keys caches by the request, and a request built from a File is keyed by its path, so a picture replaced or edited outside the app kept showing its previous self - in a file manager that reads as data loss. core/ThumbnailKey now carries path, size and modification time, is built from the numbers the listing already has (no stat, so it stays off the main thread's disk work), and has four tests that name the two ways a file changes. The request carries it as the memory and disk cache key.

Second, the placeholder: the row drew an empty square while a thumbnail decoded, which a thousand-row folder makes very visible. The kind icon is now drawn first and the picture paints over it, so every row shows what it is immediately and the thumbnail replaces it without a flicker.

Third, the cache is decided rather than inherited: memory only, at 15% of the heap, and no disk cache at all - the sources are local files that decode cheaply, and a disk copy is a second copy that survives the file changing. Coil's default would have kept up to 2% of free space of stale thumbnails.

Measured on the phone and recorded in EDGE_CASE_TESTING: a 6200-file folder holding 300 real JPEGs opened and scrolled five times with no crash, no OOM and text rows keeping their icons (PSS 86.6 MB), and a folder of 300 real JPEGs where every row drew its own picture and eight swipes down and one back left PSS at 149.6 MB with the cache full and stable. Stated as not measured: jank on this screen (this device reports no frame durations) and a 20000-image folder.
This commit is contained in:
2026-10-17 18:29:59 +02:00
parent 4846454645
commit 1371407435
7 changed files with 178 additions and 26 deletions
+12 -4
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.
77 source files, 12793 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.
78 source files, 12856 lines - 34 test files, 390 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`.
@@ -25,6 +25,7 @@ Pure Kotlin, **no Android imports**: paths, file names, kinds, sorting, formatti
| `NameMatcher.kt` | 63 | The in-folder search matcher. NameMatcher | `NameMatcherTest` (10) |
| `SortSpec.kt` | 82 | A complete, serialisable description of the ordering of a directory listing. SortKey, SortDirection, SortSpec, NaturalOrder | `SortSpecTest` (8) |
| `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) |
@@ -93,7 +94,7 @@ 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() | - |
| `BrowserComponents.kt` | 317 | 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() | `BrowserDialogsTest` (5) |
@@ -161,7 +162,7 @@ Compose theme, colours, type
| file | lines | what it holds | tests |
|---|---|---|---|
| `FileManagerApplication.kt` | 126 | Application entry point and the app's tiny object graph. FileManagerApplication | - |
| `FileManagerApplication.kt` | 151 | Application entry point and the app's tiny object graph. FileManagerApplication | - |
| `MainActivity.kt` | 65 | MainActivity | - |
## benchmark/
@@ -299,6 +300,13 @@ Every test name in the suite, by file: the fastest way to see whether something
- ancestor and descendant are strict
- is a value type
### `core/ThumbnailKeyTest.kt` (4)
- the same file with the same size and timestamp is the same cache entry
- a replaced file is a different cache entry even though its path is unchanged
- a file edited in place is a different cache entry even though its size is unchanged
- two files with the same size and timestamp do not share an entry
### `core/TransferPlannerTest.kt` (5)
- no conflict copies straight in
@@ -667,4 +675,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 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`
Purpose taken from the first KDoc rather than from a declaration sharing the file's name (15 of 77): `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`
+31
View File
@@ -256,6 +256,37 @@ Deliberately not done on the device: a favourite whose folder disappears. The ca
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.
## The thumbnail hardening session
The one screen that had never been pushed past what a photo album happens to contain: a folder that is
mostly *not* pictures, next to a folder that is nothing but pictures. Both built on the phone, both driven
by hand through `adb`.
| pass | result |
|---|---|
| **6 200 files, 300 of them real JPEGs** (`AAThumbs`) — opened and scrolled five times | **Correct and stable.** No crash, no `OutOfMemoryError`, no decode failure that left an empty square; text rows kept their document icon and the JPEGs further down would have had their frames. PSS 86.6 MB while scrolling the text rows. |
| **300 real JPEGs** (`AAImages`) — opened, scrolled eight swipes down and one back | **Correct.** Every row drew its own picture, not an icon and not a neighbour's picture — 300 copies of one photo, so a cache-key collision would have been invisible here and is covered by `ThumbnailKeyTest` instead. No crash, no OOM. PSS grew 83.9 MB → 149.6 MB as the memory cache filled, then stayed. |
Two things that pass is *not* evidence of:
- **It is not a jank measurement.** This device reports no frame durations at all (`docs/PERFORMANCE.md`),
so "it scrolled" is all that can be said here. The emulator path reports `frameDurationCpuMs`, so the
number that would matter can be taken there or on a newer phone.
- **It is not a 20 000 *image* folder.** The 6 200-file folder held 300 images, and 300 is where the memory
cache tops out. A folder of 20 000 pictures is the case that would test the cache bound rather than the
listing, and it is not staged — writing 20 000 files through MIUI's storage layer took five minutes for
6 000, so it is a fixture to build once and keep, not something to improvise.
What the pass did change, before it was run:
- thumbnails are keyed by path **and size and modification time** (`core/ThumbnailKey`), so a file that is
replaced or edited outside the app re-decodes instead of showing the previous picture — the one failure
a cache can cause that a user would mistake for data loss;
- the icon is drawn *under* the thumbnail rather than the row showing an empty square while decoding;
- the image loader's caches are decided explicitly: **memory only**, at 15 % of the heap, and no disk
cache — a disk cache would hold a second copy of pictures that are already on the device, and survive
the file changing.
## Not verified, and why
- **Long-press multi-select via adb: it works, but not every time.** `input swipe x y x y 900`
+6 -3
View File
@@ -73,9 +73,12 @@ release should not go out without.
resolve the delta. One run on a physical API 33+ device — `compile --reset` versus
`compile -m speed-profile` — turns it from "bundled" into a number in
[PERFORMANCE](PERFORMANCE.md).
2. **Thumbnail hardening before the next release** — an explicit Coil `ImageLoader` with a decided cache
policy and a placeholder, plus a large-folder device pass recorded in
[EDGE_CASE_TESTING](EDGE_CASE_TESTING.md). The only item here that touches code changed late in 1.0.
2. **Thumbnail hardening** — **done**: thumbnails are keyed by path *and* size *and* modification time
(`core/ThumbnailKey`, four tests), the icon is drawn under the thumbnail instead of an empty square
while decoding, and the image loader's caches are decided explicitly — memory only at 15 % of the heap,
no disk cache. The device pass is in [EDGE_CASE_TESTING](EDGE_CASE_TESTING.md): 6 200 files with 300
images and a folder of 300 images, correct and stable, no crash or OOM. A 20 000-*image* folder is
still unmeasured, and jank on this screen needs a device that reports frame durations.
3. **The scaling numbers** — `./tools/benchmark-fixture.sh --count 20000` and the existing benchmarks:
answers whether a full SD card is a performance problem before a user with one finds out.
4. **Copy to… / Move to… (M16)** — the comfort feature, specified below.