diff --git a/core/takserver/build.gradle.kts b/core/takserver/build.gradle.kts index 0c0405bf7..a89201ec3 100644 --- a/core/takserver/build.gradle.kts +++ b/core/takserver/build.gradle.kts @@ -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") + } + } } } diff --git a/core/takserver/src/commonMain/kotlin/org/meshtastic/core/takserver/TAKMeshIntegration.kt b/core/takserver/src/commonMain/kotlin/org/meshtastic/core/takserver/TAKMeshIntegration.kt index ce14df9cd..c01b0dd58 100644 --- a/core/takserver/src/commonMain/kotlin/org/meshtastic/core/takserver/TAKMeshIntegration.kt +++ b/core/takserver/src/commonMain/kotlin/org/meshtastic/core/takserver/TAKMeshIntegration.kt @@ -338,7 +338,7 @@ class TAKMeshIntegration( .replace("""""", "") .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( """]*/>""", // TAK version (self-closing) """]*>.*?""", // TAK version (paired) - """]*/>""", // voice chat state - """]*>.*?""", - """]*/>""", // empty marti - """]*>.*?""", + // NOTE: , , and are + // intentionally NOT stripped here. SDK v0.3.2 carries them + // end-to-end so TAKTALK receivers can: + // * fire TTS playback on + + // * recognize TAKTALK-origin chats via + // 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 + // `]*/>` ALSO matched ``, which is + // why TAKTALK b-t-f chats lost their TAKTALK-origin marker too. """<__geofence[^>]*/>""", // geofence config """<__geofence[^>]*>.*?""", """]*/>""", // toggle state diff --git a/core/takserver/src/commonMain/kotlin/org/meshtastic/core/takserver/TakMeshTestRunner.kt b/core/takserver/src/commonMain/kotlin/org/meshtastic/core/takserver/TakMeshTestRunner.kt index b422a8a12..d4dde246a 100644 --- a/core/takserver/src/commonMain/kotlin/org/meshtastic/core/takserver/TakMeshTestRunner.kt +++ b/core/takserver/src/commonMain/kotlin/org/meshtastic/core/takserver/TakMeshTestRunner.kt @@ -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 + // push-to-talk marker + 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 ) is wired + // end-to-end on both ends of the mesh. + "taktalk_sanity.xml", "aircraft_adsb.xml", "aircraft_hostile.xml", "alert_tic.xml", diff --git a/core/takserver/src/commonTest/kotlin/org/meshtastic/core/takserver/TakV2CompressorTaktalkTest.kt b/core/takserver/src/commonTest/kotlin/org/meshtastic/core/takserver/TakV2CompressorTaktalkTest.kt new file mode 100644 index 000000000..779149100 --- /dev/null +++ b/core/takserver/src/commonTest/kotlin/org/meshtastic/core/takserver/TakV2CompressorTaktalkTest.kt @@ -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 . + */ +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, " 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 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: 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 `` + ), + ) + + val decompressed = TakV2Compressor.decompress(TakV2Compressor.compress(original)) + + assertNotNull(decompressed.chat) + assertEquals("Test message", decompressed.chat!!.message) + assertEquals("English", decompressed.chat!!.lang, "TAKTALK lang must survive") + assertEquals( + "30b2755c-c547-44ef-a0cc-cdbd8a15616f", + decompressed.chat!!.room_id, + "TAKTALK must survive", + ) + assertNotNull( + decompressed.chat!!.voice_profile_id, + "empty 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") + } +} diff --git a/core/takserver/src/jvmAndroidMain/kotlin/org/meshtastic/core/takserver/TAKClientConnection.kt b/core/takserver/src/jvmAndroidMain/kotlin/org/meshtastic/core/takserver/TAKClientConnection.kt index 689afbaf9..4ca80c669 100644 --- a/core/takserver/src/jvmAndroidMain/kotlin/org/meshtastic/core/takserver/TAKClientConnection.kt +++ b/core/takserver/src/jvmAndroidMain/kotlin/org/meshtastic/core/takserver/TAKClientConnection.kt @@ -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) } diff --git a/core/takserver/src/jvmAndroidMain/kotlin/org/meshtastic/core/takserver/TakV2Compressor.kt b/core/takserver/src/jvmAndroidMain/kotlin/org/meshtastic/core/takserver/TakV2Compressor.kt index 97de4e269..102b72f7a 100644 --- a/core/takserver/src/jvmAndroidMain/kotlin/org/meshtastic/core/takserver/TakV2Compressor.kt +++ b/core/takserver/src/jvmAndroidMain/kotlin/org/meshtastic/core/takserver/TakV2Compressor.kt @@ -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 `` / `` 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 (). + // 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 , 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 ``. + 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 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 (). + // 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) }, ) } } diff --git a/core/takserver/src/jvmAndroidMain/resources/tak_test_fixtures/taktalk_sanity.xml b/core/takserver/src/jvmAndroidMain/resources/tak_test_fixtures/taktalk_sanity.xml new file mode 100644 index 000000000..1a59859af --- /dev/null +++ b/core/takserver/src/jvmAndroidMain/resources/tak_test_fixtures/taktalk_sanity.xml @@ -0,0 +1,12 @@ + + + + + ASPEN + English + TAK-Talk sanity test from ASPEN. + 1 + + + +