Enhance TAKTALK support with message and room handling, update SDK to v0.3.2 (#5634)

Co-authored-by: James Rich <james.a.rich@gmail.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
Ben Meadors
2026-05-28 12:33:16 -05:00
committed by GitHub
parent 321b73e726
commit a5e6894fe8
7 changed files with 342 additions and 8 deletions
+8
View File
@@ -76,5 +76,13 @@ kotlin {
implementation(libs.kotest.assertions)
implementation(libs.kotest.property)
}
val androidHostTest by getting {
dependencies {
// Host-JVM tests need the platform JAR (not AAR) for zstd native
// libs — the @aar from androidMain only ships ARM/x86 .so files.
implementation("com.github.luben:zstd-jni:1.5.7-9")
}
}
}
}
@@ -338,7 +338,7 @@ class TAKMeshIntegration(
.replace("""<?xml version="1.0" encoding="UTF-8"?>""", "")
.replace(Regex("""\s*\n\s*"""), "")
.trim()
// Logger.d { "RAW CoT IN (mesh): $xml" }
Logger.d { "RAW CoT IN (mesh): $xml" }
// Routes: ATAK ignores b-m-r CoT events over TCP streaming.
// Convert to a KML data package and write to ATAK's auto-import dir.
if (xml.contains("""type="b-m-r"""")) {
@@ -489,10 +489,16 @@ class TAKMeshIntegration(
listOf(
"""<takv[^>]*/>""", // TAK version (self-closing)
"""<takv[^>]*>.*?</takv>""", // TAK version (paired)
"""<voice[^>]*/>""", // voice chat state
"""<voice[^>]*>.*?</voice>""",
"""<marti[^>]*/>""", // empty marti
"""<marti[^>]*>.*?</marti>""",
// NOTE: <voice/>, <voice_profile_id*>, and <marti> are
// intentionally NOT stripped here. SDK v0.3.2 carries them
// end-to-end so TAKTALK receivers can:
// * fire TTS playback on <voice/> + <marti><dest callsign=ME>
// * recognize TAKTALK-origin chats via <voice_profile_id>
// Stripping these saves ~10-20B per packet but breaks TAKTALK
// voice messaging and directed-chat routing on the receiver —
// an unacceptable tradeoff. The previous broad pattern
// `<voice[^>]*/>` ALSO matched `<voice_profile_id/>`, which is
// why TAKTALK b-t-f chats lost their TAKTALK-origin marker too.
"""<__geofence[^>]*/>""", // geofence config
"""<__geofence[^>]*>.*?</__geofence>""",
"""<tog[^>]*/>""", // toggle state
@@ -64,6 +64,15 @@ class TakMeshTestRunner(private val commandSender: CommandSender) {
/** All bundled fixture filenames. */
val FIXTURE_NAMES =
listOf(
// TAK-Talk sanity test — first in the list so the operator's
// initial "Send Test CoTs" tap immediately exercises the
// most-stress-tested path on the receiver: m-t-t with
// <voice/> push-to-talk marker + <marti> directed routing.
// If the receiver's TAKTALK plugin plays a TTS clip for this
// single send, the v0.3.2 round-trip pipeline (sender callsign
// implicit, marti carried, no spurious <contact>) is wired
// end-to-end on both ends of the mesh.
"taktalk_sanity.xml",
"aircraft_adsb.xml",
"aircraft_hostile.xml",
"alert_tic.xml",
@@ -0,0 +1,213 @@
/*
* 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.takserver
import org.meshtastic.proto.CotType
import org.meshtastic.proto.GeoChat
import org.meshtastic.proto.TAKPacketV2
import org.meshtastic.proto.TakTalkMessage
import org.meshtastic.proto.TakTalkRoomData
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNotNull
import kotlin.test.assertNull
import kotlin.test.assertTrue
/**
* Regression tests for the TakV2Compressor adapter layer's TAKTALK handling.
*
* Before 0.3.0, the wire-to-SDK and SDK-to-wire converters in TakV2Compressor.kt had no cases for [TakTalkMessage] or
* [TakTalkRoomData]. m-t-t and y- events therefore fell through to [TakPacketV2Data.Payload.None] on the receive side,
* which surfaced as TAKTALK voice push-to-talk messages never reaching the TTS stage on the receiving ATAK device. Chat
* (b-t-f) was wired but its TAKTALK sidecars (lang, room_id, voice_profile_id) were dropped too.
*
* These tests pin the round-trip so any future regression of the same adapter gaps fails fast at unit-test time instead
* of in the field.
*/
class TakV2CompressorTaktalkTest {
@Test
fun `m-t-t taktalk message round-trips with voice marker`() {
val original =
TAKPacketV2(
cot_type_id = CotType.CotType_m_t_t,
callsign = "ASPEN",
uid = "TAKTALK-MESSAGE-81b44b48-21c5-4edf-aab2-b6bd2ec1f52f",
taktalk =
TakTalkMessage(
text = "Testing 123, testing 123.",
chatroom_id = "1",
lang = "English",
from_voice = true,
),
)
val wire = TakV2Compressor.compress(original)
val decompressed = TakV2Compressor.decompress(wire)
assertEquals(CotType.CotType_m_t_t, decompressed.cot_type_id)
assertEquals("ASPEN", decompressed.callsign)
assertNotNull(decompressed.taktalk, "taktalk variant must survive round-trip")
assertEquals("Testing 123, testing 123.", decompressed.taktalk!!.text)
assertEquals("1", decompressed.taktalk!!.chatroom_id)
assertEquals("English", decompressed.taktalk!!.lang)
assertTrue(decompressed.taktalk!!.from_voice, "<voice/> marker must survive")
assertNull(decompressed.pli, "must not fall back to Pli payload")
}
@Test
fun `m-t-t taktalk text-only message has from_voice false`() {
val original =
TAKPacketV2(
cot_type_id = CotType.CotType_m_t_t,
callsign = "ETHEL",
uid = "TAKTALK-MESSAGE-text-only",
taktalk =
TakTalkMessage(text = "typed message", chatroom_id = "1", lang = "English", from_voice = false),
)
val decompressed = TakV2Compressor.decompress(TakV2Compressor.compress(original))
assertNotNull(decompressed.taktalk)
assertEquals("typed message", decompressed.taktalk!!.text)
assertEquals(false, decompressed.taktalk!!.from_voice)
}
@Test
fun `y- taktalk room broadcast round-trips with participant list`() {
val original =
TAKPacketV2(
cot_type_id = CotType.CotType_y,
uid = "ROOM-DATA-5eac9336-2698-4dbb-9fa2-62b100e469fe",
// v0.3.2: sender identity lives on envelope packet.callsign,
// not on the deprecated TakTalkRoomData.sender_callsign field.
// The SDK builder reconstitutes <sender-callsign> from envelope
// callsign on emit.
callsign = "ASPEN",
taktalk_room =
TakTalkRoomData(
room_id = "30b2755c-c547-44ef-a0cc-cdbd8a15616f",
room_name = "test",
participants = listOf("ETHEL", "ASPEN"),
),
)
val decompressed = TakV2Compressor.decompress(TakV2Compressor.compress(original))
assertEquals(CotType.CotType_y, decompressed.cot_type_id)
assertNotNull(decompressed.taktalk_room, "taktalk_room variant must survive round-trip")
assertEquals(
"ASPEN",
decompressed.callsign,
"v0.3.2: <sender-callsign> routes to envelope callsign on round-trip",
)
assertEquals("30b2755c-c547-44ef-a0cc-cdbd8a15616f", decompressed.taktalk_room!!.room_id)
assertEquals("test", decompressed.taktalk_room!!.room_name)
assertEquals(listOf("ETHEL", "ASPEN"), decompressed.taktalk_room!!.participants)
}
@Test
fun `m-t-t with marti round-trips dest callsigns and survives compression`() {
val original =
TAKPacketV2(
cot_type_id = CotType.CotType_m_t_t,
uid = "TAKTALK-MESSAGE-marti-test-uuid",
callsign = "ASPEN",
taktalk =
TakTalkMessage(
text = "Push-to-talk to ETHEL",
chatroom_id = "1",
lang = "English",
from_voice = true,
),
// Directed-routing recipient list. TAKTALK gates voice TTS on
// this list matching the receiver's callsign — a regression
// here silently breaks voice messaging end-to-end.
marti = org.meshtastic.proto.Marti(dest_callsign = listOf("ETHEL")),
)
val decompressed = TakV2Compressor.decompress(TakV2Compressor.compress(original))
assertEquals(CotType.CotType_m_t_t, decompressed.cot_type_id)
assertEquals("ASPEN", decompressed.callsign)
assertNotNull(decompressed.marti, "marti must survive compression round-trip")
assertEquals(
listOf("ETHEL"),
decompressed.marti!!.dest_callsign,
"marti.dest_callsign must round-trip unchanged so TAKTALK can resolve TTS targets",
)
}
@Test
fun `b-t-f chat carries TAKTALK sidecars through compressor`() {
val original =
TAKPacketV2(
cot_type_id = CotType.CotType_b_t_f,
callsign = "ASPEN",
uid = "GeoChat.test",
chat =
GeoChat(
message = "Test message",
lang = "English",
room_id = "30b2755c-c547-44ef-a0cc-cdbd8a15616f",
voice_profile_id = "", // empty marker `<voice_profile_id/>`
),
)
val decompressed = TakV2Compressor.decompress(TakV2Compressor.compress(original))
assertNotNull(decompressed.chat)
assertEquals("Test message", decompressed.chat!!.message)
assertEquals("English", decompressed.chat!!.lang, "TAKTALK <Ea> lang must survive")
assertEquals(
"30b2755c-c547-44ef-a0cc-cdbd8a15616f",
decompressed.chat!!.room_id,
"TAKTALK <roomId> must survive",
)
assertNotNull(
decompressed.chat!!.voice_profile_id,
"empty <voice_profile_id/> marker must survive as empty string",
)
assertEquals("", decompressed.chat!!.voice_profile_id)
}
@Test
fun `b-t-f chat without TAKTALK sidecars leaves them null on the wire`() {
// Plain GeoChat from non-TAKTALK ATAK — no sidecars present. The
// round-trip must not synthesize empty-string sidecars that would
// change the rebuilt CoT XML for plain chat messages.
val original =
TAKPacketV2(
cot_type_id = CotType.CotType_b_t_f,
callsign = "ALPHA-1",
uid = "GeoChat.plain",
chat =
GeoChat(
message = "plain chat",
// no lang, no room_id, no voice_profile_id
),
)
val decompressed = TakV2Compressor.decompress(TakV2Compressor.compress(original))
assertNotNull(decompressed.chat)
assertEquals("plain chat", decompressed.chat!!.message)
assertNull(decompressed.chat!!.lang, "non-TAKTALK chat should have null lang on the wire")
assertNull(decompressed.chat!!.room_id, "non-TAKTALK chat should have null room_id")
assertNull(decompressed.chat!!.voice_profile_id, "non-TAKTALK chat should have null voice_profile_id")
}
}
@@ -185,7 +185,7 @@ internal class TAKClientConnection(
// Emitted at debug level so it's always available in logcat for field
// debugging without needing a release rebuild. Not truncated — the
// reader of this log needs the complete event to reproduce issues.
// Logger.d { "RAW CoT IN (TCP ${currentClientInfo.id}): $xmlString" }
Logger.d { "RAW CoT IN (TCP ${currentClientInfo.id}): $xmlString" }
val parser = CoTXmlParser(xmlString)
val result = parser.parse()
@@ -246,7 +246,7 @@ internal class TAKClientConnection(
// CoTMessage → XML round trip. This is the exact bytes the client
// will receive, so logging here closes the debugging loop with the
// matching RAW CoT IN line on the receiver.
// Logger.d { "RAW CoT OUT (TCP ${currentClientInfo.id}): $xml" }
Logger.d { "RAW CoT OUT (TCP ${currentClientInfo.id}): $xml" }
sendXmlInternal(xml)
}
@@ -264,7 +264,7 @@ internal class TAKClientConnection(
* preserve shape detail elements.
*/
fun sendRawXml(xml: String) {
// Logger.d { "RAW CoT OUT (TCP ${currentClientInfo.id}): [raw] $xml" }
Logger.d { "RAW CoT OUT (TCP ${currentClientInfo.id}): [raw] $xml" }
sendXmlInternal(xml)
}
@@ -28,6 +28,8 @@ import org.meshtastic.proto.GeoChat as WireGeoChat
import org.meshtastic.proto.Marker as WireMarker
import org.meshtastic.proto.RangeAndBearing as WireRangeAndBearing
import org.meshtastic.proto.Route as WireRoute
import org.meshtastic.proto.TakTalkMessage as WireTakTalkMessage
import org.meshtastic.proto.TakTalkRoomData as WireTakTalkRoomData
import org.meshtastic.proto.TaskRequest as WireTaskRequest
import org.meshtastic.proto.Team as WireTeam
import org.meshtastic.tak.TakCompressor as SdkCompressor
@@ -104,6 +106,41 @@ internal actual object TakV2Compressor {
toCallsign = packet.chat!!.to_callsign,
receiptForUid = packet.chat!!.receipt_for_uid,
receiptType = packet.chat!!.receipt_type.value,
// TAKTALK sidecars (proto3 optional → wire nullable).
// Empty string = empty `<Ea/>` / `<roomId/>` in source XML;
// null on the wire = field absent. The SDK's Chat data class
// uses "" for absent, so map null → "". voice_profile_id has
// a present-vs-empty-marker distinction tracked separately
// via hasVoiceProfile.
lang = packet.chat!!.lang ?: "",
roomId = packet.chat!!.room_id ?: "",
voiceProfileId = packet.chat!!.voice_profile_id ?: "",
hasVoiceProfile = packet.chat!!.voice_profile_id != null,
)
// TAKTALK voice/text message (m-t-t). Without this branch,
// m-t-t events fall through to Payload.None and the receiver
// can't rebuild the CoT event, so TTS playback never fires.
packet.taktalk != null ->
TakPacketV2Data.Payload.TakTalk(
text = packet.taktalk!!.text,
chatroomId = packet.taktalk!!.chatroom_id,
lang = packet.taktalk!!.lang,
fromVoice = packet.taktalk!!.from_voice,
)
// TAKTALK room/membership broadcast (y-).
packet.taktalk_room != null ->
@Suppress("DEPRECATION")
TakPacketV2Data.Payload.TakTalkRoom(
// sender_callsign deprecated in SDK v0.3.2 — sender
// identity is now in envelope packet.callsign;
// v0.3.1 packets still populate the legacy field so
// we forward it for source-compat readers.
senderCallsign = packet.taktalk_room!!.sender_callsign,
roomId = packet.taktalk_room!!.room_id,
roomName = packet.taktalk_room!!.room_name,
participants = packet.taktalk_room!!.participants.toList(),
)
packet.aircraft != null ->
@@ -274,6 +311,12 @@ internal actual object TakV2Compressor {
takOs = packet.tak_os,
endpoint = packet.endpoint,
phone = packet.phone,
// Directed-routing recipient callsigns (<marti><dest …/>…</marti>).
// Empty list = broadcast (default); populated for TAKTALK m-t-t,
// directed b-t-f DMs, and any other CoT shape that ATAK addresses
// to specific peers. Without this field the receive-side rebuild
// drops <marti>, breaking TAKTALK voice TTS.
marti = packet.marti?.dest_callsign?.toList() ?: emptyList(),
payload = payload,
)
}
@@ -327,6 +370,13 @@ internal actual object TakV2Compressor {
receipt_type =
WireGeoChat.ReceiptType.fromValue(chat.receiptType)
?: WireGeoChat.ReceiptType.ReceiptType_None,
// TAKTALK sidecars. Empty SDK string → wire null (field absent)
// so non-TAKTALK chats don't carry empty sidecar bytes on every
// mesh packet. voice_profile_id stays present-but-empty when
// hasVoiceProfile=true so the receiver can re-emit `<voice_profile_id/>`.
lang = chat.lang.ifEmpty { null },
room_id = chat.roomId.ifEmpty { null },
voice_profile_id = if (chat.hasVoiceProfile) chat.voiceProfileId else null,
)
},
aircraft =
@@ -476,6 +526,42 @@ internal actual object TakV2Compressor {
)
},
raw_detail = (data.payload as? TakPacketV2Data.Payload.RawDetail)?.bytes?.toByteString(),
// TAKTALK voice/text message (m-t-t). Without this, m-t-t events
// would compress with no payload set, the receiver's wireToSdkData
// would fall through to Payload.None, and TAKTALK plugin would
// never see the rebuilt CoT event for TTS playback.
taktalk =
(data.payload as? TakPacketV2Data.Payload.TakTalk)?.let { tt ->
WireTakTalkMessage(
text = tt.text,
chatroom_id = tt.chatroomId,
lang = tt.lang,
from_voice = tt.fromVoice,
)
},
// TAKTALK room/membership broadcast (y-). Required for receivers
// to resolve TAKTALK room UUIDs to friendly names + rosters.
taktalk_room =
(data.payload as? TakPacketV2Data.Payload.TakTalkRoom)?.let { room ->
@Suppress("DEPRECATION")
WireTakTalkRoomData(
// sender_callsign deprecated in SDK v0.3.2 — the SDK
// builder reconstitutes <sender-callsign> from envelope
// packet.callsign, so we stop emitting the duplicate
// wire byte. Field stays present for one release so
// v0.3.1 receivers continue decoding cleanly.
sender_callsign = "",
room_id = room.roomId,
room_name = room.roomName,
participants = room.participants.toList(),
)
},
// Directed-routing recipient list (<marti><dest …/>…</marti>).
// Empty list = broadcast (default); populated for TAKTALK m-t-t
// and directed b-t-f DMs. Encode an explicit Marti only when
// there is at least one destination — the wrapper costs wire
// bytes for no benefit on broadcast packets.
marti = data.marti.takeIf { it.isNotEmpty() }?.let { org.meshtastic.proto.Marti(dest_callsign = it) },
)
}
}
@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="UTF-8"?>
<event version="2.0" uid="TAKTALK-MESSAGE-sanity-test-aspen-to-ethel" type="m-t-t" how="null" time="2026-05-27T20:00:00.000Z" start="2026-05-27T20:00:00.000Z" stale="2026-05-27T20:06:00.000Z">
<point lat="0.0" lon="0.0" hae="9999999.0" ce="9999999.0" le="9999999.0"/>
<detail>
<callsign>ASPEN</callsign>
<lang>English</lang>
<text>TAK-Talk sanity test from ASPEN.</text>
<chatroom-id>1</chatroom-id>
<voice/>
<marti><dest callsign="ETHEL"/></marti>
</detail>
</event>