diff --git a/.skills/compose-ui/strings-index.txt b/.skills/compose-ui/strings-index.txt index 8c8e1fdad4..a909fe5141 100644 --- a/.skills/compose-ui/strings-index.txt +++ b/.skills/compose-ui/strings-index.txt @@ -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 diff --git a/core/resources/src/commonMain/composeResources/values/strings.xml b/core/resources/src/commonMain/composeResources/values/strings.xml index 4b7af67a04..06cb046cbc 100644 --- a/core/resources/src/commonMain/composeResources/values/strings.xml +++ b/core/resources/src/commonMain/composeResources/values/strings.xml @@ -1697,12 +1697,23 @@ Unspecified TAK Server Enable Local TAK Server - Starts a local TLS server on port 8089 for ATAK/iTAK connections - Generate .zip for ATAK/iTAK to connect to this server + Starts a local TLS server on port 8089 for ATAK connections + Generate .zip for ATAK to connect to this server Mesh to CoT Converter - Show Meshtastic nodes on the ATAK/iTAK map as contacts + Show Meshtastic nodes on the ATAK map as contacts Server + Status + + %1$d local client connection + %1$d local client connections + + Unable to start TAK Server. Turn it off and on to retry. + TAK Server is not running + Off + Starting TAK Server + Local TAK Server is available in Meshtastic for Android + Listening on 127.0.0.1:8089 — waiting for ATAK TAK Mesh Test (Debug) Send all %1$d test fixtures to mesh %1$dB ✓ diff --git a/core/service/src/commonMain/kotlin/org/meshtastic/core/service/MeshServiceOrchestrator.kt b/core/service/src/commonMain/kotlin/org/meshtastic/core/service/MeshServiceOrchestrator.kt index 3afe5df6eb..6264eedac2 100644 --- a/core/service/src/commonMain/kotlin/org/meshtastic/core/service/MeshServiceOrchestrator.kt +++ b/core/service/src/commonMain/kotlin/org/meshtastic/core/service/MeshServiceOrchestrator.kt @@ -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 diff --git a/core/service/src/commonTest/kotlin/org/meshtastic/core/service/MeshServiceOrchestratorTest.kt b/core/service/src/commonTest/kotlin/org/meshtastic/core/service/MeshServiceOrchestratorTest.kt index 66a1a79653..9e3a444a0b 100644 --- a/core/service/src/commonTest/kotlin/org/meshtastic/core/service/MeshServiceOrchestratorTest.kt +++ b/core/service/src/commonTest/kotlin/org/meshtastic/core/service/MeshServiceOrchestratorTest.kt @@ -203,6 +203,63 @@ class MeshServiceOrchestratorTest { orchestrator.stop() } + @Test + fun testTakServerCanRetryAfterFailedStart() { + val takEnabledFlow = MutableStateFlow(false) + val takRunningFlow = MutableStateFlow(false) + val lifecycleEvents = mutableListOf() + 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() + 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, diff --git a/core/takserver/src/commonMain/kotlin/org/meshtastic/core/takserver/TAKServer.kt b/core/takserver/src/commonMain/kotlin/org/meshtastic/core/takserver/TAKServer.kt index 0a55f4fda0..296382326f 100644 --- a/core/takserver/src/commonMain/kotlin/org/meshtastic/core/takserver/TAKServer.kt +++ b/core/takserver/src/commonMain/kotlin/org/meshtastic/core/takserver/TAKServer.kt @@ -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 diff --git a/core/takserver/src/commonMain/kotlin/org/meshtastic/core/takserver/TAKServerManager.kt b/core/takserver/src/commonMain/kotlin/org/meshtastic/core/takserver/TAKServerManager.kt index c5391ce2c3..0bd99ef3ee 100644 --- a/core/takserver/src/commonMain/kotlin/org/meshtastic/core/takserver/TAKServerManager.kt +++ b/core/takserver/src/commonMain/kotlin/org/meshtastic/core/takserver/TAKServerManager.kt @@ -27,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 + val isStarting: StateFlow val connectionCount: StateFlow + val hasStartError: StateFlow val inboundMessages: SharedFlow /** @@ -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 = _isRunning.asStateFlow() + private val _isStarting = MutableStateFlow(false) + override val isStarting: StateFlow = _isStarting.asStateFlow() + // Mirror TAKServer's event-driven connection count — no polling needed override val connectionCount: StateFlow = takServer.connectionCount + private val _hasStartError = MutableStateFlow(false) + override val hasStartError: StateFlow = _hasStartError.asStateFlow() + private val _inboundMessages = MutableSharedFlow(extraBufferCapacity = 64) override val inboundMessages: SharedFlow = _inboundMessages.asSharedFlow() @@ -80,6 +94,7 @@ internal class TAKServerManagerImpl(private val takServer: TAKServer) : TAKServe private val offlineQueue = ArrayDeque() 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 diff --git a/core/takserver/src/commonTest/kotlin/org/meshtastic/core/takserver/FakeTAKServerManager.kt b/core/takserver/src/commonTest/kotlin/org/meshtastic/core/takserver/FakeTAKServerManager.kt index f4a62f5ae3..f401521094 100644 --- a/core/takserver/src/commonTest/kotlin/org/meshtastic/core/takserver/FakeTAKServerManager.kt +++ b/core/takserver/src/commonTest/kotlin/org/meshtastic/core/takserver/FakeTAKServerManager.kt @@ -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 = _isRunning.asStateFlow() + private val _isStarting = MutableStateFlow(false) + override val isStarting: StateFlow = _isStarting.asStateFlow() + + private val _hasStartError = MutableStateFlow(false) + override val hasStartError: StateFlow = _hasStartError.asStateFlow() + val connections = MutableStateFlow(0) override val connectionCount: StateFlow = connections diff --git a/core/takserver/src/commonTest/kotlin/org/meshtastic/core/takserver/TAKServerManagerTest.kt b/core/takserver/src/commonTest/kotlin/org/meshtastic/core/takserver/TAKServerManagerTest.kt index 95c2888570..4dd7d70fbc 100644 --- a/core/takserver/src/commonTest/kotlin/org/meshtastic/core/takserver/TAKServerManagerTest.kt +++ b/core/takserver/src/commonTest/kotlin/org/meshtastic/core/takserver/TAKServerManagerTest.kt @@ -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 = MutableStateFlow(0) + override var onMessage: ((CoTMessage, TAKClientInfo?) -> Unit)? = null + override var onClientConnected: (() -> Unit)? = null + + val startResults = mutableListOf>>() + var stopCount = 0 + + override suspend fun start(scope: CoroutineScope): Result = + CompletableDeferred>().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 diff --git a/core/takserver/src/iosMain/kotlin/org/meshtastic/core/takserver/TAKServerIos.kt b/core/takserver/src/iosMain/kotlin/org/meshtastic/core/takserver/TAKServerIos.kt index 3aad75bd9a..4f20e24066 100644 --- a/core/takserver/src/iosMain/kotlin/org/meshtastic/core/takserver/TAKServerIos.kt +++ b/core/takserver/src/iosMain/kotlin/org/meshtastic/core/takserver/TAKServerIos.kt @@ -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 = _connectionCount.asStateFlow() override var onMessage: ((CoTMessage, TAKClientInfo?) -> Unit)? = null diff --git a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/component/TAKConfigItemList.kt b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/component/TAKConfigItemList.kt index b8c98d97a8..ffcc450e7b 100644 --- a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/component/TAKConfigItemList.kt +++ b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/component/TAKConfigItemList.kt @@ -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 diff --git a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/component/TAKConfigPreviews.kt b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/component/TAKConfigPreviews.kt index f969105dcf..e09c77746c 100644 --- a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/component/TAKConfigPreviews.kt +++ b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/component/TAKConfigPreviews.kt @@ -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 = {}, ) } diff --git a/feature/settings/src/commonTest/kotlin/org/meshtastic/feature/settings/radio/component/TakServerStatusTest.kt b/feature/settings/src/commonTest/kotlin/org/meshtastic/feature/settings/radio/component/TakServerStatusTest.kt new file mode 100644 index 0000000000..4af0eb17d1 --- /dev/null +++ b/feature/settings/src/commonTest/kotlin/org/meshtastic/feature/settings/radio/component/TakServerStatusTest.kt @@ -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 . + */ +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)) + } +} diff --git a/screenshot-tests/src/screenshotTest/kotlin/org/meshtastic/screenshots/feature/SettingsScreenshotTests.kt b/screenshot-tests/src/screenshotTest/kotlin/org/meshtastic/screenshots/feature/SettingsScreenshotTests.kt index efef7740fe..6c465a2e38 100644 --- a/screenshot-tests/src/screenshotTest/kotlin/org/meshtastic/screenshots/feature/SettingsScreenshotTests.kt +++ b/screenshot-tests/src/screenshotTest/kotlin/org/meshtastic/screenshots/feature/SettingsScreenshotTests.kt @@ -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 diff --git a/screenshot-tests/src/screenshotTestDebug/reference/org/meshtastic/screenshots/feature/SettingsScreenshotTestsKt/ScreenshotTakServerSectionConnected_Dark_d19fbf1f_0.png b/screenshot-tests/src/screenshotTestDebug/reference/org/meshtastic/screenshots/feature/SettingsScreenshotTestsKt/ScreenshotTakServerSectionConnected_Dark_d19fbf1f_0.png new file mode 100644 index 0000000000..56ad2b162a Binary files /dev/null and b/screenshot-tests/src/screenshotTestDebug/reference/org/meshtastic/screenshots/feature/SettingsScreenshotTestsKt/ScreenshotTakServerSectionConnected_Dark_d19fbf1f_0.png differ diff --git a/screenshot-tests/src/screenshotTestDebug/reference/org/meshtastic/screenshots/feature/SettingsScreenshotTestsKt/ScreenshotTakServerSectionConnected_Light_b29dc7a7_0.png b/screenshot-tests/src/screenshotTestDebug/reference/org/meshtastic/screenshots/feature/SettingsScreenshotTestsKt/ScreenshotTakServerSectionConnected_Light_b29dc7a7_0.png new file mode 100644 index 0000000000..10d0855473 Binary files /dev/null and b/screenshot-tests/src/screenshotTestDebug/reference/org/meshtastic/screenshots/feature/SettingsScreenshotTestsKt/ScreenshotTakServerSectionConnected_Light_b29dc7a7_0.png differ diff --git a/screenshot-tests/src/screenshotTestDebug/reference/org/meshtastic/screenshots/feature/SettingsScreenshotTestsKt/ScreenshotTakServerSectionDisabled_Dark_d19fbf1f_0.png b/screenshot-tests/src/screenshotTestDebug/reference/org/meshtastic/screenshots/feature/SettingsScreenshotTestsKt/ScreenshotTakServerSectionDisabled_Dark_d19fbf1f_0.png index f5310fc88d..25517f251d 100644 Binary files a/screenshot-tests/src/screenshotTestDebug/reference/org/meshtastic/screenshots/feature/SettingsScreenshotTestsKt/ScreenshotTakServerSectionDisabled_Dark_d19fbf1f_0.png and b/screenshot-tests/src/screenshotTestDebug/reference/org/meshtastic/screenshots/feature/SettingsScreenshotTestsKt/ScreenshotTakServerSectionDisabled_Dark_d19fbf1f_0.png differ diff --git a/screenshot-tests/src/screenshotTestDebug/reference/org/meshtastic/screenshots/feature/SettingsScreenshotTestsKt/ScreenshotTakServerSectionDisabled_Light_b29dc7a7_0.png b/screenshot-tests/src/screenshotTestDebug/reference/org/meshtastic/screenshots/feature/SettingsScreenshotTestsKt/ScreenshotTakServerSectionDisabled_Light_b29dc7a7_0.png index 34ea45c3ee..43dec6c39d 100644 Binary files a/screenshot-tests/src/screenshotTestDebug/reference/org/meshtastic/screenshots/feature/SettingsScreenshotTestsKt/ScreenshotTakServerSectionDisabled_Light_b29dc7a7_0.png and b/screenshot-tests/src/screenshotTestDebug/reference/org/meshtastic/screenshots/feature/SettingsScreenshotTestsKt/ScreenshotTakServerSectionDisabled_Light_b29dc7a7_0.png differ diff --git a/screenshot-tests/src/screenshotTestDebug/reference/org/meshtastic/screenshots/feature/SettingsScreenshotTestsKt/ScreenshotTakServerSectionEnabled_Dark_d19fbf1f_0.png b/screenshot-tests/src/screenshotTestDebug/reference/org/meshtastic/screenshots/feature/SettingsScreenshotTestsKt/ScreenshotTakServerSectionEnabled_Dark_d19fbf1f_0.png index 4887426655..6d6472d433 100644 Binary files a/screenshot-tests/src/screenshotTestDebug/reference/org/meshtastic/screenshots/feature/SettingsScreenshotTestsKt/ScreenshotTakServerSectionEnabled_Dark_d19fbf1f_0.png and b/screenshot-tests/src/screenshotTestDebug/reference/org/meshtastic/screenshots/feature/SettingsScreenshotTestsKt/ScreenshotTakServerSectionEnabled_Dark_d19fbf1f_0.png differ diff --git a/screenshot-tests/src/screenshotTestDebug/reference/org/meshtastic/screenshots/feature/SettingsScreenshotTestsKt/ScreenshotTakServerSectionEnabled_Light_b29dc7a7_0.png b/screenshot-tests/src/screenshotTestDebug/reference/org/meshtastic/screenshots/feature/SettingsScreenshotTestsKt/ScreenshotTakServerSectionEnabled_Light_b29dc7a7_0.png index e2e94408cc..c2d479d6f4 100644 Binary files a/screenshot-tests/src/screenshotTestDebug/reference/org/meshtastic/screenshots/feature/SettingsScreenshotTestsKt/ScreenshotTakServerSectionEnabled_Light_b29dc7a7_0.png and b/screenshot-tests/src/screenshotTestDebug/reference/org/meshtastic/screenshots/feature/SettingsScreenshotTestsKt/ScreenshotTakServerSectionEnabled_Light_b29dc7a7_0.png differ diff --git a/screenshot-tests/src/screenshotTestDebug/reference/org/meshtastic/screenshots/feature/SettingsScreenshotTestsKt/ScreenshotTakServerSectionFailed_Dark_d19fbf1f_0.png b/screenshot-tests/src/screenshotTestDebug/reference/org/meshtastic/screenshots/feature/SettingsScreenshotTestsKt/ScreenshotTakServerSectionFailed_Dark_d19fbf1f_0.png new file mode 100644 index 0000000000..d5fb90ebf3 Binary files /dev/null and b/screenshot-tests/src/screenshotTestDebug/reference/org/meshtastic/screenshots/feature/SettingsScreenshotTestsKt/ScreenshotTakServerSectionFailed_Dark_d19fbf1f_0.png differ diff --git a/screenshot-tests/src/screenshotTestDebug/reference/org/meshtastic/screenshots/feature/SettingsScreenshotTestsKt/ScreenshotTakServerSectionFailed_Light_b29dc7a7_0.png b/screenshot-tests/src/screenshotTestDebug/reference/org/meshtastic/screenshots/feature/SettingsScreenshotTestsKt/ScreenshotTakServerSectionFailed_Light_b29dc7a7_0.png new file mode 100644 index 0000000000..3261ed4b13 Binary files /dev/null and b/screenshot-tests/src/screenshotTestDebug/reference/org/meshtastic/screenshots/feature/SettingsScreenshotTestsKt/ScreenshotTakServerSectionFailed_Light_b29dc7a7_0.png differ