feat: Send emoji codepoint in reaction packets (#4123)

Signed-off-by: James Rich <2199651+jamesarich@users.noreply.github.com>
This commit is contained in:
James Rich 2026-01-02 20:51:23 -06:00 committed by GitHub
parent 5b1693aa04
commit c9259c793f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 85 additions and 56 deletions

View file

@ -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.repository.network
import android.annotation.SuppressLint
@ -32,6 +31,7 @@ import kotlinx.coroutines.flow.callbackFlow
import kotlinx.coroutines.flow.mapLatest
import kotlinx.coroutines.suspendCancellableCoroutine
import java.util.concurrent.CopyOnWriteArrayList
import java.util.concurrent.atomic.AtomicBoolean
import kotlin.coroutines.resume
@OptIn(ExperimentalCoroutinesApi::class)
@ -89,30 +89,37 @@ private fun NsdManager.discoverServices(
@SuppressLint("NewApi")
private suspend fun NsdManager.resolveService(serviceInfo: NsdServiceInfo): NsdServiceInfo? =
suspendCancellableCoroutine { continuation ->
val isResumed = AtomicBoolean(false)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
val callback =
object : NsdManager.ServiceInfoCallback {
override fun onServiceInfoCallbackRegistrationFailed(errorCode: Int) {
continuation.resume(null)
if (isResumed.compareAndSet(false, true)) {
continuation.resume(null)
}
}
override fun onServiceUpdated(updatedServiceInfo: NsdServiceInfo) {
if (updatedServiceInfo.hostAddresses.isNotEmpty()) {
continuation.resume(updatedServiceInfo)
try {
unregisterServiceInfoCallback(this)
} catch (e: IllegalArgumentException) {
Logger.w(e) { "Already unregistered" }
if (isResumed.compareAndSet(false, true)) {
continuation.resume(updatedServiceInfo)
try {
unregisterServiceInfoCallback(this)
} catch (e: IllegalArgumentException) {
Logger.w(e) { "Already unregistered" }
}
}
}
}
override fun onServiceLost() {
continuation.resume(null)
try {
unregisterServiceInfoCallback(this)
} catch (e: IllegalArgumentException) {
Logger.w(e) { "Already unregistered" }
if (isResumed.compareAndSet(false, true)) {
continuation.resume(null)
try {
unregisterServiceInfoCallback(this)
} catch (e: IllegalArgumentException) {
Logger.w(e) { "Already unregistered" }
}
}
}
@ -132,11 +139,15 @@ private suspend fun NsdManager.resolveService(serviceInfo: NsdServiceInfo): NsdS
val listener =
object : NsdManager.ResolveListener {
override fun onResolveFailed(serviceInfo: NsdServiceInfo, errorCode: Int) {
continuation.resume(null)
if (isResumed.compareAndSet(false, true)) {
continuation.resume(null)
}
}
override fun onServiceResolved(serviceInfo: NsdServiceInfo) {
continuation.resume(serviceInfo)
if (isResumed.compareAndSet(false, true)) {
continuation.resume(serviceInfo)
}
}
}
@Suppress("DEPRECATION")

View file

@ -71,44 +71,10 @@ constructor(
ignoreException {
val myNodeNum = nodeManager.myNodeNum ?: return@ignoreException
when (action) {
is ServiceAction.Favorite -> {
val node = action.node
commandSender.sendAdmin(myNodeNum) {
if (node.isFavorite) removeFavoriteNode = node.num else setFavoriteNode = node.num
}
nodeManager.updateNodeInfo(node.num) { it.isFavorite = !node.isFavorite }
}
is ServiceAction.Ignore -> {
val node = action.node
commandSender.sendAdmin(myNodeNum) {
if (node.isIgnored) removeIgnoredNode = node.num else setIgnoredNode = node.num
}
nodeManager.updateNodeInfo(node.num) { it.isIgnored = !node.isIgnored }
}
is ServiceAction.Reaction -> {
val channel = action.contactKey[0].digitToInt()
val destId = action.contactKey.substring(1)
val dataPacket =
org.meshtastic.core.model.DataPacket(
to = destId,
dataType = Portnums.PortNum.TEXT_MESSAGE_APP_VALUE,
bytes = action.emoji.encodeToByteArray(),
channel = channel,
replyId = action.replyId,
wantAck = false,
)
commandSender.sendData(dataPacket)
rememberReaction(action)
}
is ServiceAction.ImportContact -> {
val verifiedContact = action.contact.toBuilder().setManuallyVerified(true).build()
commandSender.sendAdmin(myNodeNum) { addContact = verifiedContact }
nodeManager.handleReceivedUser(
verifiedContact.nodeNum,
verifiedContact.user,
manuallyVerified = true,
)
}
is ServiceAction.Favorite -> handleFavorite(action, myNodeNum)
is ServiceAction.Ignore -> handleIgnore(action, myNodeNum)
is ServiceAction.Reaction -> handleReaction(action)
is ServiceAction.ImportContact -> handleImportContact(action, myNodeNum)
is ServiceAction.SendContact -> {
commandSender.sendAdmin(myNodeNum) { addContact = action.contact }
}
@ -119,6 +85,45 @@ constructor(
}
}
private fun handleFavorite(action: ServiceAction.Favorite, myNodeNum: Int) {
val node = action.node
commandSender.sendAdmin(myNodeNum) {
if (node.isFavorite) removeFavoriteNode = node.num else setFavoriteNode = node.num
}
nodeManager.updateNodeInfo(node.num) { it.isFavorite = !node.isFavorite }
}
private fun handleIgnore(action: ServiceAction.Ignore, myNodeNum: Int) {
val node = action.node
commandSender.sendAdmin(myNodeNum) {
if (node.isIgnored) removeIgnoredNode = node.num else setIgnoredNode = node.num
}
nodeManager.updateNodeInfo(node.num) { it.isIgnored = !node.isIgnored }
}
private fun handleReaction(action: ServiceAction.Reaction) {
val channel = action.contactKey[0].digitToInt()
val destId = action.contactKey.substring(1)
val dataPacket =
org.meshtastic.core.model.DataPacket(
to = destId,
dataType = Portnums.PortNum.TEXT_MESSAGE_APP_VALUE,
bytes = action.emoji.encodeToByteArray(),
channel = channel,
replyId = action.replyId,
wantAck = false,
emoji = action.emoji.codePointAt(0),
)
commandSender.sendData(dataPacket)
rememberReaction(action)
}
private fun handleImportContact(action: ServiceAction.ImportContact, myNodeNum: Int) {
val verifiedContact = action.contact.toBuilder().setManuallyVerified(true).build()
commandSender.sendAdmin(myNodeNum) { addContact = verifiedContact }
nodeManager.handleReceivedUser(verifiedContact.nodeNum, verifiedContact.user, manuallyVerified = true)
}
private fun rememberReaction(action: ServiceAction.Reaction) {
scope.handledLaunch {
val reaction =

View file

@ -152,6 +152,7 @@ constructor(
portnumValue = p.dataType
payload = ByteString.copyFrom(p.bytes ?: ByteArray(0))
p.replyId?.let { if (it != 0) replyId = it }
if (p.emoji != 0) emoji = p.emoji
}
p.time = System.currentTimeMillis()
packetHandler?.sendToRadio(meshPacket)

View file

@ -49,6 +49,7 @@ class MeshDataMapper @Inject constructor(private val nodeManager: MeshNodeManage
replyId = data.replyId,
relayNode = packet.relayNode,
viaMqtt = packet.viaMqtt,
emoji = data.emoji,
)
}
}

View file

@ -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.model
import android.os.Parcel
@ -64,6 +63,7 @@ data class DataPacket(
var relayNode: Int? = null,
var relays: Int = 0,
var viaMqtt: Boolean = false, // True if this packet passed via MQTT somewhere along its path
var emoji: Int = 0,
) : Parcelable {
/** If there was an error with this message, this string describes what was wrong. */
@ -137,6 +137,9 @@ data class DataPacket(
parcel.readInt(),
parcel.readInt().let { if (it == 0) null else it },
parcel.readInt().let { if (it == -1) null else it },
parcel.readInt(), // relays
parcel.readInt() == 1, // viaMqtt
parcel.readInt(), // emoji
)
@Suppress("CyclomaticComplexMethod")
@ -161,6 +164,7 @@ data class DataPacket(
if (rssi != other.rssi) return false
if (replyId != other.replyId) return false
if (relayNode != other.relayNode) return false
if (emoji != other.emoji) return false
return true
}
@ -181,6 +185,7 @@ data class DataPacket(
result = 31 * result + rssi
result = 31 * result + replyId.hashCode()
result = 31 * result + relayNode.hashCode()
result = 31 * result + emoji
return result
}
@ -200,6 +205,9 @@ data class DataPacket(
parcel.writeInt(rssi)
parcel.writeInt(replyId ?: 0)
parcel.writeInt(relayNode ?: -1)
parcel.writeInt(relays)
parcel.writeInt(if (viaMqtt) 1 else 0)
parcel.writeInt(emoji)
}
override fun describeContents(): Int = 0
@ -221,6 +229,9 @@ data class DataPacket(
rssi = parcel.readInt()
replyId = parcel.readInt().let { if (it == 0) null else it }
relayNode = parcel.readInt().let { if (it == -1) null else it }
relays = parcel.readInt()
viaMqtt = parcel.readInt() == 1
emoji = parcel.readInt()
}
companion object CREATOR : Parcelable.Creator<DataPacket> {

View file

@ -42,7 +42,7 @@ org.gradle.configuration-cache=true
# Watches the file system for changes, allowing Gradle to reuse information about the file system
# between builds.
org.gradle.vfs.watch=true
# org.gradle.vfs.watch=true
# AndroidX package structure to make it clearer which packages are bundled with the
# Android operating system, and which are packaged with your app's APK