mirror of
https://github.com/bitwarden/android.git
synced 2024-11-26 19:36:18 +03:00
Update Vault Item Listing screen app bar for autofill (#834)
This commit is contained in:
parent
0a6b0f8dc7
commit
5b854c17b7
12 changed files with 537 additions and 22 deletions
|
@ -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 {
|
||||
|
|
|
@ -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:
|
||||
*
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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()
|
||||
|
||||
|
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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.
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -180,6 +180,7 @@ class RootNavViewModelTest : BaseViewModelTest() {
|
|||
val viewModel = createViewModel()
|
||||
assertEquals(
|
||||
RootNavState.VaultUnlockedForAutofillSelection(
|
||||
activeUserId = "activeUserId",
|
||||
type = AutofillSelectionData.Type.LOGIN,
|
||||
),
|
||||
viewModel.stateFlow.value,
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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),
|
||||
)
|
||||
|
|
Loading…
Reference in a new issue