mirror of
https://github.com/bitwarden/android.git
synced 2025-03-15 18:58:59 +03:00
BIT-1272: Export vault screen (#804)
This commit is contained in:
parent
e5bfdd0fa7
commit
b1c6567df2
19 changed files with 704 additions and 7 deletions
|
@ -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)
|
||||
|
|
|
@ -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() })
|
||||
|
|
|
@ -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)
|
||||
}
|
|
@ -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(),
|
||||
)
|
||||
}
|
||||
}
|
|
@ -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()
|
||||
}
|
|
@ -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,
|
||||
}
|
|
@ -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)
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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.
|
||||
*/
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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,
|
||||
)
|
||||
|
|
|
@ -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)"
|
||||
}
|
|
@ -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 = "",
|
||||
)
|
||||
}
|
||||
}
|
|
@ -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 = "",
|
||||
)
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
|
|
|
@ -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(),
|
||||
)
|
||||
}
|
||||
|
|
|
@ -45,6 +45,7 @@ class VaultUnlockedNavBarScreenTest : BaseComposeTest() {
|
|||
onNavigateToAddSend = {},
|
||||
onNavigateToEditSend = {},
|
||||
onNavigateToDeleteAccount = {},
|
||||
onNavigateToExportVault = {},
|
||||
onNavigateToFolders = {},
|
||||
onNavigateToPasswordHistory = {},
|
||||
onNavigateToPendingRequests = {},
|
||||
|
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
Loading…
Add table
Reference in a new issue