Files
file-manager/docs/EDGE_CASE_TESTING.md
T
SoftwareApp 0bbee25ab3 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.
2026-10-17 09:23:11 +02:00

283 lines
24 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# On-device testing
What has actually been run on hardware, what it found, and what is still unverified. Keep
this file honest: an untested claim is a bug waiting to be reported.
## Devices used
| Device | Android | Notes |
|---|---|---|
| Xiaomi **M2101K7AG** (Redmi, `sunny`), MIUI V140 | 12 (API 31), arm64-v8a | A real phone with a real 55 GB library. Auto-rotate was on, so runs happened in both orientations. |
| AOSP `phone` AVD | 12 (API 31), x86_64 | Software emulation — the host exposes no `/dev/kvm`. Used from M0–M6. |
| AOSP `phone35` AVD | **15 (API 35)**, x86_64 | Software emulation, same reason. Added for the Android 13+ surface the phone cannot reach: the permission model, mandatory edge-to-edge, predictive back. |
Connecting a phone to this dev container needs one fix: the USB node is created
`root:plugdev 0660`, and the container's user is not in `plugdev`, so adb cannot open it.
An udev rule widening the mode for Android vendors fixes it
(`/etc/udev/rules.d/51-android.rules`), after which `adb devices` sees the phone.
## Verified on the real device
| What | Result |
|---|---|
| First run, no permission granted | The "Choose a folder" prompt appears with both routes offered: *Grant full access* and *Choose folder*. Correct — the app has no source yet, so the SAF fallback is the honest state. |
| Granting "All files access" while the app is alive | The app picks it up on resume and switches from the prompt to the roots view, showing **"Internal storage · 50.0 GB free"** (matching `df`). The lifecycle refresh works. |
| Browsing real storage | Real folders listed alphabetically with correct icons and breadcrumbs ("Internal storage / Download"). Titles render "Internal storage", not the raw volume name. |
| Text editor, read | Tapping `a.txt` opened the editor showing its real content. |
| Text editor, **write** | Editing and tapping Save persisted to the device — verified independently with `adb shell cat`. |
| Storage usage | DCIM 2.7 GB / 668 files, Android 1.2 GB / 5925 files, Music 1.2 GB / 43 files, Pictures 866.4 MB / 5849 files, … sorted largest first with per-row share bars and correct plurals. |
| Revoking "All files access" mid-use | Returned to the folder prompt without crashing; the app kept running. Graceful degradation confirmed. |
| Landscape | The whole UI adapts (the phone auto-rotated mid-session): app bar, list, breadcrumbs and dialogs all laid out correctly. |
| `BACK` from the roots view | Exits the app, which is the expected Android behaviour. |
## Bugs this session found — both fixed
1. **The first-run copy was wrong.** The prompt read *"Full access was declined, so pick a
folder…"* on a device where the user had never declined anything. Reworded to
*"Without full access you can still work inside one folder you choose here."*, which is
true whether the user declined or simply has not granted yet. Re-verified on device.
2. **The storage-usage screen took ~45–60 seconds to show anything.** Measuring the root of
a real 55 GB volume blocked on the whole walk with only a spinner for company, which
reads as a hang. Two changes:
- `UsageAnalysis` now passes the `FileEntry` down the recursion instead of re-`stat`ing
every node (the parent's listing already says whether each child is a folder), so the
walk costs one listing per directory and nothing more.
- Rows are **published as they are measured** through an `onProgress` callback, and the
ViewModel appends them, re-sorting as it goes, so the biggest folders appear while the
rest of the tree is still being walked.
The UI never froze — the progress bar kept animating throughout, which confirms the I/O
stays off the main thread — but "slow and silent" is still a defect.
## The Android 15 (API 35) session
The phone is on Android 12, and the checks that matter most for a release are precisely the
ones Android 12 cannot answer. An API 35 AVD was installed for that reason
(`sdkmanager --install 'system-images;android-35;default;x86_64'`, AVD on the `pixel_6`
profile, 1080×2400 @ 420dpi — the phone's geometry), rather than waiting for a public test
track to report back.
| What | Result |
|---|---|
| Cold start and browse | Fine. Storage root, then "Internal storage / Download", with the real file and size shown. |
| Edge-to-edge, which API 35 enforces | **Correct.** The status and navigation bars draw over the app background and the app bar sits *below* the status bar, so the `Scaffold` insets are being applied. This was the single most likely Android 15 regression. |
| 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". |
| 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. |
### The bugs this session found
**On Android 13+ the app asked for no runtime permission at all, so the progress notification
was dead.** `StorageAccessPolicy.missingPermissions` returned an empty list from API 33 —
deliberately, with a test asserting it — on the reasoning that "all files access" is a special
permission and browsing therefore needs no runtime grant. True as far as browsing goes, but the
early return also dropped `POST_NOTIFICATIONS`, and nothing else requested it: the manifest
declared it and the service posted it, so it looked implemented, while `dumpsys` showed
`granted=false` and `importance=NONE`. No crash and all 203 tests green — the feature was
simply invisible, and unrecoverable, because Android will not let a user grant in Settings a
permission the app has never asked for. The four `READ_MEDIA_*` declarations were in the same
position, and the manifest comment claiming they backed thumbnails on Android 13+ was wrong:
Coil loads thumbnails and the viewers from a real filesystem path. Fixed by removing those
declarations and requesting the notification grant alongside the storage grant; a re-run on the
same AVD shows the dialog, `importance` `NONE → DEFAULT`, and the notification lifecycle above.
Details in [CHANGELOG](../CHANGELOG.md).
**The selection bar drew three of its own actions off the screen.** With a single archive selected
the bar holds ten icon buttons, which at the 48 dp minimum touch size need ~480 dp against the
411 dp this window has. `BottomAppBar` is a plain `Row` and does not scroll, so the surplus was
laid out past the right edge and clipped: the row measured Copy at x=167 through Compress at
923–980 — Compress already squeezed from 63 px to 57 — and then simply stopped, leaving "Extract
here", "Select all" and "Clear selection" unreachable. The same selection in a 2400 px landscape
window showed all ten, which isolates it to width: nothing was wrong with the logic,
`ArchiveFormats.isExtractable` had judged the `.zip` correctly all along, and the app's own
archive feature merely looked absent on the shape of phone most people hold. Wrapping the actions
in a scrollable row makes every one of them reachable again, verified by swiping the bar in
portrait and seeing all ten appear. The row still *scrolls* on a 411 dp phone rather than showing
everything at once, which is honest but not ideal — moving the two least-used actions (Select all,
Clear selection) into the overflow menu would be the better fix, and is left as deliberate
follow-up rather than bundled in here.
**`.tar.bz2` and `.tar.xz` only half-extracted.** `ArchiveFormats.formatOf` tested `.tar.gz`/`.tgz`
first, as a combined format, but then matched `.bz2` and `.xz` *before* it ever considered the
`.tar` in the middle of the name — so a `.tar.gz` unpacked to its files while a `.tar.bz2` was
read as one compressed file and yielded a `.tar`. Driving the sweep made it exact: extracting
`z4.tar.bz2` left `z4/z4.tar`, raw `ustar` headers and all, beside `z3.tar.gz`'s fully unpacked
`z3/a.txt` and `z3/sub/b.txt`. `TAR_BZIP2` and `TAR_XZ` now exist alongside `TAR_GZIP`, and
detection consumes the whole tar suffix before the bare compressor. The suite could not have
caught this: only ZIP, GZIP, XZ and 7z had round-trip tests, so neither plain `TAR` nor
`TAR_GZIP` did, and there was nothing for the asymmetry to contradict. All four have one now.
The lesson is the one the phone already taught twice: a green test suite says the code does
what the tests describe, not that the app does anything on a device. Both times the gap was a
user-visible promise the code no longer kept — once in a dialog, once in a permission.
## The library and duplicate session (M11)
Run **2026-10-17** on the same **Xiaomi M2101K7AG** (Android 12, MIUI), with the debug build
(`de.softwareapp_hb.filemanager.debug`, installed with `adb install`). The device was **cold** for
the index: the app had never run on it, so "the first tap on a library starts a walk" was tested as
a first run rather than as a re-run. First-run permissions were granted from the host
(`appops set … MANAGE_EXTERNAL_STORAGE allow`, `pm grant … READ_EXTERNAL_STORAGE`) rather than
through the prompt, because the prompt was already covered in the M7 session.
| What | Result |
|---|---|
| Home screen, before any index exists | **Correct, and the important part is what is absent.** The **Libraries** section renders above the volume with all six rows and their icons, and **no counts at all** — not a row of zeroes. That is the design: an index that has never run cannot say "you have no photos" |
| Opening a library on a cold index | **Correct.** Title `Images`, no breadcrumbs, no new-folder action, an empty listing, and the honest status row: **"Indexing 300 files…"** with a progress bar and Rebuild beside it. The answer came first and the walk ran behind it |
| The walk finishing | **Correct.** The status row became **"Index updated 2026-10-17 01:01"**, and the library filled with real files and real thumbnails |
| Counts after the walk | **Correct.** Images **6238**, Videos 169, Audio 196, Documents 160, Archives 5, and **Recent uncounted** — the one library that is a window rather than a set, which is what the code says it should be |
| Tapping a file inside a library | **Correct.** A tap opened the image viewer with that file's name in its bar, and `BACK` returned to the library listing with the index row and the scroll position intact |
| 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 |
### 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. That lesson
is now a step rather than a note — [PLAY_RELEASE_CHECKLIST](PLAY_RELEASE_CHECKLIST.md) § 4 carries
the install commands, the paths to walk, and what to do when something differs.
- **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
is not a folder and does not respect one. The M10 rule ("never shoot over a real device's
storage") now applies to the *home screen too*, since its counts expose how many files of each
kind the device holds. See [STORE_LISTING](STORE_LISTING.md).
- **MIUI blocks `adb install` twice over, and both gates are on the device, not in the tooling.**
Developer options needs *Installieren über USB* enabled, and each install then waits for a
confirmation dialog (`com.miui.securitycenter/…AdbInstallActivity`) that times out on its own —
which is what the bare `INSTALL_FAILED_USER_RESTRICTED: Install canceled by user` means. Worth
knowing before blaming a build. A second, cheaper lesson came from this session: **do not run
`assembleDebug` and `adb install` in the same parallel batch.** They raced, the install took the
previous APK, and the new menu entry was missing while the host APK contained it — a confusing
twenty minutes caused entirely by tooling, not by code.
### Deliberately not done on this device
- **The duplicate *delete* was never pressed.** The scan is read-only and safe to run on someone
else's phone; deleting files is not, and the delete itself is the ordinary, already-covered
delete path (trash + undo + the platform dialog where it applies). The selection UI was driven
to the point of the action label and then left untouched.
- **The walk was not timed end to end** on a large volume. It finished inside the 20 seconds this
session waited, but that says nothing about a phone with a far larger library; the M7 note about
a 45–60 s usage walk is the honest precedent.
- **The 7-inch and 10-inch tablet layouts with the new section** were not re-shot or re-checked:
the AVDs have no `/dev/kvm` here, and the two-pane root list is where the libraries now appear
first.
## The batch rename session (M14)
Driven on the Xiaomi phone against a purpose-made folder — `/sdcard/AAABatchRename`, holding
`alpha.txt`, `beta.txt`, `gamma.txt` and an existing series `series (1).txt` / `series (2).txt` —
because the one thing worth proving here is the **continuation**, and that needs a folder that
already holds a series. Every step went through the UI with `adb input`, and each claim below was
read back off the device rather than off the screen.
- **The dialog continues the series on its own.** Three files selected (long-press, then a tap each —
the M4 tap-to-toggles behaviour), Rename from the action bar, `series` typed into the name and the
**Start at** field moved from 1 to **3** by itself, with the preview reading *Will be renamed to
series (3).txt, series (4).txt, series (5).txt*. Nothing but the names in the folder was consulted,
and those are the listing on screen, so the dialog and the view model cannot disagree about them.
- **The work is exactly what the dialog promised.** `ls` afterwards showed `series (1) … (5)`, the
two originals untouched, and `cat` showed `series (3).txt` holding `alpha` and `series (4).txt`
holding `beta` — the renames *moved* the files rather than copying them.
- **One undo step, through the snackbar.** A second batch was renamed to `clip (1..3)`, the snackbar
read *Renamed 3 files* with **Undo**, and a single tap put all three names back, contents intact
through the round trip (`series (5).txt` still held `gamma`).
- **Both refusals are visible before anything can be pressed.** With `series` typed and the start
forced back to 1 over a folder holding `series (1)`/`series (2)`, the dialog read *series (1).txt is
already in this folder* — the file in the way, named — and the only `enabled="false"` node in the
whole dump was the Rename button. Renaming two files to the names they already hold read *Every
selected file already has that name* instead, which is the honest version of "nothing to do", and a
name containing `/` read *A name cannot contain a slash*, button disabled as well.
- **The test folder was removed afterwards.** It was created for this pass, in the phone's own
storage, and deleted through `adb` at the end: the M10 rule about not leaving artefacts on a device
that is not ours applies to a scratch folder as much as to screenshots.
Two things this session added to the tooling notes:
- **`adb input text` does not deliver a `/` reliably.** `'a/b'` arrived in the field mangled (`a!c`,
after the trailing character had to be deleted), so the slash case was sent as `input keyevent 76`
— worth knowing before concluding that a field filters separators: here the *field* accepted it and
the dialog is what refused it.
- **The dialog lifts once the keyboard appears.** Its fields sit ~200 px higher afterwards, so a
second field has to be aimed at the post-keyboard layout. Two dumps were needed to tell the two
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`
*does* open the selection action bar on this MIUI build — it was driven that way to reach
Delete, Rename and Compress — but the same gesture has also landed as a plain tap, which
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.
- **The foreground-service notification** during a long copy. **Now observed** on the API 35 AVD
(see above), including its teardown — so what is left unverified is a *physical* device, where
the system may defer or kill a background `dataSync` service on its own schedule.
- **Archive creation/extraction against real files.** Driven end-to-end on the API 35 AVD, one
archive of every supported format: `ZIP`, `7z`, `TAR`, `TAR.GZ`, `TAR.BZ2`, `TAR.XZ`, `GZ`,
`BZ2` and `XZ` all extracted through the UI with the right entry names and byte-for-byte
contents, and a folder compressed to a `.zip` that the host's `unzip` read back. That sweep is
what found the `.tar.bz2` asymmetry fixed above. Still untested: a *hostile* archive on a
device — the zip-slip and 7z-slip cases are JVM-only — and RAR/CAB/ISO/WIM, which the app
refuses by design.