diff --git a/app/src/main/java/com/x8bit/bitwarden/data/autofill/model/AutofillSelectionData.kt b/app/src/main/java/com/x8bit/bitwarden/data/autofill/model/AutofillSelectionData.kt index d639c5669..ac31788d1 100644 --- a/app/src/main/java/com/x8bit/bitwarden/data/autofill/model/AutofillSelectionData.kt +++ b/app/src/main/java/com/x8bit/bitwarden/data/autofill/model/AutofillSelectionData.kt @@ -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 { diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/platform/base/util/StringExtensions.kt b/app/src/main/java/com/x8bit/bitwarden/ui/platform/base/util/StringExtensions.kt index e7a067562..e5cbb4356 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/platform/base/util/StringExtensions.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/platform/base/util/StringExtensions.kt @@ -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: * diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/platform/components/BitwardenTopAppBar.kt b/app/src/main/java/com/x8bit/bitwarden/ui/platform/components/BitwardenTopAppBar.kt index a363f47a6..107f0d7c3 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/platform/components/BitwardenTopAppBar.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/platform/components/BitwardenTopAppBar.kt @@ -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, diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/rootnav/RootNavViewModel.kt b/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/rootnav/RootNavViewModel.kt index a2547c53f..34cf1acf9 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/rootnav/RootNavViewModel.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/rootnav/RootNavViewModel.kt @@ -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() diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/itemlisting/VaultItemListingScreen.kt b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/itemlisting/VaultItemListingScreen.kt index f7c7e2329..3471b891c 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/itemlisting/VaultItemListingScreen.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/itemlisting/VaultItemListingScreen.kt @@ -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, + ) } } diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/itemlisting/VaultItemListingViewModel.kt b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/itemlisting/VaultItemListingViewModel.kt index f0dbaeae7..2f648ff0a 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/itemlisting/VaultItemListingViewModel.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/itemlisting/VaultItemListingViewModel.kt @@ -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( 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, 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. diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/itemlisting/handlers/VaultItemListingHandlers.kt b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/itemlisting/handlers/VaultItemListingHandlers.kt index e75667315..95728d175 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/itemlisting/handlers/VaultItemListingHandlers.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/itemlisting/handlers/VaultItemListingHandlers.kt @@ -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) diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/platform/base/util/StringExtensionsTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/platform/base/util/StringExtensionsTest.kt index 3c45c6d44..3cf2cdd28 100644 --- a/app/src/test/java/com/x8bit/bitwarden/ui/platform/base/util/StringExtensionsTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/ui/platform/base/util/StringExtensionsTest.kt @@ -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( diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/rootnav/RootNavScreenTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/rootnav/RootNavScreenTest.kt index f779a080d..0e49f87db 100644 --- a/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/rootnav/RootNavScreenTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/rootnav/RootNavScreenTest.kt @@ -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 { diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/rootnav/RootNavViewModelTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/rootnav/RootNavViewModelTest.kt index c3ccbd43c..c33ac4f42 100644 --- a/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/rootnav/RootNavViewModelTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/rootnav/RootNavViewModelTest.kt @@ -180,6 +180,7 @@ class RootNavViewModelTest : BaseViewModelTest() { val viewModel = createViewModel() assertEquals( RootNavState.VaultUnlockedForAutofillSelection( + activeUserId = "activeUserId", type = AutofillSelectionData.Type.LOGIN, ), viewModel.stateFlow.value, diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/itemlisting/VaultItemListingScreenTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/itemlisting/VaultItemListingScreenTest.kt index c9993ddfc..cba162c51 100644 --- a/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/itemlisting/VaultItemListingScreenTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/itemlisting/VaultItemListingScreenTest.kt @@ -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", diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/itemlisting/VaultItemListingViewModelTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/itemlisting/VaultItemListingViewModelTest.kt index 38f567e2f..838e1af8f 100644 --- a/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/itemlisting/VaultItemListingViewModelTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/itemlisting/VaultItemListingViewModelTest.kt @@ -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()) } just runs } + private val mutableUserStateFlow = MutableStateFlow(DEFAULT_USER_STATE) + private val authRepository = mockk { + 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.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 { + 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 { + 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), +)