Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -288,6 +292,13 @@ fun RootNavScreen(
is RootNavState.CredentialExchangeExport -> {
navController.navigateToExportItemsGraph(rootNavOptions)
}

is RootNavState.CredentialExchangeExportSkipAccountSelection -> {
navController.navigateToVerifyPassword(
userId = currentState.userId,
navOptions = rootNavOptions,
)
}
}
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,7 @@ class RootNavViewModel @Inject constructor(
}
}

@Suppress("CyclomaticComplexMethod", "MaxLineLength", "LongMethod")
@Suppress("CyclomaticComplexMethod", "LongMethod")
private fun handleUserStateUpdateReceive(
action: RootNavAction.Internal.UserStateUpdateReceive,
) {
Expand Down Expand Up @@ -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 &&
Expand Down Expand Up @@ -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()
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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()
Expand All @@ -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.",
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Does this message get displayed anywhere?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yes, it appears on the error stacktrace of the app that is trying to import.
Screenshot_1761070921

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

OK, I wasn't sure if we needed this to be localized. If it's just part of the stacktrace, this is fine.

),
),
)
}

is VerifyPasswordEvent.PasswordVerified -> {
onPasswordVerified(event.userId)
Expand All @@ -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),
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -303,6 +324,7 @@ private fun VerifyPasswordContent_Otp_preview() {
.asText(),
accountSummaryListItem = accountSummaryListItem,
showResendCodeButton = true,
hasOtherAccounts = true,
)
ExportItemsScaffold(
navIcon = rememberVectorPainter(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
Expand All @@ -81,6 +87,7 @@ class VerifyPasswordViewModel @Inject constructor(
.any { it.id in restrictedItemPolicyOrgIds },
),
showResendCodeButton = !account.hasMasterPassword,
hasOtherAccounts = !singleAccount,
)
},
) {
Expand Down Expand Up @@ -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() {
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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.
*/
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
)
}
}
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1441,14 +1441,34 @@ 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,
viewModel.stateFlow.value,
)
}

@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,
Expand Down Expand Up @@ -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,
),
),
)
Original file line number Diff line number Diff line change
Expand Up @@ -297,4 +297,5 @@ private val DEFAULT_STATE = VerifyPasswordState(
accountSummaryListItem = DEFAULT_ACCOUNT_SELECTION_LIST_ITEM,
input = "",
dialog = null,
hasOtherAccounts = true,
)
Loading
Loading