mirror of
https://github.com/bitwarden/android.git
synced 2026-08-29 10:17:56 -05:00
[PM-26111] Implement Review Export Screen and Navigation (#5946)
This commit is contained in:
+2
-2
@@ -65,7 +65,7 @@ import com.x8bit.bitwarden.ui.tools.feature.send.addedit.navigateToAddEditSend
|
||||
import com.x8bit.bitwarden.ui.vault.feature.addedit.VaultAddEditArgs
|
||||
import com.x8bit.bitwarden.ui.vault.feature.addedit.navigateToVaultAddEdit
|
||||
import com.x8bit.bitwarden.ui.vault.feature.addedit.util.toVaultItemCipherType
|
||||
import com.x8bit.bitwarden.ui.vault.feature.exportitems.ExportItemsRoute
|
||||
import com.x8bit.bitwarden.ui.vault.feature.exportitems.ExportItemsGraphRoute
|
||||
import com.x8bit.bitwarden.ui.vault.feature.exportitems.exportItemsGraph
|
||||
import com.x8bit.bitwarden.ui.vault.feature.exportitems.navigateToExportItemsGraph
|
||||
import com.x8bit.bitwarden.ui.vault.feature.itemlisting.navigateToVaultItemListingAsRoot
|
||||
@@ -142,7 +142,7 @@ fun RootNavScreen(
|
||||
is RootNavState.VaultUnlockedForProviderGetCredentials,
|
||||
-> VaultUnlockedGraphRoute
|
||||
|
||||
is RootNavState.CredentialExchangeExport -> ExportItemsRoute
|
||||
is RootNavState.CredentialExchangeExport -> ExportItemsGraphRoute
|
||||
RootNavState.OnboardingAccountLockSetup -> SetupUnlockRoute.AsRoot
|
||||
RootNavState.OnboardingAutoFillSetup -> SetupAutofillRoute.AsRoot
|
||||
RootNavState.OnboardingBrowserAutofillSetup -> SetupBrowserAutofillRoute.AsRoot
|
||||
|
||||
+22
-7
@@ -7,6 +7,8 @@ import androidx.navigation.NavGraphBuilder
|
||||
import androidx.navigation.NavOptions
|
||||
import androidx.navigation.navigation
|
||||
import com.bitwarden.annotation.OmitFromCoverage
|
||||
import com.x8bit.bitwarden.ui.vault.feature.exportitems.reviewexport.navigateToReviewExport
|
||||
import com.x8bit.bitwarden.ui.vault.feature.exportitems.reviewexport.reviewExportDestination
|
||||
import com.x8bit.bitwarden.ui.vault.feature.exportitems.selectaccount.SelectAccountRoute
|
||||
import com.x8bit.bitwarden.ui.vault.feature.exportitems.selectaccount.selectAccountDestination
|
||||
import com.x8bit.bitwarden.ui.vault.feature.exportitems.verifypassword.navigateToVerifyPassword
|
||||
@@ -14,19 +16,27 @@ import com.x8bit.bitwarden.ui.vault.feature.exportitems.verifypassword.verifyPas
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
/**
|
||||
* The type-safe route for the export items graph.
|
||||
* The type-safe route for the root of the export items feature graph.
|
||||
*
|
||||
* This route serves as the entry point for the entire export items flow.
|
||||
*/
|
||||
@OmitFromCoverage
|
||||
@Serializable
|
||||
data object ExportItemsRoute
|
||||
data object ExportItemsGraphRoute
|
||||
|
||||
/**
|
||||
* Add export items destinations to the nav graph.
|
||||
* Adds the export items feature graph to the NavGraphBuilder.
|
||||
*
|
||||
* This graph encompasses the entire flow for exporting items from the vault,
|
||||
* including account selection, password verification, and reviewing the export details.
|
||||
*
|
||||
* @param navController The [NavController] used for navigating within this graph and back to
|
||||
* previous screens.
|
||||
*/
|
||||
fun NavGraphBuilder.exportItemsGraph(
|
||||
navController: NavController,
|
||||
) {
|
||||
navigation<ExportItemsRoute>(
|
||||
navigation<ExportItemsGraphRoute>(
|
||||
startDestination = SelectAccountRoute,
|
||||
) {
|
||||
selectAccountDestination(
|
||||
@@ -37,17 +47,22 @@ fun NavGraphBuilder.exportItemsGraph(
|
||||
verifyPasswordDestination(
|
||||
onNavigateBack = { navController.popBackStack() },
|
||||
onPasswordVerified = {
|
||||
// TODO: [PM-26111] Navigate to confirm export screen.
|
||||
navController.navigateToReviewExport()
|
||||
},
|
||||
)
|
||||
reviewExportDestination(navController = navController)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Navigate to the export items graph.
|
||||
* Navigates to the main export items graph.
|
||||
*
|
||||
* This function provides a convenient and type-safe way to initiate the export items flow.
|
||||
*
|
||||
* @param navOptions Optional [NavOptions] to apply to this navigation action.
|
||||
*/
|
||||
fun NavController.navigateToExportItemsGraph(
|
||||
navOptions: NavOptions? = null,
|
||||
) {
|
||||
navigate(route = ExportItemsRoute, navOptions = navOptions)
|
||||
navigate(route = ExportItemsGraphRoute, navOptions = navOptions)
|
||||
}
|
||||
|
||||
+53
@@ -0,0 +1,53 @@
|
||||
@file:OmitFromCoverage
|
||||
|
||||
package com.x8bit.bitwarden.ui.vault.feature.exportitems.reviewexport
|
||||
|
||||
import androidx.navigation.NavController
|
||||
import androidx.navigation.NavGraphBuilder
|
||||
import androidx.navigation.NavOptions
|
||||
import com.bitwarden.annotation.OmitFromCoverage
|
||||
import com.bitwarden.ui.platform.base.util.composableWithPushTransitions
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
/**
|
||||
* The type-safe route for the Review Export screen.
|
||||
*
|
||||
* This route is used to navigate to the screen where the user can review the items
|
||||
* that will be exported from their vault. This screen operates on the currently
|
||||
* active user context for determining which items to review for export.
|
||||
*/
|
||||
@OmitFromCoverage
|
||||
@Serializable
|
||||
data object ReviewExportRoute
|
||||
|
||||
/**
|
||||
* Defines the destination for the Review Export screen within the navigation graph.
|
||||
*
|
||||
* This extension function on [NavGraphBuilder] sets up the [ReviewExportScreen]
|
||||
* composable for the [ReviewExportRoute], using push transitions.
|
||||
*
|
||||
* @param navController The [NavController] used for handling back navigation from the screen.
|
||||
*/
|
||||
fun NavGraphBuilder.reviewExportDestination(
|
||||
navController: NavController,
|
||||
) {
|
||||
composableWithPushTransitions<ReviewExportRoute> {
|
||||
ReviewExportScreen(
|
||||
onNavigateBack = { navController.popBackStack() },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Navigates to the Review Export screen.
|
||||
*
|
||||
* This extension function on [NavController] provides a type-safe way to navigate
|
||||
* to the [ReviewExportRoute].
|
||||
*
|
||||
* @param navOptions Optional [NavOptions] for this navigation action.
|
||||
*/
|
||||
fun NavController.navigateToReviewExport(
|
||||
navOptions: NavOptions? = null,
|
||||
) {
|
||||
navigate(route = ReviewExportRoute, navOptions = navOptions)
|
||||
}
|
||||
+328
@@ -0,0 +1,328 @@
|
||||
package com.x8bit.bitwarden.ui.vault.feature.exportitems.reviewexport
|
||||
|
||||
import android.net.Uri
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.defaultMinSize
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.navigationBarsPadding
|
||||
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.Text
|
||||
import androidx.compose.material3.TopAppBarDefaults
|
||||
import androidx.compose.material3.rememberTopAppBarState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.input.nestedscroll.nestedScroll
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.bitwarden.cxf.manager.CredentialExchangeCompletionManager
|
||||
import com.bitwarden.cxf.model.ImportCredentialsRequestData
|
||||
import com.bitwarden.cxf.ui.composition.LocalCredentialExchangeCompletionManager
|
||||
import com.bitwarden.ui.platform.base.util.EventsEffect
|
||||
import com.bitwarden.ui.platform.base.util.cardStyle
|
||||
import com.bitwarden.ui.platform.base.util.nullableTestTag
|
||||
import com.bitwarden.ui.platform.base.util.standardHorizontalMargin
|
||||
import com.bitwarden.ui.platform.components.button.BitwardenFilledButton
|
||||
import com.bitwarden.ui.platform.components.button.BitwardenOutlinedButton
|
||||
import com.bitwarden.ui.platform.components.dialog.BitwardenBasicDialog
|
||||
import com.bitwarden.ui.platform.components.dialog.BitwardenLoadingDialog
|
||||
import com.bitwarden.ui.platform.components.header.BitwardenListHeaderText
|
||||
import com.bitwarden.ui.platform.components.model.CardStyle
|
||||
import com.bitwarden.ui.platform.components.util.rememberVectorPainter
|
||||
import com.bitwarden.ui.platform.resource.BitwardenDrawable
|
||||
import com.bitwarden.ui.platform.resource.BitwardenString
|
||||
import com.bitwarden.ui.platform.theme.BitwardenTheme
|
||||
import com.bitwarden.ui.util.Text
|
||||
import com.bitwarden.ui.util.asText
|
||||
import com.x8bit.bitwarden.ui.vault.feature.exportitems.component.ExportItemsScaffold
|
||||
import com.x8bit.bitwarden.ui.vault.feature.exportitems.reviewexport.handlers.ReviewExportHandlers
|
||||
import com.x8bit.bitwarden.ui.vault.feature.exportitems.reviewexport.handlers.rememberReviewExportHandler
|
||||
|
||||
/**
|
||||
* The main composable for the Review Export screen.
|
||||
*
|
||||
* This screen allows the user to review a summary of items that will be exported
|
||||
* before confirming the operation. It displays a list of item types and their counts,
|
||||
* an illustrative image, and provides options to proceed with the export or cancel.
|
||||
* The screen adheres to the MVI pattern by observing state from [ReviewExportViewModel]
|
||||
* and dispatching actions via [ReviewExportHandlers]. Upon completion of the export operation,
|
||||
* it utilizes the [CredentialExchangeCompletionManager] to finalize the credential exchange
|
||||
* process.
|
||||
*
|
||||
* @param onNavigateBack Callback invoked when the user chooses to navigate back (e.g., via cancel
|
||||
* or back button).
|
||||
* @param viewModel The [ReviewExportViewModel] instance for this screen.
|
||||
* @param credentialExchangeCompletionManager Manager responsible for completing the credential
|
||||
* export process.
|
||||
* Defaults to the manager provided by [LocalCredentialExchangeCompletionManager].
|
||||
*/
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun ReviewExportScreen(
|
||||
onNavigateBack: () -> Unit,
|
||||
viewModel: ReviewExportViewModel = hiltViewModel(),
|
||||
credentialExchangeCompletionManager: CredentialExchangeCompletionManager =
|
||||
LocalCredentialExchangeCompletionManager.current,
|
||||
) {
|
||||
val state by viewModel.stateFlow.collectAsStateWithLifecycle()
|
||||
val handler = rememberReviewExportHandler(viewModel)
|
||||
|
||||
EventsEffect(viewModel) {
|
||||
when (it) {
|
||||
is ReviewExportEvent.NavigateBack -> onNavigateBack()
|
||||
is ReviewExportEvent.CompleteExport -> {
|
||||
credentialExchangeCompletionManager.completeCredentialExport(it.result)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ReviewExportDialogs(
|
||||
dialog = state.dialog,
|
||||
onDismiss = handler.onDismissDialog,
|
||||
)
|
||||
|
||||
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior(rememberTopAppBarState())
|
||||
ExportItemsScaffold(
|
||||
navIcon = rememberVectorPainter(BitwardenDrawable.ic_back),
|
||||
onNavigationIconClick = handler.onNavigateBackClick,
|
||||
navigationIconContentDescription = stringResource(BitwardenString.back),
|
||||
scrollBehavior = scrollBehavior,
|
||||
modifier = Modifier
|
||||
.nestedScroll(scrollBehavior.nestedScrollConnection)
|
||||
.fillMaxSize(),
|
||||
) {
|
||||
ReviewExportContent(
|
||||
state = state,
|
||||
onImportItemsClick = handler.onImportItemsClick,
|
||||
onCancelClick = handler.onCancelClick,
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.standardHorizontalMargin(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Displays dialogs based on the [ReviewExportState.DialogState].
|
||||
*
|
||||
* @param dialog The current dialog state from [ReviewExportState].
|
||||
* @param onDismiss Callback invoked when a dialog is dismissed.
|
||||
*/
|
||||
@Composable
|
||||
private fun ReviewExportDialogs(
|
||||
dialog: ReviewExportState.DialogState?,
|
||||
onDismiss: () -> Unit,
|
||||
) {
|
||||
when (dialog) {
|
||||
is ReviewExportState.DialogState.General -> {
|
||||
BitwardenBasicDialog(
|
||||
title = dialog.title(),
|
||||
message = dialog.message(),
|
||||
throwable = dialog.error,
|
||||
onDismissRequest = onDismiss,
|
||||
)
|
||||
}
|
||||
|
||||
is ReviewExportState.DialogState.Loading -> {
|
||||
BitwardenLoadingDialog(text = dialog.message())
|
||||
}
|
||||
|
||||
null -> Unit
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The main content area of the Review Export screen.
|
||||
*
|
||||
* This composable lays out the illustrative image, titles, list of items to export,
|
||||
* and action buttons.
|
||||
*
|
||||
* @param state The current [ReviewExportState] to render.
|
||||
* @param onImportItemsClick Callback invoked when the "Import Items" button is clicked.
|
||||
* @param onCancelClick Callback invoked when the "Cancel" button is clicked.
|
||||
* @param modifier The modifier to be applied to the content root.
|
||||
*/
|
||||
@Suppress("LongMethod")
|
||||
@Composable
|
||||
private fun ReviewExportContent(
|
||||
state: ReviewExportState,
|
||||
onImportItemsClick: () -> Unit,
|
||||
onCancelClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
modifier = modifier
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(top = 24.dp, bottom = 16.dp),
|
||||
) {
|
||||
Image(
|
||||
painter = painterResource(id = BitwardenDrawable.img_import_logins),
|
||||
contentDescription = null,
|
||||
modifier = Modifier.height(160.dp),
|
||||
contentScale = ContentScale.Fit,
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
|
||||
Text(
|
||||
text = stringResource(BitwardenString.import_items),
|
||||
style = BitwardenTheme.typography.titleLarge,
|
||||
textAlign = TextAlign.Center,
|
||||
color = BitwardenTheme.colorScheme.text.primary,
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
Text(
|
||||
text = stringResource(
|
||||
BitwardenString.import_passwords_passkeys_and_other_item_types_from_your_vault,
|
||||
),
|
||||
style = BitwardenTheme.typography.bodyMedium,
|
||||
textAlign = TextAlign.Center,
|
||||
color = BitwardenTheme.colorScheme.text.secondary,
|
||||
modifier = Modifier.padding(horizontal = 16.dp),
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
|
||||
BitwardenListHeaderText(
|
||||
label = stringResource(BitwardenString.items_to_import),
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(bottom = 8.dp),
|
||||
)
|
||||
|
||||
ItemCountRow(
|
||||
label = stringResource(BitwardenString.passwords).asText(),
|
||||
itemCount = state.viewState.itemTypeCounts.passwordCount,
|
||||
cardStyle = CardStyle.Top(),
|
||||
)
|
||||
ItemCountRow(
|
||||
label = stringResource(BitwardenString.passkeys).asText(),
|
||||
itemCount = state.viewState.itemTypeCounts.passkeyCount,
|
||||
cardStyle = CardStyle.Middle(),
|
||||
)
|
||||
ItemCountRow(
|
||||
label = stringResource(BitwardenString.identities).asText(),
|
||||
itemCount = state.viewState.itemTypeCounts.identityCount,
|
||||
cardStyle = CardStyle.Middle(),
|
||||
)
|
||||
ItemCountRow(
|
||||
label = stringResource(BitwardenString.cards).asText(),
|
||||
itemCount = state.viewState.itemTypeCounts.cardCount,
|
||||
cardStyle = CardStyle.Middle(),
|
||||
)
|
||||
ItemCountRow(
|
||||
label = stringResource(BitwardenString.secure_notes).asText(),
|
||||
itemCount = state.viewState.itemTypeCounts.secureNoteCount,
|
||||
cardStyle = CardStyle.Bottom,
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
|
||||
BitwardenFilledButton(
|
||||
label = stringResource(BitwardenString.import_verb),
|
||||
onClick = onImportItemsClick,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.nullableTestTag("ImportItemsButton"),
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
BitwardenOutlinedButton(
|
||||
label = stringResource(BitwardenString.cancel),
|
||||
onClick = onCancelClick,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.nullableTestTag("CancelButton"),
|
||||
)
|
||||
|
||||
Spacer(Modifier.height(12.dp))
|
||||
Spacer(Modifier.navigationBarsPadding())
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Displays a single row in the list of items to be exported.
|
||||
*
|
||||
* @param label The display name of the item type.
|
||||
* @param itemCount The number of items of this type that are staged for export.
|
||||
*/
|
||||
@Composable
|
||||
private fun ItemCountRow(
|
||||
label: Text,
|
||||
itemCount: Int,
|
||||
cardStyle: CardStyle,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Row(
|
||||
modifier = modifier
|
||||
.defaultMinSize(minHeight = 60.dp)
|
||||
.cardStyle(
|
||||
cardStyle = cardStyle,
|
||||
paddingHorizontal = 16.dp,
|
||||
),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(16.dp),
|
||||
) {
|
||||
Text(
|
||||
text = label(),
|
||||
style = BitwardenTheme.typography.bodyLarge,
|
||||
color = BitwardenTheme.colorScheme.text.primary,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
|
||||
Text(
|
||||
text = itemCount.toString(),
|
||||
style = BitwardenTheme.typography.labelSmall,
|
||||
color = BitwardenTheme.colorScheme.text.primary,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Preview(showBackground = true, name = "Review Export Content")
|
||||
@Composable
|
||||
private fun ReviewExportContent_preview() {
|
||||
BitwardenTheme {
|
||||
ReviewExportContent(
|
||||
state = ReviewExportState(
|
||||
importCredentialsRequest = ImportCredentialsRequestData(
|
||||
uri = Uri.EMPTY,
|
||||
requestJson = "",
|
||||
),
|
||||
viewState = ReviewExportState.ViewState(
|
||||
itemTypeCounts = ReviewExportState.ItemTypeCounts(
|
||||
passwordCount = 14,
|
||||
passkeyCount = 14,
|
||||
identityCount = 3,
|
||||
cardCount = 4,
|
||||
secureNoteCount = 5,
|
||||
),
|
||||
),
|
||||
),
|
||||
onImportItemsClick = {},
|
||||
onCancelClick = {},
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.standardHorizontalMargin(),
|
||||
)
|
||||
}
|
||||
}
|
||||
+400
@@ -0,0 +1,400 @@
|
||||
package com.x8bit.bitwarden.ui.vault.feature.exportitems.reviewexport
|
||||
|
||||
import android.os.Parcelable
|
||||
import androidx.compose.runtime.Stable
|
||||
import androidx.credentials.providerevents.exception.ImportCredentialsException
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.bitwarden.core.data.repository.model.DataState
|
||||
import com.bitwarden.cxf.manager.model.ExportCredentialsResult
|
||||
import com.bitwarden.cxf.model.ImportCredentialsRequestData
|
||||
import com.bitwarden.network.model.PolicyTypeJson
|
||||
import com.bitwarden.ui.platform.base.BaseViewModel
|
||||
import com.bitwarden.ui.platform.resource.BitwardenString
|
||||
import com.bitwarden.ui.util.Text
|
||||
import com.bitwarden.ui.util.asText
|
||||
import com.bitwarden.vault.CipherListView
|
||||
import com.bitwarden.vault.CipherListViewType
|
||||
import com.bitwarden.vault.DecryptCipherListResult
|
||||
import com.x8bit.bitwarden.data.auth.repository.AuthRepository
|
||||
import com.x8bit.bitwarden.data.autofill.util.card
|
||||
import com.x8bit.bitwarden.data.autofill.util.isActiveWithCopyablePassword
|
||||
import com.x8bit.bitwarden.data.autofill.util.isActiveWithFido2Credentials
|
||||
import com.x8bit.bitwarden.data.platform.manager.PolicyManager
|
||||
import com.x8bit.bitwarden.data.platform.manager.SpecialCircumstanceManager
|
||||
import com.x8bit.bitwarden.data.platform.manager.util.toImportCredentialsRequestDataOrNull
|
||||
import com.x8bit.bitwarden.data.vault.repository.VaultRepository
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.flow.launchIn
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.parcelize.Parcelize
|
||||
import javax.inject.Inject
|
||||
|
||||
/**
|
||||
* ViewModel for the Review Export screen.
|
||||
*
|
||||
* This ViewModel manages the UI state for reviewing items before an export operation.
|
||||
* It fetches cipher data from the [VaultRepository], transforms it into a summary list for review,
|
||||
* handles user actions such as initiating the export or cancelling the process.
|
||||
*/
|
||||
@Suppress("TooManyFunctions")
|
||||
@HiltViewModel
|
||||
class ReviewExportViewModel @Inject constructor(
|
||||
private val vaultRepository: VaultRepository,
|
||||
private val authRepository: AuthRepository,
|
||||
private val policyManager: PolicyManager,
|
||||
specialCircumstanceManager: SpecialCircumstanceManager,
|
||||
) : BaseViewModel<ReviewExportState, ReviewExportEvent, ReviewExportAction>(
|
||||
initialState = ReviewExportState(
|
||||
importCredentialsRequest = requireNotNull(
|
||||
specialCircumstanceManager
|
||||
.specialCircumstance
|
||||
?.toImportCredentialsRequestDataOrNull(),
|
||||
),
|
||||
viewState = ReviewExportState.ViewState(
|
||||
itemTypeCounts = ReviewExportState.ItemTypeCounts(),
|
||||
),
|
||||
),
|
||||
) {
|
||||
|
||||
init {
|
||||
vaultRepository
|
||||
.decryptCipherListResultStateFlow
|
||||
.map { ReviewExportAction.Internal.VaultDataReceive(data = it) }
|
||||
.onEach(::sendAction)
|
||||
.launchIn(viewModelScope)
|
||||
}
|
||||
|
||||
override fun handleAction(action: ReviewExportAction) {
|
||||
when (action) {
|
||||
is ReviewExportAction.ImportItemsClick -> handleImportItemsClicked()
|
||||
is ReviewExportAction.CancelClick -> handleCancelClicked()
|
||||
is ReviewExportAction.DismissDialog -> handleDismissDialog()
|
||||
is ReviewExportAction.NavigateBackClick -> handleBackClick()
|
||||
is ReviewExportAction.Internal -> handleInternalAction(action)
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleImportItemsClicked() {
|
||||
showLoadingDialog(message = BitwardenString.exporting_items.asText())
|
||||
viewModelScope.launch {
|
||||
trySendAction(
|
||||
ReviewExportAction.Internal.ExportResultReceive(
|
||||
vaultRepository
|
||||
.exportVaultDataToCxf(
|
||||
ciphers = vaultRepository
|
||||
.decryptCipherListResultStateFlow
|
||||
.value
|
||||
.data
|
||||
?.successes
|
||||
?.filter { it.deletedDate == null }
|
||||
.filterRestrictedItemsIfNecessary(),
|
||||
)
|
||||
.fold(
|
||||
onSuccess = { payload ->
|
||||
ExportCredentialsResult.Success(
|
||||
payload = payload,
|
||||
uri = state.importCredentialsRequest.uri,
|
||||
)
|
||||
},
|
||||
onFailure = { error ->
|
||||
ExportCredentialsResult.Failure(
|
||||
error = error as ImportCredentialsException,
|
||||
)
|
||||
},
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleCancelClicked() {
|
||||
sendEvent(ReviewExportEvent.NavigateBack)
|
||||
}
|
||||
|
||||
private fun handleDismissDialog() {
|
||||
mutableStateFlow.update { it.copy(dialog = null) }
|
||||
}
|
||||
|
||||
private fun handleBackClick() {
|
||||
sendEvent(ReviewExportEvent.NavigateBack)
|
||||
}
|
||||
|
||||
private fun handleInternalAction(action: ReviewExportAction.Internal) {
|
||||
when (action) {
|
||||
is ReviewExportAction.Internal.VaultDataReceive -> {
|
||||
handleVaultDataReceive(action)
|
||||
}
|
||||
|
||||
is ReviewExportAction.Internal.ExportResultReceive -> {
|
||||
handleExportResultReceive(action)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleVaultDataReceive(action: ReviewExportAction.Internal.VaultDataReceive) {
|
||||
when (val data = action.data) {
|
||||
DataState.Loading -> handleVaultDataLoading()
|
||||
|
||||
is DataState.Loaded -> handleVaultDataLoaded(data)
|
||||
|
||||
is DataState.Pending -> handleVaultDataPending(data)
|
||||
|
||||
is DataState.NoNetwork -> handleVaultDataNoNetwork(data)
|
||||
|
||||
is DataState.Error -> handleVaultDataError(data)
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleVaultDataLoading() {
|
||||
showLoadingDialog()
|
||||
}
|
||||
|
||||
private fun handleVaultDataLoaded(data: DataState.Loaded<DecryptCipherListResult>) {
|
||||
updateItemTypeCounts(data = data, clearDialog = true)
|
||||
}
|
||||
|
||||
private fun handleVaultDataPending(data: DataState.Pending<DecryptCipherListResult>) {
|
||||
showLoadingDialog()
|
||||
updateItemTypeCounts(data = data, clearDialog = false)
|
||||
}
|
||||
|
||||
private fun handleVaultDataNoNetwork(data: DataState.NoNetwork<DecryptCipherListResult>) {
|
||||
updateItemTypeCounts(data = data, clearDialog = true)
|
||||
}
|
||||
|
||||
private fun handleVaultDataError(data: DataState.Error<DecryptCipherListResult>) {
|
||||
mutableStateFlow.update {
|
||||
it.copy(
|
||||
viewState = it.viewState.copy(
|
||||
itemTypeCounts = data.data.toItemTypeCounts(),
|
||||
),
|
||||
dialog = ReviewExportState.DialogState.General(
|
||||
title = BitwardenString.an_error_has_occurred.asText(),
|
||||
message = BitwardenString.generic_error_message.asText(),
|
||||
error = data.error,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleExportResultReceive(action: ReviewExportAction.Internal.ExportResultReceive) {
|
||||
mutableStateFlow.update { it.copy(dialog = null) }
|
||||
sendEvent(
|
||||
ReviewExportEvent.CompleteExport(
|
||||
result = action.result,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
private fun showLoadingDialog(message: Text = BitwardenString.loading.asText()) {
|
||||
mutableStateFlow.update {
|
||||
it.copy(
|
||||
dialog = ReviewExportState.DialogState.Loading(
|
||||
message = message,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun List<CipherListView>?.filterRestrictedItemsIfNecessary(): List<CipherListView> {
|
||||
this ?: return emptyList()
|
||||
val activeUserOrgIds = authRepository.userStateFlow
|
||||
.value
|
||||
?.activeAccount
|
||||
?.organizations
|
||||
?.map { it.id }
|
||||
?: return this
|
||||
val itemRestrictedOrgIds = policyManager
|
||||
.getActivePolicies(PolicyTypeJson.RESTRICT_ITEM_TYPES)
|
||||
.filter { it.isEnabled }
|
||||
.map { it.organizationId }
|
||||
|
||||
return if (activeUserOrgIds.any { itemRestrictedOrgIds.contains(it) }) {
|
||||
this.filter { it.card == null }
|
||||
} else {
|
||||
this
|
||||
}
|
||||
}
|
||||
|
||||
private fun DecryptCipherListResult?.toItemTypeCounts(): ReviewExportState.ItemTypeCounts {
|
||||
var passwordItemCount = 0
|
||||
var passkeyItemCount = 0
|
||||
var identityItemCount = 0
|
||||
var cardItemCount = 0
|
||||
var secureNoteItemCount = 0
|
||||
this@toItemTypeCounts
|
||||
?.successes
|
||||
?.filter { it.deletedDate == null }
|
||||
.orEmpty()
|
||||
.forEach {
|
||||
when {
|
||||
it.isActiveWithCopyablePassword -> passwordItemCount++
|
||||
it.isActiveWithFido2Credentials -> passkeyItemCount++
|
||||
it.type is CipherListViewType.Identity -> identityItemCount++
|
||||
it.card != null -> cardItemCount++
|
||||
it.type is CipherListViewType.SecureNote -> secureNoteItemCount++
|
||||
}
|
||||
}
|
||||
|
||||
return ReviewExportState.ItemTypeCounts(
|
||||
passwordCount = passwordItemCount,
|
||||
passkeyCount = passkeyItemCount,
|
||||
identityCount = identityItemCount,
|
||||
cardCount = cardItemCount,
|
||||
secureNoteCount = secureNoteItemCount,
|
||||
)
|
||||
}
|
||||
|
||||
private fun updateItemTypeCounts(
|
||||
data: DataState<DecryptCipherListResult>,
|
||||
clearDialog: Boolean,
|
||||
) {
|
||||
mutableStateFlow.update {
|
||||
it.copy(
|
||||
viewState = it.viewState.copy(
|
||||
itemTypeCounts = data.data.toItemTypeCounts(),
|
||||
),
|
||||
dialog = it.dialog.takeUnless { clearDialog },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Represents the state of the Review Import screen.
|
||||
*
|
||||
* @property viewState The current view state containing item type counts.
|
||||
* @property dialog The current dialog state to be displayed, if any.
|
||||
*/
|
||||
@Parcelize
|
||||
data class ReviewExportState(
|
||||
val viewState: ViewState,
|
||||
val dialog: DialogState? = null,
|
||||
// Internally used properties
|
||||
val importCredentialsRequest: ImportCredentialsRequestData,
|
||||
) : Parcelable {
|
||||
|
||||
/**
|
||||
* Represents the view state with item type counts.
|
||||
*/
|
||||
@Parcelize
|
||||
data class ViewState(
|
||||
val itemTypeCounts: ItemTypeCounts,
|
||||
) : Parcelable
|
||||
|
||||
/**
|
||||
* Represents the counts of different item types to be exported.
|
||||
*/
|
||||
@Parcelize
|
||||
data class ItemTypeCounts(
|
||||
val passwordCount: Int = 0,
|
||||
val passkeyCount: Int = 0,
|
||||
val identityCount: Int = 0,
|
||||
val cardCount: Int = 0,
|
||||
val secureNoteCount: Int = 0,
|
||||
) : Parcelable
|
||||
|
||||
/**
|
||||
* Represents the possible dialog states for the Review Import screen.
|
||||
*/
|
||||
@Parcelize
|
||||
sealed class DialogState : Parcelable {
|
||||
/**
|
||||
* A generic dialog with a title, message, and optional error.
|
||||
*
|
||||
* @property title A function to retrieve the dialog title string resource.
|
||||
* @property message A function to retrieve the dialog message string resource.
|
||||
* @property error An optional throwable associated with the dialog.
|
||||
*/
|
||||
data class General(
|
||||
val title: Text,
|
||||
val message: Text,
|
||||
val error: Throwable? = null,
|
||||
) : DialogState()
|
||||
|
||||
/**
|
||||
* A dialog indicating an ongoing loading or processing state.
|
||||
*
|
||||
* @property message A function to retrieve the loading message string resource.
|
||||
*/
|
||||
data class Loading(
|
||||
val message: Text,
|
||||
) : DialogState()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Defines the actions that can be processed by the [ReviewExportViewModel].
|
||||
*/
|
||||
sealed class ReviewExportAction {
|
||||
/**
|
||||
* Action triggered when the "Import items" button is clicked by the user.
|
||||
*/
|
||||
data object ImportItemsClick : ReviewExportAction()
|
||||
|
||||
/**
|
||||
* Action triggered when the "Cancel" button is clicked by the user.
|
||||
*/
|
||||
data object CancelClick : ReviewExportAction()
|
||||
|
||||
/**
|
||||
* Action triggered when a dialog is dismissed by the user.
|
||||
*/
|
||||
data object DismissDialog : ReviewExportAction()
|
||||
|
||||
/**
|
||||
* Action triggered when the back button is clicked by the user.
|
||||
*/
|
||||
data object NavigateBackClick : ReviewExportAction()
|
||||
|
||||
/**
|
||||
* Internal actions that the [ReviewExportViewModel] itself may send.
|
||||
*/
|
||||
sealed class Internal : ReviewExportAction() {
|
||||
|
||||
/**
|
||||
* Represents a result of decrypting the cipher list.
|
||||
*/
|
||||
data class VaultDataReceive(
|
||||
val data: DataState<DecryptCipherListResult>,
|
||||
) : Internal()
|
||||
|
||||
/**
|
||||
* Represents a result of exporting the vault data.
|
||||
*/
|
||||
data class ExportResultReceive(
|
||||
val result: ExportCredentialsResult,
|
||||
) : Internal()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Defines events that the [ReviewExportViewModel] can send to the UI.
|
||||
*/
|
||||
@Stable
|
||||
sealed class ReviewExportEvent {
|
||||
/**
|
||||
* Event to navigate back to the previous screen, typically used when the user cancels
|
||||
* the import process.
|
||||
*/
|
||||
data object NavigateBack : ReviewExportEvent()
|
||||
|
||||
/**
|
||||
* Event indicating that the import attempt has completed.
|
||||
* The consuming screen or navigation controller should handle this event to proceed
|
||||
* appropriately based on the [result] of the import operation.
|
||||
*
|
||||
* The [ExportCredentialsResult] is used here generically to represent the outcome
|
||||
* of the import operation. For successful imports related to this feature,
|
||||
* the specific fields within [ExportCredentialsResult.Success] (payload, uri)
|
||||
* may not be directly relevant but the type indicates a successful outcome.
|
||||
* [ExportCredentialsResult.Failure] can be used as is.
|
||||
*
|
||||
* @property result The [ExportCredentialsResult] object containing details about the outcome of
|
||||
* the import process.
|
||||
*/
|
||||
data class CompleteExport(val result: ExportCredentialsResult) : ReviewExportEvent()
|
||||
}
|
||||
+67
@@ -0,0 +1,67 @@
|
||||
package com.x8bit.bitwarden.ui.vault.feature.exportitems.reviewexport.handlers
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import com.bitwarden.annotation.OmitFromCoverage
|
||||
import com.x8bit.bitwarden.ui.vault.feature.exportitems.reviewexport.ReviewExportAction
|
||||
import com.x8bit.bitwarden.ui.vault.feature.exportitems.reviewexport.ReviewExportViewModel
|
||||
|
||||
/**
|
||||
* A handler for the Review Export screen interactions, providing lambdas for UI events.
|
||||
*
|
||||
* @property onImportItemsClick Lambda to be invoked when the Import items button is clicked.
|
||||
* @property onCancelClick Lambda to be invoked when the "Cancel" button is clicked.
|
||||
* @property onDismissDialog Lambda to be invoked when a dialog is dismissed.
|
||||
*/
|
||||
data class ReviewExportHandlers(
|
||||
val onImportItemsClick: () -> Unit,
|
||||
val onCancelClick: () -> Unit,
|
||||
val onDismissDialog: () -> Unit,
|
||||
val onNavigateBackClick: () -> Unit,
|
||||
) {
|
||||
/**
|
||||
* Companion object for [ReviewExportHandlers].
|
||||
*/
|
||||
companion object {
|
||||
/**
|
||||
* Creates a [ReviewExportHandlers] from a [ReviewExportViewModel].
|
||||
*
|
||||
* This function abstracts the creation of the handler, directly linking UI event lambdas
|
||||
* to the ViewModel's action dispatching mechanism.
|
||||
*
|
||||
* @param viewModel The [ReviewExportViewModel] instance to which actions will be sent.
|
||||
* @return A new instance of [ReviewExportHandlers].
|
||||
*/
|
||||
fun create(viewModel: ReviewExportViewModel): ReviewExportHandlers = ReviewExportHandlers(
|
||||
onImportItemsClick = {
|
||||
viewModel.trySendAction(ReviewExportAction.ImportItemsClick)
|
||||
},
|
||||
onCancelClick = {
|
||||
viewModel.trySendAction(ReviewExportAction.CancelClick)
|
||||
},
|
||||
onDismissDialog = {
|
||||
viewModel.trySendAction(ReviewExportAction.DismissDialog)
|
||||
},
|
||||
onNavigateBackClick = {
|
||||
viewModel.trySendAction(ReviewExportAction.NavigateBackClick)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Remembers a [ReviewExportHandlers] instance for the Review Export screen.
|
||||
*
|
||||
* This composable function utilizes the [ReviewExportHandlers.create] method to construct
|
||||
* the handler and remembers it across recompositions. This ensures that the same handler instance
|
||||
* (with stable lambdas tied to the provided ViewModel) is used, optimizing performance and
|
||||
* adhering to Compose best practices.
|
||||
*
|
||||
* @param viewModel The [ReviewExportViewModel] that will process the actions.
|
||||
* @return A remembered instance of [ReviewExportHandlers].
|
||||
*/
|
||||
@Composable
|
||||
@OmitFromCoverage
|
||||
fun rememberReviewExportHandler(
|
||||
viewModel: ReviewExportViewModel,
|
||||
): ReviewExportHandlers = remember(viewModel) { ReviewExportHandlers.create(viewModel) }
|
||||
+2
-2
@@ -24,7 +24,7 @@ import com.x8bit.bitwarden.ui.tools.feature.send.addedit.ModeType
|
||||
import com.x8bit.bitwarden.ui.tools.feature.send.model.SendItemType
|
||||
import com.x8bit.bitwarden.ui.vault.feature.addedit.VaultAddEditMode
|
||||
import com.x8bit.bitwarden.ui.vault.feature.addedit.VaultAddEditRoute
|
||||
import com.x8bit.bitwarden.ui.vault.feature.exportitems.ExportItemsRoute
|
||||
import com.x8bit.bitwarden.ui.vault.feature.exportitems.ExportItemsGraphRoute
|
||||
import com.x8bit.bitwarden.ui.vault.feature.itemlisting.ItemListingType
|
||||
import com.x8bit.bitwarden.ui.vault.feature.itemlisting.VaultItemListingRoute
|
||||
import com.x8bit.bitwarden.ui.vault.model.VaultItemCipherType
|
||||
@@ -445,7 +445,7 @@ class RootNavScreenTest : BitwardenComposeTest() {
|
||||
composeTestRule.runOnIdle {
|
||||
verify {
|
||||
mockNavHostController.navigate(
|
||||
route = ExportItemsRoute,
|
||||
route = ExportItemsGraphRoute,
|
||||
navOptions = expectedNavOptions,
|
||||
)
|
||||
}
|
||||
|
||||
+148
@@ -0,0 +1,148 @@
|
||||
package com.x8bit.bitwarden.ui.vault.feature.exportitems.reviewexport
|
||||
|
||||
import android.net.Uri
|
||||
import androidx.compose.ui.test.filterToOne
|
||||
import androidx.compose.ui.test.hasAnyAncestor
|
||||
import androidx.compose.ui.test.isDialog
|
||||
import androidx.compose.ui.test.isDisplayed
|
||||
import androidx.compose.ui.test.onAllNodesWithText
|
||||
import androidx.compose.ui.test.onNodeWithContentDescription
|
||||
import androidx.compose.ui.test.performClick
|
||||
import com.bitwarden.core.data.repository.util.bufferedMutableSharedFlow
|
||||
import com.bitwarden.cxf.manager.CredentialExchangeCompletionManager
|
||||
import com.bitwarden.cxf.manager.model.ExportCredentialsResult
|
||||
import com.bitwarden.cxf.model.ImportCredentialsRequestData
|
||||
import com.bitwarden.ui.util.asText
|
||||
import com.bitwarden.ui.util.assertNoDialogExists
|
||||
import com.x8bit.bitwarden.ui.platform.base.BitwardenComposeTest
|
||||
import io.mockk.every
|
||||
import io.mockk.just
|
||||
import io.mockk.mockk
|
||||
import io.mockk.runs
|
||||
import io.mockk.verify
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
|
||||
class ReviewExportScreenTest : BitwardenComposeTest() {
|
||||
|
||||
private var onNavigateBackCalled = false
|
||||
private val credentialExchangeCompletionManager = mockk<CredentialExchangeCompletionManager> {
|
||||
every { completeCredentialExport(any()) } just runs
|
||||
}
|
||||
private val mockStateFlow = MutableStateFlow(DEFAULT_STATE)
|
||||
private val mockEventFlow = bufferedMutableSharedFlow<ReviewExportEvent>()
|
||||
private val mockViewModel = mockk<ReviewExportViewModel> {
|
||||
every { stateFlow } returns mockStateFlow
|
||||
every { eventFlow } returns mockEventFlow
|
||||
every { trySendAction(any()) } just runs
|
||||
}
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
setContent(
|
||||
credentialExchangeCompletionManager = credentialExchangeCompletionManager,
|
||||
) {
|
||||
ReviewExportScreen(
|
||||
onNavigateBack = { onNavigateBackCalled = true },
|
||||
viewModel = mockViewModel,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `NavigateBack event should call onNavigateBack`() {
|
||||
mockEventFlow.tryEmit(ReviewExportEvent.NavigateBack)
|
||||
assertTrue(onNavigateBackCalled)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `CompleteExport event should call credentialExchangeCompletionManager`() {
|
||||
val mockResult = mockk<ExportCredentialsResult>()
|
||||
mockEventFlow.tryEmit(ReviewExportEvent.CompleteExport(result = mockResult))
|
||||
verify {
|
||||
credentialExchangeCompletionManager.completeCredentialExport(mockResult)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `General dialog should display based on state`() {
|
||||
composeTestRule.assertNoDialogExists()
|
||||
|
||||
mockStateFlow.tryEmit(
|
||||
DEFAULT_STATE.copy(
|
||||
dialog = ReviewExportState.DialogState.General(
|
||||
title = "title".asText(),
|
||||
message = "message".asText(),
|
||||
error = null,
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
composeTestRule
|
||||
.onAllNodesWithText("title")
|
||||
.filterToOne(hasAnyAncestor(isDialog()))
|
||||
.isDisplayed()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `General dialog dismiss should send DismissDialog action`() {
|
||||
mockStateFlow.tryEmit(
|
||||
DEFAULT_STATE.copy(
|
||||
dialog = ReviewExportState.DialogState.General(
|
||||
title = "title".asText(),
|
||||
message = "message".asText(),
|
||||
error = null,
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
composeTestRule
|
||||
.onAllNodesWithText("Okay")
|
||||
.filterToOne(hasAnyAncestor(isDialog()))
|
||||
.performClick()
|
||||
|
||||
verify {
|
||||
mockViewModel.trySendAction(ReviewExportAction.DismissDialog)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Loading dialog should display based on state`() {
|
||||
composeTestRule.assertNoDialogExists()
|
||||
|
||||
mockStateFlow.tryEmit(
|
||||
DEFAULT_STATE.copy(
|
||||
dialog = ReviewExportState.DialogState.Loading("message".asText()),
|
||||
),
|
||||
)
|
||||
|
||||
composeTestRule
|
||||
.onAllNodesWithText("message")
|
||||
.filterToOne(hasAnyAncestor(isDialog()))
|
||||
.isDisplayed()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Back click should send NavigateBackClick action`() {
|
||||
composeTestRule
|
||||
.onNodeWithContentDescription("Back")
|
||||
.performClick()
|
||||
|
||||
verify {
|
||||
mockViewModel.trySendAction(ReviewExportAction.NavigateBackClick)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private val DEFAULT_STATE = ReviewExportState(
|
||||
viewState = ReviewExportState.ViewState(
|
||||
itemTypeCounts = ReviewExportState.ItemTypeCounts(),
|
||||
),
|
||||
importCredentialsRequest = ImportCredentialsRequestData(
|
||||
uri = Uri.EMPTY,
|
||||
requestJson = "",
|
||||
),
|
||||
dialog = null,
|
||||
)
|
||||
+408
@@ -0,0 +1,408 @@
|
||||
package com.x8bit.bitwarden.ui.vault.feature.exportitems.reviewexport
|
||||
|
||||
import android.net.Uri
|
||||
import androidx.credentials.providerevents.exception.ImportCredentialsUnknownErrorException
|
||||
import app.cash.turbine.test
|
||||
import com.bitwarden.core.data.repository.model.DataState
|
||||
import com.bitwarden.cxf.manager.model.ExportCredentialsResult
|
||||
import com.bitwarden.cxf.model.ImportCredentialsRequestData
|
||||
import com.bitwarden.data.repository.model.Environment
|
||||
import com.bitwarden.network.model.OrganizationType
|
||||
import com.bitwarden.network.model.PolicyTypeJson
|
||||
import com.bitwarden.network.model.createMockPolicy
|
||||
import com.bitwarden.ui.platform.base.BaseViewModelTest
|
||||
import com.bitwarden.ui.platform.resource.BitwardenString
|
||||
import com.bitwarden.ui.util.asText
|
||||
import com.bitwarden.vault.CipherListViewType
|
||||
import com.bitwarden.vault.DecryptCipherListResult
|
||||
import com.x8bit.bitwarden.data.auth.datasource.disk.model.OnboardingStatus
|
||||
import com.x8bit.bitwarden.data.auth.repository.AuthRepository
|
||||
import com.x8bit.bitwarden.data.auth.repository.model.Organization
|
||||
import com.x8bit.bitwarden.data.auth.repository.model.UserState
|
||||
import com.x8bit.bitwarden.data.platform.manager.PolicyManager
|
||||
import com.x8bit.bitwarden.data.platform.manager.SpecialCircumstanceManager
|
||||
import com.x8bit.bitwarden.data.platform.manager.model.FirstTimeState
|
||||
import com.x8bit.bitwarden.data.platform.manager.model.SpecialCircumstance
|
||||
import com.x8bit.bitwarden.data.vault.datasource.sdk.model.createMockCardListView
|
||||
import com.x8bit.bitwarden.data.vault.datasource.sdk.model.createMockCipherListView
|
||||
import com.x8bit.bitwarden.data.vault.datasource.sdk.model.createMockDecryptCipherListResult
|
||||
import com.x8bit.bitwarden.data.vault.repository.VaultRepository
|
||||
import io.mockk.awaits
|
||||
import io.mockk.coEvery
|
||||
import io.mockk.coVerify
|
||||
import io.mockk.every
|
||||
import io.mockk.just
|
||||
import io.mockk.mockk
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Assertions.assertTrue
|
||||
import org.junit.jupiter.api.Nested
|
||||
import org.junit.jupiter.api.Test
|
||||
|
||||
class ReviewExportViewModelTest : BaseViewModelTest() {
|
||||
private val mutableUserStateFlow = MutableStateFlow<UserState?>(DEFAULT_USER_STATE)
|
||||
private val authRepository = mockk<AuthRepository> {
|
||||
every { userStateFlow } returns mutableUserStateFlow
|
||||
}
|
||||
private val decryptCipherListResultFlow = MutableStateFlow<DataState<DecryptCipherListResult>>(
|
||||
DataState.Loaded(
|
||||
data = DecryptCipherListResult(
|
||||
successes = emptyList(),
|
||||
failures = emptyList(),
|
||||
),
|
||||
),
|
||||
)
|
||||
private val vaultRepository = mockk<VaultRepository> {
|
||||
every { decryptCipherListResultStateFlow } returns decryptCipherListResultFlow
|
||||
}
|
||||
private val specialCircumstanceManager = mockk<SpecialCircumstanceManager> {
|
||||
every {
|
||||
specialCircumstance
|
||||
} returns SpecialCircumstance.CredentialExchangeExport(
|
||||
data = DEFAULT_REQUEST_DATA,
|
||||
)
|
||||
}
|
||||
private val policyManager = mockk<PolicyManager> {
|
||||
every { getActivePolicies(PolicyTypeJson.RESTRICT_ITEM_TYPES) } returns emptyList()
|
||||
}
|
||||
|
||||
@Nested
|
||||
inner class Initialization {
|
||||
@Test
|
||||
fun `initial state is correct when SavedStateHandle is empty`() =
|
||||
runTest {
|
||||
val viewModel = createViewModel()
|
||||
assertEquals(DEFAULT_STATE, viewModel.stateFlow.value)
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
inner class Actions {
|
||||
@Suppress("MaxLineLength")
|
||||
@Test
|
||||
fun `ImportItemsClick shows loading, and calls exportVaultDataToCxf with all active items if there are no item restrictions`() =
|
||||
runTest {
|
||||
val mockActiveCipherListView = createMockCipherListView(
|
||||
number = 1,
|
||||
type = CipherListViewType.Card(
|
||||
createMockCardListView(number = 1),
|
||||
),
|
||||
)
|
||||
val mockDeletedCipherListView = createMockCipherListView(
|
||||
number = 1,
|
||||
isDeleted = true,
|
||||
)
|
||||
decryptCipherListResultFlow.tryEmit(
|
||||
DataState.Loaded(
|
||||
createMockDecryptCipherListResult(
|
||||
number = 1,
|
||||
successes = listOf(
|
||||
mockActiveCipherListView,
|
||||
mockDeletedCipherListView,
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
coEvery {
|
||||
vaultRepository.exportVaultDataToCxf(listOf(mockActiveCipherListView))
|
||||
} just awaits
|
||||
|
||||
val viewModel = createViewModel()
|
||||
viewModel.trySendAction(ReviewExportAction.ImportItemsClick)
|
||||
|
||||
// Check for loading dialog
|
||||
assertEquals(
|
||||
DEFAULT_STATE.copy(
|
||||
viewState = DEFAULT_STATE.viewState.copy(
|
||||
itemTypeCounts = DEFAULT_STATE.viewState.itemTypeCounts.copy(
|
||||
cardCount = 1,
|
||||
),
|
||||
),
|
||||
dialog = ReviewExportState.DialogState.Loading(
|
||||
BitwardenString.exporting_items.asText(),
|
||||
),
|
||||
),
|
||||
viewModel.stateFlow.value,
|
||||
)
|
||||
|
||||
coVerify {
|
||||
vaultRepository.exportVaultDataToCxf(listOf(mockActiveCipherListView))
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("MaxLineLength")
|
||||
@Test
|
||||
fun `ImportItemsClick shows loading, and calls exportVaultDataToCxf without restricted items if there are item restrictions`() =
|
||||
runTest {
|
||||
val mockCipherListView = createMockCipherListView(
|
||||
number = 1,
|
||||
type = CipherListViewType.Card(
|
||||
createMockCardListView(number = 1),
|
||||
),
|
||||
)
|
||||
every {
|
||||
policyManager.getActivePolicies(PolicyTypeJson.RESTRICT_ITEM_TYPES)
|
||||
} returns listOf(
|
||||
createMockPolicy(
|
||||
number = 1,
|
||||
type = PolicyTypeJson.RESTRICT_ITEM_TYPES,
|
||||
isEnabled = true,
|
||||
),
|
||||
)
|
||||
coEvery { vaultRepository.exportVaultDataToCxf(any()) } just awaits
|
||||
decryptCipherListResultFlow.tryEmit(
|
||||
DataState.Loaded(
|
||||
createMockDecryptCipherListResult(
|
||||
number = 1,
|
||||
successes = listOf(mockCipherListView),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
val viewModel = createViewModel()
|
||||
viewModel.trySendAction(ReviewExportAction.ImportItemsClick)
|
||||
|
||||
coVerify {
|
||||
vaultRepository.exportVaultDataToCxf(ciphers = emptyList())
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `ImportItemsClick sends Success event on successful export`() = runTest {
|
||||
val viewModel = createViewModel()
|
||||
val exportResult = ExportCredentialsResult.Success(
|
||||
payload = "payload",
|
||||
uri = MOCK_URI,
|
||||
)
|
||||
coEvery {
|
||||
vaultRepository.exportVaultDataToCxf(any())
|
||||
} returns Result.success("payload")
|
||||
|
||||
viewModel.eventFlow.test {
|
||||
viewModel.trySendAction(ReviewExportAction.ImportItemsClick)
|
||||
|
||||
val event = awaitItem() as ReviewExportEvent.CompleteExport
|
||||
assertEquals(exportResult, event.result)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `ImportItemsClick sends Failure event on export error`() = runTest {
|
||||
val viewModel = createViewModel()
|
||||
val mockException = ImportCredentialsUnknownErrorException("Export failed")
|
||||
coEvery {
|
||||
vaultRepository.exportVaultDataToCxf(any())
|
||||
} returns Result.failure(
|
||||
mockException,
|
||||
)
|
||||
|
||||
viewModel.eventFlow.test {
|
||||
viewModel.trySendAction(ReviewExportAction.ImportItemsClick)
|
||||
|
||||
val event = awaitItem() as ReviewExportEvent.CompleteExport
|
||||
assertTrue(event.result is ExportCredentialsResult.Failure)
|
||||
assertEquals(mockException, (event.result as ExportCredentialsResult.Failure).error)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `CancelClicked sends NavigateBack event`() = runTest {
|
||||
val viewModel = createViewModel()
|
||||
|
||||
viewModel.eventFlow.test {
|
||||
viewModel.trySendAction(ReviewExportAction.CancelClick)
|
||||
assertEquals(ReviewExportEvent.NavigateBack, awaitItem())
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `DismissDialog clears dialog from state`() = runTest {
|
||||
decryptCipherListResultFlow.value = DataState.Loading
|
||||
val viewModel = createViewModel()
|
||||
// Check for loading dialog
|
||||
assertEquals(
|
||||
DEFAULT_STATE.copy(
|
||||
dialog = ReviewExportState.DialogState.Loading(
|
||||
BitwardenString.loading.asText(),
|
||||
),
|
||||
),
|
||||
viewModel.stateFlow.value,
|
||||
)
|
||||
|
||||
viewModel.trySendAction(ReviewExportAction.DismissDialog)
|
||||
|
||||
assertEquals(
|
||||
DEFAULT_STATE.copy(dialog = null),
|
||||
viewModel.stateFlow.value,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `VaultDataReceive Pending should show loading dialog and update item type counts`() =
|
||||
runTest {
|
||||
val viewModel = createViewModel()
|
||||
viewModel.trySendAction(
|
||||
ReviewExportAction.Internal.VaultDataReceive(
|
||||
DataState.Pending(
|
||||
data = DecryptCipherListResult(
|
||||
successes = listOf(createMockCipherListView(number = 1)),
|
||||
failures = emptyList(),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
val expectedState = DEFAULT_STATE.copy(
|
||||
viewState = DEFAULT_STATE.viewState.copy(
|
||||
itemTypeCounts = DEFAULT_STATE.viewState.itemTypeCounts.copy(
|
||||
passwordCount = 1,
|
||||
),
|
||||
),
|
||||
dialog = ReviewExportState.DialogState.Loading(
|
||||
BitwardenString.loading.asText(),
|
||||
),
|
||||
)
|
||||
|
||||
assertEquals(expectedState, viewModel.stateFlow.value)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `VaultDataReceive NoNetwork should update item type counts and dismiss dialog`() =
|
||||
runTest {
|
||||
val viewModel = createViewModel()
|
||||
viewModel.trySendAction(
|
||||
ReviewExportAction.Internal.VaultDataReceive(
|
||||
DataState.NoNetwork(
|
||||
data = DecryptCipherListResult(
|
||||
successes = listOf(createMockCipherListView(number = 1)),
|
||||
failures = emptyList(),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
val expectedState = DEFAULT_STATE.copy(
|
||||
viewState = DEFAULT_STATE.viewState.copy(
|
||||
itemTypeCounts = DEFAULT_STATE.viewState.itemTypeCounts.copy(
|
||||
passwordCount = 1,
|
||||
),
|
||||
),
|
||||
dialog = null,
|
||||
)
|
||||
|
||||
assertEquals(expectedState, viewModel.stateFlow.value)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `VaultDataReceive Error should update item type counts and show error dialog`() =
|
||||
runTest {
|
||||
val throwable = Exception()
|
||||
val viewModel = createViewModel()
|
||||
viewModel.trySendAction(
|
||||
ReviewExportAction.Internal.VaultDataReceive(
|
||||
DataState.Error(
|
||||
data = DecryptCipherListResult(
|
||||
successes = listOf(createMockCipherListView(number = 1)),
|
||||
failures = emptyList(),
|
||||
),
|
||||
error = throwable,
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
val expectedState = DEFAULT_STATE.copy(
|
||||
viewState = DEFAULT_STATE.viewState.copy(
|
||||
itemTypeCounts = DEFAULT_STATE.viewState.itemTypeCounts.copy(
|
||||
passwordCount = 1,
|
||||
),
|
||||
),
|
||||
dialog = ReviewExportState.DialogState.General(
|
||||
title = BitwardenString.an_error_has_occurred.asText(),
|
||||
message = BitwardenString.generic_error_message.asText(),
|
||||
error = throwable,
|
||||
),
|
||||
)
|
||||
|
||||
assertEquals(expectedState, viewModel.stateFlow.value)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `ExportResultReceive should clear dialog and send CompleteExport event`() = runTest {
|
||||
val viewModel = createViewModel()
|
||||
val exportResult = ExportCredentialsResult.Success(
|
||||
payload = "",
|
||||
uri = mockk(),
|
||||
)
|
||||
viewModel.trySendAction(
|
||||
ReviewExportAction.Internal.ExportResultReceive(exportResult),
|
||||
)
|
||||
|
||||
assertEquals(
|
||||
DEFAULT_STATE,
|
||||
viewModel.stateFlow.value,
|
||||
)
|
||||
|
||||
viewModel.eventFlow.test {
|
||||
assertEquals(
|
||||
ReviewExportEvent.CompleteExport(exportResult),
|
||||
awaitItem(),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun createViewModel(): ReviewExportViewModel = ReviewExportViewModel(
|
||||
authRepository = authRepository,
|
||||
vaultRepository = vaultRepository,
|
||||
specialCircumstanceManager = specialCircumstanceManager,
|
||||
policyManager = policyManager,
|
||||
)
|
||||
}
|
||||
|
||||
private val MOCK_URI = mockk<Uri>()
|
||||
private val DEFAULT_REQUEST_DATA = ImportCredentialsRequestData(
|
||||
uri = MOCK_URI,
|
||||
requestJson = "mockRequestJson",
|
||||
)
|
||||
private val DEFAULT_STATE: ReviewExportState = ReviewExportState(
|
||||
importCredentialsRequest = DEFAULT_REQUEST_DATA,
|
||||
viewState = ReviewExportState.ViewState(
|
||||
itemTypeCounts = ReviewExportState.ItemTypeCounts(),
|
||||
),
|
||||
)
|
||||
private const val DEFAULT_USER_ID: String = "activeUserId"
|
||||
private val DEFAULT_USER_STATE = UserState(
|
||||
activeUserId = DEFAULT_USER_ID,
|
||||
accounts = listOf(
|
||||
UserState.Account(
|
||||
userId = "activeUserId",
|
||||
name = "Active User",
|
||||
email = "active@bitwarden.com",
|
||||
avatarColorHex = "#aa00aa",
|
||||
environment = Environment.Us,
|
||||
isPremium = true,
|
||||
isLoggedIn = true,
|
||||
isVaultUnlocked = true,
|
||||
needsPasswordReset = false,
|
||||
isBiometricsEnabled = false,
|
||||
organizations = listOf(
|
||||
Organization(
|
||||
id = "mockOrganizationId-1",
|
||||
name = "Organization User",
|
||||
shouldUseKeyConnector = false,
|
||||
shouldManageResetPassword = false,
|
||||
role = OrganizationType.USER,
|
||||
keyConnectorUrl = null,
|
||||
userIsClaimedByOrganization = false,
|
||||
),
|
||||
),
|
||||
needsMasterPassword = false,
|
||||
trustedDevice = null,
|
||||
hasMasterPassword = true,
|
||||
isUsingKeyConnector = false,
|
||||
onboardingStatus = OnboardingStatus.COMPLETE,
|
||||
firstTimeState = FirstTimeState(showImportLoginsCard = true),
|
||||
),
|
||||
),
|
||||
)
|
||||
+1
-1
@@ -13,5 +13,5 @@ import com.bitwarden.cxf.manager.CredentialExchangeCompletionManager
|
||||
@Suppress("MaxLineLength")
|
||||
val LocalCredentialExchangeCompletionManager: ProvidableCompositionLocal<CredentialExchangeCompletionManager> =
|
||||
compositionLocalOf {
|
||||
error("CompositionLocal LocalPermissionsManager not present")
|
||||
error("CompositionLocal LocalCredentialExchangeCompletionManager not present")
|
||||
}
|
||||
|
||||
@@ -1123,4 +1123,11 @@ Do you want to switch to this account?</string>
|
||||
<string name="verify_your_master_password">Verify your master password</string>
|
||||
<string name="having_trouble_with_autofill">Having trouble with autofill?</string>
|
||||
<string name="access_help_and_troubleshooting_documentation_here">Access help and troubleshooting documentation here</string>
|
||||
<string name="import_passwords_passkeys_and_other_item_types_from_your_vault">Import passwords, passkeys, and other item types from your Bitwarden vault.</string>
|
||||
<string name="items_to_import">Items to import</string>
|
||||
<string name="exporting_items">Exporting items…</string>
|
||||
<string name="export_failed">Export failed</string>
|
||||
<string name="passwords">Passwords</string>
|
||||
<string name="passkeys">Passkeys</string>
|
||||
<string name="import_verb">Import</string>
|
||||
</resources>
|
||||
|
||||
Reference in New Issue
Block a user