feat(tak): introduce built-in Local TAK Server and mesh integration (#4951)

Signed-off-by: James Rich <2199651+jamesarich@users.noreply.github.com>
This commit is contained in:
James Rich 2026-04-01 15:21:25 -05:00 committed by GitHub
parent d1ca8ec527
commit e249461e3c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
76 changed files with 4587 additions and 64 deletions

View file

@ -54,7 +54,7 @@ Meshtastic-Android is a Kotlin Multiplatform (KMP) application for off-grid, dec
| `feature/` | Feature modules (e.g., `settings`, `map`, `messaging`, `node`, `intro`, `connections`, `firmware`, `widget`). All are KMP with `jvm()` and `ios()` targets except `widget`. Use `meshtastic.kmp.feature` convention plugin. |
| `feature/firmware` | Fully KMP firmware update system: Unified OTA (BLE + WiFi via Kable/Ktor), native Nordic Secure DFU protocol (pure KMP, no Nordic library), USB/UF2 updates, and `FirmwareRetriever` with manifest-based resolution. Desktop is a first-class target. |
| `desktop/` | Compose Desktop application — first non-Android KMP target. Thin host shell relying entirely on feature modules for shared UI. Full Koin DI graph, TCP, Serial/USB, and BLE transports with `want_config` handshake. |
| `mesh_service_example/` | Sample app showing `core:api` service integration. |
| `mesh_service_example/` | **DEPRECATED — scheduled for removal.** Legacy sample app showing `core:api` service integration. Do not add code here. See `core/api/README.md` for the current integration guide. |
## 3. Development Guidelines & Coding Standards

View file

@ -80,6 +80,8 @@ Developers can integrate with the Meshtastic Android app using our published API
For detailed integration instructions, see [core/api/README.md](core/api/README.md).
Additionally, the app includes a built-in **Local TAK Server** feature that can be enabled in settings. This runs a local TCP server on port 8089 to allow ATAK clients to connect directly and route their traffic over the mesh.
## Building the Android App
> [!WARNING]
> Debug and release builds can be installed concurrently. This is solely to enable smoother development, and you should avoid running both apps simultaneously. To ensure proper function, force quit the app not in use.

View file

@ -228,6 +228,7 @@ dependencies {
implementation(projects.core.resources)
implementation(projects.core.ui)
implementation(projects.core.barcode)
implementation(projects.core.takserver)
implementation(projects.feature.intro)
implementation(projects.feature.messaging)
implementation(projects.feature.connections)

View file

@ -49,6 +49,9 @@
<!-- This permission is required for analytics - and soon the MQTT gateway -->
<uses-permission android:name="android.permission.INTERNET" />
<!-- Required for Android 17+ (API 37) Local Networking for TAK Server localhost loopback -->
<uses-permission android:name="android.permission.ACCESS_LOCAL_NETWORK" />
<uses-permission android:name="android.permission.WAKE_LOCK" />
<!--

View file

@ -44,6 +44,7 @@ import org.meshtastic.core.prefs.di.CorePrefsAndroidModule
import org.meshtastic.core.prefs.di.CorePrefsModule
import org.meshtastic.core.service.di.CoreServiceAndroidModule
import org.meshtastic.core.service.di.CoreServiceModule
import org.meshtastic.core.takserver.di.CoreTakServerModule
import org.meshtastic.core.ui.di.CoreUiModule
import org.meshtastic.feature.connections.di.FeatureConnectionsModule
import org.meshtastic.feature.firmware.di.FeatureFirmwareModule
@ -76,6 +77,7 @@ import org.meshtastic.feature.widget.di.FeatureWidgetModule
CoreServiceAndroidModule::class,
CoreNetworkModule::class,
CoreNetworkAndroidModule::class,
CoreTakServerModule::class,
CoreUiModule::class,
FeatureNodeModule::class,
FeatureMessagingModule::class,

View file

@ -176,17 +176,21 @@ internal fun Project.configureKotlinJvm() {
private inline fun <reified T : KotlinBaseExtension> Project.configureKotlin() {
extensions.configure<T> {
val javaVersion = if (project.name in listOf("api", "model", "proto")) 17 else 21
val isPublishedModule = project.name in listOf("api", "model", "proto")
// Using Java 17 for published modules for better compatibility with consumers (e.g. plugins, older environments),
// and Java 21 for the rest of the app.
jvmToolchain(javaVersion)
if (this is KotlinMultiplatformExtension) {
targets.configureEach {
val isJvmTarget = platformType.name == "jvm" || platformType.name == "androidJvm"
compilations.configureEach {
compileTaskProvider.configure {
compilerOptions {
if (!isPublishedModule) {
freeCompilerArgs.add("-opt-in=kotlinx.coroutines.ExperimentalCoroutinesApi")
}
freeCompilerArgs.addAll(
"-opt-in=kotlinx.coroutines.ExperimentalCoroutinesApi",
"-opt-in=kotlin.uuid.ExperimentalUuidApi",
"-opt-in=kotlin.time.ExperimentalTime",
"-Xexpect-actual-classes",
@ -194,6 +198,9 @@ private inline fun <reified T : KotlinBaseExtension> Project.configureKotlin() {
"-Xannotation-default-target=param-property",
"-Xskip-prerelease-check",
)
if (isJvmTarget) {
freeCompilerArgs.add("-jvm-default=no-compatibility")
}
}
}
}
@ -208,9 +215,10 @@ private inline fun <reified T : KotlinBaseExtension> Project.configureKotlin() {
val isPublishedModule = project.name in listOf("api", "model", "proto")
jvmTarget.set(if (isPublishedModule) JvmTarget.JVM_17 else JvmTarget.JVM_21)
allWarningsAsErrors.set(warningsAsErrors)
if (!isPublishedModule) {
freeCompilerArgs.add("-opt-in=kotlinx.coroutines.ExperimentalCoroutinesApi")
}
freeCompilerArgs.addAll(
// Enable experimental coroutines APIs, including Flow
"-opt-in=kotlinx.coroutines.ExperimentalCoroutinesApi",
"-opt-in=kotlin.uuid.ExperimentalUuidApi",
"-opt-in=kotlin.time.ExperimentalTime",
"-Xexpect-actual-classes",

View file

@ -1,7 +1,17 @@
# `:core:api` (Meshtastic Android API)
> **Deprecation notice**
>
> The AIDL-based service integration (`IMeshService`) is deprecated and will be removed in a future
> release. The recommended integration path for ATAK and other external apps is the built-in
> **Local TAK Server** introduced in `core:takserver`. Connect ATAK to `127.0.0.1:8087` (TCP) and
> import the DataPackage exported from the TAK Config screen to complete setup. No AIDL binding or
> JitPack dependency is required.
## Overview
The `:core:api` module contains the stable AIDL interface and dependencies required for third-party applications to integrate with the Meshtastic Android app.
The `:core:api` module contains the AIDL interface and dependencies for third-party applications
that currently integrate with the Meshtastic Android app via service binding. New integrations
should use the Local TAK Server instead (see deprecation notice above).
## Integration

View file

@ -10,6 +10,10 @@ import org.meshtastic.core.model.MyNodeInfo;
/**
This is the public android API for talking to meshtastic radios.
@deprecated The AIDL service integration is deprecated and will be removed in a future release.
New integrations should connect via the built-in Local TAK Server on 127.0.0.1:8087 (TCP).
Import the DataPackage from the TAK Config screen in the Meshtastic app to configure ATAK.
To connect to meshtastic you should bind to it per https://developer.android.com/guide/components/bound-services
The intent you use to reach the service should ideally use the action string:
@ -156,20 +160,27 @@ interface IMeshService {
*/
String connectionState();
/// If a macaddress we will try to talk to our device, if null we will be idle.
/// Any current connection will be dropped (even if the device address is the same) before reconnecting.
/// Users should not call this directly, only used internally by the MeshUtil activity
/// Returns true if the device address actually changed, or false if no change was needed
/**
* @deprecated For internal use only. External callers must not invoke this method;
* it will be removed from the public API in a future release.
*/
boolean setDeviceAddress(String deviceAddr);
/// Get basic device hardware info about our connected radio. Will never return NULL. Will return NULL
/// if no my node info is available (i.e. it will not throw an exception)
MyNodeInfo getMyNodeInfo();
/// Start updating the radios firmware
/**
* @deprecated No-op stub — firmware update is now handled entirely by the in-app OTA system.
* This method will be removed from the public API in a future release.
*/
void startFirmwareUpdate();
/// Return a number 0-100 for firmware update progress. -1 for completed and success, -2 for failure
/**
* @deprecated Always returns {@code -4}, which is outside the documented range.
* Firmware update progress is now tracked internally by the in-app OTA system.
* This method will be removed from the public API in a future release.
*/
int getUpdateStatus();
/// Start providing location (from phone GPS) to mesh

View file

@ -37,7 +37,16 @@ object MeshtasticIntent {
/** Broadcast when the mesh radio disconnects. */
const val ACTION_MESH_DISCONNECTED = "$PREFIX.MESH_DISCONNECTED"
/** Legacy broadcast for connection changes. Extra: [EXTRA_CONNECTED] */
/**
* Legacy broadcast for connection changes. Extra: [EXTRA_CONNECTED]
*
* Prefer [ACTION_MESH_CONNECTED] / [ACTION_MESH_DISCONNECTED] instead. This constant will be removed from the
* public API in a future release.
*/
@Deprecated(
message = "Use ACTION_MESH_CONNECTED / ACTION_MESH_DISCONNECTED instead.",
replaceWith = ReplaceWith("ACTION_MESH_CONNECTED"),
)
const val ACTION_CONNECTION_CHANGED = "$PREFIX.CONNECTION_CHANGED"
/** Broadcast for message status updates. Extras: [EXTRA_PACKET_ID], [EXTRA_STATUS] */

View file

@ -43,6 +43,7 @@ kotlin {
implementation(projects.core.network)
implementation(projects.core.prefs)
implementation(projects.core.proto)
implementation(projects.core.takserver)
implementation(libs.jetbrains.lifecycle.runtime)
implementation(libs.androidx.paging.common)

View file

@ -0,0 +1,66 @@
/*
* Copyright (c) 2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.core.prefs.tak
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.PreferenceDataStoreFactory
import androidx.datastore.preferences.core.Preferences
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.UnconfinedTestDispatcher
import kotlinx.coroutines.test.runTest
import org.junit.Rule
import org.junit.rules.TemporaryFolder
import org.meshtastic.core.di.CoroutineDispatchers
import org.meshtastic.core.repository.TakPrefs
import kotlin.test.BeforeTest
import kotlin.test.Test
import kotlin.test.assertFalse
import kotlin.test.assertTrue
class TakPrefsTest {
@get:Rule val tmpFolder: TemporaryFolder = TemporaryFolder.builder().assureDeletion().build()
private lateinit var dataStore: DataStore<Preferences>
private lateinit var takPrefs: TakPrefs
private lateinit var dispatchers: CoroutineDispatchers
private val testDispatcher = UnconfinedTestDispatcher()
private val testScope = TestScope(testDispatcher)
@BeforeTest
fun setup() {
dataStore =
PreferenceDataStoreFactory.create(
scope = testScope,
produceFile = { tmpFolder.newFile("test.preferences_pb") },
)
dispatchers = CoroutineDispatchers(testDispatcher, testDispatcher, testDispatcher)
takPrefs = TakPrefsImpl(dataStore, dispatchers)
}
@Test
fun `isTakServerEnabled defaults to false`() = testScope.runTest { assertFalse(takPrefs.isTakServerEnabled.value) }
@Test
fun `setting isTakServerEnabled updates preference`() = testScope.runTest {
takPrefs.setTakServerEnabled(true)
assertTrue(takPrefs.isTakServerEnabled.value)
takPrefs.setTakServerEnabled(false)
assertFalse(takPrefs.isTakServerEnabled.value)
}
}

View file

@ -0,0 +1,52 @@
/*
* Copyright (c) 2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.core.prefs.tak
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.booleanPreferencesKey
import androidx.datastore.preferences.core.edit
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import org.koin.core.annotation.Named
import org.koin.core.annotation.Single
import org.meshtastic.core.di.CoroutineDispatchers
import org.meshtastic.core.repository.TakPrefs
@Single(binds = [TakPrefs::class])
class TakPrefsImpl(
@Named("UiDataStore") private val dataStore: DataStore<Preferences>,
dispatchers: CoroutineDispatchers,
) : TakPrefs {
private val scope = CoroutineScope(SupervisorJob() + dispatchers.default)
override val isTakServerEnabled: StateFlow<Boolean> =
dataStore.data.map { it[KEY_TAK_SERVER_ENABLED] ?: false }.stateIn(scope, SharingStarted.Eagerly, false)
override fun setTakServerEnabled(enabled: Boolean) {
scope.launch { dataStore.edit { prefs -> prefs[KEY_TAK_SERVER_ENABLED] = enabled } }
}
companion object {
val KEY_TAK_SERVER_ENABLED = booleanPreferencesKey("tak_server_enabled")
}
}

View file

@ -218,6 +218,13 @@ interface MeshPrefs {
fun setStoreForwardLastRequest(address: String?, timestamp: Int)
}
/** Reactive interface for TAK server settings. */
interface TakPrefs {
val isTakServerEnabled: StateFlow<Boolean>
fun setTakServerEnabled(enabled: Boolean)
}
/** Consolidated interface for all application preferences. */
interface AppPreferences {
val analytics: AnalyticsPrefs
@ -231,4 +238,5 @@ interface AppPreferences {
val mapTileProvider: MapTileProviderPrefs
val radio: RadioPrefs
val mesh: MeshPrefs
val tak: TakPrefs
}

View file

@ -1261,6 +1261,8 @@
<string name="tak">TAK (ATAK)</string>
<string name="tak_config">TAK Configuration</string>
<string name="tak_server_enabled">Enable Local TAK Server</string>
<string name="tak_server_enabled_desc">Starts a TCP server on port 8089 for ATAK connections</string>
<string name="tak_team">Team Color</string>
<string name="tak_role">Member Role</string>

View file

@ -41,6 +41,7 @@ kotlin {
implementation(projects.core.ble)
implementation(projects.core.prefs)
implementation(projects.core.proto)
implementation(projects.core.takserver)
implementation(libs.jetbrains.lifecycle.runtime)
implementation(libs.kotlinx.atomicfu)

View file

@ -196,6 +196,7 @@ class AndroidRadioControllerImpl(
}
override fun setDeviceAddress(address: String) {
@Suppress("DEPRECATION") // Internal use: routes address change through AIDL binder
serviceRepository.meshService?.setDeviceAddress(address)
// Ensure service is running/restarted to handle the new address
val intent =

View file

@ -23,6 +23,8 @@ const val PREFIX = "com.geeksville.mesh"
const val ACTION_NODE_CHANGE = MeshtasticIntent.ACTION_NODE_CHANGE
const val ACTION_MESH_CONNECTED = MeshtasticIntent.ACTION_MESH_CONNECTED
const val ACTION_MESH_DISCONNECTED = MeshtasticIntent.ACTION_MESH_DISCONNECTED
@Suppress("DEPRECATION") // Intentionally re-exported for backward-compat broadcast in ServiceBroadcasts
const val ACTION_CONNECTION_CHANGED = MeshtasticIntent.ACTION_CONNECTION_CHANGED
const val ACTION_MESSAGE_STATUS = MeshtasticIntent.ACTION_MESSAGE_STATUS

View file

@ -85,6 +85,7 @@ class MeshService : Service() {
fun createIntent(context: Context) = Intent(context, MeshService::class.java)
fun changeDeviceAddress(context: Context, service: IMeshService, address: String?) {
@Suppress("DEPRECATION") // Internal use: routes address change through AIDL binder
service.setDeviceAddress(address)
startService(context)
}
@ -185,6 +186,7 @@ class MeshService : Service() {
private val binder =
object : IMeshService.Stub() {
@Suppress("OVERRIDE_DEPRECATION")
override fun setDeviceAddress(deviceAddr: String?) = toRemoteExceptions {
Logger.d { "Passing through device change to radio service: ${deviceAddr?.take(8)}..." }
router.actionHandler.handleUpdateLastAddress(deviceAddr)
@ -195,10 +197,12 @@ class MeshService : Service() {
serviceBroadcasts.subscribeReceiver(receiverName, packageName)
}
@Suppress("OVERRIDE_DEPRECATION")
override fun getUpdateStatus(): Int = -4
@Suppress("OVERRIDE_DEPRECATION")
override fun startFirmwareUpdate() {
// Not implemented yet
// No-op: firmware update is handled by the in-app OTA system.
}
override fun getMyNodeInfo(): MyNodeInfo? = nodeManager.getMyNodeInfo()

View file

@ -77,10 +77,10 @@ class ServiceBroadcasts(private val context: Context, private val serviceReposit
longitude = longitude,
altitude = position.altitude ?: 0,
time = position.time,
satellitesInView = position.sats_in_view ?: 0,
satellitesInView = position.sats_in_view,
groundSpeed = position.ground_speed ?: 0,
groundTrack = position.ground_track ?: 0,
precisionBits = position.precision_bits ?: 0,
precisionBits = position.precision_bits,
)
.takeIf { latitude != 0.0 || longitude != 0.0 },
snr = snr,

View file

@ -101,12 +101,15 @@ open class FakeIMeshService : IMeshService.Stub() {
override fun connectionState(): String = "CONNECTED"
@Suppress("OVERRIDE_DEPRECATION")
override fun setDeviceAddress(deviceAddr: String?): Boolean = true
override fun getMyNodeInfo(): MyNodeInfo? = null
@Suppress("OVERRIDE_DEPRECATION")
override fun startFirmwareUpdate() {}
@Suppress("OVERRIDE_DEPRECATION")
override fun getUpdateStatus(): Int = 0
override fun startProvideLocation() {}

View file

@ -32,6 +32,9 @@ import org.meshtastic.core.repository.NodeManager
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
/**
* Platform-agnostic orchestrator for the mesh service lifecycle.
@ -53,9 +56,13 @@ class MeshServiceOrchestrator(
private val connectionManager: MeshConnectionManager,
private val router: MeshRouter,
private val serviceNotifications: MeshServiceNotifications,
private val takServerManager: TAKServerManager,
private val takMeshIntegration: TAKMeshIntegration,
private val takPrefs: TakPrefs,
private val dispatchers: org.meshtastic.core.di.CoroutineDispatchers,
) {
private var serviceJob: Job? = null
private var takJob: Job? = null
/** The coroutine scope for the service. Available after [start] is called. */
var serviceScope: CoroutineScope? = null
@ -92,6 +99,20 @@ class MeshServiceOrchestrator(
messageProcessor.start(scope)
commandSender.start(scope)
// Observe TAK server pref to start/stop
takJob =
takPrefs.isTakServerEnabled
.onEach { isEnabled ->
if (isEnabled && !takServerManager.isRunning.value) {
Logger.i { "TAK Server enabled by preference, starting integration..." }
takMeshIntegration.start(scope)
} else if (!isEnabled && takServerManager.isRunning.value) {
Logger.i { "TAK Server disabled by preference, stopping integration..." }
takMeshIntegration.stop()
}
}
.launchIn(scope)
scope.handledLaunch { radioInterfaceService.connect() }
radioInterfaceService.receivedData
@ -110,6 +131,12 @@ class MeshServiceOrchestrator(
*/
fun stop() {
Logger.i { "Stopping mesh service orchestrator" }
takJob?.cancel()
takJob = null
// Guard stop() so we don't emit a spurious "stopped" log when TAK was never started
if (takServerManager.isRunning.value) {
takMeshIntegration.stop()
}
serviceJob?.cancel()
serviceJob = null
serviceScope = null

View file

@ -23,17 +23,25 @@ import dev.mokkery.matcher.any
import dev.mokkery.mock
import dev.mokkery.verify
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.test.UnconfinedTestDispatcher
import org.meshtastic.core.di.CoroutineDispatchers
import org.meshtastic.core.repository.CommandSender
import org.meshtastic.core.repository.MeshConfigHandler
import org.meshtastic.core.repository.MeshConnectionManager
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
import kotlin.test.assertTrue
@ -44,11 +52,16 @@ 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)
private val router: MeshRouter = mock(MockMode.autofill)
private val meshConfigHandler: MeshConfigHandler = mock(MockMode.autofill)
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 testDispatcher = UnconfinedTestDispatcher()
private val dispatchers = CoroutineDispatchers(testDispatcher, testDispatcher, testDispatcher)
@ -57,6 +70,22 @@ class MeshServiceOrchestratorTest {
fun testStartWiresComponents() {
every { radioInterfaceService.receivedData } returns MutableSharedFlow()
every { serviceRepository.serviceAction } returns MutableSharedFlow()
every { serviceRepository.meshPacketFlow } returns MutableSharedFlow()
every { meshConfigHandler.moduleConfig } returns MutableStateFlow(LocalModuleConfig())
every { takPrefs.isTakServerEnabled } returns MutableStateFlow(false)
every { takServerManager.isRunning } returns MutableStateFlow(false)
every { takServerManager.inboundMessages } returns MutableSharedFlow()
every { nodeRepository.nodeDBbyNum } returns MutableStateFlow(emptyMap())
val takMeshIntegration =
TAKMeshIntegration(
takServerManager = takServerManager,
commandSender = commandSender,
nodeRepository = nodeRepository,
serviceRepository = serviceRepository,
meshConfigHandler = meshConfigHandler,
cotHandler = cotHandler,
)
val orchestrator =
MeshServiceOrchestrator(
@ -69,6 +98,9 @@ class MeshServiceOrchestratorTest {
connectionManager = connectionManager,
router = router,
serviceNotifications = serviceNotifications,
takServerManager = takServerManager,
takMeshIntegration = takMeshIntegration,
takPrefs = takPrefs,
dispatchers = dispatchers,
)
@ -83,4 +115,61 @@ class MeshServiceOrchestratorTest {
orchestrator.stop()
assertFalse(orchestrator.isRunning)
}
@Test
fun testTakServerStartsAndStopsWithPreference() {
val takEnabledFlow = MutableStateFlow(false)
val takRunningFlow = MutableStateFlow(false)
every { radioInterfaceService.receivedData } returns MutableSharedFlow()
every { serviceRepository.serviceAction } returns MutableSharedFlow()
every { serviceRepository.meshPacketFlow } returns MutableSharedFlow()
every { meshConfigHandler.moduleConfig } returns MutableStateFlow(LocalModuleConfig())
every { takPrefs.isTakServerEnabled } returns takEnabledFlow
every { takServerManager.isRunning } returns takRunningFlow
every { takServerManager.inboundMessages } returns MutableSharedFlow()
every { nodeRepository.nodeDBbyNum } returns MutableStateFlow(emptyMap())
val takMeshIntegration =
TAKMeshIntegration(
takServerManager = takServerManager,
commandSender = commandSender,
nodeRepository = nodeRepository,
serviceRepository = serviceRepository,
meshConfigHandler = meshConfigHandler,
cotHandler = cotHandler,
)
val orchestrator =
MeshServiceOrchestrator(
radioInterfaceService = radioInterfaceService,
serviceRepository = serviceRepository,
packetHandler = packetHandler,
nodeManager = nodeManager,
messageProcessor = messageProcessor,
commandSender = commandSender,
connectionManager = connectionManager,
router = router,
serviceNotifications = serviceNotifications,
takServerManager = takServerManager,
takMeshIntegration = takMeshIntegration,
takPrefs = takPrefs,
dispatchers = dispatchers,
)
orchestrator.start()
// Toggle on
takEnabledFlow.value = true
verify { takServerManager.start(any()) }
// Update mock state to reflect it's running
takRunningFlow.value = true
// Toggle off
takEnabledFlow.value = false
verify { takServerManager.stop() }
orchestrator.stop()
}
}

View file

@ -0,0 +1,64 @@
/*
* Copyright (c) 2025-2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
plugins {
alias(libs.plugins.meshtastic.kmp.library)
alias(libs.plugins.meshtastic.kotlinx.serialization)
id("meshtastic.kmp.jvm.android")
id("meshtastic.koin")
}
kotlin {
@Suppress("UnstableApiUsage")
android {
namespace = "org.meshtastic.core.takserver"
androidResources.enable = false
withHostTest { isIncludeAndroidResources = true }
}
jvm {}
sourceSets {
commonMain.dependencies {
api(projects.core.repository)
implementation(projects.core.common)
implementation(projects.core.di)
implementation(projects.core.model)
implementation(projects.core.proto)
implementation(libs.okio)
implementation(libs.kotlinx.serialization.json)
implementation(libs.xmlutil.core)
implementation(libs.xmlutil.serialization)
implementation(libs.ktor.client.core)
implementation(libs.ktor.network)
implementation(libs.kotlinx.datetime)
implementation(libs.kermit)
}
jvmAndroidMain.dependencies {}
commonTest.dependencies {
implementation(projects.core.testing)
implementation(libs.kotlinx.coroutines.test)
implementation(libs.turbine)
implementation(libs.kotest.assertions)
implementation(libs.kotest.property)
}
}
}

View file

@ -0,0 +1,72 @@
/*
* Copyright (c) 2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.core.takserver
import kotlin.time.Clock
import kotlin.time.Duration.Companion.minutes
fun org.meshtastic.proto.Position.toCoTMessage(
uid: String,
callsign: String,
team: String = DEFAULT_TAK_TEAM_NAME,
role: String = DEFAULT_TAK_ROLE_NAME,
battery: Int = DEFAULT_TAK_BATTERY,
): CoTMessage {
val lat = (latitude_i ?: 0).toDouble() / TAK_COORDINATE_SCALE
val lon = (longitude_i ?: 0).toDouble() / TAK_COORDINATE_SCALE
val altitude = (altitude ?: 0).toDouble()
val speed = (ground_speed ?: 0).toDouble()
val course = (ground_track ?: 0).toDouble()
return CoTMessage.pli(
uid = uid,
callsign = callsign,
latitude = lat,
longitude = lon,
altitude = altitude,
speed = speed,
course = course,
team = team,
role = role,
battery = battery,
staleMinutes = DEFAULT_TAK_STALE_MINUTES,
)
}
fun org.meshtastic.proto.User.toCoTMessage(
position: org.meshtastic.proto.Position?,
team: String = DEFAULT_TAK_TEAM_NAME,
role: String = DEFAULT_TAK_ROLE_NAME,
battery: Int = DEFAULT_TAK_BATTERY,
): CoTMessage = if (position != null) {
position.toCoTMessage(uid = id, callsign = toTakCallsign(), team = team, role = role, battery = battery)
} else {
val now = Clock.System.now()
CoTMessage(
uid = id,
type = "a-f-G-U-C",
time = now,
start = now,
stale = now + DEFAULT_TAK_STALE_MINUTES.minutes,
how = "m-g",
latitude = 0.0,
longitude = 0.0,
contact = CoTContact(callsign = toTakCallsign(), endpoint = DEFAULT_TAK_ENDPOINT),
group = CoTGroup(name = team, role = role),
status = CoTStatus(battery = battery),
)
}

View file

@ -0,0 +1,66 @@
/*
* Copyright (c) 2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
@file:Suppress("MatchingDeclarationName", "LongMethod", "CyclomaticComplexMethod", "MaxLineLength")
package org.meshtastic.core.takserver
import kotlin.time.Instant
fun CoTMessage.toXml(): String {
val sb = StringBuilder()
sb.append(
"<?xml version='1.0' encoding='UTF-8' standalone='yes'?><event version='2.0' uid='${uid.xmlEscaped()}' type='$type' time='${time.toXmlString()}' start='${start.toXmlString()}' stale='${stale.toXmlString()}' how='$how'><point lat='$latitude' lon='$longitude' hae='$hae' ce='$ce' le='$le'/><detail>",
)
contact?.let {
sb.append(
"<contact endpoint='${it.endpoint ?: DEFAULT_TAK_ENDPOINT}' callsign='${it.callsign.xmlEscaped()}'/><uid Droid='${it.callsign.xmlEscaped()}'/>",
)
}
group?.let { sb.append("<__group role='${it.role.xmlEscaped()}' name='${it.name.xmlEscaped()}'/>") }
status?.let { sb.append("<status battery='${it.battery}'/>") }
track?.let { sb.append("<track course='${it.course}' speed='${it.speed}'/>") }
if (chat != null) {
val senderUid = uid.geoChatSenderUid()
val messageId = uid.geoChatMessageId()
sb.append(
"<__chat parent='RootContactGroup' groupOwner='false' messageId='$messageId' chatroom='${chat.chatroom.xmlEscaped()}' id='${chat.chatroom.xmlEscaped()}' senderCallsign='${chat.senderCallsign?.xmlEscaped() ?: ""}'><chatgrp uid0='${senderUid.xmlEscaped()}' uid1='${chat.chatroom.xmlEscaped()}' id='${chat.chatroom.xmlEscaped()}'/></__chat>",
)
sb.append("<link uid='${senderUid.xmlEscaped()}' type='a-f-G-U-C' relation='p-p'/>")
sb.append("<__serverdestination destinations='0.0.0.0:4242:tcp:${senderUid.xmlEscaped()}'/>")
sb.append(
"<remarks source='BAO.F.ATAK.${senderUid.xmlEscaped()}' to='${chat.chatroom.xmlEscaped()}' time='${time.toXmlString()}'>${chat.message.xmlEscaped()}</remarks>",
)
} else if (!remarks.isNullOrEmpty()) {
sb.append("<remarks>${remarks.xmlEscaped()}</remarks>")
}
rawDetailXml?.let {
if (it.isNotEmpty()) {
sb.append(it)
}
}
sb.append("</detail></event>")
return sb.toString()
}
private fun Instant.toXmlString(): String = this.toString()

View file

@ -0,0 +1,84 @@
/*
* Copyright (c) 2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.core.takserver
import kotlinx.serialization.Serializable
import nl.adaptivity.xmlutil.serialization.XmlElement
import nl.adaptivity.xmlutil.serialization.XmlSerialName
import nl.adaptivity.xmlutil.serialization.XmlValue
@Serializable
@XmlSerialName("event", "", "")
internal data class CoTEventXml(
val version: String = "2.0",
val uid: String,
val type: String,
val time: String,
val start: String,
val stale: String,
val how: String,
@XmlElement(true) val point: CoTPointXml,
@XmlElement(true) val detail: CoTDetailXml? = null,
)
@Serializable
@XmlSerialName("point", "", "")
internal data class CoTPointXml(val lat: Double, val lon: Double, val hae: Double, val ce: Double, val le: Double)
@Serializable
@XmlSerialName("detail", "", "")
internal data class CoTDetailXml(
@XmlElement(true) val contact: CoTContactXml? = null,
@XmlElement(true) @XmlSerialName("__group", "", "") val group: CoTGroupXml? = null,
@XmlElement(true) val status: CoTStatusXml? = null,
@XmlElement(true) val track: CoTTrackXml? = null,
@XmlElement(true) @XmlSerialName("__chat", "", "") val chat: CoTChatXml? = null,
@XmlElement(true) val remarks: CoTRemarksXml? = null,
)
@Serializable
@XmlSerialName("contact", "", "")
internal data class CoTContactXml(val callsign: String = "", val endpoint: String? = null, val phone: String? = null)
@Serializable
@XmlSerialName("__group", "", "")
internal data class CoTGroupXml(val role: String = "", val name: String = "")
@Serializable
@XmlSerialName("status", "", "")
internal data class CoTStatusXml(val battery: Int = 100)
@Serializable
@XmlSerialName("track", "", "")
internal data class CoTTrackXml(val speed: Double = 0.0, val course: Double = 0.0)
@Serializable
@XmlSerialName("__chat", "", "")
internal data class CoTChatXml(
val senderCallsign: String? = null,
val chatroom: String = "All Chat Rooms",
val id: String? = null,
)
@Serializable
@XmlSerialName("remarks", "", "")
internal data class CoTRemarksXml(
val source: String? = null,
val to: String? = null,
val time: String? = null,
@XmlValue(true) val value: String = "",
)

View file

@ -0,0 +1,90 @@
/*
* Copyright (c) 2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
@file:Suppress("LoopWithTooManyJumpStatements")
package org.meshtastic.core.takserver
import okio.Buffer
import okio.ByteString.Companion.encodeUtf8
internal class CoTXmlFrameBuffer(private val maxMessageSize: Long = DEFAULT_MAX_TAK_MESSAGE_SIZE) {
private val buffer = Buffer()
private var discardingUntilNextEvent = false
fun append(data: ByteArray): List<String> {
buffer.write(data)
if (discardingUntilNextEvent) {
val nextEventIdx = buffer.indexOf(EVENT_START_BYTES)
if (nextEventIdx == -1L) {
// Keep the last few bytes in case the start tag is split across chunks
if (buffer.size > EVENT_START_BYTES.size) {
buffer.skip(buffer.size - EVENT_START_BYTES.size.toLong())
}
return emptyList()
}
discardingUntilNextEvent = false
buffer.skip(nextEventIdx)
}
val messages = mutableListOf<String>()
while (true) {
val startIdx = buffer.indexOf(EVENT_START_BYTES)
if (startIdx == -1L) {
if (buffer.size > maxMessageSize) {
buffer.clear()
discardingUntilNextEvent = true
}
break
}
if (startIdx > 0L) {
buffer.skip(startIdx)
}
val endIdx = buffer.indexOf(EVENT_END_BYTES)
if (endIdx == -1L) {
if (buffer.size > maxMessageSize) {
buffer.clear()
discardingUntilNextEvent = true
}
break
}
val xmlEnd = endIdx + EVENT_END_BYTES.size
if (xmlEnd <= maxMessageSize) {
messages += buffer.readUtf8(xmlEnd)
} else {
buffer.skip(xmlEnd)
}
}
return messages
}
fun clear() {
buffer.clear()
discardingUntilNextEvent = false
}
companion object {
private val EVENT_START_BYTES = "<event".encodeUtf8()
private val EVENT_END_BYTES = "</event>".encodeUtf8()
private const val DEFAULT_MAX_TAK_MESSAGE_SIZE = 8L * 1024 * 1024
}
}

View file

@ -0,0 +1,114 @@
/*
* Copyright (c) 2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.core.takserver
import nl.adaptivity.xmlutil.serialization.XML
import kotlin.time.Clock
import kotlin.time.Instant
private val xmlParser = XML {
defaultPolicy {
ignoreUnknownChildren()
repairNamespaces = false
}
}
class CoTXmlParser(private val xml: String) {
fun parse(): Result<CoTMessage> = try {
val event = xmlParser.decodeFromString(CoTEventXml.serializer(), xml)
Result.success(buildCoTMessage(event))
} catch (e: IllegalArgumentException) {
Result.failure(e)
} catch (e: kotlinx.serialization.SerializationException) {
Result.failure(e)
} catch (e: nl.adaptivity.xmlutil.XmlException) {
Result.failure(e)
}
private fun buildCoTMessage(event: CoTEventXml): CoTMessage {
val detail = event.detail
return CoTMessage(
uid = event.uid.ifEmpty { "tak-0" },
type = event.type.ifEmpty { "a-f-G-U-C" },
time = parseDate(event.time),
start = parseDate(event.start),
stale = parseDate(event.stale),
how = event.how.ifEmpty { "m-g" },
latitude = event.point.lat,
longitude = event.point.lon,
hae = event.point.hae,
ce = event.point.ce,
le = event.point.le,
contact = buildContact(detail),
group = buildGroup(detail),
status = detail?.status?.let { CoTStatus(battery = it.battery) },
track = detail?.track?.let { CoTTrack(speed = it.speed, course = it.course) },
chat = buildChat(detail),
remarks = buildRemarks(detail),
)
}
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)
} else {
null
}
}
private fun buildGroup(detail: CoTDetailXml?): CoTGroup? = detail?.group?.let {
if (it.name.isNotEmpty() || it.role.isNotEmpty()) {
CoTGroup(
name = it.name.ifEmpty { DEFAULT_TAK_TEAM_NAME },
role = it.role.ifEmpty { DEFAULT_TAK_ROLE_NAME },
)
} else {
null
}
}
private fun buildChat(detail: CoTDetailXml?): CoTChat? = detail?.chat?.let {
val remarksText = detail.remarks?.value ?: ""
CoTChat(
message = remarksText,
senderCallsign = it.senderCallsign,
chatroom = it.chatroom.ifEmpty { it.id ?: "All Chat Rooms" },
)
}
private fun buildRemarks(detail: CoTDetailXml?): String? =
if (detail?.chat == null && detail?.remarks != null && detail.remarks.value.isNotEmpty()) {
detail.remarks.value
} else {
null
}
private fun parseDate(dateString: String?): Instant {
if (dateString.isNullOrEmpty()) return Clock.System.now()
return try {
Instant.parse(dateString)
} catch (ignored: IllegalArgumentException) {
try {
val cleaned = dateString.replace(Regex("""\.\d+"""), "").replace("Z", "+00:00")
Instant.parse(cleaned)
} catch (ignoredInner: IllegalArgumentException) {
Clock.System.now() // Return now as fallback
}
}
}
}

View file

@ -0,0 +1,228 @@
/*
* Copyright (c) 2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
@file:Suppress("TooManyFunctions", "TooGenericExceptionCaught")
package org.meshtastic.core.takserver
import co.touchlab.kermit.Logger
import io.ktor.network.sockets.Socket
import io.ktor.network.sockets.isClosed
import io.ktor.network.sockets.openReadChannel
import io.ktor.network.sockets.openWriteChannel
import io.ktor.utils.io.ByteReadChannel
import io.ktor.utils.io.ByteWriteChannel
import io.ktor.utils.io.readAvailable
import io.ktor.utils.io.writeStringUtf8
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlin.concurrent.Volatile
import kotlin.random.Random
import kotlin.time.Clock
import kotlin.time.Duration.Companion.milliseconds
import kotlin.time.Instant
import kotlinx.coroutines.isActive as coroutineIsActive
class TAKClientConnection(
private val socket: Socket,
val clientInfo: TAKClientInfo,
private val onEvent: (TAKConnectionEvent) -> Unit,
private val scope: CoroutineScope,
) {
private var currentClientInfo = clientInfo
private val frameBuffer = CoTXmlFrameBuffer()
private val readChannel: ByteReadChannel = socket.openReadChannel()
private val writeChannel: ByteWriteChannel = socket.openWriteChannel(autoFlush = true)
private val writeMutex = Mutex()
/** Guards against emitting [TAKConnectionEvent.Disconnected] more than once. */
@Volatile private var disconnectedEmitted = false
fun start() {
onEvent(TAKConnectionEvent.Connected(currentClientInfo))
sendProtocolSupport()
scope.launch { readLoop() }
scope.launch { keepaliveLoop() }
}
private fun sendProtocolSupport() {
val serverUid = "Meshtastic-TAK-Server-${Random.nextInt().toString(TAK_HEX_RADIX)}"
val now = Clock.System.now()
val stale = now + TAK_KEEPALIVE_INTERVAL_MS.milliseconds
val detail =
"""
<TakControl>
<TakProtocolSupport version="0"/>
</TakControl>
"""
.trimIndent()
sendXml(buildEventXml(uid = serverUid, type = "t-x-takp-v", now = now, stale = stale, detail = detail))
}
private suspend fun readLoop() {
try {
val buffer = ByteArray(TAK_XML_READ_BUFFER_SIZE)
while (scope.coroutineIsActive && !socket.isClosed) {
// Suspend until data is available — no polling delay needed
readChannel.awaitContent()
val bytesRead = readChannel.readAvailable(buffer)
if (bytesRead > 0) {
processReceivedData(buffer.copyOfRange(0, bytesRead))
} else if (bytesRead == -1) {
break // EOF
}
}
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
Logger.w(e) { "TAK client read error: ${currentClientInfo.id}" }
emitDisconnected(TAKConnectionEvent.Error(e))
return
}
emitDisconnected(TAKConnectionEvent.Disconnected)
}
private suspend fun keepaliveLoop() {
while (scope.coroutineIsActive && !socket.isClosed) {
kotlinx.coroutines.delay(TAK_KEEPALIVE_INTERVAL_MS)
sendKeepalive()
}
}
private fun sendKeepalive() {
val now = Clock.System.now()
val stale = now + TAK_KEEPALIVE_INTERVAL_MS.milliseconds
sendXml(buildEventXml(uid = "takPong", type = "t-x-d-d", now = now, stale = stale, detail = ""))
}
private fun processReceivedData(newData: ByteArray) {
// frameBuffer.append returns List<String> — pass directly without re-encoding
frameBuffer.append(newData).forEach { xmlString -> parseAndHandleMessage(xmlString) }
}
private fun parseAndHandleMessage(xmlString: String) {
// Parse first, then filter on the structured type field to avoid false positives
val parser = CoTXmlParser(xmlString)
val result = parser.parse()
result.onSuccess { cotMessage ->
when {
cotMessage.type.startsWith("t-x-takp") -> {
handleProtocolControl(cotMessage.type, xmlString)
return
}
cotMessage.type == "t-x-c-t" || cotMessage.uid == "ping" -> {
// Keepalive / ping — discard silently
return
}
else -> {
cotMessage.contact?.let { contact ->
val updatedClientInfo =
currentClientInfo.copy(
callsign = currentClientInfo.callsign ?: contact.callsign,
uid = currentClientInfo.uid ?: cotMessage.uid,
)
if (updatedClientInfo != currentClientInfo) {
currentClientInfo = updatedClientInfo
onEvent(TAKConnectionEvent.ClientInfoUpdated(updatedClientInfo))
}
}
onEvent(TAKConnectionEvent.Message(cotMessage))
}
}
}
}
private fun handleProtocolControl(type: String, xmlString: String) {
if (type == "t-x-takp-q") {
sendProtocolResponse()
} else {
Logger.d { "Unhandled protocol control type: $type (raw=$xmlString)" }
}
}
private fun sendProtocolResponse() {
val serverUid = "Meshtastic-TAK-Server-${Random.nextInt().toString(TAK_HEX_RADIX)}"
val now = Clock.System.now()
val stale = now + TAK_KEEPALIVE_INTERVAL_MS.milliseconds
val detail =
"""
<TakControl>
<TakResponse status="true"/>
</TakControl>
"""
.trimIndent()
sendXml(buildEventXml(uid = serverUid, type = "t-x-takp-r", now = now, stale = stale, detail = detail))
}
fun send(cotMessage: CoTMessage) {
val xml = cotMessage.toXml()
sendXml(xml)
}
private fun buildEventXml(uid: String, type: String, now: Instant, stale: Instant, detail: String): String {
val detailContent = if (detail.isBlank()) "<detail/>" else "<detail>$detail</detail>"
val point = """<point lat="0" lon="0" hae="0" ce="$TAK_UNKNOWN_POINT_VALUE" le="$TAK_UNKNOWN_POINT_VALUE"/>"""
return """<event version="2.0" uid="$uid" type="$type" time="$now" start="$now" stale="$stale" how="m-g">""" +
point +
detailContent +
"</event>"
}
private fun sendXml(xml: String) {
scope.launch {
try {
writeMutex.withLock {
if (!socket.isClosed) {
writeChannel.writeStringUtf8(xml)
}
}
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
Logger.w(e) { "TAK client send error: ${currentClientInfo.id}" }
}
}
}
fun close() {
frameBuffer.clear()
try {
socket.close()
} catch (e: Exception) {
Logger.w(e) { "Error closing TAK client socket: ${currentClientInfo.id}" }
}
emitDisconnected(TAKConnectionEvent.Disconnected)
}
/**
* Emits [event] (expected to be [TAKConnectionEvent.Disconnected] or [TAKConnectionEvent.Error]) at most once
* across all code paths.
*/
private fun emitDisconnected(event: TAKConnectionEvent) {
if (!disconnectedEmitted) {
disconnectedEmitted = true
onEvent(event)
}
}
}

View file

@ -0,0 +1,113 @@
/*
* Copyright (c) 2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.core.takserver
import nl.adaptivity.xmlutil.XmlDeclMode
import nl.adaptivity.xmlutil.serialization.XML
import kotlin.uuid.Uuid
/**
* Generates TAK data packages (.zip) compatible with ATAK/iTAK import.
*
* The data package follows the MissionPackageManifest v2 format:
* ```
* Meshtastic_TAK_Server.zip
* meshtastic-server.pref (ATAK connection preferences)
* manifest.xml (MissionPackageManifest v2)
* ```
*/
object TAKDataPackageGenerator {
private const val PREF_FILE_NAME = "meshtastic-server.pref"
private const val PACKAGE_NAME = "Meshtastic_TAK_Server"
private val xmlSerializer = XML {
xmlDeclMode = XmlDeclMode.Charset
indentString = " "
}
/**
* Generate a complete TAK data package zip.
*
* @return zip file contents as a [ByteArray]
*/
fun generateDataPackage(
serverHost: String = "127.0.0.1",
port: Int = DEFAULT_TAK_PORT,
description: String = "Meshtastic TAK Server",
): ByteArray {
val prefContent = generateConfigPref(serverHost, port, description)
val manifestContent = generateManifest(uid = Uuid.random().toString(), description = description)
val entries =
mapOf(
PREF_FILE_NAME to prefContent.encodeToByteArray(),
"manifest.xml" to manifestContent.encodeToByteArray(),
)
return ZipArchiver.createZip(entries)
}
internal fun generateConfigPref(
serverHost: String = "127.0.0.1",
port: Int = DEFAULT_TAK_PORT,
description: String = "Meshtastic TAK Server",
): String {
val prefs =
TAKPreferencesXml(
preferences =
listOf(
TAKPreferenceXml(
version = "1",
name = "cot_streams",
entries =
listOf(
TAKEntryXml("count", "class java.lang.Integer", "1"),
TAKEntryXml("description0", "class java.lang.String", description),
TAKEntryXml("enabled0", "class java.lang.Boolean", "true"),
TAKEntryXml("connectString0", "class java.lang.String", "$serverHost:$port:tcp"),
),
),
TAKPreferenceXml(
version = "1",
name = "com.atakmap.app_preferences",
entries =
listOf(TAKEntryXml("displayServerConnectionWidget", "class java.lang.Boolean", "true")),
),
),
)
return xmlSerializer
.encodeToString(TAKPreferencesXml.serializer(), prefs)
.replace(
"<?xml version=\"1.0\" encoding=\"UTF-8\"?>",
"<?xml version='1.0' encoding='ASCII' standalone='yes'?>",
)
}
internal fun generateManifest(uid: String, description: String = "Meshtastic TAK Server"): String = buildString {
appendLine("""<MissionPackageManifest version="2">""")
appendLine(" <Configuration>")
appendLine(""" <Parameter name="uid" value="${description.xmlEscaped()}.$uid"/>""")
appendLine(""" <Parameter name="name" value="$PACKAGE_NAME"/>""")
appendLine(""" <Parameter name="onReceiveDelete" value="true"/>""")
appendLine(" </Configuration>")
appendLine(" <Contents>")
appendLine(""" <Content ignore="false" zipEntry="$PREF_FILE_NAME"/>""")
appendLine(" </Contents>")
append("</MissionPackageManifest>")
}
}

View file

@ -0,0 +1,58 @@
/*
* Copyright (c) 2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.core.takserver
import org.meshtastic.proto.MemberRole
import org.meshtastic.proto.Team
import org.meshtastic.proto.User
internal const val DEFAULT_TAK_PORT = 8087
internal const val DEFAULT_TAK_ENDPOINT = "0.0.0.0:4242:tcp"
internal const val DEFAULT_TAK_TEAM_NAME = "Cyan"
internal const val DEFAULT_TAK_ROLE_NAME = "Team Member"
internal const val DEFAULT_TAK_BATTERY = 100
internal const val DEFAULT_TAK_STALE_MINUTES = 10
internal const val TAK_HEX_RADIX = 16
internal const val TAK_XML_READ_BUFFER_SIZE = 4_096
internal const val TAK_KEEPALIVE_INTERVAL_MS = 30_000L
internal const val TAK_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
internal fun Team?.toTakTeamName(): String = when (this) {
null,
Team.Unspecifed_Color,
-> DEFAULT_TAK_TEAM_NAME
else -> name.replace('_', ' ')
}
internal fun MemberRole?.toTakRoleName(): String = when (this) {
null,
MemberRole.Unspecifed,
-> DEFAULT_TAK_ROLE_NAME
MemberRole.TeamMember -> DEFAULT_TAK_ROLE_NAME
MemberRole.TeamLead -> "Team Lead"
MemberRole.ForwardObserver -> "Forward Observer"
else -> name
}
internal fun User.toTakCallsign(): String = when {
short_name.isNotBlank() -> short_name
long_name.isNotBlank() -> long_name
else -> id
}

View file

@ -0,0 +1,163 @@
/*
* Copyright (c) 2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
@file:Suppress("ReturnCount", "TooGenericExceptionCaught")
package org.meshtastic.core.takserver
import co.touchlab.kermit.Logger
import kotlinx.coroutines.CoroutineScope
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.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
class TAKMeshIntegration(
private val takServerManager: TAKServerManager,
private val commandSender: CommandSender,
private val nodeRepository: NodeRepository,
private val serviceRepository: ServiceRepository,
private val meshConfigHandler: MeshConfigHandler,
private val cotHandler: CoTHandler,
) {
@Volatile private var isRunning = false
private val jobs = mutableListOf<Job>()
private var currentTeam: Team = Team.Unspecifed_Color
private var currentRole: MemberRole = MemberRole.Unspecifed
fun start(scope: CoroutineScope) {
if (isRunning) return
isRunning = true
takServerManager.start(scope)
val newJobs =
listOf(
// Forward incoming CoT from TAK clients to mesh
scope.launch { takServerManager.inboundMessages.collect { cotMessage -> sendCoTToMesh(cotMessage) } },
// 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) }
},
// 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
}
},
)
jobs.addAll(newJobs)
Logger.i { "TAK Mesh Integration started" }
}
fun stop() {
if (!isRunning) return
isRunning = false
// Cancel all tracked jobs and clear the list
val toCancel: List<Job>
toCancel = jobs.toList()
jobs.clear()
toCancel.forEach(Job::cancel)
takServerManager.stop()
Logger.i { "TAK Mesh Integration stopped" }
}
private suspend fun sendCoTToMesh(cotMessage: CoTMessage) {
val takPacket = cotMessage.toTAKPacket()
if (takPacket == null) {
cotHandler.sendGenericCoT(cotMessage)
return
}
val payload = TAKPacket.ADAPTER.encode(takPacket)
val dataPacket =
DataPacket(
to = DataPacket.ID_BROADCAST,
bytes = payload.toByteString(),
dataType = PortNum.ATAK_PLUGIN.value,
)
commandSender.sendData(dataPacket)
Logger.d { "Forwarded CoT to mesh as TAKPacket: ${cotMessage.type}" }
}
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
}
val takPacket =
try {
TAKPacket.ADAPTER.decode(payload)
} catch (e: Exception) {
Logger.w(e) { "Failed to decode TAKPacket from mesh" }
return
}
val cotMessage = takPacket.toCoTMessage() ?: return
takServerManager.broadcast(cotMessage)
Logger.d { "Forwarded ATAK mesh packet to TAK clients: ${cotMessage.type}" }
}
}

View file

@ -0,0 +1,138 @@
/*
* Copyright (c) 2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.core.takserver
import kotlinx.serialization.Serializable
import kotlin.random.Random
import kotlin.time.Clock
import kotlin.time.Duration.Companion.days
import kotlin.time.Duration.Companion.minutes
import kotlin.time.Instant
@Serializable
data class CoTMessage(
val uid: String,
val type: String,
val time: Instant = Clock.System.now(),
val start: Instant = time,
val stale: Instant,
val how: String = "m-g",
val latitude: Double = 0.0,
val longitude: Double = 0.0,
val hae: Double = TAK_UNKNOWN_POINT_VALUE,
val ce: Double = TAK_UNKNOWN_POINT_VALUE,
val le: Double = TAK_UNKNOWN_POINT_VALUE,
val contact: CoTContact? = null,
val group: CoTGroup? = null,
val status: CoTStatus? = null,
val track: CoTTrack? = null,
val chat: CoTChat? = null,
val remarks: String? = null,
val rawDetailXml: String? = null,
) {
companion object {
fun pli(
uid: String,
callsign: String,
latitude: Double,
longitude: Double,
altitude: Double = TAK_UNKNOWN_POINT_VALUE,
speed: Double = 0.0,
course: Double = 0.0,
team: String = DEFAULT_TAK_TEAM_NAME,
role: String = DEFAULT_TAK_ROLE_NAME,
battery: Int = DEFAULT_TAK_BATTERY,
staleMinutes: Int = DEFAULT_TAK_STALE_MINUTES,
): CoTMessage {
val now = Clock.System.now()
return CoTMessage(
uid = uid,
type = "a-f-G-U-C",
time = now,
start = now,
stale = now + staleMinutes.minutes,
how = "m-g",
latitude = latitude,
longitude = longitude,
hae = altitude,
ce = TAK_UNKNOWN_POINT_VALUE,
le = TAK_UNKNOWN_POINT_VALUE,
contact = CoTContact(callsign = callsign, endpoint = DEFAULT_TAK_ENDPOINT),
group = CoTGroup(name = team, role = role),
status = CoTStatus(battery = battery),
track = CoTTrack(speed = speed, course = course),
)
}
fun chat(
senderUid: String,
senderCallsign: String,
message: String,
chatroom: String = "All Chat Rooms",
): CoTMessage {
val now = Clock.System.now()
val messageId = Random.nextInt().toString(TAK_HEX_RADIX)
return CoTMessage(
uid = "GeoChat.$senderUid.$chatroom.$messageId",
contact = CoTContact(callsign = senderCallsign, endpoint = DEFAULT_TAK_ENDPOINT),
type = "b-t-f",
time = now,
start = now,
stale = now + 1.days,
how = "h-g-i-g-o",
latitude = 0.0,
longitude = 0.0,
hae = TAK_UNKNOWN_POINT_VALUE,
ce = TAK_UNKNOWN_POINT_VALUE,
le = TAK_UNKNOWN_POINT_VALUE,
chat = CoTChat(message = message, senderCallsign = senderCallsign, chatroom = chatroom),
remarks = message,
)
}
}
}
@Serializable data class CoTContact(val callsign: String, val endpoint: String? = null, val phone: String? = null)
@Serializable data class CoTGroup(val name: String, val role: String)
@Serializable data class CoTStatus(val battery: Int)
@Serializable data class CoTTrack(val speed: Double, val course: Double)
@Serializable
data class CoTChat(val message: String, val senderCallsign: String? = null, val chatroom: String = "All Chat Rooms")
data class TAKClientInfo(
val id: String,
val endpoint: String,
val callsign: String? = null,
val uid: String? = null,
val connectedAt: Long = Clock.System.now().toEpochMilliseconds(),
)
sealed class TAKConnectionEvent {
data class Connected(val clientInfo: TAKClientInfo) : TAKConnectionEvent()
data class ClientInfoUpdated(val clientInfo: TAKClientInfo) : TAKConnectionEvent()
data class Message(val cotMessage: CoTMessage) : TAKConnectionEvent()
data object Disconnected : TAKConnectionEvent()
data class Error(val error: Throwable) : TAKConnectionEvent()
}

View file

@ -0,0 +1,196 @@
/*
* Copyright (c) 2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
@file:Suppress("CyclomaticComplexMethod", "ReturnCount")
package org.meshtastic.core.takserver
import co.touchlab.kermit.Logger
import org.meshtastic.proto.Contact
import org.meshtastic.proto.GeoChat
import org.meshtastic.proto.Group
import org.meshtastic.proto.MemberRole
import org.meshtastic.proto.PLI
import org.meshtastic.proto.Status
import org.meshtastic.proto.TAKPacket
import org.meshtastic.proto.Team
import kotlin.random.Random
import kotlin.time.Clock
import kotlin.time.Duration.Companion.minutes
object TAKPacketConversion {
fun CoTMessage.toTAKPacket(): TAKPacket? {
val group =
this.group?.let {
Group(
role = MemberRole.fromValue(getMemberRoleValue(it.role)) ?: MemberRole.Unspecifed,
team = Team.fromValue(getTeamValue(it.name)) ?: Team.Unspecifed_Color,
)
}
val status = this.status?.let { Status(battery = it.battery.coerceAtLeast(0)) }
if (type.startsWith("a-f-G") || type.startsWith("a-f-g")) {
return createPliPacket(group, status)
}
if (type == "b-t-f") {
return createChatPacket(group, status)
}
Logger.w { "Cannot convert CoT to TAKPacket for type $type" }
return null
}
private fun CoTMessage.createPliPacket(group: Group?, status: Status?): TAKPacket {
val contact = this.contact?.let { Contact(callsign = it.callsign, device_callsign = this.uid) }
val pli =
PLI(
latitude_i = (latitude * TAK_COORDINATE_SCALE).toInt(),
longitude_i = (longitude * TAK_COORDINATE_SCALE).toInt(),
altitude = if (hae >= TAK_UNKNOWN_POINT_VALUE || hae.isNaN()) 0 else hae.toInt(),
speed = track?.speed?.coerceAtLeast(0.0)?.toInt() ?: 0,
course = track?.course?.coerceAtLeast(0.0)?.toInt() ?: 0,
)
return TAKPacket(is_compressed = false, contact = contact, group = group, status = status, pli = pli)
}
private fun CoTMessage.createChatPacket(group: Group?, status: Status?): TAKPacket? {
val localChat = this.chat ?: return null
val chatMsg = localChat.message
var toUid: String? = null
var toCallsign: String? = null
val actualDeviceUid = this.uid.geoChatSenderUid()
val messageId =
if (this.uid.startsWith("GeoChat.")) {
this.uid.geoChatMessageId()
} else {
Random.nextInt().toString(TAK_HEX_RADIX)
}
val contact =
this.contact?.let {
val smuggledCallsign =
if (actualDeviceUid.isNotEmpty()) {
"$actualDeviceUid|$messageId"
} else {
it.endpoint ?: ""
}
Contact(callsign = it.callsign, device_callsign = smuggledCallsign)
}
if (localChat.chatroom.startsWith(this.uid) || this.uid.startsWith("GeoChat")) {
val parts = this.uid.split(".")
if (parts.size >= TAK_DIRECT_MESSAGE_PARTS_MIN && parts[0] == "GeoChat") {
toUid = localChat.chatroom
}
} else if (localChat.chatroom != "All Chat Rooms") {
toCallsign = localChat.chatroom
}
val chat =
GeoChat(
message = chatMsg,
to = toUid ?: if (toCallsign == null) "All Chat Rooms" else null,
to_callsign = toCallsign,
)
return TAKPacket(is_compressed = false, contact = contact, group = group, status = status, chat = chat)
}
fun TAKPacket.toCoTMessage(): CoTMessage? {
val rawDeviceCallsign = contact?.device_callsign ?: "UNKNOWN"
val senderCallsign = contact?.callsign ?: "UNKNOWN"
val timeNow = Clock.System.now()
val staleTime = timeNow + DEFAULT_TAK_STALE_MINUTES.minutes
val (senderUid, messageId) = parseDeviceCallsign(rawDeviceCallsign)
val localPli = pli
if (localPli != null) {
return CoTMessage.pli(
uid = senderUid,
callsign = senderCallsign,
latitude = localPli.latitude_i.toDouble() / TAK_COORDINATE_SCALE,
longitude = localPli.longitude_i.toDouble() / TAK_COORDINATE_SCALE,
altitude = localPli.altitude.toDouble(),
speed = localPli.speed.toDouble(),
course = localPli.course.toDouble(),
team = teamToColorName(group?.team),
role = roleToName(group?.role),
battery = status?.battery ?: DEFAULT_TAK_BATTERY,
staleMinutes = DEFAULT_TAK_STALE_MINUTES,
)
}
val localChat = chat
if (localChat != null) {
val chatroom =
if (localChat.to != null || localChat.to_callsign != null) {
localChat.to_callsign ?: localChat.to ?: "Direct Message"
} else {
"All Chat Rooms"
}
val msgId = messageId ?: Random.nextInt().toString(TAK_HEX_RADIX)
return CoTMessage(
uid = "GeoChat.$senderUid.$chatroom.$msgId",
type = "b-t-f",
how = "h-g-i-g-o",
time = timeNow,
start = timeNow,
stale = staleTime,
latitude = 0.0,
longitude = 0.0,
contact = CoTContact(callsign = senderCallsign, endpoint = DEFAULT_TAK_ENDPOINT),
group = CoTGroup(name = teamToColorName(group?.team), role = roleToName(group?.role)),
status = CoTStatus(battery = status?.battery ?: DEFAULT_TAK_BATTERY),
chat = CoTChat(chatroom = chatroom, senderCallsign = senderCallsign, message = localChat.message),
)
}
return null
}
private fun parseDeviceCallsign(combined: String): Pair<String, String?> {
val parts = combined.split("|", limit = 2)
return if (parts.size == 2) {
Pair(parts[0], parts[1].ifEmpty { null })
} else {
Pair(combined, null)
}
}
private fun getTeamValue(name: String): Int =
Team.entries.find { it.name.equals(name, ignoreCase = true) }?.value ?: 0
private fun getMemberRoleValue(roleName: String): Int =
MemberRole.entries.find { it.name.equals(roleName.replace(" ", ""), ignoreCase = true) }?.value ?: 0
private fun teamToColorName(team: Team?): String {
if (team == null || team == Team.Unspecifed_Color) return DEFAULT_TAK_TEAM_NAME
return team.toTakTeamName()
}
private fun roleToName(role: MemberRole?): String {
if (role == null || role == MemberRole.Unspecifed) return DEFAULT_TAK_ROLE_NAME
return role.toTakRoleName()
}
}

View file

@ -0,0 +1,42 @@
/*
* Copyright (c) 2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.core.takserver
import kotlinx.serialization.Serializable
import nl.adaptivity.xmlutil.serialization.XmlElement
import nl.adaptivity.xmlutil.serialization.XmlSerialName
import nl.adaptivity.xmlutil.serialization.XmlValue
@Serializable
@XmlSerialName("preferences", "", "")
internal data class TAKPreferencesXml(@XmlElement(true) val preferences: List<TAKPreferenceXml>)
@Serializable
@XmlSerialName("preference", "", "")
internal data class TAKPreferenceXml(
val version: String,
val name: String,
@XmlElement(true) val entries: List<TAKEntryXml> = emptyList(),
)
@Serializable
@XmlSerialName("entry", "", "")
internal data class TAKEntryXml(
val key: String,
@XmlSerialName("class", "", "") val clazz: String,
@XmlValue(true) val value: String,
)

View file

@ -0,0 +1,207 @@
/*
* Copyright (c) 2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
@file:Suppress("TooGenericExceptionCaught")
package org.meshtastic.core.takserver
import co.touchlab.kermit.Logger
import 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()
private val connections = mutableMapOf<String, TAKClientConnection>()
private val _connectionCount = MutableStateFlow(0)
val connectionCount: StateFlow<Int> = _connectionCount.asStateFlow()
var onMessage: ((CoTMessage) -> Unit)? = null
suspend fun start(scope: CoroutineScope): Result<Unit> {
// Double-start guard: prevents SelectorManager / ServerSocket leaks
if (running) {
Logger.w { "TAK Server already running on port $port" }
return Result.success(Unit)
}
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)
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)
}
}
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)
}
}
}
private fun handleConnection(clientSocket: Socket) {
val scope = serverScope ?: return
val endpoint = clientSocket.remoteAddress.toString()
if (!clientSocket.remoteAddress.isLoopback()) {
Logger.w { "TAK server rejected non-loopback connection from $endpoint" }
clientSocket.close()
return
}
val connectionId = Random.nextInt().toString(TAK_HEX_RADIX)
val clientInfo = TAKClientInfo(id = connectionId, endpoint = endpoint)
val connection =
TAKClientConnection(
socket = clientSocket,
clientInfo = clientInfo,
onEvent = { event -> handleConnectionEvent(connectionId, event) },
scope = scope,
)
scope.launch {
connectionsMutex.withLock {
connections[connectionId] = connection
_connectionCount.value = connections.size
}
connection.start()
}
}
private fun handleConnectionEvent(connectionId: String, event: TAKConnectionEvent) {
when (event) {
is TAKConnectionEvent.Message -> {
onMessage?.invoke(event.cotMessage)
}
is TAKConnectionEvent.Disconnected -> {
serverScope?.launch {
connectionsMutex.withLock {
connections.remove(connectionId)
_connectionCount.value = connections.size
}
}
}
is TAKConnectionEvent.Error -> {
Logger.w(event.error) { "TAK client connection error: $connectionId" }
serverScope?.launch {
connectionsMutex.withLock {
connections.remove(connectionId)
_connectionCount.value = connections.size
}
}
}
is TAKConnectionEvent.Connected -> {
/* no-op: logged by TAKClientConnection.start() */
}
is TAKConnectionEvent.ClientInfoUpdated -> {
/* no-op: TAKClientConnection tracks updated info locally */
}
}
}
fun stop() {
running = false
acceptJob?.cancel()
acceptJob = null
// Close connections synchronously — TAKClientConnection.close() is non-suspending,
// so we don't need to launch into the (possibly-cancelled) serverScope.
val toClose: List<TAKClientConnection>
// We can't use Mutex.withLock here (non-suspending context) so we swap & clear under a
// best-effort copy — worst case a connection added concurrently is closed by socket teardown.
toClose = connections.values.toList()
connections.clear()
_connectionCount.value = 0
toClose.forEach { it.close() }
serverSocket?.close()
serverSocket = null
selectorManager?.close()
selectorManager = null
serverScope = null
}
suspend fun broadcast(cotMessage: CoTMessage) {
val currentConnections = connectionsMutex.withLock { connections.values.toList() }
currentConnections.forEach { connection ->
try {
connection.send(cotMessage)
} catch (e: Exception) {
Logger.w(e) { "Failed to broadcast CoT to TAK client ${connection.clientInfo.id}" }
connection.close()
}
}
}
suspend fun hasConnections(): Boolean = connectionsMutex.withLock { connections.isNotEmpty() }
}
/**
* Returns true if 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.
*/
private fun SocketAddress.isLoopback(): Boolean {
val addr = toString().removePrefix("/")
return addr.startsWith("127.") || addr.startsWith("::1") || addr.startsWith("[::1]")
}

View file

@ -0,0 +1,151 @@
/*
* Copyright (c) 2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.core.takserver
import co.touchlab.kermit.Logger
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import org.meshtastic.core.model.Node
interface TAKServerManager {
val isRunning: StateFlow<Boolean>
val connectionCount: StateFlow<Int>
val inboundMessages: SharedFlow<CoTMessage>
/** 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)
}
class TAKServerManagerImpl(private val takServer: TAKServer) : TAKServerManager {
private var scope: CoroutineScope? = null
private val lastBroadcastPositionsMutex = Mutex()
private val _isRunning = MutableStateFlow(false)
override val isRunning: StateFlow<Boolean> = _isRunning.asStateFlow()
// Mirror TAKServer's event-driven connection count — no polling needed
override val connectionCount: StateFlow<Int> = takServer.connectionCount
private val _inboundMessages = MutableSharedFlow<CoTMessage>()
override val inboundMessages: SharedFlow<CoTMessage> = _inboundMessages.asSharedFlow()
private var lastBroadcastPositions = mutableMapOf<Int, Int>()
override fun start(scope: CoroutineScope) {
this.scope = scope
if (_isRunning.value) {
Logger.w { "TAKServerManager already running" }
return
}
scope.launch {
// Wire up inbound message handler BEFORE starting so no messages are lost
takServer.onMessage = { cotMessage -> scope.launch { _inboundMessages.emit(cotMessage) } }
val result = takServer.start(scope)
if (result.isSuccess) {
_isRunning.value = true
Logger.i { "TAK Server started" }
} else {
Logger.e(result.exceptionOrNull()) { "Failed to start TAK Server" }
// Clear onMessage if start failed so we don't hold a reference unnecessarily
takServer.onMessage = null
}
}
}
override fun stop() {
takServer.stop()
takServer.onMessage = null
_isRunning.value = false
scope = null
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) }
}
}

View file

@ -0,0 +1,33 @@
/*
* Copyright (c) 2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.core.takserver
/** Escapes XML special characters in attribute values and text content. */
internal fun String.xmlEscaped(): String =
replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;").replace("\"", "&quot;").replace("'", "&apos;")
/**
* Extracts the sender UID from a GeoChat-format UID string ("GeoChat.<senderUid>.<chatroom>.<messageId>"). Returns the
* original string unchanged for non-GeoChat UIDs.
*/
internal fun String.geoChatSenderUid(): String = if (startsWith("GeoChat.")) split(".").getOrElse(1) { "" } else this
/**
* Extracts the message ID from a GeoChat-format UID string ("GeoChat.<senderUid>.<chatroom>.<messageId>"). Returns the
* original string unchanged for non-GeoChat UIDs.
*/
internal fun String.geoChatMessageId(): String = if (startsWith("GeoChat.")) split(".").lastOrNull() ?: this else this

View file

@ -0,0 +1,26 @@
/*
* Copyright (c) 2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.core.takserver
/**
* Platform-specific zip archive creation.
*
* Each entry in [entries] is a mapping of zip entry name to its raw byte content.
*/
internal expect object ZipArchiver {
fun createZip(entries: Map<String, ByteArray>): ByteArray
}

View file

@ -0,0 +1,59 @@
/*
* Copyright (c) 2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.core.takserver.di
import org.koin.core.annotation.Module
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
@Module
class CoreTakServerModule {
@Single fun provideTAKServer(dispatchers: CoroutineDispatchers): TAKServer = TAKServer(dispatchers = dispatchers)
@Single fun provideTAKServerManager(takServer: TAKServer): TAKServerManager = TAKServerManagerImpl(takServer)
@Single
fun provideGenericCoTHandler(commandSender: CommandSender, takServerManager: TAKServerManager): CoTHandler =
GenericCoTHandler(commandSender, takServerManager)
@Single
fun provideTAKMeshIntegration(
takServerManager: TAKServerManager,
commandSender: CommandSender,
nodeRepository: NodeRepository,
serviceRepository: ServiceRepository,
meshConfigHandler: MeshConfigHandler,
cotHandler: CoTHandler,
): TAKMeshIntegration = TAKMeshIntegration(
takServerManager,
commandSender,
nodeRepository,
serviceRepository,
meshConfigHandler,
cotHandler,
)
}

View file

@ -0,0 +1,31 @@
/*
* Copyright (c) 2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.core.takserver.fountain
import org.meshtastic.core.takserver.CoTMessage
/**
* Handles incoming and outgoing generic Cursor on Target (CoT) messages wrapped in Meshtastic DataPackets.
*
* Defines the contract for routing Direct (unfragmented) vs Fountain-encoded packets, and processing decompressed
* EXI/Zlib XML payloads.
*/
interface CoTHandler {
suspend fun sendGenericCoT(cotMessage: CoTMessage)
suspend fun handleIncomingForwarderPacket(payload: ByteArray, senderNodeNum: Int)
}

View file

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

View file

@ -0,0 +1,466 @@
/*
* Copyright (c) 2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.core.takserver.fountain
import co.touchlab.kermit.Logger
import kotlin.math.ceil
import kotlin.math.ln
import kotlin.math.sqrt
import kotlin.random.Random
import kotlin.time.Clock
internal object FountainConstants {
val MAGIC = byteArrayOf(0x46, 0x54, 0x4E) // "FTN"
const val BLOCK_SIZE = 220
const val DATA_HEADER_SIZE = 11
const val FOUNTAIN_THRESHOLD = 233
const val TRANSFER_TYPE_COT: Byte = 0x00
const val ACK_TYPE_COMPLETE: Byte = 0x02
const val ACK_PACKET_SIZE = 19
}
internal data class FountainBlock(
val seed: Int, // UInt16
var indices: MutableSet<Int>,
var payload: ByteArray,
) {
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (other == null || this::class != other::class) return false
other as FountainBlock
return seed == other.seed && indices == other.indices && payload.contentEquals(other.payload)
}
override fun hashCode(): Int {
var result = seed
result = 31 * result + indices.hashCode()
result = 31 * result + payload.contentHashCode()
return result
}
}
internal class FountainReceiveState(
val transferId: Int, // UInt24
val k: Int,
val totalLength: Int,
) {
val blocks = mutableListOf<FountainBlock>()
private val createdAt = Clock.System.now().toEpochMilliseconds()
fun addBlock(block: FountainBlock) {
if (blocks.none { it.seed == block.seed }) {
blocks.add(block)
}
}
val isExpired: Boolean
get() = (Clock.System.now().toEpochMilliseconds() - createdAt) > 60_000
}
internal data class FountainDataHeader(
val transferId: Int, // UInt24
val seed: Int, // UInt16
val k: Int, // UInt8
val totalLength: Int, // UInt16
)
internal data class FountainAck(
val transferId: Int,
val type: Byte,
val received: Int,
val needed: Int,
val dataHash: ByteArray,
) {
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (other == null || this::class != other::class) return false
other as FountainAck
return transferId == other.transferId &&
type == other.type &&
received == other.received &&
needed == other.needed &&
dataHash.contentEquals(other.dataHash)
}
override fun hashCode(): Int {
var result = transferId
result = 31 * result + type.toInt()
result = 31 * result + received
result = 31 * result + needed
result = 31 * result + dataHash.contentHashCode()
return result
}
}
@Suppress("MagicNumber")
internal class JavaRandom(seed: Long) {
private var seed: Long = (seed xor 0x5DEECE66DL) and ((1L shl 48) - 1)
private fun next(bits: Int): Int {
seed = (seed * 0x5DEECE66DL + 0xBL) and ((1L shl 48) - 1)
return (seed ushr (48 - bits)).toInt()
}
fun nextInt(bound: Int): Int = when {
bound <= 0 -> 0
(bound and -bound) == bound -> ((bound.toLong() * next(31).toLong()) shr 31).toInt()
else -> {
var bits: Int
var valResult: Int
do {
bits = next(31)
valResult = bits % bound
} while (bits - valResult + (bound - 1) < 0)
valResult
}
}
fun nextDouble(): Double {
val high = next(26).toLong()
val low = next(27).toLong()
return ((high shl 27) + low).toDouble() / (1L shl 53).toDouble()
}
}
@Suppress("MagicNumber", "TooManyFunctions")
internal class FountainCodec {
private val receiveStates = mutableMapOf<Int, FountainReceiveState>()
fun generateTransferId(): Int {
val random = Random.nextInt(0, 0xFFFFFF + 1)
val time = (Clock.System.now().toEpochMilliseconds() / 1000).toInt() and 0xFFFF
return (random xor time) and 0xFFFFFF
}
fun encode(data: ByteArray, transferId: Int): List<ByteArray> {
if (data.isEmpty()) {
Logger.w { "Fountain encode: empty data" }
return emptyList()
}
val k = maxOf(1, ceil(data.size.toDouble() / FountainConstants.BLOCK_SIZE).toInt())
val overhead = getAdaptiveOverhead(k)
val blocksToSend = maxOf(1, ceil(k.toDouble() * (1.0 + overhead)).toInt())
val sourceBlocks = splitIntoBlocks(data, k)
val packets = mutableListOf<ByteArray>()
for (i in 0 until blocksToSend) {
val seed = generateSeed(transferId, i)
val indices = generateBlockIndices(seed, k, i)
var blockPayload = ByteArray(FountainConstants.BLOCK_SIZE) { 0 }
for (idx in indices) {
blockPayload = xor(blockPayload, sourceBlocks[idx])
}
val packet = buildDataBlock(transferId, seed, k, data.size, blockPayload)
packets.add(packet)
}
Logger.i { "Fountain encode: ${data.size} bytes -> $k source blocks -> $blocksToSend packets" }
return packets
}
private fun splitIntoBlocks(data: ByteArray, k: Int): List<ByteArray> {
val blocks = mutableListOf<ByteArray>()
for (i in 0 until k) {
val start = i * FountainConstants.BLOCK_SIZE
val end = minOf(start + FountainConstants.BLOCK_SIZE, data.size)
if (start < data.size) {
val block = data.copyOfRange(start, end)
if (block.size < FountainConstants.BLOCK_SIZE) {
val padded = ByteArray(FountainConstants.BLOCK_SIZE) { 0 }
block.copyInto(padded)
blocks.add(padded)
} else {
blocks.add(block)
}
} else {
blocks.add(ByteArray(FountainConstants.BLOCK_SIZE) { 0 })
}
}
return blocks
}
private fun buildDataBlock(transferId: Int, seed: Int, k: Int, totalLength: Int, payload: ByteArray): ByteArray {
val packet = ByteArray(FountainConstants.DATA_HEADER_SIZE + payload.size)
packet[0] = FountainConstants.MAGIC[0]
packet[1] = FountainConstants.MAGIC[1]
packet[2] = FountainConstants.MAGIC[2]
packet[3] = ((transferId shr 16) and 0xFF).toByte()
packet[4] = ((transferId shr 8) and 0xFF).toByte()
packet[5] = (transferId and 0xFF).toByte()
packet[6] = ((seed shr 8) and 0xFF).toByte()
packet[7] = (seed and 0xFF).toByte()
packet[8] = (k and 0xFF).toByte()
packet[9] = ((totalLength shr 8) and 0xFF).toByte()
packet[10] = (totalLength and 0xFF).toByte()
payload.copyInto(packet, FountainConstants.DATA_HEADER_SIZE)
return packet
}
fun isFountainPacket(data: ByteArray): Boolean {
if (data.size < 3) return false
return data[0] == FountainConstants.MAGIC[0] &&
data[1] == FountainConstants.MAGIC[1] &&
data[2] == FountainConstants.MAGIC[2]
}
fun parseDataHeader(data: ByteArray): FountainDataHeader? {
if (data.size < FountainConstants.DATA_HEADER_SIZE || !isFountainPacket(data)) return null
val transferId =
((data[3].toInt() and 0xFF) shl 16) or ((data[4].toInt() and 0xFF) shl 8) or (data[5].toInt() and 0xFF)
val seed = ((data[6].toInt() and 0xFF) shl 8) or (data[7].toInt() and 0xFF)
val k = data[8].toInt() and 0xFF
val totalLength = ((data[9].toInt() and 0xFF) shl 8) or (data[10].toInt() and 0xFF)
return FountainDataHeader(transferId, seed, k, totalLength)
}
fun handleIncomingPacket(data: ByteArray): Pair<ByteArray, Int>? {
cleanupExpiredStates()
val header = parseDataHeader(data)
if (header != null) {
val payload = data.copyOfRange(FountainConstants.DATA_HEADER_SIZE, data.size)
if (payload.size == FountainConstants.BLOCK_SIZE) {
return processValidIncomingPacket(header, payload)
} else {
Logger.w { "Invalid fountain payload size: ${payload.size}" }
}
}
return null
}
private fun processValidIncomingPacket(header: FountainDataHeader, payload: ByteArray): Pair<ByteArray, Int>? {
val state =
receiveStates.getOrPut(header.transferId) {
FountainReceiveState(header.transferId, header.k, header.totalLength)
}
val indices = regenerateIndices(header.seed, state.k, header.transferId)
val block = FountainBlock(header.seed, indices.toMutableSet(), payload)
state.addBlock(block)
if (state.blocks.size >= state.k) {
val decoded = peelingDecode(state)
if (decoded != null) {
receiveStates.remove(header.transferId)
Logger.i { "Fountain decode complete: ${decoded.size} bytes from ${state.blocks.size} blocks" }
return Pair(decoded, header.transferId)
}
}
return null
}
fun buildAck(transferId: Int, type: Byte, received: Int, needed: Int, dataHash: ByteArray): ByteArray {
val packet = ByteArray(FountainConstants.ACK_PACKET_SIZE)
packet[0] = FountainConstants.MAGIC[0]
packet[1] = FountainConstants.MAGIC[1]
packet[2] = FountainConstants.MAGIC[2]
packet[3] = ((transferId shr 16) and 0xFF).toByte()
packet[4] = ((transferId shr 8) and 0xFF).toByte()
packet[5] = (transferId and 0xFF).toByte()
packet[6] = type
packet[7] = ((received shr 8) and 0xFF).toByte()
packet[8] = (received and 0xFF).toByte()
packet[9] = ((needed shr 8) and 0xFF).toByte()
packet[10] = (needed and 0xFF).toByte()
val hashLen = minOf(8, dataHash.size)
dataHash.copyInto(packet, 11, 0, hashLen)
return packet
}
fun parseAck(data: ByteArray): FountainAck? {
if (data.size < FountainConstants.ACK_PACKET_SIZE || !isFountainPacket(data)) return null
val transferId =
((data[3].toInt() and 0xFF) shl 16) or ((data[4].toInt() and 0xFF) shl 8) or (data[5].toInt() and 0xFF)
val type = data[6]
val received = ((data[7].toInt() and 0xFF) shl 8) or (data[8].toInt() and 0xFF)
val needed = ((data[9].toInt() and 0xFF) shl 8) or (data[10].toInt() and 0xFF)
val dataHash = data.copyOfRange(11, 19)
return FountainAck(transferId, type, received, needed, dataHash)
}
private fun peelingDecode(state: FountainReceiveState): ByteArray? {
val decoded = mutableMapOf<Int, ByteArray>()
val workingBlocks =
state.blocks.map { FountainBlock(it.seed, it.indices.toMutableSet(), it.payload.copyOf()) }.toMutableList()
var progress = true
while (progress && decoded.size < state.k) {
progress = processWorkingBlocks(workingBlocks, decoded)
}
if (decoded.size < state.k) {
Logger.d { "Peeling decode incomplete: ${decoded.size}/${state.k} blocks decoded" }
return null
}
return assembleDecodedData(state, decoded)
}
private fun processWorkingBlocks(workingBlocks: List<FountainBlock>, decoded: MutableMap<Int, ByteArray>): Boolean {
var progress = false
for (i in workingBlocks.indices) {
val block = workingBlocks[i]
val toRemove = mutableListOf<Int>()
for (idx in block.indices) {
val decodedBlock = decoded[idx]
if (decodedBlock != null) {
block.payload = xor(block.payload, decodedBlock)
toRemove.add(idx)
}
}
block.indices.removeAll(toRemove)
if (block.indices.size == 1) {
val idx = block.indices.first()
if (!decoded.containsKey(idx)) {
decoded[idx] = block.payload
progress = true
}
}
}
return progress
}
private fun assembleDecodedData(state: FountainReceiveState, decoded: Map<Int, ByteArray>): ByteArray? {
val result = ByteArray(state.k * FountainConstants.BLOCK_SIZE)
for (i in 0 until state.k) {
val block = decoded[i] ?: return null
block.copyInto(result, i * FountainConstants.BLOCK_SIZE)
}
return result.copyOfRange(0, state.totalLength)
}
private fun cleanupExpiredStates() {
val expiredIds = receiveStates.filter { it.value.isExpired }.map { it.key }
for (id in expiredIds) {
receiveStates.remove(id)
Logger.d { "Cleaned up expired fountain state: $id" }
}
}
private fun getAdaptiveOverhead(k: Int): Double = when {
k <= 10 -> 0.50
k <= 50 -> 0.25
else -> 0.15
}
private fun generateSeed(transferId: Int, blockIndex: Int): Int {
val combined = transferId * 31337 + blockIndex * 7919
return combined and 0xFFFF
}
private fun generateBlockIndices(seed: Int, k: Int, blockIndex: Int): Set<Int> {
val rng = JavaRandom(seed.toLong())
val sampledDegree = sampleRobustSolitonDegree(rng, k)
val degree = if (blockIndex == 0) 1 else sampledDegree
return selectIndices(rng, k, degree)
}
private fun regenerateIndices(seed: Int, k: Int, transferId: Int): Set<Int> {
val rng = JavaRandom(seed.toLong())
val sampledDegree = sampleRobustSolitonDegree(rng, k)
val expectedSeed0 = generateSeed(transferId, 0)
val degree = if (seed == expectedSeed0) 1 else sampledDegree
return selectIndices(rng, k, degree)
}
private fun selectIndices(rng: JavaRandom, k: Int, degree: Int): Set<Int> {
val indices = mutableSetOf<Int>()
while (indices.size < degree && indices.size < k) {
val idx = rng.nextInt(k)
indices.add(idx)
}
return indices
}
private fun sampleRobustSolitonDegree(rng: JavaRandom, k: Int): Int {
val cdf = buildRobustSolitonCDF(k)
val u = rng.nextDouble()
for (d in 1..k) {
if (u <= cdf[d]) return d
}
return k
}
private fun buildRobustSolitonCDF(k: Int, c: Double = 0.1, delta: Double = 0.5): DoubleArray {
if (k <= 0) return doubleArrayOf(1.0)
val rho = DoubleArray(k + 1)
rho[1] = 1.0 / k.toDouble()
for (d in 2..k) {
rho[d] = 1.0 / (d.toDouble() * (d - 1).toDouble())
}
val rVal = c * ln(k.toDouble() / delta) * sqrt(k.toDouble())
val tau = DoubleArray(k + 1)
val threshold = (k.toDouble() / rVal).toInt()
for (d in 1..k) {
if (d < threshold) {
tau[d] = rVal / (d.toDouble() * k.toDouble())
} else if (d == threshold) {
tau[d] = rVal * ln(rVal / delta) / k.toDouble()
}
}
val mu = DoubleArray(k + 1)
var sum = 0.0
for (d in 1..k) {
mu[d] = rho[d] + tau[d]
sum += mu[d]
}
val cdf = DoubleArray(k + 1)
var cumulative = 0.0
for (d in 1..k) {
cumulative += mu[d] / sum
cdf[d] = cumulative
}
return cdf
}
private fun xor(a: ByteArray, b: ByteArray): ByteArray {
val result = ByteArray(maxOf(a.size, b.size))
for (i in result.indices) {
val byteA = if (i < a.size) a[i] else 0
val byteB = if (i < b.size) b[i] else 0
result[i] = (byteA.toInt() xor byteB.toInt()).toByte()
}
return result
}
}

View file

@ -0,0 +1,231 @@
/*
* Copyright (c) 2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.core.takserver.fountain
import co.touchlab.kermit.Logger
import kotlinx.coroutines.delay
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import okio.ByteString.Companion.toByteString
import org.meshtastic.core.model.DataPacket
import org.meshtastic.core.repository.CommandSender
import org.meshtastic.core.takserver.CoTMessage
import org.meshtastic.core.takserver.CoTXmlParser
import org.meshtastic.core.takserver.TAKServerManager
import org.meshtastic.core.takserver.toXml
import org.meshtastic.proto.PortNum
import kotlin.time.Clock
class GenericCoTHandler(private val commandSender: CommandSender, private val takServerManager: TAKServerManager) :
CoTHandler {
companion object {
private const val INTER_PACKET_DELAY_MS = 100L
private const val ACK_RETRANSMIT_DELAY_MS = 50L
private const val PENDING_TRANSFER_TTL_MS = 60_000L
}
private val fountainCodec = FountainCodec()
private val pendingTransfersMutex = Mutex()
private val pendingTransfers = mutableMapOf<Int, PendingTransfer>()
private data class PendingTransfer(
val transferId: Int,
val totalBlocks: Int,
val dataHash: ByteArray,
val startTime: Long = Clock.System.now().toEpochMilliseconds(),
) {
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (other == null || this::class != other::class) return false
other as PendingTransfer
return transferId == other.transferId &&
totalBlocks == other.totalBlocks &&
dataHash.contentEquals(other.dataHash) &&
startTime == other.startTime
}
override fun hashCode(): Int {
var result = transferId
result = 31 * result + totalBlocks
result = 31 * result + dataHash.contentHashCode()
result = 31 * result + startTime.hashCode()
return result
}
}
override suspend fun sendGenericCoT(cotMessage: CoTMessage) {
val xml = cotMessage.toXml()
val xmlBytes = xml.encodeToByteArray()
val compressed = ZlibCodec.compress(xmlBytes)
if (compressed == null) {
Logger.w { "Failed to compress CoT to Zlib" }
return
}
val payload = ByteArray(compressed.size + 1)
payload[0] = FountainConstants.TRANSFER_TYPE_COT
compressed.copyInto(payload, 1)
Logger.d { "Generic CoT: type=${cotMessage.type}, xml=${xmlBytes.size}B, compressed=${payload.size}B" }
if (payload.size < FountainConstants.FOUNTAIN_THRESHOLD) {
sendDirect(payload)
} else {
sendFountainCoded(payload)
}
}
private fun sendDirect(payload: ByteArray) {
val dataPacket =
DataPacket(
to = DataPacket.ID_BROADCAST,
bytes = payload.toByteString(),
dataType = PortNum.ATAK_FORWARDER.value,
)
commandSender.sendData(dataPacket)
Logger.i { "Sent generic CoT directly: ${payload.size} bytes on port 257" }
}
private suspend fun sendFountainCoded(payload: ByteArray) {
val transferId = fountainCodec.generateTransferId()
val packets = fountainCodec.encode(payload, transferId)
val hash = CryptoCodec.sha256Prefix8(payload)
pendingTransfersMutex.withLock {
pendingTransfers[transferId] = PendingTransfer(transferId, packets.size, hash)
}
Logger.i { "Sending fountain-coded CoT: ${payload.size} bytes -> ${packets.size} blocks, xferId=$transferId" }
for ((index, packetData) in packets.withIndex()) {
val dataPacket =
DataPacket(
to = DataPacket.ID_BROADCAST,
bytes = packetData.toByteString(),
dataType = PortNum.ATAK_FORWARDER.value,
)
commandSender.sendData(dataPacket)
if (index < packets.size - 1) {
delay(INTER_PACKET_DELAY_MS) // Inter-packet delay
}
}
}
override suspend fun handleIncomingForwarderPacket(payload: ByteArray, senderNodeNum: Int) {
if (payload.isEmpty()) return
if (fountainCodec.isFountainPacket(payload)) {
if (payload.size == FountainConstants.ACK_PACKET_SIZE) {
handleIncomingAck(payload, senderNodeNum)
} else {
handleFountainPacket(payload, senderNodeNum)
}
} else {
handleDirectPacket(payload, senderNodeNum)
}
}
private fun handleDirectPacket(payload: ByteArray, senderNodeNum: Int) {
if (payload.size <= 1) return
val transferType = payload[0]
if (transferType != FountainConstants.TRANSFER_TYPE_COT) return
val exiData = payload.copyOfRange(1, payload.size)
processDecompressedCoT(exiData, senderNodeNum)
}
private suspend fun handleFountainPacket(payload: ByteArray, senderNodeNum: Int) {
fountainCodec.handleIncomingPacket(payload)?.let { (decodedData, transferId) ->
val hash = CryptoCodec.sha256Prefix8(decodedData)
sendFountainAck(transferId, hash, senderNodeNum)
delay(ACK_RETRANSMIT_DELAY_MS)
sendFountainAck(transferId, hash, senderNodeNum)
if (decodedData.size > 1 && decodedData[0] == FountainConstants.TRANSFER_TYPE_COT) {
val exiData = decodedData.copyOfRange(1, decodedData.size)
processDecompressedCoT(exiData, senderNodeNum)
}
}
}
private fun processDecompressedCoT(exiData: ByteArray, senderNodeNum: Int) {
val xmlBytes = ZlibCodec.decompress(exiData) ?: return
val xml = xmlBytes.decodeToString()
val result = CoTXmlParser(xml).parse()
val cot = result.getOrNull()
if (cot != null) {
takServerManager.broadcast(cot)
Logger.i { "Received generic CoT from node $senderNodeNum: ${cot.type}" }
} else {
Logger.w(result.exceptionOrNull() ?: Exception("Unknown parse error")) { "Failed to parse CoT XML" }
}
}
private fun sendFountainAck(transferId: Int, hash: ByteArray, toNodeNum: Int) {
val ackPacket =
fountainCodec.buildAck(
transferId,
FountainConstants.ACK_TYPE_COMPLETE,
received = 0,
needed = 0,
dataHash = hash,
)
val dataPacket =
DataPacket(
to = toNodeNum.toString(),
bytes = ackPacket.toByteString(),
dataType = PortNum.ATAK_FORWARDER.value,
)
commandSender.sendData(dataPacket)
Logger.d { "Sent fountain ACK for transfer $transferId" }
}
private suspend fun handleIncomingAck(payload: ByteArray, senderNodeNum: Int) {
val ack = fountainCodec.parseAck(payload) ?: return
Logger.d { "Received fountain ACK: xferId=${ack.transferId}, type=${ack.type}, from $senderNodeNum" }
pendingTransfersMutex.withLock {
cleanupStalePendingTransfersLocked()
val pending = pendingTransfers[ack.transferId]
if (pending != null) {
if (ack.type == FountainConstants.ACK_TYPE_COMPLETE) {
if (ack.dataHash.contentEquals(pending.dataHash)) {
Logger.i { "Fountain transfer ${ack.transferId} acknowledged by node $senderNodeNum" }
} else {
Logger.w { "Fountain ACK hash mismatch for transfer ${ack.transferId}" }
}
pendingTransfers.remove(ack.transferId)
}
}
}
}
/** Must be called inside [pendingTransfersMutex]. */
private fun cleanupStalePendingTransfersLocked() {
val now = Clock.System.now().toEpochMilliseconds()
val stale = pendingTransfers.filter { (_, v) -> now - v.startTime > PENDING_TRANSFER_TTL_MS }.keys
stale.forEach { id ->
pendingTransfers.remove(id)
Logger.d { "Evicted stale outbound pending transfer: $id" }
}
}
}

View file

@ -0,0 +1,84 @@
/*
* Copyright (c) 2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.core.takserver
import okio.ByteString.Companion.encodeUtf8
import org.meshtastic.proto.Position
import org.meshtastic.proto.User
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNotNull
class CoTConversionTest {
@Test
fun testPositionToCoTMessage() {
val position =
Position(
latitude_i = 377749000,
longitude_i = -1224194000,
altitude = 15,
ground_speed = 5,
ground_track = 180,
time = 1620000000,
)
val cot =
position.toCoTMessage(uid = "!12345678", callsign = "TestUser", team = "Red", role = "HQ", battery = 85)
assertEquals("a-f-G-U-C", cot.type)
assertEquals("!12345678", cot.uid)
assertEquals(37.7749, cot.latitude, 0.0001)
assertEquals(-122.4194, cot.longitude, 0.0001)
assertEquals(15.0, cot.hae, 0.0001)
val track = cot.track
assertNotNull(track)
assertEquals(5.0, track.speed, 0.0001)
assertEquals(180.0, track.course, 0.0001)
assertEquals("TestUser", cot.contact?.callsign)
assertEquals("Red", cot.group?.name)
assertEquals("HQ", cot.group?.role)
assertEquals(85, cot.status?.battery)
}
@Test
fun testUserToCoTMessage() {
val user =
User(
id = "!87654321",
long_name = "LongName",
short_name = "SN",
macaddr = "00:11:22:33:44:55".encodeUtf8(),
)
val cot = user.toCoTMessage(position = null, team = "Blue", role = "Sniper", battery = 92)
assertEquals("a-f-G-U-C", cot.type)
assertEquals("!87654321", cot.uid)
assertEquals(0.0, cot.latitude, 0.0001)
assertEquals(0.0, cot.longitude, 0.0001)
assertEquals("SN", cot.contact?.callsign)
assertEquals("Blue", cot.group?.name)
assertEquals("Sniper", cot.group?.role)
assertEquals(92, cot.status?.battery)
}
}

View file

@ -0,0 +1,61 @@
/*
* Copyright (c) 2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.core.takserver
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertTrue
class CoTXmlFrameBufferTest {
@Test
fun `extracts multiple concatenated events`() {
val buffer = CoTXmlFrameBuffer()
val xml = "<event uid='1'></event><event uid='2'></event>"
val messages = buffer.append(xml.encodeToByteArray())
assertEquals(2, messages.size)
assertEquals("<event uid='1'></event>", messages[0])
assertEquals("<event uid='2'></event>", messages[1])
}
@Test
fun `preserves partial event until completed`() {
val buffer = CoTXmlFrameBuffer()
val firstChunk = buffer.append("<event uid='1'>".encodeToByteArray())
val secondChunk = buffer.append("</event>".encodeToByteArray())
assertTrue(firstChunk.isEmpty())
assertEquals(listOf("<event uid='1'></event>"), secondChunk)
}
@Test
fun `drops oversized partial buffer`() {
val buffer = CoTXmlFrameBuffer(maxMessageSize = 16)
val validEvent = "<event></event>"
val messages = buffer.append("<event uid='1234567890'".encodeToByteArray())
val secondMessages = buffer.append("garbage".encodeToByteArray())
val laterMessages = buffer.append(validEvent.encodeToByteArray())
assertTrue(messages.isEmpty())
assertTrue(secondMessages.isEmpty())
assertEquals(listOf(validEvent), laterMessages)
}
}

View file

@ -0,0 +1,89 @@
/*
* Copyright (c) 2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.core.takserver
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertTrue
class CoTXmlParserTest {
@Test
fun `test successful CoT XML parsing`() {
val validXml =
"""
<event version="2.0" uid="test-uid-123" type="a-f-G-U-C" time="2025-01-01T12:00:00Z" start="2025-01-01T12:00:00Z" stale="2025-01-01T12:05:00Z" how="m-g">
<point lat="45.0" lon="-90.0" hae="100.0" ce="10.0" le="10.0"/>
<detail>
<contact callsign="TestUser"/>
<__group name="Cyan" role="Team Member"/>
<status battery="85"/>
<track speed="5.0" course="180.0"/>
</detail>
</event>
"""
.trimIndent()
val parser = CoTXmlParser(validXml)
val result = parser.parse()
assertTrue(result.isSuccess)
val message = result.getOrNull()!!
assertEquals("test-uid-123", message.uid)
assertEquals("a-f-G-U-C", message.type)
assertEquals(45.0, message.latitude)
assertEquals(-90.0, message.longitude)
assertEquals("TestUser", message.contact?.callsign)
assertEquals("Cyan", message.group?.name)
assertEquals("Team Member", message.group?.role)
assertEquals(85, message.status?.battery)
assertEquals(5.0, message.track?.speed)
assertEquals(180.0, message.track?.course)
}
@Test
fun `test invalid CoT XML parsing falls back to failure`() {
val invalidXml = """<invalid_xml><event uid="missing-fields"></invalid_xml>"""
val parser = CoTXmlParser(invalidXml)
val result = parser.parse()
assertTrue(result.isFailure, "Parsing invalid XML should fail gracefully")
}
@Test
fun `test defaults applied when optional fields missing`() {
val basicXml =
"""
<event version="2.0" uid="" type="" time="2025-01-01T12:00:00.000Z" start="2025-01-01T12:00:00Z" stale="2025-01-01T12:05:00Z" how="">
<point lat="0.0" lon="0.0" hae="0.0" ce="0.0" le="0.0"/>
<detail></detail>
</event>
"""
.trimIndent()
val parser = CoTXmlParser(basicXml)
val result = parser.parse()
assertTrue(result.isSuccess)
val message = result.getOrNull()!!
assertEquals("tak-0", message.uid)
assertEquals("a-f-G-U-C", message.type)
assertEquals("m-g", message.how)
}
}

View file

@ -0,0 +1,139 @@
/*
* Copyright (c) 2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.core.takserver
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNotNull
import kotlin.test.assertTrue
/** Round-trip and structure tests for [CoTMessage.toXml]. */
class CoTXmlTest {
// ── PLI round-trip ────────────────────────────────────────────────────────
@Test
fun `toXml produces parseable XML for a PLI message`() {
val original =
CoTMessage.pli(
uid = "!1234abcd",
callsign = "TestUser",
latitude = 37.7749,
longitude = -122.4194,
altitude = 15.0,
speed = 5.0,
course = 180.0,
team = "Cyan",
role = "Team Member",
battery = 85,
)
val xml = original.toXml()
val parsed = CoTXmlParser(xml).parse()
assertTrue(parsed.isSuccess, "Parsed result should be success; error=${parsed.exceptionOrNull()}")
val roundTripped = parsed.getOrThrow()
assertEquals(original.uid, roundTripped.uid)
assertEquals(original.type, roundTripped.type)
assertEquals(original.latitude, roundTripped.latitude, 1e-4)
assertEquals(original.longitude, roundTripped.longitude, 1e-4)
assertEquals(original.hae, roundTripped.hae, 1e-4)
assertEquals(original.contact?.callsign, roundTripped.contact?.callsign)
assertEquals(original.group?.name, roundTripped.group?.name)
assertEquals(original.group?.role, roundTripped.group?.role)
assertEquals(original.status?.battery, roundTripped.status?.battery)
assertEquals(original.track?.speed, roundTripped.track?.speed)
assertEquals(original.track?.course, roundTripped.track?.course)
}
// ── Chat round-trip ───────────────────────────────────────────────────────
@Test
fun `toXml produces parseable XML for a chat message`() {
val original =
CoTMessage.chat(
senderUid = "!aabbccdd",
senderCallsign = "Alice",
message = "Hello World",
chatroom = "All Chat Rooms",
)
val xml = original.toXml()
val parsed = CoTXmlParser(xml).parse()
assertTrue(parsed.isSuccess, "Parsed result should be success; error=${parsed.exceptionOrNull()}")
val roundTripped = parsed.getOrThrow()
assertEquals("b-t-f", roundTripped.type)
assertNotNull(roundTripped.chat)
assertEquals("Hello World", roundTripped.chat?.message)
assertEquals("Alice", roundTripped.chat?.senderCallsign)
}
// ── XML escaping ─────────────────────────────────────────────────────────
@Test
fun `toXml escapes special characters in UID`() {
val message = CoTMessage.pli(uid = "uid&with<special>chars", callsign = "User", latitude = 0.0, longitude = 0.0)
val xml = message.toXml()
assertTrue(xml.contains("uid&amp;with&lt;special&gt;chars"), "Expected escaped UID in XML; got: $xml")
}
@Test
fun `toXml escapes special characters in callsign`() {
val message = CoTMessage.pli(uid = "!1234", callsign = "A&B<C>D", latitude = 0.0, longitude = 0.0)
val xml = message.toXml()
assertTrue(xml.contains("A&amp;B&lt;C&gt;D"), "Expected escaped callsign in XML; got: $xml")
}
// ── Structure ─────────────────────────────────────────────────────────────
@Test
fun `toXml includes XML declaration`() {
val message = CoTMessage.pli(uid = "!1234", callsign = "X", latitude = 0.0, longitude = 0.0)
assertTrue(message.toXml().startsWith("<?xml"), "XML should start with declaration")
}
@Test
fun `toXml omits optional elements when null`() {
val message =
CoTMessage.pli(uid = "!1234", callsign = "X", latitude = 0.0, longitude = 0.0)
.copy(track = null, status = null, group = null, contact = null)
val xml = message.toXml()
assertTrue(!xml.contains("<track"), "track element should be absent when null")
assertTrue(!xml.contains("<status"), "status element should be absent when null")
assertTrue(!xml.contains("<__group"), "__group element should be absent when null")
assertTrue(!xml.contains("<contact"), "contact element should be absent when null")
}
@Test
fun `toXml includes remarks when present`() {
val message =
CoTMessage.pli(uid = "!1234", callsign = "X", latitude = 0.0, longitude = 0.0).copy(remarks = "A remark")
val xml = message.toXml()
assertTrue(xml.contains("<remarks>A remark</remarks>"), "Expected remarks in XML; got: $xml")
}
}

View file

@ -0,0 +1,107 @@
/*
* Copyright (c) 2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.core.takserver
import org.meshtastic.proto.MemberRole
import org.meshtastic.proto.Team
import org.meshtastic.proto.User
import kotlin.test.Test
import kotlin.test.assertEquals
class TAKDefaultsTest {
// ── toTakTeamName ──────────────────────────────────────────────────────────
@Test
fun `toTakTeamName returns default for null`() {
assertEquals(DEFAULT_TAK_TEAM_NAME, null.toTakTeamName())
}
@Test
fun `toTakTeamName returns default for Unspecifed_Color`() {
assertEquals(DEFAULT_TAK_TEAM_NAME, Team.Unspecifed_Color.toTakTeamName())
}
@Test
fun `toTakTeamName converts Blue`() {
assertEquals("Blue", Team.Blue.toTakTeamName())
}
@Test
fun `toTakTeamName converts Red`() {
assertEquals("Red", Team.Red.toTakTeamName())
}
@Test
fun `toTakTeamName replaces underscores with spaces`() {
// Dark_Blue -> "Dark Blue"
assertEquals("Dark Blue", Team.Dark_Blue.toTakTeamName())
}
// ── toTakRoleName ─────────────────────────────────────────────────────────
@Test
fun `toTakRoleName returns default for null`() {
assertEquals(DEFAULT_TAK_ROLE_NAME, null.toTakRoleName())
}
@Test
fun `toTakRoleName returns default for Unspecifed`() {
assertEquals(DEFAULT_TAK_ROLE_NAME, MemberRole.Unspecifed.toTakRoleName())
}
@Test
fun `toTakRoleName returns default for TeamMember`() {
assertEquals(DEFAULT_TAK_ROLE_NAME, MemberRole.TeamMember.toTakRoleName())
}
@Test
fun `toTakRoleName converts TeamLead`() {
assertEquals("Team Lead", MemberRole.TeamLead.toTakRoleName())
}
@Test
fun `toTakRoleName converts ForwardObserver`() {
assertEquals("Forward Observer", MemberRole.ForwardObserver.toTakRoleName())
}
@Test
fun `toTakRoleName falls back to enum name for other roles`() {
// HQ is not specially mapped, so the fallback is its enum name
assertEquals(MemberRole.HQ.name, MemberRole.HQ.toTakRoleName())
}
// ── toTakCallsign ─────────────────────────────────────────────────────────
@Test
fun `toTakCallsign prefers short_name`() {
val user = User(id = "!1234", long_name = "Long Name", short_name = "SN")
assertEquals("SN", user.toTakCallsign())
}
@Test
fun `toTakCallsign falls back to long_name when short_name is blank`() {
val user = User(id = "!1234", long_name = "Long Name", short_name = "")
assertEquals("Long Name", user.toTakCallsign())
}
@Test
fun `toTakCallsign falls back to id when both names are blank`() {
val user = User(id = "!1234", long_name = "", short_name = "")
assertEquals("!1234", user.toTakCallsign())
}
}

View file

@ -0,0 +1,155 @@
/*
* Copyright (c) 2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.core.takserver
import org.meshtastic.core.takserver.TAKPacketConversion.toCoTMessage
import org.meshtastic.core.takserver.TAKPacketConversion.toTAKPacket
import org.meshtastic.proto.Contact
import org.meshtastic.proto.GeoChat
import org.meshtastic.proto.Group
import org.meshtastic.proto.MemberRole
import org.meshtastic.proto.PLI
import org.meshtastic.proto.Status
import org.meshtastic.proto.TAKPacket
import org.meshtastic.proto.Team
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNotNull
class TAKPacketConversionTest {
@Test
fun testCoTToTAKPacketPLI() {
val cot =
CoTMessage.pli(
uid = "!1234",
callsign = "Bob",
latitude = 45.0,
longitude = -90.0,
altitude = 100.0,
speed = 15.0,
course = 180.0,
team = "Blue",
role = "Team Member",
battery = 90,
)
val takPacket = cot.toTAKPacket()
assertNotNull(takPacket)
assertEquals(false, takPacket.is_compressed)
assertEquals("Bob", takPacket.contact?.callsign)
assertEquals("!1234", takPacket.contact?.device_callsign)
assertEquals(Team.Blue, takPacket.group?.team)
assertEquals(MemberRole.TeamMember, takPacket.group?.role)
assertEquals(90, takPacket.status?.battery)
assertNotNull(takPacket.pli)
assertEquals(450000000, takPacket.pli?.latitude_i)
assertEquals(-900000000, takPacket.pli?.longitude_i)
assertEquals(100, takPacket.pli?.altitude)
assertEquals(15, takPacket.pli?.speed)
assertEquals(180, takPacket.pli?.course)
}
@Test
fun testTAKPacketToCoTMessagePLI() {
val takPacket =
TAKPacket(
is_compressed = false,
contact = Contact(callsign = "Alice", device_callsign = "!5678"),
group = Group(team = Team.Cyan, role = MemberRole.HQ),
status = Status(battery = 85),
pli = PLI(latitude_i = 300000000, longitude_i = -800000000, altitude = 50, speed = 5, course = 90),
)
val cot = takPacket.toCoTMessage()
assertNotNull(cot)
assertEquals("!5678", cot.uid)
assertEquals("a-f-G-U-C", cot.type)
assertEquals(30.0, cot.latitude, 0.0001)
assertEquals(-80.0, cot.longitude, 0.0001)
assertEquals(50.0, cot.hae, 0.0001)
assertEquals("Alice", cot.contact?.callsign)
assertEquals("Cyan", cot.group?.name)
assertEquals("HQ", cot.group?.role)
assertEquals(85, cot.status?.battery)
assertNotNull(cot.track)
assertEquals(5.0, cot.track?.speed)
assertEquals(90.0, cot.track?.course)
}
@Test
fun testCoTToTAKPacketChat() {
val cot =
CoTMessage.chat(
senderUid = "!1234",
senderCallsign = "Bob",
message = "Hello World",
chatroom = "All Chat Rooms",
)
val takPacket = cot.toTAKPacket()
assertNotNull(takPacket)
assertNotNull(takPacket.chat)
assertEquals("Hello World", takPacket.chat?.message)
assertEquals("All Chat Rooms", takPacket.chat?.to)
}
@Test
fun testChatSmugglesMessageId() {
val cot =
CoTMessage.chat(
senderUid = "my-device-123",
senderCallsign = "Bob",
message = "Hello World",
chatroom = "All Chat Rooms",
)
val msgId = cot.uid.split(".").last()
val takPacket = cot.toTAKPacket()
assertNotNull(takPacket)
val expectedDeviceCallsign = "my-device-123|$msgId"
assertEquals(expectedDeviceCallsign, takPacket.contact?.device_callsign)
assertEquals("Bob", takPacket.contact?.callsign)
assertEquals("Hello World", takPacket.chat?.message)
}
@Test
fun testParseSmuggledMessageId() {
val takPacket =
TAKPacket(
is_compressed = false,
contact = Contact(callsign = "Alice", device_callsign = "alice-device-456|msg-789"),
chat = GeoChat(message = "Hi Bob", to = "Bob"),
)
val cot = takPacket.toCoTMessage()
assertNotNull(cot)
assertEquals("GeoChat.alice-device-456.Bob.msg-789", cot.uid)
assertEquals("Alice", cot.chat?.senderCallsign)
assertEquals("Hi Bob", cot.chat?.message)
assertEquals("Bob", cot.chat?.chatroom)
}
}

View file

@ -0,0 +1,97 @@
/*
* Copyright (c) 2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.core.takserver
import kotlin.test.Test
import kotlin.test.assertEquals
class XmlUtilsTest {
// ── xmlEscaped ────────────────────────────────────────────────────────────
@Test
fun `xmlEscaped leaves clean strings unchanged`() {
assertEquals("Hello World", "Hello World".xmlEscaped())
}
@Test
fun `xmlEscaped escapes ampersand`() {
assertEquals("A&amp;B", "A&B".xmlEscaped())
}
@Test
fun `xmlEscaped escapes less-than`() {
assertEquals("&lt;tag&gt;", "<tag>".xmlEscaped())
}
@Test
fun `xmlEscaped escapes double quote`() {
assertEquals("say &quot;hi&quot;", """say "hi"""".xmlEscaped())
}
@Test
fun `xmlEscaped escapes single quote`() {
assertEquals("it&apos;s", "it's".xmlEscaped())
}
@Test
fun `xmlEscaped escapes all special chars in one string`() {
assertEquals("&amp;&lt;&gt;&quot;&apos;", "&<>\"'".xmlEscaped())
}
@Test
fun `xmlEscaped escapes ampersand before other entities to avoid double-escaping`() {
// "&amp;" in input should become "&amp;amp;" — not "&amp;" (which would be a double-escape bug)
assertEquals("&amp;amp;", "&amp;".xmlEscaped())
}
// ── geoChatSenderUid ──────────────────────────────────────────────────────
@Test
fun `geoChatSenderUid extracts sender from GeoChat UID`() {
assertEquals("!1234abcd", "GeoChat.!1234abcd.All Chat Rooms.deadbeef".geoChatSenderUid())
}
@Test
fun `geoChatSenderUid returns original string for non-GeoChat UID`() {
assertEquals("!1234abcd", "!1234abcd".geoChatSenderUid())
}
@Test
fun `geoChatSenderUid handles missing second segment gracefully`() {
// "GeoChat." splits into ["GeoChat", ""] — getOrElse(1) returns "" (empty second segment)
assertEquals("", "GeoChat.".geoChatSenderUid())
}
// ── geoChatMessageId ──────────────────────────────────────────────────────
@Test
fun `geoChatMessageId extracts messageId from GeoChat UID`() {
assertEquals("deadbeef", "GeoChat.!1234abcd.All Chat Rooms.deadbeef".geoChatMessageId())
}
@Test
fun `geoChatMessageId returns original string for non-GeoChat UID`() {
assertEquals("!1234abcd", "!1234abcd".geoChatMessageId())
}
@Test
fun `geoChatMessageId handles single-segment GeoChat UID gracefully`() {
val uid = "GeoChat"
assertEquals("GeoChat", uid.geoChatMessageId())
}
}

View file

@ -0,0 +1,121 @@
/*
* Copyright (c) 2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.core.takserver.fountain
import kotlin.test.Test
import kotlin.test.assertContentEquals
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertNotNull
import kotlin.test.assertNull
import kotlin.test.assertTrue
class FountainCodecTest {
private val codec = FountainCodec()
@Test
fun `test encode and decode small payload`() {
val originalData = "Hello, TAK! This is a test payload.".encodeToByteArray()
val transferId = codec.generateTransferId()
val packets = codec.encode(originalData, transferId)
assertTrue(packets.isNotEmpty(), "Encoding should produce packets")
var decodedResult: Pair<ByteArray, Int>? = null
for (packet in packets) {
val result = codec.handleIncomingPacket(packet)
if (result != null) {
decodedResult = result
break
}
}
assertNotNull(decodedResult, "Should successfully decode payload")
assertEquals(transferId, decodedResult.second, "Transfer ID should match")
assertContentEquals(originalData, decodedResult.first, "Decoded data should match original")
}
@Test
fun `test encode and decode larger payload with packet loss`() {
// Create a payload larger than BLOCK_SIZE (220 bytes)
val originalData = ByteArray(1024) { (it % 256).toByte() }
val transferId = codec.generateTransferId()
val packets = codec.encode(originalData, transferId)
assertTrue(packets.size > 4, "Should have multiple packets for large payload")
var decodedResult: Pair<ByteArray, Int>? = null
// Drop the 2nd and 4th packets
val receivedPackets = packets.filterIndexed { index, _ -> index != 1 && index != 3 }.toMutableList()
for (packet in receivedPackets) {
val result = codec.handleIncomingPacket(packet)
if (result != null) {
decodedResult = result
break
}
}
// If it didn't decode yet, the fountain codec needs more packets.
// In a real scenario it would keep receiving new encoded blocks.
// We will encode a few extra blocks manually by simulating what the sender does.
// Since encode() generates 'blocksToSend' we just feed them all. If it decodes, great!
if (decodedResult == null) {
// Let's feed the remaining packets we dropped earlier as "retransmits" or extra blocks
val result1 = codec.handleIncomingPacket(packets[1])
if (result1 != null) decodedResult = result1
if (decodedResult == null) {
decodedResult = codec.handleIncomingPacket(packets[3])
}
}
assertNotNull(decodedResult, "Should successfully decode payload after receiving enough packets")
assertContentEquals(originalData, decodedResult.first, "Decoded data should match original")
}
@Test
fun `test build and parse ACK`() {
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 invalidPacket = byteArrayOf(0x00, 0x01, 0x02, 0x03)
assertFalse(codec.isFountainPacket(invalidPacket), "Should reject invalid magic bytes")
assertNull(codec.parseDataHeader(invalidPacket), "Should not parse invalid header")
assertNull(codec.handleIncomingPacket(invalidPacket), "Should handle invalid packet gracefully")
}
}

View file

@ -0,0 +1,115 @@
/*
* Copyright (c) 2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.core.takserver
import kotlinx.cinterop.ExperimentalForeignApi
import kotlinx.cinterop.ObjCObjectVar
import kotlinx.cinterop.addressOf
import kotlinx.cinterop.alloc
import kotlinx.cinterop.memScoped
import kotlinx.cinterop.ptr
import kotlinx.cinterop.usePinned
import kotlinx.cinterop.value
import platform.Foundation.NSData
import platform.Foundation.NSError
import platform.Foundation.NSFileCoordinator
import platform.Foundation.NSFileCoordinatorReadingForUploading
import platform.Foundation.NSFileManager
import platform.Foundation.NSTemporaryDirectory
import platform.Foundation.NSURL
import platform.Foundation.create
import platform.Foundation.dataWithContentsOfURL
import platform.Foundation.writeToURL
import platform.posix.memcpy
@OptIn(ExperimentalForeignApi::class, kotlinx.cinterop.BetaInteropApi::class)
internal actual object ZipArchiver {
actual fun createZip(entries: Map<String, ByteArray>): ByteArray {
val fileManager = NSFileManager.defaultManager
val tempDir = NSTemporaryDirectory() + "tak_data_package/"
// Clean up and create temp directory, propagating any NSFileManager errors
fileManager.removeItemAtPath(tempDir, null)
memScoped {
val errorPtr = alloc<ObjCObjectVar<NSError?>>()
val created =
fileManager.createDirectoryAtPath(
path = tempDir,
withIntermediateDirectories = true,
attributes = null,
error = errorPtr.ptr,
)
if (!created) {
val nsError = errorPtr.value
error("Failed to create temp directory: ${nsError?.localizedDescription ?: "unknown error"}")
}
}
try {
// Write each entry as a file in the temp directory
for ((name, data) in entries) {
val fileUrl = NSURL.fileURLWithPath(tempDir + name)
val nsData =
data.usePinned { pinned ->
NSData.create(bytes = pinned.addressOf(0), length = data.size.toULong())
}
val written = nsData.writeToURL(fileUrl, atomically = true)
if (!written) {
error("Failed to write entry '$name' to temp directory")
}
}
// Use NSFileCoordinator to create a zip from the directory
val dirUrl = NSURL.fileURLWithPath(tempDir)
var zipData: ByteArray? = null
var coordinatorError: String? = null
val coordinator = NSFileCoordinator()
memScoped {
val errorPtr = alloc<ObjCObjectVar<NSError?>>()
coordinator.coordinateReadingItemAtURL(
url = dirUrl,
options = NSFileCoordinatorReadingForUploading,
error = errorPtr.ptr,
) { zipUrl ->
if (zipUrl != null) {
val data = NSData.dataWithContentsOfURL(zipUrl)
if (data != null) {
zipData =
ByteArray(data.length.toInt()).also { bytes ->
bytes.usePinned { pinned -> memcpy(pinned.addressOf(0), data.bytes, data.length) }
}
} else {
coordinatorError = "NSData.dataWithContentsOfURL returned null for $zipUrl"
}
} else {
coordinatorError = "NSFileCoordinator provided null zip URL"
}
}
val nsError = errorPtr.value
if (nsError != null) {
error("NSFileCoordinator error: ${nsError.localizedDescription}")
}
}
if (coordinatorError != null) error(coordinatorError)
return zipData ?: error("Failed to create zip archive")
} finally {
fileManager.removeItemAtPath(tempDir, null)
}
}
}

View file

@ -0,0 +1,124 @@
/*
* Copyright (c) 2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.core.takserver.fountain
import kotlinx.cinterop.ExperimentalForeignApi
import kotlinx.cinterop.addressOf
import kotlinx.cinterop.alloc
import kotlinx.cinterop.memScoped
import kotlinx.cinterop.ptr
import kotlinx.cinterop.reinterpret
import kotlinx.cinterop.usePinned
import kotlinx.cinterop.value
import platform.CoreCrypto.CC_SHA256
import platform.CoreCrypto.CC_SHA256_DIGEST_LENGTH
import platform.zlib.Z_BUF_ERROR
import platform.zlib.Z_OK
import platform.zlib.compress
import platform.zlib.compressBound
import platform.zlib.uncompress
internal actual object ZlibCodec {
@OptIn(ExperimentalForeignApi::class)
actual fun compress(data: ByteArray): ByteArray? {
if (data.isEmpty()) return ByteArray(0)
return memScoped {
val destLen = alloc<platform.zlib.uLongVar>()
destLen.value = compressBound(data.size.toULong())
val destBuffer = ByteArray(destLen.value.toInt())
val result =
destBuffer.usePinned { destPin ->
data.usePinned { srcPin ->
compress(
destPin.addressOf(0).reinterpret(),
destLen.ptr,
srcPin.addressOf(0).reinterpret(),
data.size.toULong(),
)
}
}
if (result == Z_OK) {
destBuffer.copyOf(destLen.value.toInt())
} else {
null
}
}
}
@OptIn(ExperimentalForeignApi::class)
actual fun decompress(data: ByteArray): ByteArray? {
if (data.isEmpty()) return ByteArray(0)
var currentSize = data.size * 4
var maxAttempts = 5
while (maxAttempts > 0) {
val success = memScoped {
val destLen = alloc<platform.zlib.uLongVar>()
destLen.value = currentSize.toULong()
val destBuffer = ByteArray(currentSize)
val result =
destBuffer.usePinned { destPin ->
data.usePinned { srcPin ->
uncompress(
destPin.addressOf(0).reinterpret(),
destLen.ptr,
srcPin.addressOf(0).reinterpret(),
data.size.toULong(),
)
}
}
if (result == Z_OK) {
return@memScoped destBuffer.copyOf(destLen.value.toInt())
} else if (result == Z_BUF_ERROR) {
currentSize *= 2
maxAttempts--
null
} else {
maxAttempts = 0
null
}
}
if (success != null) return success
}
return null
}
}
internal actual object CryptoCodec {
@OptIn(ExperimentalForeignApi::class)
actual fun sha256Prefix8(data: ByteArray): ByteArray {
val digest = ByteArray(CC_SHA256_DIGEST_LENGTH)
if (data.isNotEmpty()) {
data.usePinned { dataPin ->
digest.usePinned { digestPin ->
CC_SHA256(dataPin.addressOf(0), data.size.toUInt(), digestPin.addressOf(0).reinterpret())
}
}
} else {
digest.usePinned { digestPin -> CC_SHA256(null, 0u, digestPin.addressOf(0).reinterpret()) }
}
return digest.copyOf(8)
}
}

View file

@ -0,0 +1,35 @@
/*
* Copyright (c) 2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.core.takserver
import java.io.ByteArrayOutputStream
import java.util.zip.ZipEntry
import java.util.zip.ZipOutputStream
internal actual object ZipArchiver {
actual fun createZip(entries: Map<String, ByteArray>): ByteArray {
val baos = ByteArrayOutputStream()
ZipOutputStream(baos).use { zos ->
for ((name, data) in entries) {
zos.putNextEntry(ZipEntry(name))
zos.write(data)
zos.closeEntry()
}
}
return baos.toByteArray()
}
}

View file

@ -0,0 +1,75 @@
/*
* Copyright (c) 2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.core.takserver.fountain
import java.io.ByteArrayOutputStream
import java.security.MessageDigest
import java.util.zip.Deflater
import java.util.zip.Inflater
internal actual object ZlibCodec {
actual fun compress(data: ByteArray): ByteArray? {
val deflater = Deflater(Deflater.DEFAULT_COMPRESSION, false)
return try {
deflater.setInput(data)
deflater.finish()
val outputStream = ByteArrayOutputStream(data.size)
val buffer = ByteArray(1024)
while (!deflater.finished()) {
val count = deflater.deflate(buffer)
outputStream.write(buffer, 0, count)
}
outputStream.close()
outputStream.toByteArray()
} catch (e: Exception) {
null
} finally {
deflater.end()
}
}
actual fun decompress(data: ByteArray): ByteArray? {
val inflater = Inflater(false)
return try {
inflater.setInput(data)
val outputStream = ByteArrayOutputStream(data.size * 2)
val buffer = ByteArray(1024)
while (!inflater.finished()) {
val count = inflater.inflate(buffer)
if (count == 0 && inflater.needsInput()) {
break
}
outputStream.write(buffer, 0, count)
}
outputStream.close()
outputStream.toByteArray()
} catch (e: Exception) {
null
} finally {
inflater.end()
}
}
}
internal actual object CryptoCodec {
actual fun sha256Prefix8(data: ByteArray): ByteArray {
val digest = MessageDigest.getInstance("SHA-256")
return digest.digest(data).copyOf(8)
}
}

View file

@ -262,4 +262,13 @@ class FakeAppPreferences : AppPreferences {
override val mapTileProvider = FakeMapTileProviderPrefs()
override val radio = FakeRadioPrefs()
override val mesh = FakeMeshPrefs()
override val tak = FakeTakPrefs()
}
class FakeTakPrefs : org.meshtastic.core.repository.TakPrefs {
override val isTakServerEnabled = MutableStateFlow(false)
override fun setTakServerEnabled(enabled: Boolean) {
isTakServerEnabled.value = enabled
}
}

View file

@ -163,6 +163,7 @@ dependencies {
implementation(projects.core.datastore)
implementation(projects.core.prefs)
implementation(projects.core.network)
implementation(projects.core.takserver)
implementation(projects.core.resources)
implementation(projects.core.service)
implementation(projects.core.ui)

View file

@ -63,6 +63,7 @@ import org.meshtastic.core.network.di.module as coreNetworkModule
import org.meshtastic.core.prefs.di.module as corePrefsModule
import org.meshtastic.core.repository.di.module as coreRepositoryModule
import org.meshtastic.core.service.di.module as coreServiceModule
import org.meshtastic.core.takserver.di.module as coreTakServerModule
import org.meshtastic.core.ui.di.module as coreUiModule
import org.meshtastic.desktop.di.module as desktopDiModule
import org.meshtastic.feature.connections.di.module as featureConnectionsModule
@ -99,6 +100,7 @@ fun desktopModule() = module {
org.meshtastic.core.ble.di.CoreBleModule().coreBleModule(),
org.meshtastic.core.ui.di.CoreUiModule().coreUiModule(),
org.meshtastic.core.service.di.CoreServiceModule().coreServiceModule(),
org.meshtastic.core.takserver.di.CoreTakServerModule().coreTakServerModule(),
org.meshtastic.feature.settings.di.FeatureSettingsModule().featureSettingsModule(),
org.meshtastic.feature.node.di.FeatureNodeModule().featureNodeModule(),
org.meshtastic.feature.messaging.di.FeatureMessagingModule().featureMessagingModule(),

View file

@ -55,12 +55,16 @@ Current reusable check workflow includes:
- `spotlessCheck detekt`
- Android lint for all directly runnable Android modules:
`app:lintFdroidDebug app:lintGoogleDebug core:barcode:lintFdroidDebug core:barcode:lintGoogleDebug core:api:lintDebug mesh_service_example:lintDebug`
*(Note: `mesh_service_example:lintDebug` is temporary — the module is deprecated and will be
removed along with its CI tasks in a future release.)*
- Host tests plus coverage aggregation:
`test koverXmlReport app:koverXmlReportFdroidDebug app:koverXmlReportGoogleDebug core:api:koverXmlReportDebug core:barcode:koverXmlReportFdroidDebug core:barcode:koverXmlReportGoogleDebug mesh_service_example:koverXmlReportDebug desktop:koverXmlReport`
*(Note: `mesh_service_example:koverXmlReportDebug` is temporary — see above.)*
- KMP smoke compile lifecycle task (auto-discovers KMP modules and runs JVM + iOS simulator compile checks):
`kmpSmokeCompile`
- Android build tasks:
`app:assembleFdroidDebug app:assembleGoogleDebug mesh_service_example:assembleDebug`
*(Note: `mesh_service_example:assembleDebug` is temporary — see above.)*
- Instrumented tests (when emulator tests are enabled):
`app:connectedFdroidDebugAndroidTest app:connectedGoogleDebugAndroidTest core:barcode:connectedFdroidDebugAndroidTest core:barcode:connectedGoogleDebugAndroidTest`
- Coverage uploads happen once from the host job; instrumented test results upload once from the first Android matrix API to avoid duplicate reporting.
@ -70,7 +74,7 @@ Reference: `.github/workflows/reusable-check.yml`
PR workflow note:
- `.github/workflows/pull-request.yml` ignores docs-only changes (`**/*.md`, `docs/**`), so doc-only PRs may skip Android CI by design.
- PR change detection includes workflow/build/config paths such as `.github/workflows/**`, `desktop/**`, `mesh_service_example/**`, `config/**`, `gradle/**`, `settings.gradle.kts`, and `test.gradle.kts`.
- PR change detection includes workflow/build/config paths such as `.github/workflows/**`, `desktop/**`, `mesh_service_example/**` (deprecated, will be removed), `config/**`, `gradle/**`, `settings.gradle.kts`, and `test.gradle.kts`.
- Android CI on PRs runs with `run_instrumented_tests: false`; merge queue keeps the full emulator matrix on API 26 and 35.
- Gradle cache writes are enabled for trusted refs/events (`main`, `merge_group`, and `gh-readonly-queue/*`); other refs run in read-only cache mode.

View file

@ -12,7 +12,7 @@ Modules that share JVM-specific code between Android and desktop now standardize
## Module Inventory
### Core Modules (20 total)
### Core Modules (21 total)
| Module | KMP? | JVM target? | Notes |
|---|:---:|:---:|---|
@ -34,10 +34,11 @@ Modules that share JVM-specific code between Android and desktop now standardize
| `core:service` | ✅ | ✅ | Service layer; Android bindings in androidMain |
| `core:ui` | ✅ | ✅ | Shared Compose UI, pure KMP QR generator, `jvmAndroidMain` + `jvmMain` actuals |
| `core:testing` | ✅ | ✅ | Shared test doubles, fakes, and utilities for `commonTest` |
| `core:takserver` | ✅ | ✅ | TAK/ATAK integration, Fountain codec |
| `core:api` | ❌ | — | Android-only (AIDL). Intentional. |
| `core:barcode` | ❌ | — | Android-only (CameraX). Flavor split minimised to decoder factory only (ML Kit / ZXing). Shared contract in `core:ui`. |
**18/20** core modules are KMP with JVM targets. The 2 Android-only modules are intentionally platform-specific, with shared contracts already abstracted into `core:ui/commonMain`.
**19/21** core modules are KMP with JVM targets. The 2 Android-only modules are intentionally platform-specific, with shared contracts already abstracted into `core:ui/commonMain`.
### Feature Modules (8 total — 8 KMP with JVM, 1 Android-only widget)

View file

@ -44,6 +44,7 @@ kotlin {
implementation(projects.core.resources)
implementation(projects.core.ui)
implementation(projects.core.di)
implementation(projects.core.takserver)
implementation(libs.kotlinx.collections.immutable)
implementation(libs.aboutlibraries.compose.m3)

View file

@ -0,0 +1,51 @@
/*
* Copyright (c) 2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.feature.settings.tak
import android.content.Context
import android.net.Uri
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.runtime.Composable
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.platform.LocalContext
import co.touchlab.kermit.Logger
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
@Composable
actual fun rememberDataPackageExporter(dataPackageProvider: suspend () -> ByteArray): (fileName: String) -> Unit {
val context = LocalContext.current
val scope = rememberCoroutineScope()
val exportLauncher =
rememberLauncherForActivityResult(ActivityResultContracts.CreateDocument("application/zip")) { createdUri ->
if (createdUri != null) {
scope.launch { exportZipToUri(context, createdUri, dataPackageProvider()) }
}
}
return { fileName -> exportLauncher.launch(fileName) }
}
private suspend fun exportZipToUri(context: Context, targetUri: Uri, data: ByteArray) = withContext(Dispatchers.IO) {
try {
context.contentResolver.openOutputStream(targetUri)?.use { os -> os.write(data) }
Logger.i { "TAK data package exported successfully to $targetUri" }
} catch (e: java.io.IOException) {
Logger.e(e) { "Failed to export TAK data package to URI: $targetUri" }
}
}

View file

@ -0,0 +1,53 @@
/*
* Copyright (c) 2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.feature.settings.tak
import android.os.Build
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import com.google.accompanist.permissions.ExperimentalPermissionsApi
import com.google.accompanist.permissions.isGranted
import com.google.accompanist.permissions.rememberPermissionState
private const val SDK_INT_ANDROID_16 = 37
@OptIn(ExperimentalPermissionsApi::class)
@Composable
actual fun TakPermissionHandler(isTakServerEnabled: Boolean, onPermissionResult: (Boolean) -> Unit) {
if (Build.VERSION.SDK_INT >= SDK_INT_ANDROID_16) {
val permissionState =
rememberPermissionState("android.permission.ACCESS_LOCAL_NETWORK") { granted ->
// Callback fires after the system dialog is dismissed — report the result
// directly so onPermissionResult is the single authority for grant/deny.
if (isTakServerEnabled) onPermissionResult(granted)
}
LaunchedEffect(isTakServerEnabled) {
if (isTakServerEnabled) {
if (permissionState.status.isGranted) {
// Already granted — confirm immediately so the orchestrator may proceed.
onPermissionResult(true)
} else {
// Show system dialog; result is delivered via the callback above.
permissionState.launchPermissionRequest()
}
}
}
} else {
LaunchedEffect(isTakServerEnabled) { onPermissionResult(true) }
}
}

View file

@ -53,6 +53,7 @@ fun <T : Message<T, *>> RadioConfigScreenList(
enabled: Boolean,
onSave: (T) -> Unit,
modifier: Modifier = Modifier,
actions: @Composable () -> Unit = {},
additionalDirtyCheck: () -> Boolean = { false },
onDiscard: () -> Unit = {},
content: LazyListScope.() -> Unit,
@ -68,7 +69,7 @@ fun <T : Message<T, *>> RadioConfigScreenList(
onNavigateUp = onBack,
ourNode = null,
showNodeChip = false,
actions = {},
actions = actions,
onClickChip = {},
)
},

View file

@ -16,23 +16,35 @@
*/
package org.meshtastic.feature.settings.radio.component
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Share
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.ui.graphics.Color
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import org.jetbrains.compose.resources.stringResource
import org.koin.compose.koinInject
import org.meshtastic.core.model.getColorFrom
import org.meshtastic.core.model.getStringResFrom
import org.meshtastic.core.repository.TakPrefs
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.tak
import org.meshtastic.core.resources.tak_config
import org.meshtastic.core.resources.tak_role
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.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.tak.TakPermissionHandler
import org.meshtastic.feature.settings.tak.rememberDataPackageExporter
import org.meshtastic.proto.ModuleConfig
@Composable
@ -41,11 +53,30 @@ fun TAKConfigScreen(viewModel: RadioConfigViewModel, onBack: () -> Unit) {
val takConfig = state.moduleConfig.tak ?: ModuleConfig.TAKConfig()
val formState = rememberConfigState(initialValue = takConfig)
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 ->
if (!granted && isTakServerEnabled) {
takPrefs.setTakServerEnabled(false)
}
},
)
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,
@ -56,24 +87,47 @@ fun TAKConfigScreen(viewModel: RadioConfigViewModel, onBack: () -> Unit) {
},
) {
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) },
)
}
TAKConfigCard(
formState = formState,
isTakServerEnabled = isTakServerEnabled,
isConnected = state.connected,
onTakServerEnabledChange = { takPrefs.setTakServerEnabled(it) },
)
}
}
}
@Composable
private fun TAKConfigCard(
formState: ConfigState<ModuleConfig.TAKConfig>,
isTakServerEnabled: Boolean,
isConnected: Boolean,
onTakServerEnabledChange: (Boolean) -> Unit,
) {
TitledCard(title = stringResource(Res.string.tak_config)) {
SwitchPreference(
title = stringResource(Res.string.tak_server_enabled),
summary = stringResource(Res.string.tak_server_enabled_desc),
checked = isTakServerEnabled,
enabled = true,
onCheckedChange = onTakServerEnabledChange,
)
HorizontalDivider()
DropDownPreference(
title = stringResource(Res.string.tak_team),
enabled = isConnected,
selectedItem = formState.value.team,
itemLabel = { stringResource(getStringResFrom(it)) },
itemColor = { Color(getColorFrom(it)) },
onItemSelected = { formState.value = formState.value.copy(team = it) },
)
HorizontalDivider()
DropDownPreference(
title = stringResource(Res.string.tak_role),
enabled = isConnected,
selectedItem = formState.value.role,
itemLabel = { stringResource(getStringResFrom(it)) },
onItemSelected = { formState.value = formState.value.copy(role = it) },
)
}
}

View file

@ -0,0 +1,28 @@
/*
* Copyright (c) 2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.feature.settings.tak
import androidx.compose.runtime.Composable
/**
* Platform-specific composable that returns a launcher for exporting a TAK data package zip.
*
* @param dataPackageProvider suspend function producing the zip [ByteArray]
* @return a lambda accepting the suggested file name to trigger the export
*/
@Composable
expect fun rememberDataPackageExporter(dataPackageProvider: suspend () -> ByteArray): (fileName: String) -> Unit

View file

@ -0,0 +1,21 @@
/*
* Copyright (c) 2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.feature.settings.tak
import androidx.compose.runtime.Composable
@Composable expect fun TakPermissionHandler(isTakServerEnabled: Boolean, onPermissionResult: (Boolean) -> Unit)

View file

@ -0,0 +1,25 @@
/*
* Copyright (c) 2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.feature.settings.tak
import androidx.compose.runtime.Composable
@Composable
actual fun rememberDataPackageExporter(dataPackageProvider: suspend () -> ByteArray): (fileName: String) -> Unit =
{ _ ->
// No-op on iOS for now
}

View file

@ -0,0 +1,25 @@
/*
* Copyright (c) 2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.feature.settings.tak
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
@Composable
actual fun TakPermissionHandler(isTakServerEnabled: Boolean, onPermissionResult: (Boolean) -> Unit) {
LaunchedEffect(isTakServerEnabled) { onPermissionResult(true) }
}

View file

@ -0,0 +1,54 @@
/*
* Copyright (c) 2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.feature.settings.tak
import androidx.compose.runtime.Composable
import androidx.compose.runtime.rememberCoroutineScope
import co.touchlab.kermit.Logger
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.awt.FileDialog
import java.awt.Frame
import java.io.File
@Composable
actual fun rememberDataPackageExporter(dataPackageProvider: suspend () -> ByteArray): (fileName: String) -> Unit {
val scope = rememberCoroutineScope()
return { fileName ->
scope.launch {
runCatching {
val fileDialog =
FileDialog(null as Frame?, "Export TAK Data Package", FileDialog.SAVE).apply {
file = fileName
isVisible = true
}
val directory = fileDialog.directory
val file = fileDialog.file
if (directory != null && file != null) {
val targetFile = File(directory, file)
val data = dataPackageProvider()
withContext(Dispatchers.IO) { targetFile.writeBytes(data) }
Logger.i { "TAK data package exported successfully to ${targetFile.absolutePath}" }
}
}
.onFailure { e -> Logger.e(e) { "Failed to export TAK data package" } }
}
}
}

View file

@ -0,0 +1,25 @@
/*
* Copyright (c) 2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.feature.settings.tak
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
@Composable
actual fun TakPermissionHandler(isTakServerEnabled: Boolean, onPermissionResult: (Boolean) -> Unit) {
LaunchedEffect(isTakServerEnabled) { onPermissionResult(true) }
}

View file

@ -1,4 +1,6 @@
[versions]
xmlutil = "0.90.2"
# Android
agp = "9.1.0"
appcompat = "1.7.1"
@ -71,6 +73,9 @@ jmdns = "3.6.3"
qrcode-kotlin = "4.5.0"
[libraries]
xmlutil-core = { module = "io.github.pdvrieze.xmlutil:core", version.ref = "xmlutil" }
xmlutil-serialization = { module = "io.github.pdvrieze.xmlutil:serialization", version.ref = "xmlutil" }
# AndroidX
androidx-activity-compose = { module = "androidx.activity:activity-compose", version = "1.13.0" }
androidx-annotation = { module = "androidx.annotation:annotation", version = "1.9.1" }

View file

@ -1,33 +1,20 @@
# mesh_service_example
This module provides an example implementation of an app that uses the [AIDL](https://developer.android.com/develop/background-work/services/aidl) Mesh Service provided by Meshtastic-Android project.
> **DEPRECATED — scheduled for removal in a future release.**
>
> This module is no longer maintained and will be deleted once the new public API documentation is
> available. Do not add new code here. Do not use it as a template for new integrations.
>
> For integrating with the Meshtastic service from your own app, refer to the `:core:api` module
> README at [`core/api/README.md`](../core/api/README.md).
## Overview
## What this was
The [AIDL](../core/api/src/main/aidl/org/meshtastic/core/service/IMeshService.aidl) is defined in the `core:api` module and is used to interact with the mesh network.
`mesh_service_example` demonstrates how to build and integrate a custom mesh service within the Meshtastic ecosystem. It is intended as a reference for developers who want to extend or customize mesh-related functionality.
## Features
- Example service structure for mesh integration
- Sample code for service registration and communication
## Usage
1. Clone the Meshtastic-Android repository.
2. Open the project in Android Studio.
3. Explore the `mesh_service_example` module source code under `mesh_service_example/src/`.
4. Use this module as a template for your own mesh service implementations.
## Development
- To build the module, use the standard Gradle build commands:
```sh
./gradlew :mesh_service_example:build
```
- To run tests for this module:
```sh
./gradlew :mesh_service_example:test
```
`mesh_service_example` was a sample Android application demonstrating how to bind to the
`IMeshService` AIDL interface and exchange data with the Meshtastic radio service. It is kept in
the repository only to avoid breaking the CI assemble task (`mesh_service_example:assembleDebug`)
and the JitPack publication that consumers may reference, until those are formally retired.
## License
This example module is provided under the same license as the main Meshtastic-Android project. See the root `LICENSE` file for details.
See the root `LICENSE` file.

View file

@ -14,6 +14,8 @@
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
@file:Suppress("DEPRECATION")
package com.meshtastic.android.meshserviceexample
import android.content.BroadcastReceiver
@ -44,7 +46,17 @@ import org.meshtastic.core.service.IMeshService
private const val TAG: String = "MeshServiceExample"
/** MainActivity for the MeshServiceExample application. */
/**
* MainActivity for the MeshServiceExample application.
*
* **DEPRECATED.** This entire module (`mesh_service_example`) is scheduled for removal in a future release. Do not use
* it as a template for new integrations. See `:core:api` README for the current public API surface.
*/
@Deprecated(
message =
"mesh_service_example is deprecated and will be removed in a future release. " +
"See core/api/README.md for integration guidance.",
)
class MainActivity : ComponentActivity() {
private var meshService: IMeshService? = null

View file

@ -35,6 +35,7 @@ include(
":core:repository",
":core:service",
":core:resources",
":core:takserver",
":core:testing",
":core:ui",
":feature:intro",