[PM-40530] feat: Hide "hide my email switch" when enforced by SendControls policy (#7244)

This commit is contained in:
André Bispo authored and GitHub committed 2026-08-12 17:28:11 +00:00
1 parent 8cd703e1b2
commit 68c69a40b2
5 files changed
+245 -54

No files matched your search

@@ -64,6 +64,7 @@ fun AddEditSendContent(
state: AddEditSendState.ViewState.Content,
policyDisablesSend: Boolean,
policySendOptionsInEffect: Boolean,
shouldHideEmailAddressToggle: Boolean,
isAddMode: Boolean,
isShared: Boolean,
addSendHandlers: AddEditSendHandlers,
@@ -181,6 +182,7 @@ fun AddEditSendContent(
AddEditSendOptions(
state = state,
isSendsRestrictedByPolicy = policyDisablesSend,
shouldHideEmailAddressToggle = shouldHideEmailAddressToggle,
isAddMode = isAddMode,
addSendHandlers = addSendHandlers,
)
@@ -383,6 +385,7 @@ private fun ColumnScope.FileTypeContent(
private fun AddEditSendOptions(
state: AddEditSendState.ViewState.Content,
isSendsRestrictedByPolicy: Boolean,
shouldHideEmailAddressToggle: Boolean,
isAddMode: Boolean,
addSendHandlers: AddEditSendHandlers,
) {
@@ -466,19 +469,22 @@ private fun AddEditSendOptions(
},
)
}
Spacer(modifier = Modifier.height(height = 8.dp))
BitwardenSwitch(
modifier = Modifier
.testTag("SendHideEmailSwitch")
.fillMaxWidth()
.standardHorizontalMargin(),
label = stringResource(id = BitwardenString.hide_email),
isChecked = state.common.isHideEmailChecked,
onCheckedChange = addSendHandlers.onHideEmailToggle,
readOnly = isSendsRestrictedByPolicy,
enabled = state.common.isHideEmailChecked || state.common.isHideEmailAddressEnabled,
cardStyle = CardStyle.Full,
)
if (!shouldHideEmailAddressToggle) {
Spacer(modifier = Modifier.height(height = 8.dp))
BitwardenSwitch(
modifier = Modifier
.testTag("SendHideEmailSwitch")
.fillMaxWidth()
.standardHorizontalMargin(),
label = stringResource(id = BitwardenString.hide_email),
isChecked = state.common.isHideEmailChecked,
onCheckedChange = addSendHandlers.onHideEmailToggle,
readOnly = isSendsRestrictedByPolicy,
enabled = state.common.isHideEmailChecked ||
state.common.isHideEmailAddressEnabled,
cardStyle = CardStyle.Full,
)
}
Spacer(modifier = Modifier.height(8.dp))
BitwardenTextField(
label = stringResource(id = BitwardenString.private_notes),
@@ -175,6 +175,7 @@ fun AddEditSendScreen(
state = viewState,
policyDisablesSend = state.policyDisablesSend,
policySendOptionsInEffect = state.shouldDisplayPolicyWarning,
shouldHideEmailAddressToggle = state.shouldHideEmailAddressToggle,
isAddMode = state.isAddMode,
isShared = state.isShared,
addSendHandlers = addSendHandlers,
@@ -4,6 +4,7 @@ import android.net.Uri
import android.os.Parcelable
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.viewModelScope
import com.bitwarden.core.data.manager.model.FlagKey
import com.bitwarden.core.data.repository.model.DataState
import com.bitwarden.core.data.repository.util.takeUntilLoaded
import com.bitwarden.data.repository.util.baseWebSendUrl
@@ -23,6 +24,7 @@ import com.bitwarden.ui.util.asText
import com.bitwarden.ui.util.concat
import com.x8bit.bitwarden.data.auth.repository.AuthRepository
import com.x8bit.bitwarden.data.billing.manager.PremiumStateManager
import com.x8bit.bitwarden.data.platform.manager.FeatureFlagManager
import com.x8bit.bitwarden.data.platform.manager.PolicyManager
import com.x8bit.bitwarden.data.platform.manager.SpecialCircumstanceManager
import com.x8bit.bitwarden.data.platform.manager.clipboard.BitwardenClipboardManager
@@ -51,6 +53,7 @@ import com.x8bit.bitwarden.ui.tools.feature.send.util.toSendUrl
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.map
@@ -83,6 +86,7 @@ class AddEditSendViewModel @Inject constructor(
private val clock: Clock,
private val clipboardManager: BitwardenClipboardManager,
private val environmentRepo: EnvironmentRepository,
private val featureFlagManager: FeatureFlagManager,
private val specialCircumstanceManager: SpecialCircumstanceManager,
private val vaultRepo: VaultRepository,
private val policyManager: PolicyManager,
@@ -148,6 +152,7 @@ class AddEditSendViewModel @Inject constructor(
dialogState = null,
baseWebSendUrl = environmentRepo.environment.baseWebSendUrl,
policyDisablesSend = effectiveSendPolicy.disableSend,
isSendControlsEnabled = featureFlagManager.getFeatureFlag(key = FlagKey.SendControls),
allowedDomains = effectiveSendPolicy.allowedDomains,
allowedSendTypes = effectiveSendPolicy.allowedSendTypes,
deletionHours = effectiveSendPolicy.deletionHours,
@@ -182,6 +187,20 @@ class AddEditSendViewModel @Inject constructor(
}
.onEach(::sendAction)
.launchIn(viewModelScope)
// The effective policy itself depends on the feature flag, so both are observed together
// to keep the derived state consistent whenever either one changes.
combine(
policyManager.getEffectiveSendPolicyFlow(),
featureFlagManager.getFeatureFlagFlow(key = FlagKey.SendControls),
) { effectiveSendPolicy, isSendControlsEnabled ->
AddEditSendAction.Internal.EffectiveSendPolicyReceive(
effectiveSendPolicy = effectiveSendPolicy,
isSendControlsEnabled = isSendControlsEnabled,
)
}
.onEach(::sendAction)
.launchIn(viewModelScope)
}
override fun handleAction(action: AddEditSendAction): Unit = when (action) {
@@ -233,6 +252,10 @@ class AddEditSendViewModel @Inject constructor(
handleRemovePasswordResultReceive(action)
}
is AddEditSendAction.Internal.EffectiveSendPolicyReceive -> {
handleEffectiveSendPolicyReceive(action)
}
is AddEditSendAction.Internal.SendDataReceive -> handleSendDataReceive(action)
is AddEditSendAction.Internal.GeneratorResultReceive -> {
@@ -384,6 +407,25 @@ class AddEditSendViewModel @Inject constructor(
}
}
private fun handleEffectiveSendPolicyReceive(
action: AddEditSendAction.Internal.EffectiveSendPolicyReceive,
) {
val effectiveSendPolicy = action.effectiveSendPolicy
mutableStateFlow.update { currentState ->
currentState.copy(
policyDisablesSend = effectiveSendPolicy.disableSend,
isSendControlsEnabled = action.isSendControlsEnabled,
allowedDomains = effectiveSendPolicy.allowedDomains,
allowedSendTypes = effectiveSendPolicy.allowedSendTypes,
deletionHours = effectiveSendPolicy.deletionHours,
whoCanAccess = effectiveSendPolicy.whoCanAccess,
)
}
updateCommonContent {
it.copy(isHideEmailAddressEnabled = !effectiveSendPolicy.disableHideEmail)
}
}
@Suppress("LongMethod")
private fun handleSendDataReceive(action: AddEditSendAction.Internal.SendDataReceive) {
when (val sendDataState = action.sendDataState) {
@@ -905,6 +947,7 @@ data class AddEditSendState(
val isShared: Boolean,
val baseWebSendUrl: String,
val policyDisablesSend: Boolean,
val isSendControlsEnabled: Boolean,
val allowedDomains: String?,
val allowedSendTypes: List<SendTypeJson>?,
val deletionHours: Int?,
@@ -929,12 +972,24 @@ data class AddEditSendState(
}
/**
* Helper to determine if the policy notice should be displayed.
* Helper to determine if the policy notice should be displayed. The notice is only relevant to
* the legacy send options policy, which disables the affected controls rather than hiding them.
* The SendControls policy removes those controls entirely, so there is nothing to explain.
*/
val shouldDisplayPolicyWarning: Boolean
get() = !policyDisablesSend &&
!isSendControlsEnabled &&
(viewState as? ViewState.Content)?.common?.isHideEmailAddressEnabled != true
/**
* Helper to determine if the "hide my email" toggle should be hidden entirely rather than
* simply disabled. The SendControls policy hides the toggle, while the legacy send options
* policy continues to only disable it.
*/
val shouldHideEmailAddressToggle: Boolean
get() = isSendControlsEnabled &&
(viewState as? ViewState.Content)?.common?.isHideEmailAddressEnabled == false
/**
* Helper to determine if the UI should display the content in add send mode.
*/
@@ -1252,6 +1307,14 @@ sealed class AddEditSendAction {
*/
data class CreateSendResultReceive(val result: CreateSendResult) : Internal()
/**
* Indicates an updated effective send policy has been received.
*/
data class EffectiveSendPolicyReceive(
val effectiveSendPolicy: EffectiveSendPolicy,
val isSendControlsEnabled: Boolean,
) : Internal()
/**
* Indicates that the vault totp code result has been received.
*/
@@ -4,7 +4,6 @@ import androidx.compose.ui.test.assert
import androidx.compose.ui.test.assertCountEquals
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.assertIsEnabled
import androidx.compose.ui.test.assertIsNotDisplayed
import androidx.compose.ui.test.assertIsNotEnabled
import androidx.compose.ui.test.assertIsOff
import androidx.compose.ui.test.assertIsOn
@@ -789,48 +788,93 @@ class AddEditSendScreenTest : BitwardenComposeTest() {
.assertIsOn()
}
@Suppress("MaxLineLength")
@Test
fun `hide email toggle should be disabled according to state`() = runTest {
// Expand options section:
composeTestRule
.onNodeWithText("Additional options")
.performScrollTo()
.performClick()
fun `hide email toggle should be disabled when restricted and send controls is disabled`() =
runTest {
// Expand options section:
composeTestRule
.onNodeWithText("Additional options")
.performScrollTo()
.performClick()
mutableStateFlow.update {
it.copy(
viewState = DEFAULT_VIEW_STATE.copy(
common = DEFAULT_COMMON_STATE.copy(
isHideEmailAddressEnabled = false,
mutableStateFlow.update {
it.copy(
viewState = DEFAULT_VIEW_STATE.copy(
common = DEFAULT_COMMON_STATE.copy(
isHideEmailAddressEnabled = false,
),
),
),
)
isSendControlsEnabled = false,
)
}
// Legacy behavior: the toggle remains visible but is not interactive.
composeTestRule
.onNodeWithText("Hide my email address", substring = true)
.performScrollTo()
.assertIsDisplayed()
.assertIsNotEnabled()
mutableStateFlow.update {
it.copy(
viewState = DEFAULT_VIEW_STATE.copy(
common = DEFAULT_COMMON_STATE.copy(
isHideEmailAddressEnabled = true,
),
),
)
}
composeTestRule
.onNodeWithText("Hide my email address", substring = true)
.performScrollTo()
.assertIsDisplayed()
.assertIsEnabled()
}
// Toggle should be disabled
composeTestRule
.onNodeWithText("Hide my email address", substring = true)
.performScrollTo()
.assertIsDisplayed()
.assertIsNotEnabled()
@Suppress("MaxLineLength")
@Test
fun `hide email toggle should be hidden when restricted and send controls is enabled`() =
runTest {
// Expand options section:
composeTestRule
.onNodeWithText("Additional options")
.performScrollTo()
.performClick()
mutableStateFlow.update {
it.copy(
viewState = DEFAULT_VIEW_STATE.copy(
common = DEFAULT_COMMON_STATE.copy(
isHideEmailChecked = true,
mutableStateFlow.update {
it.copy(
viewState = DEFAULT_VIEW_STATE.copy(
common = DEFAULT_COMMON_STATE.copy(
isHideEmailAddressEnabled = false,
),
),
),
)
}
isSendControlsEnabled = true,
)
}
// Toggle should be enabled
composeTestRule
.onNodeWithText("Hide my email address", substring = true)
.performScrollTo()
.assertIsDisplayed()
.assertIsEnabled()
}
// The toggle is hidden entirely rather than simply disabled.
composeTestRule
.onNodeWithText("Hide my email address", substring = true)
.assertDoesNotExist()
mutableStateFlow.update {
it.copy(
viewState = DEFAULT_VIEW_STATE.copy(
common = DEFAULT_COMMON_STATE.copy(
isHideEmailAddressEnabled = true,
),
),
)
}
composeTestRule
.onNodeWithText("Hide my email address", substring = true)
.performScrollTo()
.assertIsDisplayed()
.assertIsEnabled()
}
@Test
fun `progressbar should be displayed according to state`() {
@@ -1004,7 +1048,7 @@ class AddEditSendScreenTest : BitwardenComposeTest() {
composeTestRule
.onNodeWithText(text)
.assertIsNotDisplayed()
.assertDoesNotExist()
mutableStateFlow.update {
it.copy(
@@ -1020,6 +1064,13 @@ class AddEditSendScreenTest : BitwardenComposeTest() {
composeTestRule
.onNodeWithText(text)
.assertIsDisplayed()
// The notice is not relevant once send controls removes the affected options entirely.
mutableStateFlow.update { it.copy(isSendControlsEnabled = true) }
composeTestRule
.onNodeWithText(text)
.assertDoesNotExist()
}
//region Authentication UI Tests
@@ -1578,6 +1629,7 @@ private val DEFAULT_STATE = AddEditSendState(
isShared = false,
baseWebSendUrl = "https://vault.bitwarden.com/#/send/",
policyDisablesSend = false,
isSendControlsEnabled = false,
allowedDomains = null,
allowedSendTypes = null,
deletionHours = null,
@@ -3,6 +3,7 @@ package com.x8bit.bitwarden.ui.tools.feature.send.addedit
import android.net.Uri
import androidx.lifecycle.SavedStateHandle
import app.cash.turbine.test
import com.bitwarden.core.data.manager.model.FlagKey
import com.bitwarden.core.data.repository.model.DataState
import com.bitwarden.core.data.repository.util.bufferedMutableSharedFlow
import com.bitwarden.data.repository.model.Environment
@@ -18,6 +19,7 @@ import com.x8bit.bitwarden.data.auth.datasource.disk.model.OnboardingStatus
import com.x8bit.bitwarden.data.auth.repository.AuthRepository
import com.x8bit.bitwarden.data.auth.repository.model.UserState
import com.x8bit.bitwarden.data.billing.manager.PremiumStateManager
import com.x8bit.bitwarden.data.platform.manager.FeatureFlagManager
import com.x8bit.bitwarden.data.platform.manager.PolicyManager
import com.x8bit.bitwarden.data.platform.manager.SpecialCircumstanceManager
import com.x8bit.bitwarden.data.platform.manager.clipboard.BitwardenClipboardManager
@@ -92,8 +94,17 @@ class AddEditSendViewModelTest : BaseViewModelTest() {
private val vaultRepository: VaultRepository = mockk {
every { getSendStateFlow(any()) } returns mutableSendDataStateFlow
}
private val mutableEffectiveSendPolicyFlow = MutableStateFlow(DEFAULT_EFFECTIVE_SEND_POLICY)
private val mutableSendControlsFlagFlow = MutableStateFlow(false)
private val policyManager: PolicyManager = mockk {
every { getEffectiveSendPolicy() } returns DEFAULT_EFFECTIVE_SEND_POLICY
every { getEffectiveSendPolicy() } answers { mutableEffectiveSendPolicyFlow.value }
every { getEffectiveSendPolicyFlow() } returns mutableEffectiveSendPolicyFlow
}
private val featureFlagManager: FeatureFlagManager = mockk {
every { getFeatureFlag(key = FlagKey.SendControls) } answers {
mutableSendControlsFlagFlow.value
}
every { getFeatureFlagFlow(key = FlagKey.SendControls) } returns mutableSendControlsFlagFlow
}
private val networkConnectionManager = mockk<NetworkConnectionManager> {
every { isNetworkConnected } returns true
@@ -136,9 +147,8 @@ class AddEditSendViewModelTest : BaseViewModelTest() {
@Test
fun `initial state should be correct when the effective policy disables hide email`() {
every {
policyManager.getEffectiveSendPolicy()
} returns DEFAULT_EFFECTIVE_SEND_POLICY.copy(disableHideEmail = true)
mutableEffectiveSendPolicyFlow.value =
DEFAULT_EFFECTIVE_SEND_POLICY.copy(disableHideEmail = true)
val viewModel = createViewModel()
val viewState = DEFAULT_VIEW_STATE.copy(
common = DEFAULT_COMMON_STATE.copy(
@@ -148,6 +158,63 @@ class AddEditSendViewModelTest : BaseViewModelTest() {
assertEquals(DEFAULT_STATE.copy(viewState = viewState), viewModel.stateFlow.value)
}
@Test
fun `initial state should be correct when the send controls feature flag is enabled`() {
mutableSendControlsFlagFlow.value = true
val viewModel = createViewModel()
assertEquals(
DEFAULT_STATE.copy(isSendControlsEnabled = true),
viewModel.stateFlow.value,
)
}
@Suppress("MaxLineLength")
@Test
fun `state should update when the send controls feature flag changes while the screen is open`() =
runTest {
mutableEffectiveSendPolicyFlow.value =
DEFAULT_EFFECTIVE_SEND_POLICY.copy(disableHideEmail = true)
val expectedState = DEFAULT_STATE.copy(
viewState = DEFAULT_VIEW_STATE.copy(
common = DEFAULT_COMMON_STATE.copy(isHideEmailAddressEnabled = false),
),
)
val viewModel = createViewModel()
viewModel.stateFlow.test {
assertEquals(expectedState, awaitItem())
mutableSendControlsFlagFlow.value = true
assertEquals(expectedState.copy(isSendControlsEnabled = true), awaitItem())
}
}
@Suppress("MaxLineLength")
@Test
fun `state should update when the effective send policy changes while the screen is open`() =
runTest {
val viewModel = createViewModel()
viewModel.stateFlow.test {
assertEquals(DEFAULT_STATE, awaitItem())
mutableEffectiveSendPolicyFlow.value =
DEFAULT_EFFECTIVE_SEND_POLICY.copy(disableHideEmail = true)
assertEquals(
DEFAULT_STATE.copy(
viewState = DEFAULT_VIEW_STATE.copy(
common = DEFAULT_COMMON_STATE.copy(
isHideEmailAddressEnabled = false,
),
),
),
awaitItem(),
)
}
}
@Test
fun `initial state should read from saved state when present`() {
val savedState = DEFAULT_STATE.copy(
@@ -1458,6 +1525,7 @@ class AddEditSendViewModelTest : BaseViewModelTest() {
},
authRepo = authRepository,
environmentRepo = environmentRepository,
featureFlagManager = featureFlagManager,
specialCircumstanceManager = specialCircumstanceManager,
clock = clock,
clipboardManager = clipboardManager,
@@ -1506,6 +1574,7 @@ private val DEFAULT_STATE = AddEditSendState(
isShared = false,
baseWebSendUrl = DEFAULT_ENVIRONMENT_URL,
policyDisablesSend = false,
isSendControlsEnabled = false,
allowedDomains = null,
allowedSendTypes = null,
deletionHours = null,