From 8fa25b34af454974ff2800f963247ec514e9c7e2 Mon Sep 17 00:00:00 2001 From: jamesarich Date: Fri, 13 Dec 2024 08:44:04 -0600 Subject: [PATCH 1/5] Refactor: Improve Reaction UI in MessageList- Moves the ReactionRow below the MessageItem within a Column, providing better visual separation. - Updates ReactionItem UI to a more modern style with borders and rounded corners. - Implements an ellipsis indicator for overflowing reactions, allowing users to expand and view more. - Changes the reaction button to a plus icon, indicating the ability to add a reaction. --- .../mesh/ui/message/components/MessageList.kt | 69 ++++++----- .../mesh/ui/message/components/Reaction.kt | 110 +++++++++++++----- 2 files changed, 119 insertions(+), 60 deletions(-) diff --git a/app/src/main/java/com/geeksville/mesh/ui/message/components/MessageList.kt b/app/src/main/java/com/geeksville/mesh/ui/message/components/MessageList.kt index 21ad8525a..5ac539f30 100644 --- a/app/src/main/java/com/geeksville/mesh/ui/message/components/MessageList.kt +++ b/app/src/main/java/com/geeksville/mesh/ui/message/components/MessageList.kt @@ -17,9 +17,12 @@ package com.geeksville.mesh.ui.message.components +import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.wrapContentSize import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyListState @@ -38,6 +41,7 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.hapticfeedback.HapticFeedbackType import androidx.compose.ui.platform.LocalHapticFeedback +import androidx.compose.ui.unit.dp import com.geeksville.mesh.DataPacket import com.geeksville.mesh.database.entity.Reaction import com.geeksville.mesh.model.Message @@ -48,7 +52,7 @@ import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.debounce -@Suppress("LongMethod") +@Suppress("LongMethod", "MagicNumber") @Composable internal fun MessageList( messages: List, @@ -95,35 +99,40 @@ internal fun MessageList( val fromLocal = msg.node.user.id == DataPacket.ID_LOCAL val selected by remember { derivedStateOf { selectedIds.value.contains(msg.uuid) } } - ReactionRow(fromLocal, msg.emojis) { showReactionDialog = msg.emojis } - Box(Modifier.wrapContentSize(Alignment.TopStart)) { - var expandedNodeMenu by remember { mutableStateOf(false) } - MessageItem( - node = msg.node, - messageText = msg.text, - messageTime = msg.time, - messageStatus = msg.status, - selected = selected, - onClick = { if (inSelectionMode) selectedIds.toggle(msg.uuid) }, - onLongClick = { - selectedIds.toggle(msg.uuid) - haptics.performHapticFeedback(HapticFeedbackType.LongPress) - }, - onChipClick = { - if (msg.node.num != 0) { - expandedNodeMenu = true - } - }, - onStatusClick = { showStatusDialog = msg }, - onSendReaction = { onSendReaction(it, msg.packetId) }, - ) - NodeMenu( - node = msg.node, - showFullMenu = true, - onDismissRequest = { expandedNodeMenu = false }, - expanded = expandedNodeMenu, - onAction = onNodeMenuAction - ) + Column( + modifier = Modifier.padding(vertical = 8.dp), + verticalArrangement = Arrangement.spacedBy((-12).dp) + ) { + Box(Modifier.wrapContentSize(Alignment.TopStart)) { + var expandedNodeMenu by remember { mutableStateOf(false) } + MessageItem( + node = msg.node, + messageText = msg.text, + messageTime = msg.time, + messageStatus = msg.status, + selected = selected, + onClick = { if (inSelectionMode) selectedIds.toggle(msg.uuid) }, + onLongClick = { + selectedIds.toggle(msg.uuid) + haptics.performHapticFeedback(HapticFeedbackType.LongPress) + }, + onChipClick = { + if (msg.node.num != 0) { + expandedNodeMenu = true + } + }, + onStatusClick = { showStatusDialog = msg }, + onSendReaction = { onSendReaction(it, msg.packetId) }, + ) + NodeMenu( + node = msg.node, + showFullMenu = true, + onDismissRequest = { expandedNodeMenu = false }, + expanded = expandedNodeMenu, + onAction = onNodeMenuAction + ) + } + ReactionRow(fromLocal, msg.emojis) { showReactionDialog = msg.emojis } } } } diff --git a/app/src/main/java/com/geeksville/mesh/ui/message/components/Reaction.kt b/app/src/main/java/com/geeksville/mesh/ui/message/components/Reaction.kt index 53268dff1..e0fd2b430 100644 --- a/app/src/main/java/com/geeksville/mesh/ui/message/components/Reaction.kt +++ b/app/src/main/java/com/geeksville/mesh/ui/message/components/Reaction.kt @@ -17,24 +17,30 @@ package com.geeksville.mesh.ui.message.components +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.slideInVertically +import androidx.compose.animation.slideOutVertically +import androidx.compose.animation.togetherWith import androidx.compose.foundation.background +import androidx.compose.foundation.border import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.ExperimentalLayoutApi import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.FlowRowOverflow import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.items import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material.Badge -import androidx.compose.material.BadgedBox import androidx.compose.material.ContentAlpha import androidx.compose.material.Divider import androidx.compose.material.Icon @@ -43,7 +49,7 @@ import androidx.compose.material.MaterialTheme import androidx.compose.material.Surface import androidx.compose.material.Text import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.EmojiEmotions +import androidx.compose.material.icons.filled.AddReaction import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -53,10 +59,10 @@ 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.text.font.FontWeight import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.PreviewLightDark import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp import com.geeksville.mesh.MeshProtos import com.geeksville.mesh.database.entity.Reaction import com.geeksville.mesh.ui.components.BottomSheetDialog @@ -80,7 +86,7 @@ fun ReactionButton( } IconButton(onClick = { showEmojiPickerDialog = true }) { Icon( - imageVector = Icons.Default.EmojiEmotions, + imageVector = Icons.Default.AddReaction, contentDescription = "emoji", modifier = modifier.size(16.dp), tint = MaterialTheme.colors.onSurface.copy(alpha = ContentAlpha.medium), @@ -94,35 +100,51 @@ private fun ReactionItem( emojiCount: Int = 1, onClick: () -> Unit = {}, ) { - BadgedBox( - modifier = Modifier.padding(start = 2.dp, top = 8.dp, end = 2.dp, bottom = 4.dp), - badge = { - if (emojiCount > 1) { - Badge( - backgroundColor = MaterialTheme.colors.onBackground, - contentColor = MaterialTheme.colors.background, - ) { - Text( - fontWeight = FontWeight.Bold, - text = emojiCount.toString() - ) - } - } - } + + Surface( + modifier = Modifier + .padding(2.dp) + .border( + 1.dp, + MaterialTheme.colors.onSurface.copy(ContentAlpha.medium), + RoundedCornerShape(8.dp) + ) + .clickable { onClick() }, + color = MaterialTheme.colors.surface.copy(alpha = ContentAlpha.medium), + contentColor = MaterialTheme.colors.onSurface.copy(alpha = ContentAlpha.medium), + shape = RoundedCornerShape(8.dp), + elevation = 4.dp, ) { - Surface( + Row( modifier = Modifier - .clickable { onClick() }, - color = MaterialTheme.colors.surface, - shape = RoundedCornerShape(32.dp), - elevation = 4.dp, + .background(MaterialTheme.colors.surface) + .padding(4.dp), + verticalAlignment = Alignment.CenterVertically ) { Text( - text = emoji, - modifier = Modifier - .padding(8.dp) - .clip(CircleShape), + style = MaterialTheme.typography.h6, + text = emoji ) + if (emojiCount > 0) { + Spacer( + modifier = Modifier.width(2.dp) + ) + AnimatedContent( + targetState = emojiCount, + transitionSpec = { + if (targetState > initialState) { + slideInVertically { -it } togetherWith slideOutVertically { it } + } else { + slideInVertically { it } togetherWith slideOutVertically { -it } + } + } + ) { + Text( + text = "$it", + style = MaterialTheme.typography.body2, + ) + } + } } } } @@ -146,11 +168,21 @@ fun ReactionRow( ) } + var maxLines by remember { mutableStateOf(1) } FlowRow( modifier = Modifier .fillMaxWidth() .padding(horizontal = 16.dp), - horizontalArrangement = if (fromLocal) Arrangement.End else Arrangement.Start + horizontalArrangement = if (fromLocal) Arrangement.End else Arrangement.Start, + maxLines = maxLines, + overflow = FlowRowOverflow.expandIndicator { + ReactionItem( + emoji = "...", + emojiCount = 0 + ) { + maxLines += 1 + } + } ) { emojiList.forEach { entry -> ReactionItem( @@ -164,6 +196,23 @@ fun ReactionRow( } } +@Composable +internal fun Ellipsis(text: String, onClick: () -> Unit) { + Surface( + color = MaterialTheme.colors.surface, + contentColor = MaterialTheme.colors.onSurface, + modifier = Modifier + .clickable(onClick = onClick) + ) { + Text( + modifier = Modifier + .padding(3.dp), + text = text, + fontSize = 18.sp + ) + } +} + fun reduceEmojis(emojis: List): Map = emojis.groupingBy { it }.eachCount() @Composable @@ -231,6 +280,7 @@ fun ReactionItemPreview() { ) { ReactionItem(emoji = "\uD83D\uDE42") ReactionItem(emoji = "\uD83D\uDE42", emojiCount = 2) + ReactionItem(emoji = "\uD83D\uDE42", emojiCount = 222) ReactionButton() } } From 6e24b0f9fac09c143c978fbbe28878d2311289ec Mon Sep 17 00:00:00 2001 From: James Rich <2199651+jamesarich@users.noreply.github.com> Date: Sat, 14 Dec 2024 09:02:11 -0600 Subject: [PATCH 2/5] Add message reply functionality Allows users to reply to a specific message in a conversation. Adds a reply button to messages and updates the UI to show the message being replied to. Updates the service to handle reply messages. Still to-do: update db and Packet Repo to store the Reply. --- .../java/com/geeksville/mesh/model/UIState.kt | 4 +++ .../geeksville/mesh/service/MeshService.kt | 20 +++++++++++ .../com/geeksville/mesh/ui/message/Message.kt | 36 +++++++++++++++++-- .../mesh/ui/message/components/MessageItem.kt | 9 ++++- .../mesh/ui/message/components/MessageList.kt | 2 ++ .../mesh/ui/message/components/Reaction.kt | 16 +++++++++ app/src/main/res/values/strings.xml | 1 + 7 files changed, 84 insertions(+), 4 deletions(-) diff --git a/app/src/main/java/com/geeksville/mesh/model/UIState.kt b/app/src/main/java/com/geeksville/mesh/model/UIState.kt index e07304dc1..8de2a37d1 100644 --- a/app/src/main/java/com/geeksville/mesh/model/UIState.kt +++ b/app/src/main/java/com/geeksville/mesh/model/UIState.kt @@ -376,6 +376,10 @@ class UIViewModel @Inject constructor( radioConfigRepository.onServiceAction(ServiceAction.Reaction(emoji, replyId, contactKey)) } + fun sendReply(message: String, replyId: Int, contactKey: String) = viewModelScope.launch { + radioConfigRepository.onServiceAction(ServiceAction.Reply(message, replyId, contactKey)) + } + fun requestTraceroute(destNum: Int) { info("Requesting traceroute for '$destNum'") try { diff --git a/app/src/main/java/com/geeksville/mesh/service/MeshService.kt b/app/src/main/java/com/geeksville/mesh/service/MeshService.kt index f88f23d64..47ec2457e 100644 --- a/app/src/main/java/com/geeksville/mesh/service/MeshService.kt +++ b/app/src/main/java/com/geeksville/mesh/service/MeshService.kt @@ -79,6 +79,7 @@ import kotlin.math.absoluteValue sealed class ServiceAction { data class Ignore(val node: NodeEntity) : ServiceAction() data class Reaction(val emoji: String, val replyId: Int, val contactKey: String) : ServiceAction() + data class Reply(val message: String, val replyId: Int, val contactKey: String) : ServiceAction() } /** @@ -306,6 +307,7 @@ class MeshService : Service(), Logging { when (action) { is ServiceAction.Ignore -> ignoreNode(action.node) is ServiceAction.Reaction -> sendReaction(action) + is ServiceAction.Reply -> sendReply(action) } }.launchIn(serviceScope) @@ -643,6 +645,8 @@ class MeshService : Service(), Logging { packetRepository.get().insertReaction(reaction) } + + private fun rememberDataPacket(dataPacket: DataPacket, updateNotification: Boolean = true) { if (dataPacket.dataType !in rememberDataType) return val fromLocal = dataPacket.from == DataPacket.ID_LOCAL @@ -1793,6 +1797,22 @@ class MeshService : Service(), Logging { rememberReaction(packet.copy { from = myNodeNum }) } + private fun sendReply(reply: ServiceAction.Reply) = toRemoteExceptions { + val channel = reply.contactKey[0].digitToInt() + val destNum = reply.contactKey.substring(1) + + val packet = newMeshPacketTo(destNum).buildMeshPacket( + channel = channel, + priority = MeshPacket.Priority.DEFAULT, + ) { + replyId = reply.replyId + portnumValue = Portnums.PortNum.TEXT_MESSAGE_APP_VALUE + payload = ByteString.copyFrom(reply.message.encodeToByteArray()) + } + sendToRadio(packet) +// rememberReply(packet.copy { from = myNodeNum }) + } + private val binder = object : IMeshService.Stub() { override fun setDeviceAddress(deviceAddr: String?) = toRemoteExceptions { diff --git a/app/src/main/java/com/geeksville/mesh/ui/message/Message.kt b/app/src/main/java/com/geeksville/mesh/ui/message/Message.kt index 97c5bbbcf..03585cb7b 100644 --- a/app/src/main/java/com/geeksville/mesh/ui/message/Message.kt +++ b/app/src/main/java/com/geeksville/mesh/ui/message/Message.kt @@ -47,6 +47,7 @@ import androidx.compose.material.TextFieldDefaults import androidx.compose.material.TopAppBar import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.automirrored.filled.Reply import androidx.compose.material.icons.automirrored.filled.Send import androidx.compose.material.icons.filled.ContentCopy import androidx.compose.material.icons.filled.Delete @@ -90,6 +91,7 @@ import com.geeksville.mesh.R import com.geeksville.mesh.android.Logging import com.geeksville.mesh.database.entity.NodeEntity import com.geeksville.mesh.database.entity.QuickChatAction +import com.geeksville.mesh.model.Message import com.geeksville.mesh.model.UIViewModel import com.geeksville.mesh.model.getChannel import com.geeksville.mesh.ui.components.NodeKeyStatusIcon @@ -197,6 +199,7 @@ internal fun MessageScreen( val messageInput = rememberSaveable(stateSaver = TextFieldValue.Saver) { mutableStateOf(TextFieldValue(message)) } + var replyingTo by rememberSaveable { mutableStateOf(null) } var showDeleteDialog by remember { mutableStateOf(false) } if (showDeleteDialog) { @@ -264,7 +267,20 @@ internal fun MessageScreen( viewModel.sendMessage(action.message, contactKey) } } - TextInput(isConnected, messageInput) { viewModel.sendMessage(it, contactKey) } + val isReply = replyingTo != null + if (isReply) { + Text("Replying to: ${replyingTo?.text}") + } + TextInput(isConnected, isReply, messageInput) { message -> + if (isReply) { + replyingTo?.let { + viewModel.sendReply(message, it.packetId, contactKey) + replyingTo = null + } + } else { + viewModel.sendMessage(message, contactKey) + } + } } } ) { innerPadding -> @@ -275,6 +291,9 @@ internal fun MessageScreen( onUnreadChanged = { viewModel.clearUnreadCount(contactKey, it) }, contentPadding = innerPadding, onSendReaction = { emoji, id -> viewModel.sendReaction(emoji, id, contactKey) }, + onReplyClick = { msg -> + replyingTo = msg + }, ) { action -> when (action) { is NodeMenuAction.Remove -> viewModel.removeNode(action.node.num) @@ -418,6 +437,7 @@ private fun QuickChatRow( @Composable private fun TextInput( enabled: Boolean, + isReply: Boolean = false, message: MutableState, modifier: Modifier = Modifier, maxSize: Int = 200, @@ -440,7 +460,13 @@ private fun TextInput( .weight(1f) .onFocusEvent { isFocused = it.isFocused }, enabled = enabled, - placeholder = { Text(stringResource(id = R.string.send_text)) }, + placeholder = { Text( + text = if (isReply) { + stringResource(id = R.string.send_reply) + } else { + stringResource(id = R.string.send_text) + } + ) }, maxLines = 3, shape = RoundedCornerShape(24.dp), colors = TextFieldDefaults.textFieldColors( @@ -462,7 +488,11 @@ private fun TextInput( shape = CircleShape, ) { Icon( - imageVector = Icons.AutoMirrored.Default.Send, + imageVector = if (isReply) { + Icons.AutoMirrored.Filled.Reply + } else { + Icons.AutoMirrored.Default.Send + }, contentDescription = stringResource(id = R.string.send_text), modifier = Modifier.scale(scale = 1.5f), ) diff --git a/app/src/main/java/com/geeksville/mesh/ui/message/components/MessageItem.kt b/app/src/main/java/com/geeksville/mesh/ui/message/components/MessageItem.kt index 997af5a2d..bee859b79 100644 --- a/app/src/main/java/com/geeksville/mesh/ui/message/components/MessageItem.kt +++ b/app/src/main/java/com/geeksville/mesh/ui/message/components/MessageItem.kt @@ -25,6 +25,7 @@ import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width @@ -79,6 +80,8 @@ internal fun MessageItem( onChipClick: () -> Unit = {}, onStatusClick: () -> Unit = {}, onSendReaction: (String) -> Unit = {}, + onReplyClick: () -> Unit = {}, + ) = Row( modifier = Modifier .fillMaxWidth() @@ -182,8 +185,12 @@ internal fun MessageItem( } } } - if (!fromLocal) { + if (!fromLocal && selected) { ReactionButton(Modifier.padding(16.dp), onSendReaction) + + ReplyButton(Modifier.padding(16.dp), onReplyClick) + } else if (!fromLocal) { + Spacer(modifier = Modifier.width(16.dp)) } } diff --git a/app/src/main/java/com/geeksville/mesh/ui/message/components/MessageList.kt b/app/src/main/java/com/geeksville/mesh/ui/message/components/MessageList.kt index 5ac539f30..315c2620d 100644 --- a/app/src/main/java/com/geeksville/mesh/ui/message/components/MessageList.kt +++ b/app/src/main/java/com/geeksville/mesh/ui/message/components/MessageList.kt @@ -60,6 +60,7 @@ internal fun MessageList( onUnreadChanged: (Long) -> Unit, contentPadding: PaddingValues, onSendReaction: (String, Int) -> Unit, + onReplyClick: (Message) -> Unit, onNodeMenuAction: (NodeMenuAction) -> Unit = {} ) { val haptics = LocalHapticFeedback.current @@ -123,6 +124,7 @@ internal fun MessageList( }, onStatusClick = { showStatusDialog = msg }, onSendReaction = { onSendReaction(it, msg.packetId) }, + onReplyClick = { onReplyClick(msg) } ) NodeMenu( node = msg.node, diff --git a/app/src/main/java/com/geeksville/mesh/ui/message/components/Reaction.kt b/app/src/main/java/com/geeksville/mesh/ui/message/components/Reaction.kt index e0fd2b430..0ad5fef39 100644 --- a/app/src/main/java/com/geeksville/mesh/ui/message/components/Reaction.kt +++ b/app/src/main/java/com/geeksville/mesh/ui/message/components/Reaction.kt @@ -49,6 +49,7 @@ import androidx.compose.material.MaterialTheme import androidx.compose.material.Surface import androidx.compose.material.Text import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.Reply import androidx.compose.material.icons.filled.AddReaction import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue @@ -94,6 +95,21 @@ fun ReactionButton( } } +@Composable +fun ReplyButton( + modifier: Modifier = Modifier, + onClick: () -> Unit = {} +) { + IconButton(onClick = onClick) { + Icon( + imageVector = Icons.AutoMirrored.Filled.Reply, + contentDescription = "reply", + modifier = modifier.size(16.dp), + tint = MaterialTheme.colors.onSurface.copy(alpha = ContentAlpha.medium), + ) + } +} + @Composable private fun ReactionItem( emoji: String, diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index a67242570..6921585a4 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -81,6 +81,7 @@ Unknown Username Send Send Text + Send Reply You haven\'t yet paired a Meshtastic compatible radio with this phone. Please pair a device and set your username.\n\nThis open-source application is in development, if you find problems please post on our forum: https://github.com/orgs/meshtastic/discussions.\n\nFor more information see our web page - www.meshtastic.org. You Your Name From 371d93ba64871098b0f50e53468e826abc5462b1 Mon Sep 17 00:00:00 2001 From: James Rich <2199651+jamesarich@users.noreply.github.com> Date: Sat, 14 Dec 2024 13:53:25 -0600 Subject: [PATCH 3/5] feat: Add replies to messages This commit adds the ability to reply to messages. - Added a new Reply entity to the database. - Added a new ReplyRow composable to display replies. - Updated the MessageItem composable to display replies. - Updated the MessageList composable to display replies. - Updated the MeshService to handle replies. - Updated the PacketRepository to handle replies. - Updated the PacketDao to handle replies. - Updated the MessageItem to allow reactions and replies to sent messages. --- .../16.json | 572 ++++++++++++++++++ .../mesh/database/MeshtasticDatabase.kt | 5 +- .../mesh/database/PacketRepository.kt | 5 + .../geeksville/mesh/database/dao/PacketDao.kt | 4 + .../geeksville/mesh/database/entity/Packet.kt | 37 ++ .../java/com/geeksville/mesh/model/Message.kt | 2 + .../geeksville/mesh/service/MeshService.kt | 48 +- .../com/geeksville/mesh/ui/message/Message.kt | 14 +- .../mesh/ui/message/components/MessageItem.kt | 204 +++++-- .../mesh/ui/message/components/MessageList.kt | 3 +- app/src/main/res/values/strings.xml | 1 + 11 files changed, 825 insertions(+), 70 deletions(-) create mode 100644 app/schemas/com.geeksville.mesh.database.MeshtasticDatabase/16.json diff --git a/app/schemas/com.geeksville.mesh.database.MeshtasticDatabase/16.json b/app/schemas/com.geeksville.mesh.database.MeshtasticDatabase/16.json new file mode 100644 index 000000000..1b498a5af --- /dev/null +++ b/app/schemas/com.geeksville.mesh.database.MeshtasticDatabase/16.json @@ -0,0 +1,572 @@ +{ + "formatVersion": 1, + "database": { + "version": 16, + "identityHash": "ee3730e776dbb036cc6c4423910e4d03", + "entities": [ + { + "tableName": "my_node", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`myNodeNum` INTEGER NOT NULL, `model` TEXT, `firmwareVersion` TEXT, `couldUpdate` INTEGER NOT NULL, `shouldUpdate` INTEGER NOT NULL, `currentPacketId` INTEGER NOT NULL, `messageTimeoutMsec` INTEGER NOT NULL, `minAppVersion` INTEGER NOT NULL, `maxChannels` INTEGER NOT NULL, `hasWifi` INTEGER NOT NULL, PRIMARY KEY(`myNodeNum`))", + "fields": [ + { + "fieldPath": "myNodeNum", + "columnName": "myNodeNum", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "model", + "columnName": "model", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "firmwareVersion", + "columnName": "firmwareVersion", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "couldUpdate", + "columnName": "couldUpdate", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "shouldUpdate", + "columnName": "shouldUpdate", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "currentPacketId", + "columnName": "currentPacketId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "messageTimeoutMsec", + "columnName": "messageTimeoutMsec", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "minAppVersion", + "columnName": "minAppVersion", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "maxChannels", + "columnName": "maxChannels", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "hasWifi", + "columnName": "hasWifi", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "myNodeNum" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "nodes", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`num` INTEGER NOT NULL, `user` BLOB NOT NULL, `long_name` TEXT, `short_name` TEXT, `position` BLOB NOT NULL, `latitude` REAL NOT NULL, `longitude` REAL NOT NULL, `snr` REAL NOT NULL, `rssi` INTEGER NOT NULL, `last_heard` INTEGER NOT NULL, `device_metrics` BLOB NOT NULL, `channel` INTEGER NOT NULL, `via_mqtt` INTEGER NOT NULL, `hops_away` INTEGER NOT NULL, `is_favorite` INTEGER NOT NULL, `is_ignored` INTEGER NOT NULL DEFAULT 0, `environment_metrics` BLOB NOT NULL, `power_metrics` BLOB NOT NULL, `paxcounter` BLOB NOT NULL, PRIMARY KEY(`num`))", + "fields": [ + { + "fieldPath": "num", + "columnName": "num", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "user", + "columnName": "user", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "longName", + "columnName": "long_name", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "shortName", + "columnName": "short_name", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "position", + "columnName": "position", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "latitude", + "columnName": "latitude", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "longitude", + "columnName": "longitude", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "snr", + "columnName": "snr", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "rssi", + "columnName": "rssi", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastHeard", + "columnName": "last_heard", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "deviceTelemetry", + "columnName": "device_metrics", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "channel", + "columnName": "channel", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "viaMqtt", + "columnName": "via_mqtt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "hopsAway", + "columnName": "hops_away", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isFavorite", + "columnName": "is_favorite", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isIgnored", + "columnName": "is_ignored", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "environmentTelemetry", + "columnName": "environment_metrics", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "powerTelemetry", + "columnName": "power_metrics", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "paxcounter", + "columnName": "paxcounter", + "affinity": "BLOB", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "num" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "packet", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`uuid` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `myNodeNum` INTEGER NOT NULL DEFAULT 0, `port_num` INTEGER NOT NULL, `contact_key` TEXT NOT NULL, `received_time` INTEGER NOT NULL, `read` INTEGER NOT NULL DEFAULT 1, `data` TEXT NOT NULL, `packet_id` INTEGER NOT NULL DEFAULT 0, `routing_error` INTEGER NOT NULL DEFAULT -1, `reply_id` INTEGER NOT NULL DEFAULT 0)", + "fields": [ + { + "fieldPath": "uuid", + "columnName": "uuid", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "myNodeNum", + "columnName": "myNodeNum", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "port_num", + "columnName": "port_num", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "contact_key", + "columnName": "contact_key", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "received_time", + "columnName": "received_time", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "read", + "columnName": "read", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "1" + }, + { + "fieldPath": "data", + "columnName": "data", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "packetId", + "columnName": "packet_id", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "routingError", + "columnName": "routing_error", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "-1" + }, + { + "fieldPath": "replyId", + "columnName": "reply_id", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "uuid" + ] + }, + "indices": [ + { + "name": "index_packet_myNodeNum", + "unique": false, + "columnNames": [ + "myNodeNum" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_packet_myNodeNum` ON `${TABLE_NAME}` (`myNodeNum`)" + }, + { + "name": "index_packet_port_num", + "unique": false, + "columnNames": [ + "port_num" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_packet_port_num` ON `${TABLE_NAME}` (`port_num`)" + }, + { + "name": "index_packet_contact_key", + "unique": false, + "columnNames": [ + "contact_key" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_packet_contact_key` ON `${TABLE_NAME}` (`contact_key`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "contact_settings", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`contact_key` TEXT NOT NULL, `muteUntil` INTEGER NOT NULL, PRIMARY KEY(`contact_key`))", + "fields": [ + { + "fieldPath": "contact_key", + "columnName": "contact_key", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "muteUntil", + "columnName": "muteUntil", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "contact_key" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "log", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`uuid` TEXT NOT NULL, `type` TEXT NOT NULL, `received_date` INTEGER NOT NULL, `message` TEXT NOT NULL, `from_num` INTEGER NOT NULL DEFAULT 0, `port_num` INTEGER NOT NULL DEFAULT 0, `from_radio` BLOB NOT NULL DEFAULT x'', PRIMARY KEY(`uuid`))", + "fields": [ + { + "fieldPath": "uuid", + "columnName": "uuid", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "message_type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "received_date", + "columnName": "received_date", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "raw_message", + "columnName": "message", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "fromNum", + "columnName": "from_num", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "portNum", + "columnName": "port_num", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "fromRadio", + "columnName": "from_radio", + "affinity": "BLOB", + "notNull": true, + "defaultValue": "x''" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "uuid" + ] + }, + "indices": [ + { + "name": "index_log_from_num", + "unique": false, + "columnNames": [ + "from_num" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_log_from_num` ON `${TABLE_NAME}` (`from_num`)" + }, + { + "name": "index_log_port_num", + "unique": false, + "columnNames": [ + "port_num" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_log_port_num` ON `${TABLE_NAME}` (`port_num`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "quick_chat", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`uuid` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `message` TEXT NOT NULL, `mode` TEXT NOT NULL, `position` INTEGER NOT NULL)", + "fields": [ + { + "fieldPath": "uuid", + "columnName": "uuid", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "message", + "columnName": "message", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "mode", + "columnName": "mode", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "position", + "columnName": "position", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "uuid" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "reactions", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`reply_id` INTEGER NOT NULL, `user_id` TEXT NOT NULL, `emoji` TEXT NOT NULL, `timestamp` INTEGER NOT NULL, PRIMARY KEY(`reply_id`, `user_id`, `emoji`))", + "fields": [ + { + "fieldPath": "replyId", + "columnName": "reply_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "userId", + "columnName": "user_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "emoji", + "columnName": "emoji", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "timestamp", + "columnName": "timestamp", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "reply_id", + "user_id", + "emoji" + ] + }, + "indices": [ + { + "name": "index_reactions_reply_id", + "unique": false, + "columnNames": [ + "reply_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_reactions_reply_id` ON `${TABLE_NAME}` (`reply_id`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "replies", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`reply_id` INTEGER NOT NULL, `user_id` TEXT NOT NULL, `message` TEXT NOT NULL, `timestamp` INTEGER NOT NULL, PRIMARY KEY(`reply_id`, `user_id`, `message`))", + "fields": [ + { + "fieldPath": "replyId", + "columnName": "reply_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "userId", + "columnName": "user_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "message", + "columnName": "message", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "timestamp", + "columnName": "timestamp", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "reply_id", + "user_id", + "message" + ] + }, + "indices": [ + { + "name": "index_replies_reply_id", + "unique": false, + "columnNames": [ + "reply_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_replies_reply_id` ON `${TABLE_NAME}` (`reply_id`)" + } + ], + "foreignKeys": [] + } + ], + "views": [], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'ee3730e776dbb036cc6c4423910e4d03')" + ] + } +} \ No newline at end of file diff --git a/app/src/main/java/com/geeksville/mesh/database/MeshtasticDatabase.kt b/app/src/main/java/com/geeksville/mesh/database/MeshtasticDatabase.kt index 01f84d489..a8d46f698 100644 --- a/app/src/main/java/com/geeksville/mesh/database/MeshtasticDatabase.kt +++ b/app/src/main/java/com/geeksville/mesh/database/MeshtasticDatabase.kt @@ -36,6 +36,7 @@ import com.geeksville.mesh.database.entity.NodeEntity import com.geeksville.mesh.database.entity.Packet import com.geeksville.mesh.database.entity.QuickChatAction import com.geeksville.mesh.database.entity.ReactionEntity +import com.geeksville.mesh.database.entity.ReplyEntity @Database( entities = [ @@ -46,6 +47,7 @@ import com.geeksville.mesh.database.entity.ReactionEntity MeshLog::class, QuickChatAction::class, ReactionEntity::class, + ReplyEntity::class, ], autoMigrations = [ AutoMigration(from = 3, to = 4), @@ -60,8 +62,9 @@ import com.geeksville.mesh.database.entity.ReactionEntity AutoMigration(from = 12, to = 13, spec = AutoMigration12to13::class), AutoMigration(from = 13, to = 14), AutoMigration(from = 14, to = 15), + AutoMigration(from = 15, to = 16), ], - version = 15, + version = 16, exportSchema = true, ) @TypeConverters(Converters::class) diff --git a/app/src/main/java/com/geeksville/mesh/database/PacketRepository.kt b/app/src/main/java/com/geeksville/mesh/database/PacketRepository.kt index c362b66db..791b863d5 100644 --- a/app/src/main/java/com/geeksville/mesh/database/PacketRepository.kt +++ b/app/src/main/java/com/geeksville/mesh/database/PacketRepository.kt @@ -24,6 +24,7 @@ import com.geeksville.mesh.database.dao.PacketDao import com.geeksville.mesh.database.entity.ContactSettings import com.geeksville.mesh.database.entity.Packet import com.geeksville.mesh.database.entity.ReactionEntity +import com.geeksville.mesh.database.entity.ReplyEntity import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.withContext @@ -107,4 +108,8 @@ class PacketRepository @Inject constructor(private val packetDaoLazy: dagger.Laz suspend fun insertReaction(reaction: ReactionEntity) = withContext(Dispatchers.IO) { packetDao.insert(reaction) } + + suspend fun insertReply(reply: ReplyEntity) = withContext(Dispatchers.IO) { + packetDao.insert(reply) + } } diff --git a/app/src/main/java/com/geeksville/mesh/database/dao/PacketDao.kt b/app/src/main/java/com/geeksville/mesh/database/dao/PacketDao.kt index ea7e59595..2f8ce43e3 100644 --- a/app/src/main/java/com/geeksville/mesh/database/dao/PacketDao.kt +++ b/app/src/main/java/com/geeksville/mesh/database/dao/PacketDao.kt @@ -29,6 +29,7 @@ import com.geeksville.mesh.database.entity.ContactSettings import com.geeksville.mesh.database.entity.PacketEntity import com.geeksville.mesh.database.entity.Packet import com.geeksville.mesh.database.entity.ReactionEntity +import com.geeksville.mesh.database.entity.ReplyEntity import kotlinx.coroutines.flow.Flow @Dao @@ -214,4 +215,7 @@ interface PacketDao { @Upsert suspend fun insert(reaction: ReactionEntity) + + @Upsert + suspend fun insert(reply: ReplyEntity) } diff --git a/app/src/main/java/com/geeksville/mesh/database/entity/Packet.kt b/app/src/main/java/com/geeksville/mesh/database/entity/Packet.kt index c36b0c6cb..94d2be306 100644 --- a/app/src/main/java/com/geeksville/mesh/database/entity/Packet.kt +++ b/app/src/main/java/com/geeksville/mesh/database/entity/Packet.kt @@ -32,6 +32,8 @@ data class PacketEntity( @Embedded val packet: Packet, @Relation(entity = ReactionEntity::class, parentColumn = "packet_id", entityColumn = "reply_id") val reactions: List = emptyList(), + @Relation(entity = ReplyEntity::class, parentColumn = "packet_id", entityColumn = "reply_id") + val replies: List = emptyList(), ) { suspend fun toMessage(getNode: suspend (userId: String?) -> NodeEntity) = with(packet) { Message( @@ -45,6 +47,7 @@ data class PacketEntity( routingError = routingError, packetId = packetId, emojis = reactions.toReaction(getNode), + replies = replies.toReply(getNode), ) } } @@ -112,3 +115,37 @@ private suspend fun ReactionEntity.toReaction( private suspend fun List.toReaction( getNode: suspend (userId: String?) -> NodeEntity ) = this.map { it.toReaction(getNode) } + +data class Reply( + val replyId: Int, + val user: User, + val message: String, + val timestamp: Long, +) + +@Entity( + tableName = "replies", + primaryKeys = ["reply_id", "user_id", "message"], + indices = [ + Index(value = ["reply_id"]), + ], +) +data class ReplyEntity( + @ColumnInfo(name = "reply_id") val replyId: Int, + @ColumnInfo(name = "user_id") val userId: String, + val message: String, + val timestamp: Long, +) + +private suspend fun ReplyEntity.toReply( + getNode: suspend (userId: String?) -> NodeEntity +) = Reply( + replyId = replyId, + user = getNode(userId).user, + message = message, + timestamp = timestamp, +) + +private suspend fun List.toReply( + getNode: suspend (userId: String?) -> NodeEntity +) = this.map { it.toReply(getNode) } diff --git a/app/src/main/java/com/geeksville/mesh/model/Message.kt b/app/src/main/java/com/geeksville/mesh/model/Message.kt index 6dc98b8be..3c7554c26 100644 --- a/app/src/main/java/com/geeksville/mesh/model/Message.kt +++ b/app/src/main/java/com/geeksville/mesh/model/Message.kt @@ -22,6 +22,7 @@ import com.geeksville.mesh.MessageStatus import com.geeksville.mesh.R import com.geeksville.mesh.database.entity.NodeEntity import com.geeksville.mesh.database.entity.Reaction +import com.geeksville.mesh.database.entity.Reply val Routing.Error.stringRes: Int get() = when (this) { @@ -55,6 +56,7 @@ data class Message( val routingError: Int, val packetId: Int, val emojis: List, + val replies: List, ) { private fun getStatusStringRes(value: Int): Int { val error = Routing.Error.forNumber(value) ?: Routing.Error.UNRECOGNIZED diff --git a/app/src/main/java/com/geeksville/mesh/service/MeshService.kt b/app/src/main/java/com/geeksville/mesh/service/MeshService.kt index 47ec2457e..13b5d8ecb 100644 --- a/app/src/main/java/com/geeksville/mesh/service/MeshService.kt +++ b/app/src/main/java/com/geeksville/mesh/service/MeshService.kt @@ -26,17 +26,38 @@ import android.os.IBinder import android.os.RemoteException import androidx.core.app.ServiceCompat import androidx.core.location.LocationCompat -import com.geeksville.mesh.* +import com.geeksville.mesh.AdminProtos +import com.geeksville.mesh.AppOnlyProtos +import com.geeksville.mesh.BuildConfig +import com.geeksville.mesh.ChannelProtos +import com.geeksville.mesh.ConfigProtos +import com.geeksville.mesh.CoroutineDispatchers +import com.geeksville.mesh.DataPacket +import com.geeksville.mesh.IMeshService import com.geeksville.mesh.LocalOnlyProtos.LocalConfig import com.geeksville.mesh.LocalOnlyProtos.LocalModuleConfig +import com.geeksville.mesh.MeshProtos import com.geeksville.mesh.MeshProtos.MeshPacket import com.geeksville.mesh.MeshProtos.ToRadio +import com.geeksville.mesh.MeshUser +import com.geeksville.mesh.MessageStatus +import com.geeksville.mesh.ModuleConfigProtos +import com.geeksville.mesh.MyNodeInfo +import com.geeksville.mesh.NodeInfo +import com.geeksville.mesh.PaxcountProtos +import com.geeksville.mesh.Portnums +import com.geeksville.mesh.Position +import com.geeksville.mesh.R +import com.geeksville.mesh.StoreAndForwardProtos +import com.geeksville.mesh.TelemetryProtos import com.geeksville.mesh.TelemetryProtos.LocalStats import com.geeksville.mesh.analytics.DataPair import com.geeksville.mesh.android.GeeksvilleApplication import com.geeksville.mesh.android.Logging import com.geeksville.mesh.android.hasLocationPermission import com.geeksville.mesh.concurrent.handledLaunch +import com.geeksville.mesh.config +import com.geeksville.mesh.copy import com.geeksville.mesh.database.MeshLogRepository import com.geeksville.mesh.database.PacketRepository import com.geeksville.mesh.database.entity.MeshLog @@ -44,15 +65,23 @@ import com.geeksville.mesh.database.entity.MyNodeEntity import com.geeksville.mesh.database.entity.NodeEntity import com.geeksville.mesh.database.entity.Packet import com.geeksville.mesh.database.entity.ReactionEntity +import com.geeksville.mesh.database.entity.ReplyEntity import com.geeksville.mesh.database.entity.toNodeInfo +import com.geeksville.mesh.fromRadio import com.geeksville.mesh.model.DeviceVersion import com.geeksville.mesh.model.getTracerouteResponse +import com.geeksville.mesh.position import com.geeksville.mesh.repository.datastore.RadioConfigRepository import com.geeksville.mesh.repository.location.LocationRepository import com.geeksville.mesh.repository.network.MQTTRepository import com.geeksville.mesh.repository.radio.RadioInterfaceService import com.geeksville.mesh.repository.radio.RadioServiceConnectionState -import com.geeksville.mesh.util.* +import com.geeksville.mesh.telemetry +import com.geeksville.mesh.user +import com.geeksville.mesh.util.anonymize +import com.geeksville.mesh.util.toOneLineString +import com.geeksville.mesh.util.toPIIString +import com.geeksville.mesh.util.toRemoteExceptions import com.google.protobuf.ByteString import com.google.protobuf.InvalidProtocolBufferException import dagger.Lazy @@ -645,6 +674,16 @@ class MeshService : Service(), Logging { packetRepository.get().insertReaction(reaction) } + private fun rememberReply(packet: MeshPacket) = serviceScope.handledLaunch { + val reply = ReplyEntity( + replyId = packet.decoded.replyId, + userId = toNodeID(packet.from), + message = packet.decoded.payload.toByteArray().decodeToString(), + timestamp = System.currentTimeMillis(), + ) + packetRepository.get().insertReply(reply) + } + private fun rememberDataPacket(dataPacket: DataPacket, updateNotification: Boolean = true) { @@ -702,6 +741,9 @@ class MeshService : Service(), Logging { if (data.emoji != 0) { debug("Received EMOJI from $fromId") rememberReaction(packet) + } else if (data.replyId != 0) { + debug("Received REPLY from $fromId") + rememberReply(packet) } else { debug("Received CLEAR_TEXT from $fromId") rememberDataPacket(dataPacket) @@ -1810,7 +1852,7 @@ class MeshService : Service(), Logging { payload = ByteString.copyFrom(reply.message.encodeToByteArray()) } sendToRadio(packet) -// rememberReply(packet.copy { from = myNodeNum }) + rememberReply(packet.copy { from = myNodeNum }) } private val binder = object : IMeshService.Stub() { diff --git a/app/src/main/java/com/geeksville/mesh/ui/message/Message.kt b/app/src/main/java/com/geeksville/mesh/ui/message/Message.kt index 03585cb7b..7e0184cf4 100644 --- a/app/src/main/java/com/geeksville/mesh/ui/message/Message.kt +++ b/app/src/main/java/com/geeksville/mesh/ui/message/Message.kt @@ -47,8 +47,8 @@ import androidx.compose.material.TextFieldDefaults import androidx.compose.material.TopAppBar import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack -import androidx.compose.material.icons.automirrored.filled.Reply -import androidx.compose.material.icons.automirrored.filled.Send +import androidx.compose.material.icons.automirrored.twotone.Reply +import androidx.compose.material.icons.automirrored.twotone.Send import androidx.compose.material.icons.filled.ContentCopy import androidx.compose.material.icons.filled.Delete import androidx.compose.material.icons.filled.SelectAll @@ -290,9 +290,13 @@ internal fun MessageScreen( selectedIds = selectedIds, onUnreadChanged = { viewModel.clearUnreadCount(contactKey, it) }, contentPadding = innerPadding, - onSendReaction = { emoji, id -> viewModel.sendReaction(emoji, id, contactKey) }, + onSendReaction = { emoji, id -> + viewModel.sendReaction(emoji, id, contactKey) + selectedIds.value = emptySet() + }, onReplyClick = { msg -> replyingTo = msg + selectedIds.value = emptySet() }, ) { action -> when (action) { @@ -489,9 +493,9 @@ private fun TextInput( ) { Icon( imageVector = if (isReply) { - Icons.AutoMirrored.Filled.Reply + Icons.AutoMirrored.TwoTone.Reply } else { - Icons.AutoMirrored.Default.Send + Icons.AutoMirrored.TwoTone.Send }, contentDescription = stringResource(id = R.string.send_text), modifier = Modifier.scale(scale = 1.5f), diff --git a/app/src/main/java/com/geeksville/mesh/ui/message/components/MessageItem.kt b/app/src/main/java/com/geeksville/mesh/ui/message/components/MessageItem.kt index bee859b79..017757bf9 100644 --- a/app/src/main/java/com/geeksville/mesh/ui/message/components/MessageItem.kt +++ b/app/src/main/java/com/geeksville/mesh/ui/message/components/MessageItem.kt @@ -28,6 +28,7 @@ import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.Card @@ -41,6 +42,7 @@ import androidx.compose.material.MaterialTheme import androidx.compose.material.Surface import androidx.compose.material.Text import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.twotone.Reply import androidx.compose.material.icons.twotone.Cloud import androidx.compose.material.icons.twotone.CloudDone import androidx.compose.material.icons.twotone.CloudOff @@ -61,9 +63,11 @@ import com.geeksville.mesh.DataPacket import com.geeksville.mesh.MessageStatus import com.geeksville.mesh.R import com.geeksville.mesh.database.entity.NodeEntity +import com.geeksville.mesh.database.entity.Reply import com.geeksville.mesh.ui.components.AutoLinkText import com.geeksville.mesh.ui.preview.NodeEntityPreviewParameterProvider import com.geeksville.mesh.ui.theme.AppTheme +import com.geeksville.mesh.util.getShortDateTime @Suppress("LongMethod") @OptIn(ExperimentalMaterialApi::class, ExperimentalFoundationApi::class) @@ -81,6 +85,7 @@ internal fun MessageItem( onStatusClick: () -> Unit = {}, onSendReaction: (String) -> Unit = {}, onReplyClick: () -> Unit = {}, + replies: List = emptyList(), ) = Row( modifier = Modifier @@ -111,35 +116,36 @@ internal fun MessageItem( ), color = colorResource(id = messageColor), ) { - Row( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 8.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - if (!fromLocal) { - Chip( - onClick = onChipClick, - modifier = Modifier - .padding(end = 8.dp) - .width(72.dp), - colors = ChipDefaults.chipColors( - backgroundColor = Color(node.colors.second), - contentColor = Color(node.colors.first), - ), - ) { - Text( - text = node.user.shortName, - modifier = Modifier.fillMaxWidth(), - fontSize = MaterialTheme.typography.button.fontSize, - fontWeight = FontWeight.Normal, - textAlign = TextAlign.Center, - ) - } - } - Column( - modifier = Modifier.padding(top = 8.dp), + Column { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 8.dp), + verticalAlignment = Alignment.CenterVertically, ) { + if (!fromLocal) { + Chip( + onClick = onChipClick, + modifier = Modifier + .padding(end = 8.dp) + .width(72.dp), + colors = ChipDefaults.chipColors( + backgroundColor = Color(node.colors.second), + contentColor = Color(node.colors.first), + ), + ) { + Text( + text = node.user.shortName, + modifier = Modifier.fillMaxWidth(), + fontSize = MaterialTheme.typography.button.fontSize, + fontWeight = FontWeight.Normal, + textAlign = TextAlign.Center, + ) + } + } + Column( + modifier = Modifier.padding(top = 8.dp), + ) { // if (!fromLocal) { // Text( // text = with(node.user) { "$longName ($id)" }, @@ -148,49 +154,113 @@ internal fun MessageItem( // fontSize = MaterialTheme.typography.caption.fontSize, // ) // } - AutoLinkText( - text = messageText.orEmpty(), - style = LocalTextStyle.current.copy( - color = LocalContentColor.current, - ), - ) - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.End, - verticalAlignment = Alignment.CenterVertically, - ) { - Text( - text = messageTime, - color = MaterialTheme.colors.onSurface, - fontSize = MaterialTheme.typography.caption.fontSize, + AutoLinkText( + text = messageText.orEmpty(), + style = LocalTextStyle.current.copy( + color = LocalContentColor.current, + ), ) - AnimatedVisibility(visible = fromLocal) { - Icon( - imageVector = when (messageStatus) { - MessageStatus.RECEIVED -> Icons.TwoTone.HowToReg - MessageStatus.QUEUED -> Icons.TwoTone.CloudUpload - MessageStatus.DELIVERED -> Icons.TwoTone.CloudDone - MessageStatus.ENROUTE -> Icons.TwoTone.Cloud - MessageStatus.ERROR -> Icons.TwoTone.CloudOff - else -> Icons.TwoTone.Warning - }, - contentDescription = stringResource(R.string.message_delivery_status), - modifier = Modifier - .padding(start = 8.dp) - .clickable { onStatusClick() }, + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.End, + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = messageTime, + color = MaterialTheme.colors.onSurface, + fontSize = MaterialTheme.typography.caption.fontSize, ) + AnimatedVisibility(visible = fromLocal) { + Icon( + imageVector = when (messageStatus) { + MessageStatus.RECEIVED -> Icons.TwoTone.HowToReg + MessageStatus.QUEUED -> Icons.TwoTone.CloudUpload + MessageStatus.DELIVERED -> Icons.TwoTone.CloudDone + MessageStatus.ENROUTE -> Icons.TwoTone.Cloud + MessageStatus.ERROR -> Icons.TwoTone.CloudOff + else -> Icons.TwoTone.Warning + }, + contentDescription = stringResource(R.string.message_delivery_status), + modifier = Modifier + .padding(start = 8.dp) + .clickable { onStatusClick() }, + ) + } } } } + replies.forEach { reply -> + ReplyRow(reply = reply) + } } } } - if (!fromLocal && selected) { + if (selected) { ReactionButton(Modifier.padding(16.dp), onSendReaction) ReplyButton(Modifier.padding(16.dp), onReplyClick) } else if (!fromLocal) { - Spacer(modifier = Modifier.width(16.dp)) + Spacer(modifier = Modifier.width(48.dp)) + } +} + +@OptIn(ExperimentalMaterialApi::class) +@Composable +private fun ReplyRow( + reply: Reply, +) { + Card( + modifier = Modifier + .fillMaxWidth() + .padding(bottom = 8.dp, start = 32.dp, end = 4.dp), + elevation = 4.dp, + ) { + Surface( + color = MaterialTheme.colors.surface, + contentColor = MaterialTheme.colors.onSurface, + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(8.dp), + ) { + + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.Top, + horizontalArrangement = Arrangement.SpaceBetween + ) { + Text( + text = reply.user.shortName, + fontSize = MaterialTheme.typography.caption.fontSize, + fontWeight = FontWeight.Light, + ) + Spacer(modifier = Modifier.width(8.dp)) + Text( + text = reply.message, + modifier = Modifier.weight(1f), + color = MaterialTheme.colors.onSurface, + fontSize = MaterialTheme.typography.caption.fontSize, + ) + Icon( + imageVector = Icons.AutoMirrored.TwoTone.Reply, + contentDescription = stringResource(R.string.reply), + modifier = Modifier.size(8.dp), + ) + } + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.End + ) { + Text( + text = getShortDateTime(reply.timestamp), + fontSize = MaterialTheme.typography.caption.fontSize, + fontWeight = FontWeight.Light, + ) + } + } + } } } @@ -201,9 +271,23 @@ private fun MessageItemPreview() { MessageItem( node = NodeEntityPreviewParameterProvider().values.first(), messageText = stringResource(R.string.sample_message), - messageTime = "10:00", + messageTime = getShortDateTime(System.currentTimeMillis() - 720000), messageStatus = MessageStatus.DELIVERED, selected = false, + replies = listOf( + Reply( + user = NodeEntityPreviewParameterProvider().values.first().user, + message = "Nevermind, it's not scary. Phew!", + replyId = 1, + timestamp = System.currentTimeMillis() - 320000 + ), + Reply( + user = NodeEntityPreviewParameterProvider().values.last().user, + message = "Nice, good job!", + replyId = 2, + timestamp = System.currentTimeMillis() + ) + ) ) } } diff --git a/app/src/main/java/com/geeksville/mesh/ui/message/components/MessageList.kt b/app/src/main/java/com/geeksville/mesh/ui/message/components/MessageList.kt index 315c2620d..e67da886c 100644 --- a/app/src/main/java/com/geeksville/mesh/ui/message/components/MessageList.kt +++ b/app/src/main/java/com/geeksville/mesh/ui/message/components/MessageList.kt @@ -124,7 +124,8 @@ internal fun MessageList( }, onStatusClick = { showStatusDialog = msg }, onSendReaction = { onSendReaction(it, msg.packetId) }, - onReplyClick = { onReplyClick(msg) } + onReplyClick = { onReplyClick(msg) }, + replies = msg.replies, ) NodeMenu( node = msg.node, diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 6921585a4..e16f34232 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -313,4 +313,5 @@ Unknown Age Copy Alert Bell Character! + Reply From 99864081035c420f27dacad602c389827037884b Mon Sep 17 00:00:00 2001 From: James Rich <2199651+jamesarich@users.noreply.github.com> Date: Sat, 14 Dec 2024 14:47:14 -0600 Subject: [PATCH 4/5] Refactor: Display replying message in a card with cancel button --- .../com/geeksville/mesh/ui/message/Message.kt | 27 +++++++++++++++++-- 1 file changed, 25 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/com/geeksville/mesh/ui/message/Message.kt b/app/src/main/java/com/geeksville/mesh/ui/message/Message.kt index 7e0184cf4..4d796d5d3 100644 --- a/app/src/main/java/com/geeksville/mesh/ui/message/Message.kt +++ b/app/src/main/java/com/geeksville/mesh/ui/message/Message.kt @@ -36,6 +36,7 @@ import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.AlertDialog import androidx.compose.material.Button import androidx.compose.material.ButtonDefaults +import androidx.compose.material.Card import androidx.compose.material.Icon import androidx.compose.material.IconButton import androidx.compose.material.MaterialTheme @@ -49,6 +50,7 @@ import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.automirrored.twotone.Reply import androidx.compose.material.icons.automirrored.twotone.Send +import androidx.compose.material.icons.filled.Cancel import androidx.compose.material.icons.filled.ContentCopy import androidx.compose.material.icons.filled.Delete import androidx.compose.material.icons.filled.SelectAll @@ -269,7 +271,27 @@ internal fun MessageScreen( } val isReply = replyingTo != null if (isReply) { - Text("Replying to: ${replyingTo?.text}") + Card { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = "Replying to: ${replyingTo?.text}", + modifier = Modifier + .weight(1f) + .padding(8.dp) + ) + IconButton(onClick = { replyingTo = null }) { + Icon( + imageVector = Icons.Default.Cancel, + contentDescription = "Cancel" + ) + } + } + } } TextInput(isConnected, isReply, messageInput) { message -> if (isReply) { @@ -464,7 +486,8 @@ private fun TextInput( .weight(1f) .onFocusEvent { isFocused = it.isFocused }, enabled = enabled, - placeholder = { Text( + label = { + Text( text = if (isReply) { stringResource(id = R.string.send_reply) } else { From 9ff811b9e0b3301256f8eb7000e62ea8bb1bd510 Mon Sep 17 00:00:00 2001 From: James Rich <2199651+jamesarich@users.noreply.github.com> Date: Sat, 14 Dec 2024 14:54:12 -0600 Subject: [PATCH 5/5] fix: make detekt happy --- app/src/main/java/com/geeksville/mesh/service/MeshService.kt | 2 -- app/src/main/java/com/geeksville/mesh/ui/message/Message.kt | 1 + .../com/geeksville/mesh/ui/message/components/MessageItem.kt | 3 ++- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/app/src/main/java/com/geeksville/mesh/service/MeshService.kt b/app/src/main/java/com/geeksville/mesh/service/MeshService.kt index 13b5d8ecb..a29234340 100644 --- a/app/src/main/java/com/geeksville/mesh/service/MeshService.kt +++ b/app/src/main/java/com/geeksville/mesh/service/MeshService.kt @@ -684,8 +684,6 @@ class MeshService : Service(), Logging { packetRepository.get().insertReply(reply) } - - private fun rememberDataPacket(dataPacket: DataPacket, updateNotification: Boolean = true) { if (dataPacket.dataType !in rememberDataType) return val fromLocal = dataPacket.from == DataPacket.ID_LOCAL diff --git a/app/src/main/java/com/geeksville/mesh/ui/message/Message.kt b/app/src/main/java/com/geeksville/mesh/ui/message/Message.kt index 4d796d5d3..bdef97d7e 100644 --- a/app/src/main/java/com/geeksville/mesh/ui/message/Message.kt +++ b/app/src/main/java/com/geeksville/mesh/ui/message/Message.kt @@ -460,6 +460,7 @@ private fun QuickChatRow( } } +@Suppress("LongMethod") @Composable private fun TextInput( enabled: Boolean, diff --git a/app/src/main/java/com/geeksville/mesh/ui/message/components/MessageItem.kt b/app/src/main/java/com/geeksville/mesh/ui/message/components/MessageItem.kt index 017757bf9..451738b54 100644 --- a/app/src/main/java/com/geeksville/mesh/ui/message/components/MessageItem.kt +++ b/app/src/main/java/com/geeksville/mesh/ui/message/components/MessageItem.kt @@ -69,7 +69,7 @@ import com.geeksville.mesh.ui.preview.NodeEntityPreviewParameterProvider import com.geeksville.mesh.ui.theme.AppTheme import com.geeksville.mesh.util.getShortDateTime -@Suppress("LongMethod") +@Suppress("LongMethod", "CyclomaticComplexMethod") @OptIn(ExperimentalMaterialApi::class, ExperimentalFoundationApi::class) @Composable internal fun MessageItem( @@ -264,6 +264,7 @@ private fun ReplyRow( } } +@Suppress("MagicNumber") @PreviewLightDark @Composable private fun MessageItemPreview() {