Merge branch 'master' into webrtc-calls

This commit is contained in:
Evgeny Poberezkin
2022-05-04 13:39:40 +01:00
25 changed files with 857 additions and 172 deletions

View File

@@ -675,6 +675,21 @@ data class ChatItem (
file = file
)
fun getFileMsgContentSample(
id: Long = 1,
text: String = "",
fileName: String = "test.txt",
fileSize: Long = 100,
fileStatus: CIFileStatus = CIFileStatus.RcvComplete
) =
ChatItem(
chatDir = CIDirection.DirectRcv(),
meta = CIMeta.getSample(id, Clock.System.now(), text, CIStatus.RcvRead(), itemDeleted = false, itemEdited = false, editable = false),
content = CIContent.RcvMsgContent(msgContent = MsgContent.MCFile(text)),
quotedItem = null,
file = CIFile.getSample(fileName = fileName, fileSize = fileSize, fileStatus = fileStatus)
)
fun getDeletedContentSampleData(
id: Long = 1,
dir: CIDirection = CIDirection.DirectRcv(),
@@ -858,7 +873,13 @@ class CIFile(
}
companion object {
fun getSample(fileId: Long, fileName: String, fileSize: Long, filePath: String?, fileStatus: CIFileStatus = CIFileStatus.SndStored): CIFile =
fun getSample(
fileId: Long = 1,
fileName: String = "test.txt",
fileSize: Long = 100,
filePath: String? = "test.txt",
fileStatus: CIFileStatus = CIFileStatus.RcvComplete
): CIFile =
CIFile(fileId = fileId, fileName = fileName, fileSize = fileSize, filePath = filePath, fileStatus = fileStatus)
}
}
@@ -868,6 +889,7 @@ enum class CIFileStatus {
@SerialName("snd_stored") SndStored,
@SerialName("snd_cancelled") SndCancelled,
@SerialName("rcv_invitation") RcvInvitation,
@SerialName("rcv_accepted") RcvAccepted,
@SerialName("rcv_transfer") RcvTransfer,
@SerialName("rcv_complete") RcvComplete,
@SerialName("rcv_cancelled") RcvCancelled;
@@ -887,6 +909,9 @@ sealed class MsgContent {
@Serializable(with = MsgContentSerializer::class)
class MCImage(override val text: String, val image: String): MsgContent()
@Serializable(with = MsgContentSerializer::class)
class MCFile(override val text: String): MsgContent()
@Serializable(with = MsgContentSerializer::class)
class MCUnknown(val type: String? = null, override val text: String, val json: JsonElement): MsgContent()
@@ -894,6 +919,7 @@ sealed class MsgContent {
is MCText -> "text $text"
is MCLink -> "json ${json.encodeToString(this)}"
is MCImage -> "json ${json.encodeToString(this)}"
is MCFile -> "json ${json.encodeToString(this)}"
is MCUnknown -> "json $json"
}
}
@@ -911,6 +937,9 @@ object MsgContentSerializer : KSerializer<MsgContent> {
element<String>("text")
element<String>("image")
})
element("MCFile", buildClassSerialDescriptor("MCFile") {
element<String>("text")
})
element("MCUnknown", buildClassSerialDescriptor("MCUnknown"))
}
@@ -931,6 +960,7 @@ object MsgContentSerializer : KSerializer<MsgContent> {
val image = json["image"]?.jsonPrimitive?.content ?: "unknown message format"
MsgContent.MCImage(text, image)
}
"file" -> MsgContent.MCFile(text)
else -> MsgContent.MCUnknown(t, text, json)
}
} else {
@@ -961,6 +991,11 @@ object MsgContentSerializer : KSerializer<MsgContent> {
put("text", value.text)
put("image", value.image)
}
is MsgContent.MCFile ->
buildJsonObject {
put("type", "file")
put("text", value.text)
}
is MsgContent.MCUnknown -> value.json
}
encoder.encodeJsonElement(json)

View File

@@ -327,11 +327,11 @@ open class ChatController(private val ctrl: ChatCtrl, private val ntfManager: Nt
return false
}
suspend fun receiveFile(fileId: Long): Boolean {
suspend fun apiReceiveFile(fileId: Long): AChatItem? {
val r = sendCmd(CC.ReceiveFile(fileId))
if (r is CR.RcvFileAccepted) return true
if (r is CR.RcvFileAccepted) return r.chatItem
Log.e(TAG, "receiveFile bad response: ${r.responseType} ${r.details}")
return false
return null
}
fun apiErrorAlert(method: String, title: String, r: CR) {
@@ -390,8 +390,13 @@ open class ChatController(private val ctrl: ChatCtrl, private val ntfManager: Nt
val cItem = r.chatItem.chatItem
chatModel.addChatItem(cInfo, cItem)
val file = cItem.file
if (file != null && file.fileSize <= MAX_IMAGE_SIZE) {
withApi {receiveFile(file.fileId)}
if (cItem.content.msgContent is MsgContent.MCImage && file != null && file.fileSize <= MAX_IMAGE_SIZE) {
withApi {
val chatItem = apiReceiveFile(file.fileId)
if (chatItem != null) {
chatItemSimpleUpdate(chatItem)
}
}
}
if (!isAppOnForeground(appContext) || chatModel.chatId.value != cInfo.id) {
ntfManager.notifyMessageReceived(cInfo, cItem)
@@ -408,13 +413,8 @@ open class ChatController(private val ctrl: ChatCtrl, private val ntfManager: Nt
ntfManager.notifyMessageReceived(cInfo, cItem)
}
}
is CR.ChatItemUpdated -> {
val cInfo = r.chatItem.chatInfo
val cItem = r.chatItem.chatItem
if (chatModel.upsertChatItem(cInfo, cItem)) {
ntfManager.notifyMessageReceived(cInfo, cItem)
}
}
is CR.ChatItemUpdated ->
chatItemSimpleUpdate(r.chatItem)
is CR.ChatItemDeleted -> {
val cInfo = r.toChatItem.chatInfo
val cItem = r.toChatItem.chatItem
@@ -425,18 +425,23 @@ open class ChatController(private val ctrl: ChatCtrl, private val ntfManager: Nt
chatModel.upsertChatItem(cInfo, cItem)
}
}
is CR.RcvFileComplete -> {
val cInfo = r.chatItem.chatInfo
val cItem = r.chatItem.chatItem
if (chatModel.upsertChatItem(cInfo, cItem)) {
ntfManager.notifyMessageReceived(cInfo, cItem)
}
}
is CR.RcvFileStart ->
chatItemSimpleUpdate(r.chatItem)
is CR.RcvFileComplete ->
chatItemSimpleUpdate(r.chatItem)
else ->
Log.d(TAG , "unsupported event: ${r.responseType}")
}
}
private fun chatItemSimpleUpdate(aChatItem: AChatItem) {
val cInfo = aChatItem.chatInfo
val cItem = aChatItem.chatItem
if (chatModel.upsertChatItem(cInfo, cItem)) {
ntfManager.notifyMessageReceived(cInfo, cItem)
}
}
fun updateContactsStatus(contactRefs: List<ContactRef>, status: Chat.NetworkStatus) {
for (c in contactRefs) {
chatModel.updateNetworkStatus(c.id, status)
@@ -681,7 +686,8 @@ sealed class CR {
@Serializable @SerialName("chatItemStatusUpdated") class ChatItemStatusUpdated(val chatItem: AChatItem): CR()
@Serializable @SerialName("chatItemUpdated") class ChatItemUpdated(val chatItem: AChatItem): CR()
@Serializable @SerialName("chatItemDeleted") class ChatItemDeleted(val deletedChatItem: AChatItem, val toChatItem: AChatItem): CR()
@Serializable @SerialName("rcvFileAccepted") class RcvFileAccepted: CR()
@Serializable @SerialName("rcvFileAccepted") class RcvFileAccepted(val chatItem: AChatItem): CR()
@Serializable @SerialName("rcvFileStart") class RcvFileStart(val chatItem: AChatItem): CR()
@Serializable @SerialName("rcvFileComplete") class RcvFileComplete(val chatItem: AChatItem): CR()
@Serializable @SerialName("newContactConnection") class NewContactConnection(val connection: PendingContactConnection): CR()
@Serializable @SerialName("contactConnectionDeleted") class ContactConnectionDeleted(val connection: PendingContactConnection): CR()
@@ -728,6 +734,7 @@ sealed class CR {
is ChatItemUpdated -> "chatItemUpdated"
is ChatItemDeleted -> "chatItemDeleted"
is RcvFileAccepted -> "rcvFileAccepted"
is RcvFileStart -> "rcvFileStart"
is RcvFileComplete -> "rcvFileComplete"
is NewContactConnection -> "newContactConnection"
is ContactConnectionDeleted -> "contactConnectionDeleted"
@@ -774,7 +781,8 @@ sealed class CR {
is ChatItemStatusUpdated -> json.encodeToString(chatItem)
is ChatItemUpdated -> json.encodeToString(chatItem)
is ChatItemDeleted -> "deletedChatItem:\n${json.encodeToString(deletedChatItem)}\ntoChatItem:\n${json.encodeToString(toChatItem)}"
is RcvFileAccepted -> noDetails()
is RcvFileAccepted -> json.encodeToString(chatItem)
is RcvFileStart -> json.encodeToString(chatItem)
is RcvFileComplete -> json.encodeToString(chatItem)
is NewContactConnection -> json.encodeToString(connection)
is ContactConnectionDeleted -> json.encodeToString(connection)

View File

@@ -15,3 +15,4 @@ val DarkGray = Color(43, 44, 46, 255)
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)

View File

@@ -102,6 +102,16 @@ fun ChatView(chatModel: ChatModel) {
)
if (toItem != null) chatModel.removeChatItem(cInfo, toItem.chatItem)
}
},
receiveFile = { fileId ->
withApi {
val chatItem = chatModel.controller.apiReceiveFile(fileId)
if (chatItem != null) {
val cInfo = chatItem.chatInfo
val cItem = chatItem.chatItem
chatModel.upsertChatItem(cInfo, cItem)
}
}
}
)
}
@@ -120,7 +130,8 @@ fun ChatLayout(
back: () -> Unit,
info: () -> Unit,
openDirectChat: (Long) -> Unit,
deleteMessage: (Long, CIDeleteMode) -> Unit
deleteMessage: (Long, CIDeleteMode) -> Unit,
receiveFile: (Long) -> Unit
) {
fun onImageChange(bitmap: Bitmap) {
val imagePreview = resizeImageToStrSize(bitmap, maxDataSize = 14000)
@@ -153,7 +164,7 @@ fun ChatLayout(
modifier = Modifier.navigationBarsWithImePadding()
) { contentPadding ->
Box(Modifier.padding(contentPadding)) {
ChatItemsList(user, chat, composeState, chatItems, openDirectChat, deleteMessage)
ChatItemsList(user, chat, composeState, chatItems, openDirectChat, deleteMessage, receiveFile)
}
}
}
@@ -230,7 +241,8 @@ fun ChatItemsList(
composeState: MutableState<ComposeState>,
chatItems: List<ChatItem>,
openDirectChat: (Long) -> Unit,
deleteMessage: (Long, CIDeleteMode) -> Unit
deleteMessage: (Long, CIDeleteMode) -> Unit,
receiveFile: (Long) -> Unit
) {
val listState = rememberLazyListState(initialFirstVisibleItemIndex = chatItems.size - chatItems.count { it.isRcvNew })
val keyboardState by getKeyboardState()
@@ -268,11 +280,11 @@ fun ChatItemsList(
} else {
Spacer(Modifier.size(42.dp))
}
ChatItemView(user, cItem, composeState, cxt, uriHandler, showMember = showMember, deleteMessage = deleteMessage)
ChatItemView(user, cItem, composeState, cxt, uriHandler, showMember = showMember, deleteMessage = deleteMessage, receiveFile = receiveFile)
}
} else {
Box(Modifier.padding(start = 86.dp, end = 12.dp)) {
ChatItemView(user, cItem, composeState, cxt, uriHandler, deleteMessage = deleteMessage)
ChatItemView(user, cItem, composeState, cxt, uriHandler, deleteMessage = deleteMessage, receiveFile = receiveFile)
}
}
} else { // direct message
@@ -283,7 +295,7 @@ fun ChatItemsList(
end = if (sent) 12.dp else 76.dp,
)
) {
ChatItemView(user, cItem, composeState, cxt, uriHandler, deleteMessage = deleteMessage)
ChatItemView(user, cItem, composeState, cxt, uriHandler, deleteMessage = deleteMessage, receiveFile = receiveFile)
}
}
}
@@ -350,7 +362,8 @@ fun PreviewChatLayout() {
back = {},
info = {},
openDirectChat = {},
deleteMessage = { _, _ -> }
deleteMessage = { _, _ -> },
receiveFile = {}
)
}
}
@@ -393,7 +406,8 @@ fun PreviewGroupChatLayout() {
back = {},
info = {},
openDirectChat = {},
deleteMessage = { _, _ -> }
deleteMessage = { _, _ -> },
receiveFile = {}
)
}
}

View File

@@ -164,6 +164,7 @@ fun ComposeView(
is MsgContent.MCText -> checkLinkPreview()
is MsgContent.MCLink -> checkLinkPreview()
is MsgContent.MCImage -> MsgContent.MCImage(cs.message, image = msgContent.image)
is MsgContent.MCFile -> MsgContent.MCFile(cs.message)
is MsgContent.MCUnknown -> MsgContent.MCUnknown(type = msgContent.type, text = cs.message, json = msgContent.json)
}
}

View File

@@ -0,0 +1,222 @@
import android.widget.Toast
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.clickable
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.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.*
import androidx.compose.ui.unit.dp
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(
file: CIFile?,
edited: Boolean,
receiveFile: (Long) -> Unit
) {
val context = LocalContext.current
val saveFileLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.CreateDocument(),
onResult = { destination ->
saveFile(context, file, destination)
}
)
@Composable
fun fileIcon(innerIcon: ImageVector? = null, color: Color = HighOrLowlight) {
Box(
contentAlignment = Alignment.Center
) {
Icon(
Icons.Filled.InsertDriveFile,
stringResource(R.string.icon_descr_file),
Modifier.fillMaxSize(),
tint = color
)
if (innerIcon != null) {
Icon(
innerIcon,
stringResource(R.string.icon_descr_file),
Modifier
.size(32.dp)
.padding(top = 12.dp),
tint = Color.White
)
}
}
}
fun fileSizeValid(): Boolean {
if (file != null) {
return file.fileSize <= MAX_FILE_SIZE
}
return false
}
fun fileAction() {
if (file != null) {
when (file.fileStatus) {
CIFileStatus.RcvInvitation -> {
if (fileSizeValid()) {
receiveFile(file.fileId)
} else {
AlertManager.shared.showAlertMsg(
generalGetString(R.string.large_file),
String.format(generalGetString(R.string.contact_sent_large_file), MAX_FILE_SIZE)
)
}
}
CIFileStatus.RcvAccepted ->
AlertManager.shared.showAlertMsg(
generalGetString(R.string.waiting_for_file),
String.format(generalGetString(R.string.file_will_be_received_when_contact_is_online), MAX_FILE_SIZE)
)
CIFileStatus.RcvComplete -> {
val filePath = getStoredFilePath(context, file)
if (filePath != null) {
saveFileLauncher.launch(file.fileName)
} else {
Toast.makeText(context, generalGetString(R.string.file_not_found), Toast.LENGTH_SHORT).show()
}
}
else -> {}
}
}
}
@Composable
fun fileIndicator() {
Box(
Modifier.size(44.dp),
contentAlignment = Alignment.Center
) {
if (file != null) {
when (file.fileStatus) {
CIFileStatus.SndCancelled -> fileIcon(innerIcon = Icons.Outlined.Close)
CIFileStatus.RcvInvitation ->
if (fileSizeValid())
fileIcon(innerIcon = Icons.Outlined.ArrowDownward, color = MaterialTheme.colors.primary)
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.RcvCancelled -> fileIcon(innerIcon = Icons.Outlined.Close)
else -> fileIcon()
}
} else {
fileIcon()
}
}
}
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() }),
verticalAlignment = Alignment.Bottom,
horizontalArrangement = Arrangement.spacedBy(4.dp)
) {
fileIndicator()
val metaReserve = if (edited)
" "
else
" "
if (file != null) {
Column(
horizontalAlignment = Alignment.Start
) {
Text(
file.fileName,
maxLines = 1
)
Text(
formatBytes(file.fileSize) + metaReserve,
color = HighOrLowlight,
fontSize = 14.sp,
maxLines = 1
)
}
} else {
Text(metaReserve)
}
}
}
class ChatItemProvider: PreviewParameterProvider<ChatItem> {
private val sentFile = ChatItem(
chatDir = CIDirection.DirectSnd(),
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)
)
private val fileChatItemWtFile = ChatItem(
chatDir = CIDirection.DirectRcv(),
meta = CIMeta.getSample(1, Clock.System.now(), "", CIStatus.RcvRead(), itemDeleted = false, itemEdited = false, editable = false),
content = CIContent.RcvMsgContent(msgContent = MsgContent.MCFile("")),
quotedItem = null,
file = null
)
override val values = listOf(
sentFile,
ChatItem.getFileMsgContentSample(),
ChatItem.getFileMsgContentSample(fileName = "some_long_file_name_here", fileStatus = CIFileStatus.RcvInvitation),
ChatItem.getFileMsgContentSample(fileStatus = CIFileStatus.RcvAccepted),
ChatItem.getFileMsgContentSample(fileStatus = CIFileStatus.RcvTransfer),
ChatItem.getFileMsgContentSample(fileStatus = CIFileStatus.RcvCancelled),
ChatItem.getFileMsgContentSample(fileSize = 2000000, 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
).asSequence()
}
@Preview
@Composable
fun PreviewTextItemViewSnd(@PreviewParameter(ChatItemProvider::class) chatItem: ChatItem) {
val showMenu = remember { mutableStateOf(false) }
SimpleXTheme {
FramedItemView(User.sampleData, chatItem, showMenu = showMenu, receiveFile = {})
}
}

View File

@@ -1,15 +1,21 @@
import android.graphics.Bitmap
import androidx.compose.foundation.*
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.outlined.MoreHoriz
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import chat.simplex.app.R
import chat.simplex.app.model.CIFile
import chat.simplex.app.model.*
import chat.simplex.app.views.helpers.*
@Composable
@@ -18,12 +24,37 @@ fun CIImageView(
file: CIFile?,
showMenu: MutableState<Boolean>
) {
Column {
val context = LocalContext.current
var imageBitmap: Bitmap? = getStoredImage(context, file)
if (imageBitmap == null) {
imageBitmap = base64ToBitmap(image)
@Composable
fun loadingIndicator() {
if (file != null) {
Box(
Modifier
.padding(8.dp)
.size(20.dp),
contentAlignment = Alignment.Center
) {
when (file.fileStatus) {
CIFileStatus.RcvAccepted ->
Icon(
Icons.Outlined.MoreHoriz,
stringResource(R.string.icon_descr_waiting_for_image),
Modifier.fillMaxSize(),
tint = Color.White
)
CIFileStatus.RcvTransfer ->
CircularProgressIndicator(
Modifier.size(16.dp),
color = Color.White,
strokeWidth = 2.dp
)
else -> {}
}
}
}
}
@Composable
fun imageView(imageBitmap: Bitmap, onClick: () -> Unit) {
Image(
imageBitmap.asImageBitmap(),
contentDescription = stringResource(R.string.image_descr),
@@ -33,13 +64,30 @@ fun CIImageView(
.width(1000.dp)
.combinedClickable(
onLongClick = { showMenu.value = true },
onClick = {
if (getStoredFilePath(context, file) != null) {
ModalManager.shared.showCustomModal { close -> ImageFullScreenView(imageBitmap, close) }
}
}
onClick = onClick
),
contentScale = ContentScale.FillWidth,
)
}
Box(contentAlignment = Alignment.TopEnd) {
val context = LocalContext.current
val imageBitmap: Bitmap? = getStoredImage(context, file)
if (imageBitmap != null) {
imageView(imageBitmap, onClick = {
if (getStoredFilePath(context, file) != null) {
ModalManager.shared.showCustomModal { close -> ImageFullScreenView(imageBitmap, close) }
}
})
} else {
imageView(base64ToBitmap(image), onClick = {
if (file != null && file.fileStatus == CIFileStatus.RcvAccepted)
AlertManager.shared.showAlertMsg(
generalGetString(R.string.waiting_for_image),
generalGetString(R.string.image_will_be_received_when_contact_is_online)
)
})
}
loadingIndicator()
}
}

View File

@@ -1,6 +1,8 @@
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.material.*
@@ -33,11 +35,19 @@ fun ChatItemView(
cxt: Context,
uriHandler: UriHandler? = null,
showMember: Boolean = false,
deleteMessage: (Long, CIDeleteMode) -> Unit
deleteMessage: (Long, CIDeleteMode) -> Unit,
receiveFile: (Long) -> Unit
) {
val context = LocalContext.current
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)
}
)
Box(
modifier = Modifier
.padding(bottom = 4.dp)
@@ -49,7 +59,7 @@ fun ChatItemView(
if (cItem.file == null && cItem.quotedItem == null && isShortEmoji(cItem.content.text)) {
EmojiItemView(cItem)
} else {
FramedItemView(user, cItem, uriHandler, showMember = showMember, showMenu)
FramedItemView(user, cItem, uriHandler, showMember = showMember, showMenu, receiveFile)
}
} else if (cItem.isDeletedContent) {
DeletedItemView(cItem, showMember = showMember)
@@ -72,6 +82,15 @@ fun ChatItemView(
copyText(cxt, cItem.content.text)
showMenu.value = false
})
if (cItem.content.msgContent is MsgContent.MCImage) {
val filePath = getStoredFilePath(context, cItem.file)
if (filePath != null) {
ItemAction(stringResource(R.string.save_verb), Icons.Outlined.SaveAlt, onClick = {
saveFileLauncher.launch(cItem.file?.fileName)
showMenu.value = false
})
}
}
if (cItem.chatDir.sent && cItem.meta.editable) {
ItemAction(stringResource(R.string.edit_verb), Icons.Filled.Edit, onClick = {
composeState.value = ComposeState(editingItem = cItem)
@@ -147,7 +166,8 @@ fun PreviewChatItemView() {
),
composeState = remember { mutableStateOf(ComposeState()) },
cxt = LocalContext.current,
deleteMessage = { _, _ -> }
deleteMessage = { _, _ -> },
receiveFile = {}
)
}
}
@@ -161,7 +181,8 @@ fun PreviewChatItemViewDeletedContent() {
ChatItem.getDeletedContentSampleData(),
composeState = remember { mutableStateOf(ComposeState()) },
cxt = LocalContext.current,
deleteMessage = { _, _ -> }
deleteMessage = { _, _ -> },
receiveFile = {}
)
}
}

View File

@@ -1,5 +1,6 @@
package chat.simplex.app.views.chat.item
import CIFileView
import CIImageView
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
@@ -38,7 +39,8 @@ fun FramedItemView(
ci: ChatItem,
uriHandler: UriHandler? = null,
showMember: Boolean = false,
showMenu: MutableState<Boolean>
showMenu: MutableState<Boolean>,
receiveFile: (Long) -> Unit
) {
val sent = ci.chatDir.sent
Surface(
@@ -101,6 +103,12 @@ fun FramedItemView(
CIMarkdownText(ci, showMember, uriHandler)
}
}
is MsgContent.MCFile -> {
CIFileView(ci.file, ci.meta.itemEdited, receiveFile)
if (mc.text != "") {
CIMarkdownText(ci, showMember, uriHandler)
}
}
is MsgContent.MCLink -> {
ChatItemLinkView(mc.preview)
CIMarkdownText(ci, showMember, uriHandler)
@@ -141,7 +149,8 @@ fun PreviewTextItemViewSnd(@PreviewParameter(EditedProvider::class) edited: Bool
ChatItem.getSampleData(
1, CIDirection.DirectSnd(), Clock.System.now(), "hello", itemEdited = edited,
),
showMenu = showMenu
showMenu = showMenu,
receiveFile = {}
)
}
}
@@ -156,7 +165,8 @@ fun PreviewTextItemViewRcv(@PreviewParameter(EditedProvider::class) edited: Bool
ChatItem.getSampleData(
1, CIDirection.DirectRcv(), Clock.System.now(), "hello", itemEdited = edited
),
showMenu = showMenu
showMenu = showMenu,
receiveFile = {}
)
}
}
@@ -175,7 +185,8 @@ fun PreviewTextItemViewLong(@PreviewParameter(EditedProvider::class) edited: Boo
"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.",
itemEdited = edited
),
showMenu = showMenu
showMenu = showMenu,
receiveFile = {}
)
}
}
@@ -195,7 +206,8 @@ fun PreviewTextItemViewQuote(@PreviewParameter(EditedProvider::class) edited: Bo
quotedItem = CIQuote.getSample(1, Clock.System.now(), "hi", chatDir = CIDirection.DirectRcv()),
itemEdited = edited
),
showMenu = showMenu
showMenu = showMenu,
receiveFile = {}
)
}
}
@@ -215,7 +227,8 @@ fun PreviewTextItemViewEmoji(@PreviewParameter(EditedProvider::class) edited: Bo
quotedItem = CIQuote.getSample(1, Clock.System.now(), "Lorem ipsum dolor sit amet", chatDir = CIDirection.DirectRcv()),
itemEdited = edited
),
showMenu = showMenu
showMenu = showMenu,
receiveFile = {}
)
}
}

View File

@@ -1,7 +1,13 @@
package chat.simplex.app.views.helpers
import android.content.*
import android.net.Uri
import android.widget.Toast
import androidx.core.content.ContextCompat
import chat.simplex.app.R
import chat.simplex.app.model.CIFile
import java.io.File
import java.io.IOException
fun shareText(cxt: Context, text: String) {
val sendIntent: Intent = Intent().apply {
@@ -17,3 +23,25 @@ fun copyText(cxt: Context, text: String) {
val clipboard = ContextCompat.getSystemService(cxt, ClipboardManager::class.java)
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()
}
} 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

@@ -204,6 +204,8 @@ private fun spannableStringToAnnotatedString(
// maximum image file size to be auto-accepted
const val MAX_IMAGE_SIZE = 236700
const val MAX_FILE_SIZE = 1893600
fun getFilesDirectory(context: Context): String {
return context.filesDir.toString()
}

View File

@@ -65,6 +65,7 @@
<string name="reply_verb">Ответить</string>
<string name="share_verb">Поделиться</string>
<string name="copy_verb">Копировать</string>
<string name="save_verb">Сохранить</string>
<string name="edit_verb">Редактировать</string>
<string name="delete_verb">Удалить</string>
<string name="delete_message__question">Удалить сообщение?</string>
@@ -91,7 +92,20 @@
<!-- Images -->
<string name="image_descr">Изображение</string>
<string name="icon_descr_cancel_image_preview">удалить превью изображения</string>
<string name="icon_descr_cancel_image_preview">Удалить превью изображения</string>
<string name="icon_descr_waiting_for_image">Ожидание изображения</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="waiting_for_file">Ожидание файла</string>
<string name="file_will_be_received_when_contact_is_online">Файл будет получен, когда ваш контакт будет в сети, пожалуйста, подождите или проверьте позже!</string>
<string name="file_saved">Файл сохранен</string>
<string name="file_not_found">Файл не найден</string>
<string name="error_saving_file">Ошибка сохранения файла</string>
<!-- Chat Info Actions - ChatInfoView.kt -->
<string name="delete_contact__question">Удалить контакт?</string>

View File

@@ -65,6 +65,7 @@
<string name="reply_verb">Reply</string>
<string name="share_verb">Share</string>
<string name="copy_verb">Copy</string>
<string name="save_verb">Save</string>
<string name="edit_verb">Edit</string>
<string name="delete_verb">Delete</string>
<string name="delete_message__question">Delete message?</string>
@@ -91,7 +92,20 @@
<!-- Images -->
<string name="image_descr">Image</string>
<string name="icon_descr_cancel_image_preview">cancel image preview</string>
<string name="icon_descr_cancel_image_preview">Cancel image preview</string>
<string name="icon_descr_waiting_for_image">Waiting for image</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="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>
<string name="file_not_found">File not found</string>
<string name="error_saving_file">Error saving file</string>
<!-- Chat Info Actions - ChatInfoView.kt -->
<string name="delete_contact__question">Delete contact?</string>

View File

@@ -12,6 +12,8 @@ import SwiftUI
// maximum image file size to be auto-accepted
let maxImageSize = 236700
let maxFileSize = 1893600
func getDocumentsDirectory() -> URL {
return FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first!
}
@@ -24,7 +26,7 @@ func getStoredFilePath(_ file: CIFile?) -> String? {
if let file = file,
file.stored,
let savedFile = file.filePath {
return getAppFilesDirectory().path + "/" + savedFile
return getAppFilesDirectory().appendingPathComponent(savedFile).path
}
return nil
}
@@ -35,3 +37,78 @@ func getStoredImage(_ file: CIFile?) -> UIImage? {
}
return nil
}
// image utils
func dropImagePrefix(_ s: String) -> String {
dropPrefix(dropPrefix(s, "data:image/png;base64,"), "data:image/jpg;base64,")
}
private func dropPrefix(_ s: String, _ prefix: String) -> String {
s.hasPrefix(prefix) ? String(s.dropFirst(prefix.count)) : s
}
func cropToSquare(_ image: UIImage) -> UIImage {
let size = image.size
let side = min(size.width, size.height)
let newSize = CGSize(width: side, height: side)
var origin = CGPoint.zero
if size.width > side {
origin.x -= (size.width - side) / 2
} else if size.height > side {
origin.y -= (size.height - side) / 2
}
return resizeImage(image, newBounds: CGRect(origin: .zero, size: newSize), drawIn: CGRect(origin: origin, size: size))
}
func resizeImageToDataSize(_ image: UIImage, maxDataSize: Int) -> Data? {
var img = image
var data = img.jpegData(compressionQuality: 0.85)
var dataSize = data?.count ?? 0
while dataSize != 0 && dataSize > maxDataSize {
let ratio = sqrt(Double(dataSize) / Double(maxDataSize))
let clippedRatio = min(ratio, 2.0)
img = reduceSize(img, ratio: clippedRatio)
data = img.jpegData(compressionQuality: 0.85)
dataSize = data?.count ?? 0
}
logger.debug("resizeImageToDataSize final \(dataSize)")
return data
}
func resizeImageToStrSize(_ image: UIImage, maxDataSize: Int) -> String? {
var img = image
var str = compressImageStr(img)
var dataSize = str?.count ?? 0
while dataSize != 0 && dataSize > maxDataSize {
let ratio = sqrt(Double(dataSize) / Double(maxDataSize))
let clippedRatio = min(ratio, 2.0)
img = reduceSize(img, ratio: clippedRatio)
str = compressImageStr(img)
dataSize = str?.count ?? 0
}
logger.debug("resizeImageToStrSize final \(dataSize)")
return str
}
func compressImageStr(_ image: UIImage, _ compressionQuality: CGFloat = 0.85) -> String? {
if let data = image.jpegData(compressionQuality: compressionQuality) {
return "data:image/jpg;base64,\(data.base64EncodedString())"
}
return nil
}
private func reduceSize(_ image: UIImage, ratio: CGFloat) -> UIImage {
let newSize = CGSize(width: floor(image.size.width / ratio), height: floor(image.size.height / ratio))
let bounds = CGRect(origin: .zero, size: newSize)
return resizeImage(image, newBounds: bounds, drawIn: bounds)
}
private func resizeImage(_ image: UIImage, newBounds: CGRect, drawIn: CGRect) -> UIImage {
let format = UIGraphicsImageRendererFormat()
format.scale = 1.0
format.opaque = true
return UIGraphicsImageRenderer(bounds: newBounds, format: format).image { _ in
image.draw(in: drawIn)
}
}

View File

@@ -167,7 +167,8 @@ enum ChatResponse: Decodable, Error {
case chatItemStatusUpdated(chatItem: AChatItem)
case chatItemUpdated(chatItem: AChatItem)
case chatItemDeleted(deletedChatItem: AChatItem, toChatItem: AChatItem)
case rcvFileAccepted
case rcvFileAccepted(chatItem: AChatItem)
case rcvFileStart(chatItem: AChatItem)
case rcvFileComplete(chatItem: AChatItem)
case ntfTokenStatus(status: NtfTknStatus)
case newContactConnection(connection: PendingContactConnection)
@@ -216,6 +217,7 @@ enum ChatResponse: Decodable, Error {
case .chatItemUpdated: return "chatItemUpdated"
case .chatItemDeleted: return "chatItemDeleted"
case .rcvFileAccepted: return "rcvFileAccepted"
case .rcvFileStart: return "rcvFileStart"
case .rcvFileComplete: return "rcvFileComplete"
case .ntfTokenStatus: return "ntfTokenStatus"
case .newContactConnection: return "newContactConnection"
@@ -266,7 +268,8 @@ enum ChatResponse: Decodable, Error {
case let .chatItemStatusUpdated(chatItem): return String(describing: chatItem)
case let .chatItemUpdated(chatItem): return String(describing: chatItem)
case let .chatItemDeleted(deletedChatItem, toChatItem): return "deletedChatItem:\n\(String(describing: deletedChatItem))\ntoChatItem:\n\(String(describing: toChatItem))"
case .rcvFileAccepted: return noDetails
case let .rcvFileAccepted(chatItem): return String(describing: chatItem)
case let .rcvFileStart(chatItem): return String(describing: chatItem)
case let .rcvFileComplete(chatItem): return String(describing: chatItem)
case let .ntfTokenStatus(status): return String(describing: status)
case let .newContactConnection(connection): return String(describing: connection)

View File

@@ -480,6 +480,16 @@ struct ChatItem: Identifiable, Decodable {
)
}
static func getFileMsgContentSample (id: Int64 = 1, text: String = "", fileName: String = "test.txt", fileSize: Int64 = 100, fileStatus: CIFileStatus = .rcvComplete) -> ChatItem {
ChatItem(
chatDir: .directRcv,
meta: CIMeta.getSample(id, .now, text, .rcvRead, false, false, false),
content: .rcvMsgContent(msgContent: .file(text)),
quotedItem: nil,
file: CIFile.getSample(fileName: fileName, fileSize: fileSize, fileStatus: fileStatus)
)
}
static func getDeletedContentSample (_ id: Int64 = 1, dir: CIDirection = .directRcv, _ ts: Date = .now, _ text: String = "this item is deleted", _ status: CIStatus = .rcvRead) -> ChatItem {
ChatItem(
chatDir: dir,
@@ -629,7 +639,7 @@ struct CIFile: Decodable {
var filePath: String?
var fileStatus: CIFileStatus
static func getSample(_ fileId: Int64, _ fileName: String, _ fileSize: Int64, filePath: String?, fileStatus: CIFileStatus = .sndStored) -> CIFile {
static func getSample(fileId: Int64 = 1, fileName: String = "test.txt", fileSize: Int64 = 100, filePath: String? = "test.txt", fileStatus: CIFileStatus = .rcvComplete) -> CIFile {
CIFile(fileId: fileId, fileName: fileName, fileSize: fileSize, filePath: filePath, fileStatus: fileStatus)
}
@@ -649,6 +659,7 @@ enum CIFileStatus: String, Decodable {
case sndStored = "snd_stored"
case sndCancelled = "snd_cancelled"
case rcvInvitation = "rcv_invitation"
case rcvAccepted = "rcv_accepted"
case rcvTransfer = "rcv_transfer"
case rcvComplete = "rcv_complete"
case rcvCancelled = "rcv_cancelled"
@@ -658,6 +669,7 @@ enum MsgContent {
case text(String)
case link(text: String, preview: LinkPreview)
case image(text: String, image: String)
case file(String)
// TODO include original JSON, possibly using https://github.com/zoul/generic-json-swift
case unknown(type: String, text: String)
@@ -667,6 +679,7 @@ enum MsgContent {
case let .text(text): return text
case let .link(text, _): return text
case let .image(text, _): return text
case let .file(text): return text
case let .unknown(_, text): return text
}
}
@@ -680,6 +693,7 @@ enum MsgContent {
return "json {\"type\":\"link\",\"text\":\(encodeJSON(text)),\"preview\":\(encodeJSON(preview))}"
case let .image(text: text, image: image):
return "json {\"type\":\"image\",\"text\":\(encodeJSON(text)),\"image\":\(encodeJSON(image))}"
case let .file(text): return "json {\"type\":\"file\",\"text\":\(encodeJSON(text))}"
default: return ""
}
}
@@ -690,6 +704,7 @@ enum MsgContent {
case text
case preview
case image
case file
}
}
@@ -711,6 +726,9 @@ extension MsgContent: Decodable {
let text = try container.decode(String.self, forKey: CodingKeys.text)
let image = try container.decode(String.self, forKey: CodingKeys.image)
self = .image(text: text, image: image)
case "file":
let text = try container.decode(String.self, forKey: CodingKeys.text)
self = .file(text)
default:
let text = try? container.decode(String.self, forKey: CodingKeys.text)
self = .unknown(type: type, text: text ?? "unknown message format")

View File

@@ -326,9 +326,18 @@ func apiChatRead(type: ChatType, id: Int64, itemRange: (Int64, Int64)) async thr
try await sendCommandOkResp(.apiChatRead(type: type, id: id, itemRange: itemRange))
}
func receiveFile(fileId: Int64) async throws {
func receiveFile(fileId: Int64) async {
do {
let chatItem = try await apiReceiveFile(fileId: fileId)
DispatchQueue.main.async { chatItemSimpleUpdate(chatItem) }
} catch let error {
logger.error("receiveFile error: \(responseError(error))")
}
}
func apiReceiveFile(fileId: Int64) async throws -> AChatItem {
let r = await chatSendCmd(.receiveFile(fileId: fileId))
if case .rcvFileAccepted = r { return }
if case .rcvFileAccepted(let chatItem) = r { return chatItem }
throw r
}
@@ -470,14 +479,11 @@ func processReceivedMsg(_ res: ChatResponse) {
let cInfo = aChatItem.chatInfo
let cItem = aChatItem.chatItem
m.addChatItem(cInfo, cItem)
if let file = cItem.file,
if case .image = cItem.content.msgContent,
let file = cItem.file,
file.fileSize <= maxImageSize {
Task {
do {
try await receiveFile(fileId: file.fileId)
} catch {
logger.error("receiveFile error: \(error.localizedDescription)")
}
await receiveFile(fileId: file.fileId)
}
}
NtfManager.shared.notifyMessageReceived(cInfo, cItem)
@@ -499,11 +505,7 @@ func processReceivedMsg(_ res: ChatResponse) {
}
}
case let .chatItemUpdated(aChatItem):
let cInfo = aChatItem.chatInfo
let cItem = aChatItem.chatItem
if m.upsertChatItem(cInfo, cItem) {
NtfManager.shared.notifyMessageReceived(cInfo, cItem)
}
chatItemSimpleUpdate(aChatItem)
case let .chatItemDeleted(_, toChatItem):
let cInfo = toChatItem.chatInfo
let cItem = toChatItem.chatItem
@@ -513,18 +515,25 @@ func processReceivedMsg(_ res: ChatResponse) {
// currently only broadcast deletion of rcv message can be received, and only this case should happen
_ = m.upsertChatItem(cInfo, cItem)
}
case let .rcvFileStart(aChatItem):
chatItemSimpleUpdate(aChatItem)
case let .rcvFileComplete(aChatItem):
let cInfo = aChatItem.chatInfo
let cItem = aChatItem.chatItem
if m.upsertChatItem(cInfo, cItem) {
NtfManager.shared.notifyMessageReceived(cInfo, cItem)
}
chatItemSimpleUpdate(aChatItem)
default:
logger.debug("unsupported event: \(res.responseType)")
}
}
}
func chatItemSimpleUpdate(_ aChatItem: AChatItem) {
let m = ChatModel.shared
let cInfo = aChatItem.chatInfo
let cItem = aChatItem.chatItem
if m.upsertChatItem(cInfo, cItem) {
NtfManager.shared.notifyMessageReceived(cInfo, cItem)
}
}
func updateContactsStatus(_ contactRefs: [ContactRef], status: Chat.NetworkStatus) {
let m = ChatModel.shared
for c in contactRefs {

View File

@@ -55,6 +55,11 @@ struct FramedItemView: View {
} else {
ciMsgContentView (chatItem, showMember)
}
case let .file(text):
CIFileView(file: chatItem.file, edited: chatItem.meta.itemEdited)
if text != "" {
ciMsgContentView (chatItem, showMember)
}
case let .link(_, preview):
CILinkView(linkPreview: preview)
ciMsgContentView (chatItem, showMember)

View File

@@ -135,6 +135,12 @@ struct ChatView: View {
UIPasteboard.general.string = ci.content.text
}
} label: { Label("Copy", systemImage: "doc.on.doc") }
if case .image = ci.content.msgContent,
let image = getStoredImage(ci.file) {
Button {
UIImageWriteToSavedPhotosAlbum(image, nil, nil, nil)
} label: { Label("Save", systemImage: "square.and.arrow.down") }
}
if ci.meta.editable {
Button {
withAnimation {

View File

@@ -276,6 +276,8 @@ struct ComposeView: View {
return checkLinkPreview()
case .image(_, let image):
return .image(text: composeState.message, image: image)
case .file:
return .file(composeState.message)
case .unknown(let type, _):
return .unknown(type: type, text: composeState.message)
}

View File

@@ -0,0 +1,177 @@
//
// CIFileView.swift
// SimpleX
//
// Created by JRoberts on 28/04/2022.
// Copyright © 2022 SimpleX Chat. All rights reserved.
//
import SwiftUI
struct CIFileView: View {
@Environment(\.colorScheme) var colorScheme
let file: CIFile?
let edited: Bool
var body: some View {
let metaReserve = edited
? " "
: " "
Button(action: fileAction) {
HStack(alignment: .bottom, spacing: 6) {
fileIndicator()
.padding(.top, 5)
.padding(.bottom, 3)
if let file = file {
VStack(alignment: .leading, spacing: 2) {
Text(file.fileName)
.lineLimit(1)
.multilineTextAlignment(.leading)
.foregroundColor(.primary)
Text(formatBytes(bytes: file.fileSize) + metaReserve)
.font(.caption)
.lineLimit(1)
.multilineTextAlignment(.leading)
.foregroundColor(.secondary)
}
} else {
Text(metaReserve)
}
}
.padding(.top, 4)
.padding(.bottom, 6)
.padding(.leading, 10)
.padding(.trailing, 12)
}
.disabled(file == nil || (file?.fileStatus != .rcvInvitation && file?.fileStatus != .rcvAccepted && file?.fileStatus != .rcvComplete))
}
func fileSizeValid() -> Bool {
if let file = file {
return file.fileSize <= maxFileSize
}
return false
}
func fileAction() {
logger.debug("CIFileView processFile")
if let file = file {
switch (file.fileStatus) {
case .rcvInvitation:
if fileSizeValid() {
Task {
logger.debug("CIFileView processFile - in .rcvInvitation, in Task")
await receiveFile(fileId: file.fileId)
}
} else {
AlertManager.shared.showAlertMsg(
title: "Large file!",
message: "Your contact sent a file that is larger than currently supported maximum size (\(maxFileSize) bytes)."
)
}
case .rcvAccepted:
AlertManager.shared.showAlertMsg(
title: "Waiting for file",
message: "File will be received when your contact is online, please wait or check later!"
)
case .rcvComplete:
logger.debug("CIFileView processFile - in .rcvComplete")
if let filePath = getStoredFilePath(file){
let url = URL(fileURLWithPath: filePath)
showShareSheet(items: [url])
}
default: break
}
}
}
@ViewBuilder func fileIndicator() -> some View {
if let file = file {
switch file.fileStatus {
case .sndCancelled: fileIcon("doc.fill", innerIcon: "xmark", innerIconSize: 10)
case .rcvInvitation:
if fileSizeValid() {
fileIcon("arrow.down.doc.fill", color: .accentColor)
} else {
fileIcon("doc.fill", color: .orange, innerIcon: "exclamationmark", innerIconSize: 12)
}
case .rcvAccepted: fileIcon("doc.fill", innerIcon: "ellipsis", innerIconSize: 12)
case .rcvTransfer: ProgressView().frame(width: 30, height: 30)
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 {
ZStack(alignment: .center) {
Image(systemName: icon)
.resizable()
.aspectRatio(contentMode: .fit)
.frame(width: 30, height: 30)
.foregroundColor(color)
if let innerIcon = innerIcon,
let innerIconSize = innerIconSize {
Image(systemName: innerIcon)
.resizable()
.aspectRatio(contentMode: .fit)
.frame(maxHeight: 16)
.frame(width: innerIconSize, height: innerIconSize)
.foregroundColor(.white)
.padding(.top, 12)
}
}
}
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 {
static var previews: some View {
let sentFile = ChatItem(
chatDir: .directSnd,
meta: CIMeta.getSample(1, .now, "", .sndSent, false, true, false),
content: .sndMsgContent(msgContent: .file("")),
quotedItem: nil,
file: CIFile.getSample(fileStatus: .sndStored)
)
let fileChatItemWtFile = ChatItem(
chatDir: .directRcv,
meta: CIMeta.getSample(1, .now, "", .rcvRead, false, false, false),
content: .rcvMsgContent(msgContent: .file("")),
quotedItem: nil,
file: nil
)
Group{
ChatItemView(chatItem: sentFile)
ChatItemView(chatItem: ChatItem.getFileMsgContentSample())
ChatItemView(chatItem: ChatItem.getFileMsgContentSample(fileName: "some_long_file_name_here", fileStatus: .rcvInvitation))
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(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)
}
.previewLayout(.fixed(width: 360, height: 360))
}
}

View File

@@ -41,6 +41,14 @@ struct CIImageView: View {
} else if let data = Data(base64Encoded: dropImagePrefix(image)),
let uiImage = UIImage(data: data) {
imageView(uiImage)
.onTapGesture {
if case .rcvAccepted = file?.fileStatus {
AlertManager.shared.showAlertMsg(
title: "Waiting for image",
message: "Image will be received when your contact is online, please wait or check later!"
)
}
}
}
}
}
@@ -48,9 +56,32 @@ struct CIImageView: View {
private func imageView(_ img: UIImage) -> some View {
let w = img.size.width > img.size.height ? .infinity : maxWidth * 0.75
DispatchQueue.main.async { imgWidth = w }
return Image(uiImage: img)
.resizable()
.scaledToFit()
.frame(maxWidth: w)
return ZStack(alignment: .topTrailing) {
Image(uiImage: img)
.resizable()
.scaledToFit()
.frame(maxWidth: w)
loadingIndicator()
.padding(8)
}
}
@ViewBuilder private func loadingIndicator() -> some View {
if let file = file {
switch file.fileStatus {
case .rcvAccepted:
Image(systemName: "ellipsis")
.resizable()
.aspectRatio(contentMode: .fit)
.frame(width: 20, height: 20)
.foregroundColor(.white)
case .rcvTransfer:
ProgressView()
.progressViewStyle(.circular)
.frame(width: 20, height: 20)
.tint(.white)
default: EmptyView()
}
}
}
}

View File

@@ -9,80 +9,6 @@
import SwiftUI
import PhotosUI
func dropPrefix(_ s: String, _ prefix: String) -> String {
s.hasPrefix(prefix) ? String(s.dropFirst(prefix.count)) : s
}
func dropImagePrefix(_ s: String) -> String {
dropPrefix(dropPrefix(s, "data:image/png;base64,"), "data:image/jpg;base64,")
}
private func resizeImage(_ image: UIImage, newBounds: CGRect, drawIn: CGRect) -> UIImage {
let format = UIGraphicsImageRendererFormat()
format.scale = 1.0
format.opaque = true
return UIGraphicsImageRenderer(bounds: newBounds, format: format).image { _ in
image.draw(in: drawIn)
}
}
func cropToSquare(_ image: UIImage) -> UIImage {
let size = image.size
let side = min(size.width, size.height)
let newSize = CGSize(width: side, height: side)
var origin = CGPoint.zero
if size.width > side {
origin.x -= (size.width - side) / 2
} else if size.height > side {
origin.y -= (size.height - side) / 2
}
return resizeImage(image, newBounds: CGRect(origin: .zero, size: newSize), drawIn: CGRect(origin: origin, size: size))
}
func reduceSize(_ image: UIImage, ratio: CGFloat) -> UIImage {
let newSize = CGSize(width: floor(image.size.width / ratio), height: floor(image.size.height / ratio))
let bounds = CGRect(origin: .zero, size: newSize)
return resizeImage(image, newBounds: bounds, drawIn: bounds)
}
func resizeImageToStrSize(_ image: UIImage, maxDataSize: Int) -> String? {
var img = image
var str = compressImageStr(img)
var dataSize = str?.count ?? 0
while dataSize != 0 && dataSize > maxDataSize {
let ratio = sqrt(Double(dataSize) / Double(maxDataSize))
let clippedRatio = min(ratio, 2.0)
img = reduceSize(img, ratio: clippedRatio)
str = compressImageStr(img)
dataSize = str?.count ?? 0
}
logger.debug("resizeImageToStrSize final \(dataSize)")
return str
}
func compressImageStr(_ image: UIImage, _ compressionQuality: CGFloat = 0.85) -> String? {
if let data = image.jpegData(compressionQuality: compressionQuality) {
return "data:image/jpg;base64,\(data.base64EncodedString())"
}
return nil
}
func resizeImageToDataSize(_ image: UIImage, maxDataSize: Int) -> Data? {
var img = image
var data = img.jpegData(compressionQuality: 0.85)
var dataSize = data?.count ?? 0
while dataSize != 0 && dataSize > maxDataSize {
let ratio = sqrt(Double(dataSize) / Double(maxDataSize))
let clippedRatio = min(ratio, 2.0)
img = reduceSize(img, ratio: clippedRatio)
data = img.jpegData(compressionQuality: 0.85)
dataSize = data?.count ?? 0
}
logger.debug("resizeImageToDataSize final \(dataSize)")
return data
}
enum ImageSource {
case imageLibrary
case camera

View File

@@ -92,6 +92,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 */; };
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 */; };
64AA1C6927EE10C800AC7277 /* ContextItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64AA1C6827EE10C800AC7277 /* ContextItemView.swift */; };
@@ -211,6 +212,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>"; };
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>"; };
649BCDA12805D6EF00C3A862 /* CIImageView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CIImageView.swift; sourceTree = "<group>"; };
@@ -344,6 +346,7 @@
3CDBCF4727FF621E00354CDD /* CILinkView.swift */,
649BCD9F280460FD00C3A862 /* ComposeImageView.swift */,
649BCDA12805D6EF00C3A862 /* CIImageView.swift */,
648010AA281ADD15009009B9 /* CIFileView.swift */,
);
path = Helpers;
sourceTree = "<group>";
@@ -640,6 +643,7 @@
5C13730B28156D2700F43030 /* ContactConnectionView.swift in Sources */,
5CE4407927ADB701007B033A /* EmojiItemView.swift in Sources */,
5C5346A827B59A6A004DF848 /* ChatHelp.swift in Sources */,
648010AB281ADD15009009B9 /* CIFileView.swift in Sources */,
3CDBCF4227FAE51000354CDD /* ComposeLinkView.swift in Sources */,
3CDBCF4827FF621E00354CDD /* CILinkView.swift in Sources */,
5C764E80279C7276000C6508 /* dummy.m in Sources */,
@@ -879,8 +883,9 @@
ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = "SimpleX--iOS--Info.plist";
INFOPLIST_KEY_NSCameraUsageDescription = "SimpleX needs camera access to scan QR codes to connect other users and for video calls.";
INFOPLIST_KEY_NSCameraUsageDescription = "SimpleX needs camera access to scan QR codes to connect to other users and for video calls.";
INFOPLIST_KEY_NSMicrophoneUsageDescription = "SimpleX needs microphone access for audio and video calls.";
INFOPLIST_KEY_NSPhotoLibraryAddUsageDescription = "SimpleX needs access to Photo Library for saving captured and received media";
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
@@ -921,8 +926,9 @@
ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = "SimpleX--iOS--Info.plist";
INFOPLIST_KEY_NSCameraUsageDescription = "SimpleX needs camera access to scan QR codes to connect other users and for video calls.";
INFOPLIST_KEY_NSCameraUsageDescription = "SimpleX needs camera access to scan QR codes to connect to other users and for video calls.";
INFOPLIST_KEY_NSMicrophoneUsageDescription = "SimpleX needs microphone access for audio and video calls.";
INFOPLIST_KEY_NSPhotoLibraryAddUsageDescription = "SimpleX needs access to Photo Library for saving captured and received media";
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
INFOPLIST_KEY_UILaunchScreen_Generation = YES;

View File

@@ -1033,20 +1033,20 @@ testFileSndCancel =
testChat2 aliceProfile bobProfile $
\alice bob -> do
connectUsers alice bob
startFileTransfer alice bob
startFileTransfer' alice bob "test_1MB.pdf" "1017.7 KiB / 1042157 bytes"
alice ##> "/fc 1"
concurrentlyN_
[ do
alice <## "cancelled sending file 1 (test.jpg) to bob"
alice <## "cancelled sending file 1 (test_1MB.pdf) to bob"
alice ##> "/fs 1"
alice <## "sending file 1 (test.jpg) cancelled: bob"
alice <## "sending file 1 (test_1MB.pdf) cancelled: bob"
alice <## "file transfer cancelled",
do
bob <## "alice cancelled sending file 1 (test.jpg)"
bob <## "alice cancelled sending file 1 (test_1MB.pdf)"
bob ##> "/fs 1"
bob <## "receiving file 1 (test.jpg) cancelled, received part path: ./tests/tmp/test.jpg"
bob <## "receiving file 1 (test_1MB.pdf) cancelled, received part path: ./tests/tmp/test_1MB.pdf"
]
checkPartialTransfer
checkPartialTransfer "test_1MB.pdf"
testFileRcvCancel :: IO ()
testFileRcvCancel =
@@ -1068,7 +1068,7 @@ testFileRcvCancel =
alice ##> "/fs 1"
alice <## "sending file 1 (test.jpg) cancelled: bob"
]
checkPartialTransfer
checkPartialTransfer "test.jpg"
testGroupFileTransfer :: IO ()
testGroupFileTransfer =
@@ -1848,21 +1848,25 @@ withTestChatGroup3Connected' :: String -> IO ()
withTestChatGroup3Connected' dbPrefix = withTestChatGroup3Connected dbPrefix $ \_ -> pure ()
startFileTransfer :: TestCC -> TestCC -> IO ()
startFileTransfer alice bob = do
alice #> "/f @bob ./tests/fixtures/test.jpg"
startFileTransfer alice bob =
startFileTransfer' alice bob "test.jpg" "136.5 KiB / 139737 bytes"
startFileTransfer' :: TestCC -> TestCC -> String -> String -> IO ()
startFileTransfer' alice bob fileName fileSize = do
alice #> ("/f @bob ./tests/fixtures/" <> fileName)
alice <## "use /fc 1 to cancel sending"
bob <# "alice> sends file test.jpg (136.5 KiB / 139737 bytes)"
bob <# ("alice> sends file " <> fileName <> " (" <> fileSize <> ")")
bob <## "use /fr 1 [<dir>/ | <path>] to receive it"
bob ##> "/fr 1 ./tests/tmp"
bob <## "saving file 1 from alice to ./tests/tmp/test.jpg"
bob <## ("saving file 1 from alice to ./tests/tmp/" <> fileName)
concurrently_
(bob <## "started receiving file 1 (test.jpg) from alice")
(alice <## "started sending file 1 (test.jpg) to bob")
(bob <## ("started receiving file 1 (" <> fileName <> ") from alice"))
(alice <## ("started sending file 1 (" <> fileName <> ") to bob"))
checkPartialTransfer :: IO ()
checkPartialTransfer = do
src <- B.readFile "./tests/fixtures/test.jpg"
dest <- B.readFile "./tests/tmp/test.jpg"
checkPartialTransfer :: String -> IO ()
checkPartialTransfer fileName = do
src <- B.readFile $ "./tests/fixtures/" <> fileName
dest <- B.readFile $ "./tests/tmp/" <> fileName
B.unpack src `shouldStartWith` B.unpack dest
B.length src > B.length dest `shouldBe` True