feat: Add SFPP confirmed status to Messages and Reactions (#4139)

Signed-off-by: James Rich <2199651+jamesarich@users.noreply.github.com>
Co-authored-by: Mac DeCourcy <github.znq26@slmail.me>
This commit is contained in:
James Rich 2026-01-08 07:21:21 -06:00 committed by GitHub
parent 78bd1ad6dd
commit 782c068ead
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
21 changed files with 2699 additions and 61 deletions

View file

@ -75,7 +75,7 @@ constructor(
when (action) {
is ServiceAction.Favorite -> handleFavorite(action, myNodeNum)
is ServiceAction.Ignore -> handleIgnore(action, myNodeNum)
is ServiceAction.Reaction -> handleReaction(action)
is ServiceAction.Reaction -> handleReaction(action, myNodeNum)
is ServiceAction.ImportContact -> handleImportContact(action, myNodeNum)
is ServiceAction.SendContact -> {
commandSender.sendAdmin(myNodeNum) { addContact = action.contact }
@ -103,21 +103,23 @@ constructor(
nodeManager.updateNodeInfo(node.num) { it.isIgnored = !node.isIgnored }
}
private fun handleReaction(action: ServiceAction.Reaction) {
private fun handleReaction(action: ServiceAction.Reaction, myNodeNum: Int) {
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 = true,
emoji = action.emoji.codePointAt(0),
)
org.meshtastic.core.model
.DataPacket(
to = destId,
dataType = Portnums.PortNum.TEXT_MESSAGE_APP_VALUE,
bytes = action.emoji.encodeToByteArray(),
channel = channel,
replyId = action.replyId,
wantAck = true,
emoji = action.emoji.codePointAt(0),
)
.apply { from = nodeManager.getMyId().takeIf { it.isNotEmpty() } ?: DataPacket.ID_LOCAL }
commandSender.sendData(dataPacket)
rememberReaction(action, dataPacket.id)
rememberReaction(action, dataPacket.id, myNodeNum)
}
private fun handleImportContact(action: ServiceAction.ImportContact, myNodeNum: Int) {
@ -126,12 +128,13 @@ constructor(
nodeManager.handleReceivedUser(verifiedContact.nodeNum, verifiedContact.user, manuallyVerified = true)
}
private fun rememberReaction(action: ServiceAction.Reaction, packetId: Int) {
private fun rememberReaction(action: ServiceAction.Reaction, packetId: Int, myNodeNum: Int) {
scope.handledLaunch {
val reaction =
ReactionEntity(
myNodeNum = myNodeNum,
replyId = action.replyId,
userId = nodeManager.getMyId(),
userId = nodeManager.getMyId().takeIf { it.isNotEmpty() } ?: DataPacket.ID_LOCAL,
emoji = action.emoji,
timestamp = System.currentTimeMillis(),
snr = 0f,
@ -139,6 +142,8 @@ constructor(
hopsAway = 0,
packetId = packetId,
status = MessageStatus.QUEUED,
to = action.contactKey.substring(1),
channel = action.contactKey[0].digitToInt(),
)
packetRepository.get().insertReaction(reaction)
}

View file

@ -37,6 +37,7 @@ import org.meshtastic.core.database.entity.Packet
import org.meshtastic.core.database.entity.ReactionEntity
import org.meshtastic.core.model.DataPacket
import org.meshtastic.core.model.MessageStatus
import org.meshtastic.core.model.util.SfppHasher
import org.meshtastic.core.prefs.mesh.MeshPrefs
import org.meshtastic.core.service.MeshServiceNotifications
import org.meshtastic.core.service.ServiceRepository
@ -58,7 +59,7 @@ import javax.inject.Inject
import javax.inject.Singleton
import kotlin.time.Duration.Companion.milliseconds
@Suppress("LongParameterList", "TooManyFunctions")
@Suppress("LongParameterList", "TooManyFunctions", "LargeClass")
@Singleton
class MeshDataHandler
@Inject
@ -191,9 +192,82 @@ constructor(
handleReceivedStoreAndForward(dataPacket, u, myNodeNum)
}
@Suppress("LongMethod")
private fun handleStoreForwardPlusPlus(packet: MeshPacket) {
val sfpp = MeshProtos.StoreForwardPlusPlus.parseFrom(packet.decoded.payload)
Logger.d { "Received StoreForwardPlusPlus packet: $sfpp" }
when (sfpp.sfppMessageType) {
MeshProtos.StoreForwardPlusPlus.SFPP_message_type.LINK_PROVIDE,
MeshProtos.StoreForwardPlusPlus.SFPP_message_type.LINK_PROVIDE_FIRSTHALF,
MeshProtos.StoreForwardPlusPlus.SFPP_message_type.LINK_PROVIDE_SECONDHALF,
-> {
val isFragment = sfpp.sfppMessageType != MeshProtos.StoreForwardPlusPlus.SFPP_message_type.LINK_PROVIDE
// If it has a commit hash, it's already on the chain (Confirmed)
// Otherwise it's still being routed via SF++ (Routing)
val status = if (sfpp.commitHash.isEmpty) MessageStatus.SFPP_ROUTING else MessageStatus.SFPP_CONFIRMED
// Prefer a full 16-byte hash calculated from the message bytes if available
// But only if it's NOT a fragment, otherwise the calculated hash would be wrong
val hash =
when {
!sfpp.messageHash.isEmpty -> sfpp.messageHash.toByteArray()
!isFragment && !sfpp.message.isEmpty -> {
SfppHasher.computeMessageHash(
encryptedPayload = sfpp.message.toByteArray(),
// Map 0 back to NODENUM_BROADCAST to match firmware hash calculation
to =
if (sfpp.encapsulatedTo == 0) DataPacket.NODENUM_BROADCAST else sfpp.encapsulatedTo,
from = sfpp.encapsulatedFrom,
id = sfpp.encapsulatedId,
)
}
else -> null
} ?: return
Logger.d {
"SFPP updateStatus: packetId=${sfpp.encapsulatedId} from=${sfpp.encapsulatedFrom} " +
"to=${sfpp.encapsulatedTo} myNodeNum=${nodeManager.myNodeNum} status=$status"
}
scope.handledLaunch {
packetRepository
.get()
.updateSFPPStatus(
packetId = sfpp.encapsulatedId,
from = sfpp.encapsulatedFrom,
to = sfpp.encapsulatedTo,
hash = hash,
status = status,
rxTime = sfpp.encapsulatedRxtime.toLong() and 0xFFFFFFFFL,
myNodeNum = nodeManager.myNodeNum,
)
serviceBroadcasts.broadcastMessageStatus(sfpp.encapsulatedId, status)
}
}
MeshProtos.StoreForwardPlusPlus.SFPP_message_type.CANON_ANNOUNCE -> {
scope.handledLaunch {
packetRepository
.get()
.updateSFPPStatusByHash(
hash = sfpp.messageHash.toByteArray(),
status = MessageStatus.SFPP_CONFIRMED,
rxTime = sfpp.encapsulatedRxtime.toLong() and 0xFFFFFFFFL,
)
}
}
MeshProtos.StoreForwardPlusPlus.SFPP_message_type.CHAIN_QUERY -> {
Logger.i { "SF++: Node ${packet.from} is querying chain status" }
}
MeshProtos.StoreForwardPlusPlus.SFPP_message_type.LINK_REQUEST -> {
Logger.i { "SF++: Node ${packet.from} is requesting links" }
}
else -> {}
}
}
private fun handlePaxCounter(packet: MeshPacket) {
@ -345,13 +419,13 @@ constructor(
isMaxRetransmit &&
p != null &&
p.port_num == Portnums.PortNum.TEXT_MESSAGE_APP_VALUE &&
p.data.from == DataPacket.ID_LOCAL &&
(p.data.from == DataPacket.ID_LOCAL || p.data.from == nodeManager.getMyId()) &&
p.data.retryCount < MAX_RETRY_ATTEMPTS
val shouldRetryReaction =
isMaxRetransmit &&
reaction != null &&
reaction.userId == DataPacket.ID_LOCAL &&
(reaction.userId == DataPacket.ID_LOCAL || reaction.userId == nodeManager.getMyId()) &&
reaction.retryCount < MAX_RETRY_ATTEMPTS &&
reaction.to != null
@Suppress("MaxLineLength")
@ -509,7 +583,8 @@ constructor(
fun rememberDataPacket(dataPacket: DataPacket, myNodeNum: Int, updateNotification: Boolean = true) {
if (dataPacket.dataType !in rememberDataType) return
val fromLocal = dataPacket.from == DataPacket.ID_LOCAL
val fromLocal =
dataPacket.from == DataPacket.ID_LOCAL || dataPacket.from == DataPacket.nodeNumToDefaultId(myNodeNum)
val toBroadcast = dataPacket.to == DataPacket.ID_BROADCAST
val contactId = if (fromLocal || toBroadcast) dataPacket.to else dataPacket.from
@ -529,7 +604,6 @@ constructor(
snr = dataPacket.snr,
rssi = dataPacket.rssi,
hopsAway = dataPacket.hopsAway,
replyId = dataPacket.replyId ?: 0,
)
scope.handledLaunch {
packetRepository.get().apply {
@ -593,10 +667,14 @@ constructor(
private fun rememberReaction(packet: MeshPacket) = scope.handledLaunch {
val emoji = packet.decoded.payload.toByteArray().decodeToString()
val fromId = dataMapper.toNodeID(packet.from)
val toId = dataMapper.toNodeID(packet.to)
val reaction =
ReactionEntity(
myNodeNum = nodeManager.myNodeNum ?: 0,
replyId = packet.decoded.replyId,
userId = dataMapper.toNodeID(packet.from),
userId = fromId,
emoji = emoji,
timestamp = System.currentTimeMillis(),
snr = packet.rxSnr,
@ -607,6 +685,10 @@ constructor(
} else {
packet.hopStart - packet.hopLimit
},
packetId = packet.id,
status = MessageStatus.RECEIVED,
to = toId,
channel = packet.channel,
)
packetRepository.get().insertReaction(reaction)

View file

@ -39,6 +39,8 @@ class ReactionReceiver : BroadcastReceiver() {
@Inject lateinit var packetRepository: PacketRepository
@Inject lateinit var nodeManager: MeshNodeManager
private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
companion object {
@ -82,8 +84,9 @@ class ReactionReceiver : BroadcastReceiver() {
val reaction =
ReactionEntity(
myNodeNum = nodeManager.myNodeNum ?: 0,
replyId = packetId,
userId = DataPacket.ID_LOCAL,
userId = nodeManager.getMyId().takeIf { it.isNotEmpty() } ?: DataPacket.ID_LOCAL,
emoji = emoji,
timestamp = System.currentTimeMillis(),
packetId = reactionPacket.id,