mirror of
https://github.com/bitwarden/android.git
synced 2024-11-24 10:25:57 +03:00
BIT-2235: Add support for exporting vault data w/o passcode (#1281)
This commit is contained in:
parent
9648f720be
commit
dae98111e6
4 changed files with 281 additions and 55 deletions
|
@ -26,10 +26,11 @@ import androidx.compose.ui.Alignment
|
|||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.input.nestedscroll.nestedScroll
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.testTag
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.semantics.testTag
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
|
@ -177,6 +178,9 @@ fun ExportVaultScreen(
|
|||
onPasswordInputChanged = remember(viewModel) {
|
||||
{ viewModel.trySendAction(ExportVaultAction.PasswordInputChanged(it)) }
|
||||
},
|
||||
onSendCodeClicked = remember(viewModel) {
|
||||
{ viewModel.trySendAction(ExportVaultAction.SendCodeClick) }
|
||||
},
|
||||
onExportVaultClick = { shouldShowConfirmationDialog = true },
|
||||
modifier = Modifier
|
||||
.padding(innerPadding)
|
||||
|
@ -193,6 +197,7 @@ private fun ExportVaultScreenContent(
|
|||
onExportFormatOptionSelected: (ExportVaultFormat) -> Unit,
|
||||
onFilePasswordInputChanged: (String) -> Unit,
|
||||
onPasswordInputChanged: (String) -> Unit,
|
||||
onSendCodeClicked: () -> Unit,
|
||||
onExportVaultClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
|
@ -207,7 +212,7 @@ private fun ExportVaultScreenContent(
|
|||
BitwardenPolicyWarningText(
|
||||
text = stringResource(id = R.string.disable_personal_vault_export_policy_in_effect),
|
||||
modifier = Modifier
|
||||
.semantics { testTag = "DisablePrivateVaultPolicyLabel" }
|
||||
.testTag("DisablePrivateVaultPolicyLabel")
|
||||
.padding(horizontal = 16.dp)
|
||||
.fillMaxWidth(),
|
||||
)
|
||||
|
@ -228,7 +233,7 @@ private fun ExportVaultScreenContent(
|
|||
},
|
||||
isEnabled = !state.policyPreventsExport,
|
||||
modifier = Modifier
|
||||
.semantics { testTag = "FileFormatPicker" }
|
||||
.testTag("FileFormatPicker")
|
||||
.padding(horizontal = 16.dp)
|
||||
.fillMaxWidth(),
|
||||
)
|
||||
|
@ -245,7 +250,7 @@ private fun ExportVaultScreenContent(
|
|||
showPasswordChange = { showPassword = it },
|
||||
hint = stringResource(id = R.string.password_used_to_export),
|
||||
modifier = Modifier
|
||||
.semantics { testTag = "FilePasswordEntry" }
|
||||
.testTag("FilePasswordEntry")
|
||||
.padding(horizontal = 16.dp)
|
||||
.fillMaxWidth(),
|
||||
)
|
||||
|
@ -264,44 +269,87 @@ private fun ExportVaultScreenContent(
|
|||
showPassword = showPassword,
|
||||
showPasswordChange = { showPassword = it },
|
||||
modifier = Modifier
|
||||
.semantics { testTag = "ConfirmFilePasswordEntry" }
|
||||
.testTag("ConfirmFilePasswordEntry")
|
||||
.padding(horizontal = 16.dp)
|
||||
.fillMaxWidth(),
|
||||
)
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
}
|
||||
|
||||
BitwardenPasswordField(
|
||||
label = stringResource(id = R.string.master_password),
|
||||
value = state.passwordInput,
|
||||
readOnly = state.policyPreventsExport,
|
||||
onValueChange = onPasswordInputChanged,
|
||||
modifier = Modifier
|
||||
.semantics { testTag = "MasterPasswordEntry" }
|
||||
.padding(horizontal = 16.dp)
|
||||
.fillMaxWidth(),
|
||||
)
|
||||
if (state.showSendCodeButton) {
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
Text(
|
||||
text = stringResource(id = R.string.export_vault_master_password_description),
|
||||
textAlign = TextAlign.Start,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier
|
||||
.padding(horizontal = 16.dp)
|
||||
.fillMaxWidth(),
|
||||
)
|
||||
Text(
|
||||
text = stringResource(id = R.string.send_verification_code_to_email),
|
||||
textAlign = TextAlign.Start,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier
|
||||
.padding(horizontal = 16.dp)
|
||||
.fillMaxWidth(),
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
BitwardenFilledTonalButton(
|
||||
label = stringResource(R.string.send_code),
|
||||
onClick = onSendCodeClicked,
|
||||
isEnabled = !state.policyPreventsExport,
|
||||
modifier = Modifier
|
||||
.testTag("SendTOTPCodeButton")
|
||||
.padding(horizontal = 16.dp)
|
||||
.fillMaxWidth(),
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
BitwardenPasswordField(
|
||||
label = stringResource(id = R.string.verification_code),
|
||||
value = state.passwordInput,
|
||||
readOnly = state.policyPreventsExport,
|
||||
hint = stringResource(id = R.string.confirm_your_identity),
|
||||
onValueChange = onPasswordInputChanged,
|
||||
keyboardType = KeyboardType.Number,
|
||||
modifier = Modifier
|
||||
.padding(horizontal = 16.dp)
|
||||
.fillMaxWidth(),
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
} else {
|
||||
BitwardenPasswordField(
|
||||
label = stringResource(id = R.string.master_password),
|
||||
value = state.passwordInput,
|
||||
readOnly = state.policyPreventsExport,
|
||||
onValueChange = onPasswordInputChanged,
|
||||
modifier = Modifier
|
||||
.testTag("MasterPasswordEntry")
|
||||
.padding(horizontal = 16.dp)
|
||||
.fillMaxWidth(),
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
Text(
|
||||
text = stringResource(id = R.string.export_vault_master_password_description),
|
||||
textAlign = TextAlign.Start,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier
|
||||
.padding(horizontal = 16.dp)
|
||||
.fillMaxWidth(),
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
}
|
||||
|
||||
BitwardenFilledTonalButton(
|
||||
label = stringResource(id = R.string.export_vault),
|
||||
onClick = onExportVaultClick,
|
||||
isEnabled = !state.policyPreventsExport,
|
||||
modifier = Modifier
|
||||
.semantics { testTag = "ExportVaultButton" }
|
||||
.testTag("ExportVaultButton")
|
||||
.padding(horizontal = 16.dp)
|
||||
.fillMaxWidth(),
|
||||
)
|
||||
|
|
|
@ -9,6 +9,7 @@ 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.auth.repository.model.VerifyOtpResult
|
||||
import com.x8bit.bitwarden.data.platform.manager.PolicyManager
|
||||
import com.x8bit.bitwarden.data.vault.datasource.network.model.PolicyTypeJson
|
||||
import com.x8bit.bitwarden.data.vault.manager.FileManager
|
||||
|
@ -42,7 +43,7 @@ private const val KEY_STATE = "state"
|
|||
@HiltViewModel
|
||||
class ExportVaultViewModel @Inject constructor(
|
||||
private val authRepository: AuthRepository,
|
||||
private val policyManager: PolicyManager,
|
||||
policyManager: PolicyManager,
|
||||
savedStateHandle: SavedStateHandle,
|
||||
private val vaultRepository: VaultRepository,
|
||||
private val fileManager: FileManager,
|
||||
|
@ -61,6 +62,12 @@ class ExportVaultViewModel @Inject constructor(
|
|||
policyPreventsExport = policyManager
|
||||
.getActivePolicies(type = PolicyTypeJson.DISABLE_PERSONAL_VAULT_EXPORT)
|
||||
.any(),
|
||||
showSendCodeButton = authRepository
|
||||
.userStateFlow
|
||||
.value
|
||||
?.activeAccount
|
||||
?.trustedDevice
|
||||
?.hasMasterPassword == false,
|
||||
),
|
||||
) {
|
||||
/**
|
||||
|
@ -88,6 +95,7 @@ class ExportVaultViewModel @Inject constructor(
|
|||
is ExportVaultAction.FilePasswordInputChange -> handleFilePasswordInputChanged(action)
|
||||
is ExportVaultAction.ExportFormatOptionSelect -> handleExportFormatOptionSelect(action)
|
||||
is ExportVaultAction.PasswordInputChanged -> handlePasswordInputChanged(action)
|
||||
ExportVaultAction.SendCodeClick -> handleSendCodeClick()
|
||||
is ExportVaultAction.ExportLocationReceive -> handleExportLocationReceive(action)
|
||||
|
||||
is ExportVaultAction.Internal.ReceiveValidatePasswordResult -> {
|
||||
|
@ -105,6 +113,10 @@ class ExportVaultViewModel @Inject constructor(
|
|||
is ExportVaultAction.Internal.SaveExportDataToUriResultReceive -> {
|
||||
handleExportDataFinishedSavingToDisk(action)
|
||||
}
|
||||
|
||||
is ExportVaultAction.Internal.ReceiveVerifyOneTimePasscodeResult -> {
|
||||
handleReceiveVerifyOneTimePasscodeResult(action)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -158,11 +170,19 @@ class ExportVaultViewModel @Inject constructor(
|
|||
// Otherwise, validate the password.
|
||||
viewModelScope.launch {
|
||||
sendAction(
|
||||
ExportVaultAction.Internal.ReceiveValidatePasswordResult(
|
||||
result = authRepository.validatePassword(
|
||||
password = state.passwordInput,
|
||||
),
|
||||
),
|
||||
if (state.showSendCodeButton) {
|
||||
ExportVaultAction.Internal.ReceiveVerifyOneTimePasscodeResult(
|
||||
result = authRepository.verifyOneTimePasscode(
|
||||
oneTimePasscode = state.passwordInput,
|
||||
),
|
||||
)
|
||||
} else {
|
||||
ExportVaultAction.Internal.ReceiveValidatePasswordResult(
|
||||
result = authRepository.validatePassword(
|
||||
password = state.passwordInput,
|
||||
),
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
@ -248,6 +268,12 @@ class ExportVaultViewModel @Inject constructor(
|
|||
}
|
||||
}
|
||||
|
||||
private fun handleSendCodeClick() {
|
||||
viewModelScope.launch {
|
||||
authRepository.requestOneTimePasscode()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Show an alert or proceed to export the vault after validating the password.
|
||||
*/
|
||||
|
@ -266,27 +292,7 @@ class ExportVaultViewModel @Inject constructor(
|
|||
return
|
||||
}
|
||||
|
||||
mutableStateFlow.update {
|
||||
it.copy(dialogState = ExportVaultState.DialogState.Loading())
|
||||
}
|
||||
|
||||
viewModelScope.launch {
|
||||
val result = vaultRepository.exportVaultDataToString(
|
||||
format = state.exportFormat.toExportFormat(
|
||||
password = if (state.exportFormat == ExportVaultFormat.JSON_ENCRYPTED) {
|
||||
state.filePasswordInput
|
||||
} else {
|
||||
state.passwordInput
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
sendAction(
|
||||
ExportVaultAction.Internal.ReceiveExportVaultDataToStringResult(
|
||||
result = result,
|
||||
),
|
||||
)
|
||||
}
|
||||
exportVaultData()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -366,6 +372,45 @@ class ExportVaultViewModel @Inject constructor(
|
|||
sendEvent(ExportVaultEvent.ShowToast(R.string.export_vault_success.asText()))
|
||||
}
|
||||
|
||||
private fun handleReceiveVerifyOneTimePasscodeResult(
|
||||
action: ExportVaultAction.Internal.ReceiveVerifyOneTimePasscodeResult,
|
||||
) {
|
||||
when (action.result) {
|
||||
VerifyOtpResult.Verified -> exportVaultData()
|
||||
|
||||
is VerifyOtpResult.NotVerified -> {
|
||||
updateStateWithError(R.string.generic_error_message.asText())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles exporting the vault data after all validation has finished.
|
||||
*/
|
||||
private fun exportVaultData() {
|
||||
mutableStateFlow.update {
|
||||
it.copy(dialogState = ExportVaultState.DialogState.Loading())
|
||||
}
|
||||
|
||||
viewModelScope.launch {
|
||||
val result = vaultRepository.exportVaultDataToString(
|
||||
format = state.exportFormat.toExportFormat(
|
||||
password = if (state.exportFormat == ExportVaultFormat.JSON_ENCRYPTED) {
|
||||
state.filePasswordInput
|
||||
} else {
|
||||
state.passwordInput
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
sendAction(
|
||||
ExportVaultAction.Internal.ReceiveExportVaultDataToStringResult(
|
||||
result = result,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun updateStateWithError(message: Text) {
|
||||
mutableStateFlow.update {
|
||||
it.copy(
|
||||
|
@ -393,6 +438,7 @@ data class ExportVaultState(
|
|||
val passwordInput: String,
|
||||
val passwordStrengthState: PasswordStrengthState,
|
||||
val policyPreventsExport: Boolean,
|
||||
val showSendCodeButton: Boolean,
|
||||
) : Parcelable {
|
||||
/**
|
||||
* Represents the current state of any dialogs on the screen.
|
||||
|
@ -484,6 +530,11 @@ sealed class ExportVaultAction {
|
|||
*/
|
||||
data class PasswordInputChanged(val input: String) : ExportVaultAction()
|
||||
|
||||
/**
|
||||
* Indicates that the user pressed the button to send a code in place of entering a password.
|
||||
*/
|
||||
data object SendCodeClick : ExportVaultAction()
|
||||
|
||||
/**
|
||||
* Models actions that the [ExportVaultViewModel] might send itself.
|
||||
*/
|
||||
|
@ -516,5 +567,12 @@ sealed class ExportVaultAction {
|
|||
data class ReceiveValidatePasswordResult(
|
||||
val result: ValidatePasswordResult,
|
||||
) : Internal()
|
||||
|
||||
/**
|
||||
* Indicates that a result for verifying the one-time passcode has been received.
|
||||
*/
|
||||
data class ReceiveVerifyOneTimePasscodeResult(
|
||||
val result: VerifyOtpResult,
|
||||
) : Internal()
|
||||
}
|
||||
}
|
||||
|
|
|
@ -247,12 +247,55 @@ class ExportVaultScreenTest : BaseComposeTest() {
|
|||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `send code click should send SendCodeClick action`() {
|
||||
mutableStateFlow.update {
|
||||
it.copy(showSendCodeButton = true)
|
||||
}
|
||||
composeTestRule.onNodeWithText("Send code").performClick()
|
||||
verify {
|
||||
viewModel.trySendAction(ExportVaultAction.SendCodeClick)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `verification code input change should send PasswordInputChange action`() {
|
||||
val input = "123"
|
||||
mutableStateFlow.update {
|
||||
it.copy(showSendCodeButton = true)
|
||||
}
|
||||
composeTestRule.onNodeWithText("Verification code").performTextInput(input)
|
||||
verify {
|
||||
viewModel.trySendAction(ExportVaultAction.PasswordInputChanged(input))
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `send code button and verification input field should appear based on state`() {
|
||||
composeTestRule.onNodeWithText("Send code").assertIsNotDisplayed()
|
||||
composeTestRule.onNodeWithText("Verification code").assertIsNotDisplayed()
|
||||
mutableStateFlow.update {
|
||||
it.copy(showSendCodeButton = true)
|
||||
}
|
||||
composeTestRule.onNodeWithText("Send code").assertIsDisplayed()
|
||||
composeTestRule.onNodeWithText("Verification code").assertIsDisplayed()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `master password field should appear based on state`() {
|
||||
composeTestRule.onNodeWithText("Master password").assertIsDisplayed()
|
||||
mutableStateFlow.update {
|
||||
it.copy(showSendCodeButton = true)
|
||||
}
|
||||
composeTestRule.onNodeWithText("Master password").assertIsNotDisplayed()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `password input change should send PasswordInputChange action`() {
|
||||
val input = "Test123"
|
||||
composeTestRule.onNodeWithText("Master password").performTextInput(input)
|
||||
verify {
|
||||
viewModel.trySendAction(ExportVaultAction.PasswordInputChanged("Test123"))
|
||||
viewModel.trySendAction(ExportVaultAction.PasswordInputChanged(input))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -267,4 +310,5 @@ private val DEFAULT_STATE = ExportVaultState(
|
|||
exportData = "",
|
||||
passwordStrengthState = PasswordStrengthState.NONE,
|
||||
policyPreventsExport = false,
|
||||
showSendCodeButton = false,
|
||||
)
|
||||
|
|
|
@ -8,8 +8,10 @@ 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.RequestOtpResult
|
||||
import com.x8bit.bitwarden.data.auth.repository.model.UserState
|
||||
import com.x8bit.bitwarden.data.auth.repository.model.ValidatePasswordResult
|
||||
import com.x8bit.bitwarden.data.auth.repository.model.VerifyOtpResult
|
||||
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
|
||||
|
@ -105,6 +107,66 @@ class ExportVaultViewModelTest : BaseViewModelTest() {
|
|||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `ConfirmExportVaultClicked with verified code should call exportVaultDataToString`() {
|
||||
val passcode = "1234"
|
||||
val initialState = DEFAULT_STATE.copy(
|
||||
passwordInput = passcode,
|
||||
showSendCodeButton = true,
|
||||
)
|
||||
coEvery {
|
||||
authRepository.verifyOneTimePasscode(
|
||||
oneTimePasscode = passcode,
|
||||
)
|
||||
} returns VerifyOtpResult.Verified
|
||||
|
||||
val viewModel = createViewModel(initialState)
|
||||
|
||||
viewModel.trySendAction(ExportVaultAction.ConfirmExportVaultClicked)
|
||||
|
||||
assertEquals(
|
||||
initialState.copy(
|
||||
exportData = "data",
|
||||
passwordInput = "",
|
||||
),
|
||||
viewModel.stateFlow.value,
|
||||
)
|
||||
coVerify {
|
||||
vaultRepository.exportVaultDataToString(any())
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `ConfirmExportVaultClicked with invalid code should call exportVaultDataToString`() {
|
||||
val passcode = "1234"
|
||||
val initialState = DEFAULT_STATE.copy(
|
||||
passwordInput = passcode,
|
||||
showSendCodeButton = true,
|
||||
)
|
||||
coEvery {
|
||||
authRepository.verifyOneTimePasscode(
|
||||
oneTimePasscode = passcode,
|
||||
)
|
||||
} returns VerifyOtpResult.NotVerified("Wrong")
|
||||
|
||||
val viewModel = createViewModel(initialState)
|
||||
|
||||
viewModel.trySendAction(ExportVaultAction.ConfirmExportVaultClicked)
|
||||
|
||||
assertEquals(
|
||||
initialState.copy(
|
||||
dialogState = ExportVaultState.DialogState.Error(
|
||||
title = R.string.an_error_has_occurred.asText(),
|
||||
message = R.string.generic_error_message.asText(),
|
||||
),
|
||||
),
|
||||
viewModel.stateFlow.value,
|
||||
)
|
||||
coVerify(exactly = 0) {
|
||||
vaultRepository.exportVaultDataToString(any())
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("MaxLineLength")
|
||||
@Test
|
||||
fun `ConfirmExportVaultClicked should show success with valid input when export type is JSON_ENCRYPTED`() {
|
||||
|
@ -415,6 +477,19 @@ class ExportVaultViewModelTest : BaseViewModelTest() {
|
|||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `SendCodeClick should call requestOneTimePasscode`() {
|
||||
val viewModel = createViewModel()
|
||||
coEvery { authRepository.requestOneTimePasscode() } returns RequestOtpResult.Success
|
||||
viewModel.trySendAction(ExportVaultAction.SendCodeClick)
|
||||
|
||||
assertEquals(
|
||||
DEFAULT_STATE,
|
||||
viewModel.stateFlow.value,
|
||||
)
|
||||
coVerify { authRepository.requestOneTimePasscode() }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `ReceiveExportVaultDataToStringResult should update state to error if result is error`() {
|
||||
val viewModel = createViewModel()
|
||||
|
@ -659,4 +734,5 @@ private val DEFAULT_STATE = ExportVaultState(
|
|||
exportData = null,
|
||||
passwordStrengthState = PasswordStrengthState.NONE,
|
||||
policyPreventsExport = false,
|
||||
showSendCodeButton = false,
|
||||
)
|
||||
|
|
Loading…
Reference in a new issue