BIT-509: Adding the Ability to Save a SecureNote Item (#347)

This commit is contained in:
Oleg Semenenko 2023-12-07 14:03:20 -06:00 committed by Álison Fernandes
parent 3bbcc41ae5
commit 2898613876
6 changed files with 195 additions and 48 deletions

View file

@ -26,10 +26,12 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.x8bit.bitwarden.R
import com.x8bit.bitwarden.ui.platform.base.util.EventsEffect
import com.x8bit.bitwarden.ui.platform.components.BitwardenListHeaderText
import com.x8bit.bitwarden.ui.platform.components.BitwardenLoadingDialog
import com.x8bit.bitwarden.ui.platform.components.BitwardenMultiSelectButton
import com.x8bit.bitwarden.ui.platform.components.BitwardenScaffold
import com.x8bit.bitwarden.ui.platform.components.BitwardenTextButton
import com.x8bit.bitwarden.ui.platform.components.BitwardenTopAppBar
import com.x8bit.bitwarden.ui.platform.components.LoadingDialogState
import kotlinx.collections.immutable.toImmutableList
/**
@ -63,6 +65,16 @@ fun VaultAddItemScreen(
VaultAddSecureNotesItemTypeHandlers.create(viewModel = viewModel)
}
when (val dialogState = state.dialog) {
is VaultAddItemState.DialogState.Loading -> {
BitwardenLoadingDialog(
visibilityState = LoadingDialogState.Shown(dialogState.label),
)
}
null -> Unit
}
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior(rememberTopAppBarState())
BitwardenScaffold(
modifier = Modifier

View file

@ -79,9 +79,19 @@ class VaultAddItemViewModel @Inject constructor(
//region Top Level Handlers
private fun handleSaveClick() {
mutableStateFlow.update {
it.copy(
dialog = VaultAddItemState.DialogState.Loading(
R.string.saving.asText(),
),
)
}
viewModelScope.launch {
when (state.selectedType) {
is VaultAddItemState.ItemType.Login -> {
is VaultAddItemState.ItemType.Login,
is VaultAddItemState.ItemType.SecureNotes,
-> {
sendAction(
action = VaultAddItemAction.Internal.CreateCipherResultReceive(
createCipherResult = vaultRepository.createCipher(
@ -91,16 +101,12 @@ class VaultAddItemViewModel @Inject constructor(
)
}
is VaultAddItemState.ItemType.SecureNotes -> {
// TODO Add Saving of SecureNotes (BIT-509)
}
VaultAddItemState.ItemType.Card -> {
// TODO Add Saving of SecureNotes (BIT-668)
// TODO Add Saving of Card Type (BIT-668)
}
VaultAddItemState.ItemType.Identity -> {
// TODO Add Saving of SecureNotes (BIT-508)
// TODO Add Saving of Identity type (BIT-508)
}
}
}
@ -502,6 +508,10 @@ class VaultAddItemViewModel @Inject constructor(
@Suppress("MaxLineLength")
private fun handleCreateCipherResultReceive(action: VaultAddItemAction.Internal.CreateCipherResultReceive) {
mutableStateFlow.update {
it.copy(dialog = null)
}
when (action.createCipherResult) {
is CreateCipherResult.Error -> {
// TODO Display error dialog BIT-501
@ -559,6 +569,7 @@ class VaultAddItemViewModel @Inject constructor(
companion object {
val INITIAL_STATE: VaultAddItemState = VaultAddItemState(
selectedType = VaultAddItemState.ItemType.Login(),
dialog = null,
)
}
}
@ -568,10 +579,12 @@ class VaultAddItemViewModel @Inject constructor(
*
* @property selectedType The type of the item (e.g., Card, Identity, SecureNotes)
* that has been selected to be added to the vault.
* @property dialog the state for the dialogs that can be displayed
*/
@Parcelize
data class VaultAddItemState(
val selectedType: ItemType,
val dialog: DialogState?,
) : Parcelable {
/**
@ -675,7 +688,14 @@ data class VaultAddItemState(
/**
* Represents the `SecureNotes` item type.
*
* @property displayStringResId Resource ID for the display string of the secure notes type.
* @property name The name associated with the SecureNotes item.
* @property folder The folder used for the SecureNotes item
* @property favorite Indicates whether this SecureNotes item is marked as a favorite.
* @property masterPasswordReprompt Indicates if a master password reprompt is required.
* @property notes Notes or comments associated with the SecureNotes item.
* @property ownership The ownership email associated with the SecureNotes item.
* @property availableFolders A list of available folders.
* @property availableOwners A list of available owners.
*/
@Parcelize
data class SecureNotes(
@ -702,6 +722,18 @@ data class VaultAddItemState(
}
}
}
/**
* Displays a dialog.
*/
sealed class DialogState : Parcelable {
/**
* Displays a loading dialog to the user.
*/
@Parcelize
data class Loading(val label: Text) : DialogState()
}
}
/**

View file

@ -5,6 +5,8 @@ import com.bitwarden.core.CipherType
import com.bitwarden.core.CipherView
import com.bitwarden.core.LoginUriView
import com.bitwarden.core.LoginView
import com.bitwarden.core.SecureNoteType
import com.bitwarden.core.SecureNoteView
import com.bitwarden.core.UriMatchType
import com.x8bit.bitwarden.data.vault.repository.model.VaultData
import com.x8bit.bitwarden.ui.platform.base.util.asText
@ -92,12 +94,6 @@ fun VaultAddItemState.ItemType.toCipherView(): CipherView =
is VaultAddItemState.ItemType.SecureNotes -> toSecureNotesCipherView()
}
/**
* Transforms [VaultAddItemState.ItemType.SecureNotes] into [CipherView].
*/
private fun VaultAddItemState.ItemType.SecureNotes.toSecureNotesCipherView(): CipherView =
TODO("create SecureNotes CipherView BIT-509")
/**
* Transforms [VaultAddItemState.ItemType.Login] into [CipherView].
*/
@ -152,6 +148,46 @@ private fun VaultAddItemState.ItemType.Login.toLoginCipherView(): CipherView =
revisionDate = Instant.now(),
)
/**
* Transforms [VaultAddItemState.ItemType.SecureNotes] into [CipherView].
*/
private fun VaultAddItemState.ItemType.SecureNotes.toSecureNotesCipherView(): CipherView =
CipherView(
id = null,
// TODO use real organization id BIT-780
organizationId = null,
// TODO use real folder id BIT-528
folderId = null,
collectionIds = emptyList(),
key = null,
name = name,
notes = notes,
type = CipherType.SECURE_NOTE,
secureNote = SecureNoteView(SecureNoteType.GENERIC),
login = null,
identity = null,
card = null,
favorite = favorite,
reprompt = if (masterPasswordReprompt) {
CipherRepromptType.PASSWORD
} else {
CipherRepromptType.NONE
},
organizationUseTotp = false,
edit = true,
viewPassword = true,
localData = null,
attachments = null,
// TODO implement custom fields BIT-529
fields = null,
passwordHistory = null,
creationDate = Instant.now(),
deletedDate = null,
// This is a throw away value.
// The SDK will eventually remove revisionDate via encryption.
revisionDate = Instant.now(),
)
/**
* Transforms [VaultAddItemState.ItemType.Identity] into [CipherView].
*/

View file

@ -34,11 +34,8 @@ import org.junit.Test
@Suppress("LargeClass")
class VaultAddItemScreenTest : BaseComposeTest() {
private val mutableStateFlow = MutableStateFlow(
VaultAddItemState(
selectedType = VaultAddItemState.ItemType.Login(),
),
)
private val mutableStateFlow = MutableStateFlow(DEFAULT_STATE_LOGIN)
private val viewModel = mockk<VaultAddItemViewModel>(relaxed = true) {
every { eventFlow } returns emptyFlow()
@ -623,8 +620,7 @@ class VaultAddItemScreenTest : BaseComposeTest() {
@Test
fun `in ItemType_SecureNotes state changing Name text field should trigger NameTextChange`() {
mutableStateFlow.value =
VaultAddItemState(selectedType = VaultAddItemState.ItemType.SecureNotes())
mutableStateFlow.value = DEFAULT_STATE_SECURE_NOTES
composeTestRule.setContent {
VaultAddItemScreen(viewModel = viewModel, onNavigateBack = {})
@ -643,8 +639,7 @@ class VaultAddItemScreenTest : BaseComposeTest() {
@Test
fun `in ItemType_SecureNotes the name control should display the text provided by the state`() {
mutableStateFlow.value =
VaultAddItemState(selectedType = VaultAddItemState.ItemType.SecureNotes())
mutableStateFlow.value = DEFAULT_STATE_SECURE_NOTES
composeTestRule.setContent {
VaultAddItemScreen(viewModel = viewModel, onNavigateBack = {})
@ -665,8 +660,7 @@ class VaultAddItemScreenTest : BaseComposeTest() {
@Test
fun `in ItemType_SecureNotes state clicking a Folder Option should send FolderChange action`() {
mutableStateFlow.value =
VaultAddItemState(selectedType = VaultAddItemState.ItemType.SecureNotes())
mutableStateFlow.value = DEFAULT_STATE_SECURE_NOTES
composeTestRule.setContent {
VaultAddItemScreen(viewModel = viewModel, onNavigateBack = {})
@ -694,8 +688,7 @@ class VaultAddItemScreenTest : BaseComposeTest() {
@Suppress("MaxLineLength")
@Test
fun `in ItemType_SecureNotes the folder control should display the text provided by the state`() {
mutableStateFlow.value =
VaultAddItemState(selectedType = VaultAddItemState.ItemType.SecureNotes())
mutableStateFlow.value = DEFAULT_STATE_SECURE_NOTES
composeTestRule.setContent {
VaultAddItemScreen(viewModel = viewModel, onNavigateBack = {})
@ -717,8 +710,7 @@ class VaultAddItemScreenTest : BaseComposeTest() {
@Suppress("MaxLineLength")
@Test
fun `in ItemType_SecureNotes state, toggling the favorite toggle should send ToggleFavorite action`() {
mutableStateFlow.value =
VaultAddItemState(selectedType = VaultAddItemState.ItemType.SecureNotes())
mutableStateFlow.value = DEFAULT_STATE_SECURE_NOTES
composeTestRule.setContent {
VaultAddItemScreen(viewModel = viewModel, onNavigateBack = {})
@ -740,8 +732,7 @@ class VaultAddItemScreenTest : BaseComposeTest() {
@Suppress("MaxLineLength")
@Test
fun `in ItemType_SecureNotes the favorite toggle should be enabled or disabled according to state`() {
mutableStateFlow.value =
VaultAddItemState(selectedType = VaultAddItemState.ItemType.SecureNotes())
mutableStateFlow.value = DEFAULT_STATE_SECURE_NOTES
composeTestRule.setContent {
VaultAddItemScreen(viewModel = viewModel, onNavigateBack = {})
@ -763,8 +754,7 @@ class VaultAddItemScreenTest : BaseComposeTest() {
@Suppress("MaxLineLength")
@Test
fun `in ItemType_SecureNotes state, toggling the Master password re-prompt toggle should send ToggleMasterPasswordReprompt action`() {
mutableStateFlow.value =
VaultAddItemState(selectedType = VaultAddItemState.ItemType.SecureNotes())
mutableStateFlow.value = DEFAULT_STATE_SECURE_NOTES
composeTestRule.setContent {
VaultAddItemScreen(viewModel = viewModel, onNavigateBack = {})
@ -788,8 +778,7 @@ class VaultAddItemScreenTest : BaseComposeTest() {
@Suppress("MaxLineLength")
@Test
fun `in ItemType_SecureNotes the master password re-prompt toggle should be enabled or disabled according to state`() {
mutableStateFlow.value =
VaultAddItemState(selectedType = VaultAddItemState.ItemType.SecureNotes())
mutableStateFlow.value = DEFAULT_STATE_SECURE_NOTES
composeTestRule.setContent {
VaultAddItemScreen(viewModel = viewModel, onNavigateBack = {})
@ -811,8 +800,7 @@ class VaultAddItemScreenTest : BaseComposeTest() {
@Suppress("MaxLineLength")
@Test
fun `in ItemType_SecureNotes state, toggling the Master password re-prompt tooltip button should send TooltipClick action`() {
mutableStateFlow.value =
VaultAddItemState(selectedType = VaultAddItemState.ItemType.SecureNotes())
mutableStateFlow.value = DEFAULT_STATE_SECURE_NOTES
composeTestRule.setContent {
VaultAddItemScreen(viewModel = viewModel, onNavigateBack = {})
@ -831,8 +819,7 @@ class VaultAddItemScreenTest : BaseComposeTest() {
@Test
fun `in ItemType_SecureNotes state changing Notes text field should trigger NotesTextChange`() {
mutableStateFlow.value =
VaultAddItemState(selectedType = VaultAddItemState.ItemType.SecureNotes())
mutableStateFlow.value = DEFAULT_STATE_SECURE_NOTES
composeTestRule.setContent {
VaultAddItemScreen(viewModel = viewModel, onNavigateBack = {})
@ -853,8 +840,7 @@ class VaultAddItemScreenTest : BaseComposeTest() {
@Suppress("MaxLineLength")
@Test
fun `in ItemType_SecureNotes the Notes control should display the text provided by the state`() {
mutableStateFlow.value =
VaultAddItemState(selectedType = VaultAddItemState.ItemType.SecureNotes())
mutableStateFlow.value = DEFAULT_STATE_SECURE_NOTES
composeTestRule.setContent {
VaultAddItemScreen(viewModel = viewModel, onNavigateBack = {})
@ -878,8 +864,7 @@ class VaultAddItemScreenTest : BaseComposeTest() {
@Suppress("MaxLineLength")
@Test
fun `in ItemType_SecureNotes state clicking New Custom Field button should trigger AddNewCustomFieldClick`() {
mutableStateFlow.value =
VaultAddItemState(selectedType = VaultAddItemState.ItemType.SecureNotes())
mutableStateFlow.value = DEFAULT_STATE_SECURE_NOTES
composeTestRule.setContent {
VaultAddItemScreen(viewModel = viewModel, onNavigateBack = {})
@ -899,8 +884,7 @@ class VaultAddItemScreenTest : BaseComposeTest() {
@Suppress("MaxLineLength")
@Test
fun `in ItemType_SecureNotes state clicking a Ownership option should send OwnershipChange action`() {
mutableStateFlow.value =
VaultAddItemState(selectedType = VaultAddItemState.ItemType.SecureNotes())
mutableStateFlow.value = DEFAULT_STATE_SECURE_NOTES
composeTestRule.setContent {
VaultAddItemScreen(viewModel = viewModel, onNavigateBack = {})
@ -928,8 +912,7 @@ class VaultAddItemScreenTest : BaseComposeTest() {
@Suppress("MaxLineLength")
@Test
fun `in ItemType_SecureNotes the Ownership control should display the text provided by the state`() {
mutableStateFlow.value =
VaultAddItemState(selectedType = VaultAddItemState.ItemType.SecureNotes())
mutableStateFlow.value = DEFAULT_STATE_SECURE_NOTES
composeTestRule.setContent {
VaultAddItemScreen(viewModel = viewModel, onNavigateBack = {})
@ -974,4 +957,16 @@ class VaultAddItemScreenTest : BaseComposeTest() {
}
//endregion Helper functions
companion object {
private val DEFAULT_STATE_LOGIN = VaultAddItemState(
selectedType = VaultAddItemState.ItemType.Login(),
dialog = null,
)
private val DEFAULT_STATE_SECURE_NOTES = VaultAddItemState(
selectedType = VaultAddItemState.ItemType.SecureNotes(),
dialog = null,
)
}
}

View file

@ -2,6 +2,7 @@ package com.x8bit.bitwarden.ui.vault.feature.additem
import androidx.lifecycle.SavedStateHandle
import app.cash.turbine.test
import com.x8bit.bitwarden.R
import com.x8bit.bitwarden.data.vault.repository.VaultRepository
import com.x8bit.bitwarden.data.vault.repository.model.CreateCipherResult
import com.x8bit.bitwarden.ui.platform.base.BaseViewModelTest
@ -39,7 +40,27 @@ class VaultAddItemViewModelTest : BaseViewModelTest() {
}
@Test
fun `SaveClick createCipher success should emit NavigateBack`() = runTest {
fun `SaveClick should show dialog, and remove it once an item is saved`() = runTest {
val stateWithDialog = createVaultAddLoginItemState(
dialogState = VaultAddItemState.DialogState.Loading(
R.string.saving.asText(),
),
)
val viewModel = createAddVaultItemViewModel()
coEvery {
vaultRepository.createCipher(any())
} returns CreateCipherResult.Success
viewModel.stateFlow.test {
viewModel.actionChannel.trySend(VaultAddItemAction.SaveClick)
assertEquals(initialState, awaitItem())
assertEquals(stateWithDialog, awaitItem())
assertEquals(initialState, awaitItem())
}
}
@Test
fun `SaveClick should update value to loading`() = runTest {
val viewModel = createAddVaultItemViewModel()
coEvery {
vaultRepository.createCipher(any())
@ -505,6 +526,7 @@ class VaultAddItemViewModelTest : BaseViewModelTest() {
masterPasswordReprompt: Boolean = false,
notes: String = "",
ownership: String = "placeholder@email.com",
dialogState: VaultAddItemState.DialogState? = null,
): VaultAddItemState =
VaultAddItemState(
selectedType = VaultAddItemState.ItemType.Login(
@ -518,6 +540,7 @@ class VaultAddItemViewModelTest : BaseViewModelTest() {
notes = notes,
ownership = ownership,
),
dialog = dialogState,
)
@Suppress("LongParameterList")
@ -538,6 +561,7 @@ class VaultAddItemViewModelTest : BaseViewModelTest() {
notes = notes,
ownership = ownership,
),
dialog = null,
)
private fun createSavedStateHandleWithState(state: VaultAddItemState) =

View file

@ -5,6 +5,8 @@ import com.bitwarden.core.CipherType
import com.bitwarden.core.CipherView
import com.bitwarden.core.LoginUriView
import com.bitwarden.core.LoginView
import com.bitwarden.core.SecureNoteType
import com.bitwarden.core.SecureNoteView
import com.bitwarden.core.UriMatchType
import com.x8bit.bitwarden.data.vault.datasource.sdk.model.createMockCipherView
import com.x8bit.bitwarden.data.vault.datasource.sdk.model.createMockFolderView
@ -163,4 +165,50 @@ class VaultDataExtensionsTest {
result,
)
}
@Test
fun `toCipherView should transform SecureNotes ItemType to CipherView`() {
mockkStatic(Instant::class)
every { Instant.now() } returns Instant.MIN
val secureNotesItemType = VaultAddItemState.ItemType.SecureNotes(
name = "mockName-1",
folderName = "mockFolder-1".asText(),
favorite = false,
masterPasswordReprompt = false,
notes = "mockNotes-1",
ownership = "mockOwnership-1",
)
val result = secureNotesItemType.toCipherView()
assertEquals(
CipherView(
id = null,
organizationId = null,
folderId = null,
collectionIds = emptyList(),
key = null,
name = "mockName-1",
notes = "mockNotes-1",
type = CipherType.SECURE_NOTE,
login = null,
identity = null,
card = null,
secureNote = SecureNoteView(SecureNoteType.GENERIC),
favorite = false,
reprompt = CipherRepromptType.NONE,
organizationUseTotp = false,
edit = true,
viewPassword = true,
localData = null,
attachments = null,
fields = null,
passwordHistory = null,
creationDate = Instant.MIN,
deletedDate = null,
revisionDate = Instant.MIN,
),
result,
)
}
}