From 3edd5bd8527357bd8b3c6483345e1bb4201240c1 Mon Sep 17 00:00:00 2001 From: Patrick Honkonen <1883101+SaintPatrck@users.noreply.github.com> Date: Wed, 24 Sep 2025 15:53:40 -0400 Subject: [PATCH] [PM-26095] Add account selection screen for Credential Exchange (#5932) --- .../composition/LocalManagerProvider.kt | 6 + .../component/AccountSummaryListItem.kt | 119 +++++++++ .../component/ExportItemsScaffold.kt | 46 ++++ .../model/AccountSelectionListItem.kt | 18 ++ .../selectaccount/SelectAccountScreen.kt | 156 +++++++++++- .../selectaccount/SelectAccountViewModel.kt | 185 ++++++++++++++ .../handlers/SelectAccountHandlers.kt | 39 +++ .../feature/vault/util/UserStateExtensions.kt | 24 ++ .../ui/platform/base/BitwardenComposeTest.kt | 3 + .../exportitems/SelectAccountScreenTest.kt | 167 +++++++++++++ .../exportitems/SelectAccountViewModelTest.kt | 228 ++++++++++++++++++ ui/src/main/res/values/strings.xml | 3 + 12 files changed, 992 insertions(+), 2 deletions(-) create mode 100644 app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/exportitems/component/AccountSummaryListItem.kt create mode 100644 app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/exportitems/component/ExportItemsScaffold.kt create mode 100644 app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/exportitems/model/AccountSelectionListItem.kt create mode 100644 app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/exportitems/selectaccount/SelectAccountViewModel.kt create mode 100644 app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/exportitems/selectaccount/handlers/SelectAccountHandlers.kt create mode 100644 app/src/test/kotlin/com/x8bit/bitwarden/ui/vault/feature/exportitems/SelectAccountScreenTest.kt create mode 100644 app/src/test/kotlin/com/x8bit/bitwarden/ui/vault/feature/exportitems/SelectAccountViewModelTest.kt diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/composition/LocalManagerProvider.kt b/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/composition/LocalManagerProvider.kt index df06760356..c3cbefe01b 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/composition/LocalManagerProvider.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/composition/LocalManagerProvider.kt @@ -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, ) } diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/exportitems/component/AccountSummaryListItem.kt b/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/exportitems/component/AccountSummaryListItem.kt new file mode 100644 index 0000000000..d45f1639f7 --- /dev/null +++ b/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/exportitems/component/AccountSummaryListItem.kt @@ -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)) + } +} diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/exportitems/component/ExportItemsScaffold.kt b/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/exportitems/component/ExportItemsScaffold.kt new file mode 100644 index 0000000000..7cedae63df --- /dev/null +++ b/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/exportitems/component/ExportItemsScaffold.kt @@ -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, + ) +} diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/exportitems/model/AccountSelectionListItem.kt b/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/exportitems/model/AccountSelectionListItem.kt new file mode 100644 index 0000000000..91056bea00 --- /dev/null +++ b/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/exportitems/model/AccountSelectionListItem.kt @@ -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 diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/exportitems/selectaccount/SelectAccountScreen.kt b/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/exportitems/selectaccount/SelectAccountScreen.kt index 9703706ec5..900e56c87f 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/exportitems/selectaccount/SelectAccountScreen.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/exportitems/selectaccount/SelectAccountScreen.kt @@ -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 = { }, + ) + } } diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/exportitems/selectaccount/SelectAccountViewModel.kt b/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/exportitems/selectaccount/SelectAccountViewModel.kt new file mode 100644 index 0000000000..e879f557b1 --- /dev/null +++ b/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/exportitems/selectaccount/SelectAccountViewModel.kt @@ -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( + 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, +) + +/** + * 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, + val personalOwnershipOrgs: List, + ) : 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() +} diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/exportitems/selectaccount/handlers/SelectAccountHandlers.kt b/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/exportitems/selectaccount/handlers/SelectAccountHandlers.kt new file mode 100644 index 0000000000..babb67749a --- /dev/null +++ b/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/exportitems/selectaccount/handlers/SelectAccountHandlers.kt @@ -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) } diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/vault/util/UserStateExtensions.kt b/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/vault/util/UserStateExtensions.kt index cdfbffe0d8..8786765b7e 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/vault/util/UserStateExtensions.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/vault/util/UserStateExtensions.kt @@ -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()) + } diff --git a/app/src/test/kotlin/com/x8bit/bitwarden/ui/platform/base/BitwardenComposeTest.kt b/app/src/test/kotlin/com/x8bit/bitwarden/ui/platform/base/BitwardenComposeTest.kt index 442f7b5653..744e72d049 100644 --- a/app/src/test/kotlin/com/x8bit/bitwarden/ui/platform/base/BitwardenComposeTest.kt +++ b/app/src/test/kotlin/com/x8bit/bitwarden/ui/platform/base/BitwardenComposeTest.kt @@ -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, diff --git a/app/src/test/kotlin/com/x8bit/bitwarden/ui/vault/feature/exportitems/SelectAccountScreenTest.kt b/app/src/test/kotlin/com/x8bit/bitwarden/ui/vault/feature/exportitems/SelectAccountScreenTest.kt new file mode 100644 index 0000000000..a71359fbbf --- /dev/null +++ b/app/src/test/kotlin/com/x8bit/bitwarden/ui/vault/feature/exportitems/SelectAccountScreenTest.kt @@ -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() + + private val credentialExchangeCompletionManager = mockk() + private val viewModel = mockk { + 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() + 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, + ), + ), +) diff --git a/app/src/test/kotlin/com/x8bit/bitwarden/ui/vault/feature/exportitems/SelectAccountViewModelTest.kt b/app/src/test/kotlin/com/x8bit/bitwarden/ui/vault/feature/exportitems/SelectAccountViewModelTest.kt new file mode 100644 index 0000000000..b276200d74 --- /dev/null +++ b/app/src/test/kotlin/com/x8bit/bitwarden/ui/vault/feature/exportitems/SelectAccountViewModelTest.kt @@ -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>() + private val mutablePersonalOwnershipPolicyFlow = + bufferedMutableSharedFlow>() + + private val authRepository = mockk { + every { userStateFlow } returns mutableUserUserStateFlow + } + private val policyManager = mockk { + 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), +) diff --git a/ui/src/main/res/values/strings.xml b/ui/src/main/res/values/strings.xml index 84008041c1..23289cc3d5 100644 --- a/ui/src/main/res/values/strings.xml +++ b/ui/src/main/res/values/strings.xml @@ -1107,4 +1107,7 @@ Do you want to switch to this account? Enable browser Autofill to keep filling passwords Your browser recently updated how Autofill works. To continue filling your passwords with Bitwarden, enable browser Autofill in autofill settings. Not now + Import from Bitwarden + Select account + Import restricted, unable to import cards from this account.