BIT-1419: Username generation options persistence (#586)

This commit is contained in:
Joshua Queen 2024-01-12 11:12:40 -05:00 committed by Álison Fernandes
parent 1f0a1bba6f
commit 5e2e23edec
9 changed files with 580 additions and 19 deletions

View file

@ -1,6 +1,7 @@
package com.x8bit.bitwarden.data.tools.generator.datasource.disk 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.PasscodeGenerationOptions
import com.x8bit.bitwarden.data.tools.generator.repository.model.UsernameGenerationOptions
/** /**
* Primary access point for disk information related to generation. * 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]. * Stores a user's passcode generation options using a [userId].
*/ */
fun storePasscodeGenerationOptions(userId: String, options: PasscodeGenerationOptions?) 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?)
} }

View file

@ -3,10 +3,12 @@ package com.x8bit.bitwarden.data.tools.generator.datasource.disk
import android.content.SharedPreferences import android.content.SharedPreferences
import com.x8bit.bitwarden.data.platform.datasource.disk.BaseDiskSource 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.PasscodeGenerationOptions
import com.x8bit.bitwarden.data.tools.generator.repository.model.UsernameGenerationOptions
import kotlinx.serialization.encodeToString import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
private const val PASSWORD_GENERATION_OPTIONS_KEY = "passwordGenerationOptions" private const val PASSWORD_GENERATION_OPTIONS_KEY = "passwordGenerationOptions"
private const val USERNAME_GENERATION_OPTIONS_KEY = "usernameGenerationOptions"
/** /**
* Primary implementation of [GeneratorDiskSource]. * Primary implementation of [GeneratorDiskSource].
@ -35,4 +37,20 @@ class GeneratorDiskSourceImpl(
private fun getPasswordGenerationOptionsKey(userId: String): String = private fun getPasswordGenerationOptionsKey(userId: String): String =
"${BASE_KEY}_${PASSWORD_GENERATION_OPTIONS_KEY}_$userId" "${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"
} }

View file

@ -1,3 +1,5 @@
@file:Suppress("TooManyFunctions")
package com.x8bit.bitwarden.data.tools.generator.repository package com.x8bit.bitwarden.data.tools.generator.repository
import com.bitwarden.core.PassphraseGeneratorRequest 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.GeneratedPlusAddressedUsernameResult
import com.x8bit.bitwarden.data.tools.generator.repository.model.GeneratedRandomWordUsernameResult 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.PasscodeGenerationOptions
import com.x8bit.bitwarden.data.tools.generator.repository.model.UsernameGenerationOptions
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
/** /**
@ -79,6 +82,16 @@ interface GeneratorRepository {
*/ */
fun savePasscodeGenerationOptions(options: PasscodeGenerationOptions) 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. * Store a password history item for the current user.
*/ */

View file

@ -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.GeneratedPlusAddressedUsernameResult
import com.x8bit.bitwarden.data.tools.generator.repository.model.GeneratedRandomWordUsernameResult 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.PasscodeGenerationOptions
import com.x8bit.bitwarden.data.tools.generator.repository.model.UsernameGenerationOptions
import com.x8bit.bitwarden.data.vault.datasource.sdk.VaultSdkSource import com.x8bit.bitwarden.data.vault.datasource.sdk.VaultSdkSource
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
@ -193,6 +194,16 @@ class GeneratorRepositoryImpl(
userId?.let { generatorDiskSource.storePasscodeGenerationOptions(it, options) } 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) { override suspend fun storePasswordHistory(passwordHistoryView: PasswordHistoryView) {
val userId = authDiskSource.userState?.activeUserId ?: return val userId = authDiskSource.userState?.activeUserId ?: return
val encryptedPasswordHistory = vaultSdkSource val encryptedPasswordHistory = vaultSdkSource

View file

@ -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>(
UsernameGenerationOptions.UsernameType.values(),
)
@Keep
private class ForwardedEmailServiceTypeSerializer :
BaseEnumeratedIntSerializer<UsernameGenerationOptions.ForwardedEmailServiceType>(
UsernameGenerationOptions.ForwardedEmailServiceType.values(),
)

View file

@ -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.GeneratedPlusAddressedUsernameResult
import com.x8bit.bitwarden.data.tools.generator.repository.model.GeneratedRandomWordUsernameResult 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.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.BaseViewModel
import com.x8bit.bitwarden.ui.platform.base.util.Text 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.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
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.Passphrase
import com.x8bit.bitwarden.ui.tools.feature.generator.GeneratorState.MainType.Passcode.PasscodeType.Password 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 -> { is GeneratorAction.Internal.UpdateGeneratedForwardedServiceUsernameResult -> {
handleUpdateForwadedServiceGeneratedUsernameResult(action) handleUpdateForwardedServiceGeneratedUsernameResult(action)
} }
is GeneratorAction.MainType.Username.UsernameTypeOptionSelect -> { is GeneratorAction.MainType.Username.UsernameTypeOptionSelect -> {
@ -233,21 +235,48 @@ class GeneratorViewModel @Inject constructor(
} }
private fun loadUsernameOptions(selectedType: Username) { private fun loadUsernameOptions(selectedType: Username) {
val updatedSelectedType = when (selectedType.selectedType) { val options = generatorRepository.getUsernameGenerationOptions()
is PlusAddressedEmail -> Username( val updatedSelectedType = when (val type = selectedType.selectedType) {
selectedType = PlusAddressedEmail( is PlusAddressedEmail -> {
// For convenience the default is an empty email value. We can supply the val emailToUse = options
// dynamic value here before updating the state. ?.plusAddressedEmail
email = state.currentEmailAddress, ?.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 { mutableStateFlow.update { it.copy(selectedType = updatedSelectedType) }
it.copy(selectedType = updatedSelectedType)
}
} }
private fun savePasswordOptionsToDisk(password: Password) { private fun savePasswordOptionsToDisk(password: Password) {
@ -278,6 +307,82 @@ class GeneratorViewModel @Inject constructor(
generatorRepository.savePasscodeGenerationOptions(newOptions) 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 { private fun generatePasscodeDefaultOptions(): PasscodeGenerationOptions {
val defaultPassword = Password() val defaultPassword = Password()
val defaultPassphrase = Passphrase() 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) { private suspend fun generatePassword(password: Password) {
val request = PasswordGeneratorRequest( val request = PasswordGeneratorRequest(
lowercase = password.useLowercase, lowercase = password.useLowercase,
@ -423,7 +548,7 @@ class GeneratorViewModel @Inject constructor(
} }
} }
private fun handleUpdateForwadedServiceGeneratedUsernameResult( private fun handleUpdateForwardedServiceGeneratedUsernameResult(
action: GeneratorAction.Internal.UpdateGeneratedForwardedServiceUsernameResult, action: GeneratorAction.Internal.UpdateGeneratedForwardedServiceUsernameResult,
) { ) {
when (val result = action.result) { when (val result = action.result) {
@ -702,25 +827,48 @@ class GeneratorViewModel @Inject constructor(
.ForwardedEmailAlias .ForwardedEmailAlias
.ServiceTypeOptionSelect, .ServiceTypeOptionSelect,
) { ) {
val options = generatorRepository.getUsernameGenerationOptions()
?: generateUsernameDefaultOptions()
when (action.serviceTypeOption) { when (action.serviceTypeOption) {
ForwardedEmailAlias.ServiceTypeOption.ADDY_IO -> updateForwardedEmailAliasType { 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.ServiceTypeOption.DUCK_DUCK_GO -> updateForwardedEmailAliasType {
ForwardedEmailAlias(selectedServiceType = DuckDuckGo()) ForwardedEmailAlias(
selectedServiceType = DuckDuckGo(
apiKey = options.duckDuckGoApiKey.orEmpty(),
),
)
} }
ForwardedEmailAlias.ServiceTypeOption.FAST_MAIL -> updateForwardedEmailAliasType { ForwardedEmailAlias.ServiceTypeOption.FAST_MAIL -> updateForwardedEmailAliasType {
ForwardedEmailAlias(selectedServiceType = FastMail()) ForwardedEmailAlias(
selectedServiceType = FastMail(
apiKey = options.fastMailApiKey.orEmpty(),
),
)
} }
ForwardedEmailAlias.ServiceTypeOption.FIREFOX_RELAY -> updateForwardedEmailAliasType { ForwardedEmailAlias.ServiceTypeOption.FIREFOX_RELAY -> updateForwardedEmailAliasType {
ForwardedEmailAlias(selectedServiceType = FirefoxRelay()) ForwardedEmailAlias(
selectedServiceType = FirefoxRelay(
apiAccessToken = options.firefoxRelayApiAccessToken.orEmpty(),
),
)
} }
ForwardedEmailAlias.ServiceTypeOption.SIMPLE_LOGIN -> updateForwardedEmailAliasType { 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 Username -> when (val selectedType = updatedMainType.selectedType) {
is ForwardedEmailAlias -> { is ForwardedEmailAlias -> {
saveForwardedEmailAliasServiceTypeToDisk(selectedType)
if (isManualRegeneration) { if (isManualRegeneration) {
generateForwardedEmailAlias(selectedType) generateForwardedEmailAlias(selectedType)
} }
} }
is CatchAllEmail -> { is CatchAllEmail -> {
saveCatchAllEmailOptionsToDisk(selectedType)
if (isManualRegeneration) { if (isManualRegeneration) {
generateCatchAllEmail(selectedType) generateCatchAllEmail(selectedType)
} }
} }
is PlusAddressedEmail -> { is PlusAddressedEmail -> {
savePlusAddressedEmailOptionsToDisk(selectedType)
if (isManualRegeneration) { if (isManualRegeneration) {
generatePlusAddressedEmail(selectedType) generatePlusAddressedEmail(selectedType)
} }
} }
is RandomWord -> { is RandomWord -> {
saveRandomWordOptionsToDisk(selectedType)
if (isManualRegeneration) { if (isManualRegeneration) {
generateRandomWordUsername(selectedType) generateRandomWordUsername(selectedType)
} }
@ -1024,6 +1176,7 @@ class GeneratorViewModel @Inject constructor(
) )
sendAction(GeneratorAction.Internal.UpdateGeneratedRandomWordUsernameResult(result)) sendAction(GeneratorAction.Internal.UpdateGeneratedRandomWordUsernameResult(result))
} }
private inline fun updateGeneratorMainTypePasscode( private inline fun updateGeneratorMainTypePasscode(
crossinline block: (Passcode) -> Passcode, crossinline block: (Passcode) -> Passcode,
) { ) {
@ -2050,3 +2203,34 @@ private fun Password.enforceAtLeastOneToggleOn(): Password =
} else { } else {
this 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
}
}

View file

@ -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.platform.base.FakeSharedPreferences
import com.x8bit.bitwarden.data.tools.generator.repository.model.PasscodeGenerationOptions 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.ExperimentalSerializationApi
import kotlinx.serialization.encodeToString import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
@ -89,4 +90,72 @@ class GeneratorDiskSourceTest {
assertNotNull(storedValue) assertNotNull(storedValue)
assertEquals(json.encodeToString(options), 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)
}
} }

View file

@ -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.GeneratedPlusAddressedUsernameResult
import com.x8bit.bitwarden.data.tools.generator.repository.model.GeneratedRandomWordUsernameResult 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.PasscodeGenerationOptions
import com.x8bit.bitwarden.data.tools.generator.repository.model.UsernameGenerationOptions
import com.x8bit.bitwarden.data.vault.datasource.sdk.VaultSdkSource import com.x8bit.bitwarden.data.vault.datasource.sdk.VaultSdkSource
import io.mockk.coEvery import io.mockk.coEvery
import io.mockk.coVerify import io.mockk.coVerify
@ -50,6 +51,7 @@ import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test import org.junit.jupiter.api.Test
import java.time.Instant import java.time.Instant
@Suppress("LargeClass")
class GeneratorRepositoryTest { class GeneratorRepositoryTest {
private val mutableUserStateFlow = MutableStateFlow<UserStateJson?>(null) private val mutableUserStateFlow = MutableStateFlow<UserStateJson?>(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( private val USER_STATE = UserStateJson(
activeUserId = "activeUserId", activeUserId = "activeUserId",
accounts = mapOf( accounts = mapOf(

View file

@ -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.GeneratedPlusAddressedUsernameResult
import com.x8bit.bitwarden.data.tools.generator.repository.model.GeneratedRandomWordUsernameResult 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.PasscodeGenerationOptions
import com.x8bit.bitwarden.data.tools.generator.repository.model.UsernameGenerationOptions
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
@ -30,6 +31,8 @@ class FakeGeneratorRepository : GeneratorRepository {
) )
private var passcodeGenerationOptions: PasscodeGenerationOptions? = null private var passcodeGenerationOptions: PasscodeGenerationOptions? = null
private var usernameGenerationOptions: UsernameGenerationOptions? = null
private val mutablePasswordHistoryStateFlow = private val mutablePasswordHistoryStateFlow =
MutableStateFlow<LocalDataState<List<PasswordHistoryView>>>(LocalDataState.Loading) MutableStateFlow<LocalDataState<List<PasswordHistoryView>>>(LocalDataState.Loading)
@ -101,6 +104,14 @@ class FakeGeneratorRepository : GeneratorRepository {
passcodeGenerationOptions = options passcodeGenerationOptions = options
} }
override fun getUsernameGenerationOptions(): UsernameGenerationOptions? {
return usernameGenerationOptions
}
override fun saveUsernameGenerationOptions(options: UsernameGenerationOptions) {
usernameGenerationOptions = options
}
override suspend fun storePasswordHistory(passwordHistoryView: PasswordHistoryView) { override suspend fun storePasswordHistory(passwordHistoryView: PasswordHistoryView) {
val currentList = mutablePasswordHistoryStateFlow.value.data.orEmpty() val currentList = mutablePasswordHistoryStateFlow.value.data.orEmpty()
val updatedList = currentList + passwordHistoryView val updatedList = currentList + passwordHistoryView