BIT-500 Add View Item Screen (#299)

This commit is contained in:
David Perez
2023-12-04 10:12:42 -06:00
committed by Álison Fernandes
parent 0abc8886a6
commit bd2cd54d47
13 changed files with 1892 additions and 14 deletions

View File

@@ -0,0 +1,19 @@
package com.x8bit.bitwarden.data.vault.repository.model
/**
* Models result of verifying the master password.
*/
sealed class VerifyPasswordResult {
/**
* Master password is successfully verified.
*/
data class Success(
val isVerified: Boolean,
) : VerifyPasswordResult()
/**
* An error occurred while trying to verify the master password.
*/
data object Error : VerifyPasswordResult()
}

View File

@@ -0,0 +1,52 @@
package com.x8bit.bitwarden.ui.vault.feature.item
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import com.x8bit.bitwarden.R
import com.x8bit.bitwarden.ui.platform.components.BitwardenTextButton
/**
* The top level error UI state for the [VaultItemScreen].
*/
@Composable
fun VaultItemError(
errorState: VaultItemState.ViewState.Error,
onRefreshClick: () -> Unit,
modifier: Modifier = Modifier,
) {
Column(
modifier = modifier,
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally,
) {
Text(
text = errorState.message(),
style = MaterialTheme.typography.bodyMedium,
textAlign = TextAlign.Center,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp),
)
Spacer(modifier = Modifier.height(16.dp))
BitwardenTextButton(
label = stringResource(id = R.string.try_again),
onClick = onRefreshClick,
modifier = Modifier.padding(horizontal = 16.dp),
)
Spacer(modifier = Modifier.height(88.dp))
Spacer(modifier = Modifier.navigationBarsPadding())
}
}

View File

@@ -0,0 +1,30 @@
package com.x8bit.bitwarden.ui.vault.feature.item
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
/**
* The top level loading UI state for the [VaultItemScreen].
*/
@Composable
fun VaultItemLoading(
modifier: Modifier = Modifier,
) {
Column(
modifier = modifier,
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally,
) {
CircularProgressIndicator()
Spacer(modifier = Modifier.height(88.dp))
Spacer(modifier = Modifier.navigationBarsPadding())
}
}

View File

@@ -0,0 +1,544 @@
package com.x8bit.bitwarden.ui.vault.feature.item
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.unit.dp
import com.x8bit.bitwarden.R
import com.x8bit.bitwarden.ui.platform.components.BitwardenIconButtonWithResource
import com.x8bit.bitwarden.ui.platform.components.BitwardenListHeaderText
import com.x8bit.bitwarden.ui.platform.components.BitwardenPasswordFieldWithActions
import com.x8bit.bitwarden.ui.platform.components.BitwardenTextField
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
/**
* The top level content UI state for the [VaultItemScreen] when viewing a Login cipher.
*/
@Suppress("LongMethod")
@Composable
fun VaultItemLoginContent(
viewState: VaultItemState.ViewState.Content.Login,
modifier: Modifier = Modifier,
loginHandlers: LoginHandlers,
) {
LazyColumn(
modifier = modifier,
) {
item {
BitwardenListHeaderText(
label = stringResource(id = R.string.item_information),
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp),
)
}
item {
Spacer(modifier = Modifier.height(8.dp))
BitwardenTextField(
label = stringResource(id = R.string.name),
value = viewState.name,
onValueChange = { },
readOnly = true,
singleLine = false,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp),
)
}
viewState.username?.let { username ->
item {
Spacer(modifier = Modifier.height(8.dp))
UsernameField(
username = username,
onCopyUsernameClick = loginHandlers.onCopyUsernameClick,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp),
)
}
}
viewState.passwordData?.let { passwordData ->
item {
Spacer(modifier = Modifier.height(8.dp))
PasswordField(
passwordData = passwordData,
onShowPasswordClick = loginHandlers.onShowPasswordClick,
onCheckForBreachClick = loginHandlers.onCheckForBreachClick,
onCopyPasswordClick = loginHandlers.onCopyPasswordClick,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp),
)
}
}
item {
Spacer(modifier = Modifier.height(8.dp))
TotpField(
isPremiumUser = viewState.isPremiumUser,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp),
)
}
viewState.uris.takeUnless { it.isEmpty() }?.let { uris ->
item {
Spacer(modifier = Modifier.height(4.dp))
BitwardenListHeaderText(
label = stringResource(id = R.string.ur_is),
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp),
)
}
items(uris) { uriData ->
Spacer(modifier = Modifier.height(8.dp))
UriField(
uriData = uriData,
onCopyUriClick = loginHandlers.onCopyUriClick,
onLaunchUriClick = loginHandlers.onLaunchUriClick,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp),
)
}
}
viewState.notes?.let { notes ->
item {
Spacer(modifier = Modifier.height(4.dp))
BitwardenListHeaderText(
label = stringResource(id = R.string.notes),
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp),
)
Spacer(modifier = Modifier.height(8.dp))
NotesField(
notes = notes,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp),
)
}
}
viewState.customFields.takeUnless { it.isEmpty() }?.let { customFields ->
item {
Spacer(modifier = Modifier.height(4.dp))
BitwardenListHeaderText(
label = stringResource(id = R.string.custom_fields),
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp),
)
}
items(customFields) { customField ->
Spacer(modifier = Modifier.height(8.dp))
CustomField(
customField = customField,
onCopyCustomHiddenField = loginHandlers.onCopyCustomHiddenField,
onCopyCustomTextField = loginHandlers.onCopyCustomTextField,
onShowHiddenFieldClick = loginHandlers.onShowHiddenFieldClick,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp),
)
}
}
item {
Spacer(modifier = Modifier.height(24.dp))
UpdateText(
header = "${stringResource(id = R.string.date_updated)}: ",
text = viewState.lastUpdated,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp),
)
}
viewState.passwordRevisionDate?.let { revisionDate ->
item {
UpdateText(
header = "${stringResource(id = R.string.date_password_updated)}: ",
text = revisionDate,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp),
)
}
}
viewState.passwordHistoryCount?.let { passwordHistoryCount ->
item {
PasswordHistoryCount(
passwordHistoryCount = passwordHistoryCount,
onPasswordHistoryClick = loginHandlers.onPasswordHistoryClick,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp),
)
}
}
item {
Spacer(modifier = Modifier.height(88.dp))
Spacer(modifier = Modifier.navigationBarsPadding())
}
}
}
@Suppress("LongMethod")
@Composable
private fun CustomField(
customField: VaultItemState.ViewState.Content.Custom,
onCopyCustomHiddenField: (String) -> Unit,
onCopyCustomTextField: (String) -> Unit,
onShowHiddenFieldClick: (VaultItemState.ViewState.Content.Custom.HiddenField, Boolean) -> Unit,
modifier: Modifier = Modifier,
) {
when (customField) {
is VaultItemState.ViewState.Content.Custom.BooleanField -> {
BitwardenWideSwitch(
label = customField.name,
isChecked = customField.value,
readOnly = true,
onCheckedChange = { },
modifier = modifier,
)
}
is VaultItemState.ViewState.Content.Custom.HiddenField -> {
BitwardenPasswordFieldWithActions(
label = customField.name,
value = customField.value,
showPasswordChange = { onShowHiddenFieldClick(customField, it) },
showPassword = customField.isVisible,
onValueChange = { },
readOnly = true,
singleLine = false,
modifier = modifier,
actions = {
if (customField.isCopyable) {
BitwardenIconButtonWithResource(
iconRes = IconResource(
iconPainter = painterResource(id = R.drawable.ic_copy),
contentDescription = stringResource(id = R.string.copy),
),
onClick = {
onCopyCustomHiddenField(customField.value)
},
)
}
},
)
}
is VaultItemState.ViewState.Content.Custom.LinkedField -> {
BitwardenTextField(
label = customField.name,
value = customField.type.label(),
leadingIconResource = IconResource(
iconPainter = painterResource(id = R.drawable.ic_linked),
contentDescription = stringResource(id = R.string.field_type_linked),
),
onValueChange = { },
readOnly = true,
singleLine = false,
modifier = modifier,
)
}
is VaultItemState.ViewState.Content.Custom.TextField -> {
BitwardenTextFieldWithActions(
label = customField.name,
value = customField.value,
onValueChange = { },
readOnly = true,
singleLine = false,
modifier = modifier,
actions = {
if (customField.isCopyable) {
BitwardenIconButtonWithResource(
iconRes = IconResource(
iconPainter = painterResource(id = R.drawable.ic_copy),
contentDescription = stringResource(id = R.string.copy),
),
onClick = { onCopyCustomTextField(customField.value) },
)
}
},
)
}
}
}
@Composable
private fun NotesField(
notes: String,
modifier: Modifier = Modifier,
) {
BitwardenTextField(
label = stringResource(id = R.string.notes),
value = notes,
onValueChange = { },
readOnly = true,
singleLine = false,
modifier = modifier,
)
}
@Composable
private fun PasswordField(
passwordData: VaultItemState.ViewState.Content.PasswordData,
onShowPasswordClick: (Boolean) -> Unit,
onCheckForBreachClick: () -> Unit,
onCopyPasswordClick: () -> Unit,
modifier: Modifier = Modifier,
) {
BitwardenPasswordFieldWithActions(
label = stringResource(id = R.string.password),
value = passwordData.password,
showPasswordChange = { onShowPasswordClick(it) },
showPassword = passwordData.isVisible,
onValueChange = { },
readOnly = true,
singleLine = false,
actions = {
BitwardenIconButtonWithResource(
iconRes = IconResource(
iconPainter = painterResource(id = R.drawable.ic_check_mark),
contentDescription = stringResource(
id = R.string.check_known_data_breaches_for_this_password,
),
),
onClick = onCheckForBreachClick,
)
BitwardenIconButtonWithResource(
iconRes = IconResource(
iconPainter = painterResource(id = R.drawable.ic_copy),
contentDescription = stringResource(id = R.string.copy_password),
),
onClick = onCopyPasswordClick,
)
},
modifier = modifier,
)
}
@Composable
private fun PasswordHistoryCount(
passwordHistoryCount: Int,
onPasswordHistoryClick: () -> Unit,
modifier: Modifier = Modifier,
) {
Row(
modifier = modifier.semantics(mergeDescendants = true) { },
) {
Text(
text = "${stringResource(id = R.string.password_history)}: ",
style = LocalNonMaterialTypography.current.labelMediumProminent,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Text(
text = passwordHistoryCount.toString(),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.clickable(onClick = onPasswordHistoryClick),
)
}
}
@Composable
private fun TotpField(
isPremiumUser: Boolean,
modifier: Modifier = Modifier,
) {
if (isPremiumUser) {
// TODO: Insert TOTP values here (BIT-1214)
} else {
BitwardenTextField(
label = stringResource(id = R.string.verification_code_totp),
value = stringResource(id = R.string.premium_subscription_required),
enabled = false,
singleLine = false,
onValueChange = { },
readOnly = true,
modifier = modifier,
)
}
}
@Composable
private fun UpdateText(
header: String,
text: String,
modifier: Modifier = Modifier,
) {
Row(
modifier = modifier
.semantics(mergeDescendants = true) { },
) {
Text(
text = header,
style = LocalNonMaterialTypography.current.labelMediumProminent,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Text(
text = text,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
@Composable
private fun UriField(
uriData: VaultItemState.ViewState.Content.UriData,
onCopyUriClick: (String) -> Unit,
onLaunchUriClick: (String) -> Unit,
modifier: Modifier = Modifier,
) {
BitwardenTextFieldWithActions(
label = stringResource(id = R.string.uri),
value = uriData.uri,
onValueChange = { },
readOnly = true,
singleLine = false,
actions = {
if (uriData.isLaunchable) {
BitwardenIconButtonWithResource(
iconRes = IconResource(
iconPainter = painterResource(id = R.drawable.ic_launch),
contentDescription = stringResource(id = R.string.launch),
),
onClick = { onLaunchUriClick(uriData.uri) },
)
}
if (uriData.isCopyable) {
BitwardenIconButtonWithResource(
iconRes = IconResource(
iconPainter = painterResource(id = R.drawable.ic_copy),
contentDescription = stringResource(id = R.string.copy),
),
onClick = { onCopyUriClick(uriData.uri) },
)
}
},
modifier = modifier,
)
}
@Composable
private fun UsernameField(
username: String,
onCopyUsernameClick: () -> Unit,
modifier: Modifier = Modifier,
) {
BitwardenTextFieldWithActions(
label = stringResource(id = R.string.username),
value = username,
onValueChange = { },
readOnly = true,
singleLine = false,
actions = {
BitwardenIconButtonWithResource(
iconRes = IconResource(
iconPainter = painterResource(id = R.drawable.ic_copy),
contentDescription = stringResource(id = R.string.copy_username),
),
onClick = onCopyUsernameClick,
)
},
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))
},
)
}
}

View File

@@ -1,14 +1,13 @@
package com.x8bit.bitwarden.ui.vault.feature.item
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import android.widget.Toast
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FloatingActionButton
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.material3.rememberTopAppBarState
import androidx.compose.runtime.Composable
@@ -16,31 +15,78 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.platform.ClipboardManager
import androidx.compose.ui.platform.LocalClipboardManager
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.core.net.toUri
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.x8bit.bitwarden.R
import com.x8bit.bitwarden.ui.platform.base.util.EventsEffect
import com.x8bit.bitwarden.ui.platform.base.util.IntentHandler
import com.x8bit.bitwarden.ui.platform.base.util.asText
import com.x8bit.bitwarden.ui.platform.base.util.toAnnotatedString
import com.x8bit.bitwarden.ui.platform.components.BasicDialogState
import com.x8bit.bitwarden.ui.platform.components.BitwardenBasicDialog
import com.x8bit.bitwarden.ui.platform.components.BitwardenLoadingDialog
import com.x8bit.bitwarden.ui.platform.components.BitwardenMasterPasswordDialog
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
/**
* Displays the vault item screen.
*/
@Suppress("LongMethod")
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun VaultItemScreen(
viewModel: VaultItemViewModel = hiltViewModel(),
clipboardManager: ClipboardManager = LocalClipboardManager.current,
intentHandler: IntentHandler = IntentHandler(context = LocalContext.current),
onNavigateBack: () -> Unit,
) {
val state by viewModel.stateFlow.collectAsStateWithLifecycle()
val context = LocalContext.current
val resources = context.resources
EventsEffect(viewModel = viewModel) { event ->
when (event) {
is VaultItemEvent.CopyToClipboard -> {
clipboardManager.setText(event.message.toString(resources).toAnnotatedString())
}
VaultItemEvent.NavigateBack -> onNavigateBack()
is VaultItemEvent.NavigateToEdit -> {
Toast.makeText(context, "Not yet implemented.", Toast.LENGTH_SHORT).show()
}
is VaultItemEvent.NavigateToPasswordHistory -> {
Toast.makeText(context, "Not yet implemented.", Toast.LENGTH_SHORT).show()
}
is VaultItemEvent.NavigateToUri -> intentHandler.launchUri(event.uri.toUri())
is VaultItemEvent.ShowToast -> {
Toast.makeText(context, event.message(resources), Toast.LENGTH_SHORT).show()
}
}
}
VaultItemDialogs(
dialog = state.dialog,
onDismissRequest = remember(viewModel) {
{ viewModel.trySendAction(VaultItemAction.DismissDialogClick) }
},
onSubmitMasterPassword = remember(viewModel) {
{ viewModel.trySendAction(VaultItemAction.MasterPasswordSubmit(it)) }
},
)
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior(rememberTopAppBarState())
BitwardenScaffold(
modifier = Modifier
@@ -55,18 +101,96 @@ fun VaultItemScreen(
onNavigationIconClick = remember(viewModel) {
{ viewModel.trySendAction(VaultItemAction.CloseClick) }
},
actions = {
BitwardenOverflowActionItem()
},
)
},
floatingActionButton = {
FloatingActionButton(
containerColor = MaterialTheme.colorScheme.primaryContainer,
onClick = remember(viewModel) {
{ viewModel.trySendAction(VaultItemAction.EditClick) }
},
modifier = Modifier.padding(bottom = 16.dp),
) {
Icon(
painter = painterResource(id = R.drawable.ic_edit),
contentDescription = stringResource(id = R.string.edit_item),
)
}
},
) { innerPadding ->
Column(
VaultItemContent(
viewState = state.viewState,
modifier = Modifier
.imePadding()
.fillMaxSize()
.padding(innerPadding)
.verticalScroll(rememberScrollState()),
) {
Spacer(modifier = Modifier.navigationBarsPadding())
}
.padding(innerPadding),
onRefreshClick = remember(viewModel) {
{ viewModel.trySendAction(VaultItemAction.RefreshClick) }
},
loginHandlers = remember(viewModel) {
LoginHandlers.create(viewModel)
},
)
}
}
@Composable
private fun VaultItemDialogs(
dialog: VaultItemState.DialogState?,
onDismissRequest: () -> Unit,
onSubmitMasterPassword: (String) -> Unit,
) {
when (dialog) {
is VaultItemState.DialogState.Generic -> BitwardenBasicDialog(
visibilityState = BasicDialogState.Shown(
title = null,
message = dialog.message,
),
onDismissRequest = onDismissRequest,
)
VaultItemState.DialogState.Loading -> BitwardenLoadingDialog(
visibilityState = LoadingDialogState.Shown(text = R.string.loading.asText()),
)
VaultItemState.DialogState.MasterPasswordDialog -> {
BitwardenMasterPasswordDialog(
onConfirmClick = onSubmitMasterPassword,
onDismissRequest = onDismissRequest,
)
}
null -> Unit
}
}
@Composable
private fun VaultItemContent(
viewState: VaultItemState.ViewState,
modifier: Modifier = Modifier,
onRefreshClick: () -> Unit,
loginHandlers: LoginHandlers,
) {
when (viewState) {
is VaultItemState.ViewState.Error -> VaultItemError(
errorState = viewState,
onRefreshClick = onRefreshClick,
modifier = modifier,
)
is VaultItemState.ViewState.Content -> when (viewState) {
is VaultItemState.ViewState.Content.Login -> VaultItemLoginContent(
viewState = viewState,
modifier = modifier,
loginHandlers = loginHandlers,
)
}
VaultItemState.ViewState.Loading -> VaultItemLoading(
modifier = modifier,
)
}
}

View File

@@ -3,10 +3,26 @@ package com.x8bit.bitwarden.ui.vault.feature.item
import android.os.Parcelable
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.viewModelScope
import com.bitwarden.core.CipherView
import com.x8bit.bitwarden.R
import com.x8bit.bitwarden.data.auth.repository.AuthRepository
import com.x8bit.bitwarden.data.auth.repository.model.BreachCountResult
import com.x8bit.bitwarden.data.auth.repository.model.UserState
import com.x8bit.bitwarden.data.platform.repository.model.DataState
import com.x8bit.bitwarden.data.vault.repository.VaultRepository
import com.x8bit.bitwarden.data.vault.repository.model.VerifyPasswordResult
import com.x8bit.bitwarden.ui.platform.base.BaseViewModel
import com.x8bit.bitwarden.ui.platform.base.util.Text
import com.x8bit.bitwarden.ui.platform.base.util.asText
import com.x8bit.bitwarden.ui.platform.base.util.concat
import com.x8bit.bitwarden.ui.vault.feature.item.util.toViewState
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.parcelize.Parcelize
import javax.inject.Inject
@@ -15,28 +31,376 @@ private const val KEY_STATE = "state"
/**
* ViewModel responsible for handling user interactions in the vault item screen
*/
@Suppress("TooManyFunctions")
@HiltViewModel
class VaultItemViewModel @Inject constructor(
savedStateHandle: SavedStateHandle,
private val authRepository: AuthRepository,
private val vaultRepository: VaultRepository,
) : BaseViewModel<VaultItemState, VaultItemEvent, VaultItemAction>(
initialState = savedStateHandle[KEY_STATE] ?: VaultItemState(
vaultItemId = VaultItemArgs(savedStateHandle).vaultItemId,
viewState = VaultItemState.ViewState.Loading,
dialog = null,
),
) {
init {
stateFlow.onEach { savedStateHandle[KEY_STATE] = it }.launchIn(viewModelScope)
combine(
vaultRepository.getVaultItemStateFlow(state.vaultItemId),
authRepository.userStateFlow,
) { cipherViewState, userState ->
VaultItemAction.Internal.VaultDataReceive(
userState = userState,
vaultDataState = cipherViewState,
)
}
.onEach(::sendAction)
.launchIn(viewModelScope)
}
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.Internal -> handleInternalAction(action)
}
}
private fun handleLoginActions(action: VaultItemAction.Login) {
when (action) {
VaultItemAction.Login.CheckForBreachClick -> handleCheckForBreachClick()
VaultItemAction.Login.CopyPasswordClick -> handleCopyPasswordClick()
is VaultItemAction.Login.CopyCustomHiddenFieldClick -> {
handleCopyCustomHiddenFieldClick(action)
}
is VaultItemAction.Login.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 -> {
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) {
mutableStateFlow.update {
it.copy(dialog = VaultItemState.DialogState.MasterPasswordDialog)
}
return@onContent
}
sendEvent(VaultItemEvent.NavigateToEdit(state.vaultItemId))
}
}
private fun handleLaunchClick(action: VaultItemAction.Login.LaunchClick) {
sendEvent(VaultItemEvent.NavigateToUri(action.uri))
}
private fun handleMasterPasswordSubmit(action: VaultItemAction.MasterPasswordSubmit) {
mutableStateFlow.update {
it.copy(dialog = VaultItemState.DialogState.Loading)
}
viewModelScope.launch {
@Suppress("MagicNumber")
delay(2_000)
// TODO: Actually verify the password (BIT-1213)
sendAction(
VaultItemAction.Internal.VerifyPasswordReceive(
VerifyPasswordResult.Success(isVerified = true),
),
)
sendEvent(
VaultItemEvent.ShowToast("Password verification not yet implemented.".asText()),
)
}
}
private fun handlePasswordHistoryClick() {
onContent { content ->
if (content.requiresReprompt) {
mutableStateFlow.update {
it.copy(dialog = VaultItemState.DialogState.MasterPasswordDialog)
}
return@onContent
}
sendEvent(VaultItemEvent.NavigateToPasswordHistory(state.vaultItemId))
}
}
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,
) {
onLoginContent { login ->
if (login.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,
),
),
)
}
}
}
private fun handleHiddenFieldVisibilityClicked(
action: VaultItemAction.Login.HiddenFieldVisibilityClicked,
) {
onLoginContent { login ->
if (login.requiresReprompt) {
mutableStateFlow.update {
it.copy(dialog = VaultItemState.DialogState.MasterPasswordDialog)
}
return@onLoginContent
}
mutableStateFlow.update {
it.copy(
viewState = login.copy(
customFields = login.customFields.map { customField ->
if (customField == action.field) {
action.field.copy(isVisible = action.isVisible)
} else {
customField
}
},
),
)
}
}
}
private fun handlePasswordBreachReceive(
action: VaultItemAction.Internal.PasswordBreachReceive,
) {
val message = when (val result = action.result) {
BreachCountResult.Error -> R.string.generic_error_message.asText()
is BreachCountResult.Success -> {
if (result.breachCount > 0) {
R.string.password_exposed.asText(result.breachCount)
} else {
R.string.password_safe.asText()
}
}
}
mutableStateFlow.update {
it.copy(dialog = VaultItemState.DialogState.Generic(message = message))
}
}
private fun handleVaultDataReceive(action: VaultItemAction.Internal.VaultDataReceive) {
// Leave the current data alone if there is no UserState; we are in the process of logging
// out.
val userState = action.userState ?: return
when (val vaultDataState = action.vaultDataState) {
is DataState.Error -> {
mutableStateFlow.update {
it.copy(
viewState = VaultItemState.ViewState.Error(
message = R.string.generic_error_message.asText(),
),
)
}
}
is DataState.Loaded -> {
mutableStateFlow.update {
it.copy(
viewState = vaultDataState.data
?.toViewState(isPremiumUser = userState.activeAccount.isPremium)
?: VaultItemState.ViewState.Error(
message = R.string.generic_error_message.asText(),
),
)
}
}
DataState.Loading -> {
mutableStateFlow.update {
it.copy(viewState = VaultItemState.ViewState.Loading)
}
}
is DataState.NoNetwork -> {
mutableStateFlow.update {
it.copy(
viewState = VaultItemState.ViewState.Error(
message = R.string.internet_connection_required_title
.asText()
.concat(R.string.internet_connection_required_message.asText()),
),
)
}
}
is DataState.Pending -> {
mutableStateFlow.update {
it.copy(
viewState = vaultDataState.data
?.toViewState(isPremiumUser = userState.activeAccount.isPremium)
?: VaultItemState.ViewState.Error(
message = R.string.generic_error_message.asText(),
),
)
}
}
}
}
private fun handleVerifyPasswordReceive(
action: VaultItemAction.Internal.VerifyPasswordReceive,
) {
when (val result = action.result) {
VerifyPasswordResult.Error -> {
mutableStateFlow.update {
it.copy(
dialog = VaultItemState.DialogState.Generic(
message = R.string.invalid_master_password.asText(),
),
)
}
}
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
},
)
}
}
}
}
private inline fun onContent(
crossinline block: (VaultItemState.ViewState.Content) -> Unit,
) {
(state.viewState as? VaultItemState.ViewState.Content)?.let(block)
}
private inline fun onLoginContent(
crossinline block: (VaultItemState.ViewState.Content.Login) -> Unit,
) {
(state.viewState as? VaultItemState.ViewState.Content.Login)?.let(block)
}
}
/**
@@ -45,16 +409,238 @@ class VaultItemViewModel @Inject constructor(
@Parcelize
data class VaultItemState(
val vaultItemId: String,
) : Parcelable
val viewState: ViewState,
val dialog: DialogState?,
) : Parcelable {
/**
* Represents the specific view states for the [VaultItemScreen].
*/
sealed class ViewState : Parcelable {
/**
* Represents an error state for the [VaultItemScreen].
*/
@Parcelize
data class Error(
val message: Text,
) : ViewState()
/**
* Loading state for the [VaultItemScreen], signifying that the content is being processed.
*/
@Parcelize
data object Loading : ViewState()
/**
* Represents a loaded content state for the [VaultItemScreen].
*/
sealed class Content : 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.
*/
@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()
/**
* 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
* 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()
/**
* 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(
private val id: UInt,
val name: String,
) : Custom() {
val type: Type get() = Type.values().first { it.id == id }
/**
* Represents the types linked fields.
*/
enum class Type(
val id: UInt,
val label: Text,
) {
USERNAME(id = 100.toUInt(), label = R.string.username.asText()),
PASSWORD(id = 101.toUInt(), label = R.string.password.asText()),
}
}
}
}
}
/**
* Displays a dialog.
*/
sealed class DialogState : Parcelable {
/**
* Displays a generic dialog to the user.
*/
@Parcelize
data class Generic(
val message: Text,
) : DialogState()
/**
* Displays the loading dialog to the user.
*/
@Parcelize
data object Loading : DialogState()
/**
* Displays the master password dialog to the user.
*/
@Parcelize
data object MasterPasswordDialog : DialogState()
}
}
/**
* Represents a set of events related view a vault item.
*/
sealed class VaultItemEvent {
/**
* Places the given [message] in your clipboard.
*/
data class CopyToClipboard(
val message: Text,
) : VaultItemEvent()
/**
* Navigates back.
*/
data object NavigateBack : VaultItemEvent()
/**
* Navigates to the edit screen.
*/
data class NavigateToEdit(
val itemId: String,
) : VaultItemEvent()
/**
* Navigates to the password history screen.
*/
data class NavigateToPasswordHistory(
val itemId: String,
) : VaultItemEvent()
/**
* Launches the external URI.
*/
data class NavigateToUri(
val uri: String,
) : VaultItemEvent()
/**
* Places the given [message] in your clipboard.
*/
data class ShowToast(
val message: Text,
) : VaultItemEvent()
}
/**
@@ -65,4 +651,121 @@ sealed class VaultItemAction {
* The user has clicked the close button.
*/
data object CloseClick : VaultItemAction()
/**
* The user has clicked to dismiss the dialog.
*/
data object DismissDialogClick : 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.
*/
data object CheckForBreachClick : Login()
/**
* The user has clicked the copy button for a custom hidden field.
*/
data class CopyCustomHiddenFieldClick(
val field: String,
) : Login()
/**
* 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()
/**
* The user has clicked to display the a hidden field.
*/
data class HiddenFieldVisibilityClicked(
val field: VaultItemState.ViewState.Content.Custom.HiddenField,
val isVisible: Boolean,
) : Login()
}
/**
* Models actions that the [VaultItemViewModel] itself might send.
*/
sealed class Internal : VaultItemAction() {
/**
* Indicates that the password breach results have been received.
*/
data class PasswordBreachReceive(
val result: BreachCountResult,
) : Internal()
/**
* Indicates that the vault item data has been received.
*/
data class VaultDataReceive(
val userState: UserState?,
val vaultDataState: DataState<CipherView?>,
) : Internal()
/**
* Indicates that the verify password result has been received.
*/
data class VerifyPasswordReceive(
val result: VerifyPasswordResult,
) : Internal()
}
}

View File

@@ -0,0 +1,94 @@
package com.x8bit.bitwarden.ui.vault.feature.item.util
import com.bitwarden.core.CipherRepromptType
import com.bitwarden.core.CipherType
import com.bitwarden.core.CipherView
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.feature.item.VaultItemState
import com.x8bit.bitwarden.ui.vault.feature.vault.VaultState
import java.time.format.DateTimeFormatter
import java.util.TimeZone
private val dateTimeFormatter = DateTimeFormatter
.ofPattern("M/d/yy hh:mm a")
.withZone(TimeZone.getDefault().toZoneId())
/**
* Transforms [VaultData] into [VaultState.ViewState].
*/
fun CipherView.toViewState(
isPremiumUser: Boolean,
): VaultItemState.ViewState =
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(
name = name.orEmpty(),
value = value.orZeroWidthSpace(),
isCopyable = !value.isNullOrBlank(),
)
FieldType.HIDDEN -> VaultItemState.ViewState.Content.Custom.HiddenField(
name = name.orEmpty(),
value = value.orZeroWidthSpace(),
isCopyable = !value.isNullOrBlank(),
isVisible = false,
)
FieldType.BOOLEAN -> VaultItemState.ViewState.Content.Custom.BooleanField(
name = name.orEmpty(),
value = value?.toBoolean() ?: false,
)
FieldType.LINKED -> VaultItemState.ViewState.Content.Custom.LinkedField(
id = requireNotNull(linkedId),
name = name.orEmpty(),
)
}
private fun LoginUriView.toUriData() =
VaultItemState.ViewState.Content.UriData(
uri = uri.orZeroWidthSpace(),
isCopyable = !uri.isNullOrBlank(),
isLaunchable = !uri.isNullOrBlank(),
)