diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/platform/components/BitwardenMultiSelectButton.kt b/app/src/main/java/com/x8bit/bitwarden/ui/platform/components/BitwardenMultiSelectButton.kt index db2fd0581..cea7ee634 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/platform/components/BitwardenMultiSelectButton.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/platform/components/BitwardenMultiSelectButton.kt @@ -44,7 +44,8 @@ import kotlinx.collections.immutable.persistentListOf * * @param label The descriptive text label for the [OutlinedTextField]. * @param options A list of strings representing the available options in the dialog. - * @param selectedOption The currently selected option that is displayed in the [OutlinedTextField]. + * @param selectedOption The currently selected option that is displayed in the [OutlinedTextField] + * (or `null` if no option is selected). * @param onOptionSelected A lambda that is invoked when an option * is selected from the dropdown menu. * @param modifier A [Modifier] that you can use to apply custom modifications to the composable. @@ -56,7 +57,7 @@ import kotlinx.collections.immutable.persistentListOf fun BitwardenMultiSelectButton( label: String, options: ImmutableList, - selectedOption: String, + selectedOption: String?, onOptionSelected: (String) -> Unit, modifier: Modifier = Modifier, supportingText: String? = null, @@ -104,7 +105,7 @@ fun BitwardenMultiSelectButton( } } }, - value = selectedOption, + value = selectedOption ?: "", onValueChange = onOptionSelected, enabled = shouldShowDialog, trailingIcon = { 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 1cba66fee..2c0135e1c 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 @@ -47,7 +47,6 @@ import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle -import androidx.lifecycle.viewmodel.compose.viewModel import com.x8bit.bitwarden.R import com.x8bit.bitwarden.ui.platform.base.util.EventsEffect import com.x8bit.bitwarden.ui.platform.base.util.toDp @@ -56,6 +55,7 @@ import com.x8bit.bitwarden.ui.platform.components.BitwardenListHeaderText import com.x8bit.bitwarden.ui.platform.components.BitwardenMediumTopAppBar import com.x8bit.bitwarden.ui.platform.components.BitwardenMultiSelectButton import com.x8bit.bitwarden.ui.platform.components.BitwardenOverflowActionItem +import com.x8bit.bitwarden.ui.platform.components.BitwardenPasswordField import com.x8bit.bitwarden.ui.platform.components.BitwardenReadOnlyTextFieldWithActions import com.x8bit.bitwarden.ui.platform.components.BitwardenScaffold import com.x8bit.bitwarden.ui.platform.components.BitwardenStepper @@ -67,6 +67,8 @@ import com.x8bit.bitwarden.ui.platform.components.model.TooltipData import com.x8bit.bitwarden.ui.platform.components.util.nonLetterColorVisualTransformation import com.x8bit.bitwarden.ui.platform.theme.BitwardenTheme import com.x8bit.bitwarden.ui.platform.theme.LocalNonMaterialTypography +import com.x8bit.bitwarden.ui.tools.feature.generator.GeneratorState.MainType.Username.UsernameType.ForwardedEmailAlias.ServiceType +import com.x8bit.bitwarden.ui.tools.feature.generator.GeneratorState.MainType.Username.UsernameType.ForwardedEmailAlias.ServiceTypeOption 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.Companion.PASSWORD_COUNTER_MAX @@ -151,6 +153,10 @@ fun GeneratorScreen( PassphraseHandlers.create(viewModel = viewModel) } + val forwardedEmailAliasHandlers = remember(viewModel) { + ForwardedEmailAliasHandlers.create(viewModel = viewModel) + } + val plusAddressedEmailHandlers = remember(viewModel) { PlusAddressedEmailHandlers.create(viewModel = viewModel) } @@ -203,6 +209,7 @@ fun GeneratorScreen( onUsernameSubStateOptionClicked = onUsernameOptionClicked, passwordHandlers = passwordHandlers, passphraseHandlers = passphraseHandlers, + forwardedEmailAliasHandlers = forwardedEmailAliasHandlers, plusAddressedEmailHandlers = plusAddressedEmailHandlers, catchAllEmailHandlers = catchAllEmailHandlers, randomWordHandlers = randomWordHandlers, @@ -224,6 +231,7 @@ private fun ScrollContent( onUsernameSubStateOptionClicked: (GeneratorState.MainType.Username.UsernameTypeOption) -> Unit, passwordHandlers: PasswordHandlers, passphraseHandlers: PassphraseHandlers, + forwardedEmailAliasHandlers: ForwardedEmailAliasHandlers, plusAddressedEmailHandlers: PlusAddressedEmailHandlers, catchAllEmailHandlers: CatchAllEmailHandlers, randomWordHandlers: RandomWordHandlers, @@ -274,6 +282,7 @@ private fun ScrollContent( UsernameTypeItems( usernameState = selectedType, onSubStateOptionClicked = onUsernameSubStateOptionClicked, + forwardedEmailAliasHandlers = forwardedEmailAliasHandlers, plusAddressedEmailHandlers = plusAddressedEmailHandlers, catchAllEmailHandlers = catchAllEmailHandlers, randomWordHandlers = randomWordHandlers, @@ -734,6 +743,7 @@ private fun PassphraseIncludeNumberToggleItem( private fun ColumnScope.UsernameTypeItems( usernameState: GeneratorState.MainType.Username, onSubStateOptionClicked: (GeneratorState.MainType.Username.UsernameTypeOption) -> Unit, + forwardedEmailAliasHandlers: ForwardedEmailAliasHandlers, plusAddressedEmailHandlers: PlusAddressedEmailHandlers, catchAllEmailHandlers: CatchAllEmailHandlers, randomWordHandlers: RandomWordHandlers, @@ -749,7 +759,10 @@ private fun ColumnScope.UsernameTypeItems( } is GeneratorState.MainType.Username.UsernameType.ForwardedEmailAlias -> { - // TODO: Implement ForwardedEmailAlias BIT-657 + ForwardedEmailAliasTypeContent( + usernameTypeState = selectedType, + forwardedEmailAliasHandlers = forwardedEmailAliasHandlers, + ) } is GeneratorState.MainType.Username.UsernameType.CatchAllEmail -> { @@ -773,7 +786,7 @@ private fun UsernameOptionsItem( currentSubState: GeneratorState.MainType.Username, onSubStateOptionClicked: (GeneratorState.MainType.Username.UsernameTypeOption) -> Unit, ) { - val possibleSubStates = GeneratorState.MainType.Username.UsernameTypeOption.values().toList() + val possibleSubStates = GeneratorState.MainType.Username.UsernameTypeOption.entries val optionsWithStrings = possibleSubStates.associateWith { stringResource(id = it.labelRes) } BitwardenMultiSelectButton( @@ -802,6 +815,83 @@ private fun UsernameOptionsItem( //endregion UsernameType Composables +//region ForwardedEmailAliasType Composables + +@Composable +private fun ColumnScope.ForwardedEmailAliasTypeContent( + usernameTypeState: GeneratorState.MainType.Username.UsernameType.ForwardedEmailAlias, + forwardedEmailAliasHandlers: ForwardedEmailAliasHandlers, +) { + Spacer(modifier = Modifier.height(8.dp)) + + ServiceTypeOptionsItem( + currentSubState = usernameTypeState, + onSubStateOptionClicked = forwardedEmailAliasHandlers.onServiceChange, + ) + + Spacer(modifier = Modifier.height(8.dp)) + + when (usernameTypeState.selectedServiceType) { + + is ServiceType.AnonAddy -> { + // TODO: AnonAddy Service Implementation (BIT-711) + } + + is ServiceType.DuckDuckGo -> { + // TODO: DuckDuckGo Service Implementation (BIT-714) + } + + is ServiceType.FastMail -> { + // TODO: FastMail Service Implementation (BIT-712) + } + + is ServiceType.FirefoxRelay -> { + // TODO: FirefoxRelay Service Implementation (BIT-1196) + } + + is ServiceType.SimpleLogin -> { + // TODO: SimpleLogin Service Implementation (BIT-713) + } + + null -> { + var obfuscatedTextField by remember { mutableStateOf("") } + BitwardenPasswordField( + label = "", + value = obfuscatedTextField, + onValueChange = { obfuscatedTextField = it }, + modifier = Modifier + .padding(horizontal = 16.dp) + .fillMaxWidth(), + ) + } + } +} + +@Composable +private fun ServiceTypeOptionsItem( + currentSubState: GeneratorState.MainType.Username.UsernameType.ForwardedEmailAlias, + onSubStateOptionClicked: (ServiceTypeOption) -> Unit, +) { + val possibleSubStates = ServiceTypeOption.entries + val optionsWithStrings = possibleSubStates.associateWith { stringResource(id = it.labelRes) } + + BitwardenMultiSelectButton( + label = stringResource(id = R.string.service), + options = optionsWithStrings.values.toImmutableList(), + selectedOption = (currentSubState.selectedServiceType?.displayStringResId)?.let { + stringResource(id = it) + }, + onOptionSelected = { selectedOption -> + val selectedOptionId = + optionsWithStrings.entries.first { it.value == selectedOption }.key + onSubStateOptionClicked(selectedOptionId) + }, + modifier = Modifier + .padding(horizontal = 16.dp) + .fillMaxWidth(), + ) +} + //region PlusAddressedEmailType Composables @Composable @@ -1073,6 +1163,35 @@ private class PassphraseHandlers( } } +/** + * A class dedicated to handling user interactions related to forwarded email alias + * configuration. + * Each lambda corresponds to a specific user action, allowing for easy delegation of + * logic when user input is detected. + */ +private class ForwardedEmailAliasHandlers( + val onServiceChange: (ServiceTypeOption) -> Unit, +) { + companion object { + fun create(viewModel: GeneratorViewModel): ForwardedEmailAliasHandlers { + return ForwardedEmailAliasHandlers( + onServiceChange = { newServiceTypeOption -> + viewModel.trySendAction( + GeneratorAction + .MainType + .Username + .UsernameType + .ForwardedEmailAlias + .ServiceTypeOptionSelect( + serviceTypeOption = newServiceTypeOption, + ), + ) + }, + ) + } + } +} + /** * A class dedicated to handling user interactions related to plus addressed email * configuration. 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 9ded35a85..a84945aed 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 @@ -21,7 +21,8 @@ import com.x8bit.bitwarden.ui.tools.feature.generator.GeneratorState.MainType.Pa import com.x8bit.bitwarden.ui.tools.feature.generator.GeneratorState.MainType.Passcode.PasscodeTypeOption import com.x8bit.bitwarden.ui.tools.feature.generator.GeneratorState.MainType.Username import com.x8bit.bitwarden.ui.tools.feature.generator.GeneratorState.MainType.Username.UsernameType.CatchAllEmail -import com.x8bit.bitwarden.ui.tools.feature.generator.GeneratorState.MainType.Username.UsernameType.ForwardedEmailAlias.ServiceType.AnonAddy +import com.x8bit.bitwarden.ui.tools.feature.generator.GeneratorState.MainType.Username.UsernameType.ForwardedEmailAlias +import com.x8bit.bitwarden.ui.tools.feature.generator.GeneratorState.MainType.Username.UsernameType.ForwardedEmailAlias.ServiceType import com.x8bit.bitwarden.ui.tools.feature.generator.GeneratorState.MainType.Username.UsernameType.PlusAddressedEmail import com.x8bit.bitwarden.ui.tools.feature.generator.GeneratorState.MainType.Username.UsernameType.RandomWord import dagger.hilt.android.lifecycle.HiltViewModel @@ -44,6 +45,7 @@ private const val KEY_STATE = "state" * * @property savedStateHandle Handles the saved state of this ViewModel. */ +@Suppress("LargeClass") @HiltViewModel class GeneratorViewModel @Inject constructor( private val savedStateHandle: SavedStateHandle, @@ -107,6 +109,10 @@ class GeneratorViewModel @Inject constructor( handleUsernameTypeOptionSelect(action) } + is GeneratorAction.MainType.Username.UsernameType.ForwardedEmailAlias.ServiceTypeOptionSelect -> { + handleServiceTypeOptionSelect(action) + } + is GeneratorAction.MainType.Username.UsernameType.PlusAddressedEmail.EmailTextChange -> { handlePlusAddressedEmailTextInputChange(action) } @@ -553,6 +559,41 @@ class GeneratorViewModel @Inject constructor( //endregion Username Type Handlers + //region Forwarded Email Alias Specific Handlers + + private fun handleServiceTypeOptionSelect( + action: GeneratorAction + .MainType + .Username + .UsernameType + .ForwardedEmailAlias + .ServiceTypeOptionSelect, + ) { + when (action.serviceTypeOption) { + ForwardedEmailAlias.ServiceTypeOption.ANON_ADDY -> updateForwardedEmailAliasType { + ForwardedEmailAlias(selectedServiceType = ServiceType.AnonAddy()) + } + + ForwardedEmailAlias.ServiceTypeOption.DUCK_DUCK_GO -> updateForwardedEmailAliasType { + ForwardedEmailAlias(selectedServiceType = ServiceType.DuckDuckGo()) + } + + ForwardedEmailAlias.ServiceTypeOption.FAST_MAIL -> updateForwardedEmailAliasType { + ForwardedEmailAlias(selectedServiceType = ServiceType.FastMail()) + } + + ForwardedEmailAlias.ServiceTypeOption.FIREFOX_RELAY -> updateForwardedEmailAliasType { + ForwardedEmailAlias(selectedServiceType = ServiceType.FirefoxRelay()) + } + + ForwardedEmailAlias.ServiceTypeOption.SIMPLE_LOGIN -> updateForwardedEmailAliasType { + ForwardedEmailAlias(selectedServiceType = ServiceType.SimpleLogin()) + } + } + } + + //endregion Forwarded Email Alias Specific Handlers + //region Plus Addressed Email Specific Handlers private fun handlePlusAddressedEmailTextInputChange( @@ -699,6 +740,18 @@ class GeneratorViewModel @Inject constructor( } } + private inline fun updateForwardedEmailAliasType( + crossinline block: (ForwardedEmailAlias) -> ForwardedEmailAlias, + ) { + updateGeneratorMainTypeUsername { currentSelectedType -> + val currentUsernameType = currentSelectedType.selectedType + if (currentUsernameType !is ForwardedEmailAlias) { + return@updateGeneratorMainTypeUsername currentSelectedType + } + currentSelectedType.copy(selectedType = block(currentUsernameType)) + } + } + private inline fun updatePlusAddressedEmailType( crossinline block: (PlusAddressedEmail) -> PlusAddressedEmail, ) { @@ -1007,7 +1060,8 @@ data class GeneratorState( */ @Parcelize data class ForwardedEmailAlias( - val selectedServiceType: ServiceType = AnonAddy(), + val selectedServiceType: ServiceType? = null, + val obfuscatedText: String = "", ) : UsernameType(), Parcelable { override val displayStringResId: Int get() = UsernameTypeOption.FORWARDED_EMAIL_ALIAS.labelRes @@ -1042,7 +1096,7 @@ data class GeneratorState( * this property to provide the appropriate string resource ID for * its display string. */ - abstract val displayStringResId: Int + abstract val displayStringResId: Int? /** * Represents the Anon Addy service type, with a configurable option for @@ -1329,6 +1383,26 @@ sealed class GeneratorAction { */ sealed class UsernameType : Username() { + /** + * Represents actions specifically related to Forwarded Email Alias. + */ + sealed class ForwardedEmailAlias : UsernameType() { + + /** + * Represents the action of selecting a service type option. + * + * @property serviceTypeOption The selected service type option. + */ + data class ServiceTypeOptionSelect( + val serviceTypeOption: GeneratorState + .MainType + .Username + .UsernameType + .ForwardedEmailAlias + .ServiceTypeOption, + ) : ForwardedEmailAlias() + } + /** * Represents actions specifically related to Plus Addressed Email. */ 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 b1b8d2513..eaf772aef 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 @@ -874,6 +874,59 @@ class GeneratorScreenTest : BaseComposeTest() { //endregion Passcode Passphrase Tests + //region Forwarded Email Alias Tests + + @Suppress("MaxLineLength") + @Test + fun `in Username_ForwardedEmailAlias state, updating the service type should send ServiceTypeOptionSelect action`() { + updateState( + GeneratorState( + generatedText = "Placeholder", + selectedType = GeneratorState.MainType.Username( + GeneratorState.MainType.Username.UsernameType.ForwardedEmailAlias( + selectedServiceType = null, + ), + ), + ), + ) + + val newServiceType = GeneratorState + .MainType + .Username + .UsernameType + .ForwardedEmailAlias + .ServiceTypeOption + .ANON_ADDY + + // Opens the menu + composeTestRule + .onNodeWithContentDescription(label = "Service, null") + .performScrollTo() + .performClick() + + // Choose the option from the menu + composeTestRule + .onAllNodesWithText(text = "addy.io") + .onLast() + .assert(hasAnyAncestor(isDialog())) + .performClick() + + verify { + viewModel.trySendAction( + GeneratorAction.MainType.Username.UsernameType.ForwardedEmailAlias.ServiceTypeOptionSelect( + newServiceType, + ), + ) + } + + // Make sure dialog is hidden: + composeTestRule + .onNode(isDialog()) + .assertDoesNotExist() + } + + //endregion Forwarded Email Alias Tests + //region Username Plus Addressed Email Tests @Suppress("MaxLineLength") 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 ccdb2bf94..81e276fc7 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 @@ -27,6 +27,10 @@ class GeneratorViewModelTest : BaseViewModelTest() { private val initialUsernameState = createPlusAddressedEmailState() private val usernameSavedStateHandle = createSavedStateHandleWithState(initialUsernameState) + private val initialForwardedEmailAliasState = createForwardedEmailAliasState() + private val forwardedEmailAliasSavedStateHandle = + createSavedStateHandleWithState(initialForwardedEmailAliasState) + private val initialCatchAllEmailState = createCatchAllEmailState() private val catchAllEmailSavedStateHandle = createSavedStateHandleWithState(initialCatchAllEmailState) @@ -895,6 +899,58 @@ class GeneratorViewModelTest : BaseViewModelTest() { } } + @Nested + inner class ForwardedEmailAliasActions { + private val defaultForwardedEmailAliasState = createForwardedEmailAliasState() + private lateinit var viewModel: GeneratorViewModel + + @BeforeEach + fun setup() { + viewModel = + GeneratorViewModel(forwardedEmailAliasSavedStateHandle, fakeGeneratorRepository) + } + + @Test + fun `ServiceTypeOptionSelect should update service type correctly`() = runTest { + val action = GeneratorAction + .MainType + .Username + .UsernameType + .ForwardedEmailAlias + .ServiceTypeOptionSelect( + serviceTypeOption = GeneratorState + .MainType + .Username + .UsernameType + .ForwardedEmailAlias + .ServiceTypeOption + .ANON_ADDY, + ) + + viewModel.actionChannel.trySend(action) + + val expectedState = defaultForwardedEmailAliasState.copy( + selectedType = GeneratorState.MainType.Username( + selectedType = GeneratorState + .MainType + .Username + .UsernameType + .ForwardedEmailAlias( + selectedServiceType = GeneratorState + .MainType + .Username + .UsernameType + .ForwardedEmailAlias + .ServiceType + .AnonAddy(), + ), + ), + ) + + assertEquals(expectedState, viewModel.stateFlow.value) + } + } + @Nested inner class PlusAddressedEmailActions { private val defaultPlusAddressedEmailState = createPlusAddressedEmailState() @@ -1088,6 +1144,20 @@ class GeneratorViewModelTest : BaseViewModelTest() { ), ) + private fun createForwardedEmailAliasState( + generatedText: String = "defaultForwardedEmailAlias", + obfuscatedText: String = "defaultObfuscatedText", + ): GeneratorState = + GeneratorState( + generatedText = generatedText, + selectedType = GeneratorState.MainType.Username( + GeneratorState.MainType.Username.UsernameType.ForwardedEmailAlias( + selectedServiceType = null, + obfuscatedText = obfuscatedText, + ), + ), + ) + private fun createPlusAddressedEmailState( generatedText: String = "defaultPlusAddressedEmail", email: String = "defaultEmail",