mobile: files UI (#597)

Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com>
This commit is contained in:
JRoberts
2022-05-06 21:10:32 +04:00
committed by GitHub
parent e575e97019
commit 884231369f
31 changed files with 920 additions and 268 deletions

View File

@@ -867,6 +867,8 @@ class CIFile(
) {
val stored: Boolean = when (fileStatus) {
CIFileStatus.SndStored -> true
CIFileStatus.SndTransfer -> true
CIFileStatus.SndComplete -> true
CIFileStatus.SndCancelled -> true
CIFileStatus.RcvComplete -> true
else -> false

View File

@@ -79,7 +79,11 @@ class NtfManager(val context: Context) {
private fun hideSecrets(cItem: ChatItem) : String {
val md = cItem.formattedText
return if (md == null) {
cItem.content.text
if (cItem.content.text != "") {
cItem.content.text
} else {
cItem.file?.fileName ?: ""
}
} else {
var res = ""
for (ft in md) {

View File

@@ -429,6 +429,21 @@ open class ChatController(private val ctrl: ChatCtrl, private val ntfManager: Nt
chatItemSimpleUpdate(r.chatItem)
is CR.RcvFileComplete ->
chatItemSimpleUpdate(r.chatItem)
is CR.SndFileStart ->
chatItemSimpleUpdate(r.chatItem)
is CR.SndFileComplete -> {
chatItemSimpleUpdate(r.chatItem)
val cItem = r.chatItem.chatItem
val mc = cItem.content.msgContent
val fileName = cItem.file?.fileName
if (
r.chatItem.chatInfo.chatType == ChatType.Direct
&& mc is MsgContent.MCFile
&& fileName != null
) {
removeFile(appContext, fileName)
}
}
else ->
Log.d(TAG , "unsupported event: ${r.responseType}")
}
@@ -560,15 +575,7 @@ sealed class CC {
is SetFilesFolder -> "/_files_folder $filesFolder"
is ApiGetChats -> "/_get chats pcc=on"
is ApiGetChat -> "/_get chat ${chatRef(type, id)} count=100"
is ApiSendMessage -> when {
file == null && quotedItemId == null -> "/_send ${chatRef(type, id)} ${mc.cmdString}"
file != null && quotedItemId == null -> "/_send ${chatRef(type, id)} file $file ${mc.cmdString}"
file == null && quotedItemId != null -> "/_send ${chatRef(type, id)} quoted $quotedItemId ${mc.cmdString}"
file != null && quotedItemId != null -> "/_send ${chatRef(type, id)} file $file quoted $quotedItemId ${mc.cmdString}"
else -> throw Exception()
}
// TODO use below
// is ApiSendMessage -> "/_send_v2 ${chatRef(type, id)} ${json.encodeToString(ComposedMessage(file, quotedItemId, mc))}"
is ApiSendMessage -> "/_send ${chatRef(type, id)} json ${json.encodeToString(ComposedMessage(file, quotedItemId, mc))}"
is ApiUpdateChatItem -> "/_update item ${chatRef(type, id)} $itemId ${mc.cmdString}"
is ApiDeleteChatItem -> "/_delete item ${chatRef(type, id)} $itemId ${mode.deleteMode}"
is GetUserSMPServers -> "/smp_servers"

View File

@@ -16,3 +16,5 @@ val HighOrLowlight = Color(134, 135, 139, 255)
val ToolbarLight = Color(220, 220, 220, 20)
val ToolbarDark = Color(80, 80, 80, 20)
val WarningOrange = Color(255, 149, 0, 255)
val FileLight = Color(183, 190, 199, 255)
val FileDark = Color(101, 101, 106, 255)

View File

@@ -2,6 +2,7 @@ package chat.simplex.app.views.chat
import android.content.res.Configuration
import android.graphics.Bitmap
import android.net.Uri
import android.util.Log
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.*
@@ -27,6 +28,7 @@ import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import chat.simplex.app.R
import chat.simplex.app.SimplexApp.Companion.context
import chat.simplex.app.TAG
import chat.simplex.app.model.*
import chat.simplex.app.ui.theme.*
@@ -46,6 +48,7 @@ fun ChatView(chatModel: ChatModel) {
val attachmentBottomSheetState = rememberModalBottomSheetState(initialValue = ModalBottomSheetValue.Hidden)
val scope = rememberCoroutineScope()
val chosenImage = remember { mutableStateOf<Bitmap?>(null) }
val chosenFile = remember { mutableStateOf<Uri?>(null) }
if (chat == null || user == null) {
chatModel.chatId.value = null
@@ -77,9 +80,11 @@ fun ChatView(chatModel: ChatModel) {
chat,
composeState,
chosenImage,
chosenFile,
showAttachmentBottomSheet = { scope.launch { attachmentBottomSheetState.show() } })
},
chosenImage,
chosenFile,
scope,
attachmentBottomSheetState,
chatModel.chatItems,
@@ -124,6 +129,7 @@ fun ChatLayout(
composeState: MutableState<ComposeState>,
composeView: (@Composable () -> Unit),
chosenImage: MutableState<Bitmap?>,
chosenFile: MutableState<Uri?>,
scope: CoroutineScope,
attachmentBottomSheetState: ModalBottomSheetState,
chatItems: List<ChatItem>,
@@ -137,6 +143,12 @@ fun ChatLayout(
val imagePreview = resizeImageToStrSize(bitmap, maxDataSize = 14000)
composeState.value = composeState.value.copy(preview = ComposePreview.ImagePreview(imagePreview))
}
fun onFileChange(uri: Uri) {
val fileName = getFileName(context, uri)
if (fileName != null) {
composeState.value = composeState.value.copy(preview = ComposePreview.FilePreview(fileName))
}
}
Surface(
Modifier
@@ -151,6 +163,8 @@ fun ChatLayout(
GetImageBottomSheet(
chosenImage,
::onImageChange,
chosenFile,
::onFileChange,
hideBottomSheet = {
scope.launch { attachmentBottomSheetState.hide() }
})
@@ -355,6 +369,7 @@ fun PreviewChatLayout() {
),
composeState = remember { mutableStateOf(ComposeState()) },
composeView = {},
chosenFile = remember { mutableStateOf(null) },
chosenImage = remember { mutableStateOf(null) },
scope = rememberCoroutineScope(),
attachmentBottomSheetState = rememberModalBottomSheetState(initialValue = ModalBottomSheetValue.Hidden),
@@ -400,6 +415,7 @@ fun PreviewGroupChatLayout() {
composeState = remember { mutableStateOf(ComposeState()) },
composeView = {},
chosenImage = remember { mutableStateOf(null) },
chosenFile = remember { mutableStateOf(null) },
scope = rememberCoroutineScope(),
attachmentBottomSheetState = rememberModalBottomSheetState(initialValue = ModalBottomSheetValue.Hidden),
chatItems = chatItems,

View File

@@ -0,0 +1,60 @@
import androidx.compose.foundation.*
import androidx.compose.foundation.layout.*
import androidx.compose.material.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.InsertDriveFile
import androidx.compose.material.icons.outlined.Close
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import chat.simplex.app.R
import chat.simplex.app.ui.theme.*
import chat.simplex.app.views.chat.item.SentColorLight
@Composable
fun ComposeFileView(fileName: String, cancelFile: () -> Unit, cancelEnabled: Boolean) {
Row(
Modifier
.height(60.dp)
.fillMaxWidth()
.padding(top = 8.dp)
.background(SentColorLight),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
Icons.Filled.InsertDriveFile,
stringResource(R.string.icon_descr_file),
Modifier
.padding(start = 4.dp, end = 2.dp)
.size(36.dp),
tint = if (isSystemInDarkTheme()) FileDark else FileLight
)
Text(fileName)
Spacer(Modifier.weight(1f))
if (cancelEnabled) {
IconButton(onClick = cancelFile, modifier = Modifier.padding(0.dp)) {
Icon(
Icons.Outlined.Close,
contentDescription = stringResource(R.string.icon_descr_cancel_file_preview),
tint = MaterialTheme.colors.primary,
modifier = Modifier.padding(10.dp)
)
}
}
}
}
@Preview
@Composable
fun PreviewComposeFileView() {
SimpleXTheme {
ComposeFileView(
"test.txt",
cancelFile = {},
cancelEnabled = true
)
}
}

View File

@@ -1,14 +1,17 @@
package chat.simplex.app.views.chat
import ComposeFileView
import ComposeImageView
import android.content.Context
import android.graphics.Bitmap
import android.net.Uri
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.AttachFile
import androidx.compose.material.icons.filled.Edit
import androidx.compose.material.icons.outlined.Reply
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
@@ -22,13 +25,12 @@ import chat.simplex.app.model.*
import chat.simplex.app.views.chat.item.*
import chat.simplex.app.views.helpers.*
import kotlinx.coroutines.*
import java.io.File
import java.io.FileOutputStream
sealed class ComposePreview {
object NoPreview: ComposePreview()
class CLinkPreview(val linkPreview: LinkPreview): ComposePreview()
class ImagePreview(val image: String): ComposePreview()
class FilePreview(val fileName: String): ComposePreview()
}
sealed class ComposeContextItem {
@@ -55,16 +57,20 @@ data class ComposeState(
is ComposeContextItem.EditingItem -> true
else -> false
}
val sendEnabled: Boolean
get() =
when (preview) {
val sendEnabled: () -> Boolean
get() = {
val hasContent = when (preview) {
is ComposePreview.ImagePreview -> true
is ComposePreview.FilePreview -> true
else -> message.isNotEmpty()
}
hasContent && !inProgress
}
val linkPreviewAllowed: Boolean
get() =
when (preview) {
is ComposePreview.ImagePreview -> false
is ComposePreview.FilePreview -> false
else -> true
}
val linkPreview: LinkPreview?
@@ -77,8 +83,13 @@ data class ComposeState(
fun chatItemPreview(chatItem: ChatItem): ComposePreview {
return when (val mc = chatItem.content.msgContent) {
is MsgContent.MCText -> ComposePreview.NoPreview
is MsgContent.MCLink -> ComposePreview.CLinkPreview(linkPreview = mc.preview)
is MsgContent.MCImage -> ComposePreview.ImagePreview(image = mc.image)
is MsgContent.MCFile -> {
val fileName = chatItem.file?.fileName ?: ""
ComposePreview.FilePreview(fileName)
}
else -> ComposePreview.NoPreview
}
}
@@ -89,6 +100,7 @@ fun ComposeView(
chat: Chat,
composeState: MutableState<ComposeState>,
chosenImage: MutableState<Bitmap?>,
chosenFile: MutableState<Uri?>,
showAttachmentBottomSheet: () -> Unit
) {
val context = LocalContext.current
@@ -169,24 +181,19 @@ fun ComposeView(
}
}
fun saveImage(context: Context, image: Bitmap): String? {
return try {
val dataResized = resizeImageToDataSize(image, maxDataSize = MAX_IMAGE_SIZE)
val fileToSave = "image_${System.currentTimeMillis()}.jpg"
val file = File(getAppFilesDirectory(context) + "/" + fileToSave)
val output = FileOutputStream(file)
dataResized.writeTo(output)
output.flush()
output.close()
fileToSave
} catch (e: Exception) {
null
}
fun clearState() {
composeState.value = ComposeState()
chosenImage.value = null
chosenFile.value = null
linkUrl.value = null
prevLinkUrl.value = null
pendingLinkUrl.value = null
cancelledLinks.clear()
}
fun sendMessage() {
withApi {
// show "in progress"
composeState.value = composeState.value.copy(inProgress = true)
val cInfo = chat.chatInfo
val cs = composeState.value
when (val contextItem = cs.contextItem) {
@@ -218,6 +225,15 @@ fun ComposeView(
}
}
}
is ComposePreview.FilePreview -> {
val chosenFileVal = chosenFile.value
if (chosenFileVal != null) {
file = saveFileFromUri(context, chosenFileVal)
if (file != null) {
mc = MsgContent.MCFile(cs.message)
}
}
}
}
val quotedItemId: Long? = when (contextItem) {
is ComposeContextItem.QuotedItem -> contextItem.chatItem.id
@@ -236,8 +252,7 @@ fun ComposeView(
}
}
}
// hide "in progress"
composeState.value = ComposeState()
clearState()
}
}
@@ -263,8 +278,13 @@ fun ComposeView(
}
fun cancelImage() {
chosenImage.value = null
composeState.value = composeState.value.copy(preview = ComposePreview.NoPreview)
chosenImage.value = null
}
fun cancelFile() {
composeState.value = composeState.value.copy(preview = ComposePreview.NoPreview)
chosenFile.value = null
}
@Composable
@@ -277,6 +297,11 @@ fun ComposeView(
::cancelImage,
cancelEnabled = !composeState.value.editing
)
is ComposePreview.FilePreview -> ComposeFileView(
preview.fileName,
::cancelFile,
cancelEnabled = !composeState.value.editing
)
}
}
@@ -284,14 +309,21 @@ fun ComposeView(
fun contextItemView() {
when (val contextItem = composeState.value.contextItem) {
ComposeContextItem.NoContextItem -> {}
is ComposeContextItem.QuotedItem -> ContextItemView(contextItem.chatItem) { composeState.value = composeState.value.copy(contextItem = ComposeContextItem.NoContextItem) }
is ComposeContextItem.EditingItem -> ContextItemView(contextItem.chatItem) { composeState.value = ComposeState() }
is ComposeContextItem.QuotedItem -> ContextItemView(contextItem.chatItem, Icons.Outlined.Reply) {
composeState.value = composeState.value.copy(contextItem = ComposeContextItem.NoContextItem)
}
is ComposeContextItem.EditingItem -> ContextItemView(contextItem.chatItem, Icons.Filled.Edit) {
clearState()
}
}
}
Column {
contextItemView()
previewView()
when {
composeState.value.editing && composeState.value.preview is ComposePreview.FilePreview -> {}
else -> previewView()
}
Row(
modifier = Modifier.padding(start = 4.dp, end = 8.dp),
verticalAlignment = Alignment.Bottom,

View File

@@ -4,10 +4,12 @@ import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.material.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Edit
import androidx.compose.material.icons.outlined.Close
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.withStyle
@@ -16,6 +18,7 @@ import androidx.compose.ui.unit.dp
import chat.simplex.app.R
import chat.simplex.app.model.CIDirection
import chat.simplex.app.model.ChatItem
import chat.simplex.app.ui.theme.HighOrLowlight
import chat.simplex.app.ui.theme.SimpleXTheme
import chat.simplex.app.views.chat.item.*
import kotlinx.datetime.Clock
@@ -23,6 +26,7 @@ import kotlinx.datetime.Clock
@Composable
fun ContextItemView(
contextItem: ChatItem,
contextIcon: ImageVector,
cancelContextItem: () -> Unit
) {
val sent = contextItem.chatDir.sent
@@ -32,13 +36,22 @@ fun ContextItemView(
.background(if (sent) SentColorLight else ReceivedColorLight),
verticalAlignment = Alignment.CenterVertically
) {
Box(
Row(
Modifier
.padding(start = 16.dp)
.padding(vertical = 12.dp)
.fillMaxWidth()
.weight(1F)
.weight(1F),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
contextIcon,
modifier = Modifier
.padding(horizontal = 8.dp)
.height(20.dp)
.width(20.dp),
contentDescription = stringResource(R.string.icon_descr_context),
tint = HighOrLowlight,
)
ContextItemText(contextItem)
}
IconButton(onClick = cancelContextItem) {
@@ -56,11 +69,11 @@ fun ContextItemView(
private fun ContextItemText(cxtItem: ChatItem) {
val member = cxtItem.memberDisplayName
if (member == null) {
Text(cxtItem.content.text, maxLines = 3)
Text(cxtItem.text, maxLines = 3)
} else {
val annotatedText = buildAnnotatedString {
withStyle(boldFont) { append(member) }
append(": ${cxtItem.content.text}")
append(": ${cxtItem.text}")
}
Text(annotatedText, maxLines = 3)
}
@@ -72,6 +85,7 @@ fun PreviewContextItemView() {
SimpleXTheme {
ContextItemView(
contextItem = ChatItem.getSampleData(1, CIDirection.DirectRcv(), Clock.System.now(), "hello"),
contextIcon = Icons.Filled.Edit,
cancelContextItem = {}
)
}

View File

@@ -24,8 +24,7 @@ import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import chat.simplex.app.R
import chat.simplex.app.model.*
import chat.simplex.app.ui.theme.HighOrLowlight
import chat.simplex.app.ui.theme.SimpleXTheme
import chat.simplex.app.ui.theme.*
@Composable
fun SendMsgView(
@@ -65,22 +64,34 @@ fun SendMsgView(
innerTextField()
}
val icon = if (cs.editing) Icons.Filled.Check else Icons.Outlined.ArrowUpward
val color = if (cs.sendEnabled) MaterialTheme.colors.primary else Color.Gray
Icon(
icon,
stringResource(R.string.icon_descr_send_message),
tint = Color.White,
modifier = Modifier
.size(36.dp)
.padding(4.dp)
.clip(CircleShape)
.background(color)
.clickable {
if (cs.sendEnabled) {
sendMessage()
val color = if (cs.sendEnabled()) MaterialTheme.colors.primary else HighOrLowlight
if (cs.inProgress
&& (cs.preview is ComposePreview.ImagePreview || cs.preview is ComposePreview.FilePreview)
) {
CircularProgressIndicator(
Modifier
.size(36.dp)
.padding(4.dp),
color = HighOrLowlight,
strokeWidth = 4.dp
)
} else {
Icon(
icon,
stringResource(R.string.icon_descr_send_message),
tint = Color.White,
modifier = Modifier
.size(36.dp)
.padding(4.dp)
.clip(CircleShape)
.background(color)
.clickable {
if (cs.sendEnabled()) {
sendMessage()
}
}
}
)
)
}
}
}
}
@@ -127,3 +138,24 @@ fun PreviewSendMsgViewEditing() {
)
}
}
@Preview(showBackground = true)
@Preview(
uiMode = Configuration.UI_MODE_NIGHT_YES,
showBackground = true,
name = "Dark Mode"
)
@Composable
fun PreviewSendMsgViewInProgress() {
val smallFont = MaterialTheme.typography.body1.copy(color = MaterialTheme.colors.onBackground)
val textStyle = remember { mutableStateOf(smallFont) }
val composeStateInProgress = ComposeState(inProgress = true)
SimpleXTheme {
SendMsgView(
composeState = remember { mutableStateOf(composeStateInProgress) },
sendMessage = {},
onMessageChange = { _ -> },
textStyle = textStyle
)
}
}

View File

@@ -1,15 +1,19 @@
package chat.simplex.app.views.chat.item
import android.widget.Toast
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.clickable
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.filled.InsertDriveFile
import androidx.compose.material.icons.outlined.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalContext
@@ -20,11 +24,8 @@ import androidx.compose.ui.unit.sp
import chat.simplex.app.R
import chat.simplex.app.model.*
import chat.simplex.app.ui.theme.*
import chat.simplex.app.views.chat.item.FramedItemView
import chat.simplex.app.views.helpers.*
import kotlinx.datetime.Clock
import kotlin.math.log2
import kotlin.math.pow
@Composable
fun CIFileView(
@@ -33,15 +34,13 @@ fun CIFileView(
receiveFile: (Long) -> Unit
) {
val context = LocalContext.current
val saveFileLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.CreateDocument(),
onResult = { destination ->
saveFile(context, file, destination)
}
)
val saveFileLauncher = rememberSaveFileLauncher(cxt = context, ciFile = file)
@Composable
fun fileIcon(innerIcon: ImageVector? = null, color: Color = HighOrLowlight) {
fun fileIcon(
innerIcon: ImageVector? = null,
color: Color = if (isSystemInDarkTheme()) FileDark else FileLight
) {
Box(
contentAlignment = Alignment.Center
) {
@@ -80,7 +79,7 @@ fun CIFileView(
} else {
AlertManager.shared.showAlertMsg(
generalGetString(R.string.large_file),
String.format(generalGetString(R.string.contact_sent_large_file), MAX_FILE_SIZE)
String.format(generalGetString(R.string.contact_sent_large_file), formatBytes(MAX_FILE_SIZE))
)
}
}
@@ -102,14 +101,29 @@ fun CIFileView(
}
}
@Composable
fun progressIndicator() {
CircularProgressIndicator(
Modifier.size(32.dp),
color = if (isSystemInDarkTheme()) FileDark else FileLight,
strokeWidth = 4.dp
)
}
@Composable
fun fileIndicator() {
Box(
Modifier.size(44.dp),
Modifier
.size(42.dp)
.clip(RoundedCornerShape(4.dp))
.clickable(onClick = { fileAction() }),
contentAlignment = Alignment.Center
) {
if (file != null) {
when (file.fileStatus) {
CIFileStatus.SndStored -> fileIcon()
CIFileStatus.SndTransfer -> progressIndicator()
CIFileStatus.SndComplete -> fileIcon(innerIcon = Icons.Filled.Check)
CIFileStatus.SndCancelled -> fileIcon(innerIcon = Icons.Outlined.Close)
CIFileStatus.RcvInvitation ->
if (fileSizeValid())
@@ -117,14 +131,9 @@ fun CIFileView(
else
fileIcon(innerIcon = Icons.Outlined.PriorityHigh, color = WarningOrange)
CIFileStatus.RcvAccepted -> fileIcon(innerIcon = Icons.Outlined.MoreHoriz)
CIFileStatus.RcvTransfer ->
CircularProgressIndicator(
Modifier.size(36.dp),
color = HighOrLowlight,
strokeWidth = 4.dp
)
CIFileStatus.RcvTransfer -> progressIndicator()
CIFileStatus.RcvComplete -> fileIcon(innerIcon = Icons.Outlined.ArrowDownward)
CIFileStatus.RcvCancelled -> fileIcon(innerIcon = Icons.Outlined.Close)
else -> fileIcon()
}
} else {
fileIcon()
@@ -132,30 +141,10 @@ fun CIFileView(
}
}
fun formatBytes(bytes: Long): String {
if (bytes == 0.toLong()) {
return "0 bytes"
}
val bytesDouble = bytes.toDouble()
val k = 1000.toDouble()
val units = arrayOf("bytes", "KB", "MB", "GB", "TB", "PB", "EB", "ZB", "YB")
val i = kotlin.math.floor(log2(bytesDouble) / log2(k))
val size = bytesDouble / k.pow(i)
val unit = units[i.toInt()]
return if (i <= 1) {
String.format("%.0f %s", size, unit)
} else {
String.format("%.2f %s", size, unit)
}
}
Row(
Modifier
.padding(top = 4.dp, bottom = 6.dp, start = 10.dp, end = 12.dp)
.clickable(onClick = { fileAction() }),
Modifier.padding(top = 4.dp, bottom = 6.dp, start = 6.dp, end = 12.dp),
verticalAlignment = Alignment.Bottom,
horizontalArrangement = Arrangement.spacedBy(4.dp)
horizontalArrangement = Arrangement.spacedBy(2.dp)
) {
fileIndicator()
val metaReserve = if (edited)
@@ -189,7 +178,7 @@ class ChatItemProvider: PreviewParameterProvider<ChatItem> {
meta = CIMeta.getSample(1, Clock.System.now(), "", CIStatus.SndSent(), itemDeleted = false, itemEdited = true, editable = false),
content = CIContent.SndMsgContent(msgContent = MsgContent.MCFile("")),
quotedItem = null,
file = CIFile.getSample(fileStatus = CIFileStatus.SndStored)
file = CIFile.getSample(fileStatus = CIFileStatus.SndComplete)
)
private val fileChatItemWtFile = ChatItem(
chatDir = CIDirection.DirectRcv(),
@@ -205,7 +194,7 @@ class ChatItemProvider: PreviewParameterProvider<ChatItem> {
ChatItem.getFileMsgContentSample(fileStatus = CIFileStatus.RcvAccepted),
ChatItem.getFileMsgContentSample(fileStatus = CIFileStatus.RcvTransfer),
ChatItem.getFileMsgContentSample(fileStatus = CIFileStatus.RcvCancelled),
ChatItem.getFileMsgContentSample(fileSize = 2000000, fileStatus = CIFileStatus.RcvInvitation),
ChatItem.getFileMsgContentSample(fileSize = 1_000_000_000, fileStatus = CIFileStatus.RcvInvitation),
ChatItem.getFileMsgContentSample(text = "Hello there", fileStatus = CIFileStatus.RcvInvitation),
ChatItem.getFileMsgContentSample(text = "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.", fileStatus = CIFileStatus.RcvInvitation),
fileChatItemWtFile

View File

@@ -4,6 +4,7 @@ import androidx.compose.foundation.layout.*
import androidx.compose.material.CircularProgressIndicator
import androidx.compose.material.Icon
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.outlined.MoreHoriz
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
@@ -34,6 +35,19 @@ fun CIImageView(
contentAlignment = Alignment.Center
) {
when (file.fileStatus) {
CIFileStatus.SndTransfer ->
CircularProgressIndicator(
Modifier.size(16.dp),
color = Color.White,
strokeWidth = 2.dp
)
CIFileStatus.SndComplete ->
Icon(
Icons.Filled.Check,
stringResource(R.string.icon_descr_image_snd_complete),
Modifier.fillMaxSize(),
tint = Color.White
)
CIFileStatus.RcvAccepted ->
Icon(
Icons.Outlined.MoreHoriz,

View File

@@ -1,10 +1,9 @@
package chat.simplex.app.views.chat.item
import android.content.Context
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Edit
@@ -12,6 +11,7 @@ import androidx.compose.material.icons.outlined.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalContext
@@ -42,19 +42,18 @@ fun ChatItemView(
val sent = cItem.chatDir.sent
val alignment = if (sent) Alignment.CenterEnd else Alignment.CenterStart
val showMenu = remember { mutableStateOf(false) }
val saveFileLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.CreateDocument(),
onResult = { destination ->
saveFile(context, cItem.file, destination)
}
)
val saveFileLauncher = rememberSaveFileLauncher(cxt = context, ciFile = cItem.file)
Box(
modifier = Modifier
.padding(bottom = 4.dp)
.fillMaxWidth(),
contentAlignment = alignment,
) {
Column(Modifier.combinedClickable(onLongClick = { showMenu.value = true }, onClick = {})) {
Column(
Modifier
.clip(RoundedCornerShape(18.dp))
.combinedClickable(onLongClick = { showMenu.value = true }, onClick = {})
) {
if (cItem.isMsgContent) {
if (cItem.file == null && cItem.quotedItem == null && isShortEmoji(cItem.content.text)) {
EmojiItemView(cItem)
@@ -71,7 +70,11 @@ fun ChatItemView(
Modifier.width(220.dp)
) {
ItemAction(stringResource(R.string.reply_verb), Icons.Outlined.Reply, onClick = {
composeState.value = composeState.value.copy(contextItem = ComposeContextItem.QuotedItem(cItem))
if (composeState.value.editing) {
composeState.value = ComposeState(contextItem = ComposeContextItem.QuotedItem(cItem))
} else {
composeState.value = composeState.value.copy(contextItem = ComposeContextItem.QuotedItem(cItem))
}
showMenu.value = false
})
ItemAction(stringResource(R.string.share_verb), Icons.Outlined.Share, onClick = {
@@ -82,7 +85,7 @@ fun ChatItemView(
copyText(cxt, cItem.content.text)
showMenu.value = false
})
if (cItem.content.msgContent is MsgContent.MCImage) {
if (cItem.content.msgContent is MsgContent.MCImage || cItem.content.msgContent is MsgContent.MCFile) {
val filePath = getStoredFilePath(context, cItem.file)
if (filePath != null) {
ItemAction(stringResource(R.string.save_verb), Icons.Outlined.SaveAlt, onClick = {

File diff suppressed because one or more lines are too long

View File

@@ -18,8 +18,7 @@ import androidx.activity.result.contract.ActivityResultContracts
import androidx.annotation.CallSuper
import androidx.compose.foundation.layout.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Collections
import androidx.compose.material.icons.outlined.PhotoCamera
import androidx.compose.material.icons.outlined.*
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.onFocusChanged
@@ -37,7 +36,6 @@ import kotlin.math.min
import kotlin.math.sqrt
// Inspired by https://github.com/MakeItEasyDev/Jetpack-Compose-Capture-Image-Or-Choose-from-Gallery
fun cropToSquare(image: Bitmap): Bitmap {
var xOffset = 0
var yOffset = 0
@@ -50,7 +48,7 @@ fun cropToSquare(image: Bitmap): Bitmap {
return Bitmap.createBitmap(image, xOffset, yOffset, side, side)
}
fun resizeImageToStrSize(image: Bitmap, maxDataSize: Int): String {
fun resizeImageToStrSize(image: Bitmap, maxDataSize: Long): String {
var img = image
var str = compressImageStr(img)
while (str.length > maxDataSize) {
@@ -68,7 +66,7 @@ private fun compressImageStr(bitmap: Bitmap): String {
return "data:image/jpg;base64," + Base64.encodeToString(compressImageData(bitmap).toByteArray(), Base64.NO_WRAP)
}
fun resizeImageToDataSize(image: Bitmap, maxDataSize: Int): ByteArrayOutputStream {
fun resizeImageToDataSize(image: Bitmap, maxDataSize: Long): ByteArrayOutputStream {
var img = image
var stream = compressImageData(img)
while (stream.size() > maxDataSize) {
@@ -88,7 +86,7 @@ private fun compressImageData(bitmap: Bitmap): ByteArrayOutputStream {
return stream
}
fun base64ToBitmap(base64ImageString: String) : Bitmap {
fun base64ToBitmap(base64ImageString: String): Bitmap {
val imageString = base64ImageString
.removePrefix("data:image/png;base64,")
.removePrefix("data:image/jpg;base64,")
@@ -96,7 +94,7 @@ fun base64ToBitmap(base64ImageString: String) : Bitmap {
return BitmapFactory.decodeByteArray(imageBytes, 0, imageBytes.size)
}
class CustomTakePicturePreview : ActivityResultContract<Void?, Bitmap?>() {
class CustomTakePicturePreview: ActivityResultContract<Void?, Bitmap?>() {
private var uri: Uri? = null
private var tmpFile: File? = null
lateinit var externalContext: Context
@@ -122,15 +120,24 @@ class CustomTakePicturePreview : ActivityResultContract<Void?, Bitmap?>() {
tmpFile?.delete()
bitmap
} else {
Log.e( TAG, "Getting image from camera cancelled or failed.")
Log.e(TAG, "Getting image from camera cancelled or failed.")
tmpFile?.delete()
null
}
}
}
//class GetGalleryContent: ActivityResultContracts.GetContent() {
// override fun createIntent(context: Context, input: String): Intent {
// return super.createIntent(context, input).apply {
// Log.e(TAG, "########################################################### in GetGalleryContent")
// uri = DocumentsContract.buildDocumentUriUsingTree(uri, DocumentsContract.getTreeDocumentId(uri))
// putExtra(DocumentsContract.EXTRA_INITIAL_URI, Environment.DIRECTORY_PICTURES)
// }
// }
//}
@Composable
fun rememberGalleryLauncher(cb: (Uri?) -> Unit): ManagedActivityResultLauncher<String, Uri?> =
fun rememberGetContentLauncher(cb: (Uri?) -> Unit): ManagedActivityResultLauncher<String, Uri?> =
// rememberLauncherForActivityResult(contract = GetGalleryContent(), cb)
rememberLauncherForActivityResult(contract = ActivityResultContracts.GetContent(), cb)
@Composable
@@ -145,12 +152,13 @@ fun rememberPermissionLauncher(cb: (Boolean) -> Unit): ManagedActivityResultLaun
fun GetImageBottomSheet(
imageBitmap: MutableState<Bitmap?>,
onImageChange: (Bitmap) -> Unit,
fileUri: MutableState<Uri?>? = null,
onFileChange: ((Uri) -> Unit)? = null,
hideBottomSheet: () -> Unit
) {
val context = LocalContext.current
val isCameraSelected = remember { mutableStateOf (false) }
val galleryLauncher = rememberGalleryLauncher { uri: Uri? ->
val isCameraSelected = remember { mutableStateOf(false) }
val galleryLauncher = rememberGetContentLauncher { uri: Uri? ->
if (uri != null) {
val source = ImageDecoder.createSource(context.contentResolver, uri)
val bitmap = ImageDecoder.decodeBitmap(source)
@@ -158,21 +166,41 @@ fun GetImageBottomSheet(
onImageChange(bitmap)
}
}
val cameraLauncher = rememberCameraLauncher { bitmap: Bitmap? ->
if (bitmap != null) {
imageBitmap.value = bitmap
onImageChange(bitmap)
}
}
val permissionLauncher = rememberPermissionLauncher { isGranted: Boolean ->
if (isGranted) {
if (isCameraSelected.value) cameraLauncher.launch(null)
else galleryLauncher.launch("image/*")
hideBottomSheet()
} else {
Toast.makeText(context, generalGetString(R.string.toast_camera_permission_denied), Toast.LENGTH_SHORT).show()
Toast.makeText(context, generalGetString(R.string.toast_permission_denied), Toast.LENGTH_SHORT).show()
}
}
val filesLauncher = rememberGetContentLauncher { uri: Uri? ->
if (uri != null && fileUri != null && onFileChange != null) {
val fileSize = getFileSize(context, uri)
if (fileSize != null && fileSize <= MAX_FILE_SIZE) {
fileUri.value = uri
onFileChange(uri)
} else {
AlertManager.shared.showAlertMsg(
generalGetString(R.string.large_file),
String.format(generalGetString(R.string.maximum_supported_file_size), formatBytes(MAX_FILE_SIZE))
)
}
}
}
val filesPermissionLauncher = rememberPermissionLauncher { isGranted: Boolean ->
if (isGranted) {
filesLauncher.launch("*/*")
hideBottomSheet()
} else {
Toast.makeText(context, generalGetString(R.string.toast_permission_denied), Toast.LENGTH_SHORT).show()
}
}
@@ -214,6 +242,19 @@ fun GetImageBottomSheet(
}
}
}
if (fileUri != null && onFileChange != null) {
ActionButton(null, stringResource(R.string.choose_file), icon = Icons.Outlined.InsertDriveFile) {
when (PackageManager.PERMISSION_GRANTED) {
ContextCompat.checkSelfPermission(context, Manifest.permission.READ_EXTERNAL_STORAGE) -> {
filesLauncher.launch("*/*")
hideBottomSheet()
}
else -> {
filesPermissionLauncher.launch(Manifest.permission.READ_EXTERNAL_STORAGE)
}
}
}
}
}
}
}

View File

@@ -3,6 +3,10 @@ package chat.simplex.app.views.helpers
import android.content.*
import android.net.Uri
import android.widget.Toast
import androidx.activity.compose.ManagedActivityResultLauncher
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.runtime.Composable
import androidx.core.content.ContextCompat
import chat.simplex.app.R
import chat.simplex.app.model.CIFile
@@ -24,24 +28,29 @@ fun copyText(cxt: Context, text: String) {
clipboard?.setPrimaryClip(ClipData.newPlainText("text", text))
}
fun saveFile(cxt: Context, ciFile: CIFile?, destination: Uri?) {
if (destination != null) {
val filePath = getStoredFilePath(cxt, ciFile)
if (filePath != null) {
val contentResolver = cxt.contentResolver
val file = File(filePath)
try {
val outputStream = contentResolver.openOutputStream(destination)
if (outputStream != null) {
outputStream.write(file.readBytes())
outputStream.close()
Toast.makeText(cxt, generalGetString(R.string.file_saved), Toast.LENGTH_SHORT).show()
@Composable
fun rememberSaveFileLauncher(cxt: Context, ciFile: CIFile?): ManagedActivityResultLauncher<String, Uri?> =
rememberLauncherForActivityResult(
contract = ActivityResultContracts.CreateDocument(),
onResult = { destination ->
if (destination != null) {
val filePath = getStoredFilePath(cxt, ciFile)
if (filePath != null) {
val contentResolver = cxt.contentResolver
val file = File(filePath)
try {
val outputStream = contentResolver.openOutputStream(destination)
if (outputStream != null) {
outputStream.write(file.readBytes())
outputStream.close()
Toast.makeText(cxt, generalGetString(R.string.file_saved), Toast.LENGTH_SHORT).show()
}
} catch (e: IOException) {
Toast.makeText(cxt, generalGetString(R.string.error_saving_file), Toast.LENGTH_SHORT).show()
}
} else {
Toast.makeText(cxt, generalGetString(R.string.file_not_found), Toast.LENGTH_SHORT).show()
}
} catch (e: IOException) {
Toast.makeText(cxt, generalGetString(R.string.error_saving_file), Toast.LENGTH_SHORT).show()
}
} else {
Toast.makeText(cxt, generalGetString(R.string.file_not_found), Toast.LENGTH_SHORT).show()
}
}
}
)

View File

@@ -4,9 +4,13 @@ import android.content.Context
import android.content.res.Resources
import android.graphics.*
import android.graphics.Typeface
import android.net.Uri
import android.os.FileUtils
import android.provider.OpenableColumns
import android.text.Spanned
import android.text.SpannedString
import android.text.style.*
import android.util.Log
import android.view.ViewTreeObserver
import androidx.annotation.StringRes
import androidx.compose.runtime.*
@@ -24,6 +28,9 @@ import chat.simplex.app.SimplexApp
import chat.simplex.app.model.CIFile
import kotlinx.coroutines.*
import java.io.File
import java.io.FileOutputStream
import kotlin.math.log2
import kotlin.math.pow
fun withApi(action: suspend CoroutineScope.() -> Unit): Job =
GlobalScope.launch { withContext(Dispatchers.Main, action) }
@@ -202,21 +209,24 @@ private fun spannableStringToAnnotatedString(
}
// maximum image file size to be auto-accepted
const val MAX_IMAGE_SIZE = 236700
const val MAX_FILE_SIZE = 1893600
const val MAX_IMAGE_SIZE: Long = 236700
const val MAX_FILE_SIZE: Long = 8000000
fun getFilesDirectory(context: Context): String {
return context.filesDir.toString()
}
fun getAppFilesDirectory(context: Context): String {
return getFilesDirectory(context) + "/app_files"
return "${getFilesDirectory(context)}/app_files"
}
fun getAppFilePath(context: Context, fileName: String): String {
return "${getAppFilesDirectory(context)}/$fileName"
}
fun getStoredFilePath(context: Context, file: CIFile?): String? {
return if (file?.filePath != null && file.stored) {
val filePath = getAppFilesDirectory(context) + "/" + file.filePath
val filePath = getAppFilePath(context, file.filePath)
if (File(filePath).exists()) filePath else null
} else {
null
@@ -241,3 +251,92 @@ fun getStoredImage(context: Context, file: CIFile?): Bitmap? {
null
}
}
fun getFileName(context: Context, uri: Uri): String? {
return context.contentResolver.query(uri, null, null, null, null)?.use { cursor ->
val nameIndex = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME)
cursor.moveToFirst()
cursor.getString(nameIndex)
}
}
fun getFileSize(context: Context, uri: Uri): Long? {
return context.contentResolver.query(uri, null, null, null, null)?.use { cursor ->
val sizeIndex = cursor.getColumnIndex(OpenableColumns.SIZE)
cursor.moveToFirst()
cursor.getLong(sizeIndex)
}
}
fun saveImage(context: Context, image: Bitmap): String? {
return try {
val dataResized = resizeImageToDataSize(image, maxDataSize = MAX_IMAGE_SIZE)
val fileToSave = uniqueCombine(context, "image_${System.currentTimeMillis()}.jpg")
val file = File(getAppFilePath(context, fileToSave))
val output = FileOutputStream(file)
dataResized.writeTo(output)
output.flush()
output.close()
fileToSave
} catch (e: Exception) {
Log.e(chat.simplex.app.TAG, "Util.kt saveImage error: ${e.message}")
null
}
}
fun saveFileFromUri(context: Context, uri: Uri): String? {
return try {
val inputStream = context.contentResolver.openInputStream(uri)
val fileToSave = getFileName(context, uri)
if (inputStream != null && fileToSave != null) {
val destFileName = uniqueCombine(context, fileToSave)
val destFile = File(getAppFilePath(context, destFileName))
FileUtils.copy(inputStream, FileOutputStream(destFile))
destFileName
} else {
Log.e(chat.simplex.app.TAG, "Util.kt saveFileFromUri null inputStream")
null
}
} catch (e: Exception) {
Log.e(chat.simplex.app.TAG, "Util.kt saveFileFromUri error: ${e.message}")
null
}
}
fun uniqueCombine(context: Context, fileName: String): String {
fun tryCombine(fileName: String, n: Int): String {
val name = File(fileName).nameWithoutExtension
val ext = File(fileName).extension
val suffix = if (n == 0) "" else "_$n"
val f = "$name$suffix.$ext"
return if (File(getAppFilePath(context, f)).exists()) tryCombine(fileName, n + 1) else f
}
return tryCombine(fileName, 0)
}
fun formatBytes(bytes: Long): String {
if (bytes == 0.toLong()) {
return "0 bytes"
}
val bytesDouble = bytes.toDouble()
val k = 1000.toDouble()
val units = arrayOf("bytes", "KB", "MB", "GB", "TB", "PB", "EB", "ZB", "YB")
val i = kotlin.math.floor(log2(bytesDouble) / log2(k))
val size = bytesDouble / k.pow(i)
val unit = units[i.toInt()]
return if (i <= 1) {
String.format("%.0f %s", size, unit)
} else {
String.format("%.2f %s", size, unit)
}
}
fun removeFile(context: Context, fileName: String): Boolean {
val file = File(getAppFilePath(context, fileName))
val fileDeleted = file.delete()
if (!fileDeleted) {
Log.e(chat.simplex.app.TAG, "Util.kt removeFile error")
}
return fileDeleted
}

View File

@@ -87,20 +87,24 @@
<string name="your_chats">Ваши чаты</string>
<string name="contact_connection_pending">соединяется…</string>
<!-- ComposeView.kt -->
<!-- ComposeView.kt, helpers -->
<string name="attach">Прикрепить</string>
<!-- Images -->
<string name="image_descr">Изображение</string>
<string name="icon_descr_context">Значок контекста</string>
<string name="icon_descr_cancel_image_preview">Удалить превью изображения</string>
<string name="icon_descr_cancel_file_preview">Удалить превью файла</string>
<!-- Images - CIImageView.kt -->
<string name="image_descr">Изображение</string>
<string name="icon_descr_waiting_for_image">Ожидание изображения</string>
<string name="icon_descr_image_snd_complete">Изображение отправлено</string>
<string name="waiting_for_image">Ожидание изображения</string>
<string name="image_will_be_received_when_contact_is_online">Изображение будет получено, когда ваш контакт будет в сети, пожалуйста, подождите или проверьте позже!</string>
<!-- Files - CIFileView.kt -->
<string name="icon_descr_file">Файл</string>
<string name="large_file">Большой файл!</string>
<string name="contact_sent_large_file">Ваш контакт отправил файл, размер которого превышает поддерживаемый в настоящее время максимальный размер (<xliff:g id="maxFileSize">%1$s</xliff:g> байта).</string>
<string name="contact_sent_large_file">Ваш контакт отправил файл, размер которого превышает поддерживаемый в настоящее время максимальный размер (<xliff:g id="maxFileSize">%1$s</xliff:g>).</string>
<string name="maximum_supported_file_size">В настоящее время максимальный поддерживаемый размер файла составляет <xliff:g id="maxFileSize">%1$s</xliff:g>.</string>
<string name="waiting_for_file">Ожидание файла</string>
<string name="file_will_be_received_when_contact_is_online">Файл будет получен, когда ваш контакт будет в сети, пожалуйста, подождите или проверьте позже!</string>
<string name="file_saved">Файл сохранен</string>
@@ -136,9 +140,10 @@
<string name="paste_received_link_from_clipboard">(вставить ссылку из буфера обмена)</string>
<!-- GetImageView -->
<string name="toast_camera_permission_denied">Разрешение не получено!</string>
<string name="toast_permission_denied">Разрешение не получено!</string>
<string name="use_camera_button">Использовать камеру</string>
<string name="from_gallery_button">Открыть галерею</string>
<string name="choose_file">Выбрать файл</string>
<!-- help - ChatHelpView.kt -->
<string name="thank_you_for_installing_simplex">Спасибо, что установили <xliff:g id="appNameFull">SimpleX Chat</xliff:g>!</string>

View File

@@ -87,20 +87,24 @@
<string name="your_chats">Your chats</string>
<string name="contact_connection_pending">connecting…</string>
<!-- ComposeView.kt -->
<!-- ComposeView.kt, helpers -->
<string name="attach">Attach</string>
<!-- Images -->
<string name="image_descr">Image</string>
<string name="icon_descr_context">Context icon</string>
<string name="icon_descr_cancel_image_preview">Cancel image preview</string>
<string name="icon_descr_cancel_file_preview">Cancel file preview</string>
<!-- Images - CIImageView.kt -->
<string name="image_descr">Image</string>
<string name="icon_descr_waiting_for_image">Waiting for image</string>
<string name="icon_descr_image_snd_complete">Image sent</string>
<string name="waiting_for_image">Waiting for image</string>
<string name="image_will_be_received_when_contact_is_online">Image will be received when your contact is online, please wait or check later!</string>
<!-- Files - CIFileView.kt -->
<string name="icon_descr_file">File</string>
<string name="large_file">Large file!</string>
<string name="contact_sent_large_file">Your contact sent a file that is larger than currently supported maximum size (<xliff:g id="maxFileSize">%1$s</xliff:g> bytes).</string>
<string name="contact_sent_large_file">Your contact sent a file that is larger than currently supported maximum size (<xliff:g id="maxFileSize">%1$s</xliff:g>).</string>
<string name="maximum_supported_file_size">Currently maximum supported file size is <xliff:g id="maxFileSize">%1$s</xliff:g>.</string>
<string name="waiting_for_file">Waiting for file</string>
<string name="file_will_be_received_when_contact_is_online">File will be received when your contact is online, please wait or check later!</string>
<string name="file_saved">File saved</string>
@@ -137,9 +141,10 @@
<string name="paste_received_link_from_clipboard">(paste link from clipboard)</string>
<!-- GetImageView -->
<string name="toast_camera_permission_denied">Permission Denied!</string>
<string name="toast_permission_denied">Permission Denied!</string>
<string name="use_camera_button">Use Camera</string>
<string name="from_gallery_button">From Gallery</string>
<string name="choose_file">Choose file</string>
<!-- help - ChatHelpView.kt -->
<string name="thank_you_for_installing_simplex">Thank you for installing <xliff:g id="appNameFull">SimpleX Chat</xliff:g>!</string>

View File

@@ -10,23 +10,27 @@ import Foundation
import SwiftUI
// maximum image file size to be auto-accepted
let maxImageSize = 236700
let maxImageSize: Int64 = 236700
let maxFileSize = 1893600
let maxFileSize: Int64 = 8000000
func getDocumentsDirectory() -> URL {
return FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first!
FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first!
}
func getAppFilesDirectory() -> URL {
return getDocumentsDirectory().appendingPathComponent("app_files", isDirectory: true)
getDocumentsDirectory().appendingPathComponent("app_files", isDirectory: true)
}
func getAppFilePath(_ fileName: String) -> URL {
getAppFilesDirectory().appendingPathComponent(fileName)
}
func getStoredFilePath(_ file: CIFile?) -> String? {
if let file = file,
file.stored,
let savedFile = file.filePath {
return getAppFilesDirectory().appendingPathComponent(savedFile).path
return getAppFilePath(savedFile).path
}
return nil
}
@@ -38,6 +42,76 @@ func getStoredImage(_ file: CIFile?) -> UIImage? {
return nil
}
func saveFileFromURL(_ url: URL) -> String? {
let savedFile: String?
if url.startAccessingSecurityScopedResource() {
do {
let fileData = try Data(contentsOf: url)
let fileName = uniqueCombine(url.lastPathComponent)
savedFile = saveFile(fileData, fileName)
} catch {
logger.error("FileUtils.saveFileFromURL error: \(error.localizedDescription)")
savedFile = nil
}
} else {
logger.error("FileUtils.saveFileFromURL startAccessingSecurityScopedResource returned false")
savedFile = nil
}
url.stopAccessingSecurityScopedResource()
return savedFile
}
func saveImage(_ uiImage: UIImage) -> String? {
if let imageDataResized = resizeImageToDataSize(uiImage, maxDataSize: maxImageSize) {
let millisecondsSince1970 = Int64((Date().timeIntervalSince1970 * 1000.0).rounded())
let fileName = uniqueCombine("image_\(millisecondsSince1970).jpg")
return saveFile(imageDataResized, fileName)
}
return nil
}
private func saveFile(_ data: Data, _ fileName: String) -> String? {
let filePath = getAppFilePath(fileName)
do {
try data.write(to: filePath)
return fileName
} catch {
logger.error("FileUtils.saveFile error: \(error.localizedDescription)")
return nil
}
}
private func uniqueCombine(_ fileName: String) -> String {
func tryCombine(_ fileName: String, _ n: Int) -> String {
let name = fileName.deletingPathExtension
let ext = fileName.pathExtension
let suffix = (n == 0) ? "" : "_\(n)"
let f = "\(name)\(suffix).\(ext)"
return (FileManager.default.fileExists(atPath: getAppFilePath(f).path)) ? tryCombine(fileName, n + 1) : f
}
return tryCombine(fileName, 0)
}
private extension String {
var ns: NSString {
return self as NSString
}
var pathExtension: String {
return ns.pathExtension
}
var deletingPathExtension: String {
return ns.deletingPathExtension
}
}
func removeFile(_ fileName: String) {
do {
try FileManager.default.removeItem(atPath: getAppFilePath(fileName).path)
} catch {
logger.error("FileUtils.removeFile error: \(error.localizedDescription)")
}
}
// image utils
func dropImagePrefix(_ s: String) -> String {
@@ -61,7 +135,7 @@ func cropToSquare(_ image: UIImage) -> UIImage {
return resizeImage(image, newBounds: CGRect(origin: .zero, size: newSize), drawIn: CGRect(origin: origin, size: size))
}
func resizeImageToDataSize(_ image: UIImage, maxDataSize: Int) -> Data? {
func resizeImageToDataSize(_ image: UIImage, maxDataSize: Int64) -> Data? {
var img = image
var data = img.jpegData(compressionQuality: 0.85)
var dataSize = data?.count ?? 0
@@ -76,7 +150,7 @@ func resizeImageToDataSize(_ image: UIImage, maxDataSize: Int) -> Data? {
return data
}
func resizeImageToStrSize(_ image: UIImage, maxDataSize: Int) -> String? {
func resizeImageToStrSize(_ image: UIImage, maxDataSize: Int64) -> String? {
var img = image
var str = compressImageStr(img)
var dataSize = str?.count ?? 0

View File

@@ -52,7 +52,7 @@ enum ChatCommand {
case let .apiGetChat(type, id): return "/_get chat \(ref(type, id)) count=100"
case let .apiSendMessage(type, id, file, quotedItemId, mc):
let msg = encodeJSON(ComposedMessage(filePath: file, quotedItemId: quotedItemId, msgContent: mc))
return "/_send_v2 \(ref(type, id)) \(msg)"
return "/_send \(ref(type, id)) json \(msg)"
case let .apiUpdateChatItem(type, id, itemId, mc): return "/_update item \(ref(type, id)) \(itemId) \(mc.cmdString)"
case let .apiDeleteChatItem(type, id, itemId, mode): return "/_delete item \(ref(type, id)) \(itemId) \(mode.rawValue)"
case let .apiRegisterToken(token): return "/_ntf register apns \(token)"

View File

@@ -647,6 +647,8 @@ struct CIFile: Decodable {
get {
switch self.fileStatus {
case .sndStored: return true
case .sndTransfer: return true
case .sndComplete: return true
case .sndCancelled: return true
case .rcvComplete: return true
default: return false
@@ -696,6 +698,13 @@ enum MsgContent {
}
}
func isFile() -> Bool {
switch self {
case .file: return true
default: return false
}
}
enum CodingKeys: String, CodingKey {
case type
case text

View File

@@ -65,17 +65,21 @@ func createNotification(categoryIdentifier: String, title: String, subtitle: Str
}
func hideSecrets(_ cItem: ChatItem) -> String {
if let md = cItem.formattedText {
var res = ""
for ft in md {
if case .secret = ft.format {
res = res + "..."
} else {
res = res + ft.text
if cItem.content.text != "" {
if let md = cItem.formattedText {
var res = ""
for ft in md {
if case .secret = ft.format {
res = res + "..."
} else {
res = res + ft.text
}
}
return res
} else {
return cItem.content.text
}
return res
} else {
return cItem.content.text
return cItem.file?.fileName ?? ""
}
}

View File

@@ -519,6 +519,17 @@ func processReceivedMsg(_ res: ChatResponse) {
chatItemSimpleUpdate(aChatItem)
case let .rcvFileComplete(aChatItem):
chatItemSimpleUpdate(aChatItem)
case let .sndFileStart(aChatItem, _):
chatItemSimpleUpdate(aChatItem)
case let .sndFileComplete(aChatItem, _):
chatItemSimpleUpdate(aChatItem)
let cItem = aChatItem.chatItem
if aChatItem.chatInfo.chatType == .direct,
let mc = cItem.content.msgContent,
mc.isFile(),
let fileName = cItem.file?.filePath {
removeFile(fileName)
}
default:
logger.debug("unsupported event: \(res.responseType)")
}

View File

@@ -57,6 +57,7 @@ struct FramedItemView: View {
}
case let .file(text):
CIFileView(file: chatItem.file, edited: chatItem.meta.itemEdited)
.overlay(DetermineWidth())
if text != "" {
ciMsgContentView (chatItem, showMember)
}
@@ -107,6 +108,16 @@ struct FramedItemView: View {
.aspectRatio(contentMode: .fill)
.frame(width: 68, height: 68)
.clipped()
} else if case .file = qi.content {
ciQuotedMsgView(qi)
.padding(.trailing, 20).frame(minWidth: msgWidth, alignment: .leading)
Image(systemName: "doc.fill")
.resizable()
.aspectRatio(contentMode: .fit)
.frame(width: 18, height: 18)
.foregroundColor(Color(uiColor: .tertiaryLabel))
.padding(.top, 6)
.padding(.trailing, 4)
} else {
ciQuotedMsgView(qi)
}

View File

@@ -116,7 +116,11 @@ struct ChatView: View {
if ci.isMsgContent() {
Button {
withAnimation {
composeState = composeState.copy(contextItem: .quotedItem(chatItem: ci))
if composeState.editing() {
composeState = ComposeState(contextItem: .quotedItem(chatItem: ci))
} else {
composeState = composeState.copy(contextItem: .quotedItem(chatItem: ci))
}
}
} label: { Label("Reply", systemImage: "arrowshape.turn.up.left") }
Button {

View File

@@ -12,6 +12,7 @@ enum ComposePreview {
case noPreview
case linkPreview(linkPreview: LinkPreview)
case imagePreview(imagePreview: String)
case filePreview(fileName: String)
}
enum ComposeContextItem {
@@ -65,6 +66,8 @@ struct ComposeState {
switch preview {
case .imagePreview:
return true
case .filePreview:
return true
default:
return !message.isEmpty
}
@@ -74,6 +77,8 @@ struct ComposeState {
switch preview {
case .imagePreview:
return false
case .filePreview:
return false
default:
return true
}
@@ -92,10 +97,14 @@ struct ComposeState {
func chatItemPreview(chatItem: ChatItem) -> ComposePreview {
let chatItemPreview: ComposePreview
switch chatItem.content.msgContent {
case .text:
chatItemPreview = .noPreview
case let .link(_, preview: preview):
chatItemPreview = .linkPreview(linkPreview: preview)
case let .image(_, image: image):
chatItemPreview = .imagePreview(imagePreview: image)
case .file:
chatItemPreview = .filePreview(fileName: chatItem.file?.fileName ?? "")
default:
chatItemPreview = .noPreview
}
@@ -116,12 +125,18 @@ struct ComposeView: View {
@State private var showChooseSource = false
@State private var showImagePicker = false
@State private var imageSource: ImageSource = .imageLibrary
@State var chosenImage: UIImage?
@State var chosenImage: UIImage? = nil
@State private var showFileImporter = false
@State var chosenFile: URL? = nil
var body: some View {
VStack(spacing: 0) {
contextItemView()
previewView()
switch (composeState.editing(), composeState.preview) {
case (true, .filePreview): EmptyView()
default: previewView()
}
HStack (alignment: .bottom) {
Button {
showChooseSource = true
@@ -163,6 +178,9 @@ struct ComposeView: View {
imageSource = .imageLibrary
showImagePicker = true
}
Button("Choose file") {
showFileImporter = true
}
}
.sheet(isPresented: $showImagePicker) {
switch imageSource {
@@ -182,6 +200,36 @@ struct ComposeView: View {
composeState = composeState.copy(preview: .noPreview)
}
}
.fileImporter(
isPresented: $showFileImporter,
allowedContentTypes: [.data],
allowsMultipleSelection: false
) { result in
if case .success = result {
do {
let fileURL: URL = try result.get().first!
var fileSize: Int? = nil
if fileURL.startAccessingSecurityScopedResource() {
let resourceValues = try fileURL.resourceValues(forKeys: [.fileSizeKey])
fileSize = resourceValues.fileSize
}
fileURL.stopAccessingSecurityScopedResource()
if let fileSize = fileSize,
fileSize <= maxFileSize {
chosenFile = fileURL
composeState = composeState.copy(preview: .filePreview(fileName: fileURL.lastPathComponent))
} else {
let prettyMaxFileSize = ByteCountFormatter().string(fromByteCount: maxFileSize)
AlertManager.shared.showAlertMsg(
title: "Large file!",
message: "Currently maximum supported file size is \(prettyMaxFileSize)."
)
}
} catch {
logger.error("ComposeView fileImporter error \(error.localizedDescription)")
}
}
}
}
@ViewBuilder func previewView() -> some View {
@@ -193,7 +241,18 @@ struct ComposeView: View {
case let .imagePreview(imagePreview: img):
ComposeImageView(
image: img,
cancelImage: { composeState = composeState.copy(preview: .noPreview) },
cancelImage: {
composeState = composeState.copy(preview: .noPreview)
chosenImage = nil
},
cancelEnabled: !composeState.editing())
case let .filePreview(fileName: fileName):
ComposeFileView(
fileName: fileName,
cancelFile: {
composeState = composeState.copy(preview: .noPreview)
chosenFile = nil
},
cancelEnabled: !composeState.editing())
}
}
@@ -203,9 +262,17 @@ struct ComposeView: View {
case .noContextItem:
EmptyView()
case let .quotedItem(chatItem: quotedItem):
ContextItemView(contextItem: quotedItem, cancelContextItem: { composeState = composeState.copy(contextItem: .noContextItem) })
ContextItemView(
contextItem: quotedItem,
contextIcon: "arrowshape.turn.up.left.circle",
cancelContextItem: { composeState = composeState.copy(contextItem: .noContextItem) }
)
case let .editingItem(chatItem: editingItem):
ContextItemView(contextItem: editingItem, cancelContextItem: { composeState = ComposeState()})
ContextItemView(
contextItem: editingItem,
contextIcon: "pencil.circle",
cancelContextItem: { clearState() }
)
}
}
@@ -241,6 +308,12 @@ struct ComposeView: View {
mc = .image(text: composeState.message, image: image)
file = savedFile
}
case .filePreview:
if let fileURL = chosenFile,
let savedFile = saveFileFromURL(fileURL) {
mc = .file(composeState.message)
file = savedFile
}
}
var quotedItemId: Int64? = nil
@@ -261,13 +334,24 @@ struct ComposeView: View {
chatModel.addChatItem(chat.chatInfo, chatItem)
}
}
composeState = ComposeState()
clearState()
} catch {
clearState()
logger.error("ChatView.sendMessage error: \(error.localizedDescription)")
}
}
}
private func clearState() {
composeState = ComposeState()
linkUrl = nil
prevLinkUrl = nil
pendingLinkUrl = nil
cancelledLinks = []
chosenImage = nil
chosenFile = nil
}
private func updateMsgContent(_ msgContent: MsgContent) -> MsgContent {
switch msgContent {
case .text:
@@ -283,22 +367,6 @@ struct ComposeView: View {
}
}
private func saveImage(_ uiImage: UIImage) -> String? {
if let imageDataResized = resizeImageToDataSize(uiImage, maxDataSize: maxImageSize) {
let millisecondsSince1970 = Int64((Date().timeIntervalSince1970 * 1000.0).rounded())
let fileToSave = "image_\(millisecondsSince1970).jpg"
let filePath = getAppFilesDirectory().appendingPathComponent(fileToSave)
do {
try imageDataResized.write(to: filePath)
return fileToSave
} catch {
logger.error("ChatView.saveImage error: \(error.localizedDescription)")
return nil
}
}
return nil
}
private func showLinkPreview(_ s: String) {
prevLinkUrl = linkUrl
linkUrl = parseMessage(s)

View File

@@ -11,10 +11,16 @@ import SwiftUI
struct ContextItemView: View {
@Environment(\.colorScheme) var colorScheme
let contextItem: ChatItem
let contextIcon: String
let cancelContextItem: () -> Void
var body: some View {
HStack {
Image(systemName: contextIcon)
.resizable()
.aspectRatio(contentMode: .fit)
.frame(width: 20, height: 20)
.foregroundColor(.secondary)
contextText(contextItem).lineLimit(3)
Spacer()
Button {
@@ -26,6 +32,7 @@ struct ContextItemView: View {
}
}
.padding(12)
.frame(minHeight: 50)
.frame(maxWidth: .infinity)
.background(chatItemFrameColor(contextItem, colorScheme))
.padding(.top, 8)
@@ -33,9 +40,9 @@ struct ContextItemView: View {
func contextText(_ cxtItem: ChatItem) -> some View {
if let s = cxtItem.memberDisplayName {
return (Text(s).fontWeight(.medium) + Text(": \(cxtItem.content.text)"))
return (Text(s).fontWeight(.medium) + Text(": \(cxtItem.text)"))
} else {
return Text(cxtItem.content.text)
return Text(cxtItem.text)
}
}
}
@@ -43,6 +50,6 @@ struct ContextItemView: View {
struct ContextItemView_Previews: PreviewProvider {
static var previews: some View {
let contextItem: ChatItem = ChatItem.getSample(1, .directSnd, .now, "hello")
return ContextItemView(contextItem: contextItem, cancelContextItem: {})
return ContextItemView(contextItem: contextItem, contextIcon: "pencil.circle", cancelContextItem: {})
}
}

View File

@@ -23,12 +23,13 @@ struct CIFileView: View {
.padding(.top, 5)
.padding(.bottom, 3)
if let file = file {
let prettyFileSize = ByteCountFormatter().string(fromByteCount: file.fileSize)
VStack(alignment: .leading, spacing: 2) {
Text(file.fileName)
.lineLimit(1)
.multilineTextAlignment(.leading)
.foregroundColor(.primary)
Text(formatBytes(bytes: file.fileSize) + metaReserve)
Text(prettyFileSize + metaReserve)
.font(.caption)
.lineLimit(1)
.multilineTextAlignment(.leading)
@@ -64,9 +65,10 @@ struct CIFileView: View {
await receiveFile(fileId: file.fileId)
}
} else {
let prettyMaxFileSize = ByteCountFormatter().string(fromByteCount: maxFileSize)
AlertManager.shared.showAlertMsg(
title: "Large file!",
message: "Your contact sent a file that is larger than currently supported maximum size (\(maxFileSize) bytes)."
message: "Your contact sent a file that is larger than currently supported maximum size (\(prettyMaxFileSize))."
)
}
case .rcvAccepted:
@@ -88,6 +90,9 @@ struct CIFileView: View {
@ViewBuilder func fileIndicator() -> some View {
if let file = file {
switch file.fileStatus {
case .sndStored: fileIcon("doc.fill")
case .sndTransfer: ProgressView().frame(width: 30, height: 30)
case .sndComplete: fileIcon("doc.fill", innerIcon: "checkmark", innerIconSize: 10)
case .sndCancelled: fileIcon("doc.fill", innerIcon: "xmark", innerIconSize: 10)
case .rcvInvitation:
if fileSizeValid() {
@@ -97,15 +102,15 @@ struct CIFileView: View {
}
case .rcvAccepted: fileIcon("doc.fill", innerIcon: "ellipsis", innerIconSize: 12)
case .rcvTransfer: ProgressView().frame(width: 30, height: 30)
case .rcvComplete: fileIcon("arrow.down.doc.fill", innerIconSize: 10)
case .rcvCancelled: fileIcon("doc.fill", innerIcon: "xmark", innerIconSize: 10)
default: fileIcon("doc.fill")
}
} else {
fileIcon("doc.fill")
}
}
func fileIcon(_ icon: String, color: Color = .secondary, innerIcon: String? = nil, innerIconSize: CGFloat? = nil) -> some View {
func fileIcon(_ icon: String, color: Color = Color(uiColor: .tertiaryLabel), innerIcon: String? = nil, innerIconSize: CGFloat? = nil) -> some View {
ZStack(alignment: .center) {
Image(systemName: icon)
.resizable()
@@ -124,24 +129,6 @@ struct CIFileView: View {
}
}
}
func formatBytes(bytes: Int64) -> String {
if (bytes == 0) { return "0 bytes" }
let bytesDouble = Double(bytes)
let k: Double = 1000
let units = ["bytes", "KB", "MB", "GB", "TB", "PB", "EB", "ZB", "YB"]
let i = floor(log2(bytesDouble) / log2(k))
let size = bytesDouble / pow(k, i)
let unit = units[Int(i)]
if (i <= 1) {
return String(format: "%.0f \(unit)", size)
} else {
return String(format: "%.2f \(unit)", size)
}
}
}
struct CIFileView_Previews: PreviewProvider {
@@ -151,7 +138,7 @@ struct CIFileView_Previews: PreviewProvider {
meta: CIMeta.getSample(1, .now, "", .sndSent, false, true, false),
content: .sndMsgContent(msgContent: .file("")),
quotedItem: nil,
file: CIFile.getSample(fileStatus: .sndStored)
file: CIFile.getSample(fileStatus: .sndComplete)
)
let fileChatItemWtFile = ChatItem(
chatDir: .directRcv,
@@ -167,7 +154,7 @@ struct CIFileView_Previews: PreviewProvider {
ChatItemView(chatItem: ChatItem.getFileMsgContentSample(fileStatus: .rcvAccepted))
ChatItemView(chatItem: ChatItem.getFileMsgContentSample(fileStatus: .rcvTransfer))
ChatItemView(chatItem: ChatItem.getFileMsgContentSample(fileStatus: .rcvCancelled))
ChatItemView(chatItem: ChatItem.getFileMsgContentSample(fileSize: 2000000, fileStatus: .rcvInvitation))
ChatItemView(chatItem: ChatItem.getFileMsgContentSample(fileSize: 1_000_000_000, fileStatus: .rcvInvitation))
ChatItemView(chatItem: ChatItem.getFileMsgContentSample(text: "Hello there", fileStatus: .rcvInvitation))
ChatItemView(chatItem: ChatItem.getFileMsgContentSample(text: "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.", fileStatus: .rcvInvitation))
ChatItemView(chatItem: fileChatItemWtFile)

View File

@@ -62,24 +62,38 @@ struct CIImageView: View {
.scaledToFit()
.frame(maxWidth: w)
loadingIndicator()
.padding(8)
}
}
@ViewBuilder private func loadingIndicator() -> some View {
if let file = file {
switch file.fileStatus {
case .sndTransfer:
ProgressView()
.progressViewStyle(.circular)
.frame(width: 20, height: 20)
.tint(.white)
.padding(8)
case .sndComplete:
Image(systemName: "checkmark")
.resizable()
.aspectRatio(contentMode: .fit)
.frame(width: 10, height: 10)
.foregroundColor(.white)
.padding(13)
case .rcvAccepted:
Image(systemName: "ellipsis")
.resizable()
.aspectRatio(contentMode: .fit)
.frame(width: 20, height: 20)
.frame(width: 14, height: 14)
.foregroundColor(.white)
.padding(11)
case .rcvTransfer:
ProgressView()
.progressViewStyle(.circular)
.frame(width: 20, height: 20)
.tint(.white)
.padding(8)
default: EmptyView()
}
}

View File

@@ -0,0 +1,40 @@
//
// ComposeFileView.swift
// SimpleX (iOS)
//
// Created by JRoberts on 04.05.2022.
// Copyright © 2022 SimpleX Chat. All rights reserved.
//
import SwiftUI
struct ComposeFileView: View {
@Environment(\.colorScheme) var colorScheme
let fileName: String
let cancelFile: (() -> Void)
let cancelEnabled: Bool
var body: some View {
HStack(alignment: .center, spacing: 4) {
Image(systemName: "doc.fill")
.resizable()
.aspectRatio(contentMode: .fit)
.frame(width: 30, height: 30)
.foregroundColor(Color(uiColor: .tertiaryLabel))
.padding(.leading, 4)
Text(fileName)
Spacer()
if cancelEnabled {
Button { cancelFile() } label: {
Image(systemName: "multiply")
}
}
}
.padding(.vertical, 1)
.padding(.trailing, 12)
.frame(height: 50)
.background(colorScheme == .light ? sentColorLight : sentColorDark)
.frame(maxWidth: .infinity)
.padding(.top, 8)
}
}

View File

@@ -93,6 +93,7 @@
5CEACCE327DE9246000BD591 /* ComposeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CEACCE227DE9246000BD591 /* ComposeView.swift */; };
5CEACCED27DEA495000BD591 /* MsgContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CEACCEC27DEA495000BD591 /* MsgContentView.swift */; };
640F50E327CF991C001E05C2 /* SMPServers.swift in Sources */ = {isa = PBXBuildFile; fileRef = 640F50E227CF991C001E05C2 /* SMPServers.swift */; };
6454036F2822A9750090DDFF /* ComposeFileView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6454036E2822A9750090DDFF /* ComposeFileView.swift */; };
648010AB281ADD15009009B9 /* CIFileView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 648010AA281ADD15009009B9 /* CIFileView.swift */; };
649BCDA0280460FD00C3A862 /* ComposeImageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 649BCD9F280460FD00C3A862 /* ComposeImageView.swift */; };
649BCDA22805D6EF00C3A862 /* CIImageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 649BCDA12805D6EF00C3A862 /* CIImageView.swift */; };
@@ -214,6 +215,7 @@
5CEACCE227DE9246000BD591 /* ComposeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeView.swift; sourceTree = "<group>"; };
5CEACCEC27DEA495000BD591 /* MsgContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MsgContentView.swift; sourceTree = "<group>"; };
640F50E227CF991C001E05C2 /* SMPServers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SMPServers.swift; sourceTree = "<group>"; };
6454036E2822A9750090DDFF /* ComposeFileView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeFileView.swift; sourceTree = "<group>"; };
648010AA281ADD15009009B9 /* CIFileView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CIFileView.swift; sourceTree = "<group>"; };
6493D667280ED77F007A76FB /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = en; path = en.lproj/Localizable.strings; sourceTree = "<group>"; };
649BCD9F280460FD00C3A862 /* ComposeImageView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeImageView.swift; sourceTree = "<group>"; };
@@ -350,6 +352,7 @@
649BCD9F280460FD00C3A862 /* ComposeImageView.swift */,
649BCDA12805D6EF00C3A862 /* CIImageView.swift */,
648010AA281ADD15009009B9 /* CIFileView.swift */,
6454036E2822A9750090DDFF /* ComposeFileView.swift */,
);
path = Helpers;
sourceTree = "<group>";
@@ -676,6 +679,7 @@
5C2E260F27A30FDC00F70299 /* ChatView.swift in Sources */,
5C2E260B27A30CFA00F70299 /* ChatListView.swift in Sources */,
5C971E2127AEBF8300C8A3CE /* ChatInfoImage.swift in Sources */,
6454036F2822A9750090DDFF /* ComposeFileView.swift in Sources */,
5C5F2B6D27EBC3FE006A9D5F /* ImagePicker.swift in Sources */,
5C577F7D27C83AA10006112D /* MarkdownHelp.swift in Sources */,
5CA059EB279559F40002BEB4 /* SimpleXApp.swift in Sources */,