diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/feature/rootnav/RootNavScreen.kt b/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/feature/rootnav/RootNavScreen.kt index 7bdc8de4a8..326f4741e4 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/feature/rootnav/RootNavScreen.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/feature/rootnav/RootNavScreen.kt @@ -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, + ) + } } } } diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/feature/rootnav/RootNavViewModel.kt b/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/feature/rootnav/RootNavViewModel.kt index 6c81f0e6a7..b9702ac8d2 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/feature/rootnav/RootNavViewModel.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/feature/rootnav/RootNavViewModel.kt @@ -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() } /** diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/exportitems/verifypassword/VerifyPasswordScreen.kt b/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/exportitems/verifypassword/VerifyPasswordScreen.kt index 79f83a7179..9781359b5d 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/exportitems/verifypassword/VerifyPasswordScreen.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/exportitems/verifypassword/VerifyPasswordScreen.kt @@ -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( diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/exportitems/verifypassword/VerifyPasswordViewModel.kt b/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/exportitems/verifypassword/VerifyPasswordViewModel.kt index 7d694dc290..7946cfd878 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/exportitems/verifypassword/VerifyPasswordViewModel.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/exportitems/verifypassword/VerifyPasswordViewModel.kt @@ -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. */ diff --git a/app/src/test/kotlin/com/x8bit/bitwarden/ui/platform/feature/rootnav/RootNavScreenTest.kt b/app/src/test/kotlin/com/x8bit/bitwarden/ui/platform/feature/rootnav/RootNavScreenTest.kt index 7557c3becb..a3a7693cca 100644 --- a/app/src/test/kotlin/com/x8bit/bitwarden/ui/platform/feature/rootnav/RootNavScreenTest.kt +++ b/app/src/test/kotlin/com/x8bit/bitwarden/ui/platform/feature/rootnav/RootNavScreenTest.kt @@ -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, + ) + } + } } } diff --git a/app/src/test/kotlin/com/x8bit/bitwarden/ui/platform/feature/rootnav/RootNavViewModelTest.kt b/app/src/test/kotlin/com/x8bit/bitwarden/ui/platform/feature/rootnav/RootNavViewModelTest.kt index 6583ccea42..ea4fab0f3a 100644 --- a/app/src/test/kotlin/com/x8bit/bitwarden/ui/platform/feature/rootnav/RootNavViewModelTest.kt +++ b/app/src/test/kotlin/com/x8bit/bitwarden/ui/platform/feature/rootnav/RootNavViewModelTest.kt @@ -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, + ), + ), +) diff --git a/app/src/test/kotlin/com/x8bit/bitwarden/ui/vault/feature/exportitems/verifypassword/VerifyPasswordScreenTest.kt b/app/src/test/kotlin/com/x8bit/bitwarden/ui/vault/feature/exportitems/verifypassword/VerifyPasswordScreenTest.kt index f654b7d46b..ef335c579e 100644 --- a/app/src/test/kotlin/com/x8bit/bitwarden/ui/vault/feature/exportitems/verifypassword/VerifyPasswordScreenTest.kt +++ b/app/src/test/kotlin/com/x8bit/bitwarden/ui/vault/feature/exportitems/verifypassword/VerifyPasswordScreenTest.kt @@ -297,4 +297,5 @@ private val DEFAULT_STATE = VerifyPasswordState( accountSummaryListItem = DEFAULT_ACCOUNT_SELECTION_LIST_ITEM, input = "", dialog = null, + hasOtherAccounts = true, ) diff --git a/app/src/test/kotlin/com/x8bit/bitwarden/ui/vault/feature/exportitems/verifypassword/VerifyPasswordViewModelTest.kt b/app/src/test/kotlin/com/x8bit/bitwarden/ui/vault/feature/exportitems/verifypassword/VerifyPasswordViewModelTest.kt index fd303f696a..9f713293b0 100644 --- a/app/src/test/kotlin/com/x8bit/bitwarden/ui/vault/feature/exportitems/verifypassword/VerifyPasswordViewModelTest.kt +++ b/app/src/test/kotlin/com/x8bit/bitwarden/ui/vault/feature/exportitems/verifypassword/VerifyPasswordViewModelTest.kt @@ -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,