mirror of
https://github.com/bitwarden/android.git
synced 2024-11-26 19:36:18 +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
|
||||
|
||||
override val staticRetrofitBuilder: Retrofit.Builder by lazy {
|
||||
baseRetrofitBuilder
|
||||
.client(
|
||||
baseOkHttpClient
|
||||
.newBuilder()
|
||||
.addInterceptor(loggingInterceptor)
|
||||
.build(),
|
||||
)
|
||||
}
|
||||
override val staticRetrofitBuilder: Retrofit.Builder
|
||||
get() =
|
||||
baseRetrofitBuilder
|
||||
.client(
|
||||
baseOkHttpClient
|
||||
.newBuilder()
|
||||
.addInterceptor(loggingInterceptor)
|
||||
.build(),
|
||||
)
|
||||
|
||||
//endregion Other Retrofits
|
||||
|
||||
|
|
|
@ -121,4 +121,13 @@ interface CiphersApi {
|
|||
suspend fun getCipher(
|
||||
@Path("cipherId") cipherId: String,
|
||||
): Result<SyncResponseJson.Cipher>
|
||||
|
||||
/**
|
||||
* Gets a cipher attachment.
|
||||
*/
|
||||
@GET("ciphers/{cipherId}/attachment/{attachmentId}")
|
||||
suspend fun getCipherAttachment(
|
||||
@Path("cipherId") cipherId: String,
|
||||
@Path("attachmentId") attachmentId: String,
|
||||
): Result<SyncResponseJson.Cipher.Attachment>
|
||||
}
|
||||
|
|
|
@ -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.vault.datasource.network.service.CiphersService
|
||||
import com.x8bit.bitwarden.data.vault.datasource.network.service.CiphersServiceImpl
|
||||
import com.x8bit.bitwarden.data.vault.datasource.network.service.DownloadService
|
||||
import com.x8bit.bitwarden.data.vault.datasource.network.service.DownloadServiceImpl
|
||||
import com.x8bit.bitwarden.data.vault.datasource.network.service.FolderService
|
||||
import com.x8bit.bitwarden.data.vault.datasource.network.service.FolderServiceImpl
|
||||
import com.x8bit.bitwarden.data.vault.datasource.network.service.SendsService
|
||||
|
@ -78,4 +80,17 @@ object VaultNetworkModule {
|
|||
): SyncService = SyncServiceImpl(
|
||||
syncApi = retrofits.authenticatedApiRetrofit.create(),
|
||||
)
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideDownloadService(
|
||||
retrofits: Retrofits,
|
||||
): DownloadService = DownloadServiceImpl(
|
||||
downloadApi = retrofits
|
||||
.staticRetrofitBuilder
|
||||
// This URL will be overridden dynamically
|
||||
.baseUrl("https://www.bitwarden.com")
|
||||
.build()
|
||||
.create(),
|
||||
)
|
||||
}
|
||||
|
|
|
@ -93,4 +93,12 @@ interface CiphersService {
|
|||
* Attempt to retrieve a cipher.
|
||||
*/
|
||||
suspend fun getCipher(cipherId: String): Result<SyncResponseJson.Cipher>
|
||||
|
||||
/**
|
||||
* Attempt to retrieve a cipher's attachment data.
|
||||
*/
|
||||
suspend fun getCipherAttachment(
|
||||
cipherId: String,
|
||||
attachmentId: String,
|
||||
): Result<SyncResponseJson.Cipher.Attachment>
|
||||
}
|
||||
|
|
|
@ -150,4 +150,13 @@ class CiphersServiceImpl(
|
|||
cipherId: String,
|
||||
): Result<SyncResponseJson.Cipher> =
|
||||
ciphersApi.getCipher(cipherId = cipherId)
|
||||
|
||||
override suspend fun getCipherAttachment(
|
||||
cipherId: String,
|
||||
attachmentId: String,
|
||||
): Result<SyncResponseJson.Cipher.Attachment> =
|
||||
ciphersApi.getCipherAttachment(
|
||||
cipherId = cipherId,
|
||||
attachmentId = attachmentId,
|
||||
)
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
||||
import com.bitwarden.core.Attachment
|
||||
import com.bitwarden.core.AttachmentEncryptResult
|
||||
import com.bitwarden.core.AttachmentView
|
||||
import com.bitwarden.core.Cipher
|
||||
|
@ -274,6 +275,18 @@ interface VaultSdkSource {
|
|||
folderList: List<Folder>,
|
||||
): Result<List<FolderView>>
|
||||
|
||||
/**
|
||||
* Decrypts a [cipher] [attachment] file found at [encryptedFilePath] saving it at
|
||||
* [decryptedFilePath] for the user with the given [userId]
|
||||
*/
|
||||
suspend fun decryptFile(
|
||||
userId: String,
|
||||
cipher: Cipher,
|
||||
attachment: Attachment,
|
||||
encryptedFilePath: String,
|
||||
decryptedFilePath: String,
|
||||
): Result<Unit>
|
||||
|
||||
/**
|
||||
* Encrypts a given password history item for the user with the given [userId].
|
||||
*
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
package com.x8bit.bitwarden.data.vault.datasource.sdk
|
||||
|
||||
import com.bitwarden.core.Attachment
|
||||
import com.bitwarden.core.AttachmentEncryptResult
|
||||
import com.bitwarden.core.AttachmentView
|
||||
import com.bitwarden.core.Cipher
|
||||
|
@ -284,6 +285,25 @@ class VaultSdkSourceImpl(
|
|||
.decryptList(folderList)
|
||||
}
|
||||
|
||||
override suspend fun decryptFile(
|
||||
userId: String,
|
||||
cipher: Cipher,
|
||||
attachment: Attachment,
|
||||
encryptedFilePath: String,
|
||||
decryptedFilePath: String,
|
||||
): Result<Unit> =
|
||||
runCatching {
|
||||
getClient(userId = userId)
|
||||
.vault()
|
||||
.attachments()
|
||||
.decryptFile(
|
||||
cipher = cipher,
|
||||
attachment = attachment,
|
||||
encryptedFilePath = encryptedFilePath,
|
||||
decryptedFilePath = decryptedFilePath,
|
||||
)
|
||||
}
|
||||
|
||||
override suspend fun encryptPasswordHistory(
|
||||
userId: String,
|
||||
passwordHistory: PasswordHistoryView,
|
||||
|
|
|
@ -2,6 +2,8 @@ package com.x8bit.bitwarden.data.vault.manager
|
|||
|
||||
import android.net.Uri
|
||||
import com.x8bit.bitwarden.data.platform.annotation.OmitFromCoverage
|
||||
import com.x8bit.bitwarden.data.vault.manager.model.DownloadResult
|
||||
import java.io.File
|
||||
|
||||
/**
|
||||
* Manages reading files.
|
||||
|
@ -9,6 +11,23 @@ import com.x8bit.bitwarden.data.platform.annotation.OmitFromCoverage
|
|||
@OmitFromCoverage
|
||||
interface FileManager {
|
||||
|
||||
/**
|
||||
* Deletes a [file] from the system.
|
||||
*/
|
||||
suspend fun deleteFile(file: File)
|
||||
|
||||
/**
|
||||
* Downloads a file temporarily to cache from [url]. A successful [DownloadResult] will contain
|
||||
* the final file path.
|
||||
*/
|
||||
suspend fun downloadFileToCache(url: String): DownloadResult
|
||||
|
||||
/**
|
||||
* Writes an existing [file] to a [fileUri]. `true` will be returned if the file was
|
||||
* successfully saved.
|
||||
*/
|
||||
suspend fun fileToUri(fileUri: Uri, file: File): Boolean
|
||||
|
||||
/**
|
||||
* Reads the [fileUri] into memory and returns the raw [ByteArray]
|
||||
*/
|
||||
|
|
|
@ -3,7 +3,14 @@ package com.x8bit.bitwarden.data.vault.manager
|
|||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import com.x8bit.bitwarden.data.platform.annotation.OmitFromCoverage
|
||||
import com.x8bit.bitwarden.data.vault.datasource.network.service.DownloadService
|
||||
import com.x8bit.bitwarden.data.vault.manager.model.DownloadResult
|
||||
import okio.use
|
||||
import java.io.ByteArrayOutputStream
|
||||
import java.io.File
|
||||
import java.io.FileInputStream
|
||||
import java.io.FileOutputStream
|
||||
import java.util.UUID
|
||||
|
||||
/**
|
||||
* The buffer size to be used when reading from an input stream.
|
||||
|
@ -16,8 +23,69 @@ private const val BUFFER_SIZE: Int = 1024
|
|||
@OmitFromCoverage
|
||||
class FileManagerImpl(
|
||||
private val context: Context,
|
||||
private val downloadService: DownloadService,
|
||||
) : FileManager {
|
||||
|
||||
override suspend fun deleteFile(file: File) {
|
||||
file.delete()
|
||||
}
|
||||
|
||||
@Suppress("NestedBlockDepth", "ReturnCount")
|
||||
override suspend fun downloadFileToCache(url: String): DownloadResult {
|
||||
val response = downloadService
|
||||
.getDataStream(url)
|
||||
.fold(
|
||||
onSuccess = { it },
|
||||
onFailure = { return DownloadResult.Failure },
|
||||
)
|
||||
|
||||
// Create a temporary file in cache to write to
|
||||
val file = File(context.cacheDir, UUID.randomUUID().toString())
|
||||
val stream = response.byteStream()
|
||||
stream.use {
|
||||
val buffer = ByteArray(BUFFER_SIZE)
|
||||
var progress = 0
|
||||
FileOutputStream(file).use { fos ->
|
||||
@Suppress("TooGenericExceptionCaught")
|
||||
try {
|
||||
var read = stream.read(buffer)
|
||||
while (read > 0) {
|
||||
fos.write(buffer, 0, read)
|
||||
progress += read
|
||||
read = stream.read(buffer)
|
||||
}
|
||||
fos.flush()
|
||||
} catch (e: RuntimeException) {
|
||||
return DownloadResult.Failure
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return DownloadResult.Success(file)
|
||||
}
|
||||
|
||||
@Suppress("NestedBlockDepth")
|
||||
override suspend fun fileToUri(fileUri: Uri, file: File): Boolean {
|
||||
@Suppress("TooGenericExceptionCaught")
|
||||
return try {
|
||||
context
|
||||
.contentResolver
|
||||
.openOutputStream(fileUri)
|
||||
?.use { outputStream ->
|
||||
FileInputStream(file).use { inputStream ->
|
||||
val buffer = ByteArray(BUFFER_SIZE)
|
||||
var length: Int
|
||||
while (inputStream.read(buffer).also { length = it } != -1) {
|
||||
outputStream.write(buffer, 0, length)
|
||||
}
|
||||
}
|
||||
}
|
||||
true
|
||||
} catch (exception: RuntimeException) {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
override fun uriToByteArray(fileUri: Uri): ByteArray =
|
||||
context
|
||||
.contentResolver
|
||||
|
|
|
@ -7,6 +7,7 @@ import com.x8bit.bitwarden.data.auth.manager.UserLogoutManager
|
|||
import com.x8bit.bitwarden.data.platform.manager.AppForegroundManager
|
||||
import com.x8bit.bitwarden.data.platform.manager.dispatcher.DispatcherManager
|
||||
import com.x8bit.bitwarden.data.platform.repository.SettingsRepository
|
||||
import com.x8bit.bitwarden.data.vault.datasource.network.service.DownloadService
|
||||
import com.x8bit.bitwarden.data.vault.datasource.sdk.VaultSdkSource
|
||||
import com.x8bit.bitwarden.data.vault.manager.FileManager
|
||||
import com.x8bit.bitwarden.data.vault.manager.FileManagerImpl
|
||||
|
@ -33,7 +34,11 @@ object VaultManagerModule {
|
|||
@Singleton
|
||||
fun provideFileManager(
|
||||
@ApplicationContext context: Context,
|
||||
): FileManager = FileManagerImpl(context)
|
||||
downloadService: DownloadService,
|
||||
): FileManager = FileManagerImpl(
|
||||
context = context,
|
||||
downloadService = downloadService,
|
||||
)
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
|
|
|
@ -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.DeleteSendResult
|
||||
import com.x8bit.bitwarden.data.vault.repository.model.DomainsData
|
||||
import com.x8bit.bitwarden.data.vault.repository.model.DownloadAttachmentResult
|
||||
import com.x8bit.bitwarden.data.vault.repository.model.GenerateTotpResult
|
||||
import com.x8bit.bitwarden.data.vault.repository.model.RemovePasswordSendResult
|
||||
import com.x8bit.bitwarden.data.vault.repository.model.RestoreCipherResult
|
||||
|
@ -219,6 +220,15 @@ interface VaultRepository : VaultLockManager {
|
|||
fileUri: Uri,
|
||||
): CreateAttachmentResult
|
||||
|
||||
/**
|
||||
* Attempt to download an attachment file, specified by [attachmentId], for the given
|
||||
* [cipherView].
|
||||
*/
|
||||
suspend fun downloadAttachment(
|
||||
cipherView: CipherView,
|
||||
attachmentId: String,
|
||||
): DownloadAttachmentResult
|
||||
|
||||
/**
|
||||
* Attempt to delete a cipher.
|
||||
*/
|
||||
|
|
|
@ -50,6 +50,7 @@ import com.x8bit.bitwarden.data.vault.datasource.sdk.VaultSdkSource
|
|||
import com.x8bit.bitwarden.data.vault.manager.FileManager
|
||||
import com.x8bit.bitwarden.data.vault.manager.TotpCodeManager
|
||||
import com.x8bit.bitwarden.data.vault.manager.VaultLockManager
|
||||
import com.x8bit.bitwarden.data.vault.manager.model.DownloadResult
|
||||
import com.x8bit.bitwarden.data.vault.manager.model.VerificationCodeItem
|
||||
import com.x8bit.bitwarden.data.vault.repository.model.CreateAttachmentResult
|
||||
import com.x8bit.bitwarden.data.vault.repository.model.CreateCipherResult
|
||||
|
@ -60,6 +61,7 @@ import com.x8bit.bitwarden.data.vault.repository.model.DeleteCipherResult
|
|||
import com.x8bit.bitwarden.data.vault.repository.model.DeleteFolderResult
|
||||
import com.x8bit.bitwarden.data.vault.repository.model.DeleteSendResult
|
||||
import com.x8bit.bitwarden.data.vault.repository.model.DomainsData
|
||||
import com.x8bit.bitwarden.data.vault.repository.model.DownloadAttachmentResult
|
||||
import com.x8bit.bitwarden.data.vault.repository.model.GenerateTotpResult
|
||||
import com.x8bit.bitwarden.data.vault.repository.model.RemovePasswordSendResult
|
||||
import com.x8bit.bitwarden.data.vault.repository.model.RestoreCipherResult
|
||||
|
@ -109,6 +111,7 @@ import kotlinx.coroutines.flow.stateIn
|
|||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import retrofit2.HttpException
|
||||
import java.io.File
|
||||
import java.time.Clock
|
||||
import java.time.Instant
|
||||
import java.time.temporal.ChronoUnit
|
||||
|
@ -871,6 +874,63 @@ class VaultRepositoryImpl(
|
|||
)
|
||||
}
|
||||
|
||||
@Suppress("ReturnCount")
|
||||
override suspend fun downloadAttachment(
|
||||
cipherView: CipherView,
|
||||
attachmentId: String,
|
||||
): DownloadAttachmentResult {
|
||||
val userId = requireNotNull(authDiskSource.userState?.activeUserId)
|
||||
|
||||
val cipher = vaultSdkSource
|
||||
.encryptCipher(
|
||||
userId = userId,
|
||||
cipherView = cipherView,
|
||||
)
|
||||
.fold(
|
||||
onSuccess = { it },
|
||||
onFailure = { return DownloadAttachmentResult.Failure },
|
||||
)
|
||||
val attachment = cipher.attachments?.find { it.id == attachmentId }
|
||||
?: return DownloadAttachmentResult.Failure
|
||||
|
||||
val attachmentData = ciphersService
|
||||
.getCipherAttachment(
|
||||
cipherId = requireNotNull(cipher.id),
|
||||
attachmentId = attachmentId,
|
||||
)
|
||||
.fold(
|
||||
onSuccess = { it },
|
||||
onFailure = { return DownloadAttachmentResult.Failure },
|
||||
)
|
||||
|
||||
val url = attachmentData.url ?: return DownloadAttachmentResult.Failure
|
||||
|
||||
val encryptedFile = when (val result = fileManager.downloadFileToCache(url)) {
|
||||
DownloadResult.Failure -> return DownloadAttachmentResult.Failure
|
||||
is DownloadResult.Success -> result.file
|
||||
}
|
||||
|
||||
val decryptedFile = File(encryptedFile.path + "_decrypted")
|
||||
return vaultSdkSource
|
||||
.decryptFile(
|
||||
userId = userId,
|
||||
cipher = cipher,
|
||||
attachment = attachment,
|
||||
encryptedFilePath = encryptedFile.path,
|
||||
decryptedFilePath = decryptedFile.path,
|
||||
)
|
||||
.fold(
|
||||
onSuccess = {
|
||||
encryptedFile.delete()
|
||||
DownloadAttachmentResult.Success(decryptedFile)
|
||||
},
|
||||
onFailure = {
|
||||
encryptedFile.delete()
|
||||
DownloadAttachmentResult.Failure
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
override suspend fun createSend(
|
||||
sendView: SendView,
|
||||
fileUri: Uri?,
|
||||
|
|
|
@ -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
|
||||
|
||||
/**
|
||||
* Creates an intent to use when selecting to save an attachment with [fileName] to disk.
|
||||
*/
|
||||
fun createAttachmentChooserIntent(fileName: String): Intent
|
||||
|
||||
/**
|
||||
* Represents file information.
|
||||
*/
|
||||
|
|
|
@ -9,6 +9,7 @@ import android.content.pm.PackageManager
|
|||
import android.net.Uri
|
||||
import android.provider.MediaStore
|
||||
import android.provider.Settings
|
||||
import android.webkit.MimeTypeMap
|
||||
import androidx.activity.compose.ManagedActivityResultLauncher
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.ActivityResult
|
||||
|
@ -171,6 +172,19 @@ class IntentManagerImpl(
|
|||
return chooserIntent
|
||||
}
|
||||
|
||||
override fun createAttachmentChooserIntent(fileName: String): Intent =
|
||||
Intent(Intent.ACTION_CREATE_DOCUMENT).apply {
|
||||
// Attempt to get the MIME type from the file extension
|
||||
val extension = MimeTypeMap.getFileExtensionFromUrl(fileName)
|
||||
type = extension?.let {
|
||||
MimeTypeMap.getSingleton().getMimeTypeFromExtension(it)
|
||||
}
|
||||
?: "*/*"
|
||||
|
||||
addCategory(Intent.CATEGORY_OPENABLE)
|
||||
putExtra(Intent.EXTRA_TITLE, fileName)
|
||||
}
|
||||
|
||||
private fun getCameraFileData(): IntentManager.FileData {
|
||||
val tmpDir = File(context.filesDir, TEMP_CAMERA_IMAGE_DIR)
|
||||
val file = File(tmpDir, TEMP_CAMERA_IMAGE_NAME)
|
||||
|
|
|
@ -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 {
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
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 {
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
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 {
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
VaultItemUpdateText(
|
||||
|
|
|
@ -53,7 +53,7 @@ import com.x8bit.bitwarden.ui.vault.feature.item.handlers.VaultLoginItemTypeHand
|
|||
/**
|
||||
* Displays the vault item screen.
|
||||
*/
|
||||
@Suppress("LongMethod")
|
||||
@Suppress("LongMethod", "CyclomaticComplexMethod")
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun VaultItemScreen(
|
||||
|
@ -76,6 +76,16 @@ fun VaultItemScreen(
|
|||
var pendingDeleteCipher by rememberSaveable { mutableStateOf(false) }
|
||||
var pendingRestoreCipher by rememberSaveable { mutableStateOf(false) }
|
||||
|
||||
val fileChooserLauncher = intentManager.getActivityResultLauncher { activityResult ->
|
||||
intentManager.getFileDataFromActivityResult(activityResult)
|
||||
?.let {
|
||||
viewModel.trySendAction(
|
||||
VaultItemAction.Common.AttachmentFileLocationReceive(it.uri),
|
||||
)
|
||||
}
|
||||
?: viewModel.trySendAction(VaultItemAction.Common.NoAttachmentFileLocationReceive)
|
||||
}
|
||||
|
||||
EventsEffect(viewModel = viewModel) { event ->
|
||||
when (event) {
|
||||
VaultItemEvent.NavigateBack -> onNavigateBack()
|
||||
|
@ -104,6 +114,12 @@ fun VaultItemScreen(
|
|||
is VaultItemEvent.ShowToast -> {
|
||||
Toast.makeText(context, event.message(resources), Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
|
||||
is VaultItemEvent.NavigateToSelectAttachmentSaveLocation -> {
|
||||
fileChooserLauncher.launch(
|
||||
intentManager.createAttachmentChooserIntent(event.fileName),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -103,6 +103,29 @@ fun VaultItemSecureNoteContent(
|
|||
}
|
||||
}
|
||||
|
||||
commonState.attachments.takeUnless { it?.isEmpty() == true }?.let { attachments ->
|
||||
item {
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
BitwardenListHeaderText(
|
||||
label = stringResource(id = R.string.attachments),
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp),
|
||||
)
|
||||
}
|
||||
items(attachments) { attachmentItem ->
|
||||
AttachmentItemContent(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(start = 16.dp),
|
||||
attachmentItem = attachmentItem,
|
||||
onAttachmentDownloadClick =
|
||||
vaultCommonItemTypeHandlers.onAttachmentDownloadClick,
|
||||
)
|
||||
}
|
||||
item { Spacer(modifier = Modifier.height(8.dp)) }
|
||||
}
|
||||
|
||||
item {
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
Row(
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
package com.x8bit.bitwarden.ui.vault.feature.item
|
||||
|
||||
import android.net.Uri
|
||||
import android.os.Parcelable
|
||||
import androidx.lifecycle.SavedStateHandle
|
||||
import androidx.lifecycle.viewModelScope
|
||||
|
@ -12,8 +13,10 @@ import com.x8bit.bitwarden.data.auth.repository.model.ValidatePasswordResult
|
|||
import com.x8bit.bitwarden.data.platform.manager.clipboard.BitwardenClipboardManager
|
||||
import com.x8bit.bitwarden.data.platform.repository.model.DataState
|
||||
import com.x8bit.bitwarden.data.platform.repository.util.combineDataStates
|
||||
import com.x8bit.bitwarden.data.vault.manager.FileManager
|
||||
import com.x8bit.bitwarden.data.vault.repository.VaultRepository
|
||||
import com.x8bit.bitwarden.data.vault.repository.model.DeleteCipherResult
|
||||
import com.x8bit.bitwarden.data.vault.repository.model.DownloadAttachmentResult
|
||||
import com.x8bit.bitwarden.data.vault.repository.model.RestoreCipherResult
|
||||
import com.x8bit.bitwarden.ui.platform.base.BaseViewModel
|
||||
import com.x8bit.bitwarden.ui.platform.base.util.Text
|
||||
|
@ -32,9 +35,11 @@ import kotlinx.coroutines.flow.update
|
|||
import kotlinx.coroutines.launch
|
||||
import kotlinx.parcelize.IgnoredOnParcel
|
||||
import kotlinx.parcelize.Parcelize
|
||||
import java.io.File
|
||||
import javax.inject.Inject
|
||||
|
||||
private const val KEY_STATE = "state"
|
||||
private const val KEY_TEMP_ATTACHMENT = "tempAttachmentFile"
|
||||
|
||||
/**
|
||||
* ViewModel responsible for handling user interactions in the vault item screen
|
||||
|
@ -42,10 +47,11 @@ private const val KEY_STATE = "state"
|
|||
@Suppress("LargeClass", "TooManyFunctions")
|
||||
@HiltViewModel
|
||||
class VaultItemViewModel @Inject constructor(
|
||||
savedStateHandle: SavedStateHandle,
|
||||
private val savedStateHandle: SavedStateHandle,
|
||||
private val clipboardManager: BitwardenClipboardManager,
|
||||
private val authRepository: AuthRepository,
|
||||
private val vaultRepository: VaultRepository,
|
||||
private val fileManager: FileManager,
|
||||
) : BaseViewModel<VaultItemState, VaultItemEvent, VaultItemAction>(
|
||||
// We load the state from the savedStateHandle for testing purposes.
|
||||
initialState = savedStateHandle[KEY_STATE] ?: VaultItemState(
|
||||
|
@ -54,6 +60,14 @@ class VaultItemViewModel @Inject constructor(
|
|||
dialog = null,
|
||||
),
|
||||
) {
|
||||
/**
|
||||
* Reference to a temporary attachment saved in cache.
|
||||
*/
|
||||
private var temporaryAttachmentData: File?
|
||||
get() = savedStateHandle[KEY_TEMP_ATTACHMENT]
|
||||
set(value) {
|
||||
savedStateHandle[KEY_TEMP_ATTACHMENT] = value
|
||||
}
|
||||
|
||||
//region Initialization and Overrides
|
||||
init {
|
||||
|
@ -118,6 +132,18 @@ class VaultItemViewModel @Inject constructor(
|
|||
handleHiddenFieldVisibilityClicked(action)
|
||||
}
|
||||
|
||||
is VaultItemAction.Common.AttachmentDownloadClick -> {
|
||||
handleAttachmentDownloadClick(action)
|
||||
}
|
||||
|
||||
is VaultItemAction.Common.AttachmentFileLocationReceive -> {
|
||||
handleAttachmentFileLocationReceive(action)
|
||||
}
|
||||
|
||||
is VaultItemAction.Common.NoAttachmentFileLocationReceive -> {
|
||||
handleNoAttachmentFileLocationReceive()
|
||||
}
|
||||
|
||||
is VaultItemAction.Common.AttachmentsClick -> handleAttachmentsClick()
|
||||
is VaultItemAction.Common.CloneClick -> handleCloneClick()
|
||||
is VaultItemAction.Common.MoveToOrganizationClick -> handleMoveToOrganizationClick()
|
||||
|
@ -230,6 +256,83 @@ class VaultItemViewModel @Inject constructor(
|
|||
}
|
||||
}
|
||||
|
||||
private fun handleAttachmentDownloadClick(
|
||||
action: VaultItemAction.Common.AttachmentDownloadClick,
|
||||
) {
|
||||
onContent { content ->
|
||||
if (content.common.requiresReprompt) {
|
||||
mutableStateFlow.update {
|
||||
it.copy(
|
||||
dialog = VaultItemState.DialogState.MasterPasswordDialog(
|
||||
action = PasswordRepromptAction.AttachmentDownloadClick(
|
||||
attachment = action.attachment,
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
return@onContent
|
||||
}
|
||||
|
||||
mutableStateFlow.update {
|
||||
it.copy(
|
||||
dialog = VaultItemState.DialogState.Loading(R.string.downloading.asText()),
|
||||
)
|
||||
}
|
||||
|
||||
viewModelScope.launch {
|
||||
val result = vaultRepository
|
||||
.downloadAttachment(
|
||||
cipherView = requireNotNull(content.common.currentCipher),
|
||||
attachmentId = action.attachment.id,
|
||||
)
|
||||
|
||||
trySendAction(
|
||||
VaultItemAction.Internal.AttachmentDecryptReceive(
|
||||
result = result,
|
||||
fileName = action.attachment.title,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleAttachmentFileLocationReceive(
|
||||
action: VaultItemAction.Common.AttachmentFileLocationReceive,
|
||||
) {
|
||||
mutableStateFlow.update {
|
||||
it.copy(dialog = null)
|
||||
}
|
||||
|
||||
val file = temporaryAttachmentData ?: return
|
||||
viewModelScope.launch {
|
||||
val result = fileManager
|
||||
.fileToUri(
|
||||
fileUri = action.fileUri,
|
||||
file = file,
|
||||
)
|
||||
sendAction(
|
||||
VaultItemAction.Internal.AttachmentFinishedSavingToDisk(
|
||||
isSaved = result,
|
||||
file = file,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleNoAttachmentFileLocationReceive() {
|
||||
viewModelScope.launch {
|
||||
temporaryAttachmentData?.let { fileManager.deleteFile(it) }
|
||||
}
|
||||
|
||||
mutableStateFlow.update {
|
||||
it.copy(
|
||||
dialog = VaultItemState.DialogState.Generic(
|
||||
R.string.unable_to_save_attachment.asText(),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleAttachmentsClick() {
|
||||
onContent { content ->
|
||||
if (content.common.requiresReprompt) {
|
||||
|
@ -553,6 +656,14 @@ class VaultItemViewModel @Inject constructor(
|
|||
|
||||
is VaultItemAction.Internal.DeleteCipherReceive -> handleDeleteCipherReceive(action)
|
||||
is VaultItemAction.Internal.RestoreCipherReceive -> handleRestoreCipherReceive(action)
|
||||
|
||||
is VaultItemAction.Internal.AttachmentDecryptReceive -> {
|
||||
handleAttachmentDecryptReceive(action)
|
||||
}
|
||||
|
||||
is VaultItemAction.Internal.AttachmentFinishedSavingToDisk -> {
|
||||
handleAttachmentFinishedSavingToDisk(action)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -737,6 +848,51 @@ class VaultItemViewModel @Inject constructor(
|
|||
}
|
||||
}
|
||||
|
||||
private fun handleAttachmentDecryptReceive(
|
||||
action: VaultItemAction.Internal.AttachmentDecryptReceive,
|
||||
) {
|
||||
when (val result = action.result) {
|
||||
DownloadAttachmentResult.Failure -> {
|
||||
mutableStateFlow.update {
|
||||
it.copy(
|
||||
dialog = VaultItemState.DialogState.Generic(
|
||||
message = R.string.unable_to_download_file.asText(),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
is DownloadAttachmentResult.Success -> {
|
||||
temporaryAttachmentData = result.file
|
||||
sendEvent(
|
||||
VaultItemEvent.NavigateToSelectAttachmentSaveLocation(
|
||||
fileName = action.fileName,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleAttachmentFinishedSavingToDisk(
|
||||
action: VaultItemAction.Internal.AttachmentFinishedSavingToDisk,
|
||||
) {
|
||||
viewModelScope.launch {
|
||||
fileManager.deleteFile(action.file)
|
||||
}
|
||||
|
||||
if (action.isSaved) {
|
||||
sendEvent(VaultItemEvent.ShowToast(R.string.save_attachment_success.asText()))
|
||||
} else {
|
||||
mutableStateFlow.update {
|
||||
it.copy(
|
||||
dialog = VaultItemState.DialogState.Generic(
|
||||
R.string.unable_to_save_attachment.asText(),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
//endregion Internal Type Handlers
|
||||
|
||||
private inline fun onContent(
|
||||
|
@ -871,8 +1027,22 @@ data class VaultItemState(
|
|||
val requiresReprompt: Boolean,
|
||||
@IgnoredOnParcel
|
||||
val currentCipher: CipherView? = null,
|
||||
val attachments: List<AttachmentItem>?,
|
||||
) : Parcelable {
|
||||
|
||||
/**
|
||||
* Represents an attachment.
|
||||
*/
|
||||
@Parcelize
|
||||
data class AttachmentItem(
|
||||
val id: String,
|
||||
val title: String,
|
||||
val displaySize: String,
|
||||
val url: String,
|
||||
val isLargeFile: Boolean,
|
||||
val isDownloadAllowed: Boolean,
|
||||
) : Parcelable
|
||||
|
||||
/**
|
||||
* Represents a custom field, TextField, HiddenField, BooleanField, or LinkedField.
|
||||
*/
|
||||
|
@ -1109,6 +1279,13 @@ sealed class VaultItemEvent {
|
|||
val itemId: String,
|
||||
) : VaultItemEvent()
|
||||
|
||||
/**
|
||||
* Navigates to select a location where to save an attachment with the name [fileName].
|
||||
*/
|
||||
data class NavigateToSelectAttachmentSaveLocation(
|
||||
val fileName: String,
|
||||
) : VaultItemEvent()
|
||||
|
||||
/**
|
||||
* Places the given [message] in your clipboard.
|
||||
*/
|
||||
|
@ -1207,6 +1384,25 @@ sealed class VaultItemAction {
|
|||
* The user has clicked the collections button.
|
||||
*/
|
||||
data object CollectionsClick : Common()
|
||||
|
||||
/**
|
||||
* The user has clicked the download button.
|
||||
*/
|
||||
data class AttachmentDownloadClick(
|
||||
val attachment: VaultItemState.ViewState.Content.Common.AttachmentItem,
|
||||
) : Common()
|
||||
|
||||
/**
|
||||
* The user has selected a location to save the file.
|
||||
*/
|
||||
data class AttachmentFileLocationReceive(
|
||||
val fileUri: Uri,
|
||||
) : Common()
|
||||
|
||||
/**
|
||||
* The user skipped selecting a location for the attachment file.
|
||||
*/
|
||||
data object NoAttachmentFileLocationReceive : Common()
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -1329,6 +1525,23 @@ sealed class VaultItemAction {
|
|||
data class RestoreCipherReceive(
|
||||
val result: RestoreCipherResult,
|
||||
) : Internal()
|
||||
|
||||
/**
|
||||
* Indicates the attachment download and decryption is complete.
|
||||
*/
|
||||
data class AttachmentDecryptReceive(
|
||||
val result: DownloadAttachmentResult,
|
||||
val fileName: String,
|
||||
) : Internal()
|
||||
|
||||
/**
|
||||
* The attempt to save the temporary [file] attachment to disk has finished. [isSaved]
|
||||
* indicates if it was successful.
|
||||
*/
|
||||
data class AttachmentFinishedSavingToDisk(
|
||||
val isSaved: Boolean,
|
||||
val file: File,
|
||||
) : Internal()
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -1371,6 +1584,17 @@ sealed class PasswordRepromptAction : Parcelable {
|
|||
get() = VaultItemAction.Common.AttachmentsClick
|
||||
}
|
||||
|
||||
/**
|
||||
* Indicates an attachment download was clicked.
|
||||
*/
|
||||
@Parcelize
|
||||
data class AttachmentDownloadClick(
|
||||
val attachment: VaultItemState.ViewState.Content.Common.AttachmentItem,
|
||||
) : PasswordRepromptAction() {
|
||||
override val vaultItemAction: VaultItemAction
|
||||
get() = VaultItemAction.Common.AttachmentDownloadClick(attachment)
|
||||
}
|
||||
|
||||
/**
|
||||
* Indicates that we should launch the [VaultItemAction.Common.CloneClick] upon password
|
||||
* validation.
|
||||
|
|
|
@ -20,6 +20,7 @@ data class VaultCommonItemTypeHandlers(
|
|||
VaultItemState.ViewState.Content.Common.Custom.HiddenField,
|
||||
Boolean,
|
||||
) -> Unit,
|
||||
val onAttachmentDownloadClick: (VaultItemState.ViewState.Content.Common.AttachmentItem) -> Unit,
|
||||
) {
|
||||
companion object {
|
||||
/**
|
||||
|
@ -47,6 +48,9 @@ data class VaultCommonItemTypeHandlers(
|
|||
),
|
||||
)
|
||||
},
|
||||
onAttachmentDownloadClick = {
|
||||
viewModel.trySendAction(VaultItemAction.Common.AttachmentDownloadClick(it))
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -19,6 +19,7 @@ import com.x8bit.bitwarden.ui.vault.feature.vault.VaultState
|
|||
import com.x8bit.bitwarden.ui.vault.model.VaultCardBrand
|
||||
import com.x8bit.bitwarden.ui.vault.model.VaultLinkedFieldType
|
||||
import com.x8bit.bitwarden.ui.vault.model.findVaultCardBrandWithNameOrNull
|
||||
import java.lang.NumberFormatException
|
||||
import java.time.format.DateTimeFormatter
|
||||
import java.util.TimeZone
|
||||
|
||||
|
@ -43,6 +44,32 @@ fun CipherView.toViewState(
|
|||
customFields = fields.orEmpty().map { it.toCustomField() },
|
||||
lastUpdated = dateTimeFormatter.format(revisionDate),
|
||||
notes = notes,
|
||||
attachments = attachments
|
||||
?.mapNotNull {
|
||||
@Suppress("ComplexCondition")
|
||||
if (it.id == null ||
|
||||
it.fileName == null ||
|
||||
it.size == null ||
|
||||
it.sizeName == null ||
|
||||
it.url == null
|
||||
) {
|
||||
null
|
||||
} else {
|
||||
VaultItemState.ViewState.Content.Common.AttachmentItem(
|
||||
id = requireNotNull(it.id),
|
||||
title = requireNotNull(it.fileName),
|
||||
displaySize = requireNotNull(it.sizeName),
|
||||
url = requireNotNull(it.url),
|
||||
isLargeFile = try {
|
||||
requireNotNull(it.size).toLong() >= 10485760
|
||||
} catch (exception: NumberFormatException) {
|
||||
false
|
||||
},
|
||||
isDownloadAllowed = isPremiumUser || this.organizationId != null,
|
||||
)
|
||||
}
|
||||
}
|
||||
.orEmpty(),
|
||||
),
|
||||
type = when (type) {
|
||||
CipherType.LOGIN -> {
|
||||
|
|
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.UpdateCipherCollectionsJsonRequest
|
||||
import com.x8bit.bitwarden.data.vault.datasource.network.model.UpdateCipherResponseJson
|
||||
import com.x8bit.bitwarden.data.vault.datasource.network.model.createMockAttachment
|
||||
import com.x8bit.bitwarden.data.vault.datasource.network.model.createMockAttachmentJsonRequest
|
||||
import com.x8bit.bitwarden.data.vault.datasource.network.model.createMockAttachmentJsonResponse
|
||||
import com.x8bit.bitwarden.data.vault.datasource.network.model.createMockCipher
|
||||
|
@ -245,6 +246,19 @@ class CiphersServiceTest : BaseServiceTest() {
|
|||
result.getOrThrow(),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `getCipherAttachment should return the correct response`() = runTest {
|
||||
server.enqueue(MockResponse().setBody(GET_CIPHER_ATTACHMENT_SUCCESS_JSON))
|
||||
val result = ciphersService.getCipherAttachment(
|
||||
cipherId = "mockId-1",
|
||||
attachmentId = "mockId-1",
|
||||
)
|
||||
assertEquals(
|
||||
createMockAttachment(number = 1),
|
||||
result.getOrThrow(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun setupMockUri(
|
||||
|
@ -455,3 +469,14 @@ private const val UPDATE_CIPHER_INVALID_JSON = """
|
|||
"validationErrors": null
|
||||
}
|
||||
"""
|
||||
|
||||
private const val GET_CIPHER_ATTACHMENT_SUCCESS_JSON = """
|
||||
{
|
||||
"fileName": "mockFileName-1",
|
||||
"size": 1,
|
||||
"sizeName": "mockSizeName-1",
|
||||
"id": "mockId-1",
|
||||
"url": "mockUrl-1",
|
||||
"key": "mockKey-1"
|
||||
}
|
||||
"""
|
||||
|
|
|
@ -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
|
||||
|
||||
import com.bitwarden.core.Attachment
|
||||
import com.bitwarden.core.AttachmentEncryptResult
|
||||
import com.bitwarden.core.AttachmentView
|
||||
import com.bitwarden.core.Cipher
|
||||
|
@ -661,6 +662,42 @@ class VaultSdkSourceTest {
|
|||
verify { sdkClientManager.getOrCreateClient(userId = userId) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `File decrypt should call SDK and return a Result with correct data`() = runBlocking {
|
||||
val userId = "userId"
|
||||
val mockCipher = mockk<Cipher>()
|
||||
val mockAttachment = mockk<Attachment>()
|
||||
val expectedResult = Unit
|
||||
coEvery {
|
||||
clientVault.attachments().decryptFile(
|
||||
cipher = mockCipher,
|
||||
attachment = mockAttachment,
|
||||
encryptedFilePath = "encrypted_path",
|
||||
decryptedFilePath = "decrypted_path",
|
||||
)
|
||||
} just runs
|
||||
val result = vaultSdkSource.decryptFile(
|
||||
userId = userId,
|
||||
cipher = mockCipher,
|
||||
attachment = mockAttachment,
|
||||
encryptedFilePath = "encrypted_path",
|
||||
decryptedFilePath = "decrypted_path",
|
||||
)
|
||||
assertEquals(
|
||||
expectedResult.asSuccess(),
|
||||
result,
|
||||
)
|
||||
coVerify {
|
||||
clientVault.attachments().decryptFile(
|
||||
cipher = mockCipher,
|
||||
attachment = mockAttachment,
|
||||
encryptedFilePath = "encrypted_path",
|
||||
decryptedFilePath = "decrypted_path",
|
||||
)
|
||||
}
|
||||
verify { sdkClientManager.getOrCreateClient(userId = userId) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `encryptPasswordHistory should call SDK and return a Result with correct data`() =
|
||||
runBlocking {
|
||||
|
|
|
@ -3,6 +3,7 @@ package com.x8bit.bitwarden.data.vault.repository
|
|||
import android.net.Uri
|
||||
import app.cash.turbine.test
|
||||
import app.cash.turbine.turbineScope
|
||||
import com.bitwarden.core.Attachment
|
||||
import com.bitwarden.core.Cipher
|
||||
import com.bitwarden.core.CipherView
|
||||
import com.bitwarden.core.CollectionView
|
||||
|
@ -79,6 +80,7 @@ import com.x8bit.bitwarden.data.vault.datasource.sdk.model.createMockSendView
|
|||
import com.x8bit.bitwarden.data.vault.manager.FileManager
|
||||
import com.x8bit.bitwarden.data.vault.manager.TotpCodeManager
|
||||
import com.x8bit.bitwarden.data.vault.manager.VaultLockManager
|
||||
import com.x8bit.bitwarden.data.vault.manager.model.DownloadResult
|
||||
import com.x8bit.bitwarden.data.vault.manager.model.VerificationCodeItem
|
||||
import com.x8bit.bitwarden.data.vault.repository.model.CreateAttachmentResult
|
||||
import com.x8bit.bitwarden.data.vault.repository.model.CreateCipherResult
|
||||
|
@ -89,6 +91,7 @@ import com.x8bit.bitwarden.data.vault.repository.model.DeleteCipherResult
|
|||
import com.x8bit.bitwarden.data.vault.repository.model.DeleteFolderResult
|
||||
import com.x8bit.bitwarden.data.vault.repository.model.DeleteSendResult
|
||||
import com.x8bit.bitwarden.data.vault.repository.model.DomainsData
|
||||
import com.x8bit.bitwarden.data.vault.repository.model.DownloadAttachmentResult
|
||||
import com.x8bit.bitwarden.data.vault.repository.model.GenerateTotpResult
|
||||
import com.x8bit.bitwarden.data.vault.repository.model.RemovePasswordSendResult
|
||||
import com.x8bit.bitwarden.data.vault.repository.model.RestoreCipherResult
|
||||
|
@ -130,6 +133,7 @@ import org.junit.jupiter.api.Assertions.assertTrue
|
|||
import org.junit.jupiter.api.BeforeEach
|
||||
import org.junit.jupiter.api.Test
|
||||
import retrofit2.HttpException
|
||||
import java.io.File
|
||||
import java.net.UnknownHostException
|
||||
import java.time.Clock
|
||||
import java.time.Instant
|
||||
|
@ -3326,6 +3330,312 @@ class VaultRepositoryTest {
|
|||
assertEquals(CreateAttachmentResult.Success(mockCipherView), result)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `downloadAttachment with missing attachment should return Failure`() = runTest {
|
||||
fakeAuthDiskSource.userState = MOCK_USER_STATE
|
||||
|
||||
val attachmentId = "mockId-1"
|
||||
val attachment = mockk<Attachment> {
|
||||
every { id } returns attachmentId
|
||||
}
|
||||
val cipher = mockk<Cipher> {
|
||||
every { attachments } returns emptyList()
|
||||
every { id } returns "mockId-1"
|
||||
}
|
||||
val cipherView = createMockCipherView(number = 1)
|
||||
coEvery {
|
||||
vaultSdkSource.encryptCipher(MOCK_USER_STATE.activeUserId, cipherView)
|
||||
} returns cipher.asSuccess()
|
||||
|
||||
assertEquals(
|
||||
DownloadAttachmentResult.Failure,
|
||||
vaultRepository.downloadAttachment(
|
||||
cipherView = cipherView,
|
||||
attachmentId = attachmentId,
|
||||
),
|
||||
)
|
||||
|
||||
coVerify(exactly = 1) {
|
||||
vaultSdkSource.encryptCipher(MOCK_USER_STATE.activeUserId, cipherView)
|
||||
}
|
||||
coVerify(exactly = 0) {
|
||||
ciphersService.getCipherAttachment(any(), any())
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `downloadAttachment with failed attachment details request should return Failure`() =
|
||||
runTest {
|
||||
fakeAuthDiskSource.userState = MOCK_USER_STATE
|
||||
|
||||
val attachmentId = "mockId-1"
|
||||
val attachment = mockk<Attachment> {
|
||||
every { id } returns attachmentId
|
||||
}
|
||||
val cipher = mockk<Cipher> {
|
||||
every { attachments } returns listOf(attachment)
|
||||
every { id } returns "mockId-1"
|
||||
}
|
||||
val cipherView = createMockCipherView(number = 1)
|
||||
coEvery {
|
||||
vaultSdkSource.encryptCipher(MOCK_USER_STATE.activeUserId, cipherView)
|
||||
} returns cipher.asSuccess()
|
||||
|
||||
coEvery {
|
||||
ciphersService.getCipherAttachment(any(), any())
|
||||
} returns Throwable().asFailure()
|
||||
|
||||
assertEquals(
|
||||
DownloadAttachmentResult.Failure,
|
||||
vaultRepository.downloadAttachment(
|
||||
cipherView = cipherView,
|
||||
attachmentId = attachmentId,
|
||||
),
|
||||
)
|
||||
|
||||
coVerify(exactly = 1) {
|
||||
vaultSdkSource.encryptCipher(MOCK_USER_STATE.activeUserId, cipherView)
|
||||
ciphersService.getCipherAttachment(
|
||||
cipherId = requireNotNull(cipherView.id),
|
||||
attachmentId = attachmentId,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `downloadAttachment with attachment details missing url should return Failure`() = runTest {
|
||||
fakeAuthDiskSource.userState = MOCK_USER_STATE
|
||||
|
||||
val attachmentId = "mockId-1"
|
||||
val attachment = mockk<Attachment> {
|
||||
every { id } returns attachmentId
|
||||
}
|
||||
val cipher = mockk<Cipher> {
|
||||
every { attachments } returns listOf(attachment)
|
||||
every { id } returns "mockId-1"
|
||||
}
|
||||
val cipherView = createMockCipherView(number = 1)
|
||||
coEvery {
|
||||
vaultSdkSource.encryptCipher(MOCK_USER_STATE.activeUserId, cipherView)
|
||||
} returns cipher.asSuccess()
|
||||
|
||||
val response = mockk<SyncResponseJson.Cipher.Attachment> {
|
||||
every { url } returns null
|
||||
}
|
||||
coEvery {
|
||||
ciphersService.getCipherAttachment(any(), any())
|
||||
} returns response.asSuccess()
|
||||
|
||||
assertEquals(
|
||||
DownloadAttachmentResult.Failure,
|
||||
vaultRepository.downloadAttachment(
|
||||
cipherView = cipherView,
|
||||
attachmentId = attachmentId,
|
||||
),
|
||||
)
|
||||
|
||||
coVerify(exactly = 1) {
|
||||
vaultSdkSource.encryptCipher(MOCK_USER_STATE.activeUserId, cipherView)
|
||||
ciphersService.getCipherAttachment(
|
||||
cipherId = requireNotNull(cipherView.id),
|
||||
attachmentId = attachmentId,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `downloadAttachment with failed download should return Failure`() = runTest {
|
||||
fakeAuthDiskSource.userState = MOCK_USER_STATE
|
||||
|
||||
val attachmentId = "mockId-1"
|
||||
val attachment = mockk<Attachment> {
|
||||
every { id } returns attachmentId
|
||||
}
|
||||
val cipher = mockk<Cipher> {
|
||||
every { attachments } returns listOf(attachment)
|
||||
every { id } returns "mockId-1"
|
||||
}
|
||||
|
||||
val cipherView = createMockCipherView(number = 1)
|
||||
coEvery {
|
||||
vaultSdkSource.encryptCipher(MOCK_USER_STATE.activeUserId, cipherView)
|
||||
} returns cipher.asSuccess()
|
||||
|
||||
val response = mockk<SyncResponseJson.Cipher.Attachment> {
|
||||
every { url } returns "https://bitwarden.com"
|
||||
}
|
||||
coEvery {
|
||||
ciphersService.getCipherAttachment(any(), any())
|
||||
} returns response.asSuccess()
|
||||
|
||||
coEvery {
|
||||
fileManager.downloadFileToCache(any())
|
||||
} returns DownloadResult.Failure
|
||||
|
||||
assertEquals(
|
||||
DownloadAttachmentResult.Failure,
|
||||
vaultRepository.downloadAttachment(
|
||||
cipherView = cipherView,
|
||||
attachmentId = attachmentId,
|
||||
),
|
||||
)
|
||||
|
||||
coVerify(exactly = 1) {
|
||||
vaultSdkSource.encryptCipher(MOCK_USER_STATE.activeUserId, cipherView)
|
||||
ciphersService.getCipherAttachment(
|
||||
cipherId = requireNotNull(cipherView.id),
|
||||
attachmentId = attachmentId,
|
||||
)
|
||||
fileManager.downloadFileToCache("https://bitwarden.com")
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("MaxLineLength")
|
||||
@Test
|
||||
fun `downloadAttachment with failed decryption should delete file and return Failure`() =
|
||||
runTest {
|
||||
fakeAuthDiskSource.userState = MOCK_USER_STATE
|
||||
|
||||
val attachmentId = "mockId-1"
|
||||
val attachment = mockk<Attachment> {
|
||||
every { id } returns attachmentId
|
||||
}
|
||||
val cipher = mockk<Cipher> {
|
||||
every { attachments } returns listOf(attachment)
|
||||
every { id } returns "mockId-1"
|
||||
}
|
||||
val cipherView = createMockCipherView(number = 1)
|
||||
coEvery {
|
||||
vaultSdkSource.encryptCipher(MOCK_USER_STATE.activeUserId, cipherView)
|
||||
} returns cipher.asSuccess()
|
||||
|
||||
val response = mockk<SyncResponseJson.Cipher.Attachment> {
|
||||
every { url } returns "https://bitwarden.com"
|
||||
}
|
||||
coEvery {
|
||||
ciphersService.getCipherAttachment(any(), any())
|
||||
} returns response.asSuccess()
|
||||
|
||||
val file = mockk<File> {
|
||||
every { path } returns "path/to/encrypted/file"
|
||||
every { delete() } returns true
|
||||
}
|
||||
coEvery {
|
||||
fileManager.downloadFileToCache(any())
|
||||
} returns DownloadResult.Success(file)
|
||||
|
||||
coEvery {
|
||||
vaultSdkSource.decryptFile(
|
||||
userId = MOCK_USER_STATE.activeUserId,
|
||||
cipher = cipher,
|
||||
attachment = attachment,
|
||||
encryptedFilePath = "path/to/encrypted/file",
|
||||
decryptedFilePath = "path/to/encrypted/file_decrypted",
|
||||
)
|
||||
} returns Throwable().asFailure()
|
||||
|
||||
assertEquals(
|
||||
DownloadAttachmentResult.Failure,
|
||||
vaultRepository.downloadAttachment(
|
||||
cipherView = cipherView,
|
||||
attachmentId = attachmentId,
|
||||
),
|
||||
)
|
||||
|
||||
coVerify(exactly = 1) {
|
||||
vaultSdkSource.encryptCipher(MOCK_USER_STATE.activeUserId, cipherView)
|
||||
ciphersService.getCipherAttachment(
|
||||
cipherId = requireNotNull(cipherView.id),
|
||||
attachmentId = attachmentId,
|
||||
)
|
||||
fileManager.downloadFileToCache("https://bitwarden.com")
|
||||
vaultSdkSource.decryptFile(
|
||||
userId = MOCK_USER_STATE.activeUserId,
|
||||
cipher = cipher,
|
||||
attachment = attachment,
|
||||
encryptedFilePath = "path/to/encrypted/file",
|
||||
decryptedFilePath = "path/to/encrypted/file_decrypted",
|
||||
)
|
||||
}
|
||||
verify(exactly = 1) {
|
||||
file.delete()
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("MaxLineLength")
|
||||
@Test
|
||||
fun `downloadAttachment with successful decryption should delete file and return Success`() =
|
||||
runTest {
|
||||
fakeAuthDiskSource.userState = MOCK_USER_STATE
|
||||
|
||||
val attachmentId = "mockId-1"
|
||||
val attachment = mockk<Attachment> {
|
||||
every { id } returns attachmentId
|
||||
}
|
||||
val cipher = mockk<Cipher> {
|
||||
every { attachments } returns listOf(attachment)
|
||||
every { id } returns "mockId-1"
|
||||
}
|
||||
val cipherView = createMockCipherView(number = 1)
|
||||
coEvery {
|
||||
vaultSdkSource.encryptCipher(MOCK_USER_STATE.activeUserId, cipherView)
|
||||
} returns cipher.asSuccess()
|
||||
|
||||
val response = mockk<SyncResponseJson.Cipher.Attachment> {
|
||||
every { url } returns "https://bitwarden.com"
|
||||
}
|
||||
coEvery {
|
||||
ciphersService.getCipherAttachment(any(), any())
|
||||
} returns response.asSuccess()
|
||||
|
||||
val file = mockk<File> {
|
||||
every { path } returns "path/to/encrypted/file"
|
||||
every { delete() } returns true
|
||||
}
|
||||
coEvery {
|
||||
fileManager.downloadFileToCache(any())
|
||||
} returns DownloadResult.Success(file)
|
||||
|
||||
coEvery {
|
||||
vaultSdkSource.decryptFile(
|
||||
userId = MOCK_USER_STATE.activeUserId,
|
||||
cipher = cipher,
|
||||
attachment = attachment,
|
||||
encryptedFilePath = "path/to/encrypted/file",
|
||||
decryptedFilePath = "path/to/encrypted/file_decrypted",
|
||||
)
|
||||
} returns Unit.asSuccess()
|
||||
|
||||
assertEquals(
|
||||
DownloadAttachmentResult.Success(
|
||||
file = File("path/to/encrypted/file_decrypted"),
|
||||
),
|
||||
vaultRepository.downloadAttachment(
|
||||
cipherView = cipherView,
|
||||
attachmentId = attachmentId,
|
||||
),
|
||||
)
|
||||
|
||||
coVerify(exactly = 1) {
|
||||
vaultSdkSource.encryptCipher(MOCK_USER_STATE.activeUserId, cipherView)
|
||||
ciphersService.getCipherAttachment(
|
||||
cipherId = requireNotNull(cipherView.id),
|
||||
attachmentId = attachmentId,
|
||||
)
|
||||
fileManager.downloadFileToCache("https://bitwarden.com")
|
||||
vaultSdkSource.decryptFile(
|
||||
userId = MOCK_USER_STATE.activeUserId,
|
||||
cipher = cipher,
|
||||
attachment = attachment,
|
||||
encryptedFilePath = "path/to/encrypted/file",
|
||||
decryptedFilePath = "path/to/encrypted/file_decrypted",
|
||||
)
|
||||
}
|
||||
verify(exactly = 1) {
|
||||
file.delete()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `generateTotp with no active user should return GenerateTotpResult Error`() =
|
||||
runTest {
|
||||
|
@ -3901,180 +4211,185 @@ class VaultRepositoryTest {
|
|||
|
||||
@Suppress("MaxLineLength")
|
||||
@Test
|
||||
fun `syncCipherUpsertFlow create with local cipher with no common collections should do nothing`() = runTest {
|
||||
val number = 1
|
||||
val cipherId = "mockId-$number"
|
||||
fun `syncCipherUpsertFlow create with local cipher with no common collections should do nothing`() =
|
||||
runTest {
|
||||
val number = 1
|
||||
val cipherId = "mockId-$number"
|
||||
|
||||
fakeAuthDiskSource.userState = MOCK_USER_STATE
|
||||
val cipherView = createMockCipherView(number = number)
|
||||
coEvery {
|
||||
vaultSdkSource.decryptCipherList(
|
||||
userId = MOCK_USER_STATE.activeUserId,
|
||||
cipherList = listOf(createMockSdkCipher(number = number)),
|
||||
)
|
||||
} returns listOf(cipherView).asSuccess()
|
||||
fakeAuthDiskSource.userState = MOCK_USER_STATE
|
||||
val cipherView = createMockCipherView(number = number)
|
||||
coEvery {
|
||||
vaultSdkSource.decryptCipherList(
|
||||
userId = MOCK_USER_STATE.activeUserId,
|
||||
cipherList = listOf(createMockSdkCipher(number = number)),
|
||||
)
|
||||
} returns listOf(cipherView).asSuccess()
|
||||
|
||||
val ciphersFlow = bufferedMutableSharedFlow<List<SyncResponseJson.Cipher>>()
|
||||
setupVaultDiskSourceFlows(ciphersFlow = ciphersFlow)
|
||||
val ciphersFlow = bufferedMutableSharedFlow<List<SyncResponseJson.Cipher>>()
|
||||
setupVaultDiskSourceFlows(ciphersFlow = ciphersFlow)
|
||||
|
||||
vaultRepository.ciphersStateFlow.test {
|
||||
// Populate and consume items related to the ciphers flow
|
||||
awaitItem()
|
||||
ciphersFlow.tryEmit(listOf(createMockCipher(number = number)))
|
||||
awaitItem()
|
||||
vaultRepository.ciphersStateFlow.test {
|
||||
// Populate and consume items related to the ciphers flow
|
||||
awaitItem()
|
||||
ciphersFlow.tryEmit(listOf(createMockCipher(number = number)))
|
||||
awaitItem()
|
||||
|
||||
mutableSyncCipherUpsertFlow.tryEmit(
|
||||
SyncCipherUpsertData(
|
||||
cipherId = cipherId,
|
||||
revisionDate = ZonedDateTime.now(),
|
||||
isUpdate = false,
|
||||
collectionIds = null,
|
||||
organizationId = null,
|
||||
),
|
||||
)
|
||||
mutableSyncCipherUpsertFlow.tryEmit(
|
||||
SyncCipherUpsertData(
|
||||
cipherId = cipherId,
|
||||
revisionDate = ZonedDateTime.now(),
|
||||
isUpdate = false,
|
||||
collectionIds = null,
|
||||
organizationId = null,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
coVerify(exactly = 0) {
|
||||
ciphersService.getCipher(any())
|
||||
vaultDiskSource.saveCipher(any(), any())
|
||||
}
|
||||
}
|
||||
|
||||
coVerify(exactly = 0) {
|
||||
ciphersService.getCipher(any())
|
||||
vaultDiskSource.saveCipher(any(), any())
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("MaxLineLength")
|
||||
@Test
|
||||
fun `syncCipherUpsertFlow create with local cipher, and with common collections, should make a request and save cipher to disk`() = runTest {
|
||||
val number = 1
|
||||
val cipherId = "mockId-$number"
|
||||
fun `syncCipherUpsertFlow create with local cipher, and with common collections, should make a request and save cipher to disk`() =
|
||||
runTest {
|
||||
val number = 1
|
||||
val cipherId = "mockId-$number"
|
||||
|
||||
fakeAuthDiskSource.userState = MOCK_USER_STATE
|
||||
val cipherView = createMockCipherView(number = number)
|
||||
coEvery {
|
||||
vaultSdkSource.decryptCipherList(
|
||||
userId = MOCK_USER_STATE.activeUserId,
|
||||
cipherList = listOf(createMockSdkCipher(number = number)),
|
||||
fakeAuthDiskSource.userState = MOCK_USER_STATE
|
||||
val cipherView = createMockCipherView(number = number)
|
||||
coEvery {
|
||||
vaultSdkSource.decryptCipherList(
|
||||
userId = MOCK_USER_STATE.activeUserId,
|
||||
cipherList = listOf(createMockSdkCipher(number = number)),
|
||||
)
|
||||
} returns listOf(cipherView).asSuccess()
|
||||
val collectionView = createMockCollectionView(number = number)
|
||||
coEvery {
|
||||
vaultSdkSource.decryptCollectionList(
|
||||
userId = MOCK_USER_STATE.activeUserId,
|
||||
collectionList = listOf(createMockSdkCollection(number = number)),
|
||||
)
|
||||
} returns listOf(collectionView).asSuccess()
|
||||
|
||||
val cipher: SyncResponseJson.Cipher = mockk()
|
||||
coEvery {
|
||||
ciphersService.getCipher(cipherId)
|
||||
} returns cipher.asSuccess()
|
||||
coEvery {
|
||||
vaultDiskSource.saveCipher(userId = MOCK_USER_STATE.activeUserId, cipher = cipher)
|
||||
} just runs
|
||||
|
||||
val ciphersFlow = bufferedMutableSharedFlow<List<SyncResponseJson.Cipher>>()
|
||||
val collectionsFlow = bufferedMutableSharedFlow<List<SyncResponseJson.Collection>>()
|
||||
setupVaultDiskSourceFlows(
|
||||
ciphersFlow = ciphersFlow,
|
||||
collectionsFlow = collectionsFlow,
|
||||
)
|
||||
} returns listOf(cipherView).asSuccess()
|
||||
val collectionView = createMockCollectionView(number = number)
|
||||
coEvery {
|
||||
vaultSdkSource.decryptCollectionList(
|
||||
userId = MOCK_USER_STATE.activeUserId,
|
||||
collectionList = listOf(createMockSdkCollection(number = number)),
|
||||
)
|
||||
} returns listOf(collectionView).asSuccess()
|
||||
|
||||
val cipher: SyncResponseJson.Cipher = mockk()
|
||||
coEvery {
|
||||
ciphersService.getCipher(cipherId)
|
||||
} returns cipher.asSuccess()
|
||||
coEvery {
|
||||
vaultDiskSource.saveCipher(userId = MOCK_USER_STATE.activeUserId, cipher = cipher)
|
||||
} just runs
|
||||
turbineScope {
|
||||
val ciphersStateFlow = vaultRepository.ciphersStateFlow.testIn(backgroundScope)
|
||||
val collectionsStateFlow =
|
||||
vaultRepository.collectionsStateFlow.testIn(backgroundScope)
|
||||
|
||||
val ciphersFlow = bufferedMutableSharedFlow<List<SyncResponseJson.Cipher>>()
|
||||
val collectionsFlow = bufferedMutableSharedFlow<List<SyncResponseJson.Collection>>()
|
||||
setupVaultDiskSourceFlows(
|
||||
ciphersFlow = ciphersFlow,
|
||||
collectionsFlow = collectionsFlow,
|
||||
)
|
||||
// Populate and consume items related to the ciphers flow
|
||||
ciphersStateFlow.awaitItem()
|
||||
ciphersFlow.tryEmit(listOf(createMockCipher(number = number)))
|
||||
ciphersStateFlow.awaitItem()
|
||||
|
||||
turbineScope {
|
||||
val ciphersStateFlow = vaultRepository.ciphersStateFlow.testIn(backgroundScope)
|
||||
val collectionsStateFlow = vaultRepository.collectionsStateFlow.testIn(backgroundScope)
|
||||
// Populate and consume items related to the collections flow
|
||||
collectionsStateFlow.awaitItem()
|
||||
collectionsFlow.tryEmit(listOf(createMockCollection(number = number)))
|
||||
collectionsStateFlow.awaitItem()
|
||||
|
||||
// Populate and consume items related to the ciphers flow
|
||||
ciphersStateFlow.awaitItem()
|
||||
ciphersFlow.tryEmit(listOf(createMockCipher(number = number)))
|
||||
ciphersStateFlow.awaitItem()
|
||||
mutableSyncCipherUpsertFlow.tryEmit(
|
||||
SyncCipherUpsertData(
|
||||
cipherId = cipherId,
|
||||
revisionDate = ZonedDateTime.now(),
|
||||
isUpdate = false,
|
||||
collectionIds = listOf("mockId-1"),
|
||||
organizationId = "mock-id",
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
// Populate and consume items related to the collections flow
|
||||
collectionsStateFlow.awaitItem()
|
||||
collectionsFlow.tryEmit(listOf(createMockCollection(number = number)))
|
||||
collectionsStateFlow.awaitItem()
|
||||
|
||||
mutableSyncCipherUpsertFlow.tryEmit(
|
||||
SyncCipherUpsertData(
|
||||
cipherId = cipherId,
|
||||
revisionDate = ZonedDateTime.now(),
|
||||
isUpdate = false,
|
||||
collectionIds = listOf("mockId-1"),
|
||||
organizationId = "mock-id",
|
||||
),
|
||||
)
|
||||
coVerify(exactly = 1) {
|
||||
ciphersService.getCipher(any())
|
||||
vaultDiskSource.saveCipher(any(), any())
|
||||
}
|
||||
}
|
||||
|
||||
coVerify(exactly = 1) {
|
||||
ciphersService.getCipher(any())
|
||||
vaultDiskSource.saveCipher(any(), any())
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("MaxLineLength")
|
||||
@Test
|
||||
fun `syncCipherUpsertFlow update with no local cipher, but with common collections, should make a request save cipher to disk`() = runTest {
|
||||
val number = 1
|
||||
val cipherId = "mockId-$number"
|
||||
fun `syncCipherUpsertFlow update with no local cipher, but with common collections, should make a request save cipher to disk`() =
|
||||
runTest {
|
||||
val number = 1
|
||||
val cipherId = "mockId-$number"
|
||||
|
||||
fakeAuthDiskSource.userState = MOCK_USER_STATE
|
||||
coEvery {
|
||||
vaultSdkSource.decryptCipherList(
|
||||
userId = MOCK_USER_STATE.activeUserId,
|
||||
cipherList = listOf(),
|
||||
fakeAuthDiskSource.userState = MOCK_USER_STATE
|
||||
coEvery {
|
||||
vaultSdkSource.decryptCipherList(
|
||||
userId = MOCK_USER_STATE.activeUserId,
|
||||
cipherList = listOf(),
|
||||
)
|
||||
} returns listOf<CipherView>().asSuccess()
|
||||
val collectionView = createMockCollectionView(number = number)
|
||||
coEvery {
|
||||
vaultSdkSource.decryptCollectionList(
|
||||
userId = MOCK_USER_STATE.activeUserId,
|
||||
collectionList = listOf(createMockSdkCollection(number = number)),
|
||||
)
|
||||
} returns listOf(collectionView).asSuccess()
|
||||
|
||||
val cipher: SyncResponseJson.Cipher = mockk()
|
||||
coEvery {
|
||||
ciphersService.getCipher(cipherId)
|
||||
} returns cipher.asSuccess()
|
||||
coEvery {
|
||||
vaultDiskSource.saveCipher(userId = MOCK_USER_STATE.activeUserId, cipher = cipher)
|
||||
} just runs
|
||||
|
||||
val ciphersFlow = bufferedMutableSharedFlow<List<SyncResponseJson.Cipher>>()
|
||||
val collectionsFlow = bufferedMutableSharedFlow<List<SyncResponseJson.Collection>>()
|
||||
setupVaultDiskSourceFlows(
|
||||
ciphersFlow = ciphersFlow,
|
||||
collectionsFlow = collectionsFlow,
|
||||
)
|
||||
} returns listOf<CipherView>().asSuccess()
|
||||
val collectionView = createMockCollectionView(number = number)
|
||||
coEvery {
|
||||
vaultSdkSource.decryptCollectionList(
|
||||
userId = MOCK_USER_STATE.activeUserId,
|
||||
collectionList = listOf(createMockSdkCollection(number = number)),
|
||||
)
|
||||
} returns listOf(collectionView).asSuccess()
|
||||
|
||||
val cipher: SyncResponseJson.Cipher = mockk()
|
||||
coEvery {
|
||||
ciphersService.getCipher(cipherId)
|
||||
} returns cipher.asSuccess()
|
||||
coEvery {
|
||||
vaultDiskSource.saveCipher(userId = MOCK_USER_STATE.activeUserId, cipher = cipher)
|
||||
} just runs
|
||||
turbineScope {
|
||||
val ciphersStateFlow = vaultRepository.ciphersStateFlow.testIn(backgroundScope)
|
||||
val collectionsStateFlow =
|
||||
vaultRepository.collectionsStateFlow.testIn(backgroundScope)
|
||||
|
||||
val ciphersFlow = bufferedMutableSharedFlow<List<SyncResponseJson.Cipher>>()
|
||||
val collectionsFlow = bufferedMutableSharedFlow<List<SyncResponseJson.Collection>>()
|
||||
setupVaultDiskSourceFlows(
|
||||
ciphersFlow = ciphersFlow,
|
||||
collectionsFlow = collectionsFlow,
|
||||
)
|
||||
// Populate and consume items related to the ciphers flow
|
||||
ciphersStateFlow.awaitItem()
|
||||
ciphersFlow.tryEmit(listOf())
|
||||
ciphersStateFlow.awaitItem()
|
||||
|
||||
turbineScope {
|
||||
val ciphersStateFlow = vaultRepository.ciphersStateFlow.testIn(backgroundScope)
|
||||
val collectionsStateFlow = vaultRepository.collectionsStateFlow.testIn(backgroundScope)
|
||||
// Populate and consume items related to the collections flow
|
||||
collectionsStateFlow.awaitItem()
|
||||
collectionsFlow.tryEmit(listOf(createMockCollection(number = number)))
|
||||
collectionsStateFlow.awaitItem()
|
||||
|
||||
// Populate and consume items related to the ciphers flow
|
||||
ciphersStateFlow.awaitItem()
|
||||
ciphersFlow.tryEmit(listOf())
|
||||
ciphersStateFlow.awaitItem()
|
||||
mutableSyncCipherUpsertFlow.tryEmit(
|
||||
SyncCipherUpsertData(
|
||||
cipherId = cipherId,
|
||||
revisionDate = ZonedDateTime.now(),
|
||||
isUpdate = true,
|
||||
collectionIds = listOf("mockId-1"),
|
||||
organizationId = "mock-id",
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
// Populate and consume items related to the collections flow
|
||||
collectionsStateFlow.awaitItem()
|
||||
collectionsFlow.tryEmit(listOf(createMockCollection(number = number)))
|
||||
collectionsStateFlow.awaitItem()
|
||||
|
||||
mutableSyncCipherUpsertFlow.tryEmit(
|
||||
SyncCipherUpsertData(
|
||||
cipherId = cipherId,
|
||||
revisionDate = ZonedDateTime.now(),
|
||||
isUpdate = true,
|
||||
collectionIds = listOf("mockId-1"),
|
||||
organizationId = "mock-id",
|
||||
),
|
||||
)
|
||||
coVerify(exactly = 1) {
|
||||
ciphersService.getCipher(any())
|
||||
vaultDiskSource.saveCipher(any(), any())
|
||||
}
|
||||
}
|
||||
|
||||
coVerify(exactly = 1) {
|
||||
ciphersService.getCipher(any())
|
||||
vaultDiskSource.saveCipher(any(), any())
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `syncCipherUpsertFlow update with no local cipher should do nothing`() = runTest {
|
||||
val number = 1
|
||||
|
|
|
@ -60,7 +60,7 @@ class VaultItemScreenTest : BaseComposeTest() {
|
|||
private var onNavigateToMoveToOrganizationItemId: String? = null
|
||||
private var onNavigateToAttachmentsId: String? = null
|
||||
|
||||
private val intentManager = mockk<IntentManager>()
|
||||
private val intentManager = mockk<IntentManager>(relaxed = true)
|
||||
|
||||
private val mutableEventFlow = bufferedMutableSharedFlow<VaultItemEvent>()
|
||||
private val mutableStateFlow = MutableStateFlow(DEFAULT_STATE)
|
||||
|
@ -135,6 +135,15 @@ class VaultItemScreenTest : BaseComposeTest() {
|
|||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `NavigateToSelectAttachmentSaveLocation should invoke createAttachmentChooserIntent`() {
|
||||
mutableEventFlow.tryEmit(VaultItemEvent.NavigateToSelectAttachmentSaveLocation("test.mp4"))
|
||||
|
||||
verify(exactly = 1) {
|
||||
intentManager.createAttachmentChooserIntent("test.mp4")
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `basic dialog should be displayed according to state`() {
|
||||
val message = "message"
|
||||
|
@ -311,6 +320,179 @@ class VaultItemScreenTest : BaseComposeTest() {
|
|||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `attachments should be displayed according to state`() {
|
||||
DEFAULT_VIEW_STATES
|
||||
.forEach { typeState ->
|
||||
mutableStateFlow.update { it.copy(viewState = typeState) }
|
||||
composeTestRule.onNodeWithTextAfterScroll("Attachments").assertIsDisplayed()
|
||||
composeTestRule.onNodeWithTextAfterScroll("test.mp4").assertIsDisplayed()
|
||||
composeTestRule.onNodeWithTextAfterScroll("11 MB").assertIsDisplayed()
|
||||
|
||||
mutableStateFlow.update { currentState ->
|
||||
updateCommonContent(currentState) { copy(attachments = emptyList()) }
|
||||
}
|
||||
|
||||
composeTestRule.assertScrollableNodeDoesNotExist("Attachments")
|
||||
composeTestRule.assertScrollableNodeDoesNotExist("test.mp4")
|
||||
composeTestRule.assertScrollableNodeDoesNotExist("11 MB")
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `attachment download click for non-premium users should show an error dialog`() {
|
||||
mutableStateFlow.update { currentState ->
|
||||
currentState.copy(
|
||||
viewState = EMPTY_LOGIN_VIEW_STATE.copy(
|
||||
common = EMPTY_COMMON.copy(
|
||||
attachments = listOf(
|
||||
VaultItemState.ViewState.Content.Common.AttachmentItem(
|
||||
id = "attachment-id",
|
||||
displaySize = "11 MB",
|
||||
isLargeFile = true,
|
||||
isDownloadAllowed = false,
|
||||
url = "https://example.com",
|
||||
title = "test.mp4",
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
composeTestRule.assertNoDialogExists()
|
||||
composeTestRule.onNodeWithContentDescriptionAfterScroll("Download").performClick()
|
||||
|
||||
composeTestRule
|
||||
.onAllNodesWithText(
|
||||
"A premium membership is required to use this feature.",
|
||||
)
|
||||
.filterToOne(hasAnyAncestor(isDialog()))
|
||||
.assertIsDisplayed()
|
||||
|
||||
composeTestRule
|
||||
.onAllNodesWithText("Ok")
|
||||
.filterToOne(hasAnyAncestor(isDialog()))
|
||||
.performClick()
|
||||
|
||||
composeTestRule.assertNoDialogExists()
|
||||
}
|
||||
|
||||
@Suppress("MaxLineLength")
|
||||
@Test
|
||||
fun `attachment download click for large downloads should show a prompt and dismiss when clicking No`() {
|
||||
mutableStateFlow.update { currentState ->
|
||||
currentState.copy(
|
||||
viewState = EMPTY_LOGIN_VIEW_STATE.copy(
|
||||
common = EMPTY_COMMON.copy(
|
||||
attachments = listOf(
|
||||
VaultItemState.ViewState.Content.Common.AttachmentItem(
|
||||
id = "attachment-id",
|
||||
displaySize = "11 MB",
|
||||
isLargeFile = true,
|
||||
isDownloadAllowed = true,
|
||||
url = "https://example.com",
|
||||
title = "test.mp4",
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
composeTestRule.assertNoDialogExists()
|
||||
composeTestRule.onNodeWithContentDescriptionAfterScroll("Download").performClick()
|
||||
|
||||
composeTestRule
|
||||
.onAllNodesWithText(
|
||||
"This attachment is 11 MB in size. Are you sure you want to download it onto " +
|
||||
"your device?",
|
||||
)
|
||||
.filterToOne(hasAnyAncestor(isDialog()))
|
||||
.assertIsDisplayed()
|
||||
|
||||
composeTestRule
|
||||
.onAllNodesWithText("No")
|
||||
.filterToOne(hasAnyAncestor(isDialog()))
|
||||
.performClick()
|
||||
|
||||
composeTestRule.assertNoDialogExists()
|
||||
}
|
||||
|
||||
@Suppress("MaxLineLength")
|
||||
@Test
|
||||
fun `attachment download click for large downloads should show a prompt and emit AttachmentDownloadClick`() {
|
||||
val attachment = VaultItemState.ViewState.Content.Common.AttachmentItem(
|
||||
id = "attachment-id",
|
||||
displaySize = "11 MB",
|
||||
isLargeFile = true,
|
||||
isDownloadAllowed = true,
|
||||
url = "https://example.com",
|
||||
title = "test.mp4",
|
||||
)
|
||||
mutableStateFlow.update { currentState ->
|
||||
currentState.copy(
|
||||
viewState = EMPTY_LOGIN_VIEW_STATE.copy(
|
||||
common = EMPTY_COMMON.copy(
|
||||
attachments = listOf(attachment),
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
composeTestRule.assertNoDialogExists()
|
||||
composeTestRule.onNodeWithContentDescriptionAfterScroll("Download").performClick()
|
||||
|
||||
composeTestRule
|
||||
.onAllNodesWithText(
|
||||
"This attachment is 11 MB in size. Are you sure you want to download it onto " +
|
||||
"your device?",
|
||||
)
|
||||
.filterToOne(hasAnyAncestor(isDialog()))
|
||||
.assertIsDisplayed()
|
||||
|
||||
composeTestRule
|
||||
.onAllNodesWithText("Yes")
|
||||
.filterToOne(hasAnyAncestor(isDialog()))
|
||||
.performClick()
|
||||
|
||||
composeTestRule.assertNoDialogExists()
|
||||
|
||||
verify {
|
||||
viewModel.trySendAction(VaultItemAction.Common.AttachmentDownloadClick(attachment))
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `attachment download click for smaller downloads should emit AttachmentDownloadClick`() {
|
||||
val attachment = VaultItemState.ViewState.Content.Common.AttachmentItem(
|
||||
id = "attachment-id",
|
||||
displaySize = "9 MB",
|
||||
isLargeFile = false,
|
||||
isDownloadAllowed = true,
|
||||
url = "https://example.com",
|
||||
title = "test.mp4",
|
||||
)
|
||||
mutableStateFlow.update { currentState ->
|
||||
currentState.copy(
|
||||
viewState = EMPTY_LOGIN_VIEW_STATE.copy(
|
||||
common = EMPTY_COMMON.copy(
|
||||
attachments = listOf(attachment),
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
composeTestRule.assertNoDialogExists()
|
||||
composeTestRule.onNodeWithContentDescriptionAfterScroll("Download").performClick()
|
||||
|
||||
composeTestRule.assertNoDialogExists()
|
||||
|
||||
verify {
|
||||
viewModel.trySendAction(VaultItemAction.Common.AttachmentDownloadClick(attachment))
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `on show hidden field click should send HiddenFieldVisibilityClicked`() {
|
||||
val textField = VaultItemState.ViewState.Content.Common.Custom.HiddenField(
|
||||
|
@ -1913,6 +2095,16 @@ private val DEFAULT_COMMON: VaultItemState.ViewState.Content.Common =
|
|||
),
|
||||
),
|
||||
requiresReprompt = true,
|
||||
attachments = listOf(
|
||||
VaultItemState.ViewState.Content.Common.AttachmentItem(
|
||||
id = "attachment-id",
|
||||
displaySize = "11 MB",
|
||||
isLargeFile = true,
|
||||
isDownloadAllowed = true,
|
||||
url = "https://example.com",
|
||||
title = "test.mp4",
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
private val DEFAULT_LOGIN: VaultItemState.ViewState.Content.ItemType.Login =
|
||||
|
@ -1970,6 +2162,7 @@ private val EMPTY_COMMON: VaultItemState.ViewState.Content.Common =
|
|||
notes = null,
|
||||
customFields = emptyList(),
|
||||
requiresReprompt = true,
|
||||
attachments = emptyList(),
|
||||
)
|
||||
|
||||
private val EMPTY_LOGIN_TYPE: VaultItemState.ViewState.Content.ItemType.Login =
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
package com.x8bit.bitwarden.ui.vault.feature.item
|
||||
|
||||
import android.net.Uri
|
||||
import androidx.lifecycle.SavedStateHandle
|
||||
import app.cash.turbine.test
|
||||
import app.cash.turbine.turbineScope
|
||||
|
@ -13,9 +14,11 @@ import com.x8bit.bitwarden.data.platform.manager.clipboard.BitwardenClipboardMan
|
|||
import com.x8bit.bitwarden.data.platform.repository.model.DataState
|
||||
import com.x8bit.bitwarden.data.platform.repository.model.Environment
|
||||
import com.x8bit.bitwarden.data.vault.datasource.sdk.model.createMockCipherView
|
||||
import com.x8bit.bitwarden.data.vault.manager.FileManager
|
||||
import com.x8bit.bitwarden.data.vault.manager.model.VerificationCodeItem
|
||||
import com.x8bit.bitwarden.data.vault.repository.VaultRepository
|
||||
import com.x8bit.bitwarden.data.vault.repository.model.DeleteCipherResult
|
||||
import com.x8bit.bitwarden.data.vault.repository.model.DownloadAttachmentResult
|
||||
import com.x8bit.bitwarden.data.vault.repository.model.RestoreCipherResult
|
||||
import com.x8bit.bitwarden.ui.platform.base.BaseViewModelTest
|
||||
import com.x8bit.bitwarden.ui.platform.base.util.asText
|
||||
|
@ -42,6 +45,7 @@ import org.junit.jupiter.api.Assertions.assertEquals
|
|||
import org.junit.jupiter.api.BeforeEach
|
||||
import org.junit.jupiter.api.Nested
|
||||
import org.junit.jupiter.api.Test
|
||||
import java.io.File
|
||||
import java.time.Instant
|
||||
|
||||
@Suppress("LargeClass")
|
||||
|
@ -61,6 +65,8 @@ class VaultItemViewModelTest : BaseViewModelTest() {
|
|||
every { getVaultItemStateFlow(VAULT_ITEM_ID) } returns mutableVaultItemFlow
|
||||
}
|
||||
|
||||
private val mockFileManager: FileManager = mockk()
|
||||
|
||||
@BeforeEach
|
||||
fun setup() {
|
||||
mockkStatic(CipherView::toViewState)
|
||||
|
@ -744,6 +750,7 @@ class VaultItemViewModelTest : BaseViewModelTest() {
|
|||
val loginViewState = VaultItemState.ViewState.Content(
|
||||
common = createCommonContent(
|
||||
isEmpty = true,
|
||||
isPremiumUser = true,
|
||||
).copy(
|
||||
requiresReprompt = false,
|
||||
customFields = listOf(hiddenField),
|
||||
|
@ -772,7 +779,7 @@ class VaultItemViewModelTest : BaseViewModelTest() {
|
|||
assertEquals(
|
||||
loginState.copy(
|
||||
viewState = loginViewState.copy(
|
||||
common = createCommonContent(isEmpty = true).copy(
|
||||
common = createCommonContent(isEmpty = true, isPremiumUser = true).copy(
|
||||
requiresReprompt = false,
|
||||
customFields = listOf(hiddenField.copy(isVisible = true)),
|
||||
),
|
||||
|
@ -994,6 +1001,291 @@ class VaultItemViewModelTest : BaseViewModelTest() {
|
|||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `on AttachmentDownloadClick should prompt for password if required`() =
|
||||
runTest {
|
||||
val loginViewState = createViewState(
|
||||
common = DEFAULT_COMMON.copy(requiresReprompt = true),
|
||||
)
|
||||
val mockCipherView = mockk<CipherView> {
|
||||
every {
|
||||
toViewState(
|
||||
isPremiumUser = true,
|
||||
totpCodeItemData = null,
|
||||
)
|
||||
} returns loginViewState
|
||||
}
|
||||
mutableVaultItemFlow.value = DataState.Loaded(data = mockCipherView)
|
||||
mutableAuthCodeItemFlow.value = DataState.Loaded(data = null)
|
||||
val loginState = DEFAULT_STATE.copy(viewState = loginViewState)
|
||||
val viewModel = createViewModel(state = loginState)
|
||||
|
||||
val attachment = VaultItemState.ViewState.Content.Common.AttachmentItem(
|
||||
id = "attachment-id",
|
||||
displaySize = "11 MB",
|
||||
isLargeFile = true,
|
||||
isDownloadAllowed = false,
|
||||
url = "https://example.com",
|
||||
title = "test.mp4",
|
||||
)
|
||||
|
||||
viewModel.stateFlow.test {
|
||||
assertEquals(
|
||||
loginState,
|
||||
awaitItem(),
|
||||
)
|
||||
|
||||
viewModel.trySendAction(
|
||||
VaultItemAction.Common.AttachmentDownloadClick(attachment),
|
||||
)
|
||||
|
||||
assertEquals(
|
||||
loginState.copy(
|
||||
dialog = VaultItemState.DialogState.MasterPasswordDialog(
|
||||
action = PasswordRepromptAction.AttachmentDownloadClick(attachment),
|
||||
),
|
||||
),
|
||||
awaitItem(),
|
||||
)
|
||||
}
|
||||
|
||||
coVerify(exactly = 0) {
|
||||
vaultRepo.downloadAttachment(any(), any())
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("MaxLineLength")
|
||||
@Test
|
||||
fun `on AttachmentDownloadClick should show loading dialog, attempt to download an attachment, and display an error dialog on failure`() =
|
||||
runTest {
|
||||
val loginViewState = createViewState(
|
||||
common = DEFAULT_COMMON.copy(requiresReprompt = false),
|
||||
)
|
||||
val mockCipherView = mockk<CipherView> {
|
||||
every {
|
||||
toViewState(
|
||||
isPremiumUser = true,
|
||||
totpCodeItemData = null,
|
||||
)
|
||||
} returns loginViewState
|
||||
}
|
||||
mutableVaultItemFlow.value = DataState.Loaded(data = mockCipherView)
|
||||
mutableAuthCodeItemFlow.value = DataState.Loaded(data = null)
|
||||
val loginState = DEFAULT_STATE.copy(viewState = loginViewState)
|
||||
val viewModel = createViewModel(state = loginState)
|
||||
|
||||
val attachment = VaultItemState.ViewState.Content.Common.AttachmentItem(
|
||||
id = "attachment-id",
|
||||
displaySize = "11 MB",
|
||||
isLargeFile = true,
|
||||
isDownloadAllowed = false,
|
||||
url = "https://example.com",
|
||||
title = "test.mp4",
|
||||
)
|
||||
|
||||
coEvery {
|
||||
vaultRepo.downloadAttachment(any(), any())
|
||||
} returns DownloadAttachmentResult.Failure
|
||||
|
||||
viewModel.stateFlow.test {
|
||||
assertEquals(
|
||||
loginState,
|
||||
awaitItem(),
|
||||
)
|
||||
|
||||
viewModel.trySendAction(
|
||||
VaultItemAction.Common.AttachmentDownloadClick(attachment),
|
||||
)
|
||||
|
||||
assertEquals(
|
||||
loginState.copy(dialog = VaultItemState.DialogState.Loading(R.string.downloading.asText())),
|
||||
awaitItem(),
|
||||
)
|
||||
|
||||
assertEquals(
|
||||
loginState.copy(
|
||||
dialog = VaultItemState.DialogState.Generic(
|
||||
R.string.unable_to_download_file.asText(),
|
||||
),
|
||||
),
|
||||
awaitItem(),
|
||||
)
|
||||
}
|
||||
|
||||
coVerify(exactly = 1) {
|
||||
vaultRepo.downloadAttachment(any(), any())
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("MaxLineLength")
|
||||
@Test
|
||||
fun `on AttachmentDownloadClick should show loading dialog, attempt to download an attachment, and emit NavigateToSelectAttachmentSaveLocation on success`() =
|
||||
runTest {
|
||||
val loginViewState = createViewState(
|
||||
common = DEFAULT_COMMON.copy(requiresReprompt = false),
|
||||
)
|
||||
val mockCipherView = mockk<CipherView> {
|
||||
every {
|
||||
toViewState(
|
||||
isPremiumUser = true,
|
||||
totpCodeItemData = null,
|
||||
)
|
||||
} returns loginViewState
|
||||
}
|
||||
mutableVaultItemFlow.value = DataState.Loaded(data = mockCipherView)
|
||||
mutableAuthCodeItemFlow.value = DataState.Loaded(data = null)
|
||||
val loginState = DEFAULT_STATE.copy(viewState = loginViewState)
|
||||
val viewModel = createViewModel(state = loginState)
|
||||
|
||||
val attachment = VaultItemState.ViewState.Content.Common.AttachmentItem(
|
||||
id = "attachment-id",
|
||||
displaySize = "11 MB",
|
||||
isLargeFile = true,
|
||||
isDownloadAllowed = false,
|
||||
url = "https://example.com",
|
||||
title = "test.mp4",
|
||||
)
|
||||
|
||||
val file = mockk<File>()
|
||||
coEvery {
|
||||
vaultRepo.downloadAttachment(any(), any())
|
||||
} returns DownloadAttachmentResult.Success(file)
|
||||
|
||||
viewModel.stateFlow.test {
|
||||
assertEquals(
|
||||
loginState,
|
||||
awaitItem(),
|
||||
)
|
||||
|
||||
viewModel.trySendAction(
|
||||
VaultItemAction.Common.AttachmentDownloadClick(attachment),
|
||||
)
|
||||
|
||||
assertEquals(
|
||||
loginState.copy(
|
||||
dialog = VaultItemState.DialogState.Loading(
|
||||
R.string.downloading.asText(),
|
||||
),
|
||||
),
|
||||
awaitItem(),
|
||||
)
|
||||
}
|
||||
|
||||
viewModel.eventFlow.test {
|
||||
assertEquals(
|
||||
VaultItemEvent.NavigateToSelectAttachmentSaveLocation("test.mp4"),
|
||||
awaitItem(),
|
||||
)
|
||||
}
|
||||
|
||||
coVerify(exactly = 1) {
|
||||
vaultRepo.downloadAttachment(any(), any())
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("MaxLineLength")
|
||||
@Test
|
||||
fun `on AttachmentFileLocationReceive success should hide loading dialog, copy file, delete file, and show toast`() =
|
||||
runTest {
|
||||
val file = mockk<File>()
|
||||
val viewModel = createViewModel(state = DEFAULT_STATE, tempAttachmentFile = file)
|
||||
|
||||
coEvery {
|
||||
mockFileManager.deleteFile(any())
|
||||
} just runs
|
||||
|
||||
val uri = mockk<Uri>()
|
||||
coEvery {
|
||||
mockFileManager.fileToUri(uri, file)
|
||||
} returns true
|
||||
|
||||
viewModel.trySendAction(
|
||||
VaultItemAction.Common.AttachmentFileLocationReceive(uri),
|
||||
)
|
||||
|
||||
assertEquals(
|
||||
DEFAULT_STATE,
|
||||
viewModel.stateFlow.value,
|
||||
)
|
||||
|
||||
viewModel.eventFlow.test {
|
||||
assertEquals(
|
||||
VaultItemEvent.ShowToast(R.string.save_attachment_success.asText()),
|
||||
awaitItem(),
|
||||
)
|
||||
}
|
||||
|
||||
coVerify(exactly = 1) {
|
||||
mockFileManager.deleteFile(file)
|
||||
mockFileManager.fileToUri(uri, file)
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("MaxLineLength")
|
||||
@Test
|
||||
fun `on AttachmentFileLocationReceive failure should hide loading dialog, copy file, delete file, and show dialog`() =
|
||||
runTest {
|
||||
val file = mockk<File>()
|
||||
val viewModel = createViewModel(state = DEFAULT_STATE, tempAttachmentFile = file)
|
||||
|
||||
coEvery {
|
||||
mockFileManager.deleteFile(any())
|
||||
} just runs
|
||||
|
||||
val uri = mockk<Uri>()
|
||||
coEvery {
|
||||
mockFileManager.fileToUri(uri, file)
|
||||
} returns false
|
||||
|
||||
viewModel.stateFlow.test {
|
||||
viewModel.trySendAction(
|
||||
VaultItemAction.Common.AttachmentFileLocationReceive(uri),
|
||||
)
|
||||
|
||||
assertEquals(
|
||||
DEFAULT_STATE,
|
||||
awaitItem(),
|
||||
)
|
||||
|
||||
assertEquals(
|
||||
DEFAULT_STATE.copy(
|
||||
dialog = VaultItemState.DialogState.Generic(
|
||||
R.string.unable_to_save_attachment.asText(),
|
||||
),
|
||||
),
|
||||
awaitItem(),
|
||||
)
|
||||
}
|
||||
|
||||
coVerify(exactly = 1) {
|
||||
mockFileManager.deleteFile(file)
|
||||
mockFileManager.fileToUri(uri, file)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `on NoAttachmentFileLocationReceive failure should show dialog`() {
|
||||
val file = mockk<File>()
|
||||
val viewModel = createViewModel(state = DEFAULT_STATE, tempAttachmentFile = file)
|
||||
|
||||
coEvery {
|
||||
mockFileManager.deleteFile(any())
|
||||
} just runs
|
||||
|
||||
viewModel.trySendAction(VaultItemAction.Common.NoAttachmentFileLocationReceive)
|
||||
|
||||
assertEquals(
|
||||
DEFAULT_STATE.copy(
|
||||
dialog = VaultItemState.DialogState.Generic(
|
||||
R.string.unable_to_save_attachment.asText(),
|
||||
),
|
||||
),
|
||||
viewModel.stateFlow.value,
|
||||
)
|
||||
|
||||
coVerify { mockFileManager.deleteFile(file) }
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
|
@ -1488,20 +1780,25 @@ class VaultItemViewModelTest : BaseViewModelTest() {
|
|||
}
|
||||
}
|
||||
|
||||
@Suppress("LongParameterList")
|
||||
private fun createViewModel(
|
||||
state: VaultItemState?,
|
||||
vaultItemId: String = VAULT_ITEM_ID,
|
||||
bitwardenClipboardManager: BitwardenClipboardManager = clipboardManager,
|
||||
authRepository: AuthRepository = authRepo,
|
||||
vaultRepository: VaultRepository = vaultRepo,
|
||||
fileManager: FileManager = mockFileManager,
|
||||
tempAttachmentFile: File? = null,
|
||||
): VaultItemViewModel = VaultItemViewModel(
|
||||
savedStateHandle = SavedStateHandle().apply {
|
||||
set("state", state)
|
||||
set("vault_item_id", vaultItemId)
|
||||
set("tempAttachmentFile", tempAttachmentFile)
|
||||
},
|
||||
clipboardManager = bitwardenClipboardManager,
|
||||
authRepository = authRepository,
|
||||
vaultRepository = vaultRepository,
|
||||
fileManager = fileManager,
|
||||
)
|
||||
|
||||
private fun createViewState(
|
||||
|
@ -1619,6 +1916,16 @@ class VaultItemViewModelTest : BaseViewModelTest() {
|
|||
),
|
||||
requiresReprompt = true,
|
||||
currentCipher = createMockCipherView(number = 1),
|
||||
attachments = listOf(
|
||||
VaultItemState.ViewState.Content.Common.AttachmentItem(
|
||||
id = "attachment-id",
|
||||
displaySize = "11 MB",
|
||||
isLargeFile = true,
|
||||
isDownloadAllowed = true,
|
||||
url = "https://example.com",
|
||||
title = "test.mp4",
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
private val DEFAULT_VIEW_STATE: VaultItemState.ViewState.Content =
|
||||
|
|
|
@ -38,7 +38,8 @@ class CipherViewExtensionsTest {
|
|||
|
||||
assertEquals(
|
||||
VaultItemState.ViewState.Content(
|
||||
common = createCommonContent(isEmpty = false).copy(currentCipher = cipherView),
|
||||
common = createCommonContent(isEmpty = false, isPremiumUser = true)
|
||||
.copy(currentCipher = cipherView),
|
||||
type = createLoginContent(isEmpty = false),
|
||||
),
|
||||
viewState,
|
||||
|
@ -62,7 +63,8 @@ class CipherViewExtensionsTest {
|
|||
|
||||
assertEquals(
|
||||
VaultItemState.ViewState.Content(
|
||||
common = createCommonContent(isEmpty = false).copy(currentCipher = cipherView),
|
||||
common = createCommonContent(isEmpty = false, isPremiumUser = isPremiumUser)
|
||||
.copy(currentCipher = cipherView),
|
||||
type = createLoginContent(isEmpty = false).copy(isPremiumUser = isPremiumUser),
|
||||
),
|
||||
viewState,
|
||||
|
@ -79,7 +81,7 @@ class CipherViewExtensionsTest {
|
|||
|
||||
assertEquals(
|
||||
VaultItemState.ViewState.Content(
|
||||
common = createCommonContent(isEmpty = true).copy(
|
||||
common = createCommonContent(isEmpty = true, isPremiumUser = true).copy(
|
||||
currentCipher = cipherView,
|
||||
),
|
||||
type = createLoginContent(isEmpty = true),
|
||||
|
@ -98,7 +100,8 @@ class CipherViewExtensionsTest {
|
|||
|
||||
assertEquals(
|
||||
VaultItemState.ViewState.Content(
|
||||
common = createCommonContent(isEmpty = false).copy(currentCipher = cipherView),
|
||||
common = createCommonContent(isEmpty = false, isPremiumUser = true)
|
||||
.copy(currentCipher = cipherView),
|
||||
type = createIdentityContent(isEmpty = false),
|
||||
),
|
||||
viewState,
|
||||
|
@ -115,7 +118,8 @@ class CipherViewExtensionsTest {
|
|||
|
||||
assertEquals(
|
||||
VaultItemState.ViewState.Content(
|
||||
common = createCommonContent(isEmpty = true).copy(currentCipher = cipherView),
|
||||
common = createCommonContent(isEmpty = true, isPremiumUser = true)
|
||||
.copy(currentCipher = cipherView),
|
||||
type = createIdentityContent(isEmpty = true),
|
||||
),
|
||||
viewState,
|
||||
|
@ -142,7 +146,8 @@ class CipherViewExtensionsTest {
|
|||
|
||||
assertEquals(
|
||||
VaultItemState.ViewState.Content(
|
||||
common = createCommonContent(isEmpty = false).copy(currentCipher = cipherView),
|
||||
common = createCommonContent(isEmpty = false, isPremiumUser = true)
|
||||
.copy(currentCipher = cipherView),
|
||||
type = createIdentityContent(
|
||||
isEmpty = false,
|
||||
identityName = "Mx middleName",
|
||||
|
@ -174,7 +179,7 @@ class CipherViewExtensionsTest {
|
|||
|
||||
assertEquals(
|
||||
VaultItemState.ViewState.Content(
|
||||
common = createCommonContent(isEmpty = false).copy(
|
||||
common = createCommonContent(isEmpty = false, isPremiumUser = true).copy(
|
||||
currentCipher = cipherView.copy(
|
||||
identity = cipherView.identity?.copy(
|
||||
address1 = null,
|
||||
|
@ -209,7 +214,8 @@ class CipherViewExtensionsTest {
|
|||
|
||||
assertEquals(
|
||||
VaultItemState.ViewState.Content(
|
||||
common = createCommonContent(isEmpty = false).copy(currentCipher = cipherView),
|
||||
common = createCommonContent(isEmpty = false, isPremiumUser = true)
|
||||
.copy(currentCipher = cipherView),
|
||||
type = VaultItemState.ViewState.Content.ItemType.SecureNote,
|
||||
),
|
||||
viewState,
|
||||
|
@ -226,7 +232,8 @@ class CipherViewExtensionsTest {
|
|||
)
|
||||
|
||||
val expectedState = VaultItemState.ViewState.Content(
|
||||
common = createCommonContent(isEmpty = true).copy(currentCipher = cipherView),
|
||||
common = createCommonContent(isEmpty = true, isPremiumUser = true)
|
||||
.copy(currentCipher = cipherView),
|
||||
type = VaultItemState.ViewState.Content.ItemType.SecureNote,
|
||||
)
|
||||
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
package com.x8bit.bitwarden.ui.vault.feature.item.util
|
||||
|
||||
import com.bitwarden.core.AttachmentView
|
||||
import com.bitwarden.core.CipherRepromptType
|
||||
import com.bitwarden.core.CipherType
|
||||
import com.bitwarden.core.CipherView
|
||||
|
@ -86,7 +87,17 @@ fun createCipherView(type: CipherType, isEmpty: Boolean): CipherView =
|
|||
edit = false,
|
||||
viewPassword = false,
|
||||
localData = null,
|
||||
attachments = null,
|
||||
attachments = listOf(
|
||||
AttachmentView(
|
||||
id = "attachment-id",
|
||||
sizeName = "11 MB",
|
||||
size = "11000000",
|
||||
url = "https://example.com",
|
||||
fileName = "test.mp4",
|
||||
key = "key",
|
||||
),
|
||||
)
|
||||
.takeUnless { isEmpty },
|
||||
fields = listOf(
|
||||
FieldView(
|
||||
name = "text",
|
||||
|
@ -132,7 +143,10 @@ fun createCipherView(type: CipherType, isEmpty: Boolean): CipherView =
|
|||
revisionDate = Instant.ofEpochSecond(1_000L),
|
||||
)
|
||||
|
||||
fun createCommonContent(isEmpty: Boolean): VaultItemState.ViewState.Content.Common =
|
||||
fun createCommonContent(
|
||||
isEmpty: Boolean,
|
||||
isPremiumUser: Boolean,
|
||||
): VaultItemState.ViewState.Content.Common =
|
||||
if (isEmpty) {
|
||||
VaultItemState.ViewState.Content.Common(
|
||||
name = "mockName",
|
||||
|
@ -140,6 +154,7 @@ fun createCommonContent(isEmpty: Boolean): VaultItemState.ViewState.Content.Comm
|
|||
notes = null,
|
||||
customFields = emptyList(),
|
||||
requiresReprompt = true,
|
||||
attachments = emptyList(),
|
||||
)
|
||||
} else {
|
||||
VaultItemState.ViewState.Content.Common(
|
||||
|
@ -172,6 +187,16 @@ fun createCommonContent(isEmpty: Boolean): VaultItemState.ViewState.Content.Comm
|
|||
),
|
||||
),
|
||||
requiresReprompt = true,
|
||||
attachments = listOf(
|
||||
VaultItemState.ViewState.Content.Common.AttachmentItem(
|
||||
id = "attachment-id",
|
||||
displaySize = "11 MB",
|
||||
isLargeFile = true,
|
||||
isDownloadAllowed = isPremiumUser,
|
||||
url = "https://example.com",
|
||||
title = "test.mp4",
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
|
|
Loading…
Reference in a new issue