BIT-707 Implement password strength indicator with mock values (#161)

This commit is contained in:
Andrew Haisting
2024-06-20 17:08:07 +01:00
committed by Álison Fernandes
parent 4ce7e0842b
commit 343e17f1f4
11 changed files with 413 additions and 13 deletions
@@ -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<PasswordStrength>
}
@@ -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<PasswordStrength> {
// 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()
}
}
@@ -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,
@@ -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<PasswordStrength>,
) : Internal()
}
}
@@ -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,
}
@@ -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<NonMaterialColors> =
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),
)