Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions .skills/compose-ui/strings-index.txt

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

17 changes: 14 additions & 3 deletions core/resources/src/commonMain/composeResources/values/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -1657,12 +1657,23 @@
<string name="tak_role_unspecified">Unspecified</string>
<string name="tak_server">TAK Server</string>
<string name="tak_server_enabled">Enable Local TAK Server</string>
<string name="tak_server_enabled_desc">Starts a local TLS server on port 8089 for ATAK/iTAK connections</string>
<string name="tak_server_export_data_package_desc">Generate .zip for ATAK/iTAK to connect to this server</string>
<string name="tak_server_enabled_desc">Starts a local TLS server on port 8089 for ATAK connections</string>
<string name="tak_server_export_data_package_desc">Generate .zip for ATAK to connect to this server</string>
<string name="tak_server_loading">…</string>
<string name="tak_server_mesh_to_cot">Mesh to CoT Converter</string>
<string name="tak_server_mesh_to_cot_desc">Show Meshtastic nodes on the ATAK/iTAK map as contacts</string>
<string name="tak_server_mesh_to_cot_desc">Show Meshtastic nodes on the ATAK map as contacts</string>
<string name="tak_server_section">Server</string>
<string name="tak_server_status">Status</string>
<plurals name="tak_server_status_connected">
<item quantity="one">%1$d local client connection</item>
<item quantity="other">%1$d local client connections</item>
</plurals>
<string name="tak_server_status_failed">Unable to start TAK Server. Turn it off and on to retry.</string>
<string name="tak_server_status_not_running">TAK Server is not running</string>
<string name="tak_server_status_off">Off</string>
<string name="tak_server_status_starting">Starting TAK Server</string>
<string name="tak_server_status_unavailable">Local TAK Server is available in Meshtastic for Android</string>
<string name="tak_server_status_waiting">Listening on 127.0.0.1:8089 — waiting for ATAK</string>
<string name="tak_server_test_card_title">TAK Mesh Test (Debug)</string>
<string name="tak_server_test_idle">Send all %1$d test fixtures to mesh</string>
<string name="tak_server_test_result_bytes">%1$dB ✓</string>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -115,7 +115,7 @@ class MeshServiceOrchestrator(
if (isEnabled && !takServerManager.isRunning.value) {
Logger.i { "TAK Server enabled by preference, starting integration" }
takMeshIntegration.start(newScope)
} else if (!isEnabled && takServerManager.isRunning.value) {
} else if (!isEnabled) {
Logger.i { "TAK Server disabled by preference, stopping integration" }
takMeshIntegration.stop()
}
Expand Down Expand Up @@ -189,10 +189,7 @@ class MeshServiceOrchestrator(
*/
fun stop() {
Logger.i { "Stopping mesh service orchestrator" }
// Guard stop() so we don't emit a spurious "stopped" log when TAK was never started
if (takServerManager.isRunning.value) {
takMeshIntegration.stop()
}
takMeshIntegration.stop()
// Best-effort polite goodbye on service teardown (onDestroy / process shutdown). We launch
// on a fresh detached scope — not the orchestrator's per-start scope — so the subsequent
// scope.cancel() below doesn't interrupt the short drain delay inside disconnect(). The
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -203,6 +203,63 @@ class MeshServiceOrchestratorTest {
orchestrator.stop()
}

@Test
fun testTakServerCanRetryAfterFailedStart() {
val takEnabledFlow = MutableStateFlow(false)
val takRunningFlow = MutableStateFlow(false)
val lifecycleEvents = mutableListOf<String>()
every { takServerManager.start(any()) } calls
{
lifecycleEvents += "start"
Unit
}
every { takServerManager.stop() } calls
{
lifecycleEvents += "stop"
Unit
}
val orchestrator = createOrchestrator(takEnabledFlow = takEnabledFlow, takRunningFlow = takRunningFlow)

orchestrator.start()
// The mock never changes takRunningFlow, modeling a start attempt that failed before listening.
takEnabledFlow.value = true
takEnabledFlow.value = false
takEnabledFlow.value = true

assertEquals(listOf("start", "stop", "start"), lifecycleEvents)

orchestrator.stop()
Comment thread
coderabbitai[bot] marked this conversation as resolved.
assertEquals(listOf("start", "stop", "start", "stop"), lifecycleEvents)
}

@Test
fun testStopStopsTakServerWhileStarting() {
val takEnabledFlow = MutableStateFlow(true)
val takRunningFlow = MutableStateFlow(false)
val lifecycleEvents = mutableListOf<String>()
every { takServerManager.start(any()) } calls
{
lifecycleEvents += "start"
Unit
}
every { takServerManager.stop() } calls
{
lifecycleEvents += "stop"
Unit
}
val orchestrator = createOrchestrator(takEnabledFlow = takEnabledFlow, takRunningFlow = takRunningFlow)

orchestrator.start()
orchestrator.stop()

takEnabledFlow.value = false
orchestrator.start()
takEnabledFlow.value = true
orchestrator.stop()

assertEquals(listOf("start", "stop", "start", "stop"), lifecycleEvents)
}

@Test
fun testStartCallsSwitchActiveDatabase() {
// New ordering: start() waits for currentDeviceAddressFlow to surface a valid address,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,10 @@ import org.meshtastic.core.di.CoroutineDispatchers
*/
interface TAKServer {

/** Whether this platform provides a local TAK listener. */
val isSupported: Boolean
get() = true

/** Observable count of currently-connected TAK clients (ATAK/iTAK). */
val connectionCount: StateFlow<Int>

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,15 +27,19 @@ import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlin.concurrent.Volatile
import kotlin.time.Clock
import kotlin.time.Duration.Companion.minutes

/** A CoT message received from a connected TAK client, paired with the client's identity. */
data class InboundCoTMessage(val cotMessage: CoTMessage, val clientInfo: TAKClientInfo? = null)

interface TAKServerManager {
val isSupported: Boolean
val isRunning: StateFlow<Boolean>
val isStarting: StateFlow<Boolean>
val connectionCount: StateFlow<Int>
val hasStartError: StateFlow<Boolean>
val inboundMessages: SharedFlow<InboundCoTMessage>

/**
Expand All @@ -61,12 +65,22 @@ internal class TAKServerManagerImpl(private val takServer: TAKServer) : TAKServe

private var scope: CoroutineScope? = null

@Volatile private var startGeneration = 0L

override val isSupported = takServer.isSupported

private val _isRunning = MutableStateFlow(false)
override val isRunning: StateFlow<Boolean> = _isRunning.asStateFlow()

private val _isStarting = MutableStateFlow(false)
override val isStarting: StateFlow<Boolean> = _isStarting.asStateFlow()

// Mirror TAKServer's event-driven connection count — no polling needed
override val connectionCount: StateFlow<Int> = takServer.connectionCount

private val _hasStartError = MutableStateFlow(false)
override val hasStartError: StateFlow<Boolean> = _hasStartError.asStateFlow()

private val _inboundMessages = MutableSharedFlow<InboundCoTMessage>(extraBufferCapacity = 64)
override val inboundMessages: SharedFlow<InboundCoTMessage> = _inboundMessages.asSharedFlow()

Expand All @@ -80,45 +94,59 @@ internal class TAKServerManagerImpl(private val takServer: TAKServer) : TAKServe

private val offlineQueue = ArrayDeque<QueuedMessage>()
private val offlineQueueMutex = Mutex()
private val lifecycleMutex = Mutex()

companion object {
private val OFFLINE_QUEUE_TTL = 5.minutes
private const val OFFLINE_QUEUE_MAX_SIZE = 50
}

override fun start(scope: CoroutineScope) {
if (_isRunning.value) {
if (!isSupported) return
if (_isRunning.value || _isStarting.value) {
Logger.w { "TAKServerManager already running" }
return
}
_hasStartError.value = false
_isStarting.value = true
val generation = ++startGeneration
Comment thread
coderabbitai[bot] marked this conversation as resolved.
// Assign scope AFTER the guard so a second concurrent start() can never
// overwrite the active scope without actually restarting the server.
this.scope = scope

scope.launch {
// Wire up inbound message handler BEFORE starting so no messages are lost.
// Use tryEmit (non-suspending) with extraBufferCapacity to avoid launching a
// new coroutine per message, which would create unbounded coroutines under
// high message rates and could reorder messages.
takServer.onMessage = { cotMessage, clientInfo ->
if (!_inboundMessages.tryEmit(InboundCoTMessage(cotMessage, clientInfo))) {
Logger.w { "TAK inbound message buffer full; dropping message from ${clientInfo?.id}" }
lifecycleMutex.withLock {
if (generation != startGeneration) return@withLock
// Wire up inbound message handler BEFORE starting so no messages are lost.
// Use tryEmit (non-suspending) with extraBufferCapacity to avoid launching a
// new coroutine per message, which would create unbounded coroutines under
// high message rates and could reorder messages.
takServer.onMessage = { cotMessage, clientInfo ->
if (!_inboundMessages.tryEmit(InboundCoTMessage(cotMessage, clientInfo))) {
Logger.w { "TAK inbound message buffer full; dropping message from ${clientInfo?.id}" }
}
}
takServer.onClientConnected = {
drainOfflineQueue()
_clientConnected.tryEmit(Unit)
}
}
takServer.onClientConnected = {
drainOfflineQueue()
_clientConnected.tryEmit(Unit)
}

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 both callbacks if start failed so we don't hold a reference unnecessarily
takServer.onMessage = null
takServer.onClientConnected = null
val result = takServer.start(scope)
if (generation != startGeneration) {
if (result.isSuccess) takServer.stop()
return@withLock
}
_isStarting.value = false
if (result.isSuccess) {
_isRunning.value = true
Logger.i { "TAK Server started" }
} else {
_hasStartError.value = true
Logger.e(result.exceptionOrNull()) { "Failed to start TAK Server" }
// Clear both callbacks if start failed so we don't hold a reference unnecessarily
takServer.onMessage = null
takServer.onClientConnected = null
}
}
}
}
Expand All @@ -128,7 +156,10 @@ internal class TAKServerManagerImpl(private val takServer: TAKServer) : TAKServe
// any broadcast()/drainOfflineQueue() that races stop() sees _isRunning=false
// and exits early instead of launching coroutines on a scope that is about to
// be discarded.
startGeneration++
_isRunning.value = false
_isStarting.value = false
_hasStartError.value = false
scope = null
takServer.onMessage = null
takServer.onClientConnected = null
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,9 +33,17 @@ import kotlinx.coroutines.flow.asStateFlow
* only exercise the connected-client / broadcast surface (not the start/stop lifecycle) can ignore it.
*/
internal class FakeTAKServerManager : TAKServerManager {
override val isSupported = true

private val _isRunning = MutableStateFlow(false)
override val isRunning: StateFlow<Boolean> = _isRunning.asStateFlow()

private val _isStarting = MutableStateFlow(false)
override val isStarting: StateFlow<Boolean> = _isStarting.asStateFlow()

private val _hasStartError = MutableStateFlow(false)
override val hasStartError: StateFlow<Boolean> = _hasStartError.asStateFlow()

val connections = MutableStateFlow(0)
override val connectionCount: StateFlow<Int> = connections

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,10 +16,12 @@
*/
package org.meshtastic.core.takserver

import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.runCurrent
import kotlinx.coroutines.test.runTest
import kotlin.test.Test
import kotlin.test.assertEquals
Expand Down Expand Up @@ -186,15 +188,61 @@ class TAKServerManagerTest {
override suspend fun hasConnections(): Boolean = false
}

private class DelayedTAKServer : TAKServer {
override val connectionCount: StateFlow<Int> = MutableStateFlow(0)
override var onMessage: ((CoTMessage, TAKClientInfo?) -> Unit)? = null
override var onClientConnected: (() -> Unit)? = null

val startResults = mutableListOf<CompletableDeferred<Result<Unit>>>()
var stopCount = 0

override suspend fun start(scope: CoroutineScope): Result<Unit> =
CompletableDeferred<Result<Unit>>().also(startResults::add).await()

override fun stop() {
stopCount++
}

override suspend fun broadcast(cotMessage: CoTMessage) {}

override suspend fun broadcastRawXml(xml: String) {}

override suspend fun hasConnections(): Boolean = false
}

@Test
fun `start failure due to port conflict leaves isRunning false`() = runTest {
fun `start failure due to port conflict reports an error state`() = runTest {
val failingServer = FailingTAKServer()
val manager = TAKServerManagerImpl(failingServer)
manager.start(this)
advanceUntilIdle()

// Manager should NOT be running after start failure
assertEquals(false, manager.isRunning.value)
assertTrue(manager.hasStartError.value)
}

@Test
fun `next start waits for stale start cleanup`() = runTest {
val delayedServer = DelayedTAKServer()
val manager = TAKServerManagerImpl(delayedServer)
manager.start(this)
runCurrent()
assertEquals(1, delayedServer.startResults.size)

manager.stop()
manager.start(this)
runCurrent()
assertEquals(1, delayedServer.startResults.size)

delayedServer.startResults[0].complete(Result.success(Unit))
runCurrent()
assertEquals(2, delayedServer.startResults.size)

delayedServer.startResults[1].complete(Result.success(Unit))
advanceUntilIdle()

assertTrue(manager.isRunning.value)
assertEquals(2, delayedServer.stopCount)
}

@Test
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ import org.meshtastic.core.di.CoroutineDispatchers
* `TAKServer` interface entirely.
*/
private class NoopTAKServer : TAKServer {
override val isSupported = false
private val _connectionCount = MutableStateFlow(0)
override val connectionCount: StateFlow<Int> = _connectionCount.asStateFlow()
override var onMessage: ((CoTMessage, TAKClientInfo?) -> Unit)? = null
Expand Down
Loading
Loading