Give a video row a frame of the video, and fix what R8 was hiding

A video row showed a film-strip icon, and a phone holds more video than anything else, so a column of identical icons said nothing about which clip was which. Images and videos now take the same path - EntryIcon hands Coil the file, and for a video the request asks for a frame - which cost exactly one dependency: io.coil-kt.coil3:coil-video, at the same version as the Coil already in use, whose own POM adds nothing that Coil did not already bring (coil-core, kotlin-stdlib, core-ktx). No ExoPlayer, no media3, and no new permission: the decoder is the platform's own MediaMetadataRetriever.

Two details are decisions rather than defaults. The frame is taken one second in, because the first frame of a recording is very often black or the middle of a fade-in, and a thumbnail of a black rectangle is no more useful than the icon it replaced. And a decode that fails - an unsupported codec, or a file only pretending to be a video - settles on the icon rather than leaving an empty square, which the row remembers so it is not retried on every recomposition.

The dependency also nearly shipped a feature that did not work. coil-video registers its decoder through a META-INF/services entry, and in a debug APK that is enough: the file and the class it names are both there, and frames decoded. In the release APK, R8 removes both, because a class mentioned only from a resource looks unused to it. So the feature worked in every build that had been looked at and would have failed silently in the one users install - the worst shape a bug can have, since it needs no code change to "fix" and would have been reported as "video thumbnails don't work" against a build nobody had run. The factory is now registered explicitly against an ImageLoader in FileManagerApplication, which makes it a code reference R8 cannot drop, and the release APK was installed and driven to the Videos library to confirm the frames decode there too. That also closes the gap noted two changes ago, that no ImageLoader was registered at all; its cache sizes are left to Coil deliberately, since they are derived from the device's heap and a number written here would only be a guess.

The same run found a smaller thing that no test could have: opening the search field from inside a library left the scope on "this folder" - the hint read "Search this folder" and a folder toggle sat beside it - while the search in fact ran everywhere, because the view model has always known that a library is not a folder and answered from the index instead. The behaviour was right and the screen was wrong, which is the same shape as the volume-list bug fixed earlier and the same root cause: the switch-on-open asked whether this was the root list rather than whether a folder was open. It now asks for a path, and the scope toggle appears only when there is a folder to choose about, because its two settings did the same thing otherwise. Every other use of isRootView was then checked for the same conflation and none was found. One test was added for the search behaviour; the scope mismatch itself is not testable, being a disagreement between two correct pieces.

324 JVM tests, lint clean. Verified on the phone in both builds: two clips made with ffmpeg (a testsrc2 pattern and SMPTE bars) drew their own patterns as thumbnails, beside a real 4.3 MB video from the device's own storage, and the demo library's three zero-filled stand-in .mp4s fell back to the icon - which exercised the failure path with material that happened to be lying around. The release APK is 2 294 021 bytes and still declares no INTERNET. Not done: the store screenshots still show the home screen from before the library views, and the release build running on a device is in nobody's checklist - which is exactly why the R8 problem survived until now, and is the first thing worth adding.
This commit is contained in:
2026-10-17 01:40:35 +02:00
parent 05a71f12bf
commit 47e48f054e
12 changed files with 226 additions and 36 deletions
+26 -1
View File
@@ -134,9 +134,34 @@ through the prompt, because the prompt was already covered in the M7 session.
| The duplicate scan | **Correct.** Tapping *Find duplicates* in the overflow ran the scan and produced real groups: `DOC-20251121-WA0001.zip` and `Schwarzhofen.zip` at 10.5 MB each (identical content in `Android/media/…` and `Download/`), a PDF duplicated at 5.3 MB, another PDF present **three** times at 761.3 KB. Group headers read "2 copies · 10.5 MB" above "10.5 MB could be freed", each row carried its **folder path**, and the groups were ordered by reclaimable space |
| Ticking a copy | **Correct.** The row highlighted and the bar action appeared as **"Delete 1 copy"** — the plural resource agrees with the count |
| Closing the scan | **Correct.** `X` returned to the browser with nothing changed |
| Video thumbnails | **Correct.** Two clips made for the purpose (`ffmpeg`: a testsrc2 pattern, then SMPTE bars) were pushed into the demo folder and both drew **their own pattern** as the row's thumbnail — a frame from inside the file, not a poster and not the icon — beside a real 4.3 MB video from the device's own storage. The library's three zero-filled stand-in `.mp4`s cannot be decoded and **fell back to the film icon**, which exercised the failure path with material that happened to be lying around |
| The same rows on the **release** APK (R8) | **Correct, after a fix — and this is the one that mattered.** `coil-video` registers its decoder through a `META-INF/services` entry, and the release APK contained neither that file nor the class it named: frames would have decoded in debug and quietly fallen back to icons for anyone who installed the app. Registering the factory explicitly in `FileManagerApplication` fixed it, and the release build was installed and driven to the Videos library, where all four rows behaved as the debug ones had |
| The search field opened from a library | **Wrong, and fixed.** The hint read *Search this folder* and a folder toggle sat beside it, while the search in fact ran everywhere — the view model has always answered from the index when there is no folder. See the findings below |
| Crashes | **None.** `logcat -b crash` contained no entry for the package across the whole session |
### Two findings worth keeping
### Four findings worth keeping
- **A ServiceLoader entry is not a contract, and R8 is the difference between a debug build and the
one people install.** `coil-video` registers its decoder with Coil through a `META-INF/services`
file. In the debug APK both that file and the class it names are present, and video thumbnails
worked. In the **release** APK neither was: R8 removes a class that is mentioned only from a
resource, because by its reckoning nothing uses it, and the resource goes with it. The feature was
therefore working in every build that had been looked at and would have failed silently in the one
that ships — the worst possible shape for a bug, since it needs no code change to "fix" and would
have been reported as "video thumbnails don't work" against a build nobody had run. Registering the
decoder explicitly against an `ImageLoader` turns it into a code reference, and the release APK was
then installed and driven to confirm it. The lesson generalises: **audit the release build, not the
debug one**, and never trust a registration that happens by naming a class in a file.
- **The search field lied about its own scope inside a library, and only a run could show it.**
Opening the field from a library left the scope on "this folder": the hint said *Search this
folder* and offered a folder/public toggle, while the search actually answered from the index over
every volume. The behaviour was right and the *screen* was wrong — the same shape of bug the
volume-list fix had, and the same root cause: the switch-on-open asked whether this was the root
list instead of whether a folder was open, and a library is not the root list but has no folder
either. The toggle is now shown only when there is a folder to choose about, because its two
settings did the same thing otherwise. Nothing in the suite could have caught this: it was a
mismatch between two correct pieces, which is what a device pass is for.
- **The library views are device-wide, so they cannot be photographed on a real phone.** The
`Images` list showed the owner's own files — names and thumbnails included — because a library
+1
View File
@@ -22,6 +22,7 @@ Legend: ✅ done · 🚧 in progress · ⏳ not started · 💡 candidate (propo
| M10 | Store assets | ✅ | Name decided: **Plain Files** (one string; `applicationId` already locked). `tools/make-store-assets.sh` renders the 512×512 **RGBA** icon and the 1024×500 **RGB** feature graphic from the app's own vectors, so the store icon *is* the launcher icon; `tools/make-store-screenshots.sh` normalises `adb` captures to Play's 2:1 cap per form factor (phone crop default 93/130 px, tablets 0). [STORE_LISTING](STORE_LISTING.md) carries length-checked copy (name 11/30, short 67/80, full 2730/4000), the permissions paragraph the `MANAGE_EXTERNAL_STORAGE` declaration requires, and the graphics table. The feature graphic's copy was verified to survive Play's centre-square crop by cropping the rendered PNG. **Screenshots captured:** 5 phone (1080×2160), 4 on a 7-inch tablet (1200×1920) and 4 on a 10-inch tablet (2560×1600), all 24-bit RGB, inside the 2:1 cap. The tablet sets came from AOSP AVDs. The normaliser refuses a capture whose app area is blank, which is how a pure-background capture was caught before it reached the listing |
| M11 | Library views | ✅ | A **Libraries** section on the root screen — Images, Videos, Audio, Documents, Archives, Recent — each row carrying how many files the index knows of that kind, each opening an ordinary listing (thumbnails, sort, multi-select, details, the existing viewers) answered from the index instead of from a directory. `core/FileCategory` groups `FileKind`s and is asserted to leave `DIRECTORY`, `APK` and `OTHER` out deliberately; `SearchIndex` gained `byKind`/`recent`/`kindCounts`, and SQLite schema **v3** adds the derived `kind` column — the migration rebuilds the table *and* drops the volume stamps, because a stamp whose rows have gone would make the next search trust an empty index. A library obeys the indexed search's own two rules: the answer comes first with the walk behind it, and every row is confirmed against the filesystem before it is shown. Not included, and recorded in the CHANGELOG as decisions rather than omissions: Downloads and New files, and restoring a library across process death. **308 JVM tests** (19 added here) |
| 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** |
## Key decisions
+9
View File
@@ -56,6 +56,15 @@ thumbnails have something to show), a handful of documents, sized stand-ins for
audio so the storage-usage screen has something worth ranking, and a real ZIP so the extract
action can be exercised. Every name and every pixel is fake by construction.
One consequence worth knowing before shooting the Videos library: the video stand-ins are
**zero-filled**, so they cannot be decoded and photograph as the fallback film icon rather than as a
frame. A screenshot that shows video thumbnails needs a real clip, which is one command:
```bash
ffmpeg -f lavfi -i "testsrc2=duration=3:size=320x240:rate=15" -pix_fmt yuv420p clip.mp4 # synthetic, no camera needed
adb push clip.mp4 /sdcard/Download/PlainFiles-Demo/Video/
```
| Asset | Play's spec | Result | Source |
|---|---|---|---|
| App icon | 512×512, 32-bit PNG with alpha, ≤ 1 MB | `store-assets/icon-512.png` — 512×512, **RGBA**, ~5 KB | `tools/store-assets/icon.svg`, mirroring `ic_launcher_background.xml` + `ic_launcher_foreground.xml`. Background, paths and opacities are identical, so **the store icon is the launcher icon** rather than a lookalike that drifts. |