mirror of
https://github.com/bitwarden/android.git
synced 2024-10-31 15:15:34 +03:00
BIT-500 Add View Item Screen (#299)
This commit is contained in:
parent
0abc8886a6
commit
bd2cd54d47
13 changed files with 1892 additions and 14 deletions
|
@ -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()
|
||||
}
|
|
@ -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())
|
||||
}
|
||||
}
|
|
@ -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())
|
||||
}
|
||||
}
|
|
@ -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))
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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(),
|
||||
)
|
13
app/src/main/res/drawable/ic_edit.xml
Normal file
13
app/src/main/res/drawable/ic_edit.xml
Normal file
|
@ -0,0 +1,13 @@
|
|||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="20dp"
|
||||
android:height="20dp"
|
||||
android:viewportHeight="20"
|
||||
android:viewportWidth="20">
|
||||
<group>
|
||||
<clip-path android:pathData="M0,0h20v20h-20z" />
|
||||
<path
|
||||
android:fillColor="#001848"
|
||||
android:fillType="evenOdd"
|
||||
android:pathData="M2.276,13.661C2.224,13.714 2.175,13.769 2.129,13.827C2.029,13.953 1.969,14.084 1.93,14.179L0.816,16.901C0.776,16.998 0.744,17.098 0.721,17.2C0.668,17.44 0.617,17.671 0.599,17.858C0.585,17.999 0.555,18.368 0.83,18.643C1.105,18.918 1.474,18.888 1.615,18.874C1.802,18.856 2.033,18.805 2.273,18.752C2.375,18.729 2.475,18.697 2.572,18.657L5.294,17.543C5.389,17.504 5.52,17.444 5.646,17.344C5.704,17.298 5.759,17.249 5.812,17.197L18.092,4.916C18.6,4.409 19.007,3.75 19.147,3.05C19.292,2.328 19.148,1.552 18.534,0.939C17.921,0.325 17.145,0.181 16.423,0.326C15.723,0.466 15.064,0.873 14.557,1.381L2.276,13.661ZM3.108,14.606C3.11,14.602 3.112,14.6 3.112,14.599L4.866,16.365C4.857,16.37 4.843,16.377 4.82,16.386L2.098,17.501C2.066,17.514 2.033,17.524 1.999,17.532L1.925,17.549L1.941,17.474C1.949,17.44 1.959,17.407 1.972,17.375L3.087,14.653C3.096,14.63 3.103,14.616 3.108,14.606ZM14.356,3.349L3.16,14.545C3.143,14.562 3.127,14.58 3.112,14.599L4.874,16.361C4.893,16.346 4.911,16.33 4.928,16.313L16.124,5.117L14.356,3.349ZM15.24,2.466L17.007,4.233L17.208,4.032C17.585,3.656 17.84,3.209 17.921,2.805C17.998,2.422 17.921,2.093 17.65,1.823C17.38,1.552 17.051,1.475 16.668,1.552C16.264,1.633 15.817,1.888 15.441,2.265L15.24,2.466Z" />
|
||||
</group>
|
||||
</vector>
|
12
app/src/main/res/drawable/ic_launch.xml
Normal file
12
app/src/main/res/drawable/ic_launch.xml
Normal file
|
@ -0,0 +1,12 @@
|
|||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="20dp"
|
||||
android:height="20dp"
|
||||
android:viewportHeight="20"
|
||||
android:viewportWidth="20">
|
||||
<path
|
||||
android:fillColor="#151B2C"
|
||||
android:pathData="M15.891,0.076C15.584,-0.08 15.207,0.042 15.051,0.35C14.895,0.658 15.017,1.034 15.325,1.19L17.774,2.435C14.277,3.029 12.043,4.708 10.689,6.613C9.317,8.545 8.87,10.682 8.885,12.092C8.888,12.437 9.171,12.714 9.516,12.71C9.861,12.706 10.138,12.424 10.135,12.078C10.122,10.904 10.504,9.031 11.708,7.337C12.898,5.662 14.909,4.131 18.224,3.629C18.249,3.625 18.274,3.62 18.298,3.613L17.065,6.248C16.919,6.561 17.054,6.933 17.367,7.079C17.679,7.225 18.051,7.09 18.198,6.778L19.903,3.132C20.117,2.674 19.929,2.128 19.478,1.899L15.891,0.076Z" />
|
||||
<path
|
||||
android:fillColor="#151B2C"
|
||||
android:pathData="M10,1.25H3.125C2.089,1.25 1.25,2.089 1.25,3.125V16.875C1.25,17.91 2.089,18.75 3.125,18.75H16.875C17.91,18.75 18.75,17.91 18.75,16.875V10C18.75,9.655 18.47,9.375 18.125,9.375C17.78,9.375 17.5,9.655 17.5,10V16.875C17.5,17.22 17.22,17.5 16.875,17.5H3.125C2.78,17.5 2.5,17.22 2.5,16.875V3.125C2.5,2.78 2.78,2.5 3.125,2.5H10C10.345,2.5 10.625,2.22 10.625,1.875C10.625,1.53 10.345,1.25 10,1.25Z" />
|
||||
</vector>
|
10
app/src/main/res/drawable/ic_linked.xml
Normal file
10
app/src/main/res/drawable/ic_linked.xml
Normal file
|
@ -0,0 +1,10 @@
|
|||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="16dp"
|
||||
android:height="16dp"
|
||||
android:viewportHeight="16"
|
||||
android:viewportWidth="16">
|
||||
<path
|
||||
android:fillColor="#000000"
|
||||
android:fillType="evenOdd"
|
||||
android:pathData="M1.329,2.941L2.743,1.527C3.329,0.941 4.279,0.941 4.865,1.527L8.4,5.063C8.986,5.649 8.986,6.598 8.4,7.184L8.047,7.538L8.754,8.245L9.107,7.891C9.693,7.305 10.643,7.305 11.229,7.891L14.764,11.427C15.35,12.013 15.35,12.962 14.764,13.548L13.35,14.962C12.764,15.548 11.814,15.548 11.229,14.962L7.693,11.427C7.107,10.841 7.107,9.891 7.693,9.305L8.047,8.952L7.339,8.245L6.986,8.598C6.4,9.184 5.45,9.184 4.865,8.598L1.329,5.063C0.743,4.477 0.743,3.527 1.329,2.941ZM7.339,6.831L6.279,5.77C6.083,5.575 5.767,5.575 5.572,5.77C5.376,5.965 5.376,6.282 5.572,6.477L6.632,7.538L6.279,7.891C6.083,8.086 5.767,8.086 5.572,7.891L2.036,4.356C1.841,4.16 1.841,3.844 2.036,3.649L3.45,2.234C3.646,2.039 3.962,2.039 4.157,2.234L7.693,5.77C7.888,5.965 7.888,6.282 7.693,6.477L7.339,6.831ZM8.754,9.659L8.4,10.012C8.205,10.208 8.205,10.524 8.4,10.72L11.936,14.255C12.131,14.45 12.448,14.45 12.643,14.255L14.057,12.841C14.252,12.646 14.252,12.329 14.057,12.134L10.521,8.598C10.326,8.403 10.01,8.403 9.814,8.598L9.461,8.952L10.521,10.012C10.717,10.208 10.717,10.524 10.521,10.72C10.326,10.915 10.01,10.915 9.814,10.72L8.754,9.659Z" />
|
||||
</vector>
|
|
@ -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,
|
||||
)
|
||||
|
|
|
@ -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<CipherView?>>(DataState.Loading)
|
||||
private val mutableUserStateFlow = MutableStateFlow<UserState?>(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<CipherView?>>(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,
|
||||
),
|
||||
),
|
||||
)
|
||||
|
|
|
@ -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,
|
||||
)
|
Loading…
Reference in a new issue