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

@ -39,6 +39,8 @@ enum class MessageStatus : Parcelable {
QUEUED, // Waiting to send to the mesh as soon as we connect to the device
ENROUTE, // Delivered to the radio, but no ACK or NAK received
DELIVERED, // We received an ack
SFPP_ROUTING, // Message is being routed/buffered in the SFPP system
SFPP_CONFIRMED, // Message is confirmed on the SFPP chain
ERROR, // We received back a nak, message not delivered
}
@ -65,6 +67,7 @@ data class DataPacket(
var viaMqtt: Boolean = false, // True if this packet passed via MQTT somewhere along its path
var retryCount: Int = 0, // Number of automatic retry attempts
var emoji: Int = 0,
var sfppHash: ByteArray? = null,
) : Parcelable {
/** If there was an error with this message, this string describes what was wrong. */
@ -142,6 +145,7 @@ data class DataPacket(
parcel.readInt() == 1, // viaMqtt
parcel.readInt(), // retryCount
parcel.readInt(), // emoji
parcel.createByteArray(), // sfppHash
)
@Suppress("CyclomaticComplexMethod")
@ -168,6 +172,7 @@ data class DataPacket(
if (relayNode != other.relayNode) return false
if (retryCount != other.retryCount) return false
if (emoji != other.emoji) return false
if (!sfppHash.contentEquals(other.sfppHash)) return false
return true
}
@ -190,6 +195,7 @@ data class DataPacket(
result = 31 * result + relayNode.hashCode()
result = 31 * result + retryCount
result = 31 * result + emoji
result = 31 * result + (sfppHash?.contentHashCode() ?: 0)
return result
}
@ -213,6 +219,7 @@ data class DataPacket(
parcel.writeInt(if (viaMqtt) 1 else 0)
parcel.writeInt(retryCount)
parcel.writeInt(emoji)
parcel.writeByteArray(sfppHash)
}
override fun describeContents(): Int = 0
@ -238,6 +245,7 @@ data class DataPacket(
viaMqtt = parcel.readInt() == 1
retryCount = parcel.readInt()
emoji = parcel.readInt()
sfppHash = parcel.createByteArray()
}
companion object CREATOR : Parcelable.Creator<DataPacket> {

View file

@ -0,0 +1,35 @@
/*
* 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 org.meshtastic.core.model.util
import java.nio.ByteBuffer
import java.nio.ByteOrder
import java.security.MessageDigest
object SfppHasher {
private const val HASH_SIZE = 16
private const val INT_BYTES = 4
fun computeMessageHash(encryptedPayload: ByteArray, to: Int, from: Int, id: Int): ByteArray {
val digest = MessageDigest.getInstance("SHA-256")
digest.update(encryptedPayload)
digest.update(ByteBuffer.allocate(INT_BYTES).order(ByteOrder.LITTLE_ENDIAN).putInt(to).array())
digest.update(ByteBuffer.allocate(INT_BYTES).order(ByteOrder.LITTLE_ENDIAN).putInt(from).array())
digest.update(ByteBuffer.allocate(INT_BYTES).order(ByteOrder.LITTLE_ENDIAN).putInt(id).array())
return digest.digest().copyOf(HASH_SIZE)
}
}

View file

@ -0,0 +1,73 @@
/*
* Copyright (c) 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 org.meshtastic.core.model
import kotlinx.serialization.json.Json
import org.junit.Assert.assertArrayEquals
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotEquals
import org.junit.Test
class DataPacketTest {
@Test
fun `DataPacket sfppHash is nullable and correctly set`() {
val hash = byteArrayOf(1, 2, 3, 4)
val packet = DataPacket(to = "to", channel = 0, text = "hello").copy(sfppHash = hash)
assertArrayEquals(hash, packet.sfppHash)
val packetNoHash = DataPacket(to = "to", channel = 0, text = "hello")
assertEquals(null, packetNoHash.sfppHash)
}
@Test
fun `MessageStatus SFPP_CONFIRMED exists`() {
val status = MessageStatus.SFPP_CONFIRMED
assertEquals("SFPP_CONFIRMED", status.name)
}
@Test
fun `DataPacket serialization preserves sfppHash`() {
val hash = byteArrayOf(5, 6, 7, 8)
val packet =
DataPacket(to = "to", channel = 0, text = "test")
.copy(sfppHash = hash, status = MessageStatus.SFPP_CONFIRMED)
val json = Json { isLenient = true }
val encoded = json.encodeToString(DataPacket.serializer(), packet)
val decoded = json.decodeFromString(DataPacket.serializer(), encoded)
assertEquals(packet.status, decoded.status)
assertArrayEquals(hash, decoded.sfppHash)
}
@Test
fun `DataPacket equals and hashCode include sfppHash`() {
val hash1 = byteArrayOf(1, 2, 3)
val hash2 = byteArrayOf(4, 5, 6)
val p1 = DataPacket(to = "to", channel = 0, text = "text").copy(sfppHash = hash1)
val p2 = DataPacket(to = "to", channel = 0, text = "text").copy(sfppHash = hash1)
val p3 = DataPacket(to = "to", channel = 0, text = "text").copy(sfppHash = hash2)
val p4 = DataPacket(to = "to", channel = 0, text = "text").copy(sfppHash = null)
assertEquals(p1, p2)
assertEquals(p1.hashCode(), p2.hashCode())
assertNotEquals(p1, p3)
assertNotEquals(p1, p4)
assertNotEquals(p1.hashCode(), p3.hashCode())
}
}

View file

@ -0,0 +1,95 @@
/*
* 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 org.meshtastic.core.model.util
import org.junit.Assert.assertArrayEquals
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotEquals
import org.junit.Test
class SfppHasherTest {
@Test
fun `computeMessageHash produces consistent results`() {
val payload = "Hello World".toByteArray()
val to = 1234
val from = 5678
val id = 999
val hash1 = SfppHasher.computeMessageHash(payload, to, from, id)
val hash2 = SfppHasher.computeMessageHash(payload, to, from, id)
assertArrayEquals(hash1, hash2)
assertEquals(16, hash1.size)
}
@Test
fun `computeMessageHash produces different results for different inputs`() {
val payload = "Hello World".toByteArray()
val to = 1234
val from = 5678
val id = 999
val hashBase = SfppHasher.computeMessageHash(payload, to, from, id)
// Different payload
val hashDiffPayload = SfppHasher.computeMessageHash("Hello Work".toByteArray(), to, from, id)
assertNotEquals(hashBase.toList(), hashDiffPayload.toList())
// Different to
val hashDiffTo = SfppHasher.computeMessageHash(payload, 1235, from, id)
assertNotEquals(hashBase.toList(), hashDiffTo.toList())
// Different from
val hashDiffFrom = SfppHasher.computeMessageHash(payload, to, 5679, id)
assertNotEquals(hashBase.toList(), hashDiffFrom.toList())
// Different id
val hashDiffId = SfppHasher.computeMessageHash(payload, to, from, 1000)
assertNotEquals(hashBase.toList(), hashDiffId.toList())
}
@Test
fun `computeMessageHash handles large values`() {
val payload = byteArrayOf(1, 2, 3)
// Testing that large unsigned-like values don't cause issues
val to = -1 // 0xFFFFFFFF
val from = 0x7FFFFFFF
val id = Int.MIN_VALUE
val hash = SfppHasher.computeMessageHash(payload, to, from, id)
assertEquals(16, hash.size)
}
@Test
fun `computeMessageHash follows little endian for integers`() {
// This test ensures that the hash is computed consistently with the firmware
// which uses little-endian byte order for these fields.
val payload = byteArrayOf()
val to = 0x01020304
val from = 0x05060708
val id = 0x090A0B0C
val hash = SfppHasher.computeMessageHash(payload, to, from, id)
assertNotNull(hash)
assertEquals(16, hash.size)
}
private fun assertNotNull(any: Any?) {
if (any == null) throw AssertionError("Should not be null")
}
}