From 5e2e23edec29a6fb7a66bfe48f8e2d665335eef6 Mon Sep 17 00:00:00 2001 From: Joshua Queen <139182194+joshua-livefront@users.noreply.github.com> Date: Fri, 12 Jan 2024 11:12:40 -0500 Subject: [PATCH] BIT-1419: Username generation options persistence (#586) --- .../datasource/disk/GeneratorDiskSource.kt | 11 + .../disk/GeneratorDiskSourceImpl.kt | 18 ++ .../repository/GeneratorRepository.kt | 13 + .../repository/GeneratorRepositoryImpl.kt | 11 + .../model/UsernameGenerationOptions.kt | 129 ++++++++++ .../feature/generator/GeneratorViewModel.kt | 222 ++++++++++++++++-- .../disk/GeneratorDiskSourceTest.kt | 69 ++++++ .../repository/GeneratorRepositoryTest.kt | 115 +++++++++ .../util/FakeGeneratorRepository.kt | 11 + 9 files changed, 580 insertions(+), 19 deletions(-) create mode 100644 app/src/main/java/com/x8bit/bitwarden/data/tools/generator/repository/model/UsernameGenerationOptions.kt diff --git a/app/src/main/java/com/x8bit/bitwarden/data/tools/generator/datasource/disk/GeneratorDiskSource.kt b/app/src/main/java/com/x8bit/bitwarden/data/tools/generator/datasource/disk/GeneratorDiskSource.kt index 11055b047..2c9a6504a 100644 --- a/app/src/main/java/com/x8bit/bitwarden/data/tools/generator/datasource/disk/GeneratorDiskSource.kt +++ b/app/src/main/java/com/x8bit/bitwarden/data/tools/generator/datasource/disk/GeneratorDiskSource.kt @@ -1,6 +1,7 @@ package com.x8bit.bitwarden.data.tools.generator.datasource.disk import com.x8bit.bitwarden.data.tools.generator.repository.model.PasscodeGenerationOptions +import com.x8bit.bitwarden.data.tools.generator.repository.model.UsernameGenerationOptions /** * Primary access point for disk information related to generation. @@ -16,4 +17,14 @@ interface GeneratorDiskSource { * Stores a user's passcode generation options using a [userId]. */ fun storePasscodeGenerationOptions(userId: String, options: PasscodeGenerationOptions?) + + /** + * Retrieves a user's username generation options using a [userId]. + */ + fun getUsernameGenerationOptions(userId: String): UsernameGenerationOptions? + + /** + * Stores a user's username generation options using a [userId]. + */ + fun storeUsernameGenerationOptions(userId: String, options: UsernameGenerationOptions?) } diff --git a/app/src/main/java/com/x8bit/bitwarden/data/tools/generator/datasource/disk/GeneratorDiskSourceImpl.kt b/app/src/main/java/com/x8bit/bitwarden/data/tools/generator/datasource/disk/GeneratorDiskSourceImpl.kt index 40c092143..877c45831 100644 --- a/app/src/main/java/com/x8bit/bitwarden/data/tools/generator/datasource/disk/GeneratorDiskSourceImpl.kt +++ b/app/src/main/java/com/x8bit/bitwarden/data/tools/generator/datasource/disk/GeneratorDiskSourceImpl.kt @@ -3,10 +3,12 @@ package com.x8bit.bitwarden.data.tools.generator.datasource.disk import android.content.SharedPreferences import com.x8bit.bitwarden.data.platform.datasource.disk.BaseDiskSource import com.x8bit.bitwarden.data.tools.generator.repository.model.PasscodeGenerationOptions +import com.x8bit.bitwarden.data.tools.generator.repository.model.UsernameGenerationOptions import kotlinx.serialization.encodeToString import kotlinx.serialization.json.Json private const val PASSWORD_GENERATION_OPTIONS_KEY = "passwordGenerationOptions" +private const val USERNAME_GENERATION_OPTIONS_KEY = "usernameGenerationOptions" /** * Primary implementation of [GeneratorDiskSource]. @@ -35,4 +37,20 @@ class GeneratorDiskSourceImpl( private fun getPasswordGenerationOptionsKey(userId: String): String = "${BASE_KEY}_${PASSWORD_GENERATION_OPTIONS_KEY}_$userId" + + override fun getUsernameGenerationOptions(userId: String): UsernameGenerationOptions? { + val key = getUsernameGenerationOptionsKey(userId) + return getString(key)?.let { json.decodeFromString(it) } + } + + override fun storeUsernameGenerationOptions( + userId: String, + options: UsernameGenerationOptions?, + ) { + val key = getUsernameGenerationOptionsKey(userId) + putString(key, options?.let { json.encodeToString(it) }) + } + + private fun getUsernameGenerationOptionsKey(userId: String): String = + "${BASE_KEY}_${USERNAME_GENERATION_OPTIONS_KEY}_$userId" } diff --git a/app/src/main/java/com/x8bit/bitwarden/data/tools/generator/repository/GeneratorRepository.kt b/app/src/main/java/com/x8bit/bitwarden/data/tools/generator/repository/GeneratorRepository.kt index 0c097f7d1..19832d407 100644 --- a/app/src/main/java/com/x8bit/bitwarden/data/tools/generator/repository/GeneratorRepository.kt +++ b/app/src/main/java/com/x8bit/bitwarden/data/tools/generator/repository/GeneratorRepository.kt @@ -1,3 +1,5 @@ +@file:Suppress("TooManyFunctions") + package com.x8bit.bitwarden.data.tools.generator.repository import com.bitwarden.core.PassphraseGeneratorRequest @@ -12,6 +14,7 @@ import com.x8bit.bitwarden.data.tools.generator.repository.model.GeneratedPasswo import com.x8bit.bitwarden.data.tools.generator.repository.model.GeneratedPlusAddressedUsernameResult import com.x8bit.bitwarden.data.tools.generator.repository.model.GeneratedRandomWordUsernameResult import com.x8bit.bitwarden.data.tools.generator.repository.model.PasscodeGenerationOptions +import com.x8bit.bitwarden.data.tools.generator.repository.model.UsernameGenerationOptions import kotlinx.coroutines.flow.StateFlow /** @@ -79,6 +82,16 @@ interface GeneratorRepository { */ fun savePasscodeGenerationOptions(options: PasscodeGenerationOptions) + /** + * Get the [UsernameGenerationOptions] for the current user. + */ + fun getUsernameGenerationOptions(): UsernameGenerationOptions? + + /** + * Save the [UsernameGenerationOptions] for the current user. + */ + fun saveUsernameGenerationOptions(options: UsernameGenerationOptions) + /** * Store a password history item for the current user. */ diff --git a/app/src/main/java/com/x8bit/bitwarden/data/tools/generator/repository/GeneratorRepositoryImpl.kt b/app/src/main/java/com/x8bit/bitwarden/data/tools/generator/repository/GeneratorRepositoryImpl.kt index 185a74889..fb3a447b5 100644 --- a/app/src/main/java/com/x8bit/bitwarden/data/tools/generator/repository/GeneratorRepositoryImpl.kt +++ b/app/src/main/java/com/x8bit/bitwarden/data/tools/generator/repository/GeneratorRepositoryImpl.kt @@ -22,6 +22,7 @@ import com.x8bit.bitwarden.data.tools.generator.repository.model.GeneratedPasswo import com.x8bit.bitwarden.data.tools.generator.repository.model.GeneratedPlusAddressedUsernameResult import com.x8bit.bitwarden.data.tools.generator.repository.model.GeneratedRandomWordUsernameResult 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.vault.datasource.sdk.VaultSdkSource import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.Flow @@ -193,6 +194,16 @@ class GeneratorRepositoryImpl( userId?.let { generatorDiskSource.storePasscodeGenerationOptions(it, options) } } + override fun getUsernameGenerationOptions(): UsernameGenerationOptions? { + val userId = authDiskSource.userState?.activeUserId + return userId?.let { generatorDiskSource.getUsernameGenerationOptions(it) } + } + + override fun saveUsernameGenerationOptions(options: UsernameGenerationOptions) { + val userId = authDiskSource.userState?.activeUserId + userId?.let { generatorDiskSource.storeUsernameGenerationOptions(it, options) } + } + override suspend fun storePasswordHistory(passwordHistoryView: PasswordHistoryView) { val userId = authDiskSource.userState?.activeUserId ?: return val encryptedPasswordHistory = vaultSdkSource diff --git a/app/src/main/java/com/x8bit/bitwarden/data/tools/generator/repository/model/UsernameGenerationOptions.kt b/app/src/main/java/com/x8bit/bitwarden/data/tools/generator/repository/model/UsernameGenerationOptions.kt new file mode 100644 index 000000000..ead76b85c --- /dev/null +++ b/app/src/main/java/com/x8bit/bitwarden/data/tools/generator/repository/model/UsernameGenerationOptions.kt @@ -0,0 +1,129 @@ +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 generating usernames. + * + * @property type The type of username to be generated, as defined in UsernameType. + * @property serviceType The type of email forwarding service to be used, + * as defined in ForwardedEmailServiceType. + * @property capitalizeRandomWordUsername Indicates whether to capitalize the username. + * @property includeNumberRandomWordUsername Indicates whether to include a number in the username. + * @property plusAddressedEmail The email address to be used for plus-addressing. + * @property catchAllEmailDomain The domain name to be used for catch-all email addresses. + * @property firefoxRelayApiAccessToken The API access token for Firefox Relay. + * @property simpleLoginApiKey The API key for SimpleLogin. + * @property duckDuckGoApiKey The API key for DuckDuckGo. + * @property fastMailApiKey The API key for FastMail. + * @property anonAddyApiAccessToken The API access token for AnonAddy. + * @property anonAddyDomainName The domain name associated with AnonAddy. + * @property forwardEmailApiAccessToken The API access token for Forward Email. + * @property forwardEmailDomainName The domain name associated with Forward Email. + * @property emailWebsite The website associated with the email service. + */ +@Serializable +data class UsernameGenerationOptions( + @SerialName("type") + val type: UsernameType, + + @SerialName("serviceType") + val serviceType: ForwardedEmailServiceType? = null, + + @SerialName("capitalizeRandomWordUsername") + val capitalizeRandomWordUsername: Boolean? = null, + + @SerialName("includeNumberRandomWordUsername") + val includeNumberRandomWordUsername: Boolean? = null, + + @SerialName("plusAddressedEmail") + val plusAddressedEmail: String? = null, + + @SerialName("catchAllEmailDomain") + val catchAllEmailDomain: String? = null, + + @SerialName("firefoxRelayApiAccessToken") + val firefoxRelayApiAccessToken: String? = null, + + @SerialName("simpleLoginApiKey") + val simpleLoginApiKey: String? = null, + + @SerialName("duckDuckGoApiKey") + val duckDuckGoApiKey: String? = null, + + @SerialName("fastMailApiKey") + val fastMailApiKey: String? = null, + + @SerialName("anonAddyApiAccessToken") + val anonAddyApiAccessToken: String? = null, + + @SerialName("anonAddyDomainName") + val anonAddyDomainName: String? = null, + + @SerialName("forwardEmailApiAccessToken") + val forwardEmailApiAccessToken: String? = null, + + @SerialName("forwardEmailDomainName") + val forwardEmailDomainName: String? = null, + + @SerialName("emailWebsite") + val emailWebsite: String? = null, +) { + + /** + * Represents different Username Types. + */ + @Serializable(with = UsernameTypeSerializer::class) + enum class UsernameType { + @SerialName("0") + PLUS_ADDRESSED_EMAIL, + + @SerialName("1") + CATCH_ALL_EMAIL, + + @SerialName("2") + FORWARDED_EMAIL_ALIAS, + + @SerialName("3") + RANDOM_WORD, + } + + /** + * Represents different Service Types within the ForwardedEmailAlias Username Type. + */ + @Serializable(with = ForwardedEmailServiceTypeSerializer::class) + enum class ForwardedEmailServiceType { + @SerialName("-1") + NONE, + + @SerialName("0") + ANON_ADDY, + + @SerialName("1") + FIREFOX_RELAY, + + @SerialName("2") + SIMPLE_LOGIN, + + @SerialName("3") + DUCK_DUCK_GO, + + @SerialName("4") + FASTMAIL, + } +} + +@Keep +private class UsernameTypeSerializer : + BaseEnumeratedIntSerializer( + UsernameGenerationOptions.UsernameType.values(), + ) + +@Keep +private class ForwardedEmailServiceTypeSerializer : + BaseEnumeratedIntSerializer( + UsernameGenerationOptions.ForwardedEmailServiceType.values(), + ) 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 952b890a6..e4f45bedd 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 @@ -20,9 +20,11 @@ import com.x8bit.bitwarden.data.tools.generator.repository.model.GeneratedPasswo import com.x8bit.bitwarden.data.tools.generator.repository.model.GeneratedPlusAddressedUsernameResult import com.x8bit.bitwarden.data.tools.generator.repository.model.GeneratedRandomWordUsernameResult import com.x8bit.bitwarden.data.tools.generator.repository.model.PasscodeGenerationOptions +import com.x8bit.bitwarden.data.tools.generator.repository.model.UsernameGenerationOptions 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.platform.base.util.orNullIfBlank import com.x8bit.bitwarden.ui.tools.feature.generator.GeneratorState.MainType.Passcode import com.x8bit.bitwarden.ui.tools.feature.generator.GeneratorState.MainType.Passcode.PasscodeType.Passphrase import com.x8bit.bitwarden.ui.tools.feature.generator.GeneratorState.MainType.Passcode.PasscodeType.Password @@ -140,7 +142,7 @@ class GeneratorViewModel @Inject constructor( } is GeneratorAction.Internal.UpdateGeneratedForwardedServiceUsernameResult -> { - handleUpdateForwadedServiceGeneratedUsernameResult(action) + handleUpdateForwardedServiceGeneratedUsernameResult(action) } is GeneratorAction.MainType.Username.UsernameTypeOptionSelect -> { @@ -233,21 +235,48 @@ class GeneratorViewModel @Inject constructor( } private fun loadUsernameOptions(selectedType: Username) { - val updatedSelectedType = when (selectedType.selectedType) { - is PlusAddressedEmail -> Username( - selectedType = PlusAddressedEmail( - // For convenience the default is an empty email value. We can supply the - // dynamic value here before updating the state. - email = state.currentEmailAddress, - ), - ) + val options = generatorRepository.getUsernameGenerationOptions() + val updatedSelectedType = when (val type = selectedType.selectedType) { + is PlusAddressedEmail -> { + val emailToUse = options + ?.plusAddressedEmail + ?.orNullIfBlank() + ?: state.currentEmailAddress - else -> selectedType + Username(selectedType = PlusAddressedEmail(email = emailToUse)) + } + + is CatchAllEmail -> { + val catchAllEmail = CatchAllEmail( + domainName = options?.catchAllEmailDomain ?: type.domainName, + ) + Username(selectedType = catchAllEmail) + } + + is RandomWord -> { + val randomWord = RandomWord( + capitalize = options?.capitalizeRandomWordUsername ?: type.capitalize, + includeNumber = options?.includeNumberRandomWordUsername ?: type.includeNumber, + ) + Username(selectedType = randomWord) + } + + is ForwardedEmailAlias -> { + val mappedServiceType = options + ?.serviceType + ?.toServiceType(options) + ?: type.selectedServiceType + + Username( + selectedType = ForwardedEmailAlias( + selectedServiceType = mappedServiceType, + obfuscatedText = "", + ), + ) + } } - mutableStateFlow.update { - it.copy(selectedType = updatedSelectedType) - } + mutableStateFlow.update { it.copy(selectedType = updatedSelectedType) } } private fun savePasswordOptionsToDisk(password: Password) { @@ -278,6 +307,82 @@ class GeneratorViewModel @Inject constructor( generatorRepository.savePasscodeGenerationOptions(newOptions) } + private fun savePlusAddressedEmailOptionsToDisk(plusAddressedEmail: PlusAddressedEmail) { + val options = generatorRepository.getUsernameGenerationOptions() + ?: generateUsernameDefaultOptions() + val newOptions = options.copy( + type = UsernameGenerationOptions.UsernameType.PLUS_ADDRESSED_EMAIL, + plusAddressedEmail = plusAddressedEmail.email, + ) + + generatorRepository.saveUsernameGenerationOptions(newOptions) + } + + private fun saveCatchAllEmailOptionsToDisk(catchAllEmail: CatchAllEmail) { + val options = generatorRepository + .getUsernameGenerationOptions() ?: generateUsernameDefaultOptions() + val newOptions = options.copy( + type = UsernameGenerationOptions.UsernameType.CATCH_ALL_EMAIL, + catchAllEmailDomain = catchAllEmail.domainName, + ) + generatorRepository.saveUsernameGenerationOptions(newOptions) + } + + private fun saveRandomWordOptionsToDisk(randomWord: RandomWord) { + val options = generatorRepository + .getUsernameGenerationOptions() ?: generateUsernameDefaultOptions() + val newOptions = options.copy( + type = UsernameGenerationOptions.UsernameType.RANDOM_WORD, + capitalizeRandomWordUsername = randomWord.capitalize, + includeNumberRandomWordUsername = randomWord.includeNumber, + ) + generatorRepository.saveUsernameGenerationOptions(newOptions) + } + + private fun saveForwardedEmailAliasServiceTypeToDisk(forwardedEmailAlias: ForwardedEmailAlias) { + val options = + generatorRepository.getUsernameGenerationOptions() ?: generateUsernameDefaultOptions() + val newOptions = when (forwardedEmailAlias.selectedServiceType) { + is AddyIo -> options.copy( + type = UsernameGenerationOptions.UsernameType.FORWARDED_EMAIL_ALIAS, + serviceType = UsernameGenerationOptions.ForwardedEmailServiceType.ANON_ADDY, + anonAddyApiAccessToken = forwardedEmailAlias.selectedServiceType.apiAccessToken, + anonAddyDomainName = forwardedEmailAlias.selectedServiceType.domainName, + ) + + is DuckDuckGo -> options.copy( + type = UsernameGenerationOptions.UsernameType.FORWARDED_EMAIL_ALIAS, + serviceType = UsernameGenerationOptions.ForwardedEmailServiceType.DUCK_DUCK_GO, + duckDuckGoApiKey = forwardedEmailAlias.selectedServiceType.apiKey, + ) + + is FastMail -> options.copy( + type = UsernameGenerationOptions.UsernameType.FORWARDED_EMAIL_ALIAS, + serviceType = UsernameGenerationOptions.ForwardedEmailServiceType.FASTMAIL, + fastMailApiKey = forwardedEmailAlias.selectedServiceType.apiKey, + ) + + is FirefoxRelay -> options.copy( + type = UsernameGenerationOptions.UsernameType.FORWARDED_EMAIL_ALIAS, + serviceType = UsernameGenerationOptions.ForwardedEmailServiceType.FIREFOX_RELAY, + firefoxRelayApiAccessToken = forwardedEmailAlias.selectedServiceType.apiAccessToken, + ) + + is SimpleLogin -> options.copy( + type = UsernameGenerationOptions.UsernameType.FORWARDED_EMAIL_ALIAS, + serviceType = UsernameGenerationOptions.ForwardedEmailServiceType.SIMPLE_LOGIN, + simpleLoginApiKey = forwardedEmailAlias.selectedServiceType.apiKey, + ) + + else -> options.copy( + type = UsernameGenerationOptions.UsernameType.FORWARDED_EMAIL_ALIAS, + serviceType = UsernameGenerationOptions.ForwardedEmailServiceType.NONE, + ) + } + + generatorRepository.saveUsernameGenerationOptions(newOptions) + } + private fun generatePasscodeDefaultOptions(): PasscodeGenerationOptions { val defaultPassword = Password() val defaultPassphrase = Passphrase() @@ -298,6 +403,26 @@ class GeneratorViewModel @Inject constructor( ) } + private fun generateUsernameDefaultOptions(): UsernameGenerationOptions { + return UsernameGenerationOptions( + type = UsernameGenerationOptions.UsernameType.PLUS_ADDRESSED_EMAIL, + serviceType = UsernameGenerationOptions.ForwardedEmailServiceType.NONE, + capitalizeRandomWordUsername = false, + includeNumberRandomWordUsername = false, + plusAddressedEmail = "", + catchAllEmailDomain = "", + firefoxRelayApiAccessToken = "", + simpleLoginApiKey = "", + duckDuckGoApiKey = "", + fastMailApiKey = "", + anonAddyApiAccessToken = "", + anonAddyDomainName = "", + forwardEmailApiAccessToken = "", + forwardEmailDomainName = "", + emailWebsite = "", + ) + } + private suspend fun generatePassword(password: Password) { val request = PasswordGeneratorRequest( lowercase = password.useLowercase, @@ -423,7 +548,7 @@ class GeneratorViewModel @Inject constructor( } } - private fun handleUpdateForwadedServiceGeneratedUsernameResult( + private fun handleUpdateForwardedServiceGeneratedUsernameResult( action: GeneratorAction.Internal.UpdateGeneratedForwardedServiceUsernameResult, ) { when (val result = action.result) { @@ -702,25 +827,48 @@ class GeneratorViewModel @Inject constructor( .ForwardedEmailAlias .ServiceTypeOptionSelect, ) { + val options = generatorRepository.getUsernameGenerationOptions() + ?: generateUsernameDefaultOptions() when (action.serviceTypeOption) { ForwardedEmailAlias.ServiceTypeOption.ADDY_IO -> updateForwardedEmailAliasType { - ForwardedEmailAlias(selectedServiceType = AddyIo()) + ForwardedEmailAlias( + selectedServiceType = AddyIo( + apiAccessToken = options.anonAddyApiAccessToken.orEmpty(), + domainName = options.anonAddyDomainName.orEmpty(), + ), + ) } ForwardedEmailAlias.ServiceTypeOption.DUCK_DUCK_GO -> updateForwardedEmailAliasType { - ForwardedEmailAlias(selectedServiceType = DuckDuckGo()) + ForwardedEmailAlias( + selectedServiceType = DuckDuckGo( + apiKey = options.duckDuckGoApiKey.orEmpty(), + ), + ) } ForwardedEmailAlias.ServiceTypeOption.FAST_MAIL -> updateForwardedEmailAliasType { - ForwardedEmailAlias(selectedServiceType = FastMail()) + ForwardedEmailAlias( + selectedServiceType = FastMail( + apiKey = options.fastMailApiKey.orEmpty(), + ), + ) } ForwardedEmailAlias.ServiceTypeOption.FIREFOX_RELAY -> updateForwardedEmailAliasType { - ForwardedEmailAlias(selectedServiceType = FirefoxRelay()) + ForwardedEmailAlias( + selectedServiceType = FirefoxRelay( + apiAccessToken = options.firefoxRelayApiAccessToken.orEmpty(), + ), + ) } ForwardedEmailAlias.ServiceTypeOption.SIMPLE_LOGIN -> updateForwardedEmailAliasType { - ForwardedEmailAlias(selectedServiceType = SimpleLogin()) + ForwardedEmailAlias( + selectedServiceType = SimpleLogin( + apiKey = options.simpleLoginApiKey.orEmpty(), + ), + ) } } } @@ -962,24 +1110,28 @@ class GeneratorViewModel @Inject constructor( is Username -> when (val selectedType = updatedMainType.selectedType) { is ForwardedEmailAlias -> { + saveForwardedEmailAliasServiceTypeToDisk(selectedType) if (isManualRegeneration) { generateForwardedEmailAlias(selectedType) } } is CatchAllEmail -> { + saveCatchAllEmailOptionsToDisk(selectedType) if (isManualRegeneration) { generateCatchAllEmail(selectedType) } } is PlusAddressedEmail -> { + savePlusAddressedEmailOptionsToDisk(selectedType) if (isManualRegeneration) { generatePlusAddressedEmail(selectedType) } } is RandomWord -> { + saveRandomWordOptionsToDisk(selectedType) if (isManualRegeneration) { generateRandomWordUsername(selectedType) } @@ -1024,6 +1176,7 @@ class GeneratorViewModel @Inject constructor( ) sendAction(GeneratorAction.Internal.UpdateGeneratedRandomWordUsernameResult(result)) } + private inline fun updateGeneratorMainTypePasscode( crossinline block: (Passcode) -> Passcode, ) { @@ -2050,3 +2203,34 @@ private fun Password.enforceAtLeastOneToggleOn(): Password = } else { this } + +private fun UsernameGenerationOptions.ForwardedEmailServiceType?.toServiceType( + options: UsernameGenerationOptions, +): ForwardedEmailAlias.ServiceType? { + return when (this) { + UsernameGenerationOptions.ForwardedEmailServiceType.FIREFOX_RELAY -> { + FirefoxRelay(apiAccessToken = options.firefoxRelayApiAccessToken.orEmpty()) + } + + UsernameGenerationOptions.ForwardedEmailServiceType.SIMPLE_LOGIN -> { + SimpleLogin(apiKey = options.simpleLoginApiKey.orEmpty()) + } + + UsernameGenerationOptions.ForwardedEmailServiceType.DUCK_DUCK_GO -> { + DuckDuckGo(apiKey = options.duckDuckGoApiKey.orEmpty()) + } + + UsernameGenerationOptions.ForwardedEmailServiceType.FASTMAIL -> { + FastMail(apiKey = options.fastMailApiKey.orEmpty()) + } + + UsernameGenerationOptions.ForwardedEmailServiceType.ANON_ADDY -> { + AddyIo( + apiAccessToken = options.anonAddyApiAccessToken.orEmpty(), + domainName = options.anonAddyDomainName.orEmpty(), + ) + } + + else -> null + } +} 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 8dcfc2b8f..54779e591 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 @@ -2,6 +2,7 @@ package com.x8bit.bitwarden.data.tools.generator.datasource.disk import com.x8bit.bitwarden.data.platform.base.FakeSharedPreferences import com.x8bit.bitwarden.data.tools.generator.repository.model.PasscodeGenerationOptions +import com.x8bit.bitwarden.data.tools.generator.repository.model.UsernameGenerationOptions import kotlinx.serialization.ExperimentalSerializationApi import kotlinx.serialization.encodeToString import kotlinx.serialization.json.Json @@ -89,4 +90,72 @@ class GeneratorDiskSourceTest { assertNotNull(storedValue) assertEquals(json.encodeToString(options), storedValue) } + + @Test + fun `getUsernameGenerationOptions should return correct options when available`() { + val userId = "user123" + val options = UsernameGenerationOptions( + type = UsernameGenerationOptions.UsernameType.RANDOM_WORD, + serviceType = UsernameGenerationOptions.ForwardedEmailServiceType.NONE, + capitalizeRandomWordUsername = true, + includeNumberRandomWordUsername = false, + plusAddressedEmail = "example+plus@gmail.com", + catchAllEmailDomain = "example.com", + firefoxRelayApiAccessToken = "access_token_firefox_relay", + simpleLoginApiKey = "api_key_simple_login", + duckDuckGoApiKey = "api_key_duck_duck_go", + fastMailApiKey = "api_key_fast_mail", + anonAddyApiAccessToken = "access_token_anon_addy", + anonAddyDomainName = "anonaddy.com", + forwardEmailApiAccessToken = "access_token_forward_email", + forwardEmailDomainName = "forwardemail.net", + emailWebsite = "email.example.com", + ) + + val key = "bwPreferencesStorage_usernameGenerationOptions_$userId" + fakeSharedPreferences.edit().putString(key, json.encodeToString(options)).apply() + + val result = generatorDiskSource.getUsernameGenerationOptions(userId) + + assertEquals(options, result) + } + + @Test + fun `getUsernameGenerationOptions should return null when options are not available`() { + val userId = "user123" + + val result = generatorDiskSource.getUsernameGenerationOptions(userId) + + assertNull(result) + } + + @Test + fun `storeUsernameGenerationOptions should correctly store options`() { + val userId = "user123" + val options = UsernameGenerationOptions( + type = UsernameGenerationOptions.UsernameType.RANDOM_WORD, + serviceType = UsernameGenerationOptions.ForwardedEmailServiceType.NONE, + capitalizeRandomWordUsername = true, + includeNumberRandomWordUsername = false, + plusAddressedEmail = "example+plus@gmail.com", + catchAllEmailDomain = "example.com", + firefoxRelayApiAccessToken = "access_token_firefox_relay", + simpleLoginApiKey = "api_key_simple_login", + duckDuckGoApiKey = "api_key_duck_duck_go", + fastMailApiKey = "api_key_fast_mail", + anonAddyApiAccessToken = "access_token_anon_addy", + anonAddyDomainName = "anonaddy.com", + forwardEmailApiAccessToken = "access_token_forward_email", + forwardEmailDomainName = "forwardemail.net", + emailWebsite = "email.example.com", + ) + + val key = "bwPreferencesStorage_usernameGenerationOptions_$userId" + + generatorDiskSource.storeUsernameGenerationOptions(userId, options) + + val storedValue = fakeSharedPreferences.getString(key, null) + assertNotNull(storedValue) + assertEquals(json.encodeToString(options), storedValue) + } } 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 bdddb9203..94fd15278 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 @@ -31,6 +31,7 @@ import com.x8bit.bitwarden.data.tools.generator.repository.model.GeneratedPasswo import com.x8bit.bitwarden.data.tools.generator.repository.model.GeneratedPlusAddressedUsernameResult import com.x8bit.bitwarden.data.tools.generator.repository.model.GeneratedRandomWordUsernameResult 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.vault.datasource.sdk.VaultSdkSource import io.mockk.coEvery import io.mockk.coVerify @@ -50,6 +51,7 @@ import org.junit.jupiter.api.Assertions.assertTrue import org.junit.jupiter.api.Test import java.time.Instant +@Suppress("LargeClass") class GeneratorRepositoryTest { private val mutableUserStateFlow = MutableStateFlow(null) @@ -644,6 +646,119 @@ class GeneratorRepositoryTest { } } + @Test + fun `getUsernameGenerationOptions should return options when available`() = runTest { + val userId = "activeUserId" + val expectedOptions = UsernameGenerationOptions( + type = UsernameGenerationOptions.UsernameType.RANDOM_WORD, + serviceType = UsernameGenerationOptions.ForwardedEmailServiceType.NONE, + capitalizeRandomWordUsername = true, + includeNumberRandomWordUsername = false, + plusAddressedEmail = "example+plus@gmail.com", + catchAllEmailDomain = "example.com", + firefoxRelayApiAccessToken = "access_token_firefox_relay", + simpleLoginApiKey = "api_key_simple_login", + duckDuckGoApiKey = "api_key_duck_duck_go", + fastMailApiKey = "api_key_fast_mail", + anonAddyApiAccessToken = "access_token_anon_addy", + anonAddyDomainName = "anonaddy.com", + forwardEmailApiAccessToken = "access_token_forward_email", + forwardEmailDomainName = "forwardemail.net", + emailWebsite = "email.example.com", + ) + + coEvery { authDiskSource.userState } returns USER_STATE + coEvery { generatorDiskSource.getUsernameGenerationOptions(userId) } returns expectedOptions + + val result = repository.getUsernameGenerationOptions() + + assertEquals(expectedOptions, result) + coVerify { generatorDiskSource.getUsernameGenerationOptions(userId) } + } + + @Test + fun `getUsernameGenerationOptions should return null when there is no active user`() = runTest { + coEvery { authDiskSource.userState } returns null + + val result = repository.getUsernameGenerationOptions() + + assertNull(result) + coVerify(exactly = 0) { generatorDiskSource.getUsernameGenerationOptions(any()) } + } + + @Test + fun `getUsernameGenerationOptions should return null when no data is stored for active user`() = + runTest { + val userId = "activeUserId" + coEvery { authDiskSource.userState } returns USER_STATE + coEvery { generatorDiskSource.getUsernameGenerationOptions(userId) } returns null + + val result = repository.getUsernameGenerationOptions() + + assertNull(result) + coVerify { generatorDiskSource.getUsernameGenerationOptions(userId) } + } + + @Test + fun `saveUsernameGenerationOptions should store options correctly`() = runTest { + val userId = "activeUserId" + val optionsToSave = UsernameGenerationOptions( + type = UsernameGenerationOptions.UsernameType.RANDOM_WORD, + serviceType = UsernameGenerationOptions.ForwardedEmailServiceType.NONE, + capitalizeRandomWordUsername = true, + includeNumberRandomWordUsername = false, + plusAddressedEmail = "example+plus@gmail.com", + catchAllEmailDomain = "example.com", + firefoxRelayApiAccessToken = "access_token_firefox_relay", + simpleLoginApiKey = "api_key_simple_login", + duckDuckGoApiKey = "api_key_duck_duck_go", + fastMailApiKey = "api_key_fast_mail", + anonAddyApiAccessToken = "access_token_anon_addy", + anonAddyDomainName = "anonaddy.com", + forwardEmailApiAccessToken = "access_token_forward_email", + forwardEmailDomainName = "forwardemail.net", + emailWebsite = "email.example.com", + ) + + coEvery { authDiskSource.userState } returns USER_STATE + coEvery { + generatorDiskSource.storeUsernameGenerationOptions(userId, optionsToSave) + } just runs + + repository.saveUsernameGenerationOptions(optionsToSave) + + coVerify { generatorDiskSource.storeUsernameGenerationOptions(userId, optionsToSave) } + } + + @Test + fun `saveUsernameGenerationOptions should not store options when there is no active user`() = + runTest { + val optionsToSave = UsernameGenerationOptions( + type = UsernameGenerationOptions.UsernameType.RANDOM_WORD, + serviceType = UsernameGenerationOptions.ForwardedEmailServiceType.NONE, + capitalizeRandomWordUsername = true, + includeNumberRandomWordUsername = false, + plusAddressedEmail = "example+plus@gmail.com", + catchAllEmailDomain = "example.com", + firefoxRelayApiAccessToken = "access_token_firefox_relay", + simpleLoginApiKey = "api_key_simple_login", + duckDuckGoApiKey = "api_key_duck_duck_go", + fastMailApiKey = "api_key_fast_mail", + anonAddyApiAccessToken = "access_token_anon_addy", + anonAddyDomainName = "anonaddy.com", + forwardEmailApiAccessToken = "access_token_forward_email", + forwardEmailDomainName = "forwardemail.net", + emailWebsite = "email.example.com", + ) + coEvery { authDiskSource.userState } returns null + + repository.saveUsernameGenerationOptions(optionsToSave) + + coVerify(exactly = 0) { + generatorDiskSource.storeUsernameGenerationOptions(any(), any()) + } + } + private val USER_STATE = UserStateJson( activeUserId = "activeUserId", accounts = mapOf( diff --git a/app/src/test/java/com/x8bit/bitwarden/data/tools/generator/repository/util/FakeGeneratorRepository.kt b/app/src/test/java/com/x8bit/bitwarden/data/tools/generator/repository/util/FakeGeneratorRepository.kt index ca2ba5158..ce4e1ddab 100644 --- a/app/src/test/java/com/x8bit/bitwarden/data/tools/generator/repository/util/FakeGeneratorRepository.kt +++ b/app/src/test/java/com/x8bit/bitwarden/data/tools/generator/repository/util/FakeGeneratorRepository.kt @@ -13,6 +13,7 @@ import com.x8bit.bitwarden.data.tools.generator.repository.model.GeneratedPasswo import com.x8bit.bitwarden.data.tools.generator.repository.model.GeneratedPlusAddressedUsernameResult import com.x8bit.bitwarden.data.tools.generator.repository.model.GeneratedRandomWordUsernameResult import com.x8bit.bitwarden.data.tools.generator.repository.model.PasscodeGenerationOptions +import com.x8bit.bitwarden.data.tools.generator.repository.model.UsernameGenerationOptions import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow @@ -30,6 +31,8 @@ class FakeGeneratorRepository : GeneratorRepository { ) private var passcodeGenerationOptions: PasscodeGenerationOptions? = null + private var usernameGenerationOptions: UsernameGenerationOptions? = null + private val mutablePasswordHistoryStateFlow = MutableStateFlow>>(LocalDataState.Loading) @@ -101,6 +104,14 @@ class FakeGeneratorRepository : GeneratorRepository { passcodeGenerationOptions = options } + override fun getUsernameGenerationOptions(): UsernameGenerationOptions? { + return usernameGenerationOptions + } + + override fun saveUsernameGenerationOptions(options: UsernameGenerationOptions) { + usernameGenerationOptions = options + } + override suspend fun storePasswordHistory(passwordHistoryView: PasswordHistoryView) { val currentList = mutablePasswordHistoryStateFlow.value.data.orEmpty() val updatedList = currentList + passwordHistoryView