Display attachments in the UI (#754)

This commit is contained in:
David Perez 2024-01-24 15:08:02 -06:00 committed by Álison Fernandes
parent be8608e53a
commit 89fda64baa
8 changed files with 853 additions and 26 deletions

View file

@ -0,0 +1,190 @@
package com.x8bit.bitwarden.ui.vault.feature.attachments
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.defaultMinSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import com.x8bit.bitwarden.R
import com.x8bit.bitwarden.ui.platform.base.util.bottomDivider
import com.x8bit.bitwarden.ui.platform.components.BitwardenFilledTonalButton
import com.x8bit.bitwarden.ui.platform.components.BitwardenListHeaderText
import com.x8bit.bitwarden.ui.platform.components.BitwardenTwoButtonDialog
import com.x8bit.bitwarden.ui.vault.feature.attachments.handlers.AttachmentsHandlers
/**
* The top level content UI state for the [AttachmentsScreen] when viewing a content.
*/
@Suppress("LongMethod")
@Composable
fun AttachmentsContent(
viewState: AttachmentsState.ViewState.Content,
attachmentsHandlers: AttachmentsHandlers,
modifier: Modifier = Modifier,
) {
LazyColumn(
modifier = modifier,
) {
if (viewState.attachments.isEmpty()) {
item {
Spacer(modifier = Modifier.height(8.dp))
Text(
text = stringResource(id = R.string.no_attachments),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurface,
textAlign = TextAlign.Center,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp),
)
Spacer(modifier = Modifier.height(8.dp))
}
} else {
items(viewState.attachments) {
AttachmentListEntry(
attachmentItem = it,
onDeleteClick = attachmentsHandlers.onDeleteClick,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp),
)
}
}
item {
Spacer(modifier = Modifier.height(36.dp))
BitwardenListHeaderText(
label = stringResource(id = R.string.add_new_attachment),
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp),
)
}
item {
Spacer(modifier = Modifier.height(16.dp))
Text(
text = stringResource(id = R.string.no_file_chosen),
color = MaterialTheme.colorScheme.onSurfaceVariant,
style = MaterialTheme.typography.bodySmall,
textAlign = TextAlign.Center,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp),
)
}
item {
Spacer(modifier = Modifier.height(8.dp))
BitwardenFilledTonalButton(
label = stringResource(id = R.string.choose_file),
onClick = attachmentsHandlers.onChooseFileClick,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp),
)
Spacer(modifier = Modifier.height(4.dp))
Text(
text = stringResource(id = R.string.max_file_size),
color = MaterialTheme.colorScheme.onSurfaceVariant,
style = MaterialTheme.typography.bodySmall,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 32.dp),
)
}
item {
Spacer(modifier = Modifier.navigationBarsPadding())
}
}
}
@Composable
private fun AttachmentListEntry(
attachmentItem: AttachmentsState.AttachmentItem,
onDeleteClick: (attachmentId: String) -> Unit,
modifier: Modifier = Modifier,
) {
var shouldShowDeleteDialog by rememberSaveable { mutableStateOf(false) }
if (shouldShowDeleteDialog) {
BitwardenTwoButtonDialog(
title = stringResource(id = R.string.delete),
message = stringResource(id = R.string.do_you_really_want_to_delete),
confirmButtonText = stringResource(id = R.string.delete),
dismissButtonText = stringResource(id = R.string.cancel),
onConfirmClick = {
shouldShowDeleteDialog = false
onDeleteClick(attachmentItem.id)
},
onDismissClick = { shouldShowDeleteDialog = false },
onDismissRequest = { shouldShowDeleteDialog = false },
)
}
Row(
modifier = Modifier
.bottomDivider(
paddingStart = 16.dp,
color = MaterialTheme.colorScheme.outlineVariant,
)
.defaultMinSize(minHeight = 56.dp)
.padding(vertical = 8.dp)
.then(modifier),
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = attachmentItem.title,
color = MaterialTheme.colorScheme.onSurface,
style = MaterialTheme.typography.bodyMedium,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f),
)
Spacer(modifier = Modifier.width(16.dp))
Text(
text = attachmentItem.displaySize,
color = MaterialTheme.colorScheme.onSurfaceVariant,
style = MaterialTheme.typography.labelSmall,
modifier = Modifier,
)
Spacer(modifier = Modifier.width(8.dp))
IconButton(
onClick = { shouldShowDeleteDialog = true },
modifier = Modifier,
) {
Icon(
painter = painterResource(id = R.drawable.ic_trash),
contentDescription = stringResource(id = R.string.delete),
tint = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.size(24.dp),
)
}
}
}

View file

@ -1,20 +1,14 @@
package com.x8bit.bitwarden.ui.vault.feature.attachments
import android.widget.Toast
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.material3.rememberTopAppBarState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.platform.LocalContext
@ -24,27 +18,45 @@ import androidx.hilt.navigation.compose.hiltViewModel
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.BitwardenErrorContent
import com.x8bit.bitwarden.ui.platform.components.BitwardenLoadingContent
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.NavigationIcon
import com.x8bit.bitwarden.ui.platform.manager.intent.IntentManager
import com.x8bit.bitwarden.ui.platform.theme.LocalIntentManager
import com.x8bit.bitwarden.ui.vault.feature.attachments.handlers.AttachmentsHandlers
/**
* Displays the attachments screen.
*/
@Suppress("LongMethod")
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun AttachmentsScreen(
viewModel: AttachmentsViewModel = hiltViewModel(),
intentManager: IntentManager = LocalIntentManager.current,
onNavigateBack: () -> Unit,
) {
val state by viewModel.stateFlow.collectAsStateWithLifecycle()
val attachmentsHandlers = remember(viewModel) { AttachmentsHandlers.create(viewModel) }
val fileChooserLauncher = intentManager.launchActivityForResult { activityResult ->
intentManager.getFileDataFromActivityResult(activityResult)?.let {
attachmentsHandlers.onFileChoose(it)
}
}
val context = LocalContext.current
EventsEffect(viewModel = viewModel) { event ->
when (event) {
AttachmentsEvent.NavigateBack -> onNavigateBack()
AttachmentsEvent.ShowChooserSheet -> {
fileChooserLauncher.launch(
intentManager.createFileChooserIntent(withCameraIntents = false),
)
}
is AttachmentsEvent.ShowToast -> {
Toast
.makeText(context, event.message(context.resources), Toast.LENGTH_SHORT)
@ -53,7 +65,6 @@ fun AttachmentsScreen(
}
}
val attachmentHandlers = remember(viewModel) { AttachmentsHandlers.create(viewModel) }
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior(rememberTopAppBarState())
BitwardenScaffold(
modifier = Modifier
@ -66,31 +77,35 @@ fun AttachmentsScreen(
navigationIcon = NavigationIcon(
navigationIcon = painterResource(id = R.drawable.ic_back),
navigationIconContentDescription = stringResource(id = R.string.back),
onNavigationIconClick = attachmentHandlers.onBackClick,
onNavigationIconClick = attachmentsHandlers.onBackClick,
),
actions = {
BitwardenTextButton(
label = stringResource(id = R.string.save),
onClick = attachmentHandlers.onSaveClick,
onClick = attachmentsHandlers.onSaveClick,
)
},
)
},
) { innerPadding ->
LazyColumn(
modifier = Modifier
.fillMaxSize()
.padding(innerPadding),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally,
) {
item {
Text(text = "Not Yet Implemented")
}
val modifier = Modifier
.fillMaxSize()
.padding(innerPadding)
when (val viewState = state.viewState) {
is AttachmentsState.ViewState.Content -> AttachmentsContent(
viewState = viewState,
attachmentsHandlers = attachmentsHandlers,
modifier = modifier,
)
item {
Spacer(modifier = Modifier.navigationBarsPadding())
}
is AttachmentsState.ViewState.Error -> BitwardenErrorContent(
message = viewState.message(),
modifier = modifier,
)
AttachmentsState.ViewState.Loading -> BitwardenLoadingContent(
modifier = modifier,
)
}
}
}

View file

@ -2,10 +2,22 @@ package com.x8bit.bitwarden.ui.vault.feature.attachments
import android.os.Parcelable
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.viewModelScope
import com.bitwarden.core.CipherView
import com.x8bit.bitwarden.R
import com.x8bit.bitwarden.data.platform.repository.model.DataState
import com.x8bit.bitwarden.data.vault.repository.VaultRepository
import com.x8bit.bitwarden.ui.platform.base.BaseViewModel
import com.x8bit.bitwarden.ui.platform.base.util.Text
import com.x8bit.bitwarden.ui.platform.base.util.asText
import com.x8bit.bitwarden.ui.platform.base.util.concat
import com.x8bit.bitwarden.ui.platform.manager.intent.IntentManager
import com.x8bit.bitwarden.ui.vault.feature.attachments.util.toViewState
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.update
import kotlinx.parcelize.Parcelize
import javax.inject.Inject
@ -16,18 +28,32 @@ private const val KEY_STATE = "state"
*/
@HiltViewModel
class AttachmentsViewModel @Inject constructor(
private val vaultRepo: VaultRepository,
savedStateHandle: SavedStateHandle,
) : BaseViewModel<AttachmentsState, AttachmentsEvent, AttachmentsAction>(
// We load the state from the savedStateHandle for testing purposes.
initialState = savedStateHandle[KEY_STATE]
?: AttachmentsState(
cipherId = AttachmentsArgs(savedStateHandle).cipherId,
viewState = AttachmentsState.ViewState.Loading,
),
) {
init {
vaultRepo
.getVaultItemStateFlow(state.cipherId)
.map { AttachmentsAction.Internal.CipherReceive(it) }
.onEach(::sendAction)
.launchIn(viewModelScope)
}
override fun handleAction(action: AttachmentsAction) {
when (action) {
AttachmentsAction.BackClick -> handleBackClick()
AttachmentsAction.SaveClick -> handleSaveClick()
AttachmentsAction.ChooseFileClick -> handleChooseFileClick()
is AttachmentsAction.FileChoose -> handleFileChoose(action)
is AttachmentsAction.DeleteClick -> handleDeleteClick(action)
is AttachmentsAction.Internal -> handleInternalAction(action)
}
}
@ -39,6 +65,83 @@ class AttachmentsViewModel @Inject constructor(
sendEvent(AttachmentsEvent.ShowToast("Not Yet Implemented".asText()))
// TODO: Handle saving the attachments (BIT-522)
}
private fun handleChooseFileClick() {
sendEvent(AttachmentsEvent.ShowChooserSheet)
}
private fun handleFileChoose(action: AttachmentsAction.FileChoose) {
sendEvent(AttachmentsEvent.ShowToast("Not Yet Implemented".asText()))
// TODO: Handle choosing a file the attachments (BIT-522)
}
private fun handleDeleteClick(action: AttachmentsAction.DeleteClick) {
sendEvent(AttachmentsEvent.ShowToast("Not Yet Implemented".asText()))
// TODO: Handle choosing a file the attachments (BIT-522)
}
private fun handleInternalAction(action: AttachmentsAction.Internal) {
when (action) {
is AttachmentsAction.Internal.CipherReceive -> handleCipherReceive(action)
}
}
private fun handleCipherReceive(action: AttachmentsAction.Internal.CipherReceive) {
when (val dataState = action.cipherDataState) {
is DataState.Error -> {
mutableStateFlow.update {
it.copy(
viewState = AttachmentsState.ViewState.Error(
message = R.string.generic_error_message.asText(),
),
)
}
}
is DataState.Loaded -> {
mutableStateFlow.update {
it.copy(
viewState = dataState
.data
?.toViewState()
?: AttachmentsState.ViewState.Error(
message = R.string.generic_error_message.asText(),
),
)
}
}
DataState.Loading -> {
mutableStateFlow.update {
it.copy(viewState = AttachmentsState.ViewState.Loading)
}
}
is DataState.NoNetwork -> mutableStateFlow.update {
it.copy(
viewState = AttachmentsState.ViewState.Error(
message = R.string.internet_connection_required_title
.asText()
.concat("\n".asText())
.concat(R.string.internet_connection_required_message.asText()),
),
)
}
is DataState.Pending -> {
mutableStateFlow.update {
it.copy(
viewState = dataState
.data
?.toViewState()
?: AttachmentsState.ViewState.Error(
message = R.string.generic_error_message.asText(),
),
)
}
}
}
}
}
/**
@ -47,7 +150,44 @@ class AttachmentsViewModel @Inject constructor(
@Parcelize
data class AttachmentsState(
val cipherId: String,
) : Parcelable
val viewState: ViewState,
) : Parcelable {
/**
* Represents the specific view states for the [AttachmentsScreen].
*/
sealed class ViewState : Parcelable {
/**
* Represents an error state for the [AttachmentsScreen].
*/
@Parcelize
data class Error(val message: Text) : ViewState()
/**
* Loading state for the [AttachmentsScreen], signifying that the content is being
* processed.
*/
@Parcelize
data object Loading : ViewState()
/**
* Represents a loaded content state for the [AttachmentsScreen].
*/
@Parcelize
data class Content(
val attachments: List<AttachmentItem>,
) : ViewState()
}
/**
* Represents an individual attachment that is already saved to the cipher.
*/
@Parcelize
data class AttachmentItem(
val id: String,
val title: String,
val displaySize: String,
) : Parcelable
}
/**
* Represents a set of events related attachments.
@ -58,6 +198,11 @@ sealed class AttachmentsEvent {
*/
data object NavigateBack : AttachmentsEvent()
/**
* Show chooser sheet.
*/
data object ShowChooserSheet : AttachmentsEvent()
/**
* Displays the given [message] as a toast.
*/
@ -79,4 +224,35 @@ sealed class AttachmentsAction {
* User clicked the save button.
*/
data object SaveClick : AttachmentsAction()
/**
* User clicked to select a new attachment file.
*/
data object ChooseFileClick : AttachmentsAction()
/**
* User has chosen the file attachment.
*/
data class FileChoose(
val fileData: IntentManager.FileData,
) : AttachmentsAction()
/**
* User clicked delete an attachment.
*/
data class DeleteClick(
val attachmentId: String,
) : AttachmentsAction()
/**
* Internal ViewModel actions.
*/
sealed class Internal : AttachmentsAction() {
/**
* The cipher data has been received.
*/
data class CipherReceive(
val cipherDataState: DataState<CipherView?>,
) : Internal()
}
}

View file

@ -1,5 +1,6 @@
package com.x8bit.bitwarden.ui.vault.feature.attachments.handlers
import com.x8bit.bitwarden.ui.platform.manager.intent.IntentManager
import com.x8bit.bitwarden.ui.vault.feature.attachments.AttachmentsAction
import com.x8bit.bitwarden.ui.vault.feature.attachments.AttachmentsViewModel
@ -9,6 +10,9 @@ import com.x8bit.bitwarden.ui.vault.feature.attachments.AttachmentsViewModel
data class AttachmentsHandlers(
val onBackClick: () -> Unit,
val onSaveClick: () -> Unit,
val onChooseFileClick: () -> Unit,
val onFileChoose: (IntentManager.FileData) -> Unit,
val onDeleteClick: (attachmentId: String) -> Unit,
) {
companion object {
/**
@ -19,6 +23,13 @@ data class AttachmentsHandlers(
AttachmentsHandlers(
onBackClick = { viewModel.trySendAction(AttachmentsAction.BackClick) },
onSaveClick = { viewModel.trySendAction(AttachmentsAction.SaveClick) },
onChooseFileClick = {
viewModel.trySendAction(AttachmentsAction.ChooseFileClick)
},
onFileChoose = { viewModel.trySendAction(AttachmentsAction.FileChoose(it)) },
onDeleteClick = {
viewModel.trySendAction(AttachmentsAction.DeleteClick(it))
},
)
}
}

View file

@ -0,0 +1,22 @@
package com.x8bit.bitwarden.ui.vault.feature.attachments.util
import com.bitwarden.core.CipherView
import com.x8bit.bitwarden.ui.vault.feature.attachments.AttachmentsState
/**
* Converts the [CipherView] into a [AttachmentsState.ViewState.Content].
*/
fun CipherView.toViewState(): AttachmentsState.ViewState.Content =
AttachmentsState.ViewState.Content(
attachments = this
.attachments
.orEmpty()
.mapNotNull {
val id = it.id ?: return@mapNotNull null
AttachmentsState.AttachmentItem(
id = id,
title = it.fileName.orEmpty(),
displaySize = it.sizeName.orEmpty(),
)
},
)

View file

@ -1,13 +1,30 @@
package com.x8bit.bitwarden.ui.vault.feature.attachments
import androidx.compose.ui.test.assert
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.filterToOne
import androidx.compose.ui.test.hasAnyAncestor
import androidx.compose.ui.test.hasClickAction
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.x8bit.bitwarden.data.platform.repository.util.bufferedMutableSharedFlow
import com.x8bit.bitwarden.ui.platform.base.BaseComposeTest
import com.x8bit.bitwarden.ui.platform.base.util.asText
import com.x8bit.bitwarden.ui.platform.manager.intent.IntentManager
import com.x8bit.bitwarden.ui.util.isProgressBar
import com.x8bit.bitwarden.ui.util.onNodeWithContentDescriptionAfterScroll
import com.x8bit.bitwarden.ui.util.onNodeWithTextAfterScroll
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 org.junit.Assert
import kotlinx.coroutines.flow.update
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
@ -16,17 +33,19 @@ class AttachmentsScreenTest : BaseComposeTest() {
private val mutableStateFlow = MutableStateFlow(DEFAULT_STATE)
private val mutableEventFlow = bufferedMutableSharedFlow<AttachmentsEvent>()
val viewModel: AttachmentsViewModel = mockk {
private val viewModel: AttachmentsViewModel = mockk {
every { stateFlow } returns mutableStateFlow
every { eventFlow } returns mutableEventFlow
every { trySendAction(any()) } just runs
}
private val intentManager: IntentManager = mockk(relaxed = true)
@Before
fun setup() {
composeTestRule.setContent {
AttachmentsScreen(
viewModel = viewModel,
intentManager = intentManager,
onNavigateBack = { onNavigateBackCalled = true },
)
}
@ -35,10 +54,163 @@ class AttachmentsScreenTest : BaseComposeTest() {
@Test
fun `NavigateBack should call onNavigateBack`() {
mutableEventFlow.tryEmit(AttachmentsEvent.NavigateBack)
Assert.assertTrue(onNavigateBackCalled)
assertTrue(onNavigateBackCalled)
}
@Test
fun `on back click should send BackClick`() {
composeTestRule.onNodeWithContentDescription("Back").performClick()
verify(exactly = 1) {
viewModel.trySendAction(AttachmentsAction.BackClick)
}
}
@Test
fun `on save click should send SaveClick`() {
composeTestRule.onNodeWithText("Save").performClick()
verify(exactly = 1) {
viewModel.trySendAction(AttachmentsAction.SaveClick)
}
}
@Test
fun `on choose file click should send ChooseFileClick`() {
mutableStateFlow.update {
it.copy(viewState = AttachmentsState.ViewState.Content(emptyList()))
}
composeTestRule.onNodeWithTextAfterScroll("Choose file").performClick()
verify(exactly = 1) {
viewModel.trySendAction(AttachmentsAction.ChooseFileClick)
}
}
@Test
fun `progressbar should be displayed according to state`() {
mutableStateFlow.update { it.copy(viewState = AttachmentsState.ViewState.Loading) }
composeTestRule.onNode(isProgressBar).assertIsDisplayed()
mutableStateFlow.update {
it.copy(viewState = AttachmentsState.ViewState.Error("Fail".asText()))
}
composeTestRule.onNode(isProgressBar).assertDoesNotExist()
mutableStateFlow.update {
it.copy(viewState = AttachmentsState.ViewState.Content(emptyList()))
}
composeTestRule.onNode(isProgressBar).assertDoesNotExist()
}
@Test
fun `error should be displayed according to state`() {
val errorMessage = "Fail"
mutableStateFlow.update {
it.copy(viewState = AttachmentsState.ViewState.Error(errorMessage.asText()))
}
composeTestRule.onNodeWithText(errorMessage).assertIsDisplayed()
mutableStateFlow.update { it.copy(viewState = AttachmentsState.ViewState.Loading) }
composeTestRule.onNodeWithText(errorMessage).assertDoesNotExist()
mutableStateFlow.update {
it.copy(viewState = AttachmentsState.ViewState.Content(emptyList()))
}
composeTestRule.onNodeWithText(errorMessage).assertDoesNotExist()
}
@Test
fun `content with no items should be displayed according to state`() {
mutableStateFlow.update {
it.copy(viewState = AttachmentsState.ViewState.Content(emptyList()))
}
composeTestRule
.onNodeWithTextAfterScroll("There are no attachments.")
.assertIsDisplayed()
}
@Test
fun `content with items should be displayed according to state`() {
mutableStateFlow.update {
it.copy(viewState = DEFAULT_CONTENT_WITH_ATTACHMENTS)
}
composeTestRule
.onNodeWithTextAfterScroll("cool_file.png")
.assertIsDisplayed()
}
@Test
fun `on delete click should display confirmation dialog`() {
mutableStateFlow.update {
it.copy(viewState = DEFAULT_CONTENT_WITH_ATTACHMENTS)
}
composeTestRule.onNode(isDialog()).assertDoesNotExist()
composeTestRule
.onNodeWithContentDescriptionAfterScroll("Delete")
.performClick()
// Title
composeTestRule
.onAllNodesWithText("Delete")
.filterToOne(!hasClickAction())
.assert(hasAnyAncestor(isDialog()))
.assertIsDisplayed()
// Description
composeTestRule
.onAllNodesWithText("Do you really want to delete? This cannot be undone.")
.filterToOne(!hasClickAction())
.assert(hasAnyAncestor(isDialog()))
.assertIsDisplayed()
// Cancel Button
composeTestRule
.onNodeWithText("Cancel")
.assert(hasClickAction())
.assert(hasAnyAncestor(isDialog()))
.assertIsDisplayed()
// Delete Button
composeTestRule
.onAllNodesWithText("Delete")
.filterToOne(hasClickAction())
.assert(hasAnyAncestor(isDialog()))
.assertIsDisplayed()
}
@Test
fun `on confirm delete click should send DeleteClick`() {
val cipherId = "cipherId-1234"
mutableStateFlow.update {
it.copy(viewState = DEFAULT_CONTENT_WITH_ATTACHMENTS)
}
composeTestRule
.onNodeWithContentDescriptionAfterScroll("Delete")
.performClick()
composeTestRule
.onAllNodesWithText("Delete")
.filterToOne(hasClickAction())
.assert(hasAnyAncestor(isDialog()))
.performClick()
verify(exactly = 1) {
viewModel.trySendAction(AttachmentsAction.DeleteClick(cipherId))
}
}
}
private val DEFAULT_STATE: AttachmentsState = AttachmentsState(
cipherId = "cipherId-1234",
viewState = AttachmentsState.ViewState.Loading,
)
private val DEFAULT_CONTENT_WITH_ATTACHMENTS: AttachmentsState.ViewState.Content =
AttachmentsState.ViewState.Content(
attachments = listOf(
AttachmentsState.AttachmentItem(
id = "cipherId-1234",
title = "cool_file.png",
displaySize = "10 MB",
),
),
)

View file

@ -2,13 +2,43 @@ package com.x8bit.bitwarden.ui.vault.feature.attachments
import androidx.lifecycle.SavedStateHandle
import app.cash.turbine.test
import com.bitwarden.core.CipherView
import com.x8bit.bitwarden.R
import com.x8bit.bitwarden.data.platform.repository.model.DataState
import com.x8bit.bitwarden.data.vault.datasource.sdk.model.createMockCipherView
import com.x8bit.bitwarden.data.vault.repository.VaultRepository
import com.x8bit.bitwarden.ui.platform.base.BaseViewModelTest
import com.x8bit.bitwarden.ui.platform.base.util.asText
import com.x8bit.bitwarden.ui.platform.base.util.concat
import com.x8bit.bitwarden.ui.platform.manager.intent.IntentManager
import com.x8bit.bitwarden.ui.vault.feature.attachments.util.toViewState
import io.mockk.every
import io.mockk.mockk
import io.mockk.mockkStatic
import io.mockk.unmockkStatic
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.AfterEach
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
class AttachmentsViewModelTest : BaseViewModelTest() {
private val mutableVaultItemStateFlow =
MutableStateFlow<DataState<CipherView?>>(DataState.Loading)
private val vaultRepository: VaultRepository = mockk {
every { getVaultItemStateFlow(any()) } returns mutableVaultItemStateFlow
}
@BeforeEach
fun setup() {
mockkStatic(CipherView::toViewState)
}
@AfterEach
fun tearDown() {
unmockkStatic(CipherView::toViewState)
}
@Test
fun `initial state should be correct when state is null`() = runTest {
@ -41,9 +71,146 @@ class AttachmentsViewModelTest : BaseViewModelTest() {
}
}
@Test
fun `ChooseFileClick should emit ShowToast`() = runTest {
val viewModel = createViewModel()
viewModel.eventFlow.test {
viewModel.trySendAction(AttachmentsAction.ChooseFileClick)
assertEquals(AttachmentsEvent.ShowChooserSheet, awaitItem())
}
}
@Test
fun `ChooseFile should emit ShowToast`() = runTest {
val fileData = mockk<IntentManager.FileData>()
val viewModel = createViewModel()
viewModel.eventFlow.test {
viewModel.trySendAction(AttachmentsAction.FileChoose(fileData))
assertEquals(AttachmentsEvent.ShowToast("Not Yet Implemented".asText()), awaitItem())
}
}
@Test
fun `DeleteClick should emit ShowToast`() = runTest {
val attachmentId = "attachmentId-1234"
val viewModel = createViewModel()
viewModel.eventFlow.test {
viewModel.trySendAction(AttachmentsAction.DeleteClick(attachmentId))
assertEquals(AttachmentsEvent.ShowToast("Not Yet Implemented".asText()), awaitItem())
}
}
@Test
fun `vaultItemStateFlow Error should update state to Error`() = runTest {
mutableVaultItemStateFlow.tryEmit(value = DataState.Error(Throwable("Fail")))
val viewModel = createViewModel()
assertEquals(
DEFAULT_STATE.copy(
viewState = AttachmentsState.ViewState.Error(
message = R.string.generic_error_message.asText(),
),
),
viewModel.stateFlow.value,
)
}
@Test
fun `vaultItemStateFlow Loaded with data should update state to Content`() = runTest {
val cipherView = createMockCipherView(number = 1)
every { cipherView.toViewState() } returns DEFAULT_CONTENT_WITH_ATTACHMENTS
mutableVaultItemStateFlow.tryEmit(DataState.Loaded(cipherView))
val viewModel = createViewModel()
assertEquals(
DEFAULT_STATE.copy(viewState = DEFAULT_CONTENT_WITH_ATTACHMENTS),
viewModel.stateFlow.value,
)
}
@Test
fun `vaultItemStateFlow Loaded without data should update state to Content`() = runTest {
mutableVaultItemStateFlow.tryEmit(DataState.Loaded(null))
val viewModel = createViewModel()
assertEquals(
DEFAULT_STATE.copy(
viewState = AttachmentsState.ViewState.Error(
message = R.string.generic_error_message.asText(),
),
),
viewModel.stateFlow.value,
)
}
@Test
fun `vaultItemStateFlow Loading should update state to Loading`() = runTest {
mutableVaultItemStateFlow.tryEmit(value = DataState.Loading)
val viewModel = createViewModel()
assertEquals(
DEFAULT_STATE.copy(viewState = AttachmentsState.ViewState.Loading),
viewModel.stateFlow.value,
)
}
@Test
fun `vaultItemStateFlow NoNetwork should update state to Error`() = runTest {
mutableVaultItemStateFlow.tryEmit(value = DataState.NoNetwork(null))
val viewModel = createViewModel()
assertEquals(
DEFAULT_STATE.copy(
viewState = AttachmentsState.ViewState.Error(
message = R.string.internet_connection_required_title
.asText()
.concat("\n".asText())
.concat(R.string.internet_connection_required_message.asText()),
),
),
viewModel.stateFlow.value,
)
}
@Test
fun `vaultItemStateFlow Pending with data should update state to Content`() = runTest {
val cipherView = createMockCipherView(number = 1)
every { cipherView.toViewState() } returns DEFAULT_CONTENT_WITH_ATTACHMENTS
mutableVaultItemStateFlow.tryEmit(DataState.Pending(cipherView))
val viewModel = createViewModel()
assertEquals(
DEFAULT_STATE.copy(viewState = DEFAULT_CONTENT_WITH_ATTACHMENTS),
viewModel.stateFlow.value,
)
}
@Test
fun `vaultItemStateFlow Pending without data should update state to Content`() = runTest {
mutableVaultItemStateFlow.tryEmit(DataState.Pending(null))
val viewModel = createViewModel()
assertEquals(
DEFAULT_STATE.copy(
viewState = AttachmentsState.ViewState.Error(
message = R.string.generic_error_message.asText(),
),
),
viewModel.stateFlow.value,
)
}
private fun createViewModel(
initialState: AttachmentsState? = null,
): AttachmentsViewModel = AttachmentsViewModel(
vaultRepo = vaultRepository,
savedStateHandle = SavedStateHandle().apply {
set("state", initialState)
set("cipher_id", initialState?.cipherId ?: "cipherId-1234")
@ -53,4 +220,16 @@ class AttachmentsViewModelTest : BaseViewModelTest() {
private val DEFAULT_STATE: AttachmentsState = AttachmentsState(
cipherId = "cipherId-1234",
viewState = AttachmentsState.ViewState.Loading,
)
private val DEFAULT_CONTENT_WITH_ATTACHMENTS: AttachmentsState.ViewState.Content =
AttachmentsState.ViewState.Content(
attachments = listOf(
AttachmentsState.AttachmentItem(
id = "cipherId-1234",
title = "cool_file.png",
displaySize = "10 MB",
),
),
)

View file

@ -0,0 +1,62 @@
package com.x8bit.bitwarden.ui.vault.feature.attachments.util
import com.x8bit.bitwarden.data.vault.datasource.sdk.model.createMockAttachmentView
import com.x8bit.bitwarden.data.vault.datasource.sdk.model.createMockCipherView
import com.x8bit.bitwarden.ui.vault.feature.attachments.AttachmentsState
import org.junit.Test
import org.junit.jupiter.api.Assertions.assertEquals
class CipherViewExtensionsTest {
@Test
fun `toViewState should return content with items when CipherView has attachments`() {
val cipherView = createMockCipherView(number = 1)
val result = cipherView.toViewState()
assertEquals(
AttachmentsState.ViewState.Content(
attachments = listOf(
AttachmentsState.AttachmentItem(
id = "mockId-1",
title = "mockFileName-1",
displaySize = "mockSizeName-1",
),
),
),
result,
)
}
@Test
fun `toViewState should return content without item when CipherView has no attachments`() {
val cipherView = createMockCipherView(number = 1).copy(
attachments = null,
)
val result = cipherView.toViewState()
assertEquals(
AttachmentsState.ViewState.Content(attachments = emptyList()),
result,
)
}
@Test
fun `toViewState should return content without items that have a null attachment ID`() {
val cipherView = createMockCipherView(number = 1).copy(
attachments = listOf(
createMockAttachmentView(number = 1).copy(
id = null,
),
),
)
val result = cipherView.toViewState()
assertEquals(
AttachmentsState.ViewState.Content(attachments = emptyList()),
result,
)
}
}