aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--app/src/main/java/app/passwordstore/ui/editor/EditorModel.kt146
-rw-r--r--app/src/main/java/app/passwordstore/ui/list/ListModel.kt10
-rw-r--r--app/src/test/java/app/passwordstore/ui/editor/EditorModelTest.kt152
-rw-r--r--app/src/test/java/app/passwordstore/ui/list/ListModelTest.kt12
4 files changed, 320 insertions, 0 deletions
diff --git a/app/src/main/java/app/passwordstore/ui/editor/EditorModel.kt b/app/src/main/java/app/passwordstore/ui/editor/EditorModel.kt
new file mode 100644
index 00000000..cd57400d
--- /dev/null
+++ b/app/src/main/java/app/passwordstore/ui/editor/EditorModel.kt
@@ -0,0 +1,146 @@
+/*
+ * Copyright (C) 2026 Danilo M. <danix@danix.xyz>
+ * SPDX-License-Identifier: GPL-3.0-only
+ */
+
+package app.passwordstore.ui.editor
+
+import androidx.annotation.StringRes
+import androidx.compose.runtime.Immutable
+import app.passwordstore.R
+import app.passwordstore.data.passfile.PasswordEntry
+import app.passwordstore.passgen.random.MaxIterationsExceededException
+import app.passwordstore.passgen.random.NoCharactersIncludedException
+import app.passwordstore.passgen.random.PasswordGenerator
+import app.passwordstore.passgen.random.PasswordLengthTooShortException
+import app.passwordstore.passgen.random.PasswordOption
+import com.github.michaelbull.result.Result
+import com.github.michaelbull.result.mapError
+import com.github.michaelbull.result.runCatching
+import java.io.File
+import java.io.IOException
+import java.net.URLEncoder
+import java.nio.file.Files
+import java.nio.file.StandardCopyOption
+
+/**
+ * What the editor edits. [dir] is the folder relative to the store root ("" is the root), [name]
+ * the file name without `.gpg`. Plaintext: held in memory only, never in saved state or a route.
+ */
+@Immutable
+data class EditorForm(
+ val name: String = "",
+ val dir: String = "",
+ val password: String = "",
+ val username: String = "",
+ val extra: String = "",
+)
+
+/** The form for [entry], stored as [name] in [dir], split as the old editor split it. */
+fun editorForm(entry: PasswordEntry, dir: String, name: String): EditorForm =
+ EditorForm(
+ name = name,
+ dir = dir,
+ password = entry.password.orEmpty(),
+ username = entry.username.orEmpty(),
+ extra = entry.extraContentWithoutUsername,
+ )
+
+/** The pass file, as the old editor wrote it: password, `username: ...` when set, the extras. */
+fun passContent(form: EditorForm): String {
+ val username = if (form.username.isEmpty()) "" else "\nusername: ${form.username}"
+ return "${form.password}$username\n${form.extra}"
+}
+
+/** Why [form] cannot be saved, as a string resource, or null when it can. */
+@StringRes
+fun formError(form: EditorForm): Int? =
+ when {
+ form.name.isBlank() -> R.string.file_toast_text
+ '/' in form.name -> R.string.invalid_filename_text
+ form.password.isEmpty() && form.extra.isEmpty() -> R.string.empty_toast_text
+ else -> null
+ }
+
+/** Where [form] is saved in the store at [root]. */
+fun entryFile(root: File, form: EditorForm): File =
+ File(File(root, form.dir), "${form.name.trim()}.gpg")
+
+/** True when saving to [target] would replace a file other than the [original] being edited. */
+fun overwrites(target: File, original: File?): Boolean =
+ target.exists() && target.canonicalPath != original?.canonicalPath
+
+/**
+ * Writes [bytes] to [target], creating its folder, through a temporary file moved over it, so a
+ * failed write never truncates an existing entry.
+ */
+fun writeEntry(target: File, bytes: ByteArray) {
+ val dir = target.parentFile ?: throw IOException("No folder for $target")
+ dir.mkdirs()
+ val temp = File(dir, ".${target.name}.tmp")
+ try {
+ temp.writeBytes(bytes)
+ Files.move(
+ temp.toPath(),
+ target.toPath(),
+ StandardCopyOption.REPLACE_EXISTING,
+ StandardCopyOption.ATOMIC_MOVE,
+ )
+ } finally {
+ temp.delete()
+ }
+}
+
+/** [extra] with [line] added on a line of its own. */
+fun withLine(extra: String, line: String): String =
+ if (extra.isEmpty() || extra.endsWith('\n')) "$extra$line" else "$extra\n$line"
+
+/**
+ * An `otpauth://totp` URI, as the old manual OTP dialog built it, for a typed [secret] (spaces
+ * dropped: authenticators show it in groups) and an optional [issuer]; null for a blank secret.
+ */
+fun totpUri(secret: String, issuer: String): String? {
+ val key = secret.filterNot(Char::isWhitespace)
+ if (key.isEmpty()) return null
+ fun encode(s: String) = URLEncoder.encode(s, "UTF-8").replace("+", "%20")
+ val tail = if (issuer.isBlank()) "" else "&issuer=${encode(issuer.trim())}"
+ return "otpauth://totp?secret=${encode(key)}$tail"
+}
+
+/** The random generator's options, as its checkboxes show them; defaults are the old dialog's. */
+@Immutable
+data class RandomSpec(
+ val numerals: Boolean = true,
+ val symbols: Boolean = false,
+ val uppercase: Boolean = true,
+ val lowercase: Boolean = true,
+ val ambiguous: Boolean = true,
+ val pronounceable: Boolean = false,
+ val length: Int = 20,
+)
+
+/** The generator's options for this spec, negated where the generator asks what to leave out. */
+fun RandomSpec.options(): List<PasswordOption> =
+ listOfNotNull(
+ PasswordOption.NoDigits.takeUnless { numerals },
+ PasswordOption.AtLeastOneSymbol.takeIf { symbols },
+ PasswordOption.NoUppercaseLetters.takeUnless { uppercase },
+ PasswordOption.NoLowercaseLetters.takeUnless { lowercase },
+ PasswordOption.NoAmbiguousCharacters.takeUnless { ambiguous },
+ PasswordOption.FullyRandom.takeUnless { pronounceable },
+ )
+
+/** A random password for [spec], or the string resource saying why there is none. */
+fun randomPassword(spec: RandomSpec): Result<String, Int> =
+ runCatching { PasswordGenerator.generate(spec.options(), spec.length) }
+ .mapError { e ->
+ when (e) {
+ is MaxIterationsExceededException -> R.string.pwgen_max_iterations_exceeded
+ is NoCharactersIncludedException -> R.string.pwgen_no_chars_error
+ is PasswordLengthTooShortException -> R.string.pwgen_length_too_short_error
+ else -> R.string.pwgen_some_error_occurred
+ }
+ }
+
+/** The diceware generator's options; defaults are the old dialog's. */
+@Immutable data class DicewareSpec(val words: Int = 5, val separator: Char = '-')
diff --git a/app/src/main/java/app/passwordstore/ui/list/ListModel.kt b/app/src/main/java/app/passwordstore/ui/list/ListModel.kt
index 9154baa5..96b7ab4b 100644
--- a/app/src/main/java/app/passwordstore/ui/list/ListModel.kt
+++ b/app/src/main/java/app/passwordstore/ui/list/ListModel.kt
@@ -49,6 +49,16 @@ private fun childCount(folder: File): Int =
folder.listFiles { f -> f.isDirectory || f.extension == "gpg" }?.size ?: 0
/**
+ * Folders directly inside [dir] that the folder picker offers, by name: never `.git`, other hidden
+ * folders only with "Show hidden contents" ([showHidden]), as the list filters them.
+ */
+fun subfolders(dir: File, showHidden: Boolean): List<File> =
+ dir
+ .listFiles { f -> f.isDirectory && !f.name.startsWith(".git") && (showHidden || !f.isHidden) }
+ .orEmpty()
+ .sortedBy { it.name.lowercase() }
+
+/**
* 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.
diff --git a/app/src/test/java/app/passwordstore/ui/editor/EditorModelTest.kt b/app/src/test/java/app/passwordstore/ui/editor/EditorModelTest.kt
new file mode 100644
index 00000000..81174d05
--- /dev/null
+++ b/app/src/test/java/app/passwordstore/ui/editor/EditorModelTest.kt
@@ -0,0 +1,152 @@
+/*
+ * Copyright (C) 2026 Danilo M. <danix@danix.xyz>
+ * SPDX-License-Identifier: GPL-3.0-only
+ */
+
+package app.passwordstore.ui.editor
+
+import app.passwordstore.R
+import app.passwordstore.data.passfile.PasswordEntry
+import app.passwordstore.passgen.random.PasswordOption
+import app.passwordstore.util.time.UserClock
+import app.passwordstore.util.totp.UriTotpFinder
+import java.io.File
+import java.nio.file.Files
+import kotlin.test.AfterTest
+import kotlin.test.Test
+import kotlin.test.assertContentEquals
+import kotlin.test.assertEquals
+import kotlin.test.assertFalse
+import kotlin.test.assertNull
+import kotlin.test.assertTrue
+
+class EditorModelTest {
+
+ private val root = Files.createTempDirectory("aps").toFile()
+
+ @AfterTest
+ fun cleanUp() {
+ root.deleteRecursively()
+ }
+
+ private fun entry(content: String) =
+ PasswordEntry(UserClock(), UriTotpFinder(), content.encodeToByteArray())
+
+ @Test
+ fun editingWithoutChangesKeepsThePassFile() {
+ val files =
+ listOf(
+ "hunter2\nusername: alice\nurl: https://example.org\n",
+ "s3cret\notpauth://totp/example.com:carol?secret=JBSWY3DPEHPK3PXP&issuer=example.com\n",
+ )
+ for (content in files) assertEquals(content, passContent(editorForm(entry(content), "", "x")))
+ }
+
+ @Test
+ fun formFromAnEntrySplitsItsFields() {
+ val form =
+ editorForm(entry("hunter2\nusername: alice\nurl: https://example.org\n"), "work", "mail")
+ assertEquals(EditorForm("mail", "work", "hunter2", "alice", "url: https://example.org\n"), form)
+ }
+
+ @Test
+ fun newEntryContent() {
+ assertEquals(
+ "pw\nusername: bob\nnote: x",
+ passContent(EditorForm(name = "n", password = "pw", username = "bob", extra = "note: x")),
+ )
+ assertEquals("pw\n", passContent(EditorForm(name = "n", password = "pw")))
+ }
+
+ @Test
+ fun formErrors() {
+ assertEquals(R.string.file_toast_text, formError(EditorForm(name = " ", password = "pw")))
+ assertEquals(
+ R.string.invalid_filename_text,
+ formError(EditorForm(name = "a/b", password = "pw")),
+ )
+ assertEquals(R.string.empty_toast_text, formError(EditorForm(name = "a", username = "bob")))
+ assertNull(formError(EditorForm(name = "a", extra = "note")))
+ assertNull(formError(EditorForm(name = "a", password = "pw")))
+ }
+
+ @Test
+ fun entryFileTrimsTheNameAndUsesTheFolder() {
+ assertEquals(
+ File(root, "work/mail.gpg"),
+ entryFile(root, EditorForm(name = " mail ", dir = "work")),
+ )
+ assertEquals(File(root, "mail.gpg"), entryFile(root, EditorForm(name = "mail")))
+ }
+
+ @Test
+ fun overwritesOnlyAnotherFile() {
+ val taken = File(root, "a.gpg").apply { writeText("x") }
+ assertFalse(overwrites(File(root, "work/b.gpg"), null))
+ assertTrue(overwrites(taken, null))
+ assertFalse(overwrites(taken, taken))
+ assertTrue(overwrites(taken, File(root, "work/b.gpg")))
+ }
+
+ @Test
+ fun writeEntryCreatesTheFolderAndReplacesTheFile() {
+ val target = File(root, "new/dir/a.gpg")
+ writeEntry(target, byteArrayOf(1))
+ writeEntry(target, byteArrayOf(2, 3))
+ assertContentEquals(byteArrayOf(2, 3), target.readBytes())
+ assertEquals(listOf("a.gpg"), target.parentFile!!.list()!!.toList())
+ }
+
+ @Test
+ fun linesGoOnTheirOwnLine() {
+ assertEquals("otpauth://x", withLine("", "otpauth://x"))
+ assertEquals("a\notpauth://x", withLine("a", "otpauth://x"))
+ assertEquals("a\notpauth://x", withLine("a\n", "otpauth://x"))
+ }
+
+ @Test
+ fun manualTotpUriIsReadBackByTheParser() {
+ val uri = totpUri(" JBSW Y3DP EHPK 3PXP ", "Example Co")!!
+ assertEquals("JBSWY3DPEHPK3PXP", UriTotpFinder().findSecret(uri))
+ assertEquals("Example Co", UriTotpFinder().findIssuer(uri))
+ assertTrue(entry("PLACEHOLDER\n$uri").hasTotp())
+ assertEquals("otpauth://totp?secret=JBSWY3DPEHPK3PXP", totpUri("JBSWY3DPEHPK3PXP", " "))
+ assertNull(totpUri(" ", "Example Co"))
+ }
+
+ @Test
+ fun randomOptionsFollowTheCheckboxes() {
+ assertEquals(listOf(PasswordOption.FullyRandom), RandomSpec().options())
+ assertEquals(
+ listOf(
+ PasswordOption.NoDigits,
+ PasswordOption.AtLeastOneSymbol,
+ PasswordOption.NoUppercaseLetters,
+ PasswordOption.NoLowercaseLetters,
+ PasswordOption.NoAmbiguousCharacters,
+ ),
+ RandomSpec(
+ numerals = false,
+ symbols = true,
+ uppercase = false,
+ lowercase = false,
+ ambiguous = false,
+ pronounceable = true,
+ )
+ .options(),
+ )
+ }
+
+ @Test
+ fun randomPasswordOrWhyNot() {
+ assertEquals(20, randomPassword(RandomSpec()).value.length)
+ assertEquals(
+ R.string.pwgen_no_chars_error,
+ randomPassword(RandomSpec(numerals = false, uppercase = false, lowercase = false)).error,
+ )
+ assertEquals(
+ R.string.pwgen_length_too_short_error,
+ randomPassword(RandomSpec(length = 2)).error,
+ )
+ }
+}
diff --git a/app/src/test/java/app/passwordstore/ui/list/ListModelTest.kt b/app/src/test/java/app/passwordstore/ui/list/ListModelTest.kt
index 60e3020a..cf12deef 100644
--- a/app/src/test/java/app/passwordstore/ui/list/ListModelTest.kt
+++ b/app/src/test/java/app/passwordstore/ui/list/ListModelTest.kt
@@ -90,4 +90,16 @@ class ListModelTest {
assertEquals(listOf(z.absolutePath, y.absolutePath), rows.folders.map { it.path })
assertEquals(listOf(b.absolutePath, a.absolutePath), rows.entries.map { it.path })
}
+
+ @Test
+ fun pickerOffersFoldersByName() {
+ folder("b")
+ folder("A")
+ folder(".hidden")
+ folder(".git")
+ file("c.gpg")
+ assertEquals(listOf("A", "b"), subfolders(root, showHidden = false).map { it.name })
+ assertEquals(listOf(".hidden", "A", "b"), subfolders(root, showHidden = true).map { it.name })
+ assertEquals(emptyList<File>(), subfolders(File(root, "missing"), showHidden = false))
+ }
}