Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ import com.bitwarden.fido.Fido2CredentialAutofillView
import com.bitwarden.fido.PublicKeyCredentialAuthenticatorAssertionResponse
import com.bitwarden.fido.PublicKeyCredentialAuthenticatorAttestationResponse
import com.bitwarden.sdk.Fido2CredentialStore
import com.bitwarden.sdk.MakeSendFolderFileUniFfiEntry
import com.bitwarden.sdk.MakeSendFolderFileUniFfiResult
import com.bitwarden.send.Send
import com.bitwarden.send.SendView
import com.bitwarden.vault.Attachment
Expand Down Expand Up @@ -278,6 +280,21 @@ interface VaultSdkSource {
destinationFilePath: String,
): Result<File>

/**
* Creates a zip archive from the given folder [files] on disk for the user with the given
* [userId]. The SDK reads files directly from disk and writes the resulting zip to
* [outputZipPath], returning a [MakeSendFolderFileUniFfiResult] wrapped in a [Result].
*
* This should only be called after a successful call to [initializeCrypto] for the associated
* user.
*/
suspend fun makeSendFolderFile(
userId: String,
folderName: String,
files: List<MakeSendFolderFileUniFfiEntry>,
outputZipPath: String,
): Result<MakeSendFolderFileUniFfiResult>

/**
* Decrypts a [Send] for the user with the given [userId], returning a [SendView] wrapped in a
* [Result].
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@ import com.bitwarden.fido.PublicKeyCredentialAuthenticatorAttestationResponse
import com.bitwarden.sdk.BitwardenException
import com.bitwarden.sdk.Fido2CredentialStore
import com.bitwarden.sdk.VaultClient
import com.bitwarden.sdk.MakeSendFolderFileUniFfiEntry
import com.bitwarden.sdk.MakeSendFolderFileUniFfiResult
import com.bitwarden.send.Send
import com.bitwarden.send.SendView
import com.bitwarden.vault.Attachment
Expand Down Expand Up @@ -261,6 +263,22 @@ class VaultSdkSourceImpl(
File(destinationFilePath)
}

override suspend fun makeSendFolderFile(
userId: String,
folderName: String,
files: List<MakeSendFolderFileUniFfiEntry>,
outputZipPath: String,
): Result<MakeSendFolderFileUniFfiResult> =
runCatchingWithLogs {
getClient(userId = userId)
.sends()
.makeSendFolderFile(
folderName = folderName,
files = files,
destination = outputZipPath,
)
}

override suspend fun encryptAttachment(
userId: String,
cipher: Cipher,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,16 @@ interface SendManager {
*/
suspend fun createSend(sendView: SendView, fileUri: Uri?): CreateSendResult

/**
* Attempt to create a folder send. The folder at [folderUri] will be zipped via the SDK
* and uploaded as a file send. [folderName] is the display name of the selected folder.
*/
suspend fun createFolderSend(
sendView: SendView,
folderUri: Uri,
folderName: String,
): CreateSendResult

/**
* Attempt to delete a send.
*/
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import com.bitwarden.core.data.manager.dispatcher.DispatcherManager
import com.bitwarden.core.data.util.asFailure
import com.bitwarden.core.data.util.asSuccess
import com.bitwarden.core.data.util.flatMap
import com.bitwarden.sdk.MakeSendFolderFileUniFfiEntry
import com.bitwarden.data.manager.file.FileManager
import com.bitwarden.network.model.CreateFileSendResponse
import com.bitwarden.network.model.CreateSendJsonResponse
Expand Down Expand Up @@ -110,6 +111,125 @@ class SendManagerImpl(
)
}

@Suppress("LongMethod")
override suspend fun createFolderSend(
sendView: SendView,
folderUri: Uri,
folderName: String,
): CreateSendResult {
val userId = activeUserId
?: return CreateSendResult.Error(message = null, error = NoActiveUserException())

val zipFile = fileManager.createTempFileInCache(
prefix = "send_folder_",
suffix = ".zip",
)
return fileManager
.writeFolderFilesToCache(folderUri)
.flatMap { diskEntries ->
val sdkEntries = diskEntries.map { entry ->
MakeSendFolderFileUniFfiEntry(
path = entry.relativePath,
source = entry.diskPath,
)
}
vaultSdkSource
.makeSendFolderFile(
userId = userId,
folderName = folderName,
files = sdkEntries,
outputZipPath = zipFile.absolutePath,
)
.also {
// Clean up individual temp files now that the zip is written.
fileManager.delete(
*diskEntries
.map { java.io.File(it.diskPath) }
.toTypedArray(),
)
}
}
.flatMap { folderResult ->
val folderSendView = sendView.copy(
file = folderResult.file,
)

vaultSdkSource
.encryptSend(userId = userId, sendView = folderSendView)
.flatMap { send ->
vaultSdkSource
.encryptFile(
userId = userId,
send = send,
path = zipFile.absolutePath,
destinationFilePath = zipFile.absolutePath,
)
.flatMap { encryptedFile ->
sendsService
.createFileSend(
body = send.toEncryptedNetworkSend(
fileLength = encryptedFile.length(),
),
)
.flatMap { sendFileResponse ->
when (sendFileResponse) {
is CreateFileSendResponse.Invalid -> {
CreateSendJsonResponse
.Invalid(
message = sendFileResponse.message,
validationErrors = sendFileResponse
.validationErrors,
)
.asSuccess()
}

is CreateFileSendResponse.Success -> {
sendsService
.uploadFile(
sendFileResponse = sendFileResponse
.createFileJsonResponse,
encryptedFile = encryptedFile,
)
.map { CreateSendJsonResponse.Success(it) }
}
}
}
}
}
}
.also {
fileManager.delete(zipFile)
}
.map { createSendResponse ->
when (createSendResponse) {
is CreateSendJsonResponse.Invalid -> {
return CreateSendResult.Error(
message = createSendResponse.firstValidationErrorMessage,
error = null,
)
}

is CreateSendJsonResponse.Success -> {
vaultDiskSource.saveSend(userId = userId, send = createSendResponse.send)
createSendResponse
}
}
}
.flatMap { createSendSuccessResponse ->
vaultSdkSource.decryptSend(
userId = userId,
send = createSendSuccessResponse.send.toEncryptedSdkSend(),
)
}
.fold(
onFailure = { CreateSendResult.Error(message = null, error = it) },
onSuccess = {
reviewPromptManager.registerCreateSendAction()
CreateSendResult.Success(sendView = it)
},
)
}

override suspend fun deleteSend(sendId: String): DeleteSendResult {
val userId = activeUserId ?: return DeleteSendResult.Error(error = NoActiveUserException())
return sendsService
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,7 @@ fun SendScreen(

SendDialogs(
dialogState = state.dialogState,
isSendFolderEnabled = state.isSendFolderEnabled,
onAddSendSelected = { viewModel.trySendAction(SendAction.AddSendSelected(it)) },
onDismissRequest = { viewModel.trySendAction(SendAction.DismissDialog) },
)
Expand Down Expand Up @@ -211,6 +212,7 @@ fun SendScreen(
@Composable
private fun SendDialogs(
dialogState: SendState.DialogState?,
isSendFolderEnabled: Boolean,
onAddSendSelected: (SendItemType) -> Unit,
onDismissRequest: () -> Unit,
) {
Expand All @@ -230,12 +232,14 @@ private fun SendDialogs(
title = stringResource(id = BitwardenString.type),
onDismissRequest = onDismissRequest,
) {
SendItemType.entries.forEach {
BitwardenBasicDialogRow(
text = it.selectionText(),
onClick = { onAddSendSelected(it) },
)
}
SendItemType.entries
.filter { it != SendItemType.FOLDER || isSendFolderEnabled }
.forEach {
BitwardenBasicDialogRow(
text = it.selectionText(),
onClick = { onAddSendSelected(it) },
)
}
}

null -> Unit
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,10 @@ import com.bitwarden.ui.platform.resource.BitwardenDrawable
import com.bitwarden.ui.platform.resource.BitwardenString
import com.bitwarden.ui.util.Text
import com.bitwarden.ui.util.asText
import com.bitwarden.core.data.manager.model.FlagKey
import com.x8bit.bitwarden.data.auth.repository.AuthRepository
import com.x8bit.bitwarden.data.auth.repository.model.UserState
import com.x8bit.bitwarden.data.platform.manager.FeatureFlagManager
import com.x8bit.bitwarden.data.platform.manager.PolicyManager
import com.x8bit.bitwarden.data.platform.manager.clipboard.BitwardenClipboardManager
import com.x8bit.bitwarden.data.platform.manager.network.NetworkConnectionManager
Expand Down Expand Up @@ -59,6 +61,7 @@ class SendViewModel @Inject constructor(
private val environmentRepo: EnvironmentRepository,
private val vaultRepo: VaultRepository,
private val networkConnectionManager: NetworkConnectionManager,
featureFlagManager: FeatureFlagManager,
) : BaseViewModel<SendState, SendEvent, SendAction>(
// We load the state from the savedStateHandle for testing purposes.
initialState = savedStateHandle[KEY_STATE]
Expand All @@ -71,6 +74,8 @@ class SendViewModel @Inject constructor(
.any(),
isRefreshing = false,
isPremiumUser = authRepo.userStateFlow.value?.activeAccount?.isPremium == true,
isSendFolderEnabled = featureFlagManager
.getFeatureFlag(key = FlagKey.SendFolder),
),
) {

Expand Down Expand Up @@ -304,7 +309,7 @@ class SendViewModel @Inject constructor(
}

private fun handleAddSendSelected(action: SendAction.AddSendSelected) {
if (action.sendType == SendItemType.FILE) {
if (action.sendType == SendItemType.FILE || action.sendType == SendItemType.FOLDER) {
if (state.policyDisablesSend) {
mutableStateFlow.update {
it.copy(
Expand Down Expand Up @@ -475,6 +480,7 @@ data class SendState(
val policyDisablesSend: Boolean,
val isRefreshing: Boolean,
val isPremiumUser: Boolean,
val isSendFolderEnabled: Boolean,
) : Parcelable {

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,14 @@ fun AddEditSendContent(
)
}

is AddEditSendState.ViewState.Content.SendType.Folder -> {
FolderTypeContent(
folderType = type,
addSendHandlers = addSendHandlers,
isAddMode = isAddMode,
)
}

is AddEditSendState.ViewState.Content.SendType.Text -> {
TextTypeContent(
textType = type,
Expand Down Expand Up @@ -372,6 +380,86 @@ private fun ColumnScope.FileTypeContent(
}
}

@Composable
private fun ColumnScope.FolderTypeContent(
folderType: AddEditSendState.ViewState.Content.SendType.Folder,
addSendHandlers: AddEditSendHandlers,
isAddMode: Boolean,
) {
Spacer(modifier = Modifier.height(height = 8.dp))
if (isAddMode) {
folderType.name?.let { folderName ->
Box(
contentAlignment = Alignment.CenterStart,
modifier = Modifier
.fillMaxWidth()
.standardHorizontalMargin()
.defaultMinSize(minHeight = 60.dp)
.cardStyle(cardStyle = CardStyle.Full, paddingHorizontal = 16.dp),
) {
Column(modifier = Modifier.fillMaxWidth()) {
Text(
text = folderName,
color = BitwardenTheme.colorScheme.text.primary,
style = BitwardenTheme.typography.bodyLarge,
overflow = TextOverflow.Ellipsis,
maxLines = 1,
modifier = Modifier
.fillMaxWidth()
.testTag(tag = "SendCurrentFolderNameLabel"),
)
if (folderType.fileCount != null && folderType.totalSizeBytes != null) {
Spacer(modifier = Modifier.height(height = 4.dp))
Text(
text = stringResource(
id = BitwardenString.folder_info,
folderType.fileCount,
formatFileSize(folderType.totalSizeBytes),
),
color = BitwardenTheme.colorScheme.text.secondary,
style = BitwardenTheme.typography.bodySmall,
modifier = Modifier.fillMaxWidth(),
)
}
}
}
Spacer(modifier = Modifier.height(height = 8.dp))
}
BitwardenOutlinedButton(
label = stringResource(id = BitwardenString.choose_folder),
onClick = addSendHandlers.onChooseFolderClick,
isExternalLink = true,
modifier = Modifier
.testTag(tag = "SendChooseFolderButton")
.fillMaxWidth()
.standardHorizontalMargin(),
)
Spacer(modifier = Modifier.height(height = 8.dp))
Text(
text = stringResource(id = BitwardenString.required_max_folder_size),
color = BitwardenTheme.colorScheme.text.secondary,
style = BitwardenTheme.typography.bodySmall,
modifier = Modifier
.fillMaxWidth()
.standardHorizontalMargin()
.padding(horizontal = 16.dp),
)
}
}

/**
* Formats a file size in bytes to a human-readable string.
*/
private fun formatFileSize(bytes: Long): String {
val kb = 1024.0
val mb = kb * 1024
return when {
bytes >= mb -> "%.1f MB".format(bytes / mb)
bytes >= kb -> "%.1f KB".format(bytes / kb)
else -> "$bytes B"
}
}

/**
* Displays a collapsable set of new send options.
*
Expand Down
Loading
Loading