mirror of
https://github.com/bitwarden/android.git
synced 2024-11-26 19:36:18 +03:00
BIT-1407: Allow users to add attachments (#782)
This commit is contained in:
parent
3635d368f9
commit
064b767b56
3 changed files with 367 additions and 4 deletions
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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 =
|
||||
|
|
|
@ -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 =
|
||||
|
|
Loading…
Reference in a new issue