[PM-25824] Add "Import items" screen (#5906)

This commit is contained in:
Patrick Honkonen 2025-09-19 09:59:26 -04:00 committed by GitHub
parent f4569cef2b
commit b4a31764c4
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
17 changed files with 783 additions and 223 deletions

View File

@ -33,29 +33,31 @@ class CredentialExchangeImportManagerImpl(
// appropriate result.
return ImportCxfPayloadResult.NoItems
}
ciphersService.importCiphers(
request = ImportCiphersJsonRequest(
ciphers = cipherList.map {
it.toEncryptedNetworkCipher(
encryptedFor = userId,
)
},
folders = emptyList(),
folderRelationships = emptyList(),
),
)
}
.flatMap { importCiphersResponseJson ->
when (importCiphersResponseJson) {
is ImportCiphersResponseJson.Invalid -> {
ImportCredentialsUnknownErrorException().asFailure()
}
ciphersService
.importCiphers(
request = ImportCiphersJsonRequest(
ciphers = cipherList.map {
it.toEncryptedNetworkCipher(
encryptedFor = userId,
)
},
folders = emptyList(),
folderRelationships = emptyList(),
),
)
.flatMap { importCiphersResponseJson ->
when (importCiphersResponseJson) {
is ImportCiphersResponseJson.Invalid -> {
ImportCredentialsUnknownErrorException().asFailure()
}
ImportCiphersResponseJson.Success -> {
ImportCxfPayloadResult.Success
.asSuccess()
ImportCiphersResponseJson.Success -> {
ImportCxfPayloadResult
.Success(itemCount = cipherList.size)
.asSuccess()
}
}
}
}
}
.fold(
onSuccess = { it },

View File

@ -8,7 +8,7 @@ sealed class ImportCxfPayloadResult {
/**
* The vault data has been successfully imported.
*/
data object Success : ImportCxfPayloadResult()
data class Success(val itemCount: Int) : ImportCxfPayloadResult()
/**
* There are no items to import.

View File

@ -800,14 +800,14 @@ class VaultRepositoryImpl(
ImportCredentialsResult.NoItems
}
ImportCxfPayloadResult.Success -> {
is ImportCxfPayloadResult.Success -> {
when (val syncResult = syncInternal(userId = userId, forced = true)) {
is SyncVaultDataResult.Error -> {
ImportCredentialsResult.SyncFailed(error = syncResult.throwable)
}
is SyncVaultDataResult.Success -> {
ImportCredentialsResult.Success
ImportCredentialsResult.Success(itemCount = importResult.itemCount)
}
}
}

View File

@ -8,7 +8,7 @@ sealed class ImportCredentialsResult {
/**
* Indicates the vault data has been successfully imported.
*/
data object Success : ImportCredentialsResult()
data class Success(val itemCount: Int) : ImportCredentialsResult()
/**
* Indicates there are no items to import.

View File

@ -13,6 +13,9 @@ import androidx.compose.runtime.compositionLocalOf
import com.bitwarden.annotation.OmitFromCoverage
import com.bitwarden.core.data.manager.BuildInfoManager
import com.bitwarden.core.util.isBuildVersionAtLeast
import com.bitwarden.cxf.importer.CredentialExchangeImporter
import com.bitwarden.cxf.importer.dsl.credentialExchangeImporter
import com.bitwarden.cxf.ui.composition.LocalCredentialExchangeImporter
import com.bitwarden.ui.platform.composition.LocalIntentManager
import com.bitwarden.ui.platform.manager.IntentManager
import com.x8bit.bitwarden.data.platform.manager.util.AppResumeStateManager
@ -59,6 +62,8 @@ fun LocalManagerProvider(
keyChainManager: KeyChainManager = KeyChainManagerImpl(activity = activity),
nfcManager: NfcManager = NfcManagerImpl(activity = activity),
permissionsManager: PermissionsManager = PermissionsManagerImpl(activity = activity),
credentialExchangeImporter: CredentialExchangeImporter =
credentialExchangeImporter(activity = activity),
content: @Composable () -> Unit,
) {
CompositionLocalProvider(
@ -73,6 +78,7 @@ fun LocalManagerProvider(
LocalKeyChainManager provides keyChainManager,
LocalNfcManager provides nfcManager,
LocalPermissionsManager provides permissionsManager,
LocalCredentialExchangeImporter provides credentialExchangeImporter,
content = content,
)
}

View File

@ -21,4 +21,5 @@ enum class SnackbarRelay {
LOGINS_IMPORTED,
SEND_DELETED,
SEND_UPDATED,
VAULT_SYNC_FAILED,
}

View File

@ -0,0 +1,210 @@
package com.x8bit.bitwarden.ui.vault.feature.importitems
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.material3.rememberTopAppBarState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Modifier
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.res.stringResource
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.importer.CredentialExchangeImporter
import com.bitwarden.cxf.ui.composition.LocalCredentialExchangeImporter
import com.bitwarden.ui.platform.base.util.EventsEffect
import com.bitwarden.ui.platform.base.util.standardHorizontalMargin
import com.bitwarden.ui.platform.components.appbar.BitwardenTopAppBar
import com.bitwarden.ui.platform.components.appbar.NavigationIcon
import com.bitwarden.ui.platform.components.dialog.BitwardenBasicDialog
import com.bitwarden.ui.platform.components.dialog.BitwardenLoadingDialog
import com.bitwarden.ui.platform.components.model.CardStyle
import com.bitwarden.ui.platform.components.row.BitwardenTextRow
import com.bitwarden.ui.platform.components.scaffold.BitwardenScaffold
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.asText
import com.x8bit.bitwarden.ui.vault.feature.importitems.ImportItemsAction.ImportCredentialSelectionReceive
import com.x8bit.bitwarden.ui.vault.feature.importitems.handlers.rememberImportItemsHandler
import kotlinx.coroutines.launch
/**
* Top level component for the import items screen.
*/
@Suppress("LongMethod")
@Composable
fun ImportItemsScreen(
onNavigateBack: () -> Unit,
onNavigateToVault: () -> Unit,
onNavigateToImportFromComputer: () -> Unit,
viewModel: ImportItemsViewModel = hiltViewModel(),
credentialExchangeImporter: CredentialExchangeImporter =
LocalCredentialExchangeImporter.current,
) {
val state by viewModel.stateFlow.collectAsStateWithLifecycle()
val coroutineScope = rememberCoroutineScope()
val handler = rememberImportItemsHandler(viewModel = viewModel)
EventsEffect(viewModel) { event ->
when (event) {
ImportItemsEvent.NavigateBack -> onNavigateBack()
ImportItemsEvent.NavigateToVault -> onNavigateToVault()
ImportItemsEvent.NavigateToImportFromComputer -> onNavigateToImportFromComputer()
is ImportItemsEvent.ShowRegisteredImportSources -> {
coroutineScope.launch {
viewModel.trySendAction(
action = ImportCredentialSelectionReceive(
selectionResult = credentialExchangeImporter
.importCredentials(
credentialTypes = event.credentialTypes,
),
),
)
}
}
}
}
ImportItemsDialogs(
dialog = state.dialog,
onDismissDialog = handler.onDismissDialog,
)
ImportItemsScaffold(
onNavigateBack = handler.onNavigateBack,
onImportFromComputerClick = handler.onImportFromComputerClick,
onImportFromAnotherAppClick = handler.onImportFromAnotherAppClick,
)
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun ImportItemsScaffold(
onNavigateBack: () -> Unit,
onImportFromComputerClick: () -> Unit,
onImportFromAnotherAppClick: () -> Unit,
modifier: Modifier = Modifier,
) {
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior(rememberTopAppBarState())
BitwardenScaffold(
modifier = modifier
.fillMaxSize()
.nestedScroll(scrollBehavior.nestedScrollConnection),
topBar = {
BitwardenTopAppBar(
title = stringResource(BitwardenString.import_items),
navigationIcon = NavigationIcon(
navigationIcon = rememberVectorPainter(BitwardenDrawable.ic_back),
onNavigationIconClick = onNavigateBack,
navigationIconContentDescription = stringResource(BitwardenString.back),
),
scrollBehavior = scrollBehavior,
)
},
) {
ImportItemsContent(
onImportFromComputerClick = onImportFromComputerClick,
onImportFromAnotherAppClick = onImportFromAnotherAppClick,
modifier = Modifier
.fillMaxSize()
.standardHorizontalMargin(),
)
}
}
@Composable
private fun ImportItemsContent(
onImportFromComputerClick: () -> Unit,
onImportFromAnotherAppClick: () -> Unit,
modifier: Modifier = Modifier,
) {
LazyColumn(
modifier = modifier
.fillMaxSize(),
) {
item { Spacer(Modifier.height(12.dp)) }
item {
BitwardenTextRow(
text = stringResource(BitwardenString.import_from_computer),
onClick = onImportFromComputerClick,
cardStyle = CardStyle.Top(),
modifier = Modifier.fillMaxWidth(),
)
}
item {
BitwardenTextRow(
text = stringResource(BitwardenString.import_from_another_app),
onClick = onImportFromAnotherAppClick,
cardStyle = CardStyle.Bottom,
modifier = Modifier.fillMaxWidth(),
)
}
item { Spacer(Modifier.height(16.dp)) }
item { Spacer(Modifier.navigationBarsPadding()) }
}
}
@Composable
private fun ImportItemsDialogs(
dialog: ImportItemsState.DialogState?,
onDismissDialog: () -> Unit,
) {
when (dialog) {
is ImportItemsState.DialogState.General -> {
BitwardenBasicDialog(
title = dialog.title(),
message = dialog.message(),
onDismissRequest = onDismissDialog,
throwable = dialog.throwable,
)
}
is ImportItemsState.DialogState.Loading -> {
BitwardenLoadingDialog(text = dialog.message())
}
null -> Unit
}
}
//region Previews
@Preview(showBackground = true, name = "Initial state")
@Composable
private fun ImportItemsContent_preview() {
BitwardenTheme {
ImportItemsScaffold(
onNavigateBack = {},
onImportFromComputerClick = {},
onImportFromAnotherAppClick = {},
)
}
}
@Preview(showBackground = true, name = "Loading dialog")
@Composable
private fun ImportItemsDialogs_loading_preview() {
BitwardenTheme {
BitwardenScaffold {
ImportItemsDialogs(
dialog = ImportItemsState.DialogState.Loading("Decoding items...".asText()),
onDismissDialog = {},
)
}
}
}
//endregion Previews

View File

@ -7,13 +7,16 @@ import androidx.lifecycle.viewModelScope
import com.bitwarden.cxf.importer.model.ImportCredentialsSelectionResult
import com.bitwarden.ui.platform.base.BackgroundEvent
import com.bitwarden.ui.platform.base.BaseViewModel
import com.bitwarden.ui.platform.components.icon.model.IconData
import com.bitwarden.ui.platform.resource.BitwardenDrawable
import com.bitwarden.ui.platform.components.snackbar.model.BitwardenSnackbarData
import com.bitwarden.ui.platform.resource.BitwardenPlurals
import com.bitwarden.ui.platform.resource.BitwardenString
import com.bitwarden.ui.util.Text
import com.bitwarden.ui.util.asPluralsText
import com.bitwarden.ui.util.asText
import com.x8bit.bitwarden.data.vault.repository.VaultRepository
import com.x8bit.bitwarden.data.vault.repository.model.ImportCredentialsResult
import com.x8bit.bitwarden.ui.platform.manager.snackbar.SnackbarRelay
import com.x8bit.bitwarden.ui.platform.manager.snackbar.SnackbarRelayManager
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
@ -25,14 +28,14 @@ private const val KEY_STATE = "state"
/**
* View model for the [ImportItemsScreen].
*/
@Suppress("TooManyFunctions")
@HiltViewModel
class ImportItemsViewModel @Inject constructor(
savedStateHandle: SavedStateHandle,
private val vaultRepository: VaultRepository,
private val snackbarRelayManager: SnackbarRelayManager,
) : BaseViewModel<ImportItemsState, ImportItemsEvent, ImportItemsAction>(
initialState = savedStateHandle[KEY_STATE] ?: ImportItemsState(
viewState = ImportItemsState.ViewState.NotStarted,
),
initialState = savedStateHandle[KEY_STATE] ?: ImportItemsState(),
) {
override fun handleAction(action: ImportItemsAction) {
@ -41,8 +44,8 @@ class ImportItemsViewModel @Inject constructor(
handleBackClick()
}
is ImportItemsAction.GetStartedClick -> {
handleGetStartedClick()
is ImportItemsAction.ImportFromAnotherAppClick -> {
handleImportFromAnotherAppClick()
}
is ImportItemsAction.ImportCredentialSelectionReceive -> {
@ -56,6 +59,14 @@ class ImportItemsViewModel @Inject constructor(
is ImportItemsAction.Internal.ImportCredentialsResultReceive -> {
handleImportCredentialsResultReceive(action)
}
ImportItemsAction.ImportFromComputerClick -> {
handleImportFromComputerClick()
}
ImportItemsAction.DismissDialog -> {
handleDismissDialog()
}
}
}
@ -67,10 +78,7 @@ class ImportItemsViewModel @Inject constructor(
sendEvent(ImportItemsEvent.NavigateBack)
}
private fun handleGetStartedClick() {
mutableStateFlow.update {
it.copy(viewState = ImportItemsState.ViewState.AwaitingSelection)
}
private fun handleImportFromAnotherAppClick() {
sendEvent(
ImportItemsEvent.ShowRegisteredImportSources(
credentialTypes = listOf(
@ -85,41 +93,37 @@ class ImportItemsViewModel @Inject constructor(
)
}
private fun handleImportFromComputerClick() {
sendEvent(ImportItemsEvent.NavigateToImportFromComputer)
}
private fun handleImportCredentialSelectionReceive(
action: ImportItemsAction.ImportCredentialSelectionReceive,
) {
when (action.selectionResult) {
when (val result = action.selectionResult) {
ImportCredentialsSelectionResult.Cancelled -> {
mutableStateFlow.update {
it.copy(
viewState = ImportItemsState.ViewState.Completed(
title = BitwardenString.import_cancelled.asText(),
message = BitwardenString.credential_import_was_cancelled.asText(),
iconData = IconData.Local(BitwardenDrawable.ic_warning),
),
)
}
showGeneralDialog(
title = BitwardenString.import_cancelled.asText(),
message = BitwardenString.import_was_cancelled_in_the_selected_app.asText(),
throwable = null,
)
}
is ImportCredentialsSelectionResult.Failure -> {
mutableStateFlow.update {
it.copy(
viewState = ImportItemsState.ViewState.Completed(
title = BitwardenString.import_vault_failure.asText(),
message = BitwardenString.generic_error_message.asText(),
iconData = IconData.Local(BitwardenDrawable.ic_warning),
),
)
}
showGeneralDialog(
title = BitwardenString.unable_to_import_your_items.asText(),
message = BitwardenString.there_was_a_problem_importing_your_items.asText(),
throwable = result.error,
)
}
is ImportCredentialsSelectionResult.Success -> {
updateImportProgress(BitwardenString.import_items.asText())
updateImportProgress(BitwardenString.saving_items.asText())
viewModelScope.launch {
sendAction(
ImportItemsAction.Internal.ImportCredentialsResultReceive(
vaultRepository.importCxfPayload(
payload = action.selectionResult.response,
payload = result.response,
),
),
)
@ -128,75 +132,87 @@ class ImportItemsViewModel @Inject constructor(
}
}
private fun handleDismissDialog() {
clearDialogs()
}
private fun handleImportCredentialsResultReceive(
action: ImportItemsAction.Internal.ImportCredentialsResultReceive,
) {
updateImportProgress(BitwardenString.uploading_items.asText())
when (action.result) {
is ImportCredentialsResult.Error -> {
mutableStateFlow.update {
it.copy(
viewState = ImportItemsState.ViewState.Completed(
title = BitwardenString.import_error.asText(),
message = BitwardenString
.there_was_a_problem_importing_your_items
.asText(),
iconData = IconData.Local(BitwardenDrawable.ic_warning),
),
)
}
showGeneralDialog(
title = BitwardenString.unable_to_import_your_items.asText(),
message = BitwardenString.there_was_a_problem_importing_your_items.asText(),
throwable = action.result.error,
)
}
is ImportCredentialsResult.Success -> {
mutableStateFlow.update {
it.copy(
viewState = ImportItemsState.ViewState.Completed(
title = BitwardenString.import_success.asText(),
message = BitwardenString
.your_items_have_been_successfully_imported
.asText(),
iconData = IconData.Local(BitwardenDrawable.ic_plain_checkmark),
),
)
}
clearDialogs()
snackbarRelayManager.sendSnackbarData(
data = BitwardenSnackbarData(
messageHeader = BitwardenString.import_successful.asText(),
message = BitwardenPlurals
.x_items_have_been_imported_to_your_vault
.asPluralsText(
quantity = action.result.itemCount,
args = arrayOf(action.result.itemCount),
),
),
relay = SnackbarRelay.LOGINS_IMPORTED,
)
sendEvent(ImportItemsEvent.NavigateToVault)
}
ImportCredentialsResult.NoItems -> {
mutableStateFlow.update {
it.copy(
viewState = ImportItemsState.ViewState.Completed(
title = BitwardenString.no_items_imported.asText(),
message = BitwardenString
.no_items_received_from_the_selected_credential_manager
.asText(),
iconData = IconData.Local(BitwardenDrawable.ic_plain_checkmark),
),
)
}
showGeneralDialog(
title = BitwardenString.no_items_imported.asText(),
message = BitwardenString.no_items_received_from_the_selected_app.asText(),
throwable = null,
)
}
is ImportCredentialsResult.SyncFailed -> {
mutableStateFlow.update {
it.copy(
viewState = ImportItemsState.ViewState.Completed(
title = BitwardenString.vault_sync_failed.asText(),
message = BitwardenString
.your_items_have_been_successfully_imported_but_could_not_sync_vault
.asText(),
iconData = IconData.Local(BitwardenDrawable.ic_warning),
),
)
}
clearDialogs()
snackbarRelayManager.sendSnackbarData(
data = BitwardenSnackbarData(
messageHeader = BitwardenString.vault_sync_failed.asText(),
message = BitwardenString
.your_items_have_been_successfully_imported_but_could_not_sync_vault
.asText(),
actionLabel = BitwardenString.try_again.asText(),
),
relay = SnackbarRelay.VAULT_SYNC_FAILED,
)
}
}
}
private fun clearDialogs() {
mutableStateFlow.update { it.copy(dialog = null) }
}
private fun showGeneralDialog(
title: Text,
message: Text,
throwable: Throwable?,
) {
mutableStateFlow.update {
it.copy(
dialog = ImportItemsState.DialogState.General(
title = title,
message = message,
throwable = throwable,
),
)
}
}
private fun updateImportProgress(message: Text) {
mutableStateFlow.update {
it.copy(
viewState = ImportItemsState.ViewState.ImportingItems(
message = message,
),
dialog = ImportItemsState.DialogState.Loading(message = message),
)
}
}
@ -207,38 +223,28 @@ class ImportItemsViewModel @Inject constructor(
*/
@Parcelize
data class ImportItemsState(
val viewState: ViewState,
val dialog: DialogState? = null,
) : Parcelable {
/**
* View states for the [ImportItemsScreen].
* Dialog state for the [ImportItemsScreen].
*/
@Parcelize
sealed class ViewState : Parcelable {
sealed class DialogState : Parcelable {
/**
* The import has not yet started.
* Show the loading dialog.
*/
data object NotStarted : ViewState()
data class Loading(val message: Text) : DialogState()
/**
* The import has started and is awaiting selection.
* Show a general dialog with the given title and message.
*/
data object AwaitingSelection : ViewState()
/**
* The import is in progress.
*/
data class ImportingItems(val message: Text) : ViewState()
/**
* The import has completed.
*/
data class Completed(
data class General(
val title: Text,
val message: Text,
val iconData: IconData,
) : ViewState()
val throwable: Throwable?,
) : DialogState()
}
}
@ -248,9 +254,14 @@ data class ImportItemsState(
sealed class ImportItemsAction {
/**
* User clicked the Get started button.
* User clicked the Import from computer option.
*/
data object GetStartedClick : ImportItemsAction()
data object ImportFromComputerClick : ImportItemsAction()
/**
* User clicked the Import from another app option.
*/
data object ImportFromAnotherAppClick : ImportItemsAction()
/**
* Result of credential selection from the selected credential manager.
@ -271,6 +282,11 @@ sealed class ImportItemsAction {
*/
data object BackClick : ImportItemsAction()
/**
* User dismissed the dialog.
*/
data object DismissDialog : ImportItemsAction()
/**
* Internal actions that the [ImportItemsViewModel] may itself send.
*/
@ -292,6 +308,11 @@ sealed class ImportItemsEvent {
*/
data object NavigateBack : ImportItemsEvent()
/**
* Navigate to the import from computer screen.
*/
data object NavigateToImportFromComputer : ImportItemsEvent()
/**
* Navigate to the vault.
*/

View File

@ -0,0 +1,51 @@
@file:OmitFromCoverage
package com.x8bit.bitwarden.ui.vault.feature.importitems.handlers
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import com.bitwarden.annotation.OmitFromCoverage
import com.x8bit.bitwarden.ui.vault.feature.importitems.ImportItemsAction
import com.x8bit.bitwarden.ui.vault.feature.importitems.ImportItemsScreen
import com.x8bit.bitwarden.ui.vault.feature.importitems.ImportItemsViewModel
/**
* Action handlers for the [ImportItemsScreen].
*/
@OmitFromCoverage
data class ImportItemsHandler(
val onNavigateBack: () -> Unit,
val onDismissDialog: () -> Unit,
val onImportFromAnotherAppClick: () -> Unit,
val onImportFromComputerClick: () -> Unit,
) {
@Suppress("UndocumentedPublicClass")
companion object {
/**
* Creates an instance of [ImportItemsHandler] using the provided [ImportItemsViewModel].
*/
fun create(viewModel: ImportItemsViewModel) = ImportItemsHandler(
onNavigateBack = {
viewModel.trySendAction(ImportItemsAction.BackClick)
},
onImportFromAnotherAppClick = {
viewModel.trySendAction(ImportItemsAction.ImportFromAnotherAppClick)
},
onImportFromComputerClick = {
viewModel.trySendAction(ImportItemsAction.ImportFromComputerClick)
},
onDismissDialog = {
viewModel.trySendAction(ImportItemsAction.DismissDialog)
},
)
}
}
/**
* Helper function to remember a [ImportItemsHandler] instance in a [Composable] scope.
*/
@Composable
fun rememberImportItemsHandler(viewModel: ImportItemsViewModel): ImportItemsHandler =
remember(viewModel) { ImportItemsHandler.create(viewModel) }

View File

@ -188,6 +188,7 @@ class VaultViewModel @Inject constructor(
SnackbarRelay.CIPHER_RESTORED,
SnackbarRelay.CIPHER_UPDATED,
SnackbarRelay.LOGINS_IMPORTED,
SnackbarRelay.VAULT_SYNC_FAILED,
),
)
.map { VaultAction.Internal.SnackbarDataReceive(it) }

View File

@ -147,7 +147,7 @@ class CredentialExchangeImportManagerTest {
val result = importManager.importCxfPayload(DEFAULT_USER_ID, DEFAULT_PAYLOAD)
assertEquals(ImportCxfPayloadResult.Success, result)
assertEquals(ImportCxfPayloadResult.Success(itemCount = 1), result)
coVerify(exactly = 1) {
vaultSdkSource.importCxf(DEFAULT_USER_ID, DEFAULT_PAYLOAD)
ciphersService.importCiphers(any())

View File

@ -2882,14 +2882,14 @@ class VaultRepositoryTest {
userId = userId,
payload = payload,
)
} returns ImportCxfPayloadResult.Success
} returns ImportCxfPayloadResult.Success(itemCount = 1)
coEvery {
vaultSyncManager.sync(userId = userId, forced = true)
} returns SyncVaultDataResult.Success(itemsAvailable = true)
val result = vaultRepository.importCxfPayload(payload)
assertEquals(
ImportCredentialsResult.Success,
ImportCredentialsResult.Success(itemCount = 1),
result,
)
coVerify(exactly = 1) {
@ -2966,7 +2966,7 @@ class VaultRepositoryTest {
userId = userId,
payload = payload,
)
} returns ImportCxfPayloadResult.Success
} returns ImportCxfPayloadResult.Success(itemCount = 1)
coEvery {
vaultSyncManager.sync(userId = userId, forced = true)

View File

@ -1,6 +1,7 @@
package com.x8bit.bitwarden.ui.platform.base
import androidx.compose.runtime.Composable
import com.bitwarden.cxf.importer.CredentialExchangeImporter
import com.bitwarden.ui.platform.base.BaseComposeTest
import com.bitwarden.ui.platform.feature.settings.appearance.model.AppTheme
import com.bitwarden.ui.platform.manager.IntentManager
@ -40,6 +41,7 @@ abstract class BitwardenComposeTest : BaseComposeTest() {
keyChainManager: KeyChainManager = mockk(),
nfcManager: NfcManager = mockk(),
permissionsManager: PermissionsManager = mockk(),
credentialExchangeImporter: CredentialExchangeImporter = mockk(),
test: @Composable () -> Unit,
) {
setTestContent {
@ -55,6 +57,7 @@ abstract class BitwardenComposeTest : BaseComposeTest() {
keyChainManager = keyChainManager,
nfcManager = nfcManager,
permissionsManager = permissionsManager,
credentialExchangeImporter = credentialExchangeImporter,
) {
BitwardenTheme(
theme = theme,

View File

@ -0,0 +1,208 @@
package com.x8bit.bitwarden.ui.vault.feature.importitems
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.filterToOne
import androidx.compose.ui.test.hasAnyAncestor
import androidx.compose.ui.test.isDialog
import androidx.compose.ui.test.onAllNodesWithText
import androidx.compose.ui.test.onNodeWithContentDescription
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import com.bitwarden.core.data.repository.util.bufferedMutableSharedFlow
import com.bitwarden.cxf.importer.CredentialExchangeImporter
import com.bitwarden.cxf.importer.model.ImportCredentialsSelectionResult
import com.bitwarden.ui.util.asText
import com.bitwarden.ui.util.assertNoDialogExists
import com.x8bit.bitwarden.ui.platform.base.BitwardenComposeTest
import io.mockk.coEvery
import io.mockk.coVerify
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 kotlinx.coroutines.test.runTest
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
class ImportItemsScreenTest : BitwardenComposeTest() {
private var onNavigateBackCalled = false
private var onNavigateToVaultCalled = false
private var onNavigateToImportFromComputerCalled = false
private val credentialExchangeImporter = mockk<CredentialExchangeImporter>()
private val mockkStateFlow = MutableStateFlow(DEFAULT_STATE)
private val mockEventFlow = bufferedMutableSharedFlow<ImportItemsEvent>()
private val viewModel = mockk<ImportItemsViewModel> {
every { eventFlow } returns mockEventFlow
every { stateFlow } returns mockkStateFlow
every { trySendAction(any()) } just runs
}
@Before
fun setUp() {
setContent(
credentialExchangeImporter = credentialExchangeImporter,
) {
ImportItemsScreen(
onNavigateBack = { onNavigateBackCalled = true },
onNavigateToVault = { onNavigateToVaultCalled = true },
onNavigateToImportFromComputer = { onNavigateToImportFromComputerCalled = true },
viewModel = viewModel,
)
}
}
@Test
fun `initial state should be correct`() = runTest {
assertEquals(
DEFAULT_STATE,
viewModel.stateFlow.value,
)
composeTestRule
.onNodeWithText("Import from computer")
.assertExists()
composeTestRule
.onNodeWithText("Import from another app")
.assertExists()
}
@Test
fun `NavigateBack should call onNavigateBack`() {
mockEventFlow.tryEmit(ImportItemsEvent.NavigateBack)
assertTrue(onNavigateBackCalled)
}
@Test
fun `onBackClick should send BackClick`() {
composeTestRule.onNodeWithContentDescription("Back").performClick()
verify(exactly = 1) {
viewModel.trySendAction(ImportItemsAction.BackClick)
}
}
@Test
fun `ImportFromComputer click should send NavigateToImportFromComputer action`() =
runTest {
composeTestRule
.onNodeWithText("Import from computer")
.performClick()
verify(exactly = 1) {
viewModel.trySendAction(ImportItemsAction.ImportFromComputerClick)
}
}
@Test
fun `NavigateToImportFromComputer should call onNavigateToImportFromComputer`() {
mockEventFlow.tryEmit(ImportItemsEvent.NavigateToImportFromComputer)
assertTrue(onNavigateToImportFromComputerCalled)
}
@Test
fun `ImportFromAnotherApp click should send NavigateToImportFromAnotherApp action`() =
runTest {
composeTestRule
.onNodeWithText("Import from another app")
.performClick()
verify(exactly = 1) {
viewModel.trySendAction(ImportItemsAction.ImportFromAnotherAppClick)
}
}
@Test
fun `NavigateToVault should call onNavigateToVault`() {
mockEventFlow.tryEmit(ImportItemsEvent.NavigateToVault)
assertTrue(onNavigateToVaultCalled)
}
@Test
fun `ShowRegisteredImportSources should call CredentialExchangeImporter`() = runTest {
val importCredentialsSelectionResult = ImportCredentialsSelectionResult.Success(
response = "mockResponse",
callingAppInfo = mockk(relaxed = true),
)
coEvery {
credentialExchangeImporter.importCredentials(listOf(""))
} returns importCredentialsSelectionResult
mockEventFlow.tryEmit(
ImportItemsEvent.ShowRegisteredImportSources(
listOf(""),
),
)
coVerify(exactly = 1) {
credentialExchangeImporter.importCredentials(listOf(""))
viewModel.trySendAction(
ImportItemsAction.ImportCredentialSelectionReceive(
selectionResult = importCredentialsSelectionResult,
),
)
}
}
@Test
fun `General dialog should display based on state`() = runTest {
mockkStateFlow.tryEmit(ImportItemsState())
composeTestRule
.assertNoDialogExists()
mockkStateFlow.tryEmit(
ImportItemsState(
dialog = ImportItemsState.DialogState.General(
title = "title".asText(),
message = "message".asText(),
throwable = null,
),
),
)
composeTestRule
.onAllNodesWithText("title")
.filterToOne(hasAnyAncestor(isDialog()))
.assertIsDisplayed()
}
@Test
fun `General dialog dismiss should send DismissDialog`() = runTest {
mockkStateFlow.tryEmit(
ImportItemsState(
dialog = ImportItemsState.DialogState.General(
title = "title".asText(),
message = "message".asText(),
throwable = null,
),
),
)
composeTestRule
.onNodeWithText("Okay")
.performClick()
verify(exactly = 1) {
viewModel.trySendAction(ImportItemsAction.DismissDialog)
}
}
@Test
fun `BitwardenLoadingDialog should display based on state`() = runTest {
mockkStateFlow.tryEmit(
ImportItemsState(
dialog = ImportItemsState.DialogState.Loading(message = "message".asText()),
),
)
composeTestRule
.onNodeWithText("message")
.assertIsDisplayed()
}
}
private val DEFAULT_STATE: ImportItemsState = ImportItemsState(dialog = null)

View File

@ -6,17 +6,22 @@ import androidx.lifecycle.SavedStateHandle
import app.cash.turbine.test
import com.bitwarden.cxf.importer.model.ImportCredentialsSelectionResult
import com.bitwarden.ui.platform.base.BaseViewModelTest
import com.bitwarden.ui.platform.components.icon.model.IconData
import com.bitwarden.ui.platform.resource.BitwardenDrawable
import com.bitwarden.ui.platform.components.snackbar.model.BitwardenSnackbarData
import com.bitwarden.ui.platform.resource.BitwardenPlurals
import com.bitwarden.ui.platform.resource.BitwardenString
import com.bitwarden.ui.util.asPluralsText
import com.bitwarden.ui.util.asText
import com.x8bit.bitwarden.data.vault.repository.VaultRepository
import com.x8bit.bitwarden.data.vault.repository.model.ImportCredentialsResult
import com.x8bit.bitwarden.ui.platform.manager.snackbar.SnackbarRelay
import com.x8bit.bitwarden.ui.platform.manager.snackbar.SnackbarRelayManager
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 io.mockk.runs
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Test
@ -24,9 +29,12 @@ import org.junit.jupiter.api.Test
class ImportItemsViewModelTest : BaseViewModelTest() {
private val vaultRepository = mockk<VaultRepository>()
private val snackbarRelayManager = mockk<SnackbarRelayManager> {
every { sendSnackbarData(any(), any()) } just runs
}
@Test
fun `NavigateBack sends NavigateBack event`() = runTest {
fun `BackClick sends NavigateBack event`() = runTest {
val viewModel = createViewModel()
viewModel.eventFlow.test {
viewModel.trySendAction(ImportItemsAction.BackClick)
@ -35,19 +43,27 @@ class ImportItemsViewModelTest : BaseViewModelTest() {
}
@Test
fun `GetStartedClick updates state and sends ShowRegisteredImportSources event`() {
fun `ImportFromComputerClick sends NavigateToImportFromComputer event`() = runTest {
val viewModel = createViewModel()
viewModel.eventFlow.test {
viewModel.trySendAction(ImportItemsAction.ImportFromComputerClick)
assertEquals(ImportItemsEvent.NavigateToImportFromComputer, awaitItem())
}
}
@Test
fun `DismissDialog updates state`() = runTest {
val viewModel = createViewModel()
viewModel.trySendAction(ImportItemsAction.DismissDialog)
assertEquals(ImportItemsState(dialog = null), viewModel.stateFlow.value)
}
@Test
fun `ImportFromAnotherAppClick sends ShowRegisteredImportSources event`() {
runTest {
val viewModel = createViewModel()
viewModel.trySendAction(ImportItemsAction.ImportFromAnotherAppClick)
viewModel.eventFlow.test {
assertEquals(
ImportItemsState.ViewState.NotStarted,
viewModel.stateFlow.value.viewState,
)
viewModel.trySendAction(ImportItemsAction.GetStartedClick)
assertEquals(
ImportItemsState.ViewState.AwaitingSelection,
viewModel.stateFlow.value.viewState,
)
assertEquals(
ImportItemsEvent.ShowRegisteredImportSources(
listOf(
@ -75,32 +91,37 @@ class ImportItemsViewModelTest : BaseViewModelTest() {
),
)
val expectedState = ImportItemsState.ViewState.Completed(
title = BitwardenString.import_cancelled.asText(),
message = BitwardenString.credential_import_was_cancelled.asText(),
iconData = IconData.Local(BitwardenDrawable.ic_warning),
val expectedState = ImportItemsState(
dialog = ImportItemsState.DialogState.General(
title = BitwardenString.import_cancelled.asText(),
message = BitwardenString.import_was_cancelled_in_the_selected_app.asText(),
throwable = null,
),
)
assertEquals(expectedState, viewModel.stateFlow.value.viewState)
assertEquals(expectedState, viewModel.stateFlow.value)
}
@Test
fun `ImportCredentialSelectionReceive and Failure result updates state`() = runTest {
val viewModel = createViewModel()
val exception = ImportCredentialsInvalidJsonException("Error")
viewModel.trySendAction(
ImportItemsAction.ImportCredentialSelectionReceive(
selectionResult = ImportCredentialsSelectionResult.Failure(
error = ImportCredentialsInvalidJsonException(),
error = exception,
),
),
)
val expectedState = ImportItemsState.ViewState.Completed(
title = BitwardenString.import_vault_failure.asText(),
message = BitwardenString.generic_error_message.asText(),
iconData = IconData.Local(BitwardenDrawable.ic_warning),
val expectedState = ImportItemsState(
dialog = ImportItemsState.DialogState.General(
title = BitwardenString.unable_to_import_your_items.asText(),
message = BitwardenString.there_was_a_problem_importing_your_items.asText(),
throwable = exception,
),
)
assertEquals(expectedState, viewModel.stateFlow.value.viewState)
assertEquals(expectedState, viewModel.stateFlow.value)
}
@Test
@ -123,12 +144,13 @@ class ImportItemsViewModelTest : BaseViewModelTest() {
),
)
// Verify state is updated to ImportingItems
assertEquals(
ImportItemsState.ViewState.ImportingItems(
BitwardenString.import_items.asText(),
ImportItemsState(
dialog = ImportItemsState.DialogState.Loading(
message = BitwardenString.saving_items.asText(),
),
),
viewModel.stateFlow.value.viewState,
viewModel.stateFlow.value,
)
// Verify that the repository method was called
@ -150,43 +172,61 @@ class ImportItemsViewModelTest : BaseViewModelTest() {
@Test
fun `Internal ImportCxfResultReceive and Error result updates state`() = runTest {
val viewModel = createViewModel()
val exception = ImportCredentialsInvalidJsonException("Error")
viewModel.trySendAction(
ImportItemsAction.Internal.ImportCredentialsResultReceive(
ImportCredentialsResult.Error(
error = RuntimeException("Error"),
error = exception,
),
),
)
val expectedState = ImportItemsState.ViewState.Completed(
title = BitwardenString.import_error.asText(),
message = BitwardenString.there_was_a_problem_importing_your_items.asText(),
iconData = IconData.Local(BitwardenDrawable.ic_warning),
)
assertEquals(expectedState, viewModel.stateFlow.value.viewState)
}
@Test
fun `Internal ImportCxfResultReceive and Success result updates state`() = runTest {
val viewModel = createViewModel()
viewModel.trySendAction(
ImportItemsAction.Internal.ImportCredentialsResultReceive(
ImportCredentialsResult.Success,
val expectedState = ImportItemsState(
dialog = ImportItemsState.DialogState.General(
title = BitwardenString.unable_to_import_your_items.asText(),
message = BitwardenString.there_was_a_problem_importing_your_items.asText(),
throwable = exception,
),
)
val expectedState = ImportItemsState.ViewState.Completed(
title = BitwardenString.import_success.asText(),
message = BitwardenString
.your_items_have_been_successfully_imported
.asText(),
iconData = IconData.Local(BitwardenDrawable.ic_plain_checkmark),
)
assertEquals(expectedState, viewModel.stateFlow.value.viewState)
assertEquals(expectedState, viewModel.stateFlow.value)
}
@Suppress("MaxLineLength")
@Test
fun `Internal ImportCredentialsResultReceive with Success result should clear dialogs, show snackbar, and navigate to vault`() =
runTest {
val viewModel = createViewModel()
viewModel.trySendAction(
ImportItemsAction.Internal.ImportCredentialsResultReceive(
ImportCredentialsResult.Success(itemCount = 2),
),
)
val expectedState = ImportItemsState(dialog = null)
assertEquals(expectedState, viewModel.stateFlow.value)
coVerify(exactly = 1) {
snackbarRelayManager.sendSnackbarData(
data = BitwardenSnackbarData(
messageHeader = BitwardenString.import_successful.asText(),
message = BitwardenPlurals
.x_items_have_been_imported_to_your_vault
.asPluralsText(
quantity = 2,
args = arrayOf(2),
),
),
relay = SnackbarRelay.LOGINS_IMPORTED,
)
}
viewModel.eventFlow.test {
assertEquals(
ImportItemsEvent.NavigateToVault,
awaitItem(),
)
}
}
@Test
fun `Internal ImportCxfResultReceive and NoItems result updates state`() = runTest {
val viewModel = createViewModel()
@ -197,40 +237,49 @@ class ImportItemsViewModelTest : BaseViewModelTest() {
),
)
val expectedState = ImportItemsState.ViewState.Completed(
title = BitwardenString.no_items_imported.asText(),
message = BitwardenString
.no_items_received_from_the_selected_credential_manager
.asText(),
iconData = IconData.Local(BitwardenDrawable.ic_plain_checkmark),
)
assertEquals(expectedState, viewModel.stateFlow.value.viewState)
}
@Test
fun `Internal ImportCxfResultReceive and SyncFailed result updates state`() = runTest {
val viewModel = createViewModel()
viewModel.trySendAction(
ImportItemsAction.Internal.ImportCredentialsResultReceive(
ImportCredentialsResult.SyncFailed(
error = RuntimeException("Error"),
),
val expectedState = ImportItemsState(
dialog = ImportItemsState.DialogState.General(
title = BitwardenString.no_items_imported.asText(),
message = BitwardenString.no_items_received_from_the_selected_app.asText(),
throwable = null,
),
)
val expectedState = ImportItemsState.ViewState.Completed(
title = BitwardenString.vault_sync_failed.asText(),
message = BitwardenString
.your_items_have_been_successfully_imported_but_could_not_sync_vault
.asText(),
iconData = IconData.Local(BitwardenDrawable.ic_warning),
)
assertEquals(expectedState, viewModel.stateFlow.value.viewState)
assertEquals(expectedState, viewModel.stateFlow.value)
}
@Suppress("MaxLineLength")
@Test
fun `Internal ImportCxfResultReceive and SyncFailed should clear dialogs, show snackbar, and navigate to vault`() =
runTest {
val viewModel = createViewModel()
viewModel.trySendAction(
ImportItemsAction.Internal.ImportCredentialsResultReceive(
ImportCredentialsResult.SyncFailed(
error = RuntimeException("Error"),
),
),
)
val expectedState = ImportItemsState(dialog = null)
assertEquals(expectedState, viewModel.stateFlow.value)
coVerify(exactly = 1) {
snackbarRelayManager.sendSnackbarData(
data = BitwardenSnackbarData(
messageHeader = BitwardenString.vault_sync_failed.asText(),
message = BitwardenString
.your_items_have_been_successfully_imported_but_could_not_sync_vault
.asText(),
actionLabel = BitwardenString.try_again.asText(),
),
relay = SnackbarRelay.VAULT_SYNC_FAILED,
)
}
}
private fun createViewModel(): ImportItemsViewModel = ImportItemsViewModel(
vaultRepository = vaultRepository,
savedStateHandle = SavedStateHandle(),
snackbarRelayManager = snackbarRelayManager,
)
}

View File

@ -5,6 +5,7 @@ import androidx.annotation.VisibleForTesting
import androidx.credentials.providerevents.ProviderEventsManager
import androidx.credentials.providerevents.exception.ImportCredentialsCancellationException
import androidx.credentials.providerevents.exception.ImportCredentialsException
import androidx.credentials.providerevents.exception.ImportCredentialsUnknownErrorException
import androidx.credentials.providerevents.transfer.ImportCredentialsRequest
import com.bitwarden.cxf.importer.model.ImportCredentialsSelectionResult
import timber.log.Timber
@ -47,11 +48,17 @@ internal class CredentialExchangeImporterImpl(
response = response.response.responseJson,
callingAppInfo = response.callingAppInfo,
)
} catch (_: ImportCredentialsCancellationException) {
} catch (e: ImportCredentialsCancellationException) {
Timber.e(e, "User cancelled import from selected credential manager.")
ImportCredentialsSelectionResult.Cancelled
} catch (e: ImportCredentialsException) {
Timber.e(e, "Failed to import items from selected credential manager.")
ImportCredentialsSelectionResult.Failure(error = e)
} catch (@Suppress("TooGenericExceptionCaught") e: Exception) {
Timber.e(e, "Failed to import items from selected credential manager.")
ImportCredentialsSelectionResult.Failure(
error = ImportCredentialsUnknownErrorException(),
)
}
}
}

View File

@ -1087,18 +1087,19 @@ Do you want to switch to this account?</string>
<string name="default_uri_match_detection_description_advanced_options">URI match detection controls how Bitwarden identifies autofill suggestions.\n<annotation emphasis="bold">Warning:</annotation> “Starts with” is an advanced option with increased risk of exposing credentials.</string>
<string name="advanced_option_with_increased_risk_of_exposing_credentials">“Starts with” is an advanced option with increased risk of exposing credentials.</string>
<string name="advanced_option_increased_risk_exposing_credentials_used_incorrectly">“Regular expression” is an advanced option with increased risk of exposing credentials if used incorrectly.</string>
<string name="credential_import_was_cancelled">Credential import was cancelled. No credentials have been imported.</string>
<string name="import_was_cancelled_in_the_selected_app">Credential import was cancelled in the selected app. No items have been imported.</string>
<string name="import_cancelled">Import cancelled</string>
<string name="your_items_have_been_successfully_imported">Your items have been successfully imported and are now viewable in your vault.</string>
<string name="import_saved_items">Import saved items</string>
<string name="import_your_credentials_from_another_password_manager">Import your credentials, including passkeys, passwords, credit cards, and any personal identity information from another password manager.</string>
<string name="return_to_your_vault">Return to your vault</string>
<string name="select_a_credential_manager_to_import_items_from">Select a credential manager to import items from.</string>
<string name="no_items_imported">No items imported</string>
<string name="no_items_received_from_the_selected_credential_manager">No items received from the selected credential manager.</string>
<string name="no_items_received_from_the_selected_app">No items received from the selected app.</string>
<string name="vault_sync_failed">Vault sync failed</string>
<string name="your_items_have_been_successfully_imported_but_could_not_sync_vault">Your items have been successfully imported, but could not sync the vault. Imported items will not be visible in your vault until sync is performed.</string>
<string name="your_items_have_been_successfully_imported_but_could_not_sync_vault">Your items have been successfully imported, but they will not be visible in your vault until sync is performed.</string>
<string name="there_was_a_problem_importing_your_items">There was a problem importing your items. Please try again. If the problem persists, contact support.</string>
<string name="importing_items">Importing items…</string>
<string name="uploading_items">Uploading items…</string>
<string name="saving_items">Saving items…</string>
<string name="unable_to_import_your_items">Unable to import your items</string>
<string name="import_from_computer">Import from computer</string>
<string name="import_from_another_app">Import from another app</string>
<plurals name="x_items_have_been_imported_to_your_vault">
<item quantity="one">%1$d item has been imported to your vault.</item>
<item quantity="other">%1$d items have been imported to your vault.</item>
</plurals>
</resources>