PM-24240: Remove email verification feature flag (#5605)

This commit is contained in:
David Perez 2025-07-28 15:45:45 -05:00 committed by GitHub
parent 867e2287dc
commit a70f441064
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
28 changed files with 36 additions and 2177 deletions

View File

@ -11,8 +11,6 @@ import com.x8bit.bitwarden.ui.auth.feature.checkemail.navigateToCheckEmail
import com.x8bit.bitwarden.ui.auth.feature.completeregistration.completeRegistrationDestination
import com.x8bit.bitwarden.ui.auth.feature.completeregistration.navigateToCompleteRegistration
import com.x8bit.bitwarden.ui.auth.feature.completeregistration.popUpToCompleteRegistration
import com.x8bit.bitwarden.ui.auth.feature.createaccount.createAccountDestination
import com.x8bit.bitwarden.ui.auth.feature.createaccount.navigateToCreateAccount
import com.x8bit.bitwarden.ui.auth.feature.enterprisesignon.enterpriseSignOnDestination
import com.x8bit.bitwarden.ui.auth.feature.enterprisesignon.navigateToEnterpriseSignOn
import com.x8bit.bitwarden.ui.auth.feature.environment.environmentDestination
@ -61,18 +59,6 @@ fun NavGraphBuilder.authGraph(
navigation<AuthGraphRoute>(
startDestination = LandingRoute,
) {
createAccountDestination(
onNavigateBack = { navController.popBackStack() },
onNavigateToLogin = { emailAddress, captchaToken ->
navController.navigateToLogin(
emailAddress = emailAddress,
captchaToken = captchaToken,
navOptions = navOptions {
popUpTo(route = LandingRoute)
},
)
},
)
startRegistrationDestination(
onNavigateBack = { navController.popBackStack() },
onNavigateToCompleteRegistration = { emailAddress, verificationToken ->
@ -121,7 +107,6 @@ fun NavGraphBuilder.authGraph(
)
setPasswordDestination()
landingDestination(
onNavigateToCreateAccount = { navController.navigateToCreateAccount() },
onNavigateToLogin = { emailAddress ->
navController.navigateToLogin(
emailAddress = emailAddress,
@ -135,7 +120,6 @@ fun NavGraphBuilder.authGraph(
onNavigateToPreAuthSettings = { navController.navigateToPreAuthSettings() },
)
welcomeDestination(
onNavigateToCreateAccount = { navController.navigateToCreateAccount() },
onNavigateToLogin = { navController.navigateToLanding() },
onNavigateToStartRegistration = { navController.navigateToStartRegistration() },
)

View File

@ -1,35 +0,0 @@
package com.x8bit.bitwarden.ui.auth.feature.createaccount
import androidx.navigation.NavController
import androidx.navigation.NavGraphBuilder
import androidx.navigation.NavOptions
import com.bitwarden.ui.platform.base.util.composableWithSlideTransitions
import kotlinx.serialization.Serializable
/**
* The type-safe route for the create account screen.
*/
@Serializable
data object CreateAccountRoute
/**
* Navigate to the create account screen.
*/
fun NavController.navigateToCreateAccount(navOptions: NavOptions? = null) {
this.navigate(route = CreateAccountRoute, navOptions = navOptions)
}
/**
* Add the create account screen to the nav graph.
*/
fun NavGraphBuilder.createAccountDestination(
onNavigateBack: () -> Unit,
onNavigateToLogin: (emailAddress: String, captchaToken: String?) -> Unit,
) {
composableWithSlideTransitions<CreateAccountRoute> {
CreateAccountScreen(
onNavigateBack = onNavigateBack,
onNavigateToLogin = onNavigateToLogin,
)
}
}

View File

@ -1,329 +0,0 @@
package com.x8bit.bitwarden.ui.auth.feature.createaccount
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.material3.rememberTopAppBarState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.CustomAccessibilityAction
import androidx.compose.ui.semantics.customActions
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.dp
import androidx.core.net.toUri
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.bitwarden.ui.platform.base.util.EventsEffect
import com.bitwarden.ui.platform.base.util.annotatedStringResource
import com.bitwarden.ui.platform.base.util.standardHorizontalMargin
import com.bitwarden.ui.platform.components.appbar.BitwardenTopAppBar
import com.bitwarden.ui.platform.components.button.BitwardenTextButton
import com.bitwarden.ui.platform.components.model.CardStyle
import com.bitwarden.ui.platform.components.toggle.BitwardenSwitch
import com.bitwarden.ui.platform.components.util.rememberVectorPainter
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.auth.feature.completeregistration.PasswordStrengthIndicator
import com.x8bit.bitwarden.ui.auth.feature.createaccount.CreateAccountAction.AcceptPoliciesToggle
import com.x8bit.bitwarden.ui.auth.feature.createaccount.CreateAccountAction.CheckDataBreachesToggle
import com.x8bit.bitwarden.ui.auth.feature.createaccount.CreateAccountAction.CloseClick
import com.x8bit.bitwarden.ui.auth.feature.createaccount.CreateAccountAction.ConfirmPasswordInputChange
import com.x8bit.bitwarden.ui.auth.feature.createaccount.CreateAccountAction.ContinueWithBreachedPasswordClick
import com.x8bit.bitwarden.ui.auth.feature.createaccount.CreateAccountAction.EmailInputChange
import com.x8bit.bitwarden.ui.auth.feature.createaccount.CreateAccountAction.ErrorDialogDismiss
import com.x8bit.bitwarden.ui.auth.feature.createaccount.CreateAccountAction.PasswordHintChange
import com.x8bit.bitwarden.ui.auth.feature.createaccount.CreateAccountAction.PasswordInputChange
import com.x8bit.bitwarden.ui.auth.feature.createaccount.CreateAccountAction.PrivacyPolicyClick
import com.x8bit.bitwarden.ui.auth.feature.createaccount.CreateAccountAction.SubmitClick
import com.x8bit.bitwarden.ui.auth.feature.createaccount.CreateAccountAction.TermsClick
import com.x8bit.bitwarden.ui.auth.feature.createaccount.CreateAccountEvent.NavigateToPrivacyPolicy
import com.x8bit.bitwarden.ui.auth.feature.createaccount.CreateAccountEvent.NavigateToTerms
import com.x8bit.bitwarden.ui.platform.components.dialog.BitwardenBasicDialog
import com.x8bit.bitwarden.ui.platform.components.dialog.BitwardenLoadingDialog
import com.x8bit.bitwarden.ui.platform.components.dialog.BitwardenTwoButtonDialog
import com.x8bit.bitwarden.ui.platform.components.field.BitwardenPasswordField
import com.x8bit.bitwarden.ui.platform.components.field.BitwardenTextField
import com.x8bit.bitwarden.ui.platform.components.scaffold.BitwardenScaffold
import com.x8bit.bitwarden.ui.platform.composition.LocalIntentManager
import com.x8bit.bitwarden.ui.platform.manager.intent.IntentManager
/**
* Top level composable for the create account screen.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Suppress("LongMethod")
@Composable
fun CreateAccountScreen(
onNavigateBack: () -> Unit,
onNavigateToLogin: (emailAddress: String, captchaToken: String?) -> Unit,
intentManager: IntentManager = LocalIntentManager.current,
viewModel: CreateAccountViewModel = hiltViewModel(),
) {
val state by viewModel.stateFlow.collectAsStateWithLifecycle()
EventsEffect(viewModel) { event ->
when (event) {
is NavigateToPrivacyPolicy -> {
intentManager.launchUri("https://bitwarden.com/privacy/".toUri())
}
is NavigateToTerms -> {
intentManager.launchUri("https://bitwarden.com/terms/".toUri())
}
is CreateAccountEvent.NavigateBack -> onNavigateBack.invoke()
is CreateAccountEvent.NavigateToCaptcha -> {
intentManager.startCustomTabsActivity(uri = event.uri)
}
is CreateAccountEvent.NavigateToLogin -> {
onNavigateToLogin(
event.email,
event.captchaToken,
)
}
}
}
// Show dialog if needed:
when (val dialog = state.dialog) {
is CreateAccountDialog.Error -> {
BitwardenBasicDialog(
title = dialog.title?.invoke(),
message = dialog.message(),
throwable = dialog.error,
onDismissRequest = remember(viewModel) {
{ viewModel.trySendAction(ErrorDialogDismiss) }
},
)
}
is CreateAccountDialog.HaveIBeenPwned -> {
BitwardenTwoButtonDialog(
title = dialog.title(),
message = dialog.message(),
confirmButtonText = stringResource(id = BitwardenString.yes),
dismissButtonText = stringResource(id = BitwardenString.no),
onConfirmClick = remember(viewModel) {
{ viewModel.trySendAction(ContinueWithBreachedPasswordClick) }
},
onDismissClick = remember(viewModel) {
{ viewModel.trySendAction(ErrorDialogDismiss) }
},
onDismissRequest = remember(viewModel) {
{ viewModel.trySendAction(ErrorDialogDismiss) }
},
)
}
CreateAccountDialog.Loading -> {
BitwardenLoadingDialog(text = stringResource(id = BitwardenString.create_account))
}
null -> Unit
}
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior(rememberTopAppBarState())
BitwardenScaffold(
modifier = Modifier
.fillMaxSize()
.nestedScroll(scrollBehavior.nestedScrollConnection),
topBar = {
BitwardenTopAppBar(
title = stringResource(id = BitwardenString.create_account),
scrollBehavior = scrollBehavior,
navigationIcon = rememberVectorPainter(id = BitwardenDrawable.ic_close),
navigationIconContentDescription = stringResource(id = BitwardenString.close),
onNavigationIconClick = remember(viewModel) {
{ viewModel.trySendAction(CloseClick) }
},
actions = {
BitwardenTextButton(
label = stringResource(id = BitwardenString.submit),
onClick = remember(viewModel) {
{ viewModel.trySendAction(SubmitClick) }
},
modifier = Modifier.testTag("SubmitButton"),
)
},
)
},
) {
Column(
modifier = Modifier
.fillMaxSize()
.verticalScroll(rememberScrollState()),
) {
Spacer(modifier = Modifier.height(height = 12.dp))
BitwardenTextField(
label = stringResource(id = BitwardenString.email_address),
value = state.emailInput,
onValueChange = remember(viewModel) {
{ viewModel.trySendAction(EmailInputChange(it)) }
},
keyboardType = KeyboardType.Email,
textFieldTestTag = "EmailAddressEntry",
cardStyle = CardStyle.Full,
modifier = Modifier
.fillMaxWidth()
.standardHorizontalMargin(),
)
Spacer(modifier = Modifier.height(height = 8.dp))
var showPassword by rememberSaveable { mutableStateOf(false) }
BitwardenPasswordField(
label = stringResource(id = BitwardenString.master_password),
showPassword = showPassword,
showPasswordChange = { showPassword = it },
value = state.passwordInput,
onValueChange = remember(viewModel) {
{ viewModel.trySendAction(PasswordInputChange(it)) }
},
showPasswordTestTag = "PasswordVisibilityToggle",
supportingContent = {
PasswordStrengthIndicator(
modifier = Modifier.fillMaxWidth(),
state = state.passwordStrengthState,
currentCharacterCount = state.passwordInput.length,
)
Text(
modifier = Modifier.fillMaxWidth(),
text = state.passwordLengthLabel(),
style = BitwardenTheme.typography.bodySmall,
color = BitwardenTheme.colorScheme.text.secondary,
)
},
passwordFieldTestTag = "MasterPasswordEntry",
cardStyle = CardStyle.Top(dividerPadding = 0.dp),
modifier = Modifier
.fillMaxWidth()
.standardHorizontalMargin(),
)
BitwardenPasswordField(
label = stringResource(id = BitwardenString.retype_master_password),
value = state.confirmPasswordInput,
showPassword = showPassword,
showPasswordChange = { showPassword = it },
onValueChange = remember(viewModel) {
{ viewModel.trySendAction(ConfirmPasswordInputChange(it)) }
},
showPasswordTestTag = "ConfirmPasswordVisibilityToggle",
passwordFieldTestTag = "ConfirmMasterPasswordEntry",
cardStyle = CardStyle.Middle(dividerPadding = 0.dp),
modifier = Modifier
.fillMaxWidth()
.standardHorizontalMargin(),
)
BitwardenTextField(
label = stringResource(id = BitwardenString.master_password_hint),
value = state.passwordHintInput,
onValueChange = remember(viewModel) {
{ viewModel.trySendAction(PasswordHintChange(it)) }
},
supportingText = stringResource(
id = BitwardenString.master_password_hint_description),
textFieldTestTag = "MasterPasswordHintLabel",
cardStyle = CardStyle.Bottom,
modifier = Modifier
.fillMaxWidth()
.standardHorizontalMargin(),
)
Spacer(modifier = Modifier.height(height = 8.dp))
BitwardenSwitch(
label = stringResource(
id = BitwardenString.check_known_data_breaches_for_this_password),
isChecked = state.isCheckDataBreachesToggled,
onCheckedChange = remember(viewModel) {
{ newState ->
viewModel.trySendAction(CheckDataBreachesToggle(newState = newState))
}
},
cardStyle = CardStyle.Top(),
modifier = Modifier
.testTag("CheckExposedMasterPasswordToggle")
.fillMaxWidth()
.standardHorizontalMargin(),
)
TermsAndPrivacySwitch(
isChecked = state.isAcceptPoliciesToggled,
onCheckedChange = remember(viewModel) {
{ viewModel.trySendAction(AcceptPoliciesToggle(it)) }
},
onTermsClick = remember(viewModel) {
{ viewModel.trySendAction(TermsClick) }
},
onPrivacyPolicyClick = remember(viewModel) {
{ viewModel.trySendAction(PrivacyPolicyClick) }
},
modifier = Modifier
.fillMaxWidth()
.standardHorizontalMargin(),
)
Spacer(modifier = Modifier.height(height = 16.dp))
Spacer(modifier = Modifier.navigationBarsPadding())
}
}
}
@Composable
private fun TermsAndPrivacySwitch(
isChecked: Boolean,
onCheckedChange: (Boolean) -> Unit,
onTermsClick: () -> Unit,
onPrivacyPolicyClick: () -> Unit,
modifier: Modifier = Modifier,
) {
val strTerms = stringResource(id = BitwardenString.terms_of_service)
val strPrivacy = stringResource(id = BitwardenString.privacy_policy)
BitwardenSwitch(
modifier = modifier.semantics(mergeDescendants = true) {
customActions = listOf(
CustomAccessibilityAction(
label = strTerms,
action = {
onTermsClick()
true
},
),
CustomAccessibilityAction(
label = strPrivacy,
action = {
onPrivacyPolicyClick()
true
},
),
)
},
label = annotatedStringResource(
id = BitwardenString
.by_activating_this_switch_you_agree_to_the_terms_of_service_and_privacy_policy,
onAnnotationClick = {
when (it) {
"termsOfService" -> onTermsClick()
"privacyPolicy" -> onPrivacyPolicyClick()
}
},
),
isChecked = isChecked,
contentDescription = "AcceptPoliciesToggle",
onCheckedChange = onCheckedChange,
cardStyle = CardStyle.Bottom,
)
}

View File

@ -1,595 +0,0 @@
package com.x8bit.bitwarden.ui.auth.feature.createaccount
import android.net.Uri
import android.os.Parcelable
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.viewModelScope
import com.bitwarden.ui.platform.base.BaseViewModel
import com.bitwarden.ui.platform.base.util.isValidEmail
import com.bitwarden.ui.platform.resource.BitwardenString
import com.bitwarden.ui.util.Text
import com.bitwarden.ui.util.asText
import com.bitwarden.ui.util.concat
import com.x8bit.bitwarden.data.auth.datasource.sdk.model.PasswordStrength
import com.x8bit.bitwarden.data.auth.repository.AuthRepository
import com.x8bit.bitwarden.data.auth.repository.model.PasswordStrengthResult
import com.x8bit.bitwarden.data.auth.repository.model.RegisterResult
import com.x8bit.bitwarden.data.auth.repository.util.CaptchaCallbackTokenResult
import com.x8bit.bitwarden.data.auth.repository.util.generateUriForCaptcha
import com.x8bit.bitwarden.ui.auth.feature.completeregistration.PasswordStrengthState
import com.x8bit.bitwarden.ui.auth.feature.createaccount.CreateAccountAction.AcceptPoliciesToggle
import com.x8bit.bitwarden.ui.auth.feature.createaccount.CreateAccountAction.CheckDataBreachesToggle
import com.x8bit.bitwarden.ui.auth.feature.createaccount.CreateAccountAction.ConfirmPasswordInputChange
import com.x8bit.bitwarden.ui.auth.feature.createaccount.CreateAccountAction.ContinueWithBreachedPasswordClick
import com.x8bit.bitwarden.ui.auth.feature.createaccount.CreateAccountAction.EmailInputChange
import com.x8bit.bitwarden.ui.auth.feature.createaccount.CreateAccountAction.Internal.ReceivePasswordStrengthResult
import com.x8bit.bitwarden.ui.auth.feature.createaccount.CreateAccountAction.PasswordHintChange
import com.x8bit.bitwarden.ui.auth.feature.createaccount.CreateAccountAction.PasswordInputChange
import com.x8bit.bitwarden.ui.auth.feature.createaccount.CreateAccountAction.PrivacyPolicyClick
import com.x8bit.bitwarden.ui.auth.feature.createaccount.CreateAccountAction.SubmitClick
import com.x8bit.bitwarden.ui.auth.feature.createaccount.CreateAccountAction.TermsClick
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.parcelize.Parcelize
import javax.inject.Inject
private const val KEY_STATE = "state"
private const val MIN_PASSWORD_LENGTH = 12
/**
* Models logic for the create account screen.
*/
@Suppress("TooManyFunctions")
@HiltViewModel
class CreateAccountViewModel @Inject constructor(
savedStateHandle: SavedStateHandle,
private val authRepository: AuthRepository,
) : BaseViewModel<CreateAccountState, CreateAccountEvent, CreateAccountAction>(
initialState = savedStateHandle[KEY_STATE]
?: CreateAccountState(
emailInput = "",
passwordInput = "",
confirmPasswordInput = "",
passwordHintInput = "",
isAcceptPoliciesToggled = false,
isCheckDataBreachesToggled = true,
dialog = null,
passwordStrengthState = PasswordStrengthState.NONE,
),
) {
/**
* Keeps track of async request to get password strength. Should be cancelled
* when user input changes.
*/
private var passwordStrengthJob: Job = Job().apply { complete() }
init {
// As state updates, write to saved state handle:
stateFlow
.onEach { savedStateHandle[KEY_STATE] = it }
.launchIn(viewModelScope)
authRepository
.captchaTokenResultFlow
.onEach {
sendAction(
CreateAccountAction.Internal.ReceiveCaptchaToken(
tokenResult = it,
),
)
}
.launchIn(viewModelScope)
}
override fun handleAction(action: CreateAccountAction) {
when (action) {
is SubmitClick -> handleSubmitClick()
is ConfirmPasswordInputChange -> handleConfirmPasswordInputChanged(action)
is EmailInputChange -> handleEmailInputChanged(action)
is PasswordHintChange -> handlePasswordHintChanged(action)
is PasswordInputChange -> handlePasswordInputChanged(action)
is CreateAccountAction.CloseClick -> handleCloseClick()
is CreateAccountAction.ErrorDialogDismiss -> handleDialogDismiss()
is AcceptPoliciesToggle -> handleAcceptPoliciesToggle(action)
is CheckDataBreachesToggle -> handleCheckDataBreachesToggle(action)
is PrivacyPolicyClick -> handlePrivacyPolicyClick()
is TermsClick -> handleTermsClick()
is CreateAccountAction.Internal.ReceiveRegisterResult -> {
handleReceiveRegisterAccountResult(action)
}
is CreateAccountAction.Internal.ReceiveCaptchaToken -> {
handleReceiveCaptchaToken(action)
}
ContinueWithBreachedPasswordClick -> handleContinueWithBreachedPasswordClick()
is ReceivePasswordStrengthResult -> handlePasswordStrengthResult(action)
}
}
private fun handlePasswordStrengthResult(action: ReceivePasswordStrengthResult) {
when (val result = action.result) {
is PasswordStrengthResult.Success -> {
val updatedState = when (result.passwordStrength) {
PasswordStrength.LEVEL_0 -> PasswordStrengthState.WEAK_1
PasswordStrength.LEVEL_1 -> PasswordStrengthState.WEAK_2
PasswordStrength.LEVEL_2 -> PasswordStrengthState.WEAK_3
PasswordStrength.LEVEL_3 -> PasswordStrengthState.GOOD
PasswordStrength.LEVEL_4 -> PasswordStrengthState.STRONG
}
mutableStateFlow.update { oldState ->
oldState.copy(
passwordStrengthState = updatedState,
)
}
}
is PasswordStrengthResult.Error -> {
// Leave UI the same
}
}
}
private fun handleReceiveCaptchaToken(
action: CreateAccountAction.Internal.ReceiveCaptchaToken,
) {
when (val result = action.tokenResult) {
is CaptchaCallbackTokenResult.MissingToken -> {
mutableStateFlow.update {
it.copy(
dialog = CreateAccountDialog.Error(
title = BitwardenString.an_error_has_occurred.asText(),
message = BitwardenString.captcha_failed.asText(),
),
)
}
}
is CaptchaCallbackTokenResult.Success -> {
submitRegisterAccountRequest(
shouldCheckForDataBreaches = false,
shouldIgnorePasswordStrength = true,
captchaToken = result.token,
)
}
}
}
@Suppress("LongMethod", "MaxLineLength")
private fun handleReceiveRegisterAccountResult(
action: CreateAccountAction.Internal.ReceiveRegisterResult,
) {
when (val registerAccountResult = action.registerResult) {
is RegisterResult.CaptchaRequired -> {
mutableStateFlow.update { it.copy(dialog = null) }
sendEvent(
CreateAccountEvent.NavigateToCaptcha(
uri = generateUriForCaptcha(captchaId = registerAccountResult.captchaId),
),
)
}
is RegisterResult.Error -> {
mutableStateFlow.update {
it.copy(
dialog = CreateAccountDialog.Error(
title = BitwardenString.an_error_has_occurred.asText(),
message = registerAccountResult.errorMessage?.asText()
?: BitwardenString.generic_error_message.asText(),
error = registerAccountResult.error,
),
)
}
}
is RegisterResult.Success -> {
mutableStateFlow.update { it.copy(dialog = null) }
sendEvent(
CreateAccountEvent.NavigateToLogin(
email = state.emailInput,
captchaToken = registerAccountResult.captchaToken,
),
)
}
RegisterResult.DataBreachFound -> {
mutableStateFlow.update {
it.copy(
dialog = CreateAccountDialog.HaveIBeenPwned(
title = BitwardenString.exposed_master_password.asText(),
message = BitwardenString.password_found_in_a_data_breach_alert_description.asText(),
),
)
}
}
RegisterResult.DataBreachAndWeakPassword -> {
mutableStateFlow.update {
it.copy(
dialog = CreateAccountDialog.HaveIBeenPwned(
title = BitwardenString.weak_and_exposed_master_password.asText(),
message = BitwardenString.weak_password_identified_and_found_in_a_data_breach_alert_description.asText(),
),
)
}
}
RegisterResult.WeakPassword -> {
mutableStateFlow.update {
it.copy(
dialog = CreateAccountDialog.HaveIBeenPwned(
title = BitwardenString.weak_master_password.asText(),
message = BitwardenString.weak_password_identified_use_a_strong_password_to_protect_your_account.asText(),
),
)
}
}
}
}
private fun handlePrivacyPolicyClick() = sendEvent(CreateAccountEvent.NavigateToPrivacyPolicy)
private fun handleTermsClick() = sendEvent(CreateAccountEvent.NavigateToTerms)
private fun handleAcceptPoliciesToggle(action: AcceptPoliciesToggle) {
mutableStateFlow.update {
it.copy(isAcceptPoliciesToggled = action.newState)
}
}
private fun handleCheckDataBreachesToggle(action: CheckDataBreachesToggle) {
mutableStateFlow.update {
it.copy(isCheckDataBreachesToggled = action.newState)
}
}
private fun handleDialogDismiss() {
mutableStateFlow.update {
it.copy(dialog = null)
}
}
private fun handleCloseClick() {
sendEvent(CreateAccountEvent.NavigateBack)
}
private fun handleEmailInputChanged(action: EmailInputChange) {
mutableStateFlow.update { it.copy(emailInput = action.input) }
}
private fun handlePasswordHintChanged(action: PasswordHintChange) {
mutableStateFlow.update { it.copy(passwordHintInput = action.input) }
}
private fun handlePasswordInputChanged(action: PasswordInputChange) {
// Update input:
mutableStateFlow.update { it.copy(passwordInput = action.input) }
// Update password strength:
passwordStrengthJob.cancel()
if (action.input.isEmpty()) {
mutableStateFlow.update {
it.copy(passwordStrengthState = PasswordStrengthState.NONE)
}
} else {
passwordStrengthJob = viewModelScope.launch {
val result = authRepository.getPasswordStrength(
email = state.emailInput,
password = action.input,
)
trySendAction(ReceivePasswordStrengthResult(result))
}
}
}
private fun handleConfirmPasswordInputChanged(action: ConfirmPasswordInputChange) {
mutableStateFlow.update { it.copy(confirmPasswordInput = action.input) }
}
@Suppress("LongMethod")
private fun handleSubmitClick() = when {
state.emailInput.isBlank() -> {
mutableStateFlow.update {
it.copy(
dialog = CreateAccountDialog.Error(
title = BitwardenString.an_error_has_occurred.asText(),
message = BitwardenString.validation_field_required
.asText(BitwardenString.email_address.asText()),
),
)
}
}
!state.emailInput.isValidEmail() -> {
mutableStateFlow.update {
it.copy(
dialog = CreateAccountDialog.Error(
title = BitwardenString.an_error_has_occurred.asText(),
message = BitwardenString.invalid_email.asText(),
),
)
}
}
state.passwordInput.length < MIN_PASSWORD_LENGTH -> {
mutableStateFlow.update {
it.copy(
dialog = CreateAccountDialog.Error(
title = BitwardenString.an_error_has_occurred.asText(),
message = BitwardenString.master_password_length_val_message_x
.asText(MIN_PASSWORD_LENGTH),
),
)
}
}
state.passwordInput != state.confirmPasswordInput -> {
mutableStateFlow.update {
it.copy(
dialog = CreateAccountDialog.Error(
title = BitwardenString.an_error_has_occurred.asText(),
message = BitwardenString.master_password_confirmation_val_message.asText(),
),
)
}
}
!state.isAcceptPoliciesToggled -> {
mutableStateFlow.update {
it.copy(
dialog = CreateAccountDialog.Error(
title = BitwardenString.an_error_has_occurred.asText(),
message = BitwardenString.accept_policies_error.asText(),
),
)
}
}
else -> {
submitRegisterAccountRequest(
shouldCheckForDataBreaches = state.isCheckDataBreachesToggled,
shouldIgnorePasswordStrength = false,
captchaToken = null,
)
}
}
private fun handleContinueWithBreachedPasswordClick() {
submitRegisterAccountRequest(
shouldCheckForDataBreaches = false,
shouldIgnorePasswordStrength = true,
captchaToken = null,
)
}
private fun submitRegisterAccountRequest(
shouldCheckForDataBreaches: Boolean,
shouldIgnorePasswordStrength: Boolean,
captchaToken: String?,
) {
mutableStateFlow.update {
it.copy(dialog = CreateAccountDialog.Loading)
}
viewModelScope.launch {
val result = authRepository.register(
shouldCheckDataBreaches = shouldCheckForDataBreaches,
isMasterPasswordStrong = shouldIgnorePasswordStrength ||
state.isMasterPasswordStrong,
email = state.emailInput,
masterPassword = state.passwordInput,
masterPasswordHint = state.passwordHintInput.ifBlank { null },
captchaToken = captchaToken,
)
sendAction(
CreateAccountAction.Internal.ReceiveRegisterResult(
registerResult = result,
),
)
}
}
}
/**
* UI state for the create account screen.
*/
@Parcelize
data class CreateAccountState(
val emailInput: String,
val passwordInput: String,
val confirmPasswordInput: String,
val passwordHintInput: String,
val isCheckDataBreachesToggled: Boolean,
val isAcceptPoliciesToggled: Boolean,
val dialog: CreateAccountDialog?,
val passwordStrengthState: PasswordStrengthState,
) : Parcelable {
val passwordLengthLabel: Text
// Have to concat a few strings here, resulting string is:
// Important: Your master password cannot be recovered if you forget it! 12
// characters minimum
@Suppress("MaxLineLength")
get() = BitwardenString.important.asText()
.concat(
": ".asText(),
BitwardenString.your_master_password_cannot_be_recovered_if_you_forget_it_x_characters_minimum
.asText(MIN_PASSWORD_LENGTH),
)
/**
* Whether or not the provided master password is considered strong.
*/
val isMasterPasswordStrong: Boolean
get() = when (passwordStrengthState) {
PasswordStrengthState.NONE,
PasswordStrengthState.WEAK_1,
PasswordStrengthState.WEAK_2,
PasswordStrengthState.WEAK_3,
-> false
PasswordStrengthState.GOOD,
PasswordStrengthState.STRONG,
-> true
}
}
/**
* Models dialogs that can be displayed on the create account screen.
*/
sealed class CreateAccountDialog : Parcelable {
/**
* Loading dialog.
*/
@Parcelize
data object Loading : CreateAccountDialog()
/**
* Confirm the user wants to continue with potentially breached password.
*
* @param title The title for the HaveIBeenPwned dialog.
* @param message The message for the HaveIBeenPwned dialog.
*/
@Parcelize
data class HaveIBeenPwned(
val title: Text,
val message: Text,
) : CreateAccountDialog()
/**
* General error dialog with an OK button.
*/
@Parcelize
data class Error(
val title: Text?,
val message: Text,
val error: Throwable? = null,
) : CreateAccountDialog()
}
/**
* Models events for the create account screen.
*/
sealed class CreateAccountEvent {
/**
* Navigate back to previous screen.
*/
data object NavigateBack : CreateAccountEvent()
/**
* Navigates to the captcha verification screen.
*/
data class NavigateToCaptcha(val uri: Uri) : CreateAccountEvent()
/**
* Navigates to the login screen bypassing captcha with token.
*/
data class NavigateToLogin(
val email: String,
val captchaToken: String?,
) : CreateAccountEvent()
/**
* Navigate to terms and conditions.
*/
data object NavigateToTerms : CreateAccountEvent()
/**
* Navigate to privacy policy.
*/
data object NavigateToPrivacyPolicy : CreateAccountEvent()
}
/**
* Models actions for the create account screen.
*/
sealed class CreateAccountAction {
/**
* User clicked submit.
*/
data object SubmitClick : CreateAccountAction()
/**
* User clicked close.
*/
data object CloseClick : CreateAccountAction()
/**
* User clicked "Yes" when being asked if they are sure they want to use a breached password.
*/
data object ContinueWithBreachedPasswordClick : CreateAccountAction()
/**
* Email input changed.
*/
data class EmailInputChange(val input: String) : CreateAccountAction()
/**
* Password input changed.
*/
data class PasswordInputChange(val input: String) : CreateAccountAction()
/**
* Confirm password input changed.
*/
data class ConfirmPasswordInputChange(val input: String) : CreateAccountAction()
/**
* Password hint input changed.
*/
data class PasswordHintChange(val input: String) : CreateAccountAction()
/**
* User dismissed the error dialog.
*/
data object ErrorDialogDismiss : CreateAccountAction()
/**
* User tapped check data breaches toggle.
*/
data class CheckDataBreachesToggle(val newState: Boolean) : CreateAccountAction()
/**
* User tapped accept policies toggle.
*/
data class AcceptPoliciesToggle(val newState: Boolean) : CreateAccountAction()
/**
* User tapped privacy policy link.
*/
data object PrivacyPolicyClick : CreateAccountAction()
/**
* User tapped terms link.
*/
data object TermsClick : CreateAccountAction()
/**
* Models actions that the [CreateAccountViewModel] itself might send.
*/
sealed class Internal : CreateAccountAction() {
/**
* Indicates a captcha callback token has been received.
*/
data class ReceiveCaptchaToken(
val tokenResult: CaptchaCallbackTokenResult,
) : Internal()
/**
* Indicates a [RegisterResult] has been received.
*/
data class ReceiveRegisterResult(
val registerResult: RegisterResult,
) : Internal()
/**
* Indicates a password strength result has been received.
*/
data class ReceivePasswordStrengthResult(
val result: PasswordStrengthResult,
) : Internal()
}
}

View File

@ -23,7 +23,6 @@ fun NavController.navigateToLanding(navOptions: NavOptions? = null) {
* Add the Landing screen to the nav graph.
*/
fun NavGraphBuilder.landingDestination(
onNavigateToCreateAccount: () -> Unit,
onNavigateToLogin: (emailAddress: String) -> Unit,
onNavigateToEnvironment: () -> Unit,
onNavigateToStartRegistration: () -> Unit,
@ -31,7 +30,6 @@ fun NavGraphBuilder.landingDestination(
) {
composableWithStayTransitions<LandingRoute> {
LandingScreen(
onNavigateToCreateAccount = onNavigateToCreateAccount,
onNavigateToLogin = onNavigateToLogin,
onNavigateToEnvironment = onNavigateToEnvironment,
onNavigateToStartRegistration = onNavigateToStartRegistration,

View File

@ -65,7 +65,6 @@ import kotlinx.collections.immutable.toImmutableList
@Composable
@Suppress("LongMethod")
fun LandingScreen(
onNavigateToCreateAccount: () -> Unit,
onNavigateToLogin: (emailAddress: String) -> Unit,
onNavigateToEnvironment: () -> Unit,
onNavigateToStartRegistration: () -> Unit,
@ -76,7 +75,6 @@ fun LandingScreen(
val snackbarHostState = rememberBitwardenSnackbarHostState()
EventsEffect(viewModel = viewModel) { event ->
when (event) {
LandingEvent.NavigateToCreateAccount -> onNavigateToCreateAccount()
is LandingEvent.NavigateToLogin -> onNavigateToLogin(event.emailAddress)
LandingEvent.NavigateToEnvironment -> onNavigateToEnvironment()
LandingEvent.NavigateToStartRegistration -> onNavigateToStartRegistration()

View File

@ -3,7 +3,6 @@ package com.x8bit.bitwarden.ui.auth.feature.landing
import android.os.Parcelable
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.viewModelScope
import com.bitwarden.core.data.manager.model.FlagKey
import com.bitwarden.data.repository.model.Environment
import com.bitwarden.ui.platform.base.BackgroundEvent
import com.bitwarden.ui.platform.base.BaseViewModel
@ -14,7 +13,6 @@ import com.bitwarden.ui.util.asText
import com.x8bit.bitwarden.data.auth.repository.AuthRepository
import com.x8bit.bitwarden.data.auth.repository.model.LogoutReason
import com.x8bit.bitwarden.data.auth.repository.model.UserState
import com.x8bit.bitwarden.data.platform.manager.FeatureFlagManager
import com.x8bit.bitwarden.data.platform.repository.EnvironmentRepository
import com.x8bit.bitwarden.data.vault.repository.VaultRepository
import com.x8bit.bitwarden.ui.platform.components.model.AccountSummary
@ -41,7 +39,6 @@ class LandingViewModel @Inject constructor(
private val authRepository: AuthRepository,
private val vaultRepository: VaultRepository,
private val environmentRepository: EnvironmentRepository,
private val featureFlagManager: FeatureFlagManager,
snackbarRelayManager: SnackbarRelayManager,
savedStateHandle: SavedStateHandle,
) : BaseViewModel<LandingState, LandingEvent, LandingAction>(
@ -215,13 +212,7 @@ class LandingViewModel @Inject constructor(
}
private fun handleCreateAccountClicked() {
val navigationEvent =
if (featureFlagManager.getFeatureFlag(key = FlagKey.EmailVerification)) {
LandingEvent.NavigateToStartRegistration
} else {
LandingEvent.NavigateToCreateAccount
}
sendEvent(navigationEvent)
sendEvent(LandingEvent.NavigateToStartRegistration)
}
private fun handleDialogDismiss() {
@ -326,11 +317,6 @@ data class LandingState(
* Models events for the landing screen.
*/
sealed class LandingEvent {
/**
* Navigates to the Create Account screen.
*/
data object NavigateToCreateAccount : LandingEvent()
/**
* Navigates to the Start Registration screen.
*/

View File

@ -23,13 +23,11 @@ fun NavController.navigateToWelcome(navOptions: NavOptions? = null) {
* Add the Welcome screen to the nav graph.
*/
fun NavGraphBuilder.welcomeDestination(
onNavigateToCreateAccount: () -> Unit,
onNavigateToLogin: () -> Unit,
onNavigateToStartRegistration: () -> Unit,
) {
composableWithStayTransitions<WelcomeRoute> {
WelcomeScreen(
onNavigateToCreateAccount = onNavigateToCreateAccount,
onNavigateToLogin = onNavigateToLogin,
onNavigateToStartRegistration = onNavigateToStartRegistration,
)

View File

@ -63,7 +63,6 @@ private val HORIZONTAL_MARGIN_MEDIUM: Dp = 128.dp
*/
@Composable
fun WelcomeScreen(
onNavigateToCreateAccount: () -> Unit,
onNavigateToLogin: () -> Unit,
onNavigateToStartRegistration: () -> Unit,
viewModel: WelcomeViewModel = hiltViewModel(),
@ -78,7 +77,6 @@ fun WelcomeScreen(
scope.launch { pagerState.animateScrollToPage(event.index) }
}
WelcomeEvent.NavigateToCreateAccount -> onNavigateToCreateAccount()
WelcomeEvent.NavigateToLogin -> onNavigateToLogin()
WelcomeEvent.NavigateToStartRegistration -> onNavigateToStartRegistration()
}

View File

@ -1,11 +1,9 @@
package com.x8bit.bitwarden.ui.auth.feature.welcome
import android.os.Parcelable
import com.bitwarden.core.data.manager.model.FlagKey
import com.bitwarden.ui.platform.base.BaseViewModel
import com.bitwarden.ui.platform.resource.BitwardenDrawable
import com.bitwarden.ui.platform.resource.BitwardenString
import com.x8bit.bitwarden.data.platform.manager.FeatureFlagManager
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.update
import kotlinx.parcelize.Parcelize
@ -15,9 +13,7 @@ import javax.inject.Inject
* Manages application state for the welcome screen.
*/
@HiltViewModel
class WelcomeViewModel @Inject constructor(
private val featureFlagManager: FeatureFlagManager,
) :
class WelcomeViewModel @Inject constructor() :
BaseViewModel<WelcomeState, WelcomeEvent, WelcomeAction>(
initialState = WelcomeState(
index = 0,
@ -48,12 +44,7 @@ class WelcomeViewModel @Inject constructor(
}
private fun handleCreateAccountClick() {
val event = if (featureFlagManager.getFeatureFlag(FlagKey.EmailVerification)) {
WelcomeEvent.NavigateToStartRegistration
} else {
WelcomeEvent.NavigateToCreateAccount
}
sendEvent(event)
sendEvent(WelcomeEvent.NavigateToStartRegistration)
}
private fun handleLoginClick() {
@ -130,11 +121,6 @@ sealed class WelcomeEvent {
val index: Int,
) : WelcomeEvent()
/**
* Navigates to the create account screen.
*/
data object NavigateToCreateAccount : WelcomeEvent()
/**
* Navigates to the login screen.
*/

View File

@ -263,7 +263,7 @@ private fun FeatureFlagContent_preview() {
BitwardenTheme {
FeatureFlagContent(
featureFlagMap = persistentMapOf(
FlagKey.EmailVerification to true,
FlagKey.DummyBoolean to true,
),
onValueChange = { _, _ -> },
onResetValues = { },

View File

@ -26,7 +26,6 @@ fun <T : Any> FlagKey<T>.ListItemContent(
}
FlagKey.BitwardenAuthenticationEnabled,
FlagKey.EmailVerification,
FlagKey.CredentialExchangeProtocolImport,
FlagKey.CredentialExchangeProtocolExport,
FlagKey.CipherKeyEncryption,
@ -74,7 +73,6 @@ private fun <T : Any> FlagKey<T>.getDisplayLabel(): String = when (this) {
FlagKey.DummyString,
-> this.keyName
FlagKey.EmailVerification -> stringResource(BitwardenString.email_verification)
FlagKey.CredentialExchangeProtocolImport -> stringResource(BitwardenString.cxp_import)
FlagKey.CredentialExchangeProtocolExport -> stringResource(BitwardenString.cxp_export)
FlagKey.CipherKeyEncryption -> stringResource(BitwardenString.cipher_key_encryption)

View File

@ -1,11 +1,11 @@
package com.x8bit.bitwarden.data.platform.manager
import app.cash.turbine.test
import com.bitwarden.core.data.manager.model.FlagKey
import com.bitwarden.data.datasource.disk.model.ServerConfig
import com.bitwarden.network.model.ConfigResponseJson
import com.bitwarden.network.model.ConfigResponseJson.EnvironmentJson
import com.bitwarden.network.model.ConfigResponseJson.ServerJson
import com.bitwarden.core.data.manager.model.FlagKey
import com.x8bit.bitwarden.data.platform.repository.util.FakeServerConfigRepository
import com.x8bit.bitwarden.data.platform.util.isServerVersionAtLeast
import kotlinx.coroutines.test.runTest
@ -130,7 +130,7 @@ class FeatureFlagManagerTest {
)
val flagValue = manager.getFeatureFlag(
key = FlagKey.EmailVerification,
key = FlagKey.DummyBoolean,
forceRefresh = false,
)
assertFalse(flagValue)
@ -226,7 +226,7 @@ class FeatureFlagManagerTest {
fakeServerConfigRepository.serverConfigValue = null
val flagValue = manager.getFeatureFlag(
key = FlagKey.EmailVerification,
key = FlagKey.DummyBoolean,
forceRefresh = false,
)

View File

@ -1,6 +1,7 @@
package com.x8bit.bitwarden.data.platform.repository
import app.cash.turbine.test
import com.bitwarden.core.data.manager.model.FlagKey
import com.bitwarden.data.datasource.disk.model.ServerConfig
import com.bitwarden.data.repository.ServerConfigRepository
import com.x8bit.bitwarden.data.auth.datasource.disk.AuthDiskSource
@ -8,7 +9,6 @@ import com.x8bit.bitwarden.data.auth.datasource.disk.model.OnboardingStatus
import com.x8bit.bitwarden.data.auth.datasource.disk.model.UserStateJson
import com.x8bit.bitwarden.data.platform.datasource.disk.FeatureFlagOverrideDiskSource
import com.x8bit.bitwarden.data.platform.datasource.disk.SettingsDiskSource
import com.bitwarden.core.data.manager.model.FlagKey
import io.mockk.every
import io.mockk.just
import io.mockk.mockk
@ -99,8 +99,8 @@ class DebugMenuRepositoryTest {
debugMenuRepository.resetFeatureFlagOverrides()
verify(exactly = 1) {
mockFeatureFlagOverrideDiskSource.saveFeatureFlag(
FlagKey.EmailVerification,
FlagKey.EmailVerification.defaultValue,
FlagKey.CredentialExchangeProtocolImport,
FlagKey.CredentialExchangeProtocolImport.defaultValue,
)
}
debugMenuRepository.featureFlagOverridesUpdatedFlow.test {

View File

@ -1,319 +0,0 @@
package com.x8bit.bitwarden.ui.auth.feature.createaccount
import android.net.Uri
import androidx.compose.ui.test.assertCountEquals
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.assertIsOff
import androidx.compose.ui.test.assertIsOn
import androidx.compose.ui.test.filterToOne
import androidx.compose.ui.test.hasAnyAncestor
import androidx.compose.ui.test.isDialog
import androidx.compose.ui.test.onAllNodesWithContentDescription
import androidx.compose.ui.test.onAllNodesWithText
import androidx.compose.ui.test.onNodeWithContentDescription
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import androidx.compose.ui.test.performScrollTo
import androidx.compose.ui.test.performTextInput
import androidx.core.net.toUri
import com.bitwarden.core.data.repository.util.bufferedMutableSharedFlow
import com.bitwarden.ui.util.asText
import com.bitwarden.ui.util.performCustomAccessibilityAction
import com.x8bit.bitwarden.ui.auth.feature.completeregistration.PasswordStrengthState
import com.x8bit.bitwarden.ui.auth.feature.createaccount.CreateAccountAction.AcceptPoliciesToggle
import com.x8bit.bitwarden.ui.auth.feature.createaccount.CreateAccountAction.CheckDataBreachesToggle
import com.x8bit.bitwarden.ui.auth.feature.createaccount.CreateAccountAction.CloseClick
import com.x8bit.bitwarden.ui.auth.feature.createaccount.CreateAccountAction.ConfirmPasswordInputChange
import com.x8bit.bitwarden.ui.auth.feature.createaccount.CreateAccountAction.EmailInputChange
import com.x8bit.bitwarden.ui.auth.feature.createaccount.CreateAccountAction.PasswordHintChange
import com.x8bit.bitwarden.ui.auth.feature.createaccount.CreateAccountAction.PasswordInputChange
import com.x8bit.bitwarden.ui.auth.feature.createaccount.CreateAccountAction.SubmitClick
import com.x8bit.bitwarden.ui.platform.base.BitwardenComposeTest
import com.x8bit.bitwarden.ui.platform.manager.intent.IntentManager
import io.mockk.every
import io.mockk.just
import io.mockk.mockk
import io.mockk.runs
import io.mockk.verify
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.update
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
class CreateAccountScreenTest : BitwardenComposeTest() {
private var onNavigateBackCalled = false
private var onNavigateToLoginCalled = false
private val intentManager = mockk<IntentManager>(relaxed = true) {
every { startCustomTabsActivity(any()) } just runs
every { startActivity(any()) } just runs
}
private val mutableStateFlow = MutableStateFlow(DEFAULT_STATE)
private val mutableEventFlow = bufferedMutableSharedFlow<CreateAccountEvent>()
private val viewModel = mockk<CreateAccountViewModel>(relaxed = true) {
every { stateFlow } returns mutableStateFlow
every { eventFlow } returns mutableEventFlow
every { trySendAction(any()) } just runs
}
@Before
fun setup() {
setContent(
intentManager = intentManager,
) {
CreateAccountScreen(
onNavigateBack = { onNavigateBackCalled = true },
onNavigateToLogin = { _, _ -> onNavigateToLoginCalled = true },
viewModel = viewModel,
)
}
}
@Test
fun `app bar submit click should send SubmitClick action`() {
composeTestRule.onNodeWithText("Submit").performClick()
verify { viewModel.trySendAction(SubmitClick) }
}
@Test
fun `close click should send CloseClick action`() {
composeTestRule.onNodeWithContentDescription("Close").performClick()
verify { viewModel.trySendAction(CloseClick) }
}
@Test
fun `check data breaches click should send CheckDataBreachesToggle action`() {
composeTestRule
.onNodeWithText("Check known data breaches for this password")
.performScrollTo()
.performClick()
verify { viewModel.trySendAction(CheckDataBreachesToggle(true)) }
}
@Test
fun `accept policies should be toggled on or off according to the state`() {
composeTestRule
.onNodeWithText("By activating this switch, you agree", substring = true)
.assertIsOff()
mutableStateFlow.update { it.copy(isAcceptPoliciesToggled = true) }
composeTestRule
.onNodeWithText("By activating this switch, you agree", substring = true)
.assertIsOn()
}
@Test
fun `accept policies click should send AcceptPoliciesToggle action`() {
composeTestRule
.onNodeWithText("By activating this switch, you agree", substring = true)
.performScrollTo()
.performClick()
verify { viewModel.trySendAction(AcceptPoliciesToggle(true)) }
}
@Test
fun `NavigateBack event should invoke navigate back lambda`() {
mutableEventFlow.tryEmit(CreateAccountEvent.NavigateBack)
assertTrue(onNavigateBackCalled)
}
@Test
fun `NavigateToLogin event should invoke navigate login lambda`() {
mutableEventFlow.tryEmit(CreateAccountEvent.NavigateToLogin(email = "", captchaToken = ""))
assertTrue(onNavigateToLoginCalled)
}
@Test
fun `NavigateToCaptcha event should invoke intent manager`() {
val mockUri = mockk<Uri>()
mutableEventFlow.tryEmit(CreateAccountEvent.NavigateToCaptcha(uri = mockUri))
verify {
intentManager.startCustomTabsActivity(mockUri)
}
}
@Test
fun `NavigateToPrivacyPolicy event should invoke intent manager`() {
mutableEventFlow.tryEmit(CreateAccountEvent.NavigateToPrivacyPolicy)
verify {
intentManager.launchUri("https://bitwarden.com/privacy/".toUri())
}
}
@Test
fun `NavigateToTerms event should invoke intent manager`() {
mutableEventFlow.tryEmit(CreateAccountEvent.NavigateToTerms)
verify {
intentManager.launchUri("https://bitwarden.com/terms/".toUri())
}
}
@Test
fun `email input change should send EmailInputChange action`() {
composeTestRule.onNodeWithText("Email address").performTextInput(TEST_INPUT)
verify { viewModel.trySendAction(EmailInputChange(TEST_INPUT)) }
}
@Test
fun `password input change should send PasswordInputChange action`() {
composeTestRule.onNodeWithText("Master password").performTextInput(TEST_INPUT)
verify { viewModel.trySendAction(PasswordInputChange(TEST_INPUT)) }
}
@Test
fun `confirm password input change should send ConfirmPasswordInputChange action`() {
composeTestRule.onNodeWithText("Re-type master password").performTextInput(TEST_INPUT)
verify { viewModel.trySendAction(ConfirmPasswordInputChange(TEST_INPUT)) }
}
@Test
fun `password hint input change should send PasswordHintChange action`() {
composeTestRule
.onNodeWithText("Master password hint (optional)")
.performTextInput(TEST_INPUT)
verify { viewModel.trySendAction(PasswordHintChange(TEST_INPUT)) }
}
@Test
fun `clicking OK on the error dialog should send ErrorDialogDismiss action`() {
mutableStateFlow.update {
it.copy(
dialog = CreateAccountDialog.Error(
title = "title".asText(),
message = "message".asText(),
),
)
}
composeTestRule
.onAllNodesWithText(text = "Okay")
.filterToOne(hasAnyAncestor(isDialog()))
.performClick()
verify { viewModel.trySendAction(CreateAccountAction.ErrorDialogDismiss) }
}
@Test
fun `clicking No on the HIBP dialog should send ErrorDialogDismiss action`() {
mutableStateFlow.update {
it.copy(dialog = createHaveIBeenPwned())
}
composeTestRule
.onAllNodesWithText("No")
.filterToOne(hasAnyAncestor(isDialog()))
.performClick()
verify { viewModel.trySendAction(CreateAccountAction.ErrorDialogDismiss) }
}
@Test
fun `clicking Yes on the HIBP dialog should send ContinueWithBreachedPasswordClick action`() {
mutableStateFlow.update {
it.copy(dialog = createHaveIBeenPwned())
}
composeTestRule
.onAllNodesWithText("Yes")
.filterToOne(hasAnyAncestor(isDialog()))
.performClick()
verify { viewModel.trySendAction(CreateAccountAction.ContinueWithBreachedPasswordClick) }
}
@Test
fun `when BasicDialogState is Shown should show dialog`() {
mutableStateFlow.update {
it.copy(
dialog = CreateAccountDialog.Error(
title = "title".asText(),
message = "message".asText(),
),
)
}
composeTestRule.onNode(isDialog()).assertIsDisplayed()
}
@Test
fun `password strength should change as state changes`() {
mutableStateFlow.update {
DEFAULT_STATE.copy(passwordStrengthState = PasswordStrengthState.WEAK_1)
}
composeTestRule.onNodeWithText("Weak").assertIsDisplayed()
mutableStateFlow.update {
DEFAULT_STATE.copy(passwordStrengthState = PasswordStrengthState.WEAK_2)
}
composeTestRule.onNodeWithText("Weak").assertIsDisplayed()
mutableStateFlow.update {
DEFAULT_STATE.copy(passwordStrengthState = PasswordStrengthState.WEAK_3)
}
composeTestRule.onNodeWithText("Weak").assertIsDisplayed()
mutableStateFlow.update {
DEFAULT_STATE.copy(passwordStrengthState = PasswordStrengthState.GOOD)
}
composeTestRule.onNodeWithText("Good").assertIsDisplayed()
mutableStateFlow.update {
DEFAULT_STATE.copy(passwordStrengthState = PasswordStrengthState.STRONG)
}
composeTestRule.onNodeWithText("Strong").assertIsDisplayed()
}
@Test
fun `toggling one password field visibility should toggle the other`() {
// should start with 2 Show buttons:
composeTestRule
.onAllNodesWithContentDescription("Show")
.assertCountEquals(2)[0]
.performClick()
// after clicking there should be no Show buttons:
composeTestRule
.onAllNodesWithContentDescription("Show")
.assertCountEquals(0)
// and there should be 2 hide buttons now, and we'll click the second one:
composeTestRule
.onAllNodesWithContentDescription("Hide")
.assertCountEquals(2)[1]
.performClick()
// then there should be two show buttons again
composeTestRule
.onAllNodesWithContentDescription("Show")
.assertCountEquals(2)
}
@Test
fun `terms of service click should send TermsClick action`() {
composeTestRule
.onNodeWithText(text = "Terms of Service", substring = true)
.performScrollTo()
.performCustomAccessibilityAction("Terms of Service")
verify { viewModel.trySendAction(CreateAccountAction.TermsClick) }
}
@Test
fun `privacy policy click should send PrivacyPolicyClick action`() {
composeTestRule
.onNodeWithText(text = "Privacy Policy", substring = true)
.performScrollTo()
.performCustomAccessibilityAction("Privacy Policy")
verify { viewModel.trySendAction(CreateAccountAction.PrivacyPolicyClick) }
}
companion object {
private const val TEST_INPUT = "input"
private val DEFAULT_STATE = CreateAccountState(
emailInput = "",
passwordInput = "",
confirmPasswordInput = "",
passwordHintInput = "",
isCheckDataBreachesToggled = false,
isAcceptPoliciesToggled = false,
dialog = null,
passwordStrengthState = PasswordStrengthState.NONE,
)
}
}

View File

@ -1,19 +0,0 @@
package com.x8bit.bitwarden.ui.auth.feature.createaccount
import com.bitwarden.ui.platform.resource.BitwardenString
import com.bitwarden.ui.util.Text
import com.bitwarden.ui.util.asText
/**
* Creates a mock [CreateAccountDialog.HaveIBeenPwned].
*/
fun createHaveIBeenPwned(
title: Text = BitwardenString.weak_and_exposed_master_password.asText(),
message: Text = BitwardenString
.weak_password_identified_and_found_in_a_data_breach_alert_description
.asText(),
): CreateAccountDialog.HaveIBeenPwned =
CreateAccountDialog.HaveIBeenPwned(
title = title,
message = message,
)

View File

@ -1,708 +0,0 @@
package com.x8bit.bitwarden.ui.auth.feature.createaccount
import android.net.Uri
import androidx.lifecycle.SavedStateHandle
import app.cash.turbine.test
import com.bitwarden.ui.platform.base.BaseViewModelTest
import com.bitwarden.ui.platform.resource.BitwardenString
import com.bitwarden.ui.util.asText
import com.x8bit.bitwarden.data.auth.datasource.sdk.model.PasswordStrength.LEVEL_0
import com.x8bit.bitwarden.data.auth.datasource.sdk.model.PasswordStrength.LEVEL_1
import com.x8bit.bitwarden.data.auth.datasource.sdk.model.PasswordStrength.LEVEL_2
import com.x8bit.bitwarden.data.auth.datasource.sdk.model.PasswordStrength.LEVEL_3
import com.x8bit.bitwarden.data.auth.datasource.sdk.model.PasswordStrength.LEVEL_4
import com.x8bit.bitwarden.data.auth.repository.AuthRepository
import com.x8bit.bitwarden.data.auth.repository.model.PasswordStrengthResult
import com.x8bit.bitwarden.data.auth.repository.model.RegisterResult
import com.x8bit.bitwarden.data.auth.repository.util.generateUriForCaptcha
import com.x8bit.bitwarden.ui.auth.feature.completeregistration.PasswordStrengthState
import com.x8bit.bitwarden.ui.auth.feature.createaccount.CreateAccountAction.AcceptPoliciesToggle
import com.x8bit.bitwarden.ui.auth.feature.createaccount.CreateAccountAction.CloseClick
import com.x8bit.bitwarden.ui.auth.feature.createaccount.CreateAccountAction.ConfirmPasswordInputChange
import com.x8bit.bitwarden.ui.auth.feature.createaccount.CreateAccountAction.EmailInputChange
import com.x8bit.bitwarden.ui.auth.feature.createaccount.CreateAccountAction.Internal.ReceivePasswordStrengthResult
import com.x8bit.bitwarden.ui.auth.feature.createaccount.CreateAccountAction.PasswordHintChange
import com.x8bit.bitwarden.ui.auth.feature.createaccount.CreateAccountAction.PasswordInputChange
import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.every
import io.mockk.mockk
import io.mockk.mockkStatic
import io.mockk.unmockkStatic
import kotlinx.coroutines.flow.emptyFlow
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.AfterEach
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
@Suppress("LargeClass")
class CreateAccountViewModelTest : BaseViewModelTest() {
/**
* Saved state handle that has valid inputs. Useful for tests that want to test things
* after the user has entered all valid inputs.
*/
private val validInputHandle = SavedStateHandle(mapOf("state" to VALID_INPUT_STATE))
private val mockAuthRepository = mockk<AuthRepository> {
every { captchaTokenResultFlow } returns flowOf()
}
@BeforeEach
fun setUp() {
mockkStatic(::generateUriForCaptcha)
}
@AfterEach
fun tearDown() {
unmockkStatic(::generateUriForCaptcha)
}
@Test
fun `initial state should be correct`() {
val viewModel = CreateAccountViewModel(
savedStateHandle = SavedStateHandle(),
authRepository = mockAuthRepository,
)
assertEquals(DEFAULT_STATE, viewModel.stateFlow.value)
}
@Test
fun `initial state should pull from saved state handle when present`() {
val savedState = CreateAccountState(
emailInput = "email",
passwordInput = "password",
confirmPasswordInput = "confirmPassword",
passwordHintInput = "hint",
isCheckDataBreachesToggled = false,
isAcceptPoliciesToggled = false,
dialog = null,
passwordStrengthState = PasswordStrengthState.NONE,
)
val handle = SavedStateHandle(mapOf("state" to savedState))
val viewModel = CreateAccountViewModel(
savedStateHandle = handle,
authRepository = mockAuthRepository,
)
assertEquals(savedState, viewModel.stateFlow.value)
}
@Test
fun `SubmitClick with blank email should show email required`() = runTest {
val viewModel = CreateAccountViewModel(
savedStateHandle = SavedStateHandle(),
authRepository = mockAuthRepository,
)
val input = "a"
viewModel.trySendAction(EmailInputChange(input))
val expectedState = DEFAULT_STATE.copy(
emailInput = input,
dialog = CreateAccountDialog.Error(
title = BitwardenString.an_error_has_occurred.asText(),
message = BitwardenString.invalid_email.asText(),
),
)
viewModel.trySendAction(CreateAccountAction.SubmitClick)
viewModel.stateFlow.test {
assertEquals(expectedState, awaitItem())
}
}
@Test
fun `SubmitClick with invalid email should show invalid email`() = runTest {
val viewModel = CreateAccountViewModel(
savedStateHandle = SavedStateHandle(),
authRepository = mockAuthRepository,
)
val input = " "
viewModel.trySendAction(EmailInputChange(input))
val expectedState = DEFAULT_STATE.copy(
emailInput = input,
dialog = CreateAccountDialog.Error(
title = BitwardenString.an_error_has_occurred.asText(),
message = BitwardenString.validation_field_required
.asText(BitwardenString.email_address.asText()),
),
)
viewModel.trySendAction(CreateAccountAction.SubmitClick)
viewModel.stateFlow.test {
assertEquals(expectedState, awaitItem())
}
}
@Test
fun `SubmitClick with password below 12 chars should show password length dialog`() = runTest {
val input = "abcdefghikl"
coEvery {
mockAuthRepository.getPasswordStrength("test@test.com", input)
} returns PasswordStrengthResult.Error(error = Throwable("Fail!"))
val viewModel = CreateAccountViewModel(
savedStateHandle = SavedStateHandle(),
authRepository = mockAuthRepository,
)
viewModel.trySendAction(EmailInputChange(EMAIL))
viewModel.trySendAction(PasswordInputChange(input))
val expectedState = DEFAULT_STATE.copy(
emailInput = EMAIL,
passwordInput = input,
dialog = CreateAccountDialog.Error(
title = BitwardenString.an_error_has_occurred.asText(),
message = BitwardenString.master_password_length_val_message_x.asText(12),
),
)
viewModel.trySendAction(CreateAccountAction.SubmitClick)
viewModel.stateFlow.test {
assertEquals(expectedState, awaitItem())
}
}
@Test
fun `SubmitClick with passwords not matching should show password match dialog`() = runTest {
val input = "testtesttesttest"
coEvery {
mockAuthRepository.getPasswordStrength("test@test.com", input)
} returns PasswordStrengthResult.Error(error = Throwable("Fail!"))
val viewModel = CreateAccountViewModel(
savedStateHandle = SavedStateHandle(),
authRepository = mockAuthRepository,
)
viewModel.trySendAction(EmailInputChange("test@test.com"))
viewModel.trySendAction(PasswordInputChange(input))
val expectedState = DEFAULT_STATE.copy(
emailInput = "test@test.com",
passwordInput = input,
dialog = CreateAccountDialog.Error(
title = BitwardenString.an_error_has_occurred.asText(),
message = BitwardenString.master_password_confirmation_val_message.asText(),
),
)
viewModel.trySendAction(CreateAccountAction.SubmitClick)
viewModel.stateFlow.test {
assertEquals(expectedState, awaitItem())
}
}
@Test
fun `SubmitClick without policies accepted should show accept policies error`() = runTest {
val password = "testtesttesttest"
coEvery {
mockAuthRepository.getPasswordStrength("test@test.com", password)
} returns PasswordStrengthResult.Error(error = Throwable("Fail!"))
val viewModel = CreateAccountViewModel(
savedStateHandle = SavedStateHandle(),
authRepository = mockAuthRepository,
)
viewModel.trySendAction(EmailInputChange("test@test.com"))
viewModel.trySendAction(PasswordInputChange(password))
viewModel.trySendAction(ConfirmPasswordInputChange(password))
val expectedState = DEFAULT_STATE.copy(
emailInput = "test@test.com",
passwordInput = password,
confirmPasswordInput = password,
dialog = CreateAccountDialog.Error(
title = BitwardenString.an_error_has_occurred.asText(),
message = BitwardenString.accept_policies_error.asText(),
),
)
viewModel.trySendAction(CreateAccountAction.SubmitClick)
viewModel.stateFlow.test {
assertEquals(expectedState, awaitItem())
}
}
@Test
fun `SubmitClick with all inputs valid should show and hide loading dialog`() = runTest {
val repo = mockk<AuthRepository> {
every { captchaTokenResultFlow } returns flowOf()
coEvery {
register(
email = EMAIL,
masterPassword = PASSWORD,
masterPasswordHint = null,
captchaToken = null,
shouldCheckDataBreaches = false,
isMasterPasswordStrong = true,
)
} returns RegisterResult.Success(captchaToken = "mock_token")
}
val viewModel = CreateAccountViewModel(
savedStateHandle = validInputHandle,
authRepository = repo,
)
viewModel.stateEventFlow(backgroundScope) { stateFlow, eventFlow ->
assertEquals(VALID_INPUT_STATE, stateFlow.awaitItem())
viewModel.trySendAction(CreateAccountAction.SubmitClick)
assertEquals(
VALID_INPUT_STATE.copy(dialog = CreateAccountDialog.Loading),
stateFlow.awaitItem(),
)
assertEquals(
CreateAccountEvent.NavigateToLogin(
email = EMAIL,
captchaToken = "mock_token",
),
eventFlow.awaitItem(),
)
// Make sure loading dialog is hidden:
assertEquals(VALID_INPUT_STATE, stateFlow.awaitItem())
}
}
@Test
fun `SubmitClick register returns error should update errorDialogState`() = runTest {
val error = Throwable("Fail!")
val repo = mockk<AuthRepository> {
every { captchaTokenResultFlow } returns flowOf()
coEvery {
register(
email = EMAIL,
masterPassword = PASSWORD,
masterPasswordHint = null,
captchaToken = null,
shouldCheckDataBreaches = false,
isMasterPasswordStrong = true,
)
} returns RegisterResult.Error(errorMessage = "mock_error", error = error)
}
val viewModel = CreateAccountViewModel(
savedStateHandle = validInputHandle,
authRepository = repo,
)
viewModel.stateFlow.test {
assertEquals(VALID_INPUT_STATE, awaitItem())
viewModel.trySendAction(CreateAccountAction.SubmitClick)
assertEquals(
VALID_INPUT_STATE.copy(dialog = CreateAccountDialog.Loading),
awaitItem(),
)
assertEquals(
VALID_INPUT_STATE.copy(
dialog = CreateAccountDialog.Error(
title = BitwardenString.an_error_has_occurred.asText(),
message = "mock_error".asText(),
error = error,
),
),
awaitItem(),
)
}
}
@Test
fun `SubmitClick register returns CaptchaRequired should emit NavigateToCaptcha`() = runTest {
val mockkUri = mockk<Uri>()
every {
generateUriForCaptcha(captchaId = "mock_captcha_id")
} returns mockkUri
val repo = mockk<AuthRepository> {
every { captchaTokenResultFlow } returns flowOf()
coEvery {
register(
email = EMAIL,
masterPassword = PASSWORD,
masterPasswordHint = null,
captchaToken = null,
shouldCheckDataBreaches = false,
isMasterPasswordStrong = true,
)
} returns RegisterResult.CaptchaRequired(captchaId = "mock_captcha_id")
}
val viewModel = CreateAccountViewModel(
savedStateHandle = validInputHandle,
authRepository = repo,
)
viewModel.eventFlow.test {
viewModel.trySendAction(CreateAccountAction.SubmitClick)
assertEquals(
CreateAccountEvent.NavigateToCaptcha(uri = mockkUri),
awaitItem(),
)
}
}
@Test
fun `SubmitClick register returns Success should emit NavigateToLogin`() = runTest {
val mockkUri = mockk<Uri>()
every {
generateUriForCaptcha(captchaId = "mock_captcha_id")
} returns mockkUri
val repo = mockk<AuthRepository> {
every { captchaTokenResultFlow } returns flowOf()
coEvery {
register(
email = EMAIL,
masterPassword = PASSWORD,
masterPasswordHint = null,
captchaToken = null,
shouldCheckDataBreaches = false,
isMasterPasswordStrong = true,
)
} returns RegisterResult.Success(captchaToken = "mock_captcha_token")
}
val viewModel = CreateAccountViewModel(
savedStateHandle = validInputHandle,
authRepository = repo,
)
viewModel.eventFlow.test {
viewModel.trySendAction(CreateAccountAction.SubmitClick)
assertEquals(
CreateAccountEvent.NavigateToLogin(
email = EMAIL,
captchaToken = "mock_captcha_token",
),
awaitItem(),
)
}
}
@Test
fun `ContinueWithBreachedPasswordClick should call repository with checkDataBreaches false`() {
val repo = mockk<AuthRepository> {
every { captchaTokenResultFlow } returns flowOf()
coEvery {
register(
email = EMAIL,
masterPassword = PASSWORD,
masterPasswordHint = null,
captchaToken = null,
shouldCheckDataBreaches = false,
isMasterPasswordStrong = true,
)
} returns RegisterResult.Error(errorMessage = null, error = null)
}
val viewModel = CreateAccountViewModel(
savedStateHandle = validInputHandle,
authRepository = repo,
)
viewModel.trySendAction(CreateAccountAction.ContinueWithBreachedPasswordClick)
coVerify {
repo.register(
email = EMAIL,
masterPassword = PASSWORD,
masterPasswordHint = null,
captchaToken = null,
shouldCheckDataBreaches = false,
isMasterPasswordStrong = true,
)
}
}
@Test
fun `SubmitClick register returns ShowDataBreaches should show HaveIBeenPwned dialog`() =
runTest {
mockAuthRepository.apply {
every { captchaTokenResultFlow } returns flowOf()
coEvery {
register(
email = EMAIL,
masterPassword = PASSWORD,
masterPasswordHint = null,
captchaToken = null,
shouldCheckDataBreaches = true,
isMasterPasswordStrong = true,
)
} returns RegisterResult.DataBreachFound
}
val initialState = VALID_INPUT_STATE.copy(
isCheckDataBreachesToggled = true,
)
val viewModel = createCreateAccountViewModel(createAccountState = initialState)
viewModel.trySendAction(CreateAccountAction.SubmitClick)
viewModel.stateFlow.test {
assertEquals(
initialState.copy(
dialog = createHaveIBeenPwned(
title = BitwardenString.exposed_master_password.asText(),
message = BitwardenString
.password_found_in_a_data_breach_alert_description
.asText(),
),
),
awaitItem(),
)
}
}
@Test
@Suppress("MaxLineLength")
fun `SubmitClick register returns DataBreachAndWeakPassword should show HaveIBeenPwned dialog`() =
runTest {
mockAuthRepository.apply {
every { captchaTokenResultFlow } returns emptyFlow()
coEvery {
register(
email = EMAIL,
masterPassword = PASSWORD,
masterPasswordHint = null,
captchaToken = null,
shouldCheckDataBreaches = true,
isMasterPasswordStrong = false,
)
} returns RegisterResult.DataBreachAndWeakPassword
}
val initialState = VALID_INPUT_STATE.copy(
passwordStrengthState = PasswordStrengthState.WEAK_1,
isCheckDataBreachesToggled = true,
)
val viewModel = createCreateAccountViewModel(createAccountState = initialState)
viewModel.trySendAction(CreateAccountAction.SubmitClick)
viewModel.stateFlow.test {
assertEquals(
initialState.copy(dialog = createHaveIBeenPwned()),
awaitItem(),
)
}
}
@Test
@Suppress("MaxLineLength")
fun `SubmitClick register returns WeakPassword should show HaveIBeenPwned dialog`() =
runTest {
mockAuthRepository.apply {
every { captchaTokenResultFlow } returns flowOf()
coEvery {
register(
email = EMAIL,
masterPassword = PASSWORD,
masterPasswordHint = null,
captchaToken = null,
shouldCheckDataBreaches = true,
isMasterPasswordStrong = false,
)
} returns RegisterResult.WeakPassword
}
val initialState = VALID_INPUT_STATE
.copy(
passwordStrengthState = PasswordStrengthState.WEAK_1,
isCheckDataBreachesToggled = true,
)
val viewModel = createCreateAccountViewModel(createAccountState = initialState)
viewModel.trySendAction(CreateAccountAction.SubmitClick)
viewModel.stateFlow.test {
assertEquals(
initialState.copy(
dialog = createHaveIBeenPwned(
title = BitwardenString.weak_master_password.asText(),
message = BitwardenString.weak_password_identified_use_a_strong_password_to_protect_your_account.asText(),
),
),
awaitItem(),
)
}
}
@Test
fun `CloseClick should emit NavigateBack`() = runTest {
val viewModel = CreateAccountViewModel(
savedStateHandle = SavedStateHandle(),
authRepository = mockAuthRepository,
)
viewModel.eventFlow.test {
viewModel.trySendAction(CloseClick)
assertEquals(CreateAccountEvent.NavigateBack, awaitItem())
}
}
@Test
fun `PrivacyPolicyClick should emit NavigatePrivacyPolicy`() = runTest {
val viewModel = CreateAccountViewModel(
savedStateHandle = SavedStateHandle(),
authRepository = mockAuthRepository,
)
viewModel.eventFlow.test {
viewModel.trySendAction(CreateAccountAction.PrivacyPolicyClick)
assertEquals(CreateAccountEvent.NavigateToPrivacyPolicy, awaitItem())
}
}
@Test
fun `TermsClick should emit NavigateToTerms`() = runTest {
val viewModel = CreateAccountViewModel(
savedStateHandle = SavedStateHandle(),
authRepository = mockAuthRepository,
)
viewModel.eventFlow.test {
viewModel.trySendAction(CreateAccountAction.TermsClick)
assertEquals(CreateAccountEvent.NavigateToTerms, awaitItem())
}
}
@Test
fun `ConfirmPasswordInputChange update passwordInput`() = runTest {
val viewModel = CreateAccountViewModel(
savedStateHandle = SavedStateHandle(),
authRepository = mockAuthRepository,
)
viewModel.trySendAction(ConfirmPasswordInputChange("input"))
viewModel.stateFlow.test {
assertEquals(DEFAULT_STATE.copy(confirmPasswordInput = "input"), awaitItem())
}
}
@Test
fun `EmailInputChange update passwordInput`() = runTest {
val viewModel = CreateAccountViewModel(
savedStateHandle = SavedStateHandle(),
authRepository = mockAuthRepository,
)
viewModel.trySendAction(EmailInputChange("input"))
viewModel.stateFlow.test {
assertEquals(DEFAULT_STATE.copy(emailInput = "input"), awaitItem())
}
}
@Test
fun `PasswordHintChange update passwordInput`() = runTest {
val viewModel = CreateAccountViewModel(
savedStateHandle = SavedStateHandle(),
authRepository = mockAuthRepository,
)
viewModel.trySendAction(PasswordHintChange("input"))
viewModel.stateFlow.test {
assertEquals(DEFAULT_STATE.copy(passwordHintInput = "input"), awaitItem())
}
}
@Test
fun `PasswordInputChange update passwordInput and call getPasswordStrength`() = runTest {
coEvery {
mockAuthRepository.getPasswordStrength("", "input")
} returns PasswordStrengthResult.Error(error = Throwable("Fail!"))
val viewModel = CreateAccountViewModel(
savedStateHandle = SavedStateHandle(),
authRepository = mockAuthRepository,
)
viewModel.trySendAction(PasswordInputChange("input"))
viewModel.stateFlow.test {
assertEquals(DEFAULT_STATE.copy(passwordInput = "input"), awaitItem())
}
coVerify { mockAuthRepository.getPasswordStrength("", "input") }
}
@Test
fun `CheckDataBreachesToggle should change isCheckDataBreachesToggled`() = runTest {
val viewModel = CreateAccountViewModel(
savedStateHandle = SavedStateHandle(),
authRepository = mockAuthRepository,
)
viewModel.trySendAction(CreateAccountAction.CheckDataBreachesToggle(true))
viewModel.stateFlow.test {
assertEquals(DEFAULT_STATE.copy(isCheckDataBreachesToggled = true), awaitItem())
}
}
@Test
fun `AcceptPoliciesToggle should change isAcceptPoliciesToggled`() = runTest {
val viewModel = CreateAccountViewModel(
savedStateHandle = SavedStateHandle(),
authRepository = mockAuthRepository,
)
viewModel.trySendAction(AcceptPoliciesToggle(true))
viewModel.stateFlow.test {
assertEquals(DEFAULT_STATE.copy(isAcceptPoliciesToggled = true), awaitItem())
}
}
@Test
fun `ReceivePasswordStrengthResult should update password strength state`() = runTest {
val viewModel = CreateAccountViewModel(
savedStateHandle = SavedStateHandle(),
authRepository = mockAuthRepository,
)
viewModel.stateFlow.test {
assertEquals(
DEFAULT_STATE.copy(
passwordStrengthState = PasswordStrengthState.NONE,
),
awaitItem(),
)
viewModel.trySendAction(
ReceivePasswordStrengthResult(PasswordStrengthResult.Success(LEVEL_0)),
)
assertEquals(
DEFAULT_STATE.copy(
passwordStrengthState = PasswordStrengthState.WEAK_1,
),
awaitItem(),
)
viewModel.trySendAction(
ReceivePasswordStrengthResult(PasswordStrengthResult.Success(LEVEL_1)),
)
assertEquals(
DEFAULT_STATE.copy(
passwordStrengthState = PasswordStrengthState.WEAK_2,
),
awaitItem(),
)
viewModel.trySendAction(
ReceivePasswordStrengthResult(PasswordStrengthResult.Success(LEVEL_2)),
)
assertEquals(
DEFAULT_STATE.copy(
passwordStrengthState = PasswordStrengthState.WEAK_3,
),
awaitItem(),
)
viewModel.trySendAction(
ReceivePasswordStrengthResult(PasswordStrengthResult.Success(LEVEL_3)),
)
assertEquals(
DEFAULT_STATE.copy(
passwordStrengthState = PasswordStrengthState.GOOD,
),
awaitItem(),
)
viewModel.trySendAction(
ReceivePasswordStrengthResult(PasswordStrengthResult.Success(LEVEL_4)),
)
assertEquals(
DEFAULT_STATE.copy(
passwordStrengthState = PasswordStrengthState.STRONG,
),
awaitItem(),
)
}
}
private fun createCreateAccountViewModel(
createAccountState: CreateAccountState? = null,
authRepository: AuthRepository = mockAuthRepository,
): CreateAccountViewModel =
CreateAccountViewModel(
savedStateHandle = SavedStateHandle(mapOf("state" to createAccountState)),
authRepository = authRepository,
)
companion object {
private const val PASSWORD = "longenoughtpassword"
private const val EMAIL = "test@test.com"
private val DEFAULT_STATE = CreateAccountState(
passwordInput = "",
emailInput = "",
confirmPasswordInput = "",
passwordHintInput = "",
isCheckDataBreachesToggled = true,
isAcceptPoliciesToggled = false,
dialog = null,
passwordStrengthState = PasswordStrengthState.NONE,
)
private val VALID_INPUT_STATE = CreateAccountState(
passwordInput = PASSWORD,
emailInput = EMAIL,
confirmPasswordInput = PASSWORD,
passwordHintInput = "",
isCheckDataBreachesToggled = false,
isAcceptPoliciesToggled = true,
dialog = null,
passwordStrengthState = PasswordStrengthState.GOOD,
)
}
}

View File

@ -47,7 +47,6 @@ import org.junit.jupiter.api.Assertions.assertTrue
class LandingScreenTest : BitwardenComposeTest() {
private var capturedEmail: String? = null
private var onNavigateToCreateAccountCalled = false
private var onNavigateToLoginCalled = false
private var onNavigateToEnvironmentCalled = false
private var onNavigateToStartRegistrationCalled = false
@ -64,7 +63,6 @@ class LandingScreenTest : BitwardenComposeTest() {
fun setUp() {
setContent {
LandingScreen(
onNavigateToCreateAccount = { onNavigateToCreateAccountCalled = true },
onNavigateToLogin = { capturedEmail ->
this.capturedEmail = capturedEmail
onNavigateToLoginCalled = true
@ -316,12 +314,6 @@ class LandingScreenTest : BitwardenComposeTest() {
}
}
@Test
fun `NavigateToCreateAccount event should call onNavigateToCreateAccount`() {
mutableEventFlow.tryEmit(LandingEvent.NavigateToCreateAccount)
assertTrue(onNavigateToCreateAccountCalled)
}
@Test
fun `NavigateToLogin event should call onNavigateToLogin`() {
val testEmail = "test@test.com"

View File

@ -12,9 +12,7 @@ import com.x8bit.bitwarden.data.auth.repository.AuthRepository
import com.x8bit.bitwarden.data.auth.repository.model.LogoutReason
import com.x8bit.bitwarden.data.auth.repository.model.UserState
import com.x8bit.bitwarden.data.auth.repository.model.VaultUnlockType
import com.x8bit.bitwarden.data.platform.manager.FeatureFlagManager
import com.x8bit.bitwarden.data.platform.manager.model.FirstTimeState
import com.bitwarden.core.data.manager.model.FlagKey
import com.x8bit.bitwarden.data.platform.repository.util.FakeEnvironmentRepository
import com.x8bit.bitwarden.data.vault.repository.VaultRepository
import com.x8bit.bitwarden.ui.platform.components.model.AccountSummary
@ -49,9 +47,6 @@ class LandingViewModelTest : BaseViewModelTest() {
getSnackbarDataFlow(SnackbarRelay.ENVIRONMENT_SAVED)
} returns mutableSnackbarSharedFlow
}
private val featureFlagManager: FeatureFlagManager = mockk(relaxed = true) {
every { getFeatureFlag(FlagKey.EmailVerification) } returns false
}
@Test
fun `initial state should be correct when there is no remembered email`() = runTest {
@ -405,31 +400,17 @@ class LandingViewModelTest : BaseViewModelTest() {
}
@Test
fun `CreateAccountClick should emit NavigateToCreateAccount`() = runTest {
fun `CreateAccountClick should emit NavigateToStartRegistration`() = runTest {
val viewModel = createViewModel()
viewModel.eventFlow.test {
viewModel.trySendAction(LandingAction.CreateAccountClick)
assertEquals(
LandingEvent.NavigateToCreateAccount,
LandingEvent.NavigateToStartRegistration,
awaitItem(),
)
}
}
@Test
fun `When feature is enabled CreateAccountClick should emit NavigateToStartRegistration`() =
runTest {
every { featureFlagManager.getFeatureFlag(FlagKey.EmailVerification) } returns true
val viewModel = createViewModel()
viewModel.eventFlow.test {
viewModel.trySendAction(LandingAction.CreateAccountClick)
assertEquals(
LandingEvent.NavigateToStartRegistration,
awaitItem(),
)
}
}
@Test
fun `DialogDismiss should clear the active dialog`() {
val initialState = DEFAULT_STATE.copy(
@ -623,7 +604,6 @@ class LandingViewModelTest : BaseViewModelTest() {
},
vaultRepository = vaultRepository,
environmentRepository = fakeEnvironmentRepository,
featureFlagManager = featureFlagManager,
snackbarRelayManager = snackbarRelayManager,
savedStateHandle = savedStateHandle,
)

View File

@ -17,7 +17,6 @@ import org.robolectric.annotation.Config
class WelcomeScreenTest : BitwardenComposeTest() {
private var onNavigateToStartRegistrationCalled = false
private var onNavigateToCreateAccountCalled = false
private var onNavigateToLoginCalled = false
private val mutableStateFlow = MutableStateFlow(DEFAULT_STATE)
private val mutableEventFlow = bufferedMutableSharedFlow<WelcomeEvent>()
@ -30,7 +29,6 @@ class WelcomeScreenTest : BitwardenComposeTest() {
fun setUp() {
setContent {
WelcomeScreen(
onNavigateToCreateAccount = { onNavigateToCreateAccountCalled = true },
onNavigateToLogin = { onNavigateToLoginCalled = true },
onNavigateToStartRegistration = { onNavigateToStartRegistrationCalled = true },
viewModel = viewModel,
@ -85,12 +83,6 @@ class WelcomeScreenTest : BitwardenComposeTest() {
.assertIsDisplayed()
}
@Test
fun `NavigateToCreateAccount event should call onNavigateToCreateAccount`() {
mutableEventFlow.tryEmit(WelcomeEvent.NavigateToCreateAccount)
assertTrue(onNavigateToCreateAccountCalled)
}
@Test
fun `NavigateToLogin event should call onNavigateToLogin`() {
mutableEventFlow.tryEmit(WelcomeEvent.NavigateToLogin)

View File

@ -2,21 +2,15 @@ package com.x8bit.bitwarden.ui.auth.feature.welcome
import app.cash.turbine.test
import com.bitwarden.ui.platform.base.BaseViewModelTest
import com.x8bit.bitwarden.data.platform.manager.FeatureFlagManager
import com.bitwarden.core.data.manager.model.FlagKey
import io.mockk.every
import io.mockk.mockk
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Test
class WelcomeViewModelTest : BaseViewModelTest() {
private val featureFlagManager = mockk<FeatureFlagManager>()
@Test
fun `initial state should be correct`() = runTest {
val viewModel = WelcomeViewModel(featureFlagManager = featureFlagManager)
val viewModel = createViewModel()
viewModel.stateFlow.test {
assertEquals(
@ -28,7 +22,7 @@ class WelcomeViewModelTest : BaseViewModelTest() {
@Test
fun `PagerSwipe should update state`() = runTest {
val viewModel = WelcomeViewModel(featureFlagManager = featureFlagManager)
val viewModel = createViewModel()
val newIndex = 2
viewModel.trySendAction(WelcomeAction.PagerSwipe(index = newIndex))
@ -43,7 +37,7 @@ class WelcomeViewModelTest : BaseViewModelTest() {
@Test
fun `DotClick should update state and emit UpdatePager`() = runTest {
val viewModel = WelcomeViewModel(featureFlagManager = featureFlagManager)
val viewModel = createViewModel()
val newIndex = 2
viewModel.trySendAction(WelcomeAction.DotClick(index = newIndex))
@ -62,41 +56,22 @@ class WelcomeViewModelTest : BaseViewModelTest() {
}
}
@Suppress("MaxLineLength")
@Test
fun `CreateAccountClick should emit NavigateToCreateAccount when email verification is disabled`() =
runTest {
val viewModel = WelcomeViewModel(featureFlagManager = featureFlagManager)
every { featureFlagManager.getFeatureFlag(FlagKey.EmailVerification) } returns false
viewModel.trySendAction(WelcomeAction.CreateAccountClick)
fun `CreateAccountClick should emit NavigateToStartRegistration`() = runTest {
val viewModel = createViewModel()
viewModel.trySendAction(WelcomeAction.CreateAccountClick)
viewModel.eventFlow.test {
assertEquals(
WelcomeEvent.NavigateToCreateAccount,
awaitItem(),
)
}
}
@Suppress("MaxLineLength")
@Test
fun `CreateAccountClick should emit NavigateToStartRegistration when email verification is enabled`() =
runTest {
val viewModel = WelcomeViewModel(featureFlagManager = featureFlagManager)
every { featureFlagManager.getFeatureFlag(FlagKey.EmailVerification) } returns true
viewModel.trySendAction(WelcomeAction.CreateAccountClick)
viewModel.eventFlow.test {
assertEquals(
WelcomeEvent.NavigateToStartRegistration,
awaitItem(),
)
}
viewModel.eventFlow.test {
assertEquals(
WelcomeEvent.NavigateToStartRegistration,
awaitItem(),
)
}
}
@Test
fun `LoginClick should emit NavigateToLogin`() = runTest {
val viewModel = WelcomeViewModel(featureFlagManager = featureFlagManager)
val viewModel = createViewModel()
viewModel.trySendAction(WelcomeAction.LoginClick)
@ -107,6 +82,8 @@ class WelcomeViewModelTest : BaseViewModelTest() {
)
}
}
private fun createViewModel(): WelcomeViewModel = WelcomeViewModel()
}
private val DEFAULT_STATE = WelcomeState(

View File

@ -5,8 +5,8 @@ import androidx.compose.ui.test.onNodeWithContentDescription
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import androidx.compose.ui.test.performScrollTo
import com.bitwarden.core.data.repository.util.bufferedMutableSharedFlow
import com.bitwarden.core.data.manager.model.FlagKey
import com.bitwarden.core.data.repository.util.bufferedMutableSharedFlow
import com.x8bit.bitwarden.ui.platform.base.BitwardenComposeTest
import io.mockk.every
import io.mockk.mockk
@ -85,13 +85,13 @@ class DebugMenuScreenTest : BitwardenComposeTest() {
mutableStateFlow.tryEmit(
DebugMenuState(
featureFlags = persistentMapOf(
FlagKey.EmailVerification to true,
FlagKey.CredentialExchangeProtocolImport to true,
),
),
)
composeTestRule
.onNodeWithText("Email Verification", ignoreCase = true)
.onNodeWithText("CXP Import", ignoreCase = true)
.assertExists()
}
@ -100,18 +100,18 @@ class DebugMenuScreenTest : BitwardenComposeTest() {
mutableStateFlow.tryEmit(
DebugMenuState(
featureFlags = persistentMapOf(
FlagKey.EmailVerification to true,
FlagKey.CredentialExchangeProtocolImport to true,
),
),
)
composeTestRule
.onNodeWithText("Email Verification", ignoreCase = true)
.onNodeWithText("CXP Import", ignoreCase = true)
.performClick()
verify(exactly = 1) {
viewModel.trySendAction(
DebugMenuAction.UpdateFeatureFlag(
FlagKey.EmailVerification,
FlagKey.CredentialExchangeProtocolImport,
false,
),
)

View File

@ -1,11 +1,11 @@
package com.x8bit.bitwarden.ui.platform.feature.debugmenu
import app.cash.turbine.test
import com.bitwarden.core.data.manager.model.FlagKey
import com.bitwarden.ui.platform.base.BaseViewModelTest
import com.x8bit.bitwarden.data.auth.repository.AuthRepository
import com.x8bit.bitwarden.data.platform.manager.FeatureFlagManager
import com.x8bit.bitwarden.data.platform.manager.LogsManager
import com.bitwarden.core.data.manager.model.FlagKey
import com.x8bit.bitwarden.data.platform.repository.DebugMenuRepository
import com.x8bit.bitwarden.data.util.assertCoroutineThrows
import io.mockk.coEvery
@ -101,10 +101,10 @@ class DebugMenuViewModelTest : BaseViewModelTest() {
fun `handleUpdateFeatureFlag should update the feature flag via the repository`() {
val viewModel = createViewModel()
viewModel.trySendAction(
DebugMenuAction.UpdateFeatureFlag(FlagKey.EmailVerification, false),
DebugMenuAction.UpdateFeatureFlag(FlagKey.CipherKeyEncryption, false),
)
verify(exactly = 1) {
mockDebugMenuRepository.updateFeatureFlag(FlagKey.EmailVerification, false)
mockDebugMenuRepository.updateFeatureFlag(FlagKey.CipherKeyEncryption, false)
}
}
@ -144,7 +144,6 @@ class DebugMenuViewModelTest : BaseViewModelTest() {
}
private val DEFAULT_MAP_VALUE: ImmutableMap<FlagKey<Any>, Any> = persistentMapOf(
FlagKey.EmailVerification to true,
FlagKey.CredentialExchangeProtocolImport to true,
FlagKey.CredentialExchangeProtocolExport to true,
FlagKey.RestrictCipherItemDeletion to true,
@ -153,7 +152,6 @@ private val DEFAULT_MAP_VALUE: ImmutableMap<FlagKey<Any>, Any> = persistentMapOf
)
private val UPDATED_MAP_VALUE: ImmutableMap<FlagKey<Any>, Any> = persistentMapOf(
FlagKey.EmailVerification to false,
FlagKey.CredentialExchangeProtocolImport to false,
FlagKey.CredentialExchangeProtocolExport to false,
FlagKey.RestrictCipherItemDeletion to false,

View File

@ -26,7 +26,6 @@ fun <T : Any> FlagKey<T>.ListItemContent(
FlagKey.CipherKeyEncryption,
FlagKey.CredentialExchangeProtocolExport,
FlagKey.CredentialExchangeProtocolImport,
FlagKey.EmailVerification,
FlagKey.RemoveCardPolicy,
FlagKey.RestrictCipherItemDeletion,
FlagKey.UserManagedPrivilegedApps,
@ -67,7 +66,6 @@ private fun <T : Any> FlagKey<T>.getDisplayLabel(): String = when (this) {
FlagKey.DummyString,
-> this.keyName
FlagKey.EmailVerification -> stringResource(BitwardenString.email_verification)
FlagKey.CredentialExchangeProtocolImport -> stringResource(BitwardenString.cxp_import)
FlagKey.CredentialExchangeProtocolExport -> stringResource(BitwardenString.cxp_export)
FlagKey.CipherKeyEncryption -> stringResource(BitwardenString.cipher_key_encryption)

View File

@ -30,7 +30,6 @@ sealed class FlagKey<out T : Any> {
*/
val activePasswordManagerFlags: List<FlagKey<*>> by lazy {
listOf(
EmailVerification,
CredentialExchangeProtocolImport,
CredentialExchangeProtocolExport,
RestrictCipherItemDeletion,
@ -40,14 +39,6 @@ sealed class FlagKey<out T : Any> {
}
}
/**
* Data object holding the key for Email Verification feature.
*/
data object EmailVerification : FlagKey<Boolean>() {
override val keyName: String = "email-verification"
override val defaultValue: Boolean = false
}
/**
* Data object holding hte feature flag key for the Credential Exchange Protocol (CXP) import
* feature.

View File

@ -8,10 +8,6 @@ class FlagKeyTest {
@Test
fun `Feature flags have the correct key name set`() {
assertEquals(
FlagKey.EmailVerification.keyName,
"email-verification",
)
assertEquals(
FlagKey.CredentialExchangeProtocolImport.keyName,
"cxp-import-mobile",
@ -46,7 +42,6 @@ class FlagKeyTest {
fun `All feature flags have the correct default value set`() {
assertTrue(
listOf(
FlagKey.EmailVerification,
FlagKey.CredentialExchangeProtocolImport,
FlagKey.CredentialExchangeProtocolExport,
FlagKey.CipherKeyEncryption,

View File

@ -420,7 +420,6 @@ Scanning will happen automatically.</string>
<string name="master_password_policy_validation_title">Invalid password</string>
<string name="master_password_policy_validation_message">Password does not meet organization requirements. Please check the policy information and try again.</string>
<string name="loading">Loading</string>
<string name="accept_policies_error">Terms of Service and Privacy Policy have not been acknowledged.</string>
<string name="terms_of_service">Terms of Service</string>
<string name="privacy_policy">Privacy Policy</string>
<string name="passkey_management">Passkey management</string>
@ -609,8 +608,6 @@ Do you want to switch to this account?</string>
<string name="language">Language</string>
<string name="language_change_x_description">The language has been changed to %1$s. Please restart the app to see the change</string>
<string name="default_system">Default (System)</string>
<string name="important">Important</string>
<string name="your_master_password_cannot_be_recovered_if_you_forget_it_x_characters_minimum">Your master password cannot be recovered if you forget it! %1$s characters minimum.</string>
<string name="weak_master_password">Weak Master Password</string>
<string name="weak_password_identified_use_a_strong_password_to_protect_your_account">Weak password identified. Use a strong password to protect your account. Are you sure you want to use a weak password?</string>
<string name="weak">Weak</string>
@ -707,7 +704,6 @@ Do you want to switch to this account?</string>
<string name="create_account_on_with_colon">Create account on:</string>
<string name="we_sent_an_email_to">We sent an email to <annotation emphasis="bold"><annotation arg="0">%1$s</annotation></annotation>.</string>
<string name="by_continuing_you_agree_to_the_terms_of_service_and_privacy_policy">By continuing, you agree to the <annotation link="termsOfService">Terms of Service</annotation> and <annotation link="privacyPolicy">Privacy Policy</annotation></string>
<string name="by_activating_this_switch_you_agree_to_the_terms_of_service_and_privacy_policy">By activating this switch, you agree to the <annotation link="termsOfService">Terms of Service</annotation> and <annotation link="privacyPolicy">Privacy Policy</annotation></string>
<string name="unsubscribe">Unsubscribe</string>
<string name="check_your_email">Check your email</string>
<string name="open_email_app">Open email app</string>

View File

@ -8,7 +8,6 @@
<string name="json_extension_formatted" translatable="false">.json (%1$s)</string>
<!-- region Debug Menu -->
<string name="email_verification">Email Verification</string>
<string name="feature_flags">Feature Flags:</string>
<string name="debug_menu">Debug Menu</string>
<string name="reset_values">Reset values</string>