From 3d8373dfff5101e58281b3b8f2e3b0ae2212678d Mon Sep 17 00:00:00 2001 From: "Danilo M." Date: Tue, 3 Nov 2026 13:47:09 +0100 Subject: feat(app): add the setup ViewModels Git server (URL, mode, clone state, the after-clone key check), SSH key (import and generation off the main thread, outcome kept across recreation) and PGP keys (rows with key IDs, import with the replace question, delete by key ID, creation of a local store). Co-Authored-By: Claude Opus 5.5 --- .../passwordstore/ui/setup/GitServerViewModel.kt | 127 ++++++++++++ .../app/passwordstore/ui/setup/PgpKeysViewModel.kt | 225 +++++++++++++++++++++ .../app/passwordstore/ui/setup/SshKeyViewModel.kt | 141 +++++++++++++ 3 files changed, 493 insertions(+) create mode 100644 app/src/main/java/app/passwordstore/ui/setup/GitServerViewModel.kt create mode 100644 app/src/main/java/app/passwordstore/ui/setup/PgpKeysViewModel.kt create mode 100644 app/src/main/java/app/passwordstore/ui/setup/SshKeyViewModel.kt (limited to 'app/src/main/java') diff --git a/app/src/main/java/app/passwordstore/ui/setup/GitServerViewModel.kt b/app/src/main/java/app/passwordstore/ui/setup/GitServerViewModel.kt new file mode 100644 index 00000000..f2b8ca30 --- /dev/null +++ b/app/src/main/java/app/passwordstore/ui/setup/GitServerViewModel.kt @@ -0,0 +1,127 @@ +/* + * Copyright (C) 2026 Danilo M. + * SPDX-License-Identifier: GPL-3.0-only + */ + +package app.passwordstore.ui.setup + +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import app.passwordstore.R +import app.passwordstore.crypto.KeyUtils +import app.passwordstore.crypto.PGPKeyManager +import app.passwordstore.ui.list.deleteWithoutFollowingLinks +import app.passwordstore.util.coroutines.DispatcherProvider +import app.passwordstore.util.settings.GitSettings +import com.github.michaelbull.result.get +import dagger.hilt.android.lifecycle.HiltViewModel +import java.io.File +import javax.inject.Inject +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +/** + * The git server screen's state: URL and mode being edited, the clone's [progress] and outcome + * ([cloned]), the host key and SSH key state. The clone itself runs in the destination (it needs + * the activity); this ViewModel keeps what must survive a recreation. + */ +@HiltViewModel +class GitServerViewModel +@Inject +constructor( + private val gitSettings: GitSettings, + private val keyManager: PGPKeyManager, + private val dispatcherProvider: DispatcherProvider, +) : ViewModel() { + + var url by mutableStateOf(gitSettings.url.orEmpty()) + private set + + var authMode by mutableStateOf(fitAuthMode(gitSettings.url.orEmpty(), gitSettings.authMode)) + + /** What a running clone does, as a string resource; null when idle. */ + var progress by mutableStateOf(null) + + /** Null until a clone succeeded; then whether the store still needs a PGP key imported. */ + var cloned by mutableStateOf(null) + private set + + var hasHostKey by mutableStateOf(false) + private set + + var sshKey by mutableIntStateOf(R.string.ssh_key_state_none) + private set + + init { + refresh() + } + + /** Re-reads what other screens change: the saved host key and the SSH key. */ + fun refresh() { + hasHostKey = gitSettings.hasSavedHostKey() + sshKey = sshKeySummary() + } + + fun changeUrl(value: String) { + url = value + if (value.isNotEmpty()) authMode = fitAuthMode(value.trim(), authMode) + } + + fun clearHostKey() { + gitSettings.clearSavedHostKey() + hasHostKey = false + } + + /** Validates and stores URL and mode (unchanged rules in [GitSettings]). */ + fun save(): GitSettings.UpdateConnectionSettingsResult = + gitSettings.updateConnectionSettingsIfValid(newAuthMode = authMode, newUrl = url.trim()) + + /** + * Empties the store folder at [root] for a clone, unless it is empty already. Never follows a + * symlink: `deleteRecursively()` would, and a `store/link -> ../keys` link would take the PGP + * keys with it. + */ + suspend fun clearStore(root: File) { + if (withContext(dispatcherProvider.io()) { cloneTarget(root) } == CloneTarget.Empty) return + progress = R.string.delete_directory_progress_text + withContext(dispatcherProvider.io()) { root.deleteWithoutFollowingLinks() } + progress = R.string.git_clone_progress + } + + /** Set while [finishClone]'s key check runs: the clone's [progress] stays up until it is done. */ + private var checking = false + + /** + * After a successful clone into [root], checks in [viewModelScope] whether its key is missing and + * records the answer in [cloned]: no recipient of the root `.gpg-id` is an imported secret key + * (without recipients: no secret key is imported at all). A public key alone cannot decrypt. In + * [viewModelScope], so a recreation during the check cannot lose the answer. + */ + fun finishClone(root: File) { + checking = true + viewModelScope.launch { + try { + cloned = + withContext(dispatcherProvider.io()) { + val ids = readGpgId(root)?.let(::storeRecipients).orEmpty() + if (ids.isEmpty()) keyManager.getAllKeys().get().orEmpty().none(KeyUtils::isSecretKey) + else + ids.none { id -> keyManager.getKeyById(id).get()?.let(KeyUtils::isSecretKey) == true } + } + } finally { + checking = false + progress = null + } + } + } + + /** The clone's coroutine ended (done, failed or cancelled): free the screen, re-read state. */ + fun cloneEnded() { + if (!checking) progress = null + refresh() + } +} diff --git a/app/src/main/java/app/passwordstore/ui/setup/PgpKeysViewModel.kt b/app/src/main/java/app/passwordstore/ui/setup/PgpKeysViewModel.kt new file mode 100644 index 00000000..e6000206 --- /dev/null +++ b/app/src/main/java/app/passwordstore/ui/setup/PgpKeysViewModel.kt @@ -0,0 +1,225 @@ +/* + * Copyright (C) 2026 Danilo M. + * SPDX-License-Identifier: GPL-3.0-only + */ + +package app.passwordstore.ui.setup + +import android.content.Context +import android.net.Uri +import androidx.compose.runtime.Immutable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import app.passwordstore.crypto.KeyUtils +import app.passwordstore.crypto.PGPIdentifier +import app.passwordstore.crypto.PGPKey +import app.passwordstore.crypto.PGPKeyManager +import app.passwordstore.crypto.errors.KeyAlreadyExistsException +import app.passwordstore.data.repo.PasswordRepository +import app.passwordstore.util.coroutines.DispatcherProvider +import com.github.michaelbull.result.onSuccess +import dagger.hilt.android.lifecycle.HiltViewModel +import java.io.File +import java.io.IOException +import javax.inject.Inject +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.toPersistentList +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import logcat.LogPriority.ERROR +import logcat.asLog +import logcat.logcat +import org.eclipse.jgit.api.errors.GitAPIException +import org.eclipse.jgit.api.errors.JGitInternalException + +/** One imported key: its first user ID, when it has one, and its long key ID. */ +@Immutable data class KeyRow(val userId: String?, val keyId: PGPIdentifier.KeyId) + +/** The outcome of a PGP key import, shown until dismissed. */ +sealed interface KeyImport { + data class Imported(val keyId: String) : KeyImport + + /** A key with the same ID is imported already: the screen asks whether to replace it. */ + data object Exists : KeyImport + + data class Failed(val message: String?) : KeyImport +} + +/** + * The PGP keys screen's state and work: the imported [keys], import with the replace question, + * deletion by key ID, and the creation of a new local store for a chosen key. Holds no Context; + * calls take one and use the application context. + */ +@HiltViewModel +class PgpKeysViewModel +@Inject +constructor( + private val keyManager: PGPKeyManager, + private val dispatcherProvider: DispatcherProvider, +) : ViewModel() { + + var keys: ImmutableList by mutableStateOf(persistentListOf()) + private set + + /** Set while an import or a store creation runs: further taps wait. */ + var busy by mutableStateOf(false) + + var importResult by mutableStateOf(null) + private set + + /** Set once a new store is created and committed: the screen moves on. */ + var done by mutableStateOf(false) + private set + + /** The root `.gpg-id` recipients, for the `Need` screen; see [loadRecipients]. */ + var recipients: ImmutableList by mutableStateOf(persistentListOf()) + private set + + /** + * The picked file while the replace question is open. Memory only, zeroed as soon as it is not + * needed ([dropPending]): it may be a private key. + */ + private var pending: ByteArray? = null + + init { + updateKeySet() + } + + /** Reads the recipients of the store at [root] off the main thread. */ + fun loadRecipients(root: File) { + viewModelScope.launch { + recipients = + withContext(dispatcherProvider.io()) { + readGpgId(root)?.let(::storeRecipients).orEmpty().map(PGPIdentifier::toString) + } + .toPersistentList() + } + } + + private fun dropPending() { + pending?.fill(0) + pending = null + } + + fun updateKeySet() { + viewModelScope.launch { + keyManager.getAllKeys().onSuccess { all -> + keys = + withContext(dispatcherProvider.default()) { all.mapNotNull(::row) }.toPersistentList() + } + } + } + + private fun row(key: PGPKey): KeyRow? { + val id = KeyUtils.tryGetId(key) ?: return null + return KeyRow(KeyUtils.tryGetEmail(key)?.email, id) + } + + fun deleteKey(row: KeyRow) { + viewModelScope.launch { + keyManager.removeKey(row.keyId) + updateKeySet() + } + } + + /** Reads the key file at [uri] and imports it. */ + fun import(uri: Uri, context: Context) { + if (busy) return + busy = true + val resolver = context.applicationContext.contentResolver + viewModelScope.launch { + try { + val bytes = + withContext(dispatcherProvider.io()) { + resolver.openInputStream(uri)?.use { it.readBytes() } + } + if (bytes == null) importResult = KeyImport.Failed(null) else add(bytes, replace = false) + } catch (e: IOException) { + importResult = KeyImport.Failed(e.message) + } catch (e: SecurityException) { + importResult = KeyImport.Failed(e.message) + } finally { + busy = false + } + } + } + + /** Imports the key the replace question was about, over the one with its ID. */ + fun replace() { + val bytes = pending ?: return + pending = null + importResult = null + busy = true + viewModelScope.launch { + try { + add(bytes, replace = true) + } finally { + busy = false + } + } + } + + fun dismissImport() { + dropPending() + importResult = null + } + + /** Imports [bytes]; they are zeroed afterwards unless the replace question keeps them. */ + private suspend fun add(bytes: ByteArray, replace: Boolean) { + val result = keyManager.addKey(PGPKey(bytes), replace) + if (!replace && result.isErr && result.error is KeyAlreadyExistsException) { + pending = bytes + importResult = KeyImport.Exists + return + } + // The ID is read before the bytes are zeroed: the returned key shares them. + importResult = + if (result.isOk) { + val id = withContext(dispatcherProvider.default()) { KeyUtils.tryGetId(result.value) } + KeyImport.Imported(id?.toString().orEmpty()) + } else KeyImport.Failed(result.error.message) + bytes.fill(0) + if (result.isOk) updateKeySet() + } + + /** + * Creates the local store at [root] for [row]: a git repository and a `.gpg-id` naming the key by + * its uppercase long key ID ([gpgIdText]). Returns null, or why it failed. + */ + suspend fun createStore(root: File, row: KeyRow): String? = + withContext(dispatcherProvider.io()) { + try { + check(root.isDirectory || root.mkdirs()) { "Could not create $root" } + PasswordRepository.createRepository(root) + File(root, ".gpg-id").writeText(gpgIdText(listOf(row.keyId))) + null + } catch (e: IOException) { + failed(root, e) + } catch (e: GitAPIException) { + failed(root, e) + } catch (e: JGitInternalException) { + failed(root, e) + } catch (e: IllegalStateException) { + failed(root, e) + } + } + + private fun failed(root: File, e: Exception): String { + logcat(ERROR) { e.asLog() } + // Only removes the folder while it is still empty, as the old onboarding did. + root.delete() + return e.message.orEmpty() + } + + fun markDone() { + done = true + } + + override fun onCleared() { + dropPending() + } +} diff --git a/app/src/main/java/app/passwordstore/ui/setup/SshKeyViewModel.kt b/app/src/main/java/app/passwordstore/ui/setup/SshKeyViewModel.kt new file mode 100644 index 00000000..d70b06af --- /dev/null +++ b/app/src/main/java/app/passwordstore/ui/setup/SshKeyViewModel.kt @@ -0,0 +1,141 @@ +/* + * Copyright (C) 2026 Danilo M. + * SPDX-License-Identifier: GPL-3.0-only + */ + +package app.passwordstore.ui.setup + +import android.net.Uri +import androidx.annotation.StringRes +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import app.passwordstore.R +import app.passwordstore.util.coroutines.DispatcherProvider +import app.passwordstore.util.git.sshj.SshKey +import dagger.hilt.android.lifecycle.HiltViewModel +import java.io.IOException +import java.security.GeneralSecurityException +import java.security.ProviderException +import javax.inject.Inject +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +/** The key types the generation screen offers, in the old screen's order. */ +enum class SshKeyType(@StringRes val label: Int, @StringRes val explanation: Int) { + Rsa(R.string.ssh_keygen_label_rsa, R.string.ssh_keygen_explanation_rsa), + Ecdsa(R.string.ssh_keygen_label_ecdsa, R.string.ssh_keygen_explanation_ecdsa), + Ed25519(R.string.ssh_keygen_label_ed25519, R.string.ssh_keygen_explanation_ed25519), +} + +/** The current SSH key's state, as a string resource. */ +@StringRes +fun sshKeySummary(): Int = + when { + !SshKey.exists -> R.string.ssh_key_state_none + SshKey.canShowSshPublicKey -> R.string.ssh_key_state_generated + else -> R.string.ssh_key_state_imported + } + +/** + * The SSH key screens' work: import a key file, generate a key. Both run in [viewModelScope] (no + * activity needed), so their outcome survives a recreation; the screens show it from [generated], + * [imported] and [failure]. [version] changes with the key, so screens re-read [SshKey]. + */ +@HiltViewModel +class SshKeyViewModel @Inject constructor(private val dispatcherProvider: DispatcherProvider) : + ViewModel() { + + var type by mutableStateOf(SshKeyType.Ecdsa) + + /** The "protect with screen lock" switch; only effective when the device is secure. */ + var protect by mutableStateOf(true) + + var busy by mutableStateOf(false) + private set + + var version by mutableIntStateOf(0) + private set + + /** Set after a successful import, until the screen showed its toast. */ + var imported by mutableStateOf(false) + private set + + /** The new public key after a generation. */ + var generated by mutableStateOf(null) + private set + + /** Why the last import or generation failed. */ + var failure by mutableStateOf(null) + private set + + /** + * Imports the private key at [uri]; [SshKey.import] reads it and keeps the old key on refusal. + */ + fun import(uri: Uri) { + if (busy) return + busy = true + viewModelScope.launch { + try { + withContext(dispatcherProvider.io()) { SshKey.import(uri) } + imported = true + version++ + } catch (e: IOException) { + failure = e.message.orEmpty() + } catch (e: IllegalArgumentException) { + failure = e.message.orEmpty() + } catch (e: SecurityException) { + failure = e.message.orEmpty() + } finally { + busy = false + } + } + } + + /** Generates a key of [type], replacing the current one; the prompt already ran if needed. */ + fun generate(requireAuthentication: Boolean) { + if (busy) return + busy = true + val type = type + viewModelScope.launch { + try { + generated = + withContext(dispatcherProvider.io()) { + when (type) { + SshKeyType.Rsa -> + SshKey.generateKeystoreNativeKey(SshKey.Algorithm.Rsa, requireAuthentication) + SshKeyType.Ecdsa -> + SshKey.generateKeystoreNativeKey(SshKey.Algorithm.Ecdsa, requireAuthentication) + SshKeyType.Ed25519 -> SshKey.generateKeystoreWrappedEd25519Key(requireAuthentication) + } + SshKey.sshPublicKey.orEmpty() + } + version++ + } catch (e: GeneralSecurityException) { + failure = e.message.orEmpty() + } catch (e: ProviderException) { + failure = e.message.orEmpty() + } catch (e: IOException) { + failure = e.message.orEmpty() + } finally { + busy = false + } + } + } + + /** Reports a failure found before generating (the screen lock prompt). */ + fun fail(message: String) { + failure = message + } + + fun dismissFailure() { + failure = null + } + + fun announced() { + imported = false + } +} -- cgit v1.2.3