diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/item/VaultItemLoginContent.kt b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/item/VaultItemLoginContent.kt index d7a331606..f59acc1e1 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/item/VaultItemLoginContent.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/item/VaultItemLoginContent.kt @@ -26,6 +26,8 @@ import com.x8bit.bitwarden.ui.platform.components.BitwardenTextFieldWithActions import com.x8bit.bitwarden.ui.platform.components.BitwardenWideSwitch import com.x8bit.bitwarden.ui.platform.components.model.IconResource import com.x8bit.bitwarden.ui.platform.theme.LocalNonMaterialTypography +import com.x8bit.bitwarden.ui.vault.feature.item.handlers.VaultCommonItemTypeHandlers +import com.x8bit.bitwarden.ui.vault.feature.item.handlers.VaultLoginItemTypeHandlers /** * The top level content UI state for the [VaultItemScreen] when viewing a Login cipher. @@ -33,9 +35,11 @@ import com.x8bit.bitwarden.ui.platform.theme.LocalNonMaterialTypography @Suppress("LongMethod") @Composable fun VaultItemLoginContent( - viewState: VaultItemState.ViewState.Content.Login, + commonState: VaultItemState.ViewState.Content.Common, + loginItemState: VaultItemState.ViewState.Content.ItemType.Login, + vaultCommonItemTypeHandlers: VaultCommonItemTypeHandlers, + vaultLoginItemTypeHandlers: VaultLoginItemTypeHandlers, modifier: Modifier = Modifier, - loginHandlers: LoginHandlers, ) { LazyColumn( modifier = modifier, @@ -52,7 +56,7 @@ fun VaultItemLoginContent( Spacer(modifier = Modifier.height(8.dp)) BitwardenTextField( label = stringResource(id = R.string.name), - value = viewState.name, + value = commonState.name, onValueChange = { }, readOnly = true, singleLine = false, @@ -62,12 +66,12 @@ fun VaultItemLoginContent( ) } - viewState.username?.let { username -> + loginItemState.username?.let { username -> item { Spacer(modifier = Modifier.height(8.dp)) UsernameField( username = username, - onCopyUsernameClick = loginHandlers.onCopyUsernameClick, + onCopyUsernameClick = vaultLoginItemTypeHandlers.onCopyUsernameClick, modifier = Modifier .fillMaxWidth() .padding(horizontal = 16.dp), @@ -75,14 +79,14 @@ fun VaultItemLoginContent( } } - viewState.passwordData?.let { passwordData -> + loginItemState.passwordData?.let { passwordData -> item { Spacer(modifier = Modifier.height(8.dp)) PasswordField( passwordData = passwordData, - onShowPasswordClick = loginHandlers.onShowPasswordClick, - onCheckForBreachClick = loginHandlers.onCheckForBreachClick, - onCopyPasswordClick = loginHandlers.onCopyPasswordClick, + onShowPasswordClick = vaultLoginItemTypeHandlers.onShowPasswordClick, + onCheckForBreachClick = vaultLoginItemTypeHandlers.onCheckForBreachClick, + onCopyPasswordClick = vaultLoginItemTypeHandlers.onCopyPasswordClick, modifier = Modifier .fillMaxWidth() .padding(horizontal = 16.dp), @@ -93,14 +97,14 @@ fun VaultItemLoginContent( item { Spacer(modifier = Modifier.height(8.dp)) TotpField( - isPremiumUser = viewState.isPremiumUser, + isPremiumUser = commonState.isPremiumUser, modifier = Modifier .fillMaxWidth() .padding(horizontal = 16.dp), ) } - viewState.uris.takeUnless { it.isEmpty() }?.let { uris -> + loginItemState.uris.takeUnless { it.isEmpty() }?.let { uris -> item { Spacer(modifier = Modifier.height(4.dp)) BitwardenListHeaderText( @@ -114,8 +118,8 @@ fun VaultItemLoginContent( Spacer(modifier = Modifier.height(8.dp)) UriField( uriData = uriData, - onCopyUriClick = loginHandlers.onCopyUriClick, - onLaunchUriClick = loginHandlers.onLaunchUriClick, + onCopyUriClick = vaultLoginItemTypeHandlers.onCopyUriClick, + onLaunchUriClick = vaultLoginItemTypeHandlers.onLaunchUriClick, modifier = Modifier .fillMaxWidth() .padding(horizontal = 16.dp), @@ -123,7 +127,7 @@ fun VaultItemLoginContent( } } - viewState.notes?.let { notes -> + commonState.notes?.let { notes -> item { Spacer(modifier = Modifier.height(4.dp)) BitwardenListHeaderText( @@ -142,7 +146,7 @@ fun VaultItemLoginContent( } } - viewState.customFields.takeUnless { it.isEmpty() }?.let { customFields -> + commonState.customFields.takeUnless { it.isEmpty() }?.let { customFields -> item { Spacer(modifier = Modifier.height(4.dp)) BitwardenListHeaderText( @@ -156,9 +160,9 @@ fun VaultItemLoginContent( Spacer(modifier = Modifier.height(8.dp)) CustomField( customField = customField, - onCopyCustomHiddenField = loginHandlers.onCopyCustomHiddenField, - onCopyCustomTextField = loginHandlers.onCopyCustomTextField, - onShowHiddenFieldClick = loginHandlers.onShowHiddenFieldClick, + onCopyCustomHiddenField = vaultCommonItemTypeHandlers.onCopyCustomHiddenField, + onCopyCustomTextField = vaultCommonItemTypeHandlers.onCopyCustomTextField, + onShowHiddenFieldClick = vaultCommonItemTypeHandlers.onShowHiddenFieldClick, modifier = Modifier .fillMaxWidth() .padding(horizontal = 16.dp), @@ -170,14 +174,14 @@ fun VaultItemLoginContent( Spacer(modifier = Modifier.height(24.dp)) UpdateText( header = "${stringResource(id = R.string.date_updated)}: ", - text = viewState.lastUpdated, + text = commonState.lastUpdated, modifier = Modifier .fillMaxWidth() .padding(horizontal = 16.dp), ) } - viewState.passwordRevisionDate?.let { revisionDate -> + loginItemState.passwordRevisionDate?.let { revisionDate -> item { UpdateText( header = "${stringResource(id = R.string.date_password_updated)}: ", @@ -189,11 +193,11 @@ fun VaultItemLoginContent( } } - viewState.passwordHistoryCount?.let { passwordHistoryCount -> + loginItemState.passwordHistoryCount?.let { passwordHistoryCount -> item { PasswordHistoryCount( passwordHistoryCount = passwordHistoryCount, - onPasswordHistoryClick = loginHandlers.onPasswordHistoryClick, + onPasswordHistoryClick = vaultLoginItemTypeHandlers.onPasswordHistoryClick, modifier = Modifier .fillMaxWidth() .padding(horizontal = 16.dp), @@ -208,17 +212,17 @@ fun VaultItemLoginContent( } } -@Suppress("LongMethod") +@Suppress("LongMethod", "MaxLineLength") @Composable private fun CustomField( - customField: VaultItemState.ViewState.Content.Custom, + customField: VaultItemState.ViewState.Content.Common.Custom, onCopyCustomHiddenField: (String) -> Unit, onCopyCustomTextField: (String) -> Unit, - onShowHiddenFieldClick: (VaultItemState.ViewState.Content.Custom.HiddenField, Boolean) -> Unit, + onShowHiddenFieldClick: (VaultItemState.ViewState.Content.Common.Custom.HiddenField, Boolean) -> Unit, modifier: Modifier = Modifier, ) { when (customField) { - is VaultItemState.ViewState.Content.Custom.BooleanField -> { + is VaultItemState.ViewState.Content.Common.Custom.BooleanField -> { BitwardenWideSwitch( label = customField.name, isChecked = customField.value, @@ -228,7 +232,7 @@ private fun CustomField( ) } - is VaultItemState.ViewState.Content.Custom.HiddenField -> { + is VaultItemState.ViewState.Content.Common.Custom.HiddenField -> { BitwardenPasswordFieldWithActions( label = customField.name, value = customField.value, @@ -254,7 +258,7 @@ private fun CustomField( ) } - is VaultItemState.ViewState.Content.Custom.LinkedField -> { + is VaultItemState.ViewState.Content.Common.Custom.LinkedField -> { BitwardenTextField( label = customField.name, value = customField.vaultLinkedFieldType.label.invoke(), @@ -269,7 +273,7 @@ private fun CustomField( ) } - is VaultItemState.ViewState.Content.Custom.TextField -> { + is VaultItemState.ViewState.Content.Common.Custom.TextField -> { BitwardenTextFieldWithActions( label = customField.name, value = customField.value, @@ -310,7 +314,7 @@ private fun NotesField( @Composable private fun PasswordField( - passwordData: VaultItemState.ViewState.Content.PasswordData, + passwordData: VaultItemState.ViewState.Content.ItemType.Login.PasswordData, onShowPasswordClick: (Boolean) -> Unit, onCheckForBreachClick: () -> Unit, onCopyPasswordClick: () -> Unit, @@ -414,7 +418,7 @@ private fun UpdateText( @Composable private fun UriField( - uriData: VaultItemState.ViewState.Content.UriData, + uriData: VaultItemState.ViewState.Content.ItemType.Login.UriData, onCopyUriClick: (String) -> Unit, onLaunchUriClick: (String) -> Unit, modifier: Modifier = Modifier, @@ -473,72 +477,3 @@ private fun UsernameField( modifier = modifier, ) } - -/** - * A class dedicated to handling user interactions related to view login cipher UI. - * Each lambda corresponds to a specific user action, allowing for easy delegation of - * logic when user input is detected. - */ -@Suppress("LongParameterList") -class LoginHandlers( - val onCheckForBreachClick: () -> Unit, - val onCopyCustomHiddenField: (String) -> Unit, - val onCopyCustomTextField: (String) -> Unit, - val onCopyPasswordClick: () -> Unit, - val onCopyUriClick: (String) -> Unit, - val onCopyUsernameClick: () -> Unit, - val onLaunchUriClick: (String) -> Unit, - val onPasswordHistoryClick: () -> Unit, - val onShowHiddenFieldClick: ( - VaultItemState.ViewState.Content.Custom.HiddenField, - Boolean, - ) -> Unit, - val onShowPasswordClick: (isVisible: Boolean) -> Unit, -) { - companion object { - /** - * Creates the [LoginHandlers] using the [viewModel] to send the desired actions. - */ - @Suppress("LongMethod") - fun create( - viewModel: VaultItemViewModel, - ): LoginHandlers = - LoginHandlers( - onCheckForBreachClick = { - viewModel.trySendAction(VaultItemAction.Login.CheckForBreachClick) - }, - onCopyCustomHiddenField = { - viewModel.trySendAction(VaultItemAction.Login.CopyCustomHiddenFieldClick(it)) - }, - onCopyCustomTextField = { - viewModel.trySendAction(VaultItemAction.Login.CopyCustomTextFieldClick(it)) - }, - onCopyPasswordClick = { - viewModel.trySendAction(VaultItemAction.Login.CopyPasswordClick) - }, - onCopyUriClick = { - viewModel.trySendAction(VaultItemAction.Login.CopyUriClick(it)) - }, - onCopyUsernameClick = { - viewModel.trySendAction(VaultItemAction.Login.CopyUsernameClick) - }, - onLaunchUriClick = { - viewModel.trySendAction(VaultItemAction.Login.LaunchClick(it)) - }, - onPasswordHistoryClick = { - viewModel.trySendAction(VaultItemAction.Login.PasswordHistoryClick) - }, - onShowHiddenFieldClick = { customField, isVisible -> - viewModel.trySendAction( - VaultItemAction.Login.HiddenFieldVisibilityClicked( - isVisible = isVisible, - field = customField, - ), - ) - }, - onShowPasswordClick = { - viewModel.trySendAction(VaultItemAction.Login.PasswordVisibilityClicked(it)) - }, - ) - } -} diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/item/VaultItemScreen.kt b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/item/VaultItemScreen.kt index cb31c339c..a3e03547b 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/item/VaultItemScreen.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/item/VaultItemScreen.kt @@ -40,6 +40,8 @@ import com.x8bit.bitwarden.ui.platform.components.BitwardenOverflowActionItem import com.x8bit.bitwarden.ui.platform.components.BitwardenScaffold import com.x8bit.bitwarden.ui.platform.components.BitwardenTopAppBar import com.x8bit.bitwarden.ui.platform.components.LoadingDialogState +import com.x8bit.bitwarden.ui.vault.feature.item.handlers.VaultCommonItemTypeHandlers +import com.x8bit.bitwarden.ui.vault.feature.item.handlers.VaultLoginItemTypeHandlers /** * Displays the vault item screen. @@ -82,10 +84,10 @@ fun VaultItemScreen( VaultItemDialogs( dialog = state.dialog, onDismissRequest = remember(viewModel) { - { viewModel.trySendAction(VaultItemAction.DismissDialogClick) } + { viewModel.trySendAction(VaultItemAction.Common.DismissDialogClick) } }, onSubmitMasterPassword = remember(viewModel) { - { viewModel.trySendAction(VaultItemAction.MasterPasswordSubmit(it)) } + { viewModel.trySendAction(VaultItemAction.Common.MasterPasswordSubmit(it)) } }, ) @@ -101,7 +103,7 @@ fun VaultItemScreen( navigationIcon = painterResource(id = R.drawable.ic_close), navigationIconContentDescription = stringResource(id = R.string.close), onNavigationIconClick = remember(viewModel) { - { viewModel.trySendAction(VaultItemAction.CloseClick) } + { viewModel.trySendAction(VaultItemAction.Common.CloseClick) } }, actions = { BitwardenOverflowActionItem() @@ -117,7 +119,7 @@ fun VaultItemScreen( FloatingActionButton( containerColor = MaterialTheme.colorScheme.primaryContainer, onClick = remember(viewModel) { - { viewModel.trySendAction(VaultItemAction.EditClick) } + { viewModel.trySendAction(VaultItemAction.Common.EditClick) } }, modifier = Modifier.padding(bottom = 16.dp), ) { @@ -135,11 +137,11 @@ fun VaultItemScreen( .imePadding() .fillMaxSize() .padding(innerPadding), - onRefreshClick = remember(viewModel) { - { viewModel.trySendAction(VaultItemAction.RefreshClick) } + vaultCommonItemTypeHandlers = remember(viewModel) { + VaultCommonItemTypeHandlers.create(viewModel = viewModel) }, - loginHandlers = remember(viewModel) { - LoginHandlers.create(viewModel) + vaultLoginItemTypeHandlers = remember(viewModel) { + VaultLoginItemTypeHandlers.create(viewModel = viewModel) }, ) } @@ -178,23 +180,41 @@ private fun VaultItemDialogs( @Composable private fun VaultItemContent( viewState: VaultItemState.ViewState, + vaultCommonItemTypeHandlers: VaultCommonItemTypeHandlers, + vaultLoginItemTypeHandlers: VaultLoginItemTypeHandlers, modifier: Modifier = Modifier, - onRefreshClick: () -> Unit, - loginHandlers: LoginHandlers, ) { when (viewState) { is VaultItemState.ViewState.Error -> VaultItemError( errorState = viewState, - onRefreshClick = onRefreshClick, + onRefreshClick = vaultCommonItemTypeHandlers.onRefreshClick, modifier = modifier, ) - is VaultItemState.ViewState.Content -> when (viewState) { - is VaultItemState.ViewState.Content.Login -> VaultItemLoginContent( - viewState = viewState, - modifier = modifier, - loginHandlers = loginHandlers, - ) + is VaultItemState.ViewState.Content -> { + when (viewState.type) { + is VaultItemState.ViewState.Content.ItemType.Login -> { + VaultItemLoginContent( + commonState = viewState.common, + loginItemState = viewState.type, + modifier = modifier, + vaultCommonItemTypeHandlers = vaultCommonItemTypeHandlers, + vaultLoginItemTypeHandlers = vaultLoginItemTypeHandlers, + ) + } + + is VaultItemState.ViewState.Content.ItemType.Card -> { + // TODO UI for viewing Card BIT-513 + } + + is VaultItemState.ViewState.Content.ItemType.Identity -> { + // TODO UI for viewing Identity BIT-514 + } + + is VaultItemState.ViewState.Content.ItemType.SecureNote -> { + // TODO UI for viewing SecureNote BIT-515 + } + } } VaultItemState.ViewState.Loading -> VaultItemLoading( diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/item/VaultItemViewModel.kt b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/item/VaultItemViewModel.kt index 809191019..89cd2285e 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/item/VaultItemViewModel.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/item/VaultItemViewModel.kt @@ -47,6 +47,7 @@ class VaultItemViewModel @Inject constructor( ), ) { + //region Initialization and Overrides init { combine( vaultRepository.getVaultItemStateFlow(state.vaultItemId), @@ -63,124 +64,45 @@ class VaultItemViewModel @Inject constructor( override fun handleAction(action: VaultItemAction) { when (action) { - VaultItemAction.CloseClick -> handleCloseClick() - VaultItemAction.DismissDialogClick -> handleDismissDialogClick() - VaultItemAction.EditClick -> handleEditClick() - is VaultItemAction.MasterPasswordSubmit -> handleMasterPasswordSubmit(action) - VaultItemAction.RefreshClick -> handleRefreshClick() - is VaultItemAction.Login -> handleLoginActions(action) + is VaultItemAction.ItemType.Login -> handleLoginTypeActions(action) + is VaultItemAction.Common -> handleCommonActions(action) is VaultItemAction.Internal -> handleInternalAction(action) } } + //endregion Initialization and Overrides - private fun handleLoginActions(action: VaultItemAction.Login) { + //region Common Handlers + + private fun handleCommonActions(action: VaultItemAction.Common) { when (action) { - VaultItemAction.Login.CheckForBreachClick -> handleCheckForBreachClick() - VaultItemAction.Login.CopyPasswordClick -> handleCopyPasswordClick() - is VaultItemAction.Login.CopyCustomHiddenFieldClick -> { + is VaultItemAction.Common.CloseClick -> handleCloseClick() + is VaultItemAction.Common.DismissDialogClick -> handleDismissDialogClick() + is VaultItemAction.Common.EditClick -> handleEditClick() + is VaultItemAction.Common.MasterPasswordSubmit -> handleMasterPasswordSubmit(action) + is VaultItemAction.Common.RefreshClick -> handleRefreshClick() + is VaultItemAction.Common.CopyCustomHiddenFieldClick -> { handleCopyCustomHiddenFieldClick(action) } - - is VaultItemAction.Login.CopyCustomTextFieldClick -> { + is VaultItemAction.Common.CopyCustomTextFieldClick -> { handleCopyCustomTextFieldClick(action) } - - is VaultItemAction.Login.CopyUriClick -> handleCopyUriClick(action) - VaultItemAction.Login.CopyUsernameClick -> handleCopyUsernameClick() - is VaultItemAction.Login.LaunchClick -> handleLaunchClick(action) - VaultItemAction.Login.PasswordHistoryClick -> handlePasswordHistoryClick() - is VaultItemAction.Login.PasswordVisibilityClicked -> { - handlePasswordVisibilityClicked(action) - } - - is VaultItemAction.Login.HiddenFieldVisibilityClicked -> { + is VaultItemAction.Common.HiddenFieldVisibilityClicked -> { handleHiddenFieldVisibilityClicked(action) } } } - private fun handleInternalAction(action: VaultItemAction.Internal) { - when (action) { - is VaultItemAction.Internal.PasswordBreachReceive -> handlePasswordBreachReceive(action) - is VaultItemAction.Internal.VaultDataReceive -> handleVaultDataReceive(action) - is VaultItemAction.Internal.VerifyPasswordReceive -> handleVerifyPasswordReceive(action) - } - } - private fun handleCloseClick() { sendEvent(VaultItemEvent.NavigateBack) } - private fun handleCheckForBreachClick() { - onLoginContent { login -> - val password = requireNotNull(login.passwordData?.password) - mutableStateFlow.update { - it.copy(dialog = VaultItemState.DialogState.Loading) - } - viewModelScope.launch { - val result = authRepository.getPasswordBreachCount(password = password) - sendAction(VaultItemAction.Internal.PasswordBreachReceive(result)) - } - } - } - - private fun handleCopyPasswordClick() { - onLoginContent { login -> - val password = requireNotNull(login.passwordData?.password) - if (login.requiresReprompt) { - mutableStateFlow.update { - it.copy(dialog = VaultItemState.DialogState.MasterPasswordDialog) - } - return@onLoginContent - } - sendEvent(VaultItemEvent.CopyToClipboard(password.asText())) - } - } - - private fun handleCopyCustomHiddenFieldClick( - action: VaultItemAction.Login.CopyCustomHiddenFieldClick, - ) { - onContent { content -> - if (content.requiresReprompt) { - mutableStateFlow.update { - it.copy(dialog = VaultItemState.DialogState.MasterPasswordDialog) - } - return@onContent - } - sendEvent(VaultItemEvent.CopyToClipboard(action.field.asText())) - } - } - - private fun handleCopyCustomTextFieldClick( - action: VaultItemAction.Login.CopyCustomTextFieldClick, - ) { - sendEvent(VaultItemEvent.CopyToClipboard(action.field.asText())) - } - - private fun handleCopyUriClick(action: VaultItemAction.Login.CopyUriClick) { - sendEvent(VaultItemEvent.CopyToClipboard(action.uri.asText())) - } - - private fun handleCopyUsernameClick() { - onLoginContent { login -> - val username = requireNotNull(login.username) - if (login.requiresReprompt) { - mutableStateFlow.update { - it.copy(dialog = VaultItemState.DialogState.MasterPasswordDialog) - } - return@onLoginContent - } - sendEvent(VaultItemEvent.CopyToClipboard(username.asText())) - } - } - private fun handleDismissDialogClick() { mutableStateFlow.update { it.copy(dialog = null) } } private fun handleEditClick() { onContent { content -> - if (content.requiresReprompt) { + if (content.common.requiresReprompt) { mutableStateFlow.update { it.copy(dialog = VaultItemState.DialogState.MasterPasswordDialog) } @@ -190,11 +112,7 @@ class VaultItemViewModel @Inject constructor( } } - private fun handleLaunchClick(action: VaultItemAction.Login.LaunchClick) { - sendEvent(VaultItemEvent.NavigateToUri(action.uri)) - } - - private fun handleMasterPasswordSubmit(action: VaultItemAction.MasterPasswordSubmit) { + private fun handleMasterPasswordSubmit(action: VaultItemAction.Common.MasterPasswordSubmit) { mutableStateFlow.update { it.copy(dialog = VaultItemState.DialogState.Loading) } @@ -213,9 +131,147 @@ class VaultItemViewModel @Inject constructor( } } + private fun handleRefreshClick() { + // No need to update the view state, the vault repo will emit a new state during this time + vaultRepository.sync() + } + + private fun handleCopyCustomHiddenFieldClick( + action: VaultItemAction.Common.CopyCustomHiddenFieldClick, + ) { + onContent { content -> + if (content.common.requiresReprompt) { + mutableStateFlow.update { + it.copy(dialog = VaultItemState.DialogState.MasterPasswordDialog) + } + return@onContent + } + sendEvent(VaultItemEvent.CopyToClipboard(action.field.asText())) + } + } + + private fun handleCopyCustomTextFieldClick( + action: VaultItemAction.Common.CopyCustomTextFieldClick, + ) { + sendEvent(VaultItemEvent.CopyToClipboard(action.field.asText())) + } + + private fun handleHiddenFieldVisibilityClicked( + action: VaultItemAction.Common.HiddenFieldVisibilityClicked, + ) { + onContent { content -> + if (content.common.requiresReprompt) { + mutableStateFlow.update { + it.copy(dialog = VaultItemState.DialogState.MasterPasswordDialog) + } + return@onContent + } + mutableStateFlow.update { currentState -> + currentState.copy( + viewState = content.copy( + common = content.common.copy( + customFields = content.common.customFields.map { customField -> + if (customField == action.field) { + action.field.copy(isVisible = action.isVisible) + } else { + customField + } + }, + ), + ), + ) + } + } + } + + //endregion Common Handlers + + //region Login Type Handlers + + private fun handleLoginTypeActions(action: VaultItemAction.ItemType.Login) { + when (action) { + is VaultItemAction.ItemType.Login.CheckForBreachClick -> { + handleCheckForBreachClick() + } + + is VaultItemAction.ItemType.Login.CopyPasswordClick -> { + handleCopyPasswordClick() + } + + is VaultItemAction.ItemType.Login.CopyUriClick -> { + handleCopyUriClick(action) + } + + is VaultItemAction.ItemType.Login.CopyUsernameClick -> { + handleCopyUsernameClick() + } + + is VaultItemAction.ItemType.Login.LaunchClick -> { + handleLaunchClick(action) + } + + is VaultItemAction.ItemType.Login.PasswordHistoryClick -> { + handlePasswordHistoryClick() + } + + is VaultItemAction.ItemType.Login.PasswordVisibilityClicked -> { + handlePasswordVisibilityClicked(action) + } + } + } + + private fun handleCheckForBreachClick() { + onLoginContent { _, login -> + val password = requireNotNull(login.passwordData?.password) + mutableStateFlow.update { + it.copy(dialog = VaultItemState.DialogState.Loading) + } + viewModelScope.launch { + val result = authRepository.getPasswordBreachCount(password = password) + sendAction(VaultItemAction.Internal.PasswordBreachReceive(result)) + } + } + } + + private fun handleCopyPasswordClick() { + onLoginContent { content, login -> + if (content.common.requiresReprompt) { + mutableStateFlow.update { + it.copy(dialog = VaultItemState.DialogState.MasterPasswordDialog) + } + return@onLoginContent + } + val password = requireNotNull(login.passwordData?.password) + sendEvent(VaultItemEvent.CopyToClipboard(password.asText())) + } + } + + private fun handleCopyUriClick(action: VaultItemAction.ItemType.Login.CopyUriClick) { + sendEvent(VaultItemEvent.CopyToClipboard(action.uri.asText())) + } + + private fun handleCopyUsernameClick() { + onLoginContent { content, login -> + if (content.common.requiresReprompt) { + mutableStateFlow.update { + it.copy(dialog = VaultItemState.DialogState.MasterPasswordDialog) + } + return@onLoginContent + } + val username = requireNotNull(login.username) + sendEvent(VaultItemEvent.CopyToClipboard(username.asText())) + } + } + + private fun handleLaunchClick( + action: VaultItemAction.ItemType.Login.LaunchClick, + ) { + sendEvent(VaultItemEvent.NavigateToUri(action.uri)) + } + private fun handlePasswordHistoryClick() { onContent { content -> - if (content.requiresReprompt) { + if (content.common.requiresReprompt) { mutableStateFlow.update { it.copy(dialog = VaultItemState.DialogState.MasterPasswordDialog) } @@ -225,57 +281,39 @@ class VaultItemViewModel @Inject constructor( } } - private fun handleRefreshClick() { - // No need to update the view state, the vault repo will emit a new state during this time - vaultRepository.sync() - } - private fun handlePasswordVisibilityClicked( - action: VaultItemAction.Login.PasswordVisibilityClicked, + action: VaultItemAction.ItemType.Login.PasswordVisibilityClicked, ) { - onLoginContent { login -> - if (login.requiresReprompt) { + onLoginContent { content, login -> + if (content.common.requiresReprompt) { mutableStateFlow.update { it.copy(dialog = VaultItemState.DialogState.MasterPasswordDialog) } return@onLoginContent } - mutableStateFlow.update { - it.copy( - viewState = login.copy( - passwordData = login.passwordData?.copy( - isVisible = action.isVisible, + mutableStateFlow.update { currentState -> + currentState.copy( + viewState = content.copy( + type = login.copy( + passwordData = login.passwordData?.copy( + isVisible = action.isVisible, + ), + ), ), - ), - ) - } + ) + } } } - private fun handleHiddenFieldVisibilityClicked( - action: VaultItemAction.Login.HiddenFieldVisibilityClicked, - ) { - onLoginContent { login -> - if (login.requiresReprompt) { - mutableStateFlow.update { - it.copy(dialog = VaultItemState.DialogState.MasterPasswordDialog) - } - return@onLoginContent - } + //endregion Login Type Handlers - mutableStateFlow.update { - it.copy( - viewState = login.copy( - customFields = login.customFields.map { customField -> - if (customField == action.field) { - action.field.copy(isVisible = action.isVisible) - } else { - customField - } - }, - ), - ) - } + //region Internal Type Handlers + + private fun handleInternalAction(action: VaultItemAction.Internal) { + when (action) { + is VaultItemAction.Internal.PasswordBreachReceive -> handlePasswordBreachReceive(action) + is VaultItemAction.Internal.VaultDataReceive -> handleVaultDataReceive(action) + is VaultItemAction.Internal.VerifyPasswordReceive -> handleVerifyPasswordReceive(action) } } @@ -372,24 +410,24 @@ class VaultItemViewModel @Inject constructor( } is VerifyPasswordResult.Success -> { - mutableStateFlow.update { - it.copy( - dialog = null, - viewState = when (val viewState = state.viewState) { - is VaultItemState.ViewState.Content.Login -> viewState.copy( - requiresReprompt = !result.isVerified, - ) - - is VaultItemState.ViewState.Error -> viewState - - VaultItemState.ViewState.Loading -> viewState - }, - ) + onContent { content -> + mutableStateFlow.update { + it.copy( + dialog = null, + viewState = content.copy( + common = content.common.copy( + requiresReprompt = !result.isVerified, + ), + ), + ) + } } } } } + //endregion Internal Type Handlers + private inline fun onContent( crossinline block: (VaultItemState.ViewState.Content) -> Unit, ) { @@ -397,9 +435,18 @@ class VaultItemViewModel @Inject constructor( } private inline fun onLoginContent( - crossinline block: (VaultItemState.ViewState.Content.Login) -> Unit, + crossinline block: ( + VaultItemState.ViewState.Content, + VaultItemState.ViewState.Content.ItemType.Login, + ) -> Unit, ) { - (state.viewState as? VaultItemState.ViewState.Content.Login)?.let(block) + (state.viewState as? VaultItemState.ViewState.Content) + ?.let { content -> + (content.type as? VaultItemState.ViewState.Content.ItemType.Login) + ?.let { loginContent -> + block(content, loginContent) + } + } } } @@ -434,126 +481,142 @@ data class VaultItemState( /** * Represents a loaded content state for the [VaultItemScreen]. */ - sealed class Content : ViewState() { + @Parcelize + data class Content( + val common: Common, + val type: ItemType, + ) : ViewState() { /** - * The name of the cipher. - */ - abstract val name: String - - /** - * A formatted date string indicating when the cipher was last updated. - */ - abstract val lastUpdated: String - - /** - * An integer indicating how many times the password has been changed. - */ - abstract val passwordHistoryCount: Int? - - /** - * Contains general notes taken by the user. - */ - abstract val notes: String? - - /** - * Indicates if the user has subscribed to a premium account or not. - */ - abstract val isPremiumUser: Boolean - - /** - * A list of custom fields that user has added. - */ - abstract val customFields: List<Custom> - - /** - * Indicates if a master password prompt is required to view secure fields. - */ - abstract val requiresReprompt: Boolean - - /** - * Represents a loaded content state for the [VaultItemScreen] when displaying a - * login cipher. + * Content data that is common for all item types. + * + * @property name The name of the item. + * @property lastUpdated A formatted date string indicating when the item was last + * updated. + * @property notes Contains general notes taken by the user. + * @property isPremiumUser Indicates if the user has subscribed to a premium account. + * @property customFields A list of custom fields that user has added. + * @property requiresReprompt Indicates if a master password prompt is required to view + * secure fields. */ @Parcelize - data class Login( - override val name: String, - override val lastUpdated: String, - override val passwordHistoryCount: Int?, - override val notes: String?, - override val isPremiumUser: Boolean, - override val customFields: List<Custom>, - override val requiresReprompt: Boolean, - val username: String?, - val passwordData: PasswordData?, - val uris: List<UriData>, - val passwordRevisionDate: String?, - val totp: String?, - ) : Content() + data class Common( + val name: String, + val lastUpdated: String, + val notes: String?, + val isPremiumUser: Boolean, + val customFields: List<Custom>, + val requiresReprompt: Boolean, + ) : Parcelable { + + /** + * Represents a custom field, TextField, HiddenField, BooleanField, or LinkedField. + */ + sealed class Custom : Parcelable { + /** + * Represents the data for displaying a custom text field. + */ + @Parcelize + data class TextField( + val name: String, + val value: String, + val isCopyable: Boolean, + ) : Custom() + + /** + * Represents the data for displaying a custom hidden text field. + */ + @Parcelize + data class HiddenField( + val name: String, + val value: String, + val isCopyable: Boolean, + val isVisible: Boolean, + ) : Custom() + + /** + * Represents the data for displaying a custom boolean property field. + */ + @Parcelize + data class BooleanField( + val name: String, + val value: Boolean, + ) : Custom() + + /** + * Represents the data for displaying a custom linked field. + */ + @Parcelize + data class LinkedField( + val vaultLinkedFieldType: VaultLinkedFieldType, + val name: String, + ) : Custom() + } + } /** - * A wrapper for the password data, this includes the [password] itself and whether it - * should be visible. + * Content data specific to an item type. */ @Parcelize - data class PasswordData( - val password: String, - val isVisible: Boolean, - ) : Parcelable - - /** - * A wrapper for URI data, including the [uri] itself and whether it is copyable and - * launchable. - */ - @Parcelize - data class UriData( - val uri: String, - val isCopyable: Boolean, - val isLaunchable: Boolean, - ) : Parcelable - - /** - * Represents a custom field, TextField, HiddenField, BooleanField, or LinkedField. - */ - sealed class Custom : Parcelable { - /** - * Represents the data for displaying a custom text field. - */ - @Parcelize - data class TextField( - val name: String, - val value: String, - val isCopyable: Boolean, - ) : Custom() + sealed class ItemType : Parcelable { /** - * Represents the data for displaying a custom hidden text field. + * Represents the `Login` item type. + * + * @property username The username required for the login item. + * @property passwordData The password required for the login item. + * @property passwordHistoryCount An integer indicating how many times the password + * has been changed. + * @property uris The URI associated with the login item. + * @property passwordRevisionDate + * @property totp */ @Parcelize - data class HiddenField( - val name: String, - val value: String, - val isCopyable: Boolean, - val isVisible: Boolean, - ) : Custom() + data class Login( + val username: String?, + val passwordData: PasswordData?, + val passwordHistoryCount: Int?, + val uris: List<UriData>, + val passwordRevisionDate: String?, + val totp: String?, + ) : ItemType() { + + /** + * A wrapper for the password data, this includes the [password] itself + * and whether it should be visible. + */ + @Parcelize + data class PasswordData( + val password: String, + val isVisible: Boolean, + ) : Parcelable + + /** + * A wrapper for URI data, including the [uri] itself and whether it is + * copyable and launch-able. + */ + @Parcelize + data class UriData( + val uri: String, + val isCopyable: Boolean, + val isLaunchable: Boolean, + ) : Parcelable + } /** - * Represents the data for displaying a custom boolean property field. + * Represents the `SecureNote` item type. */ - @Parcelize - data class BooleanField( - val name: String, - val value: Boolean, - ) : Custom() + data object SecureNote : ItemType() /** - * Represents the data for displaying a custom linked field. + * Represents the `Identity` item type. */ - @Parcelize - data class LinkedField( - val vaultLinkedFieldType: VaultLinkedFieldType, - val name: String, - ) : Custom() + data object Identity : ItemType() + + /** + * Represents the `Card` item type. + */ + data object Card : ItemType() } } } @@ -631,102 +694,116 @@ sealed class VaultItemEvent { } /** - * Represents a set of actions related view a vault item. + * Represents a set of actions related to viewing a vault item. + * Each subclass of this sealed class denotes a distinct action that can be taken. */ sealed class VaultItemAction { - /** - * The user has clicked the close button. - */ - data object CloseClick : VaultItemAction() /** - * The user has clicked to dismiss the dialog. + * Represents actions common across all item types. */ - data object DismissDialogClick : VaultItemAction() + sealed class Common : VaultItemAction() { - /** - * The user has clicked the edit button. - */ - data object EditClick : VaultItemAction() - - /** - * The user has submitted their master password. - */ - data class MasterPasswordSubmit( - val masterPassword: String, - ) : VaultItemAction() - - /** - * The user has clicked the refresh button. - */ - data object RefreshClick : VaultItemAction() - - /** - * Models actions that are associated with the [VaultItemState.ViewState.Content.Login] state. - */ - sealed class Login : VaultItemAction() { /** - * The user has clicked the check for breach button. + * The user has clicked the close button. */ - data object CheckForBreachClick : Login() + data object CloseClick : Common() + + /** + * The user has clicked to dismiss the dialog. + */ + data object DismissDialogClick : Common() + + /** + * The user has clicked the edit button. + */ + data object EditClick : Common() + + /** + * The user has submitted their master password. + */ + data class MasterPasswordSubmit( + val masterPassword: String, + ) : Common() + + /** + * The user has clicked the refresh button. + */ + data object RefreshClick : Common() /** * The user has clicked the copy button for a custom hidden field. */ data class CopyCustomHiddenFieldClick( val field: String, - ) : Login() + ) : Common() /** * The user has clicked the copy button for a custom text field. */ data class CopyCustomTextFieldClick( val field: String, - ) : Login() - - /** - * The user has clicked the copy button for the password. - */ - data object CopyPasswordClick : Login() - - /** - * The user has clicked the copy button for a URI. - */ - data class CopyUriClick( - val uri: String, - ) : Login() - - /** - * The user has clicked the copy button for the username. - */ - data object CopyUsernameClick : Login() - - /** - * The user has clicked the launch button for a URI. - */ - data class LaunchClick( - val uri: String, - ) : Login() - - /** - * The user has clicked the password history text. - */ - data object PasswordHistoryClick : Login() - - /** - * The user has clicked to display the password. - */ - data class PasswordVisibilityClicked( - val isVisible: Boolean, - ) : Login() + ) : Common() /** * The user has clicked to display the a hidden field. */ data class HiddenFieldVisibilityClicked( - val field: VaultItemState.ViewState.Content.Custom.HiddenField, + val field: VaultItemState.ViewState.Content.Common.Custom.HiddenField, val isVisible: Boolean, - ) : Login() + ) : Common() + } + + /** + * Represents actions specific to an item type. + */ + sealed class ItemType : VaultItemAction() { + + /** + * Represents actions specific to the Login type. + */ + sealed class Login : ItemType() { + /** + * The user has clicked the check for breach button. + */ + data object CheckForBreachClick : Login() + + /** + * The user has clicked the copy button for the password. + */ + data object CopyPasswordClick : Login() + + /** + * The user has clicked the copy button for a URI. + */ + data class CopyUriClick( + val uri: String, + ) : Login() + + /** + * The user has clicked the copy button for the username. + */ + data object CopyUsernameClick : Login() + + /** + * The user has clicked the launch button for a URI. + */ + data class LaunchClick( + val uri: String, + ) : Login() + + /** + * The user has clicked the password history text. + */ + data object PasswordHistoryClick : Login() + + /** + * The user has clicked to display the password. + */ + data class PasswordVisibilityClicked( + val isVisible: Boolean, + ) : Login() + } } /** diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/item/handlers/VaultCommonItemTypeHandlers.kt b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/item/handlers/VaultCommonItemTypeHandlers.kt new file mode 100644 index 000000000..2ac0ec74d --- /dev/null +++ b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/item/handlers/VaultCommonItemTypeHandlers.kt @@ -0,0 +1,52 @@ +package com.x8bit.bitwarden.ui.vault.feature.item.handlers + +import com.x8bit.bitwarden.ui.vault.feature.item.VaultItemAction +import com.x8bit.bitwarden.ui.vault.feature.item.VaultItemState +import com.x8bit.bitwarden.ui.vault.feature.item.VaultItemViewModel + +/** + * A collection of handler functions for managing actions common within the context of viewing + * items in a vault. + * + * @property onCopyCustomHiddenField + * @property onCopyCustomTextField + * @property onShowHiddenFieldClick + */ +class VaultCommonItemTypeHandlers( + val onRefreshClick: () -> Unit, + val onCopyCustomHiddenField: (String) -> Unit, + val onCopyCustomTextField: (String) -> Unit, + val onShowHiddenFieldClick: ( + VaultItemState.ViewState.Content.Common.Custom.HiddenField, + Boolean, + ) -> Unit, +) { + companion object { + /** + * Creates an instance of [VaultCommonItemTypeHandlers] by binding actions + * to the provided [VaultItemViewModel]. + */ + fun create( + viewModel: VaultItemViewModel, + ): VaultCommonItemTypeHandlers = + VaultCommonItemTypeHandlers( + onRefreshClick = { + viewModel.trySendAction(VaultItemAction.Common.RefreshClick) + }, + onCopyCustomHiddenField = { + viewModel.trySendAction(VaultItemAction.Common.CopyCustomHiddenFieldClick(it)) + }, + onCopyCustomTextField = { + viewModel.trySendAction(VaultItemAction.Common.CopyCustomTextFieldClick(it)) + }, + onShowHiddenFieldClick = { customField, isVisible -> + viewModel.trySendAction( + VaultItemAction.Common.HiddenFieldVisibilityClicked( + isVisible = isVisible, + field = customField, + ), + ) + }, + ) + } +} diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/item/handlers/VaultLoginItemTypeHandlers.kt b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/item/handlers/VaultLoginItemTypeHandlers.kt new file mode 100644 index 000000000..6784baabb --- /dev/null +++ b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/item/handlers/VaultLoginItemTypeHandlers.kt @@ -0,0 +1,54 @@ +package com.x8bit.bitwarden.ui.vault.feature.item.handlers + +import com.x8bit.bitwarden.ui.vault.feature.item.VaultItemAction +import com.x8bit.bitwarden.ui.vault.feature.item.VaultItemViewModel + +/** + * A collection of handler functions for managing actions within the context of viewing identity + * items in a vault. + */ +@Suppress("LongParameterList") +class VaultLoginItemTypeHandlers( + val onCheckForBreachClick: () -> Unit, + val onCopyPasswordClick: () -> Unit, + val onCopyUriClick: (String) -> Unit, + val onCopyUsernameClick: () -> Unit, + val onLaunchUriClick: (String) -> Unit, + val onPasswordHistoryClick: () -> Unit, + val onShowPasswordClick: (isVisible: Boolean) -> Unit, +) { + companion object { + /** + * Creates the [VaultLoginItemTypeHandlers] using the [viewModel] to send desired actions. + */ + @Suppress("LongMethod") + fun create( + viewModel: VaultItemViewModel, + ): VaultLoginItemTypeHandlers = + VaultLoginItemTypeHandlers( + onCheckForBreachClick = { + viewModel.trySendAction(VaultItemAction.ItemType.Login.CheckForBreachClick) + }, + onCopyPasswordClick = { + viewModel.trySendAction(VaultItemAction.ItemType.Login.CopyPasswordClick) + }, + onCopyUriClick = { + viewModel.trySendAction(VaultItemAction.ItemType.Login.CopyUriClick(it)) + }, + onCopyUsernameClick = { + viewModel.trySendAction(VaultItemAction.ItemType.Login.CopyUsernameClick) + }, + onLaunchUriClick = { + viewModel.trySendAction(VaultItemAction.ItemType.Login.LaunchClick(it)) + }, + onPasswordHistoryClick = { + viewModel.trySendAction(VaultItemAction.ItemType.Login.PasswordHistoryClick) + }, + onShowPasswordClick = { + viewModel.trySendAction( + VaultItemAction.ItemType.Login.PasswordVisibilityClicked(it), + ) + }, + ) + } +} diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/item/util/CipherViewExtensions.kt b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/item/util/CipherViewExtensions.kt index eb94f07da..bc09ea3cc 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/item/util/CipherViewExtensions.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/item/util/CipherViewExtensions.kt @@ -7,11 +7,10 @@ import com.bitwarden.core.FieldType import com.bitwarden.core.FieldView import com.bitwarden.core.LoginUriView import com.x8bit.bitwarden.data.vault.repository.model.VaultData -import com.x8bit.bitwarden.ui.platform.base.util.asText import com.x8bit.bitwarden.ui.platform.base.util.orZeroWidthSpace -import com.x8bit.bitwarden.ui.vault.model.VaultLinkedFieldType import com.x8bit.bitwarden.ui.vault.feature.item.VaultItemState import com.x8bit.bitwarden.ui.vault.feature.vault.VaultState +import com.x8bit.bitwarden.ui.vault.model.VaultLinkedFieldType import java.time.format.DateTimeFormatter import java.util.TimeZone @@ -26,70 +25,77 @@ private val dateTimeFormatter fun CipherView.toViewState( isPremiumUser: Boolean, ): VaultItemState.ViewState = + VaultItemState.ViewState.Content( + common = VaultItemState.ViewState.Content.Common( + name = name, + isPremiumUser = isPremiumUser, + requiresReprompt = reprompt == CipherRepromptType.PASSWORD, + customFields = fields.orEmpty().map { it.toCustomField() }, + lastUpdated = dateTimeFormatter.format(revisionDate), + notes = notes, + ), + type = when (type) { + CipherType.LOGIN -> { + val loginValues = requireNotNull(login) + VaultItemState.ViewState.Content.ItemType.Login( + username = loginValues.username, + passwordData = loginValues.password?.let { + VaultItemState.ViewState.Content.ItemType.Login.PasswordData( + password = it, + isVisible = false, + ) + }, + uris = loginValues.uris.orEmpty().map { it.toUriData() }, + passwordRevisionDate = loginValues.passwordRevisionDate?.let { + dateTimeFormatter.format(it) + }, + passwordHistoryCount = passwordHistory?.count(), + totp = loginValues.totp, + ) + } + + CipherType.SECURE_NOTE -> { + VaultItemState.ViewState.Content.ItemType.SecureNote + } + + CipherType.CARD -> { + VaultItemState.ViewState.Content.ItemType.Card + } + + CipherType.IDENTITY -> { + VaultItemState.ViewState.Content.ItemType.Identity + } + }, + ) + +private fun FieldView.toCustomField(): VaultItemState.ViewState.Content.Common.Custom = when (type) { - CipherType.LOGIN -> { - val loginValues = requireNotNull(this.login) - VaultItemState.ViewState.Content.Login( - name = this.name, - username = loginValues.username, - passwordData = loginValues.password?.let { - VaultItemState.ViewState.Content.PasswordData(password = it, isVisible = false) - }, - isPremiumUser = isPremiumUser, - requiresReprompt = this.reprompt == CipherRepromptType.PASSWORD, - customFields = this.fields.orEmpty().map { it.toCustomField() }, - uris = loginValues.uris.orEmpty().map { it.toUriData() }, - lastUpdated = dateTimeFormatter.format(this.revisionDate), - passwordRevisionDate = loginValues.passwordRevisionDate?.let { - dateTimeFormatter.format(it) - }, - passwordHistoryCount = this.passwordHistory?.count(), - totp = loginValues.totp, - notes = this.notes, - ) - } - - CipherType.SECURE_NOTE -> VaultItemState.ViewState.Error( - message = "Not yet implemented.".asText(), - ) - - CipherType.CARD -> VaultItemState.ViewState.Error( - message = "Not yet implemented.".asText(), - ) - - CipherType.IDENTITY -> VaultItemState.ViewState.Error( - message = "Not yet implemented.".asText(), - ) - } - -private fun FieldView.toCustomField(): VaultItemState.ViewState.Content.Custom = - when (type) { - FieldType.TEXT -> VaultItemState.ViewState.Content.Custom.TextField( + FieldType.TEXT -> VaultItemState.ViewState.Content.Common.Custom.TextField( name = name.orEmpty(), value = value.orZeroWidthSpace(), isCopyable = !value.isNullOrBlank(), ) - FieldType.HIDDEN -> VaultItemState.ViewState.Content.Custom.HiddenField( + FieldType.HIDDEN -> VaultItemState.ViewState.Content.Common.Custom.HiddenField( name = name.orEmpty(), value = value.orZeroWidthSpace(), isCopyable = !value.isNullOrBlank(), isVisible = false, ) - FieldType.BOOLEAN -> VaultItemState.ViewState.Content.Custom.BooleanField( + FieldType.BOOLEAN -> VaultItemState.ViewState.Content.Common.Custom.BooleanField( name = name.orEmpty(), value = value?.toBoolean() ?: false, ) - FieldType.LINKED -> VaultItemState.ViewState.Content.Custom.LinkedField( + FieldType.LINKED -> VaultItemState.ViewState.Content.Common.Custom.LinkedField( vaultLinkedFieldType = VaultLinkedFieldType.fromId(requireNotNull(linkedId)), name = name.orEmpty(), ) } private fun LoginUriView.toUriData() = - VaultItemState.ViewState.Content.UriData( + VaultItemState.ViewState.Content.ItemType.Login.UriData( uri = uri.orZeroWidthSpace(), isCopyable = !uri.isNullOrBlank(), isLaunchable = !uri.isNullOrBlank(), diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/item/VaultItemScreenTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/item/VaultItemScreenTest.kt index 09eac2db5..5cd5cf0ef 100644 --- a/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/item/VaultItemScreenTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/item/VaultItemScreenTest.kt @@ -37,6 +37,7 @@ import org.junit.Assert.assertTrue import org.junit.Before import org.junit.Test +@Suppress("LargeClass") class VaultItemScreenTest : BaseComposeTest() { private var onNavigateBackCalled = false @@ -79,7 +80,7 @@ class VaultItemScreenTest : BaseComposeTest() { composeTestRule.onNodeWithContentDescription(label = "Close").performClick() verify { - viewModel.trySendAction(VaultItemAction.CloseClick) + viewModel.trySendAction(VaultItemAction.Common.CloseClick) } } @@ -143,7 +144,7 @@ class VaultItemScreenTest : BaseComposeTest() { .performClick() verify { - viewModel.trySendAction(VaultItemAction.DismissDialogClick) + viewModel.trySendAction(VaultItemAction.Common.DismissDialogClick) } } @@ -191,15 +192,26 @@ class VaultItemScreenTest : BaseComposeTest() { .performClick() verify { - viewModel.trySendAction(VaultItemAction.MasterPasswordSubmit(enteredPassword)) + viewModel.trySendAction(VaultItemAction.Common.MasterPasswordSubmit(enteredPassword)) } } @Test fun `in login state, on username copy click should send CopyUsernameClick`() { val username = "username1234" - mutableStateFlow.update { - it.copy(viewState = EMPTY_LOGIN_VIEW_STATE.copy(username = username)) + mutableStateFlow.update { currentState -> + currentState.copy( + viewState = EMPTY_LOGIN_VIEW_STATE.copy( + type = VaultItemState.ViewState.Content.ItemType.Login( + username = username, + passwordData = null, + passwordHistoryCount = null, + uris = emptyList(), + passwordRevisionDate = null, + totp = null, + ), + ), + ) } composeTestRule @@ -209,18 +221,24 @@ class VaultItemScreenTest : BaseComposeTest() { .performClick() verify { - viewModel.trySendAction(VaultItemAction.Login.CopyUsernameClick) + viewModel.trySendAction(VaultItemAction.ItemType.Login.CopyUsernameClick) } } @Test fun `in login state, on breach check click should send CheckForBreachClick`() { - val passwordData = VaultItemState.ViewState.Content.PasswordData( + val passwordData = VaultItemState.ViewState.Content.ItemType.Login.PasswordData( password = "12345", isVisible = true, ) - mutableStateFlow.update { - it.copy(viewState = EMPTY_LOGIN_VIEW_STATE.copy(passwordData = passwordData)) + mutableStateFlow.update { currentState -> + currentState.copy( + viewState = EMPTY_LOGIN_VIEW_STATE.copy( + type = EMPTY_LOGIN_TYPE.copy( + passwordData = passwordData, + ), + ), + ) } composeTestRule @@ -230,7 +248,7 @@ class VaultItemScreenTest : BaseComposeTest() { .performClick() verify { - viewModel.trySendAction(VaultItemAction.Login.CheckForBreachClick) + viewModel.trySendAction(VaultItemAction.ItemType.Login.CheckForBreachClick) } } @@ -245,18 +263,24 @@ class VaultItemScreenTest : BaseComposeTest() { .performClick() verify(exactly = 1) { - viewModel.trySendAction(VaultItemAction.Login.PasswordVisibilityClicked(true)) + viewModel.trySendAction(VaultItemAction.ItemType.Login.PasswordVisibilityClicked(true)) } } @Test fun `in login state, on copy password click should send CopyPasswordClick`() { - val passwordData = VaultItemState.ViewState.Content.PasswordData( + val passwordData = VaultItemState.ViewState.Content.ItemType.Login.PasswordData( password = "12345", isVisible = true, ) - mutableStateFlow.update { - it.copy(viewState = EMPTY_LOGIN_VIEW_STATE.copy(passwordData = passwordData)) + mutableStateFlow.update { currentState -> + currentState.copy( + viewState = EMPTY_LOGIN_VIEW_STATE.copy( + type = EMPTY_LOGIN_TYPE.copy( + passwordData = passwordData, + ), + ), + ) } composeTestRule @@ -266,19 +290,25 @@ class VaultItemScreenTest : BaseComposeTest() { .performClick() verify { - viewModel.trySendAction(VaultItemAction.Login.CopyPasswordClick) + viewModel.trySendAction(VaultItemAction.ItemType.Login.CopyPasswordClick) } } @Test fun `in login state, launch uri button should be displayed according to state`() { - val uriData = VaultItemState.ViewState.Content.UriData( + val uriData = VaultItemState.ViewState.Content.ItemType.Login.UriData( uri = "www.example.com", isCopyable = true, isLaunchable = true, ) - mutableStateFlow.update { - it.copy(viewState = EMPTY_LOGIN_VIEW_STATE.copy(uris = listOf(uriData))) + mutableStateFlow.update { currentState -> + currentState.copy( + viewState = EMPTY_LOGIN_VIEW_STATE.copy( + type = EMPTY_LOGIN_TYPE.copy( + uris = listOf(uriData), + ), + ), + ) } composeTestRule @@ -287,12 +317,10 @@ class VaultItemScreenTest : BaseComposeTest() { .filterToOne(hasContentDescription("Launch")) .assertIsDisplayed() - mutableStateFlow.update { - it.copy( - viewState = EMPTY_LOGIN_VIEW_STATE.copy( - uris = listOf(uriData.copy(isLaunchable = false)), - ), - ) + mutableStateFlow.update { currentState -> + updateLoginType(currentState) { + copy(uris = listOf(uriData.copy(isLaunchable = false))) + } } composeTestRule @@ -304,13 +332,19 @@ class VaultItemScreenTest : BaseComposeTest() { @Test fun `in login state, copy uri button should be displayed according to state`() { - val uriData = VaultItemState.ViewState.Content.UriData( + val uriData = VaultItemState.ViewState.Content.ItemType.Login.UriData( uri = "www.example.com", isCopyable = true, isLaunchable = true, ) - mutableStateFlow.update { - it.copy(viewState = EMPTY_LOGIN_VIEW_STATE.copy(uris = listOf(uriData))) + mutableStateFlow.update { currentState -> + currentState.copy( + viewState = EMPTY_LOGIN_VIEW_STATE.copy( + type = EMPTY_LOGIN_TYPE.copy( + uris = listOf(uriData), + ), + ), + ) } composeTestRule @@ -319,12 +353,8 @@ class VaultItemScreenTest : BaseComposeTest() { .filterToOne(hasContentDescription("Copy")) .assertIsDisplayed() - mutableStateFlow.update { - it.copy( - viewState = EMPTY_LOGIN_VIEW_STATE.copy( - uris = listOf(uriData.copy(isCopyable = false)), - ), - ) + mutableStateFlow.update { currentState -> + updateLoginType(currentState) { copy(uris = listOf(uriData.copy(isCopyable = false))) } } composeTestRule @@ -336,13 +366,19 @@ class VaultItemScreenTest : BaseComposeTest() { @Test fun `in login state, on launch URI click should send LaunchClick`() { - val uriData = VaultItemState.ViewState.Content.UriData( + val uriData = VaultItemState.ViewState.Content.ItemType.Login.UriData( uri = "www.example.com", isCopyable = true, isLaunchable = true, ) - mutableStateFlow.update { - it.copy(viewState = EMPTY_LOGIN_VIEW_STATE.copy(uris = listOf(uriData))) + mutableStateFlow.update { currentState -> + currentState.copy( + viewState = EMPTY_LOGIN_VIEW_STATE.copy( + type = EMPTY_LOGIN_TYPE.copy( + uris = listOf(uriData), + ), + ), + ) } composeTestRule @@ -352,19 +388,25 @@ class VaultItemScreenTest : BaseComposeTest() { .performClick() verify { - viewModel.trySendAction(VaultItemAction.Login.LaunchClick(uriData.uri)) + viewModel.trySendAction(VaultItemAction.ItemType.Login.LaunchClick(uriData.uri)) } } @Test fun `in login state, on copy URI click should send CopyUriClick`() { - val uriData = VaultItemState.ViewState.Content.UriData( + val uriData = VaultItemState.ViewState.Content.ItemType.Login.UriData( uri = "www.example.com", isCopyable = true, isLaunchable = true, ) - mutableStateFlow.update { - it.copy(viewState = EMPTY_LOGIN_VIEW_STATE.copy(uris = listOf(uriData))) + mutableStateFlow.update { currentState -> + currentState.copy( + viewState = EMPTY_LOGIN_VIEW_STATE.copy( + type = EMPTY_LOGIN_TYPE.copy( + uris = listOf(uriData), + ), + ), + ) } composeTestRule @@ -374,20 +416,26 @@ class VaultItemScreenTest : BaseComposeTest() { .performClick() verify { - viewModel.trySendAction(VaultItemAction.Login.CopyUriClick(uriData.uri)) + viewModel.trySendAction(VaultItemAction.ItemType.Login.CopyUriClick(uriData.uri)) } } @Test - fun `in login state, on show hidden field click should send HiddenFieldVisibilityClicked`() { - val textField = VaultItemState.ViewState.Content.Custom.HiddenField( + fun `on show hidden field click should send HiddenFieldVisibilityClicked`() { + val textField = VaultItemState.ViewState.Content.Common.Custom.HiddenField( name = "hidden", value = "hidden password", isCopyable = true, isVisible = false, ) - mutableStateFlow.update { - it.copy(viewState = EMPTY_LOGIN_VIEW_STATE.copy(customFields = listOf(textField))) + mutableStateFlow.update { currentState -> + currentState.copy( + viewState = EMPTY_LOGIN_VIEW_STATE.copy( + common = EMPTY_COMMON.copy( + customFields = listOf(textField), + ), + ), + ) } composeTestRule @@ -398,7 +446,7 @@ class VaultItemScreenTest : BaseComposeTest() { verify { viewModel.trySendAction( - VaultItemAction.Login.HiddenFieldVisibilityClicked( + VaultItemAction.Common.HiddenFieldVisibilityClicked( field = textField, isVisible = true, ), @@ -407,15 +455,21 @@ class VaultItemScreenTest : BaseComposeTest() { } @Test - fun `in login state, copy hidden field button should be displayed according to state`() { - val hiddenField = VaultItemState.ViewState.Content.Custom.HiddenField( + fun `copy hidden field button should be displayed according to state`() { + val hiddenField = VaultItemState.ViewState.Content.Common.Custom.HiddenField( name = "hidden", value = "hidden password", isCopyable = true, isVisible = false, ) - mutableStateFlow.update { - it.copy(viewState = EMPTY_LOGIN_VIEW_STATE.copy(customFields = listOf(hiddenField))) + mutableStateFlow.update { currentState -> + currentState.copy( + viewState = EMPTY_LOGIN_VIEW_STATE.copy( + common = EMPTY_COMMON.copy( + customFields = listOf(hiddenField), + ), + ), + ) } composeTestRule @@ -424,12 +478,10 @@ class VaultItemScreenTest : BaseComposeTest() { .filterToOne(hasContentDescription("Copy")) .assertIsDisplayed() - mutableStateFlow.update { - it.copy( - viewState = EMPTY_LOGIN_VIEW_STATE.copy( - customFields = listOf(hiddenField.copy(isCopyable = false)), - ), - ) + mutableStateFlow.update { currentState -> + updateCommonContent(currentState) { + copy(customFields = listOf(hiddenField.copy(isCopyable = false))) + } } composeTestRule @@ -440,15 +492,21 @@ class VaultItemScreenTest : BaseComposeTest() { } @Test - fun `in login state, on copy hidden field click should send CopyCustomHiddenFieldClick`() { - val hiddenField = VaultItemState.ViewState.Content.Custom.HiddenField( + fun `on copy hidden field click should send CopyCustomHiddenFieldClick`() { + val hiddenField = VaultItemState.ViewState.Content.Common.Custom.HiddenField( name = "hidden", value = "hidden password", isCopyable = true, isVisible = false, ) - mutableStateFlow.update { - it.copy(viewState = EMPTY_LOGIN_VIEW_STATE.copy(customFields = listOf(hiddenField))) + mutableStateFlow.update { currentState -> + currentState.copy( + viewState = EMPTY_LOGIN_VIEW_STATE.copy( + common = EMPTY_COMMON.copy( + customFields = listOf(hiddenField), + ), + ), + ) } composeTestRule @@ -459,20 +517,26 @@ class VaultItemScreenTest : BaseComposeTest() { verify { viewModel.trySendAction( - VaultItemAction.Login.CopyCustomHiddenFieldClick(hiddenField.value), + VaultItemAction.Common.CopyCustomHiddenFieldClick(hiddenField.value), ) } } @Test - fun `in login state, on copy text field click should send CopyCustomTextFieldClick`() { - val textField = VaultItemState.ViewState.Content.Custom.TextField( + fun `on copy text field click should send CopyCustomTextFieldClick`() { + val textField = VaultItemState.ViewState.Content.Common.Custom.TextField( name = "text", value = "value", isCopyable = true, ) - mutableStateFlow.update { - it.copy(viewState = EMPTY_LOGIN_VIEW_STATE.copy(customFields = listOf(textField))) + mutableStateFlow.update { currentState -> + currentState.copy( + viewState = EMPTY_LOGIN_VIEW_STATE.copy( + common = EMPTY_COMMON.copy( + customFields = listOf(textField), + ), + ), + ) } composeTestRule @@ -483,20 +547,26 @@ class VaultItemScreenTest : BaseComposeTest() { verify { viewModel.trySendAction( - VaultItemAction.Login.CopyCustomTextFieldClick(textField.value), + VaultItemAction.Common.CopyCustomTextFieldClick(textField.value), ) } } @Test - fun `in login state, text field copy button should be displayed according to state`() { - val textField = VaultItemState.ViewState.Content.Custom.TextField( + fun `text field copy button should be displayed according to state`() { + val textField = VaultItemState.ViewState.Content.Common.Custom.TextField( name = "text", value = "value", isCopyable = true, ) - mutableStateFlow.update { - it.copy(viewState = EMPTY_LOGIN_VIEW_STATE.copy(customFields = listOf(textField))) + mutableStateFlow.update { currentState -> + currentState.copy( + viewState = EMPTY_LOGIN_VIEW_STATE.copy( + common = EMPTY_COMMON.copy( + customFields = listOf(textField), + ), + ), + ) } composeTestRule @@ -505,12 +575,10 @@ class VaultItemScreenTest : BaseComposeTest() { .filterToOne(hasContentDescription("Copy")) .assertIsDisplayed() - mutableStateFlow.update { - it.copy( - viewState = EMPTY_LOGIN_VIEW_STATE.copy( - customFields = listOf(textField.copy(isCopyable = false)), - ), - ) + mutableStateFlow.update { currentState -> + updateCommonContent(currentState) { + copy(customFields = listOf(textField.copy(isCopyable = false))) + } } composeTestRule @@ -522,15 +590,21 @@ class VaultItemScreenTest : BaseComposeTest() { @Test fun `in login state, on password history click should send PasswordHistoryClick`() { - mutableStateFlow.update { - it.copy(viewState = EMPTY_LOGIN_VIEW_STATE.copy(passwordHistoryCount = 5)) + mutableStateFlow.update { currentState -> + currentState.copy( + viewState = EMPTY_LOGIN_VIEW_STATE.copy( + type = EMPTY_LOGIN_TYPE.copy( + passwordHistoryCount = 5, + ), + ), + ) } composeTestRule.onNodeWithTextAfterScroll("5") composeTestRule.onNodeWithText("5").performClick() verify { - viewModel.trySendAction(VaultItemAction.Login.PasswordHistoryClick) + viewModel.trySendAction(VaultItemAction.ItemType.Login.PasswordHistoryClick) } } @@ -551,7 +625,7 @@ class VaultItemScreenTest : BaseComposeTest() { } composeTestRule.onNodeWithContentDescription("Edit item").performClick() verify(exactly = 1) { - viewModel.trySendAction(VaultItemAction.EditClick) + viewModel.trySendAction(VaultItemAction.Common.EditClick) } } @@ -590,8 +664,8 @@ class VaultItemScreenTest : BaseComposeTest() { mutableStateFlow.update { it.copy(viewState = DEFAULT_LOGIN_VIEW_STATE) } composeTestRule.onNodeWithTextAfterScroll(username).assertIsDisplayed() - mutableStateFlow.update { - it.copy(viewState = DEFAULT_LOGIN_VIEW_STATE.copy(username = null)) + mutableStateFlow.update { currentState -> + updateLoginType(currentState) { copy(username = null) } } composeTestRule.assertScrollableNodeDoesNotExist(username) @@ -604,8 +678,8 @@ class VaultItemScreenTest : BaseComposeTest() { composeTestRule.onNodeWithTextAfterScroll("URI").assertIsDisplayed() composeTestRule.onNodeWithTextAfterScroll("www.example.com").assertIsDisplayed() - mutableStateFlow.update { - it.copy(viewState = DEFAULT_LOGIN_VIEW_STATE.copy(uris = emptyList())) + mutableStateFlow.update { currentState -> + updateLoginType(currentState) { copy(uris = emptyList()) } } composeTestRule.assertScrollableNodeDoesNotExist("URIs") @@ -614,13 +688,13 @@ class VaultItemScreenTest : BaseComposeTest() { } @Test - fun `in login state, notes should be displayed according to state`() { + fun `notes should be displayed according to state`() { mutableStateFlow.update { it.copy(viewState = DEFAULT_LOGIN_VIEW_STATE) } composeTestRule.onFirstNodeWithTextAfterScroll("Notes").assertIsDisplayed() composeTestRule.onNodeWithTextAfterScroll("Lots of notes").assertIsDisplayed() - mutableStateFlow.update { - it.copy(viewState = DEFAULT_LOGIN_VIEW_STATE.copy(notes = null)) + mutableStateFlow.update { currentState -> + updateCommonContent(currentState) { copy(notes = null) } } composeTestRule.assertScrollableNodeDoesNotExist("Notes") @@ -628,7 +702,7 @@ class VaultItemScreenTest : BaseComposeTest() { } @Test - fun `in login state, custom views should be displayed according to state`() { + fun `custom views should be displayed according to state`() { mutableStateFlow.update { it.copy(viewState = DEFAULT_LOGIN_VIEW_STATE) } composeTestRule.onNodeWithTextAfterScroll("Custom fields").assertIsDisplayed() composeTestRule.onNodeWithTextAfterScroll("text").assertIsDisplayed() @@ -638,8 +712,8 @@ class VaultItemScreenTest : BaseComposeTest() { composeTestRule.onNodeWithTextAfterScroll("linked username").assertIsDisplayed() composeTestRule.onNodeWithTextAfterScroll("linked password").assertIsDisplayed() - mutableStateFlow.update { - it.copy(viewState = DEFAULT_LOGIN_VIEW_STATE.copy(customFields = emptyList())) + mutableStateFlow.update { currentState -> + updateCommonContent(currentState) { copy(customFields = emptyList()) } } composeTestRule.assertScrollableNodeDoesNotExist("Custom fields") @@ -657,8 +731,8 @@ class VaultItemScreenTest : BaseComposeTest() { composeTestRule.onNodeWithTextAfterScroll("Password updated: ").assertIsDisplayed() composeTestRule.onNodeWithTextAfterScroll("4/14/83 3:56 PM").assertIsDisplayed() - mutableStateFlow.update { - it.copy(viewState = DEFAULT_LOGIN_VIEW_STATE.copy(passwordRevisionDate = null)) + mutableStateFlow.update { currentState -> + updateLoginType(currentState) { copy(passwordRevisionDate = null) } } composeTestRule.assertScrollableNodeDoesNotExist("Password updated: ") @@ -671,8 +745,8 @@ class VaultItemScreenTest : BaseComposeTest() { composeTestRule.onNodeWithTextAfterScroll("Password history: ").assertIsDisplayed() composeTestRule.onNodeWithTextAfterScroll("1").assertIsDisplayed() - mutableStateFlow.update { - it.copy(viewState = DEFAULT_LOGIN_VIEW_STATE.copy(passwordHistoryCount = null)) + mutableStateFlow.update { currentState -> + updateLoginType(currentState) { copy(passwordHistoryCount = null) } } composeTestRule.assertScrollableNodeDoesNotExist("Password history: ") @@ -680,6 +754,49 @@ class VaultItemScreenTest : BaseComposeTest() { } } +//region Helper functions + +@Suppress("MaxLineLength") +private fun updateLoginType( + currentState: VaultItemState, + transform: VaultItemState.ViewState.Content.ItemType.Login.() -> + VaultItemState.ViewState.Content.ItemType.Login, +): VaultItemState { + val updatedType = when (val viewState = currentState.viewState) { + is VaultItemState.ViewState.Content -> { + when (val type = viewState.type) { + is VaultItemState.ViewState.Content.ItemType.Login -> { + viewState.copy( + type = type.transform(), + ) + } + + else -> viewState + } + } + + else -> viewState + } + return currentState.copy(viewState = updatedType) +} + +@Suppress("MaxLineLength") +private fun updateCommonContent( + currentState: VaultItemState, + transform: VaultItemState.ViewState.Content.Common.() + -> VaultItemState.ViewState.Content.Common, +): VaultItemState { + val updatedType = when (val viewState = currentState.viewState) { + is VaultItemState.ViewState.Content -> + viewState.copy(common = viewState.common.transform()) + + else -> viewState + } + return currentState.copy(viewState = updatedType) +} + +//endregion Helper functions + private const val VAULT_ITEM_ID = "vault_item_id" private val DEFAULT_STATE: VaultItemState = VaultItemState( @@ -688,67 +805,81 @@ private val DEFAULT_STATE: VaultItemState = VaultItemState( dialog = null, ) -private val DEFAULT_LOGIN_VIEW_STATE: VaultItemState.ViewState.Content.Login = - VaultItemState.ViewState.Content.Login( - name = "login cipher", - lastUpdated = "12/31/69 06:16 PM", - passwordHistoryCount = 1, - notes = "Lots of notes", - isPremiumUser = true, - customFields = listOf( - VaultItemState.ViewState.Content.Custom.TextField( - name = "text", - value = "value", - isCopyable = true, - ), - VaultItemState.ViewState.Content.Custom.HiddenField( - name = "hidden", - value = "hidden password", - isCopyable = true, +private val DEFAULT_LOGIN_VIEW_STATE: VaultItemState.ViewState.Content = + VaultItemState.ViewState.Content( + type = VaultItemState.ViewState.Content.ItemType.Login( + passwordHistoryCount = 1, + username = "the username", + passwordData = VaultItemState.ViewState.Content.ItemType.Login.PasswordData( + password = "the password", isVisible = false, ), - VaultItemState.ViewState.Content.Custom.BooleanField( - name = "boolean", - value = true, - ), - VaultItemState.ViewState.Content.Custom.LinkedField( - name = "linked username", - vaultLinkedFieldType = VaultLinkedFieldType.USERNAME, - ), - VaultItemState.ViewState.Content.Custom.LinkedField( - name = "linked password", - vaultLinkedFieldType = VaultLinkedFieldType.PASSWORD, + uris = listOf( + VaultItemState.ViewState.Content.ItemType.Login.UriData( + uri = "www.example.com", + isCopyable = true, + isLaunchable = true, + ), ), + passwordRevisionDate = "4/14/83 3:56 PM", + totp = "otpauth://totp/Example:alice@google.com?secret=JBSWY3DPEHPK3PXP&issuer=Example", ), - requiresReprompt = true, - username = "the username", - passwordData = VaultItemState.ViewState.Content.PasswordData( - password = "the password", - isVisible = false, - ), - uris = listOf( - VaultItemState.ViewState.Content.UriData( - uri = "www.example.com", - isCopyable = true, - isLaunchable = true, + common = VaultItemState.ViewState.Content.Common( + lastUpdated = "12/31/69 06:16 PM", + name = "login cipher", + notes = "Lots of notes", + isPremiumUser = true, + customFields = listOf( + VaultItemState.ViewState.Content.Common.Custom.TextField( + name = "text", + value = "value", + isCopyable = true, + ), + VaultItemState.ViewState.Content.Common.Custom.HiddenField( + name = "hidden", + value = "hidden password", + isCopyable = true, + isVisible = false, + ), + VaultItemState.ViewState.Content.Common.Custom.BooleanField( + name = "boolean", + value = true, + ), + VaultItemState.ViewState.Content.Common.Custom.LinkedField( + name = "linked username", + vaultLinkedFieldType = VaultLinkedFieldType.USERNAME, + ), + VaultItemState.ViewState.Content.Common.Custom.LinkedField( + name = "linked password", + vaultLinkedFieldType = VaultLinkedFieldType.PASSWORD, + ), ), + requiresReprompt = true, ), - passwordRevisionDate = "4/14/83 3:56 PM", - totp = "otpauth://totp/Example:alice@google.com?secret=JBSWY3DPEHPK3PXP&issuer=Example", ) -private val EMPTY_LOGIN_VIEW_STATE: VaultItemState.ViewState.Content.Login = - VaultItemState.ViewState.Content.Login( +private val EMPTY_COMMON: VaultItemState.ViewState.Content.Common = + VaultItemState.ViewState.Content.Common( name = "login cipher", lastUpdated = "12/31/69 06:16 PM", - passwordHistoryCount = null, notes = null, isPremiumUser = true, customFields = emptyList(), requiresReprompt = true, + ) + +private val EMPTY_LOGIN_TYPE: VaultItemState.ViewState.Content.ItemType.Login = + VaultItemState.ViewState.Content.ItemType.Login( username = null, passwordData = null, + passwordHistoryCount = null, uris = emptyList(), passwordRevisionDate = null, totp = null, ) + +private val EMPTY_LOGIN_VIEW_STATE: VaultItemState.ViewState.Content = + VaultItemState.ViewState.Content( + common = EMPTY_COMMON, + type = EMPTY_LOGIN_TYPE, + ) diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/item/VaultItemViewModelTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/item/VaultItemViewModelTest.kt index 327207cb5..0c4998c11 100644 --- a/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/item/VaultItemViewModelTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/item/VaultItemViewModelTest.kt @@ -71,114 +71,264 @@ class VaultItemViewModelTest : BaseViewModelTest() { assertEquals(state, viewModel.stateFlow.value) } - @Test - fun `on CloseClick should emit NavigateBack`() = runTest { - val viewModel = createViewModel(state = DEFAULT_STATE) - viewModel.eventFlow.test { - viewModel.trySendAction(VaultItemAction.CloseClick) - assertEquals(VaultItemEvent.NavigateBack, awaitItem()) - } - } + @Nested + inner class CommonActions { + private lateinit var viewModel: VaultItemViewModel - @Test - fun `on DismissDialogClick should clear the dialog state`() = runTest { - val initialState = DEFAULT_STATE.copy(dialog = VaultItemState.DialogState.Loading) - val viewModel = createViewModel(state = initialState) - assertEquals(initialState, viewModel.stateFlow.value) - - viewModel.trySendAction(VaultItemAction.DismissDialogClick) - assertEquals(initialState.copy(dialog = null), viewModel.stateFlow.value) - } - - @Test - fun `on EditClick should do nothing when ViewState is not Content`() = runTest { - val initialState = DEFAULT_STATE - val viewModel = createViewModel(state = initialState) - - assertEquals(initialState, viewModel.stateFlow.value) - viewModel.eventFlow.test { - viewModel.trySendAction(VaultItemAction.EditClick) - expectNoEvents() - } - assertEquals(initialState, viewModel.stateFlow.value) - } - - @Test - fun `on EditClick should prompt for master password when required`() = runTest { - val mockCipherView = mockk<CipherView> { - every { toViewState(isPremiumUser = true) } returns DEFAULT_LOGIN_VIEW_STATE - } - mutableVaultItemFlow.value = DataState.Loaded(data = mockCipherView) - val loginState = DEFAULT_STATE.copy(viewState = DEFAULT_LOGIN_VIEW_STATE) - val viewModel = createViewModel(state = loginState) - assertEquals(loginState, viewModel.stateFlow.value) - - viewModel.trySendAction(VaultItemAction.EditClick) - assertEquals( - loginState.copy(dialog = VaultItemState.DialogState.MasterPasswordDialog), - viewModel.stateFlow.value, - ) - - verify(exactly = 1) { - mockCipherView.toViewState(isPremiumUser = true) - } - } - - @Test - fun `on EditClick should navigate password is not required`() = runTest { - val loginViewState = DEFAULT_LOGIN_VIEW_STATE.copy(requiresReprompt = false) - val mockCipherView = mockk<CipherView> { - every { toViewState(isPremiumUser = true) } returns loginViewState - } - mutableVaultItemFlow.value = DataState.Loaded(data = mockCipherView) - val loginState = DEFAULT_STATE.copy(viewState = loginViewState) - val viewModel = createViewModel(state = loginState) - - viewModel.eventFlow.test { - viewModel.trySendAction(VaultItemAction.EditClick) - assertEquals(VaultItemEvent.NavigateToEdit(VAULT_ITEM_ID), awaitItem()) - } - - verify(exactly = 1) { - mockCipherView.toViewState(isPremiumUser = true) - } - } - - @Test - fun `on MasterPasswordSubmit should verify the password`() = runTest { - val loginViewState = DEFAULT_LOGIN_VIEW_STATE.copy(requiresReprompt = false) - val mockCipherView = mockk<CipherView> { - every { toViewState(isPremiumUser = true) } returns loginViewState - } - mutableVaultItemFlow.value = DataState.Loaded(data = mockCipherView) - val loginState = DEFAULT_STATE.copy(viewState = loginViewState) - val viewModel = createViewModel(state = loginState) - - viewModel.stateFlow.test { - assertEquals(loginState, awaitItem()) - viewModel.trySendAction(VaultItemAction.MasterPasswordSubmit("password")) - assertEquals(loginState.copy(dialog = VaultItemState.DialogState.Loading), awaitItem()) - assertEquals( - loginState.copy(viewState = loginViewState.copy(requiresReprompt = false)), - awaitItem(), + @BeforeEach + fun setup() { + viewModel = createViewModel( + state = DEFAULT_STATE.copy(viewState = DEFAULT_VIEW_STATE), ) } - verify(exactly = 1) { - mockCipherView.toViewState(isPremiumUser = true) + @Test + fun `on CloseClick should emit NavigateBack`() = runTest { + val viewModel = createViewModel(state = DEFAULT_STATE) + viewModel.eventFlow.test { + viewModel.trySendAction(VaultItemAction.Common.CloseClick) + assertEquals(VaultItemEvent.NavigateBack, awaitItem()) + } } - } - @Test - fun `on RefreshClick should sync`() = runTest { - every { vaultRepo.sync() } just runs - val viewModel = createViewModel(state = DEFAULT_STATE) + @Test + fun `on DismissDialogClick should clear the dialog state`() = runTest { + val initialState = DEFAULT_STATE.copy(dialog = VaultItemState.DialogState.Loading) + val viewModel = createViewModel(state = initialState) + assertEquals(initialState, viewModel.stateFlow.value) - viewModel.trySendAction(VaultItemAction.RefreshClick) - - verify(exactly = 1) { - vaultRepo.sync() + viewModel.trySendAction(VaultItemAction.Common.DismissDialogClick) + assertEquals(initialState.copy(dialog = null), viewModel.stateFlow.value) } + + @Test + fun `on EditClick should do nothing when ViewState is not Content`() = runTest { + val initialState = DEFAULT_STATE + val viewModel = createViewModel(state = initialState) + + assertEquals(initialState, viewModel.stateFlow.value) + viewModel.eventFlow.test { + viewModel.trySendAction(VaultItemAction.Common.EditClick) + expectNoEvents() + } + assertEquals(initialState, viewModel.stateFlow.value) + } + + @Test + fun `on EditClick should prompt for master password when required`() = runTest { + val mockCipherView = mockk<CipherView> { + every { toViewState(isPremiumUser = true) } returns DEFAULT_VIEW_STATE + } + mutableVaultItemFlow.value = DataState.Loaded(data = mockCipherView) + val loginState = DEFAULT_STATE.copy(viewState = DEFAULT_VIEW_STATE) + val viewModel = createViewModel(state = loginState) + assertEquals(loginState, viewModel.stateFlow.value) + + viewModel.trySendAction(VaultItemAction.Common.EditClick) + assertEquals( + loginState.copy(dialog = VaultItemState.DialogState.MasterPasswordDialog), + viewModel.stateFlow.value, + ) + } + + @Test + fun `on EditClick should navigate password is not required`() = runTest { + val loginViewState = createViewState( + common = DEFAULT_COMMON.copy(requiresReprompt = false), + ) + val mockCipherView = mockk<CipherView> { + every { toViewState(isPremiumUser = true) } returns loginViewState + } + mutableVaultItemFlow.value = DataState.Loaded(data = mockCipherView) + val loginState = DEFAULT_STATE.copy(viewState = loginViewState) + val viewModel = createViewModel(state = loginState) + + viewModel.eventFlow.test { + viewModel.trySendAction(VaultItemAction.Common.EditClick) + assertEquals(VaultItemEvent.NavigateToEdit(VAULT_ITEM_ID), awaitItem()) + } + } + + @Test + fun `on MasterPasswordSubmit should verify the password`() = runTest { + val loginViewState = createViewState( + common = DEFAULT_COMMON.copy(requiresReprompt = false), + ) + val mockCipherView = mockk<CipherView> { + every { toViewState(isPremiumUser = true) } returns loginViewState + } + mutableVaultItemFlow.value = DataState.Loaded(data = mockCipherView) + val loginState = DEFAULT_STATE.copy(viewState = loginViewState) + val viewModel = createViewModel(state = loginState) + + viewModel.stateFlow.test { + assertEquals(loginState, awaitItem()) + viewModel.trySendAction(VaultItemAction.Common.MasterPasswordSubmit("password")) + assertEquals( + loginState.copy(dialog = VaultItemState.DialogState.Loading), + awaitItem(), + ) + assertEquals( + loginState.copy( + viewState = loginViewState.copy( + common = DEFAULT_COMMON.copy(requiresReprompt = false), + ), + ), + awaitItem(), + ) + } + } + + @Test + fun `on RefreshClick should sync`() = runTest { + every { vaultRepo.sync() } just runs + val viewModel = createViewModel(state = DEFAULT_STATE) + + viewModel.trySendAction(VaultItemAction.Common.RefreshClick) + + verify(exactly = 1) { + vaultRepo.sync() + } + } + + @Suppress("MaxLineLength") + @Test + fun `on CopyCustomHiddenFieldClick should show password dialog when re-prompt is required`() = + runTest { + val loginState = DEFAULT_STATE.copy(viewState = DEFAULT_VIEW_STATE) + val mockCipherView = mockk<CipherView> { + every { toViewState(isPremiumUser = true) } returns DEFAULT_VIEW_STATE + } + mutableVaultItemFlow.value = DataState.Loaded(data = mockCipherView) + + assertEquals(loginState, viewModel.stateFlow.value) + viewModel.trySendAction(VaultItemAction.Common.CopyCustomHiddenFieldClick("field")) + assertEquals( + loginState.copy(dialog = VaultItemState.DialogState.MasterPasswordDialog), + viewModel.stateFlow.value, + ) + + verify(exactly = 1) { + mockCipherView.toViewState(isPremiumUser = true) + } + } + + @Suppress("MaxLineLength") + @Test + fun `on CopyCustomHiddenFieldClick should emit CopyToClipboard when re-prompt is not required`() = + runTest { + val field = "field" + val mockCipherView = mockk<CipherView> { + every { + toViewState(isPremiumUser = true) + } + .returns( + createViewState( + common = DEFAULT_COMMON.copy(requiresReprompt = false), + ), + ) + } + mutableVaultItemFlow.value = DataState.Loaded(data = mockCipherView) + + viewModel.eventFlow.test { + viewModel.trySendAction(VaultItemAction.Common.CopyCustomHiddenFieldClick(field)) + assertEquals( + VaultItemEvent.CopyToClipboard(field.asText()), + awaitItem(), + ) + } + + verify(exactly = 1) { + mockCipherView.toViewState(isPremiumUser = true) + } + } + + @Test + fun `on CopyCustomTextFieldClick should emit CopyToClipboard`() = runTest { + val field = "field" + viewModel.eventFlow.test { + viewModel.trySendAction(VaultItemAction.Common.CopyCustomTextFieldClick(field)) + assertEquals(VaultItemEvent.CopyToClipboard(field.asText()), awaitItem()) + } + } + + @Suppress("MaxLineLength") + @Test + fun `on HiddenFieldVisibilityClicked should show password dialog when re-prompt is required`() = + runTest { + val loginState = DEFAULT_STATE.copy(viewState = DEFAULT_VIEW_STATE) + val mockCipherView = mockk<CipherView> { + every { toViewState(isPremiumUser = true) } returns DEFAULT_VIEW_STATE + } + mutableVaultItemFlow.value = DataState.Loaded(data = mockCipherView) + + assertEquals(loginState, viewModel.stateFlow.value) + viewModel.trySendAction( + VaultItemAction.Common.HiddenFieldVisibilityClicked( + field = VaultItemState.ViewState.Content.Common.Custom.HiddenField( + name = "hidden", + value = "value", + isCopyable = true, + isVisible = false, + ), + isVisible = true, + ), + ) + assertEquals( + loginState.copy(dialog = VaultItemState.DialogState.MasterPasswordDialog), + viewModel.stateFlow.value, + ) + + verify(exactly = 1) { + mockCipherView.toViewState(isPremiumUser = true) + } + } + + @Suppress("MaxLineLength") + @Test + fun `on HiddenFieldVisibilityClicked should update hidden field visibility when re-prompt is not required`() = + runTest { + val hiddenField = VaultItemState.ViewState.Content.Common.Custom.HiddenField( + name = "hidden", + value = "value", + isCopyable = true, + isVisible = false, + ) + val loginViewState = DEFAULT_EMPTY_LOGIN_VIEW_STATE.copy( + common = DEFAULT_EMPTY_LOGIN_VIEW_STATE.common.copy( + requiresReprompt = false, + customFields = listOf(hiddenField), + ), + ) + val loginState = DEFAULT_STATE.copy(viewState = loginViewState) + val mockCipherView = mockk<CipherView> { + every { toViewState(isPremiumUser = true) } returns loginViewState + } + mutableVaultItemFlow.value = DataState.Loaded(data = mockCipherView) + + assertEquals(loginState, viewModel.stateFlow.value) + viewModel.trySendAction( + VaultItemAction.Common.HiddenFieldVisibilityClicked( + field = hiddenField, + isVisible = true, + ), + ) + assertEquals( + loginState.copy( + viewState = loginViewState.copy( + common = DEFAULT_EMPTY_LOGIN_VIEW_STATE.common.copy( + requiresReprompt = false, + customFields = listOf(hiddenField.copy(isVisible = true)), + ), + ), + ), + viewModel.stateFlow.value, + ) + + verify(exactly = 1) { + mockCipherView.toViewState(isPremiumUser = true) + } + } } @Nested @@ -188,17 +338,17 @@ class VaultItemViewModelTest : BaseViewModelTest() { @BeforeEach fun setup() { viewModel = createViewModel( - state = DEFAULT_STATE.copy(viewState = DEFAULT_LOGIN_VIEW_STATE), + state = DEFAULT_STATE.copy(viewState = DEFAULT_VIEW_STATE), ) } @Test fun `on CheckForBreachClick should process a password`() = runTest { val mockCipherView = mockk<CipherView> { - every { toViewState(isPremiumUser = true) } returns DEFAULT_LOGIN_VIEW_STATE + every { toViewState(isPremiumUser = true) } returns DEFAULT_VIEW_STATE } mutableVaultItemFlow.value = DataState.Loaded(data = mockCipherView) - val loginState = DEFAULT_STATE.copy(viewState = DEFAULT_LOGIN_VIEW_STATE) + val loginState = DEFAULT_STATE.copy(viewState = DEFAULT_VIEW_STATE) val breachCount = 5 coEvery { authRepo.getPasswordBreachCount(password = DEFAULT_LOGIN_PASSWORD) @@ -206,7 +356,7 @@ class VaultItemViewModelTest : BaseViewModelTest() { viewModel.stateFlow.test { assertEquals(loginState, awaitItem()) - viewModel.trySendAction(VaultItemAction.Login.CheckForBreachClick) + viewModel.trySendAction(VaultItemAction.ItemType.Login.CheckForBreachClick) assertEquals( loginState.copy(dialog = VaultItemState.DialogState.Loading), awaitItem(), @@ -232,14 +382,14 @@ class VaultItemViewModelTest : BaseViewModelTest() { @Test fun `on CopyPasswordClick should show password dialog when re-prompt is required`() = runTest { - val loginState = DEFAULT_STATE.copy(viewState = DEFAULT_LOGIN_VIEW_STATE) + val loginState = DEFAULT_STATE.copy(viewState = DEFAULT_VIEW_STATE) val mockCipherView = mockk<CipherView> { - every { toViewState(isPremiumUser = true) } returns DEFAULT_LOGIN_VIEW_STATE + every { toViewState(isPremiumUser = true) } returns DEFAULT_VIEW_STATE } mutableVaultItemFlow.value = DataState.Loaded(data = mockCipherView) assertEquals(loginState, viewModel.stateFlow.value) - viewModel.trySendAction(VaultItemAction.Login.CopyPasswordClick) + viewModel.trySendAction(VaultItemAction.ItemType.Login.CopyPasswordClick) assertEquals( loginState.copy(dialog = VaultItemState.DialogState.MasterPasswordDialog), viewModel.stateFlow.value, @@ -256,12 +406,17 @@ class VaultItemViewModelTest : BaseViewModelTest() { val mockCipherView = mockk<CipherView> { every { toViewState(isPremiumUser = true) - } returns DEFAULT_LOGIN_VIEW_STATE.copy(requiresReprompt = false) + } + .returns( + createViewState( + common = DEFAULT_COMMON.copy(requiresReprompt = false), + ), + ) } mutableVaultItemFlow.value = DataState.Loaded(data = mockCipherView) viewModel.eventFlow.test { - viewModel.trySendAction(VaultItemAction.Login.CopyPasswordClick) + viewModel.trySendAction(VaultItemAction.ItemType.Login.CopyPasswordClick) assertEquals( VaultItemEvent.CopyToClipboard(DEFAULT_LOGIN_PASSWORD.asText()), awaitItem(), @@ -273,67 +428,11 @@ class VaultItemViewModelTest : BaseViewModelTest() { } } - @Suppress("MaxLineLength") - @Test - fun `on CopyCustomHiddenFieldClick should show password dialog when re-prompt is required`() = - runTest { - val loginState = DEFAULT_STATE.copy(viewState = DEFAULT_LOGIN_VIEW_STATE) - val mockCipherView = mockk<CipherView> { - every { toViewState(isPremiumUser = true) } returns DEFAULT_LOGIN_VIEW_STATE - } - mutableVaultItemFlow.value = DataState.Loaded(data = mockCipherView) - - assertEquals(loginState, viewModel.stateFlow.value) - viewModel.trySendAction(VaultItemAction.Login.CopyCustomHiddenFieldClick("field")) - assertEquals( - loginState.copy(dialog = VaultItemState.DialogState.MasterPasswordDialog), - viewModel.stateFlow.value, - ) - - verify(exactly = 1) { - mockCipherView.toViewState(isPremiumUser = true) - } - } - - @Suppress("MaxLineLength") - @Test - fun `on CopyCustomHiddenFieldClick should emit CopyToClipboard when re-prompt is not required`() = - runTest { - val field = "field" - val mockCipherView = mockk<CipherView> { - every { - toViewState(isPremiumUser = true) - } returns DEFAULT_LOGIN_VIEW_STATE.copy(requiresReprompt = false) - } - mutableVaultItemFlow.value = DataState.Loaded(data = mockCipherView) - - viewModel.eventFlow.test { - viewModel.trySendAction(VaultItemAction.Login.CopyCustomHiddenFieldClick(field)) - assertEquals( - VaultItemEvent.CopyToClipboard(field.asText()), - awaitItem(), - ) - } - - verify(exactly = 1) { - mockCipherView.toViewState(isPremiumUser = true) - } - } - - @Test - fun `on CopyCustomTextFieldClick should emit CopyToClipboard`() = runTest { - val field = "field" - viewModel.eventFlow.test { - viewModel.trySendAction(VaultItemAction.Login.CopyCustomTextFieldClick(field)) - assertEquals(VaultItemEvent.CopyToClipboard(field.asText()), awaitItem()) - } - } - @Test fun `on CopyUriClick should emit CopyToClipboard`() = runTest { val uri = "uri" viewModel.eventFlow.test { - viewModel.trySendAction(VaultItemAction.Login.CopyUriClick(uri)) + viewModel.trySendAction(VaultItemAction.ItemType.Login.CopyUriClick(uri)) assertEquals(VaultItemEvent.CopyToClipboard(uri.asText()), awaitItem()) } } @@ -341,14 +440,14 @@ class VaultItemViewModelTest : BaseViewModelTest() { @Test fun `on CopyUsernameClick should show password dialog when re-prompt is required`() = runTest { - val loginState = DEFAULT_STATE.copy(viewState = DEFAULT_LOGIN_VIEW_STATE) + val loginState = DEFAULT_STATE.copy(viewState = DEFAULT_VIEW_STATE) val mockCipherView = mockk<CipherView> { - every { toViewState(isPremiumUser = true) } returns DEFAULT_LOGIN_VIEW_STATE + every { toViewState(isPremiumUser = true) } returns DEFAULT_VIEW_STATE } mutableVaultItemFlow.value = DataState.Loaded(data = mockCipherView) assertEquals(loginState, viewModel.stateFlow.value) - viewModel.trySendAction(VaultItemAction.Login.CopyUsernameClick) + viewModel.trySendAction(VaultItemAction.ItemType.Login.CopyUsernameClick) assertEquals( loginState.copy(dialog = VaultItemState.DialogState.MasterPasswordDialog), viewModel.stateFlow.value, @@ -365,12 +464,17 @@ class VaultItemViewModelTest : BaseViewModelTest() { val mockCipherView = mockk<CipherView> { every { toViewState(isPremiumUser = true) - } returns DEFAULT_LOGIN_VIEW_STATE.copy(requiresReprompt = false) + } + .returns( + createViewState( + common = DEFAULT_COMMON.copy(requiresReprompt = false), + ), + ) } mutableVaultItemFlow.value = DataState.Loaded(data = mockCipherView) viewModel.eventFlow.test { - viewModel.trySendAction(VaultItemAction.Login.CopyUsernameClick) + viewModel.trySendAction(VaultItemAction.ItemType.Login.CopyUsernameClick) assertEquals( VaultItemEvent.CopyToClipboard(DEFAULT_LOGIN_USERNAME.asText()), awaitItem(), @@ -386,7 +490,7 @@ class VaultItemViewModelTest : BaseViewModelTest() { fun `on LaunchClick should emit NavigateToUri`() = runTest { val uri = "uri" viewModel.eventFlow.test { - viewModel.trySendAction(VaultItemAction.Login.LaunchClick(uri)) + viewModel.trySendAction(VaultItemAction.ItemType.Login.LaunchClick(uri)) assertEquals(VaultItemEvent.NavigateToUri(uri), awaitItem()) } } @@ -394,14 +498,14 @@ class VaultItemViewModelTest : BaseViewModelTest() { @Test fun `on PasswordHistoryClick should show password dialog when re-prompt is required`() = runTest { - val loginState = DEFAULT_STATE.copy(viewState = DEFAULT_LOGIN_VIEW_STATE) + val loginState = DEFAULT_STATE.copy(viewState = DEFAULT_VIEW_STATE) val mockCipherView = mockk<CipherView> { - every { toViewState(isPremiumUser = true) } returns DEFAULT_LOGIN_VIEW_STATE + every { toViewState(isPremiumUser = true) } returns DEFAULT_VIEW_STATE } mutableVaultItemFlow.value = DataState.Loaded(data = mockCipherView) assertEquals(loginState, viewModel.stateFlow.value) - viewModel.trySendAction(VaultItemAction.Login.PasswordHistoryClick) + viewModel.trySendAction(VaultItemAction.ItemType.Login.PasswordHistoryClick) assertEquals( loginState.copy(dialog = VaultItemState.DialogState.MasterPasswordDialog), viewModel.stateFlow.value, @@ -419,12 +523,17 @@ class VaultItemViewModelTest : BaseViewModelTest() { val mockCipherView = mockk<CipherView> { every { toViewState(isPremiumUser = true) - } returns DEFAULT_LOGIN_VIEW_STATE.copy(requiresReprompt = false) + } + .returns( + createViewState( + common = DEFAULT_COMMON.copy(requiresReprompt = false), + ), + ) } mutableVaultItemFlow.value = DataState.Loaded(data = mockCipherView) viewModel.eventFlow.test { - viewModel.trySendAction(VaultItemAction.Login.PasswordHistoryClick) + viewModel.trySendAction(VaultItemAction.ItemType.Login.PasswordHistoryClick) assertEquals( VaultItemEvent.NavigateToPasswordHistory(VAULT_ITEM_ID), awaitItem(), @@ -440,14 +549,18 @@ class VaultItemViewModelTest : BaseViewModelTest() { @Test fun `on PasswordVisibilityClicked should show password dialog when re-prompt is required`() = runTest { - val loginState = DEFAULT_STATE.copy(viewState = DEFAULT_LOGIN_VIEW_STATE) + val loginState = DEFAULT_STATE.copy(viewState = DEFAULT_VIEW_STATE) val mockCipherView = mockk<CipherView> { - every { toViewState(isPremiumUser = true) } returns DEFAULT_LOGIN_VIEW_STATE + every { toViewState(isPremiumUser = true) } returns DEFAULT_VIEW_STATE } mutableVaultItemFlow.value = DataState.Loaded(data = mockCipherView) assertEquals(loginState, viewModel.stateFlow.value) - viewModel.trySendAction(VaultItemAction.Login.PasswordVisibilityClicked(true)) + viewModel.trySendAction( + VaultItemAction.ItemType.Login.PasswordVisibilityClicked( + true, + ), + ) assertEquals( loginState.copy(dialog = VaultItemState.DialogState.MasterPasswordDialog), viewModel.stateFlow.value, @@ -462,74 +575,8 @@ class VaultItemViewModelTest : BaseViewModelTest() { @Test fun `on PasswordVisibilityClicked should update password visibility when re-prompt is not required`() = runTest { - val loginViewState = DEFAULT_LOGIN_VIEW_STATE.copy(requiresReprompt = false) - val loginState = DEFAULT_STATE.copy(viewState = loginViewState) - val mockCipherView = mockk<CipherView> { - every { toViewState(isPremiumUser = true) } returns loginViewState - } - mutableVaultItemFlow.value = DataState.Loaded(data = mockCipherView) - - assertEquals(loginState, viewModel.stateFlow.value) - viewModel.trySendAction(VaultItemAction.Login.PasswordVisibilityClicked(true)) - assertEquals( - loginState.copy( - viewState = loginViewState.copy( - passwordData = loginViewState.passwordData!!.copy(isVisible = true), - ), - ), - viewModel.stateFlow.value, - ) - - verify(exactly = 1) { - mockCipherView.toViewState(isPremiumUser = true) - } - } - - @Suppress("MaxLineLength") - @Test - fun `on HiddenFieldVisibilityClicked should show password dialog when re-prompt is required`() = - runTest { - val loginState = DEFAULT_STATE.copy(viewState = DEFAULT_LOGIN_VIEW_STATE) - val mockCipherView = mockk<CipherView> { - every { toViewState(isPremiumUser = true) } returns DEFAULT_LOGIN_VIEW_STATE - } - mutableVaultItemFlow.value = DataState.Loaded(data = mockCipherView) - - assertEquals(loginState, viewModel.stateFlow.value) - viewModel.trySendAction( - VaultItemAction.Login.HiddenFieldVisibilityClicked( - field = VaultItemState.ViewState.Content.Custom.HiddenField( - name = "hidden", - value = "value", - isCopyable = true, - isVisible = false, - ), - isVisible = true, - ), - ) - assertEquals( - loginState.copy(dialog = VaultItemState.DialogState.MasterPasswordDialog), - viewModel.stateFlow.value, - ) - - verify(exactly = 1) { - mockCipherView.toViewState(isPremiumUser = true) - } - } - - @Suppress("MaxLineLength") - @Test - fun `on HiddenFieldVisibilityClicked should update hidden field visibility when re-prompt is not required`() = - runTest { - val hiddenField = VaultItemState.ViewState.Content.Custom.HiddenField( - name = "hidden", - value = "value", - isCopyable = true, - isVisible = false, - ) - val loginViewState = DEFAULT_EMPTY_LOGIN_VIEW_STATE.copy( - requiresReprompt = false, - customFields = listOf(hiddenField), + val loginViewState = createViewState( + common = DEFAULT_COMMON.copy(requiresReprompt = false), ) val loginState = DEFAULT_STATE.copy(viewState = loginViewState) val mockCipherView = mockk<CipherView> { @@ -539,15 +586,17 @@ class VaultItemViewModelTest : BaseViewModelTest() { assertEquals(loginState, viewModel.stateFlow.value) viewModel.trySendAction( - VaultItemAction.Login.HiddenFieldVisibilityClicked( - field = hiddenField, - isVisible = true, + VaultItemAction.ItemType.Login.PasswordVisibilityClicked( + true, ), ) assertEquals( loginState.copy( viewState = loginViewState.copy( - customFields = listOf(hiddenField.copy(isVisible = true)), + common = DEFAULT_COMMON.copy(requiresReprompt = false), + type = DEFAULT_LOGIN_TYPE.copy( + passwordData = DEFAULT_LOGIN_TYPE.passwordData!!.copy(isVisible = true), + ), ), ), viewModel.stateFlow.value, @@ -572,82 +621,104 @@ class VaultItemViewModelTest : BaseViewModelTest() { authRepository = authRepository, vaultRepository = vaultRepository, ) + + private fun createViewState( + common: VaultItemState.ViewState.Content.Common = DEFAULT_COMMON, + type: VaultItemState.ViewState.Content.ItemType = DEFAULT_LOGIN_TYPE, + ): VaultItemState.ViewState.Content = + VaultItemState.ViewState.Content( + common = common, + type = type, + ) + + companion object { + private const val CIPHER_VIEW_EXTENSIONS_PATH: String = + "com.x8bit.bitwarden.ui.vault.feature.item.util.CipherViewExtensionsKt" + + private const val VAULT_ITEM_ID = "vault_item_id" + private const val DEFAULT_LOGIN_PASSWORD = "password" + private const val DEFAULT_LOGIN_USERNAME = "username" + + private val DEFAULT_STATE: VaultItemState = VaultItemState( + vaultItemId = VAULT_ITEM_ID, + viewState = VaultItemState.ViewState.Loading, + dialog = null, + ) + + private val DEFAULT_USER_STATE: UserState = UserState( + activeUserId = "user_id_1", + accounts = listOf( + UserState.Account( + userId = "user_id_1", + name = "Bit", + email = "bitwarden@gmail.com", + avatarColorHex = "#ff00ff", + environment = Environment.Us, + isPremium = true, + isVaultUnlocked = true, + organizations = emptyList(), + ), + ), + ) + + private val DEFAULT_LOGIN_TYPE: VaultItemState.ViewState.Content.ItemType.Login = + VaultItemState.ViewState.Content.ItemType.Login( + passwordHistoryCount = 1, + username = DEFAULT_LOGIN_USERNAME, + passwordData = VaultItemState.ViewState.Content.ItemType.Login.PasswordData( + password = DEFAULT_LOGIN_PASSWORD, + isVisible = false, + ), + uris = listOf( + VaultItemState.ViewState.Content.ItemType.Login.UriData( + uri = "www.example.com", + isCopyable = true, + isLaunchable = true, + ), + ), + passwordRevisionDate = "12/31/69 06:16 PM", + totp = "otpauth://totp/Example:alice@google.com" + + "?secret=JBSWY3DPEHPK3PXP&issuer=Example", + ) + + private val DEFAULT_COMMON: VaultItemState.ViewState.Content.Common = + VaultItemState.ViewState.Content.Common( + name = "login cipher", + lastUpdated = "12/31/69 06:16 PM", + notes = "Lots of notes", + isPremiumUser = true, + customFields = listOf( + VaultItemState.ViewState.Content.Common.Custom.TextField( + name = "text", + value = "value", + isCopyable = true, + ), + VaultItemState.ViewState.Content.Common.Custom.HiddenField( + name = "hidden", + value = "value", + isCopyable = true, + isVisible = false, + ), + VaultItemState.ViewState.Content.Common.Custom.BooleanField( + name = "boolean", + value = true, + ), + VaultItemState.ViewState.Content.Common.Custom.LinkedField( + name = "linked username", + vaultLinkedFieldType = VaultLinkedFieldType.USERNAME, + ), + VaultItemState.ViewState.Content.Common.Custom.LinkedField( + name = "linked password", + vaultLinkedFieldType = VaultLinkedFieldType.PASSWORD, + ), + ), + requiresReprompt = true, + ) + + private val DEFAULT_VIEW_STATE: VaultItemState.ViewState.Content = + VaultItemState.ViewState.Content( + common = DEFAULT_COMMON, + type = DEFAULT_LOGIN_TYPE, + ) + } } - -private const val CIPHER_VIEW_EXTENSIONS_PATH: String = - "com.x8bit.bitwarden.ui.vault.feature.item.util.CipherViewExtensionsKt" - -private const val VAULT_ITEM_ID = "vault_item_id" -private const val DEFAULT_LOGIN_PASSWORD = "password" -private const val DEFAULT_LOGIN_USERNAME = "username" - -private val DEFAULT_STATE: VaultItemState = VaultItemState( - vaultItemId = VAULT_ITEM_ID, - viewState = VaultItemState.ViewState.Loading, - dialog = null, -) - -private val DEFAULT_USER_STATE: UserState = UserState( - activeUserId = "user_id_1", - accounts = listOf( - UserState.Account( - userId = "user_id_1", - name = "Bit", - email = "bitwarden@gmail.com", - avatarColorHex = "#ff00ff", - environment = Environment.Us, - isPremium = true, - isVaultUnlocked = true, - organizations = emptyList(), - ), - ), -) - -private val DEFAULT_LOGIN_VIEW_STATE: VaultItemState.ViewState.Content.Login = - VaultItemState.ViewState.Content.Login( - name = "login cipher", - lastUpdated = "12/31/69 06:16 PM", - passwordHistoryCount = 1, - notes = "Lots of notes", - isPremiumUser = true, - customFields = listOf( - VaultItemState.ViewState.Content.Custom.TextField( - name = "text", - value = "value", - isCopyable = true, - ), - VaultItemState.ViewState.Content.Custom.HiddenField( - name = "hidden", - value = "value", - isCopyable = true, - isVisible = false, - ), - VaultItemState.ViewState.Content.Custom.BooleanField( - name = "boolean", - value = true, - ), - VaultItemState.ViewState.Content.Custom.LinkedField( - name = "linked username", - vaultLinkedFieldType = VaultLinkedFieldType.USERNAME, - ), - VaultItemState.ViewState.Content.Custom.LinkedField( - name = "linked password", - vaultLinkedFieldType = VaultLinkedFieldType.PASSWORD, - ), - ), - requiresReprompt = true, - username = DEFAULT_LOGIN_USERNAME, - passwordData = VaultItemState.ViewState.Content.PasswordData( - password = DEFAULT_LOGIN_PASSWORD, - isVisible = false, - ), - uris = listOf( - VaultItemState.ViewState.Content.UriData( - uri = "www.example.com", - isCopyable = true, - isLaunchable = true, - ), - ), - passwordRevisionDate = "12/31/69 06:16 PM", - totp = "otpauth://totp/Example:alice@google.com?secret=JBSWY3DPEHPK3PXP&issuer=Example", - ) diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/item/util/CipherViewExtensionsTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/item/util/CipherViewExtensionsTest.kt index 4edf8a713..23605eaa7 100644 --- a/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/item/util/CipherViewExtensionsTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/item/util/CipherViewExtensionsTest.kt @@ -44,7 +44,12 @@ class CipherViewExtensionsTest { val isPremiumUser = false val viewState = DEFAULT_FULL_LOGIN_CIPHER_VIEW.toViewState(isPremiumUser = isPremiumUser) - assertEquals(DEFAULT_FULL_LOGIN_VIEW_STATE.copy(isPremiumUser = isPremiumUser), viewState) + assertEquals( + DEFAULT_FULL_LOGIN_VIEW_STATE.copy( + common = DEFAULT_FULL_LOGIN_VIEW_STATE.common.copy(isPremiumUser = isPremiumUser), + ), + viewState, + ) } @Test @@ -168,67 +173,77 @@ val DEFAULT_EMPTY_LOGIN_CIPHER_VIEW: CipherView = CipherView( revisionDate = Instant.ofEpochSecond(1_000L), ) -val DEFAULT_FULL_LOGIN_VIEW_STATE: VaultItemState.ViewState.Content.Login = - VaultItemState.ViewState.Content.Login( - name = "login cipher", - lastUpdated = "1/1/70 12:16 AM", - passwordHistoryCount = 1, - notes = "Lots of notes", - isPremiumUser = true, - customFields = listOf( - VaultItemState.ViewState.Content.Custom.TextField( - name = "text", - value = "value", - isCopyable = true, +val DEFAULT_FULL_LOGIN_VIEW_STATE: VaultItemState.ViewState.Content = + VaultItemState.ViewState.Content( + common = VaultItemState.ViewState.Content.Common( + name = "login cipher", + lastUpdated = "1/1/70 12:16 AM", + notes = "Lots of notes", + isPremiumUser = true, + customFields = listOf( + VaultItemState.ViewState.Content.Common.Custom.TextField( + name = "text", + value = "value", + isCopyable = true, + ), + VaultItemState.ViewState.Content.Common.Custom.HiddenField( + name = "hidden", + value = "value", + isCopyable = true, + isVisible = false, + ), + VaultItemState.ViewState.Content.Common.Custom.BooleanField( + name = "boolean", + value = true, + ), + VaultItemState.ViewState.Content.Common.Custom.LinkedField( + name = "linked username", + vaultLinkedFieldType = VaultLinkedFieldType.USERNAME, + ), + VaultItemState.ViewState.Content.Common.Custom.LinkedField( + name = "linked password", + vaultLinkedFieldType = VaultLinkedFieldType.PASSWORD, + ), ), - VaultItemState.ViewState.Content.Custom.HiddenField( - name = "hidden", - value = "value", - isCopyable = true, + requiresReprompt = true, + ), + type = VaultItemState.ViewState.Content.ItemType.Login( + passwordHistoryCount = 1, + username = "username", + passwordData = VaultItemState.ViewState.Content.ItemType.Login.PasswordData( + password = "password", isVisible = false, ), - VaultItemState.ViewState.Content.Custom.BooleanField( - name = "boolean", - value = true, - ), - VaultItemState.ViewState.Content.Custom.LinkedField( - name = "linked username", - vaultLinkedFieldType = VaultLinkedFieldType.USERNAME, - ), - VaultItemState.ViewState.Content.Custom.LinkedField( - name = "linked password", - vaultLinkedFieldType = VaultLinkedFieldType.PASSWORD, + uris = listOf( + VaultItemState.ViewState.Content.ItemType.Login.UriData( + uri = "www.example.com", + isCopyable = true, + isLaunchable = true, + ), ), + passwordRevisionDate = "1/1/70 12:16 AM", + totp = "otpauth://totp/Example:alice@google.com?secret=JBSWY3DPEHPK3PXP&issuer=Example", ), - requiresReprompt = true, - username = "username", - passwordData = VaultItemState.ViewState.Content.PasswordData( - password = "password", - isVisible = false, - ), - uris = listOf( - VaultItemState.ViewState.Content.UriData( - uri = "www.example.com", - isCopyable = true, - isLaunchable = true, - ), - ), - passwordRevisionDate = "1/1/70 12:16 AM", - totp = "otpauth://totp/Example:alice@google.com?secret=JBSWY3DPEHPK3PXP&issuer=Example", ) -val DEFAULT_EMPTY_LOGIN_VIEW_STATE: VaultItemState.ViewState.Content.Login = - VaultItemState.ViewState.Content.Login( - name = "login cipher", - lastUpdated = "1/1/70 12:16 AM", - passwordHistoryCount = null, - notes = null, - isPremiumUser = true, - customFields = emptyList(), - requiresReprompt = true, - username = null, - passwordData = null, - uris = emptyList(), - passwordRevisionDate = null, - totp = null, +val DEFAULT_EMPTY_LOGIN_VIEW_STATE: VaultItemState.ViewState.Content = + VaultItemState.ViewState.Content( + common = VaultItemState.ViewState.Content.Common( + name = "login cipher", + lastUpdated = "1/1/70 12:16 AM", + + notes = null, + isPremiumUser = true, + customFields = emptyList(), + requiresReprompt = true, + + ), + type = VaultItemState.ViewState.Content.ItemType.Login( + passwordHistoryCount = null, + username = null, + passwordData = null, + uris = emptyList(), + passwordRevisionDate = null, + totp = null, + ), )