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 @@ -65,6 +65,7 @@ import com.x8bit.bitwarden.ui.tools.feature.send.addedit.navigateToAddEditSend
import com.x8bit.bitwarden.ui.vault.feature.addedit.VaultAddEditArgs
import com.x8bit.bitwarden.ui.vault.feature.addedit.navigateToVaultAddEdit
import com.x8bit.bitwarden.ui.vault.feature.addedit.util.toVaultItemCipherType
import com.x8bit.bitwarden.ui.vault.feature.exportitems.ExportItemsRoute
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.itemlisting.navigateToVaultItemListingAsRoot
Expand Down Expand Up @@ -113,7 +114,7 @@ fun RootNavScreen(
setupBrowserAutofillDestination()
setupAutoFillDestinationAsRoot()
setupCompleteDestination()
exportItemsGraph()
exportItemsGraph(navController)
}

val targetRoute = when (state) {
Expand All @@ -139,9 +140,9 @@ fun RootNavScreen(
is RootNavState.VaultUnlockedForFido2Assertion,
is RootNavState.VaultUnlockedForPasswordGet,
is RootNavState.VaultUnlockedForProviderGetCredentials,
is RootNavState.CredentialExchangeExport,
-> VaultUnlockedGraphRoute

is RootNavState.CredentialExchangeExport -> ExportItemsRoute
RootNavState.OnboardingAccountLockSetup -> SetupUnlockRoute.AsRoot
RootNavState.OnboardingAutoFillSetup -> SetupAutofillRoute.AsRoot
RootNavState.OnboardingBrowserAutofillSetup -> SetupBrowserAutofillRoute
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ import androidx.navigation.navigation
import com.bitwarden.annotation.OmitFromCoverage
import com.x8bit.bitwarden.ui.vault.feature.exportitems.selectaccount.SelectAccountRoute
import com.x8bit.bitwarden.ui.vault.feature.exportitems.selectaccount.selectAccountDestination
import com.x8bit.bitwarden.ui.vault.feature.exportitems.verifypassword.navigateToVerifyPassword
import com.x8bit.bitwarden.ui.vault.feature.exportitems.verifypassword.verifyPasswordDestination
import kotlinx.serialization.Serializable

/**
Expand All @@ -21,13 +23,21 @@ data object ExportItemsRoute
/**
* Add export items destinations to the nav graph.
*/
fun NavGraphBuilder.exportItemsGraph() {
fun NavGraphBuilder.exportItemsGraph(
navController: NavController,
) {
navigation<ExportItemsRoute>(
startDestination = SelectAccountRoute,
) {
selectAccountDestination(
onAccountSelected = {
// TODO: [PM-26110] Navigate to verify password screen.
navController.navigateToVerifyPassword(userId = it)
},
)
verifyPasswordDestination(
onNavigateBack = { navController.popBackStack() },
onPasswordVerified = {
// TODO: [PM-26111] Navigate to confirm export screen.
},
)
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
@file:OmitFromCoverage

package com.x8bit.bitwarden.ui.vault.feature.exportitems.verifypassword

import android.os.Parcelable
import androidx.lifecycle.SavedStateHandle
import androidx.navigation.NavController
import androidx.navigation.NavGraphBuilder
import androidx.navigation.NavOptions
import androidx.navigation.toRoute
import com.bitwarden.annotation.OmitFromCoverage
import com.bitwarden.ui.platform.base.util.composableWithPushTransitions
import com.bitwarden.ui.platform.util.ParcelableRouteSerializer
import kotlinx.parcelize.Parcelize
import kotlinx.serialization.Serializable

/**
* The type-safe route for the verify password screen.
*/
@Parcelize
@Serializable(with = VerifyPasswordRoute.Serializer::class)
@OmitFromCoverage
data class VerifyPasswordRoute(
val userId: String,
Copy link
Collaborator

Choose a reason for hiding this comment

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

Can you explain this a bit more? I would expect the screen to be displayed for the active user and switch the user if needed. Specifying the userId could lead to a misconfiguration that the repositories are not equipped to deal with since they assume the active user when doing most operations.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

This informs the screen which account's password needs to be verified. Account switching does take place prior to performing password validation, if it's needed.

Copy link
Collaborator

Choose a reason for hiding this comment

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

If the account switching already happened, do we need this value?

Can we just use the activeAccount?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Discussed offline and improved documentation.

Since account switching can be a costly operation when the vault is already unlocked, switching is deferred until absolutely necessary to perform password verification. This eliminates unnecessary account switching when the user navigates between Select Account and Verify Password screens.

) : Parcelable {

/**
* Custom serializer to support polymorphic routes.
*/
class Serializer : ParcelableRouteSerializer<VerifyPasswordRoute>(
kClass = VerifyPasswordRoute::class,
)
}

/**
* Class to retrieve verify password arguments from the [SavedStateHandle].
*/
@OmitFromCoverage
data class VerifyPasswordArgs(
val userId: String,
)

/**
* Constructs a [VerifyPasswordArgs] from the [SavedStateHandle] and internal route data.
*/
fun SavedStateHandle.toVerifyPasswordArgs(): VerifyPasswordArgs {
val route = this.toRoute<VerifyPasswordRoute>()
return VerifyPasswordArgs(
userId = route.userId,
)
}

/**
* Add the [VerifyPasswordScreen] to the nav graph.
*/
fun NavGraphBuilder.verifyPasswordDestination(
onNavigateBack: () -> Unit,
onPasswordVerified: (userId: String) -> Unit,
) {
composableWithPushTransitions<VerifyPasswordRoute> {
VerifyPasswordScreen(
onNavigateBack = onNavigateBack,
onPasswordVerified = onPasswordVerified,
)
}
}

/**
* Navigate to the [VerifyPasswordScreen].
*/
fun NavController.navigateToVerifyPassword(
userId: String,
navOptions: NavOptions? = null,
) {
navigate(
route = VerifyPasswordRoute(userId = userId),
navOptions = navOptions,
)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,201 @@
package com.x8bit.bitwarden.ui.vault.feature.exportitems.verifypassword

import androidx.compose.foundation.layout.Column
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.rememberScrollState
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.verticalScroll
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.res.stringResource
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.bitwarden.ui.platform.base.util.EventsEffect
import com.bitwarden.ui.platform.base.util.standardHorizontalMargin
import com.bitwarden.ui.platform.components.button.BitwardenFilledButton
import com.bitwarden.ui.platform.components.dialog.BitwardenBasicDialog
import com.bitwarden.ui.platform.components.dialog.BitwardenLoadingDialog
import com.bitwarden.ui.platform.components.field.BitwardenPasswordField
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.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.verifypassword.handlers.rememberVerifyPasswordHandler

/**
* Top level composable for the Verify Password screen.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun VerifyPasswordScreen(
onNavigateBack: () -> Unit,
onPasswordVerified: (userId: String) -> Unit,
viewModel: VerifyPasswordViewModel = hiltViewModel(),
) {
val state by viewModel.stateFlow.collectAsStateWithLifecycle()
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior(rememberTopAppBarState())
val handler = rememberVerifyPasswordHandler(viewModel)

EventsEffect(viewModel) { event ->
when (event) {
VerifyPasswordEvent.NavigateBack -> onNavigateBack()

is VerifyPasswordEvent.PasswordVerified -> {
onPasswordVerified(event.userId)
}
}
}

VerifyPasswordDialogs(
dialog = state.dialog,
onDismiss = handler.onDismissDialog,
)

ExportItemsScaffold(
navIcon = rememberVectorPainter(
BitwardenDrawable.ic_back,
),
onNavigationIconClick = handler.onNavigateBackClick,
navigationIconContentDescription = stringResource(BitwardenString.back),
scrollBehavior = scrollBehavior,
modifier = Modifier.fillMaxSize(),
) {
VerifyPasswordContent(
state = state,
onInputChanged = handler.onInputChanged,
onUnlockClick = handler.onUnlockClick,
modifier = Modifier
.fillMaxSize()
.standardHorizontalMargin(),
)
}
}

@Composable
private fun VerifyPasswordDialogs(
dialog: VerifyPasswordState.DialogState?,
onDismiss: () -> Unit,
) {
when (dialog) {
is VerifyPasswordState.DialogState.General -> {
BitwardenBasicDialog(
title = dialog.title(),
message = dialog.message(),
throwable = dialog.error,
onDismissRequest = onDismiss,
)
}

is VerifyPasswordState.DialogState.Loading -> {
BitwardenLoadingDialog(text = dialog.message())
}

null -> Unit
}
}

@Composable
private fun VerifyPasswordContent(
state: VerifyPasswordState,
onInputChanged: (String) -> Unit,
onUnlockClick: () -> Unit,
modifier: Modifier = Modifier,
) {
Column(
modifier = modifier
.fillMaxSize()
.verticalScroll(rememberScrollState()),
) {
Spacer(Modifier.height(24.dp))

Text(
text = stringResource(BitwardenString.verify_your_master_password),
textAlign = TextAlign.Center,
style = BitwardenTheme.typography.titleMedium,
modifier = Modifier.fillMaxWidth(),
)

Spacer(Modifier.height(16.dp))

AccountSummaryListItem(
item = state.accountSummaryListItem,
cardStyle = CardStyle.Full,
clickable = false,
modifier = Modifier.fillMaxWidth(),
)

Spacer(Modifier.height(16.dp))

BitwardenPasswordField(
label = stringResource(BitwardenString.master_password),
value = state.input,
onValueChange = onInputChanged,
showPasswordTestTag = "PasswordVisibilityToggle",
imeAction = ImeAction.Done,
keyboardActions = KeyboardActions(
onDone = {
if (state.isUnlockButtonEnabled) {
onUnlockClick()
} else {
defaultKeyboardAction(ImeAction.Done)
Copy link
Collaborator

Choose a reason for hiding this comment

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

๐Ÿ‘

}
},
),
supportingText = stringResource(BitwardenString.vault_locked_master_password),
passwordFieldTestTag = "MasterPasswordEntry",
cardStyle = CardStyle.Full,
modifier = Modifier.fillMaxWidth(),
)

Spacer(Modifier.height(16.dp))

BitwardenFilledButton(
label = stringResource(BitwardenString.unlock),
onClick = onUnlockClick,
isEnabled = state.isUnlockButtonEnabled,
modifier = Modifier.fillMaxWidth(),
)

Spacer(Modifier.height(12.dp))
}
}

@OptIn(ExperimentalMaterial3Api::class)
@Preview(showBackground = true)
@Composable
private fun VerifyPasswordContent_Preview() {
val accountSummaryListItem = AccountSelectionListItem(
userId = "userId",
isItemRestricted = false,
avatarColorHex = "#FF0000",
initials = "JD",
email = "john.doe@example.com",
)
val state = VerifyPasswordState(
accountSummaryListItem = accountSummaryListItem,
)
VerifyPasswordContent(
state = state,
onInputChanged = {},
onUnlockClick = {},
modifier = Modifier
.fillMaxSize()
.standardHorizontalMargin(),
)
}
Loading