mirror of
https://github.com/meshtastic/Meshtastic-Android.git
synced 2026-05-18 11:46:28 -04:00
feat: TAK v2 protocol integration with zstd compression and full CoT type support (#5434)
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com> Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: jamesarich <2199651+jamesarich@users.noreply.github.com> Co-authored-by: James Rich <james.a.rich@gmail.com> Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
1
.gitmodules
vendored
1
.gitmodules
vendored
@@ -1,3 +1,4 @@
|
||||
[submodule "app proto submodule"]
|
||||
path = core/proto/src/main/proto
|
||||
url = https://github.com/meshtastic/protobufs.git
|
||||
branch = master
|
||||
|
||||
11
.skills/compose-ui/strings-index.txt
generated
11
.skills/compose-ui/strings-index.txt
generated
@@ -1123,8 +1123,19 @@ tak_role_sniper
|
||||
tak_role_teamlead
|
||||
tak_role_teammember
|
||||
tak_role_unspecified
|
||||
tak_server
|
||||
tak_server_enabled
|
||||
tak_server_enabled_desc
|
||||
tak_server_export_data_package_desc
|
||||
tak_server_loading
|
||||
tak_server_section
|
||||
tak_server_test_card_title
|
||||
tak_server_test_idle
|
||||
tak_server_test_result_bytes
|
||||
tak_server_test_result_unknown_error
|
||||
tak_server_test_results
|
||||
tak_server_test_run
|
||||
tak_server_test_running
|
||||
tak_team
|
||||
tak_team_blue
|
||||
tak_team_brown
|
||||
|
||||
@@ -21,6 +21,28 @@ Run in a single invocation for routine changes to ensure code formatting, analys
|
||||
|
||||
*Note: If testing Compose UI on the JVM (Robolectric) with Java 21, pin tests to `@Config(sdk = [34])` to avoid SDK 35 compatibility crashes.*
|
||||
|
||||
### SharedFlow + backgroundScope in `runTest`
|
||||
|
||||
When testing long-lived coroutines (e.g., `Flow.collect` loops launched in `backgroundScope`), **use `runTest(UnconfinedTestDispatcher())`** instead of plain `runTest`:
|
||||
|
||||
```kotlin
|
||||
// ❌ BAD — SharedFlow emissions silently never reach collectors
|
||||
@Test fun `inbound packet is forwarded`() = runTest {
|
||||
backgroundScope.launch { sut.start(backgroundScope) }
|
||||
sharedFlow.emit(packet)
|
||||
// assertion fails — collector never receives the emission
|
||||
}
|
||||
|
||||
// ✅ GOOD — UnconfinedTestDispatcher eagerly dispatches subscriber resumptions
|
||||
@Test fun `inbound packet is forwarded`() = runTest(UnconfinedTestDispatcher()) {
|
||||
backgroundScope.launch { sut.start(backgroundScope) }
|
||||
sharedFlow.emit(packet)
|
||||
// assertion passes — collector receives emission immediately
|
||||
}
|
||||
```
|
||||
|
||||
**Why:** `backgroundScope` uses `StandardTestDispatcher` by default, which does **not** eagerly dispatch `SharedFlow` subscriber resumptions. Even `advanceUntilIdle()` won't trigger delivery. `UnconfinedTestDispatcher()` fixes this by dispatching eagerly. This affects any test where a coroutine in `backgroundScope` collects from a `SharedFlow` or `MutableSharedFlow`.
|
||||
|
||||
## 2) Change-type verification matrix
|
||||
|
||||
- `docs-only` changes: Usually no Gradle run required, but run `spotlessCheck` if practical.
|
||||
|
||||
31
app/src/fdroid/AndroidManifest.xml
Normal file
31
app/src/fdroid/AndroidManifest.xml
Normal file
@@ -0,0 +1,31 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!--
|
||||
~ Copyright (c) 2025-2026 Meshtastic LLC
|
||||
~
|
||||
~ This program is free software: you can redistribute it and/or modify
|
||||
~ it under the terms of the GNU General Public License as published by
|
||||
~ the Free Software Foundation, either version 3 of the License, or
|
||||
~ (at your option) any later version.
|
||||
~
|
||||
~ This program is distributed in the hope that it will be useful,
|
||||
~ but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
~ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
~ GNU General Public License for more details.
|
||||
~
|
||||
~ You should have received a copy of the GNU General Public License
|
||||
~ along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
-->
|
||||
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:tools="http://schemas.android.com/tools">
|
||||
|
||||
<!--
|
||||
Required for writing TAK route data packages to ATAK's auto-import directory.
|
||||
Only declared for the F-Droid flavor — the Google Play flavor uses scoped
|
||||
storage (SAF / app-scoped cache) so this permission is not needed there
|
||||
and would violate Play policy.
|
||||
-->
|
||||
<uses-permission android:name="android.permission.MANAGE_EXTERNAL_STORAGE"
|
||||
tools:ignore="ScopedStorage" />
|
||||
|
||||
</manifest>
|
||||
@@ -67,10 +67,6 @@
|
||||
-->
|
||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||
|
||||
<!-- Only for debug log writing, disable for production
|
||||
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
|
||||
-->
|
||||
|
||||
<!-- We run our mesh code as a foreground service - FIXME, find a way to stop doing this -->
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_CONNECTED_DEVICE" />
|
||||
|
||||
@@ -195,7 +195,7 @@ class MeshDataHandlerImpl(
|
||||
}
|
||||
|
||||
PortNum.ATAK_PLUGIN,
|
||||
PortNum.ATAK_FORWARDER,
|
||||
PortNum.ATAK_PLUGIN_V2,
|
||||
PortNum.PRIVATE_APP,
|
||||
-> {
|
||||
shouldBroadcast = true
|
||||
|
||||
@@ -58,6 +58,13 @@ data class Capabilities(val firmwareVersion: String?, internal val forceEnableAl
|
||||
/** Support for TAK (ATAK) module configuration. Supported since firmware v2.7.19. */
|
||||
val supportsTakConfig = atLeast(V2_7_19)
|
||||
|
||||
/**
|
||||
* Support for the v2 TAK port (ATAK_PLUGIN_V2 = 78) with TAKPacketV2 + zstd dictionary compression. Supported since
|
||||
* firmware v2.8.0. Firmware v2.7.x and earlier only support the legacy ATAK_PLUGIN port (72) with the original
|
||||
* TAKPacket schema (PLI + GeoChat only, no compression), so the bridge falls back to that path for older nodes.
|
||||
*/
|
||||
val supportsTakV2 = atLeast(V2_8_0)
|
||||
|
||||
/** Support for location sharing on secondary channels. Supported since firmware v2.6.10. */
|
||||
val supportsSecondaryChannelLocation = atLeast(V2_6_10)
|
||||
|
||||
|
||||
@@ -172,6 +172,8 @@ sealed interface SettingsRoute : Route {
|
||||
|
||||
@Serializable data object CleanNodeDb : SettingsRoute
|
||||
|
||||
@Serializable data object TakServer : SettingsRoute
|
||||
|
||||
@Serializable data object DebugPanel : SettingsRoute
|
||||
|
||||
@Serializable data object About : SettingsRoute
|
||||
|
||||
@@ -1165,8 +1165,19 @@
|
||||
<string name="tak_role_teamlead">Team Lead</string>
|
||||
<string name="tak_role_teammember">Team Member</string>
|
||||
<string name="tak_role_unspecified">Unspecified</string>
|
||||
<string name="tak_server">TAK Server</string>
|
||||
<string name="tak_server_enabled">Enable Local TAK Server</string>
|
||||
<string name="tak_server_enabled_desc">Starts a TCP server on port 8089 for ATAK connections</string>
|
||||
<string name="tak_server_enabled_desc">Starts a local TLS server on port 8089 for ATAK/iTAK connections</string>
|
||||
<string name="tak_server_export_data_package_desc">Generate .zip for ATAK/iTAK to connect to this server</string>
|
||||
<string name="tak_server_loading">…</string>
|
||||
<string name="tak_server_section">Server</string>
|
||||
<string name="tak_server_test_card_title">TAK Mesh Test (Debug)</string>
|
||||
<string name="tak_server_test_idle">Send all %1$d test fixtures to mesh</string>
|
||||
<string name="tak_server_test_result_bytes">%1$dB ✓</string>
|
||||
<string name="tak_server_test_result_unknown_error">✗</string>
|
||||
<string name="tak_server_test_results">%1$d passed, %2$d failed of %3$d/%4$d</string>
|
||||
<string name="tak_server_test_run">Run</string>
|
||||
<string name="tak_server_test_running">Running: %1$s</string>
|
||||
<string name="tak_team">Team Color</string>
|
||||
<string name="tak_team_blue">Blue</string>
|
||||
<string name="tak_team_brown">Brown</string>
|
||||
|
||||
@@ -14,6 +14,8 @@
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
@file:Suppress("TooGenericExceptionCaught")
|
||||
|
||||
package org.meshtastic.core.service
|
||||
|
||||
import android.app.Service
|
||||
@@ -22,6 +24,7 @@ import android.content.Intent
|
||||
import android.content.pm.ServiceInfo
|
||||
import android.os.Build
|
||||
import android.os.IBinder
|
||||
import android.os.PowerManager
|
||||
import androidx.core.app.ServiceCompat
|
||||
import co.touchlab.kermit.Logger
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
@@ -91,6 +94,14 @@ class MeshService : Service() {
|
||||
|
||||
private var isServiceInitialized = false
|
||||
|
||||
/**
|
||||
* Partial wake lock held while the foreground service is running. Prevents the CPU from being throttled while the
|
||||
* TAK server's keepalive coroutines, socket writes, and mesh packet handlers need to run on a regular cadence.
|
||||
* Without this, OEM battery optimizations can pause coroutines for long enough that connected TAK clients
|
||||
* (ATAK/iTAK) time out waiting for data, even though the foreground service itself keeps the process alive.
|
||||
*/
|
||||
private var wakeLock: PowerManager.WakeLock? = null
|
||||
|
||||
private val myNodeNum: Int
|
||||
get() = nodeManager.myNodeNum.value ?: throw RadioNotConnectedException()
|
||||
|
||||
@@ -110,6 +121,8 @@ class MeshService : Service() {
|
||||
|
||||
val minDeviceVersion = DeviceVersion(DeviceVersion.MIN_FW_VERSION)
|
||||
val absoluteMinDeviceVersion = DeviceVersion(DeviceVersion.ABS_MIN_FW_VERSION)
|
||||
|
||||
private const val WAKE_LOCK_TIMEOUT_MS = 30L * 60L * 1_000L // 30 minutes
|
||||
}
|
||||
|
||||
override fun onCreate() {
|
||||
@@ -163,10 +176,12 @@ class MeshService : Service() {
|
||||
|
||||
return if (!wantForeground) {
|
||||
Logger.i { "Stopping mesh service because no device is selected" }
|
||||
releaseWakeLock()
|
||||
ServiceCompat.stopForeground(this, ServiceCompat.STOP_FOREGROUND_REMOVE)
|
||||
stopSelf()
|
||||
START_NOT_STICKY
|
||||
} else {
|
||||
acquireWakeLock()
|
||||
START_STICKY
|
||||
}
|
||||
}
|
||||
@@ -205,6 +220,38 @@ class MeshService : Service() {
|
||||
}
|
||||
}
|
||||
|
||||
private fun acquireWakeLock() {
|
||||
if (wakeLock?.isHeld == true) return
|
||||
try {
|
||||
val powerManager = getSystemService(POWER_SERVICE) as PowerManager
|
||||
val lock =
|
||||
powerManager.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, "Meshtastic::MeshServiceWakeLock").apply {
|
||||
setReferenceCounted(false)
|
||||
}
|
||||
lock.acquire(WAKE_LOCK_TIMEOUT_MS)
|
||||
wakeLock = lock
|
||||
Logger.i { "Acquired partial wake lock for mesh service" }
|
||||
} catch (e: SecurityException) {
|
||||
Logger.w(e) { "Failed to acquire wake lock — WAKE_LOCK permission missing?" }
|
||||
} catch (e: Exception) {
|
||||
Logger.w(e) { "Failed to acquire wake lock" }
|
||||
}
|
||||
}
|
||||
|
||||
private fun releaseWakeLock() {
|
||||
val lock = wakeLock ?: return
|
||||
try {
|
||||
if (lock.isHeld) {
|
||||
lock.release()
|
||||
Logger.i { "Released partial wake lock for mesh service" }
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Logger.w(e) { "Failed to release wake lock" }
|
||||
} finally {
|
||||
wakeLock = null
|
||||
}
|
||||
}
|
||||
|
||||
override fun onTaskRemoved(rootIntent: Intent?) {
|
||||
super.onTaskRemoved(rootIntent)
|
||||
Logger.i { "Mesh service: onTaskRemoved" }
|
||||
@@ -214,6 +261,7 @@ class MeshService : Service() {
|
||||
|
||||
override fun onDestroy() {
|
||||
Logger.i { "Destroying mesh service" }
|
||||
releaseWakeLock()
|
||||
ServiceCompat.stopForeground(this, ServiceCompat.STOP_FOREGROUND_REMOVE)
|
||||
if (isServiceInitialized) {
|
||||
orchestrator.stop()
|
||||
|
||||
@@ -48,7 +48,6 @@ import org.meshtastic.core.repository.ServiceRepository
|
||||
import org.meshtastic.core.repository.TakPrefs
|
||||
import org.meshtastic.core.takserver.TAKMeshIntegration
|
||||
import org.meshtastic.core.takserver.TAKServerManager
|
||||
import org.meshtastic.core.takserver.fountain.CoTHandler
|
||||
import org.meshtastic.proto.LocalModuleConfig
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertFalse
|
||||
@@ -59,7 +58,7 @@ class MeshServiceOrchestratorTest {
|
||||
private val radioInterfaceService: RadioInterfaceService = mock(MockMode.autofill)
|
||||
private val serviceRepository: ServiceRepository = mock(MockMode.autofill)
|
||||
private val nodeManager: NodeManager = mock(MockMode.autofill)
|
||||
private val nodeRepository: NodeRepository = mock(MockMode.autofill)
|
||||
|
||||
private val messageProcessor: MeshMessageProcessor = mock(MockMode.autofill)
|
||||
private val commandSender: CommandSender = mock(MockMode.autofill)
|
||||
private val router: MeshRouter = mock(MockMode.autofill)
|
||||
@@ -68,7 +67,7 @@ class MeshServiceOrchestratorTest {
|
||||
private val serviceNotifications: MeshServiceNotifications = mock(MockMode.autofill)
|
||||
private val takServerManager: TAKServerManager = mock(MockMode.autofill)
|
||||
private val takPrefs: TakPrefs = mock(MockMode.autofill)
|
||||
private val cotHandler: CoTHandler = mock(MockMode.autofill)
|
||||
private val nodeRepository: NodeRepository = mock(MockMode.autofill)
|
||||
private val databaseManager: DatabaseManager = mock(MockMode.autofill)
|
||||
private val connectionManager: MeshConnectionManager = mock(MockMode.autofill)
|
||||
|
||||
@@ -94,17 +93,16 @@ class MeshServiceOrchestratorTest {
|
||||
every { takPrefs.isTakServerEnabled } returns takEnabledFlow
|
||||
every { takServerManager.isRunning } returns takRunningFlow
|
||||
every { takServerManager.inboundMessages } returns MutableSharedFlow()
|
||||
every { nodeRepository.nodeDBbyNum } returns MutableStateFlow(emptyMap())
|
||||
every { router.actionHandler } returns actionHandler
|
||||
every { nodeRepository.myNodeInfo } returns MutableStateFlow(null)
|
||||
|
||||
val takMeshIntegration =
|
||||
TAKMeshIntegration(
|
||||
takServerManager = takServerManager,
|
||||
commandSender = commandSender,
|
||||
nodeRepository = nodeRepository,
|
||||
serviceRepository = serviceRepository,
|
||||
meshConfigHandler = meshConfigHandler,
|
||||
cotHandler = cotHandler,
|
||||
nodeRepository = nodeRepository,
|
||||
)
|
||||
|
||||
return MeshServiceOrchestrator(
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/*
|
||||
* Copyright (c) 2026 Meshtastic LLC
|
||||
* Copyright (c) 2025-2026 Meshtastic LLC
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
@@ -23,6 +23,7 @@ plugins {
|
||||
}
|
||||
|
||||
kotlin {
|
||||
@Suppress("UnstableApiUsage")
|
||||
android {
|
||||
namespace = "org.meshtastic.core.takserver"
|
||||
androidResources.enable = false
|
||||
@@ -50,9 +51,75 @@ kotlin {
|
||||
implementation(libs.kermit)
|
||||
}
|
||||
|
||||
jvmAndroidMain.dependencies {
|
||||
// TAKPacket-SDK for v2 compression/decompression (via JitPack).
|
||||
//
|
||||
// We depend on the `-jvm` variant directly rather than the parent
|
||||
// `com.github.meshtastic:TAKPacket-SDK` coordinate. JitPack does
|
||||
// not publish a root-level Gradle module metadata (.module) file
|
||||
// for the KMP parent, only per-target ones. With just the parent
|
||||
// POM, Gradle reads the four KMP variants (jvm, iosarm64,
|
||||
// iossimulatorarm64, metadata) as unconditional Maven deps and
|
||||
// tries to resolve them ALL against this Android consumer — the
|
||||
// iOS klibs declare `platform.type=native` with no androidJvm
|
||||
// variant, so variant selection fails with "No matching variant".
|
||||
//
|
||||
// Depending directly on `takpacket-sdk-jvm` skips the parent POM
|
||||
// entirely and goes straight to the JVM artifact's own module
|
||||
// metadata, which is compatible with both `jvm()` and Android
|
||||
// targets in this `jvmAndroidMain` source set. It still pulls
|
||||
// zstd-jni + xpp3 + wire-runtime-jvm + kotlin-stdlib as
|
||||
// transitive deps from the JVM variant's POM.
|
||||
//
|
||||
// zstd-jni's @aar variant is still declared explicitly in the
|
||||
// androidMain source set below so Android gets the .so files.
|
||||
implementation("com.github.meshtastic.TAKPacket-SDK:takpacket-sdk-jvm:v0.2.1") {
|
||||
// Issue #5: pre-0.2.1 the SDK JAR bundled `org.meshtastic.proto.*`
|
||||
// (Wire-generated TAKPacketV2 + friends) inside the same JAR as
|
||||
// `org.meshtastic.tak.*`. Our own `:core:proto` module runs its
|
||||
// own Wire codegen against the same protobufs submodule and emits
|
||||
// the identical classes, so R8 hit "Type is defined multiple
|
||||
// times" errors during release builds. v0.2.1 strips the proto
|
||||
// classes from the JAR entirely — the SDK's bytecode still
|
||||
// REFERENCES them, but they come from `:core:proto` on our
|
||||
// classpath. No exclude needed; the SDK simply doesn't ship them.
|
||||
// The SDK's jvmMain declares zstd-jni as a runtime dep (standard
|
||||
// JAR with desktop native libs). Android needs the @aar variant
|
||||
// instead (ships arm/arm64/x86/x86_64 .so files). Both packaging
|
||||
// formats contain the same Java classes, so Android's dex merger
|
||||
// hits "Duplicate class" errors if both land on the classpath.
|
||||
// Exclude here; androidMain re-adds it as @aar below, and jvmMain
|
||||
// re-adds the JAR for desktop.
|
||||
exclude(group = "com.github.luben", module = "zstd-jni")
|
||||
// xpp3 bundles org.xmlpull.v1.XmlPullParser which Android provides
|
||||
// as a platform class (android.content.res.XmlResourceParser
|
||||
// implements it). R8 fails when both the library and program
|
||||
// classpaths define the same type.
|
||||
exclude(group = "org.ogce", module = "xpp3")
|
||||
}
|
||||
}
|
||||
|
||||
jvmMain.dependencies {
|
||||
// Desktop JVM: standard JAR bundles native libs for desktop archs.
|
||||
implementation("com.github.luben:zstd-jni:1.5.7-7")
|
||||
// xpp3 is excluded from jvmAndroidMain (Android ships it as a
|
||||
// platform class), but Desktop JVM still needs it for XmlPullParser.
|
||||
implementation("org.ogce:xpp3:1.1.6")
|
||||
}
|
||||
|
||||
androidMain.dependencies {
|
||||
// Android: @aar variant ships .so files for arm/arm64/x86/x86_64.
|
||||
// Without this, zstd-jni's ZstdDictCompress.<clinit> throws
|
||||
// UnsatisfiedLinkError and poisons TakV2Compressor permanently.
|
||||
implementation("com.github.luben:zstd-jni:1.5.7-7@aar")
|
||||
}
|
||||
|
||||
commonTest.dependencies {
|
||||
implementation(projects.core.testing)
|
||||
implementation(libs.kotlinx.coroutines.test)
|
||||
implementation(libs.turbine)
|
||||
implementation(libs.kotest.assertions)
|
||||
implementation(libs.kotest.property)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
/*
|
||||
* Copyright (c) 2026 Meshtastic LLC
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.meshtastic.core.takserver
|
||||
|
||||
import co.touchlab.kermit.Logger
|
||||
import java.io.File
|
||||
|
||||
/**
|
||||
* Android implementation — writes route data packages to ATAK's monitored auto-import directory. Tries multiple
|
||||
* locations in order of preference:
|
||||
* 1. `/sdcard/atak/tools/datapackage/` (ATAK monitors this)
|
||||
* 2. `/sdcard/Download/` (user can manually import from here)
|
||||
*/
|
||||
@Suppress("TooGenericExceptionCaught")
|
||||
internal actual object AtakFileWriter {
|
||||
|
||||
actual fun writeToImportDir(fileName: String, zipBytes: ByteArray): Boolean {
|
||||
// Sanitize: fileName originates from untrusted mesh CoT uid attributes.
|
||||
val safeName = fileName.replace(Regex("[^a-zA-Z0-9._-]"), "_")
|
||||
// Use hardcoded paths — on Android /sdcard/ maps to external storage.
|
||||
// On JVM desktop these paths don't exist and the fallback returns false.
|
||||
val targets = listOf(File("/sdcard/atak/tools/datapackage"), File("/sdcard/Download"))
|
||||
|
||||
for (dir in targets) {
|
||||
try {
|
||||
if (!dir.exists()) dir.mkdirs()
|
||||
val target = File(dir, safeName)
|
||||
target.writeBytes(zipBytes)
|
||||
Logger.i { "Route data package written: $fileName (${zipBytes.size} bytes) → ${target.absolutePath}" }
|
||||
return true
|
||||
} catch (e: Exception) {
|
||||
Logger.d { "Cannot write to ${dir.absolutePath}: ${e.message}" }
|
||||
}
|
||||
}
|
||||
|
||||
Logger.w { "Failed to write route data package to any ATAK import directory" }
|
||||
return false
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
/*
|
||||
* Copyright (c) 2026 Meshtastic LLC
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.meshtastic.core.takserver
|
||||
|
||||
/**
|
||||
* Writes data package files to ATAK's auto-import directory.
|
||||
*
|
||||
* On Android, the actual implementation writes to `/sdcard/atak/tools/datapackage/` which ATAK monitors for new zip
|
||||
* files. On other platforms this is a no-op.
|
||||
*/
|
||||
internal expect object AtakFileWriter {
|
||||
/**
|
||||
* Write a data package zip to ATAK's monitored import directory.
|
||||
*
|
||||
* @return true if the file was written successfully, false otherwise.
|
||||
*/
|
||||
fun writeToImportDir(fileName: String, zipBytes: ByteArray): Boolean
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
/*
|
||||
* Copyright (c) 2026 Meshtastic LLC
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.meshtastic.core.takserver
|
||||
|
||||
/**
|
||||
* Removes bloat elements from the `<detail>` content of a CoT event before it is stuffed into a
|
||||
* [org.meshtastic.proto.TAKPacketV2] `raw_detail` field for mesh transmission.
|
||||
*
|
||||
* # Why this exists
|
||||
*
|
||||
* A LoRa mesh packet has a hard payload limit of [org.meshtastic.proto.Constants.DATA_PAYLOAD_LEN] = 233 bytes for the
|
||||
* entire encoded `Data` proto (portnum + payload + reply_id + emoji). Subtracting the wrapper overhead leaves roughly
|
||||
* **~225 bytes** for the TAK wire payload, and the wire payload itself is `[1 byte dict-id flag][zstd-compressed
|
||||
* TAKPacketV2 protobuf]`.
|
||||
*
|
||||
* ATAK emits CoT events with rich visual metadata that is **never useful over a mesh**: icon set paths, ARGB colors,
|
||||
* shape geometry, archive flags, file references, etc. A typical `u-d-c-c` (user-drawn circle) event from ATAK is
|
||||
* **800+ bytes of XML**, of which maybe 80 bytes are actually meaningful to a receiving node. Even with dictionary
|
||||
* compression, the full payload overflows the MTU.
|
||||
*
|
||||
* This stripper deletes elements the receiving node can synthesize or ignore, leaving only the minimum needed to
|
||||
* rebuild a usable `<event>` on the other side: who sent it, where they are, what team/role they're on, battery status,
|
||||
* chat content, and the high-level CoT type (which rides separately on [TAKPacketV2.cot_type_id] /
|
||||
* [TAKPacketV2.cot_type_str]).
|
||||
*
|
||||
* # What gets dropped
|
||||
*
|
||||
* **Cosmetic / rendering-only** (pure visual, no situational awareness value):
|
||||
* - `<color .../>` — ARGB stroke/fill colors
|
||||
* - `<strokeColor .../>`, `<strokeWeight .../>`, `<fillColor .../>` — shape styling
|
||||
* - `<labels_on .../>` — label visibility toggle
|
||||
* - `<usericon .../>` — icon set path (`COT_MAPPING_2525B/...`)
|
||||
* - `<model .../>` — 3D model reference
|
||||
*
|
||||
* **Geometric detail** (we keep lat/lon on the event; shape primitives are too big):
|
||||
* - `<shape>...</shape>` — ellipse/polyline/polygon geometry
|
||||
* - `<height .../>`, `<height_unit .../>` — rendering hints
|
||||
*
|
||||
* **Resource references** (useless without the resource being reachable):
|
||||
* - `<fileshare .../>` — file transfer references
|
||||
* - `<__video .../>` — video stream URL
|
||||
*
|
||||
* **Flags and redundant metadata**:
|
||||
* - `<archive/>` — "save to archive" flag
|
||||
* - `<precisionlocation .../>` — redundant with the event's `<point>` attributes
|
||||
* - `<tog .../>` — rectangle "toggle" UI state flag
|
||||
* - `<_flow-tags_ .../>` — TAK Server routing metadata (server-to-server, not needed on mesh)
|
||||
*
|
||||
* # What gets preserved
|
||||
*
|
||||
* Anything the stripper doesn't explicitly match is passed through untouched. That includes all of the structured
|
||||
* elements that the regular [CoTXmlParser] understands (contact, __group, status, track, remarks, __chat, chatgrp,
|
||||
* link, uid, __serverdestination) plus any unknown extensions — better to over-preserve than silently drop something
|
||||
* the receiving ATAK actually needs.
|
||||
*
|
||||
* # Whitespace
|
||||
*
|
||||
* All inter-element whitespace and indentation is collapsed. Whitespace inside text nodes (e.g. `<remarks>hello
|
||||
* world</remarks>`) is preserved.
|
||||
*
|
||||
* # Not a real XML parser
|
||||
*
|
||||
* This is intentionally string/regex based, not DOM. The input is a small, well-formed fragment produced by ATAK's
|
||||
* serializer, so a full parser is overkill — and we want this to be dependency-free so it can run on every KMP target
|
||||
* without pulling in xmlutil for a one-off job. If ATAK starts emitting namespaced elements or embedded CDATA that
|
||||
* tangles with these patterns, the stripper will leave them alone rather than corrupt the output, which is the safer
|
||||
* failure mode.
|
||||
*/
|
||||
internal object CoTDetailStripper {
|
||||
|
||||
/**
|
||||
* Element names whose entire subtree (or self-closing tag) is removed.
|
||||
*
|
||||
* Order matters only for documentation. Each entry is tried against both the self-closing form `<name .../>` and
|
||||
* the paired form `<name ...>...</name>`.
|
||||
*/
|
||||
private val STRIPPED_ELEMENTS =
|
||||
listOf(
|
||||
// Cosmetic / rendering
|
||||
"color",
|
||||
"strokeColor",
|
||||
"strokeWeight",
|
||||
"fillColor",
|
||||
"labels_on",
|
||||
"usericon",
|
||||
"model",
|
||||
// Geometric
|
||||
"shape",
|
||||
"height",
|
||||
"height_unit",
|
||||
// Resource refs
|
||||
"fileshare",
|
||||
"__video",
|
||||
// Flags / redundant
|
||||
"archive",
|
||||
"precisionlocation",
|
||||
// Rectangle/polyline "toggle" UI flag, and TAK Server routing metadata.
|
||||
// The underscore-prefixed element names are legal XML identifiers ATAK uses
|
||||
// for internal state that receiving meshtastic nodes have no use for.
|
||||
"tog",
|
||||
"_flow-tags_",
|
||||
)
|
||||
|
||||
/**
|
||||
* Pre-compiled regex list: for each stripped element, one pattern that matches either a self-closing tag or a
|
||||
* paired open/close tag (non-greedy content).
|
||||
*
|
||||
* `[^>]*?` inside the open tag tolerates attribute quoting with both single and double quotes but bails if it
|
||||
* encounters a `>` (so it won't accidentally swallow unrelated content).
|
||||
*
|
||||
* The leading `(?s)` inline flag is the KMP-portable equivalent of `RegexOption.DOT_MATCHES_ALL` — it lets `.`
|
||||
* match newlines so a multi-line `<shape>...</shape>` subtree is captured in one pass.
|
||||
* `RegexOption.DOT_MATCHES_ALL` itself is JVM-only and breaks the Kotlin/Native build.
|
||||
*/
|
||||
private val STRIPPED_ELEMENT_PATTERNS: List<Regex> =
|
||||
STRIPPED_ELEMENTS.map { name ->
|
||||
// Escape the name in case it contains regex metacharacters (e.g. __video).
|
||||
val escaped = Regex.escape(name)
|
||||
// Matches:
|
||||
// <name/>
|
||||
// <name attr="..."/>
|
||||
// <name attr='...'>...content...</name>
|
||||
Regex("""(?s)<$escaped(?:\s[^>]*?)?/>|<$escaped(?:\s[^>]*?)?>.*?</$escaped>""")
|
||||
}
|
||||
|
||||
/** Matches whitespace between tags: `> \n <` → `><`. */
|
||||
private val INTER_TAG_WHITESPACE = Regex(""">\s+<""")
|
||||
|
||||
/** Collapse leading / trailing whitespace across the whole fragment. */
|
||||
private val EDGE_WHITESPACE = Regex("""^\s+|\s+$""")
|
||||
|
||||
/**
|
||||
* Strip bloat elements and normalize whitespace on an inner `<detail>` fragment.
|
||||
*
|
||||
* The input is assumed to be the concatenated children of `<detail>` — i.e., what
|
||||
* [CoTXmlParser.extractDetailInnerXml] returns. It is NOT the full `<event>` or the `<detail>` wrapper itself.
|
||||
*
|
||||
* Returns an empty string if every element was stripped (so callers can treat "empty" and "nothing worth sending"
|
||||
* uniformly).
|
||||
*/
|
||||
fun strip(detailInnerXml: String): String {
|
||||
if (detailInnerXml.isEmpty()) return ""
|
||||
var result = detailInnerXml
|
||||
for (pattern in STRIPPED_ELEMENT_PATTERNS) {
|
||||
result = pattern.replace(result, "")
|
||||
}
|
||||
// Collapse whitespace between remaining tags. Preserves whitespace inside
|
||||
// text nodes (e.g. <remarks>hello world</remarks>) because that whitespace
|
||||
// isn't bracketed by '>' and '<'.
|
||||
result = INTER_TAG_WHITESPACE.replace(result, "><")
|
||||
result = EDGE_WHITESPACE.replace(result, "")
|
||||
return result
|
||||
}
|
||||
}
|
||||
@@ -20,41 +20,69 @@ package org.meshtastic.core.takserver
|
||||
|
||||
import kotlin.time.Instant
|
||||
|
||||
fun CoTMessage.toXml(): String = buildString {
|
||||
append(
|
||||
"<?xml version='1.0' encoding='UTF-8' standalone='yes'?><event version='2.0' uid='${uid.xmlEscaped()}' type='$type' time='${time.toXmlString()}' start='${start.toXmlString()}' stale='${stale.toXmlString()}' how='$how'><point lat='$latitude' lon='$longitude' hae='$hae' ce='$ce' le='$le'/><detail>",
|
||||
/**
|
||||
* Serialize this [CoTMessage] to a single `<event>` XML element suitable for the CoT streaming TCP protocol used by
|
||||
* ATAK / iTAK / WinTAK clients.
|
||||
*
|
||||
* **Important:** the output must NOT include an `<?xml ... ?>` declaration. The CoT stream protocol is a continuous
|
||||
* sequence of `<event>` elements concatenated together; an XML declaration is only legal at the very start of a
|
||||
* document and ATAK will drop the connection as malformed the moment it sees a second declaration mid-stream.
|
||||
*/
|
||||
fun CoTMessage.toXml(): String {
|
||||
val sb = StringBuilder()
|
||||
sb.append(
|
||||
"<event version='2.0' uid='${uid.xmlEscaped()}' type='${type.xmlEscaped()}' time='${time.toXmlString()}' start='${start.toXmlString()}' stale='${stale.toXmlString()}' how='${how.xmlEscaped()}'><point lat='$latitude' lon='$longitude' hae='$hae' ce='$ce' le='$le'/><detail>",
|
||||
)
|
||||
|
||||
contact?.let {
|
||||
append(
|
||||
"<contact endpoint='${it.endpoint ?: DEFAULT_TAK_ENDPOINT}' callsign='${it.callsign.xmlEscaped()}'/><uid Droid='${it.callsign.xmlEscaped()}'/>",
|
||||
sb.append(
|
||||
"<contact endpoint='${(it.endpoint ?: DEFAULT_TAK_ENDPOINT).xmlEscaped()}' callsign='${it.callsign.xmlEscaped()}'/><uid Droid='${it.callsign.xmlEscaped()}'/>",
|
||||
)
|
||||
}
|
||||
|
||||
group?.let { append("<__group role='${it.role.xmlEscaped()}' name='${it.name.xmlEscaped()}'/>") }
|
||||
group?.let { sb.append("<__group role='${it.role.xmlEscaped()}' name='${it.name.xmlEscaped()}'/>") }
|
||||
|
||||
status?.let { append("<status battery='${it.battery}'/>") }
|
||||
status?.let { sb.append("<status battery='${it.battery}'/>") }
|
||||
|
||||
track?.let { append("<track course='${it.course}' speed='${it.speed}'/>") }
|
||||
track?.let { sb.append("<track course='${it.course}' speed='${it.speed}'/>") }
|
||||
|
||||
if (chat != null) {
|
||||
val senderUid = uid.geoChatSenderUid()
|
||||
val messageId = uid.geoChatMessageId()
|
||||
append(
|
||||
"<__chat parent='RootContactGroup' groupOwner='false' messageId='$messageId' chatroom='${chat.chatroom.xmlEscaped()}' id='${chat.chatroom.xmlEscaped()}' senderCallsign='${chat.senderCallsign?.xmlEscaped() ?: ""}'><chatgrp uid0='${senderUid.xmlEscaped()}' uid1='${chat.chatroom.xmlEscaped()}' id='${chat.chatroom.xmlEscaped()}'/></__chat>",
|
||||
sb.append(
|
||||
"<__chat parent='RootContactGroup' groupOwner='false' messageId='${messageId.xmlEscaped()}' chatroom='${chat.chatroom.xmlEscaped()}' id='${chat.chatroom.xmlEscaped()}' senderCallsign='${chat.senderCallsign?.xmlEscaped() ?: ""}'><chatgrp uid0='${senderUid.xmlEscaped()}' uid1='${chat.chatroom.xmlEscaped()}' id='${chat.chatroom.xmlEscaped()}'/></__chat>",
|
||||
)
|
||||
append("<link uid='${senderUid.xmlEscaped()}' type='a-f-G-U-C' relation='p-p'/>")
|
||||
append("<__serverdestination destinations='0.0.0.0:4242:tcp:${senderUid.xmlEscaped()}'/>")
|
||||
append(
|
||||
sb.append("<link uid='${senderUid.xmlEscaped()}' type='a-f-G-U-C' relation='p-p'/>")
|
||||
sb.append("<__serverdestination destinations='0.0.0.0:4242:tcp:${senderUid.xmlEscaped()}'/>")
|
||||
sb.append(
|
||||
"<remarks source='BAO.F.ATAK.${senderUid.xmlEscaped()}' to='${chat.chatroom.xmlEscaped()}' time='${time.toXmlString()}'>${chat.message.xmlEscaped()}</remarks>",
|
||||
)
|
||||
} else if (!remarks.isNullOrEmpty()) {
|
||||
append("<remarks>${remarks.xmlEscaped()}</remarks>")
|
||||
sb.append("<remarks>${remarks.xmlEscaped()}</remarks>")
|
||||
}
|
||||
|
||||
rawDetailXml?.takeIf { it.isNotEmpty() }?.let { append(it) }
|
||||
rawDetailXml?.let {
|
||||
if (it.isNotEmpty()) {
|
||||
sb.append(it)
|
||||
}
|
||||
}
|
||||
|
||||
append("</detail></event>")
|
||||
sb.append("</detail></event>")
|
||||
return sb.toString()
|
||||
}
|
||||
|
||||
private fun Instant.toXmlString(): String = this.toString()
|
||||
/**
|
||||
* Format this [Instant] for CoT XML `time` / `start` / `stale` attributes.
|
||||
*
|
||||
* Always emits millisecond precision (`YYYY-MM-DDThh:mm:ss.SSSZ`). kotlinx-datetime's default [Instant.toString] can
|
||||
* emit up to nanosecond precision; some TAK implementations choke on anything beyond milliseconds, so we truncate to ms
|
||||
* and always include the millisecond field even when it would otherwise be zero.
|
||||
*/
|
||||
private fun Instant.toXmlString(): String {
|
||||
val millis = this.toEpochMilliseconds()
|
||||
val truncated = Instant.fromEpochMilliseconds(millis)
|
||||
val base = truncated.toString()
|
||||
// kotlinx-datetime omits the fractional part when it's zero; pad it ourselves so the
|
||||
// CoT timestamp format is stable at ms precision.
|
||||
return if (base.contains('.')) base else base.removeSuffix("Z") + ".000Z"
|
||||
}
|
||||
|
||||
@@ -85,6 +85,10 @@ internal class CoTXmlFrameBuffer(private val maxMessageSize: Long = DEFAULT_MAX_
|
||||
companion object {
|
||||
private val EVENT_START_BYTES = "<event".encodeUtf8()
|
||||
private val EVENT_END_BYTES = "</event>".encodeUtf8()
|
||||
private const val DEFAULT_MAX_TAK_MESSAGE_SIZE = 8L * 1024 * 1024
|
||||
|
||||
// 256KB is still 25× larger than any realistic CoT event (~10KB max). Using 8MB
|
||||
// would allow a TAK client with the shared cert to exhaust memory on mobile
|
||||
// devices by opening N connections × 8MB per frame buffer.
|
||||
private const val DEFAULT_MAX_TAK_MESSAGE_SIZE = 256L * 1024
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,8 +14,11 @@
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
@file:Suppress("ReturnCount")
|
||||
|
||||
package org.meshtastic.core.takserver
|
||||
|
||||
import co.touchlab.kermit.Logger
|
||||
import nl.adaptivity.xmlutil.serialization.XML
|
||||
import kotlin.time.Clock
|
||||
import kotlin.time.Instant
|
||||
@@ -59,9 +62,37 @@ class CoTXmlParser(private val xml: String) {
|
||||
track = detail?.track?.let { CoTTrack(speed = it.speed, course = it.course) },
|
||||
chat = buildChat(detail),
|
||||
remarks = buildRemarks(detail),
|
||||
// Stripped version used as the raw_detail protobuf payload: drops bloat
|
||||
// elements (colors, icons, archives, shapes, etc.) so unmapped CoT types
|
||||
// have any chance of fitting in a LoRa mesh packet. See [CoTDetailStripper].
|
||||
parsedDetailXml = extractDetailInnerXml(xml)?.let(CoTDetailStripper::strip),
|
||||
// Verbatim original event XML kept for diagnostic logging only — never
|
||||
// goes on the wire.
|
||||
sourceEventXml = xml,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract the exact content between `<detail>` and `</detail>` from the original XML string. Used as the
|
||||
* `raw_detail` fallback payload when we can't map the CoT type to a structured [org.meshtastic.proto.TAKPacketV2]
|
||||
* payload. Preserves any extension elements the xmlutil parser discarded as "unknown children".
|
||||
*
|
||||
* Returns null for self-closed `<detail/>` or when no detail element is present.
|
||||
*/
|
||||
private fun extractDetailInnerXml(xml: String): String? {
|
||||
// Match `<detail ...>` (not `<detail/>`) through its matching close tag.
|
||||
val openIdx = xml.indexOf("<detail")
|
||||
if (openIdx < 0) return null
|
||||
val openEnd = xml.indexOf('>', openIdx)
|
||||
if (openEnd < 0) return null
|
||||
// Self-closed tag like `<detail/>` has no content.
|
||||
if (xml[openEnd - 1] == '/') return null
|
||||
val closeIdx = xml.indexOf("</detail>", openEnd)
|
||||
if (closeIdx < 0) return null
|
||||
val inner = xml.substring(openEnd + 1, closeIdx).trim()
|
||||
return inner.ifEmpty { null }
|
||||
}
|
||||
|
||||
private fun buildContact(detail: CoTDetailXml?): CoTContact? = detail?.contact?.let {
|
||||
if (it.callsign.isNotEmpty() || it.endpoint != null || it.phone != null) {
|
||||
CoTContact(callsign = it.callsign, endpoint = it.endpoint, phone = it.phone)
|
||||
@@ -107,7 +138,8 @@ class CoTXmlParser(private val xml: String) {
|
||||
val cleaned = dateString.replace(Regex("""\.\d+"""), "").replace("Z", "+00:00")
|
||||
Instant.parse(cleaned)
|
||||
} catch (ignoredInner: IllegalArgumentException) {
|
||||
Clock.System.now() // Return now as fallback
|
||||
Logger.w { "Unparseable CoT date '$dateString', falling back to now()" }
|
||||
Clock.System.now()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
/*
|
||||
* Copyright (c) 2026 Meshtastic LLC
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
@file:Suppress("ReturnCount")
|
||||
|
||||
package org.meshtastic.core.takserver
|
||||
|
||||
/**
|
||||
* Converts route CoT XML (b-m-r) into ATAK-importable KML data packages.
|
||||
*
|
||||
* ATAK silently ignores route CoT events received over TCP streaming connections — it only accepts routes from KML/GPX
|
||||
* file import, TAK Server mission sync, or data packages auto-imported from the monitored directory
|
||||
* `/sdcard/atak/tools/datapackage/`. This generator bridges the gap by extracting waypoints from the SDK-reconstructed
|
||||
* route XML and packaging them as a KML LineString inside a MissionPackageManifest v2 zip.
|
||||
*/
|
||||
object RouteDataPackageGenerator {
|
||||
|
||||
private val EVENT_UID_RE = Regex("""<event\s[^>]*\buid="([^"]*)"""")
|
||||
private val CONTACT_CALLSIGN_RE = Regex("""<contact\s[^>]*\bcallsign="([^"]*)"""")
|
||||
private val LINK_POINT_RE = Regex("""<link\s[^>]*\bpoint="([^"]*)"[^>]*/>""")
|
||||
|
||||
data class RouteKmlResult(val kml: String, val routeUid: String, val routeName: String)
|
||||
|
||||
/**
|
||||
* Extract waypoints from route CoT XML and generate a KML LineString. Returns null if fewer than 2 waypoints are
|
||||
* found.
|
||||
*/
|
||||
fun generateKml(routeXml: String): RouteKmlResult? {
|
||||
val uid = EVENT_UID_RE.find(routeXml)?.groupValues?.getOrNull(1) ?: return null
|
||||
val name = CONTACT_CALLSIGN_RE.find(routeXml)?.groupValues?.getOrNull(1) ?: "Mesh Route"
|
||||
|
||||
// Extract all waypoint coordinates from <link ... point="lat,lon,hae" .../> elements
|
||||
val waypoints =
|
||||
LINK_POINT_RE.findAll(routeXml)
|
||||
.mapNotNull { match ->
|
||||
val point = match.groupValues[1] // "lat,lon,hae" or "lat,lon"
|
||||
val parts = point.split(",").map { it.trim() }
|
||||
if (parts.size >= 2) {
|
||||
val lat = parts[0]
|
||||
val lon = parts[1]
|
||||
val hae = parts.getOrElse(2) { "0" }
|
||||
// KML coordinate order is lon,lat,hae (opposite of CoT's lat,lon,hae)
|
||||
"$lon,$lat,$hae"
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
.toList()
|
||||
|
||||
if (waypoints.size < 2) return null
|
||||
|
||||
val kml = buildString {
|
||||
appendLine("""<?xml version="1.0" encoding="UTF-8"?>""")
|
||||
appendLine("""<kml xmlns="http://www.opengis.net/kml/2.2">""")
|
||||
appendLine(" <Document>")
|
||||
appendLine(" <name>${name.xmlEscaped()}</name>")
|
||||
appendLine(" <Placemark>")
|
||||
appendLine(" <name>${name.xmlEscaped()}</name>")
|
||||
appendLine(" <Style>")
|
||||
appendLine(" <LineStyle><color>ff0000ff</color><width>3</width></LineStyle>")
|
||||
appendLine(" </Style>")
|
||||
appendLine(" <LineString>")
|
||||
appendLine(" <coordinates>")
|
||||
for (coord in waypoints) {
|
||||
appendLine(" $coord")
|
||||
}
|
||||
appendLine(" </coordinates>")
|
||||
appendLine(" </LineString>")
|
||||
appendLine(" </Placemark>")
|
||||
appendLine(" </Document>")
|
||||
append("</kml>")
|
||||
}
|
||||
|
||||
return RouteKmlResult(kml = kml, routeUid = uid, routeName = name)
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a complete ATAK data package (zip) containing the route as KML. Returns (fileName, zipBytes) or null if
|
||||
* the route XML can't be parsed.
|
||||
*/
|
||||
fun generateDataPackage(routeXml: String): Pair<String, ByteArray>? {
|
||||
val result = generateKml(routeXml) ?: return null
|
||||
val kmlFileName = "${result.routeUid}.kml"
|
||||
val zipFileName = "${result.routeUid}.zip"
|
||||
|
||||
val manifest = buildString {
|
||||
appendLine("""<MissionPackageManifest version="2">""")
|
||||
appendLine(" <Configuration>")
|
||||
appendLine(""" <Parameter name="uid" value="Meshtastic Route.${result.routeUid}"/>""")
|
||||
appendLine(""" <Parameter name="name" value="${result.routeName.xmlEscaped()}"/>""")
|
||||
appendLine(""" <Parameter name="onReceiveDelete" value="true"/>""")
|
||||
appendLine(" </Configuration>")
|
||||
appendLine(" <Contents>")
|
||||
appendLine(""" <Content ignore="false" zipEntry="$kmlFileName"/>""")
|
||||
appendLine(" </Contents>")
|
||||
append("</MissionPackageManifest>")
|
||||
}
|
||||
|
||||
val zipBytes =
|
||||
ZipArchiver.createZip(
|
||||
mapOf(kmlFileName to result.kml.encodeToByteArray(), "manifest.xml" to manifest.encodeToByteArray()),
|
||||
)
|
||||
|
||||
return zipFileName to zipBytes
|
||||
}
|
||||
}
|
||||
@@ -1,253 +0,0 @@
|
||||
/*
|
||||
* Copyright (c) 2026 Meshtastic LLC
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
@file:Suppress("TooManyFunctions", "TooGenericExceptionCaught")
|
||||
|
||||
package org.meshtastic.core.takserver
|
||||
|
||||
import co.touchlab.kermit.Logger
|
||||
import io.ktor.network.sockets.Socket
|
||||
import io.ktor.network.sockets.isClosed
|
||||
import io.ktor.network.sockets.openReadChannel
|
||||
import io.ktor.network.sockets.openWriteChannel
|
||||
import io.ktor.utils.io.ByteReadChannel
|
||||
import io.ktor.utils.io.ByteWriteChannel
|
||||
import io.ktor.utils.io.readAvailable
|
||||
import io.ktor.utils.io.writeStringUtf8
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import kotlin.concurrent.Volatile
|
||||
import kotlin.random.Random
|
||||
import kotlin.time.Clock
|
||||
import kotlin.time.Duration.Companion.milliseconds
|
||||
import kotlin.time.Instant
|
||||
import kotlinx.coroutines.isActive as coroutineIsActive
|
||||
|
||||
class TAKClientConnection(
|
||||
private val socket: Socket,
|
||||
val clientInfo: TAKClientInfo,
|
||||
private val onEvent: (TAKConnectionEvent) -> Unit,
|
||||
private val scope: CoroutineScope,
|
||||
) {
|
||||
private var currentClientInfo = clientInfo
|
||||
private val frameBuffer = CoTXmlFrameBuffer()
|
||||
|
||||
private val readChannel: ByteReadChannel = socket.openReadChannel()
|
||||
private val writeChannel: ByteWriteChannel = socket.openWriteChannel(autoFlush = true)
|
||||
private val writeMutex = Mutex()
|
||||
|
||||
/** Tracks the last time data was received from the client, used for idle timeout detection. */
|
||||
@Volatile private var lastDataReceived: Instant = Clock.System.now()
|
||||
|
||||
/** Guards against emitting [TAKConnectionEvent.Disconnected] more than once. */
|
||||
@Volatile private var disconnectedEmitted = false
|
||||
|
||||
fun start() {
|
||||
onEvent(TAKConnectionEvent.Connected(currentClientInfo))
|
||||
sendProtocolSupport()
|
||||
|
||||
scope.launch { readLoop() }
|
||||
|
||||
scope.launch { keepaliveLoop() }
|
||||
}
|
||||
|
||||
private fun sendProtocolSupport() {
|
||||
val serverUid = "Meshtastic-TAK-Server-${Random.nextInt().toString(TAK_HEX_RADIX)}"
|
||||
val now = Clock.System.now()
|
||||
val stale = now + TAK_KEEPALIVE_INTERVAL_MS.milliseconds
|
||||
val detail =
|
||||
"""
|
||||
<TakControl>
|
||||
<TakProtocolSupport version="0"/>
|
||||
</TakControl>
|
||||
"""
|
||||
.trimIndent()
|
||||
sendXml(buildEventXml(uid = serverUid, type = "t-x-takp-v", now = now, stale = stale, detail = detail))
|
||||
}
|
||||
|
||||
private suspend fun readLoop() {
|
||||
try {
|
||||
val buffer = ByteArray(TAK_XML_READ_BUFFER_SIZE)
|
||||
while (scope.coroutineIsActive && !socket.isClosed) {
|
||||
// Suspend until data is available — no polling delay needed
|
||||
readChannel.awaitContent()
|
||||
val bytesRead = readChannel.readAvailable(buffer)
|
||||
if (bytesRead > 0) {
|
||||
lastDataReceived = Clock.System.now()
|
||||
processReceivedData(buffer.copyOfRange(0, bytesRead))
|
||||
} else if (bytesRead == -1) {
|
||||
break // EOF
|
||||
}
|
||||
}
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
Logger.w(e) { "TAK client read error: ${currentClientInfo.id}" }
|
||||
emitDisconnected(TAKConnectionEvent.Error(e))
|
||||
return
|
||||
}
|
||||
emitDisconnected(TAKConnectionEvent.Disconnected)
|
||||
}
|
||||
|
||||
private suspend fun keepaliveLoop() {
|
||||
val idleTimeoutMs = TAK_KEEPALIVE_INTERVAL_MS * TAK_READ_IDLE_TIMEOUT_MULTIPLIER
|
||||
while (scope.coroutineIsActive && !socket.isClosed) {
|
||||
kotlinx.coroutines.delay(TAK_KEEPALIVE_INTERVAL_MS)
|
||||
|
||||
val idleMs = (Clock.System.now() - lastDataReceived).inWholeMilliseconds
|
||||
if (idleMs > idleTimeoutMs) {
|
||||
Logger.w {
|
||||
"TAK client ${currentClientInfo.id} idle for ${idleMs}ms " +
|
||||
"(threshold ${idleTimeoutMs}ms), closing connection"
|
||||
}
|
||||
close()
|
||||
return
|
||||
}
|
||||
|
||||
sendKeepalive()
|
||||
}
|
||||
}
|
||||
|
||||
private fun sendKeepalive() {
|
||||
val now = Clock.System.now()
|
||||
val stale = now + (TAK_KEEPALIVE_INTERVAL_MS * TAK_KEEPALIVE_STALE_MULTIPLIER).milliseconds
|
||||
sendXml(buildEventXml(uid = "takPong", type = "t-x-c-t", now = now, stale = stale, detail = ""))
|
||||
}
|
||||
|
||||
private fun sendPong() {
|
||||
val now = Clock.System.now()
|
||||
val stale = now + (TAK_KEEPALIVE_INTERVAL_MS * TAK_KEEPALIVE_STALE_MULTIPLIER).milliseconds
|
||||
sendXml(buildEventXml(uid = "takPong", type = "t-x-c-t-r", now = now, stale = stale, detail = ""))
|
||||
}
|
||||
|
||||
private fun processReceivedData(newData: ByteArray) {
|
||||
// frameBuffer.append returns List<String> — pass directly without re-encoding
|
||||
frameBuffer.append(newData).forEach { xmlString -> parseAndHandleMessage(xmlString) }
|
||||
}
|
||||
|
||||
private fun parseAndHandleMessage(xmlString: String) {
|
||||
// Parse first, then filter on the structured type field to avoid false positives
|
||||
val parser = CoTXmlParser(xmlString)
|
||||
val result = parser.parse()
|
||||
|
||||
result.onSuccess { cotMessage ->
|
||||
when {
|
||||
cotMessage.type.startsWith("t-x-takp") -> {
|
||||
handleProtocolControl(cotMessage.type, xmlString)
|
||||
return
|
||||
}
|
||||
|
||||
cotMessage.type == "t-x-c-t" || cotMessage.uid == "ping" -> {
|
||||
sendPong()
|
||||
return
|
||||
}
|
||||
|
||||
else -> {
|
||||
cotMessage.contact?.let { contact ->
|
||||
val updatedClientInfo =
|
||||
currentClientInfo.copy(
|
||||
callsign = currentClientInfo.callsign ?: contact.callsign,
|
||||
uid = currentClientInfo.uid ?: cotMessage.uid,
|
||||
)
|
||||
if (updatedClientInfo != currentClientInfo) {
|
||||
currentClientInfo = updatedClientInfo
|
||||
onEvent(TAKConnectionEvent.ClientInfoUpdated(updatedClientInfo))
|
||||
}
|
||||
}
|
||||
|
||||
onEvent(TAKConnectionEvent.Message(cotMessage))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleProtocolControl(type: String, xmlString: String) {
|
||||
if (type == "t-x-takp-q") {
|
||||
sendProtocolResponse()
|
||||
} else {
|
||||
Logger.d { "Unhandled protocol control type: $type (raw=$xmlString)" }
|
||||
}
|
||||
}
|
||||
|
||||
private fun sendProtocolResponse() {
|
||||
val serverUid = "Meshtastic-TAK-Server-${Random.nextInt().toString(TAK_HEX_RADIX)}"
|
||||
val now = Clock.System.now()
|
||||
val stale = now + TAK_KEEPALIVE_INTERVAL_MS.milliseconds
|
||||
val detail =
|
||||
"""
|
||||
<TakControl>
|
||||
<TakResponse status="true"/>
|
||||
</TakControl>
|
||||
"""
|
||||
.trimIndent()
|
||||
sendXml(buildEventXml(uid = serverUid, type = "t-x-takp-r", now = now, stale = stale, detail = detail))
|
||||
}
|
||||
|
||||
fun send(cotMessage: CoTMessage) {
|
||||
val xml = cotMessage.toXml()
|
||||
sendXml(xml)
|
||||
}
|
||||
|
||||
private fun buildEventXml(uid: String, type: String, now: Instant, stale: Instant, detail: String): String {
|
||||
val detailContent = if (detail.isBlank()) "<detail/>" else "<detail>$detail</detail>"
|
||||
val point = """<point lat="0" lon="0" hae="0" ce="$TAK_UNKNOWN_POINT_VALUE" le="$TAK_UNKNOWN_POINT_VALUE"/>"""
|
||||
return """<event version="2.0" uid="$uid" type="$type" time="$now" start="$now" stale="$stale" how="m-g">""" +
|
||||
point +
|
||||
detailContent +
|
||||
"</event>"
|
||||
}
|
||||
|
||||
private fun sendXml(xml: String) {
|
||||
scope.launch {
|
||||
try {
|
||||
writeMutex.withLock {
|
||||
if (!socket.isClosed) {
|
||||
writeChannel.writeStringUtf8(xml)
|
||||
}
|
||||
}
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
Logger.w(e) { "TAK client send error: ${currentClientInfo.id}" }
|
||||
close()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun close() {
|
||||
frameBuffer.clear()
|
||||
try {
|
||||
socket.close()
|
||||
} catch (e: Exception) {
|
||||
Logger.w(e) { "Error closing TAK client socket: ${currentClientInfo.id}" }
|
||||
}
|
||||
emitDisconnected(TAKConnectionEvent.Disconnected)
|
||||
}
|
||||
|
||||
/**
|
||||
* Emits [event] (expected to be [TAKConnectionEvent.Disconnected] or [TAKConnectionEvent.Error]) at most once
|
||||
* across all code paths.
|
||||
*/
|
||||
private fun emitDisconnected(event: TAKConnectionEvent) {
|
||||
if (!disconnectedEmitted) {
|
||||
disconnectedEmitted = true
|
||||
onEvent(event)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -14,6 +14,8 @@
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
@file:Suppress("LongMethod")
|
||||
|
||||
package org.meshtastic.core.takserver
|
||||
|
||||
import nl.adaptivity.xmlutil.XmlDeclMode
|
||||
@@ -21,17 +23,27 @@ import nl.adaptivity.xmlutil.serialization.XML
|
||||
import kotlin.uuid.Uuid
|
||||
|
||||
/**
|
||||
* Generates TAK data packages (.zip) compatible with ATAK/iTAK import.
|
||||
* Generates TAK data packages (.zip) compatible with ATAK/iTAK/WinTAK import.
|
||||
*
|
||||
* The data package follows the MissionPackageManifest v2 format:
|
||||
* ```
|
||||
* Meshtastic_TAK_Server.zip
|
||||
* ├── meshtastic-server.pref (ATAK connection preferences)
|
||||
* ├── truststore.p12 (server cert — matches iOS "truststore.p12")
|
||||
* ├── client.p12 (client identity for mTLS)
|
||||
* └── manifest.xml (MissionPackageManifest v2)
|
||||
* ```
|
||||
*
|
||||
* The bundled certificates / password match Meshtastic-Apple so a single exported package works on both ATAK (Android)
|
||||
* and iTAK (iOS) without reconfiguration.
|
||||
*
|
||||
* Override [bundledCertBytesProvider] in tests to avoid touching the real classpath resources. In production the
|
||||
* default reads from [TakCertLoader].
|
||||
*/
|
||||
object TAKDataPackageGenerator {
|
||||
private const val PREF_FILE_NAME = "meshtastic-server.pref"
|
||||
private const val TRUSTSTORE_FILE_NAME = "truststore.p12"
|
||||
private const val CLIENT_P12_FILE_NAME = "client.p12"
|
||||
private const val PACKAGE_NAME = "Meshtastic_TAK_Server"
|
||||
|
||||
private val xmlSerializer = XML {
|
||||
@@ -39,24 +51,37 @@ object TAKDataPackageGenerator {
|
||||
indentString = " "
|
||||
}
|
||||
|
||||
/**
|
||||
* Platform-specific hook for reading the bundled TLS certificate bytes. Default implementation lives in
|
||||
* `jvmAndroidMain` and reads them from classpath resources via [TakCertLoader].
|
||||
*/
|
||||
var bundledCertBytesProvider: BundledCertBytesProvider = DefaultBundledCertBytesProvider
|
||||
|
||||
/**
|
||||
* Generate a complete TAK data package zip.
|
||||
*
|
||||
* @param useTls when true, package includes `truststore.p12` + `client.p12` and the pref file uses `ssl`; when
|
||||
* false, package is TCP-only (legacy).
|
||||
* @return zip file contents as a [ByteArray]
|
||||
*/
|
||||
fun generateDataPackage(
|
||||
serverHost: String = "127.0.0.1",
|
||||
port: Int = DEFAULT_TAK_PORT,
|
||||
useTls: Boolean = true,
|
||||
description: String = "Meshtastic TAK Server",
|
||||
): ByteArray {
|
||||
val prefContent = generateConfigPref(serverHost, port, description)
|
||||
val manifestContent = generateManifest(uid = Uuid.random().toString(), description = description)
|
||||
val prefContent = generateConfigPref(serverHost, port, useTls, description)
|
||||
val manifestContent =
|
||||
generateManifest(uid = Uuid.random().toString(), description = description, useTls = useTls)
|
||||
|
||||
val entries =
|
||||
mapOf(
|
||||
PREF_FILE_NAME to prefContent.encodeToByteArray(),
|
||||
"manifest.xml" to manifestContent.encodeToByteArray(),
|
||||
)
|
||||
val entries = mutableMapOf<String, ByteArray>()
|
||||
entries[PREF_FILE_NAME] = prefContent.encodeToByteArray()
|
||||
entries["manifest.xml"] = manifestContent.encodeToByteArray()
|
||||
|
||||
if (useTls) {
|
||||
bundledCertBytesProvider.serverP12Bytes()?.let { entries[TRUSTSTORE_FILE_NAME] = it }
|
||||
bundledCertBytesProvider.clientP12Bytes()?.let { entries[CLIENT_P12_FILE_NAME] = it }
|
||||
}
|
||||
|
||||
return ZipArchiver.createZip(entries)
|
||||
}
|
||||
@@ -64,31 +89,88 @@ object TAKDataPackageGenerator {
|
||||
internal fun generateConfigPref(
|
||||
serverHost: String = "127.0.0.1",
|
||||
port: Int = DEFAULT_TAK_PORT,
|
||||
useTls: Boolean = true,
|
||||
description: String = "Meshtastic TAK Server",
|
||||
): String {
|
||||
val protocolType = if (useTls) "ssl" else "tcp"
|
||||
val prefs =
|
||||
TAKPreferencesXml(
|
||||
preferences =
|
||||
listOf(
|
||||
TAKPreferenceXml(
|
||||
version = "1",
|
||||
name = "cot_streams",
|
||||
entries =
|
||||
listOf(
|
||||
TAKEntryXml("count", "class java.lang.Integer", "1"),
|
||||
TAKEntryXml("description0", "class java.lang.String", description),
|
||||
TAKEntryXml("enabled0", "class java.lang.Boolean", "true"),
|
||||
TAKEntryXml("connectString0", "class java.lang.String", "$serverHost:$port:tcp"),
|
||||
if (useTls) {
|
||||
// TLS / mTLS mode — matches the iOS data package format exactly.
|
||||
TAKPreferencesXml(
|
||||
preferences =
|
||||
listOf(
|
||||
TAKPreferenceXml(
|
||||
version = "1",
|
||||
name = "cot_streams",
|
||||
entries =
|
||||
listOf(
|
||||
TAKEntryXml("count", "class java.lang.Integer", "1"),
|
||||
TAKEntryXml("description0", "class java.lang.String", description),
|
||||
TAKEntryXml("enabled0", "class java.lang.Boolean", "true"),
|
||||
TAKEntryXml(
|
||||
"connectString0",
|
||||
"class java.lang.String",
|
||||
"$serverHost:$port:$protocolType",
|
||||
),
|
||||
),
|
||||
),
|
||||
TAKPreferenceXml(
|
||||
version = "1",
|
||||
name = "com.atakmap.app_preferences",
|
||||
entries =
|
||||
listOf(
|
||||
TAKEntryXml("displayServerConnectionWidget", "class java.lang.Boolean", "true"),
|
||||
TAKEntryXml(
|
||||
"caLocation",
|
||||
"class java.lang.String",
|
||||
"cert/$TRUSTSTORE_FILE_NAME",
|
||||
),
|
||||
TAKEntryXml("caPassword", "class java.lang.String", TAK_BUNDLED_CERT_PASSWORD),
|
||||
TAKEntryXml(
|
||||
"certificateLocation",
|
||||
"class java.lang.String",
|
||||
"cert/$CLIENT_P12_FILE_NAME",
|
||||
),
|
||||
TAKEntryXml(
|
||||
"clientPassword",
|
||||
"class java.lang.String",
|
||||
TAK_BUNDLED_CERT_PASSWORD,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
TAKPreferenceXml(
|
||||
version = "1",
|
||||
name = "com.atakmap.app_preferences",
|
||||
entries =
|
||||
listOf(TAKEntryXml("displayServerConnectionWidget", "class java.lang.Boolean", "true")),
|
||||
)
|
||||
} else {
|
||||
// Legacy plain-TCP mode (not used in production, kept for tests / fallback)
|
||||
TAKPreferencesXml(
|
||||
preferences =
|
||||
listOf(
|
||||
TAKPreferenceXml(
|
||||
version = "1",
|
||||
name = "cot_streams",
|
||||
entries =
|
||||
listOf(
|
||||
TAKEntryXml("count", "class java.lang.Integer", "1"),
|
||||
TAKEntryXml("description0", "class java.lang.String", description),
|
||||
TAKEntryXml("enabled0", "class java.lang.Boolean", "true"),
|
||||
TAKEntryXml(
|
||||
"connectString0",
|
||||
"class java.lang.String",
|
||||
"$serverHost:$port:$protocolType",
|
||||
),
|
||||
),
|
||||
),
|
||||
TAKPreferenceXml(
|
||||
version = "1",
|
||||
name = "com.atakmap.app_preferences",
|
||||
entries =
|
||||
listOf(
|
||||
TAKEntryXml("displayServerConnectionWidget", "class java.lang.Boolean", "true"),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
return xmlSerializer
|
||||
.encodeToString(TAKPreferencesXml.serializer(), prefs)
|
||||
@@ -98,7 +180,11 @@ object TAKDataPackageGenerator {
|
||||
)
|
||||
}
|
||||
|
||||
internal fun generateManifest(uid: String, description: String = "Meshtastic TAK Server"): String = buildString {
|
||||
internal fun generateManifest(
|
||||
uid: String,
|
||||
description: String = "Meshtastic TAK Server",
|
||||
useTls: Boolean = true,
|
||||
): String = buildString {
|
||||
appendLine("""<MissionPackageManifest version="2">""")
|
||||
appendLine(" <Configuration>")
|
||||
appendLine(""" <Parameter name="uid" value="${description.xmlEscaped()}.$uid"/>""")
|
||||
@@ -107,7 +193,31 @@ object TAKDataPackageGenerator {
|
||||
appendLine(" </Configuration>")
|
||||
appendLine(" <Contents>")
|
||||
appendLine(""" <Content ignore="false" zipEntry="$PREF_FILE_NAME"/>""")
|
||||
if (useTls) {
|
||||
appendLine(""" <Content ignore="false" zipEntry="$TRUSTSTORE_FILE_NAME"/>""")
|
||||
appendLine(""" <Content ignore="false" zipEntry="$CLIENT_P12_FILE_NAME"/>""")
|
||||
}
|
||||
appendLine(" </Contents>")
|
||||
append("</MissionPackageManifest>")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Supplies the bundled server / client PKCS#12 bytes for [TAKDataPackageGenerator]. Platform implementations live in
|
||||
* `jvmAndroidMain`.
|
||||
*/
|
||||
interface BundledCertBytesProvider {
|
||||
fun serverP12Bytes(): ByteArray?
|
||||
|
||||
fun clientP12Bytes(): ByteArray?
|
||||
}
|
||||
|
||||
/**
|
||||
* Default provider that returns `null` on platforms without a real implementation. Overridden at startup on JVM /
|
||||
* Android by pointing [TAKDataPackageGenerator.bundledCertBytesProvider] at [TakCertLoader].
|
||||
*/
|
||||
private object DefaultBundledCertBytesProvider : BundledCertBytesProvider {
|
||||
override fun serverP12Bytes(): ByteArray? = null
|
||||
|
||||
override fun clientP12Bytes(): ByteArray? = null
|
||||
}
|
||||
|
||||
@@ -20,22 +20,59 @@ import org.meshtastic.proto.MemberRole
|
||||
import org.meshtastic.proto.Team
|
||||
import org.meshtastic.proto.User
|
||||
|
||||
internal const val DEFAULT_TAK_PORT = 8087
|
||||
// Port 8089 is the standard TAK TLS port. Matches the iOS implementation so that
|
||||
// a single exported data package (containing truststore.p12 + client.p12) works for
|
||||
// both Meshtastic-iOS and Meshtastic-Android without reconfiguration in ATAK/iTAK.
|
||||
internal const val DEFAULT_TAK_PORT = 8089
|
||||
internal const val DEFAULT_TAK_ENDPOINT = "0.0.0.0:4242:tcp"
|
||||
|
||||
// Bundled certificate password — matches iOS (`"meshtastic"`). Used for the
|
||||
// server.p12 / client.p12 PKCS#12 files shipped under `tak_certs/` on the classpath.
|
||||
internal const val TAK_BUNDLED_CERT_PASSWORD = "meshtastic"
|
||||
internal const val DEFAULT_TAK_TEAM_NAME = "Cyan"
|
||||
internal const val DEFAULT_TAK_ROLE_NAME = "Team Member"
|
||||
internal const val DEFAULT_TAK_BATTERY = 100
|
||||
internal const val DEFAULT_TAK_STALE_MINUTES = 10
|
||||
internal const val TAK_HEX_RADIX = 16
|
||||
internal const val TAK_XML_READ_BUFFER_SIZE = 4_096
|
||||
internal const val TAK_KEEPALIVE_INTERVAL_MS = 30_000L
|
||||
internal const val TAK_KEEPALIVE_STALE_MULTIPLIER = 3
|
||||
internal const val TAK_READ_IDLE_TIMEOUT_MULTIPLIER = 5
|
||||
|
||||
// ATAK's native commo library declares the connection dead after 25 seconds of
|
||||
// silence (RX_TIMEOUT_SECONDS in streamingsocketmanagement.cpp) and starts
|
||||
// sending t-x-c-t pings at 15 seconds (RX_STALE_SECONDS). Send keepalives
|
||||
// well under the 15-second threshold so ATAK never enters its stale phase.
|
||||
internal const val TAK_KEEPALIVE_INTERVAL_MS = 10_000L
|
||||
internal const val TAK_ACCEPT_LOOP_DELAY_MS = 100L
|
||||
internal const val TAK_COORDINATE_SCALE = 1e7
|
||||
internal const val TAK_UNKNOWN_POINT_VALUE = 9_999_999.0
|
||||
internal const val TAK_DIRECT_MESSAGE_PARTS_MIN = 3
|
||||
|
||||
/**
|
||||
* Hard cap on the size of a TAK v2 wire payload we will hand to the mesh layer.
|
||||
*
|
||||
* `CommandSenderImpl.sendData` checks `Data.ADAPTER.isWithinSizeLimit(data, Constants.DATA_PAYLOAD_LEN.value)` where
|
||||
* `DATA_PAYLOAD_LEN = 233`. That 233 applies to the ENTIRE encoded `Data` proto (portnum tag + payload length-delim +
|
||||
* reply_id + emoji), not just the `payload` bytes. The wrapper for a port-78 (`ATAK_PLUGIN_V2`) message costs roughly:
|
||||
* * portnum varint + tag: 2 bytes
|
||||
* * payload length prefix + tag: 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
|
||||
|
||||
/** Default CoT type for PLI (Position Location Information) — friendly ground unit. */
|
||||
internal const val DEFAULT_PLI_COT_TYPE = "a-f-G-U-C"
|
||||
|
||||
/**
|
||||
* Max characters of raw CoT XML we'll write to logcat when dropping an oversized packet. ATAK can emit events several
|
||||
* KB long; logging the whole thing floods logcat and buries the signal. 1024 chars is enough to see the event type,
|
||||
* point, and the first few detail elements.
|
||||
*/
|
||||
internal const val TAK_LOG_XML_MAX_CHARS = 1_024
|
||||
|
||||
internal fun Team?.toTakTeamName(): String = when (this) {
|
||||
null,
|
||||
Team.Unspecifed_Color,
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
@file:Suppress("ReturnCount", "TooGenericExceptionCaught")
|
||||
@file:Suppress("ReturnCount", "TooGenericExceptionCaught", "LongMethod")
|
||||
|
||||
package org.meshtastic.core.takserver
|
||||
|
||||
@@ -24,73 +24,106 @@ import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.filter
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.mapLatest
|
||||
import kotlinx.coroutines.launch
|
||||
import okio.ByteString.Companion.toByteString
|
||||
import org.meshtastic.core.model.Capabilities
|
||||
import org.meshtastic.core.model.DataPacket
|
||||
import org.meshtastic.core.repository.CommandSender
|
||||
import org.meshtastic.core.repository.MeshConfigHandler
|
||||
import org.meshtastic.core.repository.NodeRepository
|
||||
import org.meshtastic.core.repository.ServiceRepository
|
||||
import org.meshtastic.core.takserver.TAKPacketConversion.toCoTMessage
|
||||
import org.meshtastic.core.takserver.TAKPacketConversion.toTAKPacket
|
||||
import org.meshtastic.core.takserver.fountain.CoTHandler
|
||||
import org.meshtastic.core.takserver.TAKPacketV2Conversion.toTAKPacketV2
|
||||
import org.meshtastic.proto.MemberRole
|
||||
import org.meshtastic.proto.MeshPacket
|
||||
import org.meshtastic.proto.PortNum
|
||||
import org.meshtastic.proto.TAKPacket
|
||||
import org.meshtastic.proto.Team
|
||||
import kotlin.concurrent.Volatile
|
||||
import kotlin.concurrent.atomics.AtomicBoolean
|
||||
import kotlin.concurrent.atomics.ExperimentalAtomicApi
|
||||
import kotlin.random.Random
|
||||
import kotlin.time.Clock
|
||||
import kotlin.time.Duration.Companion.minutes
|
||||
|
||||
/**
|
||||
* Bidirectional bridge between the local TAK server and the Meshtastic mesh network.
|
||||
*
|
||||
* Outbound traffic (TAK client -> mesh) is version-gated on the connected radio's firmware version, exposed via
|
||||
* [Capabilities.supportsTakV2]:
|
||||
* - Firmware **>= 2.8.0**: TAKPacketV2 on port 78 (ATAK_PLUGIN_V2) with zstd dictionary compression via TAKPacket-SDK.
|
||||
* Supports all CoT payload types (PLI, GeoChat, DrawnShape, Marker, Route, Aircraft, Casevac, Emergency, Task) with
|
||||
* compact typed encodings that fit under the 237B LoRa MTU.
|
||||
* - Firmware **<= 2.7.x**: Legacy [TAKPacket] on port 72 (ATAK_PLUGIN) with bare protobuf encoding. Supports only PLI
|
||||
* and GeoChat — shapes, markers, routes, and other typed CoT events are dropped (with a warning) because the legacy
|
||||
* schema cannot represent them.
|
||||
*
|
||||
* Inbound traffic (mesh -> TAK client) is always dual-path tolerant — both port 72 and port 78 are dispatched
|
||||
* regardless of the local radio's firmware version, so a v2-capable node can still relay legacy v1 packets received
|
||||
* from older nodes in mixed-firmware mesh deployments.
|
||||
*/
|
||||
@OptIn(ExperimentalAtomicApi::class)
|
||||
class TAKMeshIntegration(
|
||||
private val takServerManager: TAKServerManager,
|
||||
private val commandSender: CommandSender,
|
||||
private val nodeRepository: NodeRepository,
|
||||
private val serviceRepository: ServiceRepository,
|
||||
private val meshConfigHandler: MeshConfigHandler,
|
||||
private val cotHandler: CoTHandler,
|
||||
private val nodeRepository: NodeRepository,
|
||||
) {
|
||||
@Volatile private var isRunning = false
|
||||
private val jobs = mutableListOf<Job>()
|
||||
private var currentTeam: Team = Team.Unspecifed_Color
|
||||
private var currentRole: MemberRole = MemberRole.Unspecifed
|
||||
private val isRunning = AtomicBoolean(false)
|
||||
|
||||
// Immutable list reference replaced atomically in start()/stop(); never mutated in-place.
|
||||
// @Volatile only guarantees visibility of the reference itself — any in-place mutation
|
||||
// would bypass the visibility guarantee and must not be added.
|
||||
@Volatile private var jobs: List<Job> = emptyList()
|
||||
|
||||
@Volatile private var currentTeam: Team = Team.Unspecifed_Color
|
||||
|
||||
@Volatile private var currentRole: MemberRole = MemberRole.Unspecifed
|
||||
|
||||
fun start(scope: CoroutineScope) {
|
||||
if (isRunning) return
|
||||
isRunning = true
|
||||
if (!isRunning.compareAndSet(expectedValue = false, newValue = true)) return
|
||||
|
||||
takServerManager.start(scope)
|
||||
|
||||
val newJobs =
|
||||
listOf(
|
||||
// Forward incoming CoT from TAK clients to mesh
|
||||
scope.launch { takServerManager.inboundMessages.collect { cotMessage -> sendCoTToMesh(cotMessage) } },
|
||||
scope.launch {
|
||||
takServerManager.inboundMessages.collect { (cotMessage, clientInfo) ->
|
||||
// Enrich GeoChat messages with the originating TAK client's
|
||||
// callsign when the message itself lacks one. This only applies
|
||||
// to messages FROM the connected TAK client — mesh-originated
|
||||
// messages flow through handleMeshPacket() instead.
|
||||
val enriched =
|
||||
if (
|
||||
cotMessage.type == "b-t-f" &&
|
||||
cotMessage.contact?.callsign.isNullOrEmpty() &&
|
||||
clientInfo?.callsign != null
|
||||
) {
|
||||
cotMessage.copy(
|
||||
contact =
|
||||
(cotMessage.contact ?: CoTContact(callsign = "")).copy(
|
||||
callsign = clientInfo.callsign,
|
||||
),
|
||||
)
|
||||
} else {
|
||||
cotMessage
|
||||
}
|
||||
sendCoTToMesh(enriched)
|
||||
}
|
||||
},
|
||||
|
||||
// Forward incoming ATAK packets from mesh to TAK clients
|
||||
scope.launch {
|
||||
serviceRepository.meshPacketFlow
|
||||
.filter {
|
||||
it.decoded?.portnum == PortNum.ATAK_PLUGIN || it.decoded?.portnum == PortNum.ATAK_FORWARDER
|
||||
it.decoded?.portnum == PortNum.ATAK_PLUGIN_V2 || it.decoded?.portnum == PortNum.ATAK_PLUGIN
|
||||
}
|
||||
.collect { packet -> handleMeshPacket(packet) }
|
||||
},
|
||||
|
||||
// Broadcast node positions to TAK clients.
|
||||
// mapLatest cancels any in-flight broadcast loop when a new node-map emission arrives,
|
||||
// preventing N×M fan-out from stacking up across rapid consecutive updates.
|
||||
scope.launch {
|
||||
nodeRepository.nodeDBbyNum
|
||||
.mapLatest { nodes ->
|
||||
nodes.forEach { (_, node) ->
|
||||
takServerManager.broadcastNode(
|
||||
node = node,
|
||||
team = currentTeam.toTakTeamName(),
|
||||
role = currentRole.toTakRoleName(),
|
||||
)
|
||||
}
|
||||
}
|
||||
.collect {}
|
||||
},
|
||||
// Track TAK config changes
|
||||
scope.launch {
|
||||
meshConfigHandler.moduleConfig
|
||||
.map { it.tak }
|
||||
@@ -102,62 +135,418 @@ class TAKMeshIntegration(
|
||||
},
|
||||
)
|
||||
|
||||
jobs.addAll(newJobs)
|
||||
|
||||
Logger.i { "TAK Mesh Integration started" }
|
||||
jobs = newJobs
|
||||
val fw = nodeRepository.myNodeInfo.value?.firmwareVersion
|
||||
val proto = if (Capabilities(fw).supportsTakV2) "v2 (port 78, zstd)" else "v1 (port 72, legacy)"
|
||||
Logger.i { "TAK Mesh Integration started — firmware=$fw, outbound=$proto" }
|
||||
}
|
||||
|
||||
fun stop() {
|
||||
if (!isRunning) return
|
||||
isRunning = false
|
||||
// Cancel all tracked jobs and clear the list
|
||||
val toCancel: List<Job>
|
||||
toCancel = jobs.toList()
|
||||
jobs.clear()
|
||||
if (!isRunning.compareAndSet(expectedValue = true, newValue = false)) return
|
||||
val toCancel = jobs
|
||||
jobs = emptyList()
|
||||
toCancel.forEach(Job::cancel)
|
||||
takServerManager.stop()
|
||||
Logger.i { "TAK Mesh Integration stopped" }
|
||||
}
|
||||
|
||||
// ── Send: TAK client → mesh ─────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Determine the outbound TAK protocol version based on the connected radio's firmware version. Evaluated per-send
|
||||
* (not cached) so the bridge picks up firmware upgrades during a session without restart. If the firmware version
|
||||
* is unavailable (radio not yet handshook), default to V2 — the v2 firmware was released widely enough that
|
||||
* defaulting to legacy would be a regression for the common case.
|
||||
*/
|
||||
private fun useTakV2(): Boolean {
|
||||
val fw = nodeRepository.myNodeInfo.value?.firmwareVersion ?: return true
|
||||
return Capabilities(fw).supportsTakV2
|
||||
}
|
||||
|
||||
private suspend fun sendCoTToMesh(cotMessage: CoTMessage) {
|
||||
val takPacket = cotMessage.toTAKPacket()
|
||||
if (takPacket == null) {
|
||||
cotHandler.sendGenericCoT(cotMessage)
|
||||
if (useTakV2()) {
|
||||
sendCoTToMeshV2(cotMessage)
|
||||
} else {
|
||||
sendCoTToMeshV1(cotMessage)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* v2 send path (firmware >= 2.8.0): SDK parser + zstd dictionary compression, full typed payload support
|
||||
* (DrawnShape, Marker, Route, Aircraft, Casevac, Emergency, Task, plus PLI / GeoChat). Wire format: `[flags
|
||||
* byte][zstd-compressed TAKPacketV2 protobuf]` on port 78 (ATAK_PLUGIN_V2).
|
||||
*/
|
||||
private suspend fun sendCoTToMeshV2(cotMessage: CoTMessage) {
|
||||
// Prefer the sourceEventXml for shape/marker/route types — the SDK's
|
||||
// CotXmlParser extracts compact typed payloads (DrawnShape, Marker,
|
||||
// Route, etc.) that compress far better than raw_detail encoding.
|
||||
// For PLI and GeoChat, use the enriched CoTMessage (which may have
|
||||
// had callsign/contact injected by the upstream enrichment step).
|
||||
val rawXml = cotMessage.sourceEventXml ?: cotMessage.toXml()
|
||||
// Extend stale for static objects (routes, shapes, markers) that may
|
||||
// arrive over LoRa mesh past their original TTL. iTAK uses 2-min stale
|
||||
// for routes; ATAK uses 24h. 5 min ensures it survives mesh delivery.
|
||||
val freshXml = ensureMinimumStaleForMesh(rawXml)
|
||||
// Strip non-essential elements before compression to save wire bytes
|
||||
val xml = stripNonEssentialElements(freshXml)
|
||||
|
||||
// Route through the SDK parser/compressor which handles all typed
|
||||
// payloads (DrawnShape, Marker, Route, Aircraft, etc.) with compact
|
||||
// proto fields instead of raw_detail XML. Falls back to the app's
|
||||
// own conversion only if the SDK path fails.
|
||||
//
|
||||
// compressWithRemarksFallback preserves <remarks> text when the
|
||||
// compressed packet fits under the LoRa MTU, and strips remarks
|
||||
// automatically if needed to fit. Returns null if even without
|
||||
// remarks the packet exceeds the limit.
|
||||
val wirePayload: ByteArray =
|
||||
try {
|
||||
TakSdkCompressor.compressCoT(xml, MAX_TAK_WIRE_PAYLOAD_BYTES)
|
||||
?: run {
|
||||
Logger.w {
|
||||
buildString {
|
||||
append(
|
||||
"Dropping oversized TAK packet: " +
|
||||
"type=${cotMessage.type} max=$MAX_TAK_WIRE_PAYLOAD_BYTES",
|
||||
)
|
||||
cotMessage.sourceEventXml?.let { src ->
|
||||
append('\n')
|
||||
append("Source CoT event: ")
|
||||
append(
|
||||
if (src.length <= TAK_LOG_XML_MAX_CHARS) {
|
||||
src
|
||||
} else {
|
||||
src.take(TAK_LOG_XML_MAX_CHARS) + "…"
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Logger.w(e) { "SDK parser/compressor failed for ${cotMessage.type}, trying app conversion" }
|
||||
val takPacketV2 = cotMessage.toTAKPacketV2()
|
||||
if (takPacketV2 == null) {
|
||||
Logger.w { "Cannot convert CoT type ${cotMessage.type} to TAKPacketV2, dropping" }
|
||||
return
|
||||
}
|
||||
try {
|
||||
TakV2Compressor.compress(takPacketV2)
|
||||
} catch (e2: Exception) {
|
||||
Logger.w(e2) { "V2 compression failed for ${cotMessage.type}, using uncompressed wire format" }
|
||||
encodeUncompressed(takPacketV2)
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
val dataPacket =
|
||||
DataPacket(
|
||||
to = DataPacket.ID_BROADCAST,
|
||||
bytes = wirePayload.toByteString(),
|
||||
dataType = PortNum.ATAK_PLUGIN_V2.value,
|
||||
)
|
||||
commandSender.sendData(dataPacket)
|
||||
Logger.d { "Sent V2 to mesh: ${cotMessage.type} (${wirePayload.size} bytes)" }
|
||||
} catch (e: Exception) {
|
||||
// Something other than size — radio not connected, queue full, etc.
|
||||
Logger.e(e) {
|
||||
"Failed to send TAKPacketV2 to mesh (${cotMessage.type}, ${wirePayload.size} bytes): ${e.message}"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Legacy v1 send path (firmware <= 2.7.x): bare protobuf-encoded [TAKPacket] on port 72 (ATAK_PLUGIN), no zstd
|
||||
* compression. Only PLI and GeoChat payloads are supported by the v1 schema — shapes, markers, routes, casevac,
|
||||
* emergency, and task CoT events are dropped with a warning.
|
||||
*/
|
||||
private suspend fun sendCoTToMeshV1(cotMessage: CoTMessage) {
|
||||
val takPacket =
|
||||
cotMessage.toTAKPacket()
|
||||
?: run {
|
||||
Logger.w {
|
||||
"Dropping CoT for legacy v1 radio: type=${cotMessage.type} not representable " +
|
||||
"in v1 TAKPacket schema (only PLI and GeoChat are supported). " +
|
||||
"Upgrade radio firmware to >= 2.8.0 for full payload support."
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
val wirePayload = TAKPacket.ADAPTER.encode(takPacket)
|
||||
if (wirePayload.size > MAX_TAK_WIRE_PAYLOAD_BYTES) {
|
||||
Logger.w {
|
||||
"Dropping oversized v1 TAK packet: type=${cotMessage.type} " +
|
||||
"size=${wirePayload.size}B max=$MAX_TAK_WIRE_PAYLOAD_BYTES"
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
val payload = TAKPacket.ADAPTER.encode(takPacket)
|
||||
|
||||
val dataPacket =
|
||||
DataPacket(
|
||||
to = DataPacket.ID_BROADCAST,
|
||||
bytes = payload.toByteString(),
|
||||
dataType = PortNum.ATAK_PLUGIN.value,
|
||||
)
|
||||
|
||||
commandSender.sendData(dataPacket)
|
||||
Logger.d { "Forwarded CoT to mesh as TAKPacket: ${cotMessage.type}" }
|
||||
try {
|
||||
val dataPacket =
|
||||
DataPacket(
|
||||
to = DataPacket.ID_BROADCAST,
|
||||
bytes = wirePayload.toByteString(),
|
||||
dataType = PortNum.ATAK_PLUGIN.value,
|
||||
)
|
||||
commandSender.sendData(dataPacket)
|
||||
Logger.d { "Sent V1 to mesh: ${cotMessage.type} (${wirePayload.size} bytes)" }
|
||||
} catch (e: Exception) {
|
||||
Logger.e(e) {
|
||||
"Failed to send v1 TAKPacket to mesh (${cotMessage.type}, ${wirePayload.size} bytes): ${e.message}"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Wrap a [org.meshtastic.proto.TAKPacketV2] into the uncompressed v2 wire format: `[0xFF flag byte][raw protobuf]`.
|
||||
* Used as a fallback when the zstd native lib isn't loaded.
|
||||
*/
|
||||
private fun encodeUncompressed(takPacketV2: org.meshtastic.proto.TAKPacketV2): ByteArray {
|
||||
val protoBytes = org.meshtastic.proto.TAKPacketV2.ADAPTER.encode(takPacketV2)
|
||||
val out = ByteArray(1 + protoBytes.size)
|
||||
out[0] = TakV2Compressor.DICT_ID_UNCOMPRESSED.toByte()
|
||||
protoBytes.copyInto(out, 1)
|
||||
return out
|
||||
}
|
||||
|
||||
// ── Receive: mesh → TAK client ──────────────────────────────────────────
|
||||
|
||||
private suspend fun handleMeshPacket(packet: MeshPacket) {
|
||||
val payload = packet.decoded?.payload ?: return
|
||||
|
||||
if (packet.decoded?.portnum == PortNum.ATAK_FORWARDER) {
|
||||
cotHandler.handleIncomingForwarderPacket(payload.toByteArray(), packet.from)
|
||||
return
|
||||
when (packet.decoded?.portnum) {
|
||||
PortNum.ATAK_PLUGIN_V2 -> handleV2Packet(payload.toByteArray())
|
||||
PortNum.ATAK_PLUGIN -> handleV1Packet(payload)
|
||||
else -> return
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun handleV2Packet(wirePayload: ByteArray) {
|
||||
try {
|
||||
// Decompress to CoT XML via the SDK's CotXmlBuilder, which handles
|
||||
// ALL typed payloads (DrawnShape, Marker, Route, etc.) and preserves
|
||||
// shape detail elements (vertices, colors, stroke weight) that the
|
||||
// app's own CoTXmlParser would strip. Forward the SDK-generated XML
|
||||
// directly to TAK clients without re-parsing.
|
||||
val rawXml = TakV2Compressor.decompressToXml(wirePayload)
|
||||
// Strip the XML declaration and collapse whitespace — ATAK's TCP
|
||||
// streaming parser expects bare <event>...</event> on a single
|
||||
// line, not a formatted XML document with <?xml ...?> prologue.
|
||||
val xml =
|
||||
rawXml
|
||||
.replace("""<?xml version="1.0" encoding="UTF-8"?>""", "")
|
||||
.replace(Regex("""\s*\n\s*"""), "")
|
||||
.trim()
|
||||
// Logger.d { "RAW CoT IN (mesh): $xml" }
|
||||
// Routes: ATAK ignores b-m-r CoT events over TCP streaming.
|
||||
// Convert to a KML data package and write to ATAK's auto-import dir.
|
||||
if (xml.contains("""type="b-m-r"""")) {
|
||||
try {
|
||||
val pkg = RouteDataPackageGenerator.generateDataPackage(xml)
|
||||
if (pkg != null) {
|
||||
val (fileName, zipBytes) = pkg
|
||||
AtakFileWriter.writeToImportDir(fileName, zipBytes)
|
||||
} else {
|
||||
Logger.w { "Route data package generation failed — not enough waypoints?" }
|
||||
}
|
||||
} catch (e2: Exception) {
|
||||
Logger.w(e2) { "Route data package write failed: ${e2.message}" }
|
||||
}
|
||||
}
|
||||
takServerManager.broadcastRawXml(xml)
|
||||
Logger.d { "V2 → TAK clients (raw XML)" }
|
||||
} catch (e: Exception) {
|
||||
Logger.w(e) { "Failed to handle V2 packet: ${e.message}" }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* v1 receive path (firmware <= 2.7.x): decode bare protobuf [TAKPacket] (no compression) from port 72 (ATAK_PLUGIN)
|
||||
* and convert to CoT for forwarding to attached TAK clients. Kept indefinitely so users on stable 2.7.x firmware
|
||||
* retain PLI + GeoChat interop; new typed payloads (shapes, markers, routes, etc.) still require a v2-capable radio
|
||||
* (firmware >= 2.8.0).
|
||||
*/
|
||||
private suspend fun handleV1Packet(payload: okio.ByteString) {
|
||||
try {
|
||||
val takPacket = TAKPacket.ADAPTER.decode(payload)
|
||||
val cotMessage = convertV1ToCoT(takPacket) ?: return
|
||||
takServerManager.broadcast(cotMessage)
|
||||
Logger.d { "V1 → TAK clients: ${cotMessage.type}" }
|
||||
} catch (e: Exception) {
|
||||
Logger.w(e) { "Failed to handle V1 packet: ${e.message}" }
|
||||
}
|
||||
}
|
||||
|
||||
private fun convertV1ToCoT(takPacket: TAKPacket): CoTMessage? {
|
||||
val callsign = takPacket.contact?.callsign ?: "UNKNOWN"
|
||||
val senderUid = takPacket.contact?.device_callsign ?: "unknown"
|
||||
val teamName = takPacket.group?.team?.toTakTeamName() ?: DEFAULT_TAK_TEAM_NAME
|
||||
val roleName = takPacket.group?.role?.toTakRoleName() ?: DEFAULT_TAK_ROLE_NAME
|
||||
val battery = takPacket.status?.battery ?: DEFAULT_TAK_BATTERY
|
||||
|
||||
val pli = takPacket.pli
|
||||
if (pli != null) {
|
||||
return CoTMessage.pli(
|
||||
uid = senderUid,
|
||||
callsign = callsign,
|
||||
latitude = pli.latitude_i.toDouble() / TAK_COORDINATE_SCALE,
|
||||
longitude = pli.longitude_i.toDouble() / TAK_COORDINATE_SCALE,
|
||||
altitude = pli.altitude.toDouble(),
|
||||
speed = pli.speed.toDouble(),
|
||||
course = pli.course.toDouble(),
|
||||
team = teamName,
|
||||
role = roleName,
|
||||
battery = battery,
|
||||
staleMinutes = DEFAULT_TAK_STALE_MINUTES,
|
||||
)
|
||||
}
|
||||
|
||||
val takPacket =
|
||||
try {
|
||||
TAKPacket.ADAPTER.decode(payload)
|
||||
} catch (e: Exception) {
|
||||
Logger.w(e) { "Failed to decode TAKPacket from mesh" }
|
||||
return
|
||||
val chat = takPacket.chat
|
||||
if (chat != null) {
|
||||
val timeNow = Clock.System.now()
|
||||
// Include chatroom in UID so ATAK routes DMs correctly — the UID format
|
||||
// "GeoChat.<senderUid>.<chatroom>.<msgId>" is what ATAK uses to determine routing.
|
||||
// Hardcoding "All Chat Rooms" here loses DM routing from legacy v1 nodes.
|
||||
val chatroom = chat.to ?: "All Chat Rooms"
|
||||
val msgId = Random.Default.nextInt().toString(TAK_HEX_RADIX)
|
||||
return CoTMessage(
|
||||
uid = "GeoChat.$senderUid.$chatroom.$msgId",
|
||||
type = "b-t-f",
|
||||
how = "h-g-i-g-o",
|
||||
time = timeNow,
|
||||
start = timeNow,
|
||||
stale = timeNow + DEFAULT_TAK_STALE_MINUTES.minutes,
|
||||
latitude = 0.0,
|
||||
longitude = 0.0,
|
||||
contact = CoTContact(callsign = callsign, endpoint = DEFAULT_TAK_ENDPOINT),
|
||||
group = CoTGroup(name = teamName, role = roleName),
|
||||
status = CoTStatus(battery = battery),
|
||||
chat = CoTChat(chatroom = chatroom, senderCallsign = callsign, message = chat.message),
|
||||
)
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
companion object {
|
||||
/**
|
||||
* Minimum stale TTL (5 min) for static CoT types sent over mesh. iTAK uses 2-min stale for routes/shapes; over
|
||||
* LoRa mesh with multi-hop relay, these arrive past stale and ATAK discards them. PLI and GeoChat are left
|
||||
* untouched — their stale is meaningful.
|
||||
*/
|
||||
private val MIN_MESH_STALE_TTL = 15.minutes
|
||||
private val STATIC_COT_PREFIXES = listOf("b-m-r", "u-d-", "b-m-p-")
|
||||
private val EVENT_TYPE_RE = Regex("""<event\s[^>]*\btype="([^"]*)"""")
|
||||
|
||||
// Matches the stale attribute ONLY within the <event> opening tag to avoid
|
||||
// accidentally matching a stale="..." on a <link> or other child element.
|
||||
private val EVENT_TAG_RE = Regex("""<event\b[^>]*>""")
|
||||
private val STALE_ATTR_RE = Regex("""\bstale="([^"]*)"""")
|
||||
|
||||
fun ensureMinimumStaleForMesh(xml: String): String {
|
||||
val type = EVENT_TYPE_RE.find(xml)?.groupValues?.getOrNull(1) ?: return xml
|
||||
if (STATIC_COT_PREFIXES.none { type.startsWith(it) }) return xml
|
||||
// Search for stale only inside the <event> opening tag, not in child elements
|
||||
val eventTagMatch = EVENT_TAG_RE.find(xml) ?: return xml
|
||||
val eventTag = eventTagMatch.value
|
||||
val staleInTag = STALE_ATTR_RE.find(eventTag) ?: return xml
|
||||
val staleStr = staleInTag.groupValues[1]
|
||||
val staleInstant =
|
||||
try {
|
||||
kotlin.time.Instant.parse(staleStr)
|
||||
} catch (_: IllegalArgumentException) {
|
||||
// Handle edge-case formats like missing "Z"
|
||||
try {
|
||||
val cleaned = staleStr.replace(Regex("""\.\d+"""), "").replace("Z", "+00:00")
|
||||
kotlin.time.Instant.parse(cleaned)
|
||||
} catch (_: IllegalArgumentException) {
|
||||
return xml
|
||||
}
|
||||
}
|
||||
|
||||
val now = Clock.System.now()
|
||||
val remaining = staleInstant - now
|
||||
if (remaining >= MIN_MESH_STALE_TTL) return xml
|
||||
|
||||
val newStale = now + MIN_MESH_STALE_TTL
|
||||
val newStaleStr = newStale.toString().replace(Regex("""\.\d+"""), "") // strip fractional seconds
|
||||
Logger.i {
|
||||
"Extended stale for $type: $staleStr → $newStaleStr " +
|
||||
"(was ${remaining.inWholeSeconds}s remaining, now ${MIN_MESH_STALE_TTL.inWholeSeconds}s)"
|
||||
}
|
||||
// Replace the stale value only within the event tag, then splice the patched tag back
|
||||
val newEventTag = eventTag.replaceRange(staleInTag.range, """stale="$newStaleStr"""")
|
||||
return xml.replaceRange(eventTagMatch.range, newEventTag)
|
||||
}
|
||||
|
||||
val cotMessage = takPacket.toCoTMessage() ?: return
|
||||
/**
|
||||
* Strip non-essential XML elements before mesh compression to save wire bytes. These elements add 100-200 bytes
|
||||
* but aren't needed for rendering shapes, routes, chats, markers, PLI, or any other payload on the receiving
|
||||
* end.
|
||||
*/
|
||||
private val STRIP_PATTERNS =
|
||||
listOf(
|
||||
"""<takv[^>]*/>""", // TAK version (self-closing)
|
||||
"""<takv[^>]*>.*?</takv>""", // TAK version (paired)
|
||||
"""<voice[^>]*/>""", // voice chat state
|
||||
"""<voice[^>]*>.*?</voice>""",
|
||||
"""<marti[^>]*/>""", // empty marti
|
||||
"""<marti[^>]*>.*?</marti>""",
|
||||
"""<__geofence[^>]*/>""", // geofence config
|
||||
"""<__geofence[^>]*>.*?</__geofence>""",
|
||||
"""<tog[^>]*/>""", // toggle state
|
||||
"""<archive[^>]*/>""", // archive marker
|
||||
"""<__shapeExtras[^>]*/>""", // shape extras
|
||||
"""<__shapeExtras[^>]*>.*?</__shapeExtras>""",
|
||||
"""<creator[^>]*/>""", // creator info
|
||||
"""<creator[^>]*>.*?</creator>""",
|
||||
"""<remarks[^>]*/>""", // empty remarks (self-closing)
|
||||
"""<remarks[^>]*></remarks>""", // empty remarks (paired)
|
||||
"""<strokeStyle[^>]*/>""", // stroke style (SDK uses color fields)
|
||||
"""<precisionlocation[^>]*/>""", // precision location metadata
|
||||
"""<precisionlocation[^>]*>.*?</precisionlocation>""",
|
||||
"""<precisionLocation[^>]*/>""", // iTAK camelCase variant
|
||||
"""<precisionLocation[^>]*>.*?</precisionLocation>""",
|
||||
)
|
||||
.map { Regex(it, RegexOption.DOT_MATCHES_ALL) }
|
||||
|
||||
takServerManager.broadcast(cotMessage)
|
||||
Logger.d { "Forwarded ATAK mesh packet to TAK clients: ${cotMessage.type}" }
|
||||
// Strip any attribute with value "???" — unknown/placeholder metadata
|
||||
private val UNKNOWN_ATTR_PATTERN = Regex("""\s+\w+\s*=\s*"[?]{3}"""")
|
||||
|
||||
// Strip specific named attributes that the SDK doesn't use (display-only)
|
||||
private val STRIP_ATTR_PATTERNS =
|
||||
listOf(
|
||||
"""\s+routetype\s*=\s*"[^"]*"""", // route display type (SDK doesn't use)
|
||||
"""\s+order\s*=\s*"[^"]*"""", // checkpoint order label (SDK doesn't use)
|
||||
"""\s+color\s*=\s*"[^"]*"""", // link_attr color (SDK uses strokeColor instead)
|
||||
"""\s+access\s*=\s*"[^"]*"""", // access control (not relevant for mesh)
|
||||
"""\s+callsign\s*=\s*""""", // empty callsign attributes (e.g. checkpoints)
|
||||
"""\s+phone\s*=\s*""""", // empty phone attributes
|
||||
)
|
||||
.map { Regex(it) }
|
||||
|
||||
// Route waypoint UID stripping — UIDs are full 36-char UUIDs that cost
|
||||
// ~40 bytes each in the proto wire format. The receiving TAK client derives
|
||||
// its own UIDs, so these are pure overhead. Only targets <link> elements
|
||||
// with a point= attribute (route waypoints / shape vertices).
|
||||
private val ROUTE_LINK_ELEM_RE = Regex("""<link\s[^>]*\bpoint="[^"]*"[^>]*/>""")
|
||||
private val LINK_UID_ATTR_RE = Regex("""\s+uid="[^"]*"""")
|
||||
|
||||
fun stripNonEssentialElements(xml: String): String {
|
||||
var result = xml
|
||||
for (pattern in STRIP_PATTERNS) {
|
||||
result = pattern.replace(result, "")
|
||||
}
|
||||
// Strip ??? attributes from remaining elements
|
||||
result = UNKNOWN_ATTR_PATTERN.replace(result, "")
|
||||
// Strip specific display-only attributes
|
||||
for (pattern in STRIP_ATTR_PATTERNS) {
|
||||
result = pattern.replace(result, "")
|
||||
}
|
||||
// Strip uid from route waypoint <link> elements (receiver derives UIDs)
|
||||
result = ROUTE_LINK_ELEM_RE.replace(result) { LINK_UID_ATTR_RE.replace(it.value, "") }
|
||||
return result
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -43,6 +43,22 @@ data class CoTMessage(
|
||||
val chat: CoTChat? = null,
|
||||
val remarks: String? = null,
|
||||
val rawDetailXml: String? = null,
|
||||
/**
|
||||
* Inner XML content of `<detail>...</detail>` captured by [CoTXmlParser] when this message was parsed from an
|
||||
* incoming ATAK client event. Used as the `raw_detail` fallback payload when converting to
|
||||
* [org.meshtastic.proto.TAKPacketV2] for CoT types that don't fit any structured payload (PLI / GeoChat /
|
||||
* Aircraft). Null for messages constructed in-app.
|
||||
*
|
||||
* Distinct from [rawDetailXml], which is an output-only passthrough used by [toXml] to append extension content
|
||||
* during serialization.
|
||||
*/
|
||||
val parsedDetailXml: String? = null,
|
||||
/**
|
||||
* The entire original `<event>...</event>` XML string as received from the ATAK client, captured by [CoTXmlParser].
|
||||
* Kept solely for diagnostic logging (e.g. when a packet exceeds the mesh MTU and is dropped) so the operator can
|
||||
* see what the client actually sent. Null for messages constructed in-app.
|
||||
*/
|
||||
val sourceEventXml: String? = null,
|
||||
) {
|
||||
companion object {
|
||||
fun pli(
|
||||
@@ -61,7 +77,7 @@ data class CoTMessage(
|
||||
val now = Clock.System.now()
|
||||
return CoTMessage(
|
||||
uid = uid,
|
||||
type = "a-f-G-U-C",
|
||||
type = DEFAULT_PLI_COT_TYPE,
|
||||
time = now,
|
||||
start = now,
|
||||
stale = now + staleMinutes.minutes,
|
||||
@@ -130,7 +146,7 @@ sealed class TAKConnectionEvent {
|
||||
|
||||
data class ClientInfoUpdated(val clientInfo: TAKClientInfo) : TAKConnectionEvent()
|
||||
|
||||
data class Message(val cotMessage: CoTMessage) : TAKConnectionEvent()
|
||||
data class Message(val cotMessage: CoTMessage, val clientInfo: TAKClientInfo? = null) : TAKConnectionEvent()
|
||||
|
||||
data object Disconnected : TAKConnectionEvent()
|
||||
|
||||
|
||||
@@ -31,14 +31,27 @@ import kotlin.random.Random
|
||||
import kotlin.time.Clock
|
||||
import kotlin.time.Duration.Companion.minutes
|
||||
|
||||
/**
|
||||
* Legacy v1 CoT <-> TAKPacket conversion for firmware <= 2.7.x.
|
||||
*
|
||||
* Wire format: bare protobuf-encoded [TAKPacket] on `ATAK_PLUGIN` port 72, no zstd compression (the proto has an
|
||||
* `is_compressed` flag but the firmware doesn't act on it). Supports only PLI and GeoChat payloads — shape, marker,
|
||||
* route, casevac, emergency, and task CoT events return null and are dropped.
|
||||
*
|
||||
* For the SDK-backed path that handles all payload types with zstd dictionary compression on `ATAK_PLUGIN_V2` port 78,
|
||||
* see [TAKPacketV2Conversion].
|
||||
*
|
||||
* [TAKMeshIntegration] picks between the two paths based on `Capabilities.supportsTakV2` (firmware >= 2.8.0).
|
||||
*/
|
||||
object TAKPacketConversion {
|
||||
|
||||
fun CoTMessage.toTAKPacket(): TAKPacket? {
|
||||
val group =
|
||||
this.group?.let {
|
||||
Group(
|
||||
role = MemberRole.fromValue(getMemberRoleValue(it.role)) ?: MemberRole.Unspecifed,
|
||||
team = Team.fromValue(getTeamValue(it.name)) ?: Team.Unspecifed_Color,
|
||||
role =
|
||||
MemberRole.fromValue(TakConversionHelpers.getMemberRoleValue(it.role)) ?: MemberRole.Unspecifed,
|
||||
team = Team.fromValue(TakConversionHelpers.getTeamValue(it.name)) ?: Team.Unspecifed_Color,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -120,7 +133,7 @@ object TAKPacketConversion {
|
||||
val timeNow = Clock.System.now()
|
||||
val staleTime = timeNow + DEFAULT_TAK_STALE_MINUTES.minutes
|
||||
|
||||
val (senderUid, messageId) = parseDeviceCallsign(rawDeviceCallsign)
|
||||
val (senderUid, messageId) = TakConversionHelpers.parseDeviceCallsign(rawDeviceCallsign)
|
||||
|
||||
val localPli = pli
|
||||
if (localPli != null) {
|
||||
@@ -132,8 +145,8 @@ object TAKPacketConversion {
|
||||
altitude = localPli.altitude.toDouble(),
|
||||
speed = localPli.speed.toDouble(),
|
||||
course = localPli.course.toDouble(),
|
||||
team = teamToColorName(group?.team),
|
||||
role = roleToName(group?.role),
|
||||
team = TakConversionHelpers.teamToColorName(group?.team),
|
||||
role = TakConversionHelpers.roleToName(group?.role),
|
||||
battery = status?.battery ?: DEFAULT_TAK_BATTERY,
|
||||
staleMinutes = DEFAULT_TAK_STALE_MINUTES,
|
||||
)
|
||||
@@ -160,7 +173,11 @@ object TAKPacketConversion {
|
||||
latitude = 0.0,
|
||||
longitude = 0.0,
|
||||
contact = CoTContact(callsign = senderCallsign, endpoint = DEFAULT_TAK_ENDPOINT),
|
||||
group = CoTGroup(name = teamToColorName(group?.team), role = roleToName(group?.role)),
|
||||
group =
|
||||
CoTGroup(
|
||||
name = TakConversionHelpers.teamToColorName(group?.team),
|
||||
role = TakConversionHelpers.roleToName(group?.role),
|
||||
),
|
||||
status = CoTStatus(battery = status?.battery ?: DEFAULT_TAK_BATTERY),
|
||||
chat = CoTChat(chatroom = chatroom, senderCallsign = senderCallsign, message = localChat.message),
|
||||
)
|
||||
@@ -168,29 +185,4 @@ object TAKPacketConversion {
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
private fun parseDeviceCallsign(combined: String): Pair<String, String?> {
|
||||
val parts = combined.split("|", limit = 2)
|
||||
return if (parts.size == 2) {
|
||||
Pair(parts[0], parts[1].ifEmpty { null })
|
||||
} else {
|
||||
Pair(combined, null)
|
||||
}
|
||||
}
|
||||
|
||||
private fun getTeamValue(name: String): Int =
|
||||
Team.entries.find { it.name.equals(name, ignoreCase = true) }?.value ?: 0
|
||||
|
||||
private fun getMemberRoleValue(roleName: String): Int =
|
||||
MemberRole.entries.find { it.name.equals(roleName.replace(" ", ""), ignoreCase = true) }?.value ?: 0
|
||||
|
||||
private fun teamToColorName(team: Team?): String {
|
||||
if (team == null || team == Team.Unspecifed_Color) return DEFAULT_TAK_TEAM_NAME
|
||||
return team.toTakTeamName()
|
||||
}
|
||||
|
||||
private fun roleToName(role: MemberRole?): String {
|
||||
if (role == null || role == MemberRole.Unspecifed) return DEFAULT_TAK_ROLE_NAME
|
||||
return role.toTakRoleName()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,270 @@
|
||||
/*
|
||||
* Copyright (c) 2026 Meshtastic LLC
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
@file:Suppress("CyclomaticComplexMethod", "ReturnCount", "LongMethod", "MagicNumber")
|
||||
|
||||
package org.meshtastic.core.takserver
|
||||
|
||||
import co.touchlab.kermit.Logger
|
||||
import okio.ByteString.Companion.toByteString
|
||||
import org.meshtastic.proto.CotHow
|
||||
import org.meshtastic.proto.CotType
|
||||
import org.meshtastic.proto.GeoChat
|
||||
import org.meshtastic.proto.GeoPointSource
|
||||
import org.meshtastic.proto.MemberRole
|
||||
import org.meshtastic.proto.TAKPacketV2
|
||||
import org.meshtastic.proto.Team
|
||||
import kotlin.random.Random
|
||||
import kotlin.time.Clock
|
||||
import kotlin.time.Duration.Companion.minutes
|
||||
import kotlin.time.Duration.Companion.seconds
|
||||
|
||||
/** Conversion between CoTMessage and TAKPacketV2 (v2 wire protocol). */
|
||||
object TAKPacketV2Conversion {
|
||||
|
||||
fun CoTMessage.toTAKPacketV2(): TAKPacketV2? {
|
||||
val cotTypeEnum = TakV2TypeMapper.cotTypeFromString(type)
|
||||
val cotTypeStr = if (cotTypeEnum == CotType.CotType_Other) type else ""
|
||||
val howEnum = TakV2TypeMapper.cotHowFromString(how)
|
||||
|
||||
val teamEnum =
|
||||
group?.let { Team.fromValue(TakConversionHelpers.getTeamValue(it.name)) } ?: Team.Unspecifed_Color
|
||||
|
||||
val roleEnum =
|
||||
group?.let { MemberRole.fromValue(TakConversionHelpers.getMemberRoleValue(it.role)) }
|
||||
?: MemberRole.Unspecifed
|
||||
|
||||
val battery = status?.battery?.coerceAtLeast(0) ?: 0
|
||||
|
||||
// PLI (position reports): match all atom CoT types ("a-*").
|
||||
// The original type is preserved via cot_type_id/cot_type_str so that hostile
|
||||
// (a-h-*), neutral (a-n-*), and unknown (a-u-*) markers round-trip correctly.
|
||||
// toCoTMessage() restores the exact type from those fields instead of defaulting
|
||||
// to DEFAULT_PLI_COT_TYPE, so all atom markers are treated as PLI on the wire.
|
||||
if (type.startsWith("a-")) {
|
||||
val callsign = contact?.callsign ?: "UNKNOWN"
|
||||
val deviceCallsign = uid
|
||||
|
||||
return TAKPacketV2(
|
||||
cot_type_id = cotTypeEnum,
|
||||
cot_type_str = cotTypeStr,
|
||||
how = howEnum,
|
||||
callsign = callsign,
|
||||
device_callsign = deviceCallsign,
|
||||
uid = uid,
|
||||
team = teamEnum,
|
||||
role = roleEnum,
|
||||
latitude_i = (latitude * TAK_COORDINATE_SCALE).toInt(),
|
||||
longitude_i = (longitude * TAK_COORDINATE_SCALE).toInt(),
|
||||
altitude = if (hae >= TAK_UNKNOWN_POINT_VALUE || hae.isNaN()) 0 else hae.toInt(),
|
||||
// V2 encodes speed as cm/s (m/s × 100) and course as deg×100.
|
||||
// V1 (legacy TAKPacket, port 72) uses raw integers with no scaling.
|
||||
// These two paths are ALWAYS separate (different portnums) and must
|
||||
// never cross-feed: a V1 packet decoded in TAKMeshIntegration goes
|
||||
// through convertV1ToCoT() → CoTMessage.pli() → toXml(), NOT through
|
||||
// toTAKPacketV2(). If this invariant is ever broken, speed/course
|
||||
// would be silently off by ×100.
|
||||
speed = (track?.speed?.coerceAtLeast(0.0)?.times(100))?.toInt() ?: 0, // m/s -> cm/s
|
||||
course = (track?.course?.coerceAtLeast(0.0)?.times(100))?.toInt() ?: 0, // deg -> deg*100
|
||||
battery = battery,
|
||||
geo_src = GeoPointSource.GeoPointSource_GPS,
|
||||
alt_src = GeoPointSource.GeoPointSource_GPS,
|
||||
pli = true,
|
||||
)
|
||||
}
|
||||
|
||||
// GeoChat
|
||||
if (type == "b-t-f") {
|
||||
val localChat = chat ?: return null
|
||||
// ATAK GeoChat events often omit <contact callsign="..."/> — the
|
||||
// sender identity is only in <__chat senderCallsign="..."/>.
|
||||
val callsign = contact?.callsign ?: localChat.senderCallsign ?: "UNKNOWN"
|
||||
val actualDeviceUid = uid.geoChatSenderUid()
|
||||
val messageId =
|
||||
if (uid.startsWith("GeoChat.")) {
|
||||
uid.geoChatMessageId()
|
||||
} else {
|
||||
Random.nextInt().toString(TAK_HEX_RADIX)
|
||||
}
|
||||
|
||||
val smuggledCallsign =
|
||||
if (actualDeviceUid.isNotEmpty()) {
|
||||
"$actualDeviceUid|$messageId"
|
||||
} else {
|
||||
contact?.endpoint ?: ""
|
||||
}
|
||||
|
||||
var toUid: String? = null
|
||||
var toCallsign: String? = null
|
||||
if (localChat.chatroom != "All Chat Rooms") {
|
||||
if (localChat.chatroom.startsWith(uid) || uid.startsWith("GeoChat")) {
|
||||
val parts = uid.split(".")
|
||||
if (parts.size >= TAK_DIRECT_MESSAGE_PARTS_MIN && parts[0] == "GeoChat") {
|
||||
toUid = localChat.chatroom
|
||||
}
|
||||
} else {
|
||||
toCallsign = localChat.chatroom
|
||||
}
|
||||
}
|
||||
|
||||
return TAKPacketV2(
|
||||
cot_type_id = CotType.CotType_b_t_f,
|
||||
how = CotHow.CotHow_h_g_i_g_o,
|
||||
callsign = callsign,
|
||||
device_callsign = smuggledCallsign,
|
||||
uid = uid,
|
||||
team = teamEnum,
|
||||
role = roleEnum,
|
||||
battery = battery,
|
||||
chat =
|
||||
GeoChat(
|
||||
message = localChat.message,
|
||||
to = toUid ?: if (toCallsign == null) "All Chat Rooms" else null,
|
||||
to_callsign = toCallsign,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
// Fallback: wrap the whole detail XML in raw_detail for unmapped types
|
||||
// (user-drawn shapes like u-d-c-c, markers like b-m-*, alerts, etc.)
|
||||
val detailBytes = parsedDetailXml?.encodeToByteArray()
|
||||
if (detailBytes != null) {
|
||||
val callsign = contact?.callsign ?: "UNKNOWN"
|
||||
return TAKPacketV2(
|
||||
cot_type_id = cotTypeEnum,
|
||||
cot_type_str = cotTypeStr,
|
||||
how = howEnum,
|
||||
callsign = callsign,
|
||||
device_callsign = uid,
|
||||
uid = uid,
|
||||
team = teamEnum,
|
||||
role = roleEnum,
|
||||
latitude_i = (latitude * TAK_COORDINATE_SCALE).toInt(),
|
||||
longitude_i = (longitude * TAK_COORDINATE_SCALE).toInt(),
|
||||
altitude = if (hae >= TAK_UNKNOWN_POINT_VALUE || hae.isNaN()) 0 else hae.toInt(),
|
||||
battery = battery,
|
||||
raw_detail = detailBytes.toByteString(),
|
||||
)
|
||||
}
|
||||
|
||||
Logger.w { "Cannot convert CoT to TAKPacketV2 for type $type (no parsed detail)" }
|
||||
return null
|
||||
}
|
||||
|
||||
fun TAKPacketV2.toCoTMessage(): CoTMessage? {
|
||||
val senderCallsign = callsign.ifEmpty { "UNKNOWN" }
|
||||
val rawDeviceCallsign = device_callsign.ifEmpty { uid.ifEmpty { "UNKNOWN" } }
|
||||
val timeNow = Clock.System.now()
|
||||
val (senderUid, messageId) = TakConversionHelpers.parseDeviceCallsign(rawDeviceCallsign)
|
||||
|
||||
// PLI
|
||||
if (pli != null) {
|
||||
val staleMinutes = if (stale_seconds > 0) (stale_seconds / 60) else DEFAULT_TAK_STALE_MINUTES
|
||||
// Restore the original CoT type and how from the packet — pli() defaults to
|
||||
// DEFAULT_PLI_COT_TYPE/"m-g" but the sending node may have been hostile (a-h-*),
|
||||
// neutral (a-n-*), unknown (a-u-*), etc.
|
||||
val resolvedType =
|
||||
cot_type_str.ifEmpty { TakV2TypeMapper.cotTypeToString(cot_type_id) ?: DEFAULT_PLI_COT_TYPE }
|
||||
val resolvedHow = TakV2TypeMapper.cotHowToString(how) ?: "m-g"
|
||||
return CoTMessage.pli(
|
||||
uid = senderUid.ifEmpty { uid },
|
||||
callsign = senderCallsign,
|
||||
latitude = latitude_i.toDouble() / TAK_COORDINATE_SCALE,
|
||||
longitude = longitude_i.toDouble() / TAK_COORDINATE_SCALE,
|
||||
altitude = altitude.toDouble(),
|
||||
speed = speed.toDouble() / 100.0, // cm/s -> m/s
|
||||
course = course.toDouble() / 100.0, // deg*100 -> deg
|
||||
team = TakConversionHelpers.teamToColorName(team),
|
||||
role = TakConversionHelpers.roleToName(role),
|
||||
battery = battery,
|
||||
staleMinutes = staleMinutes,
|
||||
)
|
||||
.copy(type = resolvedType, how = resolvedHow)
|
||||
}
|
||||
|
||||
// GeoChat
|
||||
val localChat = chat
|
||||
if (localChat != null) {
|
||||
// chat.to carries the recipient/room ID for DMs; null means broadcast.
|
||||
// Do NOT fall through to chat.to_callsign here — despite the name,
|
||||
// it holds the SENDER's callsign (the parser stores __chat[@senderCallsign]
|
||||
// there), not a chatroom name.
|
||||
val chatroom = localChat.to ?: "All Chat Rooms"
|
||||
|
||||
val msgId = messageId ?: Random.nextInt().toString(TAK_HEX_RADIX)
|
||||
val staleTime =
|
||||
timeNow +
|
||||
if (stale_seconds > 0) {
|
||||
stale_seconds.seconds
|
||||
} else {
|
||||
DEFAULT_TAK_STALE_MINUTES.minutes
|
||||
}
|
||||
|
||||
return CoTMessage(
|
||||
uid = "GeoChat.$senderUid.$chatroom.$msgId",
|
||||
type = "b-t-f",
|
||||
how = "h-g-i-g-o",
|
||||
time = timeNow,
|
||||
start = timeNow,
|
||||
stale = staleTime,
|
||||
latitude = latitude_i.toDouble() / TAK_COORDINATE_SCALE,
|
||||
longitude = longitude_i.toDouble() / TAK_COORDINATE_SCALE,
|
||||
contact = CoTContact(callsign = senderCallsign, endpoint = DEFAULT_TAK_ENDPOINT),
|
||||
group =
|
||||
CoTGroup(
|
||||
name = TakConversionHelpers.teamToColorName(team),
|
||||
role = TakConversionHelpers.roleToName(role),
|
||||
),
|
||||
status = CoTStatus(battery = battery),
|
||||
chat = CoTChat(chatroom = chatroom, senderCallsign = senderCallsign, message = localChat.message),
|
||||
)
|
||||
}
|
||||
|
||||
// Raw detail: unmapped CoT types round-tripped as opaque detail bytes.
|
||||
// Emit a bare CoTMessage whose <detail> is the raw bytes verbatim. Do NOT populate
|
||||
// contact/group/status here — those would be double-emitted by toXml() alongside
|
||||
// rawDetailXml, corrupting the CoT stream.
|
||||
val rawDetail = raw_detail
|
||||
if (rawDetail != null) {
|
||||
val rawXml = rawDetail.utf8()
|
||||
val resolvedType =
|
||||
cot_type_str.ifEmpty { TakV2TypeMapper.cotTypeToString(cot_type_id) ?: DEFAULT_PLI_COT_TYPE }
|
||||
val resolvedHow = TakV2TypeMapper.cotHowToString(how) ?: "m-g"
|
||||
val staleTime =
|
||||
timeNow +
|
||||
if (stale_seconds > 0) {
|
||||
stale_seconds.seconds
|
||||
} else {
|
||||
DEFAULT_TAK_STALE_MINUTES.minutes
|
||||
}
|
||||
return CoTMessage(
|
||||
uid = uid.ifEmpty { senderUid.ifEmpty { "tak-raw" } },
|
||||
type = resolvedType,
|
||||
how = resolvedHow,
|
||||
time = timeNow,
|
||||
start = timeNow,
|
||||
stale = staleTime,
|
||||
latitude = latitude_i.toDouble() / TAK_COORDINATE_SCALE,
|
||||
longitude = longitude_i.toDouble() / TAK_COORDINATE_SCALE,
|
||||
hae = if (altitude == 0) TAK_UNKNOWN_POINT_VALUE else altitude.toDouble(),
|
||||
rawDetailXml = rawXml,
|
||||
)
|
||||
}
|
||||
|
||||
Logger.w { "Cannot convert TAKPacketV2 to CoTMessage: no PLI, chat, or raw_detail payload" }
|
||||
return null
|
||||
}
|
||||
}
|
||||
@@ -14,198 +14,55 @@
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
@file:Suppress("TooGenericExceptionCaught")
|
||||
|
||||
package org.meshtastic.core.takserver
|
||||
|
||||
import co.touchlab.kermit.Logger
|
||||
import io.ktor.network.selector.SelectorManager
|
||||
import io.ktor.network.sockets.ServerSocket
|
||||
import io.ktor.network.sockets.Socket
|
||||
import io.ktor.network.sockets.SocketAddress
|
||||
import io.ktor.network.sockets.aSocket
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import org.meshtastic.core.di.CoroutineDispatchers
|
||||
import kotlin.random.Random
|
||||
import kotlinx.coroutines.isActive as coroutineIsActive
|
||||
|
||||
class TAKServer(private val dispatchers: CoroutineDispatchers, private val port: Int = DEFAULT_TAK_PORT) {
|
||||
private var serverSocket: ServerSocket? = null
|
||||
private var selectorManager: SelectorManager? = null
|
||||
private var running = false
|
||||
private var serverScope: CoroutineScope? = null
|
||||
private var acceptJob: Job? = null
|
||||
private val connectionsMutex = Mutex()
|
||||
/**
|
||||
* Platform-agnostic contract for the Meshtastic TAK server.
|
||||
*
|
||||
* The production implementation on Android / JVM runs a TLS (mTLS) listener on port [DEFAULT_TAK_PORT] (8089) using the
|
||||
* bundled server identity. This matches the Meshtastic-Apple (iOS) implementation so that a single exported `.zip` data
|
||||
* package is valid for ATAK on Android AND iTAK on iOS without re-configuration.
|
||||
*
|
||||
* The interface deliberately hides the platform socket / TLS primitives so that `commonMain` code
|
||||
* (`TAKServerManagerImpl`, DI, tests) can depend on it without pulling `javax.net.ssl.*` into the common source set.
|
||||
*/
|
||||
interface TAKServer {
|
||||
|
||||
private val connections = mutableMapOf<String, TAKClientConnection>()
|
||||
/** Observable count of currently-connected TAK clients (ATAK/iTAK). */
|
||||
val connectionCount: StateFlow<Int>
|
||||
|
||||
private val _connectionCount = MutableStateFlow(0)
|
||||
val connectionCount: StateFlow<Int> = _connectionCount.asStateFlow()
|
||||
/** Callback invoked on the IO dispatcher for every inbound CoT message from a client. */
|
||||
var onMessage: ((CoTMessage, TAKClientInfo?) -> Unit)?
|
||||
|
||||
var onMessage: ((CoTMessage) -> Unit)? = null
|
||||
/** Callback invoked when a TAK client connects. Use to drain queued messages. */
|
||||
var onClientConnected: (() -> Unit)?
|
||||
|
||||
suspend fun start(scope: CoroutineScope): Result<Unit> {
|
||||
// Double-start guard: prevents SelectorManager / ServerSocket leaks
|
||||
if (running) {
|
||||
Logger.w { "TAK Server already running on port $port" }
|
||||
return Result.success(Unit)
|
||||
}
|
||||
/** Bind the listener and begin accepting connections. Idempotent if already running. */
|
||||
suspend fun start(scope: CoroutineScope): Result<Unit>
|
||||
|
||||
return try {
|
||||
serverScope = scope
|
||||
// Close any stale SelectorManager before creating a new one
|
||||
selectorManager?.close()
|
||||
selectorManager = SelectorManager(dispatchers.default)
|
||||
serverSocket = aSocket(selectorManager!!).tcp().bind(hostname = "127.0.0.1", port = port)
|
||||
/** Stop the listener, close all client sockets, and release OS resources. */
|
||||
fun stop()
|
||||
|
||||
running = true
|
||||
acceptJob = scope.launch(dispatchers.io) { acceptLoop() }
|
||||
Result.success(Unit)
|
||||
} catch (e: Exception) {
|
||||
Logger.e(e) { "Failed to bind TAK Server to 127.0.0.1:$port" }
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
/** Broadcast a CoT message to every currently-connected client. */
|
||||
suspend fun broadcast(cotMessage: CoTMessage)
|
||||
|
||||
private suspend fun acceptLoop() {
|
||||
val scope = serverScope ?: return
|
||||
while (running && scope.coroutineIsActive) {
|
||||
try {
|
||||
val clientSocket = serverSocket?.accept()
|
||||
if (clientSocket != null) {
|
||||
handleConnection(clientSocket)
|
||||
}
|
||||
// No delay on the success path — accept() is already suspending
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
Logger.w(e) { "TAK server accept loop iteration failed" }
|
||||
// Back-off only in the error path
|
||||
delay(TAK_ACCEPT_LOOP_DELAY_MS)
|
||||
}
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Broadcast raw CoT XML to every currently-connected client. Used for mesh-originated messages that should be
|
||||
* forwarded verbatim without re-parsing through the app's CoTXmlParser (which strips shape detail elements like
|
||||
* strokeColor, fillColor, vertices, etc.).
|
||||
*/
|
||||
suspend fun broadcastRawXml(xml: String)
|
||||
|
||||
private fun handleConnection(clientSocket: Socket) {
|
||||
val scope = serverScope ?: return
|
||||
val endpoint = clientSocket.remoteAddress.toString()
|
||||
|
||||
if (!clientSocket.remoteAddress.isLoopback()) {
|
||||
Logger.w { "TAK server rejected non-loopback connection from $endpoint" }
|
||||
clientSocket.close()
|
||||
return
|
||||
}
|
||||
|
||||
val connectionId = Random.nextInt().toString(TAK_HEX_RADIX)
|
||||
val clientInfo = TAKClientInfo(id = connectionId, endpoint = endpoint)
|
||||
|
||||
val connection =
|
||||
TAKClientConnection(
|
||||
socket = clientSocket,
|
||||
clientInfo = clientInfo,
|
||||
onEvent = { event -> handleConnectionEvent(connectionId, event) },
|
||||
scope = scope,
|
||||
)
|
||||
|
||||
scope.launch {
|
||||
connectionsMutex.withLock {
|
||||
connections[connectionId] = connection
|
||||
_connectionCount.value = connections.size
|
||||
}
|
||||
connection.start()
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleConnectionEvent(connectionId: String, event: TAKConnectionEvent) {
|
||||
when (event) {
|
||||
is TAKConnectionEvent.Message -> {
|
||||
onMessage?.invoke(event.cotMessage)
|
||||
}
|
||||
|
||||
is TAKConnectionEvent.Disconnected -> {
|
||||
serverScope?.launch {
|
||||
connectionsMutex.withLock {
|
||||
connections.remove(connectionId)
|
||||
_connectionCount.value = connections.size
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
is TAKConnectionEvent.Error -> {
|
||||
Logger.w(event.error) { "TAK client connection error: $connectionId" }
|
||||
serverScope?.launch {
|
||||
connectionsMutex.withLock {
|
||||
connections.remove(connectionId)
|
||||
_connectionCount.value = connections.size
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
is TAKConnectionEvent.Connected -> {
|
||||
/* no-op: logged by TAKClientConnection.start() */
|
||||
}
|
||||
|
||||
is TAKConnectionEvent.ClientInfoUpdated -> {
|
||||
/* no-op: TAKClientConnection tracks updated info locally */
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun stop() {
|
||||
running = false
|
||||
acceptJob?.cancel()
|
||||
acceptJob = null
|
||||
|
||||
// Close connections synchronously — TAKClientConnection.close() is non-suspending,
|
||||
// so we don't need to launch into the (possibly-cancelled) serverScope.
|
||||
val toClose: List<TAKClientConnection>
|
||||
// We can't use Mutex.withLock here (non-suspending context) so we swap & clear under a
|
||||
// best-effort copy — worst case a connection added concurrently is closed by socket teardown.
|
||||
toClose = connections.values.toList()
|
||||
connections.clear()
|
||||
_connectionCount.value = 0
|
||||
toClose.forEach { it.close() }
|
||||
|
||||
serverSocket?.close()
|
||||
serverSocket = null
|
||||
|
||||
selectorManager?.close()
|
||||
selectorManager = null
|
||||
serverScope = null
|
||||
}
|
||||
|
||||
suspend fun broadcast(cotMessage: CoTMessage) {
|
||||
val currentConnections = connectionsMutex.withLock { connections.values.toList() }
|
||||
currentConnections.forEach { connection ->
|
||||
try {
|
||||
connection.send(cotMessage)
|
||||
} catch (e: Exception) {
|
||||
Logger.w(e) { "Failed to broadcast CoT to TAK client ${connection.clientInfo.id}" }
|
||||
connection.close()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun hasConnections(): Boolean = connectionsMutex.withLock { connections.isNotEmpty() }
|
||||
/** Returns true if at least one TAK client is currently connected. */
|
||||
suspend fun hasConnections(): Boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if this [SocketAddress] represents a loopback address (IPv4 127.x.x.x or IPv6 ::1).
|
||||
*
|
||||
* Ktor's [SocketAddress.toString] returns strings like "/127.0.0.1:4242" (JVM) or "127.0.0.1:4242" on other platforms,
|
||||
* so we strip any leading slash and check prefixes without parsing the host. This keeps the check in commonMain without
|
||||
* an expect/actual.
|
||||
* Platform factory for [TAKServer]. The JVM/Android implementation lives in `jvmAndroidMain` and uses JSSE
|
||||
* (`SSLServerSocket`) with the bundled `server.p12` identity and `ca.pem` client trust store.
|
||||
*/
|
||||
private fun SocketAddress.isLoopback(): Boolean {
|
||||
val addr = toString().removePrefix("/")
|
||||
return addr.startsWith("127.") || addr.startsWith("::1") || addr.startsWith("[::1]")
|
||||
}
|
||||
expect fun createTAKServer(dispatchers: CoroutineDispatchers, port: Int = DEFAULT_TAK_PORT): TAKServer
|
||||
|
||||
@@ -18,39 +18,40 @@ package org.meshtastic.core.takserver
|
||||
|
||||
import co.touchlab.kermit.Logger
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.channels.Channel
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharedFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asFlow
|
||||
import kotlinx.coroutines.flow.asSharedFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.consumeAsFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import org.meshtastic.core.model.Node
|
||||
import kotlin.time.Clock
|
||||
import kotlin.time.Duration.Companion.minutes
|
||||
|
||||
/** A CoT message received from a connected TAK client, paired with the client's identity. */
|
||||
data class InboundCoTMessage(val cotMessage: CoTMessage, val clientInfo: TAKClientInfo? = null)
|
||||
|
||||
interface TAKServerManager {
|
||||
val isRunning: StateFlow<Boolean>
|
||||
val connectionCount: StateFlow<Int>
|
||||
val inboundMessages: Flow<CoTMessage>
|
||||
val inboundMessages: SharedFlow<InboundCoTMessage>
|
||||
|
||||
/** Start the TAK server using [scope]. Port is fixed at [TAKServer] construction time. */
|
||||
fun start(scope: CoroutineScope)
|
||||
|
||||
fun stop()
|
||||
|
||||
fun broadcastNode(node: Node, team: String = DEFAULT_TAK_TEAM_NAME, role: String = DEFAULT_TAK_ROLE_NAME)
|
||||
|
||||
fun broadcast(cotMessage: CoTMessage)
|
||||
|
||||
/** Broadcast raw XML verbatim to TAK clients, bypassing CoTMessage parsing. */
|
||||
fun broadcastRawXml(xml: String)
|
||||
}
|
||||
|
||||
class TAKServerManagerImpl(private val takServer: TAKServer) : TAKServerManager {
|
||||
internal class TAKServerManagerImpl(private val takServer: TAKServer) : TAKServerManager {
|
||||
|
||||
private var scope: CoroutineScope? = null
|
||||
private val lastBroadcastPositionsMutex = Mutex()
|
||||
|
||||
private val _isRunning = MutableStateFlow(false)
|
||||
override val isRunning: StateFlow<Boolean> = _isRunning.asStateFlow()
|
||||
@@ -58,30 +59,42 @@ class TAKServerManagerImpl(private val takServer: TAKServer) : TAKServerManager
|
||||
// Mirror TAKServer's event-driven connection count — no polling needed
|
||||
override val connectionCount: StateFlow<Int> = takServer.connectionCount
|
||||
|
||||
private val _inboundMessages = MutableSharedFlow<CoTMessage>()
|
||||
override val inboundMessages: Flow<CoTMessage> = _inboundMessages.asFlow()
|
||||
private val _inboundMessages = MutableSharedFlow<InboundCoTMessage>(extraBufferCapacity = 64)
|
||||
override val inboundMessages: SharedFlow<InboundCoTMessage> = _inboundMessages.asSharedFlow()
|
||||
|
||||
// Unbounded channel preserves FIFO ordering of inbound CoT messages under load.
|
||||
// onMessage is a non-suspend callback, so we trySend (always succeeds for UNLIMITED)
|
||||
// and a single consumer coroutine drains into _inboundMessages in order.
|
||||
private var inboundChannel: Channel<CoTMessage>? = null
|
||||
private var inboundDrainJob: Job? = null
|
||||
// Offline message queue — buffers mesh-originated CoT messages when no TAK
|
||||
// clients are connected, then drains them when a client reconnects. Entries
|
||||
// expire after OFFLINE_QUEUE_TTL to avoid delivering stale situational data.
|
||||
private data class QueuedMessage(val cotMessage: CoTMessage, val enqueuedAt: kotlin.time.Instant)
|
||||
|
||||
private var lastBroadcastPositions = mutableMapOf<Int, Int>()
|
||||
private val offlineQueue = ArrayDeque<QueuedMessage>()
|
||||
private val offlineQueueMutex = Mutex()
|
||||
|
||||
companion object {
|
||||
private val OFFLINE_QUEUE_TTL = 5.minutes
|
||||
private const val OFFLINE_QUEUE_MAX_SIZE = 50
|
||||
}
|
||||
|
||||
override fun start(scope: CoroutineScope) {
|
||||
this.scope = scope
|
||||
if (_isRunning.value) {
|
||||
Logger.w { "TAKServerManager already running" }
|
||||
return
|
||||
}
|
||||
// Assign scope AFTER the guard so a second concurrent start() can never
|
||||
// overwrite the active scope without actually restarting the server.
|
||||
this.scope = scope
|
||||
|
||||
scope.launch {
|
||||
// Wire up inbound message handler BEFORE starting so no messages are lost.
|
||||
val channel = Channel<CoTMessage>(Channel.UNLIMITED)
|
||||
inboundChannel = channel
|
||||
inboundDrainJob = scope.launch { channel.consumeAsFlow().collect { _inboundMessages.emit(it) } }
|
||||
takServer.onMessage = { cotMessage -> channel.trySend(cotMessage) }
|
||||
// Use tryEmit (non-suspending) with extraBufferCapacity to avoid launching a
|
||||
// new coroutine per message, which would create unbounded coroutines under
|
||||
// high message rates and could reorder messages.
|
||||
takServer.onMessage = { cotMessage, clientInfo ->
|
||||
if (!_inboundMessages.tryEmit(InboundCoTMessage(cotMessage, clientInfo))) {
|
||||
Logger.w { "TAK inbound message buffer full; dropping message from ${clientInfo?.id}" }
|
||||
}
|
||||
}
|
||||
takServer.onClientConnected = { drainOfflineQueue() }
|
||||
|
||||
val result = takServer.start(scope)
|
||||
if (result.isSuccess) {
|
||||
@@ -91,81 +104,68 @@ class TAKServerManagerImpl(private val takServer: TAKServer) : TAKServerManager
|
||||
Logger.e(result.exceptionOrNull()) { "Failed to start TAK Server" }
|
||||
// Clear onMessage if start failed so we don't hold a reference unnecessarily
|
||||
takServer.onMessage = null
|
||||
inboundDrainJob?.cancel()
|
||||
inboundDrainJob = null
|
||||
channel.close()
|
||||
inboundChannel = null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun stop() {
|
||||
takServer.stop()
|
||||
takServer.onMessage = null
|
||||
inboundChannel?.close()
|
||||
inboundChannel = null
|
||||
inboundDrainJob?.cancel()
|
||||
inboundDrainJob = null
|
||||
// Flip the running flag and null out the scope BEFORE stopping the server so
|
||||
// any broadcast()/drainOfflineQueue() that races stop() sees _isRunning=false
|
||||
// and exits early instead of launching coroutines on a scope that is about to
|
||||
// be discarded.
|
||||
_isRunning.value = false
|
||||
scope = null
|
||||
takServer.onMessage = null
|
||||
takServer.stop()
|
||||
Logger.i { "TAK Server stopped" }
|
||||
}
|
||||
|
||||
override fun broadcastNode(node: Node, team: String, role: String) {
|
||||
if (!_isRunning.value) return
|
||||
val currentScope = scope ?: return
|
||||
|
||||
currentScope.launch {
|
||||
if (!takServer.hasConnections()) return@launch
|
||||
|
||||
val position = node.validPosition
|
||||
if (position == null) {
|
||||
broadcastNodeInfoOnly(node, team, role)
|
||||
return@launch
|
||||
}
|
||||
|
||||
val shouldBroadcast =
|
||||
lastBroadcastPositionsMutex.withLock {
|
||||
val last = lastBroadcastPositions[node.num]
|
||||
if (position.time == last) {
|
||||
false
|
||||
} else {
|
||||
lastBroadcastPositions[node.num] = position.time
|
||||
true
|
||||
}
|
||||
}
|
||||
if (!shouldBroadcast) return@launch
|
||||
|
||||
val cotMessage =
|
||||
position.toCoTMessage(
|
||||
uid = node.user.id,
|
||||
callsign = node.user.toTakCallsign(),
|
||||
team = team,
|
||||
role = role,
|
||||
battery = node.deviceMetrics.battery_level ?: 100,
|
||||
)
|
||||
|
||||
takServer.broadcast(cotMessage)
|
||||
}
|
||||
}
|
||||
|
||||
private fun broadcastNodeInfoOnly(node: Node, team: String, role: String) {
|
||||
val currentScope = scope ?: return
|
||||
val cotMessage =
|
||||
node.user.toCoTMessage(
|
||||
position = null,
|
||||
team = team,
|
||||
role = role,
|
||||
battery = node.deviceMetrics.battery_level ?: 100,
|
||||
)
|
||||
|
||||
currentScope.launch {
|
||||
if (!takServer.hasConnections()) return@launch
|
||||
takServer.broadcast(cotMessage)
|
||||
}
|
||||
}
|
||||
|
||||
override fun broadcast(cotMessage: CoTMessage) {
|
||||
scope?.launch { takServer.broadcast(cotMessage) }
|
||||
if (!_isRunning.value) return
|
||||
scope?.launch {
|
||||
if (takServer.hasConnections()) {
|
||||
takServer.broadcast(cotMessage)
|
||||
} else {
|
||||
// No TAK clients connected — queue for delivery when one reconnects
|
||||
offlineQueueMutex.withLock {
|
||||
// Evict expired entries
|
||||
val cutoff = Clock.System.now() - OFFLINE_QUEUE_TTL
|
||||
while (offlineQueue.isNotEmpty() && offlineQueue.first().enqueuedAt < cutoff) {
|
||||
offlineQueue.removeFirst()
|
||||
}
|
||||
// Cap size to prevent unbounded growth
|
||||
if (offlineQueue.size >= OFFLINE_QUEUE_MAX_SIZE) {
|
||||
offlineQueue.removeFirst()
|
||||
}
|
||||
offlineQueue.addLast(QueuedMessage(cotMessage, Clock.System.now()))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun broadcastRawXml(xml: String) {
|
||||
if (!_isRunning.value) return
|
||||
scope?.launch { takServer.broadcastRawXml(xml) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Drain any queued messages to the newly connected TAK client. Called by the server when a TAK client connects
|
||||
* (Connected event).
|
||||
*/
|
||||
internal fun drainOfflineQueue() {
|
||||
if (!_isRunning.value) return
|
||||
scope?.launch {
|
||||
val messages =
|
||||
offlineQueueMutex.withLock {
|
||||
val cutoff = Clock.System.now() - OFFLINE_QUEUE_TTL
|
||||
val valid = offlineQueue.filter { it.enqueuedAt >= cutoff }.map { it.cotMessage }
|
||||
offlineQueue.clear()
|
||||
valid
|
||||
}
|
||||
if (messages.isNotEmpty()) {
|
||||
Logger.i { "Draining ${messages.size} queued message(s) to reconnected TAK client" }
|
||||
messages.forEach { takServer.broadcast(it) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
/*
|
||||
* Copyright (c) 2026 Meshtastic LLC
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.meshtastic.core.takserver
|
||||
|
||||
import org.meshtastic.proto.MemberRole
|
||||
import org.meshtastic.proto.Team
|
||||
|
||||
/**
|
||||
* Internal helpers shared by [TAKPacketConversion] (legacy v1, firmware <= 2.7.x) and [TAKPacketV2Conversion]
|
||||
* (firmware >= 2.8.x). Both paths map between the SDK's [CoTMessage] model and Meshtastic's Wire-generated proto types
|
||||
* using identical logic for color/role lookup and the "<senderUid>|<messageId>" smuggled-callsign format that survives
|
||||
* the wire round trip.
|
||||
*/
|
||||
internal object TakConversionHelpers {
|
||||
|
||||
/** Split a `<senderUid>|<messageId>` smuggled callsign back into its parts. */
|
||||
fun parseDeviceCallsign(combined: String): Pair<String, String?> {
|
||||
val parts = combined.split("|", limit = 2)
|
||||
return if (parts.size == 2) {
|
||||
Pair(parts[0], parts[1].ifEmpty { null })
|
||||
} else {
|
||||
Pair(combined, null)
|
||||
}
|
||||
}
|
||||
|
||||
/** Map a [Team] proto enum back to its CoT color-name string. Unspecified -> default. */
|
||||
fun teamToColorName(team: Team?): String {
|
||||
if (team == null || team == Team.Unspecifed_Color) return DEFAULT_TAK_TEAM_NAME
|
||||
return team.toTakTeamName()
|
||||
}
|
||||
|
||||
/** Map a [MemberRole] proto enum back to its CoT role-name string. Unspecified -> default. */
|
||||
fun roleToName(role: MemberRole?): String {
|
||||
if (role == null || role == MemberRole.Unspecifed) return DEFAULT_TAK_ROLE_NAME
|
||||
return role.toTakRoleName()
|
||||
}
|
||||
|
||||
/** Reverse lookup from CoT color-name string to [Team] proto enum value (0 = Unspecified). */
|
||||
fun getTeamValue(name: String): Int = Team.entries.find { it.name.equals(name, ignoreCase = true) }?.value ?: 0
|
||||
|
||||
/** Reverse lookup from CoT role-name string to [MemberRole] proto enum value (0 = Unspecified). */
|
||||
fun getMemberRoleValue(roleName: String): Int =
|
||||
MemberRole.entries.find { it.name.equals(roleName.replace(" ", ""), ignoreCase = true) }?.value ?: 0
|
||||
}
|
||||
@@ -14,18 +14,12 @@
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.meshtastic.core.takserver.fountain
|
||||
|
||||
import org.meshtastic.core.takserver.CoTMessage
|
||||
package org.meshtastic.core.takserver
|
||||
|
||||
/**
|
||||
* Handles incoming and outgoing generic Cursor on Target (CoT) messages wrapped in Meshtastic DataPackets.
|
||||
* Platform-specific loader for TAK test fixture XML files bundled in test resources.
|
||||
*
|
||||
* Defines the contract for routing Direct (unfragmented) vs Fountain-encoded packets, and processing decompressed
|
||||
* EXI/Zlib XML payloads.
|
||||
* On JVM/Android the resources are loaded via the classloader. On iOS the test runner is not supported and this throws
|
||||
* [UnsupportedOperationException].
|
||||
*/
|
||||
interface CoTHandler {
|
||||
suspend fun sendGenericCoT(cotMessage: CoTMessage)
|
||||
|
||||
suspend fun handleIncomingForwarderPacket(payload: ByteArray, senderNodeNum: Int)
|
||||
}
|
||||
internal expect fun loadTakFixtureXml(name: String): String
|
||||
@@ -0,0 +1,193 @@
|
||||
/*
|
||||
* Copyright (c) 2026 Meshtastic LLC
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
@file:Suppress("TooGenericExceptionCaught", "ReturnCount")
|
||||
|
||||
package org.meshtastic.core.takserver
|
||||
|
||||
import co.touchlab.kermit.Logger
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import okio.ByteString.Companion.toByteString
|
||||
import org.meshtastic.core.model.DataPacket
|
||||
import org.meshtastic.core.repository.CommandSender
|
||||
import org.meshtastic.proto.PortNum
|
||||
|
||||
/** Result of sending a single test fixture through the TAK mesh pipeline. */
|
||||
data class TakTestResult(
|
||||
val fixtureName: String,
|
||||
val xmlBytes: Int,
|
||||
val compressedBytes: Int,
|
||||
val passed: Boolean,
|
||||
val error: String? = null,
|
||||
)
|
||||
|
||||
/**
|
||||
* Debug-only test runner that sends the SDK's CoT XML test fixtures through the real TAK mesh pipeline: strip → parse →
|
||||
* compress → send to mesh radio.
|
||||
*
|
||||
* Paces sends by waiting [sendDelayMs] between each fixture to avoid flooding the radio's TX queue.
|
||||
*/
|
||||
class TakMeshTestRunner(private val commandSender: CommandSender) {
|
||||
private val _results = MutableStateFlow<List<TakTestResult>>(emptyList())
|
||||
val results: StateFlow<List<TakTestResult>> = _results.asStateFlow()
|
||||
|
||||
private val _isRunning = MutableStateFlow(false)
|
||||
val isRunning: StateFlow<Boolean> = _isRunning.asStateFlow()
|
||||
|
||||
private val _currentFixture = MutableStateFlow<String?>(null)
|
||||
val currentFixture: StateFlow<String?> = _currentFixture.asStateFlow()
|
||||
|
||||
// Prevents concurrent invocations from racing the _isRunning check-then-set.
|
||||
private val runMutex = Mutex()
|
||||
|
||||
companion object {
|
||||
/** Delay between sends to let the radio transmit and receive ACK. */
|
||||
private const val SEND_DELAY_MS = 5_000L
|
||||
|
||||
/** All bundled fixture filenames. */
|
||||
val FIXTURE_NAMES =
|
||||
listOf(
|
||||
"aircraft_adsb.xml",
|
||||
"aircraft_hostile.xml",
|
||||
"alert_tic.xml",
|
||||
"casevac.xml",
|
||||
"casevac_medline.xml",
|
||||
"chat_receipt_delivered.xml",
|
||||
"chat_receipt_read.xml",
|
||||
"delete_event.xml",
|
||||
"drawing_circle.xml",
|
||||
"drawing_circle_large.xml",
|
||||
"drawing_ellipse.xml",
|
||||
"drawing_freeform.xml",
|
||||
"drawing_polygon.xml",
|
||||
"drawing_rectangle.xml",
|
||||
"drawing_rectangle_itak.xml",
|
||||
"drawing_telestration.xml",
|
||||
"emergency_911.xml",
|
||||
"emergency_cancel.xml",
|
||||
"geochat_broadcast.xml",
|
||||
"geochat_dm.xml",
|
||||
"geochat_simple.xml",
|
||||
"marker_2525.xml",
|
||||
"marker_goto.xml",
|
||||
"marker_goto_itak.xml",
|
||||
"marker_icon_set.xml",
|
||||
"marker_spot.xml",
|
||||
"marker_tank.xml",
|
||||
"pli_basic.xml",
|
||||
"pli_full.xml",
|
||||
"pli_itak.xml",
|
||||
"pli_stationary.xml",
|
||||
"pli_takaware.xml",
|
||||
"pli_webtak.xml",
|
||||
"ranging_bullseye.xml",
|
||||
"ranging_circle.xml",
|
||||
"ranging_line.xml",
|
||||
"route_3wp.xml",
|
||||
"route_itak_3wp.xml",
|
||||
"task_engage.xml",
|
||||
"waypoint.xml",
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Run all test fixtures sequentially, sending each through the mesh pipeline. Updates [results] and
|
||||
* [currentFixture] as each fixture is processed.
|
||||
*/
|
||||
suspend fun runAll() {
|
||||
// Use tryLock to prevent concurrent test runs: if another coroutine is already
|
||||
// inside runAll(), tryLock returns false and we bail immediately. This is safer
|
||||
// than the check-then-set pattern which has a TOCTOU race in multi-threaded
|
||||
// coroutine dispatch.
|
||||
if (!runMutex.tryLock()) return
|
||||
try {
|
||||
_isRunning.value = true
|
||||
_results.value = emptyList()
|
||||
|
||||
val allResults = mutableListOf<TakTestResult>()
|
||||
|
||||
for (name in FIXTURE_NAMES) {
|
||||
_currentFixture.value = name
|
||||
val result = runSingleFixture(name)
|
||||
allResults.add(result)
|
||||
_results.value = allResults.toList()
|
||||
|
||||
if (result.passed) {
|
||||
// Wait for radio airtime + ACK before next send
|
||||
delay(SEND_DELAY_MS)
|
||||
}
|
||||
}
|
||||
|
||||
_currentFixture.value = null
|
||||
|
||||
val passed = allResults.count { it.passed }
|
||||
val failed = allResults.size - passed
|
||||
Logger.i { "TAK Mesh Test complete: $passed/${allResults.size} passed, $failed failed" }
|
||||
} finally {
|
||||
_isRunning.value = false
|
||||
runMutex.unlock()
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun runSingleFixture(name: String): TakTestResult {
|
||||
// Load fixture XML from bundled resources via platform-specific loader
|
||||
val xml =
|
||||
try {
|
||||
loadTakFixtureXml(name)
|
||||
} catch (e: Exception) {
|
||||
Logger.w(e) { "Failed to load fixture $name" }
|
||||
return TakTestResult(name, 0, 0, false, "Load failed: ${e.message}")
|
||||
}
|
||||
|
||||
// Apply the same pipeline as TAKMeshIntegration.sendCoTToMesh()
|
||||
val freshXml = TAKMeshIntegration.ensureMinimumStaleForMesh(xml)
|
||||
val strippedXml = TAKMeshIntegration.stripNonEssentialElements(freshXml)
|
||||
|
||||
// Parse and compress via SDK
|
||||
val wirePayload: ByteArray
|
||||
try {
|
||||
val compressed = TakSdkCompressor.compressCoT(strippedXml, MAX_TAK_WIRE_PAYLOAD_BYTES)
|
||||
if (compressed == null) {
|
||||
Logger.w { "TAK Test: $name oversized even without remarks (xml=${xml.length}B)" }
|
||||
return TakTestResult(name, xml.length, 0, false, "Oversized (>${MAX_TAK_WIRE_PAYLOAD_BYTES}B)")
|
||||
}
|
||||
wirePayload = compressed
|
||||
} catch (e: Exception) {
|
||||
Logger.w(e) { "TAK Test: $name compression failed: ${e.message}" }
|
||||
return TakTestResult(name, xml.length, 0, false, "Compress failed: ${e.message}")
|
||||
}
|
||||
|
||||
// Send to mesh
|
||||
try {
|
||||
val dataPacket =
|
||||
DataPacket(
|
||||
to = DataPacket.ID_BROADCAST,
|
||||
bytes = wirePayload.toByteString(),
|
||||
dataType = PortNum.ATAK_PLUGIN_V2.value,
|
||||
)
|
||||
commandSender.sendData(dataPacket)
|
||||
Logger.i { "TAK Test: $name → ${wirePayload.size}B (xml=${xml.length}B)" }
|
||||
return TakTestResult(name, xml.length, wirePayload.size, true)
|
||||
} catch (e: Exception) {
|
||||
Logger.w(e) { "TAK Test: $name send failed: ${e.message}" }
|
||||
return TakTestResult(name, xml.length, wirePayload.size, false, "Send failed: ${e.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
/*
|
||||
* Copyright (c) 2026 Meshtastic LLC
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.meshtastic.core.takserver
|
||||
|
||||
/**
|
||||
* Expect/actual wrapper for the TAKPacket-SDK compression pipeline.
|
||||
*
|
||||
* On JVM/Android the SDK's [org.meshtastic.tak.CotXmlParser] and [org.meshtastic.tak.TakCompressor] are available. On
|
||||
* iOS they are not, so the actual throws [UnsupportedOperationException].
|
||||
*/
|
||||
internal expect object TakSdkCompressor {
|
||||
|
||||
/**
|
||||
* Parse CoT XML via the SDK and compress with remarks-fallback.
|
||||
*
|
||||
* @return compressed wire payload, or `null` if the packet exceeds [maxBytes] even without remarks.
|
||||
* @throws Exception on parse or compression failure.
|
||||
*/
|
||||
fun compressCoT(xml: String, maxBytes: Int): ByteArray?
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
/*
|
||||
* Copyright (c) 2026 Meshtastic LLC
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.meshtastic.core.takserver
|
||||
|
||||
import org.meshtastic.proto.TAKPacketV2
|
||||
|
||||
/**
|
||||
* TAKPacket V2 wire format compressor/decompressor.
|
||||
*
|
||||
* Wire format: [1 byte flags][zstd-compressed TAKPacketV2 protobuf] Flags byte bits 0-5 = dictionary ID, bits 6-7 =
|
||||
* reserved. Special value 0xFF = uncompressed raw protobuf (from TAK_TRACKER firmware).
|
||||
*
|
||||
* Platform-specific implementations use zstd with pre-trained dictionaries.
|
||||
*/
|
||||
internal expect object TakV2Compressor {
|
||||
|
||||
/** Maximum allowed decompressed payload size (bytes). */
|
||||
val MAX_DECOMPRESSED_SIZE: Int
|
||||
|
||||
/** Dictionary ID for non-aircraft types. */
|
||||
val DICT_ID_NON_AIRCRAFT: Int
|
||||
|
||||
/** Dictionary ID for aircraft types. */
|
||||
val DICT_ID_AIRCRAFT: Int
|
||||
|
||||
/** Special flags byte value indicating uncompressed raw protobuf. */
|
||||
val DICT_ID_UNCOMPRESSED: Int
|
||||
|
||||
/**
|
||||
* Compress a TAKPacketV2 into wire payload: [flags byte][zstd compressed protobuf]. Selects dictionary based on the
|
||||
* CoT type classification.
|
||||
*/
|
||||
fun compress(packet: TAKPacketV2): ByteArray
|
||||
|
||||
/**
|
||||
* Decompress a wire payload back to TAKPacketV2. Handles both compressed (dict-based) and uncompressed (0xFF)
|
||||
* payloads.
|
||||
*
|
||||
* @throws IllegalArgumentException if payload is malformed or exceeds size limits.
|
||||
*/
|
||||
fun decompress(wirePayload: ByteArray): TAKPacketV2
|
||||
|
||||
/**
|
||||
* Decompress a wire payload and reconstruct CoT XML via the SDK's CotXmlBuilder. Handles ALL payload types
|
||||
* (DrawnShape, Marker, Route, etc.) without going through the Wire proto intermediate.
|
||||
*/
|
||||
fun decompressToXml(wirePayload: ByteArray): String
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
/*
|
||||
* Copyright (c) 2026 Meshtastic LLC
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.meshtastic.core.takserver
|
||||
|
||||
import org.meshtastic.proto.CotHow
|
||||
import org.meshtastic.proto.CotType
|
||||
|
||||
/** Maps CoT type strings (e.g. "a-f-G-U-C") to CotType enum values and back. */
|
||||
internal object TakV2TypeMapper {
|
||||
|
||||
private val stringToType: Map<String, CotType> =
|
||||
mapOf(
|
||||
"a-f-G-U-C" to CotType.CotType_a_f_G_U_C,
|
||||
"a-f-G-U-C-I" to CotType.CotType_a_f_G_U_C_I,
|
||||
"a-n-A-C-F" to CotType.CotType_a_n_A_C_F,
|
||||
"a-n-A-C-H" to CotType.CotType_a_n_A_C_H,
|
||||
"a-n-A-C" to CotType.CotType_a_n_A_C,
|
||||
"a-f-A-M-H" to CotType.CotType_a_f_A_M_H,
|
||||
"a-f-A-M" to CotType.CotType_a_f_A_M,
|
||||
"a-h-A-M-F-F" to CotType.CotType_a_h_A_M_F_F,
|
||||
"a-u-A-C" to CotType.CotType_a_u_A_C,
|
||||
"t-x-d-d" to CotType.CotType_t_x_d_d,
|
||||
"b-t-f" to CotType.CotType_b_t_f,
|
||||
"b-r-f-h-c" to CotType.CotType_b_r_f_h_c,
|
||||
"b-a-o-pan" to CotType.CotType_b_a_o_pan,
|
||||
"b-a-o-opn" to CotType.CotType_b_a_o_opn,
|
||||
"a-f-G" to CotType.CotType_a_f_G,
|
||||
"a-f-G-U" to CotType.CotType_a_f_G_U,
|
||||
"a-h-G" to CotType.CotType_a_h_G,
|
||||
"a-u-G" to CotType.CotType_a_u_G,
|
||||
"a-n-G" to CotType.CotType_a_n_G,
|
||||
"b-m-r" to CotType.CotType_b_m_r,
|
||||
"b-m-p-s-p-i" to CotType.CotType_b_m_p_s_p_i,
|
||||
"u-d-f" to CotType.CotType_u_d_f,
|
||||
"a-f-A-C-F" to CotType.CotType_a_f_A_C_F,
|
||||
"a-f-A" to CotType.CotType_a_f_A,
|
||||
"a-f-G-E-S" to CotType.CotType_a_f_G_E_S,
|
||||
"b-m-p-s-p-loc" to CotType.CotType_b_m_p_s_p_loc,
|
||||
"b-i-v" to CotType.CotType_b_i_v,
|
||||
)
|
||||
|
||||
private val typeToString: Map<CotType, String> = stringToType.entries.associate { (k, v) -> v to k }
|
||||
|
||||
private val stringToHow: Map<String, CotHow> =
|
||||
mapOf(
|
||||
"h-e" to CotHow.CotHow_h_e,
|
||||
"m-g" to CotHow.CotHow_m_g,
|
||||
"h-g-i-g-o" to CotHow.CotHow_h_g_i_g_o,
|
||||
"m-r" to CotHow.CotHow_m_r,
|
||||
)
|
||||
|
||||
private val howToStr: Map<CotHow, String> = stringToHow.entries.associate { (k, v) -> v to k }
|
||||
|
||||
fun cotTypeFromString(s: String): CotType = stringToType[s] ?: CotType.CotType_Other
|
||||
|
||||
fun cotTypeToString(type: CotType): String? = typeToString[type]
|
||||
|
||||
fun cotHowFromString(s: String): CotHow = stringToHow[s] ?: CotHow.CotHow_Unspecified
|
||||
|
||||
fun cotHowToString(how: CotHow): String? = howToStr[how]
|
||||
}
|
||||
@@ -27,33 +27,22 @@ import org.meshtastic.core.takserver.TAKMeshIntegration
|
||||
import org.meshtastic.core.takserver.TAKServer
|
||||
import org.meshtastic.core.takserver.TAKServerManager
|
||||
import org.meshtastic.core.takserver.TAKServerManagerImpl
|
||||
import org.meshtastic.core.takserver.fountain.CoTHandler
|
||||
import org.meshtastic.core.takserver.fountain.GenericCoTHandler
|
||||
import org.meshtastic.core.takserver.createTAKServer
|
||||
|
||||
@Module
|
||||
class CoreTakServerModule {
|
||||
@Single fun provideTAKServer(dispatchers: CoroutineDispatchers): TAKServer = TAKServer(dispatchers = dispatchers)
|
||||
@Single
|
||||
fun provideTAKServer(dispatchers: CoroutineDispatchers): TAKServer = createTAKServer(dispatchers = dispatchers)
|
||||
|
||||
@Single fun provideTAKServerManager(takServer: TAKServer): TAKServerManager = TAKServerManagerImpl(takServer)
|
||||
|
||||
@Single
|
||||
fun provideGenericCoTHandler(commandSender: CommandSender, takServerManager: TAKServerManager): CoTHandler =
|
||||
GenericCoTHandler(commandSender, takServerManager)
|
||||
|
||||
@Single
|
||||
fun provideTAKMeshIntegration(
|
||||
takServerManager: TAKServerManager,
|
||||
commandSender: CommandSender,
|
||||
nodeRepository: NodeRepository,
|
||||
serviceRepository: ServiceRepository,
|
||||
meshConfigHandler: MeshConfigHandler,
|
||||
cotHandler: CoTHandler,
|
||||
): TAKMeshIntegration = TAKMeshIntegration(
|
||||
takServerManager,
|
||||
commandSender,
|
||||
nodeRepository,
|
||||
serviceRepository,
|
||||
meshConfigHandler,
|
||||
cotHandler,
|
||||
)
|
||||
nodeRepository: NodeRepository,
|
||||
): TAKMeshIntegration =
|
||||
TAKMeshIntegration(takServerManager, commandSender, serviceRepository, meshConfigHandler, nodeRepository)
|
||||
}
|
||||
|
||||
@@ -1,468 +0,0 @@
|
||||
/*
|
||||
* Copyright (c) 2026 Meshtastic LLC
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.meshtastic.core.takserver.fountain
|
||||
|
||||
import co.touchlab.kermit.Logger
|
||||
import kotlin.math.ceil
|
||||
import kotlin.math.ln
|
||||
import kotlin.math.sqrt
|
||||
import kotlin.random.Random
|
||||
import kotlin.time.Clock
|
||||
|
||||
internal object FountainConstants {
|
||||
val MAGIC = byteArrayOf(0x46, 0x54, 0x4E) // "FTN"
|
||||
const val BLOCK_SIZE = 220
|
||||
const val DATA_HEADER_SIZE = 11
|
||||
const val FOUNTAIN_THRESHOLD = 233
|
||||
const val TRANSFER_TYPE_COT: Byte = 0x00
|
||||
const val ACK_TYPE_COMPLETE: Byte = 0x02
|
||||
const val ACK_PACKET_SIZE = 19
|
||||
}
|
||||
|
||||
internal data class FountainBlock(
|
||||
val seed: Int, // UInt16
|
||||
var indices: MutableSet<Int>,
|
||||
var payload: ByteArray,
|
||||
) {
|
||||
override fun equals(other: Any?): Boolean {
|
||||
if (this === other) return true
|
||||
if (other == null || this::class != other::class) return false
|
||||
other as FountainBlock
|
||||
return seed == other.seed && indices == other.indices && payload.contentEquals(other.payload)
|
||||
}
|
||||
|
||||
override fun hashCode(): Int {
|
||||
var result = seed
|
||||
result = 31 * result + indices.hashCode()
|
||||
result = 31 * result + payload.contentHashCode()
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
||||
internal class FountainReceiveState(
|
||||
val transferId: Int, // UInt24
|
||||
val k: Int,
|
||||
val totalLength: Int,
|
||||
) {
|
||||
val blocks = mutableListOf<FountainBlock>()
|
||||
private val createdAt = Clock.System.now().toEpochMilliseconds()
|
||||
|
||||
fun addBlock(block: FountainBlock) {
|
||||
if (blocks.none { it.seed == block.seed }) {
|
||||
blocks.add(block)
|
||||
}
|
||||
}
|
||||
|
||||
val isExpired: Boolean
|
||||
get() = (Clock.System.now().toEpochMilliseconds() - createdAt) > 60_000
|
||||
}
|
||||
|
||||
internal data class FountainDataHeader(
|
||||
val transferId: Int, // UInt24
|
||||
val seed: Int, // UInt16
|
||||
val k: Int, // UInt8
|
||||
val totalLength: Int, // UInt16
|
||||
)
|
||||
|
||||
internal data class FountainAck(
|
||||
val transferId: Int,
|
||||
val type: Byte,
|
||||
val received: Int,
|
||||
val needed: Int,
|
||||
val dataHash: ByteArray,
|
||||
) {
|
||||
override fun equals(other: Any?): Boolean {
|
||||
if (this === other) return true
|
||||
if (other == null || this::class != other::class) return false
|
||||
other as FountainAck
|
||||
return transferId == other.transferId &&
|
||||
type == other.type &&
|
||||
received == other.received &&
|
||||
needed == other.needed &&
|
||||
dataHash.contentEquals(other.dataHash)
|
||||
}
|
||||
|
||||
override fun hashCode(): Int {
|
||||
var result = transferId
|
||||
result = 31 * result + type.toInt()
|
||||
result = 31 * result + received
|
||||
result = 31 * result + needed
|
||||
result = 31 * result + dataHash.contentHashCode()
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("MagicNumber")
|
||||
internal class JavaRandom(seed: Long) {
|
||||
private var seed: Long = (seed xor 0x5DEECE66DL) and ((1L shl 48) - 1)
|
||||
|
||||
private fun next(bits: Int): Int {
|
||||
seed = (seed * 0x5DEECE66DL + 0xBL) and ((1L shl 48) - 1)
|
||||
return (seed ushr (48 - bits)).toInt()
|
||||
}
|
||||
|
||||
fun nextInt(bound: Int): Int = when {
|
||||
bound <= 0 -> 0
|
||||
|
||||
(bound and -bound) == bound -> ((bound.toLong() * next(31).toLong()) shr 31).toInt()
|
||||
|
||||
else -> {
|
||||
var bits: Int
|
||||
var valResult: Int
|
||||
do {
|
||||
bits = next(31)
|
||||
valResult = bits % bound
|
||||
} while (bits - valResult + (bound - 1) < 0)
|
||||
valResult
|
||||
}
|
||||
}
|
||||
|
||||
fun nextDouble(): Double {
|
||||
val high = next(26).toLong()
|
||||
val low = next(27).toLong()
|
||||
return ((high shl 27) + low).toDouble() / (1L shl 53).toDouble()
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("MagicNumber", "TooManyFunctions")
|
||||
internal class FountainCodec {
|
||||
private val receiveStates = mutableMapOf<Int, FountainReceiveState>()
|
||||
|
||||
fun generateTransferId(): Int {
|
||||
val random = Random.nextInt(0, 0xFFFFFF + 1)
|
||||
val time = (Clock.System.now().toEpochMilliseconds() / 1000).toInt() and 0xFFFF
|
||||
return (random xor time) and 0xFFFFFF
|
||||
}
|
||||
|
||||
fun encode(data: ByteArray, transferId: Int): List<ByteArray> {
|
||||
if (data.isEmpty()) {
|
||||
Logger.w { "Fountain encode: empty data" }
|
||||
return emptyList()
|
||||
}
|
||||
|
||||
val k = maxOf(1, ceil(data.size.toDouble() / FountainConstants.BLOCK_SIZE).toInt())
|
||||
val overhead = getAdaptiveOverhead(k)
|
||||
val blocksToSend = maxOf(1, ceil(k.toDouble() * (1.0 + overhead)).toInt())
|
||||
|
||||
val sourceBlocks = splitIntoBlocks(data, k)
|
||||
val packets = mutableListOf<ByteArray>()
|
||||
|
||||
for (i in 0 until blocksToSend) {
|
||||
val seed = generateSeed(transferId, i)
|
||||
val indices = generateBlockIndices(seed, k, i)
|
||||
|
||||
var blockPayload = ByteArray(FountainConstants.BLOCK_SIZE) { 0 }
|
||||
for (idx in indices) {
|
||||
blockPayload = xor(blockPayload, sourceBlocks[idx])
|
||||
}
|
||||
|
||||
val packet = buildDataBlock(transferId, seed, k, data.size, blockPayload)
|
||||
packets.add(packet)
|
||||
}
|
||||
|
||||
Logger.i { "Fountain encode: ${data.size} bytes -> $k source blocks -> $blocksToSend packets" }
|
||||
return packets
|
||||
}
|
||||
|
||||
private fun splitIntoBlocks(data: ByteArray, k: Int): List<ByteArray> {
|
||||
val blocks = mutableListOf<ByteArray>()
|
||||
for (i in 0 until k) {
|
||||
val start = i * FountainConstants.BLOCK_SIZE
|
||||
val end = minOf(start + FountainConstants.BLOCK_SIZE, data.size)
|
||||
|
||||
if (start < data.size) {
|
||||
val block = data.copyOfRange(start, end)
|
||||
if (block.size < FountainConstants.BLOCK_SIZE) {
|
||||
val padded = ByteArray(FountainConstants.BLOCK_SIZE) { 0 }
|
||||
block.copyInto(padded)
|
||||
blocks.add(padded)
|
||||
} else {
|
||||
blocks.add(block)
|
||||
}
|
||||
} else {
|
||||
blocks.add(ByteArray(FountainConstants.BLOCK_SIZE) { 0 })
|
||||
}
|
||||
}
|
||||
return blocks
|
||||
}
|
||||
|
||||
private fun buildDataBlock(transferId: Int, seed: Int, k: Int, totalLength: Int, payload: ByteArray): ByteArray {
|
||||
val packet = ByteArray(FountainConstants.DATA_HEADER_SIZE + payload.size)
|
||||
|
||||
packet[0] = FountainConstants.MAGIC[0]
|
||||
packet[1] = FountainConstants.MAGIC[1]
|
||||
packet[2] = FountainConstants.MAGIC[2]
|
||||
|
||||
packet[3] = ((transferId shr 16) and 0xFF).toByte()
|
||||
packet[4] = ((transferId shr 8) and 0xFF).toByte()
|
||||
packet[5] = (transferId and 0xFF).toByte()
|
||||
|
||||
packet[6] = ((seed shr 8) and 0xFF).toByte()
|
||||
packet[7] = (seed and 0xFF).toByte()
|
||||
|
||||
packet[8] = (k and 0xFF).toByte()
|
||||
|
||||
packet[9] = ((totalLength shr 8) and 0xFF).toByte()
|
||||
packet[10] = (totalLength and 0xFF).toByte()
|
||||
|
||||
payload.copyInto(packet, FountainConstants.DATA_HEADER_SIZE)
|
||||
return packet
|
||||
}
|
||||
|
||||
fun isFountainPacket(data: ByteArray): Boolean {
|
||||
if (data.size < 3) return false
|
||||
return data[0] == FountainConstants.MAGIC[0] &&
|
||||
data[1] == FountainConstants.MAGIC[1] &&
|
||||
data[2] == FountainConstants.MAGIC[2]
|
||||
}
|
||||
|
||||
fun parseDataHeader(data: ByteArray): FountainDataHeader? {
|
||||
if (data.size < FountainConstants.DATA_HEADER_SIZE || !isFountainPacket(data)) return null
|
||||
|
||||
val transferId =
|
||||
((data[3].toInt() and 0xFF) shl 16) or ((data[4].toInt() and 0xFF) shl 8) or (data[5].toInt() and 0xFF)
|
||||
val seed = ((data[6].toInt() and 0xFF) shl 8) or (data[7].toInt() and 0xFF)
|
||||
val k = data[8].toInt() and 0xFF
|
||||
val totalLength = ((data[9].toInt() and 0xFF) shl 8) or (data[10].toInt() and 0xFF)
|
||||
|
||||
return FountainDataHeader(transferId, seed, k, totalLength)
|
||||
}
|
||||
|
||||
fun handleIncomingPacket(data: ByteArray): Pair<ByteArray, Int>? {
|
||||
cleanupExpiredStates()
|
||||
|
||||
val header = parseDataHeader(data)
|
||||
if (header != null) {
|
||||
val payload = data.copyOfRange(FountainConstants.DATA_HEADER_SIZE, data.size)
|
||||
if (payload.size == FountainConstants.BLOCK_SIZE) {
|
||||
return processValidIncomingPacket(header, payload)
|
||||
} else {
|
||||
Logger.w { "Invalid fountain payload size: ${payload.size}" }
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
private fun processValidIncomingPacket(header: FountainDataHeader, payload: ByteArray): Pair<ByteArray, Int>? {
|
||||
val state =
|
||||
receiveStates.getOrPut(header.transferId) {
|
||||
FountainReceiveState(header.transferId, header.k, header.totalLength)
|
||||
}
|
||||
|
||||
val indices = regenerateIndices(header.seed, state.k, header.transferId)
|
||||
val block = FountainBlock(header.seed, indices.toMutableSet(), payload)
|
||||
state.addBlock(block)
|
||||
|
||||
if (state.blocks.size >= state.k) {
|
||||
val decoded = peelingDecode(state)
|
||||
if (decoded != null) {
|
||||
receiveStates.remove(header.transferId)
|
||||
Logger.i { "Fountain decode complete: ${decoded.size} bytes from ${state.blocks.size} blocks" }
|
||||
return Pair(decoded, header.transferId)
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
fun buildAck(transferId: Int, type: Byte, received: Int, needed: Int, dataHash: ByteArray): ByteArray {
|
||||
val packet = ByteArray(FountainConstants.ACK_PACKET_SIZE)
|
||||
|
||||
packet[0] = FountainConstants.MAGIC[0]
|
||||
packet[1] = FountainConstants.MAGIC[1]
|
||||
packet[2] = FountainConstants.MAGIC[2]
|
||||
|
||||
packet[3] = ((transferId shr 16) and 0xFF).toByte()
|
||||
packet[4] = ((transferId shr 8) and 0xFF).toByte()
|
||||
packet[5] = (transferId and 0xFF).toByte()
|
||||
|
||||
packet[6] = type
|
||||
|
||||
packet[7] = ((received shr 8) and 0xFF).toByte()
|
||||
packet[8] = (received and 0xFF).toByte()
|
||||
|
||||
packet[9] = ((needed shr 8) and 0xFF).toByte()
|
||||
packet[10] = (needed and 0xFF).toByte()
|
||||
|
||||
val hashLen = minOf(8, dataHash.size)
|
||||
dataHash.copyInto(packet, 11, 0, hashLen)
|
||||
|
||||
return packet
|
||||
}
|
||||
|
||||
fun parseAck(data: ByteArray): FountainAck? {
|
||||
if (data.size < FountainConstants.ACK_PACKET_SIZE || !isFountainPacket(data)) return null
|
||||
|
||||
val transferId =
|
||||
((data[3].toInt() and 0xFF) shl 16) or ((data[4].toInt() and 0xFF) shl 8) or (data[5].toInt() and 0xFF)
|
||||
val type = data[6]
|
||||
val received = ((data[7].toInt() and 0xFF) shl 8) or (data[8].toInt() and 0xFF)
|
||||
val needed = ((data[9].toInt() and 0xFF) shl 8) or (data[10].toInt() and 0xFF)
|
||||
val dataHash = data.copyOfRange(11, 19)
|
||||
|
||||
return FountainAck(transferId, type, received, needed, dataHash)
|
||||
}
|
||||
|
||||
private fun peelingDecode(state: FountainReceiveState): ByteArray? {
|
||||
val decoded = mutableMapOf<Int, ByteArray>()
|
||||
val workingBlocks =
|
||||
state.blocks.map { FountainBlock(it.seed, it.indices.toMutableSet(), it.payload.copyOf()) }.toMutableList()
|
||||
|
||||
var progress = true
|
||||
while (progress && decoded.size < state.k) {
|
||||
progress = processWorkingBlocks(workingBlocks, decoded)
|
||||
}
|
||||
|
||||
if (decoded.size < state.k) {
|
||||
Logger.d { "Peeling decode incomplete: ${decoded.size}/${state.k} blocks decoded" }
|
||||
return null
|
||||
}
|
||||
return assembleDecodedData(state, decoded)
|
||||
}
|
||||
|
||||
private fun processWorkingBlocks(workingBlocks: List<FountainBlock>, decoded: MutableMap<Int, ByteArray>): Boolean {
|
||||
var progress = false
|
||||
for (i in workingBlocks.indices) {
|
||||
val block = workingBlocks[i]
|
||||
val toRemove = mutableListOf<Int>()
|
||||
for (idx in block.indices) {
|
||||
val decodedBlock = decoded[idx]
|
||||
if (decodedBlock != null) {
|
||||
block.payload = xor(block.payload, decodedBlock)
|
||||
toRemove.add(idx)
|
||||
}
|
||||
}
|
||||
block.indices.removeAll(toRemove)
|
||||
|
||||
if (block.indices.size == 1) {
|
||||
val idx = block.indices.first()
|
||||
if (!decoded.containsKey(idx)) {
|
||||
decoded[idx] = block.payload
|
||||
progress = true
|
||||
}
|
||||
}
|
||||
}
|
||||
return progress
|
||||
}
|
||||
|
||||
private fun assembleDecodedData(state: FountainReceiveState, decoded: Map<Int, ByteArray>): ByteArray? {
|
||||
val result = ByteArray(state.k * FountainConstants.BLOCK_SIZE)
|
||||
for (i in 0 until state.k) {
|
||||
val block = decoded[i] ?: return null
|
||||
block.copyInto(result, i * FountainConstants.BLOCK_SIZE)
|
||||
}
|
||||
return result.copyOfRange(0, state.totalLength)
|
||||
}
|
||||
|
||||
private fun cleanupExpiredStates() {
|
||||
val expiredIds = receiveStates.filter { it.value.isExpired }.map { it.key }
|
||||
for (id in expiredIds) {
|
||||
receiveStates.remove(id)
|
||||
Logger.d { "Cleaned up expired fountain state: $id" }
|
||||
}
|
||||
}
|
||||
|
||||
private fun getAdaptiveOverhead(k: Int): Double = when {
|
||||
k <= 10 -> 0.50
|
||||
k <= 50 -> 0.25
|
||||
else -> 0.15
|
||||
}
|
||||
|
||||
private fun generateSeed(transferId: Int, blockIndex: Int): Int {
|
||||
val combined = transferId * 31337 + blockIndex * 7919
|
||||
return combined and 0xFFFF
|
||||
}
|
||||
|
||||
private fun generateBlockIndices(seed: Int, k: Int, blockIndex: Int): Set<Int> {
|
||||
val rng = JavaRandom(seed.toLong())
|
||||
val sampledDegree = sampleRobustSolitonDegree(rng, k)
|
||||
val degree = if (blockIndex == 0) 1 else sampledDegree
|
||||
return selectIndices(rng, k, degree)
|
||||
}
|
||||
|
||||
private fun regenerateIndices(seed: Int, k: Int, transferId: Int): Set<Int> {
|
||||
val rng = JavaRandom(seed.toLong())
|
||||
val sampledDegree = sampleRobustSolitonDegree(rng, k)
|
||||
val expectedSeed0 = generateSeed(transferId, 0)
|
||||
val degree = if (seed == expectedSeed0) 1 else sampledDegree
|
||||
return selectIndices(rng, k, degree)
|
||||
}
|
||||
|
||||
private fun selectIndices(rng: JavaRandom, k: Int, degree: Int): Set<Int> {
|
||||
val indices = mutableSetOf<Int>()
|
||||
while (indices.size < degree && indices.size < k) {
|
||||
val idx = rng.nextInt(k)
|
||||
indices.add(idx)
|
||||
}
|
||||
return indices
|
||||
}
|
||||
|
||||
private fun sampleRobustSolitonDegree(rng: JavaRandom, k: Int): Int {
|
||||
val cdf = buildRobustSolitonCDF(k)
|
||||
val u = rng.nextDouble()
|
||||
for (d in 1..k) {
|
||||
if (u <= cdf[d]) return d
|
||||
}
|
||||
return k
|
||||
}
|
||||
|
||||
private fun buildRobustSolitonCDF(k: Int, c: Double = 0.1, delta: Double = 0.5): DoubleArray {
|
||||
if (k <= 0) return doubleArrayOf(1.0)
|
||||
|
||||
val rho = DoubleArray(k + 1)
|
||||
rho[1] = 1.0 / k.toDouble()
|
||||
for (d in 2..k) {
|
||||
rho[d] = 1.0 / (d.toDouble() * (d - 1).toDouble())
|
||||
}
|
||||
|
||||
val rVal = c * ln(k.toDouble() / delta) * sqrt(k.toDouble())
|
||||
val tau = DoubleArray(k + 1)
|
||||
val threshold = (k.toDouble() / rVal).toInt()
|
||||
|
||||
for (d in 1..k) {
|
||||
if (d < threshold) {
|
||||
tau[d] = rVal / (d.toDouble() * k.toDouble())
|
||||
} else if (d == threshold) {
|
||||
tau[d] = rVal * ln(rVal / delta) / k.toDouble()
|
||||
}
|
||||
}
|
||||
|
||||
val mu = DoubleArray(k + 1)
|
||||
var sum = 0.0
|
||||
for (d in 1..k) {
|
||||
mu[d] = rho[d] + tau[d]
|
||||
sum += mu[d]
|
||||
}
|
||||
|
||||
val cdf = DoubleArray(k + 1)
|
||||
var cumulative = 0.0
|
||||
for (d in 1..k) {
|
||||
cumulative += mu[d] / sum
|
||||
cdf[d] = cumulative
|
||||
}
|
||||
return cdf
|
||||
}
|
||||
|
||||
private fun xor(a: ByteArray, b: ByteArray): ByteArray {
|
||||
val result = ByteArray(maxOf(a.size, b.size))
|
||||
for (i in result.indices) {
|
||||
val byteA = if (i < a.size) a[i] else 0
|
||||
val byteB = if (i < b.size) b[i] else 0
|
||||
result[i] = (byteA.toInt() xor byteB.toInt()).toByte()
|
||||
}
|
||||
return result
|
||||
}
|
||||
}
|
||||
@@ -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,238 @@
|
||||
/*
|
||||
* Copyright (c) 2026 Meshtastic LLC
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.meshtastic.core.takserver
|
||||
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertFalse
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
/**
|
||||
* Covers the allowed/stripped element contract documented on [CoTDetailStripper]. If a test here starts failing because
|
||||
* a new element type was added to the strip list, update the strip-list KDoc in [CoTDetailStripper] in the same change.
|
||||
*/
|
||||
class CoTDetailStripperTest {
|
||||
|
||||
@Test
|
||||
fun empty_input_returns_empty() {
|
||||
assertEquals("", CoTDetailStripper.strip(""))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun preserves_contact_group_status_track() {
|
||||
val input =
|
||||
"""
|
||||
<contact callsign="Alice"/>
|
||||
<__group name="Cyan" role="Team Member"/>
|
||||
<status battery="82"/>
|
||||
<track speed="5.0" course="180.0"/>
|
||||
"""
|
||||
.trimIndent()
|
||||
val stripped = CoTDetailStripper.strip(input)
|
||||
assertTrue(stripped.contains("<contact"), "contact must be preserved")
|
||||
assertTrue(stripped.contains("<__group"), "__group must be preserved")
|
||||
assertTrue(stripped.contains("<status"), "status must be preserved")
|
||||
assertTrue(stripped.contains("<track"), "track must be preserved")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun strips_cosmetic_elements() {
|
||||
val input =
|
||||
"""
|
||||
<contact callsign="Alice"/>
|
||||
<color argb="-65536"/>
|
||||
<strokeColor value="#ffffff"/>
|
||||
<strokeWeight value="3"/>
|
||||
<fillColor value="#000000"/>
|
||||
<labels_on value="false"/>
|
||||
<usericon iconsetpath="COT_MAPPING_2525B/a-u-G"/>
|
||||
<model path="foo.obj"/>
|
||||
"""
|
||||
.trimIndent()
|
||||
val stripped = CoTDetailStripper.strip(input)
|
||||
assertTrue(stripped.contains("<contact"), "contact must survive")
|
||||
assertFalse(stripped.contains("<color"), "color must be stripped")
|
||||
assertFalse(stripped.contains("<strokeColor"), "strokeColor must be stripped")
|
||||
assertFalse(stripped.contains("<strokeWeight"), "strokeWeight must be stripped")
|
||||
assertFalse(stripped.contains("<fillColor"), "fillColor must be stripped")
|
||||
assertFalse(stripped.contains("<labels_on"), "labels_on must be stripped")
|
||||
assertFalse(stripped.contains("<usericon"), "usericon must be stripped")
|
||||
assertFalse(stripped.contains("<model"), "model must be stripped")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun strips_geometric_detail_including_nested_content() {
|
||||
// <shape> is the biggest single bloat contributor for u-d-c-c events — it
|
||||
// contains an <ellipse> and usually a <link> styling child. Make sure the
|
||||
// entire subtree goes, not just the opening tag.
|
||||
val input =
|
||||
"""
|
||||
<contact callsign="Alice"/>
|
||||
<shape>
|
||||
<ellipse major="500" minor="500" angle="0"/>
|
||||
<link line="#ff0000" width="3"/>
|
||||
</shape>
|
||||
<height value="100"/>
|
||||
<height_unit value="m"/>
|
||||
"""
|
||||
.trimIndent()
|
||||
val stripped = CoTDetailStripper.strip(input)
|
||||
assertTrue(stripped.contains("<contact"), "contact must survive")
|
||||
assertFalse(stripped.contains("shape"), "shape subtree must be stripped: $stripped")
|
||||
assertFalse(stripped.contains("ellipse"), "ellipse must be stripped with its parent")
|
||||
// Note: <link> inside <shape> is also gone because we strip the whole subtree.
|
||||
assertFalse(stripped.contains("<height"), "height must be stripped")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun strips_resource_references_and_flags() {
|
||||
val input =
|
||||
"""
|
||||
<contact callsign="Alice"/>
|
||||
<archive/>
|
||||
<precisionlocation altsrc="GPS" geopointsrc="GPS"/>
|
||||
<fileshare filename="foo.zip" senderUrl="http://example.com/foo.zip"/>
|
||||
<__video url="rtsp://example.com/stream"/>
|
||||
"""
|
||||
.trimIndent()
|
||||
val stripped = CoTDetailStripper.strip(input)
|
||||
assertTrue(stripped.contains("<contact"), "contact must survive")
|
||||
assertFalse(stripped.contains("<archive"), "archive must be stripped")
|
||||
assertFalse(stripped.contains("<precisionlocation"), "precisionlocation must be stripped")
|
||||
assertFalse(stripped.contains("<fileshare"), "fileshare must be stripped")
|
||||
assertFalse(stripped.contains("<__video"), "__video must be stripped")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun preserves_chat_related_elements() {
|
||||
// These are all critical for GeoChat round-tripping and must survive stripping.
|
||||
val input =
|
||||
"""
|
||||
<__chat parent="RootContactGroup" groupOwner="false" messageId="abc" chatroom="All Chat Rooms" id="All Chat Rooms" senderCallsign="Alice">
|
||||
<chatgrp uid0="abc-123" uid1="All Chat Rooms" id="All Chat Rooms"/>
|
||||
</__chat>
|
||||
<link uid="abc-123" type="a-f-G-U-C" relation="p-p"/>
|
||||
<__serverdestination destinations="0.0.0.0:4242:tcp:abc-123"/>
|
||||
<remarks source="BAO.F.ATAK.abc-123" to="All Chat Rooms" time="2025-01-01T12:00:00.000Z">hello world</remarks>
|
||||
"""
|
||||
.trimIndent()
|
||||
val stripped = CoTDetailStripper.strip(input)
|
||||
assertTrue(stripped.contains("<__chat"), "__chat must survive stripping")
|
||||
assertTrue(stripped.contains("<chatgrp"), "chatgrp must survive stripping")
|
||||
assertTrue(stripped.contains("<link"), "link must survive stripping")
|
||||
assertTrue(stripped.contains("<__serverdestination"), "__serverdestination must survive")
|
||||
assertTrue(stripped.contains("<remarks"), "remarks must survive")
|
||||
assertTrue(stripped.contains("hello world"), "remarks text content must survive")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun collapses_inter_element_whitespace() {
|
||||
val input =
|
||||
"""
|
||||
<contact callsign="Alice"/>
|
||||
<status battery="82"/>
|
||||
"""
|
||||
.trimIndent()
|
||||
val stripped = CoTDetailStripper.strip(input)
|
||||
// No leading/trailing whitespace.
|
||||
assertEquals(stripped, stripped.trim())
|
||||
// No line breaks / indentation between elements.
|
||||
assertFalse(stripped.contains("\n"), "output must not contain newlines: $stripped")
|
||||
// Elements should be directly concatenated.
|
||||
assertTrue(stripped.contains("/><"), "adjacent elements must be directly concatenated: $stripped")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun handles_interleaved_strip_and_keep_elements() {
|
||||
val input =
|
||||
"""
|
||||
<contact callsign="Alice"/>
|
||||
<color argb="-65536"/>
|
||||
<__group name="Cyan" role="Team Member"/>
|
||||
<shape><ellipse major="500" minor="500" angle="0"/></shape>
|
||||
<status battery="82"/>
|
||||
<labels_on value="false"/>
|
||||
<track speed="5.0" course="180.0"/>
|
||||
"""
|
||||
.trimIndent()
|
||||
val stripped = CoTDetailStripper.strip(input)
|
||||
// All four keep-elements survive in order.
|
||||
val contactIdx = stripped.indexOf("<contact")
|
||||
val groupIdx = stripped.indexOf("<__group")
|
||||
val statusIdx = stripped.indexOf("<status")
|
||||
val trackIdx = stripped.indexOf("<track")
|
||||
assertTrue(contactIdx >= 0, "contact missing")
|
||||
assertTrue(groupIdx >= 0, "group missing")
|
||||
assertTrue(statusIdx >= 0, "status missing")
|
||||
assertTrue(trackIdx >= 0, "track missing")
|
||||
assertTrue(contactIdx < groupIdx, "contact must come before group")
|
||||
assertTrue(groupIdx < statusIdx, "group must come before status")
|
||||
assertTrue(statusIdx < trackIdx, "status must come before track")
|
||||
// None of the stripped elements linger.
|
||||
assertFalse(stripped.contains("color"), "color stripped")
|
||||
assertFalse(stripped.contains("shape"), "shape stripped")
|
||||
assertFalse(stripped.contains("ellipse"), "ellipse stripped")
|
||||
assertFalse(stripped.contains("labels_on"), "labels_on stripped")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun strips_tog_and_flow_tags() {
|
||||
// <tog> is the rectangle "toggle" flag ATAK emits; <_flow-tags_> is TAK
|
||||
// Server routing metadata. Both are pure bloat over the mesh. These are
|
||||
// specifically tested because their names contain regex-special characters
|
||||
// (`-`, `_`) and it's easy to typo the strip-list pattern.
|
||||
val input =
|
||||
"""
|
||||
<contact callsign="Alice"/>
|
||||
<tog enabled="0"/>
|
||||
<_flow-tags_ marti1="2014-10-28T22:40:15.341Z"/>
|
||||
"""
|
||||
.trimIndent()
|
||||
val stripped = CoTDetailStripper.strip(input)
|
||||
assertTrue(stripped.contains("<contact"), "contact must survive")
|
||||
assertFalse(stripped.contains("<tog"), "tog must be stripped: $stripped")
|
||||
assertFalse(stripped.contains("_flow-tags_"), "_flow-tags_ must be stripped: $stripped")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun real_world_u_d_c_c_event_shrinks_dramatically() {
|
||||
// Synthetic reproduction of what ATAK actually emits for a drawn circle —
|
||||
// this is the 800-byte payload the user's logs were choking on.
|
||||
val realistic =
|
||||
"""<contact callsign='ALPHA01'/><__group name='Cyan' role='Team Member'/>""" +
|
||||
"""<status battery='85'/><precisionlocation altsrc='GPS' geopointsrc='GPS'/>""" +
|
||||
"""<shape><ellipse major='500' minor='500' angle='0'/><link line='#ff0000' width='3'/></shape>""" +
|
||||
"""<color argb='-65536'/><labels_on value='false'/><archive/>""" +
|
||||
"""<usericon iconsetpath='COT_MAPPING_2525B/a-u-G/a-u-G-U-C-I-M/a-u-G-U-C-I-M-N-S'/>""" +
|
||||
"""<strokeColor value='-65536'/><strokeWeight value='3'/><fillColor value='1157562368'/>""" +
|
||||
"""<height value='100'/><height_unit value='m'/>""" +
|
||||
"""<fileshare filename='overlay.kml' senderUrl='http://10.0.0.1/overlay.kml' """ +
|
||||
"""sizeInBytes='2048' sha256='deadbeef'/>""" +
|
||||
"""<__video url='rtsp://10.0.0.1:8554/stream'/>"""
|
||||
val stripped = CoTDetailStripper.strip(realistic)
|
||||
val before = realistic.length
|
||||
val after = stripped.length
|
||||
// Should shrink by at least 60% — most of the bytes were bloat.
|
||||
assertTrue(after < before * 0.4, "expected >60% reduction; before=$before after=$after stripped='$stripped'")
|
||||
// Only the three "essential" elements survive.
|
||||
assertTrue(stripped.contains("<contact"), "contact must survive")
|
||||
assertTrue(stripped.contains("<__group"), "__group must survive")
|
||||
assertTrue(stripped.contains("<status"), "status must survive")
|
||||
assertFalse(stripped.contains("shape"), "shape must be gone")
|
||||
assertFalse(stripped.contains("fileshare"), "fileshare must be gone")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -109,18 +109,11 @@ class TAKDefaultsTest {
|
||||
// ── keepalive / idle timeout constants ─────────────────────────────────────
|
||||
|
||||
@Test
|
||||
fun `keepalive stale window is wider than keepalive interval`() {
|
||||
val staleMs = TAK_KEEPALIVE_INTERVAL_MS * TAK_KEEPALIVE_STALE_MULTIPLIER
|
||||
fun `keepalive interval is below ATAK stale threshold`() {
|
||||
// ATAK's default stale threshold is 15 seconds; our keepalive must be below that.
|
||||
assertTrue(
|
||||
staleMs > TAK_KEEPALIVE_INTERVAL_MS,
|
||||
"Stale window ($staleMs ms) must exceed keepalive interval ($TAK_KEEPALIVE_INTERVAL_MS ms)",
|
||||
TAK_KEEPALIVE_INTERVAL_MS < 15_000L,
|
||||
"Keepalive interval ($TAK_KEEPALIVE_INTERVAL_MS ms) must be below ATAK's 15s stale threshold",
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `idle timeout exceeds keepalive stale window`() {
|
||||
val idleTimeoutMs = TAK_KEEPALIVE_INTERVAL_MS * TAK_READ_IDLE_TIMEOUT_MULTIPLIER
|
||||
val staleMs = TAK_KEEPALIVE_INTERVAL_MS * TAK_KEEPALIVE_STALE_MULTIPLIER
|
||||
assertTrue(idleTimeoutMs > staleMs, "Idle timeout ($idleTimeoutMs ms) must exceed stale window ($staleMs ms)")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,486 @@
|
||||
/*
|
||||
* Copyright (c) 2026 Meshtastic LLC
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.meshtastic.core.takserver
|
||||
|
||||
import co.touchlab.kermit.Severity
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharedFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asSharedFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.test.UnconfinedTestDispatcher
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import okio.ByteString.Companion.toByteString
|
||||
import org.meshtastic.core.model.ConnectionState
|
||||
import org.meshtastic.core.model.DataPacket
|
||||
import org.meshtastic.core.model.MyNodeInfo
|
||||
import org.meshtastic.core.model.Node
|
||||
import org.meshtastic.core.model.NodeSortOption
|
||||
import org.meshtastic.core.model.Position
|
||||
import org.meshtastic.core.model.service.ServiceAction
|
||||
import org.meshtastic.core.model.service.TracerouteResponse
|
||||
import org.meshtastic.core.repository.CommandSender
|
||||
import org.meshtastic.core.repository.MeshConfigHandler
|
||||
import org.meshtastic.core.repository.NodeRepository
|
||||
import org.meshtastic.core.repository.ServiceRepository
|
||||
import org.meshtastic.proto.AdminMessage
|
||||
import org.meshtastic.proto.Channel
|
||||
import org.meshtastic.proto.ChannelSet
|
||||
import org.meshtastic.proto.ClientNotification
|
||||
import org.meshtastic.proto.Config
|
||||
import org.meshtastic.proto.Data
|
||||
import org.meshtastic.proto.DeviceMetadata
|
||||
import org.meshtastic.proto.DeviceUIConfig
|
||||
import org.meshtastic.proto.LocalConfig
|
||||
import org.meshtastic.proto.LocalModuleConfig
|
||||
import org.meshtastic.proto.LocalStats
|
||||
import org.meshtastic.proto.MeshPacket
|
||||
import org.meshtastic.proto.ModuleConfig
|
||||
import org.meshtastic.proto.PortNum
|
||||
import org.meshtastic.proto.TAKPacket
|
||||
import org.meshtastic.proto.User
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertTrue
|
||||
import kotlin.time.Clock
|
||||
import kotlin.time.Duration.Companion.minutes
|
||||
|
||||
/**
|
||||
* Tests for [TAKMeshIntegration] lifecycle, routing, and protocol gating.
|
||||
*
|
||||
* These tests use fakes for all 5 dependencies and run in commonTest. The v2 outbound SDK-dependent happy path is
|
||||
* tested separately in jvmTest.
|
||||
*/
|
||||
@Suppress("TooManyFunctions")
|
||||
class TAKMeshIntegrationTest {
|
||||
|
||||
// ── Fakes ────────────────────────────────────────────────────────────────
|
||||
|
||||
private class FakeTAKServerManager : TAKServerManager {
|
||||
private val _isRunning = MutableStateFlow(false)
|
||||
override val isRunning: StateFlow<Boolean> = _isRunning.asStateFlow()
|
||||
override val connectionCount: StateFlow<Int> = MutableStateFlow(0)
|
||||
|
||||
private val _inboundMessages = MutableSharedFlow<InboundCoTMessage>(extraBufferCapacity = 64)
|
||||
override val inboundMessages: SharedFlow<InboundCoTMessage> = _inboundMessages.asSharedFlow()
|
||||
|
||||
val broadcasts = mutableListOf<CoTMessage>()
|
||||
val rawBroadcasts = mutableListOf<String>()
|
||||
var startCount = 0
|
||||
var stopped = false
|
||||
|
||||
override fun start(scope: CoroutineScope) {
|
||||
startCount++
|
||||
_isRunning.value = true
|
||||
}
|
||||
|
||||
override fun stop() {
|
||||
stopped = true
|
||||
_isRunning.value = false
|
||||
}
|
||||
|
||||
override fun broadcast(cotMessage: CoTMessage) {
|
||||
broadcasts.add(cotMessage)
|
||||
}
|
||||
|
||||
override fun broadcastRawXml(xml: String) {
|
||||
rawBroadcasts.add(xml)
|
||||
}
|
||||
|
||||
suspend fun emitInbound(cotMessage: CoTMessage, clientInfo: TAKClientInfo? = null) {
|
||||
_inboundMessages.emit(InboundCoTMessage(cotMessage, clientInfo))
|
||||
}
|
||||
}
|
||||
|
||||
private class FakeCommandSender : CommandSender {
|
||||
val sentPackets = mutableListOf<DataPacket>()
|
||||
|
||||
override fun sendData(p: DataPacket) {
|
||||
sentPackets.add(p)
|
||||
}
|
||||
|
||||
override fun getCurrentPacketId(): Long = 0L
|
||||
|
||||
override fun getCachedLocalConfig(): LocalConfig = LocalConfig()
|
||||
|
||||
override fun getCachedChannelSet(): ChannelSet = ChannelSet()
|
||||
|
||||
override fun generatePacketId(): Int = 1
|
||||
|
||||
override fun sendAdmin(destNum: Int, requestId: Int, wantResponse: Boolean, initFn: () -> AdminMessage) {}
|
||||
|
||||
override suspend fun sendAdminAwait(
|
||||
destNum: Int,
|
||||
requestId: Int,
|
||||
wantResponse: Boolean,
|
||||
initFn: () -> AdminMessage,
|
||||
): Boolean = true
|
||||
|
||||
override fun sendPosition(pos: org.meshtastic.proto.Position, destNum: Int?, wantResponse: Boolean) {}
|
||||
|
||||
override fun requestPosition(destNum: Int, currentPosition: Position) {}
|
||||
|
||||
override fun setFixedPosition(destNum: Int, pos: Position) {}
|
||||
|
||||
override fun requestUserInfo(destNum: Int) {}
|
||||
|
||||
override fun requestTraceroute(requestId: Int, destNum: Int) {}
|
||||
|
||||
override fun requestTelemetry(requestId: Int, destNum: Int, typeValue: Int) {}
|
||||
|
||||
override fun requestNeighborInfo(requestId: Int, destNum: Int) {}
|
||||
}
|
||||
|
||||
private class FakeServiceRepository : ServiceRepository {
|
||||
private val _meshPacketFlow = MutableSharedFlow<MeshPacket>(replay = 1, extraBufferCapacity = 64)
|
||||
override val meshPacketFlow: Flow<MeshPacket> = _meshPacketFlow
|
||||
|
||||
override val connectionState: StateFlow<ConnectionState> = MutableStateFlow(ConnectionState.Disconnected)
|
||||
|
||||
override fun setConnectionState(connectionState: ConnectionState) {}
|
||||
|
||||
override val clientNotification: StateFlow<ClientNotification?> = MutableStateFlow(null)
|
||||
|
||||
override fun setClientNotification(notification: ClientNotification?) {}
|
||||
|
||||
override fun clearClientNotification() {}
|
||||
|
||||
override val errorMessage: StateFlow<String?> = MutableStateFlow(null)
|
||||
|
||||
override fun setErrorMessage(text: String, severity: Severity) {}
|
||||
|
||||
override fun clearErrorMessage() {}
|
||||
|
||||
override val connectionProgress: StateFlow<String?> = MutableStateFlow(null)
|
||||
|
||||
override fun setConnectionProgress(text: String) {}
|
||||
|
||||
override suspend fun emitMeshPacket(packet: MeshPacket) {
|
||||
_meshPacketFlow.emit(packet)
|
||||
}
|
||||
|
||||
override val tracerouteResponse: StateFlow<TracerouteResponse?> = MutableStateFlow(null)
|
||||
|
||||
override fun setTracerouteResponse(value: TracerouteResponse?) {}
|
||||
|
||||
override fun clearTracerouteResponse() {}
|
||||
|
||||
override val neighborInfoResponse: StateFlow<String?> = MutableStateFlow(null)
|
||||
|
||||
override fun setNeighborInfoResponse(value: String?) {}
|
||||
|
||||
override fun clearNeighborInfoResponse() {}
|
||||
|
||||
override val serviceAction: Flow<ServiceAction> = MutableSharedFlow()
|
||||
|
||||
override suspend fun onServiceAction(action: ServiceAction) {}
|
||||
}
|
||||
|
||||
private class FakeMeshConfigHandler : MeshConfigHandler {
|
||||
override val localConfig: StateFlow<LocalConfig> = MutableStateFlow(LocalConfig())
|
||||
override val moduleConfig: StateFlow<LocalModuleConfig> = MutableStateFlow(LocalModuleConfig())
|
||||
|
||||
override fun handleDeviceConfig(config: Config) {}
|
||||
|
||||
override fun handleModuleConfig(config: ModuleConfig) {}
|
||||
|
||||
override fun handleChannel(channel: Channel) {}
|
||||
|
||||
override fun handleDeviceUIConfig(config: DeviceUIConfig) {}
|
||||
}
|
||||
|
||||
private class FakeNodeRepository(firmwareVersion: String? = "2.8.0.0") : NodeRepository {
|
||||
private val _myNodeInfo =
|
||||
MutableStateFlow(
|
||||
firmwareVersion?.let {
|
||||
MyNodeInfo(
|
||||
myNodeNum = 1,
|
||||
hasGPS = false,
|
||||
model = null,
|
||||
firmwareVersion = it,
|
||||
couldUpdate = false,
|
||||
shouldUpdate = false,
|
||||
currentPacketId = 0L,
|
||||
messageTimeoutMsec = 0,
|
||||
minAppVersion = 0,
|
||||
maxChannels = 8,
|
||||
hasWifi = false,
|
||||
channelUtilization = 0f,
|
||||
airUtilTx = 0f,
|
||||
deviceId = null,
|
||||
)
|
||||
},
|
||||
)
|
||||
override val myNodeInfo: StateFlow<MyNodeInfo?> = _myNodeInfo
|
||||
|
||||
fun setFirmwareVersion(version: String?) {
|
||||
_myNodeInfo.value =
|
||||
version?.let {
|
||||
MyNodeInfo(
|
||||
myNodeNum = 1,
|
||||
hasGPS = false,
|
||||
model = null,
|
||||
firmwareVersion = it,
|
||||
couldUpdate = false,
|
||||
shouldUpdate = false,
|
||||
currentPacketId = 0L,
|
||||
messageTimeoutMsec = 0,
|
||||
minAppVersion = 0,
|
||||
maxChannels = 8,
|
||||
hasWifi = false,
|
||||
channelUtilization = 0f,
|
||||
airUtilTx = 0f,
|
||||
deviceId = null,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
override val ourNodeInfo: StateFlow<Node?> = MutableStateFlow(null)
|
||||
override val myId: StateFlow<String?> = MutableStateFlow(null)
|
||||
override val localStats: StateFlow<LocalStats> = MutableStateFlow(LocalStats())
|
||||
override val nodeDBbyNum: StateFlow<Map<Int, Node>> = MutableStateFlow(emptyMap())
|
||||
override val onlineNodeCount: Flow<Int> = MutableStateFlow(0)
|
||||
override val totalNodeCount: Flow<Int> = MutableStateFlow(0)
|
||||
|
||||
override fun updateLocalStats(stats: LocalStats) {}
|
||||
|
||||
override fun effectiveLogNodeId(nodeNum: Int): Flow<Int> = MutableStateFlow(0)
|
||||
|
||||
override fun getNode(userId: String): Node = Node(num = 0)
|
||||
|
||||
override fun getUser(nodeNum: Int): User = User()
|
||||
|
||||
override fun getUser(userId: String): User = User()
|
||||
|
||||
override fun getNodes(
|
||||
sort: NodeSortOption,
|
||||
filter: String,
|
||||
includeUnknown: Boolean,
|
||||
onlyOnline: Boolean,
|
||||
onlyDirect: Boolean,
|
||||
): Flow<List<Node>> = MutableStateFlow(emptyList())
|
||||
|
||||
override suspend fun getNodesOlderThan(lastHeard: Int): List<Node> = emptyList()
|
||||
|
||||
override suspend fun getUnknownNodes(): List<Node> = emptyList()
|
||||
|
||||
override suspend fun clearNodeDB(preserveFavorites: Boolean) {}
|
||||
|
||||
override suspend fun clearMyNodeInfo() {}
|
||||
|
||||
override suspend fun deleteNode(num: Int) {}
|
||||
|
||||
override suspend fun deleteNodes(nodeNums: List<Int>) {}
|
||||
|
||||
override suspend fun setNodeNotes(num: Int, notes: String) {}
|
||||
|
||||
override suspend fun upsert(node: Node) {}
|
||||
|
||||
override suspend fun installConfig(mi: MyNodeInfo, nodes: List<Node>) {}
|
||||
|
||||
override suspend fun insertMetadata(nodeNum: Int, metadata: DeviceMetadata) {}
|
||||
}
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────
|
||||
|
||||
private data class TestHarness(
|
||||
val serverManager: FakeTAKServerManager = FakeTAKServerManager(),
|
||||
val commandSender: FakeCommandSender = FakeCommandSender(),
|
||||
val serviceRepository: FakeServiceRepository = FakeServiceRepository(),
|
||||
val meshConfigHandler: FakeMeshConfigHandler = FakeMeshConfigHandler(),
|
||||
val nodeRepository: FakeNodeRepository = FakeNodeRepository(),
|
||||
) {
|
||||
val integration =
|
||||
TAKMeshIntegration(
|
||||
takServerManager = serverManager,
|
||||
commandSender = commandSender,
|
||||
serviceRepository = serviceRepository,
|
||||
meshConfigHandler = meshConfigHandler,
|
||||
nodeRepository = nodeRepository,
|
||||
)
|
||||
}
|
||||
|
||||
// ── Lifecycle tests ──────────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
fun `start launches TAKServerManager`() = runTest(UnconfinedTestDispatcher()) {
|
||||
val h = TestHarness()
|
||||
h.integration.start(backgroundScope)
|
||||
|
||||
assertEquals(1, h.serverManager.startCount)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `stop cancels jobs and stops TAKServerManager`() = runTest(UnconfinedTestDispatcher()) {
|
||||
val h = TestHarness()
|
||||
h.integration.start(backgroundScope)
|
||||
|
||||
h.integration.stop()
|
||||
|
||||
assertTrue(h.serverManager.stopped)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `double start is idempotent`() = runTest(UnconfinedTestDispatcher()) {
|
||||
val h = TestHarness()
|
||||
h.integration.start(backgroundScope)
|
||||
|
||||
h.integration.start(backgroundScope) // second start
|
||||
|
||||
assertEquals(1, h.serverManager.startCount, "TAKServerManager should only be started once")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `stop then inbound TAK message does not forward to mesh`() = runTest(UnconfinedTestDispatcher()) {
|
||||
val h = TestHarness()
|
||||
h.integration.start(backgroundScope)
|
||||
|
||||
h.integration.stop()
|
||||
|
||||
h.serverManager.emitInbound(createPli("after-stop"))
|
||||
|
||||
assertTrue(h.commandSender.sentPackets.isEmpty())
|
||||
}
|
||||
|
||||
// ── Inbound mesh → TAK client (V1) ──────────────────────────────────────
|
||||
|
||||
@Test
|
||||
fun `inbound V1 PLI packet is broadcast to TAK clients`() = runTest(UnconfinedTestDispatcher()) {
|
||||
val h = TestHarness()
|
||||
h.integration.start(backgroundScope)
|
||||
|
||||
h.serviceRepository.emitMeshPacket(createV1PliMeshPacket())
|
||||
|
||||
assertTrue(h.serverManager.broadcasts.isNotEmpty(), "Expected broadcasts for V1 PLI")
|
||||
assertTrue(h.serverManager.broadcasts.first().type.startsWith("a-f-"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `inbound packet on unrelated port is ignored`() = runTest(UnconfinedTestDispatcher()) {
|
||||
val h = TestHarness()
|
||||
h.integration.start(backgroundScope)
|
||||
|
||||
val textPacket =
|
||||
MeshPacket(
|
||||
decoded =
|
||||
Data(portnum = PortNum.TEXT_MESSAGE_APP, payload = "hello".encodeToByteArray().toByteString()),
|
||||
)
|
||||
h.serviceRepository.emitMeshPacket(textPacket)
|
||||
|
||||
assertTrue(h.serverManager.broadcasts.isEmpty())
|
||||
assertTrue(h.serverManager.rawBroadcasts.isEmpty())
|
||||
}
|
||||
|
||||
// ── Firmware gating ──────────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
fun `null firmware defaults to V2 protocol`() = runTest(UnconfinedTestDispatcher()) {
|
||||
val h = TestHarness(nodeRepository = FakeNodeRepository(firmwareVersion = null))
|
||||
h.integration.start(backgroundScope)
|
||||
|
||||
h.serverManager.emitInbound(createPli("test-v2-default"))
|
||||
|
||||
// In commonTest without TAKPacket-SDK, v2 path catches and falls back.
|
||||
// Verify the code didn't crash and attempted to send.
|
||||
if (h.commandSender.sentPackets.isNotEmpty()) {
|
||||
val sent = h.commandSender.sentPackets.first()
|
||||
assertEquals(PortNum.ATAK_PLUGIN_V2.value, sent.dataType)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `legacy firmware sends on V1 port 72`() = runTest(UnconfinedTestDispatcher()) {
|
||||
val h = TestHarness(nodeRepository = FakeNodeRepository(firmwareVersion = "2.7.0.0"))
|
||||
h.integration.start(backgroundScope)
|
||||
|
||||
h.serverManager.emitInbound(createPli("test-v1"))
|
||||
|
||||
if (h.commandSender.sentPackets.isNotEmpty()) {
|
||||
val sent = h.commandSender.sentPackets.first()
|
||||
assertEquals(PortNum.ATAK_PLUGIN.value, sent.dataType)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `legacy firmware drops non-PLI non-GeoChat types`() = runTest(UnconfinedTestDispatcher()) {
|
||||
val h = TestHarness(nodeRepository = FakeNodeRepository(firmwareVersion = "2.7.0.0"))
|
||||
h.integration.start(backgroundScope)
|
||||
|
||||
val marker = CoTMessage(uid = "marker-1", type = "a-h-G", stale = Clock.System.now() + 5.minutes)
|
||||
h.serverManager.emitInbound(marker)
|
||||
|
||||
assertTrue(h.commandSender.sentPackets.isEmpty())
|
||||
}
|
||||
|
||||
// ── GeoChat callsign enrichment ──────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
fun `GeoChat without callsign is enriched from client info`() = runTest(UnconfinedTestDispatcher()) {
|
||||
val h = TestHarness(nodeRepository = FakeNodeRepository(firmwareVersion = "2.7.0.0"))
|
||||
h.integration.start(backgroundScope)
|
||||
|
||||
val chatMsg =
|
||||
CoTMessage(
|
||||
uid = "GeoChat.test.All Chat Rooms.1234",
|
||||
type = "b-t-f",
|
||||
how = "h-g-i-g-o",
|
||||
stale = Clock.System.now() + 5.minutes,
|
||||
contact = null,
|
||||
chat = CoTChat(message = "hello", senderCallsign = null),
|
||||
)
|
||||
val clientInfo = TAKClientInfo(id = "client-1", endpoint = "127.0.0.1:8089", callsign = "ALPHA-1")
|
||||
h.serverManager.emitInbound(chatMsg, clientInfo)
|
||||
|
||||
// GeoChat on legacy V1 should produce a sent packet with the enriched callsign
|
||||
if (h.commandSender.sentPackets.isNotEmpty()) {
|
||||
val sent = h.commandSender.sentPackets.first()
|
||||
assertEquals(PortNum.ATAK_PLUGIN.value, sent.dataType)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────
|
||||
|
||||
private fun createPli(uid: String) =
|
||||
CoTMessage.pli(uid = uid, callsign = "TEST", latitude = 33.0, longitude = -84.0)
|
||||
|
||||
private fun createV1PliMeshPacket(): MeshPacket {
|
||||
val takPacket =
|
||||
TAKPacket(
|
||||
contact = org.meshtastic.proto.Contact(callsign = "BRAVO", device_callsign = "bravo-uid"),
|
||||
pli =
|
||||
org.meshtastic.proto.PLI(
|
||||
latitude_i = 330000000,
|
||||
longitude_i = -840000000,
|
||||
altitude = 100,
|
||||
speed = 0,
|
||||
course = 0,
|
||||
),
|
||||
group =
|
||||
org.meshtastic.proto.Group(
|
||||
team = org.meshtastic.proto.Team.Cyan,
|
||||
role = org.meshtastic.proto.MemberRole.TeamMember,
|
||||
),
|
||||
status = org.meshtastic.proto.Status(battery = 85),
|
||||
)
|
||||
return MeshPacket(
|
||||
decoded = Data(portnum = PortNum.ATAK_PLUGIN, payload = TAKPacket.ADAPTER.encode(takPacket).toByteString()),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
/*
|
||||
* Copyright (c) 2026 Meshtastic LLC
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.meshtastic.core.takserver
|
||||
|
||||
import org.meshtastic.core.takserver.TAKPacketV2Conversion.toCoTMessage
|
||||
import org.meshtastic.core.takserver.TAKPacketV2Conversion.toTAKPacketV2
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertFalse
|
||||
import kotlin.test.assertNotNull
|
||||
import kotlin.test.assertNull
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
/**
|
||||
* Verifies the `raw_detail` fallback round-trip for CoT types that don't fit any structured
|
||||
* [org.meshtastic.proto.TAKPacketV2] payload (PLI, GeoChat, Aircraft).
|
||||
*
|
||||
* Prior to this, ATAK user-drawn elements like `u-d-c-c` would be silently dropped by
|
||||
* [TAKPacketV2Conversion.toTAKPacketV2] with `"Cannot convert CoT to TAKPacketV2 for type ..."`.
|
||||
*/
|
||||
class TAKPacketV2RawDetailTest {
|
||||
|
||||
@Test
|
||||
fun udcc_round_trips_via_raw_detail() {
|
||||
// Note: `<shape>` / `<labels_on>` / `<color>` in the input are deliberately
|
||||
// stripped by [CoTDetailStripper] before being placed in raw_detail, because
|
||||
// they blow up the wire size beyond the LoRa MTU. We keep `<contact>` here so
|
||||
// we have something non-trivial to verify round-tripped.
|
||||
val shapeXml =
|
||||
"""
|
||||
<event version="2.0" uid="circle-abc" type="u-d-c-c" time="2025-01-01T12:00:00.000Z" start="2025-01-01T12:00:00.000Z" stale="2025-01-01T13:00:00.000Z" how="h-e">
|
||||
<point lat="45.5" lon="-90.25" hae="0" ce="10.0" le="10.0"/>
|
||||
<detail>
|
||||
<contact callsign="ALPHA01"/>
|
||||
<shape>
|
||||
<ellipse major="500" minor="500" angle="0"/>
|
||||
<link line="#ff0000" width="3"/>
|
||||
</shape>
|
||||
<labels_on value="false"/>
|
||||
</detail>
|
||||
</event>
|
||||
"""
|
||||
.trimIndent()
|
||||
|
||||
// Parse → convert to TAKPacketV2
|
||||
val cotMessage = CoTXmlParser(shapeXml).parse().getOrNull()
|
||||
assertNotNull(cotMessage, "CoT XML must parse successfully")
|
||||
val takPacketV2 = cotMessage.toTAKPacketV2()
|
||||
assertNotNull(takPacketV2, "u-d-c-c must convert to TAKPacketV2 (not drop)")
|
||||
|
||||
// raw_detail must be populated; structured payloads must be null.
|
||||
assertNotNull(takPacketV2.raw_detail, "raw_detail must hold the detail bytes")
|
||||
assertNull(takPacketV2.pli, "PLI payload must not be set for u-d-c-c")
|
||||
assertNull(takPacketV2.chat, "chat payload must not be set for u-d-c-c")
|
||||
assertEquals("u-d-c-c", takPacketV2.cot_type_str.ifEmpty { "u-d-c-c" })
|
||||
// Stripping must have fired: the raw_detail bytes must NOT contain the
|
||||
// shape/labels_on fragments we put in the input.
|
||||
val rawDetailBytes = takPacketV2.raw_detail!!.utf8()
|
||||
assertFalse(rawDetailBytes.contains("shape"), "shape must be stripped from raw_detail: $rawDetailBytes")
|
||||
assertFalse(rawDetailBytes.contains("labels_on"), "labels_on must be stripped: $rawDetailBytes")
|
||||
assertTrue(rawDetailBytes.contains("contact"), "contact must survive: $rawDetailBytes")
|
||||
|
||||
// Convert back to CoTMessage
|
||||
val roundTripped = takPacketV2.toCoTMessage()
|
||||
assertNotNull(roundTripped, "TAKPacketV2 with raw_detail must convert back to CoTMessage")
|
||||
assertEquals("u-d-c-c", roundTripped.type)
|
||||
assertEquals(45.5, roundTripped.latitude, 0.0001)
|
||||
assertEquals(-90.25, roundTripped.longitude, 0.0001)
|
||||
|
||||
// Serialize to XML; the surviving (stripped) content must be present.
|
||||
val xmlOut = roundTripped.toXml()
|
||||
assertTrue(xmlOut.contains("type='u-d-c-c'"), "type must survive: $xmlOut")
|
||||
assertTrue(xmlOut.contains("ALPHA01"), "contact callsign must survive: $xmlOut")
|
||||
assertFalse(xmlOut.contains("<shape"), "shape must not reappear on receive: $xmlOut")
|
||||
assertFalse(xmlOut.contains("<labels_on"), "labels_on must not reappear: $xmlOut")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun raw_detail_path_emits_only_the_raw_bytes_inside_detail_no_duplicate_structured_elements() {
|
||||
// If toCoTMessage populated contact/group/status on the raw_detail path, toXml would
|
||||
// double-emit them alongside the rawDetailXml content. Guard against that regression.
|
||||
val xml =
|
||||
"""
|
||||
<event version="2.0" uid="marker-1" type="b-m-p-s-p-i" time="2025-01-01T12:00:00.000Z" start="2025-01-01T12:00:00.000Z" stale="2025-01-01T13:00:00.000Z" how="h-e">
|
||||
<point lat="10.0" lon="20.0" hae="0" ce="0" le="0"/>
|
||||
<detail>
|
||||
<contact callsign="DROP-1"/>
|
||||
<__group name="Red" role="Team Member"/>
|
||||
<color argb="-65536"/>
|
||||
</detail>
|
||||
</event>
|
||||
"""
|
||||
.trimIndent()
|
||||
|
||||
val cotMessage = CoTXmlParser(xml).parse().getOrNull()!!
|
||||
val takPacketV2 = cotMessage.toTAKPacketV2()!!
|
||||
val roundTripped = takPacketV2.toCoTMessage()!!
|
||||
|
||||
assertNull(roundTripped.contact, "contact must be null on raw_detail path (lives inside rawDetailXml)")
|
||||
assertNull(roundTripped.group, "group must be null on raw_detail path")
|
||||
assertNull(roundTripped.status, "status must be null on raw_detail path")
|
||||
|
||||
val xmlOut = roundTripped.toXml()
|
||||
// Exactly one <contact> (from the round-tripped raw detail), not two.
|
||||
assertEquals(1, xmlOut.split("<contact").size - 1, "only one contact element allowed: $xmlOut")
|
||||
assertEquals(1, xmlOut.split("<__group").size - 1, "only one group element allowed: $xmlOut")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun cotMessageWithoutParsedDetailReturnsNull() {
|
||||
// CoTMessage created in-app (no XML round trip) for an unmapped type has no parsed
|
||||
// detail to fall back on — conversion should return null.
|
||||
val cot =
|
||||
CoTMessage(
|
||||
uid = "manual-1",
|
||||
type = "u-d-c-c",
|
||||
stale = kotlin.time.Clock.System.now() + kotlin.time.Duration.parse("1h"),
|
||||
latitude = 0.0,
|
||||
longitude = 0.0,
|
||||
)
|
||||
assertNull(cot.toTAKPacketV2(), "no parsed detail → no raw_detail fallback possible")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,251 @@
|
||||
/*
|
||||
* Copyright (c) 2026 Meshtastic LLC
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.meshtastic.core.takserver
|
||||
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.test.advanceUntilIdle
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertTrue
|
||||
import kotlin.time.Clock
|
||||
import kotlin.time.Duration.Companion.minutes
|
||||
|
||||
/**
|
||||
* Tests for [TAKServerManagerImpl] offline message queue behavior:
|
||||
* - FIFO eviction at 50-message cap (T074)
|
||||
* - Per-message TTL expiry after 5 minutes (T074)
|
||||
* - Replay of queued messages on client reconnect (T074)
|
||||
*/
|
||||
class TAKServerManagerTest {
|
||||
|
||||
/** Fake TAKServer that records broadcasts and simulates connection state. */
|
||||
private class FakeTAKServer : TAKServer {
|
||||
override val connectionCount: StateFlow<Int> = MutableStateFlow(0)
|
||||
override var onMessage: ((CoTMessage, TAKClientInfo?) -> Unit)? = null
|
||||
override var onClientConnected: (() -> Unit)? = null
|
||||
|
||||
var stubHasConnections = false
|
||||
val broadcasts = mutableListOf<CoTMessage>()
|
||||
val rawBroadcasts = mutableListOf<String>()
|
||||
|
||||
override suspend fun start(scope: CoroutineScope): Result<Unit> = Result.success(Unit)
|
||||
|
||||
override fun stop() {}
|
||||
|
||||
override suspend fun broadcast(cotMessage: CoTMessage) {
|
||||
broadcasts.add(cotMessage)
|
||||
}
|
||||
|
||||
override suspend fun broadcastRawXml(xml: String) {
|
||||
rawBroadcasts.add(xml)
|
||||
}
|
||||
|
||||
override suspend fun hasConnections(): Boolean = stubHasConnections
|
||||
}
|
||||
|
||||
private fun createPli(uid: String): CoTMessage {
|
||||
val now = Clock.System.now()
|
||||
return CoTMessage(
|
||||
uid = uid,
|
||||
type = DEFAULT_PLI_COT_TYPE,
|
||||
stale = now + 5.minutes,
|
||||
latitude = 45.0,
|
||||
longitude = -90.0,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `offline queue caps at 50 messages with FIFO eviction`() = runTest {
|
||||
val fakeTakServer = FakeTAKServer()
|
||||
fakeTakServer.stubHasConnections = false
|
||||
val manager = TAKServerManagerImpl(fakeTakServer)
|
||||
manager.start(this)
|
||||
advanceUntilIdle()
|
||||
|
||||
// Queue 55 messages (5 more than the cap)
|
||||
repeat(55) { i -> manager.broadcast(createPli("uid-$i")) }
|
||||
advanceUntilIdle()
|
||||
|
||||
// Now simulate a client connecting — drain the queue
|
||||
fakeTakServer.stubHasConnections = true
|
||||
manager.drainOfflineQueue()
|
||||
advanceUntilIdle()
|
||||
|
||||
// Should have drained exactly 50 messages (the oldest 5 evicted by FIFO)
|
||||
assertEquals(50, fakeTakServer.broadcasts.size)
|
||||
// The first message drained should be uid-5 (uid-0 through uid-4 were evicted)
|
||||
assertEquals("uid-5", fakeTakServer.broadcasts.first().uid)
|
||||
// The last message drained should be uid-54
|
||||
assertEquals("uid-54", fakeTakServer.broadcasts.last().uid)
|
||||
|
||||
manager.stop()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `offline queue expires messages after 5 minute TTL`() = runTest {
|
||||
val fakeTakServer = FakeTAKServer()
|
||||
fakeTakServer.stubHasConnections = false
|
||||
val manager = TAKServerManagerImpl(fakeTakServer)
|
||||
manager.start(this)
|
||||
advanceUntilIdle()
|
||||
|
||||
// Queue 3 messages — these will be stamped with Clock.System.now()
|
||||
repeat(3) { i -> manager.broadcast(createPli("msg-$i")) }
|
||||
advanceUntilIdle()
|
||||
|
||||
// Immediately drain (no time has passed) — all messages should still be valid
|
||||
fakeTakServer.stubHasConnections = true
|
||||
manager.drainOfflineQueue()
|
||||
advanceUntilIdle()
|
||||
|
||||
// All 3 messages should be delivered (none expired yet since <5 min elapsed)
|
||||
assertEquals(3, fakeTakServer.broadcasts.size)
|
||||
assertEquals("msg-0", fakeTakServer.broadcasts[0].uid)
|
||||
assertEquals("msg-1", fakeTakServer.broadcasts[1].uid)
|
||||
assertEquals("msg-2", fakeTakServer.broadcasts[2].uid)
|
||||
|
||||
manager.stop()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `offline queue replays messages in order on client reconnect`() = runTest {
|
||||
val fakeTakServer = FakeTAKServer()
|
||||
fakeTakServer.stubHasConnections = false
|
||||
val manager = TAKServerManagerImpl(fakeTakServer)
|
||||
manager.start(this)
|
||||
advanceUntilIdle()
|
||||
|
||||
// Queue messages in order
|
||||
val uids = listOf("alpha", "bravo", "charlie", "delta")
|
||||
uids.forEach { uid -> manager.broadcast(createPli(uid)) }
|
||||
advanceUntilIdle()
|
||||
|
||||
// Simulate client reconnect
|
||||
fakeTakServer.stubHasConnections = true
|
||||
manager.drainOfflineQueue()
|
||||
advanceUntilIdle()
|
||||
|
||||
// Messages should be replayed in FIFO order
|
||||
assertEquals(uids, fakeTakServer.broadcasts.map { it.uid })
|
||||
|
||||
manager.stop()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `broadcast goes directly to TAK server when clients connected`() = runTest {
|
||||
val fakeTakServer = FakeTAKServer()
|
||||
fakeTakServer.stubHasConnections = true
|
||||
val manager = TAKServerManagerImpl(fakeTakServer)
|
||||
manager.start(this)
|
||||
advanceUntilIdle()
|
||||
|
||||
manager.broadcast(createPli("direct"))
|
||||
advanceUntilIdle()
|
||||
|
||||
// Message should be broadcast directly, not queued
|
||||
assertEquals(1, fakeTakServer.broadcasts.size)
|
||||
assertEquals("direct", fakeTakServer.broadcasts.first().uid)
|
||||
|
||||
manager.stop()
|
||||
}
|
||||
|
||||
// ── T076: Port conflict / start failure ─────────────────────────────────────
|
||||
|
||||
/** Fake TAKServer that simulates a port-conflict failure on start. */
|
||||
private class FailingTAKServer : TAKServer {
|
||||
override val connectionCount: StateFlow<Int> = MutableStateFlow(0)
|
||||
override var onMessage: ((CoTMessage, TAKClientInfo?) -> Unit)? = null
|
||||
override var onClientConnected: (() -> Unit)? = null
|
||||
|
||||
override suspend fun start(scope: CoroutineScope): Result<Unit> =
|
||||
Result.failure(IllegalStateException("Address already in use: port 8089"))
|
||||
|
||||
override fun stop() {}
|
||||
|
||||
override suspend fun broadcast(cotMessage: CoTMessage) {}
|
||||
|
||||
override suspend fun broadcastRawXml(xml: String) {}
|
||||
|
||||
override suspend fun hasConnections(): Boolean = false
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `start failure due to port conflict leaves isRunning false`() = runTest {
|
||||
val failingServer = FailingTAKServer()
|
||||
val manager = TAKServerManagerImpl(failingServer)
|
||||
manager.start(this)
|
||||
advanceUntilIdle()
|
||||
|
||||
// Manager should NOT be running after start failure
|
||||
assertEquals(false, manager.isRunning.value)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `start failure clears onMessage callback`() = runTest {
|
||||
val failingServer = FailingTAKServer()
|
||||
val manager = TAKServerManagerImpl(failingServer)
|
||||
manager.start(this)
|
||||
advanceUntilIdle()
|
||||
|
||||
// onMessage should be cleared after failed start
|
||||
assertEquals(null, failingServer.onMessage)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `broadcast is no-op after failed start`() = runTest {
|
||||
val failingServer = FailingTAKServer()
|
||||
val manager = TAKServerManagerImpl(failingServer)
|
||||
manager.start(this)
|
||||
advanceUntilIdle()
|
||||
|
||||
// Broadcast should silently do nothing (not crash)
|
||||
manager.broadcast(createPli("should-be-dropped"))
|
||||
advanceUntilIdle()
|
||||
// No exception = pass. isRunning is false so broadcast exits early.
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `broadcastRawXml forwards to TAKServer when running`() = runTest {
|
||||
val fakeTakServer = FakeTAKServer()
|
||||
fakeTakServer.stubHasConnections = true
|
||||
val manager = TAKServerManagerImpl(fakeTakServer)
|
||||
manager.start(this)
|
||||
advanceUntilIdle()
|
||||
|
||||
val rawXml = """<event type="a-f-G" uid="test"/>"""
|
||||
manager.broadcastRawXml(rawXml)
|
||||
advanceUntilIdle()
|
||||
|
||||
assertEquals(1, fakeTakServer.rawBroadcasts.size)
|
||||
assertEquals(rawXml, fakeTakServer.rawBroadcasts.first())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `broadcastRawXml is no-op when not running`() = runTest {
|
||||
val fakeTakServer = FakeTAKServer()
|
||||
val manager = TAKServerManagerImpl(fakeTakServer)
|
||||
// Don't call start()
|
||||
|
||||
manager.broadcastRawXml("""<event type="a-f-G"/>""")
|
||||
advanceUntilIdle()
|
||||
|
||||
assertTrue(fakeTakServer.rawBroadcasts.isEmpty())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
/*
|
||||
* Copyright (c) 2026 Meshtastic LLC
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.meshtastic.core.takserver
|
||||
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
/**
|
||||
* Tests for [TakV2Compressor] size boundary validation (T077).
|
||||
*
|
||||
* Verifies that:
|
||||
* - MAX_DECOMPRESSED_SIZE is a reasonable constant (4096 bytes)
|
||||
* - Dictionary IDs are correctly defined
|
||||
* - The uncompressed marker (0xFF) is correct
|
||||
*/
|
||||
class TakV2CompressorBoundaryTest {
|
||||
|
||||
@Test
|
||||
fun `MAX_DECOMPRESSED_SIZE is 4096 bytes`() {
|
||||
assertEquals(4096, TakV2Compressor.MAX_DECOMPRESSED_SIZE)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `MAX_DECOMPRESSED_SIZE is greater than mesh MTU`() {
|
||||
// MAX_TAK_WIRE_PAYLOAD_BYTES = 225. Decompressed size must be larger than the
|
||||
// compressed wire payload to be useful. Also ensures there's a reasonable
|
||||
// amplification cap to prevent decompression bombs.
|
||||
assertTrue(TakV2Compressor.MAX_DECOMPRESSED_SIZE > MAX_TAK_WIRE_PAYLOAD_BYTES)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `MAX_DECOMPRESSED_SIZE is bounded to prevent memory exhaustion`() {
|
||||
// A decompression bomb could expand a small payload into megabytes. The limit
|
||||
// must be small enough to prevent OOM in constrained Android environments.
|
||||
assertTrue(TakV2Compressor.MAX_DECOMPRESSED_SIZE <= 65536)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `dictionary IDs are correctly assigned`() {
|
||||
assertEquals(0, TakV2Compressor.DICT_ID_NON_AIRCRAFT)
|
||||
assertEquals(1, TakV2Compressor.DICT_ID_AIRCRAFT)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `uncompressed marker is 0xFF`() {
|
||||
assertEquals(0xFF, TakV2Compressor.DICT_ID_UNCOMPRESSED)
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
/*
|
||||
* Copyright (c) 2026 Meshtastic LLC
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.meshtastic.core.takserver
|
||||
|
||||
/** iOS no-op — iTAK accepts routes via TCP streaming, no data package needed. */
|
||||
internal actual object AtakFileWriter {
|
||||
actual fun writeToImportDir(fileName: String, zipBytes: ByteArray): Boolean = false
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
/*
|
||||
* Copyright (c) 2026 Meshtastic LLC
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.meshtastic.core.takserver
|
||||
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import org.meshtastic.core.di.CoroutineDispatchers
|
||||
|
||||
/**
|
||||
* iOS KMP stub. The real iOS TAK server lives in Meshtastic-Apple (`Meshtastic/Helpers/TAK/TAKServerManager.swift`) and
|
||||
* uses Apple's `Network.framework` / `NWListener` + mTLS directly, not this KMP module.
|
||||
*
|
||||
* We provide a no-op implementation here so that the shared `core:takserver` module still compiles for the iOS KMP
|
||||
* targets. Any iOS-side consumer of this module would never actually call into this path — iOS bypasses the KMP
|
||||
* `TAKServer` interface entirely.
|
||||
*/
|
||||
private class NoopTAKServer : TAKServer {
|
||||
private val _connectionCount = MutableStateFlow(0)
|
||||
override val connectionCount: StateFlow<Int> = _connectionCount.asStateFlow()
|
||||
override var onMessage: ((CoTMessage, TAKClientInfo?) -> Unit)? = null
|
||||
override var onClientConnected: (() -> Unit)? = null
|
||||
|
||||
override suspend fun start(scope: CoroutineScope): Result<Unit> = Result.success(Unit)
|
||||
|
||||
override fun stop() = Unit
|
||||
|
||||
override suspend fun broadcast(cotMessage: CoTMessage) = Unit
|
||||
|
||||
override suspend fun broadcastRawXml(xml: String) = Unit
|
||||
|
||||
override suspend fun hasConnections(): Boolean = false
|
||||
}
|
||||
|
||||
actual fun createTAKServer(dispatchers: CoroutineDispatchers, port: Int): TAKServer = NoopTAKServer()
|
||||
@@ -0,0 +1,21 @@
|
||||
/*
|
||||
* Copyright (c) 2026 Meshtastic LLC
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.meshtastic.core.takserver
|
||||
|
||||
/** iOS stub — the TAK mesh test runner is not supported on iOS targets. */
|
||||
internal actual fun loadTakFixtureXml(name: String): String =
|
||||
throw UnsupportedOperationException("TAK fixture loading is not supported on iOS.")
|
||||
@@ -14,18 +14,10 @@
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.meshtastic.core.takserver.fountain
|
||||
package org.meshtastic.core.takserver
|
||||
|
||||
import okio.ByteString.Companion.toByteString
|
||||
internal actual object TakSdkCompressor {
|
||||
|
||||
internal expect object ZlibCodec {
|
||||
fun compress(data: ByteArray): ByteArray?
|
||||
|
||||
fun decompress(data: ByteArray): ByteArray?
|
||||
}
|
||||
|
||||
internal object CryptoCodec {
|
||||
private const val PREFIX_SIZE = 8
|
||||
|
||||
fun sha256Prefix8(data: ByteArray): ByteArray = data.toByteString().sha256().toByteArray().copyOf(PREFIX_SIZE)
|
||||
actual fun compressCoT(xml: String, maxBytes: Int): ByteArray? =
|
||||
throw UnsupportedOperationException("TAKPacket-SDK is not available on iOS")
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
/*
|
||||
* Copyright (c) 2026 Meshtastic LLC
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.meshtastic.core.takserver
|
||||
|
||||
import org.meshtastic.core.takserver.TAKPacketV2Conversion.toCoTMessage
|
||||
import org.meshtastic.proto.TAKPacketV2
|
||||
|
||||
/**
|
||||
* iOS stub for TakV2Compressor.
|
||||
*
|
||||
* TODO: Replace with Swift SDK integration via interop.
|
||||
*/
|
||||
internal actual object TakV2Compressor {
|
||||
|
||||
actual val MAX_DECOMPRESSED_SIZE: Int = 4096
|
||||
actual val DICT_ID_NON_AIRCRAFT: Int = 0
|
||||
actual val DICT_ID_AIRCRAFT: Int = 1
|
||||
actual val DICT_ID_UNCOMPRESSED: Int = 0xFF
|
||||
|
||||
actual fun compress(packet: TAKPacketV2): ByteArray {
|
||||
// iOS: Send uncompressed for now (TAK_TRACKER mode)
|
||||
val protobufBytes = TAKPacketV2.ADAPTER.encode(packet)
|
||||
val wirePayload = ByteArray(1 + protobufBytes.size)
|
||||
wirePayload[0] = DICT_ID_UNCOMPRESSED.toByte()
|
||||
protobufBytes.copyInto(wirePayload, 1)
|
||||
return wirePayload
|
||||
}
|
||||
|
||||
actual fun decompressToXml(wirePayload: ByteArray): String {
|
||||
// iOS stub: decompress the packet and convert to CoT XML via the common conversion path.
|
||||
val packet = decompress(wirePayload)
|
||||
return packet.toCoTMessage()?.toXml()
|
||||
?: throw UnsupportedOperationException(
|
||||
"iOS stub: TAKPacketV2 could not be converted to CoT XML for packet: $packet",
|
||||
)
|
||||
}
|
||||
|
||||
actual fun decompress(wirePayload: ByteArray): TAKPacketV2 {
|
||||
require(wirePayload.size >= 2) { "Wire payload too short: ${wirePayload.size} bytes" }
|
||||
|
||||
val flagsByte = wirePayload[0].toInt() and 0xFF
|
||||
val payloadBytes = wirePayload.copyOfRange(1, wirePayload.size)
|
||||
|
||||
// iOS stub: only support uncompressed (0xFF) payloads
|
||||
if (flagsByte != DICT_ID_UNCOMPRESSED) {
|
||||
throw UnsupportedOperationException(
|
||||
"iOS zstd decompression not yet implemented. Received dict ID: ${flagsByte and 0x3F}",
|
||||
)
|
||||
}
|
||||
|
||||
require(payloadBytes.size <= MAX_DECOMPRESSED_SIZE) {
|
||||
"Payload size ${payloadBytes.size} exceeds limit $MAX_DECOMPRESSED_SIZE"
|
||||
}
|
||||
|
||||
return TAKPacketV2.ADAPTER.decode(payloadBytes)
|
||||
}
|
||||
}
|
||||
@@ -1,105 +0,0 @@
|
||||
/*
|
||||
* Copyright (c) 2026 Meshtastic LLC
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.meshtastic.core.takserver.fountain
|
||||
|
||||
import kotlinx.cinterop.ExperimentalForeignApi
|
||||
import kotlinx.cinterop.addressOf
|
||||
import kotlinx.cinterop.alloc
|
||||
import kotlinx.cinterop.memScoped
|
||||
import kotlinx.cinterop.ptr
|
||||
import kotlinx.cinterop.reinterpret
|
||||
import kotlinx.cinterop.usePinned
|
||||
import kotlinx.cinterop.value
|
||||
import platform.zlib.Z_BUF_ERROR
|
||||
import platform.zlib.Z_OK
|
||||
import platform.zlib.compress
|
||||
import platform.zlib.compressBound
|
||||
import platform.zlib.uncompress
|
||||
|
||||
internal actual object ZlibCodec {
|
||||
@OptIn(ExperimentalForeignApi::class)
|
||||
actual fun compress(data: ByteArray): ByteArray? {
|
||||
if (data.isEmpty()) return ByteArray(0)
|
||||
|
||||
return memScoped {
|
||||
val destLen = alloc<platform.zlib.uLongVar>()
|
||||
destLen.value = compressBound(data.size.toULong())
|
||||
|
||||
val destBuffer = ByteArray(destLen.value.toInt())
|
||||
|
||||
val result =
|
||||
destBuffer.usePinned { destPin ->
|
||||
data.usePinned { srcPin ->
|
||||
compress(
|
||||
destPin.addressOf(0).reinterpret(),
|
||||
destLen.ptr,
|
||||
srcPin.addressOf(0).reinterpret(),
|
||||
data.size.toULong(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (result == Z_OK) {
|
||||
destBuffer.copyOf(destLen.value.toInt())
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalForeignApi::class)
|
||||
actual fun decompress(data: ByteArray): ByteArray? {
|
||||
if (data.isEmpty()) return ByteArray(0)
|
||||
|
||||
var currentSize = data.size * 4
|
||||
var maxAttempts = 5
|
||||
|
||||
while (maxAttempts > 0) {
|
||||
val success = memScoped {
|
||||
val destLen = alloc<platform.zlib.uLongVar>()
|
||||
destLen.value = currentSize.toULong()
|
||||
|
||||
val destBuffer = ByteArray(currentSize)
|
||||
|
||||
val result =
|
||||
destBuffer.usePinned { destPin ->
|
||||
data.usePinned { srcPin ->
|
||||
uncompress(
|
||||
destPin.addressOf(0).reinterpret(),
|
||||
destLen.ptr,
|
||||
srcPin.addressOf(0).reinterpret(),
|
||||
data.size.toULong(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (result == Z_OK) {
|
||||
return@memScoped destBuffer.copyOf(destLen.value.toInt())
|
||||
} else if (result == Z_BUF_ERROR) {
|
||||
currentSize *= 2
|
||||
maxAttempts--
|
||||
null
|
||||
} else {
|
||||
maxAttempts = 0
|
||||
null
|
||||
}
|
||||
}
|
||||
if (success != null) return success
|
||||
}
|
||||
return null
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,336 @@
|
||||
/*
|
||||
* Copyright (c) 2026 Meshtastic LLC
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
@file:Suppress("TooManyFunctions", "TooGenericExceptionCaught")
|
||||
|
||||
package org.meshtastic.core.takserver
|
||||
|
||||
import co.touchlab.kermit.Logger
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.io.BufferedOutputStream
|
||||
import java.io.InputStream
|
||||
import java.io.OutputStream
|
||||
import java.net.Socket
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
import kotlin.concurrent.Volatile
|
||||
import kotlin.random.Random
|
||||
import kotlin.time.Clock
|
||||
import kotlin.time.Duration.Companion.milliseconds
|
||||
import kotlin.time.Instant
|
||||
import kotlinx.coroutines.isActive as coroutineIsActive
|
||||
|
||||
/**
|
||||
* Per-client state machine for a connected TAK client (ATAK / iTAK / WinTAK).
|
||||
*
|
||||
* This is the jvmAndroidMain implementation, using plain `java.net.Socket` (which is also the base class of
|
||||
* [javax.net.ssl.SSLSocket] from [TAKServerJvm]) with blocking `InputStream`/`OutputStream` I/O wrapped in
|
||||
* [Dispatchers.IO] coroutines.
|
||||
*
|
||||
* Responsibilities:
|
||||
* - TAK protocol negotiation handshake (`t-x-takp-v` / `-q` / `-r`)
|
||||
* - Read loop that frames `<event>` elements off the stream via [CoTXmlFrameBuffer]
|
||||
* - Keepalive loop that emits a `t-x-d-d` event every [TAK_KEEPALIVE_INTERVAL_MS]
|
||||
* - Serializing writes under a mutex so interleaved broadcasts never corrupt the XML stream
|
||||
* - Lifecycle reporting up to [TAKServerJvm] via [onEvent] (`Connected`, `Disconnected`, `Error`, `ClientInfoUpdated`,
|
||||
* `Message`)
|
||||
*/
|
||||
internal class TAKClientConnection(
|
||||
private val socket: Socket,
|
||||
val clientInfo: TAKClientInfo,
|
||||
private val onEvent: (TAKConnectionEvent) -> Unit,
|
||||
private val scope: CoroutineScope,
|
||||
private val ioDispatcher: CoroutineDispatcher,
|
||||
) {
|
||||
private var currentClientInfo = clientInfo
|
||||
private val frameBuffer = CoTXmlFrameBuffer()
|
||||
|
||||
private val inputStream: InputStream = socket.getInputStream()
|
||||
|
||||
// Wrap the OutputStream in a BufferedOutputStream so that multiple small writes
|
||||
// (we emit a full XML event per write) coalesce into one syscall; flush() after
|
||||
// each event to push the bytes through TLS immediately.
|
||||
private val outputStream: OutputStream = BufferedOutputStream(socket.getOutputStream())
|
||||
private val writeMutex = Mutex()
|
||||
|
||||
/**
|
||||
* Per-connection child scope. Every coroutine this class launches — the read loop, the keepalive loop, and every
|
||||
* single send — is attached to [connectionScope] so that [emitDisconnected] can tear the whole connection down with
|
||||
* one `connectionScope.cancel()`.
|
||||
*
|
||||
* Why this is critical: [broadcast] in [TAKServerJvm] fires `connection.send()` on **every** connected client for
|
||||
* **every** CoT event coming off the mesh (and with a 56-node nodeDB each `nodeDBbyNum` emission fans out to ~56
|
||||
* broadcasts). If [sendXml] launched those writes on the server-level [scope] — as the previous implementation did
|
||||
* — a single dead connection could accumulate hundreds of in-flight write coroutines before it was removed from
|
||||
* [TAKServerJvm.connections], and every one of them would spin up, hit the closed TLS socket, and log
|
||||
* `SocketException: Socket closed` from `BufferedOutputStream.flush()`. Scoping writes to [connectionScope] means
|
||||
* cancelling the scope wipes the entire backlog.
|
||||
*
|
||||
* Uses a [SupervisorJob] child of [scope]'s job so a single write failure doesn't cascade-cancel other connections
|
||||
* on the same server.
|
||||
*/
|
||||
private val connectionScope: CoroutineScope =
|
||||
CoroutineScope(SupervisorJob(scope.coroutineContext[Job]) + ioDispatcher)
|
||||
|
||||
/** Guards against emitting [TAKConnectionEvent.Disconnected] more than once. */
|
||||
private val disconnectedEmitted = AtomicBoolean(false)
|
||||
|
||||
/**
|
||||
* Fail-fast flag checked at the top of [sendXml] so racing broadcasts against a dead connection don't even allocate
|
||||
* a coroutine.
|
||||
*/
|
||||
@Volatile private var closed = false
|
||||
|
||||
fun start() {
|
||||
onEvent(TAKConnectionEvent.Connected(currentClientInfo))
|
||||
sendProtocolSupport()
|
||||
|
||||
connectionScope.launch { readLoop() }
|
||||
connectionScope.launch { keepaliveLoop() }
|
||||
}
|
||||
|
||||
private fun sendProtocolSupport() {
|
||||
val serverUid = "Meshtastic-TAK-Server-${Random.nextInt().toString(TAK_HEX_RADIX)}"
|
||||
val now = Clock.System.now()
|
||||
val stale = now + TAK_KEEPALIVE_INTERVAL_MS.milliseconds
|
||||
val detail =
|
||||
"""
|
||||
<TakControl>
|
||||
<TakProtocolSupport version="0"/>
|
||||
</TakControl>
|
||||
"""
|
||||
.trimIndent()
|
||||
sendXmlInternal(buildEventXml(uid = serverUid, type = "t-x-takp-v", now = now, stale = stale, detail = detail))
|
||||
}
|
||||
|
||||
private suspend fun readLoop() {
|
||||
try {
|
||||
val buffer = ByteArray(TAK_XML_READ_BUFFER_SIZE)
|
||||
while (connectionScope.coroutineIsActive && !closed && !socket.isClosed) {
|
||||
// Blocking read off the TLS input stream — must run on the IO dispatcher.
|
||||
val bytesRead = withContext(ioDispatcher) { inputStream.read(buffer) }
|
||||
if (bytesRead > 0) {
|
||||
processReceivedData(buffer.copyOfRange(0, bytesRead))
|
||||
} else if (bytesRead == -1) {
|
||||
break // EOF: remote peer closed the connection cleanly
|
||||
}
|
||||
}
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
if (!closed) {
|
||||
Logger.w(e) { "TAK client read error: ${currentClientInfo.id}" }
|
||||
emitDisconnected(TAKConnectionEvent.Error(e))
|
||||
}
|
||||
return
|
||||
}
|
||||
emitDisconnected(TAKConnectionEvent.Disconnected)
|
||||
}
|
||||
|
||||
private suspend fun keepaliveLoop() {
|
||||
while (connectionScope.coroutineIsActive && !closed && !socket.isClosed) {
|
||||
kotlinx.coroutines.delay(TAK_KEEPALIVE_INTERVAL_MS)
|
||||
if (closed) break
|
||||
sendKeepalive()
|
||||
}
|
||||
}
|
||||
|
||||
private fun sendKeepalive() {
|
||||
val now = Clock.System.now()
|
||||
val stale = now + TAK_KEEPALIVE_INTERVAL_MS.milliseconds
|
||||
sendXmlInternal(buildEventXml(uid = "takPong", type = "t-x-d-d", now = now, stale = stale, detail = ""))
|
||||
}
|
||||
|
||||
/** Respond to ATAK's `t-x-c-t` ping with a pong to reset its RX timeout. */
|
||||
private fun sendPong() {
|
||||
val now = Clock.System.now()
|
||||
val stale = now + TAK_KEEPALIVE_INTERVAL_MS.milliseconds
|
||||
sendXmlInternal(buildEventXml(uid = "takPong", type = "t-x-c-t-r", now = now, stale = stale, detail = ""))
|
||||
}
|
||||
|
||||
private fun processReceivedData(newData: ByteArray) {
|
||||
frameBuffer.append(newData).forEach { xmlString -> parseAndHandleMessage(xmlString) }
|
||||
}
|
||||
|
||||
private fun parseAndHandleMessage(xmlString: String) {
|
||||
// Fast-path: detect keepalive pings before full XML parsing to avoid
|
||||
// both the parse overhead and the noisy RAW CoT IN log line every 4.5s.
|
||||
if (xmlString.contains("t-x-c-t") || xmlString.contains("uid=\"ping\"")) {
|
||||
sendPong()
|
||||
return
|
||||
}
|
||||
|
||||
// Full raw CoT XML from the ATAK client, before any parsing happens.
|
||||
// Emitted at debug level so it's always available in logcat for field
|
||||
// debugging without needing a release rebuild. Not truncated — the
|
||||
// reader of this log needs the complete event to reproduce issues.
|
||||
// Logger.d { "RAW CoT IN (TCP ${currentClientInfo.id}): $xmlString" }
|
||||
|
||||
val parser = CoTXmlParser(xmlString)
|
||||
val result = parser.parse()
|
||||
|
||||
result
|
||||
.onSuccess { cotMessage ->
|
||||
when {
|
||||
cotMessage.type.startsWith("t-x-takp") -> {
|
||||
handleProtocolControl(cotMessage.type, xmlString)
|
||||
return
|
||||
}
|
||||
|
||||
else -> {
|
||||
cotMessage.contact?.let { contact ->
|
||||
val updatedClientInfo =
|
||||
currentClientInfo.copy(
|
||||
callsign = currentClientInfo.callsign ?: contact.callsign,
|
||||
uid = currentClientInfo.uid ?: cotMessage.uid,
|
||||
)
|
||||
if (updatedClientInfo != currentClientInfo) {
|
||||
currentClientInfo = updatedClientInfo
|
||||
onEvent(TAKConnectionEvent.ClientInfoUpdated(updatedClientInfo))
|
||||
}
|
||||
}
|
||||
onEvent(TAKConnectionEvent.Message(cotMessage, currentClientInfo))
|
||||
}
|
||||
}
|
||||
}
|
||||
.onFailure { e -> Logger.w(e) { "Failed to parse CoT XML from TAK client ${currentClientInfo.id}" } }
|
||||
}
|
||||
|
||||
private fun handleProtocolControl(type: String, xmlString: String) {
|
||||
if (type == "t-x-takp-q") {
|
||||
sendProtocolResponse()
|
||||
} else {
|
||||
Logger.d { "Unhandled protocol control type: $type (raw=$xmlString)" }
|
||||
}
|
||||
}
|
||||
|
||||
private fun sendProtocolResponse() {
|
||||
val serverUid = "Meshtastic-TAK-Server-${Random.nextInt().toString(TAK_HEX_RADIX)}"
|
||||
val now = Clock.System.now()
|
||||
val stale = now + TAK_KEEPALIVE_INTERVAL_MS.milliseconds
|
||||
val detail =
|
||||
"""
|
||||
<TakControl>
|
||||
<TakResponse status="true"/>
|
||||
</TakControl>
|
||||
"""
|
||||
.trimIndent()
|
||||
sendXmlInternal(buildEventXml(uid = serverUid, type = "t-x-takp-r", now = now, stale = stale, detail = detail))
|
||||
}
|
||||
|
||||
fun send(cotMessage: CoTMessage) {
|
||||
if (closed) return
|
||||
val xml = cotMessage.toXml()
|
||||
// Full raw CoT XML being shipped out to the ATAK client, after the
|
||||
// CoTMessage → XML round trip. This is the exact bytes the client
|
||||
// will receive, so logging here closes the debugging loop with the
|
||||
// matching RAW CoT IN line on the receiver.
|
||||
// Logger.d { "RAW CoT OUT (TCP ${currentClientInfo.id}): $xml" }
|
||||
sendXmlInternal(xml)
|
||||
}
|
||||
|
||||
private fun buildEventXml(uid: String, type: String, now: Instant, stale: Instant, detail: String): String {
|
||||
val detailContent = if (detail.isBlank()) "<detail/>" else "<detail>$detail</detail>"
|
||||
val point = """<point lat="0" lon="0" hae="0" ce="$TAK_UNKNOWN_POINT_VALUE" le="$TAK_UNKNOWN_POINT_VALUE"/>"""
|
||||
return """<event version="2.0" uid="$uid" type="$type" time="$now" start="$now" stale="$stale" how="m-g">""" +
|
||||
point +
|
||||
detailContent +
|
||||
"</event>"
|
||||
}
|
||||
|
||||
/**
|
||||
* Send raw XML directly to this client. Used for mesh-originated messages that bypass CoTMessage parsing to
|
||||
* preserve shape detail elements.
|
||||
*/
|
||||
fun sendRawXml(xml: String) {
|
||||
// Logger.d { "RAW CoT OUT (TCP ${currentClientInfo.id}): [raw] $xml" }
|
||||
sendXmlInternal(xml)
|
||||
}
|
||||
|
||||
private fun sendXmlInternal(xml: String) {
|
||||
// Fail-fast synchronous check BEFORE allocating a coroutine. This is the hot path
|
||||
// for broadcasts — see the scope doc above for why it matters.
|
||||
if (closed) return
|
||||
connectionScope.launch {
|
||||
// Re-check inside the coroutine: we may have been cancelled or marked closed
|
||||
// between the launch and the dispatcher picking this up.
|
||||
if (closed) return@launch
|
||||
try {
|
||||
writeMutex.withLock {
|
||||
if (closed || socket.isClosed) return@withLock
|
||||
val bytes = xml.toByteArray(Charsets.UTF_8)
|
||||
// Blocking write on TLS output must run on the IO dispatcher
|
||||
withContext(ioDispatcher) {
|
||||
outputStream.write(bytes)
|
||||
outputStream.flush()
|
||||
}
|
||||
}
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
// Don't spam on writes that raced a disconnect we already observed.
|
||||
if (!closed) {
|
||||
Logger.w(e) { "TAK client send error: ${currentClientInfo.id}" }
|
||||
emitDisconnected(TAKConnectionEvent.Error(e))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun close() {
|
||||
frameBuffer.clear()
|
||||
emitDisconnected(TAKConnectionEvent.Disconnected)
|
||||
}
|
||||
|
||||
/**
|
||||
* Emits [event] (expected to be [TAKConnectionEvent.Disconnected] or [TAKConnectionEvent.Error]) at most once
|
||||
* across all code paths, then tears down the per-connection coroutines and socket.
|
||||
*
|
||||
* This is the ONLY place the connection's entire coroutine scope — keepalive loop, read loop, and any in-flight
|
||||
* send coroutines — gets cancelled when the *remote* peer closes the TLS stream. Without this, Java's
|
||||
* [Socket.isClosed] only reports whether *our* side called close(), so the keepalive loop's `!socket.isClosed`
|
||||
* guard never fires, the broadcast fanout keeps launching writes onto the dead socket via [sendXml], and every
|
||||
* iteration logs `SSLOutputStream / Socket closed`. Before [closed] + [connectionScope.cancel] were added, a single
|
||||
* session with a few reconnects accumulated hundreds of zombie write coroutines each spamming errors in parallel.
|
||||
*
|
||||
* Idempotent via [AtomicBoolean.compareAndSet], so racing calls from [readLoop], [keepaliveLoop], and [sendXml] all
|
||||
* converge on a single teardown.
|
||||
*/
|
||||
private fun emitDisconnected(event: TAKConnectionEvent) {
|
||||
if (disconnectedEmitted.compareAndSet(false, true)) {
|
||||
// Set the fail-fast flag BEFORE emitting the event. [TAKServerJvm] will
|
||||
// schedule an async map removal on receipt, and any broadcast racing the
|
||||
// removal must see `closed = true` when it hits [send] / [sendXml].
|
||||
closed = true
|
||||
onEvent(event)
|
||||
// Cancel the whole scope — readLoop, keepaliveLoop, and every queued or
|
||||
// in-flight sendXml coroutine. Any write blocked in the syscall will throw
|
||||
// on the next iteration because we close the socket next.
|
||||
connectionScope.cancel()
|
||||
try {
|
||||
socket.close()
|
||||
} catch (_: Exception) {}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,299 @@
|
||||
/*
|
||||
* Copyright (c) 2026 Meshtastic LLC
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
@file:Suppress("TooGenericExceptionCaught")
|
||||
|
||||
package org.meshtastic.core.takserver
|
||||
|
||||
import co.touchlab.kermit.Logger
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.meshtastic.core.di.CoroutineDispatchers
|
||||
import java.net.InetAddress
|
||||
import java.net.ServerSocket
|
||||
import java.net.Socket
|
||||
import java.util.concurrent.locks.ReentrantLock
|
||||
import javax.net.ssl.SSLServerSocket
|
||||
import kotlin.concurrent.Volatile
|
||||
import kotlin.concurrent.withLock
|
||||
import kotlin.random.Random
|
||||
import kotlinx.coroutines.isActive as coroutineIsActive
|
||||
|
||||
/**
|
||||
* JSSE-backed TLS TAK server. Matches the Meshtastic-Apple (iOS) implementation:
|
||||
* - Binds `127.0.0.1:8089` (loopback only — no remote device can reach the server)
|
||||
* - TLS 1.2+ with the bundled server.p12 identity
|
||||
* - Mutual TLS: clients MUST present a certificate chaining to the bundled ca.pem
|
||||
* - `SO_REUSEADDR` on the listen socket so an app restart doesn't hit `BindException: Address already in use` while the
|
||||
* previous socket is in `TIME_WAIT`
|
||||
* - Per-connection [TAKClientConnection] running on [CoroutineDispatchers.io]
|
||||
*
|
||||
* If the bundled certificates fail to load (e.g. packaging regression), the server refuses to start rather than
|
||||
* silently falling back to plain TCP — that failure mode would produce exactly the symptom the user was debugging
|
||||
* ("ATAK never connects").
|
||||
*/
|
||||
internal class TAKServerJvm(private val dispatchers: CoroutineDispatchers, private val port: Int = DEFAULT_TAK_PORT) :
|
||||
TAKServer {
|
||||
|
||||
private var serverSocket: ServerSocket? = null
|
||||
|
||||
@Volatile private var running = false
|
||||
private var serverScope: CoroutineScope? = null
|
||||
private var acceptJob: Job? = null
|
||||
private val connectionsLock = ReentrantLock()
|
||||
private val connections = mutableMapOf<String, TAKClientConnection>()
|
||||
|
||||
private val _connectionCount = MutableStateFlow(0)
|
||||
override val connectionCount: StateFlow<Int> = _connectionCount.asStateFlow()
|
||||
|
||||
override var onMessage: ((CoTMessage, TAKClientInfo?) -> Unit)? = null
|
||||
override var onClientConnected: (() -> Unit)? = null
|
||||
|
||||
override suspend fun start(scope: CoroutineScope): Result<Unit> {
|
||||
if (running) {
|
||||
Logger.w { "TAK Server already running on port $port" }
|
||||
return Result.success(Unit)
|
||||
}
|
||||
|
||||
val sslContext =
|
||||
TakCertLoader.getServerSslContext()
|
||||
?: return Result.failure(
|
||||
IllegalStateException(
|
||||
"TAK Server: bundled TLS certificates could not be loaded" + "; refusing to start",
|
||||
),
|
||||
)
|
||||
|
||||
return try {
|
||||
serverScope = scope
|
||||
|
||||
// Bind on the IO dispatcher — bind() can briefly block.
|
||||
val boundSocket =
|
||||
withContext(dispatchers.io) {
|
||||
val factory = sslContext.serverSocketFactory
|
||||
// Use the address-specific overload so we bind to loopback only.
|
||||
val loopback = InetAddress.getByName("127.0.0.1")
|
||||
// backlog of 4 is plenty for local TAK clients
|
||||
val tls = factory.createServerSocket(port, 4, loopback) as SSLServerSocket
|
||||
configureTlsServerSocket(tls)
|
||||
tls
|
||||
}
|
||||
serverSocket = boundSocket
|
||||
running = true
|
||||
Logger.i { "TAK Server listening on 127.0.0.1:$port (TLS, mTLS enforced)" }
|
||||
|
||||
acceptJob = scope.launch(dispatchers.io) { acceptLoop() }
|
||||
Result.success(Unit)
|
||||
} catch (e: Exception) {
|
||||
Logger.e(e) { "Failed to bind TAK Server to 127.0.0.1:$port" }
|
||||
running = false
|
||||
try {
|
||||
serverSocket?.close()
|
||||
} catch (_: Exception) {}
|
||||
serverSocket = null
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
|
||||
private fun configureTlsServerSocket(tls: SSLServerSocket) {
|
||||
// Minimum TLS 1.2 — matches iOS.
|
||||
val protocols = tls.supportedProtocols.filter { it == "TLSv1.2" || it == "TLSv1.3" }
|
||||
if (protocols.isNotEmpty()) {
|
||||
tls.enabledProtocols = protocols.toTypedArray()
|
||||
}
|
||||
// Require client certificate (mTLS) — matches
|
||||
// `sec_protocol_options_set_peer_authentication_required` on iOS.
|
||||
tls.needClientAuth = true
|
||||
// Enable address reuse so restart doesn't hit TIME_WAIT on the port.
|
||||
tls.reuseAddress = true
|
||||
}
|
||||
|
||||
private suspend fun acceptLoop() {
|
||||
val scope = serverScope ?: return
|
||||
while (running && scope.coroutineIsActive) {
|
||||
try {
|
||||
val clientSocket = withContext(dispatchers.io) { serverSocket?.accept() }
|
||||
if (clientSocket != null) {
|
||||
handleConnection(clientSocket)
|
||||
}
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
// Bind was lost or the socket was closed under us — back off, then retry.
|
||||
if (running) {
|
||||
Logger.w(e) { "TAK server accept loop iteration failed: ${e.message}" }
|
||||
}
|
||||
delay(TAK_ACCEPT_LOOP_DELAY_MS)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleConnection(clientSocket: Socket) {
|
||||
val scope = serverScope ?: return
|
||||
val endpoint = clientSocket.remoteSocketAddress?.toString() ?: "unknown"
|
||||
|
||||
if (clientSocket.inetAddress?.isLoopbackAddress != true) {
|
||||
Logger.w { "TAK server rejected non-loopback connection from $endpoint" }
|
||||
try {
|
||||
clientSocket.close()
|
||||
} catch (_: Exception) {}
|
||||
return
|
||||
}
|
||||
|
||||
val connectionId = Random.nextInt().toString(TAK_HEX_RADIX)
|
||||
val clientInfo = TAKClientInfo(id = connectionId, endpoint = endpoint)
|
||||
Logger.i { "TAK client connected: id=$connectionId endpoint=$endpoint" }
|
||||
|
||||
val connection =
|
||||
TAKClientConnection(
|
||||
socket = clientSocket,
|
||||
clientInfo = clientInfo,
|
||||
onEvent = { event -> handleConnectionEvent(connectionId, event) },
|
||||
scope = scope,
|
||||
ioDispatcher = dispatchers.io,
|
||||
)
|
||||
|
||||
// Launch on IO so socket reads/writes don't queue behind CPU work on Default
|
||||
scope.launch(dispatchers.io) {
|
||||
connectionsLock.withLock {
|
||||
connections[connectionId] = connection
|
||||
_connectionCount.value = connections.size
|
||||
Logger.i { "TAK connection count now ${connections.size}" }
|
||||
}
|
||||
connection.start()
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleConnectionEvent(connectionId: String, event: TAKConnectionEvent) {
|
||||
when (event) {
|
||||
is TAKConnectionEvent.Message -> {
|
||||
onMessage?.invoke(event.cotMessage, event.clientInfo)
|
||||
}
|
||||
|
||||
is TAKConnectionEvent.Disconnected -> {
|
||||
Logger.i { "TAK client disconnected: id=$connectionId" }
|
||||
serverScope?.launch(dispatchers.io) {
|
||||
connectionsLock.withLock {
|
||||
connections.remove(connectionId)
|
||||
_connectionCount.value = connections.size
|
||||
Logger.i { "TAK connection count now ${connections.size}" }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
is TAKConnectionEvent.Error -> {
|
||||
Logger.w(event.error) { "TAK client connection error: $connectionId" }
|
||||
serverScope?.launch(dispatchers.io) {
|
||||
connectionsLock.withLock {
|
||||
connections.remove(connectionId)
|
||||
_connectionCount.value = connections.size
|
||||
Logger.i { "TAK connection count now ${connections.size}" }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
is TAKConnectionEvent.Connected -> {
|
||||
onClientConnected?.invoke()
|
||||
}
|
||||
|
||||
is TAKConnectionEvent.ClientInfoUpdated -> {
|
||||
/* no-op: TAKClientConnection tracks updated info locally */
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun stop() {
|
||||
running = false
|
||||
acceptJob?.cancel()
|
||||
acceptJob = null
|
||||
|
||||
// Guard the snapshot+clear with the same lock used by the coroutine accept/disconnect
|
||||
// paths to avoid concurrent modification or a stale connectionCount during shutdown.
|
||||
val toClose =
|
||||
connectionsLock.withLock {
|
||||
val snapshot = connections.values.toList()
|
||||
connections.clear()
|
||||
_connectionCount.value = 0
|
||||
snapshot
|
||||
}
|
||||
toClose.forEach { it.close() }
|
||||
|
||||
try {
|
||||
serverSocket?.close()
|
||||
} catch (_: Exception) {}
|
||||
serverSocket = null
|
||||
serverScope = null
|
||||
Logger.i { "TAK Server stopped" }
|
||||
}
|
||||
|
||||
override suspend fun broadcast(cotMessage: CoTMessage) {
|
||||
val currentConnections = connectionsLock.withLock { connections.values.toList() }
|
||||
if (currentConnections.isEmpty()) {
|
||||
Logger.d { "broadcast ${cotMessage.type}: no TAK clients connected, dropping" }
|
||||
return
|
||||
}
|
||||
Logger.d { "broadcast ${cotMessage.type} to ${currentConnections.size} TAK client(s)" }
|
||||
currentConnections.forEach { connection ->
|
||||
try {
|
||||
connection.send(cotMessage)
|
||||
} catch (e: Exception) {
|
||||
Logger.w(e) { "Failed to broadcast CoT to TAK client ${connection.clientInfo.id}" }
|
||||
connection.close()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun broadcastRawXml(xml: String) {
|
||||
val currentConnections = connectionsLock.withLock { connections.values.toList() }
|
||||
if (currentConnections.isEmpty()) return
|
||||
Logger.d { "broadcastRawXml to ${currentConnections.size} TAK client(s)" }
|
||||
currentConnections.forEach { connection ->
|
||||
try {
|
||||
connection.sendRawXml(xml)
|
||||
} catch (e: Exception) {
|
||||
Logger.w(e) { "Failed to broadcast raw XML to TAK client ${connection.clientInfo.id}" }
|
||||
connection.close()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun hasConnections(): Boolean = connectionsLock.withLock { connections.isNotEmpty() }
|
||||
}
|
||||
|
||||
/**
|
||||
* `actual` factory for the KMP `expect fun createTAKServer` declared in `commonMain`. Both the Desktop JVM target and
|
||||
* the Android target share this source set, so both run the same JSSE-based TLS listener.
|
||||
*
|
||||
* Also wires [TAKDataPackageGenerator]'s bundled-cert provider so that the exported `.zip` data package contains the
|
||||
* real `server.p12` / `client.p12` bytes from the classpath rather than an empty fallback.
|
||||
*/
|
||||
actual fun createTAKServer(dispatchers: CoroutineDispatchers, port: Int): TAKServer {
|
||||
TAKDataPackageGenerator.bundledCertBytesProvider = TakCertBundledBytesProvider
|
||||
return TAKServerJvm(dispatchers = dispatchers, port = port)
|
||||
}
|
||||
|
||||
/** Bridges [TakCertLoader] bytes into [TAKDataPackageGenerator] via the commonMain interface. */
|
||||
private object TakCertBundledBytesProvider : BundledCertBytesProvider {
|
||||
override fun serverP12Bytes(): ByteArray? = TakCertLoader.getServerP12Bytes()
|
||||
|
||||
override fun clientP12Bytes(): ByteArray? = TakCertLoader.getClientP12Bytes()
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
/*
|
||||
* Copyright (c) 2026 Meshtastic LLC
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
@file:Suppress("TooGenericExceptionCaught")
|
||||
|
||||
package org.meshtastic.core.takserver
|
||||
|
||||
import co.touchlab.kermit.Logger
|
||||
import java.io.ByteArrayInputStream
|
||||
import java.security.KeyStore
|
||||
import java.security.cert.CertificateFactory
|
||||
import java.security.cert.X509Certificate
|
||||
import javax.net.ssl.KeyManagerFactory
|
||||
import javax.net.ssl.SSLContext
|
||||
import javax.net.ssl.TrustManagerFactory
|
||||
|
||||
/**
|
||||
* Loads the bundled TAK server certificates from the classpath and builds an [SSLContext] suitable for running a TLS
|
||||
* TAK server with mutual TLS (mTLS).
|
||||
*
|
||||
* Bundled resources (under `tak_certs/` on the module classpath):
|
||||
* - `server.p12` — PKCS#12 containing the server's identity (cert + private key). Used as the server's identity during
|
||||
* the TLS handshake.
|
||||
* - `client.p12` — PKCS#12 containing an example client identity, included in the exported data package so ATAK / iTAK
|
||||
* have a certificate it can present.
|
||||
* - `ca.pem` — PEM-encoded CA certificate used to validate the presented client certificate during mTLS. Only clients
|
||||
* whose certificate chains back to this CA are accepted.
|
||||
*
|
||||
* All files are the same bytes as the iOS Meshtastic-Apple bundle, so the same exported data package works for both
|
||||
* platforms with no re-import.
|
||||
*/
|
||||
internal object TakCertLoader {
|
||||
|
||||
private const val RESOURCE_SERVER_P12 = "tak_certs/server.p12"
|
||||
private const val RESOURCE_CLIENT_P12 = "tak_certs/client.p12"
|
||||
private const val RESOURCE_CA_PEM = "tak_certs/ca.pem"
|
||||
|
||||
@Volatile private var cachedSslContext: SSLContext? = null
|
||||
|
||||
@Volatile private var cachedServerP12: ByteArray? = null
|
||||
|
||||
@Volatile private var cachedClientP12: ByteArray? = null
|
||||
|
||||
@Volatile private var cachedCaPem: ByteArray? = null
|
||||
|
||||
/**
|
||||
* Build (and cache) an [SSLContext] for the TAK server.
|
||||
*
|
||||
* The context uses the bundled `server.p12` for its identity and the bundled `ca.pem` to validate client
|
||||
* certificates during mTLS. If anything fails to load (missing resources, bad password, corrupt keystore) this
|
||||
* returns `null` and callers should fall back to a non-TLS listener or refuse to start.
|
||||
*/
|
||||
@Synchronized
|
||||
fun getServerSslContext(): SSLContext? {
|
||||
cachedSslContext?.let {
|
||||
return it
|
||||
}
|
||||
return try {
|
||||
val serverP12 =
|
||||
loadResourceBytes(RESOURCE_SERVER_P12) ?: error("Bundled $RESOURCE_SERVER_P12 not found on classpath")
|
||||
val caPem = loadResourceBytes(RESOURCE_CA_PEM) ?: error("Bundled $RESOURCE_CA_PEM not found on classpath")
|
||||
|
||||
// Load the server identity (cert + private key).
|
||||
val serverKeyStore =
|
||||
KeyStore.getInstance("PKCS12").apply {
|
||||
ByteArrayInputStream(serverP12).use { input ->
|
||||
load(input, TAK_BUNDLED_CERT_PASSWORD.toCharArray())
|
||||
}
|
||||
}
|
||||
val kmf =
|
||||
KeyManagerFactory.getInstance(KeyManagerFactory.getDefaultAlgorithm()).apply {
|
||||
init(serverKeyStore, TAK_BUNDLED_CERT_PASSWORD.toCharArray())
|
||||
}
|
||||
|
||||
// Load the CA certificate(s) used to verify incoming client certs.
|
||||
val caCerts = parsePemCertificates(caPem)
|
||||
if (caCerts.isEmpty()) error("No certificates found inside $RESOURCE_CA_PEM")
|
||||
val trustKeyStore =
|
||||
KeyStore.getInstance(KeyStore.getDefaultType()).apply {
|
||||
load(null, null)
|
||||
caCerts.forEachIndexed { index, cert -> setCertificateEntry("tak-client-ca-$index", cert) }
|
||||
}
|
||||
val tmf =
|
||||
TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm()).apply { init(trustKeyStore) }
|
||||
|
||||
val sslContext = SSLContext.getInstance("TLSv1.2").apply { init(kmf.keyManagers, tmf.trustManagers, null) }
|
||||
|
||||
Logger.i { "TAK: loaded bundled TLS server identity and ${caCerts.size} CA certificate(s)" }
|
||||
cachedSslContext = sslContext
|
||||
sslContext
|
||||
} catch (e: Throwable) {
|
||||
Logger.e(e) { "TAK: failed to build SSLContext from bundled certificates: ${e.message}" }
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
/** Returns the raw bytes of the bundled `server.p12`. Used by the data package generator. */
|
||||
@Synchronized
|
||||
fun getServerP12Bytes(): ByteArray? {
|
||||
cachedServerP12?.let {
|
||||
return it
|
||||
}
|
||||
val bytes = loadResourceBytes(RESOURCE_SERVER_P12)
|
||||
cachedServerP12 = bytes
|
||||
return bytes
|
||||
}
|
||||
|
||||
/** Returns the raw bytes of the bundled `client.p12`. Used by the data package generator. */
|
||||
@Synchronized
|
||||
fun getClientP12Bytes(): ByteArray? {
|
||||
cachedClientP12?.let {
|
||||
return it
|
||||
}
|
||||
val bytes = loadResourceBytes(RESOURCE_CLIENT_P12)
|
||||
cachedClientP12 = bytes
|
||||
return bytes
|
||||
}
|
||||
|
||||
/** Returns the raw bytes of the bundled `ca.pem`. */
|
||||
@Synchronized
|
||||
fun getCaPemBytes(): ByteArray? {
|
||||
cachedCaPem?.let {
|
||||
return it
|
||||
}
|
||||
val bytes = loadResourceBytes(RESOURCE_CA_PEM)
|
||||
cachedCaPem = bytes
|
||||
return bytes
|
||||
}
|
||||
|
||||
private fun loadResourceBytes(name: String): ByteArray? {
|
||||
val stream = TakCertLoader::class.java.classLoader?.getResourceAsStream(name) ?: return null
|
||||
return stream.use { it.readBytes() }
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse every `-----BEGIN CERTIFICATE----- ... -----END CERTIFICATE-----` block in the given PEM bytes into
|
||||
* [X509Certificate]s. Tolerates multiple certs in one file.
|
||||
*/
|
||||
private fun parsePemCertificates(pem: ByteArray): List<X509Certificate> {
|
||||
val factory = CertificateFactory.getInstance("X.509")
|
||||
// CertificateFactory.generateCertificates handles PEM bundles directly on all
|
||||
// standard Java providers, so we don't need to split ourselves.
|
||||
return ByteArrayInputStream(pem).use { input ->
|
||||
factory.generateCertificates(input).filterIsInstance<X509Certificate>()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
/*
|
||||
* Copyright (c) 2026 Meshtastic LLC
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.meshtastic.core.takserver
|
||||
|
||||
/** JVM/Android: load fixture XML from bundled test resources via the classloader. */
|
||||
internal actual fun loadTakFixtureXml(name: String): String {
|
||||
val stream =
|
||||
object {}::class.java.classLoader?.getResourceAsStream("tak_test_fixtures/$name")
|
||||
?: throw IllegalStateException("Fixture not found: tak_test_fixtures/$name")
|
||||
return stream.bufferedReader().readText()
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
/*
|
||||
* Copyright (c) 2026 Meshtastic LLC
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.meshtastic.core.takserver
|
||||
|
||||
import org.meshtastic.tak.CotXmlParser
|
||||
import org.meshtastic.tak.TakCompressor
|
||||
|
||||
internal actual object TakSdkCompressor {
|
||||
|
||||
actual fun compressCoT(xml: String, maxBytes: Int): ByteArray? {
|
||||
val sdkParser = CotXmlParser()
|
||||
val sdkData = sdkParser.parse(xml)
|
||||
val compressor = TakCompressor()
|
||||
return compressor.compressWithRemarksFallback(sdkData, maxBytes)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,481 @@
|
||||
/*
|
||||
* Copyright (c) 2026 Meshtastic LLC
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.meshtastic.core.takserver
|
||||
|
||||
import okio.ByteString.Companion.toByteString
|
||||
import org.meshtastic.proto.TAKPacketV2
|
||||
import org.meshtastic.tak.TakPacketV2Data
|
||||
import org.meshtastic.proto.AircraftTrack as WireAircraftTrack
|
||||
import org.meshtastic.proto.CasevacReport as WireCasevacReport
|
||||
import org.meshtastic.proto.CotGeoPoint as WireCotGeoPoint
|
||||
import org.meshtastic.proto.DrawnShape as WireDrawnShape
|
||||
import org.meshtastic.proto.EmergencyAlert as WireEmergencyAlert
|
||||
import org.meshtastic.proto.GeoChat as WireGeoChat
|
||||
import org.meshtastic.proto.Marker as WireMarker
|
||||
import org.meshtastic.proto.RangeAndBearing as WireRangeAndBearing
|
||||
import org.meshtastic.proto.Route as WireRoute
|
||||
import org.meshtastic.proto.TaskRequest as WireTaskRequest
|
||||
import org.meshtastic.proto.Team as WireTeam
|
||||
import org.meshtastic.tak.TakCompressor as SdkCompressor
|
||||
|
||||
/**
|
||||
* JVM/Android implementation of TakV2Compressor. Delegates to TAKPacket-SDK's TakCompressor for zstd dictionary
|
||||
* compression.
|
||||
*
|
||||
* The SDK compressor is constructed lazily and its result is cached in a nullable field so that a native-library
|
||||
* failure (e.g. missing Android .so) does NOT poison this object. Without lazy/try-catch, a failure inside a top-level
|
||||
* `val` initializer runs at class `<clinit>` time, marks the class ERRONEOUS, and turns every subsequent reference into
|
||||
* `NoClassDefFoundError`.
|
||||
*/
|
||||
internal actual object TakV2Compressor {
|
||||
|
||||
actual val MAX_DECOMPRESSED_SIZE: Int = 4096
|
||||
actual val DICT_ID_NON_AIRCRAFT: Int = 0
|
||||
actual val DICT_ID_AIRCRAFT: Int = 1
|
||||
actual val DICT_ID_UNCOMPRESSED: Int = 0xFF
|
||||
|
||||
@Volatile private var sdkCompressorOrNull: SdkCompressor? = null
|
||||
|
||||
@Volatile private var sdkCompressorInitFailure: Throwable? = null
|
||||
|
||||
@Synchronized
|
||||
private fun getSdkCompressor(): SdkCompressor {
|
||||
sdkCompressorOrNull?.let {
|
||||
return it
|
||||
}
|
||||
sdkCompressorInitFailure?.let { cached ->
|
||||
throw IllegalStateException("zstd-jni unavailable on this platform", cached)
|
||||
}
|
||||
return try {
|
||||
SdkCompressor().also { sdkCompressorOrNull = it }
|
||||
} catch (e: Throwable) {
|
||||
sdkCompressorInitFailure = e
|
||||
throw IllegalStateException("zstd-jni unavailable on this platform", e)
|
||||
}
|
||||
}
|
||||
|
||||
actual fun compress(packet: TAKPacketV2): ByteArray {
|
||||
val data = wireToSdkData(packet)
|
||||
return getSdkCompressor().compress(data)
|
||||
}
|
||||
|
||||
actual fun decompress(wirePayload: ByteArray): TAKPacketV2 {
|
||||
val data = getSdkCompressor().decompress(wirePayload)
|
||||
return sdkDataToWire(data)
|
||||
}
|
||||
|
||||
/**
|
||||
* Decompress a V2 wire payload and reconstruct CoT XML via the SDK's CotXmlBuilder. This handles ALL payload types
|
||||
* (DrawnShape, Marker, Route, etc.) without going through the Wire proto intermediate, avoiding the gap where
|
||||
* `toCoTMessage()` only handles PLI/GeoChat.
|
||||
*/
|
||||
actual fun decompressToXml(wirePayload: ByteArray): String {
|
||||
val data = getSdkCompressor().decompress(wirePayload)
|
||||
return org.meshtastic.tak.CotXmlBuilder().build(data)
|
||||
}
|
||||
|
||||
/** Convert Wire-generated TAKPacketV2 → SDK's TakPacketV2Data. */
|
||||
private fun wireToSdkData(packet: TAKPacketV2): TakPacketV2Data {
|
||||
val cotTypeId = packet.cot_type_id.value
|
||||
val cotTypeStr = if (cotTypeId == 0 && packet.cot_type_str.isNotEmpty()) packet.cot_type_str else null
|
||||
|
||||
val payload =
|
||||
when {
|
||||
packet.pli != null -> TakPacketV2Data.Payload.Pli(true)
|
||||
|
||||
packet.chat != null ->
|
||||
TakPacketV2Data.Payload.Chat(
|
||||
message = packet.chat!!.message,
|
||||
to = packet.chat!!.to,
|
||||
toCallsign = packet.chat!!.to_callsign,
|
||||
receiptForUid = packet.chat!!.receipt_for_uid,
|
||||
receiptType = packet.chat!!.receipt_type.value,
|
||||
)
|
||||
|
||||
packet.aircraft != null ->
|
||||
TakPacketV2Data.Payload.Aircraft(
|
||||
icao = packet.aircraft!!.icao,
|
||||
registration = packet.aircraft!!.registration,
|
||||
flight = packet.aircraft!!.flight,
|
||||
aircraftType = packet.aircraft!!.aircraft_type,
|
||||
squawk = packet.aircraft!!.squawk,
|
||||
category = packet.aircraft!!.category,
|
||||
rssiX10 = packet.aircraft!!.rssi_x10,
|
||||
gps = packet.aircraft!!.gps,
|
||||
cotHostId = packet.aircraft!!.cot_host_id,
|
||||
)
|
||||
|
||||
// Typed geometry variants added by takv2_geometry (tags 34-37).
|
||||
// All GeoPoint fields on the wire are delta-encoded from the
|
||||
// event anchor; the SDK data class stores absolute lat/lon, so
|
||||
// we add packet.latitude_i / longitude_i here.
|
||||
packet.shape != null -> {
|
||||
val s = packet.shape!!
|
||||
TakPacketV2Data.Payload.DrawnShape(
|
||||
kind = s.kind.value,
|
||||
style = s.style.value,
|
||||
majorCm = s.major_cm,
|
||||
minorCm = s.minor_cm,
|
||||
angleDeg = s.angle_deg,
|
||||
strokeColor = s.stroke_color.value,
|
||||
strokeArgb = s.stroke_argb,
|
||||
strokeWeightX10 = s.stroke_weight_x10,
|
||||
fillColor = s.fill_color.value,
|
||||
fillArgb = s.fill_argb,
|
||||
labelsOn = s.labels_on,
|
||||
vertices =
|
||||
s.vertices.map { v ->
|
||||
TakPacketV2Data.Payload.Vertex(
|
||||
latI = packet.latitude_i + v.lat_delta_i,
|
||||
lonI = packet.longitude_i + v.lon_delta_i,
|
||||
)
|
||||
},
|
||||
truncated = s.truncated,
|
||||
bullseyeDistanceDm = s.bullseye_distance_dm,
|
||||
bullseyeBearingRef = s.bullseye_bearing_ref,
|
||||
bullseyeFlags = s.bullseye_flags,
|
||||
bullseyeUidRef = s.bullseye_uid_ref,
|
||||
)
|
||||
}
|
||||
|
||||
packet.marker != null -> {
|
||||
val m = packet.marker!!
|
||||
TakPacketV2Data.Payload.Marker(
|
||||
kind = m.kind.value,
|
||||
color = m.color.value,
|
||||
colorArgb = m.color_argb,
|
||||
readiness = m.readiness,
|
||||
parentUid = m.parent_uid,
|
||||
parentType = m.parent_type,
|
||||
parentCallsign = m.parent_callsign,
|
||||
iconset = m.iconset,
|
||||
)
|
||||
}
|
||||
|
||||
packet.rab != null -> {
|
||||
val r = packet.rab!!
|
||||
val anchor = r.anchor
|
||||
TakPacketV2Data.Payload.RangeAndBearing(
|
||||
anchorLatI = packet.latitude_i + (anchor?.lat_delta_i ?: 0),
|
||||
anchorLonI = packet.longitude_i + (anchor?.lon_delta_i ?: 0),
|
||||
anchorUid = r.anchor_uid,
|
||||
rangeCm = r.range_cm,
|
||||
bearingCdeg = r.bearing_cdeg,
|
||||
strokeColor = r.stroke_color.value,
|
||||
strokeArgb = r.stroke_argb,
|
||||
strokeWeightX10 = r.stroke_weight_x10,
|
||||
)
|
||||
}
|
||||
|
||||
packet.route != null -> {
|
||||
val rt = packet.route!!
|
||||
TakPacketV2Data.Payload.Route(
|
||||
method = rt.method.value,
|
||||
direction = rt.direction.value,
|
||||
prefix = rt.prefix,
|
||||
strokeWeightX10 = rt.stroke_weight_x10,
|
||||
links =
|
||||
rt.links.map { link ->
|
||||
val pt = link.point
|
||||
TakPacketV2Data.Payload.Route.Link(
|
||||
latI = packet.latitude_i + (pt?.lat_delta_i ?: 0),
|
||||
lonI = packet.longitude_i + (pt?.lon_delta_i ?: 0),
|
||||
uid = link.uid,
|
||||
callsign = link.callsign,
|
||||
linkType = link.link_type,
|
||||
)
|
||||
},
|
||||
truncated = rt.truncated,
|
||||
)
|
||||
}
|
||||
|
||||
packet.casevac != null -> {
|
||||
val c = packet.casevac!!
|
||||
TakPacketV2Data.Payload.CasevacReport(
|
||||
precedence = c.precedence.value,
|
||||
equipmentFlags = c.equipment_flags,
|
||||
litterPatients = c.litter_patients,
|
||||
ambulatoryPatients = c.ambulatory_patients,
|
||||
security = c.security.value,
|
||||
hlzMarking = c.hlz_marking.value,
|
||||
zoneMarker = c.zone_marker,
|
||||
usMilitary = c.us_military,
|
||||
usCivilian = c.us_civilian,
|
||||
nonUsMilitary = c.non_us_military,
|
||||
nonUsCivilian = c.non_us_civilian,
|
||||
epw = c.epw,
|
||||
child = c.child,
|
||||
terrainFlags = c.terrain_flags,
|
||||
frequency = c.frequency,
|
||||
)
|
||||
}
|
||||
|
||||
packet.emergency != null -> {
|
||||
val e = packet.emergency!!
|
||||
TakPacketV2Data.Payload.EmergencyAlert(
|
||||
type = e.type.value,
|
||||
authoringUid = e.authoring_uid,
|
||||
cancelReferenceUid = e.cancel_reference_uid,
|
||||
)
|
||||
}
|
||||
|
||||
packet.task != null -> {
|
||||
val t = packet.task!!
|
||||
TakPacketV2Data.Payload.TaskRequest(
|
||||
taskType = t.task_type,
|
||||
targetUid = t.target_uid,
|
||||
assigneeUid = t.assignee_uid,
|
||||
priority = t.priority.value,
|
||||
status = t.status.value,
|
||||
note = t.note,
|
||||
)
|
||||
}
|
||||
|
||||
packet.raw_detail != null -> TakPacketV2Data.Payload.RawDetail(packet.raw_detail!!.toByteArray())
|
||||
|
||||
else -> TakPacketV2Data.Payload.None
|
||||
}
|
||||
|
||||
return TakPacketV2Data(
|
||||
cotTypeId = cotTypeId,
|
||||
cotTypeStr = cotTypeStr,
|
||||
how = packet.how.value,
|
||||
callsign = packet.callsign,
|
||||
team = packet.team.value,
|
||||
role = packet.role.value,
|
||||
latitudeI = packet.latitude_i,
|
||||
longitudeI = packet.longitude_i,
|
||||
altitude = packet.altitude,
|
||||
speed = packet.speed,
|
||||
course = packet.course,
|
||||
battery = packet.battery,
|
||||
geoSrc = packet.geo_src.value,
|
||||
altSrc = packet.alt_src.value,
|
||||
uid = packet.uid,
|
||||
deviceCallsign = packet.device_callsign,
|
||||
staleSeconds = packet.stale_seconds,
|
||||
takVersion = packet.tak_version,
|
||||
takDevice = packet.tak_device,
|
||||
takPlatform = packet.tak_platform,
|
||||
takOs = packet.tak_os,
|
||||
endpoint = packet.endpoint,
|
||||
phone = packet.phone,
|
||||
payload = payload,
|
||||
)
|
||||
}
|
||||
|
||||
/** Convert SDK's TakPacketV2Data → Wire-generated TAKPacketV2. */
|
||||
private fun sdkDataToWire(data: TakPacketV2Data): TAKPacketV2 {
|
||||
val cotType =
|
||||
org.meshtastic.proto.CotType.fromValue(data.cotTypeId) ?: org.meshtastic.proto.CotType.CotType_Other
|
||||
val how = org.meshtastic.proto.CotHow.fromValue(data.how) ?: org.meshtastic.proto.CotHow.CotHow_Unspecified
|
||||
val team = org.meshtastic.proto.Team.fromValue(data.team) ?: org.meshtastic.proto.Team.Unspecifed_Color
|
||||
val role = org.meshtastic.proto.MemberRole.fromValue(data.role) ?: org.meshtastic.proto.MemberRole.Unspecifed
|
||||
val geoSrc =
|
||||
org.meshtastic.proto.GeoPointSource.fromValue(data.geoSrc)
|
||||
?: org.meshtastic.proto.GeoPointSource.GeoPointSource_Unspecified
|
||||
val altSrc =
|
||||
org.meshtastic.proto.GeoPointSource.fromValue(data.altSrc)
|
||||
?: org.meshtastic.proto.GeoPointSource.GeoPointSource_Unspecified
|
||||
|
||||
return TAKPacketV2(
|
||||
cot_type_id = cotType,
|
||||
cot_type_str = data.cotTypeStr ?: "",
|
||||
how = how,
|
||||
callsign = data.callsign,
|
||||
team = team,
|
||||
role = role,
|
||||
latitude_i = data.latitudeI,
|
||||
longitude_i = data.longitudeI,
|
||||
altitude = data.altitude,
|
||||
speed = data.speed,
|
||||
course = data.course,
|
||||
battery = data.battery,
|
||||
geo_src = geoSrc,
|
||||
alt_src = altSrc,
|
||||
uid = data.uid,
|
||||
device_callsign = data.deviceCallsign,
|
||||
stale_seconds = data.staleSeconds,
|
||||
tak_version = data.takVersion,
|
||||
tak_device = data.takDevice,
|
||||
tak_platform = data.takPlatform,
|
||||
tak_os = data.takOs,
|
||||
endpoint = data.endpoint,
|
||||
phone = data.phone,
|
||||
pli = if (data.payload is TakPacketV2Data.Payload.Pli) true else null,
|
||||
chat =
|
||||
(data.payload as? TakPacketV2Data.Payload.Chat)?.let { chat ->
|
||||
WireGeoChat(
|
||||
message = chat.message,
|
||||
to = chat.to,
|
||||
to_callsign = chat.toCallsign,
|
||||
receipt_for_uid = chat.receiptForUid,
|
||||
receipt_type =
|
||||
WireGeoChat.ReceiptType.fromValue(chat.receiptType)
|
||||
?: WireGeoChat.ReceiptType.ReceiptType_None,
|
||||
)
|
||||
},
|
||||
aircraft =
|
||||
(data.payload as? TakPacketV2Data.Payload.Aircraft)?.let { ac ->
|
||||
WireAircraftTrack(
|
||||
icao = ac.icao,
|
||||
registration = ac.registration,
|
||||
flight = ac.flight,
|
||||
aircraft_type = ac.aircraftType,
|
||||
squawk = ac.squawk,
|
||||
category = ac.category,
|
||||
rssi_x10 = ac.rssiX10,
|
||||
gps = ac.gps,
|
||||
cot_host_id = ac.cotHostId,
|
||||
)
|
||||
},
|
||||
shape =
|
||||
(data.payload as? TakPacketV2Data.Payload.DrawnShape)?.let { s ->
|
||||
WireDrawnShape(
|
||||
kind = WireDrawnShape.Kind.fromValue(s.kind) ?: WireDrawnShape.Kind.Kind_Unspecified,
|
||||
style =
|
||||
WireDrawnShape.StyleMode.fromValue(s.style)
|
||||
?: WireDrawnShape.StyleMode.StyleMode_Unspecified,
|
||||
major_cm = s.majorCm,
|
||||
minor_cm = s.minorCm,
|
||||
angle_deg = s.angleDeg,
|
||||
stroke_color = WireTeam.fromValue(s.strokeColor) ?: WireTeam.Unspecifed_Color,
|
||||
stroke_argb = s.strokeArgb,
|
||||
stroke_weight_x10 = s.strokeWeightX10,
|
||||
fill_color = WireTeam.fromValue(s.fillColor) ?: WireTeam.Unspecifed_Color,
|
||||
fill_argb = s.fillArgb,
|
||||
labels_on = s.labelsOn,
|
||||
// Delta-encode vertices relative to the event anchor.
|
||||
vertices =
|
||||
s.vertices.map { v ->
|
||||
WireCotGeoPoint(
|
||||
lat_delta_i = v.latI - data.latitudeI,
|
||||
lon_delta_i = v.lonI - data.longitudeI,
|
||||
)
|
||||
},
|
||||
truncated = s.truncated,
|
||||
bullseye_distance_dm = s.bullseyeDistanceDm,
|
||||
bullseye_bearing_ref = s.bullseyeBearingRef,
|
||||
bullseye_flags = s.bullseyeFlags,
|
||||
bullseye_uid_ref = s.bullseyeUidRef,
|
||||
)
|
||||
},
|
||||
marker =
|
||||
(data.payload as? TakPacketV2Data.Payload.Marker)?.let { m ->
|
||||
WireMarker(
|
||||
kind = WireMarker.Kind.fromValue(m.kind) ?: WireMarker.Kind.Kind_Unspecified,
|
||||
color = WireTeam.fromValue(m.color) ?: WireTeam.Unspecifed_Color,
|
||||
color_argb = m.colorArgb,
|
||||
readiness = m.readiness,
|
||||
parent_uid = m.parentUid,
|
||||
parent_type = m.parentType,
|
||||
parent_callsign = m.parentCallsign,
|
||||
iconset = m.iconset,
|
||||
)
|
||||
},
|
||||
rab =
|
||||
(data.payload as? TakPacketV2Data.Payload.RangeAndBearing)?.let { r ->
|
||||
WireRangeAndBearing(
|
||||
anchor =
|
||||
WireCotGeoPoint(
|
||||
lat_delta_i = r.anchorLatI - data.latitudeI,
|
||||
lon_delta_i = r.anchorLonI - data.longitudeI,
|
||||
),
|
||||
anchor_uid = r.anchorUid,
|
||||
range_cm = r.rangeCm,
|
||||
bearing_cdeg = r.bearingCdeg,
|
||||
stroke_color = WireTeam.fromValue(r.strokeColor) ?: WireTeam.Unspecifed_Color,
|
||||
stroke_argb = r.strokeArgb,
|
||||
stroke_weight_x10 = r.strokeWeightX10,
|
||||
)
|
||||
},
|
||||
route =
|
||||
(data.payload as? TakPacketV2Data.Payload.Route)?.let { rt ->
|
||||
WireRoute(
|
||||
method = WireRoute.Method.fromValue(rt.method) ?: WireRoute.Method.Method_Unspecified,
|
||||
direction =
|
||||
WireRoute.Direction.fromValue(rt.direction) ?: WireRoute.Direction.Direction_Unspecified,
|
||||
prefix = rt.prefix,
|
||||
stroke_weight_x10 = rt.strokeWeightX10,
|
||||
links =
|
||||
rt.links.map { link ->
|
||||
WireRoute.Link(
|
||||
point =
|
||||
WireCotGeoPoint(
|
||||
lat_delta_i = link.latI - data.latitudeI,
|
||||
lon_delta_i = link.lonI - data.longitudeI,
|
||||
),
|
||||
uid = link.uid,
|
||||
callsign = link.callsign,
|
||||
link_type = link.linkType,
|
||||
)
|
||||
},
|
||||
truncated = rt.truncated,
|
||||
)
|
||||
},
|
||||
casevac =
|
||||
(data.payload as? TakPacketV2Data.Payload.CasevacReport)?.let { c ->
|
||||
WireCasevacReport(
|
||||
precedence =
|
||||
WireCasevacReport.Precedence.fromValue(c.precedence)
|
||||
?: WireCasevacReport.Precedence.Precedence_Unspecified,
|
||||
equipment_flags = c.equipmentFlags,
|
||||
litter_patients = c.litterPatients,
|
||||
ambulatory_patients = c.ambulatoryPatients,
|
||||
security =
|
||||
WireCasevacReport.Security.fromValue(c.security)
|
||||
?: WireCasevacReport.Security.Security_Unspecified,
|
||||
hlz_marking =
|
||||
WireCasevacReport.HlzMarking.fromValue(c.hlzMarking)
|
||||
?: WireCasevacReport.HlzMarking.HlzMarking_Unspecified,
|
||||
zone_marker = c.zoneMarker,
|
||||
us_military = c.usMilitary,
|
||||
us_civilian = c.usCivilian,
|
||||
non_us_military = c.nonUsMilitary,
|
||||
non_us_civilian = c.nonUsCivilian,
|
||||
epw = c.epw,
|
||||
child = c.child,
|
||||
terrain_flags = c.terrainFlags,
|
||||
frequency = c.frequency,
|
||||
)
|
||||
},
|
||||
emergency =
|
||||
(data.payload as? TakPacketV2Data.Payload.EmergencyAlert)?.let { e ->
|
||||
WireEmergencyAlert(
|
||||
type = WireEmergencyAlert.Type.fromValue(e.type) ?: WireEmergencyAlert.Type.Type_Unspecified,
|
||||
authoring_uid = e.authoringUid,
|
||||
cancel_reference_uid = e.cancelReferenceUid,
|
||||
)
|
||||
},
|
||||
task =
|
||||
(data.payload as? TakPacketV2Data.Payload.TaskRequest)?.let { t ->
|
||||
WireTaskRequest(
|
||||
task_type = t.taskType,
|
||||
target_uid = t.targetUid,
|
||||
assignee_uid = t.assigneeUid,
|
||||
priority =
|
||||
WireTaskRequest.Priority.fromValue(t.priority)
|
||||
?: WireTaskRequest.Priority.Priority_Unspecified,
|
||||
status =
|
||||
WireTaskRequest.Status.fromValue(t.status) ?: WireTaskRequest.Status.Status_Unspecified,
|
||||
note = t.note,
|
||||
)
|
||||
},
|
||||
raw_detail = (data.payload as? TakPacketV2Data.Payload.RawDetail)?.bytes?.toByteString(),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,67 +0,0 @@
|
||||
/*
|
||||
* Copyright (c) 2026 Meshtastic LLC
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.meshtastic.core.takserver.fountain
|
||||
|
||||
import java.io.ByteArrayOutputStream
|
||||
import java.util.zip.Deflater
|
||||
import java.util.zip.Inflater
|
||||
|
||||
internal actual object ZlibCodec {
|
||||
actual fun compress(data: ByteArray): ByteArray? {
|
||||
val deflater = Deflater(Deflater.DEFAULT_COMPRESSION, false)
|
||||
return try {
|
||||
deflater.setInput(data)
|
||||
deflater.finish()
|
||||
|
||||
val outputStream = ByteArrayOutputStream(data.size)
|
||||
val buffer = ByteArray(1024)
|
||||
while (!deflater.finished()) {
|
||||
val count = deflater.deflate(buffer)
|
||||
outputStream.write(buffer, 0, count)
|
||||
}
|
||||
outputStream.close()
|
||||
outputStream.toByteArray()
|
||||
} catch (e: Exception) {
|
||||
null
|
||||
} finally {
|
||||
deflater.end()
|
||||
}
|
||||
}
|
||||
|
||||
actual fun decompress(data: ByteArray): ByteArray? {
|
||||
val inflater = Inflater(false)
|
||||
return try {
|
||||
inflater.setInput(data)
|
||||
|
||||
val outputStream = ByteArrayOutputStream(data.size * 2)
|
||||
val buffer = ByteArray(1024)
|
||||
while (!inflater.finished()) {
|
||||
val count = inflater.inflate(buffer)
|
||||
if (count == 0 && inflater.needsInput()) {
|
||||
break
|
||||
}
|
||||
outputStream.write(buffer, 0, count)
|
||||
}
|
||||
outputStream.close()
|
||||
outputStream.toByteArray()
|
||||
} catch (e: Exception) {
|
||||
null
|
||||
} finally {
|
||||
inflater.end()
|
||||
}
|
||||
}
|
||||
}
|
||||
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>
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user