The root screen opened on a list of volumes, so "where are my photos?" began with knowing which folder they were in. It now opens on Libraries - Images, Videos, Audio, Documents, Archives and Recent - each row saying how many files the index knows of that kind, and each opening an ordinary listing with thumbnails, sorting, multi-select, details and the existing viewers, so a tap on a photo opens the image viewer and a tap on a PDF the PDF viewer. Downloads and New files are deliberately absent: one is a folder shortcut and the other a time window, and presenting three different things as one feature is how a listing starts lying about itself. Documents includes .txt, because a user looking for their documents means those too, and DIRECTORY, APK and OTHER belong to no library at all - a test asserts the three omissions so they stay decisions rather than oversights. A library has to ask for "every image" without reading a name, so IndexedFile gained a derived kind beside FileEntry's and the index stores a copy of it to filter on: schema v3, with a migration that rebuilds the files table and deletes the volume stamps with it, because a stamp means "the rows behind this are this current" and a rebuilt table has no rows behind it. Rebuilding rather than backfilling is legitimate only because this index is a cache of a walk that can be repeated; SqliteTrashIndex's rule is the opposite and its own documentation says why. A library then obeys the indexed search's two rules exactly: the answer comes first with the walk behind it, and every row is confirmed against the filesystem before it is shown, with the rows whose file has gone forgotten rather than displayed. On a device that has never been indexed the first tap on Images therefore starts a walk - which the screen admits to, using the progress row the search already had, instead of showing an empty library that reads as an empty device. "Find duplicates" in the overflow compares contents in three passes, cheapest first: size, which the index already knows, so the candidate list is one query and reads nothing at all; the first 64 KB of every candidate, a filter whose only power is to rule files out; and the whole file for whatever survives, which is the pass that decides. The separation is enforced by a test that builds two files sharing their first 64 KB and differing afterwards and asserts both were read in full - it would fail if the cheap pass were ever treated as an answer. The screen shows only what was measured: groups ordered by the space deleting the spare copies would give back, each header stating the copies and what they cost, every row carrying its folder as well as its size, and nothing preselected, because which copy to keep is the one decision the screen exists to hand over. A file whose bytes cannot be read is counted and reported rather than skipped, "nothing found" is never claimed without saying how many files were compared, and deleting closes the scan instead of patching it - its rows came from comparing bytes, and quietly dropping some of them would leave a list that still looks like a scan while its headers claim numbers nobody measured. The thumbnails went from 28 to 48 dp in a list row and 40 to 96 dp in a grid cell, with the grid's minimum column width raised to 144 dp to suit them; ListItem measures its leading slot rather than capping it, so a row grows to fit instead of clipping. That turned out to matter: the commonest complaint in the competitive survey is thumbnails that load only once scrolling stops, which is the failure mode a larger thumbnail invites. Two smaller corrections came out of the same work - a mutation now re-reads whatever is on screen, so a delete from inside a library removes the row instead of doing nothing at all because there was no path to reload, and returning to the app re-reads a library rather than dropping the user back to the roots. docs/COMPETITIVE_ANALYSIS.md records the market leader's features and what its users praise and complain about, with the sources that were blocked and the size of the review sample stated rather than glossed over; it is where both features came from, and the gaps that cost no permission are listed as candidates in the roadmap. 323 JVM tests (34 added across the two features), lint clean. Tested on the real phone - the same M2101K7AG, with the debug build installed past MIUI's two gates. The libraries on a cold index (no counts rather than a row of zeroes, "Indexing 300 files..." on the first tap, then "Index updated" and real thumbnails), the counts afterwards (Images 6238, Recent uncounted), a tap opening the viewer and BACK returning with the scroll position, and a duplicate scan that found four genuine groups in the device's own storage with nothing crashing. The duplicate delete was deliberately not pressed on a device that is not ours to empty, the store screenshots predate the new home screen, and the library views turn the M10 screenshot rule into a privacy question as well as a cosmetic one - all of that is recorded in docs/EDGE_CASE_TESTING.md rather than left implicit.
16 KiB
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
-
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.
-
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:
UsageAnalysisnow passes theFileEntrydown the recursion instead of re-stating 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
onProgresscallback, 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.
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 |
| Crashes | None. logcat -b crash contained no entry for the package across the whole session |
Two findings worth keeping
- The library views are device-wide, so they cannot be photographed on a real phone. The
Imageslist 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. - MIUI blocks
adb installtwice 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 bareINSTALL_FAILED_USER_RESTRICTED: Install canceled by usermeans. Worth knowing before blaming a build. A second, cheaper lesson came from this session: do not runassembleDebugandadb installin 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/kvmhere, and the two-pane root list is where the libraries now appear first.
Not verified, and why
- Long-press multi-select via adb: it works, but not every time.
input swipe x y x y 900does 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. - 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
dataSyncservice 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,BZ2andXZall extracted through the UI with the right entry names and byte-for-byte contents, and a folder compressed to a.zipthat the host'sunzipread back. That sweep is what found the.tar.bz2asymmetry 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.