[PM-26095] Add account selection screen for Credential Exchange (#5932)

This commit is contained in:
Patrick Honkonen
2025-09-24 19:53:40 +00:00
committed by GitHub
parent 4cd5a1ed56
commit 3edd5bd852
12 changed files with 992 additions and 2 deletions
@@ -15,6 +15,9 @@ import com.bitwarden.core.data.manager.BuildInfoManager
import com.bitwarden.core.util.isBuildVersionAtLeast
import com.bitwarden.cxf.importer.CredentialExchangeImporter
import com.bitwarden.cxf.importer.dsl.credentialExchangeImporter
import com.bitwarden.cxf.manager.CredentialExchangeCompletionManager
import com.bitwarden.cxf.manager.dsl.credentialExchangeCompletionManager
import com.bitwarden.cxf.ui.composition.LocalCredentialExchangeCompletionManager
import com.bitwarden.cxf.ui.composition.LocalCredentialExchangeImporter
import com.bitwarden.ui.platform.composition.LocalIntentManager
import com.bitwarden.ui.platform.manager.IntentManager
@@ -64,6 +67,8 @@ fun LocalManagerProvider(
permissionsManager: PermissionsManager = PermissionsManagerImpl(activity = activity),
credentialExchangeImporter: CredentialExchangeImporter =
credentialExchangeImporter(activity = activity),
credentialExchangeCompletionManager: CredentialExchangeCompletionManager =
credentialExchangeCompletionManager(activity = activity),
content: @Composable () -> Unit,
) {
CompositionLocalProvider(
@@ -79,6 +84,7 @@ fun LocalManagerProvider(
LocalNfcManager provides nfcManager,
LocalPermissionsManager provides permissionsManager,
LocalCredentialExchangeImporter provides credentialExchangeImporter,
LocalCredentialExchangeCompletionManager provides credentialExchangeCompletionManager,
content = content,
)
}
@@ -0,0 +1,119 @@
package com.x8bit.bitwarden.ui.vault.feature.exportitems.component
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.defaultMinSize
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.material3.Icon
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.Font
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import com.bitwarden.ui.R
import com.bitwarden.ui.platform.base.util.cardStyle
import com.bitwarden.ui.platform.base.util.hexToColor
import com.bitwarden.ui.platform.base.util.toSafeOverlayColor
import com.bitwarden.ui.platform.base.util.toUnscaledTextUnit
import com.bitwarden.ui.platform.components.model.CardStyle
import com.bitwarden.ui.platform.components.util.rememberVectorPainter
import com.bitwarden.ui.platform.resource.BitwardenDrawable
import com.bitwarden.ui.platform.resource.BitwardenString
import com.bitwarden.ui.platform.theme.BitwardenTheme
import com.x8bit.bitwarden.ui.vault.feature.exportitems.model.AccountSelectionListItem
/**
* A composable that displays an account summary list item.
*
* @param item The account selection list item to display.
* @param cardStyle The card style to apply to the list item.
* @param modifier The modifier to apply to the list item.
* @param clickable Whether the list item should be clickable.
*/
@Suppress("LongMethod")
@Composable
fun AccountSummaryListItem(
item: AccountSelectionListItem,
cardStyle: CardStyle,
modifier: Modifier = Modifier,
clickable: Boolean,
onClick: (userId: String) -> Unit = {},
) {
Row(
modifier = modifier
.testTag("AccountSummaryListItem")
.defaultMinSize(minHeight = 60.dp)
.clickable(
onClick = { onClick(item.userId) },
enabled = clickable,
)
.cardStyle(
cardStyle = cardStyle,
paddingStart = 16.dp,
paddingEnd = 4.dp,
),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(16.dp),
) {
Box(
contentAlignment = Alignment.Center,
modifier = Modifier.size(24.dp),
) {
Icon(
painter = rememberVectorPainter(
id = BitwardenDrawable.ic_account_initials_container,
),
contentDescription = null,
tint = item.avatarColorHex.hexToColor(),
modifier = Modifier.size(24.dp),
)
Text(
text = item.initials,
style = TextStyle(
fontSize = 11.dp.toUnscaledTextUnit(),
lineHeight = 13.dp.toUnscaledTextUnit(),
fontFamily = FontFamily(Font(R.font.dm_sans_bold)),
fontWeight = FontWeight.W600,
),
color = item.avatarColorHex.hexToColor().toSafeOverlayColor(),
)
}
Column(
modifier = Modifier.weight(1f),
) {
Text(
text = item.email,
style = BitwardenTheme.typography.bodyLarge,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.testTag("AccountEmailLabel"),
)
if (item.isItemRestricted) {
Text(
text = stringResource(
BitwardenString.import_restricted_unable_to_import_credit_cards,
),
style = BitwardenTheme.typography.bodyMedium,
color = BitwardenTheme.colorScheme.text.secondary,
modifier = Modifier.testTag("AccountRestrictedLabel"),
)
}
}
Spacer(modifier = Modifier.width(8.dp))
}
}
@@ -0,0 +1,46 @@
package com.x8bit.bitwarden.ui.vault.feature.exportitems.component
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.TopAppBarScrollBehavior
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.painter.Painter
import androidx.compose.ui.res.stringResource
import com.bitwarden.ui.platform.components.appbar.BitwardenTopAppBar
import com.bitwarden.ui.platform.components.scaffold.BitwardenScaffold
import com.bitwarden.ui.platform.resource.BitwardenString
/**
* A reusable scaffold for the export items screen.
*
* @param navIcon The navigation icon to be displayed in the top app bar.
* @param onNavigationIconClick The action to be performed when the navigation icon is clicked.
* @param navigationIconContentDescription The content description for the navigation icon.
* @param scrollBehavior The scroll behavior to be used for the top app bar.
* @param modifier The modifier to be applied to the scaffold.
* @param content The content to be displayed inside the scaffold.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ExportItemsScaffold(
navIcon: Painter,
onNavigationIconClick: () -> Unit,
navigationIconContentDescription: String,
scrollBehavior: TopAppBarScrollBehavior,
modifier: Modifier = Modifier,
content: @Composable () -> Unit = {},
) {
BitwardenScaffold(
topBar = {
BitwardenTopAppBar(
title = stringResource(BitwardenString.import_from_bitwarden),
onNavigationIconClick = onNavigationIconClick,
navigationIconContentDescription = navigationIconContentDescription,
navigationIcon = navIcon,
scrollBehavior = scrollBehavior,
)
},
modifier = modifier,
content = content,
)
}
@@ -0,0 +1,18 @@
package com.x8bit.bitwarden.ui.vault.feature.exportitems.model
import android.os.Parcelable
import kotlinx.parcelize.Parcelize
import kotlinx.serialization.Serializable
/**
* Represents a list item for selecting an account to export items from.
*/
@Serializable
@Parcelize
data class AccountSelectionListItem(
val userId: String,
val isItemRestricted: Boolean,
val avatarColorHex: String,
val initials: String,
val email: String,
) : Parcelable
@@ -1,7 +1,42 @@
package com.x8bit.bitwarden.ui.vault.feature.exportitems.selectaccount
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.material3.rememberTopAppBarState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.credentials.providerevents.exception.ImportCredentialsUnknownErrorException
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.bitwarden.cxf.manager.CredentialExchangeCompletionManager
import com.bitwarden.cxf.manager.model.ExportCredentialsResult
import com.bitwarden.cxf.ui.composition.LocalCredentialExchangeCompletionManager
import com.bitwarden.ui.platform.base.util.EventsEffect
import com.bitwarden.ui.platform.base.util.standardHorizontalMargin
import com.bitwarden.ui.platform.base.util.toListItemCardStyle
import com.bitwarden.ui.platform.components.scaffold.BitwardenScaffold
import com.bitwarden.ui.platform.components.util.rememberVectorPainter
import com.bitwarden.ui.platform.resource.BitwardenDrawable
import com.bitwarden.ui.platform.resource.BitwardenString
import com.bitwarden.ui.platform.theme.BitwardenTheme
import com.x8bit.bitwarden.ui.vault.feature.exportitems.component.AccountSummaryListItem
import com.x8bit.bitwarden.ui.vault.feature.exportitems.component.ExportItemsScaffold
import com.x8bit.bitwarden.ui.vault.feature.exportitems.model.AccountSelectionListItem
import com.x8bit.bitwarden.ui.vault.feature.exportitems.selectaccount.handlers.rememberSelectAccountHandlers
import kotlinx.collections.immutable.persistentListOf
/**
* Top level screen for selecting an account to export items from.
@@ -9,7 +44,124 @@ import androidx.compose.runtime.Composable
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun SelectAccountScreen(
onAccountSelected: (id: String) -> Unit,
onAccountSelected: (userId: String) -> Unit,
viewModel: SelectAccountViewModel = hiltViewModel(),
credentialExchangeCompletionManager: CredentialExchangeCompletionManager =
LocalCredentialExchangeCompletionManager.current,
) {
// TODO: [PM-26095] Implement select account screen.
val state by viewModel.stateFlow.collectAsStateWithLifecycle()
val handlers = rememberSelectAccountHandlers(viewModel)
EventsEffect(viewModel) { event ->
when (event) {
SelectAccountEvent.CancelExport -> {
credentialExchangeCompletionManager
.completeCredentialExport(
exportResult = ExportCredentialsResult.Failure(
// TODO: [PM-26094] Use ImportCredentialsCancellationException once
// public.
error = ImportCredentialsUnknownErrorException(
errorMessage = "User cancelled import.",
),
),
)
}
is SelectAccountEvent.NavigateToPasswordVerification -> {
onAccountSelected(event.userId)
}
}
}
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior(rememberTopAppBarState())
ExportItemsScaffold(
navIcon = rememberVectorPainter(BitwardenDrawable.ic_close),
navigationIconContentDescription = stringResource(BitwardenString.close),
onNavigationIconClick = handlers.onCloseClick,
scrollBehavior = scrollBehavior,
modifier = Modifier
.fillMaxSize()
.nestedScroll(scrollBehavior.nestedScrollConnection),
) {
SelectAccountContent(
state = state,
onAccountClick = handlers.onAccountClick,
modifier = Modifier
.fillMaxSize()
.standardHorizontalMargin(),
)
}
}
@Composable
private fun SelectAccountContent(
state: SelectAccountState,
onAccountClick: (userId: String) -> Unit,
modifier: Modifier = Modifier,
) {
LazyColumn(modifier = modifier) {
item { Spacer(Modifier.height(24.dp)) }
item {
Text(
text = stringResource(BitwardenString.select_account),
textAlign = TextAlign.Center,
style = BitwardenTheme.typography.titleMedium,
modifier = Modifier.fillMaxWidth(),
)
}
item { Spacer(Modifier.height(16.dp)) }
itemsIndexed(
items = state.accountSelectionListItems,
key = { _, item -> "AccountSummaryItem_${item.userId}" },
) { index, item ->
AccountSummaryListItem(
item = item,
cardStyle = state.accountSelectionListItems.toListItemCardStyle(index),
clickable = true,
onClick = onAccountClick,
modifier = Modifier
.fillMaxWidth()
.animateItem(),
)
}
item { Spacer(Modifier.height(16.dp)) }
}
}
@Preview(showBackground = true)
@Composable
private fun SelectAccountContentPreview() {
val state = SelectAccountState(
accountSelectionListItems = persistentListOf(
AccountSelectionListItem(
userId = "1",
email = "john.doe@example.com",
initials = "JD",
avatarColorHex = "#FFFF0000",
isItemRestricted = false,
),
AccountSelectionListItem(
userId = "2",
email = "jane.smith@example.com",
initials = "JS",
avatarColorHex = "#FF00FF00",
isItemRestricted = true,
),
AccountSelectionListItem(
userId = "3",
email = "another.user@example.com",
initials = "AU",
avatarColorHex = "#FF0000FF",
isItemRestricted = false,
),
),
)
BitwardenScaffold {
SelectAccountContent(
state = state,
onAccountClick = { },
)
}
}
@@ -0,0 +1,185 @@
package com.x8bit.bitwarden.ui.vault.feature.exportitems.selectaccount
import androidx.lifecycle.viewModelScope
import com.bitwarden.network.model.PolicyTypeJson
import com.bitwarden.network.model.SyncResponseJson
import com.bitwarden.ui.platform.base.BaseViewModel
import com.x8bit.bitwarden.data.auth.repository.AuthRepository
import com.x8bit.bitwarden.data.auth.repository.model.UserState
import com.x8bit.bitwarden.data.platform.manager.PolicyManager
import com.x8bit.bitwarden.ui.vault.feature.exportitems.model.AccountSelectionListItem
import com.x8bit.bitwarden.ui.vault.feature.vault.util.initials
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.update
import javax.inject.Inject
/**
* Manages application state for the select account screen.
*/
@HiltViewModel
class SelectAccountViewModel @Inject constructor(
authRepository: AuthRepository,
policyManager: PolicyManager,
) : BaseViewModel<SelectAccountState, SelectAccountEvent, SelectAccountAction>(
initialState = SelectAccountState(
accountSelectionListItems = persistentListOf(),
),
) {
init {
combine(
authRepository.userStateFlow,
policyManager.getActivePoliciesFlow(PolicyTypeJson.RESTRICT_ITEM_TYPES),
policyManager.getActivePoliciesFlow(PolicyTypeJson.PERSONAL_OWNERSHIP),
) { userState, itemRestrictedOrgs, personalOwnershipOrgs ->
SelectAccountAction.Internal.SelectionDataReceive(
userState,
itemRestrictedOrgs,
personalOwnershipOrgs,
)
}
.onEach(::handleAction)
.launchIn(viewModelScope)
}
override fun handleAction(action: SelectAccountAction) {
when (action) {
SelectAccountAction.CloseClick -> {
handleCloseClick()
}
is SelectAccountAction.AccountClick -> {
handleAccountClick(action)
}
is SelectAccountAction.Internal -> {
handleInternalAction(action)
}
}
}
private fun handleCloseClick() {
sendEvent(SelectAccountEvent.CancelExport)
}
private fun handleAccountClick(action: SelectAccountAction.AccountClick) {
sendEvent(
SelectAccountEvent.NavigateToPasswordVerification(
action.userId,
),
)
}
private fun handleInternalAction(action: SelectAccountAction.Internal) {
when (action) {
is SelectAccountAction.Internal.SelectionDataReceive -> {
handleSelectionDataReceive(action)
}
}
}
private fun handleSelectionDataReceive(
action: SelectAccountAction.Internal.SelectionDataReceive,
) {
val itemRestrictedOrgIds = action.itemRestrictedOrgs
.filter { it.isEnabled }
.map { it.organizationId }
val personalOwnershipRestrictedOrgIds = action
.personalOwnershipOrgs
.filter { it.isEnabled }
.map { it.organizationId }
mutableStateFlow.update {
it.copy(
accountSelectionListItems = action.userState
?.accounts
.orEmpty()
// We only want accounts that do not restrict personal vault ownership
.filter { account ->
account
.organizations
.none { org -> org.id in personalOwnershipRestrictedOrgIds }
}
.map { account ->
AccountSelectionListItem(
userId = account.userId,
email = account.email,
initials = account.initials,
avatarColorHex = account.avatarColorHex,
// Indicate which accounts have item restrictions applied.
isItemRestricted = account
.organizations
.any { org -> org.id in itemRestrictedOrgIds },
)
}
.toImmutableList(),
)
}
}
}
/**
* Represents the state for the select account screen.
*
* @param accountSelectionListItems The list of account summaries to be displayed for selection.
*/
data class SelectAccountState(
val accountSelectionListItems: ImmutableList<AccountSelectionListItem>,
)
/**
* Represents the actions that can be performed on the select account screen.
*/
sealed class SelectAccountAction {
/**
* Indicates the top-bar close button was clicked.
*/
data object CloseClick : SelectAccountAction()
/**
* Indicates the user selected an account summary.
*/
data class AccountClick(
val userId: String,
) : SelectAccountAction()
/**
* Represents internal actions for the select account screen.
*/
sealed class Internal : SelectAccountAction() {
/**
* Indicates the selection data was received.
*/
data class SelectionDataReceive(
val userState: UserState?,
val itemRestrictedOrgs: List<SyncResponseJson.Policy>,
val personalOwnershipOrgs: List<SyncResponseJson.Policy>,
) : Internal()
}
}
/**
* Models events for the select account screen.
*/
sealed class SelectAccountEvent {
/**
* Navigates back to the previous screen.
*/
data object CancelExport : SelectAccountEvent()
/**
* Navigate to the password verification screen.
*/
data class NavigateToPasswordVerification(
val userId: String,
) : SelectAccountEvent()
}
@@ -0,0 +1,39 @@
package com.x8bit.bitwarden.ui.vault.feature.exportitems.selectaccount.handlers
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import com.x8bit.bitwarden.ui.vault.feature.exportitems.selectaccount.SelectAccountAction
import com.x8bit.bitwarden.ui.vault.feature.exportitems.selectaccount.SelectAccountViewModel
/**
* Responsible for handling user interactions for the select account screen.
*/
data class SelectAccountHandlers(
val onCloseClick: () -> Unit,
val onAccountClick: (userId: String) -> Unit,
) {
@Suppress("UndocumentedPublicClass")
companion object {
/**
* Creates an instance of [SelectAccountHandlers] by binding actions to the provided
* [SelectAccountViewModel].
*/
fun create(viewModel: SelectAccountViewModel): SelectAccountHandlers =
SelectAccountHandlers(
onCloseClick = {
viewModel.trySendAction(SelectAccountAction.CloseClick)
},
onAccountClick = {
viewModel.trySendAction(SelectAccountAction.AccountClick(userId = it))
},
)
}
}
/**
* Helper function to remember a [SelectAccountHandlers] instance in a [Composable] scope.
*/
@Composable
fun rememberSelectAccountHandlers(viewModel: SelectAccountViewModel): SelectAccountHandlers =
remember(viewModel) { SelectAccountHandlers.create(viewModel) }
@@ -4,6 +4,7 @@ import com.bitwarden.ui.platform.components.account.model.AccountSummary
import com.x8bit.bitwarden.data.auth.repository.model.UserState
import com.x8bit.bitwarden.ui.vault.feature.vault.model.VaultFilterData
import com.x8bit.bitwarden.ui.vault.feature.vault.model.VaultFilterType
import java.util.Locale
/**
* Converts the given [UserState] to a list of [AccountSummary].
@@ -71,3 +72,26 @@ fun UserState.Account.toVaultFilterData(
),
)
}
/**
* Given the [UserState.Account], returns the first two "initials" found when looking at the
* [UserState.Account.name].
*
* Ex:
* - "First Last" -> "FL"
* - "First Second Last" -> "FS"
* - "First" -> "FI"
* - name is `null`, email is "test@bitwarden.com" -> "TE"
*/
val UserState.Account.initials: String
get() {
val names = this.name.orEmpty().split(" ").filter { it.isNotBlank() }
return if (names.size >= 2) {
names
.take(2)
.joinToString(separator = "") { it.first().toString() }
} else {
(this.name ?: this.email).take(2)
}
.uppercase(Locale.getDefault())
}
@@ -2,6 +2,7 @@ package com.x8bit.bitwarden.ui.platform.base
import androidx.compose.runtime.Composable
import com.bitwarden.cxf.importer.CredentialExchangeImporter
import com.bitwarden.cxf.manager.CredentialExchangeCompletionManager
import com.bitwarden.ui.platform.base.BaseComposeTest
import com.bitwarden.ui.platform.feature.settings.appearance.model.AppTheme
import com.bitwarden.ui.platform.manager.IntentManager
@@ -42,6 +43,7 @@ abstract class BitwardenComposeTest : BaseComposeTest() {
nfcManager: NfcManager = mockk(),
permissionsManager: PermissionsManager = mockk(),
credentialExchangeImporter: CredentialExchangeImporter = mockk(),
credentialExchangeCompletionManager: CredentialExchangeCompletionManager = mockk(),
test: @Composable () -> Unit,
) {
setTestContent {
@@ -58,6 +60,7 @@ abstract class BitwardenComposeTest : BaseComposeTest() {
nfcManager = nfcManager,
permissionsManager = permissionsManager,
credentialExchangeImporter = credentialExchangeImporter,
credentialExchangeCompletionManager = credentialExchangeCompletionManager,
) {
BitwardenTheme(
theme = theme,
@@ -0,0 +1,167 @@
package com.x8bit.bitwarden.ui.vault.feature.exportitems
import androidx.compose.ui.test.isDisplayed
import androidx.compose.ui.test.onNodeWithContentDescription
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import com.bitwarden.core.data.repository.util.bufferedMutableSharedFlow
import com.bitwarden.cxf.manager.CredentialExchangeCompletionManager
import com.bitwarden.cxf.manager.model.ExportCredentialsResult
import com.bitwarden.ui.platform.components.account.model.AccountSummary
import com.x8bit.bitwarden.ui.platform.base.BitwardenComposeTest
import com.x8bit.bitwarden.ui.vault.feature.exportitems.model.AccountSelectionListItem
import com.x8bit.bitwarden.ui.vault.feature.exportitems.selectaccount.SelectAccountAction
import com.x8bit.bitwarden.ui.vault.feature.exportitems.selectaccount.SelectAccountEvent
import com.x8bit.bitwarden.ui.vault.feature.exportitems.selectaccount.SelectAccountScreen
import com.x8bit.bitwarden.ui.vault.feature.exportitems.selectaccount.SelectAccountState
import com.x8bit.bitwarden.ui.vault.feature.exportitems.selectaccount.SelectAccountViewModel
import io.mockk.every
import io.mockk.just
import io.mockk.mockk
import io.mockk.runs
import io.mockk.slot
import io.mockk.verify
import kotlinx.collections.immutable.persistentListOf
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.test.runTest
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
class SelectAccountScreenTest : BitwardenComposeTest() {
private var onAccountSelectedCalled: Boolean = false
private val mockkStateFlow = MutableStateFlow(DEFAULT_STATE)
private val mockEventFlow = bufferedMutableSharedFlow<SelectAccountEvent>()
private val credentialExchangeCompletionManager = mockk<CredentialExchangeCompletionManager>()
private val viewModel = mockk<SelectAccountViewModel> {
every { eventFlow } returns mockEventFlow
every { stateFlow } returns mockkStateFlow
every { trySendAction(any()) } just runs
}
@Before
fun setUp() {
setContent(
credentialExchangeCompletionManager = credentialExchangeCompletionManager,
) {
SelectAccountScreen(
onAccountSelected = { onAccountSelectedCalled = true },
viewModel = viewModel,
)
}
}
@Test
fun `initial state should be correct`() = runTest {
composeTestRule
.onNodeWithText("Select account")
.isDisplayed()
composeTestRule
.onNodeWithText(ACTIVE_ACCOUNT_SUMMARY.email)
.isDisplayed()
}
@Test
fun `close click should send CloseClick action`() = runTest {
composeTestRule
.onNodeWithContentDescription("Close")
.performClick()
verify {
viewModel.trySendAction(SelectAccountAction.CloseClick)
}
}
@Test
fun `CancelExport event should complete credential exchange with cancellation error`() =
runTest {
val exportResultSlot = slot<ExportCredentialsResult>()
every {
credentialExchangeCompletionManager.completeCredentialExport(
exportResult = capture(exportResultSlot),
)
} just runs
mockEventFlow.emit(SelectAccountEvent.CancelExport)
verify {
credentialExchangeCompletionManager.completeCredentialExport(
exportResult = exportResultSlot.captured,
)
}
assertTrue(exportResultSlot.captured is ExportCredentialsResult.Failure)
// TODO: [PM-26094] Uncomment check to verify error is
// ImportCredentialsCancellationException when it is public.
// val result = exportResultSlot.captured as ExportCredentialsResult.Failure
// assertTrue(result.error is ImportCredentialsCancellationException)
}
@Test
fun `account list item click should send onAccountClick action`() = runTest {
composeTestRule
.onNodeWithText(ACTIVE_ACCOUNT_SUMMARY.email)
.performClick()
verify {
viewModel.trySendAction(
SelectAccountAction.AccountClick(
userId = ACTIVE_ACCOUNT_SUMMARY.userId,
),
)
}
}
@Test
fun `NavigateToPasswordVerification event should navigate to password verification`() =
runTest {
mockEventFlow.emit(
SelectAccountEvent.NavigateToPasswordVerification(
userId = ACTIVE_ACCOUNT_SUMMARY.userId,
),
)
assertTrue(onAccountSelectedCalled)
}
}
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 DEFAULT_STATE = SelectAccountState(
accountSelectionListItems = persistentListOf(
AccountSelectionListItem(
userId = ACTIVE_ACCOUNT_SUMMARY.userId,
email = ACTIVE_ACCOUNT_SUMMARY.email,
initials = "AA",
avatarColorHex = "#FFFF0000",
isItemRestricted = false,
),
AccountSelectionListItem(
userId = LOCKED_ACCOUNT_SUMMARY.userId,
email = LOCKED_ACCOUNT_SUMMARY.email,
initials = "LU",
avatarColorHex = "#FF00FF00",
isItemRestricted = false,
),
),
)
@@ -0,0 +1,228 @@
package com.x8bit.bitwarden.ui.vault.feature.exportitems
import app.cash.turbine.test
import com.bitwarden.core.data.repository.util.bufferedMutableSharedFlow
import com.bitwarden.data.repository.model.Environment
import com.bitwarden.network.model.OrganizationType
import com.bitwarden.network.model.PolicyTypeJson
import com.bitwarden.network.model.SyncResponseJson
import com.bitwarden.network.model.createMockPolicy
import com.bitwarden.ui.platform.base.BaseViewModelTest
import com.x8bit.bitwarden.data.auth.datasource.disk.model.OnboardingStatus
import com.x8bit.bitwarden.data.auth.repository.AuthRepository
import com.x8bit.bitwarden.data.auth.repository.model.Organization
import com.x8bit.bitwarden.data.auth.repository.model.UserState
import com.x8bit.bitwarden.data.platform.manager.PolicyManager
import com.x8bit.bitwarden.data.platform.manager.model.FirstTimeState
import com.x8bit.bitwarden.ui.vault.feature.exportitems.model.AccountSelectionListItem
import com.x8bit.bitwarden.ui.vault.feature.exportitems.selectaccount.SelectAccountAction
import com.x8bit.bitwarden.ui.vault.feature.exportitems.selectaccount.SelectAccountEvent
import com.x8bit.bitwarden.ui.vault.feature.exportitems.selectaccount.SelectAccountState
import com.x8bit.bitwarden.ui.vault.feature.exportitems.selectaccount.SelectAccountViewModel
import io.mockk.every
import io.mockk.mockk
import kotlinx.collections.immutable.persistentListOf
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Test
class SelectAccountViewModelTest : BaseViewModelTest() {
private val mutableUserUserStateFlow = MutableStateFlow(DEFAULT_USER_STATE)
private val mutableRestrictItemTypesFlow =
bufferedMutableSharedFlow<List<SyncResponseJson.Policy>>()
private val mutablePersonalOwnershipPolicyFlow =
bufferedMutableSharedFlow<List<SyncResponseJson.Policy>>()
private val authRepository = mockk<AuthRepository> {
every { userStateFlow } returns mutableUserUserStateFlow
}
private val policyManager = mockk<PolicyManager> {
every {
getActivePoliciesFlow(PolicyTypeJson.RESTRICT_ITEM_TYPES)
} returns mutableRestrictItemTypesFlow
every {
getActivePoliciesFlow(PolicyTypeJson.PERSONAL_OWNERSHIP)
} returns mutablePersonalOwnershipPolicyFlow
}
@Test
fun `initial state should be correct`() = runTest {
val viewModel = createViewModel()
assertEquals(
SelectAccountState(
accountSelectionListItems = persistentListOf(),
),
viewModel.stateFlow.value,
)
}
@Test
fun `state is updated when single account with no organization is emitted`() = runTest {
val viewModel = createViewModel()
val expectedItem = AccountSelectionListItem(
userId = DEFAULT_ACCOUNT.userId,
email = DEFAULT_ACCOUNT.email,
initials = "AU",
avatarColorHex = "#FF00FF00",
isItemRestricted = false,
)
viewModel.trySendAction(
SelectAccountAction.Internal.SelectionDataReceive(
userState = DEFAULT_USER_STATE,
itemRestrictedOrgs = emptyList(),
personalOwnershipOrgs = emptyList(),
),
)
assertEquals(
SelectAccountState(accountSelectionListItems = persistentListOf(expectedItem)),
viewModel.stateFlow.value,
)
}
@Test
fun `accounts in organization with PERSONAL_VAULT policy are filtered out`() = runTest {
val viewModel = createViewModel()
val organizationId = "mockOrganizationId-1"
val accountInOrg = DEFAULT_ACCOUNT.copy(
organizations = listOf(
Organization(
id = organizationId,
name = "organizationName",
shouldManageResetPassword = false,
shouldUseKeyConnector = false,
role = OrganizationType.ADMIN,
keyConnectorUrl = null,
userIsClaimedByOrganization = false,
),
),
)
viewModel.trySendAction(
SelectAccountAction.Internal.SelectionDataReceive(
userState = DEFAULT_USER_STATE.copy(accounts = listOf(accountInOrg)),
itemRestrictedOrgs = emptyList(),
personalOwnershipOrgs = listOf(
createMockPolicy(
number = 1,
id = organizationId,
isEnabled = true,
),
),
),
)
assertEquals(
SelectAccountState(
accountSelectionListItems = persistentListOf(),
),
viewModel.stateFlow.value,
)
}
@Test
fun `accounts in organization with RESTRICT_ITEM_TYPE policy are marked as restricted`() =
runTest {
val viewModel = createViewModel()
val organizationId = "mockOrganizationId-1"
val accountInOrg = DEFAULT_ACCOUNT.copy(
organizations = listOf(
Organization(
id = organizationId,
name = "organizationName",
shouldManageResetPassword = false,
shouldUseKeyConnector = false,
role = OrganizationType.ADMIN,
keyConnectorUrl = null,
userIsClaimedByOrganization = false,
),
),
)
val expectedItem = AccountSelectionListItem(
userId = accountInOrg.userId,
email = accountInOrg.email,
initials = "AU",
avatarColorHex = "#FF00FF00",
isItemRestricted = true,
)
viewModel.trySendAction(
SelectAccountAction.Internal.SelectionDataReceive(
userState = DEFAULT_USER_STATE.copy(accounts = listOf(accountInOrg)),
itemRestrictedOrgs = listOf(
createMockPolicy(
number = 1,
id = organizationId,
isEnabled = true,
),
),
personalOwnershipOrgs = emptyList(),
),
)
assertEquals(
SelectAccountState(accountSelectionListItems = persistentListOf(expectedItem)),
viewModel.stateFlow.value,
)
}
@Test
fun `when CloseClick action is sent, CancelExport event is emitted`() = runTest {
val viewModel = createViewModel()
viewModel.eventFlow.test {
viewModel.trySendAction(SelectAccountAction.CloseClick)
assertEquals(SelectAccountEvent.CancelExport, awaitItem())
}
}
@Test
fun `when AccountClick action is sent, NavigateToPasswordVerification event is emitted`() =
runTest {
val viewModel = createViewModel()
viewModel.eventFlow.test {
viewModel.trySendAction(
SelectAccountAction.AccountClick(
DEFAULT_ACCOUNT.userId,
),
)
assertEquals(
SelectAccountEvent.NavigateToPasswordVerification(
userId = DEFAULT_ACCOUNT.userId,
),
awaitItem(),
)
}
}
private fun createViewModel(): SelectAccountViewModel = SelectAccountViewModel(
authRepository = authRepository,
policyManager = policyManager,
)
}
private val DEFAULT_ACCOUNT = UserState.Account(
userId = "activeUserId",
name = "Active User",
email = "active@bitwarden.com",
environment = Environment.Us,
avatarColorHex = "#FF00FF00",
isPremium = true,
isLoggedIn = true,
isVaultUnlocked = true,
needsPasswordReset = false,
isBiometricsEnabled = false,
organizations = emptyList(),
needsMasterPassword = false,
trustedDevice = null,
hasMasterPassword = true,
isUsingKeyConnector = false,
onboardingStatus = OnboardingStatus.COMPLETE,
firstTimeState = FirstTimeState(showImportLoginsCard = true),
)
private val DEFAULT_USER_STATE = UserState(
activeUserId = "activeUserId",
accounts = listOf(DEFAULT_ACCOUNT),
)
+3
View File
@@ -1107,4 +1107,7 @@ Do you want to switch to this account?</string>
<string name="enable_browser_autofill_to_keep_filling_passwords">Enable browser Autofill to keep filling passwords</string>
<string name="your_browser_recently_updated_how_autofill_works">Your browser recently updated how Autofill works. To continue filling your passwords with Bitwarden, enable browser Autofill in autofill settings.</string>
<string name="not_now">Not now</string>
<string name="import_from_bitwarden">Import from Bitwarden</string>
<string name="select_account">Select account</string>
<string name="import_restricted_unable_to_import_credit_cards">Import restricted, unable to import cards from this account.</string>
</resources>