mirror of
https://github.com/meshtastic/Meshtastic-Android.git
synced 2026-04-20 22:23:37 +00:00
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:
parent
d1ca8ec527
commit
e249461e3c
76 changed files with 4587 additions and 64 deletions
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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" />
|
||||
|
||||
<!--
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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] */
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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 =
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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() {}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
|
|||
64
core/takserver/build.gradle.kts
Normal file
64
core/takserver/build.gradle.kts
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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),
|
||||
)
|
||||
}
|
||||
|
|
@ -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()
|
||||
|
|
@ -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 = "",
|
||||
)
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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>")
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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}" }
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
)
|
||||
|
|
@ -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]")
|
||||
}
|
||||
|
|
@ -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) }
|
||||
}
|
||||
}
|
||||
|
|
@ -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("&", "&").replace("<", "<").replace(">", ">").replace("\"", """).replace("'", "'")
|
||||
|
||||
/**
|
||||
* 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
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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" }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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&with<special>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&B<C>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")
|
||||
}
|
||||
}
|
||||
|
|
@ -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())
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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&B", "A&B".xmlEscaped())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `xmlEscaped escapes less-than`() {
|
||||
assertEquals("<tag>", "<tag>".xmlEscaped())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `xmlEscaped escapes double quote`() {
|
||||
assertEquals("say "hi"", """say "hi"""".xmlEscaped())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `xmlEscaped escapes single quote`() {
|
||||
assertEquals("it's", "it's".xmlEscaped())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `xmlEscaped escapes all special chars in one string`() {
|
||||
assertEquals("&<>"'", "&<>\"'".xmlEscaped())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `xmlEscaped escapes ampersand before other entities to avoid double-escaping`() {
|
||||
// "&" in input should become "&amp;" — not "&" (which would be a double-escape bug)
|
||||
assertEquals("&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())
|
||||
}
|
||||
}
|
||||
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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" }
|
||||
}
|
||||
}
|
||||
|
|
@ -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) }
|
||||
}
|
||||
}
|
||||
|
|
@ -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 = {},
|
||||
)
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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) },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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)
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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) }
|
||||
}
|
||||
|
|
@ -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" } }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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) }
|
||||
}
|
||||
|
|
@ -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" }
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -35,6 +35,7 @@ include(
|
|||
":core:repository",
|
||||
":core:service",
|
||||
":core:resources",
|
||||
":core:takserver",
|
||||
":core:testing",
|
||||
":core:ui",
|
||||
":feature:intro",
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue