BIT-1054, BIT-1055: Adding modal generator UI and navigation from Add/Edit item (#643)

This commit is contained in:
Joshua Queen 2024-01-17 13:20:38 -05:00 committed by Álison Fernandes
parent 61e914f8ac
commit 8d5bcc4433
10 changed files with 379 additions and 54 deletions

View file

@ -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.GeneratedPasswordResult
import com.x8bit.bitwarden.data.tools.generator.repository.model.GeneratedPlusAddressedUsernameResult 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.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.PasscodeGenerationOptions
import com.x8bit.bitwarden.data.tools.generator.repository.model.UsernameGenerationOptions import com.x8bit.bitwarden.data.tools.generator.repository.model.UsernameGenerationOptions
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
/** /**
@ -27,6 +29,16 @@ interface GeneratorRepository {
*/ */
val passwordHistoryStateFlow: StateFlow<LocalDataState<List<PasswordHistoryView>>> val passwordHistoryStateFlow: StateFlow<LocalDataState<List<PasswordHistoryView>>>
/**
* Flow that represents the modal generated text.
*/
val generatorResultFlow: Flow<GeneratorResult>
/**
* Emits the modal generator result flow to listeners.
*/
fun emitGeneratorResult(generatorResult: GeneratorResult)
/** /**
* Attempt to generate a password based on specifications in [passwordGeneratorRequest]. * Attempt to generate a password based on specifications in [passwordGeneratorRequest].
* The [shouldSave] flag determines if the password is saved for future reference * The [shouldSave] flag determines if the password is saved for future reference

View file

@ -9,6 +9,7 @@ import com.bitwarden.core.UsernameGeneratorRequest
import com.x8bit.bitwarden.data.auth.datasource.disk.AuthDiskSource import com.x8bit.bitwarden.data.auth.datasource.disk.AuthDiskSource
import com.x8bit.bitwarden.data.platform.manager.dispatcher.DispatcherManager 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.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.platform.repository.util.observeWhenSubscribedAndLoggedIn
import com.x8bit.bitwarden.data.tools.generator.datasource.disk.GeneratorDiskSource import com.x8bit.bitwarden.data.tools.generator.datasource.disk.GeneratorDiskSource
import com.x8bit.bitwarden.data.tools.generator.datasource.disk.PasswordHistoryDiskSource 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.GeneratedPasswordResult
import com.x8bit.bitwarden.data.tools.generator.repository.model.GeneratedPlusAddressedUsernameResult 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.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.PasscodeGenerationOptions
import com.x8bit.bitwarden.data.tools.generator.repository.model.UsernameGenerationOptions import com.x8bit.bitwarden.data.tools.generator.repository.model.UsernameGenerationOptions
import com.x8bit.bitwarden.data.vault.datasource.sdk.VaultSdkSource 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.Flow
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
@ -51,12 +54,18 @@ class GeneratorRepositoryImpl(
) : GeneratorRepository { ) : GeneratorRepository {
private val scope = CoroutineScope(dispatcherManager.io) private val scope = CoroutineScope(dispatcherManager.io)
private val mutablePasswordHistoryStateFlow = private val mutablePasswordHistoryStateFlow =
MutableStateFlow<LocalDataState<List<PasswordHistoryView>>>(LocalDataState.Loading) MutableStateFlow<LocalDataState<List<PasswordHistoryView>>>(LocalDataState.Loading)
private val mutableGeneratorResultFlow = bufferedMutableSharedFlow<GeneratorResult>()
override val passwordHistoryStateFlow: StateFlow<LocalDataState<List<PasswordHistoryView>>> override val passwordHistoryStateFlow: StateFlow<LocalDataState<List<PasswordHistoryView>>>
get() = mutablePasswordHistoryStateFlow.asStateFlow() get() = mutablePasswordHistoryStateFlow.asStateFlow()
override val generatorResultFlow: Flow<GeneratorResult>
get() = mutableGeneratorResultFlow.asSharedFlow()
init { init {
mutablePasswordHistoryStateFlow mutablePasswordHistoryStateFlow
.observeWhenSubscribedAndLoggedIn(authDiskSource.userStateFlow) { activeUserId -> .observeWhenSubscribedAndLoggedIn(authDiskSource.userStateFlow) { activeUserId ->
@ -90,6 +99,10 @@ class GeneratorRepositoryImpl(
) )
} }
override fun emitGeneratorResult(generatorResult: GeneratorResult) {
mutableGeneratorResultFlow.tryEmit(generatorResult)
}
override suspend fun generatePassword( override suspend fun generatePassword(
passwordGeneratorRequest: PasswordGeneratorRequest, passwordGeneratorRequest: PasswordGeneratorRequest,
shouldSave: Boolean, shouldSave: Boolean,

View file

@ -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()
}

View file

@ -23,6 +23,7 @@ import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBarDefaults import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.material3.TopAppBarScrollBehavior
import androidx.compose.material3.rememberTopAppBarState import androidx.compose.material3.rememberTopAppBarState
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue 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.BitwardenPasswordField
import com.x8bit.bitwarden.ui.platform.components.BitwardenScaffold import com.x8bit.bitwarden.ui.platform.components.BitwardenScaffold
import com.x8bit.bitwarden.ui.platform.components.BitwardenStepper 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.BitwardenTextField
import com.x8bit.bitwarden.ui.platform.components.BitwardenTextFieldWithActions 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.BitwardenWideSwitch
import com.x8bit.bitwarden.ui.platform.components.OverflowMenuItemData import com.x8bit.bitwarden.ui.platform.components.OverflowMenuItemData
import com.x8bit.bitwarden.ui.platform.components.model.IconResource 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.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.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.Username.UsernameType.ForwardedEmailAlias.ServiceTypeOption
import com.x8bit.bitwarden.ui.tools.feature.generator.model.GeneratorMode
import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList import kotlinx.collections.immutable.toImmutableList
@ -101,6 +105,8 @@ fun GeneratorScreen(
duration = SnackbarDuration.Short, duration = SnackbarDuration.Short,
) )
} }
GeneratorEvent.NavigateBack -> onNavigateBack.invoke()
} }
} }
@ -162,31 +168,36 @@ fun GeneratorScreen(
RandomWordHandlers.create(viewModel = viewModel) RandomWordHandlers.create(viewModel = viewModel)
} }
val scrollBehavior = val scrollBehavior = when (state.generatorMode) {
TopAppBarDefaults.exitUntilCollapsedScrollBehavior(rememberTopAppBarState()) GeneratorMode.Default -> {
TopAppBarDefaults.exitUntilCollapsedScrollBehavior(rememberTopAppBarState())
}
else -> TopAppBarDefaults.pinnedScrollBehavior(rememberTopAppBarState())
}
BitwardenScaffold( BitwardenScaffold(
topBar = { topBar = {
BitwardenMediumTopAppBar( when (state.generatorMode) {
title = stringResource(id = R.string.generator), GeneratorMode.Modal.Username, GeneratorMode.Modal.Password ->
scrollBehavior = scrollBehavior, ModalAppBar(
actions = { scrollBehavior = scrollBehavior,
BitwardenOverflowActionItem( onCloseClick = remember(viewModel) {
menuItemDataList = persistentListOf( { viewModel.trySendAction(GeneratorAction.CloseClick) }
OverflowMenuItemData( },
text = stringResource(id = R.string.password_history), onSelectClick = remember(viewModel) {
onClick = remember(viewModel) { { viewModel.trySendAction(GeneratorAction.SelectClick) }
{ },
viewModel.trySendAction(
GeneratorAction.PasswordHistoryClick,
)
}
},
),
),
) )
},
) GeneratorMode.Default ->
DefaultAppBar(
scrollBehavior = scrollBehavior,
onPasswordHistoryClick = remember(viewModel) {
{ viewModel.trySendAction(GeneratorAction.PasswordHistoryClick) }
},
)
}
}, },
snackbarHost = { snackbarHost = {
SnackbarHost(hostState = snackbarHostState) 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 //region ScrollContent and Static Items
@Suppress("LongMethod") @Suppress("LongMethod")
@ -242,13 +301,14 @@ private fun ScrollContent(
onRegenerateClick = onRegenerateClick, onRegenerateClick = onRegenerateClick,
) )
Spacer(modifier = Modifier.height(8.dp)) if (state.generatorMode == GeneratorMode.Default) {
Spacer(modifier = Modifier.height(8.dp))
MainStateOptionsItem( MainStateOptionsItem(
selectedType = state.selectedType, selectedType = state.selectedType,
possibleMainStates = state.typeOptions, possibleMainStates = state.typeOptions,
onMainStateOptionClicked = onMainStateOptionClicked, onMainStateOptionClicked = onMainStateOptionClicked,
) )
}
Spacer(modifier = Modifier.height(16.dp)) Spacer(modifier = Modifier.height(16.dp))

View file

@ -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.GeneratedPasswordResult
import com.x8bit.bitwarden.data.tools.generator.repository.model.GeneratedPlusAddressedUsernameResult 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.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.PasscodeGenerationOptions
import com.x8bit.bitwarden.data.tools.generator.repository.model.UsernameGenerationOptions import com.x8bit.bitwarden.data.tools.generator.repository.model.UsernameGenerationOptions
import com.x8bit.bitwarden.ui.platform.base.BaseViewModel import com.x8bit.bitwarden.ui.platform.base.BaseViewModel
@ -71,10 +72,12 @@ class GeneratorViewModel @Inject constructor(
private val authRepository: AuthRepository, private val authRepository: AuthRepository,
) : BaseViewModel<GeneratorState, GeneratorEvent, GeneratorAction>( ) : BaseViewModel<GeneratorState, GeneratorEvent, GeneratorAction>(
initialState = savedStateHandle[KEY_STATE] ?: GeneratorState( initialState = savedStateHandle[KEY_STATE] ?: GeneratorState(
generatedText = PLACEHOLDER_GENERATED_TEXT, generatedText = "",
selectedType = Passcode( selectedType = when (GeneratorArgs(savedStateHandle).type) {
selectedType = Password(), GeneratorMode.Modal.Username -> Username()
), GeneratorMode.Modal.Password -> Passcode()
GeneratorMode.Default -> Passcode(selectedType = Password())
},
generatorMode = GeneratorArgs(savedStateHandle).type, generatorMode = GeneratorArgs(savedStateHandle).type,
currentEmailAddress = currentEmailAddress =
requireNotNull(authRepository.userStateFlow.value?.activeAccount?.email), requireNotNull(authRepository.userStateFlow.value?.activeAccount?.email),
@ -100,6 +103,14 @@ class GeneratorViewModel @Inject constructor(
handlePasswordHistoryClick() handlePasswordHistoryClick()
} }
is GeneratorAction.CloseClick -> {
handleCloseClick()
}
is GeneratorAction.SelectClick -> {
handleSelectClick()
}
is GeneratorAction.RegenerateClick -> { is GeneratorAction.RegenerateClick -> {
handleRegenerationClick() handleRegenerationClick()
} }
@ -198,6 +209,27 @@ class GeneratorViewModel @Inject constructor(
sendEvent(GeneratorEvent.NavigateToPasswordHistory) 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 //endregion Top Level Handlers
//region Generation Handlers //region Generation Handlers
@ -1394,10 +1426,6 @@ class GeneratorViewModel @Inject constructor(
} }
//endregion Utility Functions //endregion Utility Functions
companion object {
private const val PLACEHOLDER_GENERATED_TEXT = "Placeholder"
}
} }
/** /**
@ -1791,6 +1819,16 @@ sealed class GeneratorAction {
*/ */
data object PasswordHistoryClick : 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. * Represents the action to regenerate a new passcode or username.
*/ */
@ -2187,6 +2225,11 @@ sealed class GeneratorEvent {
*/ */
data object NavigateToPasswordHistory : GeneratorEvent() data object NavigateToPasswordHistory : GeneratorEvent()
/**
* Navigate back to previous screen.
*/
data object NavigateBack : GeneratorEvent()
/** /**
* Displays the message in a snackbar. * Displays the message in a snackbar.
*/ */

View file

@ -8,6 +8,8 @@ import com.x8bit.bitwarden.R
import com.x8bit.bitwarden.data.platform.manager.clipboard.BitwardenClipboardManager 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.model.DataState
import com.x8bit.bitwarden.data.platform.repository.util.takeUntilLoaded 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.VaultRepository
import com.x8bit.bitwarden.data.vault.repository.model.CreateCipherResult import com.x8bit.bitwarden.data.vault.repository.model.CreateCipherResult
import com.x8bit.bitwarden.data.vault.repository.model.TotpCodeResult import com.x8bit.bitwarden.data.vault.repository.model.TotpCodeResult
@ -53,6 +55,7 @@ class VaultAddEditViewModel @Inject constructor(
savedStateHandle: SavedStateHandle, savedStateHandle: SavedStateHandle,
private val clipboardManager: BitwardenClipboardManager, private val clipboardManager: BitwardenClipboardManager,
private val vaultRepository: VaultRepository, private val vaultRepository: VaultRepository,
private val generatorRepository: GeneratorRepository,
) : BaseViewModel<VaultAddEditState, VaultAddEditEvent, VaultAddEditAction>( ) : BaseViewModel<VaultAddEditState, VaultAddEditEvent, VaultAddEditAction>(
// We load the state from the savedStateHandle for testing purposes. // We load the state from the savedStateHandle for testing purposes.
initialState = savedStateHandle[KEY_STATE] initialState = savedStateHandle[KEY_STATE]
@ -94,6 +97,14 @@ class VaultAddEditViewModel @Inject constructor(
.map { VaultAddEditAction.Internal.TotpCodeReceive(totpResult = it) } .map { VaultAddEditAction.Internal.TotpCodeReceive(totpResult = it) }
.onEach(::sendAction) .onEach(::sendAction)
.launchIn(viewModelScope) .launchIn(viewModelScope)
generatorRepository
.generatorResultFlow
.map {
VaultAddEditAction.Internal.GeneratorResultReceive(generatorResult = it)
}
.onEach(::sendAction)
.launchIn(viewModelScope)
} }
override fun handleAction(action: VaultAddEditAction) { override fun handleAction(action: VaultAddEditAction) {
@ -415,13 +426,7 @@ class VaultAddEditViewModel @Inject constructor(
} }
private fun handleLoginOpenUsernameGeneratorClick() { private fun handleLoginOpenUsernameGeneratorClick() {
viewModelScope.launch { sendEvent(event = VaultAddEditEvent.NavigateToGeneratorModal(GeneratorMode.Modal.Username))
sendEvent(
event = VaultAddEditEvent.ShowToast(
message = "Open Username Generator".asText(),
),
)
}
} }
private fun handleLoginPasswordCheckerClick() { private fun handleLoginPasswordCheckerClick() {
@ -435,13 +440,7 @@ class VaultAddEditViewModel @Inject constructor(
} }
private fun handleLoginOpenPasswordGeneratorClick() { private fun handleLoginOpenPasswordGeneratorClick() {
viewModelScope.launch { sendEvent(event = VaultAddEditEvent.NavigateToGeneratorModal(GeneratorMode.Modal.Password))
sendEvent(
event = VaultAddEditEvent.ShowToast(
message = "Open Password Generator".asText(),
),
)
}
} }
private fun handleLoginSetupTotpClick( private fun handleLoginSetupTotpClick(
@ -756,6 +755,9 @@ class VaultAddEditViewModel @Inject constructor(
is VaultAddEditAction.Internal.TotpCodeReceive -> handleVaultTotpCodeReceive(action) is VaultAddEditAction.Internal.TotpCodeReceive -> handleVaultTotpCodeReceive(action)
is VaultAddEditAction.Internal.VaultDataReceive -> handleVaultDataReceive(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 //endregion Internal Type Handlers
//region Utility Functions //region Utility Functions
@ -1697,6 +1720,13 @@ sealed class VaultAddEditAction {
*/ */
data class TotpCodeReceive(val totpResult: TotpCodeResult) : Internal() 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. * Indicates that the vault item data has been received.
*/ */

View file

@ -5,6 +5,7 @@ import com.bitwarden.core.PasswordGeneratorRequest
import com.bitwarden.core.PasswordHistoryView import com.bitwarden.core.PasswordHistoryView
import com.bitwarden.core.UsernameGeneratorRequest import com.bitwarden.core.UsernameGeneratorRequest
import com.x8bit.bitwarden.data.platform.repository.model.LocalDataState 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.GeneratorRepository
import com.x8bit.bitwarden.data.tools.generator.repository.model.GeneratedCatchAllUsernameResult import com.x8bit.bitwarden.data.tools.generator.repository.model.GeneratedCatchAllUsernameResult
import com.x8bit.bitwarden.data.tools.generator.repository.model.GeneratedForwardedServiceUsernameResult 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.GeneratedPasswordResult
import com.x8bit.bitwarden.data.tools.generator.repository.model.GeneratedPlusAddressedUsernameResult 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.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.PasscodeGenerationOptions
import com.x8bit.bitwarden.data.tools.generator.repository.model.UsernameGenerationOptions import com.x8bit.bitwarden.data.tools.generator.repository.model.UsernameGenerationOptions
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asSharedFlow
/** /**
* A fake implementation of [GeneratorRepository] for testing purposes. * A fake implementation of [GeneratorRepository] for testing purposes.
@ -36,6 +40,8 @@ class FakeGeneratorRepository : GeneratorRepository {
private val mutablePasswordHistoryStateFlow = private val mutablePasswordHistoryStateFlow =
MutableStateFlow<LocalDataState<List<PasswordHistoryView>>>(LocalDataState.Loading) MutableStateFlow<LocalDataState<List<PasswordHistoryView>>>(LocalDataState.Loading)
private val mutableGeneratorResultFlow = bufferedMutableSharedFlow<GeneratorResult>()
private var generatePlusAddressedEmailResult: GeneratedPlusAddressedUsernameResult = private var generatePlusAddressedEmailResult: GeneratedPlusAddressedUsernameResult =
GeneratedPlusAddressedUsernameResult.Success( GeneratedPlusAddressedUsernameResult.Success(
generatedEmailAddress = "email+abcd1234@address.com", generatedEmailAddress = "email+abcd1234@address.com",
@ -59,6 +65,13 @@ class FakeGeneratorRepository : GeneratorRepository {
override val passwordHistoryStateFlow: StateFlow<LocalDataState<List<PasswordHistoryView>>> override val passwordHistoryStateFlow: StateFlow<LocalDataState<List<PasswordHistoryView>>>
get() = mutablePasswordHistoryStateFlow get() = mutablePasswordHistoryStateFlow
override val generatorResultFlow: Flow<GeneratorResult>
get() = mutableGeneratorResultFlow.asSharedFlow()
override fun emitGeneratorResult(generatorResult: GeneratorResult) {
mutableGeneratorResultFlow.tryEmit(generatorResult)
}
override suspend fun generatePassword( override suspend fun generatePassword(
passwordGeneratorRequest: PasswordGeneratorRequest, passwordGeneratorRequest: PasswordGeneratorRequest,
shouldSave: Boolean, shouldSave: Boolean,

View file

@ -27,6 +27,7 @@ import androidx.compose.ui.text.AnnotatedString
import com.x8bit.bitwarden.data.platform.repository.util.bufferedMutableSharedFlow import com.x8bit.bitwarden.data.platform.repository.util.bufferedMutableSharedFlow
import com.x8bit.bitwarden.ui.platform.base.BaseComposeTest import com.x8bit.bitwarden.ui.platform.base.BaseComposeTest
import com.x8bit.bitwarden.ui.platform.base.util.asText 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.every
import io.mockk.mockk import io.mockk.mockk
import io.mockk.verify 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 @Test
fun `NavigateToPasswordHistory event should call onNavigateToPasswordHistoryScreen`() { fun `NavigateToPasswordHistory event should call onNavigateToPasswordHistoryScreen`() {
mutableEventFlow.tryEmit(GeneratorEvent.NavigateToPasswordHistory) mutableEventFlow.tryEmit(GeneratorEvent.NavigateToPasswordHistory)

View file

@ -2,6 +2,7 @@ package com.x8bit.bitwarden.ui.tools.feature.generator
import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.SavedStateHandle
import app.cash.turbine.test import app.cash.turbine.test
import app.cash.turbine.turbineScope
import com.x8bit.bitwarden.R import com.x8bit.bitwarden.R
import com.x8bit.bitwarden.data.auth.repository.AuthRepository import com.x8bit.bitwarden.data.auth.repository.AuthRepository
import com.x8bit.bitwarden.data.auth.repository.model.UserState 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.GeneratedPassphraseResult
import com.x8bit.bitwarden.data.tools.generator.repository.model.GeneratedPasswordResult 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.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.PasscodeGenerationOptions
import com.x8bit.bitwarden.data.tools.generator.repository.util.FakeGeneratorRepository 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.BaseViewModelTest
import com.x8bit.bitwarden.ui.platform.base.util.asText 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.every
import io.mockk.just import io.mockk.just
import io.mockk.mockk 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.Nested
import org.junit.jupiter.api.Test import org.junit.jupiter.api.Test
@Suppress("LargeClass")
class GeneratorViewModelTest : BaseViewModelTest() { class GeneratorViewModelTest : BaseViewModelTest() {
private val initialPasscodeState = createPasswordState() private val initialPasscodeState = createPasswordState()
private val initialPasscodeSavedStateHandle = private val initialPasscodeSavedStateHandle =
createSavedStateHandleWithState(initialPasscodeState) createSavedStateHandleWithState(initialPasscodeState)
private val initialUsernameModeState = createUsernameModeState()
private val initialPassphraseState = createPassphraseState() private val initialPassphraseState = createPassphraseState()
private val passphraseSavedStateHandle = createSavedStateHandleWithState(initialPassphraseState) private val passphraseSavedStateHandle = createSavedStateHandleWithState(initialPassphraseState)
@ -90,6 +96,35 @@ class GeneratorViewModelTest : BaseViewModelTest() {
assertEquals(initialPasscodeState, viewModel.stateFlow.value) 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") @Suppress("MaxLineLength")
@Test @Test
fun `RegenerateClick action for password state updates generatedText and saves password generation options on successful password generation`() = 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", 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( private fun createForwardedEmailAliasState(
generatedText: String = "defaultForwardedEmailAlias", generatedText: String = "defaultForwardedEmailAlias",
obfuscatedText: String = "defaultObfuscatedText", obfuscatedText: String = "defaultObfuscatedText",

View file

@ -7,6 +7,8 @@ import com.x8bit.bitwarden.R
import com.x8bit.bitwarden.data.platform.manager.clipboard.BitwardenClipboardManager 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.model.DataState
import com.x8bit.bitwarden.data.platform.repository.util.bufferedMutableSharedFlow 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.VaultRepository
import com.x8bit.bitwarden.data.vault.repository.model.CreateCipherResult import com.x8bit.bitwarden.data.vault.repository.model.CreateCipherResult
import com.x8bit.bitwarden.data.vault.repository.model.TotpCodeResult 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.BaseViewModelTest
import com.x8bit.bitwarden.ui.platform.base.util.Text import com.x8bit.bitwarden.ui.platform.base.util.Text
import com.x8bit.bitwarden.ui.platform.base.util.asText import com.x8bit.bitwarden.ui.platform.base.util.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.CustomFieldType
import com.x8bit.bitwarden.ui.vault.feature.addedit.model.toCustomField import com.x8bit.bitwarden.ui.vault.feature.addedit.model.toCustomField
import com.x8bit.bitwarden.ui.vault.feature.addedit.util.toViewState import com.x8bit.bitwarden.ui.vault.feature.addedit.util.toViewState
@ -62,6 +65,8 @@ class VaultAddEditViewModelTest : BaseViewModelTest() {
every { totpCodeFlow } returns totpTestCodeFlow every { totpCodeFlow } returns totpTestCodeFlow
} }
private val generatorRepository: GeneratorRepository = FakeGeneratorRepository()
@BeforeEach @BeforeEach
fun setup() { fun setup() {
mockkStatic(CIPHER_VIEW_EXTENSIONS_PATH) mockkStatic(CIPHER_VIEW_EXTENSIONS_PATH)
@ -570,7 +575,7 @@ class VaultAddEditViewModelTest : BaseViewModelTest() {
@Suppress("MaxLineLength") @Suppress("MaxLineLength")
@Test @Test
fun `OpenUsernameGeneratorClick should emit ShowToast with 'Open Username Generator' message`() = fun `OpenUsernameGeneratorClick should emit NavigateToGeneratorModal with username GeneratorMode`() =
runTest { runTest {
val viewModel = createAddVaultItemViewModel() val viewModel = createAddVaultItemViewModel()
@ -579,7 +584,7 @@ class VaultAddEditViewModelTest : BaseViewModelTest() {
VaultAddEditAction.ItemType.LoginType.OpenUsernameGeneratorClick, VaultAddEditAction.ItemType.LoginType.OpenUsernameGeneratorClick,
) )
assertEquals( assertEquals(
VaultAddEditEvent.ShowToast("Open Username Generator".asText()), VaultAddEditEvent.NavigateToGeneratorModal(GeneratorMode.Modal.Username),
awaitItem(), awaitItem(),
) )
} }
@ -606,7 +611,7 @@ class VaultAddEditViewModelTest : BaseViewModelTest() {
@Suppress("MaxLineLength") @Suppress("MaxLineLength")
@Test @Test
fun `OpenPasswordGeneratorClick should emit ShowToast with 'Open Password Generator' message`() = fun `OpenPasswordGeneratorClick should emit NavigateToGeneratorModal with with password GeneratorMode`() =
runTest { runTest {
val viewModel = createAddVaultItemViewModel() val viewModel = createAddVaultItemViewModel()
@ -616,7 +621,7 @@ class VaultAddEditViewModelTest : BaseViewModelTest() {
.trySend(VaultAddEditAction.ItemType.LoginType.OpenPasswordGeneratorClick) .trySend(VaultAddEditAction.ItemType.LoginType.OpenPasswordGeneratorClick)
assertEquals( assertEquals(
VaultAddEditEvent.ShowToast("Open Password Generator".asText()), VaultAddEditEvent.NavigateToGeneratorModal(GeneratorMode.Modal.Password),
awaitItem(), awaitItem(),
) )
} }
@ -1186,6 +1191,7 @@ class VaultAddEditViewModelTest : BaseViewModelTest() {
savedStateHandle = secureNotesInitialSavedStateHandle, savedStateHandle = secureNotesInitialSavedStateHandle,
clipboardManager = clipboardManager, clipboardManager = clipboardManager,
vaultRepository = vaultRepository, vaultRepository = vaultRepository,
generatorRepository = generatorRepository,
) )
} }
@ -1487,11 +1493,13 @@ class VaultAddEditViewModelTest : BaseViewModelTest() {
savedStateHandle: SavedStateHandle = loginInitialSavedStateHandle, savedStateHandle: SavedStateHandle = loginInitialSavedStateHandle,
bitwardenClipboardManager: BitwardenClipboardManager = clipboardManager, bitwardenClipboardManager: BitwardenClipboardManager = clipboardManager,
vaultRepo: VaultRepository = vaultRepository, vaultRepo: VaultRepository = vaultRepository,
generatorRepo: GeneratorRepository = generatorRepository,
): VaultAddEditViewModel = ): VaultAddEditViewModel =
VaultAddEditViewModel( VaultAddEditViewModel(
savedStateHandle = savedStateHandle, savedStateHandle = savedStateHandle,
clipboardManager = bitwardenClipboardManager, clipboardManager = bitwardenClipboardManager,
vaultRepository = vaultRepo, vaultRepository = vaultRepo,
generatorRepository = generatorRepo,
) )
/** /**