BIT-1272: Export vault screen (#804)

This commit is contained in:
Shannon Draeker 2024-01-26 14:52:57 -07:00 committed by Álison Fernandes
parent e5bfdd0fa7
commit b1c6567df2
19 changed files with 704 additions and 7 deletions

View file

@ -56,7 +56,7 @@ import com.x8bit.bitwarden.ui.platform.theme.LocalIntentManager
import kotlinx.collections.immutable.toPersistentList
/**
* The top level composable for the Login with Device screen.
* The top level composable for the Two-Factor Login screen.
*/
@Suppress("LongMethod")
@OptIn(ExperimentalMaterial3Api::class)

View file

@ -29,6 +29,7 @@ private const val SETTINGS_ROUTE: String = "settings"
fun NavGraphBuilder.settingsGraph(
navController: NavController,
onNavigateToDeleteAccount: () -> Unit,
onNavigateToExportVault: () -> Unit,
onNavigateToFolders: () -> Unit,
onNavigateToPendingRequests: () -> Unit,
) {
@ -62,6 +63,7 @@ fun NavGraphBuilder.settingsGraph(
otherDestination(onNavigateBack = { navController.popBackStack() })
vaultSettingsDestination(
onNavigateBack = { navController.popBackStack() },
onNavigateToExportVault = onNavigateToExportVault,
onNavigateToFolders = onNavigateToFolders,
)
blockAutoFillDestination(onNavigateBack = { navController.popBackStack() })

View file

@ -0,0 +1,30 @@
package com.x8bit.bitwarden.ui.platform.feature.settings.exportvault
import androidx.navigation.NavController
import androidx.navigation.NavGraphBuilder
import androidx.navigation.NavOptions
import com.x8bit.bitwarden.ui.platform.base.util.composableWithSlideTransitions
private const val EXPORT_VAULT_ROUTE = "export_vault"
/**
* Add the Export Vault screen to the nav graph.
*/
fun NavGraphBuilder.exportVaultDestination(
onNavigateBack: () -> Unit,
) {
composableWithSlideTransitions(
route = EXPORT_VAULT_ROUTE,
) {
ExportVaultScreen(
onNavigateBack = onNavigateBack,
)
}
}
/**
* Navigate to the Export Vault screen.
*/
fun NavController.navigateToExportVault(navOptions: NavOptions? = null) {
this.navigate(EXPORT_VAULT_ROUTE, navOptions)
}

View file

@ -0,0 +1,193 @@
package com.x8bit.bitwarden.ui.platform.feature.settings.exportvault
import android.widget.Toast
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.material3.rememberTopAppBarState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.Modifier
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
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.platform.base.util.EventsEffect
import com.x8bit.bitwarden.ui.platform.base.util.asText
import com.x8bit.bitwarden.ui.platform.components.BasicDialogState
import com.x8bit.bitwarden.ui.platform.components.BitwardenBasicDialog
import com.x8bit.bitwarden.ui.platform.components.BitwardenFilledTonalButton
import com.x8bit.bitwarden.ui.platform.components.BitwardenLoadingDialog
import com.x8bit.bitwarden.ui.platform.components.BitwardenMultiSelectButton
import com.x8bit.bitwarden.ui.platform.components.BitwardenPasswordField
import com.x8bit.bitwarden.ui.platform.components.BitwardenScaffold
import com.x8bit.bitwarden.ui.platform.components.BitwardenTopAppBar
import com.x8bit.bitwarden.ui.platform.components.LoadingDialogState
import com.x8bit.bitwarden.ui.platform.feature.settings.exportvault.model.ExportVaultFormat
import com.x8bit.bitwarden.ui.platform.util.displayLabel
import kotlinx.collections.immutable.toImmutableList
/**
* The top level composable for the Export Vault screen.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Suppress("LongMethod")
@Composable
fun ExportVaultScreen(
onNavigateBack: () -> Unit,
viewModel: ExportVaultViewModel = hiltViewModel(),
) {
val state by viewModel.stateFlow.collectAsStateWithLifecycle()
val context = LocalContext.current
EventsEffect(viewModel = viewModel) { event ->
when (event) {
ExportVaultEvent.NavigateBack -> onNavigateBack()
is ExportVaultEvent.ShowToast -> {
Toast.makeText(context, event.message(context.resources), Toast.LENGTH_SHORT).show()
}
}
}
when (val dialog = state.dialogState) {
is ExportVaultState.DialogState.Error -> {
BitwardenBasicDialog(
visibilityState = BasicDialogState.Shown(
title = dialog.title ?: R.string.an_error_has_occurred.asText(),
message = dialog.message,
),
onDismissRequest = remember(viewModel) {
{ viewModel.trySendAction(ExportVaultAction.DialogDismiss) }
},
)
}
is ExportVaultState.DialogState.Loading -> {
BitwardenLoadingDialog(
visibilityState = LoadingDialogState.Shown(
text = dialog.message,
),
)
}
null -> Unit
}
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior(rememberTopAppBarState())
BitwardenScaffold(
modifier = Modifier
.fillMaxSize()
.nestedScroll(scrollBehavior.nestedScrollConnection),
topBar = {
BitwardenTopAppBar(
title = stringResource(id = R.string.export_vault),
scrollBehavior = scrollBehavior,
navigationIcon = painterResource(id = R.drawable.ic_close),
navigationIconContentDescription = stringResource(id = R.string.close),
onNavigationIconClick = remember(viewModel) {
{ viewModel.trySendAction(ExportVaultAction.CloseButtonClick) }
},
)
},
) { innerPadding ->
ExportVaultScreenContent(
state = state,
onExportFormatOptionSelected = remember(viewModel) {
{ viewModel.trySendAction(ExportVaultAction.ExportFormatOptionSelect(it)) }
},
onPasswordInputChanged = remember(viewModel) {
{ viewModel.trySendAction(ExportVaultAction.PasswordInputChanged(it)) }
},
onExportVaultClick = remember(viewModel) {
{ viewModel.trySendAction(ExportVaultAction.ExportVaultClick) }
},
modifier = Modifier
.padding(innerPadding)
.fillMaxSize(),
)
}
}
@OptIn(ExperimentalComposeUiApi::class)
@Composable
private fun ExportVaultScreenContent(
state: ExportVaultState,
onExportFormatOptionSelected: (ExportVaultFormat) -> Unit,
onPasswordInputChanged: (String) -> Unit,
onExportVaultClick: () -> Unit,
modifier: Modifier = Modifier,
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = modifier
.imePadding()
.verticalScroll(rememberScrollState()),
) {
BitwardenMultiSelectButton(
label = stringResource(id = R.string.file_format),
options = ExportVaultFormat.entries.map { it.displayLabel }.toImmutableList(),
selectedOption = state.exportFormat.displayLabel,
onOptionSelected = { selectedOptionLabel ->
val selectedOption = ExportVaultFormat
.entries
.first { it.displayLabel == selectedOptionLabel }
onExportFormatOptionSelected(selectedOption)
},
modifier = Modifier
.padding(horizontal = 16.dp)
.fillMaxWidth(),
)
Spacer(modifier = Modifier.height(8.dp))
BitwardenPasswordField(
label = stringResource(id = R.string.master_password),
value = state.passwordInput,
onValueChange = onPasswordInputChanged,
modifier = Modifier
.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,
modifier = Modifier
.padding(horizontal = 16.dp)
.fillMaxWidth(),
)
}
}

View file

@ -0,0 +1,167 @@
package com.x8bit.bitwarden.ui.platform.feature.settings.exportvault
import android.os.Parcelable
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.viewModelScope
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
import com.x8bit.bitwarden.ui.platform.feature.settings.exportvault.model.ExportVaultFormat
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.update
import kotlinx.parcelize.Parcelize
import javax.inject.Inject
private const val KEY_STATE = "state"
/**
* Manages application state for the Export Vault screen.
*/
@HiltViewModel
class ExportVaultViewModel @Inject constructor(
savedStateHandle: SavedStateHandle,
) : BaseViewModel<ExportVaultState, ExportVaultEvent, ExportVaultAction>(
initialState = savedStateHandle[KEY_STATE]
?: ExportVaultState(
dialogState = null,
exportFormat = ExportVaultFormat.JSON,
passwordInput = "",
),
) {
init {
// As state updates, write to saved state handle.
stateFlow
.onEach { savedStateHandle[KEY_STATE] = it }
.launchIn(viewModelScope)
}
override fun handleAction(action: ExportVaultAction) {
when (action) {
ExportVaultAction.CloseButtonClick -> handleCloseButtonClicked()
ExportVaultAction.DialogDismiss -> handleDialogDismiss()
is ExportVaultAction.ExportFormatOptionSelect -> handleExportFormatOptionSelect(action)
ExportVaultAction.ExportVaultClick -> handleExportVaultClick()
is ExportVaultAction.PasswordInputChanged -> handlePasswordInputChanged(action)
}
}
/**
* Dismiss the view.
*/
private fun handleCloseButtonClicked() {
sendEvent(ExportVaultEvent.NavigateBack)
}
/**
* Dismiss the dialog.
*/
private fun handleDialogDismiss() {
mutableStateFlow.update { it.copy(dialogState = null) }
}
/**
* Update the state with the selected export format.
*/
private fun handleExportFormatOptionSelect(action: ExportVaultAction.ExportFormatOptionSelect) {
mutableStateFlow.update {
it.copy(exportFormat = action.option)
}
}
/**
* Show the confirmation dialog and export the vault.
*/
private fun handleExportVaultClick() {
// TODO: BIT-1273
sendEvent(ExportVaultEvent.ShowToast(message = "Coming soon to an app near you!".asText()))
}
/**
* Update the state with the new password input.
*/
private fun handlePasswordInputChanged(action: ExportVaultAction.PasswordInputChanged) {
mutableStateFlow.update {
it.copy(passwordInput = action.input)
}
}
}
/**
* Models state of the Export Vault screen.
*/
@Parcelize
data class ExportVaultState(
val dialogState: DialogState?,
val exportFormat: ExportVaultFormat,
val passwordInput: String,
) : Parcelable {
/**
* Represents the current state of any dialogs on the screen.
*/
sealed class DialogState : Parcelable {
/**
* Represents an error dialog with the given [message] and optional [title]. If no title
* is specified a default will be provided.
*/
@Parcelize
data class Error(
val title: Text? = null,
val message: Text,
) : DialogState()
/**
* Represents a loading dialog with the given [message].
*/
@Parcelize
data class Loading(
val message: Text,
) : DialogState()
}
}
/**
* Models events for the Export Vault screen.
*/
sealed class ExportVaultEvent {
/**
* Navigates back to the previous screen.
*/
data object NavigateBack : ExportVaultEvent()
/**
* Shows a toast with the given [message].
*/
data class ShowToast(val message: Text) : ExportVaultEvent()
}
/**
* Models actions for the Export Vault screen.
*/
sealed class ExportVaultAction {
/**
* Indicates that the top-bar close button was clicked.
*/
data object CloseButtonClick : ExportVaultAction()
/**
* Indicates that the dialog has been dismissed.
*/
data object DialogDismiss : ExportVaultAction()
/**
* Indicates that an export format option was selected.
*/
data class ExportFormatOptionSelect(val option: ExportVaultFormat) : ExportVaultAction()
/**
* Indicates that the export vault button was clicked.
*/
data object ExportVaultClick : ExportVaultAction()
/**
* Indicates that the password input has changed.
*/
data class PasswordInputChanged(val input: String) : ExportVaultAction()
}

View file

@ -0,0 +1,10 @@
package com.x8bit.bitwarden.ui.platform.feature.settings.exportvault.model
/**
* Represents the file formats a user can select to export the vault.
*/
enum class ExportVaultFormat {
JSON,
CSV,
JSON_ENCRYPTED,
}

View file

@ -8,10 +8,11 @@ import com.x8bit.bitwarden.ui.platform.base.util.composableWithPushTransitions
private const val VAULT_SETTINGS_ROUTE = "vault_settings"
/**
* Add vault settings destinations to the nav graph.
* Add Vault Settings destinations to the nav graph.
*/
fun NavGraphBuilder.vaultSettingsDestination(
onNavigateBack: () -> Unit,
onNavigateToExportVault: () -> Unit,
onNavigateToFolders: () -> Unit,
) {
composableWithPushTransitions(
@ -19,13 +20,14 @@ fun NavGraphBuilder.vaultSettingsDestination(
) {
VaultSettingsScreen(
onNavigateBack = onNavigateBack,
onNavigateToExportVault = onNavigateToExportVault,
onNavigateToFolders = onNavigateToFolders,
)
}
}
/**
* Navigate to the vault settings screen.
* Navigate to the Vault Settings screen.
*/
fun NavController.navigateToVaultSettings(navOptions: NavOptions? = null) {
navigate(VAULT_SETTINGS_ROUTE, navOptions)

View file

@ -33,6 +33,7 @@ import com.x8bit.bitwarden.ui.platform.components.BitwardenTopAppBar
@Composable
fun VaultSettingsScreen(
onNavigateBack: () -> Unit,
onNavigateToExportVault: () -> Unit,
onNavigateToFolders: () -> Unit,
viewModel: VaultSettingsViewModel = hiltViewModel(),
) {
@ -40,6 +41,7 @@ fun VaultSettingsScreen(
EventsEffect(viewModel = viewModel) { event ->
when (event) {
VaultSettingsEvent.NavigateBack -> onNavigateBack()
VaultSettingsEvent.NavigateToExportVault -> onNavigateToExportVault()
VaultSettingsEvent.NavigateToFolders -> onNavigateToFolders()
is VaultSettingsEvent.ShowToast -> {
Toast.makeText(context, event.message, Toast.LENGTH_SHORT).show()

View file

@ -24,8 +24,7 @@ class VaultSettingsViewModel @Inject constructor() :
}
private fun handleExportVaultClicked() {
// TODO BIT-1272 go to vault export screen
sendEvent(VaultSettingsEvent.ShowToast("Not yet implemented."))
sendEvent(VaultSettingsEvent.NavigateToExportVault)
}
private fun handleFoldersButtonClicked() {
@ -47,6 +46,11 @@ sealed class VaultSettingsEvent {
*/
data object NavigateBack : VaultSettingsEvent()
/**
* Navigate to the Export Vault screen.
*/
data object NavigateToExportVault : VaultSettingsEvent()
/**
* Navigate to the Folders screen.
*/

View file

@ -10,8 +10,10 @@ import com.x8bit.bitwarden.ui.platform.feature.settings.accountsecurity.deleteac
import com.x8bit.bitwarden.ui.platform.feature.settings.accountsecurity.deleteaccount.navigateToDeleteAccount
import com.x8bit.bitwarden.ui.platform.feature.settings.accountsecurity.pendingrequests.navigateToPendingRequests
import com.x8bit.bitwarden.ui.platform.feature.settings.accountsecurity.pendingrequests.pendingRequestsDestination
import com.x8bit.bitwarden.ui.platform.feature.settings.exportvault.exportVaultDestination
import com.x8bit.bitwarden.ui.platform.feature.settings.folders.foldersDestination
import com.x8bit.bitwarden.ui.platform.feature.settings.folders.navigateToFolders
import com.x8bit.bitwarden.ui.platform.feature.settings.exportvault.navigateToExportVault
import com.x8bit.bitwarden.ui.platform.feature.vaultunlockednavbar.VAULT_UNLOCKED_NAV_BAR_ROUTE
import com.x8bit.bitwarden.ui.platform.feature.vaultunlockednavbar.vaultUnlockedNavBarDestination
import com.x8bit.bitwarden.ui.tools.feature.generator.generatorModalDestination
@ -56,6 +58,7 @@ fun NavGraphBuilder.vaultUnlockedGraph(
route = VAULT_UNLOCKED_GRAPH_ROUTE,
) {
vaultUnlockedNavBarDestination(
onNavigateToExportVault = { navController.navigateToExportVault() },
onNavigateToFolders = { navController.navigateToFolders() },
onNavigateToVaultAddItem = {
navController.navigateToVaultAddEdit(VaultAddEditType.AddItem)
@ -124,6 +127,7 @@ fun NavGraphBuilder.vaultUnlockedGraph(
addSendDestination(onNavigateBack = { navController.popBackStack() })
passwordHistoryDestination(onNavigateBack = { navController.popBackStack() })
exportVaultDestination(onNavigateBack = { navController.popBackStack() })
foldersDestination(onNavigateBack = { navController.popBackStack() })
generatorModalDestination(onNavigateBack = { navController.popBackStack() })
searchDestination(

View file

@ -31,6 +31,7 @@ fun NavGraphBuilder.vaultUnlockedNavBarDestination(
onNavigateToAddSend: () -> Unit,
onNavigateToEditSend: (sendItemId: String) -> Unit,
onNavigateToDeleteAccount: () -> Unit,
onNavigateToExportVault: () -> Unit,
onNavigateToFolders: () -> Unit,
onNavigateToPendingRequests: () -> Unit,
onNavigateToPasswordHistory: () -> Unit,
@ -47,6 +48,7 @@ fun NavGraphBuilder.vaultUnlockedNavBarDestination(
onNavigateToAddSend = onNavigateToAddSend,
onNavigateToEditSend = onNavigateToEditSend,
onNavigateToDeleteAccount = onNavigateToDeleteAccount,
onNavigateToExportVault = onNavigateToExportVault,
onNavigateToFolders = onNavigateToFolders,
onNavigateToPendingRequests = onNavigateToPendingRequests,
onNavigateToPasswordHistory = onNavigateToPasswordHistory,

View file

@ -80,6 +80,7 @@ fun VaultUnlockedNavBarScreen(
onNavigateToAddSend: () -> Unit,
onNavigateToEditSend: (sendItemId: String) -> Unit,
onNavigateToDeleteAccount: () -> Unit,
onNavigateToExportVault: () -> Unit,
onNavigateToFolders: () -> Unit,
onNavigateToPendingRequests: () -> Unit,
onNavigateToPasswordHistory: () -> Unit,
@ -125,6 +126,7 @@ fun VaultUnlockedNavBarScreen(
navigateToAddSend = onNavigateToAddSend,
onNavigateToEditSend = onNavigateToEditSend,
navigateToDeleteAccount = onNavigateToDeleteAccount,
navigateToExportVault = onNavigateToExportVault,
navigateToFolders = onNavigateToFolders,
navigateToPendingRequests = onNavigateToPendingRequests,
navigateToPasswordHistory = onNavigateToPasswordHistory,
@ -163,6 +165,7 @@ private fun VaultUnlockedNavBarScaffold(
navigateToAddSend: () -> Unit,
onNavigateToEditSend: (sendItemId: String) -> Unit,
navigateToDeleteAccount: () -> Unit,
navigateToExportVault: () -> Unit,
navigateToFolders: () -> Unit,
navigateToPendingRequests: () -> Unit,
navigateToPasswordHistory: () -> Unit,
@ -237,6 +240,7 @@ private fun VaultUnlockedNavBarScaffold(
settingsGraph(
navController = navController,
onNavigateToDeleteAccount = navigateToDeleteAccount,
onNavigateToExportVault = navigateToExportVault,
onNavigateToFolders = navigateToFolders,
onNavigateToPendingRequests = navigateToPendingRequests,
)

View file

@ -0,0 +1,10 @@
package com.x8bit.bitwarden.ui.platform.util
import com.x8bit.bitwarden.ui.platform.feature.settings.exportvault.model.ExportVaultFormat
val ExportVaultFormat.displayLabel: String
get() = when (this) {
ExportVaultFormat.JSON -> ".json"
ExportVaultFormat.CSV -> ".csv"
ExportVaultFormat.JSON_ENCRYPTED -> ".json (Encrypted)"
}

View file

@ -0,0 +1,144 @@
package com.x8bit.bitwarden.ui.platform.feature.settings.exportvault
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.isDisplayed
import androidx.compose.ui.test.onAllNodesWithText
import androidx.compose.ui.test.onFirst
import androidx.compose.ui.test.onNodeWithContentDescription
import androidx.compose.ui.test.onNodeWithText
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.platform.base.BaseComposeTest
import com.x8bit.bitwarden.ui.platform.base.util.asText
import com.x8bit.bitwarden.ui.platform.feature.settings.exportvault.model.ExportVaultFormat
import io.mockk.every
import io.mockk.mockk
import io.mockk.verify
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.update
import org.junit.Before
import org.junit.Test
class ExportVaultScreenTest : BaseComposeTest() {
private var onNavigateBackCalled = false
private val mutableEventFlow = bufferedMutableSharedFlow<ExportVaultEvent>()
private val mutableStateFlow = MutableStateFlow(DEFAULT_STATE)
val viewModel = mockk<ExportVaultViewModel>(relaxed = true) {
every { eventFlow } returns mutableEventFlow
every { stateFlow } returns mutableStateFlow
}
@Before
fun setUp() {
composeTestRule.setContent {
ExportVaultScreen(
onNavigateBack = { onNavigateBackCalled = true },
viewModel = viewModel,
)
}
}
@Test
fun `basicDialog should update according to state`() {
composeTestRule.onNodeWithText("Error message").assertDoesNotExist()
mutableStateFlow.update {
it.copy(
dialogState = ExportVaultState.DialogState.Error(
title = null,
message = "Error message".asText(),
),
)
}
composeTestRule.onNodeWithText("Error message").isDisplayed()
}
@Test
fun `close button click should send CloseButtonClick action`() {
composeTestRule.onNodeWithContentDescription("Close").performClick()
verify {
viewModel.trySendAction(ExportVaultAction.CloseButtonClick)
}
}
@Test
fun `export vault button click should emit ExportVaultClick action`() {
composeTestRule
.onAllNodesWithText("Export vault")
.onFirst()
.performClick()
verify {
viewModel.trySendAction(ExportVaultAction.ExportVaultClick)
}
}
@Test
fun `file format selection button should send ExportFormatOptionSelect action`() {
// Open the menu.
composeTestRule
.onNodeWithContentDescription(label = "File format, .json")
.performClick()
// Choose the option from the menu.
composeTestRule
.onNodeWithText(".csv")
.performScrollTo()
.performClick()
verify {
viewModel.trySendAction(
ExportVaultAction.ExportFormatOptionSelect(ExportVaultFormat.CSV),
)
}
}
@Test
fun `file format selection button should update according to state`() {
composeTestRule
.onNodeWithContentDescription(label = "File format, .json")
.assertIsDisplayed()
mutableStateFlow.update {
it.copy(exportFormat = ExportVaultFormat.CSV)
}
composeTestRule
.onNodeWithContentDescription(label = "File format, .csv")
.assertIsDisplayed()
}
@Test
fun `loadingDialog should update according to state`() {
composeTestRule.onNodeWithText("Loading...").assertDoesNotExist()
mutableStateFlow.update {
it.copy(
dialogState = ExportVaultState.DialogState.Loading(
message = "Loading...".asText(),
),
)
}
composeTestRule.onNodeWithText("Loading...").isDisplayed()
}
@Test
fun `password input change should send PasswordInputChange action`() {
val input = "Test123"
composeTestRule.onNodeWithText("Master password").performTextInput(input)
verify {
viewModel.trySendAction(ExportVaultAction.PasswordInputChanged("Test123"))
}
}
companion object {
private val DEFAULT_STATE = ExportVaultState(
dialogState = null,
exportFormat = ExportVaultFormat.JSON,
passwordInput = "",
)
}
}

View file

@ -0,0 +1,92 @@
package com.x8bit.bitwarden.ui.platform.feature.settings.exportvault
import androidx.lifecycle.SavedStateHandle
import app.cash.turbine.test
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
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Test
class ExportVaultViewModelTest : BaseViewModelTest() {
private val savedStateHandle = SavedStateHandle()
@Test
fun `initial state should be correct`() = runTest {
val viewModel = createViewModel()
viewModel.stateFlow.test {
assertEquals(DEFAULT_STATE, awaitItem())
}
}
@Test
fun `CloseButtonClick should emit NavigateBack`() = runTest {
val viewModel = createViewModel()
viewModel.eventFlow.test {
viewModel.actionChannel.trySend(ExportVaultAction.CloseButtonClick)
assertEquals(
ExportVaultEvent.NavigateBack,
awaitItem(),
)
}
}
@Test
fun `ExportFormatOptionSelect should update the selected export format in the state`() =
runTest {
val viewModel = createViewModel()
viewModel.eventFlow.test {
viewModel.trySendAction(
ExportVaultAction.ExportFormatOptionSelect(ExportVaultFormat.CSV),
)
assertEquals(
DEFAULT_STATE.copy(
exportFormat = ExportVaultFormat.CSV,
),
viewModel.stateFlow.value,
)
}
}
@Test
fun `ExportVaultClick should emit ShowToast`() = runTest {
val viewModel = createViewModel()
viewModel.eventFlow.test {
viewModel.actionChannel.trySend(ExportVaultAction.ExportVaultClick)
assertEquals(
ExportVaultEvent.ShowToast(message = "Coming soon to an app near you!".asText()),
awaitItem(),
)
}
}
@Test
fun `PasswordInputChanged should update the password input in the state`() = runTest {
val viewModel = createViewModel()
viewModel.eventFlow.test {
viewModel.trySendAction(ExportVaultAction.PasswordInputChanged("Test123"))
assertEquals(
DEFAULT_STATE.copy(
passwordInput = "Test123",
),
viewModel.stateFlow.value,
)
}
}
private fun createViewModel(): ExportVaultViewModel =
ExportVaultViewModel(
savedStateHandle = savedStateHandle,
)
companion object {
private val DEFAULT_STATE = ExportVaultState(
dialogState = null,
exportFormat = ExportVaultFormat.JSON,
passwordInput = "",
)
}
}

View file

@ -20,6 +20,7 @@ import org.junit.Test
class VaultSettingsScreenTest : BaseComposeTest() {
private var onNavigateBackCalled = false
private var onNavigateToExportVaultCalled = false
private var onNavigateToFoldersCalled = false
private val mutableEventFlow = bufferedMutableSharedFlow<VaultSettingsEvent>()
private val mutableStateFlow = MutableStateFlow(Unit)
@ -34,6 +35,7 @@ class VaultSettingsScreenTest : BaseComposeTest() {
VaultSettingsScreen(
viewModel = viewModel,
onNavigateBack = { onNavigateBackCalled = true },
onNavigateToExportVault = { onNavigateToExportVaultCalled = true },
onNavigateToFolders = { onNavigateToFoldersCalled = true },
)
}
@ -88,6 +90,12 @@ class VaultSettingsScreenTest : BaseComposeTest() {
assertTrue(onNavigateBackCalled)
}
@Test
fun `NavigateToExportVault should call onNavigateToExportVault`() {
mutableEventFlow.tryEmit(VaultSettingsEvent.NavigateToExportVault)
assertTrue(onNavigateToExportVaultCalled)
}
@Test
fun `NavigateToFolders should call onNavigateToFolders`() {
mutableEventFlow.tryEmit(VaultSettingsEvent.NavigateToFolders)

View file

@ -18,12 +18,12 @@ class VaultSettingsViewModelTest : BaseViewModelTest() {
}
@Test
fun `ExportVaultClick should emit ShowToast`() = runTest {
fun `ExportVaultClick should emit NavigateToExportVault`() = runTest {
val viewModel = createViewModel()
viewModel.eventFlow.test {
viewModel.trySendAction(VaultSettingsAction.ExportVaultClick)
assertEquals(
VaultSettingsEvent.ShowToast("Not yet implemented."),
VaultSettingsEvent.NavigateToExportVault,
awaitItem(),
)
}

View file

@ -45,6 +45,7 @@ class VaultUnlockedNavBarScreenTest : BaseComposeTest() {
onNavigateToAddSend = {},
onNavigateToEditSend = {},
onNavigateToDeleteAccount = {},
onNavigateToExportVault = {},
onNavigateToFolders = {},
onNavigateToPasswordHistory = {},
onNavigateToPendingRequests = {},

View file

@ -0,0 +1,22 @@
package com.x8bit.bitwarden.ui.platform.util
import com.x8bit.bitwarden.ui.platform.feature.settings.exportvault.model.ExportVaultFormat
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Test
class ExportVaultFormatExtensionTest {
@Test
fun `displayLabel should return the correct value for each type`() {
mapOf(
ExportVaultFormat.JSON to ".json",
ExportVaultFormat.CSV to ".csv",
ExportVaultFormat.JSON_ENCRYPTED to ".json (Encrypted)",
)
.forEach { (type, label) ->
assertEquals(
label,
type.displayLabel,
)
}
}
}