summaryrefslogtreecommitdiffstats
path: root/app/src/main/java
diff options
context:
space:
mode:
authorDanilo M. <danix@danix.xyz>2026-11-04 18:51:35 +0100
committerDanilo M. <danix@danix.xyz>2026-11-04 18:51:35 +0100
commitba2a60a14717fd7c82cd2c4b1778673d1bb6388f (patch)
tree45fc389cbafedcfef0f88ae8b7e789af39690c84 /app/src/main/java
parent0ad0b1ca6a7f7fa814a92dd6eaf9da88948ee377 (diff)
parent136ee43d6080da288afae8ec133727962ea60640 (diff)
downloadHardenedPass-ba2a60a14717fd7c82cd2c4b1778673d1bb6388f.tar.gz
HardenedPass-ba2a60a14717fd7c82cd2c4b1778673d1bb6388f.zip
Merge branch 'compose': Compose UI overhaul
Every screen moves from XML layouts, Views and Fragments to Jetpack Compose with Material 3, in one MainActivity with a NavHost: the list with breadcrumbs, search, pull to sync and multi-select; the entry view with OTP countdown; the editor with generator sheet and folder picker; setup (welcome, git server and clone, SSH key, PGP keys); settings, git config, commit log and proxy; and the autofill picker, publisher warning and decryption prompts. LaunchActivity stays the only exported activity. Fixes found on the way reach master with it, among them symlink-safe deletes, the proxy user name, screen lock gated switches, the Android 14 app lock and screen lock prompts, and the autofill passphrase cache rule. The View-era dependencies and view binding are gone; Material Components (Views) stays for the git dialogs. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Diffstat (limited to 'app/src/main/java')
-rw-r--r--app/src/main/java/app/passwordstore/data/password/FieldItem.kt51
-rw-r--r--app/src/main/java/app/passwordstore/data/password/PasswordItem.kt34
-rw-r--r--app/src/main/java/app/passwordstore/injection/pwgen/DicewareModule.kt4
-rw-r--r--app/src/main/java/app/passwordstore/injection/totp/TotpModule.kt3
-rw-r--r--app/src/main/java/app/passwordstore/ui/adapters/FieldItemAdapter.kt101
-rw-r--r--app/src/main/java/app/passwordstore/ui/adapters/PasswordItemRecyclerAdapter.kt99
-rw-r--r--app/src/main/java/app/passwordstore/ui/autofill/AutofillDecryptActivity.kt221
-rw-r--r--app/src/main/java/app/passwordstore/ui/autofill/AutofillFilterScreen.kt149
-rw-r--r--app/src/main/java/app/passwordstore/ui/autofill/AutofillFilterView.kt184
-rw-r--r--app/src/main/java/app/passwordstore/ui/autofill/AutofillModel.kt47
-rw-r--r--app/src/main/java/app/passwordstore/ui/autofill/AutofillPublisherChangedActivity.kt115
-rw-r--r--app/src/main/java/app/passwordstore/ui/autofill/AutofillSaveActivity.kt124
-rw-r--r--app/src/main/java/app/passwordstore/ui/autofill/PasswordViewHolder.kt16
-rw-r--r--app/src/main/java/app/passwordstore/ui/autofill/PublisherChangedScreen.kt125
-rw-r--r--app/src/main/java/app/passwordstore/ui/crypto/BasePGPActivity.kt238
-rw-r--r--app/src/main/java/app/passwordstore/ui/crypto/CryptoExtensions.kt149
-rw-r--r--app/src/main/java/app/passwordstore/ui/crypto/DecryptActivity.kt298
-rw-r--r--app/src/main/java/app/passwordstore/ui/crypto/EditPasswordScreen.kt105
-rw-r--r--app/src/main/java/app/passwordstore/ui/crypto/PasswordCreationActivity.kt510
-rw-r--r--app/src/main/java/app/passwordstore/ui/crypto/PasswordDialog.kt105
-rw-r--r--app/src/main/java/app/passwordstore/ui/crypto/ViewPasswordScreen.kt132
-rw-r--r--app/src/main/java/app/passwordstore/ui/dialogs/BasicBottomSheet.kt178
-rw-r--r--app/src/main/java/app/passwordstore/ui/dialogs/DicewarePasswordGeneratorDialogFragment.kt90
-rw-r--r--app/src/main/java/app/passwordstore/ui/dialogs/FolderCreationDialogFragment.kt77
-rw-r--r--app/src/main/java/app/passwordstore/ui/dialogs/ItemCreationBottomSheet.kt77
-rw-r--r--app/src/main/java/app/passwordstore/ui/dialogs/OtpImportDialogFragment.kt47
-rw-r--r--app/src/main/java/app/passwordstore/ui/dialogs/PasswordGeneratorDialogFragment.kt148
-rw-r--r--app/src/main/java/app/passwordstore/ui/dialogs/TextInputDialog.kt71
-rw-r--r--app/src/main/java/app/passwordstore/ui/editor/EditorDestination.kt268
-rw-r--r--app/src/main/java/app/passwordstore/ui/editor/EditorModel.kt150
-rw-r--r--app/src/main/java/app/passwordstore/ui/editor/EditorScreen.kt314
-rw-r--r--app/src/main/java/app/passwordstore/ui/editor/EditorViewModel.kt273
-rw-r--r--app/src/main/java/app/passwordstore/ui/editor/GeneratorSheet.kt197
-rw-r--r--app/src/main/java/app/passwordstore/ui/entry/EntryDestination.kt246
-rw-r--r--app/src/main/java/app/passwordstore/ui/entry/EntryModel.kt35
-rw-r--r--app/src/main/java/app/passwordstore/ui/entry/EntryScreen.kt183
-rw-r--r--app/src/main/java/app/passwordstore/ui/entry/EntryViewModel.kt220
-rw-r--r--app/src/main/java/app/passwordstore/ui/folderselect/SelectFolderActivity.kt67
-rw-r--r--app/src/main/java/app/passwordstore/ui/folderselect/SelectFolderFragment.kt98
-rw-r--r--app/src/main/java/app/passwordstore/ui/git/config/GitConfigActivity.kt176
-rw-r--r--app/src/main/java/app/passwordstore/ui/git/config/GitServerConfigActivity.kt304
-rw-r--r--app/src/main/java/app/passwordstore/ui/git/log/GitLogActivity.kt49
-rw-r--r--app/src/main/java/app/passwordstore/ui/git/log/GitLogAdapter.kt65
-rw-r--r--app/src/main/java/app/passwordstore/ui/list/FolderPicker.kt130
-rw-r--r--app/src/main/java/app/passwordstore/ui/list/ListDestination.kt368
-rw-r--r--app/src/main/java/app/passwordstore/ui/list/ListModel.kt71
-rw-r--r--app/src/main/java/app/passwordstore/ui/list/ListScreen.kt340
-rw-r--r--app/src/main/java/app/passwordstore/ui/list/StoreActions.kt179
-rw-r--r--app/src/main/java/app/passwordstore/ui/main/LaunchActivity.kt42
-rw-r--r--app/src/main/java/app/passwordstore/ui/main/MainActivity.kt283
-rw-r--r--app/src/main/java/app/passwordstore/ui/onboarding/activity/OnboardingActivity.kt27
-rw-r--r--app/src/main/java/app/passwordstore/ui/onboarding/fragments/CloneFragment.kt78
-rw-r--r--app/src/main/java/app/passwordstore/ui/onboarding/fragments/KeySelectionFragment.kt77
-rw-r--r--app/src/main/java/app/passwordstore/ui/onboarding/fragments/WelcomeFragment.kt33
-rw-r--r--app/src/main/java/app/passwordstore/ui/passwords/PasswordFragment.kt388
-rw-r--r--app/src/main/java/app/passwordstore/ui/passwords/PasswordStore.kt621
-rw-r--r--app/src/main/java/app/passwordstore/ui/pgp/PGPKeyImportActivity.kt109
-rw-r--r--app/src/main/java/app/passwordstore/ui/pgp/PGPKeyList.kt169
-rw-r--r--app/src/main/java/app/passwordstore/ui/pgp/PGPKeyListActivity.kt94
-rw-r--r--app/src/main/java/app/passwordstore/ui/proxy/ProxySelectorActivity.kt109
-rw-r--r--app/src/main/java/app/passwordstore/ui/settings/AutofillSettings.kt114
-rw-r--r--app/src/main/java/app/passwordstore/ui/settings/GeneralSettings.kt120
-rw-r--r--app/src/main/java/app/passwordstore/ui/settings/GitConfigScreen.kt329
-rw-r--r--app/src/main/java/app/passwordstore/ui/settings/GitConfigViewModel.kt82
-rw-r--r--app/src/main/java/app/passwordstore/ui/settings/GitLogScreen.kt118
-rw-r--r--app/src/main/java/app/passwordstore/ui/settings/MiscSettings.kt76
-rw-r--r--app/src/main/java/app/passwordstore/ui/settings/PGPSettings.kt136
-rw-r--r--app/src/main/java/app/passwordstore/ui/settings/PasswordSettings.kt48
-rw-r--r--app/src/main/java/app/passwordstore/ui/settings/PreferenceRows.kt217
-rw-r--r--app/src/main/java/app/passwordstore/ui/settings/ProxyScreen.kt197
-rw-r--r--app/src/main/java/app/passwordstore/ui/settings/ProxyViewModel.kt62
-rw-r--r--app/src/main/java/app/passwordstore/ui/settings/RepositorySettings.kt196
-rw-r--r--app/src/main/java/app/passwordstore/ui/settings/SettingsActivity.kt129
-rw-r--r--app/src/main/java/app/passwordstore/ui/settings/SettingsDestination.kt304
-rw-r--r--app/src/main/java/app/passwordstore/ui/settings/SettingsGraph.kt87
-rw-r--r--app/src/main/java/app/passwordstore/ui/settings/SettingsModel.kt79
-rw-r--r--app/src/main/java/app/passwordstore/ui/settings/SettingsProvider.kt15
-rw-r--r--app/src/main/java/app/passwordstore/ui/settings/SettingsScreen.kt349
-rw-r--r--app/src/main/java/app/passwordstore/ui/settings/SettingsViewModel.kt226
-rw-r--r--app/src/main/java/app/passwordstore/ui/setup/GitServerScreen.kt345
-rw-r--r--app/src/main/java/app/passwordstore/ui/setup/GitServerViewModel.kt130
-rw-r--r--app/src/main/java/app/passwordstore/ui/setup/PgpKeysScreen.kt337
-rw-r--r--app/src/main/java/app/passwordstore/ui/setup/PgpKeysViewModel.kt255
-rw-r--r--app/src/main/java/app/passwordstore/ui/setup/SetupGraph.kt99
-rw-r--r--app/src/main/java/app/passwordstore/ui/setup/SetupModel.kt142
-rw-r--r--app/src/main/java/app/passwordstore/ui/setup/SshKeyScreen.kt379
-rw-r--r--app/src/main/java/app/passwordstore/ui/setup/SshKeyViewModel.kt137
-rw-r--r--app/src/main/java/app/passwordstore/ui/setup/WelcomeScreen.kt103
-rw-r--r--app/src/main/java/app/passwordstore/ui/sshkeygen/ShowSshKeyFragment.kt39
-rw-r--r--app/src/main/java/app/passwordstore/ui/sshkeygen/SshKeyGenActivity.kt173
-rw-r--r--app/src/main/java/app/passwordstore/ui/sshkeygen/SshKeyImportActivity.kt66
-rw-r--r--app/src/main/java/app/passwordstore/ui/util/OnOffItemAnimator.kt71
-rw-r--r--app/src/main/java/app/passwordstore/util/auth/BiometricAuthenticator.kt5
-rw-r--r--app/src/main/java/app/passwordstore/util/autofill/AutofillViewUtils.kt4
-rw-r--r--app/src/main/java/app/passwordstore/util/extensions/AndroidExtensions.kt38
-rw-r--r--app/src/main/java/app/passwordstore/util/extensions/FragmentExtensions.kt40
-rw-r--r--app/src/main/java/app/passwordstore/util/extensions/FragmentViewBindingDelegate.kt70
-rw-r--r--app/src/main/java/app/passwordstore/util/git/GitLogModel.kt60
-rw-r--r--app/src/main/java/app/passwordstore/util/git/operation/BreakOutOfDetached.kt5
-rw-r--r--app/src/main/java/app/passwordstore/util/git/operation/GitOperation.kt40
-rw-r--r--app/src/main/java/app/passwordstore/util/git/sshj/SshKey.kt83
-rw-r--r--app/src/main/java/app/passwordstore/util/proxy/ProxyUtils.kt11
-rw-r--r--app/src/main/java/app/passwordstore/util/settings/GitSettings.kt2
-rw-r--r--app/src/main/java/app/passwordstore/util/settings/Migrations.kt19
-rw-r--r--app/src/main/java/app/passwordstore/util/settings/PasswordSortOrder.kt17
-rw-r--r--app/src/main/java/app/passwordstore/util/viewmodel/PGPKeyListViewModel.kt48
-rw-r--r--app/src/main/java/app/passwordstore/util/viewmodel/SearchableRepositoryViewModel.kt189
107 files changed, 8223 insertions, 6829 deletions
diff --git a/app/src/main/java/app/passwordstore/data/password/FieldItem.kt b/app/src/main/java/app/passwordstore/data/password/FieldItem.kt
deleted file mode 100644
index 56ee8e0e..00000000
--- a/app/src/main/java/app/passwordstore/data/password/FieldItem.kt
+++ /dev/null
@@ -1,51 +0,0 @@
-/*
- * Copyright © 2014-2024 The Android Password Store Authors. All Rights Reserved.
- * SPDX-License-Identifier: GPL-3.0-only
- */
-
-package app.passwordstore.data.password
-
-import app.passwordstore.data.passfile.Totp
-
-class FieldItem
-private constructor(
- val type: ItemType,
- val label: String,
- val value: String,
- val action: ActionType,
-) {
- enum class ActionType {
- COPY,
- HIDE,
- }
-
- enum class ItemType() {
- USERNAME,
- PASSWORD,
- OTP,
- FREEFORM,
- }
-
- companion object {
- fun createOtpField(label: String, totp: Totp): FieldItem {
- return FieldItem(
- ItemType.OTP,
- label.format(totp.remainingTime.inWholeSeconds),
- totp.value,
- ActionType.COPY,
- )
- }
-
- fun createPasswordField(label: String, password: String): FieldItem {
- return FieldItem(ItemType.PASSWORD, label, password, ActionType.HIDE)
- }
-
- fun createUsernameField(label: String, username: String): FieldItem {
- return FieldItem(ItemType.USERNAME, label, username, ActionType.COPY)
- }
-
- fun createFreeformField(label: String, content: String): FieldItem {
- return FieldItem(ItemType.FREEFORM, label, content, ActionType.COPY)
- }
- }
-}
diff --git a/app/src/main/java/app/passwordstore/data/password/PasswordItem.kt b/app/src/main/java/app/passwordstore/data/password/PasswordItem.kt
index 82798a05..5d32108f 100644
--- a/app/src/main/java/app/passwordstore/data/password/PasswordItem.kt
+++ b/app/src/main/java/app/passwordstore/data/password/PasswordItem.kt
@@ -6,22 +6,16 @@ package app.passwordstore.data.password
import android.content.Context
import android.content.Intent
-import app.passwordstore.data.repo.PasswordRepository
-import app.passwordstore.ui.crypto.BasePGPActivity
+import app.passwordstore.ui.crypto.getLongName
import app.passwordstore.ui.main.LaunchActivity
import java.io.File
-data class PasswordItem(
- val name: String,
- val parent: PasswordItem? = null,
- val type: Char,
- val file: File,
- val rootDir: File,
-) : Comparable<PasswordItem> {
+data class PasswordItem(val name: String, val type: Char, val file: File, val rootDir: File) :
+ Comparable<PasswordItem> {
val fullPathToParent = file.absolutePath.replace(rootDir.absolutePath, "").replace(file.name, "")
- val longName = BasePGPActivity.getLongName(fullPathToParent, rootDir.absolutePath, toString())
+ val longName = getLongName(fullPathToParent, rootDir.absolutePath, toString())
override fun equals(other: Any?): Boolean {
return (other is PasswordItem) && (other.file == file)
@@ -42,9 +36,7 @@ data class PasswordItem(
/** Creates an [Intent] to launch this [PasswordItem] through the authentication process. */
fun createAuthEnabledIntent(context: Context): Intent {
val intent = Intent(context, LaunchActivity::class.java)
- intent.putExtra("NAME", toString())
- intent.putExtra("FILE_PATH", file.absolutePath)
- intent.putExtra("REPO_PATH", PasswordRepository.getRepositoryDirectory().absolutePath)
+ intent.putExtra(LaunchActivity.EXTRA_FILE_PATH, file.absolutePath)
intent.action = LaunchActivity.ACTION_DECRYPT_PASS
return intent
}
@@ -54,24 +46,12 @@ data class PasswordItem(
const val TYPE_CATEGORY = 'c'
const val TYPE_PASSWORD = 'p'
- @JvmStatic
- fun newCategory(name: String, file: File, parent: PasswordItem, rootDir: File): PasswordItem {
- return PasswordItem(name, parent, TYPE_CATEGORY, file, rootDir)
- }
-
- @JvmStatic
fun newCategory(name: String, file: File, rootDir: File): PasswordItem {
- return PasswordItem(name, null, TYPE_CATEGORY, file, rootDir)
- }
-
- @JvmStatic
- fun newPassword(name: String, file: File, parent: PasswordItem, rootDir: File): PasswordItem {
- return PasswordItem(name, parent, TYPE_PASSWORD, file, rootDir)
+ return PasswordItem(name, TYPE_CATEGORY, file, rootDir)
}
- @JvmStatic
fun newPassword(name: String, file: File, rootDir: File): PasswordItem {
- return PasswordItem(name, null, TYPE_PASSWORD, file, rootDir)
+ return PasswordItem(name, TYPE_PASSWORD, file, rootDir)
}
}
}
diff --git a/app/src/main/java/app/passwordstore/injection/pwgen/DicewareModule.kt b/app/src/main/java/app/passwordstore/injection/pwgen/DicewareModule.kt
index 35c744a2..c9c4e928 100644
--- a/app/src/main/java/app/passwordstore/injection/pwgen/DicewareModule.kt
+++ b/app/src/main/java/app/passwordstore/injection/pwgen/DicewareModule.kt
@@ -13,14 +13,14 @@ import app.passwordstore.passgen.diceware.RandomIntGenerator
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
-import dagger.hilt.android.components.FragmentComponent
+import dagger.hilt.android.components.ViewModelComponent
import dagger.hilt.android.qualifiers.ApplicationContext
import java.io.InputStream
import java.security.SecureRandom
import javax.inject.Qualifier
@Module
-@InstallIn(FragmentComponent::class)
+@InstallIn(ViewModelComponent::class)
object DicewareModule {
@Provides
diff --git a/app/src/main/java/app/passwordstore/injection/totp/TotpModule.kt b/app/src/main/java/app/passwordstore/injection/totp/TotpModule.kt
index aa85d002..b7d32b71 100644
--- a/app/src/main/java/app/passwordstore/injection/totp/TotpModule.kt
+++ b/app/src/main/java/app/passwordstore/injection/totp/TotpModule.kt
@@ -11,9 +11,10 @@ import dagger.Binds
import dagger.Module
import dagger.hilt.InstallIn
import dagger.hilt.android.components.ActivityComponent
+import dagger.hilt.android.components.ViewModelComponent
@Module
-@InstallIn(ActivityComponent::class)
+@InstallIn(ActivityComponent::class, ViewModelComponent::class)
interface TotpModule {
@Binds fun bindTotpFinder(impl: UriTotpFinder): TotpFinder
}
diff --git a/app/src/main/java/app/passwordstore/ui/adapters/FieldItemAdapter.kt b/app/src/main/java/app/passwordstore/ui/adapters/FieldItemAdapter.kt
deleted file mode 100644
index 57428ffb..00000000
--- a/app/src/main/java/app/passwordstore/ui/adapters/FieldItemAdapter.kt
+++ /dev/null
@@ -1,101 +0,0 @@
-/*
- * Copyright © 2014-2024 The Android Password Store Authors. All Rights Reserved.
- * SPDX-License-Identifier: GPL-3.0-only
- */
-
-package app.passwordstore.ui.adapters
-
-import android.text.method.PasswordTransformationMethod
-import android.view.LayoutInflater
-import android.view.View
-import android.view.ViewGroup
-import androidx.core.content.ContextCompat
-import androidx.core.content.res.ResourcesCompat
-import androidx.recyclerview.widget.RecyclerView
-import app.passwordstore.R
-import app.passwordstore.data.passfile.Totp
-import app.passwordstore.data.password.FieldItem
-import app.passwordstore.databinding.ItemFieldBinding
-import app.passwordstore.ui.compose.R as composeR
-import com.google.android.material.textfield.TextInputLayout
-
-class FieldItemAdapter(
- private var fieldItemList: List<FieldItem>,
- private val showPassword: Boolean,
- private val copyTextToClipboard: (text: String?) -> Unit,
-) : RecyclerView.Adapter<FieldItemAdapter.FieldItemViewHolder>() {
-
- override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): FieldItemViewHolder {
- val binding = ItemFieldBinding.inflate(LayoutInflater.from(parent.context), parent, false)
- return FieldItemViewHolder(binding.root, binding)
- }
-
- override fun onBindViewHolder(holder: FieldItemViewHolder, position: Int) {
- holder.bind(fieldItemList[position], showPassword, copyTextToClipboard)
- }
-
- override fun getItemCount(): Int {
- return fieldItemList.size
- }
-
- fun updateOTPCode(totp: Totp, labelFormat: String) {
- var otpItemPosition = -1
- fieldItemList =
- fieldItemList.mapIndexed { position, item ->
- if (item.type == FieldItem.ItemType.OTP) {
- otpItemPosition = position
- return@mapIndexed FieldItem.createOtpField(labelFormat, totp)
- }
-
- return@mapIndexed item
- }
-
- notifyItemChanged(otpItemPosition)
- }
-
- class FieldItemViewHolder(itemView: View, val binding: ItemFieldBinding) :
- RecyclerView.ViewHolder(itemView) {
-
- fun bind(fieldItem: FieldItem, showPassword: Boolean, copyTextToClipboard: (String?) -> Unit) {
- with(binding) {
- itemText.hint = fieldItem.label
- itemTextContainer.hint = fieldItem.label
- itemText.setText(fieldItem.value)
-
- when (fieldItem.action) {
- FieldItem.ActionType.COPY -> {
- itemTextContainer.apply {
- endIconDrawable =
- ContextCompat.getDrawable(itemView.context, R.drawable.ic_content_copy)
- endIconMode = TextInputLayout.END_ICON_CUSTOM
- setEndIconOnClickListener { copyTextToClipboard(itemText.text.toString()) }
- }
- itemText.transformationMethod = null
- }
- FieldItem.ActionType.HIDE -> {
- itemTextContainer.apply {
- endIconMode = TextInputLayout.END_ICON_PASSWORD_TOGGLE
- setOnClickListener { copyTextToClipboard(itemText.text.toString()) }
- }
- itemText.apply {
- transformationMethod =
- if (!showPassword) {
- PasswordTransformationMethod.getInstance()
- } else {
- null
- }
- if (fieldItem.type == FieldItem.ItemType.PASSWORD) {
- typeface =
- ResourcesCompat.getFont(
- binding.root.context,
- composeR.font.jetbrainsmono_nl_regular,
- )
- }
- setOnClickListener { copyTextToClipboard(itemText.text.toString()) }
- }
- }
- }
- }
- }
- }
-}
diff --git a/app/src/main/java/app/passwordstore/ui/adapters/PasswordItemRecyclerAdapter.kt b/app/src/main/java/app/passwordstore/ui/adapters/PasswordItemRecyclerAdapter.kt
deleted file mode 100644
index 54be64d6..00000000
--- a/app/src/main/java/app/passwordstore/ui/adapters/PasswordItemRecyclerAdapter.kt
+++ /dev/null
@@ -1,99 +0,0 @@
-/*
- * Copyright © 2014-2024 The Android Password Store Authors. All Rights Reserved.
- * SPDX-License-Identifier: GPL-3.0-only
- */
-package app.passwordstore.ui.adapters
-
-import android.text.SpannableString
-import android.text.style.RelativeSizeSpan
-import android.view.MotionEvent
-import android.view.View
-import androidx.appcompat.widget.AppCompatImageView
-import androidx.appcompat.widget.AppCompatTextView
-import androidx.recyclerview.selection.ItemDetailsLookup
-import androidx.recyclerview.selection.Selection
-import androidx.recyclerview.widget.RecyclerView
-import app.passwordstore.R
-import app.passwordstore.data.password.PasswordItem
-import app.passwordstore.util.coroutines.DispatcherProvider
-import app.passwordstore.util.viewmodel.SearchableRepositoryAdapter
-import app.passwordstore.util.viewmodel.stableId
-import kotlinx.coroutines.CoroutineScope
-import kotlinx.coroutines.withContext
-
-open class PasswordItemRecyclerAdapter(
- coroutineScope: CoroutineScope,
- dispatcherProvider: DispatcherProvider,
-) :
- SearchableRepositoryAdapter<PasswordItemRecyclerAdapter.PasswordItemViewHolder>(
- R.layout.password_row_layout,
- ::PasswordItemViewHolder,
- coroutineScope,
- dispatcherProvider,
- PasswordItemViewHolder::bind,
- ) {
-
- fun makeSelectable(recyclerView: RecyclerView) {
- makeSelectable(recyclerView, ::PasswordItemDetailsLookup)
- }
-
- override fun onItemClicked(
- listener: (holder: PasswordItemViewHolder, item: PasswordItem) -> Unit
- ): PasswordItemRecyclerAdapter {
- return super.onItemClicked(listener) as PasswordItemRecyclerAdapter
- }
-
- override fun onSelectionChanged(
- listener: (selection: Selection<String>) -> Unit
- ): PasswordItemRecyclerAdapter {
- return super.onSelectionChanged(listener) as PasswordItemRecyclerAdapter
- }
-
- class PasswordItemViewHolder(view: View) : RecyclerView.ViewHolder(view) {
-
- private val name: AppCompatTextView = itemView.findViewById(R.id.label)
- private val childCount: AppCompatTextView = itemView.findViewById(R.id.child_count)
- private val folderIndicator: AppCompatImageView = itemView.findViewById(R.id.folder_indicator)
- var itemDetails: ItemDetailsLookup.ItemDetails<String>? = null
-
- suspend fun bind(item: PasswordItem, dispatcherProvider: DispatcherProvider) {
- val parentPath = item.fullPathToParent.replace("(^/)|(/$)".toRegex(), "")
- val source =
- if (parentPath.isNotEmpty()) {
- "$parentPath\n$item"
- } else {
- "$item"
- }
- val spannable = SpannableString(source)
- spannable.setSpan(RelativeSizeSpan(0.7f), 0, parentPath.length, 0)
- name.text = spannable
- if (item.type == PasswordItem.TYPE_CATEGORY) {
- folderIndicator.visibility = View.VISIBLE
- val count =
- withContext(dispatcherProvider.io()) {
- item.file.listFiles { path -> path.isDirectory || path.extension == "gpg" }?.size ?: 0
- }
- childCount.visibility = if (count > 0) View.VISIBLE else View.GONE
- childCount.text = "$count"
- } else {
- childCount.visibility = View.GONE
- folderIndicator.visibility = View.GONE
- }
- itemDetails =
- object : ItemDetailsLookup.ItemDetails<String>() {
- override fun getPosition() = absoluteAdapterPosition
-
- override fun getSelectionKey() = item.stableId
- }
- }
- }
-
- class PasswordItemDetailsLookup(private val recyclerView: RecyclerView) :
- ItemDetailsLookup<String>() {
-
- override fun getItemDetails(event: MotionEvent): ItemDetails<String>? {
- val view = recyclerView.findChildViewUnder(event.x, event.y) ?: return null
- return (recyclerView.getChildViewHolder(view) as PasswordItemViewHolder).itemDetails
- }
- }
-}
diff --git a/app/src/main/java/app/passwordstore/ui/autofill/AutofillDecryptActivity.kt b/app/src/main/java/app/passwordstore/ui/autofill/AutofillDecryptActivity.kt
index 70525083..3f0fb5ef 100644
--- a/app/src/main/java/app/passwordstore/ui/autofill/AutofillDecryptActivity.kt
+++ b/app/src/main/java/app/passwordstore/ui/autofill/AutofillDecryptActivity.kt
@@ -8,24 +8,42 @@ import android.app.PendingIntent
import android.content.Context
import android.content.Intent
import android.content.IntentSender
+import android.content.SharedPreferences
import android.os.Build
import android.os.Bundle
+import android.view.WindowManager
import android.view.autofill.AutofillManager
+import androidx.activity.compose.setContent
+import androidx.activity.result.contract.ActivityResultContracts.StartActivityForResult
+import androidx.appcompat.app.AppCompatActivity
+import androidx.compose.material3.AlertDialog
+import androidx.compose.material3.Text
+import androidx.compose.material3.TextButton
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.res.stringResource
import androidx.core.content.edit
-import androidx.fragment.app.setFragmentResultListener
import androidx.lifecycle.lifecycleScope
import app.passwordstore.Application.Companion.screenWasOff
import app.passwordstore.R
import app.passwordstore.crypto.PGPIdentifier
+import app.passwordstore.data.crypto.CryptoRepository
import app.passwordstore.data.crypto.PGPPassphraseCache
import app.passwordstore.data.passfile.PasswordEntry
import app.passwordstore.data.repo.PasswordRepository
-import app.passwordstore.ui.crypto.BasePGPActivity
-import app.passwordstore.ui.crypto.PasswordDialog
+import app.passwordstore.injection.prefs.SettingsPreferences
+import app.passwordstore.ui.compose.theme.APSTheme
+import app.passwordstore.ui.crypto.getPGPIdentifiers
+import app.passwordstore.ui.crypto.getParentPath
+import app.passwordstore.ui.entry.PassphraseDialog
+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 as BiometricResult
import app.passwordstore.util.autofill.AutofillPreferences
import app.passwordstore.util.autofill.AutofillResponseBuilder
+import app.passwordstore.util.coroutines.DispatcherProvider
import app.passwordstore.util.extensions.asLog
import app.passwordstore.util.features.Feature.EnablePGPPassphraseCache
import app.passwordstore.util.features.Features
@@ -47,62 +65,157 @@ import logcat.LogPriority.ERROR
import logcat.asLog
import logcat.logcat
+/**
+ * Decrypts one entry for the autofill service and returns the dataset to fill. Its transparent,
+ * FLAG_SECURE window shows only the "no keys" and passphrase dialogs.
+ */
@AndroidEntryPoint
-class AutofillDecryptActivity : BasePGPActivity() {
+class AutofillDecryptActivity : AppCompatActivity() {
+ @SettingsPreferences @Inject lateinit var settings: SharedPreferences
+ @Inject lateinit var repository: CryptoRepository
+ @Inject lateinit var dispatcherProvider: DispatcherProvider
@Inject lateinit var passwordEntryFactory: PasswordEntry.Factory
@Inject lateinit var features: Features
@Inject lateinit var passphraseCache: PGPPassphraseCache
private lateinit var directoryStructure: DirectoryStructure
+ private lateinit var filePath: String
+ private lateinit var clientState: Bundle
+ private lateinit var action: AutofillAction
private var clearCache = true
- override fun onStart() {
- super.onStart()
- val filePath =
+ /** The screen lock prompt's outcome: the passphrase is cached only after a success. */
+ private var auth: BiometricResult = BiometricResult.CanceledByUser
+
+ /** Set once the flow started; reset after a key import, so the next window focus restarts it. */
+ private var started = false
+
+ /** Shows the "No keys imported" dialog. */
+ private var noKeys by mutableStateOf(false)
+
+ /** The recipients the passphrase dialog asks for; null while it is not shown. */
+ private var passphraseFor by mutableStateOf<List<PGPIdentifier>?>(null)
+
+ // The import's activity is still on top when its result arrives: the flow restarts on the next
+ // window focus, so the screen lock prompt is not shown (and evicted) behind it.
+ private val keyImport =
+ registerForActivityResult(StartActivityForResult()) { result ->
+ if (result.resultCode == RESULT_OK) started = false else finish()
+ }
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ // Before anything is drawn: the passphrase dialog's parent stays out of screenshots and
+ // recents.
+ window.setFlags(WindowManager.LayoutParams.FLAG_SECURE, WindowManager.LayoutParams.FLAG_SECURE)
+ // ponytail: a recreation (theme or locale change; rotation and resizing do not recreate, see
+ // the manifest) cancels the fill instead of resuming a half-done flow; the user taps the field
+ // again. Keep the flow's state in a ViewModel if this ever matters.
+ if (savedInstanceState != null) {
+ finish()
+ return
+ }
+ filePath =
intent?.getStringExtra(EXTRA_FILE_PATH)
?: run {
logcat(ERROR) { "AutofillDecryptActivity started without EXTRA_FILE_PATH" }
finish()
return
}
- val clientState =
+ clientState =
intent?.getBundleExtra(AutofillManager.EXTRA_CLIENT_STATE)
?: run {
logcat(ERROR) { "AutofillDecryptActivity started without EXTRA_CLIENT_STATE" }
finish()
return
}
- val isSearchAction = intent?.getBooleanExtra(EXTRA_SEARCH_ACTION, true)!!
- val action = if (isSearchAction) AutofillAction.Search else AutofillAction.Match
+ val isSearchAction = intent.getBooleanExtra(EXTRA_SEARCH_ACTION, true)
+ action = if (isSearchAction) AutofillAction.Search else AutofillAction.Match
directoryStructure = AutofillPreferences.directoryStructure(this)
logcat { action.toString() }
- requireKeysExist {
- if (
- features.isEnabled(EnablePGPPassphraseCache) && BiometricAuthenticator.canAuthenticate(this)
- ) {
- BiometricAuthenticator.authenticate(
- this,
- R.string.biometric_prompt_title_gpg_passphrase_cache,
- ) { authResult ->
- decrypt(filePath, clientState, action, authResult)
- }
- } else {
- decrypt(filePath, clientState, action, BiometricResult.CanceledByUser)
+ setContent {
+ APSTheme {
+ if (noKeys)
+ AlertDialog(
+ onDismissRequest = { finish() },
+ title = { Text(stringResource(R.string.no_keys_imported_dialog_title)) },
+ text = { Text(stringResource(R.string.no_keys_imported_dialog_message)) },
+ confirmButton = {
+ TextButton(
+ onClick = {
+ noKeys = false
+ keyImport.launch(
+ MainActivity.setupIntent(this@AutofillDecryptActivity, SetupStart.PgpImport)
+ )
+ }
+ ) {
+ Text(stringResource(R.string.button_label_import))
+ }
+ },
+ )
+ val identifiers = passphraseFor
+ if (identifiers != null)
+ PassphraseDialog(
+ wrong = false,
+ cacheEnabled = features.isEnabled(EnablePGPPassphraseCache),
+ clearCache = clearCache,
+ onSubmit = onSubmit@{ passphrase, clear ->
+ // Cleared before decrypting: a second submit in the same frame does nothing.
+ if (passphraseFor == null) return@onSubmit
+ passphraseFor = null
+ clearCache = clear
+ lifecycleScope.launch(dispatcherProvider.main()) {
+ decryptWithPassphrase(identifiers, passphrase, typed = true)
+ }
+ },
+ onCancel = { finish() },
+ )
}
}
}
- private fun decrypt(
- filePath: String,
- clientState: Bundle,
- action: AutofillAction,
- authResult: BiometricResult,
- ) {
+ // The flow waits for window focus, as LaunchActivity's prompt does: Android 14's SystemUI
+ // dismisses a screen lock prompt shown before its activity is on top. It runs once (again only
+ // after a key import); the old onStart start ran it on every return to this activity.
+ override fun onWindowFocusChanged(hasFocus: Boolean) {
+ super.onWindowFocusChanged(hasFocus)
+ if (!hasFocus || started || isFinishing) return
+ started = true
+ start()
+ }
+
+ /** Asks for a key import first while no PGP key is imported. */
+ private fun start() {
+ if (isFinishing) return
+ lifecycleScope.launch { if (repository.hasKeys()) unlock() else noKeys = true }
+ }
+
+ private fun unlock() {
+ if (
+ features.isEnabled(EnablePGPPassphraseCache) && BiometricAuthenticator.canAuthenticate(this)
+ ) {
+ BiometricAuthenticator.authenticate(
+ this,
+ R.string.biometric_prompt_title_gpg_passphrase_cache,
+ ) { authResult ->
+ decrypt(authResult)
+ }
+ } else {
+ decrypt(BiometricResult.CanceledByUser)
+ }
+ }
+
+ private fun decrypt(authResult: BiometricResult) {
+ auth = authResult
val gpgIdentifiers =
getPGPIdentifiers(
getParentPath(filePath, PasswordRepository.getRepositoryDirectory().toString())
- ) ?: return
+ )
+ ?: run {
+ finish()
+ return
+ }
lifecycleScope.launch(dispatcherProvider.main()) {
when (authResult) {
// Internally handled by the prompt dialog
@@ -111,8 +224,7 @@ class AutofillDecryptActivity : BasePGPActivity() {
is BiometricResult.CanceledBySystem,
is BiometricResult.CanceledByUser,
is BiometricResult.Failure,
- is BiometricResult.HardwareUnavailableOrDisabled ->
- askPassphrase(filePath, gpgIdentifiers, clientState, action)
+ is BiometricResult.HardwareUnavailableOrDisabled -> askPassphrase(gpgIdentifiers)
is BiometricResult.Success -> {
/* clear passphrase cache on first use after application startup or if screen was off;
also make sure to purge a stale cache after caching has been disabled via PGP settings */
@@ -127,56 +239,30 @@ class AutofillDecryptActivity : BasePGPActivity() {
gpgIdentifiers.first(),
)
if (cachedPassphrase != null) {
- decryptWithPassphrase(
- File(filePath),
- gpgIdentifiers,
- clientState,
- action,
- cachedPassphrase,
- )
+ decryptWithPassphrase(gpgIdentifiers, cachedPassphrase)
} else {
- askPassphrase(filePath, gpgIdentifiers, clientState, action)
+ askPassphrase(gpgIdentifiers)
}
}
}
}
}
- private suspend fun askPassphrase(
- filePath: String,
- identifiers: List<PGPIdentifier>,
- clientState: Bundle,
- action: AutofillAction,
- ) {
+ private suspend fun askPassphrase(identifiers: List<PGPIdentifier>) {
if (!repository.isPasswordProtected(identifiers)) {
- decryptWithPassphrase(File(filePath), identifiers, clientState, action, password = "")
+ decryptWithPassphrase(identifiers, password = "")
return
}
- val dialog =
- PasswordDialog.newInstance(
- cacheEnabled = features.isEnabled(EnablePGPPassphraseCache),
- clearCache = clearCache,
- )
- dialog.show(supportFragmentManager, "PASSWORD_DIALOG")
- dialog.setFragmentResultListener(PasswordDialog.PASSWORD_RESULT_KEY) { key, bundle ->
- if (key == PasswordDialog.PASSWORD_RESULT_KEY) {
- val value = bundle.getString(PasswordDialog.PASSWORD_PHRASE_KEY)!!
- clearCache = bundle.getBoolean(PasswordDialog.PASSWORD_CLEAR_KEY)
- lifecycleScope.launch(dispatcherProvider.main()) {
- decryptWithPassphrase(File(filePath), identifiers, clientState, action, value)
- }
- }
- }
+ passphraseFor = identifiers
}
+ /** Caches [password] only when the user [typed] it, as the entry view does. */
private suspend fun decryptWithPassphrase(
- filePath: File,
identifiers: List<PGPIdentifier>,
- clientState: Bundle,
- action: AutofillAction,
password: String,
+ typed: Boolean = false,
) {
- val credentials = decryptCredential(filePath, password, identifiers)
+ val credentials = decryptCredential(File(filePath), password, identifiers, typed)
if (credentials == null) {
setResult(RESULT_CANCELED)
} else {
@@ -201,6 +287,7 @@ class AutofillDecryptActivity : BasePGPActivity() {
file: File,
password: String,
identifiers: List<PGPIdentifier>,
+ typed: Boolean,
): Credentials? {
runCatching { file.readBytes().inputStream() }
.onFailure { e ->
@@ -218,7 +305,9 @@ class AutofillDecryptActivity : BasePGPActivity() {
.onSuccess { result ->
return runCatching {
runCatching {
- if (features.isEnabled(EnablePGPPassphraseCache)) {
+ if (
+ typed && cachesPassphrase(features.isEnabled(EnablePGPPassphraseCache), auth)
+ ) {
passphraseCache.cachePassphrase(this, identifiers.first(), password)
settings.edit {
putBoolean(PreferenceKeys.CLEAR_PASSPHRASE_CACHE, clearCache)
diff --git a/app/src/main/java/app/passwordstore/ui/autofill/AutofillFilterScreen.kt b/app/src/main/java/app/passwordstore/ui/autofill/AutofillFilterScreen.kt
new file mode 100644
index 00000000..acbacd40
--- /dev/null
+++ b/app/src/main/java/app/passwordstore/ui/autofill/AutofillFilterScreen.kt
@@ -0,0 +1,149 @@
+/*
+ * Copyright (C) 2026 Danilo M. <danix@danix.xyz>
+ * SPDX-License-Identifier: GPL-3.0-only
+ */
+
+package app.passwordstore.ui.autofill
+
+import androidx.compose.foundation.Image
+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.layout.size
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.items
+import androidx.compose.foundation.lazy.rememberLazyListState
+import androidx.compose.foundation.text.KeyboardOptions
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Close
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.OutlinedTextField
+import androidx.compose.material3.Surface
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.res.painterResource
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.SpanStyle
+import androidx.compose.ui.text.buildAnnotatedString
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.text.input.ImeAction
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.text.withStyle
+import androidx.compose.ui.unit.dp
+import app.passwordstore.R
+import app.passwordstore.ui.compose.EntryRow
+import app.passwordstore.ui.compose.GroupedSurface
+import app.passwordstore.ui.compose.theme.SpacingLarge
+import app.passwordstore.ui.list.ListRow
+import app.passwordstore.ui.settings.PreferenceSwitch
+import kotlinx.collections.immutable.ImmutableList
+
+/**
+ * The autofill picker: which site or app is being filled ([origin], as the user can check it), a
+ * search over the store's entries, the matching [rows] (null while the first search runs), and the
+ * switches for phishing-resistant search ([showStrict]: websites only), recording the pick as a
+ * match ([matchLabel]) and clearing earlier matches. [onPick] takes the entry's absolute path.
+ */
+@Composable
+fun AutofillFilterScreen(
+ origin: String,
+ query: String,
+ rows: ImmutableList<ListRow>?,
+ showStrict: Boolean,
+ strict: Boolean,
+ matchLabel: String,
+ match: Boolean,
+ clear: Boolean,
+ onQueryChange: (String) -> Unit,
+ onStrictChange: (Boolean) -> Unit,
+ onMatchChange: (Boolean) -> Unit,
+ onClearChange: (Boolean) -> Unit,
+ onPick: (path: String) -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ val fillInto = stringResource(R.string.oreo_autofill_select_and_fill_into)
+ val listState = rememberLazyListState()
+ // As the old picker did: every new result starts at its best match.
+ LaunchedEffect(rows) { listState.scrollToItem(0) }
+ Surface(modifier.fillMaxSize()) {
+ Column(Modifier.padding(vertical = SpacingLarge)) {
+ Image(
+ painterResource(R.drawable.ic_launcher_foreground),
+ contentDescription = stringResource(R.string.app_name),
+ modifier = Modifier.size(60.dp).align(Alignment.CenterHorizontally),
+ )
+ Text(
+ buildAnnotatedString {
+ append(fillInto)
+ append("\n")
+ withStyle(SpanStyle(fontWeight = FontWeight.Bold)) { append(origin) }
+ },
+ modifier = Modifier.fillMaxWidth().padding(horizontal = SpacingLarge),
+ textAlign = TextAlign.Center,
+ )
+ OutlinedTextField(
+ value = query,
+ onValueChange = onQueryChange,
+ modifier = Modifier.fillMaxWidth().padding(SpacingLarge),
+ label = { Text(stringResource(R.string.action_search)) },
+ leadingIcon = {
+ Icon(painterResource(R.drawable.ic_search_24dp), contentDescription = null)
+ },
+ trailingIcon =
+ if (query.isNotEmpty()) {
+ {
+ IconButton(onClick = { onQueryChange("") }) {
+ Icon(Icons.Filled.Close, stringResource(R.string.list_clear_search))
+ }
+ }
+ } else null,
+ singleLine = true,
+ keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done),
+ )
+ Box(Modifier.weight(1f).fillMaxWidth()) {
+ when {
+ rows == null -> Unit
+ rows.isEmpty() ->
+ Text(
+ stringResource(R.string.oreo_autofill_filter_no_results),
+ modifier = Modifier.padding(horizontal = SpacingLarge),
+ style = MaterialTheme.typography.titleMedium,
+ )
+ else ->
+ // One lazy item per row: a cleared search lists the whole store. The surface wraps
+ // a short list and is capped by the box for a long one.
+ GroupedSurface(Modifier.fillMaxWidth()) {
+ LazyColumn(state = listState) {
+ items(rows, key = { it.path }) { row ->
+ EntryRow(
+ name = row.label,
+ subtitle = row.subtitle,
+ onClick = { onPick(row.path) },
+ )
+ }
+ }
+ }
+ }
+ }
+ if (showStrict)
+ PreferenceSwitch(
+ stringResource(R.string.oreo_autofill_strict_domain_search),
+ strict,
+ onStrictChange,
+ )
+ PreferenceSwitch(matchLabel, match, onMatchChange)
+ PreferenceSwitch(
+ stringResource(R.string.oreo_autofill_matches_clear_existing),
+ clear,
+ onClearChange,
+ )
+ }
+ }
+}
diff --git a/app/src/main/java/app/passwordstore/ui/autofill/AutofillFilterView.kt b/app/src/main/java/app/passwordstore/ui/autofill/AutofillFilterView.kt
index b027657e..c607b81f 100644
--- a/app/src/main/java/app/passwordstore/ui/autofill/AutofillFilterView.kt
+++ b/app/src/main/java/app/passwordstore/ui/autofill/AutofillFilterView.kt
@@ -10,36 +10,36 @@ import android.content.Intent
import android.content.IntentSender
import android.os.Build
import android.os.Bundle
-import android.view.View
import android.view.autofill.AutofillManager
-import android.widget.TextView
+import androidx.activity.compose.setContent
import androidx.activity.result.contract.ActivityResultContracts.StartActivityForResult
import androidx.activity.viewModels
import androidx.appcompat.app.AppCompatActivity
-import androidx.core.text.bold
-import androidx.core.text.buildSpannedString
-import androidx.core.text.underline
-import androidx.core.widget.addTextChangedListener
-import androidx.lifecycle.lifecycleScope
-import androidx.recyclerview.widget.LinearLayoutManager
+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.lifecycle.compose.collectAsStateWithLifecycle
import app.passwordstore.R
-import app.passwordstore.data.password.PasswordItem
-import app.passwordstore.databinding.ActivityOreoAutofillFilterBinding
+import app.passwordstore.data.repo.PasswordRepository
+import app.passwordstore.ui.compose.theme.APSTheme
+import app.passwordstore.ui.list.ListRow
import app.passwordstore.util.autofill.AutofillMatcher
import app.passwordstore.util.autofill.AutofillPreferences
import app.passwordstore.util.coroutines.DispatcherProvider
-import app.passwordstore.util.extensions.viewBinding
-import app.passwordstore.util.settings.DirectoryStructure
import app.passwordstore.util.viewmodel.FilterMode
import app.passwordstore.util.viewmodel.ListMode
import app.passwordstore.util.viewmodel.SearchMode
-import app.passwordstore.util.viewmodel.SearchableRepositoryAdapter
import app.passwordstore.util.viewmodel.SearchableRepositoryViewModel
import com.github.androidpasswordstore.autofillparser.FormOrigin
import dagger.hilt.android.AndroidEntryPoint
+import java.io.File
import javax.inject.Inject
-import kotlinx.coroutines.flow.collect
-import kotlinx.coroutines.launch
+import kotlinx.collections.immutable.ImmutableList
+import kotlinx.coroutines.flow.flowOn
+import kotlinx.coroutines.flow.map
import logcat.LogPriority.ERROR
import logcat.logcat
@@ -85,11 +85,12 @@ class AutofillFilterView : AppCompatActivity() {
}
private lateinit var formOrigin: FormOrigin
- private lateinit var directoryStructure: DirectoryStructure
- private val binding by viewBinding(ActivityOreoAutofillFilterBinding::inflate)
private val model: SearchableRepositoryViewModel by viewModels()
+ /** Set by the first tap on a row: a double tap must not start a second decryption. */
+ private var picked = false
+
private val decryptAction =
registerForActivityResult(StartActivityForResult()) { result ->
if (result.resultCode == RESULT_OK) {
@@ -100,7 +101,10 @@ class AutofillFilterView : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
- setContentView(binding.root)
+ // The picker decides which entry fills the requesting app: drop touches under an overlay, as
+ // the
+ // publisher warning does.
+ window.decorView.filterTouchesWhenObscured = true
setFinishOnTouchOutside(true)
val params = window.attributes
@@ -129,111 +133,77 @@ class AutofillFilterView : AppCompatActivity() {
return
}
}
- directoryStructure = AutofillPreferences.directoryStructure(this)
-
supportActionBar?.hide()
- bindUI()
- updateSearch()
setResult(RESULT_CANCELED)
- }
- private fun bindUI() {
- with(binding) {
- rvPassword.apply {
- adapter =
- SearchableRepositoryAdapter(
- R.layout.oreo_autofill_filter_row,
- ::PasswordViewHolder,
- lifecycleScope,
- dispatcherProvider,
- ) { item, _ ->
- val file = item.file.relativeTo(item.rootDir)
- val pathToIdentifier = directoryStructure.getPathToIdentifierFor(file)
- val identifier = directoryStructure.getIdentifierFor(file)
- val accountPart = directoryStructure.getAccountPartFor(file)
- check(identifier != null || accountPart != null) {
- "At least one of identifier and accountPart should always be non-null"
- }
- title.text =
- if (identifier != null) {
- buildSpannedString {
- if (pathToIdentifier != null) append("$pathToIdentifier/")
- bold { underline { append(identifier) } }
- }
- } else {
- accountPart
- }
- subtitle.apply {
- if (identifier != null && accountPart != null) {
- text = accountPart
- visibility = View.VISIBLE
- } else {
- visibility = View.GONE
- }
- }
- }
- .onItemClicked { _, item -> decryptAndFill(item) }
- layoutManager = LinearLayoutManager(context)
- }
- search.apply {
- val initialSearch = formOrigin.getPrettyIdentifier(applicationContext, untrusted = false)
- setText(initialSearch, TextView.BufferType.EDITABLE)
- addTextChangedListener { updateSearch() }
- }
- origin.text = buildSpannedString {
- append(getString(R.string.oreo_autofill_select_and_fill_into))
- append("\n")
- bold { append(formOrigin.getPrettyIdentifier(applicationContext, untrusted = true)) }
- }
- strictDomainSearch.apply {
- visibility = if (formOrigin is FormOrigin.Web) View.VISIBLE else View.GONE
- isChecked = formOrigin is FormOrigin.Web
- setOnCheckedChangeListener { _, _ -> updateSearch() }
- }
- shouldMatch.text =
- getString(
- R.string.oreo_autofill_match_with,
- formOrigin.getPrettyIdentifier(applicationContext),
+ val isWeb = formOrigin is FormOrigin.Web
+ val initialQuery = formOrigin.getPrettyIdentifier(applicationContext, untrusted = false)
+ val root = PasswordRepository.getRepositoryDirectory()
+ val directoryStructure = AutofillPreferences.directoryStructure(this)
+ // ponytail: cold flow, so each return to STARTED walks the store again; fine for a picker open
+ // a few seconds. Share it with stateIn in the ViewModel if a large store makes it slow.
+ val rows =
+ model.searchResult
+ .map { result ->
+ autofillRows(result.passwordItems.map { it.file }, root, directoryStructure)
+ }
+ .flowOn(dispatcherProvider.io())
+ val origin = formOrigin.getPrettyIdentifier(applicationContext, untrusted = true)
+ val matchLabel =
+ getString(
+ R.string.oreo_autofill_match_with,
+ formOrigin.getPrettyIdentifier(applicationContext),
+ )
+
+ setContent {
+ APSTheme {
+ var query by rememberSaveable { mutableStateOf(initialQuery) }
+ var strict by rememberSaveable { mutableStateOf(isWeb) }
+ var match by rememberSaveable { mutableStateOf(true) }
+ var clear by rememberSaveable { mutableStateOf(false) }
+ // During the first composition, before rows is collected, so the ViewModel's default root
+ // listing (folders included) is never shown, and with the restored query and switch after
+ // recreation. The LaunchedEffect repeats it with equal arguments, which emits nothing.
+ remember { search(query, strict) }
+ LaunchedEffect(query, strict) { search(query, strict) }
+ val shown by rows.collectAsStateWithLifecycle<ImmutableList<ListRow>?>(initialValue = null)
+ AutofillFilterScreen(
+ origin = origin,
+ query = query,
+ rows = shown,
+ showStrict = isWeb,
+ strict = strict,
+ matchLabel = matchLabel,
+ match = match,
+ clear = clear,
+ onQueryChange = { query = it },
+ onStrictChange = { strict = it },
+ onMatchChange = { match = it },
+ onClearChange = { clear = it },
+ onPick = { path -> decryptAndFill(File(path), match, clear) },
)
- lifecycleScope.launch { handleSearchResults() }
- }
- }
-
- private suspend fun handleSearchResults() {
- model.searchResult.collect { result ->
- val list = result.passwordItems
- (binding.rvPassword.adapter as SearchableRepositoryAdapter).submitList(list) {
- binding.rvPassword.scrollToPosition(0)
- }
- // Switch RecyclerView out for a "no results" message if the new list is empty and
- // the message is not yet shown (and vice versa).
- if (
- (list.isEmpty() && binding.rvPasswordSwitcher.nextView.id == binding.rvPasswordEmpty.id) ||
- (list.isNotEmpty() && binding.rvPasswordSwitcher.nextView.id == binding.rvPassword.id)
- ) {
- binding.rvPasswordSwitcher.showNext()
}
}
}
- private fun updateSearch() {
+ /** The old picker's search: fuzzy or strict domain, recursive, entries only. */
+ private fun search(query: String, strict: Boolean) {
model.search(
- binding.search.text.toString().trim(),
- filterMode =
- if (binding.strictDomainSearch.isChecked) FilterMode.StrictDomain else FilterMode.Fuzzy,
+ query.trim(),
+ filterMode = if (strict) FilterMode.StrictDomain else FilterMode.Fuzzy,
searchMode = SearchMode.RecursivelyInSubdirectories,
listMode = ListMode.FilesOnly,
)
}
- private fun decryptAndFill(item: PasswordItem) {
- if (binding.shouldClear.isChecked)
- AutofillMatcher.clearMatchesFor(applicationContext, formOrigin)
- if (binding.shouldMatch.isChecked)
- AutofillMatcher.addMatchFor(applicationContext, formOrigin, item.file)
+ private fun decryptAndFill(file: File, match: Boolean, clear: Boolean) {
+ if (picked) return
+ picked = true
+ if (clear) AutofillMatcher.clearMatchesFor(applicationContext, formOrigin)
+ if (match) AutofillMatcher.addMatchFor(applicationContext, formOrigin, file)
// intent?.extras? is checked to be non-null in onCreate
decryptAction.launch(
- AutofillDecryptActivity.makeDecryptFileIntent(item.file, intent!!.extras!!, this)
+ AutofillDecryptActivity.makeDecryptFileIntent(file, intent!!.extras!!, this)
)
}
}
diff --git a/app/src/main/java/app/passwordstore/ui/autofill/AutofillModel.kt b/app/src/main/java/app/passwordstore/ui/autofill/AutofillModel.kt
new file mode 100644
index 00000000..9f436ea2
--- /dev/null
+++ b/app/src/main/java/app/passwordstore/ui/autofill/AutofillModel.kt
@@ -0,0 +1,47 @@
+/*
+ * Copyright (C) 2026 Danilo M. <danix@danix.xyz>
+ * SPDX-License-Identifier: GPL-3.0-only
+ */
+
+package app.passwordstore.ui.autofill
+
+import app.passwordstore.ui.list.ListRow
+import app.passwordstore.util.auth.BiometricAuthenticator.Result as BiometricResult
+import app.passwordstore.util.settings.DirectoryStructure
+import java.io.File
+import kotlinx.collections.immutable.ImmutableList
+import kotlinx.collections.immutable.toImmutableList
+
+/**
+ * The autofill picker's rows for [files] (entries under [root], already ranked), in their order.
+ * The name is the origin identifier per [structure], the part that must match the site or app, so
+ * it stands out as the old picker's bold part did; the subtitle holds the account part and the
+ * folders above the identifier. Without an identifier the account part is the name.
+ */
+fun autofillRows(
+ files: List<File>,
+ root: File,
+ structure: DirectoryStructure,
+): ImmutableList<ListRow> =
+ files
+ .map { file ->
+ val relative = file.relativeTo(root)
+ val identifier = structure.getIdentifierFor(relative)
+ val account = structure.getAccountPartFor(relative)
+ val subtitle =
+ if (identifier == null) null
+ else
+ listOfNotNull(account, structure.getPathToIdentifierFor(relative))
+ .joinToString(" · ")
+ .ifEmpty { null }
+ ListRow(file.absolutePath, identifier ?: account ?: file.nameWithoutExtension, subtitle, 0)
+ }
+ .toImmutableList()
+
+/**
+ * Whether autofill may cache the passphrase it just decrypted with: only with the cache feature on
+ * ([cacheEnabled]) and after a successful screen lock prompt ([auth]), as the entry view does. A
+ * passphrase typed after a cancelled, failed or unavailable prompt is never kept.
+ */
+fun cachesPassphrase(cacheEnabled: Boolean, auth: BiometricResult): Boolean =
+ cacheEnabled && auth is BiometricResult.Success
diff --git a/app/src/main/java/app/passwordstore/ui/autofill/AutofillPublisherChangedActivity.kt b/app/src/main/java/app/passwordstore/ui/autofill/AutofillPublisherChangedActivity.kt
index 5b55143a..803896a5 100644
--- a/app/src/main/java/app/passwordstore/ui/autofill/AutofillPublisherChangedActivity.kt
+++ b/app/src/main/java/app/passwordstore/ui/autofill/AutofillPublisherChangedActivity.kt
@@ -13,21 +13,20 @@ import android.os.Build
import android.os.Bundle
import android.service.autofill.FillResponse
import android.text.format.DateUtils
-import android.view.View
import android.view.autofill.AutofillManager
+import androidx.activity.compose.setContent
import androidx.appcompat.app.AppCompatActivity
import androidx.core.content.IntentCompat
import app.passwordstore.R
-import app.passwordstore.databinding.ActivityOreoAutofillPublisherChangedBinding
+import app.passwordstore.ui.compose.theme.APSTheme
import app.passwordstore.util.autofill.AutofillMatcher
import app.passwordstore.util.autofill.AutofillPublisherChangedException
import app.passwordstore.util.extensions.asLog
import app.passwordstore.util.extensions.getApplicationInfoCompat
import app.passwordstore.util.extensions.getPackageInfoCompat
-import app.passwordstore.util.extensions.viewBinding
import com.github.androidpasswordstore.autofillparser.FormOrigin
import com.github.androidpasswordstore.autofillparser.computeCertificatesHash
-import com.github.michaelbull.result.onFailure
+import com.github.michaelbull.result.getOrElse
import com.github.michaelbull.result.runCatching
import logcat.LogPriority.ERROR
import logcat.logcat
@@ -65,15 +64,15 @@ class AutofillPublisherChangedActivity : AppCompatActivity() {
}
}
- private lateinit var appPackage: String
- private val binding by viewBinding(ActivityOreoAutofillPublisherChangedBinding::inflate)
-
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
- setContentView(binding.root)
setFinishOnTouchOutside(true)
+ // Tapjacking guard: a touch that reaches this window through another app's window (an overlay
+ // drawn to trick the user into re-enabling) is dropped. ViewGroup.dispatchTouchEvent checks the
+ // decor view's own flag before dispatching to any child, so the ComposeView is covered.
+ window.decorView.filterTouchesWhenObscured = true
- appPackage =
+ val appPackage =
intent.getStringExtra(EXTRA_APP_PACKAGE)
?: run {
logcat(ERROR) { "AutofillPublisherChangedActivity started without EXTRA_PACKAGE_NAME" }
@@ -81,63 +80,57 @@ class AutofillPublisherChangedActivity : AppCompatActivity() {
return
}
supportActionBar?.hide()
- showPackageInfo()
- with(binding) {
- okButton.setOnClickListener { finish() }
- advancedButton.setOnClickListener {
- advancedButton.visibility = View.GONE
- warningAppAdvancedInfo.visibility = View.VISIBLE
- resetButton.visibility = View.VISIBLE
- }
- resetButton.setOnClickListener {
- AutofillMatcher.clearMatchesFor(
- this@AutofillPublisherChangedActivity,
- FormOrigin.App(appPackage),
- )
- val fillResponse =
- IntentCompat.getParcelableExtra(
- intent,
- EXTRA_FILL_RESPONSE_AFTER_RESET,
- FillResponse::class.java,
+ val (packageInfo, appInfo, hash) =
+ runCatching {
+ Triple(
+ packageManager.getPackageInfoCompat(appPackage, PackageManager.GET_META_DATA),
+ packageManager.getApplicationInfoCompat(appPackage, PackageManager.GET_META_DATA),
+ computeCertificatesHash(this, appPackage),
)
- setResult(
- RESULT_OK,
- Intent().apply { putExtra(AutofillManager.EXTRA_AUTHENTICATION_RESULT, fillResponse) },
+ }
+ .getOrElse { e ->
+ logcat(ERROR) { e.asLog("Failed to retrieve package info for $appPackage") }
+ finish()
+ return
+ }
+ val appName =
+ getString(
+ R.string.oreo_autofill_warning_publisher_app_name,
+ packageManager.getApplicationLabel(appInfo),
+ )
+ val installed =
+ getString(
+ R.string.oreo_autofill_warning_publisher_install_time,
+ DateUtils.getRelativeTimeSpanString(packageInfo.firstInstallTime),
+ )
+ val details =
+ getString(R.string.oreo_autofill_warning_publisher_advanced_info_template, appPackage, hash)
+ setContent {
+ APSTheme {
+ PublisherChangedScreen(
+ appName = appName,
+ installed = installed,
+ details = details,
+ onKeepDisabled = { finish() },
+ onReset = { reset(appPackage) },
)
- finish()
}
}
}
- private fun showPackageInfo() {
- runCatching {
- with(binding) {
- val packageInfo =
- packageManager.getPackageInfoCompat(appPackage, PackageManager.GET_META_DATA)
- val installTime = DateUtils.getRelativeTimeSpanString(packageInfo.firstInstallTime)
- warningAppInstallDate.text =
- getString(R.string.oreo_autofill_warning_publisher_install_time, installTime)
- val appInfo =
- packageManager.getApplicationInfoCompat(appPackage, PackageManager.GET_META_DATA)
- warningAppName.text =
- getString(
- R.string.oreo_autofill_warning_publisher_app_name,
- packageManager.getApplicationLabel(appInfo),
- )
-
- val currentHash =
- computeCertificatesHash(this@AutofillPublisherChangedActivity, appPackage)
- warningAppAdvancedInfo.text =
- getString(
- R.string.oreo_autofill_warning_publisher_advanced_info_template,
- appPackage,
- currentHash,
- )
- }
- }
- .onFailure { e ->
- logcat(ERROR) { e.asLog("Failed to retrieve package info for $appPackage") }
- finish()
- }
+ /** Forgets the app's matches and fills with the response the service prepared for this case. */
+ private fun reset(appPackage: String) {
+ AutofillMatcher.clearMatchesFor(this, FormOrigin.App(appPackage))
+ val fillResponse =
+ IntentCompat.getParcelableExtra(
+ intent,
+ EXTRA_FILL_RESPONSE_AFTER_RESET,
+ FillResponse::class.java,
+ )
+ setResult(
+ RESULT_OK,
+ Intent().apply { putExtra(AutofillManager.EXTRA_AUTHENTICATION_RESULT, fillResponse) },
+ )
+ finish()
}
}
diff --git a/app/src/main/java/app/passwordstore/ui/autofill/AutofillSaveActivity.kt b/app/src/main/java/app/passwordstore/ui/autofill/AutofillSaveActivity.kt
index 9476b344..eb533411 100644
--- a/app/src/main/java/app/passwordstore/ui/autofill/AutofillSaveActivity.kt
+++ b/app/src/main/java/app/passwordstore/ui/autofill/AutofillSaveActivity.kt
@@ -9,12 +9,17 @@ import android.content.Context
import android.content.Intent
import android.content.IntentSender
import android.os.Bundle
+import android.view.WindowManager
import android.view.autofill.AutofillManager
-import androidx.activity.result.contract.ActivityResultContracts.StartActivityForResult
+import androidx.activity.compose.setContent
import androidx.appcompat.app.AppCompatActivity
import androidx.core.os.bundleOf
-import app.passwordstore.data.repo.PasswordRepository
-import app.passwordstore.ui.crypto.PasswordCreationActivity
+import app.passwordstore.data.passfile.PasswordEntry
+import app.passwordstore.ui.compose.theme.APSTheme
+import app.passwordstore.ui.editor.EditorDestination
+import app.passwordstore.ui.editor.EditorForm
+import app.passwordstore.ui.editor.SaveResult
+import app.passwordstore.ui.editor.passContent
import app.passwordstore.util.autofill.AutofillMatcher
import app.passwordstore.util.autofill.AutofillPreferences
import app.passwordstore.util.autofill.AutofillResponseBuilder
@@ -23,10 +28,15 @@ import com.github.androidpasswordstore.autofillparser.AutofillAction
import com.github.androidpasswordstore.autofillparser.Credentials
import com.github.androidpasswordstore.autofillparser.FormOrigin
import dagger.hilt.android.AndroidEntryPoint
-import java.io.File
+import javax.inject.Inject
import logcat.LogPriority.ERROR
import logcat.logcat
+/**
+ * Saves credentials the autofill service caught (or generates a password for a form) in the Compose
+ * editor, prefilled from the form's origin, then records the autofill match and, for a generated
+ * password, fills it in.
+ */
@AndroidEntryPoint
class AutofillSaveActivity : AppCompatActivity() {
@@ -102,60 +112,68 @@ class AutofillSaveActivity : AppCompatActivity() {
}
}
+ private val generate by unsafeLazy { intent.getBooleanExtra(EXTRA_GENERATE_PASSWORD, false) }
+
+ @Inject lateinit var passwordEntryFactory: PasswordEntry.Factory
+
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
- val repo = PasswordRepository.getRepositoryDirectory()
- val saveIntent =
- Intent(this, PasswordCreationActivity::class.java).apply {
- putExtras(
- bundleOf(
- "REPO_PATH" to repo.absolutePath,
- "FILE_PATH" to repo.resolve(intent.getStringExtra(EXTRA_FOLDER_NAME)!!).absolutePath,
- PasswordCreationActivity.EXTRA_FILE_NAME to intent.getStringExtra(EXTRA_NAME),
- PasswordCreationActivity.EXTRA_PASSWORD to intent.getStringExtra(EXTRA_PASSWORD),
- PasswordCreationActivity.EXTRA_GENERATE_PASSWORD to
- intent.getBooleanExtra(EXTRA_GENERATE_PASSWORD, false),
- )
+ // The whole window is the editor: keep it out of screenshots and recents.
+ window.addFlags(WindowManager.LayoutParams.FLAG_SECURE)
+ val initial =
+ EditorForm(
+ name = intent.getStringExtra(EXTRA_NAME).orEmpty(),
+ dir = intent.getStringExtra(EXTRA_FOLDER_NAME).orEmpty().trim('/'),
+ password = intent.getStringExtra(EXTRA_PASSWORD).orEmpty(),
+ )
+ setContent {
+ APSTheme {
+ EditorDestination(
+ activity = this,
+ initial = initial,
+ original = null,
+ onClose = {
+ setResult(RESULT_CANCELED)
+ finish()
+ },
+ onSaved = ::finishSaved,
+ generate = generate,
)
}
- registerForActivityResult(StartActivityForResult()) { result ->
- val data = result.data
- if (result.resultCode == RESULT_OK && data != null) {
- val createdPath = data.getStringExtra("CREATED_FILE")!!
- formOrigin?.let { AutofillMatcher.addMatchFor(this, it, File(createdPath)) }
- val password = data.getStringExtra("PASSWORD")
- val resultIntent =
- if (password != null) {
- // Password was generated and should be filled into a form.
- val username = data.getStringExtra("USERNAME")
- val clientState =
- intent?.getBundleExtra(AutofillManager.EXTRA_CLIENT_STATE)
- ?: run {
- logcat(ERROR) { "AutofillDecryptActivity started without EXTRA_CLIENT_STATE" }
- finish()
- return@registerForActivityResult
- }
- val credentials = Credentials(username, password, null)
- val fillInDataset =
- AutofillResponseBuilder.makeFillInDataset(
- this,
- credentials,
- clientState,
- AutofillAction.Generate,
- )
- Intent().apply {
- putExtra(AutofillManager.EXTRA_AUTHENTICATION_RESULT, fillInDataset)
- }
- } else {
- // Password was extracted from a form, there is nothing to fill.
- Intent()
+ }
+ }
+
+ /** Records the autofill match and, for a generated password, returns it to fill the form. */
+ private fun finishSaved(saved: SaveResult.Saved) {
+ formOrigin?.let { AutofillMatcher.addMatchFor(this, it, saved.file) }
+ val resultIntent =
+ if (generate) {
+ val clientState =
+ intent?.getBundleExtra(AutofillManager.EXTRA_CLIENT_STATE)
+ ?: run {
+ logcat(ERROR) { "AutofillSaveActivity started without EXTRA_CLIENT_STATE" }
+ finish()
+ return
}
- setResult(RESULT_OK, resultIntent)
- } else {
- setResult(RESULT_CANCELED)
- }
- finish()
+ // Read back from the saved content, as the old editor did, so a username in the extras
+ // counts too.
+ val entry = passwordEntryFactory.create(passContent(saved.form).encodeToByteArray())
+ val username =
+ entry.username ?: AutofillPreferences.directoryStructure(this).getUsernameFor(saved.file)
+ val credentials = Credentials(username, entry.password, null)
+ val fillInDataset =
+ AutofillResponseBuilder.makeFillInDataset(
+ this,
+ credentials,
+ clientState,
+ AutofillAction.Generate,
+ )
+ Intent().putExtra(AutofillManager.EXTRA_AUTHENTICATION_RESULT, fillInDataset)
+ } else {
+ // The password came from the form: there is nothing to fill.
+ Intent()
}
- .launch(saveIntent)
+ setResult(RESULT_OK, resultIntent)
+ finish()
}
}
diff --git a/app/src/main/java/app/passwordstore/ui/autofill/PasswordViewHolder.kt b/app/src/main/java/app/passwordstore/ui/autofill/PasswordViewHolder.kt
deleted file mode 100644
index 24a28564..00000000
--- a/app/src/main/java/app/passwordstore/ui/autofill/PasswordViewHolder.kt
+++ /dev/null
@@ -1,16 +0,0 @@
-/*
- * Copyright © 2014-2024 The Android Password Store Authors. All Rights Reserved.
- * SPDX-License-Identifier: GPL-3.0-only
- */
-package app.passwordstore.ui.autofill
-
-import android.view.View
-import android.widget.TextView
-import androidx.recyclerview.widget.RecyclerView
-import app.passwordstore.R
-
-class PasswordViewHolder(view: View) : RecyclerView.ViewHolder(view) {
-
- val title: TextView = itemView.findViewById(R.id.title)
- val subtitle: TextView = itemView.findViewById(R.id.subtitle)
-}
diff --git a/app/src/main/java/app/passwordstore/ui/autofill/PublisherChangedScreen.kt b/app/src/main/java/app/passwordstore/ui/autofill/PublisherChangedScreen.kt
new file mode 100644
index 00000000..6287408e
--- /dev/null
+++ b/app/src/main/java/app/passwordstore/ui/autofill/PublisherChangedScreen.kt
@@ -0,0 +1,125 @@
+/*
+ * Copyright (C) 2026 Danilo M. <danix@danix.xyz>
+ * SPDX-License-Identifier: GPL-3.0-only
+ */
+
+package app.passwordstore.ui.autofill
+
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.text.selection.SelectionContainer
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.material3.Button
+import androidx.compose.material3.Icon
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.OutlinedButton
+import androidx.compose.material3.Surface
+import androidx.compose.material3.Text
+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.res.painterResource
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.semantics.heading
+import androidx.compose.ui.semantics.semantics
+import androidx.compose.ui.text.font.FontFamily
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.unit.dp
+import app.passwordstore.R
+import app.passwordstore.ui.compose.theme.SpacingLarge
+import app.passwordstore.ui.compose.theme.SpacingLargest
+
+/**
+ * Warns that the app being filled ([appName], installed [installed]) is signed by another publisher
+ * than when an entry was first matched to it. [onKeepDisabled] fills nothing. [onReset] forgets the
+ * old matches and fills; it is offered only after "Advanced information", which shows [details]
+ * (package and certificate hash), so re-enabling always takes two deliberate taps.
+ */
+@Composable
+fun PublisherChangedScreen(
+ appName: String,
+ installed: String,
+ details: String,
+ onKeepDisabled: () -> Unit,
+ onReset: () -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ var advanced by rememberSaveable { mutableStateOf(false) }
+ val center = Modifier.fillMaxWidth()
+ Surface(modifier) {
+ Column(
+ Modifier.verticalScroll(rememberScrollState()).padding(SpacingLargest),
+ verticalArrangement = Arrangement.spacedBy(SpacingLarge),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ ) {
+ Icon(
+ painterResource(R.drawable.ic_warning_red_24dp),
+ contentDescription =
+ stringResource(R.string.oreo_autofill_warning_publisher_warning_sign_description),
+ modifier = Modifier.size(40.dp),
+ tint = MaterialTheme.colorScheme.error,
+ )
+ Text(
+ stringResource(R.string.oreo_autofill_warning_publisher_dataset_title),
+ modifier = center.semantics { heading() },
+ color = MaterialTheme.colorScheme.error,
+ textAlign = TextAlign.Center,
+ style = MaterialTheme.typography.titleLarge,
+ )
+ Text(
+ stringResource(R.string.oreo_autofill_warning_publisher_header),
+ style = MaterialTheme.typography.bodyMedium,
+ )
+ Text(
+ appName,
+ modifier = center,
+ textAlign = TextAlign.Center,
+ style = MaterialTheme.typography.titleMedium,
+ )
+ Text(
+ installed,
+ modifier = center,
+ textAlign = TextAlign.Center,
+ style = MaterialTheme.typography.bodySmall,
+ )
+ // ponytail: stringResource drops the footer's <b> in every locale but Italian (whose footer
+ // has none); the red title carries the emphasis. Keep spans with getText if it is missed.
+ Text(
+ stringResource(R.string.oreo_autofill_warning_publisher_footer),
+ style = MaterialTheme.typography.bodyMedium,
+ )
+ Button(onClick = onKeepDisabled, modifier = center) {
+ Text(
+ stringResource(R.string.oreo_autofill_warning_publisher_changed_disable_autofill_button)
+ )
+ }
+ if (!advanced) {
+ OutlinedButton(onClick = { advanced = true }, modifier = center) {
+ Text(stringResource(R.string.oreo_autofill_warning_publisher_advanced_info_button))
+ }
+ } else {
+ // The details come first, so Re-enable never sits where Advanced information was and a
+ // double tap cannot re-enable autofill.
+ SelectionContainer {
+ Text(
+ details,
+ modifier = center,
+ textAlign = TextAlign.Center,
+ style = MaterialTheme.typography.bodySmall.copy(fontFamily = FontFamily.Monospace),
+ )
+ }
+ OutlinedButton(onClick = onReset, modifier = center) {
+ Text(stringResource(R.string.oreo_autofill_warning_publisher_reenable_button))
+ }
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/app/passwordstore/ui/crypto/BasePGPActivity.kt b/app/src/main/java/app/passwordstore/ui/crypto/BasePGPActivity.kt
deleted file mode 100644
index 2b3f4dab..00000000
--- a/app/src/main/java/app/passwordstore/ui/crypto/BasePGPActivity.kt
+++ /dev/null
@@ -1,238 +0,0 @@
-/*
- * Copyright © 2014-2024 The Android Password Store Authors. All Rights Reserved.
- * SPDX-License-Identifier: GPL-3.0-only
- */
-
-package app.passwordstore.ui.crypto
-
-import android.content.ClipData
-import android.content.Intent
-import android.content.SharedPreferences
-import android.os.Build
-import android.os.Bundle
-import android.os.PersistableBundle
-import android.view.WindowManager
-import androidx.activity.result.contract.ActivityResultContracts.StartActivityForResult
-import androidx.annotation.CallSuper
-import androidx.annotation.StringRes
-import androidx.appcompat.app.AppCompatActivity
-import androidx.lifecycle.lifecycleScope
-import app.passwordstore.R
-import app.passwordstore.crypto.PGPIdentifier
-import app.passwordstore.data.crypto.CryptoRepository
-import app.passwordstore.data.repo.PasswordRepository
-import app.passwordstore.injection.prefs.SettingsPreferences
-import app.passwordstore.ui.pgp.PGPKeyImportActivity
-import app.passwordstore.util.coroutines.DispatcherProvider
-import app.passwordstore.util.extensions.clipboard
-import app.passwordstore.util.extensions.getString
-import app.passwordstore.util.extensions.snackbar
-import app.passwordstore.util.extensions.unsafeLazy
-import app.passwordstore.util.services.ClipboardService
-import app.passwordstore.util.settings.Constants
-import app.passwordstore.util.settings.PreferenceKeys
-import com.google.android.material.dialog.MaterialAlertDialogBuilder
-import com.google.android.material.snackbar.Snackbar
-import dagger.hilt.android.AndroidEntryPoint
-import java.io.File
-import javax.inject.Inject
-import kotlinx.coroutines.launch
-import kotlinx.coroutines.withContext
-
-@Suppress("Registered")
-@AndroidEntryPoint
-open class BasePGPActivity : AppCompatActivity() {
-
- /** Full path to the repository */
- val repoPath by unsafeLazy { intent.getStringExtra("REPO_PATH")!! }
-
- /** Full path to the password file being worked on */
- val fullPath by unsafeLazy { intent.getStringExtra("FILE_PATH")!! }
-
- /**
- * Name of the password file
- *
- * Converts personal/auth.foo.org/john_doe@example.org.gpg to john_doe.example.org
- */
- val name: String by unsafeLazy { File(fullPath).nameWithoutExtension }
-
- /** Action to invoke if [keyImportAction] succeeds. */
- private var onKeyImport: (() -> Unit)? = null
- private val keyImportAction =
- registerForActivityResult(StartActivityForResult()) {
- if (it.resultCode == RESULT_OK) {
- onKeyImport?.invoke()
- onKeyImport = null
- } else {
- finish()
- }
- }
-
- /** [SharedPreferences] instance used by subclasses to persist settings */
- @SettingsPreferences @Inject lateinit var settings: SharedPreferences
- @Inject lateinit var repository: CryptoRepository
- @Inject lateinit var dispatcherProvider: DispatcherProvider
-
- /**
- * [onCreate] sets the window up with the right flags to prevent auth leaks through screenshots or
- * recent apps screen.
- */
- @CallSuper
- override fun onCreate(savedInstanceState: Bundle?) {
- super.onCreate(savedInstanceState)
- window.setFlags(WindowManager.LayoutParams.FLAG_SECURE, WindowManager.LayoutParams.FLAG_SECURE)
- }
-
- /**
- * Copies provided [text] to the clipboard. Shows a [Snackbar] which can be disabled by passing
- * [showSnackbar] as false.
- */
- fun copyTextToClipboard(
- text: String?,
- showSnackbar: Boolean = true,
- @StringRes snackbarTextRes: Int = R.string.clipboard_copied_text,
- ) {
- val clipboard = clipboard ?: return
- val clip = ClipData.newPlainText("pgp_handler_result_pm", text)
- clip.description.extras =
- PersistableBundle().apply { putBoolean("android.content.extra.IS_SENSITIVE", true) }
- clipboard.setPrimaryClip(clip)
- if (showSnackbar && Build.VERSION.SDK_INT < Build.VERSION_CODES.R) {
- snackbar(message = resources.getString(snackbarTextRes))
- }
- }
-
- /**
- * Function to execute [onKeysExist] only if there are PGP keys imported in the app's key manager.
- */
- fun requireKeysExist(onKeysExist: () -> Unit) {
- lifecycleScope.launch {
- val hasKeys = repository.hasKeys()
- if (!hasKeys) {
- withContext(dispatcherProvider.main()) {
- MaterialAlertDialogBuilder(this@BasePGPActivity)
- .setTitle(resources.getString(R.string.no_keys_imported_dialog_title))
- .setMessage(resources.getString(R.string.no_keys_imported_dialog_message))
- .setPositiveButton(resources.getString(R.string.button_label_import)) { _, _ ->
- onKeyImport = onKeysExist
- keyImportAction.launch(Intent(this@BasePGPActivity, PGPKeyImportActivity::class.java))
- }
- .show()
- }
- } else {
- onKeysExist()
- }
- }
- }
-
- /**
- * Copies a provided [password] string to the clipboard. This wraps [copyTextToClipboard] to hide
- * the default [Snackbar] and starts off an instance of [ClipboardService] to provide a way of
- * clearing the clipboard.
- */
- fun copyPasswordToClipboard(password: String?) {
- copyTextToClipboard(password)
- val clearAfter =
- settings.getString(PreferenceKeys.GENERAL_SHOW_TIME)?.toIntOrNull()
- ?: Constants.DEFAULT_DECRYPTION_TIMEOUT
-
- if (clearAfter != 0) {
- val service =
- Intent(this, ClipboardService::class.java).apply {
- action = ClipboardService.ACTION_START
- putExtra(ClipboardService.EXTRA_NOTIFICATION_TIME, clearAfter)
- }
- startForegroundService(service)
- }
- }
-
- /**
- * Get a list of available [PGPIdentifier]s for the current password repository. This method
- * throws when no identifiers were able to be parsed. If this method returns null, it means that
- * an invalid identifier was encountered and further execution must stop to let the user correct
- * the problem.
- */
- fun getPGPIdentifiers(subDir: String): List<PGPIdentifier>? {
- val repoRoot = PasswordRepository.getRepositoryDirectory()
- val gpgIdentifierFile =
- File(repoRoot, subDir).findTillRoot(".gpg-id", repoRoot)
- ?: File(repoRoot, ".gpg-id").apply { createNewFile() }
- val gpgIdentifiers =
- gpgIdentifierFile
- .readLines()
- .filter { it.isNotBlank() }
- .map { line ->
- PGPIdentifier.fromString(line)
- ?: run {
- // The line being empty means this is most likely an empty `.gpg-id`
- // file we created. Skip the validation so we can make the user add a
- // real ID.
- if (line.isEmpty()) return@run
- // Apparently `gpg-id` being the first line is also acceptable?
- if (line == "gpg-id") return@run
- if (line.removePrefix("0x").matches("[a-fA-F0-9]{8}".toRegex())) {
- snackbar(message = resources.getString(R.string.short_gpg_id))
- } else {
- snackbar(message = resources.getString(R.string.invalid_gpg_id))
- }
- return null
- }
- }
- .filterIsInstance<PGPIdentifier>()
- if (gpgIdentifiers.isEmpty()) {
- error("Failed to parse identifiers from .gpg-id: ${gpgIdentifierFile.readText()}")
- }
- return gpgIdentifiers
- }
-
- @Suppress("ReturnCount")
- private fun File.findTillRoot(fileName: String, rootPath: File): File? {
- val gpgFile = File(this, fileName)
- if (gpgFile.exists()) return gpgFile
-
- if (this.absolutePath == rootPath.absolutePath) {
- return null
- }
- val parent = parentFile
- return if (parent != null && parent.exists()) {
- parent.findTillRoot(fileName, rootPath)
- } else {
- null
- }
- }
-
- companion object {
-
- const val EXTRA_FILE_PATH = "FILE_PATH"
- const val EXTRA_REPO_PATH = "REPO_PATH"
-
- /** Gets the relative path to the repository */
- fun getRelativePath(fullPath: String, repositoryPath: String): String =
- fullPath.replace(repositoryPath, "").replace("/+".toRegex(), "/")
-
- /** Gets the Parent path, relative to the repository */
- fun getParentPath(fullPath: String, repositoryPath: String): String {
- val relativePath = getRelativePath(fullPath, repositoryPath)
- val index = relativePath.lastIndexOf("/")
- return "/${relativePath.substring(startIndex = 0, endIndex = index + 1)}/"
- .replace("/+".toRegex(), "/")
- }
-
- /** /path/to/store/social/facebook.gpg -> social/facebook */
- @JvmStatic
- fun getLongName(fullPath: String, repositoryPath: String, basename: String): String {
- var relativePath = getRelativePath(fullPath, repositoryPath)
- return if (relativePath.isNotEmpty() && relativePath != "/") {
- // remove preceding '/'
- relativePath = relativePath.substring(1)
- if (relativePath.endsWith('/')) {
- relativePath + basename
- } else {
- "$relativePath/$basename"
- }
- } else {
- basename
- }
- }
- }
-}
diff --git a/app/src/main/java/app/passwordstore/ui/crypto/CryptoExtensions.kt b/app/src/main/java/app/passwordstore/ui/crypto/CryptoExtensions.kt
new file mode 100644
index 00000000..fbaa534f
--- /dev/null
+++ b/app/src/main/java/app/passwordstore/ui/crypto/CryptoExtensions.kt
@@ -0,0 +1,149 @@
+/*
+ * Copyright © 2014-2024 The Android Password Store Authors. All Rights Reserved.
+ * SPDX-License-Identifier: GPL-3.0-only
+ */
+
+package app.passwordstore.ui.crypto
+
+import android.content.ClipData
+import android.content.Context
+import android.content.Intent
+import android.os.Build
+import android.os.PersistableBundle
+import android.widget.Toast
+import androidx.annotation.StringRes
+import androidx.fragment.app.FragmentActivity
+import app.passwordstore.R
+import app.passwordstore.crypto.PGPIdentifier
+import app.passwordstore.data.repo.PasswordRepository
+import app.passwordstore.util.extensions.clipboard
+import app.passwordstore.util.extensions.getString
+import app.passwordstore.util.extensions.sharedPrefs
+import app.passwordstore.util.extensions.snackbar
+import app.passwordstore.util.services.ClipboardService
+import app.passwordstore.util.settings.Constants
+import app.passwordstore.util.settings.PreferenceKeys
+import java.io.File
+
+/** Gets the relative path to the repository */
+private fun getRelativePath(fullPath: String, repositoryPath: String): String =
+ fullPath.replace(repositoryPath, "").replace("/+".toRegex(), "/")
+
+/** Gets the Parent path, relative to the repository */
+fun getParentPath(fullPath: String, repositoryPath: String): String {
+ val relativePath = getRelativePath(fullPath, repositoryPath)
+ val index = relativePath.lastIndexOf("/")
+ return "/${relativePath.substring(startIndex = 0, endIndex = index + 1)}/"
+ .replace("/+".toRegex(), "/")
+}
+
+/** /path/to/store/social/facebook.gpg -> social/facebook */
+fun getLongName(fullPath: String, repositoryPath: String, basename: String): String {
+ var relativePath = getRelativePath(fullPath, repositoryPath)
+ return if (relativePath.isNotEmpty() && relativePath != "/") {
+ // remove preceding '/'
+ relativePath = relativePath.substring(1)
+ if (relativePath.endsWith('/')) {
+ relativePath + basename
+ } else {
+ "$relativePath/$basename"
+ }
+ } else {
+ basename
+ }
+}
+
+/**
+ * Copies provided [text] to the clipboard, flagged sensitive. Below Android 11 it also shows a
+ * snackbar, which can be disabled by passing [showSnackbar] as false.
+ */
+fun FragmentActivity.copyTextToClipboard(
+ text: String?,
+ showSnackbar: Boolean = true,
+ @StringRes snackbarTextRes: Int = R.string.clipboard_copied_text,
+) {
+ val clipboard = clipboard ?: return
+ val clip = ClipData.newPlainText("pgp_handler_result_pm", text)
+ clip.description.extras =
+ PersistableBundle().apply { putBoolean("android.content.extra.IS_SENSITIVE", true) }
+ clipboard.setPrimaryClip(clip)
+ if (showSnackbar && Build.VERSION.SDK_INT < Build.VERSION_CODES.R) {
+ snackbar(message = resources.getString(snackbarTextRes))
+ }
+}
+
+/**
+ * Copies a provided [password] string to the clipboard. This wraps [copyTextToClipboard] and starts
+ * off an instance of [ClipboardService] to provide a way of clearing the clipboard.
+ */
+fun FragmentActivity.copyPasswordToClipboard(password: String?) {
+ copyTextToClipboard(password)
+ val clearAfter =
+ sharedPrefs.getString(PreferenceKeys.GENERAL_SHOW_TIME)?.toIntOrNull()
+ ?: Constants.DEFAULT_DECRYPTION_TIMEOUT
+
+ if (clearAfter != 0) {
+ val service =
+ Intent(this, ClipboardService::class.java).apply {
+ action = ClipboardService.ACTION_START
+ putExtra(ClipboardService.EXTRA_NOTIFICATION_TIME, clearAfter)
+ }
+ startForegroundService(service)
+ }
+}
+
+/**
+ * Get a list of available [PGPIdentifier]s for the current password repository. This method throws
+ * when no identifiers were able to be parsed. If this method returns null, an invalid identifier
+ * was encountered and reported with a toast: further execution must stop to let the user correct
+ * the problem.
+ */
+fun Context.getPGPIdentifiers(subDir: String): List<PGPIdentifier>? {
+ val repoRoot = PasswordRepository.getRepositoryDirectory()
+ val gpgIdentifierFile =
+ File(repoRoot, subDir).findTillRoot(".gpg-id", repoRoot)
+ ?: File(repoRoot, ".gpg-id").apply { createNewFile() }
+ val gpgIdentifiers =
+ gpgIdentifierFile
+ .readLines()
+ .filter { it.isNotBlank() }
+ .map { line ->
+ PGPIdentifier.fromString(line)
+ ?: run {
+ // The line being empty means this is most likely an empty `.gpg-id`
+ // file we created. Skip the validation so we can make the user add a
+ // real ID.
+ if (line.isEmpty()) return@run
+ // Apparently `gpg-id` being the first line is also acceptable?
+ if (line == "gpg-id") return@run
+ val message =
+ if (line.removePrefix("0x").matches("[a-fA-F0-9]{8}".toRegex())) R.string.short_gpg_id
+ else R.string.invalid_gpg_id
+ // A toast on the application context, not a snackbar: the autofill decryption
+ // finishes its window right after, and the toast must outlive it.
+ Toast.makeText(applicationContext, message, Toast.LENGTH_LONG).show()
+ return null
+ }
+ }
+ .filterIsInstance<PGPIdentifier>()
+ if (gpgIdentifiers.isEmpty()) {
+ error("Failed to parse identifiers from .gpg-id: ${gpgIdentifierFile.readText()}")
+ }
+ return gpgIdentifiers
+}
+
+@Suppress("ReturnCount")
+private fun File.findTillRoot(fileName: String, rootPath: File): File? {
+ val gpgFile = File(this, fileName)
+ if (gpgFile.exists()) return gpgFile
+
+ if (this.absolutePath == rootPath.absolutePath) {
+ return null
+ }
+ val parent = parentFile
+ return if (parent != null && parent.exists()) {
+ parent.findTillRoot(fileName, rootPath)
+ } else {
+ null
+ }
+}
diff --git a/app/src/main/java/app/passwordstore/ui/crypto/DecryptActivity.kt b/app/src/main/java/app/passwordstore/ui/crypto/DecryptActivity.kt
deleted file mode 100644
index 8b2109bf..00000000
--- a/app/src/main/java/app/passwordstore/ui/crypto/DecryptActivity.kt
+++ /dev/null
@@ -1,298 +0,0 @@
-/*
- * Copyright © 2014-2024 The Android Password Store Authors. All Rights Reserved.
- * SPDX-License-Identifier: GPL-3.0-only
- */
-
-package app.passwordstore.ui.crypto
-
-import android.content.Intent
-import android.os.Bundle
-import android.view.Menu
-import android.view.MenuItem
-import androidx.core.content.edit
-import androidx.fragment.app.setFragmentResultListener
-import androidx.lifecycle.lifecycleScope
-import app.passwordstore.Application.Companion.screenWasOff
-import app.passwordstore.R
-import app.passwordstore.crypto.PGPIdentifier
-import app.passwordstore.data.crypto.PGPPassphraseCache
-import app.passwordstore.data.passfile.PasswordEntry
-import app.passwordstore.data.password.FieldItem
-import app.passwordstore.databinding.DecryptLayoutBinding
-import app.passwordstore.ui.adapters.FieldItemAdapter
-import app.passwordstore.util.auth.BiometricAuthenticator
-import app.passwordstore.util.auth.BiometricAuthenticator.Result as BiometricResult
-import app.passwordstore.util.extensions.getString
-import app.passwordstore.util.extensions.unsafeLazy
-import app.passwordstore.util.extensions.viewBinding
-import app.passwordstore.util.features.Feature.EnablePGPPassphraseCache
-import app.passwordstore.util.features.Features
-import app.passwordstore.util.settings.Constants
-import app.passwordstore.util.settings.PreferenceKeys
-import com.github.michaelbull.result.onFailure
-import com.github.michaelbull.result.runCatching
-import dagger.hilt.android.AndroidEntryPoint
-import java.io.ByteArrayOutputStream
-import java.io.File
-import javax.inject.Inject
-import kotlin.time.Duration.Companion.seconds
-import kotlinx.coroutines.delay
-import kotlinx.coroutines.flow.first
-import kotlinx.coroutines.launch
-import kotlinx.coroutines.withContext
-import logcat.LogPriority.ERROR
-import logcat.asLog
-import logcat.logcat
-
-@AndroidEntryPoint
-class DecryptActivity : BasePGPActivity() {
-
- @Inject lateinit var passwordEntryFactory: PasswordEntry.Factory
- @Inject lateinit var passphraseCache: PGPPassphraseCache
- @Inject lateinit var features: Features
-
- private val binding by viewBinding(DecryptLayoutBinding::inflate)
- private val relativeParentPath by unsafeLazy { getParentPath(fullPath, repoPath) }
- private var passwordEntry: PasswordEntry? = null
- private var retries = 0
- private var clearCache = true
-
- override fun onCreate(savedInstanceState: Bundle?) {
- super.onCreate(savedInstanceState)
- supportActionBar?.setDisplayHomeAsUpEnabled(true)
- title = name
- with(binding) {
- setContentView(root)
- passwordCategory.text = relativeParentPath
- passwordFile.text = name
- passwordFile.setOnLongClickListener {
- copyTextToClipboard(name)
- true
- }
- }
- if (
- features.isEnabled(EnablePGPPassphraseCache) &&
- BiometricAuthenticator.canAuthenticate(this@DecryptActivity)
- ) {
- BiometricAuthenticator.authenticate(
- this@DecryptActivity,
- R.string.biometric_prompt_title_gpg_passphrase_cache,
- ) { authResult ->
- requireKeysExist { decrypt(isError = false, authResult) }
- }
- } else {
- requireKeysExist { decrypt(isError = false, BiometricResult.CanceledByUser) }
- }
- }
-
- override fun onCreateOptionsMenu(menu: Menu): Boolean {
- menuInflater.inflate(R.menu.pgp_handler, menu)
- passwordEntry?.let { entry ->
- menu.findItem(R.id.edit_password).isVisible = true
- if (!entry.password.isNullOrBlank()) {
- menu.findItem(R.id.share_password_as_plaintext).isVisible = true
- menu.findItem(R.id.copy_password).isVisible = true
- }
- }
- return true
- }
-
- override fun onOptionsItemSelected(item: MenuItem): Boolean {
- when (item.itemId) {
- android.R.id.home -> onBackPressedDispatcher.onBackPressed()
- R.id.edit_password -> editPassword()
- R.id.share_password_as_plaintext -> shareAsPlaintext()
- R.id.copy_password -> copyPasswordToClipboard(passwordEntry?.password)
- else -> return super.onOptionsItemSelected(item)
- }
- return true
- }
-
- /**
- * Automatically finishes the activity after [PreferenceKeys.GENERAL_SHOW_TIME] seconds decryption
- * succeeded to prevent information leaks from stale activities.
- */
- private fun startAutoDismissTimer() {
- lifecycleScope.launch {
- val timeout =
- settings.getString(PreferenceKeys.GENERAL_SHOW_TIME)?.toIntOrNull()
- ?: Constants.DEFAULT_DECRYPTION_TIMEOUT
- if (timeout != 0) {
- delay(timeout.seconds)
- finish()
- }
- }
- }
-
- /**
- * Edit the current password and hide all the fields populated by encrypted data so that when the
- * result triggers they can be repopulated with new data.
- */
- private fun editPassword() {
- val intent = Intent(this, PasswordCreationActivity::class.java)
- intent.action = Intent.ACTION_VIEW
- intent.putExtra("FILE_PATH", relativeParentPath)
- intent.putExtra("REPO_PATH", repoPath)
- intent.putExtra(PasswordCreationActivity.EXTRA_FILE_NAME, name)
- intent.putExtra(PasswordCreationActivity.EXTRA_USERNAME, passwordEntry?.username)
- intent.putExtra(PasswordCreationActivity.EXTRA_PASSWORD, passwordEntry?.password)
- intent.putExtra(
- PasswordCreationActivity.EXTRA_EXTRA_CONTENT,
- passwordEntry?.extraContentWithoutUsername,
- )
- intent.putExtra(PasswordCreationActivity.EXTRA_EDITING, true)
- startActivity(intent)
- finish()
- }
-
- private fun shareAsPlaintext() {
- val sendIntent =
- Intent().apply {
- action = Intent.ACTION_SEND
- putExtra(Intent.EXTRA_TEXT, passwordEntry?.password)
- type = "text/plain"
- }
- // Always show a picker to give the user a chance to cancel
- startActivity(
- Intent.createChooser(sendIntent, resources.getText(R.string.send_plaintext_password_to))
- )
- }
-
- private fun decrypt(isError: Boolean, authResult: BiometricResult) {
- val gpgIdentifiers = getPGPIdentifiers(relativeParentPath) ?: return
- lifecycleScope.launch(dispatcherProvider.main()) {
- when (authResult) {
- // Internally handled by the prompt dialog
- is BiometricResult.Retry -> {}
- // If the dialog is dismissed for any reason, prompt for passphrase
- is BiometricResult.CanceledByUser,
- is BiometricResult.CanceledBySystem,
- is BiometricResult.Failure,
- is BiometricResult.HardwareUnavailableOrDisabled ->
- askPassphrase(isError, gpgIdentifiers, authResult)
- is BiometricResult.Success -> {
- /* clear passphrase cache on first use after application startup or if screen was off;
- also make sure to purge a stale cache after caching has been disabled via PGP settings */
- clearCache = settings.getBoolean(PreferenceKeys.CLEAR_PASSPHRASE_CACHE, true)
- if (screenWasOff && clearCache) {
- passphraseCache.clearAllCachedPassphrases(this@DecryptActivity)
- screenWasOff = false
- }
- val cachedPassphrase =
- passphraseCache.retrieveCachedPassphrase(this@DecryptActivity, gpgIdentifiers.first())
- if (cachedPassphrase != null) {
- decryptWithPassphrase(cachedPassphrase, gpgIdentifiers, authResult)
- } else {
- askPassphrase(isError, gpgIdentifiers, authResult)
- }
- }
- }
- }
- }
-
- private suspend fun askPassphrase(
- isError: Boolean,
- gpgIdentifiers: List<PGPIdentifier>,
- authResult: BiometricResult,
- ) {
- if (retries < MAX_RETRIES) {
- retries += 1
- } else {
- finish()
- }
- if (!repository.isPasswordProtected(gpgIdentifiers)) {
- decryptWithPassphrase(passphrase = "", gpgIdentifiers, authResult)
- return
- }
- val dialog =
- PasswordDialog.newInstance(
- cacheEnabled = features.isEnabled(EnablePGPPassphraseCache),
- clearCache = clearCache,
- )
- if (isError) {
- dialog.setError()
- }
- dialog.show(supportFragmentManager, "PASSWORD_DIALOG")
- dialog.setFragmentResultListener(PasswordDialog.PASSWORD_RESULT_KEY) { key, bundle ->
- if (key == PasswordDialog.PASSWORD_RESULT_KEY) {
- val passphrase = bundle.getString(PasswordDialog.PASSWORD_PHRASE_KEY)!!
- clearCache = bundle.getBoolean(PasswordDialog.PASSWORD_CLEAR_KEY)
- lifecycleScope.launch(dispatcherProvider.main()) {
- decryptWithPassphrase(passphrase, gpgIdentifiers, authResult) {
- runCatching {
- if (authResult is BiometricResult.Success) {
- passphraseCache.cachePassphrase(
- this@DecryptActivity,
- gpgIdentifiers.first(),
- passphrase,
- )
- settings.edit { putBoolean(PreferenceKeys.CLEAR_PASSPHRASE_CACHE, clearCache) }
- }
- }
- .onFailure { e -> logcat { e.asLog() } }
- }
- }
- }
- }
- }
-
- private suspend fun decryptWithPassphrase(
- passphrase: String,
- identifiers: List<PGPIdentifier>,
- authResult: BiometricResult,
- onSuccess: suspend () -> Unit = {},
- ) {
- val message = withContext(dispatcherProvider.io()) { File(fullPath).readBytes().inputStream() }
- val outputStream = ByteArrayOutputStream()
- val result = repository.decrypt(passphrase, identifiers, message, outputStream)
- if (result.isOk) {
- val entry = passwordEntryFactory.create(result.value.toByteArray())
- passwordEntry = entry
- createPasswordUI(entry)
- startAutoDismissTimer()
- onSuccess()
- } else {
- logcat(ERROR) { result.error.stackTraceToString() }
- decrypt(isError = true, authResult = authResult)
- }
- }
-
- private suspend fun createPasswordUI(entry: PasswordEntry) =
- withContext(dispatcherProvider.main()) {
- val labelFormat = resources.getString(R.string.otp_label_format)
- val showPassword = settings.getBoolean(PreferenceKeys.SHOW_PASSWORD, true)
- invalidateOptionsMenu()
-
- val items = arrayListOf<FieldItem>()
- if (!entry.password.isNullOrBlank()) {
- items.add(FieldItem.createPasswordField(getString(R.string.password), entry.password!!))
- if (settings.getBoolean(PreferenceKeys.COPY_ON_DECRYPT, false)) {
- copyPasswordToClipboard(entry.password)
- }
- }
-
- if (entry.hasTotp()) {
- items.add(FieldItem.createOtpField(labelFormat, entry.totp.first()))
- }
-
- if (!entry.username.isNullOrBlank()) {
- items.add(FieldItem.createUsernameField(getString(R.string.username), entry.username!!))
- }
-
- entry.extraContent.forEach { (key, value) ->
- items.add(FieldItem.createFreeformField(key, value))
- }
-
- val adapter = FieldItemAdapter(items, showPassword) { text -> copyTextToClipboard(text) }
- binding.recyclerView.adapter = adapter
- binding.recyclerView.itemAnimator = null
-
- if (entry.hasTotp()) {
- lifecycleScope.launch { entry.totp.collect { adapter.updateOTPCode(it, labelFormat) } }
- }
- }
-
- private companion object {
- private const val MAX_RETRIES = 3
- }
-}
diff --git a/app/src/main/java/app/passwordstore/ui/crypto/EditPasswordScreen.kt b/app/src/main/java/app/passwordstore/ui/crypto/EditPasswordScreen.kt
deleted file mode 100644
index d8610307..00000000
--- a/app/src/main/java/app/passwordstore/ui/crypto/EditPasswordScreen.kt
+++ /dev/null
@@ -1,105 +0,0 @@
-/*
- * Copyright © 2014-2024 The Android Password Store Authors. All Rights Reserved.
- * SPDX-License-Identifier: GPL-3.0-only
- */
-
-package app.passwordstore.ui.crypto
-
-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.material3.MaterialTheme
-import androidx.compose.material3.Scaffold
-import androidx.compose.material3.Text
-import androidx.compose.material3.TextField
-import androidx.compose.runtime.Composable
-import androidx.compose.ui.Modifier
-import androidx.compose.ui.res.painterResource
-import androidx.compose.ui.res.stringResource
-import androidx.compose.ui.tooling.preview.Preview
-import app.passwordstore.R
-import app.passwordstore.data.passfile.PasswordEntry
-import app.passwordstore.ui.APSAppBar
-import app.passwordstore.ui.compose.PasswordField
-import app.passwordstore.ui.compose.theme.APSTheme
-import app.passwordstore.ui.compose.theme.SpacingLarge
-import app.passwordstore.ui.compose.theme.SpacingMedium
-import app.passwordstore.util.time.UserClock
-import app.passwordstore.util.totp.UriTotpFinder
-
-/** Composable to show allow editing an existing [PasswordEntry]. */
-@Composable
-fun EditPasswordScreen(
- entryName: String,
- entry: PasswordEntry,
- onNavigateUp: () -> Unit,
- @Suppress("UNUSED_PARAMETER") onSave: (PasswordEntry) -> Unit,
- modifier: Modifier = Modifier,
-) {
- Scaffold(
- topBar = {
- APSAppBar(
- title = entryName,
- navigationIcon = painterResource(R.drawable.ic_arrow_back_black_24dp),
- onNavigationIconClick = onNavigateUp,
- backgroundColor = MaterialTheme.colorScheme.surface,
- )
- }
- ) { paddingValues ->
- Box(modifier = modifier.padding(paddingValues)) {
- Column(
- modifier =
- Modifier.padding(vertical = SpacingMedium, horizontal = SpacingLarge).fillMaxSize()
- ) {
- if (entry.password != null) {
- PasswordField(
- value = entry.password!!,
- label = stringResource(R.string.password),
- initialVisibility = false,
- modifier = Modifier.padding(bottom = SpacingMedium).fillMaxWidth(),
- )
- }
- ExtraContent(entry = entry)
- }
- }
- }
-}
-
-@Composable
-private fun ExtraContent(entry: PasswordEntry, modifier: Modifier = Modifier) {
- TextField(
- value = entry.extraContentString,
- onValueChange = {},
- label = { Text("Extra content") },
- modifier = modifier.fillMaxWidth(),
- )
-}
-
-@Preview
-@Composable
-private fun EditPasswordScreenPreview() {
- APSTheme {
- EditPasswordScreen(
- entryName = "Test Entry",
- entry = createTestEntry(),
- onNavigateUp = {},
- onSave = {},
- )
- }
-}
-
-private fun createTestEntry() =
- PasswordEntry(
- UserClock(),
- UriTotpFinder(),
- """
- |My Password
- |otpauth://totp/ACME%20Co:john@example.com?secret=HXDMVJECJJWSRB3HWIZR4IFUGFTMXBOZ&issuer=ACME%20Co&algorithm=SHA1&digits=6&period=30
- |login: msfjarvis
- |URL: example.com
- """
- .trimMargin()
- .encodeToByteArray(),
- )
diff --git a/app/src/main/java/app/passwordstore/ui/crypto/PasswordCreationActivity.kt b/app/src/main/java/app/passwordstore/ui/crypto/PasswordCreationActivity.kt
deleted file mode 100644
index 7652c98e..00000000
--- a/app/src/main/java/app/passwordstore/ui/crypto/PasswordCreationActivity.kt
+++ /dev/null
@@ -1,510 +0,0 @@
-/*
- * Copyright © 2014-2024 The Android Password Store Authors. All Rights Reserved.
- * SPDX-License-Identifier: GPL-3.0-only
- */
-
-package app.passwordstore.ui.crypto
-
-import android.content.Context
-import android.content.Intent
-import android.content.pm.PackageManager
-import android.graphics.Bitmap
-import android.graphics.ImageDecoder
-import android.os.Build
-import android.os.Bundle
-import android.provider.MediaStore
-import android.text.InputType
-import android.view.Menu
-import android.view.MenuItem
-import android.view.View
-import androidx.activity.result.contract.ActivityResultContracts
-import androidx.activity.result.contract.ActivityResultContracts.StartActivityForResult
-import androidx.core.content.edit
-import androidx.core.view.isVisible
-import androidx.core.widget.doAfterTextChanged
-import androidx.lifecycle.lifecycleScope
-import app.passwordstore.R
-import app.passwordstore.data.passfile.PasswordEntry
-import app.passwordstore.databinding.PasswordCreationActivityBinding
-import app.passwordstore.ui.dialogs.DicewarePasswordGeneratorDialogFragment
-import app.passwordstore.ui.dialogs.OtpImportDialogFragment
-import app.passwordstore.ui.dialogs.PasswordGeneratorDialogFragment
-import app.passwordstore.util.autofill.AutofillPreferences
-import app.passwordstore.util.extensions.asLog
-import app.passwordstore.util.extensions.base64
-import app.passwordstore.util.extensions.commitChange
-import app.passwordstore.util.extensions.getString
-import app.passwordstore.util.extensions.isInsideRepository
-import app.passwordstore.util.extensions.snackbar
-import app.passwordstore.util.extensions.unsafeLazy
-import app.passwordstore.util.extensions.viewBinding
-import app.passwordstore.util.settings.DirectoryStructure
-import app.passwordstore.util.settings.PreferenceKeys
-import com.github.michaelbull.result.getOrElse
-import com.github.michaelbull.result.onFailure
-import com.github.michaelbull.result.onSuccess
-import com.github.michaelbull.result.runCatching
-import com.google.android.material.dialog.MaterialAlertDialogBuilder
-import com.google.zxing.BinaryBitmap
-import com.google.zxing.LuminanceSource
-import com.google.zxing.RGBLuminanceSource
-import com.google.zxing.common.HybridBinarizer
-import com.google.zxing.integration.android.IntentIntegrator
-import com.google.zxing.integration.android.IntentIntegrator.QR_CODE
-import com.google.zxing.qrcode.QRCodeReader
-import dagger.hilt.android.AndroidEntryPoint
-import java.io.ByteArrayOutputStream
-import java.io.IOException
-import java.nio.file.Paths
-import javax.inject.Inject
-import kotlin.io.path.absolutePathString
-import kotlin.io.path.createDirectories
-import kotlin.io.path.deleteIfExists
-import kotlin.io.path.exists
-import kotlin.io.path.isSameFileAs
-import kotlin.io.path.nameWithoutExtension
-import kotlin.io.path.pathString
-import kotlin.io.path.relativeTo
-import kotlin.io.path.writeBytes
-import kotlinx.coroutines.launch
-import kotlinx.coroutines.withContext
-import logcat.LogPriority.ERROR
-import logcat.asLog
-import logcat.logcat
-
-@AndroidEntryPoint
-class PasswordCreationActivity : BasePGPActivity() {
-
- private val binding by viewBinding(PasswordCreationActivityBinding::inflate)
- @Inject lateinit var passwordEntryFactory: PasswordEntry.Factory
-
- private val suggestedName by unsafeLazy { intent.getStringExtra(EXTRA_FILE_NAME) }
- private val suggestedUsername by unsafeLazy { intent.getStringExtra(EXTRA_USERNAME) }
- private val suggestedPass by unsafeLazy { intent.getStringExtra(EXTRA_PASSWORD) }
- private val suggestedExtra by unsafeLazy { intent.getStringExtra(EXTRA_EXTRA_CONTENT) }
- private val shouldGeneratePassword by unsafeLazy {
- intent.getBooleanExtra(EXTRA_GENERATE_PASSWORD, false)
- }
- private val editing by unsafeLazy { intent.getBooleanExtra(EXTRA_EDITING, false) }
- private var oldCategory: String? = null
- private var copy: Boolean = false
-
- private val otpImportAction =
- registerForActivityResult(StartActivityForResult()) { result ->
- if (result.resultCode == RESULT_OK) {
- binding.otpImportButton.isVisible = false
- val intentResult = IntentIntegrator.parseActivityResult(RESULT_OK, result.data)
- val contents = "${intentResult.contents}\n"
- val currentExtras = binding.extraContent.text.toString()
- if (currentExtras.isNotEmpty() && currentExtras.last() != '\n')
- binding.extraContent.append("\n$contents")
- else binding.extraContent.append(contents)
- snackbar(message = getString(R.string.otp_import_success))
- } else {
- snackbar(message = getString(R.string.otp_import_failure_generic))
- }
- }
-
- private val imageImportAction =
- registerForActivityResult(ActivityResultContracts.GetContent()) { imageUri ->
- if (imageUri == null) {
- snackbar(message = getString(R.string.otp_import_failure_no_selection))
- return@registerForActivityResult
- }
- val bitmap =
- if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
- ImageDecoder.decodeBitmap(ImageDecoder.createSource(contentResolver, imageUri))
- .copy(Bitmap.Config.ARGB_8888, true)
- } else {
- @Suppress("DEPRECATION") MediaStore.Images.Media.getBitmap(contentResolver, imageUri)
- }
- val intArray = IntArray(bitmap.width * bitmap.height)
- // copy pixel data from the Bitmap into the 'intArray' array
- bitmap.getPixels(intArray, 0, bitmap.width, 0, 0, bitmap.width, bitmap.height)
- val source: LuminanceSource = RGBLuminanceSource(bitmap.width, bitmap.height, intArray)
- val binaryBitmap = BinaryBitmap(HybridBinarizer(source))
-
- val reader = QRCodeReader()
- runCatching {
- val result = reader.decode(binaryBitmap)
- val text = result.text
- val currentExtras = binding.extraContent.text.toString()
- if (currentExtras.isNotEmpty() && currentExtras.last() != '\n')
- binding.extraContent.append("\n$text")
- else binding.extraContent.append(text)
- snackbar(message = getString(R.string.otp_import_success))
- binding.otpImportButton.isVisible = false
- }
- .onFailure { snackbar(message = getString(R.string.otp_import_failure_generic)) }
- }
-
- override fun onCreate(savedInstanceState: Bundle?) {
- super.onCreate(savedInstanceState)
- supportActionBar?.setDisplayHomeAsUpEnabled(true)
- title =
- if (editing) getString(R.string.edit_password) else getString(R.string.new_password_title)
- with(binding) {
- setContentView(root)
- generatePassword.setOnClickListener { generatePassword() }
- otpImportButton.setOnClickListener {
- supportFragmentManager.setFragmentResultListener(
- OTP_RESULT_REQUEST_KEY,
- this@PasswordCreationActivity,
- ) { requestKey, bundle ->
- if (requestKey == OTP_RESULT_REQUEST_KEY) {
- val contents = bundle.getString(RESULT)
- val currentExtras = binding.extraContent.text.toString()
- if (currentExtras.isNotEmpty() && currentExtras.last() != '\n')
- binding.extraContent.append("\n$contents")
- else binding.extraContent.append(contents)
- }
- }
- val hasCamera = packageManager?.hasSystemFeature(PackageManager.FEATURE_CAMERA_ANY) == true
- if (hasCamera) {
- val items =
- arrayOf(
- getString(R.string.otp_import_qr_code),
- getString(R.string.otp_import_from_file),
- getString(R.string.otp_import_manual_entry),
- )
- MaterialAlertDialogBuilder(this@PasswordCreationActivity)
- .setItems(items) { _, index ->
- when (index) {
- 0 ->
- otpImportAction.launch(
- IntentIntegrator(this@PasswordCreationActivity)
- .setOrientationLocked(false)
- .setBeepEnabled(false)
- .setDesiredBarcodeFormats(QR_CODE)
- .createScanIntent()
- )
- 1 -> imageImportAction.launch("image/*")
- 2 -> OtpImportDialogFragment().show(supportFragmentManager, "OtpImport")
- }
- }
- .show()
- } else {
- OtpImportDialogFragment().show(supportFragmentManager, "OtpImport")
- }
- }
-
- directoryInputLayout.apply {
- if (suggestedName != null || suggestedPass != null || shouldGeneratePassword) {
- isEnabled = true
- } else {
- setBackgroundColor(getColor(android.R.color.transparent))
- }
- val path = getRelativePath(fullPath, repoPath)
- // Keep empty path field visible if it is editable.
- if (path.isEmpty() && !isEnabled) visibility = View.GONE
- else {
- directory.setText(path)
- oldCategory = path
- }
- }
- if (suggestedName != null) {
- filename.setText(suggestedName)
- } else {
- filename.requestFocus()
- }
-
- if (
- AutofillPreferences.directoryStructure(this@PasswordCreationActivity) ==
- DirectoryStructure.EncryptedUsername || suggestedUsername != null
- ) {
- usernameInputLayout.visibility = View.VISIBLE
- if (suggestedUsername != null) username.setText(suggestedUsername)
- else if (suggestedName != null) username.requestFocus()
- }
-
- // Allow the user to quickly switch between storing the username as the filename or
- // in the encrypted extras. This only makes sense if the directory structure is
- // FileBased.
- if (
- suggestedName == null &&
- AutofillPreferences.directoryStructure(this@PasswordCreationActivity) ==
- DirectoryStructure.FileBased
- ) {
- encryptUsername.apply {
- visibility = View.VISIBLE
- setOnClickListener {
- if (isChecked) {
- // User wants to enable username encryption, so we use the filename
- // as username and insert it into the username input field.
- val login = filename.text.toString()
- filename.text?.clear()
- username.setText(login)
- usernameInputLayout.apply { visibility = View.VISIBLE }
- } else {
- // User wants to disable username encryption, so we take the username
- // from the username text field and insert it into the filename input field.
- val login = username.text.toString()
- username.text?.clear()
- filename.setText(login)
- usernameInputLayout.apply { visibility = View.GONE }
- }
- }
- }
- }
- suggestedPass?.let {
- password.setText(it)
- password.inputType = InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_VARIATION_PASSWORD
- }
- suggestedExtra?.let { extraContent.setText(it) }
- if (shouldGeneratePassword) {
- generatePassword()
- password.inputType = InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_VARIATION_PASSWORD
- }
- }
- listOf(binding.filename, binding.username, binding.extraContent).forEach {
- it.doAfterTextChanged { updateViewState() }
- }
- updateViewState()
- }
-
- override fun onCreateOptionsMenu(menu: Menu): Boolean {
- menuInflater.inflate(R.menu.pgp_handler_new_password, menu)
- return true
- }
-
- override fun onOptionsItemSelected(item: MenuItem): Boolean {
- when (item.itemId) {
- android.R.id.home -> {
- setResult(RESULT_CANCELED)
- onBackPressedDispatcher.onBackPressed()
- }
- R.id.save_password -> {
- copy = false
- requireKeysExist { encrypt() }
- }
- R.id.save_and_copy_password -> {
- copy = true
- requireKeysExist { encrypt() }
- }
- else -> return super.onOptionsItemSelected(item)
- }
- return true
- }
-
- private fun generatePassword() {
- supportFragmentManager.setFragmentResultListener(PASSWORD_RESULT_REQUEST_KEY, this) {
- requestKey,
- bundle ->
- if (requestKey == PASSWORD_RESULT_REQUEST_KEY) {
- binding.password.setText(bundle.getString(RESULT))
- }
- }
- when (settings.getString(PreferenceKeys.PREF_KEY_PWGEN_TYPE) ?: KEY_PWGEN_TYPE_DICEWARE) {
- KEY_PWGEN_TYPE_CLASSIC ->
- PasswordGeneratorDialogFragment().show(supportFragmentManager, "generator")
- KEY_PWGEN_TYPE_DICEWARE ->
- DicewarePasswordGeneratorDialogFragment().show(supportFragmentManager, "generator")
- }
- }
-
- private fun updateViewState() =
- with(binding) {
- encryptUsername.apply {
- if (visibility != View.VISIBLE) return@apply
- val hasUsernameInFileName = filename.text.toString().isNotBlank()
- val usernameIsEncrypted = username.text.toString().isNotEmpty()
- isEnabled = hasUsernameInFileName xor usernameIsEncrypted
- isChecked = usernameIsEncrypted
- }
- // Use PasswordEntry to parse extras for OTP
- val entry =
- passwordEntryFactory.create("PLACEHOLDER\n${extraContent.text}".encodeToByteArray())
- otpImportButton.isVisible = !entry.hasTotp()
- }
-
- /** Encrypts the password and the extra content */
- private fun encrypt() {
- with(binding) {
- val oldName = suggestedName
- val editName = filename.text.toString().trim()
- var editUsername = username.text.toString()
- val editPass = password.text.toString()
- val editExtra = extraContent.text.toString()
-
- if (editName.isEmpty()) {
- snackbar(message = resources.getString(R.string.file_toast_text))
- return@with
- } else if (editName.contains('/')) {
- snackbar(message = resources.getString(R.string.invalid_filename_text))
- return@with
- }
-
- if (!editUsername.isEmpty()) {
- editUsername = "\nusername: $editUsername"
- }
-
- if (editPass.isEmpty() && editExtra.isEmpty()) {
- snackbar(message = resources.getString(R.string.empty_toast_text))
- return@with
- }
-
- if (copy) {
- copyPasswordToClipboard(editPass)
- }
-
- // pass enters the key ID into `.gpg-id`.
- val gpgIdentifiers = getPGPIdentifiers(directory.text.toString()) ?: return@with
- val content = "$editPass$editUsername\n$editExtra"
- val path =
- when {
- // If we allowed the user to edit the relative path, we have to consider it here
- // instead of fullPath.
- directoryInputLayout.isEnabled -> {
- val editRelativePath = directory.text.toString().trim()
- if (editRelativePath.isEmpty()) {
- snackbar(message = resources.getString(R.string.path_toast_text))
- return
- }
- val passwordDirectory = Paths.get(repoPath, editRelativePath.trim('/'))
- passwordDirectory.createDirectories()
- if (!passwordDirectory.exists()) {
- snackbar(
- message =
- "Failed to create directory ${passwordDirectory.relativeTo(Paths.get(repoPath)).pathString}"
- )
- return
- }
-
- "${passwordDirectory.pathString}/$editName.gpg"
- }
- else -> "$fullPath/$editName.gpg"
- }
-
- lifecycleScope.launch(dispatcherProvider.main()) {
- runCatching {
- val result =
- withContext(dispatcherProvider.io()) {
- val outputStream = ByteArrayOutputStream()
- repository.encrypt(gpgIdentifiers, content.byteInputStream(), outputStream)
- }
- .getOrElse { e ->
- logcat(ERROR) { e.asLog("Failed to encrypt password file") }
- MaterialAlertDialogBuilder(this@PasswordCreationActivity)
- .setTitle(R.string.password_creation_file_fail_title)
- .setMessage(
- getString(R.string.password_creation_encrypt_fail_message, e.message)
- )
- .setPositiveButton(android.R.string.ok, null)
- .show()
- return@runCatching
- }
- val passwordFile = Paths.get(path)
- // If we're not editing, this file should not already exist!
- // Additionally, if we were editing and the incoming and outgoing
- // filenames differ, it means we renamed. Ensure that the target
- // doesn't already exist to prevent an accidental overwrite.
- if (
- (!editing || (editing && suggestedName != passwordFile.nameWithoutExtension)) &&
- passwordFile.exists()
- ) {
- snackbar(message = getString(R.string.password_creation_duplicate_error))
- return@runCatching
- }
-
- if (!passwordFile.toFile().isInsideRepository()) {
- snackbar(message = getString(R.string.message_error_destination_outside_repo))
- return@runCatching
- }
-
- withContext(dispatcherProvider.io()) { passwordFile.writeBytes(result.toByteArray()) }
-
- // associate the new password name with the last name's timestamp in history
- val preference = getSharedPreferences("recent_password_history", Context.MODE_PRIVATE)
- val oldFilePathHash = "$repoPath/${oldCategory?.trim('/')}/$suggestedName.gpg".base64()
- val timestamp = preference.getString(oldFilePathHash)
- if (timestamp != null) {
- preference.edit {
- remove(oldFilePathHash)
- putString(passwordFile.absolutePathString().base64(), timestamp)
- }
- }
-
- val returnIntent = Intent()
- returnIntent.putExtra(RETURN_EXTRA_CREATED_FILE, path)
- returnIntent.putExtra(RETURN_EXTRA_NAME, editName)
- returnIntent.putExtra(RETURN_EXTRA_LONG_NAME, getLongName(fullPath, repoPath, editName))
-
- if (shouldGeneratePassword) {
- val directoryStructure = AutofillPreferences.directoryStructure(applicationContext)
- val entry = passwordEntryFactory.create(content.encodeToByteArray())
- returnIntent.putExtra(RETURN_EXTRA_PASSWORD, entry.password)
- val username =
- entry.username ?: directoryStructure.getUsernameFor(passwordFile.toFile())
- returnIntent.putExtra(RETURN_EXTRA_USERNAME, username)
- }
-
- if (
- directoryInputLayout.isVisible &&
- directoryInputLayout.isEnabled &&
- oldName != editName
- ) {
- val oldPath = Paths.get(repoPath, oldCategory?.trim('/') ?: "", "$oldName.gpg")
- if (!oldPath.isSameFileAs(passwordFile) && !oldPath.deleteIfExists()) {
- setResult(RESULT_CANCELED)
- MaterialAlertDialogBuilder(this@PasswordCreationActivity)
- .setTitle(R.string.password_creation_file_fail_title)
- .setMessage(
- getString(R.string.password_creation_file_delete_fail_message, oldName)
- )
- .setCancelable(false)
- .setPositiveButton(android.R.string.ok) { _, _ -> finish() }
- .show()
- return@runCatching
- }
- }
-
- val commitMessageRes =
- if (editing) R.string.git_commit_edit_text else R.string.git_commit_add_text
- lifecycleScope.launch {
- commitChange(
- resources.getString(commitMessageRes, getLongName(fullPath, repoPath, editName))
- )
- .onSuccess {
- setResult(RESULT_OK, returnIntent)
- finish()
- }
- }
- }
- .onFailure { e ->
- if (e is IOException) {
- logcat(ERROR) { e.asLog("Failed to write password file") }
- setResult(RESULT_CANCELED)
- MaterialAlertDialogBuilder(this@PasswordCreationActivity)
- .setTitle(getString(R.string.password_creation_file_fail_title))
- .setMessage(getString(R.string.password_creation_file_write_fail_message))
- .setCancelable(false)
- .setPositiveButton(android.R.string.ok) { _, _ -> finish() }
- .show()
- } else {
- logcat(ERROR) { e.asLog() }
- }
- }
- }
- }
- }
-
- companion object {
-
- private const val KEY_PWGEN_TYPE_CLASSIC = "classic"
- private const val KEY_PWGEN_TYPE_DICEWARE = "diceware"
- const val PASSWORD_RESULT_REQUEST_KEY = "PASSWORD_GENERATOR"
- const val OTP_RESULT_REQUEST_KEY = "OTP_IMPORT"
- const val RESULT = "RESULT"
- const val RETURN_EXTRA_CREATED_FILE = "CREATED_FILE"
- const val RETURN_EXTRA_NAME = "NAME"
- const val RETURN_EXTRA_LONG_NAME = "LONG_NAME"
- const val RETURN_EXTRA_USERNAME = "USERNAME"
- const val RETURN_EXTRA_PASSWORD = "PASSWORD"
- const val EXTRA_FILE_NAME = "FILENAME"
- const val EXTRA_USERNAME = "USERNAME"
- const val EXTRA_PASSWORD = "PASSWORD"
- const val EXTRA_EXTRA_CONTENT = "EXTRA_CONTENT"
- const val EXTRA_GENERATE_PASSWORD = "GENERATE_PASSWORD"
- const val EXTRA_EDITING = "EDITING"
- }
-}
diff --git a/app/src/main/java/app/passwordstore/ui/crypto/PasswordDialog.kt b/app/src/main/java/app/passwordstore/ui/crypto/PasswordDialog.kt
deleted file mode 100644
index d845d689..00000000
--- a/app/src/main/java/app/passwordstore/ui/crypto/PasswordDialog.kt
+++ /dev/null
@@ -1,105 +0,0 @@
-/*
- * Copyright © 2014-2024 The Android Password Store Authors. All Rights Reserved.
- * SPDX-License-Identifier: GPL-3.0-only
- */
-
-package app.passwordstore.ui.crypto
-
-import android.app.Dialog
-import android.content.DialogInterface
-import android.os.Bundle
-import android.view.KeyEvent
-import android.view.WindowManager
-import androidx.core.os.bundleOf
-import androidx.core.view.isVisible
-import androidx.core.widget.doOnTextChanged
-import androidx.fragment.app.DialogFragment
-import androidx.fragment.app.setFragmentResult
-import app.passwordstore.R
-import app.passwordstore.databinding.DialogPasswordEntryBinding
-import app.passwordstore.util.extensions.finish
-import app.passwordstore.util.extensions.unsafeLazy
-import com.google.android.material.dialog.MaterialAlertDialogBuilder
-
-/** [DialogFragment] to request a password from the user and forward it along. */
-class PasswordDialog : DialogFragment() {
-
- private val binding by unsafeLazy { DialogPasswordEntryBinding.inflate(layoutInflater) }
- private var isError: Boolean = false
- private var clearCacheChecked: Boolean = true
-
- override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
- val builder = MaterialAlertDialogBuilder(requireContext())
- builder.setView(binding.root)
- builder.setTitle(R.string.password)
-
- val cacheEnabled = requireArguments().getBoolean(CACHE_ENABLED_EXTRA, false)
- binding.autoClearCache.isVisible = cacheEnabled
-
- if (cacheEnabled) {
- clearCacheChecked = requireArguments().getBoolean(AUTO_CLEAR_CACHE_EXTRA)
- binding.autoClearCache.isChecked = clearCacheChecked
- binding.autoClearCache.setOnCheckedChangeListener { _, isChecked ->
- clearCacheChecked = isChecked
- }
- }
-
- builder.setPositiveButton(android.R.string.ok) { _, _ -> setPasswordAndDismiss() }
- val dialog = builder.create()
- dialog.window?.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_STATE_ALWAYS_VISIBLE)
- dialog.setOnShowListener {
- if (isError) {
- binding.passwordField.error = getString(R.string.git_operation_wrong_password)
- }
- binding.passwordEditText.doOnTextChanged { _, _, _, _ -> binding.passwordField.error = null }
- binding.passwordEditText.setOnKeyListener { _, keyCode, _ ->
- if (keyCode == KeyEvent.KEYCODE_ENTER) {
- setPasswordAndDismiss()
- return@setOnKeyListener true
- }
- false
- }
- }
- dialog.window?.setFlags(
- WindowManager.LayoutParams.FLAG_SECURE,
- WindowManager.LayoutParams.FLAG_SECURE,
- )
- return dialog
- }
-
- fun setError() {
- isError = true
- }
-
- override fun onCancel(dialog: DialogInterface) {
- super.onCancel(dialog)
- finish()
- }
-
- private fun setPasswordAndDismiss() {
- val password = binding.passwordEditText.text.toString()
- setFragmentResult(
- PASSWORD_RESULT_KEY,
- bundleOf(PASSWORD_PHRASE_KEY to password, PASSWORD_CLEAR_KEY to clearCacheChecked),
- )
- dismissAllowingStateLoss()
- }
-
- companion object {
-
- private const val CACHE_ENABLED_EXTRA = "CACHE_ENABLED"
- private const val AUTO_CLEAR_CACHE_EXTRA = "AUTO_CLEAR_CACHE"
-
- const val PASSWORD_RESULT_KEY = "password_result"
- const val PASSWORD_PHRASE_KEY = "password_phrase"
- const val PASSWORD_CLEAR_KEY = "password_clear"
-
- fun newInstance(cacheEnabled: Boolean, clearCache: Boolean): PasswordDialog {
- val extras =
- bundleOf(CACHE_ENABLED_EXTRA to cacheEnabled, AUTO_CLEAR_CACHE_EXTRA to clearCache)
- val fragment = PasswordDialog()
- fragment.arguments = extras
- return fragment
- }
- }
-}
diff --git a/app/src/main/java/app/passwordstore/ui/crypto/ViewPasswordScreen.kt b/app/src/main/java/app/passwordstore/ui/crypto/ViewPasswordScreen.kt
deleted file mode 100644
index f2a67706..00000000
--- a/app/src/main/java/app/passwordstore/ui/crypto/ViewPasswordScreen.kt
+++ /dev/null
@@ -1,132 +0,0 @@
-/*
- * Copyright © 2014-2024 The Android Password Store Authors. All Rights Reserved.
- * SPDX-License-Identifier: GPL-3.0-only
- */
-
-package app.passwordstore.ui.crypto
-
-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.material3.MaterialTheme
-import androidx.compose.material3.Scaffold
-import androidx.compose.material3.Text
-import androidx.compose.material3.TextField
-import androidx.compose.runtime.Composable
-import androidx.compose.runtime.collectAsState
-import androidx.compose.runtime.getValue
-import androidx.compose.ui.Modifier
-import androidx.compose.ui.res.painterResource
-import androidx.compose.ui.res.stringResource
-import androidx.compose.ui.text.capitalize
-import androidx.compose.ui.text.intl.Locale
-import androidx.compose.ui.tooling.preview.Preview
-import app.passwordstore.R
-import app.passwordstore.data.passfile.PasswordEntry
-import app.passwordstore.ui.APSAppBar
-import app.passwordstore.ui.compose.CopyButton
-import app.passwordstore.ui.compose.PasswordField
-import app.passwordstore.ui.compose.theme.APSTheme
-import app.passwordstore.ui.compose.theme.SpacingLarge
-import app.passwordstore.ui.compose.theme.SpacingMedium
-import app.passwordstore.util.time.UserClock
-import app.passwordstore.util.totp.UriTotpFinder
-import kotlinx.coroutines.flow.first
-import kotlinx.coroutines.runBlocking
-
-/** Composable to show a decrypted [PasswordEntry]. */
-@Composable
-fun ViewPasswordScreen(
- entryName: String,
- entry: PasswordEntry,
- onNavigateUp: () -> Unit,
- modifier: Modifier = Modifier,
-) {
- Scaffold(
- topBar = {
- APSAppBar(
- title = entryName,
- navigationIcon = painterResource(R.drawable.ic_arrow_back_black_24dp),
- onNavigationIconClick = onNavigateUp,
- backgroundColor = MaterialTheme.colorScheme.surface,
- )
- }
- ) { paddingValues ->
- Box(modifier = modifier.padding(paddingValues)) {
- Column(
- modifier =
- Modifier.padding(vertical = SpacingMedium, horizontal = SpacingLarge).fillMaxSize()
- ) {
- if (entry.password != null) {
- PasswordField(
- value = entry.password!!,
- label = stringResource(R.string.password),
- initialVisibility = false,
- readOnly = true,
- modifier = Modifier.padding(bottom = SpacingMedium).fillMaxWidth(),
- )
- }
- if (entry.hasTotp()) {
- val totp by entry.totp.collectAsState(runBlocking { entry.totp.first() })
- TextField(
- value = totp.value,
- onValueChange = {},
- readOnly = true,
- label = { Text("OTP (expires in ${totp.remainingTime.inWholeSeconds}s)") },
- trailingIcon = { CopyButton(totp.value, R.string.copy_label) },
- modifier = Modifier.padding(bottom = SpacingMedium).fillMaxWidth(),
- )
- }
- if (entry.username != null) {
- TextField(
- value = entry.username!!,
- onValueChange = {},
- readOnly = true,
- label = { Text(stringResource(R.string.username)) },
- trailingIcon = { CopyButton(entry.username!!, R.string.copy_label) },
- modifier = Modifier.padding(bottom = SpacingMedium).fillMaxWidth(),
- )
- }
- ExtraContent(entry = entry)
- }
- }
- }
-}
-
-@Composable
-private fun ExtraContent(entry: PasswordEntry, modifier: Modifier = Modifier) {
- entry.extraContent.forEach { (label, value) ->
- TextField(
- value = value,
- onValueChange = {},
- readOnly = true,
- label = { Text(label.capitalize(Locale.current)) },
- trailingIcon = { CopyButton(value, R.string.copy_label) },
- modifier = modifier.padding(bottom = SpacingMedium).fillMaxWidth(),
- )
- }
-}
-
-@Preview
-@Composable
-private fun ViewPasswordScreenPreview() {
- APSTheme {
- ViewPasswordScreen(entryName = "Test Entry", entry = createTestEntry(), onNavigateUp = {})
- }
-}
-
-private fun createTestEntry() =
- PasswordEntry(
- UserClock(),
- UriTotpFinder(),
- """
- |My Password
- |otpauth://totp/ACME%20Co:john@example.com?secret=HXDMVJECJJWSRB3HWIZR4IFUGFTMXBOZ&issuer=ACME%20Co&algorithm=SHA1&digits=6&period=30
- |login: msfjarvis
- |URL: example.com
- """
- .trimMargin()
- .encodeToByteArray(),
- )
diff --git a/app/src/main/java/app/passwordstore/ui/dialogs/BasicBottomSheet.kt b/app/src/main/java/app/passwordstore/ui/dialogs/BasicBottomSheet.kt
deleted file mode 100644
index e689f3bd..00000000
--- a/app/src/main/java/app/passwordstore/ui/dialogs/BasicBottomSheet.kt
+++ /dev/null
@@ -1,178 +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.content.Context
-import android.content.DialogInterface.OnDismissListener
-import android.os.Bundle
-import android.view.LayoutInflater
-import android.view.View
-import android.view.ViewGroup
-import android.view.ViewTreeObserver
-import android.widget.FrameLayout
-import androidx.annotation.StringRes
-import androidx.core.view.isVisible
-import app.passwordstore.R
-import app.passwordstore.databinding.BasicBottomSheetBinding
-import app.passwordstore.util.extensions.viewBinding
-import com.google.android.material.bottomsheet.BottomSheetBehavior
-import com.google.android.material.bottomsheet.BottomSheetDialog
-import com.google.android.material.bottomsheet.BottomSheetDialogFragment
-
-/**
- * [BottomSheetDialogFragment] that exposes a simple [androidx.appcompat.app.AlertDialog] like API
- * through [BasicBottomSheet.Builder] to create a similar UI, just at the bottom of the screen.
- */
-class BasicBottomSheet
-private constructor(
- val title: String?,
- val message: String,
- val positiveButtonLabel: String?,
- val negativeButtonLabel: String?,
- val positiveButtonClickListener: View.OnClickListener?,
- val negativeButtonClickListener: View.OnClickListener?,
- val onDismissListener: OnDismissListener?,
-) : BottomSheetDialogFragment() {
-
- private val binding by viewBinding(BasicBottomSheetBinding::bind)
-
- private var behavior: BottomSheetBehavior<FrameLayout>? = null
- private val bottomSheetCallback =
- object : BottomSheetBehavior.BottomSheetCallback() {
- override fun onSlide(bottomSheet: View, slideOffset: Float) {}
-
- override fun onStateChanged(bottomSheet: View, newState: Int) {
- if (newState == BottomSheetBehavior.STATE_COLLAPSED) {
- dismiss()
- }
- }
- }
-
- override fun onCreateView(
- inflater: LayoutInflater,
- container: ViewGroup?,
- savedInstanceState: Bundle?,
- ): View? {
- if (savedInstanceState != null) dismiss()
- return layoutInflater.inflate(R.layout.basic_bottom_sheet, container, false)
- }
-
- override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
- super.onViewCreated(view, savedInstanceState)
- view.viewTreeObserver.addOnGlobalLayoutListener(
- object : ViewTreeObserver.OnGlobalLayoutListener {
- override fun onGlobalLayout() {
- view.viewTreeObserver.removeOnGlobalLayoutListener(this)
- val dialog = dialog as BottomSheetDialog? ?: return
- behavior = dialog.behavior
- behavior?.apply {
- state = BottomSheetBehavior.STATE_EXPANDED
- peekHeight = 0
- addBottomSheetCallback(bottomSheetCallback)
- }
- if (!title.isNullOrEmpty()) {
- binding.bottomSheetTitle.isVisible = true
- binding.bottomSheetTitle.text = title
- }
- binding.bottomSheetMessage.text = message
- if (positiveButtonClickListener != null) {
- positiveButtonLabel?.let { buttonLbl -> binding.bottomSheetOkButton.text = buttonLbl }
- binding.bottomSheetOkButton.isVisible = true
- binding.bottomSheetOkButton.setOnClickListener {
- positiveButtonClickListener.onClick(it)
- dismiss()
- }
- }
- if (negativeButtonClickListener != null) {
- binding.bottomSheetCancelButton.isVisible = true
- negativeButtonLabel?.let { buttonLbl ->
- binding.bottomSheetCancelButton.text = buttonLbl
- }
- binding.bottomSheetCancelButton.setOnClickListener {
- negativeButtonClickListener.onClick(it)
- dismiss()
- }
- }
- if (onDismissListener != null) {
- dialog.setOnDismissListener(onDismissListener)
- }
- }
- }
- )
- }
-
- override fun dismiss() {
- super.dismiss()
- behavior?.removeBottomSheetCallback(bottomSheetCallback)
- }
-
- class Builder(val context: Context) {
-
- private var title: String? = null
- private var message: String? = null
- private var positiveButtonLabel: String? = null
- private var negativeButtonLabel: String? = null
- private var positiveButtonClickListener: View.OnClickListener? = null
- private var negativeButtonClickListener: View.OnClickListener? = null
- private var onDismissListener: OnDismissListener? = null
-
- fun setTitleRes(@StringRes titleRes: Int): Builder {
- this.title = context.resources.getString(titleRes)
- return this
- }
-
- fun setTitle(title: String): Builder {
- this.title = title
- return this
- }
-
- fun setMessageRes(@StringRes messageRes: Int): Builder {
- this.message = context.resources.getString(messageRes)
- return this
- }
-
- fun setMessage(message: String): Builder {
- this.message = message
- return this
- }
-
- fun setPositiveButtonClickListener(
- buttonLabel: String? = null,
- listener: View.OnClickListener,
- ): Builder {
- this.positiveButtonClickListener = listener
- this.positiveButtonLabel = buttonLabel
- return this
- }
-
- fun setNegativeButtonClickListener(
- buttonLabel: String? = null,
- listener: View.OnClickListener,
- ): Builder {
- this.negativeButtonClickListener = listener
- this.negativeButtonLabel = buttonLabel
- return this
- }
-
- fun setOnDismissListener(onDismissListener: OnDismissListener): Builder {
- this.onDismissListener = onDismissListener
- return this
- }
-
- fun build(): BasicBottomSheet {
- require(message != null) { "Message needs to be set" }
- return BasicBottomSheet(
- title,
- message!!,
- positiveButtonLabel,
- negativeButtonLabel,
- positiveButtonClickListener,
- negativeButtonClickListener,
- onDismissListener,
- )
- }
- }
-}
diff --git a/app/src/main/java/app/passwordstore/ui/dialogs/DicewarePasswordGeneratorDialogFragment.kt b/app/src/main/java/app/passwordstore/ui/dialogs/DicewarePasswordGeneratorDialogFragment.kt
deleted file mode 100644
index 2605e0ef..00000000
--- a/app/src/main/java/app/passwordstore/ui/dialogs/DicewarePasswordGeneratorDialogFragment.kt
+++ /dev/null
@@ -1,90 +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.AlertDialog
-import android.app.Dialog
-import android.content.SharedPreferences
-import android.graphics.Typeface
-import android.os.Bundle
-import androidx.core.content.edit
-import androidx.core.os.bundleOf
-import androidx.fragment.app.DialogFragment
-import androidx.fragment.app.setFragmentResult
-import androidx.lifecycle.lifecycleScope
-import app.passwordstore.R
-import app.passwordstore.databinding.FragmentPwgenDicewareBinding
-import app.passwordstore.injection.prefs.PasswordGeneratorPreferences
-import app.passwordstore.passgen.diceware.DicewarePassphraseGenerator
-import app.passwordstore.ui.crypto.PasswordCreationActivity
-import app.passwordstore.util.extensions.getString
-import app.passwordstore.util.settings.PreferenceKeys.DICEWARE_LENGTH
-import app.passwordstore.util.settings.PreferenceKeys.DICEWARE_SEPARATOR
-import com.google.android.material.dialog.MaterialAlertDialogBuilder
-import dagger.hilt.android.AndroidEntryPoint
-import javax.inject.Inject
-import kotlinx.coroutines.flow.merge
-import kotlinx.coroutines.launch
-import reactivecircus.flowbinding.android.widget.afterTextChanges
-
-@AndroidEntryPoint
-class DicewarePasswordGeneratorDialogFragment : DialogFragment() {
-
- @Inject lateinit var dicewareGenerator: DicewarePassphraseGenerator
- @Inject @PasswordGeneratorPreferences lateinit var prefs: SharedPreferences
-
- override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
- val builder = MaterialAlertDialogBuilder(requireContext())
-
- val binding = FragmentPwgenDicewareBinding.inflate(layoutInflater)
- builder.setView(binding.root)
-
- binding.passwordSeparatorText.setText(prefs.getString(DICEWARE_SEPARATOR) ?: "-")
- binding.passwordLengthText.setText(prefs.getInt(DICEWARE_LENGTH, 5).toString())
- binding.passwordText.typeface = Typeface.MONOSPACE
-
- lifecycleScope.launch {
- merge(
- binding.passwordLengthText.afterTextChanges(),
- binding.passwordSeparatorText.afterTextChanges(),
- )
- .collect { _ -> generatePassword(binding) }
- }
- return builder
- .run {
- setTitle(R.string.pwgen_title)
- setPositiveButton(R.string.dialog_ok) { _, _ ->
- setFragmentResult(
- PasswordCreationActivity.PASSWORD_RESULT_REQUEST_KEY,
- bundleOf(PasswordCreationActivity.RESULT to "${binding.passwordText.text}"),
- )
- }
- setNeutralButton(R.string.dialog_cancel) { _, _ -> }
- setNegativeButton(R.string.pwgen_generate, null)
- create()
- }
- .apply {
- setOnShowListener {
- generatePassword(binding)
- getButton(AlertDialog.BUTTON_NEGATIVE).setOnClickListener { generatePassword(binding) }
- }
- }
- }
-
- private fun generatePassword(binding: FragmentPwgenDicewareBinding) {
- val length = binding.passwordLengthText.text?.toString()?.toIntOrNull() ?: 5
- val separator = binding.passwordSeparatorText.text?.toString()?.getOrNull(0) ?: '-'
- setPreferences(length, separator)
- binding.passwordText.text = dicewareGenerator.generatePassphrase(length, separator)
- }
-
- private fun setPreferences(length: Int, separator: Char) {
- prefs.edit {
- putInt(DICEWARE_LENGTH, length)
- putString(DICEWARE_SEPARATOR, separator.toString())
- }
- }
-}
diff --git a/app/src/main/java/app/passwordstore/ui/dialogs/FolderCreationDialogFragment.kt b/app/src/main/java/app/passwordstore/ui/dialogs/FolderCreationDialogFragment.kt
deleted file mode 100644
index 669e126e..00000000
--- a/app/src/main/java/app/passwordstore/ui/dialogs/FolderCreationDialogFragment.kt
+++ /dev/null
@@ -1,77 +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.os.Bundle
-import android.view.WindowManager
-import androidx.appcompat.app.AlertDialog
-import androidx.core.os.bundleOf
-import androidx.fragment.app.DialogFragment
-import app.passwordstore.R
-import app.passwordstore.ui.passwords.PasswordStore
-import com.google.android.material.dialog.MaterialAlertDialogBuilder
-import com.google.android.material.textfield.TextInputEditText
-import com.google.android.material.textfield.TextInputLayout
-import java.io.File
-
-class FolderCreationDialogFragment : DialogFragment() {
-
- private lateinit var newFolder: File
-
- override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
- val alertDialogBuilder = MaterialAlertDialogBuilder(requireContext())
- alertDialogBuilder.setTitle(R.string.title_create_folder)
- alertDialogBuilder.setView(R.layout.folder_dialog_fragment)
- alertDialogBuilder.setPositiveButton(getString(R.string.button_create), null)
- alertDialogBuilder.setNegativeButton(getString(android.R.string.cancel)) { _, _ -> dismiss() }
- val dialog = alertDialogBuilder.create()
- dialog.window?.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_STATE_ALWAYS_VISIBLE)
- dialog.setOnShowListener {
- dialog.getButton(AlertDialog.BUTTON_POSITIVE).setOnClickListener {
- createDirectory(requireArguments().getString(CURRENT_DIR_EXTRA)!!)
- }
- }
- return dialog
- }
-
- private fun createDirectory(currentDir: String) {
- val dialog = requireDialog()
- val folderNameView = dialog.findViewById<TextInputEditText>(R.id.folder_name_text)
- val folderNameViewContainer = dialog.findViewById<TextInputLayout>(R.id.folder_name_container)
- newFolder = File("$currentDir/${folderNameView.text}")
- folderNameViewContainer.error =
- when {
- newFolder.isFile -> getString(R.string.folder_creation_err_file_exists)
- newFolder.isDirectory -> getString(R.string.folder_creation_err_folder_exists)
- else -> null
- }
- if (folderNameViewContainer.error != null) return
- newFolder.mkdirs()
- (requireActivity() as PasswordStore).refreshPasswordList(newFolder)
- // TODO(msfjarvis): Restore this functionality
- /*
- if (dialog.findViewById<MaterialCheckBox>(R.id.set_gpg_key).isChecked) {
- keySelectAction.launch(Intent(requireContext(), GetKeyIdsActivity::class.java))
- return
- } else {
- dismiss()
- }
- */
- dismiss()
- }
-
- companion object {
-
- private const val CURRENT_DIR_EXTRA = "CURRENT_DIRECTORY"
-
- fun newInstance(startingDirectory: String): FolderCreationDialogFragment {
- val extras = bundleOf(CURRENT_DIR_EXTRA to startingDirectory)
- val fragment = FolderCreationDialogFragment()
- fragment.arguments = extras
- return fragment
- }
- }
-}
diff --git a/app/src/main/java/app/passwordstore/ui/dialogs/ItemCreationBottomSheet.kt b/app/src/main/java/app/passwordstore/ui/dialogs/ItemCreationBottomSheet.kt
deleted file mode 100644
index 0304b16c..00000000
--- a/app/src/main/java/app/passwordstore/ui/dialogs/ItemCreationBottomSheet.kt
+++ /dev/null
@@ -1,77 +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.os.Bundle
-import android.view.LayoutInflater
-import android.view.View
-import android.view.ViewGroup
-import android.view.ViewTreeObserver
-import android.widget.FrameLayout
-import androidx.core.os.bundleOf
-import androidx.fragment.app.setFragmentResult
-import app.passwordstore.R
-import app.passwordstore.ui.passwords.PasswordFragment.Companion.ACTION_FOLDER
-import app.passwordstore.ui.passwords.PasswordFragment.Companion.ACTION_KEY
-import app.passwordstore.ui.passwords.PasswordFragment.Companion.ACTION_PASSWORD
-import app.passwordstore.ui.passwords.PasswordFragment.Companion.ITEM_CREATION_REQUEST_KEY
-import com.google.android.material.bottomsheet.BottomSheetBehavior
-import com.google.android.material.bottomsheet.BottomSheetDialog
-import com.google.android.material.bottomsheet.BottomSheetDialogFragment
-
-class ItemCreationBottomSheet : BottomSheetDialogFragment() {
-
- private var behavior: BottomSheetBehavior<FrameLayout>? = null
- private val bottomSheetCallback =
- object : BottomSheetBehavior.BottomSheetCallback() {
- override fun onSlide(bottomSheet: View, slideOffset: Float) {}
-
- override fun onStateChanged(bottomSheet: View, newState: Int) {
- if (newState == BottomSheetBehavior.STATE_COLLAPSED) {
- dismiss()
- }
- }
- }
-
- override fun onCreateView(
- inflater: LayoutInflater,
- container: ViewGroup?,
- savedInstanceState: Bundle?,
- ): View? {
- if (savedInstanceState != null) dismiss()
- return inflater.inflate(R.layout.item_create_sheet, container, false)
- }
-
- override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
- super.onViewCreated(view, savedInstanceState)
- view.viewTreeObserver.addOnGlobalLayoutListener(
- object : ViewTreeObserver.OnGlobalLayoutListener {
- override fun onGlobalLayout() {
- view.viewTreeObserver.removeOnGlobalLayoutListener(this)
- val dialog = dialog as BottomSheetDialog? ?: return
- behavior = dialog.behavior
- behavior?.apply {
- state = BottomSheetBehavior.STATE_EXPANDED
- peekHeight = 0
- addBottomSheetCallback(bottomSheetCallback)
- }
- dialog.findViewById<View>(R.id.create_folder)?.setOnClickListener {
- setFragmentResult(ITEM_CREATION_REQUEST_KEY, bundleOf(ACTION_KEY to ACTION_FOLDER))
- dismiss()
- }
- dialog.findViewById<View>(R.id.create_password)?.setOnClickListener {
- setFragmentResult(ITEM_CREATION_REQUEST_KEY, bundleOf(ACTION_KEY to ACTION_PASSWORD))
- dismiss()
- }
- }
- }
- )
- }
-
- override fun dismiss() {
- super.dismiss()
- behavior?.removeBottomSheetCallback(bottomSheetCallback)
- }
-}
diff --git a/app/src/main/java/app/passwordstore/ui/dialogs/OtpImportDialogFragment.kt b/app/src/main/java/app/passwordstore/ui/dialogs/OtpImportDialogFragment.kt
deleted file mode 100644
index 1a75175b..00000000
--- a/app/src/main/java/app/passwordstore/ui/dialogs/OtpImportDialogFragment.kt
+++ /dev/null
@@ -1,47 +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.net.Uri
-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.FragmentManualOtpEntryBinding
-import app.passwordstore.ui.crypto.PasswordCreationActivity
-import com.google.android.material.dialog.MaterialAlertDialogBuilder
-
-class OtpImportDialogFragment : DialogFragment() {
-
- override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
- val builder = MaterialAlertDialogBuilder(requireContext())
- val binding = FragmentManualOtpEntryBinding.inflate(layoutInflater)
- builder.setView(binding.root)
- builder.setPositiveButton(android.R.string.ok) { _, _ ->
- setFragmentResult(
- PasswordCreationActivity.OTP_RESULT_REQUEST_KEY,
- bundleOf(PasswordCreationActivity.RESULT to getTOTPUri(binding)),
- )
- }
- val dialog = builder.create()
- dialog.window?.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_STATE_ALWAYS_VISIBLE)
- return dialog
- }
-
- private fun getTOTPUri(binding: FragmentManualOtpEntryBinding): String {
- val secret = binding.secret.text.toString()
- val account = binding.account.text.toString()
- if (secret.isBlank()) return ""
- val builder = Uri.Builder()
- builder.scheme("otpauth")
- builder.authority("totp")
- builder.appendQueryParameter("secret", secret)
- if (account.isNotBlank()) builder.appendQueryParameter("issuer", account)
- return builder.build().toString()
- }
-}
diff --git a/app/src/main/java/app/passwordstore/ui/dialogs/PasswordGeneratorDialogFragment.kt b/app/src/main/java/app/passwordstore/ui/dialogs/PasswordGeneratorDialogFragment.kt
deleted file mode 100644
index d4a2372b..00000000
--- a/app/src/main/java/app/passwordstore/ui/dialogs/PasswordGeneratorDialogFragment.kt
+++ /dev/null
@@ -1,148 +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.AlertDialog
-import android.app.Dialog
-import android.content.Context
-import android.graphics.Typeface
-import android.os.Bundle
-import android.widget.CheckBox
-import android.widget.EditText
-import android.widget.Toast
-import androidx.annotation.IdRes
-import androidx.appcompat.widget.AppCompatTextView
-import androidx.core.content.edit
-import androidx.core.os.bundleOf
-import androidx.fragment.app.DialogFragment
-import androidx.fragment.app.setFragmentResult
-import androidx.lifecycle.lifecycleScope
-import app.passwordstore.R
-import app.passwordstore.databinding.FragmentPwgenBinding
-import app.passwordstore.passgen.random.MaxIterationsExceededException
-import app.passwordstore.passgen.random.NoCharactersIncludedException
-import app.passwordstore.passgen.random.PasswordGenerator
-import app.passwordstore.passgen.random.PasswordLengthTooShortException
-import app.passwordstore.passgen.random.PasswordOption
-import app.passwordstore.ui.crypto.PasswordCreationActivity
-import app.passwordstore.util.settings.PreferenceKeys
-import com.github.michaelbull.result.getOrElse
-import com.github.michaelbull.result.runCatching
-import com.google.android.material.dialog.MaterialAlertDialogBuilder
-import kotlinx.coroutines.flow.merge
-import kotlinx.coroutines.launch
-import reactivecircus.flowbinding.android.widget.afterTextChanges
-import reactivecircus.flowbinding.android.widget.checkedChanges
-
-class PasswordGeneratorDialogFragment : DialogFragment() {
-
- override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
- val prefs = requireContext().getSharedPreferences("PasswordGenerator", Context.MODE_PRIVATE)
- val builder = MaterialAlertDialogBuilder(requireContext())
-
- val binding = FragmentPwgenBinding.inflate(layoutInflater)
- builder.setView(binding.root)
-
- binding.numerals.isChecked = !prefs.getBoolean(PasswordOption.NoDigits.key, false)
- binding.symbols.isChecked = prefs.getBoolean(PasswordOption.AtLeastOneSymbol.key, false)
- binding.uppercase.isChecked = !prefs.getBoolean(PasswordOption.NoUppercaseLetters.key, false)
- binding.lowercase.isChecked = !prefs.getBoolean(PasswordOption.NoLowercaseLetters.key, false)
- binding.ambiguous.isChecked = !prefs.getBoolean(PasswordOption.NoAmbiguousCharacters.key, false)
- binding.pronounceable.isChecked = !prefs.getBoolean(PasswordOption.FullyRandom.key, true)
- binding.lengthNumber.setText(prefs.getInt(PreferenceKeys.LENGTH, 20).toString())
- binding.passwordText.typeface = Typeface.MONOSPACE
-
- lifecycleScope.launch {
- merge(
- binding.numerals.checkedChanges().skipInitialValue(),
- binding.symbols.checkedChanges().skipInitialValue(),
- binding.uppercase.checkedChanges().skipInitialValue(),
- binding.lowercase.checkedChanges().skipInitialValue(),
- binding.ambiguous.checkedChanges().skipInitialValue(),
- binding.pronounceable.checkedChanges().skipInitialValue(),
- binding.lengthNumber.afterTextChanges().skipInitialValue(),
- )
- .collect { generate(binding.passwordText) }
- }
-
- return builder
- .run {
- setTitle(R.string.pwgen_title)
- setPositiveButton(R.string.dialog_ok) { _, _ ->
- setFragmentResult(
- PasswordCreationActivity.PASSWORD_RESULT_REQUEST_KEY,
- bundleOf(PasswordCreationActivity.RESULT to "${binding.passwordText.text}"),
- )
- }
- setNeutralButton(R.string.dialog_cancel) { _, _ -> }
- setNegativeButton(R.string.pwgen_generate, null)
- create()
- }
- .apply {
- setOnShowListener {
- generate(binding.passwordText)
- getButton(AlertDialog.BUTTON_NEGATIVE).setOnClickListener {
- generate(binding.passwordText)
- }
- }
- }
- }
-
- private fun generate(passwordField: AppCompatTextView) {
- val passwordOptions = getSelectedOptions()
- val passwordLength = getLength()
- setPrefs(requireContext(), passwordOptions, passwordLength)
- passwordField.text =
- runCatching { PasswordGenerator.generate(passwordOptions, passwordLength) }
- .getOrElse { exception ->
- val errorText =
- when (exception) {
- is MaxIterationsExceededException ->
- requireContext().getString(R.string.pwgen_max_iterations_exceeded)
- is NoCharactersIncludedException ->
- requireContext().getString(R.string.pwgen_no_chars_error)
- is PasswordLengthTooShortException ->
- requireContext().getString(R.string.pwgen_length_too_short_error)
- else -> requireContext().getString(R.string.pwgen_some_error_occurred)
- }
- Toast.makeText(requireActivity(), errorText, Toast.LENGTH_SHORT).show()
- ""
- }
- }
-
- private fun isChecked(@IdRes id: Int): Boolean {
- return requireDialog().findViewById<CheckBox>(id).isChecked
- }
-
- private fun getSelectedOptions(): List<PasswordOption> {
- return listOfNotNull(
- PasswordOption.NoDigits.takeIf { !isChecked(R.id.numerals) },
- PasswordOption.AtLeastOneSymbol.takeIf { isChecked(R.id.symbols) },
- PasswordOption.NoUppercaseLetters.takeIf { !isChecked(R.id.uppercase) },
- PasswordOption.NoAmbiguousCharacters.takeIf { !isChecked(R.id.ambiguous) },
- PasswordOption.FullyRandom.takeIf { !isChecked(R.id.pronounceable) },
- PasswordOption.NoLowercaseLetters.takeIf { !isChecked(R.id.lowercase) },
- )
- }
-
- private fun getLength(): Int {
- val lengthText = requireDialog().findViewById<EditText>(R.id.lengthNumber).text.toString()
- return lengthText.toIntOrNull()?.takeIf { it >= 0 } ?: PasswordGenerator.DEFAULT_LENGTH
- }
-
- /**
- * Enables the [PasswordOption]s in [options] and sets [targetLength] as the length for generated
- * passwords.
- */
- private fun setPrefs(ctx: Context, options: List<PasswordOption>, targetLength: Int): Boolean {
- ctx.getSharedPreferences("PasswordGenerator", Context.MODE_PRIVATE).edit {
- for (possibleOption in PasswordOption.entries) {
- putBoolean(possibleOption.key, possibleOption in options)
- }
- putInt("length", targetLength)
- }
- return true
- }
-}
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/editor/EditorDestination.kt b/app/src/main/java/app/passwordstore/ui/editor/EditorDestination.kt
new file mode 100644
index 00000000..6d9f9c9f
--- /dev/null
+++ b/app/src/main/java/app/passwordstore/ui/editor/EditorDestination.kt
@@ -0,0 +1,268 @@
+/*
+ * Copyright (C) 2026 Danilo M. <danix@danix.xyz>
+ * SPDX-License-Identifier: GPL-3.0-only
+ */
+
+package app.passwordstore.ui.editor
+
+import android.app.Activity
+import android.content.pm.PackageManager
+import androidx.activity.compose.BackHandler
+import androidx.activity.compose.rememberLauncherForActivityResult
+import androidx.activity.result.contract.ActivityResultContracts.GetContent
+import androidx.activity.result.contract.ActivityResultContracts.StartActivityForResult
+import androidx.compose.foundation.layout.Box
+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.rememberCoroutineScope
+import androidx.compose.runtime.saveable.rememberSaveable
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.LocalFocusManager
+import androidx.compose.ui.platform.LocalSoftwareKeyboardController
+import androidx.compose.ui.res.stringResource
+import androidx.fragment.app.FragmentActivity
+import androidx.hilt.navigation.compose.hiltViewModel
+import androidx.lifecycle.lifecycleScope
+import app.passwordstore.R
+import app.passwordstore.data.repo.PasswordRepository
+import app.passwordstore.ui.crypto.copyPasswordToClipboard
+import app.passwordstore.ui.crypto.getPGPIdentifiers
+import app.passwordstore.ui.list.FolderPicker
+import app.passwordstore.util.extensions.commitChange
+import app.passwordstore.util.extensions.getString
+import app.passwordstore.util.extensions.isInsideRepository
+import app.passwordstore.util.extensions.sharedPrefs
+import app.passwordstore.util.extensions.snackbar
+import app.passwordstore.util.settings.PreferenceKeys
+import com.google.android.material.snackbar.Snackbar
+import com.google.zxing.integration.android.IntentIntegrator
+import java.io.File
+import kotlinx.coroutines.launch
+
+/**
+ * The editor: wires [EditorScreen] to the [viewModel], the generator sheet, the folder picker, OTP
+ * import and the save path (checks, recipients from `.gpg-id`, encryption, write, git commit).
+ * [initial] fills the form the first time; null means the entry to edit is no longer in memory
+ * (process death) and the editor closes. [original] is the file being edited, null for a new entry.
+ * [generate] opens the generator at once (autofill's "generate password"). [onClose] leaves without
+ * saving; [onSaved] runs after the commit.
+ */
+@Composable
+fun EditorDestination(
+ activity: FragmentActivity,
+ initial: EditorForm?,
+ original: File?,
+ onClose: () -> Unit,
+ onSaved: (SaveResult.Saved) -> Unit,
+ modifier: Modifier = Modifier,
+ generate: Boolean = false,
+ viewModel: EditorViewModel = hiltViewModel(),
+) {
+ val root = PasswordRepository.getRepositoryDirectory()
+ // ponytail: filled once per ViewModel, so a recreated activity keeps the edits; an edit restored
+ // after process death has nothing to fill from (the entry view's plaintext is gone) and closes,
+ // back to the entry view's passphrase prompt.
+ val first = remember { initial != null && viewModel.start(initial) }
+ val lost = !viewModel.started
+ LaunchedEffect(lost) { if (lost) onClose() }
+ if (lost) return
+
+ val keyboard = LocalSoftwareKeyboardController.current
+ val focusManager = LocalFocusManager.current
+ val scope = rememberCoroutineScope()
+ var sheet by rememberSaveable { mutableStateOf(first && generate) }
+ var picker by rememberSaveable { mutableStateOf(false) }
+ var otp by rememberSaveable { mutableStateOf(false) }
+ var failure by remember { mutableStateOf<String?>(null) }
+ val form = viewModel.form
+ val hasOtp = remember(form.extra) { viewModel.hasOtp(form.extra) }
+ val hasCamera = remember {
+ activity.packageManager.hasSystemFeature(PackageManager.FEATURE_CAMERA_ANY)
+ }
+ val showPassword = remember {
+ activity.sharedPrefs.getBoolean(PreferenceKeys.SHOW_PASSWORD, true)
+ }
+ val classicFirst = remember {
+ activity.sharedPrefs.getString(PreferenceKeys.PREF_KEY_PWGEN_TYPE) == "classic"
+ }
+
+ fun say(message: Int) {
+ activity.snackbar(message = activity.getString(message))
+ }
+
+ fun addLine(line: String) {
+ viewModel.form = viewModel.form.copy(extra = withLine(viewModel.form.extra, line))
+ }
+
+ // The one way out without saving: only while no save runs, keyboard and focus dropped first.
+ fun close() {
+ if (viewModel.saving) return
+ keyboard?.hide()
+ focusManager.clearFocus()
+ onClose()
+ }
+
+ fun save(copy: Boolean) {
+ if (viewModel.saving) return
+ val current = viewModel.form
+ val target = entryFile(root, current)
+ val problem =
+ formError(current)
+ ?: R.string.message_error_destination_outside_repo.takeUnless {
+ target.isInsideRepository()
+ }
+ if (problem != null) return say(problem)
+ // Recipients from the nearest .gpg-id, as pass does; null: an invalid ID, already reported.
+ val ids =
+ activity.getPGPIdentifiers(target.parentFile?.relativeTo(root)?.path.orEmpty()) ?: return
+ activity.lifecycleScope.launch {
+ val result = viewModel.save(ids, target, original, activity, copy)
+ if (result is SaveResult.Failed) failure = activity.getString(result.message, result.arg)
+ }
+ }
+
+ // Finishes a save: run again by an editor recreated in the middle (a theme or locale change
+ // cancels this effect), so the commit and onSaved always happen; the copy and notice only once.
+ val saved = viewModel.saved
+ LaunchedEffect(saved) {
+ if (saved == null) return@LaunchedEffect
+ if (viewModel.announce()) {
+ if (viewModel.copyOnSave) activity.copyPasswordToClipboard(saved.form.password)
+ saved.staleOriginal?.let {
+ activity.snackbar(
+ message =
+ activity.getString(
+ R.string.password_creation_file_delete_fail_message,
+ it.nameWithoutExtension,
+ ),
+ length = Snackbar.LENGTH_LONG,
+ )
+ }
+ }
+ // ponytail: commit failures are not shown, as for the list's move and delete. A commit cut
+ // short by recreation runs again; one that already went through finds nothing to commit.
+ activity.commitChange(
+ activity.getString(
+ if (original != null) R.string.git_commit_edit_text else R.string.git_commit_add_text,
+ saved.file.relativeTo(root).path.removeSuffix(".gpg"),
+ )
+ )
+ keyboard?.hide()
+ focusManager.clearFocus()
+ viewModel.left()
+ onSaved(saved)
+ }
+
+ BackHandler(onBack = ::close)
+
+ val scan =
+ rememberLauncherForActivityResult(StartActivityForResult()) { result ->
+ val text = IntentIntegrator.parseActivityResult(result.resultCode, result.data)?.contents
+ if (result.resultCode == Activity.RESULT_OK && text != null) {
+ addLine("$text\n")
+ say(R.string.otp_import_success)
+ } else {
+ say(R.string.otp_import_failure_generic)
+ }
+ }
+ val image =
+ rememberLauncherForActivityResult(GetContent()) { uri ->
+ if (uri == null) {
+ say(R.string.otp_import_failure_no_selection)
+ } else {
+ scope.launch {
+ val text = viewModel.readQr(uri, activity)
+ if (text != null) {
+ addLine(text)
+ say(R.string.otp_import_success)
+ } else {
+ say(R.string.otp_import_failure_generic)
+ }
+ }
+ }
+ }
+
+ Box(modifier) {
+ EditorScreen(
+ form = form,
+ editing = original != null,
+ saving = viewModel.saving,
+ showOtpActions = !hasOtp,
+ initiallyRevealed = showPassword,
+ focus =
+ when {
+ !first -> EditorFocus.None
+ form.name.isEmpty() -> EditorFocus.Name
+ original == null -> EditorFocus.Username
+ else -> EditorFocus.None
+ },
+ onFormChange = { viewModel.form = it },
+ onClose = ::close,
+ onSave = ::save,
+ onChangeFolder = { picker = true },
+ onGenerate = { sheet = true },
+ onScanQr =
+ if (hasCamera) {
+ {
+ scan.launch(
+ IntentIntegrator(activity)
+ .setOrientationLocked(false)
+ .setBeepEnabled(false)
+ .setDesiredBarcodeFormats(IntentIntegrator.QR_CODE)
+ .createScanIntent()
+ )
+ }
+ } else null,
+ onQrImage = { image.launch("image/*") },
+ onManualOtp = { otp = true },
+ )
+ if (sheet)
+ GeneratorSheet(
+ classicFirst = classicFirst,
+ random = viewModel.randomSpec(activity),
+ diceware = viewModel.dicewareSpec(),
+ onRandom = { spec -> viewModel.generateRandom(spec, activity) },
+ onDiceware = viewModel::generateDiceware,
+ onUse = { password ->
+ viewModel.form = viewModel.form.copy(password = password)
+ sheet = false
+ },
+ onDismiss = { sheet = false },
+ )
+ if (picker)
+ FolderPicker(
+ root = root,
+ start = form.dir,
+ onPick = { dir ->
+ viewModel.form = viewModel.form.copy(dir = dir)
+ picker = false
+ },
+ onDismiss = { picker = false },
+ )
+ if (otp)
+ OtpDialog(
+ onAdd = { secret, account ->
+ totpUri(secret, account)?.let(::addLine)
+ otp = false
+ },
+ onDismiss = { otp = false },
+ )
+ failure?.let { message ->
+ AlertDialog(
+ onDismissRequest = { failure = null },
+ title = { Text(stringResource(R.string.password_creation_file_fail_title)) },
+ text = { Text(message) },
+ confirmButton = {
+ TextButton(onClick = { failure = null }) { Text(stringResource(R.string.dialog_ok)) }
+ },
+ )
+ }
+ }
+}
diff --git a/app/src/main/java/app/passwordstore/ui/editor/EditorModel.kt b/app/src/main/java/app/passwordstore/ui/editor/EditorModel.kt
new file mode 100644
index 00000000..d7437df3
--- /dev/null
+++ b/app/src/main/java/app/passwordstore/ui/editor/EditorModel.kt
@@ -0,0 +1,150 @@
+/*
+ * Copyright (C) 2026 Danilo M. <danix@danix.xyz>
+ * SPDX-License-Identifier: GPL-3.0-only
+ */
+
+package app.passwordstore.ui.editor
+
+import androidx.annotation.StringRes
+import androidx.compose.runtime.Immutable
+import app.passwordstore.R
+import app.passwordstore.data.passfile.PasswordEntry
+import app.passwordstore.passgen.random.MaxIterationsExceededException
+import app.passwordstore.passgen.random.NoCharactersIncludedException
+import app.passwordstore.passgen.random.PasswordGenerator
+import app.passwordstore.passgen.random.PasswordLengthTooShortException
+import app.passwordstore.passgen.random.PasswordOption
+import com.github.michaelbull.result.Result
+import com.github.michaelbull.result.mapError
+import com.github.michaelbull.result.runCatching
+import java.io.File
+import java.io.FileOutputStream
+import java.io.IOException
+import java.net.URLEncoder
+import java.nio.file.Files
+import java.nio.file.StandardCopyOption
+
+/**
+ * What the editor edits. [dir] is the folder relative to the store root ("" is the root), [name]
+ * the file name without `.gpg`. Plaintext: held in memory only, never in saved state or a route.
+ */
+@Immutable
+data class EditorForm(
+ val name: String = "",
+ val dir: String = "",
+ val password: String = "",
+ val username: String = "",
+ val extra: String = "",
+)
+
+/** The form for [entry], stored as [name] in [dir], split as the old editor split it. */
+fun editorForm(entry: PasswordEntry, dir: String, name: String): EditorForm =
+ EditorForm(
+ name = name,
+ dir = dir,
+ password = entry.password.orEmpty(),
+ username = entry.username.orEmpty(),
+ extra = entry.extraContentWithoutUsername,
+ )
+
+/** The pass file, as the old editor wrote it: password, `username: ...` when set, the extras. */
+fun passContent(form: EditorForm): String {
+ val username = if (form.username.isEmpty()) "" else "\nusername: ${form.username}"
+ return "${form.password}$username\n${form.extra}"
+}
+
+/** Why [form] cannot be saved, as a string resource, or null when it can. */
+@StringRes
+fun formError(form: EditorForm): Int? =
+ when {
+ form.name.isBlank() -> R.string.file_toast_text
+ '/' in form.name -> R.string.invalid_filename_text
+ form.password.isEmpty() && form.extra.isEmpty() -> R.string.empty_toast_text
+ else -> null
+ }
+
+/** Where [form] is saved in the store at [root]. */
+fun entryFile(root: File, form: EditorForm): File =
+ File(File(root, form.dir), "${form.name.trim()}.gpg").toPath().normalize().toFile()
+
+/** True when saving to [target] would replace a file other than the [original] being edited. */
+fun overwrites(target: File, original: File?): Boolean =
+ target.exists() && target.canonicalPath != original?.canonicalPath
+
+/**
+ * Writes [bytes] to [target], creating its folder, through a temporary file moved over it, so a
+ * failed write never truncates an existing entry.
+ */
+fun writeEntry(target: File, bytes: ByteArray) {
+ val dir = target.parentFile ?: throw IOException("No folder for $target")
+ dir.mkdirs()
+ val temp = File(dir, ".${target.name}.tmp")
+ try {
+ FileOutputStream(temp).use {
+ it.write(bytes)
+ it.fd.sync()
+ }
+ Files.move(
+ temp.toPath(),
+ target.toPath(),
+ StandardCopyOption.REPLACE_EXISTING,
+ StandardCopyOption.ATOMIC_MOVE,
+ )
+ } finally {
+ temp.delete()
+ }
+}
+
+/** [extra] with [line] added on a line of its own. */
+fun withLine(extra: String, line: String): String =
+ if (extra.isEmpty() || extra.endsWith('\n')) "$extra$line" else "$extra\n$line"
+
+/**
+ * An `otpauth://totp` URI, as the old manual OTP dialog built it, for a typed [secret] (spaces
+ * dropped: authenticators show it in groups) and an optional [issuer]; null for a blank secret.
+ */
+fun totpUri(secret: String, issuer: String): String? {
+ val key = secret.filterNot(Char::isWhitespace)
+ if (key.isEmpty()) return null
+ fun encode(s: String) = URLEncoder.encode(s, "UTF-8").replace("+", "%20")
+ val tail = if (issuer.isBlank()) "" else "&issuer=${encode(issuer.trim())}"
+ return "otpauth://totp?secret=${encode(key)}$tail"
+}
+
+/** The random generator's options, as its checkboxes show them; defaults are the old dialog's. */
+@Immutable
+data class RandomSpec(
+ val numerals: Boolean = true,
+ val symbols: Boolean = false,
+ val uppercase: Boolean = true,
+ val lowercase: Boolean = true,
+ val ambiguous: Boolean = true,
+ val pronounceable: Boolean = false,
+ val length: Int = 20,
+)
+
+/** The generator's options for this spec, negated where the generator asks what to leave out. */
+fun RandomSpec.options(): List<PasswordOption> =
+ listOfNotNull(
+ PasswordOption.NoDigits.takeUnless { numerals },
+ PasswordOption.AtLeastOneSymbol.takeIf { symbols },
+ PasswordOption.NoUppercaseLetters.takeUnless { uppercase },
+ PasswordOption.NoLowercaseLetters.takeUnless { lowercase },
+ PasswordOption.NoAmbiguousCharacters.takeUnless { ambiguous },
+ PasswordOption.FullyRandom.takeUnless { pronounceable },
+ )
+
+/** A random password for [spec], or the string resource saying why there is none. */
+fun randomPassword(spec: RandomSpec): Result<String, Int> =
+ runCatching { PasswordGenerator.generate(spec.options(), spec.length) }
+ .mapError { e ->
+ when (e) {
+ is MaxIterationsExceededException -> R.string.pwgen_max_iterations_exceeded
+ is NoCharactersIncludedException -> R.string.pwgen_no_chars_error
+ is PasswordLengthTooShortException -> R.string.pwgen_length_too_short_error
+ else -> R.string.pwgen_some_error_occurred
+ }
+ }
+
+/** The diceware generator's options; defaults are the old dialog's. */
+@Immutable data class DicewareSpec(val words: Int = 5, val separator: Char = '-')
diff --git a/app/src/main/java/app/passwordstore/ui/editor/EditorScreen.kt b/app/src/main/java/app/passwordstore/ui/editor/EditorScreen.kt
new file mode 100644
index 00000000..bb8af01a
--- /dev/null
+++ b/app/src/main/java/app/passwordstore/ui/editor/EditorScreen.kt
@@ -0,0 +1,314 @@
+/*
+ * Copyright (C) 2026 Danilo M. <danix@danix.xyz>
+ * SPDX-License-Identifier: GPL-3.0-only
+ */
+
+package app.passwordstore.ui.editor
+
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+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.filled.Close
+import androidx.compose.material.icons.filled.MoreVert
+import androidx.compose.material3.AlertDialog
+import androidx.compose.material3.DropdownMenu
+import androidx.compose.material3.DropdownMenuItem
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.LocalTextStyle
+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.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.focus.FocusRequester
+import androidx.compose.ui.focus.focusRequester
+import androidx.compose.ui.res.painterResource
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.font.FontFamily
+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.compose.ui.window.DialogProperties
+import androidx.compose.ui.window.SecureFlagPolicy
+import app.passwordstore.R
+import app.passwordstore.ui.compose.BreadcrumbBar
+import app.passwordstore.ui.compose.R as composeR
+import app.passwordstore.ui.compose.crumbsFor
+import app.passwordstore.ui.compose.theme.SpacingLarge
+import app.passwordstore.ui.compose.theme.SpacingMedium
+
+/** Which field takes focus when the editor first opens. */
+enum class EditorFocus {
+ None,
+ Name,
+ Username,
+}
+
+/**
+ * The editor: name, folder (breadcrumbs; they and "Change" open the picker), password with reveal
+ * and generator, username, free-text extra content (the pass format as text) and the OTP actions,
+ * shown while the extras hold no TOTP secret ([showOtpActions]). [onScanQr] is null on a device
+ * without a camera. Only the reveal toggle and the menus keep state here.
+ */
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun EditorScreen(
+ form: EditorForm,
+ editing: Boolean,
+ saving: Boolean,
+ showOtpActions: Boolean,
+ initiallyRevealed: Boolean,
+ focus: EditorFocus,
+ onFormChange: (EditorForm) -> Unit,
+ onClose: () -> Unit,
+ onSave: (copy: Boolean) -> Unit,
+ onChangeFolder: () -> Unit,
+ onGenerate: () -> Unit,
+ onScanQr: (() -> Unit)?,
+ onQrImage: () -> Unit,
+ onManualOtp: () -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ var revealed by remember { mutableStateOf(initiallyRevealed) }
+ var menu by remember { mutableStateOf(false) }
+ var qrMenu by remember { mutableStateOf(false) }
+ val nameFocus = remember { FocusRequester() }
+ val usernameFocus = remember { FocusRequester() }
+ LaunchedEffect(Unit) {
+ when (focus) {
+ EditorFocus.Name -> nameFocus.requestFocus()
+ EditorFocus.Username -> usernameFocus.requestFocus()
+ EditorFocus.None -> Unit
+ }
+ }
+ val field = Modifier.fillMaxWidth().padding(horizontal = SpacingLarge)
+ Scaffold(
+ modifier = modifier,
+ topBar = {
+ TopAppBar(
+ title = {
+ Text(
+ stringResource(if (editing) R.string.editor_edit_title else R.string.editor_new_title)
+ )
+ },
+ navigationIcon = {
+ IconButton(onClick = onClose) {
+ Icon(Icons.Filled.Close, stringResource(R.string.editor_close))
+ }
+ },
+ actions = {
+ TextButton(onClick = { onSave(false) }, enabled = !saving) {
+ Text(stringResource(R.string.crypto_save))
+ }
+ Box {
+ IconButton(onClick = { menu = true }) {
+ Icon(Icons.Filled.MoreVert, stringResource(R.string.list_more_options))
+ }
+ DropdownMenu(expanded = menu, onDismissRequest = { menu = false }) {
+ DropdownMenuItem(
+ text = { Text(stringResource(R.string.crypto_save_and_copy)) },
+ onClick = {
+ menu = false
+ onSave(true)
+ },
+ enabled = !saving,
+ )
+ }
+ }
+ },
+ )
+ },
+ ) { padding ->
+ Column(
+ Modifier.padding(padding)
+ .fillMaxSize()
+ .verticalScroll(rememberScrollState())
+ .padding(bottom = SpacingLarge),
+ verticalArrangement = Arrangement.spacedBy(SpacingMedium),
+ ) {
+ OutlinedTextField(
+ value = form.name,
+ onValueChange = { onFormChange(form.copy(name = it)) },
+ modifier = field.focusRequester(nameFocus),
+ label = { Text(stringResource(R.string.crypto_name_hint)) },
+ singleLine = true,
+ keyboardOptions = KeyboardOptions(autoCorrectEnabled = false, imeAction = ImeAction.Next),
+ )
+ Text(
+ stringResource(R.string.editor_folder),
+ field,
+ style = MaterialTheme.typography.labelMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ Row(verticalAlignment = Alignment.CenterVertically) {
+ BreadcrumbBar(
+ crumbsFor(form.dir),
+ stringResource(R.string.list_store_root),
+ onCrumbClick = { onChangeFolder() },
+ modifier = Modifier.weight(1f),
+ )
+ TextButton(onClick = onChangeFolder, modifier = Modifier.padding(end = SpacingMedium)) {
+ Text(stringResource(R.string.editor_change_folder))
+ }
+ }
+ OutlinedTextField(
+ value = form.password,
+ onValueChange = { onFormChange(form.copy(password = it)) },
+ modifier = field,
+ label = { Text(stringResource(R.string.crypto_pass_label)) },
+ singleLine = true,
+ textStyle = LocalTextStyle.current.copy(fontFamily = FontFamily.Monospace),
+ visualTransformation =
+ if (revealed) VisualTransformation.None else PasswordVisualTransformation(),
+ keyboardOptions =
+ KeyboardOptions(
+ autoCorrectEnabled = false,
+ keyboardType = KeyboardType.Password,
+ imeAction = ImeAction.Next,
+ ),
+ trailingIcon = {
+ Row {
+ 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
+ ),
+ )
+ }
+ IconButton(onClick = onGenerate) {
+ Icon(
+ painterResource(R.drawable.ic_password_24px),
+ contentDescription = stringResource(R.string.pwgen_title),
+ )
+ }
+ }
+ },
+ )
+ OutlinedTextField(
+ value = form.username,
+ onValueChange = { onFormChange(form.copy(username = it)) },
+ modifier = field.focusRequester(usernameFocus),
+ label = { Text(stringResource(R.string.crypto_username_hint)) },
+ singleLine = true,
+ keyboardOptions = KeyboardOptions(autoCorrectEnabled = false, imeAction = ImeAction.Next),
+ )
+ OutlinedTextField(
+ value = form.extra,
+ onValueChange = { onFormChange(form.copy(extra = it)) },
+ modifier = field,
+ label = { Text(stringResource(R.string.crypto_extra_label)) },
+ minLines = 3,
+ // A password keyboard does not learn what is typed, as the old textVisiblePassword.
+ keyboardOptions =
+ KeyboardOptions(autoCorrectEnabled = false, keyboardType = KeyboardType.Password),
+ )
+ if (showOtpActions)
+ Row(field, horizontalArrangement = Arrangement.spacedBy(SpacingMedium)) {
+ Box {
+ OutlinedButton(onClick = { qrMenu = true }) {
+ Icon(painterResource(R.drawable.ic_qr_code_scanner), contentDescription = null)
+ Text(stringResource(R.string.editor_otp_qr), Modifier.padding(start = SpacingMedium))
+ }
+ DropdownMenu(expanded = qrMenu, onDismissRequest = { qrMenu = false }) {
+ if (onScanQr != null)
+ DropdownMenuItem(
+ text = { Text(stringResource(R.string.otp_import_qr_code)) },
+ onClick = {
+ qrMenu = false
+ onScanQr()
+ },
+ )
+ DropdownMenuItem(
+ text = { Text(stringResource(R.string.otp_import_from_file)) },
+ onClick = {
+ qrMenu = false
+ onQrImage()
+ },
+ )
+ }
+ }
+ OutlinedButton(onClick = onManualOtp) { Text(stringResource(R.string.editor_otp_manual)) }
+ }
+ }
+ }
+}
+
+/**
+ * Asks for a TOTP secret and an optional account (the URI's issuer), as the old manual OTP dialog
+ * did. Both live in [remember] only, and the window is FLAG_SECURE.
+ */
+@Composable
+fun OtpDialog(
+ onAdd: (secret: String, account: String) -> Unit,
+ onDismiss: () -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ var secret by remember { mutableStateOf("") }
+ var account by remember { mutableStateOf("") }
+ val focus = remember { FocusRequester() }
+ AlertDialog(
+ onDismissRequest = onDismiss,
+ modifier = modifier,
+ properties = DialogProperties(securePolicy = SecureFlagPolicy.SecureOn),
+ title = { Text(stringResource(R.string.add_otp)) },
+ text = {
+ Column {
+ OutlinedTextField(
+ value = secret,
+ onValueChange = { secret = it },
+ modifier = Modifier.focusRequester(focus),
+ label = { Text(stringResource(R.string.otp_import_manual_hint_secret)) },
+ singleLine = true,
+ keyboardOptions =
+ KeyboardOptions(
+ autoCorrectEnabled = false,
+ keyboardType = KeyboardType.Password,
+ imeAction = ImeAction.Next,
+ ),
+ )
+ OutlinedTextField(
+ value = account,
+ onValueChange = { account = it },
+ label = { Text(stringResource(R.string.otp_import_manual_hint_account)) },
+ singleLine = true,
+ keyboardOptions = KeyboardOptions(autoCorrectEnabled = false),
+ )
+ }
+ LaunchedEffect(Unit) { focus.requestFocus() }
+ },
+ confirmButton = {
+ TextButton(onClick = { onAdd(secret, account) }, enabled = secret.isNotBlank()) {
+ Text(stringResource(R.string.dialog_ok))
+ }
+ },
+ dismissButton = {
+ TextButton(onClick = onDismiss) { Text(stringResource(R.string.dialog_cancel)) }
+ },
+ )
+}
diff --git a/app/src/main/java/app/passwordstore/ui/editor/EditorViewModel.kt b/app/src/main/java/app/passwordstore/ui/editor/EditorViewModel.kt
new file mode 100644
index 00000000..6d2fbaec
--- /dev/null
+++ b/app/src/main/java/app/passwordstore/ui/editor/EditorViewModel.kt
@@ -0,0 +1,273 @@
+/*
+ * Copyright (C) 2026 Danilo M. <danix@danix.xyz>
+ * SPDX-License-Identifier: GPL-3.0-only
+ */
+
+package app.passwordstore.ui.editor
+
+import android.content.Context
+import android.content.SharedPreferences
+import android.graphics.Bitmap
+import android.graphics.ImageDecoder
+import android.net.Uri
+import android.os.Build
+import android.provider.MediaStore
+import androidx.annotation.StringRes
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.setValue
+import androidx.core.content.edit
+import androidx.lifecycle.ViewModel
+import app.passwordstore.R
+import app.passwordstore.crypto.PGPIdentifier
+import app.passwordstore.data.crypto.CryptoRepository
+import app.passwordstore.data.passfile.PasswordEntry
+import app.passwordstore.injection.prefs.PasswordGeneratorPreferences
+import app.passwordstore.passgen.diceware.DicewarePassphraseGenerator
+import app.passwordstore.passgen.random.PasswordOption
+import app.passwordstore.ui.list.moveHistory
+import app.passwordstore.util.autofill.AutofillMatcher
+import app.passwordstore.util.coroutines.DispatcherProvider
+import app.passwordstore.util.extensions.getString
+import app.passwordstore.util.settings.PreferenceKeys
+import com.github.michaelbull.result.Result
+import com.github.michaelbull.result.get
+import com.github.michaelbull.result.runCatching
+import com.google.zxing.BinaryBitmap
+import com.google.zxing.RGBLuminanceSource
+import com.google.zxing.common.HybridBinarizer
+import com.google.zxing.qrcode.QRCodeReader
+import dagger.hilt.android.lifecycle.HiltViewModel
+import java.io.ByteArrayOutputStream
+import java.io.File
+import java.io.IOException
+import javax.inject.Inject
+import kotlinx.coroutines.NonCancellable
+import kotlinx.coroutines.withContext
+import logcat.LogPriority.ERROR
+import logcat.asLog
+import logcat.logcat
+
+/** What a save did. */
+sealed interface SaveResult {
+
+ /**
+ * Written to [file] from [form]. [staleOriginal] is the old file of a rename or move, left behind
+ * because it could not be deleted.
+ */
+ data class Saved(val file: File, val form: EditorForm, val staleOriginal: File?) : SaveResult
+
+ /** Nothing was written; [message] (a string resource, formatted with [arg]) says why. */
+ data class Failed(@StringRes val message: Int, val arg: String = "") : SaveResult
+}
+
+/**
+ * The editor's state and work: the [form] (plaintext, in memory only: never in saved state, reset
+ * when this ViewModel is cleared), the generators and their stored options, QR decoding, and the
+ * save (encrypt, write, replace the original). Holds no Context; calls take one and use the
+ * application context.
+ */
+@HiltViewModel
+class EditorViewModel
+@Inject
+constructor(
+ private val repository: CryptoRepository,
+ private val entryFactory: PasswordEntry.Factory,
+ private val diceware: dagger.Lazy<DicewarePassphraseGenerator>,
+ @PasswordGeneratorPreferences private val pwgenPrefs: SharedPreferences,
+ private val dispatcherProvider: DispatcherProvider,
+) : ViewModel() {
+
+ /** The fields being edited. */
+ var form by mutableStateOf(EditorForm())
+
+ /**
+ * Set while a save runs, and after one succeeded (the editor is leaving): saves and closes wait.
+ */
+ var saving by mutableStateOf(false)
+ private set
+
+ /**
+ * The successful save, kept until the editor has committed it and left ([left]), so an editor
+ * recreated in the middle finishes the job. Memory only: it holds the plaintext form.
+ */
+ var saved by mutableStateOf<SaveResult.Saved?>(null)
+ private set
+
+ /** Whether [saved] is to be copied to the clipboard ("Save and copy"). */
+ var copyOnSave = false
+ private set
+
+ private var announced = false
+
+ /** Whether [start] filled the form. */
+ var started = false
+ private set
+
+ /** Fills the form the first time only and returns true then; a recreated activity keeps edits. */
+ fun start(initial: EditorForm): Boolean {
+ if (started) return false
+ started = true
+ form = initial
+ return true
+ }
+
+ /**
+ * True when [extra] already holds a TOTP secret (the OTP actions hide), as the old editor did.
+ */
+ fun hasOtp(extra: String): Boolean =
+ entryFactory.create("PLACEHOLDER\n$extra".encodeToByteArray()).hasTotp()
+
+ /** The random generator's options as the old dialog stored them. */
+ fun randomSpec(context: Context): RandomSpec {
+ val prefs = context.randomPrefs()
+ return RandomSpec(
+ numerals = !prefs.getBoolean(PasswordOption.NoDigits.key, false),
+ symbols = prefs.getBoolean(PasswordOption.AtLeastOneSymbol.key, false),
+ uppercase = !prefs.getBoolean(PasswordOption.NoUppercaseLetters.key, false),
+ lowercase = !prefs.getBoolean(PasswordOption.NoLowercaseLetters.key, false),
+ ambiguous = !prefs.getBoolean(PasswordOption.NoAmbiguousCharacters.key, false),
+ pronounceable = !prefs.getBoolean(PasswordOption.FullyRandom.key, true),
+ length = prefs.getInt(PreferenceKeys.LENGTH, 20),
+ )
+ }
+
+ /**
+ * Stores [spec], as the old dialog did on every generation, then generates off the main thread.
+ */
+ suspend fun generateRandom(spec: RandomSpec, context: Context): Result<String, Int> {
+ val options = spec.options()
+ context.randomPrefs().edit {
+ PasswordOption.entries.forEach { putBoolean(it.key, it in options) }
+ putInt(PreferenceKeys.LENGTH, spec.length)
+ }
+ return withContext(dispatcherProvider.default()) { randomPassword(spec) }
+ }
+
+ /** The diceware generator's options as the old dialog stored them. */
+ fun dicewareSpec(): DicewareSpec =
+ DicewareSpec(
+ words = pwgenPrefs.getInt(PreferenceKeys.DICEWARE_LENGTH, 5),
+ separator = pwgenPrefs.getString(PreferenceKeys.DICEWARE_SEPARATOR)?.firstOrNull() ?: '-',
+ )
+
+ /** Stores [spec] and generates a passphrase; the word list is read on first use, off main. */
+ suspend fun generateDiceware(spec: DicewareSpec): String {
+ pwgenPrefs.edit {
+ putInt(PreferenceKeys.DICEWARE_LENGTH, spec.words)
+ putString(PreferenceKeys.DICEWARE_SEPARATOR, spec.separator.toString())
+ }
+ return withContext(dispatcherProvider.io()) {
+ diceware.get().generatePassphrase(spec.words, spec.separator)
+ }
+ }
+
+ /** The text of the QR code in the image at [uri], or null when it has none. Off main. */
+ suspend fun readQr(uri: Uri, context: Context): String? {
+ val resolver = context.applicationContext.contentResolver
+ return withContext(dispatcherProvider.io()) {
+ runCatching {
+ val bitmap =
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
+ ImageDecoder.decodeBitmap(ImageDecoder.createSource(resolver, uri))
+ .copy(Bitmap.Config.ARGB_8888, false)
+ } else {
+ @Suppress("DEPRECATION") MediaStore.Images.Media.getBitmap(resolver, uri)
+ }
+ val pixels = IntArray(bitmap.width * bitmap.height)
+ bitmap.getPixels(pixels, 0, bitmap.width, 0, 0, bitmap.width, bitmap.height)
+ val source = RGBLuminanceSource(bitmap.width, bitmap.height, pixels)
+ QRCodeReader().decode(BinaryBitmap(HybridBinarizer(source))).text
+ }
+ .get()
+ }
+ }
+
+ /**
+ * Encrypts the form to [ids] and writes it to [target], replacing [original] when the entry was
+ * renamed or moved. Nothing is written (not even the folder) when [target] would replace another
+ * entry or when encryption fails; a recipient whose key is missing fails it (see
+ * CryptoRepository.encrypt). The caller checked [formError] and that [target] is inside the
+ * store. A success lands in [saved], with [copy] in [copyOnSave]. Returns null when a save
+ * already ran or is running.
+ */
+ suspend fun save(
+ ids: List<PGPIdentifier>,
+ target: File,
+ original: File?,
+ context: Context,
+ copy: Boolean,
+ ): SaveResult? {
+ if (saving) return null
+ saving = true
+ copyOnSave = copy
+ try {
+ return write(ids, target, original, context.applicationContext)
+ } finally {
+ // Stays set after a success (the editor is leaving); cleared on failure or cancellation.
+ if (saved == null) saving = false
+ }
+ }
+
+ /** True the first time only: the copy and notices of [saved] run once, across recreation. */
+ fun announce(): Boolean = !announced.also { announced = true }
+
+ /** The editor committed [saved] and left: nothing is left to finish. */
+ fun left() {
+ saved = null
+ }
+
+ override fun onCleared() {
+ form = EditorForm()
+ saved = null
+ }
+
+ private suspend fun write(
+ ids: List<PGPIdentifier>,
+ target: File,
+ original: File?,
+ app: Context,
+ ): SaveResult {
+ val form = form
+ if (withContext(dispatcherProvider.io()) { overwrites(target, original) })
+ return SaveResult.Failed(R.string.password_creation_duplicate_error)
+ val out = ByteArrayOutputStream()
+ val encrypted = repository.encrypt(ids, passContent(form).byteInputStream(), out)
+ if (!encrypted.isOk) {
+ logcat(ERROR) { encrypted.error.asLog() }
+ return SaveResult.Failed(
+ R.string.password_creation_encrypt_fail_message,
+ encrypted.error.message.orEmpty(),
+ )
+ }
+ // NonCancellable: a recreated activity must not stop between writing the target and deleting
+ // the original, which would leave a rename that cannot be retried ("duplicate").
+ return withContext(NonCancellable + dispatcherProvider.io()) {
+ try {
+ writeEntry(target, out.toByteArray())
+ } catch (e: IOException) {
+ logcat(ERROR) { e.asLog() }
+ return@withContext SaveResult.Failed(R.string.password_creation_file_write_fail_message)
+ }
+ var stale: File? = null
+ if (original != null && original.canonicalPath != target.canonicalPath) {
+ if (original.delete() || !original.exists()) {
+ AutofillMatcher.updateMatches(app, moveFromTo = mapOf(original to target))
+ app.moveHistory(original, target)
+ } else {
+ stale = original
+ }
+ }
+ // Set here, inside NonCancellable: a written entry is never forgotten by a cancelled caller.
+ SaveResult.Saved(target, form, stale).also { saved = it }
+ }
+ }
+
+ private fun Context.randomPrefs(): SharedPreferences =
+ getSharedPreferences(RANDOM_PREFS, Context.MODE_PRIVATE)
+
+ private companion object {
+ /** The old random generator dialog's preferences file. */
+ const val RANDOM_PREFS = "PasswordGenerator"
+ }
+}
diff --git a/app/src/main/java/app/passwordstore/ui/editor/GeneratorSheet.kt b/app/src/main/java/app/passwordstore/ui/editor/GeneratorSheet.kt
new file mode 100644
index 00000000..67a4963a
--- /dev/null
+++ b/app/src/main/java/app/passwordstore/ui/editor/GeneratorSheet.kt
@@ -0,0 +1,197 @@
+/*
+ * Copyright (C) 2026 Danilo M. <danix@danix.xyz>
+ * SPDX-License-Identifier: GPL-3.0-only
+ */
+
+package app.passwordstore.ui.editor
+
+import androidx.annotation.StringRes
+import androidx.compose.foundation.layout.Arrangement
+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.rememberScrollState
+import androidx.compose.foundation.selection.toggleable
+import androidx.compose.foundation.text.KeyboardOptions
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.material3.Button
+import androidx.compose.material3.Checkbox
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.ModalBottomSheet
+import androidx.compose.material3.ModalBottomSheetProperties
+import androidx.compose.material3.OutlinedTextField
+import androidx.compose.material3.PrimaryTabRow
+import androidx.compose.material3.Tab
+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.mutableIntStateOf
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+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.semantics.Role
+import androidx.compose.ui.text.font.FontFamily
+import androidx.compose.ui.text.input.KeyboardType
+import androidx.compose.ui.window.SecureFlagPolicy
+import app.passwordstore.R
+import app.passwordstore.passgen.random.PasswordGenerator
+import app.passwordstore.ui.compose.theme.SpacingLarge
+import app.passwordstore.ui.compose.theme.SpacingMedium
+import app.passwordstore.ui.compose.theme.SpacingSmall
+import com.github.michaelbull.result.Result
+
+private const val RANDOM_TAB = 0
+private const val DICEWARE_TAB = 1
+
+/**
+ * The password generator as a bottom sheet: a random tab and a diceware tab with the old dialogs'
+ * options, starting from [random] and [diceware]. [classicFirst] opens the random tab (the
+ * "Password generator" setting). Every change generates again; [onUse] takes the password shown.
+ * The password lives in [remember] only, and the sheet's window is FLAG_SECURE.
+ */
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun GeneratorSheet(
+ classicFirst: Boolean,
+ random: RandomSpec,
+ diceware: DicewareSpec,
+ onRandom: suspend (RandomSpec) -> Result<String, Int>,
+ onDiceware: suspend (DicewareSpec) -> String,
+ onUse: (String) -> Unit,
+ onDismiss: () -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ var tab by remember { mutableIntStateOf(if (classicFirst) RANDOM_TAB else DICEWARE_TAB) }
+ var randomSpec by remember { mutableStateOf(random) }
+ var dicewareSpec by remember { mutableStateOf(diceware) }
+ var length by remember { mutableStateOf(random.length.toString()) }
+ var words by remember { mutableStateOf(diceware.words.toString()) }
+ var separator by remember { mutableStateOf(diceware.separator.toString()) }
+ var rolls by remember { mutableIntStateOf(0) }
+ var password by remember { mutableStateOf("") }
+ var problem by remember { mutableStateOf<Int?>(null) }
+ LaunchedEffect(tab, randomSpec, dicewareSpec, rolls) {
+ if (tab == RANDOM_TAB) {
+ val result = onRandom(randomSpec)
+ password = if (result.isOk) result.value else ""
+ problem = if (result.isOk) null else result.error
+ } else {
+ password = onDiceware(dicewareSpec)
+ problem = null
+ }
+ }
+ ModalBottomSheet(
+ onDismissRequest = onDismiss,
+ modifier = modifier,
+ properties = ModalBottomSheetProperties(securePolicy = SecureFlagPolicy.SecureOn),
+ ) {
+ PrimaryTabRow(selectedTabIndex = tab) {
+ Tab(
+ selected = tab == RANDOM_TAB,
+ onClick = { tab = RANDOM_TAB },
+ text = { Text(stringResource(R.string.generator_random)) },
+ )
+ Tab(
+ selected = tab == DICEWARE_TAB,
+ onClick = { tab = DICEWARE_TAB },
+ text = { Text(stringResource(R.string.generator_diceware)) },
+ )
+ }
+ Column(
+ Modifier.verticalScroll(rememberScrollState()).padding(SpacingLarge),
+ verticalArrangement = Arrangement.spacedBy(SpacingSmall),
+ ) {
+ val shown = problem
+ Text(
+ if (shown == null) password else stringResource(shown),
+ style = MaterialTheme.typography.titleMedium.copy(fontFamily = FontFamily.Monospace),
+ color =
+ if (shown == null) MaterialTheme.colorScheme.onSurface
+ else MaterialTheme.colorScheme.error,
+ )
+ if (tab == RANDOM_TAB) {
+ CheckRow(R.string.pwgen_numerals, randomSpec.numerals) {
+ randomSpec = randomSpec.copy(numerals = it)
+ }
+ CheckRow(R.string.pwgen_symbols, randomSpec.symbols) {
+ randomSpec = randomSpec.copy(symbols = it)
+ }
+ CheckRow(R.string.pwgen_uppercase, randomSpec.uppercase) {
+ randomSpec = randomSpec.copy(uppercase = it)
+ }
+ CheckRow(R.string.pwgen_lowercase, randomSpec.lowercase) {
+ randomSpec = randomSpec.copy(lowercase = it)
+ }
+ CheckRow(R.string.pwgen_pronounceable, randomSpec.pronounceable) {
+ randomSpec = randomSpec.copy(pronounceable = it)
+ }
+ CheckRow(R.string.pwgen_ambiguous, randomSpec.ambiguous) {
+ randomSpec = randomSpec.copy(ambiguous = it)
+ }
+ OutlinedTextField(
+ value = length,
+ onValueChange = { text ->
+ // ponytail: at most 999 characters; the generator has no upper bound of its own.
+ val digits = text.filter(Char::isDigit).take(3)
+ length = digits
+ randomSpec =
+ randomSpec.copy(length = digits.toIntOrNull() ?: PasswordGenerator.DEFAULT_LENGTH)
+ },
+ label = { Text(stringResource(R.string.pwgen_length)) },
+ singleLine = true,
+ keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
+ )
+ } else {
+ OutlinedTextField(
+ value = words,
+ onValueChange = { text ->
+ // ponytail: at most 99 words.
+ val digits = text.filter(Char::isDigit).take(2)
+ words = digits
+ dicewareSpec = dicewareSpec.copy(words = digits.toIntOrNull()?.takeIf { it > 0 } ?: 5)
+ },
+ label = { Text(stringResource(R.string.pwgen_length)) },
+ singleLine = true,
+ keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
+ )
+ OutlinedTextField(
+ value = separator,
+ onValueChange = { text ->
+ separator = text.take(1)
+ dicewareSpec = dicewareSpec.copy(separator = separator.firstOrNull() ?: '-')
+ },
+ label = { Text(stringResource(R.string.pwgen_separator)) },
+ singleLine = true,
+ )
+ }
+ Row(
+ Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.spacedBy(SpacingMedium, Alignment.End),
+ ) {
+ TextButton(onClick = { rolls++ }) { Text(stringResource(R.string.pwgen_generate)) }
+ Button(onClick = { onUse(password) }, enabled = password.isNotEmpty() && problem == null) {
+ Text(stringResource(R.string.generator_use))
+ }
+ }
+ }
+ }
+}
+
+@Composable
+private fun CheckRow(@StringRes label: Int, checked: Boolean, onChange: (Boolean) -> Unit) {
+ Row(
+ Modifier.fillMaxWidth()
+ .toggleable(value = checked, role = Role.Checkbox, onValueChange = onChange),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Checkbox(checked = checked, onCheckedChange = null)
+ Text(stringResource(label), Modifier.padding(start = SpacingMedium))
+ }
+}
diff --git a/app/src/main/java/app/passwordstore/ui/entry/EntryDestination.kt b/app/src/main/java/app/passwordstore/ui/entry/EntryDestination.kt
new file mode 100644
index 00000000..02b112f4
--- /dev/null
+++ b/app/src/main/java/app/passwordstore/ui/entry/EntryDestination.kt
@@ -0,0 +1,246 @@
+/*
+ * Copyright (C) 2026 Danilo M. <danix@danix.xyz>
+ * SPDX-License-Identifier: GPL-3.0-only
+ */
+
+package app.passwordstore.ui.entry
+
+import android.app.Activity
+import android.content.Intent
+import androidx.activity.compose.rememberLauncherForActivityResult
+import androidx.activity.result.contract.ActivityResultContracts.StartActivityForResult
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.selection.toggleable
+import androidx.compose.foundation.text.KeyboardActions
+import androidx.compose.foundation.text.KeyboardOptions
+import androidx.compose.material3.AlertDialog
+import androidx.compose.material3.Checkbox
+import androidx.compose.material3.OutlinedTextField
+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.key
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.produceState
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.focus.FocusRequester
+import androidx.compose.ui.focus.focusRequester
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.semantics.Role
+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.window.DialogProperties
+import androidx.compose.ui.window.SecureFlagPolicy
+import androidx.hilt.navigation.compose.hiltViewModel
+import app.passwordstore.R
+import app.passwordstore.data.passfile.Totp
+import app.passwordstore.data.repo.PasswordRepository
+import app.passwordstore.ui.crypto.copyPasswordToClipboard
+import app.passwordstore.ui.crypto.copyTextToClipboard
+import app.passwordstore.ui.crypto.getPGPIdentifiers
+import app.passwordstore.ui.crypto.getParentPath
+import app.passwordstore.ui.main.LaunchActivity
+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 as BiometricResult
+import app.passwordstore.util.extensions.sharedPrefs
+import app.passwordstore.util.settings.PreferenceKeys
+import java.io.File
+
+/**
+ * One entry: wires [EntryScreen] to the [viewModel], the biometric and passphrase prompts and the
+ * clipboard. [onClose] leaves the destination; [onEdit] opens the editor on the decrypted entry.
+ * The host keeps FLAG_SECURE on while this is composed.
+ */
+@Composable
+fun EntryDestination(
+ activity: MainActivity,
+ file: File,
+ onClose: () -> Unit,
+ onEdit: () -> Unit,
+ modifier: Modifier = Modifier,
+ viewModel: EntryViewModel = hiltViewModel(),
+) {
+ val root = PasswordRepository.getRepositoryDirectory()
+
+ // Routes come from the app's own listing or from LaunchActivity's check; check once more.
+ val inRepo = remember(file) { LaunchActivity.isFileInRepo(file.path, root) }
+ LaunchedEffect(inRepo) { if (!inRepo) onClose() }
+ if (!inRepo) return
+
+ fun unlock() {
+ val ids =
+ activity.getPGPIdentifiers(getParentPath(file.absolutePath, root.absolutePath)) ?: return
+ if (viewModel.cacheEnabled && BiometricAuthenticator.canAuthenticate(activity)) {
+ BiometricAuthenticator.authenticate(
+ activity,
+ R.string.biometric_prompt_title_gpg_passphrase_cache,
+ ) { result ->
+ viewModel.unlock(file, ids, result, activity)
+ }
+ } else {
+ viewModel.unlock(file, ids, BiometricResult.CanceledByUser, activity)
+ }
+ }
+
+ // ponytail: after process death during key import, the unlock flow can start
+ // twice or a prompt can be left over the list; rare, no secret exposed. Fix by tracking
+ // whether the flow already started if it shows up.
+ val keyImport =
+ rememberLauncherForActivityResult(StartActivityForResult()) { result ->
+ if (result.resultCode == Activity.RESULT_OK) unlock() else onClose()
+ }
+
+ // ponytail: a theme or locale change while the biometric prompt is up loses that prompt's
+ // callback (the flow already counts as started); back out and reopen the entry.
+ LaunchedEffect(Unit) { if (viewModel.start() && viewModel.checkKeys()) unlock() }
+ LaunchedEffect(viewModel.closed) { if (viewModel.closed) onClose() }
+
+ val entry = viewModel.entry
+ LaunchedEffect(entry) {
+ val password = entry?.password
+ if (viewModel.takeCopyOnDecrypt() && !password.isNullOrBlank()) {
+ activity.copyPasswordToClipboard(password)
+ }
+ }
+ val fields = remember(entry) { entry?.let(::entryFields) }
+ val totp by
+ produceState<Totp?>(null, entry) {
+ if (entry != null && entry.hasTotp()) entry.totp.collect { value = it }
+ }
+ val folder =
+ remember(file) { file.canonicalFile.parentFile?.relativeTo(root.canonicalFile)?.path.orEmpty() }
+ val showPassword = remember {
+ activity.sharedPrefs.getBoolean(PreferenceKeys.SHOW_PASSWORD, true)
+ }
+
+ Box(modifier) {
+ EntryScreen(
+ name = file.nameWithoutExtension,
+ folder = folder,
+ fields = fields,
+ otp = totp?.value,
+ otpSecondsLeft = totp?.remainingTime?.inWholeSeconds ?: 0L,
+ otpFractionLeft = totp?.fractionLeft() ?: 0f,
+ showPassword = showPassword,
+ onBack = onClose,
+ onEdit = { if (entry != null) onEdit() },
+ onShare = { fields?.password?.let { activity.startActivity(shareIntent(activity, it)) } },
+ onCopyName = { activity.copyTextToClipboard(file.nameWithoutExtension) },
+ onCopySecret = { activity.copyPasswordToClipboard(it) },
+ onCopy = { activity.copyTextToClipboard(it) },
+ )
+
+ when (viewModel.prompt) {
+ EntryPrompt.None -> Unit
+ EntryPrompt.NoKeys ->
+ AlertDialog(
+ onDismissRequest = viewModel::cancel,
+ title = { Text(stringResource(R.string.no_keys_imported_dialog_title)) },
+ text = { Text(stringResource(R.string.no_keys_imported_dialog_message)) },
+ confirmButton = {
+ TextButton(
+ onClick = {
+ viewModel.dismissPrompt()
+ keyImport.launch(MainActivity.setupIntent(activity, SetupStart.PgpImport))
+ }
+ ) {
+ Text(stringResource(R.string.button_label_import))
+ }
+ },
+ )
+ EntryPrompt.Passphrase,
+ EntryPrompt.WrongPassphrase ->
+ key(viewModel.retries) {
+ PassphraseDialog(
+ wrong = viewModel.prompt == EntryPrompt.WrongPassphrase,
+ cacheEnabled = viewModel.cacheEnabled,
+ clearCache = viewModel.clearCache,
+ onSubmit = { passphrase, clear -> viewModel.submit(passphrase, clear, activity) },
+ onCancel = viewModel::cancel,
+ )
+ }
+ }
+ }
+}
+
+/**
+ * Asks for the PGP passphrase (entry view and autofill). The passphrase lives only in `remember`
+ * and the dialog's window is FLAG_SECURE.
+ */
+@Composable
+internal fun PassphraseDialog(
+ wrong: Boolean,
+ cacheEnabled: Boolean,
+ clearCache: Boolean,
+ onSubmit: (passphrase: String, clearCache: Boolean) -> Unit,
+ onCancel: () -> Unit,
+) {
+ // remember, never rememberSaveable: the passphrase must not reach saved state.
+ var passphrase by remember { mutableStateOf("") }
+ var clear by remember { mutableStateOf(clearCache) }
+ var error by remember { mutableStateOf(wrong) }
+ val focus = remember { FocusRequester() }
+ AlertDialog(
+ onDismissRequest = onCancel,
+ properties = DialogProperties(securePolicy = SecureFlagPolicy.SecureOn),
+ title = { Text(stringResource(R.string.password)) },
+ text = {
+ Column {
+ OutlinedTextField(
+ value = passphrase,
+ onValueChange = {
+ passphrase = it
+ error = false
+ },
+ modifier = Modifier.focusRequester(focus),
+ label = { Text(stringResource(R.string.password)) },
+ supportingText =
+ if (error) {
+ { Text(stringResource(R.string.git_operation_wrong_password)) }
+ } else null,
+ isError = error,
+ singleLine = true,
+ visualTransformation = PasswordVisualTransformation(),
+ keyboardOptions =
+ KeyboardOptions(keyboardType = KeyboardType.Password, imeAction = ImeAction.Done),
+ keyboardActions = KeyboardActions(onDone = { onSubmit(passphrase, clear) }),
+ )
+ if (cacheEnabled)
+ Row(
+ Modifier.toggleable(
+ value = clear,
+ role = Role.Checkbox,
+ onValueChange = { clear = it },
+ ),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Checkbox(checked = clear, onCheckedChange = null)
+ Text(stringResource(R.string.clear_cached_password_on_screen_off))
+ }
+ }
+ LaunchedEffect(Unit) { focus.requestFocus() }
+ },
+ confirmButton = {
+ TextButton(onClick = { onSubmit(passphrase, clear) }) {
+ Text(stringResource(R.string.dialog_ok))
+ }
+ },
+ )
+}
+
+/** Always a chooser, so the user can still cancel. */
+private fun shareIntent(activity: Activity, password: String): Intent =
+ Intent.createChooser(
+ Intent(Intent.ACTION_SEND).putExtra(Intent.EXTRA_TEXT, password).setType("text/plain"),
+ activity.getText(R.string.send_plaintext_password_to),
+ )
diff --git a/app/src/main/java/app/passwordstore/ui/entry/EntryModel.kt b/app/src/main/java/app/passwordstore/ui/entry/EntryModel.kt
new file mode 100644
index 00000000..8dbc9eb9
--- /dev/null
+++ b/app/src/main/java/app/passwordstore/ui/entry/EntryModel.kt
@@ -0,0 +1,35 @@
+/*
+ * Copyright (C) 2026 Danilo M. <danix@danix.xyz>
+ * SPDX-License-Identifier: GPL-3.0-only
+ */
+
+package app.passwordstore.ui.entry
+
+import androidx.compose.runtime.Immutable
+import app.passwordstore.data.passfile.PasswordEntry
+import app.passwordstore.data.passfile.Totp
+import kotlinx.collections.immutable.ImmutableList
+import kotlinx.collections.immutable.toImmutableList
+
+/**
+ * What the entry view shows. [password] and [username] open the first group (the one-time code
+ * joins them from the entry's TOTP flow); [extras] are the remaining `key: value` lines in file
+ * order, plus lines that are not `key: value` under the parser's "Extra Content" key. Blank values
+ * are null, so their rows are not shown.
+ */
+@Immutable
+data class EntryFields(
+ val password: String?,
+ val username: String?,
+ val extras: ImmutableList<Pair<String, String>>,
+)
+
+fun entryFields(entry: PasswordEntry): EntryFields =
+ EntryFields(
+ password = entry.password?.takeIf { it.isNotBlank() },
+ username = entry.username?.takeIf { it.isNotBlank() },
+ extras = entry.extraContent.toList().toImmutableList(),
+ )
+
+/** Share of the code's lifetime left: 1 right after it was generated, toward 0 at expiry. */
+fun Totp.fractionLeft(): Float = (remainingTime / period).toFloat()
diff --git a/app/src/main/java/app/passwordstore/ui/entry/EntryScreen.kt b/app/src/main/java/app/passwordstore/ui/entry/EntryScreen.kt
new file mode 100644
index 00000000..d4860819
--- /dev/null
+++ b/app/src/main/java/app/passwordstore/ui/entry/EntryScreen.kt
@@ -0,0 +1,183 @@
+/*
+ * Copyright (C) 2026 Danilo M. <danix@danix.xyz>
+ * SPDX-License-Identifier: GPL-3.0-only
+ */
+
+package app.passwordstore.ui.entry
+
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+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.verticalScroll
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.automirrored.filled.ArrowBack
+import androidx.compose.material.icons.filled.MoreVert
+import androidx.compose.material3.DropdownMenu
+import androidx.compose.material3.DropdownMenuItem
+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.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.key
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.res.painterResource
+import androidx.compose.ui.res.stringResource
+import app.passwordstore.R
+import app.passwordstore.ui.compose.FieldRow
+import app.passwordstore.ui.compose.FieldRowLabels
+import app.passwordstore.ui.compose.GroupedSurface
+import app.passwordstore.ui.compose.LetterAvatar
+import app.passwordstore.ui.compose.theme.SpacingLarge
+import app.passwordstore.ui.compose.theme.SpacingMedium
+
+/**
+ * One entry: header (avatar, name, [folder] relative to the store root, "" at the root), then the
+ * password, username and one-time code, then the extra fields. [fields] is null until decrypted;
+ * [otp] is null when the entry has no TOTP. [onCopySecret] (password, one-time code) is separate
+ * from [onCopy] because only those copies start the clipboard clear timer.
+ */
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun EntryScreen(
+ name: String,
+ folder: String,
+ fields: EntryFields?,
+ otp: String?,
+ otpSecondsLeft: Long,
+ otpFractionLeft: Float,
+ showPassword: Boolean,
+ onBack: () -> Unit,
+ onEdit: () -> Unit,
+ onShare: () -> Unit,
+ onCopyName: () -> Unit,
+ onCopySecret: (String) -> Unit,
+ onCopy: (String) -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ var menu by remember { mutableStateOf(false) }
+ val labels =
+ FieldRowLabels(
+ copy = stringResource(R.string.copy_label),
+ show = stringResource(R.string.entry_show_password),
+ hide = stringResource(R.string.entry_hide_password),
+ )
+ Scaffold(
+ modifier = modifier,
+ topBar = {
+ TopAppBar(
+ title = {},
+ navigationIcon = {
+ IconButton(onClick = onBack) {
+ Icon(Icons.AutoMirrored.Filled.ArrowBack, stringResource(R.string.entry_back))
+ }
+ },
+ actions = {
+ if (fields != null)
+ IconButton(onClick = onEdit) {
+ Icon(
+ painterResource(R.drawable.ic_edit_24dp),
+ contentDescription = stringResource(R.string.edit_password),
+ )
+ }
+ Box {
+ IconButton(onClick = { menu = true }) {
+ Icon(Icons.Filled.MoreVert, stringResource(R.string.list_more_options))
+ }
+ DropdownMenu(expanded = menu, onDismissRequest = { menu = false }) {
+ DropdownMenuItem(
+ text = { Text(stringResource(R.string.entry_copy_name)) },
+ onClick = {
+ menu = false
+ onCopyName()
+ },
+ )
+ if (fields?.password != null)
+ DropdownMenuItem(
+ text = { Text(stringResource(R.string.share_as_plaintext)) },
+ onClick = {
+ menu = false
+ onShare()
+ },
+ leadingIcon = {
+ Icon(painterResource(R.drawable.ic_share_24dp), contentDescription = null)
+ },
+ )
+ }
+ }
+ },
+ )
+ },
+ ) { padding ->
+ Column(Modifier.padding(padding).fillMaxSize().verticalScroll(rememberScrollState())) {
+ Row(
+ Modifier.fillMaxWidth().padding(horizontal = SpacingLarge, vertical = SpacingMedium),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ LetterAvatar(name)
+ Column(Modifier.weight(1f).padding(start = SpacingLarge)) {
+ Text(name, style = MaterialTheme.typography.titleLarge)
+ Text(
+ if (folder.isEmpty()) stringResource(R.string.list_store_root) else folder,
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ }
+ }
+ if (fields != null) {
+ if (fields.password != null || fields.username != null || otp != null)
+ GroupedSurface(Modifier.fillMaxWidth()) {
+ if (fields.password != null)
+ FieldRow(
+ label = stringResource(R.string.password),
+ value = fields.password,
+ labels = labels.copy(copy = stringResource(R.string.copy_password)),
+ masked = true,
+ initiallyRevealed = showPassword,
+ onCopy = { onCopySecret(fields.password) },
+ )
+ if (fields.username != null)
+ FieldRow(
+ label = stringResource(R.string.username),
+ value = fields.username,
+ labels = labels,
+ onCopy = { onCopy(fields.username) },
+ )
+ if (otp != null) {
+ FieldRow(
+ label = stringResource(R.string.otp_label_format, otpSecondsLeft),
+ value = otp,
+ labels = labels,
+ onCopy = { onCopySecret(otp) },
+ )
+ LinearProgressIndicator(
+ progress = { otpFractionLeft },
+ modifier =
+ Modifier.fillMaxWidth()
+ .padding(start = SpacingLarge, end = SpacingLarge, bottom = SpacingMedium),
+ )
+ }
+ }
+ if (fields.extras.isNotEmpty())
+ GroupedSurface(Modifier.fillMaxWidth()) {
+ fields.extras.forEach { (label, value) ->
+ key(label) { FieldRow(label, value, labels, onCopy = { onCopy(value) }) }
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/app/passwordstore/ui/entry/EntryViewModel.kt b/app/src/main/java/app/passwordstore/ui/entry/EntryViewModel.kt
new file mode 100644
index 00000000..6296a9b6
--- /dev/null
+++ b/app/src/main/java/app/passwordstore/ui/entry/EntryViewModel.kt
@@ -0,0 +1,220 @@
+/*
+ * Copyright (C) 2026 Danilo M. <danix@danix.xyz>
+ * SPDX-License-Identifier: GPL-3.0-only
+ */
+
+package app.passwordstore.ui.entry
+
+import android.content.Context
+import android.content.SharedPreferences
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.setValue
+import androidx.core.content.edit
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import app.passwordstore.Application.Companion.screenWasOff
+import app.passwordstore.crypto.PGPIdentifier
+import app.passwordstore.data.crypto.CryptoRepository
+import app.passwordstore.data.crypto.PGPPassphraseCache
+import app.passwordstore.data.passfile.PasswordEntry
+import app.passwordstore.injection.prefs.SettingsPreferences
+import app.passwordstore.ui.autofill.cachesPassphrase
+import app.passwordstore.util.auth.BiometricAuthenticator.Result as BiometricResult
+import app.passwordstore.util.coroutines.DispatcherProvider
+import app.passwordstore.util.extensions.getString
+import app.passwordstore.util.features.Feature.EnablePGPPassphraseCache
+import app.passwordstore.util.features.Features
+import app.passwordstore.util.settings.Constants
+import app.passwordstore.util.settings.PreferenceKeys
+import com.github.michaelbull.result.onFailure
+import com.github.michaelbull.result.runCatching
+import dagger.hilt.android.lifecycle.HiltViewModel
+import java.io.ByteArrayOutputStream
+import java.io.File
+import javax.inject.Inject
+import kotlin.time.Duration.Companion.seconds
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.withContext
+import logcat.LogPriority.ERROR
+import logcat.asLog
+import logcat.logcat
+
+/** The dialog the entry destination shows, if any. */
+enum class EntryPrompt {
+ None,
+ NoKeys,
+ Passphrase,
+ WrongPassphrase,
+}
+
+/**
+ * Decrypts one entry as the old decrypt screen did: passphrase cache, passphrase prompt with
+ * retries, then the auto-close timer. The decrypted [entry] lives only in memory: never in saved
+ * state or a route argument. The timer drops it, and so does popping the destination (its back
+ * stack entry clears this ViewModel). Holds no Context; calls take one and use the application
+ * context.
+ */
+@HiltViewModel
+class EntryViewModel
+@Inject
+constructor(
+ private val repository: CryptoRepository,
+ private val entryFactory: PasswordEntry.Factory,
+ private val passphraseCache: PGPPassphraseCache,
+ private val features: Features,
+ @SettingsPreferences private val settings: SharedPreferences,
+ private val dispatcherProvider: DispatcherProvider,
+) : ViewModel() {
+
+ var entry by mutableStateOf<PasswordEntry?>(null)
+ private set
+
+ var prompt by mutableStateOf(EntryPrompt.None)
+ private set
+
+ /** Set when the destination must close: timer, cancelled prompt, too many wrong passphrases. */
+ var closed by mutableStateOf(false)
+ private set
+
+ /** Initial state of the passphrase dialog's "clear on screen off" box. */
+ var clearCache by mutableStateOf(true)
+ private set
+
+ val cacheEnabled: Boolean
+ get() = features.isEnabled(EnablePGPPassphraseCache)
+
+ private var started = false
+ private var copyOnDecrypt = false
+ /** Failed-or-asked passphrase prompts so far; keys the dialog so each attempt starts fresh. */
+ var retries = 0
+ private set
+
+ private var auth: BiometricResult = BiometricResult.CanceledByUser
+ private var ids: List<PGPIdentifier> = emptyList()
+ private var file: File? = null
+
+ /** True the first time only: a recreated activity must not run the unlock flow again. */
+ fun start(): Boolean {
+ if (started) return false
+ started = true
+ return true
+ }
+
+ /** False, with the "no keys" prompt up, while no PGP key is imported. */
+ suspend fun checkKeys(): Boolean {
+ val hasKeys = repository.hasKeys()
+ if (!hasKeys) prompt = EntryPrompt.NoKeys
+ return hasKeys
+ }
+
+ /** Continues after the biometric prompt (or without one) with its [auth] result. */
+ fun unlock(file: File, ids: List<PGPIdentifier>, auth: BiometricResult, context: Context) {
+ this.file = file
+ this.ids = ids
+ this.auth = auth
+ val app = context.applicationContext
+ viewModelScope.launch { next(isError = false, app) }
+ }
+
+ fun submit(passphrase: String, clearCache: Boolean, context: Context) {
+ if (prompt == EntryPrompt.None) return
+ prompt = EntryPrompt.None
+ this.clearCache = clearCache
+ val app = context.applicationContext
+ viewModelScope.launch {
+ if (decrypt(passphrase, app) && cachesPassphrase(cacheEnabled, auth)) {
+ runCatching {
+ passphraseCache.cachePassphrase(app, ids.first(), passphrase)
+ settings.edit { putBoolean(PreferenceKeys.CLEAR_PASSPHRASE_CACHE, clearCache) }
+ }
+ .onFailure { e -> logcat { e.asLog() } }
+ }
+ }
+ }
+
+ fun dismissPrompt() {
+ prompt = EntryPrompt.None
+ }
+
+ fun cancel() {
+ prompt = EntryPrompt.None
+ closed = true
+ }
+
+ /** True once after a decryption when "Automatically copy password" is on. */
+ fun takeCopyOnDecrypt(): Boolean = copyOnDecrypt.also { copyOnDecrypt = false }
+
+ override fun onCleared() {
+ entry = null
+ }
+
+ private suspend fun next(isError: Boolean, context: Context) {
+ when (auth) {
+ // Handled inside the biometric prompt.
+ BiometricResult.Retry -> Unit
+ is BiometricResult.Success -> {
+ // Clear the cache on first use after app start or after the screen was off; this also
+ // purges a stale cache after caching was disabled in the PGP settings.
+ clearCache = settings.getBoolean(PreferenceKeys.CLEAR_PASSPHRASE_CACHE, true)
+ if (screenWasOff && clearCache) {
+ passphraseCache.clearAllCachedPassphrases(context)
+ screenWasOff = false
+ }
+ // A cached passphrase that just failed is not tried again (the old screen looped on it).
+ val cached =
+ if (isError) null else passphraseCache.retrieveCachedPassphrase(context, ids.first())
+ if (cached != null) decrypt(cached, context) else ask(isError, context)
+ }
+ else -> ask(isError, context)
+ }
+ }
+
+ private suspend fun ask(isError: Boolean, context: Context) {
+ if (retries >= MAX_RETRIES) {
+ closed = true
+ return
+ }
+ retries++
+ if (withContext(dispatcherProvider.io()) { repository.isPasswordProtected(ids) }) {
+ prompt = if (isError) EntryPrompt.WrongPassphrase else EntryPrompt.Passphrase
+ } else {
+ decrypt(passphrase = "", context)
+ }
+ }
+
+ /** Decrypts off the main thread; a failure goes back to [next] with an error. */
+ private suspend fun decrypt(passphrase: String, context: Context): Boolean {
+ val source = file ?: return false
+ val bytes = withContext(dispatcherProvider.io()) { source.readBytes() }
+ val out = ByteArrayOutputStream()
+ val result = repository.decrypt(passphrase, ids, bytes.inputStream(), out)
+ if (!result.isOk) {
+ logcat(ERROR) { result.error.stackTraceToString() }
+ next(isError = true, context)
+ return false
+ }
+ copyOnDecrypt = settings.getBoolean(PreferenceKeys.COPY_ON_DECRYPT, false)
+ entry = withContext(dispatcherProvider.io()) { entryFactory.create(out.toByteArray()) }
+ startTimer()
+ return true
+ }
+
+ /** Drops the entry and closes after the "Password copy timeout" setting (0 = never). */
+ private fun startTimer() {
+ val timeout =
+ settings.getString(PreferenceKeys.GENERAL_SHOW_TIME)?.toIntOrNull()
+ ?: Constants.DEFAULT_DECRYPTION_TIMEOUT
+ if (timeout == 0) return
+ viewModelScope.launch {
+ delay(timeout.seconds)
+ entry = null
+ closed = true
+ }
+ }
+
+ private companion object {
+ const val MAX_RETRIES = 3
+ }
+}
diff --git a/app/src/main/java/app/passwordstore/ui/folderselect/SelectFolderActivity.kt b/app/src/main/java/app/passwordstore/ui/folderselect/SelectFolderActivity.kt
deleted file mode 100644
index cfeb6206..00000000
--- a/app/src/main/java/app/passwordstore/ui/folderselect/SelectFolderActivity.kt
+++ /dev/null
@@ -1,67 +0,0 @@
-/*
- * Copyright © 2014-2024 The Android Password Store Authors. All Rights Reserved.
- * SPDX-License-Identifier: GPL-3.0-only
- */
-package app.passwordstore.ui.folderselect
-
-import android.os.Bundle
-import android.view.Menu
-import android.view.MenuItem
-import androidx.appcompat.app.AppCompatActivity
-import androidx.fragment.app.FragmentManager
-import androidx.fragment.app.commit
-import app.passwordstore.R
-import app.passwordstore.data.repo.PasswordRepository
-import app.passwordstore.ui.passwords.PASSWORD_FRAGMENT_TAG
-import app.passwordstore.ui.passwords.PasswordStore
-import dagger.hilt.android.AndroidEntryPoint
-
-@AndroidEntryPoint
-class SelectFolderActivity : AppCompatActivity(R.layout.select_folder_layout) {
-
- private lateinit var passwordList: SelectFolderFragment
-
- override fun onCreate(savedInstanceState: Bundle?) {
- super.onCreate(savedInstanceState)
-
- passwordList = SelectFolderFragment()
- val args = Bundle()
- args.putString(
- PasswordStore.REQUEST_ARG_PATH,
- PasswordRepository.getRepositoryDirectory().absolutePath,
- )
-
- passwordList.arguments = args
-
- supportActionBar?.show()
-
- supportFragmentManager.popBackStack(null, FragmentManager.POP_BACK_STACK_INCLUSIVE)
-
- supportFragmentManager.commit {
- replace(R.id.pgp_handler_linearlayout, passwordList, PASSWORD_FRAGMENT_TAG)
- }
- }
-
- override fun onCreateOptionsMenu(menu: Menu): Boolean {
- menuInflater.inflate(R.menu.pgp_handler_select_folder, menu)
- return true
- }
-
- override fun onOptionsItemSelected(item: MenuItem): Boolean {
- when (item.itemId) {
- android.R.id.home -> {
- setResult(RESULT_CANCELED)
- onBackPressedDispatcher.onBackPressed()
- }
- R.id.crypto_select -> selectFolder()
- else -> return super.onOptionsItemSelected(item)
- }
- return true
- }
-
- private fun selectFolder() {
- intent.putExtra("SELECTED_FOLDER_PATH", passwordList.currentDir.absolutePath)
- setResult(RESULT_OK, intent)
- finish()
- }
-}
diff --git a/app/src/main/java/app/passwordstore/ui/folderselect/SelectFolderFragment.kt b/app/src/main/java/app/passwordstore/ui/folderselect/SelectFolderFragment.kt
deleted file mode 100644
index 3cf12fdd..00000000
--- a/app/src/main/java/app/passwordstore/ui/folderselect/SelectFolderFragment.kt
+++ /dev/null
@@ -1,98 +0,0 @@
-/*
- * Copyright © 2014-2024 The Android Password Store Authors. All Rights Reserved.
- * SPDX-License-Identifier: GPL-3.0-only
- */
-package app.passwordstore.ui.folderselect
-
-import android.content.Context
-import android.os.Bundle
-import android.view.View
-import androidx.appcompat.app.AppCompatActivity
-import androidx.fragment.app.Fragment
-import androidx.fragment.app.activityViewModels
-import androidx.lifecycle.flowWithLifecycle
-import androidx.lifecycle.lifecycleScope
-import androidx.recyclerview.widget.LinearLayoutManager
-import app.passwordstore.R
-import app.passwordstore.data.password.PasswordItem
-import app.passwordstore.databinding.PasswordRecyclerViewBinding
-import app.passwordstore.ui.adapters.PasswordItemRecyclerAdapter
-import app.passwordstore.ui.passwords.PasswordStore
-import app.passwordstore.util.coroutines.DispatcherProvider
-import app.passwordstore.util.extensions.viewBinding
-import app.passwordstore.util.viewmodel.ListMode
-import app.passwordstore.util.viewmodel.SearchableRepositoryViewModel
-import com.github.michaelbull.result.onFailure
-import com.github.michaelbull.result.runCatching
-import dagger.hilt.android.AndroidEntryPoint
-import java.io.File
-import javax.inject.Inject
-import kotlinx.coroutines.launch
-import me.zhanghai.android.fastscroll.FastScrollerBuilder
-
-@AndroidEntryPoint
-class SelectFolderFragment : Fragment(R.layout.password_recycler_view) {
-
- @Inject lateinit var dispatcherProvider: DispatcherProvider
- private val binding by viewBinding(PasswordRecyclerViewBinding::bind)
- private lateinit var recyclerAdapter: PasswordItemRecyclerAdapter
- private lateinit var listener: OnFragmentInteractionListener
-
- private val model: SearchableRepositoryViewModel by activityViewModels()
-
- override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
- super.onViewCreated(view, savedInstanceState)
- binding.fab.hide()
- recyclerAdapter =
- PasswordItemRecyclerAdapter(lifecycleScope, dispatcherProvider).onItemClicked { _, item ->
- listener.onFragmentInteraction(item)
- }
- binding.passRecycler.apply {
- layoutManager = LinearLayoutManager(requireContext())
- itemAnimator = null
- adapter = recyclerAdapter
- }
-
- FastScrollerBuilder(binding.passRecycler).build()
- registerForContextMenu(binding.passRecycler)
-
- val path =
- requireNotNull(requireArguments().getString(PasswordStore.REQUEST_ARG_PATH)) {
- "Cannot navigate if ${PasswordStore.REQUEST_ARG_PATH} is not provided"
- }
- model.navigateTo(File(path), listMode = ListMode.DirectoriesOnly, pushPreviousLocation = false)
- lifecycleScope.launch {
- model.searchResult.flowWithLifecycle(lifecycle).collect { result ->
- recyclerAdapter.submitList(result.passwordItems)
- }
- }
- }
-
- override fun onAttach(context: Context) {
- super.onAttach(context)
- runCatching {
- listener =
- object : OnFragmentInteractionListener {
- override fun onFragmentInteraction(item: PasswordItem) {
- if (item.type == PasswordItem.TYPE_CATEGORY) {
- model.navigateTo(item.file, listMode = ListMode.DirectoriesOnly)
- (requireActivity() as AppCompatActivity)
- .supportActionBar
- ?.setDisplayHomeAsUpEnabled(true)
- }
- }
- }
- }
- .onFailure {
- throw ClassCastException("$context must implement OnFragmentInteractionListener")
- }
- }
-
- val currentDir: File
- get() = model.currentDir.value
-
- interface OnFragmentInteractionListener {
-
- fun onFragmentInteraction(item: PasswordItem)
- }
-}
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/config/GitServerConfigActivity.kt b/app/src/main/java/app/passwordstore/ui/git/config/GitServerConfigActivity.kt
deleted file mode 100644
index 2a1d3ebd..00000000
--- a/app/src/main/java/app/passwordstore/ui/git/config/GitServerConfigActivity.kt
+++ /dev/null
@@ -1,304 +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.content.Context
-import android.content.Intent
-import android.os.Bundle
-import android.os.Handler
-import android.os.Looper
-import android.view.MenuItem
-import android.view.View
-import androidx.core.os.postDelayed
-import androidx.core.view.isVisible
-import androidx.core.widget.doOnTextChanged
-import androidx.lifecycle.lifecycleScope
-import app.passwordstore.R
-import app.passwordstore.data.repo.PasswordRepository
-import app.passwordstore.databinding.ActivityGitCloneBinding
-import app.passwordstore.ui.dialogs.BasicBottomSheet
-import app.passwordstore.ui.git.base.BaseGitActivity
-import app.passwordstore.util.extensions.snackbar
-import app.passwordstore.util.extensions.viewBinding
-import app.passwordstore.util.settings.AuthMode
-import app.passwordstore.util.settings.GitSettings
-import app.passwordstore.util.settings.Protocol
-import com.github.michaelbull.result.fold
-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 kotlinx.coroutines.withContext
-import logcat.LogPriority.ERROR
-import logcat.asLog
-import logcat.logcat
-
-/**
- * Activity that encompasses both the initial clone as well as editing the server config for future
- * changes.
- */
-class GitServerConfigActivity : BaseGitActivity() {
-
- private val binding by viewBinding(ActivityGitCloneBinding::inflate)
-
- private lateinit var newAuthMode: AuthMode
-
- override fun onCreate(savedInstanceState: Bundle?) {
- super.onCreate(savedInstanceState)
- val isClone = intent?.extras?.getBoolean("cloning") ?: false
- if (isClone) {
- binding.saveButton.text = getString(R.string.clone_button)
- }
- setContentView(binding.root)
- supportActionBar?.setDisplayHomeAsUpEnabled(true)
-
- newAuthMode = gitSettings.authMode
-
- binding.authModeGroup.apply {
- when (newAuthMode) {
- AuthMode.SshKey -> check(binding.authModeSshKey.id)
- AuthMode.Password -> check(binding.authModePassword.id)
- AuthMode.None -> clearChecked()
- }
- addOnButtonCheckedListener { _, _, _ ->
- if (checkedButtonIds.isEmpty()) {
- newAuthMode = AuthMode.None
- } else {
- when (checkedButtonId) {
- binding.authModeSshKey.id -> newAuthMode = AuthMode.SshKey
- binding.authModePassword.id -> newAuthMode = AuthMode.Password
- View.NO_ID -> newAuthMode = AuthMode.None
- }
- }
- }
- }
-
- binding.serverUrl.setText(
- gitSettings.url.also {
- if (it.isNullOrEmpty()) return@also
- setAuthModes(it.startsWith("http://") || it.startsWith("https://"))
- }
- )
-
- binding.serverUrl.doOnTextChanged { text, _, _, _ ->
- if (text.isNullOrEmpty()) return@doOnTextChanged
- setAuthModes(text.startsWith("http://") || text.startsWith("https://"))
- }
-
- binding.clearHostKeyButton.isVisible = gitSettings.hasSavedHostKey()
- binding.clearHostKeyButton.setOnClickListener {
- gitSettings.clearSavedHostKey()
- Snackbar.make(
- binding.root,
- getString(R.string.clear_saved_host_key_success),
- Snackbar.LENGTH_LONG,
- )
- .show()
- it.isVisible = false
- }
- binding.saveButton.setOnClickListener {
- val newUrl = binding.serverUrl.text.toString().trim()
- // If url is of type john_doe@example.org:12435/path/to/repo, then not adding `ssh://`
- // in the beginning will cause the port to be seen as part of the path. Let users know
- // about it and offer a quickfix.
- if (newUrl.contains(PORT_REGEX)) {
- if (newUrl.startsWith("https://")) {
- BasicBottomSheet.Builder(this)
- .setTitleRes(R.string.https_scheme_with_port_title)
- .setMessageRes(R.string.https_scheme_with_port_message)
- .setPositiveButtonClickListener {
- binding.serverUrl.setText(newUrl.replace(PORT_REGEX, "/"))
- }
- .build()
- .show(supportFragmentManager, "SSH_SCHEME_WARNING")
- return@setOnClickListener
- } else if (!newUrl.startsWith("ssh://")) {
- BasicBottomSheet.Builder(this)
- .setTitleRes(R.string.ssh_scheme_needed_title)
- .setMessageRes(R.string.ssh_scheme_needed_message)
- .setPositiveButtonClickListener {
- @Suppress("SetTextI18n") binding.serverUrl.setText("ssh://$newUrl")
- }
- .build()
- .show(supportFragmentManager, "SSH_SCHEME_WARNING")
- return@setOnClickListener
- }
- }
- if (newUrl.startsWith("git://")) {
- BasicBottomSheet.Builder(this)
- .setTitleRes(R.string.git_scheme_disallowed_title)
- .setMessageRes(R.string.git_scheme_disallowed_message)
- .setPositiveButtonClickListener {}
- .build()
- .show(supportFragmentManager, "SSH_SCHEME_WARNING")
- return@setOnClickListener
- }
- when (
- val updateResult =
- gitSettings.updateConnectionSettingsIfValid(
- newAuthMode = newAuthMode,
- newUrl = binding.serverUrl.text.toString().trim(),
- )
- ) {
- GitSettings.UpdateConnectionSettingsResult.FailedToParseUrl -> {
- Snackbar.make(
- binding.root,
- getString(R.string.git_server_config_save_error),
- Snackbar.LENGTH_LONG,
- )
- .show()
- }
- is GitSettings.UpdateConnectionSettingsResult.MissingUsername -> {
- when (updateResult.newProtocol) {
- Protocol.Https ->
- BasicBottomSheet.Builder(this)
- .setTitleRes(R.string.ssh_scheme_needed_title)
- .setMessageRes(R.string.git_server_config_save_missing_username_https)
- .setPositiveButtonClickListener {}
- .build()
- .show(supportFragmentManager, "HTTPS_MISSING_USERNAME")
- Protocol.Ssh ->
- BasicBottomSheet.Builder(this)
- .setTitleRes(R.string.ssh_scheme_needed_title)
- .setMessageRes(R.string.git_server_config_save_missing_username_ssh)
- .setPositiveButtonClickListener {}
- .build()
- .show(supportFragmentManager, "SSH_MISSING_USERNAME")
- }
- }
- GitSettings.UpdateConnectionSettingsResult.Valid -> {
- if (isClone && PasswordRepository.repository == null) PasswordRepository.initialize()
- if (!isClone) {
- Snackbar.make(
- binding.root,
- getString(R.string.git_server_config_save_success),
- Snackbar.LENGTH_SHORT,
- )
- .show()
- Handler(Looper.getMainLooper()).postDelayed(500) { finish() }
- } else {
- cloneRepository()
- }
- }
- is GitSettings.UpdateConnectionSettingsResult.AuthModeMismatch -> {
- val message =
- getString(
- R.string.git_server_config_save_auth_mode_mismatch,
- updateResult.newProtocol,
- updateResult.validModes.joinToString(", "),
- )
- Snackbar.make(binding.root, message, Snackbar.LENGTH_LONG).show()
- }
- }
- }
- }
-
- override fun onOptionsItemSelected(item: MenuItem): Boolean {
- return when (item.itemId) {
- android.R.id.home -> {
- onBackPressedDispatcher.onBackPressed()
- true
- }
- else -> super.onOptionsItemSelected(item)
- }
- }
-
- private fun setAuthModes(isHttps: Boolean) =
- with(binding) {
- if (isHttps) {
- authModeSshKey.isVisible = false
- authModePassword.isVisible = true
- if (authModeGroup.checkedButtonId != authModePassword.id) authModeGroup.clearChecked()
- } else {
- authModeSshKey.isVisible = true
- authModePassword.isVisible = true
- if (authModeGroup.checkedButtonId == View.NO_ID) authModeGroup.check(authModeSshKey.id)
- }
- }
-
- /** Clones the repository, the directory exists, deletes it */
- private fun cloneRepository() {
- val localDir =
- requireNotNull(PasswordRepository.getRepositoryDirectory()) {
- "Repository directory must be set before cloning"
- }
- val localDirFiles = localDir.listFiles() ?: emptyArray()
- // Warn if non-empty folder unless it's a just-initialized store that has just a .git folder
- if (
- localDir.exists() &&
- localDirFiles.isNotEmpty() &&
- !(localDirFiles.size == 1 && localDirFiles[0].name == ".git")
- ) {
- MaterialAlertDialogBuilder(this)
- .setTitle(R.string.dialog_delete_title)
- .setMessage(resources.getString(R.string.dialog_delete_msg, localDir.toString()))
- .setCancelable(false)
- .setPositiveButton(R.string.dialog_delete) { dialog, _ ->
- runCatching {
- lifecycleScope.launch {
- val snackbar =
- snackbar(
- message = getString(R.string.delete_directory_progress_text),
- length = Snackbar.LENGTH_INDEFINITE,
- )
- withContext(dispatcherProvider.io()) {
- localDir.deleteRecursively()
- localDir.mkdirs()
- }
- snackbar.dismiss()
- launchGitOperation(GitOp.CLONE)
- .fold(
- success = {
- setResult(RESULT_OK)
- finish()
- },
- failure = { err -> promptOnErrorHandler(err) { finish() } },
- )
- }
- }
- .onFailure { e ->
- e.printStackTrace()
- MaterialAlertDialogBuilder(this).setMessage(e.message).show()
- }
- dialog.cancel()
- }
- .setNegativeButton(R.string.dialog_do_not_delete) { dialog, _ -> dialog.cancel() }
- .show()
- } else {
- runCatching {
- // Silently delete & replace the lone .git folder if it exists
- if (localDir.exists() && localDirFiles.size == 1 && localDirFiles[0].name == ".git") {
- localDir.deleteRecursively()
- }
- }
- .onFailure { e ->
- logcat(ERROR) { e.asLog() }
- MaterialAlertDialogBuilder(this).setMessage(e.message).show()
- }
- lifecycleScope.launch {
- launchGitOperation(GitOp.CLONE)
- .fold(
- success = {
- setResult(RESULT_OK)
- finish()
- },
- failure = { promptOnErrorHandler(it) },
- )
- }
- }
- }
-
- companion object {
-
- private val PORT_REGEX = ":[0-9]{1,5}/".toRegex()
-
- fun createCloneIntent(context: Context): Intent {
- return Intent(context, GitServerConfigActivity::class.java).apply {
- putExtra("cloning", true)
- }
- }
- }
-}
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/FolderPicker.kt b/app/src/main/java/app/passwordstore/ui/list/FolderPicker.kt
new file mode 100644
index 00000000..bd5b66f3
--- /dev/null
+++ b/app/src/main/java/app/passwordstore/ui/list/FolderPicker.kt
@@ -0,0 +1,130 @@
+/*
+ * Copyright (C) 2026 Danilo M. <danix@danix.xyz>
+ * SPDX-License-Identifier: GPL-3.0-only
+ */
+
+package app.passwordstore.ui.list
+
+import android.widget.Toast
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.heightIn
+import androidx.compose.foundation.layout.padding
+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.getValue
+import androidx.compose.runtime.key
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.produceState
+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.unit.dp
+import app.passwordstore.R
+import app.passwordstore.ui.compose.BreadcrumbBar
+import app.passwordstore.ui.compose.FolderRow
+import app.passwordstore.ui.compose.crumbsFor
+import app.passwordstore.ui.compose.theme.SpacingMedium
+import app.passwordstore.util.extensions.sharedPrefs
+import app.passwordstore.util.settings.PreferenceKeys
+import java.io.File
+import kotlinx.collections.immutable.ImmutableList
+import kotlinx.collections.immutable.persistentListOf
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.withContext
+
+/**
+ * Picks a folder of the store at [root], starting at [start] (relative to the root, "" is the
+ * root): breadcrumbs jump up, rows open subfolders, "New folder" creates one inside the folder
+ * shown (the list's validation) and opens it, Select reports the folder shown, relative to the
+ * root. Used by the list's move action and by the editor. Shows no secrets; its window inherits
+ * FLAG_SECURE from the screen below.
+ */
+@Composable
+fun FolderPicker(
+ root: File,
+ start: String,
+ onPick: (relativePath: String) -> Unit,
+ onDismiss: () -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ val context = LocalContext.current
+ var path by rememberSaveable { mutableStateOf(start) }
+ var naming by rememberSaveable { mutableStateOf(false) }
+ val showHidden = remember {
+ context.sharedPrefs.getBoolean(PreferenceKeys.SHOW_HIDDEN_CONTENTS, false)
+ }
+ val rows by
+ produceState<ImmutableList<ListRow>>(persistentListOf(), path, naming) {
+ val dir = File(root, path)
+ // ponytail: a composable has no injected DispatcherProvider; plain IO for a directory read.
+ @Suppress("RawDispatchersUse")
+ value = withContext(Dispatchers.IO) { listRows(subfolders(dir, showHidden), dir).folders }
+ }
+ if (naming) {
+ // The list's own dialog and checks: blank, already a file or folder, outside the store
+ // (component-wise isInsideRepository).
+ val dir = File(root, path)
+ FolderNameDialog(
+ title = stringResource(R.string.title_create_folder),
+ message = null,
+ confirm = stringResource(R.string.button_create),
+ validate = { folderNameError(it, File(dir, it)) },
+ onConfirm = { name ->
+ val folder = File(dir, name).canonicalFile
+ naming = false
+ if (folder.mkdirs() || folder.isDirectory) {
+ path = folder.relativeTo(root.canonicalFile).path
+ } else {
+ Toast.makeText(context, R.string.folder_creation_err_failed, Toast.LENGTH_LONG).show()
+ }
+ },
+ onDismiss = { naming = false },
+ )
+ }
+ AlertDialog(
+ onDismissRequest = onDismiss,
+ modifier = modifier,
+ title = { Text(stringResource(R.string.folder_picker_title)) },
+ text = {
+ Column {
+ BreadcrumbBar(
+ crumbsFor(path),
+ stringResource(R.string.list_store_root),
+ onCrumbClick = { path = it },
+ modifier = Modifier.fillMaxWidth().padding(bottom = SpacingMedium),
+ )
+ // ponytail: a plain scrolling column, fine for the folders of one level; a LazyColumn
+ // if a store ever has thousands of subfolders in one folder.
+ Column(Modifier.heightIn(max = 360.dp).verticalScroll(rememberScrollState())) {
+ rows.forEach { row ->
+ key(row.path) {
+ FolderRow(
+ name = row.label,
+ count = row.count,
+ onClick = { path = File(row.path).relativeTo(root).path },
+ )
+ }
+ }
+ }
+ }
+ },
+ confirmButton = {
+ TextButton(onClick = { onPick(path) }) { Text(stringResource(R.string.crypto_select)) }
+ },
+ dismissButton = {
+ Row {
+ TextButton(onClick = { naming = true }) { Text(stringResource(R.string.folder_picker_new)) }
+ TextButton(onClick = onDismiss) { Text(stringResource(R.string.dialog_cancel)) }
+ }
+ },
+ )
+}
diff --git a/app/src/main/java/app/passwordstore/ui/list/ListDestination.kt b/app/src/main/java/app/passwordstore/ui/list/ListDestination.kt
new file mode 100644
index 00000000..a9228484
--- /dev/null
+++ b/app/src/main/java/app/passwordstore/ui/list/ListDestination.kt
@@ -0,0 +1,368 @@
+/*
+ * Copyright (C) 2026 Danilo M. <danix@danix.xyz>
+ * SPDX-License-Identifier: GPL-3.0-only
+ */
+
+package app.passwordstore.ui.list
+
+import android.widget.Toast
+import androidx.activity.compose.BackHandler
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.padding
+import androidx.compose.material3.AlertDialog
+import androidx.compose.material3.OutlinedTextField
+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.mutableIntStateOf
+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.focus.FocusRequester
+import androidx.compose.ui.focus.focusRequester
+import androidx.compose.ui.res.pluralStringResource
+import androidx.compose.ui.res.stringResource
+import androidx.hilt.navigation.compose.hiltViewModel
+import androidx.lifecycle.compose.LifecycleResumeEffect
+import androidx.lifecycle.compose.collectAsStateWithLifecycle
+import androidx.lifecycle.lifecycleScope
+import app.passwordstore.R
+import app.passwordstore.data.repo.PasswordRepository
+import app.passwordstore.ui.compose.theme.SpacingMedium
+import app.passwordstore.ui.git.base.BaseGitActivity.GitOp
+import app.passwordstore.ui.main.MainActivity
+import app.passwordstore.util.extensions.isInsideRepository
+import app.passwordstore.util.extensions.sharedPrefs
+import app.passwordstore.util.settings.AuthMode
+import app.passwordstore.util.settings.PreferenceKeys
+import app.passwordstore.util.viewmodel.SearchableRepositoryViewModel
+import java.io.File
+import kotlinx.collections.immutable.toImmutableSet
+import kotlinx.coroutines.launch
+
+private enum class ListDialog {
+ None,
+ Create,
+ NewFolder,
+ Rename,
+ Delete,
+ Move,
+ Clone,
+ NotInitialized,
+}
+
+/**
+ * The list at [path] (relative to the store root): wires [ListScreen] to the [viewModel], the
+ * folder picker 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, [onSettings] the settings; [onNoStore] runs when the store is gone.
+ */
+@Composable
+fun ListDestination(
+ activity: MainActivity,
+ path: String,
+ onOpenFolder: (relativePath: String) -> Unit,
+ onOpenEntry: (File) -> Unit,
+ onNewEntry: () -> Unit,
+ onClone: () -> Unit,
+ onSettings: () -> Unit,
+ onNoStore: () -> Unit,
+ modifier: Modifier = Modifier,
+ viewModel: SearchableRepositoryViewModel = hiltViewModel(),
+) {
+ val root = PasswordRepository.getRepositoryDirectory()
+ val dir = File(root, path)
+ val prefs = activity.sharedPrefs
+ var query by rememberSaveable { mutableStateOf("") }
+ var selected by rememberSaveable { mutableStateOf(listOf<String>()) }
+ var dialog by rememberSaveable { mutableStateOf(ListDialog.None) }
+ var deleteCount by rememberSaveable { mutableIntStateOf(0) }
+ var refreshing by remember { mutableStateOf(false) }
+ var resumes by remember { mutableIntStateOf(0) }
+
+ // Declared before the collection below on purpose: effects start in order, so the ViewModel
+ // already points at this folder when the first rows are built (no flash of the store root).
+ LaunchedEffect(path, query) {
+ viewModel.navigateTo(dir)
+ if (query.isNotBlank()) viewModel.search(query.trim())
+ }
+ val rows by viewModel.rows.collectAsStateWithLifecycle(ListRows())
+ val gitOps = remember(resumes) { activity.gitOps() }
+ val canSync = remember(resumes) { !prefs.getBoolean(PreferenceKeys.DISABLE_SYNC_ACTION, false) }
+
+ fun refresh() {
+ if (path.isNotEmpty() && !dir.isDirectory) onOpenFolder("") else viewModel.forceRefresh()
+ }
+
+ // Every return to this folder (back from a child, or from another activity) re-reads it.
+ LifecycleResumeEffect(Unit) {
+ if (activity.checkStore()) refresh() else onNoStore()
+ resumes++
+ onPauseOrDispose {}
+ }
+ BackHandler(enabled = selected.isNotEmpty()) { selected = emptyList() }
+ BackHandler(enabled = selected.isEmpty() && query.isNotEmpty()) { query = "" }
+
+ fun ifInitialized(action: () -> Unit) {
+ if (PasswordRepository.isInitialized) action() else dialog = ListDialog.NotInitialized
+ }
+
+ fun runGit(op: GitOp) {
+ refreshing = true
+ activity.lifecycleScope.launch {
+ activity.runGit(op)
+ refreshing = false
+ refresh()
+ }
+ }
+
+ Box(modifier) {
+ ListScreen(
+ path = path,
+ query = query,
+ rows = rows,
+ selected = selected.toImmutableSet(),
+ refreshing = refreshing,
+ canSync = canSync,
+ gitOps = gitOps,
+ onQueryChange = { query = it },
+ onCrumb = onOpenFolder,
+ onOpenFolder = { folder ->
+ activity.recordUse(File(folder))
+ query = ""
+ onOpenFolder(File(folder).relativeTo(root).path)
+ },
+ onOpenEntry = { entry ->
+ activity.recordUse(File(entry))
+ onOpenEntry(File(entry))
+ },
+ onToggle = { p -> selected = if (p in selected) selected - p else selected + p },
+ onClearSelection = { selected = emptyList() },
+ onSync = {
+ when {
+ root.resolve(".git").listFiles().isNullOrEmpty() -> viewModel.forceRefresh()
+ !PasswordRepository.isGitRepo() -> dialog = ListDialog.Clone
+ activity.gitSettings.authMode == AuthMode.None -> runGit(GitOp.PULL)
+ else -> runGit(GitOp.SYNC)
+ }
+ },
+ onGitOp = { op -> ifInitialized { runGit(op) } },
+ onSettings = onSettings,
+ onNew = { dialog = ListDialog.Create },
+ onMove = { dialog = ListDialog.Move },
+ onDelete = {
+ val files = selected.map(::File)
+ val count = countFiles(files)
+ if (count == 0) {
+ files.forEach { it.deleteWithoutFollowingLinks() }
+ selected = emptyList()
+ refresh()
+ } else {
+ deleteCount = count
+ dialog = ListDialog.Delete
+ }
+ },
+ onRename = { dialog = ListDialog.Rename },
+ onPin = {
+ activity.pinEntry(File(selected.single()))
+ selected = emptyList()
+ },
+ focusSearch = path.isEmpty() && prefs.getBoolean(PreferenceKeys.SEARCH_ON_START, false),
+ )
+
+ when (dialog) {
+ ListDialog.None -> Unit
+ ListDialog.Create ->
+ CreateSheet(
+ onDismiss = { dialog = ListDialog.None },
+ onNewEntry = {
+ dialog = ListDialog.None
+ ifInitialized(onNewEntry)
+ },
+ onNewFolder = {
+ dialog = ListDialog.None
+ ifInitialized { dialog = ListDialog.NewFolder }
+ },
+ )
+ ListDialog.NewFolder ->
+ FolderNameDialog(
+ title = stringResource(R.string.title_create_folder),
+ message = null,
+ confirm = stringResource(R.string.button_create),
+ validate = { folderNameError(it, File(dir, it)) },
+ onConfirm = { name ->
+ dialog = ListDialog.None
+ val folder = File(dir, name).canonicalFile
+ folder.mkdirs()
+ onOpenFolder(folder.relativeTo(root.canonicalFile).path)
+ },
+ onDismiss = { dialog = ListDialog.None },
+ )
+ ListDialog.Rename -> {
+ val folder = File(selected.single())
+ FolderNameDialog(
+ title = stringResource(R.string.title_rename_folder),
+ message = stringResource(R.string.message_rename_folder, folder.name),
+ confirm = stringResource(R.string.dialog_ok),
+ validate = { folderNameError(it, File(folder.parentFile, it)) },
+ onConfirm = { name ->
+ dialog = ListDialog.None
+ selected = emptyList()
+ activity.lifecycleScope.launch {
+ activity.renameFolder(folder, File(folder.parentFile, name))
+ refresh()
+ }
+ },
+ onDismiss = { dialog = ListDialog.None },
+ )
+ }
+ ListDialog.Delete ->
+ Prompt(
+ text = pluralStringResource(R.plurals.delete_dialog_text, deleteCount, deleteCount),
+ confirm = stringResource(R.string.dialog_yes),
+ onConfirm = {
+ val files = selected.map(::File)
+ dialog = ListDialog.None
+ selected = emptyList()
+ activity.lifecycleScope.launch {
+ activity.deleteItems(files)
+ refresh()
+ }
+ },
+ onDismiss = { dialog = ListDialog.None },
+ dismiss = stringResource(R.string.dialog_no),
+ )
+ ListDialog.Move ->
+ FolderPicker(
+ root = root,
+ start = path,
+ onPick = { target ->
+ // One move per pick: a double tap on Select must not move twice.
+ val destination = File(root, target)
+ val files = selected.map(::File)
+ val canonical = destination.canonicalFile
+ if (
+ !destination.isInsideRepository() ||
+ files.any { it.isDirectory && canonical.startsWith(it.canonicalFile) }
+ ) {
+ Toast.makeText(
+ activity,
+ R.string.message_error_destination_outside_repo,
+ Toast.LENGTH_LONG,
+ )
+ .show()
+ } else if (dialog == ListDialog.Move) {
+ dialog = ListDialog.None
+ selected = emptyList()
+ activity.lifecycleScope.launch {
+ activity.moveItems(files, destination)
+ refresh()
+ }
+ }
+ },
+ onDismiss = { dialog = ListDialog.None },
+ )
+ ListDialog.Clone ->
+ Prompt(
+ text = stringResource(R.string.clone_git_repo),
+ confirm = stringResource(R.string.clone_button),
+ onConfirm = {
+ dialog = ListDialog.None
+ onClone()
+ },
+ onDismiss = { dialog = ListDialog.None },
+ dismiss = stringResource(R.string.dialog_cancel),
+ )
+ ListDialog.NotInitialized ->
+ Prompt(
+ text = stringResource(R.string.creation_dialog_text),
+ confirm = stringResource(R.string.dialog_ok),
+ onConfirm = { dialog = ListDialog.None },
+ onDismiss = { dialog = ListDialog.None },
+ )
+ }
+ }
+}
+
+/**
+ * Asks for a folder name; [validate] returns an error string resource or null. Shared with the
+ * folder picker's "New folder".
+ */
+@Composable
+internal fun FolderNameDialog(
+ title: String,
+ message: String?,
+ confirm: String,
+ validate: (String) -> Int?,
+ onConfirm: (String) -> Unit,
+ onDismiss: () -> Unit,
+) {
+ var name by rememberSaveable { mutableStateOf("") }
+ var error by remember { mutableStateOf<Int?>(null) }
+ val focus = remember { FocusRequester() }
+ AlertDialog(
+ onDismissRequest = onDismiss,
+ title = { Text(title) },
+ text = {
+ val err = error
+ Column {
+ if (message != null) Text(message, Modifier.padding(bottom = SpacingMedium))
+ OutlinedTextField(
+ value = name,
+ onValueChange = {
+ name = it
+ error = null
+ },
+ modifier = Modifier.focusRequester(focus),
+ label = { Text(stringResource(R.string.crypto_name_hint)) },
+ supportingText =
+ if (err != null) {
+ { Text(stringResource(err)) }
+ } else null,
+ isError = err != null,
+ singleLine = true,
+ )
+ }
+ LaunchedEffect(Unit) { focus.requestFocus() }
+ },
+ confirmButton = {
+ TextButton(
+ onClick = {
+ val e = validate(name)
+ error = e
+ if (e == null) onConfirm(name)
+ }
+ ) {
+ Text(confirm)
+ }
+ },
+ dismissButton = {
+ TextButton(onClick = onDismiss) { Text(stringResource(R.string.dialog_cancel)) }
+ },
+ )
+}
+
+/** A one-message dialog; [dismiss] adds a second button that only closes it. */
+@Composable
+private fun Prompt(
+ text: String,
+ confirm: String,
+ onConfirm: () -> Unit,
+ onDismiss: () -> Unit,
+ dismiss: String? = null,
+) {
+ AlertDialog(
+ onDismissRequest = onDismiss,
+ text = { Text(text) },
+ confirmButton = { TextButton(onClick = onConfirm) { Text(confirm) } },
+ dismissButton =
+ if (dismiss != null) {
+ { TextButton(onClick = onDismiss) { Text(dismiss) } }
+ } else null,
+ )
+}
diff --git a/app/src/main/java/app/passwordstore/ui/list/ListModel.kt b/app/src/main/java/app/passwordstore/ui/list/ListModel.kt
new file mode 100644
index 00000000..96b7ab4b
--- /dev/null
+++ b/app/src/main/java/app/passwordstore/ui/list/ListModel.kt
@@ -0,0 +1,71 @@
+/*
+ * Copyright (C) 2026 Danilo M. <danix@danix.xyz>
+ * SPDX-License-Identifier: GPL-3.0-only
+ */
+
+package app.passwordstore.ui.list
+
+import app.passwordstore.ui.compose.crumbsFor
+import java.io.File
+import kotlinx.collections.immutable.ImmutableList
+import kotlinx.collections.immutable.persistentListOf
+import kotlinx.collections.immutable.toImmutableList
+
+/**
+ * One list row. [path] is the absolute path and the row's key; [count] is a folder's number of
+ * children, 0 for entries.
+ */
+data class ListRow(val path: String, val label: String, val subtitle: String?, val count: Int)
+
+/** The rows of one folder, or of a search inside it: folders above entries. */
+data class ListRows(
+ val folders: ImmutableList<ListRow> = persistentListOf(),
+ val entries: ImmutableList<ListRow> = persistentListOf(),
+)
+
+/**
+ * Splits [files] (already sorted or ranked) into folder and entry rows for [dir], keeping their
+ * order. Labels and subtitles are relative to [dir]: browsing shows plain names, search results
+ * show where they are. Entries get no username or OTP subtitle: both live inside the encrypted
+ * file, and the list never decrypts.
+ */
+fun listRows(files: List<File>, dir: File): ListRows {
+ val (folders, entries) = files.partition { it.isDirectory }
+ return ListRows(
+ folders
+ .map { ListRow(it.absolutePath, it.relativeTo(dir).path, null, childCount(it)) }
+ .toImmutableList(),
+ entries
+ .map {
+ val parent = it.parentFile?.relativeTo(dir)?.path.orEmpty()
+ ListRow(it.absolutePath, it.name.removeSuffix(".gpg"), parent.ifEmpty { null }, 0)
+ }
+ .toImmutableList(),
+ )
+}
+
+/** Children counted for a folder: subfolders and .gpg files, as the old list counted them. */
+private fun childCount(folder: File): Int =
+ folder.listFiles { f -> f.isDirectory || f.extension == "gpg" }?.size ?: 0
+
+/**
+ * Folders directly inside [dir] that the folder picker offers, by name: never `.git`, other hidden
+ * folders only with "Show hidden contents" ([showHidden]), as the list filters them.
+ */
+fun subfolders(dir: File, showHidden: Boolean): List<File> =
+ dir
+ .listFiles { f -> f.isDirectory && !f.name.startsWith(".git") && (showHidden || !f.isHidden) }
+ .orEmpty()
+ .sortedBy { it.name.lowercase() }
+
+/**
+ * How to get from the list at [from] to the list at [to] (both relative to the store root) while
+ * the back stack holds one entry per folder level: how many entries to pop, then which paths to
+ * push. Breadcrumb jumps only pop; opening a folder from search results may pop and push.
+ */
+fun folderMoves(from: String, to: String): Pair<Int, List<String>> {
+ val current = crumbsFor(from)
+ val target = crumbsFor(to)
+ val shared = current.zip(target).takeWhile { (a, b) -> a == b }.size
+ return (current.size - shared) to target.drop(shared).map { it.path }
+}
diff --git a/app/src/main/java/app/passwordstore/ui/list/ListScreen.kt b/app/src/main/java/app/passwordstore/ui/list/ListScreen.kt
new file mode 100644
index 00000000..720cfc0a
--- /dev/null
+++ b/app/src/main/java/app/passwordstore/ui/list/ListScreen.kt
@@ -0,0 +1,340 @@
+/*
+ * Copyright (C) 2026 Danilo M. <danix@danix.xyz>
+ * SPDX-License-Identifier: GPL-3.0-only
+ */
+
+package app.passwordstore.ui.list
+
+import androidx.annotation.DrawableRes
+import androidx.annotation.StringRes
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.PaddingValues
+import androidx.compose.foundation.layout.Row
+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.rememberLazyListState
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Add
+import androidx.compose.material.icons.filled.Close
+import androidx.compose.material.icons.filled.MoreVert
+import androidx.compose.material3.DropdownMenu
+import androidx.compose.material3.DropdownMenuItem
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.ExtendedFloatingActionButton
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.ListItem
+import androidx.compose.material3.ModalBottomSheet
+import androidx.compose.material3.Scaffold
+import androidx.compose.material3.SearchBar
+import androidx.compose.material3.SearchBarDefaults
+import androidx.compose.material3.Text
+import androidx.compose.material3.TopAppBar
+import androidx.compose.material3.pulltorefresh.PullToRefreshDefaults
+import androidx.compose.material3.pulltorefresh.pullToRefresh
+import androidx.compose.material3.pulltorefresh.rememberPullToRefreshState
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.key
+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.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.focus.FocusRequester
+import androidx.compose.ui.focus.focusRequester
+import androidx.compose.ui.platform.LocalFocusManager
+import androidx.compose.ui.platform.LocalWindowInfo
+import androidx.compose.ui.res.painterResource
+import androidx.compose.ui.res.pluralStringResource
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.unit.dp
+import androidx.lifecycle.compose.LifecycleResumeEffect
+import app.passwordstore.R
+import app.passwordstore.ui.compose.BreadcrumbBar
+import app.passwordstore.ui.compose.EntryRow
+import app.passwordstore.ui.compose.FolderRow
+import app.passwordstore.ui.compose.GroupedSurface
+import app.passwordstore.ui.compose.crumbsFor
+import app.passwordstore.ui.compose.theme.SpacingMedium
+import app.passwordstore.ui.git.base.BaseGitActivity.GitOp
+import kotlinx.collections.immutable.ImmutableList
+import kotlinx.collections.immutable.ImmutableSet
+
+/**
+ * The password list: search bar, breadcrumbs, folders above entries, pull to sync, long-press
+ * selection and the "New entry" button. Row callbacks take absolute paths; [onCrumb] takes a path
+ * relative to the store root, like [path].
+ */
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun ListScreen(
+ path: String,
+ query: String,
+ rows: ListRows,
+ selected: ImmutableSet<String>,
+ refreshing: Boolean,
+ canSync: Boolean,
+ gitOps: ImmutableList<GitOp>,
+ onQueryChange: (String) -> Unit,
+ onCrumb: (relativePath: String) -> Unit,
+ onOpenFolder: (path: String) -> Unit,
+ onOpenEntry: (path: String) -> Unit,
+ onToggle: (path: String) -> Unit,
+ onClearSelection: () -> Unit,
+ onSync: () -> Unit,
+ onGitOp: (GitOp) -> Unit,
+ onSettings: () -> Unit,
+ onNew: () -> Unit,
+ onMove: () -> Unit,
+ onDelete: () -> Unit,
+ onRename: () -> Unit,
+ onPin: () -> Unit,
+ modifier: Modifier = Modifier,
+ focusSearch: Boolean = false,
+) {
+ val selecting = selected.isNotEmpty()
+ val listState = rememberLazyListState()
+ val pullState = rememberPullToRefreshState()
+ val searchFocus = remember { FocusRequester() }
+ val focusManager = LocalFocusManager.current
+ // Saveable, so it survives this destination leaving composition while a folder is open.
+ var launched by rememberSaveable { mutableStateOf(false) }
+ var returned by remember { mutableStateOf(false) }
+ LifecycleResumeEffect(Unit) {
+ returned = true
+ onPauseOrDispose {}
+ }
+ // Out of touch mode (keys, D-pad) the view system hands its default focus to the first
+ // focusable, the search field, which keeps it in touch mode too and brings the keyboard back
+ // when the list returns from a screen that had it up. So every return drops focus, except the
+ // first, where search on start asks for it. Done once the window has focus: before that the
+ // view still holds the touch mode it last saw, and out of it clearing focus refocuses the field.
+ val windowFocused = LocalWindowInfo.current.isWindowFocused
+ LaunchedEffect(returned, windowFocused) {
+ if (returned && windowFocused) {
+ returned = false
+ if (focusSearch && !launched) searchFocus.requestFocus() else focusManager.clearFocus()
+ launched = true
+ }
+ }
+ // Filtered results always start at the top, where the best match is.
+ LaunchedEffect(query) { if (query.isNotEmpty()) listState.scrollToItem(0) }
+ Scaffold(
+ modifier = modifier,
+ topBar = {
+ if (selecting) {
+ SelectionBar(rows, selected, onClearSelection, onMove, onDelete, onRename, onPin)
+ } else {
+ Column(horizontalAlignment = Alignment.CenterHorizontally) {
+ SearchField(query, gitOps, onQueryChange, onGitOp, onSettings, searchFocus)
+ BreadcrumbBar(
+ crumbsFor(path),
+ stringResource(R.string.list_store_root),
+ onCrumb,
+ Modifier.fillMaxWidth().padding(vertical = SpacingMedium),
+ )
+ }
+ }
+ },
+ floatingActionButton = {
+ if (!selecting)
+ // M3 clears the label's semantics; the FAB takes its description from the icon.
+ ExtendedFloatingActionButton(
+ text = { Text(stringResource(R.string.list_new_entry)) },
+ icon = { Icon(Icons.Filled.Add, stringResource(R.string.list_new_entry)) },
+ onClick = onNew,
+ )
+ },
+ ) { padding ->
+ Box(
+ Modifier.padding(padding)
+ .fillMaxSize()
+ .pullToRefresh(refreshing, pullState, enabled = canSync && !selecting, onRefresh = onSync)
+ ) {
+ // ponytail: each group is one lazy item, fine for hundreds of rows; split rows into their
+ // own items (with per-row corner shapes) if a store ever has thousands in one folder.
+ LazyColumn(
+ state = listState,
+ modifier = Modifier.fillMaxSize(),
+ contentPadding = PaddingValues(bottom = 96.dp),
+ ) {
+ if (rows.folders.isNotEmpty())
+ item(key = "folders") {
+ GroupedSurface(Modifier.fillMaxWidth()) {
+ rows.folders.forEach { row ->
+ key(row.path) {
+ FolderRow(
+ name = row.label,
+ count = row.count,
+ onClick = { if (selecting) onToggle(row.path) else onOpenFolder(row.path) },
+ selected = row.path in selected,
+ onLongClick = { onToggle(row.path) },
+ )
+ }
+ }
+ }
+ }
+ if (rows.entries.isNotEmpty())
+ item(key = "entries") {
+ GroupedSurface(Modifier.fillMaxWidth()) {
+ rows.entries.forEach { row ->
+ key(row.path) {
+ EntryRow(
+ name = row.label,
+ subtitle = row.subtitle,
+ onClick = { if (selecting) onToggle(row.path) else onOpenEntry(row.path) },
+ selected = row.path in selected,
+ onLongClick = { onToggle(row.path) },
+ )
+ }
+ }
+ }
+ }
+ }
+ PullToRefreshDefaults.Indicator(pullState, refreshing, Modifier.align(Alignment.TopCenter))
+ }
+ }
+}
+
+/** Bottom sheet behind the "New entry" button: a password entry or a folder. */
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun CreateSheet(
+ onDismiss: () -> Unit,
+ onNewEntry: () -> Unit,
+ onNewFolder: () -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ ModalBottomSheet(onDismissRequest = onDismiss, modifier = modifier) {
+ ListItem(
+ headlineContent = { Text(stringResource(R.string.bottom_sheet_create_new_password)) },
+ modifier = Modifier.clickable(onClick = onNewEntry),
+ leadingContent = {
+ Icon(painterResource(R.drawable.ic_action_new_password), contentDescription = null)
+ },
+ )
+ ListItem(
+ headlineContent = { Text(stringResource(R.string.bottom_sheet_create_new_folder)) },
+ modifier = Modifier.clickable(onClick = onNewFolder),
+ leadingContent = {
+ Icon(painterResource(R.drawable.ic_action_new_folder), contentDescription = null)
+ },
+ )
+ }
+}
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+private fun SearchField(
+ query: String,
+ gitOps: ImmutableList<GitOp>,
+ onQueryChange: (String) -> Unit,
+ onGitOp: (GitOp) -> Unit,
+ onSettings: () -> Unit,
+ focus: FocusRequester,
+) {
+ val focusManager = LocalFocusManager.current
+ var menu by remember { mutableStateOf(false) }
+ SearchBar(
+ inputField = {
+ SearchBarDefaults.InputField(
+ query = query,
+ onQueryChange = onQueryChange,
+ onSearch = { focusManager.clearFocus() },
+ expanded = false,
+ onExpandedChange = {},
+ modifier = Modifier.focusRequester(focus),
+ placeholder = { Text(stringResource(R.string.action_search)) },
+ leadingIcon = {
+ Icon(painterResource(R.drawable.ic_search_24dp), contentDescription = null)
+ },
+ trailingIcon = {
+ Row {
+ if (query.isNotEmpty())
+ IconButton(onClick = { onQueryChange("") }) {
+ Icon(Icons.Filled.Close, stringResource(R.string.list_clear_search))
+ }
+ Box {
+ IconButton(onClick = { menu = true }) {
+ Icon(Icons.Filled.MoreVert, stringResource(R.string.list_more_options))
+ }
+ DropdownMenu(expanded = menu, onDismissRequest = { menu = false }) {
+ gitOps.forEach { op ->
+ DropdownMenuItem(
+ text = { Text(stringResource(op.label)) },
+ onClick = {
+ menu = false
+ onGitOp(op)
+ },
+ )
+ }
+ DropdownMenuItem(
+ text = { Text(stringResource(R.string.action_settings)) },
+ onClick = {
+ menu = false
+ onSettings()
+ },
+ )
+ }
+ }
+ }
+ },
+ )
+ },
+ expanded = false,
+ onExpandedChange = {},
+ ) {}
+}
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+private fun SelectionBar(
+ rows: ListRows,
+ selected: ImmutableSet<String>,
+ onClear: () -> Unit,
+ onMove: () -> Unit,
+ onDelete: () -> Unit,
+ onRename: () -> Unit,
+ onPin: () -> Unit,
+) {
+ val single = selected.singleOrNull()
+ TopAppBar(
+ title = { Text(pluralStringResource(R.plurals.delete_title, selected.size, selected.size)) },
+ navigationIcon = {
+ IconButton(onClick = onClear) {
+ Icon(Icons.Filled.Close, stringResource(R.string.dialog_cancel))
+ }
+ },
+ actions = {
+ ActionIcon(R.drawable.ic_move_folder_24dp, R.string.move, onMove)
+ ActionIcon(R.drawable.ic_delete_24dp, R.string.delete, onDelete)
+ // ponytail: rename one folder at a time; the old list queued one dialog per folder.
+ if (rows.folders.any { it.path == single })
+ ActionIcon(R.drawable.ic_edit_24dp, R.string.title_rename_folder, onRename)
+ if (rows.entries.any { it.path == single })
+ ActionIcon(R.drawable.ic_push_pin_24dp, R.string.place_shortcut_on_home_screen, onPin)
+ },
+ )
+}
+
+@Composable
+private fun ActionIcon(@DrawableRes icon: Int, @StringRes label: Int, onClick: () -> Unit) {
+ IconButton(onClick = onClick) {
+ Icon(painterResource(icon), contentDescription = stringResource(label))
+ }
+}
+
+private val GitOp.label: Int
+ get() =
+ when (this) {
+ GitOp.SYNC -> R.string.git_sync
+ GitOp.PUSH -> R.string.git_push
+ else -> R.string.git_pull
+ }
diff --git a/app/src/main/java/app/passwordstore/ui/list/StoreActions.kt b/app/src/main/java/app/passwordstore/ui/list/StoreActions.kt
new file mode 100644
index 00000000..3bdede73
--- /dev/null
+++ b/app/src/main/java/app/passwordstore/ui/list/StoreActions.kt
@@ -0,0 +1,179 @@
+/*
+ * Copyright (C) 2026 Danilo M. <danix@danix.xyz>
+ * SPDX-License-Identifier: GPL-3.0-only
+ */
+
+package app.passwordstore.ui.list
+
+import android.content.Context
+import androidx.core.content.edit
+import app.passwordstore.R
+import app.passwordstore.data.repo.PasswordRepository
+import app.passwordstore.ui.crypto.getLongName
+import app.passwordstore.ui.git.base.BaseGitActivity
+import app.passwordstore.util.autofill.AutofillMatcher
+import app.passwordstore.util.extensions.base64
+import app.passwordstore.util.extensions.commitChange
+import app.passwordstore.util.extensions.getString
+import app.passwordstore.util.extensions.isInsideRepository
+import app.passwordstore.util.extensions.listFilesRecursively
+import app.passwordstore.util.extensions.sharedPrefs
+import app.passwordstore.util.settings.PasswordSortOrder
+import app.passwordstore.util.settings.PreferenceKeys
+import com.google.android.material.dialog.MaterialAlertDialogBuilder
+import java.io.File
+import java.nio.file.Files
+import kotlin.coroutines.resume
+import kotlinx.coroutines.suspendCancellableCoroutine
+import kotlinx.coroutines.withContext
+import logcat.LogPriority.ERROR
+import logcat.logcat
+
+/** Shared preferences file of the "recently used" sort order, keyed by base64 of the path. */
+private const val HISTORY = "recent_password_history"
+
+/** Password files in [files], counting a folder by everything below it. */
+fun countFiles(files: List<File>): Int =
+ files.sumOf { if (it.isFile) 1 else it.listFilesRecursively().size }
+
+/** Why [name] cannot become the folder [target], as a string resource, or null when it can. */
+fun folderNameError(name: String, target: File): Int? =
+ when {
+ name.isBlank() -> R.string.message_category_error_empty_field
+ target.isFile -> R.string.folder_creation_err_file_exists
+ target.isDirectory -> R.string.folder_creation_err_folder_exists
+ !target.isInsideRepository() -> R.string.message_error_destination_outside_repo
+ else -> null
+ }
+
+/** Records that [file] was opened, for the "recently used" sort order. */
+fun Context.recordUse(file: File) {
+ if (sharedPrefs.getString(PreferenceKeys.SORT_ORDER) != PasswordSortOrder.RECENTLY_USED.name)
+ return
+ getSharedPreferences(HISTORY, Context.MODE_PRIVATE).edit {
+ putString(file.absolutePath.base64(), System.currentTimeMillis().toString())
+ }
+}
+
+/** Deletes this file or folder, never following symlinks; true when everything is gone. */
+internal fun File.deleteWithoutFollowingLinks(): Boolean {
+ if (isDirectory && !Files.isSymbolicLink(toPath())) {
+ // Evaluate every child, no short-circuit, so one failure leaves the rest deleted.
+ val children = listFiles().orEmpty().map { it.deleteWithoutFollowingLinks() }
+ if (!children.all { it }) return false
+ }
+ return delete()
+}
+
+/** Deletes [files] (entries or whole folders), drops their autofill matches and commits. */
+suspend fun BaseGitActivity.deleteItems(files: List<File>) {
+ val root = PasswordRepository.getRepositoryDirectory()
+ withContext(dispatcherProvider.io()) {
+ val deleted = files.flatMap { if (it.isDirectory) it.listFilesRecursively() else listOf(it) }
+ files.forEach { it.deleteWithoutFollowingLinks() }
+ AutofillMatcher.updateMatches(applicationContext, delete = deleted)
+ }
+ commitChange(
+ getString(
+ R.string.git_commit_remove_text,
+ files.joinToString(", ") { it.toRelativeString(root) },
+ )
+ )
+}
+
+/** Moves [files] into the folder [target], asking before overwriting, then commits. */
+suspend fun BaseGitActivity.moveItems(files: List<File>, target: File) {
+ val repo = PasswordRepository.getRepositoryDirectory().absolutePath
+ for (source in files) {
+ if (!source.exists()) {
+ logcat(ERROR) { "Tried moving something that appears non-existent." }
+ continue
+ }
+ val destination = File(target, source.name)
+ val name = source.nameWithoutExtension
+ if (
+ destination.exists() &&
+ !confirm(
+ getString(R.string.password_exists_title),
+ getString(
+ R.string.password_exists_message,
+ getLongName(target.absolutePath, repo, name),
+ getLongName(source.parent.orEmpty(), repo, name),
+ ),
+ )
+ )
+ continue
+ moveFile(source, destination)
+ }
+ val message =
+ if (files.size == 1) {
+ val name = files[0].nameWithoutExtension
+ getString(
+ R.string.git_commit_move_text,
+ getLongName(files[0].parent.orEmpty(), repo, name),
+ getLongName(target.absolutePath, repo, name),
+ )
+ } else {
+ val relative = "${target.absolutePath}/".replace(repo, "").replace("/+".toRegex(), "/")
+ getString(R.string.git_commit_move_multiple_text, relative)
+ }
+ commitChange(message)
+}
+
+/** Renames the folder [from] to [to], keeps its recently-used time, then commits. */
+suspend fun BaseGitActivity.renameFolder(from: File, to: File) {
+ if (!moveFile(from, to)) return
+ moveHistory(from, to)
+ commitChange(getString(R.string.git_commit_move_text, from.name, to.name))
+}
+
+/** Carries the "recently used" time of [from] over to [to]. */
+fun Context.moveHistory(from: File, to: File) {
+ val history = getSharedPreferences(HISTORY, Context.MODE_PRIVATE)
+ val time = history.getString(from.absolutePath.base64()) ?: return
+ history.edit {
+ remove(from.absolutePath.base64())
+ putString(to.absolutePath.base64(), time)
+ }
+}
+
+private suspend fun BaseGitActivity.moveFile(source: File, destination: File): Boolean {
+ val moved =
+ withContext(dispatcherProvider.io()) {
+ val map =
+ if (source.isDirectory) {
+ destination.mkdirs()
+ source.listFilesRecursively().associateWith { destination.resolve(it.relativeTo(source)) }
+ } else {
+ mapOf(source to destination)
+ }
+ source.renameTo(destination).also { ok ->
+ if (ok) AutofillMatcher.updateMatches(this@moveFile, map)
+ }
+ }
+ if (!moved) {
+ logcat(ERROR) { "Something went wrong while moving $source to $destination." }
+ // ponytail: View dialog like BaseGitActivity's git prompts; both move to Compose together.
+ MaterialAlertDialogBuilder(this)
+ .setTitle(R.string.password_move_error_title)
+ .setMessage(getString(R.string.password_move_error_message, source, destination))
+ .setPositiveButton(android.R.string.ok, null)
+ .show()
+ }
+ return moved
+}
+
+/** Shows [message] under [title] and suspends until answered; true when confirmed. */
+// ponytail: View dialog, see moveFile.
+private suspend fun Context.confirm(title: String, message: String): Boolean =
+ suspendCancellableCoroutine { cont ->
+ val dialog =
+ MaterialAlertDialogBuilder(this)
+ .setTitle(title)
+ .setMessage(message)
+ .setPositiveButton(R.string.dialog_ok) { _, _ -> cont.resume(true) }
+ .setNegativeButton(R.string.dialog_cancel) { _, _ -> cont.resume(false) }
+ .setOnCancelListener { cont.resume(false) }
+ .show()
+ cont.invokeOnCancellation { dialog.dismiss() }
+ }
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 1a5e8a89..c0af5742 100644
--- a/app/src/main/java/app/passwordstore/ui/main/LaunchActivity.kt
+++ b/app/src/main/java/app/passwordstore/ui/main/LaunchActivity.kt
@@ -12,9 +12,6 @@ import android.os.Looper
import androidx.appcompat.app.AppCompatActivity
import androidx.core.content.edit
import app.passwordstore.data.repo.PasswordRepository
-import app.passwordstore.ui.crypto.BasePGPActivity
-import app.passwordstore.ui.crypto.DecryptActivity
-import app.passwordstore.ui.passwords.PasswordStore
import app.passwordstore.util.auth.BiometricAuthenticator
import app.passwordstore.util.auth.BiometricAuthenticator.Result
import app.passwordstore.util.extensions.sharedPrefs
@@ -24,10 +21,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 -> {
@@ -45,27 +53,20 @@ class LaunchActivity : AppCompatActivity() {
is Result.Retry -> {}
}
}
- } else {
- startTargetActivity(true)
}
}
- private fun getDecryptIntent(): Intent {
- return Intent(this, DecryptActivity::class.java)
- }
-
private fun startTargetActivity(noAuth: Boolean) {
- // This activity is exported, so the file path is untrusted input: only forward it to
- // DecryptActivity when it points at a file inside the password store.
+ // This activity is exported, so the file path is untrusted input: only forward it when it
+ // points at a file inside the password store. MainActivity is not exported and trusts it.
val repoDir = PasswordRepository.getRepositoryDirectory()
- val filePath = intent.getStringExtra(BasePGPActivity.EXTRA_FILE_PATH)
- val intentToStart =
- if (intent.action == ACTION_DECRYPT_PASS && isFileInRepo(filePath, repoDir))
- getDecryptIntent().apply {
- putExtra(BasePGPActivity.EXTRA_FILE_PATH, filePath)
- putExtra(BasePGPActivity.EXTRA_REPO_PATH, repoDir.absolutePath)
- }
- else Intent(this, PasswordStore::class.java).setAction(Intent.ACTION_VIEW)
+ val filePath = intent.getStringExtra(EXTRA_FILE_PATH)
+ val intentToStart = Intent(this, MainActivity::class.java)
+ if (intent.action == ACTION_DECRYPT_PASS && isFileInRepo(filePath, repoDir))
+ intentToStart.putExtra(EXTRA_FILE_PATH, filePath)
+ // A plain start replaces the task, so an instance already open (one whose store vanished, say)
+ // is not left below the new one. An entry opens on top, back returns to where the user was.
+ else intentToStart.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK)
startActivity(intentToStart)
Handler(Looper.getMainLooper()).postDelayed({ finish() }, if (noAuth) 0L else 500L)
@@ -75,6 +76,9 @@ class LaunchActivity : AppCompatActivity() {
const val ACTION_DECRYPT_PASS = "DECRYPT_PASS"
+ /** The entry to open; home screen shortcuts carry this exact key, never change it. */
+ const val EXTRA_FILE_PATH = "FILE_PATH"
+
/** True when [path] resolves, after following `..` and symlinks, to a file under [repoDir]. */
fun isFileInRepo(path: String?, repoDir: File): Boolean {
if (path.isNullOrEmpty()) return false
diff --git a/app/src/main/java/app/passwordstore/ui/main/MainActivity.kt b/app/src/main/java/app/passwordstore/ui/main/MainActivity.kt
new file mode 100644
index 00000000..ce5fb1df
--- /dev/null
+++ b/app/src/main/java/app/passwordstore/ui/main/MainActivity.kt
@@ -0,0 +1,283 @@
+/*
+ * Copyright (C) 2026 Danilo M. <danix@danix.xyz>
+ * SPDX-License-Identifier: GPL-3.0-only
+ */
+
+package app.passwordstore.ui.main
+
+import android.content.Context
+import android.content.Intent
+import android.os.Bundle
+import android.view.WindowManager
+import androidx.activity.compose.setContent
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.DisposableEffect
+import androidx.compose.runtime.remember
+import androidx.hilt.navigation.compose.hiltViewModel
+import androidx.navigation.compose.NavHost
+import androidx.navigation.compose.composable
+import androidx.navigation.compose.rememberNavController
+import androidx.navigation.toRoute
+import app.passwordstore.data.password.PasswordItem
+import app.passwordstore.data.repo.PasswordRepository
+import app.passwordstore.ui.compose.theme.APSTheme
+import app.passwordstore.ui.editor.EditorDestination
+import app.passwordstore.ui.editor.EditorForm
+import app.passwordstore.ui.editor.editorForm
+import app.passwordstore.ui.entry.EntryDestination
+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.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.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
+import javax.inject.Inject
+import kotlinx.collections.immutable.ImmutableList
+import kotlinx.collections.immutable.persistentListOf
+import kotlinx.serialization.Serializable
+
+/**
+ * The password list of the folder at [path], relative to the store root ("" is the root). The back
+ * stack holds one entry per folder level.
+ */
+@Serializable data class ListRoute(val path: String = "")
+
+/**
+ * One entry, by absolute path. Paths from [LaunchActivity] passed [LaunchActivity.isFileInRepo];
+ * the entry destination checks again before decrypting.
+ */
+@Serializable data class EntryRoute(val filePath: String)
+
+/**
+ * The editor: a new entry in [dir] (relative to the store root, "" is the root), or the entry
+ * [name] (file name without `.gpg`) there. An edit takes its plaintext from the entry view below it
+ * on the back stack, never from the route.
+ */
+@Serializable data class EditRoute(val dir: String, val name: String? = null)
+
+/**
+ * Single-activity host for the Compose UI. Not exported: it is only started by [LaunchActivity],
+ * after its biometric gate and path check, and by the app itself through [setupIntent]. Extends
+ * [BaseGitActivity] (an AppCompatActivity, so also the FragmentActivity BiometricPrompt needs)
+ * because the git operations require one; it hosts no fragments.
+ */
+@AndroidEntryPoint
+class MainActivity : BaseGitActivity() {
+
+ @Inject lateinit var shortcutHandler: ShortcutHandler
+
+ /** Composed destinations that show secrets; FLAG_SECURE is on while this is above zero. */
+ private var secureHolds = 0
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ val filePath = intent.getStringExtra(LaunchActivity.EXTRA_FILE_PATH)
+ val setup =
+ intent.getStringExtra(EXTRA_SETUP)?.let { name ->
+ SetupStart.entries.firstOrNull { it.name == name }
+ }
+ // A restored back stack wins over this choice, so a recreation stays where it was.
+ val start: Any =
+ when {
+ filePath != null -> EntryRoute(filePath)
+ setup != null -> setup.route
+ checkStore() -> ListRoute()
+ else -> WelcomeRoute
+ }
+ setContent {
+ APSTheme {
+ val nav = rememberNavController()
+ NavHost(nav, startDestination = start) {
+ composable<ListRoute> { entry ->
+ val path = entry.toRoute<ListRoute>().path
+ ListDestination(
+ activity = this@MainActivity,
+ path = path,
+ onOpenFolder = { target ->
+ // Only the folder on top may move the stack: a double tap must not push twice.
+ if (nav.currentBackStackEntry?.id == entry.id) {
+ val (pops, pushes) = folderMoves(path, target)
+ repeat(pops) { nav.popBackStack() }
+ pushes.forEach { nav.navigate(ListRoute(it)) }
+ }
+ },
+ onOpenEntry = { file ->
+ if (nav.currentBackStackEntry?.id == entry.id) {
+ addShortcut(file)
+ nav.navigate(EntryRoute(file.absolutePath))
+ }
+ },
+ onNewEntry = {
+ if (nav.currentBackStackEntry?.id == entry.id) nav.navigate(EditRoute(path))
+ },
+ onClone = {
+ 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)
+ nav.navigate(WelcomeRoute) { popUpTo(nav.graph.id) { inclusive = true } }
+ },
+ )
+ }
+ composable<EntryRoute> { entry ->
+ val file = File(entry.toRoute<EntryRoute>().filePath)
+ SecureWindow(this@MainActivity)
+ EntryDestination(
+ activity = this@MainActivity,
+ file = file,
+ onClose = {
+ // Only the entry on top closes: the auto-close timer can race a back press.
+ if (nav.currentBackStackEntry?.id == entry.id) {
+ if (nav.previousBackStackEntry == null) finish() else nav.popBackStack()
+ }
+ },
+ onEdit = {
+ if (nav.currentBackStackEntry?.id == entry.id) {
+ // Canonical folder, link's own name: a symlinked entry keeps its name.
+ val root = PasswordRepository.getRepositoryDirectory().canonicalFile
+ val dir = file.parentFile?.canonicalFile?.relativeTo(root)?.path.orEmpty()
+ nav.navigate(EditRoute(dir, file.nameWithoutExtension))
+ }
+ },
+ )
+ }
+ composable<EditRoute> { entry ->
+ val route = entry.toRoute<EditRoute>()
+ val name = route.name
+ // An edit reads the decrypted entry from the entry view below it. After process
+ // death that ViewModel is new and empty, and the editor closes.
+ val decrypted =
+ if (name == null) null
+ else
+ remember(entry) {
+ // No entry view below (none on the stack): getBackStackEntry throws.
+ try {
+ nav.getBackStackEntry<EntryRoute>()
+ } catch (_: IllegalArgumentException) {
+ null
+ }
+ }
+ ?.let { hiltViewModel<EntryViewModel>(it).entry }
+ SecureWindow(this@MainActivity)
+ EditorDestination(
+ activity = this@MainActivity,
+ initial =
+ if (name == null) EditorForm(dir = route.dir)
+ else decrypted?.let { editorForm(it, route.dir, name) },
+ original =
+ name?.let {
+ File(File(PasswordRepository.getRepositoryDirectory(), route.dir), "$it.gpg")
+ },
+ onClose = { if (nav.currentBackStackEntry?.id == entry.id) nav.popBackStack() },
+ onSaved = {
+ // Back to the list, which re-reads the store on resume. An entry opened from a
+ // shortcut has no list below it: the list root replaces the stack.
+ if (nav.currentBackStackEntry?.id == entry.id) {
+ if (!nav.popBackStack<ListRoute>(inclusive = false))
+ nav.navigate(ListRoute()) { popUpTo<EntryRoute> { inclusive = true } }
+ }
+ },
+ )
+ }
+ setupGraph(this@MainActivity, nav)
+ settingsGraph(this@MainActivity, nav)
+ }
+ }
+ }
+ }
+
+ /** Counts a destination that shows secrets; the first one turns FLAG_SECURE on. */
+ fun holdSecure() {
+ if (secureHolds++ == 0) window.addFlags(WindowManager.LayoutParams.FLAG_SECURE)
+ }
+
+ /** Ends a [holdSecure]; FLAG_SECURE goes off when no secret-showing destination is left. */
+ fun releaseSecure() {
+ if (--secureHolds == 0) window.clearFlags(WindowManager.LayoutParams.FLAG_SECURE)
+ }
+
+ /** Re-reads the store; false when there is none yet and setup has to run. */
+ fun checkStore(): Boolean {
+ PasswordRepository.initialize()
+ return sharedPrefs.getBoolean(PreferenceKeys.REPOSITORY_INITIALIZED, false)
+ }
+
+ /** Git actions offered in the list's menu, chosen as the old list chose its menu. */
+ fun gitOps(): ImmutableList<GitOp> =
+ when {
+ gitSettings.authMode == AuthMode.None -> persistentListOf(GitOp.PULL)
+ PasswordRepository.isGitRepo() -> persistentListOf(GitOp.SYNC, GitOp.PULL, GitOp.PUSH)
+ else -> persistentListOf()
+ }
+
+ /** Runs [op]; failures show the usual git error dialog. */
+ suspend fun runGit(op: GitOp) {
+ 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)
+ shortcutHandler.addDynamicShortcut(item, item.createAuthEnabledIntent(this))
+ }
+
+ fun pinEntry(file: File) {
+ val item = entryItem(file)
+ shortcutHandler.addPinnedShortcut(item, item.createAuthEnabledIntent(this))
+ }
+
+ private fun entryItem(file: File) =
+ PasswordItem.newPassword(file.name, file, PasswordRepository.getRepositoryDirectory())
+
+ companion object {
+
+ private const val EXTRA_SETUP = "SETUP"
+
+ /**
+ * 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. Autofill's activities stay
+ // outside the graph for good (the autofill service starts them); the git operations' "No SSH
+ // key" dialog could navigate in-graph once it becomes a Compose dialog.
+ fun setupIntent(context: Context, start: SetupStart): Intent =
+ Intent(context, MainActivity::class.java).putExtra(EXTRA_SETUP, start.name)
+ }
+}
+
+/**
+ * FLAG_SECURE while composed. Counted in [MainActivity], because in a transition (entry view to
+ * editor and back) the entering destination is composed before the leaving one is disposed.
+ */
+@Composable
+private fun SecureWindow(activity: MainActivity) {
+ DisposableEffect(activity) {
+ activity.holdSecure()
+ onDispose { activity.releaseSecure() }
+ }
+}
diff --git a/app/src/main/java/app/passwordstore/ui/onboarding/activity/OnboardingActivity.kt b/app/src/main/java/app/passwordstore/ui/onboarding/activity/OnboardingActivity.kt
deleted file mode 100644
index 709c2302..00000000
--- a/app/src/main/java/app/passwordstore/ui/onboarding/activity/OnboardingActivity.kt
+++ /dev/null
@@ -1,27 +0,0 @@
-/*
- * Copyright © 2014-2024 The Android Password Store Authors. All Rights Reserved.
- * SPDX-License-Identifier: GPL-3.0-only
- */
-
-package app.passwordstore.ui.onboarding.activity
-
-import android.os.Bundle
-import androidx.activity.addCallback
-import androidx.appcompat.app.AppCompatActivity
-import app.passwordstore.R
-import dagger.hilt.android.AndroidEntryPoint
-
-@AndroidEntryPoint
-class OnboardingActivity : AppCompatActivity(R.layout.activity_onboarding) {
-
- override fun onCreate(savedInstanceState: Bundle?) {
- super.onCreate(savedInstanceState)
- supportActionBar?.hide()
- val callback = onBackPressedDispatcher.addCallback(enabled = false) { finishAffinity() }
- supportFragmentManager.addOnBackStackChangedListener {
- if (supportFragmentManager.backStackEntryCount == 0) {
- callback.isEnabled = true
- }
- }
- }
-}
diff --git a/app/src/main/java/app/passwordstore/ui/onboarding/fragments/CloneFragment.kt b/app/src/main/java/app/passwordstore/ui/onboarding/fragments/CloneFragment.kt
deleted file mode 100644
index a9d933b6..00000000
--- a/app/src/main/java/app/passwordstore/ui/onboarding/fragments/CloneFragment.kt
+++ /dev/null
@@ -1,78 +0,0 @@
-/*
- * Copyright © 2014-2024 The Android Password Store Authors. All Rights Reserved.
- * SPDX-License-Identifier: GPL-3.0-only
- */
-
-package app.passwordstore.ui.onboarding.fragments
-
-import android.os.Bundle
-import android.view.View
-import androidx.activity.result.contract.ActivityResultContracts
-import androidx.appcompat.app.AppCompatActivity
-import androidx.core.content.edit
-import androidx.fragment.app.Fragment
-import app.passwordstore.R
-import app.passwordstore.data.repo.PasswordRepository
-import app.passwordstore.databinding.FragmentCloneBinding
-import app.passwordstore.ui.git.config.GitServerConfigActivity
-import app.passwordstore.util.extensions.finish
-import app.passwordstore.util.extensions.performTransactionWithBackStack
-import app.passwordstore.util.extensions.sharedPrefs
-import app.passwordstore.util.extensions.unsafeLazy
-import app.passwordstore.util.extensions.viewBinding
-import app.passwordstore.util.settings.PreferenceKeys
-import com.github.michaelbull.result.onFailure
-import com.github.michaelbull.result.runCatching
-import logcat.LogPriority.ERROR
-import logcat.asLog
-import logcat.logcat
-
-class CloneFragment : Fragment(R.layout.fragment_clone) {
-
- private val binding by viewBinding(FragmentCloneBinding::bind)
-
- private val settings by unsafeLazy { requireActivity().applicationContext.sharedPrefs }
-
- private val cloneAction =
- registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
- if (result.resultCode == AppCompatActivity.RESULT_OK) {
- settings.edit { putBoolean(PreferenceKeys.REPOSITORY_INITIALIZED, true) }
- finish()
- }
- }
-
- override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
- super.onViewCreated(view, savedInstanceState)
- binding.cloneRemote.setOnClickListener { cloneToHiddenDir() }
- binding.createLocal.setOnClickListener { createRepository() }
- }
-
- /** Clones a remote Git repository to the app's private directory */
- private fun cloneToHiddenDir() {
- cloneAction.launch(GitServerConfigActivity.createCloneIntent(requireContext()))
- }
-
- private fun createRepository() {
- val localDir = PasswordRepository.getRepositoryDirectory()
- runCatching {
- check(localDir.exists() || localDir.mkdir()) { "Failed to create directory!" }
- PasswordRepository.createRepository(localDir)
- if (!PasswordRepository.isInitialized) {
- PasswordRepository.initialize()
- }
- parentFragmentManager.performTransactionWithBackStack(KeySelectionFragment.newInstance())
- }
- .onFailure { e ->
- logcat(ERROR) { e.asLog() }
- if (!localDir.delete()) {
- logcat { "Failed to delete local repository: $localDir" }
- }
- finish()
- }
- }
-
- companion object {
-
- fun newInstance(): CloneFragment = CloneFragment()
- }
-}
diff --git a/app/src/main/java/app/passwordstore/ui/onboarding/fragments/KeySelectionFragment.kt b/app/src/main/java/app/passwordstore/ui/onboarding/fragments/KeySelectionFragment.kt
deleted file mode 100644
index 64c2e124..00000000
--- a/app/src/main/java/app/passwordstore/ui/onboarding/fragments/KeySelectionFragment.kt
+++ /dev/null
@@ -1,77 +0,0 @@
-/*
- * Copyright © 2014-2024 The Android Password Store Authors. All Rights Reserved.
- * SPDX-License-Identifier: GPL-3.0-only
- */
-
-package app.passwordstore.ui.onboarding.fragments
-
-import android.content.SharedPreferences
-import android.os.Bundle
-import android.view.View
-import androidx.activity.result.contract.ActivityResultContracts.StartActivityForResult
-import androidx.appcompat.app.AppCompatActivity
-import androidx.core.content.edit
-import androidx.fragment.app.Fragment
-import androidx.lifecycle.lifecycleScope
-import app.passwordstore.R
-import app.passwordstore.data.repo.PasswordRepository
-import app.passwordstore.databinding.FragmentKeySelectionBinding
-import app.passwordstore.injection.prefs.SettingsPreferences
-import app.passwordstore.ui.pgp.PGPKeyListActivity
-import app.passwordstore.util.coroutines.DispatcherProvider
-import app.passwordstore.util.extensions.commitChange
-import app.passwordstore.util.extensions.finish
-import app.passwordstore.util.extensions.snackbar
-import app.passwordstore.util.extensions.viewBinding
-import app.passwordstore.util.settings.PreferenceKeys
-import com.google.android.material.snackbar.Snackbar
-import dagger.hilt.android.AndroidEntryPoint
-import java.io.File
-import javax.inject.Inject
-import kotlinx.coroutines.launch
-import kotlinx.coroutines.withContext
-
-@AndroidEntryPoint
-class KeySelectionFragment : Fragment(R.layout.fragment_key_selection) {
-
- @Inject @SettingsPreferences lateinit var settings: SharedPreferences
- @Inject lateinit var dispatcherProvider: DispatcherProvider
- private val binding by viewBinding(FragmentKeySelectionBinding::bind)
- private val gpgKeySelectAction =
- registerForActivityResult(StartActivityForResult()) { result ->
- if (result.resultCode == AppCompatActivity.RESULT_OK) {
- val data = result.data ?: return@registerForActivityResult
- val selectedKey =
- data.getStringExtra(PGPKeyListActivity.EXTRA_SELECTED_KEY)
- ?: return@registerForActivityResult
- lifecycleScope.launch {
- withContext(dispatcherProvider.io()) {
- val gpgIdentifierFile = File(PasswordRepository.getRepositoryDirectory(), ".gpg-id")
- gpgIdentifierFile.writeText(selectedKey)
- }
- settings.edit { putBoolean(PreferenceKeys.REPOSITORY_INITIALIZED, true) }
- requireActivity()
- .commitChange(getString(R.string.git_commit_gpg_id, getString(R.string.app_name)))
- finish()
- }
- } else {
- requireActivity()
- .snackbar(
- message = getString(R.string.gpg_key_select_mandatory),
- length = Snackbar.LENGTH_LONG,
- )
- }
- }
-
- override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
- super.onViewCreated(view, savedInstanceState)
- binding.selectKey.setOnClickListener {
- gpgKeySelectAction.launch(PGPKeyListActivity.newSelectionActivity(requireContext()))
- }
- }
-
- companion object {
-
- fun newInstance() = KeySelectionFragment()
- }
-}
diff --git a/app/src/main/java/app/passwordstore/ui/onboarding/fragments/WelcomeFragment.kt b/app/src/main/java/app/passwordstore/ui/onboarding/fragments/WelcomeFragment.kt
deleted file mode 100644
index 44881c64..00000000
--- a/app/src/main/java/app/passwordstore/ui/onboarding/fragments/WelcomeFragment.kt
+++ /dev/null
@@ -1,33 +0,0 @@
-/*
- * Copyright © 2014-2024 The Android Password Store Authors. All Rights Reserved.
- * SPDX-License-Identifier: GPL-3.0-only
- */
-
-package app.passwordstore.ui.onboarding.fragments
-
-import android.os.Bundle
-import android.view.View
-import androidx.annotation.Keep
-import androidx.fragment.app.Fragment
-import app.passwordstore.R
-import app.passwordstore.databinding.FragmentWelcomeBinding
-import app.passwordstore.ui.settings.SettingsActivity
-import app.passwordstore.util.extensions.launchActivity
-import app.passwordstore.util.extensions.performTransactionWithBackStack
-import app.passwordstore.util.extensions.viewBinding
-
-@Keep
-class WelcomeFragment : Fragment(R.layout.fragment_welcome) {
-
- private val binding by viewBinding(FragmentWelcomeBinding::bind)
-
- override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
- super.onViewCreated(view, savedInstanceState)
- binding.letsGo.setOnClickListener {
- parentFragmentManager.performTransactionWithBackStack(CloneFragment.newInstance())
- }
- binding.settingsButton.setOnClickListener {
- requireActivity().launchActivity(SettingsActivity::class.java)
- }
- }
-}
diff --git a/app/src/main/java/app/passwordstore/ui/passwords/PasswordFragment.kt b/app/src/main/java/app/passwordstore/ui/passwords/PasswordFragment.kt
deleted file mode 100644
index 917bb91c..00000000
--- a/app/src/main/java/app/passwordstore/ui/passwords/PasswordFragment.kt
+++ /dev/null
@@ -1,388 +0,0 @@
-/*
- * Copyright © 2014-2024 The Android Password Store Authors. All Rights Reserved.
- * SPDX-License-Identifier: GPL-3.0-only
- */
-package app.passwordstore.ui.passwords
-
-import android.content.Context
-import android.content.SharedPreferences
-import android.os.Bundle
-import android.os.Parcelable
-import android.view.Menu
-import android.view.MenuItem
-import android.view.View
-import android.view.animation.Animation
-import android.view.animation.AnimationUtils
-import androidx.activity.result.contract.ActivityResultContracts.StartActivityForResult
-import androidx.appcompat.view.ActionMode
-import androidx.core.content.edit
-import androidx.fragment.app.Fragment
-import androidx.fragment.app.activityViewModels
-import androidx.lifecycle.flowWithLifecycle
-import androidx.lifecycle.lifecycleScope
-import androidx.recyclerview.widget.DividerItemDecoration
-import androidx.recyclerview.widget.LinearLayoutManager
-import app.passwordstore.R
-import app.passwordstore.data.password.PasswordItem
-import app.passwordstore.data.repo.PasswordRepository
-import app.passwordstore.databinding.PasswordRecyclerViewBinding
-import app.passwordstore.injection.prefs.SettingsPreferences
-import app.passwordstore.ui.adapters.PasswordItemRecyclerAdapter
-import app.passwordstore.ui.dialogs.BasicBottomSheet
-import app.passwordstore.ui.dialogs.ItemCreationBottomSheet
-import app.passwordstore.ui.git.base.BaseGitActivity
-import app.passwordstore.ui.git.config.GitServerConfigActivity
-import app.passwordstore.ui.util.OnOffItemAnimator
-import app.passwordstore.util.coroutines.DispatcherProvider
-import app.passwordstore.util.extensions.base64
-import app.passwordstore.util.extensions.getString
-import app.passwordstore.util.extensions.sharedPrefs
-import app.passwordstore.util.extensions.viewBinding
-import app.passwordstore.util.settings.AuthMode
-import app.passwordstore.util.settings.GitSettings
-import app.passwordstore.util.settings.PasswordSortOrder
-import app.passwordstore.util.settings.PreferenceKeys
-import app.passwordstore.util.shortcuts.ShortcutHandler
-import app.passwordstore.util.viewmodel.SearchableRepositoryViewModel
-import com.github.michaelbull.result.fold
-import com.github.michaelbull.result.onFailure
-import com.github.michaelbull.result.runCatching
-import dagger.hilt.android.AndroidEntryPoint
-import java.io.File
-import javax.inject.Inject
-import kotlinx.coroutines.launch
-import me.zhanghai.android.fastscroll.FastScrollerBuilder
-
-@AndroidEntryPoint
-class PasswordFragment : Fragment(R.layout.password_recycler_view) {
-
- @Inject lateinit var gitSettings: GitSettings
- @Inject lateinit var shortcutHandler: ShortcutHandler
- @Inject lateinit var dispatcherProvider: DispatcherProvider
- @Inject @SettingsPreferences lateinit var prefs: SharedPreferences
- private lateinit var recyclerAdapter: PasswordItemRecyclerAdapter
- private lateinit var listener: OnFragmentInteractionListener
- private lateinit var settings: SharedPreferences
-
- private var recyclerViewStateToRestore: Parcelable? = null
- private var actionMode: ActionMode? = null
- private var scrollTarget: File? = null
-
- private val model: SearchableRepositoryViewModel by activityViewModels()
- private val binding by viewBinding(PasswordRecyclerViewBinding::bind)
- private val swipeResult =
- registerForActivityResult(StartActivityForResult()) {
- binding.swipeRefresher.isRefreshing = false
- requireStore().refreshPasswordList()
- }
-
- val currentDir: File
- get() = model.currentDir.value
-
- override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
- super.onViewCreated(view, savedInstanceState)
- settings = requireContext().sharedPrefs
- initializePasswordList()
- binding.fab.setOnClickListener {
- ItemCreationBottomSheet().show(childFragmentManager, "BOTTOM_SHEET")
- }
- childFragmentManager.setFragmentResultListener(ITEM_CREATION_REQUEST_KEY, viewLifecycleOwner) {
- _,
- bundle ->
- when (bundle.getString(ACTION_KEY)) {
- ACTION_FOLDER -> requireStore().createFolder()
- ACTION_PASSWORD -> requireStore().createPassword()
- }
- }
- }
-
- private fun initializePasswordList() {
- val gitDir = File(PasswordRepository.getRepositoryDirectory(), ".git")
- val hasGitDir =
- gitDir.exists() && gitDir.isDirectory && (gitDir.listFiles()?.isNotEmpty() == true)
- binding.swipeRefresher.setOnRefreshListener {
- if (!hasGitDir) {
- requireStore().refreshPasswordList()
- binding.swipeRefresher.isRefreshing = false
- } else if (!PasswordRepository.isGitRepo()) {
- BasicBottomSheet.Builder(requireContext())
- .setMessageRes(R.string.clone_git_repo)
- .setPositiveButtonClickListener(getString(R.string.clone_button)) {
- swipeResult.launch(GitServerConfigActivity.createCloneIntent(requireContext()))
- }
- .build()
- .show(requireActivity().supportFragmentManager, "NOT_A_GIT_REPO")
- binding.swipeRefresher.isRefreshing = false
- } else {
- // When authentication is set to AuthMode.None then the only git operation we can
- // run is a pull, so automatically fallback to that.
- val operationId =
- when (gitSettings.authMode) {
- AuthMode.None -> BaseGitActivity.GitOp.PULL
- else -> BaseGitActivity.GitOp.SYNC
- }
- requireStore().apply {
- lifecycleScope.launch {
- launchGitOperation(operationId)
- .fold(
- success = {
- binding.swipeRefresher.isRefreshing = false
- refreshPasswordList()
- },
- failure = { err ->
- promptOnErrorHandler(err) { binding.swipeRefresher.isRefreshing = false }
- },
- )
- }
- }
- }
- }
-
- recyclerAdapter =
- PasswordItemRecyclerAdapter(lifecycleScope, dispatcherProvider)
- .onItemClicked { _, item -> listener.onFragmentInteraction(item) }
- .onSelectionChanged { selection ->
- // In order to not interfere with drag selection, we disable the
- // SwipeRefreshLayout
- // once an item is selected.
- binding.swipeRefresher.isEnabled = selection.isEmpty
-
- if (actionMode == null)
- actionMode =
- requireStore().startSupportActionMode(actionModeCallback) ?: return@onSelectionChanged
-
- if (!selection.isEmpty) {
- actionMode!!.title =
- resources.getQuantityString(
- R.plurals.delete_title,
- selection.size(),
- selection.size(),
- )
- actionMode!!.invalidate()
- } else {
- actionMode!!.finish()
- }
- }
- val recyclerView = binding.passRecycler
- recyclerView.apply {
- addItemDecoration(DividerItemDecoration(requireContext(), LinearLayoutManager.VERTICAL))
- layoutManager = LinearLayoutManager(requireContext())
- itemAnimator = OnOffItemAnimator()
- adapter = recyclerAdapter
- }
-
- FastScrollerBuilder(recyclerView).build()
- recyclerAdapter.makeSelectable(recyclerView)
- registerForContextMenu(recyclerView)
-
- val path =
- requireNotNull(requireArguments().getString(PasswordStore.REQUEST_ARG_PATH)) {
- "Cannot navigate if ${PasswordStore.REQUEST_ARG_PATH} is not provided"
- }
- model.navigateTo(File(path), pushPreviousLocation = false)
- lifecycleScope.launch {
- model.searchResult.flowWithLifecycle(lifecycle).collect { result ->
- // Only run animations when the new list is filtered, i.e., the user submitted a search,
- // and not on folder navigation since the latter leads to too many removal animations.
- (recyclerView.itemAnimator as OnOffItemAnimator).isEnabled = result.isFiltered
- recyclerAdapter.submitList(result.passwordItems) {
- when {
- result.isFiltered -> {
- // When the result is filtered, we always scroll to the top since that is
- // where the best fuzzy match appears.
- recyclerView.scrollToPosition(0)
- }
- scrollTarget != null -> {
- scrollTarget?.let {
- recyclerView.scrollToPosition(recyclerAdapter.getPositionForFile(it))
- }
- scrollTarget = null
- }
- else -> {
- // When the result is not filtered and there is a saved scroll position for
- // it, we try to restore it.
- recyclerViewStateToRestore?.let {
- recyclerView.layoutManager!!.onRestoreInstanceState(it)
- }
- recyclerViewStateToRestore = null
- }
- }
- }
- }
- }
- }
-
- private val actionModeCallback =
- object : ActionMode.Callback {
- // Called when the action mode is created; startActionMode() was called
- override fun onCreateActionMode(mode: ActionMode, menu: Menu): Boolean {
- // Inflate a menu resource providing context menu items
- mode.menuInflater.inflate(R.menu.context_pass, menu)
- // hide the fab
- animateFab(false)
- return true
- }
-
- // Called each time the action mode is shown. Always called after onCreateActionMode,
- // but may be called multiple times if the mode is invalidated.
- override fun onPrepareActionMode(mode: ActionMode, menu: Menu): Boolean {
- val selectedItems = recyclerAdapter.getSelectedItems()
- menu.findItem(R.id.menu_edit_password).isVisible =
- selectedItems.all { it.type == PasswordItem.TYPE_CATEGORY }
- menu.findItem(R.id.menu_pin_password).isVisible =
- selectedItems.size == 1 && selectedItems[0].type == PasswordItem.TYPE_PASSWORD
- return true
- }
-
- // Called when the user selects a contextual menu item
- override fun onActionItemClicked(mode: ActionMode, item: MenuItem): Boolean {
- return when (item.itemId) {
- R.id.menu_delete_password -> {
- requireStore().deletePasswords(recyclerAdapter.getSelectedItems())
- // Action picked, so close the CAB
- mode.finish()
- true
- }
- R.id.menu_move_password -> {
- requireStore().movePasswords(recyclerAdapter.getSelectedItems())
- false
- }
- R.id.menu_edit_password -> {
- requireStore().renameCategory(recyclerAdapter.getSelectedItems())
- mode.finish()
- false
- }
- R.id.menu_pin_password -> {
- val passwordItem = recyclerAdapter.getSelectedItems()[0]
- shortcutHandler.addPinnedShortcut(
- passwordItem,
- passwordItem.createAuthEnabledIntent(requireContext()),
- )
- false
- }
- else -> false
- }
- }
-
- // Called when the user exits the action mode
- override fun onDestroyActionMode(mode: ActionMode) {
- recyclerAdapter.requireSelectionTracker().clearSelection()
- actionMode = null
- // show the fab
- animateFab(true)
- }
-
- private fun animateFab(show: Boolean) =
- with(binding.fab) {
- val animation =
- AnimationUtils.loadAnimation(context, if (show) R.anim.scale_up else R.anim.scale_down)
- animation.setAnimationListener(
- object : Animation.AnimationListener {
- override fun onAnimationRepeat(animation: Animation?) {}
-
- override fun onAnimationEnd(animation: Animation?) {
- if (!show) visibility = View.GONE
- }
-
- override fun onAnimationStart(animation: Animation?) {
- if (show) visibility = View.VISIBLE
- }
- }
- )
- animate()
- .rotationBy(if (show) -90f else 90f)
- .setStartDelay(if (show) 100 else 0)
- .setDuration(100)
- .start()
- startAnimation(animation)
- }
- }
-
- override fun onResume() {
- super.onResume()
- binding.swipeRefresher.isEnabled = !prefs.getBoolean(PreferenceKeys.DISABLE_SYNC_ACTION, false)
- }
-
- override fun onAttach(context: Context) {
- super.onAttach(context)
- runCatching {
- listener =
- object : OnFragmentInteractionListener {
- override fun onFragmentInteraction(item: PasswordItem) {
- if (
- settings.getString(PreferenceKeys.SORT_ORDER) ==
- PasswordSortOrder.RECENTLY_USED.name
- ) {
- // save the time when password was used
- val preferences =
- context.getSharedPreferences("recent_password_history", Context.MODE_PRIVATE)
- preferences.edit {
- putString(item.file.absolutePath.base64(), System.currentTimeMillis().toString())
- }
- }
-
- if (item.type == PasswordItem.TYPE_CATEGORY) {
- navigateTo(item.file)
- } else {
- if (requireArguments().getBoolean("matchWith", false)) {
- requireStore().matchPasswordWithApp(item)
- } else {
- requireStore().decryptPassword(item)
- }
- }
- }
- }
- }
- .onFailure {
- throw ClassCastException("$context must implement OnFragmentInteractionListener")
- }
- }
-
- private fun requireStore() = requireActivity() as PasswordStore
-
- /** Returns true if the back press was handled by the [Fragment]. */
- fun onBackPressedInActivity(): Boolean {
- if (!model.canNavigateBack) return false
- // The RecyclerView state is restored when the asynchronous update operation on the
- // adapter is completed.
- recyclerViewStateToRestore = model.navigateBack()
- if (!model.canNavigateBack) requireStore().supportActionBar?.setDisplayHomeAsUpEnabled(false)
- return true
- }
-
- fun dismissActionMode() {
- actionMode?.finish()
- }
-
- companion object {
-
- const val ITEM_CREATION_REQUEST_KEY = "creation_key"
- const val ACTION_KEY = "action"
- const val ACTION_FOLDER = "folder"
- const val ACTION_PASSWORD = "password"
-
- fun newInstance(args: Bundle): PasswordFragment {
- val fragment = PasswordFragment()
- fragment.arguments = args
- return fragment
- }
- }
-
- fun navigateTo(file: File) {
- requireStore().clearSearch()
- model.navigateTo(
- file,
- recyclerViewState = binding.passRecycler.layoutManager!!.onSaveInstanceState(),
- )
- requireStore().supportActionBar?.setDisplayHomeAsUpEnabled(true)
- }
-
- fun scrollToOnNextRefresh(file: File) {
- scrollTarget = file
- }
-
- interface OnFragmentInteractionListener {
-
- fun onFragmentInteraction(item: PasswordItem)
- }
-}
diff --git a/app/src/main/java/app/passwordstore/ui/passwords/PasswordStore.kt b/app/src/main/java/app/passwordstore/ui/passwords/PasswordStore.kt
deleted file mode 100644
index 059b398f..00000000
--- a/app/src/main/java/app/passwordstore/ui/passwords/PasswordStore.kt
+++ /dev/null
@@ -1,621 +0,0 @@
-/*
- * Copyright © 2014-2024 The Android Password Store Authors. All Rights Reserved.
- * SPDX-License-Identifier: GPL-3.0-only
- */
-package app.passwordstore.ui.passwords
-
-import android.content.ComponentName
-import android.content.Context
-import android.content.Intent
-import android.os.Bundle
-import android.view.KeyEvent
-import android.view.Menu
-import android.view.MenuItem
-import android.view.MenuItem.OnActionExpandListener
-import android.view.WindowManager
-import androidx.activity.result.contract.ActivityResultContracts.StartActivityForResult
-import androidx.activity.viewModels
-import androidx.appcompat.widget.SearchView
-import androidx.appcompat.widget.SearchView.OnQueryTextListener
-import androidx.core.content.edit
-import androidx.fragment.app.FragmentManager
-import androidx.fragment.app.commit
-import androidx.lifecycle.flowWithLifecycle
-import androidx.lifecycle.lifecycleScope
-import app.passwordstore.R
-import app.passwordstore.data.password.PasswordItem
-import app.passwordstore.data.repo.PasswordRepository
-import app.passwordstore.ui.crypto.BasePGPActivity
-import app.passwordstore.ui.crypto.BasePGPActivity.Companion.getLongName
-import app.passwordstore.ui.crypto.DecryptActivity
-import app.passwordstore.ui.crypto.PasswordCreationActivity
-import app.passwordstore.ui.dialogs.FolderCreationDialogFragment
-import app.passwordstore.ui.folderselect.SelectFolderActivity
-import app.passwordstore.ui.git.base.BaseGitActivity
-import app.passwordstore.ui.onboarding.activity.OnboardingActivity
-import app.passwordstore.ui.settings.SettingsActivity
-import app.passwordstore.util.autofill.AutofillMatcher
-import app.passwordstore.util.extensions.base64
-import app.passwordstore.util.extensions.commitChange
-import app.passwordstore.util.extensions.contains
-import app.passwordstore.util.extensions.getString
-import app.passwordstore.util.extensions.isInsideRepository
-import app.passwordstore.util.extensions.launchActivity
-import app.passwordstore.util.extensions.listFilesRecursively
-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 app.passwordstore.util.viewmodel.SearchableRepositoryViewModel
-import com.github.michaelbull.result.fold
-import com.github.michaelbull.result.onFailure
-import com.github.michaelbull.result.runCatching
-import com.google.android.material.dialog.MaterialAlertDialogBuilder
-import com.google.android.material.textfield.TextInputEditText
-import dagger.hilt.android.AndroidEntryPoint
-import java.io.File
-import java.lang.Character.UnicodeBlock
-import javax.inject.Inject
-import kotlinx.coroutines.launch
-import kotlinx.coroutines.withContext
-import logcat.LogPriority.ERROR
-import logcat.LogPriority.INFO
-import logcat.logcat
-
-const val PASSWORD_FRAGMENT_TAG = "PasswordsList"
-
-@AndroidEntryPoint
-class PasswordStore : BaseGitActivity() {
-
- @Inject lateinit var shortcutHandler: ShortcutHandler
- private lateinit var searchItem: MenuItem
- private val settings by lazy { sharedPrefs }
-
- private val model: SearchableRepositoryViewModel by viewModels()
-
- private val listRefreshAction =
- registerForActivityResult(StartActivityForResult()) { result ->
- if (result.resultCode == RESULT_OK) {
- refreshPasswordList()
- }
- }
-
- private val passwordMoveAction =
- registerForActivityResult(StartActivityForResult()) { result ->
- val intentData = result.data ?: return@registerForActivityResult
- val filesToMove =
- requireNotNull(intentData.getStringArrayExtra("Files")) {
- "'Files' intent extra must be set"
- }
- val target =
- File(
- requireNotNull(intentData.getStringExtra("SELECTED_FOLDER_PATH")) {
- "'SELECTED_FOLDER_PATH' intent extra must be set"
- }
- )
- val repositoryPath = PasswordRepository.getRepositoryDirectory().absolutePath
- if (!target.isDirectory) {
- logcat(ERROR) { "Tried moving passwords to a non-existing folder." }
- return@registerForActivityResult
- }
-
- logcat { "Moving passwords to ${intentData.getStringExtra("SELECTED_FOLDER_PATH")}" }
- logcat { filesToMove.joinToString(", ") }
-
- lifecycleScope.launch(dispatcherProvider.io()) {
- for (file in filesToMove) {
- val source = File(file)
- if (!source.exists()) {
- logcat(ERROR) { "Tried moving something that appears non-existent." }
- continue
- }
- val destinationFile = File(target.absolutePath + "/" + source.name)
- val basename = source.nameWithoutExtension
- val sourceLongName =
- getLongName(
- requireNotNull(source.parent) { "$file has no parent" },
- repositoryPath,
- basename,
- )
- val destinationLongName = getLongName(target.absolutePath, repositoryPath, basename)
- if (destinationFile.exists()) {
- logcat(ERROR) { "Trying to move a file that already exists." }
- withContext(dispatcherProvider.main()) {
- MaterialAlertDialogBuilder(this@PasswordStore)
- .setTitle(resources.getString(R.string.password_exists_title))
- .setMessage(
- resources.getString(
- R.string.password_exists_message,
- destinationLongName,
- sourceLongName,
- )
- )
- .setPositiveButton(R.string.dialog_ok) { _, _ ->
- launch(dispatcherProvider.io()) { moveFile(source, destinationFile) }
- }
- .setNegativeButton(R.string.dialog_cancel, null)
- .show()
- }
- } else {
- launch(dispatcherProvider.io()) { moveFile(source, destinationFile) }
- }
- }
- when (filesToMove.size) {
- 1 -> {
- val source = File(filesToMove[0])
- val basename = source.nameWithoutExtension
- val sourceLongName =
- getLongName(
- requireNotNull(source.parent) { "$basename has no parent" },
- repositoryPath,
- basename,
- )
- val destinationLongName = getLongName(target.absolutePath, repositoryPath, basename)
- withContext(dispatcherProvider.main()) {
- commitChange(
- resources.getString(
- R.string.git_commit_move_text,
- sourceLongName,
- destinationLongName,
- )
- )
- }
- }
- else -> {
- val repoDir = PasswordRepository.getRepositoryDirectory().absolutePath
- val relativePath = getRelativePath("${target.absolutePath}/", repoDir)
- withContext(dispatcherProvider.main()) {
- commitChange(
- resources.getString(R.string.git_commit_move_multiple_text, relativePath)
- )
- }
- }
- }
- }
- refreshPasswordList()
- getPasswordFragment()?.dismissActionMode()
- }
-
- override fun onKeyDown(keyCode: Int, event: KeyEvent): Boolean {
- // open search view on search key, or Ctr+F
- if (
- (keyCode == KeyEvent.KEYCODE_SEARCH ||
- keyCode == KeyEvent.KEYCODE_F && event.isCtrlPressed) && !searchItem.isActionViewExpanded
- ) {
- searchItem.expandActionView()
- return true
- }
-
- // open search view on any printable character and query for it
- val c = event.unicodeChar.toChar()
- val printable = isPrintable(c)
- if (printable && !searchItem.isActionViewExpanded) {
- searchItem.expandActionView()
- (searchItem.actionView as SearchView).setQuery(c.toString(), true)
- return true
- }
- return super.onKeyDown(keyCode, event)
- }
-
- override fun onCreate(savedInstanceState: Bundle?) {
- super.onCreate(savedInstanceState)
- setContentView(R.layout.activity_pwdstore)
-
- lifecycleScope.launch {
- model.currentDir.flowWithLifecycle(lifecycle).collect { dir ->
- val basePath = PasswordRepository.getRepositoryDirectory().absoluteFile
- supportActionBar?.apply {
- if (dir != basePath) title = dir.name else setTitle(R.string.app_name)
- }
- }
- }
- }
-
- override fun onStart() {
- super.onStart()
- refreshPasswordList()
- }
-
- override fun onResume() {
- super.onResume()
- checkLocalRepository()
- if (settings.getBoolean(PreferenceKeys.SEARCH_ON_START, false) && ::searchItem.isInitialized) {
- if (!searchItem.isActionViewExpanded) {
- searchItem.expandActionView()
- }
- }
- }
-
- override fun onCreateOptionsMenu(menu: Menu): Boolean {
- val menuRes =
- when {
- gitSettings.authMode == AuthMode.None -> R.menu.main_menu_no_auth
- PasswordRepository.isGitRepo() -> R.menu.main_menu_git
- else -> R.menu.main_menu_non_git
- }
- menuInflater.inflate(menuRes, menu)
- return super.onCreateOptionsMenu(menu)
- }
-
- override fun onPrepareOptionsMenu(menu: Menu): Boolean {
- // Invalidation forces onCreateOptionsMenu to be called again. This is cheap and quick so
- // we can get by without any noticeable difference in performance.
- invalidateOptionsMenu()
- searchItem = menu.findItem(R.id.action_search)
- val searchView = searchItem.actionView as SearchView
- searchView.setOnQueryTextListener(
- object : OnQueryTextListener {
- override fun onQueryTextSubmit(s: String): Boolean {
- searchView.clearFocus()
- return true
- }
-
- override fun onQueryTextChange(s: String): Boolean {
- val filter = s.trim()
- // List the contents of the current directory if the user enters a blank
- // search term.
- if (filter.isEmpty())
- model.navigateTo(newDirectory = model.currentDir.value, pushPreviousLocation = false)
- else model.search(filter)
- return true
- }
- }
- )
-
- // When using the support library, the setOnActionExpandListener() method is
- // static and accepts the MenuItem object as an argument
- searchItem.setOnActionExpandListener(
- object : OnActionExpandListener {
- override fun onMenuItemActionCollapse(item: MenuItem): Boolean {
- refreshPasswordList()
- return true
- }
-
- override fun onMenuItemActionExpand(item: MenuItem): Boolean {
- return true
- }
- }
- )
- if (settings.getBoolean(PreferenceKeys.SEARCH_ON_START, false)) {
- searchItem.expandActionView()
- }
- return super.onPrepareOptionsMenu(menu)
- }
-
- override fun onOptionsItemSelected(item: MenuItem): Boolean {
- val id = item.itemId
- val initBefore =
- MaterialAlertDialogBuilder(this)
- .setMessage(resources.getString(R.string.creation_dialog_text))
- .setPositiveButton(resources.getString(R.string.dialog_ok), null)
- when (id) {
- R.id.user_pref -> {
- runCatching { launchActivity(SettingsActivity::class.java) }
- .onFailure { e -> e.printStackTrace() }
- }
- R.id.git_push -> {
- if (!PasswordRepository.isInitialized) {
- initBefore.show()
- } else {
- runGitOperation(GitOp.PUSH)
- }
- }
- R.id.git_pull -> {
- if (!PasswordRepository.isInitialized) {
- initBefore.show()
- } else {
- runGitOperation(GitOp.PULL)
- }
- }
- R.id.git_sync -> {
- if (!PasswordRepository.isInitialized) {
- initBefore.show()
- } else {
- runGitOperation(GitOp.SYNC)
- }
- }
- R.id.refresh -> refreshPasswordList()
- android.R.id.home -> {
- @Suppress("DEPRECATION") onBackPressed()
- }
- else -> return super.onOptionsItemSelected(item)
- }
- return true
- }
-
- @Deprecated("Deprecated in Java")
- @Suppress("DEPRECATION")
- override fun onBackPressed() {
- if (getPasswordFragment()?.onBackPressedInActivity() != true) super.onBackPressed()
- }
-
- private fun getPasswordFragment(): PasswordFragment? {
- return supportFragmentManager.findFragmentByTag(PASSWORD_FRAGMENT_TAG) as? PasswordFragment
- }
-
- fun clearSearch() {
- if (searchItem.isActionViewExpanded) searchItem.collapseActionView()
- }
-
- private fun runGitOperation(operation: GitOp) =
- lifecycleScope.launch {
- launchGitOperation(operation)
- .fold(success = { refreshPasswordList() }, failure = { promptOnErrorHandler(it) })
- }
-
- private fun checkLocalRepository() {
- PasswordRepository.initialize()
- checkLocalRepository(PasswordRepository.getRepositoryDirectory())
- }
-
- private fun checkLocalRepository(localDir: File?) {
- if (localDir != null && settings.getBoolean(PreferenceKeys.REPOSITORY_INITIALIZED, false)) {
- // do not push the fragment if we already have it
- if (
- getPasswordFragment() == null || settings.getBoolean(PreferenceKeys.REPO_CHANGED, false)
- ) {
- settings.edit { putBoolean(PreferenceKeys.REPO_CHANGED, false) }
- val args = Bundle()
- args.putString(REQUEST_ARG_PATH, PasswordRepository.getRepositoryDirectory().absolutePath)
-
- // if the activity was started from the autofill settings, the
- // intent is to match a clicked pwd with app. pass this to fragment
- if (intent.getBooleanExtra("matchWith", false)) {
- args.putBoolean("matchWith", true)
- }
- supportActionBar?.apply {
- show()
- setDisplayHomeAsUpEnabled(false)
- }
- supportFragmentManager.popBackStack(null, FragmentManager.POP_BACK_STACK_INCLUSIVE)
- supportFragmentManager.commit {
- replace(R.id.main_layout, PasswordFragment.newInstance(args), PASSWORD_FRAGMENT_TAG)
- }
- }
- } else {
- launchActivity(OnboardingActivity::class.java)
- }
- }
-
- private fun getRelativePath(fullPath: String, repositoryPath: String): String {
- return fullPath.replace(repositoryPath, "").replace("/+".toRegex(), "/")
- }
-
- fun decryptPassword(item: PasswordItem) {
- val authDecryptIntent = item.createAuthEnabledIntent(this)
- val decryptIntent =
- Intent(authDecryptIntent).setComponent(ComponentName(this, DecryptActivity::class.java))
-
- startActivity(decryptIntent)
-
- // Adds shortcut
- shortcutHandler.addDynamicShortcut(item, authDecryptIntent)
- }
-
- private fun validateState(): Boolean {
- if (!PasswordRepository.isInitialized) {
- MaterialAlertDialogBuilder(this)
- .setMessage(resources.getString(R.string.creation_dialog_text))
- .setPositiveButton(resources.getString(R.string.dialog_ok), null)
- .show()
- return false
- }
- return true
- }
-
- fun createPassword() {
- if (!validateState()) return
- val currentDir = currentDir
- logcat(INFO) { "Adding file to : ${currentDir.absolutePath}" }
- val intent = Intent(this, PasswordCreationActivity::class.java)
- intent.putExtra(BasePGPActivity.EXTRA_FILE_PATH, currentDir.absolutePath)
- intent.putExtra(
- BasePGPActivity.EXTRA_REPO_PATH,
- PasswordRepository.getRepositoryDirectory().absolutePath,
- )
- listRefreshAction.launch(intent)
- }
-
- fun createFolder() {
- if (!validateState()) return
- FolderCreationDialogFragment.newInstance(currentDir.path).show(supportFragmentManager, null)
- }
-
- fun deletePasswords(selectedItems: List<PasswordItem>) {
- var size = 0
- selectedItems.forEach {
- if (it.file.isFile) size++ else size += it.file.listFilesRecursively().size
- }
- if (size == 0) {
- selectedItems.map { item -> item.file.deleteRecursively() }
- refreshPasswordList()
- return
- }
- MaterialAlertDialogBuilder(this)
- .setMessage(resources.getQuantityString(R.plurals.delete_dialog_text, size, size))
- .setPositiveButton(resources.getString(R.string.dialog_yes)) { _, _ ->
- val filesToDelete = arrayListOf<File>()
- selectedItems.forEach { item ->
- if (item.file.isDirectory) filesToDelete.addAll(item.file.listFilesRecursively())
- else filesToDelete.add(item.file)
- }
- selectedItems.map { item -> item.file.deleteRecursively() }
- refreshPasswordList()
- AutofillMatcher.updateMatches(applicationContext, delete = filesToDelete)
- val fmt =
- selectedItems.joinToString(separator = ", ") { item ->
- item.file.toRelativeString(PasswordRepository.getRepositoryDirectory())
- }
- lifecycleScope.launch {
- commitChange(resources.getString(R.string.git_commit_remove_text, fmt))
- }
- }
- .setNegativeButton(resources.getString(R.string.dialog_no), null)
- .show()
- }
-
- fun movePasswords(values: List<PasswordItem>) {
- val intent = Intent(this, SelectFolderActivity::class.java)
- val fileLocations = values.map { it.file.absolutePath }.toTypedArray()
- intent.putExtra("Files", fileLocations)
- passwordMoveAction.launch(intent)
- }
-
- enum class CategoryRenameError(val resource: Int) {
- None(0),
- EmptyField(R.string.message_category_error_empty_field),
- CategoryExists(R.string.message_category_error_category_exists),
- DestinationOutsideRepo(R.string.message_error_destination_outside_repo),
- }
-
- /**
- * Prompt the user with a new category name to assign, if the new category forms/leads a path
- * (i.e. contains "/"), intermediate directories will be created and new category will be placed
- * inside.
- *
- * @param oldCategory The category to change its name
- * @param error Determines whether to show an error to the user in the alert dialog, this error
- * may be due to the new category the user entered already exists or the field was empty or the
- * destination path is outside the repository
- * @see [CategoryRenameError]
- * @see [isInsideRepository]
- */
- private fun renameCategory(
- oldCategory: PasswordItem,
- error: CategoryRenameError = CategoryRenameError.None,
- ) {
- val view = layoutInflater.inflate(R.layout.folder_dialog_fragment, null)
- val newCategoryEditText = view.findViewById<TextInputEditText>(R.id.folder_name_text)
-
- if (error != CategoryRenameError.None) {
- newCategoryEditText.error = getString(error.resource)
- }
-
- val dialog =
- MaterialAlertDialogBuilder(this)
- .setTitle(R.string.title_rename_folder)
- .setView(view)
- .setMessage(getString(R.string.message_rename_folder, oldCategory.name))
- .setPositiveButton(R.string.dialog_ok) { _, _ ->
- val newCategory = File("${oldCategory.file.parent}/${newCategoryEditText.text}")
- when {
- newCategoryEditText.text.isNullOrBlank() ->
- renameCategory(oldCategory, CategoryRenameError.EmptyField)
- newCategory.exists() -> renameCategory(oldCategory, CategoryRenameError.CategoryExists)
- !newCategory.isInsideRepository() ->
- renameCategory(oldCategory, CategoryRenameError.DestinationOutsideRepo)
- else ->
- lifecycleScope.launch(dispatcherProvider.io()) {
- moveFile(oldCategory.file, newCategory)
-
- // associate the new category with the last category's timestamp in
- // history
- val preference =
- getSharedPreferences("recent_password_history", Context.MODE_PRIVATE)
- val timestamp = preference.getString(oldCategory.file.absolutePath.base64())
- if (timestamp != null) {
- preference.edit {
- remove(oldCategory.file.absolutePath.base64())
- putString(newCategory.absolutePath.base64(), timestamp)
- }
- }
-
- withContext(dispatcherProvider.main()) {
- commitChange(
- resources.getString(
- R.string.git_commit_move_text,
- oldCategory.name,
- newCategory.name,
- )
- )
- }
- }
- }
- }
- .setNegativeButton(R.string.dialog_skip, null)
- .create()
-
- dialog.window?.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_STATE_ALWAYS_VISIBLE)
- dialog.show()
- }
-
- fun renameCategory(categories: List<PasswordItem>) {
- for (oldCategory in categories) {
- renameCategory(oldCategory)
- }
- }
-
- /**
- * Refreshes the password list by re-executing the last navigation or search action, preserving
- * the navigation stack and scroll position. If the current directory no longer exists, navigation
- * is reset to the repository root. If the optional [target] argument is provided, it will be
- * entered if it is a directory or scrolled into view if it is a file (both inside the current
- * directory).
- */
- fun refreshPasswordList(target: File? = null) {
- val plist = getPasswordFragment()
- if (target?.isDirectory == true && model.currentDir.value.contains(target)) {
- plist?.navigateTo(target)
- } else if (target?.isFile == true && model.currentDir.value.contains(target)) {
- // Creating new passwords is handled by an activity, so we will refresh in onStart.
- plist?.scrollToOnNextRefresh(target)
- } else if (model.currentDir.value.isDirectory) {
- model.forceRefresh()
- } else {
- model.reset()
- supportActionBar?.setDisplayHomeAsUpEnabled(false)
- }
- }
-
- private val currentDir: File
- get() = getPasswordFragment()?.currentDir ?: PasswordRepository.getRepositoryDirectory()
-
- private suspend fun moveFile(source: File, destinationFile: File) {
- val sourceDestinationMap =
- if (source.isDirectory) {
- destinationFile.mkdirs()
- // Recursively list all files (not directories) below `source`, then
- // obtain the corresponding target file by resolving the relative path
- // starting at the destination folder.
- source.listFilesRecursively().associateWith {
- destinationFile.resolve(it.relativeTo(source))
- }
- } else {
- mapOf(source to destinationFile)
- }
- if (!source.renameTo(destinationFile)) {
- logcat(ERROR) { "Something went wrong while moving $source to $destinationFile." }
- withContext(dispatcherProvider.main()) {
- MaterialAlertDialogBuilder(this@PasswordStore)
- .setTitle(R.string.password_move_error_title)
- .setMessage(getString(R.string.password_move_error_message, source, destinationFile))
- .setCancelable(true)
- .setPositiveButton(android.R.string.ok, null)
- .show()
- }
- } else {
- AutofillMatcher.updateMatches(this, sourceDestinationMap)
- }
- }
-
- fun matchPasswordWithApp(item: PasswordItem) {
- val path =
- item.file.absolutePath
- .replace(PasswordRepository.getRepositoryDirectory().toString() + "/", "")
- .replace(".gpg", "")
- val data = Intent()
- data.putExtra("path", path)
- setResult(RESULT_OK, data)
- finish()
- }
-
- companion object {
-
- const val REQUEST_ARG_PATH = "PATH"
-
- private fun isPrintable(c: Char): Boolean {
- val block = UnicodeBlock.of(c)
- return (!Character.isISOControl(c) && block != null && block !== UnicodeBlock.SPECIALS)
- }
- }
-}
diff --git a/app/src/main/java/app/passwordstore/ui/pgp/PGPKeyImportActivity.kt b/app/src/main/java/app/passwordstore/ui/pgp/PGPKeyImportActivity.kt
deleted file mode 100644
index d276c552..00000000
--- a/app/src/main/java/app/passwordstore/ui/pgp/PGPKeyImportActivity.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
- */
-@file:Suppress("BlockingMethodInNonBlockingContext")
-
-package app.passwordstore.ui.pgp
-
-import android.os.Bundle
-import androidx.activity.result.contract.ActivityResultContracts.OpenDocument
-import androidx.appcompat.app.AppCompatActivity
-import app.passwordstore.R
-import app.passwordstore.crypto.KeyUtils.tryGetId
-import app.passwordstore.crypto.PGPKey
-import app.passwordstore.crypto.PGPKeyManager
-import app.passwordstore.crypto.errors.KeyAlreadyExistsException
-import com.github.michaelbull.result.Result
-import com.github.michaelbull.result.runCatching
-import com.google.android.material.dialog.MaterialAlertDialogBuilder
-import dagger.hilt.android.AndroidEntryPoint
-import javax.inject.Inject
-import kotlinx.coroutines.runBlocking
-
-@AndroidEntryPoint
-class PGPKeyImportActivity : AppCompatActivity() {
-
- /**
- * A [ByteArray] containing the contents of the previously selected file. This is necessary for
- * the replacement case where we do not want users to have to pick the file again.
- */
- private var lastBytes: ByteArray? = null
- @Inject lateinit var keyManager: PGPKeyManager
-
- private val pgpKeyImportAction =
- registerForActivityResult(OpenDocument()) { uri ->
- runCatching {
- if (uri == null) {
- return@runCatching null
- }
- val keyInputStream =
- contentResolver.openInputStream(uri)
- ?: throw IllegalStateException("Failed to open selected file")
- val bytes = keyInputStream.use { `is` -> `is`.readBytes() }
- importKey(bytes, false)
- }
- .run(::handleImportResult)
- }
-
- override fun onCreate(savedInstanceState: Bundle?) {
- super.onCreate(savedInstanceState)
- pgpKeyImportAction.launch(arrayOf("*/*"))
- }
-
- override fun onDestroy() {
- lastBytes = null
- super.onDestroy()
- }
-
- private fun importKey(bytes: ByteArray, replace: Boolean): PGPKey? {
- lastBytes = bytes
- val (key, error) = runBlocking { keyManager.addKey(PGPKey(bytes), replace = replace) }
- if (replace) {
- lastBytes = null
- }
- if (error != null) throw error
- return key
- }
-
- private fun handleImportResult(result: Result<PGPKey?, Throwable>) {
- if (result.isOk) {
- val key = result.value
- if (key == null) {
- setResult(RESULT_CANCELED)
- finish()
- // This return convinces Kotlin that the control flow for `key == null` definitely
- // terminates here and allows for a smart cast below.
- return
- }
- MaterialAlertDialogBuilder(this)
- .setTitle(getString(R.string.pgp_key_import_succeeded))
- .setMessage(getString(R.string.pgp_key_import_succeeded_message, tryGetId(key)))
- .setPositiveButton(android.R.string.ok) { _, _ ->
- setResult(RESULT_OK)
- finish()
- }
- .setCancelable(false)
- .show()
- } else {
- if (result.error is KeyAlreadyExistsException && lastBytes != null) {
- MaterialAlertDialogBuilder(this)
- .setTitle(getString(R.string.pgp_key_import_failed))
- .setMessage(getString(R.string.pgp_key_import_failed_replace_message))
- .setPositiveButton(R.string.dialog_yes) { _, _ ->
- handleImportResult(runCatching { importKey(lastBytes!!, replace = true) })
- }
- .setNegativeButton(R.string.dialog_no) { _, _ -> finish() }
- .setCancelable(false)
- .show()
- } else {
- MaterialAlertDialogBuilder(this)
- .setTitle(getString(R.string.pgp_key_import_failed))
- .setMessage(result.error.message)
- .setPositiveButton(android.R.string.ok) { _, _ -> finish() }
- .setCancelable(false)
- .show()
- }
- }
- }
-}
diff --git a/app/src/main/java/app/passwordstore/ui/pgp/PGPKeyList.kt b/app/src/main/java/app/passwordstore/ui/pgp/PGPKeyList.kt
deleted file mode 100644
index 312f5fb0..00000000
--- a/app/src/main/java/app/passwordstore/ui/pgp/PGPKeyList.kt
+++ /dev/null
@@ -1,169 +0,0 @@
-/*
- * Copyright © 2014-2024 The Android Password Store Authors. All Rights Reserved.
- * SPDX-License-Identifier: GPL-3.0-only
- */
-
-package app.passwordstore.ui.pgp
-
-import androidx.compose.foundation.Image
-import androidx.compose.foundation.background
-import androidx.compose.foundation.clickable
-import androidx.compose.foundation.layout.Arrangement
-import androidx.compose.foundation.layout.Box
-import androidx.compose.foundation.layout.Column
-import androidx.compose.foundation.layout.Row
-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.material3.AlertDialog
-import androidx.compose.material3.Icon
-import androidx.compose.material3.IconButton
-import androidx.compose.material3.MaterialTheme
-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.remember
-import androidx.compose.runtime.setValue
-import androidx.compose.ui.Alignment
-import androidx.compose.ui.Modifier
-import androidx.compose.ui.res.painterResource
-import androidx.compose.ui.res.stringResource
-import androidx.compose.ui.text.style.TextOverflow
-import androidx.compose.ui.tooling.preview.Preview
-import app.passwordstore.R
-import app.passwordstore.crypto.PGPIdentifier
-import app.passwordstore.ui.compose.theme.APSTheme
-import app.passwordstore.ui.compose.theme.SpacingLarge
-import app.passwordstore.util.extensions.conditional
-import kotlinx.collections.immutable.ImmutableList
-import kotlinx.collections.immutable.persistentListOf
-import kotlinx.collections.immutable.toPersistentList
-
-@Composable
-fun KeyList(
- identifiers: ImmutableList<PGPIdentifier>,
- onItemClick: (identifier: PGPIdentifier) -> Unit,
- modifier: Modifier = Modifier,
- onKeySelected: ((identifier: PGPIdentifier) -> Unit)? = null,
-) {
- if (identifiers.isEmpty()) {
- Column(
- modifier = modifier.fillMaxSize(),
- verticalArrangement = Arrangement.Center,
- horizontalAlignment = Alignment.CenterHorizontally,
- ) {
- Image(
- painter = painterResource(id = R.drawable.ic_launcher_foreground),
- contentDescription = "Password Store logo",
- )
- Text(stringResource(R.string.pgp_key_manager_no_keys_guidance))
- }
- } else {
- LazyColumn(modifier = modifier) {
- items(identifiers) { identifier ->
- KeyItem(identifier = identifier, onItemClick = onItemClick, onKeySelected = onKeySelected)
- }
- }
- }
-}
-
-@Composable
-private fun KeyItem(
- identifier: PGPIdentifier,
- onItemClick: (identifier: PGPIdentifier) -> Unit,
- modifier: Modifier = Modifier,
- onKeySelected: ((identifier: PGPIdentifier) -> Unit)? = null,
-) {
- var isDeleting by remember { mutableStateOf(false) }
- DeleteConfirmationDialog(
- isDeleting = isDeleting,
- onDismiss = { isDeleting = false },
- onConfirm = {
- onItemClick(identifier)
- isDeleting = false
- },
- )
- val label =
- when (identifier) {
- is PGPIdentifier.KeyId -> identifier.id.toString()
- is PGPIdentifier.UserId -> identifier.email
- }
- Row(
- modifier =
- modifier.padding(SpacingLarge).fillMaxWidth().conditional(onKeySelected != null) {
- clickable { onKeySelected?.invoke(identifier) }
- },
- horizontalArrangement = Arrangement.SpaceBetween,
- verticalAlignment = Alignment.CenterVertically,
- ) {
- Text(
- text = label,
- modifier = Modifier.weight(1f),
- overflow = TextOverflow.Ellipsis,
- maxLines = 1,
- )
- if (onKeySelected == null) {
- IconButton(onClick = { isDeleting = true }) {
- Icon(
- painter = painterResource(R.drawable.ic_delete_24dp),
- stringResource(id = R.string.delete),
- )
- }
- }
- }
-}
-
-@Suppress("NOTHING_TO_INLINE")
-@Composable
-private inline fun DeleteConfirmationDialog(
- isDeleting: Boolean,
- noinline onDismiss: () -> Unit,
- noinline onConfirm: () -> Unit,
-) {
- if (isDeleting) {
- AlertDialog(
- onDismissRequest = onDismiss,
- title = {
- Text(text = stringResource(R.string.pgp_key_manager_delete_confirmation_dialog_title))
- },
- confirmButton = {
- TextButton(onClick = onConfirm) { Text(text = stringResource(R.string.dialog_yes)) }
- },
- dismissButton = {
- TextButton(onClick = onDismiss) { Text(text = stringResource(R.string.dialog_no)) }
- },
- )
- }
-}
-
-@Preview
-@Composable
-private fun KeyListPreview() {
- APSTheme {
- Box(modifier = Modifier.background(MaterialTheme.colorScheme.background)) {
- KeyList(
- identifiers =
- listOfNotNull(
- PGPIdentifier.fromString("ultramicroscopicsilicovolcanoconiosis@example.com"),
- PGPIdentifier.fromString("0xB950AE2813841585"),
- )
- .toPersistentList(),
- onItemClick = {},
- )
- }
- }
-}
-
-@Preview
-@Composable
-private fun EmptyKeyListPreview() {
- APSTheme {
- Box(modifier = Modifier.background(MaterialTheme.colorScheme.background)) {
- KeyList(identifiers = persistentListOf(), onItemClick = {})
- }
- }
-}
diff --git a/app/src/main/java/app/passwordstore/ui/pgp/PGPKeyListActivity.kt b/app/src/main/java/app/passwordstore/ui/pgp/PGPKeyListActivity.kt
deleted file mode 100644
index 77b9aaf1..00000000
--- a/app/src/main/java/app/passwordstore/ui/pgp/PGPKeyListActivity.kt
+++ /dev/null
@@ -1,94 +0,0 @@
-/*
- * Copyright © 2014-2024 The Android Password Store Authors. All Rights Reserved.
- * SPDX-License-Identifier: GPL-3.0-only
- */
-
-package app.passwordstore.ui.pgp
-
-import android.content.Context
-import android.content.Intent
-import android.os.Bundle
-import androidx.activity.ComponentActivity
-import androidx.activity.compose.setContent
-import androidx.activity.result.contract.ActivityResultContracts.StartActivityForResult
-import androidx.activity.viewModels
-import androidx.compose.foundation.layout.padding
-import androidx.compose.material3.FloatingActionButton
-import androidx.compose.material3.Icon
-import androidx.compose.material3.MaterialTheme
-import androidx.compose.material3.Scaffold
-import androidx.compose.ui.Modifier
-import androidx.compose.ui.res.painterResource
-import androidx.compose.ui.res.stringResource
-import app.passwordstore.R
-import app.passwordstore.ui.APSAppBar
-import app.passwordstore.ui.compose.theme.APSTheme
-import app.passwordstore.util.viewmodel.PGPKeyListViewModel
-import dagger.hilt.android.AndroidEntryPoint
-
-@AndroidEntryPoint
-class PGPKeyListActivity : ComponentActivity() {
-
- private val viewModel: PGPKeyListViewModel by viewModels()
- private val keyImportAction =
- registerForActivityResult(StartActivityForResult()) {
- if (it.resultCode == RESULT_OK) {
- viewModel.updateKeySet()
- }
- }
-
- override fun onCreate(savedInstanceState: Bundle?) {
- super.onCreate(savedInstanceState)
- val isSelecting = intent.extras?.getBoolean(EXTRA_KEY_SELECTION) ?: false
- setContent {
- APSTheme {
- Scaffold(
- topBar = {
- APSAppBar(
- title = stringResource(R.string.activity_label_pgp_key_manager),
- navigationIcon = painterResource(R.drawable.ic_arrow_back_black_24dp),
- onNavigationIconClick = { finish() },
- backgroundColor = MaterialTheme.colorScheme.surface,
- )
- },
- floatingActionButton = {
- FloatingActionButton(
- onClick = { keyImportAction.launch(Intent(this, PGPKeyImportActivity::class.java)) }
- ) {
- Icon(
- painter = painterResource(R.drawable.ic_add_48dp),
- stringResource(R.string.pref_import_pgp_key_title),
- )
- }
- },
- ) { paddingValues ->
- KeyList(
- identifiers = viewModel.keys,
- onItemClick = viewModel::deleteKey,
- modifier = Modifier.padding(paddingValues),
- onKeySelected =
- if (isSelecting) {
- { identifier ->
- val result = Intent()
- result.putExtra(EXTRA_SELECTED_KEY, identifier.toString())
- setResult(RESULT_OK, result)
- finish()
- }
- } else null,
- )
- }
- }
- }
- }
-
- companion object {
- const val EXTRA_SELECTED_KEY = "SELECTED_KEY"
- private const val EXTRA_KEY_SELECTION = "KEY_SELECTION_MODE"
-
- fun newSelectionActivity(context: Context): Intent {
- val intent = Intent(context, PGPKeyListActivity::class.java)
- intent.putExtra(EXTRA_KEY_SELECTION, true)
- return intent
- }
- }
-}
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..d008eea3
--- /dev/null
+++ b/app/src/main/java/app/passwordstore/ui/settings/GitConfigScreen.kt
@@ -0,0 +1,329 @@
+/*
+ * 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.pref_edit_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()
+ // BreakOutOfDetached names the pushed branch conflicting-<branch>-<millis>: the newest one.
+ val pushed =
+ PasswordRepository.repository
+ ?.refDatabase
+ ?.getRefsByPrefix("refs/heads/conflicting-$aborted-")
+ ?.maxOfOrNull { it.name.removePrefix("refs/heads/") } ?: "conflicting-$aborted-..."
+ SetupDialog(
+ title = stringResource(R.string.git_abort_and_push_title),
+ text = stringResource(R.string.git_break_out_of_detached_success, aborted, pushed),
+ 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 d2e63779..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.pgp.PGPKeyListActivity
-import app.passwordstore.util.auth.BiometricAuthenticator
-import app.passwordstore.util.extensions.launchActivity
-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.launchActivity(PGPKeyListActivity::class.java)
- 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 fac95949..00000000
--- a/app/src/main/java/app/passwordstore/ui/settings/RepositorySettings.kt
+++ /dev/null
@@ -1,196 +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.Intent
-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.git.config.GitServerConfigActivity
-import app.passwordstore.ui.proxy.ProxySelectorActivity
-import app.passwordstore.ui.sshkeygen.ShowSshKeyFragment
-import app.passwordstore.ui.sshkeygen.SshKeyGenActivity
-import app.passwordstore.ui.sshkeygen.SshKeyImportActivity
-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.launchActivity(GitServerConfigActivity::class.java)
- 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 {
- activity.launchActivity(SshKeyImportActivity::class.java)
- true
- }
- }
- pref(PreferenceKeys.SSH_KEYGEN) {
- titleRes = R.string.pref_ssh_keygen_title
- onClick {
- generateSshKey.launch(Intent(activity, SshKeyGenActivity::class.java))
- true
- }
- }
- showSshKeyPref =
- pref(PreferenceKeys.SSH_SEE_KEY) {
- titleRes = R.string.pref_ssh_see_key_title
- visible = PasswordRepository.isGitRepo() && SshKey.canShowSshPublicKey
- onClick {
- ShowSshKeyFragment().show(activity.supportFragmentManager, "public_key")
- 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..c3809b36
--- /dev/null
+++ b/app/src/main/java/app/passwordstore/ui/settings/SettingsDestination.kt
@@ -0,0 +1,304 @@
+/*
+ * 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()
+ // hasEnabledAutofillServices() still answers true right after this call.
+ autofillEnabled = false
+ }
+ 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/GitServerScreen.kt b/app/src/main/java/app/passwordstore/ui/setup/GitServerScreen.kt
new file mode 100644
index 00000000..fd440996
--- /dev/null
+++ b/app/src/main/java/app/passwordstore/ui/setup/GitServerScreen.kt
@@ -0,0 +1,345 @@
+/*
+ * Copyright (C) 2026 Danilo M. <danix@danix.xyz>
+ * SPDX-License-Identifier: GPL-3.0-only
+ */
+
+package app.passwordstore.ui.setup
+
+import android.widget.Toast
+import androidx.activity.compose.BackHandler
+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.layout.Row
+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.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.SegmentedButton
+import androidx.compose.material3.SegmentedButtonDefaults
+import androidx.compose.material3.SingleChoiceSegmentedButtonRow
+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.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.LocalSoftwareKeyboardController
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.input.ImeAction
+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.util.extensions.snackbar
+import app.passwordstore.util.settings.AuthMode
+import app.passwordstore.util.settings.GitSettings.UpdateConnectionSettingsResult
+import app.passwordstore.util.settings.Protocol
+import com.github.michaelbull.result.fold
+import com.google.android.material.snackbar.Snackbar
+import kotlinx.coroutines.launch
+
+/**
+ * Server URL and authentication mode; the SSH key's state ([sshKey], a string resource) with Manage
+ * while the mode is SSH key; "Clear saved host key" when one is saved. [progress] (a string
+ * resource) shows while a clone runs, and disables everything.
+ */
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun GitServerScreen(
+ clone: Boolean,
+ url: String,
+ authMode: AuthMode,
+ @StringRes sshKey: Int,
+ hasHostKey: Boolean,
+ @StringRes progress: Int?,
+ onUrlChange: (String) -> Unit,
+ onAuthModeChange: (AuthMode) -> Unit,
+ onSshKey: () -> Unit,
+ onClearHostKey: () -> Unit,
+ onSubmit: () -> Unit,
+ onBack: () -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ val idle = progress == null
+ val modes = remember(url) { authModes(url.trim()) }
+ Scaffold(
+ modifier = modifier,
+ topBar = {
+ TopAppBar(
+ title = { Text(stringResource(R.string.title_activity_git_clone)) },
+ 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),
+ ) {
+ OutlinedTextField(
+ value = url,
+ onValueChange = onUrlChange,
+ modifier = Modifier.fillMaxWidth(),
+ enabled = idle,
+ label = { Text(stringResource(R.string.server_url)) },
+ singleLine = true,
+ keyboardOptions =
+ KeyboardOptions(
+ keyboardType = KeyboardType.Uri,
+ autoCorrectEnabled = false,
+ imeAction = ImeAction.Done,
+ ),
+ )
+ Text(stringResource(R.string.connection_mode), style = MaterialTheme.typography.labelLarge)
+ SingleChoiceSegmentedButtonRow(Modifier.fillMaxWidth()) {
+ modes.forEachIndexed { index, mode ->
+ SegmentedButton(
+ selected = mode == authMode,
+ onClick = { onAuthModeChange(mode) },
+ shape = SegmentedButtonDefaults.itemShape(index, modes.size),
+ enabled = idle,
+ ) {
+ Text(stringResource(mode.label()))
+ }
+ }
+ }
+ if (authMode == AuthMode.SshKey)
+ Row(verticalAlignment = Alignment.CenterVertically) {
+ Column(Modifier.weight(1f)) {
+ Text(
+ stringResource(R.string.connection_mode_ssh_key),
+ style = MaterialTheme.typography.labelMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ Text(stringResource(sshKey))
+ }
+ TextButton(onClick = onSshKey, enabled = idle) {
+ Text(stringResource(R.string.ssh_key_manage))
+ }
+ }
+ if (hasHostKey)
+ OutlinedButton(onClick = onClearHostKey, enabled = idle) {
+ Text(stringResource(R.string.clear_saved_host_key))
+ }
+ if (progress != null) {
+ LinearProgressIndicator(Modifier.fillMaxWidth())
+ Text(stringResource(progress))
+ }
+ Button(onClick = onSubmit, modifier = Modifier.fillMaxWidth(), enabled = idle) {
+ Text(stringResource(if (clone) R.string.clone_button else R.string.crypto_save))
+ }
+ }
+ }
+}
+
+@StringRes
+private fun AuthMode.label(): Int =
+ when (this) {
+ AuthMode.SshKey -> R.string.connection_mode_ssh_key
+ AuthMode.Password -> R.string.connection_mode_basic_authentication
+ AuthMode.None -> R.string.connection_mode_none
+ }
+
+private sealed interface ServerDialog {
+ data class Url(val problem: UrlProblem) : ServerDialog
+
+ data class Username(val protocol: Protocol) : ServerDialog
+
+ data object DeleteStore : ServerDialog
+}
+
+/**
+ * The git server screen: wires [GitServerScreen] to the [viewModel], the URL checks, the save and,
+ * with [clone], the clone into the store folder. [onCloned] gets whether the store still needs a
+ * PGP key. A failed clone stays here, so URL or SSH key can be fixed and the clone retried.
+ */
+@Composable
+fun GitServerDestination(
+ activity: MainActivity,
+ clone: Boolean,
+ onClose: () -> Unit,
+ onSshKey: () -> Unit,
+ onCloned: (keyNeeded: Boolean) -> Unit,
+ modifier: Modifier = Modifier,
+ viewModel: GitServerViewModel = hiltViewModel(),
+) {
+ val root = PasswordRepository.getRepositoryDirectory()
+ val keyboard = LocalSoftwareKeyboardController.current
+ var dialog by remember { mutableStateOf<ServerDialog?>(null) }
+
+ // Back from the SSH key screen (or from an instance it opened) with another key.
+ LifecycleResumeEffect(Unit) {
+ viewModel.refresh()
+ onPauseOrDispose {}
+ }
+ // A finished clone moves on from here, also when the screen was recreated in between.
+ val cloned = viewModel.cloned
+ LaunchedEffect(cloned) { if (cloned != null) onCloned(cloned) }
+ // A running clone holds the screen: leaving would cancel it half way.
+ BackHandler(enabled = viewModel.progress != null) {}
+
+ fun say(message: String) {
+ activity.snackbar(message = message, length = Snackbar.LENGTH_LONG)
+ }
+
+ // ponytail: a theme or locale change cancels the clone with the activity's scope, like every
+ // git operation of the app; the finally block frees the screen, and a new clone first offers to
+ // delete what was left. Once the clone succeeded, the key check runs in the ViewModel's scope
+ // (finishClone), so a recreation from then on loses nothing.
+ fun startClone() {
+ viewModel.progress = R.string.git_clone_progress
+ activity.lifecycleScope.launch {
+ try {
+ // A folder that could not be emptied is reported as such, not as a vague clone error.
+ viewModel.clearStore(root)?.let { failure ->
+ say(activity.getString(failure))
+ return@launch
+ }
+ activity
+ .launchGitOperation(GitOp.CLONE)
+ .fold(
+ success = { viewModel.finishClone(root) },
+ failure = { activity.promptOnErrorHandler(it) },
+ )
+ } finally {
+ viewModel.cloneEnded()
+ }
+ }
+ }
+
+ fun submit() {
+ if (viewModel.progress != null) return
+ keyboard?.hide()
+ val problem = urlProblem(viewModel.url.trim())
+ if (problem != null) {
+ dialog = ServerDialog.Url(problem)
+ return
+ }
+ when (val result = viewModel.save()) {
+ UpdateConnectionSettingsResult.FailedToParseUrl ->
+ say(activity.getString(R.string.git_server_config_save_error))
+ is UpdateConnectionSettingsResult.MissingUsername ->
+ dialog = ServerDialog.Username(result.newProtocol)
+ is UpdateConnectionSettingsResult.AuthModeMismatch ->
+ say(
+ activity.getString(
+ R.string.git_server_config_save_auth_mode_mismatch,
+ result.newProtocol,
+ result.validModes.joinToString(", "),
+ )
+ )
+ UpdateConnectionSettingsResult.Valid ->
+ if (!clone) {
+ Toast.makeText(activity, R.string.git_server_config_save_success, Toast.LENGTH_SHORT)
+ .show()
+ onClose()
+ } else {
+ if (PasswordRepository.repository == null) PasswordRepository.initialize()
+ if (cloneTarget(root) == CloneTarget.Occupied) dialog = ServerDialog.DeleteStore
+ else startClone()
+ }
+ }
+ }
+
+ Box(modifier) {
+ GitServerScreen(
+ clone = clone,
+ url = viewModel.url,
+ authMode = viewModel.authMode,
+ sshKey = viewModel.sshKey,
+ hasHostKey = viewModel.hasHostKey,
+ progress = viewModel.progress,
+ onUrlChange = viewModel::changeUrl,
+ onAuthModeChange = { viewModel.authMode = it },
+ onSshKey = onSshKey,
+ onClearHostKey = {
+ viewModel.clearHostKey()
+ say(activity.getString(R.string.clear_saved_host_key_success))
+ },
+ onSubmit = ::submit,
+ onBack = { if (viewModel.progress == null) onClose() },
+ )
+ when (val current = dialog) {
+ null -> Unit
+ is ServerDialog.Url -> {
+ val (title, text) =
+ when (current.problem) {
+ UrlProblem.HttpsPort ->
+ R.string.https_scheme_with_port_title to R.string.https_scheme_with_port_message
+ UrlProblem.SshScheme ->
+ R.string.ssh_scheme_needed_title to R.string.ssh_scheme_needed_message
+ UrlProblem.GitScheme ->
+ R.string.git_scheme_disallowed_title to R.string.git_scheme_disallowed_message
+ }
+ val fixed = fixUrl(viewModel.url.trim(), current.problem)
+ SetupDialog(
+ title = stringResource(title),
+ text = stringResource(text),
+ confirm = stringResource(R.string.dialog_ok),
+ onConfirm = {
+ dialog = null
+ fixed?.let(viewModel::changeUrl)
+ },
+ onDismiss = { dialog = null },
+ dismiss = if (fixed != null) stringResource(R.string.dialog_cancel) else null,
+ )
+ }
+ is ServerDialog.Username ->
+ SetupDialog(
+ title = stringResource(R.string.ssh_scheme_needed_title),
+ text =
+ stringResource(
+ if (current.protocol == Protocol.Https)
+ R.string.git_server_config_save_missing_username_https
+ else R.string.git_server_config_save_missing_username_ssh
+ ),
+ confirm = stringResource(R.string.dialog_ok),
+ onConfirm = { dialog = null },
+ onDismiss = { dialog = null },
+ )
+ ServerDialog.DeleteStore ->
+ SetupDialog(
+ title = stringResource(R.string.dialog_delete_title),
+ text = stringResource(R.string.dialog_delete_msg, root.toString()),
+ confirm = stringResource(R.string.dialog_delete),
+ onConfirm = {
+ dialog = null
+ startClone()
+ },
+ onDismiss = { dialog = null },
+ dismiss = stringResource(R.string.dialog_do_not_delete),
+ )
+ }
+ }
+}
diff --git a/app/src/main/java/app/passwordstore/ui/setup/GitServerViewModel.kt b/app/src/main/java/app/passwordstore/ui/setup/GitServerViewModel.kt
new file mode 100644
index 00000000..5995bf5b
--- /dev/null
+++ b/app/src/main/java/app/passwordstore/ui/setup/GitServerViewModel.kt
@@ -0,0 +1,130 @@
+/*
+ * Copyright (C) 2026 Danilo M. <danix@danix.xyz>
+ * SPDX-License-Identifier: GPL-3.0-only
+ */
+
+package app.passwordstore.ui.setup
+
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableIntStateOf
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.setValue
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import app.passwordstore.R
+import app.passwordstore.crypto.KeyUtils
+import app.passwordstore.crypto.PGPKeyManager
+import app.passwordstore.ui.list.deleteWithoutFollowingLinks
+import app.passwordstore.util.coroutines.DispatcherProvider
+import app.passwordstore.util.settings.GitSettings
+import com.github.michaelbull.result.get
+import dagger.hilt.android.lifecycle.HiltViewModel
+import java.io.File
+import javax.inject.Inject
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.withContext
+
+/**
+ * The git server screen's state: URL and mode being edited, the clone's [progress] and outcome
+ * ([cloned]), the host key and SSH key state. The clone itself runs in the destination (it needs
+ * the activity); this ViewModel keeps what must survive a recreation.
+ */
+@HiltViewModel
+class GitServerViewModel
+@Inject
+constructor(
+ private val gitSettings: GitSettings,
+ private val keyManager: PGPKeyManager,
+ private val dispatcherProvider: DispatcherProvider,
+) : ViewModel() {
+
+ var url by mutableStateOf(gitSettings.url.orEmpty())
+ private set
+
+ var authMode by mutableStateOf(fitAuthMode(gitSettings.url.orEmpty(), gitSettings.authMode))
+
+ /** What a running clone does, as a string resource; null when idle. */
+ var progress by mutableStateOf<Int?>(null)
+
+ /** Null until a clone succeeded; then whether the store still needs a PGP key imported. */
+ var cloned by mutableStateOf<Boolean?>(null)
+ private set
+
+ var hasHostKey by mutableStateOf(false)
+ private set
+
+ var sshKey by mutableIntStateOf(R.string.ssh_key_state_none)
+ private set
+
+ init {
+ refresh()
+ }
+
+ /** Re-reads what other screens change: the saved host key and the SSH key. */
+ fun refresh() {
+ hasHostKey = gitSettings.hasSavedHostKey()
+ sshKey = sshKeySummary()
+ }
+
+ fun changeUrl(value: String) {
+ url = value
+ authMode = fitAuthMode(value.trim(), authMode)
+ }
+
+ fun clearHostKey() {
+ gitSettings.clearSavedHostKey()
+ hasHostKey = false
+ }
+
+ /** Validates and stores URL and mode (unchanged rules in [GitSettings]). */
+ fun save(): GitSettings.UpdateConnectionSettingsResult =
+ gitSettings.updateConnectionSettingsIfValid(newAuthMode = authMode, newUrl = url.trim())
+
+ /**
+ * Empties the store folder at [root] for a clone, unless it is empty already. Never follows a
+ * symlink: `deleteRecursively()` would, and a `store/link -> ../keys` link would take the PGP
+ * keys with it. Returns null, or why it failed as a string resource: then the clone must not
+ * start.
+ */
+ suspend fun clearStore(root: File): Int? {
+ if (withContext(dispatcherProvider.io()) { cloneTarget(root) } == CloneTarget.Empty) return null
+ progress = R.string.delete_directory_progress_text
+ if (!withContext(dispatcherProvider.io()) { root.deleteWithoutFollowingLinks() })
+ return R.string.git_clone_delete_failed
+ progress = R.string.git_clone_progress
+ return null
+ }
+
+ /** Set while [finishClone]'s key check runs: the clone's [progress] stays up until it is done. */
+ private var checking = false
+
+ /**
+ * After a successful clone into [root], checks in [viewModelScope] whether its key is missing and
+ * records the answer in [cloned]: no recipient of the root `.gpg-id` is an imported secret key
+ * (without recipients: no secret key is imported at all). A public key alone cannot decrypt. In
+ * [viewModelScope], so a recreation during the check cannot lose the answer.
+ */
+ fun finishClone(root: File) {
+ checking = true
+ viewModelScope.launch {
+ try {
+ cloned =
+ withContext(dispatcherProvider.io()) {
+ val ids = readGpgId(root)?.let(::storeRecipients).orEmpty()
+ if (ids.isEmpty()) keyManager.getAllKeys().get().orEmpty().none(KeyUtils::isSecretKey)
+ else
+ ids.none { id -> keyManager.getKeyById(id).get()?.let(KeyUtils::isSecretKey) == true }
+ }
+ } finally {
+ checking = false
+ progress = null
+ }
+ }
+ }
+
+ /** The clone's coroutine ended (done, failed or cancelled): free the screen, re-read state. */
+ fun cloneEnded() {
+ if (!checking) progress = null
+ refresh()
+ }
+}
diff --git a/app/src/main/java/app/passwordstore/ui/setup/PgpKeysScreen.kt b/app/src/main/java/app/passwordstore/ui/setup/PgpKeysScreen.kt
new file mode 100644
index 00000000..11fd979c
--- /dev/null
+++ b/app/src/main/java/app/passwordstore/ui/setup/PgpKeysScreen.kt
@@ -0,0 +1,337 @@
+/*
+ * Copyright (C) 2026 Danilo M. <danix@danix.xyz>
+ * SPDX-License-Identifier: GPL-3.0-only
+ */
+
+package app.passwordstore.ui.setup
+
+import androidx.activity.compose.BackHandler
+import androidx.activity.compose.rememberLauncherForActivityResult
+import androidx.activity.result.contract.ActivityResultContracts.OpenDocument
+import androidx.annotation.Keep
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+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.material.icons.filled.Add
+import androidx.compose.material.icons.filled.Delete
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.ExtendedFloatingActionButton
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.LinearProgressIndicator
+import androidx.compose.material3.MaterialTheme
+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.remember
+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.semantics.contentDescription
+import androidx.compose.ui.semantics.semantics
+import androidx.compose.ui.text.font.FontFamily
+import androidx.compose.ui.text.style.TextOverflow
+import androidx.compose.ui.unit.dp
+import androidx.hilt.navigation.compose.hiltViewModel
+import androidx.lifecycle.lifecycleScope
+import app.passwordstore.R
+import app.passwordstore.data.repo.PasswordRepository
+import app.passwordstore.ui.compose.GroupedSurface
+import app.passwordstore.ui.compose.theme.SpacingLarge
+import app.passwordstore.ui.compose.theme.SpacingMedium
+import app.passwordstore.ui.main.MainActivity
+import app.passwordstore.util.extensions.commitChange
+import app.passwordstore.util.extensions.snackbar
+import com.github.michaelbull.result.onFailure
+import com.google.android.material.snackbar.Snackbar
+import kotlinx.collections.immutable.ImmutableList
+import kotlinx.coroutines.launch
+import kotlinx.serialization.Serializable
+
+/** What the PGP keys screen is for. A route argument: kept from minification. */
+@Keep
+@Serializable
+enum class KeysMode {
+ /** Import and delete keys (Settings). */
+ Manage,
+ /** Pick the key a new local store is encrypted for. */
+ Choose,
+ /** A key is needed (after a clone, or by an entry): import it, or go on without. */
+ Need,
+}
+
+/**
+ * The imported [keys] with the import FAB. [mode] decides what a row does (select in
+ * [KeysMode.Choose], delete in [KeysMode.Manage]) and the guidance shown; [recipients] are the
+ * store's `.gpg-id` entries, listed in [KeysMode.Need].
+ */
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun PgpKeysScreen(
+ mode: KeysMode,
+ keys: ImmutableList<KeyRow>,
+ recipients: ImmutableList<String>,
+ busy: Boolean,
+ onImport: () -> Unit,
+ onSelect: (KeyRow) -> Unit,
+ onDelete: (KeyRow) -> Unit,
+ onLater: () -> Unit,
+ onBack: () -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ var deleting by remember { mutableStateOf<KeyRow?>(null) }
+ Scaffold(
+ modifier = modifier,
+ topBar = {
+ TopAppBar(
+ title = {
+ Text(
+ stringResource(
+ when (mode) {
+ KeysMode.Manage -> R.string.activity_label_pgp_key_manager
+ KeysMode.Choose -> R.string.gpg_key_select
+ KeysMode.Need -> R.string.pref_import_pgp_key_title
+ }
+ )
+ )
+ },
+ navigationIcon = {
+ IconButton(onClick = onBack) {
+ Icon(Icons.AutoMirrored.Filled.ArrowBack, stringResource(R.string.entry_back))
+ }
+ },
+ actions = {
+ if (mode == KeysMode.Need)
+ TextButton(onClick = onLater) { Text(stringResource(R.string.ssh_keygen_later)) }
+ },
+ )
+ },
+ floatingActionButton = {
+ // M3 clears the semantics of the FAB's content, label and icon alike: describe the FAB.
+ val label = stringResource(R.string.pref_import_pgp_key_title)
+ ExtendedFloatingActionButton(
+ text = { Text(label) },
+ icon = { Icon(Icons.Filled.Add, null) },
+ onClick = { if (!busy) onImport() },
+ modifier = Modifier.semantics { contentDescription = label },
+ )
+ },
+ ) { padding ->
+ Column(Modifier.padding(padding).fillMaxSize().verticalScroll(rememberScrollState())) {
+ if (busy) LinearProgressIndicator(Modifier.fillMaxWidth())
+ val message =
+ when (mode) {
+ KeysMode.Manage -> null
+ KeysMode.Choose -> R.string.select_gpg_key_message
+ KeysMode.Need -> R.string.pgp_key_needed_message
+ }
+ if (message != null)
+ Text(
+ stringResource(message),
+ Modifier.padding(SpacingLarge),
+ style = MaterialTheme.typography.bodyLarge,
+ )
+ if (mode == KeysMode.Need && recipients.isNotEmpty())
+ GroupedSurface {
+ Column(Modifier.padding(SpacingLarge)) {
+ Text(
+ stringResource(R.string.pgp_key_needed_recipients),
+ style = MaterialTheme.typography.labelMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ recipients.forEach { Text(it, fontFamily = FontFamily.Monospace) }
+ }
+ }
+ if (keys.isEmpty())
+ Text(
+ stringResource(R.string.pgp_key_manager_no_keys_guidance),
+ Modifier.padding(SpacingLarge),
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ else
+ GroupedSurface {
+ keys.forEach { row ->
+ KeyItem(row = row, mode = mode, onSelect = onSelect, onDelete = { deleting = row })
+ }
+ }
+ // Room for the FAB below the last row.
+ Spacer(Modifier.height(88.dp))
+ }
+ }
+ deleting?.let { row ->
+ SetupDialog(
+ title = stringResource(R.string.pgp_key_manager_delete_confirmation_dialog_title),
+ text = row.userId ?: row.keyId.toString(),
+ confirm = stringResource(R.string.dialog_yes),
+ onConfirm = {
+ deleting = null
+ onDelete(row)
+ },
+ onDismiss = { deleting = null },
+ dismiss = stringResource(R.string.dialog_no),
+ )
+ }
+}
+
+@Composable
+private fun KeyItem(
+ row: KeyRow,
+ mode: KeysMode,
+ onSelect: (KeyRow) -> Unit,
+ onDelete: () -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ Row(
+ modifier
+ .fillMaxWidth()
+ .then(if (mode == KeysMode.Choose) Modifier.clickable { onSelect(row) } else Modifier)
+ .padding(horizontal = SpacingLarge, vertical = SpacingMedium),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Column(Modifier.weight(1f)) {
+ Text(
+ row.userId ?: row.keyId.toString(),
+ style = MaterialTheme.typography.bodyLarge,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis,
+ )
+ if (row.userId != null)
+ Text(
+ row.keyId.toString(),
+ style = MaterialTheme.typography.bodySmall,
+ fontFamily = FontFamily.Monospace,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ }
+ if (mode == KeysMode.Manage)
+ IconButton(onClick = onDelete) { Icon(Icons.Filled.Delete, stringResource(R.string.delete)) }
+ }
+}
+
+/**
+ * The PGP keys screen: wires [PgpKeysScreen] to the [viewModel], the document picker and the import
+ * dialogs. [onImported] runs after every successful import; [onDone] ends the flow: after an
+ * import, "Later" or back in [KeysMode.Need], after the new store is created in [KeysMode.Choose].
+ * [onClose] leaves the other modes.
+ */
+@Composable
+fun PgpKeysDestination(
+ activity: MainActivity,
+ mode: KeysMode,
+ onClose: () -> Unit,
+ onDone: () -> Unit,
+ onImported: () -> Unit,
+ modifier: Modifier = Modifier,
+ viewModel: PgpKeysViewModel = hiltViewModel(),
+) {
+ val root = PasswordRepository.getRepositoryDirectory()
+ // Read off the main thread, symlink- and size-checked (readGpgId).
+ LaunchedEffect(mode) { if (mode == KeysMode.Need) viewModel.loadRecipients(root) }
+ // A created store moves on from here, also when the screen was recreated in between.
+ val done = viewModel.done
+ LaunchedEffect(done) { if (done) onDone() }
+
+ fun leave() {
+ if (viewModel.busy) return
+ if (mode == KeysMode.Need) onDone() else onClose()
+ }
+ BackHandler(onBack = ::leave)
+
+ val picker =
+ rememberLauncherForActivityResult(OpenDocument()) { uri ->
+ if (uri != null) viewModel.import(uri, activity)
+ }
+
+ // ponytail: a theme or locale change in the middle cancels the commit. Choosing the same key
+ // again finds the store already made for it (createdFor), commits the `.gpg-id` and moves on.
+ fun choose(row: KeyRow) {
+ if (viewModel.busy) return
+ viewModel.busy = true
+ activity.lifecycleScope.launch {
+ try {
+ val error = viewModel.createStore(root, row, activity.getString(R.string.store_not_empty))
+ if (error != null) {
+ activity.snackbar(
+ message = activity.getString(R.string.store_create_failed, error),
+ length = Snackbar.LENGTH_LONG,
+ )
+ } else {
+ // A failed commit is reported like any git error; the store exists either way, and
+ // the next commit picks up the `.gpg-id`.
+ activity
+ .commitChange(
+ activity.getString(R.string.git_commit_gpg_id, activity.getString(R.string.app_name))
+ )
+ .onFailure { activity.promptOnErrorHandler(it) }
+ viewModel.markDone()
+ }
+ } finally {
+ viewModel.busy = false
+ }
+ }
+ }
+
+ Box(modifier) {
+ PgpKeysScreen(
+ mode = mode,
+ keys = viewModel.keys,
+ recipients = viewModel.recipients,
+ busy = viewModel.busy,
+ onImport = { picker.launch(arrayOf("*/*")) },
+ onSelect = ::choose,
+ onDelete = viewModel::deleteKey,
+ onLater = ::leave,
+ onBack = ::leave,
+ )
+ when (val result = viewModel.importResult) {
+ null -> Unit
+ is KeyImport.Imported -> {
+ fun accept() {
+ viewModel.dismissImport()
+ onImported()
+ if (mode == KeysMode.Need) onDone()
+ }
+ SetupDialog(
+ title = stringResource(R.string.pgp_key_import_succeeded),
+ text = stringResource(R.string.pgp_key_import_succeeded_message, result.keyId),
+ confirm = stringResource(R.string.dialog_ok),
+ onConfirm = ::accept,
+ onDismiss = ::accept,
+ )
+ }
+ KeyImport.Exists ->
+ SetupDialog(
+ title = stringResource(R.string.pgp_key_import_failed),
+ text = stringResource(R.string.pgp_key_import_failed_replace_message),
+ confirm = stringResource(R.string.dialog_yes),
+ onConfirm = viewModel::replace,
+ onDismiss = viewModel::dismissImport,
+ dismiss = stringResource(R.string.dialog_no),
+ )
+ is KeyImport.Failed ->
+ SetupDialog(
+ title = stringResource(R.string.pgp_key_import_failed),
+ text = result.message.orEmpty(),
+ confirm = stringResource(R.string.dialog_ok),
+ onConfirm = viewModel::dismissImport,
+ onDismiss = viewModel::dismissImport,
+ )
+ }
+ }
+}
diff --git a/app/src/main/java/app/passwordstore/ui/setup/PgpKeysViewModel.kt b/app/src/main/java/app/passwordstore/ui/setup/PgpKeysViewModel.kt
new file mode 100644
index 00000000..8f1b52ec
--- /dev/null
+++ b/app/src/main/java/app/passwordstore/ui/setup/PgpKeysViewModel.kt
@@ -0,0 +1,255 @@
+/*
+ * Copyright (C) 2026 Danilo M. <danix@danix.xyz>
+ * SPDX-License-Identifier: GPL-3.0-only
+ */
+
+package app.passwordstore.ui.setup
+
+import android.content.Context
+import android.net.Uri
+import androidx.compose.runtime.Immutable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.setValue
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import app.passwordstore.R
+import app.passwordstore.crypto.KeyUtils
+import app.passwordstore.crypto.PGPIdentifier
+import app.passwordstore.crypto.PGPKey
+import app.passwordstore.crypto.PGPKeyManager
+import app.passwordstore.crypto.errors.KeyAlreadyExistsException
+import app.passwordstore.crypto.errors.KeyDowngradeException
+import app.passwordstore.data.repo.PasswordRepository
+import app.passwordstore.ui.list.deleteWithoutFollowingLinks
+import app.passwordstore.util.coroutines.DispatcherProvider
+import com.github.michaelbull.result.onSuccess
+import dagger.hilt.android.lifecycle.HiltViewModel
+import java.io.File
+import java.io.IOException
+import javax.inject.Inject
+import kotlinx.collections.immutable.ImmutableList
+import kotlinx.collections.immutable.persistentListOf
+import kotlinx.collections.immutable.toPersistentList
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.withContext
+import logcat.LogPriority.ERROR
+import logcat.asLog
+import logcat.logcat
+import org.eclipse.jgit.api.errors.GitAPIException
+import org.eclipse.jgit.api.errors.JGitInternalException
+
+/** One imported key: its first user ID, when it has one, and its long key ID. */
+@Immutable data class KeyRow(val userId: String?, val keyId: PGPIdentifier.KeyId)
+
+/** The outcome of a PGP key import, shown until dismissed. */
+sealed interface KeyImport {
+ data class Imported(val keyId: String) : KeyImport
+
+ /** A key with the same ID is imported already: the screen asks whether to replace it. */
+ data object Exists : KeyImport
+
+ data class Failed(val message: String?) : KeyImport
+}
+
+/**
+ * The PGP keys screen's state and work: the imported [keys], import with the replace question,
+ * deletion by key ID, and the creation of a new local store for a chosen key. Holds no Context;
+ * calls take one and use the application context.
+ */
+@HiltViewModel
+class PgpKeysViewModel
+@Inject
+constructor(
+ private val keyManager: PGPKeyManager,
+ private val dispatcherProvider: DispatcherProvider,
+) : ViewModel() {
+
+ var keys: ImmutableList<KeyRow> by mutableStateOf(persistentListOf())
+ private set
+
+ /** Set while an import or a store creation runs: further taps wait. */
+ var busy by mutableStateOf(false)
+
+ var importResult by mutableStateOf<KeyImport?>(null)
+ private set
+
+ /** Set once a new store is created and committed: the screen moves on. */
+ var done by mutableStateOf(false)
+ private set
+
+ /** The root `.gpg-id` recipients, for the `Need` screen; see [loadRecipients]. */
+ var recipients: ImmutableList<String> by mutableStateOf(persistentListOf())
+ private set
+
+ /**
+ * The picked file while the replace question is open. Memory only, zeroed as soon as it is not
+ * needed ([dropPending]): it may be a private key.
+ */
+ private var pending: ByteArray? = null
+
+ init {
+ updateKeySet()
+ }
+
+ /** Reads the recipients of the store at [root] off the main thread. */
+ fun loadRecipients(root: File) {
+ viewModelScope.launch {
+ recipients =
+ withContext(dispatcherProvider.io()) {
+ readGpgId(root)?.let(::storeRecipients).orEmpty().map(PGPIdentifier::toString)
+ }
+ .toPersistentList()
+ }
+ }
+
+ private fun dropPending() {
+ pending?.fill(0)
+ pending = null
+ }
+
+ fun updateKeySet() {
+ viewModelScope.launch {
+ keyManager.getAllKeys().onSuccess { all ->
+ keys =
+ withContext(dispatcherProvider.default()) { all.mapNotNull(::row) }.toPersistentList()
+ }
+ }
+ }
+
+ private fun row(key: PGPKey): KeyRow? {
+ val id = KeyUtils.tryGetId(key) ?: return null
+ return KeyRow(KeyUtils.tryGetEmail(key)?.email, id)
+ }
+
+ fun deleteKey(row: KeyRow) {
+ viewModelScope.launch {
+ keyManager.removeKey(row.keyId)
+ updateKeySet()
+ }
+ }
+
+ /** Reads the key file at [uri] (at most [MAX_KEY_BYTES]) and imports it. */
+ fun import(uri: Uri, context: Context) {
+ if (busy) return
+ busy = true
+ val resolver = context.applicationContext.contentResolver
+ val tooLarge = context.getString(R.string.pgp_key_import_too_large)
+ val downgrade = context.getString(R.string.pgp_key_import_downgrade)
+ viewModelScope.launch {
+ try {
+ val bytes =
+ withContext(dispatcherProvider.io()) {
+ resolver.openInputStream(uri)?.use {
+ readAtMost(it, MAX_KEY_BYTES) ?: throw IOException(tooLarge)
+ }
+ }
+ if (bytes == null) importResult = KeyImport.Failed(null)
+ else add(bytes, replace = false, downgrade)
+ } catch (e: IOException) {
+ importResult = KeyImport.Failed(e.message)
+ } catch (e: SecurityException) {
+ importResult = KeyImport.Failed(e.message)
+ } finally {
+ busy = false
+ }
+ }
+ }
+
+ /** Imports the key the replace question was about, over the one with its ID. */
+ fun replace() {
+ val bytes = pending ?: return
+ pending = null
+ importResult = null
+ busy = true
+ viewModelScope.launch {
+ try {
+ add(bytes, replace = true)
+ } finally {
+ busy = false
+ }
+ }
+ }
+
+ fun dismissImport() {
+ dropPending()
+ importResult = null
+ }
+
+ /**
+ * Imports [bytes]; they are zeroed afterwards, also on failure or cancellation, unless the
+ * replace question keeps them. [downgrade] is the message for a public key over a secret one.
+ */
+ private suspend fun add(bytes: ByteArray, replace: Boolean, downgrade: String? = null) {
+ var keep = false
+ try {
+ val result = keyManager.addKey(PGPKey(bytes), replace)
+ if (!replace && result.isErr && result.error is KeyAlreadyExistsException) {
+ // A question still open from an earlier pick is superseded: zero its bytes first.
+ dropPending()
+ pending = bytes
+ keep = true
+ importResult = KeyImport.Exists
+ return
+ }
+ // The ID is read before the bytes are zeroed: the returned key shares them.
+ importResult =
+ if (result.isOk) {
+ val id = withContext(dispatcherProvider.default()) { KeyUtils.tryGetId(result.value) }
+ KeyImport.Imported(id?.toString().orEmpty())
+ } else if (result.error is KeyDowngradeException) {
+ KeyImport.Failed(downgrade ?: result.error.message)
+ } else KeyImport.Failed(result.error.message)
+ if (result.isOk) updateKeySet()
+ } finally {
+ if (!keep) bytes.fill(0)
+ }
+ }
+
+ /**
+ * Creates the local store at [root] for [row]: a git repository and a `.gpg-id` naming the key by
+ * its uppercase long key ID ([gpgIdText]). Returns null, or why it failed; [occupied] is the
+ * reason given when [root] already holds a store, unless that store was made for [row] already.
+ */
+ suspend fun createStore(root: File, row: KeyRow, occupied: String): String? =
+ withContext(dispatcherProvider.io()) {
+ if (root.isDirectory && cloneTarget(root) == CloneTarget.Occupied) {
+ // Made by an earlier attempt for this key whose commit a recreation cancelled: done.
+ if (createdFor(root, row.keyId)) return@withContext null
+ // A restored, already-cloned store must keep its .gpg-id.
+ return@withContext occupied
+ }
+ try {
+ check(root.isDirectory || root.mkdirs()) { "Could not create $root" }
+ PasswordRepository.createRepository(root)
+ File(root, ".gpg-id").writeText(gpgIdText(listOf(row.keyId)))
+ null
+ } catch (e: IOException) {
+ failed(root, e)
+ } catch (e: GitAPIException) {
+ failed(root, e)
+ } catch (e: JGitInternalException) {
+ failed(root, e)
+ } catch (e: IllegalStateException) {
+ failed(root, e)
+ }
+ }
+
+ private fun failed(root: File, e: Exception): String {
+ logcat(ERROR) { e.asLog() }
+ // Removes what this attempt made, including a fresh .git, without following links.
+ root.deleteWithoutFollowingLinks()
+ return e.message.orEmpty()
+ }
+
+ fun markDone() {
+ done = true
+ }
+
+ override fun onCleared() {
+ dropPending()
+ }
+}
+
+/** The largest key file imported: far above any real key, small enough to hold in memory. */
+private const val MAX_KEY_BYTES = 1024 * 1024
diff --git a/app/src/main/java/app/passwordstore/ui/setup/SetupGraph.kt b/app/src/main/java/app/passwordstore/ui/setup/SetupGraph.kt
new file mode 100644
index 00000000..40dfae74
--- /dev/null
+++ b/app/src/main/java/app/passwordstore/ui/setup/SetupGraph.kt
@@ -0,0 +1,99 @@
+/*
+ * Copyright (C) 2026 Danilo M. <danix@danix.xyz>
+ * SPDX-License-Identifier: GPL-3.0-only
+ */
+
+package app.passwordstore.ui.setup
+
+import android.app.Activity
+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.ListRoute
+import app.passwordstore.ui.main.MainActivity
+import app.passwordstore.ui.settings.SettingsRoute
+import kotlinx.serialization.Serializable
+
+/** The first screen without a store. */
+@Serializable data object WelcomeRoute
+
+/** Git server settings; with [clone], saving them clones the store. */
+@Serializable data class GitServerRoute(val clone: Boolean)
+
+/** The SSH key: state, public key, import. */
+@Serializable data object SshKeyRoute
+
+/** SSH key generation. */
+@Serializable data object SshKeyGenRoute
+
+/** The PGP keys, for [mode]. */
+@Serializable data class PgpKeysRoute(val mode: KeysMode = KeysMode.Manage)
+
+/** The setup screens [MainActivity.setupIntent] opens on their own. */
+enum class SetupStart(val route: Any) {
+ Ssh(SshKeyRoute),
+ SshGenerate(SshKeyGenRoute),
+ PgpImport(PgpKeysRoute(KeysMode.Need)),
+}
+
+/**
+ * The setup destinations. In an instance started for one of them ([MainActivity.setupIntent]),
+ * leaving its first screen finishes the activity.
+ */
+fun NavGraphBuilder.setupGraph(activity: MainActivity, nav: NavHostController) {
+ fun onTop(entry: NavBackStackEntry) = nav.currentBackStackEntry?.id == entry.id
+
+ fun close(entry: NavBackStackEntry) {
+ if (!onTop(entry)) return
+ if (nav.previousBackStackEntry == null) activity.finish() else nav.popBackStack()
+ }
+
+ // Setup is complete: the new store's list replaces the whole stack. An instance started for
+ // this screen alone just finishes.
+ fun complete(entry: NavBackStackEntry) {
+ if (!onTop(entry)) return
+ if (nav.previousBackStackEntry == null) activity.finish()
+ else nav.navigate(ListRoute()) { popUpTo(nav.graph.id) { inclusive = true } }
+ }
+
+ composable<WelcomeRoute> { entry ->
+ WelcomeScreen(
+ onClone = { if (onTop(entry)) nav.navigate(GitServerRoute(clone = true)) },
+ onCreate = { if (onTop(entry)) nav.navigate(PgpKeysRoute(KeysMode.Choose)) },
+ onSettings = { if (onTop(entry)) nav.navigate(SettingsRoute) },
+ )
+ }
+ composable<GitServerRoute> { entry ->
+ GitServerDestination(
+ activity = activity,
+ clone = entry.toRoute<GitServerRoute>().clone,
+ onClose = { close(entry) },
+ onSshKey = { if (onTop(entry)) nav.navigate(SshKeyRoute) },
+ onCloned = { keyNeeded ->
+ if (!keyNeeded) complete(entry)
+ else if (onTop(entry)) nav.navigate(PgpKeysRoute(KeysMode.Need))
+ },
+ )
+ }
+ composable<SshKeyRoute> { entry ->
+ SshKeyDestination(
+ activity = activity,
+ onClose = { close(entry) },
+ onGenerate = { if (onTop(entry)) nav.navigate(SshKeyGenRoute) },
+ )
+ }
+ composable<SshKeyGenRoute> { entry ->
+ SshKeyGenDestination(activity = activity, onClose = { close(entry) })
+ }
+ composable<PgpKeysRoute> { entry ->
+ PgpKeysDestination(
+ activity = activity,
+ mode = entry.toRoute<PgpKeysRoute>().mode,
+ onClose = { close(entry) },
+ onDone = { complete(entry) },
+ onImported = { activity.setResult(Activity.RESULT_OK) },
+ )
+ }
+}
diff --git a/app/src/main/java/app/passwordstore/ui/setup/SetupModel.kt b/app/src/main/java/app/passwordstore/ui/setup/SetupModel.kt
new file mode 100644
index 00000000..4e32505f
--- /dev/null
+++ b/app/src/main/java/app/passwordstore/ui/setup/SetupModel.kt
@@ -0,0 +1,142 @@
+/*
+ * Copyright (C) 2026 Danilo M. <danix@danix.xyz>
+ * SPDX-License-Identifier: GPL-3.0-only
+ */
+
+package app.passwordstore.ui.setup
+
+import app.passwordstore.crypto.PGPIdentifier
+import app.passwordstore.util.settings.AuthMode
+import java.io.File
+import java.io.IOException
+import java.io.InputStream
+import java.nio.channels.Channels
+import java.nio.file.Files
+import java.nio.file.LinkOption
+import java.nio.file.StandardOpenOption
+import java.util.Locale
+
+/** Why a repository URL is questioned before it is saved, as the old server screen checked it. */
+enum class UrlProblem {
+ /** An HTTPS URL with a custom port, which the old screen did not support. */
+ HttpsPort,
+ /** An scp-style SSH URL with a port: without `ssh://` the port is read as part of the path. */
+ SshScheme,
+ /** The unencrypted `git://` protocol. */
+ GitScheme,
+}
+
+private val PORT = ":[0-9]{1,5}/".toRegex()
+
+private fun isHttp(url: String) = url.startsWith("http://") || url.startsWith("https://")
+
+/** What is wrong with [url], or null. `git://` is checked first: a port does not make it SSH. */
+fun urlProblem(url: String): UrlProblem? =
+ when {
+ url.startsWith("git://") -> UrlProblem.GitScheme
+ !url.contains(PORT) -> null
+ url.startsWith("https://") -> UrlProblem.HttpsPort
+ url.startsWith("http://") || url.startsWith("ssh://") -> null
+ else -> UrlProblem.SshScheme
+ }
+
+/** The old screen's fix for [problem] in [url], or null when there is none (`git://`). */
+fun fixUrl(url: String, problem: UrlProblem): String? =
+ when (problem) {
+ UrlProblem.HttpsPort -> url.replace(PORT, "/")
+ UrlProblem.SshScheme -> "ssh://$url"
+ UrlProblem.GitScheme -> null
+ }
+
+/** The authentication modes offered for [url], the default first. */
+fun authModes(url: String): List<AuthMode> =
+ if (isHttp(url)) listOf(AuthMode.None, AuthMode.Password)
+ else listOf(AuthMode.SshKey, AuthMode.Password)
+
+/** [current] when [url] still allows it, the default for [url] otherwise. */
+fun fitAuthMode(url: String, current: AuthMode): AuthMode =
+ authModes(url).let { modes -> if (current in modes) current else modes.first() }
+
+/** What a clone into the store folder finds there. */
+enum class CloneTarget {
+ /** Missing or empty. */
+ Empty,
+ /** Only a `.git` folder, from a store created and abandoned: deleted without asking. */
+ OnlyGit,
+ /** Anything else: deleted after a confirmation. */
+ Occupied,
+}
+
+fun cloneTarget(dir: File): CloneTarget {
+ val files = dir.listFiles().orEmpty()
+ return when {
+ files.isEmpty() -> CloneTarget.Empty
+ files.size == 1 && files[0].name == ".git" -> CloneTarget.OnlyGit
+ else -> CloneTarget.Occupied
+ }
+}
+
+/**
+ * The recipients named by a `.gpg-id` file's [text], read as pass reads it: a `#` starts a comment,
+ * and a trailing `!` (GnuPG's "this exact subkey") is dropped. Blank and unparsable lines are
+ * skipped.
+ */
+fun storeRecipients(text: String): List<PGPIdentifier> =
+ text
+ .lines()
+ .map { it.substringBefore('#').trim().removeSuffix("!") }
+ .filter(String::isNotEmpty)
+ .mapNotNull(PGPIdentifier::fromString)
+
+/**
+ * A `.gpg-id` naming [keys]: each 16-hex long key ID in uppercase on a line of its own, the format
+ * of a desktop store made with `pass init <key ID>`.
+ */
+fun gpgIdText(keys: List<PGPIdentifier.KeyId>): String =
+ keys.joinToString("") { "${it.toString().uppercase(Locale.ROOT)}\n" }
+
+/**
+ * Whether [dir] holds a store already made for [key]: its `.gpg-id` is exactly what creating one
+ * for [key] writes. Blocking: call it off the main thread.
+ */
+fun createdFor(dir: File, key: PGPIdentifier.KeyId): Boolean =
+ readGpgId(dir) == gpgIdText(listOf(key))
+
+private const val MAX_GPG_ID_BYTES = 64 * 1024
+
+/**
+ * The text of [dir]'s `.gpg-id`, or null when it is missing, not a regular file, a symlink, larger
+ * than 64 KiB or unreadable. Blocking: call it off the main thread.
+ */
+fun readGpgId(dir: File): String? {
+ val path = File(dir, ".gpg-id").toPath()
+ if (!Files.isRegularFile(path, LinkOption.NOFOLLOW_LINKS)) return null
+ // Opened without following a link too: one swapped in after the check is refused.
+ val options = setOf(StandardOpenOption.READ, LinkOption.NOFOLLOW_LINKS)
+ return try {
+ Channels.newInputStream(Files.newByteChannel(path, options))
+ .use { readAtMost(it, MAX_GPG_ID_BYTES) }
+ ?.decodeToString()
+ } catch (_: IOException) {
+ null
+ }
+}
+
+/**
+ * At most [limit] bytes of [input], or null when it holds more. Reads at most [limit] + 1 bytes,
+ * whatever the stream's size, and zeroes its buffer: the bytes may be a private key.
+ */
+fun readAtMost(input: InputStream, limit: Int): ByteArray? {
+ val buffer = ByteArray(limit + 1)
+ try {
+ var size = 0
+ while (size < buffer.size) {
+ val read = input.read(buffer, size, buffer.size - size)
+ if (read < 0) break
+ size += read
+ }
+ return if (size > limit) null else buffer.copyOf(size)
+ } finally {
+ buffer.fill(0)
+ }
+}
diff --git a/app/src/main/java/app/passwordstore/ui/setup/SshKeyScreen.kt b/app/src/main/java/app/passwordstore/ui/setup/SshKeyScreen.kt
new file mode 100644
index 00000000..f93fc9be
--- /dev/null
+++ b/app/src/main/java/app/passwordstore/ui/setup/SshKeyScreen.kt
@@ -0,0 +1,379 @@
+/*
+ * Copyright (C) 2026 Danilo M. <danix@danix.xyz>
+ * SPDX-License-Identifier: GPL-3.0-only
+ */
+
+package app.passwordstore.ui.setup
+
+import android.content.Intent
+import android.widget.Toast
+import androidx.activity.compose.BackHandler
+import androidx.activity.compose.rememberLauncherForActivityResult
+import androidx.activity.result.contract.ActivityResultContracts.OpenDocument
+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.layout.Row
+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.selection.toggleable
+import androidx.compose.foundation.text.selection.SelectionContainer
+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.LinearProgressIndicator
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.OutlinedButton
+import androidx.compose.material3.Scaffold
+import androidx.compose.material3.SegmentedButton
+import androidx.compose.material3.SegmentedButtonDefaults
+import androidx.compose.material3.SingleChoiceSegmentedButtonRow
+import androidx.compose.material3.Switch
+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.mutableIntStateOf
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+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.semantics.Role
+import androidx.compose.ui.text.font.FontFamily
+import androidx.hilt.navigation.compose.hiltViewModel
+import androidx.lifecycle.compose.LifecycleResumeEffect
+import app.passwordstore.R
+import app.passwordstore.ui.compose.GroupedSurface
+import app.passwordstore.ui.compose.theme.SpacingLarge
+import app.passwordstore.ui.compose.theme.SpacingMedium
+import app.passwordstore.ui.crypto.copyTextToClipboard
+import app.passwordstore.ui.main.MainActivity
+import app.passwordstore.util.auth.BiometricAuthenticator
+import app.passwordstore.util.extensions.keyguardManager
+import app.passwordstore.util.git.sshj.SshKey
+
+/**
+ * The SSH key: its state ([summary], a string resource), the public key with copy and share when
+ * the device generated it, import and generation.
+ */
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun SshKeyScreen(
+ @StringRes summary: Int,
+ publicKey: String?,
+ busy: Boolean,
+ onImport: () -> Unit,
+ onGenerate: () -> Unit,
+ onCopy: () -> Unit,
+ onShare: () -> Unit,
+ onBack: () -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ Scaffold(
+ modifier = modifier,
+ topBar = {
+ TopAppBar(
+ title = { Text(stringResource(R.string.ssh_keygen_existing_title)) },
+ navigationIcon = {
+ IconButton(onClick = onBack) {
+ Icon(Icons.AutoMirrored.Filled.ArrowBack, stringResource(R.string.entry_back))
+ }
+ },
+ )
+ },
+ ) { padding ->
+ Column(Modifier.padding(padding).fillMaxSize().verticalScroll(rememberScrollState())) {
+ if (busy) LinearProgressIndicator(Modifier.fillMaxWidth())
+ GroupedSurface {
+ Text(
+ stringResource(summary),
+ Modifier.padding(SpacingLarge),
+ style = MaterialTheme.typography.bodyLarge,
+ )
+ }
+ if (publicKey != null)
+ GroupedSurface {
+ Column(
+ Modifier.padding(SpacingLarge),
+ verticalArrangement = Arrangement.spacedBy(SpacingMedium),
+ ) {
+ Text(
+ stringResource(R.string.your_public_key),
+ style = MaterialTheme.typography.labelMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ SelectionContainer {
+ Text(
+ publicKey,
+ style = MaterialTheme.typography.bodySmall,
+ fontFamily = FontFamily.Monospace,
+ )
+ }
+ Text(
+ stringResource(R.string.ssh_key_public_hint),
+ style = MaterialTheme.typography.bodySmall,
+ )
+ Row {
+ TextButton(onClick = onCopy) { Text(stringResource(R.string.copy_label)) }
+ TextButton(onClick = onShare) { Text(stringResource(R.string.ssh_keygen_share)) }
+ }
+ }
+ }
+ Column(
+ Modifier.padding(SpacingLarge),
+ verticalArrangement = Arrangement.spacedBy(SpacingMedium),
+ ) {
+ Button(onClick = onImport, enabled = !busy, modifier = Modifier.fillMaxWidth()) {
+ Text(stringResource(R.string.pref_import_ssh_key_title))
+ }
+ OutlinedButton(onClick = onGenerate, enabled = !busy, modifier = Modifier.fillMaxWidth()) {
+ Text(stringResource(R.string.pref_ssh_keygen_title))
+ }
+ }
+ }
+ }
+}
+
+/** A chooser sharing [publicKey] as text. */
+private fun shareKey(publicKey: String): Intent =
+ Intent.createChooser(
+ Intent(Intent.ACTION_SEND).setType("text/plain").putExtra(Intent.EXTRA_TEXT, publicKey),
+ null,
+ )
+
+/**
+ * The SSH key screen: wires [SshKeyScreen] to the [viewModel], the document picker and the replace
+ * confirmation. [onGenerate] opens the generation screen.
+ */
+@Composable
+fun SshKeyDestination(
+ activity: MainActivity,
+ onClose: () -> Unit,
+ onGenerate: () -> Unit,
+ modifier: Modifier = Modifier,
+ viewModel: SshKeyViewModel = hiltViewModel(),
+) {
+ var resumes by remember { mutableIntStateOf(0) }
+ // Back from the generation screen (or another instance) with a new key: read it again.
+ LifecycleResumeEffect(Unit) {
+ resumes++
+ onPauseOrDispose {}
+ }
+ val version = viewModel.version
+ val summary = remember(resumes, version) { sshKeySummary() }
+ val publicKey =
+ remember(resumes, version) { if (SshKey.canShowSshPublicKey) SshKey.sshPublicKey else null }
+ var replace by remember { mutableStateOf(false) }
+ val picker =
+ rememberLauncherForActivityResult(OpenDocument()) { uri ->
+ if (uri != null) viewModel.import(uri)
+ }
+
+ LaunchedEffect(viewModel.imported) {
+ if (viewModel.imported) {
+ Toast.makeText(activity, R.string.ssh_key_success_dialog_title, Toast.LENGTH_LONG).show()
+ viewModel.announced()
+ }
+ }
+ BackHandler(enabled = viewModel.busy) {}
+
+ Box(modifier) {
+ SshKeyScreen(
+ summary = summary,
+ publicKey = publicKey,
+ busy = viewModel.busy,
+ onImport = { if (SshKey.exists) replace = true else picker.launch(arrayOf("*/*")) },
+ onGenerate = onGenerate,
+ onCopy = { publicKey?.let { activity.copyTextToClipboard(it) } },
+ onShare = { publicKey?.let { activity.startActivity(shareKey(it)) } },
+ onBack = { if (!viewModel.busy) onClose() },
+ )
+ if (replace)
+ SetupDialog(
+ title = stringResource(R.string.ssh_keygen_existing_title),
+ text = stringResource(R.string.ssh_keygen_existing_message),
+ confirm = stringResource(R.string.ssh_keygen_existing_replace),
+ onConfirm = {
+ replace = false
+ picker.launch(arrayOf("*/*"))
+ },
+ onDismiss = { replace = false },
+ dismiss = stringResource(R.string.ssh_keygen_existing_keep),
+ )
+ viewModel.failure?.let { message ->
+ SetupDialog(
+ title = stringResource(R.string.ssh_key_error_dialog_title),
+ text = message,
+ confirm = stringResource(R.string.dialog_ok),
+ onConfirm = viewModel::dismissFailure,
+ onDismiss = viewModel::dismissFailure,
+ )
+ }
+ }
+}
+
+/** Key type with its explanation, the screen lock switch and Generate. */
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun SshKeyGenScreen(
+ type: SshKeyType,
+ protect: Boolean,
+ canProtect: Boolean,
+ busy: Boolean,
+ onTypeChange: (SshKeyType) -> Unit,
+ onProtectChange: (Boolean) -> Unit,
+ onGenerate: () -> Unit,
+ onBack: () -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ val types = SshKeyType.entries
+ Scaffold(
+ modifier = modifier,
+ topBar = {
+ TopAppBar(
+ title = { Text(stringResource(R.string.pref_ssh_keygen_title)) },
+ 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),
+ ) {
+ SingleChoiceSegmentedButtonRow(Modifier.fillMaxWidth()) {
+ types.forEachIndexed { index, entry ->
+ SegmentedButton(
+ selected = entry == type,
+ onClick = { onTypeChange(entry) },
+ shape = SegmentedButtonDefaults.itemShape(index, types.size),
+ enabled = !busy,
+ ) {
+ Text(stringResource(entry.label))
+ }
+ }
+ }
+ // ponytail: stringResource drops the explanations' bold title; plain text reads fine.
+ Text(stringResource(type.explanation), style = MaterialTheme.typography.bodyMedium)
+ Row(
+ Modifier.fillMaxWidth()
+ .toggleable(
+ value = protect,
+ enabled = canProtect && !busy,
+ role = Role.Switch,
+ onValueChange = onProtectChange,
+ ),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Text(stringResource(R.string.ssh_keygen_require_authentication), Modifier.weight(1f))
+ Switch(checked = protect, onCheckedChange = null, enabled = canProtect && !busy)
+ }
+ Button(onClick = onGenerate, enabled = !busy, modifier = Modifier.fillMaxWidth()) {
+ Text(
+ stringResource(
+ if (busy) R.string.ssh_key_gen_generating_progress else R.string.ssh_keygen_generate
+ )
+ )
+ }
+ }
+ }
+}
+
+/**
+ * The key generation screen: replace confirmation, screen lock prompt when protected, then the
+ * generation in the [viewModel] and the public key (or error) dialog; both dialogs close it.
+ */
+@Composable
+fun SshKeyGenDestination(
+ activity: MainActivity,
+ onClose: () -> Unit,
+ modifier: Modifier = Modifier,
+ viewModel: SshKeyViewModel = hiltViewModel(),
+) {
+ val canProtect = remember { activity.keyguardManager.isDeviceSecure }
+ var replace by remember { mutableStateOf(false) }
+ // While the screen lock prompt is up. Plain remember: a recreation drops the prompt's callback,
+ // and the screen must not stay disabled for it.
+ var asking by remember { mutableStateOf(false) }
+
+ fun generate() {
+ if (!(canProtect && viewModel.protect)) return viewModel.generate(requireAuthentication = false)
+ asking = true
+ BiometricAuthenticator.authenticate(activity, R.string.biometric_prompt_title_ssh_keygen) {
+ result ->
+ // Failed attempts are handled by the prompt itself.
+ if (result is BiometricAuthenticator.Result.Retry) return@authenticate
+ asking = false
+ if (result is BiometricAuthenticator.Result.Success)
+ viewModel.generate(requireAuthentication = true)
+ else viewModel.fail(activity.getString(R.string.biometric_auth_generic_failure))
+ }
+ }
+
+ BackHandler(enabled = viewModel.busy) {}
+
+ Box(modifier) {
+ SshKeyGenScreen(
+ type = viewModel.type,
+ protect = canProtect && viewModel.protect,
+ canProtect = canProtect,
+ busy = viewModel.busy || asking,
+ onTypeChange = { viewModel.type = it },
+ onProtectChange = { viewModel.protect = it },
+ onGenerate = { if (SshKey.exists) replace = true else generate() },
+ onBack = { if (!viewModel.busy) onClose() },
+ )
+ if (replace)
+ SetupDialog(
+ title = stringResource(R.string.ssh_keygen_existing_title),
+ text = stringResource(R.string.ssh_keygen_existing_message),
+ confirm = stringResource(R.string.ssh_keygen_existing_replace),
+ onConfirm = {
+ replace = false
+ generate()
+ },
+ onDismiss = { replace = false },
+ dismiss = stringResource(R.string.ssh_keygen_existing_keep),
+ onDismissButton = onClose,
+ )
+ viewModel.generated?.let { key ->
+ SetupDialog(
+ title = stringResource(R.string.your_public_key),
+ text = stringResource(R.string.ssh_keygen_message, key),
+ confirm = stringResource(R.string.ssh_keygen_share),
+ onConfirm = {
+ activity.startActivity(shareKey(key))
+ onClose()
+ },
+ onDismiss = onClose,
+ dismiss = stringResource(R.string.ssh_keygen_later),
+ )
+ }
+ viewModel.failure?.let { message ->
+ SetupDialog(
+ title = stringResource(R.string.error_generate_ssh_key),
+ text = stringResource(R.string.ssh_key_error_dialog_text) + message,
+ confirm = stringResource(R.string.dialog_ok),
+ onConfirm = onClose,
+ onDismiss = onClose,
+ )
+ }
+ }
+}
diff --git a/app/src/main/java/app/passwordstore/ui/setup/SshKeyViewModel.kt b/app/src/main/java/app/passwordstore/ui/setup/SshKeyViewModel.kt
new file mode 100644
index 00000000..bc3e9474
--- /dev/null
+++ b/app/src/main/java/app/passwordstore/ui/setup/SshKeyViewModel.kt
@@ -0,0 +1,137 @@
+/*
+ * Copyright (C) 2026 Danilo M. <danix@danix.xyz>
+ * SPDX-License-Identifier: GPL-3.0-only
+ */
+
+package app.passwordstore.ui.setup
+
+import android.net.Uri
+import androidx.annotation.StringRes
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableIntStateOf
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.setValue
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import app.passwordstore.R
+import app.passwordstore.util.coroutines.DispatcherProvider
+import app.passwordstore.util.git.sshj.SshKey
+import dagger.hilt.android.lifecycle.HiltViewModel
+import javax.inject.Inject
+import kotlin.coroutines.cancellation.CancellationException
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.withContext
+
+/** The key types the generation screen offers, in the old screen's order. */
+enum class SshKeyType(@StringRes val label: Int, @StringRes val explanation: Int) {
+ Rsa(R.string.ssh_keygen_label_rsa, R.string.ssh_keygen_explanation_rsa),
+ Ecdsa(R.string.ssh_keygen_label_ecdsa, R.string.ssh_keygen_explanation_ecdsa),
+ Ed25519(R.string.ssh_keygen_label_ed25519, R.string.ssh_keygen_explanation_ed25519),
+}
+
+/** The current SSH key's state, as a string resource. */
+@StringRes
+fun sshKeySummary(): Int =
+ when {
+ !SshKey.exists -> R.string.ssh_key_state_none
+ SshKey.canShowSshPublicKey -> R.string.ssh_key_state_generated
+ else -> R.string.ssh_key_state_imported
+ }
+
+/**
+ * The SSH key screens' work: import a key file, generate a key. Both run in [viewModelScope] (no
+ * activity needed), so their outcome survives a recreation; the screens show it from [generated],
+ * [imported] and [failure]. [version] changes with the key, so screens re-read [SshKey].
+ */
+@HiltViewModel
+class SshKeyViewModel @Inject constructor(private val dispatcherProvider: DispatcherProvider) :
+ ViewModel() {
+
+ var type by mutableStateOf(SshKeyType.Ecdsa)
+
+ /** The "protect with screen lock" switch; only effective when the device is secure. */
+ var protect by mutableStateOf(true)
+
+ var busy by mutableStateOf(false)
+ private set
+
+ var version by mutableIntStateOf(0)
+ private set
+
+ /** Set after a successful import, until the screen showed its toast. */
+ var imported by mutableStateOf(false)
+ private set
+
+ /** The new public key after a generation. */
+ var generated by mutableStateOf<String?>(null)
+ private set
+
+ /** Why the last import or generation failed. */
+ var failure by mutableStateOf<String?>(null)
+ private set
+
+ /**
+ * Imports the private key at [uri]; [SshKey.import] reads it and keeps the old key on refusal.
+ */
+ fun import(uri: Uri) {
+ if (busy) return
+ busy = true
+ viewModelScope.launch {
+ try {
+ withContext(dispatcherProvider.io()) { SshKey.import(uri) }
+ imported = true
+ version++
+ } catch (e: CancellationException) {
+ throw e
+ } catch (e: Exception) {
+ // Any failure of a provider or of sshj, not only I/O: an uncaught one would crash the app.
+ failure = e.message.orEmpty()
+ } finally {
+ busy = false
+ }
+ }
+ }
+
+ /** Generates a key of [type], replacing the current one; the prompt already ran if needed. */
+ fun generate(requireAuthentication: Boolean) {
+ if (busy) return
+ busy = true
+ val type = type
+ viewModelScope.launch {
+ try {
+ generated =
+ withContext(dispatcherProvider.io()) {
+ when (type) {
+ SshKeyType.Rsa ->
+ SshKey.generateKeystoreNativeKey(SshKey.Algorithm.Rsa, requireAuthentication)
+ SshKeyType.Ecdsa ->
+ SshKey.generateKeystoreNativeKey(SshKey.Algorithm.Ecdsa, requireAuthentication)
+ SshKeyType.Ed25519 -> SshKey.generateKeystoreWrappedEd25519Key(requireAuthentication)
+ }
+ SshKey.sshPublicKey.orEmpty()
+ }
+ version++
+ } catch (e: CancellationException) {
+ throw e
+ } catch (e: Exception) {
+ // Keystore and provider failures come as many exception types; none may crash the app.
+ failure = e.message.orEmpty()
+ } finally {
+ busy = false
+ }
+ }
+ }
+
+ /** Reports a failure found before generating (the screen lock prompt). */
+ fun fail(message: String) {
+ failure = message
+ }
+
+ fun dismissFailure() {
+ failure = null
+ }
+
+ fun announced() {
+ imported = false
+ }
+}
diff --git a/app/src/main/java/app/passwordstore/ui/setup/WelcomeScreen.kt b/app/src/main/java/app/passwordstore/ui/setup/WelcomeScreen.kt
new file mode 100644
index 00000000..b4197604
--- /dev/null
+++ b/app/src/main/java/app/passwordstore/ui/setup/WelcomeScreen.kt
@@ -0,0 +1,103 @@
+/*
+ * Copyright (C) 2026 Danilo M. <danix@danix.xyz>
+ * SPDX-License-Identifier: GPL-3.0-only
+ */
+
+package app.passwordstore.ui.setup
+
+import androidx.compose.foundation.Image
+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.layout.size
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.material3.AlertDialog
+import androidx.compose.material3.Button
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.OutlinedButton
+import androidx.compose.material3.Scaffold
+import androidx.compose.material3.Text
+import androidx.compose.material3.TextButton
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.res.painterResource
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.unit.dp
+import app.passwordstore.R
+import app.passwordstore.ui.compose.theme.SpacingLarge
+import app.passwordstore.ui.compose.theme.SpacingLargest
+
+/** First start without a store: clone one, create a local one, or open the settings. */
+@Composable
+fun WelcomeScreen(
+ onClone: () -> Unit,
+ onCreate: () -> Unit,
+ onSettings: () -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ Scaffold(modifier = modifier) { padding ->
+ Column(
+ Modifier.padding(padding)
+ .fillMaxSize()
+ .verticalScroll(rememberScrollState())
+ .padding(SpacingLargest),
+ verticalArrangement = Arrangement.spacedBy(SpacingLarge),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ ) {
+ Image(
+ painterResource(R.drawable.ic_launcher_foreground),
+ contentDescription = null,
+ modifier = Modifier.size(144.dp),
+ )
+ Text(stringResource(R.string.app_name), style = MaterialTheme.typography.headlineMedium)
+ Text(
+ stringResource(R.string.select_repo_type_text),
+ style = MaterialTheme.typography.bodyLarge,
+ textAlign = TextAlign.Center,
+ )
+ Button(onClick = onClone, modifier = Modifier.fillMaxWidth()) {
+ Text(stringResource(R.string.clone_remote_repo))
+ }
+ OutlinedButton(onClick = onCreate, modifier = Modifier.fillMaxWidth()) {
+ Text(stringResource(R.string.create_local_repo))
+ }
+ TextButton(onClick = onSettings) { Text(stringResource(R.string.action_settings)) }
+ }
+ }
+}
+
+/**
+ * A plain dialog for the setup screens: optional [title], [text], [confirm] and, when [dismiss] is
+ * given, a second button running [onDismissButton] (by default the same as dismissing).
+ */
+@Composable
+internal fun SetupDialog(
+ text: String,
+ confirm: String,
+ onConfirm: () -> Unit,
+ onDismiss: () -> Unit,
+ modifier: Modifier = Modifier,
+ title: String? = null,
+ dismiss: String? = null,
+ onDismissButton: () -> Unit = onDismiss,
+) {
+ AlertDialog(
+ onDismissRequest = onDismiss,
+ modifier = modifier,
+ title =
+ if (title != null) {
+ { Text(title) }
+ } else null,
+ text = { Text(text) },
+ confirmButton = { TextButton(onClick = onConfirm) { Text(confirm) } },
+ dismissButton =
+ if (dismiss != null) {
+ { TextButton(onClick = onDismissButton) { Text(dismiss) } }
+ } else null,
+ )
+}
diff --git a/app/src/main/java/app/passwordstore/ui/sshkeygen/ShowSshKeyFragment.kt b/app/src/main/java/app/passwordstore/ui/sshkeygen/ShowSshKeyFragment.kt
deleted file mode 100644
index 3f04e35e..00000000
--- a/app/src/main/java/app/passwordstore/ui/sshkeygen/ShowSshKeyFragment.kt
+++ /dev/null
@@ -1,39 +0,0 @@
-/*
- * Copyright © 2014-2024 The Android Password Store Authors. All Rights Reserved.
- * SPDX-License-Identifier: GPL-3.0-only
- */
-package app.passwordstore.ui.sshkeygen
-
-import android.app.Dialog
-import android.content.Intent
-import android.os.Bundle
-import androidx.fragment.app.DialogFragment
-import app.passwordstore.R
-import app.passwordstore.util.git.sshj.SshKey
-import com.google.android.material.dialog.MaterialAlertDialogBuilder
-
-class ShowSshKeyFragment : DialogFragment() {
-
- override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
- val activity = requireActivity()
- val publicKey = SshKey.sshPublicKey
- return MaterialAlertDialogBuilder(requireActivity()).run {
- setMessage(getString(R.string.ssh_keygen_message, publicKey))
- setTitle(R.string.your_public_key)
- setNegativeButton(R.string.ssh_keygen_later) { _, _ ->
- (activity as? SshKeyGenActivity)?.finish()
- }
- setPositiveButton(R.string.ssh_keygen_share) { _, _ ->
- val sendIntent =
- Intent().apply {
- action = Intent.ACTION_SEND
- type = "text/plain"
- putExtra(Intent.EXTRA_TEXT, publicKey)
- }
- startActivity(Intent.createChooser(sendIntent, null))
- (activity as? SshKeyGenActivity)?.finish()
- }
- create()
- }
- }
-}
diff --git a/app/src/main/java/app/passwordstore/ui/sshkeygen/SshKeyGenActivity.kt b/app/src/main/java/app/passwordstore/ui/sshkeygen/SshKeyGenActivity.kt
deleted file mode 100644
index 618a4be0..00000000
--- a/app/src/main/java/app/passwordstore/ui/sshkeygen/SshKeyGenActivity.kt
+++ /dev/null
@@ -1,173 +0,0 @@
-/*
- * Copyright © 2014-2024 The Android Password Store Authors. All Rights Reserved.
- * SPDX-License-Identifier: GPL-3.0-only
- */
-package app.passwordstore.ui.sshkeygen
-
-import android.content.SharedPreferences
-import android.os.Bundle
-import android.security.keystore.UserNotAuthenticatedException
-import android.view.MenuItem
-import android.view.View
-import android.view.inputmethod.InputMethodManager
-import androidx.appcompat.app.AppCompatActivity
-import androidx.core.content.edit
-import androidx.core.content.getSystemService
-import androidx.lifecycle.lifecycleScope
-import app.passwordstore.R
-import app.passwordstore.databinding.ActivitySshKeygenBinding
-import app.passwordstore.injection.prefs.GitPreferences
-import app.passwordstore.util.auth.BiometricAuthenticator
-import app.passwordstore.util.auth.BiometricAuthenticator.Result
-import app.passwordstore.util.coroutines.DispatcherProvider
-import app.passwordstore.util.extensions.keyguardManager
-import app.passwordstore.util.extensions.viewBinding
-import app.passwordstore.util.git.sshj.SshKey
-import com.github.michaelbull.result.fold
-import com.github.michaelbull.result.runCatching
-import com.google.android.material.dialog.MaterialAlertDialogBuilder
-import dagger.hilt.android.AndroidEntryPoint
-import javax.inject.Inject
-import kotlin.coroutines.resume
-import kotlin.coroutines.suspendCoroutine
-import kotlinx.coroutines.launch
-import kotlinx.coroutines.withContext
-
-private enum class KeyGenType(val generateKey: suspend (requireAuthentication: Boolean) -> Unit) {
- Rsa({ requireAuthentication ->
- SshKey.generateKeystoreNativeKey(SshKey.Algorithm.Rsa, requireAuthentication)
- }),
- Ecdsa({ requireAuthentication ->
- SshKey.generateKeystoreNativeKey(SshKey.Algorithm.Ecdsa, requireAuthentication)
- }),
- Ed25519({ requireAuthentication ->
- SshKey.generateKeystoreWrappedEd25519Key(requireAuthentication)
- }),
-}
-
-@AndroidEntryPoint
-class SshKeyGenActivity : AppCompatActivity() {
-
- private var keyGenType = KeyGenType.Ecdsa
- private val binding by viewBinding(ActivitySshKeygenBinding::inflate)
- @GitPreferences @Inject lateinit var gitPrefs: SharedPreferences
- @Inject lateinit var dispatcherProvider: DispatcherProvider
-
- override fun onCreate(savedInstanceState: Bundle?) {
- super.onCreate(savedInstanceState)
- setContentView(binding.root)
- supportActionBar?.setDisplayHomeAsUpEnabled(true)
- with(binding) {
- generate.setOnClickListener {
- if (SshKey.exists) {
- MaterialAlertDialogBuilder(this@SshKeyGenActivity).run {
- setTitle(R.string.ssh_keygen_existing_title)
- setMessage(R.string.ssh_keygen_existing_message)
- setPositiveButton(R.string.ssh_keygen_existing_replace) { _, _ ->
- lifecycleScope.launch { generate() }
- }
- setNegativeButton(R.string.ssh_keygen_existing_keep) { _, _ ->
- setResult(RESULT_CANCELED)
- finish()
- }
- show()
- }
- } else {
- lifecycleScope.launch { generate() }
- }
- }
- keyTypeGroup.check(R.id.key_type_ecdsa)
- keyTypeExplanation.setText(R.string.ssh_keygen_explanation_ecdsa)
- keyTypeGroup.addOnButtonCheckedListener { _, checkedId, isChecked ->
- if (isChecked) {
- keyGenType =
- when (checkedId) {
- R.id.key_type_ed25519 -> KeyGenType.Ed25519
- R.id.key_type_ecdsa -> KeyGenType.Ecdsa
- R.id.key_type_rsa -> KeyGenType.Rsa
- else -> throw IllegalStateException("Impossible key type selection")
- }
- keyTypeExplanation.setText(
- when (keyGenType) {
- KeyGenType.Ed25519 -> R.string.ssh_keygen_explanation_ed25519
- KeyGenType.Ecdsa -> R.string.ssh_keygen_explanation_ecdsa
- KeyGenType.Rsa -> R.string.ssh_keygen_explanation_rsa
- }
- )
- }
- }
- keyRequireAuthentication.isEnabled = keyguardManager.isDeviceSecure
- keyRequireAuthentication.isChecked = keyRequireAuthentication.isEnabled
- }
- }
-
- override fun onOptionsItemSelected(item: MenuItem): Boolean {
- return when (item.itemId) {
- android.R.id.home -> {
- onBackPressedDispatcher.onBackPressed()
- true
- }
- else -> super.onOptionsItemSelected(item)
- }
- }
-
- private suspend fun generate() {
- binding.generate.apply {
- text = getString(R.string.ssh_key_gen_generating_progress)
- isEnabled = false
- }
- binding.generate.text = getString(R.string.ssh_key_gen_generating_progress)
- val result = runCatching {
- withContext(dispatcherProvider.io()) {
- val requireAuthentication = binding.keyRequireAuthentication.isChecked
- if (requireAuthentication) {
- val result =
- withContext(dispatcherProvider.main()) {
- suspendCoroutine { cont ->
- BiometricAuthenticator.authenticate(
- this@SshKeyGenActivity,
- R.string.biometric_prompt_title_ssh_keygen,
- ) { result ->
- // Do not cancel on failed attempts as these are handled by the
- // authenticator UI.
- if (result !is Result.Retry) cont.resume(result)
- }
- }
- }
- if (result !is Result.Success)
- throw UserNotAuthenticatedException(getString(R.string.biometric_auth_generic_failure))
- }
- keyGenType.generateKey(requireAuthentication)
- }
- }
- gitPrefs.edit { remove("ssh_key_local_passphrase") }
- binding.generate.apply {
- text = getString(R.string.ssh_keygen_generate)
- isEnabled = true
- }
- result.fold(
- success = { ShowSshKeyFragment().show(supportFragmentManager, "public_key") },
- failure = { e ->
- e.printStackTrace()
- MaterialAlertDialogBuilder(this)
- .setTitle(getString(R.string.error_generate_ssh_key))
- .setMessage(getString(R.string.ssh_key_error_dialog_text) + e.message)
- .setPositiveButton(getString(R.string.dialog_ok)) { _, _ ->
- setResult(RESULT_OK)
- finish()
- }
- .show()
- },
- )
- hideKeyboard()
- }
-
- private fun hideKeyboard() {
- val imm = getSystemService<InputMethodManager>() ?: return
- var view = currentFocus
- if (view == null) {
- view = View(this)
- }
- imm.hideSoftInputFromWindow(view.windowToken, 0)
- }
-}
diff --git a/app/src/main/java/app/passwordstore/ui/sshkeygen/SshKeyImportActivity.kt b/app/src/main/java/app/passwordstore/ui/sshkeygen/SshKeyImportActivity.kt
deleted file mode 100644
index e4740741..00000000
--- a/app/src/main/java/app/passwordstore/ui/sshkeygen/SshKeyImportActivity.kt
+++ /dev/null
@@ -1,66 +0,0 @@
-/*
- * Copyright © 2014-2024 The Android Password Store Authors. All Rights Reserved.
- * SPDX-License-Identifier: GPL-3.0-only
- */
-
-package app.passwordstore.ui.sshkeygen
-
-import android.net.Uri
-import android.os.Bundle
-import android.widget.Toast
-import androidx.activity.result.contract.ActivityResultContracts
-import androidx.appcompat.app.AppCompatActivity
-import app.passwordstore.R
-import app.passwordstore.util.git.sshj.SshKey
-import com.github.michaelbull.result.onFailure
-import com.github.michaelbull.result.runCatching
-import com.google.android.material.dialog.MaterialAlertDialogBuilder
-
-class SshKeyImportActivity : AppCompatActivity() {
-
- private val sshKeyImportAction =
- registerForActivityResult(ActivityResultContracts.OpenDocument()) { uri: Uri? ->
- if (uri == null) {
- finish()
- return@registerForActivityResult
- }
- runCatching {
- SshKey.import(uri)
- Toast.makeText(
- this,
- resources.getString(R.string.ssh_key_success_dialog_title),
- Toast.LENGTH_LONG,
- )
- .show()
- setResult(RESULT_OK)
- finish()
- }
- .onFailure { e ->
- MaterialAlertDialogBuilder(this)
- .setTitle(resources.getString(R.string.ssh_key_error_dialog_title))
- .setMessage(e.message)
- .setPositiveButton(resources.getString(R.string.dialog_ok)) { _, _ -> finish() }
- .show()
- }
- }
-
- override fun onCreate(savedInstanceState: Bundle?) {
- super.onCreate(savedInstanceState)
- if (SshKey.exists) {
- MaterialAlertDialogBuilder(this).run {
- setTitle(R.string.ssh_keygen_existing_title)
- setMessage(R.string.ssh_keygen_existing_message)
- setPositiveButton(R.string.ssh_keygen_existing_replace) { _, _ -> importSshKey() }
- setNegativeButton(R.string.ssh_keygen_existing_keep) { _, _ -> finish() }
- setOnCancelListener { finish() }
- show()
- }
- } else {
- importSshKey()
- }
- }
-
- private fun importSshKey() {
- sshKeyImportAction.launch(arrayOf("*/*"))
- }
-}
diff --git a/app/src/main/java/app/passwordstore/ui/util/OnOffItemAnimator.kt b/app/src/main/java/app/passwordstore/ui/util/OnOffItemAnimator.kt
deleted file mode 100644
index cd29f626..00000000
--- a/app/src/main/java/app/passwordstore/ui/util/OnOffItemAnimator.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.util
-
-import androidx.recyclerview.widget.DefaultItemAnimator
-import androidx.recyclerview.widget.RecyclerView
-
-class OnOffItemAnimator : DefaultItemAnimator() {
-
- var isEnabled: Boolean = true
- set(value) {
- // Defer update until no animation is running anymore.
- isRunning { field = value }
- }
-
- private fun dontAnimate(viewHolder: RecyclerView.ViewHolder): Boolean {
- dispatchAnimationFinished(viewHolder)
- return false
- }
-
- override fun animateAppearance(
- viewHolder: RecyclerView.ViewHolder,
- preLayoutInfo: ItemHolderInfo?,
- postLayoutInfo: ItemHolderInfo,
- ): Boolean {
- return if (isEnabled) {
- super.animateAppearance(viewHolder, preLayoutInfo, postLayoutInfo)
- } else {
- dontAnimate(viewHolder)
- }
- }
-
- override fun animateChange(
- oldHolder: RecyclerView.ViewHolder,
- newHolder: RecyclerView.ViewHolder,
- preInfo: ItemHolderInfo,
- postInfo: ItemHolderInfo,
- ): Boolean {
- return if (isEnabled) {
- super.animateChange(oldHolder, newHolder, preInfo, postInfo)
- } else {
- dontAnimate(oldHolder)
- }
- }
-
- override fun animateDisappearance(
- viewHolder: RecyclerView.ViewHolder,
- preLayoutInfo: ItemHolderInfo,
- postLayoutInfo: ItemHolderInfo?,
- ): Boolean {
- return if (isEnabled) {
- super.animateDisappearance(viewHolder, preLayoutInfo, postLayoutInfo)
- } else {
- dontAnimate(viewHolder)
- }
- }
-
- override fun animatePersistence(
- viewHolder: RecyclerView.ViewHolder,
- preInfo: ItemHolderInfo,
- postInfo: ItemHolderInfo,
- ): Boolean {
- return if (isEnabled) {
- super.animatePersistence(viewHolder, preInfo, postInfo)
- } else {
- dontAnimate(viewHolder)
- }
- }
-}
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/autofill/AutofillViewUtils.kt b/app/src/main/java/app/passwordstore/util/autofill/AutofillViewUtils.kt
index ebe4bc7d..59fd5cca 100644
--- a/app/src/main/java/app/passwordstore/util/autofill/AutofillViewUtils.kt
+++ b/app/src/main/java/app/passwordstore/util/autofill/AutofillViewUtils.kt
@@ -20,7 +20,7 @@ import androidx.autofill.inline.UiVersions
import androidx.autofill.inline.v1.InlineSuggestionUi
import app.passwordstore.R
import app.passwordstore.data.repo.PasswordRepository
-import app.passwordstore.ui.passwords.PasswordStore
+import app.passwordstore.ui.main.LaunchActivity
import java.io.File
data class DatasetMetadata(val title: String, val subtitle: String?, @DrawableRes val iconRes: Int)
@@ -55,7 +55,7 @@ fun makeInlinePresentation(
PendingIntent.getActivity(
context,
0,
- Intent(context, PasswordStore::class.java),
+ Intent(context, LaunchActivity::class.java),
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
PendingIntent.FLAG_MUTABLE
} else {
diff --git a/app/src/main/java/app/passwordstore/util/extensions/AndroidExtensions.kt b/app/src/main/java/app/passwordstore/util/extensions/AndroidExtensions.kt
index a13484c6..6528334d 100644
--- a/app/src/main/java/app/passwordstore/util/extensions/AndroidExtensions.kt
+++ b/app/src/main/java/app/passwordstore/util/extensions/AndroidExtensions.kt
@@ -8,7 +8,6 @@ package app.passwordstore.util.extensions
import android.app.KeyguardManager
import android.content.ClipboardManager
import android.content.Context
-import android.content.Intent
import android.content.SharedPreferences
import android.content.pm.ApplicationInfo
import android.content.pm.PackageInfo
@@ -17,12 +16,8 @@ import android.content.pm.PackageManager.ApplicationInfoFlags
import android.content.pm.PackageManager.PackageInfoFlags
import android.os.Build
import android.util.Base64
-import android.util.TypedValue
import android.view.View
import android.view.autofill.AutofillManager
-import androidx.activity.ComponentActivity
-import androidx.compose.ui.Modifier
-import androidx.core.content.ContextCompat
import androidx.core.content.getSystemService
import androidx.fragment.app.FragmentActivity
import app.passwordstore.BuildConfig
@@ -56,13 +51,6 @@ val Context.keyguardManager: KeyguardManager
val Context.sharedPrefs: SharedPreferences
get() = getSharedPreferences("${BuildConfig.APPLICATION_ID}_preferences", 0)
-/** Resolve [attr] from the [Context]'s theme */
-fun Context.resolveAttribute(attr: Int): Int {
- val typedValue = TypedValue()
- this.theme.resolveAttribute(attr, typedValue, true)
- return typedValue.data
-}
-
/**
* Commit changes to the store from a [FragmentActivity] using a custom implementation of
* [GitOperation]
@@ -90,32 +78,17 @@ suspend fun FragmentActivity.commitChange(message: String): Result<Unit, Throwab
.execute()
}
-/** Check if [permission] has been granted to the app. */
-fun FragmentActivity.isPermissionGranted(permission: String): Boolean {
- return ContextCompat.checkSelfPermission(this, permission) == PackageManager.PERMISSION_GRANTED
-}
-
-/**
- * Show a [Snackbar] in a [FragmentActivity] and correctly anchor it to a
- * [com.google.android.material.floatingactionbutton.FloatingActionButton] if one exists in the
- * [view]
- */
+/** Show a [Snackbar] in a [FragmentActivity]. */
fun FragmentActivity.snackbar(
view: View = findViewById(android.R.id.content),
message: String,
length: Int = Snackbar.LENGTH_SHORT,
): Snackbar {
val snackbar = Snackbar.make(view, message, length)
- snackbar.anchorView = findViewById(R.id.fab)
snackbar.show()
return snackbar
}
-/** Launch an activity denoted by [clazz]. */
-fun <T : ComponentActivity> ComponentActivity.launchActivity(clazz: Class<T>) {
- startActivity(Intent(this, clazz).setAction(Intent.ACTION_VIEW))
-}
-
/** Simplifies the common `getString(key, null) ?: defaultValue` case slightly */
fun SharedPreferences.getString(key: String): String? = getString(key, null)
@@ -139,12 +112,3 @@ fun PackageManager.getApplicationInfoCompat(packageName: String, flags: Int): Ap
getApplicationInfo(packageName, flags)
}
}
-
-/** Allows conditionally applying the given [modifier] if [isEnabled] is `true`. */
-fun Modifier.conditional(isEnabled: Boolean, modifier: Modifier.() -> Modifier): Modifier {
- return if (isEnabled) {
- then(modifier())
- } else {
- this
- }
-}
diff --git a/app/src/main/java/app/passwordstore/util/extensions/FragmentExtensions.kt b/app/src/main/java/app/passwordstore/util/extensions/FragmentExtensions.kt
deleted file mode 100644
index 2dd3af0c..00000000
--- a/app/src/main/java/app/passwordstore/util/extensions/FragmentExtensions.kt
+++ /dev/null
@@ -1,40 +0,0 @@
-/*
- * Copyright © 2014-2024 The Android Password Store Authors. All Rights Reserved.
- * SPDX-License-Identifier: GPL-3.0-only
- */
-
-package app.passwordstore.util.extensions
-
-import androidx.annotation.IdRes
-import androidx.fragment.app.Fragment
-import androidx.fragment.app.FragmentManager
-import androidx.fragment.app.commit
-import app.passwordstore.R
-
-/** Check if [permission] is granted to the app. Aliases to [isPermissionGranted] internally. */
-fun Fragment.isPermissionGranted(permission: String): Boolean {
- return requireActivity().isPermissionGranted(permission)
-}
-
-/** Calls `finish()` on the enclosing [androidx.fragment.app.FragmentActivity] */
-fun Fragment.finish() = requireActivity().finish()
-
-/**
- * Perform a [commit] on this [FragmentManager] with custom animations and adding the
- * [destinationFragment] to the fragment backstack
- */
-fun FragmentManager.performTransactionWithBackStack(
- destinationFragment: Fragment,
- @IdRes containerViewId: Int = android.R.id.content,
-) {
- commit {
- addToBackStack(destinationFragment.tag)
- setCustomAnimations(
- R.animator.slide_in_left,
- R.animator.slide_out_left,
- R.animator.slide_in_right,
- R.animator.slide_out_right,
- )
- replace(containerViewId, destinationFragment)
- }
-}
diff --git a/app/src/main/java/app/passwordstore/util/extensions/FragmentViewBindingDelegate.kt b/app/src/main/java/app/passwordstore/util/extensions/FragmentViewBindingDelegate.kt
deleted file mode 100644
index 0050f2e5..00000000
--- a/app/src/main/java/app/passwordstore/util/extensions/FragmentViewBindingDelegate.kt
+++ /dev/null
@@ -1,70 +0,0 @@
-/*
- * Copyright © 2014-2024 The Android Password Store Authors. All Rights Reserved.
- * SPDX-License-Identifier: GPL-3.0-only
- */
-
-package app.passwordstore.util.extensions
-
-import android.view.LayoutInflater
-import android.view.View
-import androidx.appcompat.app.AppCompatActivity
-import androidx.fragment.app.Fragment
-import androidx.lifecycle.DefaultLifecycleObserver
-import androidx.lifecycle.Lifecycle
-import androidx.lifecycle.LifecycleOwner
-import androidx.viewbinding.ViewBinding
-import kotlin.properties.ReadOnlyProperty
-import kotlin.reflect.KProperty
-
-/**
- * Imported from
- * https://medium.com/@Zhuinden/simple-one-liner-viewbinding-in-fragments-and-activities-with-kotlin-961430c6c07c
- */
-class FragmentViewBindingDelegate<T : ViewBinding>(
- val fragment: Fragment,
- val viewBindingFactory: (View) -> T,
-) : ReadOnlyProperty<Fragment, T> {
-
- private var binding: T? = null
-
- init {
- fragment.lifecycle.addObserver(
- object : DefaultLifecycleObserver {
- override fun onCreate(owner: LifecycleOwner) {
- fragment.viewLifecycleOwnerLiveData.observe(fragment) { viewLifecycleOwner ->
- viewLifecycleOwner.lifecycle.addObserver(
- object : DefaultLifecycleObserver {
- override fun onDestroy(owner: LifecycleOwner) {
- binding = null
- }
- }
- )
- }
- }
- }
- )
- }
-
- override fun getValue(thisRef: Fragment, property: KProperty<*>): T {
- val binding = binding
- if (binding != null) {
- return binding
- }
-
- val lifecycle = fragment.viewLifecycleOwner.lifecycle
- if (!lifecycle.currentState.isAtLeast(Lifecycle.State.INITIALIZED)) {
- throw IllegalStateException(
- "Should not attempt to get bindings when Fragment views are destroyed."
- )
- }
-
- return viewBindingFactory(thisRef.requireView()).also { this.binding = it }
- }
-}
-
-fun <T : ViewBinding> Fragment.viewBinding(viewBindingFactory: (View) -> T) =
- FragmentViewBindingDelegate(this, viewBindingFactory)
-
-inline fun <T : ViewBinding> AppCompatActivity.viewBinding(
- crossinline bindingInflater: (LayoutInflater) -> T
-) = unsafeLazy { bindingInflater.invoke(layoutInflater) }
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 a3edac7d..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
@@ -9,16 +9,13 @@ import androidx.appcompat.app.AppCompatActivity
import androidx.fragment.app.FragmentActivity
import app.passwordstore.R
import app.passwordstore.data.repo.PasswordRepository
-import app.passwordstore.ui.sshkeygen.SshKeyGenActivity
-import app.passwordstore.ui.sshkeygen.SshKeyImportActivity
+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
-import app.passwordstore.util.extensions.launchActivity
import app.passwordstore.util.git.GitCommandExecutor
import app.passwordstore.util.git.sshj.SshAuthMethod
import app.passwordstore.util.git.sshj.SshKey
@@ -27,8 +24,6 @@ import app.passwordstore.util.settings.AuthMode
import com.github.michaelbull.result.Err
import com.github.michaelbull.result.Ok
import com.github.michaelbull.result.Result
-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
@@ -37,9 +32,6 @@ import dagger.hilt.components.SingletonComponent
import kotlin.coroutines.resume
import kotlin.coroutines.suspendCoroutine
import kotlinx.coroutines.withContext
-import logcat.LogPriority.ERROR
-import logcat.asLog
-import logcat.logcat
import net.schmizz.sshj.common.DisconnectReason
import net.schmizz.sshj.common.SSHException
import net.schmizz.sshj.userauth.password.PasswordFinder
@@ -106,12 +98,12 @@ abstract class GitOperation(protected val callingActivity: FragmentActivity) {
}
private fun getSshKey(generateKey: Boolean) {
- runCatching {
- callingActivity.launchActivity(
- if (generateKey) SshKeyGenActivity::class.java else SshKeyImportActivity::class.java
- )
- }
- .onFailure { e -> logcat(ERROR) { e.asLog() } }
+ callingActivity.startActivity(
+ MainActivity.setupIntent(
+ callingActivity,
+ if (generateKey) SetupStart.SshGenerate else SetupStart.Ssh,
+ )
+ )
}
private fun registerAuthProviders(
@@ -152,10 +144,8 @@ abstract class GitOperation(protected val callingActivity: FragmentActivity) {
) { _, _ ->
getSshKey(true)
}
- .setNeutralButton(callingActivity.resources.getString(R.string.dialog_cancel)) { _, _ ->
- // Finish the blank GitActivity so user doesn't have to press back
- callingActivity.finish()
- }
+ // Cancel only closes the dialog: the calling activity is the app's own screen now.
+ .setNeutralButton(callingActivity.resources.getString(R.string.dialog_cancel), null)
.show()
}
@@ -171,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)
}
}
}
@@ -182,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
@@ -197,7 +185,7 @@ abstract class GitOperation(protected val callingActivity: FragmentActivity) {
Toast.LENGTH_LONG,
)
.show()
- callingActivity.finish()
+ return Err(SSHException(DisconnectReason.AUTH_CANCELLED_BY_USER))
}
}
} else {
diff --git a/app/src/main/java/app/passwordstore/util/git/sshj/SshKey.kt b/app/src/main/java/app/passwordstore/util/git/sshj/SshKey.kt
index 109e36c9..7c8f5ba5 100644
--- a/app/src/main/java/app/passwordstore/util/git/sshj/SshKey.kt
+++ b/app/src/main/java/app/passwordstore/util/git/sshj/SshKey.kt
@@ -16,6 +16,7 @@ import android.util.Base64
import androidx.core.content.edit
import app.passwordstore.Application
import app.passwordstore.R
+import app.passwordstore.ui.setup.readAtMost
import app.passwordstore.util.crypto.keystoreAesKey
import app.passwordstore.util.crypto.requireUserAuthentication
import app.passwordstore.util.crypto.seal
@@ -29,6 +30,8 @@ import com.github.michaelbull.result.getOrElse
import com.github.michaelbull.result.runCatching
import java.io.File
import java.io.IOException
+import java.nio.file.Files
+import java.nio.file.StandardCopyOption
import java.security.KeyFactory
import java.security.KeyPair
import java.security.KeyPairGenerator
@@ -46,7 +49,9 @@ import net.schmizz.sshj.SSHClient
import net.schmizz.sshj.common.Buffer
import net.schmizz.sshj.common.Ed25519KeyFactory
import net.schmizz.sshj.common.KeyType
+import net.schmizz.sshj.userauth.keyprovider.KeyFormat
import net.schmizz.sshj.userauth.keyprovider.KeyProvider
+import net.schmizz.sshj.userauth.keyprovider.KeyProviderUtil
import org.bouncycastle.crypto.params.Ed25519PrivateKeyParameters
private const val PROVIDER_ANDROID_KEY_STORE = "AndroidKeyStore"
@@ -82,6 +87,25 @@ internal fun ed25519KeyPair(seed: ByteArray) =
Ed25519KeyFactory.getPrivateKey(seed),
)
+/**
+ * True when [lines] look like an SSH private key sshj can load: private key markers on the first
+ * and last line, in a format sshj recognizes. An OpenPGP private key block has the same markers and
+ * is refused by the format check.
+ */
+internal fun isSshPrivateKey(lines: List<String>): Boolean {
+ if (
+ lines.size < 2 ||
+ !Regex("BEGIN .* PRIVATE KEY").containsMatchIn(lines.first()) ||
+ !Regex("END .* PRIVATE KEY").containsMatchIn(lines.last())
+ )
+ return false
+ return try {
+ KeyProviderUtil.detectKeyFileFormat(lines.joinToString("\n"), false) != KeyFormat.Unknown
+ } catch (_: IOException) {
+ false
+ }
+}
+
object SshKey {
val sshPublicKey
@@ -182,15 +206,20 @@ object SshKey {
}
private fun delete() {
- androidKeystore.deleteEntry(KEYSTORE_ALIAS)
if (privateKeyFile.isFile) {
privateKeyFile.delete()
}
+ deleteKeyExtras()
+ type = null
+ }
+
+ /** Everything of the current key but its private key file. */
+ private fun deleteKeyExtras() {
+ androidKeystore.deleteEntry(KEYSTORE_ALIAS)
if (publicKeyFile.isFile) {
publicKeyFile.delete()
}
context.getEncryptedGitPrefs().edit { remove(PreferenceKeys.SSH_KEY_LOCAL_PASSPHRASE) }
- type = null
}
fun import(uri: Uri) {
@@ -198,9 +227,8 @@ object SshKey {
val fileSize =
context.contentResolver.query(uri, arrayOf(OpenableColumns.SIZE), null, null, null)?.use {
cursor ->
- // Cursor returns only a single row.
- cursor.moveToFirst()
- cursor.getInt(0)
+ // A single row; none when the provider cannot tell: the IOException below.
+ if (cursor.moveToFirst()) cursor.getInt(0) else null
} ?: throw IOException(context.getString(R.string.ssh_key_does_not_exist))
// We assume that an SSH key's ideal size is > 0 bytes && < 100 kilobytes.
@@ -212,26 +240,43 @@ object SshKey {
val sshKeyInputStream =
context.contentResolver.openInputStream(uri)
?: throw IOException(context.getString(R.string.ssh_key_does_not_exist))
- val lines = sshKeyInputStream.use { `is` -> `is`.bufferedReader().readLines() }
-
- // The file must have more than 2 lines, and the first and last line must have private key
- // markers.
- if (
- lines.size < 2 ||
- !Regex("BEGIN .* PRIVATE KEY").containsMatchIn(lines.first()) ||
- !Regex("END .* PRIVATE KEY").containsMatchIn(lines.last())
- )
+ // Bounded whatever the provider claimed above: a larger stream is refused, not read whole.
+ val bytes =
+ sshKeyInputStream.use { readAtMost(it, 100_000) }
+ ?: throw IllegalArgumentException(
+ context.getString(R.string.ssh_key_import_error_not_an_ssh_key_message)
+ )
+ val lines =
+ try {
+ bytes.inputStream().bufferedReader().readLines()
+ } finally {
+ bytes.fill(0)
+ }
+
+ // Private key markers and a format sshj can read; the current key stays when this fails.
+ if (!isSshPrivateKey(lines))
throw IllegalArgumentException(
context.getString(R.string.ssh_key_import_error_not_an_ssh_key_message)
)
// At this point, we are reasonably confident that we have actually been provided a private
- // key and delete the old key.
- delete()
- // Canonicalize line endings to '\n'.
- privateKeyFile.writeText(lines.joinToString("\n"))
-
+ // key. It replaces the old one atomically (an I/O error keeps the old key), then the old key's
+ // Keystore entry, public key and passphrase go.
+ val temp = File(privateKeyFile.parentFile, ".${privateKeyFile.name}.tmp")
+ try {
+ // Canonicalize line endings to '\n'.
+ temp.writeText(lines.joinToString("\n"))
+ Files.move(
+ temp.toPath(),
+ privateKeyFile.toPath(),
+ StandardCopyOption.REPLACE_EXISTING,
+ StandardCopyOption.ATOMIC_MOVE,
+ )
+ } finally {
+ temp.delete()
+ }
type = Type.Imported
+ deleteKeyExtras()
}
/** Deletes an Ed25519 key wrapped by androidx security-crypto, which can no longer be read. */
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/PGPKeyListViewModel.kt b/app/src/main/java/app/passwordstore/util/viewmodel/PGPKeyListViewModel.kt
deleted file mode 100644
index 5c7429cd..00000000
--- a/app/src/main/java/app/passwordstore/util/viewmodel/PGPKeyListViewModel.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.util.viewmodel
-
-import androidx.compose.runtime.getValue
-import androidx.compose.runtime.mutableStateOf
-import androidx.compose.runtime.setValue
-import androidx.lifecycle.ViewModel
-import androidx.lifecycle.viewModelScope
-import app.passwordstore.crypto.KeyUtils
-import app.passwordstore.crypto.PGPIdentifier
-import app.passwordstore.crypto.PGPKeyManager
-import com.github.michaelbull.result.map
-import com.github.michaelbull.result.onSuccess
-import dagger.hilt.android.lifecycle.HiltViewModel
-import javax.inject.Inject
-import kotlinx.collections.immutable.ImmutableList
-import kotlinx.collections.immutable.persistentListOf
-import kotlinx.collections.immutable.toPersistentList
-import kotlinx.coroutines.launch
-
-@HiltViewModel
-class PGPKeyListViewModel @Inject constructor(private val keyManager: PGPKeyManager) : ViewModel() {
- var keys: ImmutableList<PGPIdentifier> by mutableStateOf(persistentListOf())
-
- init {
- updateKeySet()
- }
-
- fun updateKeySet() {
- viewModelScope.launch {
- keyManager
- .getAllKeys()
- .map { keys -> keys.mapNotNull { key -> KeyUtils.tryGetEmail(key) } }
- .onSuccess { keys = it.toPersistentList() }
- }
- }
-
- fun deleteKey(identifier: PGPIdentifier) {
- viewModelScope.launch {
- keyManager.removeKey(identifier)
- updateKeySet()
- }
- }
-}
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 48804e7a..5f35644d 100644
--- a/app/src/main/java/app/passwordstore/util/viewmodel/SearchableRepositoryViewModel.kt
+++ b/app/src/main/java/app/passwordstore/util/viewmodel/SearchableRepositoryViewModel.kt
@@ -6,24 +6,12 @@ package app.passwordstore.util.viewmodel
import android.app.Application
import android.content.SharedPreferences
-import android.os.Parcelable
-import android.view.LayoutInflater
-import android.view.View
-import android.view.ViewGroup
import androidx.annotation.VisibleForTesting
import androidx.lifecycle.AndroidViewModel
-import androidx.recyclerview.selection.ItemDetailsLookup
-import androidx.recyclerview.selection.ItemKeyProvider
-import androidx.recyclerview.selection.Selection
-import androidx.recyclerview.selection.SelectionPredicates
-import androidx.recyclerview.selection.SelectionTracker
-import androidx.recyclerview.selection.StorageStrategy
-import androidx.recyclerview.widget.DiffUtil
-import androidx.recyclerview.widget.ListAdapter
-import androidx.recyclerview.widget.RecyclerView
import app.passwordstore.data.password.PasswordItem
import app.passwordstore.data.repo.PasswordRepository
import app.passwordstore.injection.prefs.SettingsPreferences
+import app.passwordstore.ui.list.listRows
import app.passwordstore.util.autofill.AutofillPreferences
import app.passwordstore.util.checkMainThread
import app.passwordstore.util.coroutines.DispatcherProvider
@@ -34,9 +22,7 @@ import com.github.androidpasswordstore.sublimefuzzy.Fuzzy
import dagger.hilt.android.lifecycle.HiltViewModel
import java.io.File
import java.text.Collator
-import java.util.Locale
import javax.inject.Inject
-import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
@@ -50,9 +36,7 @@ import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.mapLatest
import kotlinx.coroutines.flow.toList
import kotlinx.coroutines.flow.update
-import kotlinx.coroutines.launch
import kotlinx.coroutines.yield
-import me.zhanghai.android.fastscroll.PopupTextProvider
private fun File.toPasswordItem() =
if (isFile) PasswordItem.newPassword(name, this, PasswordRepository.getRepositoryDirectory())
@@ -70,12 +54,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(
@@ -88,9 +67,6 @@ private fun PasswordItem.Companion.makeComparator(
)
}
-val PasswordItem.stableId: String
- get() = file.absolutePath
-
enum class FilterMode {
NoFilter,
StrictDomain,
@@ -105,7 +81,6 @@ enum class SearchMode {
enum class ListMode {
FilesOnly,
- DirectoriesOnly,
AllEntries,
}
@@ -191,7 +166,7 @@ constructor(
)
)
- data class SearchResult(val passwordItems: List<PasswordItem>, val isFiltered: Boolean)
+ data class SearchResult(val passwordItems: List<PasswordItem>)
val searchResult =
searchActionFlow
@@ -207,8 +182,6 @@ constructor(
when (searchAction.listMode) {
ListMode.FilesOnly ->
listResultFlow.filter { it.isFile }.flowOn(dispatcherProvider.io())
- ListMode.DirectoriesOnly ->
- listResultFlow.filter { it.isDirectory }.flowOn(dispatcherProvider.io())
ListMode.AllEntries -> listResultFlow
}
val passwordList =
@@ -264,7 +237,7 @@ constructor(
.map { it.second }
}
}
- SearchResult(passwordList, isFiltered = searchAction.filterMode != FilterMode.NoFilter)
+ SearchResult(passwordList)
}
.flowOn(dispatcherProvider.io())
@@ -302,55 +275,28 @@ constructor(
private val _currentDir = MutableStateFlow(root)
val currentDir = _currentDir.asStateFlow()
- data class NavigationStackEntry(val dir: File, val recyclerViewState: Parcelable?)
-
- private val navigationStack = ArrayDeque<NavigationStackEntry>()
+ /** [searchResult] as folder and entry rows for the Compose list, built off the main thread. */
+ val rows =
+ searchResult
+ .map { result -> listRows(result.passwordItems.map { it.file }, currentDir.value) }
+ .flowOn(dispatcherProvider.io())
- fun navigateTo(
- newDirectory: File = root,
- listMode: ListMode = ListMode.AllEntries,
- recyclerViewState: Parcelable? = null,
- pushPreviousLocation: Boolean = true,
- ) {
+ /** Lists [newDirectory]; the Compose list keeps the back stack (one route per folder). */
+ fun navigateTo(newDirectory: File) {
if (!newDirectory.exists()) return
require(newDirectory.isDirectory) { "Can only navigate to a directory" }
- if (pushPreviousLocation) {
- navigationStack.addFirst(NavigationStackEntry(_currentDir.value, recyclerViewState))
- }
searchActionFlow.update {
makeSearchAction(
filter = "",
baseDirectory = newDirectory,
filterMode = FilterMode.NoFilter,
searchMode = SearchMode.InCurrentDirectoryOnly,
- listMode = listMode,
+ listMode = ListMode.AllEntries,
)
}
_currentDir.update { newDirectory }
}
- val canNavigateBack
- get() = navigationStack.isNotEmpty()
-
- /**
- * Navigate back to the last location on the [navigationStack] and restore a cached scroll
- * position if possible.
- *
- * Returns the old RecyclerView's LinearLayoutManager state as a [Parcelable] if it was cached.
- */
- fun navigateBack(): Parcelable? {
- if (!canNavigateBack) return null
- val (oldDir, oldRecyclerViewState) = navigationStack.removeFirst()
- navigateTo(oldDir, pushPreviousLocation = false)
- return oldRecyclerViewState
- }
-
- fun reset() {
- navigationStack.clear()
- forceUpdateOnNextSearchAction()
- navigateTo(pushPreviousLocation = false)
- }
-
fun search(
filter: String,
baseDirectory: File? = null,
@@ -397,114 +343,3 @@ constructor(
}
}
}
-
-private object PasswordItemDiffCallback : DiffUtil.ItemCallback<PasswordItem>() {
-
- override fun areItemsTheSame(oldItem: PasswordItem, newItem: PasswordItem) =
- oldItem.file.absolutePath == newItem.file.absolutePath
-
- override fun areContentsTheSame(oldItem: PasswordItem, newItem: PasswordItem) = oldItem == newItem
-}
-
-open class SearchableRepositoryAdapter<T : RecyclerView.ViewHolder>(
- private val layoutRes: Int,
- private val viewHolderCreator: (view: View) -> T,
- private val coroutineScope: CoroutineScope,
- private val dispatcherProvider: DispatcherProvider,
- private val viewHolderBinder:
- suspend T.(item: PasswordItem, dispatcherProvider: DispatcherProvider) -> Unit,
-) : ListAdapter<PasswordItem, T>(PasswordItemDiffCallback), PopupTextProvider {
-
- fun <T : ItemDetailsLookup<String>> makeSelectable(
- recyclerView: RecyclerView,
- itemDetailsLookupCreator: (recyclerView: RecyclerView) -> T,
- ) {
- selectionTracker =
- SelectionTracker.Builder(
- "SearchableRepositoryAdapter",
- recyclerView,
- itemKeyProvider,
- itemDetailsLookupCreator(recyclerView),
- StorageStrategy.createStringStorage(),
- )
- .withSelectionPredicate(SelectionPredicates.createSelectAnything())
- .build()
- .apply {
- addObserver(
- object : SelectionTracker.SelectionObserver<String>() {
- override fun onSelectionChanged() {
- this@SearchableRepositoryAdapter.onSelectionChangedListener?.invoke(
- requireSelectionTracker().selection
- )
- }
- }
- )
- }
- }
-
- private var onItemClickedListener: ((holder: T, item: PasswordItem) -> Unit)? = null
-
- open fun onItemClicked(
- listener: (holder: T, item: PasswordItem) -> Unit
- ): SearchableRepositoryAdapter<T> {
- check(onItemClickedListener == null) {
- "Only a single listener can be registered for onItemClicked"
- }
- onItemClickedListener = listener
- return this
- }
-
- private var onSelectionChangedListener: ((selection: Selection<String>) -> Unit)? = null
-
- open fun onSelectionChanged(
- listener: (selection: Selection<String>) -> Unit
- ): SearchableRepositoryAdapter<T> {
- check(onSelectionChangedListener == null) {
- "Only a single listener can be registered for onSelectionChanged"
- }
- onSelectionChangedListener = listener
- return this
- }
-
- private val itemKeyProvider =
- object : ItemKeyProvider<String>(SCOPE_MAPPED) {
- override fun getKey(position: Int) = getItem(position).stableId
-
- override fun getPosition(key: String) =
- (0 until itemCount).firstOrNull { getItem(it).stableId == key } ?: RecyclerView.NO_POSITION
- }
-
- private var selectionTracker: SelectionTracker<String>? = null
-
- fun requireSelectionTracker() = selectionTracker!!
-
- private val selectedFiles
- get() = requireSelectionTracker().selection.map { File(it) }
-
- fun getSelectedItems() = selectedFiles.map { it.toPasswordItem() }
-
- fun getPositionForFile(file: File) = itemKeyProvider.getPosition(file.absolutePath)
-
- final override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): T {
- val view = LayoutInflater.from(parent.context).inflate(layoutRes, parent, false)
- return viewHolderCreator(view)
- }
-
- final override fun onBindViewHolder(holder: T, position: Int) {
- val item = getItem(position)
- holder.apply {
- coroutineScope.launch(dispatcherProvider.mainImmediate()) {
- viewHolderBinder.invoke(holder, item, dispatcherProvider)
- }
- selectionTracker?.let { itemView.isSelected = it.isSelected(item.stableId) }
- itemView.setOnClickListener {
- // Do not emit custom click events while the user is selecting items.
- if (selectionTracker?.hasSelection() != true) onItemClickedListener?.invoke(holder, item)
- }
- }
- }
-
- final override fun getPopupText(view: View, position: Int): String {
- return getItem(position).name[0].toString().uppercase(Locale.getDefault())
- }
-}