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
@@ -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,