PM-10286: View Master Password Prompt (#5280)

This commit is contained in:
David Perez authored and GitHub committed 2025-05-30 21:04:59 +00:00
1 parent 997769bb1c
commit f43367ebfa
36 files changed
+1779 -2636

No files matched your search

@@ -121,10 +121,13 @@ fun SearchContent(
supportingLabelTestTag = it.subtitleTestTag,
optionsTestTag = it.overflowTestTag,
onClick = {
if (it.isTotp && it.shouldDisplayMasterPasswordReprompt) {
masterPasswordRepromptData = MasterPasswordRepromptData.Totp(it.id)
} else if (it.autofillSelectionOptions.isNotEmpty()) {
if (it.autofillSelectionOptions.isNotEmpty()) {
autofillSelectionOptionsItem = it
} else if (it.shouldDisplayMasterPasswordReprompt) {
masterPasswordRepromptData = MasterPasswordRepromptData.ViewItem(
cipherId = it.id,
itemType = it.itemType,
)
} else {
searchHandlers.onItemClick(it.id, it.itemType)
}
@@ -643,11 +643,11 @@ class SearchViewModel @Inject constructor(
)
}
is MasterPasswordRepromptData.Totp -> {
is MasterPasswordRepromptData.ViewItem -> {
trySendAction(
action = SearchAction.ItemClick(
itemId = data.cipherId,
itemType = SearchState.DisplayItem.ItemType.Vault(type = CipherType.LOGIN),
itemType = data.itemType,
),
)
}
@@ -761,7 +761,6 @@ class SearchViewModel @Inject constructor(
baseIconUrl = state.baseIconUrl,
isIconLoadingDisabled = state.isIconLoadingDisabled,
isAutofill = state.isAutofill,
isTotp = state.isTotp,
isPremiumUser = state.isPremium,
)
}
@@ -909,7 +908,6 @@ data class SearchState(
val overflowOptions: List<ListingItemOverflowAction>,
val overflowTestTag: String?,
val autofillSelectionOptions: List<AutofillSelectionOption>,
val isTotp: Boolean,
val shouldDisplayMasterPasswordReprompt: Boolean,
val itemType: ItemType,
) : Parcelable {
@@ -1296,14 +1294,6 @@ sealed class MasterPasswordRepromptData : Parcelable {
val cipherId: String,
) : MasterPasswordRepromptData()
/**
* Autofill was selected.
*/
@Parcelize
data class Totp(
val cipherId: String,
) : MasterPasswordRepromptData()
/**
* A cipher overflow menu item action was selected.
*/
@@ -1311,4 +1301,13 @@ sealed class MasterPasswordRepromptData : Parcelable {
data class OverflowItem(
val action: ListingItemOverflowAction.VaultAction,
) : MasterPasswordRepromptData()
/**
* Item was selected to be viewed.
*/
@Parcelize
data class ViewItem(
val cipherId: String,
val itemType: SearchState.DisplayItem.ItemType,
) : MasterPasswordRepromptData()
}
@@ -151,7 +151,6 @@ fun List<CipherView>.toViewState(
hasMasterPassword: Boolean,
isIconLoadingDisabled: Boolean,
isAutofill: Boolean,
isTotp: Boolean,
isPremiumUser: Boolean,
): SearchState.ViewState =
when {
@@ -163,7 +162,6 @@ fun List<CipherView>.toViewState(
hasMasterPassword = hasMasterPassword,
isIconLoadingDisabled = isIconLoadingDisabled,
isAutofill = isAutofill,
isTotp = isTotp,
isPremiumUser = isPremiumUser,
)
.sortAlphabetically(),
@@ -177,13 +175,11 @@ fun List<CipherView>.toViewState(
}
}
@Suppress("LongParameterList")
private fun List<CipherView>.toDisplayItemList(
baseIconUrl: String,
hasMasterPassword: Boolean,
isIconLoadingDisabled: Boolean,
isAutofill: Boolean,
isTotp: Boolean,
isPremiumUser: Boolean,
): List<SearchState.DisplayItem> =
this.map {
@@ -192,18 +188,15 @@ private fun List<CipherView>.toDisplayItemList(
hasMasterPassword = hasMasterPassword,
isIconLoadingDisabled = isIconLoadingDisabled,
isAutofill = isAutofill,
isTotp = isTotp,
isPremiumUser = isPremiumUser,
)
}
@Suppress("LongParameterList")
private fun CipherView.toDisplayItem(
baseIconUrl: String,
hasMasterPassword: Boolean,
isIconLoadingDisabled: Boolean,
isAutofill: Boolean,
isTotp: Boolean,
isPremiumUser: Boolean,
): SearchState.DisplayItem =
SearchState.DisplayItem(
@@ -231,8 +224,8 @@ private fun CipherView.toDisplayItem(
.filter {
this.login != null || (it != AutofillSelectionOption.AUTOFILL_AND_SAVE)
},
isTotp = isTotp,
shouldDisplayMasterPasswordReprompt = reprompt == CipherRepromptType.PASSWORD,
shouldDisplayMasterPasswordReprompt = hasMasterPassword &&
reprompt == CipherRepromptType.PASSWORD,
itemType = SearchState.DisplayItem.ItemType.Vault(type = this.type),
)
@@ -373,7 +366,6 @@ private fun SendView.toDisplayItem(
overflowTestTag = "SendOptionsButton",
totpCode = null,
autofillSelectionOptions = emptyList(),
isTotp = false,
shouldDisplayMasterPasswordReprompt = false,
itemType = SearchState.DisplayItem.ItemType.Sends(type = this.type),
)
@@ -34,7 +34,6 @@ import com.x8bit.bitwarden.ui.platform.components.content.BitwardenErrorContent
import com.x8bit.bitwarden.ui.platform.components.content.BitwardenLoadingContent
import com.x8bit.bitwarden.ui.platform.components.dialog.BitwardenBasicDialog
import com.x8bit.bitwarden.ui.platform.components.dialog.BitwardenLoadingDialog
import com.x8bit.bitwarden.ui.platform.components.dialog.BitwardenMasterPasswordDialog
import com.x8bit.bitwarden.ui.platform.components.dialog.BitwardenTwoButtonDialog
import com.x8bit.bitwarden.ui.platform.components.scaffold.BitwardenScaffold
import com.x8bit.bitwarden.ui.platform.composition.LocalIntentManager
@@ -127,16 +126,6 @@ fun VaultItemScreen(
onDismissRequest = remember(viewModel) {
{ viewModel.trySendAction(VaultItemAction.Common.DismissDialogClick) }
},
onSubmitMasterPassword = remember(viewModel) {
{ masterPassword, action ->
viewModel.trySendAction(
VaultItemAction.Common.MasterPasswordSubmit(
masterPassword = masterPassword,
action = action,
),
)
}
},
onConfirmDeleteClick = remember(viewModel) {
{ viewModel.trySendAction(VaultItemAction.Common.ConfirmDeleteClick) }
},
@@ -288,7 +277,6 @@ private fun VaultItemDialogs(
dialog: VaultItemState.DialogState?,
onDismissRequest: () -> Unit,
onConfirmDeleteClick: () -> Unit,
onSubmitMasterPassword: (masterPassword: String, action: PasswordRepromptAction) -> Unit,
onConfirmCloneWithoutFido2Credential: () -> Unit,
onConfirmRestoreAction: () -> Unit,
) {
@@ -304,13 +292,6 @@ private fun VaultItemDialogs(
text = dialog.message(),
)
is VaultItemState.DialogState.MasterPasswordDialog -> {
BitwardenMasterPasswordDialog(
onConfirmClick = { onSubmitMasterPassword(it, dialog.action) },
onDismissRequest = onDismissRequest,
)
}
is VaultItemState.DialogState.DeleteConfirmationPrompt -> {
BitwardenTwoButtonDialog(
title = stringResource(id = R.string.delete),
@@ -17,7 +17,6 @@ import com.x8bit.bitwarden.R
import com.x8bit.bitwarden.data.auth.repository.AuthRepository
import com.x8bit.bitwarden.data.auth.repository.model.BreachCountResult
import com.x8bit.bitwarden.data.auth.repository.model.UserState
import com.x8bit.bitwarden.data.auth.repository.model.ValidatePasswordResult
import com.x8bit.bitwarden.data.platform.manager.FeatureFlagManager
import com.x8bit.bitwarden.data.platform.manager.clipboard.BitwardenClipboardManager
import com.x8bit.bitwarden.data.platform.manager.event.OrganizationEventManager
@@ -234,7 +233,6 @@ class VaultItemViewModel @Inject constructor(
is VaultItemAction.Common.CloseClick -> handleCloseClick()
is VaultItemAction.Common.DismissDialogClick -> handleDismissDialogClick()
is VaultItemAction.Common.EditClick -> handleEditClick()
is VaultItemAction.Common.MasterPasswordSubmit -> handleMasterPasswordSubmit(action)
is VaultItemAction.Common.RefreshClick -> handleRefreshClick()
is VaultItemAction.Common.CopyCustomHiddenFieldClick -> {
handleCopyCustomHiddenFieldClick(action)
@@ -286,50 +284,21 @@ class VaultItemViewModel @Inject constructor(
}
private fun handleDeleteClick() {
onContent { content ->
if (content.common.requiresReprompt) {
updateDialogState(
VaultItemState.DialogState.MasterPasswordDialog(
action = PasswordRepromptAction.DeleteClick,
),
)
return@onContent
} else {
updateDialogState(
VaultItemState
.DialogState
.DeleteConfirmationPrompt(state.deletionConfirmationText),
)
}
}
updateDialogState(
dialog = VaultItemState.DialogState.DeleteConfirmationPrompt(
message = state.deletionConfirmationText,
),
)
}
private fun handleEditClick() {
onContent { content ->
if (content.common.requiresReprompt) {
updateDialogState(
VaultItemState.DialogState.MasterPasswordDialog(
action = PasswordRepromptAction.EditClick,
),
)
return@onContent
}
sendEvent(
VaultItemEvent.NavigateToAddEdit(
itemId = state.vaultItemId,
isClone = false,
type = state.cipherType,
),
)
}
}
private fun handleMasterPasswordSubmit(action: VaultItemAction.Common.MasterPasswordSubmit) {
updateDialogState(VaultItemState.DialogState.Loading(R.string.loading.asText()))
viewModelScope.launch {
val result = authRepository.validatePassword(action.masterPassword)
sendAction(VaultItemAction.Internal.ValidatePasswordReceive(result, action.action))
}
sendEvent(
VaultItemEvent.NavigateToAddEdit(
itemId = state.vaultItemId,
isClone = false,
type = state.cipherType,
),
)
}
private fun handleRefreshClick() {
@@ -341,14 +310,6 @@ class VaultItemViewModel @Inject constructor(
action: VaultItemAction.Common.CopyCustomHiddenFieldClick,
) {
onContent { content ->
if (content.common.requiresReprompt) {
updateDialogState(
VaultItemState.DialogState.MasterPasswordDialog(
action = PasswordRepromptAction.CopyClick(action.field),
),
)
return@onContent
}
clipboardManager.setText(text = action.field)
organizationEventManager.trackEvent(
event = OrganizationEvent.CipherClientCopiedHiddenField(
@@ -368,17 +329,6 @@ class VaultItemViewModel @Inject constructor(
action: VaultItemAction.Common.HiddenFieldVisibilityClicked,
) {
onContent { content ->
if (content.common.requiresReprompt) {
updateDialogState(
VaultItemState.DialogState.MasterPasswordDialog(
action = PasswordRepromptAction.ViewHiddenFieldClicked(
field = action.field,
isVisible = action.isVisible,
),
),
)
return@onContent
}
mutableStateFlow.update { currentState ->
currentState.copy(
viewState = content.copy(
@@ -408,17 +358,6 @@ class VaultItemViewModel @Inject constructor(
action: VaultItemAction.Common.AttachmentDownloadClick,
) {
onContent { content ->
if (content.common.requiresReprompt) {
updateDialogState(
VaultItemState.DialogState.MasterPasswordDialog(
action = PasswordRepromptAction.AttachmentDownloadClick(
attachment = action.attachment,
),
),
)
return@onContent
}
updateDialogState(VaultItemState.DialogState.Loading(R.string.downloading.asText()))
viewModelScope.launch {
@@ -472,36 +411,19 @@ class VaultItemViewModel @Inject constructor(
}
private fun handleAttachmentsClick() {
onContent { content ->
if (content.common.requiresReprompt) {
updateDialogState(
VaultItemState.DialogState.MasterPasswordDialog(
action = PasswordRepromptAction.AttachmentsClick,
),
)
return@onContent
}
sendEvent(VaultItemEvent.NavigateToAttachments(itemId = state.vaultItemId))
}
sendEvent(VaultItemEvent.NavigateToAttachments(itemId = state.vaultItemId))
}
@Suppress("MaxLineLength")
private fun handleCloneClick() {
onContent { content ->
if (content.common.requiresCloneConfirmation) {
updateDialogState(
@Suppress("MaxLineLength")
VaultItemState.DialogState.Fido2CredentialCannotBeCopiedConfirmationPrompt(
message = R.string.the_passkey_will_not_be_copied_to_the_cloned_item_do_you_want_to_continue_cloning_this_item.asText(),
),
)
return@onContent
} else if (content.common.requiresReprompt) {
updateDialogState(
VaultItemState.DialogState.MasterPasswordDialog(
action = PasswordRepromptAction.CloneClick,
),
)
return@onContent
}
sendEvent(
event = VaultItemEvent.NavigateToAddEdit(
@@ -531,17 +453,7 @@ class VaultItemViewModel @Inject constructor(
}
private fun handleMoveToOrganizationClick() {
onContent { content ->
if (content.common.requiresReprompt) {
updateDialogState(
VaultItemState.DialogState.MasterPasswordDialog(
action = PasswordRepromptAction.MoveToOrganizationClick,
),
)
return@onContent
}
sendEvent(VaultItemEvent.NavigateToMoveToOrganization(itemId = state.vaultItemId))
}
sendEvent(VaultItemEvent.NavigateToMoveToOrganization(itemId = state.vaultItemId))
}
private fun handleCollectionsClick() {
@@ -676,17 +588,8 @@ class VaultItemViewModel @Inject constructor(
private fun handleCopyPasswordClick() {
onLoginContent { content, login ->
val password = requireNotNull(login.passwordData?.password)
if (content.common.requiresReprompt) {
updateDialogState(
VaultItemState.DialogState.MasterPasswordDialog(
action = PasswordRepromptAction.CopyClick(value = password),
),
)
return@onLoginContent
}
clipboardManager.setText(
text = password,
text = requireNotNull(login.passwordData).password,
toastDescriptorOverride = R.string.password.asText(),
)
organizationEventManager.trackEvent(
@@ -729,33 +632,13 @@ class VaultItemViewModel @Inject constructor(
}
private fun handlePasswordHistoryClick() {
onContent { content ->
if (content.common.requiresReprompt) {
updateDialogState(
VaultItemState.DialogState.MasterPasswordDialog(
action = PasswordRepromptAction.PasswordHistoryClick,
),
)
return@onContent
}
sendEvent(VaultItemEvent.NavigateToPasswordHistory(state.vaultItemId))
}
sendEvent(VaultItemEvent.NavigateToPasswordHistory(itemId = state.vaultItemId))
}
private fun handlePasswordVisibilityClicked(
action: VaultItemAction.ItemType.Login.PasswordVisibilityClicked,
) {
onLoginContent { content, login ->
if (content.common.requiresReprompt) {
updateDialogState(
VaultItemState.DialogState.MasterPasswordDialog(
action = PasswordRepromptAction.ViewPasswordClick(
isVisible = action.isVisible,
),
),
)
return@onLoginContent
}
mutableStateFlow.update { currentState ->
currentState.copy(
viewState = content.copy(
@@ -799,16 +682,6 @@ class VaultItemViewModel @Inject constructor(
action: VaultItemAction.ItemType.Card.CodeVisibilityClick,
) {
onCardContent { content, card ->
if (content.common.requiresReprompt) {
updateDialogState(
VaultItemState.DialogState.MasterPasswordDialog(
action = PasswordRepromptAction.ViewCodeClick(
isVisible = action.isVisible,
),
),
)
return@onCardContent
}
mutableStateFlow.update { currentState ->
currentState.copy(
viewState = content.copy(
@@ -832,17 +705,8 @@ class VaultItemViewModel @Inject constructor(
private fun handleCopyNumberClick() {
onCardContent { content, card ->
val number = requireNotNull(card.number).number
if (content.common.requiresReprompt) {
updateDialogState(
VaultItemState.DialogState.MasterPasswordDialog(
action = PasswordRepromptAction.CopyClick(value = number),
),
)
return@onCardContent
}
clipboardManager.setText(
text = number,
text = requireNotNull(card.number).number,
toastDescriptorOverride = R.string.number.asText(),
)
}
@@ -850,17 +714,8 @@ class VaultItemViewModel @Inject constructor(
private fun handleCopySecurityCodeClick() {
onCardContent { content, card ->
val securityCode = requireNotNull(card.securityCode).code
if (content.common.requiresReprompt) {
updateDialogState(
VaultItemState.DialogState.MasterPasswordDialog(
action = PasswordRepromptAction.CopyClick(value = securityCode),
),
)
return@onCardContent
}
clipboardManager.setText(
text = securityCode,
text = requireNotNull(card.securityCode).code,
toastDescriptorOverride = R.string.security_code.asText(),
)
}
@@ -870,16 +725,6 @@ class VaultItemViewModel @Inject constructor(
action: VaultItemAction.ItemType.Card.NumberVisibilityClick,
) {
onCardContent { content, card ->
if (content.common.requiresReprompt) {
updateDialogState(
VaultItemState.DialogState.MasterPasswordDialog(
action = PasswordRepromptAction.ViewNumberClick(
isVisible = action.isVisible,
),
),
)
return@onCardContent
}
mutableStateFlow.update { currentState ->
currentState.copy(
viewState = content.copy(
@@ -932,16 +777,6 @@ class VaultItemViewModel @Inject constructor(
action: VaultItemAction.ItemType.SshKey.PrivateKeyVisibilityClicked,
) {
onSshKeyContent { content, sshKey ->
if (content.common.requiresReprompt) {
updateDialogState(
VaultItemState.DialogState.MasterPasswordDialog(
action = PasswordRepromptAction.ViewPrivateKeyClicked(
isVisible = action.isVisible,
),
),
)
return@onSshKeyContent
}
mutableStateFlow.update { currentState ->
currentState.copy(
viewState = content.copy(
@@ -954,14 +789,6 @@ class VaultItemViewModel @Inject constructor(
private fun handleCopyPrivateKeyClick() {
onSshKeyContent { content, sshKey ->
if (content.common.requiresReprompt) {
updateDialogState(
VaultItemState.DialogState.MasterPasswordDialog(
action = PasswordRepromptAction.CopyClick(value = sshKey.privateKey),
),
)
return@onSshKeyContent
}
clipboardManager.setText(
text = sshKey.privateKey,
toastDescriptorOverride = R.string.private_key.asText(),
@@ -1107,13 +934,8 @@ class VaultItemViewModel @Inject constructor(
is VaultItemAction.Internal.CopyValue -> handleCopyValue(action)
is VaultItemAction.Internal.PasswordBreachReceive -> handlePasswordBreachReceive(action)
is VaultItemAction.Internal.VaultDataReceive -> handleVaultDataReceive(action)
is VaultItemAction.Internal.ValidatePasswordReceive -> handleValidatePasswordReceive(
action,
)
is VaultItemAction.Internal.DeleteCipherReceive -> handleDeleteCipherReceive(action)
is VaultItemAction.Internal.RestoreCipherReceive -> handleRestoreCipherReceive(action)
is VaultItemAction.Internal.AttachmentDecryptReceive -> {
handleAttachmentDecryptReceive(action)
}
@@ -1229,7 +1051,6 @@ class VaultItemViewModel @Inject constructor(
?.toViewState(
previousState = state.viewState.asContentOrNull(),
isPremiumUser = account.isPremium,
hasMasterPassword = account.hasMasterPassword,
totpCodeItemData = this.data?.totpCodeItemData,
canDelete = this.data?.canDelete == true,
canRestore = this.data?.canRestore == true,
@@ -1241,43 +1062,6 @@ class VaultItemViewModel @Inject constructor(
)
?: VaultItemState.ViewState.Error(message = errorText)
private fun handleValidatePasswordReceive(
action: VaultItemAction.Internal.ValidatePasswordReceive,
) {
when (val result = action.result) {
is ValidatePasswordResult.Error -> {
updateDialogState(
VaultItemState.DialogState.Generic(
message = R.string.generic_error_message.asText(),
error = result.error,
),
)
}
is ValidatePasswordResult.Success -> {
if (result.isValid) {
onContent { content ->
mutableStateFlow.update {
it.copy(
dialog = null,
viewState = content.copy(
common = content.common.copy(requiresReprompt = false),
),
)
}
trySendAction(action.repromptAction.vaultItemAction)
}
} else {
updateDialogState(
VaultItemState.DialogState.Generic(
message = R.string.invalid_master_password.asText(),
),
)
}
}
}
}
private fun handleDeleteCipherReceive(action: VaultItemAction.Internal.DeleteCipherReceive) {
when (val result = action.result) {
is DeleteCipherResult.Error -> {
@@ -1367,17 +1151,7 @@ class VaultItemViewModel @Inject constructor(
}
private fun handleRestoreItemClicked() {
onContent { content ->
if (content.common.requiresReprompt) {
updateDialogState(
VaultItemState.DialogState.MasterPasswordDialog(
action = PasswordRepromptAction.RestoreItemClick,
),
)
} else {
updateDialogState(VaultItemState.DialogState.RestoreItemDialog)
}
}
updateDialogState(VaultItemState.DialogState.RestoreItemDialog)
}
private fun handleIsIconLoadingDisabledUpdateReceive(
@@ -1589,8 +1363,6 @@ data class VaultItemState(
* updated.
* @property notes Contains general notes taken by the user.
* @property customFields A list of custom fields that user has added.
* @property requiresReprompt Indicates if a master password prompt is required to view
* secure fields.
* @property requiresCloneConfirmation Indicates user confirmation is required when
* cloning a cipher.
* @property currentCipher The cipher that is currently being viewed (nullable).
@@ -1608,7 +1380,6 @@ data class VaultItemState(
val lastUpdated: String,
val notes: String?,
val customFields: List<Custom>,
val requiresReprompt: Boolean,
val requiresCloneConfirmation: Boolean,
@IgnoredOnParcel
val currentCipher: CipherView? = null,
@@ -1915,14 +1686,6 @@ data class VaultItemState(
val message: Text,
) : DialogState()
/**
* Displays the master password dialog to the user.
*/
@Parcelize
data class MasterPasswordDialog(
val action: PasswordRepromptAction,
) : DialogState()
/**
* Displays the dialog for deleting the item to the user.
*/
@@ -2062,14 +1825,6 @@ sealed class VaultItemAction {
*/
data object EditClick : Common()
/**
* The user has submitted their master password.
*/
data class MasterPasswordSubmit(
val masterPassword: String,
val action: PasswordRepromptAction,
) : Common()
/**
* The user has clicked the refresh button.
*/
@@ -2337,14 +2092,6 @@ sealed class VaultItemAction {
val vaultDataState: DataState<VaultItemStateData>,
) : Internal()
/**
* Indicates that the verify password result has been received.
*/
data class ValidatePasswordReceive(
val result: ValidatePasswordResult,
val repromptAction: PasswordRepromptAction,
) : Internal()
/**
* Indicates that the delete cipher result has been received.
*/
@@ -2384,175 +2131,3 @@ sealed class VaultItemAction {
) : Internal()
}
}
/**
* Represents all the actions that can be taken after being prompted to a master password check.
*/
sealed class PasswordRepromptAction : Parcelable {
/**
* The Vault action that should be sent when password validation has completed.
*/
abstract val vaultItemAction: VaultItemAction
/**
* Indicates that we should launch the [VaultItemAction.Common.EditClick] upon password
* validation.
*/
@Parcelize
data object EditClick : PasswordRepromptAction() {
override val vaultItemAction: VaultItemAction
get() = VaultItemAction.Common.EditClick
}
/**
* Indicates that we should launch the [VaultItemAction.Common.PasswordHistoryClick]
* upon password validation.
*/
@Parcelize
data object PasswordHistoryClick : PasswordRepromptAction() {
override val vaultItemAction: VaultItemAction
get() = VaultItemAction.Common.PasswordHistoryClick
}
/**
* Indicates that we should launch the [VaultItemAction.Common.AttachmentsClick] upon password
* validation.
*/
@Parcelize
data object AttachmentsClick : PasswordRepromptAction() {
override val vaultItemAction: VaultItemAction
get() = VaultItemAction.Common.AttachmentsClick
}
/**
* Indicates an attachment download was clicked.
*/
@Parcelize
data class AttachmentDownloadClick(
val attachment: VaultItemState.ViewState.Content.Common.AttachmentItem,
) : PasswordRepromptAction() {
override val vaultItemAction: VaultItemAction
get() = VaultItemAction.Common.AttachmentDownloadClick(attachment)
}
/**
* Indicates that we should launch the [VaultItemAction.Common.CloneClick] upon password
* validation.
*/
@Parcelize
data object CloneClick : PasswordRepromptAction() {
override val vaultItemAction: VaultItemAction
get() = VaultItemAction.Common.CloneClick
}
/**
* Indicates that we should launch the [VaultItemAction.Common.MoveToOrganizationClick] upon
* password validation.
*/
@Parcelize
data object MoveToOrganizationClick : PasswordRepromptAction() {
override val vaultItemAction: VaultItemAction
get() = VaultItemAction.Common.MoveToOrganizationClick
}
/**
* Indicates that we should launch the [VaultItemAction.Common.ConfirmDeleteClick] upon
* password validation.
*/
@Parcelize
data object DeleteClick : PasswordRepromptAction() {
override val vaultItemAction: VaultItemAction
get() = VaultItemAction.Common.DeleteClick
}
/**
* Indicates that we should launch the [VaultItemAction.Internal.CopyValue] upon password
* validation.
*/
@Parcelize
data class CopyClick(
val value: String,
) : PasswordRepromptAction() {
override val vaultItemAction: VaultItemAction
get() = VaultItemAction.Internal.CopyValue(
value = value,
)
}
/**
* Indicates that we should launch the [VaultItemAction.ItemType.Card.CodeVisibilityClick]
* upon password validation.
*/
@Parcelize
data class ViewCodeClick(
val isVisible: Boolean,
) : PasswordRepromptAction() {
override val vaultItemAction: VaultItemAction
get() = VaultItemAction.ItemType.Card.CodeVisibilityClick(isVisible = isVisible)
}
/**
* Indicates that we should launch the [VaultItemAction.ItemType.Card.NumberVisibilityClick]
* upon password validation.
*/
@Parcelize
data class ViewNumberClick(
val isVisible: Boolean,
) : PasswordRepromptAction() {
override val vaultItemAction: VaultItemAction
get() = VaultItemAction.ItemType.Card.NumberVisibilityClick(isVisible = isVisible)
}
/**
* Indicates that we should launch the
* [VaultItemAction.ItemType.Login.PasswordVisibilityClicked] upon password validation.
*/
@Parcelize
data class ViewPasswordClick(
val isVisible: Boolean,
) : PasswordRepromptAction() {
override val vaultItemAction: VaultItemAction
get() = VaultItemAction.ItemType.Login.PasswordVisibilityClicked(
isVisible = isVisible,
)
}
/**
* Indicates that we should launch the [VaultItemAction.Common.HiddenFieldVisibilityClicked]
* upon password validation.
*/
@Parcelize
data class ViewHiddenFieldClicked(
val field: VaultItemState.ViewState.Content.Common.Custom.HiddenField,
val isVisible: Boolean,
) : PasswordRepromptAction() {
override val vaultItemAction: VaultItemAction
get() = VaultItemAction.Common.HiddenFieldVisibilityClicked(
field = this.field,
isVisible = this.isVisible,
)
}
/**
* Indicates that we should show the confirm restore
*/
@Parcelize
data object RestoreItemClick : PasswordRepromptAction() {
override val vaultItemAction: VaultItemAction
get() = VaultItemAction.Common.RestoreVaultItemClick
}
/**
* Indicates that we should launch the
* [VaultItemAction.ItemType.SshKey.PrivateKeyVisibilityClicked] upon password validation.
*/
@Parcelize
data class ViewPrivateKeyClicked(
val isVisible: Boolean,
) : PasswordRepromptAction() {
override val vaultItemAction: VaultItemAction
get() = VaultItemAction.ItemType.SshKey.PrivateKeyVisibilityClicked(
isVisible = isVisible,
)
}
}
@@ -7,7 +7,6 @@ import com.bitwarden.ui.platform.base.util.orZeroWidthSpace
import com.bitwarden.ui.util.Text
import com.bitwarden.ui.util.asText
import com.bitwarden.vault.CardView
import com.bitwarden.vault.CipherRepromptType
import com.bitwarden.vault.CipherType
import com.bitwarden.vault.CipherView
import com.bitwarden.vault.Fido2Credential
@@ -41,7 +40,6 @@ private const val FIDO2_CREDENTIAL_CREATION_TIME_PATTERN: String = "h:mm a"
fun CipherView.toViewState(
previousState: VaultItemState.ViewState.Content?,
isPremiumUser: Boolean,
hasMasterPassword: Boolean,
totpCodeItemData: TotpCodeItemData?,
clock: Clock = Clock.systemDefaultZone(),
canDelete: Boolean,
@@ -56,8 +54,6 @@ fun CipherView.toViewState(
common = VaultItemState.ViewState.Content.Common(
currentCipher = this,
name = name,
requiresReprompt = (reprompt == CipherRepromptType.PASSWORD && hasMasterPassword) &&
previousState?.common?.requiresReprompt != false,
customFields = fields.orEmpty().map { fieldView ->
fieldView.toCustomField(
previousState = previousState
@@ -88,10 +88,7 @@ fun VaultItemListingContent(
BitwardenMasterPasswordDialog(
onConfirmClick = { password ->
masterPasswordRepromptData = null
masterPasswordRepromptSubmit(
password,
data,
)
masterPasswordRepromptSubmit(password, data)
},
onDismissRequest = {
masterPasswordRepromptData = null
@@ -213,14 +210,15 @@ fun VaultItemListingContent(
supportingLabelTestTag = it.subtitleTestTag,
optionsTestTag = it.optionsTestTag,
onClick = {
if (it.isTotp && it.shouldShowMasterPasswordReprompt) {
masterPasswordRepromptData = MasterPasswordRepromptData.Totp(
cipherId = it.id,
)
} else if (it.isAutofill && it.shouldShowMasterPasswordReprompt) {
if (it.isAutofill && it.shouldShowMasterPasswordReprompt) {
masterPasswordRepromptData = MasterPasswordRepromptData.Autofill(
cipherId = it.id,
)
} else if (it.shouldShowMasterPasswordReprompt) {
masterPasswordRepromptData = MasterPasswordRepromptData.ViewItem(
id = it.id,
itemType = it.itemType,
)
} else {
vaultItemClick(it.id, it.itemType)
}
@@ -1468,13 +1468,8 @@ class VaultItemListingViewModel @Inject constructor(
)
}
is MasterPasswordRepromptData.Totp -> {
sendEvent(
VaultItemListingEvent.NavigateToEditCipher(
cipherId = data.cipherId,
cipherType = VaultItemCipherType.LOGIN,
),
)
is MasterPasswordRepromptData.ViewItem -> {
trySendAction(VaultItemListingsAction.ItemClick(id = data.id, type = data.itemType))
}
}
}
@@ -2340,7 +2335,6 @@ data class VaultItemListingState(
val optionsTestTag: String,
val isAutofill: Boolean,
val isCredentialCreation: Boolean,
val isTotp: Boolean,
val shouldShowMasterPasswordReprompt: Boolean,
val itemType: ItemType,
) {
@@ -2988,28 +2982,26 @@ sealed class VaultItemListingsAction {
/**
* Data tracking the type of request that triggered a master password reprompt.
*/
sealed class MasterPasswordRepromptData : Parcelable {
sealed class MasterPasswordRepromptData {
/**
* Autofill was selected.
*/
@Parcelize
data class Autofill(
val cipherId: String,
) : MasterPasswordRepromptData()
/**
* Totp was selected.
*/
@Parcelize
data class Totp(
val cipherId: String,
) : MasterPasswordRepromptData()
/**
* A cipher overflow menu item action was selected.
*/
@Parcelize
data class OverflowItem(
val action: ListingItemOverflowAction.VaultAction,
) : MasterPasswordRepromptData()
/**
* An item was selected to be viewed.
*/
data class ViewItem(
val id: String,
val itemType: VaultItemListingState.DisplayItem.ItemType,
) : MasterPasswordRepromptData()
}
@@ -94,9 +94,9 @@ sealed class ListingItemOverflowAction : Parcelable {
data class ViewClick(
val cipherId: String,
val cipherType: CipherType,
override val requiresPasswordReprompt: Boolean,
) : VaultAction() {
override val title: Text get() = R.string.view.asText()
override val requiresPasswordReprompt: Boolean get() = false
}
/**
@@ -141,7 +141,6 @@ fun VaultData.toViewState(
isFido2Creation = createCredentialRequestData != null,
fido2CredentialAutofillViews = fido2CredentialAutofillViews,
isPremiumUser = isPremiumUser,
isTotp = totpData != null,
),
displayFolderList = folderList.map { folderView ->
VaultItemListingState.FolderDisplayItem(
@@ -341,7 +340,6 @@ private fun List<CipherView>.toDisplayItemList(
isFido2Creation: Boolean,
fido2CredentialAutofillViews: List<Fido2CredentialAutofillView>?,
isPremiumUser: Boolean,
isTotp: Boolean,
): List<VaultItemListingState.DisplayItem> =
this.map {
it.toDisplayItem(
@@ -355,7 +353,6 @@ private fun List<CipherView>.toDisplayItemList(
fido2CredentialAutofillView.cipherId == it.id
},
isPremiumUser = isPremiumUser,
isTotp = isTotp,
)
}
@@ -379,7 +376,6 @@ private fun CipherView.toDisplayItem(
isFido2Creation: Boolean,
fido2CredentialAutofillView: Fido2CredentialAutofillView?,
isPremiumUser: Boolean,
isTotp: Boolean,
): VaultItemListingState.DisplayItem =
VaultItemListingState.DisplayItem(
id = id.orEmpty(),
@@ -407,7 +403,6 @@ private fun CipherView.toDisplayItem(
optionsTestTag = "CipherOptionsButton",
isAutofill = isAutofill,
isCredentialCreation = isFido2Creation,
isTotp = isTotp,
shouldShowMasterPasswordReprompt = (reprompt == CipherRepromptType.PASSWORD) &&
hasMasterPassword,
itemType = VaultItemListingState.DisplayItem.ItemType.Vault(type = this.type),
@@ -481,7 +476,6 @@ private fun SendView.toDisplayItem(
isAutofill = false,
shouldShowMasterPasswordReprompt = false,
isCredentialCreation = false,
isTotp = false,
itemType = VaultItemListingState.DisplayItem.ItemType.Sends(type = this.type),
)
@@ -22,6 +22,7 @@ fun CipherView.toOverflowActions(
ListingItemOverflowAction.VaultAction.ViewClick(
cipherId = cipherId,
cipherType = this.type,
requiresPasswordReprompt = hasMasterPassword,
),
ListingItemOverflowAction.VaultAction.EditClick(
cipherId = cipherId,
@@ -8,6 +8,10 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.stringResource
@@ -17,6 +21,7 @@ import com.bitwarden.ui.platform.base.util.toListItemCardStyle
import com.bitwarden.ui.platform.components.model.CardStyle
import com.bitwarden.ui.platform.components.util.rememberVectorPainter
import com.x8bit.bitwarden.R
import com.x8bit.bitwarden.ui.platform.components.dialog.BitwardenMasterPasswordDialog
import com.x8bit.bitwarden.ui.platform.components.header.BitwardenListHeaderText
import com.x8bit.bitwarden.ui.platform.components.listitem.BitwardenGroupItem
import com.x8bit.bitwarden.ui.vault.feature.itemlisting.model.ListingItemOverflowAction
@@ -34,9 +39,34 @@ private const val TRASH_TYPES_COUNT: Int = 1
fun VaultContent(
state: VaultState.ViewState.Content,
vaultHandlers: VaultHandlers,
onOverflowOptionClick: (action: ListingItemOverflowAction.VaultAction) -> Unit,
modifier: Modifier = Modifier,
) {
// Handles the master password prompt for the row click
var masterPasswordRepromptItem by remember {
mutableStateOf<VaultState.ViewState.VaultItem?>(value = null)
}
masterPasswordRepromptItem?.let { action ->
BitwardenMasterPasswordDialog(
onConfirmClick = { password ->
masterPasswordRepromptItem = null
vaultHandlers.masterPasswordRepromptSubmit(action, password)
},
onDismissRequest = { masterPasswordRepromptItem = null },
)
}
// Handles the master password prompt for the overflow clicks
var overflowMasterPasswordRepromptAction by remember {
mutableStateOf<ListingItemOverflowAction.VaultAction?>(value = null)
}
overflowMasterPasswordRepromptAction?.let { action ->
BitwardenMasterPasswordDialog(
onConfirmClick = { password ->
overflowMasterPasswordRepromptAction = null
vaultHandlers.overflowMasterPasswordRepromptSubmit(action, password)
},
onDismissRequest = { overflowMasterPasswordRepromptAction = null },
)
}
LazyColumn(
modifier = modifier,
) {
@@ -93,13 +123,19 @@ fun VaultContent(
trailingLabelIcons = favoriteItem.extraIconList,
label = favoriteItem.name(),
supportingLabel = favoriteItem.supportingLabel?.invoke(),
onClick = { vaultHandlers.vaultItemClick(favoriteItem) },
onClick = {
if (favoriteItem.shouldShowMasterPasswordReprompt) {
masterPasswordRepromptItem = favoriteItem
} else {
vaultHandlers.vaultItemClick(favoriteItem)
}
},
overflowOptions = favoriteItem.overflowOptions.toImmutableList(),
onOverflowOptionClick = { action ->
if (favoriteItem.shouldShowMasterPasswordReprompt &&
action.requiresPasswordReprompt
) {
onOverflowOptionClick(action)
overflowMasterPasswordRepromptAction = action
} else {
vaultHandlers.overflowOptionClick(action)
}
@@ -267,13 +303,19 @@ fun VaultContent(
trailingLabelIcons = noFolderItem.extraIconList,
label = noFolderItem.name(),
supportingLabel = noFolderItem.supportingLabel?.invoke(),
onClick = { vaultHandlers.vaultItemClick(noFolderItem) },
onClick = {
if (noFolderItem.shouldShowMasterPasswordReprompt) {
masterPasswordRepromptItem = noFolderItem
} else {
vaultHandlers.vaultItemClick(noFolderItem)
}
},
overflowOptions = noFolderItem.overflowOptions.toImmutableList(),
onOverflowOptionClick = { action ->
if (noFolderItem.shouldShowMasterPasswordReprompt &&
action.requiresPasswordReprompt
) {
onOverflowOptionClick(action)
overflowMasterPasswordRepromptAction = action
} else {
vaultHandlers.overflowOptionClick(action)
}
@@ -48,7 +48,6 @@ import com.x8bit.bitwarden.ui.platform.components.content.BitwardenErrorContent
import com.x8bit.bitwarden.ui.platform.components.content.BitwardenLoadingContent
import com.x8bit.bitwarden.ui.platform.components.dialog.BitwardenBasicDialog
import com.x8bit.bitwarden.ui.platform.components.dialog.BitwardenLoadingDialog
import com.x8bit.bitwarden.ui.platform.components.dialog.BitwardenMasterPasswordDialog
import com.x8bit.bitwarden.ui.platform.components.dialog.BitwardenTwoButtonDialog
import com.x8bit.bitwarden.ui.platform.components.model.BitwardenPullToRefreshState
import com.x8bit.bitwarden.ui.platform.components.model.rememberBitwardenPullToRefreshState
@@ -69,7 +68,6 @@ import com.x8bit.bitwarden.ui.vault.components.VaultItemSelectionDialog
import com.x8bit.bitwarden.ui.vault.components.model.CreateVaultItemType
import com.x8bit.bitwarden.ui.vault.feature.addedit.VaultAddEditArgs
import com.x8bit.bitwarden.ui.vault.feature.item.VaultItemArgs
import com.x8bit.bitwarden.ui.vault.feature.itemlisting.model.ListingItemOverflowAction
import com.x8bit.bitwarden.ui.vault.feature.vault.handlers.VaultHandlers
import com.x8bit.bitwarden.ui.vault.model.VaultAddEditType
import com.x8bit.bitwarden.ui.vault.model.VaultItemListingType
@@ -243,21 +241,6 @@ private fun VaultScreenScaffold(
)
}
var masterPasswordRepromptAction by remember {
mutableStateOf<ListingItemOverflowAction.VaultAction?>(null)
}
masterPasswordRepromptAction?.let { action ->
BitwardenMasterPasswordDialog(
onConfirmClick = { password ->
masterPasswordRepromptAction = null
vaultHandlers.masterPasswordRepromptSubmit(action, password)
},
onDismissRequest = {
masterPasswordRepromptAction = null
},
)
}
BitwardenScaffold(
topBar = {
BitwardenMediumTopAppBar(
@@ -373,7 +356,6 @@ private fun VaultScreenScaffold(
is VaultState.ViewState.Content -> VaultContent(
state = viewState,
vaultHandlers = vaultHandlers,
onOverflowOptionClick = { masterPasswordRepromptAction = it },
modifier = Modifier.fillMaxSize(),
)
@@ -204,6 +204,9 @@ class VaultViewModel @Inject constructor(
is VaultAction.DialogDismiss -> handleDialogDismiss()
is VaultAction.RefreshPull -> handleRefreshPull()
is VaultAction.OverflowOptionClick -> handleOverflowOptionClick(action)
is VaultAction.OverflowMasterPasswordRepromptSubmit -> {
handleOverflowMasterPasswordRepromptSubmit(action)
}
is VaultAction.MasterPasswordRepromptSubmit -> {
handleMasterPasswordRepromptSubmit(action)
@@ -506,13 +509,13 @@ class VaultViewModel @Inject constructor(
}
}
private fun handleMasterPasswordRepromptSubmit(
action: VaultAction.MasterPasswordRepromptSubmit,
private fun handleOverflowMasterPasswordRepromptSubmit(
action: VaultAction.OverflowMasterPasswordRepromptSubmit,
) {
viewModelScope.launch {
val result = authRepository.validatePassword(action.password)
sendAction(
VaultAction.Internal.ValidatePasswordResultReceive(
VaultAction.Internal.OverflowValidatePasswordResultReceive(
overflowAction = action.overflowAction,
result = result,
),
@@ -520,6 +523,20 @@ class VaultViewModel @Inject constructor(
}
}
private fun handleMasterPasswordRepromptSubmit(
action: VaultAction.MasterPasswordRepromptSubmit,
) {
viewModelScope.launch {
val result = authRepository.validatePassword(password = action.password)
sendAction(
VaultAction.Internal.ValidatePasswordResultReceive(
item = action.item,
result = result,
),
)
}
}
private fun handleCopyNoteClick(action: ListingItemOverflowAction.VaultAction.CopyNoteClick) {
clipboardManager.setText(
text = action.notes,
@@ -616,6 +633,10 @@ class VaultViewModel @Inject constructor(
action,
)
is VaultAction.Internal.OverflowValidatePasswordResultReceive -> {
handleOverflowValidatePasswordResultReceive(action)
}
is VaultAction.Internal.ValidatePasswordResultReceive -> {
handleValidatePasswordResultReceive(action)
}
@@ -813,8 +834,8 @@ class VaultViewModel @Inject constructor(
}
}
private fun handleValidatePasswordResultReceive(
action: VaultAction.Internal.ValidatePasswordResultReceive,
private fun handleOverflowValidatePasswordResultReceive(
action: VaultAction.Internal.OverflowValidatePasswordResultReceive,
) {
when (val result = action.result) {
is ValidatePasswordResult.Error -> {
@@ -847,6 +868,39 @@ class VaultViewModel @Inject constructor(
}
}
private fun handleValidatePasswordResultReceive(
action: VaultAction.Internal.ValidatePasswordResultReceive,
) {
when (val result = action.result) {
is ValidatePasswordResult.Error -> {
mutableStateFlow.update {
it.copy(
dialog = VaultState.DialogState.Error(
title = R.string.an_error_has_occurred.asText(),
message = R.string.generic_error_message.asText(),
error = result.error,
),
)
}
}
is ValidatePasswordResult.Success -> {
if (result.isValid) {
trySendAction(VaultAction.VaultItemClick(vaultItem = action.item))
} else {
mutableStateFlow.update {
it.copy(
dialog = VaultState.DialogState.Error(
title = R.string.an_error_has_occurred.asText(),
message = R.string.invalid_master_password.asText(),
),
)
}
}
}
}
}
//endregion VaultAction Handlers
}
@@ -1462,11 +1516,20 @@ sealed class VaultAction {
* User submitted their master password to authenticate before continuing with
* the selected overflow action.
*/
data class MasterPasswordRepromptSubmit(
data class OverflowMasterPasswordRepromptSubmit(
val overflowAction: ListingItemOverflowAction.VaultAction,
val password: String,
) : VaultAction()
/**
* User submitted their master password to authenticate before continuing with the primary
* action.
*/
data class MasterPasswordRepromptSubmit(
val item: VaultState.ViewState.VaultItem,
val password: String,
) : VaultAction()
/**
* The lifecycle of the VaultScreen has entered a resumed state.
*/
@@ -1524,11 +1587,19 @@ sealed class VaultAction {
/**
* Indicates that a result for verifying the user's master password has been received.
*/
data class ValidatePasswordResultReceive(
data class OverflowValidatePasswordResultReceive(
val overflowAction: ListingItemOverflowAction.VaultAction,
val result: ValidatePasswordResult,
) : Internal()
/**
* Indicates that a result for verifying the user's master password has been received.
*/
data class ValidatePasswordResultReceive(
val item: VaultState.ViewState.VaultItem,
val result: ValidatePasswordResult,
) : Internal()
/**
* Indicates that a snackbar data was received.
*/
@@ -36,7 +36,11 @@ data class VaultHandlers(
val tryAgainClick: () -> Unit,
val dialogDismiss: () -> Unit,
val overflowOptionClick: (ListingItemOverflowAction.VaultAction) -> Unit,
val masterPasswordRepromptSubmit: (ListingItemOverflowAction.VaultAction, String) -> Unit,
val overflowMasterPasswordRepromptSubmit: (
ListingItemOverflowAction.VaultAction,
String,
) -> Unit,
val masterPasswordRepromptSubmit: (VaultState.ViewState.VaultItem, String) -> Unit,
val dismissImportActionCard: () -> Unit,
val importActionCardClick: () -> Unit,
val flightRecorderGoToSettingsClick: () -> Unit,
@@ -93,10 +97,18 @@ data class VaultHandlers(
overflowOptionClick = {
viewModel.trySendAction(VaultAction.OverflowOptionClick(it))
},
masterPasswordRepromptSubmit = { action, password ->
overflowMasterPasswordRepromptSubmit = { action, password ->
viewModel.trySendAction(
VaultAction.OverflowMasterPasswordRepromptSubmit(
overflowAction = action,
password = password,
),
)
},
masterPasswordRepromptSubmit = { item, password ->
viewModel.trySendAction(
VaultAction.MasterPasswordRepromptSubmit(
overflowAction = action,
item = item,
password = password,
),
)
@@ -197,7 +197,7 @@ fun List<LoginUriView>?.toLoginIconData(
/**
* Transforms a [CipherView] into a [VaultState.ViewState.VaultItem].
*/
@Suppress("MagicNumber", "LongMethod")
@Suppress("MagicNumber", "LongMethod", "CyclomaticComplexMethod")
private fun CipherView.toVaultItemOrNull(
hasMasterPassword: Boolean,
isIconLoadingDisabled: Boolean,
@@ -220,7 +220,8 @@ private fun CipherView.toVaultItemOrNull(
isPremiumUser = isPremiumUser,
),
extraIconList = toLabelIcons(),
shouldShowMasterPasswordReprompt = reprompt == CipherRepromptType.PASSWORD,
shouldShowMasterPasswordReprompt = hasMasterPassword &&
reprompt == CipherRepromptType.PASSWORD,
)
CipherType.SECURE_NOTE -> VaultState.ViewState.VaultItem.SecureNote(
@@ -231,7 +232,8 @@ private fun CipherView.toVaultItemOrNull(
isPremiumUser = isPremiumUser,
),
extraIconList = toLabelIcons(),
shouldShowMasterPasswordReprompt = reprompt == CipherRepromptType.PASSWORD,
shouldShowMasterPasswordReprompt = hasMasterPassword &&
reprompt == CipherRepromptType.PASSWORD,
)
CipherType.CARD -> VaultState.ViewState.VaultItem.Card(
@@ -246,7 +248,8 @@ private fun CipherView.toVaultItemOrNull(
isPremiumUser = isPremiumUser,
),
extraIconList = toLabelIcons(),
shouldShowMasterPasswordReprompt = reprompt == CipherRepromptType.PASSWORD,
shouldShowMasterPasswordReprompt = hasMasterPassword &&
reprompt == CipherRepromptType.PASSWORD,
)
CipherType.IDENTITY -> VaultState.ViewState.VaultItem.Identity(
@@ -263,7 +266,8 @@ private fun CipherView.toVaultItemOrNull(
isPremiumUser = isPremiumUser,
),
extraIconList = toLabelIcons(),
shouldShowMasterPasswordReprompt = reprompt == CipherRepromptType.PASSWORD,
shouldShowMasterPasswordReprompt = hasMasterPassword &&
reprompt == CipherRepromptType.PASSWORD,
)
CipherType.SSH_KEY -> VaultState.ViewState.VaultItem.SshKey(
@@ -286,7 +290,8 @@ private fun CipherView.toVaultItemOrNull(
isPremiumUser = isPremiumUser,
),
extraIconList = toLabelIcons(),
shouldShowMasterPasswordReprompt = reprompt == CipherRepromptType.PASSWORD,
shouldShowMasterPasswordReprompt = hasMasterPassword &&
reprompt == CipherRepromptType.PASSWORD,
)
}
}
@@ -13,7 +13,9 @@ import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.material3.rememberTopAppBarState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.res.stringResource
@@ -34,7 +36,9 @@ import com.x8bit.bitwarden.data.platform.manager.util.AppResumeStateManager
import com.x8bit.bitwarden.data.platform.manager.util.RegisterScreenDataOnLifecycleEffect
import com.x8bit.bitwarden.ui.platform.components.content.BitwardenErrorContent
import com.x8bit.bitwarden.ui.platform.components.content.BitwardenLoadingContent
import com.x8bit.bitwarden.ui.platform.components.dialog.BitwardenBasicDialog
import com.x8bit.bitwarden.ui.platform.components.dialog.BitwardenLoadingDialog
import com.x8bit.bitwarden.ui.platform.components.dialog.BitwardenMasterPasswordDialog
import com.x8bit.bitwarden.ui.platform.components.header.BitwardenListHeaderText
import com.x8bit.bitwarden.ui.platform.components.model.rememberBitwardenPullToRefreshState
import com.x8bit.bitwarden.ui.platform.components.scaffold.BitwardenScaffold
@@ -91,7 +95,10 @@ fun VerificationCodeScreen(
}
}
VerificationCodeDialogs(dialogState = state.dialogState)
VerificationCodeDialogs(
dialogState = state.dialogState,
onDismissRequest = verificationCodeHandler.dismissDialog,
)
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior(rememberTopAppBarState())
BitwardenScaffold(
@@ -133,7 +140,9 @@ fun VerificationCodeScreen(
VerificationCodeContent(
items = viewState.verificationCodeDisplayItems.toImmutableList(),
onCopyClick = verificationCodeHandler.copyClick,
itemClick = verificationCodeHandler.itemClick,
onItemClick = verificationCodeHandler.itemClick,
onMasterPasswordRepromptSubmit = verificationCodeHandler
.masterPasswordRepromptSubmit,
modifier = Modifier.fillMaxSize(),
)
}
@@ -156,11 +165,23 @@ fun VerificationCodeScreen(
@Composable
private fun VerificationCodeDialogs(
dialogState: VerificationCodeState.DialogState?,
onDismissRequest: () -> Unit,
) {
when (dialogState) {
is VerificationCodeState.DialogState.Loading -> BitwardenLoadingDialog(
text = dialogState.message(),
)
is VerificationCodeState.DialogState.Error -> {
BitwardenBasicDialog(
title = dialogState.title?.invoke(),
message = dialogState.message(),
throwable = dialogState.throwable,
onDismissRequest = onDismissRequest,
)
}
is VerificationCodeState.DialogState.Loading -> {
BitwardenLoadingDialog(
text = dialogState.message(),
)
}
null -> Unit
}
@@ -169,10 +190,21 @@ private fun VerificationCodeDialogs(
@Composable
private fun VerificationCodeContent(
items: ImmutableList<VerificationCodeDisplayItem>,
itemClick: (id: String) -> Unit,
onItemClick: (id: String) -> Unit,
onMasterPasswordRepromptSubmit: (id: String, password: String) -> Unit,
onCopyClick: (text: String) -> Unit,
modifier: Modifier = Modifier,
) {
var masterPasswordRepromptId by remember { mutableStateOf<String?>(value = null) }
masterPasswordRepromptId?.let { id ->
BitwardenMasterPasswordDialog(
onConfirmClick = { password ->
onMasterPasswordRepromptSubmit(id, password)
masterPasswordRepromptId = null
},
onDismissRequest = { masterPasswordRepromptId = null },
)
}
LazyColumn(
modifier = modifier,
) {
@@ -200,7 +232,11 @@ private fun VerificationCodeContent(
hideAuthCode = it.hideAuthCode,
onCopyClick = { onCopyClick(it.authCode) },
onItemClick = {
itemClick(it.id)
if (it.hideAuthCode) {
masterPasswordRepromptId = it.id
} else {
onItemClick(it.id)
}
},
cardStyle = items.toListItemCardStyle(index = index, dividerPadding = 56.dp),
modifier = Modifier
@@ -1,6 +1,7 @@
package com.x8bit.bitwarden.ui.vault.feature.verificationcode
import android.os.Parcelable
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.viewModelScope
import com.bitwarden.core.data.repository.model.DataState
import com.bitwarden.data.repository.util.baseIconUrl
@@ -11,6 +12,7 @@ import com.bitwarden.ui.util.concat
import com.x8bit.bitwarden.R
import com.x8bit.bitwarden.data.auth.repository.AuthRepository
import com.x8bit.bitwarden.data.auth.repository.model.UserState
import com.x8bit.bitwarden.data.auth.repository.model.ValidatePasswordResult
import com.x8bit.bitwarden.data.platform.manager.clipboard.BitwardenClipboardManager
import com.x8bit.bitwarden.data.platform.repository.EnvironmentRepository
import com.x8bit.bitwarden.data.platform.repository.SettingsRepository
@@ -25,9 +27,12 @@ import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.parcelize.Parcelize
import javax.inject.Inject
private const val KEY_STATE = "state"
/**
* Handles [VerificationCodeAction],
* and launches [VerificationCodeEvent] for the [VerificationCodeScreen].
@@ -35,13 +40,15 @@ import javax.inject.Inject
@Suppress("TooManyFunctions")
@HiltViewModel
class VerificationCodeViewModel @Inject constructor(
authRepository: AuthRepository,
savedStateHandle: SavedStateHandle,
private val authRepository: AuthRepository,
private val clipboardManager: BitwardenClipboardManager,
private val environmentRepository: EnvironmentRepository,
private val settingsRepository: SettingsRepository,
private val vaultRepository: VaultRepository,
) : BaseViewModel<VerificationCodeState, VerificationCodeEvent, VerificationCodeAction>(
initialState = run {
// We load the state from the savedStateHandle for testing purposes.
initialState = savedStateHandle[KEY_STATE] ?: run {
VerificationCodeState(
baseIconUrl = environmentRepository.environment.environmentUrlData.baseIconUrl,
isIconLoadingDisabled = settingsRepository.isIconLoadingDisabled,
@@ -88,7 +95,9 @@ class VerificationCodeViewModel @Inject constructor(
when (action) {
is VerificationCodeAction.BackClick -> handleBackClick()
is VerificationCodeAction.CopyClick -> handleCopyClick(action)
is VerificationCodeAction.DismissDialog -> handleDismissDialog()
is VerificationCodeAction.ItemClick -> handleItemClick(action)
is VerificationCodeAction.MasterPasswordSubmit -> handleMasterPasswordSubmit(action)
is VerificationCodeAction.LockClick -> handleLockClick()
is VerificationCodeAction.RefreshClick -> handleRefreshClick()
is VerificationCodeAction.RefreshPull -> handleRefreshPull()
@@ -112,12 +121,28 @@ class VerificationCodeViewModel @Inject constructor(
)
}
private fun handleDismissDialog() {
mutableStateFlow.update { it.copy(dialogState = null) }
}
private fun handleItemClick(action: VerificationCodeAction.ItemClick) {
sendEvent(
VerificationCodeEvent.NavigateToVaultItem(action.id),
)
}
private fun handleMasterPasswordSubmit(action: VerificationCodeAction.MasterPasswordSubmit) {
viewModelScope.launch {
val result = authRepository.validatePassword(action.password)
sendAction(
VerificationCodeAction.Internal.ValidatePasswordResultReceive(
result = result,
cipherId = action.cipherId,
),
)
}
}
private fun handleLockClick() {
vaultRepository.lockVaultForCurrentUser(isUserInitiated = true)
}
@@ -152,14 +177,54 @@ class VerificationCodeViewModel @Inject constructor(
private fun handleInternalAction(action: VerificationCodeAction.Internal) {
when (action) {
is VerificationCodeAction.Internal.IconLoadingSettingReceive ->
is VerificationCodeAction.Internal.IconLoadingSettingReceive -> {
handleIconsSettingReceived(action)
}
is VerificationCodeAction.Internal.PullToRefreshEnableReceive ->
is VerificationCodeAction.Internal.PullToRefreshEnableReceive -> {
handlePullToRefreshEnableReceive(action)
}
is VerificationCodeAction.Internal.AuthCodesReceive ->
is VerificationCodeAction.Internal.AuthCodesReceive -> {
handleAuthCodeReceive(action)
}
is VerificationCodeAction.Internal.ValidatePasswordResultReceive -> {
handleValidatePasswordResultReceive(action)
}
}
}
private fun handleValidatePasswordResultReceive(
action: VerificationCodeAction.Internal.ValidatePasswordResultReceive,
) {
when (val result = action.result) {
is ValidatePasswordResult.Error -> {
mutableStateFlow.update {
it.copy(
dialogState = VerificationCodeState.DialogState.Error(
title = null,
message = R.string.generic_error_message.asText(),
throwable = result.error,
),
)
}
}
is ValidatePasswordResult.Success -> {
if (result.isValid) {
trySendAction(VerificationCodeAction.ItemClick(id = action.cipherId))
} else {
mutableStateFlow.update {
it.copy(
dialogState = VerificationCodeState.DialogState.Error(
title = null,
message = R.string.invalid_master_password.asText(),
),
)
}
}
}
}
}
@@ -359,6 +424,15 @@ data class VerificationCodeState(
* Represents the current state of any dialogs on the screen.
*/
sealed class DialogState : Parcelable {
/**
* Represents an error dialog with the given [title] and [message].
*/
@Parcelize
data class Error(
val title: Text?,
val message: Text,
val throwable: Throwable? = null,
) : DialogState()
/**
* Represents a loading dialog with the given [message].
@@ -457,6 +531,11 @@ sealed class VerificationCodeAction {
*/
data object BackClick : VerificationCodeAction()
/**
* User has dismissed the dialog.
*/
data object DismissDialog : VerificationCodeAction()
/**
* User has clicked the copy button.
*/
@@ -469,6 +548,14 @@ sealed class VerificationCodeAction {
*/
data class ItemClick(val id: String) : VerificationCodeAction()
/**
* Validates the master password before viewing the cipher.
*/
data class MasterPasswordSubmit(
val cipherId: String,
val password: String,
) : VerificationCodeAction()
/**
* User has clicked the lock button.
*/
@@ -519,5 +606,13 @@ sealed class VerificationCodeAction {
data class AuthCodesReceive(
val verificationCodeData: DataState<List<VerificationCodeItem>>,
) : Internal()
/**
* The password validation data was received.
*/
data class ValidatePasswordResultReceive(
val result: ValidatePasswordResult,
val cipherId: String,
) : Internal()
}
}
@@ -11,10 +11,12 @@ data class VerificationCodeHandlers(
val backClick: () -> Unit,
val searchIconClick: () -> Unit,
val itemClick: (id: String) -> Unit,
val masterPasswordRepromptSubmit: (id: String, password: String) -> Unit,
val refreshClick: () -> Unit,
val syncClick: () -> Unit,
val lockClick: () -> Unit,
val copyClick: (text: String) -> Unit,
val dismissDialog: () -> Unit,
) {
@Suppress("UndocumentedPublicClass")
companion object {
@@ -31,10 +33,16 @@ data class VerificationCodeHandlers(
viewModel.trySendAction(VerificationCodeAction.SearchIconClick)
},
itemClick = { viewModel.trySendAction(VerificationCodeAction.ItemClick(it)) },
masterPasswordRepromptSubmit = { cipherId, password ->
viewModel.trySendAction(
action = VerificationCodeAction.MasterPasswordSubmit(cipherId, password),
)
},
refreshClick = { viewModel.trySendAction(VerificationCodeAction.RefreshClick) },
syncClick = { viewModel.trySendAction(VerificationCodeAction.SyncClick) },
lockClick = { viewModel.trySendAction(VerificationCodeAction.LockClick) },
copyClick = { viewModel.trySendAction(VerificationCodeAction.CopyClick(it)) },
dismissDialog = { viewModel.trySendAction(VerificationCodeAction.DismissDialog) },
)
}
}
@@ -401,11 +401,11 @@ class SearchScreenTest : BitwardenComposeTest() {
}
@Test
fun `clicking on totp when reprompt is required should show master password dialog`() {
fun `clicking on item when reprompt is required should show master password dialog`() {
mutableStateFlow.value = DEFAULT_STATE.copy(
viewState = SearchState.ViewState.Content(
displayItems = listOf(
createMockDisplayItemForCipher(number = 1, isTotp = true).copy(
createMockDisplayItemForCipher(number = 1).copy(
shouldDisplayMasterPasswordReprompt = true,
),
),
@@ -626,6 +626,7 @@ class SearchScreenTest : BitwardenComposeTest() {
overflowAction = ListingItemOverflowAction.VaultAction.ViewClick(
cipherId = "mockId-1",
cipherType = CipherType.LOGIN,
requiresPasswordReprompt = true,
),
),
)
@@ -650,8 +650,11 @@ class SearchViewModelTest : BaseViewModelTest() {
viewModel.trySendAction(
SearchAction.MasterPasswordRepromptSubmit(
password = password,
masterPasswordRepromptData = MasterPasswordRepromptData.Totp(
masterPasswordRepromptData = MasterPasswordRepromptData.ViewItem(
cipherId = cipherId,
itemType = SearchState.DisplayItem.ItemType.Vault(
type = CipherType.LOGIN,
),
),
),
)
@@ -1103,6 +1106,7 @@ class SearchViewModelTest : BaseViewModelTest() {
ListingItemOverflowAction.VaultAction.ViewClick(
cipherId = cipherId,
cipherType = CipherType.LOGIN,
requiresPasswordReprompt = true,
),
),
)
@@ -1140,7 +1144,6 @@ class SearchViewModelTest : BaseViewModelTest() {
isAutofill = false,
hasMasterPassword = true,
isPremiumUser = true,
isTotp = false,
)
} returns expectedViewState
val dataState = DataState.Loaded(
@@ -1243,7 +1246,6 @@ class SearchViewModelTest : BaseViewModelTest() {
isAutofill = false,
hasMasterPassword = true,
isPremiumUser = true,
isTotp = false,
)
} returns expectedViewState
mutableVaultDataStateFlow.tryEmit(
@@ -1356,7 +1358,6 @@ class SearchViewModelTest : BaseViewModelTest() {
isAutofill = false,
hasMasterPassword = true,
isPremiumUser = true,
isTotp = false,
)
} returns expectedViewState
val dataState = DataState.Error(
@@ -1472,7 +1473,6 @@ class SearchViewModelTest : BaseViewModelTest() {
isAutofill = false,
hasMasterPassword = true,
isPremiumUser = true,
isTotp = false,
)
} returns expectedViewState
val dataState = DataState.NoNetwork(
@@ -1637,7 +1637,6 @@ class SearchViewModelTest : BaseViewModelTest() {
isAutofill = true,
hasMasterPassword = true,
isPremiumUser = true,
isTotp = false,
)
} returns expectedViewState
val dataState = DataState.Loaded(
@@ -326,7 +326,6 @@ class SearchTypeDataExtensionsTest {
isAutofill = false,
hasMasterPassword = true,
isPremiumUser = true,
isTotp = true,
)
assertEquals(SearchState.ViewState.Empty(message = null), result)
@@ -352,7 +351,6 @@ class SearchTypeDataExtensionsTest {
isAutofill = false,
hasMasterPassword = true,
isPremiumUser = true,
isTotp = false,
)
assertEquals(
@@ -393,7 +391,6 @@ class SearchTypeDataExtensionsTest {
isAutofill = true,
hasMasterPassword = true,
isPremiumUser = true,
isTotp = false,
)
assertEquals(
@@ -444,7 +441,6 @@ class SearchTypeDataExtensionsTest {
isAutofill = false,
hasMasterPassword = true,
isPremiumUser = true,
isTotp = true,
)
assertEquals(
@@ -474,7 +470,6 @@ class SearchTypeDataExtensionsTest {
hasMasterPassword = true,
isAutofill = false,
isPremiumUser = true,
isTotp = false,
)
assertEquals(
@@ -16,7 +16,6 @@ import kotlinx.collections.immutable.persistentListOf
fun createMockDisplayItemForCipher(
number: Int,
cipherType: CipherType = CipherType.LOGIN,
isTotp: Boolean = false,
@DrawableRes fallbackIconRes: Int = R.drawable.ic_globe,
): SearchState.DisplayItem =
when (cipherType) {
@@ -47,6 +46,7 @@ fun createMockDisplayItemForCipher(
ListingItemOverflowAction.VaultAction.ViewClick(
cipherId = "mockId-$number",
cipherType = CipherType.LOGIN,
requiresPasswordReprompt = true,
),
ListingItemOverflowAction.VaultAction.EditClick(
cipherId = "mockId-$number",
@@ -72,7 +72,6 @@ fun createMockDisplayItemForCipher(
totpCode = "mockTotp-$number",
autofillSelectionOptions = emptyList(),
shouldDisplayMasterPasswordReprompt = false,
isTotp = isTotp,
itemType = SearchState.DisplayItem.ItemType.Vault(type = cipherType),
)
}
@@ -101,6 +100,7 @@ fun createMockDisplayItemForCipher(
ListingItemOverflowAction.VaultAction.ViewClick(
cipherId = "mockId-$number",
cipherType = CipherType.SECURE_NOTE,
requiresPasswordReprompt = true,
),
ListingItemOverflowAction.VaultAction.EditClick(
cipherId = "mockId-$number",
@@ -115,7 +115,6 @@ fun createMockDisplayItemForCipher(
totpCode = null,
autofillSelectionOptions = emptyList(),
shouldDisplayMasterPasswordReprompt = false,
isTotp = false,
itemType = SearchState.DisplayItem.ItemType.Vault(type = cipherType),
)
}
@@ -144,6 +143,7 @@ fun createMockDisplayItemForCipher(
ListingItemOverflowAction.VaultAction.ViewClick(
cipherId = "mockId-$number",
cipherType = CipherType.CARD,
requiresPasswordReprompt = true,
),
ListingItemOverflowAction.VaultAction.EditClick(
cipherId = "mockId-$number",
@@ -164,7 +164,6 @@ fun createMockDisplayItemForCipher(
totpCode = null,
autofillSelectionOptions = emptyList(),
shouldDisplayMasterPasswordReprompt = false,
isTotp = false,
itemType = SearchState.DisplayItem.ItemType.Vault(type = cipherType),
)
}
@@ -193,6 +192,7 @@ fun createMockDisplayItemForCipher(
ListingItemOverflowAction.VaultAction.ViewClick(
cipherId = "mockId-$number",
cipherType = CipherType.IDENTITY,
requiresPasswordReprompt = true,
),
ListingItemOverflowAction.VaultAction.EditClick(
cipherId = "mockId-$number",
@@ -204,7 +204,6 @@ fun createMockDisplayItemForCipher(
totpCode = null,
autofillSelectionOptions = emptyList(),
shouldDisplayMasterPasswordReprompt = false,
isTotp = false,
itemType = SearchState.DisplayItem.ItemType.Vault(type = cipherType),
)
}
@@ -228,6 +227,7 @@ fun createMockDisplayItemForCipher(
ListingItemOverflowAction.VaultAction.ViewClick(
cipherId = "mockId-$number",
cipherType = CipherType.SSH_KEY,
requiresPasswordReprompt = true,
),
ListingItemOverflowAction.VaultAction.EditClick(
cipherId = "mockId-$number",
@@ -239,7 +239,6 @@ fun createMockDisplayItemForCipher(
totpCode = null,
autofillSelectionOptions = emptyList(),
shouldDisplayMasterPasswordReprompt = false,
isTotp = false,
itemType = SearchState.DisplayItem.ItemType.Vault(type = cipherType),
)
}
@@ -296,7 +295,6 @@ fun createMockDisplayItemForSend(
totpCode = null,
autofillSelectionOptions = emptyList(),
shouldDisplayMasterPasswordReprompt = false,
isTotp = false,
itemType = SearchState.DisplayItem.ItemType.Sends(type = sendType),
)
}
@@ -343,7 +341,6 @@ fun createMockDisplayItemForSend(
totpCode = null,
autofillSelectionOptions = emptyList(),
shouldDisplayMasterPasswordReprompt = false,
isTotp = false,
itemType = SearchState.DisplayItem.ItemType.Sends(type = sendType),
)
}
@@ -25,7 +25,6 @@ import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.onSiblings
import androidx.compose.ui.test.performClick
import androidx.compose.ui.test.performSemanticsAction
import androidx.compose.ui.test.performTextInput
import androidx.core.net.toUri
import com.bitwarden.core.data.repository.util.bufferedMutableSharedFlow
import com.bitwarden.ui.util.asText
@@ -222,53 +221,6 @@ class VaultItemScreenTest : BitwardenComposeTest() {
.assert(hasAnyAncestor(isDialog()))
}
@Test
fun `MasterPassword dialog should be displayed according to state`() {
composeTestRule.onNode(isDialog()).assertDoesNotExist()
composeTestRule.onNodeWithText("Master password confirmation").assertDoesNotExist()
mutableStateFlow.update {
it.copy(
dialog = VaultItemState.DialogState.MasterPasswordDialog(
action = PasswordRepromptAction.DeleteClick,
),
)
}
composeTestRule
.onNodeWithText("Master password confirmation")
.assertIsDisplayed()
.assert(hasAnyAncestor(isDialog()))
}
@Test
fun `Ok click on master password dialog should emit DismissDialogClick`() {
val enteredPassword = "pass1234"
val passwordRepromptAction = PasswordRepromptAction.EditClick
mutableStateFlow.update {
it.copy(
dialog = VaultItemState.DialogState.MasterPasswordDialog(
action = passwordRepromptAction,
),
)
}
composeTestRule.onNodeWithText("Master password").performTextInput(enteredPassword)
composeTestRule
.onAllNodesWithText("Submit")
.filterToOne(hasAnyAncestor(isDialog()))
.performClick()
verify {
viewModel.trySendAction(
VaultItemAction.Common.MasterPasswordSubmit(
masterPassword = enteredPassword,
action = passwordRepromptAction,
),
)
}
}
@Test
fun `name should be displayed according to state`() {
EMPTY_VIEW_STATES
@@ -3193,7 +3145,6 @@ private val DEFAULT_COMMON: VaultItemState.ViewState.Content.Common =
value = true,
),
),
requiresReprompt = true,
requiresCloneConfirmation = false,
attachments = listOf(
VaultItemState.ViewState.Content.Common.AttachmentItem(
@@ -3291,7 +3242,6 @@ private val EMPTY_COMMON: VaultItemState.ViewState.Content.Common =
lastUpdated = "12/31/69 06:16 PM",
notes = null,
customFields = emptyList(),
requiresReprompt = true,
requiresCloneConfirmation = false,
attachments = emptyList(),
canDelete = true,
File diff suppressed because it is too large. Load diff
@@ -38,105 +38,12 @@ class CipherViewExtensionsTest {
unmockkStatic(Uri::class)
}
@Suppress("MaxLineLength")
@Test
fun `toViewState should transform full CipherView into ViewState Login Content maintaining re-prompt and visibility state`() {
val cipherView = createCipherView(type = CipherType.LOGIN, isEmpty = false)
val viewState = cipherView.toViewState(
previousState = VaultItemState.ViewState.Content(
common = createCommonContent(isEmpty = false, isPremiumUser = true).copy(
currentCipher = cipherView,
// This re-prompt state should be preserved
requiresReprompt = false,
),
type = createLoginContent(isEmpty = false).copy(
passwordData = VaultItemState.ViewState.Content.ItemType.Login.PasswordData(
password = "password",
// This visibility state should be preserved
isVisible = true,
canViewPassword = false,
),
),
),
isPremiumUser = true,
hasMasterPassword = true,
totpCodeItemData = TotpCodeItemData(
periodSeconds = 30,
timeLeftSeconds = 15,
verificationCode = "123456",
totpCode = "testCode",
),
clock = fixedClock,
canDelete = true,
canRestore = true,
canAssignToCollections = true,
canEdit = true,
baseIconUrl = "https://example.com/",
isIconLoadingDisabled = true,
relatedLocations = persistentListOf(),
)
assertEquals(
VaultItemState.ViewState.Content(
common = createCommonContent(isEmpty = false, isPremiumUser = true).copy(
currentCipher = cipherView,
requiresReprompt = false,
),
type = createLoginContent(isEmpty = false).copy(
passwordData = VaultItemState.ViewState.Content.ItemType.Login.PasswordData(
password = "password",
isVisible = true,
canViewPassword = false,
),
),
),
viewState,
)
}
@Suppress("MaxLineLength")
@Test
fun `toViewState should transform full CipherView into ViewState Login Content without master password reprompt`() {
val cipherView = createCipherView(type = CipherType.LOGIN, isEmpty = false)
val viewState = cipherView.toViewState(
previousState = null,
isPremiumUser = true,
hasMasterPassword = false,
totpCodeItemData = TotpCodeItemData(
periodSeconds = 30,
timeLeftSeconds = 15,
verificationCode = "123456",
totpCode = "testCode",
),
clock = fixedClock,
canDelete = true,
canRestore = true,
canAssignToCollections = true,
canEdit = true,
baseIconUrl = "https://example.com/",
isIconLoadingDisabled = true,
relatedLocations = persistentListOf(),
)
assertEquals(
VaultItemState.ViewState.Content(
common = createCommonContent(isEmpty = false, isPremiumUser = true).copy(
currentCipher = cipherView,
requiresReprompt = false,
),
type = createLoginContent(isEmpty = false),
),
viewState,
)
}
@Test
fun `toViewState should transform full CipherView into ViewState Login Content with premium`() {
val cipherView = createCipherView(type = CipherType.LOGIN, isEmpty = false)
val viewState = cipherView.toViewState(
previousState = null,
isPremiumUser = true,
hasMasterPassword = true,
totpCodeItemData = TotpCodeItemData(
periodSeconds = 30,
timeLeftSeconds = 15,
@@ -171,7 +78,6 @@ class CipherViewExtensionsTest {
val viewState = cipherView.toViewState(
previousState = null,
isPremiumUser = isPremiumUser,
hasMasterPassword = true,
totpCodeItemData = TotpCodeItemData(
periodSeconds = 30,
timeLeftSeconds = 15,
@@ -212,7 +118,6 @@ class CipherViewExtensionsTest {
val viewState = cipherView.toViewState(
previousState = null,
isPremiumUser = isPremiumUser,
hasMasterPassword = true,
totpCodeItemData = TotpCodeItemData(
periodSeconds = 30,
timeLeftSeconds = 15,
@@ -248,7 +153,6 @@ class CipherViewExtensionsTest {
val viewState = cipherView.toViewState(
previousState = null,
isPremiumUser = true,
hasMasterPassword = true,
totpCodeItemData = null,
clock = fixedClock,
canDelete = true,
@@ -277,7 +181,6 @@ class CipherViewExtensionsTest {
val viewState = cipherView.toViewState(
previousState = null,
isPremiumUser = true,
hasMasterPassword = true,
totpCodeItemData = null,
clock = fixedClock,
canDelete = true,
@@ -309,7 +212,6 @@ class CipherViewExtensionsTest {
val viewState = cipherView.toViewState(
previousState = null,
isPremiumUser = true,
hasMasterPassword = true,
totpCodeItemData = null,
clock = fixedClock,
canDelete = true,
@@ -351,7 +253,6 @@ class CipherViewExtensionsTest {
val viewState = cipherView.toViewState(
previousState = null,
isPremiumUser = true,
hasMasterPassword = true,
totpCodeItemData = null,
clock = fixedClock,
canDelete = true,
@@ -398,7 +299,6 @@ class CipherViewExtensionsTest {
val result = cipherView.toViewState(
previousState = null,
isPremiumUser = true,
hasMasterPassword = true,
totpCodeItemData = null,
clock = fixedClock,
canDelete = true,
@@ -447,7 +347,6 @@ class CipherViewExtensionsTest {
val viewState = cipherView.toViewState(
previousState = null,
isPremiumUser = true,
hasMasterPassword = true,
totpCodeItemData = null,
clock = fixedClock,
canDelete = true,
@@ -480,7 +379,6 @@ class CipherViewExtensionsTest {
val viewState = cipherView.toViewState(
previousState = null,
isPremiumUser = true,
hasMasterPassword = true,
totpCodeItemData = null,
clock = fixedClock,
canDelete = true,
@@ -511,7 +409,6 @@ class CipherViewExtensionsTest {
val viewState = cipherView.toViewState(
previousState = null,
isPremiumUser = true,
hasMasterPassword = true,
totpCodeItemData = null,
clock = fixedClock,
canDelete = true,
@@ -559,7 +456,6 @@ class CipherViewExtensionsTest {
val viewState = cipherView.toViewState(
previousState = null,
isPremiumUser = true,
hasMasterPassword = true,
totpCodeItemData = null,
clock = fixedClock,
canDelete = true,
@@ -587,7 +483,6 @@ class CipherViewExtensionsTest {
val viewState = cipherView.toViewState(
previousState = null,
isPremiumUser = true,
hasMasterPassword = true,
totpCodeItemData = null,
clock = fixedClock,
canDelete = true,
@@ -172,7 +172,6 @@ fun createCommonContent(
lastUpdated = "1/1/70 12:16 AM",
notes = null,
customFields = emptyList(),
requiresReprompt = true,
requiresCloneConfirmation = false,
attachments = emptyList(),
canDelete = true,
@@ -226,7 +225,6 @@ fun createCommonContent(
)
.toCustomField(null),
),
requiresReprompt = true,
requiresCloneConfirmation = true,
attachments = listOf(
VaultItemState.ViewState.Content.Common.AttachmentItem(
@@ -1121,7 +1121,6 @@ class VaultItemListingScreenTest : BitwardenComposeTest() {
displayCollectionList = emptyList(),
displayItemList = listOf(
createDisplayItem(number = 1).copy(
isTotp = true,
shouldShowMasterPasswordReprompt = true,
),
),
@@ -2373,7 +2372,6 @@ private fun createDisplayItem(number: Int): VaultItemListingState.DisplayItem =
isCredentialCreation = false,
shouldShowMasterPasswordReprompt = false,
iconTestTag = null,
isTotp = false,
itemType = VaultItemListingState.DisplayItem.ItemType.Sends(type = SendType.TEXT),
)
@@ -2400,6 +2398,5 @@ private fun createCipherDisplayItem(number: Int): VaultItemListingState.DisplayI
isCredentialCreation = false,
shouldShowMasterPasswordReprompt = false,
iconTestTag = null,
isTotp = true,
itemType = VaultItemListingState.DisplayItem.ItemType.Vault(type = CipherType.LOGIN),
)
@@ -1178,6 +1178,17 @@ class VaultItemListingViewModelTest : BaseViewModelTest() {
runTest {
val cipherId = "cipherId-1234"
val password = "password"
specialCircumstanceManager.specialCircumstance = SpecialCircumstance.AddTotpLoginItem(
data = TotpData(
uri = "uri",
issuer = "issuer",
accountName = "Name-1",
secret = "secret",
digits = 6,
period = 30,
algorithm = TotpData.CryptoHashAlgorithm.SHA_1,
),
)
val viewModel = createVaultItemListingViewModel()
coEvery {
authRepository.validatePassword(password = password)
@@ -1187,8 +1198,11 @@ class VaultItemListingViewModelTest : BaseViewModelTest() {
viewModel.trySendAction(
VaultItemListingsAction.MasterPasswordRepromptSubmit(
password = password,
masterPasswordRepromptData = MasterPasswordRepromptData.Totp(
cipherId = cipherId,
masterPasswordRepromptData = MasterPasswordRepromptData.ViewItem(
id = cipherId,
itemType = VaultItemListingState.DisplayItem.ItemType.Vault(
type = CipherType.LOGIN,
),
),
),
)
@@ -1738,6 +1752,7 @@ class VaultItemListingViewModelTest : BaseViewModelTest() {
ListingItemOverflowAction.VaultAction.ViewClick(
cipherId = cipherId,
cipherType = CipherType.LOGIN,
requiresPasswordReprompt = true,
),
),
)
@@ -1903,7 +1918,6 @@ class VaultItemListingViewModelTest : BaseViewModelTest() {
createMockDisplayItemForCipher(
number = 1,
secondSubtitleTestTag = "PasskeySite",
isTotp = true,
),
),
displayFolderList = emptyList(),
@@ -12,14 +12,12 @@ import kotlinx.collections.immutable.persistentListOf
/**
* Create a mock [VaultItemListingState.DisplayItem] with a given [number].
*/
@Suppress("LongParameterList")
fun createMockDisplayItemForCipher(
number: Int,
cipherType: CipherType = CipherType.LOGIN,
subtitle: String? = "mockUsername-$number",
secondSubtitleTestTag: String? = null,
requiresPasswordReprompt: Boolean = true,
isTotp: Boolean = false,
): VaultItemListingState.DisplayItem =
when (cipherType) {
CipherType.LOGIN -> {
@@ -51,6 +49,7 @@ fun createMockDisplayItemForCipher(
ListingItemOverflowAction.VaultAction.ViewClick(
cipherId = "mockId-$number",
cipherType = cipherType,
requiresPasswordReprompt = requiresPasswordReprompt,
),
ListingItemOverflowAction.VaultAction.EditClick(
cipherId = "mockId-$number",
@@ -77,7 +76,6 @@ fun createMockDisplayItemForCipher(
isCredentialCreation = false,
shouldShowMasterPasswordReprompt = false,
iconTestTag = "LoginCipherIcon",
isTotp = isTotp,
itemType = VaultItemListingState.DisplayItem.ItemType.Vault(type = cipherType),
)
}
@@ -108,6 +106,7 @@ fun createMockDisplayItemForCipher(
ListingItemOverflowAction.VaultAction.ViewClick(
cipherId = "mockId-$number",
cipherType = cipherType,
requiresPasswordReprompt = requiresPasswordReprompt,
),
ListingItemOverflowAction.VaultAction.EditClick(
cipherId = "mockId-$number",
@@ -123,7 +122,6 @@ fun createMockDisplayItemForCipher(
isCredentialCreation = false,
shouldShowMasterPasswordReprompt = false,
iconTestTag = "SecureNoteCipherIcon",
isTotp = false,
itemType = VaultItemListingState.DisplayItem.ItemType.Vault(type = cipherType),
)
}
@@ -154,6 +152,7 @@ fun createMockDisplayItemForCipher(
ListingItemOverflowAction.VaultAction.ViewClick(
cipherId = "mockId-$number",
cipherType = cipherType,
requiresPasswordReprompt = requiresPasswordReprompt,
),
ListingItemOverflowAction.VaultAction.EditClick(
cipherId = "mockId-$number",
@@ -175,7 +174,6 @@ fun createMockDisplayItemForCipher(
isCredentialCreation = false,
shouldShowMasterPasswordReprompt = false,
iconTestTag = "CardCipherIcon",
isTotp = false,
itemType = VaultItemListingState.DisplayItem.ItemType.Vault(type = cipherType),
)
}
@@ -206,6 +204,7 @@ fun createMockDisplayItemForCipher(
ListingItemOverflowAction.VaultAction.ViewClick(
cipherId = "mockId-$number",
cipherType = cipherType,
requiresPasswordReprompt = requiresPasswordReprompt,
),
ListingItemOverflowAction.VaultAction.EditClick(
cipherId = "mockId-$number",
@@ -218,7 +217,6 @@ fun createMockDisplayItemForCipher(
isCredentialCreation = false,
shouldShowMasterPasswordReprompt = false,
iconTestTag = "IdentityCipherIcon",
isTotp = false,
itemType = VaultItemListingState.DisplayItem.ItemType.Vault(type = cipherType),
)
}
@@ -249,6 +247,7 @@ fun createMockDisplayItemForCipher(
ListingItemOverflowAction.VaultAction.ViewClick(
cipherId = "mockId-$number",
cipherType = cipherType,
requiresPasswordReprompt = requiresPasswordReprompt,
),
ListingItemOverflowAction.VaultAction.EditClick(
cipherId = "mockId-$number",
@@ -261,7 +260,6 @@ fun createMockDisplayItemForCipher(
isCredentialCreation = false,
shouldShowMasterPasswordReprompt = false,
iconTestTag = "SshKeyCipherIcon",
isTotp = false,
itemType = VaultItemListingState.DisplayItem.ItemType.Vault(type = cipherType),
)
}
@@ -321,7 +319,6 @@ fun createMockDisplayItemForSend(
isCredentialCreation = false,
shouldShowMasterPasswordReprompt = false,
iconTestTag = null,
isTotp = false,
itemType = VaultItemListingState.DisplayItem.ItemType.Sends(type = sendType),
)
}
@@ -371,7 +368,6 @@ fun createMockDisplayItemForSend(
isCredentialCreation = false,
shouldShowMasterPasswordReprompt = false,
iconTestTag = null,
isTotp = false,
itemType = VaultItemListingState.DisplayItem.ItemType.Sends(type = sendType),
)
}
@@ -33,6 +33,7 @@ class CipherViewExtensionsTest {
ListingItemOverflowAction.VaultAction.ViewClick(
cipherId = id,
cipherType = CipherType.LOGIN,
requiresPasswordReprompt = false,
),
ListingItemOverflowAction.VaultAction.EditClick(
cipherId = id,
@@ -70,6 +71,7 @@ class CipherViewExtensionsTest {
ListingItemOverflowAction.VaultAction.ViewClick(
cipherId = id,
cipherType = CipherType.LOGIN,
requiresPasswordReprompt = false,
),
ListingItemOverflowAction.VaultAction.EditClick(
cipherId = id,
@@ -107,6 +109,7 @@ class CipherViewExtensionsTest {
ListingItemOverflowAction.VaultAction.ViewClick(
cipherId = id,
cipherType = CipherType.LOGIN,
requiresPasswordReprompt = true,
),
ListingItemOverflowAction.VaultAction.EditClick(
cipherId = id,
@@ -144,6 +147,7 @@ class CipherViewExtensionsTest {
ListingItemOverflowAction.VaultAction.ViewClick(
cipherId = id,
cipherType = CipherType.LOGIN,
requiresPasswordReprompt = true,
),
),
result,
@@ -169,6 +173,7 @@ class CipherViewExtensionsTest {
ListingItemOverflowAction.VaultAction.ViewClick(
cipherId = id,
cipherType = CipherType.CARD,
requiresPasswordReprompt = true,
),
ListingItemOverflowAction.VaultAction.EditClick(
cipherId = id,
@@ -211,6 +216,7 @@ class CipherViewExtensionsTest {
ListingItemOverflowAction.VaultAction.ViewClick(
cipherId = id,
cipherType = CipherType.CARD,
requiresPasswordReprompt = false,
),
),
result,
@@ -231,6 +237,7 @@ class CipherViewExtensionsTest {
ListingItemOverflowAction.VaultAction.ViewClick(
cipherId = id,
cipherType = CipherType.IDENTITY,
requiresPasswordReprompt = false,
),
ListingItemOverflowAction.VaultAction.EditClick(
cipherId = id,
@@ -254,13 +261,14 @@ class CipherViewExtensionsTest {
identity = createMockIdentityView(number = 1),
)
val result = cipher.toOverflowActions(hasMasterPassword = true, false)
val result = cipher.toOverflowActions(hasMasterPassword = true, isPremiumUser = false)
assertEquals(
listOf(
ListingItemOverflowAction.VaultAction.ViewClick(
cipherId = id,
cipherType = CipherType.IDENTITY,
requiresPasswordReprompt = true,
),
),
result,
@@ -283,6 +291,7 @@ class CipherViewExtensionsTest {
ListingItemOverflowAction.VaultAction.ViewClick(
cipherId = id,
cipherType = CipherType.SECURE_NOTE,
requiresPasswordReprompt = true,
),
ListingItemOverflowAction.VaultAction.EditClick(
cipherId = id,
@@ -315,6 +324,7 @@ class CipherViewExtensionsTest {
ListingItemOverflowAction.VaultAction.ViewClick(
cipherId = id,
cipherType = CipherType.SECURE_NOTE,
requiresPasswordReprompt = false,
),
),
result,
@@ -346,6 +356,7 @@ class CipherViewExtensionsTest {
ListingItemOverflowAction.VaultAction.ViewClick(
cipherId = id,
cipherType = CipherType.LOGIN,
requiresPasswordReprompt = true,
),
),
result,
@@ -377,6 +388,7 @@ class CipherViewExtensionsTest {
ListingItemOverflowAction.VaultAction.ViewClick(
cipherId = id,
cipherType = CipherType.LOGIN,
requiresPasswordReprompt = true,
),
ListingItemOverflowAction.VaultAction.EditClick(
cipherId = id,
@@ -7,17 +7,20 @@ import androidx.compose.ui.test.assertTextEquals
import androidx.compose.ui.test.filterToOne
import androidx.compose.ui.test.hasAnyAncestor
import androidx.compose.ui.test.hasClickAction
import androidx.compose.ui.test.hasContentDescription
import androidx.compose.ui.test.hasScrollToNodeAction
import androidx.compose.ui.test.hasText
import androidx.compose.ui.test.isDialog
import androidx.compose.ui.test.isDisplayed
import androidx.compose.ui.test.isPopup
import androidx.compose.ui.test.onAllNodesWithText
import androidx.compose.ui.test.onChildren
import androidx.compose.ui.test.onNodeWithContentDescription
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import androidx.compose.ui.test.performScrollTo
import androidx.compose.ui.test.performScrollToNode
import androidx.compose.ui.test.performTextInput
import androidx.core.net.toUri
import com.bitwarden.core.data.repository.util.bufferedMutableSharedFlow
import com.bitwarden.data.repository.model.Environment
@@ -26,6 +29,7 @@ import com.bitwarden.ui.util.asText
import com.bitwarden.ui.util.assertNoDialogExists
import com.bitwarden.ui.util.assertScrollableNodeDoesNotExist
import com.bitwarden.ui.util.onNodeWithTextAfterScroll
import com.bitwarden.vault.CipherType
import com.x8bit.bitwarden.R
import com.x8bit.bitwarden.data.util.advanceTimeByAndRunCurrent
import com.x8bit.bitwarden.ui.platform.base.BitwardenComposeTest
@@ -51,6 +55,7 @@ import com.x8bit.bitwarden.ui.util.performYesDialogButtonClick
import com.x8bit.bitwarden.ui.vault.components.model.CreateVaultItemType
import com.x8bit.bitwarden.ui.vault.feature.addedit.VaultAddEditArgs
import com.x8bit.bitwarden.ui.vault.feature.item.VaultItemArgs
import com.x8bit.bitwarden.ui.vault.feature.itemlisting.model.ListingItemOverflowAction
import com.x8bit.bitwarden.ui.vault.feature.vault.model.VaultFilterData
import com.x8bit.bitwarden.ui.vault.feature.vault.model.VaultFilterType
import com.x8bit.bitwarden.ui.vault.model.VaultAddEditType
@@ -885,6 +890,263 @@ class VaultScreenTest : BitwardenComposeTest() {
}
}
@Suppress("MaxLineLength")
@Test
fun `clicking a favorite item with password prompt should prompt for password before dismissing upon Cancel`() {
val itemText = "Test Item"
val userName = "Bitwarden"
val vaultItem = VaultState.ViewState.VaultItem.Login(
id = "12345",
name = itemText.asText(),
username = userName.asText(),
overflowOptions = emptyList(),
shouldShowMasterPasswordReprompt = true,
)
mutableStateFlow.update {
it.copy(
viewState = DEFAULT_CONTENT_VIEW_STATE.copy(
favoriteItems = listOf(vaultItem),
),
)
}
composeTestRule.onNode(hasScrollToNodeAction()).performScrollToNode(hasText(itemText))
composeTestRule
.onNodeWithText(text = itemText)
.assertTextEquals(itemText, userName)
.performClick()
composeTestRule
.onAllNodesWithText(text = "Master password confirmation")
.filterToOne(hasAnyAncestor(isDialog()))
.assertIsDisplayed()
composeTestRule
.onAllNodesWithText(
text = "This action is protected, to continue please re-enter your master " +
"password to verify your identity.",
)
.filterToOne(hasAnyAncestor(isDialog()))
.assertIsDisplayed()
composeTestRule
.onAllNodesWithText(text = "Master password")
.filterToOne(hasAnyAncestor(isDialog()))
.assertIsDisplayed()
composeTestRule
.onAllNodesWithText(text = "Cancel")
.filterToOne(hasAnyAncestor(isDialog()))
.assertIsDisplayed()
.performClick()
verify(exactly = 0) {
viewModel.trySendAction(
action = VaultAction.MasterPasswordRepromptSubmit(
item = vaultItem,
password = "",
),
)
}
}
@Suppress("MaxLineLength")
@Test
fun `clicking a favorite item with password prompt should prompt for password before sending VaultItemClick upon Submit`() {
val itemText = "Test Item"
val userName = "Bitwarden"
val password = "password1234"
val vaultItem = VaultState.ViewState.VaultItem.Login(
id = "12345",
name = itemText.asText(),
username = userName.asText(),
overflowOptions = emptyList(),
shouldShowMasterPasswordReprompt = true,
)
mutableStateFlow.update {
it.copy(
viewState = DEFAULT_CONTENT_VIEW_STATE.copy(
favoriteItems = listOf(vaultItem),
),
)
}
composeTestRule.onNode(hasScrollToNodeAction()).performScrollToNode(hasText(itemText))
composeTestRule
.onNodeWithText(text = itemText)
.assertTextEquals(itemText, userName)
.performClick()
composeTestRule
.onAllNodesWithText(text = "Master password confirmation")
.filterToOne(hasAnyAncestor(isDialog()))
.assertIsDisplayed()
composeTestRule
.onAllNodesWithText(
text = "This action is protected, to continue please re-enter your master " +
"password to verify your identity.",
)
.filterToOne(hasAnyAncestor(isDialog()))
.assertIsDisplayed()
composeTestRule
.onAllNodesWithText(text = "Master password")
.filterToOne(hasAnyAncestor(isDialog()))
.assertIsDisplayed()
.performTextInput(text = password)
composeTestRule
.onAllNodesWithText(text = "Submit")
.filterToOne(hasAnyAncestor(isDialog()))
.assertIsDisplayed()
.performClick()
verify(exactly = 1) {
viewModel.trySendAction(
action = VaultAction.MasterPasswordRepromptSubmit(
item = vaultItem,
password = password,
),
)
}
}
@Suppress("MaxLineLength")
@Test
fun `clicking a favorite item overflow with password prompt should prompt for password before dismissing upon Cancel`() {
val itemText = "Test Item"
val userName = "Bitwarden"
val cipherId = "12345"
val vaultItem = VaultState.ViewState.VaultItem.Login(
id = cipherId,
name = itemText.asText(),
username = userName.asText(),
overflowOptions = persistentListOf(
ListingItemOverflowAction.VaultAction.ViewClick(
cipherId = cipherId,
cipherType = CipherType.LOGIN,
requiresPasswordReprompt = true,
),
),
shouldShowMasterPasswordReprompt = true,
)
mutableStateFlow.update {
it.copy(
viewState = DEFAULT_CONTENT_VIEW_STATE.copy(
favoriteItems = listOf(vaultItem),
),
)
}
composeTestRule.onNode(hasScrollToNodeAction()).performScrollToNode(hasText(itemText))
composeTestRule
.onNodeWithText(text = itemText)
.onChildren()
.filterToOne(hasContentDescription(value = "Options"))
.performClick()
composeTestRule
.onNodeWithText(text = "View")
.assert(hasAnyAncestor(isDialog()))
.performClick()
composeTestRule
.onAllNodesWithText(text = "Master password confirmation")
.filterToOne(hasAnyAncestor(isDialog()))
.assertIsDisplayed()
composeTestRule
.onAllNodesWithText(
text = "This action is protected, to continue please re-enter your master " +
"password to verify your identity.",
)
.filterToOne(hasAnyAncestor(isDialog()))
.assertIsDisplayed()
composeTestRule
.onAllNodesWithText(text = "Master password")
.filterToOne(hasAnyAncestor(isDialog()))
.assertIsDisplayed()
composeTestRule
.onAllNodesWithText(text = "Cancel")
.filterToOne(hasAnyAncestor(isDialog()))
.assertIsDisplayed()
.performClick()
verify(exactly = 0) {
viewModel.trySendAction(
action = VaultAction.MasterPasswordRepromptSubmit(
item = vaultItem,
password = "",
),
)
}
}
@Suppress("MaxLineLength")
@Test
fun `clicking a favorite item overflow with password prompt should prompt for password before sending VaultItemClick upon Submit`() {
val itemText = "Test Item"
val userName = "Bitwarden"
val password = "password1234"
val cipherId = "12345"
val overflowAction = ListingItemOverflowAction.VaultAction.ViewClick(
cipherId = cipherId,
cipherType = CipherType.LOGIN,
requiresPasswordReprompt = true,
)
val vaultItem = VaultState.ViewState.VaultItem.Login(
id = cipherId,
name = itemText.asText(),
username = userName.asText(),
overflowOptions = persistentListOf(overflowAction),
shouldShowMasterPasswordReprompt = true,
)
mutableStateFlow.update {
it.copy(
viewState = DEFAULT_CONTENT_VIEW_STATE.copy(
favoriteItems = persistentListOf(vaultItem),
),
)
}
composeTestRule.onNode(hasScrollToNodeAction()).performScrollToNode(hasText(itemText))
composeTestRule
.onNodeWithText(text = itemText)
.onChildren()
.filterToOne(hasContentDescription(value = "Options"))
.performClick()
composeTestRule
.onNodeWithText(text = "View")
.assert(hasAnyAncestor(isDialog()))
.performClick()
composeTestRule
.onAllNodesWithText(text = "Master password confirmation")
.filterToOne(hasAnyAncestor(isDialog()))
.assertIsDisplayed()
composeTestRule
.onAllNodesWithText(
text = "This action is protected, to continue please re-enter your master " +
"password to verify your identity.",
)
.filterToOne(hasAnyAncestor(isDialog()))
.assertIsDisplayed()
composeTestRule
.onAllNodesWithText(text = "Master password")
.filterToOne(hasAnyAncestor(isDialog()))
.assertIsDisplayed()
.performTextInput(text = password)
composeTestRule
.onAllNodesWithText(text = "Submit")
.filterToOne(hasAnyAncestor(isDialog()))
.assertIsDisplayed()
.performClick()
verify(exactly = 1) {
viewModel.trySendAction(
action = VaultAction.OverflowMasterPasswordRepromptSubmit(
overflowAction = overflowAction,
password = password,
),
)
}
}
@Test
fun `clicking a folder item should send FolderClick with the correct item`() {
val folderText = "Test Folder"
@@ -973,7 +1235,7 @@ class VaultScreenTest : BitwardenComposeTest() {
}
@Test
fun `clicking a no folder item should send VaultItemClick with the correct item`() {
fun `clicking a no folder item without password prompt should send VaultItemClick`() {
val itemText = "Test Item"
val userName = "BitWarden"
val vaultItem = VaultState.ViewState.VaultItem.Login(
@@ -1001,6 +1263,262 @@ class VaultScreenTest : BitwardenComposeTest() {
}
}
@Suppress("MaxLineLength")
@Test
fun `clicking a no folder overflow item with password prompt should prompt for password before dismissing upon Cancel`() {
val itemText = "Test Item"
val userName = "Bitwarden"
val cipherId = "12345"
val overflowAction = ListingItemOverflowAction.VaultAction.ViewClick(
cipherId = cipherId,
cipherType = CipherType.LOGIN,
requiresPasswordReprompt = true,
)
val vaultItem = VaultState.ViewState.VaultItem.Login(
id = cipherId,
name = itemText.asText(),
username = userName.asText(),
overflowOptions = persistentListOf(overflowAction),
shouldShowMasterPasswordReprompt = true,
)
mutableStateFlow.update {
it.copy(
viewState = DEFAULT_CONTENT_VIEW_STATE.copy(
noFolderItems = listOf(vaultItem),
),
)
}
composeTestRule.onNode(hasScrollToNodeAction()).performScrollToNode(hasText(itemText))
composeTestRule
.onNodeWithText(text = itemText)
.onChildren()
.filterToOne(hasContentDescription(value = "Options"))
.performClick()
composeTestRule
.onNodeWithText(text = "View")
.assert(hasAnyAncestor(isDialog()))
.performClick()
composeTestRule
.onAllNodesWithText(text = "Master password confirmation")
.filterToOne(hasAnyAncestor(isDialog()))
.assertIsDisplayed()
composeTestRule
.onAllNodesWithText(
text = "This action is protected, to continue please re-enter your master " +
"password to verify your identity.",
)
.filterToOne(hasAnyAncestor(isDialog()))
.assertIsDisplayed()
composeTestRule
.onAllNodesWithText(text = "Master password")
.filterToOne(hasAnyAncestor(isDialog()))
.assertIsDisplayed()
composeTestRule
.onAllNodesWithText(text = "Cancel")
.filterToOne(hasAnyAncestor(isDialog()))
.assertIsDisplayed()
.performClick()
verify(exactly = 0) {
viewModel.trySendAction(
action = VaultAction.OverflowMasterPasswordRepromptSubmit(
overflowAction = overflowAction,
password = "",
),
)
}
}
@Suppress("MaxLineLength")
@Test
fun `clicking a no folder overflow item with password prompt should prompt for password before sending VaultItemClick upon Submit`() {
val itemText = "Test Item"
val userName = "Bitwarden"
val password = "password1234"
val cipherId = "12345"
val overflowAction = ListingItemOverflowAction.VaultAction.ViewClick(
cipherId = cipherId,
cipherType = CipherType.LOGIN,
requiresPasswordReprompt = true,
)
val vaultItem = VaultState.ViewState.VaultItem.Login(
id = cipherId,
name = itemText.asText(),
username = userName.asText(),
overflowOptions = persistentListOf(overflowAction),
shouldShowMasterPasswordReprompt = true,
)
mutableStateFlow.update {
it.copy(
viewState = DEFAULT_CONTENT_VIEW_STATE.copy(
noFolderItems = listOf(vaultItem),
),
)
}
composeTestRule.onNode(hasScrollToNodeAction()).performScrollToNode(hasText(itemText))
composeTestRule
.onNodeWithText(text = itemText)
.onChildren()
.filterToOne(hasContentDescription(value = "Options"))
.performClick()
composeTestRule
.onNodeWithText(text = "View")
.assert(hasAnyAncestor(isDialog()))
.performClick()
composeTestRule
.onAllNodesWithText(text = "Master password confirmation")
.filterToOne(hasAnyAncestor(isDialog()))
.assertIsDisplayed()
composeTestRule
.onAllNodesWithText(
text = "This action is protected, to continue please re-enter your master " +
"password to verify your identity.",
)
.filterToOne(hasAnyAncestor(isDialog()))
.assertIsDisplayed()
composeTestRule
.onAllNodesWithText(text = "Master password")
.filterToOne(hasAnyAncestor(isDialog()))
.assertIsDisplayed()
.performTextInput(text = password)
composeTestRule
.onAllNodesWithText(text = "Submit")
.filterToOne(hasAnyAncestor(isDialog()))
.assertIsDisplayed()
.performClick()
verify(exactly = 1) {
viewModel.trySendAction(
action = VaultAction.OverflowMasterPasswordRepromptSubmit(
overflowAction = overflowAction,
password = password,
),
)
}
}
@Suppress("MaxLineLength")
@Test
fun `clicking a no folder item with password prompt should prompt for password before dismissing upon Cancel`() {
val itemText = "Test Item"
val userName = "Bitwarden"
val vaultItem = VaultState.ViewState.VaultItem.Login(
id = "12345",
name = itemText.asText(),
username = userName.asText(),
overflowOptions = emptyList(),
shouldShowMasterPasswordReprompt = true,
)
mutableStateFlow.update {
it.copy(
viewState = DEFAULT_CONTENT_VIEW_STATE.copy(
noFolderItems = listOf(vaultItem),
),
)
}
composeTestRule.onNode(hasScrollToNodeAction()).performScrollToNode(hasText(itemText))
composeTestRule
.onNodeWithText(text = itemText)
.assertTextEquals(itemText, userName)
.performClick()
composeTestRule
.onAllNodesWithText(text = "Master password confirmation")
.filterToOne(hasAnyAncestor(isDialog()))
.assertIsDisplayed()
composeTestRule
.onAllNodesWithText(
text = "This action is protected, to continue please re-enter your master " +
"password to verify your identity.",
)
.filterToOne(hasAnyAncestor(isDialog()))
.assertIsDisplayed()
composeTestRule
.onAllNodesWithText(text = "Master password")
.filterToOne(hasAnyAncestor(isDialog()))
.assertIsDisplayed()
composeTestRule
.onAllNodesWithText(text = "Cancel")
.filterToOne(hasAnyAncestor(isDialog()))
.assertIsDisplayed()
.performClick()
verify(exactly = 0) {
viewModel.trySendAction(
action = VaultAction.MasterPasswordRepromptSubmit(
item = vaultItem,
password = "",
),
)
}
}
@Suppress("MaxLineLength")
@Test
fun `clicking a no folder item with password prompt should prompt for password before sending VaultItemClick upon Submit`() {
val itemText = "Test Item"
val userName = "Bitwarden"
val password = "password1234"
val vaultItem = VaultState.ViewState.VaultItem.Login(
id = "12345",
name = itemText.asText(),
username = userName.asText(),
overflowOptions = emptyList(),
shouldShowMasterPasswordReprompt = true,
)
mutableStateFlow.update {
it.copy(
viewState = DEFAULT_CONTENT_VIEW_STATE.copy(
noFolderItems = listOf(vaultItem),
),
)
}
composeTestRule.onNode(hasScrollToNodeAction()).performScrollToNode(hasText(itemText))
composeTestRule
.onNodeWithText(text = itemText)
.assertTextEquals(itemText, userName)
.performClick()
composeTestRule
.onAllNodesWithText(text = "Master password confirmation")
.filterToOne(hasAnyAncestor(isDialog()))
.assertIsDisplayed()
composeTestRule
.onAllNodesWithText(
text = "This action is protected, to continue please re-enter your master " +
"password to verify your identity.",
)
.filterToOne(hasAnyAncestor(isDialog()))
.assertIsDisplayed()
composeTestRule
.onAllNodesWithText(text = "Master password")
.filterToOne(hasAnyAncestor(isDialog()))
.assertIsDisplayed()
.performTextInput(text = password)
composeTestRule
.onAllNodesWithText(text = "Submit")
.filterToOne(hasAnyAncestor(isDialog()))
.assertIsDisplayed()
.performClick()
verify(exactly = 1) {
viewModel.trySendAction(
action = VaultAction.MasterPasswordRepromptSubmit(
item = vaultItem,
password = password,
),
)
}
}
@Test
fun `login item count should update according to state`() {
val rowText = "Login"
@@ -60,6 +60,7 @@ import io.mockk.mockkStatic
import io.mockk.runs
import io.mockk.unmockkStatic
import io.mockk.verify
import kotlinx.collections.immutable.persistentListOf
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.filterNotNull
@@ -1683,24 +1684,143 @@ class VaultViewModelTest : BaseViewModelTest() {
}
@Test
fun `OverflowOptionClick Vault ViewClick should emit NavigateToUrl`() = runTest {
val cipherId = "cipherId-9876"
val viewModel = createViewModel()
viewModel.eventFlow.test {
viewModel.trySendAction(
VaultAction.OverflowOptionClick(
ListingItemOverflowAction.VaultAction.ViewClick(
cipherId = cipherId,
cipherType = CipherType.LOGIN,
fun `OverflowOptionClick Vault ViewClick without reprompt should emit NavigateToUrl`() =
runTest {
val cipherId = "cipherId-9876"
val viewModel = createViewModel()
viewModel.eventFlow.test {
viewModel.trySendAction(
VaultAction.OverflowOptionClick(
ListingItemOverflowAction.VaultAction.ViewClick(
cipherId = cipherId,
cipherType = CipherType.LOGIN,
requiresPasswordReprompt = false,
),
),
)
assertEquals(
VaultEvent.NavigateToVaultItem(
itemId = cipherId,
type = VaultItemCipherType.LOGIN,
),
awaitItem(),
)
}
}
@Suppress("MaxLineLength")
@Test
fun `OverflowMasterPasswordRepromptSubmit for a request Error should show a generic error dialog`() =
runTest {
val password = "password"
val error = Throwable("Fail!")
coEvery {
authRepository.validatePassword(password = password)
} returns ValidatePasswordResult.Error(error = error)
val viewModel = createViewModel()
viewModel.stateFlow.test {
assertEquals(
DEFAULT_STATE,
awaitItem(),
)
viewModel.trySendAction(
VaultAction.OverflowMasterPasswordRepromptSubmit(
overflowAction = ListingItemOverflowAction.VaultAction.CopyPasswordClick(
password = password,
requiresPasswordReprompt = true,
cipherId = "cipherId",
),
password = password,
),
)
assertEquals(
DEFAULT_STATE.copy(
dialog = VaultState.DialogState.Error(
title = R.string.an_error_has_occurred.asText(),
message = R.string.generic_error_message.asText(),
error = error,
),
),
awaitItem(),
)
}
}
@Suppress("MaxLineLength")
@Test
fun `OverflowMasterPasswordRepromptSubmit for a request Success with an invalid password should show an invalid password dialog`() =
runTest {
val password = "password"
coEvery {
authRepository.validatePassword(password = password)
} returns ValidatePasswordResult.Success(isValid = false)
val viewModel = createViewModel()
viewModel.stateFlow.test {
assertEquals(
DEFAULT_STATE,
awaitItem(),
)
viewModel.trySendAction(
VaultAction.OverflowMasterPasswordRepromptSubmit(
overflowAction = ListingItemOverflowAction.VaultAction.CopyPasswordClick(
password = password,
requiresPasswordReprompt = true,
cipherId = "cipherId",
),
password = password,
),
)
assertEquals(
DEFAULT_STATE.copy(
dialog = VaultState.DialogState.Error(
title = R.string.an_error_has_occurred.asText(),
message = R.string.invalid_master_password.asText(),
),
),
awaitItem(),
)
}
}
@Suppress("MaxLineLength")
@Test
fun `OverflowMasterPasswordRepromptSubmit for a request Success with a valid password should continue the action`() =
runTest {
val password = "password"
val cipherId = "cipherId"
coEvery {
authRepository.validatePassword(password = password)
} returns ValidatePasswordResult.Success(isValid = true)
val viewModel = createViewModel()
viewModel.trySendAction(
VaultAction.OverflowMasterPasswordRepromptSubmit(
overflowAction = ListingItemOverflowAction.VaultAction.CopyPasswordClick(
password = password,
requiresPasswordReprompt = true,
cipherId = cipherId,
),
password = password,
),
)
assertEquals(
VaultEvent.NavigateToVaultItem(itemId = cipherId, type = VaultItemCipherType.LOGIN),
awaitItem(),
)
verify(exactly = 1) {
clipboardManager.setText(
text = password,
toastDescriptorOverride = R.string.password.asText(),
)
organizationEventManager.trackEvent(
event = OrganizationEvent.CipherClientCopiedPassword(cipherId = cipherId),
)
}
}
}
@Test
fun `MasterPasswordRepromptSubmit for a request Error should show a generic error dialog`() =
@@ -1720,10 +1840,12 @@ class VaultViewModelTest : BaseViewModelTest() {
viewModel.trySendAction(
VaultAction.MasterPasswordRepromptSubmit(
overflowAction = ListingItemOverflowAction.VaultAction.CopyPasswordClick(
password = password,
requiresPasswordReprompt = true,
cipherId = "cipherId",
item = VaultState.ViewState.VaultItem.Login(
id = "cipherId",
name = "name".asText(),
shouldShowMasterPasswordReprompt = true,
username = null,
overflowOptions = persistentListOf(),
),
password = password,
),
@@ -1760,10 +1882,11 @@ class VaultViewModelTest : BaseViewModelTest() {
viewModel.trySendAction(
VaultAction.MasterPasswordRepromptSubmit(
overflowAction = ListingItemOverflowAction.VaultAction.CopyPasswordClick(
password = password,
requiresPasswordReprompt = true,
cipherId = "cipherId",
item = VaultState.ViewState.VaultItem.Card(
id = "cipherId",
name = "name".asText(),
shouldShowMasterPasswordReprompt = true,
overflowOptions = persistentListOf(),
),
password = password,
),
@@ -1786,31 +1909,29 @@ class VaultViewModelTest : BaseViewModelTest() {
fun `MasterPasswordRepromptSubmit for a request Success with a valid password should continue the action`() =
runTest {
val password = "password"
val cipherId = "cipherId"
val item = VaultState.ViewState.VaultItem.Identity(
id = "cipherId",
name = "name".asText(),
shouldShowMasterPasswordReprompt = true,
fullName = null,
overflowOptions = persistentListOf(),
)
coEvery {
authRepository.validatePassword(password = password)
} returns ValidatePasswordResult.Success(isValid = true)
val viewModel = createViewModel()
viewModel.trySendAction(
VaultAction.MasterPasswordRepromptSubmit(
overflowAction = ListingItemOverflowAction.VaultAction.CopyPasswordClick(
viewModel.eventFlow.test {
viewModel.trySendAction(
VaultAction.MasterPasswordRepromptSubmit(
item = item,
password = password,
requiresPasswordReprompt = true,
cipherId = cipherId,
),
password = password,
),
)
verify(exactly = 1) {
clipboardManager.setText(
text = password,
toastDescriptorOverride = R.string.password.asText(),
)
organizationEventManager.trackEvent(
event = OrganizationEvent.CipherClientCopiedPassword(cipherId = cipherId),
assertEquals(
VaultEvent.NavigateToVaultItem(itemId = item.id, type = item.type),
awaitItem(),
)
}
}
@@ -994,6 +994,7 @@ private fun createMockSshKeyVaultItem(number: Int): VaultState.ViewState.VaultIt
ListingItemOverflowAction.VaultAction.ViewClick(
cipherId = "mockId-$number",
cipherType = CipherType.SSH_KEY,
requiresPasswordReprompt = true,
),
ListingItemOverflowAction.VaultAction.EditClick(
cipherId = "mockId-$number",
@@ -13,6 +13,7 @@ import androidx.compose.ui.test.onNodeWithContentDescription
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import androidx.compose.ui.test.performScrollTo
import androidx.compose.ui.test.performTextInput
import com.bitwarden.core.data.repository.util.bufferedMutableSharedFlow
import com.bitwarden.data.repository.model.Environment
import com.bitwarden.data.repository.util.baseIconUrl
@@ -263,12 +264,12 @@ class VerificationCodeScreenTest : BitwardenComposeTest() {
}
@Test
fun `clicking on a display item should send ItemClick action`() {
fun `clicking on a display item without hideAuthCode should send ItemClick action`() {
mutableStateFlow.update {
it.copy(
viewState = VerificationCodeState.ViewState.Content(
verificationCodeDisplayItems = listOf(
createDisplayItem(number = 1),
createDisplayItem(number = 1, hideAuthCode = false),
),
),
)
@@ -284,6 +285,104 @@ class VerificationCodeScreenTest : BitwardenComposeTest() {
}
}
@Suppress("MaxLineLength")
@Test
fun `clicking on a display item with hideAuthCode should prompt for password before dismissing the dialog upon cancel`() {
composeTestRule.assertNoDialogExists()
mutableStateFlow.update {
it.copy(
viewState = VerificationCodeState.ViewState.Content(
verificationCodeDisplayItems = listOf(
createDisplayItem(number = 1, hideAuthCode = true),
),
),
)
}
composeTestRule
.onNodeWithText(text = "Label 1")
.assertIsDisplayed()
.performClick()
composeTestRule
.onAllNodesWithText(text = "Master password confirmation")
.filterToOne(hasAnyAncestor(isDialog()))
.assertIsDisplayed()
composeTestRule
.onAllNodesWithText(
text = "This action is protected, to continue please re-enter your master " +
"password to verify your identity.",
)
.filterToOne(hasAnyAncestor(isDialog()))
.assertIsDisplayed()
composeTestRule
.onAllNodesWithText(text = "Master password")
.filterToOne(hasAnyAncestor(isDialog()))
.assertIsDisplayed()
composeTestRule
.onAllNodesWithText(text = "Cancel")
.filterToOne(hasAnyAncestor(isDialog()))
.assertIsDisplayed()
.performClick()
verify(exactly = 0) {
viewModel.trySendAction(action = any())
}
composeTestRule.assertNoDialogExists()
}
@Suppress("MaxLineLength")
@Test
fun `clicking on a display item with hideAuthCode should prompt for password before sending MasterPasswordSubmit upon submit`() {
composeTestRule.assertNoDialogExists()
mutableStateFlow.update {
it.copy(
viewState = VerificationCodeState.ViewState.Content(
verificationCodeDisplayItems = listOf(
createDisplayItem(number = 1, hideAuthCode = true),
),
),
)
}
composeTestRule
.onNodeWithText(text = "Label 1")
.assertIsDisplayed()
.performClick()
composeTestRule
.onAllNodesWithText(text = "Master password confirmation")
.filterToOne(hasAnyAncestor(isDialog()))
.assertIsDisplayed()
composeTestRule
.onAllNodesWithText(
text = "This action is protected, to continue please re-enter your master " +
"password to verify your identity.",
)
.filterToOne(hasAnyAncestor(isDialog()))
.assertIsDisplayed()
composeTestRule
.onAllNodesWithText(text = "Master password")
.filterToOne(hasAnyAncestor(isDialog()))
.assertIsDisplayed()
.performTextInput(text = "password1234")
composeTestRule
.onAllNodesWithText(text = "Submit")
.filterToOne(hasAnyAncestor(isDialog()))
.assertIsDisplayed()
.performClick()
verify {
viewModel.trySendAction(
action = VerificationCodeAction.MasterPasswordSubmit(
cipherId = "1",
password = "password1234",
),
)
}
composeTestRule.assertNoDialogExists()
}
@Test
fun `clicking on copy button should send CopyClick action`() {
mutableStateFlow.update {
@@ -374,6 +473,27 @@ class VerificationCodeScreenTest : BitwardenComposeTest() {
.assertIsDisplayed()
.assert(hasAnyAncestor(isDialog()))
}
@Test
fun `error dialog should be displayed according to state`() {
val errorMessage = "error"
composeTestRule.assertNoDialogExists()
composeTestRule.onNodeWithText(text = errorMessage).assertDoesNotExist()
mutableStateFlow.update {
it.copy(
dialogState = VerificationCodeState.DialogState.Error(
title = null,
message = errorMessage.asText(),
),
)
}
composeTestRule
.onNodeWithText(text = errorMessage)
.assertIsDisplayed()
.assert(hasAnyAncestor(isDialog()))
}
}
private fun createDisplayItem(
@@ -1,6 +1,7 @@
package com.x8bit.bitwarden.ui.vault.feature.verificationcode
import android.net.Uri
import androidx.lifecycle.SavedStateHandle
import app.cash.turbine.test
import com.bitwarden.core.data.repository.model.DataState
import com.bitwarden.data.repository.model.Environment
@@ -13,6 +14,7 @@ import com.bitwarden.vault.CipherRepromptType
import com.x8bit.bitwarden.R
import com.x8bit.bitwarden.data.auth.repository.AuthRepository
import com.x8bit.bitwarden.data.auth.repository.model.UserState
import com.x8bit.bitwarden.data.auth.repository.model.ValidatePasswordResult
import com.x8bit.bitwarden.data.platform.manager.clipboard.BitwardenClipboardManager
import com.x8bit.bitwarden.data.platform.repository.EnvironmentRepository
import com.x8bit.bitwarden.data.platform.repository.SettingsRepository
@@ -22,6 +24,8 @@ import com.x8bit.bitwarden.data.vault.repository.VaultRepository
import com.x8bit.bitwarden.ui.vault.feature.vault.model.VaultFilterType
import com.x8bit.bitwarden.ui.vault.feature.vault.util.toLoginIconData
import com.x8bit.bitwarden.ui.vault.feature.verificationcode.util.createVerificationCodeItem
import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.every
import io.mockk.just
import io.mockk.mockk
@@ -129,6 +133,86 @@ class VerificationCodeViewModelTest : BaseViewModelTest() {
}
}
@Test
fun `MasterPasswordSubmit should validate the password and display an error on failure`() {
val viewModel = createViewModel()
val password = "password_1234"
val throwable = Throwable("Error!")
coEvery {
authRepository.validatePassword(password = password)
} returns ValidatePasswordResult.Error(error = throwable)
viewModel.trySendAction(
action = VerificationCodeAction.MasterPasswordSubmit(
cipherId = "cipher_id",
password = password,
),
)
assertEquals(
viewModel.stateFlow.value,
initialState.copy(
dialogState = VerificationCodeState.DialogState.Error(
title = null,
message = R.string.generic_error_message.asText(),
throwable = throwable,
),
),
)
coVerify(exactly = 1) {
authRepository.validatePassword(password = password)
}
}
@Suppress("MaxLineLength")
@Test
fun `MasterPasswordSubmit should validate the password and display an error on invalid password`() {
val viewModel = createViewModel()
val password = "password_1234"
coEvery {
authRepository.validatePassword(password = password)
} returns ValidatePasswordResult.Success(isValid = false)
viewModel.trySendAction(
action = VerificationCodeAction.MasterPasswordSubmit(
cipherId = "cipher_id",
password = password,
),
)
assertEquals(
viewModel.stateFlow.value,
initialState.copy(
dialogState = VerificationCodeState.DialogState.Error(
title = null,
message = R.string.invalid_master_password.asText(),
),
),
)
coVerify(exactly = 1) {
authRepository.validatePassword(password = password)
}
}
@Test
fun `MasterPasswordSubmit should validate the password and emit NavigateToVaultItem`() =
runTest {
val viewModel = createViewModel()
val password = "password_1234"
val cipherId = "cipher_id"
coEvery {
authRepository.validatePassword(password = password)
} returns ValidatePasswordResult.Success(isValid = true)
viewModel.eventFlow.test {
viewModel.trySendAction(
action = VerificationCodeAction.MasterPasswordSubmit(
cipherId = cipherId,
password = password,
),
)
assertEquals(VerificationCodeEvent.NavigateToVaultItem(id = cipherId), awaitItem())
}
coVerify(exactly = 1) {
authRepository.validatePassword(password = password)
}
}
@Test
fun `LockClick should call lockVaultForCurrentUser`() {
every { vaultRepository.lockVaultForCurrentUser(any()) } just runs
@@ -481,6 +565,22 @@ class VerificationCodeViewModelTest : BaseViewModelTest() {
)
}
@Test
fun `DismissDialog should clear the dialog state`() = runTest {
val defaultState = initialState.copy(
dialogState = VerificationCodeState.DialogState.Error(
title = null,
message = "Test".asText(),
),
)
val viewModel = createViewModel(state = defaultState)
viewModel.stateFlow.test {
assertEquals(defaultState, awaitItem())
viewModel.trySendAction(VerificationCodeAction.DismissDialog)
assertEquals(initialState, awaitItem())
}
}
private fun setupMockUri() {
mockkStatic(Uri::class)
val uriMock = mockk<Uri>()
@@ -488,8 +588,13 @@ class VerificationCodeViewModelTest : BaseViewModelTest() {
every { uriMock.host } returns "www.mockuri.com"
}
private fun createViewModel(): VerificationCodeViewModel =
private fun createViewModel(
state: VerificationCodeState? = null,
): VerificationCodeViewModel =
VerificationCodeViewModel(
savedStateHandle = SavedStateHandle().apply {
set(key = "state", value = state)
},
clipboardManager = clipboardManager,
vaultRepository = vaultRepository,
environmentRepository = environmentRepository,