mirror of
https://github.com/meshtastic/Meshtastic-Android.git
synced 2026-03-27 18:21:58 -04:00
feat: Enhance message notifications with history and actions (#4133)
Signed-off-by: James Rich <2199651+jamesarich@users.noreply.github.com>
This commit is contained in:
@@ -221,6 +221,8 @@
|
||||
</intent-filter>
|
||||
</receiver>
|
||||
<receiver android:name="com.geeksville.mesh.service.ReplyReceiver"/>
|
||||
<receiver android:name="com.geeksville.mesh.service.MarkAsReadReceiver"/>
|
||||
<receiver android:name="com.geeksville.mesh.service.ReactionReceiver"/>
|
||||
|
||||
<!-- allow for plugin discovery -->
|
||||
<activity
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
/*
|
||||
* Copyright (c) 2025-2026 Meshtastic LLC
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package com.geeksville.mesh.service
|
||||
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.launch
|
||||
import org.meshtastic.core.data.repository.PacketRepository
|
||||
import org.meshtastic.core.service.MeshServiceNotifications
|
||||
import javax.inject.Inject
|
||||
|
||||
/** A [BroadcastReceiver] that handles "Mark as read" actions from notifications. */
|
||||
@AndroidEntryPoint
|
||||
class MarkAsReadReceiver : BroadcastReceiver() {
|
||||
@Inject lateinit var packetRepository: PacketRepository
|
||||
|
||||
@Inject lateinit var meshServiceNotifications: MeshServiceNotifications
|
||||
|
||||
private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
|
||||
|
||||
companion object {
|
||||
const val MARK_AS_READ_ACTION = "com.geeksville.mesh.MARK_AS_READ_ACTION"
|
||||
const val CONTACT_KEY = "contactKey"
|
||||
}
|
||||
|
||||
override fun onReceive(context: Context, intent: Intent) {
|
||||
if (intent.action == MARK_AS_READ_ACTION) {
|
||||
val contactKey = intent.getStringExtra(CONTACT_KEY) ?: return
|
||||
val pendingResult = goAsync()
|
||||
scope.launch {
|
||||
try {
|
||||
packetRepository.clearUnreadCount(contactKey, System.currentTimeMillis())
|
||||
meshServiceNotifications.cancelMessageNotification(contactKey)
|
||||
} finally {
|
||||
pendingResult.finish()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -521,11 +521,12 @@ constructor(
|
||||
}
|
||||
|
||||
private fun rememberReaction(packet: MeshPacket) = scope.handledLaunch {
|
||||
val emoji = packet.decoded.payload.toByteArray().decodeToString()
|
||||
val reaction =
|
||||
ReactionEntity(
|
||||
replyId = packet.decoded.replyId,
|
||||
userId = dataMapper.toNodeID(packet.from),
|
||||
emoji = packet.decoded.payload.toByteArray().decodeToString(),
|
||||
emoji = emoji,
|
||||
timestamp = System.currentTimeMillis(),
|
||||
snr = packet.rxSnr,
|
||||
rssi = packet.rxRssi,
|
||||
@@ -537,6 +538,31 @@ constructor(
|
||||
},
|
||||
)
|
||||
packetRepository.get().insertReaction(reaction)
|
||||
|
||||
// Find the original packet to get the contactKey
|
||||
packetRepository.get().getPacketByPacketId(packet.decoded.replyId)?.let { original ->
|
||||
val contactKey = original.packet.contact_key
|
||||
val isMuted = packetRepository.get().getContactSettings(contactKey).isMuted
|
||||
if (!isMuted) {
|
||||
val channelName =
|
||||
if (original.packet.data.to == DataPacket.ID_BROADCAST) {
|
||||
radioConfigRepository.channelSetFlow
|
||||
.first()
|
||||
.settingsList
|
||||
.getOrNull(original.packet.data.channel)
|
||||
?.name
|
||||
} else {
|
||||
null
|
||||
}
|
||||
serviceNotifications.updateReactionNotification(
|
||||
contactKey,
|
||||
getSenderName(dataMapper.toDataPacket(packet)!!),
|
||||
emoji,
|
||||
original.packet.data.to == DataPacket.ID_BROADCAST,
|
||||
channelName,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun currentTransport(address: String? = meshPrefs.deviceAddress): String = when (address?.firstOrNull()) {
|
||||
|
||||
@@ -24,21 +24,33 @@ import android.app.TaskStackBuilder
|
||||
import android.content.ContentResolver.SCHEME_ANDROID_RESOURCE
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.graphics.Canvas
|
||||
import android.graphics.Color
|
||||
import android.graphics.Paint
|
||||
import android.media.AudioAttributes
|
||||
import android.media.RingtoneManager
|
||||
import androidx.core.app.NotificationCompat
|
||||
import androidx.core.app.Person
|
||||
import androidx.core.app.RemoteInput
|
||||
import androidx.core.content.getSystemService
|
||||
import androidx.core.graphics.createBitmap
|
||||
import androidx.core.graphics.drawable.IconCompat
|
||||
import androidx.core.net.toUri
|
||||
import com.geeksville.mesh.MainActivity
|
||||
import com.geeksville.mesh.R.raw
|
||||
import com.geeksville.mesh.service.MarkAsReadReceiver.Companion.MARK_AS_READ_ACTION
|
||||
import com.geeksville.mesh.service.ReactionReceiver.Companion.REACT_ACTION
|
||||
import com.geeksville.mesh.service.ReplyReceiver.Companion.KEY_TEXT_REPLY
|
||||
import com.meshtastic.core.strings.getString
|
||||
import dagger.Lazy
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import kotlinx.coroutines.flow.first
|
||||
import org.jetbrains.compose.resources.StringResource
|
||||
import org.meshtastic.core.data.repository.NodeRepository
|
||||
import org.meshtastic.core.data.repository.PacketRepository
|
||||
import org.meshtastic.core.database.entity.NodeEntity
|
||||
import org.meshtastic.core.database.model.Message
|
||||
import org.meshtastic.core.model.DataPacket
|
||||
import org.meshtastic.core.model.util.formatUptime
|
||||
import org.meshtastic.core.navigation.DEEP_LINK_BASE_URI
|
||||
import org.meshtastic.core.service.MeshServiceNotifications
|
||||
@@ -47,7 +59,9 @@ import org.meshtastic.core.strings.Res
|
||||
import org.meshtastic.core.strings.client_notification
|
||||
import org.meshtastic.core.strings.low_battery_message
|
||||
import org.meshtastic.core.strings.low_battery_title
|
||||
import org.meshtastic.core.strings.mark_as_read
|
||||
import org.meshtastic.core.strings.meshtastic_alerts_notifications
|
||||
import org.meshtastic.core.strings.meshtastic_app_name
|
||||
import org.meshtastic.core.strings.meshtastic_broadcast_notifications
|
||||
import org.meshtastic.core.strings.meshtastic_low_battery_notifications
|
||||
import org.meshtastic.core.strings.meshtastic_low_battery_temporary_remote_notifications
|
||||
@@ -58,6 +72,7 @@ import org.meshtastic.core.strings.meshtastic_waypoints_notifications
|
||||
import org.meshtastic.core.strings.new_node_seen
|
||||
import org.meshtastic.core.strings.no_local_stats
|
||||
import org.meshtastic.core.strings.reply
|
||||
import org.meshtastic.core.strings.you
|
||||
import org.meshtastic.proto.MeshProtos
|
||||
import org.meshtastic.proto.TelemetryProtos
|
||||
import org.meshtastic.proto.TelemetryProtos.LocalStats
|
||||
@@ -69,9 +84,14 @@ import javax.inject.Inject
|
||||
* This class centralizes notification logic, including channel creation, builder configuration, and displaying
|
||||
* notifications for various events like new messages, alerts, and service status changes.
|
||||
*/
|
||||
@Suppress("TooManyFunctions")
|
||||
class MeshServiceNotificationsImpl @Inject constructor(@ApplicationContext private val context: Context) :
|
||||
MeshServiceNotifications {
|
||||
@Suppress("TooManyFunctions", "LongParameterList")
|
||||
class MeshServiceNotificationsImpl
|
||||
@Inject
|
||||
constructor(
|
||||
@ApplicationContext private val context: Context,
|
||||
private val packetRepository: Lazy<PacketRepository>,
|
||||
private val nodeRepository: Lazy<NodeRepository>,
|
||||
) : MeshServiceNotifications {
|
||||
|
||||
private val notificationManager = context.getSystemService<NotificationManager>()!!
|
||||
|
||||
@@ -79,6 +99,13 @@ class MeshServiceNotificationsImpl @Inject constructor(@ApplicationContext priva
|
||||
private const val FIFTEEN_MINUTES_IN_MILLIS = 15L * 60 * 1000
|
||||
const val MAX_BATTERY_LEVEL = 100
|
||||
private val NOTIFICATION_LIGHT_COLOR = Color.BLUE
|
||||
private const val MAX_HISTORY_MESSAGES = 10
|
||||
private const val MIN_CONTEXT_MESSAGES = 3
|
||||
private const val SNIPPET_LENGTH = 30
|
||||
private const val GROUP_KEY_MESSAGES = "com.geeksville.mesh.GROUP_MESSAGES"
|
||||
private const val SUMMARY_ID = 1
|
||||
private const val PERSON_ICON_SIZE = 128
|
||||
private const val PERSON_ICON_TEXT_SIZE_RATIO = 0.5f
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -280,21 +307,108 @@ class MeshServiceNotificationsImpl @Inject constructor(@ApplicationContext priva
|
||||
return notification
|
||||
}
|
||||
|
||||
override fun updateMessageNotification(
|
||||
override suspend fun updateMessageNotification(
|
||||
contactKey: String,
|
||||
name: String,
|
||||
message: String,
|
||||
isBroadcast: Boolean,
|
||||
channelName: String?,
|
||||
) {
|
||||
val notification = createMessageNotification(contactKey, name, message, isBroadcast, channelName)
|
||||
// Use a consistent, unique ID for each message conversation.
|
||||
showConversationNotification(contactKey, isBroadcast, channelName)
|
||||
}
|
||||
|
||||
override suspend fun updateReactionNotification(
|
||||
contactKey: String,
|
||||
name: String,
|
||||
emoji: String,
|
||||
isBroadcast: Boolean,
|
||||
channelName: String?,
|
||||
) {
|
||||
showConversationNotification(contactKey, isBroadcast, channelName)
|
||||
}
|
||||
|
||||
override suspend fun updateWaypointNotification(
|
||||
contactKey: String,
|
||||
name: String,
|
||||
message: String,
|
||||
waypointId: Int,
|
||||
) {
|
||||
val notification = createWaypointNotification(name, message, waypointId)
|
||||
notificationManager.notify(contactKey.hashCode(), notification)
|
||||
}
|
||||
|
||||
override fun updateWaypointNotification(contactKey: String, name: String, message: String, waypointId: Int) {
|
||||
val notification = createWaypointNotification(name, message, waypointId)
|
||||
private suspend fun showConversationNotification(contactKey: String, isBroadcast: Boolean, channelName: String?) {
|
||||
val ourNode = nodeRepository.get().ourNodeInfo.value
|
||||
val history =
|
||||
packetRepository
|
||||
.get()
|
||||
.getMessagesFrom(contactKey) { nodeId ->
|
||||
if (nodeId == DataPacket.ID_LOCAL) {
|
||||
ourNode ?: nodeRepository.get().getNode(nodeId)
|
||||
} else {
|
||||
nodeRepository.get().getNode(nodeId ?: "")
|
||||
}
|
||||
}
|
||||
.first()
|
||||
|
||||
val unread = history.filter { !it.read }
|
||||
val displayHistory =
|
||||
if (unread.size < MIN_CONTEXT_MESSAGES) {
|
||||
history.take(MIN_CONTEXT_MESSAGES).reversed()
|
||||
} else {
|
||||
unread.take(MAX_HISTORY_MESSAGES).reversed()
|
||||
}
|
||||
|
||||
if (displayHistory.isEmpty()) return
|
||||
|
||||
val notification = createConversationNotification(contactKey, isBroadcast, channelName, displayHistory)
|
||||
notificationManager.notify(contactKey.hashCode(), notification)
|
||||
showGroupSummary()
|
||||
}
|
||||
|
||||
private fun showGroupSummary() {
|
||||
val activeNotifications =
|
||||
notificationManager.activeNotifications.filter {
|
||||
it.id != SUMMARY_ID && it.notification.group == GROUP_KEY_MESSAGES
|
||||
}
|
||||
|
||||
val ourNode = nodeRepository.get().ourNodeInfo.value
|
||||
val meName = ourNode?.user?.longName ?: getString(Res.string.you)
|
||||
val me =
|
||||
Person.Builder()
|
||||
.setName(meName)
|
||||
.setKey(ourNode?.user?.id ?: DataPacket.ID_LOCAL)
|
||||
.apply { ourNode?.let { setIcon(createPersonIcon(meName, it.colors.second, it.colors.first)) } }
|
||||
.build()
|
||||
|
||||
val messagingStyle =
|
||||
NotificationCompat.MessagingStyle(me)
|
||||
.setGroupConversation(true)
|
||||
.setConversationTitle(getString(Res.string.meshtastic_app_name))
|
||||
|
||||
activeNotifications.forEach { sbn ->
|
||||
val senderTitle = sbn.notification.extras.getCharSequence(Notification.EXTRA_TITLE)
|
||||
val messageText = sbn.notification.extras.getCharSequence(Notification.EXTRA_TEXT)
|
||||
val postTime = sbn.postTime
|
||||
|
||||
if (senderTitle != null && messageText != null) {
|
||||
// For the summary, we're creating a generic Person for the sender from the active notification's title.
|
||||
// We don't have the original Person object or its colors/ID, so we're just using the name.
|
||||
val senderPerson = Person.Builder().setName(senderTitle).build()
|
||||
messagingStyle.addMessage(messageText, postTime, senderPerson)
|
||||
}
|
||||
}
|
||||
|
||||
val summaryNotification =
|
||||
commonBuilder(NotificationType.DirectMessage)
|
||||
.setSmallIcon(com.geeksville.mesh.R.drawable.app_icon)
|
||||
.setStyle(messagingStyle)
|
||||
.setGroup(GROUP_KEY_MESSAGES)
|
||||
.setGroupSummary(true)
|
||||
.setAutoCancel(true)
|
||||
.build()
|
||||
|
||||
notificationManager.notify(SUMMARY_ID, summaryNotification)
|
||||
}
|
||||
|
||||
override fun showAlertNotification(contactKey: String, name: String, alert: String) {
|
||||
@@ -354,35 +468,88 @@ class MeshServiceNotificationsImpl @Inject constructor(@ApplicationContext priva
|
||||
return builder.build()
|
||||
}
|
||||
|
||||
private fun createMessageNotification(
|
||||
@Suppress("LongMethod")
|
||||
private fun createConversationNotification(
|
||||
contactKey: String,
|
||||
name: String,
|
||||
message: String,
|
||||
isBroadcast: Boolean,
|
||||
channelName: String? = null,
|
||||
channelName: String?,
|
||||
history: List<Message>,
|
||||
): Notification {
|
||||
val type = if (isBroadcast) NotificationType.BroadcastMessage else NotificationType.DirectMessage
|
||||
val builder = commonBuilder(type, createOpenMessageIntent(contactKey))
|
||||
|
||||
val person = Person.Builder().setName(name).build()
|
||||
val ourNode = nodeRepository.get().ourNodeInfo.value
|
||||
val meName = ourNode?.user?.longName ?: getString(Res.string.you)
|
||||
val me =
|
||||
Person.Builder()
|
||||
.setName(meName)
|
||||
.setKey(ourNode?.user?.id ?: DataPacket.ID_LOCAL)
|
||||
.apply { ourNode?.let { setIcon(createPersonIcon(meName, it.colors.second, it.colors.first)) } }
|
||||
.build()
|
||||
|
||||
val style =
|
||||
NotificationCompat.MessagingStyle(person)
|
||||
NotificationCompat.MessagingStyle(me)
|
||||
.setGroupConversation(channelName != null)
|
||||
.setConversationTitle(channelName)
|
||||
.addMessage(message, System.currentTimeMillis(), person)
|
||||
|
||||
history.forEach { msg ->
|
||||
// Use the node attached to the message directly to ensure correct identification
|
||||
val person =
|
||||
Person.Builder()
|
||||
.setName(msg.node.user.longName)
|
||||
.setKey(msg.node.user.id)
|
||||
.setIcon(createPersonIcon(msg.node.user.shortName, msg.node.colors.second, msg.node.colors.first))
|
||||
.build()
|
||||
|
||||
val text =
|
||||
msg.originalMessage?.let { original ->
|
||||
"↩️ \"${original.node.user.shortName}: ${original.text.take(SNIPPET_LENGTH)}...\": ${msg.text}"
|
||||
} ?: msg.text
|
||||
|
||||
style.addMessage(text, msg.receivedTime, person)
|
||||
|
||||
// Add reactions as separate "messages" in history if they exist
|
||||
msg.emojis.forEach { reaction ->
|
||||
val reactorNode = nodeRepository.get().getNode(reaction.user.id)
|
||||
val reactor =
|
||||
Person.Builder()
|
||||
.setName(reaction.user.longName)
|
||||
.setKey(reaction.user.id)
|
||||
.setIcon(
|
||||
createPersonIcon(
|
||||
reaction.user.shortName,
|
||||
reactorNode.colors.second,
|
||||
reactorNode.colors.first,
|
||||
),
|
||||
)
|
||||
.build()
|
||||
style.addMessage(
|
||||
"${reaction.emoji} to \"${msg.text.take(SNIPPET_LENGTH)}...\"",
|
||||
reaction.timestamp,
|
||||
reactor,
|
||||
)
|
||||
}
|
||||
}
|
||||
val lastMessage = history.last()
|
||||
|
||||
builder
|
||||
.setCategory(Notification.CATEGORY_MESSAGE)
|
||||
.setAutoCancel(true)
|
||||
.setStyle(style)
|
||||
.setGroup(GROUP_KEY_MESSAGES)
|
||||
.setVisibility(NotificationCompat.VISIBILITY_PRIVATE)
|
||||
.setWhen(System.currentTimeMillis())
|
||||
.setWhen(lastMessage.receivedTime)
|
||||
.setShowWhen(true)
|
||||
|
||||
// Only add reply action for direct messages, not broadcasts
|
||||
if (!isBroadcast) {
|
||||
builder.addAction(createReplyAction(contactKey))
|
||||
}
|
||||
.addAction(createReplyAction(contactKey))
|
||||
.addAction(createMarkAsReadAction(contactKey))
|
||||
.addAction(
|
||||
createReactionAction(
|
||||
contactKey = contactKey,
|
||||
packetId = lastMessage.packetId,
|
||||
toId = lastMessage.node.user.id,
|
||||
channelIndex = lastMessage.node.channel,
|
||||
),
|
||||
)
|
||||
|
||||
return builder.build()
|
||||
}
|
||||
@@ -395,6 +562,7 @@ class MeshServiceNotificationsImpl @Inject constructor(@ApplicationContext priva
|
||||
.setCategory(Notification.CATEGORY_MESSAGE)
|
||||
.setAutoCancel(true)
|
||||
.setStyle(style)
|
||||
.setGroup(GROUP_KEY_MESSAGES)
|
||||
.setVisibility(NotificationCompat.VISIBILITY_PRIVATE)
|
||||
.setWhen(System.currentTimeMillis())
|
||||
.setShowWhen(true)
|
||||
@@ -517,6 +685,51 @@ class MeshServiceNotificationsImpl @Inject constructor(@ApplicationContext priva
|
||||
.build()
|
||||
}
|
||||
|
||||
private fun createMarkAsReadAction(contactKey: String): NotificationCompat.Action {
|
||||
val label = getString(Res.string.mark_as_read)
|
||||
val intent =
|
||||
Intent(context, MarkAsReadReceiver::class.java).apply {
|
||||
action = MARK_AS_READ_ACTION
|
||||
putExtra(MarkAsReadReceiver.CONTACT_KEY, contactKey)
|
||||
}
|
||||
val pendingIntent =
|
||||
PendingIntent.getBroadcast(
|
||||
context,
|
||||
contactKey.hashCode(),
|
||||
intent,
|
||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
|
||||
)
|
||||
|
||||
return NotificationCompat.Action.Builder(android.R.drawable.ic_menu_view, label, pendingIntent).build()
|
||||
}
|
||||
|
||||
private fun createReactionAction(
|
||||
contactKey: String,
|
||||
packetId: Int,
|
||||
toId: String,
|
||||
channelIndex: Int,
|
||||
): NotificationCompat.Action {
|
||||
val label = "👍"
|
||||
val intent =
|
||||
Intent(context, ReactionReceiver::class.java).apply {
|
||||
action = REACT_ACTION
|
||||
putExtra(ReactionReceiver.EXTRA_CONTACT_KEY, contactKey)
|
||||
putExtra(ReactionReceiver.EXTRA_PACKET_ID, packetId)
|
||||
putExtra(ReactionReceiver.EXTRA_TO_ID, toId)
|
||||
putExtra(ReactionReceiver.EXTRA_CHANNEL_INDEX, channelIndex)
|
||||
putExtra(ReactionReceiver.EXTRA_EMOJI, "👍")
|
||||
}
|
||||
val pendingIntent =
|
||||
PendingIntent.getBroadcast(
|
||||
context,
|
||||
packetId,
|
||||
intent,
|
||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
|
||||
)
|
||||
|
||||
return NotificationCompat.Action.Builder(android.R.drawable.ic_menu_add, label, pendingIntent).build()
|
||||
}
|
||||
|
||||
private fun commonBuilder(
|
||||
type: NotificationType,
|
||||
contentIntent: PendingIntent? = null,
|
||||
@@ -529,6 +742,33 @@ class MeshServiceNotificationsImpl @Inject constructor(@ApplicationContext priva
|
||||
.setVisibility(NotificationCompat.VISIBILITY_PUBLIC)
|
||||
.setContentIntent(contentIntent ?: openAppIntent)
|
||||
}
|
||||
|
||||
private fun createPersonIcon(name: String, backgroundColor: Int, foregroundColor: Int): IconCompat {
|
||||
val bitmap = createBitmap(PERSON_ICON_SIZE, PERSON_ICON_SIZE)
|
||||
val canvas = Canvas(bitmap)
|
||||
val paint = Paint(Paint.ANTI_ALIAS_FLAG)
|
||||
|
||||
// Draw background circle
|
||||
paint.color = backgroundColor
|
||||
canvas.drawCircle(PERSON_ICON_SIZE / 2f, PERSON_ICON_SIZE / 2f, PERSON_ICON_SIZE / 2f, paint)
|
||||
|
||||
// Draw initials
|
||||
paint.color = foregroundColor
|
||||
paint.textSize = PERSON_ICON_SIZE * PERSON_ICON_TEXT_SIZE_RATIO
|
||||
paint.textAlign = Paint.Align.CENTER
|
||||
val initial =
|
||||
if (name.isNotEmpty()) {
|
||||
val codePoint = name.codePointAt(0)
|
||||
String(Character.toChars(codePoint)).uppercase()
|
||||
} else {
|
||||
"?"
|
||||
}
|
||||
val xPos = canvas.width / 2f
|
||||
val yPos = (canvas.height / 2f - (paint.descent() + paint.ascent()) / 2f)
|
||||
canvas.drawText(initial, xPos, yPos, paint)
|
||||
|
||||
return IconCompat.createWithBitmap(bitmap)
|
||||
}
|
||||
// endregion
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
/*
|
||||
* Copyright (c) 2025-2026 Meshtastic LLC
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package com.geeksville.mesh.service
|
||||
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.launch
|
||||
import org.meshtastic.core.model.DataPacket
|
||||
import org.meshtastic.core.service.MeshServiceNotifications
|
||||
import org.meshtastic.proto.Portnums
|
||||
import javax.inject.Inject
|
||||
|
||||
@AndroidEntryPoint
|
||||
class ReactionReceiver : BroadcastReceiver() {
|
||||
@Inject lateinit var commandSender: MeshCommandSender
|
||||
|
||||
@Inject lateinit var meshServiceNotifications: MeshServiceNotifications
|
||||
|
||||
private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
|
||||
|
||||
companion object {
|
||||
const val REACT_ACTION = "com.geeksville.mesh.REACT_ACTION"
|
||||
const val EXTRA_PACKET_ID = "packetId"
|
||||
const val EXTRA_EMOJI = "emoji"
|
||||
const val EXTRA_CONTACT_KEY = "contactKey"
|
||||
const val EXTRA_TO_ID = "toId"
|
||||
const val EXTRA_CHANNEL_INDEX = "channelIndex"
|
||||
}
|
||||
|
||||
override fun onReceive(context: Context, intent: Intent) {
|
||||
if (intent.action != REACT_ACTION) return
|
||||
|
||||
val pendingResult = goAsync()
|
||||
scope.launch {
|
||||
try {
|
||||
val packetId = intent.getIntExtra(EXTRA_PACKET_ID, 0)
|
||||
val emoji = intent.getStringExtra(EXTRA_EMOJI)
|
||||
val toId = intent.getStringExtra(EXTRA_TO_ID)
|
||||
val channelIndex = intent.getIntExtra(EXTRA_CHANNEL_INDEX, 0)
|
||||
val contactKey = intent.getStringExtra(EXTRA_CONTACT_KEY)
|
||||
|
||||
@Suppress("ComplexCondition")
|
||||
if (packetId == 0 || emoji.isNullOrEmpty() || toId.isNullOrEmpty() || contactKey.isNullOrEmpty()) {
|
||||
return@launch
|
||||
}
|
||||
|
||||
// Reactions are text messages with a replyId and emoji set
|
||||
val reactionPacket =
|
||||
DataPacket(
|
||||
to = toId,
|
||||
channel = channelIndex,
|
||||
bytes = emoji.toByteArray(Charsets.UTF_8),
|
||||
dataType = Portnums.PortNum.TEXT_MESSAGE_APP_VALUE,
|
||||
replyId = packetId,
|
||||
emoji = emoji.codePointAt(0),
|
||||
)
|
||||
commandSender.sendData(reactionPacket)
|
||||
|
||||
// Dismiss the notification after reacting
|
||||
meshServiceNotifications.cancelMessageNotification(contactKey)
|
||||
} finally {
|
||||
pendingResult.finish()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Meshtastic LLC
|
||||
* Copyright (c) 2025-2026 Meshtastic LLC
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
@@ -14,7 +14,6 @@
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package com.geeksville.mesh.service
|
||||
|
||||
import android.content.BroadcastReceiver
|
||||
@@ -59,7 +58,7 @@ class ReplyReceiver : BroadcastReceiver() {
|
||||
val contactKey = intent.getStringExtra(CONTACT_KEY) ?: ""
|
||||
val message = remoteInput.getCharSequence(KEY_TEXT_REPLY)?.toString() ?: ""
|
||||
sendMessage(message, contactKey)
|
||||
MeshServiceNotificationsImpl(context).cancelMessageNotification(contactKey)
|
||||
meshServiceNotifications.cancelMessageNotification(contactKey)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -81,7 +81,7 @@ class FakeMeshServiceNotifications : MeshServiceNotifications {
|
||||
telemetry: TelemetryProtos.Telemetry?,
|
||||
): Notification = null as Notification
|
||||
|
||||
override fun updateMessageNotification(
|
||||
override suspend fun updateMessageNotification(
|
||||
contactKey: String,
|
||||
name: String,
|
||||
message: String,
|
||||
@@ -89,7 +89,20 @@ class FakeMeshServiceNotifications : MeshServiceNotifications {
|
||||
channelName: String?,
|
||||
) {}
|
||||
|
||||
override fun updateWaypointNotification(contactKey: String, name: String, message: String, waypointId: Int) {}
|
||||
override suspend fun updateWaypointNotification(
|
||||
contactKey: String,
|
||||
name: String,
|
||||
message: String,
|
||||
waypointId: Int,
|
||||
) {}
|
||||
|
||||
override suspend fun updateReactionNotification(
|
||||
contactKey: String,
|
||||
name: String,
|
||||
emoji: String,
|
||||
isBroadcast: Boolean,
|
||||
channelName: String?,
|
||||
) {}
|
||||
|
||||
override fun showAlertNotification(contactKey: String, name: String, alert: String) {}
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Meshtastic LLC
|
||||
* Copyright (c) 2025-2026 Meshtastic LLC
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
@@ -14,7 +14,6 @@
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package org.meshtastic.core.data.repository
|
||||
|
||||
import androidx.paging.Pager
|
||||
@@ -46,7 +45,7 @@ constructor(
|
||||
private val dispatchers: CoroutineDispatchers,
|
||||
) {
|
||||
fun getWaypoints(): Flow<List<Packet>> =
|
||||
dbManager.currentDb.flatMapLatest { db -> db.packetDao().getAllPackets(PortNum.WAYPOINT_APP_VALUE) }
|
||||
dbManager.currentDb.flatMapLatest { db -> db.packetDao().getAllWaypointsFlow() }
|
||||
|
||||
fun getContacts(): Flow<Map<String, Packet>> =
|
||||
dbManager.currentDb.flatMapLatest { db -> db.packetDao().getContactKeys() }
|
||||
@@ -97,18 +96,21 @@ constructor(
|
||||
suspend fun insert(packet: Packet) =
|
||||
withContext(dispatchers.io) { dbManager.currentDb.value.packetDao().insert(packet) }
|
||||
|
||||
suspend fun getMessagesFrom(contact: String, getNode: suspend (String?) -> Node) = withContext(dispatchers.io) {
|
||||
dbManager.currentDb.value.packetDao().getMessagesFrom(contact).mapLatest { packets ->
|
||||
packets.map { packet ->
|
||||
val message = packet.toMessage(getNode)
|
||||
message.replyId
|
||||
.takeIf { it != null && it != 0 }
|
||||
?.let { getPacketByPacketId(it) }
|
||||
?.toMessage(getNode)
|
||||
?.let { originalMessage -> message.copy(originalMessage = originalMessage) } ?: message
|
||||
suspend fun getMessagesFrom(contact: String, limit: Int? = null, getNode: suspend (String?) -> Node) =
|
||||
withContext(dispatchers.io) {
|
||||
val dao = dbManager.currentDb.value.packetDao()
|
||||
val flow = if (limit != null) dao.getMessagesFrom(contact, limit) else dao.getMessagesFrom(contact)
|
||||
flow.mapLatest { packets ->
|
||||
packets.map { packet ->
|
||||
val message = packet.toMessage(getNode)
|
||||
message.replyId
|
||||
.takeIf { it != null && it != 0 }
|
||||
?.let { getPacketByPacketId(it) }
|
||||
?.toMessage(getNode)
|
||||
?.let { originalMessage -> message.copy(originalMessage = originalMessage) } ?: message
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun getMessagesFromPaged(contact: String, getNode: suspend (String?) -> Node): Flow<PagingData<Message>> = Pager(
|
||||
config = PagingConfig(pageSize = 50, enablePlaceholders = false, initialLoadSize = 50),
|
||||
@@ -176,4 +178,7 @@ constructor(
|
||||
withContext(dispatchers.io) {
|
||||
dbManager.currentDb.value.packetDao().migrateChannelsByPSK(oldSettings, newSettings)
|
||||
}
|
||||
|
||||
private fun org.meshtastic.core.database.dao.PacketDao.getAllWaypointsFlow(): Flow<List<Packet>> =
|
||||
getAllPackets(PortNum.WAYPOINT_APP_VALUE)
|
||||
}
|
||||
|
||||
@@ -149,6 +149,18 @@ interface PacketDao {
|
||||
)
|
||||
fun getMessagesFrom(contact: String): Flow<List<PacketEntity>>
|
||||
|
||||
@Transaction
|
||||
@Query(
|
||||
"""
|
||||
SELECT * FROM packet
|
||||
WHERE (myNodeNum = 0 OR myNodeNum = (SELECT myNodeNum FROM my_node))
|
||||
AND port_num = 1 AND contact_key = :contact
|
||||
ORDER BY received_time DESC
|
||||
LIMIT :limit
|
||||
""",
|
||||
)
|
||||
fun getMessagesFrom(contact: String, limit: Int): Flow<List<PacketEntity>>
|
||||
|
||||
@Transaction
|
||||
@Query(
|
||||
"""
|
||||
|
||||
@@ -31,7 +31,7 @@ interface MeshServiceNotifications {
|
||||
|
||||
fun updateServiceStateNotification(summaryString: String?, telemetry: TelemetryProtos.Telemetry?): Notification
|
||||
|
||||
fun updateMessageNotification(
|
||||
suspend fun updateMessageNotification(
|
||||
contactKey: String,
|
||||
name: String,
|
||||
message: String,
|
||||
@@ -39,7 +39,15 @@ interface MeshServiceNotifications {
|
||||
channelName: String?,
|
||||
)
|
||||
|
||||
fun updateWaypointNotification(contactKey: String, name: String, message: String, waypointId: Int)
|
||||
suspend fun updateWaypointNotification(contactKey: String, name: String, message: String, waypointId: Int)
|
||||
|
||||
suspend fun updateReactionNotification(
|
||||
contactKey: String,
|
||||
name: String,
|
||||
emoji: String,
|
||||
isBroadcast: Boolean,
|
||||
channelName: String?,
|
||||
)
|
||||
|
||||
fun showAlertNotification(contactKey: String, name: String, alert: String)
|
||||
|
||||
|
||||
@@ -16,6 +16,8 @@
|
||||
-->
|
||||
|
||||
<resources>
|
||||
<string name="meshtastic_app_name">Meshtastic</string>
|
||||
|
||||
<!-- Language tags native names (not available via .getDisplayLanguage) -->
|
||||
<string name="fr_HT" translatable="false">Kreyòl ayisyen</string>
|
||||
<string name="pt_BR" translatable="false">Português do Brasil</string>
|
||||
@@ -357,6 +359,7 @@
|
||||
<string name="remove">Remove</string>
|
||||
<string name="remove_node_text">This node will be removed from your list until your node receives data from it again.</string>
|
||||
<string name="mute_notifications">Mute notifications</string>
|
||||
<string name="mute_1_hour">1 hour</string>
|
||||
<string name="mute_8_hours">8 hours</string>
|
||||
<string name="mute_1_week">1 week</string>
|
||||
<string name="mute_always">Always</string>
|
||||
@@ -575,7 +578,7 @@
|
||||
<string name="output_led_gpio">Output LED (GPIO)</string>
|
||||
<string name="output_led_active_high">Output LED active high</string>
|
||||
<string name="output_buzzer_gpio">Output buzzer (GPIO)</string>
|
||||
<string name="use_pwm_buzzer">Use PWM buzzer</string>
|
||||
<string name="use_view_buzzer">Use VIEW buzzer</string>
|
||||
<string name="output_vibra_gpio">Output vibra (GPIO)</string>
|
||||
<string name="output_duration_milliseconds">Output duration (milliseconds)</string>
|
||||
<string name="nag_timeout_seconds">Nag timeout (seconds)</string>
|
||||
@@ -1076,4 +1079,5 @@
|
||||
<string name="compass_no_location_fix">Waiting for a GPS fix to calculate distance and bearing.</string>
|
||||
<string name="compass_uncertainty">Estimated area: \u00b1%1$s (\u00b1%2$s)</string>
|
||||
<string name="compass_uncertainty_unknown">Estimated area: unknown accuracy</string>
|
||||
<string name="mark_as_read">Mark as read</string>
|
||||
</resources>
|
||||
|
||||
Reference in New Issue
Block a user