diff options
Diffstat (limited to 'app/src/main/java')
15 files changed, 402 insertions, 134 deletions
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 eb533411..dde6a7ae 100644 --- a/app/src/main/java/app/passwordstore/ui/autofill/AutofillSaveActivity.kt +++ b/app/src/main/java/app/passwordstore/ui/autofill/AutofillSaveActivity.kt @@ -20,6 +20,7 @@ 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.ui.prompts.PromptHost import app.passwordstore.util.autofill.AutofillMatcher import app.passwordstore.util.autofill.AutofillPreferences import app.passwordstore.util.autofill.AutofillResponseBuilder @@ -139,6 +140,7 @@ class AutofillSaveActivity : AppCompatActivity() { onSaved = ::finishSaved, generate = generate, ) + PromptHost(this@AutofillSaveActivity) } } } diff --git a/app/src/main/java/app/passwordstore/ui/editor/EditorDestination.kt b/app/src/main/java/app/passwordstore/ui/editor/EditorDestination.kt index b5a961d3..8e433957 100644 --- a/app/src/main/java/app/passwordstore/ui/editor/EditorDestination.kt +++ b/app/src/main/java/app/passwordstore/ui/editor/EditorDestination.kt @@ -13,6 +13,7 @@ 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.SnackbarDuration import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable @@ -41,7 +42,6 @@ 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 @@ -142,7 +142,7 @@ fun EditorDestination( R.string.password_creation_file_delete_fail_message, it.nameWithoutExtension, ), - length = Snackbar.LENGTH_LONG, + length = SnackbarDuration.Long, ) } } diff --git a/app/src/main/java/app/passwordstore/ui/git/base/BaseGitActivity.kt b/app/src/main/java/app/passwordstore/ui/git/base/BaseGitActivity.kt index 32872761..fb29b810 100644 --- a/app/src/main/java/app/passwordstore/ui/git/base/BaseGitActivity.kt +++ b/app/src/main/java/app/passwordstore/ui/git/base/BaseGitActivity.kt @@ -9,6 +9,7 @@ import androidx.appcompat.app.AppCompatActivity import androidx.core.content.edit import app.passwordstore.R import app.passwordstore.injection.prefs.GitPreferences +import app.passwordstore.ui.prompts.prompts import app.passwordstore.util.coroutines.DispatcherProvider import app.passwordstore.util.extensions.sharedPrefs import app.passwordstore.util.git.ErrorMessages @@ -25,10 +26,8 @@ import com.github.michaelbull.result.Err import com.github.michaelbull.result.Result import com.github.michaelbull.result.andThen import com.github.michaelbull.result.mapError -import com.google.android.material.dialog.MaterialAlertDialogBuilder import dagger.hilt.android.AndroidEntryPoint import javax.inject.Inject -import kotlinx.coroutines.withContext import logcat.asLog import logcat.logcat import net.schmizz.sshj.common.DisconnectReason @@ -100,23 +99,17 @@ abstract class BaseGitActivity : AppCompatActivity() { finish() } - suspend fun promptOnErrorHandler(err: Throwable, onPromptDone: () -> Unit = {}) { + suspend fun promptOnErrorHandler(err: Throwable) { val error = rootCauseException(err) if (!isExplicitlyUserInitiatedError(error)) { gitPrefs.edit { remove(PreferenceKeys.HTTPS_PASSWORD) } sharedPrefs.edit { remove(PreferenceKeys.SSH_OPENKEYSTORE_KEYID) } logcat { error.asLog() } - withContext(dispatcherProvider.main()) { - MaterialAlertDialogBuilder(this@BaseGitActivity).run { - setTitle(resources.getString(R.string.jgit_error_dialog_title)) - setMessage(ErrorMessages[error]) - setPositiveButton(resources.getString(R.string.dialog_ok)) { _, _ -> } - setOnDismissListener { onPromptDone() } - show() - } - } - } else { - onPromptDone() + prompts.ask( + title = getString(R.string.jgit_error_dialog_title), + message = ErrorMessages[error], + confirm = getString(R.string.dialog_ok), + ) } } diff --git a/app/src/main/java/app/passwordstore/ui/list/StoreActions.kt b/app/src/main/java/app/passwordstore/ui/list/StoreActions.kt index 3bdede73..cff0964c 100644 --- a/app/src/main/java/app/passwordstore/ui/list/StoreActions.kt +++ b/app/src/main/java/app/passwordstore/ui/list/StoreActions.kt @@ -11,6 +11,8 @@ 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.ui.prompts.Choice +import app.passwordstore.ui.prompts.prompts import app.passwordstore.util.autofill.AutofillMatcher import app.passwordstore.util.extensions.base64 import app.passwordstore.util.extensions.commitChange @@ -20,11 +22,8 @@ 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 @@ -153,27 +152,20 @@ private suspend fun BaseGitActivity.moveFile(source: File, destination: File): B } 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() + prompts.ask( + title = getString(R.string.password_move_error_title), + message = getString(R.string.password_move_error_message, source, destination), + confirm = getString(R.string.dialog_ok), + ) } 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() } - } +private suspend fun BaseGitActivity.confirm(title: String, message: String): Boolean = + prompts.ask( + title = title, + message = message, + confirm = getString(R.string.dialog_ok), + cancel = getString(R.string.dialog_cancel), + ) == Choice.Confirm 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 462bdb18..7ef4f906 100644 --- a/app/src/main/java/app/passwordstore/ui/main/MainActivity.kt +++ b/app/src/main/java/app/passwordstore/ui/main/MainActivity.kt @@ -29,6 +29,7 @@ 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.prompts.PromptHost import app.passwordstore.ui.settings.SettingsRoute import app.passwordstore.ui.settings.settingsGraph import app.passwordstore.ui.setup.GitServerRoute @@ -199,6 +200,7 @@ class MainActivity : BaseGitActivity() { setupGraph(this@MainActivity, nav) settingsGraph(this@MainActivity, nav) } + PromptHost(this@MainActivity) } } } @@ -263,7 +265,7 @@ class MainActivity : BaseGitActivity() { */ // 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. + // key" dialog could navigate in-graph if git operations could reach the nav controller. fun setupIntent(context: Context, start: SetupStart): Intent = Intent(context, MainActivity::class.java).putExtra(EXTRA_SETUP, start.name) } diff --git a/app/src/main/java/app/passwordstore/ui/prompts/Prompts.kt b/app/src/main/java/app/passwordstore/ui/prompts/Prompts.kt new file mode 100644 index 00000000..1af3f01c --- /dev/null +++ b/app/src/main/java/app/passwordstore/ui/prompts/Prompts.kt @@ -0,0 +1,293 @@ +/* + * Copyright (C) 2026 Danilo M. <danix@danix.xyz> + * SPDX-License-Identifier: GPL-3.0-only + */ + +package app.passwordstore.ui.prompts + +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.imePadding +import androidx.compose.foundation.layout.navigationBarsPadding +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.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.SnackbarDuration +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState +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.mutableStateListOf +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.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.text.input.VisualTransformation +import androidx.compose.ui.window.DialogProperties +import androidx.compose.ui.window.SecureFlagPolicy +import androidx.fragment.app.FragmentActivity +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import app.passwordstore.R +import app.passwordstore.ui.compose.R as composeR +import kotlin.coroutines.resume +import kotlinx.coroutines.suspendCancellableCoroutine + +/** The button that answered a [Question]. Dismissing the dialog answers [Cancel]. */ +enum class Choice { + Confirm, + Other, + Cancel, +} + +/** What the user entered in a [CredentialRequest]. */ +class CredentialAnswer(val text: String, val remember: Boolean) + +/** A dialog waiting for its answer; [cancelled] is the answer to dismissing it. */ +sealed class Prompt<T>(private val cancelled: T, private val deliver: (T) -> Unit) { + internal fun answer(value: T) = deliver(value) + + internal fun cancel() = deliver(cancelled) +} + +class Question( + val title: String, + val message: String, + val confirm: String, + val cancel: String?, + val other: String?, + deliver: (Choice) -> Unit, +) : Prompt<Choice>(Choice.Cancel, deliver) + +class CredentialRequest( + val message: String, + val hint: String, + val remember: String, + val error: String?, + deliver: (CredentialAnswer?) -> Unit, +) : Prompt<CredentialAnswer?>(null, deliver) + +/** A snackbar; each one is a distinct object, so showing the same text again shows it again. */ +class Snack(val message: String, val length: SnackbarDuration) + +/** + * The dialogs and the snackbar of one activity, for code with no composable of its own (git + * operations and store actions), drawn by [PromptHost]. Reach it through [prompts]. + */ +class Prompts : ViewModel() { + + private val queue = mutableStateListOf<Prompt<*>>() + + /** Set once the activity is gone for good: nothing draws a new dialog any more. */ + private var closed = false + + /** The dialog on screen. The others wait for it to be answered. */ + val current: Prompt<*>? + get() = queue.firstOrNull() + + var snack by mutableStateOf<Snack?>(null) + private set + + /** Shows a dialog and suspends until it is answered. Cancelling the caller closes it. */ + suspend fun ask( + title: String, + message: String, + confirm: String, + cancel: String? = null, + other: String? = null, + ): Choice = suspendCancellableCoroutine { cont -> + val question = Question(title, message, confirm, cancel, other) { cont.resume(it) } + queue += question + cont.invokeOnCancellation { queue.remove(question) } + } + + /** + * Asks for a credential. Not suspending: the SSH thread waits on [deliver], and no cancellation + * reaches it, so this dialog outlives a recreated activity and is drawn again by the new one. + */ + fun askCredential( + message: String, + hint: String, + remember: String, + error: String?, + deliver: (CredentialAnswer?) -> Unit, + ) { + if (closed) { + deliver(null) + return + } + queue += CredentialRequest(message, hint, remember, error, deliver) + } + + /** Answers [prompt], once: a double tap must not resume its caller twice. */ + fun <T> answer(prompt: Prompt<T>, value: T) { + if (queue.remove(prompt)) prompt.answer(value) + } + + fun cancel(prompt: Prompt<*>) { + if (queue.remove(prompt)) prompt.cancel() + } + + fun cancelAll() { + queue.toList().forEach(::cancel) + } + + /** Shows [message] in place of any current snackbar, as the View snackbar did. */ + fun snackbar(message: String, length: SnackbarDuration = SnackbarDuration.Short): Snack = + Snack(message, length).also { snack = it } + + /** Hides [shown], unless another snackbar replaced it. */ + fun dismiss(shown: Snack) { + if (snack === shown) snack = null + } + + internal fun close() { + closed = true + cancelAll() + } + + override fun onCleared() = close() +} + +/** The [Prompts] of this activity; it survives recreation. */ +val FragmentActivity.prompts: Prompts + get() = ViewModelProvider(this)[Prompts::class.java] + +/** Draws the [Prompts] of [activity]. Place it after the activity's content, inside `APSTheme`. */ +@Composable +fun PromptHost(activity: FragmentActivity, modifier: Modifier = Modifier) { + val prompts = remember(activity) { activity.prompts } + val snackbars = remember { SnackbarHostState() } + val snack = prompts.snack + // A new snack cancels this effect, which takes the old one off the screen. + LaunchedEffect(snack) { + if (snack != null) { + snackbars.showSnackbar(snack.message, duration = snack.length) + prompts.dismiss(snack) + } + } + Box(modifier.fillMaxSize().navigationBarsPadding().imePadding(), Alignment.BottomCenter) { + SnackbarHost(snackbars) + } + val prompt = prompts.current + // Keyed: a retry is a new request and starts with an empty field. + key(prompt) { + when (prompt) { + is Question -> QuestionDialog(prompt, prompts) + is CredentialRequest -> CredentialDialog(prompt, prompts) + null -> {} + } + } +} + +@Composable +private fun QuestionDialog(question: Question, prompts: Prompts) { + AlertDialog( + onDismissRequest = { prompts.cancel(question) }, + title = { Text(question.title) }, + text = { Text(question.message) }, + confirmButton = { + TextButton(onClick = { prompts.answer(question, Choice.Confirm) }) { Text(question.confirm) } + }, + dismissButton = { + // No Row: the dialog's button area wraps its children when they do not fit. + question.cancel?.let { + TextButton(onClick = { prompts.answer(question, Choice.Cancel) }) { Text(it) } + } + question.other?.let { + TextButton(onClick = { prompts.answer(question, Choice.Other) }) { Text(it) } + } + }, + ) +} + +/** The credential lives only in `remember` and the dialog's window is FLAG_SECURE. */ +@Composable +private fun CredentialDialog(request: CredentialRequest, prompts: Prompts) { + // remember, never rememberSaveable: the credential must not reach saved state. + var text by remember { mutableStateOf("") } + var keep by remember { mutableStateOf(true) } + var revealed by remember { mutableStateOf(false) } + var error by remember { mutableStateOf(request.error) } + val focus = remember { FocusRequester() } + fun submit() = prompts.answer(request, CredentialAnswer(text, keep)) + AlertDialog( + onDismissRequest = { prompts.cancel(request) }, + properties = DialogProperties(securePolicy = SecureFlagPolicy.SecureOn), + title = { Text(stringResource(R.string.passphrase_dialog_title)) }, + text = { + Column { + Text(request.message) + OutlinedTextField( + value = text, + onValueChange = { + text = it + error = null + }, + modifier = Modifier.focusRequester(focus), + label = { Text(request.hint) }, + supportingText = error?.let { { Text(it) } }, + isError = error != null, + 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, + ), + keyboardActions = KeyboardActions(onDone = { submit() }), + ) + Row( + Modifier.toggleable(value = keep, role = Role.Checkbox, onValueChange = { keep = it }), + verticalAlignment = Alignment.CenterVertically, + ) { + Checkbox(checked = keep, onCheckedChange = null) + Text(request.remember) + } + } + LaunchedEffect(Unit) { focus.requestFocus() } + }, + confirmButton = { TextButton(onClick = ::submit) { Text(stringResource(R.string.dialog_ok)) } }, + dismissButton = { + TextButton(onClick = { prompts.cancel(request) }) { + Text(stringResource(R.string.dialog_cancel)) + } + }, + ) +} diff --git a/app/src/main/java/app/passwordstore/ui/settings/GitConfigScreen.kt b/app/src/main/java/app/passwordstore/ui/settings/GitConfigScreen.kt index d008eea3..ed07c389 100644 --- a/app/src/main/java/app/passwordstore/ui/settings/GitConfigScreen.kt +++ b/app/src/main/java/app/passwordstore/ui/settings/GitConfigScreen.kt @@ -28,6 +28,7 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedButton import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Scaffold +import androidx.compose.material3.SnackbarDuration import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.material3.TopAppBar @@ -54,7 +55,6 @@ 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 /** @@ -223,7 +223,7 @@ fun GitConfigDestination( } activity.snackbar( message = activity.getString(R.string.git_tool_done, activity.getString(label)), - length = Snackbar.LENGTH_LONG, + length = SnackbarDuration.Long, ) viewModel.done = null } diff --git a/app/src/main/java/app/passwordstore/ui/setup/GitServerScreen.kt b/app/src/main/java/app/passwordstore/ui/setup/GitServerScreen.kt index fd440996..4b24375b 100644 --- a/app/src/main/java/app/passwordstore/ui/setup/GitServerScreen.kt +++ b/app/src/main/java/app/passwordstore/ui/setup/GitServerScreen.kt @@ -32,6 +32,7 @@ import androidx.compose.material3.Scaffold import androidx.compose.material3.SegmentedButton import androidx.compose.material3.SegmentedButtonDefaults import androidx.compose.material3.SingleChoiceSegmentedButtonRow +import androidx.compose.material3.SnackbarDuration import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.material3.TopAppBar @@ -60,7 +61,6 @@ 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 /** @@ -210,7 +210,7 @@ fun GitServerDestination( BackHandler(enabled = viewModel.progress != null) {} fun say(message: String) { - activity.snackbar(message = message, length = Snackbar.LENGTH_LONG) + activity.snackbar(message = message, length = SnackbarDuration.Long) } // ponytail: a theme or locale change cancels the clone with the activity's scope, like every diff --git a/app/src/main/java/app/passwordstore/ui/setup/PgpKeysScreen.kt b/app/src/main/java/app/passwordstore/ui/setup/PgpKeysScreen.kt index 11fd979c..6004ec90 100644 --- a/app/src/main/java/app/passwordstore/ui/setup/PgpKeysScreen.kt +++ b/app/src/main/java/app/passwordstore/ui/setup/PgpKeysScreen.kt @@ -31,6 +31,7 @@ import androidx.compose.material3.IconButton import androidx.compose.material3.LinearProgressIndicator import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold +import androidx.compose.material3.SnackbarDuration import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.material3.TopAppBar @@ -59,7 +60,6 @@ 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 @@ -269,7 +269,7 @@ fun PgpKeysDestination( if (error != null) { activity.snackbar( message = activity.getString(R.string.store_create_failed, error), - length = Snackbar.LENGTH_LONG, + length = SnackbarDuration.Long, ) } else { // A failed commit is reported like any git error; the store exists either way, and 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 9bc93d8e..382a5617 100644 --- a/app/src/main/java/app/passwordstore/util/extensions/AndroidExtensions.kt +++ b/app/src/main/java/app/passwordstore/util/extensions/AndroidExtensions.kt @@ -16,18 +16,18 @@ import android.content.pm.PackageManager.ApplicationInfoFlags import android.content.pm.PackageManager.PackageInfoFlags import android.os.Build import android.util.Base64 -import android.view.View import android.view.autofill.AutofillManager +import androidx.compose.material3.SnackbarDuration import androidx.core.content.getSystemService import androidx.fragment.app.FragmentActivity import app.passwordstore.BuildConfig -import app.passwordstore.R import app.passwordstore.data.repo.PasswordRepository +import app.passwordstore.ui.prompts.Snack +import app.passwordstore.ui.prompts.prompts import app.passwordstore.util.crypto.keystorePreferences import app.passwordstore.util.git.operation.GitOperation import com.github.michaelbull.result.Ok import com.github.michaelbull.result.Result -import com.google.android.material.snackbar.Snackbar import logcat.logcat /** Get an instance of [AutofillManager] */ @@ -70,7 +70,7 @@ suspend fun FragmentActivity.commitChange(message: String): Result<Unit, Throwab git.commit().setAll(true).setMessage(message), ) - override fun preExecute(): Boolean { + override suspend fun preExecute(): Boolean { logcat { "Committing with message: '$message'" } return true } @@ -78,16 +78,11 @@ suspend fun FragmentActivity.commitChange(message: String): Result<Unit, Throwab .execute() } -/** Show a [Snackbar] in a [FragmentActivity]. */ +/** Shows a snackbar through this activity's [prompts]; it must host a `PromptHost`. */ 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.show() - return snackbar -} + length: SnackbarDuration = SnackbarDuration.Short, +): Snack = prompts.snackbar(message, length) /** Simplifies the common `getString(key, null) ?: defaultValue` case slightly */ fun SharedPreferences.getString(key: String): String? = getString(key, null) diff --git a/app/src/main/java/app/passwordstore/util/git/GitCommandExecutor.kt b/app/src/main/java/app/passwordstore/util/git/GitCommandExecutor.kt index feb2cb2d..6d497bce 100644 --- a/app/src/main/java/app/passwordstore/util/git/GitCommandExecutor.kt +++ b/app/src/main/java/app/passwordstore/util/git/GitCommandExecutor.kt @@ -6,8 +6,10 @@ package app.passwordstore.util.git import android.widget.Toast +import androidx.compose.material3.SnackbarDuration import androidx.fragment.app.FragmentActivity import app.passwordstore.R +import app.passwordstore.ui.prompts.prompts import app.passwordstore.util.coroutines.DispatcherProvider import app.passwordstore.util.extensions.snackbar import app.passwordstore.util.extensions.unsafeLazy @@ -17,7 +19,6 @@ import app.passwordstore.util.git.operation.GitOperation import app.passwordstore.util.settings.GitSettings import com.github.michaelbull.result.Result import com.github.michaelbull.result.runCatching -import com.google.android.material.snackbar.Snackbar import dagger.hilt.EntryPoint import dagger.hilt.InstallIn import dagger.hilt.android.EntryPointAccessors @@ -45,10 +46,10 @@ class GitCommandExecutor( suspend fun execute(): Result<Unit, Throwable> { val gitSettings = hiltEntryPoint.gitSettings() val dispatcherProvider = hiltEntryPoint.dispatcherProvider() - val snackbar = + val running = activity.snackbar( message = activity.resources.getString(R.string.git_operation_running), - length = Snackbar.LENGTH_INDEFINITE, + length = SnackbarDuration.Indefinite, ) // Count the number of uncommitted files var nbChanges = 0 @@ -123,7 +124,7 @@ class GitCommandExecutor( } } } - .also { snackbar.dismiss() } + .also { activity.prompts.dismiss(running) } } @EntryPoint 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 4b4913d0..126b2be5 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 @@ -7,8 +7,8 @@ package app.passwordstore.util.git.operation import androidx.appcompat.app.AppCompatActivity import app.passwordstore.R import app.passwordstore.data.repo.PasswordRepository +import app.passwordstore.ui.prompts.prompts import app.passwordstore.util.extensions.unsafeLazy -import com.google.android.material.dialog.MaterialAlertDialogBuilder import org.eclipse.jgit.api.GitCommand import org.eclipse.jgit.api.RebaseCommand import org.eclipse.jgit.api.ResetCommand @@ -50,16 +50,14 @@ class BreakOutOfDetached(callingActivity: AppCompatActivity) : GitOperation(call } } - override fun preExecute() = + override suspend fun preExecute() = if (!git.repository.repositoryState.isRebasing && !merging) { - MaterialAlertDialogBuilder(callingActivity) - .setTitle(callingActivity.resources.getString(R.string.git_abort_and_push_title)) - .setMessage( - callingActivity.resources.getString(R.string.git_break_out_of_detached_unneeded) - ) - // Only closes the dialog: the calling activity is the app's main screen. - .setPositiveButton(callingActivity.resources.getString(R.string.dialog_ok), null) - .show() + val resources = callingActivity.resources + callingActivity.prompts.ask( + title = resources.getString(R.string.git_abort_and_push_title), + message = resources.getString(R.string.git_break_out_of_detached_unneeded), + confirm = resources.getString(R.string.dialog_ok), + ) false } else { true diff --git a/app/src/main/java/app/passwordstore/util/git/operation/CredentialFinder.kt b/app/src/main/java/app/passwordstore/util/git/operation/CredentialFinder.kt index e0ecb170..a0d6d16d 100644 --- a/app/src/main/java/app/passwordstore/util/git/operation/CredentialFinder.kt +++ b/app/src/main/java/app/passwordstore/util/git/operation/CredentialFinder.kt @@ -5,24 +5,17 @@ package app.passwordstore.util.git.operation -import android.annotation.SuppressLint import android.content.SharedPreferences -import android.view.LayoutInflater -import android.view.WindowManager import androidx.annotation.StringRes import androidx.core.content.edit -import androidx.core.widget.doOnTextChanged import androidx.fragment.app.FragmentActivity import app.passwordstore.R import app.passwordstore.injection.prefs.GitPreferences +import app.passwordstore.ui.prompts.Prompts import app.passwordstore.util.coroutines.DispatcherProvider import app.passwordstore.util.git.sshj.InteractivePasswordFinder import app.passwordstore.util.settings.AuthMode import app.passwordstore.util.settings.PreferenceKeys -import com.google.android.material.checkbox.MaterialCheckBox -import com.google.android.material.dialog.MaterialAlertDialogBuilder -import com.google.android.material.textfield.TextInputEditText -import com.google.android.material.textfield.TextInputLayout import dagger.hilt.EntryPoint import dagger.hilt.InstallIn import dagger.hilt.android.EntryPointAccessors @@ -32,6 +25,7 @@ import kotlin.coroutines.resume class CredentialFinder( private val callingActivity: FragmentActivity, + private val prompts: Prompts, private val authMode: AuthMode, dispatcherProvider: DispatcherProvider, ) : InteractivePasswordFinder(dispatcherProvider) { @@ -71,42 +65,18 @@ class CredentialFinder( if (isRetry) gitOperationPrefs.edit { remove(credentialPref) } val storedCredential = gitOperationPrefs.getString(credentialPref, null) if (storedCredential == null) { - val layoutInflater = LayoutInflater.from(callingActivity) - - @SuppressLint("InflateParams") - val dialogView = layoutInflater.inflate(R.layout.git_credential_layout, null) - val credentialLayout = - dialogView.findViewById<TextInputLayout>(R.id.git_auth_passphrase_layout) - val editCredential = dialogView.findViewById<TextInputEditText>(R.id.git_auth_credential) - editCredential.setHint(hintRes) - val rememberCredential = - dialogView.findViewById<MaterialCheckBox>(R.id.git_auth_remember_credential) - rememberCredential.setText(rememberRes) - if (isRetry) { - credentialLayout.error = callingActivity.resources.getString(errorRes) - // Reset error when user starts entering a password - editCredential.doOnTextChanged { _, _, _, _ -> credentialLayout.error = null } - } - MaterialAlertDialogBuilder(callingActivity) - .run { - setTitle(R.string.passphrase_dialog_title) - setMessage(messageRes) - setView(dialogView) - setPositiveButton(R.string.dialog_ok) { _, _ -> - val credential = editCredential.text.toString() - if (rememberCredential.isChecked) { - gitOperationPrefs.edit { putString(credentialPref, credential) } - } - cont.resume(credential) - } - setNegativeButton(R.string.dialog_cancel) { _, _ -> cont.resume(null) } - setOnCancelListener { cont.resume(null) } - create() - } - .run { - window?.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_STATE_ALWAYS_VISIBLE) - show() + val resources = callingActivity.resources + prompts.askCredential( + message = resources.getString(messageRes), + hint = resources.getString(hintRes), + remember = resources.getString(rememberRes), + error = if (isRetry) resources.getString(errorRes) else null, + ) { answer -> + if (answer?.remember == true) { + gitOperationPrefs.edit { putString(credentialPref, answer.text) } } + cont.resume(answer?.text) + } } else { cont.resume(storedCredential) } 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 25d99d6d..a4bc33df 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 @@ -10,6 +10,8 @@ import androidx.fragment.app.FragmentActivity import app.passwordstore.R import app.passwordstore.data.repo.PasswordRepository import app.passwordstore.ui.main.MainActivity +import app.passwordstore.ui.prompts.Choice +import app.passwordstore.ui.prompts.prompts import app.passwordstore.ui.setup.SetupStart import app.passwordstore.util.auth.BiometricAuthenticator import app.passwordstore.util.auth.BiometricAuthenticator.Result.CanceledByUser @@ -24,7 +26,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.google.android.material.dialog.MaterialAlertDialogBuilder import dagger.hilt.EntryPoint import dagger.hilt.InstallIn import dagger.hilt.android.EntryPointAccessors @@ -131,22 +132,22 @@ abstract class GitOperation(protected val callingActivity: FragmentActivity) { return operationResult } - private fun onMissingSshKeyFile() { - MaterialAlertDialogBuilder(callingActivity) - .setMessage(callingActivity.resources.getString(R.string.ssh_preferences_dialog_text)) - .setTitle(callingActivity.resources.getString(R.string.ssh_preferences_dialog_title)) - .setPositiveButton(callingActivity.resources.getString(R.string.button_label_import)) { _, _ - -> - getSshKey(false) - } - .setNegativeButton( - callingActivity.resources.getString(R.string.ssh_preferences_dialog_generate) - ) { _, _ -> - getSshKey(true) - } - // 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() + private suspend fun onMissingSshKeyFile() { + val resources = callingActivity.resources + val choice = + callingActivity.prompts.ask( + title = resources.getString(R.string.ssh_preferences_dialog_title), + message = resources.getString(R.string.ssh_preferences_dialog_text), + confirm = resources.getString(R.string.button_label_import), + cancel = resources.getString(R.string.dialog_cancel), + other = resources.getString(R.string.ssh_preferences_dialog_generate), + ) + when (choice) { + Choice.Confirm -> getSshKey(false) + Choice.Other -> getSshKey(true) + // Only closes the dialog: the calling activity is the app's own screen now. + Choice.Cancel -> {} + } } suspend fun executeAfterAuthentication(authMode: AuthMode): Result<Unit, Throwable> { @@ -202,6 +203,7 @@ abstract class GitOperation(protected val callingActivity: FragmentActivity) { HttpsCredentialsProvider( CredentialFinder( callingActivity, + callingActivity.prompts, AuthMode.Password, hiltEntryPoint.dispatcherProvider(), ) @@ -214,7 +216,7 @@ abstract class GitOperation(protected val callingActivity: FragmentActivity) { } /** Called before execution of the Git operation. Return false to cancel. */ - open fun preExecute() = true + open suspend fun preExecute() = true private suspend fun postExecute() { withContext(hiltEntryPoint.dispatcherProvider().io()) { sshSessionFactory?.close() } diff --git a/app/src/main/java/app/passwordstore/util/git/sshj/SshjSessionFactory.kt b/app/src/main/java/app/passwordstore/util/git/sshj/SshjSessionFactory.kt index 51657b02..ceae70bb 100644 --- a/app/src/main/java/app/passwordstore/util/git/sshj/SshjSessionFactory.kt +++ b/app/src/main/java/app/passwordstore/util/git/sshj/SshjSessionFactory.kt @@ -6,6 +6,8 @@ package app.passwordstore.util.git.sshj import android.util.Base64 import androidx.appcompat.app.AppCompatActivity +import app.passwordstore.ui.prompts.Prompts +import app.passwordstore.ui.prompts.prompts import app.passwordstore.util.coroutines.DispatcherProvider import app.passwordstore.util.git.operation.CredentialFinder import app.passwordstore.util.settings.AuthMode @@ -43,6 +45,12 @@ import org.eclipse.jgit.transport.URIish import org.eclipse.jgit.util.FS sealed class SshAuthMethod(val activity: AppCompatActivity) { + /** + * The activity's [Prompts], taken while it is alive: the SSH thread reads it later, and asking an + * activity that was finished meanwhile for it would create one nothing draws. + */ + val prompts = activity.prompts + class Password(activity: AppCompatActivity) : SshAuthMethod(activity) class SshKey(activity: AppCompatActivity) : SshAuthMethod(activity) @@ -156,7 +164,14 @@ private class SshjSession( ssh.connect(uri.host, uri.port.takeUnless { it == -1 } ?: 22) if (!ssh.isConnected) throw IOException() val passwordAuth = - AuthPassword(CredentialFinder(authMethod.activity, AuthMode.Password, dispatcherProvider)) + AuthPassword( + CredentialFinder( + authMethod.activity, + authMethod.prompts, + AuthMode.Password, + dispatcherProvider, + ) + ) when (authMethod) { is SshAuthMethod.Password -> { ssh.auth(username, passwordAuth) @@ -166,7 +181,12 @@ private class SshjSession( AuthPublickey( SshKey.provide( ssh, - CredentialFinder(authMethod.activity, AuthMode.SshKey, dispatcherProvider), + CredentialFinder( + authMethod.activity, + authMethod.prompts, + AuthMode.SshKey, + dispatcherProvider, + ), ) ) ssh.auth(username, pubkeyAuth, passwordAuth) |
