[PM-27001] Skip account selection only one exists on cxp flow (#6055)

This commit is contained in:
aj-rosado 2025-10-22 10:08:35 +01:00 committed by GitHub
parent ae4b398258
commit e610a7541d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 208 additions and 7 deletions

View File

@ -68,6 +68,7 @@ import com.x8bit.bitwarden.ui.vault.feature.addedit.util.toVaultItemCipherType
import com.x8bit.bitwarden.ui.vault.feature.exportitems.ExportItemsGraphRoute import com.x8bit.bitwarden.ui.vault.feature.exportitems.ExportItemsGraphRoute
import com.x8bit.bitwarden.ui.vault.feature.exportitems.exportItemsGraph import com.x8bit.bitwarden.ui.vault.feature.exportitems.exportItemsGraph
import com.x8bit.bitwarden.ui.vault.feature.exportitems.navigateToExportItemsGraph import com.x8bit.bitwarden.ui.vault.feature.exportitems.navigateToExportItemsGraph
import com.x8bit.bitwarden.ui.vault.feature.exportitems.verifypassword.navigateToVerifyPassword
import com.x8bit.bitwarden.ui.vault.feature.itemlisting.navigateToVaultItemListingAsRoot import com.x8bit.bitwarden.ui.vault.feature.itemlisting.navigateToVaultItemListingAsRoot
import com.x8bit.bitwarden.ui.vault.model.VaultAddEditType import com.x8bit.bitwarden.ui.vault.model.VaultAddEditType
import com.x8bit.bitwarden.ui.vault.model.VaultItemListingType import com.x8bit.bitwarden.ui.vault.model.VaultItemListingType
@ -142,7 +143,10 @@ fun RootNavScreen(
is RootNavState.VaultUnlockedForProviderGetCredentials, is RootNavState.VaultUnlockedForProviderGetCredentials,
-> VaultUnlockedGraphRoute -> VaultUnlockedGraphRoute
is RootNavState.CredentialExchangeExport -> ExportItemsGraphRoute is RootNavState.CredentialExchangeExport,
is RootNavState.CredentialExchangeExportSkipAccountSelection,
-> ExportItemsGraphRoute
RootNavState.OnboardingAccountLockSetup -> SetupUnlockRoute.AsRoot RootNavState.OnboardingAccountLockSetup -> SetupUnlockRoute.AsRoot
RootNavState.OnboardingAutoFillSetup -> SetupAutofillRoute.AsRoot RootNavState.OnboardingAutoFillSetup -> SetupAutofillRoute.AsRoot
RootNavState.OnboardingBrowserAutofillSetup -> SetupBrowserAutofillRoute.AsRoot RootNavState.OnboardingBrowserAutofillSetup -> SetupBrowserAutofillRoute.AsRoot
@ -288,6 +292,13 @@ fun RootNavScreen(
is RootNavState.CredentialExchangeExport -> { is RootNavState.CredentialExchangeExport -> {
navController.navigateToExportItemsGraph(rootNavOptions) navController.navigateToExportItemsGraph(rootNavOptions)
} }
is RootNavState.CredentialExchangeExportSkipAccountSelection -> {
navController.navigateToVerifyPassword(
userId = currentState.userId,
navOptions = rootNavOptions,
)
}
} }
} }
} }

View File

@ -59,7 +59,7 @@ class RootNavViewModel @Inject constructor(
} }
} }
@Suppress("CyclomaticComplexMethod", "MaxLineLength", "LongMethod") @Suppress("CyclomaticComplexMethod", "LongMethod")
private fun handleUserStateUpdateReceive( private fun handleUserStateUpdateReceive(
action: RootNavAction.Internal.UserStateUpdateReceive, action: RootNavAction.Internal.UserStateUpdateReceive,
) { ) {
@ -89,7 +89,13 @@ class RootNavViewModel @Inject constructor(
} }
specialCircumstance is SpecialCircumstance.CredentialExchangeExport -> { specialCircumstance is SpecialCircumstance.CredentialExchangeExport -> {
RootNavState.CredentialExchangeExport if (userState.accounts.size == 1) {
RootNavState.CredentialExchangeExportSkipAccountSelection(
userId = userState.accounts.first().userId,
)
} else {
RootNavState.CredentialExchangeExport
}
} }
userState.activeAccount.isVaultUnlocked && userState.activeAccount.isVaultUnlocked &&
@ -424,6 +430,14 @@ sealed class RootNavState : Parcelable {
*/ */
@Parcelize @Parcelize
data object CredentialExchangeExport : RootNavState() data object CredentialExchangeExport : RootNavState()
/**
* App should begin the export items flow, skipping the account selection screen.
*/
@Parcelize
data class CredentialExchangeExportSkipAccountSelection(
val userId: String,
) : RootNavState()
} }
/** /**

View File

@ -23,8 +23,12 @@ import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.credentials.providerevents.exception.ImportCredentialsCancellationException
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle 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.EventsEffect
import com.bitwarden.ui.platform.base.util.standardHorizontalMargin import com.bitwarden.ui.platform.base.util.standardHorizontalMargin
import com.bitwarden.ui.platform.components.button.BitwardenFilledButton import com.bitwarden.ui.platform.components.button.BitwardenFilledButton
@ -54,6 +58,8 @@ fun VerifyPasswordScreen(
onNavigateBack: () -> Unit, onNavigateBack: () -> Unit,
onPasswordVerified: (userId: String) -> Unit, onPasswordVerified: (userId: String) -> Unit,
viewModel: VerifyPasswordViewModel = hiltViewModel(), viewModel: VerifyPasswordViewModel = hiltViewModel(),
credentialExchangeCompletionManager: CredentialExchangeCompletionManager =
LocalCredentialExchangeCompletionManager.current,
snackbarHostState: BitwardenSnackbarHostState = rememberBitwardenSnackbarHostState(), snackbarHostState: BitwardenSnackbarHostState = rememberBitwardenSnackbarHostState(),
) { ) {
val state by viewModel.stateFlow.collectAsStateWithLifecycle() val state by viewModel.stateFlow.collectAsStateWithLifecycle()
@ -63,6 +69,16 @@ fun VerifyPasswordScreen(
EventsEffect(viewModel) { event -> EventsEffect(viewModel) { event ->
when (event) { when (event) {
VerifyPasswordEvent.NavigateBack -> onNavigateBack() VerifyPasswordEvent.NavigateBack -> onNavigateBack()
VerifyPasswordEvent.CancelExport -> {
credentialExchangeCompletionManager
.completeCredentialExport(
exportResult = ExportCredentialsResult.Failure(
error = ImportCredentialsCancellationException(
errorMessage = "User cancelled import.",
),
),
)
}
is VerifyPasswordEvent.PasswordVerified -> { is VerifyPasswordEvent.PasswordVerified -> {
onPasswordVerified(event.userId) onPasswordVerified(event.userId)
@ -81,7 +97,11 @@ fun VerifyPasswordScreen(
ExportItemsScaffold( ExportItemsScaffold(
navIcon = rememberVectorPainter( navIcon = rememberVectorPainter(
BitwardenDrawable.ic_back, id = if (state.hasOtherAccounts) {
BitwardenDrawable.ic_back
} else {
BitwardenDrawable.ic_close
},
), ),
onNavigationIconClick = handler.onNavigateBackClick, onNavigationIconClick = handler.onNavigateBackClick,
navigationIconContentDescription = stringResource(BitwardenString.back), navigationIconContentDescription = stringResource(BitwardenString.back),
@ -263,6 +283,7 @@ private fun VerifyPasswordContent_MasterPassword_preview() {
val state = VerifyPasswordState( val state = VerifyPasswordState(
title = BitwardenString.verify_your_master_password.asText(), title = BitwardenString.verify_your_master_password.asText(),
subtext = null, subtext = null,
hasOtherAccounts = true,
accountSummaryListItem = accountSummaryListItem, accountSummaryListItem = accountSummaryListItem,
) )
ExportItemsScaffold( ExportItemsScaffold(
@ -303,6 +324,7 @@ private fun VerifyPasswordContent_Otp_preview() {
.asText(), .asText(),
accountSummaryListItem = accountSummaryListItem, accountSummaryListItem = accountSummaryListItem,
showResendCodeButton = true, showResendCodeButton = true,
hasOtherAccounts = true,
) )
ExportItemsScaffold( ExportItemsScaffold(
navIcon = rememberVectorPainter( navIcon = rememberVectorPainter(

View File

@ -56,6 +56,12 @@ class VerifyPasswordViewModel @Inject constructor(
?.firstOrNull { it.userId == args.userId } ?.firstOrNull { it.userId == args.userId }
?: throw IllegalStateException("Account not found") ?: throw IllegalStateException("Account not found")
val singleAccount = authRepository
.userStateFlow
.value
?.accounts
?.size == 1
val restrictedItemPolicyOrgIds = policyManager val restrictedItemPolicyOrgIds = policyManager
.getActivePolicies(PolicyTypeJson.RESTRICT_ITEM_TYPES) .getActivePolicies(PolicyTypeJson.RESTRICT_ITEM_TYPES)
.filter { it.isEnabled } .filter { it.isEnabled }
@ -81,6 +87,7 @@ class VerifyPasswordViewModel @Inject constructor(
.any { it.id in restrictedItemPolicyOrgIds }, .any { it.id in restrictedItemPolicyOrgIds },
), ),
showResendCodeButton = !account.hasMasterPassword, showResendCodeButton = !account.hasMasterPassword,
hasOtherAccounts = !singleAccount,
) )
}, },
) { ) {
@ -138,7 +145,11 @@ class VerifyPasswordViewModel @Inject constructor(
} }
private fun handleNavigateBackClick() { private fun handleNavigateBackClick() {
sendEvent(VerifyPasswordEvent.NavigateBack) if (state.hasOtherAccounts) {
sendEvent(VerifyPasswordEvent.NavigateBack)
} else {
sendEvent(VerifyPasswordEvent.CancelExport)
}
} }
private fun handleContinueClick() { private fun handleContinueClick() {
@ -421,8 +432,10 @@ data class VerifyPasswordState(
val accountSummaryListItem: AccountSelectionListItem, val accountSummaryListItem: AccountSelectionListItem,
val title: Text, val title: Text,
val subtext: Text?, val subtext: Text?,
val hasOtherAccounts: Boolean,
// We never want this saved since the input is sensitive data. // We never want this saved since the input is sensitive data.
@IgnoredOnParcel val input: String = "", @IgnoredOnParcel
val input: String = "",
val dialog: DialogState? = null, val dialog: DialogState? = null,
val showResendCodeButton: Boolean = false, val showResendCodeButton: Boolean = false,
) : Parcelable { ) : Parcelable {
@ -475,6 +488,11 @@ sealed class VerifyPasswordEvent {
*/ */
data class PasswordVerified(val userId: String) : VerifyPasswordEvent() data class PasswordVerified(val userId: String) : VerifyPasswordEvent()
/**
* Cancel the export request.
*/
data object CancelExport : VerifyPasswordEvent()
/** /**
* Show a snackbar with the given data. * Show a snackbar with the given data.
*/ */

View File

@ -25,6 +25,7 @@ import com.x8bit.bitwarden.ui.tools.feature.send.model.SendItemType
import com.x8bit.bitwarden.ui.vault.feature.addedit.VaultAddEditMode import com.x8bit.bitwarden.ui.vault.feature.addedit.VaultAddEditMode
import com.x8bit.bitwarden.ui.vault.feature.addedit.VaultAddEditRoute import com.x8bit.bitwarden.ui.vault.feature.addedit.VaultAddEditRoute
import com.x8bit.bitwarden.ui.vault.feature.exportitems.ExportItemsGraphRoute import com.x8bit.bitwarden.ui.vault.feature.exportitems.ExportItemsGraphRoute
import com.x8bit.bitwarden.ui.vault.feature.exportitems.verifypassword.VerifyPasswordRoute
import com.x8bit.bitwarden.ui.vault.feature.itemlisting.ItemListingType import com.x8bit.bitwarden.ui.vault.feature.itemlisting.ItemListingType
import com.x8bit.bitwarden.ui.vault.feature.itemlisting.VaultItemListingRoute import com.x8bit.bitwarden.ui.vault.feature.itemlisting.VaultItemListingRoute
import com.x8bit.bitwarden.ui.vault.model.VaultItemCipherType import com.x8bit.bitwarden.ui.vault.model.VaultItemCipherType
@ -450,6 +451,26 @@ class RootNavScreenTest : BitwardenComposeTest() {
) )
} }
} }
// Make sure navigating to export items graph works as expected:
rootNavStateFlow.value = RootNavState.CredentialExchangeExportSkipAccountSelection(
userId = "activeUserId",
)
composeTestRule.runOnIdle {
verify {
mockNavHostController.navigate(
route = ExportItemsGraphRoute,
navOptions = expectedNavOptions,
)
mockNavHostController.navigate(
route = VerifyPasswordRoute(
userId = "activeUserId",
),
navOptions = expectedNavOptions,
)
}
}
} }
} }

View File

@ -1441,7 +1441,7 @@ class RootNavViewModelTest : BaseViewModelTest() {
requestJson = "mockRequestJson", requestJson = "mockRequestJson",
), ),
) )
mutableUserStateFlow.tryEmit(MOCK_VAULT_UNLOCKED_USER_STATE) mutableUserStateFlow.tryEmit(MOCK_VAULT_UNLOCKED_USER_MULTIPLE_ACCOUNTS_STATE)
val viewModel = createViewModel() val viewModel = createViewModel()
assertEquals( assertEquals(
RootNavState.CredentialExchangeExport, RootNavState.CredentialExchangeExport,
@ -1449,6 +1449,26 @@ class RootNavViewModelTest : BaseViewModelTest() {
) )
} }
@Suppress("MaxLineLength")
@Test
fun `when SpecialCircumstance is CredentialExchangeExport and only has 1 account, the nav state should be CredentialExchangeExportSkipAccountSelection`() {
specialCircumstanceManager.specialCircumstance =
SpecialCircumstance.CredentialExchangeExport(
data = ImportCredentialsRequestData(
uri = mockk(),
requestJson = "mockRequestJson",
),
)
mutableUserStateFlow.tryEmit(MOCK_VAULT_UNLOCKED_USER_STATE)
val viewModel = createViewModel()
assertEquals(
RootNavState.CredentialExchangeExportSkipAccountSelection(
userId = "activeUserId",
),
viewModel.stateFlow.value,
)
}
private fun createViewModel(): RootNavViewModel = private fun createViewModel(): RootNavViewModel =
RootNavViewModel( RootNavViewModel(
authRepository = authRepository, authRepository = authRepository,
@ -1487,3 +1507,48 @@ private val MOCK_VAULT_UNLOCKED_USER_STATE = UserState(
), ),
), ),
) )
private val MOCK_VAULT_UNLOCKED_USER_MULTIPLE_ACCOUNTS_STATE = UserState(
activeUserId = "activeUserId",
accounts = listOf(
UserState.Account(
userId = "activeUserId",
name = "name",
email = "email",
avatarColorHex = "avatarColorHex",
environment = Environment.Us,
isPremium = true,
isLoggedIn = true,
isVaultUnlocked = true,
needsPasswordReset = false,
isBiometricsEnabled = false,
organizations = emptyList(),
needsMasterPassword = false,
trustedDevice = null,
hasMasterPassword = true,
isUsingKeyConnector = false,
firstTimeState = FirstTimeState(false),
onboardingStatus = OnboardingStatus.COMPLETE,
),
UserState.Account(
userId = "activeUserTwoId",
name = "name two",
email = "email two",
avatarColorHex = "avatarColorHex",
environment = Environment.Us,
isPremium = true,
isLoggedIn = true,
isVaultUnlocked = true,
needsPasswordReset = false,
isBiometricsEnabled = false,
organizations = emptyList(),
needsMasterPassword = false,
trustedDevice = null,
hasMasterPassword = true,
isUsingKeyConnector = false,
firstTimeState = FirstTimeState(false),
onboardingStatus = OnboardingStatus.COMPLETE,
),
),
)

View File

@ -297,4 +297,5 @@ private val DEFAULT_STATE = VerifyPasswordState(
accountSummaryListItem = DEFAULT_ACCOUNT_SELECTION_LIST_ITEM, accountSummaryListItem = DEFAULT_ACCOUNT_SELECTION_LIST_ITEM,
input = "", input = "",
dialog = null, dialog = null,
hasOtherAccounts = true,
) )

View File

@ -104,6 +104,7 @@ class VerifyPasswordViewModelTest : BaseViewModelTest() {
initials = DEFAULT_USER_STATE.activeAccount.initials, initials = DEFAULT_USER_STATE.activeAccount.initials,
), ),
showResendCodeButton = true, showResendCodeButton = true,
hasOtherAccounts = true,
), ),
it.stateFlow.value, it.stateFlow.value,
) )
@ -119,6 +120,7 @@ class VerifyPasswordViewModelTest : BaseViewModelTest() {
VerifyPasswordState( VerifyPasswordState(
title = BitwardenString.verify_your_master_password.asText(), title = BitwardenString.verify_your_master_password.asText(),
subtext = null, subtext = null,
hasOtherAccounts = true,
accountSummaryListItem = AccountSelectionListItem( accountSummaryListItem = AccountSelectionListItem(
userId = DEFAULT_USER_ID, userId = DEFAULT_USER_ID,
email = DEFAULT_USER_STATE.activeAccount.email, email = DEFAULT_USER_STATE.activeAccount.email,
@ -150,6 +152,7 @@ class VerifyPasswordViewModelTest : BaseViewModelTest() {
VerifyPasswordState( VerifyPasswordState(
title = BitwardenString.verify_your_master_password.asText(), title = BitwardenString.verify_your_master_password.asText(),
subtext = null, subtext = null,
hasOtherAccounts = true,
accountSummaryListItem = DEFAULT_ACCOUNT_SELECTION_LIST_ITEM accountSummaryListItem = DEFAULT_ACCOUNT_SELECTION_LIST_ITEM
.copy(isItemRestricted = true), .copy(isItemRestricted = true),
), ),
@ -285,6 +288,21 @@ class VerifyPasswordViewModelTest : BaseViewModelTest() {
} }
} }
@Test
fun `NavigateBackClick should send CancelExport event when hasOtherAccounts is false`() =
runTest {
val initialState = DEFAULT_STATE.copy(hasOtherAccounts = false)
createViewModel(state = initialState).also {
it.trySendAction(VerifyPasswordAction.NavigateBackClick)
it.eventFlow.test {
assertEquals(
VerifyPasswordEvent.CancelExport,
awaitItem(),
)
}
}
}
@Test @Test
fun `ContinueClick with empty input should show error dialog`() = runTest { fun `ContinueClick with empty input should show error dialog`() = runTest {
createViewModel().also { createViewModel().also {
@ -724,6 +742,36 @@ private val DEFAULT_USER_STATE = UserState(
onboardingStatus = OnboardingStatus.COMPLETE, onboardingStatus = OnboardingStatus.COMPLETE,
firstTimeState = FirstTimeState(showImportLoginsCard = true), firstTimeState = FirstTimeState(showImportLoginsCard = true),
), ),
UserState.Account(
userId = "activeUserId2",
name = "Active User Two",
email = "active+two@bitwarden.com",
avatarColorHex = "#aa00aa",
environment = Environment.Us,
isPremium = true,
isLoggedIn = true,
isVaultUnlocked = true,
needsPasswordReset = false,
isBiometricsEnabled = false,
organizations = listOf(
Organization(
id = DEFAULT_ORGANIZATION_ID,
name = "Organization User Two",
shouldUseKeyConnector = false,
shouldManageResetPassword = false,
role = OrganizationType.USER,
keyConnectorUrl = null,
userIsClaimedByOrganization = false,
),
),
needsMasterPassword = false,
trustedDevice = null,
hasMasterPassword = true,
isUsingKeyConnector = false,
onboardingStatus = OnboardingStatus.COMPLETE,
firstTimeState = FirstTimeState(showImportLoginsCard = true),
),
), ),
) )
private val DEFAULT_ACCOUNT_SELECTION_LIST_ITEM = AccountSelectionListItem( private val DEFAULT_ACCOUNT_SELECTION_LIST_ITEM = AccountSelectionListItem(
@ -736,6 +784,7 @@ private val DEFAULT_ACCOUNT_SELECTION_LIST_ITEM = AccountSelectionListItem(
private val DEFAULT_STATE = VerifyPasswordState( private val DEFAULT_STATE = VerifyPasswordState(
title = BitwardenString.verify_your_master_password.asText(), title = BitwardenString.verify_your_master_password.asText(),
subtext = null, subtext = null,
hasOtherAccounts = true,
accountSummaryListItem = DEFAULT_ACCOUNT_SELECTION_LIST_ITEM, accountSummaryListItem = DEFAULT_ACCOUNT_SELECTION_LIST_ITEM,
input = "", input = "",
dialog = null, dialog = null,