diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/settings/exportvault/ExportVaultScreen.kt b/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/settings/exportvault/ExportVaultScreen.kt index aae44137cc..0c45742f8f 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/settings/exportvault/ExportVaultScreen.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/settings/exportvault/ExportVaultScreen.kt @@ -34,6 +34,7 @@ import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.x8bit.bitwarden.R +import com.x8bit.bitwarden.ui.auth.feature.createaccount.PasswordStrengthIndicator import com.x8bit.bitwarden.ui.platform.base.util.EventsEffect import com.x8bit.bitwarden.ui.platform.base.util.asText import com.x8bit.bitwarden.ui.platform.components.appbar.BitwardenTopAppBar @@ -164,9 +165,15 @@ fun ExportVaultScreen( ) { innerPadding -> ExportVaultScreenContent( state = state, + onConfirmFilePasswordInputChanged = remember(viewModel) { + { viewModel.trySendAction(ExportVaultAction.ConfirmFilePasswordInputChange(it)) } + }, onExportFormatOptionSelected = remember(viewModel) { { viewModel.trySendAction(ExportVaultAction.ExportFormatOptionSelect(it)) } }, + onFilePasswordInputChanged = remember(viewModel) { + { viewModel.trySendAction(ExportVaultAction.FilePasswordInputChange(it)) } + }, onPasswordInputChanged = remember(viewModel) { { viewModel.trySendAction(ExportVaultAction.PasswordInputChanged(it)) } }, @@ -182,7 +189,9 @@ fun ExportVaultScreen( @Suppress("LongMethod") private fun ExportVaultScreenContent( state: ExportVaultState, + onConfirmFilePasswordInputChanged: (String) -> Unit, onExportFormatOptionSelected: (ExportVaultFormat) -> Unit, + onFilePasswordInputChanged: (String) -> Unit, onPasswordInputChanged: (String) -> Unit, onExportVaultClick: () -> Unit, modifier: Modifier = Modifier, @@ -226,6 +235,37 @@ private fun ExportVaultScreenContent( Spacer(modifier = Modifier.height(8.dp)) + if (state.exportFormat == ExportVaultFormat.JSON_ENCRYPTED) { + BitwardenPasswordField( + label = stringResource(id = R.string.file_password), + value = state.filePasswordInput, + onValueChange = onFilePasswordInputChanged, + hint = stringResource(id = R.string.password_used_to_export), + modifier = Modifier + .semantics { testTag = "FilePasswordEntry" } + .padding(horizontal = 16.dp) + .fillMaxWidth(), + ) + Spacer(modifier = Modifier.height(8.dp)) + + PasswordStrengthIndicator( + modifier = Modifier.padding(horizontal = 16.dp), + state = state.passwordStrengthState, + ) + Spacer(modifier = Modifier.height(4.dp)) + + BitwardenPasswordField( + label = stringResource(id = R.string.confirm_file_password), + value = state.confirmFilePasswordInput, + onValueChange = onConfirmFilePasswordInputChanged, + modifier = Modifier + .semantics { testTag = "ConfirmFilePasswordEntry" } + .padding(horizontal = 16.dp) + .fillMaxWidth(), + ) + Spacer(modifier = Modifier.height(16.dp)) + } + BitwardenPasswordField( label = stringResource(id = R.string.master_password), value = state.passwordInput, 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 74c48e4217..dcf8604c67 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 @@ -12,6 +12,7 @@ import com.x8bit.bitwarden.data.vault.datasource.network.model.PolicyTypeJson import com.x8bit.bitwarden.data.vault.manager.FileManager import com.x8bit.bitwarden.data.vault.repository.VaultRepository import com.x8bit.bitwarden.data.vault.repository.model.ExportVaultDataResult +import com.x8bit.bitwarden.ui.auth.feature.createaccount.PasswordStrengthState 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 @@ -46,10 +47,13 @@ class ExportVaultViewModel @Inject constructor( ) : BaseViewModel( initialState = savedStateHandle[KEY_STATE] ?: ExportVaultState( + confirmFilePasswordInput = "", dialogState = null, exportData = null, exportFormat = ExportVaultFormat.JSON, + filePasswordInput = "", passwordInput = "", + passwordStrengthState = PasswordStrengthState.NONE, policyPreventsExport = policyManager .getActivePolicies(type = PolicyTypeJson.DISABLE_PERSONAL_VAULT_EXPORT) .any(), @@ -66,7 +70,12 @@ class ExportVaultViewModel @Inject constructor( when (action) { ExportVaultAction.CloseButtonClick -> handleCloseButtonClicked() ExportVaultAction.ConfirmExportVaultClicked -> handleConfirmExportVaultClicked() + is ExportVaultAction.ConfirmFilePasswordInputChange -> { + handleConfirmFilePasswordInputChanged(action) + } + ExportVaultAction.DialogDismiss -> handleDialogDismiss() + is ExportVaultAction.FilePasswordInputChange -> handleFilePasswordInputChanged(action) is ExportVaultAction.ExportFormatOptionSelect -> handleExportFormatOptionSelect(action) is ExportVaultAction.PasswordInputChanged -> handlePasswordInputChanged(action) is ExportVaultAction.ExportLocationReceive -> handleExportLocationReceive(action) @@ -118,6 +127,17 @@ class ExportVaultViewModel @Inject constructor( } } + /** + * Update the state with the new confirm file password input. + */ + private fun handleConfirmFilePasswordInputChanged( + action: ExportVaultAction.ConfirmFilePasswordInputChange, + ) { + mutableStateFlow.update { + it.copy(confirmFilePasswordInput = action.input) + } + } + /** * Dismiss the dialog. */ @@ -155,6 +175,15 @@ class ExportVaultViewModel @Inject constructor( } } + /** + * Update the state with the new file password input. + */ + private fun handleFilePasswordInputChanged(action: ExportVaultAction.FilePasswordInputChange) { + mutableStateFlow.update { + it.copy(filePasswordInput = action.input) + } + } + /** * Update the state with the new password input. */ @@ -267,9 +296,12 @@ class ExportVaultViewModel @Inject constructor( data class ExportVaultState( @IgnoredOnParcel val exportData: String? = null, + val confirmFilePasswordInput: String, val dialogState: DialogState?, val exportFormat: ExportVaultFormat, + val filePasswordInput: String, val passwordInput: String, + val passwordStrengthState: PasswordStrengthState, val policyPreventsExport: Boolean, ) : Parcelable { /** @@ -330,6 +362,11 @@ sealed class ExportVaultAction { */ data object ConfirmExportVaultClicked : ExportVaultAction() + /** + * Indicates that the confirm file password input has changed. + */ + data class ConfirmFilePasswordInputChange(val input: String) : ExportVaultAction() + /** * Indicates that the dialog has been dismissed. */ @@ -347,6 +384,11 @@ sealed class ExportVaultAction { val fileUri: Uri, ) : ExportVaultAction() + /** + * Indicates that the file password input has changed. + */ + data class FilePasswordInputChange(val input: String) : ExportVaultAction() + /** * Indicates that the password input has changed. */ diff --git a/app/src/main/res/values/strings_non_localized.xml b/app/src/main/res/values/strings_non_localized.xml index e9a92f4c79..fbe381f14c 100644 --- a/app/src/main/res/values/strings_non_localized.xml +++ b/app/src/main/res/values/strings_non_localized.xml @@ -6,9 +6,12 @@ Duo (%1$s) .json .json (%1$s) - - Give Feedback + + Confirm file password Continue to Give Feedback? Select continue to provide feedback on your experience in a web form. + File password + Give Feedback Password Protected + This password will be used to export and import this file 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 fd5a40493c..3aa1566611 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 @@ -2,6 +2,7 @@ package com.x8bit.bitwarden.ui.platform.feature.settings.exportvault import androidx.compose.ui.test.assert import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.assertIsNotDisplayed import androidx.compose.ui.test.filterToOne import androidx.compose.ui.test.hasAnyAncestor import androidx.compose.ui.test.isDialog @@ -14,6 +15,7 @@ import androidx.compose.ui.test.performClick import androidx.compose.ui.test.performScrollTo import androidx.compose.ui.test.performTextInput import com.x8bit.bitwarden.data.platform.repository.util.bufferedMutableSharedFlow +import com.x8bit.bitwarden.ui.auth.feature.createaccount.PasswordStrengthState import com.x8bit.bitwarden.ui.platform.base.BaseComposeTest import com.x8bit.bitwarden.ui.platform.base.util.asText import com.x8bit.bitwarden.ui.platform.feature.settings.exportvault.model.ExportVaultFormat @@ -215,6 +217,36 @@ class ExportVaultScreenTest : BaseComposeTest() { assertTrue(onNavigateBackCalled) } + @Test + fun `confirm file password input change should send ConfirmFilePasswordInputChange action`() { + composeTestRule.onNodeWithText("Confirm file password").assertIsNotDisplayed() + mutableStateFlow.update { + it.copy( + exportFormat = ExportVaultFormat.JSON_ENCRYPTED, + ) + } + val input = "Test123" + composeTestRule.onNodeWithText("Confirm file password").performTextInput(input) + verify { + viewModel.trySendAction(ExportVaultAction.ConfirmFilePasswordInputChange("Test123")) + } + } + + @Test + fun `file password input change should send FilePasswordInputChange action`() { + composeTestRule.onNodeWithText("File password").assertIsNotDisplayed() + mutableStateFlow.update { + it.copy( + exportFormat = ExportVaultFormat.JSON_ENCRYPTED, + ) + } + val input = "Test123" + composeTestRule.onNodeWithText("File password").performTextInput(input) + verify { + viewModel.trySendAction(ExportVaultAction.FilePasswordInputChange("Test123")) + } + } + @Test fun `password input change should send PasswordInputChange action`() { val input = "Test123" @@ -226,9 +258,12 @@ class ExportVaultScreenTest : BaseComposeTest() { } private val DEFAULT_STATE = ExportVaultState( + confirmFilePasswordInput = "", dialogState = null, exportFormat = ExportVaultFormat.JSON, + filePasswordInput = "", passwordInput = "", exportData = "", + passwordStrengthState = PasswordStrengthState.NONE, policyPreventsExport = false, ) 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 19756ab17d..0c1b2a717c 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 @@ -12,6 +12,7 @@ import com.x8bit.bitwarden.data.vault.datasource.network.model.createMockPolicy import com.x8bit.bitwarden.data.vault.manager.FileManager import com.x8bit.bitwarden.data.vault.repository.VaultRepository import com.x8bit.bitwarden.data.vault.repository.model.ExportVaultDataResult +import com.x8bit.bitwarden.ui.auth.feature.createaccount.PasswordStrengthState import com.x8bit.bitwarden.ui.platform.base.BaseViewModelTest import com.x8bit.bitwarden.ui.platform.base.util.asText import com.x8bit.bitwarden.ui.platform.feature.settings.exportvault.model.ExportVaultFormat @@ -194,18 +195,42 @@ class ExportVaultViewModelTest : BaseViewModelTest() { } @Test - fun `PasswordInputChanged should update the password input in the state`() = runTest { + fun `ConfirmFilePasswordInputChanged should update the confirm password input in the state`() { val viewModel = createViewModel() - viewModel.eventFlow.test { - viewModel.trySendAction(ExportVaultAction.PasswordInputChanged("Test123")) + viewModel.trySendAction(ExportVaultAction.ConfirmFilePasswordInputChange("Test123")) - assertEquals( - DEFAULT_STATE.copy( - passwordInput = "Test123", - ), - viewModel.stateFlow.value, - ) - } + assertEquals( + DEFAULT_STATE.copy( + confirmFilePasswordInput = "Test123", + ), + viewModel.stateFlow.value, + ) + } + + @Test + fun `FilePasswordInputChanged should update the file password input in the state`() { + val viewModel = createViewModel() + viewModel.trySendAction(ExportVaultAction.FilePasswordInputChange("Test123")) + + assertEquals( + DEFAULT_STATE.copy( + filePasswordInput = "Test123", + ), + viewModel.stateFlow.value, + ) + } + + @Test + fun `PasswordInputChanged should update the password input in the state`() { + val viewModel = createViewModel() + viewModel.trySendAction(ExportVaultAction.PasswordInputChanged("Test123")) + + assertEquals( + DEFAULT_STATE.copy( + passwordInput = "Test123", + ), + viewModel.stateFlow.value, + ) } @Test @@ -327,23 +352,25 @@ class ExportVaultViewModelTest : BaseViewModelTest() { private fun createViewModel( initialState: ExportVaultState? = null, - ): ExportVaultViewModel = - ExportVaultViewModel( - authRepository = authRepository, - policyManager = policyManager, - savedStateHandle = SavedStateHandle( - initialState = mapOf("state" to initialState), - ), - fileManager = fileManager, - vaultRepository = vaultRepository, - clock = clock, - ) + ): ExportVaultViewModel = ExportVaultViewModel( + authRepository = authRepository, + policyManager = policyManager, + savedStateHandle = SavedStateHandle( + initialState = mapOf("state" to initialState), + ), + fileManager = fileManager, + vaultRepository = vaultRepository, + clock = clock, + ) } private val DEFAULT_STATE = ExportVaultState( + confirmFilePasswordInput = "", dialogState = null, exportFormat = ExportVaultFormat.JSON, + filePasswordInput = "", passwordInput = "", exportData = null, + passwordStrengthState = PasswordStrengthState.NONE, policyPreventsExport = false, )