From 1a4fbec8953ec57ff0d9364eadc14ea54793799b Mon Sep 17 00:00:00 2001 From: JRoberts <8711996+jr-simplex@users.noreply.github.com> Date: Wed, 13 Apr 2022 22:07:57 +0400 Subject: [PATCH] android wip --- .../java/chat/simplex/app/model/ChatModel.kt | 76 +++++++++++---- .../java/chat/simplex/app/model/SimpleXAPI.kt | 18 ++-- .../chat/simplex/app/views/TerminalView.kt | 19 ++-- .../chat/simplex/app/views/chat/ChatView.kt | 95 ++++++++++++++++--- .../app/views/chat/ComposeImageView.kt | 31 ++++++ .../simplex/app/views/chat/ComposeView.kt | 48 +++++++++- .../simplex/app/views/chat/SendMsgView.kt | 2 +- .../app/views/chat/item/ChatItemImageView.kt | 52 ++++++++++ .../app/views/chat/item/FramedItemView.kt | 5 +- .../simplex/app/views/helpers/GetImageView.kt | 13 ++- .../app/views/usersettings/UserProfileView.kt | 11 ++- apps/ios/Shared/Model/ChatModel.swift | 11 +++ .../Views/Chat/ChatItem/FramedItemView.swift | 2 +- apps/ios/Shared/Views/Chat/ChatView.swift | 5 +- .../Chat/ComposeMessage/ComposeView.swift | 2 + .../Views/Helpers/ChatItemImageView.swift | 29 +----- 16 files changed, 329 insertions(+), 90 deletions(-) create mode 100644 apps/android/app/src/main/java/chat/simplex/app/views/chat/ComposeImageView.kt create mode 100644 apps/android/app/src/main/java/chat/simplex/app/views/chat/item/ChatItemImageView.kt diff --git a/apps/android/app/src/main/java/chat/simplex/app/model/ChatModel.kt b/apps/android/app/src/main/java/chat/simplex/app/model/ChatModel.kt index 71185d1feb..5dbc2889e1 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/model/ChatModel.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/model/ChatModel.kt @@ -517,7 +517,8 @@ data class ChatItem ( val meta: CIMeta, val content: CIContent, val formattedText: List? = null, - val quotedItem: CIQuote? = null + val quotedItem: CIQuote? = null, + val file: CIFile? = null ) { val id: Long get() = meta.itemId val timestampText: String get() = meta.timestampText @@ -549,6 +550,7 @@ data class ChatItem ( text: String = "hello\nthere", status: CIStatus = CIStatus.SndNew(), quotedItem: CIQuote? = null, + file: CIFile? = null, itemDeleted: Boolean = false, itemEdited: Boolean = false, editable: Boolean = true @@ -557,7 +559,8 @@ data class ChatItem ( chatDir = dir, meta = CIMeta.getSample(id, ts, text, status, itemDeleted, itemEdited, editable), content = CIContent.SndMsgContent(msgContent = MsgContent.MCText(text)), - quotedItem = quotedItem + quotedItem = quotedItem, + file = file ) fun getDeletedContentSampleData( @@ -569,9 +572,10 @@ data class ChatItem ( ) = ChatItem( chatDir = dir, - meta = CIMeta.getSample(id, ts, text, status, false, false, false), + meta = CIMeta.getSample(id, ts, text, status, itemDeleted = false, itemEdited = false, editable = false), content = CIContent.RcvDeleted(deleteMode = CIDeleteMode.cidmBroadcast), - quotedItem = null + quotedItem = null, + file = null ) } } @@ -699,18 +703,6 @@ sealed class CIContent: ItemContent { override val text get() = "deleted" override val msgContent get() = null } - - @Serializable @SerialName("sndFileInvitation") - class SndFileInvitation(val fileId: Long, val filePath: String): CIContent() { - override val text get() = "sending files is not supported yet" - override val msgContent get() = null - } - - @Serializable @SerialName("rcvFileInvitation") - class RcvFileInvitation(val rcvFileTransfer: RcvFileTransfer): CIContent() { - override val text get() = "receiving files is not supported yet" - override val msgContent get() = null - } } @Serializable @@ -738,6 +730,37 @@ class CIQuote ( } } +@Serializable +class CIFile( + val fileId: Long, + val fileName: String, + val fileSize: Long, + val filePath: String? = null, + val fileStatus: CIFileStatus +) { + val stored: Boolean = when (fileStatus) { + CIFileStatus.SndStored -> true + CIFileStatus.SndCancelled -> true + CIFileStatus.RcvComplete -> true + else -> false + } + + companion object { + fun getSample(fileId: Long, fileName: String, fileSize: Long, filePath: String?, fileStatus: CIFileStatus = CIFileStatus.SndStored): CIFile = + CIFile(fileId = fileId, fileName = fileName, fileSize = fileSize, filePath = filePath, fileStatus = fileStatus) + } +} + +@Serializable +enum class CIFileStatus { + @SerialName("snd_stored") SndStored, + @SerialName("snd_cancelled") SndCancelled, + @SerialName("rcv_invitation") RcvInvitation, + @SerialName("rcv_transfer") RcvTransfer, + @SerialName("rcv_complete") RcvComplete, + @SerialName("rcv_cancelled") RcvCancelled; +} + @Suppress("SERIALIZER_TYPE_INCOMPATIBLE") @Serializable(with = MsgContentSerializer::class) sealed class MsgContent { @@ -749,12 +772,16 @@ sealed class MsgContent { @Serializable(with = MsgContentSerializer::class) class MCLink(override val text: String, val preview: LinkPreview): MsgContent() + @Serializable(with = MsgContentSerializer::class) + class MCImage(override val text: String, val image: String): MsgContent() + @Serializable(with = MsgContentSerializer::class) class MCUnknown(val type: String? = null, override val text: String, val json: JsonElement): MsgContent() val cmdString: String get() = when (this) { is MCText -> "text $text" is MCLink -> "json ${json.encodeToString(this)}" + is MCImage -> "json ${json.encodeToString(this)}" is MCUnknown -> "json $json" } } @@ -769,6 +796,10 @@ object MsgContentSerializer : KSerializer { element("text") element("preview") }) + element("MCImage", buildClassSerialDescriptor("MCImage") { + element("text") + element("image") + }) element("MCUnknown", buildClassSerialDescriptor("MCUnknown")) } @@ -785,6 +816,10 @@ object MsgContentSerializer : KSerializer { val preview = Json.decodeFromString(json["preview"].toString()) MsgContent.MCLink(text, preview) } + "image" -> { + val image = json["image"]?.jsonPrimitive?.content ?: "unknown message format" + MsgContent.MCImage(text, image) + } else -> MsgContent.MCUnknown(t, text, json) } } else { @@ -809,6 +844,12 @@ object MsgContentSerializer : KSerializer { put("text", value.text) put("preview", json.encodeToJsonElement(value.preview)) } + is MsgContent.MCImage -> + buildJsonObject { + put("type", "image") + put("text", value.text) + put("image", value.image) + } is MsgContent.MCUnknown -> value.json } encoder.encodeJsonElement(json) @@ -876,6 +917,3 @@ enum class FormatColor(val color: String) { white -> MaterialTheme.colors.onBackground } } - -@Serializable -class RcvFileTransfer diff --git a/apps/android/app/src/main/java/chat/simplex/app/model/SimpleXAPI.kt b/apps/android/app/src/main/java/chat/simplex/app/model/SimpleXAPI.kt index eeb7b5d046..ae4cc097d3 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/model/SimpleXAPI.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/model/SimpleXAPI.kt @@ -141,9 +141,8 @@ open class ChatController(private val ctrl: ChatCtrl, private val ntfManager: Nt return null } - suspend fun apiSendMessage(type: ChatType, id: Long, quotedItemId: Long? = null, mc: MsgContent): AChatItem? { - val cmd = if (quotedItemId == null) CC.ApiSendMessage(type, id, mc) - else CC.ApiSendMessageQuote(type, id, quotedItemId, mc) + suspend fun apiSendMessage(type: ChatType, id: Long, file: String? = null, quotedItemId: Long? = null, mc: MsgContent): AChatItem? { + val cmd = CC.ApiSendMessage(type, id, file, quotedItemId, mc) val r = sendCmd(cmd) if (r is CR.NewChatItem ) return r.chatItem Log.e(TAG, "apiSendMessage bad response: ${r.responseType} ${r.details}") @@ -479,8 +478,7 @@ sealed class CC { class StartChat: CC() class ApiGetChats: CC() class ApiGetChat(val type: ChatType, val id: Long): CC() - class ApiSendMessage(val type: ChatType, val id: Long, val mc: MsgContent): CC() - class ApiSendMessageQuote(val type: ChatType, val id: Long, val itemId: Long, val mc: MsgContent): CC() + class ApiSendMessage(val type: ChatType, val id: Long, val file: String?, val quotedItemId: Long?, val mc: MsgContent): CC() class ApiUpdateChatItem(val type: ChatType, val id: Long, val itemId: Long, val mc: MsgContent): CC() class ApiDeleteChatItem(val type: ChatType, val id: Long, val itemId: Long, val mode: CIDeleteMode): CC() class GetUserSMPServers(): CC() @@ -504,8 +502,13 @@ sealed class CC { is StartChat -> "/_start" is ApiGetChats -> "/_get chats" is ApiGetChat -> "/_get chat ${chatRef(type, id)} count=100" - is ApiSendMessage -> "/_send ${chatRef(type, id)} ${mc.cmdString}" - is ApiSendMessageQuote -> "/_send_quote ${chatRef(type, id)} $itemId ${mc.cmdString}" + 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() + } is ApiUpdateChatItem -> "/_update item ${chatRef(type, id)} $itemId ${mc.cmdString}" is ApiDeleteChatItem -> "/_delete item ${chatRef(type, id)} $itemId ${mode.deleteMode}" is GetUserSMPServers -> "/smp_servers" @@ -531,7 +534,6 @@ sealed class CC { is ApiGetChats -> "apiGetChats" is ApiGetChat -> "apiGetChat" is ApiSendMessage -> "apiSendMessage" - is ApiSendMessageQuote -> "apiSendMessageQuote" is ApiUpdateChatItem -> "apiUpdateChatItem" is ApiDeleteChatItem -> "apiDeleteChatItem" is GetUserSMPServers -> "getUserSMPServers" diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/TerminalView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/TerminalView.kt index 8f828f4e19..17c63854ef 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/TerminalView.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/TerminalView.kt @@ -3,8 +3,7 @@ package chat.simplex.app.views import android.content.res.Configuration import androidx.activity.compose.BackHandler import androidx.compose.foundation.* -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.* import androidx.compose.foundation.lazy.* import androidx.compose.foundation.text.selection.SelectionContainer import androidx.compose.material.* @@ -44,13 +43,15 @@ fun TerminalLayout(terminalItems: List, close: () -> Unit, sendCom Scaffold( topBar = { CloseSheetBar(close) }, bottomBar = { - SendMsgView( - msg = remember { mutableStateOf("") }, - linkPreview = remember { mutableStateOf(null) }, - cancelledLinks = remember { mutableSetOf() }, - parseMarkdown = { null }, - sendMessage = sendCommand - ) + Box(Modifier.padding(horizontal = 8.dp)) { + SendMsgView( + msg = remember { mutableStateOf("") }, + linkPreview = remember { mutableStateOf(null) }, + cancelledLinks = remember { mutableSetOf() }, + parseMarkdown = { null }, + sendMessage = sendCommand + ) + } }, modifier = Modifier.navigationBarsWithImePadding() ) { contentPadding -> diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/chat/ChatView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/chat/ChatView.kt index d8c5bd016e..39fa1b2743 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/chat/ChatView.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/chat/ChatView.kt @@ -1,12 +1,15 @@ package chat.simplex.app.views.chat +import android.content.Context import android.content.res.Configuration +import android.graphics.Bitmap import android.util.Log import androidx.activity.compose.BackHandler import androidx.compose.foundation.* import androidx.compose.foundation.layout.* import androidx.compose.foundation.lazy.* import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.* import androidx.compose.material.icons.Icons import androidx.compose.material.icons.outlined.ArrowBackIos @@ -16,6 +19,7 @@ import androidx.compose.runtime.saveable.rememberSaveable 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.platform.LocalContext import androidx.compose.ui.platform.LocalUriHandler import androidx.compose.ui.text.font.FontWeight @@ -33,6 +37,8 @@ import com.google.accompanist.insets.ProvideWindowInsets import com.google.accompanist.insets.navigationBarsWithImePadding import kotlinx.coroutines.* import kotlinx.datetime.Clock +import java.io.File +import java.io.FileOutputStream @Composable fun ChatView(chatModel: ChatModel) { @@ -41,9 +47,12 @@ fun ChatView(chatModel: ChatModel) { if (chat == null || user == null) { chatModel.chatId.value = null } else { + val context = LocalContext.current val quotedItem = remember { mutableStateOf(null) } val editingItem = remember { mutableStateOf(null) } val linkPreview = remember { mutableStateOf(null) } + val chosenImage = remember { mutableStateOf(null) } + val imagePreview = remember { mutableStateOf(null) } var msg = remember { mutableStateOf("") } BackHandler { chatModel.chatId.value = null } @@ -62,7 +71,7 @@ fun ChatView(chatModel: ChatModel) { } } } - ChatLayout(user, chat, chatModel.chatItems, msg, quotedItem, editingItem, linkPreview, + ChatLayout(user, chat, chatModel.chatItems, msg, quotedItem, editingItem, linkPreview, chosenImage, imagePreview, back = { chatModel.chatId.value = null }, info = { ModalManager.shared.showCustomModal { close -> ChatInfoView(chatModel, close) } }, openDirectChat = { contactId -> @@ -85,12 +94,28 @@ fun ChatView(chatModel: ChatModel) { ) if (updatedItem != null) chatModel.upsertChatItem(cInfo, updatedItem.chatItem) } else { + var file: String? = null + val imagePreviewData = imagePreview.value + val chosenImageData = chosenImage.value val linkPreviewData = linkPreview.value + val mc = when { + imagePreviewData != null && chosenImageData != null -> { + file = saveImage(context, chosenImageData) + MsgContent.MCImage(msg, imagePreviewData) + } + linkPreviewData != null -> { + MsgContent.MCLink(msg, linkPreviewData) + } + else -> { + MsgContent.MCText(msg) + } + } val newItem = chatModel.controller.apiSendMessage( type = cInfo.chatType, id = cInfo.apiId, + file = file, quotedItemId = quotedItem.value?.meta?.itemId, - mc = if (linkPreviewData != null) MsgContent.MCLink(msg, linkPreviewData) else MsgContent.MCText(msg) + mc = mc ) if (newItem != null) chatModel.addChatItem(cInfo, newItem.chatItem) } @@ -113,11 +138,23 @@ fun ChatView(chatModel: ChatModel) { if (toItem != null) chatModel.removeChatItem(cInfo, toItem.chatItem) } }, - parseMarkdown = { text -> runBlocking { chatModel.controller.apiParseMarkdown(text) } } + parseMarkdown = { text -> runBlocking { chatModel.controller.apiParseMarkdown(text) } }, + onImageChange = { bitmap -> imagePreview.value = resizeImageToDataSize(bitmap, maxDataSize = 12500) } ) } } +fun saveImage(context: Context, image: Bitmap): String { + val imageResized = base64ToBitmap(resizeImageToDataSize(image, 160000)) + val imageName = "image_${System.currentTimeMillis()}.jpg" + val file = File(context.filesDir, imageName) + val output = FileOutputStream(file) + imageResized.compress(Bitmap.CompressFormat.JPEG, 100, output) + output.flush() + output.close() + return file.absolutePath +} + @Composable fun ChatLayout( user: User, @@ -127,27 +164,53 @@ fun ChatLayout( quotedItem: MutableState, editingItem: MutableState, linkPreview: MutableState, + chosenImage: MutableState, + imagePreview: MutableState, back: () -> Unit, info: () -> Unit, openDirectChat: (Long) -> Unit, sendMessage: (String) -> Unit, resetMessage: () -> Unit, deleteMessage: (Long, CIDeleteMode) -> Unit, - parseMarkdown: (String) -> List? + parseMarkdown: (String) -> List?, + onImageChange: (Bitmap) -> Unit ) { + val bottomSheetModalState = rememberModalBottomSheetState(initialValue = ModalBottomSheetValue.Hidden) + val scope = rememberCoroutineScope() + Surface( Modifier .fillMaxWidth() .background(MaterialTheme.colors.background) ) { ProvideWindowInsets(windowInsetsAnimationsEnabled = true) { - Scaffold( - topBar = { ChatInfoToolbar(chat, back, info) }, - bottomBar = { ComposeView(msg, quotedItem, editingItem, linkPreview, sendMessage, resetMessage, parseMarkdown) }, - modifier = Modifier.navigationBarsWithImePadding() - ) { contentPadding -> - Box(Modifier.padding(contentPadding)) { - ChatItemsList(user, chat, chatItems, msg, quotedItem, editingItem, openDirectChat, deleteMessage) + ModalBottomSheetLayout( + scrimColor = Color.Black.copy(alpha = 0.12F), + modifier = Modifier.navigationBarsWithImePadding(), + sheetContent = { + GetImageBottomSheet( + chosenImage, + onImageChange = onImageChange, + hideBottomSheet = { + scope.launch { bottomSheetModalState.hide() } + }) + }, + sheetState = bottomSheetModalState, + sheetShape = RoundedCornerShape(topStart = 18.dp, topEnd = 18.dp) + ) { + Scaffold( + topBar = { ChatInfoToolbar(chat, back, info) }, + bottomBar = { + ComposeView( + msg, quotedItem, editingItem, linkPreview, imagePreview, sendMessage, resetMessage, parseMarkdown, + showBottomSheet = { scope.launch { bottomSheetModalState.show() } } + ) + }, + modifier = Modifier.navigationBarsWithImePadding() + ) { contentPadding -> + Box(Modifier.padding(contentPadding)) { + ChatItemsList(user, chat, chatItems, msg, quotedItem, editingItem, openDirectChat, deleteMessage) + } } } } @@ -341,13 +404,16 @@ fun PreviewChatLayout() { quotedItem = remember { mutableStateOf(null) }, editingItem = remember { mutableStateOf(null) }, linkPreview = remember { mutableStateOf(null) }, + chosenImage = remember { mutableStateOf(null) }, + imagePreview = remember { mutableStateOf(null) }, back = {}, info = {}, openDirectChat = {}, sendMessage = {}, resetMessage = {}, deleteMessage = { _, _ -> }, - parseMarkdown = { null } + parseMarkdown = { null }, + onImageChange = {} ) } } @@ -386,13 +452,16 @@ fun PreviewGroupChatLayout() { quotedItem = remember { mutableStateOf(null) }, editingItem = remember { mutableStateOf(null) }, linkPreview = remember { mutableStateOf(null) }, + chosenImage = remember { mutableStateOf(null) }, + imagePreview = remember { mutableStateOf(null) }, back = {}, info = {}, openDirectChat = {}, sendMessage = {}, resetMessage = {}, deleteMessage = { _, _ -> }, - parseMarkdown = { null } + parseMarkdown = { null }, + onImageChange = {} ) } } diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/chat/ComposeImageView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/chat/ComposeImageView.kt new file mode 100644 index 0000000000..0e2b438de8 --- /dev/null +++ b/apps/android/app/src/main/java/chat/simplex/app/views/chat/ComposeImageView.kt @@ -0,0 +1,31 @@ +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.* +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.asImageBitmap +import androidx.compose.ui.unit.dp +import chat.simplex.app.views.chat.item.SentColorLight +import chat.simplex.app.views.helpers.base64ToBitmap + +@Composable +fun ComposeImageView(image: String) { + Row( + Modifier + .fillMaxWidth() + .padding(top = 8.dp) + .background(SentColorLight), + verticalAlignment = Alignment.CenterVertically + ) { + val imageBitmap = base64ToBitmap(image).asImageBitmap() + Image( + imageBitmap, + "preview image", + modifier = Modifier + .width(80.dp) + .height(60.dp) + .padding(end = 8.dp) + ) + } +} diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/chat/ComposeView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/chat/ComposeView.kt index 2c1973b054..e0ae8dc955 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/chat/ComposeView.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/chat/ComposeView.kt @@ -1,7 +1,20 @@ package chat.simplex.app.views.chat +import ComposeImageView +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.Icon +import androidx.compose.material.MaterialTheme +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.AddCircleOutline +import androidx.compose.material.icons.outlined.AttachFile 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.unit.dp import chat.simplex.app.model.* import chat.simplex.app.views.helpers.ComposeLinkView @@ -13,9 +26,11 @@ fun ComposeView( quotedItem: MutableState, editingItem: MutableState, linkPreview: MutableState, + imagePreview: MutableState, sendMessage: (String) -> Unit, resetMessage: () -> Unit, - parseMarkdown: (String) -> List? + parseMarkdown: (String) -> List?, + showBottomSheet: () -> Unit ) { val cancelledLinks = remember { mutableSetOf() } @@ -28,8 +43,13 @@ fun ComposeView( } Column { - val lp = linkPreview.value - if (lp != null) ComposeLinkView(lp, ::cancelPreview) + val ip = imagePreview.value + if (ip != null) { + ComposeImageView(ip) + } else { + val lp = linkPreview.value + if (lp != null) ComposeLinkView(lp, ::cancelPreview) + } when { quotedItem.value != null -> { ContextItemView(quotedItem) @@ -39,6 +59,26 @@ fun ComposeView( } else -> {} } - SendMsgView(msg, linkPreview, cancelledLinks, parseMarkdown, sendMessage, editing = editingItem.value != null) + Row( + modifier = Modifier.padding(start = 2.dp, end = 8.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(2.dp) + ) { + Icon( + Icons.Outlined.AddCircleOutline, + contentDescription = "Add image", + tint = if (editingItem.value == null) MaterialTheme.colors.primary else Color.Gray, + modifier = Modifier + .size(40.dp) + .padding(vertical = 4.dp) + .clip(CircleShape) + .clickable { + if (editingItem.value == null) { + showBottomSheet() + } + } + ) + SendMsgView(msg, linkPreview, cancelledLinks, parseMarkdown, sendMessage, editing = editingItem.value != null) + } } } diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/chat/SendMsgView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/chat/SendMsgView.kt index 7d1cb8d05b..2059462c94 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/chat/SendMsgView.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/chat/SendMsgView.kt @@ -106,7 +106,7 @@ fun SendMsgView( capitalization = KeyboardCapitalization.Sentences, autoCorrect = true ), - modifier = Modifier.padding(8.dp), + modifier = Modifier.padding(vertical = 8.dp), cursorBrush = SolidColor(HighOrLowlight), decorationBox = { innerTextField -> Surface( diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/ChatItemImageView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/ChatItemImageView.kt new file mode 100644 index 0000000000..0b8b208367 --- /dev/null +++ b/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/ChatItemImageView.kt @@ -0,0 +1,52 @@ +import android.content.Context +import android.graphics.Bitmap +import android.graphics.BitmapFactory +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.* +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.asImageBitmap +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalContext +import androidx.core.content.FileProvider +import chat.simplex.app.BuildConfig +import chat.simplex.app.model.CIFile +import chat.simplex.app.views.helpers.base64ToBitmap +import java.io.* + +@Composable +fun ChatItemImageView(image: String, file: CIFile?) { + Column { + val imageBitmap = if ( + file?.filePath != null && + File(file.filePath).exists() && + file.stored // TODO more advanced approach would be to send progressive jpeg and only check for filepath + ) { + val context = LocalContext.current + try { + getBitmapFromUri(context, file.filePath) + } catch (e: Exception) { + base64ToBitmap(image) + } + } else { + base64ToBitmap(image) + } + Image( + imageBitmap.asImageBitmap(), + contentDescription = "image", + modifier = Modifier.fillMaxWidth(), + contentScale = ContentScale.FillWidth, + ) + } +} + +// https://developer.android.com/training/data-storage/shared/documents-files#bitmap +@Throws(IOException::class) +private fun getBitmapFromUri(context: Context, file: String): Bitmap { + val uri = FileProvider.getUriForFile(context, "${BuildConfig.APPLICATION_ID}.provider", File(file)) + val parcelFileDescriptor = context.contentResolver.openFileDescriptor(uri, "r") + val fileDescriptor = parcelFileDescriptor?.fileDescriptor + val image = BitmapFactory.decodeFileDescriptor(fileDescriptor) + parcelFileDescriptor?.close() + return image +} diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/FramedItemView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/FramedItemView.kt index 54860a84f8..75e7dcb002 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/FramedItemView.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/FramedItemView.kt @@ -1,5 +1,6 @@ package chat.simplex.app.views.chat.item +import ChatItemImageView import androidx.compose.foundation.background import androidx.compose.foundation.layout.* import androidx.compose.foundation.shape.RoundedCornerShape @@ -61,7 +62,9 @@ fun FramedItemView(user: User, ci: ChatItem, uriHandler: UriHandler? = null, sho } else { Column(Modifier.fillMaxWidth()) { val mc = ci.content.msgContent - if (mc is MsgContent.MCLink) { + if (mc is MsgContent.MCImage) { + ChatItemImageView(image = mc.image, file = ci.file) + } else if (mc is MsgContent.MCLink) { ChatItemLinkView(mc.preview) } Box(Modifier.padding(vertical = 6.dp, horizontal = 12.dp)) { diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/helpers/GetImageView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/helpers/GetImageView.kt index 8c196e6ada..73a1292c61 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/helpers/GetImageView.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/helpers/GetImageView.kt @@ -37,7 +37,7 @@ import kotlin.math.sqrt // Inspired by https://github.com/MakeItEasyDev/Jetpack-Compose-Capture-Image-Or-Choose-from-Gallery -private fun cropToSquare(image: Bitmap): Bitmap { +fun cropToSquare(image: Bitmap): Bitmap { var xOffset = 0 var yOffset = 0 val side = min(image.height, image.width) @@ -124,7 +124,8 @@ fun rememberPermissionLauncher(cb: (Boolean) -> Unit): ManagedActivityResultLaun @Composable fun GetImageBottomSheet( - profileImageStr: MutableState, + imageBitmap: MutableState, + onImageChange: (Bitmap) -> Unit, hideBottomSheet: () -> Unit ) { val context = LocalContext.current @@ -134,12 +135,16 @@ fun GetImageBottomSheet( if (uri != null) { val source = ImageDecoder.createSource(context.contentResolver, uri) val bitmap = ImageDecoder.decodeBitmap(source) - profileImageStr.value = resizeImageToDataSize(cropToSquare(bitmap), maxDataSize = 12500) + imageBitmap.value = bitmap + onImageChange(bitmap) } } val cameraLauncher = rememberCameraLauncher { bitmap: Bitmap? -> - if (bitmap != null) profileImageStr.value = resizeImageToDataSize(cropToSquare(bitmap), maxDataSize = 12500) + if (bitmap != null) { + imageBitmap.value = bitmap + onImageChange(bitmap) + } } val permissionLauncher = rememberPermissionLauncher { isGranted: Boolean -> diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/usersettings/UserProfileView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/usersettings/UserProfileView.kt index 46ddaffe89..659dc8aac8 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/usersettings/UserProfileView.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/usersettings/UserProfileView.kt @@ -1,6 +1,7 @@ package chat.simplex.app.views.usersettings import android.content.res.Configuration +import android.graphics.Bitmap import androidx.compose.foundation.* import androidx.compose.foundation.layout.* import androidx.compose.foundation.shape.CircleShape @@ -63,6 +64,7 @@ fun UserProfileLayout( val bottomSheetModalState = rememberModalBottomSheetState(initialValue = ModalBottomSheetValue.Hidden) val displayName = remember { mutableStateOf(profile.displayName) } val fullName = remember { mutableStateOf(profile.fullName) } + val chosenImage = remember { mutableStateOf(null) } val profileImage = remember { mutableStateOf(profile.image) } val scope = rememberCoroutineScope() val scrollState = rememberScrollState() @@ -74,9 +76,12 @@ fun UserProfileLayout( scrimColor = Color.Black.copy(alpha = 0.12F), modifier = Modifier.navigationBarsWithImePadding(), sheetContent = { - GetImageBottomSheet(profileImage, hideBottomSheet = { - scope.launch { bottomSheetModalState.hide() } - }) + GetImageBottomSheet( + chosenImage, + onImageChange = { bitmap -> profileImage.value = resizeImageToDataSize(cropToSquare(bitmap), maxDataSize = 12500) }, + hideBottomSheet = { + scope.launch { bottomSheetModalState.hide() } + }) }, sheetState = bottomSheetModalState, sheetShape = RoundedCornerShape(topStart = 18.dp, topEnd = 18.dp) diff --git a/apps/ios/Shared/Model/ChatModel.swift b/apps/ios/Shared/Model/ChatModel.swift index 81a914aece..2b18cca0df 100644 --- a/apps/ios/Shared/Model/ChatModel.swift +++ b/apps/ios/Shared/Model/ChatModel.swift @@ -773,6 +773,17 @@ struct CIFile: Decodable { static func getSample(_ fileId: Int64, _ fileName: String, _ fileSize: Int64, filePath: String?, fileStatus: CIFileStatus = .sndStored) -> CIFile { CIFile(fileId: fileId, fileName: fileName, fileSize: fileSize, filePath: filePath, fileStatus: fileStatus) } + + var stored: Bool { + get { + switch self.fileStatus { + case .sndStored: return true + case .sndCancelled: return true + case .rcvComplete: return true + default: return false + } + } + } } enum CIFileStatus: String, Decodable { diff --git a/apps/ios/Shared/Views/Chat/ChatItem/FramedItemView.swift b/apps/ios/Shared/Views/Chat/ChatItem/FramedItemView.swift index f832f786eb..ed6ae48211 100644 --- a/apps/ios/Shared/Views/Chat/ChatItem/FramedItemView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItem/FramedItemView.swift @@ -52,7 +52,7 @@ struct FramedItemView: View { .padding(.bottom, 2) } else { if case let .image(_, image) = chatItem.content.msgContent { - ChatItemImageView(image: image, file: chatItem.file?.filePath) + ChatItemImageView(image: image, file: chatItem.file) } else if case let .link(_, preview) = chatItem.content.msgContent { ChatItemLinkView(linkPreview: preview) } diff --git a/apps/ios/Shared/Views/Chat/ChatView.swift b/apps/ios/Shared/Views/Chat/ChatView.swift index 2f7aa15e0e..6957ad5f35 100644 --- a/apps/ios/Shared/Views/Chat/ChatView.swift +++ b/apps/ios/Shared/Views/Chat/ChatView.swift @@ -260,8 +260,9 @@ struct ChatView: View { if let imageResized = resizeImageToDataSize(uiImage, maxDataSize: 160000), let dataResized = Data(base64Encoded: dropImagePrefix(imageResized)), let jpegData = UIImage(data: dataResized)?.jpegData(compressionQuality: 1) { - let imgName = "IMG_\(Date().timeIntervalSince1970).jpg" - let filename = getDocumentsDirectory().appendingPathComponent(imgName) + let millisecondsSince1970 = Int64((Date().timeIntervalSince1970 * 1000.0).rounded()) + let imageName = "image_\(millisecondsSince1970).jpg" + let filename = getDocumentsDirectory().appendingPathComponent(imageName) do { try jpegData.write(to: filename) return filename.path diff --git a/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeView.swift b/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeView.swift index 7ac4d4d69b..6cd6be00eb 100644 --- a/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeView.swift +++ b/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeView.swift @@ -124,6 +124,8 @@ struct ComposeView: View { } .onChange(of: chosenImage) { image in if let image = image { + // TODO + // imagePreview = resizeImageToDataSize(image, maxDataSize: 12500) imagePreview = resizeImageToDataSize(cropToSquare(image), maxDataSize: 12500) } else { imagePreview = nil diff --git a/apps/ios/Shared/Views/Helpers/ChatItemImageView.swift b/apps/ios/Shared/Views/Helpers/ChatItemImageView.swift index 539af990d7..4c2866a3aa 100644 --- a/apps/ios/Shared/Views/Helpers/ChatItemImageView.swift +++ b/apps/ios/Shared/Views/Helpers/ChatItemImageView.swift @@ -11,12 +11,14 @@ import SwiftUI struct ChatItemImageView: View { @Environment(\.colorScheme) var colorScheme let image: String - let file: String? + let file: CIFile? var body: some View { VStack(alignment: .center, spacing: 6) { if let file = file, - let uiImage = UIImage(contentsOfFile: file) { + let filePath = file.filePath, + file.stored, // TODO more advanced approach would be to send progressive jpeg and only check for filepath + let uiImage = UIImage(contentsOfFile: filePath) { Image(uiImage: uiImage) .resizable() .scaledToFit() @@ -29,26 +31,3 @@ struct ChatItemImageView: View { } } } - -func loadImage(file: String?) -> UIImage? { - if let file = file { - return UIImage(contentsOfFile: file) - } else { - return nil - } -} - - - -//struct ChatItemImageView_Previews: PreviewProvider { -// static var previews: some View { -// let preview = LinkPreview( -// uri: URL(string: "http://DuckDuckGo.com")!, -// title: "Privacy, simplified.", -// description: "", -// image: "data:image/jpg;base64,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" -// ) -// ChatItemLinkView(linkPreview: preview) -// .previewLayout(.fixed(width: 360, height: 200)) -// } -//}