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 7267cf05c48..cddd396700a 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 @@ -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), 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 81ae7c56dcc..edbb5288261 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 @@ -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, 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 aa26e090dd8..3c697bc19b8 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 @@ -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,31 @@ 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, + viewState = (currentState.viewState as? AddEditSendState.ViewState.Content) + ?.let { content -> + content.copy( + common = content.common.copy( + isHideEmailAddressEnabled = !effectiveSendPolicy.disableHideEmail, + ), + ) + } + ?: currentState.viewState, + ) + } + } + @Suppress("LongMethod") private fun handleSendDataReceive(action: AddEditSendAction.Internal.SendDataReceive) { when (val sendDataState = action.sendDataState) { @@ -905,6 +953,7 @@ data class AddEditSendState( val isShared: Boolean, val baseWebSendUrl: String, val policyDisablesSend: Boolean, + val isSendControlsEnabled: Boolean, val allowedDomains: String?, val allowedSendTypes: List?, val deletionHours: Int?, @@ -929,12 +978,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 +1313,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. */ diff --git a/app/src/test/kotlin/com/x8bit/bitwarden/ui/tools/feature/send/addedit/AddEditSendScreenTest.kt b/app/src/test/kotlin/com/x8bit/bitwarden/ui/tools/feature/send/addedit/AddEditSendScreenTest.kt index dba5a54e2d8..e612b3d13c2 100644 --- a/app/src/test/kotlin/com/x8bit/bitwarden/ui/tools/feature/send/addedit/AddEditSendScreenTest.kt +++ b/app/src/test/kotlin/com/x8bit/bitwarden/ui/tools/feature/send/addedit/AddEditSendScreenTest.kt @@ -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() - - mutableStateFlow.update { - it.copy( - viewState = DEFAULT_VIEW_STATE.copy( - common = DEFAULT_COMMON_STATE.copy( - isHideEmailAddressEnabled = false, + 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, + ), ), - ), - ) - } + 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, + ), + ), + ) + } - // Toggle should be disabled - composeTestRule - .onNodeWithText("Hide my email address", substring = true) - .performScrollTo() - .assertIsDisplayed() - .assertIsNotEnabled() + composeTestRule + .onNodeWithText("Hide my email address", substring = true) + .performScrollTo() + .assertIsDisplayed() + .assertIsEnabled() + } - mutableStateFlow.update { - it.copy( - viewState = DEFAULT_VIEW_STATE.copy( - common = DEFAULT_COMMON_STATE.copy( - isHideEmailChecked = true, + @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( + isHideEmailAddressEnabled = false, + ), ), - ), - ) - } + isSendControlsEnabled = true, + ) + } + + // 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, + ), + ), + ) + } - // Toggle should be enabled - composeTestRule - .onNodeWithText("Hide my email address", substring = true) - .performScrollTo() - .assertIsDisplayed() - .assertIsEnabled() - } + 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, 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 5582d25ec6a..8381f59e700 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 @@ -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 { 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,70 @@ 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 `shouldHideEmailAddressToggle should only be true when send controls is enabled and hide email is restricted`() { + mutableEffectiveSendPolicyFlow.value = + DEFAULT_EFFECTIVE_SEND_POLICY.copy(disableHideEmail = true) + + // Flag off retains the legacy behavior of only disabling the toggle. + assertEquals(false, createViewModel().stateFlow.value.shouldHideEmailAddressToggle) + + mutableSendControlsFlagFlow.value = true + + assertEquals(true, createViewModel().stateFlow.value.shouldHideEmailAddressToggle) + } + + @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 viewModel = createViewModel() + + viewModel.stateFlow.test { + val initialState = awaitItem() + assertEquals(false, initialState.isSendControlsEnabled) + assertEquals(false, initialState.shouldHideEmailAddressToggle) + + mutableSendControlsFlagFlow.value = true + + val updatedState = awaitItem() + assertEquals(true, updatedState.isSendControlsEnabled) + assertEquals(true, updatedState.shouldHideEmailAddressToggle) + } + } + + @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(true, awaitItem().isHideEmailAddressEnabledOrNull()) + + mutableEffectiveSendPolicyFlow.value = + DEFAULT_EFFECTIVE_SEND_POLICY.copy(disableHideEmail = true) + + assertEquals(false, awaitItem().isHideEmailAddressEnabledOrNull()) + } + } + + private fun AddEditSendState.isHideEmailAddressEnabledOrNull(): Boolean? = + (viewState as? AddEditSendState.ViewState.Content)?.common?.isHideEmailAddressEnabled + @Test fun `initial state should read from saved state when present`() { val savedState = DEFAULT_STATE.copy( @@ -1458,6 +1532,7 @@ class AddEditSendViewModelTest : BaseViewModelTest() { }, authRepo = authRepository, environmentRepo = environmentRepository, + featureFlagManager = featureFlagManager, specialCircumstanceManager = specialCircumstanceManager, clock = clock, clipboardManager = clipboardManager, @@ -1506,6 +1581,7 @@ private val DEFAULT_STATE = AddEditSendState( isShared = false, baseWebSendUrl = DEFAULT_ENVIRONMENT_URL, policyDisablesSend = false, + isSendControlsEnabled = false, allowedDomains = null, allowedSendTypes = null, deletionHours = null,