From 81245cf3e58ec72c3dac522b1ae2861acfeef11c Mon Sep 17 00:00:00 2001 From: Patrick Honkonen <1883101+SaintPatrck@users.noreply.github.com> Date: Mon, 29 Sep 2025 17:12:09 -0400 Subject: [PATCH] [PM-26111] Implement Review Export Screen and Navigation (#5946) --- .../platform/feature/rootnav/RootNavScreen.kt | 4 +- .../exportitems/ExportItemsNavigation.kt | 29 +- .../reviewexport/ReviewExportNavigation.kt | 53 +++ .../reviewexport/ReviewExportScreen.kt | 328 ++++++++++++++ .../reviewexport/ReviewExportViewModel.kt | 400 +++++++++++++++++ .../handlers/ReviewExportHandlers.kt | 67 +++ .../feature/rootnav/RootNavScreenTest.kt | 4 +- .../reviewexport/ReviewExportScreenTest.kt | 148 +++++++ .../reviewexport/ReviewExportViewModelTest.kt | 408 ++++++++++++++++++ ...entialExchangeCompletionManagerProvider.kt | 2 +- ui/src/main/res/values/strings.xml | 7 + 11 files changed, 1438 insertions(+), 12 deletions(-) create mode 100644 app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/exportitems/reviewexport/ReviewExportNavigation.kt create mode 100644 app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/exportitems/reviewexport/ReviewExportScreen.kt create mode 100644 app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/exportitems/reviewexport/ReviewExportViewModel.kt create mode 100644 app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/exportitems/reviewexport/handlers/ReviewExportHandlers.kt create mode 100644 app/src/test/kotlin/com/x8bit/bitwarden/ui/vault/feature/exportitems/reviewexport/ReviewExportScreenTest.kt create mode 100644 app/src/test/kotlin/com/x8bit/bitwarden/ui/vault/feature/exportitems/reviewexport/ReviewExportViewModelTest.kt diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/feature/rootnav/RootNavScreen.kt b/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/feature/rootnav/RootNavScreen.kt index 34b62e0b4a..7bdc8de4a8 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/feature/rootnav/RootNavScreen.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/feature/rootnav/RootNavScreen.kt @@ -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 diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/exportitems/ExportItemsNavigation.kt b/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/exportitems/ExportItemsNavigation.kt index 7aec2f0150..82f03b7094 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/exportitems/ExportItemsNavigation.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/exportitems/ExportItemsNavigation.kt @@ -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( + navigation( 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) } diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/exportitems/reviewexport/ReviewExportNavigation.kt b/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/exportitems/reviewexport/ReviewExportNavigation.kt new file mode 100644 index 0000000000..e6643b1b6e --- /dev/null +++ b/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/exportitems/reviewexport/ReviewExportNavigation.kt @@ -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 { + 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) +} diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/exportitems/reviewexport/ReviewExportScreen.kt b/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/exportitems/reviewexport/ReviewExportScreen.kt new file mode 100644 index 0000000000..d31fe6287d --- /dev/null +++ b/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/exportitems/reviewexport/ReviewExportScreen.kt @@ -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(), + ) + } +} diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/exportitems/reviewexport/ReviewExportViewModel.kt b/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/exportitems/reviewexport/ReviewExportViewModel.kt new file mode 100644 index 0000000000..5eb19dfb18 --- /dev/null +++ b/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/exportitems/reviewexport/ReviewExportViewModel.kt @@ -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( + 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) { + updateItemTypeCounts(data = data, clearDialog = true) + } + + private fun handleVaultDataPending(data: DataState.Pending) { + showLoadingDialog() + updateItemTypeCounts(data = data, clearDialog = false) + } + + private fun handleVaultDataNoNetwork(data: DataState.NoNetwork) { + updateItemTypeCounts(data = data, clearDialog = true) + } + + private fun handleVaultDataError(data: DataState.Error) { + 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?.filterRestrictedItemsIfNecessary(): List { + 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, + 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, + ) : 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() +} diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/exportitems/reviewexport/handlers/ReviewExportHandlers.kt b/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/exportitems/reviewexport/handlers/ReviewExportHandlers.kt new file mode 100644 index 0000000000..fb9841bc33 --- /dev/null +++ b/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/exportitems/reviewexport/handlers/ReviewExportHandlers.kt @@ -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) } diff --git a/app/src/test/kotlin/com/x8bit/bitwarden/ui/platform/feature/rootnav/RootNavScreenTest.kt b/app/src/test/kotlin/com/x8bit/bitwarden/ui/platform/feature/rootnav/RootNavScreenTest.kt index d99f00f071..7557c3becb 100644 --- a/app/src/test/kotlin/com/x8bit/bitwarden/ui/platform/feature/rootnav/RootNavScreenTest.kt +++ b/app/src/test/kotlin/com/x8bit/bitwarden/ui/platform/feature/rootnav/RootNavScreenTest.kt @@ -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, ) } diff --git a/app/src/test/kotlin/com/x8bit/bitwarden/ui/vault/feature/exportitems/reviewexport/ReviewExportScreenTest.kt b/app/src/test/kotlin/com/x8bit/bitwarden/ui/vault/feature/exportitems/reviewexport/ReviewExportScreenTest.kt new file mode 100644 index 0000000000..e64f1e6ecb --- /dev/null +++ b/app/src/test/kotlin/com/x8bit/bitwarden/ui/vault/feature/exportitems/reviewexport/ReviewExportScreenTest.kt @@ -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 { + every { completeCredentialExport(any()) } just runs + } + private val mockStateFlow = MutableStateFlow(DEFAULT_STATE) + private val mockEventFlow = bufferedMutableSharedFlow() + private val mockViewModel = mockk { + 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() + 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, +) diff --git a/app/src/test/kotlin/com/x8bit/bitwarden/ui/vault/feature/exportitems/reviewexport/ReviewExportViewModelTest.kt b/app/src/test/kotlin/com/x8bit/bitwarden/ui/vault/feature/exportitems/reviewexport/ReviewExportViewModelTest.kt new file mode 100644 index 0000000000..f1ffda1d9a --- /dev/null +++ b/app/src/test/kotlin/com/x8bit/bitwarden/ui/vault/feature/exportitems/reviewexport/ReviewExportViewModelTest.kt @@ -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(DEFAULT_USER_STATE) + private val authRepository = mockk { + every { userStateFlow } returns mutableUserStateFlow + } + private val decryptCipherListResultFlow = MutableStateFlow>( + DataState.Loaded( + data = DecryptCipherListResult( + successes = emptyList(), + failures = emptyList(), + ), + ), + ) + private val vaultRepository = mockk { + every { decryptCipherListResultStateFlow } returns decryptCipherListResultFlow + } + private val specialCircumstanceManager = mockk { + every { + specialCircumstance + } returns SpecialCircumstance.CredentialExchangeExport( + data = DEFAULT_REQUEST_DATA, + ) + } + private val policyManager = mockk { + 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() +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), + ), + ), +) diff --git a/cxf/src/main/kotlin/com/bitwarden/cxf/ui/composition/LocalCredentialExchangeCompletionManagerProvider.kt b/cxf/src/main/kotlin/com/bitwarden/cxf/ui/composition/LocalCredentialExchangeCompletionManagerProvider.kt index a230301e9b..975a9a9940 100644 --- a/cxf/src/main/kotlin/com/bitwarden/cxf/ui/composition/LocalCredentialExchangeCompletionManagerProvider.kt +++ b/cxf/src/main/kotlin/com/bitwarden/cxf/ui/composition/LocalCredentialExchangeCompletionManagerProvider.kt @@ -13,5 +13,5 @@ import com.bitwarden.cxf.manager.CredentialExchangeCompletionManager @Suppress("MaxLineLength") val LocalCredentialExchangeCompletionManager: ProvidableCompositionLocal = compositionLocalOf { - error("CompositionLocal LocalPermissionsManager not present") + error("CompositionLocal LocalCredentialExchangeCompletionManager not present") } diff --git a/ui/src/main/res/values/strings.xml b/ui/src/main/res/values/strings.xml index cb496cd387..63e117e76a 100644 --- a/ui/src/main/res/values/strings.xml +++ b/ui/src/main/res/values/strings.xml @@ -1123,4 +1123,11 @@ Do you want to switch to this account? Verify your master password Having trouble with autofill? Access help and troubleshooting documentation here + Import passwords, passkeys, and other item types from your Bitwarden vault. + Items to import + Exporting items… + Export failed + Passwords + Passkeys + Import