Separate common and type-specific state in VaultItemViewModel (#447)

This commit is contained in:
Ramsey Smith
2024-06-20 17:08:07 +01:00
committed by Álison Fernandes
parent e31febb1c4
commit 0dd162598f
9 changed files with 1373 additions and 1012 deletions
@@ -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))
},
)
}
}
@@ -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(
@@ -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()
}
}
/**
@@ -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,
),
)
},
)
}
}
@@ -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),
)
},
)
}
}
@@ -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(),