aboutsummaryrefslogtreecommitdiffstats
path: root/ui/compose/src
diff options
context:
space:
mode:
authorDanilo M. <danix@danix.xyz>2026-11-04 18:51:35 +0100
committerDanilo M. <danix@danix.xyz>2026-11-04 18:51:35 +0100
commitba2a60a14717fd7c82cd2c4b1778673d1bb6388f (patch)
tree45fc389cbafedcfef0f88ae8b7e789af39690c84 /ui/compose/src
parent0ad0b1ca6a7f7fa814a92dd6eaf9da88948ee377 (diff)
parent136ee43d6080da288afae8ec133727962ea60640 (diff)
downloadHardenedPass-ba2a60a14717fd7c82cd2c4b1778673d1bb6388f.tar.gz
HardenedPass-ba2a60a14717fd7c82cd2c4b1778673d1bb6388f.zip
Merge branch 'compose': Compose UI overhaul
Every screen moves from XML layouts, Views and Fragments to Jetpack Compose with Material 3, in one MainActivity with a NavHost: the list with breadcrumbs, search, pull to sync and multi-select; the entry view with OTP countdown; the editor with generator sheet and folder picker; setup (welcome, git server and clone, SSH key, PGP keys); settings, git config, commit log and proxy; and the autofill picker, publisher warning and decryption prompts. LaunchActivity stays the only exported activity. Fixes found on the way reach master with it, among them symlink-safe deletes, the proxy user name, screen lock gated switches, the Android 14 app lock and screen lock prompts, and the autofill passphrase cache rule. The View-era dependencies and view binding are gone; Material Components (Views) stays for the git dialogs. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Diffstat (limited to 'ui/compose/src')
-rw-r--r--ui/compose/src/main/kotlin/app/passwordstore/ui/APSAppBar.kt76
-rw-r--r--ui/compose/src/main/kotlin/app/passwordstore/ui/compose/Breadcrumbs.kt82
-rw-r--r--ui/compose/src/main/kotlin/app/passwordstore/ui/compose/EntryRows.kt146
-rw-r--r--ui/compose/src/main/kotlin/app/passwordstore/ui/compose/FieldRow.kt102
-rw-r--r--ui/compose/src/main/kotlin/app/passwordstore/ui/compose/GroupedSurface.kt32
-rw-r--r--ui/compose/src/main/kotlin/app/passwordstore/ui/compose/PasswordField.kt81
-rw-r--r--ui/compose/src/main/res/drawable/ic_folder_24.xml9
-rw-r--r--ui/compose/src/test/kotlin/app/passwordstore/ui/compose/BreadcrumbsTest.kt33
8 files changed, 404 insertions, 157 deletions
diff --git a/ui/compose/src/main/kotlin/app/passwordstore/ui/APSAppBar.kt b/ui/compose/src/main/kotlin/app/passwordstore/ui/APSAppBar.kt
deleted file mode 100644
index 7ac99023..00000000
--- a/ui/compose/src/main/kotlin/app/passwordstore/ui/APSAppBar.kt
+++ /dev/null
@@ -1,76 +0,0 @@
-/*
- * Copyright © 2014-2024 The Android Password Store Authors. All Rights Reserved.
- * SPDX-License-Identifier: GPL-3.0-only
- */
-
-package app.passwordstore.ui
-
-import android.annotation.SuppressLint
-import androidx.compose.foundation.layout.RowScope
-import androidx.compose.material.icons.Icons
-import androidx.compose.material.icons.automirrored.filled.ArrowBack
-import androidx.compose.material.icons.filled.Search
-import androidx.compose.material3.ExperimentalMaterial3Api
-import androidx.compose.material3.Icon
-import androidx.compose.material3.IconButton
-import androidx.compose.material3.MaterialTheme
-import androidx.compose.material3.Text
-import androidx.compose.material3.TopAppBar
-import androidx.compose.material3.TopAppBarDefaults
-import androidx.compose.runtime.Composable
-import androidx.compose.ui.Modifier
-import androidx.compose.ui.draw.shadow
-import androidx.compose.ui.graphics.Color
-import androidx.compose.ui.graphics.painter.Painter
-import androidx.compose.ui.graphics.vector.rememberVectorPainter
-import app.passwordstore.ui.compose.preview.DevicePreviews
-import app.passwordstore.ui.compose.preview.ThemePreviews
-import app.passwordstore.ui.compose.theme.APSTheme
-import app.passwordstore.ui.compose.theme.SpacingMedium
-
-@SuppressLint("ComposableLambdaParameterNaming") // The lint doesn't really apply to `actions`
-@Composable
-@OptIn(ExperimentalMaterial3Api::class)
-public fun APSAppBar(
- title: String,
- backgroundColor: Color,
- navigationIcon: Painter?,
- modifier: Modifier = Modifier,
- onNavigationIconClick: (() -> Unit) = {},
- actions: @Composable RowScope.() -> Unit = {},
-) {
- TopAppBar(
- title = { Text(text = title) },
- navigationIcon = {
- if (navigationIcon != null) {
- IconButton(onClick = onNavigationIconClick) {
- Icon(painter = navigationIcon, contentDescription = "Back navigation button")
- }
- }
- },
- actions = actions,
- colors = TopAppBarDefaults.topAppBarColors(containerColor = backgroundColor),
- modifier = modifier.shadow(SpacingMedium),
- )
-}
-
-@ThemePreviews
-@DevicePreviews
-@Composable
-private fun APSAppBarPreview() {
- APSTheme {
- APSAppBar(
- title = "Preview",
- backgroundColor = MaterialTheme.colorScheme.surface,
- navigationIcon = rememberVectorPainter(Icons.AutoMirrored.Filled.ArrowBack),
- actions = {
- IconButton(onClick = {}) {
- Icon(
- painter = rememberVectorPainter(Icons.Filled.Search),
- contentDescription = "Search items",
- )
- }
- },
- )
- }
-}
diff --git a/ui/compose/src/main/kotlin/app/passwordstore/ui/compose/Breadcrumbs.kt b/ui/compose/src/main/kotlin/app/passwordstore/ui/compose/Breadcrumbs.kt
new file mode 100644
index 00000000..3e51d1b4
--- /dev/null
+++ b/ui/compose/src/main/kotlin/app/passwordstore/ui/compose/Breadcrumbs.kt
@@ -0,0 +1,82 @@
+/*
+ * Copyright (C) 2026 Danilo M. <danix@danix.xyz>
+ * SPDX-License-Identifier: GPL-3.0-only
+ */
+
+package app.passwordstore.ui.compose
+
+import androidx.compose.foundation.horizontalScroll
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight
+import androidx.compose.material.icons.filled.Home
+import androidx.compose.material3.AssistChip
+import androidx.compose.material3.AssistChipDefaults
+import androidx.compose.material3.Icon
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import app.passwordstore.ui.compose.preview.ThemePreviews
+import app.passwordstore.ui.compose.theme.APSTheme
+import app.passwordstore.ui.compose.theme.SpacingLarge
+import app.passwordstore.ui.compose.theme.SpacingSmall
+
+/** One breadcrumb chip. The root crumb has an empty [label] and [path]. */
+public data class Crumb(val label: String, val path: String)
+
+/** Crumbs for a folder at [relativePath] inside the store: root first, then each level. */
+public fun crumbsFor(relativePath: String): List<Crumb> {
+ val parts = relativePath.split('/').filter { it.isNotEmpty() }
+ return listOf(Crumb("", "")) +
+ parts.indices.map { i -> Crumb(parts[i], parts.subList(0, i + 1).joinToString("/")) }
+}
+
+/**
+ * Folder location as chips. The last crumb is highlighted; tapping any crumb reports its path.
+ * [rootDescription] is the content description of the root (home) chip.
+ */
+@Composable
+@Suppress("ComposeUnstableCollections")
+public fun BreadcrumbBar(
+ crumbs: List<Crumb>,
+ rootDescription: String,
+ onCrumbClick: (path: String) -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ Row(
+ modifier = modifier.horizontalScroll(rememberScrollState()).padding(horizontal = SpacingLarge),
+ horizontalArrangement = Arrangement.spacedBy(SpacingSmall),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ crumbs.forEachIndexed { i, crumb ->
+ if (i > 0) Icon(Icons.AutoMirrored.Filled.KeyboardArrowRight, contentDescription = null)
+ val current = i == crumbs.lastIndex
+ AssistChip(
+ onClick = { onCrumbClick(crumb.path) },
+ label = {
+ if (crumb.path.isEmpty()) Icon(Icons.Filled.Home, contentDescription = rootDescription)
+ else Text(crumb.label)
+ },
+ colors =
+ if (current)
+ AssistChipDefaults.assistChipColors(
+ containerColor = MaterialTheme.colorScheme.secondaryContainer,
+ labelColor = MaterialTheme.colorScheme.onSecondaryContainer,
+ )
+ else AssistChipDefaults.assistChipColors(),
+ border = null,
+ )
+ }
+ }
+}
+
+@ThemePreviews
+@Composable
+private fun BreadcrumbBarPreview() {
+ APSTheme { BreadcrumbBar(crumbsFor("work/servers"), "Store root", onCrumbClick = {}) }
+}
diff --git a/ui/compose/src/main/kotlin/app/passwordstore/ui/compose/EntryRows.kt b/ui/compose/src/main/kotlin/app/passwordstore/ui/compose/EntryRows.kt
new file mode 100644
index 00000000..2cabaefd
--- /dev/null
+++ b/ui/compose/src/main/kotlin/app/passwordstore/ui/compose/EntryRows.kt
@@ -0,0 +1,146 @@
+/*
+ * Copyright (C) 2026 Danilo M. <danix@danix.xyz>
+ * SPDX-License-Identifier: GPL-3.0-only
+ */
+
+package app.passwordstore.ui.compose
+
+import androidx.compose.foundation.ExperimentalFoundationApi
+import androidx.compose.foundation.background
+import androidx.compose.foundation.combinedClickable
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.RowScope
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.shape.CircleShape
+import androidx.compose.material3.Icon
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.res.painterResource
+import androidx.compose.ui.semantics.clearAndSetSemantics
+import androidx.compose.ui.semantics.selected
+import androidx.compose.ui.semantics.semantics
+import androidx.compose.ui.unit.dp
+import app.passwordstore.ui.compose.preview.ThemePreviews
+import app.passwordstore.ui.compose.theme.APSTheme
+import app.passwordstore.ui.compose.theme.SpacingLarge
+import app.passwordstore.ui.compose.theme.SpacingMedium
+
+/** Circle with the first letter of [name]; the color pair is picked from the name's hash. */
+@Composable
+public fun LetterAvatar(name: String, modifier: Modifier = Modifier) {
+ val scheme = MaterialTheme.colorScheme
+ val pairs =
+ listOf(
+ scheme.primaryContainer to scheme.onPrimaryContainer,
+ scheme.secondaryContainer to scheme.onSecondaryContainer,
+ scheme.tertiaryContainer to scheme.onTertiaryContainer,
+ )
+ val (bg, fg) = pairs[Math.floorMod(name.hashCode(), pairs.size)]
+ Box(
+ modifier.size(36.dp).background(bg, CircleShape).clearAndSetSemantics {},
+ contentAlignment = Alignment.Center,
+ ) {
+ Text(name.take(1).uppercase(), color = fg, style = MaterialTheme.typography.titleMedium)
+ }
+}
+
+@OptIn(ExperimentalFoundationApi::class)
+@Composable
+private fun RowShell(
+ onClick: () -> Unit,
+ onLongClick: (() -> Unit)?,
+ selected: Boolean,
+ modifier: Modifier = Modifier,
+ content: @Composable RowScope.() -> Unit,
+) {
+ Row(
+ modifier =
+ modifier
+ .fillMaxWidth()
+ .background(
+ if (selected) MaterialTheme.colorScheme.secondaryContainer else Color.Transparent
+ )
+ .semantics { this.selected = selected }
+ .combinedClickable(onClick = onClick, onLongClick = onLongClick)
+ .padding(horizontal = SpacingLarge, vertical = SpacingMedium + 2.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ content()
+ }
+}
+
+/** A folder row: icon, name, entry count. */
+@Composable
+public fun FolderRow(
+ name: String,
+ count: Int,
+ onClick: () -> Unit,
+ modifier: Modifier = Modifier,
+ selected: Boolean = false,
+ onLongClick: (() -> Unit)? = null,
+) {
+ RowShell(onClick, onLongClick, selected, modifier) {
+ Box(
+ Modifier.size(36.dp)
+ .background(MaterialTheme.colorScheme.secondaryContainer, CircleShape)
+ .clearAndSetSemantics {},
+ contentAlignment = Alignment.Center,
+ ) {
+ Icon(
+ painterResource(R.drawable.ic_folder_24),
+ contentDescription = null,
+ tint = MaterialTheme.colorScheme.onSecondaryContainer,
+ )
+ }
+ Text(name, Modifier.weight(1f).padding(start = SpacingLarge))
+ Text(
+ count.toString(),
+ style = MaterialTheme.typography.labelMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ }
+}
+
+/** An entry row: letter avatar, name, optional subtitle (username, "OTP"). */
+@Composable
+public fun EntryRow(
+ name: String,
+ subtitle: String?,
+ onClick: () -> Unit,
+ modifier: Modifier = Modifier,
+ selected: Boolean = false,
+ onLongClick: (() -> Unit)? = null,
+) {
+ RowShell(onClick, onLongClick, selected, modifier) {
+ LetterAvatar(name)
+ Column(Modifier.weight(1f).padding(start = SpacingLarge)) {
+ Text(name)
+ if (subtitle != null)
+ Text(
+ subtitle,
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ }
+ }
+}
+
+@ThemePreviews
+@Composable
+private fun RowsPreview() {
+ APSTheme {
+ GroupedSurface {
+ FolderRow("legacy", 2, onClick = {})
+ EntryRow("git.example.org", "you", onClick = {})
+ EntryRow("vpn", "you · OTP", onClick = {}, selected = true)
+ }
+ }
+}
diff --git a/ui/compose/src/main/kotlin/app/passwordstore/ui/compose/FieldRow.kt b/ui/compose/src/main/kotlin/app/passwordstore/ui/compose/FieldRow.kt
new file mode 100644
index 00000000..e632acc1
--- /dev/null
+++ b/ui/compose/src/main/kotlin/app/passwordstore/ui/compose/FieldRow.kt
@@ -0,0 +1,102 @@
+/*
+ * Copyright (C) 2026 Danilo M. <danix@danix.xyz>
+ * SPDX-License-Identifier: GPL-3.0-only
+ */
+
+package app.passwordstore.ui.compose
+
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.saveable.rememberSaveable
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.res.painterResource
+import androidx.compose.ui.text.font.Font
+import androidx.compose.ui.text.font.FontFamily
+import app.passwordstore.ui.compose.preview.ThemePreviews
+import app.passwordstore.ui.compose.theme.APSTheme
+import app.passwordstore.ui.compose.theme.SpacingLarge
+import app.passwordstore.ui.compose.theme.SpacingMedium
+
+/** Content descriptions for [FieldRow]'s buttons, supplied by the app's translated strings. */
+public data class FieldRowLabels(val copy: String, val show: String, val hide: String)
+
+/** Masked values (passwords) use a monospace face, so 0 and O, l and 1 stay apart. */
+private val Monospace = FontFamily(Font(R.font.jetbrainsmono_nl_regular))
+
+/**
+ * A labelled value with an optional copy button. When [masked] is true the value is hidden behind
+ * dots until the reveal toggle is pressed, unless [initiallyRevealed] shows it from the start.
+ */
+@Composable
+public fun FieldRow(
+ label: String,
+ value: String,
+ labels: FieldRowLabels,
+ modifier: Modifier = Modifier,
+ masked: Boolean = false,
+ initiallyRevealed: Boolean = false,
+ onCopy: (() -> Unit)? = null,
+) {
+ var revealed by rememberSaveable { mutableStateOf(initiallyRevealed) }
+ Row(
+ modifier
+ .fillMaxWidth()
+ .padding(
+ start = SpacingLarge,
+ end = SpacingMedium,
+ top = SpacingMedium,
+ bottom = SpacingMedium,
+ ),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Column(Modifier.weight(1f)) {
+ Text(
+ label,
+ style = MaterialTheme.typography.labelMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ val style = MaterialTheme.typography.bodyLarge
+ Text(
+ if (masked && !revealed) "•".repeat(10) else value,
+ style = if (masked) style.copy(fontFamily = Monospace) else style,
+ )
+ }
+ if (masked)
+ IconButton(onClick = { revealed = !revealed }) {
+ Icon(
+ painterResource(
+ if (revealed) R.drawable.baseline_visibility_off_24
+ else R.drawable.baseline_visibility_24
+ ),
+ contentDescription = if (revealed) labels.hide else labels.show,
+ )
+ }
+ if (onCopy != null)
+ IconButton(onClick = onCopy) {
+ Icon(painterResource(R.drawable.ic_content_copy), contentDescription = labels.copy)
+ }
+ }
+}
+
+@ThemePreviews
+@Composable
+private fun FieldRowPreview() {
+ val labels = FieldRowLabels("Copy", "Show", "Hide")
+ APSTheme {
+ GroupedSurface {
+ FieldRow("Password", "hunter2", labels, masked = true, onCopy = {})
+ FieldRow("Username", "you", labels, onCopy = {})
+ }
+ }
+}
diff --git a/ui/compose/src/main/kotlin/app/passwordstore/ui/compose/GroupedSurface.kt b/ui/compose/src/main/kotlin/app/passwordstore/ui/compose/GroupedSurface.kt
new file mode 100644
index 00000000..5a86c549
--- /dev/null
+++ b/ui/compose/src/main/kotlin/app/passwordstore/ui/compose/GroupedSurface.kt
@@ -0,0 +1,32 @@
+/*
+ * Copyright (C) 2026 Danilo M. <danix@danix.xyz>
+ * SPDX-License-Identifier: GPL-3.0-only
+ */
+
+package app.passwordstore.ui.compose
+
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.ColumnScope
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Surface
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.unit.dp
+import app.passwordstore.ui.compose.theme.SpacingMedium
+
+/** Rounded container that groups related rows (folders, entries, fields). */
+@Composable
+public fun GroupedSurface(
+ modifier: Modifier = Modifier,
+ content: @Composable ColumnScope.() -> Unit,
+) {
+ Surface(
+ modifier = modifier.padding(horizontal = SpacingMedium, vertical = SpacingMedium / 2),
+ shape = RoundedCornerShape(16.dp),
+ color = MaterialTheme.colorScheme.surfaceContainerLow,
+ ) {
+ Column(content = content)
+ }
+}
diff --git a/ui/compose/src/main/kotlin/app/passwordstore/ui/compose/PasswordField.kt b/ui/compose/src/main/kotlin/app/passwordstore/ui/compose/PasswordField.kt
deleted file mode 100644
index 066a839c..00000000
--- a/ui/compose/src/main/kotlin/app/passwordstore/ui/compose/PasswordField.kt
+++ /dev/null
@@ -1,81 +0,0 @@
-/*
- * Copyright © 2014-2024 The Android Password Store Authors. All Rights Reserved.
- * SPDX-License-Identifier: GPL-3.0-only
- */
-
-package app.passwordstore.ui.compose
-
-import androidx.annotation.StringRes
-import androidx.compose.material3.Icon
-import androidx.compose.material3.IconButton
-import androidx.compose.material3.Text
-import androidx.compose.material3.TextField
-import androidx.compose.runtime.Composable
-import androidx.compose.runtime.getValue
-import androidx.compose.runtime.mutableStateOf
-import androidx.compose.runtime.remember
-import androidx.compose.runtime.setValue
-import androidx.compose.ui.Modifier
-import androidx.compose.ui.platform.LocalClipboardManager
-import androidx.compose.ui.res.painterResource
-import androidx.compose.ui.res.stringResource
-import androidx.compose.ui.text.AnnotatedString
-import androidx.compose.ui.text.input.PasswordVisualTransformation
-import androidx.compose.ui.text.input.VisualTransformation
-
-@Composable
-public fun PasswordField(
- value: String,
- label: String,
- initialVisibility: Boolean,
- modifier: Modifier = Modifier,
- readOnly: Boolean = false,
-) {
- var visible by remember { mutableStateOf(initialVisibility) }
- TextField(
- value = value,
- onValueChange = {},
- readOnly = readOnly,
- label = { Text(label) },
- visualTransformation =
- if (visible) VisualTransformation.None else PasswordVisualTransformation(),
- trailingIcon = {
- ToggleButton(
- visible = visible,
- contentDescription = "Toggle password visibility",
- onButtonClick = { visible = !visible },
- )
- },
- modifier = modifier,
- )
-}
-
-@Composable
-private fun ToggleButton(
- visible: Boolean,
- contentDescription: String,
- onButtonClick: () -> Unit,
- modifier: Modifier = Modifier,
-) {
- IconButton(onClick = onButtonClick, modifier = modifier) {
- val icon =
- if (visible) painterResource(id = R.drawable.baseline_visibility_off_24)
- else painterResource(id = R.drawable.baseline_visibility_24)
- Icon(painter = icon, contentDescription = contentDescription)
- }
-}
-
-@Composable
-public fun CopyButton(
- textToCopy: String,
- @StringRes buttonLabelRes: Int,
- modifier: Modifier = Modifier,
-) {
- val clipboard = LocalClipboardManager.current
- IconButton(onClick = { clipboard.setText(AnnotatedString(textToCopy)) }, modifier = modifier) {
- Icon(
- painter = painterResource(R.drawable.ic_content_copy),
- contentDescription = stringResource(buttonLabelRes),
- )
- }
-}
diff --git a/ui/compose/src/main/res/drawable/ic_folder_24.xml b/ui/compose/src/main/res/drawable/ic_folder_24.xml
new file mode 100644
index 00000000..747e21a6
--- /dev/null
+++ b/ui/compose/src/main/res/drawable/ic_folder_24.xml
@@ -0,0 +1,9 @@
+<vector xmlns:android="http://schemas.android.com/apk/res/android"
+ android:width="24dp"
+ android:height="24dp"
+ android:viewportWidth="24"
+ android:viewportHeight="24">
+ <path
+ android:fillColor="#FF000000"
+ android:pathData="M10,4H4c-1.1,0 -1.99,0.9 -1.99,2L2,18c0,1.1 0.9,2 2,2h16c1.1,0 2,-0.9 2,-2V8c0,-1.1 -0.9,-2 -2,-2h-8l-2,-2z" />
+</vector>
diff --git a/ui/compose/src/test/kotlin/app/passwordstore/ui/compose/BreadcrumbsTest.kt b/ui/compose/src/test/kotlin/app/passwordstore/ui/compose/BreadcrumbsTest.kt
new file mode 100644
index 00000000..2eb4c3fc
--- /dev/null
+++ b/ui/compose/src/test/kotlin/app/passwordstore/ui/compose/BreadcrumbsTest.kt
@@ -0,0 +1,33 @@
+/*
+ * Copyright (C) 2026 Danilo M. <danix@danix.xyz>
+ * SPDX-License-Identifier: GPL-3.0-only
+ */
+
+package app.passwordstore.ui.compose
+
+import kotlin.test.Test
+import kotlin.test.assertEquals
+
+class BreadcrumbsTest {
+
+ @Test
+ fun rootOnly() {
+ assertEquals(listOf(Crumb("", "")), crumbsFor(""))
+ }
+
+ @Test
+ fun nestedPath() {
+ assertEquals(
+ listOf(Crumb("", ""), Crumb("work", "work"), Crumb("servers", "work/servers")),
+ crumbsFor("work/servers"),
+ )
+ }
+
+ @Test
+ fun ignoresStraySlashes() {
+ assertEquals(
+ listOf(Crumb("", ""), Crumb("work", "work"), Crumb("servers", "work/servers")),
+ crumbsFor("/work//servers/"),
+ )
+ }
+}