diff options
Diffstat (limited to 'app/src/main/java')
4 files changed, 359 insertions, 55 deletions
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. <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(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 @@ -49,6 +56,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 * the FragmentActivity BiometricPrompt needs) because the git operations require one; it hosts no @@ -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<EntryRoute> { entry -> + val file = File(entry.toRoute<EntryRoute>().filePath) + SecureWindow(this@MainActivity) EntryDestination( activity = this@MainActivity, - file = File(entry.toRoute<EntryRoute>().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<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. 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<EntryRoute> { inclusive = true } } + if (!nav.popBackStack<ListRoute>(inclusive = false)) + nav.navigate(ListRoute()) { popUpTo<EntryRoute> { 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() } + } +} |
