Add TAK server configuration and test fixtures

This commit is contained in:
Ben Meadors 2026-04-15 21:11:56 -05:00
parent 9d5d516c37
commit 2d26621a35
92 changed files with 3837 additions and 2029 deletions

View file

@ -60,9 +60,9 @@
-->
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<!-- Only for debug log writing, disable for production
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
-->
<!-- Required for writing TAK route data packages to ATAK's auto-import directory -->
<uses-permission android:name="android.permission.MANAGE_EXTERNAL_STORAGE"
tools:ignore="ScopedStorage" />
<!-- We run our mesh code as a foreground service - FIXME, find a way to stop doing this -->
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />

View file

@ -191,7 +191,7 @@ class MeshDataHandlerImpl(
}
PortNum.ATAK_PLUGIN,
PortNum.ATAK_FORWARDER,
PortNum.ATAK_PLUGIN_V2,
PortNum.PRIVATE_APP,
-> {
shouldBroadcast = true

View file

@ -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())

View file

@ -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

@ -1 +1 @@
Subproject commit 10a86bf0b9c1fc9242363c17e4dfc54185967232
Subproject commit 9b123f392fab424db3f413243db2ea66f1880334

View file

@ -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()

View file

@ -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(

View file

@ -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.<clinit> throws
// UnsatisfiedLinkError and poisons TakV2Compressor permanently.
implementation("com.github.luben:zstd-jni:1.5.7-7@aar")
}
commonTest.dependencies {
implementation(projects.core.testing)

View file

@ -14,18 +14,19 @@
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.core.takserver.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
}

View file

@ -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 <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.core.takserver
/**
* Removes bloat elements from the `<detail>` 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 `<event>` 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):
* - `<color .../>` ARGB stroke/fill colors
* - `<strokeColor .../>`, `<strokeWeight .../>`, `<fillColor .../>` shape styling
* - `<labels_on .../>` label visibility toggle
* - `<usericon .../>` icon set path (`COT_MAPPING_2525B/...`)
* - `<model .../>` 3D model reference
*
* **Geometric detail** (we keep lat/lon on the event; shape primitives are too big):
* - `<shape>...</shape>` ellipse/polyline/polygon geometry
* - `<height .../>`, `<height_unit .../>` rendering hints
*
* **Resource references** (useless without the resource being reachable):
* - `<fileshare .../>` file transfer references
* - `<__video .../>` video stream URL
*
* **Flags and redundant metadata**:
* - `<archive/>` "save to archive" flag
* - `<precisionlocation .../>` redundant with the event's `<point>` attributes
* - `<tog .../>` 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. `<remarks>hello world</remarks>`) 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 `<name .../>` and the paired form `<name ...>...</name>`.
*/
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
* `<shape>...</shape>` 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<Regex> =
STRIPPED_ELEMENTS.map { name ->
// Escape the name in case it contains regex metacharacters (e.g. __video).
val escaped = Regex.escape(name)
// Matches:
// <name/>
// <name attr="..."/>
// <name attr='...'>...content...</name>
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 `<detail>` fragment.
*
* The input is assumed to be the concatenated children of `<detail>` i.e., what
* [CoTXmlParser.extractDetailInnerXml] returns. It is NOT the full `<event>` or
* the `<detail>` 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. <remarks>hello world</remarks>) because that whitespace
// isn't bracketed by '>' and '<'.
result = INTER_TAG_WHITESPACE.replace(result, "><")
result = EDGE_WHITESPACE.replace(result, "")
return result
}
}

View file

@ -20,10 +20,19 @@ package org.meshtastic.core.takserver
import kotlin.time.Instant
/**
* Serialize this [CoTMessage] to a single `<event>` XML element suitable for the CoT streaming
* TCP protocol used by ATAK / iTAK / WinTAK clients.
*
* **Important:** the output must NOT include an `<?xml ... ?>` declaration. The CoT stream
* protocol is a continuous sequence of `<event>` 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(
"<?xml version='1.0' encoding='UTF-8' standalone='yes'?><event version='2.0' uid='${uid.xmlEscaped()}' type='$type' time='${time.toXmlString()}' start='${start.toXmlString()}' stale='${stale.toXmlString()}' how='$how'><point lat='$latitude' lon='$longitude' hae='$hae' ce='$ce' le='$le'/><detail>",
"<event version='2.0' uid='${uid.xmlEscaped()}' type='$type' time='${time.toXmlString()}' start='${start.toXmlString()}' stale='${stale.toXmlString()}' how='$how'><point lat='$latitude' lon='$longitude' hae='$hae' ce='$ce' le='$le'/><detail>",
)
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"
}

View file

@ -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 `<detail>` and `</detail>` 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 `<detail/>` or when no detail element is present.
*/
private fun extractDetailInnerXml(xml: String): String? {
// Match `<detail ...>` (not `<detail/>`) through its matching close tag.
val openIdx = xml.indexOf("<detail")
if (openIdx < 0) return null
val openEnd = xml.indexOf('>', openIdx)
if (openEnd < 0) return null
// Self-closed tag like `<detail/>` has no content.
if (xml[openEnd - 1] == '/') return null
val closeIdx = xml.indexOf("</detail>", 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)

View file

@ -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 <https://www.gnu.org/licenses/>.
*/
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("""<event\s[^>]*\buid="([^"]*)"""")
private val CONTACT_CALLSIGN_RE = Regex("""<contact\s[^>]*\bcallsign="([^"]*)"""")
private val LINK_POINT_RE = Regex("""<link\s[^>]*\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 <link ... point="lat,lon,hae" .../> 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("""<?xml version="1.0" encoding="UTF-8"?>""")
appendLine("""<kml xmlns="http://www.opengis.net/kml/2.2">""")
appendLine(" <Document>")
appendLine(" <name>${name.xmlEscaped()}</name>")
appendLine(" <Placemark>")
appendLine(" <name>${name.xmlEscaped()}</name>")
appendLine(" <Style>")
appendLine(" <LineStyle><color>ff0000ff</color><width>3</width></LineStyle>")
appendLine(" </Style>")
appendLine(" <LineString>")
appendLine(" <coordinates>")
for (coord in waypoints) {
appendLine(" $coord")
}
appendLine(" </coordinates>")
appendLine(" </LineString>")
appendLine(" </Placemark>")
appendLine(" </Document>")
append("</kml>")
}
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<String, ByteArray>? {
val result = generateKml(routeXml) ?: return null
val kmlFileName = "${result.routeUid}.kml"
val zipFileName = "${result.routeUid}.zip"
val manifest = buildString {
appendLine("""<MissionPackageManifest version="2">""")
appendLine(" <Configuration>")
appendLine(""" <Parameter name="uid" value="Meshtastic Route.${result.routeUid}"/>""")
appendLine(""" <Parameter name="name" value="${result.routeName.xmlEscaped()}"/>""")
appendLine(""" <Parameter name="onReceiveDelete" value="true"/>""")
appendLine(" </Configuration>")
appendLine(" <Contents>")
appendLine(""" <Content ignore="false" zipEntry="$kmlFileName"/>""")
appendLine(" </Contents>")
append("</MissionPackageManifest>")
}
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("&", "&amp;")
.replace("<", "&lt;")
.replace(">", "&gt;")
.replace("\"", "&quot;")
}

View file

@ -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 <https://www.gnu.org/licenses/>.
*/
@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 =
"""
<TakControl>
<TakProtocolSupport version="0"/>
</TakControl>
"""
.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<String> — 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 =
"""
<TakControl>
<TakResponse status="true"/>
</TakControl>
"""
.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()) "<detail/>" else "<detail>$detail</detail>"
val point = """<point lat="0" lon="0" hae="0" ce="$TAK_UNKNOWN_POINT_VALUE" le="$TAK_UNKNOWN_POINT_VALUE"/>"""
return """<event version="2.0" uid="$uid" type="$type" time="$now" start="$now" stale="$stale" how="m-g">""" +
point +
detailContent +
"</event>"
}
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)
}
}
}

View file

@ -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<String, ByteArray>()
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("""<MissionPackageManifest version="2">""")
appendLine(" <Configuration>")
appendLine(""" <Parameter name="uid" value="${description.xmlEscaped()}.$uid"/>""")
@ -107,7 +194,30 @@ object TAKDataPackageGenerator {
appendLine(" </Configuration>")
appendLine(" <Contents>")
appendLine(""" <Content ignore="false" zipEntry="$PREF_FILE_NAME"/>""")
if (useTls) {
appendLine(""" <Content ignore="false" zipEntry="$TRUSTSTORE_FILE_NAME"/>""")
appendLine(""" <Content ignore="false" zipEntry="$CLIENT_P12_FILE_NAME"/>""")
}
appendLine(" </Contents>")
append("</MissionPackageManifest>")
}
}
/**
* 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
}

View file

@ -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: 23 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,

View file

@ -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<Job>()
@ -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<Job>
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 <remarks> 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 <event>...</event> on a single
// line, not a formatted XML document with <?xml ...?> prologue.
val xml = rawXml
.replace("""<?xml version="1.0" encoding="UTF-8"?>""", "")
.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("""<event\s[^>]*\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(
"""<takv[^>]*/>""", // TAK version (self-closing)
"""<takv[^>]*>.*?</takv>""", // TAK version (paired)
"""<voice[^>]*/>""", // voice chat state
"""<voice[^>]*>.*?</voice>""",
"""<marti[^>]*/>""", // empty marti
"""<marti[^>]*>.*?</marti>""",
"""<__geofence[^>]*/>""", // geofence config
"""<__geofence[^>]*>.*?</__geofence>""",
"""<tog[^>]*/>""", // toggle state
"""<archive[^>]*/>""", // archive marker
"""<__shapeExtras[^>]*/>""", // shape extras
"""<__shapeExtras[^>]*>.*?</__shapeExtras>""",
"""<creator[^>]*/>""", // creator info
"""<creator[^>]*>.*?</creator>""",
"""<remarks[^>]*/>""", // empty remarks (self-closing)
"""<remarks[^>]*></remarks>""", // empty remarks (paired)
"""<strokeStyle[^>]*/>""", // stroke style (SDK uses color fields)
"""<precisionlocation[^>]*/>""", // precision location metadata
"""<precisionlocation[^>]*>.*?</precisionlocation>""",
"""<precisionLocation[^>]*/>""", // iTAK camelCase variant
"""<precisionLocation[^>]*>.*?</precisionLocation>""",
).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 <link> elements
// with a point= attribute (route waypoints / shape vertices).
private val ROUTE_LINK_ELEM_RE = Regex("""<link\s[^>]*\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 <link> elements (receiver derives UIDs)
result = ROUTE_LINK_ELEM_RE.replace(result) { LINK_UID_ATTR_RE.replace(it.value, "") }
return result
}
}
}

View file

@ -43,6 +43,23 @@ data class CoTMessage(
val chat: CoTChat? = null,
val remarks: String? = null,
val rawDetailXml: String? = null,
/**
* Inner XML content of `<detail>...</detail>` 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 `<event>...</event>` 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()

View file

@ -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 <https://www.gnu.org/licenses/>.
*/
@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<String, String?> {
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()
}
}

View file

@ -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 <contact callsign="..."/> — 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 <detail> 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<String, String?> {
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
}

View file

@ -14,194 +14,60 @@
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
@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<String, TAKClientConnection>()
/** Observable count of currently-connected TAK clients (ATAK/iTAK). */
val connectionCount: StateFlow<Int>
private val _connectionCount = MutableStateFlow(0)
val connectionCount: StateFlow<Int> = _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<Unit> {
// 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<Unit>
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<TAKClientConnection>
// 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

View file

@ -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<Boolean>
val connectionCount: StateFlow<Int>
val inboundMessages: SharedFlow<CoTMessage>
val inboundMessages: SharedFlow<InboundCoTMessage>
/** 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<Boolean> = _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<Int> = takServer.connectionCount
private val _inboundMessages = MutableSharedFlow<CoTMessage>()
override val inboundMessages: SharedFlow<CoTMessage> = _inboundMessages.asSharedFlow()
private val _inboundMessages = MutableSharedFlow<InboundCoTMessage>()
override val inboundMessages: SharedFlow<InboundCoTMessage> = _inboundMessages.asSharedFlow()
private var lastBroadcastPositions = mutableMapOf<Int, Int>()
// 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<QueuedMessage>()
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) }
}
}
}
}

View file

@ -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<List<TakTestResult>>(emptyList())
val results: StateFlow<List<TakTestResult>> = _results.asStateFlow()
private val _isRunning = MutableStateFlow(false)
val isRunning: StateFlow<Boolean> = _isRunning.asStateFlow()
private val _currentFixture = MutableStateFlow<String?>(null)
val currentFixture: StateFlow<String?> = _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<TakTestResult>()
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()
}
}

View file

@ -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
}

View file

@ -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<String, CotType> = 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<CotType, String> =
stringToType.entries.associate { (k, v) -> v to k }
private val stringToHow: Map<String, CotHow> = 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<CotHow, String> =
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]
}

View file

@ -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,
)
}

View file

@ -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 <https://www.gnu.org/licenses/>.
*/
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<Int>,
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<FountainBlock>()
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<Int, FountainReceiveState>()
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<ByteArray> {
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<ByteArray>()
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<ByteArray> {
val blocks = mutableListOf<ByteArray>()
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<ByteArray, Int>? {
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<ByteArray, Int>? {
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<Int, ByteArray>()
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<FountainBlock>, decoded: MutableMap<Int, ByteArray>): Boolean {
var progress = false
for (i in workingBlocks.indices) {
val block = workingBlocks[i]
val toRemove = mutableListOf<Int>()
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<Int, ByteArray>): 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<Int> {
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<Int> {
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<Int> {
val indices = mutableSetOf<Int>()
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
}
}

View file

@ -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 <https://www.gnu.org/licenses/>.
*/
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<Int, PendingTransfer>()
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" }
}
}
}

View file

@ -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 <https://www.gnu.org/licenses/>.
*/
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 = """
<contact callsign="Alice"/>
<__group name="Cyan" role="Team Member"/>
<status battery="82"/>
<track speed="5.0" course="180.0"/>
""".trimIndent()
val stripped = CoTDetailStripper.strip(input)
assertTrue(stripped.contains("<contact"), "contact must be preserved")
assertTrue(stripped.contains("<__group"), "__group must be preserved")
assertTrue(stripped.contains("<status"), "status must be preserved")
assertTrue(stripped.contains("<track"), "track must be preserved")
}
@Test
fun strips_cosmetic_elements() {
val input = """
<contact callsign="Alice"/>
<color argb="-65536"/>
<strokeColor value="#ffffff"/>
<strokeWeight value="3"/>
<fillColor value="#000000"/>
<labels_on value="false"/>
<usericon iconsetpath="COT_MAPPING_2525B/a-u-G"/>
<model path="foo.obj"/>
""".trimIndent()
val stripped = CoTDetailStripper.strip(input)
assertTrue(stripped.contains("<contact"), "contact must survive")
assertFalse(stripped.contains("<color"), "color must be stripped")
assertFalse(stripped.contains("<strokeColor"), "strokeColor must be stripped")
assertFalse(stripped.contains("<strokeWeight"), "strokeWeight must be stripped")
assertFalse(stripped.contains("<fillColor"), "fillColor must be stripped")
assertFalse(stripped.contains("<labels_on"), "labels_on must be stripped")
assertFalse(stripped.contains("<usericon"), "usericon must be stripped")
assertFalse(stripped.contains("<model"), "model must be stripped")
}
@Test
fun strips_geometric_detail_including_nested_content() {
// <shape> is the biggest single bloat contributor for u-d-c-c events — it
// contains an <ellipse> and usually a <link> styling child. Make sure the
// entire subtree goes, not just the opening tag.
val input = """
<contact callsign="Alice"/>
<shape>
<ellipse major="500" minor="500" angle="0"/>
<link line="#ff0000" width="3"/>
</shape>
<height value="100"/>
<height_unit value="m"/>
""".trimIndent()
val stripped = CoTDetailStripper.strip(input)
assertTrue(stripped.contains("<contact"), "contact must survive")
assertFalse(stripped.contains("shape"), "shape subtree must be stripped: $stripped")
assertFalse(stripped.contains("ellipse"), "ellipse must be stripped with its parent")
// Note: <link> inside <shape> is also gone because we strip the whole subtree.
assertFalse(stripped.contains("<height"), "height must be stripped")
}
@Test
fun strips_resource_references_and_flags() {
val input = """
<contact callsign="Alice"/>
<archive/>
<precisionlocation altsrc="GPS" geopointsrc="GPS"/>
<fileshare filename="foo.zip" senderUrl="http://example.com/foo.zip"/>
<__video url="rtsp://example.com/stream"/>
""".trimIndent()
val stripped = CoTDetailStripper.strip(input)
assertTrue(stripped.contains("<contact"), "contact must survive")
assertFalse(stripped.contains("<archive"), "archive must be stripped")
assertFalse(stripped.contains("<precisionlocation"), "precisionlocation must be stripped")
assertFalse(stripped.contains("<fileshare"), "fileshare must be stripped")
assertFalse(stripped.contains("<__video"), "__video must be stripped")
}
@Test
fun preserves_chat_related_elements() {
// These are all critical for GeoChat round-tripping and must survive stripping.
val input = """
<__chat parent="RootContactGroup" groupOwner="false" messageId="abc" chatroom="All Chat Rooms" id="All Chat Rooms" senderCallsign="Alice">
<chatgrp uid0="abc-123" uid1="All Chat Rooms" id="All Chat Rooms"/>
</__chat>
<link uid="abc-123" type="a-f-G-U-C" relation="p-p"/>
<__serverdestination destinations="0.0.0.0:4242:tcp:abc-123"/>
<remarks source="BAO.F.ATAK.abc-123" to="All Chat Rooms" time="2025-01-01T12:00:00.000Z">hello world</remarks>
""".trimIndent()
val stripped = CoTDetailStripper.strip(input)
assertTrue(stripped.contains("<__chat"), "__chat must survive stripping")
assertTrue(stripped.contains("<chatgrp"), "chatgrp must survive stripping")
assertTrue(stripped.contains("<link"), "link must survive stripping")
assertTrue(stripped.contains("<__serverdestination"), "__serverdestination must survive")
assertTrue(stripped.contains("<remarks"), "remarks must survive")
assertTrue(stripped.contains("hello world"), "remarks text content must survive")
}
@Test
fun collapses_inter_element_whitespace() {
val input = """
<contact callsign="Alice"/>
<status battery="82"/>
""".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 = """
<contact callsign="Alice"/>
<color argb="-65536"/>
<__group name="Cyan" role="Team Member"/>
<shape><ellipse major="500" minor="500" angle="0"/></shape>
<status battery="82"/>
<labels_on value="false"/>
<track speed="5.0" course="180.0"/>
""".trimIndent()
val stripped = CoTDetailStripper.strip(input)
// All four keep-elements survive in order.
val contactIdx = stripped.indexOf("<contact")
val groupIdx = stripped.indexOf("<__group")
val statusIdx = stripped.indexOf("<status")
val trackIdx = stripped.indexOf("<track")
assertTrue(contactIdx >= 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() {
// <tog> 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 = """
<contact callsign="Alice"/>
<tog enabled="0"/>
<_flow-tags_ marti1="2014-10-28T22:40:15.341Z"/>
""".trimIndent()
val stripped = CoTDetailStripper.strip(input)
assertTrue(stripped.contains("<contact"), "contact must survive")
assertFalse(stripped.contains("<tog"), "tog must be stripped: $stripped")
assertFalse(stripped.contains("_flow-tags_"), "_flow-tags_ must be stripped: $stripped")
}
@Test
fun real_world_u_d_c_c_event_shrinks_dramatically() {
// Synthetic reproduction of what ATAK actually emits for a drawn circle —
// this is the 800-byte payload the user's logs were choking on.
val realistic =
"""<contact callsign='ALPHA01'/><__group name='Cyan' role='Team Member'/>""" +
"""<status battery='85'/><precisionlocation altsrc='GPS' geopointsrc='GPS'/>""" +
"""<shape><ellipse major='500' minor='500' angle='0'/><link line='#ff0000' width='3'/></shape>""" +
"""<color argb='-65536'/><labels_on value='false'/><archive/>""" +
"""<usericon iconsetpath='COT_MAPPING_2525B/a-u-G/a-u-G-U-C-I-M/a-u-G-U-C-I-M-N-S'/>""" +
"""<strokeColor value='-65536'/><strokeWeight value='3'/><fillColor value='1157562368'/>""" +
"""<height value='100'/><height_unit value='m'/>""" +
"""<fileshare filename='overlay.kml' senderUrl='http://10.0.0.1/overlay.kml' sizeInBytes='2048' sha256='deadbeef'/>""" +
"""<__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("<contact"), "contact must survive")
assertTrue(stripped.contains("<__group"), "__group must survive")
assertTrue(stripped.contains("<status"), "status must survive")
assertFalse(stripped.contains("shape"), "shape must be gone")
assertFalse(stripped.contains("fileshare"), "fileshare must be gone")
}
}

View file

@ -86,4 +86,77 @@ class CoTXmlParserTest {
assertEquals("a-f-G-U-C", message.type)
assertEquals("m-g", message.how)
}
@Test
fun `parsedDetailXml preserves structural elements from unmapped types`() {
// Simulates ATAK emitting a user-drawn circle (u-d-c-c) — parsedDetailXml keeps
// contact/group/status (sent by the receiver's structured fields too, but
// preserved here for raw_detail fallback fidelity). The <shape>, <labels_on>,
// and <color> bloat is stripped by CoTDetailStripper so the packet has any
// chance of fitting in a LoRa MTU.
val shapeXml =
"""
<event version="2.0" uid="circle-1" type="u-d-c-c" time="2025-01-01T12:00:00Z" start="2025-01-01T12:00:00Z" stale="2025-01-01T12:05:00Z" how="h-e">
<point lat="45.0" lon="-90.0" hae="0" ce="10.0" le="10.0"/>
<detail>
<contact callsign="TestUser"/>
<shape>
<ellipse major="500" minor="500" angle="0"/>
<link line="#ff0000" width="3"/>
</shape>
<labels_on value="false"/>
<color argb="-65536"/>
</detail>
</event>
"""
.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("<contact"), "contact must survive stripping")
// Stripped: see CoTDetailStripper for the full list.
assertTrue(!detail.contains("<shape"), "shape must be stripped from parsedDetailXml")
assertTrue(!detail.contains("<labels_on"), "labels_on must be stripped")
assertTrue(!detail.contains("<color"), "color must be stripped")
}
@Test
fun `sourceEventXml captures the complete original event verbatim`() {
val xml =
"""
<event version="2.0" uid="circle-1" type="u-d-c-c" time="2025-01-01T12:00:00Z" start="2025-01-01T12:00:00Z" stale="2025-01-01T12:05:00Z" how="h-e">
<point lat="45.0" lon="-90.0" hae="0" ce="10.0" le="10.0"/>
<detail>
<shape><ellipse major="500" minor="500" angle="0"/></shape>
</detail>
</event>
"""
.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("<shape>"), "sourceEventXml must be verbatim")
}
@Test
fun `parsedDetailXml is null for self-closed detail element`() {
val xml =
"""
<event version="2.0" uid="x" type="a-f-G-U-C" time="2025-01-01T12:00:00Z" start="2025-01-01T12:00:00Z" stale="2025-01-01T12:05:00Z" how="m-g">
<point lat="0.0" lon="0.0" hae="0" ce="0" le="0"/>
<detail/>
</event>
"""
.trimIndent()
val message = CoTXmlParser(xml).parse().getOrNull()!!
assertEquals(null, message.parsedDetailXml)
}
}

View file

@ -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 <event> elements
// with NO XML declaration. A mid-stream <?xml ... ?> 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("<?xml"), "XML should start with declaration")
val xml = message.toXml()
assertTrue(xml.startsWith("<event"), "XML should start with <event, not a declaration; got: $xml")
assertTrue(!xml.contains("<?xml"), "XML should NOT contain a declaration; got: $xml")
}
@Test

View file

@ -1,155 +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 <https://www.gnu.org/licenses/>.
*/
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)
}
}

View file

@ -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 <https://www.gnu.org/licenses/>.
*/
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: `<shape>` / `<labels_on>` / `<color>` 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 `<contact>` here so
// we have something non-trivial to verify round-tripped.
val shapeXml = """
<event version="2.0" uid="circle-abc" type="u-d-c-c" time="2025-01-01T12:00:00.000Z" start="2025-01-01T12:00:00.000Z" stale="2025-01-01T13:00:00.000Z" how="h-e">
<point lat="45.5" lon="-90.25" hae="0" ce="10.0" le="10.0"/>
<detail>
<contact callsign="ALPHA01"/>
<shape>
<ellipse major="500" minor="500" angle="0"/>
<link line="#ff0000" width="3"/>
</shape>
<labels_on value="false"/>
</detail>
</event>
""".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("<shape"), "shape must not reappear on receive: $xmlOut")
assertFalse(xmlOut.contains("<labels_on"), "labels_on must not reappear: $xmlOut")
}
@Test
fun raw_detail_path_emits_only_the_raw_bytes_inside_detail_no_duplicate_structured_elements() {
// If toCoTMessage populated contact/group/status on the raw_detail path, toXml would
// double-emit them alongside the rawDetailXml content. Guard against that regression.
val xml = """
<event version="2.0" uid="marker-1" type="b-m-p-s-p-i" time="2025-01-01T12:00:00.000Z" start="2025-01-01T12:00:00.000Z" stale="2025-01-01T13:00:00.000Z" how="h-e">
<point lat="10.0" lon="20.0" hae="0" ce="0" le="0"/>
<detail>
<contact callsign="DROP-1"/>
<__group name="Red" role="Team Member"/>
<color argb="-65536"/>
</detail>
</event>
""".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 <contact> (from the round-tripped raw detail), not two.
assertEquals(1, xmlOut.split("<contact").size - 1, "only one contact element allowed: $xmlOut")
assertEquals(1, xmlOut.split("<__group").size - 1, "only one group element allowed: $xmlOut")
}
@Test
fun CoTMessage_without_parsed_detail_returns_null() {
// CoTMessage created in-app (no XML round trip) for an unmapped type has no parsed
// detail to fall back on — conversion should return null.
val cot = CoTMessage(
uid = "manual-1",
type = "u-d-c-c",
stale = kotlin.time.Clock.System.now() + kotlin.time.Duration.parse("1h"),
latitude = 0.0,
longitude = 0.0,
)
assertNull(cot.toTAKPacketV2(), "no parsed detail → no raw_detail fallback possible")
}
}

View file

@ -1,115 +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 <https://www.gnu.org/licenses/>.
*/
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<ByteArray, Int>? = 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<ByteArray, Int>? = 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")
}
}

View file

@ -14,14 +14,9 @@
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.core.takserver.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
}

View file

@ -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 <https://www.gnu.org/licenses/>.
*/
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<Int> = _connectionCount.asStateFlow()
override var onMessage: ((CoTMessage) -> Unit)? = null
override suspend fun start(scope: CoroutineScope): Result<Unit> = 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()

View file

@ -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)
}
}

View file

@ -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 <https://www.gnu.org/licenses/>.
*/
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<platform.zlib.uLongVar>()
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<platform.zlib.uLongVar>()
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)
}
}

View file

@ -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 <https://www.gnu.org/licenses/>.
*/
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
}
}

View file

@ -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 <https://www.gnu.org/licenses/>.
*/
@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 `<event>` 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 =
"""
<TakControl>
<TakProtocolSupport version="0"/>
</TakControl>
"""
.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 =
"""
<TakControl>
<TakResponse status="true"/>
</TakControl>
"""
.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()) "<detail/>" else "<detail>$detail</detail>"
val point = """<point lat="0" lon="0" hae="0" ce="$TAK_UNKNOWN_POINT_VALUE" le="$TAK_UNKNOWN_POINT_VALUE"/>"""
return """<event version="2.0" uid="$uid" type="$type" time="$now" start="$now" stale="$stale" how="m-g">""" +
point +
detailContent +
"</event>"
}
/** 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() }
}
}
}

View file

@ -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 <https://www.gnu.org/licenses/>.
*/
@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<String, TAKClientConnection>()
private val _connectionCount = MutableStateFlow(0)
override val connectionCount: StateFlow<Int> = _connectionCount.asStateFlow()
override var onMessage: ((CoTMessage, TAKClientInfo?) -> Unit)? = null
override var onClientConnected: (() -> Unit)? = null
override suspend fun start(scope: CoroutineScope): Result<Unit> {
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<TAKClientConnection>
// 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()
}

View file

@ -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 <https://www.gnu.org/licenses/>.
*/
@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<X509Certificate> {
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<X509Certificate>()
}
}
}

View file

@ -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.
*/

View file

@ -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 <https://www.gnu.org/licenses/>.
*/
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)
}
}

View file

@ -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-----

View file

@ -0,0 +1,5 @@
<?xml version="1.0" encoding="UTF-8"?>
<event version="2.0" uid="ICAO000001" type="a-n-A-C-F" how="m-g" time="2026-03-15T17:45:00Z" start="2026-03-15T17:45:00Z" stale="2026-03-15T17:45:45Z">
<point lat="15.00000" lon="160.00000" hae="3048" ce="9999999" le="9999999"/>
<detail><contact callsign="TST100-NTEST1-A3"/><track speed="223.58" course="76.16"/><UID Droid="TST100-NTEST1-A3"/><_radio rssi="-19.4" gps="true"/><link uid="ANDROID-0000000000000003" type="a-f-G-U" relation="p-p"/><remarks>000001 ICAO: 000001 REG: NTEST1 Flight: TST100 Type: A321 Squawk: 3456 DO-260B Category: A3 #adsbreceiver</remarks><_flow-tags_ TAK-Server-00000000000000000000000000000001="2026-03-15T17:45:00Z"/></detail>
</event>

View file

@ -0,0 +1,5 @@
<?xml version="1.0" encoding="UTF-8"?>
<event version="2.0" uid="ICAO-000002" type="a-h-A-M-F-F" how="m-g" time="2026-03-15T18:20:00Z" start="2026-03-15T18:20:00Z" stale="2026-03-15T18:20:48Z">
<point lat="15.00000" lon="160.00200" hae="10000" ce="9999999" le="9999999"/>
<detail><contact callsign="TST200-NTEST2-HAWK"/><remarks>TST200 NTEST2 000002 Cat:A6 Type:HAWK sim-host@example.test</remarks><_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"/></detail>
</event>

View file

@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<event version="2.0" uid="alert-b3c4d5e6" type="b-a-o-opn" how="h-e" time="2026-03-15T20:30:00Z" start="2026-03-15T20:30:00Z" stale="2026-03-15T20:35:00Z">
<point lat="18.00000" lon="140.00000" hae="150" ce="15" le="15"/>
<detail>
<contact callsign="ALPHA-6"/>
<remarks>Troops in contact, requesting support at grid reference</remarks>
</detail>
</event>

View file

@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<event version="2.0" uid="casevac-f7e6d5c4" type="b-r-f-h-c" how="h-e" time="2026-03-15T20:00:00Z" start="2026-03-15T20:00:00Z" stale="2026-03-15T20:10:00Z">
<point lat="18.00000" lon="141.00000" hae="100" ce="10" le="10"/>
<detail>
<contact callsign="CASEVAC-1"/>
<link uid="ANDROID-0000000000000002" relation="p-p" type="a-f-G-U-C"/>
<remarks>2 urgent surgical, 1 priority. LZ marked with green smoke. No enemy activity.</remarks>
<_flow-tags_ TAK-Server-00000000000000000000000000000001="2026-03-15T20:00:00Z"/>
</detail>
</event>

View file

@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<event version="2.0" uid="medevac-01" type="b-r-f-h-c" time="2026-03-15T20:00:00Z" start="2026-03-15T20:00:00Z" stale="2026-03-15T20:10:00Z" how="h-e">
<point lat="17.99800" lon="140.00150" hae="100" ce="10" le="10"/>
<detail>
<contact callsign="Casevac-1"/>
<_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"/>
<remarks/>
<archive/>
</detail>
</event>

View file

@ -0,0 +1,9 @@
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<event version="2.0" uid="receipt-d-01" type="b-t-f-d" time="2026-03-15T19:00:30Z" start="2026-03-15T19:00:30Z" stale="2026-03-15T19:01:30Z" how="h-g-i-g-o">
<point lat="12.00000" lon="90.00000" hae="-22" ce="9999999" le="9999999"/>
<detail>
<contact callsign="TESTNODE-02"/>
<link uid="GeoChat.ANDROID-0000000000000002.All Chat Rooms.d4e5f6a7" relation="p-p" type="b-t-f"/>
<remarks/>
</detail>
</event>

View file

@ -0,0 +1,9 @@
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<event version="2.0" uid="receipt-r-01" type="b-t-f-r" time="2026-03-15T19:01:00Z" start="2026-03-15T19:01:00Z" stale="2026-03-15T19:02:00Z" how="h-g-i-g-o">
<point lat="12.00000" lon="90.00000" hae="-22" ce="9999999" le="9999999"/>
<detail>
<contact callsign="TESTNODE-02"/>
<link uid="GeoChat.ANDROID-0000000000000002.All Chat Rooms.d4e5f6a7" relation="p-p" type="b-t-f"/>
<remarks/>
</detail>
</event>

View file

@ -0,0 +1,5 @@
<?xml version="1.0" encoding="UTF-8"?>
<event version="2.0" uid="a1b2c3d4-e5f6-7a8b-9c0d-e1f2a3b4c5d6" type="t-x-d-d" how="h-g-i-g-o" time="2026-03-15T19:30:00Z" start="2026-03-15T19:30:00Z" stale="2026-03-15T19:30:20Z">
<point lat="0" lon="0" hae="0" ce="9999999" le="9999999"/>
<detail><link relation="p-p" uid="d7e8f9a0-1b2c-3d4e-5f6a-7b8c9d0e1f2a" type="a-f-G-U-C-I"/><_flow-tags_ TAK-Server-00000000000000000000000000000001="2026-03-15T19:30:00Z"/></detail>
</event>

View file

@ -0,0 +1,25 @@
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<event version="2.0" uid="6d09b6f6-720a-4eef-a197-183012512316" type="u-d-c-c" time="2026-03-15T14:22:10Z" start="2026-03-15T14:22:10Z" stale="2026-03-16T14:22:10Z" how="h-e">
<point lat="33.12840" lon="-107.25280" hae="9999999.0" ce="9999999.0" le="9999999.0"/>
<detail>
<shape>
<ellipse major="226.98" minor="226.98" angle="360"/>
<link uid="6d09b6f6-720a-4eef-a197-183012512316.Style" type="b-x-KmlStyle" relation="p-c">
<Style>
<LineStyle>
<color>ffffffff</color>
<width>4.0</width>
</LineStyle>
</Style>
</link>
</shape>
<strokeColor value="-1"/>
<strokeWeight value="4.0"/>
<fillColor value="-1761607681"/>
<contact callsign="Drawing Circle 1"/>
<remarks/>
<archive/>
<labels_on value="true"/>
<precisionlocation altsrc="???"/>
</detail>
</event>

View file

@ -0,0 +1,15 @@
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<event version="2.0" uid="67ebaf59-a216-4b0c-bd24-9ae5ee4d65e6" type="u-d-c-c" time="2026-03-15T14:22:10Z" start="2026-03-15T14:22:10Z" stale="2026-03-16T14:22:10Z" how="h-e">
<point lat="33.12840" lon="-107.25280" hae="9999999.0" ce="9999999.0" le="9999999.0"/>
<detail>
<shape>
<ellipse major="393.14" minor="393.14" angle="360"/>
</shape>
<strokeColor value="-48571"/>
<strokeWeight value="3.0"/>
<fillColor value="0"/>
<contact callsign="Shape 324"/>
<labels_on value="false"/>
<uid Droid="Shape 324"/>
</detail>
</event>

View file

@ -0,0 +1,17 @@
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<event version="2.0" uid="ellipse-01" type="u-d-c-e" time="2026-03-15T14:22:10Z" start="2026-03-15T14:22:10Z" stale="2026-03-16T14:22:10Z" how="h-e">
<point lat="33.12840" lon="-107.25280" hae="9999999.0" ce="9999999.0" le="9999999.0"/>
<detail>
<shape>
<ellipse major="250.0" minor="125.0" angle="45"/>
</shape>
<strokeColor value="-65536"/>
<strokeWeight value="3.0"/>
<fillColor value="-1761607681"/>
<contact callsign="Ellipse 1"/>
<remarks/>
<archive/>
<labels_on value="false"/>
<precisionlocation altsrc="???"/>
</detail>
</event>

View file

@ -0,0 +1,19 @@
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<event version="2.0" uid="b112202e-dd33-4fc7-8d3d-09a14e296011" type="u-d-f" time="2026-03-15T14:22:10Z" start="2026-03-15T14:22:10Z" stale="2026-03-16T14:22:10Z" how="h-e">
<point lat="33.12840" lon="-107.25280" hae="9999999.0" ce="9999999.0" le="9999999.0"/>
<detail>
<link point="33.12852,-107.25265"/>
<link point="33.12882,-107.25236"/>
<link point="33.12902,-107.25183"/>
<link point="33.12882,-107.25134"/>
<link point="33.12832,-107.25148"/>
<link point="33.12803,-107.25209"/>
<strokeColor value="-65536"/>
<strokeWeight value="3.0"/>
<contact callsign="Freeform 1"/>
<remarks/>
<archive/>
<labels_on value="false"/>
<precisionlocation altsrc="???"/>
</detail>
</event>

View file

@ -0,0 +1,19 @@
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<event version="2.0" uid="c9e8b7a6-5d4c-4a3b-9e2f-018374659821" type="u-d-p" time="2026-03-15T14:22:10Z" start="2026-03-15T14:22:10Z" stale="2026-03-16T14:22:10Z" how="h-e">
<point lat="33.12840" lon="-107.25280" hae="9999999.0" ce="9999999.0" le="9999999.0"/>
<detail>
<link point="33.12872,-107.25292"/>
<link point="33.12889,-107.25230"/>
<link point="33.12838,-107.25182"/>
<link point="33.12785,-107.25246"/>
<link point="33.12813,-107.25308"/>
<strokeColor value="-16711936"/>
<strokeWeight value="3.0"/>
<fillColor value="1090486528"/>
<contact callsign="Polygon 1"/>
<remarks/>
<archive/>
<labels_on value="true"/>
<precisionlocation altsrc="???"/>
</detail>
</event>

View file

@ -0,0 +1,19 @@
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<event version="2.0" uid="f48ad69d-31de-4089-bbf0-6533cbb1aa77" type="u-d-r" time="2026-03-15T14:22:10Z" start="2026-03-15T14:22:10Z" stale="2026-03-16T14:22:10Z" how="h-e">
<point lat="33.12840" lon="-107.25280" hae="9999999.0" ce="9999999.0" le="9999999.0"/>
<detail>
<link point="33.12952,-107.25352"/>
<link point="33.12946,-107.25193"/>
<link point="33.12727,-107.25208"/>
<link point="33.12734,-107.25367"/>
<strokeColor value="-1"/>
<strokeWeight value="3.0"/>
<fillColor value="-1761607681"/>
<contact callsign="Rectangle 1"/>
<tog enabled="0"/>
<remarks/>
<archive/>
<labels_on value="false"/>
<precisionlocation altsrc="???"/>
</detail>
</event>

View file

@ -0,0 +1,16 @@
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<event version="2.0" uid="5f839e4c-0d95-4c5f-85a9-c8b4f914bc10" type="u-d-r" time="2026-03-15T14:22:10Z" start="2026-03-15T14:22:10Z" stale="2026-03-16T14:22:10Z" how="h-e">
<point lat="33.12840" lon="-107.25280" hae="9999999.0" ce="9999999.0" le="9999999.0"/>
<detail>
<contact callsign="iPad.RectangleShape.1"/>
<link point="33.12940, -107.25380"/>
<link point="33.12940, -107.25180"/>
<link point="33.12740, -107.25180"/>
<link point="33.12740, -107.25380"/>
<strokeColor value="-9601793"/>
<fillColor value="2137881855"/>
<strokeWeight value="1.0"/>
<labels_on value="false"/>
<precisionLocation altsrc="???" geopointsrc="???"/>
</detail>
</event>

View file

@ -0,0 +1,53 @@
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<event version="2.0" uid="tele-01" type="u-d-f-m" time="2026-03-15T14:22:10Z" start="2026-03-15T14:22:10Z" stale="2026-03-16T14:22:10Z" how="h-e">
<point lat="33.12840" lon="-107.25280" hae="9999999.0" ce="9999999.0" le="9999999.0"/>
<detail>
<link point="33.12840,-107.25130"/>
<link point="33.12908,-107.25137"/>
<link point="33.12961,-107.25159"/>
<link point="33.12988,-107.25192"/>
<link point="33.12983,-107.25234"/>
<link point="33.12946,-107.25280"/>
<link point="33.12886,-107.25326"/>
<link point="33.12817,-107.25368"/>
<link point="33.12752,-107.25401"/>
<link point="33.12706,-107.25423"/>
<link point="33.12690,-107.25430"/>
<link point="33.12706,-107.25423"/>
<link point="33.12752,-107.25401"/>
<link point="33.12817,-107.25368"/>
<link point="33.12886,-107.25326"/>
<link point="33.12946,-107.25280"/>
<link point="33.12983,-107.25234"/>
<link point="33.12988,-107.25192"/>
<link point="33.12961,-107.25159"/>
<link point="33.12908,-107.25137"/>
<link point="33.12840,-107.25130"/>
<link point="33.12772,-107.25137"/>
<link point="33.12719,-107.25159"/>
<link point="33.12692,-107.25192"/>
<link point="33.12697,-107.25234"/>
<link point="33.12734,-107.25280"/>
<link point="33.12794,-107.25326"/>
<link point="33.12863,-107.25368"/>
<link point="33.12928,-107.25401"/>
<link point="33.12974,-107.25423"/>
<link point="33.12990,-107.25430"/>
<link point="33.12974,-107.25423"/>
<link point="33.12928,-107.25401"/>
<link point="33.12863,-107.25368"/>
<link point="33.12794,-107.25326"/>
<link point="33.12734,-107.25280"/>
<link point="33.12697,-107.25234"/>
<link point="33.12692,-107.25192"/>
<link point="33.12719,-107.25159"/>
<link point="33.12772,-107.25137"/>
<strokeColor value="-65281"/>
<strokeWeight value="2.5"/>
<contact callsign="Telestration 1"/>
<remarks/>
<archive/>
<labels_on value="false"/>
<precisionlocation altsrc="???"/>
</detail>
</event>

View file

@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<event version="2.0" uid="emergency-01" type="b-a-o-tbl" time="2026-03-15T20:30:00Z" start="2026-03-15T20:30:00Z" stale="2026-03-15T20:35:00Z" how="h-e">
<point lat="17.99950" lon="140.00050" hae="150" ce="15" le="15"/>
<detail>
<contact callsign="TESTNODE-04-Alert"/>
<link uid="ANDROID-0000000000000004" relation="p-p" type="a-f-G-U-C"/>
<emergency type="911 Alert"/>
<remarks/>
</detail>
</event>

View file

@ -0,0 +1,11 @@
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<event version="2.0" uid="emergency-cancel-01" type="b-a-o-can" time="2026-03-15T20:32:00Z" start="2026-03-15T20:32:00Z" stale="2026-03-15T20:37:00Z" how="h-e">
<point lat="17.99950" lon="140.00050" hae="150" ce="15" le="15"/>
<detail>
<contact callsign="TESTNODE-04"/>
<link uid="ANDROID-0000000000000004" relation="p-p" type="a-f-G-U-C"/>
<link uid="emergency-01" relation="p-p" type="b-a-o-tbl"/>
<emergency cancel="true"/>
<remarks/>
</detail>
</event>

View file

@ -0,0 +1,12 @@
<?xml version="1.0" encoding="UTF-8"?>
<event version="2.0" uid="GeoChat.ANDROID-0000000000000003.All Chat Rooms.a1b2c3d4" type="b-t-f" how="h-g-i-g-o" time="2026-03-15T19:00:00Z" start="2026-03-15T19:00:00Z" stale="2026-03-15T19:02:00Z">
<point lat="18.05000" lon="140.05000" hae="9999999.0" ce="9999999.0" le="9999999.0"/>
<detail>
<__chat parent="RootContactGroup" groupOwner="false" messageId="a1b2c3d4" chatroom="All Chat Rooms" id="All Chat Rooms" senderCallsign="ETHEL">
<chatgrp uid0="ANDROID-0000000000000003" uid1="All Chat Rooms" id="All Chat Rooms"/>
</__chat>
<link uid="ANDROID-0000000000000003" type="a-f-G-U-C" relation="p-p"/>
<__serverdestination destinations="0.0.0.0:4242:tcp:ANDROID-0000000000000003"/>
<remarks source="BAO.F.ATAK.ANDROID-0000000000000003" to="All Chat Rooms" time="2026-03-15T19:00:00Z">at breach</remarks>
</detail>
</event>

View file

@ -0,0 +1,12 @@
<?xml version="1.0" encoding="UTF-8"?>
<event version="2.0" uid="GeoChat.ANDROID-0000000000000003.ANDROID-0000000000000004.e5f6a7b8" type="b-t-f" how="h-g-i-g-o" time="2026-03-15T19:05:00Z" start="2026-03-15T19:05:00Z" stale="2026-03-15T19:06:00Z">
<point lat="18.05000" lon="140.05000" hae="9999999.0" ce="9999999.0" le="9999999.0"/>
<detail>
<__chat parent="RootContactGroup" groupOwner="false" messageId="e5f6a7b8" chatroom="ANDROID-0000000000000004" id="ANDROID-0000000000000004" senderCallsign="ETHEL">
<chatgrp uid0="ANDROID-0000000000000003" uid1="ANDROID-0000000000000004" id="ANDROID-0000000000000004"/>
</__chat>
<link uid="ANDROID-0000000000000003" type="a-f-G-U-C" relation="p-p"/>
<__serverdestination destinations="0.0.0.0:4242:tcp:ANDROID-0000000000000003"/>
<remarks source="BAO.F.ATAK.ANDROID-0000000000000003" to="ANDROID-0000000000000004" time="2026-03-15T19:05:00Z">cover by fire</remarks>
</detail>
</event>

View file

@ -0,0 +1,12 @@
<?xml version="1.0" encoding="UTF-8"?>
<event version="2.0" uid="GeoChat.ANDROID-0000000000000002.All Chat Rooms.d4e5f6a7" type="b-t-f" how="h-g-i-g-o" time="2026-03-15T19:00:00Z" start="2026-03-15T19:00:00Z" stale="2026-03-15T19:01:00Z">
<point lat="12.00000" lon="90.00000" hae="-22" ce="9999999" le="9999999"/>
<detail>
<__chat senderCallsign="TESTNODE-01" chatRoom="All Chat Rooms" id="All Chat Rooms" parent="RootContactGroup">
<chatgrp uid0="ANDROID-0000000000000002" uid1="All Chat Rooms"/>
</__chat>
<link uid="ANDROID-0000000000000002" relation="p-p" type="a-f-G-U-C"/>
<remarks source="BAO.F.ATAK.ANDROID-0000000000000002" time="2026-03-15T19:00:00Z">Roger that, moving to rally point</remarks>
<__serverdestination destinations="0.0.0.0:4242:tcp:ANDROID-0000000000000002"/>
</detail>
</event>

View file

@ -0,0 +1,14 @@
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<event version="2.0" uid="a0c524c6-0422-4382-9981-e39d1dc71730" type="a-u-G" time="2026-03-15T14:22:10Z" start="2026-03-15T14:22:10Z" stale="2026-03-16T14:22:10Z" how="h-g-i-g-o">
<point lat="33.12840" lon="-107.25280" hae="9999999.0" ce="9999999.0" le="9999999.0"/>
<detail>
<status readiness="true"/>
<archive/>
<link uid="ANDROID-0000000000000001" production_time="2026-03-15T14:20:57Z" type="a-f-G-U-C" parent_callsign="SIM-01" relation="p-p"/>
<contact callsign="U.16.135057"/>
<remarks/>
<color argb="-1"/>
<precisionlocation altsrc="???"/>
<usericon iconsetpath="COT_MAPPING_2525B/a-u/a-u-G"/>
</detail>
</event>

View file

@ -0,0 +1,12 @@
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<event version="2.0" uid="goto-01" type="b-m-p-w-GOTO" time="2026-03-15T14:22:10Z" start="2026-03-15T14:22:10Z" stale="2026-03-16T14:22:10Z" how="h-e">
<point lat="33.12840" lon="-107.25280" hae="9999999.0" ce="9999999.0" le="9999999.0"/>
<detail>
<contact callsign="GoTo-1"/>
<remarks/>
<archive/>
<color argb="-16711936"/>
<precisionlocation altsrc="???"/>
<link uid="ANDROID-0000000000000001" type="a-f-G-U-C" parent_callsign="SIM-01" relation="p-p"/>
</detail>
</event>

View file

@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<event version="2.0" uid="b2c3d4e5-f6a7-4b8c-9d0e-1f2a3b4c5d6e" type="b-m-p-w-GOTO" time="2026-03-15T14:22:10Z" start="2026-03-15T14:22:10Z" stale="2026-03-16T14:22:10Z" how="h-g-i-g-o">
<point lat="33.12840" lon="-107.25280" hae="9999999.0" ce="9999999.0" le="9999999.0"/>
<detail>
<link uid="ANDROID-0000000000000003" type="a-f-G-U-C" parent_callsign="ETHEL" relation="p-p"/>
<contact callsign="Rally Point Bravo"/>
<color argb="-256"/>
<usericon iconsetpath="34ae1613-9645-4222-a9d2-e5f243dea2865/Military/CP.png"/>
</detail>
</event>

View file

@ -0,0 +1,14 @@
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<event version="2.0" uid="4a0f4f84-240c-4ff9-b7b0-d08beec900b3" type="a-u-G" time="2026-03-15T14:22:10Z" start="2026-03-15T14:22:10Z" stale="2026-03-16T14:22:10Z" how="h-g-i-g-o">
<point lat="33.12840" lon="-107.25280" hae="9999999.0" ce="9999999.0" le="9999999.0"/>
<detail>
<status readiness="true"/>
<archive/>
<link uid="ANDROID-0000000000000001" production_time="2026-03-15T14:21:34Z" type="a-f-G-U-C" parent_callsign="SIM-01" relation="p-p"/>
<contact callsign="hiker 1"/>
<remarks/>
<color argb="-1"/>
<precisionlocation altsrc="???"/>
<usericon iconsetpath="f7f71666-8b28-4b57-9fbb-e38e61d33b79/Google/hiker.png"/>
</detail>
</event>

View file

@ -0,0 +1,14 @@
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<event version="2.0" uid="9405e320-9356-41c4-8449-f46990aa17f8" type="b-m-p-s-m" time="2026-03-15T14:22:10Z" start="2026-03-15T14:22:10Z" stale="2026-03-16T14:22:10Z" how="h-g-i-g-o">
<point lat="33.12840" lon="-107.25280" hae="9999999.0" ce="9999999.0" le="9999999.0"/>
<detail>
<status readiness="true"/>
<archive/>
<link uid="ANDROID-0000000000000001" production_time="2026-03-15T14:21:09Z" type="a-f-G-U-C" parent_callsign="SIM-01" relation="p-p"/>
<contact callsign="R 1"/>
<remarks/>
<color argb="-65536"/>
<precisionlocation altsrc="???"/>
<usericon iconsetpath="COT_MAPPING_SPOTMAP/b-m-p-s-m/-65536"/>
</detail>
</event>

View file

@ -0,0 +1,14 @@
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<event version="2.0" uid="tank-01" type="a-h-G-E-V-A-T" time="2026-03-15T14:22:10Z" start="2026-03-15T14:22:10Z" stale="2026-03-16T14:22:10Z" how="h-g-i-g-o">
<point lat="33.12840" lon="-107.25280" hae="9999999.0" ce="9999999.0" le="9999999.0"/>
<detail>
<status readiness="true"/>
<archive/>
<link uid="ANDROID-0000000000000001" production_time="2026-03-15T14:21:30Z" type="a-f-G-U-C" parent_callsign="SIM-01" relation="p-p"/>
<contact callsign="Tank 1"/>
<remarks/>
<color argb="-65536"/>
<precisionlocation altsrc="???"/>
<usericon iconsetpath="COT_MAPPING_2525B/a-h/a-h-G-E-V-A-T"/>
</detail>
</event>

View file

@ -0,0 +1,5 @@
<?xml version="1.0" encoding="UTF-8"?>
<event version="2.0" uid="testnode" type="a-f-G-U-C" how="m-g" time="2026-03-15T14:22:10Z" start="2026-03-15T14:22:10Z" stale="2026-03-15T14:22:55Z">
<point lat="37.7749" lon="-122.4194" hae="-22" ce="4.9" le="9999999"/>
<detail><contact endpoint="*:-1:stcp" callsign="testnode"/><uid Droid="testnode"/><_flow-tags_ TAK-Server-00000000000000000000000000000001="2026-03-15T14:22:10Z"/></detail>
</event>

View file

@ -0,0 +1,5 @@
<?xml version="1.0" encoding="UTF-8"?>
<event version="2.0" uid="ANDROID-0000000000000002" type="a-f-G-U-C" how="h-e" time="2026-03-15T15:30:00Z" start="2026-03-15T15:30:00Z" stale="2026-03-15T15:30:45Z">
<point lat="12.00000" lon="91.00000" hae="-29.667" ce="32.2" le="9999999"/>
<detail><takv os="34" version="4.12.0.1 (00000000)[playstore].0000000000-CIV" device="Simulator" platform="ATAK-CIV"/><contact endpoint="*:-1:stcp" phone="+15550000001" callsign="TESTNODE-01"/><uid Droid="TESTNODE-01"/><precisionlocation altsrc="GPS" geopointsrc="GPS"/><__group role="Team Member" name="Cyan"/><status battery="88"/><track course="142.75" speed="1.2"/><_flow-tags_ TAK-Server-00000000000000000000000000000001="2026-03-15T15:30:00Z"/></detail>
</event>

View file

@ -0,0 +1,11 @@
<?xml version="1.0" encoding="UTF-8"?>
<event version="2.0" uid="23131970-4D02-4092-A30A-8A49EBD04AA0" type="a-f-G-U-C" how="m-g" time="2026-03-15T14:22:10Z" start="2026-03-15T14:22:10Z" stale="2026-03-15T14:24:10Z">
<point lat="18.01200" lon="140.02300" hae="108.0" ce="9999999.0" le="9999999.0"/>
<detail>
<contact callsign="iPad" phone="" endpoint="*:-1:stcp"/>
<__group name="Cyan" role="Team Member"/>
<status battery="100"/>
<track speed="-1.0" course="228.20"/>
<uid Droid="iPad"/>
</detail>
</event>

View file

@ -0,0 +1,12 @@
<?xml version="1.0" encoding="UTF-8"?>
<event version="2.0" uid="F7749720-1356-4A23-80F4-0010A587DF6C" type="a-f-G-U-C" time="2026-03-15T14:22:10Z" start="2026-03-15T14:22:10Z" stale="2026-03-15T14:27:10Z" how="m-g">
<point lat="18.05000" lon="140.05000" hae="109.2" ce="9999999.0" le="9999999.0"/>
<detail>
<contact endpoint="*:-1:stcp" callsign="iPadTAKAware"/>
<uid Droid="iPadTAKAware"/>
<__group role="Team Member" name="Cyan"/>
<status battery="30"/>
<track course="-1.0" speed="-1.0"/>
<takv device="iPad" platform="TAKAware-CIV" version="1.7.3.233" os="iPadOS"/>
</detail>
</event>

View file

@ -0,0 +1,11 @@
<?xml version="1.0" encoding="UTF-8"?>
<event version="2.0" uid="F7749720-1356-4A23-80F4-0010A587DF6C" type="a-f-G-U-C" how="m-g" time="2026-03-15T14:22:10Z" start="2026-03-15T14:22:10Z" stale="2026-03-15T14:27:10Z">
<point lat="18.01500" lon="140.01800" hae="107.0" ce="9999999.0" le="9999999.0"/>
<detail>
<contact callsign="iPadTAKAware" endpoint="*:-1:stcp"/>
<__group role="Team Member" name="Cyan"/>
<status battery="95"/>
<track speed="-1.0" course="265.64"/>
<uid Droid="iPadTAKAware"/>
</detail>
</event>

View file

@ -0,0 +1,5 @@
<?xml version="1.0" encoding="UTF-8"?>
<event version="2.0" uid="d7e8f9a0-1b2c-3d4e-5f6a-7b8c9d0e1f2a" type="a-f-G-U-C-I" how="h-e" time="2026-03-15T16:10:00Z" start="2026-03-15T16:10:00Z" stale="2026-03-15T16:14:00Z">
<point lat="12.00000" lon="91.00000" hae="999999" ce="999999" le="999999"/>
<detail><contact callsign="TESTNODE-02" endpoint="*:-1:stcp"/><__group name="Cyan" role="Team Member"/><takv device="Chrome - 134" platform="WebTAK" os="Windows - 11" version="4.12.1"/><link relation="p-p" type="a-f-G-U-C-I" uid="d7e8f9a0-1b2c-3d4e-5f6a-7b8c9d0e1f2a"/><_flow-tags_ TAK-Server-00000000000000000000000000000001="2026-03-15T16:10:00Z"/></detail>
</event>

View file

@ -0,0 +1,17 @@
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<event version="2.0" uid="bc7a8f3e-2514-4d89-9a3b-d50128374691" type="u-r-b-bullseye" time="2026-03-15T14:22:10Z" start="2026-03-15T14:22:10Z" stale="2026-03-16T14:22:10Z" how="h-e">
<point lat="33.12840" lon="-107.25280" hae="9999999.0" ce="9999999.0" le="9999999.0"/>
<detail>
<shape>
<ellipse major="500.0" minor="500.0" angle="360"/>
</shape>
<bullseye distance="500.0" bearingRef="M" rangeRingVisible="true" hasRangeRings="true" edgeToCenter="false" mils="false"/>
<strokeColor value="-65536"/>
<strokeWeight value="3.0"/>
<contact callsign="Bullseye 1"/>
<remarks/>
<archive/>
<labels_on value="true"/>
<precisionlocation altsrc="???"/>
</detail>
</event>

View file

@ -0,0 +1,17 @@
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<event version="2.0" uid="9655dd2a-a8ee-4ca0-aae4-ac3c0522e5e5" type="u-r-b-c-c" time="2026-03-15T14:22:10Z" start="2026-03-15T14:22:10Z" stale="2026-03-16T14:22:10Z" how="h-e">
<point lat="33.12840" lon="-107.25280" hae="9999999.0" ce="9999999.0" le="9999999.0"/>
<detail>
<shape>
<ellipse major="500.0" minor="500.0" angle="360"/>
</shape>
<strokeColor value="-1"/>
<strokeWeight value="3.0"/>
<fillColor value="-1761607681"/>
<contact callsign="RB Circle 1"/>
<remarks/>
<archive/>
<labels_on value="true"/>
<precisionlocation altsrc="???"/>
</detail>
</event>

View file

@ -0,0 +1,14 @@
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<event version="2.0" uid="58df2fcd-e33e-414f-a718-b18b50cd3137" type="u-rb-a" time="2026-03-15T14:22:10Z" start="2026-03-15T14:22:10Z" stale="2026-03-16T14:22:10Z" how="h-e">
<point lat="33.12840" lon="-107.25280" hae="9999999.0" ce="9999999.0" le="9999999.0"/>
<detail>
<link uid="anchor-1" relation="p-p" type="b-m-p-w" point="33.12840,-107.25280"/>
<range value="1250.5"/>
<bearing value="135.0"/>
<contact callsign="RB Line 1"/>
<remarks/>
<archive/>
<labels_on value="false"/>
<precisionlocation altsrc="???"/>
</detail>
</event>

View file

@ -0,0 +1,16 @@
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<event version="2.0" uid="a3f58c21-91e4-4b76-8d5f-6291704835ab" type="b-m-r" time="2026-03-15T14:22:10Z" start="2026-03-15T14:22:10Z" stale="2026-03-16T14:22:10Z" how="h-e">
<point lat="33.12840" lon="-107.25280" hae="9999999.0" ce="9999999.0" le="9999999.0"/>
<detail>
<__routeinfo/>
<link_attr method="Driving" direction="Infil" prefix="CP" stroke="3"/>
<link uid="wp-a1b2c3d4-0001" type="b-m-p-w" callsign="CP1" point="33.12840,-107.25280"/>
<link uid="wp-a1b2c3d4-0002" type="b-m-p-w" callsign="CP2" point="33.12960,-107.25130"/>
<link uid="wp-a1b2c3d4-0003" type="b-m-p-w" callsign="CP3" point="33.13090,-107.25000"/>
<contact callsign="Route Alpha"/>
<remarks/>
<archive/>
<labels_on value="false"/>
<precisionlocation altsrc="???"/>
</detail>
</event>

View file

@ -0,0 +1,11 @@
<?xml version="1.0" encoding="UTF-8"?>
<event version="2.0" uid="139a3009-681e-4b1a-8f23-dbb49a2c338d" type="b-m-r" how="h-e" time="2026-03-15T14:22:10Z" start="2026-03-15T14:22:10Z" stale="2026-03-16T14:22:10Z">
<point lat="33.12840" lon="-107.25280" hae="0.0" ce="0.0" le="0.0"/>
<detail>
<contact callsign="Route Alpha"/>
<link uid="d71306c3-93a5-41f4-b323-8a5b10f0e968" callsign="SP" type="b-m-p-w" point="33.12840, -107.25280"/>
<link uid="06bdf9c8-bbdd-4ba6-80c5-f814855df756" callsign="" type="b-m-p-c" point="33.12640, -107.25580"/>
<link uid="a5449578-97d2-4e33-b9d3-390b3155abd1" callsign="VDO" type="b-m-p-w" point="33.12890, -107.25240"/>
<link_attr color="-65281" method="Walking" prefix="CP" direction="Infil"/>
</detail>
</event>

View file

@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<event version="2.0" uid="task-01" type="t-s" time="2026-03-15T21:00:00Z" start="2026-03-15T21:00:00Z" stale="2026-03-15T22:00:00Z" how="h-e">
<point lat="17.99700" lon="140.00300" hae="80" ce="9999999" le="9999999"/>
<detail>
<contact callsign="Task-Alpha"/>
<task type="engage" priority="High" status="Pending" assignee="ANDROID-0000000000000005" note="cover by fire"/>
<link uid="target-01" relation="p-p" type="a-f-G"/>
<remarks/>
</detail>
</event>

View file

@ -0,0 +1,12 @@
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<event version="2.0" uid="f6926af1-deec-44f4-ae06-46065c829887" type="b-m-p-w" time="2026-03-15T14:22:10Z" start="2026-03-15T14:22:10Z" stale="2026-03-16T14:22:10Z" how="h-e">
<point lat="33.12840" lon="-107.25280" hae="9999999.0" ce="9999999.0" le="9999999.0"/>
<detail>
<contact callsign="CP1"/>
<remarks/>
<archive/>
<color argb="-1"/>
<precisionlocation altsrc="???"/>
<link uid="ANDROID-0000000000000001" type="a-f-G-U-C" parent_callsign="SIM-01" relation="p-p"/>
</detail>
</event>

View file

@ -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)
}
}

View file

@ -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<NavKey>.settingsGraph(backStack: NavBackStack<NavKey>) {
}
}
entry<SettingsRoutes.TakServer> {
TakServerScreen(onBack = { backStack.removeLastOrNull() })
}
entry<SettingsRoutes.DebugPanel> {
val viewModel: DebugViewModel = koinViewModel()
DebugScreen(viewModel = viewModel, onNavigateUp = { backStack.removeLastOrNull() })

View file

@ -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,

View file

@ -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<ModuleConfig.TAKConfig>,
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,
)
}
}
}
}