[PM-42110] feat: Make a copy of a policy-restricted Send (#7276)

This commit is contained in:
André Bispo authored and GitHub committed 2026-08-20 19:51:02 +00:00
1 parent f5a96ebfd8
commit a26ddf45dc
14 files changed
+688 -61

No files matched your search

@@ -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(),
) {
@@ -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))
},
)
@@ -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,
@@ -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<String> = 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.
@@ -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.
*
@@ -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")
},
)
@@ -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),
@@ -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.
*/
@@ -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()
}
@@ -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<SendView>()
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
@@ -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,
)
@@ -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
@@ -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()
+1
View File
@@ -516,6 +516,7 @@ Scanning will happen automatically.</string>
<string name="this_send_is_not_compliant_with_your_organizations_send_policy">This Send is not compliant with your organization’s Send policy and will automatically expire at the set deletion date.</string>
<string name="text_sends_are_not_allowed_for_your_organization">Text Sends are not allowed for your organization and this Send will automatically expire at the set deletion date.</string>
<string name="to_edit_this_send_make_a_copy">To edit this Send, make a copy. If this Send can expire at the set deletion date, no action is needed.</string>
<string name="make_a_copy">Make a copy</string>
<string name="send_file_premium_required">Free accounts are restricted to sharing text only. A Premium membership is required to use files with Send.</string>
<string name="sharing_with_specific_people_is_a_premium_feature">Sharing with specific people is a Premium feature. Your current plan does not include access to this feature.</string>
<string name="password_prompt">Master password re-prompt</string>