mirror of
https://github.com/bitwarden/android.git
synced 2026-03-12 05:04:17 -05:00
[PM-25822] Add ImportItemsViewModel and related strings (#5882)
This commit is contained in:
@@ -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
|
||||
}
|
||||
@@ -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(),
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user