diff options
| author | Danilo M. <danix@danix.xyz> | 2026-11-03 08:39:48 +0100 |
|---|---|---|
| committer | Danilo M. <danix@danix.xyz> | 2026-11-03 08:39:48 +0100 |
| commit | 5871d4e73245e179e22286580f4668da69c1484e (patch) | |
| tree | c1a79aff1f8e90bd825d4649422bf33295de6bd0 | |
| parent | addce8a80202b5fc263ec87fd02485d8377ec54e (diff) | |
| download | HardenedPass-5871d4e73245e179e22286580f4668da69c1484e.tar.gz HardenedPass-5871d4e73245e179e22286580f4668da69c1484e.zip | |
feat(app): add the editor ViewModel
Holds the form in memory only, generates passwords with the stored
generator options, decodes QR images and saves off the main thread:
nothing is written when the target would replace another entry or when
encryption fails, the file is written through a temporary file, and a
renamed or moved entry takes its autofill matches and recently-used time
along.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
3 files changed, 259 insertions, 8 deletions
diff --git a/app/src/main/java/app/passwordstore/injection/pwgen/DicewareModule.kt b/app/src/main/java/app/passwordstore/injection/pwgen/DicewareModule.kt index 35c744a2..8572e3b8 100644 --- a/app/src/main/java/app/passwordstore/injection/pwgen/DicewareModule.kt +++ b/app/src/main/java/app/passwordstore/injection/pwgen/DicewareModule.kt @@ -14,13 +14,14 @@ import dagger.Module import dagger.Provides import dagger.hilt.InstallIn import dagger.hilt.android.components.FragmentComponent +import dagger.hilt.android.components.ViewModelComponent import dagger.hilt.android.qualifiers.ApplicationContext import java.io.InputStream import java.security.SecureRandom import javax.inject.Qualifier @Module -@InstallIn(FragmentComponent::class) +@InstallIn(FragmentComponent::class, ViewModelComponent::class) object DicewareModule { @Provides diff --git a/app/src/main/java/app/passwordstore/ui/editor/EditorViewModel.kt b/app/src/main/java/app/passwordstore/ui/editor/EditorViewModel.kt new file mode 100644 index 00000000..b67b435d --- /dev/null +++ b/app/src/main/java/app/passwordstore/ui/editor/EditorViewModel.kt @@ -0,0 +1,246 @@ +/* + * Copyright (C) 2026 Danilo M. <danix@danix.xyz> + * SPDX-License-Identifier: GPL-3.0-only + */ + +package app.passwordstore.ui.editor + +import android.content.Context +import android.content.SharedPreferences +import android.graphics.Bitmap +import android.graphics.ImageDecoder +import android.net.Uri +import android.os.Build +import android.provider.MediaStore +import androidx.annotation.StringRes +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.core.content.edit +import androidx.lifecycle.ViewModel +import app.passwordstore.R +import app.passwordstore.crypto.PGPIdentifier +import app.passwordstore.data.crypto.CryptoRepository +import app.passwordstore.data.passfile.PasswordEntry +import app.passwordstore.injection.prefs.PasswordGeneratorPreferences +import app.passwordstore.passgen.diceware.DicewarePassphraseGenerator +import app.passwordstore.passgen.random.PasswordOption +import app.passwordstore.ui.list.moveHistory +import app.passwordstore.util.autofill.AutofillMatcher +import app.passwordstore.util.coroutines.DispatcherProvider +import app.passwordstore.util.extensions.getString +import app.passwordstore.util.settings.PreferenceKeys +import com.github.michaelbull.result.Result +import com.github.michaelbull.result.get +import com.github.michaelbull.result.runCatching +import com.google.zxing.BinaryBitmap +import com.google.zxing.RGBLuminanceSource +import com.google.zxing.common.HybridBinarizer +import com.google.zxing.qrcode.QRCodeReader +import dagger.hilt.android.lifecycle.HiltViewModel +import java.io.ByteArrayOutputStream +import java.io.File +import java.io.IOException +import javax.inject.Inject +import kotlinx.coroutines.withContext +import logcat.LogPriority.ERROR +import logcat.asLog +import logcat.logcat + +/** What a save did. */ +sealed interface SaveResult { + + /** + * Written to [file] from [form]. [staleOriginal] is the old file of a rename or move, left behind + * because it could not be deleted. + */ + data class Saved(val file: File, val form: EditorForm, val staleOriginal: File?) : SaveResult + + /** Nothing was written; [message] (a string resource, formatted with [arg]) says why. */ + data class Failed(@StringRes val message: Int, val arg: String = "") : SaveResult +} + +/** + * The editor's state and work: the [form] (plaintext, in memory only: never in saved state, reset + * when this ViewModel is cleared), the generators and their stored options, QR decoding, and the + * save (encrypt, write, replace the original). Holds no Context; calls take one and use the + * application context. + */ +@HiltViewModel +class EditorViewModel +@Inject +constructor( + private val repository: CryptoRepository, + private val entryFactory: PasswordEntry.Factory, + private val diceware: dagger.Lazy<DicewarePassphraseGenerator>, + @PasswordGeneratorPreferences private val pwgenPrefs: SharedPreferences, + private val dispatcherProvider: DispatcherProvider, +) : ViewModel() { + + /** The fields being edited. */ + var form by mutableStateOf(EditorForm()) + + /** + * Set while a save runs, and after one succeeded (the editor is leaving): saves and closes wait. + */ + var saving by mutableStateOf(false) + private set + + /** Whether [start] filled the form. */ + var started = false + private set + + /** Fills the form the first time only and returns true then; a recreated activity keeps edits. */ + fun start(initial: EditorForm): Boolean { + if (started) return false + started = true + form = initial + return true + } + + /** + * True when [extra] already holds a TOTP secret (the OTP actions hide), as the old editor did. + */ + fun hasOtp(extra: String): Boolean = + entryFactory.create("PLACEHOLDER\n$extra".encodeToByteArray()).hasTotp() + + /** The random generator's options as the old dialog stored them. */ + fun randomSpec(context: Context): RandomSpec { + val prefs = context.randomPrefs() + return RandomSpec( + numerals = !prefs.getBoolean(PasswordOption.NoDigits.key, false), + symbols = prefs.getBoolean(PasswordOption.AtLeastOneSymbol.key, false), + uppercase = !prefs.getBoolean(PasswordOption.NoUppercaseLetters.key, false), + lowercase = !prefs.getBoolean(PasswordOption.NoLowercaseLetters.key, false), + ambiguous = !prefs.getBoolean(PasswordOption.NoAmbiguousCharacters.key, false), + pronounceable = !prefs.getBoolean(PasswordOption.FullyRandom.key, true), + length = prefs.getInt(PreferenceKeys.LENGTH, 20), + ) + } + + /** + * Stores [spec], as the old dialog did on every generation, then generates off the main thread. + */ + suspend fun generateRandom(spec: RandomSpec, context: Context): Result<String, Int> { + val options = spec.options() + context.randomPrefs().edit { + PasswordOption.entries.forEach { putBoolean(it.key, it in options) } + putInt(PreferenceKeys.LENGTH, spec.length) + } + return withContext(dispatcherProvider.default()) { randomPassword(spec) } + } + + /** The diceware generator's options as the old dialog stored them. */ + fun dicewareSpec(): DicewareSpec = + DicewareSpec( + words = pwgenPrefs.getInt(PreferenceKeys.DICEWARE_LENGTH, 5), + separator = pwgenPrefs.getString(PreferenceKeys.DICEWARE_SEPARATOR)?.firstOrNull() ?: '-', + ) + + /** Stores [spec] and generates a passphrase; the word list is read on first use, off main. */ + suspend fun generateDiceware(spec: DicewareSpec): String { + pwgenPrefs.edit { + putInt(PreferenceKeys.DICEWARE_LENGTH, spec.words) + putString(PreferenceKeys.DICEWARE_SEPARATOR, spec.separator.toString()) + } + return withContext(dispatcherProvider.io()) { + diceware.get().generatePassphrase(spec.words, spec.separator) + } + } + + /** The text of the QR code in the image at [uri], or null when it has none. Off main. */ + suspend fun readQr(uri: Uri, context: Context): String? { + val resolver = context.applicationContext.contentResolver + return withContext(dispatcherProvider.io()) { + runCatching { + val bitmap = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { + ImageDecoder.decodeBitmap(ImageDecoder.createSource(resolver, uri)) + .copy(Bitmap.Config.ARGB_8888, false) + } else { + @Suppress("DEPRECATION") MediaStore.Images.Media.getBitmap(resolver, uri) + } + val pixels = IntArray(bitmap.width * bitmap.height) + bitmap.getPixels(pixels, 0, bitmap.width, 0, 0, bitmap.width, bitmap.height) + val source = RGBLuminanceSource(bitmap.width, bitmap.height, pixels) + QRCodeReader().decode(BinaryBitmap(HybridBinarizer(source))).text + } + .get() + } + } + + /** + * Encrypts the form to [ids] and writes it to [target], replacing [original] when the entry was + * renamed or moved. Nothing is written (not even the folder) when [target] would replace another + * entry or when encryption fails; a recipient whose key is missing fails it (see + * CryptoRepository.encrypt). The caller checked [formError] and that [target] is inside the + * store. Returns null when a save already ran or is running. + */ + suspend fun save( + ids: List<PGPIdentifier>, + target: File, + original: File?, + context: Context, + ): SaveResult? { + if (saving) return null + saving = true + var result: SaveResult? = null + try { + result = write(ids, target, original, context.applicationContext) + } finally { + // Stays set after a success (the editor is leaving); cleared on failure or cancellation. + if (result !is SaveResult.Saved) saving = false + } + return result + } + + override fun onCleared() { + form = EditorForm() + } + + private suspend fun write( + ids: List<PGPIdentifier>, + target: File, + original: File?, + app: Context, + ): SaveResult { + val form = form + if (withContext(dispatcherProvider.io()) { overwrites(target, original) }) + return SaveResult.Failed(R.string.password_creation_duplicate_error) + val out = ByteArrayOutputStream() + val encrypted = repository.encrypt(ids, passContent(form).byteInputStream(), out) + if (!encrypted.isOk) { + logcat(ERROR) { encrypted.error.asLog() } + return SaveResult.Failed( + R.string.password_creation_encrypt_fail_message, + encrypted.error.message.orEmpty(), + ) + } + return withContext(dispatcherProvider.io()) { + try { + writeEntry(target, out.toByteArray()) + } catch (e: IOException) { + logcat(ERROR) { e.asLog() } + return@withContext SaveResult.Failed(R.string.password_creation_file_write_fail_message) + } + var stale: File? = null + if (original != null && original.canonicalPath != target.canonicalPath) { + if (original.delete() || !original.exists()) { + AutofillMatcher.updateMatches(app, moveFromTo = mapOf(original to target)) + app.moveHistory(original, target) + } else { + stale = original + } + } + SaveResult.Saved(target, form, stale) + } + } + + private fun Context.randomPrefs(): SharedPreferences = + getSharedPreferences(RANDOM_PREFS, Context.MODE_PRIVATE) + + private companion object { + /** The old random generator dialog's preferences file. */ + const val RANDOM_PREFS = "PasswordGenerator" + } +} diff --git a/app/src/main/java/app/passwordstore/ui/list/StoreActions.kt b/app/src/main/java/app/passwordstore/ui/list/StoreActions.kt index 06646461..2e148401 100644 --- a/app/src/main/java/app/passwordstore/ui/list/StoreActions.kt +++ b/app/src/main/java/app/passwordstore/ui/list/StoreActions.kt @@ -123,16 +123,20 @@ suspend fun BaseGitActivity.moveItems(files: List<File>, target: File) { /** Renames the folder [from] to [to], keeps its recently-used time, then commits. */ suspend fun BaseGitActivity.renameFolder(from: File, to: File) { if (!moveFile(from, to)) return - val history = getSharedPreferences(HISTORY, Context.MODE_PRIVATE) - val time = history.getString(from.absolutePath.base64()) - if (time != null) - history.edit { - remove(from.absolutePath.base64()) - putString(to.absolutePath.base64(), time) - } + moveHistory(from, to) commitChange(getString(R.string.git_commit_move_text, from.name, to.name)) } +/** Carries the "recently used" time of [from] over to [to]. */ +fun Context.moveHistory(from: File, to: File) { + val history = getSharedPreferences(HISTORY, Context.MODE_PRIVATE) + val time = history.getString(from.absolutePath.base64()) ?: return + history.edit { + remove(from.absolutePath.base64()) + putString(to.absolutePath.base64(), time) + } +} + private suspend fun BaseGitActivity.moveFile(source: File, destination: File): Boolean { val moved = withContext(dispatcherProvider.io()) { |
