diff options
| author | Danilo M. <danix@danix.xyz> | 2026-11-02 23:32:08 +0100 |
|---|---|---|
| committer | Danilo M. <danix@danix.xyz> | 2026-11-02 23:32:08 +0100 |
| commit | 660afb674b1580a1fc54f09550d9279cf96ec71c (patch) | |
| tree | dfe3ba22e24b6b3990cbb049646f2a147e6e6daf /app/src | |
| parent | 22bb0aae958e8458274be994f3e679cb226d2241 (diff) | |
| download | HardenedPass-660afb674b1580a1fc54f09550d9279cf96ec71c.tar.gz HardenedPass-660afb674b1580a1fc54f09550d9279cf96ec71c.zip | |
feat(app): show the Compose entry view in MainActivity
The list navigates to EntryRoute and shortcuts start on it, replacing the
legacy decrypt screen. FLAG_SECURE is on while an entry is composed and
off on the list; the route is checked with isFileInRepo again before
decrypting. Saving in the editor returns to the list. Password and one-time
code copies start the clipboard clear timer.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Diffstat (limited to 'app/src')
3 files changed, 301 insertions, 33 deletions
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..a54b517d --- /dev/null +++ b/app/src/main/java/app/passwordstore/ui/entry/EntryDestination.kt @@ -0,0 +1,266 @@ +/* + * 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 android.view.WindowManager +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.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +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.PasswordEntry +import app.passwordstore.data.passfile.Totp +import app.passwordstore.data.repo.PasswordRepository +import app.passwordstore.ui.crypto.BasePGPActivity +import app.passwordstore.ui.crypto.BasePGPActivity.Companion.getParentPath +import app.passwordstore.ui.crypto.PasswordCreationActivity +import app.passwordstore.ui.crypto.copyPasswordToClipboard +import app.passwordstore.ui.crypto.copyTextToClipboard +import app.passwordstore.ui.crypto.getPGPIdentifiers +import app.passwordstore.ui.main.LaunchActivity +import app.passwordstore.ui.main.MainActivity +import app.passwordstore.ui.pgp.PGPKeyImportActivity +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, the + * clipboard and the legacy editor. [onClose] leaves the destination; [onSaved] runs after the + * editor saved the entry (the host returns to the list). + */ +@Composable +fun EntryDestination( + activity: MainActivity, + file: File, + onClose: () -> Unit, + onSaved: () -> Unit, + modifier: Modifier = Modifier, + viewModel: EntryViewModel = hiltViewModel(), +) { + val root = PasswordRepository.getRepositoryDirectory() + + // Secrets stay out of screenshots and recents while this destination is composed. + // ponytail: plain add/clear, fine while no other secret-showing destination can overlap this + // one in a transition; count in MainActivity once the editor (step 4) becomes a destination. + DisposableEffect(Unit) { + activity.window.addFlags(WindowManager.LayoutParams.FLAG_SECURE) + onDispose { activity.window.clearFlags(WindowManager.LayoutParams.FLAG_SECURE) } + } + + // 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) + } + } + + val keyImport = + rememberLauncherForActivityResult(StartActivityForResult()) { result -> + if (result.resultCode == Activity.RESULT_OK) unlock() else onClose() + } + val editor = + rememberLauncherForActivityResult(StartActivityForResult()) { result -> + if (result.resultCode == Activity.RESULT_OK) onSaved() + } + + // 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 = { entry?.let { editor.launch(editIntent(activity, file, root, it)) } }, + 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(Intent(activity, PGPKeyImportActivity::class.java)) + } + ) { + Text(stringResource(R.string.button_label_import)) + } + }, + ) + EntryPrompt.Passphrase, + EntryPrompt.WrongPassphrase -> + 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, as the old `PasswordDialog` did. */ +@Composable +private 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)) + } + }, + ) +} + +/** The legacy editor, filled exactly as the old decrypt screen filled it. */ +private fun editIntent(activity: Activity, file: File, root: File, entry: PasswordEntry): Intent = + Intent(activity, PasswordCreationActivity::class.java) + .setAction(Intent.ACTION_VIEW) + .putExtra(BasePGPActivity.EXTRA_FILE_PATH, getParentPath(file.absolutePath, root.absolutePath)) + .putExtra(BasePGPActivity.EXTRA_REPO_PATH, root.absolutePath) + .putExtra(PasswordCreationActivity.EXTRA_FILE_NAME, file.nameWithoutExtension) + .putExtra(PasswordCreationActivity.EXTRA_USERNAME, entry.username) + .putExtra(PasswordCreationActivity.EXTRA_PASSWORD, entry.password) + .putExtra(PasswordCreationActivity.EXTRA_EXTRA_CONTENT, entry.extraContentWithoutUsername) + .putExtra(PasswordCreationActivity.EXTRA_EDITING, true) + +/** 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/list/ListDestination.kt b/app/src/main/java/app/passwordstore/ui/list/ListDestination.kt index 40f9ce41..98c820b1 100644 --- a/app/src/main/java/app/passwordstore/ui/list/ListDestination.kt +++ b/app/src/main/java/app/passwordstore/ui/list/ListDestination.kt @@ -61,13 +61,15 @@ private enum class ListDialog { /** * The list at [path] (relative to the store root): wires [ListScreen] to the [viewModel], the - * legacy activities and the store operations. [onOpenFolder] takes a path relative to the root. + * legacy activities and the store operations. [onOpenFolder] takes a path relative to the root, + * [onOpenEntry] an entry file. */ @Composable fun ListDestination( activity: MainActivity, path: String, onOpenFolder: (relativePath: String) -> Unit, + onOpenEntry: (File) -> Unit, modifier: Modifier = Modifier, viewModel: SearchableRepositoryViewModel = hiltViewModel(), ) { @@ -149,7 +151,7 @@ fun ListDestination( }, onOpenEntry = { entry -> activity.recordUse(File(entry)) - activity.openEntry(File(entry)) + onOpenEntry(File(entry)) }, onToggle = { p -> selected = if (p in selected) selected - p else selected + p }, onClearSelection = { selected = emptyList() }, diff --git a/app/src/main/java/app/passwordstore/ui/main/MainActivity.kt b/app/src/main/java/app/passwordstore/ui/main/MainActivity.kt index 5877ae07..174f7665 100644 --- a/app/src/main/java/app/passwordstore/ui/main/MainActivity.kt +++ b/app/src/main/java/app/passwordstore/ui/main/MainActivity.kt @@ -5,13 +5,9 @@ package app.passwordstore.ui.main -import android.app.Activity -import android.content.ComponentName import android.content.Intent import android.os.Bundle import androidx.activity.compose.setContent -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect import androidx.navigation.compose.NavHost import androidx.navigation.compose.composable import androidx.navigation.compose.rememberNavController @@ -20,8 +16,8 @@ import app.passwordstore.data.password.PasswordItem import app.passwordstore.data.repo.PasswordRepository import app.passwordstore.ui.compose.theme.APSTheme import app.passwordstore.ui.crypto.BasePGPActivity -import app.passwordstore.ui.crypto.DecryptActivity import app.passwordstore.ui.crypto.PasswordCreationActivity +import app.passwordstore.ui.entry.EntryDestination import app.passwordstore.ui.git.base.BaseGitActivity import app.passwordstore.ui.list.ListDestination import app.passwordstore.ui.list.folderMoves @@ -46,7 +42,10 @@ import kotlinx.serialization.Serializable */ @Serializable data class ListRoute(val path: String = "") -/** One entry. [filePath] was checked with [LaunchActivity.isFileInRepo] before routing here. */ +/** + * 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) /** @@ -81,18 +80,32 @@ class MainActivity : BaseGitActivity() { pushes.forEach { nav.navigate(ListRoute(it)) } } }, + onOpenEntry = { file -> + if (nav.currentBackStackEntry?.id == entry.id) { + addShortcut(file) + nav.navigate(EntryRoute(file.absolutePath)) + } + }, ) } composable<EntryRoute> { entry -> - val route = entry.toRoute<EntryRoute>() - LegacyScreen( - this@MainActivity, - Intent(this@MainActivity, DecryptActivity::class.java) - .putExtra(BasePGPActivity.EXTRA_FILE_PATH, route.filePath) - .putExtra( - BasePGPActivity.EXTRA_REPO_PATH, - PasswordRepository.getRepositoryDirectory().absolutePath, - ), + EntryDestination( + activity = this@MainActivity, + file = File(entry.toRoute<EntryRoute>().filePath), + 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() + } + }, + onSaved = { + // Back to the list, which re-reads the store on resume. Opened from a shortcut, + // the entry has no list below it: the list root replaces it. + if (nav.currentBackStackEntry?.id == entry.id) { + if (nav.previousBackStackEntry != null) nav.popBackStack() + else nav.navigate(ListRoute()) { popUpTo<EntryRoute> { inclusive = true } } + } + }, ) } } @@ -121,13 +134,10 @@ class MainActivity : BaseGitActivity() { launchGitOperation(op).onFailure { promptOnErrorHandler(it) } } - // ponytail: the list still opens the legacy decrypt screen directly; step 3 navigates to - // EntryRoute instead (its trampoline would finish this activity and close the list). - fun openEntry(file: File) { + /** Adds [file] to the launcher's dynamic shortcuts, as opening it from the list always did. */ + fun addShortcut(file: File) { val item = entryItem(file) - val authIntent = item.createAuthEnabledIntent(this) - startActivity(Intent(authIntent).setComponent(ComponentName(this, DecryptActivity::class.java))) - shortcutHandler.addDynamicShortcut(item, authIntent) + shortcutHandler.addDynamicShortcut(item, item.createAuthEnabledIntent(this)) } fun pinEntry(file: File) { @@ -153,13 +163,3 @@ class MainActivity : BaseGitActivity() { private fun entryItem(file: File) = PasswordItem.newPassword(file.name, file, PasswordRepository.getRepositoryDirectory()) } - -// ponytail: trampoline to a screen not yet ported; each port replaces one call with the -// real composable, and the last port deletes this function. -@Composable -private fun LegacyScreen(activity: Activity, intent: Intent) { - LaunchedEffect(Unit) { - activity.startActivity(intent) - activity.finish() - } -} |
