diff options
| author | Danilo M. <danix@danix.xyz> | 2026-11-04 11:28:40 +0100 |
|---|---|---|
| committer | Danilo M. <danix@danix.xyz> | 2026-11-04 11:28:40 +0100 |
| commit | 1d564b91ecb1b52c585e21f10596e80bc69292d4 (patch) | |
| tree | e7cfa8bfe4ec23b11f81613d67125130c4932c1c /app/src/main/java | |
| parent | e573a321d982341f2940868cb80da8d1eccc6981 (diff) | |
| download | HardenedPass-1d564b91ecb1b52c585e21f10596e80bc69292d4.tar.gz HardenedPass-1d564b91ecb1b52c585e21f10596e80bc69292d4.zip | |
feat(app): move the autofill decryption prompts to Compose
AutofillDecryptActivity shows the no keys dialog and the entry view's
passphrase dialog in Compose over its transparent FLAG_SECURE window.
The flow starts once, on the first window focus, instead of on every
onStart: Android 14 evicts a screen lock prompt shown before the
activity is on top, and a restart showed a second passphrase dialog.
After a key import it restarts on the next focus. Dismissing the no
keys dialog now finishes instead of leaving an invisible window.
Rotation and resizing no longer recreate the activity; other
recreations cancel the fill.
The passphrase is now cached only after a successful screen lock
prompt, as the entry view does: a passphrase typed after a cancelled
or unavailable prompt was cached whenever the cache feature was on.
PasswordDialog, its layout, dimens.xml and requireKeysExist lose their
last user and go.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Diffstat (limited to 'app/src/main/java')
5 files changed, 145 insertions, 218 deletions
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 6539aa14..17859cad 100644 --- a/app/src/main/java/app/passwordstore/ui/autofill/AutofillDecryptActivity.kt +++ b/app/src/main/java/app/passwordstore/ui/autofill/AutofillDecryptActivity.kt @@ -11,8 +11,16 @@ import android.content.IntentSender import android.os.Build import android.os.Bundle import android.view.autofill.AutofillManager +import androidx.activity.compose.setContent +import androidx.activity.result.contract.ActivityResultContracts.StartActivityForResult +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 @@ -20,9 +28,12 @@ import app.passwordstore.crypto.PGPIdentifier import app.passwordstore.data.crypto.PGPPassphraseCache import app.passwordstore.data.passfile.PasswordEntry import app.passwordstore.data.repo.PasswordRepository +import app.passwordstore.ui.compose.theme.APSTheme import app.passwordstore.ui.crypto.BasePGPActivity -import app.passwordstore.ui.crypto.PasswordDialog import app.passwordstore.ui.crypto.getPGPIdentifiers +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 @@ -48,6 +59,10 @@ 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() { @@ -56,50 +71,131 @@ class AutofillDecryptActivity : BasePGPActivity() { @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) + // 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) + } + }, + 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()) @@ -112,8 +208,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 */ @@ -128,56 +223,25 @@ 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 } - private suspend fun decryptWithPassphrase( - filePath: File, - identifiers: List<PGPIdentifier>, - clientState: Bundle, - action: AutofillAction, - password: String, - ) { - val credentials = decryptCredential(filePath, password, identifiers) + private suspend fun decryptWithPassphrase(identifiers: List<PGPIdentifier>, password: String) { + val credentials = decryptCredential(File(filePath), password, identifiers) if (credentials == null) { setResult(RESULT_CANCELED) } else { @@ -219,7 +283,7 @@ class AutofillDecryptActivity : BasePGPActivity() { .onSuccess { result -> return runCatching { runCatching { - if (features.isEnabled(EnablePGPPassphraseCache)) { + if (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/AutofillModel.kt b/app/src/main/java/app/passwordstore/ui/autofill/AutofillModel.kt index ac2d0948..9f436ea2 100644 --- a/app/src/main/java/app/passwordstore/ui/autofill/AutofillModel.kt +++ b/app/src/main/java/app/passwordstore/ui/autofill/AutofillModel.kt @@ -6,6 +6,7 @@ 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 @@ -36,3 +37,11 @@ fun autofillRows( 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/crypto/BasePGPActivity.kt b/app/src/main/java/app/passwordstore/ui/crypto/BasePGPActivity.kt index 6831761b..e40585d5 100644 --- a/app/src/main/java/app/passwordstore/ui/crypto/BasePGPActivity.kt +++ b/app/src/main/java/app/passwordstore/ui/crypto/BasePGPActivity.kt @@ -12,19 +12,15 @@ 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.fragment.app.FragmentActivity -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.main.MainActivity -import app.passwordstore.ui.setup.SetupStart import app.passwordstore.util.coroutines.DispatcherProvider import app.passwordstore.util.extensions.clipboard import app.passwordstore.util.extensions.getString @@ -33,29 +29,14 @@ 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 com.google.android.material.dialog.MaterialAlertDialogBuilder 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() { - /** 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 @@ -71,31 +52,6 @@ open class BasePGPActivity : AppCompatActivity() { window.setFlags(WindowManager.LayoutParams.FLAG_SECURE, WindowManager.LayoutParams.FLAG_SECURE) } - /** - * 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( - MainActivity.setupIntent(this@BasePGPActivity, SetupStart.PgpImport) - ) - } - .show() - } - } else { - onKeysExist() - } - } - } - companion object { const val EXTRA_FILE_PATH = "FILE_PATH" 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/entry/EntryDestination.kt b/app/src/main/java/app/passwordstore/ui/entry/EntryDestination.kt index 637c9b56..e5341320 100644 --- a/app/src/main/java/app/passwordstore/ui/entry/EntryDestination.kt +++ b/app/src/main/java/app/passwordstore/ui/entry/EntryDestination.kt @@ -173,9 +173,12 @@ fun EntryDestination( } } -/** Asks for the PGP passphrase, as the old `PasswordDialog` did. */ +/** + * Asks for the PGP passphrase (entry view and autofill). The passphrase lives only in `remember` + * and the dialog's window is FLAG_SECURE. + */ @Composable -private fun PassphraseDialog( +internal fun PassphraseDialog( wrong: Boolean, cacheEnabled: Boolean, clearCache: Boolean, |
