mirror of
https://github.com/bitwarden/android.git
synced 2026-03-12 05:04:17 -05:00
[PM-26110] Add verify password screen for item export (#5935)
This commit is contained in:
@@ -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
|
||||
@@ -113,7 +114,7 @@ fun RootNavScreen(
|
||||
setupBrowserAutofillDestinationAsRoot()
|
||||
setupAutoFillDestinationAsRoot()
|
||||
setupCompleteDestination()
|
||||
exportItemsGraph()
|
||||
exportItemsGraph(navController)
|
||||
}
|
||||
|
||||
val targetRoute = when (state) {
|
||||
@@ -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.AsRoot
|
||||
|
||||
@@ -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
|
||||
|
||||
/**
|
||||
@@ -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.
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
) : 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,
|
||||
)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
},
|
||||
),
|
||||
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(),
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,392 @@
|
||||
package com.x8bit.bitwarden.ui.vault.feature.exportitems.verifypassword
|
||||
|
||||
import android.os.Parcelable
|
||||
import androidx.lifecycle.SavedStateHandle
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.bitwarden.network.model.PolicyTypeJson
|
||||
import com.bitwarden.ui.platform.base.BaseViewModel
|
||||
import com.bitwarden.ui.platform.resource.BitwardenString
|
||||
import com.bitwarden.ui.util.Text
|
||||
import com.bitwarden.ui.util.asText
|
||||
import com.x8bit.bitwarden.data.auth.repository.AuthRepository
|
||||
import com.x8bit.bitwarden.data.auth.repository.model.SwitchAccountResult
|
||||
import com.x8bit.bitwarden.data.auth.repository.model.ValidatePasswordResult
|
||||
import com.x8bit.bitwarden.data.platform.manager.PolicyManager
|
||||
import com.x8bit.bitwarden.data.vault.repository.VaultRepository
|
||||
import com.x8bit.bitwarden.data.vault.repository.model.VaultUnlockResult
|
||||
import com.x8bit.bitwarden.ui.vault.feature.exportitems.model.AccountSelectionListItem
|
||||
import com.x8bit.bitwarden.ui.vault.feature.vault.util.initials
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import jakarta.inject.Inject
|
||||
import kotlinx.coroutines.flow.launchIn
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.parcelize.Parcelize
|
||||
|
||||
private const val KEY_STATE = "state"
|
||||
|
||||
/**
|
||||
* ViewModel for the VerifyPassword screen.
|
||||
*
|
||||
* This view model does not assume password verification is requested for the active user. Switching
|
||||
* to the provided account is deferred until password verification is explicitly requested. This is
|
||||
* done to reduce the number of times account switching is performed, since it can be a costly
|
||||
* operation.
|
||||
*/
|
||||
@Suppress("TooManyFunctions")
|
||||
@HiltViewModel
|
||||
class VerifyPasswordViewModel @Inject constructor(
|
||||
private val authRepository: AuthRepository,
|
||||
private val vaultRepository: VaultRepository,
|
||||
private val policyManager: PolicyManager,
|
||||
savedStateHandle: SavedStateHandle,
|
||||
) : BaseViewModel<VerifyPasswordState, VerifyPasswordEvent, VerifyPasswordAction>(
|
||||
initialState = savedStateHandle[KEY_STATE]
|
||||
?: run {
|
||||
val args = savedStateHandle.toVerifyPasswordArgs()
|
||||
val account = authRepository
|
||||
.userStateFlow
|
||||
.value
|
||||
?.accounts
|
||||
?.firstOrNull { it.userId == args.userId }
|
||||
?: throw IllegalStateException("Account not found")
|
||||
|
||||
val restrictedItemPolicyOrgIds = policyManager
|
||||
.getActivePolicies(PolicyTypeJson.RESTRICT_ITEM_TYPES)
|
||||
.filter { it.isEnabled }
|
||||
.map { it.organizationId }
|
||||
|
||||
VerifyPasswordState(
|
||||
accountSummaryListItem = AccountSelectionListItem(
|
||||
userId = args.userId,
|
||||
avatarColorHex = account.avatarColorHex,
|
||||
email = account.email,
|
||||
initials = account.initials,
|
||||
isItemRestricted = account
|
||||
.organizations
|
||||
.any { it.id in restrictedItemPolicyOrgIds },
|
||||
),
|
||||
)
|
||||
},
|
||||
) {
|
||||
|
||||
init {
|
||||
// As state updates, write to saved state handle.
|
||||
stateFlow
|
||||
.onEach { savedStateHandle[KEY_STATE] = it }
|
||||
.launchIn(viewModelScope)
|
||||
}
|
||||
|
||||
override fun handleAction(action: VerifyPasswordAction) {
|
||||
when (action) {
|
||||
VerifyPasswordAction.NavigateBackClick -> {
|
||||
handleNavigateBackClick()
|
||||
}
|
||||
|
||||
VerifyPasswordAction.UnlockClick -> {
|
||||
handleUnlockClick()
|
||||
}
|
||||
|
||||
is VerifyPasswordAction.PasswordInputChangeReceive -> {
|
||||
handlePasswordInputChange(action)
|
||||
}
|
||||
|
||||
VerifyPasswordAction.DismissDialog -> {
|
||||
handleDismissDialog()
|
||||
}
|
||||
|
||||
is VerifyPasswordAction.Internal -> {
|
||||
handleInternalAction(action)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleNavigateBackClick() {
|
||||
sendEvent(VerifyPasswordEvent.NavigateBack)
|
||||
}
|
||||
|
||||
private fun handleUnlockClick() {
|
||||
if (state.input.isBlank()) {
|
||||
mutableStateFlow.update {
|
||||
it.copy(
|
||||
dialog = VerifyPasswordState.DialogState.General(
|
||||
title = BitwardenString.an_error_has_occurred.asText(),
|
||||
message = BitwardenString.validation_field_required.asText(
|
||||
BitwardenString.master_password.asText(),
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
mutableStateFlow.update {
|
||||
it.copy(
|
||||
dialog = VerifyPasswordState.DialogState.Loading(
|
||||
message = BitwardenString.loading.asText(),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
if (authRepository.activeUserId != state.accountSummaryListItem.userId) {
|
||||
switchAccountAndVerifyPassword()
|
||||
} else {
|
||||
validatePassword()
|
||||
}
|
||||
}
|
||||
|
||||
private fun handlePasswordInputChange(
|
||||
action: VerifyPasswordAction.PasswordInputChangeReceive,
|
||||
) {
|
||||
mutableStateFlow.update { it.copy(input = action.input) }
|
||||
}
|
||||
|
||||
private fun handleDismissDialog() {
|
||||
mutableStateFlow.update { it.copy(dialog = null) }
|
||||
}
|
||||
|
||||
private fun handleInternalAction(action: VerifyPasswordAction.Internal) {
|
||||
when (action) {
|
||||
is VerifyPasswordAction.Internal.ValidatePasswordResultReceive -> {
|
||||
handleValidatePasswordResultReceive(action)
|
||||
}
|
||||
|
||||
is VerifyPasswordAction.Internal.UnlockVaultResultReceive -> {
|
||||
handleUnlockVaultResultReceive(action)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleValidatePasswordResultReceive(
|
||||
action: VerifyPasswordAction.Internal.ValidatePasswordResultReceive,
|
||||
) {
|
||||
mutableStateFlow.update { it.copy(dialog = null) }
|
||||
when (action.result) {
|
||||
is ValidatePasswordResult.Success -> {
|
||||
if (action.result.isValid) {
|
||||
sendEvent(
|
||||
VerifyPasswordEvent.PasswordVerified(
|
||||
state.accountSummaryListItem.userId,
|
||||
),
|
||||
)
|
||||
} else {
|
||||
showInvalidMasterPasswordDialog()
|
||||
}
|
||||
}
|
||||
|
||||
is ValidatePasswordResult.Error -> {
|
||||
showGenericErrorDialog(throwable = action.result.error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleUnlockVaultResultReceive(
|
||||
action: VerifyPasswordAction.Internal.UnlockVaultResultReceive,
|
||||
) {
|
||||
mutableStateFlow.update { it.copy(dialog = null) }
|
||||
when (action.vaultUnlockResult) {
|
||||
VaultUnlockResult.Success -> {
|
||||
// A successful unlock result means the provided password is correct so we can
|
||||
// consider the password verified and send the event.
|
||||
sendEvent(
|
||||
VerifyPasswordEvent.PasswordVerified(
|
||||
state.accountSummaryListItem.userId,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
is VaultUnlockResult.AuthenticationError -> {
|
||||
showInvalidMasterPasswordDialog(
|
||||
throwable = action.vaultUnlockResult.error,
|
||||
)
|
||||
}
|
||||
|
||||
is VaultUnlockResult.InvalidStateError,
|
||||
is VaultUnlockResult.BiometricDecodingError,
|
||||
is VaultUnlockResult.GenericError,
|
||||
-> {
|
||||
showGenericErrorDialog(throwable = action.vaultUnlockResult.error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun switchAccountAndVerifyPassword() {
|
||||
val switchAccountResult = authRepository
|
||||
.switchAccount(userId = state.accountSummaryListItem.userId)
|
||||
|
||||
when (switchAccountResult) {
|
||||
SwitchAccountResult.AccountSwitched -> validatePassword()
|
||||
SwitchAccountResult.NoChange -> {
|
||||
mutableStateFlow.update {
|
||||
it.copy(
|
||||
dialog = VerifyPasswordState.DialogState.General(
|
||||
title = BitwardenString.an_error_has_occurred.asText(),
|
||||
message = BitwardenString.generic_error_message.asText(),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun validatePassword() {
|
||||
val userId = state.accountSummaryListItem.userId
|
||||
|
||||
viewModelScope.launch {
|
||||
if (vaultRepository.isVaultUnlocked(userId)) {
|
||||
// If the vault is already unlocked, validate the password directly.
|
||||
sendAction(
|
||||
VerifyPasswordAction.Internal.ValidatePasswordResultReceive(
|
||||
authRepository.validatePassword(password = state.input),
|
||||
),
|
||||
)
|
||||
} else {
|
||||
// Otherwise, unlock the vault with the provided password. The unlock result will
|
||||
// indicate whether the password is correct.
|
||||
sendAction(
|
||||
VerifyPasswordAction.Internal.UnlockVaultResultReceive(
|
||||
vaultRepository
|
||||
.unlockVaultWithMasterPassword(masterPassword = state.input),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun showInvalidMasterPasswordDialog(
|
||||
throwable: Throwable? = null,
|
||||
) {
|
||||
mutableStateFlow.update {
|
||||
it.copy(
|
||||
dialog = VerifyPasswordState.DialogState.General(
|
||||
title = BitwardenString.an_error_has_occurred.asText(),
|
||||
message = BitwardenString.invalid_master_password.asText(),
|
||||
error = throwable,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun showGenericErrorDialog(throwable: Throwable?) {
|
||||
mutableStateFlow.update {
|
||||
it.copy(
|
||||
dialog = VerifyPasswordState.DialogState.General(
|
||||
title = BitwardenString.an_error_has_occurred.asText(),
|
||||
message = BitwardenString.generic_error_message.asText(),
|
||||
error = throwable,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Represents the state of the VerifyPassword screen.
|
||||
* @param accountSummaryListItem The account summary to display.
|
||||
* @param input The current password input.
|
||||
* @param dialog The current dialog state, or null if no dialog is shown.
|
||||
*/
|
||||
@Parcelize
|
||||
data class VerifyPasswordState(
|
||||
val accountSummaryListItem: AccountSelectionListItem,
|
||||
val input: String = "",
|
||||
val dialog: DialogState? = null,
|
||||
) : Parcelable {
|
||||
|
||||
/**
|
||||
* Whether the unlock button should be enabled.
|
||||
*/
|
||||
val isUnlockButtonEnabled: Boolean
|
||||
get() = input.isNotBlank() && dialog !is DialogState.Loading
|
||||
|
||||
/**
|
||||
* Represents the state of a dialog.
|
||||
*/
|
||||
@Parcelize
|
||||
sealed class DialogState : Parcelable {
|
||||
/**
|
||||
* Represents a general dialog with a title, message, and optional error.
|
||||
* @param title The dialog title.
|
||||
* @param message The dialog message.
|
||||
* @param error An optional error associated with the dialog.
|
||||
*/
|
||||
data class General(
|
||||
val title: Text,
|
||||
val message: Text,
|
||||
val error: Throwable? = null,
|
||||
) : DialogState()
|
||||
|
||||
/**
|
||||
* Represents a loading dialog with a message.
|
||||
* @param message The loading message.
|
||||
*/
|
||||
data class Loading(
|
||||
val message: Text,
|
||||
) : DialogState()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Represents events that can be emitted from the VerifyPasswordViewModel.
|
||||
*/
|
||||
sealed class VerifyPasswordEvent {
|
||||
/**
|
||||
* Indicates a request to navigate back.
|
||||
*/
|
||||
data object NavigateBack : VerifyPasswordEvent()
|
||||
|
||||
/**
|
||||
* Indicates that the password has been successfully verified.
|
||||
* @param userId The ID of the user whose password was verified.
|
||||
*/
|
||||
data class PasswordVerified(val userId: String) : VerifyPasswordEvent()
|
||||
}
|
||||
|
||||
/**
|
||||
* Represents actions that can be handled by the VerifyPasswordViewModel.
|
||||
*/
|
||||
sealed class VerifyPasswordAction {
|
||||
/**
|
||||
* Represents a click on the navigate back button.
|
||||
*/
|
||||
data object NavigateBackClick : VerifyPasswordAction()
|
||||
|
||||
/**
|
||||
* Represents a click on the unlock button.
|
||||
*/
|
||||
data object UnlockClick : VerifyPasswordAction()
|
||||
|
||||
/**
|
||||
* Dismiss the current dialog.
|
||||
*/
|
||||
data object DismissDialog : VerifyPasswordAction()
|
||||
|
||||
/**
|
||||
* Represents a change in the password input.
|
||||
* @param input The new password input.
|
||||
*/
|
||||
data class PasswordInputChangeReceive(val input: String) : VerifyPasswordAction()
|
||||
|
||||
/**
|
||||
* Represents internal actions that the VerifyPasswordViewModel itself may send.
|
||||
*/
|
||||
sealed class Internal : VerifyPasswordAction() {
|
||||
|
||||
/**
|
||||
* Represents a result of validating the password.
|
||||
* @param result The result of validating the password.
|
||||
*/
|
||||
data class ValidatePasswordResultReceive(
|
||||
val result: ValidatePasswordResult,
|
||||
) : Internal()
|
||||
|
||||
/**
|
||||
* Represents a result of unlocking the vault.
|
||||
* @param vaultUnlockResult The result of unlocking the vault.
|
||||
*/
|
||||
data class UnlockVaultResultReceive(
|
||||
val vaultUnlockResult: VaultUnlockResult,
|
||||
) : Internal()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
package com.x8bit.bitwarden.ui.vault.feature.exportitems.verifypassword.handlers
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import com.x8bit.bitwarden.ui.vault.feature.exportitems.verifypassword.VerifyPasswordAction
|
||||
import com.x8bit.bitwarden.ui.vault.feature.exportitems.verifypassword.VerifyPasswordViewModel
|
||||
|
||||
/**
|
||||
* A handler for the VerifyPassword screen interactions.
|
||||
*/
|
||||
data class VerifyPasswordHandlers(
|
||||
val onNavigateBackClick: () -> Unit,
|
||||
val onUnlockClick: () -> Unit,
|
||||
val onInputChanged: (String) -> Unit,
|
||||
val onDismissDialog: () -> Unit,
|
||||
) {
|
||||
|
||||
@Suppress("UndocumentedPublicClass")
|
||||
companion object {
|
||||
|
||||
/**
|
||||
* Creates a [VerifyPasswordHandlers] from a [VerifyPasswordViewModel].
|
||||
*/
|
||||
fun create(viewModel: VerifyPasswordViewModel): VerifyPasswordHandlers =
|
||||
VerifyPasswordHandlers(
|
||||
onNavigateBackClick = {
|
||||
viewModel.trySendAction(VerifyPasswordAction.NavigateBackClick)
|
||||
},
|
||||
onUnlockClick = {
|
||||
viewModel.trySendAction(VerifyPasswordAction.UnlockClick)
|
||||
},
|
||||
onInputChanged = {
|
||||
viewModel.trySendAction(
|
||||
VerifyPasswordAction.PasswordInputChangeReceive(it),
|
||||
)
|
||||
},
|
||||
onDismissDialog = {
|
||||
viewModel.trySendAction(VerifyPasswordAction.DismissDialog)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper function to remember a [VerifyPasswordHandlers] instance in a [Composable] scope.
|
||||
*/
|
||||
@Composable
|
||||
fun rememberVerifyPasswordHandler(viewModel: VerifyPasswordViewModel): VerifyPasswordHandlers =
|
||||
remember(viewModel) { VerifyPasswordHandlers.create(viewModel) }
|
||||
@@ -0,0 +1,265 @@
|
||||
package com.x8bit.bitwarden.ui.vault.feature.exportitems.verifypassword
|
||||
|
||||
import androidx.compose.ui.test.assertIsEnabled
|
||||
import androidx.compose.ui.test.assertIsNotEnabled
|
||||
import androidx.compose.ui.test.filterToOne
|
||||
import androidx.compose.ui.test.hasAnyAncestor
|
||||
import androidx.compose.ui.test.isDialog
|
||||
import androidx.compose.ui.test.isDisplayed
|
||||
import androidx.compose.ui.test.onAllNodesWithText
|
||||
import androidx.compose.ui.test.onNodeWithContentDescription
|
||||
import androidx.compose.ui.test.onNodeWithTag
|
||||
import androidx.compose.ui.test.onNodeWithText
|
||||
import androidx.compose.ui.test.performClick
|
||||
import androidx.compose.ui.test.performTextInput
|
||||
import com.bitwarden.core.data.repository.util.bufferedMutableSharedFlow
|
||||
import com.bitwarden.data.repository.model.Environment
|
||||
import com.bitwarden.network.model.OrganizationType
|
||||
import com.bitwarden.ui.util.asText
|
||||
import com.x8bit.bitwarden.data.auth.datasource.disk.model.OnboardingStatus
|
||||
import com.x8bit.bitwarden.data.auth.repository.model.Organization
|
||||
import com.x8bit.bitwarden.data.auth.repository.model.UserState
|
||||
import com.x8bit.bitwarden.data.platform.manager.model.FirstTimeState
|
||||
import com.x8bit.bitwarden.ui.platform.base.BitwardenComposeTest
|
||||
import com.x8bit.bitwarden.ui.vault.feature.exportitems.model.AccountSelectionListItem
|
||||
import com.x8bit.bitwarden.ui.vault.feature.vault.util.initials
|
||||
import io.mockk.every
|
||||
import io.mockk.just
|
||||
import io.mockk.mockk
|
||||
import io.mockk.runs
|
||||
import io.mockk.verify
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
|
||||
class VerifyPasswordScreenTest : BitwardenComposeTest() {
|
||||
|
||||
private var onNavigateBackClicked: Boolean = false
|
||||
private var onPasswordVerifiedClicked: Boolean = false
|
||||
private val onPasswordVerifiedArgSlot = mutableListOf<String>()
|
||||
|
||||
private val mockStateFlow = MutableStateFlow(DEFAULT_STATE)
|
||||
private val mockEventFlow = bufferedMutableSharedFlow<VerifyPasswordEvent>()
|
||||
private val viewModel = mockk<VerifyPasswordViewModel> {
|
||||
every { stateFlow } returns mockStateFlow
|
||||
every { eventFlow } returns mockEventFlow
|
||||
every { trySendAction(any()) } just runs
|
||||
}
|
||||
|
||||
@Before
|
||||
fun verifyPasswordScreen() {
|
||||
setContent {
|
||||
VerifyPasswordScreen(
|
||||
onNavigateBack = { onNavigateBackClicked = true },
|
||||
onPasswordVerified = { userId ->
|
||||
onPasswordVerifiedClicked = true
|
||||
onPasswordVerifiedArgSlot.add(userId)
|
||||
},
|
||||
viewModel = viewModel,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `initial state should be correct`() = runTest {
|
||||
composeTestRule
|
||||
.onNodeWithText("Verify your master password")
|
||||
.isDisplayed()
|
||||
|
||||
composeTestRule
|
||||
.onNodeWithText("AU")
|
||||
.isDisplayed()
|
||||
|
||||
composeTestRule
|
||||
.onNodeWithText("active@bitwarden.com")
|
||||
.isDisplayed()
|
||||
|
||||
composeTestRule
|
||||
.onNodeWithText("You vault is locked. Verify your master password to continue.")
|
||||
|
||||
composeTestRule
|
||||
.onNodeWithText("Unlock")
|
||||
.assertIsNotEnabled()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `input should update based on state`() = runTest {
|
||||
composeTestRule
|
||||
.onNodeWithText("Master password")
|
||||
.performTextInput("abc123")
|
||||
|
||||
composeTestRule
|
||||
.onNodeWithTag("PasswordVisibilityToggle")
|
||||
.performClick()
|
||||
|
||||
composeTestRule
|
||||
.onNodeWithText("abc123")
|
||||
.isDisplayed()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `input change should send PasswordInputChangeReceive action`() = runTest {
|
||||
composeTestRule
|
||||
.onNodeWithText("Master password")
|
||||
.performTextInput("abc123")
|
||||
|
||||
composeTestRule
|
||||
.onNodeWithTag("PasswordVisibilityToggle")
|
||||
.performClick()
|
||||
verify {
|
||||
viewModel.trySendAction(
|
||||
VerifyPasswordAction.PasswordInputChangeReceive("abc123"),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Unlock button should should update based on input`() = runTest {
|
||||
composeTestRule
|
||||
.onNodeWithText("Unlock")
|
||||
.assertIsNotEnabled()
|
||||
|
||||
mockStateFlow.emit(DEFAULT_STATE.copy(input = "abc123"))
|
||||
|
||||
composeTestRule
|
||||
.onNodeWithText("Unlock")
|
||||
.assertIsEnabled()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Unlock button should send UnlockClick action`() = runTest {
|
||||
mockStateFlow.emit(DEFAULT_STATE.copy(input = "abc123"))
|
||||
composeTestRule
|
||||
.onNodeWithText("Unlock")
|
||||
.performClick()
|
||||
verify {
|
||||
viewModel.trySendAction(VerifyPasswordAction.UnlockClick)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `back click should send NavigateBackClick action`() = runTest {
|
||||
composeTestRule
|
||||
.onNodeWithContentDescription("Back")
|
||||
.performClick()
|
||||
verify {
|
||||
viewModel.trySendAction(VerifyPasswordAction.NavigateBackClick)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `NavigateBack event should trigger onNavigateBack`() = runTest {
|
||||
mockEventFlow.emit(VerifyPasswordEvent.NavigateBack)
|
||||
assertTrue(onNavigateBackClicked)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `PasswordVerified event should call onPasswordVerified with userId`() = runTest {
|
||||
mockEventFlow.emit(VerifyPasswordEvent.PasswordVerified(DEFAULT_USER_ID))
|
||||
assertTrue(onPasswordVerifiedClicked)
|
||||
assertEquals(1, onPasswordVerifiedArgSlot.size)
|
||||
assertEquals(DEFAULT_USER_ID, onPasswordVerifiedArgSlot.first())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `General dialog should display based on state`() = runTest {
|
||||
mockStateFlow.emit(
|
||||
DEFAULT_STATE.copy(
|
||||
dialog = VerifyPasswordState.DialogState.General(
|
||||
title = "title".asText(),
|
||||
message = "message".asText(),
|
||||
error = null,
|
||||
),
|
||||
),
|
||||
)
|
||||
composeTestRule
|
||||
.onAllNodesWithText("title")
|
||||
.filterToOne(hasAnyAncestor(isDialog()))
|
||||
.isDisplayed()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `General dialog dismiss should send DismissDialog action`() = runTest {
|
||||
mockStateFlow.emit(
|
||||
DEFAULT_STATE.copy(
|
||||
dialog = VerifyPasswordState.DialogState.General(
|
||||
title = "title".asText(),
|
||||
message = "message".asText(),
|
||||
error = null,
|
||||
),
|
||||
),
|
||||
)
|
||||
composeTestRule
|
||||
.onAllNodesWithText("Okay")
|
||||
.filterToOne(hasAnyAncestor(isDialog()))
|
||||
.performClick()
|
||||
verify {
|
||||
viewModel.trySendAction(VerifyPasswordAction.DismissDialog)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Loading dialog should display based on state`() = runTest {
|
||||
mockStateFlow.emit(
|
||||
DEFAULT_STATE.copy(
|
||||
dialog = VerifyPasswordState.DialogState.Loading("message".asText()),
|
||||
),
|
||||
)
|
||||
composeTestRule
|
||||
.onAllNodesWithText("message")
|
||||
.filterToOne(hasAnyAncestor(isDialog()))
|
||||
.isDisplayed()
|
||||
}
|
||||
}
|
||||
|
||||
private const val DEFAULT_USER_ID: String = "activeUserId"
|
||||
private const val DEFAULT_ORGANIZATION_ID: String = "activeOrganizationId"
|
||||
private val DEFAULT_USER_STATE = UserState(
|
||||
activeUserId = DEFAULT_USER_ID,
|
||||
accounts = listOf(
|
||||
UserState.Account(
|
||||
userId = "activeUserId",
|
||||
name = "Active User",
|
||||
email = "active@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",
|
||||
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(
|
||||
userId = DEFAULT_USER_ID,
|
||||
email = DEFAULT_USER_STATE.activeAccount.email,
|
||||
avatarColorHex = DEFAULT_USER_STATE.activeAccount.avatarColorHex,
|
||||
isItemRestricted = false,
|
||||
initials = DEFAULT_USER_STATE.activeAccount.initials,
|
||||
)
|
||||
private val DEFAULT_STATE = VerifyPasswordState(
|
||||
accountSummaryListItem = DEFAULT_ACCOUNT_SELECTION_LIST_ITEM,
|
||||
input = "",
|
||||
dialog = null,
|
||||
)
|
||||
@@ -0,0 +1,577 @@
|
||||
package com.x8bit.bitwarden.ui.vault.feature.exportitems.verifypassword
|
||||
|
||||
import androidx.lifecycle.SavedStateHandle
|
||||
import app.cash.turbine.test
|
||||
import com.bitwarden.data.repository.model.Environment
|
||||
import com.bitwarden.network.model.OrganizationType
|
||||
import com.bitwarden.network.model.PolicyTypeJson
|
||||
import com.bitwarden.network.model.createMockPolicy
|
||||
import com.bitwarden.ui.platform.base.BaseViewModelTest
|
||||
import com.bitwarden.ui.platform.resource.BitwardenString
|
||||
import com.bitwarden.ui.util.asText
|
||||
import com.x8bit.bitwarden.data.auth.datasource.disk.model.OnboardingStatus
|
||||
import com.x8bit.bitwarden.data.auth.repository.AuthRepository
|
||||
import com.x8bit.bitwarden.data.auth.repository.model.Organization
|
||||
import com.x8bit.bitwarden.data.auth.repository.model.SwitchAccountResult
|
||||
import com.x8bit.bitwarden.data.auth.repository.model.UserState
|
||||
import com.x8bit.bitwarden.data.auth.repository.model.ValidatePasswordResult
|
||||
import com.x8bit.bitwarden.data.platform.manager.PolicyManager
|
||||
import com.x8bit.bitwarden.data.platform.manager.model.FirstTimeState
|
||||
import com.x8bit.bitwarden.data.vault.repository.VaultRepository
|
||||
import com.x8bit.bitwarden.data.vault.repository.model.VaultUnlockResult
|
||||
import com.x8bit.bitwarden.ui.vault.feature.exportitems.model.AccountSelectionListItem
|
||||
import com.x8bit.bitwarden.ui.vault.feature.vault.util.initials
|
||||
import io.mockk.awaits
|
||||
import io.mockk.coEvery
|
||||
import io.mockk.coVerify
|
||||
import io.mockk.every
|
||||
import io.mockk.just
|
||||
import io.mockk.mockk
|
||||
import io.mockk.mockkStatic
|
||||
import io.mockk.unmockkStatic
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.jupiter.api.AfterEach
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.BeforeEach
|
||||
import org.junit.jupiter.api.Test
|
||||
|
||||
class VerifyPasswordViewModelTest : BaseViewModelTest() {
|
||||
|
||||
private val mutableUserStateFlow = MutableStateFlow(DEFAULT_USER_STATE)
|
||||
private val authRepository = mockk<AuthRepository> {
|
||||
every { userStateFlow } returns mutableUserStateFlow
|
||||
every { activeUserId } returns DEFAULT_USER_ID
|
||||
}
|
||||
private val vaultRepository = mockk<VaultRepository> {
|
||||
every { isVaultUnlocked(any()) } returns true
|
||||
coEvery {
|
||||
unlockVaultWithMasterPassword(masterPassword = any())
|
||||
} returns VaultUnlockResult.Success
|
||||
}
|
||||
private val policyManager = mockk<PolicyManager> {
|
||||
every { getActivePolicies(PolicyTypeJson.RESTRICT_ITEM_TYPES) } returns listOf(
|
||||
createMockPolicy(
|
||||
number = 1,
|
||||
organizationId = DEFAULT_ORGANIZATION_ID,
|
||||
isEnabled = false,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@BeforeEach
|
||||
fun setUp() {
|
||||
mockkStatic(
|
||||
SavedStateHandle::toVerifyPasswordArgs,
|
||||
)
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
fun tearDown() {
|
||||
unmockkStatic(
|
||||
SavedStateHandle::toVerifyPasswordArgs,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `initial state should be correct when account is not restricted`() = runTest {
|
||||
createViewModel()
|
||||
.also {
|
||||
assertEquals(
|
||||
VerifyPasswordState(
|
||||
AccountSelectionListItem(
|
||||
userId = DEFAULT_USER_ID,
|
||||
email = DEFAULT_USER_STATE.activeAccount.email,
|
||||
avatarColorHex = DEFAULT_USER_STATE.activeAccount.avatarColorHex,
|
||||
isItemRestricted = false,
|
||||
initials = DEFAULT_USER_STATE.activeAccount.initials,
|
||||
),
|
||||
),
|
||||
it.stateFlow.value,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `initial state should be correct when account has item restrictions`() = runTest {
|
||||
every {
|
||||
policyManager.getActivePolicies(PolicyTypeJson.RESTRICT_ITEM_TYPES)
|
||||
} returns listOf(
|
||||
createMockPolicy(
|
||||
number = 1,
|
||||
organizationId = DEFAULT_ORGANIZATION_ID,
|
||||
isEnabled = true,
|
||||
),
|
||||
)
|
||||
|
||||
createViewModel()
|
||||
.also {
|
||||
assertEquals(
|
||||
VerifyPasswordState(
|
||||
accountSummaryListItem = DEFAULT_ACCOUNT_SELECTION_LIST_ITEM
|
||||
.copy(isItemRestricted = true),
|
||||
),
|
||||
it.stateFlow.value,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `NavigateBackClick should send NavigateBack event`() = runTest {
|
||||
createViewModel().also {
|
||||
it.trySendAction(VerifyPasswordAction.NavigateBackClick)
|
||||
it.eventFlow.test {
|
||||
assertEquals(
|
||||
VerifyPasswordEvent.NavigateBack,
|
||||
awaitItem(),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `UnlockClick with empty input should show error dialog`() = runTest {
|
||||
createViewModel().also {
|
||||
it.trySendAction(VerifyPasswordAction.UnlockClick)
|
||||
it.stateFlow.test {
|
||||
assertEquals(
|
||||
DEFAULT_STATE.copy(
|
||||
dialog = VerifyPasswordState.DialogState.General(
|
||||
title = BitwardenString.an_error_has_occurred.asText(),
|
||||
message = BitwardenString.validation_field_required.asText(
|
||||
BitwardenString.master_password.asText(),
|
||||
),
|
||||
),
|
||||
),
|
||||
awaitItem(),
|
||||
)
|
||||
coVerify(exactly = 0) {
|
||||
authRepository.activeUserId
|
||||
authRepository.validatePassword(password = any())
|
||||
authRepository.switchAccount(userId = any())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("MaxLineLength")
|
||||
@Test
|
||||
fun `UnlockClick with non-empty input should show loading dialog, validate password and send validates password`() =
|
||||
runTest {
|
||||
val initialState = DEFAULT_STATE.copy(input = "mockInput")
|
||||
coEvery { authRepository.validatePassword(password = "mockInput") } just awaits
|
||||
|
||||
createViewModel(state = initialState).also { viewModel ->
|
||||
viewModel.trySendAction(VerifyPasswordAction.UnlockClick)
|
||||
|
||||
viewModel.stateFlow.test {
|
||||
assertEquals(
|
||||
initialState.copy(
|
||||
dialog = VerifyPasswordState.DialogState.Loading(
|
||||
message = BitwardenString.loading.asText(),
|
||||
),
|
||||
),
|
||||
awaitItem(),
|
||||
)
|
||||
}
|
||||
|
||||
coVerify(exactly = 1) {
|
||||
authRepository.activeUserId
|
||||
authRepository.validatePassword(password = "mockInput")
|
||||
}
|
||||
coVerify(exactly = 0) {
|
||||
authRepository.switchAccount(userId = any())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("MaxLineLength")
|
||||
@Test
|
||||
fun `UnlockClick with non-empty input should show loading dialog, switch accounts, then validate password when selected account is not active and switch is successful`() =
|
||||
runTest {
|
||||
val initialState = DEFAULT_STATE.copy(
|
||||
accountSummaryListItem = DEFAULT_ACCOUNT_SELECTION_LIST_ITEM
|
||||
.copy(userId = "otherUserId"),
|
||||
input = "mockInput",
|
||||
)
|
||||
every {
|
||||
authRepository.switchAccount("otherUserId")
|
||||
} returns SwitchAccountResult.AccountSwitched
|
||||
coEvery { authRepository.validatePassword(password = "mockInput") } just awaits
|
||||
createViewModel(state = initialState).also { viewModel ->
|
||||
viewModel.trySendAction(VerifyPasswordAction.UnlockClick)
|
||||
viewModel.stateFlow.test {
|
||||
assertEquals(
|
||||
initialState.copy(
|
||||
dialog = VerifyPasswordState.DialogState.Loading(
|
||||
message = BitwardenString.loading.asText(),
|
||||
),
|
||||
),
|
||||
awaitItem(),
|
||||
)
|
||||
}
|
||||
coVerify {
|
||||
authRepository.activeUserId
|
||||
authRepository.switchAccount(userId = "otherUserId")
|
||||
authRepository.validatePassword(password = "mockInput")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("MaxLineLength")
|
||||
@Test
|
||||
fun `UnlockClick with non-empty input should show error dialog when switch account is unsuccessful`() =
|
||||
runTest {
|
||||
val initialState = DEFAULT_STATE.copy(
|
||||
accountSummaryListItem = DEFAULT_ACCOUNT_SELECTION_LIST_ITEM
|
||||
.copy(userId = "otherUserId"),
|
||||
input = "mockInput",
|
||||
)
|
||||
every {
|
||||
authRepository.switchAccount("otherUserId")
|
||||
} returns SwitchAccountResult.NoChange
|
||||
coEvery { authRepository.validatePassword(password = "mockInput") } just awaits
|
||||
|
||||
createViewModel(state = initialState).also { viewModel ->
|
||||
viewModel.stateFlow.test {
|
||||
// Await initial state update
|
||||
awaitItem()
|
||||
viewModel.trySendAction(VerifyPasswordAction.UnlockClick)
|
||||
coVerify {
|
||||
authRepository.activeUserId
|
||||
authRepository.switchAccount(userId = "otherUserId")
|
||||
}
|
||||
coVerify(exactly = 0) {
|
||||
authRepository.validatePassword(password = any())
|
||||
}
|
||||
assertEquals(
|
||||
initialState.copy(
|
||||
dialog = VerifyPasswordState.DialogState.General(
|
||||
title = BitwardenString.an_error_has_occurred.asText(),
|
||||
message = BitwardenString.generic_error_message.asText(),
|
||||
),
|
||||
),
|
||||
awaitItem(),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("MaxLineLength")
|
||||
@Test
|
||||
fun `UnlockClick with non-empty input should show loading dialog, then unlock vault when vault is locked`() =
|
||||
runTest {
|
||||
val initialState = DEFAULT_STATE.copy(input = "mockInput")
|
||||
every { vaultRepository.isVaultUnlocked(any()) } returns false
|
||||
coEvery {
|
||||
vaultRepository.unlockVaultWithMasterPassword(masterPassword = "mockInput")
|
||||
} just awaits
|
||||
createViewModel(state = initialState).also { viewModel ->
|
||||
viewModel.trySendAction(VerifyPasswordAction.UnlockClick)
|
||||
viewModel.stateFlow.test {
|
||||
assertEquals(
|
||||
initialState.copy(
|
||||
dialog = VerifyPasswordState.DialogState.Loading(
|
||||
message = BitwardenString.loading.asText(),
|
||||
),
|
||||
),
|
||||
awaitItem(),
|
||||
)
|
||||
coVerify {
|
||||
vaultRepository.unlockVaultWithMasterPassword(masterPassword = "mockInput")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `PasswordInputChangeReceive should update state`() = runTest {
|
||||
createViewModel(state = DEFAULT_STATE).also { viewModel ->
|
||||
viewModel.trySendAction(
|
||||
VerifyPasswordAction.PasswordInputChangeReceive("mockInput"),
|
||||
)
|
||||
assertEquals(
|
||||
DEFAULT_STATE.copy(input = "mockInput"),
|
||||
viewModel.stateFlow.value,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `DismissDialog should update state`() = runTest {
|
||||
val initialState = DEFAULT_STATE.copy(
|
||||
dialog = VerifyPasswordState.DialogState.Loading(
|
||||
message = BitwardenString.loading.asText(),
|
||||
),
|
||||
)
|
||||
createViewModel(state = initialState).also { viewModel ->
|
||||
viewModel.trySendAction(VerifyPasswordAction.DismissDialog)
|
||||
assertEquals(null, viewModel.stateFlow.value.dialog)
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("MaxLineLength")
|
||||
@Test
|
||||
fun `ValidatePasswordResultReceive should send PasswordVerified event when result is Success and isValid is true`() =
|
||||
runTest {
|
||||
createViewModel().also { viewModel ->
|
||||
viewModel.trySendAction(
|
||||
VerifyPasswordAction.Internal.ValidatePasswordResultReceive(
|
||||
ValidatePasswordResult.Success(isValid = true),
|
||||
),
|
||||
)
|
||||
viewModel.eventFlow.test {
|
||||
assertEquals(
|
||||
VerifyPasswordEvent.PasswordVerified(DEFAULT_USER_ID),
|
||||
awaitItem(),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("MaxLineLength")
|
||||
@Test
|
||||
fun `ValidatePasswordResultReceive should show error dialog when result is Success and isValid is false`() =
|
||||
runTest {
|
||||
createViewModel().also { viewModel ->
|
||||
viewModel.trySendAction(
|
||||
VerifyPasswordAction.Internal.ValidatePasswordResultReceive(
|
||||
ValidatePasswordResult.Success(isValid = false),
|
||||
),
|
||||
)
|
||||
assertEquals(
|
||||
VerifyPasswordState.DialogState.General(
|
||||
title = BitwardenString.an_error_has_occurred.asText(),
|
||||
message = BitwardenString.invalid_master_password.asText(),
|
||||
error = null,
|
||||
),
|
||||
viewModel.stateFlow.value.dialog,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `ValidatePasswordResultReceive should show error dialog when result is Error`() = runTest {
|
||||
val throwable = Throwable()
|
||||
createViewModel().also { viewModel ->
|
||||
viewModel.trySendAction(
|
||||
VerifyPasswordAction.Internal.ValidatePasswordResultReceive(
|
||||
ValidatePasswordResult.Error(error = throwable),
|
||||
),
|
||||
)
|
||||
assertEquals(
|
||||
VerifyPasswordState.DialogState.General(
|
||||
title = BitwardenString.an_error_has_occurred.asText(),
|
||||
message = BitwardenString.generic_error_message.asText(),
|
||||
error = throwable,
|
||||
),
|
||||
viewModel.stateFlow.value.dialog,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("MaxLineLength")
|
||||
@Test
|
||||
fun `UnlockVaultResultReceive should send PasswordVerified event when vault unlock result is Success`() =
|
||||
runTest {
|
||||
createViewModel().also { viewModel ->
|
||||
viewModel.trySendAction(
|
||||
VerifyPasswordAction.Internal.UnlockVaultResultReceive(
|
||||
VaultUnlockResult.Success,
|
||||
),
|
||||
)
|
||||
viewModel.eventFlow.test {
|
||||
assertEquals(
|
||||
VerifyPasswordEvent.PasswordVerified(DEFAULT_USER_ID),
|
||||
awaitItem(),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `UnlockVaultResultReceive should show error dialog when vault unlock result is Error`() =
|
||||
runTest {
|
||||
val throwable = Throwable()
|
||||
createViewModel().also { viewModel ->
|
||||
viewModel.trySendAction(
|
||||
VerifyPasswordAction.Internal.UnlockVaultResultReceive(
|
||||
VaultUnlockResult.GenericError(error = throwable),
|
||||
),
|
||||
)
|
||||
assertEquals(
|
||||
DEFAULT_STATE.copy(
|
||||
dialog = VerifyPasswordState.DialogState.General(
|
||||
title = BitwardenString.an_error_has_occurred.asText(),
|
||||
message = BitwardenString.generic_error_message.asText(),
|
||||
error = throwable,
|
||||
),
|
||||
),
|
||||
viewModel.stateFlow.value,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("MaxLineLength")
|
||||
@Test
|
||||
fun `UnlockVaultResultReceive should show error dialog when vault unlock result is AuthenticationError`() =
|
||||
runTest {
|
||||
val throwable = Throwable()
|
||||
createViewModel().also { viewModel ->
|
||||
viewModel.trySendAction(
|
||||
VerifyPasswordAction.Internal.UnlockVaultResultReceive(
|
||||
VaultUnlockResult.AuthenticationError(error = throwable),
|
||||
),
|
||||
)
|
||||
assertEquals(
|
||||
DEFAULT_STATE.copy(
|
||||
dialog = VerifyPasswordState.DialogState.General(
|
||||
title = BitwardenString.an_error_has_occurred.asText(),
|
||||
message = BitwardenString.invalid_master_password.asText(),
|
||||
error = throwable,
|
||||
),
|
||||
),
|
||||
viewModel.stateFlow.value,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("MaxLineLength")
|
||||
@Test
|
||||
fun `UnlockVaultResultReceive should show error dialog when vault unlock result is BiometricDecodingError`() =
|
||||
runTest {
|
||||
val throwable = Throwable()
|
||||
createViewModel().also { viewModel ->
|
||||
viewModel.trySendAction(
|
||||
VerifyPasswordAction.Internal.UnlockVaultResultReceive(
|
||||
VaultUnlockResult.BiometricDecodingError(error = throwable),
|
||||
),
|
||||
)
|
||||
assertEquals(
|
||||
DEFAULT_STATE.copy(
|
||||
dialog = VerifyPasswordState.DialogState.General(
|
||||
title = BitwardenString.an_error_has_occurred.asText(),
|
||||
message = BitwardenString.generic_error_message.asText(),
|
||||
error = throwable,
|
||||
),
|
||||
),
|
||||
viewModel.stateFlow.value,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("MaxLineLength")
|
||||
@Test
|
||||
fun `UnlockVaultResultReceive should show error dialog when vault unlock result is InvalidStateError`() =
|
||||
runTest {
|
||||
val throwable = Throwable()
|
||||
createViewModel().also { viewModel ->
|
||||
viewModel.trySendAction(
|
||||
VerifyPasswordAction.Internal.UnlockVaultResultReceive(
|
||||
VaultUnlockResult.InvalidStateError(error = throwable),
|
||||
),
|
||||
)
|
||||
assertEquals(
|
||||
DEFAULT_STATE.copy(
|
||||
dialog = VerifyPasswordState.DialogState.General(
|
||||
title = BitwardenString.an_error_has_occurred.asText(),
|
||||
message = BitwardenString.generic_error_message.asText(),
|
||||
error = throwable,
|
||||
),
|
||||
),
|
||||
viewModel.stateFlow.value,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("MaxLineLength")
|
||||
@Test
|
||||
fun `UnlockVaultResultReceive should show error dialog when vault unlock result is GenericError`() =
|
||||
runTest {
|
||||
val throwable = Throwable()
|
||||
createViewModel().also { viewModel ->
|
||||
viewModel.trySendAction(
|
||||
VerifyPasswordAction.Internal.UnlockVaultResultReceive(
|
||||
VaultUnlockResult.GenericError(error = throwable),
|
||||
),
|
||||
)
|
||||
assertEquals(
|
||||
DEFAULT_STATE.copy(
|
||||
dialog = VerifyPasswordState.DialogState.General(
|
||||
title = BitwardenString.an_error_has_occurred.asText(),
|
||||
message = BitwardenString.generic_error_message.asText(),
|
||||
error = throwable,
|
||||
),
|
||||
),
|
||||
viewModel.stateFlow.value,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun createViewModel(
|
||||
state: VerifyPasswordState? = null,
|
||||
userId: String = DEFAULT_USER_ID,
|
||||
): VerifyPasswordViewModel = VerifyPasswordViewModel(
|
||||
authRepository = authRepository,
|
||||
vaultRepository = vaultRepository,
|
||||
policyManager = policyManager,
|
||||
savedStateHandle = SavedStateHandle().apply {
|
||||
set("state", state)
|
||||
set("userId", userId)
|
||||
every {
|
||||
toVerifyPasswordArgs()
|
||||
} returns VerifyPasswordArgs(
|
||||
userId = DEFAULT_USER_ID,
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
private const val DEFAULT_USER_ID: String = "activeUserId"
|
||||
private const val DEFAULT_ORGANIZATION_ID: String = "activeOrganizationId"
|
||||
private val DEFAULT_USER_STATE = UserState(
|
||||
activeUserId = DEFAULT_USER_ID,
|
||||
accounts = listOf(
|
||||
UserState.Account(
|
||||
userId = "activeUserId",
|
||||
name = "Active User",
|
||||
email = "active@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",
|
||||
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(
|
||||
userId = DEFAULT_USER_ID,
|
||||
email = DEFAULT_USER_STATE.activeAccount.email,
|
||||
avatarColorHex = DEFAULT_USER_STATE.activeAccount.avatarColorHex,
|
||||
isItemRestricted = false,
|
||||
initials = DEFAULT_USER_STATE.activeAccount.initials,
|
||||
)
|
||||
private val DEFAULT_STATE = VerifyPasswordState(
|
||||
accountSummaryListItem = DEFAULT_ACCOUNT_SELECTION_LIST_ITEM,
|
||||
input = "",
|
||||
dialog = null,
|
||||
)
|
||||
@@ -1113,4 +1113,5 @@ Do you want to switch to this account?</string>
|
||||
<string name="import_from_bitwarden">Import from Bitwarden</string>
|
||||
<string name="select_account">Select account</string>
|
||||
<string name="import_restricted_unable_to_import_credit_cards">Import restricted, unable to import cards from this account.</string>
|
||||
<string name="verify_your_master_password">Verify your master password</string>
|
||||
</resources>
|
||||
|
||||
Reference in New Issue
Block a user