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