From febfc82a53da6e968fc7994348cc0c7eda578ee4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Bispo?= Date: Wed, 9 Jul 2025 18:30:46 +0100 Subject: [PATCH] [PM-19309] Fix search when restrict item policy is enabled (#5497) --- .../feature/search/SearchViewModel.kt | 64 ++++++++++++++++ .../feature/search/SearchScreenTest.kt | 2 + .../feature/search/SearchViewModelTest.kt | 76 +++++++++++++++++++ 3 files changed, 142 insertions(+) diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/feature/search/SearchViewModel.kt b/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/feature/search/SearchViewModel.kt index 3c16e88f3c..f1458b2202 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/feature/search/SearchViewModel.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/feature/search/SearchViewModel.kt @@ -24,10 +24,12 @@ import com.x8bit.bitwarden.data.auth.repository.model.ValidatePasswordResult import com.x8bit.bitwarden.data.autofill.accessibility.manager.AccessibilitySelectionManager import com.x8bit.bitwarden.data.autofill.manager.AutofillSelectionManager import com.x8bit.bitwarden.data.autofill.model.AutofillSelectionData +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 import com.x8bit.bitwarden.data.platform.manager.event.OrganizationEventManager +import com.x8bit.bitwarden.data.platform.manager.model.FlagKey import com.x8bit.bitwarden.data.platform.manager.model.OrganizationEvent import com.x8bit.bitwarden.data.platform.manager.model.SpecialCircumstance import com.x8bit.bitwarden.data.platform.manager.util.toAutofillSelectionDataOrNull @@ -54,6 +56,7 @@ import com.x8bit.bitwarden.ui.tools.feature.send.util.toSendItemType import com.x8bit.bitwarden.ui.vault.feature.itemlisting.model.ListingItemOverflowAction import com.x8bit.bitwarden.ui.vault.feature.vault.model.VaultFilterData import com.x8bit.bitwarden.ui.vault.feature.vault.model.VaultFilterType +import com.x8bit.bitwarden.ui.vault.feature.vault.util.applyRestrictItemTypesPolicy import com.x8bit.bitwarden.ui.vault.feature.vault.util.toFilteredList import com.x8bit.bitwarden.ui.vault.feature.vault.util.toVaultFilterData import com.x8bit.bitwarden.ui.vault.model.TotpData @@ -61,6 +64,11 @@ import com.x8bit.bitwarden.ui.vault.model.VaultItemCipherType import com.x8bit.bitwarden.ui.vault.util.toVaultItemCipherType import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.toImmutableList +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.onEach @@ -75,6 +83,7 @@ private const val KEY_STATE = "state" /** * View model for the search screen. */ +@OptIn(ExperimentalCoroutinesApi::class) @Suppress("LongParameterList", "TooManyFunctions", "LargeClass") @HiltViewModel class SearchViewModel @Inject constructor( @@ -87,6 +96,7 @@ class SearchViewModel @Inject constructor( private val organizationEventManager: OrganizationEventManager, private val vaultRepo: VaultRepository, private val authRepo: AuthRepository, + featureFlagManager: FeatureFlagManager, environmentRepo: EnvironmentRepository, settingsRepo: SettingsRepository, snackbarRelayManager: SnackbarRelayManager, @@ -125,6 +135,7 @@ class SearchViewModel @Inject constructor( totpData = specialCircumstance?.toTotpDataOrNull(), hasMasterPassword = userState.activeAccount.hasMasterPassword, isPremium = userState.activeAccount.isPremium, + restrictItemTypesPolicyOrgIds = persistentListOf(), ) }, ) { @@ -140,6 +151,28 @@ class SearchViewModel @Inject constructor( .map { SearchAction.Internal.VaultDataReceive(it) } .onEach(::sendAction) .launchIn(viewModelScope) + + featureFlagManager + .getFeatureFlagFlow(FlagKey.RemoveCardPolicy) + .flatMapLatest { isFlagEnabled -> + if (isFlagEnabled) { + policyManager + .getActivePoliciesFlow(type = PolicyTypeJson.RESTRICT_ITEM_TYPES) + .map { policies -> + policies.map { it.organizationId } + } + } else { + flowOf(emptyList()) + } + } + .map { organizationIds -> + SearchAction.Internal.RestrictItemTypesPolicyUpdateReceive( + restrictItemTypesPolicyOrdIds = organizationIds, + ) + } + .onEach(::sendAction) + .launchIn(viewModelScope) + snackbarRelayManager .getSnackbarDataFlow( SnackbarRelay.CIPHER_DELETED, @@ -499,6 +532,10 @@ class SearchViewModel @Inject constructor( } is SearchAction.Internal.VaultDataReceive -> handleVaultDataReceive(action) + + is SearchAction.Internal.RestrictItemTypesPolicyUpdateReceive -> { + handleRestrictItemTypesPolicyUpdateReceive(action) + } } } @@ -687,6 +724,22 @@ class SearchViewModel @Inject constructor( } } + private fun handleRestrictItemTypesPolicyUpdateReceive( + action: SearchAction.Internal.RestrictItemTypesPolicyUpdateReceive, + ) { + mutableStateFlow.update { + it.copy( + restrictItemTypesPolicyOrgIds = action + .restrictItemTypesPolicyOrdIds + .toImmutableList(), + ) + } + + vaultRepo.vaultDataStateFlow.value.data?.let { vaultData -> + updateStateWithVaultData(vaultData = vaultData, clearDialogState = false) + } + } + private fun vaultErrorReceive(vaultData: DataState.Error) { vaultData .data @@ -770,6 +823,9 @@ class SearchViewModel @Inject constructor( vaultData .cipherViewList .filterAndOrganize(searchType, state.searchTerm) + .applyRestrictItemTypesPolicy( + restrictItemTypesPolicyOrgIds = state.restrictItemTypesPolicyOrgIds, + ) .toFilteredList( vaultFilterType = state .vaultFilterData @@ -829,6 +885,7 @@ data class SearchState( val totpData: TotpData?, val hasMasterPassword: Boolean, val isPremium: Boolean, + val restrictItemTypesPolicyOrgIds: ImmutableList, ) : Parcelable { /** @@ -1205,6 +1262,13 @@ sealed class SearchAction { val result: RemovePasswordSendResult, ) : Internal() + /** + * Indicates that a restrict item types policy update has been received. + */ + data class RestrictItemTypesPolicyUpdateReceive( + val restrictItemTypesPolicyOrdIds: List, + ) : Internal() + /** * Indicates that snackbar data has been received. */ diff --git a/app/src/test/kotlin/com/x8bit/bitwarden/ui/platform/feature/search/SearchScreenTest.kt b/app/src/test/kotlin/com/x8bit/bitwarden/ui/platform/feature/search/SearchScreenTest.kt index a873d2c42d..fc51582b4b 100644 --- a/app/src/test/kotlin/com/x8bit/bitwarden/ui/platform/feature/search/SearchScreenTest.kt +++ b/app/src/test/kotlin/com/x8bit/bitwarden/ui/platform/feature/search/SearchScreenTest.kt @@ -45,6 +45,7 @@ import io.mockk.just import io.mockk.mockk import io.mockk.runs import io.mockk.verify +import kotlinx.collections.immutable.persistentListOf import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.update import org.junit.Assert.assertEquals @@ -1028,6 +1029,7 @@ private val DEFAULT_STATE: SearchState = SearchState( totpData = null, autofillSelectionData = null, isPremium = true, + restrictItemTypesPolicyOrgIds = persistentListOf(), ) private fun createStateForAutofill( diff --git a/app/src/test/kotlin/com/x8bit/bitwarden/ui/platform/feature/search/SearchViewModelTest.kt b/app/src/test/kotlin/com/x8bit/bitwarden/ui/platform/feature/search/SearchViewModelTest.kt index 5966871b84..8c679ff79b 100644 --- a/app/src/test/kotlin/com/x8bit/bitwarden/ui/platform/feature/search/SearchViewModelTest.kt +++ b/app/src/test/kotlin/com/x8bit/bitwarden/ui/platform/feature/search/SearchViewModelTest.kt @@ -28,12 +28,14 @@ import com.x8bit.bitwarden.data.autofill.accessibility.manager.AccessibilitySele import com.x8bit.bitwarden.data.autofill.manager.AutofillSelectionManager import com.x8bit.bitwarden.data.autofill.manager.AutofillSelectionManagerImpl import com.x8bit.bitwarden.data.autofill.model.AutofillSelectionData +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.SpecialCircumstanceManagerImpl import com.x8bit.bitwarden.data.platform.manager.clipboard.BitwardenClipboardManager import com.x8bit.bitwarden.data.platform.manager.event.OrganizationEventManager import com.x8bit.bitwarden.data.platform.manager.model.FirstTimeState +import com.x8bit.bitwarden.data.platform.manager.model.FlagKey import com.x8bit.bitwarden.data.platform.manager.model.OrganizationEvent import com.x8bit.bitwarden.data.platform.manager.model.SpecialCircumstance import com.x8bit.bitwarden.data.platform.repository.EnvironmentRepository @@ -72,6 +74,7 @@ import io.mockk.mockkStatic import io.mockk.runs import io.mockk.unmockkStatic import io.mockk.verify +import kotlinx.collections.immutable.persistentListOf import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.test.runTest @@ -100,10 +103,16 @@ class SearchViewModelTest : BaseViewModelTest() { private val clipboardManager: BitwardenClipboardManager = mockk { every { setText(text = any(), toastDescriptorOverride = any()) } just runs } + + private val mutableActivePoliciesFlow: MutableStateFlow> = + MutableStateFlow(emptyList()) private val policyManager: PolicyManager = mockk { every { getActivePolicies(type = PolicyTypeJson.PERSONAL_OWNERSHIP) } returns emptyList() + every { + getActivePoliciesFlow(type = PolicyTypeJson.RESTRICT_ITEM_TYPES) + } returns mutableActivePoliciesFlow } private val mutableVaultDataStateFlow = MutableStateFlow>(DataState.Loading) @@ -141,6 +150,13 @@ class SearchViewModelTest : BaseViewModelTest() { } returns mutableSnackbarDataFlow } + private val mutableRemoveCardPolicyFeatureFlow = MutableStateFlow(false) + private val featureFlagManager: FeatureFlagManager = mockk { + every { + getFeatureFlagFlow(FlagKey.RemoveCardPolicy) + } returns mutableRemoveCardPolicyFeatureFlow + } + @BeforeEach fun setup() { mockkStatic( @@ -1584,6 +1600,64 @@ class SearchViewModelTest : BaseViewModelTest() { } } + @Suppress("MaxLineLength") + @Test + fun `RESTRICT_ITEM_TYPES policy changes should update restrictItemTypesPolicyOrgIds accordingly if RemoveCardPolicy flag is enable`() = + runTest { + mutableRemoveCardPolicyFeatureFlow.value = true + + val viewModel = createViewModel() + assertEquals( + DEFAULT_STATE.copy(restrictItemTypesPolicyOrgIds = persistentListOf()), + viewModel.stateFlow.value, + ) + mutableActivePoliciesFlow.emit( + listOf( + SyncResponseJson.Policy( + organizationId = "Test Organization", + id = "testId", + type = PolicyTypeJson.RESTRICT_ITEM_TYPES, + isEnabled = true, + data = null, + ), + ), + ) + + assertEquals( + DEFAULT_STATE.copy( + restrictItemTypesPolicyOrgIds = persistentListOf("Test Organization"), + ), + viewModel.stateFlow.value, + ) + } + + @Suppress("MaxLineLength") + @Test + fun `RESTRICT_ITEM_TYPES policy changes should update restrictItemTypesPolicyOrgIds accordingly if RemoveCardPolicy flag is disabled`() = + runTest { + val viewModel = createViewModel() + assertEquals( + DEFAULT_STATE, + viewModel.stateFlow.value, + ) + mutableActivePoliciesFlow.emit( + listOf( + SyncResponseJson.Policy( + organizationId = "Test Organization", + id = "testId", + type = PolicyTypeJson.RESTRICT_ITEM_TYPES, + isEnabled = true, + data = null, + ), + ), + ) + + assertEquals( + DEFAULT_STATE.copy(restrictItemTypesPolicyOrgIds = persistentListOf()), + viewModel.stateFlow.value, + ) + } + @Suppress("CyclomaticComplexMethod") private fun createViewModel( initialState: SearchState? = null, @@ -1630,6 +1704,7 @@ class SearchViewModelTest : BaseViewModelTest() { autofillSelectionManager = autofillSelectionManager, organizationEventManager = organizationEventManager, snackbarRelayManager = snackbarRelayManager, + featureFlagManager = featureFlagManager, ) /** @@ -1703,6 +1778,7 @@ private val DEFAULT_STATE: SearchState = SearchState( totpData = null, autofillSelectionData = null, isPremium = true, + restrictItemTypesPolicyOrgIds = persistentListOf(), ) private val DEFAULT_USER_STATE = UserState(