diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index f3bea85f7..16b87cd52 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -60,9 +60,9 @@
-->
-
+
+
diff --git a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshDataHandlerImpl.kt b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshDataHandlerImpl.kt
index 0a3f03004..0c55c6556 100644
--- a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshDataHandlerImpl.kt
+++ b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshDataHandlerImpl.kt
@@ -191,7 +191,7 @@ class MeshDataHandlerImpl(
}
PortNum.ATAK_PLUGIN,
- PortNum.ATAK_FORWARDER,
+ PortNum.ATAK_PLUGIN_V2,
PortNum.PRIVATE_APP,
-> {
shouldBroadcast = true
diff --git a/core/navigation/src/commonMain/kotlin/org/meshtastic/core/navigation/NavigationConfig.kt b/core/navigation/src/commonMain/kotlin/org/meshtastic/core/navigation/NavigationConfig.kt
index fe5c6225a..180c6c659 100644
--- a/core/navigation/src/commonMain/kotlin/org/meshtastic/core/navigation/NavigationConfig.kt
+++ b/core/navigation/src/commonMain/kotlin/org/meshtastic/core/navigation/NavigationConfig.kt
@@ -100,6 +100,7 @@ val MeshtasticNavSavedStateConfig = SavedStateConfiguration {
// Settings - Advanced routes
subclass(SettingsRoutes.CleanNodeDb::class, SettingsRoutes.CleanNodeDb.serializer())
+ subclass(SettingsRoutes.TakServer::class, SettingsRoutes.TakServer.serializer())
subclass(SettingsRoutes.DebugPanel::class, SettingsRoutes.DebugPanel.serializer())
subclass(SettingsRoutes.About::class, SettingsRoutes.About.serializer())
subclass(SettingsRoutes.FilterSettings::class, SettingsRoutes.FilterSettings.serializer())
diff --git a/core/navigation/src/commonMain/kotlin/org/meshtastic/core/navigation/Routes.kt b/core/navigation/src/commonMain/kotlin/org/meshtastic/core/navigation/Routes.kt
index b58b20f2b..c442d7b20 100644
--- a/core/navigation/src/commonMain/kotlin/org/meshtastic/core/navigation/Routes.kt
+++ b/core/navigation/src/commonMain/kotlin/org/meshtastic/core/navigation/Routes.kt
@@ -162,6 +162,8 @@ object SettingsRoutes {
@Serializable data object CleanNodeDb : Route
+ @Serializable data object TakServer : Route
+
@Serializable data object DebugPanel : Route
@Serializable data object About : Route
diff --git a/core/proto/src/main/proto b/core/proto/src/main/proto
index 10a86bf0b..9b123f392 160000
--- a/core/proto/src/main/proto
+++ b/core/proto/src/main/proto
@@ -1 +1 @@
-Subproject commit 10a86bf0b9c1fc9242363c17e4dfc54185967232
+Subproject commit 9b123f392fab424db3f413243db2ea66f1880334
diff --git a/core/service/src/androidMain/kotlin/org/meshtastic/core/service/MeshService.kt b/core/service/src/androidMain/kotlin/org/meshtastic/core/service/MeshService.kt
index c8b7fdfab..1a0029edb 100644
--- a/core/service/src/androidMain/kotlin/org/meshtastic/core/service/MeshService.kt
+++ b/core/service/src/androidMain/kotlin/org/meshtastic/core/service/MeshService.kt
@@ -22,6 +22,7 @@ import android.content.Intent
import android.content.pm.ServiceInfo
import android.os.Build
import android.os.IBinder
+import android.os.PowerManager
import androidx.core.app.ServiceCompat
import co.touchlab.kermit.Logger
import kotlinx.coroutines.CoroutineScope
@@ -73,6 +74,16 @@ class MeshService : Service() {
private val serviceJob = Job()
private val serviceScope = CoroutineScope(Dispatchers.IO + serviceJob)
+ /**
+ * Partial wake lock held while the foreground service is running. Prevents the CPU
+ * from being throttled while the TAK server's keepalive coroutines, socket writes,
+ * and mesh packet handlers need to run on a regular cadence. Without this, OEM
+ * battery optimizations can pause coroutines for long enough that connected TAK
+ * clients (ATAK/iTAK) time out waiting for data, even though the foreground
+ * service itself keeps the process alive.
+ */
+ private var wakeLock: PowerManager.WakeLock? = null
+
private val myNodeNum: Int
get() = nodeManager.myNodeNum.value ?: throw RadioNotConnectedException()
@@ -162,14 +173,50 @@ class MeshService : Service() {
return if (!wantForeground) {
Logger.i { "Stopping mesh service because no device is selected" }
+ releaseWakeLock()
ServiceCompat.stopForeground(this, ServiceCompat.STOP_FOREGROUND_REMOVE)
stopSelf()
START_NOT_STICKY
} else {
+ acquireWakeLock()
START_STICKY
}
}
+ private fun acquireWakeLock() {
+ if (wakeLock?.isHeld == true) return
+ try {
+ val powerManager = getSystemService(POWER_SERVICE) as PowerManager
+ val lock = powerManager.newWakeLock(
+ PowerManager.PARTIAL_WAKE_LOCK,
+ "Meshtastic::MeshServiceWakeLock",
+ ).apply {
+ setReferenceCounted(false)
+ }
+ lock.acquire()
+ wakeLock = lock
+ Logger.i { "Acquired partial wake lock for mesh service" }
+ } catch (e: SecurityException) {
+ Logger.w(e) { "Failed to acquire wake lock — WAKE_LOCK permission missing?" }
+ } catch (e: Exception) {
+ Logger.w(e) { "Failed to acquire wake lock" }
+ }
+ }
+
+ private fun releaseWakeLock() {
+ val lock = wakeLock ?: return
+ try {
+ if (lock.isHeld) {
+ lock.release()
+ Logger.i { "Released partial wake lock for mesh service" }
+ }
+ } catch (e: Exception) {
+ Logger.w(e) { "Failed to release wake lock" }
+ } finally {
+ wakeLock = null
+ }
+ }
+
override fun onTaskRemoved(rootIntent: Intent?) {
super.onTaskRemoved(rootIntent)
Logger.i { "Mesh service: onTaskRemoved" }
@@ -179,6 +226,7 @@ class MeshService : Service() {
override fun onDestroy() {
Logger.i { "Destroying mesh service" }
+ releaseWakeLock()
ServiceCompat.stopForeground(this, ServiceCompat.STOP_FOREGROUND_REMOVE)
orchestrator.stop()
serviceJob.cancel()
diff --git a/core/service/src/commonTest/kotlin/org/meshtastic/core/service/MeshServiceOrchestratorTest.kt b/core/service/src/commonTest/kotlin/org/meshtastic/core/service/MeshServiceOrchestratorTest.kt
index 611454d05..428c44ac4 100644
--- a/core/service/src/commonTest/kotlin/org/meshtastic/core/service/MeshServiceOrchestratorTest.kt
+++ b/core/service/src/commonTest/kotlin/org/meshtastic/core/service/MeshServiceOrchestratorTest.kt
@@ -40,14 +40,13 @@ import org.meshtastic.core.repository.MeshMessageProcessor
import org.meshtastic.core.repository.MeshRouter
import org.meshtastic.core.repository.MeshServiceNotifications
import org.meshtastic.core.repository.NodeManager
-import org.meshtastic.core.repository.NodeRepository
+
import org.meshtastic.core.repository.PacketHandler
import org.meshtastic.core.repository.RadioInterfaceService
import org.meshtastic.core.repository.ServiceRepository
import org.meshtastic.core.repository.TakPrefs
import org.meshtastic.core.takserver.TAKMeshIntegration
import org.meshtastic.core.takserver.TAKServerManager
-import org.meshtastic.core.takserver.fountain.CoTHandler
import org.meshtastic.proto.LocalModuleConfig
import kotlin.test.Test
import kotlin.test.assertFalse
@@ -59,7 +58,7 @@ class MeshServiceOrchestratorTest {
private val serviceRepository: ServiceRepository = mock(MockMode.autofill)
private val packetHandler: PacketHandler = mock(MockMode.autofill)
private val nodeManager: NodeManager = mock(MockMode.autofill)
- private val nodeRepository: NodeRepository = mock(MockMode.autofill)
+
private val messageProcessor: MeshMessageProcessor = mock(MockMode.autofill)
private val commandSender: CommandSender = mock(MockMode.autofill)
private val connectionManager: MeshConnectionManager = mock(MockMode.autofill)
@@ -69,7 +68,6 @@ class MeshServiceOrchestratorTest {
private val serviceNotifications: MeshServiceNotifications = mock(MockMode.autofill)
private val takServerManager: TAKServerManager = mock(MockMode.autofill)
private val takPrefs: TakPrefs = mock(MockMode.autofill)
- private val cotHandler: CoTHandler = mock(MockMode.autofill)
private val databaseManager: DatabaseManager = mock(MockMode.autofill)
private val testDispatcher = UnconfinedTestDispatcher()
@@ -91,17 +89,14 @@ class MeshServiceOrchestratorTest {
every { takPrefs.isTakServerEnabled } returns takEnabledFlow
every { takServerManager.isRunning } returns takRunningFlow
every { takServerManager.inboundMessages } returns MutableSharedFlow()
- every { nodeRepository.nodeDBbyNum } returns MutableStateFlow(emptyMap())
every { router.actionHandler } returns actionHandler
val takMeshIntegration =
TAKMeshIntegration(
takServerManager = takServerManager,
commandSender = commandSender,
- nodeRepository = nodeRepository,
serviceRepository = serviceRepository,
meshConfigHandler = meshConfigHandler,
- cotHandler = cotHandler,
)
return MeshServiceOrchestrator(
diff --git a/core/takserver/build.gradle.kts b/core/takserver/build.gradle.kts
index a1b1a7acb..e36cf1852 100644
--- a/core/takserver/build.gradle.kts
+++ b/core/takserver/build.gradle.kts
@@ -51,7 +51,51 @@ kotlin {
implementation(libs.kermit)
}
- jvmAndroidMain.dependencies {}
+ jvmAndroidMain.dependencies {
+ // TAKPacket-SDK for v2 compression/decompression (via JitPack).
+ //
+ // We depend on the `-jvm` variant directly rather than the parent
+ // `com.github.meshtastic:TAKPacket-SDK` coordinate. JitPack does
+ // not publish a root-level Gradle module metadata (.module) file
+ // for the KMP parent, only per-target ones. With just the parent
+ // POM, Gradle reads the four KMP variants (jvm, iosarm64,
+ // iossimulatorarm64, metadata) as unconditional Maven deps and
+ // tries to resolve them ALL against this Android consumer — the
+ // iOS klibs declare `platform.type=native` with no androidJvm
+ // variant, so variant selection fails with "No matching variant".
+ //
+ // Depending directly on `takpacket-sdk-jvm` skips the parent POM
+ // entirely and goes straight to the JVM artifact's own module
+ // metadata, which is compatible with both `jvm()` and Android
+ // targets in this `jvmAndroidMain` source set. It still pulls
+ // zstd-jni + xpp3 + wire-runtime-jvm + kotlin-stdlib as
+ // transitive deps from the JVM variant's POM.
+ //
+ // zstd-jni's @aar variant is still declared explicitly in the
+ // androidMain source set below so Android gets the .so files.
+ implementation("com.github.meshtastic.TAKPacket-SDK:takpacket-sdk-jvm:v0.1.3") {
+ // The SDK's jvmMain declares zstd-jni as a runtime dep (standard
+ // JAR with desktop native libs). Android needs the @aar variant
+ // instead (ships arm/arm64/x86/x86_64 .so files). Both packaging
+ // formats contain the same Java classes, so Android's dex merger
+ // hits "Duplicate class" errors if both land on the classpath.
+ // Exclude here; androidMain re-adds it as @aar below, and jvmMain
+ // re-adds the JAR for desktop.
+ exclude(group = "com.github.luben", module = "zstd-jni")
+ }
+ }
+
+ jvmMain.dependencies {
+ // Desktop JVM: standard JAR bundles native libs for desktop archs.
+ implementation("com.github.luben:zstd-jni:1.5.7-7")
+ }
+
+ androidMain.dependencies {
+ // Android: @aar variant ships .so files for arm/arm64/x86/x86_64.
+ // Without this, zstd-jni's ZstdDictCompress. throws
+ // UnsatisfiedLinkError and poisons TakV2Compressor permanently.
+ implementation("com.github.luben:zstd-jni:1.5.7-7@aar")
+ }
commonTest.dependencies {
implementation(projects.core.testing)
diff --git a/core/takserver/libs/meshtastic-tak-0.1.0.jar b/core/takserver/libs/meshtastic-tak-0.1.0.jar
deleted file mode 100644
index 8cd8eccbd..000000000
Binary files a/core/takserver/libs/meshtastic-tak-0.1.0.jar and /dev/null differ
diff --git a/core/takserver/src/commonMain/kotlin/org/meshtastic/core/takserver/fountain/CoTHandler.kt b/core/takserver/src/commonMain/kotlin/org/meshtastic/core/takserver/AtakFileWriter.kt
similarity index 56%
rename from core/takserver/src/commonMain/kotlin/org/meshtastic/core/takserver/fountain/CoTHandler.kt
rename to core/takserver/src/commonMain/kotlin/org/meshtastic/core/takserver/AtakFileWriter.kt
index 544aabfad..bbe6af5f9 100644
--- a/core/takserver/src/commonMain/kotlin/org/meshtastic/core/takserver/fountain/CoTHandler.kt
+++ b/core/takserver/src/commonMain/kotlin/org/meshtastic/core/takserver/AtakFileWriter.kt
@@ -14,18 +14,19 @@
* You should have received a copy of the GNU General Public License
* along with this program. If not, see .
*/
-package org.meshtastic.core.takserver.fountain
-
-import org.meshtastic.core.takserver.CoTMessage
+package org.meshtastic.core.takserver
/**
- * Handles incoming and outgoing generic Cursor on Target (CoT) messages wrapped in Meshtastic DataPackets.
+ * Writes data package files to ATAK's auto-import directory.
*
- * Defines the contract for routing Direct (unfragmented) vs Fountain-encoded packets, and processing decompressed
- * EXI/Zlib XML payloads.
+ * On Android, the actual implementation writes to
+ * `/sdcard/atak/tools/datapackage/` which ATAK monitors for new zip files.
+ * On other platforms this is a no-op.
*/
-interface CoTHandler {
- suspend fun sendGenericCoT(cotMessage: CoTMessage)
-
- suspend fun handleIncomingForwarderPacket(payload: ByteArray, senderNodeNum: Int)
+internal expect object AtakFileWriter {
+ /**
+ * Write a data package zip to ATAK's monitored import directory.
+ * @return true if the file was written successfully, false otherwise.
+ */
+ fun writeToImportDir(fileName: String, zipBytes: ByteArray): Boolean
}
diff --git a/core/takserver/src/commonMain/kotlin/org/meshtastic/core/takserver/CoTDetailStripper.kt b/core/takserver/src/commonMain/kotlin/org/meshtastic/core/takserver/CoTDetailStripper.kt
new file mode 100644
index 000000000..8aec460ad
--- /dev/null
+++ b/core/takserver/src/commonMain/kotlin/org/meshtastic/core/takserver/CoTDetailStripper.kt
@@ -0,0 +1,176 @@
+/*
+ * 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
+
+/**
+ * Removes bloat elements from the `` content of a CoT event before it is
+ * stuffed into a [org.meshtastic.proto.TAKPacketV2] `raw_detail` field for mesh
+ * transmission.
+ *
+ * # Why this exists
+ *
+ * A LoRa mesh packet has a hard payload limit of
+ * [org.meshtastic.proto.Constants.DATA_PAYLOAD_LEN] = 233 bytes for the entire encoded
+ * `Data` proto (portnum + payload + reply_id + emoji). Subtracting the wrapper
+ * overhead leaves roughly **~225 bytes** for the TAK wire payload, and the wire payload
+ * itself is `[1 byte dict-id flag][zstd-compressed TAKPacketV2 protobuf]`.
+ *
+ * ATAK emits CoT events with rich visual metadata that is **never useful over a mesh**:
+ * icon set paths, ARGB colors, shape geometry, archive flags, file references, etc.
+ * A typical `u-d-c-c` (user-drawn circle) event from ATAK is **800+ bytes of XML**, of
+ * which maybe 80 bytes are actually meaningful to a receiving node. Even with
+ * dictionary compression, the full payload overflows the MTU.
+ *
+ * This stripper deletes elements the receiving node can synthesize or ignore, leaving
+ * only the minimum needed to rebuild a usable `` on the other side: who sent
+ * it, where they are, what team/role they're on, battery status, chat content, and
+ * the high-level CoT type (which rides separately on [TAKPacketV2.cot_type_id] /
+ * [TAKPacketV2.cot_type_str]).
+ *
+ * # What gets dropped
+ *
+ * **Cosmetic / rendering-only** (pure visual, no situational awareness value):
+ * - `` — ARGB stroke/fill colors
+ * - ``, ``, `` — shape styling
+ * - `` — label visibility toggle
+ * - `` — icon set path (`COT_MAPPING_2525B/...`)
+ * - `` — 3D model reference
+ *
+ * **Geometric detail** (we keep lat/lon on the event; shape primitives are too big):
+ * - `...` — ellipse/polyline/polygon geometry
+ * - ``, `` — rendering hints
+ *
+ * **Resource references** (useless without the resource being reachable):
+ * - `` — file transfer references
+ * - `<__video .../>` — video stream URL
+ *
+ * **Flags and redundant metadata**:
+ * - `` — "save to archive" flag
+ * - `` — redundant with the event's `` attributes
+ * - `` — rectangle "toggle" UI state flag
+ * - `<_flow-tags_ .../>` — TAK Server routing metadata (server-to-server, not needed on mesh)
+ *
+ * # What gets preserved
+ *
+ * Anything the stripper doesn't explicitly match is passed through untouched. That
+ * includes all of the structured elements that the regular [CoTXmlParser] understands
+ * (contact, __group, status, track, remarks, __chat, chatgrp, link, uid,
+ * __serverdestination) plus any unknown extensions — better to over-preserve than
+ * silently drop something the receiving ATAK actually needs.
+ *
+ * # Whitespace
+ *
+ * All inter-element whitespace and indentation is collapsed. Whitespace inside text
+ * nodes (e.g. `hello world`) is preserved.
+ *
+ * # Not a real XML parser
+ *
+ * This is intentionally string/regex based, not DOM. The input is a small, well-formed
+ * fragment produced by ATAK's serializer, so a full parser is overkill — and we want
+ * this to be dependency-free so it can run on every KMP target without pulling in
+ * xmlutil for a one-off job. If ATAK starts emitting namespaced elements or embedded
+ * CDATA that tangles with these patterns, the stripper will leave them alone rather
+ * than corrupt the output, which is the safer failure mode.
+ */
+internal object CoTDetailStripper {
+
+ /**
+ * Element names whose entire subtree (or self-closing tag) is removed.
+ *
+ * Order matters only for documentation. Each entry is tried against both the
+ * self-closing form `` and the paired form `...`.
+ */
+ private val STRIPPED_ELEMENTS = listOf(
+ // Cosmetic / rendering
+ "color",
+ "strokeColor",
+ "strokeWeight",
+ "fillColor",
+ "labels_on",
+ "usericon",
+ "model",
+ // Geometric
+ "shape",
+ "height",
+ "height_unit",
+ // Resource refs
+ "fileshare",
+ "__video",
+ // Flags / redundant
+ "archive",
+ "precisionlocation",
+ // Rectangle/polyline "toggle" UI flag, and TAK Server routing metadata.
+ // The underscore-prefixed element names are legal XML identifiers ATAK uses
+ // for internal state that receiving meshtastic nodes have no use for.
+ "tog",
+ "_flow-tags_",
+ )
+
+ /**
+ * Pre-compiled regex list: for each stripped element, one pattern that matches
+ * either a self-closing tag or a paired open/close tag (non-greedy content).
+ *
+ * `[^>]*?` inside the open tag tolerates attribute quoting with both single and
+ * double quotes but bails if it encounters a `>` (so it won't accidentally swallow
+ * unrelated content).
+ *
+ * The leading `(?s)` inline flag is the KMP-portable equivalent of
+ * `RegexOption.DOT_MATCHES_ALL` — it lets `.` match newlines so a multi-line
+ * `...` subtree is captured in one pass. `RegexOption.DOT_MATCHES_ALL`
+ * itself is JVM-only and breaks the Kotlin/Native build.
+ */
+ private val STRIPPED_ELEMENT_PATTERNS: List =
+ STRIPPED_ELEMENTS.map { name ->
+ // Escape the name in case it contains regex metacharacters (e.g. __video).
+ val escaped = Regex.escape(name)
+ // Matches:
+ //
+ //
+ // ...content...
+ Regex("""(?s)<$escaped(?:\s[^>]*?)?/>|<$escaped(?:\s[^>]*?)?>.*?$escaped>""")
+ }
+
+ /** Matches whitespace between tags: `> \n <` → `><`. */
+ private val INTER_TAG_WHITESPACE = Regex(""">\s+<""")
+
+ /** Collapse leading / trailing whitespace across the whole fragment. */
+ private val EDGE_WHITESPACE = Regex("""^\s+|\s+$""")
+
+ /**
+ * Strip bloat elements and normalize whitespace on an inner `` fragment.
+ *
+ * The input is assumed to be the concatenated children of `` — i.e., what
+ * [CoTXmlParser.extractDetailInnerXml] returns. It is NOT the full `` or
+ * the `` wrapper itself.
+ *
+ * Returns an empty string if every element was stripped (so callers can treat
+ * "empty" and "nothing worth sending" uniformly).
+ */
+ fun strip(detailInnerXml: String): String {
+ if (detailInnerXml.isEmpty()) return ""
+ var result = detailInnerXml
+ for (pattern in STRIPPED_ELEMENT_PATTERNS) {
+ result = pattern.replace(result, "")
+ }
+ // Collapse whitespace between remaining tags. Preserves whitespace inside
+ // text nodes (e.g. hello world) because that whitespace
+ // isn't bracketed by '>' and '<'.
+ result = INTER_TAG_WHITESPACE.replace(result, "><")
+ result = EDGE_WHITESPACE.replace(result, "")
+ return result
+ }
+}
diff --git a/core/takserver/src/commonMain/kotlin/org/meshtastic/core/takserver/CoTXml.kt b/core/takserver/src/commonMain/kotlin/org/meshtastic/core/takserver/CoTXml.kt
index cd616417d..662f430fa 100644
--- a/core/takserver/src/commonMain/kotlin/org/meshtastic/core/takserver/CoTXml.kt
+++ b/core/takserver/src/commonMain/kotlin/org/meshtastic/core/takserver/CoTXml.kt
@@ -20,10 +20,19 @@ package org.meshtastic.core.takserver
import kotlin.time.Instant
+/**
+ * Serialize this [CoTMessage] to a single `` XML element suitable for the CoT streaming
+ * TCP protocol used by ATAK / iTAK / WinTAK clients.
+ *
+ * **Important:** the output must NOT include an `` declaration. The CoT stream
+ * protocol is a continuous sequence of `` elements concatenated together; an XML
+ * declaration is only legal at the very start of a document and ATAK will drop the connection
+ * as malformed the moment it sees a second declaration mid-stream.
+ */
fun CoTMessage.toXml(): String {
val sb = StringBuilder()
sb.append(
- "",
+ "",
)
contact?.let {
@@ -63,4 +72,19 @@ fun CoTMessage.toXml(): String {
return sb.toString()
}
-private fun Instant.toXmlString(): String = this.toString()
+/**
+ * Format this [Instant] for CoT XML `time` / `start` / `stale` attributes.
+ *
+ * Always emits millisecond precision (`YYYY-MM-DDThh:mm:ss.SSSZ`). kotlinx-datetime's default
+ * [Instant.toString] can emit up to nanosecond precision; some TAK implementations choke on
+ * anything beyond milliseconds, so we truncate to ms and always include the millisecond field
+ * even when it would otherwise be zero.
+ */
+private fun Instant.toXmlString(): String {
+ val millis = this.toEpochMilliseconds()
+ val truncated = Instant.fromEpochMilliseconds(millis)
+ val base = truncated.toString()
+ // kotlinx-datetime omits the fractional part when it's zero; pad it ourselves so the
+ // CoT timestamp format is stable at ms precision.
+ return if (base.contains('.')) base else base.removeSuffix("Z") + ".000Z"
+}
diff --git a/core/takserver/src/commonMain/kotlin/org/meshtastic/core/takserver/CoTXmlParser.kt b/core/takserver/src/commonMain/kotlin/org/meshtastic/core/takserver/CoTXmlParser.kt
index 41d5f78dd..04c83b693 100644
--- a/core/takserver/src/commonMain/kotlin/org/meshtastic/core/takserver/CoTXmlParser.kt
+++ b/core/takserver/src/commonMain/kotlin/org/meshtastic/core/takserver/CoTXmlParser.kt
@@ -59,9 +59,38 @@ class CoTXmlParser(private val xml: String) {
track = detail?.track?.let { CoTTrack(speed = it.speed, course = it.course) },
chat = buildChat(detail),
remarks = buildRemarks(detail),
+ // Stripped version used as the raw_detail protobuf payload: drops bloat
+ // elements (colors, icons, archives, shapes, etc.) so unmapped CoT types
+ // have any chance of fitting in a LoRa mesh packet. See [CoTDetailStripper].
+ parsedDetailXml = extractDetailInnerXml(xml)?.let(CoTDetailStripper::strip),
+ // Verbatim original event XML kept for diagnostic logging only — never
+ // goes on the wire.
+ sourceEventXml = xml,
)
}
+ /**
+ * Extract the exact content between `` and `` from the original XML
+ * string. Used as the `raw_detail` fallback payload when we can't map the CoT type to
+ * a structured [org.meshtastic.proto.TAKPacketV2] payload. Preserves any extension
+ * elements the xmlutil parser discarded as "unknown children".
+ *
+ * Returns null for self-closed `` or when no detail element is present.
+ */
+ private fun extractDetailInnerXml(xml: String): String? {
+ // Match `` (not ``) through its matching close tag.
+ val openIdx = xml.indexOf("', openIdx)
+ if (openEnd < 0) return null
+ // Self-closed tag like `` has no content.
+ if (xml[openEnd - 1] == '/') return null
+ val closeIdx = xml.indexOf("", openEnd)
+ if (closeIdx < 0) return null
+ val inner = xml.substring(openEnd + 1, closeIdx).trim()
+ return inner.ifEmpty { null }
+ }
+
private fun buildContact(detail: CoTDetailXml?): CoTContact? = detail?.contact?.let {
if (it.callsign.isNotEmpty() || it.endpoint != null || it.phone != null) {
CoTContact(callsign = it.callsign, endpoint = it.endpoint, phone = it.phone)
diff --git a/core/takserver/src/commonMain/kotlin/org/meshtastic/core/takserver/RouteDataPackageGenerator.kt b/core/takserver/src/commonMain/kotlin/org/meshtastic/core/takserver/RouteDataPackageGenerator.kt
new file mode 100644
index 000000000..23913f85d
--- /dev/null
+++ b/core/takserver/src/commonMain/kotlin/org/meshtastic/core/takserver/RouteDataPackageGenerator.kt
@@ -0,0 +1,125 @@
+/*
+ * 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
+
+/**
+ * Converts route CoT XML (b-m-r) into ATAK-importable KML data packages.
+ *
+ * ATAK silently ignores route CoT events received over TCP streaming
+ * connections — it only accepts routes from KML/GPX file import, TAK Server
+ * mission sync, or data packages auto-imported from the monitored directory
+ * `/sdcard/atak/tools/datapackage/`. This generator bridges the gap by
+ * extracting waypoints from the SDK-reconstructed route XML and packaging
+ * them as a KML LineString inside a MissionPackageManifest v2 zip.
+ */
+object RouteDataPackageGenerator {
+
+ private val EVENT_UID_RE = Regex("""]*\buid="([^"]*)"""")
+ private val CONTACT_CALLSIGN_RE = Regex("""]*\bcallsign="([^"]*)"""")
+ private val LINK_POINT_RE = Regex("""]*\bpoint="([^"]*)"[^>]*/>""")
+
+ data class RouteKmlResult(
+ val kml: String,
+ val routeUid: String,
+ val routeName: String,
+ )
+
+ /**
+ * Extract waypoints from route CoT XML and generate a KML LineString.
+ * Returns null if fewer than 2 waypoints are found.
+ */
+ fun generateKml(routeXml: String): RouteKmlResult? {
+ val uid = EVENT_UID_RE.find(routeXml)?.groupValues?.getOrNull(1) ?: return null
+ val name = CONTACT_CALLSIGN_RE.find(routeXml)?.groupValues?.getOrNull(1) ?: "Mesh Route"
+
+ // Extract all waypoint coordinates from elements
+ val waypoints = LINK_POINT_RE.findAll(routeXml).mapNotNull { match ->
+ val point = match.groupValues[1] // "lat,lon,hae" or "lat,lon"
+ val parts = point.split(",").map { it.trim() }
+ if (parts.size >= 2) {
+ val lat = parts[0]
+ val lon = parts[1]
+ val hae = parts.getOrElse(2) { "0" }
+ // KML coordinate order is lon,lat,hae (opposite of CoT's lat,lon,hae)
+ "$lon,$lat,$hae"
+ } else null
+ }.toList()
+
+ if (waypoints.size < 2) return null
+
+ val kml = buildString {
+ appendLine("""""")
+ appendLine("""""")
+ appendLine(" ")
+ appendLine(" ${name.xmlEscaped()}")
+ appendLine(" ")
+ appendLine(" ${name.xmlEscaped()}")
+ appendLine(" ")
+ appendLine(" ")
+ appendLine(" ")
+ for (coord in waypoints) {
+ appendLine(" $coord")
+ }
+ appendLine(" ")
+ appendLine(" ")
+ appendLine(" ")
+ appendLine(" ")
+ append("")
+ }
+
+ return RouteKmlResult(kml = kml, routeUid = uid, routeName = name)
+ }
+
+ /**
+ * Generate a complete ATAK data package (zip) containing the route as KML.
+ * Returns (fileName, zipBytes) or null if the route XML can't be parsed.
+ */
+ fun generateDataPackage(routeXml: String): Pair? {
+ val result = generateKml(routeXml) ?: return null
+ val kmlFileName = "${result.routeUid}.kml"
+ val zipFileName = "${result.routeUid}.zip"
+
+ val manifest = buildString {
+ appendLine("""""")
+ appendLine(" ")
+ appendLine(""" """)
+ appendLine(""" """)
+ appendLine(""" """)
+ appendLine(" ")
+ appendLine(" ")
+ appendLine(""" """)
+ appendLine(" ")
+ append("")
+ }
+
+ val zipBytes = ZipArchiver.createZip(
+ mapOf(
+ kmlFileName to result.kml.encodeToByteArray(),
+ "manifest.xml" to manifest.encodeToByteArray(),
+ ),
+ )
+
+ return zipFileName to zipBytes
+ }
+
+ private fun String.xmlEscaped(): String = replace("&", "&")
+ .replace("<", "<")
+ .replace(">", ">")
+ .replace("\"", """)
+}
diff --git a/core/takserver/src/commonMain/kotlin/org/meshtastic/core/takserver/TAKClientConnection.kt b/core/takserver/src/commonMain/kotlin/org/meshtastic/core/takserver/TAKClientConnection.kt
deleted file mode 100644
index 9a24d6721..000000000
--- a/core/takserver/src/commonMain/kotlin/org/meshtastic/core/takserver/TAKClientConnection.kt
+++ /dev/null
@@ -1,228 +0,0 @@
-/*
- * 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 .
- */
-@file:Suppress("TooManyFunctions", "TooGenericExceptionCaught")
-
-package org.meshtastic.core.takserver
-
-import co.touchlab.kermit.Logger
-import io.ktor.network.sockets.Socket
-import io.ktor.network.sockets.isClosed
-import io.ktor.network.sockets.openReadChannel
-import io.ktor.network.sockets.openWriteChannel
-import io.ktor.utils.io.ByteReadChannel
-import io.ktor.utils.io.ByteWriteChannel
-import io.ktor.utils.io.readAvailable
-import io.ktor.utils.io.writeStringUtf8
-import kotlinx.coroutines.CancellationException
-import kotlinx.coroutines.CoroutineScope
-import kotlinx.coroutines.launch
-import kotlinx.coroutines.sync.Mutex
-import kotlinx.coroutines.sync.withLock
-import kotlin.concurrent.Volatile
-import kotlin.random.Random
-import kotlin.time.Clock
-import kotlin.time.Duration.Companion.milliseconds
-import kotlin.time.Instant
-import kotlinx.coroutines.isActive as coroutineIsActive
-
-class TAKClientConnection(
- private val socket: Socket,
- val clientInfo: TAKClientInfo,
- private val onEvent: (TAKConnectionEvent) -> Unit,
- private val scope: CoroutineScope,
-) {
- private var currentClientInfo = clientInfo
- private val frameBuffer = CoTXmlFrameBuffer()
-
- private val readChannel: ByteReadChannel = socket.openReadChannel()
- private val writeChannel: ByteWriteChannel = socket.openWriteChannel(autoFlush = true)
- private val writeMutex = Mutex()
-
- /** Guards against emitting [TAKConnectionEvent.Disconnected] more than once. */
- @Volatile private var disconnectedEmitted = false
-
- fun start() {
- onEvent(TAKConnectionEvent.Connected(currentClientInfo))
- sendProtocolSupport()
-
- scope.launch { readLoop() }
-
- scope.launch { keepaliveLoop() }
- }
-
- private fun sendProtocolSupport() {
- val serverUid = "Meshtastic-TAK-Server-${Random.nextInt().toString(TAK_HEX_RADIX)}"
- val now = Clock.System.now()
- val stale = now + TAK_KEEPALIVE_INTERVAL_MS.milliseconds
- val detail =
- """
-
-
-
- """
- .trimIndent()
- sendXml(buildEventXml(uid = serverUid, type = "t-x-takp-v", now = now, stale = stale, detail = detail))
- }
-
- private suspend fun readLoop() {
- try {
- val buffer = ByteArray(TAK_XML_READ_BUFFER_SIZE)
- while (scope.coroutineIsActive && !socket.isClosed) {
- // Suspend until data is available — no polling delay needed
- readChannel.awaitContent()
- val bytesRead = readChannel.readAvailable(buffer)
- if (bytesRead > 0) {
- processReceivedData(buffer.copyOfRange(0, bytesRead))
- } else if (bytesRead == -1) {
- break // EOF
- }
- }
- } catch (e: CancellationException) {
- throw e
- } catch (e: Exception) {
- Logger.w(e) { "TAK client read error: ${currentClientInfo.id}" }
- emitDisconnected(TAKConnectionEvent.Error(e))
- return
- }
- emitDisconnected(TAKConnectionEvent.Disconnected)
- }
-
- private suspend fun keepaliveLoop() {
- while (scope.coroutineIsActive && !socket.isClosed) {
- kotlinx.coroutines.delay(TAK_KEEPALIVE_INTERVAL_MS)
- sendKeepalive()
- }
- }
-
- private fun sendKeepalive() {
- val now = Clock.System.now()
- val stale = now + TAK_KEEPALIVE_INTERVAL_MS.milliseconds
- sendXml(buildEventXml(uid = "takPong", type = "t-x-d-d", now = now, stale = stale, detail = ""))
- }
-
- private fun processReceivedData(newData: ByteArray) {
- // frameBuffer.append returns List — pass directly without re-encoding
- frameBuffer.append(newData).forEach { xmlString -> parseAndHandleMessage(xmlString) }
- }
-
- private fun parseAndHandleMessage(xmlString: String) {
- // Parse first, then filter on the structured type field to avoid false positives
- val parser = CoTXmlParser(xmlString)
- val result = parser.parse()
-
- result.onSuccess { cotMessage ->
- when {
- cotMessage.type.startsWith("t-x-takp") -> {
- handleProtocolControl(cotMessage.type, xmlString)
- return
- }
- cotMessage.type == "t-x-c-t" || cotMessage.uid == "ping" -> {
- // Keepalive / ping — discard silently
- return
- }
- else -> {
- cotMessage.contact?.let { contact ->
- val updatedClientInfo =
- currentClientInfo.copy(
- callsign = currentClientInfo.callsign ?: contact.callsign,
- uid = currentClientInfo.uid ?: cotMessage.uid,
- )
- if (updatedClientInfo != currentClientInfo) {
- currentClientInfo = updatedClientInfo
- onEvent(TAKConnectionEvent.ClientInfoUpdated(updatedClientInfo))
- }
- }
-
- onEvent(TAKConnectionEvent.Message(cotMessage))
- }
- }
- }
- }
-
- private fun handleProtocolControl(type: String, xmlString: String) {
- if (type == "t-x-takp-q") {
- sendProtocolResponse()
- } else {
- Logger.d { "Unhandled protocol control type: $type (raw=$xmlString)" }
- }
- }
-
- private fun sendProtocolResponse() {
- val serverUid = "Meshtastic-TAK-Server-${Random.nextInt().toString(TAK_HEX_RADIX)}"
- val now = Clock.System.now()
- val stale = now + TAK_KEEPALIVE_INTERVAL_MS.milliseconds
- val detail =
- """
-
-
-
- """
- .trimIndent()
- sendXml(buildEventXml(uid = serverUid, type = "t-x-takp-r", now = now, stale = stale, detail = detail))
- }
-
- fun send(cotMessage: CoTMessage) {
- val xml = cotMessage.toXml()
- sendXml(xml)
- }
-
- private fun buildEventXml(uid: String, type: String, now: Instant, stale: Instant, detail: String): String {
- val detailContent = if (detail.isBlank()) "" else "$detail"
- val point = """"""
- return """""" +
- point +
- detailContent +
- ""
- }
-
- private fun sendXml(xml: String) {
- scope.launch {
- try {
- writeMutex.withLock {
- if (!socket.isClosed) {
- writeChannel.writeStringUtf8(xml)
- }
- }
- } catch (e: CancellationException) {
- throw e
- } catch (e: Exception) {
- Logger.w(e) { "TAK client send error: ${currentClientInfo.id}" }
- }
- }
- }
-
- fun close() {
- frameBuffer.clear()
- try {
- socket.close()
- } catch (e: Exception) {
- Logger.w(e) { "Error closing TAK client socket: ${currentClientInfo.id}" }
- }
- emitDisconnected(TAKConnectionEvent.Disconnected)
- }
-
- /**
- * Emits [event] (expected to be [TAKConnectionEvent.Disconnected] or [TAKConnectionEvent.Error]) at most once
- * across all code paths.
- */
- private fun emitDisconnected(event: TAKConnectionEvent) {
- if (!disconnectedEmitted) {
- disconnectedEmitted = true
- onEvent(event)
- }
- }
-}
diff --git a/core/takserver/src/commonMain/kotlin/org/meshtastic/core/takserver/TAKDataPackageGenerator.kt b/core/takserver/src/commonMain/kotlin/org/meshtastic/core/takserver/TAKDataPackageGenerator.kt
index e9a7ae668..b0e2645eb 100644
--- a/core/takserver/src/commonMain/kotlin/org/meshtastic/core/takserver/TAKDataPackageGenerator.kt
+++ b/core/takserver/src/commonMain/kotlin/org/meshtastic/core/takserver/TAKDataPackageGenerator.kt
@@ -21,17 +21,28 @@ import nl.adaptivity.xmlutil.serialization.XML
import kotlin.uuid.Uuid
/**
- * Generates TAK data packages (.zip) compatible with ATAK/iTAK import.
+ * Generates TAK data packages (.zip) compatible with ATAK/iTAK/WinTAK import.
*
* The data package follows the MissionPackageManifest v2 format:
* ```
* Meshtastic_TAK_Server.zip
* ├── meshtastic-server.pref (ATAK connection preferences)
+ * ├── truststore.p12 (server cert — matches iOS "truststore.p12")
+ * ├── client.p12 (client identity for mTLS)
* └── manifest.xml (MissionPackageManifest v2)
* ```
+ *
+ * The bundled certificates / password match Meshtastic-Apple so a single
+ * exported package works on both ATAK (Android) and iTAK (iOS) without
+ * reconfiguration.
+ *
+ * Override [bundledCertBytesProvider] in tests to avoid touching the real classpath
+ * resources. In production the default reads from [TakCertLoader].
*/
object TAKDataPackageGenerator {
private const val PREF_FILE_NAME = "meshtastic-server.pref"
+ private const val TRUSTSTORE_FILE_NAME = "truststore.p12"
+ private const val CLIENT_P12_FILE_NAME = "client.p12"
private const val PACKAGE_NAME = "Meshtastic_TAK_Server"
private val xmlSerializer = XML {
@@ -39,24 +50,38 @@ object TAKDataPackageGenerator {
indentString = " "
}
+ /**
+ * Platform-specific hook for reading the bundled TLS certificate bytes. Default
+ * implementation lives in `jvmAndroidMain` and reads them from classpath resources
+ * via [TakCertLoader].
+ */
+ var bundledCertBytesProvider: BundledCertBytesProvider = DefaultBundledCertBytesProvider
+
/**
* Generate a complete TAK data package zip.
*
+ * @param useTls when true, package includes `truststore.p12` + `client.p12` and
+ * the pref file uses `ssl`; when false, package is TCP-only (legacy).
+ *
* @return zip file contents as a [ByteArray]
*/
fun generateDataPackage(
serverHost: String = "127.0.0.1",
port: Int = DEFAULT_TAK_PORT,
+ useTls: Boolean = true,
description: String = "Meshtastic TAK Server",
): ByteArray {
- val prefContent = generateConfigPref(serverHost, port, description)
- val manifestContent = generateManifest(uid = Uuid.random().toString(), description = description)
+ val prefContent = generateConfigPref(serverHost, port, useTls, description)
+ val manifestContent = generateManifest(uid = Uuid.random().toString(), description = description, useTls = useTls)
- val entries =
- mapOf(
- PREF_FILE_NAME to prefContent.encodeToByteArray(),
- "manifest.xml" to manifestContent.encodeToByteArray(),
- )
+ val entries = mutableMapOf()
+ entries[PREF_FILE_NAME] = prefContent.encodeToByteArray()
+ entries["manifest.xml"] = manifestContent.encodeToByteArray()
+
+ if (useTls) {
+ bundledCertBytesProvider.serverP12Bytes()?.let { entries[TRUSTSTORE_FILE_NAME] = it }
+ bundledCertBytesProvider.clientP12Bytes()?.let { entries[CLIENT_P12_FILE_NAME] = it }
+ }
return ZipArchiver.createZip(entries)
}
@@ -64,31 +89,89 @@ object TAKDataPackageGenerator {
internal fun generateConfigPref(
serverHost: String = "127.0.0.1",
port: Int = DEFAULT_TAK_PORT,
+ useTls: Boolean = true,
description: String = "Meshtastic TAK Server",
): String {
- val prefs =
+ val protocolType = if (useTls) "ssl" else "tcp"
+ val prefs = if (useTls) {
+ // TLS / mTLS mode — matches the iOS data package format exactly.
TAKPreferencesXml(
- preferences =
- listOf(
+ preferences = listOf(
TAKPreferenceXml(
version = "1",
name = "cot_streams",
- entries =
- listOf(
+ entries = listOf(
TAKEntryXml("count", "class java.lang.Integer", "1"),
TAKEntryXml("description0", "class java.lang.String", description),
TAKEntryXml("enabled0", "class java.lang.Boolean", "true"),
- TAKEntryXml("connectString0", "class java.lang.String", "$serverHost:$port:tcp"),
+ TAKEntryXml(
+ "connectString0",
+ "class java.lang.String",
+ "$serverHost:$port:$protocolType",
+ ),
),
),
TAKPreferenceXml(
version = "1",
name = "com.atakmap.app_preferences",
- entries =
- listOf(TAKEntryXml("displayServerConnectionWidget", "class java.lang.Boolean", "true")),
+ entries = listOf(
+ TAKEntryXml(
+ "displayServerConnectionWidget",
+ "class java.lang.Boolean",
+ "true",
+ ),
+ TAKEntryXml(
+ "caLocation",
+ "class java.lang.String",
+ "cert/$TRUSTSTORE_FILE_NAME",
+ ),
+ TAKEntryXml(
+ "caPassword",
+ "class java.lang.String",
+ TAK_BUNDLED_CERT_PASSWORD,
+ ),
+ TAKEntryXml(
+ "certificateLocation",
+ "class java.lang.String",
+ "cert/$CLIENT_P12_FILE_NAME",
+ ),
+ TAKEntryXml(
+ "clientPassword",
+ "class java.lang.String",
+ TAK_BUNDLED_CERT_PASSWORD,
+ ),
+ ),
),
),
)
+ } else {
+ // Legacy plain-TCP mode (not used in production, kept for tests / fallback)
+ TAKPreferencesXml(
+ preferences = listOf(
+ TAKPreferenceXml(
+ version = "1",
+ name = "cot_streams",
+ entries = listOf(
+ TAKEntryXml("count", "class java.lang.Integer", "1"),
+ TAKEntryXml("description0", "class java.lang.String", description),
+ TAKEntryXml("enabled0", "class java.lang.Boolean", "true"),
+ TAKEntryXml(
+ "connectString0",
+ "class java.lang.String",
+ "$serverHost:$port:$protocolType",
+ ),
+ ),
+ ),
+ TAKPreferenceXml(
+ version = "1",
+ name = "com.atakmap.app_preferences",
+ entries = listOf(
+ TAKEntryXml("displayServerConnectionWidget", "class java.lang.Boolean", "true"),
+ ),
+ ),
+ ),
+ )
+ }
return xmlSerializer
.encodeToString(TAKPreferencesXml.serializer(), prefs)
@@ -98,7 +181,11 @@ object TAKDataPackageGenerator {
)
}
- internal fun generateManifest(uid: String, description: String = "Meshtastic TAK Server"): String = buildString {
+ internal fun generateManifest(
+ uid: String,
+ description: String = "Meshtastic TAK Server",
+ useTls: Boolean = true,
+ ): String = buildString {
appendLine("""""")
appendLine(" ")
appendLine(""" """)
@@ -107,7 +194,30 @@ object TAKDataPackageGenerator {
appendLine(" ")
appendLine(" ")
appendLine(""" """)
+ if (useTls) {
+ appendLine(""" """)
+ appendLine(""" """)
+ }
appendLine(" ")
append("")
}
}
+
+/**
+ * Supplies the bundled server / client PKCS#12 bytes for [TAKDataPackageGenerator].
+ * Platform implementations live in `jvmAndroidMain`.
+ */
+interface BundledCertBytesProvider {
+ fun serverP12Bytes(): ByteArray?
+ fun clientP12Bytes(): ByteArray?
+}
+
+/**
+ * Default provider that returns `null` on platforms without a real implementation.
+ * Overridden at startup on JVM / Android by pointing
+ * [TAKDataPackageGenerator.bundledCertBytesProvider] at [TakCertLoader].
+ */
+private object DefaultBundledCertBytesProvider : BundledCertBytesProvider {
+ override fun serverP12Bytes(): ByteArray? = null
+ override fun clientP12Bytes(): ByteArray? = null
+}
diff --git a/core/takserver/src/commonMain/kotlin/org/meshtastic/core/takserver/TAKDefaults.kt b/core/takserver/src/commonMain/kotlin/org/meshtastic/core/takserver/TAKDefaults.kt
index eef798bf9..e899c1dcd 100644
--- a/core/takserver/src/commonMain/kotlin/org/meshtastic/core/takserver/TAKDefaults.kt
+++ b/core/takserver/src/commonMain/kotlin/org/meshtastic/core/takserver/TAKDefaults.kt
@@ -20,20 +20,59 @@ import org.meshtastic.proto.MemberRole
import org.meshtastic.proto.Team
import org.meshtastic.proto.User
-internal const val DEFAULT_TAK_PORT = 8087
+// Port 8089 is the standard TAK TLS port. Matches the iOS implementation so that
+// a single exported data package (containing truststore.p12 + client.p12) works for
+// both Meshtastic-iOS and Meshtastic-Android without reconfiguration in ATAK/iTAK.
+internal const val DEFAULT_TAK_PORT = 8089
internal const val DEFAULT_TAK_ENDPOINT = "0.0.0.0:4242:tcp"
+
+// Bundled certificate password — matches iOS (`"meshtastic"`). Used for the
+// server.p12 / client.p12 PKCS#12 files shipped under `tak_certs/` on the classpath.
+internal const val TAK_BUNDLED_CERT_PASSWORD = "meshtastic"
internal const val DEFAULT_TAK_TEAM_NAME = "Cyan"
internal const val DEFAULT_TAK_ROLE_NAME = "Team Member"
internal const val DEFAULT_TAK_BATTERY = 100
internal const val DEFAULT_TAK_STALE_MINUTES = 10
internal const val TAK_HEX_RADIX = 16
internal const val TAK_XML_READ_BUFFER_SIZE = 4_096
-internal const val TAK_KEEPALIVE_INTERVAL_MS = 30_000L
+// ATAK's native commo library declares the connection dead after 25 seconds of
+// silence (RX_TIMEOUT_SECONDS in streamingsocketmanagement.cpp) and starts
+// sending t-x-c-t pings at 15 seconds (RX_STALE_SECONDS). Send keepalives
+// well under the 15-second threshold so ATAK never enters its stale phase.
+internal const val TAK_KEEPALIVE_INTERVAL_MS = 10_000L
internal const val TAK_ACCEPT_LOOP_DELAY_MS = 100L
internal const val TAK_COORDINATE_SCALE = 1e7
internal const val TAK_UNKNOWN_POINT_VALUE = 9_999_999.0
internal const val TAK_DIRECT_MESSAGE_PARTS_MIN = 3
+/**
+ * Hard cap on the size of a TAK v2 wire payload we will hand to the mesh layer.
+ *
+ * `CommandSenderImpl.sendData` checks `Data.ADAPTER.isWithinSizeLimit(data,
+ * Constants.DATA_PAYLOAD_LEN.value)` where `DATA_PAYLOAD_LEN = 233`. That 233 applies
+ * to the ENTIRE encoded `Data` proto (portnum tag + payload length-delim + reply_id +
+ * emoji), not just the `payload` bytes. The wrapper for a port-78 (`ATAK_PLUGIN_V2`)
+ * message costs roughly:
+ * * portnum varint + tag: 2 bytes
+ * * payload length prefix + tag: 2–3 bytes (depending on size)
+ * * reply_id / emoji: 0 bytes when unset
+ *
+ * That leaves ~228 bytes for the `payload` field alone. We use 225 to keep a small
+ * margin for future proto evolution. Anything larger than this is dropped in
+ * [TAKMeshIntegration.sendCoTToMesh] rather than being handed to the mesh layer,
+ * because the mesh layer would throw and the outer `SharedFlow` collector would eat
+ * the crash on every subsequent emission.
+ */
+internal const val MAX_TAK_WIRE_PAYLOAD_BYTES = 225
+
+/**
+ * Max characters of raw CoT XML we'll write to logcat when dropping an oversized
+ * packet. ATAK can emit events several KB long; logging the whole thing floods
+ * logcat and buries the signal. 1024 chars is enough to see the event type, point,
+ * and the first few detail elements.
+ */
+internal const val TAK_LOG_XML_MAX_CHARS = 1_024
+
internal fun Team?.toTakTeamName(): String = when (this) {
null,
Team.Unspecifed_Color,
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 4f3001427..ac231efbd 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
@@ -24,31 +24,37 @@ import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.map
-import kotlinx.coroutines.flow.mapLatest
+
import kotlinx.coroutines.launch
import okio.ByteString.Companion.toByteString
import org.meshtastic.core.model.DataPacket
import org.meshtastic.core.repository.CommandSender
import org.meshtastic.core.repository.MeshConfigHandler
-import org.meshtastic.core.repository.NodeRepository
+
import org.meshtastic.core.repository.ServiceRepository
-import org.meshtastic.core.takserver.TAKPacketConversion.toCoTMessage
-import org.meshtastic.core.takserver.TAKPacketConversion.toTAKPacket
-import org.meshtastic.core.takserver.fountain.CoTHandler
+import org.meshtastic.core.takserver.TAKPacketV2Conversion.toCoTMessage
+import org.meshtastic.core.takserver.TAKPacketV2Conversion.toTAKPacketV2
import org.meshtastic.proto.MemberRole
import org.meshtastic.proto.MeshPacket
import org.meshtastic.proto.PortNum
import org.meshtastic.proto.TAKPacket
import org.meshtastic.proto.Team
import kotlin.concurrent.Volatile
+import kotlin.time.Clock
+import kotlin.time.Duration.Companion.minutes
+/**
+ * Bidirectional bridge between the local TAK server and the Meshtastic mesh network.
+ *
+ * V2 protocol only: All traffic uses port 78 (ATAK_PLUGIN_V2).
+ * Legacy V1 port 72 is still received for backward compatibility but will be removed.
+ */
class TAKMeshIntegration(
private val takServerManager: TAKServerManager,
private val commandSender: CommandSender,
- private val nodeRepository: NodeRepository,
+
private val serviceRepository: ServiceRepository,
private val meshConfigHandler: MeshConfigHandler,
- private val cotHandler: CoTHandler,
) {
@Volatile private var isRunning = false
private val jobs = mutableListOf()
@@ -61,103 +67,363 @@ class TAKMeshIntegration(
takServerManager.start(scope)
- val newJobs =
- listOf(
- // Forward incoming CoT from TAK clients to mesh
- scope.launch { takServerManager.inboundMessages.collect { cotMessage -> sendCoTToMesh(cotMessage) } },
+ val newJobs = listOf(
+ // Forward incoming CoT from TAK clients to mesh
+ scope.launch {
+ takServerManager.inboundMessages.collect { (cotMessage, clientInfo) ->
+ // Enrich GeoChat messages with the originating TAK client's
+ // callsign when the message itself lacks one. This only applies
+ // to messages FROM the connected TAK client — mesh-originated
+ // messages flow through handleMeshPacket() instead.
+ val enriched = if (cotMessage.type == "b-t-f" &&
+ cotMessage.contact?.callsign.isNullOrEmpty() &&
+ clientInfo?.callsign != null
+ ) {
+ cotMessage.copy(
+ contact = (cotMessage.contact ?: CoTContact(callsign = ""))
+ .copy(callsign = clientInfo.callsign)
+ )
+ } else {
+ cotMessage
+ }
+ sendCoTToMesh(enriched)
+ }
+ },
- // Forward incoming ATAK packets from mesh to TAK clients
- scope.launch {
- serviceRepository.meshPacketFlow
- .filter {
- it.decoded?.portnum == PortNum.ATAK_PLUGIN || it.decoded?.portnum == PortNum.ATAK_FORWARDER
- }
- .collect { packet -> handleMeshPacket(packet) }
- },
+ // Forward incoming ATAK packets from mesh to TAK clients
+ scope.launch {
+ serviceRepository.meshPacketFlow
+ .filter {
+ it.decoded?.portnum == PortNum.ATAK_PLUGIN_V2 ||
+ it.decoded?.portnum == PortNum.ATAK_PLUGIN
+ }
+ .collect { packet -> handleMeshPacket(packet) }
+ },
- // Broadcast node positions to TAK clients.
- // mapLatest cancels any in-flight broadcast loop when a new node-map emission arrives,
- // preventing N×M fan-out from stacking up across rapid consecutive updates.
- scope.launch {
- nodeRepository.nodeDBbyNum
- .mapLatest { nodes ->
- nodes.forEach { (_, node) ->
- takServerManager.broadcastNode(
- node = node,
- team = currentTeam.toTakTeamName(),
- role = currentRole.toTakRoleName(),
- )
- }
- }
- .collect {}
- },
- scope.launch {
- meshConfigHandler.moduleConfig
- .map { it.tak }
- .distinctUntilChanged()
- .collect { takConfig ->
- currentTeam = takConfig?.team ?: Team.Unspecifed_Color
- currentRole = takConfig?.role ?: MemberRole.Unspecifed
- }
- },
- )
+ // Track TAK config changes
+ scope.launch {
+ meshConfigHandler.moduleConfig
+ .map { it.tak }
+ .distinctUntilChanged()
+ .collect { takConfig ->
+ currentTeam = takConfig?.team ?: Team.Unspecifed_Color
+ currentRole = takConfig?.role ?: MemberRole.Unspecifed
+ }
+ },
+ )
jobs.addAll(newJobs)
-
- Logger.i { "TAK Mesh Integration started" }
+ Logger.i { "TAK Mesh Integration started (v2 protocol)" }
}
fun stop() {
if (!isRunning) return
isRunning = false
- // Cancel all tracked jobs and clear the list
- val toCancel: List
- toCancel = jobs.toList()
+ val toCancel = jobs.toList()
jobs.clear()
toCancel.forEach(Job::cancel)
takServerManager.stop()
Logger.i { "TAK Mesh Integration stopped" }
}
+ // ── Send: TAK client → mesh ─────────────────────────────────────────────
+
private suspend fun sendCoTToMesh(cotMessage: CoTMessage) {
- val takPacket = cotMessage.toTAKPacket()
- if (takPacket == null) {
- cotHandler.sendGenericCoT(cotMessage)
- return
+ // Prefer the sourceEventXml for shape/marker/route types — the SDK's
+ // CotXmlParser extracts compact typed payloads (DrawnShape, Marker,
+ // Route, etc.) that compress far better than raw_detail encoding.
+ // For PLI and GeoChat, use the enriched CoTMessage (which may have
+ // had callsign/contact injected by the upstream enrichment step).
+ val rawXml = cotMessage.sourceEventXml ?: cotMessage.toXml()
+ // Extend stale for static objects (routes, shapes, markers) that may
+ // arrive over LoRa mesh past their original TTL. iTAK uses 2-min stale
+ // for routes; ATAK uses 24h. 5 min ensures it survives mesh delivery.
+ val freshXml = ensureMinimumStaleForMesh(rawXml)
+ // Strip non-essential elements before compression to save wire bytes
+ val xml = stripNonEssentialElements(freshXml)
+
+ Logger.d { "RAW CoT OUT (mesh, ${cotMessage.type}): $rawXml" }
+
+ // Route through the SDK parser/compressor which handles all typed
+ // payloads (DrawnShape, Marker, Route, Aircraft, etc.) with compact
+ // proto fields instead of raw_detail XML. Falls back to the app's
+ // own conversion only if the SDK path fails.
+ //
+ // compressWithRemarksFallback preserves text when the
+ // compressed packet fits under the LoRa MTU, and strips remarks
+ // automatically if needed to fit. Returns null if even without
+ // remarks the packet exceeds the limit.
+ val wirePayload: ByteArray = try {
+ val sdkParser = org.meshtastic.tak.CotXmlParser()
+ val sdkData = sdkParser.parse(xml)
+ val compressor = org.meshtastic.tak.TakCompressor()
+ compressor.compressWithRemarksFallback(sdkData, MAX_TAK_WIRE_PAYLOAD_BYTES) ?: run {
+ Logger.w {
+ buildString {
+ append("Dropping oversized TAK packet: type=${cotMessage.type} max=$MAX_TAK_WIRE_PAYLOAD_BYTES")
+ cotMessage.sourceEventXml?.let { src ->
+ append('\n')
+ append("Source CoT event: ")
+ append(if (src.length <= TAK_LOG_XML_MAX_CHARS) src else src.take(TAK_LOG_XML_MAX_CHARS) + "…")
+ }
+ }
+ }
+ return
+ }
+ } catch (e: Throwable) {
+ Logger.d(e) { "SDK parser/compressor failed for ${cotMessage.type}, trying app conversion" }
+ val takPacketV2 = cotMessage.toTAKPacketV2()
+ if (takPacketV2 == null) {
+ Logger.w { "Cannot convert CoT type ${cotMessage.type} to TAKPacketV2, dropping" }
+ return
+ }
+ try {
+ TakV2Compressor.compress(takPacketV2)
+ } catch (e2: Throwable) {
+ Logger.w(e2) { "V2 compression failed for ${cotMessage.type}, using uncompressed wire format" }
+ encodeUncompressed(takPacketV2)
+ }
}
- val payload = TAKPacket.ADAPTER.encode(takPacket)
-
- val dataPacket =
- DataPacket(
+ try {
+ val dataPacket = DataPacket(
to = DataPacket.ID_BROADCAST,
- bytes = payload.toByteString(),
- dataType = PortNum.ATAK_PLUGIN.value,
+ bytes = wirePayload.toByteString(),
+ dataType = PortNum.ATAK_PLUGIN_V2.value,
)
-
- commandSender.sendData(dataPacket)
- Logger.d { "Forwarded CoT to mesh as TAKPacket: ${cotMessage.type}" }
+ commandSender.sendData(dataPacket)
+ Logger.d { "Sent V2 to mesh: ${cotMessage.type} (${wirePayload.size} bytes)" }
+ } catch (e: Throwable) {
+ // Something other than size — radio not connected, queue full, etc.
+ Logger.e(e) { "Failed to send TAKPacketV2 to mesh (${cotMessage.type}, ${wirePayload.size} bytes): ${e.message}" }
+ }
}
+ /**
+ * Wrap a [org.meshtastic.proto.TAKPacketV2] into the uncompressed v2 wire format:
+ * `[0xFF flag byte][raw protobuf]`. Used as a fallback when the zstd native lib
+ * isn't loaded.
+ */
+ private fun encodeUncompressed(takPacketV2: org.meshtastic.proto.TAKPacketV2): ByteArray {
+ val protoBytes = org.meshtastic.proto.TAKPacketV2.ADAPTER.encode(takPacketV2)
+ val out = ByteArray(1 + protoBytes.size)
+ out[0] = TakV2Compressor.DICT_ID_UNCOMPRESSED.toByte()
+ protoBytes.copyInto(out, 1)
+ return out
+ }
+
+ // ── Receive: mesh → TAK client ──────────────────────────────────────────
+
private suspend fun handleMeshPacket(packet: MeshPacket) {
val payload = packet.decoded?.payload ?: return
- if (packet.decoded?.portnum == PortNum.ATAK_FORWARDER) {
- cotHandler.handleIncomingForwarderPacket(payload.toByteArray(), packet.from)
- return
+ when (packet.decoded?.portnum) {
+ PortNum.ATAK_PLUGIN_V2 -> handleV2Packet(payload.toByteArray())
+ PortNum.ATAK_PLUGIN -> handleV1Packet(payload)
+ else -> return
+ }
+ }
+
+ private suspend fun handleV2Packet(wirePayload: ByteArray) {
+ try {
+ // Decompress to CoT XML via the SDK's CotXmlBuilder, which handles
+ // ALL typed payloads (DrawnShape, Marker, Route, etc.) and preserves
+ // shape detail elements (vertices, colors, stroke weight) that the
+ // app's own CoTXmlParser would strip. Forward the SDK-generated XML
+ // directly to TAK clients without re-parsing.
+ val rawXml = TakV2Compressor.decompressToXml(wirePayload)
+ // Strip the XML declaration and collapse whitespace — ATAK's TCP
+ // streaming parser expects bare ... on a single
+ // line, not a formatted XML document with prologue.
+ val xml = rawXml
+ .replace("""""", "")
+ .replace(Regex("""\s*\n\s*"""), "")
+ .trim()
+ 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"""")) {
+ try {
+ val pkg = RouteDataPackageGenerator.generateDataPackage(xml)
+ if (pkg != null) {
+ val (fileName, zipBytes) = pkg
+ AtakFileWriter.writeToImportDir(fileName, zipBytes)
+ } else {
+ Logger.w { "Route data package generation failed — not enough waypoints?" }
+ }
+ } catch (e2: Throwable) {
+ Logger.w(e2) { "Route data package write failed: ${e2.message}" }
+ }
+ }
+ takServerManager.broadcastRawXml(xml)
+ Logger.d { "V2 → TAK clients (raw XML)" }
+ } catch (e: Throwable) {
+ Logger.w(e) { "Failed to handle V2 packet: ${e.message}" }
+ }
+ }
+
+ /** Backward compat for legacy V1 devices. Will be removed. */
+ private suspend fun handleV1Packet(payload: okio.ByteString) {
+ try {
+ val takPacket = TAKPacket.ADAPTER.decode(payload)
+ val cotMessage = convertV1ToCoT(takPacket) ?: return
+ takServerManager.broadcast(cotMessage)
+ Logger.d { "V1 → TAK clients: ${cotMessage.type}" }
+ } catch (e: Throwable) {
+ Logger.w(e) { "Failed to handle V1 packet: ${e.message}" }
+ }
+ }
+
+ private fun convertV1ToCoT(takPacket: TAKPacket): CoTMessage? {
+ val callsign = takPacket.contact?.callsign ?: "UNKNOWN"
+ val senderUid = takPacket.contact?.device_callsign ?: "unknown"
+ val teamName = takPacket.group?.team?.toTakTeamName() ?: DEFAULT_TAK_TEAM_NAME
+ val roleName = takPacket.group?.role?.toTakRoleName() ?: DEFAULT_TAK_ROLE_NAME
+ val battery = takPacket.status?.battery ?: DEFAULT_TAK_BATTERY
+
+ val pli = takPacket.pli
+ if (pli != null) {
+ return CoTMessage.pli(
+ uid = senderUid,
+ callsign = callsign,
+ latitude = pli.latitude_i.toDouble() / TAK_COORDINATE_SCALE,
+ longitude = pli.longitude_i.toDouble() / TAK_COORDINATE_SCALE,
+ altitude = pli.altitude.toDouble(),
+ speed = pli.speed.toDouble(),
+ course = pli.course.toDouble(),
+ team = teamName,
+ role = roleName,
+ battery = battery,
+ staleMinutes = DEFAULT_TAK_STALE_MINUTES,
+ )
}
- val takPacket =
- try {
- TAKPacket.ADAPTER.decode(payload)
- } catch (e: Exception) {
- Logger.w(e) { "Failed to decode TAKPacket from mesh" }
- return
+ val chat = takPacket.chat
+ if (chat != null) {
+ val timeNow = Clock.System.now()
+ return CoTMessage(
+ uid = "GeoChat.$senderUid.All Chat Rooms",
+ type = "b-t-f",
+ how = "h-g-i-g-o",
+ time = timeNow,
+ start = timeNow,
+ stale = timeNow + DEFAULT_TAK_STALE_MINUTES.minutes,
+ latitude = 0.0,
+ longitude = 0.0,
+ contact = CoTContact(callsign = callsign, endpoint = DEFAULT_TAK_ENDPOINT),
+ group = CoTGroup(name = teamName, role = roleName),
+ status = CoTStatus(battery = battery),
+ chat = CoTChat(
+ chatroom = chat.to ?: "All Chat Rooms",
+ senderCallsign = callsign,
+ message = chat.message,
+ ),
+ )
+ }
+
+ return null
+ }
+
+ companion object {
+ /**
+ * Minimum stale TTL (5 min) for static CoT types sent over mesh.
+ * iTAK uses 2-min stale for routes/shapes; over LoRa mesh with
+ * multi-hop relay, these arrive past stale and ATAK discards them.
+ * PLI and GeoChat are left untouched — their stale is meaningful.
+ */
+ private val MIN_MESH_STALE_TTL = 15.minutes
+ private val STATIC_COT_PREFIXES = listOf("b-m-r", "u-d-", "b-m-p-")
+ private val EVENT_TYPE_RE = Regex("""]*\btype="([^"]*)"""")
+ private val STALE_ATTR_RE = Regex("""\bstale="([^"]*)"""")
+
+ fun ensureMinimumStaleForMesh(xml: String): String {
+ val type = EVENT_TYPE_RE.find(xml)?.groupValues?.getOrNull(1) ?: return xml
+ if (STATIC_COT_PREFIXES.none { type.startsWith(it) }) return xml
+ val staleMatch = STALE_ATTR_RE.find(xml) ?: return xml
+ val staleStr = staleMatch.groupValues[1]
+ val staleInstant = try {
+ kotlin.time.Instant.parse(staleStr)
+ } catch (_: IllegalArgumentException) {
+ // Handle edge-case formats like missing "Z"
+ try {
+ val cleaned = staleStr.replace(Regex("""\.\d+"""), "").replace("Z", "+00:00")
+ kotlin.time.Instant.parse(cleaned)
+ } catch (_: IllegalArgumentException) { return xml }
}
- val cotMessage = takPacket.toCoTMessage() ?: return
+ val now = Clock.System.now()
+ val remaining = staleInstant - now
+ if (remaining >= MIN_MESH_STALE_TTL) return xml
- takServerManager.broadcast(cotMessage)
- Logger.d { "Forwarded ATAK mesh packet to TAK clients: ${cotMessage.type}" }
+ val newStale = now + MIN_MESH_STALE_TTL
+ val newStaleStr = newStale.toString().replace(Regex("""\.\d+"""), "") // strip fractional seconds
+ Logger.i { "Extended stale for $type: $staleStr → $newStaleStr (was ${remaining.inWholeSeconds}s remaining, now ${MIN_MESH_STALE_TTL.inWholeSeconds}s)" }
+ return xml.replaceRange(staleMatch.range, """stale="$newStaleStr"""")
+ }
+
+ /**
+ * Strip non-essential XML elements before mesh compression to save wire bytes.
+ * These elements add 100-200 bytes but aren't needed for rendering shapes,
+ * routes, chats, markers, PLI, or any other payload on the receiving end.
+ */
+ private val STRIP_PATTERNS = listOf(
+ """]*/>""", // TAK version (self-closing)
+ """]*>.*?""", // TAK version (paired)
+ """]*/>""", // voice chat state
+ """]*>.*?""",
+ """]*/>""", // empty marti
+ """]*>.*?""",
+ """<__geofence[^>]*/>""", // geofence config
+ """<__geofence[^>]*>.*?""",
+ """]*/>""", // toggle state
+ """]*/>""", // archive marker
+ """<__shapeExtras[^>]*/>""", // shape extras
+ """<__shapeExtras[^>]*>.*?""",
+ """]*/>""", // creator info
+ """]*>.*?""",
+ """]*/>""", // empty remarks (self-closing)
+ """]*>""", // empty remarks (paired)
+ """]*/>""", // stroke style (SDK uses color fields)
+ """]*/>""", // precision location metadata
+ """]*>.*?""",
+ """]*/>""", // iTAK camelCase variant
+ """]*>.*?""",
+ ).map { Regex(it, RegexOption.DOT_MATCHES_ALL) }
+
+ // Strip any attribute with value "???" — unknown/placeholder metadata
+ private val UNKNOWN_ATTR_PATTERN = Regex("""\s+\w+\s*=\s*"[?]{3}"""")
+
+ // Strip specific named attributes that the SDK doesn't use (display-only)
+ private val STRIP_ATTR_PATTERNS = listOf(
+ """\s+routetype\s*=\s*"[^"]*"""", // route display type (SDK doesn't use)
+ """\s+order\s*=\s*"[^"]*"""", // checkpoint order label (SDK doesn't use)
+ """\s+color\s*=\s*"[^"]*"""", // link_attr color (SDK uses strokeColor instead)
+ """\s+access\s*=\s*"[^"]*"""", // access control (not relevant for mesh)
+ """\s+callsign\s*=\s*""""", // empty callsign attributes (e.g. checkpoints)
+ """\s+phone\s*=\s*""""", // empty phone attributes
+ ).map { Regex(it) }
+
+ // Route waypoint UID stripping — UIDs are full 36-char UUIDs that cost
+ // ~40 bytes each in the proto wire format. The receiving TAK client derives
+ // its own UIDs, so these are pure overhead. Only targets elements
+ // with a point= attribute (route waypoints / shape vertices).
+ private val ROUTE_LINK_ELEM_RE = Regex("""]*\bpoint="[^"]*"[^>]*/>""")
+ private val LINK_UID_ATTR_RE = Regex("""\s+uid="[^"]*"""")
+
+ fun stripNonEssentialElements(xml: String): String {
+ var result = xml
+ for (pattern in STRIP_PATTERNS) {
+ result = pattern.replace(result, "")
+ }
+ // Strip ??? attributes from remaining elements
+ result = UNKNOWN_ATTR_PATTERN.replace(result, "")
+ // Strip specific display-only attributes
+ for (pattern in STRIP_ATTR_PATTERNS) {
+ result = pattern.replace(result, "")
+ }
+ // Strip uid from route waypoint elements (receiver derives UIDs)
+ result = ROUTE_LINK_ELEM_RE.replace(result) { LINK_UID_ATTR_RE.replace(it.value, "") }
+ return result
+ }
}
}
diff --git a/core/takserver/src/commonMain/kotlin/org/meshtastic/core/takserver/TAKModels.kt b/core/takserver/src/commonMain/kotlin/org/meshtastic/core/takserver/TAKModels.kt
index c301a5a06..1dad3f618 100644
--- a/core/takserver/src/commonMain/kotlin/org/meshtastic/core/takserver/TAKModels.kt
+++ b/core/takserver/src/commonMain/kotlin/org/meshtastic/core/takserver/TAKModels.kt
@@ -43,6 +43,23 @@ data class CoTMessage(
val chat: CoTChat? = null,
val remarks: String? = null,
val rawDetailXml: String? = null,
+ /**
+ * Inner XML content of `...` captured by [CoTXmlParser] when this message
+ * was parsed from an incoming ATAK client event. Used as the `raw_detail` fallback payload
+ * when converting to [org.meshtastic.proto.TAKPacketV2] for CoT types that don't fit any
+ * structured payload (PLI / GeoChat / Aircraft). Null for messages constructed in-app.
+ *
+ * Distinct from [rawDetailXml], which is an output-only passthrough used by [toXml] to
+ * append extension content during serialization.
+ */
+ val parsedDetailXml: String? = null,
+ /**
+ * The entire original `...` XML string as received from the ATAK client,
+ * captured by [CoTXmlParser]. Kept solely for diagnostic logging (e.g. when a packet
+ * exceeds the mesh MTU and is dropped) so the operator can see what the client actually
+ * sent. Null for messages constructed in-app.
+ */
+ val sourceEventXml: String? = null,
) {
companion object {
fun pli(
@@ -130,7 +147,7 @@ sealed class TAKConnectionEvent {
data class ClientInfoUpdated(val clientInfo: TAKClientInfo) : TAKConnectionEvent()
- data class Message(val cotMessage: CoTMessage) : TAKConnectionEvent()
+ data class Message(val cotMessage: CoTMessage, val clientInfo: TAKClientInfo? = null) : TAKConnectionEvent()
data object Disconnected : TAKConnectionEvent()
diff --git a/core/takserver/src/commonMain/kotlin/org/meshtastic/core/takserver/TAKPacketConversion.kt b/core/takserver/src/commonMain/kotlin/org/meshtastic/core/takserver/TAKPacketConversion.kt
deleted file mode 100644
index 25af8abf9..000000000
--- a/core/takserver/src/commonMain/kotlin/org/meshtastic/core/takserver/TAKPacketConversion.kt
+++ /dev/null
@@ -1,196 +0,0 @@
-/*
- * 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 .
- */
-@file:Suppress("CyclomaticComplexMethod", "ReturnCount")
-
-package org.meshtastic.core.takserver
-
-import co.touchlab.kermit.Logger
-import org.meshtastic.proto.Contact
-import org.meshtastic.proto.GeoChat
-import org.meshtastic.proto.Group
-import org.meshtastic.proto.MemberRole
-import org.meshtastic.proto.PLI
-import org.meshtastic.proto.Status
-import org.meshtastic.proto.TAKPacket
-import org.meshtastic.proto.Team
-import kotlin.random.Random
-import kotlin.time.Clock
-import kotlin.time.Duration.Companion.minutes
-
-object TAKPacketConversion {
-
- fun CoTMessage.toTAKPacket(): TAKPacket? {
- val group =
- this.group?.let {
- Group(
- role = MemberRole.fromValue(getMemberRoleValue(it.role)) ?: MemberRole.Unspecifed,
- team = Team.fromValue(getTeamValue(it.name)) ?: Team.Unspecifed_Color,
- )
- }
-
- val status = this.status?.let { Status(battery = it.battery.coerceAtLeast(0)) }
-
- if (type.startsWith("a-f-G") || type.startsWith("a-f-g")) {
- return createPliPacket(group, status)
- }
-
- if (type == "b-t-f") {
- return createChatPacket(group, status)
- }
-
- Logger.w { "Cannot convert CoT to TAKPacket for type $type" }
- return null
- }
-
- private fun CoTMessage.createPliPacket(group: Group?, status: Status?): TAKPacket {
- val contact = this.contact?.let { Contact(callsign = it.callsign, device_callsign = this.uid) }
- val pli =
- PLI(
- latitude_i = (latitude * TAK_COORDINATE_SCALE).toInt(),
- longitude_i = (longitude * TAK_COORDINATE_SCALE).toInt(),
- altitude = if (hae >= TAK_UNKNOWN_POINT_VALUE || hae.isNaN()) 0 else hae.toInt(),
- speed = track?.speed?.coerceAtLeast(0.0)?.toInt() ?: 0,
- course = track?.course?.coerceAtLeast(0.0)?.toInt() ?: 0,
- )
-
- return TAKPacket(is_compressed = false, contact = contact, group = group, status = status, pli = pli)
- }
-
- private fun CoTMessage.createChatPacket(group: Group?, status: Status?): TAKPacket? {
- val localChat = this.chat ?: return null
- val chatMsg = localChat.message
- var toUid: String? = null
- var toCallsign: String? = null
-
- val actualDeviceUid = this.uid.geoChatSenderUid()
- val messageId =
- if (this.uid.startsWith("GeoChat.")) {
- this.uid.geoChatMessageId()
- } else {
- Random.nextInt().toString(TAK_HEX_RADIX)
- }
-
- val contact =
- this.contact?.let {
- val smuggledCallsign =
- if (actualDeviceUid.isNotEmpty()) {
- "$actualDeviceUid|$messageId"
- } else {
- it.endpoint ?: ""
- }
- Contact(callsign = it.callsign, device_callsign = smuggledCallsign)
- }
-
- if (localChat.chatroom.startsWith(this.uid) || this.uid.startsWith("GeoChat")) {
- val parts = this.uid.split(".")
- if (parts.size >= TAK_DIRECT_MESSAGE_PARTS_MIN && parts[0] == "GeoChat") {
- toUid = localChat.chatroom
- }
- } else if (localChat.chatroom != "All Chat Rooms") {
- toCallsign = localChat.chatroom
- }
-
- val chat =
- GeoChat(
- message = chatMsg,
- to = toUid ?: if (toCallsign == null) "All Chat Rooms" else null,
- to_callsign = toCallsign,
- )
-
- return TAKPacket(is_compressed = false, contact = contact, group = group, status = status, chat = chat)
- }
-
- fun TAKPacket.toCoTMessage(): CoTMessage? {
- val rawDeviceCallsign = contact?.device_callsign ?: "UNKNOWN"
- val senderCallsign = contact?.callsign ?: "UNKNOWN"
- val timeNow = Clock.System.now()
- val staleTime = timeNow + DEFAULT_TAK_STALE_MINUTES.minutes
-
- val (senderUid, messageId) = parseDeviceCallsign(rawDeviceCallsign)
-
- val localPli = pli
- if (localPli != null) {
- return CoTMessage.pli(
- uid = senderUid,
- callsign = senderCallsign,
- latitude = localPli.latitude_i.toDouble() / TAK_COORDINATE_SCALE,
- longitude = localPli.longitude_i.toDouble() / TAK_COORDINATE_SCALE,
- altitude = localPli.altitude.toDouble(),
- speed = localPli.speed.toDouble(),
- course = localPli.course.toDouble(),
- team = teamToColorName(group?.team),
- role = roleToName(group?.role),
- battery = status?.battery ?: DEFAULT_TAK_BATTERY,
- staleMinutes = DEFAULT_TAK_STALE_MINUTES,
- )
- }
-
- val localChat = chat
- if (localChat != null) {
- val chatroom =
- if (localChat.to != null || localChat.to_callsign != null) {
- localChat.to_callsign ?: localChat.to ?: "Direct Message"
- } else {
- "All Chat Rooms"
- }
-
- val msgId = messageId ?: Random.nextInt().toString(TAK_HEX_RADIX)
-
- return CoTMessage(
- uid = "GeoChat.$senderUid.$chatroom.$msgId",
- type = "b-t-f",
- how = "h-g-i-g-o",
- time = timeNow,
- start = timeNow,
- stale = staleTime,
- latitude = 0.0,
- longitude = 0.0,
- contact = CoTContact(callsign = senderCallsign, endpoint = DEFAULT_TAK_ENDPOINT),
- group = CoTGroup(name = teamToColorName(group?.team), role = roleToName(group?.role)),
- status = CoTStatus(battery = status?.battery ?: DEFAULT_TAK_BATTERY),
- chat = CoTChat(chatroom = chatroom, senderCallsign = senderCallsign, message = localChat.message),
- )
- }
-
- return null
- }
-
- private fun parseDeviceCallsign(combined: String): Pair {
- val parts = combined.split("|", limit = 2)
- return if (parts.size == 2) {
- Pair(parts[0], parts[1].ifEmpty { null })
- } else {
- Pair(combined, null)
- }
- }
-
- private fun getTeamValue(name: String): Int =
- Team.entries.find { it.name.equals(name, ignoreCase = true) }?.value ?: 0
-
- private fun getMemberRoleValue(roleName: String): Int =
- MemberRole.entries.find { it.name.equals(roleName.replace(" ", ""), ignoreCase = true) }?.value ?: 0
-
- private fun teamToColorName(team: Team?): String {
- if (team == null || team == Team.Unspecifed_Color) return DEFAULT_TAK_TEAM_NAME
- return team.toTakTeamName()
- }
-
- private fun roleToName(role: MemberRole?): String {
- if (role == null || role == MemberRole.Unspecifed) return DEFAULT_TAK_ROLE_NAME
- return role.toTakRoleName()
- }
-}
diff --git a/core/takserver/src/commonMain/kotlin/org/meshtastic/core/takserver/TAKPacketV2Conversion.kt b/core/takserver/src/commonMain/kotlin/org/meshtastic/core/takserver/TAKPacketV2Conversion.kt
new file mode 100644
index 000000000..fc61c1be3
--- /dev/null
+++ b/core/takserver/src/commonMain/kotlin/org/meshtastic/core/takserver/TAKPacketV2Conversion.kt
@@ -0,0 +1,270 @@
+/*
+ * 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.
+ */
+@file:Suppress("CyclomaticComplexMethod", "ReturnCount")
+
+package org.meshtastic.core.takserver
+
+import co.touchlab.kermit.Logger
+import okio.ByteString.Companion.toByteString
+import org.meshtastic.proto.CotHow
+import org.meshtastic.proto.CotType
+import org.meshtastic.proto.GeoChat
+import org.meshtastic.proto.GeoPointSource
+import org.meshtastic.proto.MemberRole
+import org.meshtastic.proto.TAKPacketV2
+import org.meshtastic.proto.Team
+import kotlin.random.Random
+import kotlin.time.Clock
+import kotlin.time.Duration.Companion.minutes
+import kotlin.time.Duration.Companion.seconds
+
+/**
+ * Conversion between CoTMessage and TAKPacketV2 (v2 wire protocol).
+ */
+object TAKPacketV2Conversion {
+
+ fun CoTMessage.toTAKPacketV2(): TAKPacketV2? {
+ val cotTypeEnum = TakV2TypeMapper.cotTypeFromString(type)
+ val cotTypeStr = if (cotTypeEnum == CotType.CotType_Other) type else ""
+ val howEnum = TakV2TypeMapper.cotHowFromString(how)
+
+ val teamEnum = group?.let {
+ val teamValue = Team.entries.find { t -> t.name.equals(it.name, ignoreCase = true) }?.value ?: 0
+ Team.fromValue(teamValue)
+ } ?: Team.Unspecifed_Color
+
+ val roleEnum = group?.let {
+ val roleValue = MemberRole.entries.find { r -> r.name.equals(it.role.replace(" ", ""), ignoreCase = true) }?.value ?: 0
+ MemberRole.fromValue(roleValue)
+ } ?: MemberRole.Unspecifed
+
+ val battery = status?.battery?.coerceAtLeast(0) ?: 0
+
+ // PLI (position reports)
+ if (type.startsWith("a-f-G") || type.startsWith("a-f-g") || type.startsWith("a-")) {
+ val callsign = contact?.callsign ?: "UNKNOWN"
+ val deviceCallsign = uid
+
+ return TAKPacketV2(
+ cot_type_id = cotTypeEnum,
+ cot_type_str = cotTypeStr,
+ how = howEnum,
+ callsign = callsign,
+ device_callsign = deviceCallsign,
+ uid = uid,
+ team = teamEnum,
+ role = roleEnum,
+ latitude_i = (latitude * TAK_COORDINATE_SCALE).toInt(),
+ longitude_i = (longitude * TAK_COORDINATE_SCALE).toInt(),
+ altitude = if (hae >= TAK_UNKNOWN_POINT_VALUE || hae.isNaN()) 0 else hae.toInt(),
+ speed = (track?.speed?.coerceAtLeast(0.0)?.times(100))?.toInt() ?: 0, // m/s -> cm/s
+ course = (track?.course?.coerceAtLeast(0.0)?.times(100))?.toInt() ?: 0, // deg -> deg*100
+ battery = battery,
+ geo_src = GeoPointSource.GeoPointSource_GPS,
+ alt_src = GeoPointSource.GeoPointSource_GPS,
+ pli = true,
+ )
+ }
+
+ // GeoChat
+ if (type == "b-t-f") {
+ val localChat = chat ?: return null
+ // ATAK GeoChat events often omit — the
+ // sender identity is only in <__chat senderCallsign="..."/>.
+ val callsign = contact?.callsign
+ ?: localChat.senderCallsign
+ ?: "UNKNOWN"
+ val actualDeviceUid = uid.geoChatSenderUid()
+ val messageId = if (uid.startsWith("GeoChat.")) {
+ uid.geoChatMessageId()
+ } else {
+ Random.nextInt().toString(TAK_HEX_RADIX)
+ }
+
+ val smuggledCallsign = if (actualDeviceUid.isNotEmpty()) {
+ "$actualDeviceUid|$messageId"
+ } else {
+ contact?.endpoint ?: ""
+ }
+
+ var toUid: String? = null
+ var toCallsign: String? = null
+ if (localChat.chatroom != "All Chat Rooms") {
+ if (localChat.chatroom.startsWith(uid) || uid.startsWith("GeoChat")) {
+ val parts = uid.split(".")
+ if (parts.size >= TAK_DIRECT_MESSAGE_PARTS_MIN && parts[0] == "GeoChat") {
+ toUid = localChat.chatroom
+ }
+ } else {
+ toCallsign = localChat.chatroom
+ }
+ }
+
+ return TAKPacketV2(
+ cot_type_id = CotType.CotType_b_t_f,
+ how = CotHow.CotHow_h_g_i_g_o,
+ callsign = callsign,
+ device_callsign = smuggledCallsign,
+ uid = uid,
+ team = teamEnum,
+ role = roleEnum,
+ battery = battery,
+ chat = GeoChat(
+ message = localChat.message,
+ to = toUid ?: if (toCallsign == null) "All Chat Rooms" else null,
+ to_callsign = toCallsign,
+ ),
+ )
+ }
+
+ // Fallback: wrap the whole detail XML in raw_detail for unmapped types
+ // (user-drawn shapes like u-d-c-c, markers like b-m-*, alerts, etc.)
+ val detailBytes = parsedDetailXml?.encodeToByteArray()
+ if (detailBytes != null) {
+ val callsign = contact?.callsign ?: "UNKNOWN"
+ return TAKPacketV2(
+ cot_type_id = cotTypeEnum,
+ cot_type_str = cotTypeStr,
+ how = howEnum,
+ callsign = callsign,
+ device_callsign = uid,
+ uid = uid,
+ team = teamEnum,
+ role = roleEnum,
+ latitude_i = (latitude * TAK_COORDINATE_SCALE).toInt(),
+ longitude_i = (longitude * TAK_COORDINATE_SCALE).toInt(),
+ altitude = if (hae >= TAK_UNKNOWN_POINT_VALUE || hae.isNaN()) 0 else hae.toInt(),
+ battery = battery,
+ raw_detail = detailBytes.toByteString(),
+ )
+ }
+
+ Logger.w { "Cannot convert CoT to TAKPacketV2 for type $type (no parsed detail)" }
+ return null
+ }
+
+ fun TAKPacketV2.toCoTMessage(): CoTMessage? {
+ val senderCallsign = callsign.ifEmpty { "UNKNOWN" }
+ val rawDeviceCallsign = device_callsign.ifEmpty { uid.ifEmpty { "UNKNOWN" } }
+ val timeNow = Clock.System.now()
+ val (senderUid, messageId) = parseDeviceCallsign(rawDeviceCallsign)
+
+ // PLI
+ if (pli != null) {
+ val staleMinutes = if (stale_seconds > 0) (stale_seconds / 60) else DEFAULT_TAK_STALE_MINUTES
+ return CoTMessage.pli(
+ uid = senderUid.ifEmpty { uid },
+ callsign = senderCallsign,
+ latitude = latitude_i.toDouble() / TAK_COORDINATE_SCALE,
+ longitude = longitude_i.toDouble() / TAK_COORDINATE_SCALE,
+ altitude = altitude.toDouble(),
+ speed = speed.toDouble() / 100.0, // cm/s -> m/s
+ course = course.toDouble() / 100.0, // deg*100 -> deg
+ team = teamToColorName(team),
+ role = roleToName(role),
+ battery = battery,
+ staleMinutes = staleMinutes,
+ )
+ }
+
+ // GeoChat
+ val localChat = chat
+ if (localChat != null) {
+ // chat.to carries the recipient/room ID for DMs; null means broadcast.
+ // Do NOT fall through to chat.to_callsign here — despite the name,
+ // it holds the SENDER's callsign (the parser stores __chat[@senderCallsign]
+ // there), not a chatroom name.
+ val chatroom = localChat.to ?: "All Chat Rooms"
+
+ val msgId = messageId ?: Random.nextInt().toString(TAK_HEX_RADIX)
+ val staleTime = timeNow + if (stale_seconds > 0) {
+ stale_seconds.seconds
+ } else {
+ DEFAULT_TAK_STALE_MINUTES.minutes
+ }
+
+ return CoTMessage(
+ uid = "GeoChat.$senderUid.$chatroom.$msgId",
+ type = "b-t-f",
+ how = "h-g-i-g-o",
+ time = timeNow,
+ start = timeNow,
+ stale = staleTime,
+ latitude = latitude_i.toDouble() / TAK_COORDINATE_SCALE,
+ longitude = longitude_i.toDouble() / TAK_COORDINATE_SCALE,
+ contact = CoTContact(callsign = senderCallsign, endpoint = DEFAULT_TAK_ENDPOINT),
+ group = CoTGroup(name = teamToColorName(team), role = roleToName(role)),
+ status = CoTStatus(battery = battery),
+ chat = CoTChat(
+ chatroom = chatroom,
+ senderCallsign = senderCallsign,
+ message = localChat.message,
+ ),
+ )
+ }
+
+ // Raw detail: unmapped CoT types round-tripped as opaque detail bytes.
+ // Emit a bare CoTMessage whose is the raw bytes verbatim. Do NOT populate
+ // contact/group/status here — those would be double-emitted by toXml() alongside
+ // rawDetailXml, corrupting the CoT stream.
+ val rawDetail = raw_detail
+ if (rawDetail != null) {
+ val rawXml = rawDetail.utf8()
+ val resolvedType = cot_type_str.ifEmpty {
+ TakV2TypeMapper.cotTypeToString(cot_type_id) ?: "a-f-G-U-C"
+ }
+ val resolvedHow = TakV2TypeMapper.cotHowToString(how) ?: "m-g"
+ val staleTime = timeNow + if (stale_seconds > 0) {
+ stale_seconds.seconds
+ } else {
+ DEFAULT_TAK_STALE_MINUTES.minutes
+ }
+ return CoTMessage(
+ uid = uid.ifEmpty { senderUid.ifEmpty { "tak-raw" } },
+ type = resolvedType,
+ how = resolvedHow,
+ time = timeNow,
+ start = timeNow,
+ stale = staleTime,
+ latitude = latitude_i.toDouble() / TAK_COORDINATE_SCALE,
+ longitude = longitude_i.toDouble() / TAK_COORDINATE_SCALE,
+ hae = if (altitude == 0) TAK_UNKNOWN_POINT_VALUE else altitude.toDouble(),
+ rawDetailXml = rawXml,
+ )
+ }
+
+ Logger.w { "Cannot convert TAKPacketV2 to CoTMessage: no PLI, chat, or raw_detail payload" }
+ return null
+ }
+
+ private fun parseDeviceCallsign(combined: String): Pair {
+ val parts = combined.split("|", limit = 2)
+ return if (parts.size == 2) {
+ Pair(parts[0], parts[1].ifEmpty { null })
+ } else {
+ Pair(combined, null)
+ }
+ }
+
+ private fun teamToColorName(team: Team?): String {
+ if (team == null || team == Team.Unspecifed_Color) return DEFAULT_TAK_TEAM_NAME
+ return team.toTakTeamName()
+ }
+
+ private fun roleToName(role: MemberRole?): String {
+ if (role == null || role == MemberRole.Unspecifed) return DEFAULT_TAK_ROLE_NAME
+ return role.toTakRoleName()
+ }
+
+ private fun getTeamValue(name: String): Int =
+ Team.entries.find { it.name.equals(name, ignoreCase = true) }?.value ?: 0
+
+ private fun getMemberRoleValue(roleName: String): Int =
+ MemberRole.entries.find { it.name.equals(roleName.replace(" ", ""), ignoreCase = true) }?.value ?: 0
+}
diff --git a/core/takserver/src/commonMain/kotlin/org/meshtastic/core/takserver/TAKServer.kt b/core/takserver/src/commonMain/kotlin/org/meshtastic/core/takserver/TAKServer.kt
index 05f717aee..b0449e249 100644
--- a/core/takserver/src/commonMain/kotlin/org/meshtastic/core/takserver/TAKServer.kt
+++ b/core/takserver/src/commonMain/kotlin/org/meshtastic/core/takserver/TAKServer.kt
@@ -14,194 +14,60 @@
* You should have received a copy of the GNU General Public License
* along with this program. If not, see .
*/
-@file:Suppress("TooGenericExceptionCaught")
-
package org.meshtastic.core.takserver
-import co.touchlab.kermit.Logger
-import io.ktor.network.selector.SelectorManager
-import io.ktor.network.sockets.ServerSocket
-import io.ktor.network.sockets.Socket
-import io.ktor.network.sockets.SocketAddress
-import io.ktor.network.sockets.aSocket
-import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope
-import kotlinx.coroutines.Job
-import kotlinx.coroutines.delay
-import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
-import kotlinx.coroutines.flow.asStateFlow
-import kotlinx.coroutines.launch
-import kotlinx.coroutines.sync.Mutex
-import kotlinx.coroutines.sync.withLock
import org.meshtastic.core.di.CoroutineDispatchers
-import kotlin.random.Random
-import kotlinx.coroutines.isActive as coroutineIsActive
-class TAKServer(private val dispatchers: CoroutineDispatchers, private val port: Int = DEFAULT_TAK_PORT) {
- private var serverSocket: ServerSocket? = null
- private var selectorManager: SelectorManager? = null
- private var running = false
- private var serverScope: CoroutineScope? = null
- private var acceptJob: Job? = null
- private val connectionsMutex = Mutex()
+/**
+ * Platform-agnostic contract for the Meshtastic TAK server.
+ *
+ * The production implementation on Android / JVM runs a TLS (mTLS) listener on port
+ * [DEFAULT_TAK_PORT] (8089) using the bundled server identity. This matches the
+ * Meshtastic-Apple (iOS) implementation so that a single exported `.zip` data package
+ * is valid for ATAK on Android AND iTAK on iOS without re-configuration.
+ *
+ * The interface deliberately hides the platform socket / TLS primitives so that
+ * `commonMain` code (`TAKServerManagerImpl`, DI, tests) can depend on it without
+ * pulling `javax.net.ssl.*` into the common source set.
+ */
+interface TAKServer {
- private val connections = mutableMapOf()
+ /** Observable count of currently-connected TAK clients (ATAK/iTAK). */
+ val connectionCount: StateFlow
- private val _connectionCount = MutableStateFlow(0)
- val connectionCount: StateFlow = _connectionCount.asStateFlow()
+ /** Callback invoked on the IO dispatcher for every inbound CoT message from a client. */
+ var onMessage: ((CoTMessage, TAKClientInfo?) -> Unit)?
- var onMessage: ((CoTMessage) -> Unit)? = null
+ /** Callback invoked when a TAK client connects. Use to drain queued messages. */
+ var onClientConnected: (() -> Unit)?
- suspend fun start(scope: CoroutineScope): Result {
- // Double-start guard: prevents SelectorManager / ServerSocket leaks
- if (running) {
- Logger.w { "TAK Server already running on port $port" }
- return Result.success(Unit)
- }
+ /** Bind the listener and begin accepting connections. Idempotent if already running. */
+ suspend fun start(scope: CoroutineScope): Result
- return try {
- serverScope = scope
- // Close any stale SelectorManager before creating a new one
- selectorManager?.close()
- selectorManager = SelectorManager(dispatchers.default)
- serverSocket = aSocket(selectorManager!!).tcp().bind(hostname = "127.0.0.1", port = port)
+ /** Stop the listener, close all client sockets, and release OS resources. */
+ fun stop()
- running = true
- acceptJob = scope.launch(dispatchers.io) { acceptLoop() }
- Result.success(Unit)
- } catch (e: Exception) {
- Logger.e(e) { "Failed to bind TAK Server to 127.0.0.1:$port" }
- Result.failure(e)
- }
- }
+ /** Broadcast a CoT message to every currently-connected client. */
+ suspend fun broadcast(cotMessage: CoTMessage)
- private suspend fun acceptLoop() {
- val scope = serverScope ?: return
- while (running && scope.coroutineIsActive) {
- try {
- val clientSocket = serverSocket?.accept()
- if (clientSocket != null) {
- handleConnection(clientSocket)
- }
- // No delay on the success path — accept() is already suspending
- } catch (e: CancellationException) {
- throw e
- } catch (e: Exception) {
- Logger.w(e) { "TAK server accept loop iteration failed" }
- // Back-off only in the error path
- delay(TAK_ACCEPT_LOOP_DELAY_MS)
- }
- }
- }
+ /** Broadcast raw CoT XML to every currently-connected client.
+ * Used for mesh-originated messages that should be forwarded verbatim
+ * without re-parsing through the app's CoTXmlParser (which strips
+ * shape detail elements like strokeColor, fillColor, vertices, etc.). */
+ suspend fun broadcastRawXml(xml: String)
- private fun handleConnection(clientSocket: Socket) {
- val scope = serverScope ?: return
- val endpoint = clientSocket.remoteAddress.toString()
-
- if (!clientSocket.remoteAddress.isLoopback()) {
- Logger.w { "TAK server rejected non-loopback connection from $endpoint" }
- clientSocket.close()
- return
- }
-
- val connectionId = Random.nextInt().toString(TAK_HEX_RADIX)
- val clientInfo = TAKClientInfo(id = connectionId, endpoint = endpoint)
-
- val connection =
- TAKClientConnection(
- socket = clientSocket,
- clientInfo = clientInfo,
- onEvent = { event -> handleConnectionEvent(connectionId, event) },
- scope = scope,
- )
-
- scope.launch {
- connectionsMutex.withLock {
- connections[connectionId] = connection
- _connectionCount.value = connections.size
- }
- connection.start()
- }
- }
-
- private fun handleConnectionEvent(connectionId: String, event: TAKConnectionEvent) {
- when (event) {
- is TAKConnectionEvent.Message -> {
- onMessage?.invoke(event.cotMessage)
- }
- is TAKConnectionEvent.Disconnected -> {
- serverScope?.launch {
- connectionsMutex.withLock {
- connections.remove(connectionId)
- _connectionCount.value = connections.size
- }
- }
- }
- is TAKConnectionEvent.Error -> {
- Logger.w(event.error) { "TAK client connection error: $connectionId" }
- serverScope?.launch {
- connectionsMutex.withLock {
- connections.remove(connectionId)
- _connectionCount.value = connections.size
- }
- }
- }
- is TAKConnectionEvent.Connected -> {
- /* no-op: logged by TAKClientConnection.start() */
- }
- is TAKConnectionEvent.ClientInfoUpdated -> {
- /* no-op: TAKClientConnection tracks updated info locally */
- }
- }
- }
-
- fun stop() {
- running = false
- acceptJob?.cancel()
- acceptJob = null
-
- // Close connections synchronously — TAKClientConnection.close() is non-suspending,
- // so we don't need to launch into the (possibly-cancelled) serverScope.
- val toClose: List
- // We can't use Mutex.withLock here (non-suspending context) so we swap & clear under a
- // best-effort copy — worst case a connection added concurrently is closed by socket teardown.
- toClose = connections.values.toList()
- connections.clear()
- _connectionCount.value = 0
- toClose.forEach { it.close() }
-
- serverSocket?.close()
- serverSocket = null
-
- selectorManager?.close()
- selectorManager = null
- serverScope = null
- }
-
- suspend fun broadcast(cotMessage: CoTMessage) {
- val currentConnections = connectionsMutex.withLock { connections.values.toList() }
- currentConnections.forEach { connection ->
- try {
- connection.send(cotMessage)
- } catch (e: Exception) {
- Logger.w(e) { "Failed to broadcast CoT to TAK client ${connection.clientInfo.id}" }
- connection.close()
- }
- }
- }
-
- suspend fun hasConnections(): Boolean = connectionsMutex.withLock { connections.isNotEmpty() }
+ /** Returns true if at least one TAK client is currently connected. */
+ suspend fun hasConnections(): Boolean
}
/**
- * Returns true if this [SocketAddress] represents a loopback address (IPv4 127.x.x.x or IPv6 ::1).
- *
- * Ktor's [SocketAddress.toString] returns strings like "/127.0.0.1:4242" (JVM) or "127.0.0.1:4242" on other platforms,
- * so we strip any leading slash and check prefixes without parsing the host. This keeps the check in commonMain without
- * an expect/actual.
+ * Platform factory for [TAKServer]. The JVM/Android implementation lives in
+ * `jvmAndroidMain` and uses JSSE (`SSLServerSocket`) with the bundled
+ * `server.p12` identity and `ca.pem` client trust store.
*/
-private fun SocketAddress.isLoopback(): Boolean {
- val addr = toString().removePrefix("/")
- return addr.startsWith("127.") || addr.startsWith("::1") || addr.startsWith("[::1]")
-}
+expect fun createTAKServer(
+ dispatchers: CoroutineDispatchers,
+ port: Int = DEFAULT_TAK_PORT,
+): TAKServer
diff --git a/core/takserver/src/commonMain/kotlin/org/meshtastic/core/takserver/TAKServerManager.kt b/core/takserver/src/commonMain/kotlin/org/meshtastic/core/takserver/TAKServerManager.kt
index 31248ec41..9ac3957e6 100644
--- a/core/takserver/src/commonMain/kotlin/org/meshtastic/core/takserver/TAKServerManager.kt
+++ b/core/takserver/src/commonMain/kotlin/org/meshtastic/core/takserver/TAKServerManager.kt
@@ -27,27 +27,32 @@ import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
-import org.meshtastic.core.model.Node
+
+import kotlin.time.Clock
+import kotlin.time.Duration.Companion.minutes
+
+/** A CoT message received from a connected TAK client, paired with the client's identity. */
+data class InboundCoTMessage(val cotMessage: CoTMessage, val clientInfo: TAKClientInfo? = null)
interface TAKServerManager {
val isRunning: StateFlow
val connectionCount: StateFlow
- val inboundMessages: SharedFlow
+ val inboundMessages: SharedFlow
/** Start the TAK server using [scope]. Port is fixed at [TAKServer] construction time. */
fun start(scope: CoroutineScope)
fun stop()
- fun broadcastNode(node: Node, team: String = DEFAULT_TAK_TEAM_NAME, role: String = DEFAULT_TAK_ROLE_NAME)
-
fun broadcast(cotMessage: CoTMessage)
+
+ /** Broadcast raw XML verbatim to TAK clients, bypassing CoTMessage parsing. */
+ fun broadcastRawXml(xml: String)
}
class TAKServerManagerImpl(private val takServer: TAKServer) : TAKServerManager {
private var scope: CoroutineScope? = null
- private val lastBroadcastPositionsMutex = Mutex()
private val _isRunning = MutableStateFlow(false)
override val isRunning: StateFlow = _isRunning.asStateFlow()
@@ -55,10 +60,20 @@ class TAKServerManagerImpl(private val takServer: TAKServer) : TAKServerManager
// Mirror TAKServer's event-driven connection count — no polling needed
override val connectionCount: StateFlow = takServer.connectionCount
- private val _inboundMessages = MutableSharedFlow()
- override val inboundMessages: SharedFlow = _inboundMessages.asSharedFlow()
+ private val _inboundMessages = MutableSharedFlow()
+ override val inboundMessages: SharedFlow = _inboundMessages.asSharedFlow()
- private var lastBroadcastPositions = mutableMapOf()
+ // Offline message queue — buffers mesh-originated CoT messages when no TAK
+ // clients are connected, then drains them when a client reconnects. Entries
+ // expire after OFFLINE_QUEUE_TTL to avoid delivering stale situational data.
+ private data class QueuedMessage(val cotMessage: CoTMessage, val enqueuedAt: kotlin.time.Instant)
+ private val offlineQueue = ArrayDeque()
+ private val offlineQueueMutex = Mutex()
+
+ companion object {
+ private val OFFLINE_QUEUE_TTL = 5.minutes
+ private const val OFFLINE_QUEUE_MAX_SIZE = 50
+ }
override fun start(scope: CoroutineScope) {
this.scope = scope
@@ -69,7 +84,10 @@ class TAKServerManagerImpl(private val takServer: TAKServer) : TAKServerManager
scope.launch {
// Wire up inbound message handler BEFORE starting so no messages are lost
- takServer.onMessage = { cotMessage -> scope.launch { _inboundMessages.emit(cotMessage) } }
+ takServer.onMessage = { cotMessage, clientInfo ->
+ scope.launch { _inboundMessages.emit(InboundCoTMessage(cotMessage, clientInfo)) }
+ }
+ takServer.onClientConnected = { drainOfflineQueue() }
val result = takServer.start(scope)
if (result.isSuccess) {
@@ -91,61 +109,46 @@ class TAKServerManagerImpl(private val takServer: TAKServer) : TAKServerManager
Logger.i { "TAK Server stopped" }
}
- override fun broadcastNode(node: Node, team: String, role: String) {
- if (!_isRunning.value) return
- val currentScope = scope ?: return
-
- currentScope.launch {
- if (!takServer.hasConnections()) return@launch
-
- val position = node.validPosition
- if (position == null) {
- broadcastNodeInfoOnly(node, team, role)
- return@launch
- }
-
- val shouldBroadcast =
- lastBroadcastPositionsMutex.withLock {
- val last = lastBroadcastPositions[node.num]
- if (position.time == last) {
- false
- } else {
- lastBroadcastPositions[node.num] = position.time
- true
- }
- }
- if (!shouldBroadcast) return@launch
-
- val cotMessage =
- position.toCoTMessage(
- uid = node.user.id,
- callsign = node.user.toTakCallsign(),
- team = team,
- role = role,
- battery = node.deviceMetrics.battery_level ?: 100,
- )
-
- takServer.broadcast(cotMessage)
- }
- }
-
- private fun broadcastNodeInfoOnly(node: Node, team: String, role: String) {
- val currentScope = scope ?: return
- val cotMessage =
- node.user.toCoTMessage(
- position = null,
- team = team,
- role = role,
- battery = node.deviceMetrics.battery_level ?: 100,
- )
-
- currentScope.launch {
- if (!takServer.hasConnections()) return@launch
- takServer.broadcast(cotMessage)
- }
- }
-
override fun broadcast(cotMessage: CoTMessage) {
- scope?.launch { takServer.broadcast(cotMessage) }
+ scope?.launch {
+ if (takServer.hasConnections()) {
+ takServer.broadcast(cotMessage)
+ } else {
+ // No TAK clients connected — queue for delivery when one reconnects
+ offlineQueueMutex.withLock {
+ // Evict expired entries
+ val cutoff = Clock.System.now() - OFFLINE_QUEUE_TTL
+ while (offlineQueue.isNotEmpty() && offlineQueue.first().enqueuedAt < cutoff) {
+ offlineQueue.removeFirst()
+ }
+ // Cap size to prevent unbounded growth
+ if (offlineQueue.size >= OFFLINE_QUEUE_MAX_SIZE) {
+ offlineQueue.removeFirst()
+ }
+ offlineQueue.addLast(QueuedMessage(cotMessage, Clock.System.now()))
+ }
+ }
+ }
+ }
+
+ override fun broadcastRawXml(xml: String) {
+ scope?.launch { takServer.broadcastRawXml(xml) }
+ }
+
+ /** Drain any queued messages to the newly connected TAK client. Called by the server
+ * when a TAK client connects (Connected event). */
+ internal fun drainOfflineQueue() {
+ scope?.launch {
+ val messages = offlineQueueMutex.withLock {
+ val cutoff = Clock.System.now() - OFFLINE_QUEUE_TTL
+ val valid = offlineQueue.filter { it.enqueuedAt >= cutoff }.map { it.cotMessage }
+ offlineQueue.clear()
+ valid
+ }
+ if (messages.isNotEmpty()) {
+ Logger.i { "Draining ${messages.size} queued message(s) to reconnected TAK client" }
+ messages.forEach { takServer.broadcast(it) }
+ }
+ }
}
}
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
new file mode 100644
index 000000000..5a013f599
--- /dev/null
+++ b/core/takserver/src/commonMain/kotlin/org/meshtastic/core/takserver/TakMeshTestRunner.kt
@@ -0,0 +1,183 @@
+/*
+ * 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.
+ */
+package org.meshtastic.core.takserver
+
+import co.touchlab.kermit.Logger
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asStateFlow
+import okio.ByteString.Companion.toByteString
+import org.meshtastic.core.model.DataPacket
+import org.meshtastic.core.repository.CommandSender
+import org.meshtastic.proto.PortNum
+
+/**
+ * Result of sending a single test fixture through the TAK mesh pipeline.
+ */
+data class TakTestResult(
+ val fixtureName: String,
+ val xmlBytes: Int,
+ val compressedBytes: Int,
+ val passed: Boolean,
+ val error: String? = null,
+)
+
+/**
+ * Debug-only test runner that sends the SDK's CoT XML test fixtures through the
+ * real TAK mesh pipeline: strip → parse → compress → send to mesh radio.
+ *
+ * Paces sends by waiting [sendDelayMs] between each fixture to avoid flooding
+ * the radio's TX queue.
+ */
+class TakMeshTestRunner(
+ private val commandSender: CommandSender,
+) {
+ private val _results = MutableStateFlow>(emptyList())
+ val results: StateFlow> = _results.asStateFlow()
+
+ private val _isRunning = MutableStateFlow(false)
+ val isRunning: StateFlow = _isRunning.asStateFlow()
+
+ private val _currentFixture = MutableStateFlow(null)
+ val currentFixture: StateFlow = _currentFixture.asStateFlow()
+
+ companion object {
+ /** Delay between sends to let the radio transmit and receive ACK. */
+ private const val SEND_DELAY_MS = 5_000L
+ private const val MAX_TAK_WIRE_PAYLOAD_BYTES = 225
+
+ /** All bundled fixture filenames. */
+ val FIXTURE_NAMES = listOf(
+ "aircraft_adsb.xml",
+ "aircraft_hostile.xml",
+ "alert_tic.xml",
+ "casevac.xml",
+ "casevac_medline.xml",
+ "chat_receipt_delivered.xml",
+ "chat_receipt_read.xml",
+ "delete_event.xml",
+ "drawing_circle.xml",
+ "drawing_circle_large.xml",
+ "drawing_ellipse.xml",
+ "drawing_freeform.xml",
+ "drawing_polygon.xml",
+ "drawing_rectangle.xml",
+ "drawing_rectangle_itak.xml",
+ "drawing_telestration.xml",
+ "emergency_911.xml",
+ "emergency_cancel.xml",
+ "geochat_broadcast.xml",
+ "geochat_dm.xml",
+ "geochat_simple.xml",
+ "marker_2525.xml",
+ "marker_goto.xml",
+ "marker_goto_itak.xml",
+ "marker_icon_set.xml",
+ "marker_spot.xml",
+ "marker_tank.xml",
+ "pli_basic.xml",
+ "pli_full.xml",
+ "pli_itak.xml",
+ "pli_stationary.xml",
+ "pli_takaware.xml",
+ "pli_webtak.xml",
+ "ranging_bullseye.xml",
+ "ranging_circle.xml",
+ "ranging_line.xml",
+ "route_3wp.xml",
+ "route_itak_3wp.xml",
+ "task_engage.xml",
+ "waypoint.xml",
+ )
+ }
+
+ /**
+ * Run all test fixtures sequentially, sending each through the mesh pipeline.
+ * Updates [results] and [currentFixture] as each fixture is processed.
+ */
+ suspend fun runAll() {
+ if (_isRunning.value) return
+ _isRunning.value = true
+ _results.value = emptyList()
+
+ val allResults = mutableListOf()
+
+ for (name in FIXTURE_NAMES) {
+ _currentFixture.value = name
+ val result = runSingleFixture(name)
+ allResults.add(result)
+ _results.value = allResults.toList()
+
+ if (result.passed) {
+ // Wait for radio airtime + ACK before next send
+ delay(SEND_DELAY_MS)
+ }
+ }
+
+ _currentFixture.value = null
+ _isRunning.value = false
+
+ val passed = allResults.count { it.passed }
+ val failed = allResults.size - passed
+ Logger.i { "TAK Mesh Test complete: $passed/${allResults.size} passed, $failed failed" }
+ }
+
+ private suspend fun runSingleFixture(name: String): TakTestResult {
+ // Load fixture XML from bundled resources
+ val xml = try {
+ loadFixtureXml(name)
+ } catch (e: Throwable) {
+ Logger.w(e) { "Failed to load fixture $name" }
+ return TakTestResult(name, 0, 0, false, "Load failed: ${e.message}")
+ }
+
+ // Apply the same pipeline as TAKMeshIntegration.sendCoTToMesh()
+ val freshXml = TAKMeshIntegration.ensureMinimumStaleForMesh(xml)
+ val strippedXml = TAKMeshIntegration.stripNonEssentialElements(freshXml)
+
+ // Parse and compress via SDK
+ val wirePayload: ByteArray
+ try {
+ val sdkParser = org.meshtastic.tak.CotXmlParser()
+ val sdkData = sdkParser.parse(strippedXml)
+ val compressor = org.meshtastic.tak.TakCompressor()
+ val compressed = compressor.compressWithRemarksFallback(sdkData, MAX_TAK_WIRE_PAYLOAD_BYTES)
+ if (compressed == null) {
+ Logger.w { "TAK Test: $name oversized even without remarks (xml=${xml.length}B)" }
+ return TakTestResult(name, xml.length, 0, false, "Oversized (>${MAX_TAK_WIRE_PAYLOAD_BYTES}B)")
+ }
+ wirePayload = compressed
+ } catch (e: Throwable) {
+ Logger.w(e) { "TAK Test: $name compression failed: ${e.message}" }
+ return TakTestResult(name, xml.length, 0, false, "Compress failed: ${e.message}")
+ }
+
+ // Send to mesh
+ try {
+ val dataPacket = DataPacket(
+ to = DataPacket.ID_BROADCAST,
+ bytes = wirePayload.toByteString(),
+ dataType = PortNum.ATAK_PLUGIN_V2.value,
+ )
+ commandSender.sendData(dataPacket)
+ Logger.i { "TAK Test: $name → ${wirePayload.size}B (xml=${xml.length}B)" }
+ return TakTestResult(name, xml.length, wirePayload.size, true)
+ } catch (e: Throwable) {
+ Logger.w(e) { "TAK Test: $name send failed: ${e.message}" }
+ return TakTestResult(name, xml.length, wirePayload.size, false, "Send failed: ${e.message}")
+ }
+ }
+
+ private fun loadFixtureXml(name: String): String {
+ val stream = this::class.java.classLoader?.getResourceAsStream("tak_test_fixtures/$name")
+ ?: throw IllegalStateException("Fixture not found: tak_test_fixtures/$name")
+ return stream.bufferedReader().readText()
+ }
+}
diff --git a/core/takserver/src/commonMain/kotlin/org/meshtastic/core/takserver/TakV2Compressor.kt b/core/takserver/src/commonMain/kotlin/org/meshtastic/core/takserver/TakV2Compressor.kt
new file mode 100644
index 000000000..04c358cb5
--- /dev/null
+++ b/core/takserver/src/commonMain/kotlin/org/meshtastic/core/takserver/TakV2Compressor.kt
@@ -0,0 +1,56 @@
+/*
+ * 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.
+ */
+
+package org.meshtastic.core.takserver
+
+import org.meshtastic.proto.TAKPacketV2
+
+/**
+ * TAKPacket V2 wire format compressor/decompressor.
+ *
+ * Wire format: [1 byte flags][zstd-compressed TAKPacketV2 protobuf]
+ * Flags byte bits 0-5 = dictionary ID, bits 6-7 = reserved.
+ * Special value 0xFF = uncompressed raw protobuf (from TAK_TRACKER firmware).
+ *
+ * Platform-specific implementations use zstd with pre-trained dictionaries.
+ */
+internal expect object TakV2Compressor {
+
+ /** Maximum allowed decompressed payload size (bytes). */
+ val MAX_DECOMPRESSED_SIZE: Int
+
+ /** Dictionary ID for non-aircraft types. */
+ val DICT_ID_NON_AIRCRAFT: Int
+
+ /** Dictionary ID for aircraft types. */
+ val DICT_ID_AIRCRAFT: Int
+
+ /** Special flags byte value indicating uncompressed raw protobuf. */
+ val DICT_ID_UNCOMPRESSED: Int
+
+ /**
+ * Compress a TAKPacketV2 into wire payload: [flags byte][zstd compressed protobuf].
+ * Selects dictionary based on the CoT type classification.
+ */
+ fun compress(packet: TAKPacketV2): ByteArray
+
+ /**
+ * Decompress a wire payload back to TAKPacketV2.
+ * Handles both compressed (dict-based) and uncompressed (0xFF) payloads.
+ * @throws IllegalArgumentException if payload is malformed or exceeds size limits.
+ */
+ fun decompress(wirePayload: ByteArray): TAKPacketV2
+
+ /**
+ * Decompress a wire payload and reconstruct CoT XML via the SDK's CotXmlBuilder.
+ * Handles ALL payload types (DrawnShape, Marker, Route, etc.) without going
+ * through the Wire proto intermediate.
+ */
+ fun decompressToXml(wirePayload: ByteArray): String
+}
diff --git a/core/takserver/src/commonMain/kotlin/org/meshtastic/core/takserver/TakV2TypeMapper.kt b/core/takserver/src/commonMain/kotlin/org/meshtastic/core/takserver/TakV2TypeMapper.kt
new file mode 100644
index 000000000..a67cd9383
--- /dev/null
+++ b/core/takserver/src/commonMain/kotlin/org/meshtastic/core/takserver/TakV2TypeMapper.kt
@@ -0,0 +1,70 @@
+/*
+ * 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.
+ */
+
+package org.meshtastic.core.takserver
+
+import org.meshtastic.proto.CotHow
+import org.meshtastic.proto.CotType
+
+/**
+ * Maps CoT type strings (e.g. "a-f-G-U-C") to CotType enum values and back.
+ */
+internal object TakV2TypeMapper {
+
+ private val stringToType: Map = mapOf(
+ "a-f-G-U-C" to CotType.CotType_a_f_G_U_C,
+ "a-f-G-U-C-I" to CotType.CotType_a_f_G_U_C_I,
+ "a-n-A-C-F" to CotType.CotType_a_n_A_C_F,
+ "a-n-A-C-H" to CotType.CotType_a_n_A_C_H,
+ "a-n-A-C" to CotType.CotType_a_n_A_C,
+ "a-f-A-M-H" to CotType.CotType_a_f_A_M_H,
+ "a-f-A-M" to CotType.CotType_a_f_A_M,
+ "a-h-A-M-F-F" to CotType.CotType_a_h_A_M_F_F,
+ "a-u-A-C" to CotType.CotType_a_u_A_C,
+ "t-x-d-d" to CotType.CotType_t_x_d_d,
+ "b-t-f" to CotType.CotType_b_t_f,
+ "b-r-f-h-c" to CotType.CotType_b_r_f_h_c,
+ "b-a-o-pan" to CotType.CotType_b_a_o_pan,
+ "b-a-o-opn" to CotType.CotType_b_a_o_opn,
+ "a-f-G" to CotType.CotType_a_f_G,
+ "a-f-G-U" to CotType.CotType_a_f_G_U,
+ "a-h-G" to CotType.CotType_a_h_G,
+ "a-u-G" to CotType.CotType_a_u_G,
+ "a-n-G" to CotType.CotType_a_n_G,
+ "b-m-r" to CotType.CotType_b_m_r,
+ "b-m-p-s-p-i" to CotType.CotType_b_m_p_s_p_i,
+ "u-d-f" to CotType.CotType_u_d_f,
+ "a-f-A-C-F" to CotType.CotType_a_f_A_C_F,
+ "a-f-A" to CotType.CotType_a_f_A,
+ "a-f-G-E-S" to CotType.CotType_a_f_G_E_S,
+ "b-m-p-s-p-loc" to CotType.CotType_b_m_p_s_p_loc,
+ "b-i-v" to CotType.CotType_b_i_v,
+ )
+
+ private val typeToString: Map =
+ stringToType.entries.associate { (k, v) -> v to k }
+
+ private val stringToHow: Map = mapOf(
+ "h-e" to CotHow.CotHow_h_e,
+ "m-g" to CotHow.CotHow_m_g,
+ "h-g-i-g-o" to CotHow.CotHow_h_g_i_g_o,
+ "m-r" to CotHow.CotHow_m_r,
+ )
+
+ private val howToStr: Map =
+ stringToHow.entries.associate { (k, v) -> v to k }
+
+ fun cotTypeFromString(s: String): CotType = stringToType[s] ?: CotType.CotType_Other
+
+ fun cotTypeToString(type: CotType): String? = typeToString[type]
+
+ fun cotHowFromString(s: String): CotHow = stringToHow[s] ?: CotHow.CotHow_Unspecified
+
+ fun cotHowToString(how: CotHow): String? = howToStr[how]
+}
diff --git a/core/takserver/src/commonMain/kotlin/org/meshtastic/core/takserver/di/CoreTakServerModule.kt b/core/takserver/src/commonMain/kotlin/org/meshtastic/core/takserver/di/CoreTakServerModule.kt
index 66fa34a93..acb4bb667 100644
--- a/core/takserver/src/commonMain/kotlin/org/meshtastic/core/takserver/di/CoreTakServerModule.kt
+++ b/core/takserver/src/commonMain/kotlin/org/meshtastic/core/takserver/di/CoreTakServerModule.kt
@@ -21,39 +21,30 @@ import org.koin.core.annotation.Single
import org.meshtastic.core.di.CoroutineDispatchers
import org.meshtastic.core.repository.CommandSender
import org.meshtastic.core.repository.MeshConfigHandler
-import org.meshtastic.core.repository.NodeRepository
+
import org.meshtastic.core.repository.ServiceRepository
import org.meshtastic.core.takserver.TAKMeshIntegration
import org.meshtastic.core.takserver.TAKServer
import org.meshtastic.core.takserver.TAKServerManager
import org.meshtastic.core.takserver.TAKServerManagerImpl
-import org.meshtastic.core.takserver.fountain.CoTHandler
-import org.meshtastic.core.takserver.fountain.GenericCoTHandler
+import org.meshtastic.core.takserver.createTAKServer
@Module
class CoreTakServerModule {
- @Single fun provideTAKServer(dispatchers: CoroutineDispatchers): TAKServer = TAKServer(dispatchers = dispatchers)
+ @Single fun provideTAKServer(dispatchers: CoroutineDispatchers): TAKServer = createTAKServer(dispatchers = dispatchers)
@Single fun provideTAKServerManager(takServer: TAKServer): TAKServerManager = TAKServerManagerImpl(takServer)
- @Single
- fun provideGenericCoTHandler(commandSender: CommandSender, takServerManager: TAKServerManager): CoTHandler =
- GenericCoTHandler(commandSender, takServerManager)
-
@Single
fun provideTAKMeshIntegration(
takServerManager: TAKServerManager,
commandSender: CommandSender,
- nodeRepository: NodeRepository,
serviceRepository: ServiceRepository,
meshConfigHandler: MeshConfigHandler,
- cotHandler: CoTHandler,
): TAKMeshIntegration = TAKMeshIntegration(
takServerManager,
commandSender,
- nodeRepository,
serviceRepository,
meshConfigHandler,
- cotHandler,
)
}
diff --git a/core/takserver/src/commonMain/kotlin/org/meshtastic/core/takserver/fountain/FountainCodec.kt b/core/takserver/src/commonMain/kotlin/org/meshtastic/core/takserver/fountain/FountainCodec.kt
deleted file mode 100644
index 4ed743ebf..000000000
--- a/core/takserver/src/commonMain/kotlin/org/meshtastic/core/takserver/fountain/FountainCodec.kt
+++ /dev/null
@@ -1,466 +0,0 @@
-/*
- * 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.fountain
-
-import co.touchlab.kermit.Logger
-import kotlin.math.ceil
-import kotlin.math.ln
-import kotlin.math.sqrt
-import kotlin.random.Random
-import kotlin.time.Clock
-
-internal object FountainConstants {
- val MAGIC = byteArrayOf(0x46, 0x54, 0x4E) // "FTN"
- const val BLOCK_SIZE = 220
- const val DATA_HEADER_SIZE = 11
- const val FOUNTAIN_THRESHOLD = 233
- const val TRANSFER_TYPE_COT: Byte = 0x00
- const val ACK_TYPE_COMPLETE: Byte = 0x02
- const val ACK_PACKET_SIZE = 19
-}
-
-internal data class FountainBlock(
- val seed: Int, // UInt16
- var indices: MutableSet,
- var payload: ByteArray,
-) {
- override fun equals(other: Any?): Boolean {
- if (this === other) return true
- if (other == null || this::class != other::class) return false
- other as FountainBlock
- return seed == other.seed && indices == other.indices && payload.contentEquals(other.payload)
- }
-
- override fun hashCode(): Int {
- var result = seed
- result = 31 * result + indices.hashCode()
- result = 31 * result + payload.contentHashCode()
- return result
- }
-}
-
-internal class FountainReceiveState(
- val transferId: Int, // UInt24
- val k: Int,
- val totalLength: Int,
-) {
- val blocks = mutableListOf()
- private val createdAt = Clock.System.now().toEpochMilliseconds()
-
- fun addBlock(block: FountainBlock) {
- if (blocks.none { it.seed == block.seed }) {
- blocks.add(block)
- }
- }
-
- val isExpired: Boolean
- get() = (Clock.System.now().toEpochMilliseconds() - createdAt) > 60_000
-}
-
-internal data class FountainDataHeader(
- val transferId: Int, // UInt24
- val seed: Int, // UInt16
- val k: Int, // UInt8
- val totalLength: Int, // UInt16
-)
-
-internal data class FountainAck(
- val transferId: Int,
- val type: Byte,
- val received: Int,
- val needed: Int,
- val dataHash: ByteArray,
-) {
- override fun equals(other: Any?): Boolean {
- if (this === other) return true
- if (other == null || this::class != other::class) return false
- other as FountainAck
- return transferId == other.transferId &&
- type == other.type &&
- received == other.received &&
- needed == other.needed &&
- dataHash.contentEquals(other.dataHash)
- }
-
- override fun hashCode(): Int {
- var result = transferId
- result = 31 * result + type.toInt()
- result = 31 * result + received
- result = 31 * result + needed
- result = 31 * result + dataHash.contentHashCode()
- return result
- }
-}
-
-@Suppress("MagicNumber")
-internal class JavaRandom(seed: Long) {
- private var seed: Long = (seed xor 0x5DEECE66DL) and ((1L shl 48) - 1)
-
- private fun next(bits: Int): Int {
- seed = (seed * 0x5DEECE66DL + 0xBL) and ((1L shl 48) - 1)
- return (seed ushr (48 - bits)).toInt()
- }
-
- fun nextInt(bound: Int): Int = when {
- bound <= 0 -> 0
- (bound and -bound) == bound -> ((bound.toLong() * next(31).toLong()) shr 31).toInt()
- else -> {
- var bits: Int
- var valResult: Int
- do {
- bits = next(31)
- valResult = bits % bound
- } while (bits - valResult + (bound - 1) < 0)
- valResult
- }
- }
-
- fun nextDouble(): Double {
- val high = next(26).toLong()
- val low = next(27).toLong()
- return ((high shl 27) + low).toDouble() / (1L shl 53).toDouble()
- }
-}
-
-@Suppress("MagicNumber", "TooManyFunctions")
-internal class FountainCodec {
- private val receiveStates = mutableMapOf()
-
- fun generateTransferId(): Int {
- val random = Random.nextInt(0, 0xFFFFFF + 1)
- val time = (Clock.System.now().toEpochMilliseconds() / 1000).toInt() and 0xFFFF
- return (random xor time) and 0xFFFFFF
- }
-
- fun encode(data: ByteArray, transferId: Int): List {
- if (data.isEmpty()) {
- Logger.w { "Fountain encode: empty data" }
- return emptyList()
- }
-
- val k = maxOf(1, ceil(data.size.toDouble() / FountainConstants.BLOCK_SIZE).toInt())
- val overhead = getAdaptiveOverhead(k)
- val blocksToSend = maxOf(1, ceil(k.toDouble() * (1.0 + overhead)).toInt())
-
- val sourceBlocks = splitIntoBlocks(data, k)
- val packets = mutableListOf()
-
- for (i in 0 until blocksToSend) {
- val seed = generateSeed(transferId, i)
- val indices = generateBlockIndices(seed, k, i)
-
- var blockPayload = ByteArray(FountainConstants.BLOCK_SIZE) { 0 }
- for (idx in indices) {
- blockPayload = xor(blockPayload, sourceBlocks[idx])
- }
-
- val packet = buildDataBlock(transferId, seed, k, data.size, blockPayload)
- packets.add(packet)
- }
-
- Logger.i { "Fountain encode: ${data.size} bytes -> $k source blocks -> $blocksToSend packets" }
- return packets
- }
-
- private fun splitIntoBlocks(data: ByteArray, k: Int): List {
- val blocks = mutableListOf()
- for (i in 0 until k) {
- val start = i * FountainConstants.BLOCK_SIZE
- val end = minOf(start + FountainConstants.BLOCK_SIZE, data.size)
-
- if (start < data.size) {
- val block = data.copyOfRange(start, end)
- if (block.size < FountainConstants.BLOCK_SIZE) {
- val padded = ByteArray(FountainConstants.BLOCK_SIZE) { 0 }
- block.copyInto(padded)
- blocks.add(padded)
- } else {
- blocks.add(block)
- }
- } else {
- blocks.add(ByteArray(FountainConstants.BLOCK_SIZE) { 0 })
- }
- }
- return blocks
- }
-
- private fun buildDataBlock(transferId: Int, seed: Int, k: Int, totalLength: Int, payload: ByteArray): ByteArray {
- val packet = ByteArray(FountainConstants.DATA_HEADER_SIZE + payload.size)
-
- packet[0] = FountainConstants.MAGIC[0]
- packet[1] = FountainConstants.MAGIC[1]
- packet[2] = FountainConstants.MAGIC[2]
-
- packet[3] = ((transferId shr 16) and 0xFF).toByte()
- packet[4] = ((transferId shr 8) and 0xFF).toByte()
- packet[5] = (transferId and 0xFF).toByte()
-
- packet[6] = ((seed shr 8) and 0xFF).toByte()
- packet[7] = (seed and 0xFF).toByte()
-
- packet[8] = (k and 0xFF).toByte()
-
- packet[9] = ((totalLength shr 8) and 0xFF).toByte()
- packet[10] = (totalLength and 0xFF).toByte()
-
- payload.copyInto(packet, FountainConstants.DATA_HEADER_SIZE)
- return packet
- }
-
- fun isFountainPacket(data: ByteArray): Boolean {
- if (data.size < 3) return false
- return data[0] == FountainConstants.MAGIC[0] &&
- data[1] == FountainConstants.MAGIC[1] &&
- data[2] == FountainConstants.MAGIC[2]
- }
-
- fun parseDataHeader(data: ByteArray): FountainDataHeader? {
- if (data.size < FountainConstants.DATA_HEADER_SIZE || !isFountainPacket(data)) return null
-
- val transferId =
- ((data[3].toInt() and 0xFF) shl 16) or ((data[4].toInt() and 0xFF) shl 8) or (data[5].toInt() and 0xFF)
- val seed = ((data[6].toInt() and 0xFF) shl 8) or (data[7].toInt() and 0xFF)
- val k = data[8].toInt() and 0xFF
- val totalLength = ((data[9].toInt() and 0xFF) shl 8) or (data[10].toInt() and 0xFF)
-
- return FountainDataHeader(transferId, seed, k, totalLength)
- }
-
- fun handleIncomingPacket(data: ByteArray): Pair? {
- cleanupExpiredStates()
-
- val header = parseDataHeader(data)
- if (header != null) {
- val payload = data.copyOfRange(FountainConstants.DATA_HEADER_SIZE, data.size)
- if (payload.size == FountainConstants.BLOCK_SIZE) {
- return processValidIncomingPacket(header, payload)
- } else {
- Logger.w { "Invalid fountain payload size: ${payload.size}" }
- }
- }
- return null
- }
-
- private fun processValidIncomingPacket(header: FountainDataHeader, payload: ByteArray): Pair? {
- val state =
- receiveStates.getOrPut(header.transferId) {
- FountainReceiveState(header.transferId, header.k, header.totalLength)
- }
-
- val indices = regenerateIndices(header.seed, state.k, header.transferId)
- val block = FountainBlock(header.seed, indices.toMutableSet(), payload)
- state.addBlock(block)
-
- if (state.blocks.size >= state.k) {
- val decoded = peelingDecode(state)
- if (decoded != null) {
- receiveStates.remove(header.transferId)
- Logger.i { "Fountain decode complete: ${decoded.size} bytes from ${state.blocks.size} blocks" }
- return Pair(decoded, header.transferId)
- }
- }
- return null
- }
-
- fun buildAck(transferId: Int, type: Byte, received: Int, needed: Int, dataHash: ByteArray): ByteArray {
- val packet = ByteArray(FountainConstants.ACK_PACKET_SIZE)
-
- packet[0] = FountainConstants.MAGIC[0]
- packet[1] = FountainConstants.MAGIC[1]
- packet[2] = FountainConstants.MAGIC[2]
-
- packet[3] = ((transferId shr 16) and 0xFF).toByte()
- packet[4] = ((transferId shr 8) and 0xFF).toByte()
- packet[5] = (transferId and 0xFF).toByte()
-
- packet[6] = type
-
- packet[7] = ((received shr 8) and 0xFF).toByte()
- packet[8] = (received and 0xFF).toByte()
-
- packet[9] = ((needed shr 8) and 0xFF).toByte()
- packet[10] = (needed and 0xFF).toByte()
-
- val hashLen = minOf(8, dataHash.size)
- dataHash.copyInto(packet, 11, 0, hashLen)
-
- return packet
- }
-
- fun parseAck(data: ByteArray): FountainAck? {
- if (data.size < FountainConstants.ACK_PACKET_SIZE || !isFountainPacket(data)) return null
-
- val transferId =
- ((data[3].toInt() and 0xFF) shl 16) or ((data[4].toInt() and 0xFF) shl 8) or (data[5].toInt() and 0xFF)
- val type = data[6]
- val received = ((data[7].toInt() and 0xFF) shl 8) or (data[8].toInt() and 0xFF)
- val needed = ((data[9].toInt() and 0xFF) shl 8) or (data[10].toInt() and 0xFF)
- val dataHash = data.copyOfRange(11, 19)
-
- return FountainAck(transferId, type, received, needed, dataHash)
- }
-
- private fun peelingDecode(state: FountainReceiveState): ByteArray? {
- val decoded = mutableMapOf()
- val workingBlocks =
- state.blocks.map { FountainBlock(it.seed, it.indices.toMutableSet(), it.payload.copyOf()) }.toMutableList()
-
- var progress = true
- while (progress && decoded.size < state.k) {
- progress = processWorkingBlocks(workingBlocks, decoded)
- }
-
- if (decoded.size < state.k) {
- Logger.d { "Peeling decode incomplete: ${decoded.size}/${state.k} blocks decoded" }
- return null
- }
- return assembleDecodedData(state, decoded)
- }
-
- private fun processWorkingBlocks(workingBlocks: List, decoded: MutableMap): Boolean {
- var progress = false
- for (i in workingBlocks.indices) {
- val block = workingBlocks[i]
- val toRemove = mutableListOf()
- for (idx in block.indices) {
- val decodedBlock = decoded[idx]
- if (decodedBlock != null) {
- block.payload = xor(block.payload, decodedBlock)
- toRemove.add(idx)
- }
- }
- block.indices.removeAll(toRemove)
-
- if (block.indices.size == 1) {
- val idx = block.indices.first()
- if (!decoded.containsKey(idx)) {
- decoded[idx] = block.payload
- progress = true
- }
- }
- }
- return progress
- }
-
- private fun assembleDecodedData(state: FountainReceiveState, decoded: Map): ByteArray? {
- val result = ByteArray(state.k * FountainConstants.BLOCK_SIZE)
- for (i in 0 until state.k) {
- val block = decoded[i] ?: return null
- block.copyInto(result, i * FountainConstants.BLOCK_SIZE)
- }
- return result.copyOfRange(0, state.totalLength)
- }
-
- private fun cleanupExpiredStates() {
- val expiredIds = receiveStates.filter { it.value.isExpired }.map { it.key }
- for (id in expiredIds) {
- receiveStates.remove(id)
- Logger.d { "Cleaned up expired fountain state: $id" }
- }
- }
-
- private fun getAdaptiveOverhead(k: Int): Double = when {
- k <= 10 -> 0.50
- k <= 50 -> 0.25
- else -> 0.15
- }
-
- private fun generateSeed(transferId: Int, blockIndex: Int): Int {
- val combined = transferId * 31337 + blockIndex * 7919
- return combined and 0xFFFF
- }
-
- private fun generateBlockIndices(seed: Int, k: Int, blockIndex: Int): Set {
- val rng = JavaRandom(seed.toLong())
- val sampledDegree = sampleRobustSolitonDegree(rng, k)
- val degree = if (blockIndex == 0) 1 else sampledDegree
- return selectIndices(rng, k, degree)
- }
-
- private fun regenerateIndices(seed: Int, k: Int, transferId: Int): Set {
- val rng = JavaRandom(seed.toLong())
- val sampledDegree = sampleRobustSolitonDegree(rng, k)
- val expectedSeed0 = generateSeed(transferId, 0)
- val degree = if (seed == expectedSeed0) 1 else sampledDegree
- return selectIndices(rng, k, degree)
- }
-
- private fun selectIndices(rng: JavaRandom, k: Int, degree: Int): Set {
- val indices = mutableSetOf()
- while (indices.size < degree && indices.size < k) {
- val idx = rng.nextInt(k)
- indices.add(idx)
- }
- return indices
- }
-
- private fun sampleRobustSolitonDegree(rng: JavaRandom, k: Int): Int {
- val cdf = buildRobustSolitonCDF(k)
- val u = rng.nextDouble()
- for (d in 1..k) {
- if (u <= cdf[d]) return d
- }
- return k
- }
-
- private fun buildRobustSolitonCDF(k: Int, c: Double = 0.1, delta: Double = 0.5): DoubleArray {
- if (k <= 0) return doubleArrayOf(1.0)
-
- val rho = DoubleArray(k + 1)
- rho[1] = 1.0 / k.toDouble()
- for (d in 2..k) {
- rho[d] = 1.0 / (d.toDouble() * (d - 1).toDouble())
- }
-
- val rVal = c * ln(k.toDouble() / delta) * sqrt(k.toDouble())
- val tau = DoubleArray(k + 1)
- val threshold = (k.toDouble() / rVal).toInt()
-
- for (d in 1..k) {
- if (d < threshold) {
- tau[d] = rVal / (d.toDouble() * k.toDouble())
- } else if (d == threshold) {
- tau[d] = rVal * ln(rVal / delta) / k.toDouble()
- }
- }
-
- val mu = DoubleArray(k + 1)
- var sum = 0.0
- for (d in 1..k) {
- mu[d] = rho[d] + tau[d]
- sum += mu[d]
- }
-
- val cdf = DoubleArray(k + 1)
- var cumulative = 0.0
- for (d in 1..k) {
- cumulative += mu[d] / sum
- cdf[d] = cumulative
- }
- return cdf
- }
-
- private fun xor(a: ByteArray, b: ByteArray): ByteArray {
- val result = ByteArray(maxOf(a.size, b.size))
- for (i in result.indices) {
- val byteA = if (i < a.size) a[i] else 0
- val byteB = if (i < b.size) b[i] else 0
- result[i] = (byteA.toInt() xor byteB.toInt()).toByte()
- }
- return result
- }
-}
diff --git a/core/takserver/src/commonMain/kotlin/org/meshtastic/core/takserver/fountain/GenericCoTHandler.kt b/core/takserver/src/commonMain/kotlin/org/meshtastic/core/takserver/fountain/GenericCoTHandler.kt
deleted file mode 100644
index c6bfb5f1e..000000000
--- a/core/takserver/src/commonMain/kotlin/org/meshtastic/core/takserver/fountain/GenericCoTHandler.kt
+++ /dev/null
@@ -1,231 +0,0 @@
-/*
- * 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.fountain
-
-import co.touchlab.kermit.Logger
-import kotlinx.coroutines.delay
-import kotlinx.coroutines.sync.Mutex
-import kotlinx.coroutines.sync.withLock
-import okio.ByteString.Companion.toByteString
-import org.meshtastic.core.model.DataPacket
-import org.meshtastic.core.repository.CommandSender
-import org.meshtastic.core.takserver.CoTMessage
-import org.meshtastic.core.takserver.CoTXmlParser
-import org.meshtastic.core.takserver.TAKServerManager
-import org.meshtastic.core.takserver.toXml
-import org.meshtastic.proto.PortNum
-import kotlin.time.Clock
-
-class GenericCoTHandler(private val commandSender: CommandSender, private val takServerManager: TAKServerManager) :
- CoTHandler {
- companion object {
- private const val INTER_PACKET_DELAY_MS = 100L
- private const val ACK_RETRANSMIT_DELAY_MS = 50L
- private const val PENDING_TRANSFER_TTL_MS = 60_000L
- }
-
- private val fountainCodec = FountainCodec()
- private val pendingTransfersMutex = Mutex()
- private val pendingTransfers = mutableMapOf()
-
- private data class PendingTransfer(
- val transferId: Int,
- val totalBlocks: Int,
- val dataHash: ByteArray,
- val startTime: Long = Clock.System.now().toEpochMilliseconds(),
- ) {
- override fun equals(other: Any?): Boolean {
- if (this === other) return true
- if (other == null || this::class != other::class) return false
- other as PendingTransfer
- return transferId == other.transferId &&
- totalBlocks == other.totalBlocks &&
- dataHash.contentEquals(other.dataHash) &&
- startTime == other.startTime
- }
-
- override fun hashCode(): Int {
- var result = transferId
- result = 31 * result + totalBlocks
- result = 31 * result + dataHash.contentHashCode()
- result = 31 * result + startTime.hashCode()
- return result
- }
- }
-
- override suspend fun sendGenericCoT(cotMessage: CoTMessage) {
- val xml = cotMessage.toXml()
- val xmlBytes = xml.encodeToByteArray()
-
- val compressed = ZlibCodec.compress(xmlBytes)
- if (compressed == null) {
- Logger.w { "Failed to compress CoT to Zlib" }
- return
- }
-
- val payload = ByteArray(compressed.size + 1)
- payload[0] = FountainConstants.TRANSFER_TYPE_COT
- compressed.copyInto(payload, 1)
-
- Logger.d { "Generic CoT: type=${cotMessage.type}, xml=${xmlBytes.size}B, compressed=${payload.size}B" }
-
- if (payload.size < FountainConstants.FOUNTAIN_THRESHOLD) {
- sendDirect(payload)
- } else {
- sendFountainCoded(payload)
- }
- }
-
- private fun sendDirect(payload: ByteArray) {
- val dataPacket =
- DataPacket(
- to = DataPacket.ID_BROADCAST,
- bytes = payload.toByteString(),
- dataType = PortNum.ATAK_FORWARDER.value,
- )
- commandSender.sendData(dataPacket)
- Logger.i { "Sent generic CoT directly: ${payload.size} bytes on port 257" }
- }
-
- private suspend fun sendFountainCoded(payload: ByteArray) {
- val transferId = fountainCodec.generateTransferId()
- val packets = fountainCodec.encode(payload, transferId)
- val hash = CryptoCodec.sha256Prefix8(payload)
-
- pendingTransfersMutex.withLock {
- pendingTransfers[transferId] = PendingTransfer(transferId, packets.size, hash)
- }
-
- Logger.i { "Sending fountain-coded CoT: ${payload.size} bytes -> ${packets.size} blocks, xferId=$transferId" }
-
- for ((index, packetData) in packets.withIndex()) {
- val dataPacket =
- DataPacket(
- to = DataPacket.ID_BROADCAST,
- bytes = packetData.toByteString(),
- dataType = PortNum.ATAK_FORWARDER.value,
- )
- commandSender.sendData(dataPacket)
-
- if (index < packets.size - 1) {
- delay(INTER_PACKET_DELAY_MS) // Inter-packet delay
- }
- }
- }
-
- override suspend fun handleIncomingForwarderPacket(payload: ByteArray, senderNodeNum: Int) {
- if (payload.isEmpty()) return
-
- if (fountainCodec.isFountainPacket(payload)) {
- if (payload.size == FountainConstants.ACK_PACKET_SIZE) {
- handleIncomingAck(payload, senderNodeNum)
- } else {
- handleFountainPacket(payload, senderNodeNum)
- }
- } else {
- handleDirectPacket(payload, senderNodeNum)
- }
- }
-
- private fun handleDirectPacket(payload: ByteArray, senderNodeNum: Int) {
- if (payload.size <= 1) return
- val transferType = payload[0]
- if (transferType != FountainConstants.TRANSFER_TYPE_COT) return
-
- val exiData = payload.copyOfRange(1, payload.size)
- processDecompressedCoT(exiData, senderNodeNum)
- }
-
- private suspend fun handleFountainPacket(payload: ByteArray, senderNodeNum: Int) {
- fountainCodec.handleIncomingPacket(payload)?.let { (decodedData, transferId) ->
- val hash = CryptoCodec.sha256Prefix8(decodedData)
- sendFountainAck(transferId, hash, senderNodeNum)
- delay(ACK_RETRANSMIT_DELAY_MS)
- sendFountainAck(transferId, hash, senderNodeNum)
-
- if (decodedData.size > 1 && decodedData[0] == FountainConstants.TRANSFER_TYPE_COT) {
- val exiData = decodedData.copyOfRange(1, decodedData.size)
- processDecompressedCoT(exiData, senderNodeNum)
- }
- }
- }
-
- private fun processDecompressedCoT(exiData: ByteArray, senderNodeNum: Int) {
- val xmlBytes = ZlibCodec.decompress(exiData) ?: return
- val xml = xmlBytes.decodeToString()
-
- val result = CoTXmlParser(xml).parse()
- val cot = result.getOrNull()
-
- if (cot != null) {
- takServerManager.broadcast(cot)
- Logger.i { "Received generic CoT from node $senderNodeNum: ${cot.type}" }
- } else {
- Logger.w(result.exceptionOrNull() ?: Exception("Unknown parse error")) { "Failed to parse CoT XML" }
- }
- }
-
- private fun sendFountainAck(transferId: Int, hash: ByteArray, toNodeNum: Int) {
- val ackPacket =
- fountainCodec.buildAck(
- transferId,
- FountainConstants.ACK_TYPE_COMPLETE,
- received = 0,
- needed = 0,
- dataHash = hash,
- )
-
- val dataPacket =
- DataPacket(
- to = toNodeNum.toString(),
- bytes = ackPacket.toByteString(),
- dataType = PortNum.ATAK_FORWARDER.value,
- )
- commandSender.sendData(dataPacket)
- Logger.d { "Sent fountain ACK for transfer $transferId" }
- }
-
- private suspend fun handleIncomingAck(payload: ByteArray, senderNodeNum: Int) {
- val ack = fountainCodec.parseAck(payload) ?: return
- Logger.d { "Received fountain ACK: xferId=${ack.transferId}, type=${ack.type}, from $senderNodeNum" }
-
- pendingTransfersMutex.withLock {
- cleanupStalePendingTransfersLocked()
- val pending = pendingTransfers[ack.transferId]
- if (pending != null) {
- if (ack.type == FountainConstants.ACK_TYPE_COMPLETE) {
- if (ack.dataHash.contentEquals(pending.dataHash)) {
- Logger.i { "Fountain transfer ${ack.transferId} acknowledged by node $senderNodeNum" }
- } else {
- Logger.w { "Fountain ACK hash mismatch for transfer ${ack.transferId}" }
- }
- pendingTransfers.remove(ack.transferId)
- }
- }
- }
- }
-
- /** Must be called inside [pendingTransfersMutex]. */
- private fun cleanupStalePendingTransfersLocked() {
- val now = Clock.System.now().toEpochMilliseconds()
- val stale = pendingTransfers.filter { (_, v) -> now - v.startTime > PENDING_TRANSFER_TTL_MS }.keys
- stale.forEach { id ->
- pendingTransfers.remove(id)
- Logger.d { "Evicted stale outbound pending transfer: $id" }
- }
- }
-}
diff --git a/core/takserver/src/commonTest/kotlin/org/meshtastic/core/takserver/CoTDetailStripperTest.kt b/core/takserver/src/commonTest/kotlin/org/meshtastic/core/takserver/CoTDetailStripperTest.kt
new file mode 100644
index 000000000..4e8ecfe6c
--- /dev/null
+++ b/core/takserver/src/commonTest/kotlin/org/meshtastic/core/takserver/CoTDetailStripperTest.kt
@@ -0,0 +1,228 @@
+/*
+ * 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 kotlin.test.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertFalse
+import kotlin.test.assertTrue
+
+/**
+ * Covers the allowed/stripped element contract documented on [CoTDetailStripper]. If
+ * a test here starts failing because a new element type was added to the strip list,
+ * update the strip-list KDoc in [CoTDetailStripper] in the same change.
+ */
+class CoTDetailStripperTest {
+
+ @Test
+ fun empty_input_returns_empty() {
+ assertEquals("", CoTDetailStripper.strip(""))
+ }
+
+ @Test
+ fun preserves_contact_group_status_track() {
+ val input = """
+
+ <__group name="Cyan" role="Team Member"/>
+
+
+ """.trimIndent()
+ val stripped = CoTDetailStripper.strip(input)
+ assertTrue(stripped.contains("
+
+
+
+
+
+
+
+ """.trimIndent()
+ val stripped = CoTDetailStripper.strip(input)
+ assertTrue(stripped.contains(" is the biggest single bloat contributor for u-d-c-c events — it
+ // contains an and usually a styling child. Make sure the
+ // entire subtree goes, not just the opening tag.
+ val input = """
+
+
+
+
+
+
+
+ """.trimIndent()
+ val stripped = CoTDetailStripper.strip(input)
+ assertTrue(stripped.contains(" inside is also gone because we strip the whole subtree.
+ assertFalse(stripped.contains("
+
+
+
+ <__video url="rtsp://example.com/stream"/>
+ """.trimIndent()
+ val stripped = CoTDetailStripper.strip(input)
+ assertTrue(stripped.contains("
+
+
+
+ <__serverdestination destinations="0.0.0.0:4242:tcp:abc-123"/>
+ hello world
+ """.trimIndent()
+ val stripped = CoTDetailStripper.strip(input)
+ assertTrue(stripped.contains("<__chat"), "__chat must survive stripping")
+ assertTrue(stripped.contains("
+
+ """.trimIndent()
+ val stripped = CoTDetailStripper.strip(input)
+ // No leading/trailing whitespace.
+ assertEquals(stripped, stripped.trim())
+ // No line breaks / indentation between elements.
+ assertFalse(stripped.contains("\n"), "output must not contain newlines: $stripped")
+ // Elements should be directly concatenated.
+ assertTrue(
+ stripped.contains("/><"),
+ "adjacent elements must be directly concatenated: $stripped",
+ )
+ }
+
+ @Test
+ fun handles_interleaved_strip_and_keep_elements() {
+ val input = """
+
+
+ <__group name="Cyan" role="Team Member"/>
+
+
+
+
+ """.trimIndent()
+ val stripped = CoTDetailStripper.strip(input)
+ // All four keep-elements survive in order.
+ val contactIdx = stripped.indexOf("= 0, "contact missing")
+ assertTrue(groupIdx >= 0, "group missing")
+ assertTrue(statusIdx >= 0, "status missing")
+ assertTrue(trackIdx >= 0, "track missing")
+ assertTrue(contactIdx < groupIdx, "contact must come before group")
+ assertTrue(groupIdx < statusIdx, "group must come before status")
+ assertTrue(statusIdx < trackIdx, "status must come before track")
+ // None of the stripped elements linger.
+ assertFalse(stripped.contains("color"), "color stripped")
+ assertFalse(stripped.contains("shape"), "shape stripped")
+ assertFalse(stripped.contains("ellipse"), "ellipse stripped")
+ assertFalse(stripped.contains("labels_on"), "labels_on stripped")
+ }
+
+ @Test
+ fun strips_tog_and_flow_tags() {
+ // is the rectangle "toggle" flag ATAK emits; <_flow-tags_> is TAK
+ // Server routing metadata. Both are pure bloat over the mesh. These are
+ // specifically tested because their names contain regex-special characters
+ // (`-`, `_`) and it's easy to typo the strip-list pattern.
+ val input = """
+
+
+ <_flow-tags_ marti1="2014-10-28T22:40:15.341Z"/>
+ """.trimIndent()
+ val stripped = CoTDetailStripper.strip(input)
+ assertTrue(stripped.contains("<__group name='Cyan' role='Team Member'/>""" +
+ """""" +
+ """""" +
+ """""" +
+ """""" +
+ """""" +
+ """""" +
+ """""" +
+ """<__video url='rtsp://10.0.0.1:8554/stream'/>"""
+ val stripped = CoTDetailStripper.strip(realistic)
+ val before = realistic.length
+ val after = stripped.length
+ // Should shrink by at least 60% — most of the bytes were bloat.
+ assertTrue(
+ after < before * 0.4,
+ "expected >60% reduction; before=$before after=$after stripped='$stripped'",
+ )
+ // Only the three "essential" elements survive.
+ assertTrue(stripped.contains(", ,
+ // and bloat is stripped by CoTDetailStripper so the packet has any
+ // chance of fitting in a LoRa MTU.
+ val shapeXml =
+ """
+
+
+
+
+
+
+
+
+
+
+
+
+ """
+ .trimIndent()
+
+ val result = CoTXmlParser(shapeXml).parse()
+ assertTrue(result.isSuccess)
+ val message = result.getOrNull()!!
+
+ assertEquals("u-d-c-c", message.type)
+ val detail = message.parsedDetailXml
+ assertTrue(detail != null, "parsedDetailXml must be populated for unmapped types")
+ // Preserved: anything the stripper doesn't explicitly match, including contact.
+ assertTrue(detail.contains("
+
+
+
+
+
+ """
+ .trimIndent()
+ val message = CoTXmlParser(xml).parse().getOrNull()!!
+ // sourceEventXml is used for diagnostic logging only — it must be the exact
+ // bytes we received so operators can see what ATAK actually sent.
+ assertEquals(xml, message.sourceEventXml)
+ // And it MUST still contain the stripped elements (since it is untouched).
+ assertTrue(message.sourceEventXml!!.contains(""), "sourceEventXml must be verbatim")
+ }
+
+ @Test
+ fun `parsedDetailXml is null for self-closed detail element`() {
+ val xml =
+ """
+
+
+
+
+ """
+ .trimIndent()
+ val message = CoTXmlParser(xml).parse().getOrNull()!!
+ assertEquals(null, message.parsedDetailXml)
+ }
}
diff --git a/core/takserver/src/commonTest/kotlin/org/meshtastic/core/takserver/CoTXmlTest.kt b/core/takserver/src/commonTest/kotlin/org/meshtastic/core/takserver/CoTXmlTest.kt
index 7b6aa0ecd..a3cbda525 100644
--- a/core/takserver/src/commonTest/kotlin/org/meshtastic/core/takserver/CoTXmlTest.kt
+++ b/core/takserver/src/commonTest/kotlin/org/meshtastic/core/takserver/CoTXmlTest.kt
@@ -108,9 +108,14 @@ class CoTXmlTest {
// ── Structure ─────────────────────────────────────────────────────────────
@Test
- fun `toXml includes XML declaration`() {
+ fun `toXml does not include XML declaration - CoT stream protocol`() {
+ // The CoT TCP streaming protocol requires a concatenated sequence of elements
+ // with NO XML declaration. A mid-stream tag breaks ATAK's parser and
+ // causes the client to disconnect as soon as the first real event arrives.
val message = CoTMessage.pli(uid = "!1234", callsign = "X", latitude = 0.0, longitude = 0.0)
- assertTrue(message.toXml().startsWith(".
- */
-package org.meshtastic.core.takserver
-
-import org.meshtastic.core.takserver.TAKPacketConversion.toCoTMessage
-import org.meshtastic.core.takserver.TAKPacketConversion.toTAKPacket
-import org.meshtastic.proto.Contact
-import org.meshtastic.proto.GeoChat
-import org.meshtastic.proto.Group
-import org.meshtastic.proto.MemberRole
-import org.meshtastic.proto.PLI
-import org.meshtastic.proto.Status
-import org.meshtastic.proto.TAKPacket
-import org.meshtastic.proto.Team
-import kotlin.test.Test
-import kotlin.test.assertEquals
-import kotlin.test.assertNotNull
-
-class TAKPacketConversionTest {
-
- @Test
- fun testCoTToTAKPacketPLI() {
- val cot =
- CoTMessage.pli(
- uid = "!1234",
- callsign = "Bob",
- latitude = 45.0,
- longitude = -90.0,
- altitude = 100.0,
- speed = 15.0,
- course = 180.0,
- team = "Blue",
- role = "Team Member",
- battery = 90,
- )
-
- val takPacket = cot.toTAKPacket()
- assertNotNull(takPacket)
-
- assertEquals(false, takPacket.is_compressed)
- assertEquals("Bob", takPacket.contact?.callsign)
- assertEquals("!1234", takPacket.contact?.device_callsign)
- assertEquals(Team.Blue, takPacket.group?.team)
- assertEquals(MemberRole.TeamMember, takPacket.group?.role)
- assertEquals(90, takPacket.status?.battery)
-
- assertNotNull(takPacket.pli)
- assertEquals(450000000, takPacket.pli?.latitude_i)
- assertEquals(-900000000, takPacket.pli?.longitude_i)
- assertEquals(100, takPacket.pli?.altitude)
- assertEquals(15, takPacket.pli?.speed)
- assertEquals(180, takPacket.pli?.course)
- }
-
- @Test
- fun testTAKPacketToCoTMessagePLI() {
- val takPacket =
- TAKPacket(
- is_compressed = false,
- contact = Contact(callsign = "Alice", device_callsign = "!5678"),
- group = Group(team = Team.Cyan, role = MemberRole.HQ),
- status = Status(battery = 85),
- pli = PLI(latitude_i = 300000000, longitude_i = -800000000, altitude = 50, speed = 5, course = 90),
- )
-
- val cot = takPacket.toCoTMessage()
- assertNotNull(cot)
-
- assertEquals("!5678", cot.uid)
- assertEquals("a-f-G-U-C", cot.type)
- assertEquals(30.0, cot.latitude, 0.0001)
- assertEquals(-80.0, cot.longitude, 0.0001)
- assertEquals(50.0, cot.hae, 0.0001)
-
- assertEquals("Alice", cot.contact?.callsign)
- assertEquals("Cyan", cot.group?.name)
- assertEquals("HQ", cot.group?.role)
- assertEquals(85, cot.status?.battery)
-
- assertNotNull(cot.track)
- assertEquals(5.0, cot.track.speed)
- assertEquals(90.0, cot.track.course)
- }
-
- @Test
- fun testCoTToTAKPacketChat() {
- val cot =
- CoTMessage.chat(
- senderUid = "!1234",
- senderCallsign = "Bob",
- message = "Hello World",
- chatroom = "All Chat Rooms",
- )
-
- val takPacket = cot.toTAKPacket()
- assertNotNull(takPacket)
-
- assertNotNull(takPacket.chat)
- assertEquals("Hello World", takPacket.chat?.message)
- assertEquals("All Chat Rooms", takPacket.chat?.to)
- }
-
- @Test
- fun testChatSmugglesMessageId() {
- val cot =
- CoTMessage.chat(
- senderUid = "my-device-123",
- senderCallsign = "Bob",
- message = "Hello World",
- chatroom = "All Chat Rooms",
- )
-
- val msgId = cot.uid.split(".").last()
-
- val takPacket = cot.toTAKPacket()
- assertNotNull(takPacket)
-
- val expectedDeviceCallsign = "my-device-123|$msgId"
- assertEquals(expectedDeviceCallsign, takPacket.contact?.device_callsign)
- assertEquals("Bob", takPacket.contact?.callsign)
- assertEquals("Hello World", takPacket.chat?.message)
- }
-
- @Test
- fun testParseSmuggledMessageId() {
- val takPacket =
- TAKPacket(
- is_compressed = false,
- contact = Contact(callsign = "Alice", device_callsign = "alice-device-456|msg-789"),
- chat = GeoChat(message = "Hi Bob", to = "Bob"),
- )
-
- val cot = takPacket.toCoTMessage()
- assertNotNull(cot)
-
- assertEquals("GeoChat.alice-device-456.Bob.msg-789", cot.uid)
- assertEquals("Alice", cot.chat?.senderCallsign)
- assertEquals("Hi Bob", cot.chat?.message)
- assertEquals("Bob", cot.chat?.chatroom)
- }
-}
diff --git a/core/takserver/src/commonTest/kotlin/org/meshtastic/core/takserver/TAKPacketV2RawDetailTest.kt b/core/takserver/src/commonTest/kotlin/org/meshtastic/core/takserver/TAKPacketV2RawDetailTest.kt
new file mode 100644
index 000000000..65be83ca7
--- /dev/null
+++ b/core/takserver/src/commonTest/kotlin/org/meshtastic/core/takserver/TAKPacketV2RawDetailTest.kt
@@ -0,0 +1,132 @@
+/*
+ * 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.core.takserver.TAKPacketV2Conversion.toCoTMessage
+import org.meshtastic.core.takserver.TAKPacketV2Conversion.toTAKPacketV2
+import kotlin.test.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertFalse
+import kotlin.test.assertNotNull
+import kotlin.test.assertNull
+import kotlin.test.assertTrue
+
+/**
+ * Verifies the `raw_detail` fallback round-trip for CoT types that don't fit any structured
+ * [org.meshtastic.proto.TAKPacketV2] payload (PLI, GeoChat, Aircraft).
+ *
+ * Prior to this, ATAK user-drawn elements like `u-d-c-c` would be silently dropped by
+ * [TAKPacketV2Conversion.toTAKPacketV2] with `"Cannot convert CoT to TAKPacketV2 for type ..."`.
+ */
+class TAKPacketV2RawDetailTest {
+
+ @Test
+ fun udcc_round_trips_via_raw_detail() {
+ // Note: `` / `` / `` in the input are deliberately
+ // stripped by [CoTDetailStripper] before being placed in raw_detail, because
+ // they blow up the wire size beyond the LoRa MTU. We keep `` here so
+ // we have something non-trivial to verify round-tripped.
+ val shapeXml = """
+
+
+
+
+
+
+
+
+
+
+
+ """.trimIndent()
+
+ // Parse → convert to TAKPacketV2
+ val cotMessage = CoTXmlParser(shapeXml).parse().getOrNull()
+ assertNotNull(cotMessage, "CoT XML must parse successfully")
+ val takPacketV2 = cotMessage.toTAKPacketV2()
+ assertNotNull(takPacketV2, "u-d-c-c must convert to TAKPacketV2 (not drop)")
+
+ // raw_detail must be populated; structured payloads must be null.
+ assertNotNull(takPacketV2.raw_detail, "raw_detail must hold the detail bytes")
+ assertNull(takPacketV2.pli, "PLI payload must not be set for u-d-c-c")
+ assertNull(takPacketV2.chat, "chat payload must not be set for u-d-c-c")
+ assertEquals("u-d-c-c", takPacketV2.cot_type_str.ifEmpty { "u-d-c-c" })
+ // Stripping must have fired: the raw_detail bytes must NOT contain the
+ // shape/labels_on fragments we put in the input.
+ val rawDetailBytes = takPacketV2.raw_detail!!.utf8()
+ assertFalse(rawDetailBytes.contains("shape"), "shape must be stripped from raw_detail: $rawDetailBytes")
+ assertFalse(rawDetailBytes.contains("labels_on"), "labels_on must be stripped: $rawDetailBytes")
+ assertTrue(rawDetailBytes.contains("contact"), "contact must survive: $rawDetailBytes")
+
+ // Convert back to CoTMessage
+ val roundTripped = takPacketV2.toCoTMessage()
+ assertNotNull(roundTripped, "TAKPacketV2 with raw_detail must convert back to CoTMessage")
+ assertEquals("u-d-c-c", roundTripped.type)
+ assertEquals(45.5, roundTripped.latitude, 0.0001)
+ assertEquals(-90.25, roundTripped.longitude, 0.0001)
+
+ // Serialize to XML; the surviving (stripped) content must be present.
+ val xmlOut = roundTripped.toXml()
+ assertTrue(xmlOut.contains("type='u-d-c-c'"), "type must survive: $xmlOut")
+ assertTrue(xmlOut.contains("ALPHA01"), "contact callsign must survive: $xmlOut")
+ assertFalse(xmlOut.contains("
+
+
+
+ <__group name="Red" role="Team Member"/>
+
+
+
+ """.trimIndent()
+
+ val cotMessage = CoTXmlParser(xml).parse().getOrNull()!!
+ val takPacketV2 = cotMessage.toTAKPacketV2()!!
+ val roundTripped = takPacketV2.toCoTMessage()!!
+
+ assertNull(roundTripped.contact, "contact must be null on raw_detail path (lives inside rawDetailXml)")
+ assertNull(roundTripped.group, "group must be null on raw_detail path")
+ assertNull(roundTripped.status, "status must be null on raw_detail path")
+
+ val xmlOut = roundTripped.toXml()
+ // Exactly one (from the round-tripped raw detail), not two.
+ assertEquals(1, xmlOut.split(".
- */
-package org.meshtastic.core.takserver.fountain
-
-import kotlin.test.Test
-import kotlin.test.assertContentEquals
-import kotlin.test.assertEquals
-import kotlin.test.assertFalse
-import kotlin.test.assertNotNull
-import kotlin.test.assertNull
-import kotlin.test.assertTrue
-
-class FountainCodecTest {
-
- private fun createCodec() = FountainCodec()
-
- @Test
- fun `test encode and decode small payload`() {
- val codec = createCodec()
- val originalData = "Hello, TAK! This is a test payload.".encodeToByteArray()
- // Use a fixed transfer ID for deterministic peeling decode
- val transferId = 42
-
- val packets = codec.encode(originalData, transferId)
- assertTrue(packets.isNotEmpty(), "Encoding should produce packets")
-
- var decodedResult: Pair? = null
- for (packet in packets) {
- val result = codec.handleIncomingPacket(packet)
- if (result != null) {
- decodedResult = result
- break
- }
- }
-
- assertNotNull(decodedResult, "Should successfully decode payload")
- assertEquals(transferId, decodedResult.second, "Transfer ID should match")
- assertContentEquals(originalData, decodedResult.first, "Decoded data should match original")
- }
-
- @Test
- fun `test encode and decode larger payload with packet loss`() {
- val codec = createCodec()
- // Create a payload larger than BLOCK_SIZE (220 bytes)
- val originalData = ByteArray(1024) { (it % 256).toByte() }
- // Use a fixed transfer ID for deterministic peeling decode.
- // Random transfer IDs cause ~14% flake rate because the robust soliton
- // distribution with k=5 and 50% overhead doesn't always produce a
- // decodable set of encoded blocks via the peeling algorithm.
- val transferId = 42
-
- val packets = codec.encode(originalData, transferId)
- assertTrue(packets.size > 4, "Should have multiple packets for large payload")
-
- var decodedResult: Pair? = null
-
- // Process all packets - fountain codes are designed to handle packet loss
- // by receiving enough encoded packets to reconstruct the original data
- for (packet in packets) {
- val result = codec.handleIncomingPacket(packet)
- if (result != null) {
- decodedResult = result
- break
- }
- }
-
- assertNotNull(decodedResult, "Should successfully decode payload with sufficient packets")
- assertEquals(transferId, decodedResult.second, "Transfer ID should match")
- assertContentEquals(originalData, decodedResult.first, "Decoded data should match original")
- }
-
- @Test
- fun `test build and parse ACK`() {
- val codec = createCodec()
- val transferId = 123456
- val type = FountainConstants.ACK_TYPE_COMPLETE
- val received = 5
- val needed = 0
- val dataHash = byteArrayOf(1, 2, 3, 4, 5, 6, 7, 8)
-
- val ackPacket = codec.buildAck(transferId, type, received, needed, dataHash)
- assertTrue(codec.isFountainPacket(ackPacket), "ACK should be recognized as a Fountain packet")
-
- val parsedAck = codec.parseAck(ackPacket)
- assertNotNull(parsedAck, "ACK should be parseable")
- assertEquals(transferId, parsedAck.transferId)
- assertEquals(type, parsedAck.type)
- assertEquals(received, parsedAck.received)
- assertEquals(needed, parsedAck.needed)
- assertContentEquals(dataHash, parsedAck.dataHash)
- }
-
- @Test
- fun `test invalid packet handling`() {
- val codec = createCodec()
- val invalidPacket = byteArrayOf(0x00, 0x01, 0x02, 0x03)
- assertFalse(codec.isFountainPacket(invalidPacket), "Should reject invalid magic bytes")
- assertNull(codec.parseDataHeader(invalidPacket), "Should not parse invalid header")
- assertNull(codec.handleIncomingPacket(invalidPacket), "Should handle invalid packet gracefully")
- }
-}
diff --git a/core/takserver/src/commonMain/kotlin/org/meshtastic/core/takserver/fountain/CodecExpect.kt b/core/takserver/src/iosMain/kotlin/org/meshtastic/core/takserver/AtakFileWriter.kt
similarity index 72%
rename from core/takserver/src/commonMain/kotlin/org/meshtastic/core/takserver/fountain/CodecExpect.kt
rename to core/takserver/src/iosMain/kotlin/org/meshtastic/core/takserver/AtakFileWriter.kt
index 65d7077f9..9feb78cca 100644
--- a/core/takserver/src/commonMain/kotlin/org/meshtastic/core/takserver/fountain/CodecExpect.kt
+++ b/core/takserver/src/iosMain/kotlin/org/meshtastic/core/takserver/AtakFileWriter.kt
@@ -14,14 +14,9 @@
* You should have received a copy of the GNU General Public License
* along with this program. If not, see .
*/
-package org.meshtastic.core.takserver.fountain
+package org.meshtastic.core.takserver
-internal expect object ZlibCodec {
- fun compress(data: ByteArray): ByteArray?
-
- fun decompress(data: ByteArray): ByteArray?
-}
-
-internal expect object CryptoCodec {
- fun sha256Prefix8(data: ByteArray): ByteArray
+/** iOS no-op — iTAK accepts routes via TCP streaming, no data package needed. */
+internal actual object AtakFileWriter {
+ actual fun writeToImportDir(fileName: String, zipBytes: ByteArray): Boolean = false
}
diff --git a/core/takserver/src/iosMain/kotlin/org/meshtastic/core/takserver/TAKServerIos.kt b/core/takserver/src/iosMain/kotlin/org/meshtastic/core/takserver/TAKServerIos.kt
new file mode 100644
index 000000000..0a48450a3
--- /dev/null
+++ b/core/takserver/src/iosMain/kotlin/org/meshtastic/core/takserver/TAKServerIos.kt
@@ -0,0 +1,46 @@
+/*
+ * 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 kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asStateFlow
+import org.meshtastic.core.di.CoroutineDispatchers
+
+/**
+ * iOS KMP stub. The real iOS TAK server lives in Meshtastic-Apple
+ * (`Meshtastic/Helpers/TAK/TAKServerManager.swift`) and uses Apple's
+ * `Network.framework` / `NWListener` + mTLS directly, not this KMP module.
+ *
+ * We provide a no-op implementation here so that the shared `core:takserver`
+ * module still compiles for the iOS KMP targets. Any iOS-side consumer of this
+ * module would never actually call into this path — iOS bypasses the KMP
+ * `TAKServer` interface entirely.
+ */
+private class NoopTAKServer : TAKServer {
+ private val _connectionCount = MutableStateFlow(0)
+ override val connectionCount: StateFlow = _connectionCount.asStateFlow()
+ override var onMessage: ((CoTMessage) -> Unit)? = null
+
+ override suspend fun start(scope: CoroutineScope): Result = Result.success(Unit)
+ override fun stop() = Unit
+ override suspend fun broadcast(cotMessage: CoTMessage) = Unit
+ override suspend fun hasConnections(): Boolean = false
+}
+
+actual fun createTAKServer(dispatchers: CoroutineDispatchers, port: Int): TAKServer = NoopTAKServer()
diff --git a/core/takserver/src/iosMain/kotlin/org/meshtastic/core/takserver/TakV2Compressor.kt b/core/takserver/src/iosMain/kotlin/org/meshtastic/core/takserver/TakV2Compressor.kt
new file mode 100644
index 000000000..9e246777b
--- /dev/null
+++ b/core/takserver/src/iosMain/kotlin/org/meshtastic/core/takserver/TakV2Compressor.kt
@@ -0,0 +1,59 @@
+/*
+ * 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.
+ */
+
+package org.meshtastic.core.takserver
+
+import org.meshtastic.proto.TAKPacketV2
+
+/**
+ * iOS stub for TakV2Compressor.
+ * TODO: Replace with Swift SDK integration via interop.
+ */
+internal actual object TakV2Compressor {
+
+ actual val MAX_DECOMPRESSED_SIZE: Int = 4096
+ actual val DICT_ID_NON_AIRCRAFT: Int = 0
+ actual val DICT_ID_AIRCRAFT: Int = 1
+ actual val DICT_ID_UNCOMPRESSED: Int = 0xFF
+
+ actual fun compress(packet: TAKPacketV2): ByteArray {
+ // iOS: Send uncompressed for now (TAK_TRACKER mode)
+ val protobufBytes = TAKPacketV2.ADAPTER.encode(packet)
+ val wirePayload = ByteArray(1 + protobufBytes.size)
+ wirePayload[0] = DICT_ID_UNCOMPRESSED.toByte()
+ protobufBytes.copyInto(wirePayload, 1)
+ return wirePayload
+ }
+
+ actual fun decompressToXml(wirePayload: ByteArray): String {
+ // iOS stub: decompress and convert via toCoTMessage().toXml() as fallback
+ val packet = decompress(wirePayload)
+ return packet.toString() // placeholder — iOS uses Swift SDK directly
+ }
+
+ actual fun decompress(wirePayload: ByteArray): TAKPacketV2 {
+ require(wirePayload.size >= 2) { "Wire payload too short: ${wirePayload.size} bytes" }
+
+ val flagsByte = wirePayload[0].toInt() and 0xFF
+ val payloadBytes = wirePayload.copyOfRange(1, wirePayload.size)
+
+ // iOS stub: only support uncompressed (0xFF) payloads
+ if (flagsByte != DICT_ID_UNCOMPRESSED) {
+ throw UnsupportedOperationException(
+ "iOS zstd decompression not yet implemented. Received dict ID: ${flagsByte and 0x3F}"
+ )
+ }
+
+ require(payloadBytes.size <= MAX_DECOMPRESSED_SIZE) {
+ "Payload size ${payloadBytes.size} exceeds limit $MAX_DECOMPRESSED_SIZE"
+ }
+
+ return TAKPacketV2.ADAPTER.decode(payloadBytes)
+ }
+}
diff --git a/core/takserver/src/iosMain/kotlin/org/meshtastic/core/takserver/fountain/CodecActual.kt b/core/takserver/src/iosMain/kotlin/org/meshtastic/core/takserver/fountain/CodecActual.kt
deleted file mode 100644
index 4473fc521..000000000
--- a/core/takserver/src/iosMain/kotlin/org/meshtastic/core/takserver/fountain/CodecActual.kt
+++ /dev/null
@@ -1,124 +0,0 @@
-/*
- * 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.fountain
-
-import kotlinx.cinterop.ExperimentalForeignApi
-import kotlinx.cinterop.addressOf
-import kotlinx.cinterop.alloc
-import kotlinx.cinterop.memScoped
-import kotlinx.cinterop.ptr
-import kotlinx.cinterop.reinterpret
-import kotlinx.cinterop.usePinned
-import kotlinx.cinterop.value
-import platform.CoreCrypto.CC_SHA256
-import platform.CoreCrypto.CC_SHA256_DIGEST_LENGTH
-import platform.zlib.Z_BUF_ERROR
-import platform.zlib.Z_OK
-import platform.zlib.compress
-import platform.zlib.compressBound
-import platform.zlib.uncompress
-
-internal actual object ZlibCodec {
- @OptIn(ExperimentalForeignApi::class)
- actual fun compress(data: ByteArray): ByteArray? {
- if (data.isEmpty()) return ByteArray(0)
-
- return memScoped {
- val destLen = alloc()
- destLen.value = compressBound(data.size.toULong())
-
- val destBuffer = ByteArray(destLen.value.toInt())
-
- val result =
- destBuffer.usePinned { destPin ->
- data.usePinned { srcPin ->
- compress(
- destPin.addressOf(0).reinterpret(),
- destLen.ptr,
- srcPin.addressOf(0).reinterpret(),
- data.size.toULong(),
- )
- }
- }
-
- if (result == Z_OK) {
- destBuffer.copyOf(destLen.value.toInt())
- } else {
- null
- }
- }
- }
-
- @OptIn(ExperimentalForeignApi::class)
- actual fun decompress(data: ByteArray): ByteArray? {
- if (data.isEmpty()) return ByteArray(0)
-
- var currentSize = data.size * 4
- var maxAttempts = 5
-
- while (maxAttempts > 0) {
- val success = memScoped {
- val destLen = alloc()
- destLen.value = currentSize.toULong()
-
- val destBuffer = ByteArray(currentSize)
-
- val result =
- destBuffer.usePinned { destPin ->
- data.usePinned { srcPin ->
- uncompress(
- destPin.addressOf(0).reinterpret(),
- destLen.ptr,
- srcPin.addressOf(0).reinterpret(),
- data.size.toULong(),
- )
- }
- }
-
- if (result == Z_OK) {
- return@memScoped destBuffer.copyOf(destLen.value.toInt())
- } else if (result == Z_BUF_ERROR) {
- currentSize *= 2
- maxAttempts--
- null
- } else {
- maxAttempts = 0
- null
- }
- }
- if (success != null) return success
- }
- return null
- }
-}
-
-internal actual object CryptoCodec {
- @OptIn(ExperimentalForeignApi::class)
- actual fun sha256Prefix8(data: ByteArray): ByteArray {
- val digest = ByteArray(CC_SHA256_DIGEST_LENGTH)
- if (data.isNotEmpty()) {
- data.usePinned { dataPin ->
- digest.usePinned { digestPin ->
- CC_SHA256(dataPin.addressOf(0), data.size.toUInt(), digestPin.addressOf(0).reinterpret())
- }
- }
- } else {
- digest.usePinned { digestPin -> CC_SHA256(null, 0u, digestPin.addressOf(0).reinterpret()) }
- }
- return digest.copyOf(8)
- }
-}
diff --git a/core/takserver/src/jvmAndroidMain/kotlin/org/meshtastic/core/takserver/AtakFileWriter.kt b/core/takserver/src/jvmAndroidMain/kotlin/org/meshtastic/core/takserver/AtakFileWriter.kt
new file mode 100644
index 000000000..4f001de8b
--- /dev/null
+++ b/core/takserver/src/jvmAndroidMain/kotlin/org/meshtastic/core/takserver/AtakFileWriter.kt
@@ -0,0 +1,54 @@
+/*
+ * 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 co.touchlab.kermit.Logger
+import java.io.File
+
+/**
+ * Android implementation — writes route data packages to ATAK's monitored
+ * auto-import directory. Tries multiple locations in order of preference:
+ * 1. `/sdcard/atak/tools/datapackage/` (ATAK monitors this)
+ * 2. `/sdcard/Download/` (user can manually import from here)
+ */
+@Suppress("TooGenericExceptionCaught")
+internal actual object AtakFileWriter {
+
+ actual fun writeToImportDir(fileName: String, zipBytes: ByteArray): Boolean {
+ // Use hardcoded paths — on Android /sdcard/ maps to external storage.
+ // On JVM desktop these paths don't exist and the fallback returns false.
+ val targets = listOf(
+ File("/sdcard/atak/tools/datapackage"),
+ File("/sdcard/Download"),
+ )
+
+ for (dir in targets) {
+ try {
+ if (!dir.exists()) dir.mkdirs()
+ val target = File(dir, fileName)
+ target.writeBytes(zipBytes)
+ Logger.i { "Route data package written: $fileName (${zipBytes.size} bytes) → ${target.absolutePath}" }
+ return true
+ } catch (e: Exception) {
+ Logger.d { "Cannot write to ${dir.absolutePath}: ${e.message}" }
+ }
+ }
+
+ Logger.w { "Failed to write route data package to any ATAK import directory" }
+ return false
+ }
+}
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
new file mode 100644
index 000000000..075deb5ac
--- /dev/null
+++ b/core/takserver/src/jvmAndroidMain/kotlin/org/meshtastic/core/takserver/TAKClientConnection.kt
@@ -0,0 +1,334 @@
+/*
+ * 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 .
+ */
+@file:Suppress("TooManyFunctions", "TooGenericExceptionCaught")
+
+package org.meshtastic.core.takserver
+
+import co.touchlab.kermit.Logger
+import kotlinx.coroutines.CancellationException
+import kotlinx.coroutines.CoroutineDispatcher
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.SupervisorJob
+import kotlinx.coroutines.cancel
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.sync.Mutex
+import kotlinx.coroutines.sync.withLock
+import kotlinx.coroutines.withContext
+import java.io.BufferedOutputStream
+import java.io.InputStream
+import java.io.OutputStream
+import java.net.Socket
+import java.util.concurrent.atomic.AtomicBoolean
+import kotlin.concurrent.Volatile
+import kotlin.random.Random
+import kotlin.time.Clock
+import kotlin.time.Duration.Companion.milliseconds
+import kotlin.time.Instant
+import kotlinx.coroutines.isActive as coroutineIsActive
+
+/**
+ * Per-client state machine for a connected TAK client (ATAK / iTAK / WinTAK).
+ *
+ * This is the jvmAndroidMain implementation, using plain `java.net.Socket` (which is also
+ * the base class of [javax.net.ssl.SSLSocket] from [TAKServerJvm]) with blocking
+ * `InputStream`/`OutputStream` I/O wrapped in [Dispatchers.IO] coroutines.
+ *
+ * Responsibilities:
+ * - TAK protocol negotiation handshake (`t-x-takp-v` / `-q` / `-r`)
+ * - Read loop that frames `` elements off the stream via [CoTXmlFrameBuffer]
+ * - Keepalive loop that emits a `t-x-d-d` event every [TAK_KEEPALIVE_INTERVAL_MS]
+ * - Serializing writes under a mutex so interleaved broadcasts never corrupt the XML stream
+ * - Lifecycle reporting up to [TAKServerJvm] via [onEvent] (`Connected`, `Disconnected`,
+ * `Error`, `ClientInfoUpdated`, `Message`)
+ */
+internal class TAKClientConnection(
+ private val socket: Socket,
+ val clientInfo: TAKClientInfo,
+ private val onEvent: (TAKConnectionEvent) -> Unit,
+ private val scope: CoroutineScope,
+ private val ioDispatcher: CoroutineDispatcher,
+) {
+ private var currentClientInfo = clientInfo
+ private val frameBuffer = CoTXmlFrameBuffer()
+
+ private val inputStream: InputStream = socket.getInputStream()
+ // Wrap the OutputStream in a BufferedOutputStream so that multiple small writes
+ // (we emit a full XML event per write) coalesce into one syscall; flush() after
+ // each event to push the bytes through TLS immediately.
+ private val outputStream: OutputStream = BufferedOutputStream(socket.getOutputStream())
+ private val writeMutex = Mutex()
+
+ /**
+ * Per-connection child scope. Every coroutine this class launches — the read loop,
+ * the keepalive loop, and every single send — is attached to [connectionScope] so
+ * that [emitDisconnected] can tear the whole connection down with one
+ * `connectionScope.cancel()`.
+ *
+ * Why this is critical: [broadcast] in [TAKServerJvm] fires `connection.send()` on
+ * **every** connected client for **every** CoT event coming off the mesh (and with
+ * a 56-node nodeDB each `nodeDBbyNum` emission fans out to ~56 broadcasts). If
+ * [sendXml] launched those writes on the server-level [scope] — as the previous
+ * implementation did — a single dead connection could accumulate hundreds of
+ * in-flight write coroutines before it was removed from [TAKServerJvm.connections],
+ * and every one of them would spin up, hit the closed TLS socket, and log
+ * `SocketException: Socket closed` from `BufferedOutputStream.flush()`. Scoping
+ * writes to [connectionScope] means cancelling the scope wipes the entire backlog.
+ *
+ * Uses a [SupervisorJob] child of [scope]'s job so a single write failure doesn't
+ * cascade-cancel other connections on the same server.
+ */
+ private val connectionScope: CoroutineScope =
+ CoroutineScope(SupervisorJob(scope.coroutineContext[Job]) + ioDispatcher)
+
+ /** Guards against emitting [TAKConnectionEvent.Disconnected] more than once. */
+ private val disconnectedEmitted = AtomicBoolean(false)
+
+ /**
+ * Fail-fast flag checked at the top of [sendXml] so racing broadcasts against a
+ * dead connection don't even allocate a coroutine.
+ */
+ @Volatile private var closed = false
+
+ fun start() {
+ onEvent(TAKConnectionEvent.Connected(currentClientInfo))
+ sendProtocolSupport()
+
+ connectionScope.launch { readLoop() }
+ connectionScope.launch { keepaliveLoop() }
+ }
+
+ private fun sendProtocolSupport() {
+ val serverUid = "Meshtastic-TAK-Server-${Random.nextInt().toString(TAK_HEX_RADIX)}"
+ val now = Clock.System.now()
+ val stale = now + TAK_KEEPALIVE_INTERVAL_MS.milliseconds
+ val detail =
+ """
+
+
+
+ """
+ .trimIndent()
+ sendXmlInternal(buildEventXml(uid = serverUid, type = "t-x-takp-v", now = now, stale = stale, detail = detail))
+ }
+
+ private suspend fun readLoop() {
+ try {
+ val buffer = ByteArray(TAK_XML_READ_BUFFER_SIZE)
+ while (connectionScope.coroutineIsActive && !closed && !socket.isClosed) {
+ // Blocking read off the TLS input stream — must run on the IO dispatcher.
+ val bytesRead = withContext(ioDispatcher) { inputStream.read(buffer) }
+ if (bytesRead > 0) {
+ processReceivedData(buffer.copyOfRange(0, bytesRead))
+ } else if (bytesRead == -1) {
+ break // EOF: remote peer closed the connection cleanly
+ }
+ }
+ } catch (e: CancellationException) {
+ throw e
+ } catch (e: Exception) {
+ if (!closed) {
+ Logger.w(e) { "TAK client read error: ${currentClientInfo.id}" }
+ emitDisconnected(TAKConnectionEvent.Error(e))
+ }
+ return
+ }
+ emitDisconnected(TAKConnectionEvent.Disconnected)
+ }
+
+ private suspend fun keepaliveLoop() {
+ while (connectionScope.coroutineIsActive && !closed && !socket.isClosed) {
+ kotlinx.coroutines.delay(TAK_KEEPALIVE_INTERVAL_MS)
+ if (closed) break
+ sendKeepalive()
+ }
+ }
+
+ private fun sendKeepalive() {
+ val now = Clock.System.now()
+ val stale = now + TAK_KEEPALIVE_INTERVAL_MS.milliseconds
+ sendXmlInternal(buildEventXml(uid = "takPong", type = "t-x-d-d", now = now, stale = stale, detail = ""))
+ }
+
+ /** Respond to ATAK's `t-x-c-t` ping with a pong to reset its RX timeout. */
+ private fun sendPong() {
+ val now = Clock.System.now()
+ val stale = now + TAK_KEEPALIVE_INTERVAL_MS.milliseconds
+ sendXmlInternal(buildEventXml(uid = "takPong", type = "t-x-c-t-r", now = now, stale = stale, detail = ""))
+ }
+
+ private fun processReceivedData(newData: ByteArray) {
+ frameBuffer.append(newData).forEach { xmlString -> parseAndHandleMessage(xmlString) }
+ }
+
+ private fun parseAndHandleMessage(xmlString: String) {
+ // Fast-path: detect keepalive pings before full XML parsing to avoid
+ // both the parse overhead and the noisy RAW CoT IN log line every 4.5s.
+ if (xmlString.contains("t-x-c-t") || xmlString.contains("uid=\"ping\"")) {
+ sendPong()
+ return
+ }
+
+ // Full raw CoT XML from the ATAK client, before any parsing happens.
+ // 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" }
+
+ val parser = CoTXmlParser(xmlString)
+ val result = parser.parse()
+
+ result.onSuccess { cotMessage ->
+ when {
+ cotMessage.type.startsWith("t-x-takp") -> {
+ handleProtocolControl(cotMessage.type, xmlString)
+ return
+ }
+ else -> {
+ cotMessage.contact?.let { contact ->
+ val updatedClientInfo =
+ currentClientInfo.copy(
+ callsign = currentClientInfo.callsign ?: contact.callsign,
+ uid = currentClientInfo.uid ?: cotMessage.uid,
+ )
+ if (updatedClientInfo != currentClientInfo) {
+ currentClientInfo = updatedClientInfo
+ onEvent(TAKConnectionEvent.ClientInfoUpdated(updatedClientInfo))
+ }
+ }
+ onEvent(TAKConnectionEvent.Message(cotMessage, currentClientInfo))
+ }
+ }
+ }
+ }
+
+ private fun handleProtocolControl(type: String, xmlString: String) {
+ if (type == "t-x-takp-q") {
+ sendProtocolResponse()
+ } else {
+ Logger.d { "Unhandled protocol control type: $type (raw=$xmlString)" }
+ }
+ }
+
+ private fun sendProtocolResponse() {
+ val serverUid = "Meshtastic-TAK-Server-${Random.nextInt().toString(TAK_HEX_RADIX)}"
+ val now = Clock.System.now()
+ val stale = now + TAK_KEEPALIVE_INTERVAL_MS.milliseconds
+ val detail =
+ """
+
+
+
+ """
+ .trimIndent()
+ sendXmlInternal(buildEventXml(uid = serverUid, type = "t-x-takp-r", now = now, stale = stale, detail = detail))
+ }
+
+ fun send(cotMessage: CoTMessage) {
+ if (closed) return
+ val xml = cotMessage.toXml()
+ // Full raw CoT XML being shipped out to the ATAK client, after the
+ // 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" }
+ sendXmlInternal(xml)
+ }
+
+ private fun buildEventXml(uid: String, type: String, now: Instant, stale: Instant, detail: String): String {
+ val detailContent = if (detail.isBlank()) "" else "$detail"
+ val point = """"""
+ return """""" +
+ point +
+ detailContent +
+ ""
+ }
+
+ /** Send raw XML directly to this client. Used for mesh-originated messages
+ * that bypass CoTMessage parsing to preserve shape detail elements. */
+ fun sendRawXml(xml: String) {
+ Logger.d { "RAW CoT OUT (TCP ${currentClientInfo.id}): [raw] $xml" }
+ sendXmlInternal(xml)
+ }
+
+ private fun sendXmlInternal(xml: String) {
+ // Fail-fast synchronous check BEFORE allocating a coroutine. This is the hot path
+ // for broadcasts — see the scope doc above for why it matters.
+ if (closed) return
+ connectionScope.launch {
+ // Re-check inside the coroutine: we may have been cancelled or marked closed
+ // between the launch and the dispatcher picking this up.
+ if (closed) return@launch
+ try {
+ writeMutex.withLock {
+ if (closed || socket.isClosed) return@withLock
+ val bytes = xml.toByteArray(Charsets.UTF_8)
+ // Blocking write on TLS output must run on the IO dispatcher
+ withContext(ioDispatcher) {
+ outputStream.write(bytes)
+ outputStream.flush()
+ }
+ }
+ } catch (e: CancellationException) {
+ throw e
+ } catch (e: Exception) {
+ // Don't spam on writes that raced a disconnect we already observed.
+ if (!closed) {
+ Logger.w(e) { "TAK client send error: ${currentClientInfo.id}" }
+ emitDisconnected(TAKConnectionEvent.Error(e))
+ }
+ }
+ }
+ }
+
+ fun close() {
+ frameBuffer.clear()
+ emitDisconnected(TAKConnectionEvent.Disconnected)
+ }
+
+ /**
+ * Emits [event] (expected to be [TAKConnectionEvent.Disconnected] or [TAKConnectionEvent.Error]) at most once
+ * across all code paths, then tears down the per-connection coroutines and socket.
+ *
+ * This is the ONLY place the connection's entire coroutine scope — keepalive loop,
+ * read loop, and any in-flight send coroutines — gets cancelled when the *remote*
+ * peer closes the TLS stream. Without this, Java's [Socket.isClosed] only reports
+ * whether *our* side called close(), so the keepalive loop's `!socket.isClosed`
+ * guard never fires, the broadcast fanout keeps launching writes onto the dead
+ * socket via [sendXml], and every iteration logs `SSLOutputStream / Socket closed`.
+ * Before [closed] + [connectionScope.cancel] were added, a single session with a
+ * few reconnects accumulated hundreds of zombie write coroutines each spamming
+ * errors in parallel.
+ *
+ * Idempotent via [AtomicBoolean.compareAndSet], so racing calls from [readLoop],
+ * [keepaliveLoop], and [sendXml] all converge on a single teardown.
+ */
+ private fun emitDisconnected(event: TAKConnectionEvent) {
+ if (disconnectedEmitted.compareAndSet(false, true)) {
+ // Set the fail-fast flag BEFORE emitting the event. [TAKServerJvm] will
+ // schedule an async map removal on receipt, and any broadcast racing the
+ // removal must see `closed = true` when it hits [send] / [sendXml].
+ closed = true
+ onEvent(event)
+ // Cancel the whole scope — readLoop, keepaliveLoop, and every queued or
+ // in-flight sendXml coroutine. Any write blocked in the syscall will throw
+ // on the next iteration because we close the socket next.
+ connectionScope.cancel()
+ runCatching { socket.close() }
+ }
+ }
+}
diff --git a/core/takserver/src/jvmAndroidMain/kotlin/org/meshtastic/core/takserver/TAKServerJvm.kt b/core/takserver/src/jvmAndroidMain/kotlin/org/meshtastic/core/takserver/TAKServerJvm.kt
new file mode 100644
index 000000000..161b2d450
--- /dev/null
+++ b/core/takserver/src/jvmAndroidMain/kotlin/org/meshtastic/core/takserver/TAKServerJvm.kt
@@ -0,0 +1,290 @@
+/*
+ * 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 .
+ */
+@file:Suppress("TooGenericExceptionCaught")
+
+package org.meshtastic.core.takserver
+
+import co.touchlab.kermit.Logger
+import kotlinx.coroutines.CancellationException
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.sync.Mutex
+import kotlinx.coroutines.sync.withLock
+import kotlinx.coroutines.withContext
+import org.meshtastic.core.di.CoroutineDispatchers
+import java.net.InetAddress
+import java.net.ServerSocket
+import java.net.Socket
+import javax.net.ssl.SSLServerSocket
+import kotlin.random.Random
+import kotlinx.coroutines.isActive as coroutineIsActive
+
+/**
+ * JSSE-backed TLS TAK server. Matches the Meshtastic-Apple (iOS) implementation:
+ *
+ * - Binds `127.0.0.1:8089` (loopback only — no remote device can reach the server)
+ * - TLS 1.2+ with the bundled server.p12 identity
+ * - Mutual TLS: clients MUST present a certificate chaining to the bundled ca.pem
+ * - `SO_REUSEADDR` on the listen socket so an app restart doesn't hit
+ * `BindException: Address already in use` while the previous socket is in
+ * `TIME_WAIT`
+ * - Per-connection [TAKClientConnection] running on [CoroutineDispatchers.io]
+ *
+ * If the bundled certificates fail to load (e.g. packaging regression), the server
+ * refuses to start rather than silently falling back to plain TCP — that failure mode
+ * would produce exactly the symptom the user was debugging ("ATAK never connects").
+ */
+internal class TAKServerJvm(
+ private val dispatchers: CoroutineDispatchers,
+ private val port: Int = DEFAULT_TAK_PORT,
+) : TAKServer {
+
+ private var serverSocket: ServerSocket? = null
+ private var running = false
+ private var serverScope: CoroutineScope? = null
+ private var acceptJob: Job? = null
+ private val connectionsMutex = Mutex()
+ private val connections = mutableMapOf()
+
+ private val _connectionCount = MutableStateFlow(0)
+ override val connectionCount: StateFlow = _connectionCount.asStateFlow()
+
+ override var onMessage: ((CoTMessage, TAKClientInfo?) -> Unit)? = null
+ override var onClientConnected: (() -> Unit)? = null
+
+ override suspend fun start(scope: CoroutineScope): Result {
+ if (running) {
+ Logger.w { "TAK Server already running on port $port" }
+ return Result.success(Unit)
+ }
+
+ val sslContext = TakCertLoader.getServerSslContext()
+ ?: return Result.failure(
+ IllegalStateException(
+ "TAK Server: bundled TLS certificates could not be loaded; refusing to start",
+ )
+ )
+
+ return try {
+ serverScope = scope
+
+ // Bind on the IO dispatcher — bind() can briefly block.
+ val boundSocket = withContext(dispatchers.io) {
+ val factory = sslContext.serverSocketFactory
+ // Use the address-specific overload so we bind to loopback only.
+ val loopback = InetAddress.getByName("127.0.0.1")
+ // backlog of 4 is plenty for local TAK clients
+ val tls = factory.createServerSocket(port, 4, loopback) as SSLServerSocket
+ configureTlsServerSocket(tls)
+ tls
+ }
+ serverSocket = boundSocket
+ running = true
+ Logger.i { "TAK Server listening on 127.0.0.1:$port (TLS, mTLS enforced)" }
+
+ acceptJob = scope.launch(dispatchers.io) { acceptLoop() }
+ Result.success(Unit)
+ } catch (e: Exception) {
+ Logger.e(e) { "Failed to bind TAK Server to 127.0.0.1:$port" }
+ running = false
+ serverSocket?.runCatching { close() }
+ serverSocket = null
+ Result.failure(e)
+ }
+ }
+
+ private fun configureTlsServerSocket(tls: SSLServerSocket) {
+ // Minimum TLS 1.2 — matches iOS.
+ val protocols = tls.supportedProtocols.filter { it == "TLSv1.2" || it == "TLSv1.3" }
+ if (protocols.isNotEmpty()) {
+ tls.enabledProtocols = protocols.toTypedArray()
+ }
+ // Require client certificate (mTLS) — matches
+ // `sec_protocol_options_set_peer_authentication_required` on iOS.
+ tls.needClientAuth = true
+ // Enable address reuse so restart doesn't hit TIME_WAIT on the port.
+ tls.reuseAddress = true
+ }
+
+ private suspend fun acceptLoop() {
+ val scope = serverScope ?: return
+ while (running && scope.coroutineIsActive) {
+ try {
+ val clientSocket = withContext(dispatchers.io) {
+ serverSocket?.accept()
+ }
+ if (clientSocket != null) {
+ handleConnection(clientSocket)
+ }
+ } catch (e: CancellationException) {
+ throw e
+ } catch (e: Exception) {
+ // Bind was lost or the socket was closed under us — back off, then retry.
+ if (running) {
+ Logger.w(e) { "TAK server accept loop iteration failed: ${e.message}" }
+ }
+ delay(TAK_ACCEPT_LOOP_DELAY_MS)
+ }
+ }
+ }
+
+ private fun handleConnection(clientSocket: Socket) {
+ val scope = serverScope ?: return
+ val endpoint = clientSocket.remoteSocketAddress?.toString() ?: "unknown"
+
+ if (clientSocket.inetAddress?.isLoopbackAddress != true) {
+ Logger.w { "TAK server rejected non-loopback connection from $endpoint" }
+ runCatching { clientSocket.close() }
+ return
+ }
+
+ val connectionId = Random.nextInt().toString(TAK_HEX_RADIX)
+ val clientInfo = TAKClientInfo(id = connectionId, endpoint = endpoint)
+ Logger.i { "TAK client connected: id=$connectionId endpoint=$endpoint" }
+
+ val connection =
+ TAKClientConnection(
+ socket = clientSocket,
+ clientInfo = clientInfo,
+ onEvent = { event -> handleConnectionEvent(connectionId, event) },
+ scope = scope,
+ ioDispatcher = dispatchers.io,
+ )
+
+ // Launch on IO so socket reads/writes don't queue behind CPU work on Default
+ scope.launch(dispatchers.io) {
+ connectionsMutex.withLock {
+ connections[connectionId] = connection
+ _connectionCount.value = connections.size
+ Logger.i { "TAK connection count now ${connections.size}" }
+ }
+ connection.start()
+ }
+ }
+
+ private fun handleConnectionEvent(connectionId: String, event: TAKConnectionEvent) {
+ when (event) {
+ is TAKConnectionEvent.Message -> {
+ onMessage?.invoke(event.cotMessage, event.clientInfo)
+ }
+ is TAKConnectionEvent.Disconnected -> {
+ Logger.i { "TAK client disconnected: id=$connectionId" }
+ serverScope?.launch(dispatchers.io) {
+ connectionsMutex.withLock {
+ connections.remove(connectionId)
+ _connectionCount.value = connections.size
+ Logger.i { "TAK connection count now ${connections.size}" }
+ }
+ }
+ }
+ is TAKConnectionEvent.Error -> {
+ Logger.w(event.error) { "TAK client connection error: $connectionId" }
+ serverScope?.launch(dispatchers.io) {
+ connectionsMutex.withLock {
+ connections.remove(connectionId)
+ _connectionCount.value = connections.size
+ Logger.i { "TAK connection count now ${connections.size}" }
+ }
+ }
+ }
+ is TAKConnectionEvent.Connected -> {
+ onClientConnected?.invoke()
+ }
+ is TAKConnectionEvent.ClientInfoUpdated -> {
+ /* no-op: TAKClientConnection tracks updated info locally */
+ }
+ }
+ }
+
+ override fun stop() {
+ running = false
+ acceptJob?.cancel()
+ acceptJob = null
+
+ val toClose: List
+ // Non-suspending stop path — best-effort copy; any connection added concurrently
+ // will get closed when its socket is torn down by accept() returning null.
+ toClose = connections.values.toList()
+ connections.clear()
+ _connectionCount.value = 0
+ toClose.forEach { it.close() }
+
+ serverSocket?.runCatching { close() }
+ serverSocket = null
+ serverScope = null
+ Logger.i { "TAK Server stopped" }
+ }
+
+ override suspend fun broadcast(cotMessage: CoTMessage) {
+ val currentConnections = connectionsMutex.withLock { connections.values.toList() }
+ if (currentConnections.isEmpty()) {
+ Logger.d { "broadcast ${cotMessage.type}: no TAK clients connected, dropping" }
+ return
+ }
+ Logger.d { "broadcast ${cotMessage.type} to ${currentConnections.size} TAK client(s)" }
+ currentConnections.forEach { connection ->
+ try {
+ connection.send(cotMessage)
+ } catch (e: Exception) {
+ Logger.w(e) { "Failed to broadcast CoT to TAK client ${connection.clientInfo.id}" }
+ connection.close()
+ }
+ }
+ }
+
+ override suspend fun broadcastRawXml(xml: String) {
+ val currentConnections = connectionsMutex.withLock { connections.values.toList() }
+ if (currentConnections.isEmpty()) return
+ Logger.d { "broadcastRawXml to ${currentConnections.size} TAK client(s)" }
+ currentConnections.forEach { connection ->
+ try {
+ connection.sendRawXml(xml)
+ } catch (e: Exception) {
+ Logger.w(e) { "Failed to broadcast raw XML to TAK client ${connection.clientInfo.id}" }
+ connection.close()
+ }
+ }
+ }
+
+ override suspend fun hasConnections(): Boolean =
+ connectionsMutex.withLock { connections.isNotEmpty() }
+}
+
+/**
+ * `actual` factory for the KMP `expect fun createTAKServer` declared in `commonMain`.
+ * Both the Desktop JVM target and the Android target share this source set, so both
+ * run the same JSSE-based TLS listener.
+ *
+ * Also wires [TAKDataPackageGenerator]'s bundled-cert provider so that the exported
+ * `.zip` data package contains the real `server.p12` / `client.p12` bytes from the
+ * classpath rather than an empty fallback.
+ */
+actual fun createTAKServer(dispatchers: CoroutineDispatchers, port: Int): TAKServer {
+ TAKDataPackageGenerator.bundledCertBytesProvider = TakCertBundledBytesProvider
+ return TAKServerJvm(dispatchers = dispatchers, port = port)
+}
+
+/** Bridges [TakCertLoader] bytes into [TAKDataPackageGenerator] via the commonMain interface. */
+private object TakCertBundledBytesProvider : BundledCertBytesProvider {
+ override fun serverP12Bytes(): ByteArray? = TakCertLoader.getServerP12Bytes()
+ override fun clientP12Bytes(): ByteArray? = TakCertLoader.getClientP12Bytes()
+}
diff --git a/core/takserver/src/jvmAndroidMain/kotlin/org/meshtastic/core/takserver/TakCertLoader.kt b/core/takserver/src/jvmAndroidMain/kotlin/org/meshtastic/core/takserver/TakCertLoader.kt
new file mode 100644
index 000000000..250ecc342
--- /dev/null
+++ b/core/takserver/src/jvmAndroidMain/kotlin/org/meshtastic/core/takserver/TakCertLoader.kt
@@ -0,0 +1,152 @@
+/*
+ * 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 .
+ */
+@file:Suppress("TooGenericExceptionCaught")
+
+package org.meshtastic.core.takserver
+
+import co.touchlab.kermit.Logger
+import java.io.ByteArrayInputStream
+import java.security.KeyStore
+import java.security.cert.CertificateFactory
+import java.security.cert.X509Certificate
+import javax.net.ssl.KeyManagerFactory
+import javax.net.ssl.SSLContext
+import javax.net.ssl.TrustManagerFactory
+
+/**
+ * Loads the bundled TAK server certificates from the classpath and builds an [SSLContext]
+ * suitable for running a TLS TAK server with mutual TLS (mTLS).
+ *
+ * Bundled resources (under `tak_certs/` on the module classpath):
+ * - `server.p12` — PKCS#12 containing the server's identity (cert + private key).
+ * Used as the server's identity during the TLS handshake.
+ * - `client.p12` — PKCS#12 containing an example client identity, included in the
+ * exported data package so ATAK / iTAK have a certificate it can present.
+ * - `ca.pem` — PEM-encoded CA certificate used to validate the presented client
+ * certificate during mTLS. Only clients whose certificate chains back to this CA
+ * are accepted.
+ *
+ * All files are the same bytes as the iOS Meshtastic-Apple bundle, so the same
+ * exported data package works for both platforms with no re-import.
+ */
+internal object TakCertLoader {
+
+ private const val RESOURCE_SERVER_P12 = "tak_certs/server.p12"
+ private const val RESOURCE_CLIENT_P12 = "tak_certs/client.p12"
+ private const val RESOURCE_CA_PEM = "tak_certs/ca.pem"
+
+ @Volatile private var cachedSslContext: SSLContext? = null
+ @Volatile private var cachedServerP12: ByteArray? = null
+ @Volatile private var cachedClientP12: ByteArray? = null
+ @Volatile private var cachedCaPem: ByteArray? = null
+
+ /**
+ * Build (and cache) an [SSLContext] for the TAK server.
+ *
+ * The context uses the bundled `server.p12` for its identity and the bundled
+ * `ca.pem` to validate client certificates during mTLS. If anything fails to
+ * load (missing resources, bad password, corrupt keystore) this returns `null`
+ * and callers should fall back to a non-TLS listener or refuse to start.
+ */
+ @Synchronized
+ fun getServerSslContext(): SSLContext? {
+ cachedSslContext?.let { return it }
+ return try {
+ val serverP12 = loadResourceBytes(RESOURCE_SERVER_P12)
+ ?: error("Bundled $RESOURCE_SERVER_P12 not found on classpath")
+ val caPem = loadResourceBytes(RESOURCE_CA_PEM)
+ ?: error("Bundled $RESOURCE_CA_PEM not found on classpath")
+
+ // Load the server identity (cert + private key).
+ val serverKeyStore = KeyStore.getInstance("PKCS12").apply {
+ ByteArrayInputStream(serverP12).use { input ->
+ load(input, TAK_BUNDLED_CERT_PASSWORD.toCharArray())
+ }
+ }
+ val kmf = KeyManagerFactory.getInstance(KeyManagerFactory.getDefaultAlgorithm()).apply {
+ init(serverKeyStore, TAK_BUNDLED_CERT_PASSWORD.toCharArray())
+ }
+
+ // Load the CA certificate(s) used to verify incoming client certs.
+ val caCerts = parsePemCertificates(caPem)
+ if (caCerts.isEmpty()) error("No certificates found inside $RESOURCE_CA_PEM")
+ val trustKeyStore = KeyStore.getInstance(KeyStore.getDefaultType()).apply {
+ load(null, null)
+ caCerts.forEachIndexed { index, cert ->
+ setCertificateEntry("tak-client-ca-$index", cert)
+ }
+ }
+ val tmf = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm()).apply {
+ init(trustKeyStore)
+ }
+
+ val sslContext = SSLContext.getInstance("TLSv1.2").apply {
+ init(kmf.keyManagers, tmf.trustManagers, null)
+ }
+
+ Logger.i { "TAK: loaded bundled TLS server identity and ${caCerts.size} CA certificate(s)" }
+ cachedSslContext = sslContext
+ sslContext
+ } catch (e: Throwable) {
+ Logger.e(e) { "TAK: failed to build SSLContext from bundled certificates: ${e.message}" }
+ null
+ }
+ }
+
+ /** Returns the raw bytes of the bundled `server.p12`. Used by the data package generator. */
+ fun getServerP12Bytes(): ByteArray? {
+ cachedServerP12?.let { return it }
+ val bytes = loadResourceBytes(RESOURCE_SERVER_P12)
+ cachedServerP12 = bytes
+ return bytes
+ }
+
+ /** Returns the raw bytes of the bundled `client.p12`. Used by the data package generator. */
+ fun getClientP12Bytes(): ByteArray? {
+ cachedClientP12?.let { return it }
+ val bytes = loadResourceBytes(RESOURCE_CLIENT_P12)
+ cachedClientP12 = bytes
+ return bytes
+ }
+
+ /** Returns the raw bytes of the bundled `ca.pem`. */
+ fun getCaPemBytes(): ByteArray? {
+ cachedCaPem?.let { return it }
+ val bytes = loadResourceBytes(RESOURCE_CA_PEM)
+ cachedCaPem = bytes
+ return bytes
+ }
+
+ private fun loadResourceBytes(name: String): ByteArray? {
+ val stream = TakCertLoader::class.java.classLoader?.getResourceAsStream(name)
+ ?: return null
+ return stream.use { it.readBytes() }
+ }
+
+ /**
+ * Parse every `-----BEGIN CERTIFICATE----- ... -----END CERTIFICATE-----` block in the
+ * given PEM bytes into [X509Certificate]s. Tolerates multiple certs in one file.
+ */
+ private fun parsePemCertificates(pem: ByteArray): List {
+ val factory = CertificateFactory.getInstance("X.509")
+ // CertificateFactory.generateCertificates handles PEM bundles directly on all
+ // standard Java providers, so we don't need to split ourselves.
+ return ByteArrayInputStream(pem).use { input ->
+ factory.generateCertificates(input).filterIsInstance()
+ }
+ }
+}
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 c7be69ffc..97abe3553 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
@@ -71,6 +71,17 @@ internal actual object TakV2Compressor {
return sdkDataToWire(data)
}
+ /**
+ * Decompress a V2 wire payload and reconstruct CoT XML via the SDK's
+ * CotXmlBuilder. This handles ALL payload types (DrawnShape, Marker,
+ * Route, etc.) without going through the Wire proto intermediate,
+ * avoiding the gap where `toCoTMessage()` only handles PLI/GeoChat.
+ */
+ actual fun decompressToXml(wirePayload: ByteArray): String {
+ val data = getSdkCompressor().decompress(wirePayload)
+ return org.meshtastic.tak.CotXmlBuilder().build(data)
+ }
+
/**
* Convert Wire-generated TAKPacketV2 → SDK's TakPacketV2Data.
*/
diff --git a/core/takserver/src/jvmAndroidMain/kotlin/org/meshtastic/core/takserver/fountain/CodecActual.kt b/core/takserver/src/jvmAndroidMain/kotlin/org/meshtastic/core/takserver/fountain/CodecActual.kt
deleted file mode 100644
index 9db28ac66..000000000
--- a/core/takserver/src/jvmAndroidMain/kotlin/org/meshtastic/core/takserver/fountain/CodecActual.kt
+++ /dev/null
@@ -1,75 +0,0 @@
-/*
- * 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.fountain
-
-import java.io.ByteArrayOutputStream
-import java.security.MessageDigest
-import java.util.zip.Deflater
-import java.util.zip.Inflater
-
-internal actual object ZlibCodec {
- actual fun compress(data: ByteArray): ByteArray? {
- val deflater = Deflater(Deflater.DEFAULT_COMPRESSION, false)
- return try {
- deflater.setInput(data)
- deflater.finish()
-
- val outputStream = ByteArrayOutputStream(data.size)
- val buffer = ByteArray(1024)
- while (!deflater.finished()) {
- val count = deflater.deflate(buffer)
- outputStream.write(buffer, 0, count)
- }
- outputStream.close()
- outputStream.toByteArray()
- } catch (e: Exception) {
- null
- } finally {
- deflater.end()
- }
- }
-
- actual fun decompress(data: ByteArray): ByteArray? {
- val inflater = Inflater(false)
- return try {
- inflater.setInput(data)
-
- val outputStream = ByteArrayOutputStream(data.size * 2)
- val buffer = ByteArray(1024)
- while (!inflater.finished()) {
- val count = inflater.inflate(buffer)
- if (count == 0 && inflater.needsInput()) {
- break
- }
- outputStream.write(buffer, 0, count)
- }
- outputStream.close()
- outputStream.toByteArray()
- } catch (e: Exception) {
- null
- } finally {
- inflater.end()
- }
- }
-}
-
-internal actual object CryptoCodec {
- actual fun sha256Prefix8(data: ByteArray): ByteArray {
- val digest = MessageDigest.getInstance("SHA-256")
- return digest.digest(data).copyOf(8)
- }
-}
diff --git a/core/takserver/src/jvmAndroidMain/resources/tak_certs/ca.pem b/core/takserver/src/jvmAndroidMain/resources/tak_certs/ca.pem
new file mode 100644
index 000000000..1dc6e36f6
--- /dev/null
+++ b/core/takserver/src/jvmAndroidMain/resources/tak_certs/ca.pem
@@ -0,0 +1,23 @@
+-----BEGIN CERTIFICATE-----
+MIID4zCCAsugAwIBAgIUeM9XhqZCtta+QorYNjZSdAk3gkMwDQYJKoZIhvcNAQEL
+BQAwgYAxCzAJBgNVBAYTAlVTMRMwEQYDVQQIDApDYWxpZm9ybmlhMRYwFAYDVQQH
+DA1TYW4gRnJhbmNpc2NvMRMwEQYDVQQKDApNZXNodGFzdGljMRMwEQYDVQQLDApU
+QUsgU2VydmVyMRowGAYDVQQDDBFNZXNodGFzdGljIFRBSyBDQTAeFw0yNTEyMzEx
+OTQwMDJaFw0yODA0MDQxOTQwMDJaMIGAMQswCQYDVQQGEwJVUzETMBEGA1UECAwK
+Q2FsaWZvcm5pYTEWMBQGA1UEBwwNU2FuIEZyYW5jaXNjbzETMBEGA1UECgwKTWVz
+aHRhc3RpYzETMBEGA1UECwwKVEFLIFNlcnZlcjEaMBgGA1UEAwwRTWVzaHRhc3Rp
+YyBUQUsgQ0EwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQC2F6/n1CI2
+4dGtLt0irkfiU+PRmqkkuE7m49i7/FeH+38SEn9+0B4egW0kYRoRXmYdPzRsVttu
+23LZ3RLjwB6fFI3tiA27mxD58AuEMfwVR7J29oHqFwuVhuqDyjkNpUPFUomKwzvK
+SPJvoiHGkbQwWTMNP6T06tCg9llSE7SIgJWjzikQ+JsI37SqVGZ8K2evs7LTuyQh
+ssJfYVB7aE1kNNyi8YFHLoCWQMB7h8qJ3hRd7QGFG9gfWuNrWtim61iiHgBAPTRw
+gMn+YSIZiV9/iOytBKxFppNTxffEowF/iKBvgXwd9KHxYkk1Nvtcz5NJynSL75PT
+8B7XiHCGhcgzAgMBAAGjUzBRMB0GA1UdDgQWBBRRe/o9Raj93Fq22ArNSNrpsye3
+AzAfBgNVHSMEGDAWgBRRe/o9Raj93Fq22ArNSNrpsye3AzAPBgNVHRMBAf8EBTAD
+AQH/MA0GCSqGSIb3DQEBCwUAA4IBAQAsuSQ+j/1Bm7HbZWzN5qChH554vucWoqI0
+sVRHThvCASC6+wSosWZlx/Ag5KnRmBVsYA6CX5ztoF5keiSRy5G7qyRQVjITOq1o
+4XUAHBtGxKdRCEzS84GnsW9qeWX7t/xxf2fFr9gPZ7Z4nuyNg7QyX5FM01BtAlZC
+HbBhXvJyHRqJkMe7keYU7GmiAs1RZa+7593uEQ8DQ/kRvCzU0XswFSguJrd4Fnpi
+PGesGOk0NHFQY9pIu9oshgPgMA9dEWnhhvAF3PZ3sLRn9sSuslj5oumFsTYboByE
+aOKQshFe5xEX/4O7DI+wsD1Pt5gdT75nAuG7GEAIFKKGjQtUUYfH
+-----END CERTIFICATE-----
diff --git a/core/takserver/src/jvmAndroidMain/resources/tak_certs/client.p12 b/core/takserver/src/jvmAndroidMain/resources/tak_certs/client.p12
new file mode 100644
index 000000000..2f27bff2d
Binary files /dev/null and b/core/takserver/src/jvmAndroidMain/resources/tak_certs/client.p12 differ
diff --git a/core/takserver/src/jvmAndroidMain/resources/tak_certs/server.p12 b/core/takserver/src/jvmAndroidMain/resources/tak_certs/server.p12
new file mode 100644
index 000000000..88b9fcba5
Binary files /dev/null and b/core/takserver/src/jvmAndroidMain/resources/tak_certs/server.p12 differ
diff --git a/core/takserver/src/jvmAndroidMain/resources/tak_test_fixtures/aircraft_adsb.xml b/core/takserver/src/jvmAndroidMain/resources/tak_test_fixtures/aircraft_adsb.xml
new file mode 100644
index 000000000..a249883e2
--- /dev/null
+++ b/core/takserver/src/jvmAndroidMain/resources/tak_test_fixtures/aircraft_adsb.xml
@@ -0,0 +1,5 @@
+
+
+
+ <_radio rssi="-19.4" gps="true"/>000001 ICAO: 000001 REG: NTEST1 Flight: TST100 Type: A321 Squawk: 3456 DO-260B Category: A3 #adsbreceiver<_flow-tags_ TAK-Server-00000000000000000000000000000001="2026-03-15T17:45:00Z"/>
+
diff --git a/core/takserver/src/jvmAndroidMain/resources/tak_test_fixtures/aircraft_hostile.xml b/core/takserver/src/jvmAndroidMain/resources/tak_test_fixtures/aircraft_hostile.xml
new file mode 100644
index 000000000..226586fcb
--- /dev/null
+++ b/core/takserver/src/jvmAndroidMain/resources/tak_test_fixtures/aircraft_hostile.xml
@@ -0,0 +1,5 @@
+
+
+
+ TST200 NTEST2 000002 Cat:A6 Type:HAWK sim-host@example.test<_aircot_ flight="TST200" reg="NTEST2" cat="A6" icao="000002" cot_host_id="sim-host@example.test" type="HAWK"/><_flow-tags_ TAK-Server-00000000000000000000000000000001="2026-03-15T18:20:00Z"/>
+
diff --git a/core/takserver/src/jvmAndroidMain/resources/tak_test_fixtures/alert_tic.xml b/core/takserver/src/jvmAndroidMain/resources/tak_test_fixtures/alert_tic.xml
new file mode 100644
index 000000000..00fc76f12
--- /dev/null
+++ b/core/takserver/src/jvmAndroidMain/resources/tak_test_fixtures/alert_tic.xml
@@ -0,0 +1,8 @@
+
+
+
+
+
+ Troops in contact, requesting support at grid reference
+
+
diff --git a/core/takserver/src/jvmAndroidMain/resources/tak_test_fixtures/casevac.xml b/core/takserver/src/jvmAndroidMain/resources/tak_test_fixtures/casevac.xml
new file mode 100644
index 000000000..9bcbae011
--- /dev/null
+++ b/core/takserver/src/jvmAndroidMain/resources/tak_test_fixtures/casevac.xml
@@ -0,0 +1,10 @@
+
+
+
+
+
+
+ 2 urgent surgical, 1 priority. LZ marked with green smoke. No enemy activity.
+ <_flow-tags_ TAK-Server-00000000000000000000000000000001="2026-03-15T20:00:00Z"/>
+
+
diff --git a/core/takserver/src/jvmAndroidMain/resources/tak_test_fixtures/casevac_medline.xml b/core/takserver/src/jvmAndroidMain/resources/tak_test_fixtures/casevac_medline.xml
new file mode 100644
index 000000000..0c169f2b7
--- /dev/null
+++ b/core/takserver/src/jvmAndroidMain/resources/tak_test_fixtures/casevac_medline.xml
@@ -0,0 +1,10 @@
+
+
+
+
+
+ <_medevac_ precedence="Urgent" hoist="true" extraction_equipment="true" ventilator="false" blood="false" litter="2" ambulatory="1" security="N" hlz_marking="Smoke" zone_prot_marker="Green smoke" us_military="2" us_civilian="0" non_us_military="1" non_us_civilian="0" epw="0" child="0" terrain_slope="true" terrain_rough="false" terrain_loose="true" terrain_trees="false" terrain_wires="false" terrain_other="false" freq="38.90"/>
+
+
+
+
diff --git a/core/takserver/src/jvmAndroidMain/resources/tak_test_fixtures/chat_receipt_delivered.xml b/core/takserver/src/jvmAndroidMain/resources/tak_test_fixtures/chat_receipt_delivered.xml
new file mode 100644
index 000000000..d66d9c3cd
--- /dev/null
+++ b/core/takserver/src/jvmAndroidMain/resources/tak_test_fixtures/chat_receipt_delivered.xml
@@ -0,0 +1,9 @@
+
+
+
+
+
+
+
+
+
diff --git a/core/takserver/src/jvmAndroidMain/resources/tak_test_fixtures/chat_receipt_read.xml b/core/takserver/src/jvmAndroidMain/resources/tak_test_fixtures/chat_receipt_read.xml
new file mode 100644
index 000000000..86d8bfb90
--- /dev/null
+++ b/core/takserver/src/jvmAndroidMain/resources/tak_test_fixtures/chat_receipt_read.xml
@@ -0,0 +1,9 @@
+
+
+
+
+
+
+
+
+
diff --git a/core/takserver/src/jvmAndroidMain/resources/tak_test_fixtures/delete_event.xml b/core/takserver/src/jvmAndroidMain/resources/tak_test_fixtures/delete_event.xml
new file mode 100644
index 000000000..4c43a27ea
--- /dev/null
+++ b/core/takserver/src/jvmAndroidMain/resources/tak_test_fixtures/delete_event.xml
@@ -0,0 +1,5 @@
+
+
+
+ <_flow-tags_ TAK-Server-00000000000000000000000000000001="2026-03-15T19:30:00Z"/>
+
diff --git a/core/takserver/src/jvmAndroidMain/resources/tak_test_fixtures/drawing_circle.xml b/core/takserver/src/jvmAndroidMain/resources/tak_test_fixtures/drawing_circle.xml
new file mode 100644
index 000000000..a94353b0f
--- /dev/null
+++ b/core/takserver/src/jvmAndroidMain/resources/tak_test_fixtures/drawing_circle.xml
@@ -0,0 +1,25 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/core/takserver/src/jvmAndroidMain/resources/tak_test_fixtures/drawing_circle_large.xml b/core/takserver/src/jvmAndroidMain/resources/tak_test_fixtures/drawing_circle_large.xml
new file mode 100644
index 000000000..d155be57d
--- /dev/null
+++ b/core/takserver/src/jvmAndroidMain/resources/tak_test_fixtures/drawing_circle_large.xml
@@ -0,0 +1,15 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/core/takserver/src/jvmAndroidMain/resources/tak_test_fixtures/drawing_ellipse.xml b/core/takserver/src/jvmAndroidMain/resources/tak_test_fixtures/drawing_ellipse.xml
new file mode 100644
index 000000000..232877e83
--- /dev/null
+++ b/core/takserver/src/jvmAndroidMain/resources/tak_test_fixtures/drawing_ellipse.xml
@@ -0,0 +1,17 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/core/takserver/src/jvmAndroidMain/resources/tak_test_fixtures/drawing_freeform.xml b/core/takserver/src/jvmAndroidMain/resources/tak_test_fixtures/drawing_freeform.xml
new file mode 100644
index 000000000..9787f0741
--- /dev/null
+++ b/core/takserver/src/jvmAndroidMain/resources/tak_test_fixtures/drawing_freeform.xml
@@ -0,0 +1,19 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/core/takserver/src/jvmAndroidMain/resources/tak_test_fixtures/drawing_polygon.xml b/core/takserver/src/jvmAndroidMain/resources/tak_test_fixtures/drawing_polygon.xml
new file mode 100644
index 000000000..6efcd9ee0
--- /dev/null
+++ b/core/takserver/src/jvmAndroidMain/resources/tak_test_fixtures/drawing_polygon.xml
@@ -0,0 +1,19 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/core/takserver/src/jvmAndroidMain/resources/tak_test_fixtures/drawing_rectangle.xml b/core/takserver/src/jvmAndroidMain/resources/tak_test_fixtures/drawing_rectangle.xml
new file mode 100644
index 000000000..cb22fca8d
--- /dev/null
+++ b/core/takserver/src/jvmAndroidMain/resources/tak_test_fixtures/drawing_rectangle.xml
@@ -0,0 +1,19 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/core/takserver/src/jvmAndroidMain/resources/tak_test_fixtures/drawing_rectangle_itak.xml b/core/takserver/src/jvmAndroidMain/resources/tak_test_fixtures/drawing_rectangle_itak.xml
new file mode 100644
index 000000000..0197969d2
--- /dev/null
+++ b/core/takserver/src/jvmAndroidMain/resources/tak_test_fixtures/drawing_rectangle_itak.xml
@@ -0,0 +1,16 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/core/takserver/src/jvmAndroidMain/resources/tak_test_fixtures/drawing_telestration.xml b/core/takserver/src/jvmAndroidMain/resources/tak_test_fixtures/drawing_telestration.xml
new file mode 100644
index 000000000..ca9b1f22b
--- /dev/null
+++ b/core/takserver/src/jvmAndroidMain/resources/tak_test_fixtures/drawing_telestration.xml
@@ -0,0 +1,53 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/core/takserver/src/jvmAndroidMain/resources/tak_test_fixtures/emergency_911.xml b/core/takserver/src/jvmAndroidMain/resources/tak_test_fixtures/emergency_911.xml
new file mode 100644
index 000000000..88225bc63
--- /dev/null
+++ b/core/takserver/src/jvmAndroidMain/resources/tak_test_fixtures/emergency_911.xml
@@ -0,0 +1,10 @@
+
+
+
+
+
+
+
+
+
+
diff --git a/core/takserver/src/jvmAndroidMain/resources/tak_test_fixtures/emergency_cancel.xml b/core/takserver/src/jvmAndroidMain/resources/tak_test_fixtures/emergency_cancel.xml
new file mode 100644
index 000000000..6f4f0257d
--- /dev/null
+++ b/core/takserver/src/jvmAndroidMain/resources/tak_test_fixtures/emergency_cancel.xml
@@ -0,0 +1,11 @@
+
+
+
+
+
+
+
+
+
+
+
diff --git a/core/takserver/src/jvmAndroidMain/resources/tak_test_fixtures/geochat_broadcast.xml b/core/takserver/src/jvmAndroidMain/resources/tak_test_fixtures/geochat_broadcast.xml
new file mode 100644
index 000000000..30872f9ff
--- /dev/null
+++ b/core/takserver/src/jvmAndroidMain/resources/tak_test_fixtures/geochat_broadcast.xml
@@ -0,0 +1,12 @@
+
+
+
+
+ <__chat parent="RootContactGroup" groupOwner="false" messageId="a1b2c3d4" chatroom="All Chat Rooms" id="All Chat Rooms" senderCallsign="ETHEL">
+
+
+
+ <__serverdestination destinations="0.0.0.0:4242:tcp:ANDROID-0000000000000003"/>
+ at breach
+
+
diff --git a/core/takserver/src/jvmAndroidMain/resources/tak_test_fixtures/geochat_dm.xml b/core/takserver/src/jvmAndroidMain/resources/tak_test_fixtures/geochat_dm.xml
new file mode 100644
index 000000000..f3fcd1828
--- /dev/null
+++ b/core/takserver/src/jvmAndroidMain/resources/tak_test_fixtures/geochat_dm.xml
@@ -0,0 +1,12 @@
+
+
+
+
+ <__chat parent="RootContactGroup" groupOwner="false" messageId="e5f6a7b8" chatroom="ANDROID-0000000000000004" id="ANDROID-0000000000000004" senderCallsign="ETHEL">
+
+
+
+ <__serverdestination destinations="0.0.0.0:4242:tcp:ANDROID-0000000000000003"/>
+ cover by fire
+
+
diff --git a/core/takserver/src/jvmAndroidMain/resources/tak_test_fixtures/geochat_simple.xml b/core/takserver/src/jvmAndroidMain/resources/tak_test_fixtures/geochat_simple.xml
new file mode 100644
index 000000000..6fdbf123e
--- /dev/null
+++ b/core/takserver/src/jvmAndroidMain/resources/tak_test_fixtures/geochat_simple.xml
@@ -0,0 +1,12 @@
+
+
+
+
+ <__chat senderCallsign="TESTNODE-01" chatRoom="All Chat Rooms" id="All Chat Rooms" parent="RootContactGroup">
+
+
+
+ Roger that, moving to rally point
+ <__serverdestination destinations="0.0.0.0:4242:tcp:ANDROID-0000000000000002"/>
+
+
diff --git a/core/takserver/src/jvmAndroidMain/resources/tak_test_fixtures/marker_2525.xml b/core/takserver/src/jvmAndroidMain/resources/tak_test_fixtures/marker_2525.xml
new file mode 100644
index 000000000..da28fee7e
--- /dev/null
+++ b/core/takserver/src/jvmAndroidMain/resources/tak_test_fixtures/marker_2525.xml
@@ -0,0 +1,14 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/core/takserver/src/jvmAndroidMain/resources/tak_test_fixtures/marker_goto.xml b/core/takserver/src/jvmAndroidMain/resources/tak_test_fixtures/marker_goto.xml
new file mode 100644
index 000000000..7cc637331
--- /dev/null
+++ b/core/takserver/src/jvmAndroidMain/resources/tak_test_fixtures/marker_goto.xml
@@ -0,0 +1,12 @@
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/core/takserver/src/jvmAndroidMain/resources/tak_test_fixtures/marker_goto_itak.xml b/core/takserver/src/jvmAndroidMain/resources/tak_test_fixtures/marker_goto_itak.xml
new file mode 100644
index 000000000..e1d4548c3
--- /dev/null
+++ b/core/takserver/src/jvmAndroidMain/resources/tak_test_fixtures/marker_goto_itak.xml
@@ -0,0 +1,10 @@
+
+
+
+
+
+
+
+
+
+
diff --git a/core/takserver/src/jvmAndroidMain/resources/tak_test_fixtures/marker_icon_set.xml b/core/takserver/src/jvmAndroidMain/resources/tak_test_fixtures/marker_icon_set.xml
new file mode 100644
index 000000000..76739bd71
--- /dev/null
+++ b/core/takserver/src/jvmAndroidMain/resources/tak_test_fixtures/marker_icon_set.xml
@@ -0,0 +1,14 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/core/takserver/src/jvmAndroidMain/resources/tak_test_fixtures/marker_spot.xml b/core/takserver/src/jvmAndroidMain/resources/tak_test_fixtures/marker_spot.xml
new file mode 100644
index 000000000..2f3499c4b
--- /dev/null
+++ b/core/takserver/src/jvmAndroidMain/resources/tak_test_fixtures/marker_spot.xml
@@ -0,0 +1,14 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/core/takserver/src/jvmAndroidMain/resources/tak_test_fixtures/marker_tank.xml b/core/takserver/src/jvmAndroidMain/resources/tak_test_fixtures/marker_tank.xml
new file mode 100644
index 000000000..530ef51f6
--- /dev/null
+++ b/core/takserver/src/jvmAndroidMain/resources/tak_test_fixtures/marker_tank.xml
@@ -0,0 +1,14 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/core/takserver/src/jvmAndroidMain/resources/tak_test_fixtures/pli_basic.xml b/core/takserver/src/jvmAndroidMain/resources/tak_test_fixtures/pli_basic.xml
new file mode 100644
index 000000000..51435cf5a
--- /dev/null
+++ b/core/takserver/src/jvmAndroidMain/resources/tak_test_fixtures/pli_basic.xml
@@ -0,0 +1,5 @@
+
+
+
+ <_flow-tags_ TAK-Server-00000000000000000000000000000001="2026-03-15T14:22:10Z"/>
+
diff --git a/core/takserver/src/jvmAndroidMain/resources/tak_test_fixtures/pli_full.xml b/core/takserver/src/jvmAndroidMain/resources/tak_test_fixtures/pli_full.xml
new file mode 100644
index 000000000..9283cf94b
--- /dev/null
+++ b/core/takserver/src/jvmAndroidMain/resources/tak_test_fixtures/pli_full.xml
@@ -0,0 +1,5 @@
+
+
+
+ <__group role="Team Member" name="Cyan"/><_flow-tags_ TAK-Server-00000000000000000000000000000001="2026-03-15T15:30:00Z"/>
+
diff --git a/core/takserver/src/jvmAndroidMain/resources/tak_test_fixtures/pli_itak.xml b/core/takserver/src/jvmAndroidMain/resources/tak_test_fixtures/pli_itak.xml
new file mode 100644
index 000000000..cf86918d8
--- /dev/null
+++ b/core/takserver/src/jvmAndroidMain/resources/tak_test_fixtures/pli_itak.xml
@@ -0,0 +1,11 @@
+
+
+
+
+
+ <__group name="Cyan" role="Team Member"/>
+
+
+
+
+
diff --git a/core/takserver/src/jvmAndroidMain/resources/tak_test_fixtures/pli_stationary.xml b/core/takserver/src/jvmAndroidMain/resources/tak_test_fixtures/pli_stationary.xml
new file mode 100644
index 000000000..6b4da149b
--- /dev/null
+++ b/core/takserver/src/jvmAndroidMain/resources/tak_test_fixtures/pli_stationary.xml
@@ -0,0 +1,12 @@
+
+
+
+
+
+
+ <__group role="Team Member" name="Cyan"/>
+
+
+
+
+
diff --git a/core/takserver/src/jvmAndroidMain/resources/tak_test_fixtures/pli_takaware.xml b/core/takserver/src/jvmAndroidMain/resources/tak_test_fixtures/pli_takaware.xml
new file mode 100644
index 000000000..14992583a
--- /dev/null
+++ b/core/takserver/src/jvmAndroidMain/resources/tak_test_fixtures/pli_takaware.xml
@@ -0,0 +1,11 @@
+
+
+
+
+
+ <__group role="Team Member" name="Cyan"/>
+
+
+
+
+
diff --git a/core/takserver/src/jvmAndroidMain/resources/tak_test_fixtures/pli_webtak.xml b/core/takserver/src/jvmAndroidMain/resources/tak_test_fixtures/pli_webtak.xml
new file mode 100644
index 000000000..ea13b008b
--- /dev/null
+++ b/core/takserver/src/jvmAndroidMain/resources/tak_test_fixtures/pli_webtak.xml
@@ -0,0 +1,5 @@
+
+
+
+ <__group name="Cyan" role="Team Member"/><_flow-tags_ TAK-Server-00000000000000000000000000000001="2026-03-15T16:10:00Z"/>
+
diff --git a/core/takserver/src/jvmAndroidMain/resources/tak_test_fixtures/ranging_bullseye.xml b/core/takserver/src/jvmAndroidMain/resources/tak_test_fixtures/ranging_bullseye.xml
new file mode 100644
index 000000000..e23bf3fb6
--- /dev/null
+++ b/core/takserver/src/jvmAndroidMain/resources/tak_test_fixtures/ranging_bullseye.xml
@@ -0,0 +1,17 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/core/takserver/src/jvmAndroidMain/resources/tak_test_fixtures/ranging_circle.xml b/core/takserver/src/jvmAndroidMain/resources/tak_test_fixtures/ranging_circle.xml
new file mode 100644
index 000000000..544b33a7b
--- /dev/null
+++ b/core/takserver/src/jvmAndroidMain/resources/tak_test_fixtures/ranging_circle.xml
@@ -0,0 +1,17 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/core/takserver/src/jvmAndroidMain/resources/tak_test_fixtures/ranging_line.xml b/core/takserver/src/jvmAndroidMain/resources/tak_test_fixtures/ranging_line.xml
new file mode 100644
index 000000000..93d170565
--- /dev/null
+++ b/core/takserver/src/jvmAndroidMain/resources/tak_test_fixtures/ranging_line.xml
@@ -0,0 +1,14 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/core/takserver/src/jvmAndroidMain/resources/tak_test_fixtures/route_3wp.xml b/core/takserver/src/jvmAndroidMain/resources/tak_test_fixtures/route_3wp.xml
new file mode 100644
index 000000000..ede8bed8c
--- /dev/null
+++ b/core/takserver/src/jvmAndroidMain/resources/tak_test_fixtures/route_3wp.xml
@@ -0,0 +1,16 @@
+
+
+
+
+ <__routeinfo/>
+
+
+
+
+
+
+
+
+
+
+
diff --git a/core/takserver/src/jvmAndroidMain/resources/tak_test_fixtures/route_itak_3wp.xml b/core/takserver/src/jvmAndroidMain/resources/tak_test_fixtures/route_itak_3wp.xml
new file mode 100644
index 000000000..9be1f6169
--- /dev/null
+++ b/core/takserver/src/jvmAndroidMain/resources/tak_test_fixtures/route_itak_3wp.xml
@@ -0,0 +1,11 @@
+
+
+
+
+
+
+
+
+
+
+
diff --git a/core/takserver/src/jvmAndroidMain/resources/tak_test_fixtures/task_engage.xml b/core/takserver/src/jvmAndroidMain/resources/tak_test_fixtures/task_engage.xml
new file mode 100644
index 000000000..602ae5cbf
--- /dev/null
+++ b/core/takserver/src/jvmAndroidMain/resources/tak_test_fixtures/task_engage.xml
@@ -0,0 +1,10 @@
+
+
+
+
+
+
+
+
+
+
diff --git a/core/takserver/src/jvmAndroidMain/resources/tak_test_fixtures/waypoint.xml b/core/takserver/src/jvmAndroidMain/resources/tak_test_fixtures/waypoint.xml
new file mode 100644
index 000000000..3f41333cc
--- /dev/null
+++ b/core/takserver/src/jvmAndroidMain/resources/tak_test_fixtures/waypoint.xml
@@ -0,0 +1,12 @@
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/ModuleConfigurationScreen.kt b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/ModuleConfigurationScreen.kt
index faf2f792e..2563fca77 100644
--- a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/ModuleConfigurationScreen.kt
+++ b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/ModuleConfigurationScreen.kt
@@ -49,12 +49,13 @@ fun ModuleConfigurationScreen(
val state by viewModel.radioConfigState.collectAsStateWithLifecycle()
val destNode by viewModel.destNode.collectAsStateWithLifecycle()
+ val deviceRole = state.radioConfig.device?.role
val modules =
- remember(state.metadata, excludedModulesUnlocked) {
+ remember(state.metadata, deviceRole, excludedModulesUnlocked) {
if (excludedModulesUnlocked) {
ModuleRoute.entries
} else {
- ModuleRoute.filterExcludedFrom(state.metadata, state.userConfig.role)
+ ModuleRoute.filterExcludedFrom(state.metadata, deviceRole)
}
}
diff --git a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/navigation/SettingsNavigation.kt b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/navigation/SettingsNavigation.kt
index ac713ae7e..c60cfd1a8 100644
--- a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/navigation/SettingsNavigation.kt
+++ b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/navigation/SettingsNavigation.kt
@@ -64,6 +64,7 @@ import org.meshtastic.feature.settings.radio.component.SerialConfigScreen
import org.meshtastic.feature.settings.radio.component.StatusMessageConfigScreen
import org.meshtastic.feature.settings.radio.component.StoreForwardConfigScreen
import org.meshtastic.feature.settings.radio.component.TAKConfigScreen
+import org.meshtastic.feature.settings.radio.component.TakServerScreen
import org.meshtastic.feature.settings.radio.component.TelemetryConfigScreen
import org.meshtastic.feature.settings.radio.component.TrafficManagementConfigScreen
import org.meshtastic.feature.settings.radio.component.UserConfigScreen
@@ -185,6 +186,10 @@ fun EntryProviderScope.settingsGraph(backStack: NavBackStack) {
}
}
+ entry {
+ TakServerScreen(onBack = { backStack.removeLastOrNull() })
+ }
+
entry {
val viewModel: DebugViewModel = koinViewModel()
DebugScreen(viewModel = viewModel, onNavigateUp = { backStack.removeLastOrNull() })
diff --git a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/RadioConfig.kt b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/RadioConfig.kt
index 0ff5326fc..6700e2359 100644
--- a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/RadioConfig.kt
+++ b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/RadioConfig.kt
@@ -202,6 +202,13 @@ private fun AdvancedSection(isManaged: Boolean, isOtaCapable: Boolean, enabled:
onClick = { onNavigate(SettingsRoutes.CleanNodeDb) },
)
+ ListItem(
+ text = "TAK Server",
+ leadingIcon = Icons.Rounded.Settings,
+ enabled = enabled,
+ onClick = { onNavigate(SettingsRoutes.TakServer) },
+ )
+
ListItem(
text = stringResource(Res.string.debug_panel),
leadingIcon = Icons.Rounded.BugReport,
diff --git a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/component/TAKConfigItemList.kt b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/component/TAKConfigItemList.kt
index 714513e7d..46f01dee3 100644
--- a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/component/TAKConfigItemList.kt
+++ b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/component/TAKConfigItemList.kt
@@ -16,20 +16,42 @@
*/
package org.meshtastic.feature.settings.radio.component
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.automirrored.filled.ArrowBack
+import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.material.icons.filled.Share
+import androidx.compose.material3.Button
+import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.Scaffold
+import androidx.compose.material3.Text
+import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberCoroutineScope
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
+import kotlinx.coroutines.launch
import org.jetbrains.compose.resources.stringResource
import org.koin.compose.koinInject
+import org.meshtastic.core.common.BuildConfigProvider
import org.meshtastic.core.model.getColorFrom
import org.meshtastic.core.model.getStringResFrom
+import org.meshtastic.core.repository.CommandSender
import org.meshtastic.core.repository.TakPrefs
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.tak
@@ -39,27 +61,77 @@ import org.meshtastic.core.resources.tak_server_enabled
import org.meshtastic.core.resources.tak_server_enabled_desc
import org.meshtastic.core.resources.tak_team
import org.meshtastic.core.takserver.TAKDataPackageGenerator
+import org.meshtastic.core.takserver.TakMeshTestRunner
import org.meshtastic.core.ui.component.DropDownPreference
import org.meshtastic.core.ui.component.SwitchPreference
import org.meshtastic.core.ui.component.TitledCard
import org.meshtastic.feature.settings.radio.RadioConfigViewModel
+import org.meshtastic.feature.settings.radio.ResponseState
import org.meshtastic.feature.settings.tak.TakPermissionHandler
import org.meshtastic.feature.settings.tak.rememberDataPackageExporter
import org.meshtastic.proto.ModuleConfig
+// ── TAK Config Screen (Module Settings) ─────────────────────────────────────
+// Shows only the firmware module config: team and role dropdowns.
+
@Composable
fun TAKConfigScreen(viewModel: RadioConfigViewModel, onBack: () -> Unit) {
val state by viewModel.radioConfigState.collectAsStateWithLifecycle()
val takConfig = state.moduleConfig.tak ?: ModuleConfig.TAKConfig()
val formState = rememberConfigState(initialValue = takConfig)
+ LaunchedEffect(takConfig) { formState.value = takConfig }
+
+ val effectiveResponseState = when (state.responseState) {
+ is ResponseState.Loading -> ResponseState.Empty
+ else -> state.responseState
+ }
+
+ RadioConfigScreenList(
+ title = stringResource(Res.string.tak),
+ onBack = onBack,
+ configState = formState,
+ enabled = state.connected,
+ responseState = effectiveResponseState,
+ onDismissPacketResponse = viewModel::clearPacketResponse,
+ onSave = {
+ val config = ModuleConfig(tak = it)
+ viewModel.setModuleConfig(config)
+ },
+ ) {
+ item {
+ TitledCard(title = stringResource(Res.string.tak_config)) {
+ DropDownPreference(
+ title = stringResource(Res.string.tak_team),
+ enabled = state.connected,
+ selectedItem = formState.value.team,
+ itemLabel = { stringResource(getStringResFrom(it)) },
+ itemColor = { Color(getColorFrom(it)) },
+ onItemSelected = { formState.value = formState.value.copy(team = it) },
+ )
+ HorizontalDivider()
+ DropDownPreference(
+ title = stringResource(Res.string.tak_role),
+ enabled = state.connected,
+ selectedItem = formState.value.role,
+ itemLabel = { stringResource(getStringResFrom(it)) },
+ onItemSelected = { formState.value = formState.value.copy(role = it) },
+ )
+ }
+ }
+ }
+}
+
+// ── TAK Server Screen (Settings → Advanced) ─────────────────────────────────
+// App-local TAK server controls: enable/disable, export data package, debug test harness.
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun TakServerScreen(onBack: () -> Unit) {
val takPrefs: TakPrefs = koinInject()
val isTakServerEnabled by takPrefs.isTakServerEnabled.collectAsStateWithLifecycle()
-
val exportLauncher = rememberDataPackageExporter { TAKDataPackageGenerator.generateDataPackage() }
- LaunchedEffect(takConfig) { formState.value = takConfig }
-
TakPermissionHandler(
isTakServerEnabled = isTakServerEnabled,
onPermissionResult = { granted ->
@@ -69,65 +141,135 @@ fun TAKConfigScreen(viewModel: RadioConfigViewModel, onBack: () -> Unit) {
},
)
- RadioConfigScreenList(
- title = stringResource(Res.string.tak),
- onBack = onBack,
- actions = {
- IconButton(onClick = { exportLauncher("Meshtastic_TAK_Server.zip") }) {
- Icon(imageVector = Icons.Default.Share, contentDescription = "Export TAK Data Package")
- }
- },
- configState = formState,
- enabled = state.connected,
- responseState = state.responseState,
- onDismissPacketResponse = viewModel::clearPacketResponse,
- onSave = {
- val config = ModuleConfig(tak = it)
- viewModel.setModuleConfig(config)
- },
- ) {
- item {
- TAKConfigCard(
- formState = formState,
- isTakServerEnabled = isTakServerEnabled,
- isConnected = state.connected,
- onTakServerEnabledChange = { takPrefs.setTakServerEnabled(it) },
+ Scaffold(
+ topBar = {
+ TopAppBar(
+ title = { Text("TAK Server") },
+ navigationIcon = {
+ IconButton(onClick = onBack) {
+ Icon(
+ imageVector = Icons.AutoMirrored.Filled.ArrowBack,
+ contentDescription = "Back",
+ )
+ }
+ },
+ actions = {
+ if (isTakServerEnabled) {
+ IconButton(onClick = { exportLauncher("Meshtastic_TAK_Server.zip") }) {
+ Icon(imageVector = Icons.Default.Share, contentDescription = "Export TAK Data Package")
+ }
+ }
+ },
)
+ },
+ ) { padding ->
+ Column(modifier = Modifier.padding(padding).padding(horizontal = 16.dp)) {
+ TitledCard(title = "Server") {
+ SwitchPreference(
+ title = stringResource(Res.string.tak_server_enabled),
+ summary = stringResource(Res.string.tak_server_enabled_desc),
+ checked = isTakServerEnabled,
+ enabled = true,
+ onCheckedChange = { takPrefs.setTakServerEnabled(it) },
+ )
+ if (isTakServerEnabled) {
+ HorizontalDivider()
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(horizontal = 16.dp, vertical = 12.dp),
+ horizontalArrangement = Arrangement.SpaceBetween,
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Column(modifier = Modifier.weight(1f)) {
+ Text(
+ text = "Export Data Package",
+ style = MaterialTheme.typography.bodyLarge,
+ )
+ Text(
+ text = "Generate .zip for ATAK/iTAK to connect to this server",
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ }
+ IconButton(onClick = { exportLauncher("Meshtastic_TAK_Server.zip") }) {
+ Icon(imageVector = Icons.Default.Share, contentDescription = "Export TAK Data Package")
+ }
+ }
+ }
+ }
+
+ // Debug-only test harness
+ TakMeshTestCard()
}
}
}
+// ── Debug-only TAK Mesh Test Card ────────────────────────────────────────────
+
@Composable
-private fun TAKConfigCard(
- formState: ConfigState,
- isTakServerEnabled: Boolean,
- isConnected: Boolean,
- onTakServerEnabledChange: (Boolean) -> Unit,
-) {
- TitledCard(title = stringResource(Res.string.tak_config)) {
- SwitchPreference(
- title = stringResource(Res.string.tak_server_enabled),
- summary = stringResource(Res.string.tak_server_enabled_desc),
- checked = isTakServerEnabled,
- enabled = true,
- onCheckedChange = onTakServerEnabledChange,
- )
- HorizontalDivider()
- DropDownPreference(
- title = stringResource(Res.string.tak_team),
- enabled = isConnected,
- selectedItem = formState.value.team,
- itemLabel = { stringResource(getStringResFrom(it)) },
- itemColor = { Color(getColorFrom(it)) },
- onItemSelected = { formState.value = formState.value.copy(team = it) },
- )
- HorizontalDivider()
- DropDownPreference(
- title = stringResource(Res.string.tak_role),
- enabled = isConnected,
- selectedItem = formState.value.role,
- itemLabel = { stringResource(getStringResFrom(it)) },
- onItemSelected = { formState.value = formState.value.copy(role = it) },
- )
+private fun TakMeshTestCard() {
+ val buildConfig: BuildConfigProvider = koinInject()
+ if (!buildConfig.isDebug) return
+
+ val commandSender: CommandSender = koinInject()
+ val testRunner = remember { TakMeshTestRunner(commandSender) }
+ val results by testRunner.results.collectAsStateWithLifecycle()
+ val isRunning by testRunner.isRunning.collectAsStateWithLifecycle()
+ val currentFixture by testRunner.currentFixture.collectAsStateWithLifecycle()
+ val scope = rememberCoroutineScope()
+
+ val passed = results.count { it.passed }
+ val failed = results.count { !it.passed }
+
+ TitledCard(title = "TAK Mesh Test (Debug)") {
+ Row(
+ modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 12.dp),
+ horizontalArrangement = Arrangement.SpaceBetween,
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Column(modifier = Modifier.weight(1f)) {
+ Text(
+ text = if (isRunning) "Running: ${currentFixture ?: "..."}" else "Send all ${TakMeshTestRunner.FIXTURE_NAMES.size} test fixtures to mesh",
+ style = MaterialTheme.typography.bodyLarge,
+ )
+ if (results.isNotEmpty()) {
+ Text(
+ text = "$passed passed, $failed failed of ${results.size}/${TakMeshTestRunner.FIXTURE_NAMES.size}",
+ style = MaterialTheme.typography.bodySmall,
+ color = if (failed > 0) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ }
+ }
+ if (isRunning) {
+ CircularProgressIndicator()
+ } else {
+ Button(onClick = { scope.launch { testRunner.runAll() } }) {
+ Icon(imageVector = Icons.Default.PlayArrow, contentDescription = null)
+ Text("Run")
+ }
+ }
+ }
+
+ // Results list
+ for (result in results) {
+ HorizontalDivider()
+ Row(
+ modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 6.dp),
+ horizontalArrangement = Arrangement.SpaceBetween,
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Text(
+ text = result.fixtureName.removeSuffix(".xml"),
+ style = MaterialTheme.typography.bodySmall,
+ modifier = Modifier.weight(1f),
+ )
+ Text(
+ text = if (result.passed) "${result.compressedBytes}B ✓" else result.error ?: "✗",
+ style = MaterialTheme.typography.bodySmall,
+ color = if (result.passed) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.error,
+ )
+ }
+ }
}
}