diff --git a/app/src/main/java/com/x8bit/bitwarden/data/tools/generator/repository/GeneratorRepository.kt b/app/src/main/java/com/x8bit/bitwarden/data/tools/generator/repository/GeneratorRepository.kt index 19832d407..8e62d67d6 100644 --- a/app/src/main/java/com/x8bit/bitwarden/data/tools/generator/repository/GeneratorRepository.kt +++ b/app/src/main/java/com/x8bit/bitwarden/data/tools/generator/repository/GeneratorRepository.kt @@ -13,8 +13,10 @@ import com.x8bit.bitwarden.data.tools.generator.repository.model.GeneratedPassph import com.x8bit.bitwarden.data.tools.generator.repository.model.GeneratedPasswordResult import com.x8bit.bitwarden.data.tools.generator.repository.model.GeneratedPlusAddressedUsernameResult import com.x8bit.bitwarden.data.tools.generator.repository.model.GeneratedRandomWordUsernameResult +import com.x8bit.bitwarden.data.tools.generator.repository.model.GeneratorResult import com.x8bit.bitwarden.data.tools.generator.repository.model.PasscodeGenerationOptions import com.x8bit.bitwarden.data.tools.generator.repository.model.UsernameGenerationOptions +import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.StateFlow /** @@ -27,6 +29,16 @@ interface GeneratorRepository { */ val passwordHistoryStateFlow: StateFlow>> + /** + * Flow that represents the modal generated text. + */ + val generatorResultFlow: Flow + + /** + * Emits the modal generator result flow to listeners. + */ + fun emitGeneratorResult(generatorResult: GeneratorResult) + /** * Attempt to generate a password based on specifications in [passwordGeneratorRequest]. * The [shouldSave] flag determines if the password is saved for future reference diff --git a/app/src/main/java/com/x8bit/bitwarden/data/tools/generator/repository/GeneratorRepositoryImpl.kt b/app/src/main/java/com/x8bit/bitwarden/data/tools/generator/repository/GeneratorRepositoryImpl.kt index fb3a447b5..e9f0e4e40 100644 --- a/app/src/main/java/com/x8bit/bitwarden/data/tools/generator/repository/GeneratorRepositoryImpl.kt +++ b/app/src/main/java/com/x8bit/bitwarden/data/tools/generator/repository/GeneratorRepositoryImpl.kt @@ -9,6 +9,7 @@ import com.bitwarden.core.UsernameGeneratorRequest import com.x8bit.bitwarden.data.auth.datasource.disk.AuthDiskSource import com.x8bit.bitwarden.data.platform.manager.dispatcher.DispatcherManager import com.x8bit.bitwarden.data.platform.repository.model.LocalDataState +import com.x8bit.bitwarden.data.platform.repository.util.bufferedMutableSharedFlow import com.x8bit.bitwarden.data.platform.repository.util.observeWhenSubscribedAndLoggedIn import com.x8bit.bitwarden.data.tools.generator.datasource.disk.GeneratorDiskSource import com.x8bit.bitwarden.data.tools.generator.datasource.disk.PasswordHistoryDiskSource @@ -21,6 +22,7 @@ import com.x8bit.bitwarden.data.tools.generator.repository.model.GeneratedPassph import com.x8bit.bitwarden.data.tools.generator.repository.model.GeneratedPasswordResult import com.x8bit.bitwarden.data.tools.generator.repository.model.GeneratedPlusAddressedUsernameResult import com.x8bit.bitwarden.data.tools.generator.repository.model.GeneratedRandomWordUsernameResult +import com.x8bit.bitwarden.data.tools.generator.repository.model.GeneratorResult import com.x8bit.bitwarden.data.tools.generator.repository.model.PasscodeGenerationOptions import com.x8bit.bitwarden.data.tools.generator.repository.model.UsernameGenerationOptions import com.x8bit.bitwarden.data.vault.datasource.sdk.VaultSdkSource @@ -28,6 +30,7 @@ import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.map @@ -51,12 +54,18 @@ class GeneratorRepositoryImpl( ) : GeneratorRepository { private val scope = CoroutineScope(dispatcherManager.io) + private val mutablePasswordHistoryStateFlow = MutableStateFlow>>(LocalDataState.Loading) + private val mutableGeneratorResultFlow = bufferedMutableSharedFlow() + override val passwordHistoryStateFlow: StateFlow>> get() = mutablePasswordHistoryStateFlow.asStateFlow() + override val generatorResultFlow: Flow + get() = mutableGeneratorResultFlow.asSharedFlow() + init { mutablePasswordHistoryStateFlow .observeWhenSubscribedAndLoggedIn(authDiskSource.userStateFlow) { activeUserId -> @@ -90,6 +99,10 @@ class GeneratorRepositoryImpl( ) } + override fun emitGeneratorResult(generatorResult: GeneratorResult) { + mutableGeneratorResultFlow.tryEmit(generatorResult) + } + override suspend fun generatePassword( passwordGeneratorRequest: PasswordGeneratorRequest, shouldSave: Boolean, diff --git a/app/src/main/java/com/x8bit/bitwarden/data/tools/generator/repository/model/GeneratorResult.kt b/app/src/main/java/com/x8bit/bitwarden/data/tools/generator/repository/model/GeneratorResult.kt new file mode 100644 index 000000000..bf5dfeded --- /dev/null +++ b/app/src/main/java/com/x8bit/bitwarden/data/tools/generator/repository/model/GeneratorResult.kt @@ -0,0 +1,16 @@ +package com.x8bit.bitwarden.data.tools.generator.repository.model + +/** + * A result from the Generator. + */ +sealed class GeneratorResult { + /** + * A generated username. + */ + data class Username(val username: String) : GeneratorResult() + + /** + * A generated password. + */ + data class Password(val password: String) : GeneratorResult() +} 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 d56f043a9..d952e62e8 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 @@ -23,6 +23,7 @@ import androidx.compose.material3.SnackbarHost import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.Text import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.material3.TopAppBarScrollBehavior import androidx.compose.material3.rememberTopAppBarState import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue @@ -55,8 +56,10 @@ import com.x8bit.bitwarden.ui.platform.components.BitwardenOverflowActionItem import com.x8bit.bitwarden.ui.platform.components.BitwardenPasswordField import com.x8bit.bitwarden.ui.platform.components.BitwardenScaffold import com.x8bit.bitwarden.ui.platform.components.BitwardenStepper +import com.x8bit.bitwarden.ui.platform.components.BitwardenTextButton import com.x8bit.bitwarden.ui.platform.components.BitwardenTextField import com.x8bit.bitwarden.ui.platform.components.BitwardenTextFieldWithActions +import com.x8bit.bitwarden.ui.platform.components.BitwardenTopAppBar import com.x8bit.bitwarden.ui.platform.components.BitwardenWideSwitch import com.x8bit.bitwarden.ui.platform.components.OverflowMenuItemData import com.x8bit.bitwarden.ui.platform.components.model.IconResource @@ -72,6 +75,7 @@ import com.x8bit.bitwarden.ui.tools.feature.generator.GeneratorState.MainType.Pa import com.x8bit.bitwarden.ui.tools.feature.generator.GeneratorState.MainType.Passcode.PasscodeType.Password.Companion.PASSWORD_LENGTH_SLIDER_MIN 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.model.GeneratorMode import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.toImmutableList @@ -101,6 +105,8 @@ fun GeneratorScreen( duration = SnackbarDuration.Short, ) } + + GeneratorEvent.NavigateBack -> onNavigateBack.invoke() } } @@ -162,31 +168,36 @@ fun GeneratorScreen( RandomWordHandlers.create(viewModel = viewModel) } - val scrollBehavior = - TopAppBarDefaults.exitUntilCollapsedScrollBehavior(rememberTopAppBarState()) + val scrollBehavior = when (state.generatorMode) { + GeneratorMode.Default -> { + TopAppBarDefaults.exitUntilCollapsedScrollBehavior(rememberTopAppBarState()) + } + + else -> TopAppBarDefaults.pinnedScrollBehavior(rememberTopAppBarState()) + } BitwardenScaffold( topBar = { - BitwardenMediumTopAppBar( - title = stringResource(id = R.string.generator), - scrollBehavior = scrollBehavior, - actions = { - BitwardenOverflowActionItem( - menuItemDataList = persistentListOf( - OverflowMenuItemData( - text = stringResource(id = R.string.password_history), - onClick = remember(viewModel) { - { - viewModel.trySendAction( - GeneratorAction.PasswordHistoryClick, - ) - } - }, - ), - ), + when (state.generatorMode) { + GeneratorMode.Modal.Username, GeneratorMode.Modal.Password -> + ModalAppBar( + scrollBehavior = scrollBehavior, + onCloseClick = remember(viewModel) { + { viewModel.trySendAction(GeneratorAction.CloseClick) } + }, + onSelectClick = remember(viewModel) { + { viewModel.trySendAction(GeneratorAction.SelectClick) } + }, ) - }, - ) + + GeneratorMode.Default -> + DefaultAppBar( + scrollBehavior = scrollBehavior, + onPasswordHistoryClick = remember(viewModel) { + { viewModel.trySendAction(GeneratorAction.PasswordHistoryClick) } + }, + ) + } }, snackbarHost = { SnackbarHost(hostState = snackbarHostState) @@ -211,6 +222,54 @@ fun GeneratorScreen( } } +//region Top App Bar Composables + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun DefaultAppBar( + scrollBehavior: TopAppBarScrollBehavior, + onPasswordHistoryClick: () -> Unit, +) { + BitwardenMediumTopAppBar( + title = stringResource(id = R.string.generator), + scrollBehavior = scrollBehavior, + actions = { + BitwardenOverflowActionItem( + menuItemDataList = persistentListOf( + OverflowMenuItemData( + text = stringResource(id = R.string.password_history), + onClick = onPasswordHistoryClick, + ), + ), + ) + }, + ) +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun ModalAppBar( + scrollBehavior: TopAppBarScrollBehavior, + onCloseClick: () -> Unit, + onSelectClick: () -> Unit, +) { + BitwardenTopAppBar( + title = stringResource(id = R.string.generator), + navigationIcon = painterResource(id = R.drawable.ic_close), + navigationIconContentDescription = stringResource(id = R.string.close), + onNavigationIconClick = onCloseClick, + scrollBehavior = scrollBehavior, + actions = { + BitwardenTextButton( + label = stringResource(id = R.string.select), + onClick = onSelectClick, + ) + }, + ) +} + +//endregion Top App Bar Composables + //region ScrollContent and Static Items @Suppress("LongMethod") @@ -242,13 +301,14 @@ private fun ScrollContent( onRegenerateClick = onRegenerateClick, ) - Spacer(modifier = Modifier.height(8.dp)) - - MainStateOptionsItem( - selectedType = state.selectedType, - possibleMainStates = state.typeOptions, - onMainStateOptionClicked = onMainStateOptionClicked, - ) + if (state.generatorMode == GeneratorMode.Default) { + Spacer(modifier = Modifier.height(8.dp)) + MainStateOptionsItem( + selectedType = state.selectedType, + possibleMainStates = state.typeOptions, + onMainStateOptionClicked = onMainStateOptionClicked, + ) + } Spacer(modifier = Modifier.height(16.dp)) 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 9ce1d90e3..542364d89 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 @@ -19,6 +19,7 @@ import com.x8bit.bitwarden.data.tools.generator.repository.model.GeneratedPassph import com.x8bit.bitwarden.data.tools.generator.repository.model.GeneratedPasswordResult import com.x8bit.bitwarden.data.tools.generator.repository.model.GeneratedPlusAddressedUsernameResult import com.x8bit.bitwarden.data.tools.generator.repository.model.GeneratedRandomWordUsernameResult +import com.x8bit.bitwarden.data.tools.generator.repository.model.GeneratorResult import com.x8bit.bitwarden.data.tools.generator.repository.model.PasscodeGenerationOptions import com.x8bit.bitwarden.data.tools.generator.repository.model.UsernameGenerationOptions import com.x8bit.bitwarden.ui.platform.base.BaseViewModel @@ -71,10 +72,12 @@ class GeneratorViewModel @Inject constructor( private val authRepository: AuthRepository, ) : BaseViewModel( initialState = savedStateHandle[KEY_STATE] ?: GeneratorState( - generatedText = PLACEHOLDER_GENERATED_TEXT, - selectedType = Passcode( - selectedType = Password(), - ), + generatedText = "", + selectedType = when (GeneratorArgs(savedStateHandle).type) { + GeneratorMode.Modal.Username -> Username() + GeneratorMode.Modal.Password -> Passcode() + GeneratorMode.Default -> Passcode(selectedType = Password()) + }, generatorMode = GeneratorArgs(savedStateHandle).type, currentEmailAddress = requireNotNull(authRepository.userStateFlow.value?.activeAccount?.email), @@ -100,6 +103,14 @@ class GeneratorViewModel @Inject constructor( handlePasswordHistoryClick() } + is GeneratorAction.CloseClick -> { + handleCloseClick() + } + + is GeneratorAction.SelectClick -> { + handleSelectClick() + } + is GeneratorAction.RegenerateClick -> { handleRegenerationClick() } @@ -198,6 +209,27 @@ class GeneratorViewModel @Inject constructor( sendEvent(GeneratorEvent.NavigateToPasswordHistory) } + private fun handleCloseClick() { + sendEvent(GeneratorEvent.NavigateBack) + } + + private fun handleSelectClick() { + when (state.selectedType) { + is Passcode -> { + generatorRepository.emitGeneratorResult( + GeneratorResult.Password(state.generatedText), + ) + } + + is Username -> { + generatorRepository.emitGeneratorResult( + GeneratorResult.Username(state.generatedText), + ) + } + } + sendEvent(GeneratorEvent.NavigateBack) + } + //endregion Top Level Handlers //region Generation Handlers @@ -1394,10 +1426,6 @@ class GeneratorViewModel @Inject constructor( } //endregion Utility Functions - - companion object { - private const val PLACEHOLDER_GENERATED_TEXT = "Placeholder" - } } /** @@ -1791,6 +1819,16 @@ sealed class GeneratorAction { */ data object PasswordHistoryClick : GeneratorAction() + /** + * Indicates the user has selected a generated string from the modal generator + */ + data object SelectClick : GeneratorAction() + + /** + * Indicates the user has clicked the close button. + */ + data object CloseClick : GeneratorAction() + /** * Represents the action to regenerate a new passcode or username. */ @@ -2187,6 +2225,11 @@ sealed class GeneratorEvent { */ data object NavigateToPasswordHistory : GeneratorEvent() + /** + * Navigate back to previous screen. + */ + data object NavigateBack : GeneratorEvent() + /** * Displays the message in a snackbar. */ diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/addedit/VaultAddEditViewModel.kt b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/addedit/VaultAddEditViewModel.kt index 3603ce2b1..0289267aa 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/addedit/VaultAddEditViewModel.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/addedit/VaultAddEditViewModel.kt @@ -8,6 +8,8 @@ import com.x8bit.bitwarden.R import com.x8bit.bitwarden.data.platform.manager.clipboard.BitwardenClipboardManager import com.x8bit.bitwarden.data.platform.repository.model.DataState import com.x8bit.bitwarden.data.platform.repository.util.takeUntilLoaded +import com.x8bit.bitwarden.data.tools.generator.repository.GeneratorRepository +import com.x8bit.bitwarden.data.tools.generator.repository.model.GeneratorResult import com.x8bit.bitwarden.data.vault.repository.VaultRepository import com.x8bit.bitwarden.data.vault.repository.model.CreateCipherResult import com.x8bit.bitwarden.data.vault.repository.model.TotpCodeResult @@ -53,6 +55,7 @@ class VaultAddEditViewModel @Inject constructor( savedStateHandle: SavedStateHandle, private val clipboardManager: BitwardenClipboardManager, private val vaultRepository: VaultRepository, + private val generatorRepository: GeneratorRepository, ) : BaseViewModel( // We load the state from the savedStateHandle for testing purposes. initialState = savedStateHandle[KEY_STATE] @@ -94,6 +97,14 @@ class VaultAddEditViewModel @Inject constructor( .map { VaultAddEditAction.Internal.TotpCodeReceive(totpResult = it) } .onEach(::sendAction) .launchIn(viewModelScope) + + generatorRepository + .generatorResultFlow + .map { + VaultAddEditAction.Internal.GeneratorResultReceive(generatorResult = it) + } + .onEach(::sendAction) + .launchIn(viewModelScope) } override fun handleAction(action: VaultAddEditAction) { @@ -415,13 +426,7 @@ class VaultAddEditViewModel @Inject constructor( } private fun handleLoginOpenUsernameGeneratorClick() { - viewModelScope.launch { - sendEvent( - event = VaultAddEditEvent.ShowToast( - message = "Open Username Generator".asText(), - ), - ) - } + sendEvent(event = VaultAddEditEvent.NavigateToGeneratorModal(GeneratorMode.Modal.Username)) } private fun handleLoginPasswordCheckerClick() { @@ -435,13 +440,7 @@ class VaultAddEditViewModel @Inject constructor( } private fun handleLoginOpenPasswordGeneratorClick() { - viewModelScope.launch { - sendEvent( - event = VaultAddEditEvent.ShowToast( - message = "Open Password Generator".asText(), - ), - ) - } + sendEvent(event = VaultAddEditEvent.NavigateToGeneratorModal(GeneratorMode.Modal.Password)) } private fun handleLoginSetupTotpClick( @@ -756,6 +755,9 @@ class VaultAddEditViewModel @Inject constructor( is VaultAddEditAction.Internal.TotpCodeReceive -> handleVaultTotpCodeReceive(action) is VaultAddEditAction.Internal.VaultDataReceive -> handleVaultDataReceive(action) + is VaultAddEditAction.Internal.GeneratorResultReceive -> { + handleGeneratorResultReceive(action) + } } } @@ -892,6 +894,27 @@ class VaultAddEditViewModel @Inject constructor( } } + private fun handleGeneratorResultReceive( + action: VaultAddEditAction.Internal.GeneratorResultReceive, + ) { + when (action.generatorResult) { + is GeneratorResult.Password -> { + updateLoginContent { loginType -> + loginType.copy( + password = action.generatorResult.password, + ) + } + } + is GeneratorResult.Username -> { + updateLoginContent { loginType -> + loginType.copy( + username = action.generatorResult.username, + ) + } + } + } + } + //endregion Internal Type Handlers //region Utility Functions @@ -1697,6 +1720,13 @@ sealed class VaultAddEditAction { */ data class TotpCodeReceive(val totpResult: TotpCodeResult) : Internal() + /** + * Indicates that the vault totp code result has been received. + */ + data class GeneratorResultReceive( + val generatorResult: GeneratorResult, + ) : Internal() + /** * Indicates that the vault item data has been received. */ diff --git a/app/src/test/java/com/x8bit/bitwarden/data/tools/generator/repository/util/FakeGeneratorRepository.kt b/app/src/test/java/com/x8bit/bitwarden/data/tools/generator/repository/util/FakeGeneratorRepository.kt index ce4e1ddab..4832c389f 100644 --- a/app/src/test/java/com/x8bit/bitwarden/data/tools/generator/repository/util/FakeGeneratorRepository.kt +++ b/app/src/test/java/com/x8bit/bitwarden/data/tools/generator/repository/util/FakeGeneratorRepository.kt @@ -5,6 +5,7 @@ import com.bitwarden.core.PasswordGeneratorRequest import com.bitwarden.core.PasswordHistoryView import com.bitwarden.core.UsernameGeneratorRequest import com.x8bit.bitwarden.data.platform.repository.model.LocalDataState +import com.x8bit.bitwarden.data.platform.repository.util.bufferedMutableSharedFlow import com.x8bit.bitwarden.data.tools.generator.repository.GeneratorRepository import com.x8bit.bitwarden.data.tools.generator.repository.model.GeneratedCatchAllUsernameResult import com.x8bit.bitwarden.data.tools.generator.repository.model.GeneratedForwardedServiceUsernameResult @@ -12,10 +13,13 @@ import com.x8bit.bitwarden.data.tools.generator.repository.model.GeneratedPassph import com.x8bit.bitwarden.data.tools.generator.repository.model.GeneratedPasswordResult import com.x8bit.bitwarden.data.tools.generator.repository.model.GeneratedPlusAddressedUsernameResult import com.x8bit.bitwarden.data.tools.generator.repository.model.GeneratedRandomWordUsernameResult +import com.x8bit.bitwarden.data.tools.generator.repository.model.GeneratorResult import com.x8bit.bitwarden.data.tools.generator.repository.model.PasscodeGenerationOptions import com.x8bit.bitwarden.data.tools.generator.repository.model.UsernameGenerationOptions +import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asSharedFlow /** * A fake implementation of [GeneratorRepository] for testing purposes. @@ -36,6 +40,8 @@ class FakeGeneratorRepository : GeneratorRepository { private val mutablePasswordHistoryStateFlow = MutableStateFlow>>(LocalDataState.Loading) + private val mutableGeneratorResultFlow = bufferedMutableSharedFlow() + private var generatePlusAddressedEmailResult: GeneratedPlusAddressedUsernameResult = GeneratedPlusAddressedUsernameResult.Success( generatedEmailAddress = "email+abcd1234@address.com", @@ -59,6 +65,13 @@ class FakeGeneratorRepository : GeneratorRepository { override val passwordHistoryStateFlow: StateFlow>> get() = mutablePasswordHistoryStateFlow + override val generatorResultFlow: Flow + get() = mutableGeneratorResultFlow.asSharedFlow() + + override fun emitGeneratorResult(generatorResult: GeneratorResult) { + mutableGeneratorResultFlow.tryEmit(generatorResult) + } + override suspend fun generatePassword( passwordGeneratorRequest: PasswordGeneratorRequest, shouldSave: Boolean, 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 9fb3847e2..40fb1009e 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 @@ -27,6 +27,7 @@ import androidx.compose.ui.text.AnnotatedString import com.x8bit.bitwarden.data.platform.repository.util.bufferedMutableSharedFlow import com.x8bit.bitwarden.ui.platform.base.BaseComposeTest import com.x8bit.bitwarden.ui.platform.base.util.asText +import com.x8bit.bitwarden.ui.tools.feature.generator.model.GeneratorMode import io.mockk.every import io.mockk.mockk import io.mockk.verify @@ -58,6 +59,85 @@ class GeneratorScreenTest : BaseComposeTest() { } } + @Test + fun `ModalAppBar should be displayed for Password Mode`() { + updateState(DEFAULT_STATE.copy(generatorMode = GeneratorMode.Modal.Password)) + + composeTestRule + .onNodeWithContentDescription(label = "Close") + .assertIsDisplayed() + + composeTestRule + .onNodeWithText(text = "Select") + .assertIsDisplayed() + } + + @Test + fun `ModalAppBar should be displayed for Username Mode`() { + updateState(DEFAULT_STATE.copy(generatorMode = GeneratorMode.Modal.Username)) + + composeTestRule + .onNodeWithContentDescription(label = "Close") + .assertIsDisplayed() + + composeTestRule + .onNodeWithText(text = "Select") + .assertIsDisplayed() + } + + @Test + fun `on close click should send CloseClick`() { + updateState(DEFAULT_STATE.copy(generatorMode = GeneratorMode.Modal.Username)) + + composeTestRule + .onNodeWithContentDescription(label = "Close") + .performClick() + + verify { + viewModel.trySendAction(GeneratorAction.CloseClick) + } + } + + @Test + fun `on select click should send SelectClick`() { + updateState(DEFAULT_STATE.copy(generatorMode = GeneratorMode.Modal.Username)) + + composeTestRule + .onNodeWithText(text = "Select") + .performClick() + + verify { + viewModel.trySendAction(GeneratorAction.SelectClick) + } + } + + @Test + fun `DefaultAppBar should be displayed for Default Mode`() { + updateState(DEFAULT_STATE.copy(generatorMode = GeneratorMode.Default)) + + composeTestRule + .onNodeWithContentDescription(label = "More") + .assertIsDisplayed() + } + + @Test + fun `MainTypeOption select control should be hidden for password mode`() { + updateState(DEFAULT_STATE.copy(generatorMode = GeneratorMode.Modal.Password)) + + composeTestRule + .onNodeWithContentDescription(label = "What would you like to generate?, Password") + .assertDoesNotExist() + } + + @Test + fun `MainTypeOption select control should be hidden for username mode`() { + updateState(DEFAULT_STATE.copy(generatorMode = GeneratorMode.Modal.Username)) + + composeTestRule + .onNodeWithContentDescription(label = "What would you like to generate?, Password") + .assertDoesNotExist() + } + @Test fun `NavigateToPasswordHistory event should call onNavigateToPasswordHistoryScreen`() { mutableEventFlow.tryEmit(GeneratorEvent.NavigateToPasswordHistory) 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 7ba63a89d..0c608fcc1 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 @@ -2,6 +2,7 @@ package com.x8bit.bitwarden.ui.tools.feature.generator 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.repository.AuthRepository import com.x8bit.bitwarden.data.auth.repository.model.UserState @@ -12,10 +13,12 @@ import com.x8bit.bitwarden.data.tools.generator.repository.model.GeneratedForwar import com.x8bit.bitwarden.data.tools.generator.repository.model.GeneratedPassphraseResult import com.x8bit.bitwarden.data.tools.generator.repository.model.GeneratedPasswordResult import com.x8bit.bitwarden.data.tools.generator.repository.model.GeneratedRandomWordUsernameResult +import com.x8bit.bitwarden.data.tools.generator.repository.model.GeneratorResult import com.x8bit.bitwarden.data.tools.generator.repository.model.PasscodeGenerationOptions import com.x8bit.bitwarden.data.tools.generator.repository.util.FakeGeneratorRepository import com.x8bit.bitwarden.ui.platform.base.BaseViewModelTest import com.x8bit.bitwarden.ui.platform.base.util.asText +import com.x8bit.bitwarden.ui.tools.feature.generator.model.GeneratorMode import io.mockk.every import io.mockk.just import io.mockk.mockk @@ -28,12 +31,15 @@ import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Nested import org.junit.jupiter.api.Test +@Suppress("LargeClass") class GeneratorViewModelTest : BaseViewModelTest() { private val initialPasscodeState = createPasswordState() private val initialPasscodeSavedStateHandle = createSavedStateHandleWithState(initialPasscodeState) + private val initialUsernameModeState = createUsernameModeState() + private val initialPassphraseState = createPassphraseState() private val passphraseSavedStateHandle = createSavedStateHandleWithState(initialPassphraseState) @@ -90,6 +96,35 @@ class GeneratorViewModelTest : BaseViewModelTest() { assertEquals(initialPasscodeState, viewModel.stateFlow.value) } + @Test + fun `CloseClick should emit NavigateBack event`() = runTest { + val viewModel = createViewModel() + viewModel.actionChannel.trySend(GeneratorAction.CloseClick) + + viewModel.eventFlow.test { + val event = awaitItem() + assertEquals(GeneratorEvent.NavigateBack, event) + } + } + + @Test + fun `SelectClick should emit the NavigateBack event with GeneratorResult`() = runTest { + turbineScope { + val viewModel = createViewModel(state = initialUsernameModeState) + val eventTurbine = viewModel + .eventFlow + .testIn(backgroundScope) + val generatorResultTurbine = fakeGeneratorRepository + .generatorResultFlow + .testIn(backgroundScope) + + viewModel.actionChannel.trySend(GeneratorAction.SelectClick) + + assertEquals(GeneratorEvent.NavigateBack, eventTurbine.awaitItem()) + assertEquals(GeneratorResult.Username("username"), generatorResultTurbine.awaitItem()) + } + } + @Suppress("MaxLineLength") @Test fun `RegenerateClick action for password state updates generatedText and saves password generation options on successful password generation`() = @@ -1550,6 +1585,21 @@ class GeneratorViewModelTest : BaseViewModelTest() { currentEmailAddress = "currentEmail", ) + private fun createUsernameModeState( + generatedText: String = "username", + email: String = "currentEmail", + ): GeneratorState = + GeneratorState( + generatedText = generatedText, + generatorMode = GeneratorMode.Modal.Username, + selectedType = GeneratorState.MainType.Username( + GeneratorState.MainType.Username.UsernameType.PlusAddressedEmail( + email = email, + ), + ), + currentEmailAddress = "currentEmail", + ) + private fun createForwardedEmailAliasState( generatedText: String = "defaultForwardedEmailAlias", obfuscatedText: String = "defaultObfuscatedText", diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/addedit/VaultAddEditViewModelTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/addedit/VaultAddEditViewModelTest.kt index 9988b63cb..94d2a01f8 100644 --- a/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/addedit/VaultAddEditViewModelTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/addedit/VaultAddEditViewModelTest.kt @@ -7,6 +7,8 @@ import com.x8bit.bitwarden.R import com.x8bit.bitwarden.data.platform.manager.clipboard.BitwardenClipboardManager import com.x8bit.bitwarden.data.platform.repository.model.DataState import com.x8bit.bitwarden.data.platform.repository.util.bufferedMutableSharedFlow +import com.x8bit.bitwarden.data.tools.generator.repository.GeneratorRepository +import com.x8bit.bitwarden.data.tools.generator.repository.util.FakeGeneratorRepository import com.x8bit.bitwarden.data.vault.repository.VaultRepository import com.x8bit.bitwarden.data.vault.repository.model.CreateCipherResult import com.x8bit.bitwarden.data.vault.repository.model.TotpCodeResult @@ -14,6 +16,7 @@ import com.x8bit.bitwarden.data.vault.repository.model.UpdateCipherResult import com.x8bit.bitwarden.ui.platform.base.BaseViewModelTest import com.x8bit.bitwarden.ui.platform.base.util.Text import com.x8bit.bitwarden.ui.platform.base.util.asText +import com.x8bit.bitwarden.ui.tools.feature.generator.model.GeneratorMode import com.x8bit.bitwarden.ui.vault.feature.addedit.model.CustomFieldType import com.x8bit.bitwarden.ui.vault.feature.addedit.model.toCustomField import com.x8bit.bitwarden.ui.vault.feature.addedit.util.toViewState @@ -62,6 +65,8 @@ class VaultAddEditViewModelTest : BaseViewModelTest() { every { totpCodeFlow } returns totpTestCodeFlow } + private val generatorRepository: GeneratorRepository = FakeGeneratorRepository() + @BeforeEach fun setup() { mockkStatic(CIPHER_VIEW_EXTENSIONS_PATH) @@ -570,7 +575,7 @@ class VaultAddEditViewModelTest : BaseViewModelTest() { @Suppress("MaxLineLength") @Test - fun `OpenUsernameGeneratorClick should emit ShowToast with 'Open Username Generator' message`() = + fun `OpenUsernameGeneratorClick should emit NavigateToGeneratorModal with username GeneratorMode`() = runTest { val viewModel = createAddVaultItemViewModel() @@ -579,7 +584,7 @@ class VaultAddEditViewModelTest : BaseViewModelTest() { VaultAddEditAction.ItemType.LoginType.OpenUsernameGeneratorClick, ) assertEquals( - VaultAddEditEvent.ShowToast("Open Username Generator".asText()), + VaultAddEditEvent.NavigateToGeneratorModal(GeneratorMode.Modal.Username), awaitItem(), ) } @@ -606,7 +611,7 @@ class VaultAddEditViewModelTest : BaseViewModelTest() { @Suppress("MaxLineLength") @Test - fun `OpenPasswordGeneratorClick should emit ShowToast with 'Open Password Generator' message`() = + fun `OpenPasswordGeneratorClick should emit NavigateToGeneratorModal with with password GeneratorMode`() = runTest { val viewModel = createAddVaultItemViewModel() @@ -616,7 +621,7 @@ class VaultAddEditViewModelTest : BaseViewModelTest() { .trySend(VaultAddEditAction.ItemType.LoginType.OpenPasswordGeneratorClick) assertEquals( - VaultAddEditEvent.ShowToast("Open Password Generator".asText()), + VaultAddEditEvent.NavigateToGeneratorModal(GeneratorMode.Modal.Password), awaitItem(), ) } @@ -1186,6 +1191,7 @@ class VaultAddEditViewModelTest : BaseViewModelTest() { savedStateHandle = secureNotesInitialSavedStateHandle, clipboardManager = clipboardManager, vaultRepository = vaultRepository, + generatorRepository = generatorRepository, ) } @@ -1487,11 +1493,13 @@ class VaultAddEditViewModelTest : BaseViewModelTest() { savedStateHandle: SavedStateHandle = loginInitialSavedStateHandle, bitwardenClipboardManager: BitwardenClipboardManager = clipboardManager, vaultRepo: VaultRepository = vaultRepository, + generatorRepo: GeneratorRepository = generatorRepository, ): VaultAddEditViewModel = VaultAddEditViewModel( savedStateHandle = savedStateHandle, clipboardManager = bitwardenClipboardManager, vaultRepository = vaultRepo, + generatorRepository = generatorRepo, ) /**