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