diff --git a/app/src/main/java/com/x8bit/bitwarden/data/vault/repository/model/VerifyPasswordResult.kt b/app/src/main/java/com/x8bit/bitwarden/data/vault/repository/model/VerifyPasswordResult.kt new file mode 100644 index 0000000000..c14fa4fd58 --- /dev/null +++ b/app/src/main/java/com/x8bit/bitwarden/data/vault/repository/model/VerifyPasswordResult.kt @@ -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() +} diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/item/VaultItemError.kt b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/item/VaultItemError.kt new file mode 100644 index 0000000000..f4a3357243 --- /dev/null +++ b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/item/VaultItemError.kt @@ -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()) + } +} diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/item/VaultItemLoading.kt b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/item/VaultItemLoading.kt new file mode 100644 index 0000000000..8c4dc7919a --- /dev/null +++ b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/item/VaultItemLoading.kt @@ -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()) + } +} 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 new file mode 100644 index 0000000000..f293981822 --- /dev/null +++ b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/item/VaultItemLoginContent.kt @@ -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)) + }, + ) + } +} 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 d1c47abad2..695f6f9c4b 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 @@ -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, + ) } } 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 ce7253035e..823231f427 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 @@ -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( 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 + + /** + * 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, + override val requiresReprompt: Boolean, + val username: String?, + val passwordData: PasswordData?, + val uris: List, + 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, + ) : Internal() + + /** + * Indicates that the verify password result has been received. + */ + data class VerifyPasswordReceive( + val result: VerifyPasswordResult, + ) : Internal() + } } 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 new file mode 100644 index 0000000000..d276ce693d --- /dev/null +++ b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/item/util/CipherViewExtensions.kt @@ -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(), + ) diff --git a/app/src/main/res/drawable/ic_edit.xml b/app/src/main/res/drawable/ic_edit.xml new file mode 100644 index 0000000000..5868dbb980 --- /dev/null +++ b/app/src/main/res/drawable/ic_edit.xml @@ -0,0 +1,13 @@ + + + + + + diff --git a/app/src/main/res/drawable/ic_launch.xml b/app/src/main/res/drawable/ic_launch.xml new file mode 100644 index 0000000000..bdc8f694e6 --- /dev/null +++ b/app/src/main/res/drawable/ic_launch.xml @@ -0,0 +1,12 @@ + + + + diff --git a/app/src/main/res/drawable/ic_linked.xml b/app/src/main/res/drawable/ic_linked.xml new file mode 100644 index 0000000000..4fc929d77c --- /dev/null +++ b/app/src/main/res/drawable/ic_linked.xml @@ -0,0 +1,10 @@ + + + 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 b82ef26bb2..6cf6d50329 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 @@ -48,4 +48,6 @@ private const val VAULT_ITEM_ID = "vault_item_id" private val DEFAULT_STATE: VaultItemState = VaultItemState( vaultItemId = VAULT_ITEM_ID, + viewState = VaultItemState.ViewState.Loading, + dialog = null, ) 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 e66f45a99d..57f646d566 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 @@ -2,13 +2,31 @@ package com.x8bit.bitwarden.ui.vault.feature.item import androidx.lifecycle.SavedStateHandle import app.cash.turbine.test +import com.bitwarden.core.CipherView +import com.x8bit.bitwarden.data.auth.repository.AuthRepository +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.ui.platform.base.BaseViewModelTest +import io.mockk.every +import io.mockk.mockk +import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.test.runTest import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Test class VaultItemViewModelTest : BaseViewModelTest() { + private val mutableVaultItemFlow = MutableStateFlow>(DataState.Loading) + private val mutableUserStateFlow = MutableStateFlow(DEFAULT_USER_STATE) + + private val authRepo: AuthRepository = mockk { + every { userStateFlow } returns mutableUserStateFlow + } + private val vaultRepo: VaultRepository = mockk { + every { getVaultItemStateFlow(VAULT_ITEM_ID) } returns mutableVaultItemFlow + } + @Test fun `initial state should be correct when not set`() { val viewModel = createViewModel(state = null) @@ -17,7 +35,11 @@ class VaultItemViewModelTest : BaseViewModelTest() { @Test fun `initial state should be correct when set`() { - val state = DEFAULT_STATE.copy(vaultItemId = "something_different") + val differentVaultItemId = "something_different" + every { + vaultRepo.getVaultItemStateFlow(differentVaultItemId) + } returns MutableStateFlow>(DataState.Loading) + val state = DEFAULT_STATE.copy(vaultItemId = differentVaultItemId) val viewModel = createViewModel(state = state) assertEquals(state, viewModel.stateFlow.value) } @@ -34,11 +56,15 @@ class VaultItemViewModelTest : BaseViewModelTest() { private fun createViewModel( state: VaultItemState? = DEFAULT_STATE, vaultItemId: String = VAULT_ITEM_ID, + authRepository: AuthRepository = authRepo, + vaultRepository: VaultRepository = vaultRepo, ): VaultItemViewModel = VaultItemViewModel( savedStateHandle = SavedStateHandle().apply { set("state", state) set("vault_item_id", vaultItemId) }, + authRepository = authRepository, + vaultRepository = vaultRepository, ) } @@ -46,4 +72,20 @@ private const val VAULT_ITEM_ID = "vault_item_id" 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", + isPremium = true, + isVaultUnlocked = true, + ), + ), ) 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 new file mode 100644 index 0000000000..f61a5cbc9f --- /dev/null +++ b/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/item/util/CipherViewExtensionsTest.kt @@ -0,0 +1,233 @@ +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.bitwarden.core.LoginView +import com.bitwarden.core.PasswordHistoryView +import com.x8bit.bitwarden.ui.vault.feature.item.VaultItemState +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import java.time.Instant +import java.util.TimeZone + +class CipherViewExtensionsTest { + + @BeforeEach + fun setup() { + // Setting the timezone so the tests pass consistently no matter the environment. + TimeZone.setDefault(TimeZone.getTimeZone("UTC")) + } + + @AfterEach + fun tearDown() { + // Clearing the timezone after the test. + TimeZone.setDefault(null) + } + + @Test + fun `toViewState should transform full CipherView into ViewState Login Content with premium`() { + val viewState = DEFAULT_FULL_LOGIN_CIPHER_VIEW.toViewState(isPremiumUser = true) + + assertEquals(DEFAULT_FULL_LOGIN_VIEW_STATE, viewState) + } + + @Suppress("MaxLineLength") + @Test + fun `toViewState should transform full CipherView into ViewState Login Content without premium`() { + val isPremiumUser = false + val viewState = DEFAULT_FULL_LOGIN_CIPHER_VIEW.toViewState(isPremiumUser = isPremiumUser) + + assertEquals(DEFAULT_FULL_LOGIN_VIEW_STATE.copy(isPremiumUser = isPremiumUser), viewState) + } + + @Test + fun `toViewState should transform empty CipherView into ViewState Login Content`() { + val viewState = DEFAULT_EMPTY_LOGIN_CIPHER_VIEW.toViewState(isPremiumUser = true) + + assertEquals(DEFAULT_EMPTY_LOGIN_VIEW_STATE, viewState) + } +} + +val DEFAULT_FULL_LOGIN_VIEW: LoginView = LoginView( + username = "username", + password = "password", + passwordRevisionDate = Instant.ofEpochSecond(1_000L), + uris = listOf( + LoginUriView( + uri = "www.example.com", + match = null, + ), + ), + totp = "otpauth://totp/Example:alice@google.com?secret=JBSWY3DPEHPK3PXP&issuer=Example", + autofillOnPageLoad = false, +) + +val DEFAULT_EMPTY_LOGIN_VIEW: LoginView = LoginView( + username = null, + password = null, + passwordRevisionDate = null, + uris = emptyList(), + totp = null, + autofillOnPageLoad = false, +) + +val DEFAULT_FULL_LOGIN_CIPHER_VIEW: CipherView = CipherView( + id = null, + organizationId = null, + folderId = null, + collectionIds = emptyList(), + key = null, + name = "login cipher", + notes = "Lots of notes", + type = CipherType.LOGIN, + login = DEFAULT_FULL_LOGIN_VIEW, + identity = null, + card = null, + secureNote = null, + favorite = false, + reprompt = CipherRepromptType.PASSWORD, + organizationUseTotp = false, + edit = false, + viewPassword = false, + localData = null, + attachments = null, + fields = listOf( + FieldView( + name = "text", + value = "value", + type = FieldType.TEXT, + linkedId = null, + ), + FieldView( + name = "hidden", + value = "value", + type = FieldType.HIDDEN, + linkedId = null, + ), + FieldView( + name = "boolean", + value = "true", + type = FieldType.BOOLEAN, + linkedId = null, + ), + FieldView( + name = "linked username", + value = null, + type = FieldType.LINKED, + linkedId = 100U, + ), + FieldView( + name = "linked password", + value = null, + type = FieldType.LINKED, + linkedId = 101U, + ), + ), + passwordHistory = listOf( + PasswordHistoryView( + password = "old_password", + lastUsedDate = Instant.ofEpochSecond(1_000L), + ), + ), + creationDate = Instant.ofEpochSecond(1_000L), + deletedDate = null, + revisionDate = Instant.ofEpochSecond(1_000L), +) + +val DEFAULT_EMPTY_LOGIN_CIPHER_VIEW: CipherView = CipherView( + id = null, + organizationId = null, + folderId = null, + collectionIds = emptyList(), + key = null, + name = "login cipher", + notes = null, + type = CipherType.LOGIN, + login = DEFAULT_EMPTY_LOGIN_VIEW, + identity = null, + card = null, + secureNote = null, + favorite = false, + reprompt = CipherRepromptType.PASSWORD, + organizationUseTotp = false, + edit = false, + viewPassword = false, + localData = null, + attachments = null, + fields = null, + passwordHistory = null, + creationDate = Instant.ofEpochSecond(1_000L), + deletedDate = null, + 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, + ), + 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", + id = 100U, + ), + VaultItemState.ViewState.Content.Custom.LinkedField( + name = "linked password", + id = 101U, + ), + ), + 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, + )