mirror of
https://github.com/bitwarden/android.git
synced 2025-12-10 00:06:22 -06:00
[PM-27001] Skip account selection only one exists on cxp flow (#6055)
This commit is contained in:
parent
ae4b398258
commit
e610a7541d
@ -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.exportItemsGraph
|
||||
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.model.VaultAddEditType
|
||||
import com.x8bit.bitwarden.ui.vault.model.VaultItemListingType
|
||||
@ -142,7 +143,10 @@ fun RootNavScreen(
|
||||
is RootNavState.VaultUnlockedForProviderGetCredentials,
|
||||
-> VaultUnlockedGraphRoute
|
||||
|
||||
is RootNavState.CredentialExchangeExport -> ExportItemsGraphRoute
|
||||
is RootNavState.CredentialExchangeExport,
|
||||
is RootNavState.CredentialExchangeExportSkipAccountSelection,
|
||||
-> ExportItemsGraphRoute
|
||||
|
||||
RootNavState.OnboardingAccountLockSetup -> SetupUnlockRoute.AsRoot
|
||||
RootNavState.OnboardingAutoFillSetup -> SetupAutofillRoute.AsRoot
|
||||
RootNavState.OnboardingBrowserAutofillSetup -> SetupBrowserAutofillRoute.AsRoot
|
||||
@ -288,6 +292,13 @@ fun RootNavScreen(
|
||||
is RootNavState.CredentialExchangeExport -> {
|
||||
navController.navigateToExportItemsGraph(rootNavOptions)
|
||||
}
|
||||
|
||||
is RootNavState.CredentialExchangeExportSkipAccountSelection -> {
|
||||
navController.navigateToVerifyPassword(
|
||||
userId = currentState.userId,
|
||||
navOptions = rootNavOptions,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -59,7 +59,7 @@ class RootNavViewModel @Inject constructor(
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("CyclomaticComplexMethod", "MaxLineLength", "LongMethod")
|
||||
@Suppress("CyclomaticComplexMethod", "LongMethod")
|
||||
private fun handleUserStateUpdateReceive(
|
||||
action: RootNavAction.Internal.UserStateUpdateReceive,
|
||||
) {
|
||||
@ -89,7 +89,13 @@ class RootNavViewModel @Inject constructor(
|
||||
}
|
||||
|
||||
specialCircumstance is SpecialCircumstance.CredentialExchangeExport -> {
|
||||
RootNavState.CredentialExchangeExport
|
||||
if (userState.accounts.size == 1) {
|
||||
RootNavState.CredentialExchangeExportSkipAccountSelection(
|
||||
userId = userState.accounts.first().userId,
|
||||
)
|
||||
} else {
|
||||
RootNavState.CredentialExchangeExport
|
||||
}
|
||||
}
|
||||
|
||||
userState.activeAccount.isVaultUnlocked &&
|
||||
@ -424,6 +430,14 @@ sealed class RootNavState : Parcelable {
|
||||
*/
|
||||
@Parcelize
|
||||
data object CredentialExchangeExport : RootNavState()
|
||||
|
||||
/**
|
||||
* App should begin the export items flow, skipping the account selection screen.
|
||||
*/
|
||||
@Parcelize
|
||||
data class CredentialExchangeExportSkipAccountSelection(
|
||||
val userId: String,
|
||||
) : RootNavState()
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@ -23,8 +23,12 @@ import androidx.compose.ui.text.input.KeyboardType
|
||||
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.ImportCredentialsCancellationException
|
||||
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.components.button.BitwardenFilledButton
|
||||
@ -54,6 +58,8 @@ fun VerifyPasswordScreen(
|
||||
onNavigateBack: () -> Unit,
|
||||
onPasswordVerified: (userId: String) -> Unit,
|
||||
viewModel: VerifyPasswordViewModel = hiltViewModel(),
|
||||
credentialExchangeCompletionManager: CredentialExchangeCompletionManager =
|
||||
LocalCredentialExchangeCompletionManager.current,
|
||||
snackbarHostState: BitwardenSnackbarHostState = rememberBitwardenSnackbarHostState(),
|
||||
) {
|
||||
val state by viewModel.stateFlow.collectAsStateWithLifecycle()
|
||||
@ -63,6 +69,16 @@ fun VerifyPasswordScreen(
|
||||
EventsEffect(viewModel) { event ->
|
||||
when (event) {
|
||||
VerifyPasswordEvent.NavigateBack -> onNavigateBack()
|
||||
VerifyPasswordEvent.CancelExport -> {
|
||||
credentialExchangeCompletionManager
|
||||
.completeCredentialExport(
|
||||
exportResult = ExportCredentialsResult.Failure(
|
||||
error = ImportCredentialsCancellationException(
|
||||
errorMessage = "User cancelled import.",
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
is VerifyPasswordEvent.PasswordVerified -> {
|
||||
onPasswordVerified(event.userId)
|
||||
@ -81,7 +97,11 @@ fun VerifyPasswordScreen(
|
||||
|
||||
ExportItemsScaffold(
|
||||
navIcon = rememberVectorPainter(
|
||||
BitwardenDrawable.ic_back,
|
||||
id = if (state.hasOtherAccounts) {
|
||||
BitwardenDrawable.ic_back
|
||||
} else {
|
||||
BitwardenDrawable.ic_close
|
||||
},
|
||||
),
|
||||
onNavigationIconClick = handler.onNavigateBackClick,
|
||||
navigationIconContentDescription = stringResource(BitwardenString.back),
|
||||
@ -263,6 +283,7 @@ private fun VerifyPasswordContent_MasterPassword_preview() {
|
||||
val state = VerifyPasswordState(
|
||||
title = BitwardenString.verify_your_master_password.asText(),
|
||||
subtext = null,
|
||||
hasOtherAccounts = true,
|
||||
accountSummaryListItem = accountSummaryListItem,
|
||||
)
|
||||
ExportItemsScaffold(
|
||||
@ -303,6 +324,7 @@ private fun VerifyPasswordContent_Otp_preview() {
|
||||
.asText(),
|
||||
accountSummaryListItem = accountSummaryListItem,
|
||||
showResendCodeButton = true,
|
||||
hasOtherAccounts = true,
|
||||
)
|
||||
ExportItemsScaffold(
|
||||
navIcon = rememberVectorPainter(
|
||||
|
||||
@ -56,6 +56,12 @@ class VerifyPasswordViewModel @Inject constructor(
|
||||
?.firstOrNull { it.userId == args.userId }
|
||||
?: throw IllegalStateException("Account not found")
|
||||
|
||||
val singleAccount = authRepository
|
||||
.userStateFlow
|
||||
.value
|
||||
?.accounts
|
||||
?.size == 1
|
||||
|
||||
val restrictedItemPolicyOrgIds = policyManager
|
||||
.getActivePolicies(PolicyTypeJson.RESTRICT_ITEM_TYPES)
|
||||
.filter { it.isEnabled }
|
||||
@ -81,6 +87,7 @@ class VerifyPasswordViewModel @Inject constructor(
|
||||
.any { it.id in restrictedItemPolicyOrgIds },
|
||||
),
|
||||
showResendCodeButton = !account.hasMasterPassword,
|
||||
hasOtherAccounts = !singleAccount,
|
||||
)
|
||||
},
|
||||
) {
|
||||
@ -138,7 +145,11 @@ class VerifyPasswordViewModel @Inject constructor(
|
||||
}
|
||||
|
||||
private fun handleNavigateBackClick() {
|
||||
sendEvent(VerifyPasswordEvent.NavigateBack)
|
||||
if (state.hasOtherAccounts) {
|
||||
sendEvent(VerifyPasswordEvent.NavigateBack)
|
||||
} else {
|
||||
sendEvent(VerifyPasswordEvent.CancelExport)
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleContinueClick() {
|
||||
@ -421,8 +432,10 @@ data class VerifyPasswordState(
|
||||
val accountSummaryListItem: AccountSelectionListItem,
|
||||
val title: Text,
|
||||
val subtext: Text?,
|
||||
val hasOtherAccounts: Boolean,
|
||||
// We never want this saved since the input is sensitive data.
|
||||
@IgnoredOnParcel val input: String = "",
|
||||
@IgnoredOnParcel
|
||||
val input: String = "",
|
||||
val dialog: DialogState? = null,
|
||||
val showResendCodeButton: Boolean = false,
|
||||
) : Parcelable {
|
||||
@ -475,6 +488,11 @@ sealed class VerifyPasswordEvent {
|
||||
*/
|
||||
data class PasswordVerified(val userId: String) : VerifyPasswordEvent()
|
||||
|
||||
/**
|
||||
* Cancel the export request.
|
||||
*/
|
||||
data object CancelExport : VerifyPasswordEvent()
|
||||
|
||||
/**
|
||||
* Show a snackbar with the given data.
|
||||
*/
|
||||
|
||||
@ -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.VaultAddEditRoute
|
||||
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.VaultItemListingRoute
|
||||
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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -1441,7 +1441,7 @@ class RootNavViewModelTest : BaseViewModelTest() {
|
||||
requestJson = "mockRequestJson",
|
||||
),
|
||||
)
|
||||
mutableUserStateFlow.tryEmit(MOCK_VAULT_UNLOCKED_USER_STATE)
|
||||
mutableUserStateFlow.tryEmit(MOCK_VAULT_UNLOCKED_USER_MULTIPLE_ACCOUNTS_STATE)
|
||||
val viewModel = createViewModel()
|
||||
assertEquals(
|
||||
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 =
|
||||
RootNavViewModel(
|
||||
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,
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
@ -297,4 +297,5 @@ private val DEFAULT_STATE = VerifyPasswordState(
|
||||
accountSummaryListItem = DEFAULT_ACCOUNT_SELECTION_LIST_ITEM,
|
||||
input = "",
|
||||
dialog = null,
|
||||
hasOtherAccounts = true,
|
||||
)
|
||||
|
||||
@ -104,6 +104,7 @@ class VerifyPasswordViewModelTest : BaseViewModelTest() {
|
||||
initials = DEFAULT_USER_STATE.activeAccount.initials,
|
||||
),
|
||||
showResendCodeButton = true,
|
||||
hasOtherAccounts = true,
|
||||
),
|
||||
it.stateFlow.value,
|
||||
)
|
||||
@ -119,6 +120,7 @@ class VerifyPasswordViewModelTest : BaseViewModelTest() {
|
||||
VerifyPasswordState(
|
||||
title = BitwardenString.verify_your_master_password.asText(),
|
||||
subtext = null,
|
||||
hasOtherAccounts = true,
|
||||
accountSummaryListItem = AccountSelectionListItem(
|
||||
userId = DEFAULT_USER_ID,
|
||||
email = DEFAULT_USER_STATE.activeAccount.email,
|
||||
@ -150,6 +152,7 @@ class VerifyPasswordViewModelTest : BaseViewModelTest() {
|
||||
VerifyPasswordState(
|
||||
title = BitwardenString.verify_your_master_password.asText(),
|
||||
subtext = null,
|
||||
hasOtherAccounts = true,
|
||||
accountSummaryListItem = DEFAULT_ACCOUNT_SELECTION_LIST_ITEM
|
||||
.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
|
||||
fun `ContinueClick with empty input should show error dialog`() = runTest {
|
||||
createViewModel().also {
|
||||
@ -724,6 +742,36 @@ private val DEFAULT_USER_STATE = UserState(
|
||||
onboardingStatus = OnboardingStatus.COMPLETE,
|
||||
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(
|
||||
@ -736,6 +784,7 @@ private val DEFAULT_ACCOUNT_SELECTION_LIST_ITEM = AccountSelectionListItem(
|
||||
private val DEFAULT_STATE = VerifyPasswordState(
|
||||
title = BitwardenString.verify_your_master_password.asText(),
|
||||
subtext = null,
|
||||
hasOtherAccounts = true,
|
||||
accountSummaryListItem = DEFAULT_ACCOUNT_SELECTION_LIST_ITEM,
|
||||
input = "",
|
||||
dialog = null,
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user