[PM-40534] feat: Restrict Send creation flow when only one Send type is allowed (#7249)

This commit is contained in:
André Bispo authored and GitHub committed 2026-08-12 17:28:12 +00:00
1 parent 68c69a40b2
commit 1ef5568f21
7 files changed
+262 -56

No files matched your search

@@ -33,6 +33,7 @@ private const val SEND_TYPES_COUNT: Int = 2
@Composable
fun SendContent(
policyDisablesSend: Boolean,
shouldShowTypesSection: Boolean,
state: SendState.ViewState.Content,
upgradedToPremiumCardData: UpgradedToPremiumCardData?,
sendHandlers: SendHandlers,
@@ -67,44 +68,46 @@ fun SendContent(
}
}
item {
BitwardenListHeaderText(
label = stringResource(id = BitwardenString.types),
supportingLabel = SEND_TYPES_COUNT.toString(),
modifier = Modifier
.fillMaxWidth()
.standardHorizontalMargin()
.padding(horizontal = 16.dp),
)
Spacer(modifier = Modifier.height(height = 8.dp))
}
if (shouldShowTypesSection) {
item {
BitwardenListHeaderText(
label = stringResource(id = BitwardenString.types),
supportingLabel = SEND_TYPES_COUNT.toString(),
modifier = Modifier
.fillMaxWidth()
.standardHorizontalMargin()
.padding(horizontal = 16.dp),
)
Spacer(modifier = Modifier.height(height = 8.dp))
}
item {
BitwardenGroupItem(
label = stringResource(id = BitwardenString.type_text),
supportingLabel = state.textTypeCount.toString(),
startIcon = IconData.Local(iconRes = BitwardenDrawable.ic_file_text),
onClick = sendHandlers.onTextTypeClick,
cardStyle = CardStyle.Top(dividerPadding = 56.dp),
modifier = Modifier
.fillMaxWidth()
.testTag("SendTextFilter")
.standardHorizontalMargin(),
)
}
item {
BitwardenGroupItem(
label = stringResource(id = BitwardenString.type_text),
supportingLabel = state.textTypeCount.toString(),
startIcon = IconData.Local(iconRes = BitwardenDrawable.ic_file_text),
onClick = sendHandlers.onTextTypeClick,
cardStyle = CardStyle.Top(dividerPadding = 56.dp),
modifier = Modifier
.fillMaxWidth()
.testTag("SendTextFilter")
.standardHorizontalMargin(),
)
}
item {
BitwardenGroupItem(
label = stringResource(id = BitwardenString.type_file),
supportingLabel = state.fileTypeCount.toString(),
startIcon = IconData.Local(iconRes = BitwardenDrawable.ic_file),
onClick = sendHandlers.onFileTypeClick,
cardStyle = CardStyle.Bottom,
modifier = Modifier
.fillMaxWidth()
.testTag("SendFileFilter")
.standardHorizontalMargin(),
)
item {
BitwardenGroupItem(
label = stringResource(id = BitwardenString.type_file),
supportingLabel = state.fileTypeCount.toString(),
startIcon = IconData.Local(iconRes = BitwardenDrawable.ic_file),
onClick = sendHandlers.onFileTypeClick,
cardStyle = CardStyle.Bottom,
modifier = Modifier
.fillMaxWidth()
.testTag("SendFileFilter")
.standardHorizontalMargin(),
)
}
}
item {
@@ -202,6 +202,7 @@ fun SendScreen(
when (val viewState = state.viewState) {
is SendState.ViewState.Content -> SendContent(
policyDisablesSend = state.policyDisablesSend,
shouldShowTypesSection = state.shouldShowTypesSection,
state = viewState,
upgradedToPremiumCardData = upgradedToPremiumCardData,
sendHandlers = sendHandlers,
@@ -21,6 +21,7 @@ import com.x8bit.bitwarden.data.billing.manager.PremiumStateManager
import com.x8bit.bitwarden.data.billing.manager.UPGRADED_TO_PREMIUM_LEARN_MORE_URL
import com.x8bit.bitwarden.data.platform.manager.PolicyManager
import com.x8bit.bitwarden.data.platform.manager.clipboard.BitwardenClipboardManager
import com.x8bit.bitwarden.data.platform.manager.model.EffectiveSendPolicy
import com.x8bit.bitwarden.data.platform.manager.network.NetworkConnectionManager
import com.x8bit.bitwarden.data.platform.repository.EnvironmentRepository
import com.x8bit.bitwarden.data.platform.repository.SettingsRepository
@@ -30,6 +31,7 @@ import com.x8bit.bitwarden.data.vault.repository.model.RemovePasswordSendResult
import com.x8bit.bitwarden.data.vault.repository.model.SendData
import com.x8bit.bitwarden.ui.platform.model.SnackbarRelay
import com.x8bit.bitwarden.ui.tools.feature.send.model.SendItemType
import com.x8bit.bitwarden.ui.tools.feature.send.util.toSendItemType
import com.x8bit.bitwarden.ui.tools.feature.send.util.toViewState
import com.x8bit.bitwarden.ui.vault.feature.item.VaultItemScreen
import dagger.hilt.android.lifecycle.HiltViewModel
@@ -64,15 +66,18 @@ class SendViewModel @Inject constructor(
) : BaseViewModel<SendState, SendEvent, SendAction>(
// We load the state from the savedStateHandle for testing purposes.
initialState = savedStateHandle[KEY_STATE]
?: SendState(
viewState = SendState.ViewState.Loading,
dialogState = null,
isPullToRefreshSettingEnabled = settingsRepo.getPullToRefreshEnabledFlow().value,
policyDisablesSend = policyManager.getEffectiveSendPolicy().disableSend,
isRefreshing = false,
isPremiumUser = authRepo.userStateFlow.value?.activeAccount?.isPremium == true,
isUpgradedToPremiumCardEligible = false,
),
?: policyManager.getEffectiveSendPolicy().let { effectiveSendPolicy ->
SendState(
viewState = SendState.ViewState.Loading,
dialogState = null,
isPullToRefreshSettingEnabled = settingsRepo.getPullToRefreshEnabledFlow().value,
policyDisablesSend = effectiveSendPolicy.disableSend,
singleAllowedSendType = effectiveSendPolicy.singleAllowedSendType,
isRefreshing = false,
isPremiumUser = authRepo.userStateFlow.value?.activeAccount?.isPremium == true,
isUpgradedToPremiumCardEligible = false,
)
},
) {
init {
@@ -83,7 +88,7 @@ class SendViewModel @Inject constructor(
.launchIn(viewModelScope)
policyManager
.getEffectiveSendPolicyFlow()
.map { SendAction.Internal.PolicyUpdateReceive(it.disableSend) }
.map { SendAction.Internal.PolicyUpdateReceive(effectiveSendPolicy = it) }
.onEach(::sendAction)
.launchIn(viewModelScope)
vaultRepo
@@ -314,7 +319,8 @@ class SendViewModel @Inject constructor(
private fun handlePolicyUpdateReceive(action: SendAction.Internal.PolicyUpdateReceive) {
mutableStateFlow.update {
it.copy(
policyDisablesSend = action.policyDisablesSend,
policyDisablesSend = action.effectiveSendPolicy.disableSend,
singleAllowedSendType = action.effectiveSendPolicy.singleAllowedSendType,
)
}
}
@@ -324,13 +330,22 @@ class SendViewModel @Inject constructor(
}
private fun handleAddSendClick() {
mutableStateFlow.update {
it.copy(dialogState = SendState.DialogState.SelectSendAddType)
}
state
.singleAllowedSendType
// Skip the type picker when the policy only allows a single type, while still applying
// the same restrictions the picker selection would.
?.let { navigateToNewSendOrShowDialog(sendType = it) }
?: mutableStateFlow.update {
it.copy(dialogState = SendState.DialogState.SelectSendAddType)
}
}
private fun handleAddSendSelected(action: SendAction.AddSendSelected) {
if (action.sendType == SendItemType.FILE) {
navigateToNewSendOrShowDialog(sendType = action.sendType)
}
private fun navigateToNewSendOrShowDialog(sendType: SendItemType) {
if (sendType == SendItemType.FILE) {
if (state.policyDisablesSend) {
mutableStateFlow.update {
it.copy(
@@ -350,7 +365,7 @@ class SendViewModel @Inject constructor(
}
}
mutableStateFlow.update { it.copy(dialogState = null) }
sendEvent(SendEvent.NavigateNewSend(sendType = action.sendType))
sendEvent(SendEvent.NavigateNewSend(sendType = sendType))
}
private fun handleLockClick() {
@@ -499,6 +514,7 @@ data class SendState(
val dialogState: DialogState?,
private val isPullToRefreshSettingEnabled: Boolean,
val policyDisablesSend: Boolean,
val singleAllowedSendType: SendItemType?,
val isRefreshing: Boolean,
val isPremiumUser: Boolean,
val isUpgradedToPremiumCardEligible: Boolean = false,
@@ -509,6 +525,13 @@ data class SendState(
val shouldShowSearchIcon: Boolean
get() = viewState is ViewState.Content
/**
* Whether the types section should be shown. Filtering by type is meaningless when the policy
* only allows a single type.
*/
val shouldShowTypesSection: Boolean
get() = singleAllowedSendType == null
/**
* Indicates that the pull-to-refresh should be enabled in the UI.
*/
@@ -803,7 +826,7 @@ sealed class SendAction {
* Indicates that a policy update has been received.
*/
data class PolicyUpdateReceive(
val policyDisablesSend: Boolean,
val effectiveSendPolicy: EffectiveSendPolicy,
) : Internal()
/**
@@ -905,3 +928,13 @@ sealed class SendEvent {
)
}
}
/**
* The only [SendItemType] the policy permits, or `null` when the user is not restricted to a single
* type. A `null` [EffectiveSendPolicy.allowedSendTypes] means no restriction is in effect, which is
* also the case whenever the send controls feature flag is disabled.
*/
private val EffectiveSendPolicy.singleAllowedSendType: SendItemType?
get() = allowedSendTypes
?.singleOrNull()
?.toSendItemType()
@@ -1,5 +1,6 @@
package com.x8bit.bitwarden.ui.tools.feature.send.util
import com.bitwarden.network.model.SendTypeJson
import com.bitwarden.send.SendType
import com.x8bit.bitwarden.ui.tools.feature.send.model.SendItemType
@@ -12,3 +13,12 @@ fun SendType.toSendItemType(): SendItemType =
SendType.TEXT -> SendItemType.TEXT
SendType.ITEM -> TODO("[PM-41095] Support Item SendType")
}
/**
* Converts the [SendTypeJson] to its corresponding [SendItemType].
*/
fun SendTypeJson.toSendItemType(): SendItemType =
when (this) {
SendTypeJson.FILE -> SendItemType.FILE
SendTypeJson.TEXT -> SendItemType.TEXT
}
@@ -16,6 +16,7 @@ import androidx.compose.ui.test.isPopup
import androidx.compose.ui.test.onAllNodesWithText
import androidx.compose.ui.test.onChildren
import androidx.compose.ui.test.onNodeWithContentDescription
import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import androidx.compose.ui.test.performScrollTo
@@ -356,6 +357,28 @@ class SendScreenTest : BitwardenComposeTest() {
}
}
@Test
fun `types section should be displayed according to state`() {
mutableStateFlow.update {
it.copy(viewState = DEFAULT_CONTENT_VIEW_STATE, singleAllowedSendType = null)
}
composeTestRule.onNode(hasScrollToNodeAction()).performScrollToNode(hasText("Text"))
composeTestRule.onNodeWithTag("SendTextFilter").assertIsDisplayed()
composeTestRule.onNodeWithTag("SendFileFilter").assertIsDisplayed()
// Filtering by type is meaningless when only one type is allowed, so the whole section
// is removed rather than partially rendered.
mutableStateFlow.update {
it.copy(
viewState = DEFAULT_CONTENT_VIEW_STATE,
singleAllowedSendType = SendItemType.TEXT,
)
}
composeTestRule.onNodeWithTag("SendTextFilter").assertDoesNotExist()
composeTestRule.onNodeWithTag("SendFileFilter").assertDoesNotExist()
composeTestRule.onNodeWithText("Types").assertDoesNotExist()
}
@Test
fun `text type count should be updated according to state`() {
val rowText = "Text"
@@ -1107,6 +1130,7 @@ private val DEFAULT_STATE: SendState = SendState(
dialogState = null,
isPullToRefreshSettingEnabled = false,
policyDisablesSend = false,
singleAllowedSendType = null,
isRefreshing = false,
isPremiumUser = false,
)
@@ -6,6 +6,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.data.repository.util.baseWebSendUrl
import com.bitwarden.network.model.SendTypeJson
import com.bitwarden.ui.platform.base.BaseViewModelTest
import com.bitwarden.ui.platform.components.snackbar.model.BitwardenSnackbarData
import com.bitwarden.ui.platform.manager.snackbar.SnackbarRelayManager
@@ -41,7 +42,6 @@ import io.mockk.verify
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.emptyFlow
import kotlinx.coroutines.test.advanceTimeBy
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.AfterEach
@@ -72,9 +72,10 @@ class SendViewModelTest : BaseViewModelTest() {
private val vaultRepo: VaultRepository = mockk {
every { sendDataStateFlow } returns mutableSendDataFlow
}
private val mutableEffectiveSendPolicyFlow = MutableStateFlow(DEFAULT_EFFECTIVE_SEND_POLICY)
private val policyManager: PolicyManager = mockk {
every { getEffectiveSendPolicy() } returns DEFAULT_EFFECTIVE_SEND_POLICY
every { getEffectiveSendPolicyFlow() } returns emptyFlow()
every { getEffectiveSendPolicy() } answers { mutableEffectiveSendPolicyFlow.value }
every { getEffectiveSendPolicyFlow() } returns mutableEffectiveSendPolicyFlow
}
private val networkConnectionManager: NetworkConnectionManager = mockk {
@@ -129,6 +130,115 @@ class SendViewModelTest : BaseViewModelTest() {
)
}
@Test
fun `AddSendClick should still display SelectSendAddType dialog when both types are allowed`() {
mutableEffectiveSendPolicyFlow.value = DEFAULT_EFFECTIVE_SEND_POLICY.copy(
allowedSendTypes = listOf(SendTypeJson.TEXT, SendTypeJson.FILE),
)
val viewModel = createViewModel()
viewModel.trySendAction(SendAction.AddSendClick)
assertEquals(
DEFAULT_STATE.copy(dialogState = SendState.DialogState.SelectSendAddType),
viewModel.stateFlow.value,
)
}
@Test
fun `AddSendClick should skip the type dialog when only text sends are allowed`() = runTest {
mutableEffectiveSendPolicyFlow.value = DEFAULT_EFFECTIVE_SEND_POLICY.copy(
allowedSendTypes = listOf(SendTypeJson.TEXT),
)
val viewModel = createViewModel()
viewModel.eventFlow.test {
viewModel.trySendAction(SendAction.AddSendClick)
assertEquals(SendEvent.NavigateNewSend(sendType = SendItemType.TEXT), awaitItem())
}
assertEquals(
DEFAULT_STATE.copy(singleAllowedSendType = SendItemType.TEXT),
viewModel.stateFlow.value,
)
}
@Test
fun `AddSendClick should require premium when only file sends are allowed`() {
mutableEffectiveSendPolicyFlow.value = DEFAULT_EFFECTIVE_SEND_POLICY.copy(
allowedSendTypes = listOf(SendTypeJson.FILE),
)
mutableUserStateFlow.value = DEFAULT_USER_STATE.copy(
accounts = listOf(DEFAULT_USER_ACCOUNT_STATE.copy(isPremium = false)),
)
val viewModel = createViewModel()
viewModel.trySendAction(SendAction.AddSendClick)
assertEquals(
DEFAULT_STATE.copy(
dialogState = SendState.DialogState.FileTypeRequiresPremium,
singleAllowedSendType = SendItemType.FILE,
),
viewModel.stateFlow.value,
)
}
@Test
fun `AddSendClick should warn when only file sends are allowed and sends are disabled`() {
mutableEffectiveSendPolicyFlow.value = DEFAULT_EFFECTIVE_SEND_POLICY.copy(
allowedSendTypes = listOf(SendTypeJson.FILE),
disableSend = true,
)
val viewModel = createViewModel()
viewModel.trySendAction(SendAction.AddSendClick)
assertEquals(
DEFAULT_STATE.copy(
dialogState = SendState.DialogState.Error(
title = null,
message = BitwardenString.send_disabled_warning.asText(),
),
policyDisablesSend = true,
singleAllowedSendType = SendItemType.FILE,
),
viewModel.stateFlow.value,
)
}
@Test
fun `policyDisablesSend should update when the effective send policy disables sends`() =
runTest {
val viewModel = createViewModel()
viewModel.stateFlow.test {
assertEquals(DEFAULT_STATE, awaitItem())
// While the send controls flag is disabled this is driven by the legacy
// disable-send policy, which must continue to surface the policy warning.
mutableEffectiveSendPolicyFlow.value =
DEFAULT_EFFECTIVE_SEND_POLICY.copy(disableSend = true)
assertEquals(DEFAULT_STATE.copy(policyDisablesSend = true), awaitItem())
}
}
@Test
fun `state should update when the allowed send types change while the screen is open`() =
runTest {
val viewModel = createViewModel()
viewModel.stateFlow.test {
assertEquals(DEFAULT_STATE, awaitItem())
mutableEffectiveSendPolicyFlow.value = DEFAULT_EFFECTIVE_SEND_POLICY.copy(
allowedSendTypes = listOf(SendTypeJson.FILE),
)
assertEquals(
DEFAULT_STATE.copy(singleAllowedSendType = SendItemType.FILE),
awaitItem(),
)
}
}
@Test
fun `AddSendSelected with text type should emit NavigateNewSend`() = runTest {
val viewModel = createViewModel()
@@ -141,6 +251,8 @@ class SendViewModelTest : BaseViewModelTest() {
@Test
fun `AddSendSelected with file type and disabled send policy should display warning dialog`() {
mutableEffectiveSendPolicyFlow.value =
DEFAULT_EFFECTIVE_SEND_POLICY.copy(disableSend = true)
val state = DEFAULT_STATE.copy(policyDisablesSend = true)
val viewModel = createViewModel(state = state)
viewModel.trySendAction(SendAction.AddSendSelected(sendType = SendItemType.FILE))
@@ -819,6 +931,7 @@ private val DEFAULT_STATE: SendState = SendState(
dialogState = null,
isPullToRefreshSettingEnabled = false,
policyDisablesSend = false,
singleAllowedSendType = null,
isRefreshing = false,
isPremiumUser = false,
)
@@ -0,0 +1,22 @@
package com.x8bit.bitwarden.ui.tools.feature.send.util
import com.bitwarden.network.model.SendTypeJson
import com.bitwarden.send.SendType
import com.x8bit.bitwarden.ui.tools.feature.send.model.SendItemType
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Test
class SendTypeExtensionsTest {
@Test
fun `toSendItemType should map each SendType to its matching SendItemType`() {
assertEquals(SendItemType.FILE, SendType.FILE.toSendItemType())
assertEquals(SendItemType.TEXT, SendType.TEXT.toSendItemType())
}
@Test
fun `toSendItemType should map each SendTypeJson to its matching SendItemType`() {
assertEquals(SendItemType.FILE, SendTypeJson.FILE.toSendItemType())
assertEquals(SendItemType.TEXT, SendTypeJson.TEXT.toSendItemType())
}
}