Let a folder be starred, and put the starred ones on the home screen

The app bar of any folder now carries a star, and a starred folder appears in a Favourites section
on the home screen, above the libraries. Above, because it is the user's own list - and absent
entirely while empty, so someone opening the app for the first time still meets the libraries first,
which is the order they were given for a reason. The star in the row is the removal, which is what a
star means everywhere else: the mark that says "this is a favourite" is the thing you tap to stop it
being one. That avoids a long-press as well, and with it a destructive gesture nobody can see.

A favourite is a reference, not a copy: data/BookmarkStore keeps one path per row and nothing about
the folder's contents, so reading the whole list is one query and no filesystem work, which is what
makes it affordable on every visit to the home screen. It has its own SQLite database rather than a
table in the trash index - one table and three queries do not justify Room in a build that has no
annotation processor, and the two stores have nothing to say to each other. Two behaviours there are
decisions rather than defaults. A folder that has gone is kept and marked (Folder not found) instead
of being deleted: a card out of its slot and a folder deleted in another app are indistinguishable
from here, and silently forgetting someone's list because their SD card was out is the worse of the
two mistakes. And opening the row re-checks existence at that moment, because the flag is as old as
the screen - so the answer is a message rather than an empty listing, which would read as an empty
folder instead of a missing one.

Two smaller refusals came out of the same question. There is no star in a library or on the root
screen, because neither is a folder and there would be nothing to come back to: the distinction that
governs the new-folder button, and one the view model enforces too rather than trusting a stale
screen. And a bookmark has no undo, because Undo means "the last change to the filesystem the app
made" and a favourite is not one - the star is one tap to put back. The row's label comes from the
same place the breadcrumbs do, which is why a starred volume root reads as "Internal storage" rather
than as "0".

Sixteen tests: nine against real SQLite through Robolectric (round trip, ordering, starring twice
neither duplicating nor moving a row, and the same no-drop migration guard the trash index carries),
and seven through the view model (toggling, outliving the view model that added it, newest first,
the missing folder kept and marked, the missing folder refusing to open, and a library refusing to be
starred). 371 JVM tests, lint clean. Verified on the phone: the star appears only inside a folder and
flips from "Add to favourites" to "Remove from favourites" without leaving the screen; the home
screen lists the volume root above the libraries with its own label and its path underneath; the row
opens it with the star already filled; and the row's star takes the whole section away with a
snackbar. The documentation moved with it - CHANGELOG, ROADMAP (M15), README, COMPETITIVE_ANALYSIS
(that gap closes instead of joining the candidates), EDGE_CASE_TESTING, and the generated code map.
This commit is contained in:
2026-10-17 07:48:26 +02:00
parent ddae70a8bc
commit 0743bda29e
21 changed files with 831 additions and 32 deletions
+1 -1
View File
@@ -38,7 +38,7 @@ Add `--offline` when the network is unavailable — the dependency cache is alre
```
core/ pure Kotlin, no Android imports: paths, names, kinds, sorting, planning
data/ storage backends (all-files access, SAF), volumes, shares, SQLite index and trash
data/ storage backends (all-files access, SAF), volumes, shares, SQLite search/trash/favourites
domain/ operations over a source: copy/move/delete/rename, archives, search, duplicates
service/ the foreground service that keeps a long operation alive
ui/ Compose: browser/, viewer/, usage/, trash/, duplicates/, theme/
+34
View File
@@ -91,6 +91,40 @@ upload needs a **higher `versionCode`** than every previous upload.
from the first KDoc rather than from a declaration sharing the file's name, and the generated file
lists those at the end instead of pretending otherwise.
- **Folders can be starred, and the home screen opens with your own list.** The app bar of any folder
now carries a star; a filled one means it is already in **Favourites**, which sits above the
libraries on the home screen — above, because it is the user's own list, and absent entirely while
it is empty, so a first-time user still meets the libraries first. The star in the row *is* the
removal, which is what a star means everywhere else: the mark that says "this is a favourite" is
the thing you tap to stop it being one.
A favourite is a **reference, not a copy**: one path per row in a small SQLite table
(`data/BookmarkStore` and `SqliteBookmarkStore`, in their own database rather than as a table in
the trash index, because the two have nothing to say to each other), so reading the whole list is
one query and no filesystem work. That is what makes it affordable on every visit to the home
screen, and it is also why **a folder that has gone is kept and marked rather than deleted**: a
card out of its slot and a folder deleted in another app look exactly alike from here, and
silently forgetting someone's list because their SD card was out is the worse of the two
mistakes. The row then says *Folder not found*, and opening it re-checks at that moment — the flag
is as old as the screen — so the answer is a message rather than an empty listing, which would
read as "this folder is empty" instead of "this folder is gone".
Two smaller decisions came out of the same question. **No star in a library or on the root
screen**: neither is a folder, so there is nothing to come back to — the distinction that governs
the new-folder button — and the view model refuses the toggle there too rather than trusting a
stale screen. And **no undo for a bookmark**: `Undo` is "the last change to the filesystem the app
made", which a favourite is not, and one tap on the star is already the way back. **16 tests** (9
against real SQLite through Robolectric — round trip, ordering, "starring twice neither
duplicates nor moves it", and the same no-drop migration guard the trash index carries; 7 through
the view model — toggling, outliving the view model that added it, newest first, the missing
folder kept and marked, the missing folder refusing to open, and a library refusing to be
starred). **371 JVM tests**, lint clean.
Verified on the phone: the star appears only inside a folder; a starred volume reads as "Internal
storage" rather than as `0`, because the label comes from the same place the breadcrumbs do; the
home screen lists it above the libraries; the row opens it with the star already filled; and
removing it from the row takes the whole section away with a snackbar.
### Changed
- **File thumbnails are bigger.** The leading visual in a list row went from 28 dp to 48 dp and
+7 -4
View File
@@ -12,16 +12,19 @@ no advertising or tracking SDKs.
## Status
**M14 — renaming a batch: done.** Selecting several files and choosing Rename opens a **pattern**
dialog instead of the single-file one: one name, the number to start at, and a preview of the names
**M15 — favourites: done.** A star in any folder's app bar puts that folder on the home screen, above
the libraries, where a folder that has gone is marked *not found* rather than silently dropped and
the row's own star removes it. Before it: **M14 — renaming a batch** (selecting several files and
choosing Rename opens a **pattern** dialog instead of the single-file one: one name, the number to
start at, and a preview of the names
it will produce — and the numbering **carries on from the folder's own**, so `series (1)` and
`series (2)` already there make the next batch start at 3. A batch that cannot be done is refused
before anything moves, naming the file in the way, and the whole batch is one undo step. Before it:
**M13 — video thumbnails** (a video row shows a frame of the video itself rather than a film icon —
the platform's own retriever, through Coil, with a frame taken a second in and the icon kept as the
fallback when a file cannot be decoded), **M12 — duplicate finder** (size, then the head of every
candidate, then the survivors in full), **M11 — library views** (a **Libraries**
section on the root screen — Images, Videos, Audio, Documents, Archives and Recent — whose rows open
candidate, then the survivors in full), **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.
@@ -6,12 +6,14 @@ import coil3.ImageLoader
import coil3.SingletonImageLoader
import coil3.video.VideoFrameDecoder
import de.softwareapp_hb.filemanager.data.ApkInspector
import de.softwareapp_hb.filemanager.data.BookmarkStore
import de.softwareapp_hb.filemanager.data.ContentResolverIncomingSource
import de.softwareapp_hb.filemanager.data.DeleteRequestFactory
import de.softwareapp_hb.filemanager.data.IncomingSource
import de.softwareapp_hb.filemanager.data.MediaStoreDeleter
import de.softwareapp_hb.filemanager.data.PackageManagerApkInspector
import de.softwareapp_hb.filemanager.data.SearchIndex
import de.softwareapp_hb.filemanager.data.SqliteBookmarkStore
import de.softwareapp_hb.filemanager.data.SqliteSearchIndex
import de.softwareapp_hb.filemanager.data.SqliteTrashIndex
import de.softwareapp_hb.filemanager.data.StorageProvider
@@ -22,9 +24,9 @@ import de.softwareapp_hb.filemanager.service.ServiceOperationNotifier
/**
* Application entry point and the app's tiny object graph.
*
* There is no dependency-injection framework here on purpose: four long-lived collaborators
* are built once and handed to the one ViewModel that needs them. Nothing holds a reference
* to an Activity, so none of this leaks.
* There is no dependency-injection framework here on purpose: the long-lived collaborators below are
* built once and handed to the one ViewModel that needs them. Nothing holds a reference to an
* Activity, so none of this leaks.
*/
class FileManagerApplication : Application(), SingletonImageLoader.Factory {
@@ -56,6 +58,10 @@ class FileManagerApplication : Application(), SingletonImageLoader.Factory {
lateinit var searchIndex: SearchIndex
private set
/** Remembers the folders the user starred, so the home screen can offer them back. */
lateinit var bookmarkStore: BookmarkStore
private set
override fun onCreate() {
super.onCreate()
storage = StorageProvider(this)
@@ -65,6 +71,7 @@ class FileManagerApplication : Application(), SingletonImageLoader.Factory {
trashIndex = SqliteTrashIndex(this)
apkInspector = PackageManagerApkInspector(this)
searchIndex = SqliteSearchIndex(this)
bookmarkStore = SqliteBookmarkStore(this)
}
/**
@@ -0,0 +1,34 @@
package de.softwareapp_hb.filemanager.data
import de.softwareapp_hb.filemanager.core.StoragePath
/**
* The folders the user starred.
*
* A favourite is a **reference**, not a copy: nothing about the folder's contents is stored, so
* reading the whole list is one small query and no filesystem work at all. That is what makes it
* cheap enough to read every time the home screen appears.
*
* Storing a path and nothing else has one consequence worth stating, because it is a decision
* rather than an oversight: whether a starred folder still *exists* is not this store's question.
* A card taken out of the slot and a folder deleted in another app look exactly alike from here, so
* the list keeps its entry and the screen marks it as not found — silently forgetting someone's
* list because an SD card was out of the device would be the worse of the two mistakes.
*/
interface BookmarkStore {
/** Every starred folder, most recently added first. */
suspend fun bookmarks(): List<StoragePath>
/**
* Star [path], recorded as added at [addedAtMillis].
*
* Starring a folder that is already starred is not an error and does **not** move it: a
* favourite's place in the list belongs to the moment it was added, not to the last time
* something asked for it.
*/
suspend fun add(path: StoragePath, addedAtMillis: Long)
/** Un-star [path]. Removing one that is not starred is not an error. */
suspend fun remove(path: StoragePath)
}
@@ -0,0 +1,117 @@
package de.softwareapp_hb.filemanager.data
import android.content.ContentValues
import android.content.Context
import android.database.Cursor
import android.database.sqlite.SQLiteDatabase
import android.database.sqlite.SQLiteOpenHelper
import de.softwareapp_hb.filemanager.core.StoragePath
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
/**
* The starred folders, on SQLite.
*
* Its own database rather than a table in the trash index: the two have nothing to say to each
* other, and a favourite list that cannot be lost by a bug in the trash schema is worth one more
* file. No Room, for the same reason as [SqliteTrashIndex] — one table and three queries do not
* justify an annotation processor in a build that has none, and Robolectric runs the platform's
* SQLite on the JVM.
*/
class SqliteBookmarkStore(context: Context) : BookmarkStore {
private val helper = Helper(context.applicationContext)
override suspend fun bookmarks(): List<StoragePath> = withContext(Dispatchers.IO) {
helper.readableDatabase.query(
TABLE,
null,
null,
null,
null,
null,
// Newest first, and stable between two entries added in the same millisecond: the order
// a person added things is the only order that means anything here.
"$COLUMN_ADDED_AT DESC, $COLUMN_PATH ASC",
).use { cursor ->
buildList {
while (cursor.moveToNext()) add(StoragePath.of(cursor.getString(0)))
}
}
}
override suspend fun add(path: StoragePath, addedAtMillis: Long) = withContext(Dispatchers.IO) {
// IGNORE rather than REPLACE: re-starring something that is already starred must not move
// it to the top, so the row keeps the timestamp from the first time it was added.
val values = ContentValues().apply {
put(COLUMN_PATH, path.value)
put(COLUMN_ADDED_AT, addedAtMillis)
}
helper.writableDatabase.insertWithOnConflict(TABLE, null, values, SQLiteDatabase.CONFLICT_IGNORE)
Unit
}
override suspend fun remove(path: StoragePath) = withContext(Dispatchers.IO) {
helper.writableDatabase.delete(TABLE, "$COLUMN_PATH = ?", arrayOf(path.value))
Unit
}
private class Helper(context: Context) : SQLiteOpenHelper(
context,
DATABASE_NAME,
null,
DATABASE_VERSION,
) {
override fun onCreate(db: SQLiteDatabase) = create(db)
override fun onUpgrade(db: SQLiteDatabase, oldVersion: Int, newVersion: Int) =
upgrade(db, oldVersion, newVersion)
}
/**
* The table's shape, and how that shape moves forward.
*
* `internal` rather than `private` for the same reason as the trash index: a test can drive
* `create` and `upgrade` against a throwaway database, which is the only way to prove the
* upgrade path before a version 2 exists.
*/
internal companion object {
const val DATABASE_NAME = "bookmarks.db"
const val DATABASE_VERSION = 1
const val TABLE = "bookmarks"
const val COLUMN_PATH = "path"
const val COLUMN_ADDED_AT = "added_at"
fun create(db: SQLiteDatabase) {
db.execSQL(
"""
CREATE TABLE $TABLE (
$COLUMN_PATH TEXT NOT NULL PRIMARY KEY,
$COLUMN_ADDED_AT INTEGER NOT NULL
)
""".trimIndent(),
)
db.execSQL("CREATE INDEX bookmarks_added_at ON $TABLE ($COLUMN_ADDED_AT)")
}
/**
* Bring [db] from [oldVersion] up to [newVersion], one version at a time.
*
* Version 1 is the first this app has shipped, so the loop is empty. The *shape* is the
* point: when version 2 arrives, its step is the work that gets there. **A bare `DROP`
* must never appear here.** Losing a favourite list is not catastrophic the way losing the
* trash index is, but it is the user's own list, and a schema bump that silently empties it
* is not a migration — which is why a version with no step reports itself instead of
* recreating the table.
*/
fun upgrade(db: SQLiteDatabase, oldVersion: Int, newVersion: Int) {
for (version in oldVersion until newVersion) {
error(
"No migration from bookmarks schema version $version to ${version + 1}. " +
"Add one to SqliteBookmarkStore.upgrade and bump DATABASE_VERSION; " +
"do not drop the table.",
)
}
}
}
}
@@ -65,6 +65,8 @@ fun BrowserContent(
onOpenRoot: (StoragePath) -> Unit,
onNavigate: (StoragePath) -> Unit,
onOpenCategory: (FileCategory) -> Unit,
onOpenBookmark: (StoragePath) -> Unit,
onRemoveBookmark: (StoragePath) -> Unit,
onGrantAllFiles: () -> Unit,
onPickFolder: () -> Unit,
onRebuildIndex: () -> Unit,
@@ -139,6 +141,12 @@ fun BrowserContent(
state.isRootView && wide -> Row(modifier = Modifier.fillMaxSize()) {
LazyColumn(modifier = Modifier.width(MasterPaneWidth), state = listState) {
bookmarkSection(
state = state,
crumbLabel = crumbLabel,
onOpenBookmark = onOpenBookmark,
onRemoveBookmark = onRemoveBookmark,
)
librarySection(
state = state,
onOpenCategory = onOpenCategory,
@@ -162,6 +170,12 @@ fun BrowserContent(
}
state.isRootView -> LazyColumn(modifier = Modifier.fillMaxSize(), state = listState) {
bookmarkSection(
state = state,
crumbLabel = crumbLabel,
onOpenBookmark = onOpenBookmark,
onRemoveBookmark = onRemoveBookmark,
)
librarySection(
state = state,
onOpenCategory = onOpenCategory,
@@ -207,6 +221,35 @@ private fun LazyListScope.librarySection(
/** A key that cannot collide with a folder's path, which always starts with `/`. */
private const val LIBRARY_SECTION_KEY = "libraries-section"
/**
* The home screen's starred folders, as items for the root list.
*
* Above the libraries because it is the user's own list — and absent entirely while it is empty, so
* someone opening the app for the first time still meets the libraries first. [crumbLabel] is the
* browser's own naming, which is what makes a starred volume read as "Internal storage" rather than
* as its last path segment.
*/
private fun LazyListScope.bookmarkSection(
state: BrowserUiState,
crumbLabel: (StoragePath) -> String,
onOpenBookmark: (StoragePath) -> Unit,
onRemoveBookmark: (StoragePath) -> Unit,
) {
if (state.bookmarks.isEmpty()) return
item(key = BOOKMARK_SECTION_KEY) { BookmarkSectionHeader() }
items(items = state.bookmarks, key = { "bookmark-${it.path.value}" }) { bookmark ->
BookmarkRow(
label = crumbLabel(bookmark.path),
bookmark = bookmark,
onClick = { onOpenBookmark(bookmark.path) },
onRemove = { onRemoveBookmark(bookmark.path) },
)
}
}
/** A key that cannot collide with a bookmark's path, which always starts with `/`. */
private const val BOOKMARK_SECTION_KEY = "bookmarks-section"
/** A directory: its breadcrumbs, then its entries as a list or a grid. */
@Composable
private fun DirectoryView(
@@ -11,9 +11,12 @@ import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Folder
import androidx.compose.material.icons.filled.SdCard
import androidx.compose.material.icons.filled.Star
import androidx.compose.material.icons.filled.Storage
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.ListItem
import androidx.compose.material3.ListItemDefaults
import androidx.compose.material3.MaterialTheme
@@ -92,15 +95,89 @@ fun EmptyState(message: String, modifier: Modifier = Modifier) {
/** The heading over the libraries on the home screen. */
@Composable
fun LibrarySectionHeader(modifier: Modifier = Modifier) {
fun LibrarySectionHeader(modifier: Modifier = Modifier) =
SectionHeader(stringResource(R.string.library_section), modifier)
/** The heading over the starred folders on the home screen. */
@Composable
fun BookmarkSectionHeader(modifier: Modifier = Modifier) =
SectionHeader(stringResource(R.string.bookmark_section), modifier)
/** One heading for every section, so the home screen's two lists cannot drift apart. */
@Composable
private fun SectionHeader(text: String, modifier: Modifier = Modifier) {
Text(
text = stringResource(R.string.library_section),
text = text,
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 starred folder.
*
* The filled star in the row **is** the removal, which is the pattern a star has everywhere else —
* the mark that says "this is a favourite" is the thing you tap to stop it being one. [label] comes
* from the same place the breadcrumbs do, so a starred volume reads as "Internal storage" rather
* than as its last path segment.
*
* A folder that has gone keeps its place and says so instead of disappearing: see
* [BrowserUiState.bookmarks].
*/
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun BookmarkRow(
label: String,
bookmark: Bookmark,
modifier: Modifier = Modifier,
onClick: () -> Unit,
onRemove: () -> Unit,
) {
val tint = if (bookmark.exists) {
MaterialTheme.colorScheme.primary
} else {
MaterialTheme.colorScheme.onSurfaceVariant
}
ListItem(
modifier = modifier
.fillMaxWidth()
.combinedClickable(onClick = onClick),
leadingContent = {
Icon(
imageVector = Icons.Filled.Folder,
contentDescription = null,
tint = tint,
)
},
headlineContent = { Text(label) },
supportingContent = {
// The full path is what tells two folders with the same name apart, and it is what the
// details sheet shows for a file as well.
Text(
text = if (bookmark.exists) {
bookmark.path.value
} else {
stringResource(R.string.bookmark_missing)
},
style = MaterialTheme.typography.bodySmall,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
},
trailingContent = {
IconButton(onClick = onRemove) {
Icon(
imageVector = Icons.Filled.Star,
contentDescription = stringResource(R.string.browser_action_unbookmark),
tint = tint,
)
}
},
)
}
/**
* A row on the home screen: one library, and how many files the index knows it holds.
*
@@ -357,6 +357,10 @@ fun BrowserScreen(
BrowserTopBar(
title = title,
canNavigateUp = !state.isRootView,
// A library is not a folder, so there is nothing to come back to: the star means
// "come back to *this folder*", which only exists when there is a folder on screen.
canBookmark = state.path != null,
bookmarked = state.isCurrentFolderBookmarked,
query = state.query,
searchOpen = searchOpen,
searchScope = state.searchScope,
@@ -388,6 +392,7 @@ fun BrowserScreen(
},
onSort = { showSort = true },
onPaste = viewModel::paste,
onToggleBookmark = viewModel::toggleBookmark,
onRefresh = viewModel::refresh,
onUsage = viewModel::openUsage,
onDuplicates = viewModel::findDuplicates,
@@ -480,6 +485,8 @@ fun BrowserScreen(
onOpenRoot = viewModel::openDirectory,
onNavigate = viewModel::openDirectory,
onOpenCategory = viewModel::openCategory,
onOpenBookmark = viewModel::openBookmark,
onRemoveBookmark = viewModel::removeBookmark,
onGrantAllFiles = {
val missing = provider.missingRuntimePermissions()
if (missing.isEmpty()) {
@@ -15,11 +15,14 @@ import androidx.compose.material.icons.filled.ContentPaste
import androidx.compose.material.icons.filled.Refresh
import androidx.compose.material.icons.filled.Search
import androidx.compose.material.icons.filled.Sort
import androidx.compose.material.icons.filled.Star
import androidx.compose.material.icons.filled.StarBorder
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
@@ -38,12 +41,15 @@ import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import de.softwareapp_hb.filemanager.R
/** The browser's app bar: title, up, and the search / sort / overflow actions. */
/** The browser's app bar: title, up, the favourite star, and the search / sort / overflow actions. */
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun BrowserTopBar(
title: String,
canNavigateUp: Boolean,
/** Whether this view is a folder, and so can be starred at all. */
canBookmark: Boolean,
bookmarked: Boolean,
query: String,
searchOpen: Boolean,
searchScope: SearchScope,
@@ -56,6 +62,7 @@ fun BrowserTopBar(
onSearchScope: () -> Unit,
onSort: () -> Unit,
onPaste: () -> Unit,
onToggleBookmark: () -> Unit,
onRefresh: () -> Unit,
onUsage: () -> Unit,
onDuplicates: () -> Unit,
@@ -147,6 +154,27 @@ fun BrowserTopBar(
)
}
} else {
if (canBookmark) {
IconButton(onClick = onToggleBookmark) {
Icon(
// An outline is the invitation and a filled star is the state, which is
// how a star behaves everywhere else it appears.
imageVector = if (bookmarked) Icons.Filled.Star else Icons.Filled.StarBorder,
contentDescription = stringResource(
if (bookmarked) {
R.string.browser_action_unbookmark
} else {
R.string.browser_action_bookmark
},
),
tint = if (bookmarked) {
MaterialTheme.colorScheme.primary
} else {
MaterialTheme.colorScheme.onSurfaceVariant
},
)
}
}
IconButton(onClick = { onSearchOpen(true) }) {
Icon(
imageVector = Icons.Filled.Search,
@@ -70,6 +70,16 @@ data class RootItem(
val freeBytes: Long? = null,
)
/**
* One starred folder as the home screen shows it.
*
* [exists] is a **guess with a timestamp**: it is checked when the home screen is read, and a card
* can be pulled or a folder deleted a second later. So the view model checks again when the row is
* opened — a stale `true` then costs a message, rather than an empty listing that would read as
* "this folder is empty" instead of "this folder is gone".
*/
data class Bookmark(val path: StoragePath, val exists: Boolean = true)
/**
* The entry highlighted in the master pane on a wide screen, and what the detail pane
* shows for it.
@@ -220,6 +230,11 @@ data class BrowserUiState(
* know — the same distinction the search row draws between "not indexed yet" and "none".
*/
val libraryCounts: Map<FileCategory, Int> = emptyMap(),
/**
* The starred folders, newest first: the user's own list, shown above the libraries on the
* home screen.
*/
val bookmarks: List<Bookmark> = emptyList(),
val entries: List<FileEntry> = emptyList(),
val breadcrumbs: List<StoragePath> = emptyList(),
val query: String = "",
@@ -278,6 +293,15 @@ data class BrowserUiState(
) {
val isRootView: Boolean get() = path == null && category == null
/**
* Whether the folder on screen is starred.
*
* `false` on the root list and in a library, neither of which is a folder — the same distinction
* the new-folder button draws, and the reason the screen shows no star there.
*/
val isCurrentFolderBookmarked: Boolean
get() = path != null && bookmarks.any { it.path == path }
val isSelecting: Boolean get() = selection.isNotEmpty()
val selectedEntries: List<FileEntry> get() = entries.filter { it.path in selection }
@@ -26,6 +26,7 @@ import de.softwareapp_hb.filemanager.core.StoragePath
import de.softwareapp_hb.filemanager.core.TransferAction
import de.softwareapp_hb.filemanager.core.isUsableName
import de.softwareapp_hb.filemanager.data.ApkInspector
import de.softwareapp_hb.filemanager.data.BookmarkStore
import de.softwareapp_hb.filemanager.data.DeleteRequestFactory
import de.softwareapp_hb.filemanager.data.IncomingSource
import de.softwareapp_hb.filemanager.data.IndexedFile
@@ -82,6 +83,13 @@ class BrowserViewModel(
private val savedState: SavedStateHandle = SavedStateHandle(),
private val incoming: IncomingSource? = null,
private val trashIndex: TrashIndex = TrashIndex.None,
/**
* Remembers the folders the user starred.
*
* Required rather than defaulted: a view model with nowhere to keep favourites would have a
* star that lights up and then forgets, which is worse than not offering one.
*/
private val bookmarkStore: BookmarkStore,
private val apkInspector: ApkInspector = ApkInspector.None,
/** Remembers what is on the volumes by name; [SearchIndex.None] searches live instead. */
private val searchIndex: SearchIndex = SearchIndex.None,
@@ -140,6 +148,10 @@ class BrowserViewModel(
return
}
// The star's state and the home screen's list live in the store rather than in the saved
// view state, so they are read again whenever everything else is.
viewModelScope.launch { refreshBookmarks() }
val path = _state.value.path
_state.value.category?.let { return loadLibrary(it, walk = false) }
if (path == null) loadRoots() else load(path)
@@ -147,6 +159,79 @@ class BrowserViewModel(
fun openDirectory(path: StoragePath) = load(path)
// -------------------------------------------------------------- favourites
/**
* Star the folder on screen, or un-star it if it already is.
*
* There is nothing to star on the root list or in a library: neither is a folder, so there
* would be nothing to come back to. The screen hides the star there, and this refuses the tap
* as well rather than trusting a stale screen.
*/
fun toggleBookmark() {
val path = _state.value.path ?: return
val starred = _state.value.bookmarks.any { it.path == path }
viewModelScope.launch {
if (starred) {
bookmarkStore.remove(path)
message(R.string.message_bookmark_removed)
} else {
bookmarkStore.add(path, clock())
message(R.string.message_bookmark_added)
}
refreshBookmarks()
}
}
/** Un-star [path] from the home screen, where the star on the row is the action. */
fun removeBookmark(path: StoragePath) {
viewModelScope.launch {
bookmarkStore.remove(path)
message(R.string.message_bookmark_removed)
refreshBookmarks()
}
}
/**
* Open a starred folder, or say that it has gone.
*
* Checked again here rather than trusting [Bookmark.exists]: that flag is as old as the home
* screen, and a card can be pulled or a folder deleted in another app between the two. Opening
* it regardless would show an empty listing, which reads as an *empty folder* rather than a
* missing one.
*/
fun openBookmark(path: StoragePath) {
val source = storage.activeSource() ?: return
viewModelScope.launch {
if (runCatching { source.exists(path) }.getOrDefault(false)) {
load(path)
} else {
message(R.string.message_bookmark_missing)
refreshBookmarks()
}
}
}
/** Re-read the starred folders, with whether each is still there. */
private suspend fun refreshBookmarks() {
val source = storage.activeSource()
_state.update { it.copy(bookmarks = bookmarksOf(source)) }
}
/**
* Each starred folder, marked with whether it is still there.
*
* One `exists` per bookmark, which is only affordable because the list is short — and worth it,
* because a row that opens a folder which is not there looks like an empty folder instead.
* With no source at all the answer is "unknown", which is reported as present: the permission
* prompt is what is on screen in that case, and calling every favourite gone would be a lie.
*/
private suspend fun bookmarksOf(source: StorageSource?): List<Bookmark> =
bookmarkStore.bookmarks().map { path ->
val exists = source == null || runCatching { source.exists(path) }.getOrDefault(false)
Bookmark(path, exists = exists)
}
fun navigateUp() {
// A library has nowhere above it but the root list.
if (_state.value.category != null) return loadRoots()
@@ -177,6 +262,9 @@ class BrowserViewModel(
// 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(),
// The same reasoning as the counts: a folder deleted since the list was last
// read should stop being offered as openable.
bookmarks = bookmarksOf(storage.activeSource()),
)
}
recompute()
@@ -1993,6 +2081,7 @@ class BrowserViewModel(
savedState = createSavedStateHandle(),
incoming = app.incoming,
trashIndex = app.trashIndex,
bookmarkStore = app.bookmarkStore,
apkInspector = app.apkInspector,
searchIndex = app.searchIndex,
scratchRoot = app.cacheDir.resolve(SCRATCH_DIRECTORY),
+13
View File
@@ -65,6 +65,19 @@
library is not a folder, and what is missing here is an index, not files. -->
<string name="library_empty">No files indexed here yet</string>
<!-- ===== Browser: favourites =====
Folders the user starred, shown above the libraries on the home screen. The identifier is
"bookmark" throughout the code; the word a reader sees is "favourite", which is what the
row and the star's description say. -->
<string name="bookmark_section">Favourites</string>
<string name="browser_action_bookmark">Add to favourites</string>
<string name="browser_action_unbookmark">Remove from favourites</string>
<!-- A starred folder that is not there any more, e.g. on a card that is out of the slot. -->
<string name="bookmark_missing">Folder not found</string>
<string name="message_bookmark_added">Added to favourites</string>
<string name="message_bookmark_removed">Removed from favourites</string>
<string name="message_bookmark_missing">That folder no longer exists</string>
<!-- ===== Browser: duplicates =====
A scan compares file contents, so every line here is about what was read and what was
found, never about what a name or a date suggests. -->
@@ -0,0 +1,139 @@
package de.softwareapp_hb.filemanager.data
import android.database.sqlite.SQLiteDatabase
import de.softwareapp_hb.filemanager.core.StoragePath
import java.io.File
import kotlinx.coroutines.runBlocking
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.RuntimeEnvironment
import org.robolectric.annotation.Config
/**
* The favourites list is small, but it is the user's own, and it is the only thing standing between
* a restart and an empty home screen. Two rules are exactly what a fake would paper over — the
* ordering, and that starring something twice does not move it — so they are proved against real
* SQLite.
*/
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [34])
class SqliteBookmarkStoreTest {
private fun store() = SqliteBookmarkStore(RuntimeEnvironment.getApplication())
private fun path(name: String) = StoragePath.of("/storage/emulated/0/$name")
@Test
fun `a starred folder reads back whole`() = runBlocking {
val store = store()
store.add(path("Download"), 1000L)
assertEquals(listOf(path("Download")), store.bookmarks())
}
@Test
fun `the path comes back as the same value it was stored with`() = runBlocking {
// A favourite has to match the folder the app opens, so the round trip must not change the
// shape of the path it was handed.
val store = store()
store.add(StoragePath.of("/storage/emulated/0/Download/"), 1000L)
assertEquals(path("Download").value, store.bookmarks().single().value)
}
@Test
fun `the newest favourite comes first`() = runBlocking {
val store = store()
store.add(path("Download"), 1000L)
store.add(path("Documents"), 2000L)
assertEquals(listOf(path("Documents"), path("Download")), store.bookmarks())
}
@Test
fun `starring the same folder twice neither duplicates it nor moves it`() = runBlocking {
val store = store()
store.add(path("Download"), 1000L)
store.add(path("Documents"), 2000L)
store.add(path("Download"), 3000L)
assertEquals(listOf(path("Documents"), path("Download")), store.bookmarks())
}
@Test
fun `removing a favourite takes only that one out`() = runBlocking {
val store = store()
store.add(path("Download"), 1000L)
store.add(path("Documents"), 2000L)
store.remove(path("Download"))
assertEquals(listOf(path("Documents")), store.bookmarks())
}
@Test
fun `removing something that was never starred is not an error`() = runBlocking {
val store = store()
store.remove(path("Download"))
assertTrue(store.bookmarks().isEmpty())
}
@Test
fun `an empty store reads back empty rather than throwing`() = runBlocking {
assertTrue(store().bookmarks().isEmpty())
}
/**
* The same guard the trash index carries, for the same reason: losing a favourite list is not
* catastrophic, but it is the user's own list, and a schema bump that silently empties it is not
* a migration. Version 2 does not exist yet, which is exactly why this is worth pinning.
*/
@Test
fun `a schema bump with no migration reports itself instead of emptying the list`() {
val db = scratchDatabase("bookmarks-migration-undefined")
SqliteBookmarkStore.create(db)
insertOneRow(db)
val failure = runCatching { SqliteBookmarkStore.upgrade(db, 1, 2) }
assertTrue("expected the missing step to be reported", failure.isFailure)
assertEquals("a version bump must not empty the list", 1, rowCount(db))
}
@Test
fun `an already current schema is left alone`() {
val db = scratchDatabase("bookmarks-migration-current")
SqliteBookmarkStore.create(db)
insertOneRow(db)
SqliteBookmarkStore.upgrade(
db,
SqliteBookmarkStore.DATABASE_VERSION,
SqliteBookmarkStore.DATABASE_VERSION,
)
assertEquals(1, rowCount(db))
}
private fun scratchDatabase(name: String): SQLiteDatabase =
SQLiteDatabase.openOrCreateDatabase(
File(RuntimeEnvironment.getApplication().cacheDir, "$name.db").apply { delete() },
null,
)
private fun insertOneRow(db: SQLiteDatabase) {
db.execSQL(
"INSERT INTO ${SqliteBookmarkStore.TABLE} " +
"(${SqliteBookmarkStore.COLUMN_PATH}, ${SqliteBookmarkStore.COLUMN_ADDED_AT}) " +
"VALUES ('/storage/emulated/0/Download', 1000)",
)
}
private fun rowCount(db: SQLiteDatabase): Int =
db.query(SqliteBookmarkStore.TABLE, null, null, null, null, null, null).use { it.count }
}
@@ -0,0 +1,24 @@
package de.softwareapp_hb.filemanager.data.testutil
import de.softwareapp_hb.filemanager.core.StoragePath
import de.softwareapp_hb.filemanager.data.BookmarkStore
/**
* A [BookmarkStore] in a map, ordered like the real one: newest first, and an entry that is added
* again keeps the place it already had rather than jumping to the top.
*/
class InMemoryBookmarkStore : BookmarkStore {
private val added = LinkedHashMap<StoragePath, Long>()
override suspend fun bookmarks(): List<StoragePath> =
added.entries.sortedByDescending { it.value }.map { it.key }
override suspend fun add(path: StoragePath, addedAtMillis: Long) {
added.putIfAbsent(path, addedAtMillis)
}
override suspend fun remove(path: StoragePath) {
added.remove(path)
}
}
@@ -16,12 +16,14 @@ import de.softwareapp_hb.filemanager.core.SortKey
import de.softwareapp_hb.filemanager.core.StoragePath
import de.softwareapp_hb.filemanager.data.ApkInfo
import de.softwareapp_hb.filemanager.data.ApkInspector
import de.softwareapp_hb.filemanager.data.BookmarkStore
import de.softwareapp_hb.filemanager.data.DeleteRequestFactory
import de.softwareapp_hb.filemanager.data.IncomingSource
import de.softwareapp_hb.filemanager.data.IndexedFile
import de.softwareapp_hb.filemanager.data.SearchIndex
import de.softwareapp_hb.filemanager.data.StorageAccessMode
import de.softwareapp_hb.filemanager.data.VolumeInfo
import de.softwareapp_hb.filemanager.data.testutil.InMemoryBookmarkStore
import de.softwareapp_hb.filemanager.domain.ArchiveEngine
import de.softwareapp_hb.filemanager.domain.ArchiveEntry
import de.softwareapp_hb.filemanager.domain.OperationKind
@@ -131,6 +133,7 @@ class BrowserViewModelTest {
apkInspector: ApkInspector = ApkInspector.None,
scratchRoot: File? = null,
searchIndex: SearchIndex = SearchIndex.None,
bookmarks: BookmarkStore = InMemoryBookmarkStore(),
clock: () -> Long = System::currentTimeMillis,
) = BrowserViewModel(
storage = location,
@@ -140,6 +143,7 @@ class BrowserViewModelTest {
incoming = incoming,
apkInspector = apkInspector,
searchIndex = searchIndex,
bookmarkStore = bookmarks,
clock = clock,
scratchRoot = scratchRoot,
)
@@ -663,6 +667,115 @@ class BrowserViewModelTest {
)
}
/*
* Favourites: the user's own list on the home screen. Beyond "it stores a path", two things are
* worth proving: a favourite outlives the view model that added it (that is the whole point of
* the store), and a folder that has gone is *kept and marked* rather than quietly dropped — an
* SD card out of its slot looks exactly like a deleted folder from here.
*/
@Test
fun `starring the folder on screen adds it to the favourites`() {
val model = viewModel()
model.openDirectory(downloads)
model.toggleBookmark()
assertEquals(listOf(downloads), model.state.value.bookmarks.map { it.path })
assertTrue(model.state.value.isCurrentFolderBookmarked)
assertTrue("a folder that is there is not marked missing", model.state.value.bookmarks.single().exists)
assertEquals(R.string.message_bookmark_added, model.state.value.message?.textRes)
}
@Test
fun `starring it again takes it back out`() {
val model = viewModel()
model.openDirectory(downloads)
model.toggleBookmark()
model.toggleBookmark()
assertTrue(model.state.value.bookmarks.isEmpty())
assertFalse(model.state.value.isCurrentFolderBookmarked)
assertEquals(R.string.message_bookmark_removed, model.state.value.message?.textRes)
}
@Test
fun `a favourite outlives the view model that added it`() {
// One store, two view models: that is what a restart looks like from here.
val store = InMemoryBookmarkStore()
viewModel(bookmarks = store).apply {
openDirectory(downloads)
toggleBookmark()
}
val reopened = viewModel(bookmarks = store)
assertEquals(listOf(downloads), reopened.state.value.bookmarks.map { it.path })
}
@Test
fun `the newest favourite comes first`() {
val store = InMemoryBookmarkStore()
viewModel(bookmarks = store, clock = { 1000L }).apply {
openDirectory(downloads)
toggleBookmark()
}
val later = viewModel(bookmarks = store, clock = { 2000L })
later.openDirectory(documents)
later.toggleBookmark()
assertEquals(listOf(documents, downloads), later.state.value.bookmarks.map { it.path })
}
@Test
fun `a favourite whose folder has gone is kept and marked`() {
val model = viewModel()
model.openDirectory(downloads)
model.toggleBookmark()
runBlocking { storage.delete(downloads) }
// Twice: Download goes up to the volume root, and the volume root goes up to the home
// screen — which is the view that re-reads the list.
model.navigateUp()
model.navigateUp()
val bookmark = model.state.value.bookmarks.single()
assertEquals(downloads, bookmark.path)
assertFalse("a card out of its slot is not a reason to forget the list", bookmark.exists)
}
@Test
fun `opening a favourite that has gone says so instead of showing it empty`() {
val model = viewModel()
model.openDirectory(downloads)
model.toggleBookmark()
runBlocking { storage.delete(downloads) }
model.openBookmark(downloads)
assertEquals(
"an empty folder and a missing one must not look alike",
R.string.message_bookmark_missing,
model.state.value.message?.textRes,
)
assertEquals("and it stays where it was", downloads, model.state.value.path)
}
@Test
fun `a library cannot be starred`() {
// A library is not a folder, so there is nothing to come back to — the same distinction the
// new-folder button draws, and the reason the screen shows no star there.
val model = viewModel(searchIndex = indexOf())
model.openCategory(FileCategory.IMAGES)
model.toggleBookmark()
assertTrue(model.state.value.bookmarks.isEmpty())
assertNull(model.state.value.message)
}
/*
* 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,
+32 -11
View File
@@ -2,7 +2,7 @@
Generated from the source by `tools/make-code-map.py` - do not edit by hand. Regenerate it after a change (`python3 tools/make-code-map.py`); `--check` exits non-zero when this file is stale.
74 source files, 12036 lines - 28 test files, 355 tests. The behaviour index at the end lists every test name, and in this project a test name is a sentence about what the app does, so it doubles as the specification.
76 source files, 12462 lines - 29 test files, 371 tests. The behaviour index at the end lists every test name, and in this project a test name is a sentence about what the app does, so it doubles as the specification.
Start at `AGENTS.md`. What is done, and why: `docs/ROADMAP.md` and `CHANGELOG.md`. What was verified by hand rather than by a test: `docs/EDGE_CASE_TESTING.md`.
@@ -30,18 +30,20 @@ Pure Kotlin, **no Android imports**: paths, file names, kinds, sorting, formatti
## data/
Storage backends (all-files access and SAF), volumes, incoming shares, the SQLite search index and trash index
Storage backends (all-files access and SAF), volumes, incoming shares, the SQLite search index, trash index and favourite list
| file | lines | what it holds | tests |
|---|---|---|---|
| `AllFilesSource.kt` | 125 | The "All files access" backend: plain java.io.File over absolute paths. AllFilesSource, File() | `AllFilesSourceTest` (13) |
| `ApkInspector.kt` | 100 | What an APK says about itself, read without installing it. ApkInfo, interface(), PackageManagerApkInspector | - |
| `BookmarkStore.kt` | 34 | The folders the user starred. BookmarkStore | - |
| `IncomingSource.kt` | 48 | Something another app has handed to this one. IncomingSource, ContentResolverIncomingSource | - |
| `MediaStoreDeleter.kt` | 73 | A best-effort DeleteRequestFactory backed by MediaStore. interface(), MediaStoreDeleter | - |
| `SafDocumentIds.kt` | 40 | Maps a Storage Access Framework tree onto StoragePaths. SafDocumentIds | `SafDocumentIdsTest` (6) |
| `SafGrant.kt` | 41 | Picking and keeping a folder tree for the SAF fallback. SafGrant | - |
| `SafSource.kt` | 187 | The Storage Access Framework fallback backend. SafSource | `SafSourceTest` (5) |
| `SearchIndex.kt` | 196 | A by-name index of what is on the volumes, so a search does not have to walk them. IndexedFile, VolumeStamp, SearchIndex | - |
| `SqliteBookmarkStore.kt` | 117 | The starred folders, on SQLite. SqliteBookmarkStore | `SqliteBookmarkStoreTest` (9) |
| `SqliteSearchIndex.kt` | 438 | The search index, on SQLite. SqliteSearchIndex | `SqliteSearchIndexTest` (27) |
| `SqliteTrashIndex.kt` | 162 | The trash index, on SQLite. SqliteTrashIndex | `SqliteTrashIndexTest` (7) |
| `StorageAccess.kt` | 80 | The Android-facing side of the storage-access decision. StorageAccess | - |
@@ -92,14 +94,14 @@ The browser screen: view model, state, list and grid, dialogs, selection bar
| file | lines | what it holds | tests |
|---|---|---|---|
| `BrowserComponents.kt` | 301 | The thumbnail an entry's leading visual gets in a list row. ListThumbnailSize, GridThumbnailSize, GridCellMinSize, EntryIcon(), FileEntry(), FileEntry(), VIDEO_FRAME_MILLIS, iconFor(), iconFor(), EntryRow(), EntryGridCell(), FileEntry() | - |
| `BrowserContent.kt` | 420 | Everything below the app bar: the permission prompt, the roots, or a directory. WIDE_LAYOUT_MIN_WIDTH, MasterPaneWidth, BrowserContent(), LazyListScope(), LIBRARY_SECTION_KEY, DirectoryView(), FileEntry(), IndexStatusRow(), FileEntry() | - |
| `BrowserContent.kt` | 463 | Everything below the app bar: the permission prompt, the roots, or a directory. WIDE_LAYOUT_MIN_WIDTH, MasterPaneWidth, BrowserContent(), LazyListScope(), LIBRARY_SECTION_KEY, LazyListScope(), BOOKMARK_SECTION_KEY, DirectoryView(), FileEntry(), IndexStatusRow(), FileEntry() | - |
| `BrowserDetailPane.kt` | 131 | The second pane of the wide layout: what is inside the folder the user picked, or a preview and the details of the file they picked. DetailPane(), FolderPreview(), ImagePreview() | - |
| `BrowserDialogs.kt` | 279 | Renaming several files at once, with the numbering worked out before anything moves. BatchRenameDialog(), PREVIEW_NAMES, batchRenameNote(), NameDialog(), ConfirmDialog(), ConflictDialog() | - |
| `BrowserRoots.kt` | 193 | A row on the root screen: a volume, with its free space when the platform reports it. RootRow(), itemCountLabel(), EmptyState(), LibrarySectionHeader(), LibraryRow(), Breadcrumbs() | - |
| `BrowserScreen.kt` | 585 | BrowserScreen() | - |
| `BrowserTopBar.kt` | 210 | The browser's app bar: title, up, and the search / sort / overflow actions. BrowserTopBar(), SEARCH_FIELD_WIDTH | - |
| `BrowserUiState.kt` | 287 | One immutable description of everything the browser shows. ViewMode, SearchScope, Clipboard, UiMessage, ConflictPrompt, RootItem, DetailFocus, Undo, UndoMove, Overlay, BrowserUiState | - |
| `BrowserViewModel.kt` | 2011 | Drives the browser screen. BrowserViewModel, ArchivePreview | `BrowserViewModelTest` (82) |
| `BrowserRoots.kt` | 270 | A row on the root screen: a volume, with its free space when the platform reports it. RootRow(), itemCountLabel(), EmptyState(), LibrarySectionHeader(), BookmarkSectionHeader(), SectionHeader(), BookmarkRow(), LibraryRow(), Breadcrumbs() | - |
| `BrowserScreen.kt` | 592 | BrowserScreen() | - |
| `BrowserTopBar.kt` | 238 | The browser's app bar: title, up, the favourite star, and the search / sort / overflow actions. BrowserTopBar(), SEARCH_FIELD_WIDTH | - |
| `BrowserUiState.kt` | 311 | One immutable description of everything the browser shows. ViewMode, SearchScope, Clipboard, UiMessage, ConflictPrompt, RootItem, Bookmark, DetailFocus, Undo, UndoMove, Overlay, BrowserUiState | - |
| `BrowserViewModel.kt` | 2100 | Drives the browser screen. BrowserViewModel, ArchivePreview | `BrowserViewModelTest` (89) |
| `DetailsSheet.kt` | 109 | Name, location, size, type and modified date for one entry. DetailsSheet(), DetailLines(), DetailLine() | - |
| `EntryIntents.kt` | 58 | Wraps an entry's path in a content:// URI another app may read. contentUriFor(), viewIntent(), shareIntent() | - |
| `IncomingBar.kt` | 60 | The bar shown while another app is waiting on this one. IncomingBar() | - |
@@ -159,7 +161,7 @@ Compose theme, colours, type
| file | lines | what it holds | tests |
|---|---|---|---|
| `FileManagerApplication.kt` | 90 | Application entry point and the app's tiny object graph. FileManagerApplication | - |
| `FileManagerApplication.kt` | 97 | Application entry point and the app's tiny object graph. FileManagerApplication | - |
| `MainActivity.kt` | 65 | MainActivity | - |
## Behaviour index
@@ -330,6 +332,18 @@ Every test name in the suite, by file: the fastest way to see whether something
- exposes a label and exactly one root
- reports no free space, since the framework cannot
### `data/SqliteBookmarkStoreTest.kt` (9)
- a starred folder reads back whole
- the path comes back as the same value it was stored with
- the newest favourite comes first
- starring the same folder twice neither duplicates it nor moves it
- removing a favourite takes only that one out
- removing something that was never starred is not an error
- an empty store reads back empty rather than throwing
- a schema bump with no migration reports itself instead of emptying the list
- an already current schema is left alone
### `data/SqliteSearchIndexTest.kt` (27)
- a plain query is a substring of the name, case-insensitively
@@ -520,7 +534,7 @@ Every test name in the suite, by file: the fastest way to see whether something
- a determinate operation shows the counter
- starting the service puts it in the foreground with that title
### `ui/browser/BrowserViewModelTest.kt` (82)
### `ui/browser/BrowserViewModelTest.kt` (89)
- starts on the root list
- opening a folder lists it with directories first and natural order
@@ -550,6 +564,13 @@ Every test name in the suite, by file: the fastest way to see whether something
- a stale index is caught up, and the answer includes what the catch-up found
- a changed hidden-files preference asks for a walk rather than a catch-up
- a search from the volume list is answered everywhere, with no folder open
- starring the folder on screen adds it to the favourites
- starring it again takes it back out
- a favourite outlives the view model that added it
- the newest favourite comes first
- a favourite whose folder has gone is kept and marked
- opening a favourite that has gone says so instead of showing it empty
- a library cannot be starred
- opening a library lists the index's files of that kind and no folders
- a library brings the index up to date behind the answer
- a library row whose file has gone is dropped and forgotten
@@ -611,4 +632,4 @@ Every test name in the suite, by file: the fastest way to see whether something
---
Purpose taken from the first KDoc rather than from a declaration sharing the file's name (15 of 74): `MainActivity.kt`, `ArchiveFormats.kt`, `FileNames.kt`, `ApkInspector.kt`, `OperationModels.kt`, `TextRes.kt`, `BrowserComponents.kt`, `BrowserDetailPane.kt`, `BrowserDialogs.kt`, `BrowserRoots.kt`, `BrowserScreen.kt`, `EntryIntents.kt`, `Color.kt`, `Theme.kt`, `Type.kt`
Purpose taken from the first KDoc rather than from a declaration sharing the file's name (15 of 76): `MainActivity.kt`, `ArchiveFormats.kt`, `FileNames.kt`, `ApkInspector.kt`, `OperationModels.kt`, `TextRes.kt`, `BrowserComponents.kt`, `BrowserDetailPane.kt`, `BrowserDialogs.kt`, `BrowserRoots.kt`, `BrowserScreen.kt`, `EntryIntents.kt`, `Color.kt`, `Theme.kt`, `Type.kt`
+4 -3
View File
@@ -308,7 +308,7 @@ requested feature in the market leader, and the permission line is a claim it ca
| 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** | **Closed in M14.** Several files are renamed to one name with a number after each, and the numbering continues the folder's own series, so the "Ice (3), Ice (4)" wish from the top complainant is what the dialog now defaults to |
| Bookmarks | **No** | Their action list includes it; we have none |
| Bookmarks | **No** | **Closed in M15.** A star on any folder puts it in **Favourites** on the home screen, above the libraries; a folder that has gone is kept and marked rather than dropped |
| "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 |
@@ -355,8 +355,9 @@ In the ROADMAP's own vocabulary (💡 = candidate, proposed, not scheduled; ✅
2. ✅ **Duplicate finder** — shipped as **M12**. Asked for by name, by a user offering to pay.
3. ✅ **Batch rename with sequence continuation** — shipped as **M14**, including the "continue from
Ice (2)" case that the top complainant names.
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.
4. ✅ **Bookmarks** — shipped as **M15**. 💡 **"Return to source folder" and "Copy again"** after a
paste remain: 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
+24
View File
@@ -232,6 +232,30 @@ Two things this session added to the tooling notes:
positions apart, which is also why the numbers quoted above come from a dump rather than from
memory.
## The favourites session (M15)
Driven on the Xiaomi phone, starring the volume root — the smallest thing that can be starred, and
the one where the label matters most, because its own name is `0`.
- **The star is offered exactly where it means something.** The app bar of the volume root shows it
as *Add to favourites* (four actions now: star, search, sort, more); the home screen and a library
show none, because neither is a folder. Tapping it flipped the description to *Remove from
favourites* without leaving the screen, which means the state came back out of SQLite rather than
out of the button.
- **The home screen puts it where it belongs.** Back at the root view, a **Favourites** section
appeared above *Libraries* with the volume's own label — *Internal storage*, not `0` — and
`/storage/emulated/0` underneath, which is the path the details sheet would show as well.
- **The row opens what it says.** Tapping it opened the volume with the star already filled.
- **The row's star is the removal.** Tapping it on the home screen took the whole section away and
said *Removed from favourites*; the libraries moved back to the top, which is the empty case a new
user sees.
- **Nothing was left behind.** No files were created for this pass and the one bookmark made was
removed through the UI, so the device is as it was found.
Deliberately not done on the device: a favourite whose folder disappears. The case is covered by two
JVM tests, and staging it would have meant deleting a folder on someone else's phone; the missing
row's wording and the message are the same strings those tests assert.
## Not verified, and why
- **Long-press multi-select via adb: it works, but not every time.** `input swipe x y x y 900`
+7 -6
View File
@@ -1,6 +1,6 @@
# Roadmap
File Manager is built against a fixed milestone plan (**M0–M14**). This file is the
File Manager is built against a fixed milestone plan (**M0–M15**). This file is the
single source of truth for what is done and what remains.
Legend: ✅ done · 🚧 in progress · ⏳ not started · 💡 candidate (proposed, not scheduled)
@@ -24,6 +24,8 @@ Legend: ✅ done · 🚧 in progress · ⏳ not started · 💡 candidate (propo
| M12 | Duplicate finder | ✅ | ***Find duplicates*** **in the overflow.** Three passes, cheapest first: **size** (the index's `duplicateCandidates` query — one query and no reading at all), **the first 64 KB** of every candidate (a filter that may only rule files *out*), and the **whole file** to confirm (`domain/DuplicateFinder`, SHA-256, cancellable between files and between read buffers). Groups are ordered by reclaimable space; each header states the copies and what they cost; every row carries its folder, because identical names with no paths are unactionable; and **nothing is preselected** — the delete is the app's ordinary one, through the trash and the undo snackbar. A file that cannot be read is counted and reported rather than skipped, and the empty state says how many files were compared — or that the index has never run and there was nothing to compare. **323 JVM tests** (15 added). Verified on the Xiaomi phone against four genuine groups in its own storage; the delete was deliberately not pressed on a device that is not ours — see [EDGE_CASE_TESTING](EDGE_CASE_TESTING.md) |
| M13 | Video thumbnails | ✅ | A video row shows **a frame of the video** instead of a film icon: images and videos take the same path through Coil, with the video request asking for a frame a second in. One dependency — `coil-video` at the Coil version already pinned, whose POM adds only coil-core/stdlib/core-ktx — the platform's own `MediaMetadataRetriever` behind it, **no new permission** (no `READ_MEDIA_VIDEO`), and the decoder registered **explicitly** against an `ImageLoader` in `FileManagerApplication` — because R8 strips `coil-video`'s ServiceLoader entry from a release build, which would otherwise have left frames decoding in debug and quietly falling back to icons for real users. A decode that fails settles on the icon rather than an empty square. Verified on the phone against two purpose-made clips and a real video of the device's own, with the demo library's stand-ins falling back as intended, and **re-verified on the release APK**. **324 JVM tests** |
| M14 | Batch rename with sequence continuation | ✅ | Selecting more than one file and choosing Rename opens a **pattern** dialog: a name, a start number, and a live preview of the names it will produce. `core/BatchRename` plans the whole batch before a single file moves and returns one of three answers — **Ready**, **Blocked** (naming the target the folder already holds) or **NothingToDo** — with the deliberate absence of a fourth: a batch that renamed nine of twelve and stopped would leave the user to work out which nine. The numbering **continues the folder's own series** `nextNumber` from the highest `base (n)` it finds, which is the market survey's most-helpful complaint answered by name; the extension is kept per file, the series is counted across extensions, and continuing beats filling the first gap. `SelectionBar` now offers Rename to any selection (one file keeps the name dialog, several get the pattern one), and the batch's renames are **one** `Undo.Relocate` step. The same commit hardens the single rename: `core/FileNames.isUsableName` refuses a name that is not one path segment, because `notes/final.txt` was being resolved as a path and **moved** the file instead of renaming it. **350 JVM tests** (26 added) |
| M15 | Favourites | ✅ | A star in the app bar of any folder, and a **Favourites** section on the home screen above the libraries — absent while empty, so a first-time user still meets the libraries first. `data/BookmarkStore` + `SqliteBookmarkStore` keep one path per row in their own database: a **reference, never a copy**, so the list costs one query and no filesystem work. `BrowserUiState.Bookmark` carries an `exists` flag, and a folder that has gone is **kept and marked** rather than deleted — an unmounted card and a deleted folder are the same thing from here — with opening re-checked at that moment so the answer is a message instead of an empty listing. The row's filled star is the removal (the mark that says "favourite" is the thing you tap to stop it being one), there is no star in a library or on the root screen, and no `Undo` for a bookmark because that type is "the last change to the filesystem" and a favourite is not one. **371 JVM tests** (16 added) |
## Key decisions
| Decision | Choice | Rationale |
@@ -47,8 +49,7 @@ Legend: ✅ done · 🚧 in progress · ⏳ not started · 💡 candidate (propo
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, the
duplicate finder the second (M12) and batch rename with sequence continuation the third (M14)**; the
rest — 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.
listed there as 💡 candidates. **Four of them have since been shipped: category libraries (M11), the
duplicate finder (M12), batch rename with sequence continuation (M14) and favourites (M15)**. What is
left — the post-paste "copy again" / "return to source folder" — is a proposal, not scheduled work,
and taking it on is a roadmap decision rather than a side effect of reading that document.
+1 -1
View File
@@ -34,7 +34,7 @@ PACKAGES = {
"core": "Pure Kotlin, **no Android imports**: paths, file names, kinds, sorting, "
"formatting, matching, the batch-rename planner",
"data": "Storage backends (all-files access and SAF), volumes, incoming shares, the "
"SQLite search index and trash index",
"SQLite search index, trash index and favourite list",
"domain": "Operations over a source: copy/move/delete/rename, archives, search walking, "
"duplicate finding, trash, usage analysis",
"service": "The foreground service that keeps a long operation alive and reports it",