From 319872ccf9643b6460069d2b9251287a9fa4cfb4 Mon Sep 17 00:00:00 2001 From: David Perez Date: Fri, 23 Jan 2026 10:50:43 -0600 Subject: [PATCH] PM-29693: Add introducing archive action card to vault screen (#6390) --- .../datasource/disk/SettingsDiskSource.kt | 18 +++ .../datasource/disk/SettingsDiskSourceImpl.kt | 33 +++++ .../platform/repository/SettingsRepository.kt | 10 ++ .../repository/SettingsRepositoryImpl.kt | 23 +++ .../ui/vault/feature/vault/VaultContent.kt | 132 ++++++++++++++---- .../ui/vault/feature/vault/VaultScreen.kt | 1 + .../ui/vault/feature/vault/VaultViewModel.kt | 63 +++++++++ .../feature/vault/handlers/VaultHandlers.kt | 4 + .../datasource/disk/SettingsDiskSourceTest.kt | 45 ++++++ .../disk/util/FakeSettingsDiskSource.kt | 23 +++ .../repository/SettingsRepositoryTest.kt | 32 +++++ .../ui/vault/feature/vault/VaultScreenTest.kt | 53 +++++++ .../vault/feature/vault/VaultViewModelTest.kt | 38 +++++ ui/src/main/res/values/strings.xml | 3 + 14 files changed, 452 insertions(+), 26 deletions(-) diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/data/platform/datasource/disk/SettingsDiskSource.kt b/app/src/main/kotlin/com/x8bit/bitwarden/data/platform/datasource/disk/SettingsDiskSource.kt index 3cabd11839..0583347300 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/data/platform/datasource/disk/SettingsDiskSource.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/data/platform/datasource/disk/SettingsDiskSource.kt @@ -105,6 +105,24 @@ interface SettingsDiskSource : FlightRecorderDiskSource { */ fun clearData(userId: String) + /** + * Retrieves the stored value of whether the introducing archive action card has been dismissed. + */ + fun getIntroducingArchiveActionCardDismissed(userId: String): Boolean? + + /** + * Stores whether the introducing archive action card has been dismissed. + */ + fun storeIntroducingArchiveActionCardDismissed( + userId: String, + isDismissed: Boolean?, + ) + + /** + * Emits updates that track [getIntroducingArchiveActionCardDismissed] for the given [userId]. + */ + fun getIntroducingArchiveActionCardDismissedFlow(userId: String): Flow + /** * Retrieves the biometric integrity validity for the given [userId] and * [systemBioIntegrityState]. diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/data/platform/datasource/disk/SettingsDiskSourceImpl.kt b/app/src/main/kotlin/com/x8bit/bitwarden/data/platform/datasource/disk/SettingsDiskSourceImpl.kt index bc511f51aa..1c6cc3591a 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/data/platform/datasource/disk/SettingsDiskSourceImpl.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/data/platform/datasource/disk/SettingsDiskSourceImpl.kt @@ -49,6 +49,8 @@ private const val SHOULD_SHOW_GENERATOR_COACH_MARK = "shouldShowGeneratorCoachMa private const val RESUME_SCREEN = "resumeScreen" private const val IS_DYNAMIC_COLORS_ENABLED = "isDynamicColorsEnabled" private const val BROWSER_AUTOFILL_DIALOG_RESHOW_TIME = "browserAutofillDialogReshowTime" +private const val INTRODUCING_ARCHIVE_ACTION_CARD_DISMISSED = + "introducingArchiveActionCardDismissed" /** * Primary implementation of [SettingsDiskSource]. @@ -87,6 +89,9 @@ class SettingsDiskSourceImpl( private val mutableShowImportLoginsSettingBadgeFlowMap = mutableMapOf>() + private val mutableIntroducingArchiveActionCardDismissedFlowMap = + mutableMapOf>() + private val mutableIsIconLoadingDisabledFlow = bufferedMutableSharedFlow() private val mutableIsCrashLoggingEnabledFlow = bufferedMutableSharedFlow() @@ -240,8 +245,29 @@ class SettingsDiskSourceImpl( // - show unlock setting badge // - should show add login coach mark // - should show generator coach mark + // - should show introducing archive action card dismissed } + override fun getIntroducingArchiveActionCardDismissed(userId: String): Boolean? = + getBoolean( + key = INTRODUCING_ARCHIVE_ACTION_CARD_DISMISSED.appendIdentifier(identifier = userId), + ) + + override fun storeIntroducingArchiveActionCardDismissed( + userId: String, + isDismissed: Boolean?, + ) { + putBoolean( + key = INTRODUCING_ARCHIVE_ACTION_CARD_DISMISSED.appendIdentifier(identifier = userId), + value = isDismissed, + ) + getMutableIntroducingArchiveActionCardDismissedFlow(userId = userId).tryEmit(isDismissed) + } + + override fun getIntroducingArchiveActionCardDismissedFlow(userId: String): Flow = + getMutableIntroducingArchiveActionCardDismissedFlow(userId = userId) + .onSubscription { emit(getIntroducingArchiveActionCardDismissed(userId = userId)) } + override fun getAccountBiometricIntegrityValidity( userId: String, systemBioIntegrityState: String, @@ -579,6 +605,13 @@ class SettingsDiskSourceImpl( override fun getAppResumeScreen(userId: String): AppResumeScreenData? = getString(RESUME_SCREEN.appendIdentifier(userId))?.let { json.decodeFromStringOrNull(it) } + private fun getMutableIntroducingArchiveActionCardDismissedFlow( + userId: String, + ): MutableSharedFlow = + mutableIntroducingArchiveActionCardDismissedFlowMap.getOrPut(userId) { + bufferedMutableSharedFlow(replay = 1) + } + private fun getMutableLastSyncFlow( userId: String, ): MutableSharedFlow = diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/data/platform/repository/SettingsRepository.kt b/app/src/main/kotlin/com/x8bit/bitwarden/data/platform/repository/SettingsRepository.kt index 56df96a4f5..5e83bd854f 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/data/platform/repository/SettingsRepository.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/data/platform/repository/SettingsRepository.kt @@ -242,6 +242,16 @@ interface SettingsRepository : FlightRecorderManager { */ fun storePullToRefreshEnabled(isPullToRefreshEnabled: Boolean) + /** + * Gets updates for whether the introducing archive action card is dismissed. + */ + fun getIntroducingArchiveActionCardDismissedFlow(): StateFlow + + /** + * Stores that the introducing archive action card has been dismissed for the active user. + */ + fun dismissIntroducingArchiveActionCard() + /** * Stores the encrypted user key for biometrics, allowing it to be used to unlock the current * user's vault. diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/data/platform/repository/SettingsRepositoryImpl.kt b/app/src/main/kotlin/com/x8bit/bitwarden/data/platform/repository/SettingsRepositoryImpl.kt index 6a0aff7fbb..ee678e8428 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/data/platform/repository/SettingsRepositoryImpl.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/data/platform/repository/SettingsRepositoryImpl.kt @@ -500,6 +500,29 @@ class SettingsRepositoryImpl( } } + override fun getIntroducingArchiveActionCardDismissedFlow(): StateFlow { + val userId = activeUserId ?: return MutableStateFlow(value = false) + return settingsDiskSource + .getIntroducingArchiveActionCardDismissedFlow(userId = userId) + .map { it ?: false } + .stateIn( + scope = unconfinedScope, + started = SharingStarted.Eagerly, + initialValue = settingsDiskSource + .getIntroducingArchiveActionCardDismissed(userId = userId) + ?: false, + ) + } + + override fun dismissIntroducingArchiveActionCard() { + activeUserId?.let { + settingsDiskSource.storeIntroducingArchiveActionCardDismissed( + userId = it, + isDismissed = true, + ) + } + } + override suspend fun setupBiometricsKey(cipher: Cipher): BiometricsKeyResult { val userId = activeUserId ?: return BiometricsKeyResult.Error(error = NoActiveUserException()) diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/vault/VaultContent.kt b/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/vault/VaultContent.kt index 80f946d32b..9346fac4b4 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/vault/VaultContent.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/vault/VaultContent.kt @@ -7,6 +7,7 @@ import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.material3.Icon import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -18,11 +19,14 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import com.bitwarden.ui.platform.base.util.standardHorizontalMargin import com.bitwarden.ui.platform.base.util.toListItemCardStyle +import com.bitwarden.ui.platform.components.card.BitwardenActionCard import com.bitwarden.ui.platform.components.header.BitwardenListHeaderText import com.bitwarden.ui.platform.components.icon.model.IconData import com.bitwarden.ui.platform.components.model.CardStyle +import com.bitwarden.ui.platform.components.util.rememberVectorPainter import com.bitwarden.ui.platform.resource.BitwardenDrawable import com.bitwarden.ui.platform.resource.BitwardenString +import com.bitwarden.ui.platform.theme.BitwardenTheme import com.x8bit.bitwarden.ui.platform.components.dialog.BitwardenMasterPasswordDialog import com.x8bit.bitwarden.ui.platform.components.listitem.BitwardenGroupItem import com.x8bit.bitwarden.ui.vault.feature.itemlisting.model.ListingItemOverflowAction @@ -40,6 +44,7 @@ private const val TRASH_TYPES_COUNT: Int = 1 @Suppress("LongMethod", "CyclomaticComplexMethod") fun VaultContent( state: VaultState.ViewState.Content, + actionCardState: VaultState.ActionCardState?, vaultHandlers: VaultHandlers, modifier: Modifier = Modifier, ) { @@ -72,15 +77,31 @@ fun VaultContent( LazyColumn( modifier = modifier, ) { - item { + item(key = "top_spacer") { Spacer(modifier = Modifier.height(height = 12.dp)) } + + actionCardState?.let { + item(key = "action_card") { + ActionCard( + actionCardState = it, + vaultHandlers = vaultHandlers, + modifier = Modifier + .animateItem() + .fillMaxWidth() + .standardHorizontalMargin(), + ) + Spacer(modifier = Modifier.height(height = 24.dp)) + } + } + if (state.totpItemsCount > 0) { - item { + item(key = "totp_header") { BitwardenListHeaderText( label = stringResource(id = BitwardenString.totp), supportingLabel = TOTP_TYPES_COUNT.toString(), modifier = Modifier + .animateItem() .fillMaxWidth() .standardHorizontalMargin() .padding(horizontal = 16.dp), @@ -88,7 +109,7 @@ fun VaultContent( Spacer(modifier = Modifier.height(height = 8.dp)) } - item { + item(key = "verification_codes_group") { BitwardenGroupItem( startIcon = IconData.Local(iconRes = BitwardenDrawable.ic_clock), label = stringResource(id = BitwardenString.verification_codes), @@ -96,6 +117,7 @@ fun VaultContent( onClick = vaultHandlers.verificationCodesClick, cardStyle = CardStyle.Full, modifier = Modifier + .animateItem() .fillMaxWidth() .testTag("VerificationCodesFilter") .standardHorizontalMargin(), @@ -105,11 +127,12 @@ fun VaultContent( } if (state.favoriteItems.isNotEmpty()) { - item { + item(key = "favorites_header") { BitwardenListHeaderText( label = stringResource(id = BitwardenString.favorites), supportingLabel = state.favoriteItems.count().toString(), modifier = Modifier + .animateItem() .fillMaxWidth() .standardHorizontalMargin() .padding(horizontal = 16.dp), @@ -117,7 +140,10 @@ fun VaultContent( Spacer(modifier = Modifier.height(height = 8.dp)) } - itemsIndexed(state.favoriteItems) { index, favoriteItem -> + itemsIndexed( + items = state.favoriteItems, + key = { _, favorite -> favorite.id }, + ) { index, favoriteItem -> VaultEntryListItem( startIcon = favoriteItem.startIcon, startIconTestTag = favoriteItem.startIconTestTag, @@ -145,21 +171,23 @@ fun VaultContent( .favoriteItems .toListItemCardStyle(index = index, dividerPadding = 56.dp), modifier = Modifier + .animateItem() .fillMaxWidth() .testTag("CipherCell") .standardHorizontalMargin(), ) } - item { + item(key = "favorites_spacer") { Spacer(modifier = Modifier.height(height = 16.dp)) } } - item { + item(key = "types_header") { BitwardenListHeaderText( label = stringResource(id = BitwardenString.types), supportingLabel = state.itemTypesCount.toString(), modifier = Modifier + .animateItem() .fillMaxWidth() .standardHorizontalMargin() .padding(horizontal = 16.dp), @@ -167,7 +195,7 @@ fun VaultContent( Spacer(modifier = Modifier.height(height = 8.dp)) } - item { + item(key = "logins_group") { BitwardenGroupItem( startIcon = IconData.Local( iconRes = BitwardenDrawable.ic_globe, @@ -178,6 +206,7 @@ fun VaultContent( onClick = vaultHandlers.loginGroupClick, cardStyle = CardStyle.Top(dividerPadding = 56.dp), modifier = Modifier + .animateItem() .fillMaxWidth() .testTag("LoginFilter") .standardHorizontalMargin(), @@ -185,7 +214,7 @@ fun VaultContent( } if (state.showCardGroup) { - item { + item(key = "cards_group") { BitwardenGroupItem( startIcon = IconData.Local( iconRes = BitwardenDrawable.ic_payment_card, @@ -196,6 +225,7 @@ fun VaultContent( onClick = vaultHandlers.cardGroupClick, cardStyle = CardStyle.Middle(dividerPadding = 56.dp), modifier = Modifier + .animateItem() .fillMaxWidth() .testTag("CardFilter") .standardHorizontalMargin(), @@ -203,7 +233,7 @@ fun VaultContent( } } - item { + item(key = "identities_group") { BitwardenGroupItem( startIcon = IconData.Local( iconRes = BitwardenDrawable.ic_id_card, @@ -214,13 +244,14 @@ fun VaultContent( onClick = vaultHandlers.identityGroupClick, cardStyle = CardStyle.Middle(dividerPadding = 56.dp), modifier = Modifier + .animateItem() .fillMaxWidth() .testTag("IdentityFilter") .standardHorizontalMargin(), ) } - item { + item(key = "notes_group") { BitwardenGroupItem( startIcon = IconData.Local( iconRes = BitwardenDrawable.ic_note, @@ -231,13 +262,14 @@ fun VaultContent( onClick = vaultHandlers.secureNoteGroupClick, cardStyle = CardStyle.Middle(dividerPadding = 56.dp), modifier = Modifier + .animateItem() .fillMaxWidth() .testTag("SecureNoteFilter") .standardHorizontalMargin(), ) } - item { + item(key = "ssh_keys_group") { BitwardenGroupItem( startIcon = IconData.Local( iconRes = BitwardenDrawable.ic_ssh_key, @@ -248,22 +280,24 @@ fun VaultContent( onClick = vaultHandlers.sshKeyGroupClick, cardStyle = CardStyle.Bottom, modifier = Modifier + .animateItem() .fillMaxWidth() .testTag("SshKeyFilter") .standardHorizontalMargin(), ) } - item { + item(key = "types_spacer") { Spacer(modifier = Modifier.height(height = 16.dp)) } if (state.folderItems.isNotEmpty()) { - item { + item(key = "folders_header") { BitwardenListHeaderText( label = stringResource(id = BitwardenString.folders), supportingLabel = state.folderItems.count().toString(), modifier = Modifier + .animateItem() .fillMaxWidth() .standardHorizontalMargin() .padding(horizontal = 16.dp), @@ -271,7 +305,10 @@ fun VaultContent( Spacer(modifier = Modifier.height(height = 8.dp)) } - itemsIndexed(state.folderItems) { index, folder -> + itemsIndexed( + items = state.folderItems, + key = { _, folder -> folder.id ?: "no_folder_group" }, + ) { index, folder -> BitwardenGroupItem( startIcon = IconData.Local(iconRes = BitwardenDrawable.ic_folder), label = folder.name(), @@ -281,29 +318,34 @@ fun VaultContent( .folderItems .toListItemCardStyle(index = index, dividerPadding = 56.dp), modifier = Modifier + .animateItem() .fillMaxWidth() .testTag("FolderFilter") .standardHorizontalMargin(), ) } - item { + item(key = "folders_spacer") { Spacer(modifier = Modifier.height(height = 16.dp)) } } if (state.noFolderItems.isNotEmpty()) { - item { + item(key = "no_folders_header") { BitwardenListHeaderText( label = stringResource(id = BitwardenString.folder_none), supportingLabel = state.noFolderItems.count().toString(), modifier = Modifier + .animateItem() .fillMaxWidth() .standardHorizontalMargin() .padding(horizontal = 16.dp), ) Spacer(modifier = Modifier.height(height = 8.dp)) } - itemsIndexed(state.noFolderItems) { index, noFolderItem -> + itemsIndexed( + items = state.noFolderItems, + key = { _, noFolderItem -> noFolderItem.id }, + ) { index, noFolderItem -> VaultEntryListItem( startIcon = noFolderItem.startIcon, startIconTestTag = noFolderItem.startIconTestTag, @@ -331,22 +373,24 @@ fun VaultContent( .noFolderItems .toListItemCardStyle(index = index, dividerPadding = 56.dp), modifier = Modifier + .animateItem() .fillMaxWidth() .testTag("CipherCell") .standardHorizontalMargin(), ) } - item { + item(key = "no_folders_spacer") { Spacer(modifier = Modifier.height(height = 16.dp)) } } if (state.collectionItems.isNotEmpty()) { - item { + item(key = "collection_header") { BitwardenListHeaderText( label = stringResource(id = BitwardenString.collections), supportingLabel = state.collectionItems.count().toString(), modifier = Modifier + .animateItem() .fillMaxWidth() .standardHorizontalMargin() .padding(horizontal = 16.dp), @@ -354,7 +398,10 @@ fun VaultContent( Spacer(modifier = Modifier.height(height = 8.dp)) } - itemsIndexed(state.collectionItems) { index, collection -> + itemsIndexed( + items = state.collectionItems, + key = { _, collection -> collection.id }, + ) { index, collection -> BitwardenGroupItem( startIcon = IconData.Local(iconRes = BitwardenDrawable.ic_collections), label = collection.name, @@ -364,17 +411,18 @@ fun VaultContent( .collectionItems .toListItemCardStyle(index = index, dividerPadding = 56.dp), modifier = Modifier + .animateItem() .fillMaxWidth() .testTag("CollectionFilter") .standardHorizontalMargin(), ) } - item { + item(key = "collections_spacer") { Spacer(modifier = Modifier.height(height = 16.dp)) } } - item { + item(key = "hidden_items_header") { BitwardenListHeaderText( label = stringResource(id = BitwardenString.hidden_items), supportingLabel = if (state.archiveEnabled) { @@ -383,6 +431,7 @@ fun VaultContent( TRASH_TYPES_COUNT.toString() }, modifier = Modifier + .animateItem() .fillMaxWidth() .standardHorizontalMargin() .padding(horizontal = 16.dp), @@ -391,7 +440,7 @@ fun VaultContent( } if (state.archiveEnabled) { - item { + item(key = "archive_group") { BitwardenGroupItem( startIcon = IconData.Local(iconRes = BitwardenDrawable.ic_archive), endIcon = state.archiveEndIcon?.let { IconData.Local(iconRes = it) }, @@ -401,6 +450,7 @@ fun VaultContent( onClick = vaultHandlers.archiveClick, cardStyle = CardStyle.Top(dividerPadding = 56.dp), modifier = Modifier + .animateItem() .fillMaxWidth() .testTag(tag = "ArchiveFilter") .standardHorizontalMargin(), @@ -408,7 +458,7 @@ fun VaultContent( } } - item { + item(key = "trash_group") { BitwardenGroupItem( startIcon = IconData.Local(iconRes = BitwardenDrawable.ic_trash), label = stringResource(id = BitwardenString.trash), @@ -416,15 +466,45 @@ fun VaultContent( onClick = vaultHandlers.trashClick, cardStyle = if (state.archiveEnabled) CardStyle.Bottom else CardStyle.Full, modifier = Modifier + .animateItem() .fillMaxWidth() .testTag("TrashFilter") .standardHorizontalMargin(), ) } - item { + item(key = "bottom_padding") { Spacer(modifier = Modifier.height(height = 88.dp)) Spacer(modifier = Modifier.navigationBarsPadding()) } } } + +@Composable +private fun ActionCard( + actionCardState: VaultState.ActionCardState, + vaultHandlers: VaultHandlers, + modifier: Modifier = Modifier, +) { + when (actionCardState) { + VaultState.ActionCardState.IntroducingArchive -> { + BitwardenActionCard( + cardTitle = stringResource(id = BitwardenString.introducing_archive), + cardSubtitle = stringResource( + id = BitwardenString.keep_items_you_dont_need_right_now_safe_but_out_sight, + ), + actionText = stringResource(id = BitwardenString.go_to_archive), + leadingContent = { + Icon( + painter = rememberVectorPainter(id = BitwardenDrawable.ic_archive), + contentDescription = null, + tint = BitwardenTheme.colorScheme.icon.secondary, + ) + }, + onActionClick = vaultHandlers.archiveClick, + onDismissClick = { vaultHandlers.dismissActionCardClick(actionCardState) }, + modifier = modifier, + ) + } + } +} diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/vault/VaultScreen.kt b/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/vault/VaultScreen.kt index f6316a570a..c4849d5295 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/vault/VaultScreen.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/vault/VaultScreen.kt @@ -326,6 +326,7 @@ private fun VaultScreenScaffold( when (val viewState = state.viewState) { is VaultState.ViewState.Content -> VaultContent( state = viewState, + actionCardState = state.actionCard, vaultHandlers = vaultHandlers, modifier = Modifier.fillMaxSize(), ) diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/vault/VaultViewModel.kt b/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/vault/VaultViewModel.kt index ea9da8d78e..84efbf5c5c 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/vault/VaultViewModel.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/vault/VaultViewModel.kt @@ -144,6 +144,9 @@ class VaultViewModel @Inject constructor( restrictItemTypesPolicyOrgIds = emptyList(), cipherDecryptionFailureIds = persistentListOf(), hasShownDecryptionFailureAlert = false, + isIntroducingArchiveActionCardDismissed = settingsRepository + .getIntroducingArchiveActionCardDismissedFlow() + .value, ) }, ) { @@ -217,6 +220,11 @@ class VaultViewModel @Inject constructor( .map { VaultAction.Internal.FlightRecorderDataReceive(data = it) } .onEach(::sendAction) .launchIn(viewModelScope) + settingsRepository + .getIntroducingArchiveActionCardDismissedFlow() + .map { VaultAction.Internal.IntroducingArchiveActionCardDismissedFlowReceive(it) } + .onEach(::sendAction) + .launchIn(viewModelScope) policyManager .getActivePoliciesFlow(type = PolicyTypeJson.RESTRICT_ITEM_TYPES) @@ -310,6 +318,7 @@ class VaultViewModel @Inject constructor( } VaultAction.UpgradeToPremiumClick -> handleUpgradeToPremiumClick() + is VaultAction.DismissActionCardClick -> handleDismissActionCardClick(action) } } @@ -359,6 +368,14 @@ class VaultViewModel @Inject constructor( sendEvent(VaultEvent.NavigateToUrl(url = url)) } + private fun handleDismissActionCardClick(action: VaultAction.DismissActionCardClick) { + when (action.actionCard) { + VaultState.ActionCardState.IntroducingArchive -> { + settingsRepository.dismissIntroducingArchiveActionCard() + } + } + } + private fun handleSelectAddItemType() { // If policy is enable for any organization, exclude the card option val excludedOptions = persistentListOfNotNull( @@ -901,6 +918,9 @@ class VaultViewModel @Inject constructor( is VaultAction.Internal.ArchiveCipherReceive -> handleArchiveCipherReceive(action) is VaultAction.Internal.UnarchiveCipherReceive -> handleUnarchiveCipherReceive(action) + is VaultAction.Internal.IntroducingArchiveActionCardDismissedFlowReceive -> { + handleIntroducingArchiveActionCardDismissedFlowReceive(action) + } } } @@ -1001,6 +1021,14 @@ class VaultViewModel @Inject constructor( } } + private fun handleIntroducingArchiveActionCardDismissedFlowReceive( + action: VaultAction.Internal.IntroducingArchiveActionCardDismissedFlowReceive, + ) { + mutableStateFlow.update { + it.copy(isIntroducingArchiveActionCardDismissed = action.isDismissed) + } + } + private fun handleDecryptionErrorReceive(action: VaultAction.Internal.DecryptionErrorReceive) { mutableStateFlow.update { it.copy( @@ -1412,8 +1440,19 @@ data class VaultState( val cipherDecryptionFailureIds: ImmutableList, val hasShownDecryptionFailureAlert: Boolean, val restrictItemTypesPolicyOrgIds: List, + val isIntroducingArchiveActionCardDismissed: Boolean, ) : Parcelable { + /** + * Indicates what action card to display. + */ + val actionCard: ActionCardState? + get() = (viewState as? ViewState.Content)?.let { + ActionCardState.IntroducingArchive.takeIf { + isPremium && !isIntroducingArchiveActionCardDismissed && isArchiveEnabled + } + } + /** * The [Color] of the avatar. */ @@ -1725,6 +1764,16 @@ data class VaultState( } } + /** + * Represents an action card to be displayed. + */ + sealed class ActionCardState { + /** + * Indicates that the archive feature is ready for use. + */ + data object IntroducingArchive : ActionCardState() + } + /** * Information about a dialog to display. */ @@ -2132,6 +2181,13 @@ sealed class VaultAction { */ data object UpgradeToPremiumClick : VaultAction() + /** + * User clicked the dismiss button on an action card. + */ + data class DismissActionCardClick( + val actionCard: VaultState.ActionCardState, + ) : VaultAction() + /** * Models actions that the [VaultViewModel] itself might send. */ @@ -2255,6 +2311,13 @@ sealed class VaultAction { data class UnarchiveCipherReceive( val result: UnarchiveCipherResult, ) : Internal() + + /** + * Indicates that the archive action card dismissed state has been updated. + */ + data class IntroducingArchiveActionCardDismissedFlowReceive( + val isDismissed: Boolean, + ) : Internal() } } diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/vault/handlers/VaultHandlers.kt b/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/vault/handlers/VaultHandlers.kt index 6287fa9fe7..4ed88e723f 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/vault/handlers/VaultHandlers.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/vault/handlers/VaultHandlers.kt @@ -51,6 +51,7 @@ data class VaultHandlers( val onEnabledThirdPartyAutofillClick: () -> Unit, val onDismissThirdPartyAutofillDialogClick: () -> Unit, val upgradeToPremiumClick: () -> Unit, + val dismissActionCardClick: (VaultState.ActionCardState) -> Unit, ) { @Suppress("UndocumentedPublicClass") companion object { @@ -147,6 +148,9 @@ data class VaultHandlers( upgradeToPremiumClick = { viewModel.trySendAction(VaultAction.UpgradeToPremiumClick) }, + dismissActionCardClick = { + viewModel.trySendAction(VaultAction.DismissActionCardClick(it)) + }, ) } } diff --git a/app/src/test/kotlin/com/x8bit/bitwarden/data/platform/datasource/disk/SettingsDiskSourceTest.kt b/app/src/test/kotlin/com/x8bit/bitwarden/data/platform/datasource/disk/SettingsDiskSourceTest.kt index a3966ac5f8..2609a60f3e 100644 --- a/app/src/test/kotlin/com/x8bit/bitwarden/data/platform/datasource/disk/SettingsDiskSourceTest.kt +++ b/app/src/test/kotlin/com/x8bit/bitwarden/data/platform/datasource/disk/SettingsDiskSourceTest.kt @@ -138,6 +138,10 @@ class SettingsDiskSourceTest { userId = userId, isPullToRefreshEnabled = true, ) + settingsDiskSource.storeIntroducingArchiveActionCardDismissed( + userId = userId, + isDismissed = true, + ) settingsDiskSource.storeInlineAutofillEnabled( userId = userId, isInlineAutofillEnabled = true, @@ -168,6 +172,9 @@ class SettingsDiskSourceTest { assertTrue(settingsDiskSource.getShowUnlockSettingBadge(userId = userId) ?: false) assertTrue(settingsDiskSource.getShowBrowserAutofillSettingBadge(userId = userId) ?: false) assertTrue(settingsDiskSource.getShowAutoFillSettingBadge(userId = userId) ?: false) + assertTrue( + settingsDiskSource.getIntroducingArchiveActionCardDismissed(userId = userId) ?: false, + ) // These should be cleared assertNull(settingsDiskSource.getVaultTimeoutInMinutes(userId = userId)) @@ -779,6 +786,44 @@ class SettingsDiskSourceTest { } } + @Suppress("MaxLineLength") + @Test + fun `getIntroducingArchiveActionCardDismissed when values are present should pull from SharedPreferences`() { + val introducingArchiveBaseKey = "bwPreferencesStorage:introducingArchiveActionCardDismissed" + val mockUserId = "mockUserId" + val introducingArchiveKey = "${introducingArchiveBaseKey}_$mockUserId" + assertNull(settingsDiskSource.getIntroducingArchiveActionCardDismissed(userId = mockUserId)) + fakeSharedPreferences.edit { putBoolean(introducingArchiveKey, true) } + assertEquals( + true, + settingsDiskSource.getIntroducingArchiveActionCardDismissed(userId = mockUserId), + ) + } + + @Suppress("MaxLineLength") + @Test + fun `getIntroducingArchiveActionCardDismissedFlow should react to changes in storeIntroducingArchiveActionCardDismissed`() = + runTest { + val mockUserId = "mockUserId" + settingsDiskSource + .getIntroducingArchiveActionCardDismissedFlow(userId = mockUserId) + .test { + // The initial values of the Flow and the property are in sync + assertNull( + settingsDiskSource + .getIntroducingArchiveActionCardDismissed(userId = mockUserId), + ) + assertNull(awaitItem()) + + // Updating the disk source updates shared preferences + settingsDiskSource.storeIntroducingArchiveActionCardDismissed( + userId = mockUserId, + isDismissed = true, + ) + assertEquals(true, awaitItem()) + } + } + @Test fun `storePullToRefreshEnabled for non-null values should update SharedPreferences`() { val pullToRefreshBaseKey = "bwPreferencesStorage:syncOnRefresh" diff --git a/app/src/test/kotlin/com/x8bit/bitwarden/data/platform/datasource/disk/util/FakeSettingsDiskSource.kt b/app/src/test/kotlin/com/x8bit/bitwarden/data/platform/datasource/disk/util/FakeSettingsDiskSource.kt index 496683edfa..e3a1f1410c 100644 --- a/app/src/test/kotlin/com/x8bit/bitwarden/data/platform/datasource/disk/util/FakeSettingsDiskSource.kt +++ b/app/src/test/kotlin/com/x8bit/bitwarden/data/platform/datasource/disk/util/FakeSettingsDiskSource.kt @@ -66,6 +66,7 @@ class FakeSettingsDiskSource( private val storedDisableAutoTotpCopy = mutableMapOf() private val storedDisableAutofillSavePrompt = mutableMapOf() private val storedPullToRefreshEnabled = mutableMapOf() + private var storedIntroducingArchiveActionCardDismissed = mutableMapOf() private val storedInlineAutofillEnabled = mutableMapOf() private val storedBlockedAutofillUris = mutableMapOf?>() private var storedIsIconLoadingDisabled: Boolean? = null @@ -108,6 +109,9 @@ class FakeSettingsDiskSource( private val mutableVaultRegisteredForExportFlow = bufferedMutableSharedFlow() + private val mutableIntroducingArchiveActionCardDismissedFlow = + mutableMapOf>() + override var appLanguage: AppLanguage? get() = storedAppLanguage set(value) { @@ -325,6 +329,18 @@ class FakeSettingsDiskSource( getMutablePullToRefreshEnabledFlow(userId = userId).tryEmit(isPullToRefreshEnabled) } + override fun getIntroducingArchiveActionCardDismissed(userId: String): Boolean? = + storedIntroducingArchiveActionCardDismissed[userId] + + override fun getIntroducingArchiveActionCardDismissedFlow(userId: String): Flow = + getMutableIntroducingArchiveActionCardDismissedFlow(userId = userId) + .onSubscription { emit(getIntroducingArchiveActionCardDismissed(userId = userId)) } + + override fun storeIntroducingArchiveActionCardDismissed(userId: String, isDismissed: Boolean?) { + storedIntroducingArchiveActionCardDismissed[userId] = isDismissed + getMutableIntroducingArchiveActionCardDismissedFlow(userId = userId).tryEmit(isDismissed) + } + override fun getInlineAutofillEnabled(userId: String): Boolean? = storedInlineAutofillEnabled[userId] @@ -532,6 +548,13 @@ class FakeSettingsDiskSource( bufferedMutableSharedFlow(replay = 1) } + private fun getMutableIntroducingArchiveActionCardDismissedFlow( + userId: String, + ): MutableSharedFlow = + mutableIntroducingArchiveActionCardDismissedFlow.getOrPut(userId) { + bufferedMutableSharedFlow(replay = 1) + } + private fun getMutableShowAutoFillSettingBadgeFlow( userId: String, ): MutableSharedFlow = mutableShowAutoFillSettingBadgeFlowMap.getOrPut(userId) { diff --git a/app/src/test/kotlin/com/x8bit/bitwarden/data/platform/repository/SettingsRepositoryTest.kt b/app/src/test/kotlin/com/x8bit/bitwarden/data/platform/repository/SettingsRepositoryTest.kt index deea283e13..2206e61e92 100644 --- a/app/src/test/kotlin/com/x8bit/bitwarden/data/platform/repository/SettingsRepositoryTest.kt +++ b/app/src/test/kotlin/com/x8bit/bitwarden/data/platform/repository/SettingsRepositoryTest.kt @@ -855,6 +855,38 @@ class SettingsRepositoryTest { } } + @Test + fun `dismissIntroducingArchiveActionCard should properly update SettingsDiskSource`() { + fakeAuthDiskSource.userState = MOCK_USER_STATE + settingsRepository.dismissIntroducingArchiveActionCard() + assertEquals( + true, + fakeSettingsDiskSource.getIntroducingArchiveActionCardDismissed(userId = USER_ID), + ) + } + + @Suppress("MaxLineLength") + @Test + fun `getIntroducingArchiveActionCardDismissedFlow should react to changes in SettingsDiskSource`() = + runTest { + fakeAuthDiskSource.userState = MOCK_USER_STATE + settingsRepository + .getIntroducingArchiveActionCardDismissedFlow() + .test { + assertFalse(awaitItem()) + fakeSettingsDiskSource.storeIntroducingArchiveActionCardDismissed( + userId = USER_ID, + isDismissed = true, + ) + assertTrue(awaitItem()) + fakeSettingsDiskSource.storeIntroducingArchiveActionCardDismissed( + userId = USER_ID, + isDismissed = false, + ) + assertFalse(awaitItem()) + } + } + @Test fun `storePullToRefreshEnabled should properly update SettingsDiskSource`() { fakeAuthDiskSource.userState = MOCK_USER_STATE diff --git a/app/src/test/kotlin/com/x8bit/bitwarden/ui/vault/feature/vault/VaultScreenTest.kt b/app/src/test/kotlin/com/x8bit/bitwarden/ui/vault/feature/vault/VaultScreenTest.kt index 54ae2d68ff..05eb6c8a3e 100644 --- a/app/src/test/kotlin/com/x8bit/bitwarden/ui/vault/feature/vault/VaultScreenTest.kt +++ b/app/src/test/kotlin/com/x8bit/bitwarden/ui/vault/feature/vault/VaultScreenTest.kt @@ -1498,6 +1498,58 @@ class VaultScreenTest : BitwardenComposeTest() { } } + @Test + fun `action cards should be displayed according to state`() { + composeTestRule + .onNodeWithText(text = "Introducing archive") + .assertDoesNotExist() + + mutableStateFlow.value = DEFAULT_STATE.copy( + isPremium = true, + viewState = DEFAULT_CONTENT_VIEW_STATE, + ) + + composeTestRule + .onNodeWithText(text = "Introducing archive") + .assertIsDisplayed() + } + + @Test + fun `IntroducingArchive action card go to archive button click should send ArchiveClick`() { + mutableStateFlow.value = DEFAULT_STATE.copy( + isPremium = true, + viewState = DEFAULT_CONTENT_VIEW_STATE, + ) + + composeTestRule + .onNodeWithText(text = "Go to archive") + .assertIsDisplayed() + .performClick() + + verify(exactly = 1) { + viewModel.trySendAction(VaultAction.ArchiveClick) + } + } + + @Test + fun `IntroducingArchive action card dismiss button click should send DismissActionCardClick`() { + mutableStateFlow.value = DEFAULT_STATE.copy( + isPremium = true, + viewState = DEFAULT_CONTENT_VIEW_STATE, + ) + + composeTestRule + .onNodeWithContentDescription(label = "Close") + .assertIsDisplayed() + .performClick() + + verify(exactly = 1) { + viewModel.trySendAction( + VaultAction.DismissActionCardClick(VaultState.ActionCardState.IntroducingArchive), + ) + } + } + @Test fun `collection data should update according to the state`() { val collectionsHeader = "COLLECTIONS (1)" @@ -2458,6 +2510,7 @@ private val DEFAULT_STATE: VaultState = VaultState( hasShownDecryptionFailureAlert = false, restrictItemTypesPolicyOrgIds = emptyList(), isArchiveEnabled = true, + isIntroducingArchiveActionCardDismissed = false, ) private val DEFAULT_CONTENT_VIEW_STATE: VaultState.ViewState.Content = VaultState.ViewState.Content( diff --git a/app/src/test/kotlin/com/x8bit/bitwarden/ui/vault/feature/vault/VaultViewModelTest.kt b/app/src/test/kotlin/com/x8bit/bitwarden/ui/vault/feature/vault/VaultViewModelTest.kt index 4feb414113..e706f3a140 100644 --- a/app/src/test/kotlin/com/x8bit/bitwarden/ui/vault/feature/vault/VaultViewModelTest.kt +++ b/app/src/test/kotlin/com/x8bit/bitwarden/ui/vault/feature/vault/VaultViewModelTest.kt @@ -163,6 +163,7 @@ class VaultViewModelTest : BaseViewModelTest() { private var mutableFlightRecorderDataFlow = MutableStateFlow(FlightRecorderDataSet(data = emptySet())) + private var mutableIntroducingArchiveActionCardDismissedFlow = MutableStateFlow(false) private val settingsRepository: SettingsRepository = mockk { every { getPullToRefreshEnabledFlow() } returns mutablePullToRefreshEnabledFlow every { isIconLoadingDisabledFlow } returns mutableIsIconLoadingDisabledFlow @@ -171,6 +172,10 @@ class VaultViewModelTest : BaseViewModelTest() { every { flightRecorderDataFlow } returns mutableFlightRecorderDataFlow every { dismissFlightRecorderBanner() } just runs every { isAutofillEnabledStateFlow } returns MutableStateFlow(false) + every { dismissIntroducingArchiveActionCard() } just runs + every { + getIntroducingArchiveActionCardDismissedFlow() + } returns mutableIntroducingArchiveActionCardDismissedFlow } private val vaultRepository: VaultRepository = @@ -234,6 +239,38 @@ class VaultViewModelTest : BaseViewModelTest() { } } + @Test + fun `IntroducingArchiveActionCardDismissedFlow updates should update the state accordingly`() = + runTest { + val viewModel = createViewModel() + + viewModel.stateFlow.test { + assertEquals(DEFAULT_STATE, awaitItem()) + mutableIntroducingArchiveActionCardDismissedFlow.value = true + assertEquals( + DEFAULT_STATE.copy(isIntroducingArchiveActionCardDismissed = true), + awaitItem(), + ) + mutableIntroducingArchiveActionCardDismissedFlow.value = false + assertEquals(DEFAULT_STATE, awaitItem()) + } + } + + @Suppress("MaxLineLength") + @Test + fun `DismissActionCardClick with IntroducingArchive should call dismissIntroducingArchiveActionCard`() = + runTest { + val viewModel = createViewModel() + + viewModel.trySendAction( + VaultAction.DismissActionCardClick(VaultState.ActionCardState.IntroducingArchive), + ) + + verify(exactly = 1) { + settingsRepository.dismissIntroducingArchiveActionCard() + } + } + @Test fun `UserState updates with a null value should do nothing`() { val viewModel = createViewModel() @@ -3463,4 +3500,5 @@ private fun createMockVaultState( hasShownDecryptionFailureAlert = false, restrictItemTypesPolicyOrgIds = emptyList(), isArchiveEnabled = true, + isIntroducingArchiveActionCardDismissed = false, ) diff --git a/ui/src/main/res/values/strings.xml b/ui/src/main/res/values/strings.xml index d3dfb5ddea..41d08f4436 100644 --- a/ui/src/main/res/values/strings.xml +++ b/ui/src/main/res/values/strings.xml @@ -1190,6 +1190,9 @@ Do you want to switch to this account? Archiving items is a Premium feature. Your current plan does not include access to this feature. Upgrade to premium This item is archived. + Introducing archive + Keep items you don’t need right now safe but out of sight. + Go to archive This item is archived. Saving changes will restore it to your vault. Your Premium subscription ended To regain access to your archive, restart your Premium subscription. If you edit details for an archived item before restarting, it’ll be moved back into your vault.