aboutsummaryrefslogtreecommitdiffstats
path: root/app/src/main/java
diff options
context:
space:
mode:
Diffstat (limited to 'app/src/main/java')
-rw-r--r--app/src/main/java/app/passwordstore/ui/dialogs/TextInputDialog.kt71
-rw-r--r--app/src/main/java/app/passwordstore/ui/git/config/GitConfigActivity.kt176
-rw-r--r--app/src/main/java/app/passwordstore/ui/git/log/GitLogActivity.kt49
-rw-r--r--app/src/main/java/app/passwordstore/ui/git/log/GitLogAdapter.kt65
-rw-r--r--app/src/main/java/app/passwordstore/ui/list/ListDestination.kt6
-rw-r--r--app/src/main/java/app/passwordstore/ui/main/LaunchActivity.kt13
-rw-r--r--app/src/main/java/app/passwordstore/ui/main/MainActivity.kt27
-rw-r--r--app/src/main/java/app/passwordstore/ui/proxy/ProxySelectorActivity.kt109
-rw-r--r--app/src/main/java/app/passwordstore/ui/settings/AutofillSettings.kt114
-rw-r--r--app/src/main/java/app/passwordstore/ui/settings/GeneralSettings.kt120
-rw-r--r--app/src/main/java/app/passwordstore/ui/settings/GitConfigScreen.kt328
-rw-r--r--app/src/main/java/app/passwordstore/ui/settings/GitConfigViewModel.kt82
-rw-r--r--app/src/main/java/app/passwordstore/ui/settings/GitLogScreen.kt118
-rw-r--r--app/src/main/java/app/passwordstore/ui/settings/MiscSettings.kt76
-rw-r--r--app/src/main/java/app/passwordstore/ui/settings/PGPSettings.kt136
-rw-r--r--app/src/main/java/app/passwordstore/ui/settings/PasswordSettings.kt48
-rw-r--r--app/src/main/java/app/passwordstore/ui/settings/PreferenceRows.kt217
-rw-r--r--app/src/main/java/app/passwordstore/ui/settings/ProxyScreen.kt197
-rw-r--r--app/src/main/java/app/passwordstore/ui/settings/ProxyViewModel.kt62
-rw-r--r--app/src/main/java/app/passwordstore/ui/settings/RepositorySettings.kt193
-rw-r--r--app/src/main/java/app/passwordstore/ui/settings/SettingsActivity.kt129
-rw-r--r--app/src/main/java/app/passwordstore/ui/settings/SettingsDestination.kt303
-rw-r--r--app/src/main/java/app/passwordstore/ui/settings/SettingsGraph.kt87
-rw-r--r--app/src/main/java/app/passwordstore/ui/settings/SettingsModel.kt79
-rw-r--r--app/src/main/java/app/passwordstore/ui/settings/SettingsProvider.kt15
-rw-r--r--app/src/main/java/app/passwordstore/ui/settings/SettingsScreen.kt349
-rw-r--r--app/src/main/java/app/passwordstore/ui/settings/SettingsViewModel.kt226
-rw-r--r--app/src/main/java/app/passwordstore/ui/setup/SetupGraph.kt5
-rw-r--r--app/src/main/java/app/passwordstore/util/auth/BiometricAuthenticator.kt5
-rw-r--r--app/src/main/java/app/passwordstore/util/git/GitLogModel.kt60
-rw-r--r--app/src/main/java/app/passwordstore/util/git/operation/BreakOutOfDetached.kt5
-rw-r--r--app/src/main/java/app/passwordstore/util/git/operation/GitOperation.kt10
-rw-r--r--app/src/main/java/app/passwordstore/util/proxy/ProxyUtils.kt11
-rw-r--r--app/src/main/java/app/passwordstore/util/settings/GitSettings.kt2
-rw-r--r--app/src/main/java/app/passwordstore/util/settings/Migrations.kt19
-rw-r--r--app/src/main/java/app/passwordstore/util/settings/PasswordSortOrder.kt17
-rw-r--r--app/src/main/java/app/passwordstore/util/viewmodel/SearchableRepositoryViewModel.kt7
37 files changed, 2121 insertions, 1415 deletions
diff --git a/app/src/main/java/app/passwordstore/ui/dialogs/TextInputDialog.kt b/app/src/main/java/app/passwordstore/ui/dialogs/TextInputDialog.kt
deleted file mode 100644
index 58c39968..00000000
--- a/app/src/main/java/app/passwordstore/ui/dialogs/TextInputDialog.kt
+++ /dev/null
@@ -1,71 +0,0 @@
-/*
- * Copyright © 2014-2024 The Android Password Store Authors. All Rights Reserved.
- * SPDX-License-Identifier: GPL-3.0-only
- */
-
-package app.passwordstore.ui.dialogs
-
-import android.app.Dialog
-import android.content.DialogInterface
-import android.os.Bundle
-import android.view.WindowManager
-import androidx.core.os.bundleOf
-import androidx.fragment.app.DialogFragment
-import androidx.fragment.app.setFragmentResult
-import app.passwordstore.databinding.DialogTextInputBinding
-import app.passwordstore.util.extensions.finish
-import app.passwordstore.util.extensions.unsafeLazy
-import com.google.android.material.dialog.MaterialAlertDialogBuilder
-
-/**
- * General purpose [DialogFragment] that can be used to accept a single text input. It provides a
- * configurable title and text input hint through the instantiation helper at
- * [TextInputDialog.newInstance]. Typical usage would look something like this:
- * ```kotlin
- * val dialog = TextInputDialog.newInstance(getString(R.string.dialog_title), getString(R.string.dialog_hint))
- * dialog.show(supportFragmentManager, "text_input_dialog")
- * dialog.setFragmentResultListener(TextInputDialog.REQUEST_KEY) { _, bundle ->
- * doSomething(bundle.getString(TextInputDialog.BUNDLE_KEY_TEXT))
- * }
- * ```
- */
-class TextInputDialog : DialogFragment() {
- private val binding by unsafeLazy { DialogTextInputBinding.inflate(layoutInflater) }
-
- override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
- val builder = MaterialAlertDialogBuilder(requireContext())
- builder.setView(binding.root)
- builder.setTitle(arguments?.getString(BUNDLE_KEY_TITLE))
- arguments?.getString(BUNDLE_KEY_HINT)?.let { hint ->
- binding.textInputLayout.isHintEnabled = true
- binding.textInputLayout.isHintAnimationEnabled = true
- binding.textInputLayout.hint = hint
- }
- builder.setPositiveButton(android.R.string.ok) { dialogInterface, _ ->
- setFragmentResult(REQUEST_KEY, bundleOf(BUNDLE_KEY_TEXT to binding.editText.text.toString()))
- dialogInterface.dismiss()
- }
- val dialog = builder.create()
- dialog.window?.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_STATE_ALWAYS_VISIBLE)
- return dialog
- }
-
- override fun onCancel(dialog: DialogInterface) {
- super.onCancel(dialog)
- finish()
- }
-
- companion object {
- const val REQUEST_KEY = "text_input_dialog"
- const val BUNDLE_KEY_TEXT = "text"
- private const val BUNDLE_KEY_TITLE = "title"
- private const val BUNDLE_KEY_HINT = "hint"
-
- fun newInstance(title: String, hint: String? = null): TextInputDialog {
- val args = bundleOf(BUNDLE_KEY_TITLE to title, BUNDLE_KEY_HINT to hint)
- val dialog = TextInputDialog()
- dialog.arguments = args
- return dialog
- }
- }
-}
diff --git a/app/src/main/java/app/passwordstore/ui/git/config/GitConfigActivity.kt b/app/src/main/java/app/passwordstore/ui/git/config/GitConfigActivity.kt
deleted file mode 100644
index 695029d4..00000000
--- a/app/src/main/java/app/passwordstore/ui/git/config/GitConfigActivity.kt
+++ /dev/null
@@ -1,176 +0,0 @@
-/*
- * Copyright © 2014-2024 The Android Password Store Authors. All Rights Reserved.
- * SPDX-License-Identifier: GPL-3.0-only
- */
-package app.passwordstore.ui.git.config
-
-import android.os.Bundle
-import android.os.Handler
-import android.os.Looper
-import android.util.Patterns
-import android.view.MenuItem
-import androidx.core.os.postDelayed
-import androidx.fragment.app.setFragmentResultListener
-import androidx.lifecycle.lifecycleScope
-import app.passwordstore.R
-import app.passwordstore.data.repo.PasswordRepository
-import app.passwordstore.databinding.ActivityGitConfigBinding
-import app.passwordstore.ui.dialogs.TextInputDialog
-import app.passwordstore.ui.git.base.BaseGitActivity
-import app.passwordstore.ui.git.log.GitLogActivity
-import app.passwordstore.util.extensions.asLog
-import app.passwordstore.util.extensions.launchActivity
-import app.passwordstore.util.extensions.viewBinding
-import com.github.michaelbull.result.fold
-import com.github.michaelbull.result.getOrElse
-import com.github.michaelbull.result.onFailure
-import com.github.michaelbull.result.runCatching
-import com.google.android.material.dialog.MaterialAlertDialogBuilder
-import com.google.android.material.snackbar.Snackbar
-import kotlinx.coroutines.launch
-import logcat.LogPriority.ERROR
-import logcat.logcat
-import org.eclipse.jgit.lib.Constants
-import org.eclipse.jgit.lib.Repository
-import org.eclipse.jgit.lib.RepositoryState
-
-class GitConfigActivity : BaseGitActivity() {
-
- private val binding by viewBinding(ActivityGitConfigBinding::inflate)
-
- override fun onCreate(savedInstanceState: Bundle?) {
- super.onCreate(savedInstanceState)
- setContentView(binding.root)
- supportActionBar?.setDisplayHomeAsUpEnabled(true)
-
- if (gitSettings.authorName.isEmpty()) binding.gitUserName.requestFocus()
- else binding.gitUserName.setText(gitSettings.authorName)
- binding.gitUserEmail.setText(gitSettings.authorEmail)
- setupTools()
- binding.saveButton.setOnClickListener {
- val email = binding.gitUserEmail.text.toString().trim()
- val name = binding.gitUserName.text.toString().trim()
- if (!email.matches(Patterns.EMAIL_ADDRESS.toRegex())) {
- MaterialAlertDialogBuilder(this)
- .setMessage(getString(R.string.invalid_email_dialog_text))
- .setPositiveButton(getString(R.string.dialog_ok), null)
- .show()
- } else {
- gitSettings.authorEmail = email
- gitSettings.authorName = name
- Snackbar.make(
- binding.root,
- getString(R.string.git_server_config_save_success),
- Snackbar.LENGTH_SHORT,
- )
- .show()
- Handler(Looper.getMainLooper()).postDelayed(500) { finish() }
- }
- }
- }
-
- override fun onOptionsItemSelected(item: MenuItem): Boolean {
- return when (item.itemId) {
- android.R.id.home -> {
- onBackPressedDispatcher.onBackPressed()
- true
- }
- else -> super.onOptionsItemSelected(item)
- }
- }
-
- /** Sets up the UI components of the tools section. */
- private fun setupTools() {
- val repo = PasswordRepository.repository
- if (repo != null) {
- binding.gitHeadStatus.text = headStatusMsg(repo)
- // enable the abort button only if we're rebasing or merging
- val needsAbort =
- repo.repositoryState.isRebasing || repo.repositoryState == RepositoryState.MERGING
- binding.gitAbortRebase.isEnabled = needsAbort
- binding.gitAbortRebase.alpha = if (needsAbort) 1.0f else 0.5f
- }
- binding.gitLog.setOnClickListener {
- runCatching { launchActivity(GitLogActivity::class.java) }
- .onFailure { ex -> logcat(ERROR) { ex.asLog("Failed to start GitLogActivity") } }
- }
- binding.gitAbortRebase.setOnClickListener { abortRebase() }
- binding.gitResetToRemote.setOnClickListener { resetToRemote() }
- binding.gitGc.setOnClickListener {
- lifecycleScope.launch {
- launchGitOperation(GitOp.GC)
- .fold(
- success = ::finishOnSuccessHandler,
- failure = { err -> promptOnErrorHandler(err) { finish() } },
- )
- }
- }
- }
-
- private fun resetToRemote() {
- val dialog =
- TextInputDialog.newInstance(getString(R.string.git_utils_reset_remote_branch_title))
- dialog.show(supportFragmentManager, "BRANCH_INPUT_DIALOG")
- dialog.setFragmentResultListener(TextInputDialog.REQUEST_KEY) { _, bundle ->
- val result = bundle.getString(TextInputDialog.BUNDLE_KEY_TEXT)
- if (!result.isNullOrEmpty()) {
- remoteBranch = result
- lifecycleScope.launch {
- launchGitOperation(GitOp.RESET)
- .fold(
- success = ::finishOnSuccessHandler,
- failure = { err -> promptOnErrorHandler(err) { finish() } },
- )
- }
- }
- }
- }
-
- private fun abortRebase() {
- lifecycleScope.launch {
- launchGitOperation(GitOp.BREAK_OUT_OF_DETACHED)
- .fold(
- success = {
- val branch = PasswordRepository.getCurrentBranch()
- MaterialAlertDialogBuilder(this@GitConfigActivity).run {
- setTitle(resources.getString(R.string.git_abort_and_push_title))
- setMessage(
- resources.getString(
- R.string.git_break_out_of_detached_success,
- branch,
- "conflicting-$branch-...",
- )
- )
- setOnDismissListener { finish() }
- setPositiveButton(resources.getString(R.string.dialog_ok)) { _, _ -> }
- show()
- }
- },
- failure = { err -> promptOnErrorHandler(err) { finish() } },
- )
- }
- }
-
- /**
- * Returns a user-friendly message about the current state of HEAD.
- *
- * The state is recognized to be either pointing to a branch or detached.
- */
- private fun headStatusMsg(repo: Repository): String {
- return runCatching {
- val headRef = repo.findRef(Constants.HEAD)
- if (headRef.isSymbolic) {
- val branchName = headRef.target.name
- val shortBranchName = Repository.shortenRefName(branchName)
- getString(R.string.git_head_on_branch, shortBranchName)
- } else {
- val commitHash = headRef.objectId.abbreviate(8).name()
- getString(R.string.git_head_detached, commitHash)
- }
- }
- .getOrElse { ex ->
- logcat(ERROR) { "Error getting HEAD reference\n${ex}" }
- getString(R.string.git_head_missing)
- }
- }
-}
diff --git a/app/src/main/java/app/passwordstore/ui/git/log/GitLogActivity.kt b/app/src/main/java/app/passwordstore/ui/git/log/GitLogActivity.kt
deleted file mode 100644
index a72881d1..00000000
--- a/app/src/main/java/app/passwordstore/ui/git/log/GitLogActivity.kt
+++ /dev/null
@@ -1,49 +0,0 @@
-/*
- * Copyright © 2014-2024 The Android Password Store Authors. All Rights Reserved.
- * SPDX-License-Identifier: GPL-3.0-only
- */
-
-package app.passwordstore.ui.git.log
-
-import android.os.Bundle
-import android.view.MenuItem
-import androidx.recyclerview.widget.DividerItemDecoration
-import androidx.recyclerview.widget.LinearLayoutManager
-import app.passwordstore.databinding.ActivityGitLogBinding
-import app.passwordstore.ui.git.base.BaseGitActivity
-import app.passwordstore.util.extensions.viewBinding
-
-/**
- * Displays the repository's git commits in git-log fashion.
- *
- * It provides basic information about each commit by way of a non-interactive RecyclerView.
- */
-class GitLogActivity : BaseGitActivity() {
-
- private val binding by viewBinding(ActivityGitLogBinding::inflate)
-
- override fun onCreate(savedInstanceState: Bundle?) {
- super.onCreate(savedInstanceState)
- setContentView(binding.root)
- supportActionBar?.setDisplayHomeAsUpEnabled(true)
- createRecyclerView()
- }
-
- override fun onOptionsItemSelected(item: MenuItem): Boolean {
- return when (item.itemId) {
- android.R.id.home -> {
- finish()
- true
- }
- else -> super.onOptionsItemSelected(item)
- }
- }
-
- private fun createRecyclerView() {
- binding.gitLogRecyclerView.apply {
- setHasFixedSize(true)
- addItemDecoration(DividerItemDecoration(context, LinearLayoutManager.VERTICAL))
- adapter = GitLogAdapter()
- }
- }
-}
diff --git a/app/src/main/java/app/passwordstore/ui/git/log/GitLogAdapter.kt b/app/src/main/java/app/passwordstore/ui/git/log/GitLogAdapter.kt
deleted file mode 100644
index 95186188..00000000
--- a/app/src/main/java/app/passwordstore/ui/git/log/GitLogAdapter.kt
+++ /dev/null
@@ -1,65 +0,0 @@
-/*
- * Copyright © 2014-2024 The Android Password Store Authors. All Rights Reserved.
- * SPDX-License-Identifier: GPL-3.0-only
- */
-
-package app.passwordstore.ui.git.log
-
-import android.view.LayoutInflater
-import android.view.ViewGroup
-import androidx.recyclerview.widget.RecyclerView
-import app.passwordstore.databinding.GitLogRowLayoutBinding
-import app.passwordstore.util.git.GitCommit
-import app.passwordstore.util.git.GitLogModel
-import java.time.Instant
-import java.time.ZoneId
-import java.time.format.DateTimeFormatter
-import java.time.format.FormatStyle
-import java.util.Locale
-import logcat.LogPriority.ERROR
-import logcat.logcat
-
-private fun shortHash(hash: String): String {
- return hash.substring(0 until 8)
-}
-
-private fun stringFrom(date: Instant): String {
- return DateTimeFormatter.ofLocalizedDateTime(FormatStyle.MEDIUM)
- .withLocale(Locale.getDefault())
- .withZone(ZoneId.systemDefault())
- .format(date)
-}
-
-/** @see GitLogActivity */
-class GitLogAdapter : RecyclerView.Adapter<GitLogAdapter.ViewHolder>() {
-
- private val model = GitLogModel()
-
- override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
- val inflater = LayoutInflater.from(parent.context)
- val binding = GitLogRowLayoutBinding.inflate(inflater, parent, false)
- return ViewHolder(binding)
- }
-
- override fun onBindViewHolder(viewHolder: ViewHolder, position: Int) {
- val commit = model.get(position)
- if (commit == null) {
- logcat(ERROR) { "There is no git commit for view holder at position $position." }
- return
- }
- viewHolder.bind(commit)
- }
-
- override fun getItemCount() = model.size
-
- class ViewHolder(private val binding: GitLogRowLayoutBinding) :
- RecyclerView.ViewHolder(binding.root) {
-
- fun bind(commit: GitCommit) =
- with(binding) {
- gitLogRowMessage.text = commit.shortMessage
- gitLogRowHash.text = shortHash(commit.hash)
- gitLogRowTime.text = stringFrom(commit.time)
- }
- }
-}
diff --git a/app/src/main/java/app/passwordstore/ui/list/ListDestination.kt b/app/src/main/java/app/passwordstore/ui/list/ListDestination.kt
index a256fb1b..adae3649 100644
--- a/app/src/main/java/app/passwordstore/ui/list/ListDestination.kt
+++ b/app/src/main/java/app/passwordstore/ui/list/ListDestination.kt
@@ -60,7 +60,8 @@ private enum class ListDialog {
* The list at [path] (relative to the store root): wires [ListScreen] to the [viewModel], the
* folder picker, the legacy activities and the store operations. [onOpenFolder] takes a path
* relative to the root, [onOpenEntry] an entry file; [onNewEntry] opens the editor in this folder,
- * [onClone] the git server screen for a clone; [onNoStore] runs when the store is gone.
+ * [onClone] the git server screen for a clone, [onSettings] the settings; [onNoStore] runs when the
+ * store is gone.
*/
@Composable
fun ListDestination(
@@ -70,6 +71,7 @@ fun ListDestination(
onOpenEntry: (File) -> Unit,
onNewEntry: () -> Unit,
onClone: () -> Unit,
+ onSettings: () -> Unit,
onNoStore: () -> Unit,
modifier: Modifier = Modifier,
viewModel: SearchableRepositoryViewModel = hiltViewModel(),
@@ -151,7 +153,7 @@ fun ListDestination(
}
},
onGitOp = { op -> ifInitialized { runGit(op) } },
- onSettings = activity::openSettings,
+ onSettings = onSettings,
onNew = { dialog = ListDialog.Create },
onMove = { dialog = ListDialog.Move },
onDelete = {
diff --git a/app/src/main/java/app/passwordstore/ui/main/LaunchActivity.kt b/app/src/main/java/app/passwordstore/ui/main/LaunchActivity.kt
index 66b5cf1f..6f07a51a 100644
--- a/app/src/main/java/app/passwordstore/ui/main/LaunchActivity.kt
+++ b/app/src/main/java/app/passwordstore/ui/main/LaunchActivity.kt
@@ -22,10 +22,21 @@ import java.io.File
@SuppressLint("CustomSplashScreen")
class LaunchActivity : AppCompatActivity() {
+ private var prompted = false
+
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
+ if (!sharedPrefs.getBoolean(PreferenceKeys.BIOMETRIC_AUTH_2, false)) startTargetActivity(true)
+ }
+
+ // The prompt waits for window focus: Android 14's SystemUI dismisses a prompt shown while its
+ // activity is not yet on top (shown from onCreate), with ERROR_CANCELED, closing the app at once.
+ override fun onWindowFocusChanged(hasFocus: Boolean) {
+ super.onWindowFocusChanged(hasFocus)
+ if (!hasFocus || prompted || isFinishing) return
val prefs = sharedPrefs
if (prefs.getBoolean(PreferenceKeys.BIOMETRIC_AUTH_2, false)) {
+ prompted = true
BiometricAuthenticator.authenticate(this) { result ->
when (result) {
is Result.Success -> {
@@ -43,8 +54,6 @@ class LaunchActivity : AppCompatActivity() {
is Result.Retry -> {}
}
}
- } else {
- startTargetActivity(true)
}
}
diff --git a/app/src/main/java/app/passwordstore/ui/main/MainActivity.kt b/app/src/main/java/app/passwordstore/ui/main/MainActivity.kt
index c8b1861a..75062f6b 100644
--- a/app/src/main/java/app/passwordstore/ui/main/MainActivity.kt
+++ b/app/src/main/java/app/passwordstore/ui/main/MainActivity.kt
@@ -30,16 +30,17 @@ import app.passwordstore.ui.entry.EntryViewModel
import app.passwordstore.ui.git.base.BaseGitActivity
import app.passwordstore.ui.list.ListDestination
import app.passwordstore.ui.list.folderMoves
-import app.passwordstore.ui.settings.SettingsActivity
+import app.passwordstore.ui.settings.SettingsRoute
+import app.passwordstore.ui.settings.settingsGraph
import app.passwordstore.ui.setup.GitServerRoute
import app.passwordstore.ui.setup.SetupStart
import app.passwordstore.ui.setup.WelcomeRoute
import app.passwordstore.ui.setup.setupGraph
-import app.passwordstore.util.extensions.launchActivity
import app.passwordstore.util.extensions.sharedPrefs
import app.passwordstore.util.settings.AuthMode
import app.passwordstore.util.settings.PreferenceKeys
import app.passwordstore.util.shortcuts.ShortcutHandler
+import com.github.michaelbull.result.Result
import com.github.michaelbull.result.onFailure
import dagger.hilt.android.AndroidEntryPoint
import java.io.File
@@ -126,6 +127,9 @@ class MainActivity : BaseGitActivity() {
if (nav.currentBackStackEntry?.id == entry.id)
nav.navigate(GitServerRoute(clone = true))
},
+ onSettings = {
+ if (nav.currentBackStackEntry?.id == entry.id) nav.navigate(SettingsRoute)
+ },
onNoStore = {
// The store is gone (deleted from Settings): setup starts over.
if (nav.currentBackStackEntry?.id == entry.id)
@@ -194,6 +198,7 @@ class MainActivity : BaseGitActivity() {
)
}
setupGraph(this@MainActivity, nav)
+ settingsGraph(this@MainActivity, nav)
}
}
}
@@ -228,6 +233,12 @@ class MainActivity : BaseGitActivity() {
launchGitOperation(op).onFailure { promptOnErrorHandler(it) }
}
+ /** Runs [op] (a hard reset against [branch]) and returns its outcome; the caller reports it. */
+ suspend fun gitOperation(op: GitOp, branch: String = ""): Result<Unit, Throwable> {
+ remoteBranch = branch
+ return launchGitOperation(op)
+ }
+
/** Adds [file] to the launcher's dynamic shortcuts, as opening it from the list always did. */
fun addShortcut(file: File) {
val item = entryItem(file)
@@ -239,10 +250,6 @@ class MainActivity : BaseGitActivity() {
shortcutHandler.addPinnedShortcut(item, item.createAuthEnabledIntent(this))
}
- fun openSettings() {
- launchActivity(SettingsActivity::class.java)
- }
-
private fun entryItem(file: File) =
PasswordItem.newPassword(file.name, file, PasswordRepository.getRepositoryDirectory())
@@ -251,13 +258,13 @@ class MainActivity : BaseGitActivity() {
private const val EXTRA_SETUP = "SETUP"
/**
- * Opens one setup screen in an instance of its own, for callers outside the graph (legacy
- * Settings, autofill, git operations) and for those waiting for a result (the entry view's
+ * Opens one setup screen in an instance of its own, for callers outside the graph (autofill,
+ * the git operations' "No SSH key" dialog) and for those waiting for a result (the entry view's
* missing key). Leaving that screen finishes the instance; the PGP keys screen sets RESULT_OK
* once a key was imported.
*/
- // ponytail: a second instance instead of in-graph navigation, until steps 6 and 7 move
- // Settings and autofill into the graph.
+ // ponytail: a second instance instead of in-graph navigation, until step 7 moves autofill
+ // into the graph and the git operations' dialogs become Compose dialogs.
fun setupIntent(context: Context, start: SetupStart): Intent =
Intent(context, MainActivity::class.java).putExtra(EXTRA_SETUP, start.name)
}
diff --git a/app/src/main/java/app/passwordstore/ui/proxy/ProxySelectorActivity.kt b/app/src/main/java/app/passwordstore/ui/proxy/ProxySelectorActivity.kt
deleted file mode 100644
index cb5fbf40..00000000
--- a/app/src/main/java/app/passwordstore/ui/proxy/ProxySelectorActivity.kt
+++ /dev/null
@@ -1,109 +0,0 @@
-/*
- * Copyright © 2014-2024 The Android Password Store Authors. All Rights Reserved.
- * SPDX-License-Identifier: GPL-3.0-only
- */
-
-package app.passwordstore.ui.proxy
-
-import android.content.SharedPreferences
-import android.net.InetAddresses
-import android.os.Build
-import android.os.Bundle
-import android.os.Handler
-import android.os.Looper
-import android.util.Patterns
-import android.view.MenuItem
-import androidx.appcompat.app.AppCompatActivity
-import androidx.core.content.edit
-import androidx.core.os.postDelayed
-import androidx.core.widget.doOnTextChanged
-import app.passwordstore.R
-import app.passwordstore.databinding.ActivityProxySelectorBinding
-import app.passwordstore.injection.prefs.ProxyPreferences
-import app.passwordstore.util.extensions.getString
-import app.passwordstore.util.extensions.viewBinding
-import app.passwordstore.util.proxy.ProxyUtils
-import app.passwordstore.util.settings.GitSettings
-import app.passwordstore.util.settings.PreferenceKeys
-import dagger.hilt.android.AndroidEntryPoint
-import javax.inject.Inject
-
-@AndroidEntryPoint
-class ProxySelectorActivity : AppCompatActivity() {
-
- @Inject lateinit var gitSettings: GitSettings
- @ProxyPreferences @Inject lateinit var proxyPrefs: SharedPreferences
- @Inject lateinit var proxyUtils: ProxyUtils
- private val binding by viewBinding(ActivityProxySelectorBinding::inflate)
-
- override fun onCreate(savedInstanceState: Bundle?) {
- super.onCreate(savedInstanceState)
- setContentView(binding.root)
- supportActionBar?.setDisplayHomeAsUpEnabled(true)
- with(binding) {
- proxyHost.setText(proxyPrefs.getString(PreferenceKeys.PROXY_HOST))
- proxyUser.setText(proxyPrefs.getString(PreferenceKeys.PROXY_USERNAME))
- proxyPrefs
- .getInt(PreferenceKeys.PROXY_PORT, -1)
- .takeIf { it != -1 }
- ?.let { proxyPort.setText("$it") }
- proxyPassword.setText(proxyPrefs.getString(PreferenceKeys.PROXY_PASSWORD))
- save.setOnClickListener { saveSettings() }
- proxyHost.doOnTextChanged { text, _, _, _ ->
- if (text != null) {
- proxyHost.error =
- if (isNumericAddress(text) || text.matches(WEB_ADDRESS_REGEX)) {
- null
- } else {
- getString(R.string.invalid_proxy_url)
- }
- }
- }
- }
- }
-
- override fun onOptionsItemSelected(item: MenuItem): Boolean {
- when (item.itemId) {
- android.R.id.home -> {
- onBackPressedDispatcher.onBackPressed()
- }
- else -> return super.onOptionsItemSelected(item)
- }
- return true
- }
-
- private fun isNumericAddress(text: CharSequence): Boolean {
- return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
- InetAddresses.isNumericAddress(text as String)
- } else {
- @Suppress("DEPRECATION") Patterns.IP_ADDRESS.matcher(text).matches()
- }
- }
-
- private fun saveSettings() {
- proxyPrefs.edit {
- binding.proxyHost.text
- ?.toString()
- ?.takeIf { it.isNotEmpty() }
- .let { gitSettings.proxyHost = it }
- binding.proxyUser.text
- ?.toString()
- ?.takeIf { it.isNotEmpty() }
- .let { gitSettings.proxyUsername = it }
- binding.proxyPort.text
- ?.toString()
- ?.takeIf { it.isNotEmpty() }
- ?.let { gitSettings.proxyPort = it.toInt() }
- binding.proxyPassword.text
- ?.toString()
- ?.takeIf { it.isNotEmpty() }
- .let { gitSettings.proxyPassword = it }
- }
- proxyUtils.setDefaultProxy()
- Handler(Looper.getMainLooper()).postDelayed(500) { finish() }
- }
-
- private companion object {
- private val WEB_ADDRESS_REGEX = Patterns.WEB_URL.toRegex()
- }
-}
diff --git a/app/src/main/java/app/passwordstore/ui/settings/AutofillSettings.kt b/app/src/main/java/app/passwordstore/ui/settings/AutofillSettings.kt
deleted file mode 100644
index afb2a691..00000000
--- a/app/src/main/java/app/passwordstore/ui/settings/AutofillSettings.kt
+++ /dev/null
@@ -1,114 +0,0 @@
-/*
- * Copyright © 2014-2024 The Android Password Store Authors. All Rights Reserved.
- * SPDX-License-Identifier: GPL-3.0-only
- */
-
-package app.passwordstore.ui.settings
-
-import android.annotation.SuppressLint
-import android.content.Intent
-import android.provider.Settings
-import androidx.appcompat.widget.AppCompatTextView
-import androidx.core.net.toUri
-import androidx.fragment.app.FragmentActivity
-import androidx.lifecycle.Lifecycle
-import androidx.lifecycle.LifecycleEventObserver
-import app.passwordstore.BuildConfig
-import app.passwordstore.R
-import app.passwordstore.util.extensions.autofillManager
-import app.passwordstore.util.settings.PreferenceKeys
-import com.github.androidpasswordstore.autofillparser.BrowserAutofillSupportLevel
-import com.github.androidpasswordstore.autofillparser.getInstalledBrowsersWithAutofillSupportLevel
-import com.google.android.material.dialog.MaterialAlertDialogBuilder
-import de.Maxr1998.modernpreferences.PreferenceScreen
-import de.Maxr1998.modernpreferences.helpers.editText
-import de.Maxr1998.modernpreferences.helpers.onClick
-import de.Maxr1998.modernpreferences.helpers.switch
-import de.Maxr1998.modernpreferences.preferences.SwitchPreference
-
-class AutofillSettings(private val activity: FragmentActivity) : SettingsProvider {
-
- private val isAutofillServiceEnabled: Boolean
- get() {
- return activity.autofillManager?.hasEnabledAutofillServices() == true
- }
-
- private fun showAutofillDialog(pref: SwitchPreference) {
- val observer = LifecycleEventObserver { _, event ->
- when (event) {
- Lifecycle.Event.ON_RESUME -> {
- pref.checked = isAutofillServiceEnabled
- }
- else -> {}
- }
- }
- MaterialAlertDialogBuilder(activity).run {
- setTitle(R.string.pref_autofill_enable_title)
- @SuppressLint("InflateParams")
- val layout = activity.layoutInflater.inflate(R.layout.oreo_autofill_instructions, null)
- val supportedBrowsersTextView = layout.findViewById<AppCompatTextView>(R.id.supportedBrowsers)
- supportedBrowsersTextView.text =
- getInstalledBrowsersWithAutofillSupportLevel(context).joinToString(separator = "\n") {
- val appLabel = it.first
- val supportDescription =
- when (it.second) {
- BrowserAutofillSupportLevel.None ->
- activity.getString(R.string.oreo_autofill_no_support)
- BrowserAutofillSupportLevel.FlakyFill ->
- activity.getString(R.string.oreo_autofill_flaky_fill_support)
- BrowserAutofillSupportLevel.PasswordFill ->
- activity.getString(R.string.oreo_autofill_password_fill_support)
- BrowserAutofillSupportLevel.PasswordFillAndSaveIfNoAccessibility ->
- activity.getString(
- R.string.oreo_autofill_password_fill_and_conditional_save_support
- )
- BrowserAutofillSupportLevel.GeneralFill ->
- activity.getString(R.string.oreo_autofill_general_fill_support)
- BrowserAutofillSupportLevel.GeneralFillAndSave ->
- activity.getString(R.string.oreo_autofill_general_fill_and_save_support)
- }
- "$appLabel: $supportDescription"
- }
- setView(layout)
- setPositiveButton(R.string.dialog_ok) { _, _ ->
- val intent =
- Intent(Settings.ACTION_REQUEST_SET_AUTOFILL_SERVICE).apply {
- data = "package:${BuildConfig.APPLICATION_ID}".toUri()
- }
- activity.startActivity(intent)
- }
- setNegativeButton(R.string.dialog_cancel, null)
- setOnDismissListener { pref.checked = isAutofillServiceEnabled }
- activity.lifecycle.addObserver(observer)
- show()
- }
- }
-
- override fun provideSettings(builder: PreferenceScreen.Builder) {
- builder.apply {
- switch(PreferenceKeys.AUTOFILL_ENABLE) {
- titleRes = R.string.pref_autofill_enable_title
- defaultValue = isAutofillServiceEnabled
- onClick {
- if (isAutofillServiceEnabled) {
- activity.autofillManager?.disableAutofillServices()
- } else {
- showAutofillDialog(this)
- }
- false
- }
- }
- editText(PreferenceKeys.OREO_AUTOFILL_DEFAULT_USERNAME) {
- dependency = PreferenceKeys.AUTOFILL_ENABLE
- titleRes = R.string.preference_default_username_title
- summaryProvider = { activity.getString(R.string.preference_default_username_summary) }
- }
- editText(PreferenceKeys.OREO_AUTOFILL_CUSTOM_PUBLIC_SUFFIXES) {
- dependency = PreferenceKeys.AUTOFILL_ENABLE
- titleRes = R.string.preference_custom_public_suffixes_title
- summaryProvider = { activity.getString(R.string.preference_custom_public_suffixes_summary) }
- textInputHintRes = R.string.preference_custom_public_suffixes_hint
- }
- }
- }
-}
diff --git a/app/src/main/java/app/passwordstore/ui/settings/GeneralSettings.kt b/app/src/main/java/app/passwordstore/ui/settings/GeneralSettings.kt
deleted file mode 100644
index e3594269..00000000
--- a/app/src/main/java/app/passwordstore/ui/settings/GeneralSettings.kt
+++ /dev/null
@@ -1,120 +0,0 @@
-/*
- * Copyright © 2014-2024 The Android Password Store Authors. All Rights Reserved.
- * SPDX-License-Identifier: GPL-3.0-only
- */
-
-package app.passwordstore.ui.settings
-
-import android.content.pm.ShortcutManager
-import android.os.Build
-import androidx.core.content.edit
-import androidx.core.content.getSystemService
-import androidx.fragment.app.FragmentActivity
-import app.passwordstore.R
-import app.passwordstore.util.auth.BiometricAuthenticator
-import app.passwordstore.util.auth.BiometricAuthenticator.Result
-import app.passwordstore.util.extensions.sharedPrefs
-import app.passwordstore.util.settings.DirectoryStructure
-import app.passwordstore.util.settings.PreferenceKeys
-import de.Maxr1998.modernpreferences.PreferenceScreen
-import de.Maxr1998.modernpreferences.helpers.onClick
-import de.Maxr1998.modernpreferences.helpers.singleChoice
-import de.Maxr1998.modernpreferences.helpers.switch
-import de.Maxr1998.modernpreferences.preferences.choice.SelectionItem
-
-class GeneralSettings(private val activity: FragmentActivity) : SettingsProvider {
-
- override fun provideSettings(builder: PreferenceScreen.Builder) {
- builder.apply {
- val themeValues = activity.resources.getStringArray(R.array.app_theme_values)
- val themeOptions = activity.resources.getStringArray(R.array.app_theme_options)
- val themeItems =
- themeValues.zip(themeOptions).map { SelectionItem(it.first, it.second, null) }
- singleChoice(PreferenceKeys.APP_THEME, themeItems) {
- initialSelection = activity.resources.getString(R.string.app_theme_def)
- titleRes = R.string.pref_app_theme_title
- }
-
- val values = activity.resources.getStringArray(R.array.directory_structure_values)
- val titles = activity.resources.getStringArray(R.array.directory_structure_entries)
- val items = values.zip(titles).map { SelectionItem(it.first, it.second, null) }
- singleChoice(PreferenceKeys.DIRECTORY_STRUCTURE, items) {
- initialSelection = DirectoryStructure.DEFAULT.value
- titleRes = R.string.pref_directory_structure
- }
-
- val sortValues = activity.resources.getStringArray(R.array.sort_order_values)
- val sortOptions = activity.resources.getStringArray(R.array.sort_order_entries)
- val sortItems = sortValues.zip(sortOptions).map { SelectionItem(it.first, it.second, null) }
- singleChoice(PreferenceKeys.SORT_ORDER, sortItems) {
- initialSelection = sortValues[0]
- titleRes = R.string.pref_sort_order_title
- }
-
- switch(PreferenceKeys.DISABLE_SYNC_ACTION) {
- titleRes = R.string.pref_disable_sync_on_pull_title
- summaryRes = R.string.pref_disable_sync_on_pull_summary
- defaultValue = false
- }
-
- switch(PreferenceKeys.FILTER_RECURSIVELY) {
- titleRes = R.string.pref_recursive_filter_title
- summaryRes = R.string.pref_recursive_filter_summary
- defaultValue = true
- }
-
- switch(PreferenceKeys.SEARCH_ON_START) {
- titleRes = R.string.pref_search_on_start_title
- summaryRes = R.string.pref_search_on_start_summary
- defaultValue = false
- }
-
- switch(PreferenceKeys.SHOW_HIDDEN_CONTENTS) {
- titleRes = R.string.pref_show_hidden_title
- summaryRes = R.string.pref_show_hidden_summary
- defaultValue = false
- }
-
- // See https://github.com/android-password-store/Android-Password-Store/issues/2802
- val disableAuth = Build.VERSION.SDK_INT == Build.VERSION_CODES.UPSIDE_DOWN_CAKE
- val canAuthenticate = BiometricAuthenticator.canAuthenticate(activity)
- switch(PreferenceKeys.BIOMETRIC_AUTH_2) {
- titleRes = R.string.pref_biometric_auth_title
- defaultValue = false
- enabled = !disableAuth
- summaryRes =
- if (disableAuth) R.string.pref_biometric_auth_summary_disabled_platform
- else if (canAuthenticate) R.string.pref_biometric_auth_summary
- else R.string.pref_biometric_auth_summary_error
- onClick {
- enabled = false
- val isChecked = checked
- activity.sharedPrefs.edit {
- BiometricAuthenticator.authenticate(activity) { result ->
- when (result) {
- is Result.Success -> {
- // Apply the changes
- putBoolean(PreferenceKeys.BIOMETRIC_AUTH_2, checked)
- enabled = true
- }
- is Result.Retry -> {}
- else -> {
- // If any error occurs, revert back to the previous
- // state. This
- // catch-all clause includes the cancellation case.
- putBoolean(PreferenceKeys.BIOMETRIC_AUTH_2, !checked)
- checked = !isChecked
- enabled = true
- }
- }
- }
- }
- activity.getSystemService<ShortcutManager>()?.apply {
- removeDynamicShortcuts(dynamicShortcuts.map { it.id }.toMutableList())
- }
- false
- }
- }
- }
- }
-}
diff --git a/app/src/main/java/app/passwordstore/ui/settings/GitConfigScreen.kt b/app/src/main/java/app/passwordstore/ui/settings/GitConfigScreen.kt
new file mode 100644
index 00000000..d8001191
--- /dev/null
+++ b/app/src/main/java/app/passwordstore/ui/settings/GitConfigScreen.kt
@@ -0,0 +1,328 @@
+/*
+ * Copyright (C) 2026 Danilo M. <danix@danix.xyz>
+ * SPDX-License-Identifier: GPL-3.0-only
+ */
+
+package app.passwordstore.ui.settings
+
+import android.widget.Toast
+import androidx.activity.compose.BackHandler
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.text.KeyboardOptions
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.automirrored.filled.ArrowBack
+import androidx.compose.material3.AlertDialog
+import androidx.compose.material3.Button
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.LinearProgressIndicator
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.OutlinedButton
+import androidx.compose.material3.OutlinedTextField
+import androidx.compose.material3.Scaffold
+import androidx.compose.material3.Text
+import androidx.compose.material3.TextButton
+import androidx.compose.material3.TopAppBar
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.saveable.rememberSaveable
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.input.ImeAction
+import androidx.compose.ui.text.input.KeyboardCapitalization
+import androidx.compose.ui.text.input.KeyboardType
+import androidx.hilt.navigation.compose.hiltViewModel
+import androidx.lifecycle.compose.LifecycleResumeEffect
+import androidx.lifecycle.lifecycleScope
+import app.passwordstore.R
+import app.passwordstore.data.repo.PasswordRepository
+import app.passwordstore.ui.compose.theme.SpacingLarge
+import app.passwordstore.ui.git.base.BaseGitActivity.GitOp
+import app.passwordstore.ui.main.MainActivity
+import app.passwordstore.ui.setup.SetupDialog
+import app.passwordstore.util.extensions.snackbar
+import com.github.michaelbull.result.fold
+import com.google.android.material.snackbar.Snackbar
+import kotlinx.coroutines.launch
+
+/**
+ * Author name and email with Save, then the repository utilities: HEAD ([head], already a
+ * sentence), commit log, abort rebase ([canAbort]), hard reset, gc. [busy] disables everything.
+ */
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun GitConfigScreen(
+ name: String,
+ email: String,
+ head: String,
+ canAbort: Boolean,
+ busy: Boolean,
+ onNameChange: (String) -> Unit,
+ onEmailChange: (String) -> Unit,
+ onSave: () -> Unit,
+ onLog: () -> Unit,
+ onAbort: () -> Unit,
+ onReset: () -> Unit,
+ onGc: () -> Unit,
+ onBack: () -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ Scaffold(
+ modifier = modifier,
+ topBar = {
+ TopAppBar(
+ title = { Text(stringResource(R.string.title_activity_git_config)) },
+ navigationIcon = {
+ IconButton(onClick = onBack) {
+ Icon(Icons.AutoMirrored.Filled.ArrowBack, stringResource(R.string.entry_back))
+ }
+ },
+ )
+ },
+ ) { padding ->
+ Column(
+ Modifier.padding(padding)
+ .fillMaxSize()
+ .verticalScroll(rememberScrollState())
+ .padding(SpacingLarge),
+ verticalArrangement = Arrangement.spacedBy(SpacingLarge),
+ ) {
+ val field = Modifier.fillMaxWidth()
+ OutlinedTextField(
+ value = name,
+ onValueChange = onNameChange,
+ modifier = field,
+ enabled = !busy,
+ label = { Text(stringResource(R.string.git_user_name_hint)) },
+ singleLine = true,
+ keyboardOptions =
+ KeyboardOptions(capitalization = KeyboardCapitalization.Words, imeAction = ImeAction.Next),
+ )
+ OutlinedTextField(
+ value = email,
+ onValueChange = onEmailChange,
+ modifier = field,
+ enabled = !busy,
+ label = { Text(stringResource(R.string.git_user_email)) },
+ singleLine = true,
+ keyboardOptions =
+ KeyboardOptions(
+ autoCorrectEnabled = false,
+ keyboardType = KeyboardType.Email,
+ imeAction = ImeAction.Done,
+ ),
+ )
+ Button(onClick = onSave, modifier = Modifier.align(Alignment.End), enabled = !busy) {
+ Text(stringResource(R.string.crypto_save))
+ }
+ Text(stringResource(R.string.git_tools), style = MaterialTheme.typography.titleLarge)
+ Text(head)
+ if (busy) LinearProgressIndicator(field)
+ OutlinedButton(onClick = onLog, modifier = field, enabled = !busy) {
+ Text(stringResource(R.string.git_log))
+ }
+ OutlinedButton(onClick = onAbort, modifier = field, enabled = canAbort && !busy) {
+ Text(stringResource(R.string.abort_rebase))
+ }
+ OutlinedButton(onClick = onReset, modifier = field, enabled = !busy) {
+ Text(stringResource(R.string.reset_to_remote))
+ }
+ OutlinedButton(onClick = onGc, modifier = field, enabled = !busy) {
+ Text(stringResource(R.string.git_run_gc_job))
+ }
+ }
+ }
+}
+
+/** Asks before a hard reset, for the remote [branch] to reset to. */
+@Composable
+fun ResetDialog(
+ branch: String,
+ onBranchChange: (String) -> Unit,
+ onConfirm: () -> Unit,
+ onDismiss: () -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ AlertDialog(
+ onDismissRequest = onDismiss,
+ modifier = modifier,
+ title = { Text(stringResource(R.string.reset_to_remote)) },
+ text = {
+ Column(verticalArrangement = Arrangement.spacedBy(SpacingLarge)) {
+ Text(stringResource(R.string.git_reset_confirm_message))
+ OutlinedTextField(
+ value = branch,
+ onValueChange = onBranchChange,
+ modifier = Modifier.fillMaxWidth(),
+ label = { Text(stringResource(R.string.git_utils_reset_remote_branch_title)) },
+ singleLine = true,
+ keyboardOptions = KeyboardOptions(autoCorrectEnabled = false),
+ )
+ }
+ },
+ confirmButton = {
+ TextButton(onClick = onConfirm, enabled = branch.isNotBlank()) {
+ Text(stringResource(R.string.dialog_ok))
+ }
+ },
+ dismissButton = {
+ TextButton(onClick = onDismiss) { Text(stringResource(R.string.dialog_cancel)) }
+ },
+ )
+}
+
+private enum class GitConfigDialog {
+ InvalidEmail,
+ Reset,
+}
+
+/**
+ * The git config screen: wires [GitConfigScreen] to the [viewModel] and runs the utilities as git
+ * operations of [activity]. A successful reset or gc stays here with a short snackbar; an abort
+ * shows its report, which closes the screen; a failure shows the git error dialog and the screen
+ * stays.
+ */
+@Composable
+fun GitConfigDestination(
+ activity: MainActivity,
+ onClose: () -> Unit,
+ onLog: () -> Unit,
+ modifier: Modifier = Modifier,
+ viewModel: GitConfigViewModel = hiltViewModel(),
+) {
+ var dialog by rememberSaveable { mutableStateOf<GitConfigDialog?>(null) }
+ var branch by rememberSaveable { mutableStateOf("") }
+
+ LifecycleResumeEffect(Unit) {
+ viewModel.refresh()
+ onPauseOrDispose {}
+ }
+ // A running operation holds the screen: leaving would cancel it half way.
+ BackHandler(enabled = viewModel.busy) {}
+ // A finished reset or gc is announced once, also when the screen was recreated in between:
+ // the result stays in the ViewModel until the snackbar was shown.
+ val done = viewModel.done
+ LaunchedEffect(done) {
+ val label =
+ when (done) {
+ GitOp.RESET -> R.string.reset_to_remote
+ GitOp.GC -> R.string.git_run_gc_job
+ else -> return@LaunchedEffect
+ }
+ activity.snackbar(
+ message = activity.getString(R.string.git_tool_done, activity.getString(label)),
+ length = Snackbar.LENGTH_LONG,
+ )
+ viewModel.done = null
+ }
+
+ // ponytail: the activity's scope, like every git operation of the app: a theme or locale change
+ // cancels it, and the finally block frees the screen.
+ fun run(op: GitOp, remote: String = "") {
+ if (viewModel.busy) return
+ viewModel.busy = true
+ activity.lifecycleScope.launch {
+ try {
+ activity
+ .gitOperation(op, remote)
+ .fold(
+ success = {
+ if (op == GitOp.BREAK_OUT_OF_DETACHED)
+ viewModel.abortedBranch = PasswordRepository.getCurrentBranch()
+ viewModel.done = op
+ },
+ failure = { activity.promptOnErrorHandler(it) },
+ )
+ } finally {
+ viewModel.busy = false
+ viewModel.refresh()
+ }
+ }
+ }
+
+ val head =
+ when (val status = viewModel.head) {
+ is HeadStatus.Branch -> stringResource(R.string.git_head_on_branch, status.name)
+ is HeadStatus.Detached -> stringResource(R.string.git_head_detached, status.hash)
+ HeadStatus.Missing -> stringResource(R.string.git_head_missing)
+ null -> ""
+ }
+ Box(modifier) {
+ GitConfigScreen(
+ name = viewModel.name,
+ email = viewModel.email,
+ head = head,
+ canAbort = viewModel.canAbort,
+ busy = viewModel.busy,
+ onNameChange = { viewModel.name = it },
+ onEmailChange = { viewModel.email = it },
+ onSave = {
+ if (viewModel.save()) {
+ Toast.makeText(activity, R.string.git_server_config_save_success, Toast.LENGTH_SHORT)
+ .show()
+ onClose()
+ } else dialog = GitConfigDialog.InvalidEmail
+ },
+ onLog = onLog,
+ onAbort = {
+ // Checked again: BreakOutOfDetached reports success without doing anything otherwise.
+ if (PasswordRepository.repository?.let(::needsAbort) == true)
+ run(GitOp.BREAK_OUT_OF_DETACHED)
+ else viewModel.refresh()
+ },
+ onReset = {
+ branch = PasswordRepository.getCurrentBranch().orEmpty()
+ dialog = GitConfigDialog.Reset
+ },
+ onGc = { run(GitOp.GC) },
+ onBack = { if (!viewModel.busy) onClose() },
+ )
+ when (dialog) {
+ null -> Unit
+ GitConfigDialog.InvalidEmail ->
+ SetupDialog(
+ text = stringResource(R.string.invalid_email_dialog_text),
+ confirm = stringResource(R.string.dialog_ok),
+ onConfirm = { dialog = null },
+ onDismiss = { dialog = null },
+ )
+ GitConfigDialog.Reset ->
+ ResetDialog(
+ branch = branch,
+ onBranchChange = { branch = it },
+ onConfirm = {
+ dialog = null
+ run(GitOp.RESET, branch.trim())
+ },
+ onDismiss = { dialog = null },
+ )
+ }
+ if (done == GitOp.BREAK_OUT_OF_DETACHED) {
+ val aborted = viewModel.abortedBranch.orEmpty()
+ SetupDialog(
+ title = stringResource(R.string.git_abort_and_push_title),
+ text =
+ stringResource(
+ R.string.git_break_out_of_detached_success,
+ aborted,
+ "conflicting-$aborted-...",
+ ),
+ confirm = stringResource(R.string.dialog_ok),
+ onConfirm = onClose,
+ onDismiss = onClose,
+ )
+ }
+ }
+}
diff --git a/app/src/main/java/app/passwordstore/ui/settings/GitConfigViewModel.kt b/app/src/main/java/app/passwordstore/ui/settings/GitConfigViewModel.kt
new file mode 100644
index 00000000..8882ed87
--- /dev/null
+++ b/app/src/main/java/app/passwordstore/ui/settings/GitConfigViewModel.kt
@@ -0,0 +1,82 @@
+/*
+ * Copyright (C) 2026 Danilo M. <danix@danix.xyz>
+ * SPDX-License-Identifier: GPL-3.0-only
+ */
+
+package app.passwordstore.ui.settings
+
+import android.util.Patterns
+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.data.repo.PasswordRepository
+import app.passwordstore.ui.git.base.BaseGitActivity.GitOp
+import app.passwordstore.util.coroutines.DispatcherProvider
+import app.passwordstore.util.settings.GitSettings
+import dagger.hilt.android.lifecycle.HiltViewModel
+import javax.inject.Inject
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.withContext
+
+/**
+ * The git config screen's state: author name and email being edited, HEAD, and the git operation
+ * that runs or succeeded. The operation itself runs in the destination (it needs the activity);
+ * this ViewModel keeps what must survive a recreation.
+ */
+@HiltViewModel
+class GitConfigViewModel
+@Inject
+constructor(
+ private val gitSettings: GitSettings,
+ private val dispatcherProvider: DispatcherProvider,
+) : ViewModel() {
+
+ var name by mutableStateOf(gitSettings.authorName)
+ var email by mutableStateOf(gitSettings.authorEmail)
+
+ var head by mutableStateOf<HeadStatus?>(null)
+ private set
+
+ var canAbort by mutableStateOf(false)
+ private set
+
+ /** A git operation runs; everything waits. */
+ var busy by mutableStateOf(false)
+
+ /**
+ * The operation that succeeded and is not reported yet: a reset or gc gets a snackbar (then this
+ * goes back to null), an abort its dialog.
+ */
+ var done by mutableStateOf<GitOp?>(null)
+
+ /** The branch an abort pushed from, for its report. */
+ var abortedBranch by mutableStateOf<String?>(null)
+
+ init {
+ refresh()
+ }
+
+ /** Re-reads HEAD and the rebase state, off the main thread. */
+ fun refresh() {
+ viewModelScope.launch {
+ val repo = PasswordRepository.repository
+ val (status, abort) =
+ withContext(dispatcherProvider.io()) {
+ if (repo == null) HeadStatus.Missing to false else headStatus(repo) to needsAbort(repo)
+ }
+ head = status
+ canAbort = abort
+ }
+ }
+
+ /** Saves name and email; false, saving nothing, when the email is not an address. */
+ fun save(): Boolean {
+ val address = email.trim()
+ if (!Patterns.EMAIL_ADDRESS.matcher(address).matches()) return false
+ gitSettings.authorEmail = address
+ gitSettings.authorName = name.trim()
+ return true
+ }
+}
diff --git a/app/src/main/java/app/passwordstore/ui/settings/GitLogScreen.kt b/app/src/main/java/app/passwordstore/ui/settings/GitLogScreen.kt
new file mode 100644
index 00000000..276c9c11
--- /dev/null
+++ b/app/src/main/java/app/passwordstore/ui/settings/GitLogScreen.kt
@@ -0,0 +1,118 @@
+/*
+ * Copyright (C) 2026 Danilo M. <danix@danix.xyz>
+ * SPDX-License-Identifier: GPL-3.0-only
+ */
+
+package app.passwordstore.ui.settings
+
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.items
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.automirrored.filled.ArrowBack
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.HorizontalDivider
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.LinearProgressIndicator
+import androidx.compose.material3.ListItem
+import androidx.compose.material3.Scaffold
+import androidx.compose.material3.Text
+import androidx.compose.material3.TopAppBar
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.font.FontFamily
+import androidx.hilt.navigation.compose.hiltViewModel
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import app.passwordstore.R
+import app.passwordstore.data.repo.PasswordRepository
+import app.passwordstore.util.coroutines.DispatcherProvider
+import app.passwordstore.util.git.GitCommit
+import dagger.hilt.android.lifecycle.HiltViewModel
+import java.time.ZoneId
+import java.time.format.DateTimeFormatter
+import java.time.format.FormatStyle
+import java.util.Locale
+import javax.inject.Inject
+import kotlinx.collections.immutable.ImmutableList
+import kotlinx.collections.immutable.toImmutableList
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.withContext
+
+/** The store's commits, read once off the main thread. */
+@HiltViewModel
+class GitLogViewModel @Inject constructor(dispatcherProvider: DispatcherProvider) : ViewModel() {
+
+ /** Null while loading. */
+ var commits by mutableStateOf<ImmutableList<GitCommit>?>(null)
+ private set
+
+ init {
+ viewModelScope.launch {
+ commits =
+ withContext(dispatcherProvider.io()) {
+ gitLog(PasswordRepository.repository).toImmutableList()
+ }
+ }
+ }
+}
+
+/**
+ * The commit log, newest first: message, short hash, date. A progress bar while [commits] is null.
+ */
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun GitLogScreen(
+ commits: ImmutableList<GitCommit>?,
+ onBack: () -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ val formatter = remember {
+ DateTimeFormatter.ofLocalizedDateTime(FormatStyle.MEDIUM)
+ .withLocale(Locale.getDefault())
+ .withZone(ZoneId.systemDefault())
+ }
+ Scaffold(
+ modifier = modifier,
+ topBar = {
+ TopAppBar(
+ title = { Text(stringResource(R.string.title_activity_git_log)) },
+ navigationIcon = {
+ IconButton(onClick = onBack) {
+ Icon(Icons.AutoMirrored.Filled.ArrowBack, stringResource(R.string.entry_back))
+ }
+ },
+ )
+ },
+ ) { padding ->
+ if (commits == null) LinearProgressIndicator(Modifier.padding(padding).fillMaxWidth())
+ else
+ LazyColumn(Modifier.padding(padding).fillMaxSize()) {
+ items(commits, key = GitCommit::hash) { commit ->
+ ListItem(
+ headlineContent = { Text(commit.shortMessage) },
+ overlineContent = { Text(commit.hash.take(8), fontFamily = FontFamily.Monospace) },
+ supportingContent = { Text(formatter.format(commit.time)) },
+ )
+ HorizontalDivider()
+ }
+ }
+ }
+}
+
+@Composable
+fun GitLogDestination(
+ onClose: () -> Unit,
+ modifier: Modifier = Modifier,
+ viewModel: GitLogViewModel = hiltViewModel(),
+) {
+ GitLogScreen(commits = viewModel.commits, onBack = onClose, modifier = modifier)
+}
diff --git a/app/src/main/java/app/passwordstore/ui/settings/MiscSettings.kt b/app/src/main/java/app/passwordstore/ui/settings/MiscSettings.kt
deleted file mode 100644
index 9a5d1e3b..00000000
--- a/app/src/main/java/app/passwordstore/ui/settings/MiscSettings.kt
+++ /dev/null
@@ -1,76 +0,0 @@
-/*
- * Copyright © 2014-2024 The Android Password Store Authors. All Rights Reserved.
- * SPDX-License-Identifier: GPL-3.0-only
- */
-
-package app.passwordstore.ui.settings
-
-import android.content.Context
-import android.content.Intent
-import android.net.Uri
-import androidx.activity.result.contract.ActivityResultContracts
-import androidx.documentfile.provider.DocumentFile
-import androidx.fragment.app.FragmentActivity
-import app.passwordstore.BuildConfig
-import app.passwordstore.R
-import app.passwordstore.util.services.PasswordExportService
-import app.passwordstore.util.settings.PreferenceKeys
-import de.Maxr1998.modernpreferences.PreferenceScreen
-import de.Maxr1998.modernpreferences.helpers.onClick
-import de.Maxr1998.modernpreferences.helpers.pref
-import de.Maxr1998.modernpreferences.helpers.switch
-
-class MiscSettings(activity: FragmentActivity) : SettingsProvider {
-
- private val storeExportAction =
- activity.registerForActivityResult(
- object : ActivityResultContracts.OpenDocumentTree() {
- override fun createIntent(context: Context, input: Uri?): Intent {
- return super.createIntent(context, input).apply {
- flags =
- Intent.FLAG_GRANT_READ_URI_PERMISSION or
- Intent.FLAG_GRANT_WRITE_URI_PERMISSION or
- Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION or
- Intent.FLAG_GRANT_PREFIX_URI_PERMISSION
- }
- }
- }
- ) { uri: Uri? ->
- if (uri == null) return@registerForActivityResult
- val targetDirectory = DocumentFile.fromTreeUri(activity.applicationContext, uri)
-
- if (targetDirectory != null) {
- val service =
- Intent(activity.applicationContext, PasswordExportService::class.java).apply {
- action = PasswordExportService.ACTION_EXPORT_PASSWORD
- putExtra("uri", uri)
- }
-
- activity.startForegroundService(service)
- }
- }
-
- override fun provideSettings(builder: PreferenceScreen.Builder) {
- builder.apply {
- pref(PreferenceKeys.EXPORT_PASSWORDS) {
- titleRes = R.string.prefs_export_passwords_title
- summaryRes = R.string.prefs_export_passwords_summary
- onClick {
- storeExportAction.launch(null)
- true
- }
- }
- switch(PreferenceKeys.CLEAR_CLIPBOARD_HISTORY) {
- defaultValue = false
- titleRes = R.string.pref_clear_clipboard_title
- summaryRes = R.string.pref_clear_clipboard_summary
- }
- switch(PreferenceKeys.ENABLE_DEBUG_LOGGING) {
- defaultValue = false
- titleRes = R.string.pref_debug_logging_title
- summaryRes = R.string.pref_debug_logging_summary
- visible = !BuildConfig.DEBUG
- }
- }
- }
-}
diff --git a/app/src/main/java/app/passwordstore/ui/settings/PGPSettings.kt b/app/src/main/java/app/passwordstore/ui/settings/PGPSettings.kt
deleted file mode 100644
index 46e62faa..00000000
--- a/app/src/main/java/app/passwordstore/ui/settings/PGPSettings.kt
+++ /dev/null
@@ -1,136 +0,0 @@
-/*
- * Copyright © 2014-2024 The Android Password Store Authors. All Rights Reserved.
- * SPDX-License-Identifier: GPL-3.0-only
- */
-
-package app.passwordstore.ui.settings
-
-import androidx.core.content.edit
-import androidx.fragment.app.FragmentActivity
-import androidx.lifecycle.lifecycleScope
-import app.passwordstore.R
-import app.passwordstore.data.crypto.PGPPassphraseCache
-import app.passwordstore.ui.main.MainActivity
-import app.passwordstore.ui.setup.SetupStart
-import app.passwordstore.util.auth.BiometricAuthenticator
-import app.passwordstore.util.extensions.sharedPrefs
-import app.passwordstore.util.features.Feature
-import app.passwordstore.util.settings.PreferenceKeys
-import de.Maxr1998.modernpreferences.PreferenceScreen
-import de.Maxr1998.modernpreferences.helpers.onClick
-import de.Maxr1998.modernpreferences.helpers.pref
-import de.Maxr1998.modernpreferences.helpers.switch
-import kotlinx.coroutines.launch
-
-class PGPSettings(
- private val activity: FragmentActivity,
- private val passphraseCache: PGPPassphraseCache,
-) : SettingsProvider {
-
- override fun provideSettings(builder: PreferenceScreen.Builder) {
- builder.apply {
- pref("_") {
- titleRes = R.string.pref_pgp_key_manager_title
- persistent = false
- onClick {
- activity.startActivity(MainActivity.setupIntent(activity, SetupStart.PgpKeys))
- false
- }
- }
- switch(PreferenceKeys.ASCII_ARMOR) {
- titleRes = R.string.pref_pgp_ascii_armor_title
- persistent = true
- }
- switch(Feature.EnablePGPPassphraseCache.configKey) {
- enabled = BiometricAuthenticator.canAuthenticate(activity)
- titleRes = R.string.pref_passphrase_cache_title
- summaryRes = R.string.pref_passphrase_cache_summary
- defaultValue = false
- onClick {
- if (BiometricAuthenticator.canAuthenticate(activity)) {
- val promptTitle =
- if (checked) R.string.pref_passphrase_cache_authenticate_enable
- else R.string.pref_passphrase_cache_authenticate_disable
-
- BiometricAuthenticator.authenticate(activity, promptTitle) { result ->
- when (result) {
- is BiometricAuthenticator.Result.Success -> {
- /* Any successful change of this setting clears the passphrase
- * cache for safety */
- activity.lifecycleScope.launch {
- passphraseCache.clearAllCachedPassphrases(activity)
- }
- activity.sharedPrefs.edit {
- putBoolean(Feature.EnablePGPPassphraseCache.configKey, checked)
- if (!checked) remove(PreferenceKeys.CLEAR_PASSPHRASE_CACHE)
- }
- }
- is BiometricAuthenticator.Result.Retry -> {}
- else -> {
- /* revert back to previous state in case of error or cancellation */
- checked = !checked
- activity.sharedPrefs.edit {
- putBoolean(Feature.EnablePGPPassphraseCache.configKey, checked)
- }
- }
- }
- }
- } else {
- /* we may get here if device lock has been disabled while PGP settings
- * screen was left open */
- checked = false
- enabled = false
- activity.sharedPrefs.edit {
- putBoolean(Feature.EnablePGPPassphraseCache.configKey, false)
- }
- }
- false
- }
- }
- switch(PreferenceKeys.CLEAR_PASSPHRASE_CACHE) {
- dependency = Feature.EnablePGPPassphraseCache.configKey
- titleRes = R.string.pref_passphrase_cache_auto_clear_title
- summaryRes = R.string.pref_passphrase_cache_auto_clear_summary
- defaultValue = true
- /* Clear the cache once when unchecking; this is to prevent a malicious user (someone
- * knowing the screen-lock pin, but not knowing the PGP passphrase) from bypassing cache
- * clearing via the settings. Disabling it requires authentication, and cache clearing
- * stays enabled in case of any authentication failure. */
- onClick {
- if (!checked) {
- if (
- BiometricAuthenticator.canAuthenticate(activity) &&
- activity.sharedPrefs.getBoolean(Feature.EnablePGPPassphraseCache.configKey, false)
- ) {
- BiometricAuthenticator.authenticate(
- activity,
- R.string.pref_passphrase_cache_auto_clear_authenticate_disable,
- ) { result ->
- when (result) {
- is BiometricAuthenticator.Result.Success -> {
- activity.sharedPrefs.edit {
- putBoolean(PreferenceKeys.CLEAR_PASSPHRASE_CACHE, false)
- }
- activity.lifecycleScope.launch {
- passphraseCache.clearAllCachedPassphrases(activity)
- }
- }
- is BiometricAuthenticator.Result.Retry -> {}
- else -> {
- activity.sharedPrefs.edit { remove(PreferenceKeys.CLEAR_PASSPHRASE_CACHE) }
- checked = true
- }
- }
- }
- } else {
- activity.sharedPrefs.edit { remove(PreferenceKeys.CLEAR_PASSPHRASE_CACHE) }
- checked = true
- enabled = false
- }
- }
- false
- }
- }
- }
- }
-}
diff --git a/app/src/main/java/app/passwordstore/ui/settings/PasswordSettings.kt b/app/src/main/java/app/passwordstore/ui/settings/PasswordSettings.kt
deleted file mode 100644
index 9fe4cd42..00000000
--- a/app/src/main/java/app/passwordstore/ui/settings/PasswordSettings.kt
+++ /dev/null
@@ -1,48 +0,0 @@
-/*
- * Copyright © 2014-2024 The Android Password Store Authors. All Rights Reserved.
- * SPDX-License-Identifier: GPL-3.0-only
- */
-
-package app.passwordstore.ui.settings
-
-import android.text.InputType
-import androidx.fragment.app.FragmentActivity
-import app.passwordstore.R
-import app.passwordstore.util.settings.PreferenceKeys
-import de.Maxr1998.modernpreferences.PreferenceScreen
-import de.Maxr1998.modernpreferences.helpers.editText
-import de.Maxr1998.modernpreferences.helpers.singleChoice
-import de.Maxr1998.modernpreferences.helpers.switch
-import de.Maxr1998.modernpreferences.preferences.choice.SelectionItem
-
-class PasswordSettings(private val activity: FragmentActivity) : SettingsProvider {
-
- override fun provideSettings(builder: PreferenceScreen.Builder) {
- builder.apply {
- val values = activity.resources.getStringArray(R.array.pwgen_provider_values)
- val labels = activity.resources.getStringArray(R.array.pwgen_provider_labels)
- val items = values.zip(labels).map { SelectionItem(it.first, it.second, null) }
- singleChoice(PreferenceKeys.PREF_KEY_PWGEN_TYPE, items) {
- initialSelection = "diceware"
- titleRes = R.string.pref_password_generator_type_title
- }
- editText(PreferenceKeys.GENERAL_SHOW_TIME) {
- titleRes = R.string.pref_clipboard_timeout_title
- summaryProvider = { timeout ->
- activity.getString(R.string.pref_clipboard_timeout_summary, timeout ?: "45")
- }
- textInputType = InputType.TYPE_CLASS_NUMBER
- }
- switch(PreferenceKeys.SHOW_PASSWORD) {
- titleRes = R.string.show_password_pref_title
- summaryRes = R.string.show_password_pref_summary
- defaultValue = true
- }
- switch(PreferenceKeys.COPY_ON_DECRYPT) {
- titleRes = R.string.pref_copy_title
- summaryRes = R.string.pref_copy_summary
- defaultValue = false
- }
- }
- }
-}
diff --git a/app/src/main/java/app/passwordstore/ui/settings/PreferenceRows.kt b/app/src/main/java/app/passwordstore/ui/settings/PreferenceRows.kt
new file mode 100644
index 00000000..cfaa741d
--- /dev/null
+++ b/app/src/main/java/app/passwordstore/ui/settings/PreferenceRows.kt
@@ -0,0 +1,217 @@
+/*
+ * Copyright (C) 2026 Danilo M. <danix@danix.xyz>
+ * SPDX-License-Identifier: GPL-3.0-only
+ */
+
+package app.passwordstore.ui.settings
+
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.selection.selectable
+import androidx.compose.foundation.selection.selectableGroup
+import androidx.compose.foundation.selection.toggleable
+import androidx.compose.foundation.text.KeyboardOptions
+import androidx.compose.material3.AlertDialog
+import androidx.compose.material3.ListItem
+import androidx.compose.material3.ListItemColors
+import androidx.compose.material3.ListItemDefaults
+import androidx.compose.material3.OutlinedTextField
+import androidx.compose.material3.RadioButton
+import androidx.compose.material3.Switch
+import androidx.compose.material3.Text
+import androidx.compose.material3.TextButton
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.saveable.rememberSaveable
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.alpha
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.semantics.Role
+import androidx.compose.ui.text.input.KeyboardType
+import app.passwordstore.R
+import app.passwordstore.ui.compose.theme.SpacingLarge
+import app.passwordstore.ui.compose.theme.SpacingMedium
+import kotlinx.collections.immutable.ImmutableList
+
+private const val DISABLED_ALPHA = 0.38f
+
+/** List item colors for rows inside a grouped surface: the surface shows through. */
+@Composable
+internal fun rowColors(): ListItemColors =
+ ListItemDefaults.colors(containerColor = Color.Transparent)
+
+private fun Modifier.dimmed(enabled: Boolean) = if (enabled) this else alpha(DISABLED_ALPHA)
+
+/** A row that runs [onClick]: opens a screen or a dialog. */
+@Composable
+fun PreferenceAction(
+ title: String,
+ onClick: () -> Unit,
+ modifier: Modifier = Modifier,
+ summary: String? = null,
+ enabled: Boolean = true,
+) {
+ ListItem(
+ headlineContent = { Text(title) },
+ modifier =
+ modifier.clickable(enabled = enabled, role = Role.Button, onClick = onClick).dimmed(enabled),
+ supportingContent =
+ if (summary != null) {
+ { Text(summary) }
+ } else null,
+ colors = rowColors(),
+ )
+}
+
+/**
+ * A switch row showing [checked]. [onCheckedChange] gets the requested value; the caller decides
+ * whether it is stored (a screen lock prompt may come first), and the row shows what was stored.
+ */
+@Composable
+fun PreferenceSwitch(
+ title: String,
+ checked: Boolean,
+ onCheckedChange: (Boolean) -> Unit,
+ modifier: Modifier = Modifier,
+ summary: String? = null,
+ enabled: Boolean = true,
+) {
+ ListItem(
+ headlineContent = { Text(title) },
+ modifier =
+ modifier
+ .toggleable(
+ value = checked,
+ enabled = enabled,
+ role = Role.Switch,
+ onValueChange = onCheckedChange,
+ )
+ .dimmed(enabled),
+ supportingContent =
+ if (summary != null) {
+ { Text(summary) }
+ } else null,
+ trailingContent = { Switch(checked = checked, onCheckedChange = null, enabled = enabled) },
+ colors = rowColors(),
+ )
+}
+
+/**
+ * A single choice among [values], shown by their [labels] (same order); the summary is the selected
+ * label. Tapping opens a dialog of radio buttons; choosing one closes it.
+ */
+@Composable
+fun PreferenceChoice(
+ title: String,
+ values: ImmutableList<String>,
+ labels: ImmutableList<String>,
+ selected: String,
+ onSelect: (String) -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ var open by rememberSaveable { mutableStateOf(false) }
+ PreferenceAction(
+ title = title,
+ onClick = { open = true },
+ modifier = modifier,
+ summary = labels.getOrNull(values.indexOf(selected)),
+ )
+ if (open)
+ AlertDialog(
+ onDismissRequest = { open = false },
+ title = { Text(title) },
+ text = {
+ Column(Modifier.selectableGroup()) {
+ values.forEachIndexed { index, value ->
+ Row(
+ Modifier.fillMaxWidth()
+ .selectable(
+ selected = value == selected,
+ role = Role.RadioButton,
+ onClick = {
+ open = false
+ onSelect(value)
+ },
+ )
+ .padding(vertical = SpacingMedium),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ RadioButton(selected = value == selected, onClick = null)
+ Text(labels[index], Modifier.padding(start = SpacingLarge))
+ }
+ }
+ }
+ },
+ confirmButton = {
+ TextButton(onClick = { open = false }) { Text(stringResource(R.string.dialog_cancel)) }
+ },
+ )
+}
+
+/**
+ * A text value; tapping opens a dialog to edit it. The draft is kept across recreation in saved
+ * state: never use this row for a secret. [KeyboardType.Number] keeps digits only.
+ */
+@Composable
+fun PreferenceText(
+ title: String,
+ value: String,
+ onValueChange: (String) -> Unit,
+ modifier: Modifier = Modifier,
+ summary: String? = null,
+ hint: String? = null,
+ singleLine: Boolean = true,
+ keyboardType: KeyboardType = KeyboardType.Text,
+ enabled: Boolean = true,
+) {
+ var draft by rememberSaveable { mutableStateOf<String?>(null) }
+ PreferenceAction(
+ title = title,
+ onClick = { draft = value },
+ modifier = modifier,
+ summary = summary,
+ enabled = enabled,
+ )
+ draft?.let { text ->
+ AlertDialog(
+ onDismissRequest = { draft = null },
+ title = { Text(title) },
+ text = {
+ OutlinedTextField(
+ value = text,
+ onValueChange = { new ->
+ draft = if (keyboardType == KeyboardType.Number) new.filter(Char::isDigit) else new
+ },
+ modifier = Modifier.fillMaxWidth(),
+ placeholder =
+ if (hint != null) {
+ { Text(hint) }
+ } else null,
+ singleLine = singleLine,
+ minLines = if (singleLine) 1 else 3,
+ keyboardOptions = KeyboardOptions(keyboardType = keyboardType),
+ )
+ },
+ confirmButton = {
+ TextButton(
+ onClick = {
+ draft = null
+ onValueChange(text)
+ }
+ ) {
+ Text(stringResource(R.string.dialog_ok))
+ }
+ },
+ dismissButton = {
+ TextButton(onClick = { draft = null }) { Text(stringResource(R.string.dialog_cancel)) }
+ },
+ )
+ }
+}
diff --git a/app/src/main/java/app/passwordstore/ui/settings/ProxyScreen.kt b/app/src/main/java/app/passwordstore/ui/settings/ProxyScreen.kt
new file mode 100644
index 00000000..b7116bb7
--- /dev/null
+++ b/app/src/main/java/app/passwordstore/ui/settings/ProxyScreen.kt
@@ -0,0 +1,197 @@
+/*
+ * Copyright (C) 2026 Danilo M. <danix@danix.xyz>
+ * SPDX-License-Identifier: GPL-3.0-only
+ */
+
+package app.passwordstore.ui.settings
+
+import android.widget.Toast
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.text.KeyboardOptions
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.automirrored.filled.ArrowBack
+import androidx.compose.material3.Button
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.OutlinedTextField
+import androidx.compose.material3.Scaffold
+import androidx.compose.material3.Text
+import androidx.compose.material3.TopAppBar
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.DisposableEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.res.painterResource
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.input.ImeAction
+import androidx.compose.ui.text.input.KeyboardType
+import androidx.compose.ui.text.input.PasswordVisualTransformation
+import androidx.compose.ui.text.input.VisualTransformation
+import androidx.hilt.navigation.compose.hiltViewModel
+import app.passwordstore.R
+import app.passwordstore.ui.compose.R as composeR
+import app.passwordstore.ui.compose.theme.SpacingLarge
+import app.passwordstore.ui.main.MainActivity
+
+/** Proxy host, port, user name and password; [hostError] and [portError] block the save. */
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun ProxyScreen(
+ host: String,
+ port: String,
+ username: String,
+ password: String,
+ hostError: Boolean,
+ portError: Boolean,
+ onHostChange: (String) -> Unit,
+ onPortChange: (String) -> Unit,
+ onUsernameChange: (String) -> Unit,
+ onPasswordChange: (String) -> Unit,
+ onSave: () -> Unit,
+ onBack: () -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ var revealed by remember { mutableStateOf(false) }
+ Scaffold(
+ modifier = modifier,
+ topBar = {
+ TopAppBar(
+ title = { Text(stringResource(R.string.activity_proxy_label)) },
+ navigationIcon = {
+ IconButton(onClick = onBack) {
+ Icon(Icons.AutoMirrored.Filled.ArrowBack, stringResource(R.string.entry_back))
+ }
+ },
+ )
+ },
+ ) { padding ->
+ Column(
+ Modifier.padding(padding)
+ .fillMaxSize()
+ .verticalScroll(rememberScrollState())
+ .padding(SpacingLarge),
+ verticalArrangement = Arrangement.spacedBy(SpacingLarge),
+ ) {
+ val field = Modifier.fillMaxWidth()
+ OutlinedTextField(
+ value = host,
+ onValueChange = onHostChange,
+ modifier = field,
+ label = { Text(stringResource(R.string.proxy_hostname)) },
+ supportingText =
+ if (hostError) {
+ { Text(stringResource(R.string.invalid_proxy_url)) }
+ } else null,
+ isError = hostError,
+ singleLine = true,
+ keyboardOptions =
+ KeyboardOptions(
+ autoCorrectEnabled = false,
+ keyboardType = KeyboardType.Uri,
+ imeAction = ImeAction.Next,
+ ),
+ )
+ OutlinedTextField(
+ value = port,
+ onValueChange = { onPortChange(it.filter(Char::isDigit)) },
+ modifier = field,
+ label = { Text(stringResource(R.string.port)) },
+ supportingText =
+ if (portError) {
+ { Text(stringResource(R.string.invalid_proxy_port)) }
+ } else null,
+ isError = portError,
+ singleLine = true,
+ keyboardOptions =
+ KeyboardOptions(keyboardType = KeyboardType.Number, imeAction = ImeAction.Next),
+ )
+ OutlinedTextField(
+ value = username,
+ onValueChange = onUsernameChange,
+ modifier = field,
+ label = { Text(stringResource(R.string.username)) },
+ singleLine = true,
+ keyboardOptions = KeyboardOptions(autoCorrectEnabled = false, imeAction = ImeAction.Next),
+ )
+ OutlinedTextField(
+ value = password,
+ onValueChange = onPasswordChange,
+ modifier = field,
+ label = { Text(stringResource(R.string.password)) },
+ trailingIcon = {
+ IconButton(onClick = { revealed = !revealed }) {
+ Icon(
+ painterResource(
+ if (revealed) composeR.drawable.baseline_visibility_off_24
+ else composeR.drawable.baseline_visibility_24
+ ),
+ contentDescription =
+ stringResource(
+ if (revealed) R.string.entry_hide_password else R.string.entry_show_password
+ ),
+ )
+ }
+ },
+ visualTransformation =
+ if (revealed) VisualTransformation.None else PasswordVisualTransformation(),
+ singleLine = true,
+ keyboardOptions =
+ KeyboardOptions(
+ autoCorrectEnabled = false,
+ keyboardType = KeyboardType.Password,
+ imeAction = ImeAction.Done,
+ ),
+ )
+ Button(onClick = onSave, modifier = field, enabled = !hostError && !portError) {
+ Text(stringResource(R.string.crypto_save))
+ }
+ }
+ }
+}
+
+/**
+ * The proxy screen. Holds FLAG_SECURE while shown: it shows the saved proxy password (masked, with
+ * a reveal toggle).
+ */
+@Composable
+fun ProxyDestination(
+ activity: MainActivity,
+ onClose: () -> Unit,
+ modifier: Modifier = Modifier,
+ viewModel: ProxyViewModel = hiltViewModel(),
+) {
+ DisposableEffect(activity) {
+ activity.holdSecure()
+ onDispose { activity.releaseSecure() }
+ }
+ ProxyScreen(
+ host = viewModel.host,
+ port = viewModel.port,
+ username = viewModel.username,
+ password = viewModel.password,
+ hostError = viewModel.hostError,
+ portError = viewModel.portError,
+ onHostChange = { viewModel.host = it },
+ onPortChange = { viewModel.port = it },
+ onUsernameChange = { viewModel.username = it },
+ onPasswordChange = { viewModel.password = it },
+ onSave = {
+ if (viewModel.save()) {
+ Toast.makeText(activity, R.string.git_server_config_save_success, Toast.LENGTH_SHORT).show()
+ onClose()
+ }
+ },
+ onBack = onClose,
+ modifier = modifier,
+ )
+}
diff --git a/app/src/main/java/app/passwordstore/ui/settings/ProxyViewModel.kt b/app/src/main/java/app/passwordstore/ui/settings/ProxyViewModel.kt
new file mode 100644
index 00000000..fb2bfe93
--- /dev/null
+++ b/app/src/main/java/app/passwordstore/ui/settings/ProxyViewModel.kt
@@ -0,0 +1,62 @@
+/*
+ * Copyright (C) 2026 Danilo M. <danix@danix.xyz>
+ * SPDX-License-Identifier: GPL-3.0-only
+ */
+
+package app.passwordstore.ui.settings
+
+import android.net.InetAddresses
+import android.os.Build
+import android.util.Patterns
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.setValue
+import androidx.lifecycle.ViewModel
+import app.passwordstore.util.proxy.ProxyUtils
+import app.passwordstore.util.settings.GitSettings
+import dagger.hilt.android.lifecycle.HiltViewModel
+import javax.inject.Inject
+
+/** A numeric address or a plain host name: no scheme, port or path, which the port field holds. */
+fun isProxyHost(text: String): Boolean {
+ val numeric =
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) InetAddresses.isNumericAddress(text)
+ else @Suppress("DEPRECATION") Patterns.IP_ADDRESS.matcher(text).matches()
+ return numeric ||
+ (!text.contains(':') && !text.contains('/') && Patterns.DOMAIN_NAME.matcher(text).matches())
+}
+
+/**
+ * The proxy form, prefilled from the stored proxy. The password lives here in memory only, never in
+ * saved state; it is stored in `KeystorePreferences` through [GitSettings].
+ */
+@HiltViewModel
+class ProxyViewModel
+@Inject
+constructor(private val gitSettings: GitSettings, private val proxyUtils: ProxyUtils) :
+ ViewModel() {
+
+ var host by mutableStateOf(gitSettings.proxyHost.orEmpty())
+ var port by mutableStateOf(gitSettings.proxyPort.takeIf { it != -1 }?.toString().orEmpty())
+ var username by mutableStateOf(gitSettings.proxyUsername.orEmpty())
+ var password by mutableStateOf(gitSettings.proxyPassword.orEmpty())
+
+ val hostError: Boolean
+ get() = host.isNotBlank() && !isProxyHost(host.trim())
+
+ /** Invalid, or empty while a host is set: a proxy without a port is not used at all. */
+ val portError: Boolean
+ get() = proxyPort(port).let { it == null || (it == -1 && host.isNotBlank()) }
+
+ /** Stores the proxy and applies it at once; false, storing nothing, when a field is invalid. */
+ fun save(): Boolean {
+ if (hostError || portError) return false
+ val portValue = proxyPort(port) ?: return false
+ gitSettings.proxyHost = host.trim().ifEmpty { null }
+ gitSettings.proxyPort = portValue
+ gitSettings.proxyUsername = username.trim().ifEmpty { null }
+ gitSettings.proxyPassword = password.ifEmpty { null }
+ proxyUtils.setDefaultProxy()
+ return true
+ }
+}
diff --git a/app/src/main/java/app/passwordstore/ui/settings/RepositorySettings.kt b/app/src/main/java/app/passwordstore/ui/settings/RepositorySettings.kt
deleted file mode 100644
index 23dd2829..00000000
--- a/app/src/main/java/app/passwordstore/ui/settings/RepositorySettings.kt
+++ /dev/null
@@ -1,193 +0,0 @@
-/*
- * Copyright © 2014-2024 The Android Password Store Authors. All Rights Reserved.
- * SPDX-License-Identifier: GPL-3.0-only
- */
-
-package app.passwordstore.ui.settings
-
-import android.content.SharedPreferences
-import android.content.pm.ShortcutManager
-import androidx.activity.result.contract.ActivityResultContracts.StartActivityForResult
-import androidx.core.content.edit
-import androidx.core.content.getSystemService
-import androidx.fragment.app.FragmentActivity
-import app.passwordstore.R
-import app.passwordstore.data.repo.PasswordRepository
-import app.passwordstore.injection.prefs.GitPreferences
-import app.passwordstore.ui.git.config.GitConfigActivity
-import app.passwordstore.ui.main.MainActivity
-import app.passwordstore.ui.proxy.ProxySelectorActivity
-import app.passwordstore.ui.setup.SetupStart
-import app.passwordstore.util.extensions.getString
-import app.passwordstore.util.extensions.launchActivity
-import app.passwordstore.util.extensions.sharedPrefs
-import app.passwordstore.util.extensions.snackbar
-import app.passwordstore.util.extensions.unsafeLazy
-import app.passwordstore.util.git.sshj.SshKey
-import app.passwordstore.util.settings.GitSettings
-import app.passwordstore.util.settings.PreferenceKeys
-import com.github.michaelbull.result.onFailure
-import com.github.michaelbull.result.runCatching
-import com.google.android.material.dialog.MaterialAlertDialogBuilder
-import dagger.hilt.EntryPoint
-import dagger.hilt.InstallIn
-import dagger.hilt.android.EntryPointAccessors
-import dagger.hilt.components.SingletonComponent
-import de.Maxr1998.modernpreferences.Preference
-import de.Maxr1998.modernpreferences.PreferenceScreen
-import de.Maxr1998.modernpreferences.helpers.onClick
-import de.Maxr1998.modernpreferences.helpers.pref
-import de.Maxr1998.modernpreferences.helpers.switch
-
-class RepositorySettings(private val activity: FragmentActivity) : SettingsProvider {
-
- private val generateSshKey =
- activity.registerForActivityResult(StartActivityForResult()) {
- showSshKeyPref?.visible = SshKey.canShowSshPublicKey
- }
-
- private val hiltEntryPoint by unsafeLazy {
- EntryPointAccessors.fromApplication(
- activity.applicationContext,
- RepositorySettingsEntryPoint::class.java,
- )
- }
-
- private var showSshKeyPref: Preference? = null
-
- override fun provideSettings(builder: PreferenceScreen.Builder) {
- val encryptedPreferences = hiltEntryPoint.encryptedPreferences()
- val gitSettings = hiltEntryPoint.gitSettings()
-
- builder.apply {
- switch(PreferenceKeys.REBASE_ON_PULL) {
- titleRes = R.string.pref_rebase_on_pull_title
- summaryRes = R.string.pref_rebase_on_pull_summary
- summaryOnRes = R.string.pref_rebase_on_pull_summary_on
- defaultValue = true
- }
- pref(PreferenceKeys.GIT_SERVER_INFO) {
- titleRes = R.string.pref_edit_git_server_settings
- visible = PasswordRepository.isGitRepo()
- onClick {
- activity.startActivity(MainActivity.setupIntent(activity, SetupStart.GitServer))
- true
- }
- }
- pref(PreferenceKeys.PROXY_SETTINGS) {
- titleRes = R.string.pref_edit_proxy_settings
- visible = gitSettings.url?.startsWith("https") == true && PasswordRepository.isGitRepo()
- onClick {
- activity.launchActivity(ProxySelectorActivity::class.java)
- true
- }
- }
- pref(PreferenceKeys.GIT_CONFIG) {
- titleRes = R.string.pref_edit_git_config
- visible = PasswordRepository.isGitRepo()
- onClick {
- activity.launchActivity(GitConfigActivity::class.java)
- true
- }
- }
- pref(PreferenceKeys.SSH_KEY) {
- titleRes = R.string.pref_import_ssh_key_title
- visible = PasswordRepository.isGitRepo()
- onClick {
- generateSshKey.launch(MainActivity.setupIntent(activity, SetupStart.Ssh))
- true
- }
- }
- pref(PreferenceKeys.SSH_KEYGEN) {
- titleRes = R.string.pref_ssh_keygen_title
- onClick {
- generateSshKey.launch(MainActivity.setupIntent(activity, SetupStart.SshGenerate))
- true
- }
- }
- showSshKeyPref =
- pref(PreferenceKeys.SSH_SEE_KEY) {
- titleRes = R.string.pref_ssh_see_key_title
- visible = PasswordRepository.isGitRepo() && SshKey.canShowSshPublicKey
- onClick {
- generateSshKey.launch(MainActivity.setupIntent(activity, SetupStart.Ssh))
- true
- }
- }
- pref(PreferenceKeys.CLEAR_SAVED_PASS) {
- fun Preference.updatePref() {
- val sshPass = encryptedPreferences.getString(PreferenceKeys.SSH_KEY_LOCAL_PASSPHRASE)
- val httpsPass = encryptedPreferences.getString(PreferenceKeys.HTTPS_PASSWORD)
- if (sshPass == null && httpsPass == null) {
- visible = false
- return
- }
- titleRes =
- when {
- httpsPass != null -> R.string.clear_saved_passphrase_https
- else -> R.string.clear_saved_passphrase_ssh
- }
- visible = true
- requestRebind()
- }
- onClick {
- updatePref()
- true
- }
- updatePref()
- }
- pref(PreferenceKeys.SSH_OPENKEYSTORE_CLEAR_KEY_ID) {
- titleRes = R.string.pref_title_openkeystore_clear_keyid
- visible =
- activity.sharedPrefs.getString(PreferenceKeys.SSH_OPENKEYSTORE_KEYID)?.isNotEmpty()
- ?: false
- onClick {
- activity.sharedPrefs.edit { putString(PreferenceKeys.SSH_OPENKEYSTORE_KEYID, null) }
- visible = false
- true
- }
- }
- pref(PreferenceKeys.GIT_DELETE_REPO) {
- titleRes = R.string.pref_git_delete_repo_title
- summaryRes = R.string.pref_git_delete_repo_summary
- onClick {
- val repoDir = PasswordRepository.getRepositoryDirectory()
- MaterialAlertDialogBuilder(activity)
- .setTitle(R.string.pref_dialog_delete_title)
- .setMessage(activity.getString(R.string.dialog_delete_msg, repoDir))
- .setCancelable(false)
- .setPositiveButton(R.string.dialog_delete) { dialogInterface, _ ->
- runCatching {
- PasswordRepository.closeRepository()
- PasswordRepository.getRepositoryDirectory().let { dir ->
- dir.deleteRecursively()
- dir.mkdirs()
- }
- }
- .onFailure { it.message?.let { message -> activity.snackbar(message = message) } }
-
- activity.getSystemService<ShortcutManager>()?.apply {
- removeDynamicShortcuts(dynamicShortcuts.map { it.id }.toMutableList())
- }
- activity.sharedPrefs.edit { putBoolean(PreferenceKeys.REPOSITORY_INITIALIZED, false) }
- dialogInterface.cancel()
- activity.finish()
- }
- .setNegativeButton(R.string.dialog_do_not_delete) { dialogInterface, _ ->
- run { dialogInterface.cancel() }
- }
- .show()
- true
- }
- }
- }
- }
-
- @EntryPoint
- @InstallIn(SingletonComponent::class)
- interface RepositorySettingsEntryPoint {
- fun gitSettings(): GitSettings
-
- @GitPreferences fun encryptedPreferences(): SharedPreferences
- }
-}
diff --git a/app/src/main/java/app/passwordstore/ui/settings/SettingsActivity.kt b/app/src/main/java/app/passwordstore/ui/settings/SettingsActivity.kt
deleted file mode 100644
index 02addd84..00000000
--- a/app/src/main/java/app/passwordstore/ui/settings/SettingsActivity.kt
+++ /dev/null
@@ -1,129 +0,0 @@
-/*
- * Copyright © 2014-2024 The Android Password Store Authors. All Rights Reserved.
- * SPDX-License-Identifier: GPL-3.0-only
- */
-
-package app.passwordstore.ui.settings
-
-import android.os.Bundle
-import android.view.MenuItem
-import androidx.activity.OnBackPressedCallback
-import androidx.appcompat.app.AppCompatActivity
-import androidx.core.os.BundleCompat
-import app.passwordstore.R
-import app.passwordstore.data.crypto.PGPPassphraseCache
-import app.passwordstore.databinding.ActivityPreferenceRecyclerviewBinding
-import app.passwordstore.util.extensions.viewBinding
-import com.google.android.material.dialog.MaterialAlertDialogBuilder
-import dagger.hilt.android.AndroidEntryPoint
-import de.Maxr1998.modernpreferences.Preference
-import de.Maxr1998.modernpreferences.PreferencesAdapter
-import de.Maxr1998.modernpreferences.helpers.screen
-import de.Maxr1998.modernpreferences.helpers.subScreen
-import javax.inject.Inject
-
-@AndroidEntryPoint
-class SettingsActivity : AppCompatActivity() {
-
- @Inject lateinit var passphraseCache: PGPPassphraseCache
- private val miscSettings = MiscSettings(this)
- private val autofillSettings = AutofillSettings(this)
- private val passwordSettings = PasswordSettings(this)
- private val repositorySettings = RepositorySettings(this)
- private val generalSettings = GeneralSettings(this)
- private lateinit var pgpSettings: PGPSettings
-
- private val binding by viewBinding(ActivityPreferenceRecyclerviewBinding::inflate)
- private val preferencesAdapter: PreferencesAdapter
- get() = binding.preferenceRecyclerView.adapter as PreferencesAdapter
-
- override fun onCreate(savedInstanceState: Bundle?) {
- super.onCreate(savedInstanceState)
- setContentView(binding.root)
- Preference.Config.dialogBuilderFactory = { context -> MaterialAlertDialogBuilder(context) }
- pgpSettings = PGPSettings(this, passphraseCache)
- val screen =
- screen(this) {
- subScreen {
- collapseIcon = true
- titleRes = R.string.pref_category_general_title
- iconRes = R.drawable.app_settings_alt_24px
- generalSettings.provideSettings(this)
- }
- subScreen {
- collapseIcon = true
- titleRes = R.string.pref_category_autofill_title
- iconRes = R.drawable.ic_wysiwyg_24px
- autofillSettings.provideSettings(this)
- }
- subScreen {
- collapseIcon = true
- titleRes = R.string.pref_category_passwords_title
- iconRes = R.drawable.ic_password_24px
- passwordSettings.provideSettings(this)
- }
- subScreen {
- collapseIcon = true
- titleRes = R.string.pref_category_repository_title
- iconRes = R.drawable.ic_call_merge_24px
- repositorySettings.provideSettings(this)
- }
- subScreen {
- collapseIcon = true
- titleRes = R.string.pref_category_misc_title
- iconRes = R.drawable.ic_miscellaneous_services_24px
- miscSettings.provideSettings(this)
- }
- subScreen {
- collapseIcon = true
- titleRes = R.string.pref_category_pgp_title
- iconRes = R.drawable.ic_lock_open_24px
- pgpSettings.provideSettings(this)
- }
- }
- val backPressedCallback =
- object : OnBackPressedCallback(true) {
- override fun handleOnBackPressed() {
- preferencesAdapter.goBack()
- }
- }
- onBackPressedDispatcher.addCallback(backPressedCallback)
- val adapter = PreferencesAdapter(screen)
- adapter.onScreenChangeListener =
- PreferencesAdapter.OnScreenChangeListener { subScreen, entering ->
- backPressedCallback.isEnabled = entering
- supportActionBar?.title =
- if (!entering) {
- getString(R.string.action_settings)
- } else {
- getString(subScreen.titleRes)
- }
- }
- if (savedInstanceState != null) {
- BundleCompat.getParcelable(
- savedInstanceState,
- "adapter",
- PreferencesAdapter.SavedState::class.java,
- )
- ?.let(adapter::loadSavedState)
- }
- binding.preferenceRecyclerView.adapter = adapter
- }
-
- override fun onSaveInstanceState(outState: Bundle) {
- super.onSaveInstanceState(outState)
- outState.putParcelable("adapter", preferencesAdapter.getSavedState())
- }
-
- override fun onOptionsItemSelected(item: MenuItem): Boolean {
- return when (item.itemId) {
- android.R.id.home ->
- if (!preferencesAdapter.goBack()) {
- super.onOptionsItemSelected(item)
- } else {
- true
- }
- else -> super.onOptionsItemSelected(item)
- }
- }
-}
diff --git a/app/src/main/java/app/passwordstore/ui/settings/SettingsDestination.kt b/app/src/main/java/app/passwordstore/ui/settings/SettingsDestination.kt
new file mode 100644
index 00000000..9fb25e29
--- /dev/null
+++ b/app/src/main/java/app/passwordstore/ui/settings/SettingsDestination.kt
@@ -0,0 +1,303 @@
+/*
+ * Copyright (C) 2026 Danilo M. <danix@danix.xyz>
+ * SPDX-License-Identifier: GPL-3.0-only
+ */
+
+package app.passwordstore.ui.settings
+
+import android.content.Context
+import android.content.Intent
+import android.content.pm.ShortcutManager
+import android.net.Uri
+import android.provider.Settings
+import androidx.activity.compose.BackHandler
+import androidx.activity.compose.rememberLauncherForActivityResult
+import androidx.activity.result.contract.ActivityResultContracts
+import androidx.annotation.StringRes
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.material3.AlertDialog
+import androidx.compose.material3.Text
+import androidx.compose.material3.TextButton
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.saveable.rememberSaveable
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.font.FontWeight
+import androidx.core.content.getSystemService
+import androidx.core.net.toUri
+import androidx.documentfile.provider.DocumentFile
+import androidx.hilt.navigation.compose.hiltViewModel
+import androidx.lifecycle.compose.LifecycleResumeEffect
+import app.passwordstore.BuildConfig
+import app.passwordstore.R
+import app.passwordstore.ui.compose.theme.SpacingLarge
+import app.passwordstore.ui.main.MainActivity
+import app.passwordstore.ui.setup.SetupDialog
+import app.passwordstore.util.auth.BiometricAuthenticator
+import app.passwordstore.util.auth.BiometricAuthenticator.Result
+import app.passwordstore.util.extensions.autofillManager
+import app.passwordstore.util.extensions.snackbar
+import app.passwordstore.util.features.Feature
+import app.passwordstore.util.services.PasswordExportService
+import app.passwordstore.util.settings.PreferenceKeys
+import com.github.androidpasswordstore.autofillparser.BrowserAutofillSupportLevel
+import com.github.androidpasswordstore.autofillparser.getInstalledBrowsersWithAutofillSupportLevel
+
+private enum class SettingsDialog {
+ Autofill,
+ ClearSecret,
+ DeleteRepository,
+}
+
+/** The export's folder picker, with the persistable write grant `PasswordExportService` needs. */
+private class ExportFolder : ActivityResultContracts.OpenDocumentTree() {
+ override fun createIntent(context: Context, input: Uri?): Intent =
+ super.createIntent(context, input).apply {
+ flags =
+ Intent.FLAG_GRANT_READ_URI_PERMISSION or
+ Intent.FLAG_GRANT_WRITE_URI_PERMISSION or
+ Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION or
+ Intent.FLAG_GRANT_PREFIX_URI_PERMISSION
+ }
+}
+
+private fun MainActivity.autofillEnabled() = autofillManager?.hasEnabledAutofillServices() == true
+
+/**
+ * One settings category: wires [SettingsSectionScreen] to the [viewModel] and to what needs the
+ * activity: screen lock prompts, the autofill service, the export picker, confirmations. [onOpen]
+ * gets the actions that open another screen; [onDeleted] runs once the store was deleted.
+ */
+@Composable
+fun SettingsSectionDestination(
+ activity: MainActivity,
+ section: SettingsSection,
+ onBack: () -> Unit,
+ onOpen: (SettingsAction) -> Unit,
+ onDeleted: () -> Unit,
+ modifier: Modifier = Modifier,
+ viewModel: SettingsViewModel = hiltViewModel(),
+) {
+ var dialog by rememberSaveable { mutableStateOf<SettingsDialog?>(null) }
+ var canAuthenticate by remember {
+ mutableStateOf(BiometricAuthenticator.canAuthenticate(activity))
+ }
+ var autofillEnabled by remember { mutableStateOf(activity.autofillEnabled()) }
+
+ fun refresh() {
+ viewModel.refresh()
+ canAuthenticate = BiometricAuthenticator.canAuthenticate(activity)
+ autofillEnabled = activity.autofillEnabled()
+ }
+
+ // Back from another screen (git server, SSH key, key generation, the system's autofill
+ // settings): rows that depend on it follow, e.g. "View generated public SSH key".
+ LifecycleResumeEffect(Unit) {
+ refresh()
+ onPauseOrDispose {}
+ }
+ // A finished deletion moves on, also when the screen was recreated in between.
+ val deleted = viewModel.deleted
+ LaunchedEffect(deleted) {
+ if (deleted == null) return@LaunchedEffect
+ if (!deleted) activity.snackbar(message = activity.getString(R.string.store_delete_failed))
+ onDeleted()
+ }
+ BackHandler(enabled = viewModel.deleting) {}
+
+ val export =
+ rememberLauncherForActivityResult(ExportFolder()) { uri ->
+ if (uri != null && DocumentFile.fromTreeUri(activity, uri) != null)
+ activity.startForegroundService(
+ Intent(activity, PasswordExportService::class.java)
+ .setAction(PasswordExportService.ACTION_EXPORT_PASSWORD)
+ .putExtra("uri", uri)
+ )
+ }
+
+ // One screen lock prompt at a time: a double tap must not open two. A recreation resets it
+ // (remember), and the old activity's prompt is gone with it.
+ var prompting by remember { mutableStateOf(false) }
+
+ fun authenticate(@StringRes title: Int, onResult: (Result) -> Unit) {
+ if (prompting) return
+ prompting = true
+ BiometricAuthenticator.authenticate(activity, title) { result ->
+ // Retry: a finger was not recognized, the prompt stays open. Errors close it and are
+ // terminal (Failure, Canceled*, HardwareUnavailableOrDisabled).
+ if (result !is Result.Retry) prompting = false
+ onResult(result)
+ }
+ }
+
+ // Values are written only once allowed: a failed or cancelled prompt leaves the stored value,
+ // which the switch shows.
+ fun setBoolean(key: String, value: Boolean) {
+ when (key) {
+ PreferenceKeys.AUTOFILL_ENABLE ->
+ if (value) dialog = SettingsDialog.Autofill
+ else {
+ activity.autofillManager?.disableAutofillServices()
+ refresh()
+ }
+ PreferenceKeys.BIOMETRIC_AUTH_2 -> {
+ fun write() {
+ viewModel.putBoolean(key, value)
+ // Shortcuts were built for the old lock setting, as the old screen knew.
+ activity.getSystemService<ShortcutManager>()?.removeAllDynamicShortcuts()
+ }
+ // Without a screen lock no prompt can open: turning the app lock off needs none.
+ if (!value && !BiometricAuthenticator.canAuthenticate(activity)) write()
+ else
+ authenticate(R.string.biometric_prompt_title) { result ->
+ if (result is Result.Success) write()
+ }
+ }
+ Feature.EnablePGPPassphraseCache.configKey ->
+ if (!BiometricAuthenticator.canAuthenticate(activity)) viewModel.setPassphraseCache(false)
+ else
+ authenticate(
+ if (value) R.string.pref_passphrase_cache_authenticate_enable
+ else R.string.pref_passphrase_cache_authenticate_disable
+ ) { result ->
+ if (result is Result.Success) viewModel.setPassphraseCache(value)
+ }
+ PreferenceKeys.CLEAR_PASSPHRASE_CACHE ->
+ when {
+ value -> viewModel.putBoolean(key, true)
+ !BiometricAuthenticator.canAuthenticate(activity) -> viewModel.resetAutoClear()
+ else ->
+ authenticate(R.string.pref_passphrase_cache_auto_clear_authenticate_disable) { result ->
+ when (result) {
+ is Result.Success -> viewModel.disableAutoClear()
+ is Result.Retry -> Unit
+ else -> viewModel.resetAutoClear()
+ }
+ }
+ }
+ else -> viewModel.putBoolean(key, value)
+ }
+ }
+
+ Box(modifier) {
+ SettingsSectionScreen(
+ section = section,
+ state = viewModel.state,
+ canAuthenticate = canAuthenticate,
+ autofillEnabled = autofillEnabled,
+ deleting = viewModel.deleting,
+ // Nothing else starts while the store is being deleted.
+ onBoolean = { key, value -> if (!viewModel.deleting) setBoolean(key, value) },
+ onString = { key, value -> if (!viewModel.deleting) viewModel.putString(key, value) },
+ onAction = { action ->
+ if (!viewModel.deleting)
+ when (action) {
+ SettingsAction.ClearSecret -> dialog = SettingsDialog.ClearSecret
+ SettingsAction.DeleteRepository -> dialog = SettingsDialog.DeleteRepository
+ SettingsAction.Export -> export.launch(null)
+ else -> onOpen(action)
+ }
+ },
+ onBack = { if (!viewModel.deleting) onBack() },
+ )
+ when (dialog) {
+ null -> Unit
+ SettingsDialog.Autofill ->
+ AutofillDialog(
+ onConfirm = {
+ dialog = null
+ activity.startActivity(
+ Intent(Settings.ACTION_REQUEST_SET_AUTOFILL_SERVICE)
+ .setData("package:${BuildConfig.APPLICATION_ID}".toUri())
+ )
+ },
+ onDismiss = { dialog = null },
+ )
+ SettingsDialog.ClearSecret ->
+ SetupDialog(
+ title = viewModel.state.savedSecret?.let { stringResource(it) },
+ text = stringResource(R.string.clear_saved_secret_message),
+ confirm = stringResource(R.string.dialog_ok),
+ onConfirm = {
+ dialog = null
+ viewModel.clearSavedSecret()
+ },
+ onDismiss = { dialog = null },
+ dismiss = stringResource(R.string.dialog_cancel),
+ )
+ SettingsDialog.DeleteRepository ->
+ SetupDialog(
+ title = stringResource(R.string.pref_dialog_delete_title),
+ text = stringResource(R.string.store_delete_confirm),
+ confirm = stringResource(R.string.dialog_delete),
+ onConfirm = {
+ dialog = null
+ viewModel.deleteRepository()
+ },
+ onDismiss = { dialog = null },
+ dismiss = stringResource(R.string.dialog_do_not_delete),
+ )
+ }
+ }
+}
+
+/** The old autofill instructions: what it does, how to enable it, and the installed browsers. */
+@Composable
+private fun AutofillDialog(
+ onConfirm: () -> Unit,
+ onDismiss: () -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ val context = LocalContext.current
+ val browsers = remember { getInstalledBrowsersWithAutofillSupportLevel(context) }
+ AlertDialog(
+ onDismissRequest = onDismiss,
+ modifier = modifier,
+ title = { Text(stringResource(R.string.pref_autofill_enable_title)) },
+ text = {
+ Column(
+ Modifier.verticalScroll(rememberScrollState()),
+ verticalArrangement = Arrangement.spacedBy(SpacingLarge),
+ ) {
+ Text(stringResource(R.string.oreo_autofill_enable_dialog_description))
+ Text(stringResource(R.string.oreo_autofill_enable_dialog_instructions))
+ Column {
+ Text(
+ stringResource(R.string.oreo_autofill_enable_dialog_installed_browsers),
+ fontWeight = FontWeight.Bold,
+ )
+ browsers.forEach { (label, level) -> Text("$label: ${stringResource(level.label())}") }
+ }
+ }
+ },
+ confirmButton = {
+ TextButton(onClick = onConfirm) { Text(stringResource(R.string.dialog_ok)) }
+ },
+ dismissButton = {
+ TextButton(onClick = onDismiss) { Text(stringResource(R.string.dialog_cancel)) }
+ },
+ )
+}
+
+@StringRes
+private fun BrowserAutofillSupportLevel.label(): Int =
+ when (this) {
+ BrowserAutofillSupportLevel.None -> R.string.oreo_autofill_no_support
+ BrowserAutofillSupportLevel.FlakyFill -> R.string.oreo_autofill_flaky_fill_support
+ BrowserAutofillSupportLevel.PasswordFill -> R.string.oreo_autofill_password_fill_support
+ BrowserAutofillSupportLevel.PasswordFillAndSaveIfNoAccessibility ->
+ R.string.oreo_autofill_password_fill_and_conditional_save_support
+ BrowserAutofillSupportLevel.GeneralFill -> R.string.oreo_autofill_general_fill_support
+ BrowserAutofillSupportLevel.GeneralFillAndSave ->
+ R.string.oreo_autofill_general_fill_and_save_support
+ }
diff --git a/app/src/main/java/app/passwordstore/ui/settings/SettingsGraph.kt b/app/src/main/java/app/passwordstore/ui/settings/SettingsGraph.kt
new file mode 100644
index 00000000..ae4e277b
--- /dev/null
+++ b/app/src/main/java/app/passwordstore/ui/settings/SettingsGraph.kt
@@ -0,0 +1,87 @@
+/*
+ * Copyright (C) 2026 Danilo M. <danix@danix.xyz>
+ * SPDX-License-Identifier: GPL-3.0-only
+ */
+
+package app.passwordstore.ui.settings
+
+import androidx.navigation.NavBackStackEntry
+import androidx.navigation.NavGraphBuilder
+import androidx.navigation.NavHostController
+import androidx.navigation.compose.composable
+import androidx.navigation.toRoute
+import app.passwordstore.ui.main.MainActivity
+import app.passwordstore.ui.setup.GitServerRoute
+import app.passwordstore.ui.setup.PgpKeysRoute
+import app.passwordstore.ui.setup.SshKeyGenRoute
+import app.passwordstore.ui.setup.SshKeyRoute
+import app.passwordstore.ui.setup.WelcomeRoute
+import kotlinx.serialization.Serializable
+
+/** The settings categories. */
+@Serializable data object SettingsRoute
+
+/** One settings category. */
+@Serializable data class SettingsSectionRoute(val section: SettingsSection)
+
+/** Author name and email, and the repository utilities. */
+@Serializable data object GitConfigRoute
+
+/** The store's commit log. */
+@Serializable data object GitLogRoute
+
+/** HTTP(S) proxy settings. */
+@Serializable data object ProxyRoute
+
+/** The settings destinations, and where their rows lead. */
+fun NavGraphBuilder.settingsGraph(activity: MainActivity, nav: NavHostController) {
+ fun onTop(entry: NavBackStackEntry) = nav.currentBackStackEntry?.id == entry.id
+
+ fun close(entry: NavBackStackEntry) {
+ if (onTop(entry)) nav.popBackStack()
+ }
+
+ composable<SettingsRoute> { entry ->
+ SettingsScreen(
+ onSection = { if (onTop(entry)) nav.navigate(SettingsSectionRoute(it)) },
+ onBack = { close(entry) },
+ )
+ }
+ composable<SettingsSectionRoute> { entry ->
+ SettingsSectionDestination(
+ activity = activity,
+ section = entry.toRoute<SettingsSectionRoute>().section,
+ onBack = { close(entry) },
+ onOpen = { action ->
+ if (onTop(entry))
+ when (action) {
+ SettingsAction.GitServer -> nav.navigate(GitServerRoute(clone = false))
+ SettingsAction.Proxy -> nav.navigate(ProxyRoute)
+ SettingsAction.GitConfig -> nav.navigate(GitConfigRoute)
+ SettingsAction.SshKey -> nav.navigate(SshKeyRoute)
+ SettingsAction.SshKeyGen -> nav.navigate(SshKeyGenRoute)
+ SettingsAction.KeyManager -> nav.navigate(PgpKeysRoute())
+ // Handled by the destination itself.
+ SettingsAction.ClearSecret,
+ SettingsAction.DeleteRepository,
+ SettingsAction.Export -> Unit
+ }
+ },
+ onDeleted = {
+ // No store any more: setup starts over, as after a deletion from the list's side.
+ if (onTop(entry)) nav.navigate(WelcomeRoute) { popUpTo(nav.graph.id) { inclusive = true } }
+ },
+ )
+ }
+ composable<GitConfigRoute> { entry ->
+ GitConfigDestination(
+ activity = activity,
+ onClose = { close(entry) },
+ onLog = { if (onTop(entry)) nav.navigate(GitLogRoute) },
+ )
+ }
+ composable<GitLogRoute> { entry -> GitLogDestination(onClose = { close(entry) }) }
+ composable<ProxyRoute> { entry ->
+ ProxyDestination(activity = activity, onClose = { close(entry) })
+ }
+}
diff --git a/app/src/main/java/app/passwordstore/ui/settings/SettingsModel.kt b/app/src/main/java/app/passwordstore/ui/settings/SettingsModel.kt
new file mode 100644
index 00000000..eca6770c
--- /dev/null
+++ b/app/src/main/java/app/passwordstore/ui/settings/SettingsModel.kt
@@ -0,0 +1,79 @@
+/*
+ * Copyright (C) 2026 Danilo M. <danix@danix.xyz>
+ * SPDX-License-Identifier: GPL-3.0-only
+ */
+
+package app.passwordstore.ui.settings
+
+import app.passwordstore.util.extensions.hash
+import app.passwordstore.util.extensions.time
+import app.passwordstore.util.git.GitCommit
+import java.io.IOException
+import logcat.LogPriority.ERROR
+import logcat.asLog
+import logcat.logcat
+import org.eclipse.jgit.api.Git
+import org.eclipse.jgit.api.errors.GitAPIException
+import org.eclipse.jgit.api.errors.JGitInternalException
+import org.eclipse.jgit.lib.Constants
+import org.eclipse.jgit.lib.Repository
+import org.eclipse.jgit.lib.RepositoryState
+
+private const val TAG = "SettingsModel"
+
+/**
+ * The proxy port typed as [text]: -1 when empty (no port, as stored before any was set), null when
+ * it is not a port number from 1 to 65535.
+ */
+fun proxyPort(text: String): Int? {
+ val trimmed = text.trim()
+ if (trimmed.isEmpty()) return -1
+ return trimmed.toIntOrNull()?.takeIf { it in 1..65535 }
+}
+
+/** Where HEAD points, as the git config screen reports it. */
+sealed interface HeadStatus {
+ data class Branch(val name: String) : HeadStatus
+
+ data class Detached(val hash: String) : HeadStatus
+
+ data object Missing : HeadStatus
+}
+
+/** [repo]'s HEAD: a branch (also one without commits yet), a detached commit, or missing. */
+fun headStatus(repo: Repository): HeadStatus =
+ try {
+ val head = repo.findRef(Constants.HEAD)
+ when {
+ head == null -> HeadStatus.Missing
+ head.isSymbolic -> HeadStatus.Branch(Repository.shortenRefName(head.target.name))
+ else ->
+ head.objectId?.let { HeadStatus.Detached(it.abbreviate(8).name()) } ?: HeadStatus.Missing
+ }
+ } catch (e: IOException) {
+ logcat(TAG, ERROR) { e.asLog() }
+ HeadStatus.Missing
+ }
+
+/** Whether a rebase or a merge is in progress, so "Abort rebase" has something to abort. */
+fun needsAbort(repo: Repository): Boolean =
+ repo.repositoryState.let { it.isRebasing || it == RepositoryState.MERGING }
+
+/**
+ * The commits reachable from HEAD, newest first; empty without a repository or a commit. Blocking:
+ * call it off the main thread.
+ */
+// ponytail: the whole log at once, as the old screen did (tested there with 1000 commits); page it
+// if a store's history ever makes this slow.
+fun gitLog(repo: Repository?): List<GitCommit> {
+ if (repo == null) return emptyList()
+ return try {
+ Git(repo).log().call().map { GitCommit(it.hash, it.shortMessage, it.authorIdent.name, it.time) }
+ } catch (e: GitAPIException) {
+ logcat(TAG, ERROR) { e.asLog() }
+ emptyList()
+ } catch (e: JGitInternalException) {
+ logcat(TAG, ERROR) { e.asLog() }
+ emptyList()
+ }
+}
diff --git a/app/src/main/java/app/passwordstore/ui/settings/SettingsProvider.kt b/app/src/main/java/app/passwordstore/ui/settings/SettingsProvider.kt
deleted file mode 100644
index cacca80f..00000000
--- a/app/src/main/java/app/passwordstore/ui/settings/SettingsProvider.kt
+++ /dev/null
@@ -1,15 +0,0 @@
-/*
- * Copyright © 2014-2024 The Android Password Store Authors. All Rights Reserved.
- * SPDX-License-Identifier: GPL-3.0-only
- */
-
-package app.passwordstore.ui.settings
-
-import de.Maxr1998.modernpreferences.PreferenceScreen
-
-/** Used to generate a uniform API for all settings UI classes. */
-interface SettingsProvider {
-
- /** Inserts the settings items for the class into the given [builder]. */
- fun provideSettings(builder: PreferenceScreen.Builder)
-}
diff --git a/app/src/main/java/app/passwordstore/ui/settings/SettingsScreen.kt b/app/src/main/java/app/passwordstore/ui/settings/SettingsScreen.kt
new file mode 100644
index 00000000..7d45ad74
--- /dev/null
+++ b/app/src/main/java/app/passwordstore/ui/settings/SettingsScreen.kt
@@ -0,0 +1,349 @@
+/*
+ * Copyright (C) 2026 Danilo M. <danix@danix.xyz>
+ * SPDX-License-Identifier: GPL-3.0-only
+ */
+
+package app.passwordstore.ui.settings
+
+import androidx.annotation.DrawableRes
+import androidx.annotation.Keep
+import androidx.annotation.StringRes
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.ColumnScope
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.automirrored.filled.ArrowBack
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.ListItem
+import androidx.compose.material3.Scaffold
+import androidx.compose.material3.Text
+import androidx.compose.material3.TopAppBar
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.res.painterResource
+import androidx.compose.ui.res.stringArrayResource
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.semantics.Role
+import androidx.compose.ui.text.input.KeyboardType
+import app.passwordstore.BuildConfig
+import app.passwordstore.R
+import app.passwordstore.ui.compose.GroupedSurface
+import app.passwordstore.util.features.Feature
+import app.passwordstore.util.settings.PreferenceKeys
+import kotlinx.collections.immutable.persistentListOf
+import kotlinx.serialization.Serializable
+
+/** The settings categories, one screen each. A route argument: kept from minification. */
+@Keep
+@Serializable
+enum class SettingsSection(@StringRes val title: Int, @DrawableRes val icon: Int) {
+ General(R.string.pref_category_general_title, R.drawable.app_settings_alt_24px),
+ Autofill(R.string.pref_category_autofill_title, R.drawable.ic_wysiwyg_24px),
+ Passwords(R.string.pref_category_passwords_title, R.drawable.ic_password_24px),
+ Repository(R.string.pref_category_repository_title, R.drawable.ic_call_merge_24px),
+ Misc(R.string.pref_category_misc_title, R.drawable.ic_miscellaneous_services_24px),
+ Pgp(R.string.pref_category_pgp_title, R.drawable.ic_lock_open_24px),
+}
+
+/** What a row asks for beyond storing a value: another screen, a dialog or a picker. */
+enum class SettingsAction {
+ GitServer,
+ Proxy,
+ GitConfig,
+ SshKey,
+ SshKeyGen,
+ KeyManager,
+ ClearSecret,
+ DeleteRepository,
+ Export,
+}
+
+/** Top bar with [title] and back, then [content] in one grouped surface, scrolling. */
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+private fun SettingsScaffold(
+ title: String,
+ onBack: () -> Unit,
+ modifier: Modifier = Modifier,
+ content: @Composable ColumnScope.() -> Unit,
+) {
+ Scaffold(
+ modifier = modifier,
+ topBar = {
+ TopAppBar(
+ title = { Text(title) },
+ navigationIcon = {
+ IconButton(onClick = onBack) {
+ Icon(Icons.AutoMirrored.Filled.ArrowBack, stringResource(R.string.entry_back))
+ }
+ },
+ )
+ },
+ ) { padding ->
+ Column(Modifier.padding(padding).fillMaxSize().verticalScroll(rememberScrollState())) {
+ GroupedSurface(content = content)
+ }
+ }
+}
+
+/** The settings categories. */
+@Composable
+fun SettingsScreen(
+ onSection: (SettingsSection) -> Unit,
+ onBack: () -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ SettingsScaffold(stringResource(R.string.action_settings), onBack, modifier) {
+ SettingsSection.entries.forEach { section ->
+ ListItem(
+ headlineContent = { Text(stringResource(section.title)) },
+ modifier = Modifier.clickable(role = Role.Button) { onSection(section) },
+ leadingContent = { Icon(painterResource(section.icon), contentDescription = null) },
+ colors = rowColors(),
+ )
+ }
+ }
+}
+
+/**
+ * One category's rows. Switches and values go out through [onBoolean] and [onString] with their
+ * preference key (the destination decides which need a prompt first); everything else through
+ * [onAction]. [canAuthenticate] and [autofillEnabled] come from the activity.
+ */
+@Composable
+fun SettingsSectionScreen(
+ section: SettingsSection,
+ state: SettingsState,
+ canAuthenticate: Boolean,
+ autofillEnabled: Boolean,
+ deleting: Boolean,
+ onBoolean: (key: String, value: Boolean) -> Unit,
+ onString: (key: String, value: String) -> Unit,
+ onAction: (SettingsAction) -> Unit,
+ onBack: () -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ SettingsScaffold(stringResource(section.title), onBack, modifier) {
+ when (section) {
+ SettingsSection.General -> {
+ PreferenceChoice(
+ title = stringResource(R.string.pref_app_theme_title),
+ values = persistentListOf(*stringArrayResource(R.array.app_theme_values)),
+ labels = persistentListOf(*stringArrayResource(R.array.app_theme_options)),
+ selected = state.theme,
+ onSelect = { onString(PreferenceKeys.APP_THEME, it) },
+ )
+ PreferenceChoice(
+ title = stringResource(R.string.pref_directory_structure),
+ values = persistentListOf(*stringArrayResource(R.array.directory_structure_values)),
+ labels = persistentListOf(*stringArrayResource(R.array.directory_structure_entries)),
+ selected = state.directoryStructure,
+ onSelect = { onString(PreferenceKeys.DIRECTORY_STRUCTURE, it) },
+ )
+ PreferenceChoice(
+ title = stringResource(R.string.pref_sort_order_title),
+ values = persistentListOf(*stringArrayResource(R.array.sort_order_values)),
+ labels = persistentListOf(*stringArrayResource(R.array.sort_order_entries)),
+ selected = state.sortOrder,
+ onSelect = { onString(PreferenceKeys.SORT_ORDER, it) },
+ )
+ PreferenceSwitch(
+ title = stringResource(R.string.pref_disable_sync_on_pull_title),
+ checked = state.disableSync,
+ onCheckedChange = { onBoolean(PreferenceKeys.DISABLE_SYNC_ACTION, it) },
+ summary = stringResource(R.string.pref_disable_sync_on_pull_summary),
+ )
+ PreferenceSwitch(
+ title = stringResource(R.string.pref_recursive_filter_title),
+ checked = state.filterRecursively,
+ onCheckedChange = { onBoolean(PreferenceKeys.FILTER_RECURSIVELY, it) },
+ summary = stringResource(R.string.pref_recursive_filter_summary),
+ )
+ PreferenceSwitch(
+ title = stringResource(R.string.pref_search_on_start_title),
+ checked = state.searchOnStart,
+ onCheckedChange = { onBoolean(PreferenceKeys.SEARCH_ON_START, it) },
+ summary = stringResource(R.string.pref_search_on_start_summary),
+ )
+ PreferenceSwitch(
+ title = stringResource(R.string.pref_show_hidden_title),
+ checked = state.showHidden,
+ onCheckedChange = { onBoolean(PreferenceKeys.SHOW_HIDDEN_CONTENTS, it) },
+ summary = stringResource(R.string.pref_show_hidden_summary),
+ )
+ // Enabled on Android 14 too (maintainer decision 4): upstream blocked it there for issue
+ // 2802; if that platform bug shows on the maintainer's Android 14 tablet, the block
+ // returns.
+ PreferenceSwitch(
+ title = stringResource(R.string.pref_biometric_auth_title),
+ checked = state.biometric,
+ onCheckedChange = { onBoolean(PreferenceKeys.BIOMETRIC_AUTH_2, it) },
+ summary =
+ stringResource(
+ if (canAuthenticate) R.string.pref_biometric_auth_summary
+ else R.string.pref_biometric_auth_summary_error
+ ),
+ )
+ }
+ SettingsSection.Autofill -> {
+ PreferenceSwitch(
+ title = stringResource(R.string.pref_autofill_enable_title),
+ checked = autofillEnabled,
+ onCheckedChange = { onBoolean(PreferenceKeys.AUTOFILL_ENABLE, it) },
+ )
+ PreferenceText(
+ title = stringResource(R.string.preference_default_username_title),
+ value = state.defaultUsername,
+ onValueChange = { onString(PreferenceKeys.OREO_AUTOFILL_DEFAULT_USERNAME, it) },
+ summary = stringResource(R.string.preference_default_username_summary),
+ enabled = autofillEnabled,
+ )
+ PreferenceText(
+ title = stringResource(R.string.preference_custom_public_suffixes_title),
+ value = state.customSuffixes,
+ onValueChange = { onString(PreferenceKeys.OREO_AUTOFILL_CUSTOM_PUBLIC_SUFFIXES, it) },
+ summary = stringResource(R.string.preference_custom_public_suffixes_summary),
+ hint = stringResource(R.string.preference_custom_public_suffixes_hint),
+ singleLine = false,
+ enabled = autofillEnabled,
+ )
+ }
+ SettingsSection.Passwords -> {
+ PreferenceChoice(
+ title = stringResource(R.string.pref_password_generator_type_title),
+ values = persistentListOf(*stringArrayResource(R.array.pwgen_provider_values)),
+ labels = persistentListOf(*stringArrayResource(R.array.pwgen_provider_labels)),
+ selected = state.pwgenType,
+ onSelect = { onString(PreferenceKeys.PREF_KEY_PWGEN_TYPE, it) },
+ )
+ PreferenceText(
+ title = stringResource(R.string.pref_clipboard_timeout_title),
+ value = state.clipboardTimeout,
+ onValueChange = { onString(PreferenceKeys.GENERAL_SHOW_TIME, it) },
+ summary =
+ stringResource(
+ R.string.pref_clipboard_timeout_summary,
+ state.clipboardTimeout.ifEmpty { "45" },
+ ),
+ keyboardType = KeyboardType.Number,
+ )
+ PreferenceSwitch(
+ title = stringResource(R.string.show_password_pref_title),
+ checked = state.showPassword,
+ onCheckedChange = { onBoolean(PreferenceKeys.SHOW_PASSWORD, it) },
+ summary = stringResource(R.string.show_password_pref_summary),
+ )
+ PreferenceSwitch(
+ title = stringResource(R.string.pref_copy_title),
+ checked = state.copyOnDecrypt,
+ onCheckedChange = { onBoolean(PreferenceKeys.COPY_ON_DECRYPT, it) },
+ summary = stringResource(R.string.pref_copy_summary),
+ )
+ }
+ SettingsSection.Repository -> {
+ PreferenceSwitch(
+ title = stringResource(R.string.pref_rebase_on_pull_title),
+ checked = state.rebaseOnPull,
+ onCheckedChange = { onBoolean(PreferenceKeys.REBASE_ON_PULL, it) },
+ summary =
+ stringResource(
+ if (state.rebaseOnPull) R.string.pref_rebase_on_pull_summary_on
+ else R.string.pref_rebase_on_pull_summary
+ ),
+ )
+ if (state.isGitRepo) {
+ PreferenceAction(
+ title = stringResource(R.string.pref_edit_git_server_settings),
+ onClick = { onAction(SettingsAction.GitServer) },
+ )
+ if (state.httpsRemote)
+ PreferenceAction(
+ title = stringResource(R.string.pref_edit_proxy_settings),
+ onClick = { onAction(SettingsAction.Proxy) },
+ )
+ PreferenceAction(
+ title = stringResource(R.string.pref_edit_git_config),
+ onClick = { onAction(SettingsAction.GitConfig) },
+ )
+ PreferenceAction(
+ title = stringResource(R.string.pref_import_ssh_key_title),
+ onClick = { onAction(SettingsAction.SshKey) },
+ )
+ }
+ PreferenceAction(
+ title = stringResource(R.string.pref_ssh_keygen_title),
+ onClick = { onAction(SettingsAction.SshKeyGen) },
+ )
+ if (state.isGitRepo && state.canShowSshKey)
+ PreferenceAction(
+ title = stringResource(R.string.pref_ssh_see_key_title),
+ onClick = { onAction(SettingsAction.SshKey) },
+ )
+ state.savedSecret?.let { title ->
+ PreferenceAction(
+ title = stringResource(title),
+ onClick = { onAction(SettingsAction.ClearSecret) },
+ )
+ }
+ PreferenceAction(
+ title = stringResource(R.string.pref_git_delete_repo_title),
+ onClick = { onAction(SettingsAction.DeleteRepository) },
+ summary = stringResource(R.string.pref_git_delete_repo_summary),
+ enabled = !deleting,
+ )
+ }
+ SettingsSection.Misc -> {
+ PreferenceAction(
+ title = stringResource(R.string.prefs_export_passwords_title),
+ onClick = { onAction(SettingsAction.Export) },
+ summary = stringResource(R.string.prefs_export_passwords_summary),
+ )
+ PreferenceSwitch(
+ title = stringResource(R.string.pref_clear_clipboard_title),
+ checked = state.clearClipboardHistory,
+ onCheckedChange = { onBoolean(PreferenceKeys.CLEAR_CLIPBOARD_HISTORY, it) },
+ summary = stringResource(R.string.pref_clear_clipboard_summary),
+ )
+ if (!BuildConfig.DEBUG)
+ PreferenceSwitch(
+ title = stringResource(R.string.pref_debug_logging_title),
+ checked = state.debugLogging,
+ onCheckedChange = { onBoolean(PreferenceKeys.ENABLE_DEBUG_LOGGING, it) },
+ summary = stringResource(R.string.pref_debug_logging_summary),
+ )
+ }
+ SettingsSection.Pgp -> {
+ PreferenceAction(
+ title = stringResource(R.string.pref_pgp_key_manager_title),
+ onClick = { onAction(SettingsAction.KeyManager) },
+ )
+ PreferenceSwitch(
+ title = stringResource(R.string.pref_pgp_ascii_armor_title),
+ checked = state.asciiArmor,
+ onCheckedChange = { onBoolean(PreferenceKeys.ASCII_ARMOR, it) },
+ )
+ PreferenceSwitch(
+ title = stringResource(R.string.pref_passphrase_cache_title),
+ checked = state.passphraseCache,
+ onCheckedChange = { onBoolean(Feature.EnablePGPPassphraseCache.configKey, it) },
+ summary = stringResource(R.string.pref_passphrase_cache_summary),
+ // Without a screen lock it can still be turned off.
+ enabled = canAuthenticate || state.passphraseCache,
+ )
+ PreferenceSwitch(
+ title = stringResource(R.string.pref_passphrase_cache_auto_clear_title),
+ checked = state.autoClearCache,
+ onCheckedChange = { onBoolean(PreferenceKeys.CLEAR_PASSPHRASE_CACHE, it) },
+ summary = stringResource(R.string.pref_passphrase_cache_auto_clear_summary),
+ enabled = state.passphraseCache,
+ )
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/app/passwordstore/ui/settings/SettingsViewModel.kt b/app/src/main/java/app/passwordstore/ui/settings/SettingsViewModel.kt
new file mode 100644
index 00000000..ef604f1f
--- /dev/null
+++ b/app/src/main/java/app/passwordstore/ui/settings/SettingsViewModel.kt
@@ -0,0 +1,226 @@
+/*
+ * Copyright (C) 2026 Danilo M. <danix@danix.xyz>
+ * SPDX-License-Identifier: GPL-3.0-only
+ */
+
+package app.passwordstore.ui.settings
+
+import android.app.Application
+import android.content.SharedPreferences
+import android.content.pm.ShortcutManager
+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.core.content.getSystemService
+import androidx.lifecycle.AndroidViewModel
+import androidx.lifecycle.viewModelScope
+import app.passwordstore.R
+import app.passwordstore.data.crypto.PGPPassphraseCache
+import app.passwordstore.data.repo.PasswordRepository
+import app.passwordstore.injection.prefs.GitPreferences
+import app.passwordstore.injection.prefs.SettingsPreferences
+import app.passwordstore.ui.list.deleteWithoutFollowingLinks
+import app.passwordstore.util.coroutines.DispatcherProvider
+import app.passwordstore.util.extensions.getString
+import app.passwordstore.util.features.Feature
+import app.passwordstore.util.git.sshj.SshKey
+import app.passwordstore.util.settings.DirectoryStructure
+import app.passwordstore.util.settings.GitSettings
+import app.passwordstore.util.settings.PasswordSortOrder
+import app.passwordstore.util.settings.PreferenceKeys
+import dagger.hilt.android.lifecycle.HiltViewModel
+import java.nio.file.Files
+import java.nio.file.LinkOption
+import javax.inject.Inject
+import kotlinx.coroutines.NonCancellable
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.withContext
+
+/** What the settings sections show: stored values, and the state rows depend on. */
+data class SettingsState(
+ val theme: String,
+ val directoryStructure: String,
+ val sortOrder: String,
+ val disableSync: Boolean,
+ val filterRecursively: Boolean,
+ val searchOnStart: Boolean,
+ val showHidden: Boolean,
+ val biometric: Boolean,
+ val defaultUsername: String,
+ val customSuffixes: String,
+ val pwgenType: String,
+ val clipboardTimeout: String,
+ val showPassword: Boolean,
+ val copyOnDecrypt: Boolean,
+ val rebaseOnPull: Boolean,
+ val isGitRepo: Boolean,
+ val httpsRemote: Boolean,
+ val canShowSshKey: Boolean,
+ /** Title of the "clear saved credential" row, or null when none is saved. */
+ @StringRes val savedSecret: Int?,
+ val clearClipboardHistory: Boolean,
+ val debugLogging: Boolean,
+ val asciiArmor: Boolean,
+ val passphraseCache: Boolean,
+ val autoClearCache: Boolean,
+)
+
+/**
+ * One settings section's state over the existing preference keys. [state] is re-read on every
+ * change of the settings and on [refresh] (the destination calls it on resume, so changes made on
+ * other screens, such as a new SSH key, show at once).
+ */
+@HiltViewModel
+class SettingsViewModel
+@Inject
+constructor(
+ application: Application,
+ @SettingsPreferences private val prefs: SharedPreferences,
+ @GitPreferences private val gitPrefs: SharedPreferences,
+ private val gitSettings: GitSettings,
+ private val passphraseCache: PGPPassphraseCache,
+ private val dispatcherProvider: DispatcherProvider,
+) : AndroidViewModel(application) {
+
+ var state by mutableStateOf(read())
+ private set
+
+ /** True while "Delete repository" runs. */
+ var deleting by mutableStateOf(false)
+ private set
+
+ /** Null until "Delete repository" ran; then whether the whole store folder was deleted. */
+ var deleted by mutableStateOf<Boolean?>(null)
+ private set
+
+ // A field: SharedPreferences holds its listeners weakly.
+ private val listener = SharedPreferences.OnSharedPreferenceChangeListener { _, _ -> refresh() }
+
+ init {
+ prefs.registerOnSharedPreferenceChangeListener(listener)
+ }
+
+ override fun onCleared() {
+ prefs.unregisterOnSharedPreferenceChangeListener(listener)
+ }
+
+ fun refresh() {
+ state = read()
+ }
+
+ fun putBoolean(key: String, value: Boolean) {
+ prefs.edit { putBoolean(key, value) }
+ }
+
+ /** Stores [value] under [key]; an empty value removes the key, so readers use their default. */
+ fun putString(key: String, value: String) {
+ prefs.edit { if (value.isEmpty()) remove(key) else putString(key, value) }
+ }
+
+ /** Turns the passphrase cache on or off (after the screen lock prompt); it starts empty. */
+ fun setPassphraseCache(enabled: Boolean) {
+ prefs.edit {
+ putBoolean(Feature.EnablePGPPassphraseCache.configKey, enabled)
+ if (!enabled) remove(PreferenceKeys.CLEAR_PASSPHRASE_CACHE)
+ }
+ clearCachedPassphrases()
+ }
+
+ /** Stops clearing the cache when the screen goes off; the cache is emptied once now. */
+ fun disableAutoClear() {
+ putBoolean(PreferenceKeys.CLEAR_PASSPHRASE_CACHE, false)
+ clearCachedPassphrases()
+ }
+
+ /** Back to the default: the cache is cleared when the screen goes off. */
+ fun resetAutoClear() {
+ prefs.edit { remove(PreferenceKeys.CLEAR_PASSPHRASE_CACHE) }
+ }
+
+ /** Forgets the credential the "clear" row names: the HTTPS password, else the SSH passphrase. */
+ fun clearSavedSecret() {
+ val key =
+ if (gitPrefs.contains(PreferenceKeys.HTTPS_PASSWORD)) PreferenceKeys.HTTPS_PASSWORD
+ else PreferenceKeys.SSH_KEY_LOCAL_PASSPHRASE
+ gitPrefs.edit { remove(key) }
+ refresh()
+ }
+
+ /**
+ * Empties the store folder and marks setup as not done, then sets [deleted]. In [viewModelScope]
+ * and [NonCancellable], so neither a recreation nor the ViewModel's end can stop it half way (a
+ * half-deleted store still marked as set up). Never follows a symlink: `deleteRecursively()`
+ * would, and a `store/link -> ..` link would take the PGP and SSH keys with it.
+ */
+ fun deleteRepository() {
+ if (deleting) return
+ deleting = true
+ viewModelScope.launch {
+ withContext(NonCancellable) {
+ val root = PasswordRepository.getRepositoryDirectory()
+ val gone =
+ withContext(dispatcherProvider.io()) {
+ PasswordRepository.closeRepository()
+ val ok =
+ Files.notExists(root.toPath(), LinkOption.NOFOLLOW_LINKS) ||
+ root.deleteWithoutFollowingLinks()
+ root.mkdirs()
+ ok
+ }
+ getApplication<Application>()
+ .getSystemService<ShortcutManager>()
+ ?.removeAllDynamicShortcuts()
+ prefs.edit { putBoolean(PreferenceKeys.REPOSITORY_INITIALIZED, false) }
+ deleted = gone
+ }
+ }
+ }
+
+ // NonCancellable: leaving the screen right after the switch must not skip the clearing.
+ private fun clearCachedPassphrases() {
+ viewModelScope.launch(NonCancellable) {
+ passphraseCache.clearAllCachedPassphrases(getApplication())
+ }
+ }
+
+ private fun read(): SettingsState {
+ val app = getApplication<Application>()
+ return SettingsState(
+ theme = prefs.getString(PreferenceKeys.APP_THEME) ?: app.getString(R.string.app_theme_def),
+ directoryStructure =
+ prefs.getString(PreferenceKeys.DIRECTORY_STRUCTURE) ?: DirectoryStructure.DEFAULT.value,
+ sortOrder = PasswordSortOrder.getSortOrder(prefs).name,
+ disableSync = prefs.getBoolean(PreferenceKeys.DISABLE_SYNC_ACTION, false),
+ filterRecursively = prefs.getBoolean(PreferenceKeys.FILTER_RECURSIVELY, true),
+ searchOnStart = prefs.getBoolean(PreferenceKeys.SEARCH_ON_START, false),
+ showHidden = prefs.getBoolean(PreferenceKeys.SHOW_HIDDEN_CONTENTS, false),
+ biometric = prefs.getBoolean(PreferenceKeys.BIOMETRIC_AUTH_2, false),
+ defaultUsername = prefs.getString(PreferenceKeys.OREO_AUTOFILL_DEFAULT_USERNAME).orEmpty(),
+ customSuffixes =
+ prefs.getString(PreferenceKeys.OREO_AUTOFILL_CUSTOM_PUBLIC_SUFFIXES).orEmpty(),
+ pwgenType = prefs.getString(PreferenceKeys.PREF_KEY_PWGEN_TYPE) ?: "diceware",
+ clipboardTimeout = prefs.getString(PreferenceKeys.GENERAL_SHOW_TIME).orEmpty(),
+ showPassword = prefs.getBoolean(PreferenceKeys.SHOW_PASSWORD, true),
+ copyOnDecrypt = prefs.getBoolean(PreferenceKeys.COPY_ON_DECRYPT, false),
+ rebaseOnPull = gitSettings.rebaseOnPull,
+ isGitRepo = PasswordRepository.isGitRepo(),
+ httpsRemote = gitSettings.url?.startsWith("https") == true,
+ canShowSshKey = SshKey.canShowSshPublicKey,
+ // contains, never getString: the secret is not decrypted to decide whether to show a row.
+ savedSecret =
+ when {
+ gitPrefs.contains(PreferenceKeys.HTTPS_PASSWORD) -> R.string.clear_saved_passphrase_https
+ gitPrefs.contains(PreferenceKeys.SSH_KEY_LOCAL_PASSPHRASE) ->
+ R.string.clear_saved_passphrase_ssh
+ else -> null
+ },
+ clearClipboardHistory = prefs.getBoolean(PreferenceKeys.CLEAR_CLIPBOARD_HISTORY, false),
+ debugLogging = prefs.getBoolean(PreferenceKeys.ENABLE_DEBUG_LOGGING, false),
+ asciiArmor = prefs.getBoolean(PreferenceKeys.ASCII_ARMOR, false),
+ passphraseCache = prefs.getBoolean(Feature.EnablePGPPassphraseCache.configKey, false),
+ autoClearCache = prefs.getBoolean(PreferenceKeys.CLEAR_PASSPHRASE_CACHE, true),
+ )
+ }
+}
diff --git a/app/src/main/java/app/passwordstore/ui/setup/SetupGraph.kt b/app/src/main/java/app/passwordstore/ui/setup/SetupGraph.kt
index ffa47511..40dfae74 100644
--- a/app/src/main/java/app/passwordstore/ui/setup/SetupGraph.kt
+++ b/app/src/main/java/app/passwordstore/ui/setup/SetupGraph.kt
@@ -13,6 +13,7 @@ import androidx.navigation.compose.composable
import androidx.navigation.toRoute
import app.passwordstore.ui.main.ListRoute
import app.passwordstore.ui.main.MainActivity
+import app.passwordstore.ui.settings.SettingsRoute
import kotlinx.serialization.Serializable
/** The first screen without a store. */
@@ -32,10 +33,8 @@ import kotlinx.serialization.Serializable
/** The setup screens [MainActivity.setupIntent] opens on their own. */
enum class SetupStart(val route: Any) {
- GitServer(GitServerRoute(clone = false)),
Ssh(SshKeyRoute),
SshGenerate(SshKeyGenRoute),
- PgpKeys(PgpKeysRoute()),
PgpImport(PgpKeysRoute(KeysMode.Need)),
}
@@ -63,7 +62,7 @@ fun NavGraphBuilder.setupGraph(activity: MainActivity, nav: NavHostController) {
WelcomeScreen(
onClone = { if (onTop(entry)) nav.navigate(GitServerRoute(clone = true)) },
onCreate = { if (onTop(entry)) nav.navigate(PgpKeysRoute(KeysMode.Choose)) },
- onSettings = activity::openSettings,
+ onSettings = { if (onTop(entry)) nav.navigate(SettingsRoute) },
)
}
composable<GitServerRoute> { entry ->
diff --git a/app/src/main/java/app/passwordstore/util/auth/BiometricAuthenticator.kt b/app/src/main/java/app/passwordstore/util/auth/BiometricAuthenticator.kt
index 4aad4588..98f2229a 100644
--- a/app/src/main/java/app/passwordstore/util/auth/BiometricAuthenticator.kt
+++ b/app/src/main/java/app/passwordstore/util/auth/BiometricAuthenticator.kt
@@ -46,7 +46,7 @@ object BiometricAuthenticator {
/**
* An incorrect biometric was entered, but the prompt UI is offering the option to retry the
- * operation.
+ * operation. The prompt is still open; a terminal result follows.
*/
data object Retry : Result()
@@ -100,7 +100,8 @@ object BiometricAuthenticator {
when (errorCode) {
/** Keep in sync with [androidx.biometric.BiometricPrompt.AuthenticationError] */
BiometricPrompt.ERROR_HW_UNAVAILABLE -> callback(Result.HardwareUnavailableOrDisabled)
- BiometricPrompt.ERROR_UNABLE_TO_PROCESS -> callback(Result.Retry)
+ // An error always closes the prompt, so none of them is a Retry.
+ BiometricPrompt.ERROR_UNABLE_TO_PROCESS,
BiometricPrompt.ERROR_TIMEOUT ->
callback(
Result.Failure(
diff --git a/app/src/main/java/app/passwordstore/util/git/GitLogModel.kt b/app/src/main/java/app/passwordstore/util/git/GitLogModel.kt
deleted file mode 100644
index 329db6d5..00000000
--- a/app/src/main/java/app/passwordstore/util/git/GitLogModel.kt
+++ /dev/null
@@ -1,60 +0,0 @@
-/*
- * Copyright © 2014-2024 The Android Password Store Authors. All Rights Reserved.
- * SPDX-License-Identifier: GPL-3.0-only
- */
-
-package app.passwordstore.util.git
-
-import app.passwordstore.data.repo.PasswordRepository
-import app.passwordstore.util.extensions.asLog
-import app.passwordstore.util.extensions.hash
-import app.passwordstore.util.extensions.time
-import app.passwordstore.util.extensions.unsafeLazy
-import com.github.michaelbull.result.getOrElse
-import com.github.michaelbull.result.runCatching
-import logcat.LogPriority.ERROR
-import logcat.logcat
-import org.eclipse.jgit.api.Git
-import org.eclipse.jgit.revwalk.RevCommit
-
-private val TAG = GitLogModel::class.java.simpleName
-
-private fun commits(): Iterable<RevCommit> {
- val repo = PasswordRepository.repository
- if (repo == null) {
- logcat(TAG, ERROR) { "Could not access git repository" }
- return listOf()
- }
- return runCatching { Git(repo).log().call() }
- .getOrElse { e ->
- logcat(TAG, ERROR) { e.asLog("Failed to obtain git commits") }
- listOf()
- }
-}
-
-/**
- * Provides [GitCommit] s from a git-log of the password git repository.
- *
- * All commits are acquired on the first request to this object.
- */
-class GitLogModel {
-
- // All commits are acquired here at once. Acquiring the commits in batches would not have been
- // entirely sensible because the amount of computation required to obtain commit number n from
- // the log includes the amount of computation required to obtain commit number n-1 from the log.
- // This is because the commit graph is walked from HEAD to the last commit to obtain.
- // Additionally, tests with 1000 commits in the log have not produced a significant delay in the
- // user experience.
- private val cache: MutableList<GitCommit> by unsafeLazy {
- commits()
- .map { GitCommit(it.hash, it.shortMessage, it.authorIdent.name, it.time) }
- .toMutableList()
- }
- val size = cache.size
-
- fun get(index: Int): GitCommit? {
- if (index >= size)
- logcat(ERROR) { "Cannot get git commit with index $index. There are only $size." }
- return cache.getOrNull(index)
- }
-}
diff --git a/app/src/main/java/app/passwordstore/util/git/operation/BreakOutOfDetached.kt b/app/src/main/java/app/passwordstore/util/git/operation/BreakOutOfDetached.kt
index d55d9970..4b4913d0 100644
--- a/app/src/main/java/app/passwordstore/util/git/operation/BreakOutOfDetached.kt
+++ b/app/src/main/java/app/passwordstore/util/git/operation/BreakOutOfDetached.kt
@@ -57,9 +57,8 @@ class BreakOutOfDetached(callingActivity: AppCompatActivity) : GitOperation(call
.setMessage(
callingActivity.resources.getString(R.string.git_break_out_of_detached_unneeded)
)
- .setPositiveButton(callingActivity.resources.getString(R.string.dialog_ok)) { _, _ ->
- callingActivity.finish()
- }
+ // Only closes the dialog: the calling activity is the app's main screen.
+ .setPositiveButton(callingActivity.resources.getString(R.string.dialog_ok), null)
.show()
false
} else {
diff --git a/app/src/main/java/app/passwordstore/util/git/operation/GitOperation.kt b/app/src/main/java/app/passwordstore/util/git/operation/GitOperation.kt
index 2d8b46ca..25d99d6d 100644
--- a/app/src/main/java/app/passwordstore/util/git/operation/GitOperation.kt
+++ b/app/src/main/java/app/passwordstore/util/git/operation/GitOperation.kt
@@ -12,9 +12,7 @@ import app.passwordstore.data.repo.PasswordRepository
import app.passwordstore.ui.main.MainActivity
import app.passwordstore.ui.setup.SetupStart
import app.passwordstore.util.auth.BiometricAuthenticator
-import app.passwordstore.util.auth.BiometricAuthenticator.Result.CanceledBySystem
import app.passwordstore.util.auth.BiometricAuthenticator.Result.CanceledByUser
-import app.passwordstore.util.auth.BiometricAuthenticator.Result.Failure
import app.passwordstore.util.auth.BiometricAuthenticator.Result.Retry
import app.passwordstore.util.auth.BiometricAuthenticator.Result.Success
import app.passwordstore.util.coroutines.DispatcherProvider
@@ -163,7 +161,9 @@ abstract class GitOperation(protected val callingActivity: FragmentActivity) {
callingActivity,
R.string.biometric_prompt_title_ssh_auth,
) { result ->
- if (result !is Failure && result !is Retry) cont.resume(result)
+ // Retry: a failed attempt while the prompt stays open. Every other result
+ // closes it, so waiting for more would leave the operation hanging.
+ if (result !is Retry) cont.resume(result)
}
}
}
@@ -174,10 +174,6 @@ abstract class GitOperation(protected val callingActivity: FragmentActivity) {
is CanceledByUser -> {
return Err(SSHException(DisconnectReason.AUTH_CANCELLED_BY_USER))
}
- is Failure,
- is CanceledBySystem -> {
- throw IllegalStateException("Biometric authentication failures should be ignored")
- }
else -> {
// There is a chance we succeed if the user recently confirmed
// their screen lock. Doing so would have a potential to confuse
diff --git a/app/src/main/java/app/passwordstore/util/proxy/ProxyUtils.kt b/app/src/main/java/app/passwordstore/util/proxy/ProxyUtils.kt
index 1b521141..6382e1ed 100644
--- a/app/src/main/java/app/passwordstore/util/proxy/ProxyUtils.kt
+++ b/app/src/main/java/app/passwordstore/util/proxy/ProxyUtils.kt
@@ -42,13 +42,10 @@ class ProxyUtils @Inject constructor(private val gitSettings: GitSettings) {
)
val user = gitSettings.proxyUsername ?: ""
val password = gitSettings.proxyPassword ?: ""
- if (user.isEmpty() || password.isEmpty()) {
- System.clearProperty(HTTP_PROXY_USER_PROPERTY)
- System.clearProperty(HTTP_PROXY_PASSWORD_PROPERTY)
- } else {
- System.setProperty(HTTP_PROXY_USER_PROPERTY, user)
- System.setProperty(HTTP_PROXY_PASSWORD_PROPERTY, password)
- }
+ // The Authenticator below supplies the credentials; never copy the password into a
+ // process-wide system property.
+ System.clearProperty(HTTP_PROXY_USER_PROPERTY)
+ System.clearProperty(HTTP_PROXY_PASSWORD_PROPERTY)
Authenticator.setDefault(
object : Authenticator() {
override fun getPasswordAuthentication(): PasswordAuthentication? {
diff --git a/app/src/main/java/app/passwordstore/util/settings/GitSettings.kt b/app/src/main/java/app/passwordstore/util/settings/GitSettings.kt
index d315daf3..004028c4 100644
--- a/app/src/main/java/app/passwordstore/util/settings/GitSettings.kt
+++ b/app/src/main/java/app/passwordstore/util/settings/GitSettings.kt
@@ -108,7 +108,7 @@ constructor(
}
var proxyUsername
- get() = settings.getString(PreferenceKeys.PROXY_USERNAME)
+ get() = proxySettings.getString(PreferenceKeys.PROXY_USERNAME)
set(value) {
proxySettings.edit { putString(PreferenceKeys.PROXY_USERNAME, value) }
}
diff --git a/app/src/main/java/app/passwordstore/util/settings/Migrations.kt b/app/src/main/java/app/passwordstore/util/settings/Migrations.kt
index 06075655..82847f02 100644
--- a/app/src/main/java/app/passwordstore/util/settings/Migrations.kt
+++ b/app/src/main/java/app/passwordstore/util/settings/Migrations.kt
@@ -32,6 +32,8 @@ fun runMigrations(filesDirPath: String, sharedPrefs: SharedPreferences, gitSetti
migrateToDiceware(sharedPrefs)
removeExternalStorageProperties(sharedPrefs)
removeCurrentBranchValue(sharedPrefs)
+ migrateRemovedSortOrders(sharedPrefs)
+ removeOpenKeystoreKeyId(sharedPrefs)
}
/**
@@ -194,3 +196,20 @@ private fun removeExternalStorageProperties(prefs: SharedPreferences) {
}
}
}
+
+/**
+ * "Files first" and "type independent" were removed with the Compose list, which shows folders and
+ * entries in separate groups: a stored one becomes folders first, the list's default.
+ */
+private fun migrateRemovedSortOrders(sharedPrefs: SharedPreferences) {
+ val stored = sharedPrefs.getString(PreferenceKeys.SORT_ORDER) ?: return
+ if (stored != "FILE_FIRST" && stored != "INDEPENDENT") return
+ logcat(TAG, INFO) { "Replacing the removed sort order $stored" }
+ sharedPrefs.edit { putString(PreferenceKeys.SORT_ORDER, PasswordSortOrder.FOLDER_FIRST.name) }
+}
+
+/** OpenKeychain SSH support is gone and nothing reads the remembered key ID any more. */
+private fun removeOpenKeystoreKeyId(sharedPrefs: SharedPreferences) {
+ if (!sharedPrefs.contains(PreferenceKeys.SSH_OPENKEYSTORE_KEYID)) return
+ sharedPrefs.edit { remove(PreferenceKeys.SSH_OPENKEYSTORE_KEYID) }
+}
diff --git a/app/src/main/java/app/passwordstore/util/settings/PasswordSortOrder.kt b/app/src/main/java/app/passwordstore/util/settings/PasswordSortOrder.kt
index 224d9d66..921e9bc4 100644
--- a/app/src/main/java/app/passwordstore/util/settings/PasswordSortOrder.kt
+++ b/app/src/main/java/app/passwordstore/util/settings/PasswordSortOrder.kt
@@ -18,11 +18,6 @@ enum class PasswordSortOrder(val comparator: java.util.Comparator<PasswordItem>)
(p1.type + p1.name).compareTo(p2.type + p2.name, ignoreCase = true)
}
),
- INDEPENDENT(
- Comparator { p1: PasswordItem, p2: PasswordItem ->
- p1.name.compareTo(p2.name, ignoreCase = true)
- }
- ),
RECENTLY_USED(
Comparator { p1: PasswordItem, p2: PasswordItem ->
val recentHistory =
@@ -36,18 +31,18 @@ enum class PasswordSortOrder(val comparator: java.util.Comparator<PasswordItem>)
else -> p1.name.compareTo(p2.name, ignoreCase = true)
}
}
- ),
- FILE_FIRST(
- Comparator { p1: PasswordItem, p2: PasswordItem ->
- (p2.type + p1.name).compareTo(p1.type + p2.name, ignoreCase = true)
- }
);
companion object {
+ /**
+ * The stored sort order; anything else (the removed "files first" and "type independent", a
+ * value from another version) reads as [FOLDER_FIRST].
+ */
@JvmStatic
fun getSortOrder(settings: SharedPreferences): PasswordSortOrder {
- return valueOf(settings.getString(PreferenceKeys.SORT_ORDER) ?: FOLDER_FIRST.name)
+ val stored = settings.getString(PreferenceKeys.SORT_ORDER)
+ return entries.firstOrNull { it.name == stored } ?: FOLDER_FIRST
}
}
}
diff --git a/app/src/main/java/app/passwordstore/util/viewmodel/SearchableRepositoryViewModel.kt b/app/src/main/java/app/passwordstore/util/viewmodel/SearchableRepositoryViewModel.kt
index ca570dc4..f8cbf1e4 100644
--- a/app/src/main/java/app/passwordstore/util/viewmodel/SearchableRepositoryViewModel.kt
+++ b/app/src/main/java/app/passwordstore/util/viewmodel/SearchableRepositoryViewModel.kt
@@ -71,12 +71,7 @@ private fun PasswordItem.Companion.makeComparator(
directoryStructure: DirectoryStructure,
): Comparator<PasswordItem> {
return when (typeSortOrder) {
- PasswordSortOrder.FOLDER_FIRST -> compareBy { it.type }
- // In order to let INDEPENDENT not distinguish between items based on their type, we
- // simply
- // declare them all equal at this stage.
- PasswordSortOrder.INDEPENDENT -> Comparator { _, _ -> 0 }
- PasswordSortOrder.FILE_FIRST -> compareByDescending { it.type }
+ PasswordSortOrder.FOLDER_FIRST -> compareBy<PasswordItem> { it.type }
PasswordSortOrder.RECENTLY_USED -> PasswordSortOrder.RECENTLY_USED.comparator
}
.then(