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