[PM-25822] Add ImportItemsViewModel and related strings (#5882)

This commit is contained in:
Patrick Honkonen
2025-09-17 13:58:22 -04:00
committed by GitHub
parent 8de465381e
commit 7d3ed2af88
3 changed files with 559 additions and 1 deletions

View File

@@ -0,0 +1,308 @@
package com.x8bit.bitwarden.ui.vault.feature.importitems
import android.os.Parcelable
import androidx.credentials.providerevents.transfer.CredentialTypes
import androidx.lifecycle.SavedStateHandle
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.resource.BitwardenString
import com.bitwarden.ui.util.Text
import com.bitwarden.ui.util.asText
import com.x8bit.bitwarden.data.vault.repository.VaultRepository
import com.x8bit.bitwarden.data.vault.repository.model.ImportCredentialsResult
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.parcelize.Parcelize
import javax.inject.Inject
private const val KEY_STATE = "state"
/**
* View model for the [ImportItemsScreen].
*/
@HiltViewModel
class ImportItemsViewModel @Inject constructor(
savedStateHandle: SavedStateHandle,
private val vaultRepository: VaultRepository,
) : BaseViewModel<ImportItemsState, ImportItemsEvent, ImportItemsAction>(
initialState = savedStateHandle[KEY_STATE] ?: ImportItemsState(
viewState = ImportItemsState.ViewState.NotStarted,
),
) {
override fun handleAction(action: ImportItemsAction) {
when (action) {
ImportItemsAction.BackClick -> {
handleBackClick()
}
is ImportItemsAction.GetStartedClick -> {
handleGetStartedClick()
}
is ImportItemsAction.ImportCredentialSelectionReceive -> {
handleImportCredentialSelectionReceive(action)
}
ImportItemsAction.ReturnToVaultClick -> {
handleReturnToVaultClick()
}
is ImportItemsAction.Internal.ImportCredentialsResultReceive -> {
handleImportCredentialsResultReceive(action)
}
}
}
private fun handleReturnToVaultClick() {
sendEvent(ImportItemsEvent.NavigateToVault)
}
private fun handleBackClick() {
sendEvent(ImportItemsEvent.NavigateBack)
}
private fun handleGetStartedClick() {
mutableStateFlow.update {
it.copy(viewState = ImportItemsState.ViewState.AwaitingSelection)
}
sendEvent(
ImportItemsEvent.ShowRegisteredImportSources(
credentialTypes = listOf(
CredentialTypes.BASIC_AUTH,
CredentialTypes.PUBLIC_KEY,
CredentialTypes.TOTP,
CredentialTypes.CREDIT_CARD,
CredentialTypes.SSH_KEY,
CredentialTypes.ADDRESS,
),
),
)
}
private fun handleImportCredentialSelectionReceive(
action: ImportItemsAction.ImportCredentialSelectionReceive,
) {
when (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),
),
)
}
}
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),
),
)
}
}
is ImportCredentialsSelectionResult.Success -> {
updateImportProgress(BitwardenString.import_items.asText())
viewModelScope.launch {
sendAction(
ImportItemsAction.Internal.ImportCredentialsResultReceive(
vaultRepository.importCxfPayload(
payload = action.selectionResult.response,
),
),
)
}
}
}
}
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),
),
)
}
}
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),
),
)
}
}
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),
),
)
}
}
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),
),
)
}
}
}
}
private fun updateImportProgress(message: Text) {
mutableStateFlow.update {
it.copy(
viewState = ImportItemsState.ViewState.ImportingItems(
message = message,
),
)
}
}
}
/**
* State for the [ImportItemsScreen].
*/
@Parcelize
data class ImportItemsState(
val viewState: ViewState,
) : Parcelable {
/**
* View states for the [ImportItemsScreen].
*/
@Parcelize
sealed class ViewState : Parcelable {
/**
* The import has not yet started.
*/
data object NotStarted : ViewState()
/**
* The import has started and is awaiting selection.
*/
data object AwaitingSelection : ViewState()
/**
* The import is in progress.
*/
data class ImportingItems(val message: Text) : ViewState()
/**
* The import has completed.
*/
data class Completed(
val title: Text,
val message: Text,
val iconData: IconData,
) : ViewState()
}
}
/**
* Actions for the [ImportItemsViewModel].
*/
sealed class ImportItemsAction {
/**
* User clicked the Get started button.
*/
data object GetStartedClick : ImportItemsAction()
/**
* Result of credential selection from the selected credential manager.
*
* @property selectionResult The result of the credential selection.
*/
data class ImportCredentialSelectionReceive(
val selectionResult: ImportCredentialsSelectionResult,
) : ImportItemsAction()
/**
* User clicked the Return to vault button.
*/
data object ReturnToVaultClick : ImportItemsAction()
/**
* User clicked the back button.
*/
data object BackClick : ImportItemsAction()
/**
* Internal actions that the [ImportItemsViewModel] may itself send.
*/
sealed class Internal : ImportItemsAction() {
/**
* Import CXF result received.
*/
data class ImportCredentialsResultReceive(val result: ImportCredentialsResult) : Internal()
}
}
/**
* Events for the [ImportItemsViewModel].
*/
sealed class ImportItemsEvent {
/**
* Navigate back.
*/
data object NavigateBack : ImportItemsEvent()
/**
* Navigate to the vault.
*/
data object NavigateToVault : ImportItemsEvent()
/**
* Show registered import sources.
*
* @property credentialTypes The credential types to request.
*/
data class ShowRegisteredImportSources(
val credentialTypes: List<String>,
) : ImportItemsEvent(), BackgroundEvent
}

View File

@@ -0,0 +1,236 @@
package com.x8bit.bitwarden.ui.vault.feature.importitems
import androidx.credentials.providerevents.exception.ImportCredentialsInvalidJsonException
import androidx.credentials.providerevents.transfer.CredentialTypes
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.resource.BitwardenString
import com.bitwarden.ui.util.asText
import com.x8bit.bitwarden.data.vault.repository.VaultRepository
import com.x8bit.bitwarden.data.vault.repository.model.ImportCredentialsResult
import io.mockk.awaits
import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.just
import io.mockk.mockk
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Test
class ImportItemsViewModelTest : BaseViewModelTest() {
private val vaultRepository = mockk<VaultRepository>()
@Test
fun `NavigateBack sends NavigateBack event`() = runTest {
val viewModel = createViewModel()
viewModel.eventFlow.test {
viewModel.trySendAction(ImportItemsAction.BackClick)
assertEquals(ImportItemsEvent.NavigateBack, awaitItem())
}
}
@Test
fun `GetStartedClick updates state and sends ShowRegisteredImportSources event`() {
runTest {
val viewModel = createViewModel()
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(
CredentialTypes.BASIC_AUTH,
CredentialTypes.PUBLIC_KEY,
CredentialTypes.TOTP,
CredentialTypes.CREDIT_CARD,
CredentialTypes.SSH_KEY,
CredentialTypes.ADDRESS,
),
),
awaitItem(),
)
}
}
}
@Test
fun `ImportCredentialSelectionReceive and Cancelled result updates state`() = runTest {
val viewModel = createViewModel()
viewModel.trySendAction(
ImportItemsAction.ImportCredentialSelectionReceive(
selectionResult = ImportCredentialsSelectionResult.Cancelled,
),
)
val expectedState = ImportItemsState.ViewState.Completed(
title = BitwardenString.import_cancelled.asText(),
message = BitwardenString.credential_import_was_cancelled.asText(),
iconData = IconData.Local(BitwardenDrawable.ic_warning),
)
assertEquals(expectedState, viewModel.stateFlow.value.viewState)
}
@Test
fun `ImportCredentialSelectionReceive and Failure result updates state`() = runTest {
val viewModel = createViewModel()
viewModel.trySendAction(
ImportItemsAction.ImportCredentialSelectionReceive(
selectionResult = ImportCredentialsSelectionResult.Failure(
error = ImportCredentialsInvalidJsonException(),
),
),
)
val expectedState = ImportItemsState.ViewState.Completed(
title = BitwardenString.import_vault_failure.asText(),
message = BitwardenString.generic_error_message.asText(),
iconData = IconData.Local(BitwardenDrawable.ic_warning),
)
assertEquals(expectedState, viewModel.stateFlow.value.viewState)
}
@Test
fun `ImportCredentialSelectionReceive and Success result updates state and triggers import`() =
runTest {
val cxfPayload = "{\"credentials\":[]}"
val selectionResult = ImportCredentialsSelectionResult.Success(
response = cxfPayload,
callingAppInfo = mockk(),
)
coEvery {
vaultRepository.importCxfPayload(cxfPayload)
} just awaits
val viewModel = createViewModel()
viewModel.trySendAction(
ImportItemsAction.ImportCredentialSelectionReceive(
selectionResult,
),
)
// Verify state is updated to ImportingItems
assertEquals(
ImportItemsState.ViewState.ImportingItems(
BitwardenString.import_items.asText(),
),
viewModel.stateFlow.value.viewState,
)
// Verify that the repository method was called
coVerify { vaultRepository.importCxfPayload(cxfPayload) }
}
@Test
fun `ReturnToVaultClick sends NavigateToVault event`() = runTest {
val viewModel = createViewModel()
viewModel.eventFlow.test {
viewModel.trySendAction(ImportItemsAction.ReturnToVaultClick)
assertEquals(
ImportItemsEvent.NavigateToVault,
awaitItem(),
)
}
}
@Test
fun `Internal ImportCxfResultReceive and Error result updates state`() = runTest {
val viewModel = createViewModel()
viewModel.trySendAction(
ImportItemsAction.Internal.ImportCredentialsResultReceive(
ImportCredentialsResult.Error(
error = RuntimeException("Error"),
),
),
)
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.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)
}
@Test
fun `Internal ImportCxfResultReceive and NoItems result updates state`() = runTest {
val viewModel = createViewModel()
viewModel.trySendAction(
ImportItemsAction.Internal.ImportCredentialsResultReceive(
ImportCredentialsResult.NoItems,
),
)
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.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)
}
private fun createViewModel(): ImportItemsViewModel = ImportItemsViewModel(
vaultRepository = vaultRepository,
savedStateHandle = SavedStateHandle(),
)
}

View File

@@ -1,5 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:tools="http://schemas.android.com/tools">
<resources>
<string name="about">About</string>
<string name="add_folder">Add folder</string>
<string name="add_item">Add Item</string>
@@ -1087,4 +1087,18 @@ 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_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="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="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>
</resources>