Browse the device by what a file is, and find the ones that hold the same bytes
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.
This commit is contained in:
@@ -10,6 +10,20 @@ upload needs a **higher `versionCode`** than every previous upload.
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Changed
|
||||
|
||||
- **File thumbnails are bigger.** The leading visual in a list row went from 28 dp to 48 dp and
|
||||
the one in a grid cell from 40 dp to 96 dp, with the grid's minimum column width raised from
|
||||
108 dp to 144 dp so the larger tile and the two-line name under it still fit. Neither size was
|
||||
ever clipped, but both were small enough that an image read as a coloured square rather than
|
||||
as something recognisable, which is the one thing a thumbnail is for. `ListItem` measures its
|
||||
leading slot instead of capping it — checked against the Material 3 source, where the slot is
|
||||
measured with loose constraints and its width is only subtracted from what the name gets — so
|
||||
a row simply grows to suit the image rather than cropping it. Not covered by the JVM suite:
|
||||
no test in this project renders Compose, so a dp value has nothing to fail against. The change
|
||||
is held together instead by the build compiling and by the three sizes living in one place,
|
||||
`ListThumbnailSize` / `GridThumbnailSize` / `GridCellMinSize` in `BrowserComponents`.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Searching needed a volume to be open first, and needed a second tap to be typed into.** Two
|
||||
@@ -132,6 +146,75 @@ upload needs a **higher `versionCode`** than every previous upload.
|
||||
|
||||
### Added
|
||||
|
||||
- **Libraries: the home screen now opens on what is on the device, not only on where it is.** The
|
||||
volume list was the first thing a new user saw, so "where are my photos?" started with knowing
|
||||
which folder they were in. The root screen now carries a **Libraries** section — Images, Videos,
|
||||
Audio, Documents, Archives and Recent — each row showing how many files the index knows of that
|
||||
kind, and each opening an ordinary listing of them: thumbnails, sorting, multi-select, details and
|
||||
the same viewers a folder gives. A tap on a photo there opens the image viewer, on a PDF the PDF
|
||||
viewer, on an archive its contents. The libraries are actionable, not a report.
|
||||
|
||||
Three decisions are worth stating. **`kind` is derived, never passed in**: `IndexedFile.kind` is a
|
||||
computed property beside `FileEntry.kind`, so one name cannot be classified two ways, and the
|
||||
database's copy of it is written from the name purely so a query can filter on it
|
||||
(`SearchIndex.byKind` / `recent` / `kindCounts`, SQLite schema **v3**). **The v2 → v3 migration
|
||||
rebuilds the files table and deletes the volume stamps with it**: a stamp means "the rows behind
|
||||
this are this current", and a rebuilt table has no rows behind it, so keeping the stamp would make
|
||||
the next search trust an empty index and skip the walk it still needs. 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 obeys
|
||||
the index's two rules exactly as the indexed search does**: the answer comes first with the walk
|
||||
behind it, and every row is confirmed against the filesystem, with 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.
|
||||
|
||||
What is deliberately not in it: **Downloads** and **New files**, which the same competitive survey
|
||||
lists, are absent — Downloads is a folder shortcut and New files is a time window with a different
|
||||
meaning, 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,
|
||||
even though the app opens them in the text editor. `DIRECTORY`, `APK` and `OTHER` belong to no
|
||||
library on purpose, and a test asserts that so the omissions stay decisions. A library is **not
|
||||
restored after process death**: the persisted view state holds a path and a library has none, so a
|
||||
restart returns to the root screen. **308 JVM tests** (19 added here): grouping and classification
|
||||
in `FileCategoryTest`, the three queries and the v2 → v3 migration against real SQLite in
|
||||
`SqliteSearchIndexTest`, and the view model in `BrowserViewModelTest` — including that a library is
|
||||
not a folder, that the walk happens behind the answer rather than in front of it, and that
|
||||
returning to the app re-reads the library instead of dropping the user back to the roots.
|
||||
|
||||
- **A duplicate finder that decides on the bytes, never on a name or a date.** *Find duplicates* in
|
||||
the overflow scans for files holding the same contents, in three passes that each try to read less
|
||||
than the last: **size** (free — the index already knows every length, so the candidate list is one
|
||||
query and no reading 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 (the pass that decides). The
|
||||
separation is the point: a partial read must never be able to produce an answer, so two files that
|
||||
share their first 64 KB and differ afterwards are **not** reported as duplicates. A JVM test builds
|
||||
exactly that pair and asserts both files were read in full — it would fail if the cheap pass were
|
||||
ever mistaken for the deciding one.
|
||||
|
||||
The screen shows only what was measured. Groups are ordered by the space that deleting the spare
|
||||
copies would give back, each header says how many copies and what they cost ("2 copies · 10.5 MB" /
|
||||
"10.5 MB could be freed"), and every row carries the **folder** as well as the size, because a list
|
||||
of identical names with no paths is a list nobody can act on. **Nothing is preselected** — which
|
||||
copy to keep is the one decision the screen exists to hand over — and the action's label counts
|
||||
what it will delete ("Delete 1 copy"), so it cannot be read as "delete the duplicates". A file
|
||||
whose bytes cannot be read is **counted and reported** rather than skipped, because "no duplicates"
|
||||
from a scan that could not read half its candidates is not the same answer; an empty result says
|
||||
*how many* files were compared, or that the index has never run and there was nothing to compare.
|
||||
The reading happens on `Dispatchers.IO` with cancellation checked between files **and** between read
|
||||
buffers, so closing the screen stops it rather than letting gigabytes of hashing finish behind it.
|
||||
|
||||
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 delete itself is the app's ordinary one — the trash, the undo
|
||||
snackbar, and the platform's own dialog where it applies. **323 JVM tests** (15 added here: 8 for
|
||||
the engine, 3 for the candidate query with its floor and cap, 4 for the screen's behaviour).
|
||||
Verified on the real phone: four genuine groups found among the device's own files — a ZIP
|
||||
duplicated across two folders, a PDF twice, another PDF three times — with the selection label
|
||||
reading "Delete 1 copy" and no crash in `logcat -b crash`. The *delete* was deliberately not
|
||||
pressed on a device that is not mine to empty; [EDGE_CASE_TESTING](EDGE_CASE_TESTING.md) records
|
||||
what was and was not exercised.
|
||||
|
||||
- **A file inside an archive can be read without extracting it, and search can answer for the
|
||||
whole volume in one query instead of walking it.** Both were the obvious next question after
|
||||
the archive preview and the in-folder search: the preview could say what a zip held but not
|
||||
|
||||
Reference in New Issue
Block a user