From f6eceb406bfff8d36ccac7f7b0d428f148e76a55 Mon Sep 17 00:00:00 2001 From: "Danilo M." Date: Tue, 3 Nov 2026 08:47:21 +0100 Subject: feat(app): open the Compose editor from the list and entry view EditRoute replaces the legacy editor for new entries and edits. An edit copies its plaintext from the entry view's ViewModel below it, never through the route or saved state. Saving returns to the list, closing returns to where the editor was opened. FLAG_SECURE is now counted in MainActivity, so moving between the entry view and the editor never clears it. Co-Authored-By: Claude Opus 5.5 --- .../passwordstore/ui/editor/EditorDestination.kt | 262 +++++++++++++++++++++ .../app/passwordstore/ui/entry/EntryDestination.kt | 41 +--- .../app/passwordstore/ui/list/ListDestination.kt | 5 +- .../java/app/passwordstore/ui/main/MainActivity.kt | 106 +++++++-- 4 files changed, 359 insertions(+), 55 deletions(-) create mode 100644 app/src/main/java/app/passwordstore/ui/editor/EditorDestination.kt (limited to 'app') 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..8b66ba5e --- /dev/null +++ b/app/src/main/java/app/passwordstore/ui/editor/EditorDestination.kt @@ -0,0 +1,262 @@ +/* + * Copyright (C) 2026 Danilo M. + * 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(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(current.dir) ?: return + activity.lifecycleScope.launch { + when (val result = viewModel.save(ids, target, original, activity)) { + null -> Unit + is SaveResult.Failed -> failure = activity.getString(result.message, result.arg) + is SaveResult.Saved -> { + if (copy) activity.copyPasswordToClipboard(result.form.password) + result.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 theme + // or locale change in the middle of a save cancels before this commit, and the next + // commit picks the file up. + activity.commitChange( + activity.getString( + if (original != null) R.string.git_commit_edit_text else R.string.git_commit_add_text, + target.relativeTo(root).path.removeSuffix(".gpg"), + ) + ) + keyboard?.hide() + focusManager.clearFocus() + onSaved(result) + } + } + } + } + + 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/entry/EntryDestination.kt b/app/src/main/java/app/passwordstore/ui/entry/EntryDestination.kt index 5c16b5a3..1b3a75d2 100644 --- a/app/src/main/java/app/passwordstore/ui/entry/EntryDestination.kt +++ b/app/src/main/java/app/passwordstore/ui/entry/EntryDestination.kt @@ -7,7 +7,6 @@ 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 @@ -22,7 +21,6 @@ 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.key @@ -43,12 +41,9 @@ 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 @@ -62,29 +57,21 @@ 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). + * 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, - onSaved: () -> Unit, + onEdit: () -> 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() } @@ -105,17 +92,13 @@ fun EntryDestination( } } - // ponytail: after process death during key import or the editor, the unlock flow can start + // 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() } - 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. @@ -150,7 +133,7 @@ fun EntryDestination( otpFractionLeft = totp?.fractionLeft() ?: 0f, showPassword = showPassword, onBack = onClose, - onEdit = { entry?.let { editor.launch(editIntent(activity, file, root, it)) } }, + onEdit = { if (entry != null) onEdit() }, onShare = { fields?.password?.let { activity.startActivity(shareIntent(activity, it)) } }, onCopyName = { activity.copyTextToClipboard(file.nameWithoutExtension) }, onCopySecret = { activity.copyPasswordToClipboard(it) }, @@ -252,18 +235,6 @@ private fun PassphraseDialog( ) } -/** 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( 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 5c248dcb..eff3cf02 100644 --- a/app/src/main/java/app/passwordstore/ui/list/ListDestination.kt +++ b/app/src/main/java/app/passwordstore/ui/list/ListDestination.kt @@ -60,7 +60,7 @@ private enum class ListDialog { /** * The list at [path] (relative to the store root): wires [ListScreen] to the [viewModel], the * folder picker, the legacy activities and the store operations. [onOpenFolder] takes a path - * relative to the root, [onOpenEntry] an entry file. + * relative to the root, [onOpenEntry] an entry file; [onNewEntry] opens the editor in this folder. */ @Composable fun ListDestination( @@ -68,6 +68,7 @@ fun ListDestination( path: String, onOpenFolder: (relativePath: String) -> Unit, onOpenEntry: (File) -> Unit, + onNewEntry: () -> Unit, modifier: Modifier = Modifier, viewModel: SearchableRepositoryViewModel = hiltViewModel(), ) { @@ -178,7 +179,7 @@ fun ListDestination( onDismiss = { dialog = ListDialog.None }, onNewEntry = { dialog = ListDialog.None - ifInitialized { activity.createEntry(dir) } + ifInitialized(onNewEntry) }, onNewFolder = { dialog = ListDialog.None 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 174f7665..1a1ff500 100644 --- a/app/src/main/java/app/passwordstore/ui/main/MainActivity.kt +++ b/app/src/main/java/app/passwordstore/ui/main/MainActivity.kt @@ -5,9 +5,13 @@ package app.passwordstore.ui.main -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 @@ -16,8 +20,11 @@ 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.PasswordCreationActivity +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 @@ -48,6 +55,13 @@ import kotlinx.serialization.Serializable */ @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. Extends [BaseGitActivity] (an AppCompatActivity, so also @@ -59,6 +73,9 @@ 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(BasePGPActivity.EXTRA_FILE_PATH) @@ -86,24 +103,66 @@ class MainActivity : BaseGitActivity() { nav.navigate(EntryRoute(file.absolutePath)) } }, + onNewEntry = { + if (nav.currentBackStackEntry?.id == entry.id) nav.navigate(EditRoute(path)) + }, ) } composable { entry -> + val file = File(entry.toRoute().filePath) + SecureWindow(this@MainActivity) EntryDestination( activity = this@MainActivity, - file = File(entry.toRoute().filePath), + 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) { + val root = PasswordRepository.getRepositoryDirectory().canonicalFile + val dir = file.canonicalFile.parentFile?.relativeTo(root)?.path.orEmpty() + nav.navigate(EditRoute(dir, file.nameWithoutExtension)) + } + }, + ) + } + composable { entry -> + val route = entry.toRoute() + 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() + } catch (_: IllegalArgumentException) { + null + } + } + ?.let { hiltViewModel(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. Opened from a shortcut, - // the entry has no list below it: the list root replaces it. + // 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.previousBackStackEntry != null) nav.popBackStack() - else nav.navigate(ListRoute()) { popUpTo { inclusive = true } } + if (!nav.popBackStack(inclusive = false)) + nav.navigate(ListRoute()) { popUpTo { inclusive = true } } } }, ) @@ -113,6 +172,16 @@ class MainActivity : BaseGitActivity() { } } + /** 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; starts onboarding and returns false when there is none yet. */ fun checkStore(): Boolean { PasswordRepository.initialize() @@ -145,17 +214,6 @@ class MainActivity : BaseGitActivity() { shortcutHandler.addPinnedShortcut(item, item.createAuthEnabledIntent(this)) } - fun createEntry(dir: File) { - startActivity( - Intent(this, PasswordCreationActivity::class.java) - .putExtra(BasePGPActivity.EXTRA_FILE_PATH, dir.absolutePath) - .putExtra( - BasePGPActivity.EXTRA_REPO_PATH, - PasswordRepository.getRepositoryDirectory().absolutePath, - ) - ) - } - fun openSettings() { launchActivity(SettingsActivity::class.java) } @@ -163,3 +221,15 @@ class MainActivity : BaseGitActivity() { private fun entryItem(file: File) = PasswordItem.newPassword(file.name, file, PasswordRepository.getRepositoryDirectory()) } + +/** + * 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() } + } +} -- cgit v1.2.3