From 9e6c49fb7cd0c315cefee3cf9573fce29e52eeb9 Mon Sep 17 00:00:00 2001 From: David Perez Date: Thu, 11 Jan 2024 15:44:47 -0600 Subject: [PATCH] Modify Add Sends UI to allow for editing existing sends (#575) --- .../feature/send/addsend/AddSendContent.kt | 190 ++++++++++++++---- .../feature/send/addsend/AddSendScreen.kt | 1 + .../feature/send/addsend/AddSendViewModel.kt | 176 +++++++++++++++- .../send/addsend/handlers/AddSendHandlers.kt | 4 + .../send/addsend/util/SendViewExtensions.kt | 42 ++++ .../feature/send/addsend/AddSendScreenTest.kt | 100 +++++++++ .../send/addsend/AddSendViewModelTest.kt | 133 +++++++++++- .../util/AddSendStateExtensionsTest.kt | 2 + .../addsend/util/SendViewExtensionsTest.kt | 78 +++++++ .../bitwarden/ui/util/ComposeTestHelpers.kt | 6 + 10 files changed, 677 insertions(+), 55 deletions(-) create mode 100644 app/src/main/java/com/x8bit/bitwarden/ui/tools/feature/send/addsend/util/SendViewExtensions.kt create mode 100644 app/src/test/java/com/x8bit/bitwarden/ui/tools/feature/send/addsend/util/SendViewExtensionsTest.kt diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/tools/feature/send/addsend/AddSendContent.kt b/app/src/main/java/com/x8bit/bitwarden/ui/tools/feature/send/addsend/AddSendContent.kt index 94a752a67..5adf61c82 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/tools/feature/send/addsend/AddSendContent.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/tools/feature/send/addsend/AddSendContent.kt @@ -13,6 +13,8 @@ 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.layout.wrapContentWidth import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll import androidx.compose.material3.Icon @@ -36,9 +38,11 @@ import com.x8bit.bitwarden.ui.platform.components.BitwardenListHeaderText import com.x8bit.bitwarden.ui.platform.components.BitwardenPasswordField import com.x8bit.bitwarden.ui.platform.components.BitwardenSegmentedButton import com.x8bit.bitwarden.ui.platform.components.BitwardenStepper +import com.x8bit.bitwarden.ui.platform.components.BitwardenTextButton import com.x8bit.bitwarden.ui.platform.components.BitwardenTextField import com.x8bit.bitwarden.ui.platform.components.BitwardenWideSwitch import com.x8bit.bitwarden.ui.platform.components.SegmentedButtonState +import com.x8bit.bitwarden.ui.platform.theme.LocalNonMaterialTypography import com.x8bit.bitwarden.ui.tools.feature.send.addsend.handlers.AddSendHandlers /** @@ -48,6 +52,7 @@ import com.x8bit.bitwarden.ui.tools.feature.send.addsend.handlers.AddSendHandler @Composable fun AddSendContent( state: AddSendState.ViewState.Content, + isAddMode: Boolean, addSendHandlers: AddSendHandlers, modifier: Modifier = Modifier, ) { @@ -65,34 +70,36 @@ fun AddSendContent( onValueChange = addSendHandlers.onNamChange, ) - Spacer(modifier = Modifier.height(16.dp)) - BitwardenListHeaderText( - label = stringResource(id = R.string.type), - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp), - ) + if (isAddMode) { + Spacer(modifier = Modifier.height(16.dp)) + BitwardenListHeaderText( + label = stringResource(id = R.string.type), + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + ) - Spacer(modifier = Modifier.height(16.dp)) - BitwardenSegmentedButton( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp), - options = listOf( - SegmentedButtonState( - text = stringResource(id = R.string.file), - onClick = addSendHandlers.onFileTypeSelect, - isChecked = state.selectedType is AddSendState.ViewState.Content.SendType.File, + Spacer(modifier = Modifier.height(16.dp)) + BitwardenSegmentedButton( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + options = listOf( + SegmentedButtonState( + text = stringResource(id = R.string.file), + onClick = addSendHandlers.onFileTypeSelect, + isChecked = state.isFileType, + ), + SegmentedButtonState( + text = stringResource(id = R.string.text), + onClick = addSendHandlers.onTextTypeSelect, + isChecked = state.isTextType, + ), ), - SegmentedButtonState( - text = stringResource(id = R.string.text), - onClick = addSendHandlers.onTextTypeSelect, - isChecked = state.selectedType is AddSendState.ViewState.Content.SendType.Text, - ), - ), - ) + ) + } - Spacer(modifier = Modifier.height(16.dp)) + Spacer(modifier = Modifier.height(8.dp)) when (val type = state.selectedType) { is AddSendState.ViewState.Content.SendType.File -> { BitwardenListHeaderText( @@ -161,6 +168,7 @@ fun AddSendContent( Spacer(modifier = Modifier.height(16.dp)) AddSendOptions( state = state, + isAddMode = isAddMode, addSendHandlers = addSendHandlers, ) @@ -173,12 +181,15 @@ fun AddSendContent( * Displays a collapsable set of new send options. * * @param state The content state. + * @param isAddMode When `true`, indicates that we are creating a new send and `false` when editing + * an existing send. * @param addSendHandlers THe handlers various events. */ @Suppress("LongMethod") @Composable private fun AddSendOptions( state: AddSendState.ViewState.Content, + isAddMode: Boolean, addSendHandlers: AddSendHandlers, ) { var isExpanded by rememberSaveable { mutableStateOf(false) } @@ -221,25 +232,92 @@ private fun AddSendOptions( modifier = Modifier.clipToBounds(), ) { Column { - SendDeletionDateChooser( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp), - dateFormatPattern = state.common.dateFormatPattern, - timeFormatPattern = state.common.timeFormatPattern, - currentZonedDateTime = state.common.deletionDate, - onDateSelect = addSendHandlers.onDeletionDateChange, - ) - Spacer(modifier = Modifier.height(8.dp)) - SendExpirationDateChooser( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp), - dateFormatPattern = state.common.dateFormatPattern, - timeFormatPattern = state.common.timeFormatPattern, - currentZonedDateTime = state.common.expirationDate, - onDateSelect = addSendHandlers.onExpirationDateChange, - ) + if (isAddMode) { + SendDeletionDateChooser( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + dateFormatPattern = state.common.dateFormatPattern, + timeFormatPattern = state.common.timeFormatPattern, + currentZonedDateTime = state.common.deletionDate, + onDateSelect = addSendHandlers.onDeletionDateChange, + ) + Spacer(modifier = Modifier.height(8.dp)) + SendExpirationDateChooser( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + dateFormatPattern = state.common.dateFormatPattern, + timeFormatPattern = state.common.timeFormatPattern, + currentZonedDateTime = state.common.expirationDate, + onDateSelect = addSendHandlers.onExpirationDateChange, + ) + } else { + BitwardenListHeaderText( + label = stringResource(id = R.string.deletion_date), + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + ) + Spacer(modifier = Modifier.height(8.dp)) + AddSendCustomDateChooser( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + dateFormatPattern = state.common.dateFormatPattern, + timeFormatPattern = state.common.timeFormatPattern, + currentZonedDateTime = state.common.deletionDate, + onDateSelect = { addSendHandlers.onDeletionDateChange(requireNotNull(it)) }, + ) + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = stringResource(id = R.string.deletion_date_info), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + ) + + Spacer(modifier = Modifier.height(16.dp)) + BitwardenListHeaderText( + label = stringResource(id = R.string.expiration_date), + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + ) + Spacer(modifier = Modifier.height(8.dp)) + AddSendCustomDateChooser( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + dateFormatPattern = state.common.dateFormatPattern, + timeFormatPattern = state.common.timeFormatPattern, + currentZonedDateTime = state.common.expirationDate, + onDateSelect = addSendHandlers.onExpirationDateChange, + ) + Spacer(modifier = Modifier.height(4.dp)) + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = stringResource(id = R.string.expiration_date_info), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.weight(1f), + ) + Spacer(modifier = Modifier.width(4.dp)) + BitwardenTextButton( + label = stringResource(id = R.string.clear), + onClick = addSendHandlers.onClearExpirationDateClick, + isEnabled = state.common.expirationDate != null, + modifier = Modifier.wrapContentWidth(), + ) + } + } Spacer(modifier = Modifier.height(8.dp)) BitwardenStepper( label = stringResource(id = R.string.maximum_access_count), @@ -260,6 +338,30 @@ private fun AddSendOptions( .fillMaxWidth() .padding(horizontal = 32.dp), ) + if (!isAddMode) { + state.common.currentAccessCount?.let { + Spacer(modifier = Modifier.height(16.dp)) + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 32.dp), + ) { + Text( + text = stringResource(id = R.string.current_access_count) + ":", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Spacer(modifier = Modifier.width(4.dp)) + Text( + text = it.toString(), + style = LocalNonMaterialTypography.current.bodySmallProminent, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + Spacer(modifier = Modifier.height(16.dp)) + } + } + Spacer(modifier = Modifier.height(8.dp)) BitwardenPasswordField( label = stringResource(id = R.string.new_password), diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/tools/feature/send/addsend/AddSendScreen.kt b/app/src/main/java/com/x8bit/bitwarden/ui/tools/feature/send/addsend/AddSendScreen.kt index 7e7930352..db6f33a3c 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/tools/feature/send/addsend/AddSendScreen.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/tools/feature/send/addsend/AddSendScreen.kt @@ -135,6 +135,7 @@ fun AddSendScreen( when (val viewState = state.viewState) { is AddSendState.ViewState.Content -> AddSendContent( state = viewState, + isAddMode = state.isAddMode, addSendHandlers = remember(viewModel) { AddSendHandlers.create(viewModel) }, modifier = modifier, ) diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/tools/feature/send/addsend/AddSendViewModel.kt b/app/src/main/java/com/x8bit/bitwarden/ui/tools/feature/send/addsend/AddSendViewModel.kt index 64754c7b2..6e5978fad 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/tools/feature/send/addsend/AddSendViewModel.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/tools/feature/send/addsend/AddSendViewModel.kt @@ -3,18 +3,24 @@ package com.x8bit.bitwarden.ui.tools.feature.send.addsend import android.os.Parcelable import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.viewModelScope +import com.bitwarden.core.SendView import com.x8bit.bitwarden.R import com.x8bit.bitwarden.data.auth.repository.AuthRepository import com.x8bit.bitwarden.data.auth.repository.model.UserState import com.x8bit.bitwarden.data.platform.repository.EnvironmentRepository +import com.x8bit.bitwarden.data.platform.repository.model.DataState import com.x8bit.bitwarden.data.platform.repository.util.baseWebSendUrl +import com.x8bit.bitwarden.data.platform.repository.util.takeUntilLoaded import com.x8bit.bitwarden.data.vault.repository.VaultRepository import com.x8bit.bitwarden.data.vault.repository.model.CreateSendResult +import com.x8bit.bitwarden.data.vault.repository.model.UpdateSendResult 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.tools.feature.send.addsend.model.AddSendType import com.x8bit.bitwarden.ui.tools.feature.send.addsend.util.toSendView +import com.x8bit.bitwarden.ui.tools.feature.send.addsend.util.toViewState import com.x8bit.bitwarden.ui.tools.feature.send.util.toSendUrl import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.launchIn @@ -50,6 +56,7 @@ class AddSendViewModel @Inject constructor( AddSendType.AddItem -> AddSendState.ViewState.Content( common = AddSendState.ViewState.Content.Common( name = "", + currentAccessCount = null, maxAccessCount = null, passwordInput = "", noteInput = "", @@ -62,6 +69,7 @@ class AddSendViewModel @Inject constructor( .truncatedTo(ChronoUnit.DAYS) .plusWeeks(1), expirationDate = null, + sendUrl = null, ), selectedType = AddSendState.ViewState.Content.SendType.Text( input = "", @@ -69,9 +77,7 @@ class AddSendViewModel @Inject constructor( ), ) - is AddSendType.EditItem -> AddSendState.ViewState.Error( - "Not yet implemented".asText(), - ) + is AddSendType.EditItem -> AddSendState.ViewState.Loading }, dialogState = null, isPremiumUser = authRepo.userStateFlow.value?.activeAccount?.isPremium == true, @@ -85,6 +91,19 @@ class AddSendViewModel @Inject constructor( .onEach { savedStateHandle[KEY_STATE] = it } .launchIn(viewModelScope) + when (val addSendType = state.addSendType) { + AddSendType.AddItem -> Unit + is AddSendType.EditItem -> { + vaultRepo + .getSendStateFlow(addSendType.sendItemId) + // We'll stop getting updates as soon as we get some loaded data. + .takeUntilLoaded() + .map { AddSendAction.Internal.SendDataReceive(it) } + .onEach(::sendAction) + .launchIn(viewModelScope) + } + } + authRepo .userStateFlow .map { AddSendAction.Internal.UserStateReceive(it) } @@ -100,6 +119,7 @@ class AddSendViewModel @Inject constructor( is AddSendAction.CloseClick -> handleCloseClick() is AddSendAction.DeletionDateChange -> handleDeletionDateChange(action) is AddSendAction.ExpirationDateChange -> handleExpirationDateChange(action) + AddSendAction.ClearExpirationDate -> handleClearExpirationDate() AddSendAction.DismissDialogClick -> handleDismissDialogClick() is AddSendAction.SaveClick -> handleSaveClick() is AddSendAction.FileTypeClick -> handleFileTypeClick() @@ -118,7 +138,9 @@ class AddSendViewModel @Inject constructor( private fun handleInternalAction(action: AddSendAction.Internal): Unit = when (action) { is AddSendAction.Internal.CreateSendResultReceive -> handleCreateSendResultReceive(action) + is AddSendAction.Internal.UpdateSendResultReceive -> handleUpdateSendResultReceive(action) is AddSendAction.Internal.UserStateReceive -> handleUserStateReceive(action) + is AddSendAction.Internal.SendDataReceive -> handleSendDataReceive(action) } private fun handleCreateSendResultReceive( @@ -148,12 +170,113 @@ class AddSendViewModel @Inject constructor( } } + private fun handleUpdateSendResultReceive( + action: AddSendAction.Internal.UpdateSendResultReceive, + ) { + when (val result = action.result) { + is UpdateSendResult.Error -> { + mutableStateFlow.update { + it.copy( + dialogState = AddSendState.DialogState.Error( + title = R.string.an_error_has_occurred.asText(), + message = result + .errorMessage + ?.asText() + ?: R.string.generic_error_message.asText(), + ), + ) + } + } + + is UpdateSendResult.Success -> { + mutableStateFlow.update { it.copy(dialogState = null) } + sendEvent(AddSendEvent.NavigateBack) + sendEvent( + AddSendEvent.ShowShareSheet( + message = result.sendView.toSendUrl(state.baseWebSendUrl), + ), + ) + } + } + } + private fun handleUserStateReceive(action: AddSendAction.Internal.UserStateReceive) { mutableStateFlow.update { it.copy(isPremiumUser = action.userState?.activeAccount?.isPremium == true) } } + @Suppress("LongMethod") + private fun handleSendDataReceive(action: AddSendAction.Internal.SendDataReceive) { + when (val sendDataState = action.sendDataState) { + is DataState.Error -> { + mutableStateFlow.update { + it.copy( + viewState = AddSendState.ViewState.Error( + message = R.string.generic_error_message.asText(), + ), + ) + } + } + + is DataState.Loaded -> { + mutableStateFlow.update { + it.copy( + viewState = sendDataState + .data + ?.toViewState( + clock = clock, + baseWebSendUrl = environmentRepo + .environment + .environmentUrlData + .baseWebSendUrl, + ) + ?: AddSendState.ViewState.Error( + message = R.string.generic_error_message.asText(), + ), + ) + } + } + + DataState.Loading -> { + mutableStateFlow.update { + it.copy(viewState = AddSendState.ViewState.Loading) + } + } + + is DataState.NoNetwork -> { + mutableStateFlow.update { + it.copy( + viewState = AddSendState.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 = sendDataState + .data + ?.toViewState( + clock = clock, + baseWebSendUrl = environmentRepo + .environment + .environmentUrlData + .baseWebSendUrl, + ) + ?: AddSendState.ViewState.Error( + message = R.string.generic_error_message.asText(), + ), + ) + } + } + } + } + private fun handleCopyLinkClick() { // TODO Add copy link support (BIT-1435) sendEvent(AddSendEvent.ShowToast("Not yet implemented")) @@ -212,6 +335,10 @@ class AddSendViewModel @Inject constructor( } } + private fun handleClearExpirationDate() { + updateCommonContent { it.copy(expirationDate = null) } + } + private fun handleSaveClick() { onContent { content -> if (content.common.name.isBlank()) { @@ -235,8 +362,20 @@ class AddSendViewModel @Inject constructor( ) } viewModelScope.launch { - val result = vaultRepo.createSend(content.toSendView(clock)) - sendAction(AddSendAction.Internal.CreateSendResultReceive(result)) + when (val addSendType = state.addSendType) { + AddSendType.AddItem -> { + val result = vaultRepo.createSend(content.toSendView(clock)) + sendAction(AddSendAction.Internal.CreateSendResultReceive(result)) + } + + is AddSendType.EditItem -> { + val result = vaultRepo.updateSend( + sendId = addSendType.sendItemId, + sendView = content.toSendView(clock), + ) + sendAction(AddSendAction.Internal.UpdateSendResultReceive(result)) + } + } } } } @@ -402,12 +541,23 @@ data class AddSendState( val selectedType: SendType, ) : ViewState() { + /** + * Helper method to indicate if the selected type is [SendType.File]. + */ + val isFileType: Boolean get() = selectedType is SendType.File + + /** + * Helper method to indicate if the selected type is [SendType.Text]. + */ + val isTextType: Boolean get() = selectedType is SendType.Text + /** * Content data that is common for all item types. */ @Parcelize data class Common( val name: String, + val currentAccessCount: Int?, val maxAccessCount: Int?, val passwordInput: String, val noteInput: String, @@ -415,6 +565,7 @@ data class AddSendState( val isDeactivateChecked: Boolean, val deletionDate: ZonedDateTime, val expirationDate: ZonedDateTime?, + val sendUrl: String?, ) : Parcelable { val dateFormatPattern: String get() = "M/d/yyyy" @@ -592,6 +743,11 @@ sealed class AddSendAction { */ data class ExpirationDateChange(val expirationDate: ZonedDateTime?) : AddSendAction() + /** + * The user has cleared the expiration date. + */ + data object ClearExpirationDate : AddSendAction() + /** * Models actions that the [AddSendViewModel] itself might send. */ @@ -605,5 +761,15 @@ sealed class AddSendAction { * Indicates a result for creating a send has been received. */ data class CreateSendResultReceive(val result: CreateSendResult) : Internal() + + /** + * Indicates a result for updating a send has been received. + */ + data class UpdateSendResultReceive(val result: UpdateSendResult) : Internal() + + /** + * Indicates that the send item data has been received. + */ + data class SendDataReceive(val sendDataState: DataState) : Internal() } } diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/tools/feature/send/addsend/handlers/AddSendHandlers.kt b/app/src/main/java/com/x8bit/bitwarden/ui/tools/feature/send/addsend/handlers/AddSendHandlers.kt index 33a4aa037..dac92be9f 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/tools/feature/send/addsend/handlers/AddSendHandlers.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/tools/feature/send/addsend/handlers/AddSendHandlers.kt @@ -22,6 +22,7 @@ data class AddSendHandlers( val onDeactivateSendToggle: (Boolean) -> Unit, val onDeletionDateChange: (ZonedDateTime) -> Unit, val onExpirationDateChange: (ZonedDateTime?) -> Unit, + val onClearExpirationDateClick: () -> Unit, ) { companion object { /** @@ -57,6 +58,9 @@ data class AddSendHandlers( onExpirationDateChange = { viewModel.trySendAction(AddSendAction.ExpirationDateChange(it)) }, + onClearExpirationDateClick = { + viewModel.trySendAction(AddSendAction.ClearExpirationDate) + }, ) } } diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/tools/feature/send/addsend/util/SendViewExtensions.kt b/app/src/main/java/com/x8bit/bitwarden/ui/tools/feature/send/addsend/util/SendViewExtensions.kt new file mode 100644 index 000000000..a183fb1d1 --- /dev/null +++ b/app/src/main/java/com/x8bit/bitwarden/ui/tools/feature/send/addsend/util/SendViewExtensions.kt @@ -0,0 +1,42 @@ +package com.x8bit.bitwarden.ui.tools.feature.send.addsend.util + +import com.bitwarden.core.SendType +import com.bitwarden.core.SendView +import com.x8bit.bitwarden.ui.tools.feature.send.addsend.AddSendState +import com.x8bit.bitwarden.ui.tools.feature.send.util.toSendUrl +import java.time.Clock +import java.time.ZonedDateTime + +/** + * Transforms [SendView] into [AddSendState.ViewState.Content]. + */ +fun SendView.toViewState( + clock: Clock, + baseWebSendUrl: String, +): AddSendState.ViewState.Content = + AddSendState.ViewState.Content( + common = AddSendState.ViewState.Content.Common( + name = this.name, + currentAccessCount = this.accessCount.toInt(), + maxAccessCount = this.maxAccessCount?.toInt(), + // We do not set the password here + // We only allow them to create new passwords, not view old ones + passwordInput = "", + noteInput = this.notes.orEmpty(), + isHideEmailChecked = this.hideEmail, + isDeactivateChecked = this.disabled, + deletionDate = ZonedDateTime.ofInstant(this.deletionDate, clock.zone), + expirationDate = this.expirationDate?.let { ZonedDateTime.ofInstant(it, clock.zone) }, + sendUrl = this.toSendUrl(baseWebSendUrl), + ), + selectedType = when (type) { + SendType.TEXT -> { + AddSendState.ViewState.Content.SendType.Text( + input = this.text?.text.orEmpty(), + isHideByDefaultChecked = this.text?.hidden == true, + ) + } + + SendType.FILE -> AddSendState.ViewState.Content.SendType.File + }, + ) diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/tools/feature/send/addsend/AddSendScreenTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/tools/feature/send/addsend/AddSendScreenTest.kt index 62507ff81..572921d74 100644 --- a/app/src/test/java/com/x8bit/bitwarden/ui/tools/feature/send/addsend/AddSendScreenTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/ui/tools/feature/send/addsend/AddSendScreenTest.kt @@ -2,6 +2,9 @@ package com.x8bit.bitwarden.ui.tools.feature.send.addsend import androidx.compose.ui.test.assert import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.assertIsEnabled +import androidx.compose.ui.test.assertIsNotDisplayed +import androidx.compose.ui.test.assertIsNotEnabled import androidx.compose.ui.test.assertIsOff import androidx.compose.ui.test.assertIsOn import androidx.compose.ui.test.assertTextEquals @@ -22,6 +25,7 @@ import com.x8bit.bitwarden.ui.platform.base.BaseComposeTest import com.x8bit.bitwarden.ui.platform.base.util.IntentHandler import com.x8bit.bitwarden.ui.platform.base.util.asText import com.x8bit.bitwarden.ui.tools.feature.send.addsend.model.AddSendType +import com.x8bit.bitwarden.ui.util.isEditableText import com.x8bit.bitwarden.ui.util.isProgressBar import io.mockk.every import io.mockk.just @@ -35,6 +39,7 @@ import org.junit.Before import org.junit.Test import java.time.ZonedDateTime +@Suppress("LargeClass") class AddSendScreenTest : BaseComposeTest() { private var onNavigateBackCalled = false @@ -229,6 +234,41 @@ class AddSendScreenTest : BaseComposeTest() { ) } + @Test + fun `segmented buttons should appear based on state`() { + mutableStateFlow.update { it.copy(addSendType = AddSendType.AddItem) } + composeTestRule + .onNodeWithText("Type") + .performScrollTo() + .assertIsDisplayed() + composeTestRule + .onAllNodesWithText("File") + .filterToOne(!isEditableText) + .performScrollTo() + .assertIsDisplayed() + composeTestRule + .onAllNodesWithText("Text") + .filterToOne(!isEditableText) + .performScrollTo() + .assertIsDisplayed() + + mutableStateFlow.update { + it.copy(addSendType = AddSendType.EditItem(sendItemId = "sendId")) + } + + composeTestRule + .onNodeWithText("Type") + .assertIsNotDisplayed() + composeTestRule + .onAllNodesWithText("File") + .filterToOne(!isEditableText) + .assertIsNotDisplayed() + composeTestRule + .onAllNodesWithText("Text") + .filterToOne(!isEditableText) + .assertIsNotDisplayed() + } + @Test fun `File segmented button click should send FileTypeClick`() { composeTestRule @@ -594,6 +634,64 @@ class AddSendScreenTest : BaseComposeTest() { .assertIsOn() } + @Test + fun `in edit mode, clear button should be enabled based on state`() { + mutableStateFlow.update { + it.copy(addSendType = AddSendType.EditItem(sendItemId = "sendId")) + } + + composeTestRule + .onNodeWithText("Options") + .performScrollTo() + .performClick() + composeTestRule + .onNodeWithText("Clear") + .performScrollTo() + .assertIsNotEnabled() + + mutableStateFlow.update { + it.copy( + viewState = DEFAULT_VIEW_STATE.copy( + common = DEFAULT_COMMON_STATE.copy( + expirationDate = ZonedDateTime.parse("2023-10-27T12:00:00Z"), + ), + ), + ) + } + + composeTestRule + .onNodeWithText("Clear") + .performScrollTo() + .assertIsEnabled() + } + + @Test + fun `in edit mode, clear button should send ClearExpirationDate`() { + mutableStateFlow.update { + it.copy( + addSendType = AddSendType.EditItem(sendItemId = "sendId"), + viewState = DEFAULT_VIEW_STATE.copy( + common = DEFAULT_COMMON_STATE.copy( + expirationDate = ZonedDateTime.parse("2023-10-27T12:00:00Z"), + ), + ), + ) + } + + composeTestRule + .onNodeWithText("Options") + .performScrollTo() + .performClick() + composeTestRule + .onNodeWithText("Clear") + .performScrollTo() + .performClick() + + verify(exactly = 1) { + viewModel.trySendAction(AddSendAction.ClearExpirationDate) + } + } + @Test fun `progressbar should be displayed according to state`() { mutableStateFlow.update { @@ -683,6 +781,7 @@ class AddSendScreenTest : BaseComposeTest() { companion object { private val DEFAULT_COMMON_STATE = AddSendState.ViewState.Content.Common( name = "", + currentAccessCount = null, maxAccessCount = null, passwordInput = "", noteInput = "", @@ -690,6 +789,7 @@ class AddSendScreenTest : BaseComposeTest() { isDeactivateChecked = false, deletionDate = ZonedDateTime.parse("2023-10-27T12:00:00Z"), expirationDate = null, + sendUrl = null, ) private val DEFAULT_SELECTED_TYPE_STATE = AddSendState.ViewState.Content.SendType.Text( diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/tools/feature/send/addsend/AddSendViewModelTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/tools/feature/send/addsend/AddSendViewModelTest.kt index 66ae11fcd..9220a2117 100644 --- a/app/src/test/java/com/x8bit/bitwarden/ui/tools/feature/send/addsend/AddSendViewModelTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/ui/tools/feature/send/addsend/AddSendViewModelTest.kt @@ -7,13 +7,17 @@ import com.x8bit.bitwarden.R import com.x8bit.bitwarden.data.auth.repository.AuthRepository import com.x8bit.bitwarden.data.auth.repository.model.UserState import com.x8bit.bitwarden.data.platform.repository.EnvironmentRepository +import com.x8bit.bitwarden.data.platform.repository.model.DataState import com.x8bit.bitwarden.data.platform.repository.model.Environment +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.CreateSendResult +import com.x8bit.bitwarden.data.vault.repository.model.UpdateSendResult import com.x8bit.bitwarden.ui.platform.base.BaseViewModelTest import com.x8bit.bitwarden.ui.platform.base.util.asText import com.x8bit.bitwarden.ui.tools.feature.send.addsend.model.AddSendType import com.x8bit.bitwarden.ui.tools.feature.send.addsend.util.toSendView +import com.x8bit.bitwarden.ui.tools.feature.send.addsend.util.toViewState import com.x8bit.bitwarden.ui.tools.feature.send.util.toSendUrl import io.mockk.coEvery import io.mockk.coVerify @@ -45,18 +49,27 @@ class AddSendViewModelTest : BaseViewModelTest() { private val environmentRepository: EnvironmentRepository = mockk { every { environment } returns Environment.Us } - private val vaultRepository: VaultRepository = mockk() + private val mutableSendDataStateFlow = MutableStateFlow>(DataState.Loading) + private val vaultRepository: VaultRepository = mockk { + every { getSendStateFlow(any()) } returns mutableSendDataStateFlow + } @BeforeEach fun setup() { - mockkStatic(ADD_SEND_STATE_EXTENSIONS_PATH) - mockkStatic(SEND_VIEW_EXTENSIONS_PATH) + mockkStatic( + ADD_SEND_STATE_EXTENSIONS_PATH, + ADD_SEND_VIEW_EXTENSIONS_PATH, + SEND_VIEW_EXTENSIONS_PATH, + ) } @AfterEach fun tearDown() { - unmockkStatic(ADD_SEND_STATE_EXTENSIONS_PATH) - unmockkStatic(SEND_VIEW_EXTENSIONS_PATH) + unmockkStatic( + ADD_SEND_STATE_EXTENSIONS_PATH, + ADD_SEND_VIEW_EXTENSIONS_PATH, + SEND_VIEW_EXTENSIONS_PATH, + ) } @Test @@ -149,6 +162,90 @@ class AddSendViewModelTest : BaseViewModelTest() { } } + @Test + fun `SaveClick with updateSend success should emit NavigateBack and ShowShareSheet`() = + runTest { + val sendId = "sendId-1" + val viewState = DEFAULT_VIEW_STATE.copy( + common = DEFAULT_COMMON_STATE.copy(name = "input"), + ) + val initialState = DEFAULT_STATE.copy( + addSendType = AddSendType.EditItem(sendId), + viewState = viewState, + ) + val mockSendView = createMockSendView(number = 1) + every { mockSendView.toViewState(clock, DEFAULT_ENVIRONMENT_URL) } returns viewState + every { viewState.toSendView(clock) } returns mockSendView + val sendUrl = "www.test.com/send/test" + val resultSendView = mockk { + every { toSendUrl(DEFAULT_ENVIRONMENT_URL) } returns sendUrl + every { id } returns sendId + } + coEvery { + vaultRepository.updateSend(sendId = sendId, sendView = mockSendView) + } returns UpdateSendResult.Success(sendView = resultSendView) + mutableSendDataStateFlow.value = DataState.Loaded(mockSendView) + val viewModel = createViewModel(initialState, AddSendType.EditItem(sendId)) + + viewModel.eventFlow.test { + viewModel.trySendAction(AddSendAction.SaveClick) + assertEquals(AddSendEvent.NavigateBack, awaitItem()) + assertEquals(AddSendEvent.ShowShareSheet(sendUrl), awaitItem()) + } + assertEquals(initialState, viewModel.stateFlow.value) + coVerify(exactly = 1) { + vaultRepository.updateSend(sendId = sendId, sendView = mockSendView) + } + } + + @Test + fun `SaveClick with updateSend failure should show error dialog`() = runTest { + val sendId = "sendId-1" + val viewState = DEFAULT_VIEW_STATE.copy( + common = DEFAULT_COMMON_STATE.copy(name = "input"), + ) + val initialState = DEFAULT_STATE.copy( + addSendType = AddSendType.EditItem(sendId), + viewState = viewState, + ) + val mockSendView = mockk { + every { id } returns sendId + } + val errorMessage = "Failure" + every { mockSendView.toViewState(clock, DEFAULT_ENVIRONMENT_URL) } returns viewState + every { viewState.toSendView(clock) } returns mockSendView + coEvery { + vaultRepository.updateSend(sendId = sendId, sendView = mockSendView) + } returns UpdateSendResult.Error(errorMessage = errorMessage) + mutableSendDataStateFlow.value = DataState.Loaded(mockSendView) + val viewModel = createViewModel(initialState, AddSendType.EditItem(sendId)) + + viewModel.stateFlow.test { + assertEquals(initialState, awaitItem()) + viewModel.trySendAction(AddSendAction.SaveClick) + assertEquals( + initialState.copy( + dialogState = AddSendState.DialogState.Loading( + message = R.string.saving.asText(), + ), + ), + awaitItem(), + ) + assertEquals( + initialState.copy( + dialogState = AddSendState.DialogState.Error( + title = R.string.an_error_has_occurred.asText(), + message = errorMessage.asText(), + ), + ), + awaitItem(), + ) + } + coVerify(exactly = 1) { + vaultRepository.updateSend(sendId = sendId, sendView = mockSendView) + } + } + @Test fun `SaveClick with blank name should show error dialog`() { val viewModel = createViewModel(DEFAULT_STATE) @@ -276,6 +373,26 @@ class AddSendViewModelTest : BaseViewModelTest() { ) } + @Test + fun `ClearExpirationDate should clear the expiration date`() { + val initialState = DEFAULT_STATE.copy( + viewState = DEFAULT_VIEW_STATE.copy( + DEFAULT_COMMON_STATE.copy( + expirationDate = ZonedDateTime.parse("2024-09-13T00:00Z"), + ), + ), + ) + val viewModel = createViewModel(initialState) + + viewModel.trySendAction(AddSendAction.ClearExpirationDate) + + assertEquals( + // DEFAULT expiration date is null + DEFAULT_STATE, + viewModel.stateFlow.value, + ) + } + @Test fun `ChooseFileClick should emit ShowToast`() = runTest { val viewModel = createViewModel() @@ -450,7 +567,7 @@ class AddSendViewModelTest : BaseViewModelTest() { addSendType: AddSendType = AddSendType.AddItem, ): AddSendViewModel = AddSendViewModel( savedStateHandle = SavedStateHandle().apply { - set("state", state) + set("state", state?.copy(addSendType = addSendType)) set( "add_send_item_type", when (addSendType) { @@ -469,11 +586,14 @@ class AddSendViewModelTest : BaseViewModelTest() { companion object { private const val ADD_SEND_STATE_EXTENSIONS_PATH: String = "com.x8bit.bitwarden.ui.tools.feature.send.addsend.util.AddSendStateExtensionsKt" + private const val ADD_SEND_VIEW_EXTENSIONS_PATH: String = + "com.x8bit.bitwarden.ui.tools.feature.send.addsend.util.SendViewExtensionsKt" private const val SEND_VIEW_EXTENSIONS_PATH: String = "com.x8bit.bitwarden.ui.tools.feature.send.util.SendViewExtensionsKt" private val DEFAULT_COMMON_STATE = AddSendState.ViewState.Content.Common( name = "", + currentAccessCount = null, maxAccessCount = null, passwordInput = "", noteInput = "", @@ -481,6 +601,7 @@ class AddSendViewModelTest : BaseViewModelTest() { isDeactivateChecked = false, deletionDate = ZonedDateTime.parse("2023-11-03T00:00Z"), expirationDate = null, + sendUrl = null, ) private val DEFAULT_SELECTED_TYPE_STATE = AddSendState.ViewState.Content.SendType.Text( diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/tools/feature/send/addsend/util/AddSendStateExtensionsTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/tools/feature/send/addsend/util/AddSendStateExtensionsTest.kt index 0767a1d28..91a103ecb 100644 --- a/app/src/test/java/com/x8bit/bitwarden/ui/tools/feature/send/addsend/util/AddSendStateExtensionsTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/ui/tools/feature/send/addsend/util/AddSendStateExtensionsTest.kt @@ -61,6 +61,7 @@ private val FIXED_CLOCK: Clock = Clock.fixed( private val DEFAULT_COMMON_STATE = AddSendState.ViewState.Content.Common( name = "mockName-1", + currentAccessCount = 1, maxAccessCount = 1, passwordInput = "mockPassword-1", noteInput = "mockNotes-1", @@ -68,6 +69,7 @@ private val DEFAULT_COMMON_STATE = AddSendState.ViewState.Content.Common( isDeactivateChecked = false, deletionDate = ZonedDateTime.parse("2023-10-27T12:00:00Z"), expirationDate = ZonedDateTime.parse("2023-10-27T12:00:00Z"), + sendUrl = null, ) private val DEFAULT_SELECTED_TYPE_STATE = AddSendState.ViewState.Content.SendType.Text( diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/tools/feature/send/addsend/util/SendViewExtensionsTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/tools/feature/send/addsend/util/SendViewExtensionsTest.kt new file mode 100644 index 000000000..1f625a890 --- /dev/null +++ b/app/src/test/java/com/x8bit/bitwarden/ui/tools/feature/send/addsend/util/SendViewExtensionsTest.kt @@ -0,0 +1,78 @@ +package com.x8bit.bitwarden.ui.tools.feature.send.addsend.util + +import com.bitwarden.core.SendType +import com.x8bit.bitwarden.data.vault.datasource.sdk.model.createMockSendView +import com.x8bit.bitwarden.ui.tools.feature.send.addsend.AddSendState +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test +import java.time.Clock +import java.time.Instant +import java.time.ZoneOffset +import java.time.ZonedDateTime + +class SendViewExtensionsTest { + + @Test + fun `toViewState should create an appropriate ViewState for file type`() { + val sendView = createMockSendView(number = 1, type = SendType.FILE) + + val result = sendView.toViewState( + clock = FIXED_CLOCK, + baseWebSendUrl = "www.test.com/", + ) + + assertEquals(DEFAULT_STATE, result) + } + + @Test + fun `toViewState should create an appropriate ViewState for text type`() { + val sendView = createMockSendView(number = 1, type = SendType.TEXT) + + val result = sendView.toViewState( + clock = FIXED_CLOCK, + baseWebSendUrl = "www.test.com/", + ) + + assertEquals(DEFAULT_STATE.copy(selectedType = DEFAULT_TEXT_TYPE), result) + } +} + +private val FIXED_CLOCK: Clock = Clock.fixed( + Instant.parse("2023-10-27T12:00:00Z"), + ZoneOffset.UTC, +) + +private val DEFAULT_COMMON: AddSendState.ViewState.Content.Common = + AddSendState.ViewState.Content.Common( + name = "mockName-1", + currentAccessCount = 1, + maxAccessCount = 1, + passwordInput = "", + noteInput = "mockNotes-1", + isHideEmailChecked = false, + isDeactivateChecked = false, + deletionDate = ZonedDateTime.ofInstant( + Instant.parse("2023-10-27T12:00:00Z"), + ZoneOffset.UTC, + ), + expirationDate = ZonedDateTime.ofInstant( + Instant.parse("2023-10-27T12:00:00Z"), + ZoneOffset.UTC, + ), + sendUrl = "www.test.com/mockAccessId-1/mockKey-1", + ) + +private val DEFAULT_TEXT_TYPE: AddSendState.ViewState.Content.SendType.Text = + AddSendState.ViewState.Content.SendType.Text( + input = "mockText-1", + isHideByDefaultChecked = false, + ) + +private val DEFAULT_FILE_TYPE: AddSendState.ViewState.Content.SendType.File = + AddSendState.ViewState.Content.SendType.File + +private val DEFAULT_STATE: AddSendState.ViewState.Content = + AddSendState.ViewState.Content( + common = DEFAULT_COMMON, + selectedType = DEFAULT_FILE_TYPE, + ) diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/util/ComposeTestHelpers.kt b/app/src/test/java/com/x8bit/bitwarden/ui/util/ComposeTestHelpers.kt index 03922987d..48371d1d2 100644 --- a/app/src/test/java/com/x8bit/bitwarden/ui/util/ComposeTestHelpers.kt +++ b/app/src/test/java/com/x8bit/bitwarden/ui/util/ComposeTestHelpers.kt @@ -18,6 +18,12 @@ import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.performScrollToNode import org.junit.jupiter.api.assertThrows +/** + * A [SemanticsMatcher] used to find editable text nodes. + */ +val isEditableText: SemanticsMatcher + get() = SemanticsMatcher.keyIsDefined(SemanticsProperties.EditableText) + /** * A [SemanticsMatcher] used to find progressbar nodes. */