mirror of
https://github.com/bitwarden/android.git
synced 2025-12-10 09:56:45 -06:00
[PM-25824] Add "Import items" screen (#5906)
This commit is contained in:
parent
f4569cef2b
commit
b4a31764c4
@ -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 },
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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,
|
||||
)
|
||||
}
|
||||
|
||||
@ -21,4 +21,5 @@ enum class SnackbarRelay {
|
||||
LOGINS_IMPORTED,
|
||||
SEND_DELETED,
|
||||
SEND_UPDATED,
|
||||
VAULT_SYNC_FAILED,
|
||||
}
|
||||
|
||||
@ -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
|
||||
@ -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.
|
||||
*/
|
||||
|
||||
@ -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) }
|
||||
@ -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) }
|
||||
|
||||
@ -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())
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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)
|
||||
@ -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,
|
||||
)
|
||||
}
|
||||
|
||||
@ -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(),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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>
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user