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 f571c4237a..f04a849be4 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 @@ -5,9 +5,11 @@ package com.x8bit.bitwarden.ui.tools.feature.generator import android.os.Parcelable import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.viewModelScope +import com.bitwarden.core.PassphraseGeneratorRequest import com.bitwarden.core.PasswordGeneratorRequest import com.x8bit.bitwarden.R import com.x8bit.bitwarden.data.tools.generator.repository.GeneratorRepository +import com.x8bit.bitwarden.data.tools.generator.repository.model.GeneratedPassphraseResult import com.x8bit.bitwarden.data.tools.generator.repository.model.GeneratedPasswordResult import com.x8bit.bitwarden.data.tools.generator.repository.model.PasscodeGenerationOptions import com.x8bit.bitwarden.ui.platform.base.BaseViewModel @@ -89,6 +91,10 @@ class GeneratorViewModel @Inject constructor( is GeneratorAction.Internal.UpdateGeneratedPasswordResult -> { handleUpdateGeneratedPasswordResult(action) } + + is GeneratorAction.Internal.UpdateGeneratedPassphraseResult -> { + handleUpdateGeneratedPassphraseResult(action) + } } } @@ -97,28 +103,33 @@ class GeneratorViewModel @Inject constructor( //region Generation Handlers private fun loadPasscodeOptions(selectedType: Passcode) { + val options = generatorRepository.getPasscodeGenerationOptions() + ?: generatePasscodeDefaultOptions() + when (selectedType.selectedType) { is Passphrase -> { - mutableStateFlow.update { it.copy(selectedType = selectedType) } - // TODO: App should generate passphrases (BIT-653) + val passphrase = Passphrase( + numWords = options.numWords, + wordSeparator = options.wordSeparator.toCharArray().first(), + capitalize = options.allowCapitalize, + includeNumber = options.allowIncludeNumber, + ) + updateGeneratorMainType { + Passcode(selectedType = passphrase) + } } is Password -> { - val options = generatorRepository.getPasscodeGenerationOptions() - val password = if (options != null) { - Password( - length = options.length, - useCapitals = options.hasUppercase, - useLowercase = options.hasLowercase, - useNumbers = options.hasNumbers, - useSpecialChars = options.allowSpecial, - minNumbers = options.minNumber, - minSpecial = options.minSpecial, - avoidAmbiguousChars = options.allowAmbiguousChar, - ) - } else { - Password() - } + val password = Password( + length = options.length, + useCapitals = options.hasUppercase, + useLowercase = options.hasLowercase, + useNumbers = options.hasNumbers, + useSpecialChars = options.allowSpecial, + minNumbers = options.minNumber, + minSpecial = options.minSpecial, + avoidAmbiguousChars = options.allowAmbiguousChar, + ) updateGeneratorMainType { Passcode(selectedType = password) } @@ -149,6 +160,18 @@ class GeneratorViewModel @Inject constructor( generatorRepository.savePasscodeGenerationOptions(newOptions) } + private fun savePassphraseOptionsToDisk(passphrase: Passphrase) { + val options = generatorRepository + .getPasscodeGenerationOptions() ?: generatePasscodeDefaultOptions() + val newOptions = options.copy( + numWords = passphrase.numWords, + wordSeparator = passphrase.wordSeparator.toString(), + allowCapitalize = passphrase.capitalize, + allowIncludeNumber = passphrase.includeNumber, + ) + generatorRepository.savePasscodeGenerationOptions(newOptions) + } + private fun generatePasscodeDefaultOptions(): PasscodeGenerationOptions { val defaultPassword = Password() val defaultPassphrase = Passphrase() @@ -187,6 +210,18 @@ class GeneratorViewModel @Inject constructor( sendAction(GeneratorAction.Internal.UpdateGeneratedPasswordResult(result)) } + private suspend fun generatePassphrase(passphrase: Passphrase) { + val request = PassphraseGeneratorRequest( + numWords = passphrase.numWords.toUByte(), + wordSeparator = passphrase.wordSeparator.toString(), + capitalize = passphrase.capitalize, + includeNumber = passphrase.includeNumber, + ) + + val result = generatorRepository.generatePassphrase(request) + sendAction(GeneratorAction.Internal.UpdateGeneratedPassphraseResult(result)) + } + //endregion Generation Handlers //region Generated Field Handlers @@ -217,6 +252,22 @@ class GeneratorViewModel @Inject constructor( } } + private fun handleUpdateGeneratedPassphraseResult( + action: GeneratorAction.Internal.UpdateGeneratedPassphraseResult, + ) { + when (val result = action.result) { + is GeneratedPassphraseResult.Success -> { + mutableStateFlow.update { + it.copy(generatedText = result.generatedString) + } + } + + GeneratedPassphraseResult.InvalidRequest -> { + sendEvent(GeneratorEvent.ShowSnackbar(R.string.an_error_has_occurred.asText())) + } + } + } + //endregion Generated Field Handlers //region Main Type Option Handlers @@ -455,7 +506,8 @@ class GeneratorViewModel @Inject constructor( when (updatedMainType) { is Passcode -> when (val selectedType = updatedMainType.selectedType) { is Passphrase -> { - // TODO: App should generate passphrases (BIT-653) + savePassphraseOptionsToDisk(selectedType) + generatePassphrase(selectedType) } is Password -> { @@ -1068,6 +1120,13 @@ sealed class GeneratorAction { data class UpdateGeneratedPasswordResult( val result: GeneratedPasswordResult, ) : Internal() + + /** + * Indicates a generated text update is received. + */ + data class UpdateGeneratedPassphraseResult( + val result: GeneratedPassphraseResult, + ) : Internal() } } 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 a992baa9b0..5056e66ca9 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 @@ -3,6 +3,7 @@ package com.x8bit.bitwarden.ui.tools.feature.generator import androidx.lifecycle.SavedStateHandle import app.cash.turbine.test import com.x8bit.bitwarden.R +import com.x8bit.bitwarden.data.tools.generator.repository.model.GeneratedPassphraseResult import com.x8bit.bitwarden.data.tools.generator.repository.model.GeneratedPasswordResult import com.x8bit.bitwarden.data.tools.generator.repository.model.PasscodeGenerationOptions import com.x8bit.bitwarden.data.tools.generator.repository.util.FakeGeneratorRepository @@ -100,18 +101,66 @@ class GeneratorViewModelTest : BaseViewModelTest() { } } + @Suppress("MaxLineLength") @Test - fun `RegenerateClick for passphrase state should do nothing`() = runTest { - val viewModel = GeneratorViewModel(passphraseSavedStateHandle, fakeGeneratorRepository) + fun `RegenerateClick action for passphrase state updates generatedText and saves passphrase generation options on successful passphrase generation`() = + runTest { + val updatedGeneratedPassphrase = "updatedPassphrase" - fakeGeneratorRepository.setMockGeneratePasswordResult( - GeneratedPasswordResult.Success("DifferentPassphrase"), - ) + val viewModel = createViewModel(initialPassphraseState) + val initialState = viewModel.stateFlow.value - viewModel.actionChannel.trySend(GeneratorAction.RegenerateClick) + val updatedPassphraseOptions = PasscodeGenerationOptions( + length = 14, + allowAmbiguousChar = false, + hasNumbers = true, + minNumber = 1, + hasUppercase = true, + minUppercase = null, + hasLowercase = true, + minLowercase = null, + allowSpecial = false, + minSpecial = 1, + allowCapitalize = false, + allowIncludeNumber = false, + wordSeparator = "-", + numWords = 3, + ) - assertEquals(initialPassphraseState, viewModel.stateFlow.value) - } + fakeGeneratorRepository.setMockGeneratePassphraseResult( + GeneratedPassphraseResult.Success(updatedGeneratedPassphrase), + ) + + viewModel.actionChannel.trySend(GeneratorAction.RegenerateClick) + + val expectedState = initialState.copy(generatedText = updatedGeneratedPassphrase) + assertEquals(expectedState, viewModel.stateFlow.value) + + assertEquals( + updatedPassphraseOptions, + fakeGeneratorRepository.getPasscodeGenerationOptions(), + ) + } + + @Suppress("MaxLineLength") + @Test + fun `RegenerateClick action for passphrase state sends ShowSnackbar event on passphrase generation failure`() = + runTest { + val viewModel = createViewModel(initialPassphraseState) + + fakeGeneratorRepository.setMockGeneratePassphraseResult( + GeneratedPassphraseResult.InvalidRequest, + ) + + viewModel.actionChannel.trySend(GeneratorAction.RegenerateClick) + + viewModel.eventFlow.test { + assertEquals( + GeneratorEvent.ShowSnackbar(R.string.an_error_has_occurred.asText()), + awaitItem(), + ) + } + } @Test fun `RegenerateClick for username state should do nothing`() = runTest { @@ -200,8 +249,10 @@ class GeneratorViewModelTest : BaseViewModelTest() { @Test fun `PasscodeTypeOptionSelect PASSPHRASE should switch to PassphraseType`() = runTest { val viewModel = createViewModel() + val updatedText = "updatedPassphrase" + fakeGeneratorRepository.setMockGeneratePasswordResult( - GeneratedPasswordResult.Success("updatedText"), + GeneratedPasswordResult.Success(updatedText), ) val action = GeneratorAction.MainType.Passcode.PasscodeTypeOptionSelect( @@ -211,6 +262,7 @@ class GeneratorViewModelTest : BaseViewModelTest() { viewModel.actionChannel.trySend(action) val expectedState = initialState.copy( + generatedText = updatedText, selectedType = GeneratorState.MainType.Passcode( selectedType = GeneratorState.MainType.Passcode.PasscodeType.Passphrase(), ), @@ -492,9 +544,6 @@ class GeneratorViewModelTest : BaseViewModelTest() { inner class PassphraseActions { private val defaultPassphraseState = createPassphraseState() - private val passphraseSavedStateHandle = - createSavedStateHandleWithState(defaultPassphraseState) - private lateinit var viewModel: GeneratorViewModel @BeforeEach @@ -508,6 +557,11 @@ class GeneratorViewModelTest : BaseViewModelTest() { @Test fun `NumWordsCounterChange should update the numWords property correctly`() = runTest { + val updatedGeneratedPassphrase = "updatedPassword" + fakeGeneratorRepository.setMockGeneratePassphraseResult( + GeneratedPassphraseResult.Success(updatedGeneratedPassphrase), + ) + val newNumWords = 4 viewModel.actionChannel.trySend( GeneratorAction @@ -521,6 +575,7 @@ class GeneratorViewModelTest : BaseViewModelTest() { ) val expectedState = defaultPassphraseState.copy( + generatedText = updatedGeneratedPassphrase, selectedType = GeneratorState.MainType.Passcode( GeneratorState.MainType.Passcode.PasscodeType.Passphrase( numWords = newNumWords, @@ -534,6 +589,11 @@ class GeneratorViewModelTest : BaseViewModelTest() { @Test fun `WordSeparatorTextChange should update wordSeparator correctly to new value`() = runTest { + val updatedGeneratedPassphrase = "updatedPassword" + fakeGeneratorRepository.setMockGeneratePassphraseResult( + GeneratedPassphraseResult.Success(updatedGeneratedPassphrase), + ) + val newWordSeparatorChar = '_' viewModel.actionChannel.trySend( @@ -547,6 +607,7 @@ class GeneratorViewModelTest : BaseViewModelTest() { ) val expectedState = defaultPassphraseState.copy( + generatedText = updatedGeneratedPassphrase, selectedType = GeneratorState.MainType.Passcode( GeneratorState.MainType.Passcode.PasscodeType.Passphrase( wordSeparator = newWordSeparatorChar, @@ -560,6 +621,11 @@ class GeneratorViewModelTest : BaseViewModelTest() { @Test fun `ToggleIncludeNumberChange should update the includeNumber property correctly`() = runTest { + val updatedGeneratedPassphrase = "updatedPassword" + fakeGeneratorRepository.setMockGeneratePassphraseResult( + GeneratedPassphraseResult.Success(updatedGeneratedPassphrase), + ) + viewModel.actionChannel.trySend( GeneratorAction .MainType @@ -572,6 +638,7 @@ class GeneratorViewModelTest : BaseViewModelTest() { ) val expectedState = defaultPassphraseState.copy( + generatedText = updatedGeneratedPassphrase, selectedType = GeneratorState.MainType.Passcode( selectedType = GeneratorState.MainType.Passcode.PasscodeType.Passphrase( includeNumber = true, @@ -585,6 +652,11 @@ class GeneratorViewModelTest : BaseViewModelTest() { @Test fun `ToggleCapitalizeChange should update the capitalize property correctly`() = runTest { + val updatedGeneratedPassphrase = "updatedPassword" + fakeGeneratorRepository.setMockGeneratePassphraseResult( + GeneratedPassphraseResult.Success(updatedGeneratedPassphrase), + ) + viewModel.actionChannel.trySend( GeneratorAction .MainType @@ -597,6 +669,7 @@ class GeneratorViewModelTest : BaseViewModelTest() { ) val expectedState = defaultPassphraseState.copy( + generatedText = updatedGeneratedPassphrase, selectedType = GeneratorState.MainType.Passcode( GeneratorState.MainType.Passcode.PasscodeType.Passphrase( capitalize = true,