diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/data/vault/manager/CredentialExchangeImportManagerImpl.kt b/app/src/main/kotlin/com/x8bit/bitwarden/data/vault/manager/CredentialExchangeImportManagerImpl.kt index f4a625fd4f..aed48550c7 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/data/vault/manager/CredentialExchangeImportManagerImpl.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/data/vault/manager/CredentialExchangeImportManagerImpl.kt @@ -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 }, diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/data/vault/manager/model/ImportCxfPayloadResult.kt b/app/src/main/kotlin/com/x8bit/bitwarden/data/vault/manager/model/ImportCxfPayloadResult.kt index 666fe49aaa..7f2d25a58e 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/data/vault/manager/model/ImportCxfPayloadResult.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/data/vault/manager/model/ImportCxfPayloadResult.kt @@ -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. diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/data/vault/repository/VaultRepositoryImpl.kt b/app/src/main/kotlin/com/x8bit/bitwarden/data/vault/repository/VaultRepositoryImpl.kt index 2dfb25419b..ad47621f87 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/data/vault/repository/VaultRepositoryImpl.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/data/vault/repository/VaultRepositoryImpl.kt @@ -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) } } } diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/data/vault/repository/model/ImportCredentialsResult.kt b/app/src/main/kotlin/com/x8bit/bitwarden/data/vault/repository/model/ImportCredentialsResult.kt index 7be7d0dec5..56d6cb9ee8 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/data/vault/repository/model/ImportCredentialsResult.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/data/vault/repository/model/ImportCredentialsResult.kt @@ -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. diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/composition/LocalManagerProvider.kt b/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/composition/LocalManagerProvider.kt index b2579b14a8..df06760356 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/composition/LocalManagerProvider.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/composition/LocalManagerProvider.kt @@ -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, ) } diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/manager/snackbar/SnackbarRelay.kt b/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/manager/snackbar/SnackbarRelay.kt index a3d3b2e020..139c81405b 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/manager/snackbar/SnackbarRelay.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/manager/snackbar/SnackbarRelay.kt @@ -21,4 +21,5 @@ enum class SnackbarRelay { LOGINS_IMPORTED, SEND_DELETED, SEND_UPDATED, + VAULT_SYNC_FAILED, } diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/importitems/ImportItemsScreen.kt b/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/importitems/ImportItemsScreen.kt new file mode 100644 index 0000000000..5736de2a9b --- /dev/null +++ b/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/importitems/ImportItemsScreen.kt @@ -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 diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/importitems/ImportItemsViewModel.kt b/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/importitems/ImportItemsViewModel.kt index 26cf5aaf4b..031b1a2f64 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/importitems/ImportItemsViewModel.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/importitems/ImportItemsViewModel.kt @@ -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( - 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. */ diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/importitems/handlers/ImportItemsHandler.kt b/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/importitems/handlers/ImportItemsHandler.kt new file mode 100644 index 0000000000..82f6331cca --- /dev/null +++ b/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/importitems/handlers/ImportItemsHandler.kt @@ -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) } diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/vault/VaultViewModel.kt b/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/vault/VaultViewModel.kt index 35e591d8fe..91c6e52a80 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/vault/VaultViewModel.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/vault/VaultViewModel.kt @@ -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) } diff --git a/app/src/test/kotlin/com/x8bit/bitwarden/data/vault/manager/CredentialExchangeImportManagerTest.kt b/app/src/test/kotlin/com/x8bit/bitwarden/data/vault/manager/CredentialExchangeImportManagerTest.kt index 48361349bf..2bcabd9d6b 100644 --- a/app/src/test/kotlin/com/x8bit/bitwarden/data/vault/manager/CredentialExchangeImportManagerTest.kt +++ b/app/src/test/kotlin/com/x8bit/bitwarden/data/vault/manager/CredentialExchangeImportManagerTest.kt @@ -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()) diff --git a/app/src/test/kotlin/com/x8bit/bitwarden/data/vault/repository/VaultRepositoryTest.kt b/app/src/test/kotlin/com/x8bit/bitwarden/data/vault/repository/VaultRepositoryTest.kt index fc2aef052a..afe0d83bcd 100644 --- a/app/src/test/kotlin/com/x8bit/bitwarden/data/vault/repository/VaultRepositoryTest.kt +++ b/app/src/test/kotlin/com/x8bit/bitwarden/data/vault/repository/VaultRepositoryTest.kt @@ -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) diff --git a/app/src/test/kotlin/com/x8bit/bitwarden/ui/platform/base/BitwardenComposeTest.kt b/app/src/test/kotlin/com/x8bit/bitwarden/ui/platform/base/BitwardenComposeTest.kt index b79b86438e..442f7b5653 100644 --- a/app/src/test/kotlin/com/x8bit/bitwarden/ui/platform/base/BitwardenComposeTest.kt +++ b/app/src/test/kotlin/com/x8bit/bitwarden/ui/platform/base/BitwardenComposeTest.kt @@ -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, diff --git a/app/src/test/kotlin/com/x8bit/bitwarden/ui/vault/feature/importitems/ImportItemsScreenTest.kt b/app/src/test/kotlin/com/x8bit/bitwarden/ui/vault/feature/importitems/ImportItemsScreenTest.kt new file mode 100644 index 0000000000..eb8249ed13 --- /dev/null +++ b/app/src/test/kotlin/com/x8bit/bitwarden/ui/vault/feature/importitems/ImportItemsScreenTest.kt @@ -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() + private val mockkStateFlow = MutableStateFlow(DEFAULT_STATE) + private val mockEventFlow = bufferedMutableSharedFlow() + + private val viewModel = mockk { + 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) diff --git a/app/src/test/kotlin/com/x8bit/bitwarden/ui/vault/feature/importitems/ImportItemsViewModelTest.kt b/app/src/test/kotlin/com/x8bit/bitwarden/ui/vault/feature/importitems/ImportItemsViewModelTest.kt index 676200b30a..a652ea5e57 100644 --- a/app/src/test/kotlin/com/x8bit/bitwarden/ui/vault/feature/importitems/ImportItemsViewModelTest.kt +++ b/app/src/test/kotlin/com/x8bit/bitwarden/ui/vault/feature/importitems/ImportItemsViewModelTest.kt @@ -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() + private val snackbarRelayManager = mockk { + 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, ) } diff --git a/cxf/src/main/kotlin/com/bitwarden/cxf/importer/CredentialExchangeImporterImpl.kt b/cxf/src/main/kotlin/com/bitwarden/cxf/importer/CredentialExchangeImporterImpl.kt index 879445128b..bf95e9dc62 100644 --- a/cxf/src/main/kotlin/com/bitwarden/cxf/importer/CredentialExchangeImporterImpl.kt +++ b/cxf/src/main/kotlin/com/bitwarden/cxf/importer/CredentialExchangeImporterImpl.kt @@ -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(), + ) } } } diff --git a/ui/src/main/res/values/strings.xml b/ui/src/main/res/values/strings.xml index e61b40882a..a16660f531 100644 --- a/ui/src/main/res/values/strings.xml +++ b/ui/src/main/res/values/strings.xml @@ -1087,18 +1087,19 @@ Do you want to switch to this account? URI match detection controls how Bitwarden identifies autofill suggestions.\nWarning: “Starts with” is an advanced option with increased risk of exposing credentials. “Starts with” is an advanced option with increased risk of exposing credentials. “Regular expression” is an advanced option with increased risk of exposing credentials if used incorrectly. - Credential import was cancelled. No credentials have been imported. + Credential import was cancelled in the selected app. No items have been imported. Import cancelled - Your items have been successfully imported and are now viewable in your vault. - Import saved items - Import your credentials, including passkeys, passwords, credit cards, and any personal identity information from another password manager. - Return to your vault - Select a credential manager to import items from. No items imported - No items received from the selected credential manager. + No items received from the selected app. Vault sync failed - 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. + Your items have been successfully imported, but they will not be visible in your vault until sync is performed. There was a problem importing your items. Please try again. If the problem persists, contact support. - Importing items… - Uploading items… + Saving items… + Unable to import your items + Import from computer + Import from another app + + %1$d item has been imported to your vault. + %1$d items have been imported to your vault. +