feat(tak): show local server status (#6599)
No files matched your search
@@ -1658,6 +1658,14 @@ tak_server_loading
|
||||
tak_server_mesh_to_cot
|
||||
tak_server_mesh_to_cot_desc
|
||||
tak_server_section
|
||||
tak_server_status
|
||||
tak_server_status_connected
|
||||
tak_server_status_failed
|
||||
tak_server_status_not_running
|
||||
tak_server_status_off
|
||||
tak_server_status_starting
|
||||
tak_server_status_unavailable
|
||||
tak_server_status_waiting
|
||||
tak_server_test_card_title
|
||||
tak_server_test_idle
|
||||
tak_server_test_result_bytes
|
||||
|
||||
@@ -1697,12 +1697,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>
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -27,6 +27,7 @@ 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
|
||||
|
||||
@@ -34,8 +35,11 @@ import kotlin.time.Duration.Companion.minutes
|
||||
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>
|
||||
|
||||
/**
|
||||
@@ -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()
|
||||
|
||||
@@ -80,6 +94,7 @@ 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
|
||||
@@ -87,38 +102,51 @@ internal class TAKServerManagerImpl(private val takServer: TAKServer) : TAKServe
|
||||
}
|
||||
|
||||
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
|
||||
// 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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -18,11 +18,15 @@
|
||||
|
||||
package org.meshtastic.feature.settings.radio.component
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
@@ -44,6 +48,7 @@ import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import kotlinx.coroutines.launch
|
||||
import org.jetbrains.compose.resources.pluralStringResource
|
||||
import org.jetbrains.compose.resources.stringResource
|
||||
import org.koin.compose.koinInject
|
||||
import org.meshtastic.core.common.BuildConfigProvider
|
||||
@@ -65,6 +70,14 @@ import org.meshtastic.core.resources.tak_server_loading
|
||||
import org.meshtastic.core.resources.tak_server_mesh_to_cot
|
||||
import org.meshtastic.core.resources.tak_server_mesh_to_cot_desc
|
||||
import org.meshtastic.core.resources.tak_server_section
|
||||
import org.meshtastic.core.resources.tak_server_status
|
||||
import org.meshtastic.core.resources.tak_server_status_connected
|
||||
import org.meshtastic.core.resources.tak_server_status_failed
|
||||
import org.meshtastic.core.resources.tak_server_status_not_running
|
||||
import org.meshtastic.core.resources.tak_server_status_off
|
||||
import org.meshtastic.core.resources.tak_server_status_starting
|
||||
import org.meshtastic.core.resources.tak_server_status_unavailable
|
||||
import org.meshtastic.core.resources.tak_server_status_waiting
|
||||
import org.meshtastic.core.resources.tak_server_test_card_title
|
||||
import org.meshtastic.core.resources.tak_server_test_idle
|
||||
import org.meshtastic.core.resources.tak_server_test_result_bytes
|
||||
@@ -74,6 +87,7 @@ import org.meshtastic.core.resources.tak_server_test_run
|
||||
import org.meshtastic.core.resources.tak_server_test_running
|
||||
import org.meshtastic.core.resources.tak_team
|
||||
import org.meshtastic.core.takserver.TAKDataPackageGenerator
|
||||
import org.meshtastic.core.takserver.TAKServerManager
|
||||
import org.meshtastic.core.takserver.TakMeshTestRunner
|
||||
import org.meshtastic.core.takserver.TakTestResult
|
||||
import org.meshtastic.core.ui.component.DropDownPreference
|
||||
@@ -180,8 +194,22 @@ internal fun handleTakPermissionResult(granted: Boolean, isTakServerEnabled: Boo
|
||||
@Composable
|
||||
fun TakServerScreen(onBack: () -> Unit) {
|
||||
val takPrefs: TakPrefs = koinInject()
|
||||
val takServerManager: TAKServerManager = koinInject()
|
||||
val isTakServerEnabled by takPrefs.isTakServerEnabled.collectAsStateWithLifecycle()
|
||||
val isMeshToCotEnabled by takPrefs.isMeshToCotEnabled.collectAsStateWithLifecycle()
|
||||
val isTakServerRunning by takServerManager.isRunning.collectAsStateWithLifecycle()
|
||||
val isTakServerStarting by takServerManager.isStarting.collectAsStateWithLifecycle()
|
||||
val takClientCount by takServerManager.connectionCount.collectAsStateWithLifecycle()
|
||||
val hasTakServerStartError by takServerManager.hasStartError.collectAsStateWithLifecycle()
|
||||
val takServerStatus =
|
||||
TakServerStatus.resolve(
|
||||
isSupported = takServerManager.isSupported,
|
||||
isEnabled = isTakServerEnabled,
|
||||
isRunning = isTakServerRunning,
|
||||
isStarting = isTakServerStarting,
|
||||
clientCount = takClientCount,
|
||||
hasStartError = hasTakServerStartError,
|
||||
)
|
||||
val exportLauncher = rememberDataPackageExporter { TAKDataPackageGenerator.generateDataPackage() }
|
||||
|
||||
TakPermissionHandler(
|
||||
@@ -220,6 +248,8 @@ fun TakServerScreen(onBack: () -> Unit) {
|
||||
onEnabledChange = { takPrefs.setTakServerEnabled(it) },
|
||||
isMeshToCotEnabled = isMeshToCotEnabled,
|
||||
onMeshToCotChange = { takPrefs.setMeshToCotEnabled(it) },
|
||||
status = takServerStatus,
|
||||
clientCount = takClientCount,
|
||||
onExport = { exportLauncher("Meshtastic_TAK_Server.zip") },
|
||||
)
|
||||
TakMeshTestCard()
|
||||
@@ -234,6 +264,8 @@ internal fun TakServerSection(
|
||||
onEnabledChange: (Boolean) -> Unit,
|
||||
isMeshToCotEnabled: Boolean,
|
||||
onMeshToCotChange: (Boolean) -> Unit,
|
||||
status: TakServerStatus,
|
||||
clientCount: Int,
|
||||
onExport: () -> Unit,
|
||||
) {
|
||||
TitledCard(title = stringResource(Res.string.tak_server_section)) {
|
||||
@@ -244,6 +276,8 @@ internal fun TakServerSection(
|
||||
enabled = true,
|
||||
onCheckedChange = onEnabledChange,
|
||||
)
|
||||
HorizontalDivider()
|
||||
TakServerStatusRow(status = status, clientCount = clientCount)
|
||||
if (isTakServerEnabled) {
|
||||
HorizontalDivider()
|
||||
SwitchPreference(
|
||||
@@ -281,6 +315,79 @@ internal fun TakServerSection(
|
||||
}
|
||||
}
|
||||
|
||||
internal enum class TakServerStatus {
|
||||
Unavailable,
|
||||
Off,
|
||||
Starting,
|
||||
WaitingForClient,
|
||||
Connected,
|
||||
Failed,
|
||||
NotRunning,
|
||||
;
|
||||
|
||||
companion object {
|
||||
fun resolve(
|
||||
isSupported: Boolean,
|
||||
isEnabled: Boolean,
|
||||
isRunning: Boolean,
|
||||
isStarting: Boolean,
|
||||
clientCount: Int,
|
||||
hasStartError: Boolean,
|
||||
): TakServerStatus = when {
|
||||
!isSupported -> Unavailable
|
||||
!isEnabled -> Off
|
||||
hasStartError -> Failed
|
||||
isStarting -> Starting
|
||||
isRunning && clientCount > 0 -> Connected
|
||||
isRunning -> WaitingForClient
|
||||
else -> NotRunning
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun TakServerStatusRow(status: TakServerStatus, clientCount: Int) {
|
||||
val (description, color) =
|
||||
when (status) {
|
||||
TakServerStatus.Unavailable ->
|
||||
stringResource(Res.string.tak_server_status_unavailable) to MaterialTheme.colorScheme.onSurfaceVariant
|
||||
|
||||
TakServerStatus.Off ->
|
||||
stringResource(Res.string.tak_server_status_off) to MaterialTheme.colorScheme.onSurfaceVariant
|
||||
|
||||
TakServerStatus.Starting ->
|
||||
stringResource(Res.string.tak_server_status_starting) to MaterialTheme.colorScheme.onSurfaceVariant
|
||||
|
||||
TakServerStatus.WaitingForClient ->
|
||||
stringResource(Res.string.tak_server_status_waiting) to MaterialTheme.colorScheme.primary
|
||||
|
||||
TakServerStatus.Connected ->
|
||||
pluralStringResource(Res.plurals.tak_server_status_connected, clientCount, clientCount) to
|
||||
MaterialTheme.colorScheme.primary
|
||||
|
||||
TakServerStatus.Failed ->
|
||||
stringResource(Res.string.tak_server_status_failed) to MaterialTheme.colorScheme.error
|
||||
|
||||
TakServerStatus.NotRunning ->
|
||||
stringResource(Res.string.tak_server_status_not_running) to MaterialTheme.colorScheme.onSurfaceVariant
|
||||
}
|
||||
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Box(modifier = Modifier.size(10.dp).background(color, CircleShape))
|
||||
Column(modifier = Modifier.padding(start = 12.dp)) {
|
||||
Text(text = stringResource(Res.string.tak_server_status), style = MaterialTheme.typography.bodyLarge)
|
||||
Text(
|
||||
text = description,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Debug-only TAK Mesh Test Card ────────────────────────────────────────────
|
||||
|
||||
@Composable
|
||||
|
||||
@@ -48,6 +48,8 @@ fun TakServerSectionDisabledPreview() {
|
||||
onEnabledChange = {},
|
||||
isMeshToCotEnabled = false,
|
||||
onMeshToCotChange = {},
|
||||
status = TakServerStatus.Off,
|
||||
clientCount = 0,
|
||||
onExport = {},
|
||||
)
|
||||
}
|
||||
@@ -62,6 +64,40 @@ fun TakServerSectionEnabledPreview() {
|
||||
onEnabledChange = {},
|
||||
isMeshToCotEnabled = true,
|
||||
onMeshToCotChange = {},
|
||||
status = TakServerStatus.WaitingForClient,
|
||||
clientCount = 0,
|
||||
onExport = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@PreviewLightDark
|
||||
@Composable
|
||||
fun TakServerSectionConnectedPreview() {
|
||||
AppTheme {
|
||||
TakServerSection(
|
||||
isTakServerEnabled = true,
|
||||
onEnabledChange = {},
|
||||
isMeshToCotEnabled = true,
|
||||
onMeshToCotChange = {},
|
||||
status = TakServerStatus.Connected,
|
||||
clientCount = 1,
|
||||
onExport = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@PreviewLightDark
|
||||
@Composable
|
||||
fun TakServerSectionFailedPreview() {
|
||||
AppTheme {
|
||||
TakServerSection(
|
||||
isTakServerEnabled = true,
|
||||
onEnabledChange = {},
|
||||
isMeshToCotEnabled = true,
|
||||
onMeshToCotChange = {},
|
||||
status = TakServerStatus.Failed,
|
||||
clientCount = 0,
|
||||
onExport = {},
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
/*
|
||||
* 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.radio.component
|
||||
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
|
||||
class TakServerStatusTest {
|
||||
|
||||
@Test
|
||||
fun `disabled server is off`() {
|
||||
assertEquals(TakServerStatus.Off, TakServerStatus.resolve(true, false, false, false, 0, false))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `enabled server reports startup failure`() {
|
||||
assertEquals(TakServerStatus.Failed, TakServerStatus.resolve(true, true, false, false, 0, true))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `enabled server reports starting until it listens`() {
|
||||
assertEquals(TakServerStatus.Starting, TakServerStatus.resolve(true, true, false, true, 0, false))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `listening server distinguishes waiting and connected clients`() {
|
||||
assertEquals(TakServerStatus.WaitingForClient, TakServerStatus.resolve(true, true, true, false, 0, false))
|
||||
assertEquals(TakServerStatus.Connected, TakServerStatus.resolve(true, true, true, false, 1, false))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `stopped server ignores stale client count`() {
|
||||
assertEquals(TakServerStatus.NotRunning, TakServerStatus.resolve(true, true, false, false, 1, false))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `unsupported platform and inactive service report their distinct states`() {
|
||||
assertEquals(TakServerStatus.Unavailable, TakServerStatus.resolve(false, true, false, false, 0, false))
|
||||
assertEquals(TakServerStatus.NotRunning, TakServerStatus.resolve(true, true, false, false, 0, false))
|
||||
}
|
||||
}
|
||||
@@ -42,8 +42,10 @@ import org.meshtastic.feature.settings.radio.component.PacketAuthenticityStrictC
|
||||
import org.meshtastic.feature.settings.radio.component.PacketAuthenticityStrictPreview
|
||||
import org.meshtastic.feature.settings.radio.component.PacketAuthenticityUnsupportedPreview
|
||||
import org.meshtastic.feature.settings.radio.component.TakConfigCardPreview
|
||||
import org.meshtastic.feature.settings.radio.component.TakServerSectionConnectedPreview
|
||||
import org.meshtastic.feature.settings.radio.component.TakServerSectionDisabledPreview
|
||||
import org.meshtastic.feature.settings.radio.component.TakServerSectionEnabledPreview
|
||||
import org.meshtastic.feature.settings.radio.component.TakServerSectionFailedPreview
|
||||
import org.meshtastic.feature.settings.radio.component.TakTestCardIdlePreview
|
||||
import org.meshtastic.feature.settings.radio.component.TakTestCardResultsPreview
|
||||
import org.meshtastic.feature.settings.radio.component.TakTestCardRunningPreview
|
||||
@@ -97,6 +99,20 @@ fun ScreenshotTakServerSectionEnabled() {
|
||||
TakServerSectionEnabledPreview()
|
||||
}
|
||||
|
||||
@PreviewTest
|
||||
@PreviewLightDark
|
||||
@Composable
|
||||
fun ScreenshotTakServerSectionConnected() {
|
||||
TakServerSectionConnectedPreview()
|
||||
}
|
||||
|
||||
@PreviewTest
|
||||
@PreviewLightDark
|
||||
@Composable
|
||||
fun ScreenshotTakServerSectionFailed() {
|
||||
TakServerSectionFailedPreview()
|
||||
}
|
||||
|
||||
@PreviewTest
|
||||
@PreviewLightDark
|
||||
@Composable
|
||||
|
||||
|
After Width: | Height: | Size: 72 KiB |
|
After Width: | Height: | Size: 71 KiB |
|
Before Width: | Height: | Size: 28 KiB After Width: | Height: | Size: 33 KiB |
|
Before Width: | Height: | Size: 28 KiB After Width: | Height: | Size: 32 KiB |
|
Before Width: | Height: | Size: 66 KiB After Width: | Height: | Size: 76 KiB |
|
Before Width: | Height: | Size: 66 KiB After Width: | Height: | Size: 75 KiB |
|
After Width: | Height: | Size: 75 KiB |
|
After Width: | Height: | Size: 75 KiB |