aboutsummaryrefslogtreecommitdiffstats
path: root/app/src
diff options
context:
space:
mode:
Diffstat (limited to 'app/src')
-rw-r--r--app/src/main/java/app/passwordstore/ui/setup/GitServerViewModel.kt127
-rw-r--r--app/src/main/java/app/passwordstore/ui/setup/PgpKeysViewModel.kt225
-rw-r--r--app/src/main/java/app/passwordstore/ui/setup/SshKeyViewModel.kt141
-rw-r--r--app/src/main/res/values-it/strings.xml6
-rw-r--r--app/src/main/res/values/strings.xml6
5 files changed, 505 insertions, 0 deletions
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. <danix@danix.xyz>
+ * 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<Int?>(null)
+
+ /** Null until a clone succeeded; then whether the store still needs a PGP key imported. */
+ var cloned by mutableStateOf<Boolean?>(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. <danix@danix.xyz>
+ * 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<KeyRow> 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<KeyImport?>(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<String> 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. <danix@danix.xyz>
+ * 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<String?>(null)
+ private set
+
+ /** Why the last import or generation failed. */
+ var failure by mutableStateOf<String?>(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
+ }
+}
diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml
index c14efa33..c2edc768 100644
--- a/app/src/main/res/values-it/strings.xml
+++ b/app/src/main/res/values-it/strings.xml
@@ -366,4 +366,10 @@
<string name="editor_otp_manual">OTP manuale</string>
<string name="generator_random">Casuale</string>
<string name="generator_use">Usa</string>
+
+ <!-- Compose setup -->
+ <string name="ssh_key_state_none">Ancora nessuna chiave SSH</string>
+ <string name="ssh_key_state_imported">Chiave privata importata</string>
+ <string name="ssh_key_state_generated">Generata su questo dispositivo</string>
+ <string name="git_clone_progress">Clonazione…</string>
</resources>
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index 239cb219..d53700a8 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -401,4 +401,10 @@
<string name="generator_random">Random</string>
<string name="generator_diceware" translatable="false">Diceware</string>
<string name="generator_use">Use</string>
+
+ <!-- Compose setup -->
+ <string name="ssh_key_state_none">No SSH key yet</string>
+ <string name="ssh_key_state_imported">Imported private key</string>
+ <string name="ssh_key_state_generated">Generated on this device</string>
+ <string name="git_clone_progress">Cloning…</string>
</resources>