aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorDanilo M. <danix@danix.xyz>2026-11-02 21:25:16 +0100
committerDanilo M. <danix@danix.xyz>2026-11-02 21:25:16 +0100
commit58bf4426d3dc3db51089bd6fd4af1e382040a5c6 (patch)
tree32f623895dca82ba4f842c07f89d13448572eb60
parenta08ced744fbd7966f58ffa32da6d37bebf166619 (diff)
downloadHardenedPass-58bf4426d3dc3db51089bd6fd4af1e382040a5c6.tar.gz
HardenedPass-58bf4426d3dc3db51089bd6fd4af1e382040a5c6.zip
feat(app): add the list rows and folder navigation model
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
-rw-r--r--app/src/main/java/app/passwordstore/ui/list/ListModel.kt61
-rw-r--r--app/src/main/java/app/passwordstore/util/viewmodel/SearchableRepositoryViewModel.kt7
-rw-r--r--app/src/test/java/app/passwordstore/ui/list/ListModelTest.kt93
3 files changed, 161 insertions, 0 deletions
diff --git a/app/src/main/java/app/passwordstore/ui/list/ListModel.kt b/app/src/main/java/app/passwordstore/ui/list/ListModel.kt
new file mode 100644
index 00000000..9154baa5
--- /dev/null
+++ b/app/src/main/java/app/passwordstore/ui/list/ListModel.kt
@@ -0,0 +1,61 @@
+/*
+ * Copyright (C) 2026 Danilo M. <danix@danix.xyz>
+ * SPDX-License-Identifier: GPL-3.0-only
+ */
+
+package app.passwordstore.ui.list
+
+import app.passwordstore.ui.compose.crumbsFor
+import java.io.File
+import kotlinx.collections.immutable.ImmutableList
+import kotlinx.collections.immutable.persistentListOf
+import kotlinx.collections.immutable.toImmutableList
+
+/**
+ * One list row. [path] is the absolute path and the row's key; [count] is a folder's number of
+ * children, 0 for entries.
+ */
+data class ListRow(val path: String, val label: String, val subtitle: String?, val count: Int)
+
+/** The rows of one folder, or of a search inside it: folders above entries. */
+data class ListRows(
+ val folders: ImmutableList<ListRow> = persistentListOf(),
+ val entries: ImmutableList<ListRow> = persistentListOf(),
+)
+
+/**
+ * Splits [files] (already sorted or ranked) into folder and entry rows for [dir], keeping their
+ * order. Labels and subtitles are relative to [dir]: browsing shows plain names, search results
+ * show where they are. Entries get no username or OTP subtitle: both live inside the encrypted
+ * file, and the list never decrypts.
+ */
+fun listRows(files: List<File>, dir: File): ListRows {
+ val (folders, entries) = files.partition { it.isDirectory }
+ return ListRows(
+ folders
+ .map { ListRow(it.absolutePath, it.relativeTo(dir).path, null, childCount(it)) }
+ .toImmutableList(),
+ entries
+ .map {
+ val parent = it.parentFile?.relativeTo(dir)?.path.orEmpty()
+ ListRow(it.absolutePath, it.name.removeSuffix(".gpg"), parent.ifEmpty { null }, 0)
+ }
+ .toImmutableList(),
+ )
+}
+
+/** Children counted for a folder: subfolders and .gpg files, as the old list counted them. */
+private fun childCount(folder: File): Int =
+ folder.listFiles { f -> f.isDirectory || f.extension == "gpg" }?.size ?: 0
+
+/**
+ * How to get from the list at [from] to the list at [to] (both relative to the store root) while
+ * the back stack holds one entry per folder level: how many entries to pop, then which paths to
+ * push. Breadcrumb jumps only pop; opening a folder from search results may pop and push.
+ */
+fun folderMoves(from: String, to: String): Pair<Int, List<String>> {
+ val current = crumbsFor(from)
+ val target = crumbsFor(to)
+ val shared = current.zip(target).takeWhile { (a, b) -> a == b }.size
+ return (current.size - shared) to target.drop(shared).map { it.path }
+}
diff --git a/app/src/main/java/app/passwordstore/util/viewmodel/SearchableRepositoryViewModel.kt b/app/src/main/java/app/passwordstore/util/viewmodel/SearchableRepositoryViewModel.kt
index 48804e7a..ca570dc4 100644
--- a/app/src/main/java/app/passwordstore/util/viewmodel/SearchableRepositoryViewModel.kt
+++ b/app/src/main/java/app/passwordstore/util/viewmodel/SearchableRepositoryViewModel.kt
@@ -24,6 +24,7 @@ import androidx.recyclerview.widget.RecyclerView
import app.passwordstore.data.password.PasswordItem
import app.passwordstore.data.repo.PasswordRepository
import app.passwordstore.injection.prefs.SettingsPreferences
+import app.passwordstore.ui.list.listRows
import app.passwordstore.util.autofill.AutofillPreferences
import app.passwordstore.util.checkMainThread
import app.passwordstore.util.coroutines.DispatcherProvider
@@ -302,6 +303,12 @@ constructor(
private val _currentDir = MutableStateFlow(root)
val currentDir = _currentDir.asStateFlow()
+ /** [searchResult] as folder and entry rows for the Compose list, built off the main thread. */
+ val rows =
+ searchResult
+ .map { result -> listRows(result.passwordItems.map { it.file }, currentDir.value) }
+ .flowOn(dispatcherProvider.io())
+
data class NavigationStackEntry(val dir: File, val recyclerViewState: Parcelable?)
private val navigationStack = ArrayDeque<NavigationStackEntry>()
diff --git a/app/src/test/java/app/passwordstore/ui/list/ListModelTest.kt b/app/src/test/java/app/passwordstore/ui/list/ListModelTest.kt
new file mode 100644
index 00000000..60e3020a
--- /dev/null
+++ b/app/src/test/java/app/passwordstore/ui/list/ListModelTest.kt
@@ -0,0 +1,93 @@
+/*
+ * Copyright (C) 2026 Danilo M. <danix@danix.xyz>
+ * SPDX-License-Identifier: GPL-3.0-only
+ */
+
+package app.passwordstore.ui.list
+
+import java.io.File
+import java.nio.file.Files
+import kotlin.test.AfterTest
+import kotlin.test.Test
+import kotlin.test.assertEquals
+
+class ListModelTest {
+
+ private val root = Files.createTempDirectory("aps").toFile()
+
+ @AfterTest
+ fun cleanUp() {
+ root.deleteRecursively()
+ }
+
+ private fun file(path: String) =
+ File(root, path).apply {
+ parentFile!!.mkdirs()
+ writeText("x")
+ }
+
+ private fun folder(path: String) = File(root, path).apply { mkdirs() }
+
+ @Test
+ fun openingAChildFolderPushesIt() {
+ assertEquals(0 to listOf("work"), folderMoves("", "work"))
+ }
+
+ @Test
+ fun breadcrumbJumpsOnlyPop() {
+ assertEquals(1 to emptyList<String>(), folderMoves("work/servers", "work"))
+ assertEquals(2 to emptyList<String>(), folderMoves("work/servers", ""))
+ }
+
+ @Test
+ fun searchResultsRebuildTheStackFromTheSharedParent() {
+ assertEquals(1 to listOf("personal", "personal/mail"), folderMoves("work", "personal/mail"))
+ assertEquals(
+ 0 to listOf("work/servers", "work/servers/eu"),
+ folderMoves("work", "work/servers/eu"),
+ )
+ }
+
+ @Test
+ fun sameFolderMovesNothing() {
+ assertEquals(0 to emptyList<String>(), folderMoves("work", "work"))
+ }
+
+ @Test
+ fun browsingShowsNamesAndFolderCounts() {
+ val work = folder("work")
+ file("work/mail.gpg")
+ file("work/notes.txt")
+ folder("work/servers")
+ val site = file("site.gpg")
+
+ val rows = listRows(listOf(work, site), root)
+
+ assertEquals(listOf(ListRow(work.absolutePath, "work", null, 2)), rows.folders)
+ assertEquals(listOf(ListRow(site.absolutePath, "site", null, 0)), rows.entries)
+ }
+
+ @Test
+ fun searchResultsShowWhereTheyAre() {
+ val servers = folder("work/servers")
+ val vpn = file("work/vpn.gpg")
+
+ val rows = listRows(listOf(vpn, servers), root)
+
+ assertEquals(listOf(ListRow(servers.absolutePath, "work/servers", null, 0)), rows.folders)
+ assertEquals(listOf(ListRow(vpn.absolutePath, "vpn", "work", 0)), rows.entries)
+ }
+
+ @Test
+ fun keepsTheGivenOrderInsideEachGroup() {
+ val b = file("b.gpg")
+ val a = file("a.gpg")
+ val z = folder("z")
+ val y = folder("y")
+
+ val rows = listRows(listOf(b, z, a, y), root)
+
+ assertEquals(listOf(z.absolutePath, y.absolutePath), rows.folders.map { it.path })
+ assertEquals(listOf(b.absolutePath, a.absolutePath), rows.entries.map { it.path })
+ }
+}