From fad26f1273d294147e5effc0468074d7e6fb27e4 Mon Sep 17 00:00:00 2001 From: James Rich <2199651+jamesarich@users.noreply.github.com> Date: Sat, 7 Feb 2026 12:52:35 -0600 Subject: [PATCH] feat: Improve connection state broadcast and timing (#4498) Signed-off-by: James Rich <2199651+jamesarich@users.noreply.github.com> --- app/build.gradle.kts | 2 + .../com/geeksville/mesh/service/Constants.kt | 1 + .../mesh/service/MeshConfigFlowManager.kt | 7 +- .../mesh/service/MeshConnectionManager.kt | 16 +++-- .../mesh/service/MeshServiceBroadcasts.kt | 16 ++++- .../mesh/service/MeshConnectionManagerTest.kt | 27 ++++++++ .../mesh/service/MeshServiceBroadcastsTest.kt | 68 +++++++++++++++++++ 7 files changed, 127 insertions(+), 10 deletions(-) create mode 100644 app/src/test/java/com/geeksville/mesh/service/MeshServiceBroadcastsTest.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index f759ccfb0..8511eb515 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -267,6 +267,8 @@ dependencies { testImplementation(libs.nordic.client.mock) testImplementation(libs.nordic.core.mock) testImplementation(libs.nordic.core.android.mock) + testImplementation(libs.robolectric) + testImplementation(libs.androidx.test.core) } aboutLibraries { diff --git a/app/src/main/java/com/geeksville/mesh/service/Constants.kt b/app/src/main/java/com/geeksville/mesh/service/Constants.kt index 0988c6682..a107981cc 100644 --- a/app/src/main/java/com/geeksville/mesh/service/Constants.kt +++ b/app/src/main/java/com/geeksville/mesh/service/Constants.kt @@ -20,6 +20,7 @@ const val PREFIX = "com.geeksville.mesh" const val ACTION_NODE_CHANGE = "$PREFIX.NODE_CHANGE" const val ACTION_MESH_CONNECTED = "$PREFIX.MESH_CONNECTED" +const val ACTION_CONNECTION_CHANGED = "$PREFIX.CONNECTION_CHANGED" const val ACTION_MESSAGE_STATUS = "$PREFIX.MESSAGE_STATUS" fun actionReceived(portNum: String) = "$PREFIX.RECEIVED.$portNum" diff --git a/app/src/main/java/com/geeksville/mesh/service/MeshConfigFlowManager.kt b/app/src/main/java/com/geeksville/mesh/service/MeshConfigFlowManager.kt index 9e4f2936e..2885fc3d9 100644 --- a/app/src/main/java/com/geeksville/mesh/service/MeshConfigFlowManager.kt +++ b/app/src/main/java/com/geeksville/mesh/service/MeshConfigFlowManager.kt @@ -85,6 +85,9 @@ constructor( } else { myNodeInfo = newMyNodeInfo Logger.i { "myNodeInfo committed successfully" } + connectionStateHolder.setState(ConnectionState.Connected) + serviceBroadcasts.broadcastConnection() + connectionManager.onRadioConfigLoaded() } scope.handledLaunch { @@ -120,9 +123,7 @@ constructor( } nodeManager.isNodeDbReady.value = true nodeManager.allowNodeDbWrites.value = true - connectionStateHolder.setState(ConnectionState.Connected) - serviceBroadcasts.broadcastConnection() - connectionManager.onHasSettings() + connectionManager.onNodeDbReady() } } diff --git a/app/src/main/java/com/geeksville/mesh/service/MeshConnectionManager.kt b/app/src/main/java/com/geeksville/mesh/service/MeshConnectionManager.kt index d5a6d017d..f64ec3a9f 100644 --- a/app/src/main/java/com/geeksville/mesh/service/MeshConnectionManager.kt +++ b/app/src/main/java/com/geeksville/mesh/service/MeshConnectionManager.kt @@ -199,9 +199,17 @@ constructor( packetHandler.sendToRadio(ToRadio(want_config_id = NODE_INFO_NONCE)) } - fun onHasSettings() { + fun onRadioConfigLoaded() { commandSender.processQueuedPackets() + val myNodeNum = nodeManager.myNodeNum ?: 0 + // Set time + commandSender.sendAdmin(myNodeNum) { + AdminMessage(set_time_only = (System.currentTimeMillis() / MILLISECONDS_IN_SECOND).toInt()) + } + } + + fun onNodeDbReady() { // Start MQTT if enabled scope.handledLaunch { val moduleConfig = radioConfigRepository.moduleConfigFlow.first() @@ -219,14 +227,10 @@ constructor( scope.handledLaunch { val moduleConfig = radioConfigRepository.moduleConfigFlow.first() moduleConfig.store_forward?.let { - historyManager.requestHistoryReplay("onHasSettings", myNodeNum, it, "Unknown") + historyManager.requestHistoryReplay("onNodeDbReady", myNodeNum, it, "Unknown") } } - // Set time - commandSender.sendAdmin(myNodeNum) { - AdminMessage(set_time_only = (System.currentTimeMillis() / MILLISECONDS_IN_SECOND).toInt()) - } updateStatusNotification() } diff --git a/app/src/main/java/com/geeksville/mesh/service/MeshServiceBroadcasts.kt b/app/src/main/java/com/geeksville/mesh/service/MeshServiceBroadcasts.kt index b1adf57d7..1cbdc3129 100644 --- a/app/src/main/java/com/geeksville/mesh/service/MeshServiceBroadcasts.kt +++ b/app/src/main/java/com/geeksville/mesh/service/MeshServiceBroadcasts.kt @@ -26,6 +26,7 @@ import org.meshtastic.core.model.MessageStatus import org.meshtastic.core.model.NodeInfo import org.meshtastic.core.model.util.toPIIString import org.meshtastic.core.service.ServiceRepository +import java.util.Locale import javax.inject.Inject import javax.inject.Singleton @@ -75,9 +76,22 @@ constructor( /** Broadcast our current connection status */ fun broadcastConnection() { val connectionState = connectionStateHolder.connectionState.value - val intent = Intent(ACTION_MESH_CONNECTED).putExtra(EXTRA_CONNECTED, connectionState.toString()) + // ATAK expects a String: "CONNECTED" or "DISCONNECTED" + // It uses equalsIgnoreCase, but we'll use uppercase to be specific. + val stateStr = connectionState.toString().uppercase(Locale.ROOT) + + val intent = Intent(ACTION_MESH_CONNECTED).apply { putExtra(EXTRA_CONNECTED, stateStr) } serviceRepository.setConnectionState(connectionState) explicitBroadcast(intent) + + // Restore legacy action for other consumers (e.g. mesh_service_example) + val legacyIntent = + Intent(ACTION_CONNECTION_CHANGED).apply { + putExtra(EXTRA_CONNECTED, stateStr) + // Legacy boolean extra often expected by older implementations + putExtra("connected", connectionState == org.meshtastic.core.service.ConnectionState.Connected) + } + explicitBroadcast(legacyIntent) } /** diff --git a/app/src/test/java/com/geeksville/mesh/service/MeshConnectionManagerTest.kt b/app/src/test/java/com/geeksville/mesh/service/MeshConnectionManagerTest.kt index 19c2d6d4d..7249600c6 100644 --- a/app/src/test/java/com/geeksville/mesh/service/MeshConnectionManagerTest.kt +++ b/app/src/test/java/com/geeksville/mesh/service/MeshConnectionManagerTest.kt @@ -41,6 +41,8 @@ import org.meshtastic.core.service.ConnectionState import org.meshtastic.core.service.MeshServiceNotifications import org.meshtastic.proto.Config import org.meshtastic.proto.LocalConfig +import org.meshtastic.proto.LocalModuleConfig +import org.meshtastic.proto.ModuleConfig import org.meshtastic.proto.ToRadio class MeshConnectionManagerTest { @@ -61,6 +63,7 @@ class MeshConnectionManagerTest { private val analytics: PlatformAnalytics = mockk(relaxed = true) private val radioConnectionState = MutableStateFlow(ConnectionState.Disconnected) private val localConfigFlow = MutableStateFlow(LocalConfig()) + private val moduleConfigFlow = MutableStateFlow(LocalModuleConfig()) private val testDispatcher = UnconfinedTestDispatcher() @@ -74,6 +77,7 @@ class MeshConnectionManagerTest { every { radioInterfaceService.connectionState } returns radioConnectionState every { radioConfigRepository.localConfigFlow } returns localConfigFlow + every { radioConfigRepository.moduleConfigFlow } returns moduleConfigFlow every { nodeRepository.myNodeInfo } returns MutableStateFlow(null) manager = @@ -176,4 +180,27 @@ class MeshConnectionManagerTest { connectionStateHolder.connectionState.value, ) } + + @Test + fun `onRadioConfigLoaded processes queued packets and sets time`() = runTest(testDispatcher) { + manager.onRadioConfigLoaded() + + verify { commandSender.processQueuedPackets() } + verify { commandSender.sendAdmin(any(), initFn = any()) } + } + + @Test + fun `onNodeDbReady starts MQTT and requests history`() = runTest(testDispatcher) { + val moduleConfig = mockk(relaxed = true) + every { moduleConfig.mqtt } returns ModuleConfig.MQTTConfig(enabled = true) + every { moduleConfig.store_forward } returns ModuleConfig.StoreForwardConfig(enabled = true) + moduleConfigFlow.value = moduleConfig + + manager.start(backgroundScope) + manager.onNodeDbReady() + advanceUntilIdle() + + verify { mqttManager.start(any(), true, any()) } + verify { historyManager.requestHistoryReplay("onNodeDbReady", any(), any(), "Unknown") } + } } diff --git a/app/src/test/java/com/geeksville/mesh/service/MeshServiceBroadcastsTest.kt b/app/src/test/java/com/geeksville/mesh/service/MeshServiceBroadcastsTest.kt new file mode 100644 index 000000000..6993cd450 --- /dev/null +++ b/app/src/test/java/com/geeksville/mesh/service/MeshServiceBroadcastsTest.kt @@ -0,0 +1,68 @@ +/* + * 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 . + */ +package com.geeksville.mesh.service + +import android.app.Application +import android.content.Context +import androidx.test.core.app.ApplicationProvider +import io.mockk.mockk +import org.junit.Assert.assertEquals +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.meshtastic.core.service.ConnectionState +import org.meshtastic.core.service.ServiceRepository +import org.robolectric.RobolectricTestRunner +import org.robolectric.Shadows.shadowOf + +@RunWith(RobolectricTestRunner::class) +class MeshServiceBroadcastsTest { + + private lateinit var context: Context + private val connectionStateHolder = ConnectionStateHandler() + private val serviceRepository: ServiceRepository = mockk(relaxed = true) + private lateinit var broadcasts: MeshServiceBroadcasts + + @Before + fun setUp() { + context = ApplicationProvider.getApplicationContext() + broadcasts = MeshServiceBroadcasts(context, connectionStateHolder, serviceRepository) + } + + @Test + fun `broadcastConnection sends uppercase state string for ATAK`() { + connectionStateHolder.setState(ConnectionState.Connected) + + broadcasts.broadcastConnection() + + val shadowApp = shadowOf(context as Application) + val intent = shadowApp.broadcastIntents.find { it.action == ACTION_MESH_CONNECTED } + assertEquals("CONNECTED", intent?.getStringExtra(EXTRA_CONNECTED)) + } + + @Test + fun `broadcastConnection sends legacy connection intent`() { + connectionStateHolder.setState(ConnectionState.Connected) + + broadcasts.broadcastConnection() + + val shadowApp = shadowOf(context as Application) + val intent = shadowApp.broadcastIntents.find { it.action == ACTION_CONNECTION_CHANGED } + assertEquals("CONNECTED", intent?.getStringExtra(EXTRA_CONNECTED)) + assertEquals(true, intent?.getBooleanExtra("connected", false)) + } +}