PM-21134, PM-21135, PM-21136, PM-21137: Create View Send Screen (#5178)

This commit is contained in:
David Perez 2025-05-13 13:47:43 -05:00 committed by GitHub
parent 6d68c3ae24
commit 860a2e265f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
15 changed files with 1473 additions and 50 deletions

View File

@ -364,10 +364,10 @@ fun Modifier.cardStyle(
cardStyle = cardStyle,
onClick = onClick,
clickEnabled = clickEnabled,
paddingStart = paddingHorizontal,
paddingTop = paddingVertical,
paddingEnd = paddingHorizontal,
paddingBottom = paddingVertical,
padding = PaddingValues(
horizontal = paddingHorizontal,
vertical = paddingVertical,
),
containerColor = containerColor,
indicationColor = indicationColor,
)
@ -388,6 +388,34 @@ fun Modifier.cardStyle(
paddingBottom: Dp = 12.dp,
containerColor: Color = BitwardenTheme.colorScheme.background.secondary,
indicationColor: Color = BitwardenTheme.colorScheme.background.pressed,
): Modifier =
this.cardStyle(
cardStyle = cardStyle,
onClick = onClick,
clickEnabled = clickEnabled,
padding = PaddingValues(
start = paddingStart,
top = paddingTop,
end = paddingEnd,
bottom = paddingBottom,
),
containerColor = containerColor,
indicationColor = indicationColor,
)
/**
* This is a [Modifier] extension that applies a card style to the content.
*/
@OmitFromCoverage
@Stable
@Composable
fun Modifier.cardStyle(
cardStyle: CardStyle?,
onClick: (() -> Unit)? = null,
clickEnabled: Boolean = true,
padding: PaddingValues = PaddingValues(horizontal = 0.dp, vertical = 12.dp),
containerColor: Color = BitwardenTheme.colorScheme.background.secondary,
indicationColor: Color = BitwardenTheme.colorScheme.background.pressed,
): Modifier =
this
.cardBackground(
@ -401,12 +429,7 @@ fun Modifier.cardStyle(
)
.cardPadding(
cardStyle = cardStyle,
paddingValues = PaddingValues(
start = paddingStart,
top = paddingTop,
end = paddingEnd,
bottom = paddingBottom,
),
paddingValues = padding,
)
/**

View File

@ -14,7 +14,9 @@ import androidx.compose.ui.graphics.painter.Painter
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import com.bitwarden.ui.platform.components.model.CardStyle
import com.x8bit.bitwarden.R
import com.x8bit.bitwarden.ui.platform.base.util.cardStyle
import com.x8bit.bitwarden.ui.platform.components.button.color.bitwardenFilledButtonColors
import com.x8bit.bitwarden.ui.platform.components.util.rememberVectorPainter
import com.x8bit.bitwarden.ui.platform.theme.BitwardenTheme
@ -27,6 +29,9 @@ import com.x8bit.bitwarden.ui.platform.theme.BitwardenTheme
* @param modifier The [Modifier] to be applied to the button.
* @param icon The icon for the button.
* @param isEnabled Whether or not the button is enabled.
* @param cardStyle The optional card style to surround the button.
* @param cardInsets The internal insets for the card, only applied when the [cardStyle] is not
* `null`.
*/
@Composable
fun BitwardenFilledButton(
@ -36,9 +41,13 @@ fun BitwardenFilledButton(
icon: Painter? = null,
isEnabled: Boolean = true,
colors: ButtonColors = bitwardenFilledButtonColors(),
cardStyle: CardStyle? = null,
cardInsets: PaddingValues = PaddingValues(horizontal = 16.dp),
) {
Button(
modifier = modifier.semantics(mergeDescendants = true) {},
modifier = modifier
.semantics(mergeDescendants = true) {}
.cardStyle(cardStyle = cardStyle, padding = cardInsets),
onClick = onClick,
enabled = isEnabled,
contentPadding = PaddingValues(

View File

@ -17,7 +17,9 @@ import androidx.compose.ui.graphics.painter.Painter
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import com.bitwarden.ui.platform.components.model.CardStyle
import com.x8bit.bitwarden.R
import com.x8bit.bitwarden.ui.platform.base.util.cardStyle
import com.x8bit.bitwarden.ui.platform.components.button.color.bitwardenOutlinedButtonColors
import com.x8bit.bitwarden.ui.platform.components.util.rememberVectorPainter
import com.x8bit.bitwarden.ui.platform.theme.BitwardenTheme
@ -30,6 +32,10 @@ import com.x8bit.bitwarden.ui.platform.theme.BitwardenTheme
* @param modifier The [Modifier] to be applied to the button.
* @param icon The icon for the button.
* @param isEnabled Whether or not the button is enabled.
* @param cardStyle The optional card style to surround the button.
* `null`.
* @param cardInsets The internal insets for the card, only applied when the [cardStyle] is not
* `null`.
*/
@Composable
fun BitwardenOutlinedButton(
@ -39,9 +45,13 @@ fun BitwardenOutlinedButton(
icon: Painter? = null,
isEnabled: Boolean = true,
colors: BitwardenOutlinedButtonColors = bitwardenOutlinedButtonColors(),
cardStyle: CardStyle? = null,
cardInsets: PaddingValues = PaddingValues(horizontal = 16.dp, vertical = 6.dp),
) {
OutlinedButton(
modifier = modifier.semantics(mergeDescendants = true) { },
modifier = modifier
.semantics(mergeDescendants = true) { }
.cardStyle(cardStyle = cardStyle, padding = cardInsets),
onClick = onClick,
enabled = isEnabled,
contentPadding = PaddingValues(

View File

@ -34,7 +34,6 @@ import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import com.bitwarden.ui.platform.components.model.CardStyle
import com.bitwarden.ui.util.asText
import com.bitwarden.ui.util.concat
import com.x8bit.bitwarden.R
import com.x8bit.bitwarden.ui.platform.base.util.cardStyle
import com.x8bit.bitwarden.ui.platform.base.util.standardHorizontalMargin
@ -378,10 +377,7 @@ private fun AddSendOptions(
state.common.currentAccessCount.takeUnless { isAddMode }?.let {
Spacer(modifier = Modifier.height(16.dp))
Text(
text = R.string.current_access_count
.asText()
.concat(": ".asText(), it.toString().asText())
.invoke(),
text = R.string.current_access_count.asText(it).invoke(),
style = BitwardenTheme.typography.bodySmall,
color = BitwardenTheme.colorScheme.text.secondary,
modifier = Modifier.fillMaxWidth(),

View File

@ -1,49 +1,110 @@
package com.x8bit.bitwarden.ui.tools.feature.send.viewsend
import android.widget.Toast
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.expandVertically
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.scaleIn
import androidx.compose.animation.scaleOut
import androidx.compose.animation.shrinkVertically
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.defaultMinSize
import androidx.compose.foundation.layout.fillMaxSize
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.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.material3.rememberTopAppBarState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clipToBounds
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.bitwarden.ui.platform.base.util.EventsEffect
import com.bitwarden.ui.platform.components.model.CardStyle
import com.bitwarden.ui.util.asText
import com.x8bit.bitwarden.R
import com.x8bit.bitwarden.ui.platform.base.util.cardStyle
import com.x8bit.bitwarden.ui.platform.base.util.standardHorizontalMargin
import com.x8bit.bitwarden.ui.platform.components.appbar.BitwardenTopAppBar
import com.x8bit.bitwarden.ui.platform.components.appbar.NavigationIcon
import com.x8bit.bitwarden.ui.platform.components.button.BitwardenFilledButton
import com.x8bit.bitwarden.ui.platform.components.button.BitwardenOutlinedButton
import com.x8bit.bitwarden.ui.platform.components.button.BitwardenOutlinedErrorButton
import com.x8bit.bitwarden.ui.platform.components.content.BitwardenErrorContent
import com.x8bit.bitwarden.ui.platform.components.content.BitwardenLoadingContent
import com.x8bit.bitwarden.ui.platform.components.dialog.BitwardenBasicDialog
import com.x8bit.bitwarden.ui.platform.components.dialog.BitwardenLoadingDialog
import com.x8bit.bitwarden.ui.platform.components.fab.BitwardenFloatingActionButton
import com.x8bit.bitwarden.ui.platform.components.field.BitwardenTextField
import com.x8bit.bitwarden.ui.platform.components.header.BitwardenExpandingHeader
import com.x8bit.bitwarden.ui.platform.components.header.BitwardenListHeaderText
import com.x8bit.bitwarden.ui.platform.components.scaffold.BitwardenScaffold
import com.x8bit.bitwarden.ui.platform.components.stepper.BitwardenStepper
import com.x8bit.bitwarden.ui.platform.components.util.rememberVectorPainter
import com.x8bit.bitwarden.ui.platform.composition.LocalIntentManager
import com.x8bit.bitwarden.ui.platform.manager.intent.IntentManager
import com.x8bit.bitwarden.ui.platform.theme.BitwardenTheme
/**
* Displays view send screen.
*/
@Suppress("LongMethod")
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ViewSendScreen(
viewModel: ViewSendViewModel = hiltViewModel(),
onNavigateBack: () -> Unit,
onNavigateToEditSend: (sendId: String) -> Unit,
intentManager: IntentManager = LocalIntentManager.current,
) {
val state by viewModel.stateFlow.collectAsStateWithLifecycle()
val context = LocalContext.current
val resources = context.resources
EventsEffect(viewModel = viewModel) { event ->
when (event) {
is ViewSendEvent.NavigateBack -> onNavigateBack()
is ViewSendEvent.NavigateToEdit -> onNavigateToEditSend(event.sendId)
is ViewSendEvent.ShareText -> {
intentManager.shareText(text = event.text(resources).toString())
}
is ViewSendEvent.ShowToast -> {
Toast.makeText(context, event.message(resources), Toast.LENGTH_SHORT).show()
}
}
}
ViewSendDialogs(
dialogState = state.dialogState,
onDismissRequest = remember(viewModel) {
{ viewModel.trySendAction(ViewSendAction.DialogDismiss) }
},
)
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior(rememberTopAppBarState())
BitwardenScaffold(
modifier = Modifier
@ -82,18 +143,59 @@ fun ViewSendScreen(
ViewSendScreenContent(
state = state,
modifier = Modifier.fillMaxSize(),
onCopyClick = remember(viewModel) {
{ viewModel.trySendAction(ViewSendAction.CopyClick) }
},
onDeleteClick = remember(viewModel) {
{ viewModel.trySendAction(ViewSendAction.DeleteClick) }
},
onShareClick = remember(viewModel) {
{ viewModel.trySendAction(ViewSendAction.ShareClick) }
},
)
}
}
@Composable
private fun ViewSendDialogs(
dialogState: ViewSendState.DialogState?,
onDismissRequest: () -> Unit,
) {
when (dialogState) {
is ViewSendState.DialogState.Error -> {
BitwardenBasicDialog(
title = dialogState.title?.invoke(),
message = dialogState.message(),
throwable = dialogState.throwable,
onDismissRequest = onDismissRequest,
)
}
is ViewSendState.DialogState.Loading -> {
BitwardenLoadingDialog(text = dialogState.message())
}
null -> Unit
}
}
@Composable
private fun ViewSendScreenContent(
state: ViewSendState,
onCopyClick: () -> Unit,
onDeleteClick: () -> Unit,
onShareClick: () -> Unit,
modifier: Modifier = Modifier,
) {
when (val viewState = state.viewState) {
ViewSendState.ViewState.Content -> {
// TODO: Build out the UI (PM-21135)
is ViewSendState.ViewState.Content -> {
ViewStateContent(
state = viewState,
onCopyClick = onCopyClick,
onDeleteClick = onDeleteClick,
onShareClick = onShareClick,
modifier = modifier,
)
}
is ViewSendState.ViewState.Error -> {
@ -108,3 +210,260 @@ private fun ViewSendScreenContent(
}
}
}
@Suppress("LongMethod")
@Composable
private fun ViewStateContent(
state: ViewSendState.ViewState.Content,
onCopyClick: () -> Unit,
onDeleteClick: () -> Unit,
onShareClick: () -> Unit,
modifier: Modifier = Modifier,
) {
Column(
modifier = modifier.verticalScroll(state = rememberScrollState()),
) {
Spacer(modifier = Modifier.height(height = 12.dp))
ShareLinkSection(
shareLink = state.shareLink,
modifier = Modifier
.fillMaxWidth()
.standardHorizontalMargin(),
)
BitwardenFilledButton(
label = stringResource(id = R.string.copy),
onClick = onCopyClick,
icon = rememberVectorPainter(id = R.drawable.ic_copy_small),
cardStyle = CardStyle.Middle(hasDivider = false),
cardInsets = PaddingValues(top = 16.dp, bottom = 6.dp, start = 16.dp, end = 16.dp),
modifier = Modifier
.fillMaxWidth()
.standardHorizontalMargin(),
)
BitwardenOutlinedButton(
label = stringResource(id = R.string.share),
onClick = onShareClick,
icon = rememberVectorPainter(id = R.drawable.ic_share_small),
cardStyle = CardStyle.Bottom,
cardInsets = PaddingValues(top = 6.dp, bottom = 16.dp, start = 16.dp, end = 16.dp),
modifier = Modifier
.fillMaxWidth()
.standardHorizontalMargin(),
)
Spacer(modifier = Modifier.height(height = 16.dp))
BitwardenListHeaderText(
label = stringResource(id = R.string.send_details),
modifier = Modifier
.fillMaxWidth()
.standardHorizontalMargin()
.padding(horizontal = 16.dp),
)
Spacer(modifier = Modifier.height(height = 8.dp))
when (val sendType = state.sendType) {
is ViewSendState.ViewState.Content.SendType.FileType -> {
FileSendContent(
fileType = sendType,
modifier = Modifier
.fillMaxWidth()
.standardHorizontalMargin(),
)
}
is ViewSendState.ViewState.Content.SendType.TextType -> {
TextSendContent(
textType = sendType,
modifier = Modifier
.fillMaxWidth()
.standardHorizontalMargin(),
)
}
}
Spacer(modifier = Modifier.height(height = 8.dp))
BitwardenTextField(
label = stringResource(id = R.string.send_name_required),
value = state.sendName,
onValueChange = {},
readOnly = true,
cardStyle = CardStyle.Full,
modifier = Modifier
.fillMaxWidth()
.standardHorizontalMargin(),
)
Spacer(modifier = Modifier.height(height = 8.dp))
BitwardenTextField(
label = stringResource(id = R.string.deletion_date),
value = state.deletionDate,
onValueChange = {},
readOnly = true,
cardStyle = CardStyle.Full,
modifier = Modifier
.fillMaxWidth()
.standardHorizontalMargin(),
)
AdditionalOptions(state = state)
BitwardenOutlinedErrorButton(
label = stringResource(id = R.string.delete_send),
onClick = onDeleteClick,
icon = rememberVectorPainter(id = R.drawable.ic_trash_small),
modifier = Modifier
.fillMaxWidth()
.standardHorizontalMargin(),
)
Spacer(modifier = Modifier.height(height = 88.dp))
Spacer(modifier = Modifier.navigationBarsPadding())
}
}
/**
* A default content block which displays a header with an optional subtitle and an icon.
* Implemented to match design component.
*/
@Composable
private fun ShareLinkSection(
shareLink: String,
modifier: Modifier = Modifier,
) {
Column(
modifier = modifier
.cardStyle(
cardStyle = CardStyle.Top(dividerPadding = 0.dp),
paddingHorizontal = 16.dp,
paddingVertical = 12.dp,
),
) {
Text(
text = stringResource(id = R.string.share_link),
style = BitwardenTheme.typography.titleSmall,
color = BitwardenTheme.colorScheme.text.primary,
overflow = TextOverflow.Ellipsis,
maxLines = 1,
modifier = Modifier.fillMaxWidth(),
)
Spacer(modifier = Modifier.height(height = 4.dp))
Text(
text = shareLink,
style = BitwardenTheme.typography.bodyMedium,
color = BitwardenTheme.colorScheme.text.secondary,
overflow = TextOverflow.Ellipsis,
maxLines = 1,
modifier = Modifier.fillMaxWidth(),
)
}
}
@Composable
private fun FileSendContent(
fileType: ViewSendState.ViewState.Content.SendType.FileType,
modifier: Modifier = Modifier,
) {
Row(
modifier = modifier
.defaultMinSize(minHeight = 60.dp)
.cardStyle(
cardStyle = CardStyle.Full,
paddingHorizontal = 16.dp,
paddingVertical = 12.dp,
),
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = fileType.fileName,
color = BitwardenTheme.colorScheme.text.primary,
style = BitwardenTheme.typography.bodyLarge,
modifier = Modifier.weight(weight = 1f),
)
Spacer(modifier = Modifier.width(8.dp))
Text(
text = fileType.fileSize,
color = BitwardenTheme.colorScheme.text.secondary,
style = BitwardenTheme.typography.bodyLarge,
)
}
}
@Composable
private fun TextSendContent(
textType: ViewSendState.ViewState.Content.SendType.TextType,
modifier: Modifier = Modifier,
) {
BitwardenTextField(
label = stringResource(id = R.string.text_to_share),
value = textType.textToShare,
onValueChange = {},
readOnly = true,
cardStyle = CardStyle.Full,
modifier = modifier,
)
}
@Suppress("LongMethod")
@Composable
private fun ColumnScope.AdditionalOptions(
state: ViewSendState.ViewState.Content,
) {
if (state.maxAccessCount == null && state.notes == null) {
Spacer(modifier = Modifier.height(height = 16.dp))
return
}
var isExpanded by rememberSaveable { mutableStateOf(value = false) }
BitwardenExpandingHeader(
isExpanded = isExpanded,
onClick = { isExpanded = !isExpanded },
modifier = Modifier
.testTag(tag = "ViewSendAdditionalOptions")
.standardHorizontalMargin()
.fillMaxWidth(),
)
// Hide all content if not expanded:
AnimatedVisibility(
visible = isExpanded,
enter = fadeIn() + expandVertically(expandFrom = Alignment.Top),
exit = fadeOut() + shrinkVertically(shrinkTowards = Alignment.Top),
modifier = Modifier.clipToBounds(),
) {
Column {
state.maxAccessCount?.let {
BitwardenStepper(
label = stringResource(id = R.string.maximum_access_count),
value = it,
supportingText = R.string.current_access_count
.asText(state.currentAccessCount)
.invoke(),
onValueChange = {},
isDecrementEnabled = false,
isIncrementEnabled = false,
range = 0..Int.MAX_VALUE,
cardStyle = CardStyle.Full,
modifier = Modifier
.testTag(tag = "SendMaxAccessCount")
.fillMaxWidth()
.standardHorizontalMargin(),
)
}
state.notes?.let {
if (state.maxAccessCount != null) {
// If the stepper is present, we need a spacer between these 2 items
Spacer(modifier = Modifier.height(height = 8.dp))
}
BitwardenTextField(
label = stringResource(id = R.string.private_notes),
readOnly = true,
value = it,
singleLine = false,
onValueChange = {},
cardStyle = CardStyle.Full,
modifier = Modifier
.testTag(tag = "ViewSendNotes")
.fillMaxWidth()
.standardHorizontalMargin(),
)
}
Spacer(modifier = Modifier.height(height = 16.dp))
}
}
}

View File

@ -2,13 +2,30 @@ package com.x8bit.bitwarden.ui.tools.feature.send.viewsend
import android.os.Parcelable
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.viewModelScope
import com.bitwarden.core.data.repository.model.DataState
import com.bitwarden.data.repository.util.baseWebSendUrl
import com.bitwarden.send.SendView
import com.bitwarden.ui.platform.base.BackgroundEvent
import com.bitwarden.ui.platform.base.BaseViewModel
import com.bitwarden.ui.util.Text
import com.bitwarden.ui.util.asText
import com.bitwarden.ui.util.concat
import com.x8bit.bitwarden.R
import com.x8bit.bitwarden.data.platform.manager.clipboard.BitwardenClipboardManager
import com.x8bit.bitwarden.data.platform.repository.EnvironmentRepository
import com.x8bit.bitwarden.data.vault.repository.VaultRepository
import com.x8bit.bitwarden.data.vault.repository.model.DeleteSendResult
import com.x8bit.bitwarden.ui.tools.feature.send.model.SendItemType
import com.x8bit.bitwarden.ui.tools.feature.send.viewsend.util.toViewSendViewStateContent
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.parcelize.Parcelize
import java.time.Clock
import javax.inject.Inject
private const val KEY_STATE = "state"
@ -16,8 +33,13 @@ private const val KEY_STATE = "state"
/**
* View model for the view send screen.
*/
@Suppress("TooManyFunctions")
@HiltViewModel
class ViewSendViewModel @Inject constructor(
private val clipboardManager: BitwardenClipboardManager,
private val clock: Clock,
private val vaultRepository: VaultRepository,
environmentRepository: EnvironmentRepository,
savedStateHandle: SavedStateHandle,
) : BaseViewModel<ViewSendState, ViewSendEvent, ViewSendAction>(
// We load the state from the savedStateHandle for testing purposes.
@ -27,13 +49,35 @@ class ViewSendViewModel @Inject constructor(
sendType = args.sendType,
sendId = args.sendId,
viewState = ViewSendState.ViewState.Loading,
dialogState = null,
baseWebSendUrl = environmentRepository.environment.environmentUrlData.baseWebSendUrl,
)
},
) {
init {
vaultRepository
.getSendStateFlow(sendId = state.sendId)
.map { ViewSendAction.Internal.SendDataReceive(it) }
.onEach(::sendAction)
.launchIn(viewModelScope)
}
override fun handleAction(action: ViewSendAction) {
when (action) {
ViewSendAction.CloseClick -> handleCloseClick()
ViewSendAction.CopyClick -> handleCopyClick()
ViewSendAction.DeleteClick -> handleDeleteClick()
ViewSendAction.DialogDismiss -> handleDialogDismiss()
ViewSendAction.EditClick -> handleEditClick()
ViewSendAction.ShareClick -> handleShareClick()
is ViewSendAction.Internal -> handleInternalAction(action)
}
}
private fun handleInternalAction(action: ViewSendAction.Internal) {
when (action) {
is ViewSendAction.Internal.SendDataReceive -> handleSendDataReceive(action)
is ViewSendAction.Internal.DeleteResultReceive -> handleDeleteResultReceive(action)
}
}
@ -41,9 +85,127 @@ class ViewSendViewModel @Inject constructor(
sendEvent(ViewSendEvent.NavigateBack)
}
private fun handleCopyClick() {
onContent { clipboardManager.setText(text = it.shareLink) }
}
private fun handleDeleteClick() {
mutableStateFlow.update {
it.copy(dialogState = ViewSendState.DialogState.Loading(R.string.deleting.asText()))
}
viewModelScope.launch {
val result = vaultRepository.deleteSend(sendId = state.sendId)
sendAction(ViewSendAction.Internal.DeleteResultReceive(result))
}
}
private fun handleDialogDismiss() {
mutableStateFlow.update { it.copy(dialogState = null) }
}
private fun handleEditClick() {
sendEvent(ViewSendEvent.NavigateToEdit(sendType = state.sendType, sendId = state.sendId))
}
private fun handleShareClick() {
onContent { sendEvent(ViewSendEvent.ShareText(text = it.shareLink.asText())) }
}
private fun handleDeleteResultReceive(
action: ViewSendAction.Internal.DeleteResultReceive,
) {
when (val result = action.result) {
is DeleteSendResult.Error -> {
mutableStateFlow.update {
it.copy(
dialogState = ViewSendState.DialogState.Error(
title = R.string.an_error_has_occurred.asText(),
message = R.string.generic_error_message.asText(),
throwable = result.error,
),
)
}
}
is DeleteSendResult.Success -> {
mutableStateFlow.update { it.copy(dialogState = null) }
sendEvent(ViewSendEvent.ShowToast(message = R.string.send_deleted.asText()))
sendEvent(ViewSendEvent.NavigateBack)
}
}
}
private fun handleSendDataReceive(action: ViewSendAction.Internal.SendDataReceive) {
when (val dataState = action.sendDataState) {
is DataState.Error -> sendErrorReceive(dataState = dataState)
is DataState.Loaded -> sendLoadedReceive(dataState = dataState)
is DataState.Loading -> sendLoadingReceive()
is DataState.NoNetwork -> sendNoNetworkReceive(dataState = dataState)
is DataState.Pending -> sendPendingReceive(dataState = dataState)
}
}
private fun sendLoadedReceive(dataState: DataState.Loaded<SendView?>) {
dataState
.data
?.let { updateStateWithSendView(sendView = it) }
?: updateStateWithErrorMessage(
message = R.string.missing_send_resync_your_vault.asText(),
)
}
private fun sendLoadingReceive() {
mutableStateFlow.update { it.copy(viewState = ViewSendState.ViewState.Loading) }
}
private fun sendErrorReceive(dataState: DataState.Error<SendView?>) {
dataState
.data
?.let { updateStateWithSendView(sendView = it) }
?: updateStateWithErrorMessage(message = R.string.generic_error_message.asText())
}
private fun sendNoNetworkReceive(dataState: DataState.NoNetwork<SendView?>) {
dataState
.data
?.let { updateStateWithSendView(sendView = it) }
?: updateStateWithErrorMessage(
message = R.string.internet_connection_required_title
.asText()
.concat(
" ".asText(),
R.string.internet_connection_required_message.asText(),
),
)
}
private fun sendPendingReceive(dataState: DataState.Pending<SendView?>) {
dataState
.data
?.let { updateStateWithSendView(sendView = it) }
?: updateStateWithErrorMessage(message = R.string.generic_error_message.asText())
}
private fun updateStateWithSendView(sendView: SendView) {
mutableStateFlow.update {
it.copy(
viewState = sendView.toViewSendViewStateContent(
baseWebSendUrl = it.baseWebSendUrl,
clock = clock,
),
)
}
}
private fun updateStateWithErrorMessage(message: Text) {
mutableStateFlow.update {
it.copy(viewState = ViewSendState.ViewState.Error(message = message))
}
}
private fun onContent(block: (ViewSendState.ViewState.Content) -> Unit) {
(state.viewState as? ViewSendState.ViewState.Content)?.let(block = block)
}
}
/**
@ -54,6 +216,8 @@ data class ViewSendState(
val sendType: SendItemType,
val sendId: String,
val viewState: ViewState,
val dialogState: DialogState?,
val baseWebSendUrl: String,
) : Parcelable {
/**
* Helper to determine the screen display name.
@ -89,7 +253,60 @@ data class ViewSendState(
* Represents a loaded content state for the view send screen.
*/
@Parcelize
data object Content : ViewState()
data class Content(
val sendType: SendType,
val shareLink: String,
val sendName: String,
val deletionDate: String,
val maxAccessCount: Int?,
val currentAccessCount: Int,
val notes: String?,
) : ViewState() {
/**
* Content data specific to a send type.
*/
sealed class SendType : Parcelable {
/**
* Content data specific to a file send type.
*/
@Parcelize
data class FileType(
val fileName: String,
val fileSize: String,
) : SendType()
/**
* Content data specific to a text send type.
*/
@Parcelize
data class TextType(
val textToShare: String,
) : SendType()
}
}
}
/**
* Represents a dialog displayed on the view send screen.
*/
sealed class DialogState : Parcelable {
/**
* Represents an error dialog.
*/
@Parcelize
data class Error(
val title: Text?,
val message: Text,
val throwable: Throwable? = null,
) : DialogState()
/**
* Represents a loading dialog.
*/
@Parcelize
data class Loading(
val message: Text,
) : DialogState()
}
}
@ -109,6 +326,20 @@ sealed class ViewSendEvent {
val sendType: SendItemType,
val sendId: String,
) : ViewSendEvent()
/**
* Shares the [text] via the share sheet.
*/
data class ShareText(
val text: Text,
) : ViewSendEvent()
/**
* Shows the [message] via a toast.
*/
data class ShowToast(
val message: Text,
) : ViewSendEvent(), BackgroundEvent
}
/**
@ -120,8 +351,43 @@ sealed class ViewSendAction {
*/
data object CloseClick : ViewSendAction()
/**
* The user has clicked the copy button.
*/
data object CopyClick : ViewSendAction()
/**
* The user has clicked the delete button.
*/
data object DeleteClick : ViewSendAction()
/**
* The user has dismissed the dialog.
*/
data object DialogDismiss : ViewSendAction()
/**
* The user has clicked the edit button.
*/
data object EditClick : ViewSendAction()
/**
* The user has clicked the share button.
*/
data object ShareClick : ViewSendAction()
/**
* Models actions that the ViewModel itself might send.
*/
sealed class Internal : ViewSendAction() {
/**
* Indicates a result for deleting the send has been received.
*/
data class DeleteResultReceive(val result: DeleteSendResult) : Internal()
/**
* Indicates that the send item data has been received.
*/
data class SendDataReceive(val sendDataState: DataState<SendView?>) : Internal()
}
}

View File

@ -0,0 +1,43 @@
package com.x8bit.bitwarden.ui.tools.feature.send.viewsend.util
import com.bitwarden.send.SendFileView
import com.bitwarden.send.SendTextView
import com.bitwarden.send.SendType
import com.bitwarden.send.SendView
import com.x8bit.bitwarden.ui.platform.util.toFormattedPattern
import com.x8bit.bitwarden.ui.tools.feature.send.util.toSendUrl
import com.x8bit.bitwarden.ui.tools.feature.send.viewsend.ViewSendState
import java.time.Clock
/**
* Transforms the given [SendView] to a [ViewSendState.ViewState.Content].
*/
fun SendView.toViewSendViewStateContent(
baseWebSendUrl: String,
clock: Clock,
): ViewSendState.ViewState.Content =
ViewSendState.ViewState.Content(
sendType = when (this.type) {
SendType.FILE -> requireNotNull(this.file).toFileType()
SendType.TEXT -> requireNotNull(this.text).toTextType()
},
shareLink = this.toSendUrl(baseWebSendUrl = baseWebSendUrl),
sendName = this.name,
deletionDate = this
.deletionDate
.toFormattedPattern(pattern = "d MMM, yyyy, h:mma", clock = clock),
maxAccessCount = this.maxAccessCount?.toInt(),
currentAccessCount = this.accessCount.toInt(),
notes = this.notes,
)
private fun SendFileView.toFileType(): ViewSendState.ViewState.Content.SendType.FileType =
ViewSendState.ViewState.Content.SendType.FileType(
fileName = this.fileName,
fileSize = this.sizeName.orEmpty(),
)
private fun SendTextView.toTextType(): ViewSendState.ViewState.Content.SendType.TextType =
ViewSendState.ViewState.Content.SendType.TextType(
textToShare = this.text.orEmpty(),
)

View File

@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="16dp"
android:height="16dp"
android:viewportWidth="16"
android:viewportHeight="16">
<path
android:fillColor="#ffffff"
android:fillType="evenOdd"
android:pathData="M5,3C5,1.895 5.895,1 7,1H13C14.105,1 15,1.895 15,3V9C15,10.105 14.105,11 13,11H11V13C11,14.105 10.105,15 9,15H3C1.895,15 1,14.105 1,13V7C1,5.895 1.895,5 3,5H5V3ZM7,2.5H13C13.276,2.5 13.5,2.724 13.5,3V9C13.5,9.276 13.276,9.5 13,9.5H7C6.724,9.5 6.5,9.276 6.5,9V3C6.5,2.724 6.724,2.5 7,2.5ZM5,6.5H3C2.724,6.5 2.5,6.724 2.5,7V13C2.5,13.276 2.724,13.5 3,13.5H9C9.276,13.5 9.5,13.276 9.5,13V11H7C5.895,11 5,10.105 5,9V6.5Z" />
</vector>

View File

@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="17dp"
android:height="16dp"
android:viewportWidth="17"
android:viewportHeight="16">
<path
android:fillColor="#175DDC"
android:fillType="evenOdd"
android:pathData="M10.237,5.97,7.434,7.372c.043.202.066.412.066.628,0,.216-.023.426-.066.628l2.803,1.402C10.787,9.399,11.597,9,12.5,9c1.657,0,3,1.343,3,3s-1.343,3-3,3-3-1.343-3-3c0-.216.023-.426.066-.628L6.763,9.97C6.213,10.601,5.403,11,4.5,11c-1.657,0-3-1.343-3-3s1.343-3,3-3c.903,0,1.713.399,2.263,1.03L9.566,4.628C9.523,4.426,9.5,4.216,9.5,4c0-1.657,1.343-3,3-3s3,1.343,3,3-1.343,3-3,3c-.903,0-1.713-.399-2.263-1.03ZM12.5,5.5c.828,0,1.5-.672,1.5-1.5,0-.828-.672-1.5-1.5-1.5S11,3.172,11,4c0,.199.039.388.109.562.023.032.044.066.062.103.018.036.033.073.045.11.263.435.739.725,1.284.725ZM5.784,7.225C5.522,6.79,5.045,6.5,4.5,6.5,3.672,6.5,3,7.172,3,8s.672,1.5,1.5,1.5c.545,0,1.022-.29,1.284-.725.012-.037.027-.074.045-.11.018-.037.039-.071.062-.103C5.961,8.388,6,8.199,6,8s-.039-.388-.109-.562c-.023-.032-.044-.066-.062-.103-.018-.036-.033-.073-.045-.11ZM11,12c0-.199.039-.388.109-.562.023-.032.044-.066.062-.103.018-.036.033-.073.045-.11.263-.435.739-.725,1.284-.725.828,0,1.5.672,1.5,1.5s-.672,1.5-1.5,1.5S11,12.828,11,12Z" />
</vector>

View File

@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="16dp"
android:height="16dp"
android:viewportWidth="16"
android:viewportHeight="16">
<path
android:fillColor="#CB263A"
android:fillType="evenOdd"
android:pathData="M6.471,0C5.718,0 5.049,0.482 4.811,1.197L4.376,2.5H1.75C1.336,2.5 1,2.836 1,3.25C1,3.664 1.336,4 1.75,4H2C2,4.022 2.001,4.044 2.003,4.066L2.841,13.493C2.967,14.912 4.156,16 5.58,16H10.42C11.844,16 13.033,14.912 13.159,13.493L13.997,4.066C13.999,4.044 14,4.022 14,4H14.25C14.664,4 15,3.664 15,3.25C15,2.836 14.664,2.5 14.25,2.5H11.624L11.189,1.197C10.951,0.482 10.283,0 9.529,0H6.471ZM10.043,2.5L9.766,1.671C9.732,1.569 9.637,1.5 9.529,1.5L6.471,1.5C6.363,1.5 6.268,1.569 6.234,1.671L5.957,2.5H10.043ZM3.503,4H12.497L11.665,13.361C11.608,14.006 11.067,14.5 10.42,14.5H5.58C4.933,14.5 4.392,14.006 4.335,13.361L3.503,4Z" />
</vector>

View File

@ -627,7 +627,7 @@ Scanning will happen automatically.</string>
<string name="maximum_access_count">Maximum access count</string>
<string name="maximum_access_count_info">If set, users will no longer be able to access this Send once the maximum access count is reached.</string>
<string name="maximum_access_count_reached">Max access count reached</string>
<string name="current_access_count">Current access count</string>
<string name="current_access_count">Current access count: %s</string>
<string name="new_password">New password</string>
<string name="password_info">Require this password to view the Send.</string>
<string name="remove_password">Remove password</string>
@ -1268,4 +1268,6 @@ Do you want to switch to this account?</string>
<string name="this_account_will_soon_be_deleted_log_in_at_x_to_continue_using_bitwarden">This account will soon be deleted. Log in at %1$s to continue using Bitwarden.</string>
<string name="view_file_send">View file Send</string>
<string name="view_text_send">View text Send</string>
<string name="delete_send">Delete send</string>
<string name="missing_send_resync_your_vault">Missing Send re-sync your vault</string>
</resources>

View File

@ -4,51 +4,79 @@ import com.bitwarden.send.SendFileView
import com.bitwarden.send.SendTextView
import com.bitwarden.send.SendType
import com.bitwarden.send.SendView
import java.time.Instant
import java.time.ZonedDateTime
/**
* Create a mock [SendView] with a given [number].
*/
@Suppress("LongParameterList")
fun createMockSendView(
number: Int,
id: String? = "mockId-$number",
accessId: String? = "mockAccessId-$number",
name: String = "mockName-$number",
notes: String? = "mockNotes-$number",
key: String = "mockKey-$number",
newPassword: String? = "mockPassword-$number",
hasPassword: Boolean = true,
type: SendType = SendType.FILE,
file: SendFileView = createMockFileView(number = number),
text: SendTextView = createMockTextView(number = number),
maxAccessCount: UInt? = 1U,
accessCount: UInt = 1U,
disabled: Boolean = false,
hideEmail: Boolean = false,
revisionDate: Instant = ZonedDateTime.parse("2023-10-27T12:00:00Z").toInstant(),
deletionDate: Instant = ZonedDateTime.parse("2023-10-27T12:00:00Z").toInstant(),
expirationDate: Instant? = ZonedDateTime.parse("2023-10-27T12:00:00Z").toInstant(),
): SendView =
SendView(
id = "mockId-$number",
accessId = "mockAccessId-$number",
name = "mockName-$number",
notes = "mockNotes-$number",
key = "mockKey-$number",
newPassword = "mockPassword-$number",
hasPassword = true,
id = id,
accessId = accessId,
name = name,
notes = notes,
key = key,
newPassword = newPassword,
hasPassword = hasPassword,
type = type,
file = createMockFileView(number = number).takeIf { type == SendType.FILE },
text = createMockTextView(number = number).takeIf { type == SendType.TEXT },
maxAccessCount = 1u,
accessCount = 1u,
disabled = false,
hideEmail = false,
revisionDate = ZonedDateTime.parse("2023-10-27T12:00:00Z").toInstant(),
deletionDate = ZonedDateTime.parse("2023-10-27T12:00:00Z").toInstant(),
expirationDate = ZonedDateTime.parse("2023-10-27T12:00:00Z").toInstant(),
file = file.takeIf { type == SendType.FILE },
text = text.takeIf { type == SendType.TEXT },
maxAccessCount = maxAccessCount,
accessCount = accessCount,
disabled = disabled,
hideEmail = hideEmail,
revisionDate = revisionDate,
deletionDate = deletionDate,
expirationDate = expirationDate,
)
/**
* Create a mock [SendFileView] with a given [number].
*/
fun createMockFileView(number: Int): SendFileView =
fun createMockFileView(
number: Int,
id: String? = "mockId-$number",
fileName: String = "mockFileName-$number",
size: String? = "1",
sizeName: String? = "mockSizeName-$number",
): SendFileView =
SendFileView(
fileName = "mockFileName-$number",
size = "1",
sizeName = "mockSizeName-$number",
id = "mockId-$number",
id = id,
fileName = fileName,
size = size,
sizeName = sizeName,
)
/**
* Create a mock [SendTextView] with a given [number].
*/
fun createMockTextView(number: Int): SendTextView =
fun createMockTextView(
number: Int,
text: String? = "mockText-$number",
hidden: Boolean = false,
): SendTextView =
SendTextView(
hidden = false,
text = "mockText-$number",
text = text,
hidden = hidden,
)

View File

@ -1,23 +1,34 @@
package com.x8bit.bitwarden.ui.tools.feature.send.viewsend
import android.widget.Toast
import androidx.compose.ui.test.assert
import androidx.compose.ui.test.assertCountEquals
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.assertIsNotDisplayed
import androidx.compose.ui.test.hasAnyAncestor
import androidx.compose.ui.test.isDialog
import androidx.compose.ui.test.isPopup
import androidx.compose.ui.test.onNodeWithContentDescription
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import androidx.compose.ui.test.performScrollTo
import com.bitwarden.core.data.repository.util.bufferedMutableSharedFlow
import com.bitwarden.ui.util.asText
import com.x8bit.bitwarden.ui.platform.base.BaseComposeTest
import com.x8bit.bitwarden.ui.platform.manager.intent.IntentManager
import com.x8bit.bitwarden.ui.tools.feature.send.model.SendItemType
import com.x8bit.bitwarden.ui.util.assertNoDialogExists
import com.x8bit.bitwarden.ui.util.isProgressBar
import io.mockk.every
import io.mockk.just
import io.mockk.mockk
import io.mockk.mockkStatic
import io.mockk.runs
import io.mockk.unmockkStatic
import io.mockk.verify
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.update
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Before
@ -34,9 +45,16 @@ class ViewSendScreenTest : BaseComposeTest() {
every { trySendAction(action = any()) } just runs
}
private var intentManager = mockk<IntentManager> {
every { shareText(any()) } just runs
}
@Before
fun setup() {
setContent {
mockkStatic(Toast::class)
setContent(
intentManager = intentManager,
) {
ViewSendScreen(
viewModel = viewModel,
onNavigateBack = { onNavigateBackCalled = true },
@ -45,6 +63,11 @@ class ViewSendScreenTest : BaseComposeTest() {
}
}
@After
fun tearDown() {
unmockkStatic(Toast::class)
}
@Test
fun `on NavigateBack event should call onNavigateBack`() {
mutableEventFlow.tryEmit(ViewSendEvent.NavigateBack)
@ -69,6 +92,50 @@ class ViewSendScreenTest : BaseComposeTest() {
}
}
@Test
fun `on ShareText event should call IntentManager ShareText`() {
val text = "Shared Stuff"
mutableEventFlow.tryEmit(ViewSendEvent.ShareText(text = text.asText()))
verify(exactly = 1) {
intentManager.shareText(text = text)
}
}
@Test
fun `on ShowToast event should call onNavigateToEdit`() {
val message = "message"
val toast = mockk<Toast> {
every { show() } just runs
}
every { Toast.makeText(any(), message, Toast.LENGTH_SHORT) } returns toast
mutableEventFlow.tryEmit(ViewSendEvent.ShowToast(message = message.asText()))
verify(exactly = 1) {
toast.show()
}
}
@Test
fun `on copy click should send CopyClick`() {
composeTestRule
.onNodeWithText(text = "Copy")
.performScrollTo()
.performClick()
verify(exactly = 1) {
viewModel.trySendAction(ViewSendAction.CopyClick)
}
}
@Test
fun `on delete click should send DeleteClick`() {
composeTestRule
.onNodeWithText(text = "Delete send")
.performScrollTo()
.performClick()
verify(exactly = 1) {
viewModel.trySendAction(ViewSendAction.DeleteClick)
}
}
@Test
fun `on edit click should send EditClick`() {
composeTestRule
@ -79,6 +146,17 @@ class ViewSendScreenTest : BaseComposeTest() {
}
}
@Test
fun `on app bar title should updated based on state`() {
mutableStateFlow.update { it.copy(sendType = SendItemType.TEXT) }
composeTestRule.onNodeWithText(text = "View file Send").assertDoesNotExist()
composeTestRule.onNodeWithText(text = "View text Send").assertIsDisplayed()
mutableStateFlow.update { it.copy(sendType = SendItemType.FILE) }
composeTestRule.onNodeWithText(text = "View file Send").assertIsDisplayed()
composeTestRule.onNodeWithText(text = "View text Send").assertDoesNotExist()
}
@Test
fun `progress bar should be displayed based on ViewState`() {
mutableStateFlow.update { it.copy(viewState = ViewSendState.ViewState.Loading) }
@ -101,10 +179,158 @@ class ViewSendScreenTest : BaseComposeTest() {
mutableStateFlow.update { it.copy(viewState = DEFAULT_STATE.viewState) }
composeTestRule.onNodeWithText(text = errorMessage).assertIsNotDisplayed()
}
@Test
fun `file type content should be displayed based on ViewState`() {
mutableStateFlow.update { it.copy(viewState = ViewSendState.ViewState.Loading) }
composeTestRule.onNodeWithText(text = "share_link").assertDoesNotExist()
composeTestRule.onNodeWithText(text = "text_to_share").assertDoesNotExist()
composeTestRule.onNodeWithText(text = "send_name").assertDoesNotExist()
composeTestRule.onNodeWithText(text = "deletion_date").assertDoesNotExist()
mutableStateFlow.update {
it.copy(
viewState = DEFAULT_CONTENT_VIEW_STATE.copy(
sendType = ViewSendState.ViewState.Content.SendType.TextType(
textToShare = "text_to_share",
),
),
)
}
composeTestRule
.onNodeWithText(text = "share_link")
.performScrollTo()
.assertIsDisplayed()
composeTestRule
.onNodeWithText(text = "text_to_share")
.performScrollTo()
.assertIsDisplayed()
composeTestRule
.onNodeWithText(text = "send_name")
.performScrollTo()
.assertIsDisplayed()
composeTestRule
.onNodeWithText(text = "deletion_date")
.performScrollTo()
.assertIsDisplayed()
}
@Test
fun `text type content should be displayed based on ViewState`() {
mutableStateFlow.update { it.copy(viewState = ViewSendState.ViewState.Loading) }
composeTestRule.onNodeWithText(text = "file_name").assertDoesNotExist()
composeTestRule.onNodeWithText(text = "file_size").assertDoesNotExist()
composeTestRule.onNodeWithText(text = "send_name").assertDoesNotExist()
composeTestRule.onNodeWithText(text = "deletion_date").assertDoesNotExist()
mutableStateFlow.update {
it.copy(
viewState = DEFAULT_CONTENT_VIEW_STATE.copy(
sendType = ViewSendState.ViewState.Content.SendType.FileType(
fileName = "file_name",
fileSize = "file_size",
),
),
)
}
composeTestRule
.onNodeWithText(text = "share_link")
.performScrollTo()
.assertIsDisplayed()
composeTestRule
.onNodeWithText(text = "file_name")
.performScrollTo()
.assertIsDisplayed()
composeTestRule
.onNodeWithText(text = "file_size")
.performScrollTo()
.assertIsDisplayed()
composeTestRule
.onNodeWithText(text = "send_name")
.performScrollTo()
.assertIsDisplayed()
composeTestRule
.onNodeWithText(text = "deletion_date")
.performScrollTo()
.assertIsDisplayed()
}
@Test
fun `additional options should reveal themselves when clicked`() {
composeTestRule.onNodeWithText(text = "Maximum access count").assertDoesNotExist()
composeTestRule.onNodeWithText(text = "Current access count: 1").assertDoesNotExist()
composeTestRule.onNodeWithText(text = "Private notes").assertDoesNotExist()
composeTestRule
.onNodeWithText(text = "Additional options")
.performScrollTo()
.performClick()
composeTestRule
.onNodeWithText(text = "Maximum access count")
.performScrollTo()
.assertIsDisplayed()
composeTestRule
.onNodeWithText(text = "Current access count: 1")
.performScrollTo()
.assertIsDisplayed()
composeTestRule
.onNodeWithText(text = "Private notes")
.performScrollTo()
.assertIsDisplayed()
}
@Test
fun `dialog should be displayed based on ViewState`() {
composeTestRule.assertNoDialogExists()
val errorMessage = "Fail!"
mutableStateFlow.update {
it.copy(
dialogState = ViewSendState.DialogState.Error(
title = null,
message = errorMessage.asText(),
),
)
}
composeTestRule
.onNodeWithText(text = errorMessage)
.assert(hasAnyAncestor(isDialog()))
.assertIsDisplayed()
val loadingMessage = "Loading!"
mutableStateFlow.update {
it.copy(
dialogState = ViewSendState.DialogState.Loading(
message = loadingMessage.asText(),
),
)
}
composeTestRule
.onNodeWithText(text = loadingMessage)
.assert(hasAnyAncestor(isPopup()))
.assertIsDisplayed()
mutableStateFlow.update { it.copy(dialogState = null) }
composeTestRule.assertNoDialogExists()
}
}
private val DEFAULT_CONTENT_VIEW_STATE = ViewSendState.ViewState.Content(
sendType = ViewSendState.ViewState.Content.SendType.TextType(
textToShare = "text_to_share",
),
shareLink = "share_link",
sendName = "send_name",
deletionDate = "deletion_date",
maxAccessCount = 1,
currentAccessCount = 1,
notes = "notes",
)
private val DEFAULT_STATE = ViewSendState(
sendType = SendItemType.TEXT,
sendId = "send_id",
viewState = ViewSendState.ViewState.Content,
viewState = DEFAULT_CONTENT_VIEW_STATE,
dialogState = null,
baseWebSendUrl = "https://send.bitwarden.com/#",
)

View File

@ -2,22 +2,56 @@ package com.x8bit.bitwarden.ui.tools.feature.send.viewsend
import androidx.lifecycle.SavedStateHandle
import app.cash.turbine.test
import com.bitwarden.core.data.repository.model.DataState
import com.bitwarden.data.repository.model.Environment
import com.bitwarden.send.SendView
import com.bitwarden.ui.platform.base.BaseViewModelTest
import com.bitwarden.ui.util.asText
import com.bitwarden.ui.util.concat
import com.x8bit.bitwarden.R
import com.x8bit.bitwarden.data.platform.manager.clipboard.BitwardenClipboardManager
import com.x8bit.bitwarden.data.platform.repository.EnvironmentRepository
import com.x8bit.bitwarden.data.vault.datasource.sdk.model.createMockSendView
import com.x8bit.bitwarden.data.vault.repository.VaultRepository
import com.x8bit.bitwarden.data.vault.repository.model.DeleteSendResult
import com.x8bit.bitwarden.ui.tools.feature.send.model.SendItemType
import com.x8bit.bitwarden.ui.tools.feature.send.viewsend.util.toViewSendViewStateContent
import io.mockk.coEvery
import io.mockk.every
import io.mockk.just
import io.mockk.mockk
import io.mockk.mockkStatic
import io.mockk.runs
import io.mockk.unmockkStatic
import io.mockk.verify
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.test.runTest
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.Clock
import java.time.Instant
import java.time.ZoneOffset
class ViewSendViewModelTest : BaseViewModelTest() {
private val clipboardManager = mockk<BitwardenClipboardManager> {
every { setText(text = any<String>()) } just runs
}
private val mutableSendStateFlow = MutableStateFlow<DataState<SendView?>>(DataState.Loading)
private val vaultRepository = mockk<VaultRepository> {
every { getSendStateFlow(sendId = any()) } returns mutableSendStateFlow
}
private val environmentRepository = mockk<EnvironmentRepository> {
every { environment } returns Environment.Us
}
@BeforeEach
fun setup() {
mockkStatic(
SavedStateHandle::toViewSendArgs,
SendView::toViewSendViewStateContent,
)
}
@ -25,6 +59,7 @@ class ViewSendViewModelTest : BaseViewModelTest() {
fun tearDown() {
unmockkStatic(
SavedStateHandle::toViewSendArgs,
SendView::toViewSendViewStateContent,
)
}
@ -46,6 +81,111 @@ class ViewSendViewModelTest : BaseViewModelTest() {
}
}
@Test
fun `on CopyClick should call setText on ClipboardManger`() {
val viewModel = createViewModel()
val sendView = createMockSendView(number = 1)
every {
sendView.toViewSendViewStateContent(baseWebSendUrl = any(), clock = FIXED_CLOCK)
} returns DEFAULT_CONTENT_VIEW_STATE
mutableSendStateFlow.value = DataState.Loaded(data = sendView)
viewModel.trySendAction(ViewSendAction.CopyClick)
verify(exactly = 1) {
clipboardManager.setText(text = "share_link")
}
}
@Test
fun `on DeleteClick with failure should display error dialog`() = runTest {
val initialState = DEFAULT_STATE.copy(viewState = DEFAULT_CONTENT_VIEW_STATE)
val sendView = createMockSendView(number = 1)
every {
sendView.toViewSendViewStateContent(baseWebSendUrl = any(), clock = FIXED_CLOCK)
} returns DEFAULT_CONTENT_VIEW_STATE
val throwable = Throwable("Fail!")
coEvery {
vaultRepository.deleteSend(sendId = "send_id")
} returns DeleteSendResult.Error(error = throwable)
mutableSendStateFlow.value = DataState.Loaded(data = sendView)
val viewModel = createViewModel(state = initialState)
viewModel.stateFlow.test {
assertEquals(initialState, awaitItem())
viewModel.trySendAction(ViewSendAction.DeleteClick)
assertEquals(
initialState.copy(
dialogState = ViewSendState.DialogState.Loading(
message = R.string.deleting.asText(),
),
),
awaitItem(),
)
assertEquals(
initialState.copy(
dialogState = ViewSendState.DialogState.Error(
title = R.string.an_error_has_occurred.asText(),
message = R.string.generic_error_message.asText(),
throwable = throwable,
),
),
awaitItem(),
)
}
}
@Test
fun `on DeleteClick with success should display toast`() = runTest {
val initialState = DEFAULT_STATE.copy(viewState = DEFAULT_CONTENT_VIEW_STATE)
val sendView = createMockSendView(number = 1)
every {
sendView.toViewSendViewStateContent(baseWebSendUrl = any(), clock = FIXED_CLOCK)
} returns DEFAULT_CONTENT_VIEW_STATE
coEvery { vaultRepository.deleteSend(sendId = "send_id") } returns DeleteSendResult.Success
mutableSendStateFlow.value = DataState.Loaded(data = sendView)
val viewModel = createViewModel(state = initialState)
viewModel.stateEventFlow(backgroundScope) { stateFlow, eventFLow ->
assertEquals(initialState, stateFlow.awaitItem())
viewModel.trySendAction(ViewSendAction.DeleteClick)
assertEquals(
initialState.copy(
dialogState = ViewSendState.DialogState.Loading(
message = R.string.deleting.asText(),
),
),
stateFlow.awaitItem(),
)
assertEquals(
initialState.copy(dialogState = null),
stateFlow.awaitItem(),
)
assertEquals(
ViewSendEvent.ShowToast(message = R.string.send_deleted.asText()),
eventFLow.awaitItem(),
)
assertEquals(
ViewSendEvent.NavigateBack,
eventFLow.awaitItem(),
)
}
}
@Test
fun `on DialogDismiss should send clear the dialogState`() = runTest {
val initialState = DEFAULT_STATE.copy(
dialogState = ViewSendState.DialogState.Loading(message = "Loading".asText()),
)
val viewModel = createViewModel(state = initialState)
viewModel.stateFlow.test {
assertEquals(initialState, awaitItem())
viewModel.trySendAction(ViewSendAction.DialogDismiss)
assertEquals(initialState.copy(dialogState = null), awaitItem())
}
}
@Test
fun `on EditClick should send NavigateToEdit`() = runTest {
val viewModel = createViewModel()
@ -61,10 +201,190 @@ class ViewSendViewModelTest : BaseViewModelTest() {
}
}
@Test
fun `on ShareClick should send ShareText`() = runTest {
val viewModel = createViewModel()
val sendView = createMockSendView(number = 1)
every {
sendView.toViewSendViewStateContent(baseWebSendUrl = any(), clock = FIXED_CLOCK)
} returns DEFAULT_CONTENT_VIEW_STATE
mutableSendStateFlow.value = DataState.Loaded(data = sendView)
viewModel.eventFlow.test {
viewModel.trySendAction(ViewSendAction.ShareClick)
assertEquals(
ViewSendEvent.ShareText(text = "share_link".asText()),
awaitItem(),
)
}
}
@Test
fun `on SendDataReceive with loading should set viewState to loading`() = runTest {
val viewModel = createViewModel()
val sendView = createMockSendView(number = 1)
every {
sendView.toViewSendViewStateContent(baseWebSendUrl = any(), clock = FIXED_CLOCK)
} returns DEFAULT_CONTENT_VIEW_STATE
viewModel.stateFlow.test {
assertEquals(DEFAULT_STATE, awaitItem())
mutableSendStateFlow.value = DataState.Loading
expectNoEvents()
}
}
@Test
fun `on SendDataReceive with error and no data should set viewState to error`() = runTest {
val viewModel = createViewModel()
viewModel.stateFlow.test {
assertEquals(DEFAULT_STATE, awaitItem())
mutableSendStateFlow.value = DataState.Error(error = Throwable("Fail!"))
assertEquals(
DEFAULT_STATE.copy(
viewState = ViewSendState.ViewState.Error(
message = R.string.generic_error_message.asText(),
),
),
awaitItem(),
)
}
}
@Test
fun `on SendDataReceive with error and data should set viewState to content`() = runTest {
val viewModel = createViewModel()
val sendView = createMockSendView(number = 1)
every {
sendView.toViewSendViewStateContent(baseWebSendUrl = any(), clock = FIXED_CLOCK)
} returns DEFAULT_CONTENT_VIEW_STATE
viewModel.stateFlow.test {
assertEquals(DEFAULT_STATE, awaitItem())
mutableSendStateFlow.value = DataState.Error(
error = Throwable("Fail!"),
data = sendView,
)
assertEquals(
DEFAULT_STATE.copy(viewState = DEFAULT_CONTENT_VIEW_STATE),
awaitItem(),
)
}
}
@Test
fun `on SendDataReceive with no network and no data should set viewState to error`() = runTest {
val viewModel = createViewModel()
viewModel.stateFlow.test {
assertEquals(DEFAULT_STATE, awaitItem())
mutableSendStateFlow.value = DataState.NoNetwork()
assertEquals(
DEFAULT_STATE.copy(
viewState = ViewSendState.ViewState.Error(
message = R.string.internet_connection_required_title
.asText()
.concat(
" ".asText(),
R.string.internet_connection_required_message.asText(),
),
),
),
awaitItem(),
)
}
}
@Test
fun `on SendDataReceive with no network and data should set viewState to content`() = runTest {
val viewModel = createViewModel()
val sendView = createMockSendView(number = 1)
every {
sendView.toViewSendViewStateContent(baseWebSendUrl = any(), clock = FIXED_CLOCK)
} returns DEFAULT_CONTENT_VIEW_STATE
viewModel.stateFlow.test {
assertEquals(DEFAULT_STATE, awaitItem())
mutableSendStateFlow.value = DataState.NoNetwork(data = sendView)
assertEquals(
DEFAULT_STATE.copy(viewState = DEFAULT_CONTENT_VIEW_STATE),
awaitItem(),
)
}
}
@Test
fun `on SendDataReceive with pending and no data should set viewState to error`() = runTest {
val viewModel = createViewModel()
viewModel.stateFlow.test {
assertEquals(DEFAULT_STATE, awaitItem())
mutableSendStateFlow.value = DataState.Pending(data = null)
assertEquals(
DEFAULT_STATE.copy(
viewState = ViewSendState.ViewState.Error(
message = R.string.generic_error_message.asText(),
),
),
awaitItem(),
)
}
}
@Test
fun `on SendDataReceive with pending and data should set viewState to content`() = runTest {
val viewModel = createViewModel()
val sendView = createMockSendView(number = 1)
every {
sendView.toViewSendViewStateContent(baseWebSendUrl = any(), clock = FIXED_CLOCK)
} returns DEFAULT_CONTENT_VIEW_STATE
viewModel.stateFlow.test {
assertEquals(DEFAULT_STATE, awaitItem())
mutableSendStateFlow.value = DataState.Pending(data = sendView)
assertEquals(
DEFAULT_STATE.copy(viewState = DEFAULT_CONTENT_VIEW_STATE),
awaitItem(),
)
}
}
@Test
fun `on SendDataReceive with loaded and no data should set viewState to error`() = runTest {
val viewModel = createViewModel()
viewModel.stateFlow.test {
assertEquals(DEFAULT_STATE, awaitItem())
mutableSendStateFlow.value = DataState.Loaded(data = null)
assertEquals(
DEFAULT_STATE.copy(
viewState = ViewSendState.ViewState.Error(
message = R.string.missing_send_resync_your_vault.asText(),
),
),
awaitItem(),
)
}
}
@Test
fun `on SendDataReceive with loaded and data should set viewState to content`() = runTest {
val viewModel = createViewModel()
val sendView = createMockSendView(number = 1)
every {
sendView.toViewSendViewStateContent(baseWebSendUrl = any(), clock = FIXED_CLOCK)
} returns DEFAULT_CONTENT_VIEW_STATE
viewModel.stateFlow.test {
assertEquals(DEFAULT_STATE, awaitItem())
mutableSendStateFlow.value = DataState.Loaded(data = sendView)
assertEquals(
DEFAULT_STATE.copy(viewState = DEFAULT_CONTENT_VIEW_STATE),
awaitItem(),
)
}
}
private fun createViewModel(
state: ViewSendState? = null,
): ViewSendViewModel = ViewSendViewModel(
savedStateHandle = SavedStateHandle().apply {
clipboardManager = clipboardManager,
clock = FIXED_CLOCK,
vaultRepository = vaultRepository,
environmentRepository = environmentRepository,
savedStateHandle = SavedStateHandle().apply
{
set(key = "state", value = state)
every { toViewSendArgs() } returns ViewSendArgs(
sendId = (state ?: DEFAULT_STATE).sendId,
@ -74,8 +394,27 @@ class ViewSendViewModelTest : BaseViewModelTest() {
)
}
private val DEFAULT_CONTENT_VIEW_STATE = ViewSendState.ViewState.Content(
sendType = ViewSendState.ViewState.Content.SendType.TextType(
textToShare = "text_to_share",
),
shareLink = "share_link",
sendName = "send_name",
deletionDate = "deletion_date",
maxAccessCount = 1,
currentAccessCount = 1,
notes = "notes",
)
private val DEFAULT_STATE = ViewSendState(
sendType = SendItemType.TEXT,
sendId = "send_id",
viewState = ViewSendState.ViewState.Content,
viewState = ViewSendState.ViewState.Loading,
dialogState = null,
baseWebSendUrl = "https://send.bitwarden.com/#",
)
private val FIXED_CLOCK: Clock = Clock.fixed(
Instant.parse("2023-10-27T12:00:00Z"),
ZoneOffset.UTC,
)

View File

@ -0,0 +1,92 @@
package com.x8bit.bitwarden.ui.tools.feature.send.viewsend.util
import com.bitwarden.send.SendType
import com.bitwarden.send.SendView
import com.x8bit.bitwarden.data.vault.datasource.sdk.model.createMockSendView
import com.x8bit.bitwarden.ui.tools.feature.send.util.toSendUrl
import com.x8bit.bitwarden.ui.tools.feature.send.viewsend.ViewSendState
import io.mockk.every
import io.mockk.mockkStatic
import io.mockk.unmockkStatic
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.Clock
import java.time.Instant
import java.time.ZoneOffset
class SendViewExtensionsTest {
@BeforeEach
fun setup() {
mockkStatic(SendView::toSendUrl)
}
@AfterEach
fun tearDown() {
unmockkStatic(SendView::toSendUrl)
}
@Test
fun `toViewSendViewStateContent should create content with file data`() {
val fileSendView = createMockSendView(number = 1, type = SendType.FILE)
val baseWebSendUrl = "https://send.bitwarden.com/#"
val sendUrl = "send_url"
every { fileSendView.toSendUrl(baseWebSendUrl = baseWebSendUrl) } returns sendUrl
val result = fileSendView.toViewSendViewStateContent(
baseWebSendUrl = baseWebSendUrl,
clock = FIXED_CLOCK,
)
assertEquals(
ViewSendState.ViewState.Content(
sendType = ViewSendState.ViewState.Content.SendType.FileType(
fileName = "mockFileName-1",
fileSize = "mockSizeName-1",
),
shareLink = sendUrl,
sendName = "mockName-1",
deletionDate = "27 Oct, 2023, 12:00PM",
maxAccessCount = 1,
currentAccessCount = 1,
notes = "mockNotes-1",
),
result,
)
}
@Test
fun `toViewSendViewStateContent should create content with text data`() {
val fileSendView = createMockSendView(number = 2, type = SendType.TEXT)
val baseWebSendUrl = "https://send.bitwarden.com/#"
val sendUrl = "send_url"
every { fileSendView.toSendUrl(baseWebSendUrl = baseWebSendUrl) } returns sendUrl
val result = fileSendView.toViewSendViewStateContent(
baseWebSendUrl = baseWebSendUrl,
clock = FIXED_CLOCK,
)
assertEquals(
ViewSendState.ViewState.Content(
sendType = ViewSendState.ViewState.Content.SendType.TextType(
textToShare = "mockText-2",
),
shareLink = sendUrl,
sendName = "mockName-2",
deletionDate = "27 Oct, 2023, 12:00PM",
maxAccessCount = 1,
currentAccessCount = 1,
notes = "mockNotes-2",
),
result,
)
}
}
private val FIXED_CLOCK: Clock = Clock.fixed(
Instant.parse("2023-10-27T12:00:00Z"),
ZoneOffset.UTC,
)