Update Vault Item Listing screen app bar for autofill (#834)

This commit is contained in:
Brian Yencho 2024-01-28 16:34:36 -06:00 committed by Álison Fernandes
parent 0a6b0f8dc7
commit 5b854c17b7
12 changed files with 537 additions and 22 deletions

View file

@ -10,7 +10,7 @@ import kotlinx.parcelize.Parcelize
* @property uri A URI representing the location where data should be filled (if available).
*/
@Parcelize
class AutofillSelectionData(
data class AutofillSelectionData(
val type: Type,
val uri: String?,
) : Parcelable {

View file

@ -12,6 +12,7 @@ import androidx.compose.ui.text.input.VisualTransformation
import androidx.compose.ui.text.rememberTextMeasurer
import androidx.core.graphics.toColorInt
import java.net.URI
import java.net.URISyntaxException
import java.text.Normalizer
import java.util.Locale
import kotlin.math.floor
@ -54,6 +55,19 @@ fun String.isValidUri(): Boolean =
false
}
/**
* Returns the host name (or path as a fallback) for the given [String] if it represents a
* well-formed URI, or `null` otherwise.
*/
fun String.toHostOrPathOrNull(): String? {
val uri = try {
URI(this)
} catch (e: URISyntaxException) {
return null
}
return uri.host ?: uri.path
}
/**
* Returns the original [String] only if:
*

View file

@ -14,6 +14,7 @@ import androidx.compose.runtime.Composable
import androidx.compose.ui.graphics.painter.Painter
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
import com.x8bit.bitwarden.R
import com.x8bit.bitwarden.ui.platform.theme.BitwardenTheme
@ -92,6 +93,9 @@ fun BitwardenTopAppBar(
Text(
text = title,
style = MaterialTheme.typography.titleLarge,
maxLines = 1,
softWrap = false,
overflow = TextOverflow.Ellipsis,
)
},
actions = actions,

View file

@ -67,6 +67,7 @@ class RootNavViewModel @Inject constructor(
when (specialCircumstance) {
is SpecialCircumstance.AutofillSelection -> {
RootNavState.VaultUnlockedForAutofillSelection(
activeUserId = userState.activeAccount.userId,
type = specialCircumstance.autofillSelectionData.type,
)
}
@ -122,6 +123,7 @@ sealed class RootNavState : Parcelable {
*/
@Parcelize
data class VaultUnlockedForAutofillSelection(
val activeUserId: String,
val type: AutofillSelectionData.Type,
) : RootNavState()

View file

@ -15,7 +15,10 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.platform.LocalContext
@ -26,6 +29,8 @@ import androidx.hilt.navigation.compose.hiltViewModel
import com.x8bit.bitwarden.R
import com.x8bit.bitwarden.ui.platform.base.util.EventsEffect
import com.x8bit.bitwarden.ui.platform.components.BasicDialogState
import com.x8bit.bitwarden.ui.platform.components.BitwardenAccountActionItem
import com.x8bit.bitwarden.ui.platform.components.BitwardenAccountSwitcher
import com.x8bit.bitwarden.ui.platform.components.BitwardenBasicDialog
import com.x8bit.bitwarden.ui.platform.components.BitwardenErrorContent
import com.x8bit.bitwarden.ui.platform.components.BitwardenLoadingContent
@ -35,12 +40,15 @@ import com.x8bit.bitwarden.ui.platform.components.BitwardenScaffold
import com.x8bit.bitwarden.ui.platform.components.BitwardenSearchActionItem
import com.x8bit.bitwarden.ui.platform.components.BitwardenTopAppBar
import com.x8bit.bitwarden.ui.platform.components.LoadingDialogState
import com.x8bit.bitwarden.ui.platform.components.NavigationIcon
import com.x8bit.bitwarden.ui.platform.components.OverflowMenuItemData
import com.x8bit.bitwarden.ui.platform.feature.search.model.SearchType
import com.x8bit.bitwarden.ui.platform.manager.intent.IntentManager
import com.x8bit.bitwarden.ui.platform.theme.LocalIntentManager
import com.x8bit.bitwarden.ui.vault.feature.itemlisting.handlers.VaultItemListingHandlers
import com.x8bit.bitwarden.ui.vault.feature.vault.util.initials
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList
/**
* Displays the vault item listing screen.
@ -50,7 +58,7 @@ import kotlinx.collections.immutable.persistentListOf
fun VaultItemListingScreen(
onNavigateBack: () -> Unit,
onNavigateToVaultItem: (id: String) -> Unit,
onNavigateToVaultEditItemScreen: (cipherId: String) -> Unit,
onNavigateToVaultEditItemScreen: (cipherVaultId: String) -> Unit,
onNavigateToVaultAddItemScreen: () -> Unit,
onNavigateToAddSendItem: () -> Unit,
onNavigateToEditSendItem: (sendId: String) -> Unit,
@ -158,6 +166,7 @@ private fun VaultItemListingScaffold(
pullToRefreshState: PullToRefreshState?,
vaultItemListingHandlers: VaultItemListingHandlers,
) {
var isAccountMenuVisible by rememberSaveable { mutableStateOf(false) }
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior(rememberTopAppBarState())
BitwardenScaffold(
modifier = Modifier
@ -165,28 +174,40 @@ private fun VaultItemListingScaffold(
.nestedScroll(scrollBehavior.nestedScrollConnection),
topBar = {
BitwardenTopAppBar(
title = state.itemListingType.titleText(),
title = state.appBarTitle(),
scrollBehavior = scrollBehavior,
navigationIcon = painterResource(id = R.drawable.ic_back),
navigationIconContentDescription = stringResource(id = R.string.back),
onNavigationIconClick = vaultItemListingHandlers.backClick,
navigationIcon = NavigationIcon(
navigationIcon = painterResource(id = R.drawable.ic_back),
navigationIconContentDescription = stringResource(id = R.string.back),
onNavigationIconClick = vaultItemListingHandlers.backClick,
)
.takeIf { state.shouldShowNavigationIcon },
actions = {
if (state.shouldShowAccountSwitcher) {
BitwardenAccountActionItem(
initials = state.activeAccountSummary.initials,
color = state.activeAccountSummary.avatarColor,
onClick = { isAccountMenuVisible = !isAccountMenuVisible },
)
}
BitwardenSearchActionItem(
contentDescription = stringResource(id = R.string.search_vault),
onClick = vaultItemListingHandlers.searchIconClick,
)
BitwardenOverflowActionItem(
menuItemDataList = persistentListOf(
OverflowMenuItemData(
text = stringResource(id = R.string.sync),
onClick = vaultItemListingHandlers.syncClick,
if (state.shouldShowOverflowMenu) {
BitwardenOverflowActionItem(
menuItemDataList = persistentListOf(
OverflowMenuItemData(
text = stringResource(id = R.string.sync),
onClick = vaultItemListingHandlers.syncClick,
),
OverflowMenuItemData(
text = stringResource(id = R.string.lock),
onClick = vaultItemListingHandlers.lockClick,
),
),
OverflowMenuItemData(
text = stringResource(id = R.string.lock),
onClick = vaultItemListingHandlers.lockClick,
),
),
)
)
}
},
)
},
@ -239,5 +260,20 @@ private fun VaultItemListingScaffold(
BitwardenLoadingContent(modifier = modifier)
}
}
BitwardenAccountSwitcher(
isVisible = isAccountMenuVisible,
accountSummaries = state.accountSummaries.toImmutableList(),
onSwitchAccountClick = vaultItemListingHandlers.switchAccountClick,
onLockAccountClick = vaultItemListingHandlers.lockAccountClick,
onLogoutAccountClick = vaultItemListingHandlers.logoutAccountClick,
onAddAccountClick = {
// Not available
},
onDismissRequest = { isAccountMenuVisible = false },
isAddAccountAvailable = false,
topAppBarScrollBehavior = scrollBehavior,
modifier = modifier,
)
}
}

View file

@ -4,6 +4,7 @@ import android.os.Parcelable
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.viewModelScope
import com.x8bit.bitwarden.R
import com.x8bit.bitwarden.data.auth.repository.AuthRepository
import com.x8bit.bitwarden.data.autofill.manager.AutofillSelectionManager
import com.x8bit.bitwarden.data.autofill.model.AutofillSelectionData
import com.x8bit.bitwarden.data.platform.manager.SpecialCircumstanceManager
@ -22,6 +23,8 @@ import com.x8bit.bitwarden.ui.platform.base.BaseViewModel
import com.x8bit.bitwarden.ui.platform.base.util.Text
import com.x8bit.bitwarden.ui.platform.base.util.asText
import com.x8bit.bitwarden.ui.platform.base.util.concat
import com.x8bit.bitwarden.ui.platform.base.util.toHostOrPathOrNull
import com.x8bit.bitwarden.ui.platform.components.model.AccountSummary
import com.x8bit.bitwarden.ui.platform.components.model.IconData
import com.x8bit.bitwarden.ui.platform.components.model.IconRes
import com.x8bit.bitwarden.ui.platform.feature.search.model.SearchType
@ -32,6 +35,8 @@ import com.x8bit.bitwarden.ui.vault.feature.itemlisting.util.toSearchType
import com.x8bit.bitwarden.ui.vault.feature.itemlisting.util.toViewState
import com.x8bit.bitwarden.ui.vault.feature.itemlisting.util.updateWithAdditionalDataIfNecessary
import com.x8bit.bitwarden.ui.vault.feature.vault.model.VaultFilterType
import com.x8bit.bitwarden.ui.vault.feature.vault.util.toAccountSummaries
import com.x8bit.bitwarden.ui.vault.feature.vault.util.toActiveAccountSummary
import com.x8bit.bitwarden.ui.vault.feature.vault.util.toFilteredList
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.launchIn
@ -53,6 +58,7 @@ class VaultItemListingViewModel @Inject constructor(
savedStateHandle: SavedStateHandle,
private val clock: Clock,
private val clipboardManager: BitwardenClipboardManager,
private val authRepository: AuthRepository,
private val vaultRepository: VaultRepository,
private val environmentRepository: EnvironmentRepository,
private val settingsRepository: SettingsRepository,
@ -60,12 +66,17 @@ class VaultItemListingViewModel @Inject constructor(
private val specialCircumstanceManager: SpecialCircumstanceManager,
) : BaseViewModel<VaultItemListingState, VaultItemListingEvent, VaultItemListingsAction>(
initialState = run {
val userState = requireNotNull(authRepository.userStateFlow.value)
val activeAccountSummary = userState.toActiveAccountSummary()
val accountSummaries = userState.toAccountSummaries()
val specialCircumstance =
specialCircumstanceManager.specialCircumstance as? SpecialCircumstance.AutofillSelection
VaultItemListingState(
itemListingType = VaultItemListingArgs(savedStateHandle = savedStateHandle)
.vaultItemListingType
.toItemListingType(),
activeAccountSummary = activeAccountSummary,
accountSummaries = accountSummaries,
viewState = VaultItemListingState.ViewState.Loading,
vaultFilterType = vaultRepository.vaultFilterType,
baseWebSendUrl = environmentRepository.environment.environmentUrlData.baseWebSendUrl,
@ -99,6 +110,9 @@ class VaultItemListingViewModel @Inject constructor(
override fun handleAction(action: VaultItemListingsAction) {
when (action) {
is VaultItemListingsAction.LockAccountClick -> handleLockAccountClick(action)
is VaultItemListingsAction.LogoutAccountClick -> handleLogoutAccountClick(action)
is VaultItemListingsAction.SwitchAccountClick -> handleSwitchAccountClick(action)
is VaultItemListingsAction.DismissDialogClick -> handleDismissDialogClick()
is VaultItemListingsAction.BackClick -> handleBackClick()
is VaultItemListingsAction.LockClick -> handleLockClick()
@ -114,6 +128,18 @@ class VaultItemListingViewModel @Inject constructor(
}
//region VaultItemListing Handlers
private fun handleLockAccountClick(action: VaultItemListingsAction.LockAccountClick) {
vaultRepository.lockVault(userId = action.accountSummary.userId)
}
private fun handleLogoutAccountClick(action: VaultItemListingsAction.LogoutAccountClick) {
authRepository.logout(userId = action.accountSummary.userId)
}
private fun handleSwitchAccountClick(action: VaultItemListingsAction.SwitchAccountClick) {
authRepository.switchAccount(userId = action.accountSummary.userId)
}
private fun handleRefreshClick() {
vaultRepository.sync()
}
@ -414,6 +440,12 @@ class VaultItemListingViewModel @Inject constructor(
private fun handleVaultDataReceive(
action: VaultItemListingsAction.Internal.VaultDataReceive,
) {
if (state.activeAccountSummary.userId != authRepository.userStateFlow.value?.activeUserId) {
// We are in the process of switching accounts, so we should ignore any updates here
// to avoid any unnecessary visual changes.
return
}
when (val vaultData = action.vaultData) {
is DataState.Error -> vaultErrorReceive(vaultData = vaultData)
is DataState.Loaded -> vaultLoadedReceive(vaultData = vaultData)
@ -541,6 +573,8 @@ class VaultItemListingViewModel @Inject constructor(
*/
data class VaultItemListingState(
val itemListingType: ItemListingType,
val activeAccountSummary: AccountSummary,
val accountSummaries: List<AccountSummary>,
val viewState: ViewState,
val vaultFilterType: VaultFilterType,
val baseWebSendUrl: String,
@ -549,9 +583,24 @@ data class VaultItemListingState(
val dialogState: DialogState?,
// Internal
private val isPullToRefreshSettingEnabled: Boolean,
private val autofillSelectionData: AutofillSelectionData? = null,
private val shouldFinishOnComplete: Boolean = false,
val autofillSelectionData: AutofillSelectionData? = null,
val shouldFinishOnComplete: Boolean = false,
) {
/**
* Whether or not this represents a listing screen for autofill.
*/
val isAutofill: Boolean
get() = autofillSelectionData != null
/**
* A displayable title for the AppBar.
*/
val appBarTitle: Text
get() = autofillSelectionData
?.uri
?.toHostOrPathOrNull()
?.let { R.string.items_for_uri.asText(it) }
?: itemListingType.titleText
/**
* Indicates that the pull-to-refresh should be enabled in the UI.
@ -560,10 +609,19 @@ data class VaultItemListingState(
get() = isPullToRefreshSettingEnabled && viewState.isPullToRefreshEnabled
/**
* Whether or not this represents a listing screen for autofill.
* Whether or not the account switcher should be shown.
*/
val isAutofill: Boolean
get() = autofillSelectionData != null
val shouldShowAccountSwitcher: Boolean get() = isAutofill
/**
* Whether or not the navigation icon should be shown.
*/
val shouldShowNavigationIcon: Boolean get() = !isAutofill
/**
* Whether or not the overflow menu should be shown.
*/
val shouldShowOverflowMenu: Boolean get() = !isAutofill
/**
* Represents the current state of any dialogs on the screen.
@ -843,6 +901,28 @@ sealed class VaultItemListingEvent {
* Models actions for the [VaultItemListingScreen].
*/
sealed class VaultItemListingsAction {
/**
* Indicates the user has clicked on the given [accountSummary] information in order to lock
* the associated account's vault.
*/
data class LockAccountClick(
val accountSummary: AccountSummary,
) : VaultItemListingsAction()
/**
* Indicates the user has clicked on the given [accountSummary] information in order to log out
* of that account.
*/
data class LogoutAccountClick(
val accountSummary: AccountSummary,
) : VaultItemListingsAction()
/**
* The user has clicked the an account to switch too.
*/
data class SwitchAccountClick(
val accountSummary: AccountSummary,
) : VaultItemListingsAction()
/**
* Click to dismiss the dialog.

View file

@ -1,5 +1,6 @@
package com.x8bit.bitwarden.ui.vault.feature.itemlisting.handlers
import com.x8bit.bitwarden.ui.platform.components.model.AccountSummary
import com.x8bit.bitwarden.ui.vault.feature.itemlisting.VaultItemListingViewModel
import com.x8bit.bitwarden.ui.vault.feature.itemlisting.VaultItemListingsAction
import com.x8bit.bitwarden.ui.vault.feature.itemlisting.model.ListingItemOverflowAction
@ -9,6 +10,9 @@ import com.x8bit.bitwarden.ui.vault.feature.itemlisting.model.ListingItemOverflo
* items.
*/
data class VaultItemListingHandlers(
val switchAccountClick: (AccountSummary) -> Unit,
val lockAccountClick: (AccountSummary) -> Unit,
val logoutAccountClick: (AccountSummary) -> Unit,
val backClick: () -> Unit,
val searchIconClick: () -> Unit,
val addVaultItemClick: () -> Unit,
@ -27,6 +31,15 @@ data class VaultItemListingHandlers(
viewModel: VaultItemListingViewModel,
): VaultItemListingHandlers =
VaultItemListingHandlers(
switchAccountClick = {
viewModel.trySendAction(VaultItemListingsAction.SwitchAccountClick(it))
},
lockAccountClick = {
viewModel.trySendAction(VaultItemListingsAction.LockAccountClick(it))
},
logoutAccountClick = {
viewModel.trySendAction(VaultItemListingsAction.LogoutAccountClick(it))
},
backClick = { viewModel.trySendAction(VaultItemListingsAction.BackClick) },
searchIconClick = {
viewModel.trySendAction(VaultItemListingsAction.SearchIconClick)

View file

@ -2,6 +2,7 @@ package com.x8bit.bitwarden.ui.platform.base.util
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertFalse
import org.junit.jupiter.api.Assertions.assertNull
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test
@ -69,6 +70,42 @@ class StringExtensionsTest {
}
}
@Test
fun `toHostOrPathOrNull should return the correct value for an absolute URL`() {
assertEquals(
"www.abc.com",
"https://www.abc.com".toHostOrPathOrNull(),
)
}
@Test
fun `toHostOrPathOrNull should return the correct value for an absolute non-URL path`() {
assertEquals(
"/abc/com",
"file:///abc/com".toHostOrPathOrNull(),
)
}
@Test
fun `toHostOrPathOrNull should return the correct value for a relative URI`() {
assertEquals(
"abc.com",
"abc.com".toHostOrPathOrNull(),
)
}
@Test
fun `toHostOrPathOrNull should return null when there are invalid characters present`() {
listOf(
"abc com",
"abc<>com",
"abc[]com",
)
.forEach { badUri ->
assertNull(badUri.toHostOrPathOrNull())
}
}
@Test
fun `toHexColorRepresentation should return valid hex color values`() {
mapOf(

View file

@ -103,6 +103,7 @@ class RootNavScreenTest : BaseComposeTest() {
// Make sure navigating to vault unlocked for autofill works as expected:
rootNavStateFlow.value =
RootNavState.VaultUnlockedForAutofillSelection(
activeUserId = "userId",
type = AutofillSelectionData.Type.LOGIN,
)
composeTestRule.runOnIdle {

View file

@ -180,6 +180,7 @@ class RootNavViewModelTest : BaseViewModelTest() {
val viewModel = createViewModel()
assertEquals(
RootNavState.VaultUnlockedForAutofillSelection(
activeUserId = "activeUserId",
type = AutofillSelectionData.Type.LOGIN,
),
viewModel.stateFlow.value,

View file

@ -19,32 +19,49 @@ import androidx.compose.ui.test.performClick
import androidx.compose.ui.test.performScrollToNode
import androidx.core.net.toUri
import com.x8bit.bitwarden.R
import com.x8bit.bitwarden.data.autofill.model.AutofillSelectionData
import com.x8bit.bitwarden.data.platform.repository.model.Environment
import com.x8bit.bitwarden.data.platform.repository.util.baseIconUrl
import com.x8bit.bitwarden.data.platform.repository.util.baseWebSendUrl
import com.x8bit.bitwarden.data.platform.repository.util.bufferedMutableSharedFlow
import com.x8bit.bitwarden.ui.platform.base.BaseComposeTest
import com.x8bit.bitwarden.ui.platform.base.util.asText
import com.x8bit.bitwarden.ui.platform.base.util.toHostOrPathOrNull
import com.x8bit.bitwarden.ui.platform.components.model.AccountSummary
import com.x8bit.bitwarden.ui.platform.components.model.IconData
import com.x8bit.bitwarden.ui.platform.components.model.IconRes
import com.x8bit.bitwarden.ui.platform.feature.search.model.SearchType
import com.x8bit.bitwarden.ui.platform.manager.intent.IntentManager
import com.x8bit.bitwarden.ui.util.assertLockOrLogoutDialogIsDisplayed
import com.x8bit.bitwarden.ui.util.assertLogoutConfirmationDialogIsDisplayed
import com.x8bit.bitwarden.ui.util.assertNoDialogExists
import com.x8bit.bitwarden.ui.util.assertSwitcherIsDisplayed
import com.x8bit.bitwarden.ui.util.assertSwitcherIsNotDisplayed
import com.x8bit.bitwarden.ui.util.isProgressBar
import com.x8bit.bitwarden.ui.util.performAccountClick
import com.x8bit.bitwarden.ui.util.performAccountIconClick
import com.x8bit.bitwarden.ui.util.performAccountLongClick
import com.x8bit.bitwarden.ui.util.performLockAccountClick
import com.x8bit.bitwarden.ui.util.performLogoutAccountClick
import com.x8bit.bitwarden.ui.util.performLogoutAccountConfirmationClick
import com.x8bit.bitwarden.ui.vault.feature.itemlisting.model.ListingItemOverflowAction
import com.x8bit.bitwarden.ui.vault.feature.vault.model.VaultFilterType
import io.mockk.every
import io.mockk.just
import io.mockk.mockk
import io.mockk.mockkStatic
import io.mockk.runs
import io.mockk.unmockkStatic
import io.mockk.verify
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.update
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
@Suppress("LargeClass")
class VaultItemListingScreenTest : BaseComposeTest() {
private var onNavigateBackCalled = false
@ -68,6 +85,8 @@ class VaultItemListingScreenTest : BaseComposeTest() {
@Before
fun setUp() {
mockkStatic(String::toHostOrPathOrNull)
every { AUTOFILL_SELECTION_DATA.uri?.toHostOrPathOrNull() } returns "www.test.com"
composeTestRule.setContent {
VaultItemListingScreen(
viewModel = viewModel,
@ -83,6 +102,172 @@ class VaultItemListingScreenTest : BaseComposeTest() {
}
}
@After
fun tearDown() {
unmockkStatic(String::toHostOrPathOrNull)
}
@Test
fun `the app bar title should update according to state`() {
composeTestRule
.onNodeWithText("Logins")
.assertIsDisplayed()
composeTestRule
.onNodeWithText("Items for www.test.com")
.assertDoesNotExist()
mutableStateFlow.value = STATE_FOR_AUTOFILL
composeTestRule
.onNodeWithText("Logins")
.assertDoesNotExist()
composeTestRule
.onNodeWithText("Items for www.test.com")
.assertIsDisplayed()
}
@Test
fun `back button should update according to state`() {
composeTestRule
.onNodeWithContentDescription("Back")
.assertIsDisplayed()
mutableStateFlow.value = STATE_FOR_AUTOFILL
composeTestRule
.onNodeWithContentDescription("Back")
.assertDoesNotExist()
}
@Test
fun `overflow menu should update according to state`() {
composeTestRule
.onNodeWithContentDescription("More")
.assertIsDisplayed()
mutableStateFlow.value = STATE_FOR_AUTOFILL
composeTestRule
.onNodeWithContentDescription("More")
.assertDoesNotExist()
}
@Test
fun `account icon should update according to state`() {
composeTestRule
.onNodeWithText("AU")
.assertDoesNotExist()
mutableStateFlow.value = STATE_FOR_AUTOFILL
composeTestRule
.onNodeWithText("AU")
.assertIsDisplayed()
}
@Test
fun `account icon click should show the account switcher`() {
mutableStateFlow.value = STATE_FOR_AUTOFILL
composeTestRule.assertSwitcherIsNotDisplayed(
accountSummaries = ACCOUNT_SUMMARIES,
)
composeTestRule.performAccountIconClick()
composeTestRule.assertSwitcherIsDisplayed(
accountSummaries = ACCOUNT_SUMMARIES,
isAddAccountButtonVisible = false,
)
}
@Suppress("MaxLineLength")
@Test
fun `account click in the account switcher should send AccountSwitchClick and close switcher`() {
// Open the Account Switcher
mutableStateFlow.value = STATE_FOR_AUTOFILL
composeTestRule.performAccountIconClick()
composeTestRule.performAccountClick(accountSummary = LOCKED_ACCOUNT_SUMMARY)
verify {
viewModel.trySendAction(
VaultItemListingsAction.SwitchAccountClick(LOCKED_ACCOUNT_SUMMARY),
)
}
composeTestRule.assertSwitcherIsNotDisplayed(
accountSummaries = ACCOUNT_SUMMARIES,
)
}
@Suppress("MaxLineLength")
@Test
fun `account long click in the account switcher should show the lock-or-logout dialog and close the switcher`() {
// Show the account switcher
mutableStateFlow.value = STATE_FOR_AUTOFILL
composeTestRule.performAccountIconClick()
composeTestRule.assertNoDialogExists()
composeTestRule.performAccountLongClick(
accountSummary = ACTIVE_ACCOUNT_SUMMARY,
)
composeTestRule.assertLockOrLogoutDialogIsDisplayed(
accountSummary = ACTIVE_ACCOUNT_SUMMARY,
)
}
@Suppress("MaxLineLength")
@Test
fun `lock button click in the lock-or-logout dialog should send LockAccountClick action and close the dialog`() {
// Show the lock-or-logout dialog
mutableStateFlow.value = STATE_FOR_AUTOFILL
composeTestRule.performAccountIconClick()
composeTestRule.performAccountLongClick(ACTIVE_ACCOUNT_SUMMARY)
composeTestRule.performLockAccountClick()
verify {
viewModel.trySendAction(
VaultItemListingsAction.LockAccountClick(ACTIVE_ACCOUNT_SUMMARY),
)
}
composeTestRule.assertNoDialogExists()
}
@Suppress("MaxLineLength")
@Test
fun `logout button click in the lock-or-logout dialog should show the logout confirmation dialog and hide the lock-or-logout dialog`() {
// Show the lock-or-logout dialog
mutableStateFlow.value = STATE_FOR_AUTOFILL
composeTestRule.performAccountIconClick()
composeTestRule.performAccountLongClick(ACTIVE_ACCOUNT_SUMMARY)
composeTestRule.performLogoutAccountClick()
composeTestRule.assertLogoutConfirmationDialogIsDisplayed(
accountSummary = ACTIVE_ACCOUNT_SUMMARY,
)
}
@Suppress("MaxLineLength")
@Test
fun `logout button click in the logout confirmation dialog should send LogoutAccountClick action and close the dialog`() {
// Show the logout confirmation dialog
mutableStateFlow.value = STATE_FOR_AUTOFILL
composeTestRule.performAccountIconClick()
composeTestRule.performAccountLongClick(ACTIVE_ACCOUNT_SUMMARY)
composeTestRule.performLogoutAccountClick()
composeTestRule.performLogoutAccountConfirmationClick()
verify {
viewModel.trySendAction(
VaultItemListingsAction.LogoutAccountClick(ACTIVE_ACCOUNT_SUMMARY),
)
}
composeTestRule.assertNoDialogExists()
}
@Test
fun `NavigateBack event should invoke NavigateBack`() {
mutableEventFlow.tryEmit(VaultItemListingEvent.NavigateBack)
@ -722,8 +907,43 @@ class VaultItemListingScreenTest : BaseComposeTest() {
}
}
private val ACTIVE_ACCOUNT_SUMMARY = AccountSummary(
userId = "activeUserId",
name = "Active User",
email = "active@bitwarden.com",
avatarColorHex = "#aa00aa",
environmentLabel = "bitwarden.com",
isActive = true,
isLoggedIn = true,
isVaultUnlocked = true,
)
private val LOCKED_ACCOUNT_SUMMARY = AccountSummary(
userId = "lockedUserId",
name = "Locked User",
email = "locked@bitwarden.com",
avatarColorHex = "#00aaaa",
environmentLabel = "bitwarden.com",
isActive = false,
isLoggedIn = true,
isVaultUnlocked = false,
)
private val ACCOUNT_SUMMARIES = listOf(
ACTIVE_ACCOUNT_SUMMARY,
LOCKED_ACCOUNT_SUMMARY,
)
private val AUTOFILL_SELECTION_DATA =
AutofillSelectionData(
type = AutofillSelectionData.Type.LOGIN,
uri = "https:://www.test.com",
)
private val DEFAULT_STATE = VaultItemListingState(
itemListingType = VaultItemListingState.ItemListingType.Vault.Login,
activeAccountSummary = ACTIVE_ACCOUNT_SUMMARY,
accountSummaries = ACCOUNT_SUMMARIES,
viewState = VaultItemListingState.ViewState.Loading,
vaultFilterType = VaultFilterType.AllVaults,
baseWebSendUrl = Environment.Us.environmentUrlData.baseWebSendUrl,
@ -733,6 +953,10 @@ private val DEFAULT_STATE = VaultItemListingState(
dialogState = null,
)
private val STATE_FOR_AUTOFILL = DEFAULT_STATE.copy(
autofillSelectionData = AUTOFILL_SELECTION_DATA,
)
private fun createDisplayItem(number: Int): VaultItemListingState.DisplayItem =
VaultItemListingState.DisplayItem(
id = "mockId-$number",

View file

@ -4,6 +4,9 @@ import android.net.Uri
import androidx.lifecycle.SavedStateHandle
import app.cash.turbine.test
import com.x8bit.bitwarden.R
import com.x8bit.bitwarden.data.auth.repository.AuthRepository
import com.x8bit.bitwarden.data.auth.repository.model.SwitchAccountResult
import com.x8bit.bitwarden.data.auth.repository.model.UserState
import com.x8bit.bitwarden.data.autofill.manager.AutofillSelectionManager
import com.x8bit.bitwarden.data.autofill.manager.AutofillSelectionManagerImpl
import com.x8bit.bitwarden.data.platform.manager.SpecialCircumstanceManagerImpl
@ -26,10 +29,13 @@ import com.x8bit.bitwarden.data.vault.repository.model.VaultData
import com.x8bit.bitwarden.ui.platform.base.BaseViewModelTest
import com.x8bit.bitwarden.ui.platform.base.util.asText
import com.x8bit.bitwarden.ui.platform.base.util.concat
import com.x8bit.bitwarden.ui.platform.components.model.AccountSummary
import com.x8bit.bitwarden.ui.platform.feature.search.model.SearchType
import com.x8bit.bitwarden.ui.vault.feature.itemlisting.model.ListingItemOverflowAction
import com.x8bit.bitwarden.ui.vault.feature.itemlisting.util.createMockDisplayItemForCipher
import com.x8bit.bitwarden.ui.vault.feature.vault.model.VaultFilterType
import com.x8bit.bitwarden.ui.vault.feature.vault.util.toAccountSummaries
import com.x8bit.bitwarden.ui.vault.feature.vault.util.toActiveAccountSummary
import com.x8bit.bitwarden.ui.vault.model.VaultItemListingType
import io.mockk.coEvery
import io.mockk.every
@ -63,11 +69,20 @@ class VaultItemListingViewModelTest : BaseViewModelTest() {
every { setText(any<String>()) } just runs
}
private val mutableUserStateFlow = MutableStateFlow<UserState?>(DEFAULT_USER_STATE)
private val authRepository = mockk<AuthRepository> {
every { activeUserId } answers { mutableUserStateFlow.value?.activeUserId }
every { userStateFlow } returns mutableUserStateFlow
every { logout() } just runs
every { logout(any()) } just runs
every { switchAccount(any()) } returns SwitchAccountResult.AccountSwitched
}
private val mutableVaultDataStateFlow =
MutableStateFlow<DataState<VaultData>>(DataState.Loading)
private val vaultRepository: VaultRepository = mockk {
every { vaultFilterType } returns VaultFilterType.AllVaults
every { vaultDataStateFlow } returns mutableVaultDataStateFlow
every { lockVault(any()) } just runs
every { sync() } just runs
}
private val environmentRepository: EnvironmentRepository = mockk {
@ -98,6 +113,46 @@ class VaultItemListingViewModelTest : BaseViewModelTest() {
}
}
@Test
fun `on LockAccountClick should call lockVault for the given account`() {
val accountUserId = "userId"
val accountSummary = mockk<AccountSummary> {
every { userId } returns accountUserId
}
val viewModel = createVaultItemListingViewModel()
viewModel.trySendAction(VaultItemListingsAction.LockAccountClick(accountSummary))
verify { vaultRepository.lockVault(userId = accountUserId) }
}
@Test
fun `on LogoutAccountClick should call logout for the given account`() {
val accountUserId = "userId"
val accountSummary = mockk<AccountSummary> {
every { userId } returns accountUserId
}
val viewModel = createVaultItemListingViewModel()
viewModel.trySendAction(VaultItemListingsAction.LogoutAccountClick(accountSummary))
verify { authRepository.logout(userId = accountUserId) }
}
@Test
fun `on SwitchAccountClick should switch to the given account`() = runTest {
val viewModel = createVaultItemListingViewModel()
val updatedUserId = "updatedUserId"
viewModel.trySendAction(
VaultItemListingsAction.SwitchAccountClick(
accountSummary = mockk {
every { userId } returns updatedUserId
},
),
)
verify { authRepository.switchAccount(userId = updatedUserId) }
}
@Test
fun `BackClick should emit NavigateBack`() = runTest {
val viewModel = createVaultItemListingViewModel()
@ -888,6 +943,33 @@ class VaultItemListingViewModelTest : BaseViewModelTest() {
)
}
@Test
fun `vaultDataStateFlow updates should do nothing when switching accounts`() {
val viewModel = createVaultItemListingViewModel()
assertEquals(
initialState,
viewModel.stateFlow.value,
)
// Log out the accounts
mutableUserStateFlow.value = null
// Emit fresh data
mutableVaultDataStateFlow.value = DataState.Loaded(
data = VaultData(
cipherViewList = listOf(createMockCipherView(number = 1)),
folderViewList = listOf(createMockFolderView(number = 1)),
collectionViewList = listOf(createMockCollectionView(number = 1)),
sendViewList = listOf(createMockSendView(number = 1)),
),
)
assertEquals(
initialState,
viewModel.stateFlow.value,
)
}
@Test
fun `icon loading state updates should update isIconLoadingDisabled`() = runTest {
val viewModel = createVaultItemListingViewModel()
@ -974,6 +1056,7 @@ class VaultItemListingViewModelTest : BaseViewModelTest() {
savedStateHandle = savedStateHandle,
clock = clock,
clipboardManager = clipboardManager,
authRepository = authRepository,
vaultRepository = vaultRepository,
environmentRepository = environmentRepository,
settingsRepository = settingsRepository,
@ -988,6 +1071,8 @@ class VaultItemListingViewModelTest : BaseViewModelTest() {
): VaultItemListingState =
VaultItemListingState(
itemListingType = itemListingType,
activeAccountSummary = DEFAULT_USER_STATE.toActiveAccountSummary(),
accountSummaries = DEFAULT_USER_STATE.toAccountSummaries(),
viewState = viewState,
vaultFilterType = vaultRepository.vaultFilterType,
baseWebSendUrl = Environment.Us.environmentUrlData.baseWebSendUrl,
@ -999,3 +1084,21 @@ class VaultItemListingViewModelTest : BaseViewModelTest() {
shouldFinishOnComplete = false,
)
}
private val DEFAULT_ACCOUNT = UserState.Account(
userId = "activeUserId",
name = "Active User",
email = "active@bitwarden.com",
environment = Environment.Us,
avatarColorHex = "#aa00aa",
isPremium = true,
isLoggedIn = true,
isVaultUnlocked = true,
isBiometricsEnabled = false,
organizations = emptyList(),
)
private val DEFAULT_USER_STATE = UserState(
activeUserId = "activeUserId",
accounts = listOf(DEFAULT_ACCOUNT),
)