From ce3f0acf74a51ffdfd024736c02cefa1a775ced0 Mon Sep 17 00:00:00 2001 From: aj-rosado <109146700+aj-rosado@users.noreply.github.com> Date: Fri, 13 Feb 2026 22:19:09 +0000 Subject: [PATCH] [PM-31614] feat: Added new UI for the Email verification on sends (#6488) --- .../send/addedit/AddEditSendContent.kt | 90 ++-- .../send/addedit/AddEditSendViewModel.kt | 135 ++++- .../components/AddEditSendAuthTypeChooser.kt | 160 ++++++ .../addedit/handlers/AddEditSendHandlers.kt | 24 + .../feature/send/addedit/model/AuthEmail.kt | 17 + .../feature/send/addedit/model/SendAuth.kt | 66 +++ .../util/AddEditSendStateExtensions.kt | 18 +- .../send/addedit/util/SendViewExtensions.kt | 15 + .../datasource/sdk/model/SendViewUtil.kt | 2 +- .../send/addedit/AddEditSendScreenTest.kt | 461 ++++++++++++++++++ .../send/addedit/AddEditSendViewModelTest.kt | 233 ++++++++- .../util/AddEditSendStateExtensionsTest.kt | 57 +++ .../addedit/util/SendViewExtensionsTest.kt | 15 +- ui/src/main/res/values/strings.xml | 7 + 14 files changed, 1257 insertions(+), 43 deletions(-) create mode 100644 app/src/main/kotlin/com/x8bit/bitwarden/ui/tools/feature/send/addedit/components/AddEditSendAuthTypeChooser.kt create mode 100644 app/src/main/kotlin/com/x8bit/bitwarden/ui/tools/feature/send/addedit/model/AuthEmail.kt create mode 100644 app/src/main/kotlin/com/x8bit/bitwarden/ui/tools/feature/send/addedit/model/SendAuth.kt diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/ui/tools/feature/send/addedit/AddEditSendContent.kt b/app/src/main/kotlin/com/x8bit/bitwarden/ui/tools/feature/send/addedit/AddEditSendContent.kt index ef965415b5..550fa5eccd 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/ui/tools/feature/send/addedit/AddEditSendContent.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/ui/tools/feature/send/addedit/AddEditSendContent.kt @@ -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( diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/ui/tools/feature/send/addedit/AddEditSendViewModel.kt b/app/src/main/kotlin/com/x8bit/bitwarden/ui/tools/feature/send/addedit/AddEditSendViewModel.kt index 1a485ce2e5..499e2e289c 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/ui/tools/feature/send/addedit/AddEditSendViewModel.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/ui/tools/feature/send/addedit/AddEditSendViewModel.kt @@ -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, + private val featureFlagManager: FeatureFlagManager, ) : BaseViewModel( // 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() .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. */ diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/ui/tools/feature/send/addedit/components/AddEditSendAuthTypeChooser.kt b/app/src/main/kotlin/com/x8bit/bitwarden/ui/tools/feature/send/addedit/components/AddEditSendAuthTypeChooser.kt new file mode 100644 index 0000000000..a43f051013 --- /dev/null +++ b/app/src/main/kotlin/com/x8bit/bitwarden/ui/tools/feature/send/addedit/components/AddEditSendAuthTypeChooser.kt @@ -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, + 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), + ) +} diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/ui/tools/feature/send/addedit/handlers/AddEditSendHandlers.kt b/app/src/main/kotlin/com/x8bit/bitwarden/ui/tools/feature/send/addedit/handlers/AddEditSendHandlers.kt index 9c57876ebe..121b4b7383 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/ui/tools/feature/send/addedit/handlers/AddEditSendHandlers.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/ui/tools/feature/send/addedit/handlers/AddEditSendHandlers.kt @@ -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)) + }, ) } } diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/ui/tools/feature/send/addedit/model/AuthEmail.kt b/app/src/main/kotlin/com/x8bit/bitwarden/ui/tools/feature/send/addedit/model/AuthEmail.kt new file mode 100644 index 0000000000..f0c57a89ec --- /dev/null +++ b/app/src/main/kotlin/com/x8bit/bitwarden/ui/tools/feature/send/addedit/model/AuthEmail.kt @@ -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 diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/ui/tools/feature/send/addedit/model/SendAuth.kt b/app/src/main/kotlin/com/x8bit/bitwarden/ui/tools/feature/send/addedit/model/SendAuth.kt new file mode 100644 index 0000000000..57a9d1d26b --- /dev/null +++ b/app/src/main/kotlin/com/x8bit/bitwarden/ui/tools/feature/send/addedit/model/SendAuth.kt @@ -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 = 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 + } +} diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/ui/tools/feature/send/addedit/util/AddEditSendStateExtensions.kt b/app/src/main/kotlin/com/x8bit/bitwarden/ui/tools/feature/send/addedit/util/AddEditSendStateExtensions.kt index f895908b87..26ec126eb2 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/ui/tools/feature/send/addedit/util/AddEditSendStateExtensions.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/ui/tools/feature/send/addedit/util/AddEditSendStateExtensions.kt @@ -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 = diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/ui/tools/feature/send/addedit/util/SendViewExtensions.kt b/app/src/main/kotlin/com/x8bit/bitwarden/ui/tools/feature/send/addedit/util/SendViewExtensions.kt index 9bb4c9ab25..52e0e71292 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/ui/tools/feature/send/addedit/util/SendViewExtensions.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/ui/tools/feature/send/addedit/util/SendViewExtensions.kt @@ -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 -> { diff --git a/app/src/test/kotlin/com/x8bit/bitwarden/data/vault/datasource/sdk/model/SendViewUtil.kt b/app/src/test/kotlin/com/x8bit/bitwarden/data/vault/datasource/sdk/model/SendViewUtil.kt index c03f9cf910..20b1d8ddfb 100644 --- a/app/src/test/kotlin/com/x8bit/bitwarden/data/vault/datasource/sdk/model/SendViewUtil.kt +++ b/app/src/test/kotlin/com/x8bit/bitwarden/data/vault/datasource/sdk/model/SendViewUtil.kt @@ -51,7 +51,7 @@ fun createMockSendView( deletionDate = deletionDate, expirationDate = expirationDate, emails = emptyList(), - authType = AuthType.NONE, + authType = AuthType.PASSWORD, ) /** diff --git a/app/src/test/kotlin/com/x8bit/bitwarden/ui/tools/feature/send/addedit/AddEditSendScreenTest.kt b/app/src/test/kotlin/com/x8bit/bitwarden/ui/tools/feature/send/addedit/AddEditSendScreenTest.kt index 0abd63aa76..56ed07a76e 100644 --- a/app/src/test/kotlin/com/x8bit/bitwarden/ui/tools/feature/send/addedit/AddEditSendScreenTest.kt +++ b/app/src/test/kotlin/com/x8bit/bitwarden/ui/tools/feature/send/addedit/AddEditSendScreenTest.kt @@ -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( diff --git a/app/src/test/kotlin/com/x8bit/bitwarden/ui/tools/feature/send/addedit/AddEditSendViewModelTest.kt b/app/src/test/kotlin/com/x8bit/bitwarden/ui/tools/feature/send/addedit/AddEditSendViewModelTest.kt index 28b4d14d59..1ee4fadd27 100644 --- a/app/src/test/kotlin/com/x8bit/bitwarden/ui/tools/feature/send/addedit/AddEditSendViewModelTest.kt +++ b/app/src/test/kotlin/com/x8bit/bitwarden/ui/tools/feature/send/addedit/AddEditSendViewModelTest.kt @@ -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 { every { userStateFlow } returns mutableUserStateFlow } - private val generatorRepository = mockk (relaxed = true) { + private val generatorRepository = mockk(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( diff --git a/app/src/test/kotlin/com/x8bit/bitwarden/ui/tools/feature/send/addedit/util/AddEditSendStateExtensionsTest.kt b/app/src/test/kotlin/com/x8bit/bitwarden/ui/tools/feature/send/addedit/util/AddEditSendStateExtensionsTest.kt index 265a4892eb..cf7c7f68e3 100644 --- a/app/src/test/kotlin/com/x8bit/bitwarden/ui/tools/feature/send/addedit/util/AddEditSendStateExtensionsTest.kt +++ b/app/src/test/kotlin/com/x8bit/bitwarden/ui/tools/feature/send/addedit/util/AddEditSendStateExtensionsTest.kt @@ -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( diff --git a/app/src/test/kotlin/com/x8bit/bitwarden/ui/tools/feature/send/addedit/util/SendViewExtensionsTest.kt b/app/src/test/kotlin/com/x8bit/bitwarden/ui/tools/feature/send/addedit/util/SendViewExtensionsTest.kt index ac132d4573..4a6409c95e 100644 --- a/app/src/test/kotlin/com/x8bit/bitwarden/ui/tools/feature/send/addedit/util/SendViewExtensionsTest.kt +++ b/app/src/test/kotlin/com/x8bit/bitwarden/ui/tools/feature/send/addedit/util/SendViewExtensionsTest.kt @@ -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 = diff --git a/ui/src/main/res/values/strings.xml b/ui/src/main/res/values/strings.xml index 579498df43..c30e6e059f 100644 --- a/ui/src/main/res/values/strings.xml +++ b/ui/src/main/res/values/strings.xml @@ -448,6 +448,13 @@ Scanning will happen automatically. Deletion date Deletion time The Send will be permanently deleted on the specified date and time. + Who can view + Anyone with the link + Anyone with this link can view this Send + Specific people + After sharing this Send link, individuals will need to verify their email with a code to view this Send + Anyone with a password set by you + Add email Pending deletion Expired Expires at %s