mirror of
https://github.com/bitwarden/android.git
synced 2026-05-10 16:45:43 -05:00
Compare commits
4 Commits
v2026.4.1-
...
innovation
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7e91e71d95 | ||
|
|
1b69c2a066 | ||
|
|
13b41d104a | ||
|
|
2946186b51 |
@@ -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
|
||||
@@ -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].
|
||||
|
||||
@@ -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
|
||||
@@ -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,
|
||||
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -122,6 +122,7 @@ fun SendScreen(
|
||||
|
||||
SendDialogs(
|
||||
dialogState = state.dialogState,
|
||||
isSendFolderEnabled = state.isSendFolderEnabled,
|
||||
onAddSendSelected = { viewModel.trySendAction(SendAction.AddSendSelected(it)) },
|
||||
onDismissRequest = { viewModel.trySendAction(SendAction.DismissDialog) },
|
||||
)
|
||||
@@ -211,6 +212,7 @@ fun SendScreen(
|
||||
@Composable
|
||||
private fun SendDialogs(
|
||||
dialogState: SendState.DialogState?,
|
||||
isSendFolderEnabled: Boolean,
|
||||
onAddSendSelected: (SendItemType) -> Unit,
|
||||
onDismissRequest: () -> Unit,
|
||||
) {
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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]
|
||||
@@ -71,6 +74,8 @@ class SendViewModel @Inject constructor(
|
||||
.any(),
|
||||
isRefreshing = false,
|
||||
isPremiumUser = authRepo.userStateFlow.value?.activeAccount?.isPremium == true,
|
||||
isSendFolderEnabled = featureFlagManager
|
||||
.getFeatureFlag(key = FlagKey.SendFolder),
|
||||
),
|
||||
) {
|
||||
|
||||
@@ -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(
|
||||
@@ -475,6 +480,7 @@ data class SendState(
|
||||
val policyDisablesSend: Boolean,
|
||||
val isRefreshing: Boolean,
|
||||
val isPremiumUser: Boolean,
|
||||
val isSendFolderEnabled: Boolean,
|
||||
) : Parcelable {
|
||||
|
||||
/**
|
||||
|
||||
@@ -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,
|
||||
@@ -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.
|
||||
*
|
||||
|
||||
@@ -66,6 +66,11 @@ fun AddEditSendScreen(
|
||||
addSendHandlers.onFileChoose(it)
|
||||
}
|
||||
}
|
||||
val folderChooserLauncher = intentManager.getActivityResultLauncher { activityResult ->
|
||||
intentManager.getFolderDataFromActivityResult(activityResult)?.let {
|
||||
addSendHandlers.onFolderChoose(it)
|
||||
}
|
||||
}
|
||||
|
||||
val snackbarHostState = rememberBitwardenSnackbarHostState()
|
||||
BackHandler(
|
||||
@@ -85,6 +90,12 @@ fun AddEditSendScreen(
|
||||
)
|
||||
}
|
||||
|
||||
is AddEditSendEvent.ShowFolderChooser -> {
|
||||
folderChooserLauncher.launch(
|
||||
intentManager.createFolderChooserIntent(),
|
||||
)
|
||||
}
|
||||
|
||||
is AddEditSendEvent.ShowShareSheet -> {
|
||||
intentManager.shareText(event.message)
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@ import com.bitwarden.ui.platform.base.util.isValidEmail
|
||||
import com.bitwarden.ui.platform.components.snackbar.model.BitwardenSnackbarData
|
||||
import com.bitwarden.ui.platform.manager.snackbar.SnackbarRelayManager
|
||||
import com.bitwarden.ui.platform.model.FileData
|
||||
import com.bitwarden.ui.platform.model.FolderData
|
||||
import com.bitwarden.ui.platform.resource.BitwardenString
|
||||
import com.bitwarden.ui.util.Text
|
||||
import com.bitwarden.ui.util.asText
|
||||
@@ -138,6 +139,15 @@ class AddEditSendViewModel @Inject constructor(
|
||||
)
|
||||
}
|
||||
|
||||
SendItemType.FOLDER -> {
|
||||
AddEditSendState.ViewState.Content.SendType.Folder(
|
||||
uri = null,
|
||||
name = null,
|
||||
fileCount = null,
|
||||
totalSizeBytes = null,
|
||||
)
|
||||
}
|
||||
|
||||
SendItemType.TEXT -> {
|
||||
AddEditSendState.ViewState.Content.SendType.Text(
|
||||
input = "",
|
||||
@@ -197,6 +207,8 @@ class AddEditSendViewModel @Inject constructor(
|
||||
AddEditSendAction.DismissDialogClick -> handleDismissDialogClick()
|
||||
is AddEditSendAction.SaveClick -> handleSaveClick()
|
||||
is AddEditSendAction.ChooseFileClick -> handleChooseFileClick(action)
|
||||
AddEditSendAction.ChooseFolderClick -> handleChooseFolderClick()
|
||||
is AddEditSendAction.FolderChoose -> handleFolderChoose(action)
|
||||
is AddEditSendAction.NameChange -> handleNameChange(action)
|
||||
is AddEditSendAction.MaxAccessCountChange -> handleMaxAccessCountChange(action)
|
||||
is AddEditSendAction.TextChange -> handleTextChange(action)
|
||||
@@ -750,6 +762,44 @@ class AddEditSendViewModel @Inject constructor(
|
||||
return@onContent
|
||||
}
|
||||
}
|
||||
|
||||
(content.selectedType as? AddEditSendState.ViewState.Content.SendType.Folder)
|
||||
?.let { folderType ->
|
||||
if (!state.isPremium) {
|
||||
mutableStateFlow.update {
|
||||
it.copy(
|
||||
dialogState = AddEditSendState.DialogState.Error(
|
||||
title = BitwardenString.send.asText(),
|
||||
message = BitwardenString.send_file_premium_required.asText(),
|
||||
),
|
||||
)
|
||||
}
|
||||
return@onContent
|
||||
}
|
||||
if (folderType.uri == null) {
|
||||
mutableStateFlow.update {
|
||||
it.copy(
|
||||
dialogState = AddEditSendState.DialogState.Error(
|
||||
title = BitwardenString.an_error_has_occurred.asText(),
|
||||
message = BitwardenString.choose_folder.asText(),
|
||||
),
|
||||
)
|
||||
}
|
||||
return@onContent
|
||||
}
|
||||
if ((folderType.totalSizeBytes ?: 0) > MAX_FILE_SIZE_BYTES) {
|
||||
mutableStateFlow.update {
|
||||
it.copy(
|
||||
dialogState = AddEditSendState.DialogState.Error(
|
||||
title = BitwardenString.an_error_has_occurred.asText(),
|
||||
message = BitwardenString.max_file_size.asText(),
|
||||
),
|
||||
)
|
||||
}
|
||||
return@onContent
|
||||
}
|
||||
}
|
||||
|
||||
if (!networkConnectionManager.isNetworkConnected) {
|
||||
mutableStateFlow.update {
|
||||
it.copy(
|
||||
@@ -771,12 +821,23 @@ class AddEditSendViewModel @Inject constructor(
|
||||
viewModelScope.launch {
|
||||
when (val addSendType = state.addEditSendType) {
|
||||
AddEditSendType.AddItem -> {
|
||||
val fileType = content
|
||||
.selectedType as? AddEditSendState.ViewState.Content.SendType.File
|
||||
val result = vaultRepo.createSend(
|
||||
sendView = content.toSendView(clock),
|
||||
fileUri = fileType?.uri,
|
||||
)
|
||||
val folderType = content
|
||||
.selectedType as? AddEditSendState.ViewState.Content.SendType.Folder
|
||||
val result = if (folderType != null) {
|
||||
vaultRepo.createFolderSend(
|
||||
sendView = content.toSendView(clock),
|
||||
folderUri = requireNotNull(folderType.uri),
|
||||
folderName = requireNotNull(folderType.name),
|
||||
)
|
||||
} else {
|
||||
val fileType = content
|
||||
.selectedType
|
||||
as? AddEditSendState.ViewState.Content.SendType.File
|
||||
vaultRepo.createSend(
|
||||
sendView = content.toSendView(clock),
|
||||
fileUri = fileType?.uri,
|
||||
)
|
||||
}
|
||||
sendAction(AddEditSendAction.Internal.CreateSendResultReceive(result))
|
||||
}
|
||||
|
||||
@@ -818,6 +879,34 @@ class AddEditSendViewModel @Inject constructor(
|
||||
sendEvent(AddEditSendEvent.ShowChooserSheet(action.isCameraPermissionGranted))
|
||||
}
|
||||
|
||||
private fun handleChooseFolderClick() {
|
||||
sendEvent(AddEditSendEvent.ShowFolderChooser)
|
||||
}
|
||||
|
||||
private fun handleFolderChoose(action: AddEditSendAction.FolderChoose) {
|
||||
if (action.folderData.fileCount <= 0) {
|
||||
mutableStateFlow.update {
|
||||
it.copy(
|
||||
dialogState = AddEditSendState.DialogState.Error(
|
||||
title = BitwardenString.an_error_has_occurred.asText(),
|
||||
message = BitwardenString
|
||||
.the_selected_folder_is_empty
|
||||
.asText(),
|
||||
),
|
||||
)
|
||||
}
|
||||
return
|
||||
}
|
||||
updateFolderContent {
|
||||
it.copy(
|
||||
uri = action.folderData.uri,
|
||||
name = action.folderData.folderName,
|
||||
fileCount = action.folderData.fileCount,
|
||||
totalSizeBytes = action.folderData.totalSizeBytes,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleMaxAccessCountChange(action: AddEditSendAction.MaxAccessCountChange) {
|
||||
updateCommonContent { common ->
|
||||
common.copy(maxAccessCount = action.value.takeUnless { it == 0 })
|
||||
@@ -905,6 +994,17 @@ class AddEditSendViewModel @Inject constructor(
|
||||
?.let { currentContent.copy(selectedType = block(it)) }
|
||||
}
|
||||
}
|
||||
|
||||
private inline fun updateFolderContent(
|
||||
crossinline block: (
|
||||
AddEditSendState.ViewState.Content.SendType.Folder,
|
||||
) -> AddEditSendState.ViewState.Content.SendType.Folder,
|
||||
) {
|
||||
updateContent { currentContent ->
|
||||
(currentContent.selectedType as? AddEditSendState.ViewState.Content.SendType.Folder)
|
||||
?.let { currentContent.copy(selectedType = block(it)) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -930,11 +1030,13 @@ data class AddEditSendState(
|
||||
get() = when (addEditSendType) {
|
||||
AddEditSendType.AddItem -> when (sendType) {
|
||||
SendItemType.FILE -> BitwardenString.add_file_send.asText()
|
||||
SendItemType.FOLDER -> BitwardenString.add_folder_send.asText()
|
||||
SendItemType.TEXT -> BitwardenString.add_text_send.asText()
|
||||
}
|
||||
|
||||
is AddEditSendType.EditItem -> when (sendType) {
|
||||
SendItemType.FILE -> BitwardenString.edit_file_send.asText()
|
||||
SendItemType.FOLDER -> BitwardenString.edit_folder_send.asText()
|
||||
SendItemType.TEXT -> BitwardenString.edit_text_send.asText()
|
||||
}
|
||||
}
|
||||
@@ -1020,6 +1122,17 @@ data class AddEditSendState(
|
||||
val sizeBytes: Long?,
|
||||
) : SendType()
|
||||
|
||||
/**
|
||||
* Sending a folder (zipped as a file).
|
||||
*/
|
||||
@Parcelize
|
||||
data class Folder(
|
||||
val uri: Uri?,
|
||||
val name: String?,
|
||||
val fileCount: Int?,
|
||||
val totalSizeBytes: Long?,
|
||||
) : SendType()
|
||||
|
||||
/**
|
||||
* Sending text.
|
||||
*/
|
||||
@@ -1095,6 +1208,11 @@ sealed class AddEditSendEvent {
|
||||
*/
|
||||
data class ShowChooserSheet(val withCameraOption: Boolean) : AddEditSendEvent()
|
||||
|
||||
/**
|
||||
* Show folder chooser.
|
||||
*/
|
||||
data object ShowFolderChooser : AddEditSendEvent()
|
||||
|
||||
/**
|
||||
* Show share sheet.
|
||||
*/
|
||||
@@ -1199,6 +1317,16 @@ sealed class AddEditSendAction {
|
||||
val isCameraPermissionGranted: Boolean,
|
||||
) : AddEditSendAction()
|
||||
|
||||
/**
|
||||
* User clicked the choose folder button.
|
||||
*/
|
||||
data object ChooseFolderClick : AddEditSendAction()
|
||||
|
||||
/**
|
||||
* User has chosen a folder.
|
||||
*/
|
||||
data class FolderChoose(val folderData: FolderData) : AddEditSendAction()
|
||||
|
||||
/**
|
||||
* User toggled the "hide text by default" toggle.
|
||||
*/
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package com.x8bit.bitwarden.ui.tools.feature.send.addedit.handlers
|
||||
|
||||
import com.bitwarden.ui.platform.model.FileData
|
||||
import com.bitwarden.ui.platform.model.FolderData
|
||||
import com.x8bit.bitwarden.ui.tools.feature.send.addedit.AddEditSendAction
|
||||
import com.x8bit.bitwarden.ui.tools.feature.send.addedit.AddEditSendViewModel
|
||||
import com.x8bit.bitwarden.ui.tools.feature.send.addedit.model.AuthEmail
|
||||
@@ -15,6 +16,8 @@ data class AddEditSendHandlers(
|
||||
val onNameChange: (String) -> Unit,
|
||||
val onChooseFileClick: (hasPermission: Boolean) -> Unit,
|
||||
val onFileChoose: (FileData) -> Unit,
|
||||
val onChooseFolderClick: () -> Unit,
|
||||
val onFolderChoose: (FolderData) -> Unit,
|
||||
val onTextChange: (String) -> Unit,
|
||||
val onIsHideByDefaultToggle: (Boolean) -> Unit,
|
||||
val onMaxAccessCountChange: (Int) -> Unit,
|
||||
@@ -47,6 +50,12 @@ data class AddEditSendHandlers(
|
||||
viewModel.trySendAction(AddEditSendAction.ChooseFileClick(it))
|
||||
},
|
||||
onFileChoose = { viewModel.trySendAction(AddEditSendAction.FileChoose(it)) },
|
||||
onChooseFolderClick = {
|
||||
viewModel.trySendAction(AddEditSendAction.ChooseFolderClick)
|
||||
},
|
||||
onFolderChoose = {
|
||||
viewModel.trySendAction(AddEditSendAction.FolderChoose(it))
|
||||
},
|
||||
onTextChange = { viewModel.trySendAction(AddEditSendAction.TextChange(it)) },
|
||||
onIsHideByDefaultToggle = {
|
||||
viewModel.trySendAction(AddEditSendAction.HideByDefaultToggle(it))
|
||||
|
||||
@@ -59,17 +59,31 @@ fun AddEditSendState.ViewState.Content.toSendView(
|
||||
private fun AddEditSendState.ViewState.Content.SendType.toSendType(): SendType =
|
||||
when (this) {
|
||||
is AddEditSendState.ViewState.Content.SendType.File -> SendType.FILE
|
||||
is AddEditSendState.ViewState.Content.SendType.Folder -> SendType.FILE
|
||||
is AddEditSendState.ViewState.Content.SendType.Text -> SendType.TEXT
|
||||
}
|
||||
|
||||
private fun AddEditSendState.ViewState.Content.toSendFileView(): SendFileView? =
|
||||
(this.selectedType as? AddEditSendState.ViewState.Content.SendType.File)?.let {
|
||||
SendFileView(
|
||||
id = null,
|
||||
fileName = it.name.orEmpty(),
|
||||
size = null,
|
||||
sizeName = null,
|
||||
)
|
||||
when (val type = this.selectedType) {
|
||||
is AddEditSendState.ViewState.Content.SendType.File -> {
|
||||
SendFileView(
|
||||
id = null,
|
||||
fileName = type.name.orEmpty(),
|
||||
size = null,
|
||||
sizeName = null,
|
||||
)
|
||||
}
|
||||
|
||||
is AddEditSendState.ViewState.Content.SendType.Folder -> {
|
||||
SendFileView(
|
||||
id = null,
|
||||
fileName = "${type.name.orEmpty()}.zip",
|
||||
size = null,
|
||||
sizeName = null,
|
||||
)
|
||||
}
|
||||
|
||||
else -> null
|
||||
}
|
||||
|
||||
private fun AddEditSendState.ViewState.Content.toSendTextView(): SendTextView? =
|
||||
|
||||
@@ -8,5 +8,6 @@ import kotlinx.serialization.Serializable
|
||||
@Serializable
|
||||
enum class SendItemType {
|
||||
FILE,
|
||||
FOLDER,
|
||||
TEXT,
|
||||
}
|
||||
|
||||
@@ -11,5 +11,6 @@ import com.x8bit.bitwarden.ui.tools.feature.send.model.SendItemType
|
||||
val SendItemType.selectionText: Text
|
||||
get() = when (this) {
|
||||
SendItemType.FILE -> BitwardenString.file.asText()
|
||||
SendItemType.FOLDER -> BitwardenString.folder.asText()
|
||||
SendItemType.TEXT -> BitwardenString.text.asText()
|
||||
}
|
||||
|
||||
@@ -253,7 +253,9 @@ data class ViewSendState(
|
||||
*/
|
||||
val screenDisplayName: Text
|
||||
get() = when (sendType) {
|
||||
SendItemType.FILE -> BitwardenString.view_file_send.asText()
|
||||
SendItemType.FILE,
|
||||
SendItemType.FOLDER,
|
||||
-> BitwardenString.view_file_send.asText()
|
||||
SendItemType.TEXT -> BitwardenString.view_text_send.asText()
|
||||
}
|
||||
|
||||
|
||||
@@ -889,7 +889,9 @@ class VaultItemListingViewModel @Inject constructor(
|
||||
|
||||
is VaultItemListingState.ItemListingType.Send -> {
|
||||
when (val sendType = itemListingType.toSendItemType()) {
|
||||
SendItemType.FILE -> {
|
||||
SendItemType.FILE,
|
||||
SendItemType.FOLDER,
|
||||
-> {
|
||||
if (state.isPremium) {
|
||||
sendEvent(VaultItemListingEvent.NavigateToAddSendItem(sendType))
|
||||
} else {
|
||||
|
||||
@@ -38,6 +38,7 @@ sealed class FlagKey<out T : Any> {
|
||||
ArchiveItems,
|
||||
SendEmailVerification,
|
||||
MobilePremiumUpgrade,
|
||||
SendFolder,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -116,6 +117,14 @@ sealed class FlagKey<out T : Any> {
|
||||
override val defaultValue: Boolean = false
|
||||
}
|
||||
|
||||
/**
|
||||
* Data object holding the feature flag key for the Send Folder feature.
|
||||
*/
|
||||
data object SendFolder : FlagKey<Boolean>() {
|
||||
override val keyName: String = "innovation-sprint-2026-send-folder"
|
||||
override val defaultValue: Boolean = false
|
||||
}
|
||||
|
||||
//region Dummy keys for testing
|
||||
/**
|
||||
* Data object holding the key for a [Boolean] flag to be used in tests.
|
||||
|
||||
@@ -53,6 +53,7 @@ dependencies {
|
||||
implementation(project(":network"))
|
||||
|
||||
implementation(libs.androidx.core.ktx)
|
||||
implementation(libs.androidx.documentfile)
|
||||
implementation(libs.androidx.security.crypto)
|
||||
implementation(libs.androidx.lifecycle.process)
|
||||
implementation(libs.google.hilt.android)
|
||||
|
||||
@@ -3,6 +3,7 @@ package com.bitwarden.data.manager.file
|
||||
import android.net.Uri
|
||||
import com.bitwarden.annotation.OmitFromCoverage
|
||||
import com.bitwarden.data.manager.model.DownloadResult
|
||||
import com.bitwarden.data.manager.model.FolderDiskEntry
|
||||
import com.bitwarden.data.manager.model.ZipFileResult
|
||||
import java.io.File
|
||||
|
||||
@@ -27,6 +28,12 @@ interface FileManager {
|
||||
*/
|
||||
suspend fun delete(vararg files: File)
|
||||
|
||||
/**
|
||||
* Creates a temporary file in the private cache directory with the given [prefix] and
|
||||
* [suffix]. Returns the created [File].
|
||||
*/
|
||||
suspend fun createTempFileInCache(prefix: String, suffix: String): File
|
||||
|
||||
/**
|
||||
* Downloads a file temporarily to cache from [url]. A successful [DownloadResult] will contain
|
||||
* the final file path.
|
||||
@@ -61,4 +68,11 @@ interface FileManager {
|
||||
* reference.
|
||||
*/
|
||||
suspend fun writeUriToCache(fileUri: Uri): Result<File>
|
||||
|
||||
/**
|
||||
* Streams all files from a folder tree identified by [folderUri] (a tree URI from
|
||||
* `ACTION_OPEN_DOCUMENT_TREE`) to temporary files in the cache directory. Returns a flat
|
||||
* list of [FolderDiskEntry] mapping relative paths to their on-disk locations.
|
||||
*/
|
||||
suspend fun writeFolderFilesToCache(folderUri: Uri): Result<List<FolderDiskEntry>>
|
||||
}
|
||||
|
||||
@@ -7,7 +7,9 @@ import android.net.Uri
|
||||
import com.bitwarden.annotation.OmitFromCoverage
|
||||
import com.bitwarden.core.data.manager.dispatcher.DispatcherManager
|
||||
import com.bitwarden.core.data.util.sdkAgnosticTransferTo
|
||||
import androidx.documentfile.provider.DocumentFile
|
||||
import com.bitwarden.data.manager.model.DownloadResult
|
||||
import com.bitwarden.data.manager.model.FolderDiskEntry
|
||||
import com.bitwarden.data.manager.model.ZipFileResult
|
||||
import com.bitwarden.network.service.DownloadService
|
||||
import kotlinx.coroutines.withContext
|
||||
@@ -48,6 +50,13 @@ internal class FileManagerImpl(
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun createTempFileInCache(
|
||||
prefix: String,
|
||||
suffix: String,
|
||||
): File = withContext(dispatcherManager.io) {
|
||||
File.createTempFile(prefix, suffix, context.cacheDir)
|
||||
}
|
||||
|
||||
@Suppress("NestedBlockDepth")
|
||||
override suspend fun downloadFileToCache(url: String): DownloadResult {
|
||||
val response = downloadService
|
||||
@@ -194,6 +203,71 @@ internal class FileManagerImpl(
|
||||
File(context.filesDir, tempFileName)
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun writeFolderFilesToCache(
|
||||
folderUri: Uri,
|
||||
): Result<List<FolderDiskEntry>> =
|
||||
runCatching {
|
||||
withContext(dispatcherManager.io) {
|
||||
val rootDocument = DocumentFile.fromTreeUri(context, folderUri)
|
||||
?: throw IllegalStateException("Cannot access folder")
|
||||
val entries = mutableListOf<FolderDiskEntry>()
|
||||
streamDocumentTreeToCache(
|
||||
document = rootDocument,
|
||||
parentPath = "",
|
||||
entries = entries,
|
||||
)
|
||||
entries
|
||||
}
|
||||
}
|
||||
|
||||
private fun streamDocumentTreeToCache(
|
||||
document: DocumentFile,
|
||||
parentPath: String,
|
||||
entries: MutableList<FolderDiskEntry>,
|
||||
) {
|
||||
document.listFiles().forEach { child ->
|
||||
val childPath = if (parentPath.isEmpty()) {
|
||||
child.name.orEmpty()
|
||||
} else {
|
||||
"$parentPath/${child.name.orEmpty()}"
|
||||
}
|
||||
if (child.isDirectory) {
|
||||
streamDocumentTreeToCache(
|
||||
document = child,
|
||||
parentPath = childPath,
|
||||
entries = entries,
|
||||
)
|
||||
} else {
|
||||
val tempFile = File.createTempFile(
|
||||
"send_folder_file_",
|
||||
null,
|
||||
context.cacheDir,
|
||||
)
|
||||
context
|
||||
.contentResolver
|
||||
.openInputStream(child.uri)
|
||||
?.use { inputStream ->
|
||||
FileOutputStream(tempFile).use { outputStream ->
|
||||
val buffer = ByteArray(BUFFER_SIZE)
|
||||
var length: Int
|
||||
while (
|
||||
inputStream.read(buffer).also { length = it } != -1
|
||||
) {
|
||||
outputStream.write(buffer, 0, length)
|
||||
}
|
||||
}
|
||||
}
|
||||
?: throw IllegalStateException("Cannot read file: $childPath")
|
||||
entries.add(
|
||||
FolderDiskEntry(
|
||||
relativePath = childPath,
|
||||
diskPath = tempFile.absolutePath,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
package com.bitwarden.data.manager.model
|
||||
|
||||
/**
|
||||
* Represents a single file entry within a folder, with its [relativePath] from the folder root
|
||||
* and its absolute [diskPath] on the local file system.
|
||||
*/
|
||||
data class FolderDiskEntry(
|
||||
val relativePath: String,
|
||||
val diskPath: String,
|
||||
)
|
||||
@@ -21,6 +21,7 @@ androidxBrowser = "1.9.0"
|
||||
androidxCamera = "1.5.3"
|
||||
androidxComposeBom = "2026.03.00"
|
||||
androidxCore = "1.18.0"
|
||||
androidxDocumentFile = "1.1.0"
|
||||
androidxCredentials = "1.6.0-rc02"
|
||||
androidxCredentialsProviderEvents = "1.0.0-alpha05"
|
||||
androidxHiltNavigationCompose = "1.3.0"
|
||||
@@ -84,6 +85,7 @@ androidx-compose-ui-test-manifest = { module = "androidx.compose.ui:ui-test-mani
|
||||
androidx-compose-ui-tooling = { module = "androidx.compose.ui:ui-tooling" }
|
||||
androidx-compose-ui-tooling-preview = { module = "androidx.compose.ui:ui-tooling-preview" }
|
||||
androidx-core-ktx = { module = "androidx.core:core-ktx", version.ref = "androidxCore" }
|
||||
androidx-documentfile = { module = "androidx.documentfile:documentfile", version.ref = "androidxDocumentFile" }
|
||||
#noinspection CredentialDependency - Used for Passkey support, which is not available below Android 14
|
||||
androidx-credentials = { module = "androidx.credentials:credentials", version.ref = "androidxCredentials" }
|
||||
androidx-credentials-providerevents = { module = "androidx.credentials.providerevents:providerevents", version.ref = "androidxCredentialsProviderEvents" }
|
||||
|
||||
@@ -74,6 +74,7 @@ dependencies {
|
||||
implementation(libs.androidx.compose.ui.tooling.preview)
|
||||
implementation(libs.androidx.core.ktx)
|
||||
implementation(libs.androidx.credentials)
|
||||
implementation(libs.androidx.documentfile)
|
||||
implementation(libs.androidx.navigation.compose)
|
||||
implementation(libs.bumptech.glide)
|
||||
implementation(libs.kotlinx.serialization)
|
||||
|
||||
@@ -32,6 +32,7 @@ fun <T : Any> FlagKey<T>.ListItemContent(
|
||||
FlagKey.ArchiveItems,
|
||||
FlagKey.SendEmailVerification,
|
||||
FlagKey.MobilePremiumUpgrade,
|
||||
FlagKey.SendFolder,
|
||||
-> {
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
BooleanFlagItem(
|
||||
@@ -85,4 +86,5 @@ private fun <T : Any> FlagKey<T>.getDisplayLabel(): String = when (this) {
|
||||
FlagKey.ArchiveItems -> stringResource(BitwardenString.archive_items)
|
||||
FlagKey.SendEmailVerification -> stringResource(BitwardenString.send_email_verification)
|
||||
FlagKey.MobilePremiumUpgrade -> stringResource(BitwardenString.mobile_premium_upgrade)
|
||||
FlagKey.SendFolder -> stringResource(BitwardenString.send_folder)
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ import com.bitwarden.annotation.OmitFromCoverage
|
||||
import com.bitwarden.core.data.manager.BuildInfoManager
|
||||
import com.bitwarden.ui.platform.manager.intent.model.AuthTabData
|
||||
import com.bitwarden.ui.platform.model.FileData
|
||||
import com.bitwarden.ui.platform.model.FolderData
|
||||
import java.time.Clock
|
||||
|
||||
/**
|
||||
@@ -99,6 +100,16 @@ interface IntentManager {
|
||||
*/
|
||||
fun createDocumentIntent(fileName: String): Intent
|
||||
|
||||
/**
|
||||
* Creates an intent for choosing a folder from the device.
|
||||
*/
|
||||
fun createFolderChooserIntent(): Intent
|
||||
|
||||
/**
|
||||
* Processes the [activityResult] and attempts to get the relevant folder data from it.
|
||||
*/
|
||||
fun getFolderDataFromActivityResult(activityResult: ActivityResult): FolderData?
|
||||
|
||||
@Suppress("UndocumentedPublicClass")
|
||||
@OmitFromCoverage
|
||||
companion object {
|
||||
|
||||
@@ -30,7 +30,9 @@ import com.bitwarden.core.data.manager.util.deviceData
|
||||
import com.bitwarden.core.data.manager.util.fileProviderAuthority
|
||||
import com.bitwarden.core.util.isBuildVersionAtLeast
|
||||
import com.bitwarden.ui.platform.manager.intent.model.AuthTabData
|
||||
import androidx.documentfile.provider.DocumentFile
|
||||
import com.bitwarden.ui.platform.model.FileData
|
||||
import com.bitwarden.ui.platform.model.FolderData
|
||||
import com.bitwarden.ui.platform.resource.BitwardenString
|
||||
import com.bitwarden.ui.platform.util.getLocalFileData
|
||||
import timber.log.Timber
|
||||
@@ -230,6 +232,49 @@ internal class IntentManagerImpl(
|
||||
putExtra(Intent.EXTRA_TITLE, fileName)
|
||||
}
|
||||
|
||||
override fun createFolderChooserIntent(): Intent =
|
||||
Intent(Intent.ACTION_OPEN_DOCUMENT_TREE)
|
||||
|
||||
override fun getFolderDataFromActivityResult(
|
||||
activityResult: ActivityResult,
|
||||
): FolderData? {
|
||||
if (activityResult.resultCode != Activity.RESULT_OK) return null
|
||||
val treeUri = activityResult.data?.data ?: return null
|
||||
val rootDocument = DocumentFile.fromTreeUri(activity, treeUri) ?: return null
|
||||
var fileCount = 0
|
||||
var totalSize = 0L
|
||||
countFilesRecursively(rootDocument) { count, size ->
|
||||
fileCount = count
|
||||
totalSize = size
|
||||
}
|
||||
return FolderData(
|
||||
folderName = rootDocument.name ?: "folder",
|
||||
uri = treeUri,
|
||||
fileCount = fileCount,
|
||||
totalSizeBytes = totalSize,
|
||||
)
|
||||
}
|
||||
|
||||
private fun countFilesRecursively(
|
||||
document: DocumentFile,
|
||||
onResult: (fileCount: Int, totalSize: Long) -> Unit,
|
||||
) {
|
||||
var count = 0
|
||||
var size = 0L
|
||||
fun traverse(doc: DocumentFile) {
|
||||
doc.listFiles().forEach { child ->
|
||||
if (child.isDirectory) {
|
||||
traverse(child)
|
||||
} else {
|
||||
count++
|
||||
size += child.length()
|
||||
}
|
||||
}
|
||||
}
|
||||
traverse(document)
|
||||
onResult(count, size)
|
||||
}
|
||||
|
||||
private fun createPlayStoreIntent(packageName: String): Intent {
|
||||
val playStoreUri = "https://play.google.com/store/apps/details"
|
||||
.toUri()
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
package com.bitwarden.ui.platform.model
|
||||
|
||||
import android.net.Uri
|
||||
import android.os.Parcelable
|
||||
import kotlinx.parcelize.Parcelize
|
||||
|
||||
/**
|
||||
* Represents metadata about a selected folder.
|
||||
*/
|
||||
@Parcelize
|
||||
data class FolderData(
|
||||
val folderName: String,
|
||||
val uri: Uri,
|
||||
val fileCount: Int,
|
||||
val totalSizeBytes: Long,
|
||||
) : Parcelable
|
||||
@@ -483,6 +483,12 @@ Scanning will happen automatically.</string>
|
||||
<string name="edit_file_send">Edit file Send</string>
|
||||
<string name="edit_text_send">Edit text Send</string>
|
||||
<string name="add_file_send">New file Send</string>
|
||||
<string name="add_folder_send">New folder Send</string>
|
||||
<string name="edit_folder_send">Edit folder Send</string>
|
||||
<string name="choose_folder">Choose folder</string>
|
||||
<string name="the_selected_folder_is_empty">The selected folder is empty. Please choose a folder that contains files.</string>
|
||||
<string name="folder_info">%1$d files, %2$s</string>
|
||||
<string name="required_max_folder_size">Maximum total size: 100 MB.</string>
|
||||
<string name="add_text_send">New text Send</string>
|
||||
<string name="are_you_sure_delete_send">Are you sure you want to delete this Send?</string>
|
||||
<string name="send_deleted">Send deleted</string>
|
||||
|
||||
@@ -46,6 +46,7 @@
|
||||
<string name="trigger_cookie_acquisition">Trigger cookie acquisition</string>
|
||||
<string name="clear_sso_cookies">Clear SSO cookies</string>
|
||||
<string name="mobile_premium_upgrade">Mobile Premium Upgrade</string>
|
||||
<string name="send_folder">Send Folder</string>
|
||||
|
||||
<!-- endregion Debug Menu -->
|
||||
</resources>
|
||||
|
||||
Reference in New Issue
Block a user