[PM-31614] feat: Added new UI for the Email verification on sends (#6488)

This commit is contained in:
aj-rosado
2026-02-13 22:19:09 +00:00
committed by GitHub
parent b20622e7d6
commit ce3f0acf74
14 changed files with 1257 additions and 43 deletions
@@ -52,6 +52,7 @@ import com.bitwarden.ui.platform.resource.BitwardenDrawable
import com.bitwarden.ui.platform.resource.BitwardenString
import com.bitwarden.ui.platform.theme.BitwardenTheme
import com.x8bit.bitwarden.ui.platform.manager.permissions.PermissionsManager
import com.x8bit.bitwarden.ui.tools.feature.send.addedit.components.AddEditSendAuthTypeChooser
import com.x8bit.bitwarden.ui.tools.feature.send.addedit.components.AddEditSendCustomDateChooser
import com.x8bit.bitwarden.ui.tools.feature.send.addedit.components.AddEditSendDeletionDateChooser
import com.x8bit.bitwarden.ui.tools.feature.send.addedit.handlers.AddEditSendHandlers
@@ -160,6 +161,24 @@ fun AddEditSendContent(
)
}
if (state.common.isSendEmailVerificationEnabled) {
Spacer(modifier = Modifier.height(height = 8.dp))
AddEditSendAuthTypeChooser(
sendAuth = state.common.sendAuth,
onAuthTypeSelect = addSendHandlers.onAuthTypeSelect,
onPasswordChange = addSendHandlers.onAuthPasswordChange,
onEmailValueChange = addSendHandlers.onEmailValueChange,
onRemoveEmailClick = addSendHandlers.onEmailsRemoveClick,
onAddNewEmailClick = addSendHandlers.onAddNewEmailClick,
password = state.common.passwordInput,
isEnabled = !policyDisablesSend,
modifier = Modifier
.testTag("SendAuthTypeChooser")
.fillMaxWidth()
.standardHorizontalMargin(),
)
}
AddEditSendOptions(
state = state,
sendRestrictionPolicy = policyDisablesSend,
@@ -427,40 +446,43 @@ private fun AddEditSendOptions(
.fillMaxWidth()
.standardHorizontalMargin(),
)
Spacer(modifier = Modifier.height(8.dp))
BitwardenPasswordField(
label = stringResource(id = BitwardenString.new_password),
supportingText = stringResource(id = BitwardenString.password_info),
readOnly = sendRestrictionPolicy,
value = state.common.passwordInput,
onValueChange = addSendHandlers.onPasswordChange,
passwordFieldTestTag = "SendNewPasswordEntry",
cardStyle = CardStyle.Full,
modifier = Modifier
.fillMaxWidth()
.standardHorizontalMargin(),
) {
BitwardenStandardIconButton(
vectorIconRes = BitwardenDrawable.ic_generate,
contentDescription = stringResource(id = BitwardenString.generate_password),
onClick = {
if (state.common.passwordInput.isEmpty()) {
addSendHandlers.onOpenPasswordGeneratorClick()
} else {
shouldShowDialog = true
}
},
modifier = Modifier.testTag(tag = "RegeneratePasswordButton"),
)
BitwardenStandardIconButton(
vectorIconRes = BitwardenDrawable.ic_copy,
contentDescription = stringResource(id = BitwardenString.copy_password),
isEnabled = state.common.passwordInput.isNotEmpty(),
onClick = {
addSendHandlers.onPasswordCopyClick(state.common.passwordInput)
},
modifier = Modifier.testTag(tag = "CopyPasswordButton"),
)
if (!state.common.isSendEmailVerificationEnabled) {
Spacer(modifier = Modifier.height(8.dp))
BitwardenPasswordField(
label = stringResource(id = BitwardenString.new_password),
supportingText = stringResource(id = BitwardenString.password_info),
readOnly = sendRestrictionPolicy,
value = state.common.passwordInput,
onValueChange = addSendHandlers.onPasswordChange,
passwordFieldTestTag = "SendNewPasswordEntry",
cardStyle = CardStyle.Full,
modifier = Modifier
.fillMaxWidth()
.standardHorizontalMargin(),
) {
BitwardenStandardIconButton(
vectorIconRes = BitwardenDrawable.ic_generate,
contentDescription = stringResource(id = BitwardenString.generate_password),
onClick = {
if (state.common.passwordInput.isEmpty()) {
addSendHandlers.onOpenPasswordGeneratorClick()
} else {
shouldShowDialog = true
}
},
modifier = Modifier.testTag(tag = "RegeneratePasswordButton"),
)
BitwardenStandardIconButton(
vectorIconRes = BitwardenDrawable.ic_copy,
contentDescription = stringResource(id = BitwardenString.copy_password),
isEnabled = state.common.passwordInput.isNotEmpty(),
onClick = {
addSendHandlers.onPasswordCopyClick(state.common.passwordInput)
},
modifier = Modifier.testTag(tag = "CopyPasswordButton"),
)
}
}
if (shouldShowDialog) {
BitwardenTwoButtonDialog(
@@ -4,6 +4,7 @@ import android.net.Uri
import android.os.Parcelable
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.viewModelScope
import com.bitwarden.core.data.manager.model.FlagKey
import com.bitwarden.core.data.repository.model.DataState
import com.bitwarden.core.data.repository.util.takeUntilLoaded
import com.bitwarden.data.repository.util.baseWebSendUrl
@@ -20,6 +21,7 @@ import com.bitwarden.ui.util.asText
import com.bitwarden.ui.util.concat
import com.x8bit.bitwarden.data.auth.repository.AuthRepository
import com.x8bit.bitwarden.data.auth.repository.model.PolicyInformation
import com.x8bit.bitwarden.data.platform.manager.FeatureFlagManager
import com.x8bit.bitwarden.data.platform.manager.PolicyManager
import com.x8bit.bitwarden.data.platform.manager.SpecialCircumstanceManager
import com.x8bit.bitwarden.data.platform.manager.clipboard.BitwardenClipboardManager
@@ -36,6 +38,8 @@ import com.x8bit.bitwarden.data.vault.repository.model.UpdateSendResult
import com.x8bit.bitwarden.ui.platform.model.SnackbarRelay
import com.x8bit.bitwarden.ui.tools.feature.generator.model.GeneratorMode
import com.x8bit.bitwarden.ui.tools.feature.send.addedit.model.AddEditSendType
import com.x8bit.bitwarden.ui.tools.feature.send.addedit.model.AuthEmail
import com.x8bit.bitwarden.ui.tools.feature.send.addedit.model.SendAuth
import com.x8bit.bitwarden.ui.tools.feature.send.addedit.util.shouldFinishOnComplete
import com.x8bit.bitwarden.ui.tools.feature.send.addedit.util.toSendName
import com.x8bit.bitwarden.ui.tools.feature.send.addedit.util.toSendType
@@ -44,6 +48,8 @@ import com.x8bit.bitwarden.ui.tools.feature.send.addedit.util.toViewState
import com.x8bit.bitwarden.ui.tools.feature.send.model.SendItemType
import com.x8bit.bitwarden.ui.tools.feature.send.util.toSendUrl
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.map
@@ -80,6 +86,7 @@ class AddEditSendViewModel @Inject constructor(
private val policyManager: PolicyManager,
private val networkConnectionManager: NetworkConnectionManager,
private val snackbarRelayManager: SnackbarRelayManager<SnackbarRelay>,
private val featureFlagManager: FeatureFlagManager,
) : BaseViewModel<AddEditSendState, AddEditSendEvent, AddEditSendAction>(
// We load the state from the savedStateHandle for testing purposes.
initialState = savedStateHandle[KEY_STATE] ?: run {
@@ -89,6 +96,7 @@ class AddEditSendViewModel @Inject constructor(
val args = savedStateHandle.toAddEditSendArgs()
val sendType = args.sendType
val addEditSendType = args.addEditSendType
AddEditSendState(
sendType = sendType,
shouldFinishOnComplete = specialCircumstance.shouldFinishOnComplete(),
@@ -111,6 +119,9 @@ class AddEditSendViewModel @Inject constructor(
expirationDate = null,
sendUrl = null,
hasPassword = false,
isSendEmailVerificationEnabled = featureFlagManager
.getFeatureFlag(key = FlagKey.SendEmailVerification),
sendAuth = SendAuth.None,
),
selectedType = shareSendType ?: when (sendType) {
SendItemType.FILE -> {
@@ -194,6 +205,12 @@ class AddEditSendViewModel @Inject constructor(
is AddEditSendAction.PasswordCopyClick -> {
handleCopyClick(password = action.password)
}
is AddEditSendAction.AuthTypeSelect -> handleAuthTypeSelect(action)
is AddEditSendAction.AuthPasswordChange -> handleAuthPasswordChange(action)
is AddEditSendAction.AuthEmailChange -> handleAuthEmailsChange(action)
is AddEditSendAction.AuthEmailAdd -> handleAuthEmailsAdd()
is AddEditSendAction.AuthEmailRemove -> handleAuthEmailsRemove(action)
}
private fun handleInternalAction(action: AddEditSendAction.Internal): Unit = when (action) {
@@ -386,6 +403,7 @@ class AddEditSendViewModel @Inject constructor(
.environmentUrlData
.baseWebSendUrl,
isHideEmailAddressEnabled = isHideEmailAddressEnabled,
isSendEmailVerificationEnabled = isSendEmailVerificationEnabled,
)
?: AddEditSendState.ViewState.Error(
message = BitwardenString.generic_error_message.asText(),
@@ -427,6 +445,7 @@ class AddEditSendViewModel @Inject constructor(
.environmentUrlData
.baseWebSendUrl,
isHideEmailAddressEnabled = isHideEmailAddressEnabled,
isSendEmailVerificationEnabled = isSendEmailVerificationEnabled,
)
?: AddEditSendState.ViewState.Error(
message = BitwardenString.generic_error_message.asText(),
@@ -500,7 +519,14 @@ class AddEditSendViewModel @Inject constructor(
private fun handlePasswordChange(action: AddEditSendAction.PasswordChange) {
updateCommonContent {
it.copy(passwordInput = action.input)
it.copy(
passwordInput = action.input,
sendAuth = when {
action.input.isNotEmpty() -> SendAuth.Password
!isSendEmailVerificationEnabled -> SendAuth.None
else -> it.sendAuth
},
)
}
}
@@ -530,6 +556,82 @@ class AddEditSendViewModel @Inject constructor(
}
}
private fun handleAuthTypeSelect(action: AddEditSendAction.AuthTypeSelect) {
updateCommonContent { commonContent ->
commonContent.copy(
sendAuth = when (action.sendAuth) {
is SendAuth.Email -> {
// Preserve existing emails if switching back to EMAIL
when (val currentAuth = commonContent.sendAuth) {
is SendAuth.Email -> currentAuth
else -> action.sendAuth
}
}
else -> action.sendAuth
},
)
}
}
private fun handleAuthPasswordChange(action: AddEditSendAction.AuthPasswordChange) {
updateCommonContent {
it.copy(passwordInput = action.password)
}
}
private fun handleAuthEmailsChange(action: AddEditSendAction.AuthEmailChange) {
updateCommonContent { commonContent ->
val currentAuth = commonContent.sendAuth as? SendAuth.Email
?: return@updateCommonContent commonContent
val updatedEmails = currentAuth.emails.map { authEmail ->
if (authEmail.id == action.authEmail.id) {
action.authEmail
} else {
authEmail
}
}
commonContent.copy(
sendAuth = currentAuth.copy(emails = updatedEmails.toImmutableList()),
)
}
}
private fun handleAuthEmailsAdd() {
updateCommonContent { commonContent ->
val currentAuth = commonContent.sendAuth as? SendAuth.Email
?: return@updateCommonContent commonContent
commonContent.copy(
sendAuth = currentAuth.copy(
emails = currentAuth
.emails
.plus(AuthEmail(value = ""))
.toImmutableList(),
),
)
}
}
private fun handleAuthEmailsRemove(action: AddEditSendAction.AuthEmailRemove) {
updateCommonContent { commonContent ->
val currentAuth = commonContent.sendAuth as? SendAuth.Email
?: return@updateCommonContent commonContent
val updatedEmails = currentAuth.emails.filterNot { it.id == action.authEmail.id }
commonContent.copy(
sendAuth = currentAuth.copy(
emails = if (updatedEmails.isEmpty()) {
persistentListOf(AuthEmail(value = ""))
} else {
updatedEmails.toImmutableList()
},
),
)
}
}
@Suppress("LongMethod")
private fun handleSaveClick() {
onContent { content ->
@@ -685,6 +787,10 @@ class AddEditSendViewModel @Inject constructor(
.getActivePolicies<PolicyInformation.SendOptions>()
.any { it.shouldDisableHideEmail ?: false }
private val isSendEmailVerificationEnabled: Boolean
get() = featureFlagManager
.getFeatureFlag(key = FlagKey.SendEmailVerification)
private inline fun onContent(
crossinline block: (AddEditSendState.ViewState.Content) -> Unit,
) {
@@ -834,6 +940,8 @@ data class AddEditSendState(
val expirationDate: ZonedDateTime?,
val sendUrl: String?,
val hasPassword: Boolean,
val isSendEmailVerificationEnabled: Boolean,
val sendAuth: SendAuth,
) : Parcelable
/**
@@ -1043,6 +1151,31 @@ sealed class AddEditSendAction {
*/
data class DeletionDateChange(val deletionDate: ZonedDateTime) : AddEditSendAction()
/**
* The user selected an authentication type.
*/
data class AuthTypeSelect(val sendAuth: SendAuth) : AddEditSendAction()
/**
* The user changed the authentication password.
*/
data class AuthPasswordChange(val password: String) : AddEditSendAction()
/**
* The user changed the authentication email.
*/
data class AuthEmailChange(val authEmail: AuthEmail) : AddEditSendAction()
/**
* The user added a new authentication email field.
*/
data object AuthEmailAdd : AddEditSendAction()
/**
* The user removed an authentication email field.
*/
data class AuthEmailRemove(val authEmail: AuthEmail) : AddEditSendAction()
/**
* Models actions that the [AddEditSendViewModel] itself might send.
*/
@@ -0,0 +1,160 @@
package com.x8bit.bitwarden.ui.tools.feature.send.addedit.components
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.dp
import com.bitwarden.ui.platform.base.util.cardStyle
import com.bitwarden.ui.platform.components.button.BitwardenStandardIconButton
import com.bitwarden.ui.platform.components.dropdown.BitwardenMultiSelectButton
import com.bitwarden.ui.platform.components.field.BitwardenPasswordField
import com.bitwarden.ui.platform.components.field.BitwardenTextField
import com.bitwarden.ui.platform.components.model.CardStyle
import com.bitwarden.ui.platform.components.text.BitwardenClickableText
import com.bitwarden.ui.platform.resource.BitwardenDrawable
import com.bitwarden.ui.platform.resource.BitwardenString
import com.bitwarden.ui.platform.theme.BitwardenTheme
import com.x8bit.bitwarden.ui.tools.feature.send.addedit.model.AuthEmail
import com.x8bit.bitwarden.ui.tools.feature.send.addedit.model.SendAuth
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.toPersistentList
/**
* Displays UX for choosing authentication type for a send.
*
* @param sendAuth The current authentication configuration.
* @param onAuthTypeSelect Callback invoked when the authentication type is selected.
* @param onPasswordChange Callback invoked when the password value changes
* (only relevant for [SendAuth.Password]).
* @param onEmailValueChange Callback invoked when an email value changes
* (only relevant for [SendAuth.Email]).
* @param onAddNewEmailClick Callback invoked when adding a new email.
* @param onRemoveEmailClick Callback invoked when removing an email.
* @param password The current password value (only relevant for [SendAuth.Password]).
* @param isEnabled Whether the chooser is enabled.
* @param modifier Modifier for the composable.
*/
@Composable
fun AddEditSendAuthTypeChooser(
sendAuth: SendAuth,
onAuthTypeSelect: (SendAuth) -> Unit,
onPasswordChange: (String) -> Unit,
onEmailValueChange: (AuthEmail) -> Unit,
onAddNewEmailClick: () -> Unit,
onRemoveEmailClick: (AuthEmail) -> Unit,
password: String,
isEnabled: Boolean,
modifier: Modifier = Modifier,
) {
// Map option texts to their corresponding SendAuth factory functions
val textToNoneAuth = stringResource(id = BitwardenString.anyone_with_the_link)
val textToEmailAuth = stringResource(id = BitwardenString.specific_people)
val textToPasswordAuth = stringResource(id = BitwardenString.anyone_with_password)
val options = listOf(
textToNoneAuth,
textToEmailAuth,
textToPasswordAuth,
)
Column(modifier = modifier) {
BitwardenMultiSelectButton(
label = stringResource(id = BitwardenString.who_can_view),
isEnabled = isEnabled,
options = options.toPersistentList(),
selectedOption = sendAuth.text(),
onOptionSelected = { selected ->
val newAuth = when (selected) {
textToNoneAuth -> SendAuth.None
textToEmailAuth -> SendAuth.Email()
textToPasswordAuth -> SendAuth.Password
else -> SendAuth.None // fallback
}
onAuthTypeSelect(newAuth)
},
supportingText = sendAuth.supportingText?.let { it() },
insets = PaddingValues(top = 6.dp, bottom = 4.dp),
cardStyle = if (sendAuth is SendAuth.None) {
CardStyle.Full
} else {
CardStyle.Top()
},
)
when (sendAuth) {
is SendAuth.Email -> {
SpecificPeopleEmailContent(
emails = sendAuth.emails,
onEmailValueChange = onEmailValueChange,
onAddNewEmailClick = onAddNewEmailClick,
onRemoveEmailClick = onRemoveEmailClick,
)
}
is SendAuth.Password -> {
BitwardenPasswordField(
label = stringResource(id = BitwardenString.password),
value = password,
onValueChange = onPasswordChange,
cardStyle = CardStyle.Bottom,
modifier = Modifier.fillMaxWidth(),
)
}
is SendAuth.None -> Unit
}
}
}
@Composable
private fun ColumnScope.SpecificPeopleEmailContent(
emails: ImmutableList<AuthEmail>,
onEmailValueChange: (AuthEmail) -> Unit,
onAddNewEmailClick: () -> Unit,
onRemoveEmailClick: (AuthEmail) -> Unit,
) {
emails.forEachIndexed { index, authEmail ->
BitwardenTextField(
label = stringResource(id = BitwardenString.email),
value = authEmail.value,
onValueChange = { onEmailValueChange(authEmail.copy(value = it)) },
singleLine = false,
keyboardType = KeyboardType.Email,
actions = {
if (index > 0 || authEmail.value.isNotEmpty()) {
BitwardenStandardIconButton(
vectorIconRes = BitwardenDrawable.ic_delete,
contentDescription = stringResource(id = BitwardenString.delete),
contentColor = BitwardenTheme.colorScheme.status.error,
onClick = {
onRemoveEmailClick(authEmail)
},
)
}
},
textFieldTestTag = "SendEmailEntry",
cardStyle = CardStyle.Middle(),
modifier = Modifier.fillMaxWidth(),
)
}
BitwardenClickableText(
label = stringResource(id = BitwardenString.add_email),
onClick = onAddNewEmailClick,
leadingIcon = painterResource(id = BitwardenDrawable.ic_plus_small),
style = BitwardenTheme.typography.labelMedium,
innerPadding = PaddingValues(all = 16.dp),
cornerSize = 0.dp,
modifier = Modifier
.fillMaxWidth()
.testTag(tag = "AddEditSendAddEmailButton")
.cardStyle(cardStyle = CardStyle.Bottom, paddingVertical = 0.dp),
)
}
@@ -3,6 +3,8 @@ package com.x8bit.bitwarden.ui.tools.feature.send.addedit.handlers
import com.bitwarden.ui.platform.model.FileData
import com.x8bit.bitwarden.ui.tools.feature.send.addedit.AddEditSendAction
import com.x8bit.bitwarden.ui.tools.feature.send.addedit.AddEditSendViewModel
import com.x8bit.bitwarden.ui.tools.feature.send.addedit.model.AuthEmail
import com.x8bit.bitwarden.ui.tools.feature.send.addedit.model.SendAuth
import java.time.ZonedDateTime
/**
@@ -24,6 +26,11 @@ data class AddEditSendHandlers(
val onDeleteClick: () -> Unit,
val onOpenPasswordGeneratorClick: () -> Unit,
val onPasswordCopyClick: (String) -> Unit,
val onAuthTypeSelect: (SendAuth) -> Unit,
val onAuthPasswordChange: (String) -> Unit,
val onEmailValueChange: (AuthEmail) -> Unit,
val onAddNewEmailClick: () -> Unit,
val onEmailsRemoveClick: (AuthEmail) -> Unit,
) {
@Suppress("UndocumentedPublicClass")
companion object {
@@ -71,6 +78,23 @@ data class AddEditSendHandlers(
),
)
},
onAuthTypeSelect = {
viewModel.trySendAction(AddEditSendAction.AuthTypeSelect(it))
},
onAuthPasswordChange = {
viewModel.trySendAction(AddEditSendAction.AuthPasswordChange(it))
},
onAddNewEmailClick = {
viewModel.trySendAction(AddEditSendAction.AuthEmailAdd)
},
onEmailValueChange = { authEmail ->
viewModel.trySendAction(
AddEditSendAction.AuthEmailChange(authEmail = authEmail),
)
},
onEmailsRemoveClick = {
viewModel.trySendAction(AddEditSendAction.AuthEmailRemove(it))
},
)
}
}
@@ -0,0 +1,17 @@
package com.x8bit.bitwarden.ui.tools.feature.send.addedit.model
import android.os.Parcelable
import kotlinx.parcelize.Parcelize
import java.util.UUID
/**
* Represents an authentication email with a unique identifier.
*
* @property id A unique identifier for this email entry.
* @property value The email address value.
*/
@Parcelize
data class AuthEmail(
val id: String = UUID.randomUUID().toString(),
val value: String,
) : Parcelable
@@ -0,0 +1,66 @@
package com.x8bit.bitwarden.ui.tools.feature.send.addedit.model
import android.os.Parcelable
import com.bitwarden.ui.platform.resource.BitwardenString
import com.bitwarden.ui.util.Text
import com.bitwarden.ui.util.asText
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
import kotlinx.parcelize.Parcelize
/**
* Sealed class representing the authentication types for a send.
*/
sealed class SendAuth : Parcelable {
/**
* The display text for the authentication type.
*/
abstract val text: Text
/**
* The display text for the supporting component.
*/
abstract val supportingText: Text?
/**
* Anyone with the link can view the send.
*/
@Parcelize
data object None : SendAuth() {
override val text: Text
get() = BitwardenString.anyone_with_the_link.asText()
override val supportingText: Text
get() = BitwardenString.anyone_with_link_can_view_send.asText()
}
/**
* Specific people who verify their email can view the send.
*
* @property emails The list of email addresses that can view the send.
*/
@Parcelize
data class Email(
val emails: ImmutableList<AuthEmail> = persistentListOf(AuthEmail(value = "")),
) : SendAuth() {
override val text: Text
get() = BitwardenString.specific_people.asText()
override val supportingText: Text
get() = BitwardenString
.specific_people_verification_info.asText()
}
/**
* Anyone with the password set by the user can view the send.
* Note: The password value is stored separately in the state's `passwordInput` field.
*/
@Parcelize
data object Password : SendAuth() {
override val text: Text
get() = BitwardenString.anyone_with_password.asText()
override val supportingText: Text?
get() = null // Not used, password field shown instead
}
}
@@ -7,6 +7,7 @@ import com.bitwarden.send.SendType
import com.bitwarden.send.SendView
import com.bitwarden.ui.platform.base.util.orNullIfBlank
import com.x8bit.bitwarden.ui.tools.feature.send.addedit.AddEditSendState
import com.x8bit.bitwarden.ui.tools.feature.send.addedit.model.SendAuth
import java.time.Clock
/**
@@ -21,7 +22,10 @@ fun AddEditSendState.ViewState.Content.toSendView(
name = common.name,
notes = common.noteInput.orNullIfBlank(),
key = common.originalSendView?.key,
newPassword = common.passwordInput.orNullIfBlank(),
newPassword = common
.passwordInput
.takeIf { common.sendAuth is SendAuth.Password }
.orNullIfBlank(),
hasPassword = false,
type = selectedType.toSendType(),
file = toSendFileView(),
@@ -37,8 +41,16 @@ fun AddEditSendState.ViewState.Content.toSendView(
// we just update it to match the deletion date.
common.deletionDate.toInstant()
},
emails = emptyList(),
authType = AuthType.NONE,
emails = (common.sendAuth as? SendAuth.Email)
?.emails
?.map { it.value }
?.filter { it.isNotBlank() }
.orEmpty(),
authType = when (common.sendAuth) {
is SendAuth.Password -> AuthType.PASSWORD
is SendAuth.Email -> AuthType.EMAIL
is SendAuth.None -> AuthType.NONE
},
)
private fun AddEditSendState.ViewState.Content.SendType.toSendType(): SendType =
@@ -3,7 +3,10 @@ package com.x8bit.bitwarden.ui.tools.feature.send.addedit.util
import com.bitwarden.send.SendType
import com.bitwarden.send.SendView
import com.x8bit.bitwarden.ui.tools.feature.send.addedit.AddEditSendState
import com.x8bit.bitwarden.ui.tools.feature.send.addedit.model.AuthEmail
import com.x8bit.bitwarden.ui.tools.feature.send.addedit.model.SendAuth
import com.x8bit.bitwarden.ui.tools.feature.send.util.toSendUrl
import kotlinx.collections.immutable.toImmutableList
import java.time.Clock
import java.time.ZonedDateTime
@@ -14,6 +17,7 @@ fun SendView.toViewState(
clock: Clock,
baseWebSendUrl: String,
isHideEmailAddressEnabled: Boolean,
isSendEmailVerificationEnabled: Boolean,
): AddEditSendState.ViewState.Content =
AddEditSendState.ViewState.Content(
common = AddEditSendState.ViewState.Content.Common(
@@ -32,6 +36,17 @@ fun SendView.toViewState(
sendUrl = this.toSendUrl(baseWebSendUrl),
hasPassword = this.hasPassword,
isHideEmailAddressEnabled = isHideEmailAddressEnabled,
isSendEmailVerificationEnabled = isSendEmailVerificationEnabled,
sendAuth = when {
hasPassword -> SendAuth.Password
emails.isNotEmpty() -> {
SendAuth.Email(
emails = this.emails.map { AuthEmail(value = it) }.toImmutableList(),
)
}
else -> SendAuth.None
},
),
selectedType = when (type) {
SendType.TEXT -> {
@@ -51,7 +51,7 @@ fun createMockSendView(
deletionDate = deletionDate,
expirationDate = expirationDate,
emails = emptyList(),
authType = AuthType.NONE,
authType = AuthType.PASSWORD,
)
/**
@@ -1,6 +1,7 @@
package com.x8bit.bitwarden.ui.tools.feature.send.addedit
import androidx.compose.ui.test.assert
import androidx.compose.ui.test.assertCountEquals
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.assertIsEnabled
import androidx.compose.ui.test.assertIsNotDisplayed
@@ -13,6 +14,7 @@ import androidx.compose.ui.test.hasAnyAncestor
import androidx.compose.ui.test.hasSetTextAction
import androidx.compose.ui.test.isDialog
import androidx.compose.ui.test.isPopup
import androidx.compose.ui.test.onAllNodesWithContentDescription
import androidx.compose.ui.test.onAllNodesWithText
import androidx.compose.ui.test.onNodeWithContentDescription
import androidx.compose.ui.test.onNodeWithTag
@@ -32,12 +34,15 @@ import com.x8bit.bitwarden.ui.platform.base.BitwardenComposeTest
import com.x8bit.bitwarden.ui.platform.manager.permissions.FakePermissionManager
import com.x8bit.bitwarden.ui.tools.feature.generator.model.GeneratorMode
import com.x8bit.bitwarden.ui.tools.feature.send.addedit.model.AddEditSendType
import com.x8bit.bitwarden.ui.tools.feature.send.addedit.model.AuthEmail
import com.x8bit.bitwarden.ui.tools.feature.send.addedit.model.SendAuth
import com.x8bit.bitwarden.ui.tools.feature.send.model.SendItemType
import io.mockk.every
import io.mockk.just
import io.mockk.mockk
import io.mockk.runs
import io.mockk.verify
import kotlinx.collections.immutable.persistentListOf
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.test.runTest
@@ -956,6 +961,460 @@ class AddEditSendScreenTest : BitwardenComposeTest() {
.onNodeWithText(text)
.assertIsDisplayed()
}
//region Authentication UI Tests
@Test
fun `auth type chooser should be displayed when feature flag is enabled`() {
mutableStateFlow.update {
it.copy(
viewState = DEFAULT_VIEW_STATE.copy(
common = DEFAULT_COMMON_STATE.copy(
isSendEmailVerificationEnabled = true,
),
),
)
}
composeTestRule
.onNodeWithTag("SendAuthTypeChooser")
.performScrollTo()
.assertIsDisplayed()
composeTestRule
.onNodeWithText("Who can view")
.assertIsDisplayed()
}
@Test
fun `auth type chooser should not be displayed when feature flag is disabled`() {
mutableStateFlow.update {
it.copy(
viewState = DEFAULT_VIEW_STATE.copy(
common = DEFAULT_COMMON_STATE.copy(
isSendEmailVerificationEnabled = false,
),
),
)
}
composeTestRule
.onNodeWithTag("SendAuthTypeChooser")
.assertDoesNotExist()
}
@Test
fun `selecting EMAIL auth type should display email fields`() {
mutableStateFlow.update {
it.copy(
viewState = DEFAULT_VIEW_STATE.copy(
common = DEFAULT_COMMON_STATE.copy(
isSendEmailVerificationEnabled = true,
sendAuth = SendAuth.None,
),
),
)
}
// Click to expand dropdown
composeTestRule
.onNodeWithTag("SendAuthTypeChooser")
.performScrollTo()
.performClick()
// Select "Specific people"
composeTestRule
.onNodeWithText("Specific people")
.performClick()
verify {
viewModel.trySendAction(
match {
it is AddEditSendAction.AuthTypeSelect &&
it.sendAuth is SendAuth.Email
},
)
}
}
@Test
fun `selecting PASSWORD auth type should display password field`() {
mutableStateFlow.update {
it.copy(
viewState = DEFAULT_VIEW_STATE.copy(
common = DEFAULT_COMMON_STATE.copy(
isSendEmailVerificationEnabled = true,
hasPassword = false,
sendAuth = SendAuth.None,
),
),
)
}
// Click to expand dropdown
composeTestRule
.onNodeWithTag("SendAuthTypeChooser")
.performScrollTo()
.performClick()
// Select "Anyone with a password set by you"
composeTestRule
.onNodeWithText("Anyone with a password set by you")
.performClick()
verify {
viewModel.trySendAction(
AddEditSendAction.AuthTypeSelect(
SendAuth.Password,
),
)
}
}
@Test
fun `typing in auth password field should send AuthPasswordChange action`() {
mutableStateFlow.update {
it.copy(
viewState = DEFAULT_VIEW_STATE.copy(
common = DEFAULT_COMMON_STATE.copy(
hasPassword = false,
isSendEmailVerificationEnabled = true,
passwordInput = "",
sendAuth = SendAuth.None,
),
),
)
}
// Switch to PASSWORD auth type first
composeTestRule
.onNodeWithTag("SendAuthTypeChooser")
.performScrollTo()
.performClick()
composeTestRule
.onNodeWithText("Anyone with a password set by you")
.performClick()
// Update state to show password field with authType set to PASSWORD
mutableStateFlow.update {
it.copy(
viewState = DEFAULT_VIEW_STATE.copy(
common = DEFAULT_COMMON_STATE.copy(
isSendEmailVerificationEnabled = true,
passwordInput = "",
sendAuth = SendAuth.Password,
),
),
)
}
composeTestRule
.onNodeWithText("Password")
.performTextInput("testpassword")
verify {
viewModel.trySendAction(AddEditSendAction.AuthPasswordChange("testpassword"))
}
}
@Test
fun `typing in email field should send AuthEmailChange action`() {
mutableStateFlow.update {
it.copy(
viewState = DEFAULT_VIEW_STATE.copy(
common = DEFAULT_COMMON_STATE.copy(
isSendEmailVerificationEnabled = true,
sendAuth = SendAuth.None,
),
),
)
}
// Switch to EMAIL auth type first
composeTestRule
.onNodeWithTag("SendAuthTypeChooser")
.performScrollTo()
.performClick()
composeTestRule
.onNodeWithText("Specific people")
.performClick()
// Update state to show email field with authType set to EMAIL
val testEmail = AuthEmail(id = "test-id", value = "")
mutableStateFlow.update {
it.copy(
viewState = DEFAULT_VIEW_STATE.copy(
common = DEFAULT_COMMON_STATE.copy(
isSendEmailVerificationEnabled = true,
sendAuth = SendAuth.Email(emails = persistentListOf(testEmail)),
),
),
)
}
composeTestRule
.onNodeWithTag("SendEmailEntry")
.performTextInput("test@example.com")
verify {
viewModel.trySendAction(
AddEditSendAction.AuthEmailChange(
AuthEmail(
value = "test@example.com",
id = "test-id",
),
),
)
}
}
@Test
fun `clicking add email button should send AuthEmailAdd action`() {
mutableStateFlow.update {
it.copy(
viewState = DEFAULT_VIEW_STATE.copy(
common = DEFAULT_COMMON_STATE.copy(
hasPassword = false,
isSendEmailVerificationEnabled = true,
sendAuth = SendAuth.Email(
emails = persistentListOf(
AuthEmail(
id = "id1",
value = "test@example.com",
),
),
),
),
),
)
}
composeTestRule
.onNodeWithText("Add email")
.performScrollTo()
.performClick()
verify {
viewModel.trySendAction(AddEditSendAction.AuthEmailAdd)
}
}
@Test
fun `clicking delete email button should send AuthEmailRemove action`() {
val email1 = AuthEmail(id = "id1", value = "test1@example.com")
val email2 = AuthEmail(id = "id2", value = "test2@example.com")
mutableStateFlow.update {
it.copy(
viewState = DEFAULT_VIEW_STATE.copy(
common = DEFAULT_COMMON_STATE.copy(
hasPassword = false,
isSendEmailVerificationEnabled = true,
sendAuth = SendAuth.Email(emails = persistentListOf(email1, email2)),
),
),
)
}
// Switch to EMAIL auth type
composeTestRule
.onNodeWithTag("SendAuthTypeChooser")
.performScrollTo()
.performClick()
composeTestRule
.onNodeWithText("Specific people")
.performClick()
// Update state to show email fields with authType set to EMAIL
mutableStateFlow.update {
it.copy(
viewState = DEFAULT_VIEW_STATE.copy(
common = DEFAULT_COMMON_STATE.copy(
isSendEmailVerificationEnabled = true,
sendAuth = SendAuth.Email(
emails = persistentListOf(email1, email2),
),
),
),
)
}
composeTestRule
.onAllNodesWithContentDescription("Delete")[0]
.performScrollTo()
.performClick()
verify {
viewModel.trySendAction(
AddEditSendAction.AuthEmailRemove(
AuthEmail(
value = "test1@example.com",
id = "id1",
),
),
)
}
}
@Test
fun `auth type chooser should show EMAIL option for premium users`() {
mutableStateFlow.update {
it.copy(
isPremium = true,
viewState = DEFAULT_VIEW_STATE.copy(
common = DEFAULT_COMMON_STATE.copy(
isSendEmailVerificationEnabled = true,
sendAuth = SendAuth.None,
),
),
)
}
// Click to expand dropdown
composeTestRule
.onNodeWithTag("SendAuthTypeChooser")
.performScrollTo()
.performClick()
// "Specific people" should be visible
composeTestRule
.onNodeWithText("Specific people")
.assertIsDisplayed()
}
@Test
fun `legacy password field should be hidden when auth chooser is displayed`() {
// Expand options section
composeTestRule
.onNodeWithText("Additional options")
.performScrollTo()
.performClick()
// With feature flag OFF, legacy password field should be visible
mutableStateFlow.update {
it.copy(
viewState = DEFAULT_VIEW_STATE.copy(
common = DEFAULT_COMMON_STATE.copy(
isSendEmailVerificationEnabled = false,
),
),
)
}
composeTestRule
.onNodeWithText("New password")
.performScrollTo()
.assertIsDisplayed()
// With feature flag ON, legacy password field should be hidden
mutableStateFlow.update {
it.copy(
viewState = DEFAULT_VIEW_STATE.copy(
common = DEFAULT_COMMON_STATE.copy(
isSendEmailVerificationEnabled = true,
),
),
)
}
composeTestRule
.onNodeWithText("New password")
.assertDoesNotExist()
}
@Test
fun `email fields should display provided emails from state`() {
val email1 = AuthEmail(id = "id1", value = "user1@example.com")
val email2 = AuthEmail(id = "id2", value = "user2@example.com")
mutableStateFlow.update {
it.copy(
viewState = DEFAULT_VIEW_STATE.copy(
common = DEFAULT_COMMON_STATE.copy(
isSendEmailVerificationEnabled = true,
sendAuth = SendAuth.Email(emails = persistentListOf(email1, email2)),
),
),
)
}
// Switch to EMAIL auth type
composeTestRule
.onNodeWithTag("SendAuthTypeChooser")
.performScrollTo()
.performClick()
composeTestRule
.onNodeWithText("Specific people")
.performClick()
// Update state to show emails with authType set to EMAIL
mutableStateFlow.update {
it.copy(
viewState = DEFAULT_VIEW_STATE.copy(
common = DEFAULT_COMMON_STATE.copy(
isSendEmailVerificationEnabled = true,
sendAuth = SendAuth.Email(emails = persistentListOf(email1, email2)),
),
),
)
}
// Verify both email fields are present
composeTestRule
.onAllNodesWithText("Email")
.assertCountEquals(2)
}
@Test
fun `supporting text should display for EMAIL auth type`() {
mutableStateFlow.update {
it.copy(
viewState = DEFAULT_VIEW_STATE.copy(
common = DEFAULT_COMMON_STATE.copy(
isSendEmailVerificationEnabled = true,
sendAuth = SendAuth.None,
),
),
)
}
// Click to select EMAIL
composeTestRule
.onNodeWithTag("SendAuthTypeChooser")
.performScrollTo()
.performClick()
composeTestRule
.onNodeWithText("Specific people")
.performClick()
// Update state with authType set to EMAIL
mutableStateFlow.update {
it.copy(
viewState = DEFAULT_VIEW_STATE.copy(
common = DEFAULT_COMMON_STATE.copy(
isSendEmailVerificationEnabled = true,
sendAuth = SendAuth.Email(
emails = persistentListOf(
AuthEmail(
id = "id1",
value = "",
),
),
),
),
),
)
}
composeTestRule
.onNodeWithText(
"After sharing this Send link, individuals will need to verify " +
"their email with a code to view this Send",
)
.assertIsDisplayed()
}
//endregion Authentication UI Tests
}
private val DEFAULT_COMMON_STATE = AddEditSendState.ViewState.Content.Common(
@@ -971,6 +1430,8 @@ private val DEFAULT_COMMON_STATE = AddEditSendState.ViewState.Content.Common(
sendUrl = null,
hasPassword = true,
isHideEmailAddressEnabled = true,
isSendEmailVerificationEnabled = false,
sendAuth = SendAuth.None,
)
private val DEFAULT_SELECTED_TYPE_STATE = AddEditSendState.ViewState.Content.SendType.Text(
@@ -3,6 +3,7 @@ package com.x8bit.bitwarden.ui.tools.feature.send.addedit
import android.net.Uri
import androidx.lifecycle.SavedStateHandle
import app.cash.turbine.test
import com.bitwarden.core.data.manager.model.FlagKey
import com.bitwarden.core.data.repository.model.DataState
import com.bitwarden.core.data.repository.util.bufferedMutableSharedFlow
import com.bitwarden.data.repository.model.Environment
@@ -20,6 +21,7 @@ import com.x8bit.bitwarden.data.auth.datasource.disk.model.OnboardingStatus
import com.x8bit.bitwarden.data.auth.repository.AuthRepository
import com.x8bit.bitwarden.data.auth.repository.model.PolicyInformation
import com.x8bit.bitwarden.data.auth.repository.model.UserState
import com.x8bit.bitwarden.data.platform.manager.FeatureFlagManager
import com.x8bit.bitwarden.data.platform.manager.PolicyManager
import com.x8bit.bitwarden.data.platform.manager.SpecialCircumstanceManager
import com.x8bit.bitwarden.data.platform.manager.clipboard.BitwardenClipboardManager
@@ -37,6 +39,8 @@ import com.x8bit.bitwarden.data.vault.repository.model.UpdateSendResult
import com.x8bit.bitwarden.ui.platform.model.SnackbarRelay
import com.x8bit.bitwarden.ui.tools.feature.generator.model.GeneratorMode
import com.x8bit.bitwarden.ui.tools.feature.send.addedit.model.AddEditSendType
import com.x8bit.bitwarden.ui.tools.feature.send.addedit.model.AuthEmail
import com.x8bit.bitwarden.ui.tools.feature.send.addedit.model.SendAuth
import com.x8bit.bitwarden.ui.tools.feature.send.addedit.util.toSendView
import com.x8bit.bitwarden.ui.tools.feature.send.addedit.util.toViewState
import com.x8bit.bitwarden.ui.tools.feature.send.model.SendItemType
@@ -50,6 +54,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.flow.MutableStateFlow
import kotlinx.coroutines.test.runTest
import kotlinx.serialization.json.Json
@@ -79,7 +84,7 @@ class AddEditSendViewModelTest : BaseViewModelTest() {
private val authRepository = mockk<AuthRepository> {
every { userStateFlow } returns mutableUserStateFlow
}
private val generatorRepository = mockk<GeneratorRepository> (relaxed = true) {
private val generatorRepository = mockk<GeneratorRepository>(relaxed = true) {
every { generatorResultFlow } returns mutableGeneratorResultFlow
}
private val environmentRepository: EnvironmentRepository = mockk {
@@ -104,6 +109,16 @@ class AddEditSendViewModelTest : BaseViewModelTest() {
every { sendSnackbarData(data = any(), relay = any()) } just runs
}
private val mutableSendEmailVerificationFeatureFlagFlow = MutableStateFlow(false)
private val featureFlagManager: FeatureFlagManager = mockk {
every {
getFeatureFlagFlow(FlagKey.SendEmailVerification)
} returns mutableSendEmailVerificationFeatureFlagFlow
every {
getFeatureFlag(FlagKey.SendEmailVerification)
} answers { mutableSendEmailVerificationFeatureFlagFlow.value }
}
@BeforeEach
fun setup() {
mockkStatic(
@@ -304,6 +319,7 @@ class AddEditSendViewModelTest : BaseViewModelTest() {
clock = clock,
baseWebSendUrl = DEFAULT_ENVIRONMENT_URL,
isHideEmailAddressEnabled = true,
isSendEmailVerificationEnabled = false,
)
} returns viewState
every { viewState.toSendView(clock) } returns mockSendView
@@ -348,6 +364,7 @@ class AddEditSendViewModelTest : BaseViewModelTest() {
clock = clock,
baseWebSendUrl = DEFAULT_ENVIRONMENT_URL,
isHideEmailAddressEnabled = true,
isSendEmailVerificationEnabled = false,
)
} returns viewState
every { viewState.toSendView(clock) } returns mockSendView
@@ -532,6 +549,7 @@ class AddEditSendViewModelTest : BaseViewModelTest() {
clock = clock,
baseWebSendUrl = DEFAULT_ENVIRONMENT_URL,
isHideEmailAddressEnabled = true,
isSendEmailVerificationEnabled = false,
)
} returns viewState
mutableSendDataStateFlow.value = DataState.Loaded(mockSendView)
@@ -594,6 +612,7 @@ class AddEditSendViewModelTest : BaseViewModelTest() {
clock = clock,
baseWebSendUrl = DEFAULT_ENVIRONMENT_URL,
isHideEmailAddressEnabled = true,
isSendEmailVerificationEnabled = false,
)
} returns DEFAULT_VIEW_STATE
mutableSendDataStateFlow.value = DataState.Loaded(mockSendView)
@@ -640,6 +659,7 @@ class AddEditSendViewModelTest : BaseViewModelTest() {
clock = clock,
baseWebSendUrl = DEFAULT_ENVIRONMENT_URL,
isHideEmailAddressEnabled = true,
isSendEmailVerificationEnabled = false,
)
} returns DEFAULT_VIEW_STATE
mutableSendDataStateFlow.value = DataState.Loaded(mockSendView)
@@ -715,6 +735,7 @@ class AddEditSendViewModelTest : BaseViewModelTest() {
clock = clock,
baseWebSendUrl = DEFAULT_ENVIRONMENT_URL,
isHideEmailAddressEnabled = true,
isSendEmailVerificationEnabled = false,
)
} returns DEFAULT_VIEW_STATE
mutableSendDataStateFlow.value = DataState.Loaded(mockSendView)
@@ -783,6 +804,7 @@ class AddEditSendViewModelTest : BaseViewModelTest() {
clock = clock,
baseWebSendUrl = DEFAULT_ENVIRONMENT_URL,
isHideEmailAddressEnabled = true,
isSendEmailVerificationEnabled = false,
)
} returns viewState
mutableSendDataStateFlow.value = DataState.Loaded(mockSendView)
@@ -966,7 +988,10 @@ class AddEditSendViewModelTest : BaseViewModelTest() {
fun `PasswordChange should update note input`() = runTest {
val viewModel = createViewModel()
val expectedViewState = DEFAULT_VIEW_STATE.copy(
common = DEFAULT_COMMON_STATE.copy(passwordInput = "input"),
common = DEFAULT_COMMON_STATE.copy(
passwordInput = "input",
sendAuth = SendAuth.Password,
),
)
viewModel.stateFlow.test {
@@ -1059,6 +1084,207 @@ class AddEditSendViewModelTest : BaseViewModelTest() {
}
}
//region Authentication Tests
@Test
fun `Changing AuthTypeSelect should not clear emails and password`() = runTest {
val email1 = AuthEmail(id = "id1", value = "test@example.com")
val initialCommonState = DEFAULT_COMMON_STATE.copy(
passwordInput = "oldpassword",
sendAuth = SendAuth.Password,
)
val initialState = DEFAULT_STATE.copy(
viewState = DEFAULT_VIEW_STATE.copy(
common = initialCommonState,
),
)
val viewModel = createViewModel(initialState)
val expectedNoneViewState = DEFAULT_VIEW_STATE.copy(
common = initialCommonState.copy(
sendAuth = SendAuth.None,
),
)
viewModel.stateFlow.test {
assertEquals(initialState, awaitItem())
viewModel.trySendAction(
AddEditSendAction.AuthTypeSelect(
SendAuth.None,
),
)
assertEquals(initialState.copy(viewState = expectedNoneViewState), awaitItem())
viewModel.trySendAction(
AddEditSendAction.AuthTypeSelect(
SendAuth.Email(),
),
)
// Check the structure rather than exact equality due to random UUIDs
val emailState = awaitItem()
val sendAuth = (emailState.viewState as AddEditSendState.ViewState.Content)
.common
.sendAuth as SendAuth.Email
assertEquals(1, sendAuth.emails.size)
assertEquals("", sendAuth.emails[0].value)
}
}
@Test
fun `AuthPasswordChange should update passwordInput`() = runTest {
val viewModel = createViewModel()
val expectedViewState = DEFAULT_VIEW_STATE.copy(
common = DEFAULT_COMMON_STATE.copy(passwordInput = "newpassword"),
)
viewModel.stateFlow.test {
assertEquals(DEFAULT_STATE, awaitItem())
viewModel.trySendAction(AddEditSendAction.AuthPasswordChange("newpassword"))
assertEquals(DEFAULT_STATE.copy(viewState = expectedViewState), awaitItem())
}
}
@Test
fun `AuthEmailChange should update email at specified index`() = runTest {
val email1 = AuthEmail(id = "id1", value = "test1@example.com")
val email2 = AuthEmail(id = "id2", value = "test2@example.com")
val initialState = DEFAULT_STATE.copy(
viewState = DEFAULT_VIEW_STATE.copy(
common = DEFAULT_COMMON_STATE.copy(
sendAuth = SendAuth.Email(
emails = persistentListOf(email1, email2),
),
),
),
)
val viewModel = createViewModel(initialState)
val expectedViewState = DEFAULT_VIEW_STATE.copy(
common = DEFAULT_COMMON_STATE.copy(
sendAuth = SendAuth.Email(
emails = persistentListOf(
email1,
email2.copy(value = "updated@example.com"),
),
),
),
)
viewModel.stateFlow.test {
assertEquals(initialState, awaitItem())
viewModel.trySendAction(
AddEditSendAction.AuthEmailChange(
AuthEmail(
value = "updated@example.com",
id = "id2",
),
),
)
assertEquals(initialState.copy(viewState = expectedViewState), awaitItem())
}
}
@Test
fun `AuthEmailAdd should add empty email to list`() = runTest {
val email1 = AuthEmail(id = "id1", value = "test@example.com")
val initialState = DEFAULT_STATE.copy(
viewState = DEFAULT_VIEW_STATE.copy(
common = DEFAULT_COMMON_STATE.copy(
sendAuth = SendAuth.Email(
emails = persistentListOf(email1),
),
),
),
)
val viewModel = createViewModel(initialState)
viewModel.stateFlow.test {
assertEquals(initialState, awaitItem())
viewModel.trySendAction(AddEditSendAction.AuthEmailAdd)
val newState = awaitItem()
val actualEmails = (newState.viewState as AddEditSendState.ViewState.Content)
.common
.sendAuth as SendAuth.Email
// Verify we have 2 emails, the first unchanged and the second empty
assertEquals(2, actualEmails.emails.size)
assertEquals(email1, actualEmails.emails[0])
assertEquals("", actualEmails.emails[1].value)
}
}
@Test
fun `AuthEmailRemove should remove email at specified index`() = runTest {
val email1 = AuthEmail(id = "id1", value = "test1@example.com")
val email2 = AuthEmail(id = "id2", value = "test2@example.com")
val email3 = AuthEmail(id = "id3", value = "test3@example.com")
val initialState = DEFAULT_STATE.copy(
viewState = DEFAULT_VIEW_STATE.copy(
common = DEFAULT_COMMON_STATE.copy(
sendAuth = SendAuth.Email(
emails = persistentListOf(email1, email2, email3),
),
),
),
)
val viewModel = createViewModel(initialState)
val expectedViewState = DEFAULT_VIEW_STATE.copy(
common = DEFAULT_COMMON_STATE.copy(
sendAuth = SendAuth.Email(
emails = persistentListOf(email1, email3),
),
),
)
viewModel.stateFlow.test {
assertEquals(initialState, awaitItem())
viewModel.trySendAction(
AddEditSendAction.AuthEmailRemove(
AuthEmail(
value = "test@example.com",
id = "id2",
),
),
)
assertEquals(initialState.copy(viewState = expectedViewState), awaitItem())
}
}
@Test
fun `AuthEmailRemove with last email should result in single empty AuthEmail`() = runTest {
val email1 = AuthEmail(id = "id1", value = "test@example.com")
val initialState = DEFAULT_STATE.copy(
viewState = DEFAULT_VIEW_STATE.copy(
common = DEFAULT_COMMON_STATE.copy(
sendAuth = SendAuth.Email(
emails = persistentListOf(email1),
),
),
),
)
val viewModel = createViewModel(initialState)
viewModel.stateFlow.test {
assertEquals(initialState, awaitItem())
viewModel.trySendAction(
AddEditSendAction.AuthEmailRemove(
AuthEmail(
value = "test@example.com",
id = "id1",
),
),
)
val newState = awaitItem()
val actualEmails = (newState.viewState as AddEditSendState.ViewState.Content)
.common
.sendAuth as SendAuth.Email
// Verify we have 1 empty email after removing the last one
assertEquals(1, actualEmails.emails.size)
assertEquals("", actualEmails.emails[0].value)
}
}
//endregion Authentication Tests
private fun createViewModel(
state: AddEditSendState? = null,
addEditSendType: AddEditSendType = AddEditSendType.AddItem,
@@ -1082,6 +1308,7 @@ class AddEditSendViewModelTest : BaseViewModelTest() {
networkConnectionManager = networkConnectionManager,
snackbarRelayManager = snackbarRelayManager,
generatorRepository = generatorRepository,
featureFlagManager = featureFlagManager,
)
}
@@ -1098,6 +1325,8 @@ private val DEFAULT_COMMON_STATE = AddEditSendState.ViewState.Content.Common(
sendUrl = null,
hasPassword = false,
isHideEmailAddressEnabled = true,
isSendEmailVerificationEnabled = false,
sendAuth = SendAuth.None,
)
private val DEFAULT_SELECTED_TYPE_STATE = AddEditSendState.ViewState.Content.SendType.Text(
@@ -1,9 +1,13 @@
package com.x8bit.bitwarden.ui.tools.feature.send.addedit.util
import com.bitwarden.send.AuthType
import com.bitwarden.send.SendType
import com.x8bit.bitwarden.data.vault.datasource.sdk.model.createMockFileView
import com.x8bit.bitwarden.data.vault.datasource.sdk.model.createMockSendView
import com.x8bit.bitwarden.ui.tools.feature.send.addedit.AddEditSendState
import com.x8bit.bitwarden.ui.tools.feature.send.addedit.model.AuthEmail
import com.x8bit.bitwarden.ui.tools.feature.send.addedit.model.SendAuth
import kotlinx.collections.immutable.persistentListOf
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Test
import java.time.Clock
@@ -83,6 +87,57 @@ class AddEditSendStateExtensionsTest {
assertEquals(sendView, result)
}
@Test
fun `toSendView should create an appropriate SendView with Auth type NONE`() {
val sendView = createMockSendView(number = 1, type = SendType.TEXT).copy(
id = null,
accessId = null,
key = null,
accessCount = 0U,
hasPassword = false,
newPassword = null,
authType = AuthType.NONE,
)
val result = DEFAULT_VIEW_STATE
.copy(
common = DEFAULT_COMMON_STATE.copy(
passwordInput = "",
sendAuth = SendAuth.None,
),
)
.toSendView(FIXED_CLOCK)
assertEquals(sendView, result)
}
@Test
fun `toSendView should create an appropriate SendView with Auth type EMAIL`() {
val sendView = createMockSendView(number = 1, type = SendType.TEXT).copy(
id = null,
accessId = null,
key = null,
accessCount = 0U,
hasPassword = false,
newPassword = null,
emails = listOf("email@email.com"),
authType = AuthType.EMAIL,
)
val result = DEFAULT_VIEW_STATE
.copy(
common = DEFAULT_COMMON_STATE.copy(
passwordInput = "",
sendAuth = SendAuth.Email(
emails = persistentListOf(AuthEmail(id = "id1", value = "email@email.com")),
),
),
)
.toSendView(FIXED_CLOCK)
assertEquals(sendView, result)
}
}
private val FIXED_CLOCK: Clock = Clock.fixed(
@@ -103,6 +158,8 @@ private val DEFAULT_COMMON_STATE = AddEditSendState.ViewState.Content.Common(
sendUrl = null,
hasPassword = false,
isHideEmailAddressEnabled = true,
isSendEmailVerificationEnabled = false,
sendAuth = SendAuth.Password,
)
private val DEFAULT_SELECTED_TYPE_STATE = AddEditSendState.ViewState.Content.SendType.Text(
@@ -3,6 +3,7 @@ package com.x8bit.bitwarden.ui.tools.feature.send.addedit.util
import com.bitwarden.send.SendType
import com.x8bit.bitwarden.data.vault.datasource.sdk.model.createMockSendView
import com.x8bit.bitwarden.ui.tools.feature.send.addedit.AddEditSendState
import com.x8bit.bitwarden.ui.tools.feature.send.addedit.model.SendAuth
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Test
import java.time.Clock
@@ -20,11 +21,15 @@ class SendViewExtensionsTest {
clock = FIXED_CLOCK,
baseWebSendUrl = "www.test.com/",
isHideEmailAddressEnabled = true,
isSendEmailVerificationEnabled = false,
)
assertEquals(
DEFAULT_STATE.copy(
common = DEFAULT_COMMON.copy(originalSendView = sendView),
common = DEFAULT_COMMON.copy(
originalSendView = sendView,
sendAuth = SendAuth.Password,
),
),
result,
)
@@ -38,11 +43,15 @@ class SendViewExtensionsTest {
clock = FIXED_CLOCK,
baseWebSendUrl = "www.test.com/",
isHideEmailAddressEnabled = true,
isSendEmailVerificationEnabled = false,
)
assertEquals(
DEFAULT_STATE.copy(
common = DEFAULT_COMMON.copy(originalSendView = sendView),
common = DEFAULT_COMMON.copy(
originalSendView = sendView,
sendAuth = SendAuth.Password,
),
selectedType = DEFAULT_TEXT_TYPE,
),
result,
@@ -75,6 +84,8 @@ private val DEFAULT_COMMON: AddEditSendState.ViewState.Content.Common =
sendUrl = "www.test.com/mockAccessId-1/mockKey-1",
hasPassword = true,
isHideEmailAddressEnabled = true,
isSendEmailVerificationEnabled = false,
sendAuth = SendAuth.None,
)
private val DEFAULT_TEXT_TYPE: AddEditSendState.ViewState.Content.SendType.Text =
+7
View File
@@ -448,6 +448,13 @@ Scanning will happen automatically.</string>
<string name="deletion_date">Deletion date</string>
<string name="deletion_time">Deletion time</string>
<string name="deletion_date_info">The Send will be permanently deleted on the specified date and time.</string>
<string name="who_can_view">Who can view</string>
<string name="anyone_with_the_link">Anyone with the link</string>
<string name="anyone_with_link_can_view_send">Anyone with this link can view this Send</string>
<string name="specific_people">Specific people</string>
<string name="specific_people_verification_info">After sharing this Send link, individuals will need to verify their email with a code to view this Send</string>
<string name="anyone_with_password">Anyone with a password set by you</string>
<string name="add_email">Add email</string>
<string name="pending_delete">Pending deletion</string>
<string name="expired">Expired</string>
<string name="expires_at">Expires at %s</string>