aboutsummaryrefslogtreecommitdiffstats
path: root/app/src
diff options
context:
space:
mode:
authorDanilo M. <danix@danix.xyz>2026-11-05 12:13:35 +0100
committerDanilo M. <danix@danix.xyz>2026-11-05 12:21:40 +0100
commit68714bf78feb5a5a59354da624f2d69d6705e8ab (patch)
tree030eda99da60c4fc8c48b617381e6b1c25171c64 /app/src
parentc85e7c8801caf7a73b46dda9ebf75be023cad9b4 (diff)
downloadHardenedPass-68714bf78feb5a5a59354da624f2d69d6705e8ab.tar.gz
HardenedPass-68714bf78feb5a5a59354da624f2d69d6705e8ab.zip
feat(app): move the git dialogs and the snackbar to Compose
An activity-scoped Prompts ViewModel holds them, since git operations have no composable to draw in; PromptHost draws them. The credential dialog's window is now FLAG_SECURE, and a recreated activity no longer loses it and strands the SSH thread. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Diffstat (limited to 'app/src')
-rw-r--r--app/src/main/java/app/passwordstore/ui/autofill/AutofillSaveActivity.kt2
-rw-r--r--app/src/main/java/app/passwordstore/ui/editor/EditorDestination.kt4
-rw-r--r--app/src/main/java/app/passwordstore/ui/git/base/BaseGitActivity.kt21
-rw-r--r--app/src/main/java/app/passwordstore/ui/list/StoreActions.kt36
-rw-r--r--app/src/main/java/app/passwordstore/ui/main/MainActivity.kt4
-rw-r--r--app/src/main/java/app/passwordstore/ui/prompts/Prompts.kt293
-rw-r--r--app/src/main/java/app/passwordstore/ui/settings/GitConfigScreen.kt4
-rw-r--r--app/src/main/java/app/passwordstore/ui/setup/GitServerScreen.kt4
-rw-r--r--app/src/main/java/app/passwordstore/ui/setup/PgpKeysScreen.kt4
-rw-r--r--app/src/main/java/app/passwordstore/util/extensions/AndroidExtensions.kt19
-rw-r--r--app/src/main/java/app/passwordstore/util/git/GitCommandExecutor.kt9
-rw-r--r--app/src/main/java/app/passwordstore/util/git/operation/BreakOutOfDetached.kt18
-rw-r--r--app/src/main/java/app/passwordstore/util/git/operation/CredentialFinder.kt56
-rw-r--r--app/src/main/java/app/passwordstore/util/git/operation/GitOperation.kt38
-rw-r--r--app/src/main/java/app/passwordstore/util/git/sshj/SshjSessionFactory.kt24
-rw-r--r--app/src/main/res/layout/git_credential_layout.xml37
-rw-r--r--app/src/test/java/app/passwordstore/ui/prompts/PromptsTest.kt102
17 files changed, 504 insertions, 171 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)
diff --git a/app/src/main/res/layout/git_credential_layout.xml b/app/src/main/res/layout/git_credential_layout.xml
deleted file mode 100644
index 8fc527e9..00000000
--- a/app/src/main/res/layout/git_credential_layout.xml
+++ /dev/null
@@ -1,37 +0,0 @@
-<?xml version="1.0" encoding="utf-8"?><!--
- ~ Copyright © 2014-2024 The Android Password Store Authors. All Rights Reserved.
- ~ SPDX-License-Identifier: GPL-3.0-only
- -->
-
-<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
- xmlns:app="http://schemas.android.com/apk/res-auto"
- android:layout_width="match_parent"
- android:layout_height="match_parent"
- android:orientation="vertical"
- android:padding="16dp">
-
- <com.google.android.material.textfield.TextInputLayout
- android:id="@+id/git_auth_passphrase_layout"
- android:layout_width="match_parent"
- android:layout_height="wrap_content"
- app:endIconMode="password_toggle"
- app:errorEnabled="true"
- app:hintEnabled="true">
-
- <com.google.android.material.textfield.TextInputEditText
- android:id="@+id/git_auth_credential"
- android:layout_width="match_parent"
- android:layout_height="wrap_content"
- android:hint="@string/ssh_keygen_passphrase"
- android:inputType="textPassword" />
-
- <requestFocus />
- </com.google.android.material.textfield.TextInputLayout>
-
- <com.google.android.material.checkbox.MaterialCheckBox
- android:id="@+id/git_auth_remember_credential"
- android:layout_width="wrap_content"
- android:layout_height="wrap_content"
- android:text="@string/git_operation_remember_passphrase"
- app:checkedState="checked" />
-</LinearLayout>
diff --git a/app/src/test/java/app/passwordstore/ui/prompts/PromptsTest.kt b/app/src/test/java/app/passwordstore/ui/prompts/PromptsTest.kt
new file mode 100644
index 00000000..12d1671e
--- /dev/null
+++ b/app/src/test/java/app/passwordstore/ui/prompts/PromptsTest.kt
@@ -0,0 +1,102 @@
+/*
+ * Copyright (C) 2026 Danilo M. <danix@danix.xyz>
+ * SPDX-License-Identifier: GPL-3.0-only
+ */
+
+package app.passwordstore.ui.prompts
+
+import androidx.compose.material3.SnackbarDuration
+import kotlin.test.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertNull
+import kotlin.test.assertSame
+import kotlinx.coroutines.CoroutineStart
+import kotlinx.coroutines.async
+import kotlinx.coroutines.runBlocking
+
+class PromptsTest {
+
+ private fun Prompts.question() = current as Question
+
+ @Test
+ fun answerResumesAsk() = runBlocking {
+ val prompts = Prompts()
+ val asked = async(start = CoroutineStart.UNDISPATCHED) { prompts.ask("t", "m", "ok") }
+ prompts.answer(prompts.question(), Choice.Other)
+ assertEquals(Choice.Other, asked.await())
+ assertNull(prompts.current)
+ }
+
+ @Test
+ fun dismissingAnswersCancel() = runBlocking {
+ val prompts = Prompts()
+ val asked = async(start = CoroutineStart.UNDISPATCHED) { prompts.ask("t", "m", "ok") }
+ prompts.cancel(prompts.question())
+ assertEquals(Choice.Cancel, asked.await())
+ }
+
+ @Test
+ fun secondAnswerIsIgnored() = runBlocking {
+ val prompts = Prompts()
+ val asked = async(start = CoroutineStart.UNDISPATCHED) { prompts.ask("t", "m", "ok") }
+ val question = prompts.question()
+ prompts.answer(question, Choice.Confirm)
+ prompts.answer(question, Choice.Cancel)
+ assertEquals(Choice.Confirm, asked.await())
+ }
+
+ @Test
+ fun cancelledAskRemovesItsDialog() = runBlocking {
+ val prompts = Prompts()
+ val asked = async(start = CoroutineStart.UNDISPATCHED) { prompts.ask("t", "m", "ok") }
+ asked.cancel()
+ assertNull(prompts.current)
+ }
+
+ @Test
+ fun promptsShowOneAtATimeInOrder() =
+ runBlocking<Unit> {
+ val prompts = Prompts()
+ val first = async(start = CoroutineStart.UNDISPATCHED) { prompts.ask("1", "m", "ok") }
+ val second = async(start = CoroutineStart.UNDISPATCHED) { prompts.ask("2", "m", "ok") }
+ assertEquals("1", prompts.question().title)
+ prompts.answer(prompts.question(), Choice.Confirm)
+ assertEquals("2", prompts.question().title)
+ prompts.answer(prompts.question(), Choice.Confirm)
+ first.await()
+ second.await()
+ }
+
+ @Test
+ fun cancelAllAnswersAPendingCredentialWithNull() {
+ val prompts = Prompts()
+ val answers = mutableListOf<CredentialAnswer?>(CredentialAnswer("unset", false))
+ prompts.askCredential("m", "h", "r", null) { answers[0] = it }
+ prompts.cancelAll()
+ assertNull(answers[0])
+ assertNull(prompts.current)
+ }
+
+ @Test
+ fun askCredentialAfterCloseAnswersNullAtOnce() {
+ val prompts = Prompts()
+ prompts.close()
+ val answers = mutableListOf<CredentialAnswer?>(CredentialAnswer("unset", false))
+ prompts.askCredential("m", "h", "r", null) { answers[0] = it }
+ assertNull(answers[0])
+ assertNull(prompts.current)
+ }
+
+ @Test
+ fun newSnackbarReplacesTheCurrentOne() {
+ val prompts = Prompts()
+ val first = prompts.snackbar("one", SnackbarDuration.Indefinite)
+ val second = prompts.snackbar("two", SnackbarDuration.Short)
+ assertSame(second, prompts.snack)
+ // The finished operation dismisses its own snackbar only.
+ prompts.dismiss(first)
+ assertSame(second, prompts.snack)
+ prompts.dismiss(second)
+ assertNull(prompts.snack)
+ }
+}