From 05a71f12bf11fab7fe2e5c53b7a6037b98ffdc1b Mon Sep 17 00:00:00 2001 From: Hadrian Burkhardt Date: Sat, 17 Oct 2026 01:15:41 +0200 Subject: [PATCH] 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. --- CHANGELOG.md | 83 ++++ README.md | 26 +- .../filemanager/core/FileCategory.kt | 53 +++ .../filemanager/data/SearchIndex.kt | 63 ++- .../filemanager/data/SqliteSearchIndex.kt | 147 ++++++- .../filemanager/domain/DuplicateFinder.kt | 202 +++++++++ .../filemanager/text/TextRes.kt | 17 + .../ui/browser/BrowserComponents.kt | 37 +- .../filemanager/ui/browser/BrowserContent.kt | 62 ++- .../filemanager/ui/browser/BrowserRoots.kt | 62 +++ .../filemanager/ui/browser/BrowserScreen.kt | 32 +- .../filemanager/ui/browser/BrowserTopBar.kt | 7 + .../filemanager/ui/browser/BrowserUiState.kt | 42 +- .../ui/browser/BrowserViewModel.kt | 330 ++++++++++++-- .../ui/duplicates/DuplicateScreen.kt | 246 +++++++++++ app/src/main/res/values/strings.xml | 37 ++ .../filemanager/core/FileCategoryTest.kt | 68 +++ .../filemanager/data/SqliteSearchIndexTest.kt | 228 +++++++++- .../filemanager/domain/DuplicateFinderTest.kt | 199 +++++++++ .../domain/testutil/InMemorySearchIndex.kt | 35 ++ .../ui/browser/BrowserViewModelTest.kt | 230 +++++++++- docs/COMPETITIVE_ANALYSIS.md | 407 ++++++++++++++++++ docs/EDGE_CASE_TESTING.md | 50 +++ docs/ROADMAP.md | 11 + docs/STORE_LISTING.md | 7 + 25 files changed, 2608 insertions(+), 73 deletions(-) create mode 100644 app/src/main/java/de/softwareapp_hb/filemanager/core/FileCategory.kt create mode 100644 app/src/main/java/de/softwareapp_hb/filemanager/domain/DuplicateFinder.kt create mode 100644 app/src/main/java/de/softwareapp_hb/filemanager/ui/duplicates/DuplicateScreen.kt create mode 100644 app/src/test/java/de/softwareapp_hb/filemanager/core/FileCategoryTest.kt create mode 100644 app/src/test/java/de/softwareapp_hb/filemanager/domain/DuplicateFinderTest.kt create mode 100644 docs/COMPETITIVE_ANALYSIS.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 29d5aff..f2147f0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/README.md b/README.md index e88ab17..01de8e4 100644 --- a/README.md +++ b/README.md @@ -12,10 +12,17 @@ no advertising or tracking SDKs. ## Status -**M10 — store assets: done.** The app is named **Plain Files**, the icon and feature graphic -are generated from the app's own vectors, and [docs/STORE_LISTING.md](docs/STORE_LISTING.md) -holds length-checked listing copy. The screenshots are captured: 5 phone (1080×2160), 4 on a -7-inch tablet (1200×1920) and 4 on a 10-inch tablet (2560×1600). +**M12 — duplicate finder: done.** The browser scans for files that hold the same bytes — size first, +then the head of every candidate, then the survivors in full — and shows what deleting the spare +copies would give back. Before it: **M11 — library views**, a **Libraries** section on the root +screen (Images, Videos, Audio, Documents, Archives and Recent) whose rows open ordinary listings of +those files, each carrying a count from the search index. And **M10 — store assets**: the app is +named **Plain Files**, the icon and feature graphic come from the app's own vectors, and +[docs/STORE_LISTING.md](docs/STORE_LISTING.md) holds length-checked listing copy. Screenshots exist +for 5 phone (1080×2160), 4 on a 7-inch tablet (1200×1920) and 4 on a 10-inch tablet (2560×1600) — +but they **predate M11**, so the home screen in them is the old volume list and they will need +re-shooting, which the library views make a privacy question as well as a cosmetic one +([docs/STORE_LISTING.md](docs/STORE_LISTING.md)). ## Tech stack @@ -166,7 +173,8 @@ UI.** Translated UI text has to be supplied in `values-/`. no archive encryption or password support. - **Cancellation happens between entries**, so a single very large file finishes before a stop is honoured. -- **View state is not persisted across process death** (`SavedStateHandle` is not wired up). +- **A library is not restored across process death.** The persisted view state holds a path, and a + library has none, so a restart returns to the root screen rather than to Images or Recent. - **Long-press multi-select is awkward to script from the host.** The gesture that opens the selection bar (`input swipe x y x y 900`) works on the MIUI test device but has also registered as a plain tap, so compress/extract from the UI wants a manual pass (see @@ -187,6 +195,14 @@ alternatives and the Console steps: [docs/MONETISATION.md](docs/MONETISATION.md) ⚠️ **The price must be set before the first upload** — Play allows paid → free but never free → paid. +## Competitive context + +The category leader is **File Manager Plus** (AlphaInventor; 100 M+ installs, 4.7–4.8★, free +with ads and in-app purchases, and a Data Safety form that shares device IDs with third +parties). What it offers, what its users praise, what they complain about, and which of those +complaints this app can answer without asking for a single new permission: +[docs/COMPETITIVE_ANALYSIS.md](docs/COMPETITIVE_ANALYSIS.md). + ## Licence Proprietary — all rights reserved. diff --git a/app/src/main/java/de/softwareapp_hb/filemanager/core/FileCategory.kt b/app/src/main/java/de/softwareapp_hb/filemanager/core/FileCategory.kt new file mode 100644 index 0000000..4f5eb4b --- /dev/null +++ b/app/src/main/java/de/softwareapp_hb/filemanager/core/FileCategory.kt @@ -0,0 +1,53 @@ +package de.softwareapp_hb.filemanager.core + +/** + * A library the home screen can open: everything on the device of a given *kind*, rather than + * everything in a given folder. + * + * The libraries group kinds rather than mirroring [FileKind] one to one, for two reasons. A user + * looking for "documents" means their `.txt` files as well as their PDFs, and [FileKind] keeps + * those apart because the app opens them differently — one in the text editor, one in the PDF + * viewer — which is a distinction about *behaviour*, not about where a person would look for it. + * And a library is something a user reads and taps, so it is a smaller and stabler set than the + * categories a classifier happens to distinguish. + * + * Three kinds deliberately belong to no library. `DIRECTORY`, because a library of folders is not + * what this is. `APK`, which wants an app-manager view this app has not built and would need + * `QUERY_ALL_PACKAGES` to build properly. And `OTHER`, which means "nothing recognised" and so has + * nothing to offer. A test asserts exactly that, so the omissions stay deliberate. + * + * @property kinds the kinds this library collects; empty for [RECENT], which is a window rather + * than a kind. + */ +enum class FileCategory(val kinds: Set) { + IMAGES(setOf(FileKind.IMAGE)), + VIDEOS(setOf(FileKind.VIDEO)), + AUDIO(setOf(FileKind.AUDIO)), + + /** Includes [FileKind.TEXT]: a `.md` note is a document to the person who wrote it. */ + DOCUMENTS(setOf(FileKind.DOCUMENT, FileKind.TEXT)), + + ARCHIVES(setOf(FileKind.ARCHIVE)), + + /** + * The files changed most recently, whatever they are. + * + * Not a kind at all, so it is the one library answered by a different query — ordered by date + * instead of filtered by name — and the one whose size is not worth quoting on the home + * screen, because "how many recent files are there" has no answer anyone wants. + */ + RECENT(emptySet()), + ; + + /** + * `true` when this library is defined by *when* a file changed rather than by *what* it is. + * + * The index asks a different question for these, and the home screen shows no count. + */ + val isTimeBased: Boolean get() = kinds.isEmpty() + + companion object { + /** The library [kind] belongs to, or `null` when no library collects it. */ + fun of(kind: FileKind): FileCategory? = entries.firstOrNull { kind in it.kinds } + } +} diff --git a/app/src/main/java/de/softwareapp_hb/filemanager/data/SearchIndex.kt b/app/src/main/java/de/softwareapp_hb/filemanager/data/SearchIndex.kt index 73e83ba..b66857f 100644 --- a/app/src/main/java/de/softwareapp_hb/filemanager/data/SearchIndex.kt +++ b/app/src/main/java/de/softwareapp_hb/filemanager/data/SearchIndex.kt @@ -1,5 +1,6 @@ package de.softwareapp_hb.filemanager.data +import de.softwareapp_hb.filemanager.core.FileKind import de.softwareapp_hb.filemanager.core.StoragePath /** @@ -17,7 +18,17 @@ data class IndexedFile( val isDirectory: Boolean, val sizeBytes: Long, val modifiedMillis: Long, -) +) { + /** + * The category this name belongs to. + * + * Derived rather than stored on the way in, exactly as [de.softwareapp_hb.filemanager.core + * .FileEntry.kind] is, so the two can never disagree about the same name. The database does + * keep a copy of it — an index may denormalise what it can recompute, which is the whole + * point of an index — but nothing reads that column except the query that needs it. + */ + val kind: FileKind get() = FileKind.of(name, isDirectory) +} /** * What a volume's index is: how current it is, and how it was built. @@ -78,6 +89,47 @@ interface SearchIndex { */ suspend fun find(query: String, limit: Int): List + /** + * Rows of the given [kinds], at most [limit], most recently changed first. + * + * The order matters only for *which* rows survive the cap: a library larger than [limit] is + * sampled by recency, because the newest files are the ones a person is most likely to be + * looking for. What is shown is then sorted by the app's own sort, like any other listing. + * + * An empty [kinds] answers nothing rather than everything, so a library that collects no kind + * — [de.softwareapp_hb.filemanager.core.FileCategory.RECENT] — cannot silently turn into a + * listing of the whole device. + */ + suspend fun byKind(kinds: Set, limit: Int): List + + /** Files changed most recently, at most [limit], newest first. Folders are not files here. */ + suspend fun recent(limit: Int): List + + /** + * How many indexed rows there are of each kind. + * + * For the home screen's counts, so the answer has to be read the same way everything else + * from here is: it is what the index knew **when it was built**, not what is on the device + * now. A library count with no index behind it is an empty map, which the UI shows as no + * count at all rather than as zero. + */ + suspend fun kindCounts(): Map + + /** + * Rows whose size is shared by at least one other row, at most [limit], largest first. + * + * The free, first pass of a duplicate scan: the index already holds every size, so finding + * *candidates* costs one query and reads nothing at all. What comes back is a candidate list, + * not an answer — two files of the same length are not the same file until their bytes say so, + * which is + * [de.softwareapp_hb.filemanager.domain.DuplicateFinder]'s job. + * + * [minSizeBytes] keeps a scan off the thousands of tiny files a phone holds (empty files + * included), and the cap keeps it off a device with more same-sized files than anyone could + * act on — keeping the largest, because that is where the space is. + */ + suspend fun duplicateCandidates(minSizeBytes: Long, limit: Int): List + /** Drop one row, because the file it pointed at has gone. */ suspend fun forget(path: StoragePath) @@ -120,6 +172,15 @@ interface SearchIndex { override suspend fun find(query: String, limit: Int): List = emptyList() + override suspend fun byKind(kinds: Set, limit: Int): List = emptyList() + + override suspend fun recent(limit: Int): List = emptyList() + + override suspend fun kindCounts(): Map = emptyMap() + + override suspend fun duplicateCandidates(minSizeBytes: Long, limit: Int): List = + emptyList() + override suspend fun forget(path: StoragePath) = Unit override suspend fun forgetTree(path: StoragePath) = Unit diff --git a/app/src/main/java/de/softwareapp_hb/filemanager/data/SqliteSearchIndex.kt b/app/src/main/java/de/softwareapp_hb/filemanager/data/SqliteSearchIndex.kt index f28a6c6..690187d 100644 --- a/app/src/main/java/de/softwareapp_hb/filemanager/data/SqliteSearchIndex.kt +++ b/app/src/main/java/de/softwareapp_hb/filemanager/data/SqliteSearchIndex.kt @@ -5,6 +5,7 @@ import android.content.Context import android.database.Cursor import android.database.sqlite.SQLiteDatabase import android.database.sqlite.SQLiteOpenHelper +import de.softwareapp_hb.filemanager.core.FileKind import de.softwareapp_hb.filemanager.core.StoragePath import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext @@ -143,6 +144,80 @@ class SqliteSearchIndex(context: Context) : SearchIndex { ).use { cursor -> buildList { while (cursor.moveToNext()) add(cursor.toRow()) } } } + /** + * A library's rows: everything of the given kinds, newest first, capped at [limit]. + * + * `kind` is stored so this never has to look at a name: without the column, "every image" + * would mean matching fifteen file extensions in SQL and classifying the rest in Kotlin, + * which is both slower and a second place for the classification to be wrong. The order is + * by date because it decides which rows the cap keeps — see [SearchIndex.byKind]. + */ + override suspend fun byKind(kinds: Set, limit: Int): List = + withContext(Dispatchers.IO) { + // No kinds means no library, not the whole device. + if (kinds.isEmpty()) return@withContext emptyList() + val placeholders = kinds.joinToString(", ") { "?" } + helper.readableDatabase.query( + TABLE_FILES, + null, + "$COLUMN_KIND IN ($placeholders)", + kinds.map { it.name }.toTypedArray(), + null, + null, + "$COLUMN_MODIFIED DESC, $COLUMN_NAME ASC", + limit.toString(), + ).use { cursor -> buildList { while (cursor.moveToNext()) add(cursor.toRow()) } } + } + + override suspend fun recent(limit: Int): List = withContext(Dispatchers.IO) { + helper.readableDatabase.query( + TABLE_FILES, + null, + // Folders have dates too, and a folder is not a "recent file". + "$COLUMN_IS_DIRECTORY = 0", + null, + null, + null, + "$COLUMN_MODIFIED DESC, $COLUMN_NAME ASC", + limit.toString(), + ).use { cursor -> buildList { while (cursor.moveToNext()) add(cursor.toRow()) } } + } + + override suspend fun kindCounts(): Map = withContext(Dispatchers.IO) { + helper.readableDatabase + .rawQuery("SELECT $COLUMN_KIND, COUNT(*) FROM $TABLE_FILES GROUP BY $COLUMN_KIND", null) + .use { cursor -> + buildMap { + while (cursor.moveToNext()) { + // A kind this build does not know is a row from a future version: counting + // it under a guess would be worse than leaving it out of a count. + val kind = runCatching { FileKind.valueOf(cursor.getString(0)) }.getOrNull() + if (kind != null) put(kind, cursor.getInt(1)) + } + } + } + } + + override suspend fun duplicateCandidates(minSizeBytes: Long, limit: Int): List = + withContext(Dispatchers.IO) { + // A self-join is the obvious spelling and the wrong one: `GROUP BY size_bytes HAVING + // COUNT(*) > 1` walks the size index once, where a join reads the table once per + // matching pair — and a phone with 6 000 same-sized files has a great many pairs. + val sized = "$COLUMN_IS_DIRECTORY = 0 AND $COLUMN_SIZE_BYTES >= ?" + helper.readableDatabase.query( + TABLE_FILES, + null, + "$sized AND $COLUMN_SIZE_BYTES IN (" + + "SELECT $COLUMN_SIZE_BYTES FROM $TABLE_FILES WHERE $sized " + + "GROUP BY $COLUMN_SIZE_BYTES HAVING COUNT(*) > 1)", + arrayOf(minSizeBytes.toString(), minSizeBytes.toString()), + null, + null, + "$COLUMN_SIZE_BYTES DESC, $COLUMN_NAME ASC", + limit.toString(), + ).use { cursor -> buildList { while (cursor.moveToNext()) add(cursor.toRow()) } } + } + override suspend fun forget(path: StoragePath) = withContext(Dispatchers.IO) { helper.writableDatabase.delete(TABLE_FILES, "$COLUMN_PATH = ?", arrayOf(path.value)) Unit @@ -175,6 +250,9 @@ class SqliteSearchIndex(context: Context) : SearchIndex { put(COLUMN_SIZE_BYTES, sizeBytes) put(COLUMN_MODIFIED, modifiedMillis) put(COLUMN_VOLUME, volume) + // Written from the name rather than passed in, so a row cannot be stored with a kind + // that disagrees with the name it is stored under. + put(COLUMN_KIND, kind.name) } private fun Cursor.toRow(): IndexedFile = IndexedFile( @@ -243,7 +321,7 @@ class SqliteSearchIndex(context: Context) : SearchIndex { */ internal companion object { const val DATABASE_NAME = "search-index.db" - const val DATABASE_VERSION = 2 + const val DATABASE_VERSION = 3 const val TABLE_FILES = "indexed_files" const val TABLE_VOLUMES = "indexed_volumes" const val COLUMN_PATH = "path" @@ -252,6 +330,7 @@ class SqliteSearchIndex(context: Context) : SearchIndex { const val COLUMN_SIZE_BYTES = "size_bytes" const val COLUMN_MODIFIED = "modified" const val COLUMN_VOLUME = "volume" + const val COLUMN_KIND = "kind" const val COLUMN_INDEXED_AT = "indexed_at" const val COLUMN_FULL_AT = "full_at" const val COLUMN_HIDDEN_INCLUDED = "hidden_included" @@ -259,22 +338,7 @@ class SqliteSearchIndex(context: Context) : SearchIndex { private const val LIKE_ESCAPE = '\\' fun create(db: SQLiteDatabase) { - db.execSQL( - """ - CREATE TABLE $TABLE_FILES ( - $COLUMN_PATH TEXT NOT NULL PRIMARY KEY, - $COLUMN_NAME TEXT NOT NULL, - $COLUMN_IS_DIRECTORY INTEGER NOT NULL, - $COLUMN_SIZE_BYTES INTEGER NOT NULL, - $COLUMN_MODIFIED INTEGER NOT NULL, - $COLUMN_VOLUME TEXT NOT NULL - ) - """.trimIndent(), - ) - // A search is a scan over names or a prefix of one, and a phone's storage is - // hundreds of thousands of rows; without this every keystroke is a table scan. - db.execSQL("CREATE INDEX indexed_files_name ON $TABLE_FILES ($COLUMN_NAME)") - db.execSQL("CREATE INDEX indexed_files_volume ON $TABLE_FILES ($COLUMN_VOLUME)") + createFiles(db) db.execSQL( """ CREATE TABLE $TABLE_VOLUMES ( @@ -287,6 +351,38 @@ class SqliteSearchIndex(context: Context) : SearchIndex { ) } + /** + * The files table, and the indexes the three queries need. + * + * A search is a scan over names or a prefix of one, and a phone's storage is hundreds of + * thousands of rows; without the name index every keystroke would be a table scan. The + * other two exist for the library views: `kind` is what "every image" filters on, and + * `modified` is what orders both a library's cap and [SearchIndex.recent] — without it, + * asking for the newest files would sort the whole table to throw nearly all of it away. + * + * Separate from [create] because the v2 → v3 upgrade rebuilds this one table on its own, + * and a second copy of the DDL is a second place for the schema to drift. + */ + private fun createFiles(db: SQLiteDatabase) { + db.execSQL( + """ + CREATE TABLE $TABLE_FILES ( + $COLUMN_PATH TEXT NOT NULL PRIMARY KEY, + $COLUMN_NAME TEXT NOT NULL, + $COLUMN_IS_DIRECTORY INTEGER NOT NULL, + $COLUMN_SIZE_BYTES INTEGER NOT NULL, + $COLUMN_MODIFIED INTEGER NOT NULL, + $COLUMN_VOLUME TEXT NOT NULL, + $COLUMN_KIND TEXT NOT NULL + ) + """.trimIndent(), + ) + db.execSQL("CREATE INDEX indexed_files_name ON $TABLE_FILES ($COLUMN_NAME)") + db.execSQL("CREATE INDEX indexed_files_volume ON $TABLE_FILES ($COLUMN_VOLUME)") + db.execSQL("CREATE INDEX indexed_files_kind ON $TABLE_FILES ($COLUMN_KIND)") + db.execSQL("CREATE INDEX indexed_files_modified ON $TABLE_FILES ($COLUMN_MODIFIED)") + } + /** * Bring [db] from [oldVersion] up to [newVersion], one version at a time. * @@ -313,6 +409,23 @@ class SqliteSearchIndex(context: Context) : SearchIndex { ) } + 2 -> { + // v3 adds `kind`, so a library can ask for "every image" without reading + // a name. The column is derived from the name, which means an existing + // row's value could be worked out — but it would mean rewriting every + // row of a table that can hold hundreds of thousands of them, at the + // moment the user first searches after an update. Rebuilding is the + // cheaper and the more honest step, and it is allowed *here* precisely + // because this index is a cache of a walk that can be repeated. + // + // The volume stamps go with it. Keeping them would leave the index + // claiming to be current while holding nothing — the one lie an index + // must not tell — and the next pass would skip the walk it still needs. + db.execSQL("DROP TABLE IF EXISTS $TABLE_FILES") + db.execSQL("DELETE FROM $TABLE_VOLUMES") + createFiles(db) + } + else -> error( "No migration from search-index schema version $version to ${version + 1}. " + "The index can be rebuilt from the device, so dropping and recreating " + diff --git a/app/src/main/java/de/softwareapp_hb/filemanager/domain/DuplicateFinder.kt b/app/src/main/java/de/softwareapp_hb/filemanager/domain/DuplicateFinder.kt new file mode 100644 index 0000000..721d47e --- /dev/null +++ b/app/src/main/java/de/softwareapp_hb/filemanager/domain/DuplicateFinder.kt @@ -0,0 +1,202 @@ +package de.softwareapp_hb.filemanager.domain + +import de.softwareapp_hb.filemanager.core.StoragePath +import de.softwareapp_hb.filemanager.data.IndexedFile +import de.softwareapp_hb.filemanager.data.StorageSource +import java.security.MessageDigest +import java.util.Base64 +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.currentCoroutineContext +import kotlinx.coroutines.isActive +import kotlinx.coroutines.withContext + +/** + * Files that hold the same bytes. + * + * [paths] is never shorter than two — a file that shares its contents with nothing is not a + * duplicate, and reporting a "group" of one would put a decision in front of the user that has + * only one possible answer. + */ +data class DuplicateGroup( + val sizeBytes: Long, + val paths: List, +) { + /** The space the extra copies hold: what deleting all but one would give back. */ + val reclaimableBytes: Long get() = sizeBytes * (paths.size - 1) +} + +/** How far a scan has got, and which of its two reading passes is running. */ +data class DuplicateProgress( + val stage: Stage, + val done: Int, + val total: Int, +) { + enum class Stage { + /** Reading the head of every candidate. Cheap, and only ever rules files out. */ + PARTIAL, + + /** Reading the survivors to the end. This is the pass that decides. */ + FULL, + } +} + +/** What a scan found, and what it could not read. */ +data class DuplicateScan( + val groups: List, + /** + * Files whose bytes could not be read, and so could be neither confirmed nor denied. + * + * Counted rather than swallowed: "no duplicates" from a scan that could not read half the + * candidates is not the same answer as "no duplicates", and someone deciding what to delete + * is entitled to know which of the two they are looking at. + */ + val unreadable: Int = 0, +) + +/** + * Finds files that hold the same bytes. + * + * Three passes, cheapest first, because the only honest way to compare gigabytes is to read as + * little of them as possible while still being *certain*: + * + * 1. **Size.** Two files of different lengths cannot be the same file. Free: the index already + * holds every size, so this pass reads nothing at all — it happens before [find] is called, in + * the query that produces [candidates]. + * 2. **A partial digest.** The first [PARTIAL_BYTES] of each file, which tells apart almost + * everything that merely happens to share a length. It can only ever **rule files out**: two + * files whose heads differ are certainly different, and two whose heads agree are not yet + * anything. + * 3. **A full digest.** Everything that survived the second pass is read to the end. This is the + * pass that decides, and the reason a partial collision cannot become a wrong answer: a group + * is reported only when the *whole* file hashes the same. + * + * A file that cannot be read — gone, refused, a directory that lied about itself — is neither a + * duplicate nor not one, so it lands in [DuplicateScan.unreadable] rather than in a group. + * + * Cancellation is honoured between files and between read buffers, so leaving the screen stops the + * reading instead of letting it run on in the background. Nothing is written and nothing is + * deleted, so a cancelled scan costs only the time it already spent. + */ +class DuplicateFinder(private val source: StorageSource) { + + /** + * Scan [candidates] and report what is genuinely identical. + * + * [candidates] is expected to have been narrowed by size already — see + * [de.softwareapp_hb.filemanager.data.SearchIndex.duplicateCandidates] — but they are grouped + * by size here as well, because trusting a caller to have done the free pass is how a + * duplicate scan ends up reading two files that were never the same length. + */ + suspend fun find( + candidates: List, + onProgress: (DuplicateProgress) -> Unit = {}, + ): DuplicateScan { + val worthChecking = candidates + .groupBy { it.sizeBytes } + .filterValues { it.size >= 2 } + .values + .flatten() + .distinctBy { it.path.value } + if (worthChecking.isEmpty()) return DuplicateScan(emptyList()) + + var unreadable = 0 + + // The head of every candidate. `size` is part of the key even though the group already + // implies it, so a key means exactly "same length and same beginning". + val byPartial = LinkedHashMap>() + var done = 0 + for (file in worthChecking) { + if (!currentCoroutineContext().isActive) return DuplicateScan(emptyList()) + val head = digest(file.path, PARTIAL_BYTES) + done++ + onProgress(DuplicateProgress(DuplicateProgress.Stage.PARTIAL, done, worthChecking.size)) + if (head == null) { + unreadable++ + continue + } + byPartial.getOrPut("${file.sizeBytes}:$head") { mutableListOf() } += file + } + + // Everything that still has a partner, read in full. Usually a handful of files even in a + // library of thousands, because the cheap pass has already dismissed the rest. + val survivors = byPartial.values.filter { it.size >= 2 }.flatten() + if (survivors.isEmpty()) return DuplicateScan(emptyList(), unreadable) + + val byFull = LinkedHashMap>() + done = 0 + for (file in survivors) { + if (!currentCoroutineContext().isActive) return DuplicateScan(emptyList()) + val whole = digest(file.path, limit = null) + done++ + onProgress(DuplicateProgress(DuplicateProgress.Stage.FULL, done, survivors.size)) + if (whole == null) { + unreadable++ + continue + } + byFull.getOrPut(whole) { mutableListOf() } += file + } + + val groups = byFull.values + .filter { it.size >= 2 } + .map { files -> + DuplicateGroup( + sizeBytes = files.first().sizeBytes, + // Sorted by path, so the list is stable between scans: a re-scan that + // reordered the rows would look like it had found something new. + paths = files.map { it.path }.sortedBy { it.value }, + ) + } + // Most reclaimable space first, which is the order a person would want to act in: + // one duplicated video beats a thousand duplicated screenshots. + .sortedWith( + compareByDescending { it.reclaimableBytes } + .thenByDescending { it.sizeBytes } + .thenBy { it.paths.first().value }, + ) + + return DuplicateScan(groups, unreadable) + } + + /** + * The digest of a file's bytes, or `null` when it cannot be read. + * + * [limit] reads only that many bytes from the front, for the cheap pass; `null` reads the file + * to the end, which is the only version of this that decides anything. + */ + private suspend fun digest(path: StoragePath, limit: Int?): String? = withContext(Dispatchers.IO) { + runCatching { + val digest = MessageDigest.getInstance(DIGEST) + source.openRead(path).use { stream -> + val buffer = ByteArray(BUFFER_BYTES) + var left = limit?.toLong() ?: Long.MAX_VALUE + while (left > 0) { + // Checked here as well as between files: one very large file is a long enough + // stretch of work to be worth abandoning on its own. + if (!currentCoroutineContext().isActive) break + val read = stream.read(buffer, 0, minOf(buffer.size.toLong(), left).toInt()) + if (read <= 0) break + digest.update(buffer, 0, read) + left -= read + } + } + Base64.getEncoder().encodeToString(digest.digest()) + }.getOrNull() + } + + private companion object { + const val DIGEST = "SHA-256" + + /** + * How much of a file the cheap pass reads. + * + * 64 KB costs one read per candidate — and on a phone's mounted storage each read is a + * round trip — while being far more than the header most same-length files share. It has + * to be *only* a filter and never a decision, which is why nothing here depends on it + * being big enough. + */ + const val PARTIAL_BYTES = 64 * 1024 + + /** Read buffer for the full pass, so a large file is not a syscall per kilobyte. */ + const val BUFFER_BYTES = 64 * 1024 + } +} diff --git a/app/src/main/java/de/softwareapp_hb/filemanager/text/TextRes.kt b/app/src/main/java/de/softwareapp_hb/filemanager/text/TextRes.kt index 88dd987..c894db2 100644 --- a/app/src/main/java/de/softwareapp_hb/filemanager/text/TextRes.kt +++ b/app/src/main/java/de/softwareapp_hb/filemanager/text/TextRes.kt @@ -2,6 +2,7 @@ package de.softwareapp_hb.filemanager.text import androidx.annotation.StringRes import de.softwareapp_hb.filemanager.R +import de.softwareapp_hb.filemanager.core.FileCategory import de.softwareapp_hb.filemanager.core.FileKind import de.softwareapp_hb.filemanager.domain.OperationKind @@ -39,3 +40,19 @@ fun FileKind.labelRes(): Int = when (this) { FileKind.TEXT -> R.string.browser_kind_text FileKind.OTHER -> R.string.browser_kind_other } + +/** + * The name of a library, for the home screen's row and the app bar of the listing it opens. + * + * Here rather than in the UI for the same reason as [FileKind.labelRes]: the row a user taps and + * the title of what opens must not be able to disagree about what it is called. + */ +@StringRes +fun FileCategory.labelRes(): Int = when (this) { + FileCategory.IMAGES -> R.string.library_images + FileCategory.VIDEOS -> R.string.library_videos + FileCategory.AUDIO -> R.string.library_audio + FileCategory.DOCUMENTS -> R.string.library_documents + FileCategory.ARCHIVES -> R.string.library_archives + FileCategory.RECENT -> R.string.library_recent +} diff --git a/app/src/main/java/de/softwareapp_hb/filemanager/ui/browser/BrowserComponents.kt b/app/src/main/java/de/softwareapp_hb/filemanager/ui/browser/BrowserComponents.kt index cebd692..dc2ccfa 100644 --- a/app/src/main/java/de/softwareapp_hb/filemanager/ui/browser/BrowserComponents.kt +++ b/app/src/main/java/de/softwareapp_hb/filemanager/ui/browser/BrowserComponents.kt @@ -17,6 +17,7 @@ import androidx.compose.material.icons.filled.Check import androidx.compose.material.icons.filled.Description import androidx.compose.material.icons.filled.Folder import androidx.compose.material.icons.filled.FolderZip +import androidx.compose.material.icons.filled.History import androidx.compose.material.icons.filled.Image import androidx.compose.material.icons.filled.InsertDriveFile import androidx.compose.material.icons.filled.Movie @@ -40,11 +41,26 @@ import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import coil3.compose.AsyncImage import de.softwareapp_hb.filemanager.R +import de.softwareapp_hb.filemanager.core.FileCategory import de.softwareapp_hb.filemanager.core.FileEntry import de.softwareapp_hb.filemanager.core.FileKind import de.softwareapp_hb.filemanager.core.FileSize import java.io.File +/** The thumbnail an entry's leading visual gets in a list row. */ +private val ListThumbnailSize = 48.dp + +/** The thumbnail an entry's leading visual gets in a grid cell. */ +private val GridThumbnailSize = 96.dp + +/** + * The minimum width of a grid cell. + * + * Wide enough for the 96 dp thumbnail and the two-line name under it; the adaptive grid then + * fits as many of those columns as the window allows. + */ +internal val GridCellMinSize = 144.dp + /** * The leading visual for an entry: a real thumbnail for an image we can actually read, * otherwise the category icon. @@ -90,6 +106,21 @@ fun iconFor(kind: FileKind): ImageVector = when (kind) { FileKind.OTHER -> Icons.Filled.InsertDriveFile } +/** + * The icon that stands for a library. + * + * Separate from [iconFor] rather than derived from it, because a library groups kinds and + * [FileCategory.RECENT] has no kind behind it at all — it is about *when*, so it gets a clock. + */ +fun iconFor(category: FileCategory): ImageVector = when (category) { + FileCategory.IMAGES -> Icons.Filled.Image + FileCategory.VIDEOS -> Icons.Filled.Movie + FileCategory.AUDIO -> Icons.Filled.MusicNote + FileCategory.DOCUMENTS -> Icons.Filled.Description + FileCategory.ARCHIVES -> Icons.Filled.FolderZip + FileCategory.RECENT -> Icons.Filled.History +} + /** One row of the list view. */ @OptIn(ExperimentalFoundationApi::class) @Composable @@ -122,7 +153,9 @@ fun EntryRow( } else { ListItemDefaults.colors() }, - leadingContent = { EntryIcon(entry = entry, size = 28.dp) }, + // `ListItem` measures the leading slot rather than capping it, so the row grows to + // suit a 48 dp thumbnail instead of clipping it. + leadingContent = { EntryIcon(entry = entry, size = ListThumbnailSize) }, headlineContent = { Text(text = entry.name, maxLines = 1, overflow = TextOverflow.Ellipsis) }, supportingContent = { val description = entry.subtitle() @@ -189,7 +222,7 @@ fun EntryGridCell( if (selectionMode) { Checkbox(checked = selected, onCheckedChange = null) } - EntryIcon(entry = entry, size = 40.dp) + EntryIcon(entry = entry, size = GridThumbnailSize) Spacer(Modifier.height(8.dp)) Text( text = entry.name, diff --git a/app/src/main/java/de/softwareapp_hb/filemanager/ui/browser/BrowserContent.kt b/app/src/main/java/de/softwareapp_hb/filemanager/ui/browser/BrowserContent.kt index c3b1ebc..7f20bc9 100644 --- a/app/src/main/java/de/softwareapp_hb/filemanager/ui/browser/BrowserContent.kt +++ b/app/src/main/java/de/softwareapp_hb/filemanager/ui/browser/BrowserContent.kt @@ -12,6 +12,7 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.foundation.lazy.LazyListScope import androidx.compose.foundation.lazy.grid.GridCells import androidx.compose.foundation.lazy.grid.LazyGridState import androidx.compose.foundation.lazy.grid.LazyVerticalGrid @@ -32,6 +33,7 @@ import androidx.compose.ui.res.pluralStringResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import de.softwareapp_hb.filemanager.R +import de.softwareapp_hb.filemanager.core.FileCategory import de.softwareapp_hb.filemanager.core.FileEntry import de.softwareapp_hb.filemanager.core.StoragePath import de.softwareapp_hb.filemanager.core.Timestamps @@ -62,6 +64,7 @@ fun BrowserContent( onToggleSelection: (StoragePath) -> Unit, onOpenRoot: (StoragePath) -> Unit, onNavigate: (StoragePath) -> Unit, + onOpenCategory: (FileCategory) -> Unit, onGrantAllFiles: () -> Unit, onPickFolder: () -> Unit, onRebuildIndex: () -> Unit, @@ -136,6 +139,10 @@ fun BrowserContent( state.isRootView && wide -> Row(modifier = Modifier.fillMaxSize()) { LazyColumn(modifier = Modifier.width(MasterPaneWidth), state = listState) { + librarySection( + state = state, + onOpenCategory = onOpenCategory, + ) items(items = state.roots, key = { it.path.value }) { root -> RootRow( item = root, @@ -155,6 +162,10 @@ fun BrowserContent( } state.isRootView -> LazyColumn(modifier = Modifier.fillMaxSize(), state = listState) { + librarySection( + state = state, + onOpenCategory = onOpenCategory, + ) items(items = state.roots, key = { it.path.value }) { root -> RootRow(item = root, onClick = { onOpenRoot(root.path) }) } @@ -169,6 +180,33 @@ fun BrowserContent( } } +/** + * The home screen's libraries, as items for the root list. + * + * A `LazyListScope` extension rather than a composable, because the single-pane and two-pane root + * layouts build the same list and neither should own a second copy of it. + * + * A tap opens a library outright, unlike a root — which highlights on a tap when there are two + * panes — because the detail pane previews a *folder's* contents, and a library has no folder to + * preview. There is nothing for a highlight to show, so nothing is deferred to a second tap. + */ +private fun LazyListScope.librarySection( + state: BrowserUiState, + onOpenCategory: (FileCategory) -> Unit, +) { + item(key = LIBRARY_SECTION_KEY) { LibrarySectionHeader() } + items(items = FileCategory.entries, key = { "library-${it.name}" }) { category -> + LibraryRow( + category = category, + count = state.libraryCounts[category], + onClick = { onOpenCategory(category) }, + ) + } +} + +/** A key that cannot collide with a folder's path, which always starts with `/`. */ +private const val LIBRARY_SECTION_KEY = "libraries-section" + /** A directory: its breadcrumbs, then its entries as a list or a grid. */ @Composable private fun DirectoryView( @@ -186,7 +224,10 @@ private fun DirectoryView( modifier: Modifier = Modifier, ) { Column(modifier = modifier.fillMaxSize()) { - Breadcrumbs(crumbs = state.breadcrumbs, labelFor = crumbLabel, onNavigate = onNavigate) + // A library has no trail: it is not inside anything. + if (state.breadcrumbs.isNotEmpty()) { + Breadcrumbs(crumbs = state.breadcrumbs, labelFor = crumbLabel, onNavigate = onNavigate) + } // Full access is a better experience than the folder fallback, so offer the upgrade // without ever blocking the browsing the user can already do. @@ -208,9 +249,10 @@ private fun DirectoryView( } } - // Searching everywhere is answered by the index, so what the index is worth is part of - // the answer: its age is on screen, and the way to make it current is one tap away. - if (state.searchScope == SearchScope.EVERYWHERE) { + // Searching everywhere is answered by the index, and so is a library: for both, what the + // index is worth is part of the answer — its age is on screen, and the way to make it + // current is one tap away. + if (state.category != null || state.searchScope == SearchScope.EVERYWHERE) { IndexStatusRow( indexing = state.indexing, indexedCount = state.indexedCount, @@ -224,10 +266,12 @@ private fun DirectoryView( // second, and it is exactly the sort of lie this feature exists to avoid. if (state.entries.isEmpty() && !state.indexing) { EmptyState( - message = if (state.query.isBlank()) { - stringResource(R.string.browser_empty_folder) - } else { - stringResource(R.string.browser_no_matches, state.query) + message = when { + // A library is not a folder, and what an empty one is missing is an index + // rather than files — the row above it says which of the two this is. + state.category != null -> stringResource(R.string.library_empty) + state.query.isBlank() -> stringResource(R.string.browser_empty_folder) + else -> stringResource(R.string.browser_no_matches, state.query) }, modifier = Modifier.fillMaxSize(), ) @@ -274,7 +318,7 @@ private fun DirectoryView( } ViewMode.GRID -> LazyVerticalGrid( - columns = GridCells.Adaptive(minSize = 108.dp), + columns = GridCells.Adaptive(minSize = GridCellMinSize), modifier = Modifier.fillMaxSize(), state = gridState, contentPadding = PaddingValues(8.dp), diff --git a/app/src/main/java/de/softwareapp_hb/filemanager/ui/browser/BrowserRoots.kt b/app/src/main/java/de/softwareapp_hb/filemanager/ui/browser/BrowserRoots.kt index 5471c91..e200ad8 100644 --- a/app/src/main/java/de/softwareapp_hb/filemanager/ui/browser/BrowserRoots.kt +++ b/app/src/main/java/de/softwareapp_hb/filemanager/ui/browser/BrowserRoots.kt @@ -26,8 +26,10 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import de.softwareapp_hb.filemanager.R +import de.softwareapp_hb.filemanager.core.FileCategory import de.softwareapp_hb.filemanager.core.FileSize import de.softwareapp_hb.filemanager.core.StoragePath +import de.softwareapp_hb.filemanager.text.labelRes /** A row on the root screen: a volume, with its free space when the platform reports it. */ @OptIn(ExperimentalFoundationApi::class) @@ -88,6 +90,66 @@ fun EmptyState(message: String, modifier: Modifier = Modifier) { } } +/** The heading over the libraries on the home screen. */ +@Composable +fun LibrarySectionHeader(modifier: Modifier = Modifier) { + Text( + text = stringResource(R.string.library_section), + style = MaterialTheme.typography.titleSmall, + color = MaterialTheme.colorScheme.primary, + modifier = modifier.padding(start = 16.dp, top = 12.dp, bottom = 4.dp), + ) +} + +/** + * A row on the home screen: one library, and how many files the index knows it holds. + * + * The count is `null` rather than `0` when the index has nothing to say — see + * [BrowserUiState.libraryCounts] — so the row shows a name and nothing else rather than claiming + * the library is empty. + */ +@OptIn(ExperimentalFoundationApi::class) +@Composable +fun LibraryRow( + category: FileCategory, + /** The index's count for this library, or `null` when it has nothing to say. */ + count: Int?, + modifier: Modifier = Modifier, + selected: Boolean = false, + onClick: () -> Unit, + onDoubleClick: (() -> Unit)? = null, +) { + ListItem( + modifier = modifier + .fillMaxWidth() + .combinedClickable(onClick = onClick, onDoubleClick = onDoubleClick), + colors = if (selected) { + ListItemDefaults.colors(containerColor = MaterialTheme.colorScheme.primaryContainer) + } else { + ListItemDefaults.colors() + }, + leadingContent = { + Icon( + imageVector = iconFor(category), + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + ) + }, + headlineContent = { Text(text = stringResource(category.labelRes())) }, + supportingContent = when (count) { + null -> null + else -> { + { + Text( + text = pluralStringResource(R.plurals.browser_item_count, count, count), + style = MaterialTheme.typography.bodySmall, + ) + } + } + }, + ) +} + /** * The breadcrumb trail. * diff --git a/app/src/main/java/de/softwareapp_hb/filemanager/ui/browser/BrowserScreen.kt b/app/src/main/java/de/softwareapp_hb/filemanager/ui/browser/BrowserScreen.kt index 72a9984..35fe978 100644 --- a/app/src/main/java/de/softwareapp_hb/filemanager/ui/browser/BrowserScreen.kt +++ b/app/src/main/java/de/softwareapp_hb/filemanager/ui/browser/BrowserScreen.kt @@ -68,6 +68,8 @@ import de.softwareapp_hb.filemanager.R import de.softwareapp_hb.filemanager.core.ConflictPolicy import de.softwareapp_hb.filemanager.core.FileEntry import de.softwareapp_hb.filemanager.core.StoragePath +import de.softwareapp_hb.filemanager.text.labelRes +import de.softwareapp_hb.filemanager.ui.duplicates.DuplicateScreen import de.softwareapp_hb.filemanager.ui.trash.TrashScreen import de.softwareapp_hb.filemanager.ui.usage.UsageScreen import de.softwareapp_hb.filemanager.ui.viewer.ApkInfoScreen @@ -108,9 +110,9 @@ fun BrowserScreen( val listState = rememberLazyListState() val gridState = rememberLazyGridState() - // A different folder starts at the top. Keyed on the folder alone, so coming back from a - // viewer — which never changes the folder — keeps the user exactly where they were. - LaunchedEffect(state.path) { + // A different folder — or a different library — starts at the top. Keyed on those alone, so + // coming back from a viewer keeps the user exactly where they were. + LaunchedEffect(state.path, state.category) { listState.scrollToItem(0) gridState.scrollToItem(0) } @@ -276,6 +278,21 @@ fun BrowserScreen( return } + is Overlay.Duplicates -> { + DuplicateScreen( + groups = overlay.groups, + progress = overlay.progress, + loading = overlay.loading, + unreadable = overlay.unreadable, + candidates = overlay.candidates, + selection = overlay.selection, + onToggle = viewModel::toggleDuplicateSelection, + onDelete = viewModel::deleteSelectedDuplicates, + onClose = viewModel::closeOverlay, + ) + return + } + is Overlay.Usage -> { UsageScreen( nodes = overlay.nodes, @@ -322,7 +339,10 @@ fun BrowserScreen( val crumbLabel: (StoragePath) -> String = { path -> if (path.isRoot || state.roots.any { it.path == path }) rootLabel else path.name } + val category = state.category val title = when { + // A library is named by what it holds rather than by where it is. + category != null -> stringResource(category.labelRes()) state.isRootView -> stringResource(R.string.browser_roots_title) state.path == null || state.path!!.isRoot || state.roots.any { it.path == state.path } -> rootLabel else -> state.path!!.name @@ -362,11 +382,14 @@ fun BrowserScreen( onPaste = viewModel::paste, onRefresh = viewModel::refresh, onUsage = viewModel::openUsage, + onDuplicates = viewModel::findDuplicates, onTrash = viewModel::openTrash, ) }, floatingActionButton = { - if (!state.isRootView && !state.isSelecting && !state.loading && !state.needsTreeGrant) { + // A new folder needs a folder to go into, so the action is absent from the root list + // and from a library, neither of which is one. + if (state.path != null && !state.isSelecting && !state.loading && !state.needsTreeGrant) { FloatingActionButton(onClick = { showNewFolder = true }) { Icon( imageVector = Icons.Filled.CreateNewFolder, @@ -439,6 +462,7 @@ fun BrowserScreen( onToggleSelection = viewModel::toggleSelection, onOpenRoot = viewModel::openDirectory, onNavigate = viewModel::openDirectory, + onOpenCategory = viewModel::openCategory, onGrantAllFiles = { val missing = provider.missingRuntimePermissions() if (missing.isEmpty()) { diff --git a/app/src/main/java/de/softwareapp_hb/filemanager/ui/browser/BrowserTopBar.kt b/app/src/main/java/de/softwareapp_hb/filemanager/ui/browser/BrowserTopBar.kt index 52d64bc..9dd7ef7 100644 --- a/app/src/main/java/de/softwareapp_hb/filemanager/ui/browser/BrowserTopBar.kt +++ b/app/src/main/java/de/softwareapp_hb/filemanager/ui/browser/BrowserTopBar.kt @@ -7,6 +7,7 @@ import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.filled.Close import androidx.compose.material.icons.filled.Delete import androidx.compose.material.icons.filled.Folder +import androidx.compose.material.icons.filled.FileCopy import androidx.compose.material.icons.filled.MoreVert import androidx.compose.material.icons.filled.PieChart import androidx.compose.material.icons.filled.Public @@ -55,6 +56,7 @@ fun BrowserTopBar( onPaste: () -> Unit, onRefresh: () -> Unit, onUsage: () -> Unit, + onDuplicates: () -> Unit, onTrash: () -> Unit, ) { var menuOpen by remember { mutableStateOf(false) } @@ -165,6 +167,11 @@ fun BrowserTopBar( onClick = { menuOpen = false; onPaste() }, ) } + DropdownMenuItem( + text = { Text(stringResource(R.string.browser_action_duplicates)) }, + leadingIcon = { Icon(Icons.Filled.FileCopy, contentDescription = null) }, + onClick = { menuOpen = false; onDuplicates() }, + ) DropdownMenuItem( text = { Text(stringResource(R.string.browser_action_disk_usage)) }, leadingIcon = { Icon(Icons.Filled.PieChart, contentDescription = null) }, diff --git a/app/src/main/java/de/softwareapp_hb/filemanager/ui/browser/BrowserUiState.kt b/app/src/main/java/de/softwareapp_hb/filemanager/ui/browser/BrowserUiState.kt index a7dc3ab..5a66d16 100644 --- a/app/src/main/java/de/softwareapp_hb/filemanager/ui/browser/BrowserUiState.kt +++ b/app/src/main/java/de/softwareapp_hb/filemanager/ui/browser/BrowserUiState.kt @@ -5,12 +5,15 @@ import androidx.activity.result.IntentSenderRequest import androidx.annotation.AnyRes import androidx.annotation.StringRes import de.softwareapp_hb.filemanager.core.ConflictPolicy +import de.softwareapp_hb.filemanager.core.FileCategory import de.softwareapp_hb.filemanager.core.FileEntry import de.softwareapp_hb.filemanager.core.SortSpec import de.softwareapp_hb.filemanager.core.StoragePath import de.softwareapp_hb.filemanager.data.ApkInfo import de.softwareapp_hb.filemanager.data.StorageAccessMode import de.softwareapp_hb.filemanager.domain.ArchiveEntry +import de.softwareapp_hb.filemanager.domain.DuplicateGroup +import de.softwareapp_hb.filemanager.domain.DuplicateProgress import de.softwareapp_hb.filemanager.domain.TrashBatch import de.softwareapp_hb.filemanager.domain.UsageNode @@ -155,6 +158,26 @@ sealed interface Overlay { val loading: Boolean, ) : Overlay + /** + * Files that hold the same bytes, found by comparing them rather than by trusting a name or a + * date. + * + * [selection] holds **spare copies** — the rows this screen deletes — and it starts empty: the + * one decision a duplicate screen exists to hand to the user is which copy to keep, and + * preselecting anything would make that decision on their behalf. + */ + data class Duplicates( + val groups: List = emptyList(), + /** How far the running scan has got; `null` once it has finished or before it starts. */ + val progress: DuplicateProgress? = null, + val loading: Boolean = true, + /** Files whose bytes could not be read, so could be neither confirmed nor denied. */ + val unreadable: Int = 0, + /** How many candidate files the scan is comparing — the honest denominator for "found none". */ + val candidates: Int = 0, + val selection: Set = emptySet(), + ) : Overlay + /** * What the trash still holds, rebuilt from the index rather than from memory. * @@ -180,6 +203,23 @@ data class BrowserUiState( val roots: List = emptyList(), /** `null` means "showing the root list" rather than a directory. */ val path: StoragePath? = null, + /** + * The library being shown, or `null` when this is a folder or the root list. + * + * A library has no path: its rows come from the index rather than from one directory, and they + * can be anywhere on the device. That is why it is a field of its own rather than a special + * [path] — [isRootView], the up action and the title all have to tell three things apart, not + * two. + */ + val category: FileCategory? = null, + /** + * How many indexed files each library holds; empty when the index has nothing to say. + * + * The home screen shows a count only when there is one. A library showing `0` would claim + * there are no photos on the device, which is not something an index that has never run can + * know — the same distinction the search row draws between "not indexed yet" and "none". + */ + val libraryCounts: Map = emptyMap(), val entries: List = emptyList(), val breadcrumbs: List = emptyList(), val query: String = "", @@ -236,7 +276,7 @@ data class BrowserUiState( */ val returnTo: Overlay? = null, ) { - val isRootView: Boolean get() = path == null + val isRootView: Boolean get() = path == null && category == null val isSelecting: Boolean get() = selection.isNotEmpty() diff --git a/app/src/main/java/de/softwareapp_hb/filemanager/ui/browser/BrowserViewModel.kt b/app/src/main/java/de/softwareapp_hb/filemanager/ui/browser/BrowserViewModel.kt index 8a6d0e4..6e11317 100644 --- a/app/src/main/java/de/softwareapp_hb/filemanager/ui/browser/BrowserViewModel.kt +++ b/app/src/main/java/de/softwareapp_hb/filemanager/ui/browser/BrowserViewModel.kt @@ -13,6 +13,7 @@ import de.softwareapp_hb.filemanager.FileManagerApplication import de.softwareapp_hb.filemanager.R import de.softwareapp_hb.filemanager.core.ArchiveFormats import de.softwareapp_hb.filemanager.core.ConflictPolicy +import de.softwareapp_hb.filemanager.core.FileCategory import de.softwareapp_hb.filemanager.core.FileEntry import de.softwareapp_hb.filemanager.core.FileKind import de.softwareapp_hb.filemanager.core.NameAllocator @@ -32,6 +33,7 @@ import de.softwareapp_hb.filemanager.data.StorageSource import de.softwareapp_hb.filemanager.data.TrashIndex import de.softwareapp_hb.filemanager.domain.ArchiveEngine import de.softwareapp_hb.filemanager.domain.ArchiveEntry +import de.softwareapp_hb.filemanager.domain.DuplicateFinder import de.softwareapp_hb.filemanager.domain.FileOperations import de.softwareapp_hb.filemanager.domain.FileSearch import de.softwareapp_hb.filemanager.domain.OperationFailure @@ -100,6 +102,12 @@ class BrowserViewModel( /** The running recursive search, so the next keystroke can supersede it. */ private var searchJob: Job? = null + /** The library being loaded, so leaving one mid-read cannot land its rows on the next screen. */ + private var libraryJob: Job? = null + + /** The duplicate scan, which reads files: cancelling it is how the reading stops. */ + private var duplicateJob: Job? = null + /** Makes each preview copy a distinct name, so two previews cannot overwrite each other. */ private var scratchCounter: Int = 0 @@ -130,12 +138,15 @@ class BrowserViewModel( } val path = _state.value.path + _state.value.category?.let { return loadLibrary(it, walk = false) } if (path == null) loadRoots() else load(path) } fun openDirectory(path: StoragePath) = load(path) fun navigateUp() { + // A library has nowhere above it but the root list. + if (_state.value.category != null) return loadRoots() val path = _state.value.path ?: return val parent = path.parent val isVolumeRoot = storage.volumes().any { it.root == path } @@ -144,12 +155,14 @@ class BrowserViewModel( private fun loadRoots() { searchJob?.cancel() + libraryJob?.cancel() viewModelScope.launch { _state.update { it.copy(loading = true) } listing = emptyList() _state.update { it.copy( path = null, + category = null, breadcrumbs = emptyList(), selection = emptySet(), focus = null, @@ -158,6 +171,9 @@ class BrowserViewModel( searching = false, freeBytes = null, loading = false, + // The counts belong to the root screen, so they are read when it is shown + // rather than kept from whenever the index last ran. + libraryCounts = libraryCountsOf(), ) } recompute() @@ -169,12 +185,14 @@ class BrowserViewModel( val source = storage.activeSource() ?: return // Navigating abandons any search: its results belonged to the folder being left. searchJob?.cancel() + libraryJob?.cancel() viewModelScope.launch { _state.update { it.copy(loading = true) } listing = runCatching { source.list(path) }.getOrDefault(emptyList()) _state.update { it.copy( path = path, + category = null, breadcrumbs = breadcrumbsOf(path), selection = emptySet(), focus = null, @@ -240,6 +258,129 @@ class BrowserViewModel( } } + // ----------------------------------------------------------------- libraries + + /** + * Open a library: everything of a kind, or the most recently changed files. + * + * The one listing in the app that is not a directory. Its rows come from the index, so it + * inherits the index's rules verbatim — **the answer comes first**, and **every row is + * confirmed against the filesystem** before it is shown — and, like a search everywhere, it + * brings the index up to date *behind* that answer rather than in front of it. On a device + * that has never been indexed the first tap therefore starts a walk; the screen says so, with + * the progress the search row already provides, instead of showing an empty library that a + * user would reasonably read as an empty device. + */ + fun openCategory(category: FileCategory) = loadLibrary(category, walk = true) + + /** + * Show a library, and optionally bring the index current behind it. + * + * [walk] is `false` for a *re-read* — returning to the app, or a mutation that has to leave the + * listing honest — because a resume must not start a walk the user did not ask for. The + * index's age is on screen either way, with Rebuild beside it. + */ + private fun loadLibrary(category: FileCategory, walk: Boolean) { + val source = storage.activeSource() ?: return + searchJob?.cancel() + libraryJob?.cancel() + libraryJob = viewModelScope.launch { + _state.update { + it.copy( + loading = true, + category = category, + // A library is not inside a folder, and leaving these set would draw a + // breadcrumb trail for a place the user is not. + path = null, + breadcrumbs = emptyList(), + selection = emptySet(), + focus = null, + query = "", + searchRoot = null, + searching = false, + freeBytes = null, + indexedAt = searchIndex.indexedAt(), + ) + } + + showLibrary(category, source) + if (walk) bringIndexCurrent(source) + + // Only if the user is still here: another library, or a folder, is another job, and + // this one must not paint over what that job is showing. + if (_state.value.category == category) { + showLibrary(category, source) + _state.update { it.copy(loading = false, libraryCounts = libraryCountsOf()) } + persistViewState() + } + } + } + + /** + * Read a library's rows out of the index, dropping the ones whose file is no longer there. + * + * Deliberately the same confirmation the indexed search performs, and for the same reason: the + * index points at files rather than being the truth about them, so a row is only a candidate + * until the filesystem agrees — and a candidate that has gone is forgotten, so the next visit + * does not pay for it again. + */ + private suspend fun showLibrary(category: FileCategory, source: StorageSource) { + val rows = runCatching { + if (category.isTimeBased) { + searchIndex.recent(LIBRARY_LIMIT) + } else { + searchIndex.byKind(category.kinds, LIBRARY_LIMIT) + } + }.getOrDefault(emptyList()) + + val confirmed = ArrayList(rows.size) + for (row in rows) { + if (!currentCoroutineContext().isActive) return + if (source.exists(row.path)) { + confirmed += row.toEntry() + } else { + searchIndex.forget(row.path) + } + } + + // Through `listing` and `recompute`, so a library sorts and hides exactly like a folder. + listing = confirmed + recompute() + } + + /** + * The home screen's counts, summed from the index's per-kind totals. + * + * An empty index yields no counts at all rather than a row of zeroes: "you have no photos" is + * not something an index that has never run can say. An index that *has* run does get to say + * it, which is why a real zero is shown as one. + */ + private suspend fun libraryCountsOf(): Map { + val byKind = runCatching { searchIndex.kindCounts() }.getOrDefault(emptyMap()) + if (byKind.isEmpty()) return emptyMap() + return FileCategory.entries + .filterNot { it.isTimeBased } + .associateWith { category -> category.kinds.sumOf { byKind[it] ?: 0 } } + } + + /** + * Re-read whatever is on screen, after something changed it. + * + * Three things can be: a folder, a library, or the root list. Every mutation ends here rather + * than at `load(path)` so that deleting from inside a library actually removes the row — while + * still not throwing the user out to the roots merely because there was no path to reload. + */ + private fun reloadCurrentView() { + val current = _state.value + val category = current.category + val path = current.path + when { + category != null -> loadLibrary(category, walk = false) + path != null -> load(path) + else -> refresh() + } + } + // --------------------------------------------------- persisted view state /** @@ -366,30 +507,9 @@ class BrowserViewModel( } searchJob = viewModelScope.launch { - val volumes = volumes() - val stampAt = clock() - val stamps = searchIndex.stamps() - val hidden = _state.value.showHidden + if (searchIndex.stamps().isNotEmpty()) showIndexedResults(query, source) - if (stamps.isNotEmpty()) showIndexedResults(query, source) - - // A volume whose index was built with the other hidden-files preference, or which - // has not been walked end to end for a long time, needs a walk rather than a - // catch-up: a catch-up compares names, so it cannot see a name that is there both - // times but is not the file it was, and the preference is part of what rows mean. - val toWalk = volumes.filter { volume -> - val stamp = stamps[volume.value] - stamp == null || - stamp.hiddenIncluded != hidden || - stampAt - stamp.fullAtMillis > INDEX_FULL_PASS_AFTER_MILLIS - } - if (toWalk.isNotEmpty()) pass(source, toWalk, full = true) - - val toCatchUp = volumes.filterNot(toWalk::contains).filter { volume -> - val stamp = stamps[volume.value] - stamp == null || stampAt - stamp.indexedAtMillis > INDEX_CATCH_UP_AFTER_MILLIS - } - if (toCatchUp.isNotEmpty()) pass(source, toCatchUp, full = false) + bringIndexCurrent(source) // Answered again, because the pass may have found the file this search is for — // which is the whole of "I just downloaded it, so why is it not here?". @@ -405,6 +525,37 @@ class BrowserViewModel( } } + /** + * Bring every volume's index up to date, and say so while it happens. + * + * The policy is here rather than in its callers because search and the library views must + * agree about it: a volume that was never built, was built with the other hidden-files + * preference, or has not been walked end to end for a week needs a **walk** — a catch-up + * compares names, so it cannot see a name that is there both times but is not the file it was, + * and the preference is part of what a row means. Everything else needs a **catch-up**, which + * reads names and nothing else. + */ + private suspend fun bringIndexCurrent(source: StorageSource) { + val volumes = volumes() + val stampAt = clock() + val stamps = searchIndex.stamps() + val hidden = _state.value.showHidden + + val toWalk = volumes.filter { volume -> + val stamp = stamps[volume.value] + stamp == null || + stamp.hiddenIncluded != hidden || + stampAt - stamp.fullAtMillis > INDEX_FULL_PASS_AFTER_MILLIS + } + if (toWalk.isNotEmpty()) pass(source, toWalk, full = true) + + val toCatchUp = volumes.filterNot(toWalk::contains).filter { volume -> + val stamp = stamps[volume.value] + stamp == null || stampAt - stamp.indexedAtMillis > INDEX_CATCH_UP_AFTER_MILLIS + } + if (toCatchUp.isNotEmpty()) pass(source, toCatchUp, full = false) + } + /** Answer [query] from the index, confirming every candidate against the filesystem. */ private suspend fun showIndexedResults(query: String, source: StorageSource) { val hits = runCatching { searchIndex.find(query, FileSearch.DEFAULT_LIMIT) } @@ -468,7 +619,15 @@ class BrowserViewModel( val source = storage.activeSource() ?: return viewModelScope.launch { pass(source, volumes(), full = true) - setQuery(_state.value.query) + // Answered again, in whichever view asked for the rebuild: a search re-runs its query, + // a library re-reads its rows, and the home screen's counts follow either way. + val category = _state.value.category + if (category == null) { + setQuery(_state.value.query) + } else if (_state.value.category == category) { + showLibrary(category, source) + _state.update { it.copy(libraryCounts = libraryCountsOf()) } + } } } @@ -803,7 +962,7 @@ class BrowserViewModel( }.isSuccess message(if (saved) R.string.message_saved else R.string.message_save_failed) _state.update { it.copy(overlay = Overlay.None) } - _state.value.path?.let { load(it) } + reloadCurrentView() } } @@ -899,11 +1058,97 @@ class BrowserViewModel( // Re-read rather than dropping the batch locally: the truth is the index *and* // the filesystem, and a partial restore changes both. openTrash() - _state.value.path?.let { load(it) } + reloadCurrentView() } } + // --------------------------------------------------------------- duplicates + + /** + * Scan for files that hold the same bytes. + * + * Unlike every other screen in this app, there is no answer to show while the work happens: + * whether two files are the same *is* the work. So this is the one place that shows progress + * instead of results, and the progress says which of the two passes is running — reading the + * beginning of every candidate, or reading the survivors in full — because those cost very + * different amounts of time and a user is owed the difference. + * + * The candidate list comes from the index, which already knows every file's size, so the free + * pass costs one query. If the index has never been built there are no candidates and the + * screen says so rather than reporting a clean device. + */ + fun findDuplicates() { + val source = storage.activeSource() ?: return + duplicateJob?.cancel() + _state.update { it.copy(overlay = Overlay.Duplicates()) } + + duplicateJob = viewModelScope.launch { + val candidates = runCatching { + searchIndex.duplicateCandidates(DUPLICATE_MIN_BYTES, DUPLICATE_CANDIDATES) + }.getOrDefault(emptyList()) + + _state.update { current -> + val overlay = current.overlay as? Overlay.Duplicates ?: return@update current + current.copy(overlay = overlay.copy(candidates = candidates.size)) + } + + val scan = DuplicateFinder(source).find(candidates) { progress -> + _state.update { current -> + val overlay = current.overlay as? Overlay.Duplicates ?: return@update current + current.copy(overlay = overlay.copy(progress = progress)) + } + } + + _state.update { current -> + val overlay = current.overlay as? Overlay.Duplicates ?: return@update current + current.copy( + overlay = overlay.copy( + groups = scan.groups, + unreadable = scan.unreadable, + loading = false, + progress = null, + ), + ) + } + } + } + + /** Tick a spare copy in or out of the delete. */ + fun toggleDuplicateSelection(path: StoragePath) { + _state.update { current -> + val overlay = current.overlay as? Overlay.Duplicates ?: return@update current + val selection = if (path in overlay.selection) { + overlay.selection - path + } else { + overlay.selection + path + } + current.copy(overlay = overlay.copy(selection = selection)) + } + } + + /** + * Delete the ticked copies. + * + * The scan is closed rather than patched afterwards. Its rows came from comparing bytes, and + * quietly dropping some of them would leave a list that still looks like a scan and no longer + * is — the count on the last group's header would be a number nobody measured. Closing also + * puts the undo snackbar and the trash in front of the user, which is where a delete should + * end: the files are recoverable, and the screen that offered to remove them is gone. + */ + fun deleteSelectedDuplicates() { + val overlay = _state.value.overlay as? Overlay.Duplicates ?: return + if (overlay.selection.isEmpty()) return + val paths = overlay.selection.toList() + + _state.update { it.copy(overlay = Overlay.None) } + delete(paths) + } + fun closeOverlay() { + // A scan that is still reading has nothing left to show, and reading a library of files + // nobody is waiting for is exactly the work cancel exists for. + duplicateJob?.cancel() + // A preview of an archive entry may have left a copy in the cache; closing the // preview is the end of that copy's reason to exist. discardScratch() @@ -1011,7 +1256,7 @@ class BrowserViewModel( report(result) } // The folder behind the preview has a new file in it now. - _state.value.path?.let { load(it) } + reloadCurrentView() } } @@ -1415,7 +1660,7 @@ class BrowserViewModel( } else { message(R.string.message_name_in_use) } - _state.value.path?.let { load(it) } + reloadCurrentView() } } @@ -1632,6 +1877,35 @@ class BrowserViewModel( */ const val INDEX_FULL_PASS_AFTER_MILLIS = 7L * 24L * 60L * 60L * 1000L + /** + * How many rows a library view asks for. + * + * Larger than a search's 200 because a library is the point of its screen rather than a + * narrowed list: 500 is more than anyone scrolls in one go, and few enough that the + * listing arrives without a visible wait. Past the cap the index answers with the newest + * rows — see [de.softwareapp_hb.filemanager.data.SearchIndex.byKind] — so what falls off + * the end is the oldest, which is the right thing to lose. + */ + const val LIBRARY_LIMIT = 500 + + /** + * The smallest file a duplicate scan will consider. + * + * An empty or 200-byte file wastes nothing worth a read, and a phone holds thousands of + * them — they would fill the candidate list without ever finding space worth having. 1 KB + * is where "this is a real file" starts. + */ + const val DUPLICATE_MIN_BYTES = 1024L + + /** + * How many candidate files one scan will read. + * + * The cap bounds *reads*, which is what costs time, and it keeps the largest candidates: + * that is where the reclaimable space is. A device with more same-sized files than this + * does not have a duplicate problem a list of two thousand rows would solve. + */ + const val DUPLICATE_CANDIDATES = 2000 + val Factory: ViewModelProvider.Factory = viewModelFactory { initializer { val app = this[ViewModelProvider.AndroidViewModelFactory.APPLICATION_KEY] diff --git a/app/src/main/java/de/softwareapp_hb/filemanager/ui/duplicates/DuplicateScreen.kt b/app/src/main/java/de/softwareapp_hb/filemanager/ui/duplicates/DuplicateScreen.kt new file mode 100644 index 0000000..c51579a --- /dev/null +++ b/app/src/main/java/de/softwareapp_hb/filemanager/ui/duplicates/DuplicateScreen.kt @@ -0,0 +1,246 @@ +package de.softwareapp_hb.filemanager.ui.duplicates + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyListScope +import androidx.compose.foundation.lazy.items +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Close +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.LinearProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.pluralStringResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import de.softwareapp_hb.filemanager.R +import de.softwareapp_hb.filemanager.core.FileEntry +import de.softwareapp_hb.filemanager.core.FileSize +import de.softwareapp_hb.filemanager.core.StoragePath +import de.softwareapp_hb.filemanager.domain.DuplicateGroup +import de.softwareapp_hb.filemanager.domain.DuplicateProgress +import de.softwareapp_hb.filemanager.ui.browser.EntryRow + +/** + * The duplicate scan: what was compared, what matched, and which copies to remove. + * + * The one screen in the app that cannot show an answer before it has one, because the answer *is* + * the comparison — so when it has nothing to show it shows what it is reading instead, and which + * of the two passes is doing the reading. "Found nothing" is never claimed without saying how many + * files were looked at, and a copy is shown with its folder, because a list of identical names + * with no paths would be a list nobody could act on. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun DuplicateScreen( + groups: List, + progress: DuplicateProgress?, + loading: Boolean, + unreadable: Int, + candidates: Int, + selection: Set, + onToggle: (StoragePath) -> Unit, + onDelete: () -> Unit, + onClose: () -> Unit, +) { + Scaffold( + topBar = { + TopAppBar( + title = { Text(stringResource(R.string.duplicates_title)) }, + navigationIcon = { + IconButton(onClick = onClose) { + Icon( + imageVector = Icons.Filled.Close, + contentDescription = stringResource(R.string.browser_action_close), + ) + } + }, + actions = { + // The label counts what it will delete, so the action cannot be mistaken for + // "delete the duplicates" — it deletes exactly the rows that are ticked. + if (selection.isNotEmpty()) { + TextButton(onClick = onDelete) { + Text( + stringResource( + R.string.duplicates_action_delete, + pluralStringResource( + R.plurals.duplicates_copies, + selection.size, + selection.size, + ), + ), + ) + } + } + }, + ) + }, + ) { padding -> + Column( + modifier = Modifier + .fillMaxSize() + .padding(padding), + ) { + when { + loading || progress != null -> ScanRow(progress) + + groups.isEmpty() -> EmptyDuplicates(candidates = candidates, unreadable = unreadable) + + else -> LazyColumn(modifier = Modifier.fillMaxSize()) { + duplicateGroups( + groups = groups, + selection = selection, + onToggle = onToggle, + ) + } + } + } + } +} + +/** + * The scan in progress. + * + * The two passes are named differently because they are different jobs: reading the head of every + * candidate is cheap and touches all of them, while comparing survivors reads only a few files but + * reads each one to the end. Calling both "scanning" would hide the moment the work gets expensive. + */ +@Composable +private fun ScanRow(progress: DuplicateProgress?) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + ) { + progress?.let { running -> + Text( + text = when (running.stage) { + DuplicateProgress.Stage.PARTIAL -> + stringResource(R.string.duplicates_checking, running.done, running.total) + + DuplicateProgress.Stage.FULL -> + stringResource(R.string.duplicates_comparing, running.done, running.total) + }, + style = MaterialTheme.typography.bodyMedium, + ) + Spacer(Modifier.height(8.dp)) + } + LinearProgressIndicator( + progress = { progress?.let { it.done.toFloat() / it.total.coerceAtLeast(1) } ?: 0f }, + modifier = Modifier.fillMaxWidth(), + ) + } +} + +/** + * One group per identical set: how many copies, how large each is, and what removing the spare ones + * would give back, followed by the copies themselves. + */ +private fun LazyListScope.duplicateGroups( + groups: List, + selection: Set, + onToggle: (StoragePath) -> Unit, +) { + for (group in groups) { + // A group's identity is its first path. Two groups can share a size — that is what the + // comparison separated them by — but no two can share a path. + item(key = "duplicates-${group.paths.first().value}") { GroupHeader(group) } + items(items = group.paths, key = { it.value }) { path -> + EntryRow( + entry = FileEntry( + path = path, + isDirectory = false, + sizeBytes = group.sizeBytes, + // This screen is about what a file *holds*, and the row shows the size rather + // than a date, so there is no timestamp to be wrong about here. + lastModifiedMillis = 0L, + ), + selected = path in selection, + // Every row carries a tick box: which of these copies to delete is the decision + // this screen exists to hand over, and it must be visible before it is made. + selectionMode = true, + // The folder, so two copies of the same name are told apart by where they live. + location = path.parent?.value, + onClick = { onToggle(path) }, + onLongClick = { onToggle(path) }, + ) + } + } +} + +@Composable +private fun GroupHeader(group: DuplicateGroup) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(start = 16.dp, end = 16.dp, top = 16.dp, bottom = 4.dp), + ) { + Text( + text = pluralStringResource( + R.plurals.duplicates_copies, + group.paths.size, + group.paths.size, + ) + " · " + FileSize.format(group.sizeBytes), + style = MaterialTheme.typography.titleSmall, + ) + Text( + text = stringResource(R.string.duplicates_free, FileSize.format(group.reclaimableBytes)), + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } +} + +@Composable +private fun EmptyDuplicates(candidates: Int, unreadable: Int) { + Column( + modifier = Modifier + .fillMaxSize() + .padding(24.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { + Text( + // "Nothing found" is only an answer when it says what was looked at. No candidates at + // all means the index has never been built, which is a different sentence entirely. + text = stringResource( + if (candidates == 0) { + R.string.duplicates_nothing_checked + } else { + R.string.duplicates_empty + }, + ), + style = MaterialTheme.typography.titleMedium, + ) + if (candidates > 0) { + Spacer(Modifier.height(8.dp)) + Text( + text = stringResource(R.string.duplicates_checked, candidates), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + if (unreadable > 0) { + Spacer(Modifier.height(4.dp)) + Text( + text = stringResource(R.string.duplicates_unreadable, unreadable), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.error, + ) + } + } +} diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index e546ae8..56f0bb3 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -51,6 +51,43 @@ Not indexed yet Rebuild + + Libraries + Images + Videos + Audio + Documents + Archives + Recent + + No files indexed here yet + + + Find duplicates + Duplicates + + Checking %1$s of %2$s… + Comparing %1$s of %2$s… + + No two files hold the same bytes + Compared %1$s candidate files + %1$s could not be read + Nothing on the device shares a file size + + %1$s could be freed + + Delete %1$s + + %1$d copy + %1$d copies + + New folder Sort diff --git a/app/src/test/java/de/softwareapp_hb/filemanager/core/FileCategoryTest.kt b/app/src/test/java/de/softwareapp_hb/filemanager/core/FileCategoryTest.kt new file mode 100644 index 0000000..6650c1c --- /dev/null +++ b/app/src/test/java/de/softwareapp_hb/filemanager/core/FileCategoryTest.kt @@ -0,0 +1,68 @@ +package de.softwareapp_hb.filemanager.core + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * The grouping rules, which are a policy decision rather than a computation — so what is worth + * pinning down is not "does `of` return something" but *which* kinds a library claims, and + * which kinds are left out on purpose. + */ +class FileCategoryTest { + + @Test + fun `the kinds that belong to no library are exactly the three that were decided`() { + val collected = FileCategory.entries.flatMapTo(HashSet()) { it.kinds } + + assertEquals( + "a kind nobody collects must be a decision, not an oversight", + setOf(FileKind.DIRECTORY, FileKind.APK, FileKind.OTHER), + FileKind.entries.toSet() - collected, + ) + } + + @Test + fun `documents covers text as well as documents`() { + // A `.txt` file is a document to the person looking for it, even though the app opens + // it in the text editor rather than the PDF viewer. + assertTrue(FileKind.TEXT in FileCategory.DOCUMENTS.kinds) + assertEquals(FileCategory.DOCUMENTS, FileCategory.of(FileKind.TEXT)) + assertEquals(FileCategory.DOCUMENTS, FileCategory.of(FileKind.DOCUMENT)) + } + + @Test + fun `every kind maps back to the library that claims it`() { + assertEquals(FileCategory.IMAGES, FileCategory.of(FileKind.IMAGE)) + assertEquals(FileCategory.VIDEOS, FileCategory.of(FileKind.VIDEO)) + assertEquals(FileCategory.AUDIO, FileCategory.of(FileKind.AUDIO)) + assertEquals(FileCategory.ARCHIVES, FileCategory.of(FileKind.ARCHIVE)) + } + + @Test + fun `an uncollected kind answers null`() { + assertNull(FileCategory.of(FileKind.OTHER)) + assertNull(FileCategory.of(FileKind.DIRECTORY)) + assertNull(FileCategory.of(FileKind.APK)) + } + + @Test + fun `recent is the only library that is not about a kind`() { + assertTrue(FileCategory.RECENT.isTimeBased) + assertEquals(emptySet(), FileCategory.RECENT.kinds) + assertEquals( + listOf(FileCategory.RECENT), + FileCategory.entries.filter { it.isTimeBased }, + ) + } + + @Test + fun `no kind is claimed by two libraries`() { + val claimed = FileCategory.entries.flatMap { it.kinds } + + // A file that belonged to two libraries would appear twice in a home screen count, + // and "how many images do I have" would stop adding up. + assertEquals(claimed.size, claimed.toSet().size) + } +} diff --git a/app/src/test/java/de/softwareapp_hb/filemanager/data/SqliteSearchIndexTest.kt b/app/src/test/java/de/softwareapp_hb/filemanager/data/SqliteSearchIndexTest.kt index 9e39a2c..5428322 100644 --- a/app/src/test/java/de/softwareapp_hb/filemanager/data/SqliteSearchIndexTest.kt +++ b/app/src/test/java/de/softwareapp_hb/filemanager/data/SqliteSearchIndexTest.kt @@ -1,6 +1,7 @@ package de.softwareapp_hb.filemanager.data import android.database.sqlite.SQLiteDatabase +import de.softwareapp_hb.filemanager.core.FileKind import de.softwareapp_hb.filemanager.core.StoragePath import java.io.File import kotlinx.coroutines.runBlocking @@ -27,15 +28,28 @@ class SqliteSearchIndexTest { private fun index() = SqliteSearchIndex(RuntimeEnvironment.getApplication()) - private fun file(name: String, inVolume: String = volume) = IndexedFile( + private fun file( + name: String, + inVolume: String = volume, + modified: Long = 0L, + isDirectory: Boolean = false, + size: Long = 10L, + ) = IndexedFile( volume = inVolume, path = StoragePath.of("$inVolume/$name"), name = name.substringAfterLast('/'), - isDirectory = false, - sizeBytes = 10L, - modifiedMillis = 0L, + isDirectory = isDirectory, + sizeBytes = size, + modifiedMillis = modified, ) + /** Rows written straight in, for the queries where the date each was changed is the point. */ + private suspend fun SqliteSearchIndex.rows(vararg rows: IndexedFile, at: Long = 1000L) { + beginVolume(volume) + add(rows.toList()) + endVolume(volume, at, full = true, hiddenIncluded = false) + } + /** One finished pass over [volume], as an indexer would leave it. */ private suspend fun SqliteSearchIndex.filled( vararg names: String, @@ -224,6 +238,94 @@ class SqliteSearchIndexTest { assertTrue(index.stamps().getValue(volume).hiddenIncluded) } + @Test + fun `a library query returns only the kinds it was asked for`() = runBlocking { + val index = index() + index.rows( + file("holiday.jpg", modified = 10L), + file("report.pdf", modified = 20L), + file("song.mp3", modified = 30L), + ) + + assertEquals(listOf("holiday.jpg"), names(index.byKind(setOf(FileKind.IMAGE), 10))) + } + + @Test + fun `a library query is newest first, so the cap keeps the newest`() = runBlocking { + val index = index() + index.rows( + file("old.jpg", modified = 100L), + file("newest.jpg", modified = 300L), + file("middle.jpg", modified = 200L), + ) + + assertEquals( + listOf("newest.jpg", "middle.jpg"), + names(index.byKind(setOf(FileKind.IMAGE), 2)), + ) + } + + @Test + fun `a library of two kinds holds both of them`() = runBlocking { + // DOCUMENTS is Document plus Text: a `.md` note belongs beside the PDFs, whatever the app + // opens each of them in. + val index = index() + index.rows( + file("holiday.jpg", modified = 1L), + file("report.pdf", modified = 2L), + file("notes.md", modified = 3L), + ) + + assertEquals( + listOf("notes.md", "report.pdf"), + names(index.byKind(setOf(FileKind.DOCUMENT, FileKind.TEXT), 10)), + ) + } + + @Test + fun `a library that collects no kind answers nothing rather than everything`() = runBlocking { + val index = index() + index.rows(file("holiday.jpg"), file("notes.txt")) + + // RECENT collects no kind. If an empty set meant "no filter", opening it would list the + // whole device under the wrong name. + assertEquals(emptyList(), names(index.byKind(emptySet(), 10))) + } + + @Test + fun `recent is ordered by date and leaves folders out`() = runBlocking { + val index = index() + index.rows( + file("a-folder", modified = 900L, isDirectory = true), + file("photo.jpg", modified = 500L), + file("notes.txt", modified = 700L), + ) + + assertEquals(listOf("notes.txt", "photo.jpg"), names(index.recent(10))) + } + + @Test + fun `counts are grouped by kind`() = runBlocking { + val index = index() + index.rows(file("a.jpg"), file("b.jpg"), file("c.mp3"), file("a-folder", isDirectory = true)) + + val counts = index.kindCounts() + + assertEquals(2, counts[FileKind.IMAGE]) + assertEquals(1, counts[FileKind.AUDIO]) + assertEquals(1, counts[FileKind.DIRECTORY]) + } + + /** + * The home screen shows no count at all when the index has nothing to say, rather than a row + * of zeroes — the same distinction the search row draws between "not indexed yet" and + * "nothing matched". + */ + @Test + fun `an index that has never been filled counts nothing`() = runBlocking { + assertEquals(emptyMap(), index().kindCounts()) + } + /** * v1 recorded only when a volume was last touched; v2 has to tell a walk from a catch-up. * The rows are still good, so the migration adds columns instead of recreating anything — @@ -252,6 +354,124 @@ class SqliteSearchIndexTest { } } + /** + * v2 knew names and nothing about kinds. v3 adds a `kind` column so a library view can ask + * "every image" without reading every name, and rebuilds the table rather than rewriting + * hundreds of thousands of rows on the user's own time. + * + * What must *not* survive is the volume stamps: a stamp says "the rows behind this are this + * current", and after a rebuild there are no rows behind it. Leaving the stamp would make the + * next search trust an empty index and skip the walk it still needs. + */ + @Test + fun `migrating from version 2 rebuilds the files table and forgets the stamps`() { + val db = scratchDatabase("search-migration-v2") + db.execSQL( + "CREATE TABLE ${SqliteSearchIndex.TABLE_FILES} (" + + "${SqliteSearchIndex.COLUMN_PATH} TEXT NOT NULL PRIMARY KEY, " + + "${SqliteSearchIndex.COLUMN_NAME} TEXT NOT NULL, " + + "${SqliteSearchIndex.COLUMN_IS_DIRECTORY} INTEGER NOT NULL, " + + "${SqliteSearchIndex.COLUMN_SIZE_BYTES} INTEGER NOT NULL, " + + "${SqliteSearchIndex.COLUMN_MODIFIED} INTEGER NOT NULL, " + + "${SqliteSearchIndex.COLUMN_VOLUME} TEXT NOT NULL)", + ) + db.execSQL( + "INSERT INTO ${SqliteSearchIndex.TABLE_FILES} " + + "(${SqliteSearchIndex.COLUMN_PATH}, ${SqliteSearchIndex.COLUMN_NAME}, " + + "${SqliteSearchIndex.COLUMN_IS_DIRECTORY}, ${SqliteSearchIndex.COLUMN_SIZE_BYTES}, " + + "${SqliteSearchIndex.COLUMN_MODIFIED}, ${SqliteSearchIndex.COLUMN_VOLUME}) " + + "VALUES ('$volume/from-v2.jpg', 'from-v2.jpg', 0, 1, 1, '$volume')", + ) + db.execSQL( + "CREATE TABLE ${SqliteSearchIndex.TABLE_VOLUMES} (" + + "${SqliteSearchIndex.COLUMN_VOLUME} TEXT NOT NULL PRIMARY KEY, " + + "${SqliteSearchIndex.COLUMN_INDEXED_AT} INTEGER NOT NULL, " + + "${SqliteSearchIndex.COLUMN_FULL_AT} INTEGER NOT NULL DEFAULT 0, " + + "${SqliteSearchIndex.COLUMN_HIDDEN_INCLUDED} INTEGER NOT NULL DEFAULT 0)", + ) + db.execSQL( + "INSERT INTO ${SqliteSearchIndex.TABLE_VOLUMES} " + + "(${SqliteSearchIndex.COLUMN_VOLUME}, ${SqliteSearchIndex.COLUMN_INDEXED_AT}) " + + "VALUES ('$volume', 1234)", + ) + + SqliteSearchIndex.upgrade(db, 2, 3) + + // The rebuilt table has the new column and holds nothing, so no row can be counted with a + // kind that was never derived. + db.rawQuery("SELECT ${SqliteSearchIndex.COLUMN_KIND} FROM ${SqliteSearchIndex.TABLE_FILES}", null) + .use { cursor -> assertEquals("the rebuilt table starts empty", 0, cursor.count) } + + db.rawQuery("SELECT COUNT(*) FROM ${SqliteSearchIndex.TABLE_VOLUMES}", null) + .use { cursor -> + assertTrue(cursor.moveToFirst()) + assertEquals("a stamp whose rows have gone is a lie", 0, cursor.getInt(0)) + } + } + + /** + * The free first pass of a duplicate scan. What matters is that it is *only* a first pass: + * a candidate is a file whose length something else shares, which is where the reading starts + * and never where it ends. + */ + @Test + fun `duplicate candidates share a size, largest first`() = runBlocking { + val index = index() + index.rows( + file("a.txt", size = 100L), + file("b.txt", size = 100L), + // Nothing else is this long, so reading it could not find anything. + file("unique.txt", size = 500L), + file("big1.bin", size = 900L), + file("big2.bin", size = 900L), + ) + + assertEquals( + listOf("big1.bin", "big2.bin", "a.txt", "b.txt"), + names(index.duplicateCandidates(minSizeBytes = 1L, limit = 10)), + ) + } + + @Test + fun `folders are never candidates, however their size falls out`() = runBlocking { + val index = index() + index.rows( + file("a.txt", size = 100L), + file("b.txt", size = 100L), + file("folder-a", size = 100L, isDirectory = true), + file("folder-b", size = 100L, isDirectory = true), + ) + + assertEquals( + listOf("a.txt", "b.txt"), + names(index.duplicateCandidates(minSizeBytes = 1L, limit = 10)), + ) + } + + @Test + fun `the floor and the cap bound the candidate list`() = runBlocking { + val index = index() + index.rows( + file("tiny1.txt", size = 2L), + file("tiny2.txt", size = 2L), + file("mid1.bin", size = 500L), + file("mid2.bin", size = 500L), + file("big1.bin", size = 900L), + file("big2.bin", size = 900L), + ) + + // Below the floor, the space a duplicate wastes is not worth a read. + assertEquals( + listOf("big1.bin", "big2.bin", "mid1.bin", "mid2.bin"), + names(index.duplicateCandidates(minSizeBytes = 100L, limit = 10)), + ) + // Capped, and it keeps the largest, which is where the space is. + assertEquals( + listOf("big1.bin", "big2.bin"), + names(index.duplicateCandidates(minSizeBytes = 1L, limit = 2)), + ) + } + /** A throwaway database, so a migration can be driven without disturbing the real index. */ private fun scratchDatabase(name: String): SQLiteDatabase = SQLiteDatabase.openOrCreateDatabase( diff --git a/app/src/test/java/de/softwareapp_hb/filemanager/domain/DuplicateFinderTest.kt b/app/src/test/java/de/softwareapp_hb/filemanager/domain/DuplicateFinderTest.kt new file mode 100644 index 0000000..ffb980b --- /dev/null +++ b/app/src/test/java/de/softwareapp_hb/filemanager/domain/DuplicateFinderTest.kt @@ -0,0 +1,199 @@ +package de.softwareapp_hb.filemanager.domain + +import de.softwareapp_hb.filemanager.core.StoragePath +import de.softwareapp_hb.filemanager.data.IndexedFile +import de.softwareapp_hb.filemanager.domain.testutil.InMemoryStorageSource +import kotlinx.coroutines.runBlocking +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * The rules a duplicate scan must obey — above all the one it must not break: a partial read may + * only ever *rule files out*. Every wrong answer here is a wrong answer about somebody's data, + * because the only thing anyone does with a duplicate screen is delete something. + */ +class DuplicateFinderTest { + + private val root = StoragePath.of("/storage/emulated/0") + + private fun source(vararg files: Pair) = InMemoryStorageSource().apply { + addDirectory(root) + for ((name, bytes) in files) putBytes(root.child(name), bytes) + } + + private fun row(name: String, bytes: ByteArray) = IndexedFile( + volume = root.value, + path = root.child(name), + name = name, + isDirectory = false, + sizeBytes = bytes.size.toLong(), + modifiedMillis = 0L, + ) + + private fun rows(vararg files: Pair) = files.map { row(it.first, it.second) } + + @Test + fun `files holding the same bytes are one group, and the group says what it would give back`() { + val bytes = "the same bytes, twice over".toByteArray() + val storage = source("holiday.jpg" to bytes, "holiday copy.jpg" to bytes) + + val scan = runBlocking { + DuplicateFinder(storage).find(rows("holiday.jpg" to bytes, "holiday copy.jpg" to bytes)) + } + + assertEquals(1, scan.groups.size) + val group = scan.groups.single() + assertEquals(bytes.size.toLong(), group.sizeBytes) + assertEquals(2, group.paths.size) + assertEquals( + "one spare copy, so one file's worth of space", + bytes.size.toLong(), + group.reclaimableBytes, + ) + assertEquals(0, scan.unreadable) + } + + @Test + fun `three copies are one group of three`() { + val bytes = "three of these".toByteArray() + val storage = source("a.txt" to bytes, "b.txt" to bytes, "c.txt" to bytes) + + val scan = runBlocking { + DuplicateFinder(storage).find(rows("a.txt" to bytes, "b.txt" to bytes, "c.txt" to bytes)) + } + + assertEquals(listOf(3), scan.groups.map { it.paths.size }) + // Two of the three are spare. + assertEquals(bytes.size.toLong() * 2, scan.groups.single().reclaimableBytes) + } + + @Test + fun `files of the same length but different contents are never a group`() { + // The cheap pass compares size first, which is why anything that reaches the finder at all + // shares a length. It must still not be enough. + val one = "aaaa".toByteArray() + val other = "bbbb".toByteArray() + val storage = source("one.txt" to one, "other.txt" to other) + + val scan = runBlocking { + DuplicateFinder(storage).find(rows("one.txt" to one, "other.txt" to other)) + } + + assertTrue(scan.groups.isEmpty()) + } + + @Test + fun `files that share a beginning but differ later are caught by the full pass`() { + // The case the cheap pass structurally cannot see: the first 64 KB of these two is + // byte-for-byte identical, and only reading them to the end tells them apart. This is the + // test that would fail if the partial digest were ever treated as an answer. + val shared = ByteArray(64 * 1024) { 'a'.code.toByte() } + val one = shared + ByteArray(32 * 1024) { 'b'.code.toByte() } + val other = shared + ByteArray(32 * 1024) { 'c'.code.toByte() } + val storage = source("one.bin" to one, "other.bin" to other) + + val stages = mutableListOf() + val scan = runBlocking { + DuplicateFinder(storage).find(rows("one.bin" to one, "other.bin" to other)) { + stages += it.stage + } + } + + assertTrue("the heads agree, so only the full read can tell them apart", scan.groups.isEmpty()) + assertEquals( + "both files are read cheaply, and then both in full", + listOf( + DuplicateProgress.Stage.PARTIAL, + DuplicateProgress.Stage.PARTIAL, + DuplicateProgress.Stage.FULL, + DuplicateProgress.Stage.FULL, + ), + stages, + ) + } + + @Test + fun `a file that cannot be read is counted rather than guessed at`() { + val bytes = "identical twice".toByteArray() + val storage = source("a.txt" to bytes, "b.txt" to bytes) + // Gone, refused, whatever the reason: the bytes are not available. + storage.unreadable += root.child("b.txt") + + val scan = runBlocking { + DuplicateFinder(storage).find(rows("a.txt" to bytes, "b.txt" to bytes)) + } + + assertTrue("one readable file is not a duplicate of anything", scan.groups.isEmpty()) + assertEquals( + "it could be neither confirmed nor denied, and the user is told so", + 1, + scan.unreadable, + ) + } + + @Test + fun `groups are ordered by the space they would give back`() { + val small = "five!".toByteArray() + val big = ByteArray(4096) { 'x'.code.toByte() } + val storage = source( + "s1" to small, + "s2" to small, + "b1" to big, + "b2" to big, + "b3" to big, + ) + + val scan = runBlocking { + DuplicateFinder(storage).find( + rows( + "s1" to small, + "s2" to small, + "b1" to big, + "b2" to big, + "b3" to big, + ), + ) + } + + // 4 KB with two spare copies beats 5 bytes with one, even though the small group would be + // the "first" one by name. + assertEquals(2, scan.groups.size) + assertEquals(4096L, scan.groups.first().sizeBytes) + assertEquals(8192L, scan.groups.first().reclaimableBytes) + assertEquals(small.size.toLong(), scan.groups.last().sizeBytes) + } + + @Test + fun `a file whose length nothing else shares is never read`() { + // Both files are marked unreadable, so the only way `unreadable` can stay at 0 is that + // neither was opened: the size pass has to dismiss them before a single byte is read. + val one = "one".toByteArray() + val two = "two!".toByteArray() + val storage = source("one.txt" to one, "two.txt" to two) + storage.unreadable += root.child("one.txt") + storage.unreadable += root.child("two.txt") + + val scan = runBlocking { + DuplicateFinder(storage).find(rows("one.txt" to one, "two.txt" to two)) + } + + assertEquals("nothing needed reading, so nothing failed to read", 0, scan.unreadable) + assertTrue(scan.groups.isEmpty()) + } + + @Test + fun `nothing to compare is nothing to report`() { + val bytes = "alone".toByteArray() + val storage = source("only.txt" to bytes) + + val progress = mutableListOf() + val scan = runBlocking { + DuplicateFinder(storage).find(rows("only.txt" to bytes)) { progress += it } + } + + assertTrue(scan.groups.isEmpty()) + assertEquals(0, scan.unreadable) + assertTrue("a file with no partner is not even opened", progress.isEmpty()) + } +} diff --git a/app/src/test/java/de/softwareapp_hb/filemanager/domain/testutil/InMemorySearchIndex.kt b/app/src/test/java/de/softwareapp_hb/filemanager/domain/testutil/InMemorySearchIndex.kt index 9682cd0..9d1fc4c 100644 --- a/app/src/test/java/de/softwareapp_hb/filemanager/domain/testutil/InMemorySearchIndex.kt +++ b/app/src/test/java/de/softwareapp_hb/filemanager/domain/testutil/InMemorySearchIndex.kt @@ -1,5 +1,6 @@ package de.softwareapp_hb.filemanager.domain.testutil +import de.softwareapp_hb.filemanager.core.FileKind import de.softwareapp_hb.filemanager.core.NameMatcher import de.softwareapp_hb.filemanager.core.StoragePath import de.softwareapp_hb.filemanager.data.IndexedFile @@ -59,6 +60,40 @@ class InMemorySearchIndex : SearchIndex { .sortedBy { it.name } .take(limit) + /** + * The same answer the SQL gives, in the same order — `modified` descending, then name — so a + * test that passes here is about the *rule* and not about which of the two implementations + * happened to be exercised. + */ + override suspend fun byKind(kinds: Set, limit: Int): List { + if (kinds.isEmpty()) return emptyList() + return rows.values + .filter { it.kind in kinds } + .sortedWith(compareByDescending { it.modifiedMillis }.thenBy { it.name }) + .take(limit) + } + + override suspend fun recent(limit: Int): List = rows.values + .filterNot { it.isDirectory } + .sortedWith(compareByDescending { it.modifiedMillis }.thenBy { it.name }) + .take(limit) + + override suspend fun kindCounts(): Map = rows.values.groupingBy { it.kind }.eachCount() + + /** + * The same rule as the SQL, expressed the readable way: keep the files whose size at least one + * other file shares, largest first. + */ + override suspend fun duplicateCandidates(minSizeBytes: Long, limit: Int): List = + rows.values + .filter { !it.isDirectory && it.sizeBytes >= minSizeBytes } + .groupBy { it.sizeBytes } + .filterValues { it.size >= 2 } + .values + .flatten() + .sortedWith(compareByDescending { it.sizeBytes }.thenBy { it.name }) + .take(limit) + override suspend fun forget(path: StoragePath) { forgotten += path.value rows.remove(path.value) diff --git a/app/src/test/java/de/softwareapp_hb/filemanager/ui/browser/BrowserViewModelTest.kt b/app/src/test/java/de/softwareapp_hb/filemanager/ui/browser/BrowserViewModelTest.kt index c1197fb..15c041e 100644 --- a/app/src/test/java/de/softwareapp_hb/filemanager/ui/browser/BrowserViewModelTest.kt +++ b/app/src/test/java/de/softwareapp_hb/filemanager/ui/browser/BrowserViewModelTest.kt @@ -7,7 +7,9 @@ import androidx.activity.result.IntentSenderRequest import androidx.lifecycle.SavedStateHandle import de.softwareapp_hb.filemanager.R import de.softwareapp_hb.filemanager.core.ConflictPolicy +import de.softwareapp_hb.filemanager.core.FileCategory import de.softwareapp_hb.filemanager.core.FileEntry +import de.softwareapp_hb.filemanager.core.FileKind import de.softwareapp_hb.filemanager.core.SortDirection import de.softwareapp_hb.filemanager.core.SortKey import de.softwareapp_hb.filemanager.core.StoragePath @@ -30,10 +32,12 @@ import de.softwareapp_hb.filemanager.domain.testutil.InMemoryStorageSource import de.softwareapp_hb.filemanager.ui.browser.testutil.FakeStorageLocation import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.delay import kotlinx.coroutines.runBlocking import kotlinx.coroutines.test.UnconfinedTestDispatcher import kotlinx.coroutines.test.resetMain import kotlinx.coroutines.test.setMain +import kotlinx.coroutines.withTimeout import org.junit.After import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse @@ -658,12 +662,234 @@ class BrowserViewModelTest { ) } - private fun indexed(path: StoragePath, name: String) = IndexedFile( + /* + * Libraries: the one listing in the app that is not a folder. Its rows come from the index, so + * what matters is that it shows what the index knows, drops what the filesystem no longer has, + * brings the index up to date *behind* the answer rather than in front of it, and never + * pretends to be a folder. + */ + + /** An index holding exactly [rows], and freshly built, so no pass is triggered by reading it. */ + private fun indexOf(vararg rows: IndexedFile): InMemorySearchIndex { + val index = InMemorySearchIndex() + runBlocking { + index.beginVolume(root.value) + index.add(rows.toList()) + index.endVolume(root.value, System.currentTimeMillis(), full = true, hiddenIncluded = false) + } + return index + } + + @Test + fun `opening a library lists the index's files of that kind and no folders`() { + storage.addFile(root.child("holiday.png"), "x") + storage.addFile(root.child("notes.txt"), "y") + val index = indexOf( + indexed(root.child("holiday.png"), "holiday.png"), + indexed(root.child("notes.txt"), "notes.txt"), + ) + + val model = viewModel(searchIndex = index) + model.openCategory(FileCategory.IMAGES) + + val state = model.state.value + assertEquals(listOf("holiday.png"), state.entries.map { it.name }) + assertEquals(FileCategory.IMAGES, state.category) + assertNull("a library is not inside a folder", state.path) + assertEquals(emptyList(), state.breadcrumbs) + assertFalse("and it is not the root list either", state.isRootView) + } + + @Test + fun `a library brings the index up to date behind the answer`() { + // Nothing has ever been indexed, which is the state a fresh install is in. The answer + // starts empty, the walk runs behind it, and what the walk finds is shown without the + // user having to ask twice. + storage.addFile(root.child("holiday.png"), "x") + val index = InMemorySearchIndex() + + val model = viewModel(searchIndex = index) + model.openCategory(FileCategory.IMAGES) + + assertEquals(listOf("holiday.png"), model.state.value.entries.map { it.name }) + assertFalse("the pass is over by the time the listing is final", model.state.value.indexing) + assertTrue( + "and it left the rows behind, so the next visit costs nothing", + runBlocking { index.byKind(setOf(FileKind.IMAGE), 10) }.isNotEmpty(), + ) + } + + @Test + fun `a library row whose file has gone is dropped and forgotten`() { + val index = indexOf(indexed(root.child("ghost.png"), "ghost.png")) + + val model = viewModel(searchIndex = index) + model.openCategory(FileCategory.IMAGES) + + assertEquals(emptyList(), model.state.value.entries.map { it.name }) + assertTrue( + "an index row with no file behind it is not worth keeping", + runBlocking { index.byKind(setOf(FileKind.IMAGE), 10) }.isEmpty(), + ) + } + + @Test + fun `returning to the app keeps the library open rather than dropping to the roots`() { + // `refresh` is what ON_RESUME calls, and it used to mean "reload the folder" — which a + // library does not have. Re-reading must not silently turn into navigating away. + storage.addFile(root.child("holiday.png"), "x") + val index = indexOf(indexed(root.child("holiday.png"), "holiday.png")) + + val model = viewModel(searchIndex = index) + model.openCategory(FileCategory.IMAGES) + model.refresh() + + assertEquals(FileCategory.IMAGES, model.state.value.category) + assertEquals(listOf("holiday.png"), model.state.value.entries.map { it.name }) + } + + @Test + fun `the home counts come from the index, and there are none without one`() { + val index = indexOf( + indexed(root.child("a.png"), "a.png"), + indexed(root.child("b.png"), "b.png"), + indexed(root.child("notes.txt"), "notes.txt"), + ) + + val counted = viewModel(searchIndex = index) + + assertEquals(2, counted.state.value.libraryCounts[FileCategory.IMAGES]) + // A `.txt` file is a document to the person looking for it. + assertEquals(1, counted.state.value.libraryCounts[FileCategory.DOCUMENTS]) + // No index is no counts at all, rather than a row of zeroes claiming an empty device. + assertEquals(emptyMap(), viewModel().state.value.libraryCounts) + } + + /* + * Duplicates: the one screen that cannot answer before it has read, so what matters is where + * its candidate list comes from, that its delete ends like every other delete, and that a + * device with nothing to compare is not reported as a clean one. + */ + + /** + * Wait for a running scan to finish. + * + * The finder hashes on `Dispatchers.IO` — deliberately, because reading is blocking work — so + * unlike the index queries the rest of this suite exercises, it does not complete inside the + * test's unconfined dispatcher. The wait is bounded, so a scan that never finishes fails here + * instead of hanging the suite. + */ + private fun awaitScan(model: BrowserViewModel): Overlay.Duplicates = runBlocking { + withTimeout(5_000) { + var overlay = model.state.value.overlay as? Overlay.Duplicates + while (overlay == null || overlay.loading) { + delay(10) + overlay = model.state.value.overlay as? Overlay.Duplicates + } + overlay + } + } + + @Test + fun `finding duplicates compares the index's candidates and reports what matched`() { + // Above the scan's floor: a duplicate of a 25-byte file is not worth a read, and the + // candidate query says so. + val same = ByteArray(2048) { 'a'.code.toByte() } + val different = ByteArray(same.size) { 'z'.code.toByte() } + storage.putBytes(root.child("one.bin"), same) + storage.putBytes(root.child("two.bin"), same) + // A third file of the same length but different bytes: it is a candidate, and comparing it + // is what rules it out. + storage.putBytes(root.child("three.bin"), different) + + val index = indexOf( + indexed(root.child("one.bin"), "one.bin", same.size.toLong()), + indexed(root.child("two.bin"), "two.bin", same.size.toLong()), + indexed(root.child("three.bin"), "three.bin", different.size.toLong()), + ) + + val model = viewModel(searchIndex = index) + model.findDuplicates() + + val overlay = awaitScan(model) + assertNull("the progress is cleared once the scan is done", overlay.progress) + assertEquals("three files were compared", 3, overlay.candidates) + assertEquals(1, overlay.groups.size) + assertEquals( + listOf(root.child("one.bin"), root.child("two.bin")), + overlay.groups.single().paths, + ) + } + + @Test + fun `a scan with nothing to compare says so rather than reporting a clean device`() { + // No index at all, which is a fresh install: no candidates were looked at, and the screen + // must be able to say that instead of "no duplicates". + val model = viewModel() + model.findDuplicates() + + val overlay = model.state.value.overlay as Overlay.Duplicates + assertFalse(overlay.loading) + assertEquals(0, overlay.candidates) + assertTrue(overlay.groups.isEmpty()) + } + + @Test + fun `deleting the ticked copies closes the scan rather than patching it`() { + val same = ByteArray(2048) { 'a'.code.toByte() } + storage.putBytes(root.child("one.bin"), same) + storage.putBytes(root.child("two.bin"), same) + val index = indexOf( + indexed(root.child("one.bin"), "one.bin", same.size.toLong()), + indexed(root.child("two.bin"), "two.bin", same.size.toLong()), + ) + + val model = viewModel(searchIndex = index) + model.findDuplicates() + awaitScan(model) + model.toggleDuplicateSelection(root.child("two.bin")) + model.deleteSelectedDuplicates() + + // Its rows came from comparing bytes, so quietly dropping one would leave a list that + // still looks like a scan and no longer is. The delete itself is the ordinary one, and is + // covered by the delete tests above. + assertFalse( + "the scan is gone rather than showing a count nobody measured", + model.state.value.overlay is Overlay.Duplicates, + ) + } + + @Test + fun `ticking a copy does not tick every copy`() { + val same = ByteArray(2048) { 'a'.code.toByte() } + storage.putBytes(root.child("one.bin"), same) + storage.putBytes(root.child("two.bin"), same) + val index = indexOf( + indexed(root.child("one.bin"), "one.bin", same.size.toLong()), + indexed(root.child("two.bin"), "two.bin", same.size.toLong()), + ) + + val model = viewModel(searchIndex = index) + model.findDuplicates() + + // Nothing is preselected: which copy to keep is the decision this screen hands over. + val overlay = awaitScan(model) + assertEquals(emptySet(), overlay.selection) + + model.toggleDuplicateSelection(root.child("one.bin")) + + assertEquals( + setOf(root.child("one.bin")), + (model.state.value.overlay as Overlay.Duplicates).selection, + ) + } + + private fun indexed(path: StoragePath, name: String, size: Long = 1L) = IndexedFile( volume = root.value, path = path, name = name, isDirectory = false, - sizeBytes = 1L, + sizeBytes = size, modifiedMillis = 0L, ) diff --git a/docs/COMPETITIVE_ANALYSIS.md b/docs/COMPETITIVE_ANALYSIS.md new file mode 100644 index 0000000..a4f8b1c --- /dev/null +++ b/docs/COMPETITIVE_ANALYSIS.md @@ -0,0 +1,407 @@ +# Competitive analysis: File Manager Plus (AlphaInventor) + +A structured look at the category leader we are actually up against, and what its users say +about it. The point is not to copy it — most of what it does needs `INTERNET`, which this app +will not declare — but to know (a) which of its features are table stakes for a file manager, +(b) which of its users' complaints we can answer for free, and (c) which of our differences are +worth putting in the listing. + +Surveyed **2026-10-17**. Sources and their limits are at the bottom; every claim here is +attributed, and the ones I could not confirm are marked as unconfirmed rather than smoothed over. + +## The app at a glance + +| | | +|---|---| +| Play listing name | **File Manager** (branded in-app as **File Manager Plus** / **File Manager +**) | +| Package | `com.alphainventor.filemanager` | +| Developer | 주식회사 알파인벤터 (AlphaInventor Inc.), Seoul, South Korea | +| Category / rating band | Productivity · Everyone / PEGI 3 / "Rated for 3+" | +| Play rating | **4.8★** on the default (en) listing, 4.7★ US/GB/NG, 4.6★ IN — **1.71 M ratings** (1.54 M in the breakdown) | +| Installs | **100 M+** | +| Money | **Free, ad-supported, plus in-app purchases** — the listing is labelled "Contains ads · In-app purchases" | +| Version / size / age | **3.8.3**, updated **Aug 6, 2026**; ~12.9 MB APK; Android 5.0+ | +| Form factors | Phone, tablet, **Android TV** (the ratings breakdown also shows a desktop/ChromeOS bucket) | +| Declared data safety | May **share** *Device or other IDs* with third parties; may **collect** *App activity*, *App info and performance*, *Device or other IDs*; encrypted in transit; **"Data can't be deleted"** | +| Support | `app.support@alphainventor.com`, +82 70-4509-2539 | + +The two lines that matter most for us are the money row and the data-safety row: this is a free +app whose feature surface is paid for by ads and by sharing a device identifier with third +parties, and whose own Data Safety form says data cannot be deleted on request. That is the +sharpest contrast available against a one-time-paid app with no `INTERNET` permission. + +## How this was researched — and what that does not tell you + +| Source | What it gave | +|---|---| +| Play listing, fetched in `en`/US, GB, IN, CA, NG | The developer's own description, "What's new" block, price/ads labels, Data Safety form, rating, installs, and the *"most helpful"* reviews (about three per locale) | +| APKPure mirror | Version history, APK size, a security scan (0/34 vendors flagged) and a **third-party** prose description | +| Play **search** pages for the query `file manager`, fetched in 10 storefronts (IN, US, BR, ID, PH, NG, MX, VN, DE, KR) | Where the app ranks in the organic results, and its star rating *per storefront* — see "Markets" | +| Everything else | AppBrain, APKGK, apk.support all returned **HTTP 403**; SimilarWeb needs JavaScript; DuckDuckGo's HTML endpoint served a bot challenge; Reddit's JSON API returned 403; Uptodown 404 and Softonic 412 | + +Four honest limitations, because they shape how much weight the review section deserves: + +- **The review sample is ~10 reviews, not a survey.** Play's server-rendered page only carries a + handful of "most helpful" reviews per locale, and it repeats them across regions — the same + Brian Witte review appeared in five of the five locales fetched. Treat the themes below as + *hypotheses about the user base*, not as a measured distribution. The 100 M+ installs and + 4.8★ are the measured numbers; the complaints are anecdotes that recur. +- **Per-review star ratings were not in the fetched HTML.** Only the aggregate is. So no star + value is attributed to any review quoted below — the ones shown carry a date and a + *"N people found this review helpful"* count instead, which is the honest substitute. +- **Reviews are user claims, not facts.** Several complaints are performance reports; those can + be device- or SD-card-bound, and the developer says as much in a public reply (quoted below). + Nobody here re-tested the app. +- **APKPure's description contradicts the developer's own listing.** It claims RAR creation, + duplicate detection, cache cleaning and link sharing, none of which the store listing mentions + (which offers **zip** for compression and **zip/gz/xz/tar** for decompression). That prose + reads like generated copy. It is recorded below as *unconfirmed*, not as a feature. + +## What it ships + +Grouped by what a user is trying to do, with the source for each claim: + +| Capability | Detail | Where it comes from | +|---|---|---| +| Internal storage / SD card / USB OTG | "manage all files and folders on both your internal storage and external storage" | Store description | +| Automatic category libraries | **Downloads · New files · Images · Audio · Videos · Documents · Apps**, "automatically sorted by their types and characteristics" | Store description | +| App manager | "see and manage all applications installed on your local device" | Store description | +| Built-in viewers | "Internal image viewer / Internal music player / Internal text editor … for faster and better performance" | Store description | +| Archive handling | compress **zip**; decompress **zip, gz, xz, tar** | Store description | +| Cloud targets | **Google Drive, OneDrive, Dropbox, Box, Yandex** (+ **pCloud**, added in 3.7.3) | Store description / What's new | +| Remote / network targets | **NAS**, **FTP server**, and — from the changelog — **FTPS** (3.8.3 fixes "an FTPS connection error"); APKPure additionally says **SMB** | What's new / APKPure | +| Phone → PC access | runs an FTP server so a computer can reach the phone's storage | Store description | +| Storage analysis | "analyze local storages to clean up useless files… which files and apps take up the most space" | Store description | +| Full action set | "open, search, navigate directory, copy and paste, cut, delete, rename, compress, decompress, transfer, download, bookmark, and organize" | Store description | +| Media + APK formats | "for various file formats including media and apk" | Store description | +| Form factors | "Android TV, phone and tablet" | Store description | +| Post-paste conveniences | **"Copy again"** and **"Return to source folder"** after pasting (3.6.8) | What's new | +| Freshly-changed files stand out | "Temporarily highlight new files with a dot after file operations" (3.6.5) | What's new | +| Settings depth | switchable music-player engine, a date-format setting (3.7.3), a toggle for video-player gestures (3.6.8) | What's new | +| Localisation | multi-language (APKPure lists a long language set for its own mirror; the Play listing is served localised) | APKPure (unconfirmed in detail) | +| *Unconfirmed* | RAR **creation**, **duplicate** detection, cache cleaning, share-by-link | APKPure prose only — absent from the developer's listing | + +Two observations from that table: + +- **The changelog is the more truthful feature list.** The store bullets stop at "FTP server", + while the "What's new" block shows the network surface is actually broader (FTPS, and pCloud + as a seventh cloud). For a listing audit, the *changelog* is where a competitor's real surface + shows up — worth remembering when writing our own "What's new". +- **Maintenance looks genuinely active.** 3.8.3 → 3.8.2 → 3.7.2 all landed inside 2026, and the + entries are specific ("Fixed an FTPS connection error", "pCloud support added") rather than + only "bug fixes and performance improvements". A competitor that ships is harder to beat on + freshness; we win on the permission set and the business model, not on cadence. + +## What users value + +Six themes, each with the review it comes from. All quotes are verbatim (trimmed); "N helpful" +is the count Play showed beside the review. + +1. **It is simple *and* powerful, at the same time.** This is the single most repeated remark, + and it is always the same shape: the UI is unfussy, the capability is not. + > "it's quick, it's easy, and clean to look at, and so nice to organize your stuff" + > — hennie postma, Jun 25 2025 · **998 helpful** + > + > "easy to use and easily organizes my all files according to types, sizes, and dates … I + > preferred this app than other apps like Google folder and system folder because it easy it + > has not so many options, so you can easily access this" + > — Nirmalya Chakraborty, Jun 30 2024 · **540 helpful** + > + > "Love this app for it's simplicity while still being powerful." + > — Karen Hellawell, Sep 8 2026 · 6 helpful + +2. **Feature depth is valued even when unused.** A high-helpfulness review says this explicitly: + > "There's a huge amount of powerful functionality that I'll not likely use/need, but it's + > always better to have it than vice versa…!" + > — Ivan Lewis-Coker, Jul 1 2024 · **392 helpful** + +3. **The storage-analysis screen is a reason to install.** This is the only feature a user names + as the reason they came: + > "I especially enjoy the analyse feature to find large files and folders … It's what brought + > me to this app in fact." + > — Sean Nym, Aug 19 2024 · **166 helpful** + +4. **Search that answers quickly.** + > "The search function is quick and delivers good results." + > — Sean Nym, Aug 19 2024 + +5. **Bulk network transfer is the killer feature nobody markets.** The most enthusiastic review + is about one big push to a PC over the app's own FTP server: + > "I literally just transferred my ENTIRE picture/video/music collection to my computer + > (equalling out to around 150-185gigs …) in around 3-4min... And I did it all at once!" + > — Duncan Elliott, Jul 21 2026 · 22 helpful + +6. **The ads are tolerated — and one user does not even see them.** Three reviews say the ads + do not get in the way, and one asserts there are none at all: + > "there are some ads, but it's a little bar that sits at the bottom of the screen, and they + > haven't bothered me at all yet. none that jump you while you're doing your thing." + > — hennie postma + > + > "Ads aren't too intrusive either." + > — Sean Nym + > + > "**No ad available.**" — Nirmalya Chakraborty *(the listing itself says "Contains ads")* + + That last one is the interesting one: a bottom banner that does not interrupt is reported by + a repeat reviewer as *no ads*. Ad tolerance is about interruption, not about presence. It is + also the reason "we have no ads" is a weaker listing line on its own than "nothing leaves the + device" — the competitor's users do not experience the ads as a problem. + +## What users do not like + +The complaints cluster far more tightly than the praise does. **Five of the ten reviews are +about speed or timeouts**, and the single most-helpful complaint in the whole sample is about +thumbnails. + +### 1. Thumbnails load late, and only when the scrolling stops — the top complaint + +> "Photo thumbnails load too slowly, only once scrolling stops. This is very inconvenient when +> trying to scroll quickly through thousands of photos to organize them." +> — Brian Witte, Feb 13 2025 · **255 helpful** + +This is the highest-signal item in the whole analysis for us, because it is the exact trade-off +we just walked into: the previous change to this repository enlarged the browser thumbnails +(list 28 → 48 dp, grid 40 → 96 dp). A thumbnail that is bigger and slower is strictly worse than +one that is smaller and instant, and this review is what that failure looks like at 100 M+ +installs. See "Actionable" below. + +### 2. Performance regressions over time — folder listing, image opening, refresh + +> "it used to load the contents of my folders almost instantly … now it takes its sweet time +> loading the contents of pretty much any folder. … it will take forever to just display a +> single image file when opened regardless of file size. when it finally lets me see it if I +> leave the folder I have to wait again. this issue still persists and is getting worse." +> — Kaizer 913, Sep 10 2026 · **100 helpful** + +> "the only and the most annoying thing that has been going on for a while now **despite all the +> updates** is the refresh speed, it's Soo slow😩. The transfer speed is also very very slow when +> transferring videos or files … just to refresh the page alone, like swiping down takes very +> long to reload" +> — ANTHONY EZENWA, Apr 19 2026 · 24 helpful + +Both are *regression* complaints from long-time users. That matters more than a first-impression +complaint: the damage is to trust, not to the first run. + +### 3. The developer replies can read as deflection + +The reply to EZENWA, shown publicly underneath the review: + +> "Where exactly is the copying process slow—from where to where? Most users who have recently +> reported slow speeds were found to be using slow SD cards. If copying to an SD card is +> particularly slow, consider switching to a faster SD card." +> — File Manager Plus, Apr 19 2026 + +The suggested cause is plausible and the answer is polite, but it opens by asking the user to +justify the report and closes by blaming their hardware — while the review also complained about +*refresh*, which no SD card explains. The older reply is a copy-paste reassurance: + +> "We are sorry for your inconvenience. We have fixed this issue in the latest version." +> — File Manager Plus, under Kaizer 913's report — which was still being reported 2½ years later + +There is a lesson in this for us that costs nothing to apply: answer the specific report, say +what was measured, and do not close a performance complaint with "your SD card is slow". + +### 4. Folders with very many entries time out + +> "it keeps timing out when it encounters a folder with too many files and folders in it. I get +> 'Loading Error! Check your network connection' messages. **Other apps I have on my phone can +> access these same folders on my NAS with no issues.** The timeout window probably needs to be +> made larger. Or maybe there's a limit on the # of files/folders that needs to be bumped up." +> — Leon Davidoff, Sep 23 2024 · 57 helpful + +A hard-coded limit surfacing as a *misleading network error* — the user is told to check a +connection that demonstrably works in other apps. Note the design lesson: an opaque failure +message turns a limit into a bug report about a different subsystem. + +### 5. Batch rename cannot continue a numbering sequence + +> "when I batch rename a group of photos, it will create 'Ice (1), Ice (2)', which is great, but, +> if I later find another group of photos which I want to continue the batch naming with 'Ice +> (3), Ice (4), etc.', it's not possible." +> — Brian Witte, Feb 13 2025 (*same review as the thumbnail complaint*) + +### 6. Missing features users explicitly ask for — and offer to pay for + +> "I'd love to be able to add more cloud apps like **Terabox** … Would also love a **find +> duplicates** setting, especially for photos, videos and music. A **sync option with Cloud +> apps** would also be great. **Would be happy to pay for those extras.**" +> — Karen Hellawell, Sep 8 2026 (*4 stars — "only dropped a star"*) + +> "The only other functionality I'd request is the ability to have **two side by side file list +> views which you can swipe between** so you can, for example, easily verify what & where you're +> cut/copy/pasting to/from." +> — Ivan Lewis-Coker, Jul 1 2024 · **392 helpful** + +Note the last one carefully: **the second most-helpful review in the sample asks for the +two-pane layout this app already ships** (a master/detail split at ≥ 840 dp, with a single tap +to focus and a double tap to open). It is not a small thing — it is the request of a *Total +Commander* user, i.e. exactly the file-manager power user a paid app should be selling to. + +## Markets: where it is strongest + +**No public per-country install or revenue figure exists for this app**, and that is the honest +answer to "which countries are best for it". Every source that would give one is gated — Sensor +Tower, data.ai, AppMagic, Apptopia and SimilarWeb are all login/paywall (and their free pages are +JavaScript-only, so a plain fetch returns an empty shell) — which leaves the developer's own Play +Console as the only place that number actually exists. What *is* publicly measurable is where the +app ranks. It is the **first organic result for the query "file manager" in 9 of 10 storefronts** +sampled, and second in the tenth: + +| Storefront | Rank for "file manager" | Its rating there | +|---|---|---| +| India | **#1** | 4.6★ | +| United States | **#1** | 4.7★ | +| Brazil | **#1** | 4.8★ | +| Indonesia | **#1** | 4.8★ | +| Philippines | **#1** | 4.7★ | +| Mexico | **#1** | 4.9★ | +| Vietnam | **#1** | 4.5★ | +| Germany | **#1** | 4.8★ | +| South Korea | **#1** | 4.9★ | +| Nigeria | #2 *(behind Files by Google)* | 4.7★ | + +What that does and does not say: + +- It says the app owns the **category query** almost everywhere. For a free, ad-funded app revenue + is installs × ads, and the door to installs is precisely this query — first place in the biggest + Android markets is the whole business. +- It does **not** say which countries install it most. A #1 slot in India and a #1 slot in the + United States are nothing like equal in installs and are wildly unequal in revenue per install. +- Search is personalised and the fetch is unauthenticated from a datacentre, so read the ranks as + "consistently first", not as a fixed position. +- The per-storefront ratings do differ (4.5 Vietnam → 4.9 Mexico and South Korea). That part is + measured — but a rating is sentiment, not market size, and must not be read as share. + +**Inference, not measurement:** for a free file manager the largest markets are structurally the +large-population Android markets — India, Brazil, Indonesia, the US, Mexico, Vietnam, the +Philippines, Nigeria — while the highest revenue *per install* sits in the US, Germany, South Korea +and Japan. Where this app's revenue actually leans cannot be established from outside. + +One useful side-effect of the same search results, for our own [MONETISATION](MONETISATION.md) +check: the paid rivals show local price points where our €4.49 base and €2.29 launch price assume +they are — **US$6.99** for MiXplorer Silver (and **₩10,000** for the same app in Korea), **R$3.49** +and **MX$13.00** for Simple File Manager Pro, **€0.99** in Germany, **₫52,000 / ₫180,000** in +Vietnam. + +## What this means for Plain Files + +### Where we already match or beat it + +| Their capability | Ours | +|---|---| +| Internal storage / SD card / USB OTG | Same — `VolumeRepository` enumerates mounted volumes (ROADMAP M2) | +| Full action set (copy, cut, delete, rename, compress, decompress, transfer, search) | Same, plus a real **trash with restore** and undo, which their listing does not mention | +| Decompression (their *listed* set: zip, gz, xz, tar) | Broader: **zip, tar, tar.gz/tgz, tar.bz2, tar.xz, gz, bz2, xz, and 7z** — 7z included, on the all-files backend | +| Storage analysis | Same idea, and honest about cost: one listing per directory instead of a `stat` per node, rows streamed as measured (M5/M7) | +| Built-in viewers | Image, text editor, PDF, media, archive list **and** in-archive entry preview, APK info | +| Search | Indexed search everywhere over SQLite, with the index's **age shown** and a rebuild action | +| Two-pane | **Present** — master/detail at ≥ 840 dp. The second most-helpful review in our sample is a request for exactly this | +| Ads / accounts / analytics | **None**, and no `INTERNET` permission at all | + +The two rows worth putting in the store copy are the last two: "two-pane on a tablet" answers a +requested feature in the market leader, and the permission line is a claim it cannot make. + +### Where we are behind, and each gap's real cost + +| Gap | Costs a permission? | Notes | +|---|---|---| +| Automatic category libraries (Images / Audio / Videos / Documents / Downloads / New files / Recent) | **No** | The most-praised organisational feature in the reviews, and the one thing a brand-new user sees before they understand folders. We open on a volume list instead. This is the largest honest gap | +| Duplicate finder | **No** | Explicitly requested, with an offer to pay. Pure local computation over a listing we can already do | +| Batch rename, including continuing an existing numbering sequence | **No** | We have single-item rename only; "Ice (3), Ice (4)" is a stated wish from the top complainant | +| Bookmarks | **No** | Their action list includes it; we have none | +| "Copy again" / "Return to source folder" after a paste | **No** | Shipped in their 3.6.8 and cheap for us — the post-paste moment is when a user most needs a way back | +| New-file dot after an operation | **No** | Their 3.6.5. Cheap, and it answers "what did that 4 GB copy just do?" | +| Date-format setting | **No** | We deliberately leave the timestamp pattern unlocalised (README); a *user setting* is a different thing | +| Music-player engine switch, video gesture toggle | **No** | Deep settings; low priority for a first release | +| Android TV | **No** (form factor, not permission) | Their listing supports it; ours does not | +| App manager | Yes — `QUERY_ALL_PACKAGES` | Already deferred in the ROADMAP | +| Cloud + NAS + FTP/SMB/SFTP, and an FTP server on the phone | Yes — `INTERNET` | Deferred by decision. This is the axis we give up on purpose, and their most enthusiastic review is about it | + +### Actionable now: the thumbnail complaint is about us too + +We have just made thumbnails bigger. The top complaint about the market leader is thumbnail +latency, so this needs to be pinned down rather than assumed: + +1. **Coil 3 sizes its decode to the composable**, so a 96 dp grid tile does not decode a 12 MP + JPEG — that is the mechanism keeping us out of the "loads only when scrolling stops" trap. + Worth writing down as the reason it is safe, and worth not breaking. +2. **No `ImageLoader` is registered** (`FileManagerApplication` builds no Coil component), so + Coil's defaults are what applies: no explicit memory- or disk-cache policy, no placeholder + and no error state for a tile that is still decoding. A placeholder-sized box matters here — + without one, a slow decode is also a layout shift. +3. **Not yet exercised on a hard case.** The enlarged sizes have not been run against a folder + with thousands of images on a device. `docs/EDGE_CASE_TESTING.md` is the right place to record + that pass, with the decode time observed rather than assumed. +4. **Keep the grid size moderate.** 96 dp is a deliberately visible thumbnail; if a large-folder + pass shows jank, the first knob is the grid size, not the list size. + +### Themes in their feedback that argue for our existing choices + +- *"It's what brought me to this app"* was the storage-analysis screen — we have one. +- *"Simple UI"* plus *"huge amount of powerful functionality"* — the reviews consistently reward + depth that stays out of the way; our details sheet / selection bar split is that shape. +- Their timeout complaint was reported as a **misleading network error**. Our own rule — state + the limit plainly ("7z needs a seekable channel; not available in folder mode") — is the same + lesson learned the other way round. + +## What I would do with this + +In the ROADMAP's own vocabulary (💡 = candidate, proposed, not scheduled): + +1. 💡 **Category libraries (Images / Audio / Videos / Documents / Downloads · New files · Recent).** + The largest honest gap: it is the market leader's most-praised organisation feature, it needs no + permission, and it is the first thing a new user sees. +2. 💡 **Duplicate finder.** Asked for by name, by a user offering to pay. +3. 💡 **Batch rename with sequence continuation** — including the "continue from Ice (2)" case. +4. 💡 **Bookmarks, plus "Return to source folder" and "Copy again" after a paste.** Cheap, local, + and the post-paste moment is the one the competitor's changelog calls out. +5. 💡 **Highlight files changed by an operation.** Their 3.6.5; answers an obvious question. +6. **Thumbnail hardening before the next release**: register an explicit Coil `ImageLoader` with a + decided cache policy and a placeholder, and record a large-folder device pass in + [EDGE_CASE_TESTING](EDGE_CASE_TESTING.md). This is the only item here that touches code we + changed yesterday. +7. **Listing work** ([STORE_LISTING](STORE_LISTING.md)): name the two-pane tablet layout — it is a + requested feature in the leader — and sharpen the contrast that the Data Safety form makes + available (their app shares device IDs with third parties and cannot delete data on request; + ours declares no network permission and no collection). + +Deliberately **not** copied, and the reason is already on record: cloud, NAS, FTP/SMB/SFTP, an FTP +server on the phone and an app manager. The first four need `INTERNET`; the last needs +`QUERY_ALL_PACKAGES`. See the connectivity decision and "Deliberately deferred" in the +[ROADMAP](ROADMAP.md). + +## Review sample, in full + +Ten reviews were visible across the locales fetched. Listed here so the analysis above can be +checked against what was actually read, rather than against a summary. + +| Author | Date | Helpful | Substance | +|---|---|---|---| +| hennie postma | 2025-06-25 | **998** | Praise: quick, easy, clean; ads are a bottom bar that never interrupts | +| Nirmalya Chakraborty | 2024-06-30 | **540** | Praise: easy; auto-sorts by type/size/date; "No ad available" | +| Ivan Lewis-Coker | 2024-07-01 | **392** | Praise: powerful, uses it beside Total Commander; requests two-pane | +| Brian Witte | 2025-02-13 | **255** | Complaint: thumbnails load only when scrolling stops; batch rename cannot continue a sequence | +| Sean Nym | 2024-08-19 | **166** | Praise: easy navigation, quick search, the "analyse" feature; ads not intrusive | +| Kaizer 913 | 2026-09-10 | **100** | Complaint: folder listing, image opening and refresh all slower; "getting worse" | +| Leon Davidoff | 2024-09-23 | 57 | Complaint: timeout on large/NAS folders, surfaced as a misleading network error | +| ANTHONY EZENWA | 2026-04-19 | 24 | Complaint: refresh and transfer speed; developer reply blames the SD card | +| Duncan Elliott | 2026-07-21 | 22 | Praise: free, easy, moved ~150–185 GB to a PC in one 3–4 minute transfer | +| Karen Hellawell | 2026-09-08 | 6 | Mixed: loves the simplicity; wants more clouds, a duplicate finder and cloud sync; "would be happy to pay" | + +Two developer replies were also visible and are quoted in § "The developer replies can read as +deflection". + +## Sources + +| Source | URL | Fetched | +|---|---|---| +| Play listing (the developer's own description, "What's new", price labels, Data Safety form, ratings) | `https://play.google.com/store/apps/details?id=com.alphainventor.filemanager` — locales `hl=en` with `gl=US`, `GB`, `IN`, `CA`, `NG` | 2026-10-17 | +| APKPure mirror (version history, APK size, security scan, *third-party* prose description) | `https://apkpure.com/file-manager/com.alphainventor.filemanager` | 2026-10-17 | +| Play **search** results for `file manager`, per storefront (rank and per-storefront rating) | `https://play.google.com/store/search?q=file%20manager&c=apps&hl=en&gl=IN` with `gl` = `US`, `BR`, `ID`, `PH`, `NG`, `MX`, `VN`, `DE`, `KR` | 2026-10-17 | +| Market-data sites, **all gated** — no country numbers obtainable from outside | Sensor Tower, data.ai, AppMagic, Apptopia, SimilarWeb (login/paywall, free pages need JS); AppBrain, APKGK, apk.support (403); 42matters, AppStoreSpy (404); Play's country top-charts endpoints (404 — the legacy `collection/*` URLs are gone) | 2026-10-17 | +| Attempted, **blocked** — nothing from these is used here | AppBrain (403), APKGK (403), apk.support (403), SimilarWeb (needs JS), DuckDuckGo HTML (bot challenge), Reddit search JSON (403), Uptodown (404), Softonic (412), APKCombo (connection failed) | 2026-10-17 | + +**Re-check this document** if the app ships a major version, and treat the review themes as +hypotheses until a larger sample exists — the honest way to get one is the Play Developer API's +review feed (or the Play Console's own review inbox for a competing listing we do not own), not +another scrape of the server-rendered page. diff --git a/docs/EDGE_CASE_TESTING.md b/docs/EDGE_CASE_TESTING.md index 5d8509c..f08ce15 100644 --- a/docs/EDGE_CASE_TESTING.md +++ b/docs/EDGE_CASE_TESTING.md @@ -115,6 +115,56 @@ The lesson is the one the phone already taught twice: a green test suite says th 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 + `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. + ## Not verified, and why - **Long-press multi-select via adb: it works, but not every time.** `input swipe x y x y 900` diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index db03528..fc125dd 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -20,6 +20,8 @@ Legend: ✅ done · 🚧 in progress · ⏳ not started · 💡 candidate (propo | M8 | Localisation groundwork | ✅ | Every user-visible string is in `res/values/strings.xml` with real ``, and `text/TextRes.kt` maps **both** `OperationKind` and `FileKind` onto resources so the screen, the notification and the details sheet cannot drift. The audit found a genuine miss: the details sheet rendered `FileKind.name`, i.e. untranslatable English — now a resource. `isPseudoLocalesEnabled = true` on the debug build makes "did every string come from resources?" mechanically checkable; verified `en-rXA` is present in the debug APK and the flag is debug-only. Byte units and the timestamp pattern are deliberately unlocalised, and the README says why. **142 JVM tests** | | M9 | Monetisation | ✅ | **Decided: one-time paid — €4.49 base with a 14-day launch price of €2.29**, both set in the Play Console (*Products › App pricing*), so there is **no Billing client, no `com.android.vending.BILLING` permission and no purchase code**. Rationale, market anchors and Console steps: [MONETISATION](MONETISATION.md). Verified against the release APK: zero billing and zero `INTERNET` references. **The price must be set before the first upload** — Play allows paid → free but never free → paid | | 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) | ## Key decisions @@ -39,3 +41,12 @@ Legend: ✅ done · 🚧 in progress · ⏳ not started · 💡 candidate (propo - App manager (`QUERY_ALL_PACKAGES`), APK installation (`REQUEST_INSTALL_PACKAGES`). - RAR archives (the format needs native code) and 7z *creation*; root explorer; encryption vault; themes/icon packs. + +## Candidates from the competitive analysis + +A survey of the market leader (its features, and what its users praise and complain about) is in +[COMPETITIVE_ANALYSIS](COMPETITIVE_ANALYSIS.md). Its gaps that cost **no** new permission are +listed there as 💡 candidates. **Category libraries were the first of them and are now M11, and the +duplicate finder the second (M12)**; the rest — batch rename with sequence continuation, bookmarks, +and the post-paste "copy again" / "return to source folder" — are proposals, not scheduled work, and +adding one is a roadmap decision rather than a side effect of reading that document. diff --git a/docs/STORE_LISTING.md b/docs/STORE_LISTING.md index 8ff6647..25ccbfe 100644 --- a/docs/STORE_LISTING.md +++ b/docs/STORE_LISTING.md @@ -44,6 +44,13 @@ python3 tools/make-demo-library.py # -> demo-files/ (git-ignored, ~161 MB adb push demo-files /sdcard/Download/PlainFiles-Demo ``` +⚠️ **The home screen and the library views need this more than a folder listing does.** A library is +not a folder — it lists files from *everywhere* — so shooting `Images` on a real phone publishes the +owner's own filenames and thumbnails, and the home screen's counts publish how many files of each +kind the device holds. Both must be shot on a device, or an AVD, whose only storage is the demo +library. This was found by running the libraries on a real phone and seeing its owner's files in +them: [EDGE_CASE_TESTING](EDGE_CASE_TESTING.md). + `tools/make-demo-library.py` builds eight distinct sample images (so the grid view and thumbnails have something to show), a handful of documents, sized stand-ins for video and audio so the storage-usage screen has something worth ranking, and a real ZIP so the extract