BIT-1407: Allow users to add attachments (#782)

This commit is contained in:
David Perez 2024-01-25 13:46:03 -06:00 committed by Álison Fernandes
parent 3635d368f9
commit 064b767b56
3 changed files with 367 additions and 4 deletions

View file

@ -6,8 +6,11 @@ import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.viewModelScope
import com.bitwarden.core.CipherView
import com.x8bit.bitwarden.R
import com.x8bit.bitwarden.data.auth.repository.AuthRepository
import com.x8bit.bitwarden.data.auth.repository.model.UserState
import com.x8bit.bitwarden.data.platform.repository.model.DataState
import com.x8bit.bitwarden.data.vault.repository.VaultRepository
import com.x8bit.bitwarden.data.vault.repository.model.CreateAttachmentResult
import com.x8bit.bitwarden.data.vault.repository.model.DeleteAttachmentResult
import com.x8bit.bitwarden.ui.platform.base.BaseViewModel
import com.x8bit.bitwarden.ui.platform.base.util.Text
@ -27,12 +30,18 @@ import javax.inject.Inject
private const val KEY_STATE = "state"
/**
* The maximum size an upload-able file is allowed to be (100 MiB).
*/
private const val MAX_FILE_SIZE_BYTES: Long = 100 * 1024 * 1024
/**
* ViewModel responsible for handling user interactions in the attachments screen.
*/
@Suppress("TooManyFunctions")
@HiltViewModel
class AttachmentsViewModel @Inject constructor(
private val authRepo: AuthRepository,
private val vaultRepo: VaultRepository,
savedStateHandle: SavedStateHandle,
) : BaseViewModel<AttachmentsState, AttachmentsEvent, AttachmentsAction>(
@ -42,6 +51,7 @@ class AttachmentsViewModel @Inject constructor(
cipherId = AttachmentsArgs(savedStateHandle).cipherId,
viewState = AttachmentsState.ViewState.Loading,
dialogState = null,
isPremiumUser = authRepo.userStateFlow.value?.activeAccount?.isPremium == true,
),
) {
init {
@ -50,6 +60,12 @@ class AttachmentsViewModel @Inject constructor(
.map { AttachmentsAction.Internal.CipherReceive(it) }
.onEach(::sendAction)
.launchIn(viewModelScope)
authRepo
.userStateFlow
.map { AttachmentsAction.Internal.UserStateReceive(it) }
.onEach(::sendAction)
.launchIn(viewModelScope)
}
override fun handleAction(action: AttachmentsAction) {
@ -69,8 +85,62 @@ class AttachmentsViewModel @Inject constructor(
}
private fun handleSaveClick() {
sendEvent(AttachmentsEvent.ShowToast("Not Yet Implemented".asText()))
// TODO: Handle saving the attachments (BIT-522)
onContent { content ->
if (!state.isPremiumUser) {
mutableStateFlow.update {
it.copy(
dialogState = AttachmentsState.DialogState.Error(
title = R.string.an_error_has_occurred.asText(),
message = R.string.premium_required.asText(),
),
)
}
return@onContent
}
if (content.newAttachment == null) {
mutableStateFlow.update {
it.copy(
dialogState = AttachmentsState.DialogState.Error(
title = R.string.an_error_has_occurred.asText(),
message = R.string.validation_field_required.asText(
R.string.file.asText(),
),
),
)
}
return@onContent
}
if (content.newAttachment.sizeBytes > MAX_FILE_SIZE_BYTES) {
// Must be under 100 MiB
mutableStateFlow.update {
it.copy(
dialogState = AttachmentsState.DialogState.Error(
title = R.string.an_error_has_occurred.asText(),
message = R.string.max_file_size.asText(),
),
)
}
return@onContent
}
mutableStateFlow.update {
it.copy(
dialogState = AttachmentsState.DialogState.Loading(
message = R.string.saving.asText(),
),
)
}
viewModelScope.launch {
val result = vaultRepo.createAttachment(
cipherId = state.cipherId,
cipherView = requireNotNull(content.originalCipher),
fileSizeBytes = content.newAttachment.sizeBytes.toString(),
fileName = content.newAttachment.displayName,
fileUri = content.newAttachment.uri,
)
sendAction(AttachmentsAction.Internal.CreateAttachmentResultReceive(result))
}
}
}
private fun handleDismissDialogClick() {
@ -117,7 +187,12 @@ class AttachmentsViewModel @Inject constructor(
private fun handleInternalAction(action: AttachmentsAction.Internal) {
when (action) {
is AttachmentsAction.Internal.CipherReceive -> handleCipherReceive(action)
is AttachmentsAction.Internal.CreateAttachmentResultReceive -> {
handleCreateAttachmentResultReceive(action)
}
is AttachmentsAction.Internal.DeleteResultReceive -> handleDeleteResultReceive(action)
is AttachmentsAction.Internal.UserStateReceive -> handleUserStateReceive(action)
}
}
@ -178,6 +253,28 @@ class AttachmentsViewModel @Inject constructor(
}
}
private fun handleCreateAttachmentResultReceive(
action: AttachmentsAction.Internal.CreateAttachmentResultReceive,
) {
when (action.result) {
CreateAttachmentResult.Error -> {
mutableStateFlow.update {
it.copy(
dialogState = AttachmentsState.DialogState.Error(
title = R.string.an_error_has_occurred.asText(),
message = R.string.generic_error_message.asText(),
),
)
}
}
is CreateAttachmentResult.Success -> {
mutableStateFlow.update { it.copy(dialogState = null) }
sendEvent(AttachmentsEvent.ShowToast(R.string.save_attachment_success.asText()))
}
}
}
private fun handleDeleteResultReceive(action: AttachmentsAction.Internal.DeleteResultReceive) {
when (action.result) {
DeleteAttachmentResult.Error -> {
@ -198,6 +295,12 @@ class AttachmentsViewModel @Inject constructor(
}
}
private fun handleUserStateReceive(action: AttachmentsAction.Internal.UserStateReceive) {
mutableStateFlow.update {
it.copy(isPremiumUser = action.userState?.activeAccount?.isPremium == true)
}
}
private inline fun onContent(
crossinline block: (AttachmentsState.ViewState.Content) -> Unit,
) {
@ -225,6 +328,7 @@ data class AttachmentsState(
val cipherId: String,
val viewState: ViewState,
val dialogState: DialogState?,
val isPremiumUser: Boolean,
) : Parcelable {
/**
* Represents the specific view states for the [AttachmentsScreen].
@ -369,11 +473,25 @@ sealed class AttachmentsAction {
val cipherDataState: DataState<CipherView?>,
) : Internal()
/**
* The result of creating an attachment.
*/
data class CreateAttachmentResultReceive(
val result: CreateAttachmentResult,
) : Internal()
/**
* The result of deleting the attachment.
*/
data class DeleteResultReceive(
val result: DeleteAttachmentResult,
) : Internal()
/**
* The updated user state.
*/
data class UserStateReceive(
val userState: UserState?,
) : Internal()
}
}

View file

@ -241,6 +241,7 @@ private val DEFAULT_STATE: AttachmentsState = AttachmentsState(
cipherId = "cipherId-1234",
viewState = AttachmentsState.ViewState.Loading,
dialogState = null,
isPremiumUser = false,
)
private val DEFAULT_CONTENT_WITHOUT_ATTACHMENTS: AttachmentsState.ViewState.Content =

View file

@ -5,9 +5,13 @@ import androidx.lifecycle.SavedStateHandle
import app.cash.turbine.test
import com.bitwarden.core.CipherView
import com.x8bit.bitwarden.R
import com.x8bit.bitwarden.data.auth.repository.AuthRepository
import com.x8bit.bitwarden.data.auth.repository.model.UserState
import com.x8bit.bitwarden.data.platform.repository.model.DataState
import com.x8bit.bitwarden.data.platform.repository.model.Environment
import com.x8bit.bitwarden.data.vault.datasource.sdk.model.createMockCipherView
import com.x8bit.bitwarden.data.vault.repository.VaultRepository
import com.x8bit.bitwarden.data.vault.repository.model.CreateAttachmentResult
import com.x8bit.bitwarden.data.vault.repository.model.DeleteAttachmentResult
import com.x8bit.bitwarden.ui.platform.base.BaseViewModelTest
import com.x8bit.bitwarden.ui.platform.base.util.asText
@ -15,6 +19,7 @@ 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.coEvery
import io.mockk.coVerify
import io.mockk.every
import io.mockk.mockk
import io.mockk.mockkStatic
@ -27,6 +32,10 @@ import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
class AttachmentsViewModelTest : BaseViewModelTest() {
private val mutableUserStateFlow = MutableStateFlow<UserState?>(null)
private val authRepository: AuthRepository = mockk {
every { userStateFlow } returns mutableUserStateFlow
}
private val mutableVaultItemStateFlow =
MutableStateFlow<DataState<CipherView?>>(DataState.Loading)
private val vaultRepository: VaultRepository = mockk {
@ -68,11 +77,216 @@ class AttachmentsViewModelTest : BaseViewModelTest() {
}
@Test
fun `SaveClick should emit ShowToast`() = runTest {
fun `SaveClick should display error dialog when user is not premium`() = runTest {
val cipherView = createMockCipherView(number = 1)
val state = DEFAULT_STATE.copy(viewState = DEFAULT_CONTENT_WITH_ATTACHMENTS)
mutableVaultItemStateFlow.tryEmit(DataState.Loaded(cipherView))
val viewModel = createViewModel()
viewModel.stateFlow.test {
assertEquals(state, awaitItem())
viewModel.trySendAction(AttachmentsAction.SaveClick)
assertEquals(
state.copy(
dialogState = AttachmentsState.DialogState.Error(
title = R.string.an_error_has_occurred.asText(),
message = R.string.premium_required.asText(),
),
),
awaitItem(),
)
}
}
@Test
fun `SaveClick should display error dialog when no file is selected`() = runTest {
val cipherView = createMockCipherView(number = 1)
val state = DEFAULT_STATE.copy(
viewState = DEFAULT_CONTENT_WITH_ATTACHMENTS,
isPremiumUser = true,
)
mutableVaultItemStateFlow.tryEmit(DataState.Loaded(cipherView))
mutableUserStateFlow.value = DEFAULT_USER_STATE
val viewModel = createViewModel()
viewModel.stateFlow.test {
assertEquals(state, awaitItem())
viewModel.trySendAction(AttachmentsAction.SaveClick)
assertEquals(
state.copy(
dialogState = AttachmentsState.DialogState.Error(
title = R.string.an_error_has_occurred.asText(),
message = R.string.validation_field_required.asText(R.string.file.asText()),
),
),
awaitItem(),
)
}
}
@Test
fun `SaveClick should display error dialog when file is too large`() = runTest {
val cipherView = createMockCipherView(number = 1)
val fileName = "test.png"
val uri = mockk<Uri>()
val sizeToBig = 104_857_601L
val state = DEFAULT_STATE.copy(
viewState = DEFAULT_CONTENT_WITH_ATTACHMENTS.copy(
newAttachment = AttachmentsState.NewAttachment(
displayName = fileName,
uri = uri,
sizeBytes = sizeToBig,
),
),
isPremiumUser = true,
)
val fileData = IntentManager.FileData(
fileName = fileName,
uri = uri,
sizeBytes = sizeToBig,
)
mutableVaultItemStateFlow.tryEmit(DataState.Loaded(cipherView))
mutableUserStateFlow.value = DEFAULT_USER_STATE
val viewModel = createViewModel()
// Need to populate the VM with a file
viewModel.trySendAction(AttachmentsAction.FileChoose(fileData))
viewModel.stateFlow.test {
assertEquals(state, awaitItem())
viewModel.trySendAction(AttachmentsAction.SaveClick)
assertEquals(
state.copy(
dialogState = AttachmentsState.DialogState.Error(
title = R.string.an_error_has_occurred.asText(),
message = R.string.max_file_size.asText(),
),
),
awaitItem(),
)
}
}
@Test
fun `SaveClick should display loading dialog and error dialog when createAttachment fails`() =
runTest {
val cipherView = createMockCipherView(number = 1)
val fileName = "test.png"
val uri = mockk<Uri>()
val sizeJustRight = 104_857_600L
val state = DEFAULT_STATE.copy(
viewState = DEFAULT_CONTENT_WITH_ATTACHMENTS.copy(
newAttachment = AttachmentsState.NewAttachment(
displayName = fileName,
uri = uri,
sizeBytes = sizeJustRight,
),
),
isPremiumUser = true,
)
val fileData = IntentManager.FileData(
fileName = fileName,
uri = uri,
sizeBytes = sizeJustRight,
)
mutableVaultItemStateFlow.tryEmit(DataState.Loaded(cipherView))
mutableUserStateFlow.value = DEFAULT_USER_STATE
coEvery {
vaultRepository.createAttachment(
cipherId = state.cipherId,
cipherView = cipherView,
fileSizeBytes = sizeJustRight.toString(),
fileName = fileName,
fileUri = uri,
)
} returns CreateAttachmentResult.Error
val viewModel = createViewModel()
// Need to populate the VM with a file
viewModel.trySendAction(AttachmentsAction.FileChoose(fileData))
viewModel.stateFlow.test {
assertEquals(state, awaitItem())
viewModel.trySendAction(AttachmentsAction.SaveClick)
assertEquals(
state.copy(
dialogState = AttachmentsState.DialogState.Loading(
message = R.string.saving.asText(),
),
),
awaitItem(),
)
assertEquals(
state.copy(
dialogState = AttachmentsState.DialogState.Error(
title = R.string.an_error_has_occurred.asText(),
message = R.string.generic_error_message.asText(),
),
),
awaitItem(),
)
}
coVerify(exactly = 1) {
vaultRepository.createAttachment(
cipherId = state.cipherId,
cipherView = cipherView,
fileSizeBytes = sizeJustRight.toString(),
fileName = fileName,
fileUri = uri,
)
}
}
@Test
fun `SaveClick should send ShowToast when createAttachment succeeds`() = runTest {
val cipherView = createMockCipherView(number = 1)
val fileName = "test.png"
val uri = mockk<Uri>()
val sizeJustRight = 104_857_600L
val state = DEFAULT_STATE.copy(
viewState = DEFAULT_CONTENT_WITH_ATTACHMENTS.copy(
newAttachment = AttachmentsState.NewAttachment(
displayName = fileName,
uri = uri,
sizeBytes = sizeJustRight,
),
),
isPremiumUser = true,
)
val fileData = IntentManager.FileData(
fileName = fileName,
uri = uri,
sizeBytes = sizeJustRight,
)
mutableVaultItemStateFlow.tryEmit(DataState.Loaded(cipherView))
mutableUserStateFlow.value = DEFAULT_USER_STATE
coEvery {
vaultRepository.createAttachment(
cipherId = state.cipherId,
cipherView = cipherView,
fileSizeBytes = sizeJustRight.toString(),
fileName = fileName,
fileUri = uri,
)
} returns CreateAttachmentResult.Success(cipherView)
val viewModel = createViewModel()
// Need to populate the VM with a file
viewModel.trySendAction(AttachmentsAction.FileChoose(fileData))
viewModel.eventFlow.test {
viewModel.trySendAction(AttachmentsAction.SaveClick)
assertEquals(AttachmentsEvent.ShowToast("Not Yet Implemented".asText()), awaitItem())
assertEquals(
AttachmentsEvent.ShowToast(R.string.save_attachment_success.asText()),
awaitItem(),
)
}
coVerify(exactly = 1) {
vaultRepository.createAttachment(
cipherId = state.cipherId,
cipherView = cipherView,
fileSizeBytes = sizeJustRight.toString(),
fileName = fileName,
fileUri = uri,
)
}
}
@ -296,9 +510,21 @@ class AttachmentsViewModelTest : BaseViewModelTest() {
)
}
@Test
fun `userStateFlow should update isPremiumUser state`() = runTest {
val viewModel = createViewModel()
mutableUserStateFlow.value = DEFAULT_USER_STATE
assertEquals(
DEFAULT_STATE.copy(isPremiumUser = true),
viewModel.stateFlow.value,
)
}
private fun createViewModel(
initialState: AttachmentsState? = null,
): AttachmentsViewModel = AttachmentsViewModel(
authRepo = authRepository,
vaultRepo = vaultRepository,
savedStateHandle = SavedStateHandle().apply {
set("state", initialState)
@ -307,10 +533,28 @@ class AttachmentsViewModelTest : BaseViewModelTest() {
)
}
private val DEFAULT_USER_STATE = UserState(
activeUserId = "mockUserId-1",
accounts = listOf(
UserState.Account(
userId = "mockUserId-1",
name = "Active User",
email = "active@bitwarden.com",
environment = Environment.Us,
avatarColorHex = "#aa00aa",
isPremium = true,
isLoggedIn = true,
isVaultUnlocked = true,
organizations = emptyList(),
),
),
)
private val DEFAULT_STATE: AttachmentsState = AttachmentsState(
cipherId = "mockId-1",
viewState = AttachmentsState.ViewState.Loading,
dialogState = null,
isPremiumUser = false,
)
private val DEFAULT_CONTENT_WITH_ATTACHMENTS: AttachmentsState.ViewState.Content =