feat: TAK v2 protocol integration with zstd compression and full CoT type support (#5434)

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: jamesarich <2199651+jamesarich@users.noreply.github.com>
Co-authored-by: James Rich <james.a.rich@gmail.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
Ben Meadors
2026-05-14 07:50:01 -05:00
committed by GitHub
parent 0ef3072f3c
commit a04a261b80
133 changed files with 7620 additions and 1782 deletions

1
.gitmodules vendored
View File

@@ -1,3 +1,4 @@
[submodule "app proto submodule"]
path = core/proto/src/main/proto
url = https://github.com/meshtastic/protobufs.git
branch = master

View File

@@ -1123,8 +1123,19 @@ tak_role_sniper
tak_role_teamlead
tak_role_teammember
tak_role_unspecified
tak_server
tak_server_enabled
tak_server_enabled_desc
tak_server_export_data_package_desc
tak_server_loading
tak_server_section
tak_server_test_card_title
tak_server_test_idle
tak_server_test_result_bytes
tak_server_test_result_unknown_error
tak_server_test_results
tak_server_test_run
tak_server_test_running
tak_team
tak_team_blue
tak_team_brown

View File

@@ -21,6 +21,28 @@ Run in a single invocation for routine changes to ensure code formatting, analys
*Note: If testing Compose UI on the JVM (Robolectric) with Java 21, pin tests to `@Config(sdk = [34])` to avoid SDK 35 compatibility crashes.*
### SharedFlow + backgroundScope in `runTest`
When testing long-lived coroutines (e.g., `Flow.collect` loops launched in `backgroundScope`), **use `runTest(UnconfinedTestDispatcher())`** instead of plain `runTest`:
```kotlin
// ❌ BAD — SharedFlow emissions silently never reach collectors
@Test fun `inbound packet is forwarded`() = runTest {
backgroundScope.launch { sut.start(backgroundScope) }
sharedFlow.emit(packet)
// assertion fails — collector never receives the emission
}
// ✅ GOOD — UnconfinedTestDispatcher eagerly dispatches subscriber resumptions
@Test fun `inbound packet is forwarded`() = runTest(UnconfinedTestDispatcher()) {
backgroundScope.launch { sut.start(backgroundScope) }
sharedFlow.emit(packet)
// assertion passes — collector receives emission immediately
}
```
**Why:** `backgroundScope` uses `StandardTestDispatcher` by default, which does **not** eagerly dispatch `SharedFlow` subscriber resumptions. Even `advanceUntilIdle()` won't trigger delivery. `UnconfinedTestDispatcher()` fixes this by dispatching eagerly. This affects any test where a coroutine in `backgroundScope` collects from a `SharedFlow` or `MutableSharedFlow`.
## 2) Change-type verification matrix
- `docs-only` changes: Usually no Gradle run required, but run `spotlessCheck` if practical.

View File

@@ -0,0 +1,31 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
~ Copyright (c) 2025-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/>.
-->
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<!--
Required for writing TAK route data packages to ATAK's auto-import directory.
Only declared for the F-Droid flavor — the Google Play flavor uses scoped
storage (SAF / app-scoped cache) so this permission is not needed there
and would violate Play policy.
-->
<uses-permission android:name="android.permission.MANAGE_EXTERNAL_STORAGE"
tools:ignore="ScopedStorage" />
</manifest>

View File

@@ -67,10 +67,6 @@
-->
<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" />
-->
<!-- 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" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_CONNECTED_DEVICE" />

View File

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

View File

@@ -58,6 +58,13 @@ data class Capabilities(val firmwareVersion: String?, internal val forceEnableAl
/** Support for TAK (ATAK) module configuration. Supported since firmware v2.7.19. */
val supportsTakConfig = atLeast(V2_7_19)
/**
* Support for the v2 TAK port (ATAK_PLUGIN_V2 = 78) with TAKPacketV2 + zstd dictionary compression. Supported since
* firmware v2.8.0. Firmware v2.7.x and earlier only support the legacy ATAK_PLUGIN port (72) with the original
* TAKPacket schema (PLI + GeoChat only, no compression), so the bridge falls back to that path for older nodes.
*/
val supportsTakV2 = atLeast(V2_8_0)
/** Support for location sharing on secondary channels. Supported since firmware v2.6.10. */
val supportsSecondaryChannelLocation = atLeast(V2_6_10)

View File

@@ -172,6 +172,8 @@ sealed interface SettingsRoute : Route {
@Serializable data object CleanNodeDb : SettingsRoute
@Serializable data object TakServer : SettingsRoute
@Serializable data object DebugPanel : SettingsRoute
@Serializable data object About : SettingsRoute

View File

@@ -1165,8 +1165,19 @@
<string name="tak_role_teamlead">Team Lead</string>
<string name="tak_role_teammember">Team Member</string>
<string name="tak_role_unspecified">Unspecified</string>
<string name="tak_server">TAK Server</string>
<string name="tak_server_enabled">Enable Local TAK Server</string>
<string name="tak_server_enabled_desc">Starts a TCP server on port 8089 for ATAK connections</string>
<string name="tak_server_enabled_desc">Starts a local TLS server on port 8089 for ATAK/iTAK connections</string>
<string name="tak_server_export_data_package_desc">Generate .zip for ATAK/iTAK to connect to this server</string>
<string name="tak_server_loading"></string>
<string name="tak_server_section">Server</string>
<string name="tak_server_test_card_title">TAK Mesh Test (Debug)</string>
<string name="tak_server_test_idle">Send all %1$d test fixtures to mesh</string>
<string name="tak_server_test_result_bytes">%1$dB ✓</string>
<string name="tak_server_test_result_unknown_error"></string>
<string name="tak_server_test_results">%1$d passed, %2$d failed of %3$d/%4$d</string>
<string name="tak_server_test_run">Run</string>
<string name="tak_server_test_running">Running: %1$s</string>
<string name="tak_team">Team Color</string>
<string name="tak_team_blue">Blue</string>
<string name="tak_team_brown">Brown</string>

View File

@@ -14,6 +14,8 @@
* 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.service
import android.app.Service
@@ -22,6 +24,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
@@ -91,6 +94,14 @@ class MeshService : Service() {
private var isServiceInitialized = false
/**
* 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()
@@ -110,6 +121,8 @@ class MeshService : Service() {
val minDeviceVersion = DeviceVersion(DeviceVersion.MIN_FW_VERSION)
val absoluteMinDeviceVersion = DeviceVersion(DeviceVersion.ABS_MIN_FW_VERSION)
private const val WAKE_LOCK_TIMEOUT_MS = 30L * 60L * 1_000L // 30 minutes
}
override fun onCreate() {
@@ -163,10 +176,12 @@ 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
}
}
@@ -205,6 +220,38 @@ class MeshService : Service() {
}
}
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(WAKE_LOCK_TIMEOUT_MS)
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" }
@@ -214,6 +261,7 @@ class MeshService : Service() {
override fun onDestroy() {
Logger.i { "Destroying mesh service" }
releaseWakeLock()
ServiceCompat.stopForeground(this, ServiceCompat.STOP_FOREGROUND_REMOVE)
if (isServiceInitialized) {
orchestrator.stop()

View File

@@ -48,7 +48,6 @@ 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 radioInterfaceService: RadioInterfaceService = mock(MockMode.autofill)
private val serviceRepository: ServiceRepository = 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 router: MeshRouter = mock(MockMode.autofill)
@@ -68,7 +67,7 @@ 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 nodeRepository: NodeRepository = mock(MockMode.autofill)
private val databaseManager: DatabaseManager = mock(MockMode.autofill)
private val connectionManager: MeshConnectionManager = mock(MockMode.autofill)
@@ -94,17 +93,16 @@ 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
every { nodeRepository.myNodeInfo } returns MutableStateFlow(null)
val takMeshIntegration =
TAKMeshIntegration(
takServerManager = takServerManager,
commandSender = commandSender,
nodeRepository = nodeRepository,
serviceRepository = serviceRepository,
meshConfigHandler = meshConfigHandler,
cotHandler = cotHandler,
nodeRepository = nodeRepository,
)
return MeshServiceOrchestrator(

View File

@@ -1,5 +1,5 @@
/*
* Copyright (c) 2026 Meshtastic LLC
* Copyright (c) 2025-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
@@ -23,6 +23,7 @@ plugins {
}
kotlin {
@Suppress("UnstableApiUsage")
android {
namespace = "org.meshtastic.core.takserver"
androidResources.enable = false
@@ -50,9 +51,75 @@ kotlin {
implementation(libs.kermit)
}
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.2.1") {
// Issue #5: pre-0.2.1 the SDK JAR bundled `org.meshtastic.proto.*`
// (Wire-generated TAKPacketV2 + friends) inside the same JAR as
// `org.meshtastic.tak.*`. Our own `:core:proto` module runs its
// own Wire codegen against the same protobufs submodule and emits
// the identical classes, so R8 hit "Type is defined multiple
// times" errors during release builds. v0.2.1 strips the proto
// classes from the JAR entirely — the SDK's bytecode still
// REFERENCES them, but they come from `:core:proto` on our
// classpath. No exclude needed; the SDK simply doesn't ship them.
// 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")
// xpp3 bundles org.xmlpull.v1.XmlPullParser which Android provides
// as a platform class (android.content.res.XmlResourceParser
// implements it). R8 fails when both the library and program
// classpaths define the same type.
exclude(group = "org.ogce", module = "xpp3")
}
}
jvmMain.dependencies {
// Desktop JVM: standard JAR bundles native libs for desktop archs.
implementation("com.github.luben:zstd-jni:1.5.7-7")
// xpp3 is excluded from jvmAndroidMain (Android ships it as a
// platform class), but Desktop JVM still needs it for XmlPullParser.
implementation("org.ogce:xpp3:1.1.6")
}
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)
implementation(libs.kotlinx.coroutines.test)
implementation(libs.turbine)
implementation(libs.kotest.assertions)
implementation(libs.kotest.property)
}
}
}

View File

@@ -0,0 +1,53 @@
/*
* 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 {
// Sanitize: fileName originates from untrusted mesh CoT uid attributes.
val safeName = fileName.replace(Regex("[^a-zA-Z0-9._-]"), "_")
// 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, safeName)
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,32 @@
/*
* 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
/**
* Writes data package files to ATAK's auto-import directory.
*
* 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.
*/
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,168 @@
/*
* 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,41 +20,69 @@ package org.meshtastic.core.takserver
import kotlin.time.Instant
fun CoTMessage.toXml(): String = buildString {
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>",
/**
* 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(
"<event version='2.0' uid='${uid.xmlEscaped()}' type='${type.xmlEscaped()}' time='${time.toXmlString()}' start='${start.toXmlString()}' stale='${stale.toXmlString()}' how='${how.xmlEscaped()}'><point lat='$latitude' lon='$longitude' hae='$hae' ce='$ce' le='$le'/><detail>",
)
contact?.let {
append(
"<contact endpoint='${it.endpoint ?: DEFAULT_TAK_ENDPOINT}' callsign='${it.callsign.xmlEscaped()}'/><uid Droid='${it.callsign.xmlEscaped()}'/>",
sb.append(
"<contact endpoint='${(it.endpoint ?: DEFAULT_TAK_ENDPOINT).xmlEscaped()}' callsign='${it.callsign.xmlEscaped()}'/><uid Droid='${it.callsign.xmlEscaped()}'/>",
)
}
group?.let { append("<__group role='${it.role.xmlEscaped()}' name='${it.name.xmlEscaped()}'/>") }
group?.let { sb.append("<__group role='${it.role.xmlEscaped()}' name='${it.name.xmlEscaped()}'/>") }
status?.let { append("<status battery='${it.battery}'/>") }
status?.let { sb.append("<status battery='${it.battery}'/>") }
track?.let { append("<track course='${it.course}' speed='${it.speed}'/>") }
track?.let { sb.append("<track course='${it.course}' speed='${it.speed}'/>") }
if (chat != null) {
val senderUid = uid.geoChatSenderUid()
val messageId = uid.geoChatMessageId()
append(
"<__chat parent='RootContactGroup' groupOwner='false' messageId='$messageId' chatroom='${chat.chatroom.xmlEscaped()}' id='${chat.chatroom.xmlEscaped()}' senderCallsign='${chat.senderCallsign?.xmlEscaped() ?: ""}'><chatgrp uid0='${senderUid.xmlEscaped()}' uid1='${chat.chatroom.xmlEscaped()}' id='${chat.chatroom.xmlEscaped()}'/></__chat>",
sb.append(
"<__chat parent='RootContactGroup' groupOwner='false' messageId='${messageId.xmlEscaped()}' chatroom='${chat.chatroom.xmlEscaped()}' id='${chat.chatroom.xmlEscaped()}' senderCallsign='${chat.senderCallsign?.xmlEscaped() ?: ""}'><chatgrp uid0='${senderUid.xmlEscaped()}' uid1='${chat.chatroom.xmlEscaped()}' id='${chat.chatroom.xmlEscaped()}'/></__chat>",
)
append("<link uid='${senderUid.xmlEscaped()}' type='a-f-G-U-C' relation='p-p'/>")
append("<__serverdestination destinations='0.0.0.0:4242:tcp:${senderUid.xmlEscaped()}'/>")
append(
sb.append("<link uid='${senderUid.xmlEscaped()}' type='a-f-G-U-C' relation='p-p'/>")
sb.append("<__serverdestination destinations='0.0.0.0:4242:tcp:${senderUid.xmlEscaped()}'/>")
sb.append(
"<remarks source='BAO.F.ATAK.${senderUid.xmlEscaped()}' to='${chat.chatroom.xmlEscaped()}' time='${time.toXmlString()}'>${chat.message.xmlEscaped()}</remarks>",
)
} else if (!remarks.isNullOrEmpty()) {
append("<remarks>${remarks.xmlEscaped()}</remarks>")
sb.append("<remarks>${remarks.xmlEscaped()}</remarks>")
}
rawDetailXml?.takeIf { it.isNotEmpty() }?.let { append(it) }
rawDetailXml?.let {
if (it.isNotEmpty()) {
sb.append(it)
}
}
append("</detail></event>")
sb.append("</detail></event>")
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

@@ -85,6 +85,10 @@ internal class CoTXmlFrameBuffer(private val maxMessageSize: Long = DEFAULT_MAX_
companion object {
private val EVENT_START_BYTES = "<event".encodeUtf8()
private val EVENT_END_BYTES = "</event>".encodeUtf8()
private const val DEFAULT_MAX_TAK_MESSAGE_SIZE = 8L * 1024 * 1024
// 256KB is still 25× larger than any realistic CoT event (~10KB max). Using 8MB
// would allow a TAK client with the shared cert to exhaust memory on mobile
// devices by opening N connections × 8MB per frame buffer.
private const val DEFAULT_MAX_TAK_MESSAGE_SIZE = 256L * 1024
}
}

View File

@@ -14,8 +14,11 @@
* 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("ReturnCount")
package org.meshtastic.core.takserver
import co.touchlab.kermit.Logger
import nl.adaptivity.xmlutil.serialization.XML
import kotlin.time.Clock
import kotlin.time.Instant
@@ -59,9 +62,37 @@ 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)
@@ -107,7 +138,8 @@ class CoTXmlParser(private val xml: String) {
val cleaned = dateString.replace(Regex("""\.\d+"""), "").replace("Z", "+00:00")
Instant.parse(cleaned)
} catch (ignoredInner: IllegalArgumentException) {
Clock.System.now() // Return now as fallback
Logger.w { "Unparseable CoT date '$dateString', falling back to now()" }
Clock.System.now()
}
}
}

View File

@@ -0,0 +1,119 @@
/*
* 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("ReturnCount")
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
}
}

View File

@@ -1,253 +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()
/** Tracks the last time data was received from the client, used for idle timeout detection. */
@Volatile private var lastDataReceived: Instant = Clock.System.now()
/** 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) {
lastDataReceived = Clock.System.now()
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() {
val idleTimeoutMs = TAK_KEEPALIVE_INTERVAL_MS * TAK_READ_IDLE_TIMEOUT_MULTIPLIER
while (scope.coroutineIsActive && !socket.isClosed) {
kotlinx.coroutines.delay(TAK_KEEPALIVE_INTERVAL_MS)
val idleMs = (Clock.System.now() - lastDataReceived).inWholeMilliseconds
if (idleMs > idleTimeoutMs) {
Logger.w {
"TAK client ${currentClientInfo.id} idle for ${idleMs}ms " +
"(threshold ${idleTimeoutMs}ms), closing connection"
}
close()
return
}
sendKeepalive()
}
}
private fun sendKeepalive() {
val now = Clock.System.now()
val stale = now + (TAK_KEEPALIVE_INTERVAL_MS * TAK_KEEPALIVE_STALE_MULTIPLIER).milliseconds
sendXml(buildEventXml(uid = "takPong", type = "t-x-c-t", now = now, stale = stale, detail = ""))
}
private fun sendPong() {
val now = Clock.System.now()
val stale = now + (TAK_KEEPALIVE_INTERVAL_MS * TAK_KEEPALIVE_STALE_MULTIPLIER).milliseconds
sendXml(buildEventXml(uid = "takPong", type = "t-x-c-t-r", 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" -> {
sendPong()
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}" }
close()
}
}
}
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

@@ -14,6 +14,8 @@
* 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("LongMethod")
package org.meshtastic.core.takserver
import nl.adaptivity.xmlutil.XmlDeclMode
@@ -21,17 +23,27 @@ 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 +51,37 @@ 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,88 @@ 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 protocolType = if (useTls) "ssl" else "tcp"
val prefs =
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:tcp"),
if (useTls) {
// TLS / mTLS mode — matches the iOS data package format exactly.
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"),
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,
),
),
),
),
TAKPreferenceXml(
version = "1",
name = "com.atakmap.app_preferences",
entries =
listOf(TAKEntryXml("displayServerConnectionWidget", "class java.lang.Boolean", "true")),
)
} 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 +180,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 +193,31 @@ 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,22 +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
internal const val TAK_KEEPALIVE_STALE_MULTIPLIER = 3
internal const val TAK_READ_IDLE_TIMEOUT_MULTIPLIER = 5
// 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
/** Default CoT type for PLI (Position Location Information) — friendly ground unit. */
internal const val DEFAULT_PLI_COT_TYPE = "a-f-G-U-C"
/**
* 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

@@ -14,7 +14,7 @@
* 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("ReturnCount", "TooGenericExceptionCaught")
@file:Suppress("ReturnCount", "TooGenericExceptionCaught", "LongMethod")
package org.meshtastic.core.takserver
@@ -24,73 +24,106 @@ 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.Capabilities
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.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.concurrent.atomics.AtomicBoolean
import kotlin.concurrent.atomics.ExperimentalAtomicApi
import kotlin.random.Random
import kotlin.time.Clock
import kotlin.time.Duration.Companion.minutes
/**
* Bidirectional bridge between the local TAK server and the Meshtastic mesh network.
*
* Outbound traffic (TAK client -> mesh) is version-gated on the connected radio's firmware version, exposed via
* [Capabilities.supportsTakV2]:
* - Firmware **>= 2.8.0**: TAKPacketV2 on port 78 (ATAK_PLUGIN_V2) with zstd dictionary compression via TAKPacket-SDK.
* Supports all CoT payload types (PLI, GeoChat, DrawnShape, Marker, Route, Aircraft, Casevac, Emergency, Task) with
* compact typed encodings that fit under the 237B LoRa MTU.
* - Firmware **<= 2.7.x**: Legacy [TAKPacket] on port 72 (ATAK_PLUGIN) with bare protobuf encoding. Supports only PLI
* and GeoChat — shapes, markers, routes, and other typed CoT events are dropped (with a warning) because the legacy
* schema cannot represent them.
*
* Inbound traffic (mesh -> TAK client) is always dual-path tolerant — both port 72 and port 78 are dispatched
* regardless of the local radio's firmware version, so a v2-capable node can still relay legacy v1 packets received
* from older nodes in mixed-firmware mesh deployments.
*/
@OptIn(ExperimentalAtomicApi::class)
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,
private val nodeRepository: NodeRepository,
) {
@Volatile private var isRunning = false
private val jobs = mutableListOf<Job>()
private var currentTeam: Team = Team.Unspecifed_Color
private var currentRole: MemberRole = MemberRole.Unspecifed
private val isRunning = AtomicBoolean(false)
// Immutable list reference replaced atomically in start()/stop(); never mutated in-place.
// @Volatile only guarantees visibility of the reference itself — any in-place mutation
// would bypass the visibility guarantee and must not be added.
@Volatile private var jobs: List<Job> = emptyList()
@Volatile private var currentTeam: Team = Team.Unspecifed_Color
@Volatile private var currentRole: MemberRole = MemberRole.Unspecifed
fun start(scope: CoroutineScope) {
if (isRunning) return
isRunning = true
if (!isRunning.compareAndSet(expectedValue = false, newValue = true)) return
takServerManager.start(scope)
val newJobs =
listOf(
// Forward incoming CoT from TAK clients to mesh
scope.launch { takServerManager.inboundMessages.collect { cotMessage -> sendCoTToMesh(cotMessage) } },
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
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 {}
},
// Track TAK config changes
scope.launch {
meshConfigHandler.moduleConfig
.map { it.tak }
@@ -102,62 +135,418 @@ class TAKMeshIntegration(
},
)
jobs.addAll(newJobs)
Logger.i { "TAK Mesh Integration started" }
jobs = newJobs
val fw = nodeRepository.myNodeInfo.value?.firmwareVersion
val proto = if (Capabilities(fw).supportsTakV2) "v2 (port 78, zstd)" else "v1 (port 72, legacy)"
Logger.i { "TAK Mesh Integration started — firmware=$fw, outbound=$proto" }
}
fun stop() {
if (!isRunning) return
isRunning = false
// Cancel all tracked jobs and clear the list
val toCancel: List<Job>
toCancel = jobs.toList()
jobs.clear()
if (!isRunning.compareAndSet(expectedValue = true, newValue = false)) return
val toCancel = jobs
jobs = emptyList()
toCancel.forEach(Job::cancel)
takServerManager.stop()
Logger.i { "TAK Mesh Integration stopped" }
}
// ── Send: TAK client → mesh ─────────────────────────────────────────────
/**
* Determine the outbound TAK protocol version based on the connected radio's firmware version. Evaluated per-send
* (not cached) so the bridge picks up firmware upgrades during a session without restart. If the firmware version
* is unavailable (radio not yet handshook), default to V2 — the v2 firmware was released widely enough that
* defaulting to legacy would be a regression for the common case.
*/
private fun useTakV2(): Boolean {
val fw = nodeRepository.myNodeInfo.value?.firmwareVersion ?: return true
return Capabilities(fw).supportsTakV2
}
private suspend fun sendCoTToMesh(cotMessage: CoTMessage) {
val takPacket = cotMessage.toTAKPacket()
if (takPacket == null) {
cotHandler.sendGenericCoT(cotMessage)
if (useTakV2()) {
sendCoTToMeshV2(cotMessage)
} else {
sendCoTToMeshV1(cotMessage)
}
}
/**
* v2 send path (firmware >= 2.8.0): SDK parser + zstd dictionary compression, full typed payload support
* (DrawnShape, Marker, Route, Aircraft, Casevac, Emergency, Task, plus PLI / GeoChat). Wire format: `[flags
* byte][zstd-compressed TAKPacketV2 protobuf]` on port 78 (ATAK_PLUGIN_V2).
*/
private suspend fun sendCoTToMeshV2(cotMessage: CoTMessage) {
// 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)
// 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 {
TakSdkCompressor.compressCoT(xml, 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: Exception) {
Logger.w(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: Exception) {
Logger.w(e2) { "V2 compression failed for ${cotMessage.type}, using uncompressed wire format" }
encodeUncompressed(takPacketV2)
}
}
try {
val dataPacket =
DataPacket(
to = DataPacket.ID_BROADCAST,
bytes = wirePayload.toByteString(),
dataType = PortNum.ATAK_PLUGIN_V2.value,
)
commandSender.sendData(dataPacket)
Logger.d { "Sent V2 to mesh: ${cotMessage.type} (${wirePayload.size} bytes)" }
} catch (e: Exception) {
// 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}"
}
}
}
/**
* Legacy v1 send path (firmware <= 2.7.x): bare protobuf-encoded [TAKPacket] on port 72 (ATAK_PLUGIN), no zstd
* compression. Only PLI and GeoChat payloads are supported by the v1 schema — shapes, markers, routes, casevac,
* emergency, and task CoT events are dropped with a warning.
*/
private suspend fun sendCoTToMeshV1(cotMessage: CoTMessage) {
val takPacket =
cotMessage.toTAKPacket()
?: run {
Logger.w {
"Dropping CoT for legacy v1 radio: type=${cotMessage.type} not representable " +
"in v1 TAKPacket schema (only PLI and GeoChat are supported). " +
"Upgrade radio firmware to >= 2.8.0 for full payload support."
}
return
}
val wirePayload = TAKPacket.ADAPTER.encode(takPacket)
if (wirePayload.size > MAX_TAK_WIRE_PAYLOAD_BYTES) {
Logger.w {
"Dropping oversized v1 TAK packet: type=${cotMessage.type} " +
"size=${wirePayload.size}B max=$MAX_TAK_WIRE_PAYLOAD_BYTES"
}
return
}
val payload = TAKPacket.ADAPTER.encode(takPacket)
val dataPacket =
DataPacket(
to = DataPacket.ID_BROADCAST,
bytes = payload.toByteString(),
dataType = PortNum.ATAK_PLUGIN.value,
)
commandSender.sendData(dataPacket)
Logger.d { "Forwarded CoT to mesh as TAKPacket: ${cotMessage.type}" }
try {
val dataPacket =
DataPacket(
to = DataPacket.ID_BROADCAST,
bytes = wirePayload.toByteString(),
dataType = PortNum.ATAK_PLUGIN.value,
)
commandSender.sendData(dataPacket)
Logger.d { "Sent V1 to mesh: ${cotMessage.type} (${wirePayload.size} bytes)" }
} catch (e: Exception) {
Logger.e(e) {
"Failed to send v1 TAKPacket 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: Exception) {
Logger.w(e2) { "Route data package write failed: ${e2.message}" }
}
}
takServerManager.broadcastRawXml(xml)
Logger.d { "V2 → TAK clients (raw XML)" }
} catch (e: Exception) {
Logger.w(e) { "Failed to handle V2 packet: ${e.message}" }
}
}
/**
* v1 receive path (firmware <= 2.7.x): decode bare protobuf [TAKPacket] (no compression) from port 72 (ATAK_PLUGIN)
* and convert to CoT for forwarding to attached TAK clients. Kept indefinitely so users on stable 2.7.x firmware
* retain PLI + GeoChat interop; new typed payloads (shapes, markers, routes, etc.) still require a v2-capable radio
* (firmware >= 2.8.0).
*/
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: Exception) {
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()
// Include chatroom in UID so ATAK routes DMs correctly — the UID format
// "GeoChat.<senderUid>.<chatroom>.<msgId>" is what ATAK uses to determine routing.
// Hardcoding "All Chat Rooms" here loses DM routing from legacy v1 nodes.
val chatroom = chat.to ?: "All Chat Rooms"
val msgId = Random.Default.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 = 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 = chatroom, 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="([^"]*)"""")
// Matches the stale attribute ONLY within the <event> opening tag to avoid
// accidentally matching a stale="..." on a <link> or other child element.
private val EVENT_TAG_RE = Regex("""<event\b[^>]*>""")
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
// Search for stale only inside the <event> opening tag, not in child elements
val eventTagMatch = EVENT_TAG_RE.find(xml) ?: return xml
val eventTag = eventTagMatch.value
val staleInTag = STALE_ATTR_RE.find(eventTag) ?: return xml
val staleStr = staleInTag.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 now = Clock.System.now()
val remaining = staleInstant - now
if (remaining >= MIN_MESH_STALE_TTL) return xml
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)"
}
// Replace the stale value only within the event tag, then splice the patched tag back
val newEventTag = eventTag.replaceRange(staleInTag.range, """stale="$newStaleStr"""")
return xml.replaceRange(eventTagMatch.range, newEventTag)
}
val cotMessage = takPacket.toCoTMessage() ?: return
/**
* 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) }
takServerManager.broadcast(cotMessage)
Logger.d { "Forwarded ATAK mesh packet to TAK clients: ${cotMessage.type}" }
// 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,22 @@ 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(
@@ -61,7 +77,7 @@ data class CoTMessage(
val now = Clock.System.now()
return CoTMessage(
uid = uid,
type = "a-f-G-U-C",
type = DEFAULT_PLI_COT_TYPE,
time = now,
start = now,
stale = now + staleMinutes.minutes,
@@ -130,7 +146,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

@@ -31,14 +31,27 @@ import kotlin.random.Random
import kotlin.time.Clock
import kotlin.time.Duration.Companion.minutes
/**
* Legacy v1 CoT <-> TAKPacket conversion for firmware <= 2.7.x.
*
* Wire format: bare protobuf-encoded [TAKPacket] on `ATAK_PLUGIN` port 72, no zstd compression (the proto has an
* `is_compressed` flag but the firmware doesn't act on it). Supports only PLI and GeoChat payloads — shape, marker,
* route, casevac, emergency, and task CoT events return null and are dropped.
*
* For the SDK-backed path that handles all payload types with zstd dictionary compression on `ATAK_PLUGIN_V2` port 78,
* see [TAKPacketV2Conversion].
*
* [TAKMeshIntegration] picks between the two paths based on `Capabilities.supportsTakV2` (firmware >= 2.8.0).
*/
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,
role =
MemberRole.fromValue(TakConversionHelpers.getMemberRoleValue(it.role)) ?: MemberRole.Unspecifed,
team = Team.fromValue(TakConversionHelpers.getTeamValue(it.name)) ?: Team.Unspecifed_Color,
)
}
@@ -120,7 +133,7 @@ object TAKPacketConversion {
val timeNow = Clock.System.now()
val staleTime = timeNow + DEFAULT_TAK_STALE_MINUTES.minutes
val (senderUid, messageId) = parseDeviceCallsign(rawDeviceCallsign)
val (senderUid, messageId) = TakConversionHelpers.parseDeviceCallsign(rawDeviceCallsign)
val localPli = pli
if (localPli != null) {
@@ -132,8 +145,8 @@ object TAKPacketConversion {
altitude = localPli.altitude.toDouble(),
speed = localPli.speed.toDouble(),
course = localPli.course.toDouble(),
team = teamToColorName(group?.team),
role = roleToName(group?.role),
team = TakConversionHelpers.teamToColorName(group?.team),
role = TakConversionHelpers.roleToName(group?.role),
battery = status?.battery ?: DEFAULT_TAK_BATTERY,
staleMinutes = DEFAULT_TAK_STALE_MINUTES,
)
@@ -160,7 +173,11 @@ object TAKPacketConversion {
latitude = 0.0,
longitude = 0.0,
contact = CoTContact(callsign = senderCallsign, endpoint = DEFAULT_TAK_ENDPOINT),
group = CoTGroup(name = teamToColorName(group?.team), role = roleToName(group?.role)),
group =
CoTGroup(
name = TakConversionHelpers.teamToColorName(group?.team),
role = TakConversionHelpers.roleToName(group?.role),
),
status = CoTStatus(battery = status?.battery ?: DEFAULT_TAK_BATTERY),
chat = CoTChat(chatroom = chatroom, senderCallsign = senderCallsign, message = localChat.message),
)
@@ -168,29 +185,4 @@ object TAKPacketConversion {
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.
*
* 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", "LongMethod", "MagicNumber")
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 { Team.fromValue(TakConversionHelpers.getTeamValue(it.name)) } ?: Team.Unspecifed_Color
val roleEnum =
group?.let { MemberRole.fromValue(TakConversionHelpers.getMemberRoleValue(it.role)) }
?: MemberRole.Unspecifed
val battery = status?.battery?.coerceAtLeast(0) ?: 0
// PLI (position reports): match all atom CoT types ("a-*").
// The original type is preserved via cot_type_id/cot_type_str so that hostile
// (a-h-*), neutral (a-n-*), and unknown (a-u-*) markers round-trip correctly.
// toCoTMessage() restores the exact type from those fields instead of defaulting
// to DEFAULT_PLI_COT_TYPE, so all atom markers are treated as PLI on the wire.
if (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(),
// V2 encodes speed as cm/s (m/s × 100) and course as deg×100.
// V1 (legacy TAKPacket, port 72) uses raw integers with no scaling.
// These two paths are ALWAYS separate (different portnums) and must
// never cross-feed: a V1 packet decoded in TAKMeshIntegration goes
// through convertV1ToCoT() → CoTMessage.pli() → toXml(), NOT through
// toTAKPacketV2(). If this invariant is ever broken, speed/course
// would be silently off by ×100.
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) = TakConversionHelpers.parseDeviceCallsign(rawDeviceCallsign)
// PLI
if (pli != null) {
val staleMinutes = if (stale_seconds > 0) (stale_seconds / 60) else DEFAULT_TAK_STALE_MINUTES
// Restore the original CoT type and how from the packet — pli() defaults to
// DEFAULT_PLI_COT_TYPE/"m-g" but the sending node may have been hostile (a-h-*),
// neutral (a-n-*), unknown (a-u-*), etc.
val resolvedType =
cot_type_str.ifEmpty { TakV2TypeMapper.cotTypeToString(cot_type_id) ?: DEFAULT_PLI_COT_TYPE }
val resolvedHow = TakV2TypeMapper.cotHowToString(how) ?: "m-g"
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 = TakConversionHelpers.teamToColorName(team),
role = TakConversionHelpers.roleToName(role),
battery = battery,
staleMinutes = staleMinutes,
)
.copy(type = resolvedType, how = resolvedHow)
}
// 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 = TakConversionHelpers.teamToColorName(team),
role = TakConversionHelpers.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) ?: DEFAULT_PLI_COT_TYPE }
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
}
}

View File

@@ -14,198 +14,55 @@
* 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

@@ -18,39 +18,40 @@ package org.meshtastic.core.takserver
import co.touchlab.kermit.Logger
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.consumeAsFlow
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: Flow<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 {
internal 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()
@@ -58,30 +59,42 @@ 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: Flow<CoTMessage> = _inboundMessages.asFlow()
private val _inboundMessages = MutableSharedFlow<InboundCoTMessage>(extraBufferCapacity = 64)
override val inboundMessages: SharedFlow<InboundCoTMessage> = _inboundMessages.asSharedFlow()
// Unbounded channel preserves FIFO ordering of inbound CoT messages under load.
// onMessage is a non-suspend callback, so we trySend (always succeeds for UNLIMITED)
// and a single consumer coroutine drains into _inboundMessages in order.
private var inboundChannel: Channel<CoTMessage>? = null
private var inboundDrainJob: Job? = null
// 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 var lastBroadcastPositions = mutableMapOf<Int, Int>()
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
if (_isRunning.value) {
Logger.w { "TAKServerManager already running" }
return
}
// Assign scope AFTER the guard so a second concurrent start() can never
// overwrite the active scope without actually restarting the server.
this.scope = scope
scope.launch {
// Wire up inbound message handler BEFORE starting so no messages are lost.
val channel = Channel<CoTMessage>(Channel.UNLIMITED)
inboundChannel = channel
inboundDrainJob = scope.launch { channel.consumeAsFlow().collect { _inboundMessages.emit(it) } }
takServer.onMessage = { cotMessage -> channel.trySend(cotMessage) }
// Use tryEmit (non-suspending) with extraBufferCapacity to avoid launching a
// new coroutine per message, which would create unbounded coroutines under
// high message rates and could reorder messages.
takServer.onMessage = { cotMessage, clientInfo ->
if (!_inboundMessages.tryEmit(InboundCoTMessage(cotMessage, clientInfo))) {
Logger.w { "TAK inbound message buffer full; dropping message from ${clientInfo?.id}" }
}
}
takServer.onClientConnected = { drainOfflineQueue() }
val result = takServer.start(scope)
if (result.isSuccess) {
@@ -91,81 +104,68 @@ class TAKServerManagerImpl(private val takServer: TAKServer) : TAKServerManager
Logger.e(result.exceptionOrNull()) { "Failed to start TAK Server" }
// Clear onMessage if start failed so we don't hold a reference unnecessarily
takServer.onMessage = null
inboundDrainJob?.cancel()
inboundDrainJob = null
channel.close()
inboundChannel = null
}
}
}
override fun stop() {
takServer.stop()
takServer.onMessage = null
inboundChannel?.close()
inboundChannel = null
inboundDrainJob?.cancel()
inboundDrainJob = null
// Flip the running flag and null out the scope BEFORE stopping the server so
// any broadcast()/drainOfflineQueue() that races stop() sees _isRunning=false
// and exits early instead of launching coroutines on a scope that is about to
// be discarded.
_isRunning.value = false
scope = null
takServer.onMessage = null
takServer.stop()
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) }
if (!_isRunning.value) return
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) {
if (!_isRunning.value) return
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() {
if (!_isRunning.value) return
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,58 @@
/*
* Copyright (c) 2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.core.takserver
import org.meshtastic.proto.MemberRole
import org.meshtastic.proto.Team
/**
* Internal helpers shared by [TAKPacketConversion] (legacy v1, firmware <= 2.7.x) and [TAKPacketV2Conversion]
* (firmware >= 2.8.x). Both paths map between the SDK's [CoTMessage] model and Meshtastic's Wire-generated proto types
* using identical logic for color/role lookup and the "<senderUid>|<messageId>" smuggled-callsign format that survives
* the wire round trip.
*/
internal object TakConversionHelpers {
/** Split a `<senderUid>|<messageId>` smuggled callsign back into its parts. */
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)
}
}
/** Map a [Team] proto enum back to its CoT color-name string. Unspecified -> default. */
fun teamToColorName(team: Team?): String {
if (team == null || team == Team.Unspecifed_Color) return DEFAULT_TAK_TEAM_NAME
return team.toTakTeamName()
}
/** Map a [MemberRole] proto enum back to its CoT role-name string. Unspecified -> default. */
fun roleToName(role: MemberRole?): String {
if (role == null || role == MemberRole.Unspecifed) return DEFAULT_TAK_ROLE_NAME
return role.toTakRoleName()
}
/** Reverse lookup from CoT color-name string to [Team] proto enum value (0 = Unspecified). */
fun getTeamValue(name: String): Int = Team.entries.find { it.name.equals(name, ignoreCase = true) }?.value ?: 0
/** Reverse lookup from CoT role-name string to [MemberRole] proto enum value (0 = Unspecified). */
fun getMemberRoleValue(roleName: String): Int =
MemberRole.entries.find { it.name.equals(roleName.replace(" ", ""), ignoreCase = true) }?.value ?: 0
}

View File

@@ -14,18 +14,12 @@
* 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.
* Platform-specific loader for TAK test fixture XML files bundled in test resources.
*
* Defines the contract for routing Direct (unfragmented) vs Fountain-encoded packets, and processing decompressed
* EXI/Zlib XML payloads.
* On JVM/Android the resources are loaded via the classloader. On iOS the test runner is not supported and this throws
* [UnsupportedOperationException].
*/
interface CoTHandler {
suspend fun sendGenericCoT(cotMessage: CoTMessage)
suspend fun handleIncomingForwarderPacket(payload: ByteArray, senderNodeNum: Int)
}
internal expect fun loadTakFixtureXml(name: String): String

View File

@@ -0,0 +1,193 @@
/*
* 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", "ReturnCount")
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 kotlinx.coroutines.sync.Mutex
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()
// Prevents concurrent invocations from racing the _isRunning check-then-set.
private val runMutex = Mutex()
companion object {
/** Delay between sends to let the radio transmit and receive ACK. */
private const val SEND_DELAY_MS = 5_000L
/** 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() {
// Use tryLock to prevent concurrent test runs: if another coroutine is already
// inside runAll(), tryLock returns false and we bail immediately. This is safer
// than the check-then-set pattern which has a TOCTOU race in multi-threaded
// coroutine dispatch.
if (!runMutex.tryLock()) return
try {
_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
val passed = allResults.count { it.passed }
val failed = allResults.size - passed
Logger.i { "TAK Mesh Test complete: $passed/${allResults.size} passed, $failed failed" }
} finally {
_isRunning.value = false
runMutex.unlock()
}
}
private suspend fun runSingleFixture(name: String): TakTestResult {
// Load fixture XML from bundled resources via platform-specific loader
val xml =
try {
loadTakFixtureXml(name)
} catch (e: Exception) {
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 compressed = TakSdkCompressor.compressCoT(strippedXml, 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: Exception) {
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: Exception) {
Logger.w(e) { "TAK Test: $name send failed: ${e.message}" }
return TakTestResult(name, xml.length, wirePayload.size, false, "Send failed: ${e.message}")
}
}
}

View File

@@ -0,0 +1,34 @@
/*
* 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
/**
* Expect/actual wrapper for the TAKPacket-SDK compression pipeline.
*
* On JVM/Android the SDK's [org.meshtastic.tak.CotXmlParser] and [org.meshtastic.tak.TakCompressor] are available. On
* iOS they are not, so the actual throws [UnsupportedOperationException].
*/
internal expect object TakSdkCompressor {
/**
* Parse CoT XML via the SDK and compress with remarks-fallback.
*
* @return compressed wire payload, or `null` if the packet exceeds [maxBytes] even without remarks.
* @throws Exception on parse or compression failure.
*/
fun compressCoT(xml: String, maxBytes: Int): ByteArray?
}

View File

@@ -0,0 +1,62 @@
/*
* Copyright (c) 2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.core.takserver
import org.meshtastic.proto.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,75 @@
/*
* Copyright (c) 2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.core.takserver
import org.meshtastic.proto.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

@@ -27,33 +27,22 @@ 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,
)
nodeRepository: NodeRepository,
): TAKMeshIntegration =
TAKMeshIntegration(takServerManager, commandSender, serviceRepository, meshConfigHandler, nodeRepository)
}

View File

@@ -1,468 +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,238 @@
/*
* 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

@@ -109,18 +109,11 @@ class TAKDefaultsTest {
// ── keepalive / idle timeout constants ─────────────────────────────────────
@Test
fun `keepalive stale window is wider than keepalive interval`() {
val staleMs = TAK_KEEPALIVE_INTERVAL_MS * TAK_KEEPALIVE_STALE_MULTIPLIER
fun `keepalive interval is below ATAK stale threshold`() {
// ATAK's default stale threshold is 15 seconds; our keepalive must be below that.
assertTrue(
staleMs > TAK_KEEPALIVE_INTERVAL_MS,
"Stale window ($staleMs ms) must exceed keepalive interval ($TAK_KEEPALIVE_INTERVAL_MS ms)",
TAK_KEEPALIVE_INTERVAL_MS < 15_000L,
"Keepalive interval ($TAK_KEEPALIVE_INTERVAL_MS ms) must be below ATAK's 15s stale threshold",
)
}
@Test
fun `idle timeout exceeds keepalive stale window`() {
val idleTimeoutMs = TAK_KEEPALIVE_INTERVAL_MS * TAK_READ_IDLE_TIMEOUT_MULTIPLIER
val staleMs = TAK_KEEPALIVE_INTERVAL_MS * TAK_KEEPALIVE_STALE_MULTIPLIER
assertTrue(idleTimeoutMs > staleMs, "Idle timeout ($idleTimeoutMs ms) must exceed stale window ($staleMs ms)")
}
}

View File

@@ -0,0 +1,486 @@
/*
* 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.Severity
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.test.UnconfinedTestDispatcher
import kotlinx.coroutines.test.runTest
import okio.ByteString.Companion.toByteString
import org.meshtastic.core.model.ConnectionState
import org.meshtastic.core.model.DataPacket
import org.meshtastic.core.model.MyNodeInfo
import org.meshtastic.core.model.Node
import org.meshtastic.core.model.NodeSortOption
import org.meshtastic.core.model.Position
import org.meshtastic.core.model.service.ServiceAction
import org.meshtastic.core.model.service.TracerouteResponse
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.proto.AdminMessage
import org.meshtastic.proto.Channel
import org.meshtastic.proto.ChannelSet
import org.meshtastic.proto.ClientNotification
import org.meshtastic.proto.Config
import org.meshtastic.proto.Data
import org.meshtastic.proto.DeviceMetadata
import org.meshtastic.proto.DeviceUIConfig
import org.meshtastic.proto.LocalConfig
import org.meshtastic.proto.LocalModuleConfig
import org.meshtastic.proto.LocalStats
import org.meshtastic.proto.MeshPacket
import org.meshtastic.proto.ModuleConfig
import org.meshtastic.proto.PortNum
import org.meshtastic.proto.TAKPacket
import org.meshtastic.proto.User
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertTrue
import kotlin.time.Clock
import kotlin.time.Duration.Companion.minutes
/**
* Tests for [TAKMeshIntegration] lifecycle, routing, and protocol gating.
*
* These tests use fakes for all 5 dependencies and run in commonTest. The v2 outbound SDK-dependent happy path is
* tested separately in jvmTest.
*/
@Suppress("TooManyFunctions")
class TAKMeshIntegrationTest {
// ── Fakes ────────────────────────────────────────────────────────────────
private class FakeTAKServerManager : TAKServerManager {
private val _isRunning = MutableStateFlow(false)
override val isRunning: StateFlow<Boolean> = _isRunning.asStateFlow()
override val connectionCount: StateFlow<Int> = MutableStateFlow(0)
private val _inboundMessages = MutableSharedFlow<InboundCoTMessage>(extraBufferCapacity = 64)
override val inboundMessages: SharedFlow<InboundCoTMessage> = _inboundMessages.asSharedFlow()
val broadcasts = mutableListOf<CoTMessage>()
val rawBroadcasts = mutableListOf<String>()
var startCount = 0
var stopped = false
override fun start(scope: CoroutineScope) {
startCount++
_isRunning.value = true
}
override fun stop() {
stopped = true
_isRunning.value = false
}
override fun broadcast(cotMessage: CoTMessage) {
broadcasts.add(cotMessage)
}
override fun broadcastRawXml(xml: String) {
rawBroadcasts.add(xml)
}
suspend fun emitInbound(cotMessage: CoTMessage, clientInfo: TAKClientInfo? = null) {
_inboundMessages.emit(InboundCoTMessage(cotMessage, clientInfo))
}
}
private class FakeCommandSender : CommandSender {
val sentPackets = mutableListOf<DataPacket>()
override fun sendData(p: DataPacket) {
sentPackets.add(p)
}
override fun getCurrentPacketId(): Long = 0L
override fun getCachedLocalConfig(): LocalConfig = LocalConfig()
override fun getCachedChannelSet(): ChannelSet = ChannelSet()
override fun generatePacketId(): Int = 1
override fun sendAdmin(destNum: Int, requestId: Int, wantResponse: Boolean, initFn: () -> AdminMessage) {}
override suspend fun sendAdminAwait(
destNum: Int,
requestId: Int,
wantResponse: Boolean,
initFn: () -> AdminMessage,
): Boolean = true
override fun sendPosition(pos: org.meshtastic.proto.Position, destNum: Int?, wantResponse: Boolean) {}
override fun requestPosition(destNum: Int, currentPosition: Position) {}
override fun setFixedPosition(destNum: Int, pos: Position) {}
override fun requestUserInfo(destNum: Int) {}
override fun requestTraceroute(requestId: Int, destNum: Int) {}
override fun requestTelemetry(requestId: Int, destNum: Int, typeValue: Int) {}
override fun requestNeighborInfo(requestId: Int, destNum: Int) {}
}
private class FakeServiceRepository : ServiceRepository {
private val _meshPacketFlow = MutableSharedFlow<MeshPacket>(replay = 1, extraBufferCapacity = 64)
override val meshPacketFlow: Flow<MeshPacket> = _meshPacketFlow
override val connectionState: StateFlow<ConnectionState> = MutableStateFlow(ConnectionState.Disconnected)
override fun setConnectionState(connectionState: ConnectionState) {}
override val clientNotification: StateFlow<ClientNotification?> = MutableStateFlow(null)
override fun setClientNotification(notification: ClientNotification?) {}
override fun clearClientNotification() {}
override val errorMessage: StateFlow<String?> = MutableStateFlow(null)
override fun setErrorMessage(text: String, severity: Severity) {}
override fun clearErrorMessage() {}
override val connectionProgress: StateFlow<String?> = MutableStateFlow(null)
override fun setConnectionProgress(text: String) {}
override suspend fun emitMeshPacket(packet: MeshPacket) {
_meshPacketFlow.emit(packet)
}
override val tracerouteResponse: StateFlow<TracerouteResponse?> = MutableStateFlow(null)
override fun setTracerouteResponse(value: TracerouteResponse?) {}
override fun clearTracerouteResponse() {}
override val neighborInfoResponse: StateFlow<String?> = MutableStateFlow(null)
override fun setNeighborInfoResponse(value: String?) {}
override fun clearNeighborInfoResponse() {}
override val serviceAction: Flow<ServiceAction> = MutableSharedFlow()
override suspend fun onServiceAction(action: ServiceAction) {}
}
private class FakeMeshConfigHandler : MeshConfigHandler {
override val localConfig: StateFlow<LocalConfig> = MutableStateFlow(LocalConfig())
override val moduleConfig: StateFlow<LocalModuleConfig> = MutableStateFlow(LocalModuleConfig())
override fun handleDeviceConfig(config: Config) {}
override fun handleModuleConfig(config: ModuleConfig) {}
override fun handleChannel(channel: Channel) {}
override fun handleDeviceUIConfig(config: DeviceUIConfig) {}
}
private class FakeNodeRepository(firmwareVersion: String? = "2.8.0.0") : NodeRepository {
private val _myNodeInfo =
MutableStateFlow(
firmwareVersion?.let {
MyNodeInfo(
myNodeNum = 1,
hasGPS = false,
model = null,
firmwareVersion = it,
couldUpdate = false,
shouldUpdate = false,
currentPacketId = 0L,
messageTimeoutMsec = 0,
minAppVersion = 0,
maxChannels = 8,
hasWifi = false,
channelUtilization = 0f,
airUtilTx = 0f,
deviceId = null,
)
},
)
override val myNodeInfo: StateFlow<MyNodeInfo?> = _myNodeInfo
fun setFirmwareVersion(version: String?) {
_myNodeInfo.value =
version?.let {
MyNodeInfo(
myNodeNum = 1,
hasGPS = false,
model = null,
firmwareVersion = it,
couldUpdate = false,
shouldUpdate = false,
currentPacketId = 0L,
messageTimeoutMsec = 0,
minAppVersion = 0,
maxChannels = 8,
hasWifi = false,
channelUtilization = 0f,
airUtilTx = 0f,
deviceId = null,
)
}
}
override val ourNodeInfo: StateFlow<Node?> = MutableStateFlow(null)
override val myId: StateFlow<String?> = MutableStateFlow(null)
override val localStats: StateFlow<LocalStats> = MutableStateFlow(LocalStats())
override val nodeDBbyNum: StateFlow<Map<Int, Node>> = MutableStateFlow(emptyMap())
override val onlineNodeCount: Flow<Int> = MutableStateFlow(0)
override val totalNodeCount: Flow<Int> = MutableStateFlow(0)
override fun updateLocalStats(stats: LocalStats) {}
override fun effectiveLogNodeId(nodeNum: Int): Flow<Int> = MutableStateFlow(0)
override fun getNode(userId: String): Node = Node(num = 0)
override fun getUser(nodeNum: Int): User = User()
override fun getUser(userId: String): User = User()
override fun getNodes(
sort: NodeSortOption,
filter: String,
includeUnknown: Boolean,
onlyOnline: Boolean,
onlyDirect: Boolean,
): Flow<List<Node>> = MutableStateFlow(emptyList())
override suspend fun getNodesOlderThan(lastHeard: Int): List<Node> = emptyList()
override suspend fun getUnknownNodes(): List<Node> = emptyList()
override suspend fun clearNodeDB(preserveFavorites: Boolean) {}
override suspend fun clearMyNodeInfo() {}
override suspend fun deleteNode(num: Int) {}
override suspend fun deleteNodes(nodeNums: List<Int>) {}
override suspend fun setNodeNotes(num: Int, notes: String) {}
override suspend fun upsert(node: Node) {}
override suspend fun installConfig(mi: MyNodeInfo, nodes: List<Node>) {}
override suspend fun insertMetadata(nodeNum: Int, metadata: DeviceMetadata) {}
}
// ── Helpers ───────────────────────────────────────────────────────────────
private data class TestHarness(
val serverManager: FakeTAKServerManager = FakeTAKServerManager(),
val commandSender: FakeCommandSender = FakeCommandSender(),
val serviceRepository: FakeServiceRepository = FakeServiceRepository(),
val meshConfigHandler: FakeMeshConfigHandler = FakeMeshConfigHandler(),
val nodeRepository: FakeNodeRepository = FakeNodeRepository(),
) {
val integration =
TAKMeshIntegration(
takServerManager = serverManager,
commandSender = commandSender,
serviceRepository = serviceRepository,
meshConfigHandler = meshConfigHandler,
nodeRepository = nodeRepository,
)
}
// ── Lifecycle tests ──────────────────────────────────────────────────────
@Test
fun `start launches TAKServerManager`() = runTest(UnconfinedTestDispatcher()) {
val h = TestHarness()
h.integration.start(backgroundScope)
assertEquals(1, h.serverManager.startCount)
}
@Test
fun `stop cancels jobs and stops TAKServerManager`() = runTest(UnconfinedTestDispatcher()) {
val h = TestHarness()
h.integration.start(backgroundScope)
h.integration.stop()
assertTrue(h.serverManager.stopped)
}
@Test
fun `double start is idempotent`() = runTest(UnconfinedTestDispatcher()) {
val h = TestHarness()
h.integration.start(backgroundScope)
h.integration.start(backgroundScope) // second start
assertEquals(1, h.serverManager.startCount, "TAKServerManager should only be started once")
}
@Test
fun `stop then inbound TAK message does not forward to mesh`() = runTest(UnconfinedTestDispatcher()) {
val h = TestHarness()
h.integration.start(backgroundScope)
h.integration.stop()
h.serverManager.emitInbound(createPli("after-stop"))
assertTrue(h.commandSender.sentPackets.isEmpty())
}
// ── Inbound mesh → TAK client (V1) ──────────────────────────────────────
@Test
fun `inbound V1 PLI packet is broadcast to TAK clients`() = runTest(UnconfinedTestDispatcher()) {
val h = TestHarness()
h.integration.start(backgroundScope)
h.serviceRepository.emitMeshPacket(createV1PliMeshPacket())
assertTrue(h.serverManager.broadcasts.isNotEmpty(), "Expected broadcasts for V1 PLI")
assertTrue(h.serverManager.broadcasts.first().type.startsWith("a-f-"))
}
@Test
fun `inbound packet on unrelated port is ignored`() = runTest(UnconfinedTestDispatcher()) {
val h = TestHarness()
h.integration.start(backgroundScope)
val textPacket =
MeshPacket(
decoded =
Data(portnum = PortNum.TEXT_MESSAGE_APP, payload = "hello".encodeToByteArray().toByteString()),
)
h.serviceRepository.emitMeshPacket(textPacket)
assertTrue(h.serverManager.broadcasts.isEmpty())
assertTrue(h.serverManager.rawBroadcasts.isEmpty())
}
// ── Firmware gating ──────────────────────────────────────────────────────
@Test
fun `null firmware defaults to V2 protocol`() = runTest(UnconfinedTestDispatcher()) {
val h = TestHarness(nodeRepository = FakeNodeRepository(firmwareVersion = null))
h.integration.start(backgroundScope)
h.serverManager.emitInbound(createPli("test-v2-default"))
// In commonTest without TAKPacket-SDK, v2 path catches and falls back.
// Verify the code didn't crash and attempted to send.
if (h.commandSender.sentPackets.isNotEmpty()) {
val sent = h.commandSender.sentPackets.first()
assertEquals(PortNum.ATAK_PLUGIN_V2.value, sent.dataType)
}
}
@Test
fun `legacy firmware sends on V1 port 72`() = runTest(UnconfinedTestDispatcher()) {
val h = TestHarness(nodeRepository = FakeNodeRepository(firmwareVersion = "2.7.0.0"))
h.integration.start(backgroundScope)
h.serverManager.emitInbound(createPli("test-v1"))
if (h.commandSender.sentPackets.isNotEmpty()) {
val sent = h.commandSender.sentPackets.first()
assertEquals(PortNum.ATAK_PLUGIN.value, sent.dataType)
}
}
@Test
fun `legacy firmware drops non-PLI non-GeoChat types`() = runTest(UnconfinedTestDispatcher()) {
val h = TestHarness(nodeRepository = FakeNodeRepository(firmwareVersion = "2.7.0.0"))
h.integration.start(backgroundScope)
val marker = CoTMessage(uid = "marker-1", type = "a-h-G", stale = Clock.System.now() + 5.minutes)
h.serverManager.emitInbound(marker)
assertTrue(h.commandSender.sentPackets.isEmpty())
}
// ── GeoChat callsign enrichment ──────────────────────────────────────────
@Test
fun `GeoChat without callsign is enriched from client info`() = runTest(UnconfinedTestDispatcher()) {
val h = TestHarness(nodeRepository = FakeNodeRepository(firmwareVersion = "2.7.0.0"))
h.integration.start(backgroundScope)
val chatMsg =
CoTMessage(
uid = "GeoChat.test.All Chat Rooms.1234",
type = "b-t-f",
how = "h-g-i-g-o",
stale = Clock.System.now() + 5.minutes,
contact = null,
chat = CoTChat(message = "hello", senderCallsign = null),
)
val clientInfo = TAKClientInfo(id = "client-1", endpoint = "127.0.0.1:8089", callsign = "ALPHA-1")
h.serverManager.emitInbound(chatMsg, clientInfo)
// GeoChat on legacy V1 should produce a sent packet with the enriched callsign
if (h.commandSender.sentPackets.isNotEmpty()) {
val sent = h.commandSender.sentPackets.first()
assertEquals(PortNum.ATAK_PLUGIN.value, sent.dataType)
}
}
// ── Helpers ───────────────────────────────────────────────────────────────
private fun createPli(uid: String) =
CoTMessage.pli(uid = uid, callsign = "TEST", latitude = 33.0, longitude = -84.0)
private fun createV1PliMeshPacket(): MeshPacket {
val takPacket =
TAKPacket(
contact = org.meshtastic.proto.Contact(callsign = "BRAVO", device_callsign = "bravo-uid"),
pli =
org.meshtastic.proto.PLI(
latitude_i = 330000000,
longitude_i = -840000000,
altitude = 100,
speed = 0,
course = 0,
),
group =
org.meshtastic.proto.Group(
team = org.meshtastic.proto.Team.Cyan,
role = org.meshtastic.proto.MemberRole.TeamMember,
),
status = org.meshtastic.proto.Status(battery = 85),
)
return MeshPacket(
decoded = Data(portnum = PortNum.ATAK_PLUGIN, payload = TAKPacket.ADAPTER.encode(takPacket).toByteString()),
)
}
}

View File

@@ -0,0 +1,137 @@
/*
* 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 cotMessageWithoutParsedDetailReturnsNull() {
// 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

@@ -0,0 +1,251 @@
/*
* 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.test.advanceUntilIdle
import kotlinx.coroutines.test.runTest
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertTrue
import kotlin.time.Clock
import kotlin.time.Duration.Companion.minutes
/**
* Tests for [TAKServerManagerImpl] offline message queue behavior:
* - FIFO eviction at 50-message cap (T074)
* - Per-message TTL expiry after 5 minutes (T074)
* - Replay of queued messages on client reconnect (T074)
*/
class TAKServerManagerTest {
/** Fake TAKServer that records broadcasts and simulates connection state. */
private class FakeTAKServer : TAKServer {
override val connectionCount: StateFlow<Int> = MutableStateFlow(0)
override var onMessage: ((CoTMessage, TAKClientInfo?) -> Unit)? = null
override var onClientConnected: (() -> Unit)? = null
var stubHasConnections = false
val broadcasts = mutableListOf<CoTMessage>()
val rawBroadcasts = mutableListOf<String>()
override suspend fun start(scope: CoroutineScope): Result<Unit> = Result.success(Unit)
override fun stop() {}
override suspend fun broadcast(cotMessage: CoTMessage) {
broadcasts.add(cotMessage)
}
override suspend fun broadcastRawXml(xml: String) {
rawBroadcasts.add(xml)
}
override suspend fun hasConnections(): Boolean = stubHasConnections
}
private fun createPli(uid: String): CoTMessage {
val now = Clock.System.now()
return CoTMessage(
uid = uid,
type = DEFAULT_PLI_COT_TYPE,
stale = now + 5.minutes,
latitude = 45.0,
longitude = -90.0,
)
}
@Test
fun `offline queue caps at 50 messages with FIFO eviction`() = runTest {
val fakeTakServer = FakeTAKServer()
fakeTakServer.stubHasConnections = false
val manager = TAKServerManagerImpl(fakeTakServer)
manager.start(this)
advanceUntilIdle()
// Queue 55 messages (5 more than the cap)
repeat(55) { i -> manager.broadcast(createPli("uid-$i")) }
advanceUntilIdle()
// Now simulate a client connecting — drain the queue
fakeTakServer.stubHasConnections = true
manager.drainOfflineQueue()
advanceUntilIdle()
// Should have drained exactly 50 messages (the oldest 5 evicted by FIFO)
assertEquals(50, fakeTakServer.broadcasts.size)
// The first message drained should be uid-5 (uid-0 through uid-4 were evicted)
assertEquals("uid-5", fakeTakServer.broadcasts.first().uid)
// The last message drained should be uid-54
assertEquals("uid-54", fakeTakServer.broadcasts.last().uid)
manager.stop()
}
@Test
fun `offline queue expires messages after 5 minute TTL`() = runTest {
val fakeTakServer = FakeTAKServer()
fakeTakServer.stubHasConnections = false
val manager = TAKServerManagerImpl(fakeTakServer)
manager.start(this)
advanceUntilIdle()
// Queue 3 messages — these will be stamped with Clock.System.now()
repeat(3) { i -> manager.broadcast(createPli("msg-$i")) }
advanceUntilIdle()
// Immediately drain (no time has passed) — all messages should still be valid
fakeTakServer.stubHasConnections = true
manager.drainOfflineQueue()
advanceUntilIdle()
// All 3 messages should be delivered (none expired yet since <5 min elapsed)
assertEquals(3, fakeTakServer.broadcasts.size)
assertEquals("msg-0", fakeTakServer.broadcasts[0].uid)
assertEquals("msg-1", fakeTakServer.broadcasts[1].uid)
assertEquals("msg-2", fakeTakServer.broadcasts[2].uid)
manager.stop()
}
@Test
fun `offline queue replays messages in order on client reconnect`() = runTest {
val fakeTakServer = FakeTAKServer()
fakeTakServer.stubHasConnections = false
val manager = TAKServerManagerImpl(fakeTakServer)
manager.start(this)
advanceUntilIdle()
// Queue messages in order
val uids = listOf("alpha", "bravo", "charlie", "delta")
uids.forEach { uid -> manager.broadcast(createPli(uid)) }
advanceUntilIdle()
// Simulate client reconnect
fakeTakServer.stubHasConnections = true
manager.drainOfflineQueue()
advanceUntilIdle()
// Messages should be replayed in FIFO order
assertEquals(uids, fakeTakServer.broadcasts.map { it.uid })
manager.stop()
}
@Test
fun `broadcast goes directly to TAK server when clients connected`() = runTest {
val fakeTakServer = FakeTAKServer()
fakeTakServer.stubHasConnections = true
val manager = TAKServerManagerImpl(fakeTakServer)
manager.start(this)
advanceUntilIdle()
manager.broadcast(createPli("direct"))
advanceUntilIdle()
// Message should be broadcast directly, not queued
assertEquals(1, fakeTakServer.broadcasts.size)
assertEquals("direct", fakeTakServer.broadcasts.first().uid)
manager.stop()
}
// ── T076: Port conflict / start failure ─────────────────────────────────────
/** Fake TAKServer that simulates a port-conflict failure on start. */
private class FailingTAKServer : TAKServer {
override val connectionCount: StateFlow<Int> = MutableStateFlow(0)
override var onMessage: ((CoTMessage, TAKClientInfo?) -> Unit)? = null
override var onClientConnected: (() -> Unit)? = null
override suspend fun start(scope: CoroutineScope): Result<Unit> =
Result.failure(IllegalStateException("Address already in use: port 8089"))
override fun stop() {}
override suspend fun broadcast(cotMessage: CoTMessage) {}
override suspend fun broadcastRawXml(xml: String) {}
override suspend fun hasConnections(): Boolean = false
}
@Test
fun `start failure due to port conflict leaves isRunning false`() = runTest {
val failingServer = FailingTAKServer()
val manager = TAKServerManagerImpl(failingServer)
manager.start(this)
advanceUntilIdle()
// Manager should NOT be running after start failure
assertEquals(false, manager.isRunning.value)
}
@Test
fun `start failure clears onMessage callback`() = runTest {
val failingServer = FailingTAKServer()
val manager = TAKServerManagerImpl(failingServer)
manager.start(this)
advanceUntilIdle()
// onMessage should be cleared after failed start
assertEquals(null, failingServer.onMessage)
}
@Test
fun `broadcast is no-op after failed start`() = runTest {
val failingServer = FailingTAKServer()
val manager = TAKServerManagerImpl(failingServer)
manager.start(this)
advanceUntilIdle()
// Broadcast should silently do nothing (not crash)
manager.broadcast(createPli("should-be-dropped"))
advanceUntilIdle()
// No exception = pass. isRunning is false so broadcast exits early.
}
@Test
fun `broadcastRawXml forwards to TAKServer when running`() = runTest {
val fakeTakServer = FakeTAKServer()
fakeTakServer.stubHasConnections = true
val manager = TAKServerManagerImpl(fakeTakServer)
manager.start(this)
advanceUntilIdle()
val rawXml = """<event type="a-f-G" uid="test"/>"""
manager.broadcastRawXml(rawXml)
advanceUntilIdle()
assertEquals(1, fakeTakServer.rawBroadcasts.size)
assertEquals(rawXml, fakeTakServer.rawBroadcasts.first())
}
@Test
fun `broadcastRawXml is no-op when not running`() = runTest {
val fakeTakServer = FakeTAKServer()
val manager = TAKServerManagerImpl(fakeTakServer)
// Don't call start()
manager.broadcastRawXml("""<event type="a-f-G"/>""")
advanceUntilIdle()
assertTrue(fakeTakServer.rawBroadcasts.isEmpty())
}
}

View File

@@ -0,0 +1,63 @@
/*
* 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.assertTrue
/**
* Tests for [TakV2Compressor] size boundary validation (T077).
*
* Verifies that:
* - MAX_DECOMPRESSED_SIZE is a reasonable constant (4096 bytes)
* - Dictionary IDs are correctly defined
* - The uncompressed marker (0xFF) is correct
*/
class TakV2CompressorBoundaryTest {
@Test
fun `MAX_DECOMPRESSED_SIZE is 4096 bytes`() {
assertEquals(4096, TakV2Compressor.MAX_DECOMPRESSED_SIZE)
}
@Test
fun `MAX_DECOMPRESSED_SIZE is greater than mesh MTU`() {
// MAX_TAK_WIRE_PAYLOAD_BYTES = 225. Decompressed size must be larger than the
// compressed wire payload to be useful. Also ensures there's a reasonable
// amplification cap to prevent decompression bombs.
assertTrue(TakV2Compressor.MAX_DECOMPRESSED_SIZE > MAX_TAK_WIRE_PAYLOAD_BYTES)
}
@Test
fun `MAX_DECOMPRESSED_SIZE is bounded to prevent memory exhaustion`() {
// A decompression bomb could expand a small payload into megabytes. The limit
// must be small enough to prevent OOM in constrained Android environments.
assertTrue(TakV2Compressor.MAX_DECOMPRESSED_SIZE <= 65536)
}
@Test
fun `dictionary IDs are correctly assigned`() {
assertEquals(0, TakV2Compressor.DICT_ID_NON_AIRCRAFT)
assertEquals(1, TakV2Compressor.DICT_ID_AIRCRAFT)
}
@Test
fun `uncompressed marker is 0xFF`() {
assertEquals(0xFF, TakV2Compressor.DICT_ID_UNCOMPRESSED)
}
}

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

@@ -0,0 +1,22 @@
/*
* 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
/** 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,50 @@
/*
* 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, TAKClientInfo?) -> Unit)? = null
override var onClientConnected: (() -> 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 broadcastRawXml(xml: String) = Unit
override suspend fun hasConnections(): Boolean = false
}
actual fun createTAKServer(dispatchers: CoroutineDispatchers, port: Int): TAKServer = NoopTAKServer()

View File

@@ -0,0 +1,21 @@
/*
* 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
/** iOS stub — the TAK mesh test runner is not supported on iOS targets. */
internal actual fun loadTakFixtureXml(name: String): String =
throw UnsupportedOperationException("TAK fixture loading is not supported on iOS.")

View File

@@ -14,18 +14,10 @@
* 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
import okio.ByteString.Companion.toByteString
internal actual object TakSdkCompressor {
internal expect object ZlibCodec {
fun compress(data: ByteArray): ByteArray?
fun decompress(data: ByteArray): ByteArray?
}
internal object CryptoCodec {
private const val PREFIX_SIZE = 8
fun sha256Prefix8(data: ByteArray): ByteArray = data.toByteString().sha256().toByteArray().copyOf(PREFIX_SIZE)
actual fun compressCoT(xml: String, maxBytes: Int): ByteArray? =
throw UnsupportedOperationException("TAKPacket-SDK is not available on iOS")
}

View File

@@ -0,0 +1,71 @@
/*
* 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.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 the packet and convert to CoT XML via the common conversion path.
val packet = decompress(wirePayload)
return packet.toCoTMessage()?.toXml()
?: throw UnsupportedOperationException(
"iOS stub: TAKPacketV2 could not be converted to CoT XML for packet: $packet",
)
}
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,105 +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.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
}
}

View File

@@ -0,0 +1,336 @@
/*
* 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))
}
}
}
.onFailure { e -> Logger.w(e) { "Failed to parse CoT XML from TAK client ${currentClientInfo.id}" } }
}
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()
try {
socket.close()
} catch (_: Exception) {}
}
}
}

View File

@@ -0,0 +1,299 @@
/*
* 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.withContext
import org.meshtastic.core.di.CoroutineDispatchers
import java.net.InetAddress
import java.net.ServerSocket
import java.net.Socket
import java.util.concurrent.locks.ReentrantLock
import javax.net.ssl.SSLServerSocket
import kotlin.concurrent.Volatile
import kotlin.concurrent.withLock
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
@Volatile private var running = false
private var serverScope: CoroutineScope? = null
private var acceptJob: Job? = null
private val connectionsLock = ReentrantLock()
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
try {
serverSocket?.close()
} catch (_: Exception) {}
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" }
try {
clientSocket.close()
} catch (_: Exception) {}
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) {
connectionsLock.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) {
connectionsLock.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) {
connectionsLock.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
// Guard the snapshot+clear with the same lock used by the coroutine accept/disconnect
// paths to avoid concurrent modification or a stale connectionCount during shutdown.
val toClose =
connectionsLock.withLock {
val snapshot = connections.values.toList()
connections.clear()
_connectionCount.value = 0
snapshot
}
toClose.forEach { it.close() }
try {
serverSocket?.close()
} catch (_: Exception) {}
serverSocket = null
serverScope = null
Logger.i { "TAK Server stopped" }
}
override suspend fun broadcast(cotMessage: CoTMessage) {
val currentConnections = connectionsLock.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 = connectionsLock.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 = connectionsLock.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,160 @@
/*
* 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. */
@Synchronized
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. */
@Synchronized
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`. */
@Synchronized
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

@@ -0,0 +1,25 @@
/*
* 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
/** JVM/Android: load fixture XML from bundled test resources via the classloader. */
internal actual fun loadTakFixtureXml(name: String): String {
val stream =
object {}::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,30 @@
/*
* 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.tak.CotXmlParser
import org.meshtastic.tak.TakCompressor
internal actual object TakSdkCompressor {
actual fun compressCoT(xml: String, maxBytes: Int): ByteArray? {
val sdkParser = CotXmlParser()
val sdkData = sdkParser.parse(xml)
val compressor = TakCompressor()
return compressor.compressWithRemarksFallback(sdkData, maxBytes)
}
}

View File

@@ -0,0 +1,481 @@
/*
* 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 okio.ByteString.Companion.toByteString
import org.meshtastic.proto.TAKPacketV2
import org.meshtastic.tak.TakPacketV2Data
import org.meshtastic.proto.AircraftTrack as WireAircraftTrack
import org.meshtastic.proto.CasevacReport as WireCasevacReport
import org.meshtastic.proto.CotGeoPoint as WireCotGeoPoint
import org.meshtastic.proto.DrawnShape as WireDrawnShape
import org.meshtastic.proto.EmergencyAlert as WireEmergencyAlert
import org.meshtastic.proto.GeoChat as WireGeoChat
import org.meshtastic.proto.Marker as WireMarker
import org.meshtastic.proto.RangeAndBearing as WireRangeAndBearing
import org.meshtastic.proto.Route as WireRoute
import org.meshtastic.proto.TaskRequest as WireTaskRequest
import org.meshtastic.proto.Team as WireTeam
import org.meshtastic.tak.TakCompressor as SdkCompressor
/**
* JVM/Android implementation of TakV2Compressor. Delegates to TAKPacket-SDK's TakCompressor for zstd dictionary
* compression.
*
* The SDK compressor is constructed lazily and its result is cached in a nullable field so that a native-library
* failure (e.g. missing Android .so) does NOT poison this object. Without lazy/try-catch, a failure inside a top-level
* `val` initializer runs at class `<clinit>` time, marks the class ERRONEOUS, and turns every subsequent reference into
* `NoClassDefFoundError`.
*/
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
@Volatile private var sdkCompressorOrNull: SdkCompressor? = null
@Volatile private var sdkCompressorInitFailure: Throwable? = null
@Synchronized
private fun getSdkCompressor(): SdkCompressor {
sdkCompressorOrNull?.let {
return it
}
sdkCompressorInitFailure?.let { cached ->
throw IllegalStateException("zstd-jni unavailable on this platform", cached)
}
return try {
SdkCompressor().also { sdkCompressorOrNull = it }
} catch (e: Throwable) {
sdkCompressorInitFailure = e
throw IllegalStateException("zstd-jni unavailable on this platform", e)
}
}
actual fun compress(packet: TAKPacketV2): ByteArray {
val data = wireToSdkData(packet)
return getSdkCompressor().compress(data)
}
actual fun decompress(wirePayload: ByteArray): TAKPacketV2 {
val data = getSdkCompressor().decompress(wirePayload)
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. */
private fun wireToSdkData(packet: TAKPacketV2): TakPacketV2Data {
val cotTypeId = packet.cot_type_id.value
val cotTypeStr = if (cotTypeId == 0 && packet.cot_type_str.isNotEmpty()) packet.cot_type_str else null
val payload =
when {
packet.pli != null -> TakPacketV2Data.Payload.Pli(true)
packet.chat != null ->
TakPacketV2Data.Payload.Chat(
message = packet.chat!!.message,
to = packet.chat!!.to,
toCallsign = packet.chat!!.to_callsign,
receiptForUid = packet.chat!!.receipt_for_uid,
receiptType = packet.chat!!.receipt_type.value,
)
packet.aircraft != null ->
TakPacketV2Data.Payload.Aircraft(
icao = packet.aircraft!!.icao,
registration = packet.aircraft!!.registration,
flight = packet.aircraft!!.flight,
aircraftType = packet.aircraft!!.aircraft_type,
squawk = packet.aircraft!!.squawk,
category = packet.aircraft!!.category,
rssiX10 = packet.aircraft!!.rssi_x10,
gps = packet.aircraft!!.gps,
cotHostId = packet.aircraft!!.cot_host_id,
)
// Typed geometry variants added by takv2_geometry (tags 34-37).
// All GeoPoint fields on the wire are delta-encoded from the
// event anchor; the SDK data class stores absolute lat/lon, so
// we add packet.latitude_i / longitude_i here.
packet.shape != null -> {
val s = packet.shape!!
TakPacketV2Data.Payload.DrawnShape(
kind = s.kind.value,
style = s.style.value,
majorCm = s.major_cm,
minorCm = s.minor_cm,
angleDeg = s.angle_deg,
strokeColor = s.stroke_color.value,
strokeArgb = s.stroke_argb,
strokeWeightX10 = s.stroke_weight_x10,
fillColor = s.fill_color.value,
fillArgb = s.fill_argb,
labelsOn = s.labels_on,
vertices =
s.vertices.map { v ->
TakPacketV2Data.Payload.Vertex(
latI = packet.latitude_i + v.lat_delta_i,
lonI = packet.longitude_i + v.lon_delta_i,
)
},
truncated = s.truncated,
bullseyeDistanceDm = s.bullseye_distance_dm,
bullseyeBearingRef = s.bullseye_bearing_ref,
bullseyeFlags = s.bullseye_flags,
bullseyeUidRef = s.bullseye_uid_ref,
)
}
packet.marker != null -> {
val m = packet.marker!!
TakPacketV2Data.Payload.Marker(
kind = m.kind.value,
color = m.color.value,
colorArgb = m.color_argb,
readiness = m.readiness,
parentUid = m.parent_uid,
parentType = m.parent_type,
parentCallsign = m.parent_callsign,
iconset = m.iconset,
)
}
packet.rab != null -> {
val r = packet.rab!!
val anchor = r.anchor
TakPacketV2Data.Payload.RangeAndBearing(
anchorLatI = packet.latitude_i + (anchor?.lat_delta_i ?: 0),
anchorLonI = packet.longitude_i + (anchor?.lon_delta_i ?: 0),
anchorUid = r.anchor_uid,
rangeCm = r.range_cm,
bearingCdeg = r.bearing_cdeg,
strokeColor = r.stroke_color.value,
strokeArgb = r.stroke_argb,
strokeWeightX10 = r.stroke_weight_x10,
)
}
packet.route != null -> {
val rt = packet.route!!
TakPacketV2Data.Payload.Route(
method = rt.method.value,
direction = rt.direction.value,
prefix = rt.prefix,
strokeWeightX10 = rt.stroke_weight_x10,
links =
rt.links.map { link ->
val pt = link.point
TakPacketV2Data.Payload.Route.Link(
latI = packet.latitude_i + (pt?.lat_delta_i ?: 0),
lonI = packet.longitude_i + (pt?.lon_delta_i ?: 0),
uid = link.uid,
callsign = link.callsign,
linkType = link.link_type,
)
},
truncated = rt.truncated,
)
}
packet.casevac != null -> {
val c = packet.casevac!!
TakPacketV2Data.Payload.CasevacReport(
precedence = c.precedence.value,
equipmentFlags = c.equipment_flags,
litterPatients = c.litter_patients,
ambulatoryPatients = c.ambulatory_patients,
security = c.security.value,
hlzMarking = c.hlz_marking.value,
zoneMarker = c.zone_marker,
usMilitary = c.us_military,
usCivilian = c.us_civilian,
nonUsMilitary = c.non_us_military,
nonUsCivilian = c.non_us_civilian,
epw = c.epw,
child = c.child,
terrainFlags = c.terrain_flags,
frequency = c.frequency,
)
}
packet.emergency != null -> {
val e = packet.emergency!!
TakPacketV2Data.Payload.EmergencyAlert(
type = e.type.value,
authoringUid = e.authoring_uid,
cancelReferenceUid = e.cancel_reference_uid,
)
}
packet.task != null -> {
val t = packet.task!!
TakPacketV2Data.Payload.TaskRequest(
taskType = t.task_type,
targetUid = t.target_uid,
assigneeUid = t.assignee_uid,
priority = t.priority.value,
status = t.status.value,
note = t.note,
)
}
packet.raw_detail != null -> TakPacketV2Data.Payload.RawDetail(packet.raw_detail!!.toByteArray())
else -> TakPacketV2Data.Payload.None
}
return TakPacketV2Data(
cotTypeId = cotTypeId,
cotTypeStr = cotTypeStr,
how = packet.how.value,
callsign = packet.callsign,
team = packet.team.value,
role = packet.role.value,
latitudeI = packet.latitude_i,
longitudeI = packet.longitude_i,
altitude = packet.altitude,
speed = packet.speed,
course = packet.course,
battery = packet.battery,
geoSrc = packet.geo_src.value,
altSrc = packet.alt_src.value,
uid = packet.uid,
deviceCallsign = packet.device_callsign,
staleSeconds = packet.stale_seconds,
takVersion = packet.tak_version,
takDevice = packet.tak_device,
takPlatform = packet.tak_platform,
takOs = packet.tak_os,
endpoint = packet.endpoint,
phone = packet.phone,
payload = payload,
)
}
/** Convert SDK's TakPacketV2Data → Wire-generated TAKPacketV2. */
private fun sdkDataToWire(data: TakPacketV2Data): TAKPacketV2 {
val cotType =
org.meshtastic.proto.CotType.fromValue(data.cotTypeId) ?: org.meshtastic.proto.CotType.CotType_Other
val how = org.meshtastic.proto.CotHow.fromValue(data.how) ?: org.meshtastic.proto.CotHow.CotHow_Unspecified
val team = org.meshtastic.proto.Team.fromValue(data.team) ?: org.meshtastic.proto.Team.Unspecifed_Color
val role = org.meshtastic.proto.MemberRole.fromValue(data.role) ?: org.meshtastic.proto.MemberRole.Unspecifed
val geoSrc =
org.meshtastic.proto.GeoPointSource.fromValue(data.geoSrc)
?: org.meshtastic.proto.GeoPointSource.GeoPointSource_Unspecified
val altSrc =
org.meshtastic.proto.GeoPointSource.fromValue(data.altSrc)
?: org.meshtastic.proto.GeoPointSource.GeoPointSource_Unspecified
return TAKPacketV2(
cot_type_id = cotType,
cot_type_str = data.cotTypeStr ?: "",
how = how,
callsign = data.callsign,
team = team,
role = role,
latitude_i = data.latitudeI,
longitude_i = data.longitudeI,
altitude = data.altitude,
speed = data.speed,
course = data.course,
battery = data.battery,
geo_src = geoSrc,
alt_src = altSrc,
uid = data.uid,
device_callsign = data.deviceCallsign,
stale_seconds = data.staleSeconds,
tak_version = data.takVersion,
tak_device = data.takDevice,
tak_platform = data.takPlatform,
tak_os = data.takOs,
endpoint = data.endpoint,
phone = data.phone,
pli = if (data.payload is TakPacketV2Data.Payload.Pli) true else null,
chat =
(data.payload as? TakPacketV2Data.Payload.Chat)?.let { chat ->
WireGeoChat(
message = chat.message,
to = chat.to,
to_callsign = chat.toCallsign,
receipt_for_uid = chat.receiptForUid,
receipt_type =
WireGeoChat.ReceiptType.fromValue(chat.receiptType)
?: WireGeoChat.ReceiptType.ReceiptType_None,
)
},
aircraft =
(data.payload as? TakPacketV2Data.Payload.Aircraft)?.let { ac ->
WireAircraftTrack(
icao = ac.icao,
registration = ac.registration,
flight = ac.flight,
aircraft_type = ac.aircraftType,
squawk = ac.squawk,
category = ac.category,
rssi_x10 = ac.rssiX10,
gps = ac.gps,
cot_host_id = ac.cotHostId,
)
},
shape =
(data.payload as? TakPacketV2Data.Payload.DrawnShape)?.let { s ->
WireDrawnShape(
kind = WireDrawnShape.Kind.fromValue(s.kind) ?: WireDrawnShape.Kind.Kind_Unspecified,
style =
WireDrawnShape.StyleMode.fromValue(s.style)
?: WireDrawnShape.StyleMode.StyleMode_Unspecified,
major_cm = s.majorCm,
minor_cm = s.minorCm,
angle_deg = s.angleDeg,
stroke_color = WireTeam.fromValue(s.strokeColor) ?: WireTeam.Unspecifed_Color,
stroke_argb = s.strokeArgb,
stroke_weight_x10 = s.strokeWeightX10,
fill_color = WireTeam.fromValue(s.fillColor) ?: WireTeam.Unspecifed_Color,
fill_argb = s.fillArgb,
labels_on = s.labelsOn,
// Delta-encode vertices relative to the event anchor.
vertices =
s.vertices.map { v ->
WireCotGeoPoint(
lat_delta_i = v.latI - data.latitudeI,
lon_delta_i = v.lonI - data.longitudeI,
)
},
truncated = s.truncated,
bullseye_distance_dm = s.bullseyeDistanceDm,
bullseye_bearing_ref = s.bullseyeBearingRef,
bullseye_flags = s.bullseyeFlags,
bullseye_uid_ref = s.bullseyeUidRef,
)
},
marker =
(data.payload as? TakPacketV2Data.Payload.Marker)?.let { m ->
WireMarker(
kind = WireMarker.Kind.fromValue(m.kind) ?: WireMarker.Kind.Kind_Unspecified,
color = WireTeam.fromValue(m.color) ?: WireTeam.Unspecifed_Color,
color_argb = m.colorArgb,
readiness = m.readiness,
parent_uid = m.parentUid,
parent_type = m.parentType,
parent_callsign = m.parentCallsign,
iconset = m.iconset,
)
},
rab =
(data.payload as? TakPacketV2Data.Payload.RangeAndBearing)?.let { r ->
WireRangeAndBearing(
anchor =
WireCotGeoPoint(
lat_delta_i = r.anchorLatI - data.latitudeI,
lon_delta_i = r.anchorLonI - data.longitudeI,
),
anchor_uid = r.anchorUid,
range_cm = r.rangeCm,
bearing_cdeg = r.bearingCdeg,
stroke_color = WireTeam.fromValue(r.strokeColor) ?: WireTeam.Unspecifed_Color,
stroke_argb = r.strokeArgb,
stroke_weight_x10 = r.strokeWeightX10,
)
},
route =
(data.payload as? TakPacketV2Data.Payload.Route)?.let { rt ->
WireRoute(
method = WireRoute.Method.fromValue(rt.method) ?: WireRoute.Method.Method_Unspecified,
direction =
WireRoute.Direction.fromValue(rt.direction) ?: WireRoute.Direction.Direction_Unspecified,
prefix = rt.prefix,
stroke_weight_x10 = rt.strokeWeightX10,
links =
rt.links.map { link ->
WireRoute.Link(
point =
WireCotGeoPoint(
lat_delta_i = link.latI - data.latitudeI,
lon_delta_i = link.lonI - data.longitudeI,
),
uid = link.uid,
callsign = link.callsign,
link_type = link.linkType,
)
},
truncated = rt.truncated,
)
},
casevac =
(data.payload as? TakPacketV2Data.Payload.CasevacReport)?.let { c ->
WireCasevacReport(
precedence =
WireCasevacReport.Precedence.fromValue(c.precedence)
?: WireCasevacReport.Precedence.Precedence_Unspecified,
equipment_flags = c.equipmentFlags,
litter_patients = c.litterPatients,
ambulatory_patients = c.ambulatoryPatients,
security =
WireCasevacReport.Security.fromValue(c.security)
?: WireCasevacReport.Security.Security_Unspecified,
hlz_marking =
WireCasevacReport.HlzMarking.fromValue(c.hlzMarking)
?: WireCasevacReport.HlzMarking.HlzMarking_Unspecified,
zone_marker = c.zoneMarker,
us_military = c.usMilitary,
us_civilian = c.usCivilian,
non_us_military = c.nonUsMilitary,
non_us_civilian = c.nonUsCivilian,
epw = c.epw,
child = c.child,
terrain_flags = c.terrainFlags,
frequency = c.frequency,
)
},
emergency =
(data.payload as? TakPacketV2Data.Payload.EmergencyAlert)?.let { e ->
WireEmergencyAlert(
type = WireEmergencyAlert.Type.fromValue(e.type) ?: WireEmergencyAlert.Type.Type_Unspecified,
authoring_uid = e.authoringUid,
cancel_reference_uid = e.cancelReferenceUid,
)
},
task =
(data.payload as? TakPacketV2Data.Payload.TaskRequest)?.let { t ->
WireTaskRequest(
task_type = t.taskType,
target_uid = t.targetUid,
assignee_uid = t.assigneeUid,
priority =
WireTaskRequest.Priority.fromValue(t.priority)
?: WireTaskRequest.Priority.Priority_Unspecified,
status =
WireTaskRequest.Status.fromValue(t.status) ?: WireTaskRequest.Status.Status_Unspecified,
note = t.note,
)
},
raw_detail = (data.payload as? TakPacketV2Data.Payload.RawDetail)?.bytes?.toByteString(),
)
}
}

View File

@@ -1,67 +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.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()
}
}
}

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

Binary file not shown.

View File

Binary file not shown.

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>

Some files were not shown because too many files have changed in this diff Show More