diff --git a/app/src/main/java/com/x8bit/bitwarden/data/platform/datasource/network/retrofit/RetrofitsImpl.kt b/app/src/main/java/com/x8bit/bitwarden/data/platform/datasource/network/retrofit/RetrofitsImpl.kt index c9ef0077b..2acf93eb0 100644 --- a/app/src/main/java/com/x8bit/bitwarden/data/platform/datasource/network/retrofit/RetrofitsImpl.kt +++ b/app/src/main/java/com/x8bit/bitwarden/data/platform/datasource/network/retrofit/RetrofitsImpl.kt @@ -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 diff --git a/app/src/main/java/com/x8bit/bitwarden/data/vault/datasource/network/api/CiphersApi.kt b/app/src/main/java/com/x8bit/bitwarden/data/vault/datasource/network/api/CiphersApi.kt index 9bd8be51d..888c438bc 100644 --- a/app/src/main/java/com/x8bit/bitwarden/data/vault/datasource/network/api/CiphersApi.kt +++ b/app/src/main/java/com/x8bit/bitwarden/data/vault/datasource/network/api/CiphersApi.kt @@ -121,4 +121,13 @@ interface CiphersApi { suspend fun getCipher( @Path("cipherId") cipherId: String, ): Result + + /** + * Gets a cipher attachment. + */ + @GET("ciphers/{cipherId}/attachment/{attachmentId}") + suspend fun getCipherAttachment( + @Path("cipherId") cipherId: String, + @Path("attachmentId") attachmentId: String, + ): Result } diff --git a/app/src/main/java/com/x8bit/bitwarden/data/vault/datasource/network/api/DownloadApi.kt b/app/src/main/java/com/x8bit/bitwarden/data/vault/datasource/network/api/DownloadApi.kt new file mode 100644 index 000000000..ae7bd4471 --- /dev/null +++ b/app/src/main/java/com/x8bit/bitwarden/data/vault/datasource/network/api/DownloadApi.kt @@ -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 +} diff --git a/app/src/main/java/com/x8bit/bitwarden/data/vault/datasource/network/di/VaultNetworkModule.kt b/app/src/main/java/com/x8bit/bitwarden/data/vault/datasource/network/di/VaultNetworkModule.kt index 395348eda..10c5b196d 100644 --- a/app/src/main/java/com/x8bit/bitwarden/data/vault/datasource/network/di/VaultNetworkModule.kt +++ b/app/src/main/java/com/x8bit/bitwarden/data/vault/datasource/network/di/VaultNetworkModule.kt @@ -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(), + ) } diff --git a/app/src/main/java/com/x8bit/bitwarden/data/vault/datasource/network/service/CiphersService.kt b/app/src/main/java/com/x8bit/bitwarden/data/vault/datasource/network/service/CiphersService.kt index ab6847b93..34bf84716 100644 --- a/app/src/main/java/com/x8bit/bitwarden/data/vault/datasource/network/service/CiphersService.kt +++ b/app/src/main/java/com/x8bit/bitwarden/data/vault/datasource/network/service/CiphersService.kt @@ -93,4 +93,12 @@ interface CiphersService { * Attempt to retrieve a cipher. */ suspend fun getCipher(cipherId: String): Result + + /** + * Attempt to retrieve a cipher's attachment data. + */ + suspend fun getCipherAttachment( + cipherId: String, + attachmentId: String, + ): Result } diff --git a/app/src/main/java/com/x8bit/bitwarden/data/vault/datasource/network/service/CiphersServiceImpl.kt b/app/src/main/java/com/x8bit/bitwarden/data/vault/datasource/network/service/CiphersServiceImpl.kt index b34359207..56142a9e0 100644 --- a/app/src/main/java/com/x8bit/bitwarden/data/vault/datasource/network/service/CiphersServiceImpl.kt +++ b/app/src/main/java/com/x8bit/bitwarden/data/vault/datasource/network/service/CiphersServiceImpl.kt @@ -150,4 +150,13 @@ class CiphersServiceImpl( cipherId: String, ): Result = ciphersApi.getCipher(cipherId = cipherId) + + override suspend fun getCipherAttachment( + cipherId: String, + attachmentId: String, + ): Result = + ciphersApi.getCipherAttachment( + cipherId = cipherId, + attachmentId = attachmentId, + ) } diff --git a/app/src/main/java/com/x8bit/bitwarden/data/vault/datasource/network/service/DownloadService.kt b/app/src/main/java/com/x8bit/bitwarden/data/vault/datasource/network/service/DownloadService.kt new file mode 100644 index 000000000..f0527e912 --- /dev/null +++ b/app/src/main/java/com/x8bit/bitwarden/data/vault/datasource/network/service/DownloadService.kt @@ -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 +} diff --git a/app/src/main/java/com/x8bit/bitwarden/data/vault/datasource/network/service/DownloadServiceImpl.kt b/app/src/main/java/com/x8bit/bitwarden/data/vault/datasource/network/service/DownloadServiceImpl.kt new file mode 100644 index 000000000..50e915362 --- /dev/null +++ b/app/src/main/java/com/x8bit/bitwarden/data/vault/datasource/network/service/DownloadServiceImpl.kt @@ -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 = + downloadApi.getDataStream(url = url) +} diff --git a/app/src/main/java/com/x8bit/bitwarden/data/vault/datasource/sdk/VaultSdkSource.kt b/app/src/main/java/com/x8bit/bitwarden/data/vault/datasource/sdk/VaultSdkSource.kt index 413c32642..ff079aacc 100644 --- a/app/src/main/java/com/x8bit/bitwarden/data/vault/datasource/sdk/VaultSdkSource.kt +++ b/app/src/main/java/com/x8bit/bitwarden/data/vault/datasource/sdk/VaultSdkSource.kt @@ -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, ): Result> + /** + * 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 + /** * Encrypts a given password history item for the user with the given [userId]. * diff --git a/app/src/main/java/com/x8bit/bitwarden/data/vault/datasource/sdk/VaultSdkSourceImpl.kt b/app/src/main/java/com/x8bit/bitwarden/data/vault/datasource/sdk/VaultSdkSourceImpl.kt index 0afaaf62e..65cc57c69 100644 --- a/app/src/main/java/com/x8bit/bitwarden/data/vault/datasource/sdk/VaultSdkSourceImpl.kt +++ b/app/src/main/java/com/x8bit/bitwarden/data/vault/datasource/sdk/VaultSdkSourceImpl.kt @@ -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 = + runCatching { + getClient(userId = userId) + .vault() + .attachments() + .decryptFile( + cipher = cipher, + attachment = attachment, + encryptedFilePath = encryptedFilePath, + decryptedFilePath = decryptedFilePath, + ) + } + override suspend fun encryptPasswordHistory( userId: String, passwordHistory: PasswordHistoryView, diff --git a/app/src/main/java/com/x8bit/bitwarden/data/vault/manager/FileManager.kt b/app/src/main/java/com/x8bit/bitwarden/data/vault/manager/FileManager.kt index 1c0810a7c..0307ea699 100644 --- a/app/src/main/java/com/x8bit/bitwarden/data/vault/manager/FileManager.kt +++ b/app/src/main/java/com/x8bit/bitwarden/data/vault/manager/FileManager.kt @@ -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] */ diff --git a/app/src/main/java/com/x8bit/bitwarden/data/vault/manager/FileManagerImpl.kt b/app/src/main/java/com/x8bit/bitwarden/data/vault/manager/FileManagerImpl.kt index 3238a7f2c..a65233448 100644 --- a/app/src/main/java/com/x8bit/bitwarden/data/vault/manager/FileManagerImpl.kt +++ b/app/src/main/java/com/x8bit/bitwarden/data/vault/manager/FileManagerImpl.kt @@ -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 diff --git a/app/src/main/java/com/x8bit/bitwarden/data/vault/manager/di/VaultManagerModule.kt b/app/src/main/java/com/x8bit/bitwarden/data/vault/manager/di/VaultManagerModule.kt index d2039289b..8c57d721d 100644 --- a/app/src/main/java/com/x8bit/bitwarden/data/vault/manager/di/VaultManagerModule.kt +++ b/app/src/main/java/com/x8bit/bitwarden/data/vault/manager/di/VaultManagerModule.kt @@ -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 diff --git a/app/src/main/java/com/x8bit/bitwarden/data/vault/manager/model/DownloadResult.kt b/app/src/main/java/com/x8bit/bitwarden/data/vault/manager/model/DownloadResult.kt new file mode 100644 index 000000000..5bbc4ed81 --- /dev/null +++ b/app/src/main/java/com/x8bit/bitwarden/data/vault/manager/model/DownloadResult.kt @@ -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() +} diff --git a/app/src/main/java/com/x8bit/bitwarden/data/vault/repository/VaultRepository.kt b/app/src/main/java/com/x8bit/bitwarden/data/vault/repository/VaultRepository.kt index feb51a893..e9a635bd8 100644 --- a/app/src/main/java/com/x8bit/bitwarden/data/vault/repository/VaultRepository.kt +++ b/app/src/main/java/com/x8bit/bitwarden/data/vault/repository/VaultRepository.kt @@ -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. */ diff --git a/app/src/main/java/com/x8bit/bitwarden/data/vault/repository/VaultRepositoryImpl.kt b/app/src/main/java/com/x8bit/bitwarden/data/vault/repository/VaultRepositoryImpl.kt index 40d603015..905f00350 100644 --- a/app/src/main/java/com/x8bit/bitwarden/data/vault/repository/VaultRepositoryImpl.kt +++ b/app/src/main/java/com/x8bit/bitwarden/data/vault/repository/VaultRepositoryImpl.kt @@ -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?, diff --git a/app/src/main/java/com/x8bit/bitwarden/data/vault/repository/model/DownloadAttachmentResult.kt b/app/src/main/java/com/x8bit/bitwarden/data/vault/repository/model/DownloadAttachmentResult.kt new file mode 100644 index 000000000..ea96d6f44 --- /dev/null +++ b/app/src/main/java/com/x8bit/bitwarden/data/vault/repository/model/DownloadAttachmentResult.kt @@ -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() +} diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/platform/manager/intent/IntentManager.kt b/app/src/main/java/com/x8bit/bitwarden/ui/platform/manager/intent/IntentManager.kt index 97f7a1b87..7441a81cd 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/platform/manager/intent/IntentManager.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/platform/manager/intent/IntentManager.kt @@ -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. */ diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/platform/manager/intent/IntentManagerImpl.kt b/app/src/main/java/com/x8bit/bitwarden/ui/platform/manager/intent/IntentManagerImpl.kt index a3ef64a02..f197fa441 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/platform/manager/intent/IntentManagerImpl.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/platform/manager/intent/IntentManagerImpl.kt @@ -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) diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/item/VaultItemAttachmentContent.kt b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/item/VaultItemAttachmentContent.kt new file mode 100644 index 000000000..67fe99aff --- /dev/null +++ b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/item/VaultItemAttachmentContent.kt @@ -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 },) + } +} diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/item/VaultItemCardContent.kt b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/item/VaultItemCardContent.kt index 4b172a023..3b4f2e7d2 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/item/VaultItemCardContent.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/item/VaultItemCardContent.kt @@ -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( diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/item/VaultItemIdentityContent.kt b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/item/VaultItemIdentityContent.kt index fc3050119..8e7d31a42 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/item/VaultItemIdentityContent.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/item/VaultItemIdentityContent.kt @@ -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( diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/item/VaultItemLoginContent.kt b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/item/VaultItemLoginContent.kt index b7fd01e30..72db2c72a 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/item/VaultItemLoginContent.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/item/VaultItemLoginContent.kt @@ -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( diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/item/VaultItemScreen.kt b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/item/VaultItemScreen.kt index 701950745..5d4c86185 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/item/VaultItemScreen.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/item/VaultItemScreen.kt @@ -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), + ) + } } } diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/item/VaultItemSecureNoteContent.kt b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/item/VaultItemSecureNoteContent.kt index 54d9935d3..abb79bb89 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/item/VaultItemSecureNoteContent.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/item/VaultItemSecureNoteContent.kt @@ -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( diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/item/VaultItemViewModel.kt b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/item/VaultItemViewModel.kt index d533b7a3b..15ef21368 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/item/VaultItemViewModel.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/item/VaultItemViewModel.kt @@ -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( // 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?, ) : 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. diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/item/handlers/VaultCommonItemTypeHandlers.kt b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/item/handlers/VaultCommonItemTypeHandlers.kt index fc5300d46..468720ec0 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/item/handlers/VaultCommonItemTypeHandlers.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/item/handlers/VaultCommonItemTypeHandlers.kt @@ -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)) + }, ) } } diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/item/util/CipherViewExtensions.kt b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/item/util/CipherViewExtensions.kt index fd9ba97ef..8d0caa3c5 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/item/util/CipherViewExtensions.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/item/util/CipherViewExtensions.kt @@ -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 -> { diff --git a/app/src/main/res/drawable/ic_download.xml b/app/src/main/res/drawable/ic_download.xml new file mode 100644 index 000000000..1888e7a9f --- /dev/null +++ b/app/src/main/res/drawable/ic_download.xml @@ -0,0 +1,13 @@ + + + + diff --git a/app/src/test/java/com/x8bit/bitwarden/data/vault/datasource/network/service/CiphersServiceTest.kt b/app/src/test/java/com/x8bit/bitwarden/data/vault/datasource/network/service/CiphersServiceTest.kt index 9ccad3913..6fa22aa81 100644 --- a/app/src/test/java/com/x8bit/bitwarden/data/vault/datasource/network/service/CiphersServiceTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/data/vault/datasource/network/service/CiphersServiceTest.kt @@ -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" +} +""" diff --git a/app/src/test/java/com/x8bit/bitwarden/data/vault/datasource/network/service/DownloadServiceTest.kt b/app/src/test/java/com/x8bit/bitwarden/data/vault/datasource/network/service/DownloadServiceTest.kt new file mode 100644 index 000000000..8977db20a --- /dev/null +++ b/app/src/test/java/com/x8bit/bitwarden/data/vault/datasource/network/service/DownloadServiceTest.kt @@ -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())) + } +} diff --git a/app/src/test/java/com/x8bit/bitwarden/data/vault/datasource/sdk/VaultSdkSourceTest.kt b/app/src/test/java/com/x8bit/bitwarden/data/vault/datasource/sdk/VaultSdkSourceTest.kt index f6daa23ed..faba1469c 100644 --- a/app/src/test/java/com/x8bit/bitwarden/data/vault/datasource/sdk/VaultSdkSourceTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/data/vault/datasource/sdk/VaultSdkSourceTest.kt @@ -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() + val mockAttachment = mockk() + 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 { diff --git a/app/src/test/java/com/x8bit/bitwarden/data/vault/repository/VaultRepositoryTest.kt b/app/src/test/java/com/x8bit/bitwarden/data/vault/repository/VaultRepositoryTest.kt index ce3fe917f..753f79ced 100644 --- a/app/src/test/java/com/x8bit/bitwarden/data/vault/repository/VaultRepositoryTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/data/vault/repository/VaultRepositoryTest.kt @@ -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 { + every { id } returns attachmentId + } + val cipher = mockk { + 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 { + every { id } returns attachmentId + } + val cipher = mockk { + 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 { + every { id } returns attachmentId + } + val cipher = mockk { + 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 { + 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 { + every { id } returns attachmentId + } + val cipher = mockk { + 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 { + 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 { + every { id } returns attachmentId + } + val cipher = mockk { + 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 { + every { url } returns "https://bitwarden.com" + } + coEvery { + ciphersService.getCipherAttachment(any(), any()) + } returns response.asSuccess() + + val file = mockk { + 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 { + every { id } returns attachmentId + } + val cipher = mockk { + 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 { + every { url } returns "https://bitwarden.com" + } + coEvery { + ciphersService.getCipherAttachment(any(), any()) + } returns response.asSuccess() + + val file = mockk { + 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>() - setupVaultDiskSourceFlows(ciphersFlow = ciphersFlow) + val ciphersFlow = bufferedMutableSharedFlow>() + 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>() + val collectionsFlow = bufferedMutableSharedFlow>() + 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>() - val collectionsFlow = bufferedMutableSharedFlow>() - 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().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>() + val collectionsFlow = bufferedMutableSharedFlow>() + setupVaultDiskSourceFlows( + ciphersFlow = ciphersFlow, + collectionsFlow = collectionsFlow, ) - } returns listOf().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>() - val collectionsFlow = bufferedMutableSharedFlow>() - 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 diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/item/VaultItemScreenTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/item/VaultItemScreenTest.kt index f74710713..bc98bcc6a 100644 --- a/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/item/VaultItemScreenTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/item/VaultItemScreenTest.kt @@ -60,7 +60,7 @@ class VaultItemScreenTest : BaseComposeTest() { private var onNavigateToMoveToOrganizationItemId: String? = null private var onNavigateToAttachmentsId: String? = null - private val intentManager = mockk() + private val intentManager = mockk(relaxed = true) private val mutableEventFlow = bufferedMutableSharedFlow() 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 = diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/item/VaultItemViewModelTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/item/VaultItemViewModelTest.kt index 395a2f253..fb71c42f9 100644 --- a/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/item/VaultItemViewModelTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/item/VaultItemViewModelTest.kt @@ -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 { + 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 { + 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 { + 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() + 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() + val viewModel = createViewModel(state = DEFAULT_STATE, tempAttachmentFile = file) + + coEvery { + mockFileManager.deleteFile(any()) + } just runs + + val uri = mockk() + 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() + val viewModel = createViewModel(state = DEFAULT_STATE, tempAttachmentFile = file) + + coEvery { + mockFileManager.deleteFile(any()) + } just runs + + val uri = mockk() + 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() + 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 = diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/item/util/CipherViewExtensionsTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/item/util/CipherViewExtensionsTest.kt index 90e7b611c..bbd9da9cc 100644 --- a/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/item/util/CipherViewExtensionsTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/item/util/CipherViewExtensionsTest.kt @@ -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, ) diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/item/util/VaultItemTestUtil.kt b/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/item/util/VaultItemTestUtil.kt index f94ae555e..fd35d2b0b 100644 --- a/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/item/util/VaultItemTestUtil.kt +++ b/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/item/util/VaultItemTestUtil.kt @@ -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", + ), + ), ) }