mirror of
https://github.com/bitwarden/android.git
synced 2024-11-23 01:46:00 +03:00
BIT-1527: Handle attachment downloads (#894)
This commit is contained in:
parent
6e945a4385
commit
8bb754f85b
37 changed files with 1956 additions and 171 deletions
|
@ -53,15 +53,15 @@ class RetrofitsImpl(
|
||||||
|
|
||||||
//region Other Retrofits
|
//region Other Retrofits
|
||||||
|
|
||||||
override val staticRetrofitBuilder: Retrofit.Builder by lazy {
|
override val staticRetrofitBuilder: Retrofit.Builder
|
||||||
baseRetrofitBuilder
|
get() =
|
||||||
.client(
|
baseRetrofitBuilder
|
||||||
baseOkHttpClient
|
.client(
|
||||||
.newBuilder()
|
baseOkHttpClient
|
||||||
.addInterceptor(loggingInterceptor)
|
.newBuilder()
|
||||||
.build(),
|
.addInterceptor(loggingInterceptor)
|
||||||
)
|
.build(),
|
||||||
}
|
)
|
||||||
|
|
||||||
//endregion Other Retrofits
|
//endregion Other Retrofits
|
||||||
|
|
||||||
|
|
|
@ -121,4 +121,13 @@ interface CiphersApi {
|
||||||
suspend fun getCipher(
|
suspend fun getCipher(
|
||||||
@Path("cipherId") cipherId: String,
|
@Path("cipherId") cipherId: String,
|
||||||
): Result<SyncResponseJson.Cipher>
|
): Result<SyncResponseJson.Cipher>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets a cipher attachment.
|
||||||
|
*/
|
||||||
|
@GET("ciphers/{cipherId}/attachment/{attachmentId}")
|
||||||
|
suspend fun getCipherAttachment(
|
||||||
|
@Path("cipherId") cipherId: String,
|
||||||
|
@Path("attachmentId") attachmentId: String,
|
||||||
|
): Result<SyncResponseJson.Cipher.Attachment>
|
||||||
}
|
}
|
||||||
|
|
|
@ -0,0 +1,20 @@
|
||||||
|
package com.x8bit.bitwarden.data.vault.datasource.network.api
|
||||||
|
|
||||||
|
import okhttp3.ResponseBody
|
||||||
|
import retrofit2.http.GET
|
||||||
|
import retrofit2.http.Streaming
|
||||||
|
import retrofit2.http.Url
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Defines endpoints to retrieve content from arbitrary URLs.
|
||||||
|
*/
|
||||||
|
interface DownloadApi {
|
||||||
|
/**
|
||||||
|
* Streams data from a [url].
|
||||||
|
*/
|
||||||
|
@GET
|
||||||
|
@Streaming
|
||||||
|
suspend fun getDataStream(
|
||||||
|
@Url url: String,
|
||||||
|
): Result<ResponseBody>
|
||||||
|
}
|
|
@ -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.platform.datasource.network.retrofit.Retrofits
|
||||||
import com.x8bit.bitwarden.data.vault.datasource.network.service.CiphersService
|
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.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.FolderService
|
||||||
import com.x8bit.bitwarden.data.vault.datasource.network.service.FolderServiceImpl
|
import com.x8bit.bitwarden.data.vault.datasource.network.service.FolderServiceImpl
|
||||||
import com.x8bit.bitwarden.data.vault.datasource.network.service.SendsService
|
import com.x8bit.bitwarden.data.vault.datasource.network.service.SendsService
|
||||||
|
@ -78,4 +80,17 @@ object VaultNetworkModule {
|
||||||
): SyncService = SyncServiceImpl(
|
): SyncService = SyncServiceImpl(
|
||||||
syncApi = retrofits.authenticatedApiRetrofit.create(),
|
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(),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
|
@ -93,4 +93,12 @@ interface CiphersService {
|
||||||
* Attempt to retrieve a cipher.
|
* Attempt to retrieve a cipher.
|
||||||
*/
|
*/
|
||||||
suspend fun getCipher(cipherId: String): Result<SyncResponseJson.Cipher>
|
suspend fun getCipher(cipherId: String): Result<SyncResponseJson.Cipher>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Attempt to retrieve a cipher's attachment data.
|
||||||
|
*/
|
||||||
|
suspend fun getCipherAttachment(
|
||||||
|
cipherId: String,
|
||||||
|
attachmentId: String,
|
||||||
|
): Result<SyncResponseJson.Cipher.Attachment>
|
||||||
}
|
}
|
||||||
|
|
|
@ -150,4 +150,13 @@ class CiphersServiceImpl(
|
||||||
cipherId: String,
|
cipherId: String,
|
||||||
): Result<SyncResponseJson.Cipher> =
|
): Result<SyncResponseJson.Cipher> =
|
||||||
ciphersApi.getCipher(cipherId = cipherId)
|
ciphersApi.getCipher(cipherId = cipherId)
|
||||||
|
|
||||||
|
override suspend fun getCipherAttachment(
|
||||||
|
cipherId: String,
|
||||||
|
attachmentId: String,
|
||||||
|
): Result<SyncResponseJson.Cipher.Attachment> =
|
||||||
|
ciphersApi.getCipherAttachment(
|
||||||
|
cipherId = cipherId,
|
||||||
|
attachmentId = attachmentId,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
|
@ -0,0 +1,13 @@
|
||||||
|
package com.x8bit.bitwarden.data.vault.datasource.network.service
|
||||||
|
|
||||||
|
import okhttp3.ResponseBody
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Provides an API for querying arbitrary endpoints.
|
||||||
|
*/
|
||||||
|
interface DownloadService {
|
||||||
|
/**
|
||||||
|
* Streams data from [url], returning a raw [ResponseBody].
|
||||||
|
*/
|
||||||
|
suspend fun getDataStream(url: String): Result<ResponseBody>
|
||||||
|
}
|
|
@ -0,0 +1,16 @@
|
||||||
|
package com.x8bit.bitwarden.data.vault.datasource.network.service
|
||||||
|
|
||||||
|
import com.x8bit.bitwarden.data.vault.datasource.network.api.DownloadApi
|
||||||
|
import okhttp3.ResponseBody
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Default implementation of [DownloadService].
|
||||||
|
*/
|
||||||
|
class DownloadServiceImpl(
|
||||||
|
private val downloadApi: DownloadApi,
|
||||||
|
) : DownloadService {
|
||||||
|
override suspend fun getDataStream(
|
||||||
|
url: String,
|
||||||
|
): Result<ResponseBody> =
|
||||||
|
downloadApi.getDataStream(url = url)
|
||||||
|
}
|
|
@ -1,5 +1,6 @@
|
||||||
package com.x8bit.bitwarden.data.vault.datasource.sdk
|
package com.x8bit.bitwarden.data.vault.datasource.sdk
|
||||||
|
|
||||||
|
import com.bitwarden.core.Attachment
|
||||||
import com.bitwarden.core.AttachmentEncryptResult
|
import com.bitwarden.core.AttachmentEncryptResult
|
||||||
import com.bitwarden.core.AttachmentView
|
import com.bitwarden.core.AttachmentView
|
||||||
import com.bitwarden.core.Cipher
|
import com.bitwarden.core.Cipher
|
||||||
|
@ -274,6 +275,18 @@ interface VaultSdkSource {
|
||||||
folderList: List<Folder>,
|
folderList: List<Folder>,
|
||||||
): Result<List<FolderView>>
|
): Result<List<FolderView>>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decrypts a [cipher] [attachment] file found at [encryptedFilePath] saving it at
|
||||||
|
* [decryptedFilePath] for the user with the given [userId]
|
||||||
|
*/
|
||||||
|
suspend fun decryptFile(
|
||||||
|
userId: String,
|
||||||
|
cipher: Cipher,
|
||||||
|
attachment: Attachment,
|
||||||
|
encryptedFilePath: String,
|
||||||
|
decryptedFilePath: String,
|
||||||
|
): Result<Unit>
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Encrypts a given password history item for the user with the given [userId].
|
* Encrypts a given password history item for the user with the given [userId].
|
||||||
*
|
*
|
||||||
|
|
|
@ -1,5 +1,6 @@
|
||||||
package com.x8bit.bitwarden.data.vault.datasource.sdk
|
package com.x8bit.bitwarden.data.vault.datasource.sdk
|
||||||
|
|
||||||
|
import com.bitwarden.core.Attachment
|
||||||
import com.bitwarden.core.AttachmentEncryptResult
|
import com.bitwarden.core.AttachmentEncryptResult
|
||||||
import com.bitwarden.core.AttachmentView
|
import com.bitwarden.core.AttachmentView
|
||||||
import com.bitwarden.core.Cipher
|
import com.bitwarden.core.Cipher
|
||||||
|
@ -284,6 +285,25 @@ class VaultSdkSourceImpl(
|
||||||
.decryptList(folderList)
|
.decryptList(folderList)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override suspend fun decryptFile(
|
||||||
|
userId: String,
|
||||||
|
cipher: Cipher,
|
||||||
|
attachment: Attachment,
|
||||||
|
encryptedFilePath: String,
|
||||||
|
decryptedFilePath: String,
|
||||||
|
): Result<Unit> =
|
||||||
|
runCatching {
|
||||||
|
getClient(userId = userId)
|
||||||
|
.vault()
|
||||||
|
.attachments()
|
||||||
|
.decryptFile(
|
||||||
|
cipher = cipher,
|
||||||
|
attachment = attachment,
|
||||||
|
encryptedFilePath = encryptedFilePath,
|
||||||
|
decryptedFilePath = decryptedFilePath,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
override suspend fun encryptPasswordHistory(
|
override suspend fun encryptPasswordHistory(
|
||||||
userId: String,
|
userId: String,
|
||||||
passwordHistory: PasswordHistoryView,
|
passwordHistory: PasswordHistoryView,
|
||||||
|
|
|
@ -2,6 +2,8 @@ package com.x8bit.bitwarden.data.vault.manager
|
||||||
|
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
import com.x8bit.bitwarden.data.platform.annotation.OmitFromCoverage
|
import com.x8bit.bitwarden.data.platform.annotation.OmitFromCoverage
|
||||||
|
import com.x8bit.bitwarden.data.vault.manager.model.DownloadResult
|
||||||
|
import java.io.File
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Manages reading files.
|
* Manages reading files.
|
||||||
|
@ -9,6 +11,23 @@ import com.x8bit.bitwarden.data.platform.annotation.OmitFromCoverage
|
||||||
@OmitFromCoverage
|
@OmitFromCoverage
|
||||||
interface FileManager {
|
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]
|
* Reads the [fileUri] into memory and returns the raw [ByteArray]
|
||||||
*/
|
*/
|
||||||
|
|
|
@ -3,7 +3,14 @@ package com.x8bit.bitwarden.data.vault.manager
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
import com.x8bit.bitwarden.data.platform.annotation.OmitFromCoverage
|
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.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.
|
* The buffer size to be used when reading from an input stream.
|
||||||
|
@ -16,8 +23,69 @@ private const val BUFFER_SIZE: Int = 1024
|
||||||
@OmitFromCoverage
|
@OmitFromCoverage
|
||||||
class FileManagerImpl(
|
class FileManagerImpl(
|
||||||
private val context: Context,
|
private val context: Context,
|
||||||
|
private val downloadService: DownloadService,
|
||||||
) : FileManager {
|
) : 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 =
|
override fun uriToByteArray(fileUri: Uri): ByteArray =
|
||||||
context
|
context
|
||||||
.contentResolver
|
.contentResolver
|
||||||
|
|
|
@ -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.AppForegroundManager
|
||||||
import com.x8bit.bitwarden.data.platform.manager.dispatcher.DispatcherManager
|
import com.x8bit.bitwarden.data.platform.manager.dispatcher.DispatcherManager
|
||||||
import com.x8bit.bitwarden.data.platform.repository.SettingsRepository
|
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.datasource.sdk.VaultSdkSource
|
||||||
import com.x8bit.bitwarden.data.vault.manager.FileManager
|
import com.x8bit.bitwarden.data.vault.manager.FileManager
|
||||||
import com.x8bit.bitwarden.data.vault.manager.FileManagerImpl
|
import com.x8bit.bitwarden.data.vault.manager.FileManagerImpl
|
||||||
|
@ -33,7 +34,11 @@ object VaultManagerModule {
|
||||||
@Singleton
|
@Singleton
|
||||||
fun provideFileManager(
|
fun provideFileManager(
|
||||||
@ApplicationContext context: Context,
|
@ApplicationContext context: Context,
|
||||||
): FileManager = FileManagerImpl(context)
|
downloadService: DownloadService,
|
||||||
|
): FileManager = FileManagerImpl(
|
||||||
|
context = context,
|
||||||
|
downloadService = downloadService,
|
||||||
|
)
|
||||||
|
|
||||||
@Provides
|
@Provides
|
||||||
@Singleton
|
@Singleton
|
||||||
|
|
|
@ -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()
|
||||||
|
}
|
|
@ -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.DeleteFolderResult
|
||||||
import com.x8bit.bitwarden.data.vault.repository.model.DeleteSendResult
|
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.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.GenerateTotpResult
|
||||||
import com.x8bit.bitwarden.data.vault.repository.model.RemovePasswordSendResult
|
import com.x8bit.bitwarden.data.vault.repository.model.RemovePasswordSendResult
|
||||||
import com.x8bit.bitwarden.data.vault.repository.model.RestoreCipherResult
|
import com.x8bit.bitwarden.data.vault.repository.model.RestoreCipherResult
|
||||||
|
@ -219,6 +220,15 @@ interface VaultRepository : VaultLockManager {
|
||||||
fileUri: Uri,
|
fileUri: Uri,
|
||||||
): CreateAttachmentResult
|
): 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.
|
* Attempt to delete a cipher.
|
||||||
*/
|
*/
|
||||||
|
|
|
@ -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.FileManager
|
||||||
import com.x8bit.bitwarden.data.vault.manager.TotpCodeManager
|
import com.x8bit.bitwarden.data.vault.manager.TotpCodeManager
|
||||||
import com.x8bit.bitwarden.data.vault.manager.VaultLockManager
|
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.manager.model.VerificationCodeItem
|
||||||
import com.x8bit.bitwarden.data.vault.repository.model.CreateAttachmentResult
|
import com.x8bit.bitwarden.data.vault.repository.model.CreateAttachmentResult
|
||||||
import com.x8bit.bitwarden.data.vault.repository.model.CreateCipherResult
|
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.DeleteFolderResult
|
||||||
import com.x8bit.bitwarden.data.vault.repository.model.DeleteSendResult
|
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.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.GenerateTotpResult
|
||||||
import com.x8bit.bitwarden.data.vault.repository.model.RemovePasswordSendResult
|
import com.x8bit.bitwarden.data.vault.repository.model.RemovePasswordSendResult
|
||||||
import com.x8bit.bitwarden.data.vault.repository.model.RestoreCipherResult
|
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.flow.update
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import retrofit2.HttpException
|
import retrofit2.HttpException
|
||||||
|
import java.io.File
|
||||||
import java.time.Clock
|
import java.time.Clock
|
||||||
import java.time.Instant
|
import java.time.Instant
|
||||||
import java.time.temporal.ChronoUnit
|
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(
|
override suspend fun createSend(
|
||||||
sendView: SendView,
|
sendView: SendView,
|
||||||
fileUri: Uri?,
|
fileUri: Uri?,
|
||||||
|
|
|
@ -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()
|
||||||
|
}
|
|
@ -75,6 +75,11 @@ interface IntentManager {
|
||||||
*/
|
*/
|
||||||
fun createFileChooserIntent(withCameraIntents: Boolean): Intent
|
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.
|
* Represents file information.
|
||||||
*/
|
*/
|
||||||
|
|
|
@ -9,6 +9,7 @@ import android.content.pm.PackageManager
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
import android.provider.MediaStore
|
import android.provider.MediaStore
|
||||||
import android.provider.Settings
|
import android.provider.Settings
|
||||||
|
import android.webkit.MimeTypeMap
|
||||||
import androidx.activity.compose.ManagedActivityResultLauncher
|
import androidx.activity.compose.ManagedActivityResultLauncher
|
||||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||||
import androidx.activity.result.ActivityResult
|
import androidx.activity.result.ActivityResult
|
||||||
|
@ -171,6 +172,19 @@ class IntentManagerImpl(
|
||||||
return chooserIntent
|
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 {
|
private fun getCameraFileData(): IntentManager.FileData {
|
||||||
val tmpDir = File(context.filesDir, TEMP_CAMERA_IMAGE_DIR)
|
val tmpDir = File(context.filesDir, TEMP_CAMERA_IMAGE_DIR)
|
||||||
val file = File(tmpDir, TEMP_CAMERA_IMAGE_NAME)
|
val file = File(tmpDir, TEMP_CAMERA_IMAGE_NAME)
|
||||||
|
|
|
@ -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 },)
|
||||||
|
}
|
||||||
|
}
|
|
@ -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 {
|
item {
|
||||||
Spacer(modifier = Modifier.height(24.dp))
|
Spacer(modifier = Modifier.height(24.dp))
|
||||||
VaultItemUpdateText(
|
VaultItemUpdateText(
|
||||||
|
|
|
@ -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 {
|
item {
|
||||||
Spacer(modifier = Modifier.height(24.dp))
|
Spacer(modifier = Modifier.height(24.dp))
|
||||||
VaultItemUpdateText(
|
VaultItemUpdateText(
|
||||||
|
|
|
@ -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 {
|
item {
|
||||||
Spacer(modifier = Modifier.height(24.dp))
|
Spacer(modifier = Modifier.height(24.dp))
|
||||||
VaultItemUpdateText(
|
VaultItemUpdateText(
|
||||||
|
|
|
@ -53,7 +53,7 @@ import com.x8bit.bitwarden.ui.vault.feature.item.handlers.VaultLoginItemTypeHand
|
||||||
/**
|
/**
|
||||||
* Displays the vault item screen.
|
* Displays the vault item screen.
|
||||||
*/
|
*/
|
||||||
@Suppress("LongMethod")
|
@Suppress("LongMethod", "CyclomaticComplexMethod")
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun VaultItemScreen(
|
fun VaultItemScreen(
|
||||||
|
@ -76,6 +76,16 @@ fun VaultItemScreen(
|
||||||
var pendingDeleteCipher by rememberSaveable { mutableStateOf(false) }
|
var pendingDeleteCipher by rememberSaveable { mutableStateOf(false) }
|
||||||
var pendingRestoreCipher 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 ->
|
EventsEffect(viewModel = viewModel) { event ->
|
||||||
when (event) {
|
when (event) {
|
||||||
VaultItemEvent.NavigateBack -> onNavigateBack()
|
VaultItemEvent.NavigateBack -> onNavigateBack()
|
||||||
|
@ -104,6 +114,12 @@ fun VaultItemScreen(
|
||||||
is VaultItemEvent.ShowToast -> {
|
is VaultItemEvent.ShowToast -> {
|
||||||
Toast.makeText(context, event.message(resources), Toast.LENGTH_SHORT).show()
|
Toast.makeText(context, event.message(resources), Toast.LENGTH_SHORT).show()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
is VaultItemEvent.NavigateToSelectAttachmentSaveLocation -> {
|
||||||
|
fileChooserLauncher.launch(
|
||||||
|
intentManager.createAttachmentChooserIntent(event.fileName),
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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 {
|
item {
|
||||||
Spacer(modifier = Modifier.height(24.dp))
|
Spacer(modifier = Modifier.height(24.dp))
|
||||||
Row(
|
Row(
|
||||||
|
|
|
@ -1,5 +1,6 @@
|
||||||
package com.x8bit.bitwarden.ui.vault.feature.item
|
package com.x8bit.bitwarden.ui.vault.feature.item
|
||||||
|
|
||||||
|
import android.net.Uri
|
||||||
import android.os.Parcelable
|
import android.os.Parcelable
|
||||||
import androidx.lifecycle.SavedStateHandle
|
import androidx.lifecycle.SavedStateHandle
|
||||||
import androidx.lifecycle.viewModelScope
|
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.manager.clipboard.BitwardenClipboardManager
|
||||||
import com.x8bit.bitwarden.data.platform.repository.model.DataState
|
import com.x8bit.bitwarden.data.platform.repository.model.DataState
|
||||||
import com.x8bit.bitwarden.data.platform.repository.util.combineDataStates
|
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.VaultRepository
|
||||||
import com.x8bit.bitwarden.data.vault.repository.model.DeleteCipherResult
|
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.data.vault.repository.model.RestoreCipherResult
|
||||||
import com.x8bit.bitwarden.ui.platform.base.BaseViewModel
|
import com.x8bit.bitwarden.ui.platform.base.BaseViewModel
|
||||||
import com.x8bit.bitwarden.ui.platform.base.util.Text
|
import com.x8bit.bitwarden.ui.platform.base.util.Text
|
||||||
|
@ -32,9 +35,11 @@ import kotlinx.coroutines.flow.update
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.parcelize.IgnoredOnParcel
|
import kotlinx.parcelize.IgnoredOnParcel
|
||||||
import kotlinx.parcelize.Parcelize
|
import kotlinx.parcelize.Parcelize
|
||||||
|
import java.io.File
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
|
||||||
private const val KEY_STATE = "state"
|
private const val KEY_STATE = "state"
|
||||||
|
private const val KEY_TEMP_ATTACHMENT = "tempAttachmentFile"
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* ViewModel responsible for handling user interactions in the vault item screen
|
* ViewModel responsible for handling user interactions in the vault item screen
|
||||||
|
@ -42,10 +47,11 @@ private const val KEY_STATE = "state"
|
||||||
@Suppress("LargeClass", "TooManyFunctions")
|
@Suppress("LargeClass", "TooManyFunctions")
|
||||||
@HiltViewModel
|
@HiltViewModel
|
||||||
class VaultItemViewModel @Inject constructor(
|
class VaultItemViewModel @Inject constructor(
|
||||||
savedStateHandle: SavedStateHandle,
|
private val savedStateHandle: SavedStateHandle,
|
||||||
private val clipboardManager: BitwardenClipboardManager,
|
private val clipboardManager: BitwardenClipboardManager,
|
||||||
private val authRepository: AuthRepository,
|
private val authRepository: AuthRepository,
|
||||||
private val vaultRepository: VaultRepository,
|
private val vaultRepository: VaultRepository,
|
||||||
|
private val fileManager: FileManager,
|
||||||
) : BaseViewModel<VaultItemState, VaultItemEvent, VaultItemAction>(
|
) : BaseViewModel<VaultItemState, VaultItemEvent, VaultItemAction>(
|
||||||
// We load the state from the savedStateHandle for testing purposes.
|
// We load the state from the savedStateHandle for testing purposes.
|
||||||
initialState = savedStateHandle[KEY_STATE] ?: VaultItemState(
|
initialState = savedStateHandle[KEY_STATE] ?: VaultItemState(
|
||||||
|
@ -54,6 +60,14 @@ class VaultItemViewModel @Inject constructor(
|
||||||
dialog = null,
|
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
|
//region Initialization and Overrides
|
||||||
init {
|
init {
|
||||||
|
@ -118,6 +132,18 @@ class VaultItemViewModel @Inject constructor(
|
||||||
handleHiddenFieldVisibilityClicked(action)
|
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.AttachmentsClick -> handleAttachmentsClick()
|
||||||
is VaultItemAction.Common.CloneClick -> handleCloneClick()
|
is VaultItemAction.Common.CloneClick -> handleCloneClick()
|
||||||
is VaultItemAction.Common.MoveToOrganizationClick -> handleMoveToOrganizationClick()
|
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() {
|
private fun handleAttachmentsClick() {
|
||||||
onContent { content ->
|
onContent { content ->
|
||||||
if (content.common.requiresReprompt) {
|
if (content.common.requiresReprompt) {
|
||||||
|
@ -553,6 +656,14 @@ class VaultItemViewModel @Inject constructor(
|
||||||
|
|
||||||
is VaultItemAction.Internal.DeleteCipherReceive -> handleDeleteCipherReceive(action)
|
is VaultItemAction.Internal.DeleteCipherReceive -> handleDeleteCipherReceive(action)
|
||||||
is VaultItemAction.Internal.RestoreCipherReceive -> handleRestoreCipherReceive(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
|
//endregion Internal Type Handlers
|
||||||
|
|
||||||
private inline fun onContent(
|
private inline fun onContent(
|
||||||
|
@ -871,8 +1027,22 @@ data class VaultItemState(
|
||||||
val requiresReprompt: Boolean,
|
val requiresReprompt: Boolean,
|
||||||
@IgnoredOnParcel
|
@IgnoredOnParcel
|
||||||
val currentCipher: CipherView? = null,
|
val currentCipher: CipherView? = null,
|
||||||
|
val attachments: List<AttachmentItem>?,
|
||||||
) : Parcelable {
|
) : 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.
|
* Represents a custom field, TextField, HiddenField, BooleanField, or LinkedField.
|
||||||
*/
|
*/
|
||||||
|
@ -1109,6 +1279,13 @@ sealed class VaultItemEvent {
|
||||||
val itemId: String,
|
val itemId: String,
|
||||||
) : VaultItemEvent()
|
) : 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.
|
* Places the given [message] in your clipboard.
|
||||||
*/
|
*/
|
||||||
|
@ -1207,6 +1384,25 @@ sealed class VaultItemAction {
|
||||||
* The user has clicked the collections button.
|
* The user has clicked the collections button.
|
||||||
*/
|
*/
|
||||||
data object CollectionsClick : Common()
|
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(
|
data class RestoreCipherReceive(
|
||||||
val result: RestoreCipherResult,
|
val result: RestoreCipherResult,
|
||||||
) : Internal()
|
) : 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
|
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
|
* Indicates that we should launch the [VaultItemAction.Common.CloneClick] upon password
|
||||||
* validation.
|
* validation.
|
||||||
|
|
|
@ -20,6 +20,7 @@ data class VaultCommonItemTypeHandlers(
|
||||||
VaultItemState.ViewState.Content.Common.Custom.HiddenField,
|
VaultItemState.ViewState.Content.Common.Custom.HiddenField,
|
||||||
Boolean,
|
Boolean,
|
||||||
) -> Unit,
|
) -> Unit,
|
||||||
|
val onAttachmentDownloadClick: (VaultItemState.ViewState.Content.Common.AttachmentItem) -> Unit,
|
||||||
) {
|
) {
|
||||||
companion object {
|
companion object {
|
||||||
/**
|
/**
|
||||||
|
@ -47,6 +48,9 @@ data class VaultCommonItemTypeHandlers(
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
|
onAttachmentDownloadClick = {
|
||||||
|
viewModel.trySendAction(VaultItemAction.Common.AttachmentDownloadClick(it))
|
||||||
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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.VaultCardBrand
|
||||||
import com.x8bit.bitwarden.ui.vault.model.VaultLinkedFieldType
|
import com.x8bit.bitwarden.ui.vault.model.VaultLinkedFieldType
|
||||||
import com.x8bit.bitwarden.ui.vault.model.findVaultCardBrandWithNameOrNull
|
import com.x8bit.bitwarden.ui.vault.model.findVaultCardBrandWithNameOrNull
|
||||||
|
import java.lang.NumberFormatException
|
||||||
import java.time.format.DateTimeFormatter
|
import java.time.format.DateTimeFormatter
|
||||||
import java.util.TimeZone
|
import java.util.TimeZone
|
||||||
|
|
||||||
|
@ -43,6 +44,32 @@ fun CipherView.toViewState(
|
||||||
customFields = fields.orEmpty().map { it.toCustomField() },
|
customFields = fields.orEmpty().map { it.toCustomField() },
|
||||||
lastUpdated = dateTimeFormatter.format(revisionDate),
|
lastUpdated = dateTimeFormatter.format(revisionDate),
|
||||||
notes = notes,
|
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) {
|
type = when (type) {
|
||||||
CipherType.LOGIN -> {
|
CipherType.LOGIN -> {
|
||||||
|
|
13
app/src/main/res/drawable/ic_download.xml
Normal file
13
app/src/main/res/drawable/ic_download.xml
Normal file
|
@ -0,0 +1,13 @@
|
||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="24dp"
|
||||||
|
android:height="24dp"
|
||||||
|
android:viewportWidth="24"
|
||||||
|
android:viewportHeight="24">
|
||||||
|
<path
|
||||||
|
android:pathData="M2.625,10.75C2.97,10.75 3.25,11.03 3.25,11.375V20.75H19.5V11.375C19.5,11.03 19.78,10.75 20.125,10.75C20.47,10.75 20.75,11.03 20.75,11.375V20.75C20.75,21.44 20.19,22 19.5,22H3.25C2.56,22 2,21.44 2,20.75L2,11.375C2,11.03 2.28,10.75 2.625,10.75Z"
|
||||||
|
android:fillColor="#000000"
|
||||||
|
android:fillType="evenOdd"/>
|
||||||
|
<path
|
||||||
|
android:pathData="M12,5.125C12,4.78 11.72,4.5 11.375,4.5C11.03,4.5 10.75,4.78 10.75,5.125V15.094L9.203,13.547C8.959,13.303 8.563,13.303 8.319,13.547C8.075,13.791 8.075,14.187 8.319,14.431L10.491,16.603C10.979,17.091 11.771,17.091 12.259,16.603L14.431,14.431C14.675,14.187 14.675,13.791 14.431,13.547C14.187,13.303 13.791,13.303 13.547,13.547L12,15.094V5.125Z"
|
||||||
|
android:fillColor="#000000"/>
|
||||||
|
</vector>
|
|
@ -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.ShareCipherJsonRequest
|
||||||
import com.x8bit.bitwarden.data.vault.datasource.network.model.UpdateCipherCollectionsJsonRequest
|
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.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.createMockAttachmentJsonRequest
|
||||||
import com.x8bit.bitwarden.data.vault.datasource.network.model.createMockAttachmentJsonResponse
|
import com.x8bit.bitwarden.data.vault.datasource.network.model.createMockAttachmentJsonResponse
|
||||||
import com.x8bit.bitwarden.data.vault.datasource.network.model.createMockCipher
|
import com.x8bit.bitwarden.data.vault.datasource.network.model.createMockCipher
|
||||||
|
@ -245,6 +246,19 @@ class CiphersServiceTest : BaseServiceTest() {
|
||||||
result.getOrThrow(),
|
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(
|
private fun setupMockUri(
|
||||||
|
@ -455,3 +469,14 @@ private const val UPDATE_CIPHER_INVALID_JSON = """
|
||||||
"validationErrors": null
|
"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"
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
|
|
@ -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()))
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,5 +1,6 @@
|
||||||
package com.x8bit.bitwarden.data.vault.datasource.sdk
|
package com.x8bit.bitwarden.data.vault.datasource.sdk
|
||||||
|
|
||||||
|
import com.bitwarden.core.Attachment
|
||||||
import com.bitwarden.core.AttachmentEncryptResult
|
import com.bitwarden.core.AttachmentEncryptResult
|
||||||
import com.bitwarden.core.AttachmentView
|
import com.bitwarden.core.AttachmentView
|
||||||
import com.bitwarden.core.Cipher
|
import com.bitwarden.core.Cipher
|
||||||
|
@ -661,6 +662,42 @@ class VaultSdkSourceTest {
|
||||||
verify { sdkClientManager.getOrCreateClient(userId = userId) }
|
verify { sdkClientManager.getOrCreateClient(userId = userId) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `File decrypt should call SDK and return a Result with correct data`() = runBlocking {
|
||||||
|
val userId = "userId"
|
||||||
|
val mockCipher = mockk<Cipher>()
|
||||||
|
val mockAttachment = mockk<Attachment>()
|
||||||
|
val expectedResult = Unit
|
||||||
|
coEvery {
|
||||||
|
clientVault.attachments().decryptFile(
|
||||||
|
cipher = mockCipher,
|
||||||
|
attachment = mockAttachment,
|
||||||
|
encryptedFilePath = "encrypted_path",
|
||||||
|
decryptedFilePath = "decrypted_path",
|
||||||
|
)
|
||||||
|
} just runs
|
||||||
|
val result = vaultSdkSource.decryptFile(
|
||||||
|
userId = userId,
|
||||||
|
cipher = mockCipher,
|
||||||
|
attachment = mockAttachment,
|
||||||
|
encryptedFilePath = "encrypted_path",
|
||||||
|
decryptedFilePath = "decrypted_path",
|
||||||
|
)
|
||||||
|
assertEquals(
|
||||||
|
expectedResult.asSuccess(),
|
||||||
|
result,
|
||||||
|
)
|
||||||
|
coVerify {
|
||||||
|
clientVault.attachments().decryptFile(
|
||||||
|
cipher = mockCipher,
|
||||||
|
attachment = mockAttachment,
|
||||||
|
encryptedFilePath = "encrypted_path",
|
||||||
|
decryptedFilePath = "decrypted_path",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
verify { sdkClientManager.getOrCreateClient(userId = userId) }
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `encryptPasswordHistory should call SDK and return a Result with correct data`() =
|
fun `encryptPasswordHistory should call SDK and return a Result with correct data`() =
|
||||||
runBlocking {
|
runBlocking {
|
||||||
|
|
|
@ -3,6 +3,7 @@ package com.x8bit.bitwarden.data.vault.repository
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
import app.cash.turbine.test
|
import app.cash.turbine.test
|
||||||
import app.cash.turbine.turbineScope
|
import app.cash.turbine.turbineScope
|
||||||
|
import com.bitwarden.core.Attachment
|
||||||
import com.bitwarden.core.Cipher
|
import com.bitwarden.core.Cipher
|
||||||
import com.bitwarden.core.CipherView
|
import com.bitwarden.core.CipherView
|
||||||
import com.bitwarden.core.CollectionView
|
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.FileManager
|
||||||
import com.x8bit.bitwarden.data.vault.manager.TotpCodeManager
|
import com.x8bit.bitwarden.data.vault.manager.TotpCodeManager
|
||||||
import com.x8bit.bitwarden.data.vault.manager.VaultLockManager
|
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.manager.model.VerificationCodeItem
|
||||||
import com.x8bit.bitwarden.data.vault.repository.model.CreateAttachmentResult
|
import com.x8bit.bitwarden.data.vault.repository.model.CreateAttachmentResult
|
||||||
import com.x8bit.bitwarden.data.vault.repository.model.CreateCipherResult
|
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.DeleteFolderResult
|
||||||
import com.x8bit.bitwarden.data.vault.repository.model.DeleteSendResult
|
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.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.GenerateTotpResult
|
||||||
import com.x8bit.bitwarden.data.vault.repository.model.RemovePasswordSendResult
|
import com.x8bit.bitwarden.data.vault.repository.model.RemovePasswordSendResult
|
||||||
import com.x8bit.bitwarden.data.vault.repository.model.RestoreCipherResult
|
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.BeforeEach
|
||||||
import org.junit.jupiter.api.Test
|
import org.junit.jupiter.api.Test
|
||||||
import retrofit2.HttpException
|
import retrofit2.HttpException
|
||||||
|
import java.io.File
|
||||||
import java.net.UnknownHostException
|
import java.net.UnknownHostException
|
||||||
import java.time.Clock
|
import java.time.Clock
|
||||||
import java.time.Instant
|
import java.time.Instant
|
||||||
|
@ -3326,6 +3330,312 @@ class VaultRepositoryTest {
|
||||||
assertEquals(CreateAttachmentResult.Success(mockCipherView), result)
|
assertEquals(CreateAttachmentResult.Success(mockCipherView), result)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `downloadAttachment with missing attachment should return Failure`() = runTest {
|
||||||
|
fakeAuthDiskSource.userState = MOCK_USER_STATE
|
||||||
|
|
||||||
|
val attachmentId = "mockId-1"
|
||||||
|
val attachment = mockk<Attachment> {
|
||||||
|
every { id } returns attachmentId
|
||||||
|
}
|
||||||
|
val cipher = mockk<Cipher> {
|
||||||
|
every { attachments } returns emptyList()
|
||||||
|
every { id } returns "mockId-1"
|
||||||
|
}
|
||||||
|
val cipherView = createMockCipherView(number = 1)
|
||||||
|
coEvery {
|
||||||
|
vaultSdkSource.encryptCipher(MOCK_USER_STATE.activeUserId, cipherView)
|
||||||
|
} returns cipher.asSuccess()
|
||||||
|
|
||||||
|
assertEquals(
|
||||||
|
DownloadAttachmentResult.Failure,
|
||||||
|
vaultRepository.downloadAttachment(
|
||||||
|
cipherView = cipherView,
|
||||||
|
attachmentId = attachmentId,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
coVerify(exactly = 1) {
|
||||||
|
vaultSdkSource.encryptCipher(MOCK_USER_STATE.activeUserId, cipherView)
|
||||||
|
}
|
||||||
|
coVerify(exactly = 0) {
|
||||||
|
ciphersService.getCipherAttachment(any(), any())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `downloadAttachment with failed attachment details request should return Failure`() =
|
||||||
|
runTest {
|
||||||
|
fakeAuthDiskSource.userState = MOCK_USER_STATE
|
||||||
|
|
||||||
|
val attachmentId = "mockId-1"
|
||||||
|
val attachment = mockk<Attachment> {
|
||||||
|
every { id } returns attachmentId
|
||||||
|
}
|
||||||
|
val cipher = mockk<Cipher> {
|
||||||
|
every { attachments } returns listOf(attachment)
|
||||||
|
every { id } returns "mockId-1"
|
||||||
|
}
|
||||||
|
val cipherView = createMockCipherView(number = 1)
|
||||||
|
coEvery {
|
||||||
|
vaultSdkSource.encryptCipher(MOCK_USER_STATE.activeUserId, cipherView)
|
||||||
|
} returns cipher.asSuccess()
|
||||||
|
|
||||||
|
coEvery {
|
||||||
|
ciphersService.getCipherAttachment(any(), any())
|
||||||
|
} returns Throwable().asFailure()
|
||||||
|
|
||||||
|
assertEquals(
|
||||||
|
DownloadAttachmentResult.Failure,
|
||||||
|
vaultRepository.downloadAttachment(
|
||||||
|
cipherView = cipherView,
|
||||||
|
attachmentId = attachmentId,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
coVerify(exactly = 1) {
|
||||||
|
vaultSdkSource.encryptCipher(MOCK_USER_STATE.activeUserId, cipherView)
|
||||||
|
ciphersService.getCipherAttachment(
|
||||||
|
cipherId = requireNotNull(cipherView.id),
|
||||||
|
attachmentId = attachmentId,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `downloadAttachment with attachment details missing url should return Failure`() = runTest {
|
||||||
|
fakeAuthDiskSource.userState = MOCK_USER_STATE
|
||||||
|
|
||||||
|
val attachmentId = "mockId-1"
|
||||||
|
val attachment = mockk<Attachment> {
|
||||||
|
every { id } returns attachmentId
|
||||||
|
}
|
||||||
|
val cipher = mockk<Cipher> {
|
||||||
|
every { attachments } returns listOf(attachment)
|
||||||
|
every { id } returns "mockId-1"
|
||||||
|
}
|
||||||
|
val cipherView = createMockCipherView(number = 1)
|
||||||
|
coEvery {
|
||||||
|
vaultSdkSource.encryptCipher(MOCK_USER_STATE.activeUserId, cipherView)
|
||||||
|
} returns cipher.asSuccess()
|
||||||
|
|
||||||
|
val response = mockk<SyncResponseJson.Cipher.Attachment> {
|
||||||
|
every { url } returns null
|
||||||
|
}
|
||||||
|
coEvery {
|
||||||
|
ciphersService.getCipherAttachment(any(), any())
|
||||||
|
} returns response.asSuccess()
|
||||||
|
|
||||||
|
assertEquals(
|
||||||
|
DownloadAttachmentResult.Failure,
|
||||||
|
vaultRepository.downloadAttachment(
|
||||||
|
cipherView = cipherView,
|
||||||
|
attachmentId = attachmentId,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
coVerify(exactly = 1) {
|
||||||
|
vaultSdkSource.encryptCipher(MOCK_USER_STATE.activeUserId, cipherView)
|
||||||
|
ciphersService.getCipherAttachment(
|
||||||
|
cipherId = requireNotNull(cipherView.id),
|
||||||
|
attachmentId = attachmentId,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `downloadAttachment with failed download should return Failure`() = runTest {
|
||||||
|
fakeAuthDiskSource.userState = MOCK_USER_STATE
|
||||||
|
|
||||||
|
val attachmentId = "mockId-1"
|
||||||
|
val attachment = mockk<Attachment> {
|
||||||
|
every { id } returns attachmentId
|
||||||
|
}
|
||||||
|
val cipher = mockk<Cipher> {
|
||||||
|
every { attachments } returns listOf(attachment)
|
||||||
|
every { id } returns "mockId-1"
|
||||||
|
}
|
||||||
|
|
||||||
|
val cipherView = createMockCipherView(number = 1)
|
||||||
|
coEvery {
|
||||||
|
vaultSdkSource.encryptCipher(MOCK_USER_STATE.activeUserId, cipherView)
|
||||||
|
} returns cipher.asSuccess()
|
||||||
|
|
||||||
|
val response = mockk<SyncResponseJson.Cipher.Attachment> {
|
||||||
|
every { url } returns "https://bitwarden.com"
|
||||||
|
}
|
||||||
|
coEvery {
|
||||||
|
ciphersService.getCipherAttachment(any(), any())
|
||||||
|
} returns response.asSuccess()
|
||||||
|
|
||||||
|
coEvery {
|
||||||
|
fileManager.downloadFileToCache(any())
|
||||||
|
} returns DownloadResult.Failure
|
||||||
|
|
||||||
|
assertEquals(
|
||||||
|
DownloadAttachmentResult.Failure,
|
||||||
|
vaultRepository.downloadAttachment(
|
||||||
|
cipherView = cipherView,
|
||||||
|
attachmentId = attachmentId,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
coVerify(exactly = 1) {
|
||||||
|
vaultSdkSource.encryptCipher(MOCK_USER_STATE.activeUserId, cipherView)
|
||||||
|
ciphersService.getCipherAttachment(
|
||||||
|
cipherId = requireNotNull(cipherView.id),
|
||||||
|
attachmentId = attachmentId,
|
||||||
|
)
|
||||||
|
fileManager.downloadFileToCache("https://bitwarden.com")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Suppress("MaxLineLength")
|
||||||
|
@Test
|
||||||
|
fun `downloadAttachment with failed decryption should delete file and return Failure`() =
|
||||||
|
runTest {
|
||||||
|
fakeAuthDiskSource.userState = MOCK_USER_STATE
|
||||||
|
|
||||||
|
val attachmentId = "mockId-1"
|
||||||
|
val attachment = mockk<Attachment> {
|
||||||
|
every { id } returns attachmentId
|
||||||
|
}
|
||||||
|
val cipher = mockk<Cipher> {
|
||||||
|
every { attachments } returns listOf(attachment)
|
||||||
|
every { id } returns "mockId-1"
|
||||||
|
}
|
||||||
|
val cipherView = createMockCipherView(number = 1)
|
||||||
|
coEvery {
|
||||||
|
vaultSdkSource.encryptCipher(MOCK_USER_STATE.activeUserId, cipherView)
|
||||||
|
} returns cipher.asSuccess()
|
||||||
|
|
||||||
|
val response = mockk<SyncResponseJson.Cipher.Attachment> {
|
||||||
|
every { url } returns "https://bitwarden.com"
|
||||||
|
}
|
||||||
|
coEvery {
|
||||||
|
ciphersService.getCipherAttachment(any(), any())
|
||||||
|
} returns response.asSuccess()
|
||||||
|
|
||||||
|
val file = mockk<File> {
|
||||||
|
every { path } returns "path/to/encrypted/file"
|
||||||
|
every { delete() } returns true
|
||||||
|
}
|
||||||
|
coEvery {
|
||||||
|
fileManager.downloadFileToCache(any())
|
||||||
|
} returns DownloadResult.Success(file)
|
||||||
|
|
||||||
|
coEvery {
|
||||||
|
vaultSdkSource.decryptFile(
|
||||||
|
userId = MOCK_USER_STATE.activeUserId,
|
||||||
|
cipher = cipher,
|
||||||
|
attachment = attachment,
|
||||||
|
encryptedFilePath = "path/to/encrypted/file",
|
||||||
|
decryptedFilePath = "path/to/encrypted/file_decrypted",
|
||||||
|
)
|
||||||
|
} returns Throwable().asFailure()
|
||||||
|
|
||||||
|
assertEquals(
|
||||||
|
DownloadAttachmentResult.Failure,
|
||||||
|
vaultRepository.downloadAttachment(
|
||||||
|
cipherView = cipherView,
|
||||||
|
attachmentId = attachmentId,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
coVerify(exactly = 1) {
|
||||||
|
vaultSdkSource.encryptCipher(MOCK_USER_STATE.activeUserId, cipherView)
|
||||||
|
ciphersService.getCipherAttachment(
|
||||||
|
cipherId = requireNotNull(cipherView.id),
|
||||||
|
attachmentId = attachmentId,
|
||||||
|
)
|
||||||
|
fileManager.downloadFileToCache("https://bitwarden.com")
|
||||||
|
vaultSdkSource.decryptFile(
|
||||||
|
userId = MOCK_USER_STATE.activeUserId,
|
||||||
|
cipher = cipher,
|
||||||
|
attachment = attachment,
|
||||||
|
encryptedFilePath = "path/to/encrypted/file",
|
||||||
|
decryptedFilePath = "path/to/encrypted/file_decrypted",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
verify(exactly = 1) {
|
||||||
|
file.delete()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Suppress("MaxLineLength")
|
||||||
|
@Test
|
||||||
|
fun `downloadAttachment with successful decryption should delete file and return Success`() =
|
||||||
|
runTest {
|
||||||
|
fakeAuthDiskSource.userState = MOCK_USER_STATE
|
||||||
|
|
||||||
|
val attachmentId = "mockId-1"
|
||||||
|
val attachment = mockk<Attachment> {
|
||||||
|
every { id } returns attachmentId
|
||||||
|
}
|
||||||
|
val cipher = mockk<Cipher> {
|
||||||
|
every { attachments } returns listOf(attachment)
|
||||||
|
every { id } returns "mockId-1"
|
||||||
|
}
|
||||||
|
val cipherView = createMockCipherView(number = 1)
|
||||||
|
coEvery {
|
||||||
|
vaultSdkSource.encryptCipher(MOCK_USER_STATE.activeUserId, cipherView)
|
||||||
|
} returns cipher.asSuccess()
|
||||||
|
|
||||||
|
val response = mockk<SyncResponseJson.Cipher.Attachment> {
|
||||||
|
every { url } returns "https://bitwarden.com"
|
||||||
|
}
|
||||||
|
coEvery {
|
||||||
|
ciphersService.getCipherAttachment(any(), any())
|
||||||
|
} returns response.asSuccess()
|
||||||
|
|
||||||
|
val file = mockk<File> {
|
||||||
|
every { path } returns "path/to/encrypted/file"
|
||||||
|
every { delete() } returns true
|
||||||
|
}
|
||||||
|
coEvery {
|
||||||
|
fileManager.downloadFileToCache(any())
|
||||||
|
} returns DownloadResult.Success(file)
|
||||||
|
|
||||||
|
coEvery {
|
||||||
|
vaultSdkSource.decryptFile(
|
||||||
|
userId = MOCK_USER_STATE.activeUserId,
|
||||||
|
cipher = cipher,
|
||||||
|
attachment = attachment,
|
||||||
|
encryptedFilePath = "path/to/encrypted/file",
|
||||||
|
decryptedFilePath = "path/to/encrypted/file_decrypted",
|
||||||
|
)
|
||||||
|
} returns Unit.asSuccess()
|
||||||
|
|
||||||
|
assertEquals(
|
||||||
|
DownloadAttachmentResult.Success(
|
||||||
|
file = File("path/to/encrypted/file_decrypted"),
|
||||||
|
),
|
||||||
|
vaultRepository.downloadAttachment(
|
||||||
|
cipherView = cipherView,
|
||||||
|
attachmentId = attachmentId,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
coVerify(exactly = 1) {
|
||||||
|
vaultSdkSource.encryptCipher(MOCK_USER_STATE.activeUserId, cipherView)
|
||||||
|
ciphersService.getCipherAttachment(
|
||||||
|
cipherId = requireNotNull(cipherView.id),
|
||||||
|
attachmentId = attachmentId,
|
||||||
|
)
|
||||||
|
fileManager.downloadFileToCache("https://bitwarden.com")
|
||||||
|
vaultSdkSource.decryptFile(
|
||||||
|
userId = MOCK_USER_STATE.activeUserId,
|
||||||
|
cipher = cipher,
|
||||||
|
attachment = attachment,
|
||||||
|
encryptedFilePath = "path/to/encrypted/file",
|
||||||
|
decryptedFilePath = "path/to/encrypted/file_decrypted",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
verify(exactly = 1) {
|
||||||
|
file.delete()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `generateTotp with no active user should return GenerateTotpResult Error`() =
|
fun `generateTotp with no active user should return GenerateTotpResult Error`() =
|
||||||
runTest {
|
runTest {
|
||||||
|
@ -3901,180 +4211,185 @@ class VaultRepositoryTest {
|
||||||
|
|
||||||
@Suppress("MaxLineLength")
|
@Suppress("MaxLineLength")
|
||||||
@Test
|
@Test
|
||||||
fun `syncCipherUpsertFlow create with local cipher with no common collections should do nothing`() = runTest {
|
fun `syncCipherUpsertFlow create with local cipher with no common collections should do nothing`() =
|
||||||
val number = 1
|
runTest {
|
||||||
val cipherId = "mockId-$number"
|
val number = 1
|
||||||
|
val cipherId = "mockId-$number"
|
||||||
|
|
||||||
fakeAuthDiskSource.userState = MOCK_USER_STATE
|
fakeAuthDiskSource.userState = MOCK_USER_STATE
|
||||||
val cipherView = createMockCipherView(number = number)
|
val cipherView = createMockCipherView(number = number)
|
||||||
coEvery {
|
coEvery {
|
||||||
vaultSdkSource.decryptCipherList(
|
vaultSdkSource.decryptCipherList(
|
||||||
userId = MOCK_USER_STATE.activeUserId,
|
userId = MOCK_USER_STATE.activeUserId,
|
||||||
cipherList = listOf(createMockSdkCipher(number = number)),
|
cipherList = listOf(createMockSdkCipher(number = number)),
|
||||||
)
|
)
|
||||||
} returns listOf(cipherView).asSuccess()
|
} returns listOf(cipherView).asSuccess()
|
||||||
|
|
||||||
val ciphersFlow = bufferedMutableSharedFlow<List<SyncResponseJson.Cipher>>()
|
val ciphersFlow = bufferedMutableSharedFlow<List<SyncResponseJson.Cipher>>()
|
||||||
setupVaultDiskSourceFlows(ciphersFlow = ciphersFlow)
|
setupVaultDiskSourceFlows(ciphersFlow = ciphersFlow)
|
||||||
|
|
||||||
vaultRepository.ciphersStateFlow.test {
|
vaultRepository.ciphersStateFlow.test {
|
||||||
// Populate and consume items related to the ciphers flow
|
// Populate and consume items related to the ciphers flow
|
||||||
awaitItem()
|
awaitItem()
|
||||||
ciphersFlow.tryEmit(listOf(createMockCipher(number = number)))
|
ciphersFlow.tryEmit(listOf(createMockCipher(number = number)))
|
||||||
awaitItem()
|
awaitItem()
|
||||||
|
|
||||||
mutableSyncCipherUpsertFlow.tryEmit(
|
mutableSyncCipherUpsertFlow.tryEmit(
|
||||||
SyncCipherUpsertData(
|
SyncCipherUpsertData(
|
||||||
cipherId = cipherId,
|
cipherId = cipherId,
|
||||||
revisionDate = ZonedDateTime.now(),
|
revisionDate = ZonedDateTime.now(),
|
||||||
isUpdate = false,
|
isUpdate = false,
|
||||||
collectionIds = null,
|
collectionIds = null,
|
||||||
organizationId = 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")
|
@Suppress("MaxLineLength")
|
||||||
@Test
|
@Test
|
||||||
fun `syncCipherUpsertFlow create with local cipher, and with common collections, should make a request and save cipher to disk`() = runTest {
|
fun `syncCipherUpsertFlow create with local cipher, and with common collections, should make a request and save cipher to disk`() =
|
||||||
val number = 1
|
runTest {
|
||||||
val cipherId = "mockId-$number"
|
val number = 1
|
||||||
|
val cipherId = "mockId-$number"
|
||||||
|
|
||||||
fakeAuthDiskSource.userState = MOCK_USER_STATE
|
fakeAuthDiskSource.userState = MOCK_USER_STATE
|
||||||
val cipherView = createMockCipherView(number = number)
|
val cipherView = createMockCipherView(number = number)
|
||||||
coEvery {
|
coEvery {
|
||||||
vaultSdkSource.decryptCipherList(
|
vaultSdkSource.decryptCipherList(
|
||||||
userId = MOCK_USER_STATE.activeUserId,
|
userId = MOCK_USER_STATE.activeUserId,
|
||||||
cipherList = listOf(createMockSdkCipher(number = number)),
|
cipherList = listOf(createMockSdkCipher(number = number)),
|
||||||
|
)
|
||||||
|
} returns listOf(cipherView).asSuccess()
|
||||||
|
val collectionView = createMockCollectionView(number = number)
|
||||||
|
coEvery {
|
||||||
|
vaultSdkSource.decryptCollectionList(
|
||||||
|
userId = MOCK_USER_STATE.activeUserId,
|
||||||
|
collectionList = listOf(createMockSdkCollection(number = number)),
|
||||||
|
)
|
||||||
|
} returns listOf(collectionView).asSuccess()
|
||||||
|
|
||||||
|
val cipher: SyncResponseJson.Cipher = mockk()
|
||||||
|
coEvery {
|
||||||
|
ciphersService.getCipher(cipherId)
|
||||||
|
} returns cipher.asSuccess()
|
||||||
|
coEvery {
|
||||||
|
vaultDiskSource.saveCipher(userId = MOCK_USER_STATE.activeUserId, cipher = cipher)
|
||||||
|
} just runs
|
||||||
|
|
||||||
|
val ciphersFlow = bufferedMutableSharedFlow<List<SyncResponseJson.Cipher>>()
|
||||||
|
val collectionsFlow = bufferedMutableSharedFlow<List<SyncResponseJson.Collection>>()
|
||||||
|
setupVaultDiskSourceFlows(
|
||||||
|
ciphersFlow = ciphersFlow,
|
||||||
|
collectionsFlow = collectionsFlow,
|
||||||
)
|
)
|
||||||
} returns listOf(cipherView).asSuccess()
|
|
||||||
val collectionView = createMockCollectionView(number = number)
|
|
||||||
coEvery {
|
|
||||||
vaultSdkSource.decryptCollectionList(
|
|
||||||
userId = MOCK_USER_STATE.activeUserId,
|
|
||||||
collectionList = listOf(createMockSdkCollection(number = number)),
|
|
||||||
)
|
|
||||||
} returns listOf(collectionView).asSuccess()
|
|
||||||
|
|
||||||
val cipher: SyncResponseJson.Cipher = mockk()
|
turbineScope {
|
||||||
coEvery {
|
val ciphersStateFlow = vaultRepository.ciphersStateFlow.testIn(backgroundScope)
|
||||||
ciphersService.getCipher(cipherId)
|
val collectionsStateFlow =
|
||||||
} returns cipher.asSuccess()
|
vaultRepository.collectionsStateFlow.testIn(backgroundScope)
|
||||||
coEvery {
|
|
||||||
vaultDiskSource.saveCipher(userId = MOCK_USER_STATE.activeUserId, cipher = cipher)
|
|
||||||
} just runs
|
|
||||||
|
|
||||||
val ciphersFlow = bufferedMutableSharedFlow<List<SyncResponseJson.Cipher>>()
|
// Populate and consume items related to the ciphers flow
|
||||||
val collectionsFlow = bufferedMutableSharedFlow<List<SyncResponseJson.Collection>>()
|
ciphersStateFlow.awaitItem()
|
||||||
setupVaultDiskSourceFlows(
|
ciphersFlow.tryEmit(listOf(createMockCipher(number = number)))
|
||||||
ciphersFlow = ciphersFlow,
|
ciphersStateFlow.awaitItem()
|
||||||
collectionsFlow = collectionsFlow,
|
|
||||||
)
|
|
||||||
|
|
||||||
turbineScope {
|
// Populate and consume items related to the collections flow
|
||||||
val ciphersStateFlow = vaultRepository.ciphersStateFlow.testIn(backgroundScope)
|
collectionsStateFlow.awaitItem()
|
||||||
val collectionsStateFlow = vaultRepository.collectionsStateFlow.testIn(backgroundScope)
|
collectionsFlow.tryEmit(listOf(createMockCollection(number = number)))
|
||||||
|
collectionsStateFlow.awaitItem()
|
||||||
|
|
||||||
// Populate and consume items related to the ciphers flow
|
mutableSyncCipherUpsertFlow.tryEmit(
|
||||||
ciphersStateFlow.awaitItem()
|
SyncCipherUpsertData(
|
||||||
ciphersFlow.tryEmit(listOf(createMockCipher(number = number)))
|
cipherId = cipherId,
|
||||||
ciphersStateFlow.awaitItem()
|
revisionDate = ZonedDateTime.now(),
|
||||||
|
isUpdate = false,
|
||||||
|
collectionIds = listOf("mockId-1"),
|
||||||
|
organizationId = "mock-id",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
// Populate and consume items related to the collections flow
|
coVerify(exactly = 1) {
|
||||||
collectionsStateFlow.awaitItem()
|
ciphersService.getCipher(any())
|
||||||
collectionsFlow.tryEmit(listOf(createMockCollection(number = number)))
|
vaultDiskSource.saveCipher(any(), any())
|
||||||
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())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Suppress("MaxLineLength")
|
@Suppress("MaxLineLength")
|
||||||
@Test
|
@Test
|
||||||
fun `syncCipherUpsertFlow update with no local cipher, but with common collections, should make a request save cipher to disk`() = runTest {
|
fun `syncCipherUpsertFlow update with no local cipher, but with common collections, should make a request save cipher to disk`() =
|
||||||
val number = 1
|
runTest {
|
||||||
val cipherId = "mockId-$number"
|
val number = 1
|
||||||
|
val cipherId = "mockId-$number"
|
||||||
|
|
||||||
fakeAuthDiskSource.userState = MOCK_USER_STATE
|
fakeAuthDiskSource.userState = MOCK_USER_STATE
|
||||||
coEvery {
|
coEvery {
|
||||||
vaultSdkSource.decryptCipherList(
|
vaultSdkSource.decryptCipherList(
|
||||||
userId = MOCK_USER_STATE.activeUserId,
|
userId = MOCK_USER_STATE.activeUserId,
|
||||||
cipherList = listOf(),
|
cipherList = listOf(),
|
||||||
|
)
|
||||||
|
} returns listOf<CipherView>().asSuccess()
|
||||||
|
val collectionView = createMockCollectionView(number = number)
|
||||||
|
coEvery {
|
||||||
|
vaultSdkSource.decryptCollectionList(
|
||||||
|
userId = MOCK_USER_STATE.activeUserId,
|
||||||
|
collectionList = listOf(createMockSdkCollection(number = number)),
|
||||||
|
)
|
||||||
|
} returns listOf(collectionView).asSuccess()
|
||||||
|
|
||||||
|
val cipher: SyncResponseJson.Cipher = mockk()
|
||||||
|
coEvery {
|
||||||
|
ciphersService.getCipher(cipherId)
|
||||||
|
} returns cipher.asSuccess()
|
||||||
|
coEvery {
|
||||||
|
vaultDiskSource.saveCipher(userId = MOCK_USER_STATE.activeUserId, cipher = cipher)
|
||||||
|
} just runs
|
||||||
|
|
||||||
|
val ciphersFlow = bufferedMutableSharedFlow<List<SyncResponseJson.Cipher>>()
|
||||||
|
val collectionsFlow = bufferedMutableSharedFlow<List<SyncResponseJson.Collection>>()
|
||||||
|
setupVaultDiskSourceFlows(
|
||||||
|
ciphersFlow = ciphersFlow,
|
||||||
|
collectionsFlow = collectionsFlow,
|
||||||
)
|
)
|
||||||
} returns listOf<CipherView>().asSuccess()
|
|
||||||
val collectionView = createMockCollectionView(number = number)
|
|
||||||
coEvery {
|
|
||||||
vaultSdkSource.decryptCollectionList(
|
|
||||||
userId = MOCK_USER_STATE.activeUserId,
|
|
||||||
collectionList = listOf(createMockSdkCollection(number = number)),
|
|
||||||
)
|
|
||||||
} returns listOf(collectionView).asSuccess()
|
|
||||||
|
|
||||||
val cipher: SyncResponseJson.Cipher = mockk()
|
turbineScope {
|
||||||
coEvery {
|
val ciphersStateFlow = vaultRepository.ciphersStateFlow.testIn(backgroundScope)
|
||||||
ciphersService.getCipher(cipherId)
|
val collectionsStateFlow =
|
||||||
} returns cipher.asSuccess()
|
vaultRepository.collectionsStateFlow.testIn(backgroundScope)
|
||||||
coEvery {
|
|
||||||
vaultDiskSource.saveCipher(userId = MOCK_USER_STATE.activeUserId, cipher = cipher)
|
|
||||||
} just runs
|
|
||||||
|
|
||||||
val ciphersFlow = bufferedMutableSharedFlow<List<SyncResponseJson.Cipher>>()
|
// Populate and consume items related to the ciphers flow
|
||||||
val collectionsFlow = bufferedMutableSharedFlow<List<SyncResponseJson.Collection>>()
|
ciphersStateFlow.awaitItem()
|
||||||
setupVaultDiskSourceFlows(
|
ciphersFlow.tryEmit(listOf())
|
||||||
ciphersFlow = ciphersFlow,
|
ciphersStateFlow.awaitItem()
|
||||||
collectionsFlow = collectionsFlow,
|
|
||||||
)
|
|
||||||
|
|
||||||
turbineScope {
|
// Populate and consume items related to the collections flow
|
||||||
val ciphersStateFlow = vaultRepository.ciphersStateFlow.testIn(backgroundScope)
|
collectionsStateFlow.awaitItem()
|
||||||
val collectionsStateFlow = vaultRepository.collectionsStateFlow.testIn(backgroundScope)
|
collectionsFlow.tryEmit(listOf(createMockCollection(number = number)))
|
||||||
|
collectionsStateFlow.awaitItem()
|
||||||
|
|
||||||
// Populate and consume items related to the ciphers flow
|
mutableSyncCipherUpsertFlow.tryEmit(
|
||||||
ciphersStateFlow.awaitItem()
|
SyncCipherUpsertData(
|
||||||
ciphersFlow.tryEmit(listOf())
|
cipherId = cipherId,
|
||||||
ciphersStateFlow.awaitItem()
|
revisionDate = ZonedDateTime.now(),
|
||||||
|
isUpdate = true,
|
||||||
|
collectionIds = listOf("mockId-1"),
|
||||||
|
organizationId = "mock-id",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
// Populate and consume items related to the collections flow
|
coVerify(exactly = 1) {
|
||||||
collectionsStateFlow.awaitItem()
|
ciphersService.getCipher(any())
|
||||||
collectionsFlow.tryEmit(listOf(createMockCollection(number = number)))
|
vaultDiskSource.saveCipher(any(), any())
|
||||||
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())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `syncCipherUpsertFlow update with no local cipher should do nothing`() = runTest {
|
fun `syncCipherUpsertFlow update with no local cipher should do nothing`() = runTest {
|
||||||
val number = 1
|
val number = 1
|
||||||
|
|
|
@ -60,7 +60,7 @@ class VaultItemScreenTest : BaseComposeTest() {
|
||||||
private var onNavigateToMoveToOrganizationItemId: String? = null
|
private var onNavigateToMoveToOrganizationItemId: String? = null
|
||||||
private var onNavigateToAttachmentsId: String? = null
|
private var onNavigateToAttachmentsId: String? = null
|
||||||
|
|
||||||
private val intentManager = mockk<IntentManager>()
|
private val intentManager = mockk<IntentManager>(relaxed = true)
|
||||||
|
|
||||||
private val mutableEventFlow = bufferedMutableSharedFlow<VaultItemEvent>()
|
private val mutableEventFlow = bufferedMutableSharedFlow<VaultItemEvent>()
|
||||||
private val mutableStateFlow = MutableStateFlow(DEFAULT_STATE)
|
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
|
@Test
|
||||||
fun `basic dialog should be displayed according to state`() {
|
fun `basic dialog should be displayed according to state`() {
|
||||||
val message = "message"
|
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
|
@Test
|
||||||
fun `on show hidden field click should send HiddenFieldVisibilityClicked`() {
|
fun `on show hidden field click should send HiddenFieldVisibilityClicked`() {
|
||||||
val textField = VaultItemState.ViewState.Content.Common.Custom.HiddenField(
|
val textField = VaultItemState.ViewState.Content.Common.Custom.HiddenField(
|
||||||
|
@ -1913,6 +2095,16 @@ private val DEFAULT_COMMON: VaultItemState.ViewState.Content.Common =
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
requiresReprompt = true,
|
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 =
|
private val DEFAULT_LOGIN: VaultItemState.ViewState.Content.ItemType.Login =
|
||||||
|
@ -1970,6 +2162,7 @@ private val EMPTY_COMMON: VaultItemState.ViewState.Content.Common =
|
||||||
notes = null,
|
notes = null,
|
||||||
customFields = emptyList(),
|
customFields = emptyList(),
|
||||||
requiresReprompt = true,
|
requiresReprompt = true,
|
||||||
|
attachments = emptyList(),
|
||||||
)
|
)
|
||||||
|
|
||||||
private val EMPTY_LOGIN_TYPE: VaultItemState.ViewState.Content.ItemType.Login =
|
private val EMPTY_LOGIN_TYPE: VaultItemState.ViewState.Content.ItemType.Login =
|
||||||
|
|
|
@ -1,5 +1,6 @@
|
||||||
package com.x8bit.bitwarden.ui.vault.feature.item
|
package com.x8bit.bitwarden.ui.vault.feature.item
|
||||||
|
|
||||||
|
import android.net.Uri
|
||||||
import androidx.lifecycle.SavedStateHandle
|
import androidx.lifecycle.SavedStateHandle
|
||||||
import app.cash.turbine.test
|
import app.cash.turbine.test
|
||||||
import app.cash.turbine.turbineScope
|
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.DataState
|
||||||
import com.x8bit.bitwarden.data.platform.repository.model.Environment
|
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.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.manager.model.VerificationCodeItem
|
||||||
import com.x8bit.bitwarden.data.vault.repository.VaultRepository
|
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.DeleteCipherResult
|
||||||
|
import com.x8bit.bitwarden.data.vault.repository.model.DownloadAttachmentResult
|
||||||
import com.x8bit.bitwarden.data.vault.repository.model.RestoreCipherResult
|
import com.x8bit.bitwarden.data.vault.repository.model.RestoreCipherResult
|
||||||
import com.x8bit.bitwarden.ui.platform.base.BaseViewModelTest
|
import com.x8bit.bitwarden.ui.platform.base.BaseViewModelTest
|
||||||
import com.x8bit.bitwarden.ui.platform.base.util.asText
|
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.BeforeEach
|
||||||
import org.junit.jupiter.api.Nested
|
import org.junit.jupiter.api.Nested
|
||||||
import org.junit.jupiter.api.Test
|
import org.junit.jupiter.api.Test
|
||||||
|
import java.io.File
|
||||||
import java.time.Instant
|
import java.time.Instant
|
||||||
|
|
||||||
@Suppress("LargeClass")
|
@Suppress("LargeClass")
|
||||||
|
@ -61,6 +65,8 @@ class VaultItemViewModelTest : BaseViewModelTest() {
|
||||||
every { getVaultItemStateFlow(VAULT_ITEM_ID) } returns mutableVaultItemFlow
|
every { getVaultItemStateFlow(VAULT_ITEM_ID) } returns mutableVaultItemFlow
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private val mockFileManager: FileManager = mockk()
|
||||||
|
|
||||||
@BeforeEach
|
@BeforeEach
|
||||||
fun setup() {
|
fun setup() {
|
||||||
mockkStatic(CipherView::toViewState)
|
mockkStatic(CipherView::toViewState)
|
||||||
|
@ -744,6 +750,7 @@ class VaultItemViewModelTest : BaseViewModelTest() {
|
||||||
val loginViewState = VaultItemState.ViewState.Content(
|
val loginViewState = VaultItemState.ViewState.Content(
|
||||||
common = createCommonContent(
|
common = createCommonContent(
|
||||||
isEmpty = true,
|
isEmpty = true,
|
||||||
|
isPremiumUser = true,
|
||||||
).copy(
|
).copy(
|
||||||
requiresReprompt = false,
|
requiresReprompt = false,
|
||||||
customFields = listOf(hiddenField),
|
customFields = listOf(hiddenField),
|
||||||
|
@ -772,7 +779,7 @@ class VaultItemViewModelTest : BaseViewModelTest() {
|
||||||
assertEquals(
|
assertEquals(
|
||||||
loginState.copy(
|
loginState.copy(
|
||||||
viewState = loginViewState.copy(
|
viewState = loginViewState.copy(
|
||||||
common = createCommonContent(isEmpty = true).copy(
|
common = createCommonContent(isEmpty = true, isPremiumUser = true).copy(
|
||||||
requiresReprompt = false,
|
requiresReprompt = false,
|
||||||
customFields = listOf(hiddenField.copy(isVisible = true)),
|
customFields = listOf(hiddenField.copy(isVisible = true)),
|
||||||
),
|
),
|
||||||
|
@ -994,6 +1001,291 @@ class VaultItemViewModelTest : BaseViewModelTest() {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `on AttachmentDownloadClick should prompt for password if required`() =
|
||||||
|
runTest {
|
||||||
|
val loginViewState = createViewState(
|
||||||
|
common = DEFAULT_COMMON.copy(requiresReprompt = true),
|
||||||
|
)
|
||||||
|
val mockCipherView = mockk<CipherView> {
|
||||||
|
every {
|
||||||
|
toViewState(
|
||||||
|
isPremiumUser = true,
|
||||||
|
totpCodeItemData = null,
|
||||||
|
)
|
||||||
|
} returns loginViewState
|
||||||
|
}
|
||||||
|
mutableVaultItemFlow.value = DataState.Loaded(data = mockCipherView)
|
||||||
|
mutableAuthCodeItemFlow.value = DataState.Loaded(data = null)
|
||||||
|
val loginState = DEFAULT_STATE.copy(viewState = loginViewState)
|
||||||
|
val viewModel = createViewModel(state = loginState)
|
||||||
|
|
||||||
|
val attachment = VaultItemState.ViewState.Content.Common.AttachmentItem(
|
||||||
|
id = "attachment-id",
|
||||||
|
displaySize = "11 MB",
|
||||||
|
isLargeFile = true,
|
||||||
|
isDownloadAllowed = false,
|
||||||
|
url = "https://example.com",
|
||||||
|
title = "test.mp4",
|
||||||
|
)
|
||||||
|
|
||||||
|
viewModel.stateFlow.test {
|
||||||
|
assertEquals(
|
||||||
|
loginState,
|
||||||
|
awaitItem(),
|
||||||
|
)
|
||||||
|
|
||||||
|
viewModel.trySendAction(
|
||||||
|
VaultItemAction.Common.AttachmentDownloadClick(attachment),
|
||||||
|
)
|
||||||
|
|
||||||
|
assertEquals(
|
||||||
|
loginState.copy(
|
||||||
|
dialog = VaultItemState.DialogState.MasterPasswordDialog(
|
||||||
|
action = PasswordRepromptAction.AttachmentDownloadClick(attachment),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
awaitItem(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
coVerify(exactly = 0) {
|
||||||
|
vaultRepo.downloadAttachment(any(), any())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Suppress("MaxLineLength")
|
||||||
|
@Test
|
||||||
|
fun `on AttachmentDownloadClick should show loading dialog, attempt to download an attachment, and display an error dialog on failure`() =
|
||||||
|
runTest {
|
||||||
|
val loginViewState = createViewState(
|
||||||
|
common = DEFAULT_COMMON.copy(requiresReprompt = false),
|
||||||
|
)
|
||||||
|
val mockCipherView = mockk<CipherView> {
|
||||||
|
every {
|
||||||
|
toViewState(
|
||||||
|
isPremiumUser = true,
|
||||||
|
totpCodeItemData = null,
|
||||||
|
)
|
||||||
|
} returns loginViewState
|
||||||
|
}
|
||||||
|
mutableVaultItemFlow.value = DataState.Loaded(data = mockCipherView)
|
||||||
|
mutableAuthCodeItemFlow.value = DataState.Loaded(data = null)
|
||||||
|
val loginState = DEFAULT_STATE.copy(viewState = loginViewState)
|
||||||
|
val viewModel = createViewModel(state = loginState)
|
||||||
|
|
||||||
|
val attachment = VaultItemState.ViewState.Content.Common.AttachmentItem(
|
||||||
|
id = "attachment-id",
|
||||||
|
displaySize = "11 MB",
|
||||||
|
isLargeFile = true,
|
||||||
|
isDownloadAllowed = false,
|
||||||
|
url = "https://example.com",
|
||||||
|
title = "test.mp4",
|
||||||
|
)
|
||||||
|
|
||||||
|
coEvery {
|
||||||
|
vaultRepo.downloadAttachment(any(), any())
|
||||||
|
} returns DownloadAttachmentResult.Failure
|
||||||
|
|
||||||
|
viewModel.stateFlow.test {
|
||||||
|
assertEquals(
|
||||||
|
loginState,
|
||||||
|
awaitItem(),
|
||||||
|
)
|
||||||
|
|
||||||
|
viewModel.trySendAction(
|
||||||
|
VaultItemAction.Common.AttachmentDownloadClick(attachment),
|
||||||
|
)
|
||||||
|
|
||||||
|
assertEquals(
|
||||||
|
loginState.copy(dialog = VaultItemState.DialogState.Loading(R.string.downloading.asText())),
|
||||||
|
awaitItem(),
|
||||||
|
)
|
||||||
|
|
||||||
|
assertEquals(
|
||||||
|
loginState.copy(
|
||||||
|
dialog = VaultItemState.DialogState.Generic(
|
||||||
|
R.string.unable_to_download_file.asText(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
awaitItem(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
coVerify(exactly = 1) {
|
||||||
|
vaultRepo.downloadAttachment(any(), any())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Suppress("MaxLineLength")
|
||||||
|
@Test
|
||||||
|
fun `on AttachmentDownloadClick should show loading dialog, attempt to download an attachment, and emit NavigateToSelectAttachmentSaveLocation on success`() =
|
||||||
|
runTest {
|
||||||
|
val loginViewState = createViewState(
|
||||||
|
common = DEFAULT_COMMON.copy(requiresReprompt = false),
|
||||||
|
)
|
||||||
|
val mockCipherView = mockk<CipherView> {
|
||||||
|
every {
|
||||||
|
toViewState(
|
||||||
|
isPremiumUser = true,
|
||||||
|
totpCodeItemData = null,
|
||||||
|
)
|
||||||
|
} returns loginViewState
|
||||||
|
}
|
||||||
|
mutableVaultItemFlow.value = DataState.Loaded(data = mockCipherView)
|
||||||
|
mutableAuthCodeItemFlow.value = DataState.Loaded(data = null)
|
||||||
|
val loginState = DEFAULT_STATE.copy(viewState = loginViewState)
|
||||||
|
val viewModel = createViewModel(state = loginState)
|
||||||
|
|
||||||
|
val attachment = VaultItemState.ViewState.Content.Common.AttachmentItem(
|
||||||
|
id = "attachment-id",
|
||||||
|
displaySize = "11 MB",
|
||||||
|
isLargeFile = true,
|
||||||
|
isDownloadAllowed = false,
|
||||||
|
url = "https://example.com",
|
||||||
|
title = "test.mp4",
|
||||||
|
)
|
||||||
|
|
||||||
|
val file = mockk<File>()
|
||||||
|
coEvery {
|
||||||
|
vaultRepo.downloadAttachment(any(), any())
|
||||||
|
} returns DownloadAttachmentResult.Success(file)
|
||||||
|
|
||||||
|
viewModel.stateFlow.test {
|
||||||
|
assertEquals(
|
||||||
|
loginState,
|
||||||
|
awaitItem(),
|
||||||
|
)
|
||||||
|
|
||||||
|
viewModel.trySendAction(
|
||||||
|
VaultItemAction.Common.AttachmentDownloadClick(attachment),
|
||||||
|
)
|
||||||
|
|
||||||
|
assertEquals(
|
||||||
|
loginState.copy(
|
||||||
|
dialog = VaultItemState.DialogState.Loading(
|
||||||
|
R.string.downloading.asText(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
awaitItem(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
viewModel.eventFlow.test {
|
||||||
|
assertEquals(
|
||||||
|
VaultItemEvent.NavigateToSelectAttachmentSaveLocation("test.mp4"),
|
||||||
|
awaitItem(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
coVerify(exactly = 1) {
|
||||||
|
vaultRepo.downloadAttachment(any(), any())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Suppress("MaxLineLength")
|
||||||
|
@Test
|
||||||
|
fun `on AttachmentFileLocationReceive success should hide loading dialog, copy file, delete file, and show toast`() =
|
||||||
|
runTest {
|
||||||
|
val file = mockk<File>()
|
||||||
|
val viewModel = createViewModel(state = DEFAULT_STATE, tempAttachmentFile = file)
|
||||||
|
|
||||||
|
coEvery {
|
||||||
|
mockFileManager.deleteFile(any())
|
||||||
|
} just runs
|
||||||
|
|
||||||
|
val uri = mockk<Uri>()
|
||||||
|
coEvery {
|
||||||
|
mockFileManager.fileToUri(uri, file)
|
||||||
|
} returns true
|
||||||
|
|
||||||
|
viewModel.trySendAction(
|
||||||
|
VaultItemAction.Common.AttachmentFileLocationReceive(uri),
|
||||||
|
)
|
||||||
|
|
||||||
|
assertEquals(
|
||||||
|
DEFAULT_STATE,
|
||||||
|
viewModel.stateFlow.value,
|
||||||
|
)
|
||||||
|
|
||||||
|
viewModel.eventFlow.test {
|
||||||
|
assertEquals(
|
||||||
|
VaultItemEvent.ShowToast(R.string.save_attachment_success.asText()),
|
||||||
|
awaitItem(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
coVerify(exactly = 1) {
|
||||||
|
mockFileManager.deleteFile(file)
|
||||||
|
mockFileManager.fileToUri(uri, file)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Suppress("MaxLineLength")
|
||||||
|
@Test
|
||||||
|
fun `on AttachmentFileLocationReceive failure should hide loading dialog, copy file, delete file, and show dialog`() =
|
||||||
|
runTest {
|
||||||
|
val file = mockk<File>()
|
||||||
|
val viewModel = createViewModel(state = DEFAULT_STATE, tempAttachmentFile = file)
|
||||||
|
|
||||||
|
coEvery {
|
||||||
|
mockFileManager.deleteFile(any())
|
||||||
|
} just runs
|
||||||
|
|
||||||
|
val uri = mockk<Uri>()
|
||||||
|
coEvery {
|
||||||
|
mockFileManager.fileToUri(uri, file)
|
||||||
|
} returns false
|
||||||
|
|
||||||
|
viewModel.stateFlow.test {
|
||||||
|
viewModel.trySendAction(
|
||||||
|
VaultItemAction.Common.AttachmentFileLocationReceive(uri),
|
||||||
|
)
|
||||||
|
|
||||||
|
assertEquals(
|
||||||
|
DEFAULT_STATE,
|
||||||
|
awaitItem(),
|
||||||
|
)
|
||||||
|
|
||||||
|
assertEquals(
|
||||||
|
DEFAULT_STATE.copy(
|
||||||
|
dialog = VaultItemState.DialogState.Generic(
|
||||||
|
R.string.unable_to_save_attachment.asText(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
awaitItem(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
coVerify(exactly = 1) {
|
||||||
|
mockFileManager.deleteFile(file)
|
||||||
|
mockFileManager.fileToUri(uri, file)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `on NoAttachmentFileLocationReceive failure should show dialog`() {
|
||||||
|
val file = mockk<File>()
|
||||||
|
val viewModel = createViewModel(state = DEFAULT_STATE, tempAttachmentFile = file)
|
||||||
|
|
||||||
|
coEvery {
|
||||||
|
mockFileManager.deleteFile(any())
|
||||||
|
} just runs
|
||||||
|
|
||||||
|
viewModel.trySendAction(VaultItemAction.Common.NoAttachmentFileLocationReceive)
|
||||||
|
|
||||||
|
assertEquals(
|
||||||
|
DEFAULT_STATE.copy(
|
||||||
|
dialog = VaultItemState.DialogState.Generic(
|
||||||
|
R.string.unable_to_save_attachment.asText(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
viewModel.stateFlow.value,
|
||||||
|
)
|
||||||
|
|
||||||
|
coVerify { mockFileManager.deleteFile(file) }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Nested
|
@Nested
|
||||||
|
@ -1488,20 +1780,25 @@ class VaultItemViewModelTest : BaseViewModelTest() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Suppress("LongParameterList")
|
||||||
private fun createViewModel(
|
private fun createViewModel(
|
||||||
state: VaultItemState?,
|
state: VaultItemState?,
|
||||||
vaultItemId: String = VAULT_ITEM_ID,
|
vaultItemId: String = VAULT_ITEM_ID,
|
||||||
bitwardenClipboardManager: BitwardenClipboardManager = clipboardManager,
|
bitwardenClipboardManager: BitwardenClipboardManager = clipboardManager,
|
||||||
authRepository: AuthRepository = authRepo,
|
authRepository: AuthRepository = authRepo,
|
||||||
vaultRepository: VaultRepository = vaultRepo,
|
vaultRepository: VaultRepository = vaultRepo,
|
||||||
|
fileManager: FileManager = mockFileManager,
|
||||||
|
tempAttachmentFile: File? = null,
|
||||||
): VaultItemViewModel = VaultItemViewModel(
|
): VaultItemViewModel = VaultItemViewModel(
|
||||||
savedStateHandle = SavedStateHandle().apply {
|
savedStateHandle = SavedStateHandle().apply {
|
||||||
set("state", state)
|
set("state", state)
|
||||||
set("vault_item_id", vaultItemId)
|
set("vault_item_id", vaultItemId)
|
||||||
|
set("tempAttachmentFile", tempAttachmentFile)
|
||||||
},
|
},
|
||||||
clipboardManager = bitwardenClipboardManager,
|
clipboardManager = bitwardenClipboardManager,
|
||||||
authRepository = authRepository,
|
authRepository = authRepository,
|
||||||
vaultRepository = vaultRepository,
|
vaultRepository = vaultRepository,
|
||||||
|
fileManager = fileManager,
|
||||||
)
|
)
|
||||||
|
|
||||||
private fun createViewState(
|
private fun createViewState(
|
||||||
|
@ -1619,6 +1916,16 @@ class VaultItemViewModelTest : BaseViewModelTest() {
|
||||||
),
|
),
|
||||||
requiresReprompt = true,
|
requiresReprompt = true,
|
||||||
currentCipher = createMockCipherView(number = 1),
|
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 =
|
private val DEFAULT_VIEW_STATE: VaultItemState.ViewState.Content =
|
||||||
|
|
|
@ -38,7 +38,8 @@ class CipherViewExtensionsTest {
|
||||||
|
|
||||||
assertEquals(
|
assertEquals(
|
||||||
VaultItemState.ViewState.Content(
|
VaultItemState.ViewState.Content(
|
||||||
common = createCommonContent(isEmpty = false).copy(currentCipher = cipherView),
|
common = createCommonContent(isEmpty = false, isPremiumUser = true)
|
||||||
|
.copy(currentCipher = cipherView),
|
||||||
type = createLoginContent(isEmpty = false),
|
type = createLoginContent(isEmpty = false),
|
||||||
),
|
),
|
||||||
viewState,
|
viewState,
|
||||||
|
@ -62,7 +63,8 @@ class CipherViewExtensionsTest {
|
||||||
|
|
||||||
assertEquals(
|
assertEquals(
|
||||||
VaultItemState.ViewState.Content(
|
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),
|
type = createLoginContent(isEmpty = false).copy(isPremiumUser = isPremiumUser),
|
||||||
),
|
),
|
||||||
viewState,
|
viewState,
|
||||||
|
@ -79,7 +81,7 @@ class CipherViewExtensionsTest {
|
||||||
|
|
||||||
assertEquals(
|
assertEquals(
|
||||||
VaultItemState.ViewState.Content(
|
VaultItemState.ViewState.Content(
|
||||||
common = createCommonContent(isEmpty = true).copy(
|
common = createCommonContent(isEmpty = true, isPremiumUser = true).copy(
|
||||||
currentCipher = cipherView,
|
currentCipher = cipherView,
|
||||||
),
|
),
|
||||||
type = createLoginContent(isEmpty = true),
|
type = createLoginContent(isEmpty = true),
|
||||||
|
@ -98,7 +100,8 @@ class CipherViewExtensionsTest {
|
||||||
|
|
||||||
assertEquals(
|
assertEquals(
|
||||||
VaultItemState.ViewState.Content(
|
VaultItemState.ViewState.Content(
|
||||||
common = createCommonContent(isEmpty = false).copy(currentCipher = cipherView),
|
common = createCommonContent(isEmpty = false, isPremiumUser = true)
|
||||||
|
.copy(currentCipher = cipherView),
|
||||||
type = createIdentityContent(isEmpty = false),
|
type = createIdentityContent(isEmpty = false),
|
||||||
),
|
),
|
||||||
viewState,
|
viewState,
|
||||||
|
@ -115,7 +118,8 @@ class CipherViewExtensionsTest {
|
||||||
|
|
||||||
assertEquals(
|
assertEquals(
|
||||||
VaultItemState.ViewState.Content(
|
VaultItemState.ViewState.Content(
|
||||||
common = createCommonContent(isEmpty = true).copy(currentCipher = cipherView),
|
common = createCommonContent(isEmpty = true, isPremiumUser = true)
|
||||||
|
.copy(currentCipher = cipherView),
|
||||||
type = createIdentityContent(isEmpty = true),
|
type = createIdentityContent(isEmpty = true),
|
||||||
),
|
),
|
||||||
viewState,
|
viewState,
|
||||||
|
@ -142,7 +146,8 @@ class CipherViewExtensionsTest {
|
||||||
|
|
||||||
assertEquals(
|
assertEquals(
|
||||||
VaultItemState.ViewState.Content(
|
VaultItemState.ViewState.Content(
|
||||||
common = createCommonContent(isEmpty = false).copy(currentCipher = cipherView),
|
common = createCommonContent(isEmpty = false, isPremiumUser = true)
|
||||||
|
.copy(currentCipher = cipherView),
|
||||||
type = createIdentityContent(
|
type = createIdentityContent(
|
||||||
isEmpty = false,
|
isEmpty = false,
|
||||||
identityName = "Mx middleName",
|
identityName = "Mx middleName",
|
||||||
|
@ -174,7 +179,7 @@ class CipherViewExtensionsTest {
|
||||||
|
|
||||||
assertEquals(
|
assertEquals(
|
||||||
VaultItemState.ViewState.Content(
|
VaultItemState.ViewState.Content(
|
||||||
common = createCommonContent(isEmpty = false).copy(
|
common = createCommonContent(isEmpty = false, isPremiumUser = true).copy(
|
||||||
currentCipher = cipherView.copy(
|
currentCipher = cipherView.copy(
|
||||||
identity = cipherView.identity?.copy(
|
identity = cipherView.identity?.copy(
|
||||||
address1 = null,
|
address1 = null,
|
||||||
|
@ -209,7 +214,8 @@ class CipherViewExtensionsTest {
|
||||||
|
|
||||||
assertEquals(
|
assertEquals(
|
||||||
VaultItemState.ViewState.Content(
|
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,
|
type = VaultItemState.ViewState.Content.ItemType.SecureNote,
|
||||||
),
|
),
|
||||||
viewState,
|
viewState,
|
||||||
|
@ -226,7 +232,8 @@ class CipherViewExtensionsTest {
|
||||||
)
|
)
|
||||||
|
|
||||||
val expectedState = VaultItemState.ViewState.Content(
|
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,
|
type = VaultItemState.ViewState.Content.ItemType.SecureNote,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
@ -1,5 +1,6 @@
|
||||||
package com.x8bit.bitwarden.ui.vault.feature.item.util
|
package com.x8bit.bitwarden.ui.vault.feature.item.util
|
||||||
|
|
||||||
|
import com.bitwarden.core.AttachmentView
|
||||||
import com.bitwarden.core.CipherRepromptType
|
import com.bitwarden.core.CipherRepromptType
|
||||||
import com.bitwarden.core.CipherType
|
import com.bitwarden.core.CipherType
|
||||||
import com.bitwarden.core.CipherView
|
import com.bitwarden.core.CipherView
|
||||||
|
@ -86,7 +87,17 @@ fun createCipherView(type: CipherType, isEmpty: Boolean): CipherView =
|
||||||
edit = false,
|
edit = false,
|
||||||
viewPassword = false,
|
viewPassword = false,
|
||||||
localData = null,
|
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(
|
fields = listOf(
|
||||||
FieldView(
|
FieldView(
|
||||||
name = "text",
|
name = "text",
|
||||||
|
@ -132,7 +143,10 @@ fun createCipherView(type: CipherType, isEmpty: Boolean): CipherView =
|
||||||
revisionDate = Instant.ofEpochSecond(1_000L),
|
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) {
|
if (isEmpty) {
|
||||||
VaultItemState.ViewState.Content.Common(
|
VaultItemState.ViewState.Content.Common(
|
||||||
name = "mockName",
|
name = "mockName",
|
||||||
|
@ -140,6 +154,7 @@ fun createCommonContent(isEmpty: Boolean): VaultItemState.ViewState.Content.Comm
|
||||||
notes = null,
|
notes = null,
|
||||||
customFields = emptyList(),
|
customFields = emptyList(),
|
||||||
requiresReprompt = true,
|
requiresReprompt = true,
|
||||||
|
attachments = emptyList(),
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
VaultItemState.ViewState.Content.Common(
|
VaultItemState.ViewState.Content.Common(
|
||||||
|
@ -172,6 +187,16 @@ fun createCommonContent(isEmpty: Boolean): VaultItemState.ViewState.Content.Comm
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
requiresReprompt = true,
|
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",
|
||||||
|
),
|
||||||
|
),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
Loading…
Reference in a new issue