PM-29693: Add introducing archive action card to vault screen (#6390)

This commit is contained in:
David Perez
2026-01-23 16:50:43 +00:00
committed by GitHub
parent 9f1fad8be0
commit 319872ccf9
14 changed files with 452 additions and 26 deletions
@@ -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<Boolean?>
/**
* Retrieves the biometric integrity validity for the given [userId] and
* [systemBioIntegrityState].
@@ -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<String, MutableSharedFlow<Boolean?>>()
private val mutableIntroducingArchiveActionCardDismissedFlowMap =
mutableMapOf<String, MutableSharedFlow<Boolean?>>()
private val mutableIsIconLoadingDisabledFlow = bufferedMutableSharedFlow<Boolean?>()
private val mutableIsCrashLoggingEnabledFlow = bufferedMutableSharedFlow<Boolean?>()
@@ -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<Boolean?> =
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<Boolean?> =
mutableIntroducingArchiveActionCardDismissedFlowMap.getOrPut(userId) {
bufferedMutableSharedFlow(replay = 1)
}
private fun getMutableLastSyncFlow(
userId: String,
): MutableSharedFlow<Instant?> =
@@ -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<Boolean>
/**
* 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.
@@ -500,6 +500,29 @@ class SettingsRepositoryImpl(
}
}
override fun getIntroducingArchiveActionCardDismissedFlow(): StateFlow<Boolean> {
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())
@@ -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,
)
}
}
}
@@ -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(),
)
@@ -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<String>,
val hasShownDecryptionFailureAlert: Boolean,
val restrictItemTypesPolicyOrgIds: List<String>,
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()
}
}
@@ -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))
},
)
}
}
@@ -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"
@@ -66,6 +66,7 @@ class FakeSettingsDiskSource(
private val storedDisableAutoTotpCopy = mutableMapOf<String, Boolean?>()
private val storedDisableAutofillSavePrompt = mutableMapOf<String, Boolean?>()
private val storedPullToRefreshEnabled = mutableMapOf<String, Boolean?>()
private var storedIntroducingArchiveActionCardDismissed = mutableMapOf<String, Boolean?>()
private val storedInlineAutofillEnabled = mutableMapOf<String, Boolean?>()
private val storedBlockedAutofillUris = mutableMapOf<String, List<String>?>()
private var storedIsIconLoadingDisabled: Boolean? = null
@@ -108,6 +109,9 @@ class FakeSettingsDiskSource(
private val mutableVaultRegisteredForExportFlow =
bufferedMutableSharedFlow<Boolean?>()
private val mutableIntroducingArchiveActionCardDismissedFlow =
mutableMapOf<String, MutableSharedFlow<Boolean?>>()
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<Boolean?> =
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<Boolean?> =
mutableIntroducingArchiveActionCardDismissedFlow.getOrPut(userId) {
bufferedMutableSharedFlow(replay = 1)
}
private fun getMutableShowAutoFillSettingBadgeFlow(
userId: String,
): MutableSharedFlow<Boolean?> = mutableShowAutoFillSettingBadgeFlowMap.getOrPut(userId) {
@@ -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
@@ -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(
@@ -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,
)
+3
View File
@@ -1190,6 +1190,9 @@ Do you want to switch to this account?</string>
<string name="archiving_items_is_a_premium_feature">Archiving items is a Premium feature. Your current plan does not include access to this feature.</string>
<string name="upgrade_to_premium">Upgrade to premium</string>
<string name="this_item_is_archived">This item is archived.</string>
<string name="introducing_archive">Introducing archive</string>
<string name="keep_items_you_dont_need_right_now_safe_but_out_sight">Keep items you dont need right now safe but out of sight.</string>
<string name="go_to_archive">Go to archive</string>
<string name="this_item_is_archived_saving_changes_will_restore_it_to_your_vault">This item is archived. Saving changes will restore it to your vault.</string>
<string name="your_premium_subscription_ended">Your Premium subscription ended</string>
<string name="to_regain_access_to_your_archive_restart_your_premium_subscription">To regain access to your archive, restart your Premium subscription. If you edit details for an archived item before restarting, itll be moved back into your vault.</string>