[PM-19779] Make Authenticator TOTP codes collapsible (#5452)

This commit is contained in:
Patrick Honkonen
2025-07-02 14:15:03 +00:00
committed by GitHub
parent eae870cb3a
commit 986cd2ee30
10 changed files with 613 additions and 105 deletions
@@ -36,8 +36,11 @@ import androidx.compose.material3.TopAppBarScrollBehavior
import androidx.compose.material3.rememberTopAppBarState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.input.nestedscroll.nestedScroll
@@ -72,7 +75,7 @@ import com.bitwarden.authenticator.ui.platform.components.dialog.BitwardenTwoBut
import com.bitwarden.authenticator.ui.platform.components.dialog.LoadingDialogState
import com.bitwarden.authenticator.ui.platform.components.fab.ExpandableFabIcon
import com.bitwarden.authenticator.ui.platform.components.fab.ExpandableFloatingActionButton
import com.bitwarden.authenticator.ui.platform.components.header.BitwardenListHeaderText
import com.bitwarden.authenticator.ui.platform.components.header.AuthenticatorExpandingHeader
import com.bitwarden.authenticator.ui.platform.components.header.BitwardenListHeaderTextWithSupportLabel
import com.bitwarden.authenticator.ui.platform.components.model.IconResource
import com.bitwarden.authenticator.ui.platform.components.scaffold.BitwardenScaffold
@@ -85,6 +88,7 @@ import com.bitwarden.ui.platform.base.util.EventsEffect
import com.bitwarden.ui.platform.components.util.rememberVectorPainter
import com.bitwarden.ui.platform.feature.settings.appearance.model.AppTheme
import com.bitwarden.ui.platform.resource.BitwardenDrawable
import com.bitwarden.ui.platform.theme.BitwardenTheme
import com.bitwarden.ui.util.asText
import kotlinx.coroutines.launch
@@ -245,6 +249,9 @@ fun ItemListingScreen(
onSyncLearnMoreClick = remember(viewModel) {
{ viewModel.trySendAction(ItemListingAction.SyncLearnMoreClick) }
},
onSectionExpandedClick = remember(viewModel) {
{ viewModel.trySendAction(ItemListingAction.SectionExpandedClick(it)) }
},
)
}
@@ -358,6 +365,7 @@ private fun ItemListingContent(
onSyncWithBitwardenClick: () -> Unit,
onDismissSyncWithBitwardenClick: () -> Unit,
onSyncLearnMoreClick: () -> Unit,
onSectionExpandedClick: (SharedCodesDisplayState.SharedCodesAccountSection) -> Unit,
) {
BitwardenScaffold(
modifier = Modifier
@@ -414,80 +422,45 @@ private fun ItemListingContent(
floatingActionButtonPosition = FabPosition.EndOverlay,
snackbarHost = { FirstTimeSyncSnackbarHost(state = snackbarHostState) },
) { paddingValues ->
Column(
var isLocalHeaderExpanded by rememberSaveable { mutableStateOf(true) }
LazyColumn(
modifier = Modifier
.fillMaxSize()
.padding(paddingValues),
) {
LazyColumn {
item {
ActionCard(
actionCardState = state.actionCard,
onDownloadBitwardenClick = onDownloadBitwardenClick,
onDownloadBitwardenDismissClick = onDismissDownloadBitwardenClick,
onSyncWithBitwardenClick = onSyncWithBitwardenClick,
onSyncWithBitwardenDismissClick = onDismissSyncWithBitwardenClick,
onSyncLearnMoreClick = onSyncLearnMoreClick,
modifier = Modifier.padding(all = 16.dp),
item(key = "action_card") {
ActionCard(
actionCardState = state.actionCard,
onDownloadBitwardenClick = onDownloadBitwardenClick,
onDownloadBitwardenDismissClick = onDismissDownloadBitwardenClick,
onSyncWithBitwardenClick = onSyncWithBitwardenClick,
onSyncWithBitwardenDismissClick = onDismissSyncWithBitwardenClick,
onSyncLearnMoreClick = onSyncLearnMoreClick,
modifier = Modifier
.padding(all = 16.dp)
.animateItem(),
)
}
if (state.favoriteItems.isNotEmpty()) {
item(key = "favorites_header") {
BitwardenListHeaderTextWithSupportLabel(
label = stringResource(id = R.string.favorites),
supportingLabel = state.favoriteItems.count().toString(),
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp)
.animateItem(),
)
}
if (state.favoriteItems.isNotEmpty()) {
item {
BitwardenListHeaderTextWithSupportLabel(
label = stringResource(id = R.string.favorites),
supportingLabel = state.favoriteItems.count().toString(),
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp),
)
}
item {
Spacer(modifier = Modifier.height(4.dp))
}
items(state.favoriteItems) {
VaultVerificationCodeItem(
authCode = it.authCode,
primaryLabel = it.title,
secondaryLabel = it.subtitle,
periodSeconds = it.periodSeconds,
timeLeftSeconds = it.timeLeftSeconds,
alertThresholdSeconds = it.alertThresholdSeconds,
startIcon = it.startIcon,
onItemClick = { onItemClick(it.authCode) },
onDropdownMenuClick = { action ->
onDropdownMenuClick(action, it)
},
showMoveToBitwarden = it.showMoveToBitwarden,
allowLongPress = it.allowLongPressActions,
modifier = Modifier.fillMaxWidth(),
)
}
item {
HorizontalDivider(
thickness = 1.dp,
color = MaterialTheme.colorScheme.outlineVariant,
modifier = Modifier
.fillMaxWidth()
.padding(all = 16.dp),
)
}
item {
Spacer(modifier = Modifier.height(4.dp))
}
if (state.shouldShowLocalHeader) {
item {
BitwardenListHeaderText(
label = stringResource(id = R.string.local_codes),
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp),
)
}
}
items(state.itemList) {
items(
items = state.favoriteItems,
key = { "favorite_item_${it.id}" },
) {
VaultVerificationCodeItem(
authCode = it.authCode,
primaryLabel = it.title,
@@ -506,26 +479,87 @@ private fun ItemListingContent(
)
}
// If there are any items in the local lists, add a spacer between
// local codes and shared codes:
if (state.itemList.isNotEmpty() || state.favoriteItems.isNotEmpty()) {
item {
Spacer(Modifier.height(16.dp))
}
item(key = "favorites_divider") {
HorizontalDivider(
thickness = 1.dp,
color = MaterialTheme.colorScheme.outlineVariant,
modifier = Modifier
.fillMaxWidth()
.padding(all = 16.dp)
.animateItem(),
)
}
}
when (state.sharedItems) {
is SharedCodesDisplayState.Codes -> {
state.sharedItems.sections.forEach { section ->
item {
BitwardenListHeaderText(
label = section.label(),
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp),
)
}
items(section.codes) {
if (state.shouldShowLocalHeader) {
item(key = "local_items_header") {
AuthenticatorExpandingHeader(
label = stringResource(id = R.string.local_codes, state.itemList.size),
isExpanded = isLocalHeaderExpanded,
onClick = { isLocalHeaderExpanded = !isLocalHeaderExpanded },
onClickLabel = if (isLocalHeaderExpanded) {
stringResource(R.string.local_items_are_expanded_click_to_collapse)
} else {
stringResource(R.string.local_items_are_collapsed_click_to_expand)
},
modifier = Modifier
.fillMaxWidth()
.animateItem(),
)
}
}
if (isLocalHeaderExpanded) {
items(
items = state.itemList,
key = { "local_item_${it.id}" },
) {
VaultVerificationCodeItem(
authCode = it.authCode,
primaryLabel = it.title,
secondaryLabel = it.subtitle,
periodSeconds = it.periodSeconds,
timeLeftSeconds = it.timeLeftSeconds,
alertThresholdSeconds = it.alertThresholdSeconds,
startIcon = it.startIcon,
onItemClick = { onItemClick(it.authCode) },
onDropdownMenuClick = { action ->
onDropdownMenuClick(action, it)
},
showMoveToBitwarden = it.showMoveToBitwarden,
allowLongPress = it.allowLongPressActions,
modifier = Modifier
.fillMaxWidth()
.animateItem(),
)
}
}
when (state.sharedItems) {
is SharedCodesDisplayState.Codes -> {
state.sharedItems.sections.forEachIndexed { index, section ->
item(key = "sharedSection_${section.label}") {
AuthenticatorExpandingHeader(
label = section.label(),
isExpanded = section.isExpanded,
onClick = {
onSectionExpandedClick(section)
},
onClickLabel = if (section.isExpanded) {
stringResource(R.string.items_expanded_click_to_collapse)
} else {
stringResource(R.string.items_are_collapsed_click_to_expand)
},
modifier = Modifier
.fillMaxWidth()
.animateItem(),
)
}
if (section.isExpanded) {
items(
items = section.codes,
key = { code -> "code_${code.id}" },
) {
VaultVerificationCodeItem(
authCode = it.authCode,
primaryLabel = it.title,
@@ -540,29 +574,33 @@ private fun ItemListingContent(
},
showMoveToBitwarden = it.showMoveToBitwarden,
allowLongPress = it.allowLongPressActions,
modifier = Modifier.fillMaxWidth(),
modifier = Modifier
.fillMaxWidth()
.animateItem(),
)
}
}
}
}
SharedCodesDisplayState.Error -> {
item {
Text(
text = stringResource(R.string.shared_codes_error),
modifier = Modifier.padding(horizontal = 16.dp),
color = MaterialTheme.colorScheme.onSurfaceVariant,
style = MaterialTheme.typography.bodySmall,
)
}
SharedCodesDisplayState.Error -> {
item(key = "shared_codes_error") {
Text(
text = stringResource(R.string.shared_codes_error),
color = MaterialTheme.colorScheme.onSurfaceVariant,
style = MaterialTheme.typography.bodySmall,
modifier = Modifier
.padding(horizontal = 16.dp)
.animateItem(),
)
}
}
}
// Add a spacer item to prevent the FAB from hiding verification codes at the
// bottom of the list
item {
Spacer(Modifier.height(72.dp))
}
// Add a spacer item to prevent the FAB from hiding verification codes at the
// bottom of the list
item {
Spacer(Modifier.height(72.dp))
}
}
}
@@ -869,3 +907,72 @@ private fun EmptyListingContentPreview() {
onDismissSyncWithBitwardenClick = { },
)
}
@Suppress("LongMethod")
@OptIn(ExperimentalMaterial3Api::class)
@Composable
@Preview(showBackground = true)
private fun ContentPreview() {
BitwardenTheme {
ItemListingContent(
state = ItemListingState.ViewState.Content(
actionCard = ItemListingState.ActionCardState.None,
favoriteItems = emptyList(),
itemList = listOf(
VerificationCodeDisplayItem(
id = "",
title = "Local item",
subtitle = "with a subtitle",
timeLeftSeconds = 20,
periodSeconds = 30,
alertThresholdSeconds = 15,
authCode = "123456",
favorite = false,
showMoveToBitwarden = true,
allowLongPressActions = true,
),
),
sharedItems = SharedCodesDisplayState.Codes(
sections = listOf(
SharedCodesDisplayState.SharedCodesAccountSection(
id = "id",
label =
"longemailaddress+verification+codes@email.com | Bitawrden.eu (1)"
.asText(),
codes = listOf(
VerificationCodeDisplayItem(
id = "",
title = "Shared item",
subtitle = "with a subtitle",
timeLeftSeconds = 15,
periodSeconds = 30,
alertThresholdSeconds = 15,
authCode = "123456",
favorite = false,
showMoveToBitwarden = false,
allowLongPressActions = false,
),
),
isExpanded = true,
),
),
),
),
snackbarHostState = remember { SnackbarHostState() },
scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior(
rememberTopAppBarState(),
),
onNavigateToSearch = { },
onScanQrCodeClick = { },
onEnterSetupKeyClick = { },
onItemClick = { },
onDropdownMenuClick = { _, _ -> },
onDownloadBitwardenClick = { },
onDismissDownloadBitwardenClick = { },
onSyncWithBitwardenClick = { },
onDismissSyncWithBitwardenClick = { },
onSyncLearnMoreClick = { },
onSectionExpandedClick = { },
)
}
}
@@ -157,6 +157,10 @@ class ItemListingViewModel @Inject constructor(
ItemListingAction.SyncLearnMoreClick -> {
handleSyncLearnMoreClick()
}
is ItemListingAction.SectionExpandedClick -> {
handleSectionExpandedClick(action)
}
}
}
@@ -452,6 +456,7 @@ class ItemListingViewModel @Inject constructor(
}
}
@Suppress("LongMethod")
private fun handleAuthenticatorDataReceive(
action: ItemListingAction.Internal.AuthCodesUpdated,
) {
@@ -473,10 +478,14 @@ class ItemListingViewModel @Inject constructor(
SharedVerificationCodesState.SyncNotEnabled,
-> SharedCodesDisplayState.Codes(emptyList())
is SharedVerificationCodesState.Success ->
is SharedVerificationCodesState.Success -> {
val viewState = state.viewState as? ItemListingState.ViewState.Content
val currentCodes = viewState?.sharedItems as? SharedCodesDisplayState.Codes
action.sharedCodesState.toSharedCodesDisplayState(
alertThresholdSeconds = state.alertThresholdSeconds,
currentSections = currentCodes?.sections.orEmpty(),
)
}
}
if (localItems.isEmpty() && sharedItemsState.isEmpty()) {
@@ -496,7 +505,7 @@ class ItemListingViewModel @Inject constructor(
it.toDisplayItem(
alertThresholdSeconds = state.alertThresholdSeconds,
sharedVerificationCodesState =
authenticatorRepository.sharedCodesStateFlow.value,
authenticatorRepository.sharedCodesStateFlow.value,
)
},
itemList = localItems
@@ -505,7 +514,7 @@ class ItemListingViewModel @Inject constructor(
it.toDisplayItem(
alertThresholdSeconds = state.alertThresholdSeconds,
sharedVerificationCodesState =
authenticatorRepository.sharedCodesStateFlow.value,
authenticatorRepository.sharedCodesStateFlow.value,
)
},
sharedItems = sharedItemsState,
@@ -572,6 +581,22 @@ class ItemListingViewModel @Inject constructor(
sendEvent(ItemListingEvent.NavigateToSyncInformation)
}
private fun handleSectionExpandedClick(action: ItemListingAction.SectionExpandedClick) {
updateSharedItems { codes ->
codes.copy(
sections = codes.sections.map {
it.copy(
isExpanded = if (it == action.section) {
!it.isExpanded
} else {
it.isExpanded
},
)
},
)
}
}
/**
* Converts a [SharedVerificationCodesState] into an action card for display.
*/
@@ -645,6 +670,29 @@ class ItemListingViewModel @Inject constructor(
favorite = false,
)
}
private inline fun updateContent(
crossinline block: (
ItemListingState.ViewState.Content,
) -> ItemListingState.ViewState.Content,
) {
val updatedContent = (state.viewState as? ItemListingState.ViewState.Content)
?.let(block)
?: return
mutableStateFlow.update { it.copy(viewState = updatedContent) }
}
private inline fun updateSharedItems(
crossinline block: (SharedCodesDisplayState.Codes) -> SharedCodesDisplayState.Codes,
) {
updateContent {
it.copy(
sharedItems = (it.sharedItems as? SharedCodesDisplayState.Codes)
?.let(block)
?: it.sharedItems,
)
}
}
}
const val ALGORITHM = "algorithm"
@@ -890,6 +938,13 @@ sealed class ItemListingAction {
*/
data object SyncLearnMoreClick : ItemListingAction()
/**
* The user tapped the section header to expand or collapse the section.
*/
data class SectionExpandedClick(
val section: SharedCodesDisplayState.SharedCodesAccountSection,
) : ItemListingAction()
/**
* The user dismissed sync Bitwarden action card.
*/
@@ -26,8 +26,10 @@ sealed class SharedCodesDisplayState : Parcelable {
*/
@Parcelize
data class SharedCodesAccountSection(
val id: String,
val label: Text,
val codes: List<VerificationCodeDisplayItem>,
val isExpanded: Boolean,
) : Parcelable
/**
@@ -12,6 +12,7 @@ import com.bitwarden.ui.util.asText
*/
fun SharedVerificationCodesState.Success.toSharedCodesDisplayState(
alertThresholdSeconds: Int,
currentSections: List<SharedCodesDisplayState.SharedCodesAccountSection> = emptyList(),
): SharedCodesDisplayState.Codes {
val codesMap =
mutableMapOf<AuthenticatorItem.Source.Shared, MutableList<VerificationCodeDisplayItem>>()
@@ -32,11 +33,17 @@ fun SharedVerificationCodesState.Success.toSharedCodesDisplayState(
return codesMap
.map {
SharedCodesDisplayState.SharedCodesAccountSection(
id = it.key.userId,
label = R.string.shared_accounts_header.asText(
it.key.email,
it.key.environmentLabel,
it.value.size,
),
codes = it.value,
isExpanded = currentSections
?.find { section -> section.id == it.key.userId }
?.isExpanded
?: true,
)
}
.let { SharedCodesDisplayState.Codes(it) }
@@ -0,0 +1,88 @@
package com.bitwarden.authenticator.ui.platform.components.header
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.minimumInteractiveComponentSize
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.rotate
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import com.bitwarden.ui.platform.components.util.rememberVectorPainter
import com.bitwarden.ui.platform.resource.BitwardenDrawable
/**
* A header that can be expanded and collapsed.
*/
@Composable
fun AuthenticatorExpandingHeader(
label: String,
isExpanded: Boolean,
onClick: () -> Unit,
onClickLabel: String,
modifier: Modifier = Modifier,
insets: PaddingValues = PaddingValues(top = 16.dp, bottom = 8.dp),
) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = modifier
.fillMaxWidth()
.clickable(
onClickLabel = onClickLabel,
onClick = onClick,
)
.minimumInteractiveComponentSize()
.padding(paddingValues = insets)
.padding(horizontal = 16.dp)
.semantics(mergeDescendants = true) {},
) {
val iconRotationDegrees = animateFloatAsState(
targetValue = if (isExpanded) 0f else 180f,
label = "expanderIconRotationAnimation",
)
Text(
text = label,
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.weight(1f, fill = false),
)
Spacer(modifier = Modifier.width(width = 8.dp))
Icon(
painter = rememberVectorPainter(id = BitwardenDrawable.ic_chevron_up_small),
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.rotate(degrees = iconRotationDegrees.value),
)
}
}
@Composable
@Preview(showBackground = true)
private fun ExpandingHeaderPreview() {
Column {
AuthenticatorExpandingHeader(
label = "Label Collapsed",
isExpanded = false,
onClick = { },
onClickLabel = "",
)
AuthenticatorExpandingHeader(
label = "Label Expanded",
isExpanded = true,
onClick = { },
onClickLabel = "",
)
}
}
@@ -118,7 +118,7 @@
<string name="sync_with_bitwarden_app">Sync with Bitwarden app</string>
<string name="this_feature_is_not_not_yet_available_for_self_hosted_users">This feature is not yet available for self-hosted users. <annotation link="learnMore">Learn more</annotation></string>
<string name="shared_codes_error">Unable to sync codes from the Bitwarden app. Make sure both apps are up-to-date. You can still access your existing codes in the Bitwarden app.</string>
<string name="shared_accounts_header">%1$s | %2$s</string>
<string name="shared_accounts_header">%1$s | %2$s (%3$d)</string>
<string name="sync_with_the_bitwarden_app">Sync with the Bitwarden app</string>
<string name="sync_with_bitwarden_action_card_message">In order to view all of your verification codes, youll need to allow for syncing on all of your accounts.</string>
<string name="take_me_to_app_settings">Take me to the app settings</string>
@@ -135,11 +135,15 @@
<string name="choose_save_location_message">Save this authenticator key here, or add it to a login in your Bitwarden app.</string>
<string name="save_option_as_default">Save option as default</string>
<string name="account_synced_from_bitwarden_app">Account synced from Bitwarden app</string>
<string name="local_codes">Local codes</string>
<string name="local_codes">Local codes (%1$d)</string>
<string name="required_information_missing">Required Information Missing</string>
<string name="required_information_missing_message">"Required info is missing (e.g., services or secret). Check your file and try again. Visit bitwarden.com/help for support"</string>
<string name="file_could_not_be_processed">"File Could Not Be Processed"</string>
<string name="file_could_not_be_processed_message">"File could not be processed. Ensure its valid JSON and try again. Need help? Visit bitwarden.com/help"</string>
<string name="get_help">Get Help</string>
<string name="expand_advanced_options">Expand advanced options</string>
<string name="local_items_are_expanded_click_to_collapse">Local items are expanded, click to collapse.</string>
<string name="local_items_are_collapsed_click_to_expand">Local items are collapsed, click to expand.</string>
<string name="items_expanded_click_to_collapse">Items are expanded, click to collapse.</string>
<string name="items_are_collapsed_click_to_expand">Items are collapsed, click to expand.</string>
</resources>
@@ -418,6 +418,118 @@ class ItemListingScreenTest : AuthenticatorComposeTest() {
.onNodeWithText("Account synced from Bitwarden app")
.assertIsDisplayed()
}
@Test
fun `local codes header should be displayed and expanded when syncing is enabled`() {
mutableStateFlow.value = DEFAULT_STATE.copy(
viewState = ItemListingState.ViewState.Content(
actionCard = ItemListingState.ActionCardState.None,
favoriteItems = emptyList(),
itemList = listOf(LOCAL_CODE),
sharedItems = SharedCodesDisplayState.Codes(
sections = listOf(
SHARED_ACCOUNTS_SECTION,
),
),
),
)
composeTestRule
.onNodeWithText("Local codes (1)")
.assertIsDisplayed()
composeTestRule
.onNodeWithText(LOCAL_CODE.title)
.assertIsDisplayed()
}
@Test
fun `shared codes header click should emit SectionExpandedClick`() {
val sharedItems = SharedCodesDisplayState.Codes(
sections = listOf(SHARED_ACCOUNTS_SECTION),
)
val viewState = ItemListingState.ViewState.Content(
actionCard = ItemListingState.ActionCardState.None,
favoriteItems = emptyList(),
itemList = listOf(LOCAL_CODE),
sharedItems = sharedItems,
)
mutableStateFlow.value = DEFAULT_STATE.copy(viewState = viewState)
composeTestRule
.onNodeWithTextAfterScroll("test@test.com | bitwarden.com (1)")
.performClick()
verify {
viewModel.trySendAction(ItemListingAction.SectionExpandedClick(SHARED_ACCOUNTS_SECTION))
}
}
@Test
fun `shared codes header should be displayed and collapsed when syncing is enabled`() {
val sharedItems = SharedCodesDisplayState.Codes(
sections = listOf(SHARED_ACCOUNTS_SECTION),
)
val viewState = ItemListingState.ViewState.Content(
actionCard = ItemListingState.ActionCardState.None,
favoriteItems = emptyList(),
itemList = listOf(LOCAL_CODE),
sharedItems = sharedItems,
)
mutableStateFlow.value = DEFAULT_STATE.copy(viewState = viewState)
composeTestRule
.onNodeWithTextAfterScroll("test@test.com | bitwarden.com (1)")
.assertIsDisplayed()
composeTestRule
.onNodeWithTextAfterScroll(SHARED_ACCOUNTS_SECTION.codes[0].title)
.assertIsDisplayed()
mutableStateFlow.value = DEFAULT_STATE.copy(
viewState = viewState.copy(
sharedItems = sharedItems.copy(
sections = listOf(SHARED_ACCOUNTS_SECTION.copy(isExpanded = false)),
),
),
)
composeTestRule
.onNodeWithTextAfterScroll("test@test.com | bitwarden.com (1)")
.assertIsDisplayed()
composeTestRule
.onNodeWithText(SHARED_ACCOUNTS_SECTION.codes[0].title)
.assertIsNotDisplayed()
}
@Test
fun `local codes should be displayed based on expanding header state`() {
mutableStateFlow.value = DEFAULT_STATE.copy(
viewState = ItemListingState.ViewState.Content(
actionCard = ItemListingState.ActionCardState.None,
favoriteItems = emptyList(),
itemList = listOf(LOCAL_CODE),
sharedItems = SharedCodesDisplayState.Codes(
sections = listOf(
SHARED_ACCOUNTS_SECTION,
),
),
),
)
composeTestRule
.onNodeWithText(LOCAL_CODE.title)
.assertIsDisplayed()
composeTestRule
.onNodeWithText("Local codes (1)")
.performClick()
composeTestRule
.onNodeWithText(LOCAL_CODE.title)
.assertIsNotDisplayed()
}
}
private val APP_THEME = AppTheme.DEFAULT
@@ -437,7 +549,8 @@ private val LOCAL_CODE = VerificationCodeDisplayItem(
)
private val SHARED_ACCOUNTS_SECTION = SharedCodesDisplayState.SharedCodesAccountSection(
label = "test@test.com".asText(),
id = "id",
label = "test@test.com | bitwarden.com (1)".asText(),
codes = listOf(
VerificationCodeDisplayItem(
id = "1",
@@ -452,6 +565,7 @@ private val SHARED_ACCOUNTS_SECTION = SharedCodesDisplayState.SharedCodesAccount
showMoveToBitwarden = false,
),
),
isExpanded = true,
)
private val DEFAULT_STATE = ItemListingState(
@@ -504,6 +504,31 @@ class ItemListingViewModelTest : BaseViewModelTest() {
)
}
@Test
fun `on SectionExpandedClick should update expanded state for clicked section`() = runTest {
val expectedState = DEFAULT_STATE.copy(
viewState = ItemListingState.ViewState.Content(
actionCard = ItemListingState.ActionCardState.None,
favoriteItems = LOCAL_FAVORITE_ITEMS.map { it.copy(showMoveToBitwarden = true) },
itemList = LOCAL_NON_FAVORITE_ITEMS.map { it.copy(showMoveToBitwarden = true) },
sharedItems = SHARED_DISPLAY_ITEMS.copy(
sections = SHARED_DISPLAY_ITEMS.sections.map { it.copy(isExpanded = false) },
),
),
)
mutableVerificationCodesFlow.value = DataState.Loaded(LOCAL_VERIFICATION_ITEMS)
mutableSharedCodesFlow.value =
SharedVerificationCodesState.Success(SHARED_VERIFICATION_ITEMS)
val viewModel = createViewModel()
viewModel.trySendAction(
ItemListingAction.SectionExpandedClick(section = SHARED_DISPLAY_ITEMS.sections.first()),
)
assertEquals(
expectedState,
viewModel.stateFlow.value,
)
}
private fun createViewModel() = ItemListingViewModel(
authenticatorRepository = authenticatorRepository,
authenticatorBridgeManager = authenticatorBridgeManager,
@@ -124,9 +124,11 @@ private val SHARED_ITEMS = listOf(
private val SHARED_DISPLAY_ITEMS = SharedCodesDisplayState.Codes(
sections = listOf(
SharedCodesDisplayState.SharedCodesAccountSection(
id = "mockUserId-2",
label = R.string.shared_accounts_header.asText(
"mockEmail-2",
"mockkEnvironmentLabel-2",
1,
),
codes = listOf(
VerificationCodeDisplayItem(
@@ -142,6 +144,7 @@ private val SHARED_DISPLAY_ITEMS = SharedCodesDisplayState.Codes(
showMoveToBitwarden = false,
),
),
isExpanded = true,
),
),
)
@@ -61,9 +61,11 @@ class SharedVerificationCodesStateTest {
val expected = SharedCodesDisplayState.Codes(
sections = listOf(
SharedCodesDisplayState.SharedCodesAccountSection(
id = "user1",
label = R.string.shared_accounts_header.asText(
"John@test.com",
"bitwarden.com",
1,
),
codes = listOf(
VerificationCodeDisplayItem(
@@ -79,11 +81,14 @@ class SharedVerificationCodesStateTest {
showMoveToBitwarden = false,
),
),
isExpanded = true,
),
SharedCodesDisplayState.SharedCodesAccountSection(
id = "user1",
label = R.string.shared_accounts_header.asText(
"Jane@test.com",
"bitwarden.eu",
1,
),
codes = listOf(
VerificationCodeDisplayItem(
@@ -99,6 +104,7 @@ class SharedVerificationCodesStateTest {
showMoveToBitwarden = false,
),
),
isExpanded = true,
),
),
)
@@ -107,6 +113,103 @@ class SharedVerificationCodesStateTest {
state.toSharedCodesDisplayState(ALERT_THRESHOLD),
)
}
@Test
fun `toSharedCodesDisplayState should return list of sections maintaining expanded state`() {
val state = SharedVerificationCodesState.Success(
items = listOf(
VerificationCodeItem(
code = "123456",
periodSeconds = 30,
timeLeftSeconds = 10,
issueTime = 100L,
id = "123",
issuer = null,
label = null,
source = AuthenticatorItem.Source.Shared(
userId = "user1",
nameOfUser = "John Appleseed",
email = "John@test.com",
environmentLabel = "bitwarden.com",
),
),
VerificationCodeItem(
code = "987654",
periodSeconds = 30,
timeLeftSeconds = 10,
issueTime = 100L,
id = "987",
issuer = "issuer",
label = "accountName",
source = AuthenticatorItem.Source.Shared(
userId = "user1",
nameOfUser = "Jane Doe",
email = "Jane@test.com",
environmentLabel = "bitwarden.eu",
),
),
),
)
val expected = SharedCodesDisplayState.Codes(
sections = listOf(
SharedCodesDisplayState.SharedCodesAccountSection(
id = "user1",
label = R.string.shared_accounts_header.asText(
"John@test.com",
"bitwarden.com",
1,
),
codes = listOf(
VerificationCodeDisplayItem(
authCode = "123456",
periodSeconds = 30,
timeLeftSeconds = 10,
id = "123",
title = "--",
subtitle = null,
favorite = false,
allowLongPressActions = false,
alertThresholdSeconds = ALERT_THRESHOLD,
showMoveToBitwarden = false,
),
),
isExpanded = false,
),
SharedCodesDisplayState.SharedCodesAccountSection(
id = "user1",
label = R.string.shared_accounts_header.asText(
"Jane@test.com",
"bitwarden.eu",
1,
),
codes = listOf(
VerificationCodeDisplayItem(
authCode = "987654",
periodSeconds = 30,
timeLeftSeconds = 10,
id = "987",
title = "issuer",
subtitle = "accountName",
favorite = false,
allowLongPressActions = false,
alertThresholdSeconds = ALERT_THRESHOLD,
showMoveToBitwarden = false,
),
),
isExpanded = false,
),
),
)
assertEquals(
expected,
state.toSharedCodesDisplayState(
alertThresholdSeconds = ALERT_THRESHOLD,
currentSections = expected.sections.map {
it.copy(label = "junk to show that it does update the other values".asText())
},
),
)
}
}
private const val ALERT_THRESHOLD = 7