diff --git a/app/src/main/java/com/x8bit/bitwarden/data/auth/repository/AuthRepository.kt b/app/src/main/java/com/x8bit/bitwarden/data/auth/repository/AuthRepository.kt index 4543572926..ad91264459 100644 --- a/app/src/main/java/com/x8bit/bitwarden/data/auth/repository/AuthRepository.kt +++ b/app/src/main/java/com/x8bit/bitwarden/data/auth/repository/AuthRepository.kt @@ -1,5 +1,6 @@ package com.x8bit.bitwarden.data.auth.repository +import com.x8bit.bitwarden.data.auth.datasource.sdk.model.PasswordStrength import com.x8bit.bitwarden.data.auth.repository.model.AuthState import com.x8bit.bitwarden.data.auth.repository.model.LoginResult import com.x8bit.bitwarden.data.auth.repository.model.RegisterResult @@ -57,4 +58,9 @@ interface AuthRepository { * Set the value of [captchaTokenResultFlow]. */ fun setCaptchaCallbackTokenResult(tokenResult: CaptchaCallbackTokenResult) + + /** + * Get the password strength for the given [email] and [password] combo. + */ + suspend fun getPasswordStrength(email: String, password: String): Result } diff --git a/app/src/main/java/com/x8bit/bitwarden/data/auth/repository/AuthRepositoryImpl.kt b/app/src/main/java/com/x8bit/bitwarden/data/auth/repository/AuthRepositoryImpl.kt index cf833ff716..0d4e005f42 100644 --- a/app/src/main/java/com/x8bit/bitwarden/data/auth/repository/AuthRepositoryImpl.kt +++ b/app/src/main/java/com/x8bit/bitwarden/data/auth/repository/AuthRepositoryImpl.kt @@ -11,6 +11,7 @@ import com.x8bit.bitwarden.data.auth.datasource.network.service.AccountsService import com.x8bit.bitwarden.data.auth.datasource.network.service.HaveIBeenPwnedService import com.x8bit.bitwarden.data.auth.datasource.network.service.IdentityService import com.x8bit.bitwarden.data.auth.datasource.sdk.AuthSdkSource +import com.x8bit.bitwarden.data.auth.datasource.sdk.model.PasswordStrength import com.x8bit.bitwarden.data.auth.datasource.sdk.util.toKdfTypeJson import com.x8bit.bitwarden.data.auth.repository.model.AuthState import com.x8bit.bitwarden.data.auth.repository.model.LoginResult @@ -18,6 +19,7 @@ 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.toUserState import com.x8bit.bitwarden.data.auth.util.toSdkParams +import com.x8bit.bitwarden.data.platform.util.asSuccess import com.x8bit.bitwarden.data.platform.util.flatMap import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineScope @@ -225,4 +227,22 @@ class AuthRepositoryImpl @Inject constructor( override fun setCaptchaCallbackTokenResult(tokenResult: CaptchaCallbackTokenResult) { mutableCaptchaTokenFlow.tryEmit(tokenResult) } + + @Suppress("MagicNumber") + override suspend fun getPasswordStrength( + email: String, + password: String, + ): Result { + // TODO: Replace with SDK call (BIT-964) + // Ex: return authSdkSource.passwordStrength(email, password) + val length = password.length + return when { + length <= 3 -> PasswordStrength.LEVEL_0 + length <= 6 -> PasswordStrength.LEVEL_1 + length <= 9 -> PasswordStrength.LEVEL_2 + length <= 11 -> PasswordStrength.LEVEL_3 + else -> PasswordStrength.LEVEL_4 + } + .asSuccess() + } } diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/createaccount/CreateAccountScreen.kt b/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/createaccount/CreateAccountScreen.kt index 60e0401e8b..6178b7499b 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/createaccount/CreateAccountScreen.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/createaccount/CreateAccountScreen.kt @@ -215,7 +215,19 @@ fun CreateAccountScreen( .fillMaxWidth() .padding(horizontal = 16.dp), ) - Spacer(modifier = Modifier.height(16.dp)) + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = state.passwordLengthLabel(), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(horizontal = 32.dp), + ) + Spacer(modifier = Modifier.height(8.dp)) + PasswordStrengthIndicator( + modifier = Modifier.padding(horizontal = 16.dp), + state = state.passwordStrengthState, + ) + Spacer(modifier = Modifier.height(8.dp)) BitwardenPasswordField( label = stringResource(id = R.string.retype_master_password), value = state.confirmPasswordInput, diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/createaccount/CreateAccountViewModel.kt b/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/createaccount/CreateAccountViewModel.kt index 2e9605ae1c..81cb30e770 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/createaccount/CreateAccountViewModel.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/createaccount/CreateAccountViewModel.kt @@ -5,6 +5,7 @@ import android.os.Parcelable import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.viewModelScope import com.x8bit.bitwarden.R +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.RegisterResult import com.x8bit.bitwarden.data.auth.repository.util.CaptchaCallbackTokenResult @@ -14,16 +15,19 @@ import com.x8bit.bitwarden.ui.auth.feature.createaccount.CreateAccountAction.Che 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 com.x8bit.bitwarden.ui.platform.base.BaseViewModel +import com.x8bit.bitwarden.ui.platform.base.util.Text import com.x8bit.bitwarden.ui.platform.base.util.asText import com.x8bit.bitwarden.ui.platform.base.util.isValidEmail import com.x8bit.bitwarden.ui.platform.components.BasicDialogState 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 @@ -52,9 +56,16 @@ class CreateAccountViewModel @Inject constructor( 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 @@ -94,6 +105,24 @@ class CreateAccountViewModel @Inject constructor( } ContinueWithBreachedPasswordClick -> handleContinueWithBreachedPasswordClick() + is ReceivePasswordStrengthResult -> handlePasswordStrengthResult(action) + } + } + + private fun handlePasswordStrengthResult(action: ReceivePasswordStrengthResult) { + action.result.onSuccess { + val updatedState = when (it) { + 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, + ) + } } } @@ -203,7 +232,23 @@ class CreateAccountViewModel @Inject constructor( } 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 = mutableStateFlow.value.emailInput, + password = action.input, + ) + trySendAction(ReceivePasswordStrengthResult(result)) + } + } } private fun handleConfirmPasswordInputChanged(action: ConfirmPasswordInputChange) { @@ -300,7 +345,14 @@ data class CreateAccountState( val isCheckDataBreachesToggled: Boolean, val isAcceptPoliciesToggled: Boolean, val dialog: CreateAccountDialog?, -) : Parcelable + val passwordStrengthState: PasswordStrengthState, +) : Parcelable { + + val passwordLengthLabel: Text + get() = + R.string.your_master_password_cannot_be_recovered_if_you_forget_it_x_characters_minimum + .asText(MIN_PASSWORD_LENGTH) +} /** * Models dialogs that can be displayed on the create account screen. @@ -445,5 +497,12 @@ sealed class CreateAccountAction { data class ReceiveRegisterResult( val registerResult: RegisterResult, ) : Internal() + + /** + * Indicates a password strength result has been received. + */ + data class ReceivePasswordStrengthResult( + val result: Result, + ) : Internal() } } diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/createaccount/PasswordStrengthIndicator.kt b/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/createaccount/PasswordStrengthIndicator.kt new file mode 100644 index 0000000000..a9ca846d2d --- /dev/null +++ b/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/createaccount/PasswordStrengthIndicator.kt @@ -0,0 +1,106 @@ +package com.x8bit.bitwarden.ui.auth.feature.createaccount + +import androidx.compose.animation.animateColorAsState +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.drawBehind +import androidx.compose.ui.graphics.TransformOrigin +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.unit.dp +import com.x8bit.bitwarden.R +import com.x8bit.bitwarden.ui.platform.base.util.asText +import com.x8bit.bitwarden.ui.platform.theme.LocalNonMaterialColors + +/** + * Draws a password indicator that displays password strength based on the given [state]. + */ +@Suppress("LongMethod", "CyclomaticComplexMethod", "MagicNumber") +@Composable +fun PasswordStrengthIndicator( + modifier: Modifier = Modifier, + state: PasswordStrengthState, +) { + val widthPercent by animateFloatAsState( + targetValue = when (state) { + PasswordStrengthState.NONE -> 0f + PasswordStrengthState.WEAK_1 -> .25f + PasswordStrengthState.WEAK_2 -> .5f + PasswordStrengthState.WEAK_3 -> .66f + PasswordStrengthState.GOOD -> .82f + PasswordStrengthState.STRONG -> 1f + }, + label = "Width Percent State", + ) + val indicatorColor = when (state) { + PasswordStrengthState.NONE -> MaterialTheme.colorScheme.error + PasswordStrengthState.WEAK_1 -> MaterialTheme.colorScheme.error + PasswordStrengthState.WEAK_2 -> MaterialTheme.colorScheme.error + PasswordStrengthState.WEAK_3 -> LocalNonMaterialColors.current.passwordWeak + PasswordStrengthState.GOOD -> MaterialTheme.colorScheme.primary + PasswordStrengthState.STRONG -> LocalNonMaterialColors.current.passwordStrong + } + val animatedIndicatorColor by animateColorAsState( + targetValue = indicatorColor, + label = "Indicator Color State", + ) + val label = when (state) { + PasswordStrengthState.NONE -> "".asText() + PasswordStrengthState.WEAK_1 -> R.string.weak.asText() + PasswordStrengthState.WEAK_2 -> R.string.weak.asText() + PasswordStrengthState.WEAK_3 -> R.string.weak.asText() + PasswordStrengthState.GOOD -> R.string.good.asText() + PasswordStrengthState.STRONG -> R.string.strong.asText() + } + Column( + modifier = modifier, + ) { + Box( + Modifier + .fillMaxWidth() + .height(4.dp) + .background(MaterialTheme.colorScheme.surfaceContainerHigh), + ) { + Box( + modifier = Modifier + .fillMaxHeight() + .fillMaxWidth() + .graphicsLayer { + transformOrigin = TransformOrigin(pivotFractionX = 0f, pivotFractionY = 0f) + scaleX = widthPercent + } + .drawBehind { + drawRect(animatedIndicatorColor) + }, + ) + } + Spacer(Modifier.height(4.dp)) + Text( + text = label(), + style = MaterialTheme.typography.labelSmall, + color = indicatorColor, + ) + } +} + +/** + * Models various levels of password strength that can be displayed by [PasswordStrengthIndicator]. + */ +enum class PasswordStrengthState { + NONE, + WEAK_1, + WEAK_2, + WEAK_3, + GOOD, + STRONG, +} diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/platform/theme/Theme.kt b/app/src/main/java/com/x8bit/bitwarden/ui/platform/theme/BitwardenTheme.kt similarity index 79% rename from app/src/main/java/com/x8bit/bitwarden/ui/platform/theme/Theme.kt rename to app/src/main/java/com/x8bit/bitwarden/ui/platform/theme/BitwardenTheme.kt index 9f2928244f..c5b2aeb99a 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/platform/theme/Theme.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/platform/theme/BitwardenTheme.kt @@ -12,7 +12,10 @@ import androidx.compose.material3.dynamicDarkColorScheme import androidx.compose.material3.dynamicLightColorScheme import androidx.compose.material3.lightColorScheme import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.ProvidableCompositionLocal import androidx.compose.runtime.SideEffect +import androidx.compose.runtime.compositionLocalOf import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalView @@ -52,12 +55,20 @@ fun BitwardenTheme( } } - // Set overall theme based on color scheme and typography settings - MaterialTheme( - colorScheme = colorScheme, - typography = Typography, - content = content, - ) + val nonMaterialColors = if (darkTheme) { + darkNonMaterialColors(context) + } else { + lightNonMaterialColors(context) + } + + CompositionLocalProvider(LocalNonMaterialColors provides nonMaterialColors) { + // Set overall theme based on color scheme and typography settings + MaterialTheme( + colorScheme = colorScheme, + typography = Typography, + content = content, + ) + } } private fun darkColorScheme(context: Context): ColorScheme = @@ -135,3 +146,33 @@ private fun lightColorScheme(context: Context): ColorScheme = @ColorRes private fun Int.toColor(context: Context): Color = Color(context.getColor(this)) + +/** + * Provides access to non material theme colors throughout the app. + */ +val LocalNonMaterialColors: ProvidableCompositionLocal = + compositionLocalOf { + // Default value here will immediately be overridden in BitwardenTheme, similar + // to how MaterialTheme works. + NonMaterialColors(Color.Transparent, Color.Transparent) + } + +/** + * Models colors that live outside of the Material Theme spec. + */ +data class NonMaterialColors( + val passwordWeak: Color, + val passwordStrong: Color, +) + +private fun lightNonMaterialColors(context: Context): NonMaterialColors = + NonMaterialColors( + passwordWeak = R.color.light_password_strength_weak.toColor(context), + passwordStrong = R.color.light_password_strength_strong.toColor(context), + ) + +private fun darkNonMaterialColors(context: Context): NonMaterialColors = + NonMaterialColors( + passwordWeak = R.color.dark_password_strength_weak.toColor(context), + passwordStrong = R.color.dark_password_strength_strong.toColor(context), + ) diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml index cec5e87387..d17df32b69 100644 --- a/app/src/main/res/values/colors.xml +++ b/app/src/main/res/values/colors.xml @@ -104,4 +104,8 @@ @color/grey_738182 @color/grey_EFEFF4 @color/white_FFFFFF + @color/orange_B27400 + @color/orange_C9914F + @color/green_009A38 + @color/green_41B06D diff --git a/app/src/main/res/values/colors_palette.xml b/app/src/main/res/values/colors_palette.xml index 73e0133114..c57bc669ba 100644 --- a/app/src/main/res/values/colors_palette.xml +++ b/app/src/main/res/values/colors_palette.xml @@ -62,5 +62,9 @@ #dddddd #000000 + #FFB27400 + #FFC9914F + #FF009A38 + #FF41B06D diff --git a/app/src/test/java/com/x8bit/bitwarden/data/auth/repository/AuthRepositoryTest.kt b/app/src/test/java/com/x8bit/bitwarden/data/auth/repository/AuthRepositoryTest.kt index 736848f5a8..07c5cdc61d 100644 --- a/app/src/test/java/com/x8bit/bitwarden/data/auth/repository/AuthRepositoryTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/data/auth/repository/AuthRepositoryTest.kt @@ -17,6 +17,11 @@ import com.x8bit.bitwarden.data.auth.datasource.network.service.AccountsService import com.x8bit.bitwarden.data.auth.datasource.network.service.HaveIBeenPwnedService import com.x8bit.bitwarden.data.auth.datasource.network.service.IdentityService import com.x8bit.bitwarden.data.auth.datasource.sdk.AuthSdkSource +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.model.AuthState import com.x8bit.bitwarden.data.auth.repository.model.LoginResult import com.x8bit.bitwarden.data.auth.repository.model.RegisterResult @@ -643,6 +648,27 @@ class AuthRepositoryTest { } } + @Test + fun `getPasswordStrength should be based on password length`() = runTest { + // TODO: Replace with SDK call (BIT-964) + assertEquals(LEVEL_0.asSuccess(), repository.getPasswordStrength(EMAIL, "1")) + assertEquals(LEVEL_0.asSuccess(), repository.getPasswordStrength(EMAIL, "12")) + assertEquals(LEVEL_0.asSuccess(), repository.getPasswordStrength(EMAIL, "123")) + + assertEquals(LEVEL_1.asSuccess(), repository.getPasswordStrength(EMAIL, "1234")) + assertEquals(LEVEL_1.asSuccess(), repository.getPasswordStrength(EMAIL, "12345")) + assertEquals(LEVEL_1.asSuccess(), repository.getPasswordStrength(EMAIL, "123456")) + + assertEquals(LEVEL_2.asSuccess(), repository.getPasswordStrength(EMAIL, "1234567")) + assertEquals(LEVEL_2.asSuccess(), repository.getPasswordStrength(EMAIL, "12345678")) + assertEquals(LEVEL_2.asSuccess(), repository.getPasswordStrength(EMAIL, "123456789")) + + assertEquals(LEVEL_3.asSuccess(), repository.getPasswordStrength(EMAIL, "123456789a")) + assertEquals(LEVEL_3.asSuccess(), repository.getPasswordStrength(EMAIL, "123456789ab")) + + assertEquals(LEVEL_4.asSuccess(), repository.getPasswordStrength(EMAIL, "123456789abc")) + } + companion object { private const val GET_TOKEN_RESPONSE_EXTENSIONS_PATH = "com.x8bit.bitwarden.data.auth.repository.util.GetTokenResponseExtensionsKt" diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/createaccount/CreateAccountScreenTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/createaccount/CreateAccountScreenTest.kt index 27177ba316..79f0d4ac77 100644 --- a/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/createaccount/CreateAccountScreenTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/createaccount/CreateAccountScreenTest.kt @@ -33,6 +33,7 @@ import io.mockk.verify import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.emptyFlow import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.flow.update import org.junit.Assert.assertTrue import org.junit.Test @@ -408,6 +409,46 @@ class CreateAccountScreenTest : BaseComposeTest() { composeTestRule.onNode(isDialog()).assertIsDisplayed() } + @Test + fun `password strength should change as state changes`() { + val mutableStateFlow = MutableStateFlow(DEFAULT_STATE) + val viewModel = mockk(relaxed = true) { + every { stateFlow } returns mutableStateFlow + every { eventFlow } returns emptyFlow() + } + composeTestRule.setContent { + CreateAccountScreen( + onNavigateBack = {}, + onNavigateToLogin = { _, _ -> }, + viewModel = viewModel, + ) + } + 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`() { val viewModel = mockk(relaxed = true) { @@ -457,6 +498,7 @@ class CreateAccountScreenTest : BaseComposeTest() { isCheckDataBreachesToggled = false, isAcceptPoliciesToggled = false, dialog = null, + passwordStrengthState = PasswordStrengthState.NONE, ) } } diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/createaccount/CreateAccountViewModelTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/createaccount/CreateAccountViewModelTest.kt index 9d5211bcd6..fed0ddf689 100644 --- a/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/createaccount/CreateAccountViewModelTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/createaccount/CreateAccountViewModelTest.kt @@ -5,13 +5,21 @@ import androidx.lifecycle.SavedStateHandle import app.cash.turbine.test import app.cash.turbine.turbineScope import com.x8bit.bitwarden.R +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.RegisterResult import com.x8bit.bitwarden.data.auth.repository.util.generateUriForCaptcha +import com.x8bit.bitwarden.data.platform.util.asFailure +import com.x8bit.bitwarden.data.platform.util.asSuccess 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 com.x8bit.bitwarden.ui.platform.base.BaseViewModelTest @@ -71,6 +79,7 @@ class CreateAccountViewModelTest : BaseViewModelTest() { isCheckDataBreachesToggled = false, isAcceptPoliciesToggled = false, dialog = null, + passwordStrengthState = PasswordStrengthState.NONE, ) val handle = SavedStateHandle(mapOf("state" to savedState)) val viewModel = CreateAccountViewModel( @@ -129,13 +138,16 @@ class CreateAccountViewModelTest : BaseViewModelTest() { @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 Throwable().asFailure() val viewModel = CreateAccountViewModel( savedStateHandle = SavedStateHandle(), authRepository = mockAuthRepository, ) - val input = "abcdefghikl" viewModel.trySendAction(EmailInputChange(EMAIL)) - viewModel.trySendAction(PasswordInputChange("abcdefghikl")) + viewModel.trySendAction(PasswordInputChange(input)) val expectedState = DEFAULT_STATE.copy( emailInput = EMAIL, passwordInput = input, @@ -154,11 +166,14 @@ class CreateAccountViewModelTest : BaseViewModelTest() { @Test fun `SubmitClick with passwords not matching should show password match dialog`() = runTest { + val input = "testtesttesttest" + coEvery { + mockAuthRepository.getPasswordStrength("test@test.com", input) + } returns Throwable().asFailure() val viewModel = CreateAccountViewModel( savedStateHandle = SavedStateHandle(), authRepository = mockAuthRepository, ) - val input = "testtesttesttest" viewModel.trySendAction(EmailInputChange("test@test.com")) viewModel.trySendAction(PasswordInputChange(input)) val expectedState = DEFAULT_STATE.copy( @@ -179,11 +194,14 @@ class CreateAccountViewModelTest : BaseViewModelTest() { @Test fun `SubmitClick without policies accepted should show accept policies error`() = runTest { + val password = "testtesttesttest" + coEvery { + mockAuthRepository.getPasswordStrength("test@test.com", password) + } returns Throwable().asFailure() val viewModel = CreateAccountViewModel( savedStateHandle = SavedStateHandle(), authRepository = mockAuthRepository, ) - val password = "testtesttesttest" viewModel.trySendAction(EmailInputChange("test@test.com")) viewModel.trySendAction(PasswordInputChange(password)) viewModel.trySendAction(ConfirmPasswordInputChange(password)) @@ -483,7 +501,10 @@ class CreateAccountViewModelTest : BaseViewModelTest() { } @Test - fun `PasswordInputChange update passwordInput`() = runTest { + fun `PasswordInputChange update passwordInput and call getPasswordStrength`() = runTest { + coEvery { + mockAuthRepository.getPasswordStrength("", "input") + } returns Result.failure(Throwable()) val viewModel = CreateAccountViewModel( savedStateHandle = SavedStateHandle(), authRepository = mockAuthRepository, @@ -492,6 +513,7 @@ class CreateAccountViewModelTest : BaseViewModelTest() { viewModel.stateFlow.test { assertEquals(DEFAULT_STATE.copy(passwordInput = "input"), awaitItem()) } + coVerify { mockAuthRepository.getPasswordStrength("", "input") } } @Test @@ -518,6 +540,62 @@ class CreateAccountViewModelTest : BaseViewModelTest() { } } + @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(LEVEL_0.asSuccess())) + assertEquals( + DEFAULT_STATE.copy( + passwordStrengthState = PasswordStrengthState.WEAK_1, + ), + awaitItem(), + ) + + viewModel.trySendAction(ReceivePasswordStrengthResult(LEVEL_1.asSuccess())) + assertEquals( + DEFAULT_STATE.copy( + passwordStrengthState = PasswordStrengthState.WEAK_2, + ), + awaitItem(), + ) + + viewModel.trySendAction(ReceivePasswordStrengthResult(LEVEL_2.asSuccess())) + assertEquals( + DEFAULT_STATE.copy( + passwordStrengthState = PasswordStrengthState.WEAK_3, + ), + awaitItem(), + ) + + viewModel.trySendAction(ReceivePasswordStrengthResult(LEVEL_3.asSuccess())) + assertEquals( + DEFAULT_STATE.copy( + passwordStrengthState = PasswordStrengthState.GOOD, + ), + awaitItem(), + ) + + viewModel.trySendAction(ReceivePasswordStrengthResult(LEVEL_4.asSuccess())) + assertEquals( + DEFAULT_STATE.copy( + passwordStrengthState = PasswordStrengthState.STRONG, + ), + awaitItem(), + ) + } + } + companion object { private const val PASSWORD = "longenoughtpassword" private const val EMAIL = "test@test.com" @@ -529,6 +607,7 @@ class CreateAccountViewModelTest : BaseViewModelTest() { isCheckDataBreachesToggled = true, isAcceptPoliciesToggled = false, dialog = null, + passwordStrengthState = PasswordStrengthState.NONE, ) private val VALID_INPUT_STATE = CreateAccountState( passwordInput = PASSWORD, @@ -538,6 +617,7 @@ class CreateAccountViewModelTest : BaseViewModelTest() { isCheckDataBreachesToggled = false, isAcceptPoliciesToggled = true, dialog = null, + passwordStrengthState = PasswordStrengthState.GOOD, ) private const val LOGIN_RESULT_PATH = "com.x8bit.bitwarden.data.auth.repository.util.CaptchaUtilsKt"