diff options
Diffstat (limited to 'app/src/main/java')
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( |
