From 064b767b566cc0518e0ff672ecaa578258260d78 Mon Sep 17 00:00:00 2001 From: David Perez Date: Thu, 25 Jan 2024 13:46:03 -0600 Subject: [PATCH] BIT-1407: Allow users to add attachments (#782) --- .../attachments/AttachmentsViewModel.kt | 122 ++++++++- .../attachments/AttachmentsScreenTest.kt | 1 + .../attachments/AttachmentsViewModelTest.kt | 248 +++++++++++++++++- 3 files changed, 367 insertions(+), 4 deletions(-) diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/attachments/AttachmentsViewModel.kt b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/attachments/AttachmentsViewModel.kt index a719460d4..6602671c1 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/attachments/AttachmentsViewModel.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/attachments/AttachmentsViewModel.kt @@ -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( @@ -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, ) : 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() } } diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/attachments/AttachmentsScreenTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/attachments/AttachmentsScreenTest.kt index 265690c4e..d04577fc4 100644 --- a/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/attachments/AttachmentsScreenTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/attachments/AttachmentsScreenTest.kt @@ -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 = diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/attachments/AttachmentsViewModelTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/attachments/AttachmentsViewModelTest.kt index 406236e0c..d3f9c5073 100644 --- a/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/attachments/AttachmentsViewModelTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/attachments/AttachmentsViewModelTest.kt @@ -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(null) + private val authRepository: AuthRepository = mockk { + every { userStateFlow } returns mutableUserStateFlow + } private val mutableVaultItemStateFlow = MutableStateFlow>(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() + 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() + 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() + 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 =