[PM-26111] Implement Review Export Screen and Navigation (#5946)

This commit is contained in:
Patrick Honkonen
2025-09-29 21:12:09 +00:00
committed by GitHub
parent fec6479f6a
commit 81245cf3e5
11 changed files with 1438 additions and 12 deletions
@@ -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
@@ -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)
}
@@ -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)
}
@@ -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(),
)
}
}
@@ -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()
}
@@ -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) }
@@ -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,
)
}
@@ -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,
)
@@ -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),
),
),
)
@@ -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")
}
+7
View File
@@ -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>