mirror of
https://github.com/meshtastic/Meshtastic-Android.git
synced 2026-06-02 06:24:16 +02:00
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:
@@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+11
-5
@@ -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
|
||||
|
||||
+9
@@ -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",
|
||||
|
||||
+213
@@ -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")
|
||||
}
|
||||
}
|
||||
+3
-3
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
+86
@@ -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>
|
||||
Reference in New Issue
Block a user