From a26ddf45dc919799770704e650f81d4dd8a37fc3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Bispo?= Date: Thu, 20 Aug 2026 19:51:02 +0000 Subject: [PATCH] [PM-42110] feat: Make a copy of a policy-restricted Send (#7276) --- .../send/addedit/AddEditSendContent.kt | 22 +- .../send/addedit/AddEditSendNavigation.kt | 2 + .../feature/send/addedit/AddEditSendScreen.kt | 4 +- .../send/addedit/AddEditSendViewModel.kt | 99 +++++---- .../send/addedit/model/AddEditSendType.kt | 11 + .../send/addedit/util/SendViewExtensions.kt | 144 ++++++++++++- .../feature/send/viewsend/ViewSendScreen.kt | 20 ++ .../send/viewsend/ViewSendViewModel.kt | 18 ++ .../viewsend/model/SendPolicyRestriction.kt | 11 + .../send/addedit/AddEditSendViewModelTest.kt | 138 ++++++++++++ .../addedit/util/SendViewExtensionsTest.kt | 200 ++++++++++++++++++ .../send/viewsend/ViewSendScreenTest.kt | 67 ++++++ .../send/viewsend/ViewSendViewModelTest.kt | 12 ++ ui/src/main/res/values/strings.xml | 1 + 14 files changed, 688 insertions(+), 61 deletions(-) diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/ui/tools/feature/send/addedit/AddEditSendContent.kt b/app/src/main/kotlin/com/x8bit/bitwarden/ui/tools/feature/send/addedit/AddEditSendContent.kt index 644cdfd418..05c7705c09 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/ui/tools/feature/send/addedit/AddEditSendContent.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/ui/tools/feature/send/addedit/AddEditSendContent.kt @@ -68,7 +68,7 @@ fun AddEditSendContent( policyDisablesSend: Boolean, policySendOptionsInEffect: Boolean, shouldHideEmailAddressToggle: Boolean, - isAddMode: Boolean, + isNewSend: Boolean, isShared: Boolean, addSendHandlers: AddEditSendHandlers, permissionsManager: PermissionsManager, @@ -126,7 +126,7 @@ fun AddEditSendContent( fileType = type, addSendHandlers = addSendHandlers, permissionsManager = permissionsManager, - isAddMode = isAddMode, + isNewSend = isNewSend, isShared = isShared, ) } @@ -142,7 +142,7 @@ fun AddEditSendContent( Spacer(modifier = Modifier.height(height = 8.dp)) - if (isAddMode) { + if (isNewSend) { AddEditSendDeletionDateChooser( enforcedDeletionHours = enforcedDeletionHours, onDateSelect = addSendHandlers.onDeletionDateChange, @@ -188,11 +188,11 @@ fun AddEditSendContent( state = state, isSendsRestrictedByPolicy = policyDisablesSend, shouldHideEmailAddressToggle = shouldHideEmailAddressToggle, - isAddMode = isAddMode, + isNewSend = isNewSend, addSendHandlers = addSendHandlers, ) - if (!isAddMode) { + if (!isNewSend) { DeleteButton( onDeleteClick = addSendHandlers.onDeleteClick, modifier = Modifier @@ -273,7 +273,7 @@ private fun ColumnScope.FileTypeContent( fileType: AddEditSendState.ViewState.Content.SendType.File, addSendHandlers: AddEditSendHandlers, permissionsManager: PermissionsManager, - isAddMode: Boolean, + isNewSend: Boolean, isShared: Boolean, ) { val chooseFileCameraPermissionLauncher = permissionsManager.getLauncher { isGranted -> @@ -300,7 +300,7 @@ private fun ColumnScope.FileTypeContent( .standardHorizontalMargin() .padding(horizontal = 16.dp), ) - } else if (isAddMode) { + } else if (isNewSend) { fileType.name?.let { Box( contentAlignment = Alignment.CenterStart, @@ -381,8 +381,8 @@ private fun ColumnScope.FileTypeContent( * @param state The content state. * @param isSendsRestrictedByPolicy When `true`, indicates that there's a policy preventing the user * from editing or creating sends. - * @param isAddMode When `true`, indicates that we are creating a new send and `false` when editing - * an existing send. + * @param isNewSend When `true`, indicates that we are creating a new send and `false` when editing + * an existing send. Copying a send creates a new one, so it is also `true` in that case. * @param addSendHandlers THe handlers various events. */ @Suppress("LongMethod") @@ -391,7 +391,7 @@ private fun AddEditSendOptions( state: AddEditSendState.ViewState.Content, isSendsRestrictedByPolicy: Boolean, shouldHideEmailAddressToggle: Boolean, - isAddMode: Boolean, + isNewSend: Boolean, addSendHandlers: AddEditSendHandlers, ) { var isExpanded by rememberSaveable { mutableStateOf(false) } @@ -424,7 +424,7 @@ private fun AddEditSendOptions( targetState = state .common .currentAccessCount - ?.takeUnless { isAddMode || state.common.maxAccessCount == null }, + ?.takeUnless { isNewSend || state.common.maxAccessCount == null }, enter = fadeIn() + expandVertically(), exit = fadeOut() + shrinkVertically(), ) { diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/ui/tools/feature/send/addedit/AddEditSendNavigation.kt b/app/src/main/kotlin/com/x8bit/bitwarden/ui/tools/feature/send/addedit/AddEditSendNavigation.kt index 969f373b41..c385ec9eaf 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/ui/tools/feature/send/addedit/AddEditSendNavigation.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/ui/tools/feature/send/addedit/AddEditSendNavigation.kt @@ -27,6 +27,7 @@ data class AddEditSendRoute( @Serializable enum class ModeType { ADD, + COPY, EDIT, } @@ -47,6 +48,7 @@ fun SavedStateHandle.toAddEditSendArgs(): AddEditSendArgs { sendType = route.sendType, addEditSendType = when (route.modeType) { ModeType.ADD -> AddEditSendType.AddItem + ModeType.COPY -> AddEditSendType.CopyItem(sendItemId = requireNotNull(route.sendId)) ModeType.EDIT -> AddEditSendType.EditItem(sendItemId = requireNotNull(route.sendId)) }, ) diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/ui/tools/feature/send/addedit/AddEditSendScreen.kt b/app/src/main/kotlin/com/x8bit/bitwarden/ui/tools/feature/send/addedit/AddEditSendScreen.kt index 617a8e6035..480d2813cd 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/ui/tools/feature/send/addedit/AddEditSendScreen.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/ui/tools/feature/send/addedit/AddEditSendScreen.kt @@ -136,7 +136,7 @@ fun AddEditSendScreen( modifier = Modifier.testTag("SaveButton"), ) BitwardenOverflowActionItem( - isVisible = !state.isAddMode, + isVisible = !state.isNewSend, menuItemDataList = persistentListOfNotNull( OverflowMenuItemData( text = stringResource(id = BitwardenString.remove_password), @@ -178,7 +178,7 @@ fun AddEditSendScreen( policyDisablesSend = state.policyDisablesSend, policySendOptionsInEffect = state.shouldDisplayPolicyWarning, shouldHideEmailAddressToggle = state.shouldHideEmailAddressToggle, - isAddMode = state.isAddMode, + isNewSend = state.isNewSend, isShared = state.isShared, addSendHandlers = addSendHandlers, permissionsManager = permissionsManager, diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/ui/tools/feature/send/addedit/AddEditSendViewModel.kt b/app/src/main/kotlin/com/x8bit/bitwarden/ui/tools/feature/send/addedit/AddEditSendViewModel.kt index 2dd333d91d..58bb9a4763 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/ui/tools/feature/send/addedit/AddEditSendViewModel.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/ui/tools/feature/send/addedit/AddEditSendViewModel.kt @@ -43,11 +43,13 @@ import com.x8bit.bitwarden.ui.tools.feature.generator.model.GeneratorMode import com.x8bit.bitwarden.ui.tools.feature.send.addedit.model.AddEditSendType import com.x8bit.bitwarden.ui.tools.feature.send.addedit.model.AuthEmail import com.x8bit.bitwarden.ui.tools.feature.send.addedit.model.SendAuth +import com.x8bit.bitwarden.ui.tools.feature.send.addedit.util.DEFAULT_DELETION_HOURS import com.x8bit.bitwarden.ui.tools.feature.send.addedit.util.shouldFinishOnComplete +import com.x8bit.bitwarden.ui.tools.feature.send.addedit.util.toAddEditViewState +import com.x8bit.bitwarden.ui.tools.feature.send.addedit.util.toEnforcedSendAuth import com.x8bit.bitwarden.ui.tools.feature.send.addedit.util.toSendName import com.x8bit.bitwarden.ui.tools.feature.send.addedit.util.toSendType import com.x8bit.bitwarden.ui.tools.feature.send.addedit.util.toSendView -import com.x8bit.bitwarden.ui.tools.feature.send.addedit.util.toViewState import com.x8bit.bitwarden.ui.tools.feature.send.model.SendItemType import com.x8bit.bitwarden.ui.tools.feature.send.util.toSendUrl import dagger.hilt.android.lifecycle.HiltViewModel @@ -68,11 +70,6 @@ import java.time.Instant import java.time.temporal.ChronoUnit import javax.inject.Inject -/** - * The deletion window applied to a new Send when no deletion date is enforced by policy (7 days). - */ -private const val DEFAULT_DELETION_HOURS: Long = 7 * 24L - private const val KEY_STATE = "state" /** @@ -102,17 +99,6 @@ private fun String?.splitToDomains(): ImmutableList = this .orEmpty() .toImmutableList() -/** - * Returns the [SendAuth] this access type restricts a Send to, preserving [current] when it already - * matches the restricted type so that any emails already entered are kept. - */ -private fun SendAccessTypeJson.toEnforcedSendAuth(current: SendAuth): SendAuth = when (this) { - // Every option stays available, so the current selection is left alone. - SendAccessTypeJson.ANY -> current - SendAccessTypeJson.PASSWORD_PROTECTED -> SendAuth.Password - SendAccessTypeJson.SPECIFIC_PEOPLE -> current as? SendAuth.Email ?: SendAuth.Email() -} - /** * View model for the add/edit send screen. */ @@ -196,7 +182,10 @@ class AddEditSendViewModel @Inject constructor( }, ) - is AddEditSendType.EditItem -> AddEditSendState.ViewState.Loading + // A copy is loaded from the send it is based on, the same way an edit is. + is AddEditSendType.CopyItem, + is AddEditSendType.EditItem, + -> AddEditSendState.ViewState.Loading }, dialogState = null, baseWebSendUrl = environmentRepo.environment.baseWebSendUrl, @@ -212,18 +201,17 @@ class AddEditSendViewModel @Inject constructor( ) { init { - when (val addSendType = state.addEditSendType) { - AddEditSendType.AddItem -> Unit - is AddEditSendType.EditItem -> { + state + .sourceSendItemIdOrNull + ?.let { sendItemId -> vaultRepo - .getSendStateFlow(addSendType.sendItemId) + .getSendStateFlow(sendItemId) // We'll stop getting updates as soon as we get some loaded data. .takeUntilLoaded() .map { AddEditSendAction.Internal.SendDataReceive(it) } .onEach(::sendAction) .launchIn(viewModelScope) } - } generatorRepository .generatorResultFlow @@ -472,7 +460,7 @@ class AddEditSendViewModel @Inject constructor( // created with. val newEnforcedWhoCanAccess = effectiveSendPolicy .whoCanAccess - ?.takeIf { action.isSendControlsEnabled && state.isAddMode } + ?.takeIf { action.isSendControlsEnabled && state.isNewSend } mutableStateFlow.update { currentState -> currentState.copy( policyDisablesSend = effectiveSendPolicy.disableSend, @@ -507,7 +495,7 @@ class AddEditSendViewModel @Inject constructor( * unlocks. */ private fun AddEditSendState.newDeletionDateOrNull(newEnforcedDeletionHours: Int?): Instant? { - if (!isAddMode) return null + if (!isNewSend) return null val hours = when { newEnforcedDeletionHours != null -> newEnforcedDeletionHours.toLong() // The enforcement was just dropped, so the default window is restored. @@ -535,7 +523,22 @@ class AddEditSendViewModel @Inject constructor( ) } - @Suppress("LongMethod") + /** + * Maps a loaded [SendView] into the content for whichever mode this screen is in, supplying the + * mapping with the collaborators it needs from this view model. + * + * @param currentState The state being updated, which is the in-flight value rather than the + * [state] property so that the mapping sees the same policy data as the update it belongs to. + */ + private fun SendView.toCurrentModeViewState( + currentState: AddEditSendState, + ): AddEditSendState.ViewState.Content = toAddEditViewState( + state = currentState, + clock = clock, + baseWebSendUrl = environmentRepo.environment.baseWebSendUrl, + isHideEmailAddressEnabled = isHideEmailAddressEnabled, + ) + private fun handleSendDataReceive(action: AddEditSendAction.Internal.SendDataReceive) { when (val sendDataState = action.sendDataState) { is DataState.Error -> { @@ -553,10 +556,7 @@ class AddEditSendViewModel @Inject constructor( it.copy( viewState = sendDataState .data - ?.toViewState( - baseWebSendUrl = environmentRepo.environment.baseWebSendUrl, - isHideEmailAddressEnabled = isHideEmailAddressEnabled, - ) + ?.toCurrentModeViewState(currentState = it) ?: AddEditSendState.ViewState.Error( message = BitwardenString.generic_error_message.asText(), ), @@ -590,10 +590,7 @@ class AddEditSendViewModel @Inject constructor( it.copy( viewState = sendDataState .data - ?.toViewState( - baseWebSendUrl = environmentRepo.environment.baseWebSendUrl, - isHideEmailAddressEnabled = isHideEmailAddressEnabled, - ) + ?.toCurrentModeViewState(currentState = it) ?: AddEditSendState.ViewState.Error( message = BitwardenString.generic_error_message.asText(), ), @@ -908,7 +905,10 @@ class AddEditSendViewModel @Inject constructor( } viewModelScope.launch { when (val addSendType = state.addEditSendType) { - AddEditSendType.AddItem -> { + // A copy is saved as a brand new Send, leaving the original untouched. + AddEditSendType.AddItem, + is AddEditSendType.CopyItem, + -> { val fileType = content .selectedType as? AddEditSendState.ViewState.Content.SendType.File val result = vaultRepo.createSend( @@ -1098,7 +1098,10 @@ data class AddEditSendState( */ val screenDisplayName: Text get() = when (addEditSendType) { - AddEditSendType.AddItem -> when (sendType) { + // A copy is a new Send, so it is titled the same way as one. + AddEditSendType.AddItem, + is AddEditSendType.CopyItem, + -> when (sendType) { SendItemType.FILE -> BitwardenString.add_file_send.asText() SendItemType.TEXT -> BitwardenString.add_text_send.asText() } @@ -1129,9 +1132,29 @@ data class AddEditSendState( (viewState as? ViewState.Content)?.common?.isHideEmailAddressEnabled == false /** - * Helper to determine if the UI should display the content in add send mode. + * Helper to determine if this screen creates a brand-new Send rather than modifying an + * existing one. A copy is a new Send that happens to be pre-filled from another, so it counts + * as new despite being loaded from an existing one. */ - val isAddMode: Boolean get() = addEditSendType is AddEditSendType.AddItem + val isNewSend: Boolean + get() = when (addEditSendType) { + AddEditSendType.AddItem, + is AddEditSendType.CopyItem, + -> true + + is AddEditSendType.EditItem -> false + } + + /** + * Helper to determine the ID of the send whose data this screen loads, or `null` when the send + * is being created from scratch and there is nothing to load. + */ + val sourceSendItemIdOrNull: String? + get() = when (val type = addEditSendType) { + AddEditSendType.AddItem -> null + is AddEditSendType.CopyItem -> type.sendItemId + is AddEditSendType.EditItem -> type.sendItemId + } /** * Helper to determine if the currently displayed send has a password already set. diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/ui/tools/feature/send/addedit/model/AddEditSendType.kt b/app/src/main/kotlin/com/x8bit/bitwarden/ui/tools/feature/send/addedit/model/AddEditSendType.kt index 393083666e..f19425a362 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/ui/tools/feature/send/addedit/model/AddEditSendType.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/ui/tools/feature/send/addedit/model/AddEditSendType.kt @@ -13,6 +13,17 @@ sealed class AddEditSendType : Parcelable { @Parcelize data object AddItem : AddEditSendType() + /** + * Indicates that we want to create a new send item pre-filled from an existing one. + * + * This creates a send like [AddItem] does, but its initial values are loaded from the send + * being copied. + * + * @param sendItemId The ID of the send item to copy. + */ + @Parcelize + data class CopyItem(val sendItemId: String) : AddEditSendType() + /** * Indicates that we want to edit an existing send item. * diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/ui/tools/feature/send/addedit/util/SendViewExtensions.kt b/app/src/main/kotlin/com/x8bit/bitwarden/ui/tools/feature/send/addedit/util/SendViewExtensions.kt index c277263d04..f6938e3d4f 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/ui/tools/feature/send/addedit/util/SendViewExtensions.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/ui/tools/feature/send/addedit/util/SendViewExtensions.kt @@ -1,12 +1,85 @@ package com.x8bit.bitwarden.ui.tools.feature.send.addedit.util +import com.bitwarden.network.model.SendAccessTypeJson import com.bitwarden.send.SendType import com.bitwarden.send.SendView import com.x8bit.bitwarden.ui.tools.feature.send.addedit.AddEditSendState +import com.x8bit.bitwarden.ui.tools.feature.send.addedit.model.AddEditSendType import com.x8bit.bitwarden.ui.tools.feature.send.addedit.model.AuthEmail import com.x8bit.bitwarden.ui.tools.feature.send.addedit.model.SendAuth import com.x8bit.bitwarden.ui.tools.feature.send.util.toSendUrl import kotlinx.collections.immutable.toImmutableList +import java.time.Clock +import java.time.Instant +import java.time.temporal.ChronoUnit + +/** + * The deletion window applied to a new Send when no deletion date is enforced by policy (7 days). + */ +internal const val DEFAULT_DELETION_HOURS: Long = 7 * 24L + +/** + * Returns the [SendAuth] this access type restricts a Send to, preserving [current] when it already + * matches the restricted type so that any emails already entered are kept. + */ +internal fun SendAccessTypeJson.toEnforcedSendAuth(current: SendAuth): SendAuth = when (this) { + // Every option stays available, so the current selection is left alone. + SendAccessTypeJson.ANY -> current + SendAccessTypeJson.PASSWORD_PROTECTED -> SendAuth.Password + SendAccessTypeJson.SPECIFIC_PEOPLE -> current as? SendAuth.Email ?: SendAuth.Email() +} + +/** + * Returns the [SendAuth] describing who this Send is shared with. + */ +fun SendView.toSendAuth(): SendAuth = when { + hasPassword -> SendAuth.Password + emails.isNotEmpty() -> { + SendAuth.Email(emails = this.emails.map { AuthEmail(value = it) }.toImmutableList()) + } + + else -> SendAuth.None +} + +/** + * Maps this loaded [SendView] into the content for whichever mode the add/edit screen is in: the + * send's own values when editing it, or the values a compliant copy of it should start from. + * + * @param state The state being updated, which is the in-flight value rather than the view model's + * `state` property so that the mapping sees the same policy data as the update it belongs to. + * @param clock Used to derive the copy's deletion date from the enforced window. + * @param baseWebSendUrl The base URL used to build an existing send's share URL. + * @param isHideEmailAddressEnabled Whether hiding the sender's email is currently allowed. + */ +fun SendView.toAddEditViewState( + state: AddEditSendState, + clock: Clock, + baseWebSendUrl: String, + isHideEmailAddressEnabled: Boolean, +): AddEditSendState.ViewState.Content = when (state.addEditSendType) { + is AddEditSendType.CopyItem -> { + toCopyViewState( + deletionDate = clock.instant().plus( + state.enforcedDeletionHours?.toLong() ?: DEFAULT_DELETION_HOURS, + ChronoUnit.HOURS, + ), + isHideEmailAddressEnabled = isHideEmailAddressEnabled, + sendAuth = state + .enforcedWhoCanAccess + ?.toEnforcedSendAuth(current = this.toSendAuth()) + ?: this.toSendAuth(), + ) + } + + AddEditSendType.AddItem, + is AddEditSendType.EditItem, + -> { + toViewState( + baseWebSendUrl = baseWebSendUrl, + isHideEmailAddressEnabled = isHideEmailAddressEnabled, + ) + } +} /** * Transforms [SendView] into [AddEditSendState.ViewState.Content]. @@ -32,16 +105,7 @@ fun SendView.toViewState( sendUrl = this.toSendUrl(baseWebSendUrl), hasPassword = this.hasPassword, isHideEmailAddressEnabled = isHideEmailAddressEnabled, - sendAuth = when { - hasPassword -> SendAuth.Password - emails.isNotEmpty() -> { - SendAuth.Email( - emails = this.emails.map { AuthEmail(value = it) }.toImmutableList(), - ) - } - - else -> SendAuth.None - }, + sendAuth = this.toSendAuth(), ), selectedType = when (type) { SendType.TEXT -> { @@ -64,3 +128,63 @@ fun SendView.toViewState( SendType.ITEM -> TODO("[PM-41095] Support Item SendType") }, ) + +/** + * Transforms [SendView] into the [AddEditSendState.ViewState.Content] for a copy of it. + * + * A copy is a new Send rather than an edit of this one, so anything that belongs to the original is + * left behind: it has no share URL or access count yet, it is never created deactivated, and the + * original password cannot be carried over because it is never readable. [deletionDate] comes from + * the caller so that a policy-enforced window is applied to the copy. + * + * Recipient emails are copied as they are, even when a policy restricts their domains. The user is + * told which domains are allowed when they save, which is friendlier than silently dropping + * recipients they chose. + * + * @param deletionDate The deletion date to apply to the copy. + * @param isHideEmailAddressEnabled Whether hiding the sender's email is currently allowed. + * @param sendAuth The access type to apply to the copy, which the policy may have forced. + */ +fun SendView.toCopyViewState( + deletionDate: Instant, + isHideEmailAddressEnabled: Boolean, + sendAuth: SendAuth, +): AddEditSendState.ViewState.Content = + AddEditSendState.ViewState.Content( + common = AddEditSendState.ViewState.Content.Common( + originalSendView = null, + name = this.name, + currentAccessCount = null, + maxAccessCount = this.maxAccessCount?.toInt(), + passwordInput = "", + noteInput = this.notes.orEmpty(), + isHideEmailChecked = this.hideEmail && isHideEmailAddressEnabled, + isDeactivateChecked = false, + deletionDate = deletionDate, + expirationDate = null, + sendUrl = null, + hasPassword = false, + isHideEmailAddressEnabled = isHideEmailAddressEnabled, + sendAuth = sendAuth, + ), + selectedType = when (type) { + SendType.TEXT -> { + AddEditSendState.ViewState.Content.SendType.Text( + input = this.text?.text.orEmpty(), + isHideByDefaultChecked = this.text?.hidden == true, + ) + } + + // A file send's attachment cannot be uploaded again, so it is never offered a copy. + SendType.FILE -> { + AddEditSendState.ViewState.Content.SendType.File( + uri = null, + name = null, + displaySize = null, + sizeBytes = null, + ) + } + + SendType.ITEM -> TODO("[PM-41095] Support Item SendType") + }, + ) diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/ui/tools/feature/send/viewsend/ViewSendScreen.kt b/app/src/main/kotlin/com/x8bit/bitwarden/ui/tools/feature/send/viewsend/ViewSendScreen.kt index 6b51c614d1..b1f966b58e 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/ui/tools/feature/send/viewsend/ViewSendScreen.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/ui/tools/feature/send/viewsend/ViewSendScreen.kt @@ -51,6 +51,7 @@ import com.bitwarden.ui.platform.components.button.BitwardenFilledButton import com.bitwarden.ui.platform.components.button.BitwardenOutlinedButton import com.bitwarden.ui.platform.components.button.BitwardenOutlinedErrorButton import com.bitwarden.ui.platform.components.button.BitwardenStandardIconButton +import com.bitwarden.ui.platform.components.button.model.BitwardenButtonData import com.bitwarden.ui.platform.components.card.BitwardenActionCard import com.bitwarden.ui.platform.components.content.BitwardenErrorContent import com.bitwarden.ui.platform.components.content.BitwardenLoadingContent @@ -97,6 +98,16 @@ fun ViewSendScreen( EventsEffect(viewModel = viewModel) { event -> when (event) { is ViewSendEvent.NavigateBack -> onNavigateBack() + is ViewSendEvent.NavigateToCopy -> { + onNavigateToAddEditSend( + AddEditSendRoute( + sendType = event.sendType, + modeType = ModeType.COPY, + sendId = event.sendId, + ), + ) + } + is ViewSendEvent.NavigateToEdit -> { onNavigateToAddEditSend( AddEditSendRoute( @@ -158,6 +169,7 @@ fun ViewSendScreen( onCopyClick = { viewModel.trySendAction(ViewSendAction.CopyClick) }, onCopyNotesClick = { viewModel.trySendAction(ViewSendAction.CopyNotesClick) }, onDeleteClick = { viewModel.trySendAction(ViewSendAction.DeleteClick) }, + onMakeACopyClick = { viewModel.trySendAction(ViewSendAction.MakeACopyClick) }, onShareClick = { viewModel.trySendAction(ViewSendAction.ShareClick) }, ) } @@ -192,6 +204,7 @@ private fun ViewSendScreenContent( onCopyClick: () -> Unit, onCopyNotesClick: () -> Unit, onDeleteClick: () -> Unit, + onMakeACopyClick: () -> Unit, onShareClick: () -> Unit, modifier: Modifier = Modifier, ) { @@ -203,6 +216,7 @@ private fun ViewSendScreenContent( onCopyClick = onCopyClick, onCopyNotesClick = onCopyNotesClick, onDeleteClick = onDeleteClick, + onMakeACopyClick = onMakeACopyClick, onShareClick = onShareClick, modifier = modifier, ) @@ -229,6 +243,7 @@ private fun ViewStateContent( onCopyClick: () -> Unit, onCopyNotesClick: () -> Unit, onDeleteClick: () -> Unit, + onMakeACopyClick: () -> Unit, onShareClick: () -> Unit, modifier: Modifier = Modifier, ) { @@ -240,6 +255,11 @@ private fun ViewStateContent( BitwardenActionCard( cardTitle = stringResource(id = BitwardenString.organization_policy_restriction), cardSubtitle = it.message(), + actionButton = BitwardenButtonData( + label = BitwardenString.make_a_copy.asText(), + onClick = onMakeACopyClick, + ) + .takeIf { _ -> it.isCopyable }, leadingContent = { BitwardenIcon( iconData = IconData.Local(iconRes = BitwardenDrawable.ic_info_circle), diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/ui/tools/feature/send/viewsend/ViewSendViewModel.kt b/app/src/main/kotlin/com/x8bit/bitwarden/ui/tools/feature/send/viewsend/ViewSendViewModel.kt index edded7480c..2f68190a0d 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/ui/tools/feature/send/viewsend/ViewSendViewModel.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/ui/tools/feature/send/viewsend/ViewSendViewModel.kt @@ -109,6 +109,7 @@ class ViewSendViewModel @Inject constructor( ViewSendAction.DeleteClick -> handleDeleteClick() ViewSendAction.DialogDismiss -> handleDialogDismiss() ViewSendAction.EditClick -> handleEditClick() + ViewSendAction.MakeACopyClick -> handleMakeACopyClick() ViewSendAction.ShareClick -> handleShareClick() is ViewSendAction.Internal -> handleInternalAction(action) } @@ -172,6 +173,10 @@ class ViewSendViewModel @Inject constructor( sendEvent(ViewSendEvent.NavigateToEdit(sendType = state.sendType, sendId = state.sendId)) } + private fun handleMakeACopyClick() { + sendEvent(ViewSendEvent.NavigateToCopy(sendType = state.sendType, sendId = state.sendId)) + } + private fun handleShareClick() { onContent { sendEvent(ViewSendEvent.ShareText(text = it.shareLink.asText())) } } @@ -434,6 +439,14 @@ sealed class ViewSendEvent { */ data object NavigateBack : ViewSendEvent() + /** + * Navigate to the add send screen, pre-filled from the current send. + */ + data class NavigateToCopy( + val sendType: SendItemType, + val sendId: String, + ) : ViewSendEvent() + /** * Navigate to the edit send screen for the current send. */ @@ -491,6 +504,11 @@ sealed class ViewSendAction { */ data object EditClick : ViewSendAction() + /** + * The user clicked the make a copy button on the policy restriction warning. + */ + data object MakeACopyClick : ViewSendAction() + /** * The user has clicked the share button. */ diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/ui/tools/feature/send/viewsend/model/SendPolicyRestriction.kt b/app/src/main/kotlin/com/x8bit/bitwarden/ui/tools/feature/send/viewsend/model/SendPolicyRestriction.kt index 4544c46b1d..6787de68bf 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/ui/tools/feature/send/viewsend/model/SendPolicyRestriction.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/ui/tools/feature/send/viewsend/model/SendPolicyRestriction.kt @@ -11,6 +11,11 @@ import kotlinx.parcelize.Parcelize * warning shown on the view send screen and whether a compliant copy can be made. */ sealed class SendPolicyRestriction : Parcelable { + /** + * Whether a compliant copy of the Send can be made to replace it. + */ + abstract val isCopyable: Boolean + /** * The explanation shown to the user for this restriction. */ @@ -22,6 +27,8 @@ sealed class SendPolicyRestriction : Parcelable { */ @Parcelize data object FileNotCompliant : SendPolicyRestriction() { + override val isCopyable: Boolean get() = false + override val message: Text get() = BitwardenString .this_send_is_not_compliant_with_your_organizations_send_policy @@ -34,6 +41,8 @@ sealed class SendPolicyRestriction : Parcelable { */ @Parcelize data object TypeNotAllowed : SendPolicyRestriction() { + override val isCopyable: Boolean get() = false + override val message: Text get() = BitwardenString.text_sends_are_not_allowed_for_your_organization.asText() } @@ -43,6 +52,8 @@ sealed class SendPolicyRestriction : Parcelable { */ @Parcelize data object CopyRequired : SendPolicyRestriction() { + override val isCopyable: Boolean get() = true + override val message: Text get() = BitwardenString.to_edit_this_send_make_a_copy.asText() } diff --git a/app/src/test/kotlin/com/x8bit/bitwarden/ui/tools/feature/send/addedit/AddEditSendViewModelTest.kt b/app/src/test/kotlin/com/x8bit/bitwarden/ui/tools/feature/send/addedit/AddEditSendViewModelTest.kt index 4f4c97379f..dabc9292db 100644 --- a/app/src/test/kotlin/com/x8bit/bitwarden/ui/tools/feature/send/addedit/AddEditSendViewModelTest.kt +++ b/app/src/test/kotlin/com/x8bit/bitwarden/ui/tools/feature/send/addedit/AddEditSendViewModelTest.kt @@ -8,6 +8,7 @@ import com.bitwarden.core.data.repository.model.DataState import com.bitwarden.core.data.repository.util.bufferedMutableSharedFlow import com.bitwarden.data.repository.model.Environment import com.bitwarden.network.model.SendAccessTypeJson +import com.bitwarden.send.SendType import com.bitwarden.send.SendView import com.bitwarden.ui.platform.base.BaseViewModelTest import com.bitwarden.ui.platform.components.snackbar.model.BitwardenSnackbarData @@ -61,6 +62,7 @@ import kotlinx.coroutines.test.runTest import org.junit.jupiter.api.AfterEach import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Assertions.assertNull +import org.junit.jupiter.api.Assertions.assertTrue import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Test import java.time.Clock @@ -623,6 +625,101 @@ class AddEditSendViewModelTest : BaseViewModelTest() { } } + @Test + fun `copy mode should apply the enforced deletion window to the copy`() { + mutableSendControlsFlagFlow.value = true + mutableEffectiveSendPolicyFlow.value = + DEFAULT_EFFECTIVE_SEND_POLICY.copy(deletionHours = ENFORCED_DELETION_HOURS) + val mockSendView = createMockSendView(number = 1, type = SendType.TEXT) + mutableSendDataStateFlow.value = DataState.Loaded(mockSendView) + + val state = createViewModel( + addEditSendType = AddEditSendType.CopyItem(sendItemId = COPY_SEND_ID), + ) + .stateFlow + .value + + // The copy adopts the policy window rather than the original send's deletion date. + assertEquals( + copyModeState( + sendAuth = SendAuth.Password, + deletionDate = ENFORCED_DELETION_DATE, + isSendControlsEnabled = true, + deletionHours = ENFORCED_DELETION_HOURS, + ), + state, + ) + } + + @Test + fun `copy mode should apply the enforced access type to the copy`() { + mutableSendControlsFlagFlow.value = true + mutableEffectiveSendPolicyFlow.value = DEFAULT_EFFECTIVE_SEND_POLICY + .copy(whoCanAccess = SendAccessTypeJson.PASSWORD_PROTECTED) + val mockSendView = createMockSendView( + number = 1, + type = SendType.TEXT, + hasPassword = false, + ) + mutableSendDataStateFlow.value = DataState.Loaded(mockSendView) + + val state = createViewModel( + addEditSendType = AddEditSendType.CopyItem(sendItemId = COPY_SEND_ID), + ) + .stateFlow + .value + + assertEquals( + copyModeState( + sendAuth = SendAuth.Password, + isSendControlsEnabled = true, + whoCanAccess = SendAccessTypeJson.PASSWORD_PROTECTED, + ), + state, + ) + } + + @Test + fun `copy mode should be treated as a new send`() { + val mockSendView = createMockSendView(number = 1, type = SendType.TEXT) + mutableSendDataStateFlow.value = DataState.Loaded(mockSendView) + + val state = createViewModel( + addEditSendType = AddEditSendType.CopyItem(sendItemId = COPY_SEND_ID), + ) + .stateFlow + .value + + assertEquals(copyModeState(sendAuth = SendAuth.Password), state) + assertTrue(state.isNewSend) + } + + @Test + fun `SaveClick in copy mode should create a new send rather than update the original`() = + runTest { + val sendId = "sendId-1" + val mockSendView = createMockSendView(number = 1, type = SendType.TEXT) + mutableSendDataStateFlow.value = DataState.Loaded(mockSendView) + val viewModel = createViewModel( + addEditSendType = AddEditSendType.CopyItem(sendItemId = sendId), + ) + val content = viewModel.stateFlow.value.viewState as AddEditSendState.ViewState.Content + val createdSendView = mockk() + every { content.toSendView(clock) } returns createdSendView + coEvery { + vaultRepository.createSend(sendView = createdSendView, fileUri = null) + } returns CreateSendResult.Error(message = null, error = null) + + viewModel.trySendAction(AddEditSendAction.SaveClick) + + coVerify(exactly = 1) { + vaultRepository.createSend(sendView = createdSendView, fileUri = null) + } + coVerify(exactly = 0) { + vaultRepository.updateSend(sendId = any(), sendView = any()) + } + } + @Test fun `initial state should read from saved state when present`() { val savedState = DEFAULT_STATE.copy( @@ -2203,6 +2300,47 @@ private fun enforcedAccessState( ), ) +private const val COPY_SEND_ID: String = "sendId-1" + +/** + * Builds the state expected in copy mode, where the content is derived from the send being copied + * rather than from [DEFAULT_VIEW_STATE]. + */ +private fun copyModeState( + sendAuth: SendAuth, + deletionDate: Instant = Instant.parse("2023-11-03T12:00:00Z"), + isSendControlsEnabled: Boolean = false, + deletionHours: Int? = null, + whoCanAccess: SendAccessTypeJson? = null, +): AddEditSendState = DEFAULT_STATE.copy( + addEditSendType = AddEditSendType.CopyItem(sendItemId = COPY_SEND_ID), + isSendControlsEnabled = isSendControlsEnabled, + deletionHours = deletionHours, + whoCanAccess = whoCanAccess, + viewState = AddEditSendState.ViewState.Content( + common = AddEditSendState.ViewState.Content.Common( + originalSendView = null, + name = "mockName-1", + currentAccessCount = null, + maxAccessCount = 1, + passwordInput = "", + noteInput = "mockNotes-1", + isHideEmailChecked = false, + isDeactivateChecked = false, + deletionDate = deletionDate, + expirationDate = null, + sendUrl = null, + hasPassword = false, + isHideEmailAddressEnabled = true, + sendAuth = sendAuth, + ), + selectedType = AddEditSendState.ViewState.Content.SendType.Text( + input = "mockText-1", + isHideByDefaultChecked = false, + ), + ), +) + private val ENFORCED_DELETION_DATE: Instant = Instant.parse("2023-10-28T12:00:00Z") private const val ENFORCED_DELETION_HOURS: Int = 24 diff --git a/app/src/test/kotlin/com/x8bit/bitwarden/ui/tools/feature/send/addedit/util/SendViewExtensionsTest.kt b/app/src/test/kotlin/com/x8bit/bitwarden/ui/tools/feature/send/addedit/util/SendViewExtensionsTest.kt index 61d66a1f12..6865c2a552 100644 --- a/app/src/test/kotlin/com/x8bit/bitwarden/ui/tools/feature/send/addedit/util/SendViewExtensionsTest.kt +++ b/app/src/test/kotlin/com/x8bit/bitwarden/ui/tools/feature/send/addedit/util/SendViewExtensionsTest.kt @@ -3,13 +3,31 @@ package com.x8bit.bitwarden.ui.tools.feature.send.addedit.util import com.bitwarden.send.SendType import com.x8bit.bitwarden.data.vault.datasource.sdk.model.createMockSendView import com.x8bit.bitwarden.ui.tools.feature.send.addedit.AddEditSendState +import com.x8bit.bitwarden.ui.tools.feature.send.addedit.model.AuthEmail import com.x8bit.bitwarden.ui.tools.feature.send.addedit.model.SendAuth +import io.mockk.every +import io.mockk.mockkStatic +import io.mockk.unmockkStatic +import kotlinx.collections.immutable.persistentListOf +import org.junit.jupiter.api.AfterEach import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Test import java.time.Instant +import java.util.UUID class SendViewExtensionsTest { + @BeforeEach + fun setup() { + mockkStatic(UUID::randomUUID) + } + + @AfterEach + fun tearDown() { + unmockkStatic(UUID::randomUUID) + } + @Test fun `toViewState should create an appropriate ViewState for file type`() { val sendView = createMockSendView(number = 1, type = SendType.FILE) @@ -50,8 +68,172 @@ class SendViewExtensionsTest { result, ) } + + @Test + fun `toSendAuth should use a password when the original has one`() { + val sendView = createMockSendView(number = 1, hasPassword = true) + + assertEquals(SendAuth.Password, sendView.toSendAuth()) + } + + @Test + fun `toSendAuth should carry over the original recipients`() { + every { UUID.randomUUID().toString() } returns "uuid" + val sendView = createMockSendView(number = 1, hasPassword = false) + .copy(emails = listOf("one@example.com", "two@example.com")) + + assertEquals( + SendAuth.Email( + emails = persistentListOf( + AuthEmail(id = "uuid", value = "one@example.com"), + AuthEmail(id = "uuid", value = "two@example.com"), + ), + ), + sendView.toSendAuth(), + ) + } + + @Test + fun `toSendAuth should use link access when the original has neither`() { + val sendView = createMockSendView(number = 1, hasPassword = false) + + assertEquals(SendAuth.None, sendView.toSendAuth()) + } + + @Test + fun `toCopyViewState should carry over the copyable fields of a text send`() { + val sendView = createMockSendView(number = 1, type = SendType.TEXT) + + val result = sendView.toCopyViewState( + deletionDate = COPY_DELETION_DATE, + isHideEmailAddressEnabled = true, + sendAuth = SendAuth.None, + ) + + assertEquals( + AddEditSendState.ViewState.Content( + common = COPY_COMMON, + selectedType = DEFAULT_TEXT_TYPE, + ), + result, + ) + } + + @Test + fun `toCopyViewState should not carry over anything belonging to the original send`() { + val sendView = createMockSendView( + number = 1, + type = SendType.TEXT, + disabled = true, + hasPassword = true, + ) + + val result = sendView.toCopyViewState( + deletionDate = COPY_DELETION_DATE, + isHideEmailAddressEnabled = true, + sendAuth = SendAuth.None, + ) + + // The copy is a brand new Send, so the original's URL, access count, expiration date, + // password and deactivated state are all left behind. + assertEquals( + AddEditSendState.ViewState.Content( + common = COPY_COMMON, + selectedType = DEFAULT_TEXT_TYPE, + ), + result, + ) + } + + @Test + fun `toCopyViewState should use the deletion date it is given`() { + val sendView = createMockSendView(number = 1, type = SendType.TEXT) + + val result = sendView.toCopyViewState( + deletionDate = OTHER_DELETION_DATE, + isHideEmailAddressEnabled = true, + sendAuth = SendAuth.None, + ) + + assertEquals( + AddEditSendState.ViewState.Content( + common = COPY_COMMON.copy(deletionDate = OTHER_DELETION_DATE), + selectedType = DEFAULT_TEXT_TYPE, + ), + result, + ) + } + + @Test + fun `toCopyViewState should use the access type it is given`() { + val sendView = createMockSendView(number = 1, type = SendType.TEXT) + val sendAuth = SendAuth.Email( + emails = persistentListOf(AuthEmail(id = "id", value = "test@example.com")), + ) + + val result = sendView.toCopyViewState( + deletionDate = COPY_DELETION_DATE, + isHideEmailAddressEnabled = true, + sendAuth = sendAuth, + ) + + assertEquals( + AddEditSendState.ViewState.Content( + common = COPY_COMMON.copy(sendAuth = sendAuth), + selectedType = DEFAULT_TEXT_TYPE, + ), + result, + ) + } + + @Test + fun `toCopyViewState should not hide the email when the policy disallows it`() { + val sendView = createMockSendView(number = 1, type = SendType.TEXT, hideEmail = true) + + val result = sendView.toCopyViewState( + deletionDate = COPY_DELETION_DATE, + isHideEmailAddressEnabled = false, + sendAuth = SendAuth.None, + ) + + assertEquals( + AddEditSendState.ViewState.Content( + common = COPY_COMMON.copy(isHideEmailAddressEnabled = false), + selectedType = DEFAULT_TEXT_TYPE, + ), + result, + ) + } + + @Test + fun `toCopyViewState should drop the attachment of a file send`() { + val sendView = createMockSendView(number = 1, type = SendType.FILE) + + val result = sendView.toCopyViewState( + deletionDate = COPY_DELETION_DATE, + isHideEmailAddressEnabled = true, + sendAuth = SendAuth.None, + ) + + assertEquals( + AddEditSendState.ViewState.Content( + common = COPY_COMMON, + selectedType = AddEditSendState.ViewState.Content.SendType.File( + uri = null, + name = null, + displaySize = null, + sizeBytes = null, + ), + ), + result, + ) + } } +private val COPY_DELETION_DATE: Instant = Instant.parse("2023-11-03T12:00:00Z") + +private val OTHER_DELETION_DATE: Instant = Instant.parse("2023-12-25T12:00:00Z") + private val DEFAULT_COMMON: AddEditSendState.ViewState.Content.Common = AddEditSendState.ViewState.Content.Common( name = "mockName-1", @@ -88,3 +270,21 @@ private val DEFAULT_STATE: AddEditSendState.ViewState.Content = common = DEFAULT_COMMON, selectedType = DEFAULT_FILE_TYPE, ) + +private val COPY_COMMON: AddEditSendState.ViewState.Content.Common = + AddEditSendState.ViewState.Content.Common( + originalSendView = null, + name = "mockName-1", + currentAccessCount = null, + maxAccessCount = 1, + passwordInput = "", + noteInput = "mockNotes-1", + isHideEmailChecked = false, + isDeactivateChecked = false, + deletionDate = COPY_DELETION_DATE, + expirationDate = null, + sendUrl = null, + hasPassword = false, + isHideEmailAddressEnabled = true, + sendAuth = SendAuth.None, + ) diff --git a/app/src/test/kotlin/com/x8bit/bitwarden/ui/tools/feature/send/viewsend/ViewSendScreenTest.kt b/app/src/test/kotlin/com/x8bit/bitwarden/ui/tools/feature/send/viewsend/ViewSendScreenTest.kt index 010ceb0ab8..173507283f 100644 --- a/app/src/test/kotlin/com/x8bit/bitwarden/ui/tools/feature/send/viewsend/ViewSendScreenTest.kt +++ b/app/src/test/kotlin/com/x8bit/bitwarden/ui/tools/feature/send/viewsend/ViewSendScreenTest.kt @@ -177,6 +177,73 @@ class ViewSendScreenTest : BitwardenComposeTest() { .assertIsDisplayed() } + @Test + fun `make a copy button should only be displayed when a copy is possible`() { + mutableStateFlow.update { + it.copy( + isSendDisabled = true, + isSendControlsEnabled = true, + isSendControlsExistingSendsEnabled = true, + ) + } + + composeTestRule + .onNodeWithText(text = "Make a copy") + .performScrollTo() + .assertIsDisplayed() + + // A file send cannot be copied, so the action is not offered. + mutableStateFlow.update { it.copy(sendType = SendItemType.FILE) } + + composeTestRule + .onNodeWithText(text = "Make a copy") + .assertDoesNotExist() + + // Neither can a send whose type the policy no longer allows. + mutableStateFlow.update { + it.copy(sendType = SendItemType.TEXT, allowedSendTypes = listOf(SendTypeJson.FILE)) + } + + composeTestRule + .onNodeWithText(text = "Make a copy") + .assertDoesNotExist() + } + + @Test + fun `on make a copy click should send MakeACopyClick`() { + mutableStateFlow.update { + it.copy( + isSendDisabled = true, + isSendControlsEnabled = true, + isSendControlsExistingSendsEnabled = true, + ) + } + + composeTestRule + .onNodeWithText(text = "Make a copy") + .performScrollTo() + .performClick() + + verify(exactly = 1) { + viewModel.trySendAction(ViewSendAction.MakeACopyClick) + } + } + + @Test + fun `on NavigateToCopy event should navigate to add send in copy mode`() { + val sendType = SendItemType.TEXT + val sendId = "send_id" + + mutableEventFlow.tryEmit( + ViewSendEvent.NavigateToCopy(sendType = sendType, sendId = sendId), + ) + + assertEquals( + AddEditSendRoute(sendId = sendId, sendType = sendType, modeType = ModeType.COPY), + onNavigateToAddEditRoute, + ) + } + @Test fun `edit fab should be hidden when a policy restriction applies`() { composeTestRule diff --git a/app/src/test/kotlin/com/x8bit/bitwarden/ui/tools/feature/send/viewsend/ViewSendViewModelTest.kt b/app/src/test/kotlin/com/x8bit/bitwarden/ui/tools/feature/send/viewsend/ViewSendViewModelTest.kt index 13a5993b39..8233df6eff 100644 --- a/app/src/test/kotlin/com/x8bit/bitwarden/ui/tools/feature/send/viewsend/ViewSendViewModelTest.kt +++ b/app/src/test/kotlin/com/x8bit/bitwarden/ui/tools/feature/send/viewsend/ViewSendViewModelTest.kt @@ -234,6 +234,18 @@ class ViewSendViewModelTest : BaseViewModelTest() { assertFalse(state.isFabVisible) } + @Test + fun `on MakeACopyClick should send NavigateToCopy`() = runTest { + val viewModel = createViewModel() + viewModel.eventFlow.test { + viewModel.trySendAction(ViewSendAction.MakeACopyClick) + assertEquals( + ViewSendEvent.NavigateToCopy(sendType = SendItemType.TEXT, sendId = "send_id"), + awaitItem(), + ) + } + } + @Test fun `on CloseClick should send NavigateBack`() = runTest { val viewModel = createViewModel() diff --git a/ui/src/main/res/values/strings.xml b/ui/src/main/res/values/strings.xml index a425f285f5..44267d14d4 100644 --- a/ui/src/main/res/values/strings.xml +++ b/ui/src/main/res/values/strings.xml @@ -516,6 +516,7 @@ Scanning will happen automatically. This Send is not compliant with your organization’s Send policy and will automatically expire at the set deletion date. Text Sends are not allowed for your organization and this Send will automatically expire at the set deletion date. To edit this Send, make a copy. If this Send can expire at the set deletion date, no action is needed. + Make a copy Free accounts are restricted to sharing text only. A Premium membership is required to use files with Send. Sharing with specific people is a Premium feature. Your current plan does not include access to this feature. Master password re-prompt