BIT-1527: Handle attachment downloads (#894)

This commit is contained in:
Sean Weiser 2024-01-31 22:55:10 -06:00 committed by Álison Fernandes
parent 6e945a4385
commit 8bb754f85b
37 changed files with 1956 additions and 171 deletions

View file

@ -53,15 +53,15 @@ class RetrofitsImpl(
//region Other Retrofits
override val staticRetrofitBuilder: Retrofit.Builder by lazy {
baseRetrofitBuilder
.client(
baseOkHttpClient
.newBuilder()
.addInterceptor(loggingInterceptor)
.build(),
)
}
override val staticRetrofitBuilder: Retrofit.Builder
get() =
baseRetrofitBuilder
.client(
baseOkHttpClient
.newBuilder()
.addInterceptor(loggingInterceptor)
.build(),
)
//endregion Other Retrofits

View file

@ -121,4 +121,13 @@ interface CiphersApi {
suspend fun getCipher(
@Path("cipherId") cipherId: String,
): Result<SyncResponseJson.Cipher>
/**
* Gets a cipher attachment.
*/
@GET("ciphers/{cipherId}/attachment/{attachmentId}")
suspend fun getCipherAttachment(
@Path("cipherId") cipherId: String,
@Path("attachmentId") attachmentId: String,
): Result<SyncResponseJson.Cipher.Attachment>
}

View file

@ -0,0 +1,20 @@
package com.x8bit.bitwarden.data.vault.datasource.network.api
import okhttp3.ResponseBody
import retrofit2.http.GET
import retrofit2.http.Streaming
import retrofit2.http.Url
/**
* Defines endpoints to retrieve content from arbitrary URLs.
*/
interface DownloadApi {
/**
* Streams data from a [url].
*/
@GET
@Streaming
suspend fun getDataStream(
@Url url: String,
): Result<ResponseBody>
}

View file

@ -3,6 +3,8 @@ package com.x8bit.bitwarden.data.vault.datasource.network.di
import com.x8bit.bitwarden.data.platform.datasource.network.retrofit.Retrofits
import com.x8bit.bitwarden.data.vault.datasource.network.service.CiphersService
import com.x8bit.bitwarden.data.vault.datasource.network.service.CiphersServiceImpl
import com.x8bit.bitwarden.data.vault.datasource.network.service.DownloadService
import com.x8bit.bitwarden.data.vault.datasource.network.service.DownloadServiceImpl
import com.x8bit.bitwarden.data.vault.datasource.network.service.FolderService
import com.x8bit.bitwarden.data.vault.datasource.network.service.FolderServiceImpl
import com.x8bit.bitwarden.data.vault.datasource.network.service.SendsService
@ -78,4 +80,17 @@ object VaultNetworkModule {
): SyncService = SyncServiceImpl(
syncApi = retrofits.authenticatedApiRetrofit.create(),
)
@Provides
@Singleton
fun provideDownloadService(
retrofits: Retrofits,
): DownloadService = DownloadServiceImpl(
downloadApi = retrofits
.staticRetrofitBuilder
// This URL will be overridden dynamically
.baseUrl("https://www.bitwarden.com")
.build()
.create(),
)
}

View file

@ -93,4 +93,12 @@ interface CiphersService {
* Attempt to retrieve a cipher.
*/
suspend fun getCipher(cipherId: String): Result<SyncResponseJson.Cipher>
/**
* Attempt to retrieve a cipher's attachment data.
*/
suspend fun getCipherAttachment(
cipherId: String,
attachmentId: String,
): Result<SyncResponseJson.Cipher.Attachment>
}

View file

@ -150,4 +150,13 @@ class CiphersServiceImpl(
cipherId: String,
): Result<SyncResponseJson.Cipher> =
ciphersApi.getCipher(cipherId = cipherId)
override suspend fun getCipherAttachment(
cipherId: String,
attachmentId: String,
): Result<SyncResponseJson.Cipher.Attachment> =
ciphersApi.getCipherAttachment(
cipherId = cipherId,
attachmentId = attachmentId,
)
}

View file

@ -0,0 +1,13 @@
package com.x8bit.bitwarden.data.vault.datasource.network.service
import okhttp3.ResponseBody
/**
* Provides an API for querying arbitrary endpoints.
*/
interface DownloadService {
/**
* Streams data from [url], returning a raw [ResponseBody].
*/
suspend fun getDataStream(url: String): Result<ResponseBody>
}

View file

@ -0,0 +1,16 @@
package com.x8bit.bitwarden.data.vault.datasource.network.service
import com.x8bit.bitwarden.data.vault.datasource.network.api.DownloadApi
import okhttp3.ResponseBody
/**
* Default implementation of [DownloadService].
*/
class DownloadServiceImpl(
private val downloadApi: DownloadApi,
) : DownloadService {
override suspend fun getDataStream(
url: String,
): Result<ResponseBody> =
downloadApi.getDataStream(url = url)
}

View file

@ -1,5 +1,6 @@
package com.x8bit.bitwarden.data.vault.datasource.sdk
import com.bitwarden.core.Attachment
import com.bitwarden.core.AttachmentEncryptResult
import com.bitwarden.core.AttachmentView
import com.bitwarden.core.Cipher
@ -274,6 +275,18 @@ interface VaultSdkSource {
folderList: List<Folder>,
): Result<List<FolderView>>
/**
* Decrypts a [cipher] [attachment] file found at [encryptedFilePath] saving it at
* [decryptedFilePath] for the user with the given [userId]
*/
suspend fun decryptFile(
userId: String,
cipher: Cipher,
attachment: Attachment,
encryptedFilePath: String,
decryptedFilePath: String,
): Result<Unit>
/**
* Encrypts a given password history item for the user with the given [userId].
*

View file

@ -1,5 +1,6 @@
package com.x8bit.bitwarden.data.vault.datasource.sdk
import com.bitwarden.core.Attachment
import com.bitwarden.core.AttachmentEncryptResult
import com.bitwarden.core.AttachmentView
import com.bitwarden.core.Cipher
@ -284,6 +285,25 @@ class VaultSdkSourceImpl(
.decryptList(folderList)
}
override suspend fun decryptFile(
userId: String,
cipher: Cipher,
attachment: Attachment,
encryptedFilePath: String,
decryptedFilePath: String,
): Result<Unit> =
runCatching {
getClient(userId = userId)
.vault()
.attachments()
.decryptFile(
cipher = cipher,
attachment = attachment,
encryptedFilePath = encryptedFilePath,
decryptedFilePath = decryptedFilePath,
)
}
override suspend fun encryptPasswordHistory(
userId: String,
passwordHistory: PasswordHistoryView,

View file

@ -2,6 +2,8 @@ package com.x8bit.bitwarden.data.vault.manager
import android.net.Uri
import com.x8bit.bitwarden.data.platform.annotation.OmitFromCoverage
import com.x8bit.bitwarden.data.vault.manager.model.DownloadResult
import java.io.File
/**
* Manages reading files.
@ -9,6 +11,23 @@ import com.x8bit.bitwarden.data.platform.annotation.OmitFromCoverage
@OmitFromCoverage
interface FileManager {
/**
* Deletes a [file] from the system.
*/
suspend fun deleteFile(file: File)
/**
* Downloads a file temporarily to cache from [url]. A successful [DownloadResult] will contain
* the final file path.
*/
suspend fun downloadFileToCache(url: String): DownloadResult
/**
* Writes an existing [file] to a [fileUri]. `true` will be returned if the file was
* successfully saved.
*/
suspend fun fileToUri(fileUri: Uri, file: File): Boolean
/**
* Reads the [fileUri] into memory and returns the raw [ByteArray]
*/

View file

@ -3,7 +3,14 @@ package com.x8bit.bitwarden.data.vault.manager
import android.content.Context
import android.net.Uri
import com.x8bit.bitwarden.data.platform.annotation.OmitFromCoverage
import com.x8bit.bitwarden.data.vault.datasource.network.service.DownloadService
import com.x8bit.bitwarden.data.vault.manager.model.DownloadResult
import okio.use
import java.io.ByteArrayOutputStream
import java.io.File
import java.io.FileInputStream
import java.io.FileOutputStream
import java.util.UUID
/**
* The buffer size to be used when reading from an input stream.
@ -16,8 +23,69 @@ private const val BUFFER_SIZE: Int = 1024
@OmitFromCoverage
class FileManagerImpl(
private val context: Context,
private val downloadService: DownloadService,
) : FileManager {
override suspend fun deleteFile(file: File) {
file.delete()
}
@Suppress("NestedBlockDepth", "ReturnCount")
override suspend fun downloadFileToCache(url: String): DownloadResult {
val response = downloadService
.getDataStream(url)
.fold(
onSuccess = { it },
onFailure = { return DownloadResult.Failure },
)
// Create a temporary file in cache to write to
val file = File(context.cacheDir, UUID.randomUUID().toString())
val stream = response.byteStream()
stream.use {
val buffer = ByteArray(BUFFER_SIZE)
var progress = 0
FileOutputStream(file).use { fos ->
@Suppress("TooGenericExceptionCaught")
try {
var read = stream.read(buffer)
while (read > 0) {
fos.write(buffer, 0, read)
progress += read
read = stream.read(buffer)
}
fos.flush()
} catch (e: RuntimeException) {
return DownloadResult.Failure
}
}
}
return DownloadResult.Success(file)
}
@Suppress("NestedBlockDepth")
override suspend fun fileToUri(fileUri: Uri, file: File): Boolean {
@Suppress("TooGenericExceptionCaught")
return try {
context
.contentResolver
.openOutputStream(fileUri)
?.use { outputStream ->
FileInputStream(file).use { inputStream ->
val buffer = ByteArray(BUFFER_SIZE)
var length: Int
while (inputStream.read(buffer).also { length = it } != -1) {
outputStream.write(buffer, 0, length)
}
}
}
true
} catch (exception: RuntimeException) {
false
}
}
override fun uriToByteArray(fileUri: Uri): ByteArray =
context
.contentResolver

View file

@ -7,6 +7,7 @@ import com.x8bit.bitwarden.data.auth.manager.UserLogoutManager
import com.x8bit.bitwarden.data.platform.manager.AppForegroundManager
import com.x8bit.bitwarden.data.platform.manager.dispatcher.DispatcherManager
import com.x8bit.bitwarden.data.platform.repository.SettingsRepository
import com.x8bit.bitwarden.data.vault.datasource.network.service.DownloadService
import com.x8bit.bitwarden.data.vault.datasource.sdk.VaultSdkSource
import com.x8bit.bitwarden.data.vault.manager.FileManager
import com.x8bit.bitwarden.data.vault.manager.FileManagerImpl
@ -33,7 +34,11 @@ object VaultManagerModule {
@Singleton
fun provideFileManager(
@ApplicationContext context: Context,
): FileManager = FileManagerImpl(context)
downloadService: DownloadService,
): FileManager = FileManagerImpl(
context = context,
downloadService = downloadService,
)
@Provides
@Singleton

View file

@ -0,0 +1,18 @@
package com.x8bit.bitwarden.data.vault.manager.model
import java.io.File
/**
* Represents a result from downloading a raw file.
*/
sealed class DownloadResult {
/**
* The download was a success, and was saved to [file].
*/
data class Success(val file: File) : DownloadResult()
/**
* The download failed.
*/
data object Failure : DownloadResult()
}

View file

@ -20,6 +20,7 @@ import com.x8bit.bitwarden.data.vault.repository.model.DeleteCipherResult
import com.x8bit.bitwarden.data.vault.repository.model.DeleteFolderResult
import com.x8bit.bitwarden.data.vault.repository.model.DeleteSendResult
import com.x8bit.bitwarden.data.vault.repository.model.DomainsData
import com.x8bit.bitwarden.data.vault.repository.model.DownloadAttachmentResult
import com.x8bit.bitwarden.data.vault.repository.model.GenerateTotpResult
import com.x8bit.bitwarden.data.vault.repository.model.RemovePasswordSendResult
import com.x8bit.bitwarden.data.vault.repository.model.RestoreCipherResult
@ -219,6 +220,15 @@ interface VaultRepository : VaultLockManager {
fileUri: Uri,
): CreateAttachmentResult
/**
* Attempt to download an attachment file, specified by [attachmentId], for the given
* [cipherView].
*/
suspend fun downloadAttachment(
cipherView: CipherView,
attachmentId: String,
): DownloadAttachmentResult
/**
* Attempt to delete a cipher.
*/

View file

@ -50,6 +50,7 @@ import com.x8bit.bitwarden.data.vault.datasource.sdk.VaultSdkSource
import com.x8bit.bitwarden.data.vault.manager.FileManager
import com.x8bit.bitwarden.data.vault.manager.TotpCodeManager
import com.x8bit.bitwarden.data.vault.manager.VaultLockManager
import com.x8bit.bitwarden.data.vault.manager.model.DownloadResult
import com.x8bit.bitwarden.data.vault.manager.model.VerificationCodeItem
import com.x8bit.bitwarden.data.vault.repository.model.CreateAttachmentResult
import com.x8bit.bitwarden.data.vault.repository.model.CreateCipherResult
@ -60,6 +61,7 @@ import com.x8bit.bitwarden.data.vault.repository.model.DeleteCipherResult
import com.x8bit.bitwarden.data.vault.repository.model.DeleteFolderResult
import com.x8bit.bitwarden.data.vault.repository.model.DeleteSendResult
import com.x8bit.bitwarden.data.vault.repository.model.DomainsData
import com.x8bit.bitwarden.data.vault.repository.model.DownloadAttachmentResult
import com.x8bit.bitwarden.data.vault.repository.model.GenerateTotpResult
import com.x8bit.bitwarden.data.vault.repository.model.RemovePasswordSendResult
import com.x8bit.bitwarden.data.vault.repository.model.RestoreCipherResult
@ -109,6 +111,7 @@ import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import retrofit2.HttpException
import java.io.File
import java.time.Clock
import java.time.Instant
import java.time.temporal.ChronoUnit
@ -871,6 +874,63 @@ class VaultRepositoryImpl(
)
}
@Suppress("ReturnCount")
override suspend fun downloadAttachment(
cipherView: CipherView,
attachmentId: String,
): DownloadAttachmentResult {
val userId = requireNotNull(authDiskSource.userState?.activeUserId)
val cipher = vaultSdkSource
.encryptCipher(
userId = userId,
cipherView = cipherView,
)
.fold(
onSuccess = { it },
onFailure = { return DownloadAttachmentResult.Failure },
)
val attachment = cipher.attachments?.find { it.id == attachmentId }
?: return DownloadAttachmentResult.Failure
val attachmentData = ciphersService
.getCipherAttachment(
cipherId = requireNotNull(cipher.id),
attachmentId = attachmentId,
)
.fold(
onSuccess = { it },
onFailure = { return DownloadAttachmentResult.Failure },
)
val url = attachmentData.url ?: return DownloadAttachmentResult.Failure
val encryptedFile = when (val result = fileManager.downloadFileToCache(url)) {
DownloadResult.Failure -> return DownloadAttachmentResult.Failure
is DownloadResult.Success -> result.file
}
val decryptedFile = File(encryptedFile.path + "_decrypted")
return vaultSdkSource
.decryptFile(
userId = userId,
cipher = cipher,
attachment = attachment,
encryptedFilePath = encryptedFile.path,
decryptedFilePath = decryptedFile.path,
)
.fold(
onSuccess = {
encryptedFile.delete()
DownloadAttachmentResult.Success(decryptedFile)
},
onFailure = {
encryptedFile.delete()
DownloadAttachmentResult.Failure
},
)
}
override suspend fun createSend(
sendView: SendView,
fileUri: Uri?,

View file

@ -0,0 +1,18 @@
package com.x8bit.bitwarden.data.vault.repository.model
import java.io.File
/**
* Represents the overall result when attempting to download an attachment.
*/
sealed class DownloadAttachmentResult {
/**
* The attachment was successfully downloaded and saved to [file].
*/
data class Success(val file: File) : DownloadAttachmentResult()
/**
* The attachment could not be downloaded.
*/
data object Failure : DownloadAttachmentResult()
}

View file

@ -75,6 +75,11 @@ interface IntentManager {
*/
fun createFileChooserIntent(withCameraIntents: Boolean): Intent
/**
* Creates an intent to use when selecting to save an attachment with [fileName] to disk.
*/
fun createAttachmentChooserIntent(fileName: String): Intent
/**
* Represents file information.
*/

View file

@ -9,6 +9,7 @@ import android.content.pm.PackageManager
import android.net.Uri
import android.provider.MediaStore
import android.provider.Settings
import android.webkit.MimeTypeMap
import androidx.activity.compose.ManagedActivityResultLauncher
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.ActivityResult
@ -171,6 +172,19 @@ class IntentManagerImpl(
return chooserIntent
}
override fun createAttachmentChooserIntent(fileName: String): Intent =
Intent(Intent.ACTION_CREATE_DOCUMENT).apply {
// Attempt to get the MIME type from the file extension
val extension = MimeTypeMap.getFileExtensionFromUrl(fileName)
type = extension?.let {
MimeTypeMap.getSingleton().getMimeTypeFromExtension(it)
}
?: "*/*"
addCategory(Intent.CATEGORY_OPENABLE)
putExtra(Intent.EXTRA_TITLE, fileName)
}
private fun getCameraFileData(): IntentManager.FileData {
val tmpDir = File(context.filesDir, TEMP_CAMERA_IMAGE_DIR)
val file = File(tmpDir, TEMP_CAMERA_IMAGE_NAME)

View file

@ -0,0 +1,128 @@
package com.x8bit.bitwarden.ui.vault.feature.item
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.defaultMinSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.material3.AlertDialog
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.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.BitwardenTextButton
import com.x8bit.bitwarden.ui.platform.components.BitwardenTwoButtonDialog
/**
* Attachment UI common for all item types.
*/
@Suppress("LongMethod")
@Composable
fun AttachmentItemContent(
attachmentItem: VaultItemState.ViewState.Content.Common.AttachmentItem,
onAttachmentDownloadClick: (VaultItemState.ViewState.Content.Common.AttachmentItem) -> Unit,
modifier: Modifier = Modifier,
) {
var shouldShowPremiumWarningDialog by rememberSaveable { mutableStateOf(false) }
var shouldShowSizeWarningDialog by rememberSaveable { mutableStateOf(false) }
Row(
modifier = modifier
.bottomDivider(
color = MaterialTheme.colorScheme.outlineVariant,
)
.defaultMinSize(minHeight = 56.dp)
.padding(vertical = 8.dp),
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 = {
if (!attachmentItem.isDownloadAllowed) {
shouldShowPremiumWarningDialog = true
return@IconButton
}
if (attachmentItem.isLargeFile) {
shouldShowSizeWarningDialog = true
return@IconButton
}
onAttachmentDownloadClick(attachmentItem)
},
modifier = Modifier,
) {
Icon(
painter = painterResource(id = R.drawable.ic_download),
contentDescription = stringResource(id = R.string.download),
tint = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.size(24.dp),
)
}
}
if (shouldShowPremiumWarningDialog) {
AlertDialog(
onDismissRequest = { shouldShowPremiumWarningDialog = false },
confirmButton = {
BitwardenTextButton(
label = stringResource(R.string.ok),
onClick = { shouldShowPremiumWarningDialog = false },
)
},
text = {
Text(
text = stringResource(R.string.premium_required),
style = MaterialTheme.typography.bodyMedium,
)
},
containerColor = MaterialTheme.colorScheme.surfaceContainerHigh,
)
}
if (shouldShowSizeWarningDialog) {
BitwardenTwoButtonDialog(
title = null,
message = stringResource(R.string.attachment_large_warning, attachmentItem.displaySize),
confirmButtonText = stringResource(R.string.yes),
dismissButtonText = stringResource(R.string.no),
onConfirmClick = {
shouldShowSizeWarningDialog = false
onAttachmentDownloadClick(attachmentItem)
},
onDismissClick = { shouldShowSizeWarningDialog = false },
onDismissRequest = { shouldShowSizeWarningDialog = false },)
}
}

View file

@ -202,6 +202,29 @@ fun VaultItemCardContent(
}
}
commonState.attachments.takeUnless { it?.isEmpty() == true }?.let { attachments ->
item {
Spacer(modifier = Modifier.height(4.dp))
BitwardenListHeaderText(
label = stringResource(id = R.string.attachments),
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp),
)
}
items(attachments) { attachmentItem ->
AttachmentItemContent(
modifier = Modifier
.fillMaxWidth()
.padding(start = 16.dp),
attachmentItem = attachmentItem,
onAttachmentDownloadClick =
vaultCommonItemTypeHandlers.onAttachmentDownloadClick,
)
}
item { Spacer(modifier = Modifier.height(8.dp)) }
}
item {
Spacer(modifier = Modifier.height(24.dp))
VaultItemUpdateText(

View file

@ -232,6 +232,29 @@ fun VaultItemIdentityContent(
}
}
commonState.attachments.takeUnless { it?.isEmpty() == true }?.let { attachments ->
item {
Spacer(modifier = Modifier.height(4.dp))
BitwardenListHeaderText(
label = stringResource(id = R.string.attachments),
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp),
)
}
items(attachments) { attachmentItem ->
AttachmentItemContent(
modifier = Modifier
.fillMaxWidth()
.padding(start = 16.dp),
attachmentItem = attachmentItem,
onAttachmentDownloadClick =
vaultCommonItemTypeHandlers.onAttachmentDownloadClick,
)
}
item { Spacer(modifier = Modifier.height(8.dp)) }
}
item {
Spacer(modifier = Modifier.height(24.dp))
VaultItemUpdateText(

View file

@ -178,6 +178,29 @@ fun VaultItemLoginContent(
}
}
commonState.attachments.takeUnless { it?.isEmpty() == true }?.let { attachments ->
item {
Spacer(modifier = Modifier.height(4.dp))
BitwardenListHeaderText(
label = stringResource(id = R.string.attachments),
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp),
)
}
items(attachments) { attachmentItem ->
AttachmentItemContent(
modifier = Modifier
.fillMaxWidth()
.padding(start = 16.dp),
attachmentItem = attachmentItem,
onAttachmentDownloadClick =
vaultCommonItemTypeHandlers.onAttachmentDownloadClick,
)
}
item { Spacer(modifier = Modifier.height(8.dp)) }
}
item {
Spacer(modifier = Modifier.height(24.dp))
VaultItemUpdateText(

View file

@ -53,7 +53,7 @@ import com.x8bit.bitwarden.ui.vault.feature.item.handlers.VaultLoginItemTypeHand
/**
* Displays the vault item screen.
*/
@Suppress("LongMethod")
@Suppress("LongMethod", "CyclomaticComplexMethod")
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun VaultItemScreen(
@ -76,6 +76,16 @@ fun VaultItemScreen(
var pendingDeleteCipher by rememberSaveable { mutableStateOf(false) }
var pendingRestoreCipher by rememberSaveable { mutableStateOf(false) }
val fileChooserLauncher = intentManager.getActivityResultLauncher { activityResult ->
intentManager.getFileDataFromActivityResult(activityResult)
?.let {
viewModel.trySendAction(
VaultItemAction.Common.AttachmentFileLocationReceive(it.uri),
)
}
?: viewModel.trySendAction(VaultItemAction.Common.NoAttachmentFileLocationReceive)
}
EventsEffect(viewModel = viewModel) { event ->
when (event) {
VaultItemEvent.NavigateBack -> onNavigateBack()
@ -104,6 +114,12 @@ fun VaultItemScreen(
is VaultItemEvent.ShowToast -> {
Toast.makeText(context, event.message(resources), Toast.LENGTH_SHORT).show()
}
is VaultItemEvent.NavigateToSelectAttachmentSaveLocation -> {
fileChooserLauncher.launch(
intentManager.createAttachmentChooserIntent(event.fileName),
)
}
}
}

View file

@ -103,6 +103,29 @@ fun VaultItemSecureNoteContent(
}
}
commonState.attachments.takeUnless { it?.isEmpty() == true }?.let { attachments ->
item {
Spacer(modifier = Modifier.height(4.dp))
BitwardenListHeaderText(
label = stringResource(id = R.string.attachments),
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp),
)
}
items(attachments) { attachmentItem ->
AttachmentItemContent(
modifier = Modifier
.fillMaxWidth()
.padding(start = 16.dp),
attachmentItem = attachmentItem,
onAttachmentDownloadClick =
vaultCommonItemTypeHandlers.onAttachmentDownloadClick,
)
}
item { Spacer(modifier = Modifier.height(8.dp)) }
}
item {
Spacer(modifier = Modifier.height(24.dp))
Row(

View file

@ -1,5 +1,6 @@
package com.x8bit.bitwarden.ui.vault.feature.item
import android.net.Uri
import android.os.Parcelable
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.viewModelScope
@ -12,8 +13,10 @@ import com.x8bit.bitwarden.data.auth.repository.model.ValidatePasswordResult
import com.x8bit.bitwarden.data.platform.manager.clipboard.BitwardenClipboardManager
import com.x8bit.bitwarden.data.platform.repository.model.DataState
import com.x8bit.bitwarden.data.platform.repository.util.combineDataStates
import com.x8bit.bitwarden.data.vault.manager.FileManager
import com.x8bit.bitwarden.data.vault.repository.VaultRepository
import com.x8bit.bitwarden.data.vault.repository.model.DeleteCipherResult
import com.x8bit.bitwarden.data.vault.repository.model.DownloadAttachmentResult
import com.x8bit.bitwarden.data.vault.repository.model.RestoreCipherResult
import com.x8bit.bitwarden.ui.platform.base.BaseViewModel
import com.x8bit.bitwarden.ui.platform.base.util.Text
@ -32,9 +35,11 @@ import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.parcelize.IgnoredOnParcel
import kotlinx.parcelize.Parcelize
import java.io.File
import javax.inject.Inject
private const val KEY_STATE = "state"
private const val KEY_TEMP_ATTACHMENT = "tempAttachmentFile"
/**
* ViewModel responsible for handling user interactions in the vault item screen
@ -42,10 +47,11 @@ private const val KEY_STATE = "state"
@Suppress("LargeClass", "TooManyFunctions")
@HiltViewModel
class VaultItemViewModel @Inject constructor(
savedStateHandle: SavedStateHandle,
private val savedStateHandle: SavedStateHandle,
private val clipboardManager: BitwardenClipboardManager,
private val authRepository: AuthRepository,
private val vaultRepository: VaultRepository,
private val fileManager: FileManager,
) : BaseViewModel<VaultItemState, VaultItemEvent, VaultItemAction>(
// We load the state from the savedStateHandle for testing purposes.
initialState = savedStateHandle[KEY_STATE] ?: VaultItemState(
@ -54,6 +60,14 @@ class VaultItemViewModel @Inject constructor(
dialog = null,
),
) {
/**
* Reference to a temporary attachment saved in cache.
*/
private var temporaryAttachmentData: File?
get() = savedStateHandle[KEY_TEMP_ATTACHMENT]
set(value) {
savedStateHandle[KEY_TEMP_ATTACHMENT] = value
}
//region Initialization and Overrides
init {
@ -118,6 +132,18 @@ class VaultItemViewModel @Inject constructor(
handleHiddenFieldVisibilityClicked(action)
}
is VaultItemAction.Common.AttachmentDownloadClick -> {
handleAttachmentDownloadClick(action)
}
is VaultItemAction.Common.AttachmentFileLocationReceive -> {
handleAttachmentFileLocationReceive(action)
}
is VaultItemAction.Common.NoAttachmentFileLocationReceive -> {
handleNoAttachmentFileLocationReceive()
}
is VaultItemAction.Common.AttachmentsClick -> handleAttachmentsClick()
is VaultItemAction.Common.CloneClick -> handleCloneClick()
is VaultItemAction.Common.MoveToOrganizationClick -> handleMoveToOrganizationClick()
@ -230,6 +256,83 @@ class VaultItemViewModel @Inject constructor(
}
}
private fun handleAttachmentDownloadClick(
action: VaultItemAction.Common.AttachmentDownloadClick,
) {
onContent { content ->
if (content.common.requiresReprompt) {
mutableStateFlow.update {
it.copy(
dialog = VaultItemState.DialogState.MasterPasswordDialog(
action = PasswordRepromptAction.AttachmentDownloadClick(
attachment = action.attachment,
),
),
)
}
return@onContent
}
mutableStateFlow.update {
it.copy(
dialog = VaultItemState.DialogState.Loading(R.string.downloading.asText()),
)
}
viewModelScope.launch {
val result = vaultRepository
.downloadAttachment(
cipherView = requireNotNull(content.common.currentCipher),
attachmentId = action.attachment.id,
)
trySendAction(
VaultItemAction.Internal.AttachmentDecryptReceive(
result = result,
fileName = action.attachment.title,
),
)
}
}
}
private fun handleAttachmentFileLocationReceive(
action: VaultItemAction.Common.AttachmentFileLocationReceive,
) {
mutableStateFlow.update {
it.copy(dialog = null)
}
val file = temporaryAttachmentData ?: return
viewModelScope.launch {
val result = fileManager
.fileToUri(
fileUri = action.fileUri,
file = file,
)
sendAction(
VaultItemAction.Internal.AttachmentFinishedSavingToDisk(
isSaved = result,
file = file,
),
)
}
}
private fun handleNoAttachmentFileLocationReceive() {
viewModelScope.launch {
temporaryAttachmentData?.let { fileManager.deleteFile(it) }
}
mutableStateFlow.update {
it.copy(
dialog = VaultItemState.DialogState.Generic(
R.string.unable_to_save_attachment.asText(),
),
)
}
}
private fun handleAttachmentsClick() {
onContent { content ->
if (content.common.requiresReprompt) {
@ -553,6 +656,14 @@ class VaultItemViewModel @Inject constructor(
is VaultItemAction.Internal.DeleteCipherReceive -> handleDeleteCipherReceive(action)
is VaultItemAction.Internal.RestoreCipherReceive -> handleRestoreCipherReceive(action)
is VaultItemAction.Internal.AttachmentDecryptReceive -> {
handleAttachmentDecryptReceive(action)
}
is VaultItemAction.Internal.AttachmentFinishedSavingToDisk -> {
handleAttachmentFinishedSavingToDisk(action)
}
}
}
@ -737,6 +848,51 @@ class VaultItemViewModel @Inject constructor(
}
}
private fun handleAttachmentDecryptReceive(
action: VaultItemAction.Internal.AttachmentDecryptReceive,
) {
when (val result = action.result) {
DownloadAttachmentResult.Failure -> {
mutableStateFlow.update {
it.copy(
dialog = VaultItemState.DialogState.Generic(
message = R.string.unable_to_download_file.asText(),
),
)
}
}
is DownloadAttachmentResult.Success -> {
temporaryAttachmentData = result.file
sendEvent(
VaultItemEvent.NavigateToSelectAttachmentSaveLocation(
fileName = action.fileName,
),
)
}
}
}
private fun handleAttachmentFinishedSavingToDisk(
action: VaultItemAction.Internal.AttachmentFinishedSavingToDisk,
) {
viewModelScope.launch {
fileManager.deleteFile(action.file)
}
if (action.isSaved) {
sendEvent(VaultItemEvent.ShowToast(R.string.save_attachment_success.asText()))
} else {
mutableStateFlow.update {
it.copy(
dialog = VaultItemState.DialogState.Generic(
R.string.unable_to_save_attachment.asText(),
),
)
}
}
}
//endregion Internal Type Handlers
private inline fun onContent(
@ -871,8 +1027,22 @@ data class VaultItemState(
val requiresReprompt: Boolean,
@IgnoredOnParcel
val currentCipher: CipherView? = null,
val attachments: List<AttachmentItem>?,
) : Parcelable {
/**
* Represents an attachment.
*/
@Parcelize
data class AttachmentItem(
val id: String,
val title: String,
val displaySize: String,
val url: String,
val isLargeFile: Boolean,
val isDownloadAllowed: Boolean,
) : Parcelable
/**
* Represents a custom field, TextField, HiddenField, BooleanField, or LinkedField.
*/
@ -1109,6 +1279,13 @@ sealed class VaultItemEvent {
val itemId: String,
) : VaultItemEvent()
/**
* Navigates to select a location where to save an attachment with the name [fileName].
*/
data class NavigateToSelectAttachmentSaveLocation(
val fileName: String,
) : VaultItemEvent()
/**
* Places the given [message] in your clipboard.
*/
@ -1207,6 +1384,25 @@ sealed class VaultItemAction {
* The user has clicked the collections button.
*/
data object CollectionsClick : Common()
/**
* The user has clicked the download button.
*/
data class AttachmentDownloadClick(
val attachment: VaultItemState.ViewState.Content.Common.AttachmentItem,
) : Common()
/**
* The user has selected a location to save the file.
*/
data class AttachmentFileLocationReceive(
val fileUri: Uri,
) : Common()
/**
* The user skipped selecting a location for the attachment file.
*/
data object NoAttachmentFileLocationReceive : Common()
}
/**
@ -1329,6 +1525,23 @@ sealed class VaultItemAction {
data class RestoreCipherReceive(
val result: RestoreCipherResult,
) : Internal()
/**
* Indicates the attachment download and decryption is complete.
*/
data class AttachmentDecryptReceive(
val result: DownloadAttachmentResult,
val fileName: String,
) : Internal()
/**
* The attempt to save the temporary [file] attachment to disk has finished. [isSaved]
* indicates if it was successful.
*/
data class AttachmentFinishedSavingToDisk(
val isSaved: Boolean,
val file: File,
) : Internal()
}
}
@ -1371,6 +1584,17 @@ sealed class PasswordRepromptAction : Parcelable {
get() = VaultItemAction.Common.AttachmentsClick
}
/**
* Indicates an attachment download was clicked.
*/
@Parcelize
data class AttachmentDownloadClick(
val attachment: VaultItemState.ViewState.Content.Common.AttachmentItem,
) : PasswordRepromptAction() {
override val vaultItemAction: VaultItemAction
get() = VaultItemAction.Common.AttachmentDownloadClick(attachment)
}
/**
* Indicates that we should launch the [VaultItemAction.Common.CloneClick] upon password
* validation.

View file

@ -20,6 +20,7 @@ data class VaultCommonItemTypeHandlers(
VaultItemState.ViewState.Content.Common.Custom.HiddenField,
Boolean,
) -> Unit,
val onAttachmentDownloadClick: (VaultItemState.ViewState.Content.Common.AttachmentItem) -> Unit,
) {
companion object {
/**
@ -47,6 +48,9 @@ data class VaultCommonItemTypeHandlers(
),
)
},
onAttachmentDownloadClick = {
viewModel.trySendAction(VaultItemAction.Common.AttachmentDownloadClick(it))
},
)
}
}

View file

@ -19,6 +19,7 @@ import com.x8bit.bitwarden.ui.vault.feature.vault.VaultState
import com.x8bit.bitwarden.ui.vault.model.VaultCardBrand
import com.x8bit.bitwarden.ui.vault.model.VaultLinkedFieldType
import com.x8bit.bitwarden.ui.vault.model.findVaultCardBrandWithNameOrNull
import java.lang.NumberFormatException
import java.time.format.DateTimeFormatter
import java.util.TimeZone
@ -43,6 +44,32 @@ fun CipherView.toViewState(
customFields = fields.orEmpty().map { it.toCustomField() },
lastUpdated = dateTimeFormatter.format(revisionDate),
notes = notes,
attachments = attachments
?.mapNotNull {
@Suppress("ComplexCondition")
if (it.id == null ||
it.fileName == null ||
it.size == null ||
it.sizeName == null ||
it.url == null
) {
null
} else {
VaultItemState.ViewState.Content.Common.AttachmentItem(
id = requireNotNull(it.id),
title = requireNotNull(it.fileName),
displaySize = requireNotNull(it.sizeName),
url = requireNotNull(it.url),
isLargeFile = try {
requireNotNull(it.size).toLong() >= 10485760
} catch (exception: NumberFormatException) {
false
},
isDownloadAllowed = isPremiumUser || this.organizationId != null,
)
}
}
.orEmpty(),
),
type = when (type) {
CipherType.LOGIN -> {

View file

@ -0,0 +1,13 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:pathData="M2.625,10.75C2.97,10.75 3.25,11.03 3.25,11.375V20.75H19.5V11.375C19.5,11.03 19.78,10.75 20.125,10.75C20.47,10.75 20.75,11.03 20.75,11.375V20.75C20.75,21.44 20.19,22 19.5,22H3.25C2.56,22 2,21.44 2,20.75L2,11.375C2,11.03 2.28,10.75 2.625,10.75Z"
android:fillColor="#000000"
android:fillType="evenOdd"/>
<path
android:pathData="M12,5.125C12,4.78 11.72,4.5 11.375,4.5C11.03,4.5 10.75,4.78 10.75,5.125V15.094L9.203,13.547C8.959,13.303 8.563,13.303 8.319,13.547C8.075,13.791 8.075,14.187 8.319,14.431L10.491,16.603C10.979,17.091 11.771,17.091 12.259,16.603L14.431,14.431C14.675,14.187 14.675,13.791 14.431,13.547C14.187,13.303 13.791,13.303 13.547,13.547L12,15.094V5.125Z"
android:fillColor="#000000"/>
</vector>

View file

@ -9,6 +9,7 @@ import com.x8bit.bitwarden.data.vault.datasource.network.model.FileUploadType
import com.x8bit.bitwarden.data.vault.datasource.network.model.ShareCipherJsonRequest
import com.x8bit.bitwarden.data.vault.datasource.network.model.UpdateCipherCollectionsJsonRequest
import com.x8bit.bitwarden.data.vault.datasource.network.model.UpdateCipherResponseJson
import com.x8bit.bitwarden.data.vault.datasource.network.model.createMockAttachment
import com.x8bit.bitwarden.data.vault.datasource.network.model.createMockAttachmentJsonRequest
import com.x8bit.bitwarden.data.vault.datasource.network.model.createMockAttachmentJsonResponse
import com.x8bit.bitwarden.data.vault.datasource.network.model.createMockCipher
@ -245,6 +246,19 @@ class CiphersServiceTest : BaseServiceTest() {
result.getOrThrow(),
)
}
@Test
fun `getCipherAttachment should return the correct response`() = runTest {
server.enqueue(MockResponse().setBody(GET_CIPHER_ATTACHMENT_SUCCESS_JSON))
val result = ciphersService.getCipherAttachment(
cipherId = "mockId-1",
attachmentId = "mockId-1",
)
assertEquals(
createMockAttachment(number = 1),
result.getOrThrow(),
)
}
}
private fun setupMockUri(
@ -455,3 +469,14 @@ private const val UPDATE_CIPHER_INVALID_JSON = """
"validationErrors": null
}
"""
private const val GET_CIPHER_ATTACHMENT_SUCCESS_JSON = """
{
"fileName": "mockFileName-1",
"size": 1,
"sizeName": "mockSizeName-1",
"id": "mockId-1",
"url": "mockUrl-1",
"key": "mockKey-1"
}
"""

View file

@ -0,0 +1,32 @@
package com.x8bit.bitwarden.data.vault.datasource.network.service
import com.x8bit.bitwarden.data.platform.base.BaseServiceTest
import com.x8bit.bitwarden.data.vault.datasource.network.api.DownloadApi
import kotlinx.coroutines.test.runTest
import okhttp3.mockwebserver.MockResponse
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test
import retrofit2.create
class DownloadServiceTest : BaseServiceTest() {
private val downloadApi: DownloadApi = retrofit.create()
private val downloadService: DownloadService = DownloadServiceImpl(
downloadApi = downloadApi,
)
@Test
fun `getDataStream should return a raw stream RespondBody`() = runTest {
server.enqueue(
MockResponse()
.setResponseCode(200)
.setBody("Bitwarden")
.setHeader("Content-Type", "application/stream"),
)
val url = "/test-url"
val result = downloadService.getDataStream(url)
assertTrue(result.isSuccess)
assertEquals("Bitwarden", String(result.getOrThrow().bytes()))
}
}

View file

@ -1,5 +1,6 @@
package com.x8bit.bitwarden.data.vault.datasource.sdk
import com.bitwarden.core.Attachment
import com.bitwarden.core.AttachmentEncryptResult
import com.bitwarden.core.AttachmentView
import com.bitwarden.core.Cipher
@ -661,6 +662,42 @@ class VaultSdkSourceTest {
verify { sdkClientManager.getOrCreateClient(userId = userId) }
}
@Test
fun `File decrypt should call SDK and return a Result with correct data`() = runBlocking {
val userId = "userId"
val mockCipher = mockk<Cipher>()
val mockAttachment = mockk<Attachment>()
val expectedResult = Unit
coEvery {
clientVault.attachments().decryptFile(
cipher = mockCipher,
attachment = mockAttachment,
encryptedFilePath = "encrypted_path",
decryptedFilePath = "decrypted_path",
)
} just runs
val result = vaultSdkSource.decryptFile(
userId = userId,
cipher = mockCipher,
attachment = mockAttachment,
encryptedFilePath = "encrypted_path",
decryptedFilePath = "decrypted_path",
)
assertEquals(
expectedResult.asSuccess(),
result,
)
coVerify {
clientVault.attachments().decryptFile(
cipher = mockCipher,
attachment = mockAttachment,
encryptedFilePath = "encrypted_path",
decryptedFilePath = "decrypted_path",
)
}
verify { sdkClientManager.getOrCreateClient(userId = userId) }
}
@Test
fun `encryptPasswordHistory should call SDK and return a Result with correct data`() =
runBlocking {

View file

@ -3,6 +3,7 @@ package com.x8bit.bitwarden.data.vault.repository
import android.net.Uri
import app.cash.turbine.test
import app.cash.turbine.turbineScope
import com.bitwarden.core.Attachment
import com.bitwarden.core.Cipher
import com.bitwarden.core.CipherView
import com.bitwarden.core.CollectionView
@ -79,6 +80,7 @@ import com.x8bit.bitwarden.data.vault.datasource.sdk.model.createMockSendView
import com.x8bit.bitwarden.data.vault.manager.FileManager
import com.x8bit.bitwarden.data.vault.manager.TotpCodeManager
import com.x8bit.bitwarden.data.vault.manager.VaultLockManager
import com.x8bit.bitwarden.data.vault.manager.model.DownloadResult
import com.x8bit.bitwarden.data.vault.manager.model.VerificationCodeItem
import com.x8bit.bitwarden.data.vault.repository.model.CreateAttachmentResult
import com.x8bit.bitwarden.data.vault.repository.model.CreateCipherResult
@ -89,6 +91,7 @@ import com.x8bit.bitwarden.data.vault.repository.model.DeleteCipherResult
import com.x8bit.bitwarden.data.vault.repository.model.DeleteFolderResult
import com.x8bit.bitwarden.data.vault.repository.model.DeleteSendResult
import com.x8bit.bitwarden.data.vault.repository.model.DomainsData
import com.x8bit.bitwarden.data.vault.repository.model.DownloadAttachmentResult
import com.x8bit.bitwarden.data.vault.repository.model.GenerateTotpResult
import com.x8bit.bitwarden.data.vault.repository.model.RemovePasswordSendResult
import com.x8bit.bitwarden.data.vault.repository.model.RestoreCipherResult
@ -130,6 +133,7 @@ import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
import retrofit2.HttpException
import java.io.File
import java.net.UnknownHostException
import java.time.Clock
import java.time.Instant
@ -3326,6 +3330,312 @@ class VaultRepositoryTest {
assertEquals(CreateAttachmentResult.Success(mockCipherView), result)
}
@Test
fun `downloadAttachment with missing attachment should return Failure`() = runTest {
fakeAuthDiskSource.userState = MOCK_USER_STATE
val attachmentId = "mockId-1"
val attachment = mockk<Attachment> {
every { id } returns attachmentId
}
val cipher = mockk<Cipher> {
every { attachments } returns emptyList()
every { id } returns "mockId-1"
}
val cipherView = createMockCipherView(number = 1)
coEvery {
vaultSdkSource.encryptCipher(MOCK_USER_STATE.activeUserId, cipherView)
} returns cipher.asSuccess()
assertEquals(
DownloadAttachmentResult.Failure,
vaultRepository.downloadAttachment(
cipherView = cipherView,
attachmentId = attachmentId,
),
)
coVerify(exactly = 1) {
vaultSdkSource.encryptCipher(MOCK_USER_STATE.activeUserId, cipherView)
}
coVerify(exactly = 0) {
ciphersService.getCipherAttachment(any(), any())
}
}
@Test
fun `downloadAttachment with failed attachment details request should return Failure`() =
runTest {
fakeAuthDiskSource.userState = MOCK_USER_STATE
val attachmentId = "mockId-1"
val attachment = mockk<Attachment> {
every { id } returns attachmentId
}
val cipher = mockk<Cipher> {
every { attachments } returns listOf(attachment)
every { id } returns "mockId-1"
}
val cipherView = createMockCipherView(number = 1)
coEvery {
vaultSdkSource.encryptCipher(MOCK_USER_STATE.activeUserId, cipherView)
} returns cipher.asSuccess()
coEvery {
ciphersService.getCipherAttachment(any(), any())
} returns Throwable().asFailure()
assertEquals(
DownloadAttachmentResult.Failure,
vaultRepository.downloadAttachment(
cipherView = cipherView,
attachmentId = attachmentId,
),
)
coVerify(exactly = 1) {
vaultSdkSource.encryptCipher(MOCK_USER_STATE.activeUserId, cipherView)
ciphersService.getCipherAttachment(
cipherId = requireNotNull(cipherView.id),
attachmentId = attachmentId,
)
}
}
@Test
fun `downloadAttachment with attachment details missing url should return Failure`() = runTest {
fakeAuthDiskSource.userState = MOCK_USER_STATE
val attachmentId = "mockId-1"
val attachment = mockk<Attachment> {
every { id } returns attachmentId
}
val cipher = mockk<Cipher> {
every { attachments } returns listOf(attachment)
every { id } returns "mockId-1"
}
val cipherView = createMockCipherView(number = 1)
coEvery {
vaultSdkSource.encryptCipher(MOCK_USER_STATE.activeUserId, cipherView)
} returns cipher.asSuccess()
val response = mockk<SyncResponseJson.Cipher.Attachment> {
every { url } returns null
}
coEvery {
ciphersService.getCipherAttachment(any(), any())
} returns response.asSuccess()
assertEquals(
DownloadAttachmentResult.Failure,
vaultRepository.downloadAttachment(
cipherView = cipherView,
attachmentId = attachmentId,
),
)
coVerify(exactly = 1) {
vaultSdkSource.encryptCipher(MOCK_USER_STATE.activeUserId, cipherView)
ciphersService.getCipherAttachment(
cipherId = requireNotNull(cipherView.id),
attachmentId = attachmentId,
)
}
}
@Test
fun `downloadAttachment with failed download should return Failure`() = runTest {
fakeAuthDiskSource.userState = MOCK_USER_STATE
val attachmentId = "mockId-1"
val attachment = mockk<Attachment> {
every { id } returns attachmentId
}
val cipher = mockk<Cipher> {
every { attachments } returns listOf(attachment)
every { id } returns "mockId-1"
}
val cipherView = createMockCipherView(number = 1)
coEvery {
vaultSdkSource.encryptCipher(MOCK_USER_STATE.activeUserId, cipherView)
} returns cipher.asSuccess()
val response = mockk<SyncResponseJson.Cipher.Attachment> {
every { url } returns "https://bitwarden.com"
}
coEvery {
ciphersService.getCipherAttachment(any(), any())
} returns response.asSuccess()
coEvery {
fileManager.downloadFileToCache(any())
} returns DownloadResult.Failure
assertEquals(
DownloadAttachmentResult.Failure,
vaultRepository.downloadAttachment(
cipherView = cipherView,
attachmentId = attachmentId,
),
)
coVerify(exactly = 1) {
vaultSdkSource.encryptCipher(MOCK_USER_STATE.activeUserId, cipherView)
ciphersService.getCipherAttachment(
cipherId = requireNotNull(cipherView.id),
attachmentId = attachmentId,
)
fileManager.downloadFileToCache("https://bitwarden.com")
}
}
@Suppress("MaxLineLength")
@Test
fun `downloadAttachment with failed decryption should delete file and return Failure`() =
runTest {
fakeAuthDiskSource.userState = MOCK_USER_STATE
val attachmentId = "mockId-1"
val attachment = mockk<Attachment> {
every { id } returns attachmentId
}
val cipher = mockk<Cipher> {
every { attachments } returns listOf(attachment)
every { id } returns "mockId-1"
}
val cipherView = createMockCipherView(number = 1)
coEvery {
vaultSdkSource.encryptCipher(MOCK_USER_STATE.activeUserId, cipherView)
} returns cipher.asSuccess()
val response = mockk<SyncResponseJson.Cipher.Attachment> {
every { url } returns "https://bitwarden.com"
}
coEvery {
ciphersService.getCipherAttachment(any(), any())
} returns response.asSuccess()
val file = mockk<File> {
every { path } returns "path/to/encrypted/file"
every { delete() } returns true
}
coEvery {
fileManager.downloadFileToCache(any())
} returns DownloadResult.Success(file)
coEvery {
vaultSdkSource.decryptFile(
userId = MOCK_USER_STATE.activeUserId,
cipher = cipher,
attachment = attachment,
encryptedFilePath = "path/to/encrypted/file",
decryptedFilePath = "path/to/encrypted/file_decrypted",
)
} returns Throwable().asFailure()
assertEquals(
DownloadAttachmentResult.Failure,
vaultRepository.downloadAttachment(
cipherView = cipherView,
attachmentId = attachmentId,
),
)
coVerify(exactly = 1) {
vaultSdkSource.encryptCipher(MOCK_USER_STATE.activeUserId, cipherView)
ciphersService.getCipherAttachment(
cipherId = requireNotNull(cipherView.id),
attachmentId = attachmentId,
)
fileManager.downloadFileToCache("https://bitwarden.com")
vaultSdkSource.decryptFile(
userId = MOCK_USER_STATE.activeUserId,
cipher = cipher,
attachment = attachment,
encryptedFilePath = "path/to/encrypted/file",
decryptedFilePath = "path/to/encrypted/file_decrypted",
)
}
verify(exactly = 1) {
file.delete()
}
}
@Suppress("MaxLineLength")
@Test
fun `downloadAttachment with successful decryption should delete file and return Success`() =
runTest {
fakeAuthDiskSource.userState = MOCK_USER_STATE
val attachmentId = "mockId-1"
val attachment = mockk<Attachment> {
every { id } returns attachmentId
}
val cipher = mockk<Cipher> {
every { attachments } returns listOf(attachment)
every { id } returns "mockId-1"
}
val cipherView = createMockCipherView(number = 1)
coEvery {
vaultSdkSource.encryptCipher(MOCK_USER_STATE.activeUserId, cipherView)
} returns cipher.asSuccess()
val response = mockk<SyncResponseJson.Cipher.Attachment> {
every { url } returns "https://bitwarden.com"
}
coEvery {
ciphersService.getCipherAttachment(any(), any())
} returns response.asSuccess()
val file = mockk<File> {
every { path } returns "path/to/encrypted/file"
every { delete() } returns true
}
coEvery {
fileManager.downloadFileToCache(any())
} returns DownloadResult.Success(file)
coEvery {
vaultSdkSource.decryptFile(
userId = MOCK_USER_STATE.activeUserId,
cipher = cipher,
attachment = attachment,
encryptedFilePath = "path/to/encrypted/file",
decryptedFilePath = "path/to/encrypted/file_decrypted",
)
} returns Unit.asSuccess()
assertEquals(
DownloadAttachmentResult.Success(
file = File("path/to/encrypted/file_decrypted"),
),
vaultRepository.downloadAttachment(
cipherView = cipherView,
attachmentId = attachmentId,
),
)
coVerify(exactly = 1) {
vaultSdkSource.encryptCipher(MOCK_USER_STATE.activeUserId, cipherView)
ciphersService.getCipherAttachment(
cipherId = requireNotNull(cipherView.id),
attachmentId = attachmentId,
)
fileManager.downloadFileToCache("https://bitwarden.com")
vaultSdkSource.decryptFile(
userId = MOCK_USER_STATE.activeUserId,
cipher = cipher,
attachment = attachment,
encryptedFilePath = "path/to/encrypted/file",
decryptedFilePath = "path/to/encrypted/file_decrypted",
)
}
verify(exactly = 1) {
file.delete()
}
}
@Test
fun `generateTotp with no active user should return GenerateTotpResult Error`() =
runTest {
@ -3901,180 +4211,185 @@ class VaultRepositoryTest {
@Suppress("MaxLineLength")
@Test
fun `syncCipherUpsertFlow create with local cipher with no common collections should do nothing`() = runTest {
val number = 1
val cipherId = "mockId-$number"
fun `syncCipherUpsertFlow create with local cipher with no common collections should do nothing`() =
runTest {
val number = 1
val cipherId = "mockId-$number"
fakeAuthDiskSource.userState = MOCK_USER_STATE
val cipherView = createMockCipherView(number = number)
coEvery {
vaultSdkSource.decryptCipherList(
userId = MOCK_USER_STATE.activeUserId,
cipherList = listOf(createMockSdkCipher(number = number)),
)
} returns listOf(cipherView).asSuccess()
fakeAuthDiskSource.userState = MOCK_USER_STATE
val cipherView = createMockCipherView(number = number)
coEvery {
vaultSdkSource.decryptCipherList(
userId = MOCK_USER_STATE.activeUserId,
cipherList = listOf(createMockSdkCipher(number = number)),
)
} returns listOf(cipherView).asSuccess()
val ciphersFlow = bufferedMutableSharedFlow<List<SyncResponseJson.Cipher>>()
setupVaultDiskSourceFlows(ciphersFlow = ciphersFlow)
val ciphersFlow = bufferedMutableSharedFlow<List<SyncResponseJson.Cipher>>()
setupVaultDiskSourceFlows(ciphersFlow = ciphersFlow)
vaultRepository.ciphersStateFlow.test {
// Populate and consume items related to the ciphers flow
awaitItem()
ciphersFlow.tryEmit(listOf(createMockCipher(number = number)))
awaitItem()
vaultRepository.ciphersStateFlow.test {
// Populate and consume items related to the ciphers flow
awaitItem()
ciphersFlow.tryEmit(listOf(createMockCipher(number = number)))
awaitItem()
mutableSyncCipherUpsertFlow.tryEmit(
SyncCipherUpsertData(
cipherId = cipherId,
revisionDate = ZonedDateTime.now(),
isUpdate = false,
collectionIds = null,
organizationId = null,
),
)
mutableSyncCipherUpsertFlow.tryEmit(
SyncCipherUpsertData(
cipherId = cipherId,
revisionDate = ZonedDateTime.now(),
isUpdate = false,
collectionIds = null,
organizationId = null,
),
)
}
coVerify(exactly = 0) {
ciphersService.getCipher(any())
vaultDiskSource.saveCipher(any(), any())
}
}
coVerify(exactly = 0) {
ciphersService.getCipher(any())
vaultDiskSource.saveCipher(any(), any())
}
}
@Suppress("MaxLineLength")
@Test
fun `syncCipherUpsertFlow create with local cipher, and with common collections, should make a request and save cipher to disk`() = runTest {
val number = 1
val cipherId = "mockId-$number"
fun `syncCipherUpsertFlow create with local cipher, and with common collections, should make a request and save cipher to disk`() =
runTest {
val number = 1
val cipherId = "mockId-$number"
fakeAuthDiskSource.userState = MOCK_USER_STATE
val cipherView = createMockCipherView(number = number)
coEvery {
vaultSdkSource.decryptCipherList(
userId = MOCK_USER_STATE.activeUserId,
cipherList = listOf(createMockSdkCipher(number = number)),
fakeAuthDiskSource.userState = MOCK_USER_STATE
val cipherView = createMockCipherView(number = number)
coEvery {
vaultSdkSource.decryptCipherList(
userId = MOCK_USER_STATE.activeUserId,
cipherList = listOf(createMockSdkCipher(number = number)),
)
} returns listOf(cipherView).asSuccess()
val collectionView = createMockCollectionView(number = number)
coEvery {
vaultSdkSource.decryptCollectionList(
userId = MOCK_USER_STATE.activeUserId,
collectionList = listOf(createMockSdkCollection(number = number)),
)
} returns listOf(collectionView).asSuccess()
val cipher: SyncResponseJson.Cipher = mockk()
coEvery {
ciphersService.getCipher(cipherId)
} returns cipher.asSuccess()
coEvery {
vaultDiskSource.saveCipher(userId = MOCK_USER_STATE.activeUserId, cipher = cipher)
} just runs
val ciphersFlow = bufferedMutableSharedFlow<List<SyncResponseJson.Cipher>>()
val collectionsFlow = bufferedMutableSharedFlow<List<SyncResponseJson.Collection>>()
setupVaultDiskSourceFlows(
ciphersFlow = ciphersFlow,
collectionsFlow = collectionsFlow,
)
} returns listOf(cipherView).asSuccess()
val collectionView = createMockCollectionView(number = number)
coEvery {
vaultSdkSource.decryptCollectionList(
userId = MOCK_USER_STATE.activeUserId,
collectionList = listOf(createMockSdkCollection(number = number)),
)
} returns listOf(collectionView).asSuccess()
val cipher: SyncResponseJson.Cipher = mockk()
coEvery {
ciphersService.getCipher(cipherId)
} returns cipher.asSuccess()
coEvery {
vaultDiskSource.saveCipher(userId = MOCK_USER_STATE.activeUserId, cipher = cipher)
} just runs
turbineScope {
val ciphersStateFlow = vaultRepository.ciphersStateFlow.testIn(backgroundScope)
val collectionsStateFlow =
vaultRepository.collectionsStateFlow.testIn(backgroundScope)
val ciphersFlow = bufferedMutableSharedFlow<List<SyncResponseJson.Cipher>>()
val collectionsFlow = bufferedMutableSharedFlow<List<SyncResponseJson.Collection>>()
setupVaultDiskSourceFlows(
ciphersFlow = ciphersFlow,
collectionsFlow = collectionsFlow,
)
// Populate and consume items related to the ciphers flow
ciphersStateFlow.awaitItem()
ciphersFlow.tryEmit(listOf(createMockCipher(number = number)))
ciphersStateFlow.awaitItem()
turbineScope {
val ciphersStateFlow = vaultRepository.ciphersStateFlow.testIn(backgroundScope)
val collectionsStateFlow = vaultRepository.collectionsStateFlow.testIn(backgroundScope)
// Populate and consume items related to the collections flow
collectionsStateFlow.awaitItem()
collectionsFlow.tryEmit(listOf(createMockCollection(number = number)))
collectionsStateFlow.awaitItem()
// Populate and consume items related to the ciphers flow
ciphersStateFlow.awaitItem()
ciphersFlow.tryEmit(listOf(createMockCipher(number = number)))
ciphersStateFlow.awaitItem()
mutableSyncCipherUpsertFlow.tryEmit(
SyncCipherUpsertData(
cipherId = cipherId,
revisionDate = ZonedDateTime.now(),
isUpdate = false,
collectionIds = listOf("mockId-1"),
organizationId = "mock-id",
),
)
}
// Populate and consume items related to the collections flow
collectionsStateFlow.awaitItem()
collectionsFlow.tryEmit(listOf(createMockCollection(number = number)))
collectionsStateFlow.awaitItem()
mutableSyncCipherUpsertFlow.tryEmit(
SyncCipherUpsertData(
cipherId = cipherId,
revisionDate = ZonedDateTime.now(),
isUpdate = false,
collectionIds = listOf("mockId-1"),
organizationId = "mock-id",
),
)
coVerify(exactly = 1) {
ciphersService.getCipher(any())
vaultDiskSource.saveCipher(any(), any())
}
}
coVerify(exactly = 1) {
ciphersService.getCipher(any())
vaultDiskSource.saveCipher(any(), any())
}
}
@Suppress("MaxLineLength")
@Test
fun `syncCipherUpsertFlow update with no local cipher, but with common collections, should make a request save cipher to disk`() = runTest {
val number = 1
val cipherId = "mockId-$number"
fun `syncCipherUpsertFlow update with no local cipher, but with common collections, should make a request save cipher to disk`() =
runTest {
val number = 1
val cipherId = "mockId-$number"
fakeAuthDiskSource.userState = MOCK_USER_STATE
coEvery {
vaultSdkSource.decryptCipherList(
userId = MOCK_USER_STATE.activeUserId,
cipherList = listOf(),
fakeAuthDiskSource.userState = MOCK_USER_STATE
coEvery {
vaultSdkSource.decryptCipherList(
userId = MOCK_USER_STATE.activeUserId,
cipherList = listOf(),
)
} returns listOf<CipherView>().asSuccess()
val collectionView = createMockCollectionView(number = number)
coEvery {
vaultSdkSource.decryptCollectionList(
userId = MOCK_USER_STATE.activeUserId,
collectionList = listOf(createMockSdkCollection(number = number)),
)
} returns listOf(collectionView).asSuccess()
val cipher: SyncResponseJson.Cipher = mockk()
coEvery {
ciphersService.getCipher(cipherId)
} returns cipher.asSuccess()
coEvery {
vaultDiskSource.saveCipher(userId = MOCK_USER_STATE.activeUserId, cipher = cipher)
} just runs
val ciphersFlow = bufferedMutableSharedFlow<List<SyncResponseJson.Cipher>>()
val collectionsFlow = bufferedMutableSharedFlow<List<SyncResponseJson.Collection>>()
setupVaultDiskSourceFlows(
ciphersFlow = ciphersFlow,
collectionsFlow = collectionsFlow,
)
} returns listOf<CipherView>().asSuccess()
val collectionView = createMockCollectionView(number = number)
coEvery {
vaultSdkSource.decryptCollectionList(
userId = MOCK_USER_STATE.activeUserId,
collectionList = listOf(createMockSdkCollection(number = number)),
)
} returns listOf(collectionView).asSuccess()
val cipher: SyncResponseJson.Cipher = mockk()
coEvery {
ciphersService.getCipher(cipherId)
} returns cipher.asSuccess()
coEvery {
vaultDiskSource.saveCipher(userId = MOCK_USER_STATE.activeUserId, cipher = cipher)
} just runs
turbineScope {
val ciphersStateFlow = vaultRepository.ciphersStateFlow.testIn(backgroundScope)
val collectionsStateFlow =
vaultRepository.collectionsStateFlow.testIn(backgroundScope)
val ciphersFlow = bufferedMutableSharedFlow<List<SyncResponseJson.Cipher>>()
val collectionsFlow = bufferedMutableSharedFlow<List<SyncResponseJson.Collection>>()
setupVaultDiskSourceFlows(
ciphersFlow = ciphersFlow,
collectionsFlow = collectionsFlow,
)
// Populate and consume items related to the ciphers flow
ciphersStateFlow.awaitItem()
ciphersFlow.tryEmit(listOf())
ciphersStateFlow.awaitItem()
turbineScope {
val ciphersStateFlow = vaultRepository.ciphersStateFlow.testIn(backgroundScope)
val collectionsStateFlow = vaultRepository.collectionsStateFlow.testIn(backgroundScope)
// Populate and consume items related to the collections flow
collectionsStateFlow.awaitItem()
collectionsFlow.tryEmit(listOf(createMockCollection(number = number)))
collectionsStateFlow.awaitItem()
// Populate and consume items related to the ciphers flow
ciphersStateFlow.awaitItem()
ciphersFlow.tryEmit(listOf())
ciphersStateFlow.awaitItem()
mutableSyncCipherUpsertFlow.tryEmit(
SyncCipherUpsertData(
cipherId = cipherId,
revisionDate = ZonedDateTime.now(),
isUpdate = true,
collectionIds = listOf("mockId-1"),
organizationId = "mock-id",
),
)
}
// Populate and consume items related to the collections flow
collectionsStateFlow.awaitItem()
collectionsFlow.tryEmit(listOf(createMockCollection(number = number)))
collectionsStateFlow.awaitItem()
mutableSyncCipherUpsertFlow.tryEmit(
SyncCipherUpsertData(
cipherId = cipherId,
revisionDate = ZonedDateTime.now(),
isUpdate = true,
collectionIds = listOf("mockId-1"),
organizationId = "mock-id",
),
)
coVerify(exactly = 1) {
ciphersService.getCipher(any())
vaultDiskSource.saveCipher(any(), any())
}
}
coVerify(exactly = 1) {
ciphersService.getCipher(any())
vaultDiskSource.saveCipher(any(), any())
}
}
@Test
fun `syncCipherUpsertFlow update with no local cipher should do nothing`() = runTest {
val number = 1

View file

@ -60,7 +60,7 @@ class VaultItemScreenTest : BaseComposeTest() {
private var onNavigateToMoveToOrganizationItemId: String? = null
private var onNavigateToAttachmentsId: String? = null
private val intentManager = mockk<IntentManager>()
private val intentManager = mockk<IntentManager>(relaxed = true)
private val mutableEventFlow = bufferedMutableSharedFlow<VaultItemEvent>()
private val mutableStateFlow = MutableStateFlow(DEFAULT_STATE)
@ -135,6 +135,15 @@ class VaultItemScreenTest : BaseComposeTest() {
}
}
@Test
fun `NavigateToSelectAttachmentSaveLocation should invoke createAttachmentChooserIntent`() {
mutableEventFlow.tryEmit(VaultItemEvent.NavigateToSelectAttachmentSaveLocation("test.mp4"))
verify(exactly = 1) {
intentManager.createAttachmentChooserIntent("test.mp4")
}
}
@Test
fun `basic dialog should be displayed according to state`() {
val message = "message"
@ -311,6 +320,179 @@ class VaultItemScreenTest : BaseComposeTest() {
}
}
@Test
fun `attachments should be displayed according to state`() {
DEFAULT_VIEW_STATES
.forEach { typeState ->
mutableStateFlow.update { it.copy(viewState = typeState) }
composeTestRule.onNodeWithTextAfterScroll("Attachments").assertIsDisplayed()
composeTestRule.onNodeWithTextAfterScroll("test.mp4").assertIsDisplayed()
composeTestRule.onNodeWithTextAfterScroll("11 MB").assertIsDisplayed()
mutableStateFlow.update { currentState ->
updateCommonContent(currentState) { copy(attachments = emptyList()) }
}
composeTestRule.assertScrollableNodeDoesNotExist("Attachments")
composeTestRule.assertScrollableNodeDoesNotExist("test.mp4")
composeTestRule.assertScrollableNodeDoesNotExist("11 MB")
}
}
@Test
fun `attachment download click for non-premium users should show an error dialog`() {
mutableStateFlow.update { currentState ->
currentState.copy(
viewState = EMPTY_LOGIN_VIEW_STATE.copy(
common = EMPTY_COMMON.copy(
attachments = listOf(
VaultItemState.ViewState.Content.Common.AttachmentItem(
id = "attachment-id",
displaySize = "11 MB",
isLargeFile = true,
isDownloadAllowed = false,
url = "https://example.com",
title = "test.mp4",
),
),
),
),
)
}
composeTestRule.assertNoDialogExists()
composeTestRule.onNodeWithContentDescriptionAfterScroll("Download").performClick()
composeTestRule
.onAllNodesWithText(
"A premium membership is required to use this feature.",
)
.filterToOne(hasAnyAncestor(isDialog()))
.assertIsDisplayed()
composeTestRule
.onAllNodesWithText("Ok")
.filterToOne(hasAnyAncestor(isDialog()))
.performClick()
composeTestRule.assertNoDialogExists()
}
@Suppress("MaxLineLength")
@Test
fun `attachment download click for large downloads should show a prompt and dismiss when clicking No`() {
mutableStateFlow.update { currentState ->
currentState.copy(
viewState = EMPTY_LOGIN_VIEW_STATE.copy(
common = EMPTY_COMMON.copy(
attachments = listOf(
VaultItemState.ViewState.Content.Common.AttachmentItem(
id = "attachment-id",
displaySize = "11 MB",
isLargeFile = true,
isDownloadAllowed = true,
url = "https://example.com",
title = "test.mp4",
),
),
),
),
)
}
composeTestRule.assertNoDialogExists()
composeTestRule.onNodeWithContentDescriptionAfterScroll("Download").performClick()
composeTestRule
.onAllNodesWithText(
"This attachment is 11 MB in size. Are you sure you want to download it onto " +
"your device?",
)
.filterToOne(hasAnyAncestor(isDialog()))
.assertIsDisplayed()
composeTestRule
.onAllNodesWithText("No")
.filterToOne(hasAnyAncestor(isDialog()))
.performClick()
composeTestRule.assertNoDialogExists()
}
@Suppress("MaxLineLength")
@Test
fun `attachment download click for large downloads should show a prompt and emit AttachmentDownloadClick`() {
val attachment = VaultItemState.ViewState.Content.Common.AttachmentItem(
id = "attachment-id",
displaySize = "11 MB",
isLargeFile = true,
isDownloadAllowed = true,
url = "https://example.com",
title = "test.mp4",
)
mutableStateFlow.update { currentState ->
currentState.copy(
viewState = EMPTY_LOGIN_VIEW_STATE.copy(
common = EMPTY_COMMON.copy(
attachments = listOf(attachment),
),
),
)
}
composeTestRule.assertNoDialogExists()
composeTestRule.onNodeWithContentDescriptionAfterScroll("Download").performClick()
composeTestRule
.onAllNodesWithText(
"This attachment is 11 MB in size. Are you sure you want to download it onto " +
"your device?",
)
.filterToOne(hasAnyAncestor(isDialog()))
.assertIsDisplayed()
composeTestRule
.onAllNodesWithText("Yes")
.filterToOne(hasAnyAncestor(isDialog()))
.performClick()
composeTestRule.assertNoDialogExists()
verify {
viewModel.trySendAction(VaultItemAction.Common.AttachmentDownloadClick(attachment))
}
}
@Test
fun `attachment download click for smaller downloads should emit AttachmentDownloadClick`() {
val attachment = VaultItemState.ViewState.Content.Common.AttachmentItem(
id = "attachment-id",
displaySize = "9 MB",
isLargeFile = false,
isDownloadAllowed = true,
url = "https://example.com",
title = "test.mp4",
)
mutableStateFlow.update { currentState ->
currentState.copy(
viewState = EMPTY_LOGIN_VIEW_STATE.copy(
common = EMPTY_COMMON.copy(
attachments = listOf(attachment),
),
),
)
}
composeTestRule.assertNoDialogExists()
composeTestRule.onNodeWithContentDescriptionAfterScroll("Download").performClick()
composeTestRule.assertNoDialogExists()
verify {
viewModel.trySendAction(VaultItemAction.Common.AttachmentDownloadClick(attachment))
}
}
@Test
fun `on show hidden field click should send HiddenFieldVisibilityClicked`() {
val textField = VaultItemState.ViewState.Content.Common.Custom.HiddenField(
@ -1913,6 +2095,16 @@ private val DEFAULT_COMMON: VaultItemState.ViewState.Content.Common =
),
),
requiresReprompt = true,
attachments = listOf(
VaultItemState.ViewState.Content.Common.AttachmentItem(
id = "attachment-id",
displaySize = "11 MB",
isLargeFile = true,
isDownloadAllowed = true,
url = "https://example.com",
title = "test.mp4",
),
),
)
private val DEFAULT_LOGIN: VaultItemState.ViewState.Content.ItemType.Login =
@ -1970,6 +2162,7 @@ private val EMPTY_COMMON: VaultItemState.ViewState.Content.Common =
notes = null,
customFields = emptyList(),
requiresReprompt = true,
attachments = emptyList(),
)
private val EMPTY_LOGIN_TYPE: VaultItemState.ViewState.Content.ItemType.Login =

View file

@ -1,5 +1,6 @@
package com.x8bit.bitwarden.ui.vault.feature.item
import android.net.Uri
import androidx.lifecycle.SavedStateHandle
import app.cash.turbine.test
import app.cash.turbine.turbineScope
@ -13,9 +14,11 @@ import com.x8bit.bitwarden.data.platform.manager.clipboard.BitwardenClipboardMan
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.manager.FileManager
import com.x8bit.bitwarden.data.vault.manager.model.VerificationCodeItem
import com.x8bit.bitwarden.data.vault.repository.VaultRepository
import com.x8bit.bitwarden.data.vault.repository.model.DeleteCipherResult
import com.x8bit.bitwarden.data.vault.repository.model.DownloadAttachmentResult
import com.x8bit.bitwarden.data.vault.repository.model.RestoreCipherResult
import com.x8bit.bitwarden.ui.platform.base.BaseViewModelTest
import com.x8bit.bitwarden.ui.platform.base.util.asText
@ -42,6 +45,7 @@ import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Nested
import org.junit.jupiter.api.Test
import java.io.File
import java.time.Instant
@Suppress("LargeClass")
@ -61,6 +65,8 @@ class VaultItemViewModelTest : BaseViewModelTest() {
every { getVaultItemStateFlow(VAULT_ITEM_ID) } returns mutableVaultItemFlow
}
private val mockFileManager: FileManager = mockk()
@BeforeEach
fun setup() {
mockkStatic(CipherView::toViewState)
@ -744,6 +750,7 @@ class VaultItemViewModelTest : BaseViewModelTest() {
val loginViewState = VaultItemState.ViewState.Content(
common = createCommonContent(
isEmpty = true,
isPremiumUser = true,
).copy(
requiresReprompt = false,
customFields = listOf(hiddenField),
@ -772,7 +779,7 @@ class VaultItemViewModelTest : BaseViewModelTest() {
assertEquals(
loginState.copy(
viewState = loginViewState.copy(
common = createCommonContent(isEmpty = true).copy(
common = createCommonContent(isEmpty = true, isPremiumUser = true).copy(
requiresReprompt = false,
customFields = listOf(hiddenField.copy(isVisible = true)),
),
@ -994,6 +1001,291 @@ class VaultItemViewModelTest : BaseViewModelTest() {
)
}
}
@Test
fun `on AttachmentDownloadClick should prompt for password if required`() =
runTest {
val loginViewState = createViewState(
common = DEFAULT_COMMON.copy(requiresReprompt = true),
)
val mockCipherView = mockk<CipherView> {
every {
toViewState(
isPremiumUser = true,
totpCodeItemData = null,
)
} returns loginViewState
}
mutableVaultItemFlow.value = DataState.Loaded(data = mockCipherView)
mutableAuthCodeItemFlow.value = DataState.Loaded(data = null)
val loginState = DEFAULT_STATE.copy(viewState = loginViewState)
val viewModel = createViewModel(state = loginState)
val attachment = VaultItemState.ViewState.Content.Common.AttachmentItem(
id = "attachment-id",
displaySize = "11 MB",
isLargeFile = true,
isDownloadAllowed = false,
url = "https://example.com",
title = "test.mp4",
)
viewModel.stateFlow.test {
assertEquals(
loginState,
awaitItem(),
)
viewModel.trySendAction(
VaultItemAction.Common.AttachmentDownloadClick(attachment),
)
assertEquals(
loginState.copy(
dialog = VaultItemState.DialogState.MasterPasswordDialog(
action = PasswordRepromptAction.AttachmentDownloadClick(attachment),
),
),
awaitItem(),
)
}
coVerify(exactly = 0) {
vaultRepo.downloadAttachment(any(), any())
}
}
@Suppress("MaxLineLength")
@Test
fun `on AttachmentDownloadClick should show loading dialog, attempt to download an attachment, and display an error dialog on failure`() =
runTest {
val loginViewState = createViewState(
common = DEFAULT_COMMON.copy(requiresReprompt = false),
)
val mockCipherView = mockk<CipherView> {
every {
toViewState(
isPremiumUser = true,
totpCodeItemData = null,
)
} returns loginViewState
}
mutableVaultItemFlow.value = DataState.Loaded(data = mockCipherView)
mutableAuthCodeItemFlow.value = DataState.Loaded(data = null)
val loginState = DEFAULT_STATE.copy(viewState = loginViewState)
val viewModel = createViewModel(state = loginState)
val attachment = VaultItemState.ViewState.Content.Common.AttachmentItem(
id = "attachment-id",
displaySize = "11 MB",
isLargeFile = true,
isDownloadAllowed = false,
url = "https://example.com",
title = "test.mp4",
)
coEvery {
vaultRepo.downloadAttachment(any(), any())
} returns DownloadAttachmentResult.Failure
viewModel.stateFlow.test {
assertEquals(
loginState,
awaitItem(),
)
viewModel.trySendAction(
VaultItemAction.Common.AttachmentDownloadClick(attachment),
)
assertEquals(
loginState.copy(dialog = VaultItemState.DialogState.Loading(R.string.downloading.asText())),
awaitItem(),
)
assertEquals(
loginState.copy(
dialog = VaultItemState.DialogState.Generic(
R.string.unable_to_download_file.asText(),
),
),
awaitItem(),
)
}
coVerify(exactly = 1) {
vaultRepo.downloadAttachment(any(), any())
}
}
@Suppress("MaxLineLength")
@Test
fun `on AttachmentDownloadClick should show loading dialog, attempt to download an attachment, and emit NavigateToSelectAttachmentSaveLocation on success`() =
runTest {
val loginViewState = createViewState(
common = DEFAULT_COMMON.copy(requiresReprompt = false),
)
val mockCipherView = mockk<CipherView> {
every {
toViewState(
isPremiumUser = true,
totpCodeItemData = null,
)
} returns loginViewState
}
mutableVaultItemFlow.value = DataState.Loaded(data = mockCipherView)
mutableAuthCodeItemFlow.value = DataState.Loaded(data = null)
val loginState = DEFAULT_STATE.copy(viewState = loginViewState)
val viewModel = createViewModel(state = loginState)
val attachment = VaultItemState.ViewState.Content.Common.AttachmentItem(
id = "attachment-id",
displaySize = "11 MB",
isLargeFile = true,
isDownloadAllowed = false,
url = "https://example.com",
title = "test.mp4",
)
val file = mockk<File>()
coEvery {
vaultRepo.downloadAttachment(any(), any())
} returns DownloadAttachmentResult.Success(file)
viewModel.stateFlow.test {
assertEquals(
loginState,
awaitItem(),
)
viewModel.trySendAction(
VaultItemAction.Common.AttachmentDownloadClick(attachment),
)
assertEquals(
loginState.copy(
dialog = VaultItemState.DialogState.Loading(
R.string.downloading.asText(),
),
),
awaitItem(),
)
}
viewModel.eventFlow.test {
assertEquals(
VaultItemEvent.NavigateToSelectAttachmentSaveLocation("test.mp4"),
awaitItem(),
)
}
coVerify(exactly = 1) {
vaultRepo.downloadAttachment(any(), any())
}
}
@Suppress("MaxLineLength")
@Test
fun `on AttachmentFileLocationReceive success should hide loading dialog, copy file, delete file, and show toast`() =
runTest {
val file = mockk<File>()
val viewModel = createViewModel(state = DEFAULT_STATE, tempAttachmentFile = file)
coEvery {
mockFileManager.deleteFile(any())
} just runs
val uri = mockk<Uri>()
coEvery {
mockFileManager.fileToUri(uri, file)
} returns true
viewModel.trySendAction(
VaultItemAction.Common.AttachmentFileLocationReceive(uri),
)
assertEquals(
DEFAULT_STATE,
viewModel.stateFlow.value,
)
viewModel.eventFlow.test {
assertEquals(
VaultItemEvent.ShowToast(R.string.save_attachment_success.asText()),
awaitItem(),
)
}
coVerify(exactly = 1) {
mockFileManager.deleteFile(file)
mockFileManager.fileToUri(uri, file)
}
}
@Suppress("MaxLineLength")
@Test
fun `on AttachmentFileLocationReceive failure should hide loading dialog, copy file, delete file, and show dialog`() =
runTest {
val file = mockk<File>()
val viewModel = createViewModel(state = DEFAULT_STATE, tempAttachmentFile = file)
coEvery {
mockFileManager.deleteFile(any())
} just runs
val uri = mockk<Uri>()
coEvery {
mockFileManager.fileToUri(uri, file)
} returns false
viewModel.stateFlow.test {
viewModel.trySendAction(
VaultItemAction.Common.AttachmentFileLocationReceive(uri),
)
assertEquals(
DEFAULT_STATE,
awaitItem(),
)
assertEquals(
DEFAULT_STATE.copy(
dialog = VaultItemState.DialogState.Generic(
R.string.unable_to_save_attachment.asText(),
),
),
awaitItem(),
)
}
coVerify(exactly = 1) {
mockFileManager.deleteFile(file)
mockFileManager.fileToUri(uri, file)
}
}
@Test
fun `on NoAttachmentFileLocationReceive failure should show dialog`() {
val file = mockk<File>()
val viewModel = createViewModel(state = DEFAULT_STATE, tempAttachmentFile = file)
coEvery {
mockFileManager.deleteFile(any())
} just runs
viewModel.trySendAction(VaultItemAction.Common.NoAttachmentFileLocationReceive)
assertEquals(
DEFAULT_STATE.copy(
dialog = VaultItemState.DialogState.Generic(
R.string.unable_to_save_attachment.asText(),
),
),
viewModel.stateFlow.value,
)
coVerify { mockFileManager.deleteFile(file) }
}
}
@Nested
@ -1488,20 +1780,25 @@ class VaultItemViewModelTest : BaseViewModelTest() {
}
}
@Suppress("LongParameterList")
private fun createViewModel(
state: VaultItemState?,
vaultItemId: String = VAULT_ITEM_ID,
bitwardenClipboardManager: BitwardenClipboardManager = clipboardManager,
authRepository: AuthRepository = authRepo,
vaultRepository: VaultRepository = vaultRepo,
fileManager: FileManager = mockFileManager,
tempAttachmentFile: File? = null,
): VaultItemViewModel = VaultItemViewModel(
savedStateHandle = SavedStateHandle().apply {
set("state", state)
set("vault_item_id", vaultItemId)
set("tempAttachmentFile", tempAttachmentFile)
},
clipboardManager = bitwardenClipboardManager,
authRepository = authRepository,
vaultRepository = vaultRepository,
fileManager = fileManager,
)
private fun createViewState(
@ -1619,6 +1916,16 @@ class VaultItemViewModelTest : BaseViewModelTest() {
),
requiresReprompt = true,
currentCipher = createMockCipherView(number = 1),
attachments = listOf(
VaultItemState.ViewState.Content.Common.AttachmentItem(
id = "attachment-id",
displaySize = "11 MB",
isLargeFile = true,
isDownloadAllowed = true,
url = "https://example.com",
title = "test.mp4",
),
),
)
private val DEFAULT_VIEW_STATE: VaultItemState.ViewState.Content =

View file

@ -38,7 +38,8 @@ class CipherViewExtensionsTest {
assertEquals(
VaultItemState.ViewState.Content(
common = createCommonContent(isEmpty = false).copy(currentCipher = cipherView),
common = createCommonContent(isEmpty = false, isPremiumUser = true)
.copy(currentCipher = cipherView),
type = createLoginContent(isEmpty = false),
),
viewState,
@ -62,7 +63,8 @@ class CipherViewExtensionsTest {
assertEquals(
VaultItemState.ViewState.Content(
common = createCommonContent(isEmpty = false).copy(currentCipher = cipherView),
common = createCommonContent(isEmpty = false, isPremiumUser = isPremiumUser)
.copy(currentCipher = cipherView),
type = createLoginContent(isEmpty = false).copy(isPremiumUser = isPremiumUser),
),
viewState,
@ -79,7 +81,7 @@ class CipherViewExtensionsTest {
assertEquals(
VaultItemState.ViewState.Content(
common = createCommonContent(isEmpty = true).copy(
common = createCommonContent(isEmpty = true, isPremiumUser = true).copy(
currentCipher = cipherView,
),
type = createLoginContent(isEmpty = true),
@ -98,7 +100,8 @@ class CipherViewExtensionsTest {
assertEquals(
VaultItemState.ViewState.Content(
common = createCommonContent(isEmpty = false).copy(currentCipher = cipherView),
common = createCommonContent(isEmpty = false, isPremiumUser = true)
.copy(currentCipher = cipherView),
type = createIdentityContent(isEmpty = false),
),
viewState,
@ -115,7 +118,8 @@ class CipherViewExtensionsTest {
assertEquals(
VaultItemState.ViewState.Content(
common = createCommonContent(isEmpty = true).copy(currentCipher = cipherView),
common = createCommonContent(isEmpty = true, isPremiumUser = true)
.copy(currentCipher = cipherView),
type = createIdentityContent(isEmpty = true),
),
viewState,
@ -142,7 +146,8 @@ class CipherViewExtensionsTest {
assertEquals(
VaultItemState.ViewState.Content(
common = createCommonContent(isEmpty = false).copy(currentCipher = cipherView),
common = createCommonContent(isEmpty = false, isPremiumUser = true)
.copy(currentCipher = cipherView),
type = createIdentityContent(
isEmpty = false,
identityName = "Mx middleName",
@ -174,7 +179,7 @@ class CipherViewExtensionsTest {
assertEquals(
VaultItemState.ViewState.Content(
common = createCommonContent(isEmpty = false).copy(
common = createCommonContent(isEmpty = false, isPremiumUser = true).copy(
currentCipher = cipherView.copy(
identity = cipherView.identity?.copy(
address1 = null,
@ -209,7 +214,8 @@ class CipherViewExtensionsTest {
assertEquals(
VaultItemState.ViewState.Content(
common = createCommonContent(isEmpty = false).copy(currentCipher = cipherView),
common = createCommonContent(isEmpty = false, isPremiumUser = true)
.copy(currentCipher = cipherView),
type = VaultItemState.ViewState.Content.ItemType.SecureNote,
),
viewState,
@ -226,7 +232,8 @@ class CipherViewExtensionsTest {
)
val expectedState = VaultItemState.ViewState.Content(
common = createCommonContent(isEmpty = true).copy(currentCipher = cipherView),
common = createCommonContent(isEmpty = true, isPremiumUser = true)
.copy(currentCipher = cipherView),
type = VaultItemState.ViewState.Content.ItemType.SecureNote,
)

View file

@ -1,5 +1,6 @@
package com.x8bit.bitwarden.ui.vault.feature.item.util
import com.bitwarden.core.AttachmentView
import com.bitwarden.core.CipherRepromptType
import com.bitwarden.core.CipherType
import com.bitwarden.core.CipherView
@ -86,7 +87,17 @@ fun createCipherView(type: CipherType, isEmpty: Boolean): CipherView =
edit = false,
viewPassword = false,
localData = null,
attachments = null,
attachments = listOf(
AttachmentView(
id = "attachment-id",
sizeName = "11 MB",
size = "11000000",
url = "https://example.com",
fileName = "test.mp4",
key = "key",
),
)
.takeUnless { isEmpty },
fields = listOf(
FieldView(
name = "text",
@ -132,7 +143,10 @@ fun createCipherView(type: CipherType, isEmpty: Boolean): CipherView =
revisionDate = Instant.ofEpochSecond(1_000L),
)
fun createCommonContent(isEmpty: Boolean): VaultItemState.ViewState.Content.Common =
fun createCommonContent(
isEmpty: Boolean,
isPremiumUser: Boolean,
): VaultItemState.ViewState.Content.Common =
if (isEmpty) {
VaultItemState.ViewState.Content.Common(
name = "mockName",
@ -140,6 +154,7 @@ fun createCommonContent(isEmpty: Boolean): VaultItemState.ViewState.Content.Comm
notes = null,
customFields = emptyList(),
requiresReprompt = true,
attachments = emptyList(),
)
} else {
VaultItemState.ViewState.Content.Common(
@ -172,6 +187,16 @@ fun createCommonContent(isEmpty: Boolean): VaultItemState.ViewState.Content.Comm
),
),
requiresReprompt = true,
attachments = listOf(
VaultItemState.ViewState.Content.Common.AttachmentItem(
id = "attachment-id",
displaySize = "11 MB",
isLargeFile = true,
isDownloadAllowed = isPremiumUser,
url = "https://example.com",
title = "test.mp4",
),
),
)
}