diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/tools/feature/generator/GeneratorScreen.kt b/app/src/main/java/com/x8bit/bitwarden/ui/tools/feature/generator/GeneratorScreen.kt index 867819adae..198efff5e4 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/tools/feature/generator/GeneratorScreen.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/tools/feature/generator/GeneratorScreen.kt @@ -12,11 +12,16 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.layout.wrapContentWidth import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.verticalScroll import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Scaffold +import androidx.compose.material3.Slider import androidx.compose.material3.Text import androidx.compose.material3.TopAppBarDefaults import androidx.compose.material3.rememberTopAppBarState @@ -29,6 +34,8 @@ import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel @@ -42,6 +49,8 @@ import com.x8bit.bitwarden.ui.platform.components.BitwardenTextFieldWithTwoIcons import com.x8bit.bitwarden.ui.platform.components.BitwardenWideSwitch import com.x8bit.bitwarden.ui.platform.components.model.IconResource import com.x8bit.bitwarden.ui.platform.theme.BitwardenTheme +import com.x8bit.bitwarden.ui.tools.feature.generator.GeneratorState.MainType.Passcode.PasscodeType.Password.Companion.PASSWORD_LENGTH_SLIDER_MAX +import com.x8bit.bitwarden.ui.tools.feature.generator.GeneratorState.MainType.Passcode.PasscodeType.Password.Companion.PASSWORD_LENGTH_SLIDER_MIN /** * Top level composable for the generator screen. @@ -84,7 +93,90 @@ fun GeneratorScreen(viewModel: GeneratorViewModel = hiltViewModel()) { } } - val onNumWordsCounterChange: (Int) -> Unit = remember(viewModel) { + val onPasswordSliderLengthChange: (Int) -> Unit = remember(viewModel) { + { newLength -> + viewModel.trySendAction( + GeneratorAction.MainType.Passcode.PasscodeType.Password.SliderLengthChange( + length = newLength, + ), + ) + } + } + + val onPasswordToggleCapitalLettersChange: (Boolean) -> Unit = remember(viewModel) { + { shouldUseCapitals -> + viewModel.trySendAction( + GeneratorAction.MainType.Passcode.PasscodeType.Password.ToggleCapitalLettersChange( + useCapitals = shouldUseCapitals, + ), + ) + } + } + + val onPasswordToggleLowercaseLettersChange: (Boolean) -> Unit = remember(viewModel) { + { shouldUseLowercase -> + viewModel.trySendAction( + GeneratorAction.MainType.Passcode.PasscodeType.Password + .ToggleLowercaseLettersChange( + useLowercase = shouldUseLowercase, + ), + ) + } + } + + val onPasswordToggleNumbersChange: (Boolean) -> Unit = remember(viewModel) { + { shouldUseNumbers -> + viewModel.trySendAction( + GeneratorAction.MainType.Passcode.PasscodeType.Password.ToggleNumbersChange( + useNumbers = shouldUseNumbers, + ), + ) + } + } + + val onPasswordToggleSpecialCharactersChange: (Boolean) -> Unit = remember(viewModel) { + { shouldUseSpecialChars -> + viewModel.trySendAction( + GeneratorAction.MainType.Passcode.PasscodeType.Password + .ToggleSpecialCharactersChange( + useSpecialChars = shouldUseSpecialChars, + ), + ) + } + } + + val onPasswordMinNumbersCounterChange: (Int) -> Unit = remember(viewModel) { + { newMinNumbers -> + viewModel.trySendAction( + GeneratorAction.MainType.Passcode.PasscodeType.Password.MinNumbersCounterChange( + minNumbers = newMinNumbers, + ), + ) + } + } + + val onPasswordMinSpecialCharactersChange: (Int) -> Unit = remember(viewModel) { + { newMinSpecial -> + viewModel.trySendAction( + GeneratorAction.MainType.Passcode.PasscodeType.Password.MinSpecialCharactersChange( + minSpecial = newMinSpecial, + ), + ) + } + } + + val onPasswordToggleAvoidAmbiguousCharsChange: (Boolean) -> Unit = remember(viewModel) { + { shouldAvoidAmbiguousChars -> + viewModel.trySendAction( + GeneratorAction.MainType.Passcode.PasscodeType.Password + .ToggleAvoidAmbigousCharactersChange( + avoidAmbiguousChars = shouldAvoidAmbiguousChars, + ), + ) + } + } + + val onPassphraseNumWordsCounterChange: (Int) -> Unit = remember(viewModel) { { changeInCounter -> viewModel.trySendAction( GeneratorAction.MainType.Passcode.PasscodeType.Passphrase.NumWordsCounterChange( @@ -104,7 +196,7 @@ fun GeneratorScreen(viewModel: GeneratorViewModel = hiltViewModel()) { } } - val onIncludeNumberToggleChange: (Boolean) -> Unit = remember(viewModel) { + val onPassphraseIncludeNumberToggleChange: (Boolean) -> Unit = remember(viewModel) { { shouldIncludeNumber -> viewModel.trySendAction( GeneratorAction.MainType.Passcode.PasscodeType.Passphrase.ToggleIncludeNumberChange( @@ -114,7 +206,7 @@ fun GeneratorScreen(viewModel: GeneratorViewModel = hiltViewModel()) { } } - val onWordSeparatorChange: (Char?) -> Unit = remember(viewModel) { + val onPassphraseWordSeparatorChange: (Char?) -> Unit = remember(viewModel) { { newSeparator -> viewModel.trySendAction( GeneratorAction.MainType.Passcode.PasscodeType.Passphrase.WordSeparatorTextChange( @@ -137,16 +229,29 @@ fun GeneratorScreen(viewModel: GeneratorViewModel = hiltViewModel()) { modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection), ) { innerPadding -> ScrollContent( - state, - onRegenerateClick, - onCopyClick, - onMainStateOptionClicked, - onPasscodeOptionClicked, - onNumWordsCounterChange, - onWordSeparatorChange, - onPassphraseCapitalizeToggleChange, - onIncludeNumberToggleChange, - Modifier.padding(innerPadding), + state = state, + onRegenerateClick = onRegenerateClick, + onCopyClick = onCopyClick, + onMainStateOptionClicked = onMainStateOptionClicked, + onSubStateOptionClicked = onPasscodeOptionClicked, + + // Password handlers + onPasswordSliderLengthChange = onPasswordSliderLengthChange, + onPasswordToggleCapitalLettersChange = onPasswordToggleCapitalLettersChange, + onPasswordToggleLowercaseLettersChange = onPasswordToggleLowercaseLettersChange, + onPasswordToggleNumbersChange = onPasswordToggleNumbersChange, + onPasswordToggleSpecialCharactersChange = onPasswordToggleSpecialCharactersChange, + onPasswordMinNumbersCounterChange = onPasswordMinNumbersCounterChange, + onPasswordMinSpecialCharactersChange = onPasswordMinSpecialCharactersChange, + onPasswordToggleAvoidAmbiguousCharsChange = onPasswordToggleAvoidAmbiguousCharsChange, + + // Passphrase handlers + onPassphraseNumWordsCounterChange = onPassphraseNumWordsCounterChange, + onPassphraseWordSeparatorChange = onPassphraseWordSeparatorChange, + onPassphraseCapitalizeToggleChange = onPassphraseCapitalizeToggleChange, + onPassphraseIncludeNumberToggleChange = onPassphraseIncludeNumberToggleChange, + + modifier = Modifier.padding(innerPadding), ) } } @@ -161,10 +266,18 @@ private fun ScrollContent( onCopyClick: () -> Unit, onMainStateOptionClicked: (GeneratorState.MainTypeOption) -> Unit, onSubStateOptionClicked: (GeneratorState.MainType.Passcode.PasscodeTypeOption) -> Unit, - onNumWordsCounterChange: (Int) -> Unit, - onWordSeparatorChange: (Char?) -> Unit, - onCapitalizeToggleChange: (Boolean) -> Unit, - onIncludeNumberToggleChange: (Boolean) -> Unit, + onPasswordSliderLengthChange: (Int) -> Unit, + onPasswordToggleCapitalLettersChange: (Boolean) -> Unit, + onPasswordToggleLowercaseLettersChange: (Boolean) -> Unit, + onPasswordToggleNumbersChange: (Boolean) -> Unit, + onPasswordToggleSpecialCharactersChange: (Boolean) -> Unit, + onPasswordMinNumbersCounterChange: (Int) -> Unit, + onPasswordMinSpecialCharactersChange: (Int) -> Unit, + onPasswordToggleAvoidAmbiguousCharsChange: (Boolean) -> Unit, + onPassphraseNumWordsCounterChange: (Int) -> Unit, + onPassphraseWordSeparatorChange: (Char?) -> Unit, + onPassphraseCapitalizeToggleChange: (Boolean) -> Unit, + onPassphraseIncludeNumberToggleChange: (Boolean) -> Unit, modifier: Modifier = Modifier, ) { Column( @@ -202,12 +315,26 @@ private fun ScrollContent( when (val selectedType = state.selectedType) { is GeneratorState.MainType.Passcode -> { PasscodeTypeItems( - selectedType, - onSubStateOptionClicked, - onNumWordsCounterChange, - onWordSeparatorChange, - onCapitalizeToggleChange, - onIncludeNumberToggleChange, + passcodeState = selectedType, + onSubStateOptionClicked = onSubStateOptionClicked, + + // Password handlers + onPasswordSliderLengthChange = onPasswordSliderLengthChange, + onPasswordToggleCapitalLettersChange = onPasswordToggleCapitalLettersChange, + onPasswordToggleLowercaseLettersChange = onPasswordToggleLowercaseLettersChange, + onPasswordToggleNumbersChange = onPasswordToggleNumbersChange, + onPasswordToggleSpecialCharactersChange = + onPasswordToggleSpecialCharactersChange, + onPasswordMinNumbersCounterChange = onPasswordMinNumbersCounterChange, + onPasswordMinSpecialCharactersChange = onPasswordMinSpecialCharactersChange, + onPasswordToggleAvoidAmbiguousCharsChange = + onPasswordToggleAvoidAmbiguousCharsChange, + + // Passphrase handlers + onPassphraseNumWordsCounterChange = onPassphraseNumWordsCounterChange, + onPassphraseWordSeparatorChange = onPassphraseWordSeparatorChange, + onPassphraseCapitalizeToggleChange = onPassphraseCapitalizeToggleChange, + onPassphraseIncludeNumberToggleChange = onPassphraseIncludeNumberToggleChange, ) } @@ -265,49 +392,49 @@ private fun MainStateOptionsItem( //region PasscodeType Composables -/** - * A composable function to represent a collection of passcode type items based on the selected - * [GeneratorState.MainType.Passcode.PasscodeType]. It dynamically displays content depending on - * the currently selected passcode type. - * - * @param passcodeState The current state of the passcode generator, - * holding the selected passcode type and other settings. - * @param onSubStateOptionClicked A lambda function invoked when a substate option is clicked. - * It takes the selected [GeneratorState.MainType.Passcode.PasscodeTypeOption] as a parameter. - * @param onNumWordsCounterChange A lambda function invoked when there is a change - * in the number of words for passphrase. It takes the updated number of words as a parameter. - * @param onWordSeparatorChange A lambda function invoked when there is a change - * in the word separator character for passphrase. It takes the updated character as a parameter, - * `null` if there is no separator. - * @param onCapitalizeToggleChange A lambda function invoked when the capitalize - * toggle state changes for passphrase. It takes the updated toggle state as a parameter. - * @param onIncludeNumberToggleChange A lambda function invoked when the include number toggle - * state changes for passphrase. It takes the updated toggle state as a parameter. - */ @Composable -fun PasscodeTypeItems( +private fun PasscodeTypeItems( passcodeState: GeneratorState.MainType.Passcode, onSubStateOptionClicked: (GeneratorState.MainType.Passcode.PasscodeTypeOption) -> Unit, - onNumWordsCounterChange: (Int) -> Unit, - onWordSeparatorChange: (Char?) -> Unit, - onCapitalizeToggleChange: (Boolean) -> Unit, - onIncludeNumberToggleChange: (Boolean) -> Unit, + onPasswordSliderLengthChange: (Int) -> Unit, + onPasswordToggleCapitalLettersChange: (Boolean) -> Unit, + onPasswordToggleLowercaseLettersChange: (Boolean) -> Unit, + onPasswordToggleNumbersChange: (Boolean) -> Unit, + onPasswordToggleSpecialCharactersChange: (Boolean) -> Unit, + onPasswordMinNumbersCounterChange: (Int) -> Unit, + onPasswordMinSpecialCharactersChange: (Int) -> Unit, + onPasswordToggleAvoidAmbiguousCharsChange: (Boolean) -> Unit, + onPassphraseNumWordsCounterChange: (Int) -> Unit, + onPassphraseWordSeparatorChange: (Char?) -> Unit, + onPassphraseCapitalizeToggleChange: (Boolean) -> Unit, + onPassphraseIncludeNumberToggleChange: (Boolean) -> Unit, ) { PasscodeOptionsItem(passcodeState, onSubStateOptionClicked) when (val selectedType = passcodeState.selectedType) { - is GeneratorState.MainType.Passcode.PasscodeType.Passphrase -> { - PassphraseTypeContent( - selectedType, - onNumWordsCounterChange, - onWordSeparatorChange, - onCapitalizeToggleChange, - onIncludeNumberToggleChange, + is GeneratorState.MainType.Passcode.PasscodeType.Password -> { + PasswordTypeContent( + passwordTypeState = selectedType, + onPasswordSliderLengthChange = onPasswordSliderLengthChange, + onPasswordToggleCapitalLettersChange = onPasswordToggleCapitalLettersChange, + onPasswordToggleLowercaseLettersChange = onPasswordToggleLowercaseLettersChange, + onPasswordToggleNumbersChange = onPasswordToggleNumbersChange, + onPasswordToggleSpecialCharactersChange = onPasswordToggleSpecialCharactersChange, + onPasswordMinNumbersCounterChange = onPasswordMinNumbersCounterChange, + onPasswordMinSpecialCharactersChange = onPasswordMinSpecialCharactersChange, + onPasswordToggleAvoidAmbiguousCharsChange = + onPasswordToggleAvoidAmbiguousCharsChange, ) } - is GeneratorState.MainType.Passcode.PasscodeType.Password -> { - // TODO(BIT-334): Render UI for Password type + is GeneratorState.MainType.Passcode.PasscodeType.Passphrase -> { + PassphraseTypeContent( + passphraseTypeState = selectedType, + onPassphraseNumWordsCounterChange = onPassphraseNumWordsCounterChange, + onPassphraseWordSeparatorChange = onPassphraseWordSeparatorChange, + onPassphraseCapitalizeToggleChange = onPassphraseCapitalizeToggleChange, + onPassphraseIncludeNumberToggleChange = onPassphraseIncludeNumberToggleChange, + ) } } } @@ -335,34 +462,238 @@ private fun PasscodeOptionsItem( //endregion PasscodeType Composables +//region PasswordType Composables + +@Composable +private fun PasswordTypeContent( + passwordTypeState: GeneratorState.MainType.Passcode.PasscodeType.Password, + onPasswordSliderLengthChange: (Int) -> Unit, + onPasswordToggleCapitalLettersChange: (Boolean) -> Unit, + onPasswordToggleLowercaseLettersChange: (Boolean) -> Unit, + onPasswordToggleNumbersChange: (Boolean) -> Unit, + onPasswordToggleSpecialCharactersChange: (Boolean) -> Unit, + onPasswordMinNumbersCounterChange: (Int) -> Unit, + onPasswordMinSpecialCharactersChange: (Int) -> Unit, + onPasswordToggleAvoidAmbiguousCharsChange: (Boolean) -> Unit, +) { + PasswordLengthSliderItem( + length = passwordTypeState.length, + onPasswordSliderLengthChange = onPasswordSliderLengthChange, + ) + Column( + modifier = Modifier.fillMaxWidth(), + ) { + + PasswordCapitalLettersToggleItem( + useCapitals = passwordTypeState.useCapitals, + onPasswordToggleCapitalLettersChange = onPasswordToggleCapitalLettersChange, + ) + PasswordLowercaseLettersToggleItem( + useLowercase = passwordTypeState.useLowercase, + onPasswordToggleLowercaseLettersChange = onPasswordToggleLowercaseLettersChange, + ) + PasswordNumbersToggleItem( + useNumbers = passwordTypeState.useNumbers, + onPasswordToggleNumbersChange = onPasswordToggleNumbersChange, + ) + PasswordSpecialCharactersToggleItem( + useSpecialChars = passwordTypeState.useSpecialChars, + onPasswordToggleSpecialCharactersChange = onPasswordToggleSpecialCharactersChange, + ) + } + PasswordMinNumbersCounterItem( + minNumbers = passwordTypeState.minNumbers, + onPasswordMinNumbersCounterChange = onPasswordMinNumbersCounterChange, + ) + PasswordMinSpecialCharactersCounterItem( + minSpecial = passwordTypeState.minSpecial, + onPasswordMinSpecialCharactersChange = onPasswordMinSpecialCharactersChange, + ) + PasswordAvoidAmbiguousCharsToggleItem( + avoidAmbiguousChars = passwordTypeState.avoidAmbiguousChars, + onPasswordToggleAvoidAmbiguousCharsChange = onPasswordToggleAvoidAmbiguousCharsChange, + ) +} + +@Composable +private fun PasswordLengthSliderItem( + length: Int, + onPasswordSliderLengthChange: (Int) -> Unit, +) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .fillMaxWidth() + .semantics(mergeDescendants = true) {}, + ) { + OutlinedTextField( + value = length.toString(), + readOnly = true, + onValueChange = { newText -> + newText.toIntOrNull()?.let { newValue -> + onPasswordSliderLengthChange(newValue) + } + }, + label = { Text(stringResource(id = R.string.length)) }, + singleLine = true, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), + modifier = Modifier + .wrapContentWidth() + .widthIn(max = 71.dp), + ) + + Slider( + value = length.toFloat(), + onValueChange = { newValue -> + onPasswordSliderLengthChange(newValue.toInt()) + }, + valueRange = + PASSWORD_LENGTH_SLIDER_MIN.toFloat()..PASSWORD_LENGTH_SLIDER_MAX.toFloat(), + steps = PASSWORD_LENGTH_SLIDER_MAX - 1, + ) + } +} + +@Composable +private fun PasswordCapitalLettersToggleItem( + useCapitals: Boolean, + onPasswordToggleCapitalLettersChange: (Boolean) -> Unit, +) { + BitwardenWideSwitch( + label = stringResource(id = R.string.uppercase_ato_z), + isChecked = useCapitals, + onCheckedChange = onPasswordToggleCapitalLettersChange, + ) +} + +@Composable +private fun PasswordLowercaseLettersToggleItem( + useLowercase: Boolean, + onPasswordToggleLowercaseLettersChange: (Boolean) -> Unit, +) { + BitwardenWideSwitch( + label = stringResource(id = R.string.lowercase_ato_z), + isChecked = useLowercase, + onCheckedChange = onPasswordToggleLowercaseLettersChange, + ) +} + +@Composable +private fun PasswordNumbersToggleItem( + useNumbers: Boolean, + onPasswordToggleNumbersChange: (Boolean) -> Unit, +) { + BitwardenWideSwitch( + label = stringResource(id = R.string.numbers_zero_to_nine), + isChecked = useNumbers, + onCheckedChange = onPasswordToggleNumbersChange, + ) +} + +@Composable +private fun PasswordSpecialCharactersToggleItem( + useSpecialChars: Boolean, + onPasswordToggleSpecialCharactersChange: (Boolean) -> Unit, +) { + BitwardenWideSwitch( + label = stringResource(id = R.string.special_characters), + isChecked = useSpecialChars, + onCheckedChange = onPasswordToggleSpecialCharactersChange, + ) +} + +@Composable +private fun PasswordMinNumbersCounterItem( + minNumbers: Int, + onPasswordMinNumbersCounterChange: (Int) -> Unit, +) { + BitwardenTextFieldWithTwoIcons( + label = stringResource(id = R.string.min_numbers), + value = minNumbers.toString(), + firstIconResource = IconResource( + iconPainter = painterResource(id = R.drawable.ic_minus), + contentDescription = "\u2212", + ), + onFirstIconClick = { + onPasswordMinNumbersCounterChange(minNumbers - 1) + }, + secondIconResource = IconResource( + iconPainter = painterResource(id = R.drawable.ic_plus), + contentDescription = "+", + ), + onSecondIconClick = { + onPasswordMinNumbersCounterChange(minNumbers + 1) + }, + ) +} + +@Composable +private fun PasswordMinSpecialCharactersCounterItem( + minSpecial: Int, + onPasswordMinSpecialCharactersChange: (Int) -> Unit, +) { + BitwardenTextFieldWithTwoIcons( + label = stringResource(id = R.string.min_special), + value = minSpecial.toString(), + firstIconResource = IconResource( + iconPainter = painterResource(id = R.drawable.ic_minus), + contentDescription = "\u2212", + ), + onFirstIconClick = { + onPasswordMinSpecialCharactersChange(minSpecial - 1) + }, + secondIconResource = IconResource( + iconPainter = painterResource(id = R.drawable.ic_plus), + contentDescription = "+", + ), + onSecondIconClick = { + onPasswordMinSpecialCharactersChange(minSpecial + 1) + }, + ) +} + +@Composable +private fun PasswordAvoidAmbiguousCharsToggleItem( + avoidAmbiguousChars: Boolean, + onPasswordToggleAvoidAmbiguousCharsChange: (Boolean) -> Unit, +) { + BitwardenWideSwitch( + label = stringResource(id = R.string.avoid_ambiguous_characters), + isChecked = avoidAmbiguousChars, + onCheckedChange = onPasswordToggleAvoidAmbiguousCharsChange, + ) +} + +//endregion PasswordType Composables + //region PassphraseType Composables @Composable private fun PassphraseTypeContent( passphraseTypeState: GeneratorState.MainType.Passcode.PasscodeType.Passphrase, - onNumWordsCounterChange: (Int) -> Unit, - onWordSeparatorChange: (Char?) -> Unit, - onCapitalizeToggleChange: (Boolean) -> Unit, - onIncludeNumberToggleChange: (Boolean) -> Unit, + onPassphraseNumWordsCounterChange: (Int) -> Unit, + onPassphraseWordSeparatorChange: (Char?) -> Unit, + onPassphraseCapitalizeToggleChange: (Boolean) -> Unit, + onPassphraseIncludeNumberToggleChange: (Boolean) -> Unit, ) { PassphraseNumWordsCounterItem( numWords = passphraseTypeState.numWords, - onNumWordsCounterChange = onNumWordsCounterChange, + onPassphraseNumWordsCounterChange = onPassphraseNumWordsCounterChange, ) PassphraseWordSeparatorInputItem( wordSeparator = passphraseTypeState.wordSeparator, - onWordSeparatorChange = onWordSeparatorChange, + onPassphraseWordSeparatorChange = onPassphraseWordSeparatorChange, ) Column( modifier = Modifier.fillMaxWidth(), ) { PassphraseCapitalizeToggleItem( capitalize = passphraseTypeState.capitalize, - onPassphraseCapitalizeToggleChange = onCapitalizeToggleChange, + onPassphraseCapitalizeToggleChange = onPassphraseCapitalizeToggleChange, ) PassphraseIncludeNumberToggleItem( includeNumber = passphraseTypeState.includeNumber, - onIncludeNumberToggleChange = onIncludeNumberToggleChange, + onPassphraseIncludeNumberToggleChange = onPassphraseIncludeNumberToggleChange, ) } } @@ -370,7 +701,7 @@ private fun PassphraseTypeContent( @Composable private fun PassphraseNumWordsCounterItem( numWords: Int, - onNumWordsCounterChange: (Int) -> Unit, + onPassphraseNumWordsCounterChange: (Int) -> Unit, ) { BitwardenTextFieldWithTwoIcons( label = stringResource(id = R.string.number_of_words), @@ -380,14 +711,14 @@ private fun PassphraseNumWordsCounterItem( contentDescription = "\u2212", ), onFirstIconClick = { - onNumWordsCounterChange(numWords - 1) + onPassphraseNumWordsCounterChange(numWords - 1) }, secondIconResource = IconResource( iconPainter = painterResource(id = R.drawable.ic_plus), contentDescription = "+", ), onSecondIconClick = { - onNumWordsCounterChange(numWords + 1) + onPassphraseNumWordsCounterChange(numWords + 1) }, ) } @@ -395,13 +726,13 @@ private fun PassphraseNumWordsCounterItem( @Composable private fun PassphraseWordSeparatorInputItem( wordSeparator: Char?, - onWordSeparatorChange: (wordSeparator: Char?) -> Unit, + onPassphraseWordSeparatorChange: (wordSeparator: Char?) -> Unit, ) { BitwardenTextField( label = stringResource(id = R.string.word_separator), value = wordSeparator?.toString() ?: "", onValueChange = { - onWordSeparatorChange(it.toCharArray().firstOrNull()) + onPassphraseWordSeparatorChange(it.toCharArray().firstOrNull()) }, modifier = Modifier.width(267.dp), ) @@ -422,12 +753,12 @@ private fun PassphraseCapitalizeToggleItem( @Composable private fun PassphraseIncludeNumberToggleItem( includeNumber: Boolean, - onIncludeNumberToggleChange: (Boolean) -> Unit, + onPassphraseIncludeNumberToggleChange: (Boolean) -> Unit, ) { BitwardenWideSwitch( label = stringResource(id = R.string.include_number), isChecked = includeNumber, - onCheckedChange = onIncludeNumberToggleChange, + onCheckedChange = onPassphraseIncludeNumberToggleChange, ) } diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/tools/feature/generator/GeneratorViewModel.kt b/app/src/main/java/com/x8bit/bitwarden/ui/tools/feature/generator/GeneratorViewModel.kt index 3b84889b05..7eb33c749d 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/tools/feature/generator/GeneratorViewModel.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/tools/feature/generator/GeneratorViewModel.kt @@ -12,6 +12,8 @@ import com.x8bit.bitwarden.ui.tools.feature.generator.GeneratorState.MainType.Pa import com.x8bit.bitwarden.ui.tools.feature.generator.GeneratorState.MainType.Passcode.PasscodeType.Passphrase.Companion.PASSPHRASE_MAX_NUMBER_OF_WORDS import com.x8bit.bitwarden.ui.tools.feature.generator.GeneratorState.MainType.Passcode.PasscodeType.Passphrase.Companion.PASSPHRASE_MIN_NUMBER_OF_WORDS import com.x8bit.bitwarden.ui.tools.feature.generator.GeneratorState.MainType.Passcode.PasscodeType.Password +import com.x8bit.bitwarden.ui.tools.feature.generator.GeneratorState.MainType.Passcode.PasscodeType.Password.Companion.PASSWORD_COUNTER_MAX +import com.x8bit.bitwarden.ui.tools.feature.generator.GeneratorState.MainType.Passcode.PasscodeType.Password.Companion.PASSWORD_COUNTER_MIN import com.x8bit.bitwarden.ui.tools.feature.generator.GeneratorState.MainType.Passcode.PasscodeTypeOption import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.launchIn @@ -67,6 +69,10 @@ class GeneratorViewModel @Inject constructor( handlePasscodeTypeOptionSelect(action) } + is GeneratorAction.MainType.Passcode.PasscodeType.Password -> { + handlePasswordSpecificAction(action) + } + is GeneratorAction.MainType.Passcode.PasscodeType.Passphrase -> { handlePassphraseSpecificAction(action) } @@ -158,6 +164,139 @@ class GeneratorViewModel @Inject constructor( //endregion Passcode Type Handlers + //region Password Specific Handlers + + private fun handlePasswordSpecificAction( + action: GeneratorAction.MainType.Passcode.PasscodeType.Password, + ) { + when (action) { + is GeneratorAction.MainType.Passcode.PasscodeType.Password.SliderLengthChange, + -> { + handlePasswordLengthSliderChange(action) + } + + is GeneratorAction.MainType.Passcode.PasscodeType.Password.ToggleCapitalLettersChange, + -> { + handleToggleCapitalLetters(action) + } + + is GeneratorAction.MainType.Passcode.PasscodeType.Password.ToggleLowercaseLettersChange, + -> { + handleToggleLowercaseLetters(action) + } + + is GeneratorAction.MainType.Passcode.PasscodeType.Password.ToggleNumbersChange, + -> { + handleToggleNumbers(action) + } + + is GeneratorAction.MainType.Passcode.PasscodeType.Password + .ToggleSpecialCharactersChange, + -> { + handleToggleSpecialChars(action) + } + + is GeneratorAction.MainType.Passcode.PasscodeType.Password.MinNumbersCounterChange, + -> { + handleMinNumbersChange(action) + } + + is GeneratorAction.MainType.Passcode.PasscodeType.Password.MinSpecialCharactersChange, + -> { + handleMinSpecialChange(action) + } + + is GeneratorAction.MainType.Passcode.PasscodeType.Password + .ToggleAvoidAmbigousCharactersChange, + -> { + handleToggleAmbiguousChars(action) + } + } + } + + private fun handlePasswordLengthSliderChange( + action: GeneratorAction.MainType.Passcode.PasscodeType.Password.SliderLengthChange, + ) { + val adjustedLength = action.length + + updatePasswordType { currentPasswordType -> + currentPasswordType.copy(length = adjustedLength) + } + } + + private fun handleToggleCapitalLetters( + action: GeneratorAction.MainType.Passcode.PasscodeType.Password.ToggleCapitalLettersChange, + ) { + updatePasswordType { currentPasswordType -> + currentPasswordType.copy( + useCapitals = action.useCapitals, + ) + } + } + + private fun handleToggleLowercaseLetters( + action: GeneratorAction.MainType.Passcode.PasscodeType.Password + .ToggleLowercaseLettersChange, + ) { + updatePasswordType { currentPasswordType -> + currentPasswordType.copy(useLowercase = action.useLowercase) + } + } + + private fun handleToggleNumbers( + action: GeneratorAction.MainType.Passcode.PasscodeType.Password.ToggleNumbersChange, + ) { + updatePasswordType { currentPasswordType -> + currentPasswordType.copy(useNumbers = action.useNumbers) + } + } + + private fun handleToggleSpecialChars( + action: GeneratorAction.MainType.Passcode.PasscodeType.Password + .ToggleSpecialCharactersChange, + ) { + updatePasswordType { currentPasswordType -> + currentPasswordType.copy(useSpecialChars = action.useSpecialChars) + } + } + + private fun handleMinNumbersChange( + action: GeneratorAction.MainType.Passcode.PasscodeType.Password.MinNumbersCounterChange, + ) { + val adjustedMinNumbers = action + .minNumbers + .coerceIn(PASSWORD_COUNTER_MIN, PASSWORD_COUNTER_MAX) + + updatePasswordType { currentPasswordType -> + currentPasswordType.copy(minNumbers = adjustedMinNumbers) + } + } + + private fun handleMinSpecialChange( + action: GeneratorAction.MainType.Passcode.PasscodeType.Password.MinSpecialCharactersChange, + ) { + val adjustedMinSpecial = action + .minSpecial + .coerceIn(PASSWORD_COUNTER_MIN, PASSWORD_COUNTER_MAX) + + updatePasswordType { currentPasswordType -> + currentPasswordType.copy(minSpecial = adjustedMinSpecial) + } + } + + private fun handleToggleAmbiguousChars( + action: GeneratorAction.MainType.Passcode.PasscodeType.Password + .ToggleAvoidAmbigousCharactersChange, + ) { + updatePasswordType { currentPasswordType -> + currentPasswordType.copy( + avoidAmbiguousChars = action.avoidAmbiguousChars, + ) + } + } + + //endregion Password Specific Handlers + //region Passphrase Specific Handlers private fun handlePassphraseSpecificAction( @@ -248,6 +387,18 @@ class GeneratorViewModel @Inject constructor( } } + private inline fun updatePasswordType( + crossinline block: (Password) -> Password, + ) { + updateGeneratorMainTypePassword { currentSelectedType -> + val currentPasswordType = currentSelectedType.selectedType + if (currentPasswordType !is Password) { + return@updateGeneratorMainTypePassword currentSelectedType + } + currentSelectedType.copy(selectedType = block(currentPasswordType)) + } + } + private inline fun updatePassphraseType( crossinline block: (Passphrase) -> Passphrase, ) { @@ -268,8 +419,7 @@ class GeneratorViewModel @Inject constructor( val INITIAL_STATE: GeneratorState = GeneratorState( generatedText = PLACEHOLDER_GENERATED_TEXT, selectedType = Passcode( - // TODO (BIT-634): Update the initial state to Password - selectedType = Passphrase(), + selectedType = Password(), ), ) } @@ -358,19 +508,41 @@ data class GeneratorState( abstract val displayStringResId: Int /** - * Represents a standard PASSWORD type, with a specified length. + * Represents a standard PASSWORD type, with configurable options for + * length, character types, and requirements. * * @property length The length of the generated password. + * @property useCapitals Whether to include capital letters. + * @property useLowercase Whether to include lowercase letters. + * @property useNumbers Whether to include numbers. + * @property useSpecialChars Whether to include special characters. + * @property minNumbers The minimum number of numeric characters. + * @property minSpecial The minimum number of special characters. + * @property avoidAmbiguousChars Whether to avoid characters that look similar. */ @Parcelize data class Password( val length: Int = DEFAULT_PASSWORD_LENGTH, + val useCapitals: Boolean = true, + val useLowercase: Boolean = true, + val useNumbers: Boolean = true, + val useSpecialChars: Boolean = false, + val minNumbers: Int = MIN_NUMBERS, + val minSpecial: Int = MIN_SPECIAL, + val avoidAmbiguousChars: Boolean = false, ) : PasscodeType(), Parcelable { override val displayStringResId: Int get() = PasscodeTypeOption.PASSWORD.labelRes companion object { - const val DEFAULT_PASSWORD_LENGTH: Int = 10 + private const val DEFAULT_PASSWORD_LENGTH: Int = 14 + private const val MIN_NUMBERS: Int = 1 + private const val MIN_SPECIAL: Int = 1 + + const val PASSWORD_LENGTH_SLIDER_MIN: Int = 5 + const val PASSWORD_LENGTH_SLIDER_MAX: Int = 128 + const val PASSWORD_COUNTER_MIN: Int = 0 + const val PASSWORD_COUNTER_MAX: Int = 5 } } @@ -470,7 +642,94 @@ sealed class GeneratorAction { /** * Represents actions specifically related to passwords, a subtype of passcode. */ - sealed class Password : PasscodeType() + sealed class Password : PasscodeType() { + /** + * Represents a change action for the length of the password, + * adjusted using a slider. + * + * @property length The new desired length for the password. + */ + data class SliderLengthChange( + val length: Int, + ) : Password() + + /** + * Represents a change action to toggle the usage of + * capital letters in the password. + * + * @property useCapitals Flag indicating whether capital letters + * should be used. + */ + data class ToggleCapitalLettersChange( + val useCapitals: Boolean, + ) : Password() + + /** + * Represents a change action to toggle the usage of lowercase letters + * in the password. + * + * @property useLowercase Flag indicating whether lowercase letters + * should be used. + */ + data class ToggleLowercaseLettersChange( + val useLowercase: Boolean, + ) : Password() + + /** + * Represents a change action to toggle the inclusion of numbers + * in the password. + * + * @property useNumbers Flag indicating whether numbers + * should be used. + */ + data class ToggleNumbersChange( + val useNumbers: Boolean, + ) : Password() + + /** + * Represents a change action to toggle the usage of special characters + * in the password. + * + * @property useSpecialChars Flag indicating whether special characters + * should be used. + */ + data class ToggleSpecialCharactersChange( + val useSpecialChars: Boolean, + ) : Password() + + /** + * Represents a change action for the minimum required number of numbers + * in the password. + * + * @property minNumbers The minimum required number of numbers + * for the password. + */ + data class MinNumbersCounterChange( + val minNumbers: Int, + ) : Password() + + /** + * Represents a change action for the minimum required number of special + * characters in the password. + * + * @property minSpecial The minimum required number of special characters + * for the password. + */ + data class MinSpecialCharactersChange( + val minSpecial: Int, + ) : Password() + + /** + * Represents a change action to toggle the avoidance of ambiguous + * characters in the password. + * + * @property avoidAmbiguousChars Flag indicating whether ambiguous characters + * should be avoided. + */ + data class ToggleAvoidAmbigousCharactersChange( + val avoidAmbiguousChars: Boolean, + ) : Password() + } /** * Represents actions specifically related to passphrases, a subtype of passcode. diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/tools/feature/generator/GeneratorScreenTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/tools/feature/generator/GeneratorScreenTest.kt index 610ce843b7..613a8247ef 100644 --- a/app/src/test/java/com/x8bit/bitwarden/ui/tools/feature/generator/GeneratorScreenTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/ui/tools/feature/generator/GeneratorScreenTest.kt @@ -2,19 +2,29 @@ package com.x8bit.bitwarden.ui.tools.feature.generator +import androidx.compose.ui.semantics.ProgressBarRangeInfo import androidx.compose.ui.semantics.Role.Companion.Switch +import androidx.compose.ui.semantics.SemanticsProperties import androidx.compose.ui.semantics.SemanticsProperties.Role import androidx.compose.ui.test.SemanticsMatcher.Companion.expectValue +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.hasContentDescription +import androidx.compose.ui.test.hasProgressBarRangeInfo import androidx.compose.ui.test.onAllNodesWithText import androidx.compose.ui.test.onChildren import androidx.compose.ui.test.onLast import androidx.compose.ui.test.onNodeWithContentDescription import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.onSiblings import androidx.compose.ui.test.performClick import androidx.compose.ui.test.performScrollTo import androidx.compose.ui.test.performTextInput +import androidx.compose.ui.test.performTouchInput +import androidx.compose.ui.test.swipeRight +import androidx.compose.ui.text.AnnotatedString import com.x8bit.bitwarden.ui.platform.base.BaseComposeTest import io.mockk.every import io.mockk.mockk @@ -79,6 +89,333 @@ class GeneratorScreenTest : BaseComposeTest() { } } + //region Passcode Password Tests + + @Test + fun `in Passcode_Password state, the ViewModel state should update the UI correctly`() { + composeTestRule.setContent { + GeneratorScreen(viewModel = viewModel) + } + + composeTestRule + .onNodeWithContentDescription(label = "What would you like to generate?, Password") + .assertIsDisplayed() + + composeTestRule + .onNodeWithContentDescription(label = "Password, Password") + .assertIsDisplayed() + + composeTestRule + .onNode( + expectValue( + SemanticsProperties.EditableText, AnnotatedString("14"), + ), + ) + .assertExists() + + composeTestRule + .onNodeWithText("Uppercase (A to Z)") + .onChildren() + .filterToOne(expectValue(Role, Switch)) + .assertIsOn() + + composeTestRule + .onNodeWithText("Lowercase (A to Z)") + .onChildren() + .filterToOne(expectValue(Role, Switch)) + .assertIsOn() + + composeTestRule + .onNodeWithText("Numbers (0 to 9)") + .onChildren() + .filterToOne(expectValue(Role, Switch)) + .assertIsOn() + + composeTestRule + .onNodeWithText("Special characters (!@#$%^*)") + .onChildren() + .filterToOne(expectValue(Role, Switch)) + .assertIsOff() + + composeTestRule + .onNodeWithContentDescription("Minimum numbers, 1") + .onChildren() + .filterToOne(hasContentDescription("\u2212")) + .performScrollTo() + .assertIsDisplayed() + + composeTestRule + .onNodeWithContentDescription("Minimum numbers, 1") + .onChildren() + .filterToOne(hasContentDescription("+")) + .performScrollTo() + .assertIsDisplayed() + + composeTestRule + .onNodeWithText("Avoid ambiguous characters") + .onChildren() + .filterToOne(expectValue(Role, Switch)) + .performScrollTo() + .assertIsOff() + } + + @Test + fun `in Passcode_Password state, adjusting the slider should send SliderLengthChange action with length not equal to default`() { + composeTestRule.setContent { + GeneratorScreen(viewModel = viewModel) + } + + composeTestRule + .onNodeWithText("Length") + .onSiblings() + .filterToOne( + hasProgressBarRangeInfo( + ProgressBarRangeInfo( + current = 13.6484375f, + range = 5.0f..128.0f, + steps = 127, + ), + ), + ) + .performScrollTo() + .performTouchInput { + swipeRight(50f, 800f) + } + + verify { + viewModel.trySendAction( + GeneratorAction.MainType.Passcode.PasscodeType.Password.SliderLengthChange( + length = 128, + ), + ) + } + } + + @Test + fun `in Passcode_Password state, toggling the capital letters toggle should send ToggleCapitalLettersChange action`() { + composeTestRule.setContent { + GeneratorScreen(viewModel = viewModel) + } + + composeTestRule.onNodeWithText("Uppercase (A to Z)") + .onChildren() + .filterToOne(expectValue(Role, Switch)) + .performScrollTo() + .performClick() + + verify { + viewModel.trySendAction( + GeneratorAction.MainType.Passcode.PasscodeType.Password.ToggleCapitalLettersChange( + useCapitals = false, + ), + ) + } + } + + @Test + fun `in Passcode_Password state, toggling the use lowercase toggle should send ToggleLowercaseLettersChange action`() { + composeTestRule.setContent { + GeneratorScreen(viewModel = viewModel) + } + + composeTestRule.onNodeWithText("Lowercase (A to Z)") + .onChildren() + .filterToOne(expectValue(Role, Switch)) + .performScrollTo() + .performClick() + + verify { + viewModel.trySendAction( + GeneratorAction.MainType.Passcode.PasscodeType.Password.ToggleLowercaseLettersChange( + useLowercase = false, + ), + ) + } + } + + @Test + fun `in Passcode_Password state, toggling the use numbers toggle should send ToggleNumbersChange action`() { + composeTestRule.setContent { + GeneratorScreen(viewModel = viewModel) + } + + composeTestRule.onNodeWithText("Numbers (0 to 9)") + .onChildren() + .filterToOne(expectValue(Role, Switch)) + .performScrollTo() + .performClick() + + verify { + viewModel.trySendAction( + GeneratorAction.MainType.Passcode.PasscodeType.Password.ToggleNumbersChange( + useNumbers = false, + ), + ) + } + } + + @Test + fun `in Passcode_Password state, toggling the use special characters toggle should send ToggleSpecialCharactersChange action`() { + composeTestRule.setContent { + GeneratorScreen(viewModel = viewModel) + } + + composeTestRule.onNodeWithText("Special characters (!@#$%^*)") + .onChildren() + .filterToOne(expectValue(Role, Switch)) + .performScrollTo() + .performClick() + + verify { + viewModel.trySendAction( + GeneratorAction.MainType.Passcode.PasscodeType.Password.ToggleSpecialCharactersChange( + useSpecialChars = true, + ), + ) + } + } + + @Test + fun `in Passcode_Password state, decrementing the minimum numbers counter should send MinNumbersCounterChange action`() { + val initialMinNumbers = 1 + updateState( + GeneratorState( + generatedText = "Placeholder", + selectedType = GeneratorState.MainType.Passcode(GeneratorState.MainType.Passcode.PasscodeType.Password()), + ), + ) + + composeTestRule.setContent { + GeneratorScreen(viewModel = viewModel) + } + + composeTestRule.onNodeWithContentDescription("Minimum numbers, 1") + .onChildren() + .filterToOne(hasContentDescription("\u2212")) + .performScrollTo() + .performClick() + + verify { + viewModel.trySendAction( + GeneratorAction.MainType.Passcode.PasscodeType.Password.MinNumbersCounterChange( + minNumbers = initialMinNumbers - 1, + ), + ) + } + } + + @Test + fun `in Passcode_Password state, incrementing the minimum numbers counter should send MinNumbersCounterChange action`() { + val initialMinNumbers = 1 + updateState( + GeneratorState( + generatedText = "Placeholder", + selectedType = GeneratorState.MainType.Passcode(GeneratorState.MainType.Passcode.PasscodeType.Password()), + ), + ) + + composeTestRule.setContent { + GeneratorScreen(viewModel = viewModel) + } + + composeTestRule.onNodeWithContentDescription("Minimum numbers, 1") + .onChildren() + .filterToOne(hasContentDescription("+")) + .performScrollTo() + .performClick() + + verify { + viewModel.trySendAction( + GeneratorAction.MainType.Passcode.PasscodeType.Password.MinNumbersCounterChange( + minNumbers = initialMinNumbers + 1, + ), + ) + } + } + + @Test + fun `in Passcode_Password state, decrementing the minimum special characters counter should send MinSpecialCharactersChange action`() { + val initialSpecialChars = 1 + updateState( + GeneratorState( + generatedText = "Placeholder", + selectedType = GeneratorState.MainType.Passcode(GeneratorState.MainType.Passcode.PasscodeType.Password()), + ), + ) + + composeTestRule.setContent { + GeneratorScreen(viewModel = viewModel) + } + + composeTestRule.onNodeWithContentDescription("Minimum special, 1") + .onChildren() + .filterToOne(hasContentDescription("\u2212")) + .performScrollTo() + .performClick() + + verify { + viewModel.trySendAction( + GeneratorAction.MainType.Passcode.PasscodeType.Password.MinSpecialCharactersChange( + minSpecial = initialSpecialChars - 1, + ), + ) + } + } + + @Test + fun `in Passcode_Password state, incrementing the minimum special characters counter should send MinSpecialCharactersChange action`() { + val initialSpecialChars = 1 + updateState( + GeneratorState( + generatedText = "Placeholder", + selectedType = GeneratorState.MainType.Passcode(GeneratorState.MainType.Passcode.PasscodeType.Password()), + ), + ) + + composeTestRule.setContent { + GeneratorScreen(viewModel = viewModel) + } + + composeTestRule.onNodeWithContentDescription("Minimum special, 1") + .onChildren() + .filterToOne(hasContentDescription("+")) + .performScrollTo() + .performClick() + + verify { + viewModel.trySendAction( + GeneratorAction.MainType.Passcode.PasscodeType.Password.MinSpecialCharactersChange( + minSpecial = initialSpecialChars + 1, + ), + ) + } + } + + @Test + fun `in Passcode_Password state, toggling the use avoid ambiguous characters toggle should send ToggleSpecialCharactersChange action`() { + composeTestRule.setContent { + GeneratorScreen(viewModel = viewModel) + } + + composeTestRule.onNodeWithText("Avoid ambiguous characters") + .onChildren() + .filterToOne(expectValue(Role, Switch)) + .performScrollTo() + .performClick() + + verify { + viewModel.trySendAction( + GeneratorAction.MainType.Passcode.PasscodeType.Password.ToggleAvoidAmbigousCharactersChange( + avoidAmbiguousChars = true, + ), + ) + } + } + + //endregion Passcode Password Tests + + //region Passcode Passphrase Tests + @Test fun `in Passcode_Passphrase state, decrementing number of words should send NumWordsCounterChange action with decremented value`() { val initialNumWords = 3 @@ -224,6 +561,8 @@ class GeneratorScreenTest : BaseComposeTest() { } } + //endregion Passcode Passphrase Tests + private fun updateState(state: GeneratorState) { mutableStateFlow.value = state } diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/tools/feature/generator/GeneratorViewModelTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/tools/feature/generator/GeneratorViewModelTest.kt index 3398fbeb68..c66f0a6560 100644 --- a/app/src/test/java/com/x8bit/bitwarden/ui/tools/feature/generator/GeneratorViewModelTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/ui/tools/feature/generator/GeneratorViewModelTest.kt @@ -106,6 +106,210 @@ class GeneratorViewModelTest : BaseViewModelTest() { assertEquals(expectedState, viewModel.stateFlow.value) } + @Nested + inner class PasswordActions { + private val defaultPasswordState = createPasswordState() + private lateinit var viewModel: GeneratorViewModel + + @BeforeEach + fun setup() { + viewModel = GeneratorViewModel(initialSavedStateHandle) + } + + @Test + fun `SliderLengthChange should update password length correctly to new value`() = runTest { + viewModel.eventFlow.test { + val newLength = 16 + + viewModel.actionChannel.trySend( + GeneratorAction.MainType.Passcode.PasscodeType.Password.SliderLengthChange( + length = newLength, + ), + ) + + val expectedState = defaultPasswordState.copy( + generatedText = "redlohecalP", + selectedType = GeneratorState.MainType.Passcode( + GeneratorState.MainType.Passcode.PasscodeType.Password( + length = newLength, + ), + ), + ) + + assertEquals(expectedState, viewModel.stateFlow.value) + } + } + + @Test + fun `ToggleCapitalLettersChange should update useCapitals correctly`() = runTest { + viewModel.eventFlow.test { + val useCapitals = true + + viewModel.actionChannel.trySend( + GeneratorAction.MainType.Passcode.PasscodeType.Password.ToggleCapitalLettersChange( + useCapitals = useCapitals, + ), + ) + + val expectedState = defaultPasswordState.copy( + generatedText = "redlohecalP", + selectedType = GeneratorState.MainType.Passcode( + GeneratorState.MainType.Passcode.PasscodeType.Password( + useCapitals = useCapitals, + ), + ), + ) + + assertEquals(expectedState, viewModel.stateFlow.value) + } + } + + @Test + fun `ToggleLowercaseLettersChange should update useLowercase correctly`() = runTest { + viewModel.eventFlow.test { + val useLowercase = true + + viewModel.actionChannel.trySend( + GeneratorAction.MainType.Passcode.PasscodeType.Password.ToggleLowercaseLettersChange( + useLowercase = useLowercase, + ), + ) + + val expectedState = defaultPasswordState.copy( + generatedText = "redlohecalP", + selectedType = GeneratorState.MainType.Passcode( + GeneratorState.MainType.Passcode.PasscodeType.Password( + useLowercase = useLowercase, + ), + ), + ) + + assertEquals(expectedState, viewModel.stateFlow.value) + } + } + + @Test + fun `ToggleNumbersChange should update useNumbers correctly`() = runTest { + viewModel.eventFlow.test { + val useNumbers = true + + viewModel.actionChannel.trySend( + GeneratorAction.MainType.Passcode.PasscodeType.Password.ToggleNumbersChange( + useNumbers = useNumbers, + ), + ) + + val expectedState = defaultPasswordState.copy( + generatedText = "redlohecalP", + selectedType = GeneratorState.MainType.Passcode( + GeneratorState.MainType.Passcode.PasscodeType.Password( + useNumbers = useNumbers, + ), + ), + ) + + assertEquals(expectedState, viewModel.stateFlow.value) + } + } + + @Test + fun `ToggleSpecialCharactersChange should update useSpecialChars correctly`() = runTest { + viewModel.eventFlow.test { + val useSpecialChars = true + + viewModel.actionChannel.trySend( + GeneratorAction.MainType.Passcode.PasscodeType.Password.ToggleSpecialCharactersChange( + useSpecialChars = useSpecialChars, + ), + ) + + val expectedState = defaultPasswordState.copy( + generatedText = "redlohecalP", + selectedType = GeneratorState.MainType.Passcode( + GeneratorState.MainType.Passcode.PasscodeType.Password( + useSpecialChars = useSpecialChars, + ), + ), + ) + + assertEquals(expectedState, viewModel.stateFlow.value) + } + } + + @Test + fun `MinNumbersCounterChange should update minNumbers correctly`() = runTest { + viewModel.eventFlow.test { + val minNumbers = 4 + + viewModel.actionChannel.trySend( + GeneratorAction.MainType.Passcode.PasscodeType.Password.MinNumbersCounterChange( + minNumbers = minNumbers, + ), + ) + + val expectedState = defaultPasswordState.copy( + generatedText = "redlohecalP", + selectedType = GeneratorState.MainType.Passcode( + GeneratorState.MainType.Passcode.PasscodeType.Password( + minNumbers = minNumbers, + ), + ), + ) + + assertEquals(expectedState, viewModel.stateFlow.value) + } + } + + @Test + fun `MinSpecialCharactersChange should update minSpecial correctly`() = runTest { + viewModel.eventFlow.test { + val minSpecial = 2 + + viewModel.actionChannel.trySend( + GeneratorAction.MainType.Passcode.PasscodeType.Password.MinSpecialCharactersChange( + minSpecial = minSpecial, + ), + ) + + val expectedState = defaultPasswordState.copy( + generatedText = "redlohecalP", + selectedType = GeneratorState.MainType.Passcode( + GeneratorState.MainType.Passcode.PasscodeType.Password( + minSpecial = minSpecial, + ), + ), + ) + + assertEquals(expectedState, viewModel.stateFlow.value) + } + } + + @Test + fun `ToggleAvoidAmbigousCharactersChange should update avoidAmbiguousChars correctly`() = + runTest { + viewModel.eventFlow.test { + val avoidAmbiguousChars = true + + viewModel.actionChannel.trySend( + GeneratorAction.MainType.Passcode.PasscodeType.Password.ToggleAvoidAmbigousCharactersChange( + avoidAmbiguousChars = avoidAmbiguousChars, + ), + ) + + val expectedState = defaultPasswordState.copy( + generatedText = "redlohecalP", + selectedType = GeneratorState.MainType.Passcode( + GeneratorState.MainType.Passcode.PasscodeType.Password( + avoidAmbiguousChars = avoidAmbiguousChars, + ), + ), + ) + + assertEquals(expectedState, viewModel.stateFlow.value) + } + } + } + @Nested inner class PassphraseActions { @@ -221,14 +425,32 @@ class GeneratorViewModelTest : BaseViewModelTest() { } //region Helper Functions + @Suppress("LongParameterList") + private fun createPasswordState( generatedText: String = "Placeholder", - length: Int = 10, + length: Int = 14, + useCapitals: Boolean = true, + useLowercase: Boolean = true, + useNumbers: Boolean = true, + useSpecialChars: Boolean = false, + minNumbers: Int = 1, + minSpecial: Int = 1, + avoidAmbiguousChars: Boolean = false, ): GeneratorState = GeneratorState( generatedText = generatedText, selectedType = GeneratorState.MainType.Passcode( - GeneratorState.MainType.Passcode.PasscodeType.Password(length = length), + GeneratorState.MainType.Passcode.PasscodeType.Password( + length = length, + useCapitals = useCapitals, + useLowercase = useLowercase, + useNumbers = useNumbers, + useSpecialChars = useSpecialChars, + minNumbers = minNumbers, + minSpecial = minSpecial, + avoidAmbiguousChars = avoidAmbiguousChars, + ), ), )