diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/settings/exportvault/ExportVaultViewModel.kt b/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/settings/exportvault/ExportVaultViewModel.kt index dcf8604c67..0874a1261c 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/settings/exportvault/ExportVaultViewModel.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/settings/exportvault/ExportVaultViewModel.kt @@ -5,7 +5,9 @@ import android.os.Parcelable import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.viewModelScope import com.x8bit.bitwarden.R +import com.x8bit.bitwarden.data.auth.datasource.sdk.model.PasswordStrength import com.x8bit.bitwarden.data.auth.repository.AuthRepository +import com.x8bit.bitwarden.data.auth.repository.model.PasswordStrengthResult import com.x8bit.bitwarden.data.auth.repository.model.ValidatePasswordResult import com.x8bit.bitwarden.data.platform.manager.PolicyManager import com.x8bit.bitwarden.data.vault.datasource.network.model.PolicyTypeJson @@ -21,6 +23,7 @@ import com.x8bit.bitwarden.ui.platform.feature.settings.exportvault.model.toExpo import com.x8bit.bitwarden.ui.platform.util.fileExtension import com.x8bit.bitwarden.ui.platform.util.toFormattedPattern import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.Job import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.update @@ -49,6 +52,7 @@ class ExportVaultViewModel @Inject constructor( ?: ExportVaultState( confirmFilePasswordInput = "", dialogState = null, + email = requireNotNull(authRepository.userStateFlow.value?.activeAccount?.email), exportData = null, exportFormat = ExportVaultFormat.JSON, filePasswordInput = "", @@ -59,6 +63,12 @@ class ExportVaultViewModel @Inject constructor( .any(), ), ) { + /** + * Keeps track of async request to get password strength. Should be cancelled + * when user input changes. + */ + private var passwordStrengthJob: Job = Job().apply { complete() } + init { // As state updates, write to saved state handle. stateFlow @@ -88,6 +98,10 @@ class ExportVaultViewModel @Inject constructor( handleReceivePrepareVaultDataResult(action) } + is ExportVaultAction.Internal.ReceivePasswordStrengthResult -> { + handleReceivePasswordStrengthResult(action) + } + is ExportVaultAction.Internal.SaveExportDataToUriResultReceive -> { handleExportDataFinishedSavingToDisk(action) } @@ -182,6 +196,21 @@ class ExportVaultViewModel @Inject constructor( mutableStateFlow.update { it.copy(filePasswordInput = action.input) } + // Update password strength + passwordStrengthJob.cancel() + if (action.input.isEmpty()) { + mutableStateFlow.update { + it.copy(passwordStrengthState = PasswordStrengthState.NONE) + } + } else { + passwordStrengthJob = viewModelScope.launch { + val result = authRepository.getPasswordStrength( + email = state.email, + password = action.input, + ) + trySendAction(ExportVaultAction.Internal.ReceivePasswordStrengthResult(result)) + } + } } /** @@ -266,6 +295,31 @@ class ExportVaultViewModel @Inject constructor( } } + private fun handleReceivePasswordStrengthResult( + action: ExportVaultAction.Internal.ReceivePasswordStrengthResult, + ) { + when (val result = action.result) { + is PasswordStrengthResult.Success -> { + val updatedState = when (result.passwordStrength) { + PasswordStrength.LEVEL_0 -> PasswordStrengthState.WEAK_1 + PasswordStrength.LEVEL_1 -> PasswordStrengthState.WEAK_2 + PasswordStrength.LEVEL_2 -> PasswordStrengthState.WEAK_3 + PasswordStrength.LEVEL_3 -> PasswordStrengthState.GOOD + PasswordStrength.LEVEL_4 -> PasswordStrengthState.STRONG + } + mutableStateFlow.update { oldState -> + oldState.copy( + passwordStrengthState = updatedState, + ) + } + } + + PasswordStrengthResult.Error -> { + // Leave UI the same + } + } + } + private fun handleExportDataFinishedSavingToDisk( action: ExportVaultAction.Internal.SaveExportDataToUriResultReceive, ) { @@ -298,6 +352,7 @@ data class ExportVaultState( val exportData: String? = null, val confirmFilePasswordInput: String, val dialogState: DialogState?, + val email: String, val exportFormat: ExportVaultFormat, val filePasswordInput: String, val passwordInput: String, @@ -413,6 +468,13 @@ sealed class ExportVaultAction { val result: ExportVaultDataResult, ) : Internal() + /** + * Indicates that the result for getting the password strength has been received. + */ + data class ReceivePasswordStrengthResult( + val result: PasswordStrengthResult, + ) : Internal() + /** * Indicates that a validate password result has been received. */ diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/settings/exportvault/ExportVaultScreenTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/settings/exportvault/ExportVaultScreenTest.kt index 3aa1566611..17af3dc751 100644 --- a/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/settings/exportvault/ExportVaultScreenTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/settings/exportvault/ExportVaultScreenTest.kt @@ -260,6 +260,7 @@ class ExportVaultScreenTest : BaseComposeTest() { private val DEFAULT_STATE = ExportVaultState( confirmFilePasswordInput = "", dialogState = null, + email = "test@bitwarden.com", exportFormat = ExportVaultFormat.JSON, filePasswordInput = "", passwordInput = "", diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/settings/exportvault/ExportVaultViewModelTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/settings/exportvault/ExportVaultViewModelTest.kt index 0c1b2a717c..ce1aac4adb 100644 --- a/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/settings/exportvault/ExportVaultViewModelTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/settings/exportvault/ExportVaultViewModelTest.kt @@ -4,9 +4,13 @@ import android.net.Uri import androidx.lifecycle.SavedStateHandle import app.cash.turbine.test import com.x8bit.bitwarden.R +import com.x8bit.bitwarden.data.auth.datasource.sdk.model.PasswordStrength import com.x8bit.bitwarden.data.auth.repository.AuthRepository +import com.x8bit.bitwarden.data.auth.repository.model.PasswordStrengthResult +import com.x8bit.bitwarden.data.auth.repository.model.UserState import com.x8bit.bitwarden.data.auth.repository.model.ValidatePasswordResult import com.x8bit.bitwarden.data.platform.manager.PolicyManager +import com.x8bit.bitwarden.data.platform.repository.model.Environment import com.x8bit.bitwarden.data.vault.datasource.network.model.PolicyTypeJson import com.x8bit.bitwarden.data.vault.datasource.network.model.createMockPolicy import com.x8bit.bitwarden.data.vault.manager.FileManager @@ -20,6 +24,8 @@ import io.mockk.coEvery import io.mockk.coVerify import io.mockk.every import io.mockk.mockk +import io.mockk.verify +import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.test.runTest import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Test @@ -28,7 +34,10 @@ import java.time.Instant import java.time.ZoneOffset class ExportVaultViewModelTest : BaseViewModelTest() { - private val authRepository: AuthRepository = mockk() + private val mutableUserStateFlow = MutableStateFlow(DEFAULT_USER_STATE) + private val authRepository: AuthRepository = mockk { + every { userStateFlow } returns mutableUserStateFlow + } private val policyManager: PolicyManager = mockk { every { @@ -61,6 +70,7 @@ class ExportVaultViewModelTest : BaseViewModelTest() { awaitItem(), ) } + verify { authRepository.userStateFlow } } @Test @@ -147,6 +157,11 @@ class ExportVaultViewModelTest : BaseViewModelTest() { viewModel.stateFlow.value, ) } + coVerify { + authRepository.validatePassword( + password = password, + ) + } } @Test @@ -209,15 +224,31 @@ class ExportVaultViewModelTest : BaseViewModelTest() { @Test fun `FilePasswordInputChanged should update the file password input in the state`() { + val password = "Test123" + coEvery { + authRepository.getPasswordStrength( + email = EMAIL_ADDRESS, + password = password, + ) + } returns PasswordStrengthResult.Success( + passwordStrength = PasswordStrength.LEVEL_4, + ) val viewModel = createViewModel() - viewModel.trySendAction(ExportVaultAction.FilePasswordInputChange("Test123")) + viewModel.trySendAction(ExportVaultAction.FilePasswordInputChange(password)) assertEquals( DEFAULT_STATE.copy( - filePasswordInput = "Test123", + filePasswordInput = password, + passwordStrengthState = PasswordStrengthState.STRONG, ), viewModel.stateFlow.value, ) + coVerify { + authRepository.getPasswordStrength( + email = EMAIL_ADDRESS, + password = password, + ) + } } @Test @@ -277,6 +308,89 @@ class ExportVaultViewModelTest : BaseViewModelTest() { } } + @Test + fun `ReceivePasswordStrengthResult should update password strength state`() = runTest { + val viewModel = createViewModel() + viewModel.stateFlow.test { + assertEquals( + DEFAULT_STATE.copy( + passwordStrengthState = PasswordStrengthState.NONE, + ), + awaitItem(), + ) + + viewModel.trySendAction( + ExportVaultAction.Internal.ReceivePasswordStrengthResult( + PasswordStrengthResult.Success( + PasswordStrength.LEVEL_0, + ), + ), + ) + assertEquals( + DEFAULT_STATE.copy( + passwordStrengthState = PasswordStrengthState.WEAK_1, + ), + awaitItem(), + ) + + viewModel.trySendAction( + ExportVaultAction.Internal.ReceivePasswordStrengthResult( + PasswordStrengthResult.Success( + PasswordStrength.LEVEL_1, + ), + ), + ) + assertEquals( + DEFAULT_STATE.copy( + passwordStrengthState = PasswordStrengthState.WEAK_2, + ), + awaitItem(), + ) + + viewModel.trySendAction( + ExportVaultAction.Internal.ReceivePasswordStrengthResult( + PasswordStrengthResult.Success( + PasswordStrength.LEVEL_2, + ), + ), + ) + assertEquals( + DEFAULT_STATE.copy( + passwordStrengthState = PasswordStrengthState.WEAK_3, + ), + awaitItem(), + ) + + viewModel.trySendAction( + ExportVaultAction.Internal.ReceivePasswordStrengthResult( + PasswordStrengthResult.Success( + PasswordStrength.LEVEL_3, + ), + ), + ) + assertEquals( + DEFAULT_STATE.copy( + passwordStrengthState = PasswordStrengthState.GOOD, + ), + awaitItem(), + ) + + viewModel.trySendAction( + ExportVaultAction.Internal.ReceivePasswordStrengthResult( + PasswordStrengthResult.Success( + PasswordStrength.LEVEL_4, + ), + ), + ) + assertEquals( + DEFAULT_STATE.copy( + passwordStrengthState = PasswordStrengthState.STRONG, + ), + awaitItem(), + ) + } + } + @Test fun `ExportLocationReceive should update state to error if exportData is null`() { val viewModel = createViewModel() @@ -364,9 +478,31 @@ class ExportVaultViewModelTest : BaseViewModelTest() { ) } +private const val EMAIL_ADDRESS = "active@bitwarden.com" +private val DEFAULT_USER_STATE = UserState( + activeUserId = "activeUserId", + accounts = listOf( + UserState.Account( + userId = "activeUserId", + name = "Active User", + email = EMAIL_ADDRESS, + avatarColorHex = "#aa00aa", + environment = Environment.Us, + isPremium = true, + isLoggedIn = true, + isVaultUnlocked = true, + needsPasswordReset = false, + isBiometricsEnabled = false, + organizations = emptyList(), + needsMasterPassword = false, + ), + ), +) + private val DEFAULT_STATE = ExportVaultState( confirmFilePasswordInput = "", dialogState = null, + email = EMAIL_ADDRESS, exportFormat = ExportVaultFormat.JSON, filePasswordInput = "", passwordInput = "",