BIT-2101 BIT-2103: Update export flow for JSON (password protected) (#1188)

This commit is contained in:
Caleb Derosier 2024-03-29 13:51:17 -06:00 committed by Álison Fernandes
parent 3565054a4c
commit 90ff2897f5
5 changed files with 170 additions and 23 deletions

View file

@ -34,6 +34,7 @@ import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.x8bit.bitwarden.R 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.EventsEffect
import com.x8bit.bitwarden.ui.platform.base.util.asText import com.x8bit.bitwarden.ui.platform.base.util.asText
import com.x8bit.bitwarden.ui.platform.components.appbar.BitwardenTopAppBar import com.x8bit.bitwarden.ui.platform.components.appbar.BitwardenTopAppBar
@ -164,9 +165,15 @@ fun ExportVaultScreen(
) { innerPadding -> ) { innerPadding ->
ExportVaultScreenContent( ExportVaultScreenContent(
state = state, state = state,
onConfirmFilePasswordInputChanged = remember(viewModel) {
{ viewModel.trySendAction(ExportVaultAction.ConfirmFilePasswordInputChange(it)) }
},
onExportFormatOptionSelected = remember(viewModel) { onExportFormatOptionSelected = remember(viewModel) {
{ viewModel.trySendAction(ExportVaultAction.ExportFormatOptionSelect(it)) } { viewModel.trySendAction(ExportVaultAction.ExportFormatOptionSelect(it)) }
}, },
onFilePasswordInputChanged = remember(viewModel) {
{ viewModel.trySendAction(ExportVaultAction.FilePasswordInputChange(it)) }
},
onPasswordInputChanged = remember(viewModel) { onPasswordInputChanged = remember(viewModel) {
{ viewModel.trySendAction(ExportVaultAction.PasswordInputChanged(it)) } { viewModel.trySendAction(ExportVaultAction.PasswordInputChanged(it)) }
}, },
@ -182,7 +189,9 @@ fun ExportVaultScreen(
@Suppress("LongMethod") @Suppress("LongMethod")
private fun ExportVaultScreenContent( private fun ExportVaultScreenContent(
state: ExportVaultState, state: ExportVaultState,
onConfirmFilePasswordInputChanged: (String) -> Unit,
onExportFormatOptionSelected: (ExportVaultFormat) -> Unit, onExportFormatOptionSelected: (ExportVaultFormat) -> Unit,
onFilePasswordInputChanged: (String) -> Unit,
onPasswordInputChanged: (String) -> Unit, onPasswordInputChanged: (String) -> Unit,
onExportVaultClick: () -> Unit, onExportVaultClick: () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
@ -226,6 +235,37 @@ private fun ExportVaultScreenContent(
Spacer(modifier = Modifier.height(8.dp)) 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( BitwardenPasswordField(
label = stringResource(id = R.string.master_password), label = stringResource(id = R.string.master_password),
value = state.passwordInput, value = state.passwordInput,

View file

@ -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.manager.FileManager
import com.x8bit.bitwarden.data.vault.repository.VaultRepository import com.x8bit.bitwarden.data.vault.repository.VaultRepository
import com.x8bit.bitwarden.data.vault.repository.model.ExportVaultDataResult 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.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
@ -46,10 +47,13 @@ class ExportVaultViewModel @Inject constructor(
) : BaseViewModel<ExportVaultState, ExportVaultEvent, ExportVaultAction>( ) : BaseViewModel<ExportVaultState, ExportVaultEvent, ExportVaultAction>(
initialState = savedStateHandle[KEY_STATE] initialState = savedStateHandle[KEY_STATE]
?: ExportVaultState( ?: ExportVaultState(
confirmFilePasswordInput = "",
dialogState = null, dialogState = null,
exportData = null, exportData = null,
exportFormat = ExportVaultFormat.JSON, exportFormat = ExportVaultFormat.JSON,
filePasswordInput = "",
passwordInput = "", passwordInput = "",
passwordStrengthState = PasswordStrengthState.NONE,
policyPreventsExport = policyManager policyPreventsExport = policyManager
.getActivePolicies(type = PolicyTypeJson.DISABLE_PERSONAL_VAULT_EXPORT) .getActivePolicies(type = PolicyTypeJson.DISABLE_PERSONAL_VAULT_EXPORT)
.any(), .any(),
@ -66,7 +70,12 @@ class ExportVaultViewModel @Inject constructor(
when (action) { when (action) {
ExportVaultAction.CloseButtonClick -> handleCloseButtonClicked() ExportVaultAction.CloseButtonClick -> handleCloseButtonClicked()
ExportVaultAction.ConfirmExportVaultClicked -> handleConfirmExportVaultClicked() ExportVaultAction.ConfirmExportVaultClicked -> handleConfirmExportVaultClicked()
is ExportVaultAction.ConfirmFilePasswordInputChange -> {
handleConfirmFilePasswordInputChanged(action)
}
ExportVaultAction.DialogDismiss -> handleDialogDismiss() ExportVaultAction.DialogDismiss -> handleDialogDismiss()
is ExportVaultAction.FilePasswordInputChange -> handleFilePasswordInputChanged(action)
is ExportVaultAction.ExportFormatOptionSelect -> handleExportFormatOptionSelect(action) is ExportVaultAction.ExportFormatOptionSelect -> handleExportFormatOptionSelect(action)
is ExportVaultAction.PasswordInputChanged -> handlePasswordInputChanged(action) is ExportVaultAction.PasswordInputChanged -> handlePasswordInputChanged(action)
is ExportVaultAction.ExportLocationReceive -> handleExportLocationReceive(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. * 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. * Update the state with the new password input.
*/ */
@ -267,9 +296,12 @@ class ExportVaultViewModel @Inject constructor(
data class ExportVaultState( data class ExportVaultState(
@IgnoredOnParcel @IgnoredOnParcel
val exportData: String? = null, val exportData: String? = null,
val confirmFilePasswordInput: String,
val dialogState: DialogState?, val dialogState: DialogState?,
val exportFormat: ExportVaultFormat, val exportFormat: ExportVaultFormat,
val filePasswordInput: String,
val passwordInput: String, val passwordInput: String,
val passwordStrengthState: PasswordStrengthState,
val policyPreventsExport: Boolean, val policyPreventsExport: Boolean,
) : Parcelable { ) : Parcelable {
/** /**
@ -330,6 +362,11 @@ sealed class ExportVaultAction {
*/ */
data object ConfirmExportVaultClicked : 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. * Indicates that the dialog has been dismissed.
*/ */
@ -347,6 +384,11 @@ sealed class ExportVaultAction {
val fileUri: Uri, val fileUri: Uri,
) : ExportVaultAction() ) : ExportVaultAction()
/**
* Indicates that the file password input has changed.
*/
data class FilePasswordInputChange(val input: String) : ExportVaultAction()
/** /**
* Indicates that the password input has changed. * Indicates that the password input has changed.
*/ */

View file

@ -6,9 +6,12 @@
<string name="duo_org_title" translatable="false">Duo (%1$s)</string> <string name="duo_org_title" translatable="false">Duo (%1$s)</string>
<string name="json_extension" translatable="false">.json</string> <string name="json_extension" translatable="false">.json</string>
<string name="json_extension_formatted" translatable="false">.json (%1$s)</string> <string name="json_extension_formatted" translatable="false">.json (%1$s)</string>
<!-- TODO BIT-2140: Update typeform strings --> <!-- TODO BIT-2140: Update typeform and vault export strings -->
<string name="give_feedback" translatable="false">Give Feedback</string> <string name="confirm_file_password" translatable="false">Confirm file password</string>
<string name="continue_to_give_feedback" translatable="false">Continue to Give Feedback?</string> <string name="continue_to_give_feedback" translatable="false">Continue to Give Feedback?</string>
<string name="continue_to_provide_feedback" translatable="false">Select continue to provide feedback on your experience in a web form.</string> <string name="continue_to_provide_feedback" translatable="false">Select continue to provide feedback on your experience in a web form.</string>
<string name="file_password" translatable="false">File password</string>
<string name="give_feedback" translatable="false">Give Feedback</string>
<string name="password_protected" translatable="false">Password Protected</string> <string name="password_protected" translatable="false">Password Protected</string>
<string name="password_used_to_export" translatable="false">This password will be used to export and import this file</string>
</resources> </resources>

View file

@ -2,6 +2,7 @@ package com.x8bit.bitwarden.ui.platform.feature.settings.exportvault
import androidx.compose.ui.test.assert import androidx.compose.ui.test.assert
import androidx.compose.ui.test.assertIsDisplayed import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.assertIsNotDisplayed
import androidx.compose.ui.test.filterToOne import androidx.compose.ui.test.filterToOne
import androidx.compose.ui.test.hasAnyAncestor import androidx.compose.ui.test.hasAnyAncestor
import androidx.compose.ui.test.isDialog 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.performScrollTo
import androidx.compose.ui.test.performTextInput import androidx.compose.ui.test.performTextInput
import com.x8bit.bitwarden.data.platform.repository.util.bufferedMutableSharedFlow 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.BaseComposeTest
import com.x8bit.bitwarden.ui.platform.base.util.asText import com.x8bit.bitwarden.ui.platform.base.util.asText
import com.x8bit.bitwarden.ui.platform.feature.settings.exportvault.model.ExportVaultFormat import com.x8bit.bitwarden.ui.platform.feature.settings.exportvault.model.ExportVaultFormat
@ -215,6 +217,36 @@ class ExportVaultScreenTest : BaseComposeTest() {
assertTrue(onNavigateBackCalled) 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 @Test
fun `password input change should send PasswordInputChange action`() { fun `password input change should send PasswordInputChange action`() {
val input = "Test123" val input = "Test123"
@ -226,9 +258,12 @@ class ExportVaultScreenTest : BaseComposeTest() {
} }
private val DEFAULT_STATE = ExportVaultState( private val DEFAULT_STATE = ExportVaultState(
confirmFilePasswordInput = "",
dialogState = null, dialogState = null,
exportFormat = ExportVaultFormat.JSON, exportFormat = ExportVaultFormat.JSON,
filePasswordInput = "",
passwordInput = "", passwordInput = "",
exportData = "", exportData = "",
passwordStrengthState = PasswordStrengthState.NONE,
policyPreventsExport = false, policyPreventsExport = false,
) )

View file

@ -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.manager.FileManager
import com.x8bit.bitwarden.data.vault.repository.VaultRepository import com.x8bit.bitwarden.data.vault.repository.VaultRepository
import com.x8bit.bitwarden.data.vault.repository.model.ExportVaultDataResult 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.BaseViewModelTest
import com.x8bit.bitwarden.ui.platform.base.util.asText import com.x8bit.bitwarden.ui.platform.base.util.asText
import com.x8bit.bitwarden.ui.platform.feature.settings.exportvault.model.ExportVaultFormat import com.x8bit.bitwarden.ui.platform.feature.settings.exportvault.model.ExportVaultFormat
@ -194,18 +195,42 @@ class ExportVaultViewModelTest : BaseViewModelTest() {
} }
@Test @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() val viewModel = createViewModel()
viewModel.eventFlow.test { viewModel.trySendAction(ExportVaultAction.ConfirmFilePasswordInputChange("Test123"))
viewModel.trySendAction(ExportVaultAction.PasswordInputChanged("Test123"))
assertEquals( assertEquals(
DEFAULT_STATE.copy( DEFAULT_STATE.copy(
passwordInput = "Test123", confirmFilePasswordInput = "Test123",
), ),
viewModel.stateFlow.value, 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 @Test
@ -327,23 +352,25 @@ class ExportVaultViewModelTest : BaseViewModelTest() {
private fun createViewModel( private fun createViewModel(
initialState: ExportVaultState? = null, initialState: ExportVaultState? = null,
): ExportVaultViewModel = ): ExportVaultViewModel = ExportVaultViewModel(
ExportVaultViewModel( authRepository = authRepository,
authRepository = authRepository, policyManager = policyManager,
policyManager = policyManager, savedStateHandle = SavedStateHandle(
savedStateHandle = SavedStateHandle( initialState = mapOf("state" to initialState),
initialState = mapOf("state" to initialState), ),
), fileManager = fileManager,
fileManager = fileManager, vaultRepository = vaultRepository,
vaultRepository = vaultRepository, clock = clock,
clock = clock, )
)
} }
private val DEFAULT_STATE = ExportVaultState( private val DEFAULT_STATE = ExportVaultState(
confirmFilePasswordInput = "",
dialogState = null, dialogState = null,
exportFormat = ExportVaultFormat.JSON, exportFormat = ExportVaultFormat.JSON,
filePasswordInput = "",
passwordInput = "", passwordInput = "",
exportData = null, exportData = null,
passwordStrengthState = PasswordStrengthState.NONE,
policyPreventsExport = false, policyPreventsExport = false,
) )