mirror of
https://github.com/bitwarden/android.git
synced 2026-04-29 04:18:52 -05:00
PM-10619 screen to generate master password (#3721)
This commit is contained in:
@@ -12,6 +12,8 @@ import com.x8bit.bitwarden.ui.auth.feature.enterprisesignon.enterpriseSignOnDest
|
||||
import com.x8bit.bitwarden.ui.auth.feature.enterprisesignon.navigateToEnterpriseSignOn
|
||||
import com.x8bit.bitwarden.ui.auth.feature.environment.environmentDestination
|
||||
import com.x8bit.bitwarden.ui.auth.feature.environment.navigateToEnvironment
|
||||
import com.x8bit.bitwarden.ui.auth.feature.masterpasswordgenerator.masterPasswordGeneratorDestination
|
||||
import com.x8bit.bitwarden.ui.auth.feature.masterpasswordgenerator.navigateToMasterPasswordGenerator
|
||||
import com.x8bit.bitwarden.ui.auth.feature.landing.LANDING_ROUTE
|
||||
import com.x8bit.bitwarden.ui.auth.feature.landing.landingDestination
|
||||
import com.x8bit.bitwarden.ui.auth.feature.landing.navigateToLanding
|
||||
@@ -23,6 +25,7 @@ import com.x8bit.bitwarden.ui.auth.feature.loginwithdevice.navigateToLoginWithDe
|
||||
import com.x8bit.bitwarden.ui.auth.feature.masterpasswordguidance.masterPasswordGuidanceDestination
|
||||
import com.x8bit.bitwarden.ui.auth.feature.masterpasswordhint.masterPasswordHintDestination
|
||||
import com.x8bit.bitwarden.ui.auth.feature.masterpasswordhint.navigateToMasterPasswordHint
|
||||
import com.x8bit.bitwarden.ui.auth.feature.preventaccountlockout.navigateToPreventAccountLockout
|
||||
import com.x8bit.bitwarden.ui.auth.feature.preventaccountlockout.preventAccountLockoutDestination
|
||||
import com.x8bit.bitwarden.ui.auth.feature.setpassword.navigateToSetPassword
|
||||
import com.x8bit.bitwarden.ui.auth.feature.setpassword.setPasswordDestination
|
||||
@@ -127,13 +130,15 @@ fun NavGraphBuilder.authGraph(
|
||||
)
|
||||
masterPasswordGuidanceDestination(
|
||||
onNavigateBack = { navController.popBackStack() },
|
||||
onNavigateToGeneratePassword = {
|
||||
// TODO [PM-10619](https://bitwarden.atlassian.net/browse/PM-10619)
|
||||
},
|
||||
onNavigateToGeneratePassword = { navController.navigateToMasterPasswordGenerator() },
|
||||
)
|
||||
preventAccountLockoutDestination(
|
||||
onNavigateBack = { navController.popBackStack() },
|
||||
)
|
||||
masterPasswordGeneratorDestination(
|
||||
onNavigateBack = { navController.popBackStack() },
|
||||
onNavigateToPreventLockout = { navController.navigateToPreventAccountLockout() },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
package com.x8bit.bitwarden.ui.auth.feature.masterpasswordgenerator
|
||||
|
||||
import androidx.navigation.NavController
|
||||
import androidx.navigation.NavGraphBuilder
|
||||
import androidx.navigation.NavOptions
|
||||
import com.x8bit.bitwarden.ui.platform.base.util.composableWithSlideTransitions
|
||||
|
||||
private const val MASTER_PASSWORD_GENERATOR = "master_password_generator"
|
||||
|
||||
/**
|
||||
* Navigate to master password generator screen.
|
||||
*/
|
||||
fun NavController.navigateToMasterPasswordGenerator(navOptions: NavOptions? = null) {
|
||||
this.navigate(MASTER_PASSWORD_GENERATOR, navOptions)
|
||||
}
|
||||
|
||||
/**
|
||||
* Add the master password generator screen to the nav graph.
|
||||
*/
|
||||
fun NavGraphBuilder.masterPasswordGeneratorDestination(
|
||||
onNavigateBack: () -> Unit,
|
||||
onNavigateToPreventLockout: () -> Unit,
|
||||
) {
|
||||
composableWithSlideTransitions(
|
||||
route = MASTER_PASSWORD_GENERATOR,
|
||||
) {
|
||||
MasterPasswordGeneratorScreen(
|
||||
onNavigateBack = onNavigateBack,
|
||||
onNavigateToPreventLockout = onNavigateToPreventLockout,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,224 @@
|
||||
package com.x8bit.bitwarden.ui.auth.feature.masterpasswordgenerator
|
||||
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
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.layout.navigationBarsPadding
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.SnackbarDuration
|
||||
import androidx.compose.material3.SnackbarHost
|
||||
import androidx.compose.material3.SnackbarHostState
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TopAppBarDefaults
|
||||
import androidx.compose.material3.TopAppBarScrollBehavior
|
||||
import androidx.compose.material3.rememberTopAppBarState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.input.nestedscroll.nestedScroll
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.x8bit.bitwarden.R
|
||||
import com.x8bit.bitwarden.ui.platform.base.util.EventsEffect
|
||||
import com.x8bit.bitwarden.ui.platform.base.util.standardHorizontalMargin
|
||||
import com.x8bit.bitwarden.ui.platform.components.appbar.BitwardenTopAppBar
|
||||
import com.x8bit.bitwarden.ui.platform.components.button.BitwardenFilledButtonWithIcon
|
||||
import com.x8bit.bitwarden.ui.platform.components.button.BitwardenTextButton
|
||||
import com.x8bit.bitwarden.ui.platform.components.field.BitwardenTextField
|
||||
import com.x8bit.bitwarden.ui.platform.components.scaffold.BitwardenScaffold
|
||||
import com.x8bit.bitwarden.ui.platform.components.text.BitwardenClickableText
|
||||
import com.x8bit.bitwarden.ui.platform.components.util.nonLetterColorVisualTransformation
|
||||
import com.x8bit.bitwarden.ui.platform.components.util.rememberVectorPainter
|
||||
import com.x8bit.bitwarden.ui.platform.theme.BitwardenTheme
|
||||
import com.x8bit.bitwarden.ui.platform.theme.LocalNonMaterialTypography
|
||||
|
||||
/**
|
||||
* Top level composable for the master password generator.
|
||||
*/
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Suppress("LongMethod")
|
||||
@Composable
|
||||
fun MasterPasswordGeneratorScreen(
|
||||
onNavigateBack: () -> Unit,
|
||||
onNavigateToPreventLockout: () -> Unit,
|
||||
viewModel: MasterPasswordGeneratorViewModel = hiltViewModel(),
|
||||
) {
|
||||
val state by viewModel.stateFlow.collectAsStateWithLifecycle()
|
||||
val context = LocalContext.current
|
||||
val resources = context.resources
|
||||
val snackbarHostState = remember {
|
||||
SnackbarHostState()
|
||||
}
|
||||
EventsEffect(viewModel = viewModel) { event ->
|
||||
when (event) {
|
||||
MasterPasswordGeneratorEvent.NavigateBack -> onNavigateBack()
|
||||
MasterPasswordGeneratorEvent.NavigateToPreventLockout -> onNavigateToPreventLockout()
|
||||
is MasterPasswordGeneratorEvent.ShowSnackbar -> {
|
||||
snackbarHostState.showSnackbar(
|
||||
message = event.text.toString(resources),
|
||||
duration = SnackbarDuration.Short,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior(rememberTopAppBarState())
|
||||
BitwardenScaffold(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.nestedScroll(scrollBehavior.nestedScrollConnection),
|
||||
topBar = {
|
||||
MasterPasswordGeneratorTopBar(
|
||||
scrollBehavior = scrollBehavior,
|
||||
onBackClick = remember(viewModel) {
|
||||
{
|
||||
viewModel.trySendAction(MasterPasswordGeneratorAction.BackClickAction)
|
||||
}
|
||||
},
|
||||
onSaveClick = remember(viewModel) {
|
||||
{
|
||||
viewModel.trySendAction(
|
||||
MasterPasswordGeneratorAction.SavePasswordClickAction,
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
},
|
||||
snackbarHost = {
|
||||
SnackbarHost(hostState = snackbarHostState)
|
||||
},
|
||||
) { innerPadding ->
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(innerPadding),
|
||||
) {
|
||||
MasterPasswordGeneratorContent(
|
||||
generatedPassword = state.generatedPassword,
|
||||
onGenerateNewPassword = remember(viewModel) {
|
||||
{
|
||||
viewModel.trySendAction(
|
||||
MasterPasswordGeneratorAction.GeneratePasswordClickAction,
|
||||
)
|
||||
}
|
||||
},
|
||||
onLearnToPreventLockout = remember(viewModel) {
|
||||
{
|
||||
viewModel.trySendAction(
|
||||
MasterPasswordGeneratorAction.PreventLockoutClickAction,
|
||||
)
|
||||
}
|
||||
},
|
||||
modifier = Modifier.standardHorizontalMargin(),
|
||||
)
|
||||
Spacer(modifier = Modifier.navigationBarsPadding())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun MasterPasswordGeneratorContent(
|
||||
generatedPassword: String,
|
||||
onGenerateNewPassword: () -> Unit,
|
||||
onLearnToPreventLockout: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Column(
|
||||
modifier = modifier.fillMaxWidth(),
|
||||
) {
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
BitwardenTextField(
|
||||
label = "",
|
||||
value = generatedPassword,
|
||||
onValueChange = {},
|
||||
readOnly = true,
|
||||
shouldAddCustomLineBreaks = true,
|
||||
textStyle = LocalNonMaterialTypography.current.sensitiveInfoSmall,
|
||||
visualTransformation = nonLetterColorVisualTransformation(),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
BitwardenFilledButtonWithIcon(
|
||||
label = stringResource(R.string.generate_button_label),
|
||||
onClick = onGenerateNewPassword,
|
||||
icon = rememberVectorPainter(id = R.drawable.ic_generator),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
Text(
|
||||
text = stringResource(R.string.write_this_password_down_and_keep_it_somewhere_safe),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
BitwardenClickableText(
|
||||
label = stringResource(R.string.learn_about_other_ways_to_prevent_account_lockout),
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
onClick = onLearnToPreventLockout,
|
||||
innerPadding = PaddingValues(horizontal = 0.dp, vertical = 4.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
private fun MasterPasswordGeneratorTopBar(
|
||||
scrollBehavior: TopAppBarScrollBehavior,
|
||||
onBackClick: () -> Unit,
|
||||
onSaveClick: () -> Unit,
|
||||
) {
|
||||
BitwardenTopAppBar(
|
||||
title = stringResource(R.string.generate_master_password),
|
||||
scrollBehavior = scrollBehavior,
|
||||
navigationIcon = rememberVectorPainter(id = R.drawable.ic_back),
|
||||
navigationIconContentDescription = stringResource(id = R.string.back),
|
||||
onNavigationIconClick = onBackClick,
|
||||
actions = {
|
||||
BitwardenTextButton(
|
||||
label = stringResource(id = R.string.save),
|
||||
labelTextColor = MaterialTheme.colorScheme.primary,
|
||||
onClick = onSaveClick,
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Preview
|
||||
@Composable
|
||||
private fun MasterPasswordGeneratorTopBarPreview() {
|
||||
BitwardenTheme {
|
||||
MasterPasswordGeneratorTopBar(
|
||||
scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior(),
|
||||
onBackClick = { },
|
||||
onSaveClick = { },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Preview(showBackground = true)
|
||||
@Composable
|
||||
private fun MasterPasswordGeneratorContentPreview() {
|
||||
BitwardenTheme {
|
||||
MasterPasswordGeneratorContent(
|
||||
generatedPassword = "really-secure-password",
|
||||
onGenerateNewPassword = { },
|
||||
onLearnToPreventLockout = { },
|
||||
modifier = Modifier.padding(16.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,207 @@
|
||||
package com.x8bit.bitwarden.ui.auth.feature.masterpasswordgenerator
|
||||
|
||||
import android.os.Parcelable
|
||||
import androidx.annotation.VisibleForTesting
|
||||
import androidx.lifecycle.SavedStateHandle
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.bitwarden.generators.PassphraseGeneratorRequest
|
||||
import com.x8bit.bitwarden.R
|
||||
import com.x8bit.bitwarden.data.auth.repository.model.PolicyInformation
|
||||
import com.x8bit.bitwarden.data.platform.manager.PolicyManager
|
||||
import com.x8bit.bitwarden.data.platform.manager.util.getActivePolicies
|
||||
import com.x8bit.bitwarden.data.tools.generator.repository.GeneratorRepository
|
||||
import com.x8bit.bitwarden.data.tools.generator.repository.model.GeneratedPassphraseResult
|
||||
import com.x8bit.bitwarden.ui.platform.base.BaseViewModel
|
||||
import com.x8bit.bitwarden.ui.platform.base.util.Text
|
||||
import com.x8bit.bitwarden.ui.platform.base.util.asText
|
||||
import com.x8bit.bitwarden.ui.tools.feature.generator.util.toStrictestPolicy
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.flow.launchIn
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.parcelize.Parcelize
|
||||
import javax.inject.Inject
|
||||
import kotlin.math.max
|
||||
|
||||
private const val KEY_STATE = "state"
|
||||
private const val DEFAULT_SEPARATOR = "-"
|
||||
private const val DEFAULT_WORD_COUNT = 3
|
||||
|
||||
/**
|
||||
* ViewModel to support the [MasterPasswordGeneratorScreen]
|
||||
*/
|
||||
@HiltViewModel
|
||||
@Suppress("MaxLineLength")
|
||||
class MasterPasswordGeneratorViewModel @Inject constructor(
|
||||
savedStateHandle: SavedStateHandle,
|
||||
private val generatorRepository: GeneratorRepository,
|
||||
private val policyManager: PolicyManager,
|
||||
) : BaseViewModel<MasterPasswordGeneratorState, MasterPasswordGeneratorEvent, MasterPasswordGeneratorAction>(
|
||||
initialState = savedStateHandle[KEY_STATE]
|
||||
?: MasterPasswordGeneratorState(generatedPassword = DEFAULT_SEPARATOR),
|
||||
) {
|
||||
private var generatePasswordJob: Job? = null
|
||||
private val passphraseRequest = getPolicyBasedPassphraseRequest()
|
||||
|
||||
init {
|
||||
if (state.generatedPassword == DEFAULT_SEPARATOR) {
|
||||
generateNewPassphrase()
|
||||
}
|
||||
|
||||
stateFlow
|
||||
.onEach { savedStateHandle[KEY_STATE] = it }
|
||||
.launchIn(viewModelScope)
|
||||
}
|
||||
|
||||
override fun handleAction(action: MasterPasswordGeneratorAction) {
|
||||
when (action) {
|
||||
MasterPasswordGeneratorAction.BackClickAction -> handleBackAction()
|
||||
MasterPasswordGeneratorAction.GeneratePasswordClickAction -> {
|
||||
handleGeneratePasswordAction()
|
||||
}
|
||||
|
||||
MasterPasswordGeneratorAction.PreventLockoutClickAction -> handlePreventLockoutAction()
|
||||
MasterPasswordGeneratorAction.SavePasswordClickAction -> handleSavePasswordAction()
|
||||
is MasterPasswordGeneratorAction.Internal -> {
|
||||
handleInternalAction(internalAction = action)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleBackAction() = sendEvent(MasterPasswordGeneratorEvent.NavigateBack)
|
||||
|
||||
private fun handleSavePasswordAction() {
|
||||
// TODO [PM-10692](https://bitwarden.atlassian.net/browse/PM-10692)
|
||||
}
|
||||
|
||||
private fun handlePreventLockoutAction() =
|
||||
sendEvent(MasterPasswordGeneratorEvent.NavigateToPreventLockout)
|
||||
|
||||
private fun handleInternalAction(internalAction: MasterPasswordGeneratorAction.Internal) {
|
||||
when (internalAction) {
|
||||
is MasterPasswordGeneratorAction.Internal.ReceiveUpdatedPassphraseResultAction -> {
|
||||
handleUpdatedPassphraseResult(internalAction.result)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleUpdatedPassphraseResult(passphraseResult: GeneratedPassphraseResult) {
|
||||
when (passphraseResult) {
|
||||
GeneratedPassphraseResult.InvalidRequest -> {
|
||||
sendEvent(
|
||||
MasterPasswordGeneratorEvent.ShowSnackbar(
|
||||
R.string.an_error_has_occurred.asText(),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
is GeneratedPassphraseResult.Success -> {
|
||||
mutableStateFlow.update {
|
||||
it.copy(generatedPassword = passphraseResult.generatedString)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleGeneratePasswordAction() = generateNewPassphrase()
|
||||
|
||||
private fun generateNewPassphrase() {
|
||||
generatePasswordJob?.cancel()
|
||||
generatePasswordJob = viewModelScope.launch {
|
||||
val result = generatorRepository.generatePassphrase(
|
||||
passphraseGeneratorRequest = passphraseRequest,
|
||||
)
|
||||
sendAction(
|
||||
MasterPasswordGeneratorAction.Internal.ReceiveUpdatedPassphraseResultAction(
|
||||
result = result,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun getPolicyBasedPassphraseRequest(): PassphraseGeneratorRequest {
|
||||
val policy = policyManager
|
||||
.getActivePolicies<PolicyInformation.MasterPassword>()
|
||||
.toStrictestPolicy()
|
||||
val options = generatorRepository.getPasscodeGenerationOptions()
|
||||
val optionsWordCount = options?.numWords ?: DEFAULT_WORD_COUNT
|
||||
return PassphraseGeneratorRequest(
|
||||
numWords = max(optionsWordCount, DEFAULT_WORD_COUNT).toUByte(),
|
||||
wordSeparator = options?.wordSeparator ?: DEFAULT_SEPARATOR,
|
||||
capitalize = policy.requireUpper == true,
|
||||
includeNumber = policy.requireNumbers == true,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* MasterPasswordGeneratorState
|
||||
*/
|
||||
@Parcelize
|
||||
data class MasterPasswordGeneratorState(
|
||||
val generatedPassword: String,
|
||||
) : Parcelable
|
||||
|
||||
/**
|
||||
* Model events to send to the UI
|
||||
*/
|
||||
sealed class MasterPasswordGeneratorEvent {
|
||||
|
||||
/**
|
||||
* Navigate back to the previous screen.
|
||||
*/
|
||||
data object NavigateBack : MasterPasswordGeneratorEvent()
|
||||
|
||||
/**
|
||||
* Navigate to the prevent account lockout tips screen.
|
||||
*/
|
||||
data object NavigateToPreventLockout : MasterPasswordGeneratorEvent()
|
||||
|
||||
/**
|
||||
* Show a Snackbar message.
|
||||
*/
|
||||
data class ShowSnackbar(val text: Text) : MasterPasswordGeneratorEvent()
|
||||
}
|
||||
|
||||
/**
|
||||
* Model actions from the UI and internal sources for the ViewModel to handle.
|
||||
*/
|
||||
sealed class MasterPasswordGeneratorAction {
|
||||
|
||||
/**
|
||||
* Internal actions that should only be sent via the owner of the action flow.
|
||||
* @see [MasterPasswordGeneratorViewModel]
|
||||
*/
|
||||
@VisibleForTesting
|
||||
sealed class Internal : MasterPasswordGeneratorAction() {
|
||||
|
||||
/**
|
||||
* Internal action to indicate a generated password result has been received.
|
||||
*/
|
||||
data class ReceiveUpdatedPassphraseResultAction(
|
||||
val result: GeneratedPassphraseResult,
|
||||
) : Internal()
|
||||
}
|
||||
|
||||
/**
|
||||
* Indicate the generate new passphrase button has been clicked.
|
||||
*/
|
||||
data object GeneratePasswordClickAction : MasterPasswordGeneratorAction()
|
||||
|
||||
/**
|
||||
* Indicate the prevent lockout link has been clicked.
|
||||
*/
|
||||
data object PreventLockoutClickAction : MasterPasswordGeneratorAction()
|
||||
|
||||
/**
|
||||
* Indicate the back arrow button has been clicked.
|
||||
*/
|
||||
data object BackClickAction : MasterPasswordGeneratorAction()
|
||||
|
||||
/**
|
||||
* Indicate the save button has been clicked.
|
||||
*/
|
||||
data object SavePasswordClickAction : MasterPasswordGeneratorAction()
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
package com.x8bit.bitwarden.ui.tools.feature.generator.util
|
||||
|
||||
import com.x8bit.bitwarden.data.auth.repository.model.PolicyInformation
|
||||
|
||||
/**
|
||||
* Creates the strictest set of rules based on contents of list of
|
||||
* [PolicyInformation.MasterPassword].
|
||||
*/
|
||||
fun List<PolicyInformation.MasterPassword>.toStrictestPolicy() = PolicyInformation.MasterPassword(
|
||||
minLength = mapNotNull { it.minLength }.maxOrNull(),
|
||||
minComplexity = mapNotNull { it.minComplexity }.maxOrNull(),
|
||||
requireUpper = mapNotNull { it.requireUpper }.any { it },
|
||||
requireLower = mapNotNull { it.requireLower }.any { it },
|
||||
requireNumbers = mapNotNull { it.requireNumbers }.any { it },
|
||||
requireSpecial = mapNotNull { it.requireSpecial }.any { it },
|
||||
enforceOnLogin = mapNotNull { it.enforceOnLogin }.any { it },
|
||||
)
|
||||
@@ -959,4 +959,8 @@ Do you want to switch to this account?</string>
|
||||
<string name="write_your_password_down">Write your password down</string>
|
||||
<string name="keep_it_secret_keep_it_safe">Be careful to keep your written password somewhere secret and safe.</string>
|
||||
<string name="prevent_account_lockout">Prevent account lockout</string>
|
||||
<string name="generate_master_password">Generate master password</string>
|
||||
<string name="generate_button_label">Generate</string>
|
||||
<string name="write_this_password_down_and_keep_it_somewhere_safe">Write this password down and keep it somewhere safe.</string>
|
||||
<string name="learn_about_other_ways_to_prevent_account_lockout">Learn about other ways to prevent account lockout</string>
|
||||
</resources>
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
package com.x8bit.bitwarden.ui.auth.feature.masterpasswordgenerator
|
||||
|
||||
import androidx.compose.ui.test.assertIsDisplayed
|
||||
import androidx.compose.ui.test.onNodeWithContentDescription
|
||||
import androidx.compose.ui.test.onNodeWithText
|
||||
import androidx.compose.ui.test.performClick
|
||||
import androidx.compose.ui.test.performScrollTo
|
||||
import com.x8bit.bitwarden.data.platform.repository.util.bufferedMutableSharedFlow
|
||||
import com.x8bit.bitwarden.ui.platform.base.BaseComposeTest
|
||||
import io.mockk.every
|
||||
import io.mockk.mockk
|
||||
import io.mockk.verify
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
|
||||
class MasterPasswordGeneratorScreenTest : BaseComposeTest() {
|
||||
private var onNavigateBackCalled = false
|
||||
private var onNavigateToPreventLockoutCalled = false
|
||||
private val mutableEventFlow = bufferedMutableSharedFlow<MasterPasswordGeneratorEvent>()
|
||||
private val mutableStateFlow = MutableStateFlow(
|
||||
value = MasterPasswordGeneratorState(generatedPassword = "-"),
|
||||
)
|
||||
private val viewModel = mockk<MasterPasswordGeneratorViewModel>(relaxed = true) {
|
||||
every { eventFlow } returns mutableEventFlow
|
||||
every { stateFlow } returns mutableStateFlow.asStateFlow()
|
||||
}
|
||||
|
||||
@Before
|
||||
fun setup() {
|
||||
composeTestRule.setContent {
|
||||
MasterPasswordGeneratorScreen(
|
||||
onNavigateBack = { onNavigateBackCalled = true },
|
||||
onNavigateToPreventLockout = { onNavigateToPreventLockoutCalled = true },
|
||||
viewModel = viewModel,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Generated password field state should update with ViewModel state`() {
|
||||
val updatedValue = "soup-r-stronk-pazzwerd"
|
||||
mutableStateFlow.update { it.copy(generatedPassword = updatedValue) }
|
||||
|
||||
composeTestRule
|
||||
.onNodeWithText(updatedValue)
|
||||
.performScrollTo()
|
||||
.assertIsDisplayed()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `NavigateBack event should invoke onNavigateBack lambda`() {
|
||||
mutableEventFlow.tryEmit(MasterPasswordGeneratorEvent.NavigateBack)
|
||||
|
||||
assertTrue(onNavigateBackCalled)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `NavigateToPreventLockout event should invoke onNavigateToPreventLockout lambda`() {
|
||||
mutableEventFlow.tryEmit(MasterPasswordGeneratorEvent.NavigateToPreventLockout)
|
||||
|
||||
assertTrue(onNavigateToPreventLockoutCalled)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Verify clicking the back navigation button sends correct action`() {
|
||||
composeTestRule
|
||||
.onNodeWithContentDescription("Back")
|
||||
.performClick()
|
||||
|
||||
verify { viewModel.trySendAction(MasterPasswordGeneratorAction.BackClickAction) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Verify clicking the save text button sends correct action`() {
|
||||
composeTestRule
|
||||
.onNodeWithText("Save")
|
||||
.performClick()
|
||||
|
||||
verify { viewModel.trySendAction(MasterPasswordGeneratorAction.SavePasswordClickAction) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Verify clicking the generate button sends correct action`() {
|
||||
composeTestRule
|
||||
.onNodeWithText("Generate")
|
||||
.performScrollTo()
|
||||
.performClick()
|
||||
|
||||
verify {
|
||||
viewModel.trySendAction(MasterPasswordGeneratorAction.GeneratePasswordClickAction)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Verify clicking the learn to prevent lockout text sends correct action`() {
|
||||
composeTestRule
|
||||
.onNodeWithText("Learn about other ways to prevent account lockout")
|
||||
.performScrollTo()
|
||||
.performClick()
|
||||
|
||||
verify { viewModel.trySendAction(MasterPasswordGeneratorAction.PreventLockoutClickAction) }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
package com.x8bit.bitwarden.ui.auth.feature.masterpasswordgenerator
|
||||
|
||||
import androidx.lifecycle.SavedStateHandle
|
||||
import app.cash.turbine.test
|
||||
import com.x8bit.bitwarden.R
|
||||
import com.x8bit.bitwarden.data.platform.manager.PolicyManager
|
||||
import com.x8bit.bitwarden.data.tools.generator.repository.model.GeneratedPassphraseResult
|
||||
import com.x8bit.bitwarden.data.tools.generator.repository.util.FakeGeneratorRepository
|
||||
import com.x8bit.bitwarden.data.vault.datasource.network.model.PolicyTypeJson
|
||||
import com.x8bit.bitwarden.ui.platform.base.BaseViewModelTest
|
||||
import com.x8bit.bitwarden.ui.platform.base.util.asText
|
||||
import io.mockk.every
|
||||
import io.mockk.mockk
|
||||
import io.mockk.verify
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Assertions.assertNotEquals
|
||||
import org.junit.jupiter.api.BeforeEach
|
||||
import org.junit.jupiter.api.Test
|
||||
|
||||
class MasterPasswordGeneratorViewModelTest : BaseViewModelTest() {
|
||||
private val fakeGeneratorRepository = FakeGeneratorRepository()
|
||||
private val mockPolicyManager = mockk<PolicyManager>(relaxed = true) {
|
||||
every { getActivePolicies(type = PolicyTypeJson.MASTER_PASSWORD) } returns emptyList()
|
||||
}
|
||||
|
||||
@BeforeEach
|
||||
fun setup() {
|
||||
fakeGeneratorRepository.setMockGeneratePassphraseResult(
|
||||
result = GeneratedPassphraseResult.Success(
|
||||
generatedString = "generatedString",
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
@Suppress("MaxLineLength")
|
||||
fun `With no saved state and failed generator result, initial password state is default value`() {
|
||||
fakeGeneratorRepository.setMockGeneratePassphraseResult(
|
||||
result = GeneratedPassphraseResult.InvalidRequest,
|
||||
)
|
||||
val viewModel = createViewModel()
|
||||
|
||||
assertEquals(
|
||||
MasterPasswordGeneratorState(generatedPassword = "-"),
|
||||
viewModel.stateFlow.value,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `With previous saved state, initial password state is saved value`() {
|
||||
val savedPassword = "saved-pw"
|
||||
val viewModel = createViewModel(
|
||||
initialState = MasterPasswordGeneratorState(generatedPassword = savedPassword),
|
||||
)
|
||||
|
||||
assertEquals(
|
||||
MasterPasswordGeneratorState(generatedPassword = savedPassword),
|
||||
viewModel.stateFlow.value,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Verify passphrase request is created and attempts to check for policy constraints`() {
|
||||
createViewModel()
|
||||
|
||||
verify { mockPolicyManager.getActivePolicies(type = PolicyTypeJson.MASTER_PASSWORD) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `State updates when generate action is sent and repository returns success result`() =
|
||||
runTest {
|
||||
val viewModel = createViewModel()
|
||||
val newPassPhrase = "I am new"
|
||||
val expectedResult = MasterPasswordGeneratorState(generatedPassword = newPassPhrase)
|
||||
|
||||
viewModel.stateFlow.test {
|
||||
assertNotEquals(expectedResult, awaitItem())
|
||||
fakeGeneratorRepository.setMockGeneratePassphraseResult(
|
||||
GeneratedPassphraseResult.Success(generatedString = newPassPhrase),
|
||||
)
|
||||
viewModel.trySendAction(
|
||||
MasterPasswordGeneratorAction.GeneratePasswordClickAction,
|
||||
)
|
||||
assertEquals(expectedResult, awaitItem())
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `ShowSnackbar event is sent when the generate passphrase result is a failure`() = runTest {
|
||||
val viewModel = createViewModel()
|
||||
|
||||
fakeGeneratorRepository.setMockGeneratePassphraseResult(
|
||||
GeneratedPassphraseResult.InvalidRequest,
|
||||
)
|
||||
|
||||
viewModel.eventFlow.test {
|
||||
viewModel.trySendAction(MasterPasswordGeneratorAction.GeneratePasswordClickAction)
|
||||
assertEquals(
|
||||
MasterPasswordGeneratorEvent.ShowSnackbar(R.string.an_error_has_occurred.asText()),
|
||||
awaitItem(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `NavigateBack event is sent when BackClickAction is handled`() = runTest {
|
||||
val viewModel = createViewModel()
|
||||
|
||||
viewModel.eventFlow.test {
|
||||
viewModel.trySendAction(MasterPasswordGeneratorAction.BackClickAction)
|
||||
assertEquals(MasterPasswordGeneratorEvent.NavigateBack, awaitItem())
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `NavigateToPreventLockout event is sent when PreventLockoutClickAction is handled`() =
|
||||
runTest {
|
||||
val viewModel = createViewModel()
|
||||
|
||||
viewModel.eventFlow.test {
|
||||
viewModel.trySendAction(MasterPasswordGeneratorAction.PreventLockoutClickAction)
|
||||
assertEquals(MasterPasswordGeneratorEvent.NavigateToPreventLockout, awaitItem())
|
||||
}
|
||||
}
|
||||
|
||||
// region helpers
|
||||
|
||||
private fun createViewModel(
|
||||
initialState: MasterPasswordGeneratorState? = null,
|
||||
): MasterPasswordGeneratorViewModel = MasterPasswordGeneratorViewModel(
|
||||
savedStateHandle = SavedStateHandle().apply { this["state"] = initialState },
|
||||
generatorRepository = fakeGeneratorRepository,
|
||||
policyManager = mockPolicyManager,
|
||||
)
|
||||
|
||||
// endregion helpers
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
package com.x8bit.bitwarden.ui.tools.feature.generator.util
|
||||
|
||||
import com.x8bit.bitwarden.data.auth.repository.model.PolicyInformation
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Test
|
||||
|
||||
class PolicyInformationMasterPasswordExtensionsTest {
|
||||
|
||||
@Test
|
||||
fun `toStrictestPolicy enforces the strictest MasterPassword policy`() {
|
||||
assertEquals(
|
||||
PolicyInformation.MasterPassword(
|
||||
minLength = 24,
|
||||
minComplexity = 24,
|
||||
requireUpper = true,
|
||||
requireLower = true,
|
||||
requireNumbers = true,
|
||||
requireSpecial = true,
|
||||
enforceOnLogin = true,
|
||||
),
|
||||
listOf(
|
||||
POLICY_1,
|
||||
POLICY_2,
|
||||
POLICY_3,
|
||||
)
|
||||
.toStrictestPolicy(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private val POLICY_1 = PolicyInformation.MasterPassword(
|
||||
minLength = 6,
|
||||
minComplexity = 24,
|
||||
requireUpper = false,
|
||||
requireLower = true,
|
||||
requireNumbers = false,
|
||||
requireSpecial = false,
|
||||
enforceOnLogin = true,
|
||||
)
|
||||
|
||||
private val POLICY_2 = PolicyInformation.MasterPassword(
|
||||
minLength = 24,
|
||||
minComplexity = 12,
|
||||
requireUpper = true,
|
||||
requireLower = false,
|
||||
requireNumbers = false,
|
||||
requireSpecial = true,
|
||||
enforceOnLogin = false,
|
||||
)
|
||||
|
||||
private val POLICY_3 = PolicyInformation.MasterPassword(
|
||||
minLength = 12,
|
||||
minComplexity = 24,
|
||||
requireUpper = false,
|
||||
requireLower = false,
|
||||
requireNumbers = true,
|
||||
requireSpecial = false,
|
||||
enforceOnLogin = false,
|
||||
)
|
||||
Reference in New Issue
Block a user