diff options
| author | Danilo M. <danix@danix.xyz> | 2026-11-03 08:36:30 +0100 |
|---|---|---|
| committer | Danilo M. <danix@danix.xyz> | 2026-11-03 08:36:30 +0100 |
| commit | 8dad89ec9a37ab53a0de8983afafb0ef82d6d2d5 (patch) | |
| tree | ddb31c60c570a7a6353cbd8b104ae682b4431784 /app/src/main/java | |
| parent | ba977fd26b28caa1c49e57993516359fe1afaa27 (diff) | |
| download | HardenedPass-8dad89ec9a37ab53a0de8983afafb0ef82d6d2d5.tar.gz HardenedPass-8dad89ec9a37ab53a0de8983afafb0ef82d6d2d5.zip | |
feat(app): add the editor model and the picker's folder listing
Pure functions for the Compose editor: the form and the pass file it
writes, validation, the target file, the overwrite check, an atomic
write, OTP URI building and the random generator's options. The folder
picker lists subfolders with the list's hidden filter.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Diffstat (limited to 'app/src/main/java')
| -rw-r--r-- | app/src/main/java/app/passwordstore/ui/editor/EditorModel.kt | 146 | ||||
| -rw-r--r-- | app/src/main/java/app/passwordstore/ui/list/ListModel.kt | 10 |
2 files changed, 156 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. |
