diff --git a/app/src/main/java/com/x8bit/bitwarden/data/tools/generator/repository/model/PasscodeGenerationOptions.kt b/app/src/main/java/com/x8bit/bitwarden/data/tools/generator/repository/model/PasscodeGenerationOptions.kt index f83e3488fe..0302130cf8 100644 --- a/app/src/main/java/com/x8bit/bitwarden/data/tools/generator/repository/model/PasscodeGenerationOptions.kt +++ b/app/src/main/java/com/x8bit/bitwarden/data/tools/generator/repository/model/PasscodeGenerationOptions.kt @@ -1,11 +1,14 @@ package com.x8bit.bitwarden.data.tools.generator.repository.model +import androidx.annotation.Keep +import com.x8bit.bitwarden.data.platform.datasource.network.serializer.BaseEnumeratedIntSerializer import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable /** * A data class representing the configuration options for both password and passphrase generation. * + * @property type The type of passcode to be generated, as defined in PasscodeType. * @property length The total length of the generated password. * @property allowAmbiguousChar Indicates whether ambiguous characters are allowed in the password. * @property hasNumbers Indicates whether the password should contain numbers. @@ -23,6 +26,8 @@ import kotlinx.serialization.Serializable */ @Serializable data class PasscodeGenerationOptions( + @SerialName("type") + val type: PasscodeType, // Password-specific options @@ -69,4 +74,22 @@ data class PasscodeGenerationOptions( @SerialName("includeNumber") val allowIncludeNumber: Boolean, -) +) { + /** + * Represents different Passcode types. + */ + @Serializable(with = PasscodeTypeSerializer::class) + enum class PasscodeType { + @SerialName("0") + PASSWORD, + + @SerialName("1") + PASSPHRASE, + } +} + +@Keep +private class PasscodeTypeSerializer : + BaseEnumeratedIntSerializer( + PasscodeGenerationOptions.PasscodeType.entries.toTypedArray(), + ) diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/tools/feature/generator/GeneratorViewModel.kt b/app/src/main/java/com/x8bit/bitwarden/ui/tools/feature/generator/GeneratorViewModel.kt index 7a380c9cee..64faa41a85 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/tools/feature/generator/GeneratorViewModel.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/tools/feature/generator/GeneratorViewModel.kt @@ -85,8 +85,16 @@ class GeneratorViewModel @Inject constructor( GeneratorState( generatedText = NO_GENERATED_TEXT, selectedType = when (generatorMode) { - is GeneratorMode.Modal.Username -> Username() - GeneratorMode.Modal.Password -> Passcode() + is GeneratorMode.Modal.Username -> { + val type = generatorRepository.getUsernameGenerationOptions().usernameType + Username(selectedType = type) + } + + GeneratorMode.Modal.Password -> { + val type = generatorRepository.getPasscodeGenerationOptions().passcodeType + Passcode(selectedType = type) + } + GeneratorMode.Default -> Passcode(selectedType = Password()) }, generatorMode = generatorMode, @@ -260,7 +268,10 @@ class GeneratorViewModel @Inject constructor( private fun loadOptions() { when (val selectedType = state.selectedType) { - is Passcode -> loadPasscodeOptions(selectedType = selectedType, usePolicyDefault = true) + is Passcode -> loadPasscodeOptions( + selectedType = selectedType, + ) + is Username -> loadUsernameOptions( selectedType = selectedType, forceRegeneration = selectedType.selectedType !is ForwardedEmailAlias, @@ -269,26 +280,14 @@ class GeneratorViewModel @Inject constructor( } @Suppress("CyclomaticComplexMethod") - private fun loadPasscodeOptions(selectedType: Passcode, usePolicyDefault: Boolean) { - val passwordType = if (usePolicyDefault) { - Passcode( - selectedType = generatorRepository - .getPasswordGeneratorPolicy() - ?.defaultType - ?.toSelectedType() - ?: Password(), - ) - } else { - selectedType - } - + private fun loadPasscodeOptions(selectedType: Passcode) { val options = generatorRepository.getPasscodeGenerationOptions() ?: generatePasscodeDefaultOptions() val policy = policyManager .getActivePolicies() .toStrictestPolicy() - when (passwordType.selectedType) { + when (selectedType.selectedType) { is Passphrase -> { val minNumWords = policy.minNumberWords ?: Passphrase.PASSPHRASE_MIN_NUMBER_OF_WORDS val passphrase = Passphrase( @@ -380,6 +379,7 @@ class GeneratorViewModel @Inject constructor( val options = generatorRepository .getPasscodeGenerationOptions() ?: generatePasscodeDefaultOptions() val newOptions = options.copy( + type = PasscodeGenerationOptions.PasscodeType.PASSWORD, length = password.length, allowAmbiguousChar = password.avoidAmbiguousChars, hasNumbers = password.useNumbers, @@ -396,6 +396,7 @@ class GeneratorViewModel @Inject constructor( val options = generatorRepository .getPasscodeGenerationOptions() ?: generatePasscodeDefaultOptions() val newOptions = options.copy( + type = PasscodeGenerationOptions.PasscodeType.PASSPHRASE, numWords = passphrase.numWords, wordSeparator = passphrase.wordSeparator.toString(), allowCapitalize = passphrase.capitalize, @@ -485,6 +486,7 @@ class GeneratorViewModel @Inject constructor( val defaultPassphrase = Passphrase() return PasscodeGenerationOptions( + type = PasscodeGenerationOptions.PasscodeType.PASSWORD, length = defaultPassword.length, allowAmbiguousChar = defaultPassword.avoidAmbiguousChars, hasNumbers = defaultPassword.useNumbers, @@ -684,11 +686,18 @@ class GeneratorViewModel @Inject constructor( private fun handleMainTypeOptionSelect(action: GeneratorAction.MainTypeOptionSelect) { when (action.mainTypeOption) { GeneratorState.MainTypeOption.PASSWORD -> { - loadPasscodeOptions(selectedType = Passcode(), usePolicyDefault = true) + val type = generatorRepository.getPasscodeGenerationOptions().passcodeType + loadPasscodeOptions( + selectedType = Passcode(selectedType = type), + ) } GeneratorState.MainTypeOption.USERNAME -> { - loadUsernameOptions(selectedType = Username(), forceRegeneration = true) + val type = generatorRepository.getUsernameGenerationOptions().usernameType + loadUsernameOptions( + selectedType = Username(selectedType = type), + forceRegeneration = true, + ) } } } @@ -703,12 +712,10 @@ class GeneratorViewModel @Inject constructor( when (action.passcodeTypeOption) { PasscodeTypeOption.PASSWORD -> loadPasscodeOptions( selectedType = Passcode(selectedType = Password()), - usePolicyDefault = false, ) PasscodeTypeOption.PASSPHRASE -> loadPasscodeOptions( selectedType = Passcode(selectedType = Passphrase()), - usePolicyDefault = false, ) } } @@ -2572,6 +2579,22 @@ private val Password.minimumLength: Int return minLowercase + minUppercase + minimumNumbers + minimumSpecial } +private val PasscodeGenerationOptions?.passcodeType: Passcode.PasscodeType + get() = when (this?.type) { + PasscodeGenerationOptions.PasscodeType.PASSWORD -> Password() + PasscodeGenerationOptions.PasscodeType.PASSPHRASE -> Passphrase() + else -> Password() + } + +private val UsernameGenerationOptions?.usernameType: Username.UsernameType + get() = when (this?.type) { + UsernameGenerationOptions.UsernameType.PLUS_ADDRESSED_EMAIL -> PlusAddressedEmail() + UsernameGenerationOptions.UsernameType.CATCH_ALL_EMAIL -> CatchAllEmail() + UsernameGenerationOptions.UsernameType.FORWARDED_EMAIL_ALIAS -> ForwardedEmailAlias() + UsernameGenerationOptions.UsernameType.RANDOM_WORD -> RandomWord() + else -> PlusAddressedEmail() + } + private fun UsernameGenerationOptions.ForwardedEmailServiceType?.toServiceType( options: UsernameGenerationOptions, ): ForwardedEmailAlias.ServiceType? { diff --git a/app/src/test/java/com/x8bit/bitwarden/data/tools/generator/datasource/disk/GeneratorDiskSourceTest.kt b/app/src/test/java/com/x8bit/bitwarden/data/tools/generator/datasource/disk/GeneratorDiskSourceTest.kt index 76d92520f2..9a804bbb30 100644 --- a/app/src/test/java/com/x8bit/bitwarden/data/tools/generator/datasource/disk/GeneratorDiskSourceTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/data/tools/generator/datasource/disk/GeneratorDiskSourceTest.kt @@ -29,6 +29,7 @@ class GeneratorDiskSourceTest { val userId = "userId" val passcodeOptions = PasscodeGenerationOptions( + type = PasscodeGenerationOptions.PasscodeType.PASSWORD, length = 14, allowAmbiguousChar = false, hasNumbers = true, @@ -81,6 +82,7 @@ class GeneratorDiskSourceTest { fun `getPasscodeGenerationOptions should return correct options when available`() { val userId = "user123" val options = PasscodeGenerationOptions( + type = PasscodeGenerationOptions.PasscodeType.PASSWORD, length = 14, allowAmbiguousChar = false, hasNumbers = true, @@ -118,6 +120,7 @@ class GeneratorDiskSourceTest { fun `storePasscodeGenerationOptions should correctly store options`() { val userId = "user123" val options = PasscodeGenerationOptions( + type = PasscodeGenerationOptions.PasscodeType.PASSWORD, length = 14, allowAmbiguousChar = false, hasNumbers = true, diff --git a/app/src/test/java/com/x8bit/bitwarden/data/tools/generator/repository/GeneratorRepositoryTest.kt b/app/src/test/java/com/x8bit/bitwarden/data/tools/generator/repository/GeneratorRepositoryTest.kt index 6036684c6d..a34bf1b28b 100644 --- a/app/src/test/java/com/x8bit/bitwarden/data/tools/generator/repository/GeneratorRepositoryTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/data/tools/generator/repository/GeneratorRepositoryTest.kt @@ -421,6 +421,7 @@ class GeneratorRepositoryTest { fun `getPasscodeGenerationOptions should return options when available`() = runTest { val userId = "activeUserId" val expectedOptions = PasscodeGenerationOptions( + type = PasscodeGenerationOptions.PasscodeType.PASSWORD, length = 14, allowAmbiguousChar = false, hasNumbers = true, @@ -476,6 +477,7 @@ class GeneratorRepositoryTest { fun `savePasscodeGenerationOptions should store options correctly`() = runTest { val userId = "activeUserId" val optionsToSave = PasscodeGenerationOptions( + type = PasscodeGenerationOptions.PasscodeType.PASSWORD, length = 14, allowAmbiguousChar = false, hasNumbers = true, @@ -607,6 +609,7 @@ class GeneratorRepositoryTest { fun `savePasscodeGenerationOptions should not store options when there is no active user`() = runTest { val optionsToSave = PasscodeGenerationOptions( + type = PasscodeGenerationOptions.PasscodeType.PASSWORD, length = 14, allowAmbiguousChar = false, hasNumbers = true, diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/tools/feature/generator/GeneratorViewModelTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/tools/feature/generator/GeneratorViewModelTest.kt index 1bcba2e91d..3bce98de58 100644 --- a/app/src/test/java/com/x8bit/bitwarden/ui/tools/feature/generator/GeneratorViewModelTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/ui/tools/feature/generator/GeneratorViewModelTest.kt @@ -19,6 +19,7 @@ import com.x8bit.bitwarden.data.tools.generator.repository.model.GeneratedPasswo import com.x8bit.bitwarden.data.tools.generator.repository.model.GeneratedRandomWordUsernameResult import com.x8bit.bitwarden.data.tools.generator.repository.model.GeneratorResult import com.x8bit.bitwarden.data.tools.generator.repository.model.PasscodeGenerationOptions +import com.x8bit.bitwarden.data.tools.generator.repository.model.UsernameGenerationOptions import com.x8bit.bitwarden.data.tools.generator.repository.util.FakeGeneratorRepository import com.x8bit.bitwarden.data.vault.datasource.network.model.PolicyTypeJson import com.x8bit.bitwarden.data.vault.datasource.network.model.SyncResponseJson @@ -112,6 +113,70 @@ class GeneratorViewModelTest : BaseViewModelTest() { assertEquals(initialPasscodeState, viewModel.stateFlow.value) } + @Test + fun `initial state should be correct for username modal`() { + val usernameGenerationOptions = UsernameGenerationOptions( + type = UsernameGenerationOptions.UsernameType.RANDOM_WORD, + ) + fakeGeneratorRepository.saveUsernameGenerationOptions(usernameGenerationOptions) + val expected = GeneratorState( + generatedText = "randomWord", + selectedType = GeneratorState.MainType.Username( + selectedType = GeneratorState.MainType.Username.UsernameType.RandomWord( + capitalize = false, + includeNumber = false, + ), + ), + generatorMode = GeneratorMode.Modal.Username(website = ""), + currentEmailAddress = "currentEmail", + isUnderPolicy = false, + website = "", + ) + + val viewModel = createViewModel( + state = null, + type = "username_generator", + website = "", + ) + assertEquals(expected, viewModel.stateFlow.value) + } + + @Test + fun `initial state should be correct for passcode modal`() { + val passcodeGenerationOptions = PasscodeGenerationOptions( + type = PasscodeGenerationOptions.PasscodeType.PASSPHRASE, + length = 14, + allowAmbiguousChar = true, + hasNumbers = true, + minNumber = 1, + hasUppercase = true, + hasLowercase = true, + allowSpecial = false, + minSpecial = 1, + numWords = 3, + wordSeparator = "-", + allowCapitalize = false, + allowIncludeNumber = false, + ) + fakeGeneratorRepository.savePasscodeGenerationOptions(passcodeGenerationOptions) + val expected = GeneratorState( + generatedText = "updatedPassphrase", + selectedType = GeneratorState.MainType.Passcode( + selectedType = GeneratorState.MainType.Passcode.PasscodeType.Passphrase(), + ), + generatorMode = GeneratorMode.Modal.Password, + currentEmailAddress = "currentEmail", + isUnderPolicy = false, + website = null, + ) + + val viewModel = createViewModel( + state = null, + type = "password_generator", + ) + assertEquals(expected, viewModel.stateFlow.value) + } + @Test fun `activePolicyFlow changes should update state`() = runTest { val payload = mapOf( @@ -253,6 +318,7 @@ class GeneratorViewModelTest : BaseViewModelTest() { val initialState = viewModel.stateFlow.value val updatedPasswordOptions = PasscodeGenerationOptions( + type = PasscodeGenerationOptions.PasscodeType.PASSWORD, length = 14, allowAmbiguousChar = false, hasNumbers = true, @@ -331,6 +397,7 @@ class GeneratorViewModelTest : BaseViewModelTest() { val initialState = viewModel.stateFlow.value val updatedPassphraseOptions = PasscodeGenerationOptions( + type = PasscodeGenerationOptions.PasscodeType.PASSPHRASE, length = 14, allowAmbiguousChar = false, hasNumbers = true, @@ -2336,8 +2403,14 @@ class GeneratorViewModelTest : BaseViewModelTest() { private fun createViewModel( state: GeneratorState? = initialPasscodeState, + type: String? = null, + website: String? = null, ): GeneratorViewModel = createViewModel( - savedStateHandle = SavedStateHandle().apply { set("state", state) }, + savedStateHandle = SavedStateHandle().apply { + set("state", state) + set("generator_mode_type", type) + set("generator_website", website) + }, ) private fun setupMockPassphraseTypePolicy() {